mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
feat: harden plugin api for separate repos, signed updates and generic external login
This commit is contained in:
1 parent
fb9c168b6b
commit
80944301dd
310 files changed
+6577
-4927
No files matched your search
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Whether a user signs in through an external login (SSO, LDAP or any login
|
||||
* plugin) rather than only a password. Read from the is_oidc column, which
|
||||
* keeps its 2.8 name until 3.0.0 replaces it with user_external_identities.
|
||||
*/
|
||||
export function isExternalAccount(user: {
|
||||
isOidc?: boolean | number | null;
|
||||
}): boolean {
|
||||
return !!user.isOidc;
|
||||
}
|
||||
@@ -177,7 +177,7 @@ async function unlockUser(
|
||||
|
||||
if (identity.kind === "external" || user.isOidc) {
|
||||
try {
|
||||
await authManager.authenticateOIDCUser(user.id, deviceType);
|
||||
await authManager.authenticateExternalUser(user.id, deviceType);
|
||||
} catch (error) {
|
||||
authLogger.error("Failed to set up external user encryption", error, {
|
||||
operation: "external_user_encryption_setup_failed",
|
||||
|
||||
@@ -167,7 +167,9 @@ async function findLinkedUser(
|
||||
// not scoped to a provider, so only an SSO account that has no link yet,
|
||||
// from the same provider when both say which, can be claimed this way.
|
||||
if (identity.legacyIdentifier) {
|
||||
const legacy = await users.findByOidcIdentifier(identity.legacyIdentifier);
|
||||
const legacy = await users.findByExternalIdentifier(
|
||||
identity.legacyIdentifier,
|
||||
);
|
||||
if (legacy && (await mayClaimLegacyAccount(legacy, identity))) {
|
||||
await identities.linkIdentity({
|
||||
userId: legacy.id,
|
||||
@@ -258,7 +260,7 @@ async function createUser(
|
||||
deviceType === "desktop" || deviceType === "mobile"
|
||||
? THIRTY_DAYS_MS
|
||||
: ONE_DAY_MS;
|
||||
await AuthManager.getInstance().registerOIDCUser(id, sessionDurationMs);
|
||||
await AuthManager.getInstance().registerExternalUser(id, sessionDurationMs);
|
||||
} catch (encryptionError) {
|
||||
await users.delete(id);
|
||||
authLogger.error(
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* they all behave the same way.
|
||||
*/
|
||||
|
||||
import { isExternalAccount } from "./external-account.js";
|
||||
import type { Request, Response } from "express";
|
||||
import { AuthManager } from "../utils/auth-manager.js";
|
||||
import { authLogger } from "../utils/logger.js";
|
||||
@@ -139,6 +140,8 @@ export async function issueSession(
|
||||
is_admin: !!user.isAdmin,
|
||||
username: user.username,
|
||||
userId: user.id,
|
||||
is_external: isExternalAccount(user),
|
||||
// 2.8 name, kept until 3.0.0.
|
||||
is_oidc: !!user.isOidc,
|
||||
// Any second factor; the name is what 2.8 clients read.
|
||||
totp_enabled: await createCurrentUserAuthRepository().hasSecondFactor(
|
||||
|
||||
@@ -625,7 +625,7 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
|
||||
|
||||
if (!DataCrypto.getUserDataKey(userId)) {
|
||||
if (isOidcUser) {
|
||||
const oidcUnlocked = await authManager.authenticateOIDCUser(
|
||||
const oidcUnlocked = await authManager.authenticateExternalUser(
|
||||
userId,
|
||||
deviceInfo.type,
|
||||
);
|
||||
@@ -1058,7 +1058,7 @@ app.post(
|
||||
|
||||
if (!DataCrypto.getUserDataKey(userId)) {
|
||||
if (isOidcUser) {
|
||||
const oidcUnlocked = await authManager.authenticateOIDCUser(
|
||||
const oidcUnlocked = await authManager.authenticateExternalUser(
|
||||
userId,
|
||||
deviceInfo.type,
|
||||
);
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { pluginInstallCounts } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { rowsAffected } from "./mutation-result.js";
|
||||
import { insertReturning, updateReturning } from "./returning.js";
|
||||
|
||||
export type PluginInstallCountRecord = typeof pluginInstallCounts.$inferSelect;
|
||||
|
||||
export interface PluginInstallCountUpsertInput {
|
||||
pluginId: string;
|
||||
registryId: string;
|
||||
count: number;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
export class PluginInstallCountRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async listByPlugin(pluginId: string): Promise<PluginInstallCountRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(pluginInstallCounts)
|
||||
.where(eq(pluginInstallCounts.pluginId, pluginId));
|
||||
}
|
||||
|
||||
async findByPluginAndRegistry(
|
||||
pluginId: string,
|
||||
registryId: string,
|
||||
): Promise<PluginInstallCountRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(pluginInstallCounts)
|
||||
.where(
|
||||
and(
|
||||
eq(pluginInstallCounts.pluginId, pluginId),
|
||||
eq(pluginInstallCounts.registryId, registryId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async upsert(
|
||||
input: PluginInstallCountUpsertInput,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<PluginInstallCountRecord> {
|
||||
const existing = await this.findByPluginAndRegistry(
|
||||
input.pluginId,
|
||||
input.registryId,
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
const [updated] = await updateReturning(
|
||||
this.context,
|
||||
pluginInstallCounts,
|
||||
{
|
||||
count: input.count,
|
||||
source: input.source ?? existing.source,
|
||||
updatedAt: now,
|
||||
},
|
||||
eq(pluginInstallCounts.id, existing.id),
|
||||
);
|
||||
|
||||
await this.afterWrite();
|
||||
return updated;
|
||||
}
|
||||
|
||||
const [created] = await insertReturning(this.context, pluginInstallCounts, {
|
||||
pluginId: input.pluginId,
|
||||
registryId: input.registryId,
|
||||
count: input.count,
|
||||
source: input.source ?? "aggregate-telemetry",
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
await this.afterWrite();
|
||||
return created;
|
||||
}
|
||||
|
||||
async deleteByPlugin(pluginId: string): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(pluginInstallCounts)
|
||||
.where(eq(pluginInstallCounts.pluginId, pluginId));
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { pluginRegistries } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { rowsAffected } from "./mutation-result.js";
|
||||
import {
|
||||
deleteReturning,
|
||||
insertReturning,
|
||||
updateReturning,
|
||||
} from "./returning.js";
|
||||
|
||||
export type PluginRegistryRecord = typeof pluginRegistries.$inferSelect;
|
||||
|
||||
export interface PluginRegistryCreateInput {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
kind?: string;
|
||||
enabled?: boolean;
|
||||
signingKey?: string | null;
|
||||
}
|
||||
|
||||
export interface PluginRegistryUpdateInput {
|
||||
name?: string;
|
||||
url?: string;
|
||||
kind?: string;
|
||||
enabled?: boolean;
|
||||
signingKey?: string | null;
|
||||
lastCheckedAt?: string | null;
|
||||
lastIndexHash?: string | null;
|
||||
}
|
||||
|
||||
export class PluginRegistryRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async listAll(): Promise<PluginRegistryRecord[]> {
|
||||
return this.context.drizzle.select().from(pluginRegistries);
|
||||
}
|
||||
|
||||
async findById(registryId: string): Promise<PluginRegistryRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(pluginRegistries)
|
||||
.where(eq(pluginRegistries.id, registryId))
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async create(
|
||||
input: PluginRegistryCreateInput,
|
||||
): Promise<PluginRegistryRecord> {
|
||||
const [created] = await insertReturning(this.context, pluginRegistries, {
|
||||
id: input.id,
|
||||
name: input.name,
|
||||
url: input.url,
|
||||
kind: input.kind ?? "community",
|
||||
enabled: input.enabled ?? true,
|
||||
signingKey: input.signingKey ?? null,
|
||||
});
|
||||
|
||||
await this.afterWrite();
|
||||
return created;
|
||||
}
|
||||
|
||||
async update(
|
||||
registryId: string,
|
||||
input: PluginRegistryUpdateInput,
|
||||
): Promise<PluginRegistryRecord | null> {
|
||||
const existing = await this.findById(registryId);
|
||||
if (!existing) return null;
|
||||
|
||||
const [updated] = await updateReturning(
|
||||
this.context,
|
||||
pluginRegistries,
|
||||
{
|
||||
name: input.name ?? existing.name,
|
||||
url: input.url ?? existing.url,
|
||||
kind: input.kind ?? existing.kind,
|
||||
enabled: input.enabled ?? existing.enabled,
|
||||
signingKey:
|
||||
input.signingKey === undefined
|
||||
? existing.signingKey
|
||||
: input.signingKey,
|
||||
lastCheckedAt:
|
||||
input.lastCheckedAt === undefined
|
||||
? existing.lastCheckedAt
|
||||
: input.lastCheckedAt,
|
||||
lastIndexHash:
|
||||
input.lastIndexHash === undefined
|
||||
? existing.lastIndexHash
|
||||
: input.lastIndexHash,
|
||||
},
|
||||
eq(pluginRegistries.id, registryId),
|
||||
);
|
||||
|
||||
await this.afterWrite();
|
||||
return updated ?? null;
|
||||
}
|
||||
|
||||
async delete(registryId: string): Promise<boolean> {
|
||||
const deleted = await deleteReturning(
|
||||
this.context,
|
||||
pluginRegistries,
|
||||
eq(pluginRegistries.id, registryId),
|
||||
);
|
||||
|
||||
if (deleted.length > 0) {
|
||||
await this.afterWrite();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async deleteAll(): Promise<number> {
|
||||
const all = await this.listAll();
|
||||
const result = await this.context.drizzle.delete(pluginRegistries);
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
return all.length;
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -79,7 +79,7 @@ export class UserRepository {
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async findByOidcIdentifier(
|
||||
async findByExternalIdentifier(
|
||||
oidcIdentifier: string,
|
||||
): Promise<UserRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import type { Request, Response, Router } from "express";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
import { isOidcTokenCallback } from "../../utils/oidc-desktop-callback.js";
|
||||
import { isExternalTokenCallback } from "../../utils/external-login-callback.js";
|
||||
import { ensureCoreLoginProviders } from "../../auth/core-auth.js";
|
||||
import {
|
||||
readPendingLogin,
|
||||
@@ -32,7 +32,7 @@ export interface PublicLoginMethod {
|
||||
kind: "redirect" | "form";
|
||||
labelKey: string;
|
||||
icon?: string;
|
||||
instances: Array<{ id: string; label: string }>;
|
||||
instances: Array<{ id: string; label: string; autoStart?: boolean }>;
|
||||
}
|
||||
|
||||
async function enabledInstances(method: LoginMethod) {
|
||||
@@ -93,12 +93,18 @@ export async function listPublicLoginMethods(): Promise<PublicLoginMethod[]> {
|
||||
ensureCoreLoginProviders();
|
||||
const methods: PublicLoginMethod[] = [];
|
||||
for (const method of listLoginMethods()) {
|
||||
let instances: Array<{ id: string; label: string }> = [];
|
||||
let instances: PublicLoginMethod["instances"] = [];
|
||||
if (method.describe) {
|
||||
try {
|
||||
instances = (await method.describe())
|
||||
.filter((instance) => instance.enabled)
|
||||
.map((instance) => ({ id: instance.id, label: instance.label }));
|
||||
.map((instance) => ({
|
||||
id: instance.id,
|
||||
label: instance.label,
|
||||
...(instance.autoStart && method.kind === "redirect"
|
||||
? { autoStart: true }
|
||||
: {}),
|
||||
}));
|
||||
} catch (error) {
|
||||
authLogger.warn("Login method could not describe itself", {
|
||||
operation: "login_method_describe",
|
||||
@@ -178,7 +184,7 @@ export async function handleRedirectCallback(
|
||||
res,
|
||||
identity,
|
||||
{ methodId: method.id, rememberMe: !!identity.rememberMe },
|
||||
isOidcTokenCallback,
|
||||
isExternalTokenCallback,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2303,19 +2303,6 @@ registerHostNetworkRoutes(router, {
|
||||
|
||||
export default router;
|
||||
|
||||
/**
|
||||
* A config field arriving as a JSON string (an import, or a client that
|
||||
* stringified it) must still reach storage as an object. Malformed input
|
||||
* becomes null rather than throwing inside a host save.
|
||||
*/
|
||||
function safeParseJson(raw: string): unknown {
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Seconds between status checks, or null to follow the global setting. */
|
||||
function normalizeStatusInterval(value: unknown): number | null {
|
||||
const seconds = Number(value);
|
||||
|
||||
@@ -283,9 +283,8 @@ router.get(
|
||||
* get:
|
||||
* summary: Get all active backend sessions for the current user
|
||||
* description: >
|
||||
* Returns the live terminal sessions the caller owns. Sessions other
|
||||
* users shared with the caller come from the session sharing plugin.
|
||||
* Used by the Active Connections panel and tab restore logic.
|
||||
* Returns the live terminal sessions the caller owns. Used by the
|
||||
* Active Connections panel and tab restore logic.
|
||||
* tags:
|
||||
* - Open Tabs
|
||||
* responses:
|
||||
@@ -310,17 +309,6 @@ router.get(
|
||||
* type: boolean
|
||||
* createdAt:
|
||||
* type: number
|
||||
* isOwnSession:
|
||||
* type: boolean
|
||||
* sharedByUsername:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* permissionLevel:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* shareId:
|
||||
* type: string
|
||||
* nullable: true
|
||||
*/
|
||||
router.get(
|
||||
"/active-sessions",
|
||||
@@ -336,10 +324,6 @@ router.get(
|
||||
tabInstanceId: s.tabInstanceId,
|
||||
isConnected: s.isConnected,
|
||||
createdAt: s.createdAt,
|
||||
isOwnSession: true,
|
||||
sharedByUsername: null as string | null,
|
||||
permissionLevel: null as string | null,
|
||||
shareId: null as string | null,
|
||||
}));
|
||||
|
||||
return res.json(result);
|
||||
|
||||
@@ -25,13 +25,25 @@ export interface SshAuthProviderSummary {
|
||||
missingPlugin?: { id: string; name: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* A plugin names its keys relative to its own locales, so they are sent
|
||||
* qualified with its namespace. Core never has to hold a plugin's strings.
|
||||
*/
|
||||
function qualifyKey(
|
||||
pluginId: string,
|
||||
key: string | undefined,
|
||||
): string | undefined {
|
||||
if (!key || pluginId === "core" || key.includes(":")) return key;
|
||||
return `${pluginId}:${key}`;
|
||||
}
|
||||
|
||||
function summarizeSshAuthProviders(): SshAuthProviderSummary[] {
|
||||
ensureCoreSshAuthProviders();
|
||||
const summaries: SshAuthProviderSummary[] = listSshAuthProviders().map(
|
||||
(provider) => ({
|
||||
type: provider.type,
|
||||
labelKey: provider.labelKey,
|
||||
descriptionKey: provider.descriptionKey,
|
||||
labelKey: qualifyKey(provider.pluginId, provider.labelKey) ?? "",
|
||||
descriptionKey: qualifyKey(provider.pluginId, provider.descriptionKey),
|
||||
pluginId: provider.pluginId,
|
||||
fields: provider.fields ?? [],
|
||||
credentialType: !!provider.credentialType,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isExternalAccount } from "../../auth/external-account.js";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import type { RequestHandler, Router } from "express";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
@@ -121,6 +122,7 @@ export function registerUserAdminRoutes(
|
||||
userId: u.id,
|
||||
username: u.username,
|
||||
is_admin: u.isAdmin,
|
||||
is_external: isExternalAccount(u),
|
||||
is_oidc: u.isOidc,
|
||||
password_hash: u.passwordHash ? "set" : null,
|
||||
// Management-only details stay admin-eyes-only; regular users hit
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
import { getErrorMessage } from "../../utils/error-message.js";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import type { Request, RequestHandler, Response, Router } from "express";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
import {
|
||||
createCurrentUserRepository,
|
||||
createCurrentUserAuthRepository,
|
||||
} from "../repositories/factory.js";
|
||||
import { deleteUserAndRelatedData } from "./delete-user-data.js";
|
||||
import { isExternalAccount } from "../../auth/external-account.js";
|
||||
|
||||
type UserExternalAccountRoutesDeps = {
|
||||
authenticateJWT: RequestHandler;
|
||||
authManager: AuthManager;
|
||||
};
|
||||
|
||||
function isNonEmptyString(val: unknown): val is string {
|
||||
return typeof val === "string" && val.trim().length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merging an account that only signs in externally (SSO, LDAP or any login
|
||||
* plugin) into a password account, and taking the external sign-in away
|
||||
* again. The identities themselves live in user_external_identities.
|
||||
*/
|
||||
export function registerUserExternalAccountRoutes(
|
||||
router: Router,
|
||||
{ authenticateJWT, authManager }: UserExternalAccountRoutesDeps,
|
||||
): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /users/link-external-to-password:
|
||||
* post:
|
||||
* summary: Merge an external account into a password account
|
||||
* description: Moves an external-only account's sign-in identities onto a password account and deletes the external-only account (admin only). The 2.8 path /users/link-oidc-to-password and its oidcUserId field are accepted too.
|
||||
* tags:
|
||||
* - Users
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* externalUserId:
|
||||
* type: string
|
||||
* targetUsername:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Accounts linked successfully.
|
||||
* 400:
|
||||
* description: Invalid request or incompatible accounts.
|
||||
* 403:
|
||||
* description: Admin access required.
|
||||
* 404:
|
||||
* description: User not found.
|
||||
* 500:
|
||||
* description: Failed to link accounts.
|
||||
*/
|
||||
const link = async (req: Request, res: Response) => {
|
||||
const adminUserId = (req as AuthenticatedRequest).userId;
|
||||
const externalUserId = req.body?.externalUserId ?? req.body?.oidcUserId;
|
||||
const targetUsername = req.body?.targetUsername;
|
||||
|
||||
if (
|
||||
!isNonEmptyString(externalUserId) ||
|
||||
!isNonEmptyString(targetUsername)
|
||||
) {
|
||||
return res.status(400).json({
|
||||
error: "External user ID and target username are required",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const userRepository = createCurrentUserRepository();
|
||||
const identities = createCurrentUserAuthRepository();
|
||||
const adminUser = await userRepository.findById(adminUserId);
|
||||
if (!adminUser?.isAdmin) {
|
||||
return res.status(403).json({ error: "Admin access required" });
|
||||
}
|
||||
|
||||
const externalUser = await userRepository.findById(externalUserId);
|
||||
if (!externalUser) {
|
||||
return res.status(404).json({ error: "External user not found" });
|
||||
}
|
||||
if (!isExternalAccount(externalUser)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Source user does not sign in externally" });
|
||||
}
|
||||
|
||||
const targetUser = await userRepository.findByUsername(targetUsername);
|
||||
if (!targetUser) {
|
||||
return res
|
||||
.status(404)
|
||||
.json({ error: "Target password user not found" });
|
||||
}
|
||||
if (isExternalAccount(targetUser) || !targetUser.passwordHash) {
|
||||
return res.status(400).json({
|
||||
error: "Target user must be a password-based account",
|
||||
});
|
||||
}
|
||||
if ((await identities.listIdentitiesForUser(targetUser.id)).length > 0) {
|
||||
return res.status(400).json({
|
||||
error: "Target user already has an external sign-in",
|
||||
});
|
||||
}
|
||||
|
||||
authLogger.info("Linking an external account to a password account", {
|
||||
operation: "link_external_to_password",
|
||||
externalUserId,
|
||||
targetUserId: targetUser.id,
|
||||
adminUserId,
|
||||
});
|
||||
|
||||
await userRepository.update(targetUser.id, {
|
||||
isOidc: true,
|
||||
oidcIdentifier: externalUser.oidcIdentifier,
|
||||
});
|
||||
await identities.moveIdentities(externalUserId, targetUser.id);
|
||||
|
||||
await authManager.revokeAllUserSessions(externalUserId);
|
||||
authManager.logoutUser(externalUserId);
|
||||
await deleteUserAndRelatedData(externalUserId);
|
||||
|
||||
try {
|
||||
await DatabaseSaveTrigger.forceSave("link_external_explicit_save");
|
||||
} catch (saveError) {
|
||||
authLogger.error(
|
||||
"Failed to persist account linking to disk",
|
||||
saveError,
|
||||
{
|
||||
operation: "link_external_save_failed",
|
||||
externalUserId,
|
||||
targetUserId: targetUser.id,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
authLogger.success(
|
||||
`External account ${externalUser.username} linked to password account ${targetUser.username}`,
|
||||
{
|
||||
operation: "link_external_to_password_success",
|
||||
externalUserId,
|
||||
targetUserId: targetUser.id,
|
||||
adminUserId,
|
||||
},
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `${externalUser.username} has been linked to ${targetUser.username}. The password account can now sign in both ways.`,
|
||||
});
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to link an external account", err, {
|
||||
operation: "link_external_to_password_failed",
|
||||
externalUserId,
|
||||
targetUsername,
|
||||
adminUserId,
|
||||
});
|
||||
res.status(500).json({
|
||||
error: "Failed to link accounts",
|
||||
details: getErrorMessage(err),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/unlink-external-from-password:
|
||||
* post:
|
||||
* summary: Remove external sign-in from a password account
|
||||
* description: Removes every external sign-in identity from an account that also has a password (admin only). The 2.8 path /users/unlink-oidc-from-password is accepted too.
|
||||
* tags:
|
||||
* - Users
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* userId:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: External sign-in removed.
|
||||
* 400:
|
||||
* description: Invalid request, or the user has no external sign-in or no password.
|
||||
* 403:
|
||||
* description: Admin privileges required.
|
||||
* 404:
|
||||
* description: User not found.
|
||||
* 500:
|
||||
* description: Failed to unlink.
|
||||
*/
|
||||
const unlink = async (req: Request, res: Response) => {
|
||||
const adminUserId = (req as AuthenticatedRequest).userId;
|
||||
const { userId } = req.body ?? {};
|
||||
|
||||
if (!userId) {
|
||||
return res.status(400).json({ error: "User ID is required" });
|
||||
}
|
||||
|
||||
try {
|
||||
const userRepository = createCurrentUserRepository();
|
||||
const adminUser = await userRepository.findById(adminUserId);
|
||||
if (!adminUser?.isAdmin) {
|
||||
authLogger.warn("Non-admin attempted to remove an external sign-in", {
|
||||
operation: "unlink_external_unauthorized",
|
||||
adminUserId,
|
||||
targetUserId: userId,
|
||||
});
|
||||
return res.status(403).json({ error: "Admin privileges required" });
|
||||
}
|
||||
|
||||
const targetUser = await userRepository.findById(userId);
|
||||
if (!targetUser) {
|
||||
return res.status(404).json({ error: "User not found" });
|
||||
}
|
||||
if (!isExternalAccount(targetUser)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "User does not have an external sign-in" });
|
||||
}
|
||||
if (!targetUser.passwordHash) {
|
||||
return res.status(400).json({
|
||||
error:
|
||||
"Cannot remove the external sign-in from a user without a password. This would leave the user unable to sign in.",
|
||||
});
|
||||
}
|
||||
|
||||
await userRepository.update(targetUser.id, {
|
||||
isOidc: false,
|
||||
oidcIdentifier: null,
|
||||
});
|
||||
await createCurrentUserAuthRepository().unlinkIdentitiesForUser(
|
||||
targetUser.id,
|
||||
);
|
||||
|
||||
try {
|
||||
await DatabaseSaveTrigger.forceSave("unlink_external_explicit_save");
|
||||
} catch (saveError) {
|
||||
authLogger.error(
|
||||
"Failed to save database after removing an external sign-in",
|
||||
saveError,
|
||||
{
|
||||
operation: "unlink_external_save_failed",
|
||||
targetUserId: targetUser.id,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
authLogger.success("External sign-in removed from password account", {
|
||||
operation: "unlink_external_from_password_success",
|
||||
targetUserId: targetUser.id,
|
||||
adminUserId,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `External sign-in has been removed from ${targetUser.username}. The user can now only sign in with a password.`,
|
||||
});
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to remove an external sign-in", err, {
|
||||
operation: "unlink_external_from_password_failed",
|
||||
targetUserId: userId,
|
||||
adminUserId,
|
||||
});
|
||||
res.status(500).json({
|
||||
error: "Failed to unlink",
|
||||
details: getErrorMessage(err),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
router.post("/link-external-to-password", authenticateJWT, link);
|
||||
router.post("/unlink-external-from-password", authenticateJWT, unlink);
|
||||
// 2.8 paths, kept until 3.0.0.
|
||||
router.post("/link-oidc-to-password", authenticateJWT, link);
|
||||
router.post("/unlink-oidc-from-password", authenticateJWT, unlink);
|
||||
}
|
||||
@@ -1,318 +0,0 @@
|
||||
import { getErrorMessage } from "../../utils/error-message.js";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import type { RequestHandler, Router } from "express";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
import {
|
||||
createCurrentUserRepository,
|
||||
createCurrentUserAuthRepository,
|
||||
} from "../repositories/factory.js";
|
||||
import { deleteUserAndRelatedData } from "./delete-user-data.js";
|
||||
|
||||
type UserOidcAccountRoutesDeps = {
|
||||
authenticateJWT: RequestHandler;
|
||||
authManager: AuthManager;
|
||||
};
|
||||
|
||||
function isNonEmptyString(val: unknown): val is string {
|
||||
return typeof val === "string" && val.trim().length > 0;
|
||||
}
|
||||
|
||||
export function registerUserOidcAccountRoutes(
|
||||
router: Router,
|
||||
{ authenticateJWT, authManager }: UserOidcAccountRoutesDeps,
|
||||
): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /users/link-oidc-to-password:
|
||||
* post:
|
||||
* summary: Link OIDC user to password account
|
||||
* description: Merges an OIDC-only account into a password-based account (admin only).
|
||||
* tags:
|
||||
* - Users
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* oidcUserId:
|
||||
* type: string
|
||||
* targetUsername:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Accounts linked successfully.
|
||||
* 400:
|
||||
* description: Invalid request or incompatible accounts.
|
||||
* 403:
|
||||
* description: Admin access required.
|
||||
* 404:
|
||||
* description: User not found.
|
||||
* 500:
|
||||
* description: Failed to link accounts.
|
||||
*/
|
||||
router.post("/link-oidc-to-password", authenticateJWT, async (req, res) => {
|
||||
const adminUserId = (req as AuthenticatedRequest).userId;
|
||||
const { oidcUserId, targetUsername } = req.body;
|
||||
|
||||
if (!isNonEmptyString(oidcUserId) || !isNonEmptyString(targetUsername)) {
|
||||
return res.status(400).json({
|
||||
error: "OIDC user ID and target username are required",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const userRepository = createCurrentUserRepository();
|
||||
const adminUser = await userRepository.findById(adminUserId);
|
||||
if (!adminUser?.isAdmin) {
|
||||
return res.status(403).json({ error: "Admin access required" });
|
||||
}
|
||||
|
||||
const oidcUser = await userRepository.findById(oidcUserId);
|
||||
if (!oidcUser) {
|
||||
return res.status(404).json({ error: "OIDC user not found" });
|
||||
}
|
||||
|
||||
if (!oidcUser.isOidc) {
|
||||
return res.status(400).json({
|
||||
error: "Source user is not an OIDC user",
|
||||
});
|
||||
}
|
||||
|
||||
const targetUser = await userRepository.findByUsername(targetUsername);
|
||||
if (!targetUser) {
|
||||
return res
|
||||
.status(404)
|
||||
.json({ error: "Target password user not found" });
|
||||
}
|
||||
|
||||
if (targetUser.isOidc || !targetUser.passwordHash) {
|
||||
return res.status(400).json({
|
||||
error: "Target user must be a password-based account",
|
||||
});
|
||||
}
|
||||
|
||||
if (targetUser.clientId && targetUser.oidcIdentifier) {
|
||||
return res.status(400).json({
|
||||
error: "Target user already has OIDC authentication configured",
|
||||
});
|
||||
}
|
||||
|
||||
authLogger.info("Linking OIDC user to password account", {
|
||||
operation: "link_oidc_to_password",
|
||||
oidcUserId,
|
||||
oidcUsername: oidcUser.username,
|
||||
targetUserId: targetUser.id,
|
||||
targetUsername: targetUser.username,
|
||||
adminUserId,
|
||||
});
|
||||
|
||||
await userRepository.update(targetUser.id, {
|
||||
isOidc: true,
|
||||
oidcIdentifier: oidcUser.oidcIdentifier,
|
||||
clientId: oidcUser.clientId,
|
||||
clientSecret: oidcUser.clientSecret,
|
||||
issuerUrl: oidcUser.issuerUrl,
|
||||
authorizationUrl: oidcUser.authorizationUrl,
|
||||
tokenUrl: oidcUser.tokenUrl,
|
||||
identifierPath: oidcUser.identifierPath,
|
||||
namePath: oidcUser.namePath,
|
||||
scopes: oidcUser.scopes || "openid email profile",
|
||||
});
|
||||
|
||||
// Identities hang off the user row and would go with it.
|
||||
await createCurrentUserAuthRepository().moveIdentities(
|
||||
oidcUserId,
|
||||
targetUser.id,
|
||||
);
|
||||
|
||||
await authManager.revokeAllUserSessions(oidcUserId);
|
||||
authManager.logoutUser(oidcUserId);
|
||||
|
||||
await deleteUserAndRelatedData(oidcUserId);
|
||||
|
||||
try {
|
||||
await DatabaseSaveTrigger.forceSave("link_oidc_explicit_save");
|
||||
} catch (saveError) {
|
||||
authLogger.error(
|
||||
"Failed to persist account linking to disk",
|
||||
saveError,
|
||||
{
|
||||
operation: "link_oidc_save_failed",
|
||||
oidcUserId,
|
||||
targetUserId: targetUser.id,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
authLogger.success(
|
||||
`OIDC user ${oidcUser.username} linked to password account ${targetUser.username}`,
|
||||
{
|
||||
operation: "link_oidc_to_password_success",
|
||||
oidcUserId,
|
||||
oidcUsername: oidcUser.username,
|
||||
targetUserId: targetUser.id,
|
||||
targetUsername: targetUser.username,
|
||||
adminUserId,
|
||||
},
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `OIDC user ${oidcUser.username} has been linked to ${targetUser.username}. The password account can now use both password and OIDC login.`,
|
||||
});
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to link OIDC user to password account", err, {
|
||||
operation: "link_oidc_to_password_failed",
|
||||
oidcUserId,
|
||||
targetUsername,
|
||||
adminUserId,
|
||||
});
|
||||
res.status(500).json({
|
||||
error: "Failed to link accounts",
|
||||
details: getErrorMessage(err),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/unlink-oidc-from-password:
|
||||
* post:
|
||||
* summary: Unlink OIDC from password account
|
||||
* description: Removes OIDC authentication from a dual-auth account (admin only).
|
||||
* tags:
|
||||
* - Users
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* userId:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: OIDC unlinked successfully.
|
||||
* 400:
|
||||
* description: Invalid request or user doesn't have OIDC.
|
||||
* 403:
|
||||
* description: Admin privileges required.
|
||||
* 404:
|
||||
* description: User not found.
|
||||
* 500:
|
||||
* description: Failed to unlink OIDC.
|
||||
*/
|
||||
router.post(
|
||||
"/unlink-oidc-from-password",
|
||||
authenticateJWT,
|
||||
async (req, res) => {
|
||||
const adminUserId = (req as AuthenticatedRequest).userId;
|
||||
const { userId } = req.body;
|
||||
|
||||
if (!userId) {
|
||||
return res.status(400).json({
|
||||
error: "User ID is required",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const userRepository = createCurrentUserRepository();
|
||||
const adminUser = await userRepository.findById(adminUserId);
|
||||
|
||||
if (!adminUser?.isAdmin) {
|
||||
authLogger.warn("Non-admin attempted to unlink OIDC from password", {
|
||||
operation: "unlink_oidc_unauthorized",
|
||||
adminUserId,
|
||||
targetUserId: userId,
|
||||
});
|
||||
return res.status(403).json({
|
||||
error: "Admin privileges required",
|
||||
});
|
||||
}
|
||||
|
||||
const targetUser = await userRepository.findById(userId);
|
||||
if (!targetUser) {
|
||||
return res.status(404).json({
|
||||
error: "User not found",
|
||||
});
|
||||
}
|
||||
|
||||
if (!targetUser.isOidc) {
|
||||
return res.status(400).json({
|
||||
error: "User does not have OIDC authentication enabled",
|
||||
});
|
||||
}
|
||||
|
||||
if (!targetUser.passwordHash || targetUser.passwordHash === "") {
|
||||
return res.status(400).json({
|
||||
error:
|
||||
"Cannot unlink OIDC from a user without password authentication. This would leave the user unable to login.",
|
||||
});
|
||||
}
|
||||
|
||||
authLogger.info("Unlinking OIDC from password account", {
|
||||
operation: "unlink_oidc_from_password_start",
|
||||
targetUserId: targetUser.id,
|
||||
targetUsername: targetUser.username,
|
||||
adminUserId,
|
||||
});
|
||||
|
||||
await userRepository.update(targetUser.id, {
|
||||
isOidc: false,
|
||||
oidcIdentifier: null,
|
||||
clientId: "",
|
||||
clientSecret: "",
|
||||
issuerUrl: "",
|
||||
authorizationUrl: "",
|
||||
tokenUrl: "",
|
||||
identifierPath: "",
|
||||
namePath: "",
|
||||
scopes: "openid email profile",
|
||||
});
|
||||
await createCurrentUserAuthRepository().unlinkIdentitiesForUser(
|
||||
targetUser.id,
|
||||
);
|
||||
|
||||
try {
|
||||
await DatabaseSaveTrigger.forceSave("unlink_oidc_explicit_save");
|
||||
} catch (saveError) {
|
||||
authLogger.error(
|
||||
"Failed to save database after unlinking OIDC",
|
||||
saveError,
|
||||
{
|
||||
operation: "unlink_oidc_save_failed",
|
||||
targetUserId: targetUser.id,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
authLogger.success("OIDC unlinked from password account successfully", {
|
||||
operation: "unlink_oidc_from_password_success",
|
||||
targetUserId: targetUser.id,
|
||||
targetUsername: targetUser.username,
|
||||
adminUserId,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `OIDC authentication has been removed from ${targetUser.username}. User can now only login with password.`,
|
||||
});
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to unlink OIDC from password account", err, {
|
||||
operation: "unlink_oidc_from_password_failed",
|
||||
targetUserId: userId,
|
||||
adminUserId,
|
||||
});
|
||||
res.status(500).json({
|
||||
error: "Failed to unlink OIDC",
|
||||
details: getErrorMessage(err),
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -20,7 +20,6 @@ const pickPreferences = (row?: UserPreferenceRecord | null) => ({
|
||||
accentColor: row?.accentColor ?? null,
|
||||
language: row?.language ?? null,
|
||||
storageMode: row?.storageMode ?? "cloud",
|
||||
commandAutocomplete: row?.commandAutocomplete ?? null,
|
||||
commandPaletteEnabled: row?.commandPaletteEnabled ?? null,
|
||||
showHostTags: row?.showHostTags ?? null,
|
||||
hostTrayOnClick: row?.hostTrayOnClick ?? null,
|
||||
@@ -32,23 +31,9 @@ const pickPreferences = (row?: UserPreferenceRecord | null) => ({
|
||||
hiddenRailTabs: row?.hiddenRailTabs ?? null,
|
||||
compactHostView: row?.compactHostView ?? null,
|
||||
statusColorScheme: row?.statusColorScheme ?? null,
|
||||
customThemes: row?.customThemes ?? null,
|
||||
customKeybindings: row?.customKeybindings ?? null,
|
||||
terminalDefaults: row?.terminalDefaults ?? null,
|
||||
});
|
||||
|
||||
const connectionDefaultFields = ["terminalDefaults"] as const;
|
||||
|
||||
export function validateDefaultsJson(value: string): boolean {
|
||||
if (value.length > 32_768) return false;
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return !!parsed && typeof parsed === "object" && !Array.isArray(parsed);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /user-preferences:
|
||||
@@ -82,9 +67,6 @@ export function validateDefaultsJson(value: string): boolean {
|
||||
* storageMode:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* commandAutocomplete:
|
||||
* type: boolean
|
||||
* nullable: true
|
||||
* commandPaletteEnabled:
|
||||
* type: boolean
|
||||
* nullable: true
|
||||
@@ -118,10 +100,6 @@ export function validateDefaultsJson(value: string): boolean {
|
||||
* statusColorScheme:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* customThemes:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* description: JSON-encoded array of the user's saved global custom terminal themes.
|
||||
* customKeybindings:
|
||||
* type: string
|
||||
* nullable: true
|
||||
@@ -170,8 +148,6 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
* type: string
|
||||
* storageMode:
|
||||
* type: string
|
||||
* commandAutocomplete:
|
||||
* type: boolean
|
||||
* commandPaletteEnabled:
|
||||
* type: boolean
|
||||
* pinAppRail:
|
||||
@@ -186,9 +162,6 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
* type: boolean
|
||||
* hiddenRailTabs:
|
||||
* type: string
|
||||
* customThemes:
|
||||
* type: string
|
||||
* description: JSON-encoded array of the user's saved global custom terminal themes.
|
||||
* 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.
|
||||
@@ -205,7 +178,6 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
accentColor,
|
||||
language,
|
||||
storageMode,
|
||||
commandAutocomplete,
|
||||
commandPaletteEnabled,
|
||||
pinAppRail,
|
||||
expandAppRailOnHover,
|
||||
@@ -213,9 +185,7 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
disableUpdateCheck,
|
||||
confirmTabClose,
|
||||
hiddenRailTabs,
|
||||
customThemes,
|
||||
customKeybindings,
|
||||
terminalDefaults,
|
||||
} = req.body as {
|
||||
reopenTabsOnLogin?: boolean;
|
||||
theme?: string | null;
|
||||
@@ -223,7 +193,6 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
accentColor?: string | null;
|
||||
language?: string | null;
|
||||
storageMode?: string | null;
|
||||
commandAutocomplete?: boolean | null;
|
||||
commandPaletteEnabled?: boolean | null;
|
||||
pinAppRail?: boolean | null;
|
||||
expandAppRailOnHover?: boolean | null;
|
||||
@@ -231,9 +200,7 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
disableUpdateCheck?: boolean | null;
|
||||
confirmTabClose?: boolean | null;
|
||||
hiddenRailTabs?: string | null;
|
||||
customThemes?: string | null;
|
||||
customKeybindings?: string | null;
|
||||
terminalDefaults?: string | null;
|
||||
};
|
||||
// showHostTags, hostTrayOnClick, compactHostView, statusColorScheme are no
|
||||
// longer writable here -- they moved to /host-sidebar/preferences as of the
|
||||
@@ -262,55 +229,13 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
language,
|
||||
storageMode,
|
||||
hiddenRailTabs,
|
||||
customThemes,
|
||||
customKeybindings,
|
||||
terminalDefaults,
|
||||
})) {
|
||||
if (value !== undefined && value !== null && typeof value !== "string") {
|
||||
return res.status(400).json({ error: `${key} must be a string` });
|
||||
}
|
||||
}
|
||||
|
||||
const connectionDefaults = {
|
||||
terminalDefaults,
|
||||
};
|
||||
for (const key of connectionDefaultFields) {
|
||||
const value = connectionDefaults[key];
|
||||
if (value !== undefined && value !== null && !validateDefaultsJson(value)) {
|
||||
return res.status(400).json({
|
||||
error: `${key} must be a JSON-encoded object of at most 32 KiB`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (customThemes !== undefined && customThemes !== null) {
|
||||
let parsedThemes: unknown;
|
||||
try {
|
||||
parsedThemes = JSON.parse(customThemes);
|
||||
} catch {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "customThemes must be a JSON-encoded array" });
|
||||
}
|
||||
if (!Array.isArray(parsedThemes) || parsedThemes.length > 100) {
|
||||
return res.status(400).json({
|
||||
error: "customThemes must be a JSON array of at most 100 themes",
|
||||
});
|
||||
}
|
||||
const isValidTheme = (entry: unknown): boolean =>
|
||||
!!entry &&
|
||||
typeof entry === "object" &&
|
||||
typeof (entry as { id?: unknown }).id === "string" &&
|
||||
typeof (entry as { name?: unknown }).name === "string" &&
|
||||
!!(entry as { colors?: unknown }).colors &&
|
||||
typeof (entry as { colors?: unknown }).colors === "object";
|
||||
if (!parsedThemes.every(isValidTheme)) {
|
||||
return res.status(400).json({
|
||||
error: "Each custom theme must have an id, name, and colors object",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (customKeybindings !== undefined && customKeybindings !== null) {
|
||||
let parsedKeybindings: unknown;
|
||||
try {
|
||||
@@ -334,7 +259,6 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
}
|
||||
|
||||
const boolFields: Record<string, boolean | null | undefined> = {
|
||||
commandAutocomplete,
|
||||
commandPaletteEnabled,
|
||||
pinAppRail,
|
||||
expandAppRailOnHover,
|
||||
@@ -354,8 +278,6 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
if (language !== undefined) updates.language = language;
|
||||
if (storageMode !== undefined) updates.storageMode = storageMode;
|
||||
if (hiddenRailTabs !== undefined) updates.hiddenRailTabs = hiddenRailTabs;
|
||||
if (commandAutocomplete !== undefined)
|
||||
updates.commandAutocomplete = commandAutocomplete;
|
||||
if (commandPaletteEnabled !== undefined)
|
||||
updates.commandPaletteEnabled = commandPaletteEnabled;
|
||||
if (pinAppRail !== undefined) updates.pinAppRail = pinAppRail;
|
||||
@@ -366,11 +288,8 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
if (disableUpdateCheck !== undefined)
|
||||
updates.disableUpdateCheck = disableUpdateCheck;
|
||||
if (confirmTabClose !== undefined) updates.confirmTabClose = confirmTabClose;
|
||||
if (customThemes !== undefined) updates.customThemes = customThemes;
|
||||
if (customKeybindings !== undefined)
|
||||
updates.customKeybindings = customKeybindings;
|
||||
if (terminalDefaults !== undefined)
|
||||
updates.terminalDefaults = terminalDefaults;
|
||||
|
||||
if (Object.keys(updates).length === 1) {
|
||||
return res.status(400).json({ error: "No preferences provided" });
|
||||
|
||||
@@ -341,25 +341,6 @@ export function registerUserSettingsRoutes(
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/session-sharing-enabled:
|
||||
* get:
|
||||
* summary: Get session sharing globally enabled setting
|
||||
* description: Returns whether live session sharing (terminal and remote desktop share links and in-app joins) is allowed instance-wide. Overrides every per-host toggle when false.
|
||||
* tags:
|
||||
* - Users
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Session sharing enabled status.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* enabled:
|
||||
* type: boolean
|
||||
*/
|
||||
/**
|
||||
* @openapi
|
||||
* /users/audit-forwarding:
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { isExternalAccount } from "../../auth/external-account.js";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import express, { type Request, type Response } from "express";
|
||||
import express, {
|
||||
type Request,
|
||||
type RequestHandler,
|
||||
type Response,
|
||||
} from "express";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { nanoid } from "nanoid";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
@@ -21,7 +26,7 @@ import { registerBrandingRoutes } from "./branding-routes.js";
|
||||
import { registerUserSettingsRoutes } from "./user-settings-routes.js";
|
||||
import { registerTlsRoutes } from "./tls-routes.js";
|
||||
import { registerUserSessionRoutes } from "./user-session-routes.js";
|
||||
import { registerUserOidcAccountRoutes } from "./user-oidc-account-routes.js";
|
||||
import { registerUserExternalAccountRoutes } from "./user-external-account-routes.js";
|
||||
import { registerUserPasswordResetRoutes } from "./user-password-reset-routes.js";
|
||||
import { registerUserAdminRoutes } from "./user-admin-routes.js";
|
||||
import { registerUserDataAccessRoutes } from "./user-data-access-routes.js";
|
||||
@@ -90,12 +95,6 @@ function isPasswordResetAllowed(): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
function getOidcSilentLoginDefaultFromEnv(): boolean | undefined {
|
||||
const envVal = process.env.OIDC_SILENT_LOGIN_DEFAULT;
|
||||
if (envVal === undefined) return undefined;
|
||||
return envVal.trim().toLowerCase() === "true";
|
||||
}
|
||||
|
||||
async function findCurrentUser(userId: string): Promise<UserRecord | null> {
|
||||
return createCurrentUserRepository().findById(userId);
|
||||
}
|
||||
@@ -601,8 +600,8 @@ router.get("/me", authenticateJWT, async (req: Request, res: Response) => {
|
||||
}
|
||||
|
||||
const hasPassword = user.passwordHash && user.passwordHash.trim() !== "";
|
||||
const hasOidc = user.isOidc && user.oidcIdentifier;
|
||||
const isDualAuth = hasPassword && hasOidc;
|
||||
const isDualAuth =
|
||||
hasPassword && isExternalAccount(user) && !!user.oidcIdentifier;
|
||||
|
||||
const showDonationModal = shouldShowDonationModal(
|
||||
user.registeredAt,
|
||||
@@ -613,6 +612,8 @@ router.get("/me", authenticateJWT, async (req: Request, res: Response) => {
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
is_admin: !!user.isAdmin,
|
||||
is_external: isExternalAccount(user),
|
||||
// 2.8 name, kept until 3.0.0.
|
||||
is_oidc: !!user.isOidc,
|
||||
is_dual_auth: isDualAuth,
|
||||
// Any second factor; the name is what 2.8 clients read.
|
||||
@@ -940,17 +941,17 @@ router.patch("/registration-allowed", authenticateJWT, async (req, res) => {
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/oidc-auto-provision:
|
||||
* /users/external-auto-provision:
|
||||
* get:
|
||||
* summary: Get OIDC auto-provision status
|
||||
* description: Whether a new user account is created automatically on first SSO sign-in.
|
||||
* summary: Get the external account auto-create setting
|
||||
* description: Whether a new account is created the first time someone signs in through an external login (SSO, LDAP or any login plugin). The 2.8 path /users/oidc-auto-provision is accepted too.
|
||||
* tags:
|
||||
* - Users
|
||||
* responses:
|
||||
* 200:
|
||||
* description: OIDC auto-provision status.
|
||||
* description: Auto-create setting.
|
||||
*/
|
||||
router.get("/oidc-auto-provision", async (_req, res) => {
|
||||
const getExternalAutoProvision: RequestHandler = async (_req, res) => {
|
||||
try {
|
||||
res.json({
|
||||
enabled: await createCurrentSettingsRepository().getBoolean(
|
||||
@@ -959,19 +960,19 @@ router.get("/oidc-auto-provision", async (_req, res) => {
|
||||
),
|
||||
});
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to get OIDC auto-provision setting", err);
|
||||
authLogger.error("Failed to get external auto-provision setting", err);
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: "Failed to get OIDC auto-provision setting" });
|
||||
.json({ error: "Failed to get external auto-provision setting" });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/oidc-auto-provision:
|
||||
* /users/external-auto-provision:
|
||||
* patch:
|
||||
* summary: Set OIDC auto-provision status
|
||||
* description: Enables or disables automatic account creation on first SSO sign-in.
|
||||
* summary: Set the external account auto-create setting
|
||||
* description: Enables or disables creating an account on first external sign-in. The 2.8 path /users/oidc-auto-provision is accepted too.
|
||||
* tags:
|
||||
* - Users
|
||||
* requestBody:
|
||||
@@ -985,15 +986,15 @@ router.get("/oidc-auto-provision", async (_req, res) => {
|
||||
* type: boolean
|
||||
* responses:
|
||||
* 200:
|
||||
* description: OIDC auto-provision status updated.
|
||||
* description: Setting updated.
|
||||
* 400:
|
||||
* description: Invalid value for enabled.
|
||||
* 403:
|
||||
* description: Not authorized.
|
||||
* 500:
|
||||
* description: Failed to set OIDC auto-provision setting.
|
||||
* description: Failed to save the setting.
|
||||
*/
|
||||
router.patch("/oidc-auto-provision", authenticateJWT, async (req, res) => {
|
||||
const setExternalAutoProvision: RequestHandler = async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
const user = await requireCurrentAdmin(userId);
|
||||
@@ -1010,10 +1011,20 @@ router.patch("/oidc-auto-provision", authenticateJWT, async (req, res) => {
|
||||
);
|
||||
res.json({ enabled });
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to set OIDC auto-provision", err);
|
||||
res.status(500).json({ error: "Failed to set OIDC auto-provision" });
|
||||
authLogger.error("Failed to set external auto-provision", err);
|
||||
res.status(500).json({ error: "Failed to set external auto-provision" });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
router.get("/external-auto-provision", getExternalAutoProvision);
|
||||
router.patch(
|
||||
"/external-auto-provision",
|
||||
authenticateJWT,
|
||||
setExternalAutoProvision,
|
||||
);
|
||||
// 2.8 paths, kept until 3.0.0.
|
||||
router.get("/oidc-auto-provision", getExternalAutoProvision);
|
||||
router.patch("/oidc-auto-provision", authenticateJWT, setExternalAutoProvision);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
@@ -1104,103 +1115,6 @@ router.patch(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/oidc-silent-login-default:
|
||||
* get:
|
||||
* summary: Get OIDC silent login default setting
|
||||
* description: Returns whether silent OIDC login is enabled as the default behavior. Can be pinned via the OIDC_SILENT_LOGIN_DEFAULT env var.
|
||||
* tags:
|
||||
* - Users
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Silent login default setting.
|
||||
* 500:
|
||||
* description: Failed to get setting.
|
||||
*/
|
||||
router.get("/oidc-silent-login-default", async (_req, res) => {
|
||||
try {
|
||||
const envVal = getOidcSilentLoginDefaultFromEnv();
|
||||
if (envVal !== undefined) {
|
||||
res.json({ enabled: envVal, locked: true });
|
||||
return;
|
||||
}
|
||||
res.json({
|
||||
enabled: await createCurrentSettingsRepository().getBoolean(
|
||||
"oidc_silent_login_default",
|
||||
false,
|
||||
),
|
||||
locked: false,
|
||||
});
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to get OIDC silent login default", err);
|
||||
res.status(500).json({ error: "Failed to get OIDC silent login default" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/oidc-silent-login-default:
|
||||
* patch:
|
||||
* summary: Set OIDC silent login default setting
|
||||
* description: Enables or disables silent OIDC login as the default behavior on the login page.
|
||||
* tags:
|
||||
* - Users
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* enabled:
|
||||
* type: boolean
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Setting updated.
|
||||
* 400:
|
||||
* description: Invalid value.
|
||||
* 403:
|
||||
* description: Not authorized.
|
||||
* 409:
|
||||
* description: Setting is pinned by the OIDC_SILENT_LOGIN_DEFAULT env var.
|
||||
* 500:
|
||||
* description: Failed to update setting.
|
||||
*/
|
||||
router.patch(
|
||||
"/oidc-silent-login-default",
|
||||
authenticateJWT,
|
||||
async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
const user = await requireCurrentAdmin(userId);
|
||||
if (!user) {
|
||||
return res.status(403).json({ error: "Not authorized" });
|
||||
}
|
||||
if (getOidcSilentLoginDefaultFromEnv() !== undefined) {
|
||||
return res.status(409).json({
|
||||
error:
|
||||
"OIDC silent login default is set via the OIDC_SILENT_LOGIN_DEFAULT env var and cannot be changed here",
|
||||
});
|
||||
}
|
||||
const { enabled } = req.body;
|
||||
if (typeof enabled !== "boolean") {
|
||||
return res.status(400).json({ error: "Invalid value for enabled" });
|
||||
}
|
||||
await createCurrentSettingsRepository().set(
|
||||
"oidc_silent_login_default",
|
||||
enabled ? "true" : "false",
|
||||
);
|
||||
res.json({ enabled });
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to set OIDC silent login default", err);
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: "Failed to set OIDC silent login default" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/password-login-allowed:
|
||||
@@ -1668,7 +1582,7 @@ registerUserSessionRoutes(router, {
|
||||
authManager,
|
||||
});
|
||||
|
||||
registerUserOidcAccountRoutes(router, {
|
||||
registerUserExternalAccountRoutes(router, {
|
||||
authenticateJWT,
|
||||
authManager,
|
||||
});
|
||||
|
||||
@@ -34,16 +34,20 @@ export function pickResolvedPassword(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const USERNAME_PLACEHOLDER =
|
||||
/\$(?:external\.username|oidc\.preferred_username)/g;
|
||||
|
||||
/**
|
||||
* Expands the `$oidc.preferred_username` placeholder in an SSH username to the
|
||||
* connecting user's OIDC identifier. Returns the username unchanged if it does
|
||||
* not contain the placeholder or the user has no OIDC identifier.
|
||||
* Expands `$external.username` (or its 2.8 spelling `$oidc.preferred_username`)
|
||||
* in an SSH username to the name the connecting user signed in with through
|
||||
* SSO or LDAP. Returns the username unchanged if it has no placeholder or the
|
||||
* user has no external sign-in.
|
||||
*/
|
||||
export async function expandOidcUsername(
|
||||
export async function expandExternalUsername(
|
||||
username: string | undefined,
|
||||
userId: string,
|
||||
): Promise<string | undefined> {
|
||||
if (!username || !username.includes("$oidc.preferred_username")) {
|
||||
if (!username || !new RegExp(USERNAME_PLACEHOLDER.source).test(username)) {
|
||||
return username;
|
||||
}
|
||||
|
||||
@@ -51,10 +55,10 @@ export async function expandOidcUsername(
|
||||
const { createCurrentUserRepository } =
|
||||
await import("../database/repositories/factory.js");
|
||||
const user = await createCurrentUserRepository().findById(userId);
|
||||
let oidcIdentifier = user?.oidcIdentifier;
|
||||
if (!oidcIdentifier) return username;
|
||||
let externalName = user?.oidcIdentifier;
|
||||
if (!externalName) return username;
|
||||
|
||||
const match = /^ldap:(\d+):(.+)$/.exec(oidcIdentifier);
|
||||
const match = /^ldap:(\d+):(.+)$/.exec(externalName);
|
||||
if (match) {
|
||||
// Only strip the prefix for a real LDAP identity, to prevent spoofing
|
||||
// through an SSO subject that happens to look like one.
|
||||
@@ -69,11 +73,11 @@ export async function expandOidcUsername(
|
||||
identity.subject === match[2],
|
||||
)
|
||||
) {
|
||||
oidcIdentifier = match[2];
|
||||
externalName = match[2];
|
||||
}
|
||||
}
|
||||
|
||||
return username.replace(/\$oidc\.preferred_username/g, oidcIdentifier);
|
||||
return username.replace(USERNAME_PLACEHOLDER, () => externalName);
|
||||
} catch {
|
||||
return username;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { resolveRecipientSharedHostAuthentication } from "../utils/shared-host-a
|
||||
import {
|
||||
pickResolvedPassword,
|
||||
pickResolvedUsername,
|
||||
expandOidcUsername,
|
||||
expandExternalUsername,
|
||||
} from "./credential-username.js";
|
||||
import type { SSHHost } from "../../types/index.js";
|
||||
|
||||
@@ -210,7 +210,7 @@ export async function resolveHostById(
|
||||
}
|
||||
}
|
||||
|
||||
host.username = await expandOidcUsername(
|
||||
host.username = await expandExternalUsername(
|
||||
host.username as string | undefined,
|
||||
ownerEquivalent ? ownerId : userId,
|
||||
);
|
||||
|
||||
@@ -22,7 +22,6 @@ export interface LiveSessionInfo {
|
||||
createdAt: number;
|
||||
lastDetachedAt: number | null;
|
||||
tabInstanceId: string | null;
|
||||
tmuxSessionName: string | null;
|
||||
cols: number;
|
||||
rows: number;
|
||||
}
|
||||
|
||||
@@ -46,15 +46,3 @@ export function getActorSessionId(): string | undefined {
|
||||
export function getActor(): string | undefined {
|
||||
return storage.getStore()?.userId;
|
||||
}
|
||||
|
||||
class PluginActorError extends Error {
|
||||
readonly code = "EPLUGINACTOR";
|
||||
|
||||
constructor(pluginId: string, operation: string) {
|
||||
super(
|
||||
`Plugin "${pluginId}" called ${operation} with no acting user. ` +
|
||||
`Wrap background work in ctx.asUser(userId, fn).`,
|
||||
);
|
||||
this.name = "PluginActorError";
|
||||
}
|
||||
}
|
||||
@@ -93,12 +93,7 @@ export function describePluginFrontend(
|
||||
const entry = frontendEntry(plugin);
|
||||
if (!entry || !fileExists(entry.file)) return { ...none, locales };
|
||||
|
||||
const stat = fs.statSync(entry.file);
|
||||
const assetVersion = crypto
|
||||
.createHash("sha256")
|
||||
.update(`${plugin.manifest.version}:${stat.size}:${stat.mtimeMs}`)
|
||||
.digest("hex")
|
||||
.slice(0, 12);
|
||||
const assetVersion = contentVersion(entry.file, plugin.manifest.version);
|
||||
|
||||
return {
|
||||
frontend: true,
|
||||
@@ -108,6 +103,29 @@ export function describePluginFrontend(
|
||||
};
|
||||
}
|
||||
|
||||
const versionCache = new Map<string, { stamp: string; version: string }>();
|
||||
|
||||
/**
|
||||
* A hash of the bundle's bytes, not its mtime: assets are cached as immutable
|
||||
* for a year, and a rebuilt .tmxplug has a fixed mtime, so a same-size rebuild
|
||||
* would otherwise keep serving the stale copy.
|
||||
*/
|
||||
function contentVersion(file: string, version: string): string {
|
||||
const stat = fs.statSync(file);
|
||||
// ctime, unlike mtime, cannot be set by tar, so an unpack always changes it.
|
||||
const stamp = `${version}:${stat.size}:${stat.mtimeMs}:${stat.ctimeMs}`;
|
||||
const cached = versionCache.get(file);
|
||||
if (cached?.stamp === stamp) return cached.version;
|
||||
const hashed = crypto
|
||||
.createHash("sha256")
|
||||
.update(version)
|
||||
.update(fs.readFileSync(file))
|
||||
.digest("hex")
|
||||
.slice(0, 12);
|
||||
versionCache.set(file, { stamp, version: hashed });
|
||||
return hashed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a request path under /plugin-assets/<id>/ to a file, or null.
|
||||
*
|
||||
|
||||
@@ -81,7 +81,6 @@ export const HOST_SECRET_FIELDS = [
|
||||
"passphrase",
|
||||
"sudoPassword",
|
||||
"socks5Password",
|
||||
"vaultToken",
|
||||
] as const;
|
||||
|
||||
/** A copy of a host with every secret field removed, nested ones included. */
|
||||
|
||||
@@ -51,6 +51,34 @@ async function sha256Of(file: string): Promise<string | null> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads through the SSRF guard, following https redirects by hand
|
||||
* because the guard refuses automatic ones (release assets redirect to a
|
||||
* CDN).
|
||||
*/
|
||||
async function guardedDownload(
|
||||
url: URL | string,
|
||||
init: RequestInit,
|
||||
): Promise<Response> {
|
||||
const { safeOutboundFetch } = await import("../utils/safe-outbound-fetch.js");
|
||||
let current = new URL(url.toString());
|
||||
for (let hop = 0; hop < 5; hop++) {
|
||||
const response = await safeOutboundFetch(current.toString(), {
|
||||
...init,
|
||||
redirect: "manual",
|
||||
});
|
||||
const location = response.headers.get("location");
|
||||
if (response.status < 300 || response.status >= 400 || !location) {
|
||||
return response;
|
||||
}
|
||||
current = new URL(location, current);
|
||||
if (current.protocol !== "https:") {
|
||||
throw new Error("Binary downloads must stay on https");
|
||||
}
|
||||
}
|
||||
throw new Error("Too many redirects");
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
@@ -60,7 +88,7 @@ export function createPluginProcess(deps: Deps): PluginProcess {
|
||||
const declared = deps.manifest.capabilities;
|
||||
const binDir =
|
||||
deps.binDir ?? (() => path.join(getPluginDataDir(pluginId), "bin"));
|
||||
const doFetch = deps.fetch ?? fetch;
|
||||
const doFetch = deps.fetch ?? (guardedDownload as typeof fetch);
|
||||
|
||||
const audited = async <T>(
|
||||
action: string,
|
||||
|
||||
@@ -791,11 +791,12 @@ export function createPluginAuth({ manifest, bag, audit }: Deps): PluginAuth {
|
||||
await granted();
|
||||
if (!match.sub && !match.sid) return 0;
|
||||
const { AuthManager } = await import("../utils/auth-manager.js");
|
||||
const revoked = await AuthManager.getInstance().revokeSessionsByOidc({
|
||||
ssoProviderId: match.providerId ?? null,
|
||||
sub: match.sub ?? null,
|
||||
sid: match.sid ?? null,
|
||||
});
|
||||
const revoked =
|
||||
await AuthManager.getInstance().revokeSessionsByExternalSession({
|
||||
ssoProviderId: match.providerId ?? null,
|
||||
sub: match.sub ?? null,
|
||||
sid: match.sid ?? null,
|
||||
});
|
||||
await audit(
|
||||
"auth_sessions_revoked",
|
||||
`${revoked} session(s) for provider ${match.providerId ?? "none"}`,
|
||||
|
||||
+83
-22
@@ -47,7 +47,7 @@ import {
|
||||
type PluginContext,
|
||||
type PluginModule,
|
||||
type PluginOpenIsolatedWindowRequest,
|
||||
type PluginNativeRdpRequest,
|
||||
type PluginExternalClientRequest,
|
||||
} from "@termix/plugin-sdk/backend";
|
||||
import type { PluginTableDefinition } from "@termix/plugin-sdk/db";
|
||||
import * as syncRegistry from "./sync-registry.js";
|
||||
@@ -191,14 +191,17 @@ export function createPluginContext(
|
||||
await import("../database/repositories/factory.js");
|
||||
return createCurrentPluginSettingsRepository();
|
||||
};
|
||||
// A user named by the plugin instead of the ambient actor. Honoured, like
|
||||
// ctx.asUser, and audited the same way.
|
||||
// A user named by the plugin instead of the ambient actor. The same thing
|
||||
// as ctx.asUser, so it needs the same capability and gets the same audit.
|
||||
const namedUser = (
|
||||
userId: string | undefined,
|
||||
via: string,
|
||||
): string | undefined => {
|
||||
if (!userId) return getActor();
|
||||
if (userId !== getActor()) {
|
||||
if (!declared.includes("users:impersonate")) {
|
||||
throw capabilityRefused(pluginId, "users:impersonate");
|
||||
}
|
||||
void writeAudit(
|
||||
manifest,
|
||||
{
|
||||
@@ -342,11 +345,11 @@ export function createPluginContext(
|
||||
|
||||
const dbRefs = guarded(
|
||||
manifest,
|
||||
"db:own",
|
||||
"db:core-refs",
|
||||
async () => {
|
||||
// Read-only by convention, not by engine: a plugin holding these can
|
||||
// write through them. The capability and the audit line are the record
|
||||
// that it did. See the header of this file.
|
||||
// write through them, which is why the capability is rated high. The
|
||||
// audit line is the record that it asked.
|
||||
const schema = await import("../database/db/schema.js");
|
||||
return {
|
||||
users: schema.users,
|
||||
@@ -380,25 +383,25 @@ export function createPluginContext(
|
||||
},
|
||||
);
|
||||
|
||||
const desktopLaunchNativeRdp = guarded(
|
||||
const desktopLaunchExternalClient = guarded(
|
||||
manifest,
|
||||
"desktop:window",
|
||||
async (request: PluginNativeRdpRequest) => {
|
||||
async (request: PluginExternalClientRequest) => {
|
||||
const { isElectronIpcAvailable, requestFromElectronMain } =
|
||||
await import("../utils/electron-ipc-bridge.js");
|
||||
if (!isElectronIpcAvailable()) {
|
||||
throw new Error(
|
||||
`Plugin ${pluginId} tried to open the native RDP client outside the desktop app`,
|
||||
`Plugin ${pluginId} tried to open an external client outside the desktop app`,
|
||||
);
|
||||
}
|
||||
return requestFromElectronMain<{ success: boolean; error?: string }>(
|
||||
"launch-native-rdp",
|
||||
"launch-external-client",
|
||||
request,
|
||||
);
|
||||
},
|
||||
{
|
||||
action: "desktop_launch_native_rdp",
|
||||
details: () => "opened the native RDP client",
|
||||
action: "desktop_launch_external_client",
|
||||
details: () => "opened an external client",
|
||||
},
|
||||
);
|
||||
|
||||
@@ -455,7 +458,26 @@ export function createPluginContext(
|
||||
`Declare the events:core capability to listen to core topics.`,
|
||||
);
|
||||
}
|
||||
const unsubscribe = pluginEvents.on(topic, listener);
|
||||
// A throwing listener counts against the plugin's error budget.
|
||||
const counted: typeof listener = (payload) => {
|
||||
try {
|
||||
const result = listener(payload) as unknown;
|
||||
if (
|
||||
result &&
|
||||
typeof (result as Promise<void>).then === "function"
|
||||
) {
|
||||
return (result as Promise<void>).catch((error) => {
|
||||
void reportRuntimeError(pluginId, error);
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
return result as never;
|
||||
} catch (error) {
|
||||
void reportRuntimeError(pluginId, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
const unsubscribe = pluginEvents.on(topic, counted);
|
||||
handle.bag.add(unsubscribe, `event listener for "${topic}"`);
|
||||
return unsubscribe;
|
||||
},
|
||||
@@ -502,7 +524,10 @@ export function createPluginContext(
|
||||
},
|
||||
|
||||
registry: {
|
||||
// Keys are namespaced by plugin id, so one plugin cannot replace or
|
||||
// remove what another provides. Anyone may consume.
|
||||
provide: (key, value) => {
|
||||
assertOwnRegistryKey(pluginId, key);
|
||||
registry.provide(key, value);
|
||||
handle.bag.add(
|
||||
() => void registry.revoke(key, value),
|
||||
@@ -510,7 +535,10 @@ export function createPluginContext(
|
||||
);
|
||||
},
|
||||
consume: (key) => registry.consume(key),
|
||||
revoke: (key, value) => registry.revoke(key, value),
|
||||
revoke: (key, value) => {
|
||||
assertOwnRegistryKey(pluginId, key);
|
||||
return registry.revoke(key, value);
|
||||
},
|
||||
},
|
||||
|
||||
services: {
|
||||
@@ -548,8 +576,18 @@ export function createPluginContext(
|
||||
);
|
||||
},
|
||||
|
||||
get: (service, options) =>
|
||||
serviceRegistry.createServiceHandle(
|
||||
get: (service, options) => {
|
||||
// Declared, like getShared: a plugin reaches only the services its
|
||||
// manifest names in requires, or its own.
|
||||
const known =
|
||||
manifest.requires?.some((entry) => entry.service === service) ||
|
||||
manifest.provides?.some((entry) => entry.service === service);
|
||||
if (!known) {
|
||||
throw new Error(
|
||||
`Plugin ${pluginId} cannot use service "${service}": it is not declared in the manifest's requires array`,
|
||||
);
|
||||
}
|
||||
return serviceRegistry.createServiceHandle(
|
||||
service,
|
||||
pluginId,
|
||||
{
|
||||
@@ -562,7 +600,8 @@ export function createPluginContext(
|
||||
audit: (entry) => writeServiceAudit(entry),
|
||||
},
|
||||
options?.provider ?? "",
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
providers: (service) => serviceRegistry.listProviderNames(service),
|
||||
},
|
||||
@@ -709,10 +748,20 @@ export function createPluginContext(
|
||||
if (!declared.includes("network:serve")) {
|
||||
throw capabilityRefused(pluginId, "network:serve");
|
||||
}
|
||||
// Only a signed-in caller's socket counts against the error budget,
|
||||
// so an anonymous peer cannot switch the plugin off.
|
||||
const counted: typeof wsHandler = async (connection) => {
|
||||
try {
|
||||
return await wsHandler(connection);
|
||||
} catch (error) {
|
||||
if (connection.userId) void reportRuntimeError(pluginId, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
const dispose = registerPluginWsRoute(
|
||||
pluginId,
|
||||
path,
|
||||
wsHandler,
|
||||
counted,
|
||||
declared,
|
||||
options,
|
||||
);
|
||||
@@ -840,13 +889,14 @@ export function createPluginContext(
|
||||
|
||||
hosts: createPluginHosts({ manifest, bag: handle.bag, audit: auditCall }),
|
||||
|
||||
schedule: createPluginSchedule(handle.bag, (message, error) =>
|
||||
schedule: createPluginSchedule(handle.bag, (message, error) => {
|
||||
pluginLogger.error(
|
||||
message,
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
logContext,
|
||||
),
|
||||
),
|
||||
);
|
||||
void reportRuntimeError(pluginId, error);
|
||||
}),
|
||||
ssh: createPluginSsh({ manifest, bag: handle.bag, audit: auditCall }),
|
||||
notify: createPluginNotify({
|
||||
manifest,
|
||||
@@ -864,7 +914,7 @@ export function createPluginContext(
|
||||
|
||||
desktop: {
|
||||
openIsolatedWindow: (request) => desktopOpenIsolatedWindow(request),
|
||||
launchNativeRdp: (request) => desktopLaunchNativeRdp(request),
|
||||
launchExternalClient: (request) => desktopLaunchExternalClient(request),
|
||||
available: () => isElectronIpcAvailable(),
|
||||
},
|
||||
|
||||
@@ -909,6 +959,9 @@ export function createPluginContext(
|
||||
`Plugin ${pluginId} called ctx.asUser without a user id`,
|
||||
);
|
||||
}
|
||||
if (!declared.includes("users:impersonate")) {
|
||||
throw capabilityRefused(pluginId, "users:impersonate");
|
||||
}
|
||||
await writeAudit(
|
||||
manifest,
|
||||
{
|
||||
@@ -942,6 +995,14 @@ async function reportRuntimeError(
|
||||
}
|
||||
}
|
||||
|
||||
function assertOwnRegistryKey(pluginId: string, key: string): void {
|
||||
if (typeof key !== "string" || !key.startsWith(`${pluginId}.`)) {
|
||||
throw new Error(
|
||||
`Plugin ${pluginId} may only use registry keys under "${pluginId}.", not "${key}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertKvKey(key: string): void {
|
||||
if (typeof key !== "string" || key.length === 0) {
|
||||
throw new Error("ctx.kv keys must be non-empty strings");
|
||||
|
||||
+75
-11
@@ -30,14 +30,19 @@ export function registerTable(
|
||||
definition: PluginTableDefinition,
|
||||
): unknown {
|
||||
const definitions = registered.get(pluginId) ?? [];
|
||||
const existing = definitions.find((entry) => entry.name === definition.name);
|
||||
if (existing) {
|
||||
// Re-registering on re-activation is normal: the module is cached, so
|
||||
// activate runs again against the same definitions.
|
||||
return getTable(pluginId, definition.name);
|
||||
const index = definitions.findIndex(
|
||||
(entry) => entry.name === definition.name,
|
||||
);
|
||||
if (index >= 0) {
|
||||
// Re-registering on re-activation is normal. The same definition keeps
|
||||
// its table object; a changed one (an updated plugin) is rebuilt.
|
||||
if (JSON.stringify(definitions[index]) === JSON.stringify(definition)) {
|
||||
return getTable(pluginId, definition.name);
|
||||
}
|
||||
definitions[index] = definition;
|
||||
} else {
|
||||
definitions.push(definition);
|
||||
}
|
||||
|
||||
definitions.push(definition);
|
||||
registered.set(pluginId, definitions);
|
||||
|
||||
const table = buildTable(pluginId, definition);
|
||||
@@ -86,12 +91,65 @@ async function execute(statements: string[]): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Postgres runs DDL inside a transaction, so one migration file applies
|
||||
* whole or not at all. Through drizzle's transaction because a pool would
|
||||
* spread a raw BEGIN and the statements over different connections.
|
||||
*/
|
||||
async function executeInTransaction(statements: string[]): Promise<void> {
|
||||
const { getDb } = await import("../database/db/index.js");
|
||||
const db = getDb() as unknown as {
|
||||
transaction: (
|
||||
fn: (tx: {
|
||||
execute: (query: unknown) => Promise<unknown>;
|
||||
}) => Promise<void>,
|
||||
) => Promise<void>;
|
||||
};
|
||||
await db.transaction(async (tx) => {
|
||||
for (const statement of statements) {
|
||||
await tx.execute(sql.raw(statement));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* SQLite has one connection, so a raw BEGIN covers the statements and the
|
||||
* migration record together.
|
||||
*/
|
||||
async function sqliteTransaction(fn: () => Promise<void>): Promise<void> {
|
||||
const { runStatement } =
|
||||
await import("../utils/crypto-migration/raw-rows.js");
|
||||
await runStatement(sql.raw("BEGIN"));
|
||||
try {
|
||||
await fn();
|
||||
await runStatement(sql.raw("COMMIT"));
|
||||
} catch (error) {
|
||||
await runStatement(sql.raw("ROLLBACK")).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function executeNamingFailure(statements: string[]): Promise<void> {
|
||||
const { runStatement } =
|
||||
await import("../utils/crypto-migration/raw-rows.js");
|
||||
for (const [index, statement] of statements.entries()) {
|
||||
try {
|
||||
await runStatement(sql.raw(statement));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(
|
||||
`statement ${index + 1} of ${statements.length} failed (earlier ones are already applied on MySQL): ${message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the runner that applies migrations against the live database.
|
||||
*
|
||||
* No transaction on any engine: MySQL commits implicitly on DDL, and each
|
||||
* migration is recorded once it has run, so a retry after a failure starts
|
||||
* at the migration that failed.
|
||||
* SQLite and Postgres apply each migration file atomically. MySQL commits
|
||||
* implicitly on DDL, so there a failed file can leave earlier statements
|
||||
* applied; the error names the statement so it can be fixed by hand.
|
||||
*/
|
||||
async function createMigrationRunner(
|
||||
dialect: DatabaseDialect = resolveDatabaseDialect(),
|
||||
@@ -109,7 +167,13 @@ async function createMigrationRunner(
|
||||
checksum: row.checksum,
|
||||
}));
|
||||
},
|
||||
execute,
|
||||
execute:
|
||||
dialect === "postgres"
|
||||
? executeInTransaction
|
||||
: dialect === "mysql"
|
||||
? executeNamingFailure
|
||||
: execute,
|
||||
transaction: dialect === "sqlite" ? sqliteTransaction : undefined,
|
||||
record: async (pluginId, migration) => {
|
||||
await repository.record(pluginId, migration.id, migration.checksum);
|
||||
},
|
||||
|
||||
@@ -38,6 +38,43 @@ const DEFAULT_BODY_LIMIT = "2mb";
|
||||
/** Routers by plugin id, read by the dispatcher in plugin-api-routes.ts. */
|
||||
const routers = new Map<string, Router>();
|
||||
|
||||
/**
|
||||
* Requests per minute one address may make to one plugin's public routes.
|
||||
* They run without a login, so they are the cheap way to hammer a plugin.
|
||||
*/
|
||||
function publicRateLimit(): number {
|
||||
const parsed = Number(process.env.TERMIX_PLUGIN_PUBLIC_RATE_LIMIT);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 120;
|
||||
}
|
||||
const PUBLIC_RATE_WINDOW_MS = 60_000;
|
||||
const publicHits = new Map<string, { count: number; resetAt: number }>();
|
||||
|
||||
/** True when this request is over the public route limit. */
|
||||
export function overPublicRateLimit(
|
||||
pluginId: string,
|
||||
ip: string,
|
||||
now = Date.now(),
|
||||
): boolean {
|
||||
if (publicHits.size > 10_000) {
|
||||
for (const [hitKey, entry] of publicHits) {
|
||||
if (entry.resetAt <= now) publicHits.delete(hitKey);
|
||||
}
|
||||
}
|
||||
const hitKey = `${pluginId}|${ip}`;
|
||||
const entry = publicHits.get(hitKey);
|
||||
if (!entry || entry.resetAt <= now) {
|
||||
publicHits.set(hitKey, { count: 1, resetAt: now + PUBLIC_RATE_WINDOW_MS });
|
||||
return false;
|
||||
}
|
||||
entry.count++;
|
||||
return entry.count > publicRateLimit();
|
||||
}
|
||||
|
||||
/** Test seam. */
|
||||
export function resetPublicRateLimits(): void {
|
||||
publicHits.clear();
|
||||
}
|
||||
|
||||
/** Declared public paths by plugin id, for the admin plugin details. */
|
||||
const publicRoutes = new Map<string, string[]>();
|
||||
|
||||
@@ -151,6 +188,11 @@ export function createPluginRouter({
|
||||
reportError,
|
||||
}: CreatePluginRouterArgs): Router {
|
||||
const pluginId = manifest.id;
|
||||
if (routers.has(pluginId)) {
|
||||
throw new Error(
|
||||
`Plugin ${pluginId} already has a router; call ctx.http.router() once`,
|
||||
);
|
||||
}
|
||||
const declaredPublic = (options?.public ?? []).map((path) =>
|
||||
normalizePublicPath(path),
|
||||
);
|
||||
@@ -209,6 +251,11 @@ export function createPluginRouter({
|
||||
|
||||
outer.use((req: Request, res: Response, next: NextFunction) => {
|
||||
if (isPublicRequest(req, publicPaths)) {
|
||||
const ip = req.ip || req.socket?.remoteAddress || "unknown";
|
||||
if (overPublicRateLimit(pluginId, ip)) {
|
||||
res.status(429).json({ error: "Too many requests" });
|
||||
return;
|
||||
}
|
||||
pluginLogger.info(`Unauthenticated request to ${pluginId}${req.path}`, {
|
||||
operation: "plugin_http_public_request",
|
||||
});
|
||||
|
||||
@@ -29,7 +29,9 @@ let loader: PluginLoader | null = null;
|
||||
|
||||
export function getPluginRuntime(): { loader: PluginLoader } {
|
||||
if (!loader) {
|
||||
loader = new PluginLoader();
|
||||
loader = new PluginLoader({
|
||||
onFailed: (plugin) => void handlePluginFailed(plugin),
|
||||
});
|
||||
// The 503-while-disabled answer comes from here rather than from the
|
||||
// router being torn down, so a request that arrives mid-disable gets a
|
||||
// truthful status instead of a 404.
|
||||
@@ -91,6 +93,29 @@ export function getPluginRuntime(): { loader: PluginLoader } {
|
||||
return { loader };
|
||||
}
|
||||
|
||||
/**
|
||||
* A plugin that tripped the error budget is off until an admin retries it, so
|
||||
* it has to look off everywhere: no routes, greyed permissions, and a failed
|
||||
* row that survives a restart.
|
||||
*/
|
||||
async function handlePluginFailed(plugin: LoadedPlugin): Promise<void> {
|
||||
unregisterPluginHttp(plugin.id);
|
||||
try {
|
||||
if (plugin.manifest.contributes?.permissions?.length) {
|
||||
const { markPluginPermissionsDisabled } =
|
||||
await import("../utils/permission-catalog.js");
|
||||
markPluginPermissionsDisabled(plugin.id);
|
||||
}
|
||||
await persistRuntimeState([plugin]);
|
||||
} catch (error) {
|
||||
pluginLogger.error(
|
||||
`Could not record that plugin ${plugin.id} failed`,
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
{ operation: "plugin_runtime" },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gives every discovered plugin a row, and refreshes the stored manifest.
|
||||
*
|
||||
@@ -533,13 +558,10 @@ export async function installPluginArtifact(
|
||||
file: string,
|
||||
): Promise<LoadedPlugin> {
|
||||
const { loader: pluginLoader } = getPluginRuntime();
|
||||
const bundledIds = new Set(
|
||||
pluginLoader
|
||||
.list()
|
||||
.filter((plugin) => plugin.source === "bundled")
|
||||
.map((plugin) => plugin.id),
|
||||
const plugin = await pluginLoader.loadArtifact(
|
||||
file,
|
||||
pluginLoader.bundledIds(),
|
||||
);
|
||||
const plugin = await pluginLoader.loadArtifact(file, bundledIds);
|
||||
await seedPlugins([plugin]);
|
||||
await syncCapabilityGrants([plugin]);
|
||||
return plugin;
|
||||
|
||||
+129
-37
@@ -18,7 +18,8 @@ import path from "node:path";
|
||||
import semver from "semver";
|
||||
import * as tar from "tar";
|
||||
import { pluginLogger } from "../utils/logger.js";
|
||||
import { parseManifest } from "./manifest.js";
|
||||
import { isTermixCompatible, parseManifest } from "./manifest.js";
|
||||
import { getLocalVersion } from "../utils/app-version.js";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import {
|
||||
getBundledPluginsDir,
|
||||
@@ -67,6 +68,13 @@ export interface LoadedPlugin {
|
||||
lastError: string | null;
|
||||
/** Timestamps of recent runtime errors, trimmed to the window. */
|
||||
errorTimestamps: number[];
|
||||
/**
|
||||
* The .tmxplug this copy was unpacked from. Set for user plugins and for a
|
||||
* signed update that replaced a bundled plugin.
|
||||
*/
|
||||
artifact?: string;
|
||||
/** Key id that signed the artifact, when a trusted key signed it. */
|
||||
signedBy?: string;
|
||||
}
|
||||
|
||||
export interface PluginLoaderOptions {
|
||||
@@ -78,6 +86,8 @@ export interface PluginLoaderOptions {
|
||||
|
||||
export class PluginLoader {
|
||||
private readonly plugins = new Map<string, LoadedPlugin>();
|
||||
/** 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. */
|
||||
private activationOrder: string[] = [];
|
||||
|
||||
@@ -91,6 +101,16 @@ export class PluginLoader {
|
||||
return this.plugins.get(pluginId);
|
||||
}
|
||||
|
||||
/** Ids of the plugins that ship with this install, loaded or replaced. */
|
||||
bundledIds(): Set<string> {
|
||||
return new Set([
|
||||
...this.bundledVersions.keys(),
|
||||
...this.list()
|
||||
.filter((plugin) => plugin.source === "bundled")
|
||||
.map((plugin) => plugin.id),
|
||||
]);
|
||||
}
|
||||
|
||||
/** Drops a stopped plugin from the list, so a new copy can load in its place. */
|
||||
forget(pluginId: string): void {
|
||||
const plugin = this.plugins.get(pluginId);
|
||||
@@ -122,6 +142,12 @@ export class PluginLoader {
|
||||
);
|
||||
}
|
||||
|
||||
if (!isTermixCompatible(manifest.engine.termix, getLocalVersion())) {
|
||||
throw new Error(
|
||||
`Plugin ${manifest.id} needs Termix ${manifest.engine.termix}, but this is ${getLocalVersion()}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (path.basename(dir) !== manifest.id) {
|
||||
throw new Error(
|
||||
`Plugin directory "${path.basename(dir)}" does not match manifest id "${manifest.id}"`,
|
||||
@@ -141,6 +167,10 @@ export class PluginLoader {
|
||||
);
|
||||
}
|
||||
|
||||
if (this.plugins.has(manifest.id)) {
|
||||
throw new Error(`another plugin already uses the id "${manifest.id}"`);
|
||||
}
|
||||
|
||||
const overlap = [...this.plugins.values()].find((other) =>
|
||||
prefixesOverlap(other.id, manifest.id),
|
||||
);
|
||||
@@ -175,16 +205,16 @@ export class PluginLoader {
|
||||
}
|
||||
|
||||
/**
|
||||
* Scans both roots. A user-installed plugin can never shadow a bundled one:
|
||||
* the collision is rejected with an error rather than silently skipped, so
|
||||
* dropping a folder with a bundled plugin's id into the data directory is visibly
|
||||
* refused instead of quietly ignored.
|
||||
* Scans both roots. A user plugin folder can never shadow a bundled one: the
|
||||
* collision is rejected with an error rather than silently skipped.
|
||||
*
|
||||
* The check is on the directory name, which load() then requires to equal
|
||||
* the manifest id, and it covers bundled folders that failed to load too.
|
||||
* The one way to replace a bundled plugin is a .tmxplug signed by a trusted
|
||||
* key with a higher version (see loadArtifact). That is how one official
|
||||
* plugin ships an update without a core release.
|
||||
*/
|
||||
async loadAll(): Promise<LoadedPlugin[]> {
|
||||
this.plugins.clear();
|
||||
this.bundledVersions.clear();
|
||||
const loaded: LoadedPlugin[] = [];
|
||||
// Every bundled folder name, loaded or not: a bundled plugin that fails
|
||||
// to load must not leave its id free for a user plugin to take.
|
||||
@@ -216,13 +246,16 @@ export class PluginLoader {
|
||||
const dir = path.join(root, entry.name);
|
||||
try {
|
||||
if (isArtifact) {
|
||||
loaded.push(await this.loadArtifact(dir, bundledIds));
|
||||
const plugin = await this.loadArtifact(dir, bundledIds);
|
||||
const index = loaded.findIndex((other) => other.id === plugin.id);
|
||||
if (index >= 0) loaded[index] = plugin;
|
||||
else loaded.push(plugin);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (source === "user" && bundledIds.has(entry.name)) {
|
||||
throw new Error(
|
||||
`a bundled plugin already uses the id "${entry.name}"; a plugin in the data directory cannot replace it`,
|
||||
`a bundled plugin already uses the id "${entry.name}"; only a signed .tmxplug with a higher version can replace it`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -236,6 +269,9 @@ export class PluginLoader {
|
||||
}
|
||||
|
||||
const plugin = await this.load(dir, source);
|
||||
if (source === "bundled") {
|
||||
this.bundledVersions.set(plugin.id, plugin.manifest.version);
|
||||
}
|
||||
loaded.push(plugin);
|
||||
} catch (error) {
|
||||
pluginLogger.error(
|
||||
@@ -255,6 +291,10 @@ export class PluginLoader {
|
||||
* against a pinned key; with TERMIX_REQUIRE_SIGNED_PLUGINS=true it must
|
||||
* also exist. The archive is unpacked fresh on every load, so the file
|
||||
* stays the only source of truth.
|
||||
*
|
||||
* An artifact with a bundled plugin's id is an update to it. It replaces
|
||||
* the bundled copy only when a trusted key signed it and its version is
|
||||
* higher; if it then fails to load the bundled copy stays.
|
||||
*/
|
||||
async loadArtifact(
|
||||
file: string,
|
||||
@@ -262,6 +302,7 @@ export class PluginLoader {
|
||||
): Promise<LoadedPlugin> {
|
||||
const buffer = await fs.promises.readFile(file);
|
||||
const sigFile = `${file}.sig`;
|
||||
let signedBy: string | undefined;
|
||||
|
||||
if (fs.existsSync(sigFile)) {
|
||||
const sha256 = crypto.createHash("sha256").update(buffer).digest("hex");
|
||||
@@ -273,6 +314,7 @@ export class PluginLoader {
|
||||
if (result.ok === false) {
|
||||
throw new Error(`signature check failed: ${result.reason}`);
|
||||
}
|
||||
signedBy = result.keyId;
|
||||
} else if (requireSignedPlugins()) {
|
||||
throw new Error(
|
||||
`TERMIX_REQUIRE_SIGNED_PLUGINS is on and ${path.basename(file)} has no .sig next to it`,
|
||||
@@ -301,22 +343,47 @@ export class PluginLoader {
|
||||
await fs.promises.readFile(getPluginManifestPath(staging), "utf8"),
|
||||
);
|
||||
const id = typeof raw?.id === "string" ? raw.id : "";
|
||||
if (!/^[a-z0-9][a-z0-9-]*$/.test(id)) {
|
||||
if (!PLUGIN_ID_PATTERN.test(id)) {
|
||||
throw new Error(`${path.basename(file)} has no valid manifest id`);
|
||||
}
|
||||
if (bundledIds.has(id)) {
|
||||
throw new Error(
|
||||
`a bundled plugin already uses the id "${id}"; a plugin in the data directory cannot replace it`,
|
||||
|
||||
const isUpdate = bundledIds.has(id);
|
||||
const replaced = isUpdate ? this.plugins.get(id) : undefined;
|
||||
if (isUpdate) {
|
||||
assertBundledUpdate(
|
||||
id,
|
||||
raw?.version,
|
||||
signedBy,
|
||||
this.bundledVersions.get(id) ?? replaced?.manifest.version,
|
||||
);
|
||||
}
|
||||
if (this.plugins.has(id)) {
|
||||
} else if (this.plugins.has(id)) {
|
||||
throw new Error(`another plugin already uses the id "${id}"`);
|
||||
}
|
||||
if (replaced && replaced.state !== "loaded") {
|
||||
throw new Error(`plugin ${id} is running; stop it before updating`);
|
||||
}
|
||||
|
||||
const target = path.join(unpackedRoot, id);
|
||||
await fs.promises.rm(target, { recursive: true, force: true });
|
||||
await fs.promises.rename(staging, target);
|
||||
return await this.load(target, "user");
|
||||
|
||||
if (replaced) this.plugins.delete(id);
|
||||
try {
|
||||
// A signed update keeps the bundled trust it replaces.
|
||||
const plugin = await this.load(target, isUpdate ? "bundled" : "user");
|
||||
plugin.artifact = file;
|
||||
plugin.signedBy = signedBy;
|
||||
if (replaced) {
|
||||
pluginLogger.info(
|
||||
`Plugin ${id} updated from ${replaced.manifest.version} to ${plugin.manifest.version}`,
|
||||
{ operation: "plugin_load" },
|
||||
);
|
||||
}
|
||||
return plugin;
|
||||
} catch (error) {
|
||||
if (replaced) this.plugins.set(id, replaced);
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
await fs.promises.rm(staging, { recursive: true, force: true });
|
||||
}
|
||||
@@ -427,9 +494,12 @@ export class PluginLoader {
|
||||
plugin.state = "activating";
|
||||
plugin.lastError = null;
|
||||
|
||||
const entry = pathToFileUrlString(
|
||||
getPluginBackendEntry(plugin.dir, plugin.manifest),
|
||||
);
|
||||
const entryPath = getPluginBackendEntry(plugin.dir, plugin.manifest);
|
||||
// Node caches ESM by URL, so a plugin replaced without a restart would
|
||||
// otherwise keep running the old code.
|
||||
const entry = `${pathToFileUrlString(entryPath)}?v=${encodeURIComponent(
|
||||
`${plugin.manifest.version}-${fileStamp(entryPath)}`,
|
||||
)}`;
|
||||
|
||||
try {
|
||||
const imported = (await import(entry)) as {
|
||||
@@ -639,27 +709,12 @@ export class PluginLoader {
|
||||
this.options.onFailed?.(plugin);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a plugin callback so a throw is counted rather than escaping into
|
||||
* whichever core call site invoked it.
|
||||
*/
|
||||
wrap<Args extends unknown[], Result>(
|
||||
pluginId: string,
|
||||
fn: (...args: Args) => Result | Promise<Result>,
|
||||
): (...args: Args) => Promise<Result | undefined> {
|
||||
return async (...args: Args) => {
|
||||
try {
|
||||
return await fn(...args);
|
||||
} catch (error) {
|
||||
await this.reportError(pluginId, error);
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** Clears the error budget and starts the plugin again. */
|
||||
async retry(pluginId: string): Promise<void> {
|
||||
const plugin = this.requirePlugin(pluginId);
|
||||
if (plugin.state === "activating") {
|
||||
throw new Error(`Plugin ${pluginId} is still activating`);
|
||||
}
|
||||
plugin.errorTimestamps = [];
|
||||
plugin.lastError = null;
|
||||
plugin.state = "loaded";
|
||||
@@ -709,6 +764,43 @@ function withTimeout<T>(
|
||||
});
|
||||
}
|
||||
|
||||
const PLUGIN_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
|
||||
|
||||
/**
|
||||
* Throws unless an artifact may replace the bundled plugin with the same id:
|
||||
* a trusted key signed it and its version is strictly higher, so an old
|
||||
* signed build can never be replayed as a downgrade.
|
||||
*/
|
||||
export function assertBundledUpdate(
|
||||
id: string,
|
||||
version: unknown,
|
||||
signedBy: string | undefined,
|
||||
bundledVersion: string | undefined,
|
||||
): void {
|
||||
if (!signedBy) {
|
||||
throw new Error(
|
||||
`a bundled plugin already uses the id "${id}"; only a signed update can replace it`,
|
||||
);
|
||||
}
|
||||
if (typeof version !== "string" || !semver.valid(version)) {
|
||||
throw new Error(`update for "${id}" has no valid version`);
|
||||
}
|
||||
if (bundledVersion && !semver.gt(version, bundledVersion)) {
|
||||
throw new Error(
|
||||
`update for "${id}" is ${version}, which is not newer than the bundled ${bundledVersion}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function fileStamp(filePath: string): string {
|
||||
try {
|
||||
const stat = fs.statSync(filePath);
|
||||
return `${stat.size}-${Math.round(stat.mtimeMs)}`;
|
||||
} catch {
|
||||
return "0";
|
||||
}
|
||||
}
|
||||
|
||||
function pathToFileUrlString(filePath: string): string {
|
||||
const resolved = path.resolve(filePath).replace(/\\/g, "/");
|
||||
return `file://${resolved.startsWith("/") ? "" : "/"}${resolved}`;
|
||||
|
||||
@@ -11,6 +11,9 @@ export {
|
||||
validateManifest,
|
||||
parseManifest,
|
||||
SUPPORTED_PLUGIN_API_VERSION,
|
||||
PLUGIN_API_VERSION,
|
||||
isApiCompatible,
|
||||
isTermixCompatible,
|
||||
SYSTEM_ROLE_NAMES,
|
||||
RESERVED_PERMISSION_PREFIXES,
|
||||
qualifyPermission,
|
||||
|
||||
@@ -20,6 +20,7 @@ import { pluginLogger } from "../utils/logger.js";
|
||||
import type { DatabaseDialect } from "../database/db/dialect.js";
|
||||
import { LEGACY_TABLE_OWNERS } from "@termix/plugin-sdk/db";
|
||||
import {
|
||||
collectOwnedIndexes,
|
||||
findUnownedTableWrites,
|
||||
splitStatements,
|
||||
} from "@termix/plugin-sdk/ddl";
|
||||
@@ -28,9 +29,6 @@ import {
|
||||
// migration exactly the way this runner does.
|
||||
export { LEGACY_TABLE_OWNERS, splitStatements };
|
||||
|
||||
/** Dialect directory names, matching DatabaseDialect exactly. */
|
||||
const DIALECTS: readonly DatabaseDialect[] = ["sqlite", "postgres", "mysql"];
|
||||
|
||||
const FILE_PATTERN = /^(\d{4})_([a-z0-9_]+)\.sql$/;
|
||||
|
||||
export interface PluginMigration {
|
||||
@@ -120,7 +118,7 @@ export function readMigrations(
|
||||
export function assertOwnedTables(
|
||||
pluginId: string,
|
||||
sql: string,
|
||||
options: { bundled?: boolean } = {},
|
||||
options: { bundled?: boolean; ownedIndexes?: ReadonlySet<string> } = {},
|
||||
): void {
|
||||
const legacy = new Set(
|
||||
options.bundled
|
||||
@@ -129,16 +127,19 @@ export function assertOwnedTables(
|
||||
.map(([table]) => table)
|
||||
: [],
|
||||
);
|
||||
const problems = findUnownedTableWrites(pluginId, sql, legacy).map(
|
||||
(problem) => {
|
||||
// Name the owner when the table is another plugin's legacy table.
|
||||
const table = /writes to "([^"]+)"/.exec(problem)?.[1];
|
||||
const owner = table ? LEGACY_TABLE_OWNERS[table] : undefined;
|
||||
return owner && owner !== pluginId
|
||||
? `may not touch "${table}": it belongs to the "${owner}" plugin`
|
||||
: problem;
|
||||
},
|
||||
);
|
||||
const problems = findUnownedTableWrites(
|
||||
pluginId,
|
||||
sql,
|
||||
legacy,
|
||||
options.ownedIndexes,
|
||||
).map((problem) => {
|
||||
// Name the owner when the table is another plugin's legacy table.
|
||||
const table = /writes to "([^"]+)"/.exec(problem)?.[1];
|
||||
const owner = table ? LEGACY_TABLE_OWNERS[table] : undefined;
|
||||
return owner && owner !== pluginId
|
||||
? `may not touch "${table}": it belongs to the "${owner}" plugin`
|
||||
: problem;
|
||||
});
|
||||
if (problems.length > 0) {
|
||||
throw new Error(
|
||||
`Plugin "${pluginId}" migration refused: ${problems.join("; ")}`,
|
||||
@@ -197,9 +198,13 @@ export async function applyPluginMigrations(
|
||||
|
||||
if (pending.length === 0) return [];
|
||||
|
||||
const ownedIndexes = collectOwnedIndexes(
|
||||
pluginId,
|
||||
migrations.map((migration) => migration.sql),
|
||||
);
|
||||
const run = async () => {
|
||||
for (const migration of pending) {
|
||||
assertOwnedTables(pluginId, migration.sql, options);
|
||||
assertOwnedTables(pluginId, migration.sql, { ...options, ownedIndexes });
|
||||
const statements = splitStatements(migration.sql);
|
||||
await runner.execute(statements);
|
||||
await runner.record(pluginId, migration);
|
||||
|
||||
@@ -1,19 +1,11 @@
|
||||
/**
|
||||
* A small main-thread service registry.
|
||||
* A small in-process key/value registry for live objects plugins hand each
|
||||
* other (a guest-link resolver, a host import normalizer). Services with
|
||||
* versions and permissions go through service-registry.ts instead.
|
||||
*
|
||||
* This exists so a module can depend on a capability without depending on
|
||||
* whichever module happens to provide it. Two uses today:
|
||||
*
|
||||
* - core registers the SSH connection pool as "ssh.transport" at boot, so
|
||||
* everything that needs a pooled connection resolves a provider rather
|
||||
* than importing the pool directly.
|
||||
* - the session-sharing plugin publishes "sessions.sharing.guests", guest
|
||||
* link resolution for the terminal, keyed by a token rather than a user.
|
||||
*
|
||||
* Deliberately NOT reachable from a worker plugin's ctx. Values here are live
|
||||
* objects -- pools, session managers, sockets -- and handing one across the
|
||||
* postMessage boundary is exactly what that boundary exists to prevent. Only
|
||||
* in-process first-party plugins get a reference to this.
|
||||
* ctx.registry only lets a plugin provide or revoke keys under its own id
|
||||
* ("<pluginId>.something"); anyone may consume. Core also reads a few
|
||||
* well-known per-plugin keys, such as "<pluginId>.hostImportNormalizer".
|
||||
*
|
||||
* `consume` returns undefined rather than throwing when nothing is registered.
|
||||
* A provider can disappear when its plugin is disabled, and callers are
|
||||
|
||||
@@ -23,6 +23,10 @@ export interface UserOwnedTable {
|
||||
/** The legacy core name the table adopted, for older export files. */
|
||||
legacyName?: string;
|
||||
userColumn: string;
|
||||
/** Other refUser columns, such as a grantee or the user who shared. */
|
||||
otherUserColumns: string[];
|
||||
/** refHost columns. */
|
||||
hostColumns: string[];
|
||||
/** Columns that travel, as SQL names. The primary key and secrets do not. */
|
||||
columns: string[];
|
||||
definition: PluginTableDefinition;
|
||||
@@ -39,6 +43,13 @@ export function listUserOwnedTables(): UserOwnedTable[] {
|
||||
table: prefixedTableName(pluginId, definition.name),
|
||||
legacyName: definition.adopts,
|
||||
userColumn: columnName(user[0], user[1]),
|
||||
otherUserColumns: entries
|
||||
.filter(([, column]) => column.type === "refUser")
|
||||
.slice(1)
|
||||
.map(([property, column]) => columnName(property, column)),
|
||||
hostColumns: entries
|
||||
.filter(([, column]) => column.type === "refHost")
|
||||
.map(([property, column]) => columnName(property, column)),
|
||||
columns: entries
|
||||
.filter(
|
||||
([, column]) =>
|
||||
@@ -130,10 +141,40 @@ export interface PluginRowsImport {
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a row from an import file may be written as `userId`. The file is
|
||||
* user input, so a reference to anyone else's user or host is not trusted:
|
||||
* other user columns must point back at the row's own owner, and host columns
|
||||
* at a host the importing user owns.
|
||||
*/
|
||||
async function rowReferencesAllowed(
|
||||
owned: UserOwnedTable,
|
||||
source: Row,
|
||||
userId: string,
|
||||
): Promise<boolean> {
|
||||
const owner = source[owned.userColumn];
|
||||
for (const column of owned.otherUserColumns) {
|
||||
const value = source[column];
|
||||
if (value !== null && value !== undefined && value !== owner) return false;
|
||||
}
|
||||
for (const column of owned.hostColumns) {
|
||||
const value = source[column];
|
||||
if (value === null || value === undefined) continue;
|
||||
const rows = await selectRows(
|
||||
sql`SELECT 1 FROM ${sql.identifier("ssh_data")} WHERE ${sql.identifier(
|
||||
"id",
|
||||
)} = ${value} AND ${sql.identifier("user_id")} = ${userId} LIMIT 1`,
|
||||
);
|
||||
if (rows.length === 0) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies plugin rows from an import file into this server as `userId`. A row
|
||||
* matching one the user already has is skipped. Files from before 2.9 carry
|
||||
* the legacy table names, which are read too.
|
||||
* matching one the user already has is skipped, and so is one that points at
|
||||
* another user or at a host the importer does not own. Files from before 2.9
|
||||
* carry the legacy table names, which are read too.
|
||||
*/
|
||||
export async function importUserPluginRows(
|
||||
importDb: SqliteLike,
|
||||
@@ -157,10 +198,18 @@ export async function importUserPluginRows(
|
||||
const columns = owned.columns.filter(
|
||||
(column) => column === owned.userColumn || source[column] !== undefined,
|
||||
);
|
||||
const owner = source[owned.userColumn];
|
||||
const values = columns.map((column) =>
|
||||
column === owned.userColumn ? userId : source[column],
|
||||
column === owned.userColumn ||
|
||||
(owned.otherUserColumns.includes(column) && source[column] === owner)
|
||||
? userId
|
||||
: source[column],
|
||||
);
|
||||
try {
|
||||
if (!(await rowReferencesAllowed(owned, source, userId))) {
|
||||
summary.skipped++;
|
||||
continue;
|
||||
}
|
||||
const existing = await selectRows(
|
||||
sql`SELECT 1 FROM ${sql.identifier(owned.table)} WHERE ${sql.join(
|
||||
columns.map((column, index) =>
|
||||
|
||||
@@ -49,8 +49,13 @@ interface Route {
|
||||
declared: readonly string[];
|
||||
handler?: PluginWebSocketHandler;
|
||||
rawHandler?: RawUpgradeHandler;
|
||||
/** Sockets handed to rawHandler, destroyed when the route goes away. */
|
||||
rawSockets?: Set<Duplex>;
|
||||
}
|
||||
|
||||
/** Largest frame a plugin socket accepts. ws defaults to 100MB. */
|
||||
const MAX_PAYLOAD_BYTES = 64 * 1024 * 1024;
|
||||
|
||||
/** Keyed by "<pluginId>:<path>". */
|
||||
const routes = new Map<string, Route>();
|
||||
|
||||
@@ -177,6 +182,11 @@ async function handlePluginUpgrade(
|
||||
|
||||
if (route.rawHandler) {
|
||||
const rawHandler = route.rawHandler;
|
||||
const tracked = route.rawSockets;
|
||||
if (tracked) {
|
||||
tracked.add(socket);
|
||||
socket.once("close", () => tracked.delete(socket));
|
||||
}
|
||||
asCaller(() => rawHandler(request, socket, head, userId));
|
||||
return true;
|
||||
}
|
||||
@@ -270,7 +280,10 @@ export function registerPluginWsRoute(
|
||||
handler,
|
||||
});
|
||||
if (options.public) auditPublicSocket(pluginId, normalized);
|
||||
servers.set(routeKey, new WebSocketServer({ noServer: true }));
|
||||
servers.set(
|
||||
routeKey,
|
||||
new WebSocketServer({ noServer: true, maxPayload: MAX_PAYLOAD_BYTES }),
|
||||
);
|
||||
|
||||
pluginLogger.info(`Mounted /plugin-ws/${pluginId}${normalized}`, {
|
||||
operation: "plugin_ws_mount",
|
||||
@@ -301,6 +314,7 @@ export function registerPluginWsUpgrade(
|
||||
options,
|
||||
declared,
|
||||
rawHandler: handler,
|
||||
rawSockets: new Set(),
|
||||
});
|
||||
if (options.public) auditPublicSocket(pluginId, normalized);
|
||||
|
||||
@@ -312,8 +326,18 @@ export function registerPluginWsUpgrade(
|
||||
}
|
||||
|
||||
function disposeRoute(routeKey: string, pluginId: string, path: string): void {
|
||||
const route = routes.get(routeKey);
|
||||
routes.delete(routeKey);
|
||||
|
||||
for (const socket of route?.rawSockets ?? []) {
|
||||
try {
|
||||
socket.destroy();
|
||||
} catch {
|
||||
// Already gone.
|
||||
}
|
||||
}
|
||||
route?.rawSockets?.clear();
|
||||
|
||||
const server = servers.get(routeKey);
|
||||
if (server) {
|
||||
servers.delete(routeKey);
|
||||
|
||||
@@ -32,6 +32,8 @@ export interface RemotePlugin {
|
||||
name: string;
|
||||
version: string;
|
||||
source: "bundled" | "user";
|
||||
/** The server can send a signed .tmxplug for it. */
|
||||
artifact?: boolean;
|
||||
enabled: boolean;
|
||||
active: boolean;
|
||||
desktop: PluginDesktopMode;
|
||||
@@ -52,6 +54,8 @@ async function isEnabledLocally(pluginId: string): Promise<boolean> {
|
||||
return record?.state === "enabled";
|
||||
}
|
||||
|
||||
const PLUGIN_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
|
||||
|
||||
async function download(link: SyncLink, plugin: RemotePlugin): Promise<string> {
|
||||
const response = await remoteFetch(
|
||||
link,
|
||||
@@ -83,6 +87,9 @@ async function matchGrants(plugin: RemotePlugin): Promise<void> {
|
||||
const repository = createCurrentPluginPermissionGrantRepository();
|
||||
const local = getPluginRuntime().loader.get(plugin.id);
|
||||
if (!local || local.source !== "user") return;
|
||||
// Grants are only copied for code a trusted key signed. Anything else the
|
||||
// desktop's own admin has to review, as with any plugin dropped on disk.
|
||||
if (!local.signedBy) return;
|
||||
const declared = new Set(local.manifest.capabilities);
|
||||
const wanted = new Set(plugin.granted.filter((cap) => declared.has(cap)));
|
||||
const existing = await repository.listByPlugin(plugin.id);
|
||||
@@ -117,7 +124,25 @@ async function alertOlderDesktop(plugin: RemotePlugin): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
async function alertUnsigned(plugin: RemotePlugin): Promise<void> {
|
||||
const key = `unsigned:${plugin.id}@${plugin.version}`;
|
||||
if (alertedVersions.has(key)) return;
|
||||
alertedVersions.add(key);
|
||||
await sendCoreAlert({
|
||||
title: "Review a feature from your server",
|
||||
body: `${plugin.name} (${plugin.version}) was copied from your server but is not signed, so it stays off here until you turn it on.`,
|
||||
severity: "warning",
|
||||
category: "termix.sync.unsigned_plugin",
|
||||
dedupeKey: `sync:${key}`,
|
||||
audience: "admins",
|
||||
});
|
||||
}
|
||||
|
||||
async function mirrorOne(link: SyncLink, plugin: RemotePlugin): Promise<void> {
|
||||
// The id becomes a file name below, so it must be a real plugin id.
|
||||
if (typeof plugin.id !== "string" || !PLUGIN_ID_PATTERN.test(plugin.id)) {
|
||||
throw new Error("The server sent a plugin with an invalid id");
|
||||
}
|
||||
const { loader } = getPluginRuntime();
|
||||
let local = loader.get(plugin.id);
|
||||
const mode = local?.manifest.desktop ?? plugin.desktop ?? "mirror";
|
||||
@@ -131,7 +156,20 @@ async function mirrorOne(link: SyncLink, plugin: RemotePlugin): Promise<void> {
|
||||
if (mode !== "mirror") return;
|
||||
|
||||
if (plugin.source === "bundled") {
|
||||
if (!local || semver.gt(plugin.version, local.manifest.version)) {
|
||||
const newer = !local || semver.gt(plugin.version, local.manifest.version);
|
||||
if (newer && plugin.artifact && local?.state !== "activating") {
|
||||
// A signed update of a bundled plugin. The loader checks the signature
|
||||
// and that the version really is newer.
|
||||
const wasEnabled = await isEnabledLocally(plugin.id);
|
||||
if (local) await unloadPlugin(plugin.id);
|
||||
try {
|
||||
local = await installPluginArtifact(await download(link, plugin));
|
||||
} catch (error) {
|
||||
await alertOlderDesktop(plugin);
|
||||
throw error;
|
||||
}
|
||||
if (wasEnabled) await setPluginEnabled(plugin.id, true);
|
||||
} else if (newer) {
|
||||
await alertOlderDesktop(plugin);
|
||||
}
|
||||
if (!local) return;
|
||||
@@ -150,6 +188,16 @@ async function mirrorOne(link: SyncLink, plugin: RemotePlugin): Promise<void> {
|
||||
}
|
||||
}
|
||||
await matchGrants(plugin);
|
||||
// Unsigned code is only ever switched off from here, never on.
|
||||
if (!local.signedBy) {
|
||||
const enabledHere = await isEnabledLocally(plugin.id);
|
||||
if (!plugin.enabled && enabledHere) {
|
||||
await setPluginEnabled(plugin.id, false);
|
||||
} else if (plugin.enabled && !enabledHere) {
|
||||
await alertUnsigned(plugin);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!local) return;
|
||||
|
||||
@@ -493,6 +493,8 @@ router.get(
|
||||
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",
|
||||
@@ -517,7 +519,7 @@ router.get(
|
||||
* /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, not bundled ones. 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.
|
||||
* 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:
|
||||
@@ -541,11 +543,10 @@ router.get(
|
||||
const plugin = (await listPluginsForDesktop()).find(
|
||||
(candidate) => candidate.id === pluginId,
|
||||
);
|
||||
if (!plugin || plugin.source !== "user") {
|
||||
if (!plugin || (plugin.source !== "user" && !plugin.artifact)) {
|
||||
return res.status(404).json({ error: "Plugin not found" });
|
||||
}
|
||||
const { getPluginsDir } = await import("../../plugins/paths.js");
|
||||
const artifact = path.join(getPluginsDir(), `${pluginId}.tmxplug`);
|
||||
const artifact = plugin.artifact ?? "";
|
||||
res.setHeader("Content-Type", "application/gzip");
|
||||
if (fs.existsSync(artifact)) {
|
||||
const sig = `${artifact}.sig`;
|
||||
|
||||
@@ -43,7 +43,7 @@ export function fakeFactory(state: AuthState) {
|
||||
findById: async (id: string) => state.users.get(id) ?? null,
|
||||
findByUsername: async (username: string) =>
|
||||
[...state.users.values()].find((u) => u.username === username) ?? null,
|
||||
findByOidcIdentifier: async (identifier: string) =>
|
||||
findByExternalIdentifier: async (identifier: string) =>
|
||||
[...state.users.values()].find(
|
||||
(u) => u.oidcIdentifier === identifier,
|
||||
) ?? null,
|
||||
@@ -206,10 +206,10 @@ export function fakeAuthManager(state: AuthState) {
|
||||
return userId ? { userId, pendingTOTP: true } : null;
|
||||
}),
|
||||
authenticateUser: vi.fn(async () => true),
|
||||
authenticateOIDCUser: vi.fn(async () => true),
|
||||
revokeSessionsByOidc: vi.fn(async () => 1),
|
||||
authenticateExternalUser: vi.fn(async () => true),
|
||||
revokeSessionsByExternalSession: vi.fn(async () => 1),
|
||||
unlockWithSystemKey: vi.fn(async () => true),
|
||||
registerOIDCUser: vi.fn(async () => {}),
|
||||
registerExternalUser: vi.fn(async () => {}),
|
||||
isTrustedDevice: vi.fn(async (userId: string, fingerprint: string) =>
|
||||
state.trusted.has(`${userId}:${fingerprint}`),
|
||||
),
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { TestSqliteDatabase } from "./test-support.js";
|
||||
import { PluginInstallCountRepository } from "../../../database/repositories/plugin-install-count-repository.js";
|
||||
|
||||
describe("PluginInstallCountRepository", () => {
|
||||
let adapter: TestSqliteDatabase | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (adapter) {
|
||||
await adapter.close();
|
||||
adapter = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function createRepository(
|
||||
onWrite?: () => void | Promise<void>,
|
||||
): Promise<PluginInstallCountRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
return new PluginInstallCountRepository(context, onWrite);
|
||||
}
|
||||
|
||||
it("inserts a count on first upsert", async () => {
|
||||
let writes = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writes += 1;
|
||||
});
|
||||
|
||||
const created = await repo.upsert({
|
||||
pluginId: "plugin-a",
|
||||
registryId: "registry-official",
|
||||
count: 42,
|
||||
source: "github-releases",
|
||||
});
|
||||
|
||||
expect(created).toMatchObject({
|
||||
pluginId: "plugin-a",
|
||||
registryId: "registry-official",
|
||||
count: 42,
|
||||
source: "github-releases",
|
||||
});
|
||||
expect(writes).toBe(1);
|
||||
});
|
||||
|
||||
it("updates the existing row on a repeat upsert for the same plugin/registry pair", async () => {
|
||||
let writes = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writes += 1;
|
||||
});
|
||||
|
||||
const first = await repo.upsert({
|
||||
pluginId: "plugin-a",
|
||||
registryId: "registry-official",
|
||||
count: 10,
|
||||
});
|
||||
|
||||
const second = await repo.upsert({
|
||||
pluginId: "plugin-a",
|
||||
registryId: "registry-official",
|
||||
count: 25,
|
||||
});
|
||||
|
||||
expect(second.id).toBe(first.id);
|
||||
expect(second.count).toBe(25);
|
||||
expect(writes).toBe(2);
|
||||
|
||||
const rows = await repo.listByPlugin("plugin-a");
|
||||
expect(rows).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps counts from different registries for the same plugin separate", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
await repo.upsert({
|
||||
pluginId: "plugin-a",
|
||||
registryId: "registry-official",
|
||||
count: 10,
|
||||
});
|
||||
await repo.upsert({
|
||||
pluginId: "plugin-a",
|
||||
registryId: "registry-community",
|
||||
count: 5,
|
||||
});
|
||||
|
||||
const rows = await repo.listByPlugin("plugin-a");
|
||||
expect(rows.map((r) => r.registryId).sort()).toEqual([
|
||||
"registry-community",
|
||||
"registry-official",
|
||||
]);
|
||||
|
||||
expect(
|
||||
await repo.findByPluginAndRegistry("plugin-a", "registry-official"),
|
||||
).toMatchObject({ count: 10 });
|
||||
expect(
|
||||
await repo.findByPluginAndRegistry("plugin-a", "missing-registry"),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("deletes all counts for a plugin", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
await repo.upsert({
|
||||
pluginId: "plugin-a",
|
||||
registryId: "registry-official",
|
||||
count: 10,
|
||||
});
|
||||
await repo.upsert({
|
||||
pluginId: "plugin-a",
|
||||
registryId: "registry-community",
|
||||
count: 5,
|
||||
});
|
||||
|
||||
expect(await repo.deleteByPlugin("plugin-a")).toBe(2);
|
||||
expect(await repo.listByPlugin("plugin-a")).toEqual([]);
|
||||
expect(await repo.deleteByPlugin("plugin-a")).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,128 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { TestSqliteDatabase } from "./test-support.js";
|
||||
import { PluginRegistryRepository } from "../../../database/repositories/plugin-registry-repository.js";
|
||||
|
||||
describe("PluginRegistryRepository", () => {
|
||||
let adapter: TestSqliteDatabase | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (adapter) {
|
||||
await adapter.close();
|
||||
adapter = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function createRepository(
|
||||
onWrite?: () => void | Promise<void>,
|
||||
): Promise<PluginRegistryRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
return new PluginRegistryRepository(context, onWrite);
|
||||
}
|
||||
|
||||
it("creates, finds, and lists registries", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
await repo.create({
|
||||
id: "registry-official",
|
||||
name: "Official Registry",
|
||||
url: "https://plugins.termix.site/index.json",
|
||||
kind: "official",
|
||||
});
|
||||
await repo.create({
|
||||
id: "registry-custom",
|
||||
name: "Custom Registry",
|
||||
url: "https://example.com/index.json",
|
||||
});
|
||||
|
||||
const found = await repo.findById("registry-official");
|
||||
expect(found).toMatchObject({
|
||||
id: "registry-official",
|
||||
name: "Official Registry",
|
||||
kind: "official",
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const custom = await repo.findById("registry-custom");
|
||||
expect(custom).toMatchObject({ kind: "community", enabled: true });
|
||||
|
||||
const all = await repo.listAll();
|
||||
expect(all.map((r) => r.id).sort()).toEqual([
|
||||
"registry-custom",
|
||||
"registry-official",
|
||||
]);
|
||||
expect(await repo.findById("missing")).toBeNull();
|
||||
});
|
||||
|
||||
it("updates only the provided fields", async () => {
|
||||
let writes = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writes += 1;
|
||||
});
|
||||
|
||||
await repo.create({
|
||||
id: "registry-custom",
|
||||
name: "Custom Registry",
|
||||
url: "https://example.com/index.json",
|
||||
});
|
||||
expect(writes).toBe(1);
|
||||
|
||||
const updated = await repo.update("registry-custom", {
|
||||
enabled: false,
|
||||
lastCheckedAt: "2026-01-01T00:00:00.000Z",
|
||||
lastIndexHash: "abc123",
|
||||
});
|
||||
|
||||
expect(updated).toMatchObject({
|
||||
id: "registry-custom",
|
||||
name: "Custom Registry",
|
||||
enabled: false,
|
||||
lastCheckedAt: "2026-01-01T00:00:00.000Z",
|
||||
lastIndexHash: "abc123",
|
||||
});
|
||||
expect(writes).toBe(2);
|
||||
|
||||
expect(await repo.update("missing", { enabled: false })).toBeNull();
|
||||
expect(writes).toBe(2);
|
||||
});
|
||||
|
||||
it("deletes a registry and reports whether a row was removed", async () => {
|
||||
let writes = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writes += 1;
|
||||
});
|
||||
|
||||
await repo.create({
|
||||
id: "registry-custom",
|
||||
name: "Custom Registry",
|
||||
url: "https://example.com/index.json",
|
||||
});
|
||||
expect(writes).toBe(1);
|
||||
|
||||
expect(await repo.delete("missing")).toBe(false);
|
||||
expect(writes).toBe(1);
|
||||
|
||||
expect(await repo.delete("registry-custom")).toBe(true);
|
||||
expect(writes).toBe(2);
|
||||
expect(await repo.findById("registry-custom")).toBeNull();
|
||||
});
|
||||
|
||||
it("deletes all registries and returns the count removed", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
await repo.create({
|
||||
id: "registry-a",
|
||||
name: "A",
|
||||
url: "https://a.example.com/index.json",
|
||||
});
|
||||
await repo.create({
|
||||
id: "registry-b",
|
||||
name: "B",
|
||||
url: "https://b.example.com/index.json",
|
||||
});
|
||||
|
||||
expect(await repo.deleteAll()).toBe(2);
|
||||
expect(await repo.listAll()).toEqual([]);
|
||||
expect(await repo.deleteAll()).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -67,7 +67,7 @@ describe("UserRepository and SessionRepository", () => {
|
||||
oidcIdentifier: "oidc:admin",
|
||||
});
|
||||
expect(updated?.oidcIdentifier).toBe("oidc:admin");
|
||||
expect((await repo.users.findByOidcIdentifier("oidc:admin"))?.id).toBe(
|
||||
expect((await repo.users.findByExternalIdentifier("oidc:admin"))?.id).toBe(
|
||||
"user-1",
|
||||
);
|
||||
|
||||
|
||||
+35
-3
@@ -40,6 +40,8 @@ vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
},
|
||||
}),
|
||||
createCurrentUserAuthRepository: () => ({
|
||||
listIdentitiesForUser: async (userId: string) =>
|
||||
h.identities.filter((row) => row.userId === userId),
|
||||
moveIdentities: async (from: string, to: string) => {
|
||||
for (const row of h.identities) if (row.userId === from) row.userId = to;
|
||||
},
|
||||
@@ -68,8 +70,8 @@ vi.mock("../../../utils/logger.js", () => {
|
||||
return { authLogger: log };
|
||||
});
|
||||
|
||||
const { registerUserOidcAccountRoutes } =
|
||||
await import("../../../database/routes/user-oidc-account-routes.js");
|
||||
const { registerUserExternalAccountRoutes } =
|
||||
await import("../../../database/routes/user-external-account-routes.js");
|
||||
|
||||
let server: http.Server;
|
||||
let base: string;
|
||||
@@ -114,7 +116,7 @@ beforeEach(async () => {
|
||||
|
||||
const router = express.Router();
|
||||
router.use(express.json());
|
||||
registerUserOidcAccountRoutes(router, {
|
||||
registerUserExternalAccountRoutes(router, {
|
||||
authenticateJWT: (req, _res, next) => {
|
||||
(req as unknown as { userId: string }).userId =
|
||||
req.header("x-user") ?? "";
|
||||
@@ -164,6 +166,24 @@ describe("linking an external account to a password account", () => {
|
||||
expect(h.deleted).toEqual(["sso"]);
|
||||
});
|
||||
|
||||
it("takes the new path and field name", async () => {
|
||||
const response = await post("/link-external-to-password", {
|
||||
externalUserId: "sso",
|
||||
targetUsername: "alice",
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
expect(h.identities[0].userId).toBe("local");
|
||||
});
|
||||
|
||||
it("refuses a target that already signs in externally", async () => {
|
||||
h.identities.push({ userId: "local", providerId: "ldap:4", subject: "x" });
|
||||
const response = await post("/link-external-to-password", {
|
||||
externalUserId: "sso",
|
||||
targetUsername: "alice",
|
||||
});
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it("is for admins only", async () => {
|
||||
const response = await post(
|
||||
"/link-oidc-to-password",
|
||||
@@ -200,6 +220,18 @@ describe("unlinking", () => {
|
||||
expect(h.identities).toEqual([]);
|
||||
});
|
||||
|
||||
it("takes the new path", async () => {
|
||||
await post("/link-external-to-password", {
|
||||
externalUserId: "sso",
|
||||
targetUsername: "alice",
|
||||
});
|
||||
const response = await post("/unlink-external-from-password", {
|
||||
userId: "local",
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
expect(h.identities).toEqual([]);
|
||||
});
|
||||
|
||||
it("refuses to leave an account with no way in", async () => {
|
||||
const response = await post("/unlink-oidc-from-password", {
|
||||
userId: "sso",
|
||||
@@ -1,21 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { validateDefaultsJson } from "../../../database/routes/user-preferences.js";
|
||||
|
||||
describe("user connection defaults validation", () => {
|
||||
it("accepts JSON objects", () => {
|
||||
expect(validateDefaultsJson('{"fontSize":16}')).toBe(true);
|
||||
expect(validateDefaultsJson("{}")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects malformed JSON and non-object values", () => {
|
||||
expect(validateDefaultsJson("{")).toBe(false);
|
||||
expect(validateDefaultsJson("null")).toBe(false);
|
||||
expect(validateDefaultsJson("[]")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects payloads larger than 32 KiB", () => {
|
||||
expect(
|
||||
validateDefaultsJson(JSON.stringify({ value: "x".repeat(32_768) })),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -165,6 +165,7 @@ function startBackend(
|
||||
describe("local-files transfer target resolution", () => {
|
||||
const resolve = localFiles.createTargetResolver({
|
||||
localBaseUrl: "http://127.0.0.1:30001/plugin-api/file-manager",
|
||||
apiPath: "/plugin-api/file-manager",
|
||||
getLinkedServer: () => ({
|
||||
serverUrl: "https://termix.example.com/",
|
||||
token: "remote-jwt",
|
||||
@@ -1149,3 +1150,17 @@ describe("local-files upload boundary", () => {
|
||||
expect(result.error).toMatch(/Unknown transfer origin/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("local-files transfer API path", () => {
|
||||
it("accepts only a /plugin-api/<id> path from the plugin", () => {
|
||||
const set = (
|
||||
localFiles as unknown as {
|
||||
setTransferApiPath: (path: unknown) => void;
|
||||
}
|
||||
).setTransferApiPath;
|
||||
expect(() => set("/plugin-api/file-manager")).not.toThrow();
|
||||
for (const bad of ["/users", "/plugin-api/../users", "https://x", 3]) {
|
||||
expect(() => set(bad)).toThrow(/Invalid transfer API path/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import {
|
||||
pickResolvedUsername,
|
||||
pickResolvedPassword,
|
||||
expandOidcUsername,
|
||||
expandExternalUsername,
|
||||
} from "../../hosts/credential-username.js";
|
||||
|
||||
describe("pickResolvedUsername", () => {
|
||||
@@ -52,14 +52,14 @@ describe("pickResolvedPassword", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("expandOidcUsername", () => {
|
||||
describe("expandExternalUsername", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("returns the username unchanged when it has no placeholder", async () => {
|
||||
expect(await expandOidcUsername("alice", "user-1")).toBe("alice");
|
||||
expect(await expandOidcUsername(undefined, "user-1")).toBeUndefined();
|
||||
expect(await expandExternalUsername("alice", "user-1")).toBe("alice");
|
||||
expect(await expandExternalUsername(undefined, "user-1")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("expands the placeholder with the user's OIDC identifier", async () => {
|
||||
@@ -69,9 +69,13 @@ describe("expandOidcUsername", () => {
|
||||
}),
|
||||
}));
|
||||
|
||||
const { expandOidcUsername: expand } =
|
||||
const { expandExternalUsername: expand } =
|
||||
await import("../../hosts/credential-username.js");
|
||||
expect(await expand("$oidc.preferred_username", "user-1")).toBe("jdoe");
|
||||
expect(await expand("$external.username", "user-1")).toBe("jdoe");
|
||||
expect(
|
||||
await expand("$external.username-$oidc.preferred_username", "user-1"),
|
||||
).toBe("jdoe-jdoe");
|
||||
});
|
||||
|
||||
it("leaves the placeholder as-is when the user has no OIDC identifier", async () => {
|
||||
@@ -81,7 +85,7 @@ describe("expandOidcUsername", () => {
|
||||
}),
|
||||
}));
|
||||
|
||||
const { expandOidcUsername: expand } =
|
||||
const { expandExternalUsername: expand } =
|
||||
await import("../../hosts/credential-username.js");
|
||||
expect(await expand("$oidc.preferred_username", "user-1")).toBe(
|
||||
"$oidc.preferred_username",
|
||||
@@ -95,7 +99,7 @@ describe("expandOidcUsername", () => {
|
||||
},
|
||||
}));
|
||||
|
||||
const { expandOidcUsername: expand } =
|
||||
const { expandExternalUsername: expand } =
|
||||
await import("../../hosts/credential-username.js");
|
||||
expect(await expand("$oidc.preferred_username", "user-1")).toBe(
|
||||
"$oidc.preferred_username",
|
||||
@@ -117,7 +121,7 @@ describe("expandOidcUsername", () => {
|
||||
}),
|
||||
}));
|
||||
|
||||
const { expandOidcUsername: expand } =
|
||||
const { expandExternalUsername: expand } =
|
||||
await import("../../hosts/credential-username.js");
|
||||
expect(await expand("$oidc.preferred_username", "user-1")).toBe("jdoe");
|
||||
});
|
||||
@@ -137,7 +141,7 @@ describe("expandOidcUsername", () => {
|
||||
}),
|
||||
}));
|
||||
|
||||
const { expandOidcUsername: expand } =
|
||||
const { expandExternalUsername: expand } =
|
||||
await import("../../hosts/credential-username.js");
|
||||
expect(await expand("$oidc.preferred_username", "user-1")).toBe(
|
||||
"ldap:1:admin",
|
||||
@@ -159,7 +163,7 @@ describe("expandOidcUsername", () => {
|
||||
}),
|
||||
}));
|
||||
|
||||
const { expandOidcUsername: expand } =
|
||||
const { expandExternalUsername: expand } =
|
||||
await import("../../hosts/credential-username.js");
|
||||
expect(await expand("$oidc.preferred_username", "user-1")).toBe(
|
||||
"ldap:1:admin",
|
||||
|
||||
@@ -149,7 +149,7 @@ describe("capability gate", () => {
|
||||
describe("ctx.asUser", () => {
|
||||
it("makes the named user the actor and audits the switch", async () => {
|
||||
grants.set("sample-plugin", ["kv:own"]);
|
||||
const ctx = context(["kv:own"]);
|
||||
const ctx = context(["kv:own", "users:impersonate"]);
|
||||
|
||||
let seen: string | undefined;
|
||||
await ctx.asUser("user-7", async () => {
|
||||
@@ -169,7 +169,7 @@ describe("ctx.asUser", () => {
|
||||
});
|
||||
|
||||
it("restores the previous actor afterwards", async () => {
|
||||
const ctx = context([]);
|
||||
const ctx = context(["users:impersonate"]);
|
||||
|
||||
await runAsActor("outer-user", "request", async () => {
|
||||
await ctx.asUser("inner-user", async () => {
|
||||
@@ -186,6 +186,46 @@ describe("ctx.asUser", () => {
|
||||
/without a user id/,
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses without users:impersonate", async () => {
|
||||
const ctx = context(["kv:own"]);
|
||||
|
||||
await expect(ctx.asUser("user-7", async () => {})).rejects.toThrow(
|
||||
/users:impersonate/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.registry namespacing", () => {
|
||||
it("lets a plugin provide and revoke its own keys", () => {
|
||||
const ctx = context([]);
|
||||
const value = { ok: true };
|
||||
|
||||
ctx.registry.provide("sample-plugin.thing", value);
|
||||
expect(ctx.registry.consume("sample-plugin.thing")).toBe(value);
|
||||
expect(ctx.registry.revoke("sample-plugin.thing", value)).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses a key outside the plugin's namespace", () => {
|
||||
const ctx = context([]);
|
||||
|
||||
expect(() => ctx.registry.provide("other.thing", {})).toThrow(
|
||||
/registry keys under "sample-plugin\."/,
|
||||
);
|
||||
expect(() => ctx.registry.revoke("other.thing")).toThrow(
|
||||
/registry keys under/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.services.get", () => {
|
||||
it("refuses a service the manifest does not require", () => {
|
||||
const ctx = context([]);
|
||||
|
||||
expect(() => ctx.services.get("someone.else")).toThrow(
|
||||
/not declared in the manifest's requires/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.events namespacing", () => {
|
||||
|
||||
@@ -140,7 +140,7 @@ function setup() {
|
||||
kind: "redirect",
|
||||
external: true,
|
||||
describe: async () => [
|
||||
{ id: "3", label: "Corp", enabled: true, type: "oidc" },
|
||||
{ id: "3", label: "Corp", enabled: true, type: "oidc", autoStart: true },
|
||||
],
|
||||
start: async () => ({ redirectUrl: "https://idp.example/auth" }),
|
||||
callback: async () => ({
|
||||
@@ -161,7 +161,9 @@ function setup() {
|
||||
labelKey: "signIn",
|
||||
kind: "form",
|
||||
external: true,
|
||||
describe: async () => [{ id: "4", label: "Directory", enabled: true }],
|
||||
describe: async () => [
|
||||
{ id: "4", label: "Directory", enabled: true, autoStart: true },
|
||||
],
|
||||
verify: async (request) => {
|
||||
if (request.body.password !== "hunter2") {
|
||||
await auth.loginRateLimit.recordFailure(
|
||||
@@ -303,7 +305,7 @@ describe("the other ctx.auth helpers", () => {
|
||||
it("revokes sessions by logout claims and audits it", async () => {
|
||||
const { auth } = setup();
|
||||
expect(await auth.revokeSessions({ providerId: 3, sid: "sid-1" })).toBe(1);
|
||||
expect(h.manager.revokeSessionsByOidc).toHaveBeenCalledWith({
|
||||
expect(h.manager.revokeSessionsByExternalSession).toHaveBeenCalledWith({
|
||||
ssoProviderId: 3,
|
||||
sub: null,
|
||||
sid: "sid-1",
|
||||
@@ -352,6 +354,17 @@ describe("a disabled plugin", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("passes autoStart through for redirect methods only", async () => {
|
||||
setup();
|
||||
const methods = await listPublicLoginMethods();
|
||||
expect(methods.find((m) => m.id === "corp-sso")?.instances).toEqual([
|
||||
{ id: "3", label: "Corp", autoStart: true },
|
||||
]);
|
||||
expect(methods.find((m) => m.id === "corp-dir")?.instances).toEqual([
|
||||
{ id: "4", label: "Directory" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("hides its buttons and leaves no route that signs anyone in", async () => {
|
||||
const { bag } = setup();
|
||||
await bag.disposeAll();
|
||||
|
||||
@@ -190,10 +190,17 @@ describe("ctx.db capability", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("hands back read-only refs to the core tables a plugin may join", async () => {
|
||||
it("refuses core refs with only db:own", async () => {
|
||||
grants.set("demo", ["db:own"]);
|
||||
const { ctx } = contextFor("demo", ["db:own"]);
|
||||
|
||||
await expect(ctx.db.refs()).rejects.toThrow(/db:core-refs/);
|
||||
});
|
||||
|
||||
it("hands back refs to the core tables a plugin may join", async () => {
|
||||
grants.set("demo", ["db:core-refs"]);
|
||||
const { ctx } = contextFor("demo", ["db:core-refs"]);
|
||||
|
||||
const refs = (await ctx.db.refs()) as Record<string, unknown>;
|
||||
|
||||
expect(Object.keys(refs).sort()).toEqual([
|
||||
|
||||
@@ -122,19 +122,24 @@ describe("ctx.desktop.openIsolatedWindow", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.desktop.launchNativeRdp", () => {
|
||||
const request = { host: "win.example.test", port: 3389, username: "bob" };
|
||||
describe("ctx.desktop.launchExternalClient", () => {
|
||||
const request = {
|
||||
protocol: "rdp",
|
||||
host: "win.example.test",
|
||||
port: 3389,
|
||||
username: "bob",
|
||||
};
|
||||
|
||||
it("refuses without desktop:window", async () => {
|
||||
grants.set("demo", []);
|
||||
const { ctx } = contextFor("demo", []);
|
||||
|
||||
await expect(ctx.desktop.launchNativeRdp(request)).rejects.toThrow(
|
||||
await expect(ctx.desktop.launchExternalClient(request)).rejects.toThrow(
|
||||
/desktop:window/,
|
||||
);
|
||||
expect(bridge.requestFromElectronMain).not.toHaveBeenCalled();
|
||||
expect(auditEntries.at(-1)).toMatchObject({
|
||||
action: "plugin_desktop_launch_native_rdp",
|
||||
action: "plugin_desktop_launch_external_client",
|
||||
success: false,
|
||||
});
|
||||
});
|
||||
@@ -144,7 +149,7 @@ describe("ctx.desktop.launchNativeRdp", () => {
|
||||
const { ctx } = contextFor("demo", ["desktop:window"]);
|
||||
|
||||
expect(ctx.desktop.available()).toBe(false);
|
||||
await expect(ctx.desktop.launchNativeRdp(request)).rejects.toThrow(
|
||||
await expect(ctx.desktop.launchExternalClient(request)).rejects.toThrow(
|
||||
/desktop app/,
|
||||
);
|
||||
});
|
||||
@@ -155,15 +160,15 @@ describe("ctx.desktop.launchNativeRdp", () => {
|
||||
const { ctx } = contextFor("demo", ["desktop:window"]);
|
||||
|
||||
expect(ctx.desktop.available()).toBe(true);
|
||||
await expect(ctx.desktop.launchNativeRdp(request)).resolves.toEqual({
|
||||
await expect(ctx.desktop.launchExternalClient(request)).resolves.toEqual({
|
||||
success: true,
|
||||
});
|
||||
expect(bridge.requestFromElectronMain).toHaveBeenCalledWith(
|
||||
"launch-native-rdp",
|
||||
"launch-external-client",
|
||||
request,
|
||||
);
|
||||
expect(auditEntries.at(-1)).toMatchObject({
|
||||
action: "plugin_desktop_launch_native_rdp",
|
||||
action: "plugin_desktop_launch_external_client",
|
||||
success: true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -401,3 +401,28 @@ describe("ctx.http streaming", () => {
|
||||
expect(rest).toContain("data: done");
|
||||
});
|
||||
});
|
||||
|
||||
describe("public route rate limit", () => {
|
||||
afterEach(() => {
|
||||
http.resetPublicRateLimits();
|
||||
delete process.env.TERMIX_PLUGIN_PUBLIC_RATE_LIMIT;
|
||||
});
|
||||
|
||||
it("allows up to the limit per address and plugin, then refuses", () => {
|
||||
process.env.TERMIX_PLUGIN_PUBLIC_RATE_LIMIT = "3";
|
||||
const now = 1_000;
|
||||
const hits = [1, 2, 3, 4].map(() =>
|
||||
http.overPublicRateLimit("demo", "1.2.3.4", now),
|
||||
);
|
||||
expect(hits).toEqual([false, false, false, true]);
|
||||
expect(http.overPublicRateLimit("demo", "5.6.7.8", now)).toBe(false);
|
||||
expect(http.overPublicRateLimit("other", "1.2.3.4", now)).toBe(false);
|
||||
});
|
||||
|
||||
it("starts a new window after a minute", () => {
|
||||
process.env.TERMIX_PLUGIN_PUBLIC_RATE_LIMIT = "1";
|
||||
expect(http.overPublicRateLimit("demo", "ip", 0)).toBe(false);
|
||||
expect(http.overPublicRateLimit("demo", "ip", 10)).toBe(true);
|
||||
expect(http.overPublicRateLimit("demo", "ip", 60_001)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -472,19 +472,6 @@ describe("PluginLoader error budget", () => {
|
||||
expect(loader.get("sample-plugin")?.state).toBe("active");
|
||||
});
|
||||
|
||||
it("wrap() counts a throwing callback instead of letting it escape", async () => {
|
||||
const loader = await failing(2);
|
||||
|
||||
const wrapped = loader.wrap("sample-plugin", () => {
|
||||
throw new Error("callback failed");
|
||||
});
|
||||
|
||||
await expect(wrapped()).resolves.toBeUndefined();
|
||||
await wrapped();
|
||||
|
||||
expect(loader.get("sample-plugin")?.state).toBe("failed");
|
||||
});
|
||||
|
||||
it("retry clears the budget and starts the plugin again", async () => {
|
||||
const loader = await failing(1);
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
parseManifest,
|
||||
validateManifest,
|
||||
SUPPORTED_PLUGIN_API_VERSION,
|
||||
isTermixCompatible,
|
||||
} from "../../plugins/manifest.js";
|
||||
|
||||
function base(overrides: Record<string, unknown> = {}) {
|
||||
@@ -91,7 +92,35 @@ describe("manifest v2 validation", () => {
|
||||
);
|
||||
|
||||
expect(manifest).toBeUndefined();
|
||||
expect(errors.join()).toMatch(/SDK API version/);
|
||||
expect(errors.join()).toMatch(/needs plugin API 2/);
|
||||
});
|
||||
|
||||
it("accepts an api range this build satisfies", () => {
|
||||
for (const api of ["1", "^1.0.0", ">=1.0.0 <2", "1.x"]) {
|
||||
expect(
|
||||
parseManifest(base({ engine: { termix: ">=2.9.0", api } })).errors,
|
||||
).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses an api range needing a newer minor", () => {
|
||||
const { errors } = parseManifest(
|
||||
base({ engine: { termix: ">=2.9.0", api: "^1.99" } }),
|
||||
);
|
||||
expect(errors.join()).toMatch(/needs plugin API \^1\.99/);
|
||||
});
|
||||
|
||||
it("refuses an engine that is not a semver range", () => {
|
||||
expect(
|
||||
validateManifest(base({ engine: { termix: "soon", api: "one" } })).join(),
|
||||
).toMatch(/engine.termix.*semver[\s\S]*engine.api.*semver/);
|
||||
});
|
||||
|
||||
it("checks engine.termix against the core version", () => {
|
||||
expect(isTermixCompatible(">=2.9.0", "2.9.0")).toBe(true);
|
||||
expect(isTermixCompatible(">=2.9.0", "2.9.0-beta.3")).toBe(true);
|
||||
expect(isTermixCompatible(">=3.0.0", "2.9.1")).toBe(false);
|
||||
expect(isTermixCompatible(">=3.0.0", null)).toBe(true);
|
||||
});
|
||||
|
||||
it("fills in the entry point defaults", () => {
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* group was declared twice.
|
||||
*/
|
||||
|
||||
import type { PluginLoader } from "../../plugins/loader.js";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
@@ -113,7 +114,6 @@ vi.mock("../../database/repositories/factory.js", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
const { PluginLoader } = await import("../../plugins/loader.js");
|
||||
const { createFixturePlugin } = await import("./fixture-plugin.js");
|
||||
const {
|
||||
getPermissionCatalog,
|
||||
@@ -433,7 +433,7 @@ describe("shipped manifests keep their permission ids", () => {
|
||||
|
||||
describe("role defaults apply exactly once", () => {
|
||||
let fixture: Fixture;
|
||||
let loader: InstanceType<typeof PluginLoader>;
|
||||
let loader: PluginLoader;
|
||||
|
||||
beforeEach(() => {
|
||||
state.roles.set("user", { id: 2, permissions: ["hosts.*"] });
|
||||
|
||||
@@ -128,7 +128,7 @@ function consumerFixture(
|
||||
): Fixture {
|
||||
return createFixturePlugin({
|
||||
id: CONSUMER_ID,
|
||||
capabilities: ["kv:own"],
|
||||
capabilities: ["kv:own", "users:impersonate"],
|
||||
backendSource: CONSUMER_SOURCE,
|
||||
manifestOverrides: { category: "Infrastructure", requiresSecret },
|
||||
});
|
||||
|
||||
@@ -81,7 +81,13 @@ function contextFor(pluginId: string, extra: Record<string, unknown> = {}) {
|
||||
license: "MIT",
|
||||
category: "Productivity",
|
||||
engine: { termix: ">=2.9.0", api: "1" },
|
||||
capabilities: ["hosts:read", "ssh:connect", "credentials:use"],
|
||||
capabilities: [
|
||||
"hosts:read",
|
||||
"ssh:connect",
|
||||
"credentials:use",
|
||||
"users:impersonate",
|
||||
],
|
||||
requires: [{ service: "sample.echo", versionRange: "^1.0.0" }],
|
||||
...extra,
|
||||
} as never,
|
||||
handle,
|
||||
|
||||
@@ -11,7 +11,10 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { findUnownedTableWrites } from "@termix/plugin-sdk/ddl";
|
||||
import {
|
||||
collectOwnedIndexes,
|
||||
findUnownedTableWrites,
|
||||
} from "@termix/plugin-sdk/ddl";
|
||||
import { LEGACY_TABLE_OWNERS } from "@termix/plugin-sdk/db";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
@@ -74,6 +77,40 @@ describe("the migration checker", () => {
|
||||
["a truncate", "TRUNCATE TABLE users"],
|
||||
["an unlogged table", "CREATE UNLOGGED TABLE users (id int)"],
|
||||
["a CTE write", "WITH x AS (SELECT 1) DELETE FROM users"],
|
||||
[
|
||||
"a Postgres dollar-quoted string hiding a statement",
|
||||
"SELECT $$'$$; UPDATE users SET is_admin = true; SELECT $$'$$",
|
||||
],
|
||||
[
|
||||
"a MySQL backslash escape hiding a statement",
|
||||
"SELECT 'a\\''; UPDATE users SET is_admin = 1; SELECT ''",
|
||||
],
|
||||
["dropping a core index by name", "DROP INDEX idx_sessions_user_id"],
|
||||
["altering a core index by name", "ALTER INDEX users_pkey RENAME TO x"],
|
||||
["dropping a core sequence", "DROP SEQUENCE users_id_seq"],
|
||||
["a cascade", "DROP TABLE p_foo_a CASCADE"],
|
||||
[
|
||||
"a MySQL multi-table update",
|
||||
"UPDATE p_foo_a JOIN users ON 1 = 1 SET users.is_admin = 1",
|
||||
],
|
||||
["a MySQL comma update", "UPDATE p_foo_a, users SET users.is_admin = 1"],
|
||||
[
|
||||
"a MySQL multi-table delete",
|
||||
"DELETE p_foo_a, users FROM p_foo_a JOIN users",
|
||||
],
|
||||
[
|
||||
"a delete with USING",
|
||||
"DELETE FROM p_foo_a, users USING p_foo_a JOIN users",
|
||||
],
|
||||
["SELECT INTO", "SELECT * INTO users_copy FROM users"],
|
||||
["writing a file", "SELECT 1 INTO OUTFILE '/tmp/x'"],
|
||||
["reading a server file", "SELECT pg_read_file('/etc/passwd')"],
|
||||
["changing a server setting", "SELECT set_config('x', 'y', false)"],
|
||||
[
|
||||
"running a string as SQL",
|
||||
"SELECT query_to_xml('DELETE FROM users', true, true, '')",
|
||||
],
|
||||
["setval on a core sequence", "SELECT setval('users_id_seq', 1)"],
|
||||
])("refuses %s", (_label, sql) => {
|
||||
expect(findUnownedTableWrites("foo", sql)).not.toEqual([]);
|
||||
});
|
||||
@@ -90,10 +127,34 @@ describe("the migration checker", () => {
|
||||
"a quoted name in a string",
|
||||
"INSERT INTO p_foo_a (x) VALUES ('DROP TABLE users')",
|
||||
],
|
||||
[
|
||||
"dropping an index it created in the same file",
|
||||
"CREATE INDEX idx_foo_a ON p_foo_a (id); DROP INDEX idx_foo_a",
|
||||
],
|
||||
["dropping a prefixed index", "DROP INDEX IF EXISTS p_foo_a_idx"],
|
||||
[
|
||||
"setval on its own sequence",
|
||||
"SELECT setval(pg_get_serial_sequence('p_foo_a', 'id'), 1)",
|
||||
],
|
||||
["its own sequence", "CREATE SEQUENCE IF NOT EXISTS p_foo_seq"],
|
||||
])("allows %s", (_label, sql) => {
|
||||
expect(findUnownedTableWrites("foo", sql)).toEqual([]);
|
||||
});
|
||||
|
||||
it("allows dropping an index an earlier migration created", () => {
|
||||
const owned = collectOwnedIndexes("foo", [
|
||||
"CREATE INDEX idx_foo_a ON p_foo_a (id)",
|
||||
"CREATE INDEX idx_core ON users (id)",
|
||||
]);
|
||||
expect([...owned]).toEqual(["idx_foo_a"]);
|
||||
expect(
|
||||
findUnownedTableWrites("foo", "DROP INDEX idx_foo_a", new Set(), owned),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
findUnownedTableWrites("foo", "DROP INDEX idx_core", new Set(), owned),
|
||||
).not.toEqual([]);
|
||||
});
|
||||
|
||||
it("passes every migration a bundled plugin ships", () => {
|
||||
const plugins = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
|
||||
@@ -121,7 +121,7 @@ function consumerFixture(
|
||||
): Fixture {
|
||||
return createFixturePlugin({
|
||||
id: "docker",
|
||||
capabilities: ["kv:own"],
|
||||
capabilities: ["kv:own", "users:impersonate"],
|
||||
backendSource: CONSUMER_SOURCE,
|
||||
manifestOverrides: { category: "Infrastructure", requires },
|
||||
});
|
||||
@@ -219,6 +219,7 @@ describe("plugin service contracts", () => {
|
||||
|
||||
it("activates anyway when an unsatisfied requirement is optional", async () => {
|
||||
consumer = consumerFixture([
|
||||
{ service: SERVICE, versionRange: "^1.2.0", optional: true },
|
||||
{ service: "testplugin.absent", versionRange: "^1.0.0", optional: true },
|
||||
]);
|
||||
|
||||
|
||||
@@ -69,10 +69,11 @@ async function writeArtifact(
|
||||
dir: string,
|
||||
id: string,
|
||||
sig: "valid" | "wrong-key" | "none",
|
||||
version = "1.0.0",
|
||||
): Promise<string> {
|
||||
const build = tempRoot("termix-build-");
|
||||
createFixturePlugin({ id, root: build });
|
||||
const file = path.join(dir, `${id}-1.0.0.tmxplug`);
|
||||
createFixturePlugin({ id, root: build, manifestOverrides: { version } });
|
||||
const file = path.join(dir, `${id}-${version}.tmxplug`);
|
||||
await tar.c({ gzip: true, file, cwd: path.join(build, id) }, [
|
||||
"manifest.json",
|
||||
"backend",
|
||||
@@ -146,13 +147,57 @@ describe("signed plugins gate on", () => {
|
||||
expect(await loadedIds()).toEqual(["bundled-one", "signed-one"]);
|
||||
});
|
||||
|
||||
it("refuses a signed archive that takes a bundled plugin's id", async () => {
|
||||
it("refuses a signed archive of a bundled id that is not newer", async () => {
|
||||
const { userDir } = setup();
|
||||
process.env.TERMIX_REQUIRE_SIGNED_PLUGINS = "true";
|
||||
await writeArtifact(userDir, "bundled-one", "valid");
|
||||
const loader = new PluginLoader();
|
||||
const loaded = await loader.loadAll();
|
||||
expect(loaded.map((plugin) => plugin.id)).toEqual(["bundled-one"]);
|
||||
expect(loader.get("bundled-one")?.source).toBe("bundled");
|
||||
expect(loader.get("bundled-one")?.artifact).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("updating a bundled plugin", () => {
|
||||
it("loads a signed, newer archive in place of the bundled copy", async () => {
|
||||
const { userDir } = setup();
|
||||
const file = await writeArtifact(userDir, "bundled-one", "valid", "1.0.1");
|
||||
const loader = new PluginLoader();
|
||||
const loaded = await loader.loadAll();
|
||||
|
||||
expect(loaded.map((plugin) => plugin.id)).toEqual(["bundled-one"]);
|
||||
const plugin = loader.get("bundled-one");
|
||||
expect(plugin?.manifest.version).toBe("1.0.1");
|
||||
expect(plugin?.source).toBe("bundled");
|
||||
expect(plugin?.artifact).toBe(file);
|
||||
expect(plugin?.signedBy).toBe("test");
|
||||
});
|
||||
|
||||
it("keeps the bundled copy for an unsigned newer archive", async () => {
|
||||
const { userDir } = setup();
|
||||
await writeArtifact(userDir, "bundled-one", "none", "2.0.0");
|
||||
const loader = new PluginLoader();
|
||||
await loader.loadAll();
|
||||
expect(loader.get("bundled-one")?.manifest.version).toBe("1.0.0");
|
||||
});
|
||||
|
||||
it("keeps the bundled copy for a signed older archive", async () => {
|
||||
const { userDir } = setup();
|
||||
await writeArtifact(userDir, "bundled-one", "valid", "0.9.0");
|
||||
const loader = new PluginLoader();
|
||||
await loader.loadAll();
|
||||
expect(loader.get("bundled-one")?.manifest.version).toBe("1.0.0");
|
||||
});
|
||||
|
||||
it("refuses a downgrade after the bundled copy was unloaded", async () => {
|
||||
const { userDir } = setup();
|
||||
const loader = new PluginLoader();
|
||||
await loader.loadAll();
|
||||
loader.forget("bundled-one");
|
||||
const file = await writeArtifact(userDir, "bundled-one", "valid", "0.5.0");
|
||||
|
||||
await expect(
|
||||
loader.loadArtifact(file, loader.bundledIds()),
|
||||
).rejects.toThrow(/not newer than the bundled 1\.0\.0/);
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
defineTable,
|
||||
encryptedText,
|
||||
id,
|
||||
refHost,
|
||||
refUser,
|
||||
text,
|
||||
varchar,
|
||||
@@ -134,4 +135,37 @@ describe("plugin user data", () => {
|
||||
.get(),
|
||||
).toEqual({ user_id: "u1", note: "x" });
|
||||
});
|
||||
|
||||
it("skips imported rows that point at another user or host", async () => {
|
||||
sqlite.exec("DROP TABLE ssh_data");
|
||||
sqlite.exec("CREATE TABLE ssh_data (id INTEGER PRIMARY KEY, user_id TEXT)");
|
||||
sqlite.exec(
|
||||
"INSERT INTO ssh_data (id, user_id) VALUES (1, 'u2'), (2, 'u1')",
|
||||
);
|
||||
const grants = defineTable("grant", {
|
||||
id: id(),
|
||||
ownerId: refUser(),
|
||||
granteeId: refUser(),
|
||||
hostId: refHost(),
|
||||
});
|
||||
for (const statement of createTableSql("sqlite", "fixture", grants)) {
|
||||
sqlite.exec(statement);
|
||||
}
|
||||
registerTable("fixture", grants);
|
||||
|
||||
const file = new Database(":memory:");
|
||||
file.exec(
|
||||
"CREATE TABLE p_fixture_grant (id INTEGER PRIMARY KEY, owner_id TEXT, grantee_id TEXT, host_id INTEGER)",
|
||||
);
|
||||
file.exec(`INSERT INTO p_fixture_grant (owner_id, grantee_id, host_id) VALUES
|
||||
('old', 'old', 1), ('old', 'u1', 1), ('old', 'old', 2)`);
|
||||
|
||||
const result = await importUserPluginRows(file, "u2");
|
||||
expect(result).toMatchObject({ imported: 1, skipped: 2 });
|
||||
expect(
|
||||
sqlite
|
||||
.prepare("SELECT owner_id, grantee_id, host_id FROM p_fixture_grant")
|
||||
.all(),
|
||||
).toEqual([{ owner_id: "u2", grantee_id: "u2", host_id: 1 }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* The silent sign-in default moving from core settings into the sso plugin's
|
||||
* admin settings.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
core: new Map<string, string>(),
|
||||
plugin: new Map<string, string | null>(),
|
||||
pluginInstalled: true,
|
||||
}));
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginRepository: () => ({
|
||||
findById: async (id: string) =>
|
||||
state.pluginInstalled && id === "sso" ? { id } : null,
|
||||
}),
|
||||
createCurrentSettingsRepository: () => ({
|
||||
get: async (key: string) => state.core.get(key) ?? null,
|
||||
delete: async (key: string) => {
|
||||
state.core.delete(key);
|
||||
},
|
||||
}),
|
||||
createCurrentPluginSettingsRepository: () => ({
|
||||
get: async (
|
||||
_pluginId: string,
|
||||
_scope: string,
|
||||
_scopeId: string | null,
|
||||
key: string,
|
||||
) => (state.plugin.has(key) ? { value: state.plugin.get(key) } : null),
|
||||
set: async (
|
||||
_pluginId: string,
|
||||
_scope: string,
|
||||
_scopeId: string | null,
|
||||
key: string,
|
||||
value: string | null,
|
||||
) => {
|
||||
state.plugin.set(key, value);
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
databaseLogger: { info: vi.fn(), warn: vi.fn() },
|
||||
}));
|
||||
|
||||
import { runSsoSettingsMigration } from "../../upgrade/sso-settings-migration.js";
|
||||
|
||||
beforeEach(() => {
|
||||
state.core = new Map();
|
||||
state.plugin = new Map();
|
||||
state.pluginInstalled = true;
|
||||
});
|
||||
|
||||
describe("runSsoSettingsMigration", () => {
|
||||
it("moves the core value into the sso admin setting", async () => {
|
||||
state.core.set("oidc_silent_login_default", "true");
|
||||
expect(await runSsoSettingsMigration()).toEqual({ moved: true });
|
||||
expect(state.plugin.get("silentLoginDefault")).toBe("true");
|
||||
expect(state.core.has("oidc_silent_login_default")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps a value already in plugin settings", async () => {
|
||||
state.core.set("oidc_silent_login_default", "true");
|
||||
state.plugin.set("silentLoginDefault", "false");
|
||||
await runSsoSettingsMigration();
|
||||
expect(state.plugin.get("silentLoginDefault")).toBe("false");
|
||||
expect(state.core.has("oidc_silent_login_default")).toBe(false);
|
||||
});
|
||||
|
||||
it("does nothing twice or without the plugin", async () => {
|
||||
expect(await runSsoSettingsMigration()).toEqual({ moved: false });
|
||||
state.core.set("oidc_silent_login_default", "true");
|
||||
state.pluginInstalled = false;
|
||||
expect(await runSsoSettingsMigration()).toEqual({ moved: false });
|
||||
expect(state.core.get("oidc_silent_login_default")).toBe("true");
|
||||
});
|
||||
});
|
||||
@@ -99,7 +99,7 @@ function sessionIds(): string[] {
|
||||
.map((row) => String(row.id));
|
||||
}
|
||||
|
||||
describe("AuthManager.revokeSessionsByOidc", () => {
|
||||
describe("AuthManager.revokeSessionsByExternalSession", () => {
|
||||
beforeAll(() => {
|
||||
mocks.sqlite!.exec(`
|
||||
CREATE TABLE sessions (
|
||||
@@ -147,7 +147,7 @@ describe("AuthManager.revokeSessionsByOidc", () => {
|
||||
});
|
||||
|
||||
await expect(
|
||||
authManager.revokeSessionsByOidc({
|
||||
authManager.revokeSessionsByExternalSession({
|
||||
ssoProviderId: 7,
|
||||
sub: "subject-1",
|
||||
sid: "session-1",
|
||||
@@ -181,7 +181,7 @@ describe("AuthManager.revokeSessionsByOidc", () => {
|
||||
});
|
||||
|
||||
await expect(
|
||||
authManager.revokeSessionsByOidc({
|
||||
authManager.revokeSessionsByExternalSession({
|
||||
ssoProviderId: 7,
|
||||
sub: "subject-1",
|
||||
}),
|
||||
@@ -192,7 +192,7 @@ describe("AuthManager.revokeSessionsByOidc", () => {
|
||||
|
||||
it("does not persist when no session matches", async () => {
|
||||
await expect(
|
||||
authManager.revokeSessionsByOidc({
|
||||
authManager.revokeSessionsByExternalSession({
|
||||
ssoProviderId: 7,
|
||||
sid: "missing",
|
||||
}),
|
||||
@@ -212,7 +212,7 @@ describe("AuthManager.revokeSessionsByOidc", () => {
|
||||
mocks.saveDatabase.mockRejectedValueOnce(new Error("disk full"));
|
||||
|
||||
await expect(
|
||||
authManager.revokeSessionsByOidc({
|
||||
authManager.revokeSessionsByExternalSession({
|
||||
ssoProviderId: 7,
|
||||
sid: "session-1",
|
||||
}),
|
||||
|
||||
+4
-4
@@ -1,13 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isOidcTokenCallback } from "../../utils/oidc-desktop-callback.js";
|
||||
import { isExternalTokenCallback } from "../../utils/external-login-callback.js";
|
||||
|
||||
describe("isOidcTokenCallback", () => {
|
||||
describe("isExternalTokenCallback", () => {
|
||||
it.each([
|
||||
"http://localhost:17850/oidc-callback",
|
||||
"http://127.0.0.1:17850/oidc-callback",
|
||||
"termix-mobile://oidc-callback",
|
||||
])("recognizes app callback %s", (url) => {
|
||||
expect(isOidcTokenCallback(url)).toBe(true);
|
||||
expect(isExternalTokenCallback(url)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -15,6 +15,6 @@ describe("isOidcTokenCallback", () => {
|
||||
"http://example.com:17850/oidc-callback",
|
||||
"http://localhost:17850/other",
|
||||
])("rejects non-app callback %s", (url) => {
|
||||
expect(isOidcTokenCallback(url)).toBe(false);
|
||||
expect(isExternalTokenCallback(url)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,146 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import express from "express";
|
||||
import http from "http";
|
||||
import type { AddressInfo } from "net";
|
||||
import { listenOnServicePort } from "../../utils/service-listen.js";
|
||||
import { Logger } from "../../utils/logger.js";
|
||||
|
||||
function testLogger() {
|
||||
const logger = new Logger("TEST", "🧪", "#000000");
|
||||
return {
|
||||
logger,
|
||||
error: vi.spyOn(logger, "error").mockImplementation(() => {}),
|
||||
};
|
||||
}
|
||||
|
||||
function occupyPort(): Promise<{ port: number; release: () => void }> {
|
||||
return new Promise((resolve) => {
|
||||
const blocker = http.createServer();
|
||||
blocker.listen(0, "127.0.0.1", () => {
|
||||
resolve({
|
||||
port: (blocker.address() as AddressInfo).port,
|
||||
release: () => blocker.close(),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const servers: http.Server[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
while (servers.length) servers.pop()?.close();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("listenOnServicePort", () => {
|
||||
it("starts the service and reports listening on a free port", async () => {
|
||||
const { logger, error } = testLogger();
|
||||
const onListening = vi.fn();
|
||||
|
||||
const server = listenOnServicePort({
|
||||
app: express(),
|
||||
port: 0,
|
||||
logger,
|
||||
serviceName: "test",
|
||||
onListening,
|
||||
});
|
||||
servers.push(server);
|
||||
|
||||
await vi.waitFor(() => expect(onListening).toHaveBeenCalledOnce());
|
||||
expect(server.listening).toBe(true);
|
||||
expect(error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// The bug this helper exists for: express's app.listen(port, host, cb)
|
||||
// registers cb as the server's error handler too, so a port conflict
|
||||
// invoked the "service started" callback and emitted nothing at all.
|
||||
it("does not report the service as started when the port is taken", async () => {
|
||||
const { port, release } = await occupyPort();
|
||||
const { logger } = testLogger();
|
||||
const onListening = vi.fn();
|
||||
const exit = vi
|
||||
.spyOn(process, "exit")
|
||||
.mockImplementation(() => undefined as never);
|
||||
|
||||
const server = listenOnServicePort({
|
||||
app: express(),
|
||||
port,
|
||||
logger,
|
||||
serviceName: "test",
|
||||
onListening,
|
||||
});
|
||||
servers.push(server);
|
||||
|
||||
await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(1));
|
||||
expect(onListening).not.toHaveBeenCalled();
|
||||
release();
|
||||
});
|
||||
|
||||
it("names the conflicting port so the desktop can surface it", async () => {
|
||||
const { port, release } = await occupyPort();
|
||||
const { logger, error } = testLogger();
|
||||
vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
|
||||
|
||||
servers.push(
|
||||
listenOnServicePort({
|
||||
app: express(),
|
||||
port,
|
||||
logger,
|
||||
serviceName: "metrics",
|
||||
}),
|
||||
);
|
||||
|
||||
await vi.waitFor(() => expect(error).toHaveBeenCalled());
|
||||
const [message, , context] = error.mock.calls[0];
|
||||
expect(message).toContain(String(port));
|
||||
expect(context).toMatchObject({
|
||||
operation: "service_port_conflict",
|
||||
port,
|
||||
service: "metrics",
|
||||
});
|
||||
release();
|
||||
});
|
||||
|
||||
it("leaves any other listen error to the process-level handlers", async () => {
|
||||
const { logger } = testLogger();
|
||||
const exit = vi
|
||||
.spyOn(process, "exit")
|
||||
.mockImplementation(() => undefined as never);
|
||||
const server = listenOnServicePort({
|
||||
app: express(),
|
||||
port: 0,
|
||||
logger,
|
||||
serviceName: "test",
|
||||
});
|
||||
servers.push(server);
|
||||
|
||||
const notPortConflict = Object.assign(new Error("boom"), { code: "EPERM" });
|
||||
expect(() => server.emit("error", notPortConflict)).toThrow("boom");
|
||||
expect(exit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// express's app.listen() swallows a bind failure by design (see
|
||||
// service-listen.ts), so no service may reach for it again.
|
||||
describe("backend services", () => {
|
||||
function sourceFiles(dir: string): string[] {
|
||||
return readdirSync(dir).flatMap((entry) => {
|
||||
const full = path.join(dir, entry);
|
||||
if (statSync(full).isDirectory()) {
|
||||
return entry === "tests" ? [] : sourceFiles(full);
|
||||
}
|
||||
return full.endsWith(".ts") ? [full] : [];
|
||||
});
|
||||
}
|
||||
|
||||
it("never bind a port with express's app.listen()", () => {
|
||||
const offenders = sourceFiles(path.resolve("src/backend"))
|
||||
// service-listen.ts names the call only to document why it is banned.
|
||||
.filter((file) => !file.endsWith(path.join("utils", "service-listen.ts")))
|
||||
.filter((file) => /\bapp\.listen\(/.test(readFileSync(file, "utf8")));
|
||||
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { IncomingMessage } from "http";
|
||||
import { extractWebSocketToken } from "../../utils/ws-auth.js";
|
||||
import {
|
||||
extractWebSocketToken,
|
||||
isCookieOriginAllowed,
|
||||
} from "../../utils/ws-auth.js";
|
||||
|
||||
function request(headers: Record<string, string>): IncomingMessage {
|
||||
return { headers } as IncomingMessage;
|
||||
@@ -26,3 +29,58 @@ describe("extractWebSocketToken", () => {
|
||||
).toBe("cookie-token");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the jwt cookie on a socket", () => {
|
||||
it("counts when the page is this server", () => {
|
||||
expect(
|
||||
extractWebSocketToken(
|
||||
request({
|
||||
cookie: "jwt=cookie-token",
|
||||
host: "termix.example.com",
|
||||
origin: "https://termix.example.com",
|
||||
}),
|
||||
),
|
||||
).toBe("cookie-token");
|
||||
});
|
||||
|
||||
it("is ignored when another site opened the socket", () => {
|
||||
expect(
|
||||
extractWebSocketToken(
|
||||
request({
|
||||
cookie: "jwt=cookie-token",
|
||||
host: "termix.example.com",
|
||||
origin: "https://evil.example.com",
|
||||
}),
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("still takes an explicit token from another origin", () => {
|
||||
expect(
|
||||
extractWebSocketToken(
|
||||
request({
|
||||
cookie: "jwt=cookie-token",
|
||||
host: "termix.example.com",
|
||||
origin: "https://evil.example.com",
|
||||
"sec-websocket-protocol": "termix.jwt.protocol-token",
|
||||
}),
|
||||
),
|
||||
).toBe("protocol-token");
|
||||
});
|
||||
|
||||
it("allows the dev proxy, which rewrites Host to loopback", () => {
|
||||
expect(
|
||||
isCookieOriginAllowed(
|
||||
request({ host: "127.0.0.1:30001", origin: "http://localhost:5173" }),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("allows the desktop app, which is not a web origin", () => {
|
||||
expect(
|
||||
isCookieOriginAllowed(
|
||||
request({ host: "termix.example.com", origin: "file://" }),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -34,6 +34,7 @@ import { runSecretSourcesTokenMigration } from "./secret-sources-token-migration
|
||||
import { runTotpMigration } from "./totp-migration.js";
|
||||
import { runNotificationChannelMigration } from "./notification-channel-migration.js";
|
||||
import { runTermixIdentityCaMigration } from "./termix-identity-ca-migration.js";
|
||||
import { runSsoSettingsMigration } from "./sso-settings-migration.js";
|
||||
|
||||
const MIGRATIONS: Array<[string, () => Promise<unknown>]> = [
|
||||
["runTailscaleSettingsMigration", runTailscaleSettingsMigration],
|
||||
@@ -64,6 +65,7 @@ const MIGRATIONS: Array<[string, () => Promise<unknown>]> = [
|
||||
["runTotpMigration", runTotpMigration],
|
||||
["runNotificationChannelMigration", runNotificationChannelMigration],
|
||||
["runTermixIdentityCaMigration", runTermixIdentityCaMigration],
|
||||
["runSsoSettingsMigration", runSsoSettingsMigration],
|
||||
];
|
||||
|
||||
export async function runPluginDataMigrations(): Promise<void> {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Moves the silent sign-in default out of core settings and into the sso
|
||||
* plugin's admin settings. Core used to own the toggle and read it on the
|
||||
* login page; the sso plugin now marks its own login instance to start on
|
||||
* its own.
|
||||
*
|
||||
* Idempotent: a value already in plugin settings wins and the core copy is
|
||||
* dropped.
|
||||
*/
|
||||
|
||||
import { databaseLogger } from "../utils/logger.js";
|
||||
import {
|
||||
createCurrentPluginRepository,
|
||||
createCurrentPluginSettingsRepository,
|
||||
createCurrentSettingsRepository,
|
||||
} from "../database/repositories/factory.js";
|
||||
|
||||
const PLUGIN_ID = "sso";
|
||||
const LEGACY_KEY = "oidc_silent_login_default";
|
||||
const FIELD = "silentLoginDefault";
|
||||
|
||||
export interface SsoSettingsMigrationResult {
|
||||
moved: boolean;
|
||||
}
|
||||
|
||||
export async function runSsoSettingsMigration(): Promise<SsoSettingsMigrationResult> {
|
||||
try {
|
||||
const plugin = await createCurrentPluginRepository().findById(PLUGIN_ID);
|
||||
if (!plugin) return { moved: false };
|
||||
|
||||
const settings = createCurrentSettingsRepository();
|
||||
const legacy = await settings.get(LEGACY_KEY);
|
||||
if (legacy === null || legacy === "") return { moved: false };
|
||||
|
||||
const pluginSettings = createCurrentPluginSettingsRepository();
|
||||
const existing = await pluginSettings.get(PLUGIN_ID, "admin", null, FIELD);
|
||||
if (!existing || existing.value === null) {
|
||||
await pluginSettings.set(
|
||||
PLUGIN_ID,
|
||||
"admin",
|
||||
null,
|
||||
FIELD,
|
||||
JSON.stringify(legacy === "true"),
|
||||
false,
|
||||
);
|
||||
}
|
||||
await settings.delete(LEGACY_KEY);
|
||||
databaseLogger.info("Moved the silent sign-in default into sso settings", {
|
||||
operation: "sso_settings_migration",
|
||||
});
|
||||
return { moved: true };
|
||||
} catch (error) {
|
||||
databaseLogger.warn("SSO settings migration failed", {
|
||||
operation: "sso_settings_migration",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return { moved: false };
|
||||
}
|
||||
}
|
||||
@@ -137,7 +137,7 @@ class AuthManager {
|
||||
}
|
||||
}
|
||||
|
||||
async registerOIDCUser(
|
||||
async registerExternalUser(
|
||||
userId: string,
|
||||
_sessionDurationMs?: number,
|
||||
): Promise<void> {
|
||||
@@ -154,7 +154,7 @@ class AuthManager {
|
||||
return false;
|
||||
}
|
||||
|
||||
async authenticateOIDCUser(
|
||||
async authenticateExternalUser(
|
||||
userId: string,
|
||||
_deviceType?: DeviceType,
|
||||
): Promise<boolean> {
|
||||
@@ -505,7 +505,7 @@ class AuthManager {
|
||||
}
|
||||
}
|
||||
|
||||
async revokeSessionsByOidc(params: {
|
||||
async revokeSessionsByExternalSession(params: {
|
||||
ssoProviderId?: number | null;
|
||||
sub?: string | null;
|
||||
sid?: string | null;
|
||||
@@ -529,7 +529,6 @@ class AuthManager {
|
||||
if (matched.length === 0) return 0;
|
||||
|
||||
const matchedIds = matched.map((s) => s.id);
|
||||
const affectedUsers = new Set(matched.map((s) => s.userId));
|
||||
|
||||
await db.delete(sessions).where(inArray(sessions.id, matchedIds));
|
||||
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ const CALLBACK_PATH = "/oidc-callback";
|
||||
* listener or the mobile app's scheme, which get the token in the URL
|
||||
* because they cannot read a cookie.
|
||||
*/
|
||||
export function isOidcTokenCallback(value: string): boolean {
|
||||
export function isExternalTokenCallback(value: string): boolean {
|
||||
if (value.startsWith("termix-mobile:")) return true;
|
||||
|
||||
try {
|
||||
@@ -34,7 +34,6 @@ export interface LogContext {
|
||||
operation?: string;
|
||||
userId?: string;
|
||||
hostId?: number;
|
||||
tunnelName?: string;
|
||||
sessionId?: string;
|
||||
requestId?: string;
|
||||
duration?: number;
|
||||
@@ -139,8 +138,6 @@ export class Logger {
|
||||
contextParts.push(`user:${sanitizedContext.userId}`);
|
||||
if (sanitizedContext.hostId)
|
||||
contextParts.push(`host:${sanitizedContext.hostId}`);
|
||||
if (sanitizedContext.tunnelName)
|
||||
contextParts.push(`tunnel:${sanitizedContext.tunnelName}`);
|
||||
if (sanitizedContext.sessionId)
|
||||
contextParts.push(`session:${sanitizedContext.sessionId}`);
|
||||
if (sanitizedContext.requestId)
|
||||
@@ -255,10 +252,6 @@ export class Logger {
|
||||
this.info(`SSH: ${message}`, { ...context, operation: "ssh" });
|
||||
}
|
||||
|
||||
tunnel(message: string, context?: LogContext): void {
|
||||
this.info(`TUNNEL: ${message}`, { ...context, operation: "tunnel" });
|
||||
}
|
||||
|
||||
file(message: string, context?: LogContext): void {
|
||||
this.info(`FILE: ${message}`, { ...context, operation: "file" });
|
||||
}
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
import http from "http";
|
||||
import type { RequestListener } from "http";
|
||||
import type { Logger } from "./logger.js";
|
||||
|
||||
interface ServiceListenOptions {
|
||||
app: RequestListener;
|
||||
port: number;
|
||||
logger: Logger;
|
||||
/** Used only to identify the service in the conflict log. */
|
||||
serviceName: string;
|
||||
host?: string;
|
||||
onListening?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts one of the backend's fixed-port services.
|
||||
*
|
||||
* Express's `app.listen(port, host, callback)` registers that callback as
|
||||
* the server's `error` handler as well as its `listening` handler:
|
||||
*
|
||||
* if (typeof args[args.length - 1] === 'function') {
|
||||
* var done = args[args.length - 1] = once(args[args.length - 1])
|
||||
* server.once('error', done)
|
||||
* }
|
||||
*
|
||||
* so a port conflict called the service's "started" callback with an
|
||||
* EADDRINUSE error as its argument and emitted nothing. Since none of the
|
||||
* services read that argument, a failed bind was indistinguishable from a
|
||||
* successful one: the service logged that it had started, ran its
|
||||
* initialisation, and served nothing, with no error anywhere in the logs.
|
||||
*
|
||||
* Building the server here keeps `listening` and `error` separate. A port
|
||||
* conflict is treated the way database.ts already treats one on the main
|
||||
* HTTP port -- reported with the port that was taken, then exit -- rather
|
||||
* than leaving the backend up with one feature silently missing. Exiting
|
||||
* also gives the desktop app the classified failure it already knows how
|
||||
* to surface, so the user is told which port to free.
|
||||
*/
|
||||
/**
|
||||
* Reports a port conflict on an already-built service server and stops the
|
||||
* backend, for services that must own their `http.Server` (the tunnel
|
||||
* service attaches a WebSocket upgrade handler to it, for example) and so
|
||||
* cannot go through {@link listenOnServicePort}.
|
||||
*/
|
||||
function attachServicePortConflictHandler(
|
||||
server: http.Server,
|
||||
port: number,
|
||||
logger: Logger,
|
||||
serviceName: string,
|
||||
): void {
|
||||
server.on("error", (err: NodeJS.ErrnoException) => {
|
||||
if (err.code === "EADDRINUSE") {
|
||||
logger.error(
|
||||
`Port ${port} is already in use. Kill the existing process and retry.`,
|
||||
err,
|
||||
{
|
||||
operation: "service_port_conflict",
|
||||
port,
|
||||
service: serviceName,
|
||||
},
|
||||
);
|
||||
// process.exit() does not return, so nothing below runs in
|
||||
// production; the explicit return keeps that obvious and keeps the
|
||||
// rethrow below reachable only for genuinely unexpected errors.
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
export function listenOnServicePort({
|
||||
app,
|
||||
port,
|
||||
logger,
|
||||
serviceName,
|
||||
host = "127.0.0.1",
|
||||
onListening,
|
||||
}: ServiceListenOptions): http.Server {
|
||||
const server = http.createServer(app);
|
||||
attachServicePortConflictHandler(server, port, logger, serviceName);
|
||||
server.listen(port, host, onListening);
|
||||
return server;
|
||||
}
|
||||
@@ -8,6 +8,42 @@ type SwaggerJSDocOptions = Parameters<typeof swaggerJSDoc>[0];
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
// dist/backend/backend/utils -> the repository.
|
||||
const REPO_ROOT = path.join(__dirname, "..", "..", "..", "..");
|
||||
|
||||
/**
|
||||
* A tag per plugin, named and described by its manifest, so the reference
|
||||
* groups a plugin's routes without core naming any plugin.
|
||||
*/
|
||||
async function pluginTags(): Promise<{ name: string; description: string }[]> {
|
||||
const pluginsDir = path.join(REPO_ROOT, "plugins");
|
||||
const tags: { name: string; description: string }[] = [];
|
||||
let entries: string[] = [];
|
||||
try {
|
||||
entries = await fs.readdir(pluginsDir);
|
||||
} catch {
|
||||
return tags;
|
||||
}
|
||||
for (const entry of entries.sort()) {
|
||||
try {
|
||||
const manifest = JSON.parse(
|
||||
await fs.readFile(
|
||||
path.join(pluginsDir, entry, "manifest.json"),
|
||||
"utf8",
|
||||
),
|
||||
) as { name?: string; description?: string };
|
||||
if (manifest.name) {
|
||||
tags.push({
|
||||
name: manifest.name,
|
||||
description: manifest.description ?? "",
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Not a plugin folder.
|
||||
}
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
|
||||
const swaggerOptions: SwaggerJSDocOptions = {
|
||||
definition: {
|
||||
@@ -47,30 +83,14 @@ const swaggerOptions: SwaggerJSDocOptions = {
|
||||
},
|
||||
],
|
||||
tags: [
|
||||
{
|
||||
name: "AI",
|
||||
description: "AI assistant providers, conversations and proposals",
|
||||
},
|
||||
{
|
||||
name: "Alerts",
|
||||
description: "System alerts and notifications management",
|
||||
},
|
||||
{
|
||||
name: "Credentials",
|
||||
description: "SSH credential management",
|
||||
},
|
||||
{
|
||||
name: "Network Topology",
|
||||
description: "Network topology visualization and management",
|
||||
},
|
||||
{
|
||||
name: "RBAC",
|
||||
description: "Role-based access control for host sharing",
|
||||
},
|
||||
{
|
||||
name: "Terminal",
|
||||
description: "Terminal command history",
|
||||
},
|
||||
{
|
||||
name: "Users",
|
||||
description: "User management and authentication",
|
||||
@@ -79,22 +99,6 @@ const swaggerOptions: SwaggerJSDocOptions = {
|
||||
name: "Dashboard",
|
||||
description: "Dashboard statistics and activity",
|
||||
},
|
||||
{
|
||||
name: "Docker",
|
||||
description: "Docker container management",
|
||||
},
|
||||
{
|
||||
name: "SSH Tunnels",
|
||||
description: "SSH tunnel connection management",
|
||||
},
|
||||
{
|
||||
name: "Host Metrics",
|
||||
description: "Host status monitoring, metrics collection, and managers",
|
||||
},
|
||||
{
|
||||
name: "File Manager",
|
||||
description: "SSH file management operations",
|
||||
},
|
||||
{
|
||||
name: "SSH",
|
||||
description: "SSH host management and configuration",
|
||||
@@ -103,50 +107,10 @@ const swaggerOptions: SwaggerJSDocOptions = {
|
||||
name: "Host Enrollment",
|
||||
description: "Host enrollment and onboarding",
|
||||
},
|
||||
{
|
||||
name: "Fleets",
|
||||
description: "Fleet grouping, membership, and inventory",
|
||||
},
|
||||
{
|
||||
name: "Workspaces",
|
||||
description: "Saved tab and split layouts",
|
||||
},
|
||||
{
|
||||
name: "Open Tabs",
|
||||
description: "Per-user open tab state",
|
||||
},
|
||||
{
|
||||
name: "Automations",
|
||||
description: "Scheduled and triggered automations",
|
||||
},
|
||||
{
|
||||
name: "Guacamole",
|
||||
description: "Remote desktop sessions",
|
||||
},
|
||||
{
|
||||
name: "Proxmox",
|
||||
description: "Proxmox host integration",
|
||||
},
|
||||
{
|
||||
name: "Proxmox Stats",
|
||||
description: "Proxmox node and VM statistics",
|
||||
},
|
||||
{
|
||||
name: "Session Sharing",
|
||||
description: "Share live sessions by link or with another user",
|
||||
},
|
||||
{
|
||||
name: "Collab",
|
||||
description: "Collaboration rooms that present a live session",
|
||||
},
|
||||
{
|
||||
name: "Session Logs",
|
||||
description: "Session recording and playback",
|
||||
},
|
||||
{
|
||||
name: "Homepage",
|
||||
description: "Homepage service links and layout",
|
||||
},
|
||||
{
|
||||
name: "Audit",
|
||||
description: "Audit log querying and export",
|
||||
@@ -155,26 +119,10 @@ const swaggerOptions: SwaggerJSDocOptions = {
|
||||
name: "API Keys",
|
||||
description: "API key management",
|
||||
},
|
||||
{
|
||||
name: "Vault",
|
||||
description: "HashiCorp Vault SSH signing profiles",
|
||||
},
|
||||
{
|
||||
name: "Termix ID",
|
||||
description: "Built-in SSH certificate authority",
|
||||
},
|
||||
{
|
||||
name: "Tailscale",
|
||||
description: "Tailscale network integration",
|
||||
},
|
||||
{
|
||||
name: "Sync",
|
||||
description: "Remote sync between desktop and server",
|
||||
},
|
||||
{
|
||||
name: "Tunnel Presets",
|
||||
description: "Saved tunnel configurations",
|
||||
},
|
||||
{
|
||||
name: "User Preferences",
|
||||
description: "Per-user application preferences",
|
||||
@@ -201,6 +149,11 @@ const swaggerOptions: SwaggerJSDocOptions = {
|
||||
path.join(__dirname, "..", "services", "*.js").replace(/\\/g, "/"),
|
||||
path.join(__dirname, "..", "hosts", "*.js").replace(/\\/g, "/"),
|
||||
path.join(__dirname, "..", "hosts", "**", "*.js").replace(/\\/g, "/"),
|
||||
// Plugin routes document themselves. Read from source, because a plugin's
|
||||
// bundle drops comments.
|
||||
path
|
||||
.join(REPO_ROOT, "plugins", "*", "src", "backend", "**", "*.ts")
|
||||
.replace(/\\/g, "/"),
|
||||
],
|
||||
};
|
||||
|
||||
@@ -210,6 +163,8 @@ async function generateOpenAPISpec() {
|
||||
operation: "openapi_generate_start",
|
||||
});
|
||||
|
||||
const definition = swaggerOptions.definition as { tags?: unknown[] };
|
||||
definition.tags = [...(definition.tags ?? []), ...(await pluginTags())];
|
||||
const swaggerSpec = await swaggerJSDoc(swaggerOptions);
|
||||
|
||||
const outputPath = path.join(
|
||||
|
||||
@@ -2,11 +2,50 @@ import type { IncomingMessage } from "http";
|
||||
|
||||
const JWT_PROTOCOL_PREFIX = "termix.jwt.";
|
||||
|
||||
/**
|
||||
* A browser attaches the jwt cookie to a socket opened from any page on the
|
||||
* same site, including a sibling subdomain. The cookie only counts when the
|
||||
* page that opened the socket is this server. A non-web origin (the desktop
|
||||
* app) or no Origin at all is not a browser page on another host.
|
||||
*/
|
||||
export function isCookieOriginAllowed(req: IncomingMessage): boolean {
|
||||
const origin = req.headers.origin;
|
||||
if (!origin) return true;
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(origin);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return true;
|
||||
const hosts = [req.headers.host, req.headers["x-forwarded-host"]]
|
||||
.flatMap((value) => String(value ?? "").split(","))
|
||||
.map((value) => value.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
if (hosts.includes(parsed.host.toLowerCase())) return true;
|
||||
// The Vite dev proxy rewrites Host, so loopback to loopback is allowed.
|
||||
if (
|
||||
isLoopback(parsed.hostname) &&
|
||||
hosts.some((host) => isLoopback(host.replace(/:\d+$/, "")))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return (process.env.TERMIX_ALLOWED_ORIGINS ?? "")
|
||||
.split(",")
|
||||
.map((value) => value.trim().toLowerCase())
|
||||
.includes(parsed.origin.toLowerCase());
|
||||
}
|
||||
|
||||
function isLoopback(hostname: string): boolean {
|
||||
const host = hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
||||
return host === "localhost" || host === "::1" || host.startsWith("127.");
|
||||
}
|
||||
|
||||
export function extractWebSocketToken(
|
||||
req: IncomingMessage,
|
||||
): string | undefined {
|
||||
const cookieHeader = req.headers.cookie;
|
||||
if (cookieHeader) {
|
||||
if (cookieHeader && isCookieOriginAllowed(req)) {
|
||||
const match = cookieHeader.match(/(?:^|;\s*)jwt=([^;]+)/);
|
||||
if (match) return decodeURIComponent(match[1]);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
/**
|
||||
* Where a connection is. Core's SSH pipeline uses the named stages; a plugin
|
||||
* may log its own (such as "guac_ready") and they pass through unchanged.
|
||||
*/
|
||||
export type ConnectionStage =
|
||||
| "dns"
|
||||
| "tcp"
|
||||
@@ -9,29 +13,7 @@ export type ConnectionStage =
|
||||
| "proxy"
|
||||
| "jump"
|
||||
| "validation"
|
||||
| "docker_connecting"
|
||||
| "docker_auth"
|
||||
| "docker_session"
|
||||
| "docker_ready"
|
||||
| "stats_connecting"
|
||||
| "stats_totp"
|
||||
| "stats_polling"
|
||||
| "stats_heartbeat"
|
||||
| "tunnel_connecting"
|
||||
| "tunnel_source"
|
||||
| "tunnel_endpoint"
|
||||
| "tunnel_forwarding"
|
||||
| "tunnel_retry"
|
||||
| "tunnel_connected"
|
||||
| "sftp_connecting"
|
||||
| "sftp_auth"
|
||||
| "sftp_connected"
|
||||
| "guac_token"
|
||||
| "guac_guacd"
|
||||
| "guac_connecting"
|
||||
| "guac_handshake"
|
||||
| "guac_ready"
|
||||
| "guac_disconnected";
|
||||
| (string & {});
|
||||
|
||||
export type LogEntry = {
|
||||
id: string;
|
||||
|
||||
@@ -85,14 +85,6 @@ function sanitizeStringArray(input: unknown): string[] {
|
||||
return input.filter((v): v is string => typeof v === "string");
|
||||
}
|
||||
|
||||
function sanitizeEnumArray<T extends string>(
|
||||
input: unknown,
|
||||
allowed: readonly T[],
|
||||
): T[] {
|
||||
if (!Array.isArray(input)) return [];
|
||||
return input.filter((v): v is T => allowed.includes(v as T));
|
||||
}
|
||||
|
||||
export function sanitizeCredentialSidebarPreferences(
|
||||
input: unknown,
|
||||
): CredentialSidebarPreferences {
|
||||
|
||||
Vendored
+1
-383
@@ -1,383 +1 @@
|
||||
interface ServerConfig {
|
||||
serverUrl?: string;
|
||||
allowInvalidCertificate?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface ConnectionTestResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface DialogOptions {
|
||||
title?: string;
|
||||
defaultPath?: string;
|
||||
buttonLabel?: string;
|
||||
filters?: Array<{ name: string; extensions: string[] }>;
|
||||
properties?: string[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface DialogResult {
|
||||
canceled: boolean;
|
||||
filePath?: string;
|
||||
filePaths?: string[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface LocalDirectoryResult {
|
||||
success: boolean;
|
||||
path: string;
|
||||
parent?: string;
|
||||
entries: LocalFileEntry[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface LocalCollectedFile {
|
||||
path: string;
|
||||
name: string;
|
||||
relativePath: string;
|
||||
size: number;
|
||||
created?: string;
|
||||
modified: string;
|
||||
}
|
||||
|
||||
export type LocalPathMutationResult =
|
||||
| ({ success: true } & Partial<LocalFileEntry>)
|
||||
| { success: false; error?: string };
|
||||
|
||||
export interface ElectronAPI {
|
||||
getAppVersion: () => Promise<string>;
|
||||
setZoomFactor?: (factor: number) => void;
|
||||
getPlatform: () => Promise<string>;
|
||||
getSetting?: (key: string) => Promise<string | null | undefined>;
|
||||
setSetting?: (key: string, value: string) => Promise<void>;
|
||||
|
||||
getServerConfig: () => Promise<ServerConfig>;
|
||||
saveServerConfig: (config: ServerConfig) => Promise<{ success: boolean }>;
|
||||
testServerConnection: (
|
||||
serverUrl: string,
|
||||
allowInvalidCertificate?: boolean,
|
||||
) => Promise<ConnectionTestResult>;
|
||||
getC2STunnelConfig: () => Promise<unknown[]>;
|
||||
saveC2STunnelConfig: (
|
||||
config: unknown[],
|
||||
) => Promise<{ success: boolean; error?: string }>;
|
||||
checkLocalPortAvailable: (
|
||||
host: string,
|
||||
port: number,
|
||||
) => Promise<{ available: boolean; error?: string }>;
|
||||
getC2STunnelPresetDefaultName: () => Promise<string>;
|
||||
startC2STunnel: (
|
||||
tunnel: unknown,
|
||||
index: number,
|
||||
authToken?: string,
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
tunnelName?: string;
|
||||
error?: string;
|
||||
code?: string;
|
||||
}>;
|
||||
testC2STunnel: (
|
||||
tunnel: unknown,
|
||||
index: number,
|
||||
authToken?: string,
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
message?: string;
|
||||
error?: string;
|
||||
code?: string;
|
||||
}>;
|
||||
stopC2STunnel: (
|
||||
tunnelName: string,
|
||||
) => Promise<{ success: boolean; error?: string }>;
|
||||
getC2STunnelStatuses: () => Promise<Record<string, unknown>>;
|
||||
onC2STunnelStatuses?: (
|
||||
callback: (statuses: Record<string, unknown>) => void,
|
||||
) => () => void;
|
||||
startC2SAutoStartTunnels: () => Promise<{
|
||||
success: boolean;
|
||||
started: number;
|
||||
errors: string[];
|
||||
}>;
|
||||
onCloseActiveTab?: (callback: () => void) => () => void;
|
||||
clearSessionCookies: () => Promise<void>;
|
||||
getSessionCookie: (
|
||||
name: string,
|
||||
targetUrl?: string,
|
||||
) => Promise<string | null>;
|
||||
waitForSessionCookie: (
|
||||
name: string,
|
||||
targetUrl?: string,
|
||||
previousValue?: string | null,
|
||||
timeoutMs?: number,
|
||||
) => Promise<{ success: boolean; value?: string; error?: string }>;
|
||||
|
||||
showSaveDialog: (options: DialogOptions) => Promise<DialogResult>;
|
||||
showOpenDialog: (options: DialogOptions) => Promise<DialogResult>;
|
||||
getLocalHomeDirectory?: () => Promise<string>;
|
||||
listLocalDirectory?: (path?: string) => Promise<LocalDirectoryResult>;
|
||||
statLocalPaths?: (
|
||||
paths: string[],
|
||||
) => Promise<
|
||||
Array<
|
||||
| (LocalFileEntry & { success: true })
|
||||
| { success: false; path: string; error?: string }
|
||||
>
|
||||
>;
|
||||
collectLocalFiles?: (paths: string[]) => Promise<{
|
||||
success: boolean;
|
||||
files: LocalCollectedFile[];
|
||||
truncated?: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
createLocalFolder?: (
|
||||
parentPath: string,
|
||||
folderName: string,
|
||||
) => Promise<LocalPathMutationResult>;
|
||||
renameLocalPath?: (
|
||||
path: string,
|
||||
newName: string,
|
||||
) => Promise<LocalPathMutationResult>;
|
||||
trashLocalPath?: (path: string) => Promise<LocalPathMutationResult>;
|
||||
chmodLocalPath?: (
|
||||
path: string,
|
||||
permissions: string,
|
||||
) => Promise<LocalPathMutationResult>;
|
||||
|
||||
openExternalEditor: (fileData: {
|
||||
fileName: string;
|
||||
content: string;
|
||||
encoding?: "utf8" | "base64";
|
||||
editorPath?: string | null;
|
||||
}) => Promise<{
|
||||
success: boolean;
|
||||
editId?: string;
|
||||
path?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
closeExternalEditor: (editId: string) => Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
onExternalEditorSaved?: (
|
||||
callback: (payload: {
|
||||
editId: string;
|
||||
content: string;
|
||||
encoding: "utf8";
|
||||
path: string;
|
||||
}) => void,
|
||||
) => () => void;
|
||||
|
||||
onUpdateAvailable: (callback: () => void) => void;
|
||||
onUpdateDownloaded: (callback: () => void) => void;
|
||||
|
||||
removeAllListeners: (channel: string) => void;
|
||||
isElectron: boolean;
|
||||
isDev: boolean;
|
||||
|
||||
invoke: (channel: string, ...args: unknown[]) => Promise<unknown>;
|
||||
|
||||
createTempFile: (fileData: {
|
||||
fileName: string;
|
||||
content: string;
|
||||
encoding?: "base64" | "utf8";
|
||||
}) => Promise<{
|
||||
success: boolean;
|
||||
tempId?: string;
|
||||
path?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
createTempFolder: (folderData: {
|
||||
folderName: string;
|
||||
files: Array<{
|
||||
relativePath: string;
|
||||
content: string;
|
||||
encoding?: "base64" | "utf8";
|
||||
}>;
|
||||
}) => Promise<{
|
||||
success: boolean;
|
||||
tempId?: string;
|
||||
path?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
startDragToDesktop: (dragData: {
|
||||
tempId: string;
|
||||
fileName: string;
|
||||
}) => Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
cleanupTempFile: (tempId: string) => Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
startLocalTerminal(dimensions: {
|
||||
cols: number;
|
||||
rows: number;
|
||||
shell?: "default" | "wsl";
|
||||
}): Promise<{ sessionId: string; shell: string }>;
|
||||
readyLocalTerminal(sessionId: string): Promise<boolean>;
|
||||
writeLocalTerminal(sessionId: string, data: string): Promise<boolean>;
|
||||
resizeLocalTerminal(
|
||||
sessionId: string,
|
||||
cols: number,
|
||||
rows: number,
|
||||
): Promise<boolean>;
|
||||
closeLocalTerminal(sessionId: string): Promise<boolean>;
|
||||
onLocalTerminalData(
|
||||
sessionId: string,
|
||||
callback: (data: string) => void,
|
||||
): () => void;
|
||||
onLocalTerminalExit(
|
||||
sessionId: string,
|
||||
callback: (exitCode: number) => void,
|
||||
): () => void;
|
||||
|
||||
/** Local disk browsing for the dual-pane file manager (desktop only). */
|
||||
localFs?: {
|
||||
home: () => Promise<LocalFsResult<LocalFsHomeInfo>>;
|
||||
list: (dirPath: string) => Promise<LocalFsResult<LocalDirectoryListing>>;
|
||||
mkdir: (
|
||||
parentPath: string,
|
||||
name: string,
|
||||
) => Promise<LocalFsResult<{ path: string }>>;
|
||||
createFile: (
|
||||
parentPath: string,
|
||||
name: string,
|
||||
) => Promise<LocalFsResult<{ path: string }>>;
|
||||
rename: (
|
||||
oldPath: string,
|
||||
newName: string,
|
||||
) => Promise<LocalFsResult<{ path: string }>>;
|
||||
trash: (paths: string[]) => Promise<LocalFsResult<LocalTrashResult>>;
|
||||
ensureDir: (
|
||||
dirPath: string,
|
||||
rootPath?: string,
|
||||
) => Promise<LocalFsResult<{ path: string }>>;
|
||||
exists: (paths: string[]) => Promise<LocalFsResult<{ existing: string[] }>>;
|
||||
walk: (paths: string[]) => Promise<LocalFsResult<LocalWalkResult>>;
|
||||
reveal: (targetPath: string) => Promise<LocalFsResult<unknown>>;
|
||||
open: (targetPath: string) => Promise<LocalFsResult<unknown>>;
|
||||
};
|
||||
|
||||
/** Streamed local<->remote transfers driven by the main process. */
|
||||
localTransfer?: {
|
||||
upload: (
|
||||
options: LocalUploadRequest,
|
||||
) => Promise<LocalFsResult<{ bytes: number }>>;
|
||||
download: (
|
||||
options: LocalDownloadRequest,
|
||||
) => Promise<LocalFsResult<{ path: string }>>;
|
||||
cancel: (
|
||||
transferId: string,
|
||||
) => Promise<LocalFsResult<{ cancelled: boolean }>>;
|
||||
onProgress: (
|
||||
callback: (payload: LocalTransferProgress) => void,
|
||||
) => () => void;
|
||||
};
|
||||
}
|
||||
|
||||
export type LocalFsResult<T> =
|
||||
({ success: true } & T) | { success: false; error: string; code?: string };
|
||||
|
||||
export interface LocalFsHomeInfo {
|
||||
home: string;
|
||||
separator: string;
|
||||
platform: string;
|
||||
}
|
||||
|
||||
export interface LocalFileEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
type: "file" | "directory" | "link" | "other";
|
||||
created?: string;
|
||||
modified?: string;
|
||||
permissions?: string;
|
||||
owner?: string;
|
||||
group?: string;
|
||||
size: number;
|
||||
modifiedTimestamp?: number;
|
||||
linkTarget?: string;
|
||||
hidden: boolean;
|
||||
}
|
||||
|
||||
export interface LocalDirectoryListing {
|
||||
path: string;
|
||||
parent: string | null;
|
||||
entries: LocalFileEntry[];
|
||||
}
|
||||
|
||||
export interface LocalTrashResult {
|
||||
trashed: number;
|
||||
failed: Array<{ path: string; error: string }>;
|
||||
}
|
||||
|
||||
export interface LocalWalkFile {
|
||||
localPath: string;
|
||||
/** Path relative to the drop root; "/"-separated and includes the root's own name. */
|
||||
relativePath: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface LocalWalkResult {
|
||||
files: LocalWalkFile[];
|
||||
emptyDirs: string[];
|
||||
totalBytes: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which Termix backend a transfer talks to. The main process resolves the
|
||||
* actual URL and credentials for the origin; the renderer never supplies them.
|
||||
*/
|
||||
export type LocalTransferOrigin = "local" | "remote";
|
||||
|
||||
export interface LocalUploadRequest {
|
||||
transferId: string;
|
||||
origin: LocalTransferOrigin;
|
||||
fields: Record<string, string>;
|
||||
localPath: string;
|
||||
fileName: string;
|
||||
deviceId?: string;
|
||||
/** The renderer's token for the embedded backend (origin "local"); sent as a Bearer header. */
|
||||
authToken?: string;
|
||||
}
|
||||
|
||||
export interface LocalDownloadRequest {
|
||||
transferId: string;
|
||||
origin: LocalTransferOrigin;
|
||||
body: Record<string, unknown>;
|
||||
destPath: string;
|
||||
/** Selected download folder; destPath must resolve strictly inside it (code EINVAL otherwise). */
|
||||
rootPath: string;
|
||||
expectedSize?: number;
|
||||
/** Replace an existing file at destPath; otherwise the transfer is refused with code EEXIST. */
|
||||
overwrite?: boolean;
|
||||
deviceId?: string;
|
||||
/** The renderer's token for the embedded backend (origin "local"); sent as a Bearer header. */
|
||||
authToken?: string;
|
||||
}
|
||||
|
||||
export interface LocalTransferProgress {
|
||||
transferId: string;
|
||||
transferred: number;
|
||||
total?: number;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electronAPI: ElectronAPI;
|
||||
IS_ELECTRON: boolean;
|
||||
electronClipboard?: {
|
||||
writeText(text: string): Promise<boolean>;
|
||||
readText(): Promise<string>;
|
||||
};
|
||||
}
|
||||
}
|
||||
export type * from "@termix/plugin-sdk/desktop";
|
||||
Vendored
-225
@@ -1,225 +0,0 @@
|
||||
declare module "guacamole-common-js" {
|
||||
namespace Guacamole {
|
||||
class Client {
|
||||
constructor(tunnel: Tunnel);
|
||||
connect(data?: string): void;
|
||||
disconnect(): void;
|
||||
getDisplay(): Display;
|
||||
sendKeyEvent(pressed: number, keysym: number): void;
|
||||
sendMouseState(state: Mouse.State): void;
|
||||
sendSize(width: number, height: number): void;
|
||||
setClipboard(stream: OutputStream, mimetype: string): void;
|
||||
createClipboardStream(mimetype: string): OutputStream;
|
||||
onstatechange: ((state: number) => void) | null;
|
||||
onerror: ((error: Status) => void) | null;
|
||||
onsync: ((timestamp: number) => void) | null;
|
||||
onclipboard: ((stream: InputStream, mimetype: string) => void) | null;
|
||||
onaudio: ((stream: InputStream, mimetype: string) => void) | null;
|
||||
onfile:
|
||||
| ((stream: InputStream, mimetype: string, filename: string) => void)
|
||||
| null;
|
||||
onfilesystem: ((filesystem: Object, name: string) => void) | null;
|
||||
}
|
||||
|
||||
// Mirrors Guacamole.Object: a named collection of streams. Within this
|
||||
// namespace `Object` refers to this class, not the global one.
|
||||
class Object {
|
||||
static readonly ROOT_STREAM: string;
|
||||
static readonly STREAM_INDEX_MIMETYPE: string;
|
||||
readonly index: number;
|
||||
requestInputStream(
|
||||
name: string,
|
||||
bodyCallback?: (stream: InputStream, mimetype: string) => void,
|
||||
): void;
|
||||
createOutputStream(mimetype: string, name: string): OutputStream;
|
||||
onbody: ((stream: InputStream, mimetype: string) => void) | null;
|
||||
onundefine: (() => void) | null;
|
||||
}
|
||||
|
||||
class AudioPlayer {
|
||||
static getInstance(
|
||||
stream: InputStream,
|
||||
mimetype: string,
|
||||
): AudioPlayer | null;
|
||||
sync(): void;
|
||||
}
|
||||
|
||||
class Display {
|
||||
getElement(): HTMLElement;
|
||||
getWidth(): number;
|
||||
getHeight(): number;
|
||||
scale(scale: number): void;
|
||||
onresize: (() => void) | null;
|
||||
}
|
||||
|
||||
class Tunnel {
|
||||
onerror: ((status: Status) => void) | null;
|
||||
onstatechange: ((state: number) => void) | null;
|
||||
}
|
||||
|
||||
class WebSocketTunnel extends Tunnel {
|
||||
constructor(url: string);
|
||||
}
|
||||
|
||||
class SessionRecording {
|
||||
constructor(source: Blob | Tunnel, refreshInterval?: number);
|
||||
onload: (() => void) | null;
|
||||
onerror: ((message: string) => void) | null;
|
||||
onprogress: ((duration: number, parsedSize: number) => void) | null;
|
||||
onplay: (() => void) | null;
|
||||
onpause: (() => void) | null;
|
||||
onseek:
|
||||
((position: number, current: number, total: number) => void) | null;
|
||||
getDisplay(): Display;
|
||||
getPosition(): number;
|
||||
getDuration(): number;
|
||||
isPlaying(): boolean;
|
||||
play(): void;
|
||||
pause(): void;
|
||||
seek(position: number, callback?: () => void): void;
|
||||
abort(): void;
|
||||
}
|
||||
|
||||
class Mouse {
|
||||
constructor(element: HTMLElement);
|
||||
onmousedown: ((state: Mouse.State) => void) | null;
|
||||
onmouseup: ((state: Mouse.State) => void) | null;
|
||||
onmousemove: ((state: Mouse.State) => void) | null;
|
||||
onmouseout: ((state: Mouse.State) => void) | null;
|
||||
}
|
||||
|
||||
namespace Mouse {
|
||||
class State {
|
||||
constructor(
|
||||
x: number,
|
||||
y: number,
|
||||
left?: boolean,
|
||||
middle?: boolean,
|
||||
right?: boolean,
|
||||
up?: boolean,
|
||||
down?: boolean,
|
||||
);
|
||||
constructor(state: {
|
||||
x: number;
|
||||
y: number;
|
||||
left?: boolean;
|
||||
middle?: boolean;
|
||||
right?: boolean;
|
||||
up?: boolean;
|
||||
down?: boolean;
|
||||
});
|
||||
x: number;
|
||||
y: number;
|
||||
left: boolean;
|
||||
middle: boolean;
|
||||
right: boolean;
|
||||
up: boolean;
|
||||
down: boolean;
|
||||
}
|
||||
|
||||
interface MouseEvent {
|
||||
state: Mouse.State;
|
||||
preventDefault(): void;
|
||||
stopPropagation(): void;
|
||||
}
|
||||
|
||||
class Touchpad {
|
||||
constructor(element: HTMLElement);
|
||||
currentState: Mouse.State;
|
||||
clickTimingThreshold: number;
|
||||
clickMoveThreshold: number;
|
||||
scrollThreshold: number;
|
||||
onEach(
|
||||
types: string[],
|
||||
listener: (event: Mouse.MouseEvent) => void,
|
||||
): void;
|
||||
on(type: string, listener: (event: Mouse.MouseEvent) => void): void;
|
||||
}
|
||||
|
||||
class Touchscreen {
|
||||
constructor(element: HTMLElement);
|
||||
currentState: Mouse.State;
|
||||
clickTimingThreshold: number;
|
||||
clickMoveThreshold: number;
|
||||
scrollThreshold: number;
|
||||
longPressThreshold: number;
|
||||
onEach(
|
||||
types: string[],
|
||||
listener: (event: Mouse.MouseEvent) => void,
|
||||
): void;
|
||||
on(type: string, listener: (event: Mouse.MouseEvent) => void): void;
|
||||
}
|
||||
}
|
||||
|
||||
class Keyboard {
|
||||
constructor(element: Document | HTMLElement);
|
||||
reset(): void;
|
||||
onkeydown: ((keysym: number) => void) | null;
|
||||
onkeyup: ((keysym: number) => void) | null;
|
||||
}
|
||||
|
||||
class Status {
|
||||
code: number;
|
||||
message: string;
|
||||
isError(): boolean;
|
||||
static readonly Code: {
|
||||
SUCCESS: number;
|
||||
UNSUPPORTED: number;
|
||||
SERVER_ERROR: number;
|
||||
SERVER_BUSY: number;
|
||||
UPSTREAM_TIMEOUT: number;
|
||||
UPSTREAM_ERROR: number;
|
||||
RESOURCE_NOT_FOUND: number;
|
||||
RESOURCE_CONFLICT: number;
|
||||
RESOURCE_CLOSED: number;
|
||||
CLIENT_BAD_REQUEST: number;
|
||||
CLIENT_UNAUTHORIZED: number;
|
||||
CLIENT_FORBIDDEN: number;
|
||||
CLIENT_TIMEOUT: number;
|
||||
};
|
||||
}
|
||||
|
||||
class InputStream {
|
||||
onblob: ((data: string) => void) | null;
|
||||
onend: (() => void) | null;
|
||||
sendAck(message: string, code: number): void;
|
||||
}
|
||||
|
||||
class OutputStream {
|
||||
sendBlob(data: string): void;
|
||||
sendEnd(): void;
|
||||
}
|
||||
|
||||
class StringReader {
|
||||
constructor(stream: InputStream);
|
||||
ontext: ((text: string) => void) | null;
|
||||
onend: (() => void) | null;
|
||||
}
|
||||
|
||||
class StringWriter {
|
||||
constructor(stream: OutputStream);
|
||||
sendText(text: string): void;
|
||||
sendEnd(): void;
|
||||
}
|
||||
|
||||
class BlobReader {
|
||||
constructor(stream: InputStream, mimetype: string);
|
||||
getBlob(): Blob;
|
||||
getLength(): number;
|
||||
onprogress: ((length: number) => void) | null;
|
||||
onend: (() => void) | null;
|
||||
}
|
||||
|
||||
class BlobWriter {
|
||||
constructor(stream: OutputStream);
|
||||
sendBlob(blob: Blob): void;
|
||||
sendEnd(): void;
|
||||
onack: ((status: Status) => void) | null;
|
||||
onerror: ((blob: Blob, offset: number, error: Status) => void) | null;
|
||||
onprogress: ((blob: Blob, offset: number) => void) | null;
|
||||
oncomplete: ((blob: Blob) => void) | null;
|
||||
}
|
||||
}
|
||||
|
||||
export default Guacamole;
|
||||
}
|
||||
@@ -32,48 +32,6 @@ export interface HostProtocolAuthInput {
|
||||
fields?: Record<string, string | null>;
|
||||
}
|
||||
|
||||
export type WebEndpointAccess = "direct" | "tunnel";
|
||||
export type WebEndpointRender = "external" | "embedded";
|
||||
|
||||
/** One web UI a host serves, declared in the host's settings. */
|
||||
export interface WebEndpoint {
|
||||
/**
|
||||
* Stable identifier. Must NOT be derived from the port: it keys both the
|
||||
* tunnel name and the tab identity, so editing a port has to leave a live
|
||||
* tunnel findable under the same name.
|
||||
*/
|
||||
id: string;
|
||||
label: string;
|
||||
scheme: "http" | "https";
|
||||
port: number;
|
||||
/** Defaults to "/". Normalized at the storage boundary, never here. */
|
||||
path?: string;
|
||||
access: WebEndpointAccess;
|
||||
render: WebEndpointRender;
|
||||
/**
|
||||
* Direct endpoints only. Allows an invalid TLS certificate for this
|
||||
* endpoint's exact origin. A no-op for tunnel access, whose host component
|
||||
* is loopback and therefore already exempt.
|
||||
*/
|
||||
ignoreCert?: boolean;
|
||||
/**
|
||||
* Tunnel endpoints only. Where the backend binds the forward, exactly as
|
||||
* the server tunnels feature exposes it. Defaults to 127.0.0.1, reachable
|
||||
* only from the machine running the backend. A web deployment runs the
|
||||
* backend on a server, so reaching the forward from a browser needs an
|
||||
* address that machine answers on -- which also exposes the target's web UI
|
||||
* to anyone who can reach the port, with no login in front of it.
|
||||
*/
|
||||
bindHost?: string;
|
||||
/**
|
||||
* Tunnel endpoints only. Which port the forward listens on, as the server
|
||||
* tunnels feature's Source Port does. Left unset the kernel picks a free
|
||||
* one, which is fine when backend and browser share a machine -- but a
|
||||
* container can only publish ports it knows in advance.
|
||||
*/
|
||||
localPort?: number;
|
||||
}
|
||||
|
||||
export interface JumpHost {
|
||||
hostId: number;
|
||||
}
|
||||
@@ -306,29 +264,6 @@ export interface CredentialBackend {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TUNNEL TYPES
|
||||
// ============================================================================
|
||||
|
||||
export type TunnelScope = "s2s" | "c2s";
|
||||
export type TunnelMode = "local" | "remote" | "dynamic";
|
||||
|
||||
// ============================================================================
|
||||
// FILE MANAGER TYPES
|
||||
// ============================================================================
|
||||
|
||||
export interface Tab {
|
||||
id: string | number;
|
||||
title: string;
|
||||
fileName: string;
|
||||
content: string;
|
||||
isSSH?: boolean;
|
||||
sshSessionId?: string;
|
||||
filePath?: string;
|
||||
loading?: boolean;
|
||||
dirty?: boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TERMINAL CONFIGURATION TYPES
|
||||
// ============================================================================
|
||||
@@ -368,7 +303,6 @@ export interface TerminalRefHandle {
|
||||
subscribeOutput?: (listener: (data: string) => void) => () => void;
|
||||
notifyResize?: () => void;
|
||||
refresh?: () => void;
|
||||
openFileManager?: () => void;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -427,21 +361,3 @@ export interface CacheEntry<T = unknown> {
|
||||
timestamp: number;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DATABASE EXPORT/IMPORT TYPES
|
||||
// ============================================================================
|
||||
|
||||
export interface ExportSummary {
|
||||
sshHostsImported: number;
|
||||
sshCredentialsImported: number;
|
||||
pluginItemsImported: number;
|
||||
credentialUsageImported: number;
|
||||
settingsImported: number;
|
||||
skippedItems: number;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DOCKER TYPES
|
||||
// ============================================================================
|
||||
+21
-45
@@ -17,7 +17,7 @@
|
||||
*/
|
||||
|
||||
/** 2: the docker and host metrics areas moved to their plugins. */
|
||||
export const UI_PREFERENCES_VERSION = 2;
|
||||
export const UI_PREFERENCES_VERSION = 3;
|
||||
|
||||
/** Bump when onboarding gains steps existing users should be shown again. */
|
||||
export const UI_ONBOARDING_VERSION = 2;
|
||||
@@ -30,16 +30,12 @@ export type UiAreaKey =
|
||||
| "credentialList"
|
||||
| "rail"
|
||||
| "dashboard"
|
||||
| "terminal"
|
||||
| "fileManager"
|
||||
| "hostEditor";
|
||||
|
||||
export type UiDensity = "comfortable" | "compact";
|
||||
export type UiTrayTrigger = "always" | "hover" | "click" | "actionsOnly";
|
||||
export type UiRowActions = "essential" | "full";
|
||||
export type UiEmptyStateVerbosity = "minimal" | "guided";
|
||||
export type UiToolbarDensity = "icon" | "labeled" | "expanded";
|
||||
export type UiFileViewMode = "grid" | "list";
|
||||
export type UiHostEditorMode = "simple" | "full";
|
||||
|
||||
export interface UiChromePreferences {
|
||||
@@ -75,15 +71,6 @@ export interface UiDashboardPreferences {
|
||||
enabledCards: string[];
|
||||
}
|
||||
|
||||
export interface UiTerminalPreferences {
|
||||
toolbarDensity: UiToolbarDensity;
|
||||
}
|
||||
|
||||
export interface UiFileManagerPreferences {
|
||||
viewMode: UiFileViewMode;
|
||||
showHiddenFiles: boolean;
|
||||
}
|
||||
|
||||
export interface UiHostEditorPreferences {
|
||||
mode: UiHostEditorMode;
|
||||
}
|
||||
@@ -94,8 +81,6 @@ export interface UiAreaPreferences {
|
||||
credentialList: UiCredentialListPreferences;
|
||||
rail: UiRailPreferences;
|
||||
dashboard: UiDashboardPreferences;
|
||||
terminal: UiTerminalPreferences;
|
||||
fileManager: UiFileManagerPreferences;
|
||||
hostEditor: UiHostEditorPreferences;
|
||||
}
|
||||
|
||||
@@ -149,10 +134,8 @@ const BALANCED_DASHBOARD_CARDS = [
|
||||
"host_status",
|
||||
"recent_activity",
|
||||
];
|
||||
// Advanced adds service links but leaves network_graph and homepage_preview
|
||||
// off: both are wide, and enabling them by default pushes the dashboard past
|
||||
// the edge of the screen. They stay available in the Add card tray.
|
||||
const ADVANCED_DASHBOARD_CARDS = [...BALANCED_DASHBOARD_CARDS, "service_links"];
|
||||
// Only core's own cards: a plugin's cards are added from the Add card tray.
|
||||
const ADVANCED_DASHBOARD_CARDS = [...BALANCED_DASHBOARD_CARDS];
|
||||
|
||||
export const PRESETS: Record<Exclude<UiPreset, "custom">, UiAreaPreferences> = {
|
||||
simple: {
|
||||
@@ -181,8 +164,6 @@ export const PRESETS: Record<Exclude<UiPreset, "custom">, UiAreaPreferences> = {
|
||||
"host_status",
|
||||
],
|
||||
},
|
||||
terminal: { toolbarDensity: "icon" },
|
||||
fileManager: { viewMode: "grid", showHiddenFiles: false },
|
||||
hostEditor: { mode: "simple" },
|
||||
},
|
||||
balanced: {
|
||||
@@ -202,9 +183,6 @@ export const PRESETS: Record<Exclude<UiPreset, "custom">, UiAreaPreferences> = {
|
||||
credentialList: { density: "comfortable", showTags: true },
|
||||
rail: { hiddenTabs: [] },
|
||||
dashboard: { enabledCards: BALANCED_DASHBOARD_CARDS },
|
||||
terminal: { toolbarDensity: "labeled" },
|
||||
// FileManager.tsx has always defaulted to grid when nothing is stored.
|
||||
fileManager: { viewMode: "grid", showHiddenFiles: false },
|
||||
// 3 is defaultLayoutFromWidgets's own default, i.e. today's behavior.
|
||||
hostEditor: { mode: "full" },
|
||||
},
|
||||
@@ -225,9 +203,6 @@ export const PRESETS: Record<Exclude<UiPreset, "custom">, UiAreaPreferences> = {
|
||||
credentialList: { density: "compact", showTags: true },
|
||||
rail: { hiddenTabs: [] },
|
||||
dashboard: { enabledCards: ADVANCED_DASHBOARD_CARDS },
|
||||
terminal: { toolbarDensity: "expanded" },
|
||||
// List packs more files and metadata per screen than the grid.
|
||||
fileManager: { viewMode: "list", showHiddenFiles: true },
|
||||
hostEditor: { mode: "full" },
|
||||
},
|
||||
};
|
||||
@@ -273,16 +248,6 @@ const AREA_SPECS: {
|
||||
dashboard: {
|
||||
enabledCards: { kind: "stringArray" },
|
||||
},
|
||||
terminal: {
|
||||
toolbarDensity: {
|
||||
kind: "enum",
|
||||
values: ["icon", "labeled", "expanded"],
|
||||
},
|
||||
},
|
||||
fileManager: {
|
||||
viewMode: { kind: "enum", values: ["grid", "list"] },
|
||||
showHiddenFiles: { kind: "bool" },
|
||||
},
|
||||
hostEditor: {
|
||||
mode: { kind: "enum", values: ["simple", "full"] },
|
||||
},
|
||||
@@ -349,11 +314,21 @@ function sanitizePluginArea(input: unknown): Record<string, unknown> {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Version 1 stored the docker and host metrics areas under core names. */
|
||||
const LEGACY_PLUGIN_AREAS: Record<string, UiPluginAreaKey> = {
|
||||
docker: "plugin:docker",
|
||||
hostMetrics: "plugin:host-metrics",
|
||||
};
|
||||
/**
|
||||
* Areas that moved into a plugin: before `version` they were stored under
|
||||
* core's name. Version 1 had docker and host metrics, version 2 the terminal
|
||||
* and the file manager.
|
||||
*/
|
||||
const MOVED_PLUGIN_AREAS: Array<{
|
||||
before: number;
|
||||
legacy: string;
|
||||
area: UiPluginAreaKey;
|
||||
}> = [
|
||||
{ before: 2, legacy: "docker", area: "plugin:docker" }, // plugin-id-ok: 2.8 key
|
||||
{ before: 2, legacy: "hostMetrics", area: "plugin:host-metrics" },
|
||||
{ before: 3, legacy: "terminal", area: "plugin:ssh-terminal" },
|
||||
{ before: 3, legacy: "fileManager", area: "plugin:file-manager" },
|
||||
];
|
||||
|
||||
export function sanitizeUiOverrides(
|
||||
input: unknown,
|
||||
@@ -362,9 +337,10 @@ export function sanitizeUiOverrides(
|
||||
const out: Record<string, Record<string, unknown>> = {};
|
||||
if (!input || typeof input !== "object") return out as UiOverrides;
|
||||
|
||||
if (version < 2) {
|
||||
const moves = MOVED_PLUGIN_AREAS.filter((move) => version < move.before);
|
||||
if (moves.length > 0) {
|
||||
const upgraded = { ...(input as Record<string, unknown>) };
|
||||
for (const [legacy, area] of Object.entries(LEGACY_PLUGIN_AREAS)) {
|
||||
for (const { legacy, area } of moves) {
|
||||
if (legacy in upgraded) {
|
||||
upgraded[area] = upgraded[legacy];
|
||||
delete upgraded[legacy];
|
||||
|
||||
+1
-22
@@ -166,12 +166,6 @@ export type Tab = {
|
||||
host?: Host;
|
||||
openedAt: number;
|
||||
restoredSessionId?: string | null;
|
||||
/** Set when this tab joins someone else's live shared session instead of connecting/attaching its own. */
|
||||
joinSharedSessionId?: string | null;
|
||||
joinShareId?: string | null;
|
||||
initialFilePath?: string;
|
||||
/** Directory to open a Files tab into, distinct from initialFilePath (a specific file to open in an editor window). */
|
||||
initialPath?: string;
|
||||
/** Payload owned by the tab's plugin, e.g. which fleet or endpoint it shows. */
|
||||
data?: Record<string, unknown>;
|
||||
/** Present only on a split-screen container tab. Pane tab ids reference live child tabs. */
|
||||
@@ -189,7 +183,6 @@ export type Tab = {
|
||||
notifyResize?: () => void;
|
||||
refresh?: () => void;
|
||||
getApplicationCursorKeysMode?: () => boolean;
|
||||
openFileManager?: () => void;
|
||||
focus?: () => void;
|
||||
} | null>;
|
||||
};
|
||||
@@ -281,9 +274,9 @@ export type WorkspaceTabSnapshot = {
|
||||
hostNameSnapshot?: string | null;
|
||||
label: string;
|
||||
customLabel?: string;
|
||||
/** Read from payloads saved before tabs carried `data`. */
|
||||
initialFilePath?: string;
|
||||
initialPath?: string;
|
||||
/** Read from payloads saved before tabs carried `data`. */
|
||||
fleetId?: number;
|
||||
/** The tab's plugin payload. */
|
||||
data?: Record<string, unknown>;
|
||||
@@ -323,17 +316,3 @@ export type WorkspacePayload = {
|
||||
right: WorkspaceDockState;
|
||||
};
|
||||
};
|
||||
|
||||
const FOLDER_ICONS = [
|
||||
"folder",
|
||||
"server",
|
||||
"cloud",
|
||||
"database",
|
||||
"box",
|
||||
"network",
|
||||
"copy",
|
||||
"settings",
|
||||
"cpu",
|
||||
"globe",
|
||||
] as const;
|
||||
type FolderIconId = (typeof FOLDER_ICONS)[number];
|
||||
+14
-75
@@ -230,7 +230,6 @@ import {
|
||||
} from "@/shell/tab-registry";
|
||||
import { runHostAction } from "@/sidebar/host-contributions";
|
||||
import { getPanel, usePanels } from "@/shell/panel-registry";
|
||||
import { invokeAction } from "@/shell/action-registry";
|
||||
import { usePluginStore } from "@/plugin-host/plugin-store";
|
||||
import {
|
||||
notifyShellReady,
|
||||
@@ -1142,19 +1141,6 @@ export function AppShell({
|
||||
);
|
||||
}, [allHosts]);
|
||||
|
||||
// Let HostManager trigger tab opens via custom event
|
||||
useEffect(() => {
|
||||
const handle = (e: Event) => {
|
||||
const { hostId, type } = (
|
||||
e as CustomEvent<{ hostId: string; type?: TabType }>
|
||||
).detail;
|
||||
const host = allHosts.find((h) => h.id === hostId);
|
||||
if (host) connectHost(host, type);
|
||||
};
|
||||
window.addEventListener("termix:open-tab", handle);
|
||||
return () => window.removeEventListener("termix:open-tab", handle);
|
||||
}, [allHosts]);
|
||||
|
||||
function buildWorkspacePayload(): WorkspacePayload {
|
||||
return buildLayoutPayload({
|
||||
tabs,
|
||||
@@ -1205,13 +1191,16 @@ export function AppShell({
|
||||
}
|
||||
|
||||
if (target.kind === "host") {
|
||||
const newTabId = openTab(target.host, snapshot.type, {
|
||||
instanceId: createId(),
|
||||
restoredSessionId: null,
|
||||
savedLabel: snapshot.customLabel ?? snapshot.label,
|
||||
initialFilePath: snapshot.initialFilePath,
|
||||
initialPath: snapshot.initialPath,
|
||||
});
|
||||
const newTabId = openTab(
|
||||
target.host,
|
||||
snapshot.type,
|
||||
{
|
||||
instanceId: createId(),
|
||||
restoredSessionId: null,
|
||||
savedLabel: snapshot.customLabel ?? snapshot.label,
|
||||
},
|
||||
{ data: snapshotData(snapshot) },
|
||||
);
|
||||
slotIdToNewTabId.set(snapshot.slotId, newTabId);
|
||||
}
|
||||
}
|
||||
@@ -1474,10 +1463,6 @@ export function AppShell({
|
||||
instanceId: string;
|
||||
restoredSessionId: string | null;
|
||||
savedLabel?: string;
|
||||
initialFilePath?: string;
|
||||
initialPath?: string;
|
||||
joinSharedSessionId?: string | null;
|
||||
joinShareId?: string | null;
|
||||
},
|
||||
options?: {
|
||||
data?: Record<string, unknown>;
|
||||
@@ -1506,10 +1491,6 @@ export function AppShell({
|
||||
|
||||
let finalLabel = host.name;
|
||||
const savedLabel = restore?.savedLabel;
|
||||
const initialFilePath = restore?.initialFilePath;
|
||||
const initialPath = restore?.initialPath;
|
||||
const joinSharedSessionId = restore?.joinSharedSessionId ?? null;
|
||||
const joinShareId = restore?.joinShareId ?? null;
|
||||
// A saved label that doesn't match the bare host name or the auto-numbered pattern is a custom label
|
||||
const isCustomLabel =
|
||||
savedLabel != null &&
|
||||
@@ -1531,10 +1512,7 @@ export function AppShell({
|
||||
openedAt,
|
||||
terminalRef: ref,
|
||||
restoredSessionId: restore?.restoredSessionId ?? null,
|
||||
joinSharedSessionId,
|
||||
joinShareId,
|
||||
initialFilePath,
|
||||
initialPath,
|
||||
data: options?.data,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -1568,10 +1546,6 @@ export function AppShell({
|
||||
openedAt,
|
||||
terminalRef: ref,
|
||||
restoredSessionId: restore?.restoredSessionId ?? null,
|
||||
joinSharedSessionId,
|
||||
joinShareId,
|
||||
initialFilePath,
|
||||
initialPath,
|
||||
data: options?.data,
|
||||
},
|
||||
];
|
||||
@@ -1690,7 +1664,6 @@ export function AppShell({
|
||||
const id = type;
|
||||
const singletonLabels: Partial<Record<TabType, string>> = {
|
||||
"host-manager": t("nav.hostManager"),
|
||||
sftp: t("nav.sftp"),
|
||||
};
|
||||
// A plugin tab names itself; promoted rail panels reuse the rail's own
|
||||
// label so the two stay in sync.
|
||||
@@ -1701,7 +1674,7 @@ export function AppShell({
|
||||
setTabs((prev) => {
|
||||
const existing = prev.find((t) => t.id === id);
|
||||
if (existing) {
|
||||
// --- tmux-monitor --- refocusing with a host preselects it
|
||||
// Refocusing a singleton with a host or data passes it on.
|
||||
if (!host && data === undefined) return prev;
|
||||
return prev.map((t) =>
|
||||
t.id === id
|
||||
@@ -1721,7 +1694,7 @@ export function AppShell({
|
||||
type,
|
||||
label,
|
||||
openedAt: Date.now(),
|
||||
...(host ? { host } : {}), // --- tmux-monitor ---
|
||||
...(host ? { host } : {}),
|
||||
...(data !== undefined ? { data } : {}),
|
||||
},
|
||||
];
|
||||
@@ -2537,6 +2510,7 @@ export function AppShell({
|
||||
},
|
||||
openSingletonTab: (type, options) =>
|
||||
openSingletonTab(type, undefined, undefined, options?.data),
|
||||
connectHost: (host, type) => connectHost(host, type as TabType),
|
||||
closeTab: (tabId) => closeTab(tabId),
|
||||
renameTab: (tabId, label) => renameTab(tabId, label),
|
||||
|
||||
@@ -2697,35 +2671,6 @@ export function AppShell({
|
||||
}}
|
||||
onRenameTab={renameTab}
|
||||
onReorderTabs={setTabs}
|
||||
onJoinSharedSession={(session) => {
|
||||
if (!session.shareId) return;
|
||||
const existingHost = allHosts.find(
|
||||
(h) => h.id === String(session.hostId),
|
||||
);
|
||||
const host: Host = existingHost ?? {
|
||||
id: String(session.hostId),
|
||||
name: session.hostName,
|
||||
username: "",
|
||||
ip: "",
|
||||
port: 0,
|
||||
folder: "",
|
||||
online: false,
|
||||
cpu: null,
|
||||
ram: null,
|
||||
lastAccess: new Date().toISOString(),
|
||||
authType: "none",
|
||||
enableSsh: false,
|
||||
sshPort: 22,
|
||||
};
|
||||
void invokeAction("terminal.open", host, {
|
||||
joinSharedSessionId: session.sessionId,
|
||||
joinShareId: session.shareId,
|
||||
label: t("connections.sharedSessionLabel", {
|
||||
hostName: session.hostName,
|
||||
}),
|
||||
});
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -3037,12 +2982,6 @@ export function AppShell({
|
||||
onReorderTabs={reorderTopLevelTabs}
|
||||
onSplitAction={handleTabSplitAction}
|
||||
onRenameTab={renameTab}
|
||||
onOpenFileManager={(tabId) => {
|
||||
const targetTab = tabs.find((t) => t.id === tabId);
|
||||
if (targetTab?.host) {
|
||||
void invokeAction("host.openFiles", targetTab.host);
|
||||
}
|
||||
}}
|
||||
isAppFullscreen={isAppFullscreen}
|
||||
onToggleAppFullscreen={toggleAppFullscreen}
|
||||
rightDockOpen={rightRailView !== null}
|
||||
|
||||
@@ -11,7 +11,7 @@ export interface PublicLoginMethod {
|
||||
kind: "redirect" | "form";
|
||||
labelKey: string;
|
||||
icon?: string;
|
||||
instances: Array<{ id: string; label: string }>;
|
||||
instances: Array<{ id: string; label: string; autoStart?: boolean }>;
|
||||
}
|
||||
|
||||
export interface SecondFactorRef {
|
||||
|
||||
@@ -12,7 +12,7 @@ export interface UptimeInfo {
|
||||
export interface RecentActivityItem {
|
||||
id: number;
|
||||
userId: string;
|
||||
/** Core records file_manager and tunnel; plugin tabs record their own. */
|
||||
/** The activity type; each plugin records its own. */
|
||||
type: string;
|
||||
hostId: number;
|
||||
hostName: string;
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { authApi, handleApiError } from "@/main-axios";
|
||||
|
||||
// EXTERNAL ACCOUNT LINKING
|
||||
// ============================================================================
|
||||
|
||||
export async function linkExternalToPasswordAccount(
|
||||
externalUserId: string,
|
||||
targetUsername: string,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const response = await authApi.post("/users/link-external-to-password", {
|
||||
externalUserId,
|
||||
targetUsername,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "link external account to password account");
|
||||
}
|
||||
}
|
||||
|
||||
export async function unlinkExternalFromPasswordAccount(
|
||||
userId: string,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const response = await authApi.post(
|
||||
"/users/unlink-external-from-password",
|
||||
{ userId },
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(
|
||||
error,
|
||||
"unlink external sign-in from password account",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { authApi, handleApiError } from "@/main-axios";
|
||||
|
||||
// OIDC ACCOUNT LINKING
|
||||
// ============================================================================
|
||||
|
||||
export async function linkOIDCToPasswordAccount(
|
||||
oidcUserId: string,
|
||||
targetUsername: string,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const response = await authApi.post("/users/link-oidc-to-password", {
|
||||
oidcUserId,
|
||||
targetUsername,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "link OIDC account to password account");
|
||||
}
|
||||
}
|
||||
|
||||
export async function unlinkOIDCFromPasswordAccount(
|
||||
userId: string,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const response = await authApi.post("/users/unlink-oidc-from-password", {
|
||||
userId,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "unlink OIDC from password account");
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,6 @@
|
||||
import { authApi } from "@/main-axios";
|
||||
import { createTtlRequestCache } from "@/lib/ttl-request-cache";
|
||||
import type { CustomKeybinding } from "@/types/keybindings";
|
||||
import { invokeAction, isActionRegistered } from "@/shell/action-registry";
|
||||
|
||||
const SHARED_WITH_ME_ACTION = "sessions.sharedWithMe";
|
||||
|
||||
// OPEN TABS API
|
||||
// ============================================================================
|
||||
@@ -45,10 +42,6 @@ export interface ActiveSessionInfo {
|
||||
tabInstanceId: string | null;
|
||||
isConnected: boolean;
|
||||
createdAt: number;
|
||||
isOwnSession: boolean;
|
||||
sharedByUsername: string | null;
|
||||
permissionLevel: string | null;
|
||||
shareId: string | null;
|
||||
}
|
||||
|
||||
// Negative IDs identify remote-only shared hosts, absent from the local DB.
|
||||
@@ -86,21 +79,11 @@ export async function addOpenTab(tab: OpenTabUpsertPayload): Promise<void> {
|
||||
await authApi.post("/open-tabs", tab);
|
||||
}
|
||||
|
||||
/**
|
||||
* The caller's live sessions, plus sessions other users shared with them,
|
||||
* which the session sharing plugin answers through an action (none while it
|
||||
* is off).
|
||||
*/
|
||||
/** The caller's own live sessions. */
|
||||
export async function getActiveSessions(): Promise<ActiveSessionInfo[]> {
|
||||
return activeSessionsCache.get(async () => {
|
||||
const [response, shared] = await Promise.all([
|
||||
authApi.get("/open-tabs/active-sessions"),
|
||||
isActionRegistered(SHARED_WITH_ME_ACTION)
|
||||
? invokeAction(SHARED_WITH_ME_ACTION).catch(() => [])
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
const own = Array.isArray(response.data) ? response.data : [];
|
||||
return Array.isArray(shared) ? [...own, ...shared] : own;
|
||||
const response = await authApi.get("/open-tabs/active-sessions");
|
||||
return Array.isArray(response.data) ? response.data : [];
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -78,19 +78,10 @@ export interface PluginContributions {
|
||||
uiPresets?: PluginUiPresets;
|
||||
}
|
||||
|
||||
/** A secret as it arrives from the server. The value never leaves the server. */
|
||||
export interface RedactedSecret {
|
||||
set: boolean;
|
||||
}
|
||||
|
||||
export function isRedactedSecret(value: unknown): value is RedactedSecret {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
!Array.isArray(value) &&
|
||||
typeof (value as RedactedSecret).set === "boolean"
|
||||
);
|
||||
}
|
||||
export {
|
||||
isRedactedSecret,
|
||||
type RedactedSecret,
|
||||
} from "@termix/plugin-sdk/settings";
|
||||
|
||||
export type PluginSettingsValues = Record<string, unknown>;
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ export async function updateAnalyticsEnabled(
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TERMINAL IMAGE STORAGE SETTINGS
|
||||
// BRANDING SETTINGS
|
||||
// ============================================================================
|
||||
|
||||
export interface BrandingSettings {
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
export interface ServerSentEvent {
|
||||
event: string;
|
||||
data: string;
|
||||
}
|
||||
|
||||
export async function streamServerSentEvents(
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
onEvent: (event: ServerSentEvent) => void,
|
||||
): Promise<void> {
|
||||
const response = await fetch(url, init);
|
||||
if (!response.ok) {
|
||||
throw new Error(`SSE request failed with status ${response.status}`);
|
||||
}
|
||||
if (!response.body) throw new Error("SSE response has no body");
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let eventName = "message";
|
||||
let data: string[] = [];
|
||||
|
||||
const dispatch = () => {
|
||||
if (data.length > 0) onEvent({ event: eventName, data: data.join("\n") });
|
||||
eventName = "message";
|
||||
data = [];
|
||||
};
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
buffer += decoder.decode(value, { stream: !done });
|
||||
const lines = buffer.split(/\r?\n/);
|
||||
buffer = done ? "" : (lines.pop() ?? "");
|
||||
|
||||
for (const line of lines) {
|
||||
if (line === "") dispatch();
|
||||
else if (line.startsWith("event:")) eventName = line.slice(6).trimStart();
|
||||
else if (line.startsWith("data:")) data.push(line.slice(5).trimStart());
|
||||
}
|
||||
|
||||
if (done) {
|
||||
dispatch();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -221,20 +221,22 @@ export async function updateRegistrationAllowed(
|
||||
}
|
||||
}
|
||||
|
||||
export async function getOidcAutoProvision(): Promise<{ enabled: boolean }> {
|
||||
export async function getExternalAutoProvision(): Promise<{
|
||||
enabled: boolean;
|
||||
}> {
|
||||
try {
|
||||
const response = await authApi.get("/users/oidc-auto-provision");
|
||||
const response = await authApi.get("/users/external-auto-provision");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "check OIDC auto-provision status");
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateOidcAutoProvision(
|
||||
export async function updateExternalAutoProvision(
|
||||
enabled: boolean,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await authApi.patch("/users/oidc-auto-provision", {
|
||||
const response = await authApi.patch("/users/external-auto-provision", {
|
||||
enabled,
|
||||
});
|
||||
return response.data;
|
||||
@@ -270,31 +272,6 @@ export async function updateSecondFactorAfterExternalLogin(
|
||||
}
|
||||
}
|
||||
|
||||
export async function getOidcSilentLoginDefault(): Promise<{
|
||||
enabled: boolean;
|
||||
locked?: boolean;
|
||||
}> {
|
||||
try {
|
||||
const response = await authApi.get("/users/oidc-silent-login-default");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "get OIDC silent login default");
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateOidcSilentLoginDefault(
|
||||
enabled: boolean,
|
||||
): Promise<{ enabled: boolean }> {
|
||||
try {
|
||||
const response = await authApi.patch("/users/oidc-silent-login-default", {
|
||||
enabled,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "update OIDC silent login default");
|
||||
}
|
||||
}
|
||||
|
||||
export async function updatePasswordLoginAllowed(
|
||||
allowed: boolean,
|
||||
): Promise<{ allowed: boolean }> {
|
||||
|
||||
+24
-27
@@ -27,7 +27,6 @@ import {
|
||||
completePasswordReset,
|
||||
isElectron,
|
||||
getCurrentToken,
|
||||
getOidcSilentLoginDefault,
|
||||
requestDesktopAutoSession,
|
||||
requestTrustedProxyLogin,
|
||||
} from "@/main-axios";
|
||||
@@ -343,9 +342,6 @@ export function Auth({ onLogin }: AuthProps) {
|
||||
);
|
||||
const silentSigninHandledRef = useRef(false);
|
||||
const proxySigninHandledRef = useRef(false);
|
||||
const [oidcSilentLoginDefault, setOidcSilentLoginDefault] = useState(false);
|
||||
const [oidcSilentLoginDefaultLoaded, setOidcSilentLoginDefaultLoaded] =
|
||||
useState(false);
|
||||
const [firstUser, setFirstUser] = useState(false);
|
||||
const [dbConnectionFailed, setDbConnectionFailed] = useState(false);
|
||||
const [dbHealthChecking, setDbHealthChecking] = useState(
|
||||
@@ -401,7 +397,7 @@ export function Auth({ onLogin }: AuthProps) {
|
||||
useEffect(() => {
|
||||
if (!isInElectronWebView()) return;
|
||||
|
||||
const handleOIDCSystemBrowserResult = (event: MessageEvent) => {
|
||||
const handleExternalBrowserResult = (event: MessageEvent) => {
|
||||
if (event.source !== window.parent) return;
|
||||
if (!event.data || typeof event.data !== "object") return;
|
||||
if (event.data.type !== "OIDC_SYSTEM_BROWSER_AUTH_RESULT") return;
|
||||
@@ -418,15 +414,14 @@ export function Auth({ onLogin }: AuthProps) {
|
||||
toast.error(event.data.error || t("errors.failedOidcLogin"));
|
||||
};
|
||||
|
||||
window.addEventListener("message", handleOIDCSystemBrowserResult);
|
||||
window.addEventListener("message", handleExternalBrowserResult);
|
||||
return () =>
|
||||
window.removeEventListener("message", handleOIDCSystemBrowserResult);
|
||||
window.removeEventListener("message", handleExternalBrowserResult);
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (localDesktopAuth) {
|
||||
setAuthMethodsLoaded(true);
|
||||
setOidcSilentLoginDefaultLoaded(true);
|
||||
return;
|
||||
}
|
||||
getRegistrationAllowed()
|
||||
@@ -444,10 +439,6 @@ export function Auth({ onLogin }: AuthProps) {
|
||||
.then((methods) => setAuthMethods(methods))
|
||||
.catch(() => setAuthMethods([]))
|
||||
.finally(() => setAuthMethodsLoaded(true));
|
||||
getOidcSilentLoginDefault()
|
||||
.then((res) => setOidcSilentLoginDefault(res.enabled))
|
||||
.catch(() => {})
|
||||
.finally(() => setOidcSilentLoginDefaultLoaded(true));
|
||||
}, [localDesktopAuth]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -997,7 +988,7 @@ export function Auth({ onLogin }: AuthProps) {
|
||||
const electronAPI = (
|
||||
window as unknown as {
|
||||
electronAPI?: {
|
||||
oidcSystemBrowserAuth?: (
|
||||
externalBrowserLogin?: (
|
||||
authUrl: string,
|
||||
port: number,
|
||||
) => Promise<{
|
||||
@@ -1011,14 +1002,14 @@ export function Auth({ onLogin }: AuthProps) {
|
||||
};
|
||||
}
|
||||
).electronAPI;
|
||||
if (electronAPI?.oidcSystemBrowserAuth) {
|
||||
if (electronAPI?.externalBrowserLogin) {
|
||||
const authUrl = await startLoginRedirect(methodId, {
|
||||
instanceId,
|
||||
rememberMe,
|
||||
desktopCallbackPort: callbackPort,
|
||||
});
|
||||
if (!authUrl) throw new Error(t("errors.invalidAuthUrl"));
|
||||
const result = await electronAPI.oidcSystemBrowserAuth(
|
||||
const result = await electronAPI.externalBrowserLogin(
|
||||
authUrl,
|
||||
callbackPort,
|
||||
);
|
||||
@@ -1093,10 +1084,18 @@ export function Auth({ onLogin }: AuthProps) {
|
||||
|
||||
useEffect(() => {
|
||||
if (!authMethodsLoaded || silentSigninHandledRef.current) return;
|
||||
if (!oidcSilentLoginDefaultLoaded) return;
|
||||
|
||||
// A login method may ask to start without a click (the sso plugin's
|
||||
// silent sign-in setting); ?silent_signin asks for it once.
|
||||
const autoStart = externalMethods
|
||||
.filter((method) => method.kind === "redirect")
|
||||
.flatMap((method) =>
|
||||
method.instances
|
||||
.filter((instance) => instance.autoStart)
|
||||
.map((instance) => ({ method, instance })),
|
||||
)[0];
|
||||
const urlTriggered = shouldTriggerSilentSignin(window.location.search);
|
||||
if (!urlTriggered && !oidcSilentLoginDefault) return;
|
||||
if (!urlTriggered && !autoStart) return;
|
||||
|
||||
if (urlTriggered) {
|
||||
const nextSearch = removeSilentSigninFromSearch(window.location.search);
|
||||
@@ -1112,22 +1111,20 @@ export function Auth({ onLogin }: AuthProps) {
|
||||
const redirectMethod = externalMethods.find(
|
||||
(method) => method.kind === "redirect" && method.instances.length > 0,
|
||||
);
|
||||
if (redirectMethod && !isElectron()) {
|
||||
void startRedirect(redirectMethod.id, redirectMethod.instances[0].id);
|
||||
const target =
|
||||
autoStart ??
|
||||
(redirectMethod
|
||||
? { method: redirectMethod, instance: redirectMethod.instances[0] }
|
||||
: null);
|
||||
if (target && !isElectron()) {
|
||||
void startRedirect(target.method.id, target.instance.id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (urlTriggered) {
|
||||
toast.info(t("errors.silentSigninOidcUnavailable"));
|
||||
}
|
||||
}, [
|
||||
startRedirect,
|
||||
authMethodsLoaded,
|
||||
externalMethods,
|
||||
t,
|
||||
oidcSilentLoginDefault,
|
||||
oidcSilentLoginDefaultLoaded,
|
||||
]);
|
||||
}, [startRedirect, authMethodsLoaded, externalMethods, t]);
|
||||
|
||||
// Electron, non-iframed: wait for the auto-session probe before rendering
|
||||
// anything, so a standalone desktop install never flashes a login form
|
||||
|
||||
@@ -121,7 +121,7 @@ export function ElectronLoginForm({
|
||||
const electronAPI = (
|
||||
window as unknown as {
|
||||
electronAPI?: {
|
||||
oidcSystemBrowserAuth?: (
|
||||
externalBrowserLogin?: (
|
||||
url: string,
|
||||
port: number,
|
||||
) => Promise<{
|
||||
@@ -135,14 +135,14 @@ export function ElectronLoginForm({
|
||||
};
|
||||
}
|
||||
).electronAPI;
|
||||
if (!electronAPI?.oidcSystemBrowserAuth) {
|
||||
if (!electronAPI?.externalBrowserLogin) {
|
||||
sendResultToIframe({
|
||||
success: false,
|
||||
error: t("errors.failedOidcLogin"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const result = await electronAPI.oidcSystemBrowserAuth(
|
||||
const result = await electronAPI.externalBrowserLogin(
|
||||
authUrl,
|
||||
callbackPort,
|
||||
);
|
||||
|
||||
+1
-1
@@ -48,7 +48,7 @@ export const FullScreenAppWrapper: React.FC<FullScreenAppWrapperProps> = ({
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Popped-out windows (terminal/tunnel/file-manager/etc.) share the main
|
||||
// Popped-out plugin windows share the main
|
||||
// window's session rather than owning their own login -- there's no
|
||||
// login form to fall back to here. On a cold Electron launch this
|
||||
// window's renderer can start before the embedded backend has finished
|
||||
@@ -1,40 +1,2 @@
|
||||
import type { ConnectionStage } from "@/types/connection-log.ts";
|
||||
|
||||
export type ConnectionStatus =
|
||||
"connecting" | "connected" | "error" | "disconnected";
|
||||
|
||||
// Guacamole client states, per guacamole-common-js Guacamole.Client#STATE_*.
|
||||
const GUAC_STATE_IDLE = 0;
|
||||
const GUAC_STATE_CONNECTING = 1;
|
||||
const GUAC_STATE_WAITING = 2;
|
||||
const GUAC_STATE_CONNECTED = 3;
|
||||
const GUAC_STATE_DISCONNECTING = 4;
|
||||
const GUAC_STATE_DISCONNECTED = 5;
|
||||
|
||||
export function guacStateToStage(state: number): ConnectionStage {
|
||||
switch (state) {
|
||||
case GUAC_STATE_CONNECTING:
|
||||
return "guac_connecting";
|
||||
case GUAC_STATE_WAITING:
|
||||
return "guac_handshake";
|
||||
case GUAC_STATE_CONNECTED:
|
||||
return "guac_ready";
|
||||
case GUAC_STATE_DISCONNECTING:
|
||||
case GUAC_STATE_DISCONNECTED:
|
||||
return "guac_disconnected";
|
||||
case GUAC_STATE_IDLE:
|
||||
default:
|
||||
return "guac_connecting";
|
||||
}
|
||||
}
|
||||
|
||||
export function guacStateToStatus(state: number): ConnectionStatus {
|
||||
switch (state) {
|
||||
case GUAC_STATE_CONNECTED:
|
||||
return "connected";
|
||||
case GUAC_STATE_DISCONNECTED:
|
||||
return "error";
|
||||
default:
|
||||
return "connecting";
|
||||
}
|
||||
}
|
||||
@@ -484,9 +484,9 @@ export function HostStatusCard({
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-3">
|
||||
{/* Plugins such as host metrics show live usage here. */}
|
||||
{/* Plugins add live details to a host row here. */}
|
||||
<ComponentSlot
|
||||
slotId="dashboard.hostMetrics"
|
||||
slotId="dashboard.hostRow"
|
||||
props={{
|
||||
hostId: Number(host.id),
|
||||
online: availability === "online",
|
||||
|
||||
+4
-81
@@ -1,5 +1,6 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "./theme.css";
|
||||
@import "@fontsource-variable/jetbrains-mono";
|
||||
@import "@fontsource/jetbrains-mono/400.css";
|
||||
@import "@fontsource/jetbrains-mono/400-italic.css";
|
||||
@@ -12,6 +13,9 @@
|
||||
@import "@fontsource/source-code-pro/700.css";
|
||||
@import "@fontsource/source-code-pro/700-italic.css";
|
||||
|
||||
/* Plugins compile their own classes (termix-plugin build). */
|
||||
@source not "../../plugins";
|
||||
|
||||
@font-face {
|
||||
font-family: "Caskaydia Cove Nerd Font Mono";
|
||||
src: url("/fonts/CaskaydiaCoveNerdFontMono-Regular.ttf") format("truetype");
|
||||
@@ -44,61 +48,6 @@
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
@custom-variant dracula (&:is(.dracula *));
|
||||
@custom-variant catppuccin (&:is(.catppuccin *));
|
||||
@custom-variant nord (&:is(.nord *));
|
||||
@custom-variant solarized (&:is(.solarized *));
|
||||
@custom-variant tokyo-night (&:is(.tokyo-night *));
|
||||
@custom-variant one-dark (&:is(.one-dark *));
|
||||
@custom-variant gruvbox (&:is(.gruvbox *));
|
||||
|
||||
@theme inline {
|
||||
--font-heading: var(--font-mono);
|
||||
--font-mono: var(--font-ui, "JetBrains Mono Variable", monospace);
|
||||
--color-accent-brand: var(--accent-brand);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-background: var(--background);
|
||||
--color-surface: var(--surface);
|
||||
--color-surface-dim: var(--surface-dim);
|
||||
--radius-sm: calc(var(--radius) * 0.6);
|
||||
--radius-md: calc(var(--radius) * 0.8);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) * 1.4);
|
||||
--radius-2xl: calc(var(--radius) * 1.8);
|
||||
--radius-3xl: calc(var(--radius) * 2.2);
|
||||
--radius-4xl: calc(var(--radius) * 2.6);
|
||||
}
|
||||
|
||||
:root {
|
||||
--accent-brand: #f59145;
|
||||
--motion-duration-micro: 110ms;
|
||||
@@ -633,28 +582,6 @@ html.fs-xl {
|
||||
}
|
||||
}
|
||||
|
||||
@utility scrollbar-none {
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@utility thin-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: oklch(1 0 0 / 15%) transparent;
|
||||
&::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: oklch(1 0 0 / 15%);
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%,
|
||||
100% {
|
||||
@@ -665,10 +592,6 @@ html.fs-xl {
|
||||
}
|
||||
}
|
||||
|
||||
.terminal-toolbar-collapsed-shell {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Motion communicates state and hierarchy. Keep it off the terminal glyph layer. */
|
||||
.motion-interactive {
|
||||
transition-duration: var(--motion-duration-micro);
|
||||
|
||||
@@ -165,10 +165,4 @@ export async function applyPresetSideEffects(
|
||||
JSON.stringify(buildDashboardSlots(target.dashboard.enabledCards)),
|
||||
);
|
||||
window.dispatchEvent(new Event("dashboardSlotsChanged"));
|
||||
|
||||
writeLocal("termix-terminal-toolbar-density", target.terminal.toolbarDensity);
|
||||
window.dispatchEvent(new Event("terminalToolbarDensityChanged"));
|
||||
|
||||
writeLocal("fileManagerViewMode", target.fileManager.viewMode);
|
||||
window.dispatchEvent(new Event("fileManagerViewModeChanged"));
|
||||
}
|
||||
@@ -4,7 +4,6 @@ export interface LogContext {
|
||||
operation?: string;
|
||||
userId?: string;
|
||||
hostId?: number;
|
||||
tunnelName?: string;
|
||||
sessionId?: string;
|
||||
requestId?: string;
|
||||
duration?: number;
|
||||
@@ -53,7 +52,6 @@ class FrontendLogger {
|
||||
if (context.operation) contextParts.push(context.operation);
|
||||
if (context.userId) contextParts.push(`user:${context.userId}`);
|
||||
if (context.hostId) contextParts.push(`host:${context.hostId}`);
|
||||
if (context.tunnelName) contextParts.push(`tunnel:${context.tunnelName}`);
|
||||
if (context.sessionId) contextParts.push(`session:${context.sessionId}`);
|
||||
if (context.responseTime) contextParts.push(`${context.responseTime}ms`);
|
||||
if (context.status) contextParts.push(`status:${context.status}`);
|
||||
@@ -372,8 +370,6 @@ export const apiLogger = new FrontendLogger("API", "🌐", "#3b82f6");
|
||||
export const authLogger = new FrontendLogger("AUTH", "🔐", "#dc2626");
|
||||
export const sshLogger = new FrontendLogger("SSH", "🖥️", "#1e3a8a");
|
||||
export const fileLogger = new FrontendLogger("FILE", "📁", "#1e3a8a");
|
||||
export const statsLogger = new FrontendLogger("STATS", "📊", "#22c55e");
|
||||
const systemLogger = new FrontendLogger("SYSTEM", "🚀", "#1e3a8a");
|
||||
export const dashboardLogger = new FrontendLogger("DASHBOARD", "📊", "#ec4899");
|
||||
|
||||
/** A named console logger, for plugins that want the same log format. */
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ export const DASHBOARD_CARDS: DashboardCardConfig[] = [
|
||||
{
|
||||
id: "counters_bar",
|
||||
label: "Counters Bar",
|
||||
description: "Total hosts, credentials, and tunnels count",
|
||||
description: "Host and credential totals, plus any counters plugins add",
|
||||
defaultEnabled: true,
|
||||
},
|
||||
{
|
||||
|
||||
+35
-119
@@ -257,7 +257,6 @@
|
||||
"nav": {
|
||||
"home": "Home",
|
||||
"terminal": "Terminal",
|
||||
"localTerminal": "Local Terminal",
|
||||
"fileManager": "File Manager",
|
||||
"serverStats": "Host Metrics",
|
||||
"admin": "Admin",
|
||||
@@ -274,11 +273,8 @@
|
||||
"failedToCopyPassword": "Failed to copy password",
|
||||
"refreshTab": "Refresh connection",
|
||||
"renameTab": "Rename tab",
|
||||
"openFileManager": "Open File Manager",
|
||||
"dashboard": "Dashboard",
|
||||
"quickConnect": "Quick Connect",
|
||||
"sshTools": "SSH Tools",
|
||||
"history": "History",
|
||||
"openAsTabHint": "Ctrl+click or middle click to open as a tab",
|
||||
"openAsTab": "Open as tab",
|
||||
"openInRightDock": "Open in right dock",
|
||||
@@ -320,9 +316,6 @@
|
||||
"addHost": "Add Host",
|
||||
"editHost": "Edit Host",
|
||||
"cloneHost": "Clone Host",
|
||||
"enableTunnel": "Enable Tunnel",
|
||||
"enableFileManager": "Enable File Manager",
|
||||
"enableDocker": "Enable Docker",
|
||||
"defaultPath": "Default Path",
|
||||
"connection": "Connection",
|
||||
"upload": "Upload",
|
||||
@@ -359,19 +352,17 @@
|
||||
"behavior": "Behavior",
|
||||
"scrollbackBuffer": "Scrollback Buffer",
|
||||
"sshAgentForwarding": "SSH Agent Forwarding",
|
||||
"startupSnippet": "Startup Snippet",
|
||||
"selectSnippet": "Select snippet",
|
||||
"forceKeyboardInteractive": "Force Keyboard-Interactive",
|
||||
"overrideCredentialUsername": "Override Credential Username",
|
||||
"overrideCredentialUsernameDesc": "Use the username specified above instead of the credential's username",
|
||||
"oidcUsernameHint": "Use $oidc.preferred_username to substitute your OIDC login name.",
|
||||
"oidcUsernameHint": "Use $external.username to fill in the name you signed in with through SSO or LDAP.",
|
||||
"jumpHostChain": "Jump Host Chain",
|
||||
"portKnocking": "Port Knocking",
|
||||
"addKnock": "Add Port",
|
||||
"addProxyNode": "Add Node",
|
||||
"proxyNode": "Proxy Node",
|
||||
"proxyType": "Proxy Type",
|
||||
"quickActions": "Quick Actions",
|
||||
"sudoPassword": "Sudo Password",
|
||||
"keepaliveInterval": "Keepalive Interval (ms)",
|
||||
"environmentVariables": "Environment Variables",
|
||||
@@ -468,11 +459,6 @@
|
||||
"keepaliveIntervalLabel": "Keepalive Interval (seconds)",
|
||||
"maxKeepaliveMisses": "Max Keepalive Misses",
|
||||
"sameHost": "This host (direct tunnel)",
|
||||
"proxmoxLastSyncStatus": {
|
||||
"success": "Success"
|
||||
},
|
||||
"proxmoxResultSkippedNoIp": "{{count}} skipped (no IP found)",
|
||||
"proxmoxStatsUrlCopied": "Proxmox Stats URL copied to clipboard",
|
||||
"statusChecksLabel": "Status Checks",
|
||||
"enableStatusChecks": "Enable Status Checks",
|
||||
"enableStatusChecksDesc": "Periodically ping this host to verify availability",
|
||||
@@ -634,8 +620,6 @@
|
||||
"openInNewTab": "Open in new tab",
|
||||
"deletedHost": "Deleted {{name}}",
|
||||
"copiedToClipboard": "Copied to clipboard",
|
||||
"rdpUrlCopied": "RDP URL copied",
|
||||
"vncUrlCopied": "VNC URL copied",
|
||||
"remoteDesktopUrlCopied": "Remote Desktop URL copied",
|
||||
"expandActions": "Expand actions",
|
||||
"collapseActions": "Collapse actions",
|
||||
@@ -755,7 +739,6 @@
|
||||
"filterAuthKey": "SSH Key",
|
||||
"filterAuthCredential": "Credential",
|
||||
"filterAuthNone": "None",
|
||||
"filterAuthStepca": "Step CA",
|
||||
"filterProtocolGroup": "Protocol",
|
||||
"filterProtocolSsh": "SSH",
|
||||
"filterTagsGroup": "Tags",
|
||||
@@ -843,24 +826,6 @@
|
||||
"authOverrideLoadError": "Failed to load your SSH authentication. Please try again.",
|
||||
"authOverrideSaveError": "Failed to save your SSH authentication"
|
||||
},
|
||||
"guac": {
|
||||
"noCredential": "No credential (direct credentials below)",
|
||||
"authTypeDirect": "Direct",
|
||||
"authTypeCredential": "Credential",
|
||||
"authTypeNone": "None",
|
||||
"recordingPath": "Recording Path",
|
||||
"recordingName": "Recording Name",
|
||||
"saveHostFirst": "Save the host first.",
|
||||
"sharingOptionsAfterSave": "Sharing options are available after the host has been saved.",
|
||||
"permissionLevel": "Permission Level",
|
||||
"typeHeader": "Type",
|
||||
"targetHeader": "Target",
|
||||
"permissionHeader": "Permission",
|
||||
"cancelBtn": "Cancel",
|
||||
"savingBtn": "Saving...",
|
||||
"updateHostBtn": "Update Host",
|
||||
"addHostBtn": "Add Host"
|
||||
},
|
||||
"arrangeUnlockedSwitchedToManual": "Switched to manual sort so your order sticks",
|
||||
"dragToRearrange": "Drag to rearrange",
|
||||
"arrangeUnlocked": "Unlock drag to rearrange",
|
||||
@@ -872,7 +837,6 @@
|
||||
"webUiErrorPathInvalid": "Endpoint {{row}} has an invalid path. Use a path like /admin.",
|
||||
"copyViewUrlAction": "Copy {{name}} link",
|
||||
"filterAuthAgent": "SSH Agent",
|
||||
"filterAuthVault": "Vault",
|
||||
"authTypeNeedsPlugin": "This host uses {{type}}, which needs {{plugin}}. It is turned off on this server.",
|
||||
"authTypeUnknown": "This host uses {{type}}, which is not available on this server.",
|
||||
"authFieldsAfterSave": "Save the host first, then fill in these fields.",
|
||||
@@ -883,7 +847,11 @@
|
||||
"localOnlyDesc": "This host is not sent to the server and does not appear on your other devices.",
|
||||
"folderLocalOnly": "Keep on this device only",
|
||||
"folderLocalOnlyDesc": "This folder and every host in it stay on this device and are not sent to the server.",
|
||||
"sudoPasswordDesc": "Used when the terminal fills a sudo prompt, and by features that run commands with sudo."
|
||||
"sudoPasswordDesc": "Used when the terminal fills a sudo prompt, and by features that run commands with sudo.",
|
||||
"editorCancel": "Cancel",
|
||||
"editorSaving": "Saving...",
|
||||
"editorUpdate": "Update Host",
|
||||
"editorAdd": "Add Host"
|
||||
},
|
||||
"commandPalette": {
|
||||
"searchPlaceholder": "Search hosts, commands, or settings...",
|
||||
@@ -980,23 +948,9 @@
|
||||
"expiresIn": "Expires in {{duration}}",
|
||||
"search": "Search connections...",
|
||||
"noSearchResults": "No connections match your search",
|
||||
"rename": "Rename session",
|
||||
"sectionSharedWithMe": "Shared with me",
|
||||
"sharedBy": "Shared by {{username}}",
|
||||
"join": "Join",
|
||||
"sharedSessionLabel": "{{hostName}} (shared)"
|
||||
},
|
||||
"sessionSharing": {
|
||||
"permissionLevel": {
|
||||
"readOnly": "Read-only",
|
||||
"readWrite": "Read-write"
|
||||
}
|
||||
"rename": "Rename session"
|
||||
},
|
||||
"terminal": {
|
||||
"connect": "Connect to Host",
|
||||
"clear": "Clear",
|
||||
"paste": "Paste",
|
||||
"tmuxDetach": "Detach from tmux session",
|
||||
"totpRequired": "Two-Factor Authentication Required",
|
||||
"totpCodeLabel": "Verification Code",
|
||||
"totpVerify": "Verify",
|
||||
@@ -1011,7 +965,6 @@
|
||||
"browserSignInOpen": "Open in Browser",
|
||||
"browserSignInContinue": "I've Completed Sign-in",
|
||||
"browserSignInRequired": "{{provider}} Sign-in Required",
|
||||
"sudoPasswordPopupTitle": "Insert Password?",
|
||||
"connectionLogTitle": "Connection Log",
|
||||
"connectionLogCopy": "Copy logs to clipboard",
|
||||
"connectionLogEmpty": "No connection logs yet",
|
||||
@@ -1019,41 +972,6 @@
|
||||
"connectionLogCopied": "Connection logs copied to clipboard",
|
||||
"connectionLogCopyFailed": "Failed to copy logs to clipboard"
|
||||
},
|
||||
"transfer": {
|
||||
"collapseRecentDestinations": "Collapse recent destinations",
|
||||
"expandRecentDestinations": "Expand recent destinations",
|
||||
"browseDestination": "Browse or enter path",
|
||||
"transferring": "Transferring…",
|
||||
"compressing": "Compressing…",
|
||||
"extracting": "Extracting…",
|
||||
"transferringItems": "Transferring {{current}} of {{total}} items…",
|
||||
"transferPartial": "Transfer completed with {{count}} errors",
|
||||
"selectThisFolder": "Select this folder",
|
||||
"metricsTitle": "Transfer timings",
|
||||
"metricsHopSourceRead": "Source → server: {{throughput}}",
|
||||
"metricsHopDestSftpWrite": "Server → dest (SFTP): {{throughput}}",
|
||||
"metricsHopDestLocalWrite": "Server → dest (local): {{throughput}}",
|
||||
"sourceNotDeletedPartial": "Source files kept (partial transfer)"
|
||||
},
|
||||
"tunnels": {
|
||||
"connect": "Connect",
|
||||
"disconnect": "Disconnect",
|
||||
"bindIp": "Local IP",
|
||||
"remoteAddress": "Remote Address",
|
||||
"endpointSshConfig": "Endpoint SSH Configuration",
|
||||
"clientTunnel": "Client Tunnel",
|
||||
"addClientTunnel": "Add Client Tunnel",
|
||||
"typeServerLocalDesc": "Current host to endpoint.",
|
||||
"typeServerRemoteDesc": "Endpoint back to current host.",
|
||||
"typeClientLocalDesc": "Local computer to endpoint.",
|
||||
"typeClientRemoteDesc": "Endpoint back to local computer.",
|
||||
"typeClientDynamicDesc": "SOCKS on local computer.",
|
||||
"typeDynamicDesc": "Forward SOCKS5 CONNECT traffic through SSH",
|
||||
"maxRetriesDescription": "Maximum amount of retry attempts.",
|
||||
"retryIntervalDescription": "Time to wait between retry attempts.",
|
||||
"local": "Local",
|
||||
"remote": "Remote"
|
||||
},
|
||||
"cardGrid": {
|
||||
"dragToMove": "Drag to move",
|
||||
"dragToResize": "Drag to resize",
|
||||
@@ -1092,7 +1010,7 @@
|
||||
"external": "External",
|
||||
"loginWithExternal": "Login with External Provider",
|
||||
"loginWithExternalDesc": "Login using your configured external identity provider",
|
||||
"externalNotSupportedInElectron": "External authentication is not supported in the Electron app yet. Please use the web version for OIDC login.",
|
||||
"externalNotSupportedInElectron": "External sign-in is not supported in the desktop app yet. Please use the web version.",
|
||||
"loginWithProvider": "Login with {{name}}",
|
||||
"orContinueWith": "or continue with",
|
||||
"resetPasswordButton": "Reset Password",
|
||||
@@ -1141,10 +1059,10 @@
|
||||
"failedVerifyCode": "Failed to verify reset code",
|
||||
"failedCompleteReset": "Failed to complete password reset",
|
||||
"invalidTotpCode": "Invalid TOTP code",
|
||||
"failedOidcLogin": "Failed to start OIDC login",
|
||||
"silentSigninOidcUnavailable": "Silent sign-in was requested, but OIDC login is not available.",
|
||||
"failedOidcLogin": "Failed to start external sign-in",
|
||||
"silentSigninOidcUnavailable": "Silent sign-in was requested, but no external sign-in is set up.",
|
||||
"failedUserInfo": "Failed to get user info after login",
|
||||
"oidcAuthFailed": "OIDC authentication failed",
|
||||
"oidcAuthFailed": "External sign-in failed",
|
||||
"invalidAuthUrl": "Invalid authorization URL received from backend",
|
||||
"requiredField": "This field is required",
|
||||
"minLength": "Minimum length is {{min}}",
|
||||
@@ -1172,10 +1090,6 @@
|
||||
"loginSuccess": "Login successful",
|
||||
"registrationSuccess": "Registration successful"
|
||||
},
|
||||
"profile": {
|
||||
"c2sTunnelConfigDesc": "Local desktop tunnels targeting configured SSH hosts.",
|
||||
"c2sTunnelPresetsDesc": "Save this desktop client's local tunnel list as a named server preset, or load a preset back into this client."
|
||||
},
|
||||
"placeholders": {
|
||||
"language": "Language",
|
||||
"keyPassword": "key password",
|
||||
@@ -1200,7 +1114,6 @@
|
||||
"healthy": "Healthy",
|
||||
"error": "Error",
|
||||
"totalHosts": "Total Hosts",
|
||||
"totalTunnels": "Total Tunnels",
|
||||
"totalCredentials": "Total Credentials",
|
||||
"recentActivity": "Recent Activity",
|
||||
"reset": "Reset",
|
||||
@@ -1220,7 +1133,6 @@
|
||||
"resetLayout": "Reset to Default",
|
||||
"serverOverviewCard": "Server Overview",
|
||||
"recentActivityCard": "Recent Activity",
|
||||
"networkGraphCard": "Network Graph",
|
||||
"quickActionsCard": "Quick Actions",
|
||||
"serverStatsCard": "Server Stats",
|
||||
"panelMain": "Main",
|
||||
@@ -1319,9 +1231,6 @@
|
||||
"oidcAutoProvisionDesc": "Create an account the first time someone signs in through an external login such as SSO or LDAP, even when registration is off",
|
||||
"secondFactorAfterExternalLogin": "Ask for a second factor after external logins (SSO, GitHub, Google, LDAP)",
|
||||
"secondFactorAfterExternalLoginDesc": "Off, an enrolled second factor is only asked for after password and other local logins, same as before. On, it is asked for after every login method, including SSO and LDAP.",
|
||||
"oidcSilentLoginDefault": "Silent external login by default",
|
||||
"oidcSilentLoginDefaultDesc": "Send visitors straight to the external login provider, skipping the login form",
|
||||
"oidcSilentLoginDefaultLockedDesc": "This setting is set by the OIDC_SILENT_LOGIN_DEFAULT environment variable and cannot be changed here.",
|
||||
"allowPasswordReset": "Allow Password Reset",
|
||||
"allowPasswordResetDesc": "Reset code via Docker logs",
|
||||
"analyticsEnabled": "Share Anonymous Usage Statistics",
|
||||
@@ -1380,7 +1289,7 @@
|
||||
"apiKeyNoExpiry": "No expiry",
|
||||
"revokedBadge": "REVOKED",
|
||||
"authTypeDual": "Dual Auth",
|
||||
"authTypeOidc": "OIDC",
|
||||
"authTypeOidc": "External",
|
||||
"authTypeLocal": "Local",
|
||||
"adminStatusAdministrator": "Administrator",
|
||||
"adminStatusRegularUser": "Regular User",
|
||||
@@ -1431,34 +1340,33 @@
|
||||
"editUserAdminStatus": "Admin Status",
|
||||
"editUserUserId": "User ID",
|
||||
"linkAccountTitle": "Link Accounts",
|
||||
"linkAccountDesc": "Merge the OIDC account {{username}} with an existing local account.",
|
||||
"linkAccountDescLocal": "Link a local account {{username}} with an existing OIDC-only account.",
|
||||
"linkAccountDesc": "Merge the external account {{username}} with an existing local account.",
|
||||
"linkAccountDescLocal": "Link a local account {{username}} with an existing external-only account.",
|
||||
"linkAccountWarningTitle": "This will:",
|
||||
"linkAccountEffect1": "Delete the OIDC-only account",
|
||||
"linkAccountEffect2": "Add OIDC login to the target account",
|
||||
"linkAccountEffect3": "Allow both OIDC and password login",
|
||||
"linkAccountEffect1": "Delete the external-only account",
|
||||
"linkAccountEffect2": "Add its external sign-in to the target account",
|
||||
"linkAccountEffect3": "Allow both external and password sign-in",
|
||||
"linkAccountTargetUsername": "Local Account Username",
|
||||
"linkAccountTargetPlaceholder": "Enter the local account username to link to",
|
||||
"linkAccountOidcUsername": "OIDC Account Username",
|
||||
"linkAccountOidcPlaceholder": "Enter the OIDC-only account username to merge in",
|
||||
"linkAccountOidcNotFound": "No OIDC-only account found with that username",
|
||||
"linkAccountOidcUsername": "External Account Username",
|
||||
"linkAccountOidcPlaceholder": "Enter the external-only account username to merge in",
|
||||
"linkAccountOidcNotFound": "No external-only account found with that username",
|
||||
"linkAccounts": "Link Accounts",
|
||||
"linkAccountSuccess": "Accounts linked successfully",
|
||||
"linkAccountFailed": "Failed to link accounts",
|
||||
"linkAccountInProgress": "Linking...",
|
||||
"unlinkAccountTitle": "Unlink OIDC",
|
||||
"unlinkAccountDesc": "Remove OIDC authentication from {{username}}. They will only be able to log in with their password.",
|
||||
"unlinkAccountWarning": "This will remove OIDC login from this account. The user must have a password set to continue logging in.",
|
||||
"unlinkAccount": "Unlink OIDC",
|
||||
"unlinkAccountTitle": "Unlink External Sign-in",
|
||||
"unlinkAccountDesc": "Remove external sign-in from {{username}}. They will only be able to log in with their password.",
|
||||
"unlinkAccountWarning": "This will remove external sign-in from this account. The user must have a password set to keep logging in.",
|
||||
"unlinkAccount": "Unlink External Sign-in",
|
||||
"unlinkAccountInProgress": "Unlinking...",
|
||||
"unlinkAccountSuccess": "OIDC unlinked successfully",
|
||||
"unlinkAccountFailed": "Failed to unlink OIDC",
|
||||
"unlinkAccountSuccess": "External sign-in unlinked",
|
||||
"unlinkAccountFailed": "Failed to unlink external sign-in",
|
||||
"saving": "Saving...",
|
||||
"updateRegistrationFailed": "Failed to update registration setting",
|
||||
"updatePasswordLoginFailed": "Failed to update password login setting",
|
||||
"updateOidcAutoProvisionFailed": "Failed to update the auto-create setting",
|
||||
"updateSecondFactorAfterExternalLoginFailed": "Failed to update second factor setting",
|
||||
"updateOidcSilentLoginDefaultFailed": "Failed to update the silent login setting",
|
||||
"updatePasswordResetFailed": "Failed to update password reset setting",
|
||||
"sessionTimeoutRange2": "Session timeout must be between 1 and 720 hours",
|
||||
"sessionTimeoutSaved": "Session timeout saved",
|
||||
@@ -1795,7 +1703,7 @@
|
||||
"importFailed": "Import failed: {{error}}",
|
||||
"roleUser": "User",
|
||||
"authMethodDual": "Dual Auth",
|
||||
"authMethodOidc": "OIDC",
|
||||
"authMethodOidc": "External",
|
||||
"done": "Done",
|
||||
"apiKeyNameRequired": "Key name is required",
|
||||
"apiKeyCreated": "API key \"{{name}}\" created",
|
||||
@@ -1910,6 +1818,14 @@
|
||||
"title": "Open desktop windows",
|
||||
"consequence": "Only in the Termix desktop app."
|
||||
},
|
||||
"users:impersonate": {
|
||||
"title": "Act as other users",
|
||||
"consequence": "Runs background work with another user's access, such as scheduled jobs. Every use is written to the audit log."
|
||||
},
|
||||
"db:core-refs": {
|
||||
"title": "Read core account tables",
|
||||
"consequence": "Can query users, roles and hosts directly from the database."
|
||||
},
|
||||
"hosts:read": {
|
||||
"title": "See your host list",
|
||||
"consequence": "Names and addresses for hosts you can already see."
|
||||
|
||||
Loaded 100 of 310 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user