mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 07:05: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>
570 lines
16 KiB
TypeScript
570 lines
16 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const state = vi.hoisted(() => ({
|
|
host: null as Record<string, unknown> | null,
|
|
hasAccess: true,
|
|
isAdminBypass: false,
|
|
overrideCredentialId: null as number | null,
|
|
credentials: new Map<string, Record<string, unknown>>(),
|
|
sharedCredentials: new Map<string, Record<string, unknown>>(),
|
|
auditCalls: [] as Record<string, unknown>[],
|
|
sharedSecret: null as Record<string, unknown> | null,
|
|
activeHostAccessId: null as number | null,
|
|
snapshotForUserCalls: [] as Record<string, unknown>[],
|
|
snapshotHeals: false,
|
|
}));
|
|
|
|
vi.mock("../../database/repositories/factory.js", () => ({
|
|
createCurrentHostResolutionRepository: () => ({
|
|
findHostOwnerId: async () => (state.host?.userId as string) ?? null,
|
|
findHostById: async () => (state.host ? { ...state.host } : null),
|
|
findCredentialByIdForUser: async (credentialId: number, userId: string) =>
|
|
state.credentials.get(`${credentialId}:${userId}`) ?? null,
|
|
}),
|
|
createCurrentSharedHostAuthOverrideRepository: () => ({
|
|
findCredentialId: async () => state.overrideCredentialId,
|
|
}),
|
|
createCurrentUserRepository: () => ({
|
|
findById: async (userId: string) => ({ id: userId, username: userId }),
|
|
}),
|
|
createCurrentRoleRepository: () => ({
|
|
listUserRoleIds: async () => [],
|
|
}),
|
|
createCurrentRbacAccessRepository: () => ({
|
|
findActiveHostAccess: async () =>
|
|
state.activeHostAccessId !== null
|
|
? { id: state.activeHostAccessId }
|
|
: null,
|
|
}),
|
|
}));
|
|
|
|
vi.mock("../../hosts/usable-credential.js", () => ({
|
|
findUsableCredential: async (credentialId: number, userId: string) =>
|
|
state.credentials.get(`${credentialId}:${userId}`) ??
|
|
state.sharedCredentials.get(`${credentialId}:${userId}`) ??
|
|
null,
|
|
}));
|
|
|
|
vi.mock("../../utils/audit-logger.js", () => ({
|
|
logAudit: async (params: Record<string, unknown>) => {
|
|
state.auditCalls.push(params);
|
|
},
|
|
}));
|
|
|
|
vi.mock("../../utils/permission-manager.js", () => ({
|
|
PermissionManager: {
|
|
getInstance: () => ({
|
|
canAccessHost: async () => ({
|
|
hasAccess: state.hasAccess,
|
|
isAdminBypass: state.isAdminBypass,
|
|
}),
|
|
}),
|
|
},
|
|
}));
|
|
|
|
vi.mock("../../utils/shared-host-secrets-manager.js", () => ({
|
|
SharedHostSecretsManager: {
|
|
getInstance: () => ({
|
|
getSecretForUser: async () => state.sharedSecret,
|
|
snapshotForUser: async (
|
|
hostAccessId: number,
|
|
hostId: number,
|
|
targetUserId: string,
|
|
ownerId: string,
|
|
) => {
|
|
state.snapshotForUserCalls.push({
|
|
hostAccessId,
|
|
hostId,
|
|
targetUserId,
|
|
ownerId,
|
|
});
|
|
if (state.snapshotHeals) {
|
|
state.sharedSecret = {
|
|
username: "shared-user",
|
|
authType: "key",
|
|
key: "HEALED-KEY",
|
|
};
|
|
}
|
|
},
|
|
}),
|
|
},
|
|
}));
|
|
|
|
vi.mock("../../utils/logger.js", () => ({
|
|
logger: {
|
|
debug: vi.fn(),
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
error: vi.fn(),
|
|
success: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
import { resolveHostById } from "../../hosts/host-resolver.js";
|
|
|
|
function baseHost(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
id: 42,
|
|
userId: "owner",
|
|
name: "prod",
|
|
ip: "10.0.0.42",
|
|
port: 22,
|
|
username: "root",
|
|
authType: "password",
|
|
password: "owner-secret",
|
|
key: null,
|
|
keyPassword: null,
|
|
keyType: null,
|
|
credentialId: null,
|
|
shareSshAuth: false,
|
|
sudoPassword: "owner-sudo",
|
|
jumpHosts: null,
|
|
tunnelConnections: null,
|
|
socks5ProxyChain: null,
|
|
quickActions: null,
|
|
overrideCredentialUsername: false,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
beforeEach(() => {
|
|
state.host = baseHost();
|
|
state.hasAccess = true;
|
|
state.isAdminBypass = false;
|
|
state.overrideCredentialId = null;
|
|
state.credentials.clear();
|
|
state.sharedCredentials.clear();
|
|
state.auditCalls = [];
|
|
state.sharedSecret = null;
|
|
state.activeHostAccessId = null;
|
|
state.snapshotForUserCalls = [];
|
|
state.snapshotHeals = false;
|
|
});
|
|
|
|
describe("resolveHostById", () => {
|
|
it("returns null when access is denied", async () => {
|
|
state.hasAccess = false;
|
|
expect(await resolveHostById(42, "stranger")).toBeNull();
|
|
});
|
|
|
|
it("resolves the owner's credential on the owner path", async () => {
|
|
// Empty host username so the credential's username is used as fallback.
|
|
state.host = baseHost({
|
|
authType: "credential",
|
|
credentialId: 9,
|
|
username: "",
|
|
});
|
|
state.credentials.set("9:owner", {
|
|
id: 9,
|
|
username: "cred-user",
|
|
authType: "key",
|
|
password: null,
|
|
privateKey: "PRIVATE-KEY",
|
|
key: null,
|
|
keyPassword: "kp",
|
|
keyType: "ssh-ed25519",
|
|
certPublicKey: null,
|
|
});
|
|
|
|
const host = (await resolveHostById(42, "owner")) as Record<
|
|
string,
|
|
unknown
|
|
>;
|
|
expect(host.key).toBe("PRIVATE-KEY");
|
|
expect(host.username).toBe("cred-user");
|
|
expect(host.authType).toBe("key");
|
|
expect(host.sudoPassword).toBe("owner-sudo");
|
|
});
|
|
|
|
it("prefers the host's own credential over its folder's credential", async () => {
|
|
state.host = baseHost({
|
|
authType: "credential",
|
|
credentialId: 9,
|
|
folder: "switches",
|
|
username: "",
|
|
password: null,
|
|
});
|
|
state.credentials.set("9:owner", {
|
|
id: 9,
|
|
username: "host-user",
|
|
authType: "password",
|
|
password: "host-pass",
|
|
privateKey: null,
|
|
key: null,
|
|
keyPassword: null,
|
|
keyType: null,
|
|
});
|
|
|
|
const host = (await resolveHostById(42, "owner")) as Record<
|
|
string,
|
|
unknown
|
|
>;
|
|
expect(host.username).toBe("host-user");
|
|
expect(host.password).toBe("host-pass");
|
|
});
|
|
|
|
it("does not expose the owner's secret-backed SSH authentication", async () => {
|
|
expect(await resolveHostById(42, "recipient")).toBeNull();
|
|
});
|
|
|
|
it("uses the owner-provided SSH snapshot when sharing is enabled", async () => {
|
|
state.host = baseHost({ shareSshAuth: true, username: "host-user" });
|
|
state.sharedSecret = {
|
|
username: "shared-user",
|
|
authType: "password",
|
|
password: "shared-pass",
|
|
};
|
|
|
|
const host = (await resolveHostById(42, "recipient")) as Record<
|
|
string,
|
|
unknown
|
|
>;
|
|
expect(host.username).toBe("host-user");
|
|
expect(host.password).toBe("shared-pass");
|
|
expect(host.authType).toBe("password");
|
|
});
|
|
|
|
it("denies shared secret-backed auth when the opted-in snapshot is missing and cannot be healed", async () => {
|
|
state.host = baseHost({ shareSshAuth: true });
|
|
expect(await resolveHostById(42, "recipient")).toBeNull();
|
|
});
|
|
|
|
it("lazily re-snapshots and resolves shared auth when the grant exists but its snapshot was never created", async () => {
|
|
// e.g. the owner shared the host while the recipient's data key was
|
|
// unavailable, so snapshotForUser silently skipped at share time.
|
|
state.host = baseHost({ shareSshAuth: true, username: "host-user" });
|
|
state.activeHostAccessId = 77;
|
|
state.snapshotHeals = true;
|
|
|
|
const host = (await resolveHostById(42, "recipient")) as Record<
|
|
string,
|
|
unknown
|
|
>;
|
|
expect(state.snapshotForUserCalls).toEqual([
|
|
{
|
|
hostAccessId: 77,
|
|
hostId: 42,
|
|
targetUserId: "recipient",
|
|
ownerId: "owner",
|
|
},
|
|
]);
|
|
expect(host.key).toBe("HEALED-KEY");
|
|
expect(host.authType).toBe("key");
|
|
});
|
|
|
|
it("keeps SSH agent authentication private unless the owner opts in", async () => {
|
|
state.host = baseHost({
|
|
authType: "agent",
|
|
password: null,
|
|
sshOptions: JSON.stringify({
|
|
agentSocketPath: "/run/user/1000/ssh-agent.sock",
|
|
}),
|
|
});
|
|
|
|
expect(await resolveHostById(42, "recipient")).toBeNull();
|
|
});
|
|
|
|
it("allows SSH agent authentication after the owner explicitly opts in", async () => {
|
|
state.host = baseHost({
|
|
authType: "agent",
|
|
password: null,
|
|
shareSshAuth: true,
|
|
sshOptions: JSON.stringify({
|
|
agentSocketPath: "/run/user/1000/ssh-agent.sock",
|
|
}),
|
|
});
|
|
|
|
const host = (await resolveHostById(42, "recipient")) as Record<
|
|
string,
|
|
unknown
|
|
>;
|
|
expect(host.authType).toBe("agent");
|
|
expect(host.sshOptions).toEqual({
|
|
agentSocketPath: "/run/user/1000/ssh-agent.sock",
|
|
});
|
|
});
|
|
|
|
it("uses the recipient's credential instead of the owner's authentication", async () => {
|
|
state.host = baseHost({ username: "", shareSshAuth: true });
|
|
state.sharedSecret = {
|
|
username: "shared-user",
|
|
authType: "password",
|
|
password: "shared-pass",
|
|
};
|
|
state.overrideCredentialId = 5;
|
|
state.credentials.set("5:recipient", {
|
|
id: 5,
|
|
username: "my-user",
|
|
authType: "password",
|
|
password: "my-pass",
|
|
privateKey: null,
|
|
key: null,
|
|
keyPassword: null,
|
|
keyType: null,
|
|
});
|
|
|
|
const host = (await resolveHostById(42, "recipient")) as Record<
|
|
string,
|
|
unknown
|
|
>;
|
|
expect(host.password).toBe("my-pass");
|
|
expect(host.username).toBe("my-user");
|
|
});
|
|
|
|
it("uses the recipient credential username even when the owner forces their own credential username", async () => {
|
|
state.host = baseHost({
|
|
username: "owner-login",
|
|
overrideCredentialUsername: true,
|
|
});
|
|
state.overrideCredentialId = 5;
|
|
state.credentials.set("5:recipient", {
|
|
id: 5,
|
|
username: "recipient-login",
|
|
authType: "key",
|
|
password: null,
|
|
privateKey: "RECIPIENT-KEY",
|
|
key: null,
|
|
keyPassword: null,
|
|
keyType: "ssh-ed25519",
|
|
});
|
|
|
|
const host = (await resolveHostById(42, "recipient")) as Record<
|
|
string,
|
|
unknown
|
|
>;
|
|
expect(host.username).toBe("recipient-login");
|
|
expect(host.authType).toBe("key");
|
|
expect(host.key).toBe("RECIPIENT-KEY");
|
|
});
|
|
|
|
it("fully replaces a plugin auth type with the recipient override", async () => {
|
|
state.host = baseHost({
|
|
authType: "vault",
|
|
password: null,
|
|
});
|
|
state.overrideCredentialId = 5;
|
|
state.credentials.set("5:recipient", {
|
|
id: 5,
|
|
username: "recipient-login",
|
|
authType: "key",
|
|
password: null,
|
|
privateKey: "RECIPIENT-KEY",
|
|
key: null,
|
|
keyPassword: null,
|
|
keyType: "ssh-ed25519",
|
|
certPublicKey: "ssh-ed25519-cert-v01@example certificate",
|
|
});
|
|
|
|
const host = (await resolveHostById(42, "recipient")) as Record<
|
|
string,
|
|
unknown
|
|
>;
|
|
expect(host.authType).toBe("key");
|
|
expect(host.key).toBe("RECIPIENT-KEY");
|
|
expect(host.certPublicKey).toBe("ssh-ed25519-cert-v01@example certificate");
|
|
});
|
|
|
|
it("falls back to the host username when the override credential has none", async () => {
|
|
state.host = baseHost({ username: "shared-login" });
|
|
state.overrideCredentialId = 5;
|
|
state.credentials.set("5:recipient", {
|
|
id: 5,
|
|
username: null,
|
|
authType: "password",
|
|
password: "my-pass",
|
|
privateKey: null,
|
|
key: null,
|
|
keyPassword: null,
|
|
keyType: null,
|
|
});
|
|
|
|
const host = (await resolveHostById(42, "recipient")) as Record<
|
|
string,
|
|
unknown
|
|
>;
|
|
expect(host.username).toBe("shared-login");
|
|
});
|
|
|
|
it("uses a credential shared with the recipient as their personal override", async () => {
|
|
state.overrideCredentialId = 7;
|
|
state.sharedCredentials.set("7:recipient", {
|
|
id: 7,
|
|
username: "shared-cred-user",
|
|
password: "shared-cred-pass",
|
|
privateKey: null,
|
|
key: null,
|
|
keyPassword: null,
|
|
keyType: null,
|
|
});
|
|
|
|
const host = (await resolveHostById(42, "recipient")) as Record<
|
|
string,
|
|
unknown
|
|
>;
|
|
expect(host.username).toBe("shared-cred-user");
|
|
expect(host.password).toBe("shared-cred-pass");
|
|
});
|
|
|
|
it("uses the host's credential when it is also shared with the recipient", async () => {
|
|
state.host = baseHost({
|
|
authType: "credential",
|
|
password: null,
|
|
credentialId: 1,
|
|
});
|
|
state.sharedCredentials.set("1:recipient", {
|
|
id: 1,
|
|
username: "deploy",
|
|
password: null,
|
|
privateKey: "OWNER-KEY-COPY",
|
|
key: null,
|
|
keyPassword: null,
|
|
keyType: "ssh-ed25519",
|
|
});
|
|
|
|
const host = (await resolveHostById(42, "recipient")) as Record<
|
|
string,
|
|
unknown
|
|
>;
|
|
expect(host.authType).toBe("key");
|
|
expect(host.key).toBe("OWNER-KEY-COPY");
|
|
expect(host.username).toBe("deploy");
|
|
});
|
|
|
|
it("denies a recipient when the host's credential is not shared with them", async () => {
|
|
state.host = baseHost({
|
|
authType: "credential",
|
|
password: null,
|
|
credentialId: 1,
|
|
});
|
|
|
|
expect(await resolveHostById(42, "recipient")).toBeNull();
|
|
});
|
|
|
|
it("denies a non-owner when a secret-bearing host has no personal credential", async () => {
|
|
expect(await resolveHostById(42, "recipient")).toBeNull();
|
|
});
|
|
|
|
it("ignores a stored override when shared access is inactive", async () => {
|
|
state.hasAccess = false;
|
|
state.overrideCredentialId = 5;
|
|
state.credentials.set("5:recipient", {
|
|
id: 5,
|
|
username: "recipient",
|
|
authType: "password",
|
|
password: "my-pass",
|
|
});
|
|
|
|
expect(await resolveHostById(42, "recipient")).toBeNull();
|
|
});
|
|
|
|
it("lets a non-owner through on secret-less auth types without a snapshot", async () => {
|
|
state.host = baseHost({
|
|
authType: "none",
|
|
password: "stale-owner-password",
|
|
key: "stale-owner-key",
|
|
credentialId: null,
|
|
sudoPassword: "owner-sudo",
|
|
});
|
|
const host = (await resolveHostById(42, "recipient")) as Record<
|
|
string,
|
|
unknown
|
|
>;
|
|
expect(host.password).toBeNull();
|
|
expect(host.key).toBeNull();
|
|
expect(host.credentialId).toBeNull();
|
|
// The owner's sudo password never reaches a recipient.
|
|
expect(host.sudoPassword).toBeNull();
|
|
});
|
|
|
|
it("reads the ssh_options column", async () => {
|
|
state.host = baseHost({
|
|
sshOptions: JSON.stringify({ keepaliveCountMax: 3 }),
|
|
});
|
|
const host = (await resolveHostById(42, "owner")) as Record<
|
|
string,
|
|
unknown
|
|
>;
|
|
expect(host.sshOptions).toEqual({ keepaliveCountMax: 3 });
|
|
});
|
|
|
|
it("turns the stored force_keyboard_interactive text into a boolean", async () => {
|
|
state.host = baseHost({ forceKeyboardInteractive: "false" });
|
|
const off = (await resolveHostById(42, "owner")) as Record<string, unknown>;
|
|
expect(off.forceKeyboardInteractive).toBe(false);
|
|
|
|
state.host = baseHost({ forceKeyboardInteractive: "true" });
|
|
const on = (await resolveHostById(42, "owner")) as Record<string, unknown>;
|
|
expect(on.forceKeyboardInteractive).toBe(true);
|
|
});
|
|
|
|
it("resolves an admin bypass like the owner, keeping owner-only secrets", async () => {
|
|
state.isAdminBypass = true;
|
|
state.host = baseHost({
|
|
authType: "credential",
|
|
credentialId: 9,
|
|
username: "",
|
|
password: null,
|
|
});
|
|
state.credentials.set("9:owner", {
|
|
id: 9,
|
|
username: "cred-user",
|
|
authType: "key",
|
|
password: null,
|
|
privateKey: "OWNER-PRIVATE-KEY",
|
|
key: null,
|
|
keyPassword: "kp",
|
|
keyType: "ssh-ed25519",
|
|
certPublicKey: null,
|
|
});
|
|
|
|
const host = (await resolveHostById(42, "adminUser")) as Record<
|
|
string,
|
|
unknown
|
|
>;
|
|
// Owner credential resolved (not the share snapshot path).
|
|
expect(host.key).toBe("OWNER-PRIVATE-KEY");
|
|
expect(host.username).toBe("cred-user");
|
|
// Owner-only operational secrets are NOT stripped for the admin.
|
|
expect(host.sudoPassword).toBe("owner-sudo");
|
|
});
|
|
|
|
it("audits every admin-bypass host resolution", async () => {
|
|
state.isAdminBypass = true;
|
|
await resolveHostById(42, "adminUser");
|
|
expect(state.auditCalls).toHaveLength(1);
|
|
expect(state.auditCalls[0]).toMatchObject({
|
|
action: "admin_connect_host",
|
|
resourceType: "host",
|
|
resourceId: "42",
|
|
userId: "adminUser",
|
|
});
|
|
});
|
|
|
|
it("does not audit an ordinary owner resolution", async () => {
|
|
await resolveHostById(42, "owner");
|
|
expect(state.auditCalls).toHaveLength(0);
|
|
});
|
|
|
|
it("parses an empty port_knock_sequence '[]' string into an empty array (no bogus knock)", async () => {
|
|
state.host = baseHost({ portKnockSequence: "[]" });
|
|
const host = (await resolveHostById(42, "owner")) as Record<
|
|
string,
|
|
unknown
|
|
>;
|
|
expect(host.portKnockSequence).toEqual([]);
|
|
});
|
|
|
|
it("parses a real port_knock_sequence JSON string into an array", async () => {
|
|
state.host = baseHost({
|
|
portKnockSequence: '[{"port":1234,"protocol":"tcp","delay":100}]',
|
|
});
|
|
const host = (await resolveHostById(42, "owner")) as Record<
|
|
string,
|
|
unknown
|
|
>;
|
|
expect(host.portKnockSequence).toEqual([
|
|
{ port: 1234, protocol: "tcp", delay: 100 },
|
|
]);
|
|
});
|
|
});
|