@kortix/agent-tunnel 0.13.42 → 0.13.44
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 +4 -0
- package/dist/agent-cli.js +40 -19
- package/package.json +1 -1
- package/src/agent/agent.ts +6 -1
- package/src/agent/device-auth.test.ts +62 -0
- package/src/agent/device-auth.ts +43 -0
package/README.md
CHANGED
|
@@ -23,6 +23,10 @@ of, in your private sessions. `--project-id` only offers "Also share with
|
|
|
23
23
|
<project>" on the approval page; without it the computer is still yours
|
|
24
24
|
everywhere.
|
|
25
25
|
|
|
26
|
+
Connecting the same computer again reuses its entry. The agent sends a SHA-256
|
|
27
|
+
hash of the hardware ID (IOPlatformUUID, `/etc/machine-id`, or `MachineGuid`);
|
|
28
|
+
the raw ID never leaves the computer.
|
|
29
|
+
|
|
26
30
|
After approval, the interactive flow asks whether it should install a persistent background service. The default answer is yes.
|
|
27
31
|
|
|
28
32
|
## Run in the background
|
package/dist/agent-cli.js
CHANGED
|
@@ -2971,7 +2971,7 @@ if (typeof globalThis.WebSocket === "undefined") {
|
|
|
2971
2971
|
}
|
|
2972
2972
|
|
|
2973
2973
|
// src/agent/cli.ts
|
|
2974
|
-
import { existsSync as existsSync7, readFileSync as
|
|
2974
|
+
import { existsSync as existsSync7, readFileSync as readFileSync10, rmSync as rmSync5, writeFileSync as writeFileSync7 } from "fs";
|
|
2975
2975
|
import { hostname as hostname4 } from "os";
|
|
2976
2976
|
import { join as join12 } from "path";
|
|
2977
2977
|
|
|
@@ -3672,6 +3672,8 @@ function keepAwakeCommand(platform, pid) {
|
|
|
3672
3672
|
|
|
3673
3673
|
// src/agent/device-auth.ts
|
|
3674
3674
|
import { execFileSync, spawn as spawn2 } from "child_process";
|
|
3675
|
+
import { createHash as createHash2 } from "crypto";
|
|
3676
|
+
import { readFileSync as readFileSync4 } from "fs";
|
|
3675
3677
|
import { hostname, platform } from "os";
|
|
3676
3678
|
var UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
3677
3679
|
var TUNNEL_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
@@ -3792,12 +3794,29 @@ function machineDisplayName({
|
|
|
3792
3794
|
return fallback;
|
|
3793
3795
|
}
|
|
3794
3796
|
}
|
|
3797
|
+
function machineId({
|
|
3798
|
+
os = platform(),
|
|
3799
|
+
run = (command, args) => execFileSync(command, args, { encoding: "utf8", timeout: 2000, stdio: ["ignore", "pipe", "ignore"] }),
|
|
3800
|
+
read = (path) => readFileSync4(path, "utf8")
|
|
3801
|
+
} = {}) {
|
|
3802
|
+
const attempt = (source) => {
|
|
3803
|
+
try {
|
|
3804
|
+
return source()?.trim() || null;
|
|
3805
|
+
} catch {
|
|
3806
|
+
return null;
|
|
3807
|
+
}
|
|
3808
|
+
};
|
|
3809
|
+
const raw = os === "darwin" ? attempt(() => /"IOPlatformUUID" = "([^"]+)"/.exec(run("ioreg", ["-rd1", "-c", "IOPlatformExpertDevice"]))?.[1]) : os === "win32" ? attempt(() => /MachineGuid\s+REG_SZ\s+(\S+)/.exec(run("reg", ["query", "HKLM\\SOFTWARE\\Microsoft\\Cryptography", "/v", "MachineGuid"]))?.[1]) : attempt(() => read("/etc/machine-id")) ?? attempt(() => read("/var/lib/dbus/machine-id"));
|
|
3810
|
+
return raw ? createHash2("sha256").update(`kortix-machine:${raw.toLowerCase()}`).digest("hex") : null;
|
|
3811
|
+
}
|
|
3795
3812
|
async function requestDeviceAuthorization(apiUrl, options = {}) {
|
|
3813
|
+
const id = machineId();
|
|
3796
3814
|
const response = await fetch(`${apiUrl}/device-auth`, {
|
|
3797
3815
|
method: "POST",
|
|
3798
3816
|
headers: { "Content-Type": "application/json" },
|
|
3799
3817
|
body: JSON.stringify({
|
|
3800
3818
|
machineHostname: machineDisplayName(),
|
|
3819
|
+
...id ? { machine_id: id } : {},
|
|
3801
3820
|
...options.projectId ? { project_id: options.projectId } : {}
|
|
3802
3821
|
})
|
|
3803
3822
|
});
|
|
@@ -3852,7 +3871,7 @@ function openBrowser(url) {
|
|
|
3852
3871
|
}
|
|
3853
3872
|
|
|
3854
3873
|
// src/agent/version.ts
|
|
3855
|
-
import { readFileSync as
|
|
3874
|
+
import { readFileSync as readFileSync5 } from "fs";
|
|
3856
3875
|
import { dirname as dirname2, join as join4 } from "path";
|
|
3857
3876
|
import { fileURLToPath } from "url";
|
|
3858
3877
|
var PACKAGE_NAME = "@kortix/agent-tunnel";
|
|
@@ -3871,7 +3890,7 @@ function agentTunnelVersion() {
|
|
|
3871
3890
|
let directory = moduleDirectory();
|
|
3872
3891
|
for (let depth = 0;depth < MAX_LOOKUP_DEPTH; depth++) {
|
|
3873
3892
|
try {
|
|
3874
|
-
const manifest = JSON.parse(
|
|
3893
|
+
const manifest = JSON.parse(readFileSync5(join4(directory, "package.json"), "utf8"));
|
|
3875
3894
|
if (manifest?.name === PACKAGE_NAME && typeof manifest.version === "string") {
|
|
3876
3895
|
const version = manifest.version;
|
|
3877
3896
|
cached = version;
|
|
@@ -3979,7 +3998,7 @@ class PermissionGuard {
|
|
|
3979
3998
|
}
|
|
3980
3999
|
|
|
3981
4000
|
// src/shared/crypto.ts
|
|
3982
|
-
import { createHash as
|
|
4001
|
+
import { createHash as createHash3, createHmac, timingSafeEqual, randomInt } from "crypto";
|
|
3983
4002
|
function signMessage(signingKey, payload, nonce) {
|
|
3984
4003
|
return createHmac("sha256", signingKey).update(`${nonce}:${payload}`).digest("hex");
|
|
3985
4004
|
}
|
|
@@ -4069,6 +4088,7 @@ class TunnelAgent {
|
|
|
4069
4088
|
status = null;
|
|
4070
4089
|
uptime = 0;
|
|
4071
4090
|
displayName;
|
|
4091
|
+
hardwareId = machineId();
|
|
4072
4092
|
uptimeInterval = null;
|
|
4073
4093
|
signingKey = null;
|
|
4074
4094
|
lastNonce = 0;
|
|
@@ -4397,6 +4417,7 @@ class TunnelAgent {
|
|
|
4397
4417
|
machineInfo: {
|
|
4398
4418
|
hostname: hostname2(),
|
|
4399
4419
|
displayName: this.displayName ??= machineDisplayName(),
|
|
4420
|
+
...this.hardwareId ? { machineId: this.hardwareId } : {},
|
|
4400
4421
|
platform: platform2(),
|
|
4401
4422
|
arch: arch(),
|
|
4402
4423
|
osVersion: release(),
|
|
@@ -5005,7 +5026,7 @@ function createDesktopCapability() {
|
|
|
5005
5026
|
|
|
5006
5027
|
// src/agent/capabilities/filesystem.ts
|
|
5007
5028
|
import { open, writeFile, readdir, stat, unlink, mkdir } from "fs/promises";
|
|
5008
|
-
import { createHash as
|
|
5029
|
+
import { createHash as createHash4 } from "node:crypto";
|
|
5009
5030
|
|
|
5010
5031
|
// src/shared/filesystem-validation.ts
|
|
5011
5032
|
function validateFilesystemParams(method, params) {
|
|
@@ -5192,7 +5213,7 @@ function createFilesystemCapability(config) {
|
|
|
5192
5213
|
throw new Error(`Content exceeds max size (${contentBytes} > ${maxFileSize})`);
|
|
5193
5214
|
}
|
|
5194
5215
|
const bytes = Buffer.from(content, encoding);
|
|
5195
|
-
const expectedHash =
|
|
5216
|
+
const expectedHash = createHash4("sha256").update(bytes).digest("hex");
|
|
5196
5217
|
if (params.sha256 !== undefined && params.sha256.toLowerCase() !== expectedHash) {
|
|
5197
5218
|
throw new Error("SHA-256 mismatch: content differs from the source; destination was not modified");
|
|
5198
5219
|
}
|
|
@@ -5203,7 +5224,7 @@ function createFilesystemCapability(config) {
|
|
|
5203
5224
|
const handle = await open(path, "r");
|
|
5204
5225
|
try {
|
|
5205
5226
|
const stats = await handle.stat();
|
|
5206
|
-
const persistedHash =
|
|
5227
|
+
const persistedHash = createHash4("sha256").update(await handle.readFile()).digest("hex");
|
|
5207
5228
|
if (persistedHash !== expectedHash)
|
|
5208
5229
|
throw new Error("SHA-256 mismatch after write; destination verification failed");
|
|
5209
5230
|
return {
|
|
@@ -5416,14 +5437,14 @@ function createEnabledCapabilityRegistry(config, findDesktopDriver = findCuaDriv
|
|
|
5416
5437
|
}
|
|
5417
5438
|
|
|
5418
5439
|
// src/agent/credential-store.ts
|
|
5419
|
-
import { chmodSync as chmodSync3, existsSync as existsSync4, mkdirSync as mkdirSync2, readFileSync as
|
|
5440
|
+
import { chmodSync as chmodSync3, existsSync as existsSync4, mkdirSync as mkdirSync2, readFileSync as readFileSync6, renameSync as renameSync2, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "fs";
|
|
5420
5441
|
import { join as join8 } from "path";
|
|
5421
5442
|
var CONFIG_DIR2 = agentTunnelHome();
|
|
5422
5443
|
var CONFIG_FILE2 = join8(CONFIG_DIR2, "config.json");
|
|
5423
5444
|
var PAIRING_FIELDS = ["token", "tunnelId", "enabledCapabilities"];
|
|
5424
5445
|
function readConfigFile() {
|
|
5425
5446
|
try {
|
|
5426
|
-
const parsed = JSON.parse(
|
|
5447
|
+
const parsed = JSON.parse(readFileSync6(CONFIG_FILE2, "utf-8"));
|
|
5427
5448
|
return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
5428
5449
|
} catch {
|
|
5429
5450
|
return {};
|
|
@@ -5458,7 +5479,7 @@ function clearSavedCredentials() {
|
|
|
5458
5479
|
return false;
|
|
5459
5480
|
let existing;
|
|
5460
5481
|
try {
|
|
5461
|
-
existing = JSON.parse(
|
|
5482
|
+
existing = JSON.parse(readFileSync6(CONFIG_FILE2, "utf-8"));
|
|
5462
5483
|
} catch {
|
|
5463
5484
|
rmSync2(CONFIG_FILE2, { force: true });
|
|
5464
5485
|
return true;
|
|
@@ -5578,13 +5599,13 @@ async function promptYesNo(question, defaultValue) {
|
|
|
5578
5599
|
}
|
|
5579
5600
|
|
|
5580
5601
|
// src/agent/service.ts
|
|
5581
|
-
import { chmodSync as chmodSync4, copyFileSync, existsSync as existsSync6, mkdirSync as mkdirSync4, readFileSync as
|
|
5602
|
+
import { chmodSync as chmodSync4, copyFileSync, existsSync as existsSync6, mkdirSync as mkdirSync4, readFileSync as readFileSync8, realpathSync as realpathSync3, rmSync as rmSync4, writeFileSync as writeFileSync5 } from "fs";
|
|
5582
5603
|
import { platform as platform6 } from "os";
|
|
5583
5604
|
import { join as join10 } from "path";
|
|
5584
5605
|
|
|
5585
5606
|
// src/agent/service-drivers.ts
|
|
5586
5607
|
import { spawnSync } from "child_process";
|
|
5587
|
-
import { existsSync as existsSync5, mkdirSync as mkdirSync3, readFileSync as
|
|
5608
|
+
import { existsSync as existsSync5, mkdirSync as mkdirSync3, readFileSync as readFileSync7, rmSync as rmSync3, writeFileSync as writeFileSync4 } from "fs";
|
|
5588
5609
|
import { homedir as homedir4, platform as platform5, userInfo } from "os";
|
|
5589
5610
|
import { dirname as dirname5, join as join9 } from "path";
|
|
5590
5611
|
|
|
@@ -5764,7 +5785,7 @@ var launchd = {
|
|
|
5764
5785
|
return {
|
|
5765
5786
|
active: status.ok,
|
|
5766
5787
|
enabled,
|
|
5767
|
-
detail: status.detail || (installed ?
|
|
5788
|
+
detail: status.detail || (installed ? readFileSync7(paths.launchdPlist, "utf8") : undefined)
|
|
5768
5789
|
};
|
|
5769
5790
|
}
|
|
5770
5791
|
};
|
|
@@ -5895,7 +5916,7 @@ var scheduledTask = {
|
|
|
5895
5916
|
return {
|
|
5896
5917
|
active: state === "Running",
|
|
5897
5918
|
enabled: state !== "Disabled",
|
|
5898
|
-
detail: state || (installed ?
|
|
5919
|
+
detail: state || (installed ? readFileSync7(paths.windowsScript, "utf8") : undefined)
|
|
5899
5920
|
};
|
|
5900
5921
|
}
|
|
5901
5922
|
};
|
|
@@ -6016,7 +6037,7 @@ function getServiceStatus() {
|
|
|
6016
6037
|
return withDriver((driver, paths, installed) => {
|
|
6017
6038
|
let upToDate = false;
|
|
6018
6039
|
try {
|
|
6019
|
-
upToDate = installed &&
|
|
6040
|
+
upToDate = installed && readFileSync8(driver.unitPath(paths), "utf8") === driver.render(paths, currentRunnerParts(plannedRunner));
|
|
6020
6041
|
} catch {}
|
|
6021
6042
|
return { ...driver.status(paths, installed), upToDate };
|
|
6022
6043
|
}, { installed: false, active: null });
|
|
@@ -6065,7 +6086,7 @@ function renderServiceAction(action, status) {
|
|
|
6065
6086
|
}
|
|
6066
6087
|
|
|
6067
6088
|
// src/agent/state-file.ts
|
|
6068
|
-
import { chmodSync as chmodSync5, mkdirSync as mkdirSync5, readFileSync as
|
|
6089
|
+
import { chmodSync as chmodSync5, mkdirSync as mkdirSync5, readFileSync as readFileSync9, renameSync as renameSync3, writeFileSync as writeFileSync6 } from "fs";
|
|
6069
6090
|
import { join as join11 } from "path";
|
|
6070
6091
|
function stateFilePath(home = agentTunnelHome()) {
|
|
6071
6092
|
return join11(home, "state.json");
|
|
@@ -6092,7 +6113,7 @@ function writeAgentState(status, tunnel, home = agentTunnelHome()) {
|
|
|
6092
6113
|
}
|
|
6093
6114
|
function readAgentState(home = agentTunnelHome()) {
|
|
6094
6115
|
try {
|
|
6095
|
-
const parsed = JSON.parse(
|
|
6116
|
+
const parsed = JSON.parse(readFileSync9(stateFilePath(home), "utf8"));
|
|
6096
6117
|
return parsed && typeof parsed === "object" && typeof parsed.status === "string" ? parsed : null;
|
|
6097
6118
|
} catch {
|
|
6098
6119
|
return null;
|
|
@@ -6372,7 +6393,7 @@ function commandRun(flags) {
|
|
|
6372
6393
|
}
|
|
6373
6394
|
function lastServiceActivity() {
|
|
6374
6395
|
try {
|
|
6375
|
-
const lines =
|
|
6396
|
+
const lines = readFileSync10(join12(getServicePaths().logDir, "agent-tunnel.out.log"), "utf8").split(/\r?\n/).map((line) => stripAnsi(line).trim()).filter((line) => line.length > 0);
|
|
6376
6397
|
return lines.at(-1) ?? null;
|
|
6377
6398
|
} catch {
|
|
6378
6399
|
return null;
|
|
@@ -6494,7 +6515,7 @@ function commandLogs(flags) {
|
|
|
6494
6515
|
console.log(` ${c.dim}not created yet${c.reset}`);
|
|
6495
6516
|
continue;
|
|
6496
6517
|
}
|
|
6497
|
-
const kept =
|
|
6518
|
+
const kept = readFileSync10(file, "utf8").split(/\r?\n/).map((line) => line.trimEnd()).filter((line) => line.trim().length > 0).filter((line) => showAll || !isShellStartupNoise(line));
|
|
6498
6519
|
const lines = collapseRepeatedLines(kept).slice(-limit);
|
|
6499
6520
|
if (lines.length === 0) {
|
|
6500
6521
|
console.log(` ${c.dim}empty${c.reset}`);
|
package/package.json
CHANGED
package/src/agent/agent.ts
CHANGED
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
writeAccessRequest,
|
|
13
13
|
} from './access';
|
|
14
14
|
import { agentTunnelHome } from './service-paths';
|
|
15
|
-
import { machineDisplayName } from './device-auth';
|
|
15
|
+
import { machineDisplayName, machineId } from './device-auth';
|
|
16
16
|
import { capabilityForMethod } from '../shared/permissions';
|
|
17
17
|
import { TunnelErrorCode } from '../shared/types';
|
|
18
18
|
import { agentTunnelVersion } from './version';
|
|
@@ -174,6 +174,8 @@ export class TunnelAgent {
|
|
|
174
174
|
private uptime = 0;
|
|
175
175
|
/** Read once: on macOS it runs `scutil`, and the pong repeats every 30 s. */
|
|
176
176
|
private displayName?: string;
|
|
177
|
+
/** Hashed hardware id, read once (null when unreadable). */
|
|
178
|
+
private readonly hardwareId = machineId();
|
|
177
179
|
private uptimeInterval: ReturnType<typeof setInterval> | null = null;
|
|
178
180
|
|
|
179
181
|
// HMAC signature verification
|
|
@@ -576,6 +578,9 @@ export class TunnelAgent {
|
|
|
576
578
|
machineInfo: {
|
|
577
579
|
hostname: hostname(),
|
|
578
580
|
displayName: (this.displayName ??= machineDisplayName()),
|
|
581
|
+
// Registers this machine's identity on its row, so re-pairing it
|
|
582
|
+
// later reuses the registration instead of adding a second one.
|
|
583
|
+
...(this.hardwareId ? { machineId: this.hardwareId } : {}),
|
|
579
584
|
platform: platform(),
|
|
580
585
|
arch: arch(),
|
|
581
586
|
osVersion: release(),
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
InvalidDeviceAuthResponseError,
|
|
4
4
|
awaitDeviceAuthorization,
|
|
5
5
|
machineDisplayName,
|
|
6
|
+
machineId,
|
|
6
7
|
parseDeviceAuthChallenge,
|
|
7
8
|
parseDeviceAuthStatus,
|
|
8
9
|
requestDeviceAuthorization,
|
|
@@ -187,3 +188,64 @@ describe('machine display name', () => {
|
|
|
187
188
|
expect(body.machineHostname).toBe(machineDisplayName());
|
|
188
189
|
});
|
|
189
190
|
});
|
|
191
|
+
|
|
192
|
+
describe('machine id', () => {
|
|
193
|
+
const HEX = /^[a-f0-9]{64}$/;
|
|
194
|
+
const fails = () => {
|
|
195
|
+
throw new Error('not available');
|
|
196
|
+
};
|
|
197
|
+
const UUID = '8B1F2C3D-4E5F-4A6B-9C7D-0E1F2A3B4C5D';
|
|
198
|
+
|
|
199
|
+
test('macOS hashes the IOPlatformUUID; the raw id never leaves the machine', () => {
|
|
200
|
+
const id = machineId({
|
|
201
|
+
os: 'darwin',
|
|
202
|
+
run: (command, args) => {
|
|
203
|
+
expect([command, ...args]).toEqual(['ioreg', '-rd1', '-c', 'IOPlatformExpertDevice']);
|
|
204
|
+
return `+-o J316sAP <class IOPlatformExpertDevice>\n "IOPlatformUUID" = "${UUID}"\n`;
|
|
205
|
+
},
|
|
206
|
+
read: fails,
|
|
207
|
+
});
|
|
208
|
+
expect(id).toMatch(HEX);
|
|
209
|
+
expect(id).not.toContain(UUID.toLowerCase().replace(/-/g, ''));
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test('the same hardware gives the same id on every platform source, and other hardware a different one', () => {
|
|
213
|
+
const mac = (uuid: string) => machineId({ os: 'darwin', run: () => `"IOPlatformUUID" = "${uuid}"`, read: fails });
|
|
214
|
+
expect(mac(UUID)).toBe(mac(UUID));
|
|
215
|
+
expect(mac(UUID)).not.toBe(mac(UUID.replace('8B1F', '9B1F')));
|
|
216
|
+
const win = machineId({
|
|
217
|
+
os: 'win32',
|
|
218
|
+
run: () => `\r\nHKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Cryptography\r\n MachineGuid REG_SZ ${UUID}\r\n`,
|
|
219
|
+
read: fails,
|
|
220
|
+
});
|
|
221
|
+
expect(win).toBe(mac(UUID));
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test('Linux reads /etc/machine-id, then the dbus copy', () => {
|
|
225
|
+
const first = machineId({ os: 'linux', run: fails, read: (path) => (path === '/etc/machine-id' ? 'abc123\n' : fails()) });
|
|
226
|
+
const dbus = machineId({ os: 'linux', run: fails, read: (path) => (path === '/etc/machine-id' ? fails() : 'abc123') });
|
|
227
|
+
expect(first).toMatch(HEX);
|
|
228
|
+
expect(dbus).toBe(first);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test('an unreadable or empty id is null, never a random one', () => {
|
|
232
|
+
expect(machineId({ os: 'darwin', run: fails, read: fails })).toBeNull();
|
|
233
|
+
expect(machineId({ os: 'darwin', run: () => 'no uuid here', read: fails })).toBeNull();
|
|
234
|
+
expect(machineId({ os: 'linux', run: fails, read: () => ' \n' })).toBeNull();
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test('pairing sends the machine id', async () => {
|
|
238
|
+
const original = globalThis.fetch;
|
|
239
|
+
let body: Record<string, unknown> = {};
|
|
240
|
+
globalThis.fetch = (async (_url: string, init: RequestInit) => {
|
|
241
|
+
body = JSON.parse(String(init.body));
|
|
242
|
+
return new Response(JSON.stringify(challenge()), { status: 200 });
|
|
243
|
+
}) as unknown as typeof fetch;
|
|
244
|
+
try {
|
|
245
|
+
await requestDeviceAuthorization('https://api.kortix.com/v1/tunnel');
|
|
246
|
+
} finally {
|
|
247
|
+
globalThis.fetch = original;
|
|
248
|
+
}
|
|
249
|
+
expect(body.machine_id).toBe(machineId() ?? undefined);
|
|
250
|
+
});
|
|
251
|
+
});
|
package/src/agent/device-auth.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { execFileSync, spawn } from 'child_process';
|
|
2
|
+
import { createHash } from 'crypto';
|
|
3
|
+
import { readFileSync } from 'fs';
|
|
2
4
|
import { hostname, platform } from 'os';
|
|
3
5
|
import { isTunnelCapability } from '../shared/permissions';
|
|
4
6
|
import type { TunnelCapability } from '../shared/types';
|
|
@@ -168,6 +170,44 @@ export function machineDisplayName({
|
|
|
168
170
|
}
|
|
169
171
|
}
|
|
170
172
|
|
|
173
|
+
/**
|
|
174
|
+
* This machine's identity: sha256 of its hardware id (macOS IOPlatformUUID,
|
|
175
|
+
* Linux /etc/machine-id, Windows MachineGuid), so the raw id never leaves the
|
|
176
|
+
* machine. It survives reinstalls, renames, and network changes; the relay
|
|
177
|
+
* reuses the owner's existing registration of it instead of pairing a new
|
|
178
|
+
* one. Null when the id cannot be read.
|
|
179
|
+
*/
|
|
180
|
+
export function machineId({
|
|
181
|
+
os = platform(),
|
|
182
|
+
run = (command: string, args: string[]) =>
|
|
183
|
+
execFileSync(command, args, { encoding: 'utf8', timeout: 2_000, stdio: ['ignore', 'pipe', 'ignore'] }),
|
|
184
|
+
read = (path: string) => readFileSync(path, 'utf8'),
|
|
185
|
+
}: {
|
|
186
|
+
os?: string;
|
|
187
|
+
run?: (command: string, args: string[]) => string;
|
|
188
|
+
read?: (path: string) => string;
|
|
189
|
+
} = {}): string | null {
|
|
190
|
+
const attempt = (source: () => string | undefined) => {
|
|
191
|
+
try {
|
|
192
|
+
return source()?.trim() || null;
|
|
193
|
+
} catch {
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
const raw =
|
|
198
|
+
os === 'darwin'
|
|
199
|
+
? attempt(() => /"IOPlatformUUID" = "([^"]+)"/.exec(run('ioreg', ['-rd1', '-c', 'IOPlatformExpertDevice']))?.[1])
|
|
200
|
+
: os === 'win32'
|
|
201
|
+
? attempt(
|
|
202
|
+
() =>
|
|
203
|
+
/MachineGuid\s+REG_SZ\s+(\S+)/.exec(
|
|
204
|
+
run('reg', ['query', 'HKLM\\SOFTWARE\\Microsoft\\Cryptography', '/v', 'MachineGuid']),
|
|
205
|
+
)?.[1],
|
|
206
|
+
)
|
|
207
|
+
: (attempt(() => read('/etc/machine-id')) ?? attempt(() => read('/var/lib/dbus/machine-id')));
|
|
208
|
+
return raw ? createHash('sha256').update(`kortix-machine:${raw.toLowerCase()}`).digest('hex') : null;
|
|
209
|
+
}
|
|
210
|
+
|
|
171
211
|
/**
|
|
172
212
|
* Starts pairing. `projectId` names the project the machine is being connected
|
|
173
213
|
* to, so the approval page can skip its project picker. A relay that predates
|
|
@@ -177,12 +217,15 @@ export async function requestDeviceAuthorization(
|
|
|
177
217
|
apiUrl: string,
|
|
178
218
|
options: { projectId?: string } = {},
|
|
179
219
|
): Promise<DeviceAuthChallenge> {
|
|
220
|
+
const id = machineId();
|
|
180
221
|
const response = await fetch(`${apiUrl}/device-auth`, {
|
|
181
222
|
method: 'POST',
|
|
182
223
|
headers: { 'Content-Type': 'application/json' },
|
|
183
224
|
body: JSON.stringify({
|
|
184
225
|
// The default machine name on the approval page.
|
|
185
226
|
machineHostname: machineDisplayName(),
|
|
227
|
+
// One registration per machine: re-pairing reuses it.
|
|
228
|
+
...(id ? { machine_id: id } : {}),
|
|
186
229
|
...(options.projectId ? { project_id: options.projectId } : {}),
|
|
187
230
|
}),
|
|
188
231
|
});
|