mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-31 07:35:24 +00:00
* feat: let the login page send the token back to the mobile app * feat: add host-scripts entry to the plugin sdk * feat: let plugins say how they show in the mobile app * feat: frame host for running one plugin view outside the shell * fix: build the SDK host modules in production mode * chore: note the SDK host build fix in the changelog * feat: list plugin engine ranges for the mobile app * feat: host color and icon columns that sync * chore: update translations and plugin pins * fix: test * fix: show source address when rejecting proxy auth * fix: deleting your own account removes all of its data * fix(deps): update body-parser to 2.4.0 (#1736) * fix: body-parser 보안 패치 반영 * chore: 자동 검사를 막던 기존 JSON 서식 정리 --------- Co-authored-by: renovys <180682734+renovys@users.noreply.github.com> * fix(sdk): decode command output as GBK when it is not valid UTF-8 (#1735) * fix: honor shared credentials on shared hosts (#1730) * fix: honor shared credentials on shared hosts and in personal SSH auth * fix: snapshot shared SSH auth from a credential shared with the host owner * fix: size DataView list columns by the table's own width (#1713) * fix: size DataView list columns by the table's own width The list grid was built from every column, so columns hidden below md/lg left empty tracks and the visible cells landed under the wrong headers. Each width now gets a template of only its visible columns (CSS variables + container queries on the table, so a narrow panel counts, not the window), the header uses the same gap as the rows, and rows use a minimum height so wrapped cells no longer overlap. * test: wait for the title bar offset effect before asserting The bar shows once getTitleBarVisible resolves, but --termix-titlebar-height is set in a later passive effect, so reading it right after findByText raced (3-5 of 40 runs under load, on main too). * chore: rerun CI against dev-26.10.6 * fix: unlock passphrase keys once per connect burst (#1737) * feat: give ssh transports the host origin and sync id (#1717) * fix: wake other clients after a sync push (Plugin-Sync#1) * feat: look up local row ids by sync id for the sync plugin (Plugin-Sync#4) * feat: rail items can show a status dot (Plugin-Sync#5) * fix: add the desktop shared copy banner text (#1709) * feat: sidebar preference for alternating rows (#1712) * fix: striped rows text * feat: shell can open a saved host's editor for plugins (Plugin-SSH-Terminal#36) * fix: show the logo in the macos menu bar (#1728) * fix: number shortcuts skip the home tab (#1724) * fix: show plugins that could not be downloaded as failed (#1714) * fix: pass the host id as text when opening its editor * fix: retry a plugin bundle that was not ready yet (#1708) * fix: let commonjs plugins fall back to core packages on the desktop * fix: let non docker installs switch nginx to https (#1711) * feat: remove a folder share from its hosts too (#1719) * chore: changelog for merged pull requests * fix: accept the release .sig when installing a plugin from a file * feat: folder logins for shared hosts, read only plugin sync, full desktop builds * fix: test * fix: stop dev runner restart loops and narrow plugin watchers * chore: update translations and plugin pins * chore: lint, format, and bump version to 26.10.6 --------- Co-authored-by: renovys <renovy@gmail.com> Co-authored-by: renovys <180682734+renovys@users.noreply.github.com> Co-authored-by: Sebastian Cao <cycsmail@gmail.com> Co-authored-by: Jonathan Rehuel Lewerissa <jonathan.rehuel16@gmail.com> Co-authored-by: Rhiz3K <33246262+Rhiz3K@users.noreply.github.com>
498 lines
15 KiB
TypeScript
498 lines
15 KiB
TypeScript
import { getErrorMessage } from "./error-message.js";
|
|
import {
|
|
createCurrentHostResolutionRepository,
|
|
createCurrentRbacAccessRepository,
|
|
createCurrentRoleRepository,
|
|
createCurrentSharedHostSecretsRepository,
|
|
} from "../database/repositories/factory.js";
|
|
import type {
|
|
SharedHostSecretRecord,
|
|
ShareProtocol,
|
|
} from "../database/repositories/shared-host-secrets-repository.js";
|
|
import type { HostResolutionHostRecord } from "../database/repositories/host-resolution-repository.js";
|
|
import { DataCrypto } from "./data-crypto.js";
|
|
import { FieldCrypto } from "./field-crypto.js";
|
|
import { databaseLogger } from "./logger.js";
|
|
import { findUsableCredential } from "../hosts/usable-credential.js";
|
|
import { listProtocolLogins } from "../hosts/protocol-auth/protocol-auth.js";
|
|
|
|
export interface SharedSecretData {
|
|
username?: string;
|
|
authType: string;
|
|
password?: string;
|
|
key?: string;
|
|
keyPassword?: string;
|
|
keyType?: string;
|
|
domain?: string;
|
|
/** A plugin protocol's declared credential fields. */
|
|
fields?: Record<string, string>;
|
|
}
|
|
|
|
interface ProtocolSnapshot {
|
|
protocol: ShareProtocol;
|
|
sourceType: "credential" | "inline";
|
|
originalCredentialId: number | null;
|
|
data: SharedSecretData;
|
|
}
|
|
|
|
function snapshotRecordId(
|
|
hostAccessId: number,
|
|
targetUserId: string,
|
|
protocol: ShareProtocol,
|
|
): string {
|
|
return `shared-${hostAccessId}-${targetUserId}-${protocol}`;
|
|
}
|
|
|
|
// SSH follows its own switch. A plugin protocol's switch is that plugin's
|
|
// host setting, and the editor removes a protocol's login when it is
|
|
// switched off, so a snapshot is taken whenever a login is stored.
|
|
function sshEnabled(host: HostResolutionHostRecord): boolean {
|
|
const ct = host.connectionType;
|
|
return host.enableSsh !== false && (!ct || ct === "ssh");
|
|
}
|
|
|
|
// Per-recipient copies of connection secrets, re-encrypted under the
|
|
// recipient's DEK. SSH authentication is copied only when the host owner
|
|
// explicitly opts in; recipient-owned credential overrides remain separate.
|
|
/**
|
|
* A snapshot's protocol fields. Snapshots taken before 2.9.0 kept a domain
|
|
* in its own column and nothing else.
|
|
*/
|
|
function parseFields(
|
|
value: string | undefined,
|
|
legacyDomain: string | undefined,
|
|
): Record<string, string> | undefined {
|
|
if (value) {
|
|
try {
|
|
const parsed = JSON.parse(value);
|
|
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
return parsed as Record<string, string>;
|
|
}
|
|
} catch {
|
|
// Unreadable fields are left out.
|
|
}
|
|
}
|
|
return legacyDomain ? { domain: legacyDomain } : undefined;
|
|
}
|
|
|
|
class SharedHostSecretsManager {
|
|
private static instance: SharedHostSecretsManager;
|
|
|
|
private constructor() {}
|
|
|
|
static getInstance(): SharedHostSecretsManager {
|
|
if (!this.instance) {
|
|
this.instance = new SharedHostSecretsManager();
|
|
}
|
|
return this.instance;
|
|
}
|
|
|
|
async snapshotForUser(
|
|
hostAccessId: number,
|
|
hostId: number,
|
|
targetUserId: string,
|
|
ownerId: string,
|
|
): Promise<void> {
|
|
if (targetUserId === ownerId) return;
|
|
|
|
try {
|
|
const targetDEK = DataCrypto.validateUserAccess(targetUserId);
|
|
DataCrypto.validateUserAccess(ownerId);
|
|
|
|
const host = await createCurrentHostResolutionRepository().findHostById(
|
|
hostId,
|
|
ownerId,
|
|
);
|
|
if (!host) {
|
|
throw new Error(`Host ${hostId} not found`);
|
|
}
|
|
|
|
const snapshots = await this.collectProtocolSnapshots(host, ownerId);
|
|
const repository = createCurrentSharedHostSecretsRepository();
|
|
|
|
for (const snapshot of snapshots) {
|
|
const recordId = snapshotRecordId(
|
|
hostAccessId,
|
|
targetUserId,
|
|
snapshot.protocol,
|
|
);
|
|
const encrypt = (value: string | undefined, fieldName: string) =>
|
|
value
|
|
? FieldCrypto.encryptField(value, targetDEK, recordId, fieldName)
|
|
: null;
|
|
|
|
await repository.upsert({
|
|
hostAccessId,
|
|
targetUserId,
|
|
protocol: snapshot.protocol,
|
|
sourceType: snapshot.sourceType,
|
|
originalCredentialId: snapshot.originalCredentialId,
|
|
encryptedUsername: encrypt(snapshot.data.username, "username"),
|
|
encryptedAuthType: snapshot.data.authType,
|
|
encryptedPassword: encrypt(snapshot.data.password, "password"),
|
|
encryptedKey: encrypt(snapshot.data.key, "key"),
|
|
encryptedKeyPassword: encrypt(
|
|
snapshot.data.keyPassword,
|
|
"key_password",
|
|
),
|
|
encryptedKeyType: snapshot.data.keyType || null,
|
|
encryptedDomain: encrypt(snapshot.data.domain, "domain"),
|
|
encryptedFields: encrypt(
|
|
snapshot.data.fields ? JSON.stringify(snapshot.data.fields) : "",
|
|
"fields",
|
|
),
|
|
});
|
|
}
|
|
|
|
await repository.deleteForHostAccessAndTarget(
|
|
hostAccessId,
|
|
targetUserId,
|
|
snapshots.map((snapshot) => snapshot.protocol),
|
|
);
|
|
} catch (error) {
|
|
databaseLogger.error("Failed to snapshot shared host secrets", error, {
|
|
operation: "shared_host_secrets_snapshot",
|
|
hostAccessId,
|
|
hostId,
|
|
targetUserId,
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async snapshotForRole(
|
|
hostAccessId: number,
|
|
hostId: number,
|
|
roleId: number,
|
|
ownerId: string,
|
|
): Promise<void> {
|
|
const roleUserIds =
|
|
await createCurrentRoleRepository().listRoleUserIds(roleId);
|
|
|
|
for (const userId of roleUserIds) {
|
|
try {
|
|
await this.snapshotForUser(hostAccessId, hostId, userId, ownerId);
|
|
} catch (error) {
|
|
databaseLogger.error(
|
|
"Failed to snapshot shared host secrets for role member",
|
|
error,
|
|
{
|
|
operation: "shared_host_secrets_snapshot_role",
|
|
hostAccessId,
|
|
roleId,
|
|
userId,
|
|
},
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
async snapshotForRoleMember(
|
|
roleId: number,
|
|
targetUserId: string,
|
|
): Promise<void> {
|
|
const hostsSharedWithRole =
|
|
await createCurrentRbacAccessRepository().listRoleHostAccessCredentialSources(
|
|
roleId,
|
|
);
|
|
|
|
for (const sharedHost of hostsSharedWithRole) {
|
|
try {
|
|
await this.snapshotForUser(
|
|
sharedHost.hostAccessId,
|
|
sharedHost.hostId,
|
|
targetUserId,
|
|
sharedHost.hostOwnerId,
|
|
);
|
|
} catch (error) {
|
|
databaseLogger.error(
|
|
"Failed to snapshot shared host secrets for role member",
|
|
error,
|
|
{
|
|
operation: "shared_host_secrets_snapshot_role_member",
|
|
roleId,
|
|
targetUserId,
|
|
hostId: sharedHost.hostId,
|
|
},
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
async snapshotForUserRoles(userId: string): Promise<void> {
|
|
const roleIds = await createCurrentRoleRepository().listUserRoleIds(userId);
|
|
|
|
for (const roleId of roleIds) {
|
|
await this.snapshotForRoleMember(roleId, userId);
|
|
}
|
|
}
|
|
|
|
// Re-snapshot every active grant on a host. Called after the owner (or an
|
|
// editor) changes the host so recipients keep working secrets.
|
|
async resyncHost(hostId: number): Promise<void> {
|
|
const hostResolutionRepository = createCurrentHostResolutionRepository();
|
|
const ownerId = await hostResolutionRepository.findHostOwnerId(hostId);
|
|
if (!ownerId) return;
|
|
|
|
const grants =
|
|
await createCurrentRbacAccessRepository().listActiveHostAccessGrants(
|
|
hostId,
|
|
);
|
|
if (grants.length === 0) return;
|
|
|
|
const roleRepository = createCurrentRoleRepository();
|
|
|
|
for (const grant of grants) {
|
|
const targetUserIds = grant.userId
|
|
? [grant.userId]
|
|
: grant.roleId
|
|
? await roleRepository.listRoleUserIds(grant.roleId)
|
|
: [];
|
|
|
|
for (const targetUserId of targetUserIds) {
|
|
if (targetUserId === ownerId) continue;
|
|
try {
|
|
await this.snapshotForUser(grant.id, hostId, targetUserId, ownerId);
|
|
} catch (error) {
|
|
databaseLogger.warn(
|
|
"Skipping shared host secret resync for recipient",
|
|
{
|
|
operation: "shared_host_secrets_resync_skip",
|
|
hostId,
|
|
hostAccessId: grant.id,
|
|
targetUserId,
|
|
error: getErrorMessage(error),
|
|
},
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Re-snapshot every shared host that references a credential after the
|
|
// owner edits that credential.
|
|
async resyncHostsForCredential(
|
|
credentialId: number,
|
|
ownerId: string,
|
|
): Promise<void> {
|
|
try {
|
|
const hostIds =
|
|
await createCurrentSharedHostSecretsRepository().findHostIdsReferencingCredential(
|
|
ownerId,
|
|
credentialId,
|
|
);
|
|
|
|
for (const hostId of hostIds) {
|
|
await this.resyncHost(hostId);
|
|
}
|
|
} catch (error) {
|
|
databaseLogger.error(
|
|
"Failed to resync shared host secrets for credential",
|
|
error,
|
|
{
|
|
operation: "shared_host_secrets_resync_credential",
|
|
credentialId,
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
async getSecretForUser(
|
|
hostId: number,
|
|
userId: string,
|
|
protocol: ShareProtocol,
|
|
): Promise<SharedSecretData | null> {
|
|
try {
|
|
const userDEK = DataCrypto.validateUserAccess(userId);
|
|
|
|
const secret =
|
|
await createCurrentSharedHostSecretsRepository().findForHostUserProtocol(
|
|
hostId,
|
|
userId,
|
|
protocol,
|
|
);
|
|
|
|
if (!secret) return null;
|
|
|
|
return this.decryptSnapshot(secret, userDEK);
|
|
} catch (error) {
|
|
databaseLogger.error("Failed to get shared host secret", error, {
|
|
operation: "shared_host_secrets_get",
|
|
hostId,
|
|
userId,
|
|
protocol,
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async deleteForHostAccess(hostAccessId: number): Promise<void> {
|
|
try {
|
|
await createCurrentSharedHostSecretsRepository().deleteByHostAccessId(
|
|
hostAccessId,
|
|
);
|
|
} catch (error) {
|
|
databaseLogger.error(
|
|
"Failed to delete shared host secrets for access grant",
|
|
error,
|
|
{
|
|
operation: "shared_host_secrets_delete_access",
|
|
hostAccessId,
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
async deleteForUser(userId: string): Promise<void> {
|
|
try {
|
|
await createCurrentSharedHostSecretsRepository().deleteByTargetUserId(
|
|
userId,
|
|
);
|
|
} catch (error) {
|
|
databaseLogger.error(
|
|
"Failed to delete shared host secrets for user",
|
|
error,
|
|
{
|
|
operation: "shared_host_secrets_delete_user",
|
|
userId,
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
async deleteForCredential(credentialId: number): Promise<void> {
|
|
try {
|
|
await createCurrentSharedHostSecretsRepository().deleteByOriginalCredentialId(
|
|
credentialId,
|
|
);
|
|
} catch (error) {
|
|
databaseLogger.error(
|
|
"Failed to delete shared host secrets for credential",
|
|
error,
|
|
{
|
|
operation: "shared_host_secrets_delete_credential",
|
|
credentialId,
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
private async collectProtocolSnapshots(
|
|
host: HostResolutionHostRecord,
|
|
ownerId: string,
|
|
): Promise<ProtocolSnapshot[]> {
|
|
const snapshots: ProtocolSnapshot[] = [];
|
|
|
|
if (sshEnabled(host) && host.shareSshAuth) {
|
|
if (host.credentialId) {
|
|
const credential = await findUsableCredential(
|
|
host.credentialId,
|
|
ownerId,
|
|
);
|
|
if (credential) {
|
|
snapshots.push({
|
|
protocol: "ssh",
|
|
sourceType: "credential",
|
|
originalCredentialId: host.credentialId,
|
|
data: {
|
|
username: credential.username || undefined,
|
|
authType: credential.authType,
|
|
password: credential.password || undefined,
|
|
key: credential.privateKey || credential.key || undefined,
|
|
keyPassword: credential.keyPassword || undefined,
|
|
keyType: credential.keyType || undefined,
|
|
},
|
|
});
|
|
}
|
|
} else if (
|
|
(host.authType === "password" && host.password) ||
|
|
(host.authType === "key" && host.key)
|
|
) {
|
|
snapshots.push({
|
|
protocol: "ssh",
|
|
sourceType: "inline",
|
|
originalCredentialId: null,
|
|
data: {
|
|
username: host.username || undefined,
|
|
authType: host.authType,
|
|
password: host.password || undefined,
|
|
key: host.key || undefined,
|
|
keyPassword: host.keyPassword || undefined,
|
|
keyType: host.keyType || undefined,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
for (const login of await listProtocolLogins(host.id, ownerId)) {
|
|
const fields = { ...login.fields, ...login.secretFields };
|
|
const hasFields = Object.keys(fields).length > 0;
|
|
if (login.authType === "credential" && login.credentialId) {
|
|
const credential = await findUsableCredential(
|
|
login.credentialId,
|
|
ownerId,
|
|
);
|
|
if (credential) {
|
|
snapshots.push({
|
|
protocol: login.protocol,
|
|
sourceType: "credential",
|
|
originalCredentialId: login.credentialId,
|
|
data: {
|
|
username: credential.username || undefined,
|
|
authType: "credential",
|
|
password: credential.password || undefined,
|
|
fields: hasFields ? fields : undefined,
|
|
},
|
|
});
|
|
}
|
|
} else if (
|
|
login.authType === "direct" &&
|
|
(login.username || login.password || hasFields)
|
|
) {
|
|
snapshots.push({
|
|
protocol: login.protocol,
|
|
sourceType: "inline",
|
|
originalCredentialId: null,
|
|
data: {
|
|
username: login.username || undefined,
|
|
authType: "direct",
|
|
password: login.password || undefined,
|
|
fields: hasFields ? fields : undefined,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
return snapshots;
|
|
}
|
|
|
|
private decryptSnapshot(
|
|
secret: SharedHostSecretRecord,
|
|
userDEK: Buffer,
|
|
): SharedSecretData {
|
|
const recordId = snapshotRecordId(
|
|
secret.hostAccessId,
|
|
secret.targetUserId,
|
|
secret.protocol as ShareProtocol,
|
|
);
|
|
const decrypt = (value: string | null, fieldName: string) =>
|
|
value
|
|
? FieldCrypto.decryptField(value, userDEK, recordId, fieldName)
|
|
: undefined;
|
|
|
|
const domain = decrypt(secret.encryptedDomain, "domain");
|
|
return {
|
|
username: decrypt(secret.encryptedUsername, "username"),
|
|
authType: secret.encryptedAuthType || "password",
|
|
password: decrypt(secret.encryptedPassword, "password"),
|
|
key: decrypt(secret.encryptedKey, "key"),
|
|
keyPassword: decrypt(secret.encryptedKeyPassword, "key_password"),
|
|
keyType: secret.encryptedKeyType || undefined,
|
|
domain,
|
|
fields: parseFields(decrypt(secret.encryptedFields, "fields"), domain),
|
|
};
|
|
}
|
|
}
|
|
|
|
export { SharedHostSecretsManager };
|