mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-31 07:35:24 +00:00
v26.10.5 (#1710)
* feat: add an auto or fixed columns picker to card grids * feat: add safe mode that only runs layout and theme plugins * docs: add simple layout to plugin table * fix: ask for local network access in the macOS app * feat: allow plugin registries on listed private hosts (#1705) * chore: add private registry hosts to the changelog * feat: use the proxy for plugin registry downloads * fix: allow changing connection origin on shared hosts * chore: update translations and plugin pins * chore: lint, format, and bump version to 26.10.5 --------- Co-authored-by: Lukas <80832688+0skater0@users.noreply.github.com>
This commit is contained in:
1 parent
7b7b17e31f
commit
96b8520fb4
96 files changed
+1885
-474
No files matched your search
@@ -102,6 +102,7 @@ beforeEach(async () => {
|
||||
state.defaultLookIds = [];
|
||||
state.installed = [];
|
||||
delete process.env.TERMIX_SAFE_LAYOUT;
|
||||
delete process.env.TERMIX_SAFE_MODE;
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
@@ -113,6 +114,7 @@ beforeEach(async () => {
|
||||
|
||||
afterEach(async () => {
|
||||
delete process.env.TERMIX_SAFE_LAYOUT;
|
||||
delete process.env.TERMIX_SAFE_MODE;
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
@@ -138,6 +140,7 @@ describe("GET /user-preferences/look", () => {
|
||||
defaults: { layout: null, theme: null },
|
||||
defaultLook: { layout: null, theme: null },
|
||||
safeLayout: false,
|
||||
safeMode: false,
|
||||
unavailable: [],
|
||||
});
|
||||
});
|
||||
@@ -159,6 +162,7 @@ describe("GET /user-preferences/look", () => {
|
||||
{ id: "other", version: null, layouts: [], themes: [], error: "y" },
|
||||
];
|
||||
process.env.TERMIX_SAFE_LAYOUT = "1";
|
||||
process.env.TERMIX_SAFE_MODE = "1";
|
||||
|
||||
const { body } = await getLook();
|
||||
expect(body).toMatchObject({
|
||||
@@ -167,6 +171,7 @@ describe("GET /user-preferences/look", () => {
|
||||
defaults: { layout: "admin-layout", theme: "gruvbox" },
|
||||
defaultLook: { layout: "shipped", theme: "ink" },
|
||||
safeLayout: true,
|
||||
safeMode: true,
|
||||
});
|
||||
expect(body.unavailable.map((plugin: { id: string }) => plugin.id)).toEqual(
|
||||
["broken"],
|
||||
|
||||
@@ -488,6 +488,26 @@ describe("plugins route", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("reports an enabled plugin that safe mode keeps off", async () => {
|
||||
state.plugins.set("sample-plugin", makePlugin({ state: "enabled" }));
|
||||
|
||||
const read = async () =>
|
||||
(
|
||||
(await (await fetch(`${baseUrl}/plugins`)).json()) as Array<{
|
||||
state: string;
|
||||
enabled: boolean;
|
||||
}>
|
||||
)[0];
|
||||
|
||||
expect((await read()).state).not.toBe("safe-mode");
|
||||
process.env.TERMIX_SAFE_MODE = "1";
|
||||
try {
|
||||
expect(await read()).toMatchObject({ state: "safe-mode", enabled: true });
|
||||
} finally {
|
||||
delete process.env.TERMIX_SAFE_MODE;
|
||||
}
|
||||
});
|
||||
|
||||
it("grants a declared capability", async () => {
|
||||
state.plugins.set("sample-plugin", makePlugin());
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { startablePluginIds } from "../../plugins/boot-state.js";
|
||||
import {
|
||||
missingShippedPluginIds,
|
||||
startablePluginIds,
|
||||
} from "../../plugins/boot-state.js";
|
||||
|
||||
describe("startablePluginIds", () => {
|
||||
it("starts enabled plugins and retries ones that failed or were blocked", () => {
|
||||
@@ -13,3 +16,25 @@ describe("startablePluginIds", () => {
|
||||
expect([...ids].sort()).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("missingShippedPluginIds", () => {
|
||||
const records = [
|
||||
{ id: "gone", tier: "bundled", state: "enabled" },
|
||||
{ id: "here", tier: "bundled", state: "enabled" },
|
||||
{ id: "off", tier: "bundled", state: "disabled" },
|
||||
{ id: "failed", tier: "bundled", state: "failed" },
|
||||
{ id: "community", tier: "community", state: "enabled" },
|
||||
{ id: "removed", tier: "bundled", state: "enabled" },
|
||||
];
|
||||
|
||||
it("lists shipped plugins that are on but not in the image", () => {
|
||||
expect(
|
||||
missingShippedPluginIds(records, new Set(["here"]), new Set(["removed"])),
|
||||
).toEqual(["failed", "gone"]);
|
||||
});
|
||||
|
||||
it("finds nothing when every plugin is present", () => {
|
||||
const present = new Set(records.map((record) => record.id));
|
||||
expect(missingShippedPluginIds(records, present, new Set())).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -621,6 +621,26 @@ describe("dependencies", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("safe mode", () => {
|
||||
afterEach(() => {
|
||||
delete process.env.TERMIX_SAFE_MODE;
|
||||
});
|
||||
|
||||
it("refuses to start a plugin that is not a layout or theme, but still stops one", async () => {
|
||||
createFixturePlugin({ id: "base", root: bundled });
|
||||
const loader = await boot();
|
||||
await manage.setPluginState("base", false);
|
||||
|
||||
process.env.TERMIX_SAFE_MODE = "1";
|
||||
await expect(manage.setPluginState("base", true)).rejects.toMatchObject({
|
||||
status: 409,
|
||||
code: "SAFE_MODE",
|
||||
});
|
||||
expect(loader.get("base")?.state).toBe("stopped");
|
||||
expect(db.rows.get("base")?.state).toBe("disabled");
|
||||
});
|
||||
});
|
||||
|
||||
describe("reloading a bundled plugin for the dev runner", () => {
|
||||
it("loads the rebuilt copy and restarts what depends on it", async () => {
|
||||
createFixturePlugin({ id: "base", root: bundled });
|
||||
|
||||
@@ -8,10 +8,23 @@ import * as tar from "tar";
|
||||
const fetchMock = vi.hoisted(() => vi.fn());
|
||||
const testKey = vi.hoisted(() => ({ value: null as null | object }));
|
||||
|
||||
vi.mock("../../utils/safe-outbound-fetch.js", () => ({
|
||||
vi.mock("../../utils/safe-outbound-fetch.js", async (importOriginal) => ({
|
||||
...(await importOriginal<
|
||||
typeof import("../../utils/safe-outbound-fetch.js")
|
||||
>()),
|
||||
safeOutboundFetch: fetchMock,
|
||||
}));
|
||||
|
||||
const proxyMock = vi.hoisted(() => ({
|
||||
on: false,
|
||||
fetch: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/proxy-agent.js", () => ({
|
||||
getProxyAgent: () => (proxyMock.on ? {} : undefined),
|
||||
fetchWithProxy: proxyMock.fetch,
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/app-version.js", () => ({
|
||||
getLocalVersion: () => "26.10.3",
|
||||
}));
|
||||
@@ -43,9 +56,11 @@ vi.mock("../../plugins/trust.js", async (importOriginal) => {
|
||||
import {
|
||||
communitySource,
|
||||
downloadRelease,
|
||||
fetchIndexAt,
|
||||
fetchRegistryIndex,
|
||||
fetchRegistryStats,
|
||||
findRelease,
|
||||
getRegistryPrivateHosts,
|
||||
latestOnChannel,
|
||||
getStatsUrl,
|
||||
canRunRelease,
|
||||
@@ -409,6 +424,91 @@ describe("fetchRegistryIndex", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("registry fetches with a proxy", () => {
|
||||
afterEach(() => {
|
||||
proxyMock.on = false;
|
||||
proxyMock.fetch.mockReset();
|
||||
});
|
||||
|
||||
it("goes through the proxy when one is configured", async () => {
|
||||
proxyMock.on = true;
|
||||
proxyMock.fetch.mockResolvedValueOnce(
|
||||
response(JSON.stringify({ plugins: [] })),
|
||||
);
|
||||
await fetchIndexAt("https://registry.example/index.json");
|
||||
expect(proxyMock.fetch).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still refuses a private address typed in as a literal", async () => {
|
||||
proxyMock.on = true;
|
||||
await expect(fetchIndexAt("https://127.0.0.1/index.json")).rejects.toThrow(
|
||||
"Private destinations are not allowed",
|
||||
);
|
||||
expect(proxyMock.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("private registry hosts", () => {
|
||||
afterEach(() => {
|
||||
delete process.env.TERMIX_PLUGIN_REGISTRY_PRIVATE_HOSTS;
|
||||
});
|
||||
|
||||
it("reads a comma separated list of host names", () => {
|
||||
process.env.TERMIX_PLUGIN_REGISTRY_PRIVATE_HOSTS =
|
||||
" GitLab.lan ,, 192.168.1.5 ";
|
||||
expect(getRegistryPrivateHosts()).toEqual(["gitlab.lan", "192.168.1.5"]);
|
||||
delete process.env.TERMIX_PLUGIN_REGISTRY_PRIVATE_HOSTS;
|
||||
expect(getRegistryPrivateHosts()).toEqual([]);
|
||||
});
|
||||
|
||||
it("brings each entry into the form the fetch compares against", () => {
|
||||
process.env.TERMIX_PLUGIN_REGISTRY_PRIVATE_HOSTS =
|
||||
"GitLab.LAN,[FD00::0001],fd00::2,bücher.lan";
|
||||
expect(getRegistryPrivateHosts()).toEqual([
|
||||
"gitlab.lan",
|
||||
"fd00::1",
|
||||
"fd00::2",
|
||||
"xn--bcher-kva.lan",
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops entries with more than a host", () => {
|
||||
process.env.TERMIX_PLUGIN_REGISTRY_PRIVATE_HOSTS =
|
||||
"gitlab.lan:8443,https://git.lan,[fd00::1]:80,nas.lan";
|
||||
expect(getRegistryPrivateHosts()).toEqual(["nas.lan"]);
|
||||
});
|
||||
|
||||
it("allows the listed hosts on every hop", async () => {
|
||||
process.env.TERMIX_PLUGIN_REGISTRY_PRIVATE_HOSTS = "gitlab.lan";
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(
|
||||
response("", 302, { location: "https://gitlab.lan/raw/index.json" }),
|
||||
)
|
||||
.mockResolvedValueOnce(response(JSON.stringify({ plugins: [] })));
|
||||
await fetchIndexAt("https://gitlab.lan/index.json");
|
||||
expect(fetchMock.mock.calls.map((call) => call[2])).toEqual([
|
||||
["gitlab.lan"],
|
||||
["gitlab.lan"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("still blocks a redirect to a private host that is not listed", async () => {
|
||||
process.env.TERMIX_PLUGIN_REGISTRY_PRIVATE_HOSTS = "gitlab.lan";
|
||||
const actual = await vi.importActual<
|
||||
typeof import("../../utils/safe-outbound-fetch.js")
|
||||
>("../../utils/safe-outbound-fetch.js");
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(
|
||||
response("", 302, { location: "https://127.0.0.1/index.json" }),
|
||||
)
|
||||
.mockImplementationOnce(actual.safeOutboundFetch);
|
||||
await expect(fetchIndexAt("https://gitlab.lan/index.json")).rejects.toThrow(
|
||||
"Private destinations are not allowed",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/** A real .tmxplug holding only a manifest, enough for the checks. */
|
||||
function pack(manifest: Record<string, unknown>): Buffer {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-pack-"));
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
mayRunInSafeMode,
|
||||
safeModeOn,
|
||||
safeModePluginIds,
|
||||
} from "../../plugins/safe-mode.js";
|
||||
import type { PluginManifest } from "../../plugins/manifest.js";
|
||||
|
||||
function plugin(id: string, manifest: Partial<PluginManifest> = {}) {
|
||||
return { id, manifest: { id, ...manifest } as PluginManifest };
|
||||
}
|
||||
|
||||
const plugins = [
|
||||
plugin("layout", {
|
||||
contributes: { layouts: [{ id: "classic" }] },
|
||||
dependencies: { helper: "^1.0.0" },
|
||||
} as Partial<PluginManifest>),
|
||||
plugin("themes", {
|
||||
contributes: { themes: [{ id: "dark" }] },
|
||||
} as Partial<PluginManifest>),
|
||||
plugin("helper"),
|
||||
plugin("docker"),
|
||||
];
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.TERMIX_SAFE_MODE;
|
||||
});
|
||||
|
||||
describe("safeModeOn", () => {
|
||||
it("reads 1 and true, anything else is off", () => {
|
||||
expect(safeModeOn()).toBe(false);
|
||||
process.env.TERMIX_SAFE_MODE = "1";
|
||||
expect(safeModeOn()).toBe(true);
|
||||
process.env.TERMIX_SAFE_MODE = " TRUE ";
|
||||
expect(safeModeOn()).toBe(true);
|
||||
process.env.TERMIX_SAFE_MODE = "0";
|
||||
expect(safeModeOn()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("safeModePluginIds", () => {
|
||||
it("keeps layout and theme plugins and what they depend on", () => {
|
||||
expect([...safeModePluginIds(plugins)].sort()).toEqual([
|
||||
"helper",
|
||||
"layout",
|
||||
"themes",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mayRunInSafeMode", () => {
|
||||
it("lets every plugin run when safe mode is off", () => {
|
||||
expect(mayRunInSafeMode("docker", plugins)).toBe(true);
|
||||
});
|
||||
|
||||
it("only lets look plugins run when safe mode is on", () => {
|
||||
process.env.TERMIX_SAFE_MODE = "1";
|
||||
expect(mayRunInSafeMode("docker", plugins)).toBe(false);
|
||||
expect(mayRunInSafeMode("layout", plugins)).toBe(true);
|
||||
expect(mayRunInSafeMode("helper", plugins)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const h = vi.hoisted(() => ({
|
||||
row: { sharedSource: '{"owner":"x"}' } as { sharedSource: string | null },
|
||||
updates: [] as unknown[],
|
||||
}));
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentRepositoryContext: () => ({
|
||||
drizzle: {
|
||||
select: () => ({
|
||||
from: () => ({ where: () => ({ limit: async () => [h.row] }) }),
|
||||
}),
|
||||
update: () => ({
|
||||
set: (values: unknown) => ({
|
||||
where: async () => {
|
||||
h.updates.push(values);
|
||||
},
|
||||
}),
|
||||
}),
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const { rejectSharedCopyWrites } =
|
||||
await import("../../sync/shared-copy-guard.js");
|
||||
|
||||
function run(body: Record<string, unknown>) {
|
||||
const guard = rejectSharedCopyWrites("host", /^\/db\/host\/(\d+)$/);
|
||||
const res = {
|
||||
code: 0,
|
||||
payload: undefined as unknown,
|
||||
status(code: number) {
|
||||
this.code = code;
|
||||
return this;
|
||||
},
|
||||
json(payload: unknown) {
|
||||
this.payload = payload;
|
||||
return this;
|
||||
},
|
||||
};
|
||||
const next = vi.fn();
|
||||
return guard(
|
||||
{ method: "PUT", path: "/db/host/5", body } as never,
|
||||
res as never,
|
||||
next,
|
||||
).then(() => ({ res, next }));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.ELECTRON_EMBEDDED = "true";
|
||||
h.row = { sharedSource: '{"owner":"x"}' };
|
||||
h.updates = [];
|
||||
});
|
||||
|
||||
describe("shared copy guard", () => {
|
||||
it("refuses edits to a shared copy", async () => {
|
||||
const { res, next } = await run({ name: "new" });
|
||||
expect(res.code).toBe(403);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(h.updates).toEqual([]);
|
||||
});
|
||||
|
||||
it("still lets the connection origin change on this device", async () => {
|
||||
const { res, next } = await run({ name: "new", connectionOrigin: "local" });
|
||||
expect(res.code).toBe(0);
|
||||
expect(res.payload).toMatchObject({ connectionOrigin: "local" });
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(h.updates).toEqual([{ connectionOrigin: "local" }]);
|
||||
});
|
||||
|
||||
it("passes hosts that are not shared copies", async () => {
|
||||
h.row = { sharedSource: null };
|
||||
const { next } = await run({ name: "new" });
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user