Skip to content

Commit 33a91fd

Browse files
committed
feat(client): enforce bidirectional Core compatibility
Check the selected site in the Electron main process before login, account restoration or switching. Require both version minimums, preserve the active session on failed checks and ignore stale attempts. Offer retry or the private site's download page according to the failure. Default Luna and its minimum Core requirement to 5.1.0. Preserve web login and release version injection, and leave WebLite unchanged. Reject failed login profiles before the renderer can close the previous site's workspace. Validate SemVer boundaries, unknown responses, session isolation and races, legacy protocol behavior, download URLs, and web session behavior.
1 parent 6d0c862 commit 33a91fd

32 files changed

Lines changed: 1628 additions & 278 deletions

‎electron/README.md‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,38 @@
22

33
This directory contains the JumpServer desktop runtime.
44

5+
Desktop connections first check anonymous
6+
`GET /api/v1/settings/client/compatibility/` in the main process. Both
7+
`app.getVersion() >= Core.MIN_CLIENT_VERSION` and
8+
`Core.VERSION >= MIN_CORE_VERSION` must hold. The client's minimum lives in
9+
`src/auth/compatibility.ts` and starts at `5.1.0`. Core represents the whole
10+
server release, including Koko, Chen and Kael. Developers decide whether a
11+
contract change breaks old clients; reviewers check that decision. Compatible
12+
releases leave the minimums unchanged. There is no component version matrix,
13+
automatic updater or automated compatibility gate.
14+
15+
Release builds already run `scripts/set-version.js` to inject the complete
16+
version into the Electron package. Core and Luna development versions default to
17+
`5.1.0`; release builds still inject their actual versions. Do not reuse an
18+
existing release number for new capabilities.
19+
Comparisons follow SemVer, including patch, prerelease and metadata. A lowercase
20+
`X.Y.Z-lts` stable channel label (also before `+metadata`) equals `X.Y.Z`;
21+
other suffixes remain prereleases. A leading `v` is accepted.
22+
23+
Login, saved-account restoration and site switching share the same preflight.
24+
Failed or obsolete checks cannot activate a session. Missing endpoints, invalid
25+
responses, timeouts and TLS failures pause the new connection and offer retry.
26+
They never fall back to the legacy version array. Old clients get a button that
27+
opens the selected site's `/core/download/` in the system browser, retaining the
28+
deployment prefix. Old Core versions require an administrator upgrade.
29+
30+
Run `pnpm test:electron`, `pnpm test:web`, `pnpm lint:check` and `pnpm typecheck`.
31+
For desktop review, use local test servers and release-versioned test builds:
32+
check both minimum boundaries, each single failure and both failures, an old Core
33+
returning 404, retry after a network failure, and a prefixed download URL. Keep A
34+
connected while B fails a check, then rapidly switch B/C and verify only the
35+
latest result can activate. These checks must not access production accounts.
36+
537
Run the current development shell with:
638

739
```sh

‎electron/package.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "jumpserver-client-electron",
33
"productName": "JumpServer",
4-
"version": "5.0.0",
4+
"version": "5.1.0",
55
"private": true,
66
"description": "Electron runtime for JumpServer",
77
"author": "JumpServer",
@@ -11,7 +11,7 @@
1111
"start": "electron-forge start",
1212
"package": "electron-forge package",
1313
"make": "electron-forge make",
14-
"test": "node --import tsx --test tests/auth-language.test.ts tests/auth-request-site.test.ts tests/application-config.test.ts tests/client-protocol.test.ts tests/debug-log.test.ts tests/executable-path.test.ts tests/face-manager.test.ts tests/face-socket-policy.test.ts tests/ffmpeg-plugin.test.ts tests/forge-artifacts.test.ts tests/menu-command.test.ts tests/oauth-callback.test.ts tests/product-name.test.ts tests/replay-codec.test.ts tests/replay-transcoder-archive.test.ts tests/ssh-helper.test.ts tests/url.test.ts tests/web-proxy.test.ts && go -C ssh-helper test ./...",
14+
"test": "node --import tsx --test tests/auth-language.test.ts tests/auth-request-site.test.ts tests/auth-compatibility.test.ts tests/client-compatibility.test.ts tests/application-config.test.ts tests/client-protocol.test.ts tests/debug-log.test.ts tests/executable-path.test.ts tests/face-manager.test.ts tests/face-socket-policy.test.ts tests/ffmpeg-plugin.test.ts tests/forge-artifacts.test.ts tests/menu-command.test.ts tests/oauth-callback.test.ts tests/product-name.test.ts tests/replay-codec.test.ts tests/replay-transcoder-archive.test.ts tests/ssh-helper.test.ts tests/url.test.ts tests/web-proxy.test.ts && go -C ssh-helper test ./...",
1515
"test:web-proxy:interaction": "electron tests/fixtures/web-proxy-interaction-app",
1616
"typecheck": "tsc -p tsconfig.json --pretty false",
1717
"postinstall": "node ../scripts/fix-electron-permissions.mjs"

‎electron/src/auth/compatibility.ts‎

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import type { SiteCompatibility } from "../../../ui/types/clientCompatibility";
2+
import { parseUrl } from "../shared/url";
3+
4+
// Raise only when the client starts depending on a breaking server change.
5+
export const MIN_CORE_VERSION = "5.1.0";
6+
const COMPATIBILITY_PATH = "/api/v1/settings/client/compatibility/";
7+
8+
function parseVersion(value: unknown) {
9+
if (typeof value !== "string" || value.length > 256) return null;
10+
// JumpServer's terminal -lts suffix labels the stable release channel.
11+
// Other suffixes retain their SemVer prerelease meaning (including rc1/beta8).
12+
const normalized = value
13+
.trim()
14+
.replace(/^v/, "")
15+
.replace(/^(\d+\.\d+\.\d+)-lts(?=\+|$)/, "$1");
16+
const match = normalized.match(
17+
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/
18+
);
19+
if (!match) return null;
20+
const prerelease = match[4]?.split(".") || [];
21+
if (prerelease.some((part) => /^\d+$/.test(part) && part.length > 1 && part.startsWith("0"))) return null;
22+
return { release: match.slice(1, 4).map(BigInt), prerelease };
23+
}
24+
25+
export function compareVersions(left: unknown, right: unknown): number | null {
26+
const a = parseVersion(left);
27+
const b = parseVersion(right);
28+
if (!a || !b) return null;
29+
for (let i = 0; i < 3; i += 1) {
30+
if (a.release[i] !== b.release[i]) return a.release[i] > b.release[i] ? 1 : -1;
31+
}
32+
if (!a.prerelease.length || !b.prerelease.length) {
33+
return Number(!a.prerelease.length) - Number(!b.prerelease.length);
34+
}
35+
for (let i = 0; i < Math.max(a.prerelease.length, b.prerelease.length); i += 1) {
36+
const x = a.prerelease[i];
37+
const y = b.prerelease[i];
38+
if (x === y) continue;
39+
if (x === undefined || y === undefined) return x === undefined ? -1 : 1;
40+
const xNumeric = /^\d+$/.test(x);
41+
const yNumeric = /^\d+$/.test(y);
42+
if (xNumeric && yNumeric) return BigInt(x) > BigInt(y) ? 1 : -1;
43+
if (xNumeric !== yNumeric) return xNumeric ? -1 : 1;
44+
return x > y ? 1 : -1;
45+
}
46+
return 0;
47+
}
48+
49+
export function normalizeCompatibilitySite(value: unknown): string {
50+
if (
51+
typeof value !== "string" ||
52+
!/^https?:\/\//i.test(value) ||
53+
Array.from(value).some((character) => character.charCodeAt(0) <= 32 || character === "\\")
54+
) {
55+
throw new Error("Invalid site URL");
56+
}
57+
const url = parseUrl(value);
58+
if (!url.hostname || url.username || url.password || /[?#]/.test(url.href)) throw new Error("Invalid site URL");
59+
return url.toString().replace(/\/+$/, "");
60+
}
61+
62+
export function clientDownloadUrl(site: unknown): string {
63+
return `${normalizeCompatibilitySite(site)}/core/download/`;
64+
}
65+
66+
export async function checkSiteCompatibility(
67+
site: unknown,
68+
clientVersion: string,
69+
fetchResponse: typeof fetch = globalThis.fetch,
70+
timeout = 10_000
71+
): Promise<SiteCompatibility> {
72+
const result: SiteCompatibility = {
73+
status: "unknown",
74+
site: typeof site === "string" ? site : "",
75+
clientVersion,
76+
minCoreVersion: MIN_CORE_VERSION,
77+
failures: []
78+
};
79+
try {
80+
result.site = normalizeCompatibilitySite(site);
81+
} catch {
82+
return { ...result, reason: "invalid-site" };
83+
}
84+
if (!parseVersion(clientVersion)) return { ...result, reason: "invalid-client-version" };
85+
86+
let response: Response;
87+
let body: string;
88+
try {
89+
// Node's fetch has no Electron cookies or certificate trust overrides.
90+
// Never use authService.fetchSite here: it permanently trusts entered hosts.
91+
response = await fetchResponse(`${result.site}${COMPATIBILITY_PATH}`, {
92+
headers: { Accept: "application/json", "Cache-Control": "no-cache" },
93+
credentials: "omit",
94+
redirect: "error",
95+
cache: "no-store",
96+
signal: AbortSignal.timeout(timeout)
97+
});
98+
if (response.status === 404) return { ...result, reason: "endpoint-missing" };
99+
if (!response.ok) return { ...result, reason: "http" };
100+
body = "";
101+
const reader = response.body?.getReader();
102+
if (reader) {
103+
const decoder = new TextDecoder();
104+
let bytes = 0;
105+
try {
106+
while (true) {
107+
const chunk = await reader.read();
108+
if (chunk.done) break;
109+
bytes += chunk.value.byteLength;
110+
if (bytes > 64 * 1024) {
111+
await reader.cancel();
112+
return { ...result, reason: "invalid-response" };
113+
}
114+
body += decoder.decode(chunk.value, { stream: true });
115+
}
116+
body += decoder.decode();
117+
} finally {
118+
reader.releaseLock();
119+
}
120+
}
121+
} catch (error) {
122+
const failure = error as { name?: string; cause?: { code?: string } };
123+
const reason = ["TimeoutError", "AbortError"].includes(failure?.name || "")
124+
? "timeout"
125+
: /CERT|TLS|SELF_SIGNED/.test(failure?.cause?.code || "")
126+
? "tls"
127+
: "network";
128+
return { ...result, reason };
129+
}
130+
131+
try {
132+
const data = JSON.parse(body);
133+
if (
134+
!data ||
135+
data.schema_version !== 1 ||
136+
!parseVersion(data.core_version) ||
137+
!parseVersion(data.min_client_version)
138+
) {
139+
throw new Error("Invalid compatibility response");
140+
}
141+
result.coreVersion = data.core_version;
142+
result.minClientVersion = data.min_client_version;
143+
if (compareVersions(clientVersion, data.min_client_version)! < 0) result.failures.push("client-too-old");
144+
if (compareVersions(data.core_version, MIN_CORE_VERSION)! < 0) result.failures.push("core-too-old");
145+
return { ...result, status: result.failures.length ? "incompatible" : "compatible" };
146+
} catch {
147+
return { ...result, reason: "invalid-response" };
148+
}
149+
}

0 commit comments

Comments
 (0)