roxxie-proxy-u-prod 0.2.3 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -6
- package/dist/assets/{index-BQkXFDet.js → index-MRhaNu_Q.js} +195 -135
- package/embed.js +1 -1
- package/examples/single-page.html +2 -2
- package/package.json +1 -1
- package/runtime/index.html +3 -3
- package/scripts/verify.mjs +2 -2
- package/source/packages/chrome/src/Tab/History.test.ts +137 -0
- package/source/packages/chrome/src/Tab/History.ts +8 -3
- package/source/packages/chrome/src/Tab/Tab.tsx +4 -1
- package/source/packages/chrome/src/components/Omnibar/Omnibox.tsx +28 -38
- package/source/packages/chrome/src/components/Omnibar/autocomplete.test.ts +51 -0
- package/source/packages/chrome/src/components/Omnibar/autocomplete.ts +30 -0
- package/source/packages/chrome/src/pages/AdvancedSettings.tsx +225 -0
- package/source/packages/chrome/src/pages/SettingsPage.tsx +11 -0
- package/source/packages/chrome/src/pages/nodes.test.ts +94 -0
- package/source/packages/chrome/src/pages/nodes.ts +108 -0
- package/source/packages/chrome/src/proxy/user-agent.test.ts +78 -0
- package/source/packages/chrome/src/services/SettingsService.ts +8 -1
- package/source/packages/roxxie-transport/package.json +1 -1
- package/source/packages/roxxie-transport/src/transport.ts +51 -7
- package/source/packages/scramjet/packages/core/src/fetch/headers.ts +8 -0
- package/source/packages/scramjet/packages/core/src/fetch/user-agent.ts +51 -0
- package/source/packages/tracker-protocol/package.json +1 -1
- package/source/packages/tracker-protocol/src/index.ts +22 -0
- package/source/pnpm-lock.yaml +1 -1
- package/standalone.html +2 -2
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
AUTOMATIC_NODE,
|
|
5
|
+
type CandidateNode,
|
|
6
|
+
buildNodeRows,
|
|
7
|
+
nodeSummary,
|
|
8
|
+
resolvePreferredNode,
|
|
9
|
+
toNodeRow,
|
|
10
|
+
versionLabel,
|
|
11
|
+
} from "./nodes";
|
|
12
|
+
|
|
13
|
+
function candidate(overrides: Partial<CandidateNode> = {}): CandidateNode {
|
|
14
|
+
return {
|
|
15
|
+
nodeId: "018f1f5e-7b2a-7c35-8b1d-2a4d9f8e6c10",
|
|
16
|
+
name: "Roxxie community node",
|
|
17
|
+
activeSessions: 1,
|
|
18
|
+
maxSessions: 8,
|
|
19
|
+
utilization: 0.125,
|
|
20
|
+
protocolVersion: "6.0",
|
|
21
|
+
packageVersion: "0.5.1",
|
|
22
|
+
platform: "linux",
|
|
23
|
+
...overrides,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe("advanced settings node list", () => {
|
|
28
|
+
it("surfaces the node software version", () => {
|
|
29
|
+
const row = toNodeRow(candidate());
|
|
30
|
+
expect(row.version).toBe("0.5.1");
|
|
31
|
+
expect(versionLabel(row)).toBe("v0.5.1");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("says so plainly when a tracker does not report a version", () => {
|
|
35
|
+
// Trackers older than the version field omit it; showing "v undefined"
|
|
36
|
+
// would be worse than admitting we do not know.
|
|
37
|
+
const row = toNodeRow(candidate({ packageVersion: undefined }));
|
|
38
|
+
expect(row.version).toBeNull();
|
|
39
|
+
expect(versionLabel(row)).toBe("version unknown");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("marks which node is currently connected", () => {
|
|
43
|
+
const row = toNodeRow(candidate(), candidate().nodeId);
|
|
44
|
+
expect(row.connected).toBe(true);
|
|
45
|
+
expect(toNodeRow(candidate(), "someone-else").connected).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("renders load as a clamped percentage", () => {
|
|
49
|
+
expect(toNodeRow(candidate({ utilization: 0 })).loadPercent).toBe(0);
|
|
50
|
+
expect(toNodeRow(candidate({ utilization: 0.5 })).loadPercent).toBe(50);
|
|
51
|
+
expect(toNodeRow(candidate({ utilization: 1 })).loadPercent).toBe(100);
|
|
52
|
+
expect(toNodeRow(candidate({ utilization: 2 })).loadPercent).toBe(100);
|
|
53
|
+
expect(toNodeRow(candidate({ utilization: -1 })).loadPercent).toBe(0);
|
|
54
|
+
expect(toNodeRow(candidate({ utilization: Number.NaN })).loadPercent).toBe(
|
|
55
|
+
0
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("lists the least loaded node first", () => {
|
|
60
|
+
const rows = buildNodeRows([
|
|
61
|
+
candidate({ nodeId: "c", name: "C", utilization: 0.9 }),
|
|
62
|
+
candidate({ nodeId: "a", name: "A", utilization: 0.1 }),
|
|
63
|
+
candidate({ nodeId: "b", name: "B", utilization: 0.5 }),
|
|
64
|
+
]);
|
|
65
|
+
expect(rows.map((row) => row.nodeId)).toEqual(["a", "b", "c"]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("orders ties stably so the list does not reshuffle", () => {
|
|
69
|
+
const rows = buildNodeRows([
|
|
70
|
+
candidate({ nodeId: "z", name: "Same", utilization: 0.2 }),
|
|
71
|
+
candidate({ nodeId: "a", name: "Same", utilization: 0.2 }),
|
|
72
|
+
]);
|
|
73
|
+
expect(rows.map((row) => row.nodeId)).toEqual(["a", "z"]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("summarises a node in one line", () => {
|
|
77
|
+
expect(nodeSummary(toNodeRow(candidate()))).toBe(
|
|
78
|
+
"v0.5.1 · linux · 1/8 sessions · 13% load"
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("keeps a pin that still matches an offered node", () => {
|
|
83
|
+
const rows = buildNodeRows([candidate({ nodeId: "keep" })]);
|
|
84
|
+
expect(resolvePreferredNode("keep", rows)).toBe("keep");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("falls back to automatic when the pinned node is gone", () => {
|
|
88
|
+
// Otherwise the user would be stuck pointing at a node that no longer
|
|
89
|
+
// exists with no obvious way back.
|
|
90
|
+
const rows = buildNodeRows([candidate({ nodeId: "present" })]);
|
|
91
|
+
expect(resolvePreferredNode("vanished", rows)).toBe(AUTOMATIC_NODE);
|
|
92
|
+
expect(resolvePreferredNode("", rows)).toBe(AUTOMATIC_NODE);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The parts of a tracker candidate this page renders. Declared structurally so
|
|
3
|
+
* the settings page does not need a direct dependency on the tracker protocol
|
|
4
|
+
* package.
|
|
5
|
+
*/
|
|
6
|
+
export type CandidateNode = {
|
|
7
|
+
nodeId: string;
|
|
8
|
+
name: string;
|
|
9
|
+
activeSessions: number;
|
|
10
|
+
maxSessions: number;
|
|
11
|
+
utilization: number;
|
|
12
|
+
protocolVersion: string;
|
|
13
|
+
packageVersion?: string | undefined;
|
|
14
|
+
platform?: string | undefined;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** A node as presented in Advanced settings. */
|
|
18
|
+
export type NodeRow = {
|
|
19
|
+
nodeId: string;
|
|
20
|
+
name: string;
|
|
21
|
+
/** Node agent release, or null when the tracker does not report one. */
|
|
22
|
+
version: string | null;
|
|
23
|
+
platform: string | null;
|
|
24
|
+
activeSessions: number;
|
|
25
|
+
maxSessions: number;
|
|
26
|
+
/** 0-100, rounded, clamped. */
|
|
27
|
+
loadPercent: number;
|
|
28
|
+
protocolVersion: string;
|
|
29
|
+
connected: boolean;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** Value used in the picker to mean "let the browser choose". */
|
|
33
|
+
export const AUTOMATIC_NODE = "";
|
|
34
|
+
|
|
35
|
+
function clampPercent(value: number): number {
|
|
36
|
+
if (!Number.isFinite(value)) return 0;
|
|
37
|
+
return Math.min(100, Math.max(0, Math.round(value * 100)));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function toNodeRow(
|
|
41
|
+
candidate: CandidateNode,
|
|
42
|
+
connectedNodeId?: string
|
|
43
|
+
): NodeRow {
|
|
44
|
+
return {
|
|
45
|
+
nodeId: candidate.nodeId,
|
|
46
|
+
name: candidate.name,
|
|
47
|
+
// Trackers predating version reporting simply omit it.
|
|
48
|
+
version: candidate.packageVersion ?? null,
|
|
49
|
+
platform: candidate.platform ?? null,
|
|
50
|
+
activeSessions: candidate.activeSessions,
|
|
51
|
+
maxSessions: candidate.maxSessions,
|
|
52
|
+
loadPercent: clampPercent(candidate.utilization),
|
|
53
|
+
protocolVersion: candidate.protocolVersion,
|
|
54
|
+
connected: candidate.nodeId === connectedNodeId,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Least loaded first, so the ordering matches how a node would be picked
|
|
60
|
+
* automatically. Ties fall back to name then id to keep the list from
|
|
61
|
+
* reshuffling between refreshes.
|
|
62
|
+
*/
|
|
63
|
+
export function sortNodeRows(rows: readonly NodeRow[]): NodeRow[] {
|
|
64
|
+
return [...rows].sort(
|
|
65
|
+
(left, right) =>
|
|
66
|
+
left.loadPercent - right.loadPercent ||
|
|
67
|
+
left.activeSessions - right.activeSessions ||
|
|
68
|
+
left.name.localeCompare(right.name) ||
|
|
69
|
+
left.nodeId.localeCompare(right.nodeId)
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function buildNodeRows(
|
|
74
|
+
candidates: readonly CandidateNode[],
|
|
75
|
+
connectedNodeId?: string
|
|
76
|
+
): NodeRow[] {
|
|
77
|
+
return sortNodeRows(
|
|
78
|
+
candidates.map((candidate) => toNodeRow(candidate, connectedNodeId))
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Version text for display, distinguishing "unknown" from a real version. */
|
|
83
|
+
export function versionLabel(row: NodeRow): string {
|
|
84
|
+
return row.version === null ? "version unknown" : `v${row.version}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Short summary line shown under each node's name. */
|
|
88
|
+
export function nodeSummary(row: NodeRow): string {
|
|
89
|
+
const parts = [versionLabel(row)];
|
|
90
|
+
if (row.platform) parts.push(row.platform);
|
|
91
|
+
parts.push(`${row.activeSessions}/${row.maxSessions} sessions`);
|
|
92
|
+
parts.push(`${row.loadPercent}% load`);
|
|
93
|
+
return parts.join(" · ");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Whether a saved preference still refers to a node the tracker is offering.
|
|
98
|
+
* A stale pin is treated as automatic rather than leaving the user stuck.
|
|
99
|
+
*/
|
|
100
|
+
export function resolvePreferredNode(
|
|
101
|
+
preferredNodeId: string,
|
|
102
|
+
rows: readonly NodeRow[]
|
|
103
|
+
): string {
|
|
104
|
+
if (!preferredNodeId) return AUTOMATIC_NODE;
|
|
105
|
+
return rows.some((row) => row.nodeId === preferredNodeId)
|
|
106
|
+
? preferredNodeId
|
|
107
|
+
: AUTOMATIC_NODE;
|
|
108
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
applyUserAgentHeader,
|
|
5
|
+
browserUserAgent,
|
|
6
|
+
resetUserAgentCache,
|
|
7
|
+
} from "../../../scramjet/packages/core/src/fetch/user-agent";
|
|
8
|
+
|
|
9
|
+
/** Minimal stand-in for ScramjetHeaders' case-insensitive storage. */
|
|
10
|
+
function headers(initial: Record<string, string> = {}) {
|
|
11
|
+
const store = new Map<string, string>();
|
|
12
|
+
for (const [name, value] of Object.entries(initial)) {
|
|
13
|
+
store.set(name.toLowerCase(), value);
|
|
14
|
+
}
|
|
15
|
+
return {
|
|
16
|
+
get: (key: string) => store.get(key.toLowerCase()) ?? null,
|
|
17
|
+
set: (key: string, value: string) => {
|
|
18
|
+
store.set(key.toLowerCase(), value);
|
|
19
|
+
},
|
|
20
|
+
raw: store,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const FIREFOX =
|
|
25
|
+
"Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0";
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
resetUserAgentCache();
|
|
29
|
+
vi.unstubAllGlobals();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("User-Agent forwarding", () => {
|
|
33
|
+
it("sends the browser's own User-Agent when the request carries none", () => {
|
|
34
|
+
// Without this an origin that content-negotiates on User-Agent serves its
|
|
35
|
+
// non-browser representation: ip.me returns a text/plain IP, not its page.
|
|
36
|
+
vi.stubGlobal("navigator", { userAgent: FIREFOX });
|
|
37
|
+
const h = headers();
|
|
38
|
+
|
|
39
|
+
applyUserAgentHeader(h);
|
|
40
|
+
|
|
41
|
+
expect(h.get("user-agent")).toBe(FIREFOX);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("matches the header case-insensitively", () => {
|
|
45
|
+
vi.stubGlobal("navigator", { userAgent: FIREFOX });
|
|
46
|
+
const h = headers({ "User-Agent": "PageChosen/1.0" });
|
|
47
|
+
|
|
48
|
+
applyUserAgentHeader(h);
|
|
49
|
+
|
|
50
|
+
expect(h.get("user-agent")).toBe("PageChosen/1.0");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("never overwrites a User-Agent the page set deliberately", () => {
|
|
54
|
+
vi.stubGlobal("navigator", { userAgent: FIREFOX });
|
|
55
|
+
const h = headers({ "user-agent": "CustomBot/2.0" });
|
|
56
|
+
|
|
57
|
+
applyUserAgentHeader(h);
|
|
58
|
+
|
|
59
|
+
expect(h.get("user-agent")).toBe("CustomBot/2.0");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("leaves the header absent rather than inventing one", () => {
|
|
63
|
+
vi.stubGlobal("navigator", { userAgent: "" });
|
|
64
|
+
const h = headers();
|
|
65
|
+
|
|
66
|
+
applyUserAgentHeader(h);
|
|
67
|
+
|
|
68
|
+
expect(h.get("user-agent")).toBeNull();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("tolerates environments with no navigator at all", () => {
|
|
72
|
+
vi.stubGlobal("navigator", undefined);
|
|
73
|
+
const h = headers();
|
|
74
|
+
|
|
75
|
+
expect(() => applyUserAgentHeader(h)).not.toThrow();
|
|
76
|
+
expect(browserUserAgent()).toBeNull();
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -24,6 +24,8 @@ export type Settings = {
|
|
|
24
24
|
clearHistoryOnExit: boolean;
|
|
25
25
|
doNotTrack: boolean;
|
|
26
26
|
extensionsDevMode: boolean;
|
|
27
|
+
/** Node id the user pinned in Advanced settings; empty means automatic. */
|
|
28
|
+
preferredNodeId: string;
|
|
27
29
|
};
|
|
28
30
|
|
|
29
31
|
export type TabLayoutMode = Settings["tabLayout"];
|
|
@@ -44,6 +46,7 @@ const DEFAULT_SETTINGS: Settings = {
|
|
|
44
46
|
clearHistoryOnExit: false,
|
|
45
47
|
doNotTrack: true,
|
|
46
48
|
extensionsDevMode: false,
|
|
49
|
+
preferredNodeId: "",
|
|
47
50
|
};
|
|
48
51
|
|
|
49
52
|
export type SettingsServiceState = {
|
|
@@ -63,6 +66,7 @@ export type SettingsServiceState = {
|
|
|
63
66
|
clearHistoryOnExit: boolean;
|
|
64
67
|
doNotTrack: boolean;
|
|
65
68
|
extensionsDevMode: boolean;
|
|
69
|
+
preferredNodeId: string;
|
|
66
70
|
};
|
|
67
71
|
};
|
|
68
72
|
|
|
@@ -72,7 +76,9 @@ export class SettingsService extends Service {
|
|
|
72
76
|
constructor(data: SettingsServiceState | null) {
|
|
73
77
|
super();
|
|
74
78
|
if (data) {
|
|
75
|
-
|
|
79
|
+
// Profiles saved before a setting existed omit it entirely, so fill in
|
|
80
|
+
// defaults rather than leaving the state with undefined values.
|
|
81
|
+
this.settings = createState({ ...DEFAULT_SETTINGS, ...data.settings });
|
|
76
82
|
} else {
|
|
77
83
|
this.settings = createState(DEFAULT_SETTINGS);
|
|
78
84
|
}
|
|
@@ -102,6 +108,7 @@ export class SettingsService extends Service {
|
|
|
102
108
|
clearHistoryOnExit: this.settings.clearHistoryOnExit,
|
|
103
109
|
doNotTrack: this.settings.doNotTrack,
|
|
104
110
|
extensionsDevMode: this.settings.extensionsDevMode,
|
|
111
|
+
preferredNodeId: this.settings.preferredNodeId ?? "",
|
|
105
112
|
},
|
|
106
113
|
};
|
|
107
114
|
}
|
|
@@ -78,6 +78,9 @@ export class RoxxieProxyTransport implements ProxyTransport {
|
|
|
78
78
|
private readonly nodeCooldowns = new Map<string, number>();
|
|
79
79
|
private readonly dependencies: RoxxieTransportDependencies;
|
|
80
80
|
private closed = false;
|
|
81
|
+
/** Node the user pinned in advanced settings, if any. */
|
|
82
|
+
private preferredNodeId: string | undefined;
|
|
83
|
+
private lastCandidates: readonly CandidateNode[] = [];
|
|
81
84
|
|
|
82
85
|
constructor(
|
|
83
86
|
private readonly source: TrackerNetworkSource,
|
|
@@ -243,16 +246,57 @@ export class RoxxieProxyTransport implements ProxyTransport {
|
|
|
243
246
|
}
|
|
244
247
|
}
|
|
245
248
|
|
|
249
|
+
/**
|
|
250
|
+
* Nodes the tracker offered on the most recent connection attempt. Used by
|
|
251
|
+
* the settings UI to show what is available without forcing a reconnect.
|
|
252
|
+
*/
|
|
253
|
+
get availableNodes(): readonly CandidateNode[] {
|
|
254
|
+
return this.lastCandidates;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Ask the tracker for the current node list without connecting. */
|
|
258
|
+
async listNodes(): Promise<readonly CandidateNode[]> {
|
|
259
|
+
const document = await this.getDocument();
|
|
260
|
+
const tracker = this.getTracker(document);
|
|
261
|
+
const candidates = [...(await tracker.requestCandidates())];
|
|
262
|
+
this.lastCandidates = candidates;
|
|
263
|
+
return candidates;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
get preferredNode(): string | undefined {
|
|
267
|
+
return this.preferredNodeId;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Pin future connections to one node, or pass undefined to go back to
|
|
272
|
+
* automatic selection. Takes effect on the next connection.
|
|
273
|
+
*/
|
|
274
|
+
setPreferredNode(nodeId: string | undefined): void {
|
|
275
|
+
this.preferredNodeId = nodeId || undefined;
|
|
276
|
+
}
|
|
277
|
+
|
|
246
278
|
private async establishConnection(): Promise<RoxxieConnection> {
|
|
247
279
|
const document = await this.getDocument();
|
|
248
280
|
const tracker = this.getTracker(document);
|
|
249
|
-
const
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
281
|
+
const offered = [...(await tracker.requestCandidates())];
|
|
282
|
+
// Keep the full list for the settings UI; only the probe set is trimmed.
|
|
283
|
+
this.lastCandidates = offered;
|
|
284
|
+
const ranked = offered.sort(
|
|
285
|
+
(left, right) =>
|
|
286
|
+
left.activeSessions - right.activeSessions ||
|
|
287
|
+
left.utilization - right.utilization
|
|
288
|
+
);
|
|
289
|
+
// A pinned node has to be selected before the list is trimmed, or picking
|
|
290
|
+
// a lightly-used node outside the top few would silently do nothing.
|
|
291
|
+
const pinned =
|
|
292
|
+
this.preferredNodeId === undefined
|
|
293
|
+
? []
|
|
294
|
+
: ranked.filter(
|
|
295
|
+
(candidate) => candidate.nodeId === this.preferredNodeId
|
|
296
|
+
);
|
|
297
|
+
// Fall back to automatic selection rather than stranding the user on a node
|
|
298
|
+
// that has gone away since they picked it.
|
|
299
|
+
const discovered = pinned.length > 0 ? pinned : ranked.slice(0, 3);
|
|
256
300
|
const compatible = discovered.filter(
|
|
257
301
|
(candidate) => candidate.protocolVersion === PROTOCOL_VERSION
|
|
258
302
|
);
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
import { RawHeaders } from "@mercuryworkshop/proxy-transports";
|
|
14
14
|
import { _URL, _Set } from "@/shared/snapshot";
|
|
15
15
|
import { createReferrerString } from "./util";
|
|
16
|
+
import { applyUserAgentHeader } from "./user-agent";
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* Headers for security policy features that haven't been emulated yet
|
|
@@ -123,6 +124,13 @@ export function rewriteRequestHeaders(
|
|
|
123
124
|
): ScramjetHeaders {
|
|
124
125
|
const headers = request.initialHeaders.clone();
|
|
125
126
|
|
|
127
|
+
// A service worker never sees User-Agent on the request it intercepts, and
|
|
128
|
+
// nothing downstream adds one, so requests reach origins with no User-Agent
|
|
129
|
+
// at all. Servers that content-negotiate on it then serve their non-browser
|
|
130
|
+
// representation - ip.me, for example, answers with a bare text/plain IP
|
|
131
|
+
// instead of its actual page.
|
|
132
|
+
applyUserAgentHeader(headers);
|
|
133
|
+
|
|
126
134
|
// avoid leaking the scramjet referer
|
|
127
135
|
headers.delete("Referer");
|
|
128
136
|
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* User-Agent forwarding.
|
|
3
|
+
*
|
|
4
|
+
* A service worker never sees User-Agent on the request it intercepts - the
|
|
5
|
+
* browser strips it before the fetch event - and nothing further down the proxy
|
|
6
|
+
* adds one. Requests therefore reached origins with no User-Agent at all, and
|
|
7
|
+
* servers that content-negotiate on it served their non-browser representation.
|
|
8
|
+
* ip.me, for instance, answers a bare `text/plain` IP address rather than its
|
|
9
|
+
* actual page.
|
|
10
|
+
*
|
|
11
|
+
* Kept free of the rest of the fetch pipeline's imports so it stays testable on
|
|
12
|
+
* its own.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** The minimal surface this needs from ScramjetHeaders. */
|
|
16
|
+
export interface UserAgentHeaders {
|
|
17
|
+
get(key: string): string | null;
|
|
18
|
+
set(key: string, value: string): void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let cachedUserAgent: string | null | undefined;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The browser's own User-Agent, or null where no navigator is available. Cached
|
|
25
|
+
* because the value cannot change for the life of the worker.
|
|
26
|
+
*/
|
|
27
|
+
export function browserUserAgent(): string | null {
|
|
28
|
+
if (cachedUserAgent === undefined) {
|
|
29
|
+
const value =
|
|
30
|
+
typeof navigator === "undefined" ? undefined : navigator.userAgent;
|
|
31
|
+
cachedUserAgent =
|
|
32
|
+
typeof value === "string" && value.length > 0 ? value : null;
|
|
33
|
+
}
|
|
34
|
+
return cachedUserAgent;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Forces the next read to re-detect the User-Agent. Intended for tests. */
|
|
38
|
+
export function resetUserAgentCache(): void {
|
|
39
|
+
cachedUserAgent = undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Send the real browser User-Agent, unless the page set one of its own. Passing
|
|
44
|
+
* the browser's string through unchanged keeps a proxied request
|
|
45
|
+
* indistinguishable from a direct one for content negotiation.
|
|
46
|
+
*/
|
|
47
|
+
export function applyUserAgentHeader(headers: UserAgentHeaders): void {
|
|
48
|
+
if (headers.get("user-agent")) return;
|
|
49
|
+
const agent = browserUserAgent();
|
|
50
|
+
if (agent) headers.set("User-Agent", agent);
|
|
51
|
+
}
|
|
@@ -109,6 +109,13 @@ export interface CandidateNode {
|
|
|
109
109
|
maxSessions: number;
|
|
110
110
|
utilization: number;
|
|
111
111
|
protocolVersion: string;
|
|
112
|
+
/**
|
|
113
|
+
* Node agent release, surfaced so operators can see what each node runs.
|
|
114
|
+
* Optional for compatibility with trackers that predate it.
|
|
115
|
+
*/
|
|
116
|
+
packageVersion?: string;
|
|
117
|
+
/** Operating system the node runs on, when the tracker reports it. */
|
|
118
|
+
platform?: string;
|
|
112
119
|
iceServers: RTCIceServerJSON[];
|
|
113
120
|
}
|
|
114
121
|
|
|
@@ -668,6 +675,21 @@ export function assertBrowserServerMessage(
|
|
|
668
675
|
`candidates[${index}].utilization is invalid`
|
|
669
676
|
);
|
|
670
677
|
}
|
|
678
|
+
// Optional: older trackers omit these entirely.
|
|
679
|
+
if (candidate.packageVersion !== undefined) {
|
|
680
|
+
stringValue(
|
|
681
|
+
candidate.packageVersion,
|
|
682
|
+
`candidates[${index}].packageVersion`,
|
|
683
|
+
32
|
|
684
|
+
);
|
|
685
|
+
}
|
|
686
|
+
if (candidate.platform !== undefined) {
|
|
687
|
+
stringValue(
|
|
688
|
+
candidate.platform,
|
|
689
|
+
`candidates[${index}].platform`,
|
|
690
|
+
32
|
|
691
|
+
);
|
|
692
|
+
}
|
|
671
693
|
assertIceServers(
|
|
672
694
|
candidate.iceServers,
|
|
673
695
|
`candidates[${index}].iceServers`
|
package/source/pnpm-lock.yaml
CHANGED
package/standalone.html
CHANGED
|
@@ -18,8 +18,8 @@
|
|
|
18
18
|
<div id="roxxie-browser"></div>
|
|
19
19
|
<script
|
|
20
20
|
defer
|
|
21
|
-
src="https://cdn.jsdelivr.net/npm/roxxie-proxy-u-prod@0.2.
|
|
22
|
-
integrity="sha384-
|
|
21
|
+
src="https://cdn.jsdelivr.net/npm/roxxie-proxy-u-prod@0.2.4/embed.js"
|
|
22
|
+
integrity="sha384-o6wwoyCvQbj3evNh2USZSUAOc625OxxX2yUnWs7o2i4NmKGhII4Y9SxvCG03OjAk"
|
|
23
23
|
crossorigin="anonymous"
|
|
24
24
|
data-roxxie-target="#roxxie-browser"
|
|
25
25
|
></script>
|