mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 22:55:24 +00:00
chore: lock the plugin boundary and remove 2.9.0 leftovers
This commit is contained in:
1 parent
6489b62ece
commit
2961297989
399 files changed
+3964
-5627
No files matched your search
@@ -0,0 +1,119 @@
|
||||
import type { PluginLogger } from "@termix/plugin-sdk/backend";
|
||||
|
||||
export type ConnectionStage =
|
||||
| "dns"
|
||||
| "tcp"
|
||||
| "handshake"
|
||||
| "auth"
|
||||
| "connected"
|
||||
| "connection"
|
||||
| "error"
|
||||
| "proxy"
|
||||
| "jump"
|
||||
| "sftp_connecting"
|
||||
| "sftp_auth"
|
||||
| "sftp_connected";
|
||||
|
||||
export type LogEntry = {
|
||||
id: string;
|
||||
timestamp: Date;
|
||||
type: "info" | "success" | "warning" | "error";
|
||||
stage: ConnectionStage;
|
||||
message: string;
|
||||
details?: Record<string, unknown> | string;
|
||||
};
|
||||
|
||||
export function createConnectionLog(
|
||||
type: LogEntry["type"],
|
||||
stage: ConnectionStage,
|
||||
message: string,
|
||||
details?: Record<string, unknown>,
|
||||
): Omit<LogEntry, "id" | "timestamp"> {
|
||||
return { type, stage, message, details };
|
||||
}
|
||||
|
||||
function normalizeHostAddress(value: unknown): string {
|
||||
if (typeof value !== "string") return "";
|
||||
return value
|
||||
.replace(/^\[|\]$/g, "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a numeric host id resolved to a different machine than the client
|
||||
* meant. Ids from the desktop app and a sync server drift apart, and the
|
||||
* resolved row supplies the credentials, so a mismatch must be refused.
|
||||
*/
|
||||
export function hostAddressMismatch(
|
||||
clientAddress: unknown,
|
||||
resolvedAddress: unknown,
|
||||
): boolean {
|
||||
const resolved = normalizeHostAddress(resolvedAddress);
|
||||
if (!resolved) return false;
|
||||
return resolved !== normalizeHostAddress(clientAddress);
|
||||
}
|
||||
|
||||
export function resolveServerHostId(
|
||||
clientHostId: number | null,
|
||||
resolvedHost: { id?: unknown } | null,
|
||||
): number | null {
|
||||
return typeof resolvedHost?.id === "number" ? resolvedHost.id : clientHostId;
|
||||
}
|
||||
|
||||
export class HostAddressMismatchError extends Error {
|
||||
constructor() {
|
||||
super(
|
||||
"Host mismatch: this server resolved the selected host to a different machine, so the connection was refused. " +
|
||||
"The host ids on this device and on the sync server have drifted apart. " +
|
||||
'Set the connection origin to "This device" for this host, or re-run a full sync, then try again.',
|
||||
);
|
||||
this.name = "HostAddressMismatchError";
|
||||
}
|
||||
}
|
||||
|
||||
export class HostNotOnThisServerError extends Error {
|
||||
constructor() {
|
||||
super(
|
||||
"This host does not exist on the sync server, so the connection was refused. " +
|
||||
'Run a sync so the server knows about it, or set the connection origin to "This device" for this host.',
|
||||
);
|
||||
this.name = "HostNotOnThisServerError";
|
||||
}
|
||||
}
|
||||
|
||||
type Meta = Record<string, unknown>;
|
||||
|
||||
export interface FileLogger {
|
||||
info: (message: string, meta?: Meta) => void;
|
||||
success: (message: string, meta?: Meta) => void;
|
||||
warn: (message: string, meta?: Meta) => void;
|
||||
error: (message: string, error?: unknown, meta?: Meta) => void;
|
||||
}
|
||||
|
||||
function withMeta(message: string, meta?: Meta): string {
|
||||
if (!meta || Object.keys(meta).length === 0) return message;
|
||||
try {
|
||||
return `${message} ${JSON.stringify(meta)}`;
|
||||
} catch {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
/** ctx.log takes a plain message; this keeps the structured fields readable. */
|
||||
export function createFileLogger(log: PluginLogger): FileLogger {
|
||||
return {
|
||||
info: (message, meta) => log.info(withMeta(message, meta)),
|
||||
success: (message, meta) => log.info(withMeta(message, meta)),
|
||||
warn: (message, meta) => log.warn(withMeta(message, meta)),
|
||||
error: (message, error, meta) => {
|
||||
if (error instanceof Error) {
|
||||
log.error(withMeta(message, meta), error);
|
||||
} else if (error && typeof error === "object" && !meta) {
|
||||
log.error(withMeta(message, error as Meta));
|
||||
} else {
|
||||
log.error(withMeta(message, meta));
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,9 +1,5 @@
|
||||
// Core imports below point at TypeScript source so tsc can type-check them.
|
||||
// The plugin build rewrites the prefix to the compiled output path; see
|
||||
// packages/plugin-sdk/cli/lib/legacy-core-imports.mjs.
|
||||
import express, { type Router } from "express";
|
||||
import cookieParser from "cookie-parser";
|
||||
import axios from "axios";
|
||||
import { Client as SSHClient } from "ssh2";
|
||||
import type { PluginContext, PluginSshHost } from "@termix/plugin-sdk/backend";
|
||||
import { getErrorMessage } from "./error-message.js";
|
||||
@@ -15,30 +11,17 @@ import {
|
||||
} from "./transfer-tuning.js";
|
||||
|
||||
const TRANSFER_PROFILES_KEY = "transfer-profiles";
|
||||
import { AuthManager } from "../../../../src/backend/utils/auth-manager.js";
|
||||
import { logger } from "../../../../src/backend/utils/logger.js";
|
||||
import {
|
||||
logAudit,
|
||||
getAuditUsername,
|
||||
getRequestMeta,
|
||||
} from "../../../../src/backend/utils/audit-logger.js";
|
||||
import { createCurrentHostResolutionRepository } from "../../../../src/backend/database/repositories/factory.js";
|
||||
import {
|
||||
resolveHostById,
|
||||
resolveHostBySyncId,
|
||||
} from "../../../../src/backend/hosts/host-resolver.js";
|
||||
import { createConnectionLog } from "../../../../src/backend/hosts/connection-log.js";
|
||||
import { JumpHostChainError } from "../../../../src/backend/hosts/jump-host-chain.js";
|
||||
import {
|
||||
createConnectionLog,
|
||||
createFileLogger,
|
||||
hostAddressMismatch,
|
||||
HostAddressMismatchError,
|
||||
HostNotOnThisServerError,
|
||||
resolveServerHostId,
|
||||
} from "../../../../src/backend/hosts/host-identity.js";
|
||||
import type {
|
||||
LogEntry,
|
||||
ConnectionStage,
|
||||
} from "../../../../src/types/connection-log.js";
|
||||
type ConnectionStage,
|
||||
type FileLogger,
|
||||
type LogEntry,
|
||||
} from "./host-identity.js";
|
||||
import {
|
||||
ChannelOpenSerializer,
|
||||
execChannel,
|
||||
@@ -66,7 +49,17 @@ import {
|
||||
import { createFilesService } from "./service.js";
|
||||
import { tables } from "./tables.js";
|
||||
|
||||
const fileLogger = logger;
|
||||
let fileLogger: FileLogger = createFileLogger(console);
|
||||
|
||||
// Shape of a resolved host as this route reads it; core fills more fields
|
||||
// than PluginSshHost names.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type LooseHost = PluginSshHost & Record<string, any>;
|
||||
|
||||
// Core's jump chain failure, matched by name so no core class is imported.
|
||||
function isJumpHostChainError(error: unknown): boolean {
|
||||
return error instanceof Error && error.name === "JumpHostChainError";
|
||||
}
|
||||
|
||||
/** Cosmetic only: whether the proxy step is worth a connection-log line. */
|
||||
function hasSocks5Config(host: PluginSshHost): boolean {
|
||||
@@ -115,6 +108,7 @@ function assertResolvedHost(
|
||||
}
|
||||
|
||||
export async function activate(ctx: PluginContext) {
|
||||
fileLogger = createFileLogger(ctx.log);
|
||||
setPluginSsh(ctx.ssh);
|
||||
setPluginCtx(ctx);
|
||||
ctx.disposables.add(() => setPluginSsh(null));
|
||||
@@ -276,7 +270,7 @@ export async function activate(ctx: PluginContext) {
|
||||
throw new Error("Cannot open transfer connection: unknown host");
|
||||
}
|
||||
|
||||
const host = await resolveHostById(hostId, userId);
|
||||
const host = await ctx.asUser(userId, () => ctx.ssh.resolveHost(hostId));
|
||||
if (!host) {
|
||||
throw new Error("Host not found for transfer connection");
|
||||
}
|
||||
@@ -304,6 +298,7 @@ export async function activate(ctx: PluginContext) {
|
||||
host as unknown as PluginSshHost,
|
||||
{
|
||||
purpose: "file-transfer",
|
||||
profile: "stream",
|
||||
timeoutMs: 60000,
|
||||
overrides: { tcpKeepAliveInitialDelay: 5000 },
|
||||
},
|
||||
@@ -600,9 +595,9 @@ export async function activate(ctx: PluginContext) {
|
||||
let resolvedSocks5ProxyChain = socks5ProxyChain;
|
||||
|
||||
const resolveFrom = async () => {
|
||||
const resolvedHost = hostSyncId
|
||||
? await resolveHostBySyncId(hostSyncId, userId)
|
||||
: await resolveHostById(hostId, userId);
|
||||
const resolvedHost = (await ctx.ssh.resolveHost(hostId, {
|
||||
syncId: hostSyncId,
|
||||
})) as LooseHost | null;
|
||||
assertResolvedHost(ip, hostSyncId, resolvedHost, hostId, userId);
|
||||
if (!resolvedHost) return;
|
||||
resolvedIp = resolvedHost.ip;
|
||||
@@ -690,7 +685,9 @@ export async function activate(ctx: PluginContext) {
|
||||
|
||||
let serverHostId = hostId;
|
||||
if (hostSyncId && userId) {
|
||||
const resolvedHost = await resolveHostBySyncId(hostSyncId, userId);
|
||||
const resolvedHost = await ctx.ssh.resolveHost(hostId, {
|
||||
syncId: hostSyncId,
|
||||
});
|
||||
assertResolvedHost(ip, hostSyncId, resolvedHost, hostId, userId);
|
||||
serverHostId = resolveServerHostId(hostId, resolvedHost) ?? hostId;
|
||||
}
|
||||
@@ -744,6 +741,7 @@ export async function activate(ctx: PluginContext) {
|
||||
const ssh = pluginSsh();
|
||||
const prepared = await ssh.prepare(connectTarget, {
|
||||
purpose: "file-manager",
|
||||
profile: "session",
|
||||
client,
|
||||
serverHostId,
|
||||
log: (level, message) =>
|
||||
@@ -850,17 +848,13 @@ export async function activate(ctx: PluginContext) {
|
||||
scheduleSessionCleanup(sessionId);
|
||||
|
||||
if (userId) {
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
void (async () => {
|
||||
await logAudit({
|
||||
userId,
|
||||
username: await getAuditUsername(userId),
|
||||
await ctx.audit.record({
|
||||
request: req,
|
||||
action: "file_manager_connect",
|
||||
resourceType: "host",
|
||||
resourceId: serverHostId ? String(serverHostId) : undefined,
|
||||
resourceName: `${username}@${ip}:${port}`,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true,
|
||||
});
|
||||
})();
|
||||
@@ -1169,17 +1163,14 @@ export async function activate(ctx: PluginContext) {
|
||||
hostId,
|
||||
});
|
||||
const stage =
|
||||
error instanceof JumpHostChainError || jumpHostCount > 0
|
||||
? "jump"
|
||||
: "proxy";
|
||||
isJumpHostChainError(error) || jumpHostCount > 0 ? "jump" : "proxy";
|
||||
connectionLogs.push(
|
||||
createConnectionLog("error", stage, getErrorMessage(error)),
|
||||
);
|
||||
return res.status(500).json({
|
||||
error:
|
||||
error instanceof JumpHostChainError
|
||||
? `Failed to connect through jump hosts: ${error.message}`
|
||||
: getErrorMessage(error),
|
||||
error: isJumpHostChainError(error)
|
||||
? `Failed to connect through jump hosts: ${error.message}`
|
||||
: getErrorMessage(error),
|
||||
connectionLogs,
|
||||
});
|
||||
}
|
||||
@@ -1195,24 +1186,13 @@ export async function activate(ctx: PluginContext) {
|
||||
port: number,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const host = await createCurrentHostResolutionRepository().findHostById(
|
||||
hostId,
|
||||
userId,
|
||||
);
|
||||
const hostName =
|
||||
host?.userId === userId && host.name
|
||||
? host.name
|
||||
: `${username}@${ip}:${port}`;
|
||||
|
||||
const authManager = AuthManager.getInstance();
|
||||
await axios.post(
|
||||
"http://localhost:30006/activity/log",
|
||||
{ type: "file_manager", hostId, hostName },
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${await authManager.generateJWTToken(userId)}`,
|
||||
},
|
||||
},
|
||||
const host = await ctx.hosts.get(hostId);
|
||||
await ctx.asUser(userId, () =>
|
||||
ctx.hosts.recordActivity(
|
||||
hostId,
|
||||
"file_manager",
|
||||
host?.name || `${username}@${ip}:${port}`,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
fileLogger.warn("Failed to log file manager activity", {
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
permanentlyDeleteTrashItem,
|
||||
restoreTrashItem,
|
||||
} from "./trash-service.js";
|
||||
import { PermissionManager } from "../../../../src/backend/utils/permission-manager.js";
|
||||
|
||||
type FileOperationRoutesDeps = {
|
||||
ctx: PluginContext;
|
||||
@@ -29,10 +28,8 @@ export function registerFileOperationRoutes(
|
||||
app: Express,
|
||||
{ ctx, sshSessions, verifySessionOwnership }: FileOperationRoutesDeps,
|
||||
): void {
|
||||
// NOTE: trash retention is an install-wide admin setting, not a per-host
|
||||
// access check, so it stays on PermissionManager.isAdmin rather than
|
||||
// ctx.hosts.checkAccess (see rule 5 scope: host resolution/access only).
|
||||
const permissionManager = PermissionManager.getInstance();
|
||||
// Trash retention is an install-wide admin setting, not a per-host check.
|
||||
const canManageRetention = () => ctx.rbac.has("admin.settings.manage");
|
||||
const getTrashRetentionDays = async (): Promise<number> => {
|
||||
try {
|
||||
const value = Number(await ctx.settings.get(TRASH_RETENTION_DAYS_KEY));
|
||||
@@ -68,9 +65,7 @@ export function registerFileOperationRoutes(
|
||||
res.json({
|
||||
items: await listTrash(await getSessionSftp(session), retentionDays),
|
||||
retentionDays,
|
||||
canManageRetention: await permissionManager.isAdmin(
|
||||
ctx.currentActor()!,
|
||||
),
|
||||
canManageRetention: await canManageRetention(),
|
||||
});
|
||||
} catch (error) {
|
||||
ctx.log.error("Failed to list trash", error as Error);
|
||||
@@ -121,8 +116,7 @@ export function registerFileOperationRoutes(
|
||||
});
|
||||
|
||||
app.put("/trash-retention", async (req, res) => {
|
||||
const userId = ctx.currentActor()!;
|
||||
if (!(await permissionManager.isAdmin(userId))) {
|
||||
if (!(await canManageRetention())) {
|
||||
return res.status(403).json({ error: "Admin access required" });
|
||||
}
|
||||
const retentionDays = Number(req.body?.retentionDays);
|
||||
|
||||
@@ -40,7 +40,11 @@ async function withServiceSftp<T>(
|
||||
): Promise<T> {
|
||||
return ctx.ssh.withConnection<T, import("ssh2").Client>(
|
||||
hostId,
|
||||
{ pool: "file-manager-service", purpose: "file-manager" },
|
||||
{
|
||||
pool: "file-manager-service",
|
||||
purpose: "file-manager",
|
||||
profile: "session",
|
||||
},
|
||||
(client) =>
|
||||
new Promise<T>((resolve, reject) => {
|
||||
client.sftp(async (err, sftp) => {
|
||||
|
||||
Reference in new issue
Block a user