feat: move the terminal fully into the ssh-terminal plugin (B9)

This commit is contained in:
LukeGus committed 2026-09-23 21:41:22 -05:00
1 parent 535126fd1b
commit 79d77678c9
309 files changed
+41789 -14958

No files matched your search

+11 -85
View File
@@ -6,13 +6,9 @@ import { dashboardLogger } from "../utils/logger.js";
import { AuthManager } from "../utils/auth-manager.js";
import type { AuthenticatedRequest } from "../../types/index.js";
import { dashboardServiceLinksRouter } from "../database/routes/dashboard-service-links-routes.js";
import {
createCurrentHostResolutionRepository,
createCurrentRbacAccessRepository,
createCurrentRecentActivityRepository,
createCurrentRoleRepository,
} from "../database/repositories/factory.js";
import { createCurrentRecentActivityRepository } from "../database/repositories/factory.js";
import { DataCrypto } from "../utils/data-crypto.js";
import { recordRecentActivity } from "./recent-activity.js";
import { listenOnServicePort } from "../utils/service-listen.js";
const app = express();
@@ -21,9 +17,6 @@ const authManager = AuthManager.getInstance();
const serverStartTime = Date.now();
const activityRateLimiter = new Map<string, number>();
const RATE_LIMIT_MS = 1000;
function isUserDataUnlocked(userId: string): boolean {
return DataCrypto.getUserDataKey(userId) !== null;
}
@@ -181,91 +174,24 @@ app.post("/activity/log", async (req, res) => {
});
}
if (
![
"terminal",
"file_manager",
"server_stats",
"tunnel",
"docker",
"telnet",
"vnc",
"rdp",
].includes(type)
) {
const result = await recordRecentActivity(userId, {
type,
hostId,
hostName,
});
if (result.status === "invalid_type") {
return res.status(400).json({
error:
"Invalid activity type. Must be 'terminal', 'file_manager', 'server_stats', 'tunnel', 'docker', 'telnet', 'vnc', or 'rdp'",
});
}
const rateLimitKey = `${userId}:${hostId}:${type}`;
const now = Date.now();
const lastLogged = activityRateLimiter.get(rateLimitKey);
if (lastLogged && now - lastLogged < RATE_LIMIT_MS) {
if (result.status === "rate_limited") {
return res.json({
message: "Activity already logged recently (rate limited)",
});
}
activityRateLimiter.set(rateLimitKey, now);
if (activityRateLimiter.size > 10000) {
const entriesToDelete: string[] = [];
for (const [key, timestamp] of activityRateLimiter.entries()) {
if (now - timestamp > RATE_LIMIT_MS * 2) {
entriesToDelete.push(key);
}
}
entriesToDelete.forEach((key) => activityRateLimiter.delete(key));
}
const isOwnedHost =
await createCurrentHostResolutionRepository().isHostOwnedByUser(
hostId,
userId,
);
if (!isOwnedHost) {
const roleIds =
await createCurrentRoleRepository().listUserRoleIds(userId);
const sharedHosts =
await createCurrentRbacAccessRepository().listVisibleHostAccessEntries(
userId,
roleIds,
);
const hasSharedAccess = sharedHosts.some(
(access) => access.hostId === hostId,
);
if (!hasSharedAccess) {
return res
.status(404)
.json({ error: "Host not found or access denied" });
}
}
const result = await createCurrentRecentActivityRepository().create({
userId,
type,
hostId,
hostName,
});
// Best-effort trim of old activity entries; failures here should not
// cause the primary /activity/log request to 500.
try {
await createCurrentRecentActivityRepository().trimUserActivity(
userId,
100,
);
} catch (trimErr) {
dashboardLogger.warn("Failed to trim recent_activity (non-fatal)", {
operation: "trim_recent_activity",
userId,
error: trimErr instanceof Error ? trimErr.message : String(trimErr),
});
if (result.status === "denied") {
return res.status(404).json({ error: "Host not found or access denied" });
}
res.json({ message: "Activity logged", id: result.id });
+93
View File
@@ -0,0 +1,93 @@
import {
createCurrentHostResolutionRepository,
createCurrentRbacAccessRepository,
createCurrentRecentActivityRepository,
createCurrentRoleRepository,
} from "../database/repositories/factory.js";
import { dashboardLogger } from "../utils/logger.js";
const RATE_LIMIT_MS = 1000;
const activityRateLimiter = new Map<string, number>();
export const RECENT_ACTIVITY_TYPES = [
"terminal",
"file_manager",
"server_stats",
"tunnel",
"docker",
"telnet",
"vnc",
"rdp",
] as const;
export type RecordActivityResult =
| { status: "logged"; id: number }
| { status: "rate_limited" }
| { status: "invalid_type" }
| { status: "denied" };
/**
* Adds one recent activity entry: the dashboard's /activity/log route and
* ctx.hosts.recordActivity both come through here, so the rate limit and the
* access rule are the same for both.
*/
export async function recordRecentActivity(
userId: string,
entry: { type: string; hostId: number; hostName: string },
): Promise<RecordActivityResult> {
const { type, hostId, hostName } = entry;
if (!(RECENT_ACTIVITY_TYPES as readonly string[]).includes(type)) {
return { status: "invalid_type" };
}
const rateLimitKey = `${userId}:${hostId}:${type}`;
const now = Date.now();
const lastLogged = activityRateLimiter.get(rateLimitKey);
if (lastLogged && now - lastLogged < RATE_LIMIT_MS) {
return { status: "rate_limited" };
}
activityRateLimiter.set(rateLimitKey, now);
if (activityRateLimiter.size > 10000) {
for (const [key, timestamp] of activityRateLimiter.entries()) {
if (now - timestamp > RATE_LIMIT_MS * 2) activityRateLimiter.delete(key);
}
}
const isOwnedHost =
await createCurrentHostResolutionRepository().isHostOwnedByUser(
hostId,
userId,
);
if (!isOwnedHost) {
const roleIds = await createCurrentRoleRepository().listUserRoleIds(userId);
const sharedHosts =
await createCurrentRbacAccessRepository().listVisibleHostAccessEntries(
userId,
roleIds,
);
if (!sharedHosts.some((access) => access.hostId === hostId)) {
return { status: "denied" };
}
}
const result = await createCurrentRecentActivityRepository().create({
userId,
type,
hostId,
hostName,
});
// Best-effort trim; a failure here must not fail the log itself.
try {
await createCurrentRecentActivityRepository().trimUserActivity(userId, 100);
} catch (trimErr) {
dashboardLogger.warn("Failed to trim recent_activity (non-fatal)", {
operation: "trim_recent_activity",
userId,
error: trimErr instanceof Error ? trimErr.message : String(trimErr),
});
}
return { status: "logged", id: result.id };
}