mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 15:15:24 +00:00
feat: rebuild desktop sync as linked server accounts, fix packaged plugin bundles and UI scaling
This commit is contained in:
1 parent
7c02daccf4
commit
6cd9a2846d
242 files changed
+39127
-8558
No files matched your search
@@ -0,0 +1,85 @@
|
||||
import { createRequire } from "node:module";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const linked = require("../../../../electron/linked-server.cjs") as {
|
||||
setLinkedServer: (config: unknown) => void;
|
||||
getLinkedServer: () => Record<string, unknown> | null;
|
||||
isLinkedOrigin: (url: string) => boolean;
|
||||
applyLinkHeaders: (
|
||||
url: string,
|
||||
headers: Record<string, string>,
|
||||
) => Record<string, string>;
|
||||
linkedRequestHeaders: (
|
||||
extra?: Record<string, string>,
|
||||
) => Record<string, string>;
|
||||
answerLogin: (
|
||||
authInfo: { isProxy?: boolean } | null,
|
||||
url: string,
|
||||
) => { username: string; password: string } | null;
|
||||
};
|
||||
|
||||
const ORIGIN = "https://termix.example";
|
||||
|
||||
beforeEach(() => {
|
||||
linked.setLinkedServer(null);
|
||||
});
|
||||
|
||||
describe("linked server in Electron main", () => {
|
||||
it("adds proxy headers only for the linked origin", () => {
|
||||
linked.setLinkedServer({
|
||||
origin: ORIGIN,
|
||||
headers: [{ name: "CF-Access-Client-Id", value: "id" }],
|
||||
});
|
||||
expect(
|
||||
linked.applyLinkHeaders(`${ORIGIN}/sync/v2/info`, { Accept: "x" }),
|
||||
).toEqual({ Accept: "x", "CF-Access-Client-Id": "id" });
|
||||
expect(
|
||||
linked.applyLinkHeaders("https://elsewhere.example/", { Accept: "x" }),
|
||||
).toEqual({ Accept: "x" });
|
||||
});
|
||||
|
||||
it("moves a Termix bearer into the jwt cookie when basic auth is in front", () => {
|
||||
linked.setLinkedServer({
|
||||
origin: ORIGIN,
|
||||
basicAuth: { username: "gate", password: "pw" },
|
||||
});
|
||||
const headers = linked.applyLinkHeaders(`${ORIGIN}/host/status`, {
|
||||
authorization: "Bearer tok",
|
||||
Cookie: "a=b",
|
||||
});
|
||||
expect(headers.Authorization).toBe(
|
||||
`Basic ${Buffer.from("gate:pw").toString("base64")}`,
|
||||
);
|
||||
expect(headers.authorization).toBeUndefined();
|
||||
expect(headers.Cookie).toBe("a=b; jwt=tok");
|
||||
});
|
||||
|
||||
it("builds main's own request headers with the session", () => {
|
||||
linked.setLinkedServer({ origin: ORIGIN, token: "tok" });
|
||||
expect(linked.linkedRequestHeaders()).toEqual({
|
||||
"X-Electron-App": "true",
|
||||
Authorization: "Bearer tok",
|
||||
});
|
||||
});
|
||||
|
||||
it("answers basic auth challenges from the linked origin only", () => {
|
||||
linked.setLinkedServer({
|
||||
origin: ORIGIN,
|
||||
basicAuth: { username: "gate", password: "pw" },
|
||||
});
|
||||
expect(linked.answerLogin({}, `${ORIGIN}/`)).toEqual({
|
||||
username: "gate",
|
||||
password: "pw",
|
||||
});
|
||||
expect(linked.answerLogin({ isProxy: true }, `${ORIGIN}/`)).toBeNull();
|
||||
expect(linked.answerLogin({}, "https://other.example/")).toBeNull();
|
||||
});
|
||||
|
||||
it("forgets everything on unlink", () => {
|
||||
linked.setLinkedServer({ origin: ORIGIN, token: "tok" });
|
||||
linked.setLinkedServer(null);
|
||||
expect(linked.getLinkedServer()).toBeNull();
|
||||
expect(linked.isLinkedOrigin(`${ORIGIN}/`)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -44,8 +44,10 @@ const localFiles = require("../../../../electron/local-files.cjs") as {
|
||||
TRANSFER_ROUTES: Record<string, string>;
|
||||
createTargetResolver: (deps: {
|
||||
localBaseUrl?: string;
|
||||
getRemoteSyncConfig?: () => { serverUrl?: string } | null;
|
||||
getRemoteSyncJwt?: () => string | null;
|
||||
getLinkedServer?: () => {
|
||||
serverUrl?: string;
|
||||
token?: string | null;
|
||||
} | null;
|
||||
}) => (req: { origin?: unknown; route?: unknown; deviceId?: unknown }) => {
|
||||
url: string;
|
||||
headers: Record<string, string>;
|
||||
@@ -53,8 +55,10 @@ const localFiles = require("../../../../electron/local-files.cjs") as {
|
||||
createLocalFileHandlers: (deps: {
|
||||
net: unknown;
|
||||
shell: unknown;
|
||||
getRemoteSyncConfig?: () => { serverUrl?: string } | null;
|
||||
getRemoteSyncJwt?: () => string | null;
|
||||
getLinkedServer?: () => {
|
||||
serverUrl?: string;
|
||||
token?: string | null;
|
||||
} | null;
|
||||
localBaseUrl?: string;
|
||||
publishFs?: PublishFs;
|
||||
}) => Record<
|
||||
@@ -161,8 +165,10 @@ function startBackend(
|
||||
describe("local-files transfer target resolution", () => {
|
||||
const resolve = localFiles.createTargetResolver({
|
||||
localBaseUrl: "http://127.0.0.1:30001/plugin-api/file-manager",
|
||||
getRemoteSyncConfig: () => ({ serverUrl: "https://termix.example.com/" }),
|
||||
getRemoteSyncJwt: () => "remote-jwt",
|
||||
getLinkedServer: () => ({
|
||||
serverUrl: "https://termix.example.com/",
|
||||
token: "remote-jwt",
|
||||
}),
|
||||
});
|
||||
|
||||
it("only reaches the two file-manager streaming routes on the local backend", () => {
|
||||
@@ -175,7 +181,7 @@ describe("local-files transfer target resolution", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("derives the remote target from the configured sync server and attaches its JWT itself", () => {
|
||||
it("derives the remote target from the linked server and attaches its session itself", () => {
|
||||
const target = resolve({ origin: "remote", route: "downloadFileStream" });
|
||||
expect(target.url).toBe(
|
||||
"https://termix.example.com/plugin-api/file-manager/downloadFileStream",
|
||||
@@ -196,15 +202,15 @@ describe("local-files transfer target resolution", () => {
|
||||
expect(() => resolve({})).toThrow(/Unknown transfer route/);
|
||||
});
|
||||
|
||||
it("refuses a remote origin when no sync server is configured or it is not http(s)", () => {
|
||||
it("refuses a remote origin when the device is not linked or the server is not http(s)", () => {
|
||||
const unconfigured = localFiles.createTargetResolver({
|
||||
getRemoteSyncConfig: () => null,
|
||||
getLinkedServer: () => null,
|
||||
});
|
||||
expect(() =>
|
||||
unconfigured({ origin: "remote", route: "uploadFileStream" }),
|
||||
).toThrow(/not configured/);
|
||||
).toThrow(/not linked/);
|
||||
const bogus = localFiles.createTargetResolver({
|
||||
getRemoteSyncConfig: () => ({ serverUrl: "file:///etc/passwd" }),
|
||||
getLinkedServer: () => ({ serverUrl: "file:///etc/passwd" }),
|
||||
});
|
||||
expect(() =>
|
||||
bogus({ origin: "remote", route: "uploadFileStream" }),
|
||||
@@ -290,8 +296,7 @@ describe("local-files download boundary", () => {
|
||||
net: fakeNet,
|
||||
shell: {},
|
||||
localBaseUrl: backend.url,
|
||||
getRemoteSyncConfig: () => null,
|
||||
getRemoteSyncJwt: () => null,
|
||||
getLinkedServer: () => null,
|
||||
});
|
||||
});
|
||||
afterAll(() => backend.close());
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { createRequire } from "node:module";
|
||||
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import https from "node:https";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { afterAll, beforeAll, expect, it } from "vitest";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const {
|
||||
createRemoteSyncFetch,
|
||||
} = require("../../../../electron/remote-sync-fetch.cjs");
|
||||
let server: https.Server;
|
||||
let origin: string;
|
||||
let directory: string;
|
||||
let redirectedRequests = 0;
|
||||
const config = { serverUrl: "", allowInvalidCertificate: false };
|
||||
const request = createRemoteSyncFetch(() => config);
|
||||
|
||||
beforeAll(async () => {
|
||||
directory = mkdtempSync(join(tmpdir(), "termix-sync-tls-"));
|
||||
const key = join(directory, "key.pem");
|
||||
const cert = join(directory, "cert.pem");
|
||||
execFileSync(
|
||||
"openssl",
|
||||
[
|
||||
"req",
|
||||
"-x509",
|
||||
"-newkey",
|
||||
"rsa:2048",
|
||||
"-nodes",
|
||||
"-keyout",
|
||||
key,
|
||||
"-out",
|
||||
cert,
|
||||
"-days",
|
||||
"1",
|
||||
"-subj",
|
||||
"/CN=localhost",
|
||||
],
|
||||
{ stdio: "ignore" },
|
||||
);
|
||||
server = https.createServer(
|
||||
{ key: readFileSync(key), cert: readFileSync(cert) },
|
||||
(req, res) => {
|
||||
if (req.url === "/redirect") {
|
||||
res.writeHead(302, { Location: `${origin}/redirect-target` });
|
||||
} else {
|
||||
if (req.url === "/redirect-target") redirectedRequests++;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.write(
|
||||
JSON.stringify({
|
||||
authorization: req.headers.authorization,
|
||||
method: req.method,
|
||||
}),
|
||||
);
|
||||
}
|
||||
res.end();
|
||||
},
|
||||
);
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
origin = `https://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||
config.serverUrl = `${origin}/termix`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
server?.closeAllConnections();
|
||||
if (server)
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
if (directory) rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("rejects self-signed certificates until enabled, then preserves authenticated requests", async () => {
|
||||
await expect(request(`${origin}/users/me`)).rejects.toThrow();
|
||||
config.allowInvalidCertificate = true;
|
||||
const response = await request(`${origin}/users/me`, {
|
||||
headers: { Authorization: "Bearer dummy-token" },
|
||||
method: "POST",
|
||||
body: "{}",
|
||||
});
|
||||
expect(await response.json()).toEqual({
|
||||
authorization: "Bearer dummy-token",
|
||||
method: "POST",
|
||||
});
|
||||
config.allowInvalidCertificate = false;
|
||||
await expect(request(`${origin}/users/me`)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("does not apply an exception to another configured origin", async () => {
|
||||
config.allowInvalidCertificate = true;
|
||||
config.serverUrl = "https://another.example";
|
||||
await expect(request(`${origin}/sync/hosts`)).rejects.toThrow();
|
||||
config.serverUrl = origin;
|
||||
});
|
||||
|
||||
it("refuses redirects rather than carrying the TLS exception to another request", async () => {
|
||||
config.allowInvalidCertificate = true;
|
||||
await expect(request(`${origin}/redirect`)).rejects.toThrow();
|
||||
expect(redirectedRequests).toBe(0);
|
||||
});
|
||||
@@ -1,112 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { resolve } from "node:path";
|
||||
import { runInNewContext } from "node:vm";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { deserializeSyncReferences } from "../../database/routes/sync-references.js";
|
||||
|
||||
const filename = resolve("electron/remote-sync.cjs");
|
||||
const localRequire = createRequire(filename);
|
||||
const { orderSyncWrites } = localRequire("./sync-write-order.cjs");
|
||||
const moduleStub = { exports: {} };
|
||||
const Engine = runInNewContext(
|
||||
readFileSync(filename, "utf8") + "\nRemoteSyncEngine;",
|
||||
{
|
||||
module: moduleStub,
|
||||
require: (name: string) => (name === "electron" ? {} : localRequire(name)),
|
||||
},
|
||||
);
|
||||
|
||||
type Row = {
|
||||
syncId: string;
|
||||
updatedAt: string;
|
||||
parentHostSyncId: string | null;
|
||||
};
|
||||
const local = "http://127.0.0.1:30001";
|
||||
const remote = "https://remote.example";
|
||||
|
||||
describe("nested host sync writes", () => {
|
||||
it.each([local, remote])(
|
||||
"copies parents before reparented children into %s",
|
||||
async (destination) => {
|
||||
const oldChild: Row = {
|
||||
syncId: "child",
|
||||
updatedAt: "2026-01-01",
|
||||
parentHostSyncId: null,
|
||||
};
|
||||
const parent: Row = {
|
||||
syncId: "parent",
|
||||
updatedAt: "2026-01-02",
|
||||
parentHostSyncId: null,
|
||||
};
|
||||
const newChild: Row = {
|
||||
...oldChild,
|
||||
updatedAt: "2026-01-02",
|
||||
parentHostSyncId: "parent",
|
||||
};
|
||||
const source = destination === local ? remote : local;
|
||||
const stores = new Map<string, Map<string, Row>>([
|
||||
[destination, new Map([["child", oldChild]])],
|
||||
[
|
||||
source,
|
||||
new Map([
|
||||
["parent", parent],
|
||||
["child", newChild],
|
||||
]),
|
||||
],
|
||||
]);
|
||||
const engine = new Engine(() => null);
|
||||
engine.localJwt = "local-token";
|
||||
engine.pullSide = async (url: string) => [...stores.get(url)!.values()];
|
||||
engine.pullTombstones = async () => [];
|
||||
const written: string[] = [];
|
||||
engine.pushRow = async (
|
||||
url: string,
|
||||
_token: string,
|
||||
entity: "hosts",
|
||||
row: Row,
|
||||
) => {
|
||||
await deserializeSyncReferences(entity, row, async (_type, id) =>
|
||||
stores.get(url)!.has(id) ? 1 : null,
|
||||
);
|
||||
stores.get(url)!.set(row.syncId, row);
|
||||
written.push(row.syncId);
|
||||
};
|
||||
await engine.syncEntity({
|
||||
entityType: "hosts",
|
||||
remoteBaseUrl: remote,
|
||||
remoteJwt: "remote-token",
|
||||
});
|
||||
expect(written).toEqual(["parent", "child"]);
|
||||
expect(stores.get(destination)!.get("child")!.parentHostSyncId).toBe(
|
||||
"parent",
|
||||
);
|
||||
written.length = 0;
|
||||
await engine.syncEntity({
|
||||
entityType: "hosts",
|
||||
remoteBaseUrl: remote,
|
||||
remoteJwt: "remote-token",
|
||||
});
|
||||
expect(written).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects cyclic write dependencies before any writes are sent", () => {
|
||||
const writes = [
|
||||
{ baseUrl: local, row: { syncId: "a", parentHostSyncId: "b" } },
|
||||
{ baseUrl: local, row: { syncId: "b", parentHostSyncId: "a" } },
|
||||
];
|
||||
expect(() => orderSyncWrites("hosts", writes)).toThrow(
|
||||
"Cyclic parent host",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not confuse writes to different destinations", () => {
|
||||
const writes = [
|
||||
{ baseUrl: local, row: { syncId: "a", parentHostSyncId: "b" } },
|
||||
{ baseUrl: remote, row: { syncId: "b", parentHostSyncId: "a" } },
|
||||
];
|
||||
expect(orderSyncWrites("hosts", writes)).toEqual(writes);
|
||||
expect(orderSyncWrites("snippets", writes)).toBe(writes);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user