mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-30 02:41:34 +00:00
release-2.7.1 (#1296)
* Add Helm and GitOps deployment setup * fix: build better-sqlite3 from source in Docker (#1267) * fix: preserve runtime SSL settings (#1268) * fix: support forwarding from the memory SSH agent (#1269) * fix: support forwarding from the memory agent * style: format memory agent test * fix: prompt for encrypted SFTP key passphrases (#1270) * fix: prompt for SFTP key passphrases * style: format SSH key utility test * fix: include host context in automation notifications (#1271) * fix: include host context in automation notifications * style: format automation notification changes * fix: reserve sidebar height for host tags (#1272) * fix: keep host action rows stable at large font sizes (#1273) * fix: honor certificate setting during server probe (#1274) * fix: package standard Linux icon sizes (#1275) * fix: avoid duplicate Docker HTTPS listener (#1276) * Fix host status without metrics collection (#1277) * fix: allow eight-digit secure auth codes (#1263) Allow TOTP prompts to accept secure auth codes longer than six digits without blocking valid authentication attempts. Generated with Codebuff 🤖 Co-authored-by: Chetan <chetan.development@gmail.com> Co-authored-by: Codebuff <noreply@codebuff.com> * Harden Helm deployment defaults * Update Helm workflow action * Exclude Helm templates from Prettier * Fix browser RDP file drops (#1279) * Fix Proxmox guest credential usernames (#1280) * Add WSL local terminal option (#1281) * refactor: split the transfer engine into focused modules (#1282) * refactor: extract SFTP promisify helpers into sftp-promisify module * refactor: extract transfer timing and rate stats into transfer-stats module * refactor: extract transfer error classes and recovery checks into transfer-errors module * refactor: extract host/path utility helpers into transfer-host-utils module * refactor: extract SFTP directory tree helpers into transfer-sftp-dir module * refactor: extract segment copy job builder into transfer-segment-copy module * refactor: extract file scan and sample helpers into transfer-scan module * refactor: move throttled progress helper into transfer-stats module * style: format transfer modules * perf: optimize tmux monitor aggregation (#1283) * fix: reserve credential tag row height (#1284) * feat: edit AI provider model settings (#1285) * fix: clarify click-to-expand host setting (#1286) * fix: allow portable imports on remote databases (#1287) * fix: allow HTTPS to share the configured port (#1288) * fix: resolve synced jump hosts on the server (#1289) * fix: make terminal clipboard shortcuts layout independent (#1290) * fix: use compatible fetch dispatcher for Tailscale (#1291) * fix: add OIDC environment recovery override (#1292) * fix: coalesce rapid mobile terminal input (#1293) * fix: coalesce rapid mobile terminal input * fix: support clean xterm patch installs * fix: resolve synced remote desktop host IDs (#1295) * feat: make the SFTP file manager path bar editable (#1294) Co-authored-by: Maxime Bonillo <257463937+dropafterfree@users.noreply.github.com> Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> * feat: add passkey sign in to the login screen * fix: remove rounded corners from the host list search bar * fix: stop image storage settings text wrapping to one word per line * fix: prevent malformed websocket messages from crashing the server * chore: increment version * fix: remove gaps between host rows in the sidebar list Keep sub-pixel row measurements and stop wiping the size cache on hover. * fix: Failed to connect through jump hosts (#1180) https://github.com/Termix-SSH/Support/issues/1180 * feat: Progress bar for file downloads in the file manager (#1158) https://github.com/Termix-SSH/Support/issues/1158 * feat: Allow setting Silent OIDC Login via ENV var (#1174) https://github.com/Termix-SSH/Support/issues/1174 * feat: `IdentityFile` to limit the number of attempts by agents (#1165) https://github.com/Termix-SSH/Support/issues/1165 * feat: Credentials clone (#1159) https://github.com/Termix-SSH/Support/issues/1159 * chore: update release notes * docs: move helm setup guide to the docs site * fix: type errors in FilteredAgent agent identity handling * fix: remove stale better-sqlite3 prebuilds so the source build is used * fix: actually build better-sqlite3 from source so arm64 docker images work * fix: credential edit pencil in host editor and add clone action to credential list * fix: clear editingHost so the credential pencil actually opens the editor * chore: run format and lint * fix: folder drag and drop upload failing in the file manager * chore: sync Crowdin translations for 2.7.1 --------- Co-authored-by: alex-ctms <alex-ctms@users.noreply.github.com> Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> Co-authored-by: Chetan Kumar <74929596+ckloop@users.noreply.github.com> Co-authored-by: Chetan <chetan.development@gmail.com> Co-authored-by: Codebuff <noreply@codebuff.com> Co-authored-by: ZacharyZcR <payasonorahc@protonmail.com> Co-authored-by: dropafterfree <maxime.bonillo@gmail.com> Co-authored-by: Maxime Bonillo <257463937+dropafterfree@users.noreply.github.com>
This commit is contained in:
co-authored by
Chetan
Codebuff
Maxime Bonillo
ZacharyZcR
alex-ctms
Chetan Kumar
ZacharyZcR
dropafterfree
parent
566b908daf
commit
76fd9eedbf
@@ -388,6 +388,165 @@ router.get(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /credentials/{id}/duplicate:
|
||||
* post:
|
||||
* summary: Duplicate a credential
|
||||
* description: Creates a new credential from an existing one, optionally overriding fields (e.g. password), leaving the original credential untouched.
|
||||
* tags:
|
||||
* - Credentials
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* name:
|
||||
* type: string
|
||||
* username:
|
||||
* type: string
|
||||
* password:
|
||||
* type: string
|
||||
* key:
|
||||
* type: string
|
||||
* keyPassword:
|
||||
* type: string
|
||||
* responses:
|
||||
* 201:
|
||||
* description: New credential created from the duplicate.
|
||||
* 400:
|
||||
* description: Invalid request.
|
||||
* 404:
|
||||
* description: Credential not found.
|
||||
* 500:
|
||||
* description: Failed to duplicate credential.
|
||||
*/
|
||||
router.post(
|
||||
"/:id/duplicate",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
||||
const { name, username, password, key, keyPassword, certPublicKey } =
|
||||
req.body ?? {};
|
||||
|
||||
if (!isNonEmptyString(userId) || !id) {
|
||||
authLogger.warn("Invalid request for credential duplicate");
|
||||
return res.status(400).json({ error: "Invalid request" });
|
||||
}
|
||||
if (!isNonEmptyString(name)) {
|
||||
return res.status(400).json({ error: "Name is required" });
|
||||
}
|
||||
|
||||
const credentialId = parseInt(id);
|
||||
|
||||
try {
|
||||
const credentialRepository = createCurrentCredentialRepository();
|
||||
const source = await credentialRepository.findDecryptedByIdForUser(
|
||||
userId,
|
||||
credentialId,
|
||||
);
|
||||
|
||||
if (!source) {
|
||||
return res.status(404).json({ error: "Credential not found" });
|
||||
}
|
||||
|
||||
const authType = source.authType;
|
||||
const plainPassword =
|
||||
password !== undefined ? password || null : source.password;
|
||||
const plainKey = key !== undefined ? key || null : source.key;
|
||||
const plainKeyPassword =
|
||||
keyPassword !== undefined ? keyPassword || null : source.keyPassword;
|
||||
|
||||
let keyInfo = null;
|
||||
if (authType === "key" && plainKey) {
|
||||
keyInfo = parseSSHKey(plainKey, plainKeyPassword);
|
||||
if (!keyInfo.success) {
|
||||
return res.status(400).json({
|
||||
error: keyInfo.error
|
||||
? `Invalid SSH key: ${keyInfo.error}`
|
||||
: "Unrecognized SSH key format. Use an OpenSSH, PEM, or PuTTY PPK v2 RSA/DSA private key.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const credentialData = {
|
||||
userId,
|
||||
name: name.trim(),
|
||||
description: source.description,
|
||||
folder: source.folder,
|
||||
tags: source.tags,
|
||||
authType,
|
||||
username:
|
||||
username !== undefined ? username?.trim() || null : source.username,
|
||||
password: authType === "password" ? plainPassword : null,
|
||||
key: authType === "key" ? plainKey : null,
|
||||
privateKey: authType === "key" ? keyInfo?.privateKey || plainKey : null,
|
||||
publicKey: authType === "key" ? keyInfo?.publicKey || null : null,
|
||||
keyPassword: authType === "key" ? plainKeyPassword : null,
|
||||
keyType: source.keyType,
|
||||
detectedKeyType: authType === "key" ? keyInfo?.keyType || null : null,
|
||||
certPublicKey:
|
||||
authType === "key"
|
||||
? certPublicKey !== undefined
|
||||
? certPublicKey?.trim() || null
|
||||
: source.certPublicKey
|
||||
: null,
|
||||
usageCount: 0,
|
||||
lastUsed: null,
|
||||
};
|
||||
|
||||
const created = await credentialRepository.createEncryptedForUser(
|
||||
userId,
|
||||
credentialData,
|
||||
);
|
||||
|
||||
const { ipAddress: dupIp, userAgent: dupUa } = getRequestMeta(req);
|
||||
await logAudit({
|
||||
userId,
|
||||
username: await getAuditUsername(userId),
|
||||
action: "duplicate_credential",
|
||||
resourceType: "credential",
|
||||
resourceId: String(created.id),
|
||||
resourceName: name,
|
||||
ipAddress: dupIp,
|
||||
userAgent: dupUa,
|
||||
success: true,
|
||||
});
|
||||
|
||||
authLogger.success(
|
||||
`SSH credential duplicated: ${name} (from ${credentialId}) by user ${userId}`,
|
||||
{
|
||||
operation: "credential_duplicate_success",
|
||||
userId,
|
||||
sourceCredentialId: credentialId,
|
||||
credentialId: created.id,
|
||||
},
|
||||
);
|
||||
|
||||
res.status(201).json(formatCredentialOutput(created));
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to duplicate credential", err, {
|
||||
operation: "credential_duplicate",
|
||||
userId,
|
||||
credentialId,
|
||||
});
|
||||
res.status(500).json({
|
||||
error: getErrorMessage(err, "Failed to duplicate credential"),
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /credentials/{id}:
|
||||
|
||||
@@ -32,7 +32,7 @@ export function resolveProxmoxImportAuth(
|
||||
return {
|
||||
authType: "credential",
|
||||
credentialId,
|
||||
overrideCredentialUsername: 1,
|
||||
overrideCredentialUsername: 0,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -689,6 +689,12 @@ async function syncProxmoxHost(
|
||||
? existing.connectionType
|
||||
: null;
|
||||
const connectionType = existingConnectionType ?? guest.connectionType;
|
||||
const usesImportCredential =
|
||||
connectionType === "ssh" && importAuth.authType === "credential";
|
||||
const existingUsesImportCredential =
|
||||
usesImportCredential &&
|
||||
existing?.authType === "credential" &&
|
||||
existing?.credentialId === importAuth.credentialId;
|
||||
const port =
|
||||
typeof existing?.port === "number"
|
||||
? existing.port
|
||||
@@ -696,11 +702,13 @@ async function syncProxmoxHost(
|
||||
? 3389
|
||||
: 22;
|
||||
const username =
|
||||
typeof existing?.username === "string" && existing.username
|
||||
? existing.username
|
||||
: connectionType === "rdp"
|
||||
? ""
|
||||
: "root";
|
||||
usesImportCredential && (!existing || existingUsesImportCredential)
|
||||
? ""
|
||||
: typeof existing?.username === "string" && existing.username
|
||||
? existing.username
|
||||
: connectionType === "rdp"
|
||||
? ""
|
||||
: "root";
|
||||
const update: Record<string, unknown> = {
|
||||
name: guest.name,
|
||||
ip: guest.ip || existing?.ip || "0.0.0.0",
|
||||
@@ -714,6 +722,10 @@ async function syncProxmoxHost(
|
||||
};
|
||||
|
||||
if (existing) {
|
||||
if (existingUsesImportCredential) {
|
||||
update.credentialId = importAuth.credentialId;
|
||||
update.overrideCredentialUsername = false;
|
||||
}
|
||||
await createCurrentHostRepository().updateEncryptedForUser(
|
||||
userId,
|
||||
existing.id as number,
|
||||
|
||||
@@ -7,7 +7,10 @@ import { authLogger } from "../../utils/logger.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import type { SSOProviderType } from "../../../types/index.js";
|
||||
import { createCurrentSsoProviderRepository } from "../repositories/factory.js";
|
||||
import { getOIDCConfigFromEnv } from "./user-oidc-utils.js";
|
||||
import {
|
||||
getOIDCConfigFromEnv,
|
||||
isOIDCEnvOverrideEnabled,
|
||||
} from "./user-oidc-utils.js";
|
||||
import {
|
||||
decryptSsoConfigSecrets,
|
||||
encryptSsoConfigSecrets,
|
||||
@@ -18,6 +21,19 @@ function isOidcLike(type: SSOProviderType): boolean {
|
||||
return type === "oidc" || type === "github" || type === "google";
|
||||
}
|
||||
|
||||
export function isValidOidcIssuer(value: unknown): boolean {
|
||||
if (typeof value !== "string") return false;
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return (
|
||||
["http:", "https:"].includes(url.protocol) &&
|
||||
!/\/userinfo\/?$/i.test(url.pathname)
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const authManager = AuthManager.getInstance();
|
||||
|
||||
/**
|
||||
@@ -95,13 +111,19 @@ export function registerSSOProviderRoutes(router: Router): void {
|
||||
*/
|
||||
router.get("/sso-providers", async (_req, res) => {
|
||||
try {
|
||||
const envConfig = getOIDCConfigFromEnv();
|
||||
if (envConfig && isOIDCEnvOverrideEnabled()) {
|
||||
return res.json([
|
||||
{ id: 0, name: "SSO", type: "oidc", displayOrder: 0 },
|
||||
]);
|
||||
}
|
||||
|
||||
const providers =
|
||||
await createCurrentSsoProviderRepository().listEnabledPublic();
|
||||
|
||||
// If no DB providers exist, synthesize one from env vars so SSO login
|
||||
// remains available when configured purely via environment variables.
|
||||
if (providers.length === 0) {
|
||||
const envConfig = getOIDCConfigFromEnv();
|
||||
if (envConfig) {
|
||||
providers.push({ id: 0, name: "SSO", type: "oidc", displayOrder: 0 });
|
||||
}
|
||||
@@ -222,6 +244,12 @@ export function registerSSOProviderRoutes(router: Router): void {
|
||||
error: `Missing required OIDC fields: ${missing.join(", ")}`,
|
||||
});
|
||||
}
|
||||
if (c.issuer_url && !isValidOidcIssuer(c.issuer_url)) {
|
||||
return res.status(400).json({
|
||||
error:
|
||||
"Issuer URL must be an HTTP(S) issuer and not a userinfo endpoint",
|
||||
});
|
||||
}
|
||||
if (
|
||||
(type === "github" || type === "google") &&
|
||||
(!c.client_id || !c.client_secret)
|
||||
@@ -353,6 +381,16 @@ export function registerSSOProviderRoutes(router: Router): void {
|
||||
),
|
||||
...rawConfig,
|
||||
};
|
||||
if (
|
||||
isOidcLike(effectiveType) &&
|
||||
mergedConfig.issuer_url &&
|
||||
!isValidOidcIssuer(mergedConfig.issuer_url)
|
||||
) {
|
||||
return res.status(400).json({
|
||||
error:
|
||||
"Issuer URL must be an HTTP(S) issuer and not a userinfo endpoint",
|
||||
});
|
||||
}
|
||||
encryptedConfig = await encryptProviderConfig(
|
||||
mergedConfig,
|
||||
userId,
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
type Router as ExpressRouter,
|
||||
} from "express";
|
||||
import { apiLogger } from "../../utils/logger.js";
|
||||
import { getFetchDispatcher } from "../../utils/proxy-agent.js";
|
||||
import { fetchWithProxy } from "../../utils/proxy-agent.js";
|
||||
import { createCurrentSettingsRepository } from "../repositories/factory.js";
|
||||
|
||||
interface TailscaleDevice {
|
||||
@@ -74,12 +74,11 @@ export function registerTailscaleRoutes(
|
||||
);
|
||||
|
||||
const url = `${apiBase}/tailnet/-/devices?fields=all`;
|
||||
const response = await fetch(url, {
|
||||
const response = await fetchWithProxy(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"User-Agent": "Termix/1.0",
|
||||
},
|
||||
dispatcher: getFetchDispatcher(url),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -105,6 +105,10 @@ export function getOIDCConfigFromEnv(): OIDCConfig | null {
|
||||
};
|
||||
}
|
||||
|
||||
export function isOIDCEnvOverrideEnabled(): boolean {
|
||||
return process.env.OIDC_ENV_OVERRIDE?.toLowerCase() === "true";
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a group name for comparison. Providers are inconsistent about
|
||||
* whether they emit bare names (`devops-interns`) or full paths
|
||||
@@ -438,6 +442,11 @@ export async function loadProviderConfig(
|
||||
providerType: SSOProviderType;
|
||||
providerDbId: number | null;
|
||||
} | null> {
|
||||
const envConfig = getOIDCConfigFromEnv();
|
||||
if (envConfig && isOIDCEnvOverrideEnabled()) {
|
||||
return { config: envConfig, providerType: "oidc", providerDbId: null };
|
||||
}
|
||||
|
||||
if (providerId != null) {
|
||||
try {
|
||||
const row =
|
||||
@@ -485,7 +494,6 @@ export async function loadProviderConfig(
|
||||
}
|
||||
|
||||
// Fallback: env vars
|
||||
const envConfig = getOIDCConfigFromEnv();
|
||||
if (envConfig) {
|
||||
return { config: envConfig, providerType: "oidc", providerDbId: null };
|
||||
}
|
||||
@@ -542,6 +550,14 @@ export async function resolveProviderByIssuer(issuer: string): Promise<{
|
||||
providerDbId: number | null;
|
||||
} | null> {
|
||||
const target = normalizeIssuer(issuer);
|
||||
const envConfig = getOIDCConfigFromEnv();
|
||||
if (
|
||||
envConfig?.issuer_url &&
|
||||
isOIDCEnvOverrideEnabled() &&
|
||||
normalizeIssuer(envConfig.issuer_url) === target
|
||||
) {
|
||||
return { config: envConfig, providerType: "oidc", providerDbId: null };
|
||||
}
|
||||
|
||||
try {
|
||||
const rows = await createCurrentSsoProviderRepository().listEnabled();
|
||||
@@ -569,7 +585,6 @@ export async function resolveProviderByIssuer(issuer: string): Promise<{
|
||||
});
|
||||
}
|
||||
|
||||
const envConfig = getOIDCConfigFromEnv();
|
||||
if (
|
||||
envConfig?.issuer_url &&
|
||||
normalizeIssuer(envConfig.issuer_url) === target
|
||||
|
||||
@@ -137,6 +137,12 @@ 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";
|
||||
}
|
||||
|
||||
function isNativeAppRequest(req: Request): boolean {
|
||||
return (
|
||||
(req.get("User-Agent") || "").startsWith("Termix-Mobile/") ||
|
||||
@@ -2460,7 +2466,7 @@ router.patch("/oidc-auto-provision", authenticateJWT, async (req, res) => {
|
||||
* /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.
|
||||
* 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:
|
||||
@@ -2471,11 +2477,17 @@ router.patch("/oidc-auto-provision", authenticateJWT, async (req, res) => {
|
||||
*/
|
||||
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);
|
||||
@@ -2507,6 +2519,8 @@ router.get("/oidc-silent-login-default", async (_req, res) => {
|
||||
* 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.
|
||||
*/
|
||||
@@ -2520,6 +2534,12 @@ router.patch(
|
||||
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" });
|
||||
|
||||
Reference in New Issue
Block a user