mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-31 07:35:24 +00:00
* feat: shell controller, TabStage, layout and theme plugins with repair view * feat: move the classic layout into its own bundled plugin * feat: move color themes into a bundled themes plugin * fix: plugin tests and type checks work with a linked sdk * fix: home tab, layout switch and missing layout fallbacks for looks * chore: add classic layout and themes to the readme * chore: drop example plugin links from the plugin guide * feat: plugin filters by type, paged browse, hide plugins needing newer termix * feat: move desktop, status, import, audit and branding features into plugins * feat: host lock api so a plugin can load hosts from a config file (#1581) * chore: add hosts config to the plugin table * feat: login policy hook for plugins and optional https redirect (#1584) * chore: add login security to the plugin table * feat: api key socket tickets so a master can open remote sessions (#1654) * feat: sync moves to a plugin, desktop gets multiple accounts (#1580, #1654) * chore: add sync to the plugin table * feat: move the sync screens and wizard out of core into the plugin (#1580) * fix: add a neutral linked desktop hook for plugins (#1580) * fix: show the linked account's name and admin flag in the app (#1580) * feat: ssh transport providers, core ssh relay and plugin folders api * feat: support FIDO2 security keys through ssh agent * feat: add companion panels beside host tabs * feat: mTLS client certificates for desktop server links * feat: native web view for plugins in the desktop app * fix: detect the desktop link window by Electron's default user agent Fixes #1675 * fix: changelog for desktop link fix * fix: keep text primary keys in the plugin data export * fix: ship sharp binaries for Windows arm64 builds * feat: client cert on the sync link types (sdk 1.0.18) * chore: update translations * feat: themed title bar for the desktop app (#1690) * chore: changelog for the themed title bar * fix: draw the title bar in the sidebar color * feat: per host layout for the terminal panel bar * chore: sdk 1.0.19 for the panel bar layout * chore: bundle all 43 plugins for 26.10.3 * chore: update translations --------- Co-authored-by: SirHephaistos <60616553+SirHephaistos@users.noreply.github.com> Co-authored-by: Giovanny Aranda <garanda21@gmail.com>
376 lines
11 KiB
TypeScript
376 lines
11 KiB
TypeScript
import type { AuthenticatedRequest } from "../../../types/index.js";
|
|
import type { RequestHandler, Router } from "express";
|
|
import {
|
|
authLogger,
|
|
getGlobalLogLevel,
|
|
setGlobalLogLevel,
|
|
} from "../../utils/logger.js";
|
|
import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
|
|
import {
|
|
NOTIFICATION_PRIVATE_ALLOWLIST_KEY,
|
|
parseNotificationAllowlist,
|
|
} from "../../utils/notification-egress.js";
|
|
import {
|
|
createCurrentSettingsRepository,
|
|
createCurrentUserRepository,
|
|
} from "../repositories/factory.js";
|
|
import type { UserRecord } from "../repositories/user-repository.js";
|
|
import { isForceHttpsEnabled, setForceHttps } from "../../tls/https-policy.js";
|
|
import { getTlsConfig } from "../../tls/tls-service.js";
|
|
|
|
async function getAdminActor(
|
|
userId: string | undefined,
|
|
): Promise<UserRecord | null> {
|
|
if (!userId) return null;
|
|
const user = await createCurrentUserRepository().findById(userId);
|
|
return user?.isAdmin ? user : null;
|
|
}
|
|
|
|
export function registerUserSettingsRoutes(
|
|
router: Router,
|
|
authenticateJWT: RequestHandler,
|
|
): void {
|
|
/**
|
|
* @openapi
|
|
* /users/log-level:
|
|
* get:
|
|
* summary: Get log level setting
|
|
* description: Returns the configured log verbosity level.
|
|
* tags:
|
|
* - Users
|
|
* responses:
|
|
* 200:
|
|
* description: Current log level.
|
|
*/
|
|
router.get("/log-level", authenticateJWT, async (_req, res) => {
|
|
try {
|
|
const level = await createCurrentSettingsRepository().get("log_level");
|
|
res.json({
|
|
level: level ?? getGlobalLogLevel(),
|
|
});
|
|
} catch (err) {
|
|
authLogger.error("Failed to get log level", err);
|
|
res.status(500).json({ error: "Failed to get log level" });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /users/log-level:
|
|
* patch:
|
|
* summary: Update log level setting (admin only)
|
|
* description: Sets the log verbosity level.
|
|
* tags:
|
|
* - Users
|
|
* responses:
|
|
* 200:
|
|
* description: Log level updated.
|
|
* 400:
|
|
* description: Invalid log level.
|
|
* 403:
|
|
* description: Not authorized.
|
|
*/
|
|
router.patch("/log-level", authenticateJWT, async (req, res) => {
|
|
const userId = (req as AuthenticatedRequest).userId;
|
|
try {
|
|
const actor = await getAdminActor(userId);
|
|
if (!actor) {
|
|
return res.status(403).json({ error: "Not authorized" });
|
|
}
|
|
const { level } = req.body;
|
|
const validLevels = ["debug", "info", "warn", "error"];
|
|
if (typeof level !== "string" || !validLevels.includes(level)) {
|
|
return res
|
|
.status(400)
|
|
.json({ error: "level must be one of: debug, info, warn, error" });
|
|
}
|
|
await createCurrentSettingsRepository().set("log_level", level);
|
|
setGlobalLogLevel(level);
|
|
|
|
const { ipAddress, userAgent } = getRequestMeta(req);
|
|
await logAudit({
|
|
userId,
|
|
username: actor.username ?? userId,
|
|
action: "update_log_level",
|
|
resourceType: "setting",
|
|
details: JSON.stringify({ level }),
|
|
ipAddress,
|
|
userAgent,
|
|
success: true,
|
|
});
|
|
|
|
res.json({ level });
|
|
} catch (err) {
|
|
authLogger.error("Failed to set log level", err);
|
|
res.status(500).json({ error: "Failed to set log level" });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /users/session-timeout:
|
|
* get:
|
|
* summary: Get session timeout setting
|
|
* description: Returns the configured session timeout in hours.
|
|
* tags:
|
|
* - Users
|
|
* responses:
|
|
* 200:
|
|
* description: Current session timeout hours.
|
|
*/
|
|
router.get("/session-timeout", authenticateJWT, async (_req, res) => {
|
|
try {
|
|
const value = await createCurrentSettingsRepository().get(
|
|
"session_timeout_hours",
|
|
);
|
|
res.json({
|
|
timeoutHours: value ? parseInt(value, 10) : 24,
|
|
});
|
|
} catch (err) {
|
|
authLogger.error("Failed to get session timeout", err);
|
|
res.status(500).json({ error: "Failed to get session timeout" });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /users/session-timeout:
|
|
* patch:
|
|
* summary: Update session timeout setting (admin only)
|
|
* description: Sets the session timeout in hours.
|
|
* tags:
|
|
* - Users
|
|
* responses:
|
|
* 200:
|
|
* description: Session timeout updated.
|
|
* 400:
|
|
* description: Invalid value.
|
|
* 403:
|
|
* description: Not authorized.
|
|
*/
|
|
router.patch("/session-timeout", authenticateJWT, async (req, res) => {
|
|
const userId = (req as AuthenticatedRequest).userId;
|
|
try {
|
|
const actor = await getAdminActor(userId);
|
|
if (!actor) {
|
|
return res.status(403).json({ error: "Not authorized" });
|
|
}
|
|
const { timeoutHours } = req.body;
|
|
if (
|
|
typeof timeoutHours !== "number" ||
|
|
timeoutHours < 1 ||
|
|
timeoutHours > 720
|
|
) {
|
|
return res
|
|
.status(400)
|
|
.json({ error: "timeoutHours must be between 1 and 720" });
|
|
}
|
|
await createCurrentSettingsRepository().set(
|
|
"session_timeout_hours",
|
|
String(timeoutHours),
|
|
);
|
|
|
|
const { ipAddress, userAgent } = getRequestMeta(req);
|
|
await logAudit({
|
|
userId,
|
|
username: actor.username ?? userId,
|
|
action: "update_session_timeout",
|
|
resourceType: "setting",
|
|
details: JSON.stringify({ timeoutHours }),
|
|
ipAddress,
|
|
userAgent,
|
|
success: true,
|
|
});
|
|
|
|
res.json({ timeoutHours });
|
|
} catch (err) {
|
|
authLogger.error("Failed to set session timeout", err);
|
|
res.status(500).json({ error: "Failed to set session timeout" });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /users/https-settings:
|
|
* get:
|
|
* summary: Get the HTTPS redirect setting (admin only)
|
|
* description: Whether plain HTTP is redirected to HTTPS with HSTS, and whether HTTPS is on at all. The redirect only applies while HTTPS is on.
|
|
* tags:
|
|
* - Users
|
|
* responses:
|
|
* 200:
|
|
* description: forceHttps and tlsEnabled.
|
|
* 403:
|
|
* description: Not authorized.
|
|
*/
|
|
router.get("/https-settings", authenticateJWT, async (req, res) => {
|
|
try {
|
|
if (!(await getAdminActor((req as AuthenticatedRequest).userId))) {
|
|
return res.status(403).json({ error: "Not authorized" });
|
|
}
|
|
res.json({
|
|
forceHttps: await isForceHttpsEnabled(),
|
|
tlsEnabled: getTlsConfig().enabled,
|
|
});
|
|
} catch (err) {
|
|
authLogger.error("Failed to get HTTPS settings", err);
|
|
res.status(500).json({ error: "Failed to get HTTPS settings" });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /users/https-settings:
|
|
* patch:
|
|
* summary: Update the HTTPS redirect setting (admin only)
|
|
* description: Turns the redirect from HTTP to HTTPS and the HSTS header on or off. Off by default so reverse proxies keep working.
|
|
* tags:
|
|
* - Users
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* forceHttps:
|
|
* type: boolean
|
|
* responses:
|
|
* 200:
|
|
* description: Setting updated.
|
|
* 400:
|
|
* description: Invalid value.
|
|
* 403:
|
|
* description: Not authorized.
|
|
*/
|
|
router.patch("/https-settings", authenticateJWT, async (req, res) => {
|
|
const userId = (req as AuthenticatedRequest).userId;
|
|
try {
|
|
const actor = await getAdminActor(userId);
|
|
if (!actor) {
|
|
return res.status(403).json({ error: "Not authorized" });
|
|
}
|
|
const { forceHttps } = req.body ?? {};
|
|
if (typeof forceHttps !== "boolean") {
|
|
return res.status(400).json({ error: "forceHttps must be a boolean" });
|
|
}
|
|
await setForceHttps(forceHttps);
|
|
const { ipAddress, userAgent } = getRequestMeta(req);
|
|
await logAudit({
|
|
userId,
|
|
username: actor.username ?? userId,
|
|
action: "update_force_https",
|
|
resourceType: "setting",
|
|
details: JSON.stringify({ forceHttps }),
|
|
ipAddress,
|
|
userAgent,
|
|
success: true,
|
|
});
|
|
res.json({ forceHttps, tlsEnabled: getTlsConfig().enabled });
|
|
} catch (err) {
|
|
authLogger.error("Failed to set HTTPS settings", err);
|
|
res.status(500).json({ error: "Failed to set HTTPS settings" });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* GET/PATCH a comma-list of private hosts an outbound feature may reach.
|
|
* One route pair per private endpoint allowlist, each with its own setting key.
|
|
*/
|
|
const registerPrivateEndpointAllowlist = (
|
|
path: string,
|
|
settingKey: string,
|
|
auditAction: string,
|
|
label: string,
|
|
) => {
|
|
router.get(path, authenticateJWT, async (req, res) => {
|
|
const userId = (req as AuthenticatedRequest).userId;
|
|
try {
|
|
if (!(await getAdminActor(userId))) {
|
|
return res.status(403).json({ error: "Not authorized" });
|
|
}
|
|
const raw = await createCurrentSettingsRepository().get(settingKey);
|
|
res.json({ hosts: parseNotificationAllowlist(raw) });
|
|
} catch (err) {
|
|
authLogger.error(`Failed to get ${label} allowlist`, err);
|
|
res.status(500).json({ error: "Failed to get the allowlist" });
|
|
}
|
|
});
|
|
|
|
router.patch(path, authenticateJWT, async (req, res) => {
|
|
const userId = (req as AuthenticatedRequest).userId;
|
|
try {
|
|
const actor = await getAdminActor(userId);
|
|
if (!actor) {
|
|
return res.status(403).json({ error: "Not authorized" });
|
|
}
|
|
|
|
const { hosts } = req.body;
|
|
if (!Array.isArray(hosts)) {
|
|
return res.status(400).json({ error: "hosts must be an array" });
|
|
}
|
|
if (hosts.length > 50) {
|
|
return res
|
|
.status(400)
|
|
.json({ error: "At most 50 hosts are allowed" });
|
|
}
|
|
const cleaned: string[] = [];
|
|
for (const entry of hosts) {
|
|
if (typeof entry !== "string") {
|
|
return res
|
|
.status(400)
|
|
.json({ error: "Each host must be a string" });
|
|
}
|
|
const host = entry.trim().toLowerCase();
|
|
if (!host) continue;
|
|
if (!/^[a-z0-9._:-]+$/.test(host)) {
|
|
return res
|
|
.status(400)
|
|
.json({ error: `${entry} is not a valid hostname` });
|
|
}
|
|
if (!cleaned.includes(host)) cleaned.push(host);
|
|
}
|
|
|
|
await createCurrentSettingsRepository().set(
|
|
settingKey,
|
|
JSON.stringify(cleaned),
|
|
);
|
|
const { ipAddress, userAgent } = getRequestMeta(req);
|
|
await logAudit({
|
|
userId,
|
|
username: actor.username ?? userId,
|
|
action: auditAction,
|
|
resourceType: "setting",
|
|
details: JSON.stringify({ hosts: cleaned }),
|
|
ipAddress,
|
|
userAgent,
|
|
success: true,
|
|
});
|
|
res.json({ hosts: cleaned });
|
|
} catch (err) {
|
|
authLogger.error(`Failed to update ${label} allowlist`, err);
|
|
res.status(500).json({ error: "Failed to update the allowlist" });
|
|
}
|
|
});
|
|
};
|
|
|
|
/**
|
|
* @openapi
|
|
* /users/notification-private-endpoints:
|
|
* get:
|
|
* summary: Get the private hosts notification channels may contact (admin only)
|
|
* tags:
|
|
* - Users
|
|
* patch:
|
|
* summary: Replace that allowlist (admin only)
|
|
* tags:
|
|
* - Users
|
|
*/
|
|
registerPrivateEndpointAllowlist(
|
|
"/notification-private-endpoints",
|
|
NOTIFICATION_PRIVATE_ALLOWLIST_KEY,
|
|
"update_notification_private_endpoints",
|
|
"notification endpoint",
|
|
);
|
|
}
|