* feat: shell controller, TabStage, layout and theme plugins with repair view

* feat: move the classic layout into its own bundled plugin

* feat: move color themes into a bundled themes plugin

* fix: plugin tests and type checks work with a linked sdk

* fix: home tab, layout switch and missing layout fallbacks for looks

* chore: add classic layout and themes to the readme

* chore: drop example plugin links from the plugin guide

* feat: plugin filters by type, paged browse, hide plugins needing newer termix

* feat: move desktop, status, import, audit and branding features into plugins

* feat: host lock api so a plugin can load hosts from a config file (#1581)

* chore: add hosts config to the plugin table

* feat: login policy hook for plugins and optional https redirect (#1584)

* chore: add login security to the plugin table

* feat: api key socket tickets so a master can open remote sessions (#1654)

* feat: sync moves to a plugin, desktop gets multiple accounts (#1580, #1654)

* chore: add sync to the plugin table

* feat: move the sync screens and wizard out of core into the plugin (#1580)

* fix: add a neutral linked desktop hook for plugins (#1580)

* fix: show the linked account's name and admin flag in the app (#1580)

* feat: ssh transport providers, core ssh relay and plugin folders api

* feat: support FIDO2 security keys through ssh agent

* feat: add companion panels beside host tabs

* feat: mTLS client certificates for desktop server links

* feat: native web view for plugins in the desktop app

* fix: detect the desktop link window by Electron's default user agent

Fixes #1675

* fix: changelog for desktop link fix

* fix: keep text primary keys in the plugin data export

* fix: ship sharp binaries for Windows arm64 builds

* feat: client cert on the sync link types (sdk 1.0.18)

* chore: update translations

* feat: themed title bar for the desktop app (#1690)

* chore: changelog for the themed title bar

* fix: draw the title bar in the sidebar color

* feat: per host layout for the terminal panel bar

* chore: sdk 1.0.19 for the panel bar layout

* chore: bundle all 43 plugins for 26.10.3

* chore: update translations

---------

Co-authored-by: SirHephaistos <60616553+SirHephaistos@users.noreply.github.com>
Co-authored-by: Giovanny Aranda <garanda21@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-10-20 16:03:29 -05:00
1 parent aff29a9581
commit a89ccc6b07
577 files changed
+76479 -101831

No files matched your search

+74 -17
View File
@@ -26,6 +26,12 @@ import {
isSecondFactorAfterExternalLoginEnabled,
} from "./core-auth.js";
import { findOrProvisionExternalUser } from "./provisioning.js";
import {
SECOND_FACTOR_SETUP,
evaluateAuthPolicy,
hasAuthPolicies,
rememberDeviceAllowed,
} from "./policy.js";
import {
getLoginMethod,
getSecondFactor,
@@ -122,12 +128,15 @@ export async function evaluateSecondFactors(
* governs external methods (SSO, LDAP) alone.
*/
function shouldRunSecondFactors(methodId: string): boolean {
ensureCoreLoginProviders();
const method = getLoginMethod(methodId);
if (!method?.external) return true;
if (!isExternalMethod(methodId)) return true;
return isSecondFactorAfterExternalLoginEnabled();
}
function isExternalMethod(methodId: string): boolean {
ensureCoreLoginProviders();
return !!getLoginMethod(methodId)?.external;
}
export const TRUST_DEVICE_COOKIE = "termix_trust_device";
const TRUST_TOKEN_PATTERN = /^[a-f0-9]{64}$/;
const TRUST_TOKEN_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
@@ -294,9 +303,40 @@ export async function runLogin(
`${context.methodId}_role_shared_credentials`,
);
const factors = shouldRunSecondFactors(context.methodId)
? await evaluateSecondFactors(user.id)
: { required: [], blocked: [] };
// A login policy may want a factor where core would skip it, so it always
// sees the full picture.
const runFactors = shouldRunSecondFactors(context.methodId);
const factors =
runFactors || hasAuthPolicies()
? await evaluateSecondFactors(user.id)
: { required: [], blocked: [] };
const trusted =
factors.required.length > 0 || hasAuthPolicies()
? await isTrustedDevice(req, user.id)
: false;
const policy = await evaluateAuthPolicy({
userId: user.id,
methodId: context.methodId,
external: isExternalMethod(context.methodId),
deviceType,
trustedDevice: trusted,
mfaSatisfied: !!identity.mfaSatisfied,
secondFactors: listSecondFactors().map((factor) => ({
id: factor.id,
pluginId: factor.pluginId,
enrolled: factors.required.some(
(required) =>
required.id === factor.id && required.pluginId === factor.pluginId,
),
})),
});
if (!runFactors && !policy.secondFactor) {
factors.required = [];
factors.blocked = [];
}
const rememberMe = context.rememberMe && policy.allowRememberDevice;
const skipByDevice =
trusted && policy.allowRememberDevice && policy.secondFactor !== "always";
if (factors.blocked.length > 0) {
authLogger.warn("Login refused: an enrolled second factor is unavailable", {
operation: "second_factor_unavailable",
@@ -324,11 +364,7 @@ export async function runLogin(
const ssoClaims = { externalSession: identity.externalSession ?? null };
if (
factors.required.length > 0 &&
!identity.mfaSatisfied &&
!(await isTrustedDevice(req, user.id))
) {
if (factors.required.length > 0 && !identity.mfaSatisfied && !skipByDevice) {
const tempToken = await AuthManager.getInstance().generateJWTToken(
user.id,
{ pendingTOTP: true, expiresIn: "10m" },
@@ -337,14 +373,17 @@ export async function runLogin(
pendingLogins.set(pendingKey(tempToken), {
userId: user.id,
methodId: context.methodId,
rememberMe: context.rememberMe,
rememberMe,
allowRememberDevice: policy.allowRememberDevice,
maxSessionSeconds: policy.maxSessionSeconds,
idleTimeoutSeconds: policy.idleTimeoutSeconds,
...ssoClaims,
createdAt: Date.now(),
});
return {
kind: "second-factor",
tempToken,
rememberMe: context.rememberMe,
rememberMe,
factors: factors.required.map((factor) => ({
id: factor.id,
pluginId: factor.pluginId,
@@ -353,11 +392,22 @@ export async function runLogin(
};
}
// Required but nothing set up yet: a session that can only set one up.
const needsSetup =
!!policy.secondFactor &&
factors.required.length === 0 &&
!identity.mfaSatisfied &&
!skipByDevice;
const session = await issueSession(req, user, {
methodId: context.methodId,
rememberMe: context.rememberMe,
rememberMe,
rateLimitUsername: context.rateLimitUsername ?? identity.rateLimitUsername,
longLivedForApps: identity.kind === "external",
longLivedForApps:
identity.kind === "external" && policy.allowRememberDevice,
maxAgeSeconds: policy.maxSessionSeconds,
idleTimeoutSeconds: policy.idleTimeoutSeconds,
restriction: needsSetup ? SECOND_FACTOR_SETUP : null,
...ssoClaims,
});
return { kind: "session", ...session };
@@ -510,10 +560,15 @@ export async function verifySecondFactorAndRespond(
}
loginRateLimiter.resetTOTPAttempts(user.id);
// A pending entry lost to a restart falls back to the current policy.
const allowRemember = lookup.pending
? lookup.pending.allowRememberDevice !== false
: rememberDeviceAllowed();
const rememberMe =
typeof req.body?.rememberMe === "boolean"
allowRemember &&
(typeof req.body?.rememberMe === "boolean"
? req.body.rememberMe
: (lookup.pending?.rememberMe ?? false);
: (lookup.pending?.rememberMe ?? false));
let trustToken: string | null = null;
if (rememberMe) {
@@ -545,6 +600,8 @@ export async function verifySecondFactorAndRespond(
methodId: pending?.methodId ?? "password",
rememberMe,
externalSession: pending?.externalSession ?? null,
maxAgeSeconds: pending?.maxSessionSeconds ?? null,
idleTimeoutSeconds: pending?.idleTimeoutSeconds ?? null,
});
if (trustToken && isNativeAppRequest(req)) {
+125
View File
@@ -0,0 +1,125 @@
/**
* Login policy from plugins through ctx.auth.policy.provide: idle timeout,
* session length, remember me and a required second factor. Core asks every
* provider and the strictest answer wins. A provider that throws at login
* refuses the login, since skipping a security rule is worse than a failed
* sign in. The idle timeout is stored on the session at login. Desktop
* loopback, sync link and plugin minted sessions are device credentials, not
* logins, so the policy never reaches them.
*/
import type {
PluginAuthPolicyDecision,
PluginAuthPolicyInput,
PluginAuthPolicyProvider,
} from "@termix-ssh/plugin-sdk/backend";
import { authLogger } from "../utils/logger.js";
import { LoginMethodError } from "./types.js";
/** sessions.restriction of a session that may only set up a second factor. */
export const SECOND_FACTOR_SETUP = "second-factor-setup";
interface Entry {
pluginId: string;
provider: PluginAuthPolicyProvider;
}
const providers = new Map<symbol, Entry>();
export function provideAuthPolicy(
pluginId: string,
provider: PluginAuthPolicyProvider,
): () => void {
const key = Symbol(pluginId);
providers.set(key, { pluginId, provider });
return () => {
providers.delete(key);
};
}
/** Test seam. */
export function clearAuthPolicies(): void {
providers.clear();
}
export function hasAuthPolicies(): boolean {
return providers.size > 0;
}
export interface AuthPolicy {
allowRememberDevice: boolean;
maxSessionSeconds: number | null;
idleTimeoutSeconds: number | null;
secondFactor: "always" | "new-device" | null;
}
const NO_POLICY: AuthPolicy = {
allowRememberDevice: true,
maxSessionSeconds: null,
idleTimeoutSeconds: null,
secondFactor: null,
};
function shortest(current: number | null, next: unknown): number | null {
if (typeof next !== "number" || !(next > 0)) return current;
const value = Math.floor(next);
return current === null ? value : Math.min(current, value);
}
function merge(policy: AuthPolicy, next: PluginAuthPolicyDecision): AuthPolicy {
let secondFactor = policy.secondFactor;
if (next.secondFactor === "always") secondFactor = "always";
else if (next.secondFactor === "new-device" && secondFactor !== "always") {
secondFactor = "new-device";
}
return {
allowRememberDevice:
policy.allowRememberDevice && next.allowRememberDevice !== false,
maxSessionSeconds: shortest(
policy.maxSessionSeconds,
next.maxSessionSeconds,
),
idleTimeoutSeconds: shortest(
policy.idleTimeoutSeconds,
next.idleTimeoutSeconds,
),
secondFactor,
};
}
export async function evaluateAuthPolicy(
input: PluginAuthPolicyInput,
): Promise<AuthPolicy> {
let policy = NO_POLICY;
for (const entry of providers.values()) {
let decision: PluginAuthPolicyDecision;
try {
decision = (await entry.provider.evaluate(input)) ?? {};
} catch (error) {
authLogger.warn("Login policy failed, refusing the login", {
operation: "auth_policy",
pluginId: entry.pluginId,
error: error instanceof Error ? error.message : String(error),
});
throw new LoginMethodError(
"Sign in is not available right now. Contact an admin.",
503,
"auth_policy_unavailable",
);
}
policy = merge(policy, decision);
}
return policy;
}
/** Whether the login page should offer remember me. */
export function rememberDeviceAllowed(): boolean {
for (const entry of providers.values()) {
try {
if (entry.provider.allowRememberDevice?.() === false) return false;
} catch {
return false;
}
}
return true;
}
+13 -1
View File
@@ -68,6 +68,11 @@ export interface IssueSessionOptions {
externalSession?: ExternalSessionRef | null;
/** SSO logins keep desktop and mobile apps signed in for 30 days. */
longLivedForApps?: boolean;
/** Login policy cap on the session length. */
maxAgeSeconds?: number | null;
/** See SECOND_FACTOR_SETUP. */
restriction?: string | null;
idleTimeoutSeconds?: number | null;
}
export interface IssuedSession {
@@ -93,6 +98,9 @@ export async function issueSession(
deviceType: deviceInfo.type,
deviceInfo: deviceInfo.deviceInfo,
externalSession: options.externalSession ?? null,
maxAgeSeconds: options.maxAgeSeconds ?? null,
restriction: options.restriction ?? null,
idleTimeoutSeconds: options.idleTimeoutSeconds ?? null,
});
if (options.rateLimitUsername) {
@@ -123,11 +131,14 @@ export async function issueSession(
method: options.methodId,
});
const maxAge =
let maxAge =
options.longLivedForApps &&
(deviceInfo.type === "desktop" || deviceInfo.type === "mobile")
? THIRTY_DAYS_MS
: await sessionCookieMaxAge(options.rememberMe);
if (options.maxAgeSeconds) {
maxAge = Math.min(maxAge, options.maxAgeSeconds * 1000);
}
return {
token,
@@ -142,6 +153,7 @@ export async function issueSession(
totp_enabled: await createCurrentUserAuthRepository().hasSecondFactor(
user.id,
),
...(options.restriction ? { restriction: options.restriction } : {}),
...(isNativeAppRequest(req) ? { token } : {}),
},
};
+4
View File
@@ -35,5 +35,9 @@ export interface PendingLogin {
methodId: string;
rememberMe: boolean;
externalSession?: ExternalSessionRef | null;
/** Login policy that applied when the login paused. */
allowRememberDevice?: boolean;
maxSessionSeconds?: number | null;
idleTimeoutSeconds?: number | null;
createdAt: number;
}
+8 -4
View File
@@ -22,12 +22,12 @@ import sshAuthRoutes from "./routes/ssh-auth-routes.js";
import rbacRoutes from "./routes/rbac.js";
import openTabsRoutes from "./routes/open-tabs.js";
import userPreferencesRoutes from "./routes/user-preferences.js";
import lookRoutes from "./routes/look.js";
import hostSidebarPreferencesRoutes from "./routes/host-sidebar-preferences.js";
import credentialSidebarPreferencesRoutes from "./routes/credential-sidebar-preferences.js";
import uiPreferencesRoutes from "./routes/ui-preferences.js";
import { registerAuditLogRoutes } from "./routes/audit-log-routes.js";
import syncRoutes from "../sync/server/routes.js";
import syncLinkRoutes from "../sync/client/routes.js";
import { syncForwarder } from "../sync/provider.js";
import { syncChangeWatcher } from "../sync/server/change-watcher.js";
import dashboardRoutes from "./routes/dashboard-routes.js";
import {
@@ -35,6 +35,7 @@ import {
mountPluginLegacyPaths,
} from "./routes/plugin-api-routes.js";
import { attachPluginWebSockets } from "../plugins/ws.js";
import { registerSshRelay } from "../hosts/ssh-relay.js";
import pluginRoutes from "./routes/plugins.js";
import { createPluginAssetsRouter } from "../plugins/assets.js";
import { getPluginRuntime } from "../plugins/index.js";
@@ -72,6 +73,7 @@ import {
} from "../plugins/user-data.js";
import { configureDirectHttps, getTlsConfig } from "../tls/tls-service.js";
import { acmeChallengeHandler } from "../tls/acme-challenges.js";
import { httpsPolicyMiddleware } from "../tls/https-policy.js";
import {
createCurrentCredentialRepository,
createCurrentHostRepository,
@@ -104,6 +106,7 @@ const __dirname = path.dirname(__filename);
const app = express();
app.set("trust proxy", "loopback");
app.use(httpsPolicyMiddleware);
const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
@@ -1616,13 +1619,13 @@ app.use("/credentials", credentialsRoutes);
app.use("/ssh-auth", sshAuthRoutes);
app.use("/rbac", rbacRoutes);
app.use("/open-tabs", openTabsRoutes);
app.use("/user-preferences/look", lookRoutes);
app.use("/user-preferences", userPreferencesRoutes);
app.use("/host-sidebar/preferences", hostSidebarPreferencesRoutes);
app.use("/credential-sidebar/preferences", credentialSidebarPreferencesRoutes);
app.use("/ui-preferences", uiPreferencesRoutes);
registerAuditLogRoutes(app, authenticateJWT);
app.use("/sync", syncRoutes);
app.use("/sync", syncLinkRoutes);
app.use("/sync", syncForwarder);
app.use("/dashboard", dashboardRoutes);
app.use("/plugins", pluginRoutes);
app.use(
@@ -1896,6 +1899,7 @@ const httpServer = http.createServer(app);
// Plugin sockets ride this server at /plugin-ws/<id>/<path>. Anything else is
// left alone, so core's own upgrade handling is unaffected.
attachPluginWebSockets(httpServer);
registerSshRelay();
httpServer.on("error", (err: NodeJS.ErrnoException) => {
if (err.code === "EADDRINUSE") {
+4 -11
View File
@@ -370,17 +370,6 @@ async function initializeCompleteDatabase(): Promise<void> {
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS recent_activity (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
type TEXT NOT NULL,
host_id INTEGER NOT NULL,
host_name TEXT,
timestamp TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS host_access (
id INTEGER PRIMARY KEY AUTOINCREMENT,
host_id INTEGER NOT NULL,
@@ -985,6 +974,8 @@ const migrateSchema = () => {
addColumnIfNotExists("user_preferences", "compact_host_view", "INTEGER");
addColumnIfNotExists("user_preferences", "status_color_scheme", "TEXT");
addColumnIfNotExists("user_preferences", "custom_keybindings", "TEXT");
addColumnIfNotExists("user_preferences", "layout_id", "TEXT");
addColumnIfNotExists("user_preferences", "theme_id", "TEXT");
addColumnIfNotExists("users", "is_admin", "INTEGER NOT NULL DEFAULT 0");
@@ -1030,6 +1021,8 @@ const migrateSchema = () => {
}
enforceUniqueUsernames();
addColumnIfNotExists("sessions", "external_session_ref", "TEXT");
addColumnIfNotExists("sessions", "restriction", "TEXT");
addColumnIfNotExists("sessions", "idle_timeout", "INTEGER");
addColumnIfNotExists("ssh_data", "name", "TEXT");
addColumnIfNotExists("ssh_data", "folder", "TEXT");
@@ -98,12 +98,7 @@ export const PERFORMANCE_INDEXES: PerformanceIndex[] = [
columns: "user_id",
},
// Per-user history and file manager surfaces.
{
name: "idx_recent_activity_user_ts",
table: "recent_activity",
columns: "user_id, timestamp",
},
// Per-user open tabs.
{
name: "idx_user_open_tabs_user_id",
table: "user_open_tabs",
+6 -22
View File
@@ -59,6 +59,10 @@ export const sessions = mysqlTable(
lastActiveAt: text("last_active_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
// Set while the session may only set up a second factor.
restriction: text("restriction"),
// Seconds without a request before the session ends, from the login policy.
idleTimeout: int("idle_timeout"),
},
// Listing a user's devices, and the startup sweep of expired rows.
(table) => [
@@ -414,28 +418,6 @@ export const hostDefaults = mysqlTable(
],
);
export const recentActivity = mysqlTable(
"recent_activity",
{
id: int("id").autoincrement().primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
type: text("type").notNull(),
hostId: int("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
hostName: text("host_name"),
timestamp: varchar("timestamp", { length: 255 })
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
},
// Always read newest-first for one user, so timestamp follows user_id.
(table) => [
index("idx_recent_activity_user_ts").on(table.userId, table.timestamp),
],
);
export const hostAccess = mysqlTable(
"host_access",
{
@@ -709,6 +691,8 @@ export const userPreferences = mysqlTable("user_preferences", {
compactHostView: boolean("compact_host_view"),
statusColorScheme: text("status_color_scheme"),
customKeybindings: text("custom_keybindings"),
layoutId: text("layout_id"),
themeId: text("theme_id"),
updatedAt: text("updated_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
+6 -22
View File
@@ -59,6 +59,10 @@ export const sessions = pgTable(
lastActiveAt: text("last_active_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
// Set while the session may only set up a second factor.
restriction: text("restriction"),
// Seconds without a request before the session ends, from the login policy.
idleTimeout: integer("idle_timeout"),
},
// Listing a user's devices, and the startup sweep of expired rows.
(table) => [
@@ -414,28 +418,6 @@ export const hostDefaults = pgTable(
],
);
export const recentActivity = pgTable(
"recent_activity",
{
id: serial("id").primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
type: text("type").notNull(),
hostId: integer("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
hostName: text("host_name"),
timestamp: varchar("timestamp", { length: 255 })
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
// Always read newest-first for one user, so timestamp follows user_id.
(table) => [
index("idx_recent_activity_user_ts").on(table.userId, table.timestamp),
],
);
export const hostAccess = pgTable(
"host_access",
{
@@ -709,6 +691,8 @@ export const userPreferences = pgTable("user_preferences", {
compactHostView: boolean("compact_host_view"),
statusColorScheme: text("status_color_scheme"),
customKeybindings: text("custom_keybindings"),
layoutId: text("layout_id"),
themeId: text("theme_id"),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
+6 -22
View File
@@ -47,6 +47,10 @@ export const sessions = sqliteTable(
lastActiveAt: text("last_active_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
// Set while the session may only set up a second factor.
restriction: text("restriction", { length: 32 }),
// Seconds without a request before the session ends, from the login policy.
idleTimeout: integer("idle_timeout"),
},
// Listing a user's devices, and the startup sweep of expired rows.
(table) => [
@@ -404,28 +408,6 @@ export const hostDefaults = sqliteTable(
],
);
export const recentActivity = sqliteTable(
"recent_activity",
{
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
type: text("type").notNull(),
hostId: integer("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
hostName: text("host_name"),
timestamp: text("timestamp")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
// Always read newest-first for one user, so timestamp follows user_id.
(table) => [
index("idx_recent_activity_user_ts").on(table.userId, table.timestamp),
],
);
export const hostAccess = sqliteTable(
"host_access",
{
@@ -703,6 +685,8 @@ export const userPreferences = sqliteTable("user_preferences", {
compactHostView: integer("compact_host_view", { mode: "boolean" }),
statusColorScheme: text("status_color_scheme"),
customKeybindings: text("custom_keybindings"),
layoutId: text("layout_id"),
themeId: text("theme_id"),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
@@ -23,7 +23,6 @@ import { PluginPermissionGrantRepository } from "./plugin-permission-grant-repos
import { UserAuthRepository } from "./user-auth-repository.js";
import { RbacAccessRepository } from "./rbac-access-repository.js";
import { RbacPermissionRepository } from "./rbac-permission-repository.js";
import { RecentActivityRepository } from "./recent-activity-repository.js";
import { RoleRepository } from "./role-repository.js";
import { SessionRepository } from "./session-repository.js";
import { CredentialAccessRepository } from "./credential-access-repository.js";
@@ -269,13 +268,6 @@ export function createCurrentRbacAccessRepository(): RbacAccessRepository {
);
}
export function createCurrentRecentActivityRepository(): RecentActivityRepository {
return new RecentActivityRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("recent_activity_repository_write"),
);
}
export function createCurrentRoleRepository(): RoleRepository {
return new RoleRepository(
createCurrentRepositoryContext(),
@@ -236,6 +236,29 @@ export class HostFolderRepository {
.where(and(eq(hosts.userId, userId), folderMatch(hosts.folder)));
}
/** Drops the folder rows (and subfolders) only when no host is left in them. */
async deleteEmptyFolderRecords(
userId: string,
folderName: string,
): Promise<boolean> {
if ((await this.listHostsInFolder(userId, folderName)).length > 0) {
return false;
}
await deleteReturning(
this.context,
sshFolders,
and(
eq(sshFolders.userId, userId),
or(
eq(sshFolders.name, folderName),
like(sshFolders.name, `${folderName} / %`),
),
),
);
await this.afterWrite();
return true;
}
async deleteHostsAndFolderRecords(
userId: string,
folderName: string,
@@ -1,107 +0,0 @@
import { desc, eq, inArray } from "drizzle-orm";
import { recentActivity } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import { insertReturning } from "./returning.js";
export type RecentActivityRecord = typeof recentActivity.$inferSelect;
export type NewRecentActivityRecord = typeof recentActivity.$inferInsert;
export class RecentActivityRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async listByUserId(
userId: string,
limit: number,
): Promise<RecentActivityRecord[]> {
return this.context.drizzle
.select()
.from(recentActivity)
.where(eq(recentActivity.userId, userId))
.orderBy(desc(recentActivity.timestamp))
.limit(limit);
}
async create(
activity: NewRecentActivityRecord,
): Promise<RecentActivityRecord> {
const rows = await insertReturning(this.context, recentActivity, activity);
await this.afterWrite();
return rows[0];
}
async trimUserActivity(userId: string, keepCount: number): Promise<number> {
const rows = await this.context.drizzle
.select({ id: recentActivity.id })
.from(recentActivity)
.where(eq(recentActivity.userId, userId))
.orderBy(desc(recentActivity.timestamp));
const idsToDelete = rows
.slice(keepCount)
.map((row) => row.id)
.filter((id) => typeof id === "number");
if (idsToDelete.length === 0) {
return 0;
}
const result = await this.context.drizzle
.delete(recentActivity)
.where(inArray(recentActivity.id, idsToDelete));
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result);
}
async deleteByUserId(userId: string): Promise<number> {
const result = await this.context.drizzle
.delete(recentActivity)
.where(eq(recentActivity.userId, userId));
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result);
}
async deleteByHostId(hostId: number): Promise<number> {
const result = await this.context.drizzle
.delete(recentActivity)
.where(eq(recentActivity.hostId, hostId));
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result);
}
async deleteByHostIds(hostIds: number[]): Promise<number> {
if (hostIds.length === 0) {
return 0;
}
const result = await this.context.drizzle
.delete(recentActivity)
.where(inArray(recentActivity.hostId, hostIds));
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result);
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}
@@ -69,6 +69,14 @@ export class SessionRepository {
return true;
}
async clearRestrictionForUser(userId: string): Promise<void> {
await this.context.drizzle
.update(sessions)
.set({ restriction: null })
.where(eq(sessions.userId, userId));
await this.afterWrite();
}
async updateToken(
id: string,
jwtToken: string,
@@ -5,12 +5,6 @@ import {
createCurrentUserRepository,
} from "../repositories/factory.js";
import { apiLogger } from "../../utils/logger.js";
import { exportFilename, toCsv, toNdjson } from "../../utils/audit-export.js";
import {
logAudit,
getAuditUsername,
getRequestMeta,
} from "../../utils/audit-logger.js";
async function isAdminUser(userId: string | undefined): Promise<boolean> {
if (!userId) return false;
@@ -138,125 +132,4 @@ export function registerAuditLogRoutes(
.json({ error: "Failed to fetch audit log actions" });
}
});
/**
* @openapi
* /audit-logs/export:
* get:
* summary: Export audit logs
* description: Streams the full filtered result set as CSV or NDJSON. Accepts the same filters as GET /audit-logs. Admin only. The export is itself audited.
* tags:
* - Audit
* parameters:
* - in: query
* name: format
* schema: { type: string, enum: [csv, ndjson], default: csv }
* - in: query
* name: userId
* schema: { type: string }
* - in: query
* name: action
* schema: { type: string }
* - in: query
* name: resourceType
* schema: { type: string }
* - in: query
* name: success
* schema: { type: string, enum: [true, false] }
* - in: query
* name: startDate
* schema: { type: string, format: date-time }
* - in: query
* name: endDate
* schema: { type: string, format: date-time }
* responses:
* 200:
* description: Audit log file.
* 403:
* description: Not authorized.
* 500:
* description: Failed to export audit logs.
*/
router.get("/audit-logs/export", authenticateJWT, async (req, res) => {
const authReq = req as AuthenticatedRequest;
try {
if (!(await isAdminUser(authReq.userId))) {
return res.status(403).json({ error: "Not authorized" });
}
const format = req.query.format === "ndjson" ? "ndjson" : "csv";
const { userId, action, resourceType, success, startDate, endDate } =
req.query as Record<string, string | undefined>;
const filters = {
userId,
action,
resourceType,
success:
success !== undefined && success !== ""
? success === "true"
: undefined,
startDate,
endDate,
};
res.setHeader(
"Content-Type",
format === "csv" ? "text/csv; charset=utf-8" : "application/x-ndjson",
);
res.setHeader(
"Content-Disposition",
`attachment; filename="${exportFilename(format, new Date())}"`,
);
// Streamed in batches: an export is unbounded by definition, and the
// whole point is to move data out before retention drops it.
const BATCH = 500;
let offset = 0;
let exported = 0;
for (;;) {
const rows = await createCurrentAuditLogRepository().listForExport({
filters,
limit: BATCH,
offset,
});
if (rows.length === 0) break;
if (format === "csv") {
// Header only on the first batch.
const chunk = toCsv(rows);
res.write(
offset === 0 ? chunk : chunk.slice(chunk.indexOf("\n") + 1),
);
} else {
res.write(toNdjson(rows));
}
exported += rows.length;
offset += rows.length;
if (rows.length < BATCH) break;
}
res.end();
// Reading the whole trail is itself worth recording.
const { ipAddress, userAgent } = getRequestMeta(req);
void logAudit({
userId: authReq.userId!,
username: await getAuditUsername(authReq.userId!),
action: "export_audit_logs",
resourceType: "audit_log",
details: JSON.stringify({ format, exported, filters }),
ipAddress,
userAgent,
success: true,
});
} catch (err) {
apiLogger.error("Failed to export audit logs", err);
if (!res.headersSent) {
return res.status(500).json({ error: "Failed to export audit logs" });
}
res.end();
}
});
}
+6 -2
View File
@@ -25,6 +25,7 @@ import {
} from "../../auth/registry.js";
import { isTrustedProxyAuthEnabled } from "../../utils/trusted-proxy-auth.js";
import { LoginMethodError, type VerifiedIdentity } from "../../auth/types.js";
import { rememberDeviceAllowed } from "../../auth/policy.js";
export interface PublicLoginMethod {
id: string;
@@ -243,7 +244,7 @@ export function registerAuthRoutes(router: Router): void {
* /users/auth/methods:
* get:
* summary: List login methods
* description: Public. The login methods with at least one enabled instance, for the login screen. Carries ids and labels only.
* description: Public. The login methods with at least one enabled instance, for the login screen. Carries ids and labels only, plus rememberDevice, false when the login policy turned remember me off.
* tags:
* - Auth
* responses:
@@ -252,7 +253,10 @@ export function registerAuthRoutes(router: Router): void {
*/
router.get("/auth/methods", async (_req, res) => {
try {
res.json({ methods: await listPublicLoginMethods() });
res.json({
methods: await listPublicLoginMethods(),
rememberDevice: rememberDeviceAllowed(),
});
} catch (error) {
authLogger.error("Failed to list login methods", error);
res.status(500).json({ error: "Failed to list login methods" });
+3 -136
View File
@@ -1,98 +1,9 @@
import type { Request, RequestHandler, Response, Router } from "express";
import type { Request, Response, Router } from "express";
import { databaseLogger } from "../../utils/logger.js";
import { createCurrentSettingsRepository } from "../repositories/factory.js";
import {
BRANDING_KEYS,
parseBrandingAppName,
parseBrandingLogoDataUrl,
parseBrandingTagline,
resolveBrandingSettings,
} from "./branding-settings.js";
import { resolveBrandingSettings } from "./branding-settings.js";
const PATCHABLE_FIELDS = ["appName", "tagline", "logo"] as const;
type PatchableField = (typeof PATCHABLE_FIELDS)[number];
function invalidField(res: Response, field: string): void {
res.status(400).json({
error: `Invalid value for ${field}`,
code: "BRANDING_SETTINGS_INVALID",
field,
});
}
/**
* Validates a PATCH body and returns the settings-table writes/deletes it
* implies, or null after the response has already been failed with a 400.
* `logo: null` clears the custom logo (falls back to the bundled default).
*/
async function buildBrandingWrites(
body: unknown,
res: Response,
): Promise<{
writes: Array<{ key: string; value: string }>;
deletes: string[];
} | null> {
if (typeof body !== "object" || body === null || Array.isArray(body)) {
res.status(400).json({
error: "Invalid request body",
code: "BRANDING_SETTINGS_INVALID",
});
return null;
}
for (const field of Object.keys(body)) {
if (!(PATCHABLE_FIELDS as readonly string[]).includes(field)) {
res.status(400).json({
error: `Unknown setting field: ${field}`,
code: "BRANDING_SETTINGS_UNKNOWN_FIELD",
field,
});
return null;
}
}
const input = body as Partial<Record<PatchableField, unknown>>;
const writes: Array<{ key: string; value: string }> = [];
const deletes: string[] = [];
if (input.appName !== undefined) {
const appName = parseBrandingAppName(input.appName);
if (appName === null) {
invalidField(res, "appName");
return null;
}
writes.push({ key: BRANDING_KEYS.appName, value: appName });
}
if (input.tagline !== undefined) {
const tagline = parseBrandingTagline(input.tagline);
if (tagline === null) {
invalidField(res, "tagline");
return null;
}
writes.push({ key: BRANDING_KEYS.tagline, value: tagline });
}
if (input.logo !== undefined) {
if (input.logo === null) {
deletes.push(BRANDING_KEYS.logo);
} else {
const logo = await parseBrandingLogoDataUrl(input.logo);
if (logo === null) {
invalidField(res, "logo");
return null;
}
writes.push({ key: BRANDING_KEYS.logo, value: logo });
}
}
return { writes, deletes };
}
export function registerBrandingRoutes(
router: Router,
requireAdmin: RequestHandler,
): void {
export function registerBrandingRoutes(router: Router): void {
/**
* @openapi
* /users/branding:
@@ -118,48 +29,4 @@ export function registerBrandingRoutes(
res.status(500).json({ error: "Failed to load branding settings" });
}
});
/**
* @openapi
* /users/branding:
* patch:
* summary: Update white-label branding (admin only)
* description: Persists a partial update to appName, tagline and/or logo (a PNG/JPEG/WEBP data URL; pass null to clear the logo). Invalid values are rejected with a 400.
* tags:
* - Users
* responses:
* 200:
* description: Updated effective branding settings.
* 400:
* description: Invalid or unknown setting field.
* 401:
* description: Not authenticated.
* 403:
* description: Admin access required.
* 500:
* description: Failed to save branding settings.
*/
router.patch(
"/branding",
requireAdmin,
async (req: Request, res: Response) => {
const result = await buildBrandingWrites(req.body, res);
if (result === null) return;
try {
const settings = createCurrentSettingsRepository();
if (result.writes.length > 0) {
await settings.setMany(result.writes);
}
for (const key of result.deletes) {
await settings.delete(key);
}
const resolved = await resolveBrandingSettings(settings);
res.json(resolved);
} catch (err) {
databaseLogger.error("Failed to save branding settings", err);
res.status(500).json({ error: "Failed to save branding settings" });
}
},
);
}
@@ -1,5 +1,4 @@
import sharp from "sharp";
import { databaseLogger } from "../../utils/logger.js";
import { consumeByName } from "../../plugins/registry.js";
/**
* White-label branding: admin-configurable app name, logo and tagline shown
@@ -20,25 +19,33 @@ export const BRANDING_KEYS = {
} as const;
export const BRANDING_DEFAULT_APP_NAME = "Termix";
const APP_NAME_MAX_LENGTH = 60;
const TAGLINE_MAX_LENGTH = 160;
/**
* Cap applies to the decoded image bytes, not the base64 text. The settings
* table is fully cached in memory per backend process (see
* settings-cache.ts), so an oversized logo would be permanently resident RAM
* on every instance, not just a one-time upload cost.
*/
export const BRANDING_LOGO_MAX_BYTES = 750 * 1024;
const BRANDING_LOGO_MAX_INPUT_PIXELS = 20_000_000;
interface SettingSource {
get(key: string): Promise<string | null>;
}
export const BRANDING_PROVIDER_NAME = "brandingProvider";
/** A plugin supplies branding by providing "<its id>.brandingProvider". */
export type BrandingProvider = () =>
Promise<BrandingSettings> | BrandingSettings;
export async function resolveBrandingSettings(
settings: SettingSource,
): Promise<BrandingSettings> {
const provider = consumeByName<BrandingProvider>(BRANDING_PROVIDER_NAME);
if (provider) {
try {
const provided = await provider();
return {
appName: provided.appName?.trim() || BRANDING_DEFAULT_APP_NAME,
tagline: provided.tagline ?? "",
logo: provided.logo || null,
};
} catch {
// A broken provider falls back to the stored settings.
}
}
const [appName, tagline, logo] = await Promise.all([
settings.get(BRANDING_KEYS.appName),
settings.get(BRANDING_KEYS.tagline),
@@ -53,74 +60,3 @@ export async function resolveBrandingSettings(
}
/** Trimmed, 1-60 chars. Falls back to the default when cleared. */
export function parseBrandingAppName(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
if (trimmed.length === 0) return BRANDING_DEFAULT_APP_NAME;
if (trimmed.length > APP_NAME_MAX_LENGTH) return null;
return trimmed;
}
/** Trimmed, 0-160 chars. An empty string means "use the built-in default". */
export function parseBrandingTagline(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
if (trimmed.length > TAGLINE_MAX_LENGTH) return null;
return trimmed;
}
/**
* Validates and re-encodes an uploaded logo. Only raster formats are
* accepted (no SVG), sidestepping SVG-script XSS entirely rather than trying
* to sanitize markup. The image is always re-encoded to PNG through sharp:
* this both proves the payload is a genuine image (not a disguised file
* behind a spoofed data-URL prefix) and strips any embedded metadata.
*
* Returns null on any validation failure; the caller is expected to reject
* the request with a 400 without echoing back details of what was rejected.
*/
export async function parseBrandingLogoDataUrl(
value: unknown,
): Promise<string | null> {
if (typeof value !== "string") return null;
const match = value.match(
/^data:image\/(png|jpe?g|webp);base64,([a-zA-Z0-9+/]+=*)$/,
);
if (!match) return null;
const [, , base64] = match;
let buffer: Buffer;
try {
buffer = Buffer.from(base64, "base64");
} catch {
return null;
}
if (buffer.length === 0 || buffer.length > BRANDING_LOGO_MAX_BYTES * 4) {
// Reject wildly oversized input before decoding it with sharp; the final
// size check happens below on the re-encoded PNG.
return null;
}
try {
const source = sharp(buffer, {
failOn: "error",
limitInputPixels: BRANDING_LOGO_MAX_INPUT_PIXELS,
});
const metadata = await source.metadata();
if (
!metadata.format ||
!["png", "jpeg", "webp"].includes(metadata.format)
) {
return null;
}
const normalized = await source.rotate().png().toBuffer();
if (normalized.length > BRANDING_LOGO_MAX_BYTES) return null;
return `data:image/png;base64,${normalized.toString("base64")}`;
} catch (err) {
databaseLogger.warn("Rejected invalid branding logo upload", {
operation: "branding_logo_invalid",
error: err instanceof Error ? err.message : String(err),
});
return null;
}
}
@@ -1,436 +0,0 @@
import { getErrorMessage } from "../../utils/error-message.js";
import type {
AuthenticatedRequest,
CredentialBackend,
} from "../../../types/index.js";
import type { Request, RequestHandler, Response, Router } from "express";
import type { Client as SshClient } from "ssh2";
import { createCurrentHostResolutionRepository } from "../repositories/factory.js";
import { connectHost } from "../../hosts/connect/connect-host.js";
import type { SshConnectHost } from "../../hosts/connect/types.js";
import { resolveHostById } from "../../hosts/host-resolver.js";
import { isSecretReference } from "../../hosts/external-secrets.js";
import { resolveKeyReferences } from "../../hosts/stored-ssh-key.js";
import { parseSSHKey } from "../../utils/ssh-key-utils.js";
function describeDeployError(err: unknown): string {
const message = getErrorMessage(err, "Connection failed");
if (message.includes("All configured authentication methods failed")) {
return "Authentication failed. Please check your credentials and ensure the SSH service is running.";
}
if (message.includes("ENOTFOUND") || message.includes("ENOENT")) {
return "Could not resolve hostname or connect to server.";
}
if (message.includes("ECONNREFUSED")) {
return "Connection refused. The server may not be running or the port may be incorrect.";
}
if (message.includes("ETIMEDOUT")) {
return "Connection timed out. Check your network connection and server availability.";
}
if (
message.includes("authentication failed") ||
message.includes("Permission denied")
) {
return "Authentication failed. Please check your username and password/key.";
}
if (message === "SSH connection timeout") return "Connection timeout";
return message;
}
async function deploySSHKeyToHost(
host: SshConnectHost,
userId: string,
credData: CredentialBackend,
): Promise<{ success: boolean; message?: string; error?: string }> {
const publicKey = credData.publicKey as string;
let conn: SshClient;
try {
({ client: conn } = await connectHost(host, {
userId,
purpose: "credential-deploy",
timeoutMs: 120000,
}));
} catch (error) {
return { success: false, error: describeDeployError(error) };
}
return new Promise((resolve) => {
void (async () => {
try {
await new Promise<void>((resolveCmd, rejectCmd) => {
const cmdTimeout = setTimeout(() => {
rejectCmd(new Error("mkdir command timeout"));
}, 10000);
conn.exec(
"test -d ~/.ssh || mkdir -p ~/.ssh; chmod 700 ~/.ssh",
(err, stream) => {
if (err) {
clearTimeout(cmdTimeout);
return rejectCmd(err);
}
stream.on("close", (code) => {
clearTimeout(cmdTimeout);
if (code === 0) {
resolveCmd();
} else {
rejectCmd(
new Error(`mkdir command failed with code ${code}`),
);
}
});
stream.on("data", () => {
// Ignore output
});
},
);
});
const keyExists = await new Promise<boolean>(
(resolveCheck, rejectCheck) => {
const checkTimeout = setTimeout(() => {
rejectCheck(new Error("Key check timeout"));
}, 5000);
let actualPublicKey = publicKey;
try {
const parsed = JSON.parse(publicKey);
if (parsed.data) {
actualPublicKey = parsed.data;
}
} catch {
// Ignore parse errors
}
const keyParts = actualPublicKey.trim().split(" ");
if (keyParts.length < 2) {
clearTimeout(checkTimeout);
return rejectCheck(
new Error(
"Invalid public key format - must contain at least 2 parts",
),
);
}
const keyPattern = keyParts[1];
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(keyPattern)) {
clearTimeout(checkTimeout);
return rejectCheck(new Error("Invalid public key data"));
}
conn.exec(
`if [ -f ~/.ssh/authorized_keys ]; then grep -F "${keyPattern}" ~/.ssh/authorized_keys >/dev/null 2>&1; echo $?; else echo 1; fi`,
(err, stream) => {
if (err) {
clearTimeout(checkTimeout);
return rejectCheck(err);
}
let output = "";
stream.on("data", (data) => {
output += data.toString();
});
stream.on("close", () => {
clearTimeout(checkTimeout);
const exists = output.trim() === "0";
resolveCheck(exists);
});
},
);
},
);
if (keyExists) {
conn.end();
resolve({ success: true, message: "SSH key already deployed" });
return;
}
await new Promise<void>((resolveAdd, rejectAdd) => {
const addTimeout = setTimeout(() => {
rejectAdd(new Error("Key add timeout"));
}, 30000);
let actualPublicKey = publicKey;
try {
const parsed = JSON.parse(publicKey);
if (parsed.data) {
actualPublicKey = parsed.data;
}
} catch {
// Ignore parse errors
}
const escapedKey = actualPublicKey
.replace(/\\/g, "\\\\")
.replace(/'/g, "'\\''");
const escapedName = credData.name
.replace(/\\/g, "\\\\")
.replace(/'/g, "'\\''");
conn.exec(
`printf '%s\n' '${escapedKey} ${escapedName}@Termix' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys`,
(err, stream) => {
if (err) {
clearTimeout(addTimeout);
return rejectAdd(err);
}
stream.on("data", () => {
// Consume output
});
stream.on("close", (code) => {
clearTimeout(addTimeout);
if (code === 0) {
resolveAdd();
} else {
rejectAdd(
new Error(`Key deployment failed with code ${code}`),
);
}
});
},
);
});
const verifySuccess = await new Promise<boolean>(
(resolveVerify, rejectVerify) => {
const verifyTimeout = setTimeout(() => {
rejectVerify(new Error("Key verification timeout"));
}, 5000);
let actualPublicKey = publicKey;
try {
const parsed = JSON.parse(publicKey);
if (parsed.data) {
actualPublicKey = parsed.data;
}
} catch {
// Ignore parse errors
}
const keyParts = actualPublicKey.trim().split(" ");
if (keyParts.length < 2) {
clearTimeout(verifyTimeout);
return rejectVerify(
new Error(
"Invalid public key format - must contain at least 2 parts",
),
);
}
const keyPattern = keyParts[1];
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(keyPattern)) {
clearTimeout(verifyTimeout);
return rejectVerify(new Error("Invalid public key data"));
}
conn.exec(
`grep -F "${keyPattern}" ~/.ssh/authorized_keys >/dev/null 2>&1; echo $?`,
(err, stream) => {
if (err) {
clearTimeout(verifyTimeout);
return rejectVerify(err);
}
let output = "";
stream.on("data", (data) => {
output += data.toString();
});
stream.on("close", () => {
clearTimeout(verifyTimeout);
const verified = output.trim() === "0";
resolveVerify(verified);
});
},
);
},
);
conn.end();
if (verifySuccess) {
resolve({ success: true, message: "SSH key deployed successfully" });
} else {
resolve({
success: false,
error: "Key deployment verification failed",
});
}
} catch (error) {
conn.end();
resolve({
success: false,
error: getErrorMessage(error, "Deployment failed"),
});
}
})();
});
}
export function registerCredentialDeployRoutes(
router: Router,
authenticateJWT: RequestHandler,
requireCredentialViewPermission: RequestHandler,
requireHostEditPermission: RequestHandler,
requireDataAccess: RequestHandler,
): void {
/**
* @openapi
* /credentials/{id}/deploy-to-host:
* post:
* summary: Deploy SSH key to a host
* description: Deploys an SSH public key to a target host's authorized_keys file.
* tags:
* - Credentials
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* targetHostId:
* type: integer
* responses:
* 200:
* description: SSH key deployed successfully.
* 400:
* description: Credential ID and target host ID are required.
* 401:
* description: Authentication required.
* 404:
* description: Credential or target host not found.
* 500:
* description: Failed to deploy SSH key.
*/
router.post(
"/:id/deploy-to-host",
authenticateJWT,
requireCredentialViewPermission,
requireHostEditPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const id = Array.isArray(req.params.id)
? req.params.id[0]
: req.params.id;
const credentialId = parseInt(id);
const { targetHostId } = req.body;
if (!credentialId || !targetHostId) {
return res.status(400).json({
success: false,
error: "Credential ID and target host ID are required",
});
}
try {
const userId = (req as AuthenticatedRequest).userId;
if (!userId) {
return res.status(401).json({
success: false,
error: "Authentication required",
});
}
const repository = createCurrentHostResolutionRepository();
const credential = await repository.findCredentialByIdForUser(
credentialId,
userId,
);
if (!credential) {
return res.status(404).json({
success: false,
error: "Credential not found",
});
}
const credData = credential as unknown as CredentialBackend;
if (credData.authType !== "key") {
return res.status(400).json({
success: false,
error: "Only SSH key-based credentials can be deployed",
});
}
let publicKey = credData.publicKey;
const storedKey = credData.privateKey || credData.key;
if (!publicKey && isSecretReference(storedKey)) {
// A key kept in a secret source has no stored public key; derive
// it from the resolved key.
const resolved = await resolveKeyReferences(
userId,
storedKey,
(credData as { keyPassword?: string | null }).keyPassword,
);
publicKey =
parseSSHKey(resolved.key, resolved.passphrase).publicKey ||
undefined;
}
if (!publicKey) {
return res.status(400).json({
success: false,
error: "Public key is required for deployment",
});
}
// Writing authorized_keys is an owner action, so ownership is checked
// before the shared resolver fills in the credentials.
const hostData = await repository.findHostByIdForUser(
targetHostId,
userId,
);
if (!hostData) {
return res.status(404).json({
success: false,
error: "Target host not found",
});
}
const resolvedHost = await resolveHostById(targetHostId, userId);
if (!resolvedHost) {
return res.status(400).json({
success: false,
error: "Host credential not found",
});
}
const deployResult = await deploySSHKeyToHost(
resolvedHost as unknown as SshConnectHost,
userId,
credData,
);
if (deployResult.success) {
res.json({
success: true,
message: deployResult.message || "SSH key deployed successfully",
});
} else {
res.status(500).json({
success: false,
error: deployResult.error || "Deployment failed",
});
}
} catch (error) {
res.status(500).json({
success: false,
error: getErrorMessage(error, "Failed to deploy SSH key"),
});
}
},
);
}
@@ -1,519 +0,0 @@
import { getErrorMessage } from "../../utils/error-message.js";
import type { Request, RequestHandler, Response, Router } from "express";
import crypto from "crypto";
import ssh2Pkg from "ssh2";
import { authLogger } from "../../utils/logger.js";
import {
parsePublicKey,
parseSSHKey,
validateKeyPair,
} from "../../utils/ssh-key-utils.js";
import { resolveKeyReferences } from "../../hosts/stored-ssh-key.js";
import type { AuthenticatedRequest } from "../../../types/index.js";
const { utils: ssh2Utils } = ssh2Pkg;
function generateSSHKeyPair(
keyType: string,
keySize?: number,
passphrase?: string,
): {
success: boolean;
privateKey?: string;
publicKey?: string;
error?: string;
} {
try {
let ssh2Type = keyType;
const options: {
bits?: number;
passphrase?: string;
cipher?: string;
} = {};
if (keyType === "ssh-rsa") {
ssh2Type = "rsa";
options.bits = keySize || 2048;
} else if (keyType === "ssh-ed25519") {
ssh2Type = "ed25519";
} else if (keyType === "ecdsa-sha2-nistp256") {
ssh2Type = "ecdsa";
options.bits = 256;
}
if (passphrase && passphrase.trim()) {
options.passphrase = passphrase;
options.cipher = "aes128-cbc";
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const keyPair = ssh2Utils.generateKeyPairSync(ssh2Type as any, options);
return {
success: true,
privateKey: keyPair.private,
publicKey: keyPair.public,
};
} catch (error) {
return {
success: false,
error: getErrorMessage(error, "SSH key generation failed"),
};
}
}
export function registerCredentialKeyRoutes(
router: Router,
authenticateJWT: RequestHandler,
requireCredentialViewPermission: RequestHandler,
requireDataAccess: RequestHandler,
): void {
/**
* @openapi
* /credentials/detect-key-type:
* post:
* summary: Detect SSH key type
* description: Detects the type of an SSH private key.
* tags:
* - Credentials
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* privateKey:
* type: string
* keyPassword:
* type: string
* responses:
* 200:
* description: Key type detection result.
* 400:
* description: Private key is required.
* 500:
* description: Failed to detect key type.
*/
router.post(
"/detect-key-type",
authenticateJWT,
requireCredentialViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
if (!req.body?.privateKey || typeof req.body.privateKey !== "string") {
return res.status(400).json({ error: "Private key is required" });
}
try {
const { key: privateKey, passphrase: keyPassword } =
await resolveKeyReferences(
(req as AuthenticatedRequest).userId,
req.body.privateKey,
req.body.keyPassword,
);
const keyInfo = parseSSHKey(privateKey, keyPassword);
const response = {
success: keyInfo.success,
keyType: keyInfo.keyType,
detectedKeyType: keyInfo.keyType,
hasPublicKey: !!keyInfo.publicKey,
error: keyInfo.error || null,
};
res.json(response);
} catch (error) {
authLogger.error("Failed to detect key type", error);
res.status(500).json({
error: getErrorMessage(error, "Failed to detect key type"),
});
}
},
);
/**
* @openapi
* /credentials/detect-public-key-type:
* post:
* summary: Detect SSH public key type
* description: Detects the type of an SSH public key.
* tags:
* - Credentials
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* publicKey:
* type: string
* responses:
* 200:
* description: Key type detection result.
* 400:
* description: Public key is required.
* 500:
* description: Failed to detect public key type.
*/
router.post(
"/detect-public-key-type",
authenticateJWT,
requireCredentialViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const { publicKey } = req.body;
if (!publicKey || typeof publicKey !== "string") {
return res.status(400).json({ error: "Public key is required" });
}
try {
const keyInfo = parsePublicKey(publicKey);
const response = {
success: keyInfo.success,
keyType: keyInfo.keyType,
detectedKeyType: keyInfo.keyType,
error: keyInfo.error || null,
};
res.json(response);
} catch (error) {
authLogger.error("Failed to detect public key type", error);
res.status(500).json({
error: getErrorMessage(error, "Failed to detect public key type"),
});
}
},
);
/**
* @openapi
* /credentials/validate-key-pair:
* post:
* summary: Validate SSH key pair
* description: Validates if a given SSH private key and public key match.
* tags:
* - Credentials
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* privateKey:
* type: string
* publicKey:
* type: string
* keyPassword:
* type: string
* responses:
* 200:
* description: Key pair validation result.
* 400:
* description: Private key and public key are required.
* 500:
* description: Failed to validate key pair.
*/
router.post(
"/validate-key-pair",
authenticateJWT,
requireCredentialViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const { privateKey, publicKey, keyPassword } = req.body;
if (!privateKey || typeof privateKey !== "string") {
return res.status(400).json({ error: "Private key is required" });
}
if (!publicKey || typeof publicKey !== "string") {
return res.status(400).json({ error: "Public key is required" });
}
try {
const validationResult = validateKeyPair(
privateKey,
publicKey,
keyPassword,
);
const response = {
isValid: validationResult.isValid,
privateKeyType: validationResult.privateKeyType,
publicKeyType: validationResult.publicKeyType,
generatedPublicKey: validationResult.generatedPublicKey,
error: validationResult.error || null,
};
res.json(response);
} catch (error) {
authLogger.error("Failed to validate key pair", error);
res.status(500).json({
error: getErrorMessage(error, "Failed to validate key pair"),
});
}
},
);
/**
* @openapi
* /credentials/generate-key-pair:
* post:
* summary: Generate new SSH key pair
* description: Generates a new SSH key pair.
* tags:
* - Credentials
* requestBody:
* content:
* application/json:
* schema:
* type: object
* properties:
* keyType:
* type: string
* keySize:
* type: integer
* passphrase:
* type: string
* responses:
* 200:
* description: The new key pair.
* 500:
* description: Failed to generate SSH key pair.
*/
router.post(
"/generate-key-pair",
authenticateJWT,
requireCredentialViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const { keyType = "ssh-ed25519", keySize = 2048, passphrase } = req.body;
try {
const result = generateSSHKeyPair(keyType, keySize, passphrase);
if (result.success && result.privateKey && result.publicKey) {
const response = {
success: true,
privateKey: result.privateKey,
publicKey: result.publicKey,
keyType: keyType,
format: "ssh",
algorithm: keyType,
keySize: keyType === "ssh-rsa" ? keySize : undefined,
curve: keyType === "ecdsa-sha2-nistp256" ? "nistp256" : undefined,
};
res.json(response);
} else {
res.status(500).json({
success: false,
error: result.error || "Failed to generate SSH key pair",
});
}
} catch (error) {
authLogger.error("Failed to generate key pair", error);
res.status(500).json({
success: false,
error: getErrorMessage(error, "Failed to generate key pair"),
});
}
},
);
/**
* @openapi
* /credentials/generate-public-key:
* post:
* summary: Generate public key from private key
* description: Generates a public key from a given private key.
* tags:
* - Credentials
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* privateKey:
* type: string
* keyPassword:
* type: string
* responses:
* 200:
* description: The generated public key.
* 400:
* description: Private key is required.
* 500:
* description: Failed to generate public key.
*/
router.post(
"/generate-public-key",
authenticateJWT,
requireCredentialViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
if (!req.body?.privateKey || typeof req.body.privateKey !== "string") {
return res.status(400).json({ error: "Private key is required" });
}
try {
const { key: privateKey, passphrase: keyPassword } =
await resolveKeyReferences(
(req as AuthenticatedRequest).userId,
req.body.privateKey,
req.body.keyPassword,
);
let privateKeyObj;
const parseAttempts = [];
try {
privateKeyObj = crypto.createPrivateKey({
key: privateKey,
passphrase: keyPassword,
});
} catch (error) {
parseAttempts.push(`Method 1 (with passphrase): ${error.message}`);
}
if (!privateKeyObj) {
try {
privateKeyObj = crypto.createPrivateKey(privateKey);
} catch (error) {
parseAttempts.push(
`Method 2 (without passphrase): ${error.message}`,
);
}
}
if (!privateKeyObj) {
try {
privateKeyObj = crypto.createPrivateKey({
key: privateKey,
format: "pem",
type: "pkcs8",
});
} catch (error) {
parseAttempts.push(`Method 3 (PKCS#8): ${error.message}`);
}
}
if (
!privateKeyObj &&
privateKey.includes("-----BEGIN RSA PRIVATE KEY-----")
) {
try {
privateKeyObj = crypto.createPrivateKey({
key: privateKey,
format: "pem",
type: "pkcs1",
});
} catch (error) {
parseAttempts.push(`Method 4 (PKCS#1): ${error.message}`);
}
}
if (
!privateKeyObj &&
privateKey.includes("-----BEGIN EC PRIVATE KEY-----")
) {
try {
privateKeyObj = crypto.createPrivateKey({
key: privateKey,
format: "pem",
type: "sec1",
});
} catch (error) {
parseAttempts.push(`Method 5 (SEC1): ${error.message}`);
}
}
if (!privateKeyObj) {
try {
const keyInfo = parseSSHKey(privateKey, keyPassword);
if (keyInfo.success && keyInfo.publicKey) {
const publicKeyString = String(keyInfo.publicKey);
return res.json({
success: true,
publicKey: publicKeyString,
keyType: keyInfo.keyType,
});
} else {
parseAttempts.push(
`SSH2 fallback: ${keyInfo.error || "No public key generated"}`,
);
}
} catch (error) {
parseAttempts.push(`SSH2 fallback exception: ${error.message}`);
}
}
if (!privateKeyObj) {
return res.status(400).json({
success: false,
error: "Unable to parse private key. Tried multiple formats.",
details: parseAttempts,
});
}
const publicKeyObj = crypto.createPublicKey(privateKeyObj);
const publicKeyPem = publicKeyObj.export({
type: "spki",
format: "pem",
});
const publicKeyString =
typeof publicKeyPem === "string"
? publicKeyPem
: (publicKeyPem as Buffer).toString("utf8");
let keyType = "unknown";
const asymmetricKeyType = privateKeyObj.asymmetricKeyType;
if (asymmetricKeyType === "rsa") {
keyType = "ssh-rsa";
} else if (asymmetricKeyType === "ed25519") {
keyType = "ssh-ed25519";
} else if (asymmetricKeyType === "ec") {
keyType = "ecdsa-sha2-nistp256";
}
let finalPublicKey = publicKeyString;
let formatType = "pem";
try {
const ssh2PrivateKey = ssh2Utils.parseKey(privateKey, keyPassword);
if (!(ssh2PrivateKey instanceof Error)) {
const publicKeyBuffer = ssh2PrivateKey.getPublicSSH();
const base64Data = publicKeyBuffer.toString("base64");
finalPublicKey = `${keyType} ${base64Data}`;
formatType = "ssh";
}
} catch {
// Ignore validation errors
}
const response = {
success: true,
publicKey: finalPublicKey,
keyType: keyType,
format: formatType,
};
res.json(response);
} catch (error) {
authLogger.error("Failed to generate public key", error);
res.status(500).json({
success: false,
error: getErrorMessage(error, "Failed to generate public key"),
});
}
},
);
}
@@ -8,8 +8,6 @@ import { authLogger } from "../../utils/logger.js";
import { PermissionManager } from "../../utils/permission-manager.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { parseKeyForStorage } from "../../hosts/stored-ssh-key.js";
import { registerCredentialKeyRoutes } from "./credential-key-routes.js";
import { registerCredentialDeployRoutes } from "./credential-deploy-routes.js";
import { registerCredentialBulkRoutes } from "./credential-bulk-routes.js";
import {
logAudit,
@@ -1211,19 +1209,4 @@ router.put(
},
);
registerCredentialKeyRoutes(
router,
authenticateJWT,
permissionManager.requirePermission("credentials.view"),
requireDataAccess,
);
registerCredentialDeployRoutes(
router,
authenticateJWT,
permissionManager.requirePermission("credentials.view"),
permissionManager.requirePermission("hosts.edit"),
requireDataAccess,
);
export default router;
@@ -3,8 +3,7 @@ import express, { type Request, type Response } from "express";
import { dashboardLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { DataCrypto } from "../../utils/data-crypto.js";
import { createCurrentRecentActivityRepository } from "../repositories/factory.js";
import { recordRecentActivity } from "../../services/recent-activity.js";
import { recentActivity } from "../../plugins/activity-provider.js";
const router = express.Router();
@@ -99,11 +98,7 @@ router.get(
const limit = Number(req.query.limit) || 20;
const activities =
await createCurrentRecentActivityRepository().listByUserId(
userId,
limit,
);
const activities = await recentActivity.list(userId, limit);
res.json(activities);
} catch (err) {
@@ -169,7 +164,7 @@ router.post(
});
}
const result = await recordRecentActivity(userId, {
const result = await recentActivity.record(userId, {
type,
hostId,
hostName,
@@ -185,6 +180,11 @@ router.post(
message: "Activity already logged recently (rate limited)",
});
}
if (result.status === "unavailable") {
return res
.status(404)
.json({ error: "Recent activity is not available" });
}
if (result.status === "denied") {
return res
.status(404)
@@ -222,7 +222,7 @@ router.delete(
try {
const userId = (req as AuthenticatedRequest).userId;
await createCurrentRecentActivityRepository().deleteByUserId(userId);
await recentActivity.deleteByUserId(userId);
dashboardLogger.success("Recent activity cleared", {
operation: "reset_recent_activity",
@@ -1,4 +1,5 @@
import { authLogger } from "../../utils/logger.js";
import { recentActivity } from "../../plugins/activity-provider.js";
import {
createCurrentApiKeyRepository,
createCurrentAuditLogRepository,
@@ -9,7 +10,6 @@ import {
createCurrentCredentialSidebarPreferenceRepository,
createCurrentUiPreferenceRepository,
createCurrentOpenTabRepository,
createCurrentRecentActivityRepository,
createCurrentRbacAccessRepository,
createCurrentRoleRepository,
createCurrentSessionRepository,
@@ -64,7 +64,7 @@ export async function deleteUserAndRelatedData(
await createCurrentSshCredentialUsageRepository().deleteByUserId(userId);
await createCurrentRecentActivityRepository().deleteByUserId(userId);
await recentActivity.deleteByUserId(userId);
await createCurrentHostFolderRepository().deleteByUserId(userId);
@@ -61,15 +61,23 @@ export function isNativeTokenExportRequest(req: Request): boolean {
* during testing, or a household sharing one install) -- a user in that
* state deserves to get into the app they installed, not a confusing,
* unexplained dead end. So this always returns a single, deterministic
* user: the admin account if one exists, else the earliest-registered
* account. It never returns null.
* user: the account the user switched to if it still exists, else the admin
* account if one exists, else the earliest-registered account. It never
* returns null when there are users.
*/
export function resolveDesktopAutoSessionUser(
allUsers: UserRecord[],
preferredId?: string | null,
): UserRecord | null {
if (allUsers.length === 0) return null;
if (allUsers.length === 1) return allUsers[0];
// An account the user switched to on purpose, while it still exists.
const preferred = preferredId
? allUsers.find((user) => user.id === preferredId)
: undefined;
if (preferred) return preferred;
const admin = allUsers.find((user) => user.isAdmin);
if (admin) return admin;
+13 -821
View File
@@ -1,172 +1,23 @@
import {
prepareHostImports,
remapImportedJumpHosts,
} from "./host-import-order.js";
import { sshOptionsForWrite } from "../../hosts/ssh-options.js";
import {
applyDefaultsAfterHostWrites,
applyHostDefaultsToWrite,
} from "../../hosts/defaults/index.js";
import {
changeHostOverrides,
type OverrideChange,
} from "../../hosts/defaults/overrides.js";
import { recompute } from "../../hosts/defaults/recompute.js";
import { splitDefaultKey } from "../../../types/host-defaults.js";
import {
keepUsableProtocolCredentials,
readProtocolAuthPayload,
writeProtocolAuth,
} from "../../hosts/protocol-auth/protocol-auth.js";
import { getErrorMessage } from "../../utils/error-message.js";
import type { AuthenticatedRequest } from "../../../types/index.js";
import type { Request, RequestHandler, Response, Router } from "express";
import { sshLogger } from "../../utils/logger.js";
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
import { pluginEvents, TOPICS } from "../../plugins/events.js";
import {
createCurrentCredentialRepository,
createCurrentHostRepository,
createCurrentHostResolutionRepository,
createCurrentHostDefaultsRepository,
} from "../repositories/factory.js";
import { createCurrentHostRepository } from "../repositories/factory.js";
import { validateParentHostId } from "./host-parent-validation.js";
import {
listSshAuthProviders,
listSshAuthTypeOwners,
} from "../../hosts/connect/auth-provider-registry.js";
import {
applyPluginHostImportSettings,
setHostPluginEnabled,
} from "./host-plugin-settings.js";
import {
isNonEmptyString,
isValidPort,
normalizeImportedHost,
} from "./host-normalizers.js";
import { assertHostsUnlocked, sendHostLocked } from "../../hosts/locks.js";
type SSHConfigHost = {
name: string;
hostname?: string;
user?: string;
port?: number;
identityFile?: string;
proxyJump?: string;
};
type ShareCredential = {
alias?: unknown;
name?: unknown;
description?: unknown;
folder?: unknown;
tags?: unknown;
authType?: unknown;
username?: unknown;
keyType?: unknown;
};
function textValue(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function tagString(value: unknown): string {
if (Array.isArray(value)) {
return value
.map((tag) => textValue(tag))
.filter((tag): tag is string => !!tag)
.join(",");
}
return textValue(value) || "";
}
function normalizeCredentialAuthType(value: unknown): "password" | "key" {
return value === "key" ? "key" : "password";
}
export function parseSSHConfig(content: string): SSHConfigHost[] {
const results: SSHConfigHost[] = [];
let current: SSHConfigHost | null = null;
for (const rawLine of content.split("\n")) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) continue;
const match = /^(\S+?)(?:\s*=\s*|\s+)(.+)$/.exec(line);
if (!match) continue;
const key = match[1].toLowerCase();
const value = match[2];
if (key === "host") {
if (current && current.hostname) results.push(current);
// Skip wildcard patterns
if (value === "*" || value.includes("*") || value.includes("?")) {
current = null;
} else {
current = { name: value };
}
continue;
}
if (!current) continue;
switch (key) {
case "hostname":
current.hostname = value;
break;
case "user":
current.user = value;
break;
case "port": {
const p = Number.parseInt(value, 10);
if (p > 0 && p <= 65535) current.port = p;
break;
}
case "identityfile":
if (!current.identityFile) current.identityFile = value;
break;
case "proxyjump":
current.proxyJump = value;
break;
}
}
if (current && current.hostname) results.push(current);
return results;
}
/**
* Tells status checks and other pollers about hosts an import wrote, the way
* a single host create or update does.
*/
export function notifyImportedHosts(
userId: string,
hostIds: number[],
createdHostIds: number[],
): void {
for (const hostId of createdHostIds) {
pluginEvents.emit(TOPICS.hostCreated, { hostId, userId });
}
for (const hostId of hostIds) {
pluginEvents.emit(TOPICS.hostUpdated, { hostId, userId });
}
}
export function importedHostUsername(
connectionType: string,
authType: unknown,
username: unknown,
): string | null {
if (isNonEmptyString(username)) return username;
if (connectionType !== "ssh" || authType === "credential") return "";
return null;
}
import { setHostPluginEnabled } from "./host-plugin-settings.js";
export function registerHostBulkRoutes(
router: Router,
authenticateJWT: RequestHandler,
requireCreatePermission: RequestHandler,
requireEditPermission: RequestHandler,
requireDataAccess: RequestHandler,
): void {
@@ -291,6 +142,15 @@ export function registerHostBulkRoutes(
);
const ownedIds = ownedHosts.map((h) => h.id);
// Pins stay the user's own on a managed host.
if (Object.keys(updates).some((key) => key !== "pin")) {
try {
assertHostsUnlocked(ownedIds);
} catch (error) {
if (sendHostLocked(res, error)) return;
throw error;
}
}
const unauthorizedIds = hostIds.filter(
(id: number) => !ownedIds.includes(id),
);
@@ -467,674 +327,6 @@ export function registerHostBulkRoutes(
}
},
);
router.post(
"/bulk-import",
authenticateJWT,
requireCreatePermission,
requireEditPermission,
requireDataAccess,
DatabaseSaveTrigger.batched(async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const {
hosts: hostsToImport,
overwrite,
credentials: credentialsToImport,
} = req.body;
if (!Array.isArray(hostsToImport) || hostsToImport.length === 0) {
return res
.status(400)
.json({ error: "Hosts array is required and must not be empty" });
}
if (hostsToImport.length > 100) {
return res
.status(400)
.json({ error: "Maximum 100 hosts allowed per import" });
}
let orderedHosts: ReturnType<typeof prepareHostImports>;
try {
orderedHosts = prepareHostImports(hostsToImport);
} catch (error) {
return res.status(400).json({ error: getErrorMessage(error) });
}
const importedIds = new Map<unknown, number>();
const results = {
success: 0,
updated: 0,
skipped: 0,
failed: 0,
errors: [] as string[],
};
const credentialAliasMap = new Map<string, number>();
const addCredentialAlias = (alias: unknown, id: number) => {
const key = textValue(alias);
if (key) credentialAliasMap.set(key.toLowerCase(), id);
};
try {
const credentialRepository = createCurrentCredentialRepository();
const existingCredentials =
await credentialRepository.listDecryptedByUserId(userId);
for (const credential of existingCredentials) {
addCredentialAlias(credential.name, credential.id as number);
}
if (Array.isArray(credentialsToImport)) {
for (const rawCredential of credentialsToImport as ShareCredential[]) {
const alias = textValue(rawCredential.alias);
const name = textValue(rawCredential.name) || alias;
if (!alias || !name) continue;
const existingId = credentialAliasMap.get(name.toLowerCase());
if (existingId) {
addCredentialAlias(alias, existingId);
continue;
}
const now = new Date().toISOString();
const created = await credentialRepository.createEncryptedForUser(
userId,
{
userId,
name,
description:
textValue(rawCredential.description) ||
"Imported placeholder. Add the secret before connecting.",
folder: textValue(rawCredential.folder),
tags: tagString(rawCredential.tags),
authType: normalizeCredentialAuthType(rawCredential.authType),
username: textValue(rawCredential.username),
password: null,
key: null,
privateKey: null,
publicKey: null,
keyPassword: null,
keyType: textValue(rawCredential.keyType),
detectedKeyType: null,
usageCount: 0,
lastUsed: null,
createdAt: now,
updatedAt: now,
},
);
const createdCredential = created as Record<string, unknown>;
addCredentialAlias(alias, createdCredential.id as number);
addCredentialAlias(name, createdCredential.id as number);
}
}
} catch (error) {
results.errors.push(
`Credential placeholders: ${getErrorMessage(error, "failed to prepare credential aliases")}`,
);
}
// Loaded for both modes: overwrite updates a match, skip leaves it alone.
let existingHostMap: Map<string, { id: number }> | undefined;
const hostRepository = createCurrentHostRepository();
try {
const allHosts =
await createCurrentHostResolutionRepository().findHostsByUserId(
userId,
);
existingHostMap = new Map();
for (const h of allHosts) {
const key = `${h.ip}:${h.port}:${h.username}`;
existingHostMap.set(key, { id: h.id as number });
}
} catch {
existingHostMap = undefined;
}
const knownAuthTypes = listKnownAuthTypes();
const writtenHostIds: number[] = [];
const createdHostIds: number[] = [];
for (const { host: hostData, index: i, exportId } of orderedHosts) {
try {
const effectiveConnectionType = hostData.connectionType || "ssh";
if (
effectiveConnectionType === "ssh" &&
hostData.authType === "credential" &&
!hostData.credentialId &&
hostData.credentialAlias
) {
hostData.credentialId = credentialAliasMap.get(
hostData.credentialAlias.toLowerCase(),
);
}
if (!isNonEmptyString(hostData.ip) || !isValidPort(hostData.port)) {
results.failed++;
results.errors.push(
`Host ${i + 1}: Missing required fields (ip, port)`,
);
continue;
}
const username = importedHostUsername(
effectiveConnectionType,
hostData.authType,
hostData.username,
);
if (username === null) {
results.failed++;
results.errors.push(
`Host ${i + 1}: Username required for SSH connections`,
);
continue;
}
const lookupKey = `${hostData.ip}:${hostData.port}:${hostData.username}`;
const existing = existingHostMap?.get(lookupKey);
if (existing && !overwrite) {
// Hosts that jump through this one still resolve to the kept host.
if (exportId !== undefined) importedIds.set(exportId, existing.id);
results.skipped++;
continue;
}
if (
effectiveConnectionType === "ssh" &&
hostData.authType &&
!knownAuthTypes.has(hostData.authType)
) {
results.failed++;
results.errors.push(
`Host ${i + 1}: Invalid authType. Must be one of ${[...knownAuthTypes].join(", ")}`,
);
continue;
}
if (
effectiveConnectionType === "ssh" &&
hostData.authType === "password" &&
!isNonEmptyString(hostData.password)
) {
results.failed++;
results.errors.push(
`Host ${i + 1}: Password required for password authentication`,
);
continue;
}
if (
effectiveConnectionType === "ssh" &&
hostData.authType === "key" &&
!isNonEmptyString(hostData.key)
) {
results.failed++;
results.errors.push(
`Host ${i + 1}: Key required for key authentication`,
);
continue;
}
if (
effectiveConnectionType === "ssh" &&
hostData.authType === "credential" &&
!hostData.credentialId
) {
results.failed++;
results.errors.push(
`Host ${i + 1}: credentialId required for credential authentication`,
);
continue;
}
if (
effectiveConnectionType === "ssh" &&
hostData.authType === "credential" &&
hostData.credentialId
) {
const credentialRepository = createCurrentCredentialRepository();
const cred = await credentialRepository.findByIdForUser(
userId,
hostData.credentialId,
);
if (!cred) {
const fallback = await credentialRepository.listByUserId(userId);
if (fallback.length > 0) {
hostData.credentialId = fallback[0].id;
} else if (isNonEmptyString(hostData.key)) {
hostData.authType = "key";
hostData.credentialId = undefined;
} else if (isNonEmptyString(hostData.password)) {
hostData.authType = "password";
hostData.credentialId = undefined;
} else {
results.failed++;
results.errors.push(
`Host ${i + 1}: credentialId ${hostData.credentialId} not found and no fallback credential available`,
);
continue;
}
}
}
const jumpHosts = remapImportedJumpHosts(
hostData.jumpHosts,
importedIds,
);
const sshDataObj: Record<string, unknown> = {
userId: userId,
connectionType: effectiveConnectionType,
name: hostData.name || `${username}@${hostData.ip}`,
folder: hostData.folder || "Default",
tags: Array.isArray(hostData.tags) ? hostData.tags.join(",") : "",
ip: hostData.ip,
port: hostData.port,
username,
pin: hostData.pin || false,
sudoPassword: hostData.sudoPassword || null,
jumpHosts: jumpHosts ? JSON.stringify(jumpHosts) : null,
...importedStatusCheck(hostData as Record<string, unknown>),
// A 2.8 export carries these inside terminalConfig.
sshOptions:
sshOptionsForWrite({
sshOptions: hostData.sshOptions,
terminalConfig: hostData.terminalConfig,
}) ?? null,
forceKeyboardInteractive: hostData.forceKeyboardInteractive
? "true"
: "false",
notes: hostData.notes || null,
useSocks5: hostData.useSocks5 ? 1 : 0,
socks5Host: hostData.socks5Host || null,
socks5Port: hostData.socks5Port || null,
socks5Username: hostData.socks5Username || null,
socks5Password: hostData.socks5Password || null,
socks5ProxyChain: hostData.socks5ProxyChain
? JSON.stringify(hostData.socks5ProxyChain)
: null,
portKnockSequence: hostData.portKnockSequence
? JSON.stringify(hostData.portKnockSequence)
: null,
overrideCredentialUsername: hostData.overrideCredentialUsername
? 1
: 0,
enableSsh: hostData.enableSsh ?? effectiveConnectionType === "ssh",
updatedAt: new Date().toISOString(),
};
if (effectiveConnectionType !== "ssh") {
sshDataObj.password = hostData.password || null;
sshDataObj.authType = "password";
sshDataObj.credentialId = null;
sshDataObj.key = null;
sshDataObj.keyPassword = null;
sshDataObj.keyType = null;
} else {
sshDataObj.password =
hostData.authType === "password" ? hostData.password : null;
sshDataObj.authType = hostData.authType || "password";
sshDataObj.credentialId =
hostData.authType === "credential" ? hostData.credentialId : null;
sshDataObj.key = hostData.authType === "key" ? hostData.key : null;
sshDataObj.keyPassword =
hostData.authType === "key" ? hostData.keyPassword || null : null;
sshDataObj.keyType =
hostData.authType === "key" ? hostData.keyType || "auto" : null;
}
await withHostDefaults(
userId,
existing?.id ?? null,
sshDataObj,
hostData as Record<string, unknown>,
);
let savedHostId: number;
if (existing) {
const saved = await hostRepository.updateEncryptedForUser(
userId,
existing.id,
sshDataObj,
);
if (!saved) throw new Error("Host no longer exists");
if (exportId !== undefined) importedIds.set(exportId, existing.id);
savedHostId = existing.id;
results.updated++;
} else {
sshDataObj.createdAt = new Date().toISOString();
const saved = await hostRepository.createEncryptedForUser(
userId,
sshDataObj,
);
if (exportId !== undefined) importedIds.set(exportId, saved.id);
savedHostId = saved.id;
createdHostIds.push(saved.id);
results.success++;
}
const protocolAuth = readProtocolAuthPayload(
hostData as Record<string, unknown>,
);
if (protocolAuth) {
await writeProtocolAuth(
userId,
savedHostId,
await keepUsableProtocolCredentials(protocolAuth, userId),
{ isOwner: true },
);
}
// Every enabled plugin that declares host-scope settings and
// registered a hostImportNormalizer validates and writes its own
// fields here, so this loop does not need to know which plugins
// exist. See host-plugin-settings.ts.
await applyPluginHostImportSettings(
savedHostId,
hostData as Record<string, unknown>,
);
writtenHostIds.push(savedHostId);
} catch (error) {
results.failed++;
results.errors.push(`Host ${i + 1}: ${getErrorMessage(error)}`);
}
}
await applyDefaultsAfterHostWrites(writtenHostIds);
notifyImportedHosts(userId, writtenHostIds, createdHostIds);
res.json({
message: `Import completed: ${results.success} created, ${results.updated} updated, ${results.skipped} skipped, ${results.failed} failed`,
success: results.success,
updated: results.updated,
skipped: results.skipped,
failed: results.failed,
errors: results.errors,
});
}),
);
/**
* @openapi
* /host/ssh-config-import:
* post:
* summary: Import hosts from an OpenSSH config file
* description: Parses an OpenSSH ~/.ssh/config file and imports the defined hosts.
* tags:
* - SSH
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - content
* properties:
* content:
* type: string
* description: Raw text content of the SSH config file.
* overwrite:
* type: boolean
* responses:
* 200:
* description: Import completed.
* 400:
* description: Invalid request body.
*/
router.post(
"/ssh-config-import",
authenticateJWT,
requireCreatePermission,
requireEditPermission,
requireDataAccess,
DatabaseSaveTrigger.batched(async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { content, overwrite } = req.body;
if (!isNonEmptyString(content)) {
return res.status(400).json({
error: "content is required and must be a non-empty string",
});
}
let parsed: SSHConfigHost[];
try {
parsed = parseSSHConfig(content);
} catch {
return res
.status(400)
.json({ error: "Failed to parse SSH config file" });
}
if (parsed.length === 0) {
return res.status(400).json({
error: "No valid Host entries found in the SSH config file",
});
}
if (parsed.length > 100) {
return res
.status(400)
.json({ error: "Maximum 100 hosts allowed per import" });
}
const hostsToImport = parsed.map((h) => ({
name: h.name,
ip: h.hostname,
port: h.port ?? 22,
username: h.user,
authType: h.identityFile ? "key" : undefined,
connectionType: "ssh",
enableSsh: true,
...(h.proxyJump
? {
jumpHosts: [{ host: h.proxyJump, port: 22 }],
}
: {}),
}));
const results = {
success: 0,
updated: 0,
skipped: 0,
failed: 0,
errors: [] as string[],
};
let existingHostMap: Map<string, { id: number }> | undefined;
const hostRepository = createCurrentHostRepository();
try {
const allHosts =
await createCurrentHostResolutionRepository().findHostsByUserId(
userId,
);
existingHostMap = new Map();
for (const h of allHosts) {
const key = `${h.ip}:${h.port}:${h.username}`;
existingHostMap.set(key, { id: h.id as number });
}
} catch {
existingHostMap = undefined;
}
const writtenHostIds: number[] = [];
const createdHostIds: number[] = [];
for (let i = 0; i < hostsToImport.length; i++) {
const hostData = normalizeImportedHost(
hostsToImport[i] as Record<string, unknown>,
);
try {
if (!isNonEmptyString(hostData.ip) || !isValidPort(hostData.port)) {
results.failed++;
results.errors.push(
`Host "${parsed[i].name}": Missing required fields (HostName, Port)`,
);
continue;
}
const lookupKey = `${hostData.ip}:${hostData.port}:${hostData.username}`;
const existing = existingHostMap?.get(lookupKey);
if (existing && !overwrite) {
results.skipped++;
continue;
}
const sshDataObj: Record<string, unknown> = {
userId,
connectionType: "ssh",
name: hostData.name || hostData.ip,
folder: "Default",
tags: "",
ip: hostData.ip,
port: hostData.port,
username: hostData.username || null,
authType: hostData.authType || "none",
password: null,
key: null,
keyPassword: null,
keyType: null,
credentialId: null,
pin: false,
sudoPassword: null,
jumpHosts: hostData.jumpHosts
? JSON.stringify(hostData.jumpHosts)
: null,
statusCheckEnabled: true,
statusCheckInterval: null,
sshOptions: null,
forceKeyboardInteractive: "false",
notes: null,
useSocks5: 0,
socks5Host: null,
socks5Port: null,
socks5Username: null,
socks5Password: null,
socks5ProxyChain: null,
portKnockSequence: null,
overrideCredentialUsername: 0,
enableSsh: true,
updatedAt: new Date().toISOString(),
};
await withHostDefaults(
userId,
existing?.id ?? null,
sshDataObj,
hostData as unknown as Record<string, unknown>,
);
if (existing) {
await hostRepository.updateEncryptedForUser(
userId,
existing.id,
sshDataObj,
);
writtenHostIds.push(existing.id);
results.updated++;
} else {
sshDataObj.createdAt = new Date().toISOString();
const saved = await hostRepository.createEncryptedForUser(
userId,
sshDataObj,
);
writtenHostIds.push(saved.id);
createdHostIds.push(saved.id);
results.success++;
}
} catch (error) {
results.failed++;
results.errors.push(
`Host "${parsed[i].name}": ${getErrorMessage(error)}`,
);
}
}
await applyDefaultsAfterHostWrites(writtenHostIds);
notifyImportedHosts(userId, writtenHostIds, createdHostIds);
res.json({
message: `Import completed: ${results.success} created, ${results.updated} updated, ${results.skipped} skipped, ${results.failed} failed`,
success: results.success,
updated: results.updated,
skipped: results.skipped,
failed: results.failed,
errors: results.errors,
});
}),
);
}
/**
* Status check fields from an import row. Exports from before 2.9.0 carried
* them inside statsConfig.
*/
function importedStatusCheck(raw: Record<string, unknown>): {
statusCheckEnabled: boolean;
statusCheckInterval: number | null;
} {
const legacy =
raw.statsConfig && typeof raw.statsConfig === "object"
? (raw.statsConfig as Record<string, unknown>)
: {};
const enabled = raw.statusCheckEnabled ?? legacy.statusCheckEnabled;
const useGlobal = legacy.useGlobalStatusInterval !== false;
const interval =
raw.statusCheckInterval ?? (useGlobal ? null : legacy.statusCheckInterval);
const seconds = Number(interval);
return {
statusCheckEnabled: enabled !== false && legacy.disableTcpPing !== true,
statusCheckInterval:
interval != null && Number.isInteger(seconds) && seconds >= 5
? seconds
: null,
};
}
const BUILTIN_SSH_AUTH_TYPES = [
"password",
"key",
"credential",
"agent",
"none",
];
/** Core's own types plus every type a plugin registers or declares. */
function listKnownAuthTypes(): Set<string> {
return new Set([
...BUILTIN_SSH_AUTH_TYPES,
...listSshAuthProviders().map((provider) => provider.type),
...listSshAuthTypeOwners().map((owner) => owner.type),
]);
}
/** Fills an imported host's inherited keys and records what it sets itself. */
async function withHostDefaults(
userId: string,
hostId: number | null,
columns: Record<string, unknown>,
body: Record<string, unknown>,
): Promise<void> {
const stored =
hostId === null
? null
: ((
await createCurrentHostDefaultsRepository().listHosts({
hostIds: [hostId],
})
)[0] ?? null);
columns.defaultOverrides = JSON.stringify(
await applyHostDefaultsToWrite({
ownerId: userId,
hostId,
columns,
body,
stored,
}),
);
}
/** A resetDefaults body as an override change, or null when there is none. */
@@ -32,6 +32,7 @@ import {
} from "../../hosts/defaults/service.js";
import { changeHostOverrides } from "../../hosts/defaults/overrides.js";
import { readDefaultsReset } from "./host-bulk-routes.js";
import { assertHostsUnlocked, sendHostLocked } from "../../hosts/locks.js";
type HostDefaultsRoutesDeps = {
authenticateJWT: RequestHandler;
@@ -678,6 +679,7 @@ export function registerHostDefaultsRoutes(
const reset = readDefaultsReset(req.body);
if (!reset) return res.status(400).json({ error: "Nothing to reset" });
try {
assertHostsUnlocked([hostId]);
const access = await PermissionManager.getInstance().canAccessHost(
userId,
hostId,
@@ -700,6 +702,7 @@ export function registerHostDefaultsRoutes(
await changeHostOverrides([hostId], reset);
res.json({ message: "Host reset to its defaults" });
} catch (error) {
if (sendHostLocked(res, error)) return;
databaseLogger.error("Failed to reset host defaults", error);
res.status(500).json({ error: "Failed to reset host defaults" });
}
@@ -2,11 +2,11 @@ import type { Request, RequestHandler, Response, Router } from "express";
import type { AuthenticatedRequest } from "../../../types/index.js";
import { databaseLogger, sshLogger } from "../../utils/logger.js";
import { pluginEvents, TOPICS } from "../../plugins/events.js";
import { recentActivity } from "../../plugins/activity-provider.js";
import {
createCurrentCredentialRepository,
createCurrentHostFolderRepository,
createCurrentFolderAccessRepository,
createCurrentRecentActivityRepository,
createCurrentRbacAccessRepository,
createCurrentSshCredentialUsageRepository,
} from "../repositories/factory.js";
@@ -16,6 +16,31 @@ import {
folderCredentialIds,
setFolderCredentialDefault,
} from "../../hosts/defaults/service.js";
import {
assertHostsUnlocked,
hasHostLocks,
sendHostLocked,
} from "../../hosts/locks.js";
/** Refuses a folder change that would touch a locked host. */
async function folderHostsUnlocked(
userId: string,
folderName: string,
res: Response,
): Promise<boolean> {
if (!hasHostLocks()) return true;
const inFolder = await createCurrentHostFolderRepository().listHostsInFolder(
userId,
folderName,
);
try {
assertHostsUnlocked(inFolder.map((host) => host.id));
return true;
} catch (error) {
if (sendHostLocked(res, error)) return false;
throw error;
}
}
type HostFolderRoutesDeps = {
authenticateJWT: RequestHandler;
@@ -86,6 +111,7 @@ export function registerHostFolderRoutes(
}
try {
if (!(await folderHostsUnlocked(userId, oldName, res))) return;
const { updatedHosts, updatedCredentials } =
await createCurrentHostFolderRepository().renameFolder(
userId,
@@ -417,6 +443,7 @@ export function registerHostFolderRoutes(
});
try {
if (!(await folderHostsUnlocked(userId, folderName, res))) return;
const hostFolderRepository = createCurrentHostFolderRepository();
const hostsToDelete = await hostFolderRepository.listHostsInFolder(
userId,
@@ -432,9 +459,7 @@ export function registerHostFolderRoutes(
hostIds,
);
await createCurrentRecentActivityRepository().deleteByHostIds(
hostIds,
);
await recentActivity.deleteByHostIds(hostIds);
await createCurrentRbacAccessRepository().deleteHostAccessForHosts(
hostIds,
@@ -5,6 +5,7 @@ import {
} from "../../hosts/ssh-options.js";
import { listHostProtocols } from "../../hosts/protocol-auth/registry.js";
import { sanitizeProtocolAuthForRecipient } from "../../hosts/protocol-auth/summary.js";
import { hostLock } from "../../hosts/locks.js";
export function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
@@ -337,6 +338,7 @@ const CONNECT_LEVEL_FIELDS = new Set([
"sshPort",
"protocolAuth",
"jumpHosts",
"lock",
"createdAt",
"updatedAt",
]);
@@ -439,8 +441,10 @@ export function transformHostResponse(
host: Record<string, unknown>,
): Record<string, unknown> {
const shared = parseSharedSource(host.sharedSource);
const lock = typeof host.id === "number" ? hostLock(host.id) : null;
return {
...host,
...(lock ? { lock } : {}),
sharedSource: undefined,
localOnly: !!host.localOnly,
sharedCopy: !!shared,
@@ -3,13 +3,10 @@ import type { Request, RequestHandler, Response, Router } from "express";
import { PermissionManager } from "../../utils/permission-manager.js";
import { DataCrypto } from "../../utils/data-crypto.js";
import { sshLogger } from "../../utils/logger.js";
import { createCurrentSettingsRepository } from "../repositories/factory.js";
import {
DEFAULT_STATUS_INTERVAL,
GLOBAL_STATUS_INTERVAL_KEY,
hostStatusService,
hostStatus,
type HostStatusEntry,
} from "../../hosts/status/host-status-service.js";
} from "../../plugins/host-status-provider.js";
interface HostStatusRoutesDeps {
authenticateJWT: RequestHandler;
@@ -71,7 +68,7 @@ export function registerHostStatusRoutes(
return sessionExpired(res);
const requested = parseStatusHostIds(req.query.hostIds);
const statuses = await hostStatusService.statusesFor(userId, requested);
const statuses = await hostStatus.statusesFor(userId, requested);
// One batched permission check: this is polled every few seconds, and a
// per-host check made it linear in host count against the database.
@@ -110,15 +107,7 @@ export function registerHostStatusRoutes(
authenticateJWT,
requireAdmin,
async (_req: Request, res: Response) => {
const value = Number(
await createCurrentSettingsRepository().get(GLOBAL_STATUS_INTERVAL_KEY),
);
res.json({
statusCheckInterval:
Number.isInteger(value) && value >= 5
? value
: DEFAULT_STATUS_INTERVAL,
});
res.json({ statusCheckInterval: await hostStatus.getIntervalSeconds() });
},
);
@@ -161,11 +150,7 @@ export function registerHostStatusRoutes(
.json({ error: "Status check interval must be 5 to 3600 seconds" });
}
try {
await createCurrentSettingsRepository().set(
GLOBAL_STATUS_INTERVAL_KEY,
String(seconds),
);
hostStatusService.retimeAll();
await hostStatus.setIntervalSeconds(seconds);
res.json({ statusCheckInterval: seconds });
} catch (error) {
sshLogger.error("Failed to save status check settings", error, {
@@ -198,7 +183,7 @@ export function registerHostStatusRoutes(
if (DataCrypto.getUserDataKey(userId) === null) {
return sessionExpired(res);
}
await hostStatusService.refresh(userId);
await hostStatus.refresh(userId);
res.json({ message: "Status checks restarted" });
},
);
@@ -247,9 +232,8 @@ export function registerHostStatusRoutes(
return res.status(404).json({ error: "Status not available" });
}
await hostStatusService.statusesFor(userId, null);
const entry =
hostStatusService.get(id) ?? (await hostStatusService.check(id));
await hostStatus.statusesFor(userId, null);
const entry = hostStatus.get(id) ?? (await hostStatus.check(id));
if (!entry) {
return res.status(404).json({ error: "Status not available" });
}
+33 -579
View File
@@ -7,25 +7,21 @@ import type { AuthenticatedRequest } from "../../../types/index.js";
import express, { type Request, type Response } from "express";
import multer from "multer";
import { sshLogger, databaseLogger } from "../../utils/logger.js";
import { resolveHostCredentials } from "../../hosts/resolve-host-credentials.js";
import { pluginEvents, TOPICS } from "../../plugins/events.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { PermissionManager } from "../../utils/permission-manager.js";
import { DataCrypto } from "../../utils/data-crypto.js";
import { parseKeyForStorage } from "../../hosts/stored-ssh-key.js";
import {
pickResolvedPassword,
pickResolvedUsername,
} from "../../hosts/credential-username.js";
import { pickResolvedPassword } from "../../hosts/credential-username.js";
import { emitInternalEvent } from "../../hosts/internal-events.js";
import { deleteOwnedHost } from "../../hosts/delete-host.js";
import {
createCurrentCredentialRepository,
createCurrentRbacAccessRepository,
createCurrentRoleRepository,
createCurrentHostResolutionRepository,
createCurrentHostRepository,
createCurrentUserRepository,
createCurrentSharedHostAuthOverrideRepository,
createCurrentHostDefaultsRepository,
} from "../repositories/factory.js";
import {
@@ -41,7 +37,6 @@ import {
OWNER_PRIVATE_AUTH_FIELDS,
OWNER_PRIVATE_SSH_OPTION_FIELDS,
OWNER_PRIVATE_TERMINAL_CONFIG_FIELDS,
hostTerminalExport,
sanitizeHostForRecipient,
stripSensitiveFields,
transformHostResponse,
@@ -67,14 +62,8 @@ import {
getAuditUsername,
getRequestMeta,
} from "../../utils/audit-logger.js";
import type {
HostResolutionCredentialRecord,
HostResolutionHostRecord,
} from "../repositories/host-resolution-repository.js";
import {
requiresPersonalHostAuthentication,
resolveRecipientSharedHostAuthentication,
} from "../../utils/shared-host-auth-resolver.js";
import type { HostResolutionCredentialRecord } from "../repositories/host-resolution-repository.js";
import { rejectSharedCopyWrites } from "../../sync/shared-copy-guard.js";
import { sshOptionsForWrite } from "../../hosts/ssh-options.js";
import {
@@ -91,15 +80,16 @@ import {
planProtocolAuthWrite,
ProtocolAuthWriteError,
readProtocolAuthPayload,
toPortableLogins,
withProtocolAuth,
writeProtocolAuth,
type PlannedProtocolAuth,
} from "../../hosts/protocol-auth/protocol-auth.js";
import { findHostProtocol } from "../../hosts/protocol-auth/registry.js";
import {
findHostProtocol,
listHostProtocols,
} from "../../hosts/protocol-auth/registry.js";
manualCreateBlockedBy,
requireManualCreate,
requireUnlockedHost,
} from "../../hosts/locks.js";
import {
mergeStoredSshOptions,
parseTerminalConfig,
@@ -172,6 +162,7 @@ router.post(
authenticateJWT,
permissionManager.requirePermission("hosts.create"),
requireDataAccess,
requireManualCreate,
requireHostEnrollmentAccessForPath,
upload.single("key"),
async (req: Request, res: Response) => {
@@ -751,6 +742,7 @@ router.put(
authenticateJWT,
permissionManager.requirePermission("hosts.edit"),
requireDataAccess,
requireUnlockedHost,
upload.single("key"),
async (req: Request, res: Response) => {
const hostId = Array.isArray(req.params.id)
@@ -1220,6 +1212,27 @@ router.put(
},
);
/**
* @openapi
* /host/policy:
* get:
* summary: Host creation policy
* description: Whether hosts can be added by hand, or a plugin manages them all.
* tags:
* - SSH
* responses:
* 200:
* description: "{ manualCreate, blockedBy? } where blockedBy is the managing plugin's name."
*/
router.get("/policy", authenticateJWT, (_req: Request, res: Response) => {
const blocked = manualCreateBlockedBy();
res.json(
blocked
? { manualCreate: false, blockedBy: blocked.pluginName }
: { manualCreate: true },
);
});
/**
* @openapi
* /host/db/host:
@@ -1688,340 +1701,6 @@ router.get(
},
);
/**
* @openapi
* /host/db/host/{id}/export:
* get:
* summary: Export SSH host
* description: Exports a specific SSH host with decrypted credentials.
* tags:
* - SSH
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: The exported SSH host.
* 400:
* description: Invalid userId or hostId.
* 404:
* description: SSH host not found.
* 500:
* description: Failed to export SSH host.
*/
router.get(
"/db/host/:id/export",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const hostId = Array.isArray(req.params.id)
? req.params.id[0]
: req.params.id;
const userId = (req as AuthenticatedRequest).userId;
if (!isNonEmptyString(userId) || !hostId) {
return res.status(400).json({ error: "Invalid userId or hostId" });
}
try {
const host =
await createCurrentHostResolutionRepository().findHostByIdForUser(
Number(hostId),
userId,
);
if (!host) {
return res.status(404).json({ error: "SSH host not found" });
}
const resolvedHost = (await resolveHostCredentials(host, userId)) || host;
const hostPluginSettings = (
await loadHostPluginSettings([Number(hostId)])
).get(Number(hostId));
const exportedConnectionType =
(resolvedHost.connectionType as string) || "ssh";
const isRemoteDesktop = exportedConnectionType !== "ssh";
const baseExportData = {
exportId: resolvedHost.id,
connectionType: exportedConnectionType,
name: resolvedHost.name,
ip: resolvedHost.ip,
port: resolvedHost.port,
username: resolvedHost.username,
password: resolvedHost.password || null,
folder: resolvedHost.folder,
tags:
typeof resolvedHost.tags === "string"
? resolvedHost.tags.split(",").filter(Boolean)
: resolvedHost.tags || [],
pin: !!resolvedHost.pin,
notes: resolvedHost.notes || null,
// Every plugin's host settings, secrets redacted, for import to hand back.
pluginSettings: hostPluginSettings ?? {},
// Each plugin protocol's login, secrets included like the SSH ones.
protocolAuth: toPortableLogins(
await listProtocolLogins(Number(hostId), userId),
),
};
const exportData = isRemoteDesktop
? baseExportData
: {
...baseExportData,
authType: resolvedHost.authType,
key: resolvedHost.key || null,
keyPassword: resolvedHost.keyPassword || null,
keyType: resolvedHost.keyType || null,
credentialId: resolvedHost.credentialId || null,
overrideCredentialUsername:
!!resolvedHost.overrideCredentialUsername,
sudoPassword: resolvedHost.sudoPassword || null,
jumpHosts: resolvedHost.jumpHosts
? JSON.parse(resolvedHost.jumpHosts as string)
: null,
terminalConfig:
hostTerminalExport(resolvedHost).terminalConfig ?? null,
sshOptions: hostTerminalExport(resolvedHost).sshOptions,
forceKeyboardInteractive:
resolvedHost.forceKeyboardInteractive === "true",
useSocks5: !!resolvedHost.useSocks5,
socks5Host: resolvedHost.socks5Host || null,
socks5Port: resolvedHost.socks5Port || null,
socks5Username: resolvedHost.socks5Username || null,
socks5Password: resolvedHost.socks5Password || null,
socks5ProxyChain: resolvedHost.socks5ProxyChain
? JSON.parse(resolvedHost.socks5ProxyChain as string)
: null,
portKnockSequence: resolvedHost.portKnockSequence
? JSON.parse(resolvedHost.portKnockSequence as string)
: null,
};
sshLogger.success("Host exported with decrypted credentials", {
operation: "host_export",
hostId: parseInt(hostId),
userId,
});
res.json(exportData);
} catch (err) {
sshLogger.error("Failed to export SSH host", err, {
operation: "host_export",
hostId: parseInt(hostId),
userId,
});
res.status(500).json({ error: "Failed to export SSH host" });
}
},
);
/**
* @openapi
* /host/db/hosts/export:
* get:
* summary: Export all SSH hosts
* description: Exports all SSH hosts for the current user. By default credentials are decrypted and embedded. With `share=1`, secrets are omitted and credential-authenticated hosts instead reference a scrubbed `credentials` array by alias, suitable for handing off to another user.
* tags:
* - SSH
* parameters:
* - in: query
* name: share
* required: false
* schema:
* type: string
* description: Set to "1" to export without embedded secrets.
* responses:
* 200:
* description: All exported SSH hosts.
* 400:
* description: Invalid userId.
* 500:
* description: Failed to export SSH hosts.
*/
router.get(
"/db/hosts/export",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const shareMode = req.query.share === "1" || req.query.share === "true";
if (!isNonEmptyString(userId)) {
return res.status(400).json({ error: "Invalid userId" });
}
try {
const allHosts =
await createCurrentHostResolutionRepository().findHostsByUserId(userId);
const pluginSettingsByHost = await loadHostPluginSettings(
allHosts.map((h) => h.id as number),
);
const exportedHosts = [];
const usedCredentialIds = new Set<number>();
for (const host of allHosts) {
const resolvedHost = shareMode
? host
: (await resolveHostCredentials(host, userId)) || host;
const hostPluginSettings = pluginSettingsByHost.get(host.id as number);
const exportedConnectionType =
(resolvedHost.connectionType as string) || "ssh";
const isRemoteDesktop = exportedConnectionType !== "ssh";
const baseExportData = {
exportId: resolvedHost.id,
connectionType: exportedConnectionType,
name: resolvedHost.name,
ip: resolvedHost.ip,
port: resolvedHost.port,
username: resolvedHost.username,
password: shareMode ? null : resolvedHost.password || null,
folder: resolvedHost.folder,
tags:
typeof resolvedHost.tags === "string"
? resolvedHost.tags.split(",").filter(Boolean)
: resolvedHost.tags || [],
pin: !!resolvedHost.pin,
notes: resolvedHost.notes || null,
// Every plugin's host settings, secrets redacted, for import to hand back.
pluginSettings: hostPluginSettings ?? {},
protocolAuth: shareableLogins(
toPortableLogins(
await listProtocolLogins(host.id as number, userId),
),
shareMode,
),
};
const exportData = isRemoteDesktop
? baseExportData
: {
...baseExportData,
authType: resolvedHost.authType,
key: shareMode ? null : resolvedHost.key || null,
keyPassword: shareMode ? null : resolvedHost.keyPassword || null,
keyType: resolvedHost.keyType || null,
credentialId: resolvedHost.credentialId || null,
overrideCredentialUsername:
!!resolvedHost.overrideCredentialUsername,
sudoPassword: shareMode
? null
: resolvedHost.sudoPassword || null,
jumpHosts: resolvedHost.jumpHosts
? JSON.parse(resolvedHost.jumpHosts as string)
: null,
terminalConfig:
hostTerminalExport(resolvedHost).terminalConfig ?? null,
sshOptions: hostTerminalExport(resolvedHost).sshOptions,
forceKeyboardInteractive:
resolvedHost.forceKeyboardInteractive === "true",
useSocks5: !!resolvedHost.useSocks5,
socks5Host: resolvedHost.socks5Host || null,
socks5Port: resolvedHost.socks5Port || null,
socks5Username: resolvedHost.socks5Username || null,
socks5Password: shareMode
? null
: resolvedHost.socks5Password || null,
socks5ProxyChain: resolvedHost.socks5ProxyChain
? JSON.parse(resolvedHost.socks5ProxyChain as string)
: null,
};
if (
shareMode &&
!isRemoteDesktop &&
resolvedHost.authType === "credential" &&
resolvedHost.credentialId
) {
usedCredentialIds.add(resolvedHost.credentialId as number);
}
exportedHosts.push(exportData);
}
if (!shareMode) {
sshLogger.success("All hosts exported with decrypted credentials", {
operation: "hosts_export_all",
count: exportedHosts.length,
userId,
});
return res.json({ hosts: exportedHosts });
}
const exportedCredentials: Record<string, unknown>[] = [];
if (usedCredentialIds.size > 0) {
const credentialRepository = createCurrentCredentialRepository();
const ownedCredentials =
await credentialRepository.listDecryptedByUserId(userId);
const credentialById = new Map(
ownedCredentials.map((credential) => [credential.id, credential]),
);
for (const host of exportedHosts as Record<string, unknown>[]) {
const credentialId = host.credentialId as number | null;
if (!credentialId) continue;
const credential = credentialById.get(credentialId);
if (!credential) continue;
host.credentialAlias = credential.name;
if (
!exportedCredentials.some(
(entry) => entry.alias === credential.name,
)
) {
exportedCredentials.push({
alias: credential.name,
name: credential.name,
description: credential.description || null,
folder: credential.folder || null,
tags:
typeof credential.tags === "string"
? credential.tags.split(",").filter(Boolean)
: [],
authType: credential.authType,
username: credential.username || null,
keyType: credential.keyType || null,
});
}
}
}
for (const host of exportedHosts as Record<string, unknown>[]) {
delete host.credentialId;
}
sshLogger.success("All hosts exported for sharing without secrets", {
operation: "hosts_export_all_share",
count: exportedHosts.length,
credentialCount: exportedCredentials.length,
userId,
});
res.json({
version: "1",
exportedAt: new Date().toISOString(),
credentials: exportedCredentials,
hosts: exportedHosts,
});
} catch (err) {
sshLogger.error("Failed to export all SSH hosts", err, {
operation: "hosts_export_all",
userId,
});
res.status(500).json({ error: "Failed to export SSH hosts" });
}
},
);
/**
* @openapi
* /host/db/host/{id}:
@@ -2051,6 +1730,7 @@ router.delete(
authenticateJWT,
permissionManager.requirePermission("hosts.delete"),
requireDataAccess,
requireUnlockedHost,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const hostId = Array.isArray(req.params.id)
@@ -2117,231 +1797,6 @@ router.delete(
// the file-manager plugin, under /plugin-api/file-manager/, and command
// history to the ssh-terminal plugin, under /plugin-api/ssh-terminal/.
/**
* A share export leaves out every secret and credential link, the same as
* it does for SSH.
*/
function shareableLogins(
logins: ReturnType<typeof toPortableLogins>,
shareMode: boolean,
): ReturnType<typeof toPortableLogins> {
if (!shareMode) return logins;
const out: ReturnType<typeof toPortableLogins> = {};
for (const [protocol, login] of Object.entries(logins)) {
const declared = findHostProtocol(protocol);
const secret = new Set(
(declared?.credentialFields ?? [])
.filter((field) => field.secret)
.map((field) => field.key),
);
out[protocol] = {
authType: login.authType === "credential" ? "direct" : login.authType,
credentialId: null,
username: login.username,
password: null,
fields: Object.fromEntries(
Object.entries(login.fields).filter(
([key]) => declared && !secret.has(key),
),
),
};
}
return out;
}
async function resolveHostCredentials(
host: Record<string, unknown>,
requestingUserId?: string,
/**
* Credentials already fetched for this request, keyed by id. The host list
* preloads them in one query; single-host callers omit it and fall back to
* fetching the one credential they need.
*/
preloadedCredentials?: Map<number, HostResolutionCredentialRecord>,
): Promise<Record<string, unknown>> {
try {
const ownerId = (host.ownerId || host.userId) as string | undefined;
if (
requestingUserId &&
ownerId &&
requestingUserId !== ownerId &&
typeof host.id === "number"
) {
const authHost = host as unknown as HostResolutionHostRecord;
const needsPersonalCredential = requiresPersonalHostAuthentication(
authHost,
"ssh",
);
const baseSshOverrideState = {
required: needsPersonalCredential,
ownerAuthShared: !!host.shareSshAuth,
};
// Owner auth for the remote desktop protocols is always snapshotted
// for recipients; only their own override credential varies per user.
const authOverrides: Record<string, unknown> = {
ssh: baseSshOverrideState,
};
const overrideCredentialIds =
await createCurrentSharedHostAuthOverrideRepository().listCredentialIds(
host.id,
requestingUserId,
);
// Whether a plugin protocol is on is its plugin's host setting; the
// client only offers an override for one that is.
for (const { id: protocol } of listHostProtocols()) {
authOverrides[protocol] = {
credentialId: overrideCredentialIds[protocol],
required: false,
ownerAuthShared: true,
};
}
const recipientHost: Record<string, unknown> = {
...host,
credentialId: null,
password: null,
key: null,
keyPassword: null,
keyType: null,
authOverrides,
};
try {
const resolution = await resolveRecipientSharedHostAuthentication(
authHost,
host.id,
requestingUserId,
"ssh",
);
if (resolution.source === "personal-override") {
const credential = resolution.credential;
return {
...recipientHost,
authOverrides: {
...authOverrides,
ssh: {
credentialId: resolution.credentialId,
required: false,
ownerAuthShared: !!host.shareSshAuth,
},
},
authType:
credential.key || credential.privateKey
? "key"
: credential.password
? "password"
: "none",
username: credential.username || recipientHost.username,
password: credential.password,
key: credential.privateKey || credential.key,
keyPassword: credential.keyPassword,
keyType: credential.keyType,
};
}
if (resolution.source === "owner-shared") {
if (resolution.authType === "agent") {
return {
...recipientHost,
authOverrides: {
...authOverrides,
ssh: {
required: false,
ownerAuthShared: true,
},
},
authType: "agent",
};
}
const sharedAuth = resolution.secret;
if (sharedAuth) {
const resolvedUsername = pickResolvedUsername(
recipientHost.username,
sharedAuth.username,
host.overrideCredentialUsername,
);
return {
...recipientHost,
authOverrides: {
...authOverrides,
ssh: {
required: false,
ownerAuthShared: true,
},
},
authType: sharedAuth.key
? "key"
: sharedAuth.password
? "password"
: "none",
username: resolvedUsername,
password: sharedAuth.password,
key: sharedAuth.key,
keyPassword: sharedAuth.keyPassword,
keyType: sharedAuth.keyType,
};
}
}
if (resolution.source === "secretless") {
return {
...recipientHost,
authOverrides: {
...authOverrides,
ssh: {
required: false,
ownerAuthShared: !!host.shareSshAuth,
},
},
};
}
} catch {
// A missing/deleted override or snapshot behaves like unavailable auth.
}
return recipientHost;
}
if (host.credentialId && (host.userId || host.ownerId)) {
const credentialId = host.credentialId as number;
const credentialOwnerId = (host.ownerId || host.userId) as string;
const credential =
preloadedCredentials?.get(credentialId) ??
(await findUsableCredential(credentialId, credentialOwnerId));
if (credential) {
const resolvedHost: Record<string, unknown> = {
...host,
password: pickResolvedPassword(host.password, credential.password),
key: credential.key,
keyPassword: credential.keyPassword,
keyType: credential.keyType,
};
const resolvedUsername = pickResolvedUsername(
host.username,
credential.username,
host.overrideCredentialUsername,
);
if (resolvedUsername !== undefined) {
resolvedHost.username = resolvedUsername;
}
return resolvedHost;
}
}
return { ...host };
} catch (error) {
sshLogger.warn(
`Failed to resolve credentials for host ${host.id}: ${getErrorMessage(error)}`,
);
return host;
}
}
registerHostFolderRoutes(router, {
authenticateJWT,
requireViewPermission: permissionManager.requirePermission("hosts.view"),
@@ -2355,7 +1810,6 @@ registerHostFolderRoutes(router, {
registerHostBulkRoutes(
router,
authenticateJWT,
permissionManager.requirePermission("hosts.create"),
permissionManager.requirePermission("hosts.edit"),
requireDataAccess,
);
+210
View File
@@ -0,0 +1,210 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import express, { type Request, type Response } from "express";
import { databaseLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
import {
createCurrentSettingsRepository,
createCurrentUserPreferenceRepository,
createCurrentUserRepository,
} from "../repositories/factory.js";
import { getPluginRuntime } from "../../plugins/index.js";
import {
LAYOUT_SETTING,
LOOK_ID_PATTERN,
THEME_SETTING,
installedLayoutIds,
resolveDefaultLook,
safeLayoutForced,
unavailableLooks,
} from "../../plugins/looks.js";
const router = express.Router();
const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
async function adminDefaults() {
const settings = createCurrentSettingsRepository();
return {
layout: await settings.get(LAYOUT_SETTING),
theme: await settings.get(THEME_SETTING),
};
}
/**
* @openapi
* /user-preferences/look:
* get:
* summary: Get the layout and theme to show the current user
* description: The user's own choice, the admin defaults (a layout no installed plugin brings reads as null), the default look of the bundled plugins, whether TERMIX_SAFE_LAYOUT forces the repair view, and installed layout or theme plugins that could not load. The browser resolves the final look from these.
* tags:
* - User Preferences
* responses:
* 200:
* description: What the look is chosen from.
* content:
* application/json:
* schema:
* type: object
* properties:
* layoutId:
* type: string
* nullable: true
* themeId:
* type: string
* nullable: true
* defaults:
* type: object
* description: Admin defaults, null where unset.
* properties:
* layout:
* type: string
* nullable: true
* theme:
* type: string
* nullable: true
* defaultLook:
* type: object
* description: The default-look plugin's layout and theme, null where none is installed.
* properties:
* layout:
* type: string
* nullable: true
* theme:
* type: string
* nullable: true
* safeLayout:
* type: boolean
* unavailable:
* type: array
* items:
* type: object
* properties:
* id:
* type: string
* version:
* type: string
* nullable: true
* layouts:
* type: array
* items:
* type: string
* themes:
* type: array
* items:
* type: string
* error:
* type: string
*/
router.get("/", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const preferences =
await createCurrentUserPreferenceRepository().findByUserId(userId);
const { loader } = getPluginRuntime();
const installed = installedLayoutIds(loader);
const known = (id: string | null | undefined) =>
id && installed.has(id) ? id : null;
const defaults = await adminDefaults();
res.json({
layoutId: known(preferences?.layoutId),
themeId: preferences?.themeId ?? null,
defaults: { ...defaults, layout: known(defaults.layout) },
defaultLook: resolveDefaultLook(loader),
safeLayout: safeLayoutForced(),
unavailable: unavailableLooks(loader),
});
} catch (error) {
databaseLogger.error("Failed to get the look", error, {
operation: "get_look",
userId,
});
res.status(500).json({ error: "Failed to get the look" });
}
});
/**
* @openapi
* /user-preferences/look/defaults:
* put:
* summary: Set the default layout and theme (admin only)
* description: What everyone sees until they choose their own. Null clears a default so the default look applies.
* tags:
* - User Preferences
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* layout:
* type: string
* nullable: true
* theme:
* type: string
* nullable: true
* responses:
* 200:
* description: The defaults now in effect.
* 400:
* description: Not a layout or theme id.
* 403:
* description: Not an admin.
*/
router.put(
"/defaults",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const actor = await createCurrentUserRepository().findById(userId);
if (!actor?.isAdmin) {
return res.status(403).json({ error: "Not authorized" });
}
const body = (req.body ?? {}) as { layout?: unknown; theme?: unknown };
const changes: Array<[string, unknown]> = [
[LAYOUT_SETTING, body.layout],
[THEME_SETTING, body.theme],
];
for (const [key, value] of changes) {
if (
value !== undefined &&
value !== null &&
(typeof value !== "string" || !LOOK_ID_PATTERN.test(value))
) {
return res
.status(400)
.json({ error: `${key} must be a layout or theme id, or null` });
}
}
const settings = createCurrentSettingsRepository();
for (const [key, value] of changes) {
if (value === undefined) continue;
if (value === null) await settings.delete(key);
else await settings.set(key, value as string);
}
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: actor.username ?? userId,
action: "update_default_look",
resourceType: "setting",
details: JSON.stringify({ layout: body.layout, theme: body.theme }),
ipAddress,
userAgent,
success: true,
});
res.json(await adminDefaults());
} catch (error) {
databaseLogger.error("Failed to set the default look", error, {
operation: "set_default_look",
userId,
});
res.status(500).json({ error: "Failed to set the default look" });
}
},
);
export default router;
+82 -3
View File
@@ -13,7 +13,9 @@ import {
createCurrentPluginRepository,
} from "../repositories/factory.js";
import { getPluginRuntime } from "../../plugins/index.js";
import { resolvePluginDesktopMode } from "@termix-ssh/plugin-sdk/manifest";
import { describePluginFrontend } from "../../plugins/assets.js";
import { isDefaultLookPlugin } from "../../plugins/bundled-index.js";
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
import { getPluginPublicHttpRoutes } from "../../plugins/http.js";
import { getPluginPublicWsRoutes } from "../../plugins/ws.js";
@@ -42,6 +44,7 @@ import {
validateSettingsSave,
type PluginSettingsScope,
} from "../../plugins/settings.js";
import { assertHostsUnlocked, sendHostLocked } from "../../hosts/locks.js";
import {
getAuditUsername,
getRequestMeta,
@@ -296,7 +299,9 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
let features: string[] = [];
let docs: string | undefined;
let env: PluginEnvVar[] = [];
let desktopMode: unknown = loaded?.manifest.desktop;
let desktopMode: unknown = loaded
? resolvePluginDesktopMode(loaded.manifest)
: undefined;
try {
const manifest = JSON.parse(record.manifestJson) as {
contributes?: unknown;
@@ -312,8 +317,12 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
dependencies?: Record<string, string>;
optionalDependencies?: Record<string, string>;
desktop?: unknown;
runtime?: unknown;
};
desktopMode = loaded?.manifest.desktop ?? manifest?.desktop;
desktopMode = loaded
? resolvePluginDesktopMode(loaded.manifest)
: (manifest?.desktop ??
(manifest?.runtime === "desktop" ? "local" : undefined));
contributes = manifest?.contributes ?? null;
capabilities = Array.isArray(manifest?.capabilities)
? (manifest.capabilities as string[])
@@ -352,6 +361,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
dependencies,
optionalDependencies,
docs,
defaultLook: isDefaultLookPlugin(record.id),
...describePluginFrontend(loaded),
};
@@ -1869,6 +1879,18 @@ router.put(
const inherit = Array.isArray(inheritRaw)
? inheritRaw.filter((key): key is string => typeof key === "string")
: [];
// A managed host keeps its settings; only personal ones stay per user.
const changesShared = [...Object.keys(written), ...inherit].some(
(key) => !findField(manifest, "host", key)?.personal,
);
if (changesShared) {
try {
assertHostsUnlocked([hostId]);
} catch (error) {
if (sendHostLocked(res, error)) return;
throw error;
}
}
const errors = await applySettings(
userId,
manifest,
@@ -2279,6 +2301,63 @@ router.post(
},
);
/**
* @openapi
* /plugins/{id}/restore-bundled:
* post:
* summary: Restore the bundled copy of a plugin
* description: >
* Drops any update downloaded over a plugin that ships with Termix and
* loads the copy from the image again, so a broken release can be rolled
* back from the layout repair view. The shipped version is pinned so
* auto update does not bring the release back. Dependents restart.
* tags:
* - Plugins
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* responses:
* 200:
* description: The restored version and the plugin's state.
* 400:
* description: Invalid plugin id.
* 403:
* description: The caller lacks admin.plugins.manage.
* 409:
* description: The plugin has no bundled copy, or a linked server manages it.
*/
router.post(
"/:id/restore-bundled",
authenticateJWT,
requireManagePlugins,
async (req: Request, res: Response) => {
const pluginId = String(req.params.id);
if (!ID_PATTERN.test(pluginId)) {
res.status(400).json({ error: "Invalid plugin id" });
return;
}
if (await refuseOnLinkedDesktop(res)) return;
try {
const { restoreBundledPlugin } = await import("../../plugins/manage.js");
const result = await restoreBundledPlugin(pluginId);
await auditPluginAction(req, "restore_bundled_plugin", pluginId, {
version: result.version,
});
res.json(result);
} catch (error) {
sendManageError(
res,
error,
"Failed to restore the bundled copy",
"plugin_restore_bundled",
);
}
},
);
/**
* @openapi
* /plugins/{id}:
@@ -2306,7 +2385,7 @@ router.post(
* 404:
* description: No such plugin.
* 409:
* description: Other running plugins depend on it, or managed by a linked server.
* description: Other running plugins depend on it, it is the default look, or a linked server manages it.
*/
router.delete(
"/:id",
+18
View File
@@ -9,6 +9,12 @@ import {
logAudit,
} from "../../utils/audit-logger.js";
import { isAuthOverrideProtocol } from "../../hosts/protocol-auth/registry.js";
import {
assertHostsUnlocked,
hasHostLocks,
requireUnlockedHost,
sendHostLocked,
} from "../../hosts/locks.js";
import {
SharedHostAuthOverrideService,
SharedHostAuthOverrideServiceError,
@@ -229,6 +235,7 @@ router.post(
"/host/:id/share",
authenticateJWT,
permissionManager.requirePermission("hosts.share"),
requireUnlockedHost,
async (req: AuthenticatedRequest, res: Response) => {
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
const hostId = parseInt(id, 10);
@@ -451,6 +458,14 @@ router.post(
}
try {
if (hasHostLocks()) {
const inFolder =
await createCurrentHostFolderRepository().listHostsInFolder(
userId,
folder,
);
assertHostsUnlocked(inFolder.map((host) => host.id));
}
const targets = parseShareTargets(req.body ?? {});
if (!targets) {
return res.status(400).json({
@@ -613,6 +628,7 @@ router.post(
hostResults,
});
} catch (error) {
if (sendHostLocked(res, error)) return;
databaseLogger.error("Failed to share folder", error, {
operation: "share_folder",
folder,
@@ -786,6 +802,7 @@ router.delete(
router.patch(
"/host/:id/access/:accessId",
authenticateJWT,
requireUnlockedHost,
async (req: AuthenticatedRequest, res: Response) => {
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
const accessIdParam = Array.isArray(req.params.accessId)
@@ -908,6 +925,7 @@ router.patch(
router.delete(
"/host/:id/access/:accessId",
authenticateJWT,
requireUnlockedHost,
async (req: AuthenticatedRequest, res: Response) => {
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
const accessIdParam = Array.isArray(req.params.accessId)
@@ -2,13 +2,13 @@ import type { Router } from "express";
import crypto from "crypto";
import bcrypt from "bcryptjs";
import { nanoid } from "nanoid";
import { recentActivity } from "../../plugins/activity-provider.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { authLogger } from "../../utils/logger.js";
import { loginRateLimiter } from "../../utils/login-rate-limiter.js";
import {
createCurrentCredentialRepository,
createCurrentHostRepository,
createCurrentRecentActivityRepository,
createCurrentSettingsRepository,
createCurrentSshCredentialUsageRepository,
createCurrentUserRepository,
@@ -68,7 +68,7 @@ export async function resetUserPassword(
await userRepository.update(userId, { passwordHash });
await createCurrentSshCredentialUsageRepository().deleteByUserId(userId);
await createCurrentRecentActivityRepository().deleteByUserId(userId);
await recentActivity.deleteByUserId(userId);
await createCurrentHostRepository().deleteByUserId(userId);
await createCurrentCredentialRepository().deleteByUserId(userId);
// A plugin holding this user's data (anything keyed by
@@ -32,8 +32,13 @@ const pickPreferences = (row?: UserPreferenceRecord | null) => ({
compactHostView: row?.compactHostView ?? null,
statusColorScheme: row?.statusColorScheme ?? null,
customKeybindings: row?.customKeybindings ?? null,
layoutId: row?.layoutId ?? null,
themeId: row?.themeId ?? null,
});
/** Layout and theme ids, the same shape the plugin manifest allows. */
const LOOK_ID = /^[a-z][a-z0-9-]{1,39}$/;
/**
* @openapi
* /user-preferences:
@@ -104,6 +109,14 @@ const pickPreferences = (row?: UserPreferenceRecord | null) => ({
* type: string
* nullable: true
* description: JSON-encoded array of the user's custom keybindings. An action's type is one of the shell's own or one a plugin declares in contributes.keybindingActions.
* layoutId:
* type: string
* nullable: true
* description: The layout this user chose, overriding the admin default. Null follows the default.
* themeId:
* type: string
* nullable: true
* description: The theme this user chose, overriding the admin default. Null follows the default.
*/
router.get("/", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
@@ -165,6 +178,14 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
* customKeybindings:
* type: string
* description: JSON-encoded array of the user's custom keybindings. Each action is checked against the shell's own types and the parameters a plugin declares for its type.
* layoutId:
* type: string
* nullable: true
* description: A layout id, or null to follow the admin default.
* themeId:
* type: string
* nullable: true
* description: A theme id, or null to follow the admin default.
* responses:
* 200:
* description: Preferences updated successfully.
@@ -186,6 +207,8 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
confirmTabClose,
hiddenRailTabs,
customKeybindings,
layoutId,
themeId,
} = req.body as {
reopenTabsOnLogin?: boolean;
theme?: string | null;
@@ -201,6 +224,8 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
confirmTabClose?: boolean | null;
hiddenRailTabs?: string | null;
customKeybindings?: string | null;
layoutId?: string | null;
themeId?: string | null;
};
// showHostTags, hostTrayOnClick, compactHostView, statusColorScheme are no
// longer writable here -- they moved to /host-sidebar/preferences as of the
@@ -258,6 +283,18 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
}
}
for (const [key, value] of Object.entries({ layoutId, themeId })) {
if (
value !== undefined &&
value !== null &&
(typeof value !== "string" || !LOOK_ID.test(value))
) {
return res
.status(400)
.json({ error: `${key} must be a layout or theme id, or null` });
}
}
const boolFields: Record<string, boolean | null | undefined> = {
commandPaletteEnabled,
pinAppRail,
@@ -290,6 +327,8 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
if (confirmTabClose !== undefined) updates.confirmTabClose = confirmTabClose;
if (customKeybindings !== undefined)
updates.customKeybindings = customKeybindings;
if (layoutId !== undefined) updates.layoutId = layoutId;
if (themeId !== undefined) updates.themeId = themeId;
if (Object.keys(updates).length === 1) {
return res.status(400).json({ error: "No preferences provided" });
@@ -6,11 +6,6 @@ import {
setGlobalLogLevel,
} from "../../utils/logger.js";
import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
import {
AUDIT_FORWARD_TOKEN_SETTING,
AUDIT_FORWARD_URL_ENV,
AUDIT_FORWARD_URL_SETTING,
} from "../../utils/audit-forwarder.js";
import {
NOTIFICATION_PRIVATE_ALLOWLIST_KEY,
parseNotificationAllowlist,
@@ -20,6 +15,8 @@ import {
createCurrentUserRepository,
} from "../repositories/factory.js";
import type { UserRecord } from "../repositories/user-repository.js";
import { isForceHttpsEnabled, setForceHttps } from "../../tls/https-policy.js";
import { getTlsConfig } from "../../tls/tls-service.js";
async function getAdminActor(
userId: string | undefined,
@@ -194,46 +191,39 @@ export function registerUserSettingsRoutes(
/**
* @openapi
* /users/audit-forwarding:
* /users/https-settings:
* get:
* summary: Get audit log forwarding settings (admin only)
* summary: Get the HTTPS redirect setting (admin only)
* description: Whether plain HTTP is redirected to HTTPS with HSTS, and whether HTTPS is on at all. The redirect only applies while HTTPS is on.
* tags:
* - Users
* responses:
* 200:
* description: Forwarding target. The token itself is never returned.
* description: forceHttps and tlsEnabled.
* 403:
* description: Not authorized.
*/
router.get("/audit-forwarding", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
router.get("/https-settings", authenticateJWT, async (req, res) => {
try {
if (!(await getAdminActor(userId))) {
if (!(await getAdminActor((req as AuthenticatedRequest).userId))) {
return res.status(403).json({ error: "Not authorized" });
}
const settingsRepository = createCurrentSettingsRepository();
const url =
(await settingsRepository.get(AUDIT_FORWARD_URL_SETTING)) ?? "";
const hasToken = !!(await settingsRepository.get(
AUDIT_FORWARD_TOKEN_SETTING,
));
res.json({
url,
hasToken,
envConfigured: !!process.env[AUDIT_FORWARD_URL_ENV]?.trim(),
forceHttps: await isForceHttpsEnabled(),
tlsEnabled: getTlsConfig().enabled,
});
} catch (err) {
authLogger.error("Failed to get audit forwarding settings", err);
res
.status(500)
.json({ error: "Failed to get audit forwarding settings" });
authLogger.error("Failed to get HTTPS settings", err);
res.status(500).json({ error: "Failed to get HTTPS settings" });
}
});
/**
* @openapi
* /users/audit-forwarding:
* /users/https-settings:
* patch:
* summary: Update audit log forwarding settings (admin only)
* description: Sets the collector URL audit entries are shipped to. An empty URL disables forwarding and clears the stored token. Omitting token keeps the stored one.
* summary: Update the HTTPS redirect setting (admin only)
* description: Turns the redirect from HTTP to HTTPS and the HSTS header on or off. Off by default so reverse proxies keep working.
* tags:
* - Users
* requestBody:
@@ -243,71 +233,43 @@ export function registerUserSettingsRoutes(
* schema:
* type: object
* properties:
* url:
* type: string
* token:
* type: string
* forceHttps:
* type: boolean
* responses:
* 200:
* description: Setting updated.
* 400:
* description: Invalid value.
* 403:
* description: Not authorized.
*/
router.patch("/audit-forwarding", authenticateJWT, async (req, res) => {
router.patch("/https-settings", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const actor = await getAdminActor(userId);
if (!actor) {
return res.status(403).json({ error: "Not authorized" });
}
const { url, token } = req.body ?? {};
if (
typeof url !== "string" ||
(token !== undefined && typeof token !== "string")
) {
return res.status(400).json({ error: "url must be a string" });
const { forceHttps } = req.body ?? {};
if (typeof forceHttps !== "boolean") {
return res.status(400).json({ error: "forceHttps must be a boolean" });
}
const trimmedUrl = url.trim();
if (trimmedUrl && !/^https?:\/\//i.test(trimmedUrl)) {
return res.status(400).json({ error: "url must be http(s)" });
}
const settingsRepository = createCurrentSettingsRepository();
if (!trimmedUrl) {
await settingsRepository.delete(AUDIT_FORWARD_URL_SETTING);
await settingsRepository.delete(AUDIT_FORWARD_TOKEN_SETTING);
} else {
await settingsRepository.set(AUDIT_FORWARD_URL_SETTING, trimmedUrl);
if (token !== undefined) {
if (token.trim()) {
await settingsRepository.set(
AUDIT_FORWARD_TOKEN_SETTING,
token.trim(),
);
} else {
await settingsRepository.delete(AUDIT_FORWARD_TOKEN_SETTING);
}
}
}
await setForceHttps(forceHttps);
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: actor.username ?? userId,
action: "update_audit_forwarding",
action: "update_force_https",
resourceType: "setting",
details: JSON.stringify({ enabled: !!trimmedUrl }),
details: JSON.stringify({ forceHttps }),
ipAddress,
userAgent,
success: true,
});
res.json({ url: trimmedUrl, hasToken: !!token?.trim() });
res.json({ forceHttps, tlsEnabled: getTlsConfig().enabled });
} catch (err) {
authLogger.error("Failed to update audit forwarding settings", err);
res
.status(500)
.json({ error: "Failed to update audit forwarding settings" });
authLogger.error("Failed to set HTTPS settings", err);
res.status(500).json({ error: "Failed to set HTTPS settings" });
}
});
@@ -61,4 +61,38 @@ export function registerUserSocketTicketRoutes(
res.status(500).json({ error: "Failed to issue a socket ticket" });
}
});
/**
* @openapi
* /users/ws-ticket/api-key:
* post:
* summary: Get a WebSocket ticket for an API key
* description: For another Termix server that manages this one with an API key. Returns a token that is valid for one minute and only opens plugin WebSockets, as the key's user. It cannot call any other route.
* tags:
* - Users
* responses:
* 200:
* description: The ticket.
* 401:
* description: The request did not use an API key.
* 500:
* description: Failed to issue the ticket.
*/
router.post("/ws-ticket/api-key", authenticateJWT, async (req, res) => {
const authReq = req as AuthenticatedRequest;
if (!authReq.apiKeyId) {
return res.status(401).json({ error: "An API key is required" });
}
try {
const ticket = await authManager.issueApiKeySocketTicket(
authReq.userId,
authReq.apiKeyId,
);
res.setHeader("Cache-Control", "no-store");
res.json({ ticket });
} catch (err) {
authLogger.error("Failed to issue an API key socket ticket", err);
res.status(500).json({ error: "Failed to issue a socket ticket" });
}
});
}
+42 -34
View File
@@ -27,6 +27,7 @@ import { registerUserSettingsRoutes } from "./user-settings-routes.js";
import { registerTlsRoutes } from "./tls-routes.js";
import { registerUserSessionRoutes } from "./user-session-routes.js";
import { registerUserSocketTicketRoutes } from "./user-socket-ticket-routes.js";
import { shownIdentity } from "../../sync/shown-identity.js";
import { registerUserExternalAccountRoutes } from "./user-external-account-routes.js";
import { registerUserPasswordResetRoutes } from "./user-password-reset-routes.js";
import { registerUserAdminRoutes } from "./user-admin-routes.js";
@@ -61,6 +62,7 @@ import {
syncSharedCredentialsForUserRoles,
} from "../../auth/session-issuer.js";
import { EXTERNAL_AUTO_PROVISION_KEY } from "../../auth/provisioning.js";
import { evaluateAuthPolicy } from "../../auth/policy.js";
const authManager = AuthManager.getInstance();
@@ -420,9 +422,21 @@ router.post("/proxy-login", async (req, res) => {
userRecord.id,
"trusted_proxy_login_role_shared_credentials",
);
// The proxy owns second factors, so only the session length applies.
const policy = await evaluateAuthPolicy({
userId: userRecord.id,
methodId: "proxy",
external: true,
deviceType: deviceInfo.type,
trustedDevice: false,
mfaSatisfied: false,
secondFactors: [],
});
const token = await authManager.generateJWTToken(userRecord.id, {
deviceType: deviceInfo.type,
deviceInfo: deviceInfo.deviceInfo,
maxAgeSeconds: policy.maxSessionSeconds,
idleTimeoutSeconds: policy.idleTimeoutSeconds,
});
const payload = await authManager.verifyJWTToken(token);
const { ipAddress, userAgent } = getRequestMeta(req);
@@ -443,7 +457,16 @@ router.post("/proxy-login", async (req, res) => {
});
return res
.cookie("jwt", token, authManager.getSecureCookieOptions(req))
.cookie(
"jwt",
token,
authManager.getSecureCookieOptions(
req,
policy.maxSessionSeconds
? Math.min(24 * 60 * 60 * 1000, policy.maxSessionSeconds * 1000)
: undefined,
),
)
.json({
enabled: true,
success: true,
@@ -546,30 +569,6 @@ router.post("/logout", authenticateJWT, async (req, res) => {
}
});
/**
* On a desktop linked to a server, the account it is signed in to there.
* That account is who the user is; the local profile only mirrors it.
*/
async function describeDesktopLink(userId: string) {
if (process.env.ELECTRON_EMBEDDED !== "true") return null;
try {
const { getLink } = await import("../../sync/client/link-store.js");
const link = await getLink();
if (!link || link.userId !== userId) return null;
return {
serverUrl: link.serverUrl,
serverName: link.serverName,
username: link.account?.username ?? link.remoteUsername,
isAdmin: !!link.account?.isAdmin,
roles: link.account?.roles ?? [],
permissions: link.account?.permissions ?? [],
status: link.status,
};
} catch {
return null;
}
}
/**
* @openapi
* /users/me:
@@ -602,18 +601,21 @@ router.get("/me", authenticateJWT, async (req: Request, res: Response) => {
const hasPassword = user.passwordHash && user.passwordHash.trim() !== "";
const isDualAuth = hasPassword && isExternalAccount(user);
const shown = await shownIdentity(user);
res.json({
userId: user.id,
username: user.username,
is_admin: !!user.isAdmin,
username: shown.username,
is_admin: shown.isAdmin,
is_external: isExternalAccount(user),
is_dual_auth: isDualAuth,
// Any second factor; the name is what 2.8 clients read.
totp_enabled: await createCurrentUserAuthRepository().hasSecondFactor(
user.id,
),
linked: await describeDesktopLink(user.id),
linked: shown.linked,
// "second-factor-setup" while the login policy wants a factor set up.
restriction: (req as AuthenticatedRequest).sessionRestriction ?? null,
});
} catch (err) {
authLogger.error("Failed to get username", err);
@@ -719,7 +721,11 @@ router.post("/internal/auto-session", async (req, res) => {
const userRepository = createCurrentUserRepository();
const allUsers = await userRepository.listAll();
const userRecord = resolveDesktopAutoSessionUser(allUsers);
const { getSavedActiveUserId } = await import("../../sync/accounts.js");
const userRecord = resolveDesktopAutoSessionUser(
allUsers,
await getSavedActiveUserId(),
);
if (!userRecord) {
return res.status(403).json({
error: "No local users exist",
@@ -730,10 +736,11 @@ router.post("/internal/auto-session", async (req, res) => {
if (existingToken) {
const existingPayload = await authManager.verifyJWTToken(existingToken);
if (existingPayload?.userId === userRecord.id) {
const shownNow = await shownIdentity(userRecord);
return res.json({
success: true,
is_admin: !!userRecord.isAdmin,
username: userRecord.username,
is_admin: shownNow.isAdmin,
username: shownNow.username,
token: existingToken,
});
}
@@ -745,10 +752,11 @@ router.post("/internal/auto-session", async (req, res) => {
rememberMe: true,
});
const shown = await shownIdentity(userRecord);
const response = {
success: true,
is_admin: !!userRecord.isAdmin,
username: userRecord.username,
is_admin: shown.isAdmin,
username: shown.username,
token,
};
@@ -1543,7 +1551,7 @@ registerUserSocketTicketRoutes(router, { authenticateJWT, authManager });
registerTlsRoutes(router, authenticateJWT);
registerUserApiKeyRoutes(router, requireAdmin);
registerBrandingRoutes(router, requireAdmin);
registerBrandingRoutes(router);
registerAuthRoutes(router);
registerAuthCompatRoutes(router);
@@ -0,0 +1,51 @@
/**
* Other ways to reach a host, provided by plugins through
* ctx.ssh.transports.provide. A provider claims some hosts by id and hands the
* pipeline an open stream to them instead of a TCP connection, for example a
* relay through another Termix server. The first provider that claims a host
* wins, and nothing else (jump hosts, proxies) is tried for it.
*/
import type { Duplex } from "node:stream";
import type { PluginSshTransportProvider } from "@termix-ssh/plugin-sdk/backend";
interface Entry {
pluginId: string;
provider: PluginSshTransportProvider;
}
const providers = new Map<symbol, Entry>();
export function provideSshTransport(
pluginId: string,
provider: PluginSshTransportProvider,
): () => void {
const key = Symbol(pluginId);
providers.set(key, { pluginId, provider });
return () => {
providers.delete(key);
};
}
/** Test seam. */
export function clearSshTransports(): void {
providers.clear();
}
/** Opens a stream to the host if a provider claims it, else null. */
export async function openProvidedTransport(host: {
id: number;
ip: string;
port: number;
}): Promise<Duplex | null> {
for (const { provider } of providers.values()) {
let claimed = false;
try {
claimed = provider.claims(host.id);
} catch {
claimed = false;
}
if (claimed) return provider.open(host);
}
return null;
}
+14 -3
View File
@@ -1,6 +1,6 @@
/**
* How the bytes get to a host: port knocking, a jump host chain or a SOCKS5
* proxy, in that order. Sets config.sock and
* How the bytes get to a host: a transport a plugin provides, else port
* knocking, a jump host chain or a SOCKS5 proxy, in that order. Sets config.sock and
* returns the jump client so the caller can close it with the connection.
*/
@@ -13,6 +13,7 @@ import {
} from "../../utils/socks5-helper.js";
import type { ProxyNode } from "../../../types/index.js";
import { createJumpHostChain } from "../jump-host-chain.js";
import { openProvidedTransport } from "./transport-providers.js";
import { resolveSshConnectConfigHost } from "../ssh-dns.js";
import { performPortKnocking } from "../terminal-auth-helpers.js";
import type {
@@ -59,7 +60,7 @@ export interface OpenTransportOptions {
export interface OpenedTransport {
jumpClient: Client | null;
via: "direct" | "proxy" | "jump";
via: "direct" | "proxy" | "jump" | "provided";
}
function forwardThrough(
@@ -94,6 +95,16 @@ export async function openSshTransport(
config: MutableConnectConfig,
options: OpenTransportOptions = {},
): Promise<OpenedTransport> {
const provided = await openProvidedTransport({
id: host.id,
ip: host.ip,
port: host.port || 22,
});
if (provided) {
config.sock = provided;
return { jumpClient: null, via: "provided" };
}
if (
options.portKnock !== false &&
Array.isArray(host.portKnockSequence) &&
+2 -2
View File
@@ -2,10 +2,10 @@ import {
createCurrentHostRepository,
createCurrentHostResolutionRepository,
createCurrentRbacAccessRepository,
createCurrentRecentActivityRepository,
createCurrentSshCredentialUsageRepository,
} from "../database/repositories/factory.js";
import { pluginEvents, TOPICS } from "../plugins/events.js";
import { recentActivity } from "../plugins/activity-provider.js";
import { sshLogger } from "../utils/logger.js";
import { emitInternalEvent } from "./internal-events.js";
@@ -33,7 +33,7 @@ export async function deleteOwnedHost(
// Plugin rows tied to a host cascade on their refHost() foreign keys.
await createCurrentSshCredentialUsageRepository().deleteByHostId(hostId);
await createCurrentRecentActivityRepository().deleteByHostId(hostId);
await recentActivity.deleteByHostId(hostId);
await createCurrentRbacAccessRepository().deleteHostAccessForHost(hostId);
await createCurrentHostRepository().deleteForUser(userId, hostId);
+213
View File
@@ -0,0 +1,213 @@
import {
createCurrentCredentialRepository,
createCurrentHostResolutionRepository,
} from "../../database/repositories/factory.js";
import { hostTerminalExport } from "../../database/routes/host-normalizers.js";
import { loadHostPluginSettings } from "../../database/routes/host-plugin-settings.js";
import {
listProtocolLogins,
toPortableLogins,
} from "../protocol-auth/protocol-auth.js";
import { findHostProtocol } from "../protocol-auth/registry.js";
import { resolveHostCredentials } from "../resolve-host-credentials.js";
/**
* A share export leaves out every secret and credential link, the same as
* it does for SSH.
*/
function shareableLogins(
logins: ReturnType<typeof toPortableLogins>,
shareMode: boolean,
): ReturnType<typeof toPortableLogins> {
if (!shareMode) return logins;
const out: ReturnType<typeof toPortableLogins> = {};
for (const [protocol, login] of Object.entries(logins)) {
const declared = findHostProtocol(protocol);
const secret = new Set(
(declared?.credentialFields ?? [])
.filter((field) => field.secret)
.map((field) => field.key),
);
out[protocol] = {
authType: login.authType === "credential" ? "direct" : login.authType,
credentialId: null,
username: login.username,
password: null,
fields: Object.fromEntries(
Object.entries(login.fields).filter(
([key]) => declared && !secret.has(key),
),
),
};
}
return out;
}
type Row = Record<string, unknown>;
/** One host as the Termix JSON export carries it. */
async function exportRow(
host: Row,
userId: string,
shareMode: boolean,
pluginSettings: Awaited<ReturnType<typeof loadHostPluginSettings>>,
): Promise<{ data: Row; isRemoteDesktop: boolean; resolved: Row }> {
const resolvedHost = shareMode
? host
: (await resolveHostCredentials(host, userId)) || host;
const connectionType = (resolvedHost.connectionType as string) || "ssh";
const isRemoteDesktop = connectionType !== "ssh";
const base = {
exportId: resolvedHost.id,
connectionType,
name: resolvedHost.name,
ip: resolvedHost.ip,
port: resolvedHost.port,
username: resolvedHost.username,
password: shareMode ? null : resolvedHost.password || null,
folder: resolvedHost.folder,
tags:
typeof resolvedHost.tags === "string"
? resolvedHost.tags.split(",").filter(Boolean)
: resolvedHost.tags || [],
pin: !!resolvedHost.pin,
notes: resolvedHost.notes || null,
// Every plugin's host settings, secrets redacted, for import to hand back.
pluginSettings: pluginSettings.get(host.id as number) ?? {},
// Each plugin protocol's login, secrets included like the SSH ones.
protocolAuth: shareableLogins(
toPortableLogins(await listProtocolLogins(host.id as number, userId)),
shareMode,
),
};
if (isRemoteDesktop) {
return { data: base, isRemoteDesktop, resolved: resolvedHost };
}
const terminal = hostTerminalExport(resolvedHost);
const data = {
...base,
authType: resolvedHost.authType,
key: shareMode ? null : resolvedHost.key || null,
keyPassword: shareMode ? null : resolvedHost.keyPassword || null,
keyType: resolvedHost.keyType || null,
credentialId: resolvedHost.credentialId || null,
overrideCredentialUsername: !!resolvedHost.overrideCredentialUsername,
sudoPassword: shareMode ? null : resolvedHost.sudoPassword || null,
jumpHosts: resolvedHost.jumpHosts
? JSON.parse(resolvedHost.jumpHosts as string)
: null,
terminalConfig: terminal.terminalConfig ?? null,
sshOptions: terminal.sshOptions,
forceKeyboardInteractive: resolvedHost.forceKeyboardInteractive === "true",
useSocks5: !!resolvedHost.useSocks5,
socks5Host: resolvedHost.socks5Host || null,
socks5Port: resolvedHost.socks5Port || null,
socks5Username: resolvedHost.socks5Username || null,
socks5Password: shareMode ? null : resolvedHost.socks5Password || null,
socks5ProxyChain: resolvedHost.socks5ProxyChain
? JSON.parse(resolvedHost.socks5ProxyChain as string)
: null,
portKnockSequence: resolvedHost.portKnockSequence
? JSON.parse(resolvedHost.portKnockSequence as string)
: null,
};
return { data, isRemoteDesktop, resolved: resolvedHost };
}
export interface HostExport {
hosts: Row[];
/** Share mode only. */
version?: string;
exportedAt?: string;
credentials?: Row[];
}
/**
* All of the user's own hosts. Without shareMode credentials are decrypted and
* embedded. With it every secret is left out and credential logins point at a
* scrubbed credentials list by alias.
*/
export async function exportAllHosts(
userId: string,
shareMode: boolean,
hostIds?: number[],
): Promise<HostExport> {
const owned =
await createCurrentHostResolutionRepository().findHostsByUserId(userId);
const wanted = hostIds ? new Set(hostIds) : null;
const allHosts = wanted
? owned.filter((host) => wanted.has(host.id as number))
: owned;
const settings = await loadHostPluginSettings(
allHosts.map((h) => h.id as number),
);
const hosts: Row[] = [];
const usedCredentialIds = new Set<number>();
for (const host of allHosts) {
const { data, isRemoteDesktop, resolved } = await exportRow(
host as unknown as Row,
userId,
shareMode,
settings,
);
if (
shareMode &&
!isRemoteDesktop &&
resolved.authType === "credential" &&
resolved.credentialId
) {
usedCredentialIds.add(resolved.credentialId as number);
}
hosts.push(data);
}
if (!shareMode) return { hosts };
const credentials: Row[] = [];
if (usedCredentialIds.size > 0) {
const owned =
await createCurrentCredentialRepository().listDecryptedByUserId(userId);
const byId = new Map(
owned.map((credential) => [credential.id, credential]),
);
for (const host of hosts) {
const credentialId = host.credentialId as number | null;
if (!credentialId) continue;
const credential = byId.get(credentialId);
if (!credential) continue;
host.credentialAlias = credential.name;
if (!credentials.some((entry) => entry.alias === credential.name)) {
credentials.push({
alias: credential.name,
name: credential.name,
description: credential.description || null,
folder: credential.folder || null,
tags:
typeof credential.tags === "string"
? credential.tags.split(",").filter(Boolean)
: [],
authType: credential.authType,
username: credential.username || null,
keyType: credential.keyType || null,
});
}
}
}
for (const host of hosts) delete host.credentialId;
return {
version: "1",
exportedAt: new Date().toISOString(),
credentials,
hosts,
};
}
+855
View File
@@ -0,0 +1,855 @@
import {
prepareHostImports,
remapImportedJumpHosts,
} from "../../database/routes/host-import-order.js";
import { sshOptionsForWrite } from "../ssh-options.js";
import {
applyDefaultsAfterHostWrites,
applyHostDefaultsToWrite,
} from "../defaults/index.js";
import {
keepUsableProtocolCredentials,
readProtocolAuthPayload,
writeProtocolAuth,
} from "../protocol-auth/protocol-auth.js";
import { getErrorMessage } from "../../utils/error-message.js";
import { pluginEvents, TOPICS } from "../../plugins/events.js";
import {
createCurrentCredentialRepository,
createCurrentHostRepository,
createCurrentHostResolutionRepository,
createCurrentHostDefaultsRepository,
} from "../../database/repositories/factory.js";
import {
listSshAuthProviders,
listSshAuthTypeOwners,
} from "../connect/auth-provider-registry.js";
import { applyPluginHostImportSettings } from "../../database/routes/host-plugin-settings.js";
import {
isNonEmptyString,
isValidPort,
normalizeImportedHost,
} from "../../database/routes/host-normalizers.js";
import { hasHostLocks, hostLock, manualCreateBlockedBy } from "../locks.js";
/** A request the caller got wrong. Routes answer it with a 400. */
export class ImportError extends Error {
constructor(message: string) {
super(message);
this.name = "ImportError";
}
}
export interface ImportResults {
success: number;
updated: number;
skipped: number;
failed: number;
errors: string[];
/** Hosts this call created. */
createdHostIds: number[];
/** True when atomic was set and a failure undid the creates. */
rolledBack?: boolean;
}
export interface BulkImportInput {
hosts: unknown;
overwrite?: unknown;
credentials?: unknown;
/** All or nothing: any failure deletes what this call created. */
atomic?: boolean;
}
type SSHConfigHost = {
name: string;
hostname?: string;
user?: string;
port?: number;
identityFile?: string;
proxyJump?: string;
};
type ShareCredential = {
alias?: unknown;
name?: unknown;
description?: unknown;
folder?: unknown;
tags?: unknown;
authType?: unknown;
username?: unknown;
keyType?: unknown;
};
function textValue(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function tagString(value: unknown): string {
if (Array.isArray(value)) {
return value
.map((tag) => textValue(tag))
.filter((tag): tag is string => !!tag)
.join(",");
}
return textValue(value) || "";
}
function normalizeCredentialAuthType(value: unknown): "password" | "key" {
return value === "key" ? "key" : "password";
}
export function parseSSHConfig(content: string): SSHConfigHost[] {
const results: SSHConfigHost[] = [];
let current: SSHConfigHost | null = null;
for (const rawLine of content.split("\n")) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) continue;
const match = /^(\S+?)(?:\s*=\s*|\s+)(.+)$/.exec(line);
if (!match) continue;
const key = match[1].toLowerCase();
const value = match[2];
if (key === "host") {
if (current && current.hostname) results.push(current);
// Skip wildcard patterns
if (value === "*" || value.includes("*") || value.includes("?")) {
current = null;
} else {
current = { name: value };
}
continue;
}
if (!current) continue;
switch (key) {
case "hostname":
current.hostname = value;
break;
case "user":
current.user = value;
break;
case "port": {
const p = Number.parseInt(value, 10);
if (p > 0 && p <= 65535) current.port = p;
break;
}
case "identityfile":
if (!current.identityFile) current.identityFile = value;
break;
case "proxyjump":
current.proxyJump = value;
break;
}
}
if (current && current.hostname) results.push(current);
return results;
}
/**
* Tells status checks and other pollers about hosts an import wrote, the way
* a single host create or update does.
*/
export function notifyImportedHosts(
userId: string,
hostIds: number[],
createdHostIds: number[],
): void {
for (const hostId of createdHostIds) {
pluginEvents.emit(TOPICS.hostCreated, { hostId, userId });
}
for (const hostId of hostIds) {
pluginEvents.emit(TOPICS.hostUpdated, { hostId, userId });
}
}
export function importedHostUsername(
connectionType: string,
authType: unknown,
username: unknown,
): string | null {
if (isNonEmptyString(username)) return username;
if (connectionType !== "ssh" || authType === "credential") return "";
return null;
}
/**
* Status check fields from an import row. Exports from before 2.9.0 carried
* them inside statsConfig.
*/
function importedStatusCheck(raw: Record<string, unknown>): {
statusCheckEnabled: boolean;
statusCheckInterval: number | null;
} {
const legacy =
raw.statsConfig && typeof raw.statsConfig === "object"
? (raw.statsConfig as Record<string, unknown>)
: {};
const enabled = raw.statusCheckEnabled ?? legacy.statusCheckEnabled;
const useGlobal = legacy.useGlobalStatusInterval !== false;
const interval =
raw.statusCheckInterval ?? (useGlobal ? null : legacy.statusCheckInterval);
const seconds = Number(interval);
return {
statusCheckEnabled: enabled !== false && legacy.disableTcpPing !== true,
statusCheckInterval:
interval != null && Number.isInteger(seconds) && seconds >= 5
? seconds
: null,
};
}
const BUILTIN_SSH_AUTH_TYPES = [
"password",
"key",
"credential",
"agent",
"none",
];
/** Core's own types plus every type a plugin registers or declares. */
function listKnownAuthTypes(): Set<string> {
return new Set([
...BUILTIN_SSH_AUTH_TYPES,
...listSshAuthProviders().map((provider) => provider.type),
...listSshAuthTypeOwners().map((owner) => owner.type),
]);
}
/** Fills an imported host's inherited keys and records what it sets itself. */
async function withHostDefaults(
userId: string,
hostId: number | null,
columns: Record<string, unknown>,
body: Record<string, unknown>,
): Promise<void> {
const stored =
hostId === null
? null
: ((
await createCurrentHostDefaultsRepository().listHosts({
hostIds: [hostId],
})
)[0] ?? null);
columns.defaultOverrides = JSON.stringify(
await applyHostDefaultsToWrite({
ownerId: userId,
hostId,
columns,
body,
stored,
}),
);
}
export function importSummary(results: ImportResults): string {
return `Import completed: ${results.success} created, ${results.updated} updated, ${results.skipped} skipped, ${results.failed} failed`;
}
export function importResponse(results: ImportResults) {
return {
message: importSummary(results),
success: results.success,
updated: results.updated,
skipped: results.skipped,
failed: results.failed,
errors: results.errors,
};
}
export const MAX_IMPORT_HOSTS = 100;
/** Why an import row can't be written while a plugin manages hosts, if it can't. */
function importLockError(existingId: number | undefined): string | null {
if (!hasHostLocks()) return null;
if (existingId === undefined) {
const blocked = manualCreateBlockedBy();
return blocked
? `Hosts are managed by ${blocked.pluginName}, so new hosts can't be added`
: null;
}
const lock = hostLock(existingId);
return lock ? `This host is managed by ${lock.pluginName}` : null;
}
/** Deletes what an atomic import created, newest first. */
async function rollBackCreated(
userId: string,
results: ImportResults,
): Promise<void> {
const repository = createCurrentHostRepository();
for (const hostId of [...results.createdHostIds].reverse()) {
await repository.deleteForUser(userId, hostId).catch(() => {});
}
results.rolledBack = true;
results.success = 0;
results.createdHostIds = [];
}
/**
* The Termix JSON import: shared by POST /host/bulk-import and
* ctx.hosts.importMany. Callers wrap it in DatabaseSaveTrigger.batched.
*/
export async function runBulkImport(
userId: string,
input: BulkImportInput,
): Promise<ImportResults> {
const {
hosts: hostsToImport,
overwrite,
credentials: credentialsToImport,
} = input;
if (input.atomic && overwrite) {
throw new ImportError("An atomic import cannot overwrite existing hosts");
}
if (!Array.isArray(hostsToImport) || hostsToImport.length === 0) {
throw new ImportError("Hosts array is required and must not be empty");
}
if (hostsToImport.length > 100) {
throw new ImportError("Maximum 100 hosts allowed per import");
}
let orderedHosts: ReturnType<typeof prepareHostImports>;
try {
orderedHosts = prepareHostImports(hostsToImport);
} catch (error) {
throw new ImportError(getErrorMessage(error));
}
const importedIds = new Map<unknown, number>();
const results: ImportResults = {
success: 0,
updated: 0,
skipped: 0,
failed: 0,
errors: [],
createdHostIds: [],
};
const credentialAliasMap = new Map<string, number>();
const addCredentialAlias = (alias: unknown, id: number) => {
const key = textValue(alias);
if (key) credentialAliasMap.set(key.toLowerCase(), id);
};
try {
const credentialRepository = createCurrentCredentialRepository();
const existingCredentials =
await credentialRepository.listDecryptedByUserId(userId);
for (const credential of existingCredentials) {
addCredentialAlias(credential.name, credential.id as number);
}
if (Array.isArray(credentialsToImport)) {
for (const rawCredential of credentialsToImport as ShareCredential[]) {
const alias = textValue(rawCredential.alias);
const name = textValue(rawCredential.name) || alias;
if (!alias || !name) continue;
const existingId = credentialAliasMap.get(name.toLowerCase());
if (existingId) {
addCredentialAlias(alias, existingId);
continue;
}
const now = new Date().toISOString();
const created = await credentialRepository.createEncryptedForUser(
userId,
{
userId,
name,
description:
textValue(rawCredential.description) ||
"Imported placeholder. Add the secret before connecting.",
folder: textValue(rawCredential.folder),
tags: tagString(rawCredential.tags),
authType: normalizeCredentialAuthType(rawCredential.authType),
username: textValue(rawCredential.username),
password: null,
key: null,
privateKey: null,
publicKey: null,
keyPassword: null,
keyType: textValue(rawCredential.keyType),
detectedKeyType: null,
usageCount: 0,
lastUsed: null,
createdAt: now,
updatedAt: now,
},
);
const createdCredential = created as Record<string, unknown>;
addCredentialAlias(alias, createdCredential.id as number);
addCredentialAlias(name, createdCredential.id as number);
}
}
} catch (error) {
results.errors.push(
`Credential placeholders: ${getErrorMessage(error, "failed to prepare credential aliases")}`,
);
}
// Loaded for both modes: overwrite updates a match, skip leaves it alone.
let existingHostMap: Map<string, { id: number }> | undefined;
const hostRepository = createCurrentHostRepository();
try {
const allHosts =
await createCurrentHostResolutionRepository().findHostsByUserId(userId);
existingHostMap = new Map();
for (const h of allHosts) {
const key = `${h.ip}:${h.port}:${h.username}`;
existingHostMap.set(key, { id: h.id as number });
}
} catch {
existingHostMap = undefined;
}
const knownAuthTypes = listKnownAuthTypes();
const writtenHostIds: number[] = [];
const createdHostIds: number[] = [];
for (const { host: hostData, index: i, exportId } of orderedHosts) {
try {
const effectiveConnectionType = hostData.connectionType || "ssh";
if (
effectiveConnectionType === "ssh" &&
hostData.authType === "credential" &&
!hostData.credentialId &&
hostData.credentialAlias
) {
hostData.credentialId = credentialAliasMap.get(
hostData.credentialAlias.toLowerCase(),
);
}
if (!isNonEmptyString(hostData.ip) || !isValidPort(hostData.port)) {
results.failed++;
results.errors.push(
`Host ${i + 1}: Missing required fields (ip, port)`,
);
continue;
}
const username = importedHostUsername(
effectiveConnectionType,
hostData.authType,
hostData.username,
);
if (username === null) {
results.failed++;
results.errors.push(
`Host ${i + 1}: Username required for SSH connections`,
);
continue;
}
const lookupKey = `${hostData.ip}:${hostData.port}:${hostData.username}`;
const existing = existingHostMap?.get(lookupKey);
if (existing && !overwrite) {
// Hosts that jump through this one still resolve to the kept host.
if (exportId !== undefined) importedIds.set(exportId, existing.id);
results.skipped++;
continue;
}
const locked = importLockError(existing?.id);
if (locked) {
results.failed++;
results.errors.push(`Host ${i + 1}: ${locked}`);
continue;
}
if (
effectiveConnectionType === "ssh" &&
hostData.authType &&
!knownAuthTypes.has(hostData.authType)
) {
results.failed++;
results.errors.push(
`Host ${i + 1}: Invalid authType. Must be one of ${[...knownAuthTypes].join(", ")}`,
);
continue;
}
if (
effectiveConnectionType === "ssh" &&
hostData.authType === "password" &&
!isNonEmptyString(hostData.password)
) {
results.failed++;
results.errors.push(
`Host ${i + 1}: Password required for password authentication`,
);
continue;
}
if (
effectiveConnectionType === "ssh" &&
hostData.authType === "key" &&
!isNonEmptyString(hostData.key)
) {
results.failed++;
results.errors.push(
`Host ${i + 1}: Key required for key authentication`,
);
continue;
}
if (
effectiveConnectionType === "ssh" &&
hostData.authType === "credential" &&
!hostData.credentialId
) {
results.failed++;
results.errors.push(
`Host ${i + 1}: credentialId required for credential authentication`,
);
continue;
}
if (
effectiveConnectionType === "ssh" &&
hostData.authType === "credential" &&
hostData.credentialId
) {
const credentialRepository = createCurrentCredentialRepository();
const cred = await credentialRepository.findByIdForUser(
userId,
hostData.credentialId,
);
if (!cred) {
const fallback = await credentialRepository.listByUserId(userId);
if (fallback.length > 0) {
hostData.credentialId = fallback[0].id;
} else if (isNonEmptyString(hostData.key)) {
hostData.authType = "key";
hostData.credentialId = undefined;
} else if (isNonEmptyString(hostData.password)) {
hostData.authType = "password";
hostData.credentialId = undefined;
} else {
results.failed++;
results.errors.push(
`Host ${i + 1}: credentialId ${hostData.credentialId} not found and no fallback credential available`,
);
continue;
}
}
}
const jumpHosts = remapImportedJumpHosts(hostData.jumpHosts, importedIds);
const sshDataObj: Record<string, unknown> = {
userId: userId,
connectionType: effectiveConnectionType,
name: hostData.name || `${username}@${hostData.ip}`,
folder: hostData.folder || "Default",
tags: Array.isArray(hostData.tags) ? hostData.tags.join(",") : "",
ip: hostData.ip,
port: hostData.port,
username,
pin: hostData.pin || false,
sudoPassword: hostData.sudoPassword || null,
jumpHosts: jumpHosts ? JSON.stringify(jumpHosts) : null,
...importedStatusCheck(hostData as Record<string, unknown>),
// A 2.8 export carries these inside terminalConfig.
sshOptions:
sshOptionsForWrite({
sshOptions: hostData.sshOptions,
terminalConfig: hostData.terminalConfig,
}) ?? null,
forceKeyboardInteractive: hostData.forceKeyboardInteractive
? "true"
: "false",
notes: hostData.notes || null,
useSocks5: hostData.useSocks5 ? 1 : 0,
socks5Host: hostData.socks5Host || null,
socks5Port: hostData.socks5Port || null,
socks5Username: hostData.socks5Username || null,
socks5Password: hostData.socks5Password || null,
socks5ProxyChain: hostData.socks5ProxyChain
? JSON.stringify(hostData.socks5ProxyChain)
: null,
portKnockSequence: hostData.portKnockSequence
? JSON.stringify(hostData.portKnockSequence)
: null,
overrideCredentialUsername: hostData.overrideCredentialUsername ? 1 : 0,
enableSsh: hostData.enableSsh ?? effectiveConnectionType === "ssh",
updatedAt: new Date().toISOString(),
};
if (effectiveConnectionType !== "ssh") {
sshDataObj.password = hostData.password || null;
sshDataObj.authType = "password";
sshDataObj.credentialId = null;
sshDataObj.key = null;
sshDataObj.keyPassword = null;
sshDataObj.keyType = null;
} else {
sshDataObj.password =
hostData.authType === "password" ? hostData.password : null;
sshDataObj.authType = hostData.authType || "password";
sshDataObj.credentialId =
hostData.authType === "credential" ? hostData.credentialId : null;
sshDataObj.key = hostData.authType === "key" ? hostData.key : null;
sshDataObj.keyPassword =
hostData.authType === "key" ? hostData.keyPassword || null : null;
sshDataObj.keyType =
hostData.authType === "key" ? hostData.keyType || "auto" : null;
}
await withHostDefaults(
userId,
existing?.id ?? null,
sshDataObj,
hostData as Record<string, unknown>,
);
let savedHostId: number;
if (existing) {
const saved = await hostRepository.updateEncryptedForUser(
userId,
existing.id,
sshDataObj,
);
if (!saved) throw new Error("Host no longer exists");
if (exportId !== undefined) importedIds.set(exportId, existing.id);
savedHostId = existing.id;
results.updated++;
} else {
sshDataObj.createdAt = new Date().toISOString();
const saved = await hostRepository.createEncryptedForUser(
userId,
sshDataObj,
);
if (exportId !== undefined) importedIds.set(exportId, saved.id);
savedHostId = saved.id;
createdHostIds.push(saved.id);
results.success++;
}
const protocolAuth = readProtocolAuthPayload(
hostData as Record<string, unknown>,
);
if (protocolAuth) {
await writeProtocolAuth(
userId,
savedHostId,
await keepUsableProtocolCredentials(protocolAuth, userId),
{ isOwner: true },
);
}
// Every enabled plugin that declares host-scope settings and
// registered a hostImportNormalizer validates and writes its own
// fields here, so this loop does not need to know which plugins
// exist. See host-plugin-settings.ts.
await applyPluginHostImportSettings(
savedHostId,
hostData as Record<string, unknown>,
);
writtenHostIds.push(savedHostId);
} catch (error) {
results.failed++;
results.errors.push(`Host ${i + 1}: ${getErrorMessage(error)}`);
}
}
results.createdHostIds = createdHostIds;
if (input.atomic && results.failed > 0) {
await rollBackCreated(userId, results);
return results;
}
await applyDefaultsAfterHostWrites(writtenHostIds);
notifyImportedHosts(userId, writtenHostIds, createdHostIds);
results.createdHostIds = createdHostIds;
return results;
}
/** The OpenSSH config import, shared by the route and ctx.hosts. */
export async function runSshConfigImport(
userId: string,
input: { content?: unknown; overwrite?: unknown; atomic?: boolean },
): Promise<ImportResults> {
const { content, overwrite } = input;
if (input.atomic && overwrite) {
throw new ImportError("An atomic import cannot overwrite existing hosts");
}
if (!isNonEmptyString(content)) {
throw new ImportError("content is required and must be a non-empty string");
}
let parsed: SSHConfigHost[];
try {
parsed = parseSSHConfig(content);
} catch {
throw new ImportError("Failed to parse SSH config file");
}
if (parsed.length === 0) {
throw new ImportError("No valid Host entries found in the SSH config file");
}
if (parsed.length > 100) {
throw new ImportError("Maximum 100 hosts allowed per import");
}
const hostsToImport = parsed.map((h) => ({
name: h.name,
ip: h.hostname,
port: h.port ?? 22,
username: h.user,
authType: h.identityFile ? "key" : undefined,
connectionType: "ssh",
enableSsh: true,
...(h.proxyJump
? {
jumpHosts: [{ host: h.proxyJump, port: 22 }],
}
: {}),
}));
const results: ImportResults = {
success: 0,
updated: 0,
skipped: 0,
failed: 0,
errors: [],
createdHostIds: [],
};
let existingHostMap: Map<string, { id: number }> | undefined;
const hostRepository = createCurrentHostRepository();
try {
const allHosts =
await createCurrentHostResolutionRepository().findHostsByUserId(userId);
existingHostMap = new Map();
for (const h of allHosts) {
const key = `${h.ip}:${h.port}:${h.username}`;
existingHostMap.set(key, { id: h.id as number });
}
} catch {
existingHostMap = undefined;
}
const writtenHostIds: number[] = [];
const createdHostIds: number[] = [];
for (let i = 0; i < hostsToImport.length; i++) {
const hostData = normalizeImportedHost(
hostsToImport[i] as Record<string, unknown>,
);
try {
if (!isNonEmptyString(hostData.ip) || !isValidPort(hostData.port)) {
results.failed++;
results.errors.push(
`Host "${parsed[i].name}": Missing required fields (HostName, Port)`,
);
continue;
}
const lookupKey = `${hostData.ip}:${hostData.port}:${hostData.username}`;
const existing = existingHostMap?.get(lookupKey);
if (existing && !overwrite) {
results.skipped++;
continue;
}
const locked = importLockError(existing?.id);
if (locked) {
results.failed++;
results.errors.push(`Host "${parsed[i].name}": ${locked}`);
continue;
}
const sshDataObj: Record<string, unknown> = {
userId,
connectionType: "ssh",
name: hostData.name || hostData.ip,
folder: "Default",
tags: "",
ip: hostData.ip,
port: hostData.port,
username: hostData.username || null,
authType: hostData.authType || "none",
password: null,
key: null,
keyPassword: null,
keyType: null,
credentialId: null,
pin: false,
sudoPassword: null,
jumpHosts: hostData.jumpHosts
? JSON.stringify(hostData.jumpHosts)
: null,
statusCheckEnabled: true,
statusCheckInterval: null,
sshOptions: null,
forceKeyboardInteractive: "false",
notes: null,
useSocks5: 0,
socks5Host: null,
socks5Port: null,
socks5Username: null,
socks5Password: null,
socks5ProxyChain: null,
portKnockSequence: null,
overrideCredentialUsername: 0,
enableSsh: true,
updatedAt: new Date().toISOString(),
};
await withHostDefaults(
userId,
existing?.id ?? null,
sshDataObj,
hostData as unknown as Record<string, unknown>,
);
if (existing) {
await hostRepository.updateEncryptedForUser(
userId,
existing.id,
sshDataObj,
);
writtenHostIds.push(existing.id);
results.updated++;
} else {
sshDataObj.createdAt = new Date().toISOString();
const saved = await hostRepository.createEncryptedForUser(
userId,
sshDataObj,
);
writtenHostIds.push(saved.id);
createdHostIds.push(saved.id);
results.success++;
}
} catch (error) {
results.failed++;
results.errors.push(
`Host "${parsed[i].name}": ${getErrorMessage(error)}`,
);
}
}
results.createdHostIds = createdHostIds;
if (input.atomic && results.failed > 0) {
await rollBackCreated(userId, results);
return results;
}
await applyDefaultsAfterHostWrites(writtenHostIds);
notifyImportedHosts(userId, writtenHostIds, createdHostIds);
results.createdHostIds = createdHostIds;
return results;
}
+173
View File
@@ -0,0 +1,173 @@
/**
* Host locks: hosts a plugin manages itself (for example from a config file)
* through ctx.hosts.locks.provide. Core refuses edits, deletes and share
* changes on a locked host everywhere, except from the plugin that locks it,
* and a provider can block adding hosts by hand. Pins and order stay with the
* user.
*/
import type { PluginHostLockProvider } from "@termix-ssh/plugin-sdk/backend";
import { pluginLogger } from "../utils/logger.js";
export interface HostLockInfo {
pluginId: string;
pluginName: string;
note: string | null;
}
interface Entry {
pluginId: string;
pluginName: string;
provider: PluginHostLockProvider;
}
const providers = new Map<symbol, Entry>();
export class HostLockedError extends Error {
readonly status = 403;
readonly code = "HOST_LOCKED";
constructor(
message: string,
readonly lock: { pluginId: string; pluginName: string },
) {
super(message);
this.name = "HostLockedError";
}
}
export function provideHostLocks(
pluginId: string,
pluginName: string,
provider: PluginHostLockProvider,
): () => void {
const key = Symbol(pluginId);
providers.set(key, { pluginId, pluginName, provider });
return () => {
providers.delete(key);
};
}
/** Test seam. */
export function clearHostLocks(): void {
providers.clear();
}
// A provider that throws never locks: a broken plugin must not freeze hosts.
function safely<T>(entry: Entry, fallback: T, read: () => T): T {
try {
return read();
} catch (error) {
pluginLogger.warn("Host lock provider failed", {
operation: "host_lock",
pluginId: entry.pluginId,
error: error instanceof Error ? error.message : String(error),
});
return fallback;
}
}
export function hostLock(
hostId: number,
exemptPluginId?: string,
): HostLockInfo | null {
if (!Number.isInteger(hostId)) return null;
for (const entry of providers.values()) {
if (entry.pluginId === exemptPluginId) continue;
if (!safely(entry, false, () => entry.provider.isLocked(hostId))) continue;
return {
pluginId: entry.pluginId,
pluginName: entry.pluginName,
note: safely(entry, null, () => entry.provider.note(hostId) ?? null),
};
}
return null;
}
export function hasHostLocks(): boolean {
return providers.size > 0;
}
/** Throws HostLockedError for the first locked host. */
export function assertHostsUnlocked(
hostIds: Iterable<number>,
exemptPluginId?: string,
): void {
if (providers.size === 0) return;
for (const hostId of hostIds) {
const lock = hostLock(Number(hostId), exemptPluginId);
if (lock) {
throw new HostLockedError(
`This host is managed by ${lock.pluginName} and can't be changed here`,
lock,
);
}
}
}
/** The plugin that blocks adding hosts by hand, if any. */
export function manualCreateBlockedBy(
exemptPluginId?: string,
): { pluginId: string; pluginName: string } | null {
for (const entry of providers.values()) {
if (entry.pluginId === exemptPluginId) continue;
if (!safely(entry, true, () => entry.provider.allowManualCreate())) {
return { pluginId: entry.pluginId, pluginName: entry.pluginName };
}
}
return null;
}
export function assertManualCreateAllowed(exemptPluginId?: string): void {
const blocked = manualCreateBlockedBy(exemptPluginId);
if (blocked) {
throw new HostLockedError(
`Hosts are managed by ${blocked.pluginName}, so new hosts can't be added here`,
blocked,
);
}
}
type Res = { status: (code: number) => { json: (body: unknown) => unknown } };
type Next = () => void;
/** Route guard for creating hosts by hand. */
export function requireManualCreate(_req: unknown, res: Res, next: Next) {
try {
assertManualCreateAllowed();
} catch (error) {
if (sendHostLocked(res, error)) return;
throw error;
}
next();
}
/** Route guard for routes that change the host named by :id. */
export function requireUnlockedHost(
req: { params: Record<string, string | string[]> },
res: Res,
next: Next,
) {
const raw = req.params.id;
const hostId = Number(Array.isArray(raw) ? raw[0] : raw);
try {
assertHostsUnlocked([hostId]);
} catch (error) {
if (sendHostLocked(res, error)) return;
throw error;
}
next();
}
/** Sends a HostLockedError as a 403. False when the error is something else. */
export function sendHostLocked(
res: { status: (code: number) => { json: (body: unknown) => unknown } },
error: unknown,
): boolean {
if (!(error instanceof HostLockedError)) return false;
res.status(403).json({
error: error.message,
code: error.code,
lockedBy: error.lock.pluginId,
});
return true;
}
@@ -0,0 +1,211 @@
import { getErrorMessage } from "../utils/error-message.js";
import { sshLogger } from "../utils/logger.js";
import { findUsableCredential } from "./usable-credential.js";
import {
pickResolvedPassword,
pickResolvedUsername,
} from "./credential-username.js";
import { createCurrentSharedHostAuthOverrideRepository } from "../database/repositories/factory.js";
import { listHostProtocols } from "./protocol-auth/registry.js";
import {
requiresPersonalHostAuthentication,
resolveRecipientSharedHostAuthentication,
} from "../utils/shared-host-auth-resolver.js";
import type {
HostResolutionCredentialRecord,
HostResolutionHostRecord,
} from "../database/repositories/host-resolution-repository.js";
/** A host with its credential's secrets filled in for the asking user. */
export async function resolveHostCredentials(
host: Record<string, unknown>,
requestingUserId?: string,
/**
* Credentials already fetched for this request, keyed by id. The host list
* preloads them in one query; single-host callers omit it and fall back to
* fetching the one credential they need.
*/
preloadedCredentials?: Map<number, HostResolutionCredentialRecord>,
): Promise<Record<string, unknown>> {
try {
const ownerId = (host.ownerId || host.userId) as string | undefined;
if (
requestingUserId &&
ownerId &&
requestingUserId !== ownerId &&
typeof host.id === "number"
) {
const authHost = host as unknown as HostResolutionHostRecord;
const needsPersonalCredential = requiresPersonalHostAuthentication(
authHost,
"ssh",
);
const baseSshOverrideState = {
required: needsPersonalCredential,
ownerAuthShared: !!host.shareSshAuth,
};
// Owner auth for the remote desktop protocols is always snapshotted
// for recipients; only their own override credential varies per user.
const authOverrides: Record<string, unknown> = {
ssh: baseSshOverrideState,
};
const overrideCredentialIds =
await createCurrentSharedHostAuthOverrideRepository().listCredentialIds(
host.id,
requestingUserId,
);
// Whether a plugin protocol is on is its plugin's host setting; the
// client only offers an override for one that is.
for (const { id: protocol } of listHostProtocols()) {
authOverrides[protocol] = {
credentialId: overrideCredentialIds[protocol],
required: false,
ownerAuthShared: true,
};
}
const recipientHost: Record<string, unknown> = {
...host,
credentialId: null,
password: null,
key: null,
keyPassword: null,
keyType: null,
authOverrides,
};
try {
const resolution = await resolveRecipientSharedHostAuthentication(
authHost,
host.id,
requestingUserId,
"ssh",
);
if (resolution.source === "personal-override") {
const credential = resolution.credential;
return {
...recipientHost,
authOverrides: {
...authOverrides,
ssh: {
credentialId: resolution.credentialId,
required: false,
ownerAuthShared: !!host.shareSshAuth,
},
},
authType:
credential.key || credential.privateKey
? "key"
: credential.password
? "password"
: "none",
username: credential.username || recipientHost.username,
password: credential.password,
key: credential.privateKey || credential.key,
keyPassword: credential.keyPassword,
keyType: credential.keyType,
};
}
if (resolution.source === "owner-shared") {
if (resolution.authType === "agent") {
return {
...recipientHost,
authOverrides: {
...authOverrides,
ssh: {
required: false,
ownerAuthShared: true,
},
},
authType: "agent",
};
}
const sharedAuth = resolution.secret;
if (sharedAuth) {
const resolvedUsername = pickResolvedUsername(
recipientHost.username,
sharedAuth.username,
host.overrideCredentialUsername,
);
return {
...recipientHost,
authOverrides: {
...authOverrides,
ssh: {
required: false,
ownerAuthShared: true,
},
},
authType: sharedAuth.key
? "key"
: sharedAuth.password
? "password"
: "none",
username: resolvedUsername,
password: sharedAuth.password,
key: sharedAuth.key,
keyPassword: sharedAuth.keyPassword,
keyType: sharedAuth.keyType,
};
}
}
if (resolution.source === "secretless") {
return {
...recipientHost,
authOverrides: {
...authOverrides,
ssh: {
required: false,
ownerAuthShared: !!host.shareSshAuth,
},
},
};
}
} catch {
// A missing/deleted override or snapshot behaves like unavailable auth.
}
return recipientHost;
}
if (host.credentialId && (host.userId || host.ownerId)) {
const credentialId = host.credentialId as number;
const credentialOwnerId = (host.ownerId || host.userId) as string;
const credential =
preloadedCredentials?.get(credentialId) ??
(await findUsableCredential(credentialId, credentialOwnerId));
if (credential) {
const resolvedHost: Record<string, unknown> = {
...host,
password: pickResolvedPassword(host.password, credential.password),
key: credential.key,
keyPassword: credential.keyPassword,
keyType: credential.keyType,
};
const resolvedUsername = pickResolvedUsername(
host.username,
credential.username,
host.overrideCredentialUsername,
);
if (resolvedUsername !== undefined) {
resolvedHost.username = resolvedUsername;
}
return resolvedHost;
}
}
return { ...host };
} catch (error) {
sshLogger.warn(
`Failed to resolve credentials for host ${host.id}: ${getErrorMessage(error)}`,
);
return host;
}
}
+110
View File
@@ -0,0 +1,110 @@
/**
* A relay to a host's SSH port, for another Termix server that manages this
* one. It opens a WebSocket at /plugin-ws/core/ssh-relay with an API key
* ticket, names a host, and the bytes of an SSH connection run through it.
* This server reaches the host the way it always does, jump hosts, proxy and
* port knocking included, so the other server never needs a route to it. The
* SSH session itself stays between that server and the host.
*
* The caller needs connect access to the host, the same as for a session.
*/
import net from "node:net";
import type { Duplex } from "node:stream";
import { createWebSocketStream, type WebSocket } from "ws";
import type { PluginWebSocketConnection } from "@termix-ssh/plugin-sdk/backend";
import { resolveHostById } from "./host-resolver.js";
import { openSshTransport } from "./connect/transport.js";
import type { MutableConnectConfig, SshConnectHost } from "./connect/types.js";
import { sshLogger } from "../utils/logger.js";
import { registerCoreWsRoute } from "../plugins/ws.js";
const MAX_RELAYS_PER_USER = 100;
const open = new Map<string, number>();
function close(ws: WebSocket, code: number, reason: string): void {
try {
ws.close(code, reason);
} catch {
// Already closed.
}
}
async function handleRelay(
connection: PluginWebSocketConnection,
): Promise<void> {
const ws = connection.socket as WebSocket;
const url = String((connection.request as { url?: string }).url ?? "");
const hostId = Number(
new URL(url, "http://relay").searchParams.get("hostId"),
);
const userId = connection.userId;
if (!userId || !Number.isSafeInteger(hostId) || hostId <= 0) {
close(ws, 1008, "A host is required");
return;
}
if ((open.get(userId) ?? 0) >= MAX_RELAYS_PER_USER) {
close(ws, 1013, "Too many connections");
return;
}
const host = await resolveHostById(hostId, userId);
if (!host) {
close(ws, 1008, "Host not found or access denied");
return;
}
open.set(userId, (open.get(userId) ?? 0) + 1);
let stream: Duplex | null = null;
let jumpEnd: (() => void) | null = null;
let done = false;
const finish = () => {
if (done) return;
done = true;
open.set(userId, Math.max(0, (open.get(userId) ?? 1) - 1));
stream?.destroy();
jumpEnd?.();
close(ws, 1000, "Closed");
};
ws.once("close", finish);
try {
const sshHost = host as unknown as SshConnectHost;
const config = {
host: sshHost.ip,
port: sshHost.port || 22,
} as MutableConnectConfig;
const { jumpClient } = await openSshTransport(sshHost, config);
jumpEnd = () => jumpClient?.end();
stream = (config.sock as Duplex | undefined) ?? null;
if (!stream) {
stream = net.connect({
host: String(config.host),
port: Number(config.port),
});
}
const pipe = createWebSocketStream(ws);
stream.once("error", finish);
stream.once("close", finish);
pipe.once("error", finish);
stream.pipe(pipe);
pipe.pipe(stream);
} catch (error) {
sshLogger.warn("A relay could not reach its host", {
operation: "ssh_relay_open",
hostId,
error: error instanceof Error ? error.message : String(error),
});
finish();
}
}
let registered = false;
/** Mounts the relay once, for servers that other Termix servers manage. */
export function registerSshRelay(): void {
if (registered) return;
registered = true;
registerCoreWsRoute("/ssh-relay", handleRelay);
}
@@ -1,491 +0,0 @@
/**
* The host list's status dot.
*
* Every host with status checks on gets a TCP connection to its port on a
* timer. A port that answers is "online", one that does not (twice in a row)
* is "offline". An open session or a working login also counts as online.
* Probing never logs in to the host itself: on RADIUS or Duo backed devices a
* login fires a real 2FA push, every interval. A host behind jump hosts is
* probed through a chain, which does log in to the hops, so hosts that share
* a hop list share one chain (jump-probe-pool.ts).
*
* Polling is demand driven. A user asking for statuses starts polling their
* own hosts; nothing is probed for a user who never opened the app. The first
* request waits briefly for those first checks so it is not empty.
*/
import { pluginEvents, TOPICS } from "../../plugins/events.js";
import { hostSessionStatus } from "../host-session-status.js";
import { sshLogger } from "../../utils/logger.js";
import {
createCurrentHostResolutionRepository,
getCurrentSettingValue,
} from "../../database/repositories/factory.js";
import type { HostStatusTargetRow } from "../../database/repositories/host-resolution-repository.js";
import type { HostStatus } from "./host-status.js";
import { ConcurrentLimiter } from "./limiter.js";
import { tcpPing } from "./tcp-ping.js";
import { JumpProbePool } from "./jump-probe-pool.js";
export const GLOBAL_STATUS_INTERVAL_KEY = "global_status_check_interval";
export const DEFAULT_STATUS_INTERVAL = 30;
/** How long the first request for a user waits for the first checks. */
const FIRST_CHECK_WAIT_MS = 6_000;
/** A status younger than this is fresh enough for check(). */
const FRESH_MS = 30_000;
export interface HostStatusEntry {
status: HostStatus;
lastChecked: string;
}
export interface HostStatusPayload {
hostId: number;
ownerUserId: string;
status: HostStatus;
previous: HostStatus | null;
/** Anything but offline, the shape automations' trigger already reads. */
online: boolean;
}
export interface StatusTarget {
id: number;
userId: string;
ip: string;
port: number;
connectionType: string;
jumpHosts: Array<{ hostId: number }>;
statusCheckEnabled: boolean;
statusCheckInterval: number | null;
}
type PortResolver = (
hostId: number,
) => number | undefined | Promise<number | undefined>;
export interface HostStatusDeps {
loadTargets: (filter: {
userId?: string;
hostIds?: number[];
}) => Promise<StatusTarget[]>;
/** Hosts other users shared with this one, directly or through a role. */
loadSharedHostIds?: (userId: string) => Promise<number[]>;
ping: (host: string, port: number) => Promise<boolean>;
pingThroughJumpHosts: (
target: StatusTarget,
port: number,
) => Promise<boolean>;
hasActiveSession?: (hostId: number) => boolean;
globalInterval: () => number;
emit: (payload: HostStatusPayload) => void;
/** Pause before a failed ping is tried again. */
retryDelayMs?: number;
}
function parseJumpHosts(raw: string | null): Array<{ hostId: number }> {
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed)
? parsed.filter(
(hop): hop is { hostId: number } =>
!!hop && Number.isInteger((hop as { hostId?: unknown }).hostId),
)
: [];
} catch {
return [];
}
}
export function toStatusTarget(row: HostStatusTargetRow): StatusTarget {
return {
id: row.id,
userId: row.userId,
ip: row.ip,
port: row.port,
connectionType: row.connectionType || "ssh",
jumpHosts: parseJumpHosts(row.jumpHosts),
statusCheckEnabled: row.statusCheckEnabled !== false,
statusCheckInterval: row.statusCheckInterval ?? null,
};
}
const jumpProbes = new JumpProbePool(async (jumpHosts, userId) => {
const { createJumpHostChain } = await import("../jump-host-chain.js");
return createJumpHostChain(jumpHosts, userId);
});
const defaultDeps: HostStatusDeps = {
loadTargets: async (filter) =>
(
await createCurrentHostResolutionRepository().listStatusTargets(filter)
).map(toStatusTarget),
loadSharedHostIds: async (userId) => {
const { createCurrentRbacAccessRepository, createCurrentRoleRepository } =
await import("../../database/repositories/factory.js");
const roleIds = await createCurrentRoleRepository().listUserRoleIds(userId);
const entries =
await createCurrentRbacAccessRepository().listVisibleHostAccessEntries(
userId,
roleIds,
);
return [...new Set(entries.map((entry) => entry.hostId))];
},
hasActiveSession: (hostId) => hostSessionStatus.hasActiveSession(hostId),
ping: (host, port) => tcpPing(host, port, 5000),
pingThroughJumpHosts: (target, port) =>
jumpProbes.ping(target.jumpHosts, target.userId, target.ip, port, 5000),
globalInterval: () => {
const value = Number(getCurrentSettingValue(GLOBAL_STATUS_INTERVAL_KEY));
return Number.isInteger(value) && value >= 5
? value
: DEFAULT_STATUS_INTERVAL;
},
emit: (payload) => pluginEvents.emit(TOPICS.hostStatus, payload),
};
interface Polled {
target: StatusTarget;
timer?: NodeJS.Timeout;
}
export class HostStatusService {
private readonly polled = new Map<number, Polled>();
private readonly store = new Map<number, HostStatusEntry>();
private readonly owners = new Map<number, string>();
private readonly inFlight = new Map<number, Promise<void>>();
private readonly startedUsers = new Set<string>();
private readonly portResolvers = new Map<string, Set<PortResolver>>();
private readonly limiter = new ConcurrentLimiter(20);
private unsubscribers: Array<() => void> = [];
constructor(private readonly deps: HostStatusDeps = defaultDeps) {}
/** Subscribes to the core events the status depends on. */
start(): void {
if (this.unsubscribers.length > 0) return;
this.unsubscribers = [
hostSessionStatus.subscribe((hostId, online) => {
if (online) this.reportLogin(hostId, { ok: true });
}),
pluginEvents.on(TOPICS.hostUpdated, (payload) => {
const { hostId } = payload as { hostId?: number };
if (hostId) void this.reload(hostId);
}),
pluginEvents.on(TOPICS.hostDeleted, (payload) => {
const { hostId } = payload as { hostId?: number };
if (hostId) this.forget(hostId);
}),
];
}
stop(): void {
for (const unsubscribe of this.unsubscribers) unsubscribe();
this.unsubscribers = [];
for (const hostId of [...this.polled.keys()]) this.stopPolling(hostId);
this.startedUsers.clear();
}
get(hostId: number): HostStatusEntry | null {
return this.store.get(hostId) ?? null;
}
/**
* Statuses for a user's request. With hostIds, polling is brought in line
* with exactly those of their own hosts (the desktop app sends only the
* hosts that live on this backend). Without, their own hosts are started
* once.
*/
async statusesFor(
userId: string,
requestedHostIds: Set<number> | null,
): Promise<Map<number, HostStatusEntry>> {
const started: Promise<void>[] = [];
if (requestedHostIds !== null) {
await this.reconcile(userId, requestedHostIds, started);
} else if (!this.startedUsers.has(userId)) {
await this.startUser(userId, started);
}
if (started.length > 0) await this.waitFor(started);
return this.store;
}
/** Drops every timer for a user's hosts and starts them again. */
async refresh(userId: string): Promise<void> {
const targets = await this.deps.loadTargets({ userId });
for (const target of targets) this.stopPolling(target.id);
for (const target of targets) this.startPolling(target);
this.startedUsers.add(userId);
}
/** Re-times every polled host, after the global interval changed. */
retimeAll(): void {
for (const { target } of [...this.polled.values()]) {
this.startPolling(target, false);
}
}
/** Checks now unless the last result is fresh. */
async check(hostId: number): Promise<HostStatusEntry | null> {
const current = this.store.get(hostId);
if (current && Date.now() - Date.parse(current.lastChecked) < FRESH_MS) {
return current;
}
const target =
this.polled.get(hostId)?.target ??
(await this.deps.loadTargets({ hostIds: [hostId] }))[0];
if (!target || !target.statusCheckEnabled) return current ?? null;
await this.probe(target);
return this.store.get(hostId) ?? null;
}
/**
* A working login proves the host is up. A failed one says nothing about
* reachability (a wrong password, another user's login), so it is ignored.
*/
reportLogin(hostId: number, outcome: { ok: boolean }): void {
if (outcome.ok) this.set(hostId, { status: "online" });
}
registerPort(connectionType: string, resolve: PortResolver): () => void {
let set = this.portResolvers.get(connectionType);
if (!set) {
set = new Set();
this.portResolvers.set(connectionType, set);
}
set.add(resolve);
return () => {
set!.delete(resolve);
if (set!.size === 0) this.portResolvers.delete(connectionType);
};
}
private async waitFor(started: Promise<void>[]): Promise<void> {
let timer: NodeJS.Timeout | undefined;
await Promise.race([
Promise.allSettled(started),
new Promise<void>((resolve) => {
timer = setTimeout(resolve, FIRST_CHECK_WAIT_MS);
timer.unref?.();
}),
]);
if (timer) clearTimeout(timer);
}
private track(started: Promise<void>[], target: StatusTarget): void {
const probe = this.startPolling(target);
if (probe) started.push(probe);
}
private async startUser(
userId: string,
started: Promise<void>[],
): Promise<void> {
this.startedUsers.add(userId);
const targets = await this.deps.loadTargets({ userId });
for (const target of targets) {
if (!this.polled.has(target.id)) this.track(started, target);
}
await this.startShared(userId, null, started);
}
/**
* Hosts shared with the user are checked as their owners, even when no
* owner has asked for statuses since the server started.
*/
private async startShared(
userId: string,
allowed: Set<number> | null,
started: Promise<void>[],
): Promise<void> {
if (!this.deps.loadSharedHostIds) return;
try {
const shared = (await this.deps.loadSharedHostIds(userId)).filter(
(hostId) =>
!this.polled.has(hostId) && (allowed === null || allowed.has(hostId)),
);
if (shared.length === 0) return;
const targets = await this.deps.loadTargets({ hostIds: shared });
for (const target of targets) {
if (!this.polled.has(target.id)) this.track(started, target);
}
} catch (error) {
sshLogger.warn("Could not start status checks for shared hosts", {
operation: "host_status_shared",
userId,
error: error instanceof Error ? error.message : String(error),
});
}
}
private async reconcile(
userId: string,
allowed: Set<number>,
started: Promise<void>[],
): Promise<void> {
const targets = await this.deps.loadTargets({ userId });
for (const target of targets) {
if (!allowed.has(target.id)) {
this.stopPolling(target.id);
this.store.delete(target.id);
continue;
}
if (!this.polled.has(target.id)) this.track(started, target);
}
await this.startShared(userId, allowed, started);
}
private async reload(hostId: number): Promise<void> {
try {
const [target] = await this.deps.loadTargets({ hostIds: [hostId] });
if (!target) {
this.forget(hostId);
return;
}
if (this.polled.has(hostId) || this.startedUsers.has(target.userId)) {
this.startPolling(target);
}
} catch (error) {
sshLogger.warn("Could not reload host for status checks", {
operation: "host_status_reload",
hostId,
error: error instanceof Error ? error.message : String(error),
});
}
}
private forget(hostId: number): void {
this.stopPolling(hostId);
this.store.delete(hostId);
this.owners.delete(hostId);
}
private intervalMs(target: StatusTarget): number {
const seconds = target.statusCheckInterval ?? this.deps.globalInterval();
const intervalMs = Math.max(5, seconds) * 1000;
// Spread hosts so a fleet does not fire on the same second.
const spread = Math.min(intervalMs * 0.2, 15_000);
return (
intervalMs + ((target.id * 1103515245) % Math.max(1, Math.floor(spread)))
);
}
/** Returns the first probe when one was started. */
private startPolling(
target: StatusTarget,
probeNow = true,
): Promise<void> | undefined {
this.stopPolling(target.id);
this.owners.set(target.id, target.userId);
if (!target.statusCheckEnabled) {
this.store.delete(target.id);
return undefined;
}
const polled: Polled = { target };
this.polled.set(target.id, polled);
const first = probeNow ? this.probe(target) : undefined;
polled.timer = setInterval(() => {
const latest = this.polled.get(target.id);
if (latest) void this.probe(latest.target);
}, this.intervalMs(target));
polled.timer.unref?.();
return first;
}
private stopPolling(hostId: number): void {
const polled = this.polled.get(hostId);
if (polled?.timer) clearInterval(polled.timer);
this.polled.delete(hostId);
}
private probe(target: StatusTarget): Promise<void> {
const running = this.inFlight.get(target.id);
if (running) return running;
const job = this.limiter
.run(() => this.probeNow(target))
.catch((error) => {
sshLogger.error("Status check failed", error, {
operation: "host_status_probe",
hostId: target.id,
});
})
.finally(() => this.inFlight.delete(target.id));
this.inFlight.set(target.id, job);
return job;
}
private async portFor(target: StatusTarget): Promise<number> {
if (target.connectionType !== "ssh") {
for (const resolve of this.portResolvers.get(target.connectionType) ??
[]) {
try {
const port = await resolve(target.id);
if (Number.isInteger(port) && port! > 0) return port!;
} catch {
// fall through to the next resolver
}
}
}
return target.port;
}
private async pingOnce(target: StatusTarget): Promise<boolean> {
try {
if (this.deps.hasActiveSession?.(target.id)) return true;
const port = await this.portFor(target);
return target.jumpHosts.length > 0
? await this.deps.pingThroughJumpHosts(target, port)
: await this.deps.ping(target.ip, port);
} catch {
return false;
}
}
private async probeNow(target: StatusTarget): Promise<void> {
this.owners.set(target.id, target.userId);
let reachable = await this.pingOnce(target);
if (!reachable) {
// One lost packet should not flip the dot.
const delay = this.deps.retryDelayMs ?? 1000;
if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay));
reachable = await this.pingOnce(target);
}
this.set(target.id, { status: reachable ? "online" : "offline" });
}
private set(hostId: number, entry: Omit<HostStatusEntry, "lastChecked">) {
const previous = this.store.get(hostId)?.status ?? null;
const next: HostStatusEntry = {
...entry,
lastChecked: new Date().toISOString(),
};
this.store.set(hostId, next);
if (previous === next.status) return;
void this.emitChange(hostId, next, previous);
}
private async emitChange(
hostId: number,
entry: HostStatusEntry,
previous: HostStatus | null,
): Promise<void> {
let ownerUserId = this.owners.get(hostId);
if (!ownerUserId) {
const [target] = await this.deps
.loadTargets({ hostIds: [hostId] })
.catch(() => []);
if (!target) return;
ownerUserId = target.userId;
this.owners.set(hostId, ownerUserId);
}
this.deps.emit({
hostId,
ownerUserId,
status: entry.status,
previous,
online: entry.status === "online",
});
}
}
export const hostStatusService = new HostStatusService();
-9
View File
@@ -1,9 +0,0 @@
export type HostStatus = "online" | "offline";
export function isHostKeyVerificationError(error: unknown): boolean {
return (
error instanceof Error &&
(error.message.includes("Host denied (verification failed)") ||
error.message.includes("Host key changed"))
);
}
-81
View File
@@ -1,81 +0,0 @@
/**
* Limits how many async jobs run at once. Extra callers wait in FIFO order.
*/
export class ConcurrentLimiter {
private active = 0;
private readonly waiters: Array<() => void> = [];
constructor(private maxConcurrent: number) {
if (maxConcurrent < 1) {
throw new Error("maxConcurrent must be >= 1");
}
}
get activeCount(): number {
return this.active;
}
get pendingCount(): number {
return this.waiters.length;
}
get limit(): number {
return this.maxConcurrent;
}
/**
* Changes the ceiling at runtime.
*
* Raising it wakes the waiters the new headroom allows, so a queue that built
* up under the old limit drains at once instead of one job at a time as
* running work finishes. Lowering it never interrupts work already running;
* the new limit simply applies from the next release onward.
*/
setLimit(maxConcurrent: number): void {
if (maxConcurrent < 1) {
throw new Error("maxConcurrent must be >= 1");
}
this.maxConcurrent = maxConcurrent;
this.releaseWaiters();
}
/**
* Wakes as many queued callers as there is now room for.
*
* `woken` counts callers that have been resumed but have not yet reached the
* `active += 1` on the far side of their await. Without it the occupancy
* looks lower than it really is for a microtask, and the loop would release
* past the ceiling.
*/
private releaseWaiters(): void {
while (
this.active + this.woken < this.maxConcurrent &&
this.waiters.length > 0
) {
this.woken += 1;
this.waiters.shift()!();
}
}
private woken = 0;
async run<T>(fn: () => Promise<T>): Promise<T> {
// Queue behind anything already woken, so a caller arriving mid-handoff
// cannot jump the queue and push occupancy past the ceiling.
if (this.active + this.woken >= this.maxConcurrent) {
await new Promise<void>((resolve) => {
this.waiters.push(resolve);
});
// Resumed by a slot handoff; that reservation is now consumed.
this.woken = Math.max(0, this.woken - 1);
}
this.active += 1;
try {
return await fn();
} finally {
this.active -= 1;
this.releaseWaiters();
}
}
}
+105
View File
@@ -0,0 +1,105 @@
/**
* Recent activity is a provider: core owns the callers (the dashboard routes,
* ctx.hosts.recordActivity, the cleanup when a user or host goes away) and one
* provider owns the storage. A plugin registers with
* ctx.hosts.activity.provide. With none, nothing is recorded and the list is
* empty. Core checks that the user may reach the host before it records.
*/
export interface ActivityEntry {
id: number;
userId: string;
type: string;
hostId: number;
hostName: string | null;
timestamp: string;
}
export type RecordActivityResult =
| { status: "logged"; id: number }
| { status: "rate_limited" }
| { status: "invalid_type" }
| { status: "denied" }
| { status: "unavailable" };
export interface ActivityProvider {
record(
userId: string,
entry: { type: string; hostId: number; hostName: string },
): Promise<RecordActivityResult>;
list(userId: string, limit: number): Promise<ActivityEntry[]>;
deleteByUserId(userId: string): Promise<void>;
deleteByHostIds(hostIds: number[]): Promise<void>;
}
let plugged: { pluginId: string; provider: ActivityProvider } | null = null;
export function provideActivity(
pluginId: string,
provider: ActivityProvider,
): () => void {
if (plugged) {
throw new Error(
`Recent activity is already provided by plugin "${plugged.pluginId}"`,
);
}
const entry = { pluginId, provider };
plugged = entry;
return () => {
if (plugged === entry) plugged = null;
};
}
function active(): ActivityProvider | null {
return plugged?.provider ?? null;
}
async function canReachHost(userId: string, hostId: number): Promise<boolean> {
const {
createCurrentHostResolutionRepository,
createCurrentRbacAccessRepository,
createCurrentRoleRepository,
} = await import("../database/repositories/factory.js");
if (
await createCurrentHostResolutionRepository().isHostOwnedByUser(
hostId,
userId,
)
) {
return true;
}
const roleIds = await createCurrentRoleRepository().listUserRoleIds(userId);
const shared =
await createCurrentRbacAccessRepository().listVisibleHostAccessEntries(
userId,
roleIds,
);
return shared.some((access) => access.hostId === hostId);
}
/** What the rest of core calls. */
export const recentActivity = {
async record(
userId: string,
entry: { type: string; hostId: number; hostName: string },
): Promise<RecordActivityResult> {
const provider = active();
if (!provider) return { status: "unavailable" };
if (!(await canReachHost(userId, entry.hostId))) {
return { status: "denied" };
}
return provider.record(userId, entry);
},
async list(userId: string, limit: number): Promise<ActivityEntry[]> {
return (await active()?.list(userId, limit)) ?? [];
},
async deleteByUserId(userId: string): Promise<void> {
await active()?.deleteByUserId(userId);
},
async deleteByHostId(hostId: number): Promise<void> {
await active()?.deleteByHostIds([hostId]);
},
async deleteByHostIds(hostIds: number[]): Promise<void> {
await active()?.deleteByHostIds(hostIds);
},
};
+36 -3
View File
@@ -1,4 +1,5 @@
// Serves a plugin's built frontend: its bundle, stylesheet and locales.
// Serves a plugin's built frontend: its bundle, stylesheet, locales and the
// CSS files its themes declare.
//
// Unauthenticated on purpose, the same as /assets: import() cannot carry the
// bearer header, and nothing here is secret. It only ever serves files from
@@ -28,6 +29,22 @@ export interface PluginFrontendInfo {
assetVersion: string | null;
/** Language codes with a locale file, "en" plus xx_YY names. */
locales: string[];
/** Changes whenever a theme's CSS does, for its ?v= link. */
themeVersion: string | null;
}
/** Where a theme's CSS is served: /plugin-assets/<id>/_themes/<theme>.css. */
export const THEME_ASSET_PREFIX = "_themes/";
/** The declared CSS file of each of the plugin's themes, by theme id. */
function themeFiles(plugin: AssetPlugin): Map<string, string> {
const files = new Map<string, string>();
const manifest = plugin.manifest as PluginManifest;
for (const theme of manifest.contributes?.themes ?? []) {
const file = path.resolve(plugin.dir, theme.css);
if (isInside(plugin.dir, file)) files.set(theme.id, file);
}
return files;
}
function frontendEntry(
@@ -72,9 +89,16 @@ export function describePluginFrontend(
css: false,
assetVersion: null,
locales: [],
themeVersion: null,
};
if (!plugin) return none;
const themes = [...themeFiles(plugin).values()].filter(fileExists);
const themeVersion =
themes.length > 0
? contentVersion(themes.sort(), plugin.manifest.version)
: null;
const locales: string[] = [];
const localeFiles: string[] = [];
const dir = localesDir(plugin);
@@ -96,7 +120,8 @@ export function describePluginFrontend(
}
const entry = frontendEntry(plugin);
if (!entry || !fileExists(entry.file)) return { ...none, locales };
if (!entry || !fileExists(entry.file))
return { ...none, locales, themeVersion };
const cssFile = path.join(entry.dir, "frontend.css");
const css = fileExists(cssFile);
@@ -111,6 +136,7 @@ export function describePluginFrontend(
css,
assetVersion,
locales: locales.sort(),
themeVersion,
};
}
@@ -158,7 +184,14 @@ export function resolvePluginAsset(
let root: string | null;
let rest: string;
if (clean.startsWith("locales/")) {
if (clean.startsWith(THEME_ASSET_PREFIX)) {
// Only a declared theme file, which the loader checked on load.
const themeId = clean.slice(THEME_ASSET_PREFIX.length, -".css".length);
const file = themeFiles(plugin).get(themeId);
if (!file || !clean.endsWith(".css")) return null;
root = path.dirname(file);
rest = path.basename(file);
} else if (clean.startsWith("locales/")) {
root = localesDir(plugin);
rest = clean.slice("locales/".length);
} else {
+21 -1
View File
@@ -1,5 +1,5 @@
/**
* The onboarding defaults of the bundled plugins, from
* The onboarding defaults and the default look of the bundled plugins, from
* dist/plugins/bundled-index.json (written by scripts/build-plugins.cjs from
* docker/bundled-plugins.json). Core never names plugins, so which ones a
* fresh install keeps lives in that file and not here.
@@ -27,6 +27,7 @@ interface BundledIndex {
consent?: boolean;
enabledByEnv?: string[];
};
defaultLook?: boolean;
}
>;
}
@@ -52,6 +53,23 @@ export function readBundledIndex(): BundledIndex | null {
return cached;
}
/**
* Bundled plugins whose layout and theme a fresh install shows. Core reads
* the default look from here so it never has to name a plugin.
*/
export function defaultLookPluginIds(): string[] {
const index = readBundledIndex();
if (!index) return [];
return Object.entries(index.plugins)
.filter(([, entry]) => entry?.defaultLook === true)
.map(([id]) => id)
.sort();
}
export function isDefaultLookPlugin(pluginId: string): boolean {
return defaultLookPluginIds().includes(pluginId);
}
export function resetBundledIndexCache(): void {
cached = undefined;
}
@@ -96,6 +114,8 @@ export function initialPluginState(
): "enabled" | "disabled" {
if (source !== "bundled") return "disabled";
if (!freshInstall) return "enabled";
// The default look is what the app is drawn with.
if (isDefaultLookPlugin(pluginId)) return "enabled";
const { recommended, consent } = onboardingDefaults(pluginId);
return recommended && !consent ? "enabled" : "disabled";
}
+143
View File
@@ -0,0 +1,143 @@
/**
* ctx.audit.subscribe, list and exportBatches: reading the audit trail,
* behind audit:read. Writing lines (record) stays in ctx.ts.
*/
import type {
PluginAudit,
PluginAuditEntry,
PluginAuditFilters,
} from "@termix-ssh/plugin-sdk/backend";
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
import { assertCapability, capabilityRefused } from "./permissions.js";
import type { DisposableBag } from "./disposables.js";
import { onAuditEntry } from "../utils/audit-hooks.js";
import type { AuditLogRecord } from "../database/repositories/audit-log-repository.js";
type AuditFn = (
action: string,
details: string,
outcome: { success: boolean; errorMessage?: string },
) => Promise<void>;
interface Deps {
manifest: PluginManifest;
bag: DisposableBag;
audit: AuditFn;
}
const MAX_PAGE = 200;
const MAX_BATCH = 1000;
function toEntry(row: AuditLogRecord): PluginAuditEntry {
return {
id: row.id,
timestamp: new Date(
row.timestamp as unknown as string | number,
).toISOString(),
userId: row.userId ?? null,
username: row.username,
action: row.action,
resourceType: row.resourceType,
resourceId: row.resourceId ?? null,
resourceName: row.resourceName ?? null,
details: row.details ?? null,
ipAddress: row.ipAddress ?? null,
userAgent: row.userAgent ?? null,
success: Boolean(row.success),
errorMessage: row.errorMessage ?? null,
};
}
export function createPluginAuditReader(
deps: Deps,
): Pick<PluginAudit, "subscribe" | "list" | "exportBatches"> {
const { manifest, bag, audit } = deps;
const require = (action: string) =>
assertCapability(manifest.id, "audit:read", manifest.capabilities, action);
return {
subscribe: (handler) => {
if (!manifest.capabilities.includes("audit:read")) {
throw capabilityRefused(manifest.id, "audit:read", "audit_subscribe");
}
let remove: (() => void) | null = null;
let forgetBag: (() => void) | null = null;
// Granted state is checked once, then every line is delivered until
// unsubscribed; a revoked grant stops the next subscribe.
void require("audit_subscribe")
.then(async () => {
await audit("audit_subscribe", "listened to the audit log", {
success: true,
});
remove = onAuditEntry((entry) => {
handler({
timestamp: entry.timestamp.toISOString(),
userId: entry.userId,
username: entry.username,
action: entry.action,
resourceType: entry.resourceType,
resourceId: entry.resourceId ?? null,
resourceName: entry.resourceName ?? null,
details: entry.details ?? null,
ipAddress: entry.ipAddress ?? null,
userAgent: entry.userAgent ?? null,
success: entry.success,
errorMessage: entry.errorMessage ?? null,
});
});
})
.catch(() => {});
forgetBag = bag.add(() => remove?.(), "audit subscription");
return () => {
remove?.();
remove = null;
forgetBag?.();
};
},
list: async (options) => {
await require("audit_list");
const { createCurrentAuditLogRepository } =
await import("../database/repositories/factory.js");
const limit = Math.min(MAX_PAGE, Math.max(1, options?.limit ?? 50));
const page = await createCurrentAuditLogRepository().listPage({
filters: options?.filters ?? {},
limit,
offset: Math.max(0, options?.offset ?? 0),
});
await audit("audit_list", "read audit log entries", { success: true });
return { entries: page.logs.map(toEntry), total: page.total };
},
exportBatches: async function* (
filters?: PluginAuditFilters,
batchSize?: number,
) {
await require("audit_export");
const { createCurrentAuditLogRepository } =
await import("../database/repositories/factory.js");
const limit = Math.min(MAX_BATCH, Math.max(1, batchSize ?? 500));
let offset = 0;
let exported = 0;
try {
for (;;) {
const rows = await createCurrentAuditLogRepository().listForExport({
filters: filters ?? {},
limit,
offset,
});
if (rows.length === 0) break;
exported += rows.length;
offset += rows.length;
yield rows.map(toEntry);
if (rows.length < limit) break;
}
} finally {
await audit("audit_export", `exported ${exported} audit log entries`, {
success: true,
});
}
},
};
}
+99
View File
@@ -150,6 +150,37 @@ export function createPluginCredentials({
}
},
list: async () => {
try {
await assertCapability(pluginId, "credentials:use", declared);
const userId = actingUser();
await requireCorePermission(userId, "credentials.view");
const { createCurrentCredentialRepository } =
await import("../database/repositories/factory.js");
const rows =
await createCurrentCredentialRepository().listDecryptedByUserId(
userId,
);
const list = rows.map((row) => ({
id: row.id,
name: row.name,
username: row.username ?? null,
authType: String(row.authType ?? ""),
folder: row.folder ?? null,
}));
await audit("credentials_list", `${list.length} credentials`, {
success: true,
});
return list;
} catch (error) {
await audit("credentials_list", "saved credentials", {
success: false,
errorMessage: errorText(error),
});
throw error;
}
},
createSshKey: async (input) => {
const details = `SSH key "${input?.name ?? ""}"`;
try {
@@ -194,6 +225,74 @@ export function createPluginCredentials({
}
},
publicKey: async (credentialId) => {
const details = `public key of credential ${credentialId}`;
try {
await assertCapability(pluginId, "credentials:use", declared);
const userId = actingUser();
await requireCorePermission(userId, "credentials.view");
if (!Number.isInteger(credentialId) || credentialId <= 0) {
throw new Error("A credential id is required");
}
const { createCurrentHostResolutionRepository } =
await import("../database/repositories/factory.js");
const credential =
await createCurrentHostResolutionRepository().findCredentialByIdForUser(
credentialId,
userId,
);
const row = credential as unknown as Record<string, unknown> | null;
if (!row || row.authType !== "key") {
await audit("credentials_public_key", details, { success: true });
return null;
}
let publicKey =
typeof row.publicKey === "string" && row.publicKey.trim()
? row.publicKey.trim()
: null;
if (!publicKey) {
const stored = str(row.privateKey) || str(row.key);
if (stored) {
const { resolveKeyReferences } =
await import("../hosts/stored-ssh-key.js");
const resolved = await resolveKeyReferences(
userId,
stored,
typeof row.keyPassword === "string" ? row.keyPassword : null,
);
publicKey = derivePublicKey(resolved.key, resolved.passphrase);
}
}
await audit("credentials_public_key", details, { success: true });
return publicKey;
} catch (error) {
await audit("credentials_public_key", details, {
success: false,
errorMessage: errorText(error),
});
throw error;
}
},
resolveKeyRefs: async (key, passphrase) => {
const details = "resolved key references";
try {
await assertCapability(pluginId, "credentials:read", declared);
const userId = actingUser();
const { resolveKeyReferences } =
await import("../hosts/stored-ssh-key.js");
const resolved = await resolveKeyReferences(userId, key, passphrase);
await audit("credentials_resolve_key", details, { success: true });
return resolved;
} catch (error) {
await audit("credentials_resolve_key", details, {
success: false,
errorMessage: errorText(error),
});
throw error;
}
},
registerSecretResolver: (scheme, resolve) => {
if (!declared.includes("auth:provide")) {
throw capabilityRefused(pluginId, "auth:provide");
+260
View File
@@ -0,0 +1,260 @@
/**
* ctx.desktop.local: the user's own machine, behind desktop:local.
*
* Only works on the desktop app's embedded backend. On a server every call
* throws, and available() is false. Path rules live in desktop-local/local-fs.
*/
import type {
PluginDesktopLocal,
PluginLocalPty,
} from "@termix-ssh/plugin-sdk/backend";
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
import { assertCapability } from "./permissions.js";
import type { DisposableBag } from "./disposables.js";
import * as localFs from "./desktop-local/local-fs.js";
import { remoteJson, requestHeaders } from "../sync/client/http.js";
import { spawnLocalPty } from "./desktop-local/local-pty.js";
type AuditFn = (
action: string,
details: string,
outcome: { success: boolean; errorMessage?: string },
) => Promise<void>;
interface Deps {
manifest: PluginManifest;
bag: DisposableBag;
audit: AuditFn;
}
function isEmbedded(): boolean {
return process.env.ELECTRON_EMBEDDED === "true";
}
export function createPluginDesktopLocal(deps: Deps): PluginDesktopLocal {
const { manifest, bag, audit } = deps;
const audited = new Set<string>();
const guard = async (action: string, details: string, every = false) => {
await assertCapability(
manifest.id,
"desktop:local",
manifest.capabilities,
action,
);
if (!isEmbedded()) {
throw new Error(
`Plugin ${manifest.id} used the local machine outside the desktop app`,
);
}
if (every || !audited.has(action)) {
audited.add(action);
await audit(action, details, { success: true });
}
};
const mainRequest = async (channel: string, path: string) => {
const { requestFromElectronMain } =
await import("../utils/electron-ipc-bridge.js");
return requestFromElectronMain<{ success: boolean; error?: string }>(
channel,
{ path },
);
};
return {
available: () =>
isEmbedded() && manifest.capabilities.includes("desktop:local"),
pluginFetch: async (origin, subPath, init) => {
await guard("desktop_local_fetch", "called its own routes");
if (
typeof subPath !== "string" ||
!subPath.startsWith("/") ||
subPath.startsWith("//") ||
subPath.includes("\\") ||
/(^|\/)\.\.?(\/|\?|$)/.test(subPath)
) {
throw new Error("Invalid plugin route path");
}
const route = `/plugin-api/${manifest.id}${subPath}`;
const headers: Record<string, string> = { ...init?.headers };
for (const name of Object.keys(headers)) {
if (/^(authorization|cookie|host)$/i.test(name)) delete headers[name];
}
if (origin === "remote") {
const { getLink } = await import("../sync/client/link-store.js");
const link = await getLink();
if (!link) throw new Error("This device is not linked to a server");
const { remoteStreamFetch } = await import("../sync/client/http.js");
return (await remoteStreamFetch(link, route, {
method: init?.method,
headers,
body: init?.body,
signal: init?.signal,
})) as never;
}
if (origin !== "local") throw new Error("Unknown origin");
const { getActor } = await import("./actor.js");
const actor = getActor();
if (!actor) throw new Error("No acting user");
const { AuthManager } = await import("../utils/auth-manager.js");
const session = await AuthManager.getInstance().issuePluginSession(
actor,
{
pluginId: manifest.id,
purpose: "desktop local transfer",
ttlSeconds: 900,
},
);
const port = Number(process.env.EMBEDDED_BACKEND_PORT) || 30001;
const { fetch: undiciFetch } = await import("undici");
return (await undiciFetch(`http://127.0.0.1:${port}${route}`, {
method: init?.method ?? "GET",
headers: {
...headers,
"X-Electron-App": "true",
Authorization: `Bearer ${session.token}`,
},
body: init?.body as never,
duplex: "half",
signal: init?.signal,
} as never)) as never;
},
info: async () => {
await guard("desktop_local_info", "read local machine info");
const home = await localFs.homeInfo();
const { getLink } = await import("../sync/client/link-store.js");
const link = await getLink();
return {
...home,
userDataDir: process.env.ELECTRON_USER_DATA ?? "",
backendPort: Number(process.env.EMBEDDED_BACKEND_PORT) || 30001,
link: link
? {
serverUrl: link.serverUrl,
token: link.sessionToken,
headers: requestHeaders(link),
allowInvalidCertificate: !!link.allowInvalidCertificate,
}
: null,
};
},
linkJson: async (pathname) => {
await guard("desktop_local_link", "called the linked server");
const { getLink } = await import("../sync/client/link-store.js");
const link = await getLink();
if (!link)
throw new Error("This device is not linked to a Termix server");
if (!pathname.startsWith("/")) throw new Error("Invalid path");
return remoteJson(
{
serverUrl: link.serverUrl,
sessionToken: link.sessionToken,
customHeaders: link.customHeaders,
basicAuth: link.basicAuth,
allowInvalidCertificate: link.allowInvalidCertificate,
clientCert: link.clientCert,
},
pathname,
);
},
fs: {
list: async (dirPath) => {
await guard("desktop_local_fs", "used the local file system");
return localFs.listDirectory(dirPath);
},
mkdir: async (parentPath, name) => {
await guard("desktop_local_fs", "used the local file system");
return localFs.makeDirectory(parentPath, name);
},
createFile: async (parentPath, name) => {
await guard("desktop_local_fs", "used the local file system");
return localFs.createEmptyFile(parentPath, name);
},
rename: async (path, newName) => {
await guard("desktop_local_fs", "used the local file system");
return localFs.renameEntry(path, newName);
},
ensureDir: async (dirPath, rootPath) => {
await guard("desktop_local_fs", "used the local file system");
return localFs.ensureDirectory(dirPath, rootPath);
},
exists: async (paths) => {
await guard("desktop_local_fs", "used the local file system");
return localFs.existingPaths(paths);
},
walk: async (rootPaths) => {
await guard("desktop_local_fs", "used the local file system");
if (!Array.isArray(rootPaths) || rootPaths.length === 0) {
throw new Error("No local paths provided");
}
return localFs.walkPaths(rootPaths);
},
trash: async (paths) => {
await guard(
"desktop_local_trash",
`moved ${Array.isArray(paths) ? paths.length : 0} local items to the trash`,
true,
);
if (!Array.isArray(paths) || paths.length === 0) {
throw new Error("No local paths provided");
}
const failed: Array<{ path: string; error: string }> = [];
for (const candidate of paths) {
const target = localFs.normalizeLocalPath(candidate);
try {
const result = await mainRequest("shell-trash-item", target);
if (!result?.success) throw new Error(result?.error ?? "Failed");
} catch (error) {
failed.push({
path: target,
error: error instanceof Error ? error.message : String(error),
});
}
}
return { trashed: paths.length - failed.length, failed };
},
reveal: async (path) => {
await guard("desktop_local_fs", "used the local file system");
await mainRequest("shell-show-item", localFs.normalizeLocalPath(path));
},
open: async (path) => {
await guard("desktop_local_open", "opened a local path", true);
const result = await mainRequest(
"shell-open-path",
localFs.normalizeLocalPath(path),
);
if (!result?.success) throw new Error(result?.error ?? "Failed");
},
openRead: async (path) => {
await guard("desktop_local_fs", "used the local file system");
return localFs.openRead(path);
},
openDownload: async (options) => {
await guard("desktop_local_fs", "used the local file system");
return localFs.openDownload(options);
},
},
pty: {
spawn: async (options) => {
await guard("desktop_local_pty", "started a local shell", true);
const child: PluginLocalPty = spawnLocalPty(options);
// A shell never outlives the plugin that started it.
bag.add(() => {
try {
child.kill();
} catch {
// already gone
}
});
return child;
},
},
};
}
+192
View File
@@ -0,0 +1,192 @@
/**
* ctx.hosts.importMany, importSshConfig, exportMany and registerImportNormalizer.
*
* The import and export logic is the same code the host routes run
* (hosts/import/), so a plugin gets the same validation, defaults, protocol
* logins and plugin settings. What this file adds is the capability checks,
* the core permission checks, one batched save and the audit lines.
*/
import type {
PluginHostExport,
PluginHostImportNormalizer,
PluginHostImportOptions,
PluginHostImportResult,
PluginHosts,
} from "@termix-ssh/plugin-sdk/backend";
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
import { assertCapability, capabilityRefused } from "./permissions.js";
import { getActor } from "./actor.js";
import * as registry from "./registry.js";
import type { DisposableBag } from "./disposables.js";
import { DatabaseSaveTrigger } from "../utils/database-save-trigger.js";
import { PermissionManager } from "../utils/permission-manager.js";
type AuditFn = (
action: string,
details: string,
outcome: { success: boolean; errorMessage?: string },
) => Promise<void>;
interface Deps {
manifest: PluginManifest;
bag?: DisposableBag;
audit: AuditFn;
}
type ImportApi = Pick<
PluginHosts,
"importMany" | "importSshConfig" | "exportMany" | "registerImportNormalizer"
>;
function actingUser(): string {
const actor = getActor();
if (!actor) throw new Error("This call needs an acting user");
return actor;
}
async function requirePermissions(userId: string, names: string[]) {
const permissions = PermissionManager.getInstance();
for (const name of names) {
if (!(await permissions.hasPermission(userId, name))) {
throw new Error(`The acting user lacks the ${name} permission`);
}
}
}
function toResult(
results: Awaited<
ReturnType<typeof import("../hosts/import/host-import.js").runBulkImport>
>,
): PluginHostImportResult {
return {
success: results.success,
updated: results.updated,
skipped: results.skipped,
failed: results.failed,
errors: results.errors,
createdHostIds: results.createdHostIds,
...(results.rolledBack ? { rolledBack: true } : {}),
};
}
export function createPluginHostImport({
manifest,
bag,
audit,
}: Deps): ImportApi {
const pluginId = manifest.id;
const declared = manifest.capabilities;
const runImport = async (
action: string,
run: (userId: string) => Promise<PluginHostImportResult>,
needsCredentials: boolean,
): Promise<PluginHostImportResult> => {
try {
await assertCapability(pluginId, "hosts:write", declared, action);
if (needsCredentials) {
await assertCapability(pluginId, "credentials:write", declared, action);
}
const userId = actingUser();
await requirePermissions(userId, ["hosts.create", "hosts.edit"]);
const result = await DatabaseSaveTrigger.batched(() => run(userId))();
await audit(
action,
`${result.success} created, ${result.updated} updated, ${result.skipped} skipped, ${result.failed} failed`,
{ success: true },
);
return result;
} catch (error) {
await audit(action, "host import", {
success: false,
errorMessage: error instanceof Error ? error.message : String(error),
});
throw error;
}
};
return {
importMany: (hosts, options?: PluginHostImportOptions) =>
runImport(
"hosts_import",
async (userId) => {
const { runBulkImport } =
await import("../hosts/import/host-import.js");
return toResult(
await runBulkImport(userId, {
hosts,
overwrite: options?.overwrite,
credentials: options?.credentials,
atomic: options?.atomic,
}),
);
},
Array.isArray(options?.credentials) && options.credentials.length > 0,
),
importSshConfig: (content, options) =>
runImport(
"hosts_import_ssh_config",
async (userId) => {
const { runSshConfigImport } =
await import("../hosts/import/host-import.js");
return toResult(
await runSshConfigImport(userId, {
content,
overwrite: options?.overwrite,
atomic: options?.atomic,
}),
);
},
false,
),
exportMany: async (options): Promise<PluginHostExport> => {
const share = options?.share === true;
try {
await assertCapability(
pluginId,
"hosts:export",
declared,
"hosts_export",
);
const userId = actingUser();
const { exportAllHosts } =
await import("../hosts/import/host-export.js");
const exported = await exportAllHosts(userId, share, options?.hostIds);
await audit(
"export_hosts",
`exported ${exported.hosts.length} hosts${share ? " without secrets" : " with secrets"}`,
{ success: true },
);
return exported;
} catch (error) {
await audit("export_hosts", share ? "share export" : "full export", {
success: false,
errorMessage: error instanceof Error ? error.message : String(error),
});
throw error;
}
},
registerImportNormalizer: (normalizer: PluginHostImportNormalizer) => {
if (!declared.includes("hosts:write")) {
throw capabilityRefused(pluginId, "hosts:write");
}
// Same key the import path has always read, so the older
// ctx.registry.provide form keeps working.
const key = `${pluginId}.hostImportNormalizer`;
registry.provide(key, normalizer);
const remove = () => {
registry.revoke(key, normalizer);
};
const drop = bag?.add(remove, "host import normalizer");
return () => {
drop?.();
remove();
};
},
};
}
+384 -61
View File
@@ -20,18 +20,27 @@ import type {
PluginShareableUser,
PluginShareableRole,
PluginHostJumpHost,
PluginHostShareGrant,
} from "@termix-ssh/plugin-sdk/backend";
import { PLUGIN_HOST_INPUT_KEYS } from "@termix-ssh/plugin-sdk/backend";
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
import { assertCapability, capabilityRefused } from "./permissions.js";
import { hostSessionStatus } from "../hosts/host-session-status.js";
import { getActor } from "./actor.js";
import { hostStatusService } from "../hosts/status/host-status-service.js";
import { hostStatus, provideHostStatus } from "./host-status-provider.js";
import { provideActivity, recentActivity } from "./activity-provider.js";
import { loadProbeTargets } from "./ctx-probe.js";
import { createPluginHostImport } from "./ctx-host-import.js";
import { parseSshOptions } from "../hosts/ssh-options.js";
import { hostTerminalExport } from "../database/routes/host-normalizers.js";
import type { DisposableBag } from "./disposables.js";
import { pluginEvents, TOPICS } from "./events.js";
import { pluginLogger } from "../utils/logger.js";
import {
assertHostsUnlocked,
assertManualCreateAllowed,
provideHostLocks,
} from "../hosts/locks.js";
type AuditFn = (
action: string,
@@ -171,7 +180,7 @@ export function toRecord(
connectionOrigin: origin === "local" || origin === "remote" ? origin : null,
sshOptions: hostTerminalExport(host).sshOptions,
pluginSettings,
status: Number.isInteger(id) ? hostStatusService.get(id) : null,
status: Number.isInteger(id) ? hostStatus.get(id) : null,
localOnly: asFlag(host.localOnly, false),
createdAt: (host.createdAt as string | null) ?? null,
updatedAt: (host.updatedAt as string | null) ?? null,
@@ -204,6 +213,8 @@ void inputKeysComplete;
export function toHostWrite(input: PluginHostUpdateInput): {
row: Record<string, unknown>;
settings: Record<string, Record<string, unknown>>;
protocolAuth: Record<string, unknown> | null;
pluginEnable: Record<string, boolean>;
} {
const unknown = Object.keys(input ?? {}).filter(
(key) => !INPUT_KEYS.has(key),
@@ -265,6 +276,32 @@ export function toHostWrite(input: PluginHostUpdateInput): {
? null
: JSON.stringify(parseSshOptions(input.sshOptions)),
);
for (const key of [
"password",
"key",
"keyPassword",
"keyType",
"sudoPassword",
] as const) {
const value = input[key];
if (value !== undefined && value !== null && typeof value !== "string") {
throw new PluginHostInputError(`${key} must be a string`);
}
set(key, value === undefined ? undefined : value || null);
}
let protocolAuth: Record<string, unknown> | null = null;
if (input.protocolAuth !== undefined && input.protocolAuth !== null) {
if (
typeof input.protocolAuth !== "object" ||
Array.isArray(input.protocolAuth)
) {
throw new PluginHostInputError(
"protocolAuth must map protocol ids to their logins",
);
}
protocolAuth = input.protocolAuth as Record<string, unknown>;
}
const settings: Record<string, Record<string, unknown>> = {};
if (input.pluginSettings !== undefined && input.pluginSettings !== null) {
@@ -285,7 +322,136 @@ export function toHostWrite(input: PluginHostUpdateInput): {
settings[pluginId] = values;
}
}
return { row, settings };
const pluginEnable: Record<string, boolean> = {};
if (input.pluginEnable !== undefined && input.pluginEnable !== null) {
if (
typeof input.pluginEnable !== "object" ||
Array.isArray(input.pluginEnable)
) {
throw new PluginHostInputError(
"pluginEnable must map plugin ids to true or false",
);
}
for (const [pluginId, enabled] of Object.entries(input.pluginEnable)) {
if (typeof enabled !== "boolean") {
throw new PluginHostInputError(
`pluginEnable.${pluginId} must be true or false`,
);
}
pluginEnable[pluginId] = enabled;
}
}
return { row, settings, protocolAuth, pluginEnable };
}
/** Folds pluginEnable into each plugin's settings under its enableKey. */
async function foldPluginEnable(
settings: Record<string, Record<string, unknown>>,
pluginEnable: Record<string, boolean>,
): Promise<Record<string, Record<string, unknown>>> {
const ids = Object.keys(pluginEnable);
if (ids.length === 0) return settings;
const { hostSettingsPlugins } =
await import("../database/routes/host-plugin-settings.js");
const running = hostSettingsPlugins();
const out = { ...settings };
for (const pluginId of ids) {
const manifest = running.find((m) => m.id === pluginId);
if (!manifest) {
// checkHostPluginSettingsInput skips and logs a plugin that is not running.
out[pluginId] = { ...out[pluginId] };
continue;
}
const enableKey = manifest.contributes?.settings?.host?.enableKey;
if (!enableKey) {
throw new PluginHostInputError(`${pluginId} has no host switch`);
}
out[pluginId] = { ...out[pluginId], [enableKey]: pluginEnable[pluginId] };
}
return out;
}
/** Writes a host's protocol logins; a credential the owner can't use is refused. */
async function writeHostProtocolAuth(
ownerId: string,
hostId: number,
protocolAuth: Record<string, unknown> | null,
): Promise<void> {
if (!protocolAuth) return;
const { readProtocolAuthPayload, writeProtocolAuth } =
await import("../hosts/protocol-auth/protocol-auth.js");
const patch = readProtocolAuthPayload({ protocolAuth });
if (!patch) return;
const { findUsableCredential } =
await import("../hosts/usable-credential.js");
for (const [protocol, login] of patch) {
const credentialId = Number(login?.credentialId);
if (
Number.isInteger(credentialId) &&
credentialId > 0 &&
!(await findUsableCredential(credentialId, ownerId))
) {
throw new PluginHostInputError(
`protocolAuth.${protocol}: credential ${credentialId} not found`,
);
}
}
await writeProtocolAuth(ownerId, hostId, patch, { isOwner: true });
}
const SHARE_LEVELS: readonly string[] = ["connect", "view", "edit", "manage"];
/** Upserts one grant and snapshots the host's secrets for it. */
async function grantHostShare(
hostId: number,
ownerId: string,
grantedBy: string,
target: PluginShareTarget,
permissionLevel: PluginHostShareLevel,
expiresAt: string | null,
): Promise<number> {
const { createCurrentRbacAccessRepository } =
await import("../database/repositories/factory.js");
const grant = await createCurrentRbacAccessRepository().upsertHostAccess({
hostId,
grantedBy,
permissionLevel,
expiresAt,
...(target.type === "user"
? { targetType: "user" as const, targetUserId: target.id as string }
: { targetType: "role" as const, targetRoleId: target.id as number }),
});
const { SharedHostSecretsManager } =
await import("../utils/shared-host-secrets-manager.js");
const secretsManager = SharedHostSecretsManager.getInstance();
try {
if (target.type === "user") {
await secretsManager.snapshotForUser(
grant.id,
hostId,
target.id as string,
ownerId,
);
} else {
await secretsManager.snapshotForRole(
grant.id,
hostId,
target.id as number,
ownerId,
);
}
} catch {
// A snapshot failure never blocks the grant: the recipient can
// still be prompted, or the owner can retry sharing later.
}
return grant.id;
}
function hoursFromNow(durationHours: number | undefined): string | null {
return durationHours && durationHours > 0
? new Date(Date.now() + durationHours * 60 * 60 * 1000).toISOString()
: null;
}
function actingUser(): string {
@@ -389,6 +555,8 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
};
return {
...createPluginHostImport({ manifest, bag, audit }),
list: async () => {
await requireRead();
const userId = actingUser();
@@ -471,6 +639,7 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
create: async (host: PluginHostCreateInput): Promise<PluginHostRecord> => {
try {
await requireWrite();
assertManualCreateAllowed(pluginId);
} catch (error) {
await audit("hosts_create", host?.name ?? host?.ip ?? "host", {
success: false,
@@ -479,8 +648,10 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
throw error;
}
const userId = actingUser();
const { row, settings } = toHostWrite(host);
const writes = await checkSettings(settings);
const { row, settings, protocolAuth, pluginEnable } = toHostWrite(host);
const writes = await checkSettings(
await foldPluginEnable(settings, pluginEnable),
);
const { createCurrentHostRepository } =
await import("../database/repositories/factory.js");
const { applyHostDefaultsToWrite, applyDefaultsAfterHostWrite } =
@@ -499,6 +670,7 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
userId,
columns,
);
await writeHostProtocolAuth(userId, created.id, protocolAuth);
await applyDefaultsAfterHostWrite(created.id);
await writeSettings(created.id, writes);
await markSettingsOwn(created.id, writes);
@@ -515,6 +687,7 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
): Promise<PluginHostRecord | null> => {
try {
await requireWrite();
assertHostsUnlocked([hostId], pluginId);
} catch (error) {
await audit("hosts_update", `host ${hostId}`, {
success: false,
@@ -523,8 +696,10 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
throw error;
}
const userId = actingUser();
const { row, settings } = toHostWrite(patch);
const writes = await checkSettings(settings);
const { row, settings, protocolAuth, pluginEnable } = toHostWrite(patch);
const writes = await checkSettings(
await foldPluginEnable(settings, pluginEnable),
);
const { createCurrentHostRepository } =
await import("../database/repositories/factory.js");
const { applyHostDefaultsToWrite, applyDefaultsAfterHostWrite } =
@@ -555,6 +730,7 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
columns,
);
if (updated) {
await writeHostProtocolAuth(userId, hostId, protocolAuth);
await writeSettings(hostId, writes);
await markSettingsOwn(hostId, writes);
await applyDefaultsAfterHostWrite(hostId, {
@@ -579,6 +755,7 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
delete: async (hostId: number): Promise<boolean> => {
try {
await requireWrite();
assertHostsUnlocked([hostId], pluginId);
const userId = actingUser();
const { PermissionManager } =
await import("../utils/permission-manager.js");
@@ -626,6 +803,7 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
): Promise<PluginHostShareResult> => {
try {
await requireWrite();
assertHostsUnlocked([hostId], pluginId);
} catch (error) {
await audit("hosts_share", `host ${hostId}`, {
success: false,
@@ -655,10 +833,8 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
return result;
}
const {
createCurrentHostResolutionRepository,
createCurrentRbacAccessRepository,
} = await import("../database/repositories/factory.js");
const { createCurrentHostResolutionRepository } =
await import("../database/repositories/factory.js");
const ownerId =
(await createCurrentHostResolutionRepository().findHostOwnerId(
hostId,
@@ -672,56 +848,95 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
return { hostId, shared: false, reason: "owner" };
}
const expiresAt =
durationHours && durationHours > 0
? new Date(Date.now() + durationHours * 60 * 60 * 1000).toISOString()
: null;
const rbacAccessRepository = createCurrentRbacAccessRepository();
const { SharedHostSecretsManager } =
await import("../utils/shared-host-secrets-manager.js");
const secretsManager = SharedHostSecretsManager.getInstance();
const expiresAt = hoursFromNow(durationHours);
for (const target of targets) {
const grant = await rbacAccessRepository.upsertHostAccess({
await grantHostShare(
hostId,
grantedBy: userId,
ownerId,
userId,
target,
permissionLevel,
expiresAt,
...(target.type === "user"
? { targetType: "user" as const, targetUserId: target.id as string }
: {
targetType: "role" as const,
targetRoleId: target.id as number,
}),
});
try {
if (target.type === "user") {
await secretsManager.snapshotForUser(
grant.id,
hostId,
target.id as string,
ownerId,
);
} else {
await secretsManager.snapshotForRole(
grant.id,
hostId,
target.id as number,
ownerId,
);
}
} catch {
// A snapshot failure never blocks the grant: the recipient can
// still be prompted, or the owner can retry sharing later.
}
);
}
await audit("hosts_share", `host ${hostId}`, { success: true });
return { hostId, shared: true };
},
setShares: async (
hostId: number,
grants: PluginHostShareGrant[],
): Promise<PluginHostShareResult> => {
const fail = async (reason: string): Promise<PluginHostShareResult> => {
await audit("hosts_set_shares", `host ${hostId}`, {
success: false,
errorMessage: reason,
});
return { hostId, shared: false, reason };
};
try {
await requireWrite();
assertHostsUnlocked([hostId], pluginId);
} catch (error) {
await fail(error instanceof Error ? error.message : String(error));
throw error;
}
if (!Array.isArray(grants)) {
throw new PluginHostInputError("grants must be a list");
}
for (const grant of grants) {
const type = grant?.target?.type;
if (
(type !== "user" && type !== "role") ||
!SHARE_LEVELS.includes(grant.permissionLevel)
) {
throw new PluginHostInputError(
"Each grant needs a user or role target and a share level",
);
}
}
const userId = actingUser();
const {
createCurrentHostResolutionRepository,
createCurrentRbacAccessRepository,
} = await import("../database/repositories/factory.js");
const ownerId =
await createCurrentHostResolutionRepository().findHostOwnerId(hostId);
if (ownerId !== userId) return fail("forbidden");
if (
grants.some((g) => g.target.type === "user" && g.target.id === userId)
) {
return fail("owner");
}
const kept = new Set<number>();
for (const grant of grants) {
kept.add(
await grantHostShare(
hostId,
ownerId,
userId,
grant.target,
grant.permissionLevel,
hoursFromNow(grant.durationHours),
),
);
}
const repository = createCurrentRbacAccessRepository();
for (const existing of await repository.listHostAccess(hostId)) {
if (!kept.has(existing.id)) {
await repository.revokeHostAccess(existing.id, hostId);
}
}
const { pruneHostGrants } = await import("../database/routes/rbac.js");
await pruneHostGrants(hostId);
await audit("hosts_set_shares", `host ${hostId}`, { success: true });
return { hostId, shared: true };
},
listUsers: async (): Promise<PluginShareableUser[]> => {
try {
await requireWrite();
@@ -787,38 +1002,146 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
await requireRead();
const userId = actingUser();
if (!(await actorMayReach(hostId))) return;
const { recordRecentActivity } =
await import("../services/recent-activity.js");
await recordRecentActivity(userId, { type, hostId, hostName });
await recentActivity.record(userId, { type, hostId, hostName });
},
probeTargets: async (filter) => {
await requireRead();
const actor = getActor();
if (!actor) {
throw new Error("ctx.hosts.probeTargets needs an acting user");
}
const ids = filter?.hostIds?.filter((id) => Number.isInteger(id));
return loadProbeTargets(actor, ids);
},
sessionActive: (hostId) => {
requireReadSync();
return hostSessionStatus.hasActiveSession(hostId);
},
locks: {
provide: (provider) => {
if (!declared.includes("hosts:lock")) {
throw capabilityRefused(pluginId, "hosts:lock");
}
const remove = provideHostLocks(
pluginId,
manifest.name || pluginId,
provider,
);
void audit("host_locks_provide", `${pluginId} manages hosts`, {
success: true,
});
const drop = bag?.add(remove, "host lock provider");
return () => {
drop?.();
remove();
};
},
},
folders: {
ensure: async (name, style) => {
await requireWrite();
const userId = actingUser();
const { createCurrentHostFolderRepository } =
await import("../database/repositories/factory.js");
await createCurrentHostFolderRepository().upsertMetadata(
userId,
name,
style?.color ?? null,
style?.icon ?? null,
);
},
removeEmpty: async (name) => {
await requireWrite();
const userId = actingUser();
const { createCurrentHostFolderRepository } =
await import("../database/repositories/factory.js");
return createCurrentHostFolderRepository().deleteEmptyFolderRecords(
userId,
name,
);
},
},
activity: {
provide: (provider) => {
if (!declared.includes("hosts:activity-provide")) {
throw capabilityRefused(pluginId, "hosts:activity-provide");
}
const remove = provideActivity(
pluginId,
provider as Parameters<typeof provideActivity>[1],
);
void audit(
"host_activity_provide",
`${pluginId} stores recent activity`,
{
success: true,
},
);
const drop = bag?.add(remove, "activity provider");
return () => {
drop?.();
remove();
};
},
},
// Not audited: pollers call these on every sample.
status: {
provide: (provider) => {
requireReadSync();
if (!declared.includes("hosts:status-provide")) {
throw capabilityRefused(pluginId, "hosts:status-provide");
}
const remove = provideHostStatus(pluginId, {
start: () => provider.start(),
stop: () => provider.stop(),
get: (hostId) => provider.get(hostId),
statusesFor: (userId, requested) =>
provider.statusesFor(userId, requested ? [...requested] : null),
refresh: (userId) => provider.refresh(userId),
check: (hostId) => provider.check(hostId),
reportLogin: (hostId, outcome) =>
provider.reportLogin(hostId, outcome),
registerPort: (type, resolve) => provider.registerPort(type, resolve),
getIntervalSeconds: () => provider.getIntervalSeconds(),
setIntervalSeconds: (seconds) => provider.setIntervalSeconds(seconds),
});
void audit("host_status_provide", `${pluginId} provides host status`, {
success: true,
});
const drop = bag?.add(remove, "host status provider");
return () => {
drop?.();
remove();
};
},
get: async (hostId) => {
await requireRead();
if (!(await actorMayReach(hostId))) return null;
return hostStatusService.get(hostId);
return hostStatus.get(hostId);
},
check: async (hostId) => {
await requireRead();
if (!(await actorMayReach(hostId))) return null;
return hostStatusService.check(hostId);
return hostStatus.check(hostId);
},
// Synchronous for the same reason as trackSession.
reportLogin: (hostId, outcome) => {
requireReadSync();
void actorMayReach(hostId)
.then((allowed) => {
if (allowed) hostStatusService.reportLogin(hostId, outcome);
if (allowed) hostStatus.reportLogin(hostId, outcome);
})
.catch(() => {});
},
registerPort: (connectionType, resolve) => {
requireReadSync();
const unregister = hostStatusService.registerPort(
connectionType,
resolve,
);
const unregister = hostStatus.registerPort(connectionType, resolve);
const drop = bag?.add(unregister, `status port for ${connectionType}`);
return () => {
drop?.();
+172
View File
@@ -0,0 +1,172 @@
/**
* Reachability for status providers: ctx.network.tcpProbe, ctx.ssh.probe and
* ctx.hosts.probeTargets. Nothing here ever logs in to the probed host.
*/
import type {
PluginHostProbeTarget,
PluginNetwork,
} from "@termix-ssh/plugin-sdk/backend";
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
import { assertCapability } from "./permissions.js";
import { getActor } from "./actor.js";
import { tcpPing } from "./probe/tcp-ping.js";
import { sharedJumpProbes } from "./probe/jump-probe-pool.js";
import type { HostStatusTargetRow } from "../database/repositories/host-resolution-repository.js";
type AuditFn = (
action: string,
details: string,
outcome: { success: boolean; errorMessage?: string },
) => Promise<void>;
const MIN_TIMEOUT_MS = 500;
const MAX_TIMEOUT_MS = 15_000;
const DEFAULT_TIMEOUT_MS = 5000;
function clampTimeout(value?: number): number {
const n = Number(value);
if (!Number.isFinite(n)) return DEFAULT_TIMEOUT_MS;
return Math.min(MAX_TIMEOUT_MS, Math.max(MIN_TIMEOUT_MS, n));
}
function validPort(port: number): boolean {
return Number.isInteger(port) && port > 0 && port <= 65535;
}
function parseJumpHosts(raw: string | null): Array<{ hostId: number }> {
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed)
? parsed.filter(
(hop): hop is { hostId: number } =>
!!hop && Number.isInteger((hop as { hostId?: unknown }).hostId),
)
: [];
} catch {
return [];
}
}
function toTarget(row: HostStatusTargetRow): PluginHostProbeTarget {
return {
id: row.id,
ownerUserId: row.userId,
ip: row.ip,
port: row.port,
connectionType: row.connectionType || "ssh",
jumpHosts: parseJumpHosts(row.jumpHosts),
statusCheckEnabled: row.statusCheckEnabled !== false,
statusCheckInterval: row.statusCheckInterval ?? null,
};
}
export function createPluginNetwork(deps: {
manifest: PluginManifest;
audit: AuditFn;
}): PluginNetwork {
const { manifest, audit } = deps;
let audited = false;
return {
tcpProbe: async (host, port, options) => {
await assertCapability(
manifest.id,
"network:probe",
manifest.capabilities,
"network_tcp_probe",
);
if (typeof host !== "string" || !host.trim() || host.length > 255) {
throw new Error("A host name or address is required");
}
if (!validPort(port))
throw new Error("A port from 1 to 65535 is required");
if (!audited) {
audited = true;
await audit("network_tcp_probe", "checked if TCP ports answer", {
success: true,
});
}
return tcpPing(host.trim(), port, clampTimeout(options?.timeoutMs));
},
};
}
/** The hosts the actor owns or was shared, narrowed to ones they may connect to. */
export async function loadProbeTargets(
actor: string,
hostIds?: number[],
): Promise<PluginHostProbeTarget[]> {
const {
createCurrentHostResolutionRepository,
createCurrentRbacAccessRepository,
createCurrentRoleRepository,
} = await import("../database/repositories/factory.js");
const { PermissionManager } = await import("../utils/permission-manager.js");
const hosts = createCurrentHostResolutionRepository();
const own = await hosts.listStatusTargets({
userId: actor,
hostIds,
});
const ownIds = new Set(own.map((row) => row.id));
const roleIds = await createCurrentRoleRepository().listUserRoleIds(actor);
const entries =
await createCurrentRbacAccessRepository().listVisibleHostAccessEntries(
actor,
roleIds,
);
let sharedIds = [...new Set(entries.map((entry) => entry.hostId))].filter(
(id) => !ownIds.has(id) && (!hostIds || hostIds.includes(id)),
);
if (sharedIds.length > 0) {
const allowed =
await PermissionManager.getInstance().filterAccessibleHostIds(
actor,
sharedIds,
);
sharedIds = sharedIds.filter((id) => allowed.has(id));
}
const shared =
sharedIds.length > 0
? await hosts.listStatusTargets({ hostIds: sharedIds })
: [];
return [...own, ...shared].map(toTarget);
}
export async function probeHost(
hostId: number,
options?: { port?: number; timeoutMs?: number },
): Promise<boolean> {
const actor = getActor();
if (actor) {
const { PermissionManager } =
await import("../utils/permission-manager.js");
const access = await PermissionManager.getInstance().canAccessHost(
actor,
hostId,
"connect",
);
if (!access.hasAccess) return false;
}
const { createCurrentHostResolutionRepository } =
await import("../database/repositories/factory.js");
const [row] = await createCurrentHostResolutionRepository().listStatusTargets(
{ hostIds: [hostId] },
);
if (!row) return false;
const target = toTarget(row);
const port = options?.port ?? target.port;
if (!validPort(port)) throw new Error("A port from 1 to 65535 is required");
const timeout = clampTimeout(options?.timeoutMs);
return target.jumpHosts.length > 0
? sharedJumpProbes.ping(
target.jumpHosts,
target.ownerUserId,
target.ip,
port,
timeout,
)
: tcpPing(target.ip, port, timeout);
}
+83 -5
View File
@@ -26,6 +26,8 @@ import { getActor, getActorSessionId } from "./actor.js";
import type { DisposableBag } from "./disposables.js";
import { redactHostSecrets } from "./ctx-hosts.js";
import { issueUserToken } from "./ctx-users.js";
import { hostStatus } from "./host-status-provider.js";
import { probeHost } from "./ctx-probe.js";
import {
getSshAuthProvider,
listSshAuthProviders,
@@ -36,9 +38,12 @@ import { classifyKeyboardInteractive } from "../hosts/connect/keyboard-interacti
import { ensureCoreSshAuthProviders } from "../hosts/connect/core-providers.js";
import {
getLoginMethod,
listSecondFactors,
registerLoginMethod,
registerSecondFactor,
} from "../auth/registry.js";
import { provideAuthPolicy } from "../auth/policy.js";
import { provideSshTransport } from "../hosts/connect/transport-providers.js";
import type { VerifiedIdentity } from "../auth/types.js";
import type {
MutableConnectConfig,
@@ -224,11 +229,7 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
const reportLogin = (host: number | PluginSshHost) => {
const hostId = typeof host === "number" ? host : lookup(host)?.host.id;
if (!Number.isInteger(hostId) || hostId <= 0) return;
void import("../hosts/status/host-status-service.js")
.then(({ hostStatusService }) =>
hostStatusService.reportLogin(hostId, { ok: true }),
)
.catch(() => {});
hostStatus.reportLogin(hostId, { ok: true });
};
/** Checks, connects and audits one new connection. */
@@ -296,6 +297,31 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
}`;
return {
transports: {
provide: (provider) => {
if (!declared.includes("ssh:transport")) {
throw capabilityRefused(pluginId, "ssh:transport");
}
const remove = provideSshTransport(pluginId, provider);
void audit(
"ssh_transport_provide",
`${pluginId} carries host connections`,
{
success: true,
},
);
const drop = bag.add(remove, "ssh transport");
return () => {
drop();
remove();
};
},
},
probe: async (hostId, probeOptions) => {
await assertCapability(pluginId, "ssh:connect", declared);
if (!Number.isInteger(hostId) || hostId <= 0) return false;
return probeHost(hostId, probeOptions);
},
connect: async (host, options) => {
const connection = await connectOnce(host, options);
return {
@@ -306,6 +332,33 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
};
},
exec: async (host, command, execOptions) => {
const connection = await connectOnce(host, {
...execOptions,
purpose: execOptions?.purpose ?? "exec",
timeoutMs: undefined,
});
const details = `${describeHost(host)}: command run`;
try {
const { runExec } = await import("./ctx-ssh-exec.js");
const result = await runExec(
connection.client as never,
command,
execOptions,
);
await audit("ssh_exec", details, { success: true });
return result;
} catch (error) {
await audit("ssh_exec", details, {
success: false,
errorMessage: error instanceof Error ? error.message : String(error),
});
throw error;
} finally {
connection.dispose();
}
},
withConnection: async (host, options, fn) => {
await checkSsh(true);
const { resolveConnectHost } =
@@ -826,6 +879,8 @@ export function createPluginAuth({ manifest, bag, audit }: Deps): PluginAuth {
getActor() === userId ? getActorSessionId() : undefined,
);
await createCurrentTrustedDeviceRepository().deleteByUserId(userId);
// The session that set it up may have been a setup only one.
await createCurrentSessionRepository().clearRestrictionForUser(userId);
await audit("auth_factor_enrolled", `${factorId} for ${userId}`, {
success: true,
});
@@ -904,5 +959,28 @@ export function createPluginAuth({ manifest, bag, audit }: Deps): PluginAuth {
},
issueUserToken: (userId, options) =>
issueUserToken(manifest, userId, options),
policy: {
provide: (provider) => {
if (!declared.includes("auth:policy")) {
throw capabilityRefused(pluginId, "auth:policy");
}
const remove = provideAuthPolicy(pluginId, provider);
void audit("auth_policy_provide", `${pluginId} sets login rules`, {
success: true,
});
const drop = bag.add(remove, "login policy");
return () => {
drop();
remove();
};
},
},
listSecondFactors: () =>
listSecondFactors().map((factor) => ({
id: factor.id,
pluginId: factor.pluginId,
})),
};
}
+123
View File
@@ -0,0 +1,123 @@
/**
* Runs one command on an open ssh2 client for ctx.ssh.exec, with a time limit
* and caps on how much output is kept.
*/
import type { PluginSshExecOptions } from "@termix-ssh/plugin-sdk/backend";
export const EXEC_DEFAULT_TIMEOUT_MS = 30_000;
export const EXEC_MAX_TIMEOUT_MS = 10 * 60_000;
export const EXEC_DEFAULT_MAX_OUTPUT = 256 * 1024;
export const EXEC_HARD_MAX_OUTPUT = 4 * 1024 * 1024;
export const EXEC_MAX_COMMAND_LENGTH = 64 * 1024;
interface ExecStream {
on: (event: string, listener: (...args: never[]) => void) => unknown;
stderr: { on: (event: string, listener: (chunk: Buffer) => void) => unknown };
write: (data: string) => unknown;
end: (data?: string) => unknown;
close: () => void;
}
export interface ExecClient {
exec: (
command: string,
callback: (error: Error | undefined, stream: ExecStream) => void,
) => unknown;
}
export interface ExecResult {
stdout: string;
stderr: string;
/** Null when the command was killed by a signal or hit the time limit. */
code: number | null;
truncated: boolean;
timedOut: boolean;
}
export function runExec(
client: ExecClient,
command: string,
options: PluginSshExecOptions = {},
): Promise<ExecResult> {
if (typeof command !== "string" || command.trim() === "") {
return Promise.reject(new Error("A command is required"));
}
if (command.length > EXEC_MAX_COMMAND_LENGTH) {
return Promise.reject(new Error("The command is too long"));
}
const timeoutMs = Math.min(
EXEC_MAX_TIMEOUT_MS,
Math.max(1000, options.timeoutMs ?? EXEC_DEFAULT_TIMEOUT_MS),
);
const maxOutput = Math.min(
EXEC_HARD_MAX_OUTPUT,
Math.max(1024, options.maxOutputBytes ?? EXEC_DEFAULT_MAX_OUTPUT),
);
return new Promise<ExecResult>((resolve, reject) => {
let settled = false;
let timedOut = false;
let truncated = false;
let stdout = "";
let stderr = "";
let timer: NodeJS.Timeout | null = null;
const finish = (code: number | null) => {
if (settled) return;
settled = true;
if (timer) clearTimeout(timer);
resolve({ stdout, stderr, code, truncated, timedOut });
};
const append = (current: string, chunk: Buffer): string => {
const room = maxOutput - Buffer.byteLength(current);
if (room <= 0) {
truncated = true;
return current;
}
if (chunk.length > room) {
truncated = true;
return current + chunk.subarray(0, room).toString("utf8");
}
return current + chunk.toString("utf8");
};
client.exec(command, (error, stream) => {
if (error) {
if (!settled) {
settled = true;
reject(error);
}
return;
}
timer = setTimeout(() => {
timedOut = true;
try {
stream.close();
} catch {
// already closed
}
finish(null);
}, timeoutMs);
stream.on("data", ((chunk: Buffer) => {
stdout = append(stdout, chunk);
}) as never);
stream.stderr.on("data", (chunk: Buffer) => {
stderr = append(stderr, chunk);
});
stream.on("close", ((code: number | null | undefined) => {
finish(typeof code === "number" ? code : null);
}) as never);
stream.on("error", ((streamError: Error) => {
if (!settled) {
settled = true;
if (timer) clearTimeout(timer);
reject(streamError);
}
}) as never);
if (options.stdin !== undefined) stream.end(options.stdin);
});
});
}
+49 -6
View File
@@ -22,6 +22,9 @@
import { pluginLogger } from "../utils/logger.js";
import { getRequestBaseUrlWithForceHTTPS } from "../utils/request-origin.js";
import { pluginEvents } from "./events.js";
import { createPluginDesktopLocal } from "./ctx-desktop-local.js";
import { createPluginAuditReader } from "./ctx-audit.js";
import { createPluginNetwork } from "./ctx-probe.js";
import * as registry from "./registry.js";
import * as serviceRegistry from "./service-registry.js";
import type { ServiceRegistration } from "./service-registry.js";
@@ -51,6 +54,12 @@ import {
} from "@termix-ssh/plugin-sdk/backend";
import type { PluginTableDefinition } from "@termix-ssh/plugin-sdk/db";
import * as syncRegistry from "./sync-registry.js";
import { provideSync } from "../sync/provider.js";
import { createSyncHost } from "../sync/host.js";
import type {
PluginSync,
PluginSyncHost,
} from "@termix-ssh/plugin-sdk/backend";
import {
needsExplicitPersist,
resolveDatabaseDialect,
@@ -186,6 +195,34 @@ export function createPluginContext(
handle: PluginHandle,
): PluginContext {
const pluginId = manifest.id;
let syncHost: PluginSyncHost | undefined;
const syncApi: PluginSync = {
registerEntity: (entity) => {
const dispose = syncRegistry.registerEntity(pluginId, entity);
handle.bag.add(dispose, `sync entity ${entity.type}`);
},
provide: (provider) => {
if (!manifest.capabilities.includes("sync:provide")) {
throw capabilityRefused(pluginId, "sync:provide");
}
const remove = provideSync(pluginId, provider);
const drop = handle.bag.add(remove, "sync provider");
return () => {
drop();
remove();
};
},
} as PluginSync;
// Not enumerable, so tools that walk the context do not trip the check.
Object.defineProperty(syncApi, "host", {
enumerable: false,
get() {
if (!manifest.capabilities.includes("sync:provide")) {
throw capabilityRefused(pluginId, "sync:provide");
}
return (syncHost ??= createSyncHost());
},
});
const auditCall = (
action: string,
details: string,
@@ -517,12 +554,7 @@ export function createPluginContext(
},
},
sync: {
registerEntity: (entity) => {
const dispose = syncRegistry.registerEntity(pluginId, entity);
handle.bag.add(dispose, `sync entity ${entity.type}`);
},
},
sync: syncApi,
kv: {
get: (key) => kvGet(key),
@@ -950,6 +982,7 @@ export function createPluginContext(
bag: handle.bag,
}),
fetch: createPluginFetch({ manifest, audit: auditCall }),
network: createPluginNetwork({ manifest, audit: auditCall }),
process: createPluginProcess({
manifest,
bag: handle.bag,
@@ -964,6 +997,11 @@ export function createPluginContext(
openIsolatedWindow: (request) => desktopOpenIsolatedWindow(request),
launchExternalClient: (request) => desktopLaunchExternalClient(request),
available: () => isElectronIpcAvailable(),
local: createPluginDesktopLocal({
manifest,
bag: handle.bag,
audit: auditCall,
}),
},
credentials: createPluginCredentials({
@@ -995,6 +1033,11 @@ export function createPluginContext(
// Auditing must never break the caller.
}
},
...createPluginAuditReader({
manifest,
bag: handle.bag,
audit: auditCall,
}),
},
/**
@@ -0,0 +1,572 @@
/**
* Local filesystem helpers for plugins on the desktop app's embedded backend.
* Ported from the old Electron local-files module. The path rules are the
* security part: a download can never leave the folder the user picked, links
* inside it are refused, and a download never replaces a file by accident.
*/
import fs from "node:fs";
import fsp from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type {
PluginLocalDirEntry,
PluginLocalDownload,
PluginLocalWalkResult,
} from "@termix-ssh/plugin-sdk/backend";
export class LocalFileError extends Error {
code: string;
constructor(code: string, message: string) {
super(message);
this.name = "LocalFileError";
this.code = code;
}
}
type PathImpl = typeof path;
export function normalizeLocalPath(candidate: unknown): string {
if (typeof candidate !== "string" || !path.isAbsolute(candidate)) {
throw new Error("A local absolute path is required");
}
return path.normalize(candidate);
}
// A single path segment: no separators, not "." / "..", no NULs.
export function requireEntryName(name: unknown, what: string): string {
const safeName = String(name || "").trim();
if (
!safeName ||
safeName === "." ||
safeName === ".." ||
/[/\\\0]/.test(safeName)
) {
throw new Error(`Invalid ${what}`);
}
return safeName;
}
// pathImpl is injectable so the Windows rules can be tested on any OS.
export function assertWithinRoot(
rootPath: string,
candidate: string,
pathImpl: PathImpl = path,
): string {
if (typeof rootPath !== "string" || !rootPath.trim()) {
throw new LocalFileError("EINVAL", "A download folder is required");
}
const root = pathImpl.normalize(pathImpl.resolve(rootPath));
const target = pathImpl.normalize(pathImpl.resolve(candidate));
const rel = pathImpl.relative(root, target);
const escapes =
rel === "" ||
rel === ".." ||
rel.startsWith(`..${pathImpl.sep}`) ||
pathImpl.isAbsolute(rel) ||
rel.split(pathImpl.sep).some((seg) => seg === ".." || seg === "");
if (escapes) {
throw new LocalFileError(
"EINVAL",
`"${pathImpl.basename(candidate)}" would be written outside the selected folder`,
);
}
return target;
}
// Resolve a deliberately selected root once, but never follow links supplied
// as descendants of a downloaded tree.
export async function prepareDownloadPath(
rootPath: string,
candidate: string,
directory = false,
): Promise<{ root: string; path: string }> {
const target = assertWithinRoot(rootPath, normalizeLocalPath(candidate));
const selected = path.resolve(rootPath);
const realRoot = await fsp.realpath(selected);
if (!(await fsp.stat(realRoot)).isDirectory()) {
throw new LocalFileError("EINVAL", "The download root is not a directory");
}
const parts = path.relative(selected, target).split(path.sep);
let current = realRoot;
for (let i = 0; i < parts.length; i++) {
current = path.join(current, parts[i]);
const needsDirectory = directory || i < parts.length - 1;
if (needsDirectory) {
await fsp.mkdir(current).catch((error: NodeJS.ErrnoException) => {
if (error.code !== "EEXIST") throw error;
});
}
const stat = await fsp
.lstat(current)
.catch((error: NodeJS.ErrnoException) => {
if (error.code === "ENOENT" && !needsDirectory) return null;
throw error;
});
if (stat?.isSymbolicLink()) {
throw new LocalFileError(
"EINVAL",
"Downloads cannot follow links inside the selected folder",
);
}
if (needsDirectory && !stat?.isDirectory()) {
throw new LocalFileError(
"ENOTDIR",
"A download parent is not a directory",
);
}
}
return { root: realRoot, path: current };
}
export async function pathExists(target: string): Promise<boolean> {
try {
await fsp.lstat(target);
return true;
} catch {
return false;
}
}
async function describeEntry(
dirPath: string,
dirent: fs.Dirent,
): Promise<PluginLocalDirEntry> {
const entryPath = path.join(dirPath, dirent.name);
let type: PluginLocalDirEntry["type"] = "file";
let size = 0;
let modifiedTimestamp: number | undefined;
let linkTarget: string | undefined;
try {
if (dirent.isSymbolicLink()) {
type = "link";
try {
linkTarget = await fsp.readlink(entryPath);
} catch {
// dangling or unreadable link
}
try {
const target = await fsp.stat(entryPath);
if (target.isDirectory()) type = "directory";
size = target.size;
modifiedTimestamp = target.mtimeMs;
} catch {
const own = await fsp.lstat(entryPath);
modifiedTimestamp = own.mtimeMs;
}
} else if (dirent.isDirectory()) {
type = "directory";
modifiedTimestamp = (await fsp.stat(entryPath)).mtimeMs;
} else {
const stat = await fsp.stat(entryPath);
size = stat.size;
modifiedTimestamp = stat.mtimeMs;
}
} catch {
// Unreadable entry: still list it so the user sees it exists.
}
return {
name: dirent.name,
path: entryPath,
type,
size,
modifiedTimestamp,
linkTarget,
hidden: dirent.name.startsWith("."),
};
}
export async function listDirectory(dirPath: string): Promise<{
path: string;
parent: string | null;
entries: PluginLocalDirEntry[];
}> {
const resolved = normalizeLocalPath(dirPath);
const dirents = await fsp.readdir(resolved, { withFileTypes: true });
const entries = await Promise.all(
dirents.map((dirent) => describeEntry(resolved, dirent)),
);
const parent = path.dirname(resolved);
return {
path: resolved,
parent: parent === resolved ? null : parent,
entries,
};
}
// Expands dropped local paths into a flat file list with paths relative to the
// drop root, plus empty directories.
export async function walkPaths(
rootPaths: string[],
): Promise<PluginLocalWalkResult> {
const files: PluginLocalWalkResult["files"] = [];
const emptyDirs: string[] = [];
let totalBytes = 0;
async function walkDir(absDir: string, relDir: string): Promise<void> {
const dirents = await fsp.readdir(absDir, { withFileTypes: true });
if (dirents.length === 0) {
emptyDirs.push(relDir);
return;
}
for (const dirent of dirents) {
const abs = path.join(absDir, dirent.name);
const rel = `${relDir}/${dirent.name}`;
if (dirent.isDirectory()) {
await walkDir(abs, rel);
} else if (dirent.isFile()) {
const stat = await fsp.stat(abs);
files.push({ localPath: abs, relativePath: rel, size: stat.size });
totalBytes += stat.size;
} else if (dirent.isSymbolicLink()) {
try {
const stat = await fsp.stat(abs);
if (stat.isFile()) {
files.push({ localPath: abs, relativePath: rel, size: stat.size });
totalBytes += stat.size;
}
} catch {
// dangling link: skip
}
}
}
}
for (const rootPath of rootPaths) {
const abs = normalizeLocalPath(rootPath);
const stat = await fsp.stat(abs);
const name = path.basename(abs);
if (stat.isDirectory()) {
await walkDir(abs, name);
} else if (stat.isFile()) {
files.push({ localPath: abs, relativePath: name, size: stat.size });
totalBytes += stat.size;
}
}
return { files, emptyDirs, totalBytes };
}
export async function homeInfo(): Promise<{
home: string;
separator: string;
platform: NodeJS.Platform;
}> {
return {
home: os.homedir(),
separator: path.sep,
platform: process.platform,
};
}
export async function makeDirectory(
parentPath: string,
name: string,
): Promise<string> {
const target = path.join(
normalizeLocalPath(parentPath),
requireEntryName(name, "folder name"),
);
await fsp.mkdir(target);
return target;
}
export async function createEmptyFile(
parentPath: string,
name: string,
): Promise<string> {
const target = path.join(
normalizeLocalPath(parentPath),
requireEntryName(name, "file name"),
);
// "wx" fails if the file already exists rather than truncating it.
await fsp.writeFile(target, "", { flag: "wx" });
return target;
}
// Exclusive rename: a destination that appeared meanwhile is never replaced.
export async function renameLocalPath(
source: string,
target: string,
): Promise<void> {
if (source === target) return;
try {
await fsp.lstat(target);
throw new LocalFileError(
"EEXIST",
"A file or folder with that name already exists",
);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
if ((await fsp.lstat(source)).isDirectory()) {
await fsp.rename(source, target);
return;
}
await fsp.link(source, target);
await fsp.unlink(source);
}
export async function renameEntry(
oldPath: string,
newName: string,
): Promise<string> {
const source = normalizeLocalPath(oldPath);
const safeName = requireEntryName(newName, "name");
const target = path.join(path.dirname(source), safeName);
if (target === source) return source;
if (await pathExists(target)) {
throw new LocalFileError("EEXIST", `"${safeName}" already exists`);
}
await renameLocalPath(source, target);
return target;
}
export async function ensureDirectory(
dirPath: string,
rootPath?: string,
): Promise<string> {
let target = normalizeLocalPath(dirPath);
if (rootPath !== undefined) {
target = (await prepareDownloadPath(rootPath, target, true)).path;
} else {
await fsp.mkdir(target, { recursive: true });
}
return target;
}
export async function existingPaths(paths: string[]): Promise<string[]> {
if (!Array.isArray(paths)) throw new Error("Expected a list of paths");
const existing: string[] = [];
for (const candidate of paths) {
const target = normalizeLocalPath(candidate);
if (await pathExists(target)) existing.push(target);
}
return existing;
}
export async function openRead(
localPath: string,
): Promise<{ stream: fs.ReadStream; size: number }> {
const absPath = normalizeLocalPath(localPath);
const stat = await fsp.stat(absPath);
if (!stat.isFile()) throw new Error("Only regular files can be uploaded");
return {
stream: fs.createReadStream(absPath, { highWaterMark: 1024 * 1024 }),
size: stat.size,
};
}
// Destinations with a download in flight, so two transfers never race onto
// the same file.
const activeDestinations = new Set<string>();
function transferToken(transferId: string): string {
return (
String(transferId)
.replace(/[^A-Za-z0-9_-]/g, "")
.slice(0, 48) || "transfer"
);
}
export interface PublishFs {
rename: (from: string, to: string) => Promise<void>;
link: (from: string, to: string) => Promise<void>;
copyFileExcl: (from: string, to: string) => Promise<void>;
rm: (target: string) => Promise<void>;
lstat: (target: string) => Promise<fs.Stats>;
}
export const defaultPublishFs: PublishFs = Object.freeze({
rename: (from, to) => fsp.rename(from, to),
link: (from, to) => fsp.link(from, to),
copyFileExcl: (from, to) =>
fsp.copyFile(from, to, fs.constants.COPYFILE_EXCL),
rm: (target) => fsp.rm(target, { force: true }),
lstat: (target) => fsp.lstat(target),
});
// link() is atomic and fails with EEXIST if the name is taken; filesystems
// without hard links fall back to an exclusive copy.
async function publishExclusive(
io: PublishFs,
sourcePath: string,
absDest: string,
): Promise<void> {
const exists = () =>
new LocalFileError("EEXIST", `"${path.basename(absDest)}" already exists`);
try {
await io.link(sourcePath, absDest);
} catch (error) {
if ((error as NodeJS.ErrnoException)?.code === "EEXIST") throw exists();
try {
await io.copyFileExcl(sourcePath, absDest);
} catch (copyError) {
if ((copyError as NodeJS.ErrnoException)?.code === "EEXIST") {
throw exists();
}
throw copyError;
}
}
await io.rm(sourcePath);
}
// rename() onto an existing file is not portable (it commonly fails on
// Windows), so move the old file aside, publish exclusively, then drop it.
async function replaceExisting(
io: PublishFs,
partialPath: string,
absDest: string,
transferId: string,
): Promise<void> {
let current: fs.Stats;
try {
current = await io.lstat(absDest);
} catch (error) {
if ((error as NodeJS.ErrnoException)?.code === "ENOENT") {
await publishExclusive(io, partialPath, absDest);
return;
}
throw error;
}
if (current.isDirectory()) {
throw new LocalFileError(
"EISDIR",
`"${path.basename(absDest)}" is a folder and cannot be replaced by a file`,
);
}
const asidePath = `${absDest}.${transferToken(transferId)}.termix-replaced`;
try {
await io.rename(absDest, asidePath);
} catch (error) {
const code = (error as NodeJS.ErrnoException)?.code;
if (code === "EPERM" || code === "EBUSY" || code === "EACCES") {
throw new LocalFileError(
"EBUSY",
`"${path.basename(absDest)}" is in use and could not be replaced`,
);
}
throw error;
}
try {
await publishExclusive(io, partialPath, absDest);
} catch (error) {
await io.rm(absDest).catch(() => {});
await io.rename(asidePath, absDest).catch(() => {});
throw error;
}
try {
await io.rm(asidePath);
} catch {
await new Promise((resolve) => setTimeout(resolve, 100));
await io.rm(asidePath).catch(() => {});
}
}
export async function publishDownload(
partialPath: string,
absDest: string,
overwrite: boolean,
transferId: string,
io: PublishFs = defaultPublishFs,
): Promise<void> {
if (overwrite) {
await replaceExisting(io, partialPath, absDest, transferId);
return;
}
await publishExclusive(io, partialPath, absDest);
}
// Bytes go to a transfer-unique temp sibling first and are published under the
// real name only on success, so a failed transfer never leaves a truncated file.
export async function openDownload(
options: {
rootPath: string;
destPath: string;
transferId: string;
overwrite?: boolean;
},
io: PublishFs = defaultPublishFs,
): Promise<PluginLocalDownload> {
const { rootPath, destPath, transferId } = options;
if (!transferId || !destPath) throw new Error("Missing download parameters");
const destination = await prepareDownloadPath(rootPath, destPath);
const absDest = destination.path;
if (activeDestinations.has(absDest)) {
throw new LocalFileError(
"EBUSY",
`"${path.basename(absDest)}" is already being downloaded`,
);
}
activeDestinations.add(absDest);
const partialPath = `${path.join(destination.root, path.basename(absDest))}.${transferToken(
transferId,
)}.termix-part`;
let released = false;
const release = () => {
if (released) return;
released = true;
activeDestinations.delete(absDest);
};
try {
if (options.overwrite !== true && (await pathExists(absDest))) {
throw new LocalFileError(
"EEXIST",
`"${path.basename(absDest)}" already exists`,
);
}
// "wx": the temp name is ours alone; refuse to reuse a stale one.
const stream = fs.createWriteStream(partialPath, { flags: "wx" });
// A write error must not crash the backend. publish() reports it.
let streamError: Error | null = null;
stream.on("error", (error) => {
streamError = error;
});
return {
path: absDest,
stream,
publish: async (publishOptions) => {
try {
if (streamError) throw streamError;
await new Promise<void>((resolve, reject) => {
if (stream.writableFinished) return resolve();
stream.once("error", reject);
stream.end(() => resolve());
});
if (streamError) throw streamError;
await prepareDownloadPath(destination.root, absDest);
await publishDownload(
partialPath,
absDest,
(publishOptions?.overwrite ?? options.overwrite) === true,
transferId,
io,
);
return absDest;
} catch (error) {
await fsp.rm(partialPath, { force: true }).catch(() => {});
throw error;
} finally {
release();
}
},
abort: async () => {
stream.destroy();
await fsp.rm(partialPath, { force: true }).catch(() => {});
release();
},
};
} catch (error) {
release();
throw error;
}
}
@@ -0,0 +1,167 @@
/**
* Local shells for plugins on the desktop app's embedded backend. Ported from
* the old Electron local terminal handlers.
*/
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { createRequire } from "node:module";
import type { PluginLocalPty } from "@termix-ssh/plugin-sdk/backend";
export function resolveLocalShell(
platform: NodeJS.Platform,
requestedShell?: string,
env: NodeJS.ProcessEnv = process.env,
exists: (file: string) => boolean = fs.existsSync,
): { file: string; args: string[] } {
if (platform === "win32") {
if (requestedShell === "wsl") return { file: "wsl.exe", args: [] };
return {
file: env.TERMIX_LOCAL_SHELL || "powershell.exe",
args: ["-NoLogo"],
};
}
// A SHELL left pointing at an uninstalled shell makes the spawn fail.
const fallbacks =
platform === "darwin"
? ["/bin/zsh", "/bin/bash", "/bin/sh"]
: ["/bin/bash", "/bin/sh"];
const usable = (file?: string): file is string =>
!!file && (!path.isAbsolute(file) || exists(file));
const file =
[env.TERMIX_LOCAL_SHELL, env.SHELL, ...fallbacks].find(usable) ||
fallbacks[0];
return { file, args: ["-l"] };
}
/**
* node-pty starts every shell through its spawn-helper binary, and a packaged
* copy that lost its execute bit fails with "posix_spawnp failed". Returns how
* many helpers were fixed.
*/
export function repairSpawnHelpers(ptyDir: string, fsImpl = fs): number {
// A universal macOS build ships app-arm64.asar and app-x64.asar.
const dir = ptyDir.replace(
/\b(app(?:-[a-z0-9]+)?)\.asar(?!\.unpacked)/,
"$1.asar.unpacked",
);
const candidates = [
path.join(dir, "build", "Release", "spawn-helper"),
path.join(
dir,
"prebuilds",
`${process.platform}-${process.arch}`,
"spawn-helper",
),
];
let fixed = 0;
for (const helper of candidates) {
try {
const mode = fsImpl.statSync(helper).mode;
if ((mode & 0o111) === 0) {
fsImpl.chmodSync(helper, mode | 0o755);
fixed += 1;
}
} catch {
// Missing or read-only; nothing to fix here.
}
}
return fixed;
}
interface NodePty {
spawn: (
file: string,
args: string[],
options: Record<string, unknown>,
) => {
pid: number;
write: (data: string) => void;
resize: (cols: number, rows: number) => void;
kill: () => void;
onData: (cb: (data: string) => void) => { dispose: () => void };
onExit: (cb: (e: { exitCode: number }) => void) => { dispose: () => void };
};
}
const requireFromHere = createRequire(import.meta.url);
function loadNodePty(): NodePty {
try {
return requireFromHere("node-pty") as NodePty;
} catch {
throw new Error("The local terminal is not available in this build");
}
}
export const MAX_PTY_COLS = 500;
export const MAX_PTY_ROWS = 300;
export function spawnLocalPty(options?: {
cols?: number;
rows?: number;
shell?: string;
}): PluginLocalPty {
const pty = loadNodePty();
const cols = Math.min(MAX_PTY_COLS, Math.max(2, Number(options?.cols) || 80));
const rows = Math.min(MAX_PTY_ROWS, Math.max(1, Number(options?.rows) || 24));
const shellConfig = resolveLocalShell(process.platform, options?.shell);
const start = () =>
pty.spawn(shellConfig.file, shellConfig.args, {
name: "xterm-256color",
cols,
rows,
cwd: os.homedir(),
env: { ...process.env, TERM: "xterm-256color", COLORTERM: "truecolor" },
});
let child: ReturnType<NodePty["spawn"]>;
try {
child = start();
} catch (error) {
const ptyDir = path.dirname(
requireFromHere.resolve("node-pty/package.json"),
);
if (
!String((error as Error)?.message).includes("posix_spawnp") ||
repairSpawnHelpers(ptyDir) === 0
) {
throw error;
}
child = start();
}
return {
pid: child.pid,
shell: shellConfig.file,
write: (data) => {
if (typeof data === "string" && data.length <= 64 * 1024) {
child.write(data);
}
},
resize: (c, r) => {
if (
Number.isInteger(c) &&
Number.isInteger(r) &&
c >= 2 &&
c <= MAX_PTY_COLS &&
r >= 1 &&
r <= MAX_PTY_ROWS
) {
child.resize(c, r);
}
},
kill: () => child.kill(),
onData: (handler) => {
const sub = child.onData(handler);
return () => sub.dispose();
},
onExit: (handler) => {
const sub = child.onExit(({ exitCode }) => handler(exitCode));
return () => sub.dispose();
},
};
}
+1 -1
View File
@@ -22,7 +22,7 @@ export const TOPICS = {
hostSessionStatus: "host.session.status",
/** A generic named internal event (host_deleted, user_login, ...). */
internalEvent: "internal.event",
/** A host's status dot changed (hosts/status/host-status-service.ts). */
/** A host's status dot changed (the host status provider). */
hostStatus: "host.status",
/** A host's key was accepted again after it changed. */
hostKeyUpdated: "host.key.updated",
+149
View File
@@ -0,0 +1,149 @@
/**
* Host status is a provider: core owns the interface and the callers (the
* status routes, the sync feed, ctx.hosts.status), and one provider fills it.
* A plugin registers with ctx.hosts.status.provide. With none, every status
* is unknown.
*/
export type ProviderHostStatus = "online" | "reachable" | "offline";
export interface HostStatusEntry {
status: ProviderHostStatus;
lastChecked: string;
}
export type HostPortResolver = (
hostId: number,
) => number | undefined | Promise<number | undefined>;
export interface HostStatusProvider {
start(): void;
stop(): void;
get(hostId: number): HostStatusEntry | null;
/** All known statuses after bringing polling in line with the request. */
statusesFor(
userId: string,
requestedHostIds: Set<number> | null,
): Promise<Map<number, HostStatusEntry>>;
refresh(userId: string): Promise<void>;
check(hostId: number): Promise<HostStatusEntry | null>;
reportLogin(hostId: number, outcome: { ok: boolean }): void;
registerPort(connectionType: string, resolve: HostPortResolver): () => void;
getIntervalSeconds(): Promise<number>;
setIntervalSeconds(seconds: number): Promise<void>;
}
let builtin: HostStatusProvider | null = null;
let plugged: { pluginId: string; provider: HostStatusProvider } | null = null;
let running = false;
const ports = new Map<symbol, { type: string; resolve: HostPortResolver }>();
const portDrops = new Map<symbol, () => void>();
function active(): HostStatusProvider | null {
return plugged?.provider ?? builtin;
}
function attachPorts(provider: HostStatusProvider): void {
for (const [key, { type, resolve }] of ports) {
portDrops.set(key, provider.registerPort(type, resolve));
}
}
function detachPorts(): void {
for (const drop of portDrops.values()) drop();
portDrops.clear();
}
function swap(next: () => void): void {
const before = active();
if (running) before?.stop();
detachPorts();
next();
const after = active();
if (after) attachPorts(after);
if (running) after?.start();
}
/** A fallback provider. Core registers none; tests use it. */
export function registerBuiltinHostStatusProvider(
provider: HostStatusProvider | null,
): void {
swap(() => {
builtin = provider;
});
}
/** Returns the remover. A second provider is refused. */
export function provideHostStatus(
pluginId: string,
provider: HostStatusProvider,
): () => void {
if (plugged) {
throw new Error(
`Host status is already provided by plugin "${plugged.pluginId}"`,
);
}
const entry = { pluginId, provider };
swap(() => {
plugged = entry;
});
return () => {
if (plugged !== entry) return;
swap(() => {
plugged = null;
});
};
}
export function hostStatusProviderId(): string | null {
return plugged?.pluginId ?? null;
}
/** What the rest of core calls. */
export const hostStatus = {
start(): void {
running = true;
active()?.start();
},
stop(): void {
running = false;
active()?.stop();
},
get(hostId: number): HostStatusEntry | null {
return active()?.get(hostId) ?? null;
},
async statusesFor(
userId: string,
requestedHostIds: Set<number> | null,
): Promise<Map<number, HostStatusEntry>> {
return (await active()?.statusesFor(userId, requestedHostIds)) ?? new Map();
},
async refresh(userId: string): Promise<void> {
await active()?.refresh(userId);
},
async check(hostId: number): Promise<HostStatusEntry | null> {
return (await active()?.check(hostId)) ?? null;
},
reportLogin(hostId: number, outcome: { ok: boolean }): void {
active()?.reportLogin(hostId, outcome);
},
/** Kept across a provider change, so a plugin's port stays registered. */
registerPort(connectionType: string, resolve: HostPortResolver): () => void {
const key = Symbol(connectionType);
ports.set(key, { type: connectionType, resolve });
const provider = active();
if (provider)
portDrops.set(key, provider.registerPort(connectionType, resolve));
return () => {
ports.delete(key);
portDrops.get(key)?.();
portDrops.delete(key);
};
},
async getIntervalSeconds(): Promise<number> {
return (await active()?.getIntervalSeconds()) ?? 30;
},
async setIntervalSeconds(seconds: number): Promise<void> {
await active()?.setIntervalSeconds(seconds);
},
};
+106 -3
View File
@@ -18,7 +18,11 @@ import path from "node:path";
import semver from "semver";
import * as tar from "tar";
import { pluginLogger } from "../utils/logger.js";
import { isTermixCompatible, parseManifest } from "./manifest.js";
import {
isTermixCompatible,
parseManifest,
validateThemeCss,
} from "./manifest.js";
import { getLocalVersion } from "../utils/app-version.js";
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
import {
@@ -85,6 +89,19 @@ export interface LoadedPlugin {
signedTier?: KeyTier;
}
/**
* A plugin folder that did not load. Kept so the layout repair view can say
* why a chosen layout or theme is missing instead of only "not installed".
*/
export interface SkippedPlugin {
/** Manifest id when it could be read, else the folder or file name. */
id: string;
version: string | null;
layouts: string[];
themes: string[];
error: string;
}
export interface PluginLoaderOptions {
errorThreshold?: number;
errorWindowMs?: number;
@@ -92,8 +109,70 @@ export interface PluginLoaderOptions {
onFailed?: (plugin: LoadedPlugin) => void;
}
/**
* Every theme file a manifest declares must sit inside the plugin and hold
* only what a theme may hold. Refusing the plugin keeps a bad file from ever
* being served.
*/
function validatePluginThemes(dir: string, manifest: PluginManifest): void {
for (const theme of manifest.contributes?.themes ?? []) {
const file = path.join(dir, theme.css);
if (!fs.existsSync(file) || !isRealPathInside(dir, file)) {
throw new Error(
`Plugin ${manifest.id} theme ${theme.id} file ${theme.css} is missing`,
);
}
const errors = validateThemeCss(fs.readFileSync(file, "utf8"), theme.id);
if (errors.length > 0) {
throw new Error(
`Plugin ${manifest.id} theme ${theme.id} is not allowed: ${errors.join("; ")}`,
);
}
}
}
/** What can still be said about a plugin folder that failed to load. */
function describeSkipped(
dir: string,
name: string,
isArtifact: boolean,
error: unknown,
): SkippedPlugin {
const skipped: SkippedPlugin = {
id: isArtifact ? name.replace(/(-\d[^/]*)?\.tmxplug$/, "") : name,
version: null,
layouts: [],
themes: [],
error: error instanceof Error ? error.message : String(error),
};
if (isArtifact) return skipped;
try {
const raw = JSON.parse(
fs.readFileSync(getPluginManifestPath(dir), "utf8"),
) as {
id?: unknown;
version?: unknown;
contributes?: { layouts?: unknown; themes?: unknown };
};
const ids = (list: unknown) =>
Array.isArray(list)
? list
.map((entry) => (entry as { id?: unknown })?.id)
.filter((id): id is string => typeof id === "string")
: [];
if (typeof raw.id === "string") skipped.id = raw.id;
if (typeof raw.version === "string") skipped.version = raw.version;
skipped.layouts = ids(raw.contributes?.layouts);
skipped.themes = ids(raw.contributes?.themes);
} catch {
// An unreadable manifest leaves only the folder name.
}
return skipped;
}
export class PluginLoader {
private readonly plugins = new Map<string, LoadedPlugin>();
private skippedPlugins: SkippedPlugin[] = [];
/** Version of each bundled plugin as shipped, the floor for any update. */
private readonly bundledVersions = new Map<string, string>();
/** Activation order, so shutdown can run it backwards. */
@@ -113,6 +192,11 @@ export class PluginLoader {
return [...this.plugins.values()];
}
/** Plugin folders the last full load skipped, with the reason. */
skipped(): SkippedPlugin[] {
return [...this.skippedPlugins];
}
get(pluginId: string): LoadedPlugin | undefined {
return this.plugins.get(pluginId);
}
@@ -210,6 +294,8 @@ export class PluginLoader {
);
}
validatePluginThemes(dir, manifest);
if (this.plugins.has(manifest.id)) {
throw new Error(`another plugin already uses the id "${manifest.id}"`);
}
@@ -258,6 +344,7 @@ export class PluginLoader {
async loadAll(): Promise<LoadedPlugin[]> {
this.plugins.clear();
this.bundledVersions.clear();
this.skippedPlugins = [];
await removeStaleStaging();
const loaded: LoadedPlugin[] = [];
// Every bundled folder name, loaded or not: a bundled plugin that fails
@@ -322,6 +409,9 @@ export class PluginLoader {
}
loaded.push(plugin);
} catch (error) {
this.skippedPlugins.push(
describeSkipped(dir, entry.name, isArtifact, error),
);
pluginLogger.error(
`Skipping plugin directory ${entry.name} in ${root}`,
error instanceof Error ? error : new Error(String(error)),
@@ -566,6 +656,15 @@ export class PluginLoader {
if (plugin.state === "active" || plugin.state === "activating") return;
if (
plugin.manifest.runtime === "desktop" &&
process.env.ELECTRON_EMBEDDED !== "true"
) {
plugin.state = "blocked";
plugin.lastError = "only runs in the desktop app";
return;
}
plugin.state = "activating";
plugin.lastError = null;
@@ -629,8 +728,12 @@ export class PluginLoader {
bundled:
plugin.source === "bundled" || plugin.signedTier === "official",
});
// A new table may be where core data is waiting to move.
if (applied.length > 0) {
// A new table, or a plugin that keeps its state in settings, may be
// where core data is waiting to move. Every move is idempotent.
if (
applied.length > 0 ||
plugin.manifest.contributes?.settings?.admin?.length
) {
const { runPluginDataMoves } =
await import("../upgrade/plugin-data-moves.js");
await runPluginDataMoves();
+76
View File
@@ -0,0 +1,76 @@
/**
* Layouts and themes on the server side: which look a fresh install shows,
* and which installed looks could not load. Core names no plugin here; the
* default look comes from the bundled index's defaultLook flag.
*/
import { defaultLookPluginIds } from "./bundled-index.js";
import type { LoadedPlugin, SkippedPlugin } from "./loader.js";
export interface DefaultLook {
layout: string | null;
theme: string | null;
}
interface LookLoader {
get(pluginId: string): Pick<LoadedPlugin, "manifest"> | undefined;
list(): Array<Pick<LoadedPlugin, "manifest">>;
skipped(): SkippedPlugin[];
}
/**
* The first default-look plugin's default layout (else its first one) and
* that layout's theme. A default-look plugin with themes only fills in the
* theme. Null where none is installed, and the browser uses its built-in.
*/
export function resolveDefaultLook(loader: LookLoader): DefaultLook {
const look: DefaultLook = { layout: null, theme: null };
for (const id of defaultLookPluginIds()) {
const contributes = loader.get(id)?.manifest.contributes;
const layouts = contributes?.layouts ?? [];
const layout = layouts.find((entry) => entry.default) ?? layouts[0];
if (layout && !look.layout) {
look.layout = layout.id;
look.theme ??= layout.defaultTheme ?? null;
}
if (!look.theme && contributes?.themes?.length) {
look.theme = contributes.themes[0].id;
}
}
return look;
}
/** Plugins with a layout or theme that were found but did not load. */
export function unavailableLooks(loader: LookLoader): SkippedPlugin[] {
return loader
.skipped()
.filter((plugin) => plugin.layouts.length > 0 || plugin.themes.length > 0);
}
/**
* Layout ids some installed plugin brings, whether it runs, is turned off or
* failed to load. A choice outside this set was uninstalled, so the next
* choice down applies instead of the repair view.
*/
export function installedLayoutIds(loader: LookLoader): Set<string> {
const ids = new Set<string>();
for (const plugin of loader.list()) {
for (const layout of plugin.manifest.contributes?.layouts ?? []) {
ids.add(layout.id);
}
}
for (const plugin of loader.skipped()) {
for (const id of plugin.layouts) ids.add(id);
}
return ids;
}
/** TERMIX_SAFE_LAYOUT=1 starts everyone in the layout repair view. */
export function safeLayoutForced(): boolean {
const value = process.env.TERMIX_SAFE_LAYOUT?.trim().toLowerCase();
return value === "1" || value === "true";
}
export const LOOK_ID_PATTERN = /^[a-z][a-z0-9-]{1,39}$/;
export const LAYOUT_SETTING = "ui.layout";
export const THEME_SETTING = "ui.theme";
+109 -28
View File
@@ -7,7 +7,9 @@
* sha256 and signature checks and stays marked unverified. Bundled plugins ship in the
* image and can still be uninstalled: their files are deleted and the id is
* recorded in the settings table, so the shipped copy is ignored from then
* on and a reinstall downloads the plugin like any other.
* on and a reinstall downloads the plugin like any other. The one exception
* is a default-look plugin (bundled-index.ts): the app is drawn with it, so
* it can be disabled or swapped but never uninstalled.
*/
import crypto from "node:crypto";
@@ -15,6 +17,7 @@ import fs from "node:fs";
import path from "node:path";
import semver from "semver";
import { pluginLogger } from "../utils/logger.js";
import { isDefaultLookPlugin } from "./bundled-index.js";
import {
activatePlugin,
deactivatePlugin,
@@ -31,6 +34,7 @@ import {
} from "./paths.js";
import {
OFFICIAL_REGISTRY_ID,
canRunRelease,
downloadRelease,
fetchRegistryIndex,
channelReleases,
@@ -39,6 +43,7 @@ import {
getRegistryStatus,
isApiCompatible,
latestOnChannel,
neededTermix,
officialSource,
parsePluginChannel,
type PluginChannel,
@@ -254,10 +259,13 @@ export interface RegistryListing {
videoId?: string;
features: string[];
docs?: string;
contributes: string[];
versions: Array<{
version: string;
prerelease: boolean;
compatible: boolean;
/** The Termix range this version needs, when this build is too old. */
needsTermix: string | null;
capabilities: string[];
publishedAt?: string;
releaseNotesUrl?: string;
@@ -393,7 +401,7 @@ export async function listRegistry(
const channel = channelOf(record);
const latest = latestOnChannel(plugin, channel);
const latestBeta =
plugin.prereleases.find((entry) => isApiCompatible(entry.api)) ?? null;
plugin.prereleases.find((entry) => canRunRelease(entry)) ?? null;
const installedVersion =
live?.manifest.version ?? record?.version ?? null;
const updateAvailable = Boolean(
@@ -416,10 +424,12 @@ export async function listRegistry(
videoId: plugin.videoId,
features: plugin.features,
docs: plugin.docs,
contributes: plugin.contributes,
versions: channelReleases(plugin, "beta").map((entry) => ({
version: entry.version,
prerelease: Boolean(entry.prerelease),
compatible: isApiCompatible(entry.api),
compatible: canRunRelease(entry),
needsTermix: neededTermix(entry),
capabilities: entry.capabilities,
publishedAt: entry.publishedAt,
releaseNotesUrl: entry.releaseNotesUrl,
@@ -899,9 +909,12 @@ async function requireRelease(
"NOT_IN_REGISTRY",
);
}
if (!isApiCompatible(found.release.api)) {
if (!canRunRelease(found.release)) {
const termix = neededTermix(found.release);
throw new PluginManageError(
`${pluginId} ${found.release.version} does not run on this version of Termix`,
termix
? `${pluginId} ${found.release.version} needs Termix ${termix}`
: `${pluginId} ${found.release.version} does not run on this version of Termix`,
409,
"INCOMPATIBLE",
);
@@ -1154,6 +1167,13 @@ export async function uninstallPluginUnlocked(
throw new PluginManageError("Plugin not found", 404, "NOT_FOUND");
}
const shipped = loader.bundledIds().has(pluginId);
if (shipped && isDefaultLookPlugin(pluginId)) {
throw new PluginManageError(
"The default look cannot be uninstalled",
409,
"DEFAULT_LOOK",
);
}
const dependents = dependentChain(pluginId, await enabledIds());
if (dependents.length > 0) {
@@ -1492,6 +1512,14 @@ function validateUpload(buffer: Buffer): PluginManifest {
"INCOMPATIBLE",
);
}
const termix = neededTermix({ engine: manifest.engine });
if (termix) {
throw new PluginManageError(
`${manifest.id} ${manifest.version} needs Termix ${termix}`,
409,
"INCOMPATIBLE",
);
}
return manifest;
}
@@ -1634,41 +1662,94 @@ export function installUpload(
});
}
/**
* Loads the copy in the bundled folder again. Running dependents stop first
* and start again after. `prepare` runs once the plugin is stopped, for work
* that needs its files out of use.
*/
async function reloadBundledUnlocked(
pluginId: string,
prepare?: (previous: LoadedPlugin | undefined) => Promise<void>,
): Promise<{ id: string; version: string; state: string }> {
const { loader } = getPluginRuntime();
const previous = loader.get(pluginId);
if (previous && previous.source !== "bundled" && !prepare) {
throw new PluginManageError(
`${pluginId} is not loaded from the bundled folder`,
409,
"NOT_BUNDLED",
);
}
const dependents = previous
? dependentChain(pluginId, await enabledIds()).filter(
(id) => loader.get(id)?.state === "active",
)
: [];
for (const id of dependents) await deactivatePlugin(id);
await unloadPlugin(pluginId);
if (prepare) await prepare(previous);
const plugin = await loader.loadBundled(pluginId);
await registerLoadedPlugins([plugin]);
let state = "stopped";
if ((await enabledIds()).has(pluginId)) {
const { missing } = dependencyChain(pluginId);
state = missing.length > 0 ? "blocked" : await startAndRecord(pluginId);
}
for (const id of [...dependents].reverse()) await startAndRecord(id);
return { id: pluginId, version: plugin.manifest.version, state };
}
/**
* Loads the copy in the bundled folder again after it was rebuilt, for the
* dev runner. Running dependents stop first and start again after.
* dev runner.
*/
export function reloadBundledPlugin(
pluginId: string,
): Promise<{ id: string; version: string; state: string }> {
return serialized(() => reloadBundledUnlocked(pluginId));
}
/**
* Puts a bundled plugin back to the copy that shipped with this install,
* dropping any update downloaded over it. The shipped version is pinned so
* auto update does not bring a broken release straight back; unpin it in the
* Plugins tab to follow updates again.
*/
export function restoreBundledPlugin(
pluginId: string,
): Promise<{ id: string; version: string; state: string }> {
return serialized(async () => {
if (!ID_PATTERN.test(pluginId)) {
throw new PluginManageError("Invalid plugin id", 400, "INVALID_ID");
}
const { loader } = getPluginRuntime();
const previous = loader.get(pluginId);
if (previous && previous.source !== "bundled") {
if (!loader.bundledIds().has(pluginId) || !isBundledOnDisk(pluginId)) {
throw new PluginManageError(
`${pluginId} is not loaded from the bundled folder`,
"This plugin has no bundled copy",
409,
"NOT_BUNDLED",
"NO_BUNDLED_COPY",
);
}
const dependents = previous
? dependentChain(pluginId, await enabledIds()).filter(
(id) => loader.get(id)?.state === "active",
)
: [];
for (const id of dependents) await deactivatePlugin(id);
await unloadPlugin(pluginId);
const plugin = await loader.loadBundled(pluginId);
await registerLoadedPlugins([plugin]);
let state = "stopped";
if ((await enabledIds()).has(pluginId)) {
const { missing } = dependencyChain(pluginId);
state = missing.length > 0 ? "blocked" : await startAndRecord(pluginId);
}
for (const id of [...dependents].reverse()) await startAndRecord(id);
return { id: pluginId, version: plugin.manifest.version, state };
const { createCurrentPluginRepository } = await repos();
const repository = createCurrentPluginRepository();
const result = await reloadBundledUnlocked(pluginId, async (previous) => {
await removeArtifactFiles(pluginId, previous?.artifact);
await fs.promises.rm(path.join(getUnpackedPluginsDir(), pluginId), {
recursive: true,
force: true,
});
});
await repository.update(pluginId, {
autoUpdate: false,
pinnedVersion: result.version,
});
await applyPin(pluginId, result.version);
pluginLogger.info(`Restored the bundled copy of ${pluginId}`, {
operation: "plugin_restore_bundled",
});
return result;
});
}
+1
View File
@@ -21,6 +21,7 @@ export {
pluginFeatures,
pluginDocsUrl,
pluginEnvVars,
validateThemeCss,
} from "@termix-ssh/plugin-sdk/manifest";
export type {
@@ -97,3 +97,10 @@ export class JumpProbePool {
if (this.entries.get(key) === entry) this.entries.delete(key);
}
}
/** One pool for core's checks and for plugins probing through ctx. */
export const sharedJumpProbes = new JumpProbePool(async (jumpHosts, userId) => {
const { createJumpHostChain } =
await import("../../hosts/jump-host-chain.js");
return createJumpHostChain(jumpHosts, userId);
});
File renamed without changes.
+60 -9
View File
@@ -15,11 +15,14 @@ import fs from "node:fs";
import path from "node:path";
import semver from "semver";
import {
isApiCompatible as satisfiesPluginApi,
isTermixCompatible,
pluginDocsUrl,
pluginFeatures,
SUPPORTED_PLUGIN_API_VERSION,
youtubeVideoId,
} from "@termix-ssh/plugin-sdk/manifest";
import { getLocalVersion } from "../utils/app-version.js";
import { safeOutboundFetch } from "../utils/safe-outbound-fetch.js";
import { pluginLogger } from "../utils/logger.js";
import {
@@ -59,7 +62,10 @@ const ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
export interface RegistryVersion {
version: string;
/** The API major, all that builds before engine ranges read. */
api: string;
/** The manifest's engine ranges, when the index lists them. */
engine?: { api: string; termix: string };
url: string;
sha256: string;
signature: string;
@@ -92,6 +98,8 @@ export interface RegistryPlugin {
features: string[];
/** https docs link from the index. */
docs?: string;
/** The manifest's contributes keys, like layouts, themes or tabs. */
contributes: string[];
/** Stable releases, newest first. */
versions: RegistryVersion[];
/**
@@ -149,6 +157,9 @@ function parseVersion(raw: unknown): RegistryVersion | null {
return null;
}
if (!/^[a-f0-9]{64}$/i.test(sha256)) return null;
const engine = entry.engine as Record<string, unknown> | undefined;
const engineApi = asString(engine?.api);
const engineTermix = asString(engine?.termix);
try {
if (new URL(url).protocol !== "https:") return null;
} catch {
@@ -157,6 +168,9 @@ function parseVersion(raw: unknown): RegistryVersion | null {
return {
version,
api: asString(entry.api) ?? "",
...(engineApi && engineTermix
? { engine: { api: engineApi, termix: engineTermix } }
: {}),
url,
sha256: sha256.toLowerCase(),
signature,
@@ -191,11 +205,15 @@ export function parseRegistryIndex(raw: unknown): RegistryIndex {
.map(parseVersion)
.filter((v): v is RegistryVersion => v !== null)
.sort((a, b) => semver.rcompare(a.version, b.version));
const versions = parseList(entry.versions).filter(
(v) => !semver.prerelease(v.version),
);
const prereleases = parseList(entry.prereleases)
// gated holds releases that need a newer Termix than builds which skip
// the engine.termix check, so only builds that check it read the list.
const gated = parseList(entry.gated);
const versions = [...parseList(entry.versions), ...gated]
.filter((v) => !semver.prerelease(v.version))
.sort((a, b) => semver.rcompare(a.version, b.version));
const prereleases = [...parseList(entry.prereleases), ...gated]
.filter((v) => semver.prerelease(v.version))
.sort((a, b) => semver.rcompare(a.version, b.version))
.map((v) => ({ ...v, prerelease: true }));
if (versions.length === 0 && prereleases.length === 0) continue;
plugins.push({
@@ -209,6 +227,14 @@ export function parseRegistryIndex(raw: unknown): RegistryIndex {
videoId: youtubeVideoId(entry.video) ?? undefined,
features: pluginFeatures(entry.features),
docs: pluginDocsUrl(entry.docs) ?? undefined,
contributes: Array.isArray(entry.contributes)
? entry.contributes
.filter(
(kind): kind is string =>
typeof kind === "string" && /^[A-Za-z]+$/.test(kind),
)
.slice(0, 50)
: [],
versions,
prereleases,
});
@@ -229,6 +255,29 @@ export function isApiCompatible(api: string): boolean {
return major !== undefined && String(major) === SUPPORTED_PLUGIN_API_VERSION;
}
/**
* The Termix range a release needs when this build is too old for it, or
* null when this build satisfies it.
*/
export function neededTermix(
release: Pick<RegistryVersion, "engine">,
): string | null {
const range = release.engine?.termix;
if (!range) return null;
return isTermixCompatible(range, getLocalVersion()) ? null : range;
}
/** Whether this build can run a release: its API and its engine ranges. */
export function canRunRelease(
release: Pick<RegistryVersion, "api" | "engine">,
): boolean {
if (!isApiCompatible(release.api)) return false;
if (!release.engine) return true;
return (
satisfiesPluginApi(release.engine.api) && neededTermix(release) === null
);
}
/** Follows redirects by hand so each hop still gets the outbound checks. */
async function fetchFollowing(url: string): Promise<Response> {
let current = url;
@@ -427,9 +476,8 @@ export function latestOnChannel(
channel: PluginChannel,
): RegistryVersion | null {
return (
channelReleases(plugin, channel).find((entry) =>
isApiCompatible(entry.api),
) ?? null
channelReleases(plugin, channel).find((entry) => canRunRelease(entry)) ??
null
);
}
@@ -486,9 +534,12 @@ export async function downloadRelease(
release: RegistryVersion,
keys: readonly TrustedPluginKey[] = TRUSTED_PLUGIN_KEYS,
): Promise<{ file: string; signatureFile: string }> {
if (!isApiCompatible(release.api)) {
if (!canRunRelease(release)) {
const termix = neededTermix(release);
throw new Error(
`${pluginId} ${release.version} needs plugin API ${release.api}, this build runs ${SUPPORTED_PLUGIN_API_VERSION}`,
termix
? `${pluginId} ${release.version} needs Termix ${termix}, this is ${getLocalVersion()}`
: `${pluginId} ${release.version} needs plugin API ${release.engine?.api ?? release.api}, this build runs ${SUPPORTED_PLUGIN_API_VERSION}`,
);
}
+12
View File
@@ -41,6 +41,18 @@ export function revoke(key: string, value?: unknown): boolean {
return providers.delete(key);
}
/**
* The provider under "<some plugin id>.<name>", for a well-known name core
* reads without knowing which plugin supplies it. First match wins.
*/
export function consumeByName<T>(name: string): T | undefined {
const suffix = `.${name}`;
for (const [key, value] of providers) {
if (key.endsWith(suffix)) return value as T;
}
return undefined;
}
export function has(key: string): boolean {
return providers.has(key);
}
+4
View File
@@ -44,6 +44,10 @@ export type { PluginSettingsScope };
export const CORE_SETTINGS_ALLOWLIST: readonly string[] = [
"app_name",
"notification_private_endpoint_allowlist",
// Read once by the branding provider to keep a pre-plugin install's branding.
"branding_app_name",
"branding_tagline",
"branding_logo",
];
type ChangeListener = (value: unknown) => void;
+2 -4
View File
@@ -27,7 +27,7 @@ export interface UserOwnedTable {
otherUserColumns: string[];
/** refHost columns. */
hostColumns: string[];
/** Columns that travel, as SQL names. The primary key and secrets do not. */
/** Columns that travel, as SQL names. Auto ids and secrets do not. */
columns: string[];
/** The owner column is the primary key: one row per user. */
userIsKey: boolean;
@@ -57,9 +57,7 @@ export function listUserOwnedTables(): UserOwnedTable[] {
.filter(
([, column]) =>
column === user[1] ||
(column.type !== "id" &&
!column.primaryKey &&
column.type !== "encryptedText"),
(column.type !== "id" && column.type !== "encryptedText"),
)
.map(([property, column]) => columnName(property, column)),
userIsKey: !!user[1].primaryKey,
+32 -1
View File
@@ -54,6 +54,8 @@ interface Route {
rawHandler?: RawUpgradeHandler;
/** Sockets handed to rawHandler, destroyed when the route goes away. */
rawSockets?: Set<Duplex>;
/** Core's own socket: served under /plugin-ws/core/, no plugin grant. */
core?: boolean;
}
/** Largest frame a plugin socket accepts. ws defaults to 100MB. */
@@ -163,7 +165,10 @@ async function handlePluginUpgrade(
// checked per upgrade rather than at registration, and a revoke takes effect
// on the next connection without a restart.
const { hasCapability } = await import("./permissions.js");
if (!(await hasCapability(route.pluginId, "network:serve", route.declared))) {
if (
!route.core &&
!(await hasCapability(route.pluginId, "network:serve", route.declared))
) {
reject(socket, 403, "Forbidden");
return true;
}
@@ -303,6 +308,32 @@ export function registerPluginWsRoute(
return () => disposeRoute(routeKey, pluginId, normalized);
}
/**
* A socket core itself serves, at /plugin-ws/core/<path>, so it rides the same
* proxy rules and token checks as plugin sockets. Authenticated like them.
*/
export function registerCoreWsRoute(
path: string,
handler: PluginWebSocketHandler,
): () => void {
const routeKey = key("core", path);
const normalized = normalizePath(path);
if (routes.has(routeKey)) return () => {};
routes.set(routeKey, {
pluginId: "core",
path: normalized,
options: {},
declared: [],
handler,
core: true,
});
servers.set(
routeKey,
new WebSocketServer({ noServer: true, maxPayload: MAX_PAYLOAD_BYTES }),
);
return () => disposeRoute(routeKey, "core", normalized);
}
export function registerPluginWsUpgrade(
pluginId: string,
path: string,
-90
View File
@@ -1,90 +0,0 @@
import {
createCurrentHostResolutionRepository,
createCurrentRbacAccessRepository,
createCurrentRecentActivityRepository,
createCurrentRoleRepository,
} from "../database/repositories/factory.js";
import { dashboardLogger } from "../utils/logger.js";
const RATE_LIMIT_MS = 1000;
const activityRateLimiter = new Map<string, number>();
/**
* Activity types are whatever the recording plugin calls them (each tab
* declares the types it reopens), so only the shape is checked here.
*/
function isActivityType(type: unknown): type is string {
return typeof type === "string" && /^[a-z][a-z0-9_]{0,31}$/.test(type);
}
export type RecordActivityResult =
| { status: "logged"; id: number }
| { status: "rate_limited" }
| { status: "invalid_type" }
| { status: "denied" };
/**
* Adds one recent activity entry: the dashboard's /activity/log route and
* ctx.hosts.recordActivity both come through here, so the rate limit and the
* access rule are the same for both.
*/
export async function recordRecentActivity(
userId: string,
entry: { type: string; hostId: number; hostName: string },
): Promise<RecordActivityResult> {
const { type, hostId, hostName } = entry;
if (!isActivityType(type)) {
return { status: "invalid_type" };
}
const rateLimitKey = `${userId}:${hostId}:${type}`;
const now = Date.now();
const lastLogged = activityRateLimiter.get(rateLimitKey);
if (lastLogged && now - lastLogged < RATE_LIMIT_MS) {
return { status: "rate_limited" };
}
activityRateLimiter.set(rateLimitKey, now);
if (activityRateLimiter.size > 10000) {
for (const [key, timestamp] of activityRateLimiter.entries()) {
if (now - timestamp > RATE_LIMIT_MS * 2) activityRateLimiter.delete(key);
}
}
const isOwnedHost =
await createCurrentHostResolutionRepository().isHostOwnedByUser(
hostId,
userId,
);
if (!isOwnedHost) {
const roleIds = await createCurrentRoleRepository().listUserRoleIds(userId);
const sharedHosts =
await createCurrentRbacAccessRepository().listVisibleHostAccessEntries(
userId,
roleIds,
);
if (!sharedHosts.some((access) => access.hostId === hostId)) {
return { status: "denied" };
}
}
const result = await createCurrentRecentActivityRepository().create({
userId,
type,
hostId,
hostName,
});
// Best-effort trim; a failure here must not fail the log itself.
try {
await createCurrentRecentActivityRepository().trimUserActivity(userId, 100);
} catch (trimErr) {
dashboardLogger.warn("Failed to trim recent_activity (non-fatal)", {
operation: "trim_recent_activity",
userId,
error: trimErr instanceof Error ? trimErr.message : String(trimErr),
});
}
return { status: "logged", id: result.id };
}
+7 -9
View File
@@ -200,9 +200,9 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
const { runCoreBootMigrations } = await import("./boot.js");
await runCoreBootMigrations();
const { hostStatusService } =
await import("./hosts/status/host-status-service.js");
hostStatusService.start();
// Status comes from whichever plugin provides it; none means unknown.
const { hostStatus } = await import("./plugins/host-status-provider.js");
hostStatus.start();
// Counted before the desktop user is provisioned, so a first desktop
// run counts as a fresh install too.
@@ -226,7 +226,7 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
// Plugins serve their routes under /plugin-api/<id>/ and /plugin-ws/<id>/
// through ctx on activate, and start their own schedulers there, so
// disabling one answers 503 instead of leaving a dead import here. The
// dashboard's own uptime and recent-activity routes are core and are
// dashboard's uptime and recent-activity routes are core forwarders and are
// mounted on the main server in database.ts.
// Initialize log level from database settings
@@ -265,17 +265,15 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
});
}
// After plugins, so their sync entities are registered before a pass.
// Sync itself starts with the plugin that provides it.
if (process.env.ELECTRON_EMBEDDED === "true") {
try {
const { startDesktopSync } = await import("./sync/client/engine.js");
startDesktopSync();
const { startDesktopUpdateCheck } =
await import("./updates/desktop-update-check.js");
startDesktopUpdateCheck();
} catch (error) {
systemLogger.warn("Desktop sync failed to start", {
operation: "sync_start",
systemLogger.warn("Desktop update check failed to start", {
operation: "update_check_start",
error: getErrorMessage(error),
});
}
+94
View File
@@ -0,0 +1,94 @@
/**
* Accounts on a desktop. Each local user is one account: the plain local one,
* or the replica of a linked server. Exactly one is active, and the app signs
* in as it. Switching only changes which user the desktop auto session picks,
* so no data is shared between accounts.
*/
import crypto from "crypto";
import {
createCurrentRoleRepository,
createCurrentSettingsRepository,
createCurrentUserRepository,
} from "../database/repositories/factory.js";
import { resolveDesktopAutoSessionUser } from "../database/routes/desktop-auto-session.js";
import { AuthManager } from "../utils/auth-manager.js";
const ACTIVE_KEY = "desktop_active_user";
/** The user the desktop signs in as. Falls back to the usual single-user pick. */
export async function getActiveUserId(): Promise<string | null> {
const users = await createCurrentUserRepository().listAll();
if (users.length === 0) return null;
const saved = await createCurrentSettingsRepository().get(ACTIVE_KEY);
return resolveDesktopAutoSessionUser(users, saved)?.id ?? null;
}
export async function setActiveUserId(userId: string): Promise<void> {
const user = await createCurrentUserRepository().findById(userId);
if (!user) throw new Error("No such account");
await createCurrentSettingsRepository().set(ACTIVE_KEY, userId);
}
/** Saved choice only, for the auto session route. */
export async function getSavedActiveUserId(): Promise<string | null> {
return createCurrentSettingsRepository().get(ACTIVE_KEY);
}
export interface DesktopAccount {
id: string;
username: string;
isAdmin: boolean;
active: boolean;
}
export async function listAccounts(): Promise<DesktopAccount[]> {
const users = await createCurrentUserRepository().listAll();
const active = await getActiveUserId();
return users.map((user) => ({
id: user.id,
username: user.username,
isAdmin: !!user.isAdmin,
active: user.id === active,
}));
}
/** A new empty account for another server to be linked to. */
export async function createAccount(): Promise<string> {
const users = createCurrentUserRepository();
const id = crypto.randomUUID();
await users.create({
id,
username: `server-${id.slice(0, 8)}`,
passwordHash: "",
isAdmin: true,
});
try {
await createCurrentRoleRepository().assignRoleNameToUser({
userId: id,
roleName: "admin",
grantedBy: id,
});
} catch {
// The account still works; admin is also set on the user row.
}
await AuthManager.getInstance().registerUser(
id,
crypto.randomBytes(32).toString("hex"),
);
return id;
}
/** Deletes an account and everything it owns. The last account stays. */
export async function removeAccount(userId: string): Promise<void> {
const users = await createCurrentUserRepository().listAll();
if (users.length <= 1) throw new Error("The last account cannot be removed");
const active = await getActiveUserId();
const { deleteUserAndRelatedData } =
await import("../database/routes/delete-user-data.js");
await deleteUserAndRelatedData(userId);
if (active === userId) {
const next = users.find((user) => user.id !== userId);
if (next) await setActiveUserId(next.id);
}
}
+5
View File
@@ -0,0 +1,5 @@
import os from "os";
export function defaultDeviceName(): string {
return os.hostname() || "Termix Desktop";
}
+6 -1
View File
@@ -14,7 +14,11 @@ export async function applyElectronProxyConfig(
link:
| (Pick<
SyncLink,
"serverUrl" | "customHeaders" | "basicAuth" | "allowInvalidCertificate"
| "serverUrl"
| "customHeaders"
| "basicAuth"
| "allowInvalidCertificate"
| "clientCert"
> & { sessionToken?: string | null; status?: string })
| null,
): Promise<void> {
@@ -30,6 +34,7 @@ export async function applyElectronProxyConfig(
headers: link.customHeaders ?? [],
basicAuth: link.basicAuth ?? null,
allowInvalidCertificate: !!link.allowInvalidCertificate,
clientCert: link.clientCert ?? null,
}
: null,
);
-780
View File
@@ -1,780 +0,0 @@
/**
* The linked desktop's side of sync.
*
* A pass pushes local edits, then pulls what changed on the server after the
* saved cursor, then backfills any entity type this device has never pulled
* (first link, a feature switched on, a category turned back on). Local
* edits are found the same way the server finds changes: by comparing each
* row's hash with the one recorded at the last sync.
*/
import os from "os";
import type { SyncRow } from "@termix-ssh/plugin-sdk/backend";
import {
getEntity,
listEntities,
onEntitiesChanged,
type RegisteredSyncEntity,
} from "../../plugins/sync-registry.js";
import { syncLogger } from "../../utils/logger.js";
import { sendCoreAlert } from "../../notify/core-notify.js";
import { registerCoreSyncEntities } from "../entities.js";
import {
createResolvers,
deleteStoredRow,
loadWireRow,
loadWireRows,
writeWireRow,
} from "../store.js";
import {
listRecords,
putRecord,
recordKey,
saveConflict,
setRecordError,
type SyncRecord,
} from "../records.js";
import { orderSelfReferences } from "../wire.js";
import { onLocalWrite } from "../server/change-watcher.js";
import { getLink, updateLink, type SyncLink } from "./link-store.js";
import { RemoteError, remoteFetch, remoteJson } from "./http.js";
import { mirrorPlugins } from "./plugins.js";
import { applyElectronProxyConfig } from "./electron-proxy.js";
export interface RemoteChange {
seq: number;
entityType: string;
syncId: string;
revision: number;
deleted: boolean;
row: SyncRow | null;
}
interface ChangesPage {
changes: RemoteChange[];
nextCursor: number;
hasMore: boolean;
entityTypes: Array<{
type: string;
owner: string;
order: number;
readOnly: boolean;
}>;
}
interface PushOp {
entityType: string;
syncId: string;
baseRevision: number;
op: "upsert" | "delete";
row?: SyncRow;
}
type PushResult =
| { syncId: string; entityType: string; status: "ok"; revision: number }
| {
syncId: string;
entityType: string;
status: "conflict";
revision: number;
deleted: boolean;
row: SyncRow | null;
}
| { syncId: string; entityType: string; status: "rejected"; reason: string };
const MAX_BATCH_OPS = 200;
const MAX_BATCH_BYTES = 1_000_000;
const MAX_PAGES = 500;
const SAFETY_INTERVAL_MS = 5 * 60_000;
const REFRESH_CHECK_MS = 24 * 60 * 60_000;
const DELETED_HASH = "deleted";
export interface EngineState {
running: boolean;
/** Local changes found in the last pass that the server has not taken. */
pending: number;
/** Entity types the server runs, from the last pull. */
serverTypes: Array<{ type: string; owner: string; readOnly: boolean }>;
lastPassAt: string | null;
}
/** An entity this device could sync: registered here, and on the server. */
function syncable(
entity: RegisteredSyncEntity,
link: SyncLink,
serverTypes: Set<string> | null,
): boolean {
if (link.disabledTypes.includes(entity.type)) return false;
if (serverTypes && !serverTypes.has(entity.type)) return false;
return true;
}
function sortChanges(changes: RemoteChange[]): RemoteChange[] {
const byType = new Map<string, RemoteChange[]>();
for (const change of changes) {
const list = byType.get(change.entityType) ?? [];
list.push(change);
byType.set(change.entityType, list);
}
const types = [...byType.keys()].sort(
(a, b) => (getEntity(a)?.order ?? 1e6) - (getEntity(b)?.order ?? 1e6),
);
const out: RemoteChange[] = [];
for (const type of types) {
const list = byType.get(type)!;
const entity = getEntity(type);
// Only the latest record of each row matters.
const latest = new Map<string, RemoteChange>();
for (const change of list) latest.set(change.syncId, change);
const live = [...latest.values()].filter((change) => !change.deleted);
const gone = [...latest.values()].filter((change) => change.deleted);
if (entity) {
const bySyncId = new Map(live.map((change) => [change.syncId, change]));
const ordered = orderSelfReferences(
entity,
live.map((change) => ({
...(change.row ?? {}),
syncId: change.syncId,
})),
);
out.push(...ordered.map((row) => bySyncId.get(row.syncId as string)!));
} else {
out.push(...live);
}
out.push(...gone);
}
return out;
}
export class SyncEngine {
private running = false;
private again = false;
private timer: NodeJS.Timeout | null = null;
private safety: NodeJS.Timeout | null = null;
private backoffMs = 0;
private events: AbortController | null = null;
private eventsFor: string | null = null;
private lastRefreshCheck = 0;
private serverTypes: Set<string> | null = null;
private stopFns: Array<() => void> = [];
private alerted = false;
readonly state: EngineState = {
running: false,
pending: 0,
serverTypes: [],
lastPassAt: null,
};
start(): void {
if (this.safety) return;
registerCoreSyncEntities();
this.safety = setInterval(() => this.request(0), SAFETY_INTERVAL_MS);
this.safety.unref?.();
this.stopFns.push(onLocalWrite(() => this.request(2000)));
// A plugin that came up may bring entity types to backfill.
this.stopFns.push(onEntitiesChanged(() => this.request(3000)));
this.request(3000);
}
stop(): void {
if (this.safety) clearInterval(this.safety);
if (this.timer) clearTimeout(this.timer);
this.safety = this.timer = null;
this.closeEvents();
for (const stop of this.stopFns.splice(0)) stop();
}
/** Runs a pass after `delayMs`, folding requests that arrive meanwhile. */
request(delayMs = 0): void {
if (this.timer) {
if (delayMs > 0) return;
clearTimeout(this.timer);
}
this.timer = setTimeout(() => {
this.timer = null;
void this.run();
}, delayMs);
this.timer.unref?.();
}
/** Runs a pass now and waits for it. */
async syncNow(): Promise<void> {
if (this.running) {
this.again = true;
while (this.running) await new Promise((r) => setTimeout(r, 100));
return;
}
await this.run();
}
/** Forgets what the server runs, after a relink or unlink. */
reset(): void {
this.serverTypes = null;
this.state.serverTypes = [];
this.state.pending = 0;
this.alerted = false;
this.backoffMs = 0;
this.closeEvents();
}
private async run(): Promise<void> {
if (this.running) {
this.again = true;
return;
}
this.running = this.state.running = true;
try {
await this.pass();
} finally {
this.running = this.state.running = false;
this.state.lastPassAt = new Date().toISOString();
if (this.again) {
this.again = false;
this.request(0);
}
}
}
private async pass(): Promise<void> {
const link = await getLink();
if (!link?.sessionToken) {
this.closeEvents();
return;
}
await updateLink({ status: "syncing" });
try {
await this.refreshSession(link);
const fresh = (await getLink())!;
await mirrorPlugins(fresh);
// A first pass pulls before it pushes, so rows both sides already
// have are matched up instead of sent back as edits.
const firstPass = fresh.knownTypes.length === 0;
if (!firstPass) {
await this.push(fresh);
await this.pull(fresh);
}
await this.backfill((await getLink())!);
if (firstPass) await this.push((await getLink())!);
this.backoffMs = 0;
this.alerted = false;
await updateLink({
status: "idle",
lastError: null,
lastSyncAt: new Date().toISOString(),
});
const done = (await getLink())!;
await applyElectronProxyConfig(done).catch(() => {});
this.openEvents(done);
} catch (error) {
await this.fail(error);
}
}
private async fail(error: unknown): Promise<void> {
const remote = error instanceof RemoteError ? error : null;
const message = error instanceof Error ? error.message : String(error);
syncLogger.warn("Sync pass failed", {
operation: "sync_pass",
kind: remote?.kind ?? "internal",
error: message,
});
if (remote?.kind === "signed_out") {
const link = await updateLink({
status: "signed_out",
lastError: message,
});
await applyElectronProxyConfig(link).catch(() => {});
this.closeEvents();
if (!this.alerted) {
this.alerted = true;
await sendCoreAlert({
title: "Sync is paused",
body: "This device was signed out of the server it syncs with. Sign in again from Sync to keep going.",
severity: "warning",
category: "termix.sync.signed_out",
dedupeKey: "sync:signed_out",
audience: "admins",
link: { tab: "sync" },
});
}
return;
}
const offline = remote?.kind === "offline";
await updateLink({
status: offline ? "offline" : "error",
lastError: message,
});
this.backoffMs = Math.min(
this.backoffMs ? this.backoffMs * 2 : 15_000,
10 * 60_000,
);
this.request(this.backoffMs);
}
private async refreshSession(link: SyncLink): Promise<void> {
if (Date.now() - this.lastRefreshCheck < REFRESH_CHECK_MS) return;
const result = await remoteJson<{ token: string | null }>(
link,
"/sync/v2/session/refresh",
{ method: "POST", body: {} },
);
this.lastRefreshCheck = Date.now();
if (result.token) await updateLink({ sessionToken: result.token });
}
/** Local rows the server does not have in this form yet. */
async outbox(link: SyncLink): Promise<{
ops: PushOp[];
hashes: Map<string, string>;
}> {
const records = await listRecords(link.userId);
const resolvers = createResolvers(link.userId);
const ops: PushOp[] = [];
const hashes = new Map<string, string>();
for (const entity of listEntities()) {
if (entity.readOnly) continue;
if (!link.knownTypes.includes(entity.type)) continue;
if (!syncable(entity, link, this.serverTypes)) continue;
const { rows, failed } = await loadWireRows(
entity,
link.userId,
resolvers,
);
for (const row of rows.values()) {
const key = recordKey(entity.type, row.syncId);
const record = records.get(key);
if (record && !record.deleted && record.hash === row.hash) continue;
// Unavailable server rows can recover without another local edit.
if (record?.errorHash === row.hash && record.error !== "unavailable") {
continue;
}
ops.push({
entityType: entity.type,
syncId: row.syncId,
baseRevision: record?.revision ?? 0,
op: "upsert",
row: row.wire,
});
hashes.set(key, row.hash);
}
for (const record of records.values()) {
if (record.entityType !== entity.type || record.deleted) continue;
if (record.revision === 0) continue;
if (rows.has(record.syncId) || failed.has(record.syncId)) continue;
if (entity.covers && !entity.covers(record.syncId)) continue;
if (
record.errorHash === DELETED_HASH &&
record.error !== "unavailable"
) {
continue;
}
ops.push({
entityType: entity.type,
syncId: record.syncId,
baseRevision: record.revision,
op: "delete",
});
}
}
return { ops, hashes };
}
private async push(link: SyncLink): Promise<void> {
const { ops, hashes } = await this.outbox(link);
this.state.pending = ops.length;
if (ops.length === 0) return;
for (const batch of batches(ops)) {
const { results } = await remoteJson<{ results: PushResult[] }>(
link,
"/sync/v2/push",
{ method: "POST", body: { ops: batch }, timeoutMs: 120_000 },
);
const byKey = new Map(
batch.map((op) => [recordKey(op.entityType, op.syncId), op]),
);
for (const result of results) {
const key = recordKey(result.entityType, result.syncId);
const op = byKey.get(key);
if (!op) continue;
await this.settle(link, op, result, hashes.get(key) ?? null);
}
this.state.pending = Math.max(0, this.state.pending - batch.length);
}
}
private async settle(
link: SyncLink,
op: PushOp,
result: PushResult,
hash: string | null,
): Promise<void> {
const userId = link.userId;
const entity = getEntity(op.entityType);
if (!entity) return;
if (result.status === "ok") {
await putRecord(userId, op.entityType, op.syncId, {
revision: result.revision,
hash: op.op === "delete" ? null : hash,
deleted: op.op === "delete",
});
return;
}
if (result.status === "rejected") {
await setRecordError(
userId,
op.entityType,
op.syncId,
result.reason,
hash ?? DELETED_HASH,
);
return;
}
// A live row the server could not send says nothing; keep ours to retry.
if (!result.deleted && !result.row) return;
// Conflict: the server moved on since this device last saw the row. Its
// version wins here, and a local edit it replaced is kept for the user.
if (op.op === "upsert" && op.row) {
await saveConflict(
userId,
op.entityType,
op.syncId,
op.row,
result.revision,
);
}
const resolvers = createResolvers(userId);
if (result.deleted) {
await deleteStoredRow(entity, userId, op.syncId);
await putRecord(userId, op.entityType, op.syncId, {
revision: result.revision,
hash: null,
deleted: true,
});
return;
}
await writeWireRow(
entity,
userId,
{ ...result.row, syncId: op.syncId },
resolvers,
);
const written = await loadWireRow(entity, userId, op.syncId, resolvers);
await putRecord(userId, op.entityType, op.syncId, {
revision: result.revision,
hash: written?.hash ?? null,
deleted: false,
});
}
private async fetchChanges(
link: SyncLink,
cursor: number,
types?: string[],
): Promise<{ changes: RemoteChange[]; cursor: number }> {
const all: RemoteChange[] = [];
let next = cursor;
for (let page = 0; page < MAX_PAGES; page++) {
const query = new URLSearchParams({
cursor: String(next),
limit: "1000",
});
if (types?.length) query.set("types", types.join(","));
const result = await remoteJson<ChangesPage>(
link,
`/sync/v2/changes?${query.toString()}`,
{ timeoutMs: 120_000 },
);
all.push(...result.changes);
next = result.nextCursor;
this.learnTypes(result);
if (!result.hasMore) break;
}
return { changes: all, cursor: next };
}
private learnTypes(page: ChangesPage): void {
this.serverTypes = new Set(page.entityTypes.map((entry) => entry.type));
this.state.serverTypes = page.entityTypes.map((entry) => ({
type: entry.type,
owner: entry.owner,
readOnly: entry.readOnly,
}));
}
private async pull(link: SyncLink): Promise<void> {
const { changes, cursor } = await this.fetchChanges(link, link.cursor);
const known = new Set(link.knownTypes);
await this.apply(
link,
changes.filter(
(change) =>
known.has(change.entityType) &&
!link.disabledTypes.includes(change.entityType),
),
false,
);
await updateLink({ cursor });
}
/** Pulls every row of types this device has not pulled before. */
private async backfill(link: SyncLink): Promise<void> {
if (!this.serverTypes) {
// Only the entity types are wanted, so a one-row page is enough.
this.learnTypes(
await remoteJson<ChangesPage>(
link,
`/sync/v2/changes?cursor=${link.cursor}&limit=1`,
{ timeoutMs: 120_000 },
),
);
}
const fresh = listEntities()
.map((entity) => entity.type)
.filter(
(type) =>
!link.knownTypes.includes(type) &&
!link.disabledTypes.includes(type) &&
this.serverTypes?.has(type),
);
if (fresh.length === 0) return;
const { changes, cursor } = await this.fetchChanges(link, 0, fresh);
await this.apply(link, changes, true);
const current = (await getLink())!;
await updateLink({
knownTypes: [...new Set([...current.knownTypes, ...fresh])],
// On a first pass the backfill is the whole pull, so it sets the cursor.
...(link.knownTypes.length === 0 ? { cursor } : {}),
});
}
/**
* Writes pulled changes. `adopt` is for a first pull of a type: a local row
* with no record yet takes the server's version without a conflict.
*/
private async apply(
link: SyncLink,
changes: RemoteChange[],
adopt: boolean,
): Promise<void> {
if (changes.length === 0) return;
const userId = link.userId;
const records = await listRecords(userId);
let missed = false;
const resolvers = createResolvers(userId, () => {
missed = true;
});
const retry: RemoteChange[] = [];
for (const change of sortChanges(changes)) {
const entity = getEntity(change.entityType);
if (!entity) continue;
const key = recordKey(change.entityType, change.syncId);
const record = records.get(key);
if (record && record.revision >= change.revision) continue;
try {
if (!entity.readOnly) {
await this.keepLocalEdit(
entity,
userId,
change,
record,
adopt,
resolvers,
);
}
if (change.deleted) {
await deleteStoredRow(entity, userId, change.syncId);
await putRecord(userId, change.entityType, change.syncId, {
revision: change.revision,
hash: null,
deleted: true,
});
continue;
}
missed = false;
await writeWireRow(
entity,
userId,
{ ...(change.row ?? {}), syncId: change.syncId },
resolvers,
);
if (missed) retry.push(change);
await this.recordApplied(entity, userId, change, resolvers);
} catch (error) {
syncLogger.warn("Could not apply a synced change", {
operation: "sync_apply",
entityType: change.entityType,
syncId: change.syncId,
error: error instanceof Error ? error.message : String(error),
});
}
}
// Rows that pointed at something written later in the same pull.
for (const change of retry) {
const entity = getEntity(change.entityType)!;
try {
await writeWireRow(
entity,
userId,
{ ...(change.row ?? {}), syncId: change.syncId },
resolvers,
);
await this.recordApplied(entity, userId, change, resolvers);
} catch {
// The first write stands.
}
}
}
private async recordApplied(
entity: RegisteredSyncEntity,
userId: string,
change: RemoteChange,
resolvers: ReturnType<typeof createResolvers>,
): Promise<void> {
const written = entity.readOnly
? null
: await loadWireRow(entity, userId, change.syncId, resolvers);
await putRecord(userId, change.entityType, change.syncId, {
revision: change.revision,
hash: written?.hash ?? null,
deleted: false,
});
}
/** Saves a local edit the incoming change is about to replace. */
private async keepLocalEdit(
entity: RegisteredSyncEntity,
userId: string,
change: RemoteChange,
record: SyncRecord | undefined,
adopt: boolean,
resolvers: ReturnType<typeof createResolvers>,
): Promise<void> {
if (!record && adopt) return;
const local = await loadWireRow(
entity,
userId,
change.syncId,
resolvers,
).catch(() => null);
if (!local) return;
if (record && !record.deleted && record.hash === local.hash) return;
await saveConflict(
userId,
change.entityType,
change.syncId,
local.wire,
change.revision,
);
}
private openEvents(link: SyncLink): void {
const key = `${link.serverUrl}|${link.sessionToken}`;
if (this.events && this.eventsFor === key) return;
this.closeEvents();
const controller = new AbortController();
this.events = controller;
this.eventsFor = key;
void this.listen(link, controller);
}
private closeEvents(): void {
this.events?.abort();
this.events = null;
this.eventsFor = null;
}
private async listen(
link: SyncLink,
controller: AbortController,
): Promise<void> {
try {
const response = await remoteFetch(link, "/sync/v2/events", {
signal: controller.signal,
});
if (!response.ok || !response.body)
throw new Error(`HTTP ${response.status}`);
const decoder = new TextDecoder();
let buffer = "";
for await (const chunk of response.body) {
buffer += decoder.decode(chunk as Uint8Array, { stream: true });
let index;
while ((index = buffer.indexOf("\n\n")) >= 0) {
const block = buffer.slice(0, index);
buffer = buffer.slice(index + 2);
if (/^event: change$/m.test(block)) this.request(500);
}
}
} catch {
// Dropped or refused: the safety interval still syncs, and the next
// successful pass reopens the stream.
}
if (this.events === controller) {
this.events = null;
this.eventsFor = null;
if (!controller.signal.aborted) {
setTimeout(() => this.request(0), 30_000).unref?.();
}
}
}
/** Where the link's proxy settings have to reach outside the backend. */
async applyProxyConfig(): Promise<void> {
await applyElectronProxyConfig(await getLink());
}
}
function* batches(ops: PushOp[]): Generator<PushOp[]> {
let batch: PushOp[] = [];
let bytes = 0;
for (const op of ops) {
const size = JSON.stringify(op).length;
if (
batch.length > 0 &&
(batch.length >= MAX_BATCH_OPS || bytes + size > MAX_BATCH_BYTES)
) {
yield batch;
batch = [];
bytes = 0;
}
batch.push(op);
bytes += size;
}
if (batch.length > 0) yield batch;
}
let engine: SyncEngine | null = null;
export function getSyncEngine(): SyncEngine {
if (!engine) engine = new SyncEngine();
return engine;
}
/** Starts syncing on a desktop. A server install never links. */
export function startDesktopSync(): void {
if (process.env.ELECTRON_EMBEDDED !== "true") return;
const instance = getSyncEngine();
instance.start();
void instance.applyProxyConfig().catch(() => {});
}
export function defaultDeviceName(): string {
return os.hostname() || "Termix Desktop";
}
+65 -5
View File
@@ -9,7 +9,7 @@
import { Agent, ProxyAgent, fetch as undiciFetch } from "undici";
import type { Dispatcher } from "undici";
import type { BasicAuth, ProxyHeader } from "./link-store.js";
import type { BasicAuth, ClientCert, ProxyHeader } from "./link-store.js";
export interface RemoteTarget {
serverUrl: string;
@@ -17,6 +17,7 @@ export interface RemoteTarget {
customHeaders?: ProxyHeader[];
basicAuth?: BasicAuth | null;
allowInvalidCertificate?: boolean;
clientCert?: ClientCert | null;
}
export type RemoteErrorKind =
@@ -24,6 +25,8 @@ export type RemoteErrorKind =
| "offline"
/** TLS failed and the certificate is not allowed. */
| "tls"
/** The server wants a client certificate and none was accepted. */
| "client_cert"
/** A proxy answered instead of Termix (its login page, a 404 page). */
| "proxy"
/** The proxy wants basic auth. */
@@ -53,6 +56,13 @@ const TLS_CODES = new Set([
"CERT_UNTRUSTED",
]);
const CLIENT_CERT_CODES = new Set([
"ERR_SSL_TLSV13_ALERT_CERTIFICATE_REQUIRED",
"ERR_SSL_SSLV3_ALERT_BAD_CERTIFICATE",
"ERR_SSL_TLSV1_ALERT_UNKNOWN_CA",
"ERR_SSL_SSLV3_ALERT_HANDSHAKE_FAILURE",
]);
function envProxy(url: string): string | null {
const proxy =
process.env.https_proxy ||
@@ -84,10 +94,16 @@ const dispatchers = new Map<string, Dispatcher>();
function dispatcherFor(target: RemoteTarget): Dispatcher {
const insecure = !!target.allowInvalidCertificate;
const proxy = envProxy(target.serverUrl);
const key = `${insecure}|${proxy ?? ""}`;
const cert = target.clientCert;
const key = `${insecure}|${proxy ?? ""}|${cert ? `${cert.passphrase}|${cert.pfx}` : ""}`;
let dispatcher = dispatchers.get(key);
if (!dispatcher) {
const tls = insecure ? { rejectUnauthorized: false } : {};
const tls = {
...(insecure ? { rejectUnauthorized: false } : {}),
...(cert
? { pfx: Buffer.from(cert.pfx, "base64"), passphrase: cert.passphrase }
: {}),
};
dispatcher = proxy
? new ProxyAgent({ uri: proxy, requestTls: tls })
: new Agent({ connect: tls });
@@ -141,10 +157,22 @@ export function requestHeaders(target: RemoteTarget): Record<string, string> {
return headers;
}
function describeNetworkError(error: unknown): RemoteError {
// A server that requires a client certificate often just drops the
// connection when it gets none, or one it does not accept.
const DROPPED_CODES = new Set(["UND_ERR_SOCKET", "ECONNRESET"]);
function describeNetworkError(
error: unknown,
target?: RemoteTarget,
): RemoteError {
const cause = (error as { cause?: { code?: string; message?: string } })
?.cause;
const code = cause?.code ?? (error as { code?: string })?.code ?? "";
const droppedOverTls =
DROPPED_CODES.has(code) && target?.serverUrl.startsWith("https:");
if (CLIENT_CERT_CODES.has(code) || droppedOverTls) {
return new RemoteError("client_cert", cause?.message || code);
}
if (TLS_CODES.has(code) || /certificate/i.test(cause?.message ?? "")) {
return new RemoteError("tls", cause?.message || code);
}
@@ -193,13 +221,45 @@ export async function remoteFetch(
signal: controller.signal,
});
} catch (error) {
throw describeNetworkError(error);
throw describeNetworkError(error, target);
} finally {
if (timeout) clearTimeout(timeout);
}
return response;
}
export interface RemoteStreamRequest {
method?: string;
headers?: Record<string, string>;
/** A string, a buffer or a Node stream. */
body?: unknown;
signal?: AbortSignal;
}
/**
* Sends a request with the link's proxy headers and certificate rules and a
* body that may be a stream. The response body is left unread for the caller.
*/
export async function remoteStreamFetch(
target: RemoteTarget,
pathname: string,
request: RemoteStreamRequest = {},
) {
const headers = { ...request.headers, ...requestHeaders(target) };
try {
return await undiciFetch(remoteUrl(target.serverUrl, pathname), {
method: request.method ?? "GET",
headers,
body: request.body as never,
duplex: "half",
dispatcher: dispatcherFor(target),
signal: request.signal,
} as never);
} catch (error) {
throw describeNetworkError(error, target);
}
}
/** Sends a request and parses a JSON answer from Termix. */
export async function remoteJson<T>(
target: RemoteTarget,
+96 -14
View File
@@ -1,9 +1,13 @@
/**
* The desktop's link to a server, one row in sync_link. Secrets (the session
* token, proxy headers, basic auth) are stored encrypted with the system key.
* The desktop's links to servers, one row each in sync_link and at most one
* per local user, since each link has its own replica user. Secrets (the
* session token, proxy headers, basic auth) are stored encrypted with the
* system key. Without a user, a call means the active account's link.
*/
import { createSecureContext } from "node:tls";
import { eq } from "drizzle-orm";
import { getActiveUserId } from "../accounts.js";
import { syncLink } from "../../database/db/schema.js";
import { createCurrentRepositoryContext } from "../../database/repositories/factory.js";
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
@@ -33,6 +37,14 @@ export interface BasicAuth {
password: string;
}
/** A PFX/P12 client certificate for servers that require mutual TLS. */
export interface ClientCert {
/** Base64 of the PFX/P12 file. */
pfx: string;
passphrase: string;
name: string | null;
}
export interface SyncLink {
id: number;
userId: string;
@@ -43,11 +55,14 @@ export interface SyncLink {
customHeaders: ProxyHeader[];
basicAuth: BasicAuth | null;
allowInvalidCertificate: boolean;
clientCert: ClientCert | null;
remoteUserId: string | null;
remoteUsername: string | null;
account: LinkAccount | null;
/** Entity types switched off on this device. */
disabledTypes: string[];
/** Extra connection options for this server, kept open for new ones. */
options: Record<string, unknown>;
/** Entity types this device has fully pulled at least once. */
knownTypes: string[];
cursor: number;
@@ -90,7 +105,11 @@ async function fromRow(row: Row): Promise<SyncLink> {
sessionToken = null;
}
}
const scope = parseJson<{ disabled?: string[] }>(row.scope, {});
const scope = parseJson<{
disabled?: string[];
options?: Record<string, unknown>;
clientCert?: string;
}>(row.scope, {});
return {
id: row.id,
userId: row.userId,
@@ -101,10 +120,16 @@ async function fromRow(row: Row): Promise<SyncLink> {
customHeaders: await decryptJson<ProxyHeader[]>(row.customHeaders, []),
basicAuth: await decryptJson<BasicAuth | null>(row.basicAuth, null),
allowInvalidCertificate: !!row.allowInvalidCertificate,
clientCert: await decryptJson<ClientCert | null>(
scope.clientCert ?? null,
null,
),
remoteUserId: row.remoteUserId,
remoteUsername: row.remoteUsername,
account: parseJson<LinkAccount | null>(row.account, null),
disabledTypes: Array.isArray(scope.disabled) ? scope.disabled : [],
options:
scope.options && typeof scope.options === "object" ? scope.options : {},
knownTypes: parseJson<string[]>(row.knownTypes, []),
cursor: Number(row.cursor) || 0,
status: (row.status as LinkStatus) || "idle",
@@ -114,8 +139,20 @@ async function fromRow(row: Row): Promise<SyncLink> {
};
}
export async function getLink(): Promise<SyncLink | null> {
const [row] = await db().select().from(syncLink).limit(1);
export async function listLinks(): Promise<SyncLink[]> {
const rows = await db().select().from(syncLink);
return Promise.all(rows.map(fromRow));
}
/** The link of one local user, or of the active account when none is given. */
export async function getLink(userId?: string): Promise<SyncLink | null> {
const owner = userId ?? (await getActiveUserId());
if (!owner) return null;
const [row] = await db()
.select()
.from(syncLink)
.where(eq(syncLink.userId, owner))
.limit(1);
return row ? fromRow(row) : null;
}
@@ -128,12 +165,40 @@ export interface NewLink {
customHeaders?: ProxyHeader[];
basicAuth?: BasicAuth | null;
allowInvalidCertificate?: boolean;
clientCert?: ClientCert | null;
remoteUserId?: string | null;
remoteUsername?: string | null;
}
/** Throws when the PFX is unreadable or the passphrase is wrong. */
export function assertClientCert(cert: ClientCert): void {
try {
createSecureContext({
pfx: Buffer.from(cert.pfx, "base64"),
passphrase: cert.passphrase,
});
} catch {
throw new Error("The client certificate or its passphrase is not valid");
}
}
async function sealScope(
disabled: string[],
options: Record<string, unknown>,
clientCert: ClientCert | null,
): Promise<string> {
return JSON.stringify({
disabled,
options,
...(clientCert
? { clientCert: await encryptSystemSecret(JSON.stringify(clientCert)) }
: {}),
});
}
export async function createLink(link: NewLink): Promise<SyncLink> {
await db().delete(syncLink);
if (link.clientCert) assertClientCert(link.clientCert);
await db().delete(syncLink).where(eq(syncLink.userId, link.userId));
await db()
.insert(syncLink)
.values({
@@ -149,6 +214,7 @@ export async function createLink(link: NewLink): Promise<SyncLink> {
? await encryptSystemSecret(JSON.stringify(link.basicAuth))
: null,
allowInvalidCertificate: !!link.allowInvalidCertificate,
scope: link.clientCert ? await sealScope([], {}, link.clientCert) : null,
remoteUserId: link.remoteUserId ?? null,
remoteUsername: link.remoteUsername ?? null,
cursor: 0,
@@ -157,7 +223,7 @@ export async function createLink(link: NewLink): Promise<SyncLink> {
linkedAt: new Date().toISOString(),
});
await DatabaseSaveTrigger.forceSave("sync_link");
return (await getLink())!;
return (await getLink(link.userId))!;
}
export interface LinkPatch {
@@ -167,10 +233,12 @@ export interface LinkPatch {
customHeaders?: ProxyHeader[];
basicAuth?: BasicAuth | null;
allowInvalidCertificate?: boolean;
clientCert?: ClientCert | null;
remoteUserId?: string | null;
remoteUsername?: string | null;
account?: LinkAccount | null;
disabledTypes?: string[];
options?: Record<string, unknown>;
knownTypes?: string[];
cursor?: number;
status?: LinkStatus;
@@ -178,9 +246,13 @@ export interface LinkPatch {
lastSyncAt?: string | null;
}
export async function updateLink(patch: LinkPatch): Promise<SyncLink | null> {
const current = await getLink();
export async function updateLink(
patch: LinkPatch,
userId?: string,
): Promise<SyncLink | null> {
const current = await getLink(userId);
if (!current) return null;
if (patch.clientCert) assertClientCert(patch.clientCert);
const set: Partial<Row> = {};
if (patch.serverName !== undefined) set.serverName = patch.serverName;
if (patch.serverVersion !== undefined)
@@ -210,8 +282,16 @@ export async function updateLink(patch: LinkPatch): Promise<SyncLink | null> {
if (patch.account !== undefined) {
set.account = patch.account ? JSON.stringify(patch.account) : null;
}
if (patch.disabledTypes !== undefined) {
set.scope = JSON.stringify({ disabled: patch.disabledTypes });
if (
patch.disabledTypes !== undefined ||
patch.options !== undefined ||
patch.clientCert !== undefined
) {
set.scope = await sealScope(
patch.disabledTypes ?? current.disabledTypes,
patch.options ?? current.options,
patch.clientCert === undefined ? current.clientCert : patch.clientCert,
);
}
if (patch.knownTypes !== undefined) {
set.knownTypes = JSON.stringify(patch.knownTypes);
@@ -224,10 +304,12 @@ export async function updateLink(patch: LinkPatch): Promise<SyncLink | null> {
await db().update(syncLink).set(set).where(eq(syncLink.id, current.id));
DatabaseSaveTrigger.triggerSave("sync_link");
}
return getLink();
return getLink(current.userId);
}
export async function deleteLink(): Promise<void> {
await db().delete(syncLink);
export async function deleteLink(userId?: string): Promise<void> {
const owner = userId ?? (await getActiveUserId());
if (!owner) return;
await db().delete(syncLink).where(eq(syncLink.userId, owner));
await DatabaseSaveTrigger.forceSave("sync_link");
}
+9 -4
View File
@@ -11,7 +11,10 @@
import fs from "fs";
import path from "path";
import semver from "semver";
import type { PluginDesktopMode } from "@termix-ssh/plugin-sdk/manifest";
import {
resolvePluginDesktopMode,
type PluginDesktopMode,
} from "@termix-ssh/plugin-sdk/manifest";
import { syncLogger } from "../../utils/logger.js";
import { sendCoreAlert } from "../../notify/core-notify.js";
import {
@@ -227,7 +230,9 @@ async function mirrorOne(link: SyncLink, plugin: RemotePlugin): Promise<void> {
}
const { loader } = getPluginRuntime();
let local = loader.get(plugin.id);
const mode = local?.manifest.desktop ?? plugin.desktop ?? "mirror";
const mode = local
? resolvePluginDesktopMode(local.manifest)
: (plugin.desktop ?? "mirror");
if (mode === "server") {
if (local && (await isEnabledLocally(plugin.id))) {
@@ -328,7 +333,7 @@ export async function mirrorPlugins(link: SyncLink): Promise<void> {
// A plugin the server no longer has is switched off here. Its data stays.
for (const local of getPluginRuntime().loader.list()) {
if (remoteIds.has(local.id) || local.source !== "user") continue;
if ((local.manifest.desktop ?? "mirror") !== "mirror") continue;
if (resolvePluginDesktopMode(local.manifest) !== "mirror") continue;
if (await isEnabledLocally(local.id)) {
await setPluginEnabled(local.id, false).catch(() => {});
}
@@ -341,7 +346,7 @@ export async function mirrorPlugins(link: SyncLink): Promise<void> {
*/
export function isServerManaged(pluginId: string): boolean {
const local = getPluginRuntime().loader.get(pluginId);
const mode = local?.manifest.desktop ?? "mirror";
const mode = local ? resolvePluginDesktopMode(local.manifest) : "mirror";
if (mode !== "mirror") return mode === "server";
return local?.source !== "user" || vouchedHere(local);
}
-761
View File
@@ -1,761 +0,0 @@
/**
* The desktop's own sync routes, under /sync/link. They only exist in the
* embedded desktop backend; on a server they answer 404.
*/
import express, {
type NextFunction,
type Request,
type Response,
} from "express";
import { and, eq, isNotNull } from "drizzle-orm";
import type { SyncRow } from "@termix-ssh/plugin-sdk/backend";
import type { AuthenticatedRequest } from "../../../types/index.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { syncLogger } from "../../utils/logger.js";
import { hosts, sshCredentials } from "../../database/db/schema.js";
import { createCurrentRepositoryContext } from "../../database/repositories/factory.js";
import { getPluginRuntime } from "../../plugins/index.js";
import { getEntity, listEntities } from "../../plugins/sync-registry.js";
import { registerCoreSyncEntities } from "../entities.js";
import {
createResolvers,
deleteStoredRow,
listStoredRows,
regenerateSyncIds,
writeWireRow,
} from "../store.js";
import {
clearRecordErrors,
deleteConflict,
deleteRecordsForUser,
getConflict,
listConflicts,
listRecordErrors,
} from "../records.js";
import { SYNC_PROTOCOL_VERSION } from "../protocol.js";
import {
createLink,
deleteLink,
getLink,
updateLink,
type BasicAuth,
type ProxyHeader,
type SyncLink,
} from "./link-store.js";
import {
normalizeServerUrl,
RemoteError,
remoteJson,
type RemoteTarget,
} from "./http.js";
import { defaultDeviceName, getSyncEngine } from "./engine.js";
import { applyElectronProxyConfig } from "./electron-proxy.js";
import { lastRemotePlugins } from "./plugins.js";
const router = express.Router();
const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
function desktopOnly(_req: Request, res: Response, next: NextFunction) {
if (process.env.ELECTRON_EMBEDDED !== "true") {
res.status(404).json({ error: "Not found" });
return;
}
next();
}
const guard = [desktopOnly, authenticateJWT];
function userOf(req: Request): string {
return (req as AuthenticatedRequest).userId;
}
function readHeaders(value: unknown): ProxyHeader[] {
if (!Array.isArray(value)) return [];
return value
.filter(
(header): header is ProxyHeader =>
!!header &&
typeof header.name === "string" &&
typeof header.value === "string" &&
/^[A-Za-z0-9-]{1,100}$/.test(header.name.trim()),
)
.map((header) => ({ name: header.name.trim(), value: header.value }))
.slice(0, 20);
}
function readBasicAuth(value: unknown): BasicAuth | null {
if (!value || typeof value !== "object") return null;
const { username, password } = value as Record<string, unknown>;
if (typeof username !== "string" || !username) return null;
return {
username,
password: typeof password === "string" ? password : "",
};
}
function readTarget(body: Record<string, unknown>): RemoteTarget | null {
const serverUrl =
typeof body.serverUrl === "string"
? normalizeServerUrl(body.serverUrl)
: null;
if (!serverUrl) return null;
return {
serverUrl,
customHeaders: readHeaders(body.customHeaders),
basicAuth: readBasicAuth(body.basicAuth),
allowInvalidCertificate: body.allowInvalidCertificate === true,
};
}
/** What the sync panel can show about each entity type. */
function describeEntities(link: SyncLink | null) {
registerCoreSyncEntities();
const names = new Map<string, string>();
for (const plugin of getPluginRuntime().loader.list()) {
names.set(plugin.id, plugin.manifest.name);
}
for (const plugin of lastRemotePlugins()) {
if (!names.has(plugin.id)) names.set(plugin.id, plugin.name);
}
const serverTypes = getSyncEngine().state.serverTypes;
const seen = new Set<string>();
const entries = [
...listEntities().map((entity) => ({
type: entity.type,
owner: entity.owner,
readOnly: !!entity.readOnly,
})),
...serverTypes,
];
return entries
.filter((entry) => {
if (seen.has(entry.type) || entry.type === "accountProfile") return false;
seen.add(entry.type);
return true;
})
.map((entry) => ({
type: entry.type,
owner: entry.owner,
ownerName:
entry.owner === "core" ? null : (names.get(entry.owner) ?? entry.owner),
readOnly: entry.readOnly,
onServer: serverTypes.some((server) => server.type === entry.type),
onDevice: !!getEntity(entry.type),
enabled: !link?.disabledTypes.includes(entry.type),
}));
}
async function statusBody() {
const link = await getLink();
const engine = getSyncEngine();
if (!link) {
return { linked: false, entities: describeEntities(null) };
}
return {
linked: true,
serverUrl: link.serverUrl,
serverName: link.serverName,
serverVersion: link.serverVersion,
account: link.account ?? {
username: link.remoteUsername ?? undefined,
},
status: engine.state.running ? "syncing" : link.status,
lastError: link.lastError,
linkedAt: link.linkedAt,
lastSyncAt: link.lastSyncAt,
pending: engine.state.pending,
conflicts: (await listConflicts(link.userId)).length,
errors: (await listRecordErrors(link.userId)).length,
firstSyncDone: link.knownTypes.length > 0,
allowInvalidCertificate: link.allowInvalidCertificate,
customHeaders: link.customHeaders.map((header) => ({
name: header.name,
value: "",
set: true,
})),
basicAuth: link.basicAuth
? { username: link.basicAuth.username, password: "", set: true }
: null,
entities: describeEntities(link),
};
}
/** Removes read-only copies of what other users shared with the account. */
async function removeSharedCopies(userId: string): Promise<void> {
const db = createCurrentRepositoryContext().drizzle;
const sharedHosts = await db
.select({ id: hosts.id })
.from(hosts)
.where(and(eq(hosts.userId, userId), isNotNull(hosts.sharedSource)));
const { deleteOwnedHost } = await import("../../hosts/delete-host.js");
for (const host of sharedHosts) await deleteOwnedHost(userId, host.id);
const sharedCredentials = await db
.select({ id: sshCredentials.id })
.from(sshCredentials)
.where(
and(
eq(sshCredentials.userId, userId),
isNotNull(sshCredentials.sharedSource),
),
);
const { deleteOwnedCredential } =
await import("../../hosts/delete-credential.js");
for (const credential of sharedCredentials) {
await deleteOwnedCredential(userId, credential.id);
}
}
/** Deletes every row that syncs, keeping local-only ones. */
async function wipeSyncedData(userId: string): Promise<void> {
const entities = [...listEntities()].reverse();
for (const entity of entities) {
if (entity.readOnly) continue;
try {
if (entity.load) {
for (const row of await entity.load(userId)) {
await entity.erase?.(userId, String(row.syncId));
}
continue;
}
if (!entity.table || entity.singleton) continue;
for (const row of await listStoredRows(entity, userId)) {
if (entity.shouldSync && entity.shouldSync(row) === false) continue;
if (typeof row.syncId !== "string") continue;
await deleteStoredRow(entity, userId, row.syncId);
}
} catch (error) {
syncLogger.warn("Could not clear synced data", {
operation: "sync_wipe",
entityType: entity.type,
error: error instanceof Error ? error.message : String(error),
});
}
}
}
function problemFor(error: unknown) {
if (!(error instanceof RemoteError)) {
return { problem: "unreachable", message: String(error) };
}
switch (error.kind) {
case "tls":
return { problem: "tls_untrusted", message: error.message };
case "basic_auth":
return { problem: "basic_auth_required", message: error.message };
case "proxy":
return error.status === 404
? { problem: "not_termix", message: error.message }
: { problem: "proxy_login", message: error.message };
case "signed_out":
case "server":
// A Termix older than 2.9 has no /sync/v2 and answers with an error.
return { problem: "version_mismatch", message: error.message };
default:
return { problem: "unreachable", message: error.message };
}
}
/**
* @openapi
* /sync/link/status:
* get:
* summary: This desktop's link to a server
* description: Desktop only. Whether the app is linked, to which server and account, how syncing is going, and which kinds of data sync.
* tags:
* - Sync
* responses:
* 200:
* description: The link status.
*/
router.get("/link/status", ...guard, async (_req: Request, res: Response) => {
try {
res.json(await statusBody());
} catch (error) {
syncLogger.error("Failed to read sync status", error, {
operation: "sync_status",
});
res.status(500).json({ error: "Failed to read sync status" });
}
});
/**
* @openapi
* /sync/link/probe:
* post:
* summary: Check a server address before linking
* description: Desktop only. Tries the server with the given proxy settings and says what is in the way, if anything. On success the proxy settings are also applied to the desktop's browser session so the sign-in page loads.
* tags:
* - Sync
* responses:
* 200:
* description: The server's name and version, or the problem found.
*/
router.post("/link/probe", ...guard, async (req: Request, res: Response) => {
const target = readTarget(req.body ?? {});
if (!target) return res.json({ ok: false, problem: "invalid_url" });
try {
const info = await remoteJson<{
termix?: boolean;
name?: string;
version?: string;
protocol?: number;
}>(target, "/sync/v2/info", { timeoutMs: 15_000 });
if (!info?.termix) return res.json({ ok: false, problem: "not_termix" });
if (info.protocol !== SYNC_PROTOCOL_VERSION) {
return res.json({
ok: false,
problem: "version_mismatch",
version: info.version,
});
}
await applyElectronProxyConfig({
serverUrl: target.serverUrl,
customHeaders: target.customHeaders ?? [],
basicAuth: target.basicAuth ?? null,
allowInvalidCertificate: !!target.allowInvalidCertificate,
}).catch(() => {});
res.json({
ok: true,
serverUrl: target.serverUrl,
name: info.name ?? "Termix",
version: info.version ?? null,
});
} catch (error) {
res.json({ ok: false, ...problemFor(error) });
}
});
/**
* @openapi
* /sync/link/preview:
* get:
* summary: How much this desktop has that would sync
* description: Desktop only. Row counts per kind of data, shown before choosing whether to merge this desktop's data into the account or replace it.
* tags:
* - Sync
* responses:
* 200:
* description: Counts per entity type.
*/
router.get("/link/preview", ...guard, async (req: Request, res: Response) => {
const userId = userOf(req);
registerCoreSyncEntities();
const counts: Record<string, number> = {};
for (const entity of listEntities()) {
if (entity.readOnly) continue;
try {
if (entity.load) {
counts[entity.type] = (await entity.load(userId)).length;
continue;
}
if (!entity.table) continue;
counts[entity.type] = (await listStoredRows(entity, userId)).filter(
(row) => !entity.shouldSync || entity.shouldSync(row) !== false,
).length;
} catch {
counts[entity.type] = 0;
}
}
res.json({ counts });
});
/**
* @openapi
* /sync/link/complete:
* post:
* summary: Link this desktop to a server account
* description: Desktop only. Takes the session from signing in to the server, trades it for a long-lived desktop session, and starts syncing. "merge" uploads this desktop's data into the account; "replace" deletes it here first and takes the account's.
* tags:
* - Sync
* responses:
* 200:
* description: Linked; the new status.
* 400:
* description: Missing server or session.
* 502:
* description: The server refused the session.
*/
router.post("/link/complete", ...guard, async (req: Request, res: Response) => {
const userId = userOf(req);
const body = req.body ?? {};
const target = readTarget(body);
const token = typeof body.token === "string" ? body.token : "";
const mode = body.mode === "replace" ? "replace" : "merge";
if (!target || !token) {
return res.status(400).json({ error: "Missing server or session" });
}
try {
const deviceName =
typeof body.deviceName === "string" && body.deviceName.trim()
? body.deviceName.trim()
: defaultDeviceName();
const linked = await remoteJson<{
token: string;
user: { id: string; username: string };
server?: { version?: string | null };
}>({ ...target, sessionToken: token }, "/sync/v2/link", {
method: "POST",
body: { deviceName },
});
const previous = await getLink();
if (previous) {
await removeSharedCopies(previous.userId);
await deleteRecordsForUser(previous.userId);
}
if (mode === "replace") {
await wipeSyncedData(userId);
}
await deleteRecordsForUser(userId);
const link = await createLink({
userId,
serverUrl: target.serverUrl,
serverName: typeof body.serverName === "string" ? body.serverName : null,
serverVersion: linked.server?.version ?? null,
sessionToken: linked.token,
customHeaders: target.customHeaders,
basicAuth: target.basicAuth,
allowInvalidCertificate: target.allowInvalidCertificate,
remoteUserId: linked.user.id,
remoteUsername: linked.user.username,
});
const engine = getSyncEngine();
engine.reset();
await applyElectronProxyConfig(link).catch(() => {});
engine.request(0);
res.json(await statusBody());
} catch (error) {
syncLogger.warn("Linking to a server failed", {
operation: "sync_link_complete",
error: error instanceof Error ? error.message : String(error),
});
res.status(502).json({ ...problemFor(error) });
}
});
/**
* @openapi
* /sync/link/relogin:
* post:
* summary: Sign this desktop back in after its session ended
* description: Desktop only. Takes a new sign-in session for the same account and resumes syncing where it stopped.
* tags:
* - Sync
* responses:
* 200:
* description: Signed in again; the new status.
* 409:
* description: The sign-in was for a different account.
*/
router.post("/link/relogin", ...guard, async (req: Request, res: Response) => {
const token = typeof req.body?.token === "string" ? req.body.token : "";
const link = await getLink();
if (!link || !token) return res.status(400).json({ error: "Not linked" });
try {
const linked = await remoteJson<{
token: string;
user: { id: string; username: string };
}>({ ...link, sessionToken: token }, "/sync/v2/link", {
method: "POST",
body: { deviceName: defaultDeviceName() },
});
if (link.remoteUserId && linked.user.id !== link.remoteUserId) {
return res.status(409).json({ error: "different_account" });
}
await updateLink({
sessionToken: linked.token,
status: "idle",
lastError: null,
});
getSyncEngine().request(0);
res.json(await statusBody());
} catch (error) {
res.status(502).json({ ...problemFor(error) });
}
});
/**
* @openapi
* /sync/link/unlink:
* post:
* summary: Unlink this desktop from its server
* description: Desktop only. Ends the desktop's session on the server when it can be reached. With keepData the synced data stays on this device as its own; otherwise it is deleted. Copies of what others shared with the account are always removed.
* tags:
* - Sync
* responses:
* 200:
* description: Unlinked.
*/
router.post("/link/unlink", ...guard, async (req: Request, res: Response) => {
const keepData = req.body?.keepData !== false;
const link = await getLink();
if (!link) return res.json(await statusBody());
const userId = link.userId;
try {
await remoteJson(link, "/sync/v2/unlink", {
method: "POST",
body: {},
timeoutMs: 10_000,
}).catch(() => {});
const engine = getSyncEngine();
engine.reset();
await removeSharedCopies(userId);
if (keepData) {
// New syncIds so this data reads as new if it is ever linked again.
for (const entity of listEntities()) {
if (!entity.readOnly) await regenerateSyncIds(entity, userId);
}
} else {
await wipeSyncedData(userId);
}
await deleteRecordsForUser(userId);
await deleteLink();
await applyElectronProxyConfig(null).catch(() => {});
res.json(await statusBody());
} catch (error) {
syncLogger.error("Unlinking failed", error, { operation: "sync_unlink" });
res.status(500).json({ error: "Failed to unlink" });
}
});
/**
* @openapi
* /sync/link/sync:
* post:
* summary: Sync now
* description: Desktop only. Runs a sync pass and waits for it.
* tags:
* - Sync
* responses:
* 200:
* description: The status after the pass.
*/
router.post("/link/sync", ...guard, async (_req: Request, res: Response) => {
await getSyncEngine().syncNow();
res.json(await statusBody());
});
/**
* @openapi
* /sync/link/settings:
* put:
* summary: Change what syncs and how the server is reached
* description: Desktop only. disabledTypes lists the kinds of data that stay on this device. Proxy headers and basic auth left blank keep their saved values.
* tags:
* - Sync
* responses:
* 200:
* description: The new status.
* 400:
* description: Not linked.
*/
router.put("/link/settings", ...guard, async (req: Request, res: Response) => {
const link = await getLink();
if (!link) return res.status(400).json({ error: "Not linked" });
const body = req.body ?? {};
const patch: Parameters<typeof updateLink>[0] = {};
if (Array.isArray(body.disabledTypes)) {
const disabled = body.disabledTypes.filter(
(type: unknown): type is string =>
typeof type === "string" && type !== "accountProfile",
);
const changed = new Set([
...disabled.filter((type: string) => !link.disabledTypes.includes(type)),
...link.disabledTypes.filter((type) => !disabled.includes(type)),
]);
patch.disabledTypes = disabled;
// A type switched back on is pulled again in full.
patch.knownTypes = link.knownTypes.filter((type) => !changed.has(type));
}
if (Array.isArray(body.customHeaders)) {
const saved = new Map(link.customHeaders.map((h) => [h.name, h.value]));
patch.customHeaders = (body.customHeaders as unknown[])
.filter(
(header): header is { name: string; value?: string } =>
!!header && typeof (header as { name?: unknown }).name === "string",
)
.map((header) => ({
name: header.name,
value: header.value || saved.get(header.name) || "",
}))
.filter((header) => header.value);
patch.customHeaders = readHeaders(patch.customHeaders);
}
if (body.basicAuth === null) {
patch.basicAuth = null;
} else if (body.basicAuth && typeof body.basicAuth === "object") {
const next = readBasicAuth(body.basicAuth);
if (next && !next.password && link.basicAuth) {
next.password = link.basicAuth.password;
}
patch.basicAuth = next;
}
if (typeof body.allowInvalidCertificate === "boolean") {
patch.allowInvalidCertificate = body.allowInvalidCertificate;
}
const updated = await updateLink(patch);
await applyElectronProxyConfig(updated).catch(() => {});
getSyncEngine().request(0);
res.json(await statusBody());
});
/**
* @openapi
* /sync/link/conflicts:
* get:
* summary: Local edits that lost to a newer edit on the server
* description: Desktop only. The server's version was kept; each entry holds this device's version so it can be restored.
* tags:
* - Sync
* responses:
* 200:
* description: The conflicts, oldest first.
*/
router.get("/link/conflicts", ...guard, async (req: Request, res: Response) => {
const conflicts = await listConflicts(userOf(req));
res.json({
conflicts: conflicts.map((conflict) => {
let row: SyncRow = {};
try {
row = JSON.parse(conflict.localRow);
} catch {
row = {};
}
return {
id: conflict.id,
entityType: conflict.entityType,
syncId: conflict.syncId,
name: row.name ?? row.title ?? row.label ?? row.ip ?? conflict.syncId,
createdAt: conflict.createdAt,
};
}),
});
});
/**
* @openapi
* /sync/link/conflicts/{id}:
* post:
* summary: Settle a conflict
* description: Desktop only. keep "mine" puts this device's version back, and the next sync sends it to the server. keep "server" drops it.
* tags:
* - Sync
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Settled.
* 404:
* description: No such conflict.
*/
router.post(
"/link/conflicts/:id",
...guard,
async (req: Request, res: Response) => {
const userId = userOf(req);
const conflict = await getConflict(userId, Number(req.params.id));
if (!conflict) return res.status(404).json({ error: "Not found" });
try {
if (req.body?.keep === "mine") {
const entity = getEntity(conflict.entityType);
if (entity && !entity.readOnly) {
const row = JSON.parse(conflict.localRow) as SyncRow;
await writeWireRow(
entity,
userId,
{ ...row, syncId: conflict.syncId },
createResolvers(userId),
);
}
}
await deleteConflict(userId, conflict.id);
getSyncEngine().request(0);
res.json({ success: true });
} catch (error) {
syncLogger.warn("Could not settle a sync conflict", {
operation: "sync_conflict",
error: error instanceof Error ? error.message : String(error),
});
res.status(500).json({ error: "Failed to settle conflict" });
}
},
);
/**
* @openapi
* /sync/link/errors:
* get:
* summary: Changes the server refused
* description: Desktop only. Each entry names the record and why the server refused it. A refused change is not sent again until it changes or is retried.
* tags:
* - Sync
* responses:
* 200:
* description: The refused changes.
*/
router.get("/link/errors", ...guard, async (req: Request, res: Response) => {
const errors = await listRecordErrors(userOf(req));
res.json({
errors: errors.map((record) => ({
entityType: record.entityType,
syncId: record.syncId,
reason: record.error,
})),
});
});
/**
* @openapi
* /sync/link/errors/retry:
* post:
* summary: Send refused changes again
* description: Desktop only. Clears every refusal and syncs.
* tags:
* - Sync
* responses:
* 200:
* description: The status after the pass.
*/
router.post(
"/link/errors/retry",
...guard,
async (req: Request, res: Response) => {
await clearRecordErrors(userOf(req));
await getSyncEngine().syncNow();
res.json(await statusBody());
},
);
/**
* @openapi
* /sync/link/session:
* get:
* summary: The linked server and session, for connecting to it directly
* description: Desktop only. Used when a host is set to connect through the server rather than from this device.
* tags:
* - Sync
* responses:
* 200:
* description: The server address and session token, or nulls when not linked.
*/
router.get("/link/session", ...guard, async (_req: Request, res: Response) => {
const link = await getLink();
if (!link?.sessionToken || link.status === "signed_out") {
return res.json({ serverUrl: null, token: null });
}
res.json({ serverUrl: link.serverUrl, token: link.sessionToken });
});
export default router;
+544
View File
@@ -0,0 +1,544 @@
/**
* What the sync provider plugin gets from core through ctx.sync.host: the
* replica store, the change feed, the linked server registry, sessions and
* a few helpers. Core stays neutral; the plugin decides what to do with it.
*/
import fs from "fs";
import os from "os";
import path from "path";
import * as tar from "tar";
import { and, eq, isNotNull } from "drizzle-orm";
import type { NextFunction, Request, Response } from "express";
import type { PluginSyncHost, SyncRow } from "@termix-ssh/plugin-sdk/backend";
import { resolvePluginDesktopMode } from "@termix-ssh/plugin-sdk/manifest";
import { AuthManager } from "../utils/auth-manager.js";
import { getLocalVersion } from "../utils/app-version.js";
import { syncLogger } from "../utils/logger.js";
import { sendCoreAlert } from "../notify/core-notify.js";
import { getPluginRuntime } from "../plugins/index.js";
import {
getEntity,
listEntities,
onEntitiesChanged,
type RegisteredSyncEntity,
} from "../plugins/sync-registry.js";
import {
createCurrentHostRepository,
createCurrentHostResolutionRepository,
createCurrentPluginPermissionGrantRepository,
createCurrentPluginRepository,
createCurrentRepositoryContext,
createCurrentSessionRepository,
createCurrentSettingsRepository,
createCurrentUserRepository,
} from "../database/repositories/factory.js";
import { resolveBrandingSettings } from "../database/routes/branding-settings.js";
import { hosts, sshCredentials } from "../database/db/schema.js";
import {
createResolvers,
deleteStoredRow,
listStoredRows,
loadWireRow,
loadWireRows,
regenerateSyncIds,
writeWireRow,
type Resolvers,
} from "./store.js";
import {
clearRecordErrors,
deleteConflict,
deleteRecordsForUser,
getConflict,
listChanges,
listConflicts,
listRecordErrors,
listRecords,
putRecord,
recordKey,
saveConflict,
setRecordError,
} from "./records.js";
import { orderSelfReferences } from "./wire.js";
import { onLocalWrite } from "./server/write-listeners.js";
import {
createLink,
deleteLink,
getLink,
listLinks,
updateLink,
} from "./client/link-store.js";
import {
createAccount,
getActiveUserId,
listAccounts,
removeAccount,
setActiveUserId,
} from "./accounts.js";
import {
normalizeServerUrl,
RemoteError,
remoteFetch,
remoteJson,
} from "./client/http.js";
import { applyElectronProxyConfig } from "./client/electron-proxy.js";
import { lastRemotePlugins, mirrorPlugins } from "./client/plugins.js";
import { defaultDeviceName } from "./client/device.js";
function entityOf(type: string): RegisteredSyncEntity {
const entity = getEntity(type);
if (!entity) throw new Error(`Unknown sync entity "${type}"`);
return entity;
}
function info(entity: RegisteredSyncEntity) {
return {
type: entity.type,
owner: entity.owner,
order: entity.order,
readOnly: !!entity.readOnly,
covers: entity.covers,
};
}
/** Deletes every row that syncs, keeping local-only ones. */
async function wipeSyncedData(userId: string): Promise<void> {
const entities = [...listEntities()].reverse();
for (const entity of entities) {
if (entity.readOnly) continue;
try {
if (entity.load) {
for (const row of await entity.load(userId)) {
await entity.erase?.(userId, String(row.syncId));
}
continue;
}
if (!entity.table || entity.singleton) continue;
for (const row of await listStoredRows(entity, userId)) {
if (entity.shouldSync && entity.shouldSync(row) === false) continue;
if (typeof row.syncId !== "string") continue;
await deleteStoredRow(entity, userId, row.syncId);
}
} catch (error) {
syncLogger.warn("Could not clear synced data", {
operation: "sync_wipe",
entityType: entity.type,
error: error instanceof Error ? error.message : String(error),
});
}
}
}
/** Removes read-only copies of what other users shared with the account. */
async function removeSharedCopies(userId: string): Promise<void> {
const db = createCurrentRepositoryContext().drizzle;
const sharedHosts = await db
.select({ id: hosts.id })
.from(hosts)
.where(and(eq(hosts.userId, userId), isNotNull(hosts.sharedSource)));
const { deleteOwnedHost } = await import("../hosts/delete-host.js");
for (const host of sharedHosts) await deleteOwnedHost(userId, host.id);
const sharedCredentials = await db
.select({ id: sshCredentials.id })
.from(sshCredentials)
.where(
and(
eq(sshCredentials.userId, userId),
isNotNull(sshCredentials.sharedSource),
),
);
const { deleteOwnedCredential } =
await import("../hosts/delete-credential.js");
for (const credential of sharedCredentials) {
await deleteOwnedCredential(userId, credential.id);
}
}
async function ensureCoreEntities(): Promise<void> {
const { registerCoreSyncEntities } = await import("./entities.js");
registerCoreSyncEntities();
}
async function counts(userId: string): Promise<Record<string, number>> {
await ensureCoreEntities();
const result: Record<string, number> = {};
for (const entity of listEntities()) {
if (entity.readOnly) continue;
try {
if (entity.load) {
result[entity.type] = (await entity.load(userId)).length;
continue;
}
if (!entity.table) continue;
result[entity.type] = (await listStoredRows(entity, userId)).filter(
(row) => !entity.shouldSync || entity.shouldSync(row) !== false,
).length;
} catch {
result[entity.type] = 0;
}
}
return result;
}
async function listPluginsForDesktop() {
const records = new Map(
(await createCurrentPluginRepository().listAll()).map((record) => [
record.id,
record,
]),
);
const grants = createCurrentPluginPermissionGrantRepository();
const plugins = [];
for (const plugin of getPluginRuntime().loader.list()) {
plugins.push({
id: plugin.id,
name: plugin.manifest.name,
version: plugin.manifest.version,
source: plugin.source,
// A bundled plugin updated from a signed .tmxplug can be mirrored.
artifact: !!plugin.artifact,
enabled: records.get(plugin.id)?.state === "enabled",
active: plugin.state === "active",
desktop: resolvePluginDesktopMode(plugin.manifest),
syncEntities: plugin.manifest.contributes?.syncEntities ?? [],
granted: (await grants.listByPlugin(plugin.id)).map(
(grant) => grant.capability,
),
});
}
const { customRegistryKeys } = await import("../plugins/registries.js");
return { plugins, registryKeys: await customRegistryKeys() };
}
async function sendPackage(
pluginId: string,
target: unknown,
): Promise<boolean> {
const res = target as Response;
const plugin = getPluginRuntime()
.loader.list()
.find((candidate) => candidate.id === pluginId);
if (!plugin || (plugin.source !== "user" && !plugin.artifact)) return false;
const artifact = plugin.artifact ?? "";
res.setHeader("Content-Type", "application/gzip");
if (fs.existsSync(artifact)) {
const sig = `${artifact}.sig`;
if (fs.existsSync(sig)) {
res.setHeader(
"X-Termix-Signature",
Buffer.from(await fs.promises.readFile(sig, "utf8")).toString("base64"),
);
}
fs.createReadStream(artifact).pipe(res);
return true;
}
const temp = path.join(
os.tmpdir(),
`termix-plugin-${pluginId}-${Date.now()}.tmxplug`,
);
await tar.c({ gzip: true, cwd: plugin.dir, file: temp }, ["."]);
const stream = fs.createReadStream(temp);
stream.on("close", () => void fs.promises.rm(temp, { force: true }));
stream.pipe(res);
return true;
}
export function createSyncHost(): PluginSyncHost {
const authManager = AuthManager.getInstance();
return {
embedded: process.env.ELECTRON_EMBEDDED === "true",
info: {
appName: async () => {
try {
return (
await resolveBrandingSettings(createCurrentSettingsRepository())
).appName;
} catch {
return "Termix";
}
},
version: () => getLocalVersion(),
},
entities: {
list: () => listEntities().map(info),
get: (type) => {
const entity = getEntity(type);
return entity ? info(entity) : undefined;
},
ensureCore: () => ensureCoreEntities(),
onChanged: (listener) => onEntitiesChanged(listener),
onLocalWrite: (listener) => onLocalWrite(listener),
},
replica: {
resolvers: (userId, onMissing) => createResolvers(userId, onMissing),
loadRows: async (type, userId, resolvers) => {
const result = await loadWireRows(
entityOf(type),
userId,
resolvers as Resolvers,
);
return { rows: result.rows, failed: result.failed };
},
loadRow: (type, userId, syncId, resolvers) =>
loadWireRow(entityOf(type), userId, syncId, resolvers as Resolvers),
writeRow: async (type, userId, wire, resolvers) => {
await writeWireRow(
entityOf(type),
userId,
wire,
resolvers as Resolvers,
);
},
deleteRow: async (type, userId, syncId) => {
await deleteStoredRow(entityOf(type), userId, syncId);
},
orderSelfReferences: (type, rows) =>
orderSelfReferences(entityOf(type), rows as SyncRow[]),
counts: (userId) => counts(userId),
wipe: (userId) => wipeSyncedData(userId),
regenerateSyncIds: async (userId) => {
for (const entity of listEntities()) {
if (!entity.readOnly) await regenerateSyncIds(entity, userId);
}
},
removeSharedCopies: (userId) => removeSharedCopies(userId),
records: {
list: (userId) => listRecords(userId) as never,
put: (userId, type, syncId, values) =>
putRecord(userId, type, syncId, values),
setError: (userId, type, syncId, reason, hash) =>
setRecordError(userId, type, syncId, reason, hash),
clearErrors: (userId) => clearRecordErrors(userId),
listErrors: (userId) => listRecordErrors(userId) as never,
deleteForUser: (userId) => deleteRecordsForUser(userId),
key: (type, syncId) => recordKey(type, syncId),
},
conflicts: {
save: (userId, type, syncId, row, revision) =>
saveConflict(userId, type, syncId, row, revision),
list: (userId) => listConflicts(userId) as never,
get: (userId, id) => getConflict(userId, id) as never,
delete: (userId, id) => deleteConflict(userId, id),
},
},
feed: {
withUserLock: async (userId, run) => {
const { withUserLock } = await import("./server/feed.js");
return withUserLock(userId, run);
},
changes: async (userId, cursor, limit, types) => {
const { withUserLock, reconcileUser } =
await import("./server/feed.js");
const page = await withUserLock(userId, async () => {
const { wire } = await reconcileUser(userId);
const records = await listChanges(userId, cursor, limit + 1, types);
return { wire, records };
});
const hasMore = page.records.length > limit;
const records = [];
for (const record of page.records.slice(0, limit)) {
if (!getEntity(record.entityType)) continue;
const row = record.deleted
? null
: (page.wire.get(recordKey(record.entityType, record.syncId)) ??
null);
// A live record with no row failed to serialize; it comes again once
// it changes, rather than arriving as a delete.
if (!record.deleted && !row) continue;
records.push({
seq: record.seq,
entityType: record.entityType,
syncId: record.syncId,
revision: record.revision,
deleted: !!record.deleted,
row,
});
}
const slice = page.records.slice(0, limit);
return {
records,
hasMore,
nextCursor: slice.length ? slice[slice.length - 1].seq : cursor,
entityTypes: listEntities().map((entity) => ({
type: entity.type,
owner: entity.owner,
order: entity.order,
readOnly: !!entity.readOnly,
})),
} as never;
},
parsePushOps: async (body) => {
const { parsePushOps } = await import("./server/push.js");
return parsePushOps(body) as never;
},
applyPush: async (userId, ops) => {
const { withUserLock } = await import("./server/feed.js");
const { applyPush } = await import("./server/push.js");
return withUserLock(userId, () =>
applyPush(userId, ops as never),
) as never;
},
listen: async (userId, res) => {
const { addListener } = await import("./server/feed.js");
return addListener(userId, res as Response);
},
},
links: {
list: () => listLinks() as never,
get: (userId) => getLink(userId) as never,
create: (link) => createLink(link) as never,
update: (patch, userId) => updateLink(patch, userId) as never,
remove: (userId) => deleteLink(userId),
},
accounts: {
list: () => listAccounts(),
create: () => createAccount(),
setActive: (userId) => setActiveUserId(userId),
activeId: () => getActiveUserId(),
remove: (userId) => removeAccount(userId),
},
http: {
json: (target, pathname, request) =>
remoteJson(target, pathname, request),
fetch: ((target, pathname, request) =>
remoteFetch(target, pathname, request)) as never,
normalizeServerUrl,
isRemoteError: ((error: unknown) =>
error instanceof RemoteError) as never,
},
sessions: {
issueDesktop: (userId, options) =>
authManager.generateJWTToken(userId, {
expiresIn: `${options.days}d`,
deviceType: "desktop",
deviceInfo: options.deviceInfo,
}),
revoke: async (sessionId) => {
await authManager.revokeSession(sessionId);
},
find: async (sessionId) => {
const session =
await createCurrentSessionRepository().findById(sessionId);
return session
? { expiresAt: session.expiresAt, deviceInfo: session.deviceInfo }
: null;
},
findUser: async (userId) => {
const user = await createCurrentUserRepository().findById(userId);
return user ? { id: user.id, username: user.username } : null;
},
verify: async (token) => {
const payload = await authManager.verifyJWTToken(token);
return payload
? { userId: payload.userId, sessionId: payload.sessionId }
: null;
},
setCookie: (req, res, value, options) => {
(res as Response).cookie(
options?.name ?? "jwt",
value,
authManager.getSecureCookieOptions(
req as Request,
(options?.days ?? 30) * 24 * 60 * 60 * 1000,
),
);
},
clearCookie: (req, res, name) => {
(res as Response).clearCookie(
name ?? "jwt",
authManager.getClearCookieOptions(req as Request),
);
},
middleware: () => authManager.createAuthMiddleware(),
desktopOnly: () => (_req: Request, res: Response, next: NextFunction) => {
if (process.env.ELECTRON_EMBEDDED !== "true") {
res.status(404).json({ error: "Not found" });
return;
}
next();
},
},
hosts: {
findForSync: async (userId, syncId) => {
const hostId =
await createCurrentHostResolutionRepository().findHostIdBySyncId(
syncId,
);
if (hostId === null) return null;
const { PermissionManager } =
await import("../utils/permission-manager.js");
const access = await PermissionManager.getInstance().canAccessHost(
userId,
hostId,
"connect",
);
if (!access.hasAccess) return null;
const row = await createCurrentHostRepository().findById(hostId);
return { id: hostId, name: row?.name ?? row?.ip ?? null };
},
statusBySyncId: async (userId) => {
const { hostStatus } =
await import("../plugins/host-status-provider.js");
const { PermissionManager } =
await import("../utils/permission-manager.js");
const statuses = await hostStatus.statusesFor(userId, null);
const entries = [...statuses.entries()];
const allowed =
await PermissionManager.getInstance().filterAccessibleHostIds(
userId,
entries.map(([id]) => id),
);
const hostRepository = createCurrentHostRepository();
const result: Record<string, unknown> = {};
for (const [id, entry] of entries) {
if (!allowed.has(id)) continue;
const row = await hostRepository.findById(id);
if (row?.syncId) result[row.syncId] = entry;
}
return result;
},
},
plugins: {
listForDesktop: listPluginsForDesktop as never,
sendPackage,
mirror: (link) => mirrorPlugins(link as never),
lastRemote: () => lastRemotePlugins() as never,
localNames: () => {
const names = new Map<string, string>();
for (const plugin of getPluginRuntime().loader.list()) {
names.set(plugin.id, plugin.manifest.name);
}
return names;
},
},
desktop: {
applyProxy: (link) => applyElectronProxyConfig(link as never),
deviceName: defaultDeviceName,
},
alerts: {
send: (alert) =>
sendCoreAlert({
title: alert.title,
body: alert.body,
severity: alert.severity,
category: alert.category,
dedupeKey: alert.dedupeKey,
audience: "admins",
link: alert.link,
} as never),
},
};
}
-2
View File
@@ -1,2 +0,0 @@
/** Bumped when a desktop and server can no longer understand each other. */
export const SYNC_PROTOCOL_VERSION = 1;
+80
View File
@@ -0,0 +1,80 @@
/**
* The sync provider: a plugin that answers /sync/v2 and /sync/link through
* ctx.sync.provide. Core only forwards. With no provider the paths answer 404,
* so a server without it looks like one that never had sync.
*/
import type { NextFunction, Request, RequestHandler, Response } from "express";
import type { PluginSyncProvider } from "@termix-ssh/plugin-sdk/backend";
import { syncLogger } from "../utils/logger.js";
interface Entry {
pluginId: string;
provider: PluginSyncProvider;
}
const providers = new Map<symbol, Entry>();
export function provideSync(
pluginId: string,
provider: PluginSyncProvider,
): () => void {
const key = Symbol(pluginId);
providers.set(key, { pluginId, provider });
return () => {
providers.delete(key);
};
}
/** Test seam. */
export function clearSyncProviders(): void {
providers.clear();
}
export function hasSyncProvider(): boolean {
return providers.size > 0;
}
/** Sends the request to the first provider that does not pass. */
export const syncForwarder: RequestHandler = (
req: Request,
res: Response,
next: NextFunction,
) => {
const entries = [...providers.values()];
const run = (index: number): void => {
const entry = entries[index];
if (!entry) {
next();
return;
}
const pass = (error?: unknown): void => {
if (error) next(error);
else run(index + 1);
};
try {
const result = entry.provider.handle(req, res, pass);
if (result && typeof result.catch === "function") {
result.catch((error: unknown) => fail(entry, error, res, next));
}
} catch (error) {
fail(entry, error, res, next);
}
};
run(0);
};
function fail(
entry: Entry,
error: unknown,
res: Response,
next: NextFunction,
): void {
syncLogger.warn("Sync provider failed", {
operation: "sync_provider",
pluginId: entry.pluginId,
error: error instanceof Error ? error.message : String(error),
});
if (res.headersSent) next(error);
else res.status(500).json({ error: "Sync is not available right now" });
}
+2 -8
View File
@@ -1,14 +1,8 @@
import type { NextFunction, Request, Response } from "express";
import { markChanged } from "./feed.js";
import { notifyLocalWrite } from "./write-listeners.js";
const READS = new Set(["GET", "HEAD", "OPTIONS"]);
const writeListeners = new Set<() => void>();
/** Called after every successful write request, for a linked desktop's engine. */
export function onLocalWrite(listener: () => void): () => void {
writeListeners.add(listener);
return () => writeListeners.delete(listener);
}
/**
* Any successful write may change what a linked desktop should have, so it
@@ -23,7 +17,7 @@ export function syncChangeWatcher(
res.on("finish", () => {
if (res.statusCode >= 400) return;
markChanged();
for (const listener of writeListeners) listener();
notifyLocalWrite();
});
}
next();
+29
View File
@@ -11,6 +11,12 @@ import type { SyncRow } from "@termix-ssh/plugin-sdk/backend";
import { getEntity } from "../../plugins/sync-registry.js";
import { PermissionManager } from "../../utils/permission-manager.js";
import { syncLogger } from "../../utils/logger.js";
import {
hasHostLocks,
hostLock,
manualCreateBlockedBy,
} from "../../hosts/locks.js";
import { createCurrentHostResolutionRepository } from "../../database/repositories/factory.js";
import {
createResolvers,
deleteStoredRow,
@@ -48,6 +54,22 @@ export type PushResult =
export const MAX_PUSH_OPS = 500;
/** A host a plugin manages can't be changed from a desktop either. */
async function hostPushLocked(
userId: string,
syncId: string,
exists: boolean,
): Promise<boolean> {
if (!hasHostLocks()) return false;
if (!exists) return manualCreateBlockedBy() !== null;
const hostId =
await createCurrentHostResolutionRepository().findHostIdBySyncIdForUser(
syncId,
userId,
);
return hostId !== null && hostId !== undefined && hostLock(hostId) !== null;
}
function isPushOp(value: unknown): value is PushOp {
if (!value || typeof value !== "object") return false;
const op = value as Record<string, unknown>;
@@ -166,6 +188,13 @@ export async function applyPush(
results.set(key, { ...base, status: "rejected", reason: "permission" });
continue;
}
if (
op.entityType === "hosts" &&
(await hostPushLocked(userId, op.syncId, exists))
) {
results.set(key, { ...base, status: "rejected", reason: "locked" });
continue;
}
try {
if (op.op === "delete") {
-604
View File
@@ -1,604 +0,0 @@
import express, { type Request, type Response } from "express";
import fs from "fs";
import path from "path";
import os from "os";
import * as tar from "tar";
import type { AuthenticatedRequest } from "../../../types/index.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { syncLogger } from "../../utils/logger.js";
import { getLocalVersion } from "../../utils/app-version.js";
import { getEntity, listEntities } from "../../plugins/sync-registry.js";
import { getPluginRuntime } from "../../plugins/index.js";
import {
createCurrentSessionRepository,
createCurrentSettingsRepository,
createCurrentUserRepository,
createCurrentPluginRepository,
createCurrentPluginPermissionGrantRepository,
} from "../../database/repositories/factory.js";
import { resolveBrandingSettings } from "../../database/routes/branding-settings.js";
import { registerCoreSyncEntities } from "../entities.js";
import { listChanges, recordKey } from "../records.js";
import { addListener, reconcileUser, withUserLock } from "./feed.js";
import { applyPush, parsePushOps } from "./push.js";
import { SYNC_PROTOCOL_VERSION } from "../protocol.js";
const SESSION_DAYS = 365;
const REFRESH_WITHIN_MS = 60 * 24 * 60 * 60 * 1000;
const MAX_PAGE = 1000;
const router = express.Router();
const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
registerCoreSyncEntities();
function userOf(req: Request): string {
return (req as AuthenticatedRequest).userId;
}
/** A non-negative integer query value, the fallback when absent, null when malformed. */
function queryInt(value: unknown, fallback: number): number | null {
if (value === undefined) return fallback;
const parsed = Number(value);
return typeof value === "string" &&
/^\d+$/.test(value) &&
Number.isSafeInteger(parsed)
? parsed
: null;
}
/**
* @openapi
* /sync/v2/info:
* get:
* summary: Describe this server to a desktop that wants to link to it
* description: Public. Lets the desktop app confirm it reached Termix and not a proxy's login page, and that the sync protocol matches.
* tags:
* - Sync
* responses:
* 200:
* description: Server name, version and sync protocol version.
*/
router.get("/v2/info", async (_req: Request, res: Response) => {
let name = "Termix";
try {
name = (await resolveBrandingSettings(createCurrentSettingsRepository()))
.appName;
} catch {
// The default name is fine.
}
res.setHeader("X-Termix-Sync", String(SYNC_PROTOCOL_VERSION));
res.json({
termix: true,
name,
version: getLocalVersion(),
protocol: SYNC_PROTOCOL_VERSION,
});
});
/**
* @openapi
* /sync/v2/link:
* post:
* summary: Link a desktop to this account
* description: Trades the short login session the desktop just signed in with for a long-lived desktop session, which shows in the account's sessions list and is revoked like any other. The login session is ended.
* tags:
* - Sync
* requestBody:
* content:
* application/json:
* schema:
* type: object
* properties:
* deviceName:
* type: string
* responses:
* 200:
* description: The desktop session token and the account it belongs to.
* 401:
* description: Not signed in.
* 403:
* description: API keys cannot link a desktop.
*/
router.post(
"/v2/link",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = userOf(req);
if ((req as AuthenticatedRequest).apiKeyId) {
return res.status(403).json({ error: "API keys cannot link a desktop" });
}
try {
const user = await createCurrentUserRepository().findById(userId);
if (!user) return res.status(401).json({ error: "User not found" });
const rawName =
typeof req.body?.deviceName === "string" ? req.body.deviceName : "";
const deviceName = rawName.trim().slice(0, 100) || "Termix Desktop";
const token = await authManager.generateJWTToken(userId, {
expiresIn: `${SESSION_DAYS}d`,
deviceType: "desktop",
deviceInfo: `${deviceName} (sync)`,
});
const loginSession = (req as AuthenticatedRequest).sessionId;
if (loginSession) await authManager.revokeSession(loginSession);
res.json({
token,
user: { id: user.id, username: user.username },
server: { version: getLocalVersion() },
});
} catch (error) {
syncLogger.error("Failed to link a desktop", error, {
operation: "sync_link",
userId,
});
res.status(500).json({ error: "Failed to link" });
}
},
);
/**
* @openapi
* /sync/v2/session/refresh:
* post:
* summary: Keep a linked desktop's session from expiring
* description: Returns a fresh desktop session when the current one ends within 60 days, and ends the old one. Otherwise returns null and the desktop keeps its token.
* tags:
* - Sync
* responses:
* 200:
* description: The new token, or null.
* 401:
* description: Session revoked or expired.
*/
router.post(
"/v2/session/refresh",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = userOf(req);
const sessionId = (req as AuthenticatedRequest).sessionId;
try {
if (!sessionId) return res.json({ token: null });
const session =
await createCurrentSessionRepository().findById(sessionId);
if (!session) return res.status(401).json({ error: "Session not found" });
const remaining = new Date(session.expiresAt).getTime() - Date.now();
if (remaining > REFRESH_WITHIN_MS) return res.json({ token: null });
const token = await authManager.generateJWTToken(userId, {
expiresIn: `${SESSION_DAYS}d`,
deviceType: "desktop",
deviceInfo: session.deviceInfo,
});
await authManager.revokeSession(sessionId);
res.json({ token });
} catch (error) {
syncLogger.error("Failed to refresh a desktop session", error, {
operation: "sync_session_refresh",
userId,
});
res.status(500).json({ error: "Failed to refresh session" });
}
},
);
/**
* @openapi
* /sync/v2/unlink:
* post:
* summary: End a linked desktop's session
* description: Called by a desktop when it unlinks, so its session stops working here right away.
* tags:
* - Sync
* responses:
* 200:
* description: The session was ended.
*/
router.post(
"/v2/unlink",
authenticateJWT,
async (req: Request, res: Response) => {
const sessionId = (req as AuthenticatedRequest).sessionId;
try {
if (sessionId) await authManager.revokeSession(sessionId);
res.json({ success: true });
} catch (error) {
syncLogger.error("Failed to end a desktop session", error, {
operation: "sync_unlink",
});
res.status(500).json({ error: "Failed to unlink" });
}
},
);
/**
* @openapi
* /sync/v2/changes:
* get:
* summary: Pull what changed for this account after a cursor
* description: Returns records in change order. Each has the entity type, syncId, revision, whether it is deleted, and the row when it is not. Records of entity types this server does not currently run are skipped but still move the cursor.
* tags:
* - Sync
* parameters:
* - in: query
* name: cursor
* schema:
* type: integer
* - in: query
* name: limit
* schema:
* type: integer
* - in: query
* name: types
* description: Comma-separated entity types, to backfill only those.
* schema:
* type: string
* responses:
* 200:
* description: A page of changes, the next cursor and whether more remain.
* 400:
* description: The cursor or limit is not a non-negative integer.
* 500:
* description: Failed to read changes.
*/
router.get(
"/v2/changes",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = userOf(req);
const cursor = queryInt(req.query.cursor, 0);
const pageSize = queryInt(req.query.limit, MAX_PAGE);
if (cursor === null || pageSize === null) {
return res.status(400).json({ error: "Invalid cursor or limit" });
}
const limit = Math.min(MAX_PAGE, Math.max(1, pageSize));
const types =
typeof req.query.types === "string" && req.query.types
? req.query.types.split(",").filter(Boolean)
: undefined;
try {
const page = await withUserLock(userId, async () => {
const { wire } = await reconcileUser(userId);
const records = await listChanges(userId, cursor, limit + 1, types);
return { wire, records };
});
const hasMore = page.records.length > limit;
const records = page.records.slice(0, limit);
const changes = [];
for (const record of records) {
if (!getEntity(record.entityType)) continue;
const row = record.deleted
? null
: (page.wire.get(recordKey(record.entityType, record.syncId)) ??
null);
// A live record with no row failed to serialize; it comes again once it
// changes, rather than arriving as a delete.
if (!record.deleted && !row) continue;
changes.push({
seq: record.seq,
entityType: record.entityType,
syncId: record.syncId,
revision: record.revision,
deleted: !!record.deleted,
row,
});
}
res.json({
changes,
nextCursor: records.length ? records[records.length - 1].seq : cursor,
hasMore,
entityTypes: listEntities().map((entity) => ({
type: entity.type,
owner: entity.owner,
order: entity.order,
readOnly: !!entity.readOnly,
})),
});
} catch (error) {
syncLogger.error("Failed to read sync changes", error, {
operation: "sync_changes",
userId,
});
res.status(500).json({ error: "Failed to read changes" });
}
},
);
/**
* @openapi
* /sync/v2/push:
* post:
* summary: Push a desktop's changes
* description: Each op carries the revision the desktop last saw. Ops are applied in dependency order and each gets its own result, ok with the new revision, conflict with the server's version, or rejected with a reason.
* tags:
* - Sync
* requestBody:
* content:
* application/json:
* schema:
* type: object
* properties:
* ops:
* type: array
* items:
* type: object
* responses:
* 200:
* description: One result per op, in the order sent.
* 400:
* description: Malformed ops, or more than 500.
* 500:
* description: Failed to apply changes.
*/
router.post(
"/v2/push",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = userOf(req);
const ops = parsePushOps(req.body);
if (!ops) return res.status(400).json({ error: "Invalid ops" });
try {
const results = await withUserLock(userId, () => applyPush(userId, ops));
res.json({ results });
} catch (error) {
syncLogger.error("Failed to apply pushed changes", error, {
operation: "sync_push",
userId,
});
res.status(500).json({ error: "Failed to apply changes" });
}
},
);
/**
* @openapi
* /sync/v2/events:
* get:
* summary: Stream a notice whenever this account's data changes
* description: Server-sent events. A "change" event means there is something new to pull. Comments are sent every 25 seconds to keep proxies from closing the stream.
* tags:
* - Sync
* responses:
* 200:
* description: An event stream.
*/
router.get("/v2/events", authenticateJWT, (req: Request, res: Response) => {
const userId = userOf(req);
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache, no-transform");
res.setHeader("Connection", "keep-alive");
res.setHeader("X-Accel-Buffering", "no");
res.flushHeaders?.();
res.write(`event: ready\ndata: {}\n\n`);
const remove = addListener(userId, res);
req.on("close", remove);
});
/**
* @openapi
* /sync/v2/hosts/{syncId}:
* get:
* summary: This server's id for a host, by its sync id
* description: For a linked desktop that asks the server to act on a host (a tunnel through it), which needs the server's own id. Only for hosts the caller can connect to.
* tags:
* - Sync
* parameters:
* - in: path
* name: syncId
* required: true
* schema:
* type: string
* responses:
* 200:
* description: The host's id and name.
* 404:
* description: No such host the caller can connect to.
*/
router.get(
"/v2/hosts/:syncId",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = userOf(req);
try {
const { createCurrentHostResolutionRepository } =
await import("../../database/repositories/factory.js");
const hostId =
await createCurrentHostResolutionRepository().findHostIdBySyncId(
String(req.params.syncId),
);
if (hostId === null) return res.status(404).json({ error: "Not found" });
const { PermissionManager } =
await import("../../utils/permission-manager.js");
const access = await PermissionManager.getInstance().canAccessHost(
userId,
hostId,
"connect",
);
if (!access.hasAccess)
return res.status(404).json({ error: "Not found" });
const { createCurrentHostRepository } =
await import("../../database/repositories/factory.js");
const row = await createCurrentHostRepository().findById(hostId);
res.json({ id: hostId, name: row?.name ?? row?.ip ?? null });
} catch (error) {
syncLogger.error("Failed to look up a host by sync id", error, {
operation: "sync_host_lookup",
});
res.status(500).json({ error: "Failed to look up host" });
}
},
);
/**
* @openapi
* /sync/v2/host-status:
* get:
* summary: Status of the hosts this account can see, keyed by sync id
* description: For a linked desktop whose hosts connect through this server, so it can show their status against its own copies.
* tags:
* - Sync
* responses:
* 200:
* description: Status entries by host sync id.
*/
router.get(
"/v2/host-status",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = userOf(req);
try {
const { hostStatusService } =
await import("../../hosts/status/host-status-service.js");
const { PermissionManager } =
await import("../../utils/permission-manager.js");
const { createCurrentHostRepository } =
await import("../../database/repositories/factory.js");
const statuses = await hostStatusService.statusesFor(userId, null);
const entries = [...statuses.entries()];
const allowed =
await PermissionManager.getInstance().filterAccessibleHostIds(
userId,
entries.map(([id]) => id),
);
const hostRepository = createCurrentHostRepository();
const result: Record<string, unknown> = {};
for (const [id, entry] of entries) {
if (!allowed.has(id)) continue;
const row = await hostRepository.findById(id);
if (row?.syncId) result[row.syncId] = entry;
}
res.json(result);
} catch (error) {
syncLogger.error("Failed to read host status for sync", error, {
operation: "sync_host_status",
});
res.status(500).json({ error: "Failed to read host status" });
}
},
);
async function listPluginsForDesktop() {
return getPluginRuntime().loader.list();
}
/**
* @openapi
* /sync/v2/plugins:
* get:
* summary: The features this server runs, for a linked desktop to mirror
* description: Every installed plugin with its version, where it came from, whether it is on, how a desktop should treat it and which sync entities it owns. registryKeys lists the signing keys of the custom registries an admin added, so plugins from them load on the desktop.
* tags:
* - Sync
* responses:
* 200:
* description: The plugin list.
*/
router.get(
"/v2/plugins",
authenticateJWT,
async (_req: Request, res: Response) => {
try {
const records = new Map(
(await createCurrentPluginRepository().listAll()).map((record) => [
record.id,
record,
]),
);
const grants = createCurrentPluginPermissionGrantRepository();
const plugins = [];
for (const plugin of await listPluginsForDesktop()) {
plugins.push({
id: plugin.id,
name: plugin.manifest.name,
version: plugin.manifest.version,
source: plugin.source,
// A bundled plugin updated from a signed .tmxplug can be mirrored.
artifact: !!plugin.artifact,
enabled: records.get(plugin.id)?.state === "enabled",
active: plugin.state === "active",
desktop: plugin.manifest.desktop ?? "mirror",
syncEntities: plugin.manifest.contributes?.syncEntities ?? [],
granted: (await grants.listByPlugin(plugin.id)).map(
(grant) => grant.capability,
),
});
}
const { customRegistryKeys } =
await import("../../plugins/registries.js");
res.json({ plugins, registryKeys: await customRegistryKeys() });
} catch (error) {
syncLogger.error("Failed to list plugins for sync", error, {
operation: "sync_plugins",
});
res.status(500).json({ error: "Failed to list plugins" });
}
},
);
/**
* @openapi
* /sync/v2/plugins/{id}/package:
* get:
* summary: Download an installed plugin so a linked desktop can install it
* description: Only for plugins installed on this server, or bundled ones replaced by a signed update. Sends the original .tmxplug when there is one, with its signature in the X-Termix-Signature header, otherwise a fresh archive of the plugin folder.
* tags:
* - Sync
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* responses:
* 200:
* description: The plugin archive.
* 404:
* description: No installed plugin with that id.
*/
router.get(
"/v2/plugins/:id/package",
authenticateJWT,
async (req: Request, res: Response) => {
const pluginId = String(req.params.id);
try {
const plugin = (await listPluginsForDesktop()).find(
(candidate) => candidate.id === pluginId,
);
if (!plugin || (plugin.source !== "user" && !plugin.artifact)) {
return res.status(404).json({ error: "Plugin not found" });
}
const artifact = plugin.artifact ?? "";
res.setHeader("Content-Type", "application/gzip");
if (fs.existsSync(artifact)) {
const sig = `${artifact}.sig`;
if (fs.existsSync(sig)) {
res.setHeader(
"X-Termix-Signature",
Buffer.from(await fs.promises.readFile(sig, "utf8")).toString(
"base64",
),
);
}
return fs.createReadStream(artifact).pipe(res);
}
const temp = path.join(
os.tmpdir(),
`termix-plugin-${pluginId}-${Date.now()}.tmxplug`,
);
await tar.c({ gzip: true, cwd: plugin.dir, file: temp }, ["."]);
const stream = fs.createReadStream(temp);
stream.on("close", () => void fs.promises.rm(temp, { force: true }));
stream.pipe(res);
} catch (error) {
syncLogger.error("Failed to package a plugin for sync", error, {
operation: "sync_plugin_package",
pluginId,
});
if (!res.headersSent) {
res.status(500).json({ error: "Failed to package plugin" });
}
}
},
);
export default router;
@@ -0,0 +1,11 @@
const writeListeners = new Set<() => void>();
/** Called after every successful write request, for a linked desktop's engine. */
export function onLocalWrite(listener: () => void): () => void {
writeListeners.add(listener);
return () => writeListeners.delete(listener);
}
export function notifyLocalWrite(): void {
for (const listener of writeListeners) listener();
}
+40
View File
@@ -0,0 +1,40 @@
/**
* On a desktop linked to a server, the account it is signed in to there.
* That account is who the user is; the local profile only mirrors it.
*/
export async function describeDesktopLink(userId: string) {
if (process.env.ELECTRON_EMBEDDED !== "true") return null;
try {
const { getLink } = await import("./client/link-store.js");
const link = await getLink(userId);
if (!link) return null;
return {
serverUrl: link.serverUrl,
serverName: link.serverName,
username: link.account?.username ?? link.remoteUsername,
isAdmin: !!link.account?.isAdmin,
roles: link.account?.roles ?? [],
permissions: link.account?.permissions ?? [],
status: link.status,
};
} catch {
return null;
}
}
/**
* Who the app shows. On a desktop linked to a server that is the account it
* is signed in to there, not the local user that holds the copy of its data.
*/
export async function shownIdentity(user: {
id: string;
username: string;
isAdmin: boolean | null;
}) {
const linked = await describeDesktopLink(user.id);
return {
linked,
username: linked?.username || user.username,
isAdmin: linked ? linked.isAdmin : !!user.isAdmin,
};
}
@@ -97,6 +97,8 @@ const { getPasswordLoginStatus } = await import("../../auth/core-auth.js");
const { registerLoginMethod, registerSecondFactor } =
await import("../../auth/registry.js");
const { loginRateLimiter } = await import("../../utils/login-rate-limiter.js");
const { provideAuthPolicy, clearAuthPolicies, SECOND_FACTOR_SETUP } =
await import("../../auth/policy.js");
const PASSWORD = "correct-horse";
// Stands in for the totp plugin's factor: core only sees the registration.
@@ -137,6 +139,7 @@ beforeEach(() => {
delete process.env.ALLOW_PASSWORD_LOGIN;
disposeTotp?.();
registerTotpFixture();
clearAuthPolicies();
});
async function passwordLogin(body: Record<string, unknown>, headers = {}) {
@@ -891,3 +894,219 @@ describe("lockout guard", () => {
});
});
});
describe("login policy", () => {
function enrolTotp() {
h.state.factors.push({ userId: "u1", pluginId: "totp", factorId: "totp" });
}
function trustedLogin(token: string) {
const req = fakeRequest({
body: { username: "alice", password: PASSWORD },
cookies: { [TRUST_DEVICE_COOKIE]: token },
});
return runLogin(
req as never,
{ kind: "user", userId: "u1" },
{ methodId: "password", rememberMe: true },
);
}
it("hands the provider the user's factors and the device", async () => {
addUser();
enrolTotp();
const evaluate = vi.fn(() => ({}));
provideAuthPolicy("policy", { evaluate });
await passwordLogin({ username: "alice", password: PASSWORD });
expect(evaluate).toHaveBeenCalledWith({
userId: "u1",
methodId: "password",
external: false,
deviceType: "web",
trustedDevice: false,
mfaSatisfied: false,
secondFactors: [{ id: "totp", pluginId: "totp", enrolled: true }],
});
});
it("turns remember me off and caps the session", async () => {
addUser();
provideAuthPolicy("policy", {
evaluate: () => ({ allowRememberDevice: false, maxSessionSeconds: 600 }),
});
const res = await passwordLogin({
username: "alice",
password: PASSWORD,
rememberMe: true,
});
expect(h.manager.generateJWTToken).toHaveBeenCalledWith(
"u1",
expect.objectContaining({ rememberMe: false, maxAgeSeconds: 600 }),
);
expect((res.cookies[0].options as { maxAge: number }).maxAge).toBe(
600 * 1000,
);
});
it("stores the idle timeout on the session", async () => {
addUser();
provideAuthPolicy("policy", {
evaluate: () => ({ idleTimeoutSeconds: 900 }),
});
provideAuthPolicy("other", {
evaluate: () => ({ idleTimeoutSeconds: 300 }),
});
await passwordLogin({ username: "alice", password: PASSWORD });
expect(h.manager.generateJWTToken).toHaveBeenCalledWith(
"u1",
expect.objectContaining({ idleTimeoutSeconds: 300 }),
);
});
it("ignores a remembered device when remember me is off", async () => {
addUser();
enrolTotp();
const token = "c".repeat(64);
h.state.trusted.add(`u1:${hashTrustToken(token)}`);
provideAuthPolicy("policy", {
evaluate: () => ({ allowRememberDevice: false }),
});
const result = await trustedLogin(token);
expect(result.kind).toBe("second-factor");
});
it("asks for the factor on a remembered device with always, not with new-device", async () => {
addUser();
enrolTotp();
const token = "d".repeat(64);
h.state.trusted.add(`u1:${hashTrustToken(token)}`);
const remove = provideAuthPolicy("policy", {
evaluate: () => ({ secondFactor: "always" }),
});
expect((await trustedLogin(token)).kind).toBe("second-factor");
remove();
provideAuthPolicy("policy", {
evaluate: () => ({ secondFactor: "new-device" }),
});
expect((await trustedLogin(token)).kind).toBe("session");
});
it("gives a user without a factor a setup only session", async () => {
addUser();
provideAuthPolicy("policy", {
evaluate: () => ({ secondFactor: "always" }),
});
const res = await passwordLogin({ username: "alice", password: PASSWORD });
expect(h.manager.generateJWTToken).toHaveBeenCalledWith(
"u1",
expect.objectContaining({ restriction: SECOND_FACTOR_SETUP }),
);
expect(res.body).toMatchObject({ restriction: SECOND_FACTOR_SETUP });
});
it("lets a verified passkey satisfy a required factor", async () => {
addUser();
provideAuthPolicy("policy", {
evaluate: () => ({ secondFactor: "always" }),
});
const result = await runLogin(
fakeRequest() as never,
{ kind: "user", userId: "u1", mfaSatisfied: true },
{ methodId: "passkey", rememberMe: false },
);
expect(result.kind).toBe("session");
expect(h.manager.generateJWTToken).toHaveBeenCalledWith(
"u1",
expect.objectContaining({ restriction: null }),
);
});
it("runs factors for SSO logins when the policy requires one", async () => {
const dispose = registerLoginMethod({
id: "oidc",
pluginId: "sso",
labelKey: "loginWithSso",
kind: "redirect",
external: true,
});
try {
addUser({ passwordHash: "" });
enrolTotp();
h.state.identities.push({
id: 1,
userId: "u1",
providerId: "3",
subject: "sub-1",
email: null,
});
provideAuthPolicy("policy", {
evaluate: (input) => (input.external ? { secondFactor: "always" } : {}),
});
const result = await runLogin(
fakeRequest() as never,
{
kind: "external",
provider: "3",
subject: "sub-1",
email: null,
name: "Alice",
} as never,
{ methodId: "oidc", rememberMe: false },
);
expect(result.kind).toBe("second-factor");
} finally {
dispose();
}
});
it("keeps the policy for the factor step: no trusted device, same cap", async () => {
addUser();
enrolTotp();
provideAuthPolicy("policy", {
evaluate: () => ({ allowRememberDevice: false, maxSessionSeconds: 1200 }),
});
const first = await passwordLogin({
username: "alice",
password: PASSWORD,
rememberMe: true,
});
const res = fakeResponse();
await verifySecondFactorAndRespond(
fakeRequest({
body: {
temp_token: (first.body as { temp_token: string }).temp_token,
totp_code: TOTP_CODE,
rememberMe: true,
},
}) as never,
res as never,
"totp",
);
expect(res.statusCode).toBe(200);
expect(res.cookies.map((c) => c.name)).toEqual(["jwt"]);
expect(h.state.trustedAdded).toEqual([]);
expect(h.manager.generateJWTToken).toHaveBeenLastCalledWith(
"u1",
expect.objectContaining({ rememberMe: false, maxAgeSeconds: 1200 }),
);
});
it("refuses the login when a provider throws", async () => {
addUser();
provideAuthPolicy("policy", {
evaluate: () => {
throw new Error("boom");
},
});
await expect(
runLogin(
fakeRequest() as never,
{ kind: "user", userId: "u1" },
{ methodId: "password", rememberMe: false },
),
).rejects.toMatchObject({ status: 503, code: "auth_policy_unavailable" });
expect(h.state.audits).toEqual([]);
});
});
+100
View File
@@ -0,0 +1,100 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../../utils/logger.js", () => ({
authLogger: { warn: vi.fn() },
}));
const {
clearAuthPolicies,
evaluateAuthPolicy,
hasAuthPolicies,
provideAuthPolicy,
rememberDeviceAllowed,
} = await import("../../auth/policy.js");
const input = {
userId: "u1",
methodId: "password",
external: false,
deviceType: "web" as const,
trustedDevice: false,
mfaSatisfied: false,
secondFactors: [],
};
beforeEach(() => clearAuthPolicies());
describe("auth policy", () => {
it("changes nothing without a provider", async () => {
expect(hasAuthPolicies()).toBe(false);
expect(await evaluateAuthPolicy(input)).toEqual({
allowRememberDevice: true,
maxSessionSeconds: null,
idleTimeoutSeconds: null,
secondFactor: null,
});
expect(rememberDeviceAllowed()).toBe(true);
});
it("takes the strictest answer from every provider", async () => {
provideAuthPolicy("a", {
evaluate: () => ({
maxSessionSeconds: 3600,
idleTimeoutSeconds: 900,
secondFactor: "new-device",
}),
});
provideAuthPolicy("b", {
evaluate: async () => ({
allowRememberDevice: false,
maxSessionSeconds: 7200,
idleTimeoutSeconds: 300,
secondFactor: "always",
}),
allowRememberDevice: () => false,
});
expect(await evaluateAuthPolicy(input)).toEqual({
allowRememberDevice: false,
maxSessionSeconds: 3600,
idleTimeoutSeconds: 300,
secondFactor: "always",
});
expect(rememberDeviceAllowed()).toBe(false);
});
it("ignores zero and negative limits", async () => {
provideAuthPolicy("a", {
evaluate: () => ({ maxSessionSeconds: 0, idleTimeoutSeconds: -5 }),
});
expect(await evaluateAuthPolicy(input)).toMatchObject({
maxSessionSeconds: null,
idleTimeoutSeconds: null,
});
});
it("refuses the login when a provider throws, until it is removed", async () => {
const remove = provideAuthPolicy("a", {
evaluate: () => {
throw new Error("boom");
},
});
await expect(evaluateAuthPolicy(input)).rejects.toMatchObject({
status: 503,
code: "auth_policy_unavailable",
});
remove();
await expect(evaluateAuthPolicy(input)).resolves.toMatchObject({
secondFactor: null,
});
});
it("hides remember me when the hint throws", () => {
provideAuthPolicy("a", {
evaluate: () => ({}),
allowRememberDevice: () => {
throw new Error("boom");
},
});
expect(rememberDeviceAllowed()).toBe(false);
});
});
@@ -1,99 +0,0 @@
import { sql } from "drizzle-orm";
import { afterEach, describe, expect, it } from "vitest";
import { TestSqliteDatabase } from "./test-support.js";
import { RecentActivityRepository } from "../../../database/repositories/recent-activity-repository.js";
describe("RecentActivityRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(
onWrite?: () => void | Promise<void>,
): Promise<{
repository: RecentActivityRepository;
}> {
adapter = new TestSqliteDatabase();
const context = await adapter.connect();
await adapter.exec(`
INSERT INTO users (id, username, password_hash)
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type)
VALUES (1, 'user-1', 'one', '10.0.0.1', 22, 'root', 'password'), (2, 'user-1', 'two', '10.0.0.1', 22, 'root', 'password'), (3, 'user-2', 'other', '10.0.0.1', 22, 'root', 'password');
INSERT INTO recent_activity (id, user_id, type, host_id, host_name, timestamp)
VALUES
(1, 'user-1', 'connect', 1, 'one', '2026-06-26T00:00:00.000Z'),
(2, 'user-1', 'disconnect', 2, 'two', '2026-06-26T00:01:00.000Z'),
(3, 'user-2', 'connect', 3, 'other', '2026-06-26T00:02:00.000Z');
`);
return {
repository: new RecentActivityRepository(context, onWrite),
};
}
it("lists, creates, and trims recent activity", async () => {
let writeCount = 0;
const { repository } = await createRepository(() => {
writeCount += 1;
});
expect(
(await repository.listByUserId("user-1", 2)).map((row) => row.id),
).toEqual([2, 1]);
const created = await repository.create({
userId: "user-1",
type: "terminal",
hostId: 1,
hostName: "one",
timestamp: "2026-06-26T00:03:00.000Z",
});
expect(created).toMatchObject({
userId: "user-1",
type: "terminal",
hostId: 1,
});
expect(await repository.trimUserActivity("user-1", 2)).toBe(1);
expect(
await adapter!.query(
sql`SELECT id FROM recent_activity WHERE user_id = 'user-1' ORDER BY timestamp DESC`,
),
).toEqual([{ id: created.id }, { id: 2 }]);
expect(writeCount).toBe(2);
});
it("deletes activity by user id and only triggers writes for changed rows", async () => {
let writeCount = 0;
const { repository: repo } = await createRepository(() => {
writeCount += 1;
});
expect(await repo.deleteByUserId("missing")).toBe(0);
expect(writeCount).toBe(0);
expect(await repo.deleteByUserId("user-1")).toBe(2);
expect(writeCount).toBe(1);
expect(await repo.deleteByUserId("user-1")).toBe(0);
expect(writeCount).toBe(1);
});
it("deletes activity by host id and host id list", async () => {
let writeCount = 0;
const { repository: repo } = await createRepository(() => {
writeCount += 1;
});
expect(await repo.deleteByHostId(1)).toBe(1);
expect(await repo.deleteByHostId(1)).toBe(0);
expect(await repo.deleteByHostIds([])).toBe(0);
expect(await repo.deleteByHostIds([2, 3])).toBe(2);
expect(writeCount).toBe(2);
});
});
@@ -1,5 +1,4 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import sharp from "sharp";
import { beforeEach, describe, expect, it, vi } from "vitest";
import express, {
type NextFunction,
type Request,
@@ -19,15 +18,6 @@ vi.mock("../../../utils/logger.js", () => ({
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentSettingsRepository: () => ({
get: async (key: string) => state.settings[key] ?? null,
set: async (key: string, value: string) => {
state.settings[key] = value;
},
setMany: async (writes: Array<{ key: string; value: string }>) => {
for (const write of writes) state.settings[write.key] = write.value;
},
delete: async (key: string) => {
delete state.settings[key];
},
}),
}));
@@ -42,25 +32,17 @@ interface RouteLayer {
};
}
/** Runs a route's full middleware chain (e.g. requireAdmin, then the handler). */
function handlersFor(
router: express.Router,
pathName: string,
method: string,
): RequestHandler[] {
const layer = (router as unknown as { stack: RouteLayer[] }).stack.find(
(candidate) =>
candidate.route?.path === pathName && candidate.route.methods[method],
);
if (!layer) throw new Error(`Route not registered: ${method} ${pathName}`);
return layer.route!.stack.map((entry) => entry.handle);
function routesOf(router: express.Router) {
return (router as unknown as { stack: RouteLayer[] }).stack
.filter((layer) => layer.route)
.map((layer) => ({
path: layer.route!.path,
methods: Object.keys(layer.route!.methods),
handlers: layer.route!.stack.map((entry) => entry.handle),
}));
}
async function invokeChain(
handlers: RequestHandler[],
body: unknown = {},
): Promise<{ statusCode: number; body: unknown }> {
const req = { body, headers: {} } as unknown as Request;
async function call(handler: RequestHandler) {
const result = { statusCode: 200, body: null as unknown };
const res = {
status(code: number) {
@@ -72,67 +54,34 @@ async function invokeChain(
return this;
},
} as unknown as Response;
for (const handler of handlers) {
let proceeded = false;
await handler(req, res, ((err?: unknown) => {
if (err) throw err;
proceeded = true;
}) as NextFunction);
if (!proceeded) return result;
}
await handler(
{ body: {}, headers: {} } as Request,
res,
(() => {}) as NextFunction,
);
return result;
}
/** Mimics the real requireAdmin: 401 with no session, 403 for a non-admin. */
function fakeRequireAdmin(
outcome: "unauthenticated" | "not-admin" | "admin",
): RequestHandler {
return (_req, res, next) => {
if (outcome === "unauthenticated") {
res.status(401).json({ error: "Missing authentication token" });
return;
}
if (outcome === "not-admin") {
res.status(403).json({ error: "Not authorized" });
return;
}
// Must return (not just call) next() so invokeChain's await actually
// waits for the downstream async handler to finish before returning.
return next();
};
}
function buildRouter(requireAdmin: RequestHandler) {
const router = express.Router();
registerBrandingRoutes(router, requireAdmin);
return router;
}
let pngDataUrl: string;
beforeAll(async () => {
const buffer = await sharp({
create: { width: 4, height: 4, channels: 3, background: "#112233" },
})
.png()
.toBuffer();
pngDataUrl = `data:image/png;base64,${buffer.toString("base64")}`;
});
beforeEach(() => {
state.settings = {};
});
describe("GET /users/branding", () => {
it("succeeds with no auth at all, since the route registers no auth handler", async () => {
// requireAdmin is only ever wired onto PATCH below; GET's own handler chain
// has exactly one entry, proving the public route never runs an admin gate.
const router = buildRouter(fakeRequireAdmin("unauthenticated"));
const handlers = handlersFor(router, "/branding", "get");
expect(handlers).toHaveLength(1);
it("is the only branding route and has no auth handler", () => {
const router = express.Router();
registerBrandingRoutes(router);
const routes = routesOf(router);
const response = await invokeChain(handlers);
expect(routes).toHaveLength(1);
expect(routes[0].path).toBe("/branding");
expect(routes[0].methods).toEqual(["get"]);
expect(routes[0].handlers).toHaveLength(1);
});
it("answers defaults when nothing is set", async () => {
const router = express.Router();
registerBrandingRoutes(router);
const response = await call(routesOf(router)[0].handlers[0]);
expect(response.statusCode).toBe(200);
expect(response.body).toEqual({
@@ -142,126 +91,19 @@ describe("GET /users/branding", () => {
});
});
it("reflects persisted branding values", async () => {
state.settings.branding_app_name = "Acme Ops";
state.settings.branding_tagline = "Ship it";
state.settings.branding_logo = pngDataUrl;
const router = buildRouter(fakeRequireAdmin("admin"));
it("keeps serving the stored branding of an older install", async () => {
state.settings = {
branding_app_name: "Acme",
branding_tagline: "Ops",
};
const router = express.Router();
registerBrandingRoutes(router);
const response = await call(routesOf(router)[0].handlers[0]);
const response = await invokeChain(handlersFor(router, "/branding", "get"));
expect(response.statusCode).toBe(200);
expect(response.body).toEqual({
appName: "Acme Ops",
tagline: "Ship it",
logo: pngDataUrl,
appName: "Acme",
tagline: "Ops",
logo: null,
});
});
});
describe("PATCH /users/branding", () => {
it("rejects an unauthenticated request and persists nothing", async () => {
const router = buildRouter(fakeRequireAdmin("unauthenticated"));
const response = await invokeChain(
handlersFor(router, "/branding", "patch"),
{ appName: "Hijacked" },
);
expect(response.statusCode).toBe(401);
expect(state.settings).toEqual({});
});
it("rejects a non-admin request and persists nothing", async () => {
const router = buildRouter(fakeRequireAdmin("not-admin"));
const response = await invokeChain(
handlersFor(router, "/branding", "patch"),
{ appName: "Hijacked" },
);
expect(response.statusCode).toBe(403);
expect(state.settings).toEqual({});
});
it("persists only the allowed fields for an admin request", async () => {
const router = buildRouter(fakeRequireAdmin("admin"));
const response = await invokeChain(
handlersFor(router, "/branding", "patch"),
{ appName: "Acme Ops", tagline: "Ship it", logo: pngDataUrl },
);
expect(response.statusCode).toBe(200);
expect(state.settings.branding_app_name).toBe("Acme Ops");
expect(state.settings.branding_tagline).toBe("Ship it");
expect(state.settings.branding_logo).toMatch(/^data:image\/png;base64,/);
expect(response.body).toMatchObject({
appName: "Acme Ops",
tagline: "Ship it",
});
});
it("rejects unknown fields without writing anything", async () => {
const router = buildRouter(fakeRequireAdmin("admin"));
const response = await invokeChain(
handlersFor(router, "/branding", "patch"),
{ theme: "dark" },
);
expect(response.statusCode).toBe(400);
expect(response.body).toMatchObject({
code: "BRANDING_SETTINGS_UNKNOWN_FIELD",
});
expect(state.settings).toEqual({});
});
it("rejects an app name over the length limit", async () => {
const router = buildRouter(fakeRequireAdmin("admin"));
const response = await invokeChain(
handlersFor(router, "/branding", "patch"),
{ appName: "x".repeat(61) },
);
expect(response.statusCode).toBe(400);
expect(response.body).toMatchObject({
code: "BRANDING_SETTINGS_INVALID",
field: "appName",
});
expect(state.settings).toEqual({});
});
it("rejects malformed/non-raster logo data without persisting it", async () => {
const router = buildRouter(fakeRequireAdmin("admin"));
const response = await invokeChain(
handlersFor(router, "/branding", "patch"),
{
logo: `data:image/png;base64,${Buffer.from("not an image").toString("base64")}`,
},
);
expect(response.statusCode).toBe(400);
expect(response.body).toMatchObject({
code: "BRANDING_SETTINGS_INVALID",
field: "logo",
});
expect(state.settings).toEqual({});
});
it("clears the logo when patched with null", async () => {
state.settings.branding_logo = pngDataUrl;
const router = buildRouter(fakeRequireAdmin("admin"));
const response = await invokeChain(
handlersFor(router, "/branding", "patch"),
{ logo: null },
);
expect(response.statusCode).toBe(200);
expect(state.settings.branding_logo).toBeUndefined();
expect(response.body).toMatchObject({ logo: null });
});
});
@@ -1,149 +1,17 @@
import { beforeAll, describe, expect, it, vi } from "vitest";
import sharp from "sharp";
import { describe, expect, it, vi } from "vitest";
vi.mock("../../../utils/logger.js", () => ({
authLogger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() },
databaseLogger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() },
}));
const {
BRANDING_DEFAULT_APP_NAME,
BRANDING_LOGO_MAX_BYTES,
parseBrandingAppName,
parseBrandingLogoDataUrl,
parseBrandingTagline,
resolveBrandingSettings,
} = await import("../../../database/routes/branding-settings.js");
const { BRANDING_DEFAULT_APP_NAME, resolveBrandingSettings } =
await import("../../../database/routes/branding-settings.js");
function settingSource(values: Record<string, string>) {
return { get: async (key: string) => values[key] ?? null };
}
async function toDataUrl(
buffer: Buffer,
mime: "image/png" | "image/jpeg" | "image/webp",
): Promise<string> {
return `data:${mime};base64,${buffer.toString("base64")}`;
}
let pngBuffer: Buffer;
let jpegBuffer: Buffer;
let webpBuffer: Buffer;
let oversizedPngBuffer: Buffer;
beforeAll(async () => {
const image = sharp({
create: { width: 4, height: 4, channels: 3, background: "#336699" },
});
pngBuffer = await image.clone().png().toBuffer();
jpegBuffer = await image.clone().jpeg().toBuffer();
webpBuffer = await image.clone().webp().toBuffer();
oversizedPngBuffer = await sharp({
create: { width: 1200, height: 1200, channels: 4, background: "#abcdef" },
})
.png({ compressionLevel: 0 })
.toBuffer();
});
describe("parseBrandingAppName", () => {
it("trims and accepts a normal name", () => {
expect(parseBrandingAppName(" My Company ")).toBe("My Company");
});
it("falls back to the default when cleared", () => {
expect(parseBrandingAppName("")).toBe(BRANDING_DEFAULT_APP_NAME);
expect(parseBrandingAppName(" ")).toBe(BRANDING_DEFAULT_APP_NAME);
});
it("rejects names over 60 characters", () => {
expect(parseBrandingAppName("x".repeat(61))).toBeNull();
});
it("rejects non-string values", () => {
expect(parseBrandingAppName(42)).toBeNull();
expect(parseBrandingAppName(null)).toBeNull();
});
});
describe("parseBrandingTagline", () => {
it("trims and accepts a normal tagline", () => {
expect(parseBrandingTagline(" Ship faster ")).toBe("Ship faster");
});
it("accepts an empty tagline (means: use the default)", () => {
expect(parseBrandingTagline("")).toBe("");
expect(parseBrandingTagline(" ")).toBe("");
});
it("rejects taglines over 160 characters", () => {
expect(parseBrandingTagline("x".repeat(161))).toBeNull();
});
it("rejects non-string values", () => {
expect(parseBrandingTagline(42)).toBeNull();
});
});
describe("parseBrandingLogoDataUrl", () => {
it("accepts a PNG data URL and re-encodes it to PNG", async () => {
const result = await parseBrandingLogoDataUrl(
await toDataUrl(pngBuffer, "image/png"),
);
expect(result).not.toBeNull();
expect(result!.startsWith("data:image/png;base64,")).toBe(true);
const decoded = Buffer.from(result!.split(",")[1]!, "base64");
expect(decoded.subarray(1, 4).toString()).toBe("PNG");
});
it("accepts a JPEG data URL and normalizes it to PNG", async () => {
const result = await parseBrandingLogoDataUrl(
await toDataUrl(jpegBuffer, "image/jpeg"),
);
expect(result).not.toBeNull();
expect(result!.startsWith("data:image/png;base64,")).toBe(true);
});
it("accepts a WEBP data URL and normalizes it to PNG", async () => {
const result = await parseBrandingLogoDataUrl(
await toDataUrl(webpBuffer, "image/webp"),
);
expect(result).not.toBeNull();
expect(result!.startsWith("data:image/png;base64,")).toBe(true);
});
it("rejects a non-image/SVG mime prefix outright", async () => {
const svg = Buffer.from('<svg onload="alert(1)"></svg>', "utf8").toString(
"base64",
);
const result = await parseBrandingLogoDataUrl(
`data:image/svg+xml;base64,${svg}`,
);
expect(result).toBeNull();
});
it("rejects a value that isn't a data URL at all", async () => {
expect(await parseBrandingLogoDataUrl("not-a-data-url")).toBeNull();
expect(await parseBrandingLogoDataUrl(undefined)).toBeNull();
});
it("rejects a spoofed data URL whose bytes aren't a real image", async () => {
const fake = Buffer.from("definitely not an image").toString("base64");
const result = await parseBrandingLogoDataUrl(
`data:image/png;base64,${fake}`,
);
expect(result).toBeNull();
});
it("rejects an image that normalizes over the size cap", async () => {
// Sanity check the fixture is actually large enough to exceed the cap.
expect(oversizedPngBuffer.length).toBeGreaterThan(BRANDING_LOGO_MAX_BYTES);
const result = await parseBrandingLogoDataUrl(
await toDataUrl(oversizedPngBuffer, "image/png"),
);
expect(result).toBeNull();
});
});
describe("resolveBrandingSettings", () => {
it("returns built-in defaults when nothing is persisted", async () => {
const settings = await resolveBrandingSettings(settingSource({}));
@@ -169,3 +37,34 @@ describe("resolveBrandingSettings", () => {
});
});
});
describe("branding provider", () => {
it("uses a plugin's branding provider and falls back when it throws", async () => {
const registry = await import("../../../plugins/registry.js");
const provider = async () => ({
appName: " Acme ",
tagline: "Hello",
logo: "data:image/png;base64,AAAA",
});
registry.provide("demo.brandingProvider", provider);
try {
await expect(resolveBrandingSettings(settingSource({}))).resolves.toEqual(
{
appName: "Acme",
tagline: "Hello",
logo: "data:image/png;base64,AAAA",
},
);
registry.revoke("demo.brandingProvider");
registry.provide("demo.brandingProvider", async () => {
throw new Error("broken");
});
await expect(
resolveBrandingSettings(settingSource({ branding_app_name: "Stored" })),
).resolves.toMatchObject({ appName: "Stored" });
} finally {
registry.clearRegistry();
}
});
});
@@ -52,12 +52,6 @@ vi.mock("../../../utils/logger.js", () => {
};
return { authLogger: log, sshLogger: log, databaseLogger: log };
});
vi.mock("../../../database/routes/credential-key-routes.js", () => ({
registerCredentialKeyRoutes: () => {},
}));
vi.mock("../../../database/routes/credential-deploy-routes.js", () => ({
registerCredentialDeployRoutes: () => {},
}));
vi.mock("../../../database/routes/credential-bulk-routes.js", () => ({
registerCredentialBulkRoutes: () => {},
}));
Loaded 100 of 577 files, more files were not shown because too many files have changed in this diff. Show more