replica-bridge 0.3.1 → 0.3.3
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/package.json +1 -1
- package/src/bridge/enroll.js +52 -2
- package/src/bridge/main.js +16 -12
- package/src/bridge/pair.js +27 -17
- package/src/bridge/service.js +8 -1
- package/src/relay/config.js +27 -0
package/package.json
CHANGED
package/src/bridge/enroll.js
CHANGED
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import { b64url, generateKeyPair } from "../protocol.js";
|
|
5
|
-
import { relayRequest } from "../http.js";
|
|
5
|
+
import { RelayHTTPError, relayRequest } from "../http.js";
|
|
6
6
|
import { defaultEnvPath, loadRelayConfig } from "../relay/config.js";
|
|
7
7
|
import { loadState, saveState } from "./state.js";
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
/** The project's public relay, which the app also signs in to by default (Sources/Core/RelayPairing.swift). */
|
|
11
|
-
export const PUBLIC_RELAY = "https://
|
|
11
|
+
export const PUBLIC_RELAY = "https://agensrelay.office-cli.onl";
|
|
12
12
|
|
|
13
13
|
/** With no --relay: REPLICA_RELAY, else the relay on this machine over loopback, else the public relay. */
|
|
14
14
|
export function defaultRelayURL() {
|
|
@@ -25,6 +25,56 @@ export async function enroll(relay , options
|
|
|
25
25
|
return state;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
/** Public relays the project ran before; a computer still set up with one moves to the default relay. */
|
|
29
|
+
export const RETIRED_RELAYS = ["https://agens.office-cli.onl"];
|
|
30
|
+
|
|
31
|
+
const sameURL = (a , b ) => a.replace(/\/+$/, "") === b.replace(/\/+$/, "");
|
|
32
|
+
|
|
33
|
+
/** Whether the saved relay still serves this computer. Throws when it cannot be reached, which may pass. */
|
|
34
|
+
async function checkRelay(state ) {
|
|
35
|
+
try {
|
|
36
|
+
await relayRequest(state.relay, "GET", "/v1/host/devices", { token: state.hostToken });
|
|
37
|
+
return "ok";
|
|
38
|
+
} catch (error) {
|
|
39
|
+
if (!(error instanceof RelayHTTPError)) throw new Error(`cannot reach the relay ${state.relay} (${(error ).message}); if it moved, run again with --relay <url>`);
|
|
40
|
+
if (error.status === 401) return "unknown computer";
|
|
41
|
+
// Something answered but not with the relay's own errors: is it a relay at all? Its / says so.
|
|
42
|
+
const home = await fetch(state.relay.endsWith("/") ? state.relay : `${state.relay}/`).then((response) => response.text()).catch(() => "");
|
|
43
|
+
if (home.trim() === "replica-relay") throw error;
|
|
44
|
+
return "not a relay";
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The saved state, set up afresh whenever it cannot serve a phone: nothing saved yet, --relay or REPLICA_RELAY names
|
|
50
|
+
* another relay, the saved relay is retired or no longer a relay, or it no longer knows this computer. Pairings live
|
|
51
|
+
* on one relay, so a fresh set-up has none and the phone scans again; `moved` says a running bridge must restart.
|
|
52
|
+
*/
|
|
53
|
+
export async function ensureRelay(relay , say , name ) {
|
|
54
|
+
let saved ;
|
|
55
|
+
try {
|
|
56
|
+
saved = loadState();
|
|
57
|
+
} catch (error) {
|
|
58
|
+
if (!/not set up yet/.test((error ).message)) throw error;
|
|
59
|
+
return { state: await ensureEnrolled(relay, say, name), moved: true };
|
|
60
|
+
}
|
|
61
|
+
const asked = relay ?? process.env.REPLICA_RELAY;
|
|
62
|
+
let target ;
|
|
63
|
+
let reason ;
|
|
64
|
+
if (asked && !sameURL(asked, saved.relay) && !(saved.publicRelay && sameURL(asked, saved.publicRelay))) {
|
|
65
|
+
[target, reason] = [asked, `it was set up with ${saved.relay}`];
|
|
66
|
+
} else if (RETIRED_RELAYS.some((url) => sameURL(url, saved.relay))) {
|
|
67
|
+
[target, reason] = [defaultRelayURL(), `${saved.relay} no longer runs a relay`];
|
|
68
|
+
} else {
|
|
69
|
+
const check = await checkRelay(saved);
|
|
70
|
+
if (check === "ok") return { state: saved, moved: false };
|
|
71
|
+
[target, reason] = check === "unknown computer" ? [saved.relay, `${saved.relay} no longer knows this computer`] : [defaultRelayURL(), `${saved.relay} is no longer a relay`];
|
|
72
|
+
}
|
|
73
|
+
const state = await enroll(target, { name: name ?? saved.hostName, publicRelay: sameURL(target, saved.relay) ? saved.publicRelay : undefined });
|
|
74
|
+
say(`Set up ${state.hostName} again with ${target} (${state.hostId}): ${reason}. Phones paired before scan the new QR code.`);
|
|
75
|
+
return { state, moved: true };
|
|
76
|
+
}
|
|
77
|
+
|
|
28
78
|
/** The saved state, enrolling first if this computer never has. */
|
|
29
79
|
export async function ensureEnrolled(relay , say , name ) {
|
|
30
80
|
try {
|
package/src/bridge/main.js
CHANGED
|
@@ -16,7 +16,7 @@ import readline from "node:readline/promises";
|
|
|
16
16
|
import { parseArgs, format } from "node:util";
|
|
17
17
|
import { relayRequest } from "../http.js";
|
|
18
18
|
import { spawnCommandSync } from "./command.js";
|
|
19
|
-
import { defaultRelayURL, enroll, ensureEnrolled } from "./enroll.js";
|
|
19
|
+
import { defaultRelayURL, enroll, ensureEnrolled, ensureRelay } from "./enroll.js";
|
|
20
20
|
import { PACKAGE_VERSION, clearPid, installService, logPath, readServiceInfo, runningPid, servicePlan, uninstallService, writePid } from "./install.js";
|
|
21
21
|
import { pair, publicRelayURL } from "./pair.js";
|
|
22
22
|
import { BridgeService } from "./service.js";
|
|
@@ -138,17 +138,17 @@ async function main() {
|
|
|
138
138
|
case undefined:
|
|
139
139
|
case "up": {
|
|
140
140
|
const installed = readServiceInfo();
|
|
141
|
-
if (installed
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
141
|
+
if (!installed || values.pair || values.install) checkCodex();
|
|
142
|
+
const { state, moved } = await ensureRelay(values.relay, console.log, values.name);
|
|
143
|
+
if (installed) {
|
|
144
|
+
// The background service serves the phone: restart it onto a new set-up or a newer version, then pair.
|
|
145
|
+
if (moved) console.log("Restarting the background service with the new set-up.");
|
|
146
|
+
else if (installed.version !== PACKAGE_VERSION) console.log(`Updating the background service from ${installed.version} to ${PACKAGE_VERSION}.`);
|
|
147
|
+
if (moved || values.install || installed.version !== PACKAGE_VERSION) install();
|
|
148
|
+
if (moved || values.pair || !state.devices.length) await pairOrExplain();
|
|
149
|
+
else status();
|
|
147
150
|
break;
|
|
148
151
|
}
|
|
149
|
-
checkCodex();
|
|
150
|
-
const state = await ensureEnrolled(values.relay, console.log, values.name);
|
|
151
|
-
if (installed && !values.install) { await pairOrExplain(); break; } // the background service serves the phone
|
|
152
152
|
if (runningPid()) throw new Error(`a bridge already runs for this computer (pid ${runningPid()}); stop it first`);
|
|
153
153
|
let bridge = startBridge();
|
|
154
154
|
if (values.pair || !state.devices.length) await pairOrExplain();
|
|
@@ -168,7 +168,7 @@ async function main() {
|
|
|
168
168
|
break;
|
|
169
169
|
}
|
|
170
170
|
case "install":
|
|
171
|
-
await
|
|
171
|
+
await ensureRelay(values.relay, console.log, values.name);
|
|
172
172
|
install();
|
|
173
173
|
break;
|
|
174
174
|
case "uninstall": {
|
|
@@ -184,6 +184,7 @@ async function main() {
|
|
|
184
184
|
const state = await enroll(relay, { name: values.name, publicRelay: values["public-url"] });
|
|
185
185
|
console.log(`Set up ${state.hostName} as ${state.hostId}. Next: "npx replica-bridge".`);
|
|
186
186
|
console.log("Nobody owns it yet: the first phone that scans its QR code and is approved here claims it.");
|
|
187
|
+
if (readServiceInfo()) { console.log("Restarting the background service with the new set-up."); install(); }
|
|
187
188
|
break;
|
|
188
189
|
}
|
|
189
190
|
case "config": {
|
|
@@ -194,7 +195,10 @@ async function main() {
|
|
|
194
195
|
break;
|
|
195
196
|
}
|
|
196
197
|
case "pair":
|
|
197
|
-
await
|
|
198
|
+
if ((await ensureRelay(values.relay, console.log, values.name)).moved && readServiceInfo()) {
|
|
199
|
+
console.log("Restarting the background service with the new set-up.");
|
|
200
|
+
install();
|
|
201
|
+
}
|
|
198
202
|
await pair({ confirm: values.confirm, yes: values.yes, timeoutSeconds: Number(values.timeout), pngPath: values.png });
|
|
199
203
|
if (!runningPid()) console.log(`Nothing serves this computer right now: run "npx replica-bridge" so the phone can connect.`);
|
|
200
204
|
break;
|
package/src/bridge/pair.js
CHANGED
|
@@ -74,26 +74,36 @@ async function askTerminal(claim ) {
|
|
|
74
74
|
export async function pair(options ) {
|
|
75
75
|
const state = loadState();
|
|
76
76
|
const say = (text ) => { if (!options.quiet) console.log(text); };
|
|
77
|
-
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
77
|
+
// A code lives five minutes on the relay; until the timeout a fresh one replaces it, so a late scan still works.
|
|
78
|
+
const deadline = Date.now() + options.timeoutSeconds * 1000;
|
|
79
|
+
let secret ;
|
|
80
|
+
let code ;
|
|
81
|
+
let expiresAt ;
|
|
82
|
+
const show = async (renewed ) => {
|
|
83
|
+
secret = crypto.randomBytes(32);
|
|
84
|
+
({ code, expiresAt } = await relayRequest (state.relay, "POST", "/v1/pair/start", { token: state.hostToken, body: { secretHash: secretHash(secret) } }));
|
|
85
|
+
const link = buildPairingLink({ relay: publicRelayURL(state), hostId: state.hostId, hostPublicKey: fromB64url(state.hostPublicKey), code, secret, hostName: state.hostName });
|
|
86
|
+
options.onLink?.(link, code);
|
|
87
|
+
if (!options.quiet) {
|
|
88
|
+
if (renewed) console.log("The code expired; scan this new one.");
|
|
89
|
+
console.log(await QRCode.toString(link, { type: "terminal", small: true, errorCorrectionLevel: "M" }));
|
|
90
|
+
console.log(`Scan with the app (远程 › 添加连接 › 配对新设备) or the iPhone camera, or type the code: ${formatCode(code)}`);
|
|
91
|
+
console.log(`Link: ${link}`);
|
|
92
|
+
console.log(`Expires at ${new Date(expiresAt).toLocaleTimeString()}${expiresAt < deadline ? " (a new code follows until " + new Date(deadline).toLocaleTimeString() + ")" : ""}. Waiting for the phone…`);
|
|
93
|
+
}
|
|
94
|
+
if (options.pngPath) {
|
|
95
|
+
await QRCode.toFile(options.pngPath, link, { width: 512, errorCorrectionLevel: "M" });
|
|
96
|
+
say(`QR code also saved to ${options.pngPath}`);
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
await show(false);
|
|
91
100
|
|
|
92
|
-
const deadline = Math.min(expiresAt, Date.now() + options.timeoutSeconds * 1000);
|
|
93
101
|
let claim = null;
|
|
94
102
|
while (Date.now() < deadline) {
|
|
95
|
-
|
|
103
|
+
if (Date.now() >= expiresAt ) { await show(true); continue; }
|
|
104
|
+
const view = await relayRequest (state.relay, "GET", `/v1/pair/claim?code=${code }`, { token: state.hostToken });
|
|
96
105
|
if (view.status === "claimed") { claim = view; break; }
|
|
106
|
+
if (view.status === "expired") { expiresAt = 0; continue; }
|
|
97
107
|
if (view.status !== "open") throw new Error(`pairing ${view.status}`);
|
|
98
108
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
99
109
|
}
|
|
@@ -106,7 +116,7 @@ export async function pair(options )
|
|
|
106
116
|
};
|
|
107
117
|
const decide = options.decide ?? askTerminal;
|
|
108
118
|
let approve ;
|
|
109
|
-
if (claim.proof && !verifyPairingProof(secret, code, devicePublicKey, fromB64url(claim.proof))) {
|
|
119
|
+
if (claim.proof && !verifyPairingProof(secret , code , devicePublicKey, fromB64url(claim.proof))) {
|
|
110
120
|
say("The claim's proof does not match this QR code; rejecting it.");
|
|
111
121
|
approve = false;
|
|
112
122
|
} else if (info.safetyCode || options.confirm || (info.firstClaim && !options.yes)) {
|
package/src/bridge/service.js
CHANGED
|
@@ -100,7 +100,14 @@ export class BridgeService {
|
|
|
100
100
|
try { envelope = JSON.parse(data.toString()); } catch { return; }
|
|
101
101
|
this.onEnvelope(envelope);
|
|
102
102
|
});
|
|
103
|
-
|
|
103
|
+
// Something other than the relay answered (a web page where the relay used to be, a proxy error, a revoked
|
|
104
|
+
// token). With this listener ws leaves the socket half-open and emits no close, so nothing retried and the
|
|
105
|
+
// process ran out of work and exited 0, which the background services read as "replaced" and left stopped.
|
|
106
|
+
// Ending the socket goes through close and the usual backoff instead.
|
|
107
|
+
ws.on("unexpected-response", (_req, res) => {
|
|
108
|
+
this.log(`relay refused the host connection: HTTP ${res.statusCode}; retrying`);
|
|
109
|
+
ws.terminate();
|
|
110
|
+
});
|
|
104
111
|
ws.on("error", (error) => this.log(`relay connection error: ${error.message}`));
|
|
105
112
|
ws.on("close", (code) => {
|
|
106
113
|
if (ws !== this.relay) return; // a socket this bridge already replaced itself
|
package/src/relay/config.js
CHANGED
|
@@ -40,6 +40,8 @@ export function readEnvFile(file ) {
|
|
|
40
40
|
|
|
41
41
|
|
|
42
42
|
|
|
43
|
+
|
|
44
|
+
|
|
43
45
|
|
|
44
46
|
|
|
45
47
|
/** "github:583231, 12345, *" → ["583231", "12345", "*"]; anything that is not a numeric id or * is refused. */
|
|
@@ -51,6 +53,30 @@ export function parseAllowedIds(text ) {
|
|
|
51
53
|
});
|
|
52
54
|
}
|
|
53
55
|
|
|
56
|
+
/** "~/repos/minim/deploy/minim.env:MINIM_ALLOW" → { file, name }. */
|
|
57
|
+
export function parseAllowedFrom(text ) {
|
|
58
|
+
const colon = text.lastIndexOf(":");
|
|
59
|
+
const file = text.slice(0, colon).trim();
|
|
60
|
+
const name = text.slice(colon + 1).trim();
|
|
61
|
+
if (colon < 1 || !file || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) throw new Error(`GITHUB_ALLOWED_USERS_FROM takes <env file>:<VARIABLE>, not "${text}"`);
|
|
62
|
+
return { file: expandHome(file), name };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The allowed GitHub ids: GITHUB_ALLOWED_USERS plus, when set, the list in GITHUB_ALLOWED_USERS_FROM, read again on
|
|
67
|
+
* every call so editing that file takes effect without a restart. A missing file or variable throws (nobody gets in).
|
|
68
|
+
*/
|
|
69
|
+
export function allowedIdsReader(config ) {
|
|
70
|
+
const from = config.githubAllowedFrom;
|
|
71
|
+
if (!from) return () => config.githubAllowedIds;
|
|
72
|
+
return () => {
|
|
73
|
+
if (!fs.existsSync(from.file)) throw new Error(`GITHUB_ALLOWED_USERS_FROM: ${from.file} does not exist`);
|
|
74
|
+
const list = readEnvFile(from.file)[from.name];
|
|
75
|
+
if (list === undefined) throw new Error(`GITHUB_ALLOWED_USERS_FROM: ${from.file} has no ${from.name}`);
|
|
76
|
+
return [...new Set([...config.githubAllowedIds, ...parseAllowedIds(list)])];
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
54
80
|
const expandHome = (value ) => (value.startsWith("~/") ? path.join(os.homedir(), value.slice(2)) : value);
|
|
55
81
|
|
|
56
82
|
export function loadRelayConfig(envPath = defaultEnvPath()) {
|
|
@@ -72,5 +98,6 @@ export function loadRelayConfig(envPath = defaultEnvPath()) {
|
|
|
72
98
|
githubClientSecret: get("GITHUB_CLIENT_SECRET"),
|
|
73
99
|
githubCallbackUrl: get("GITHUB_CALLBACK_URL"),
|
|
74
100
|
githubAllowedIds: parseAllowedIds(get("GITHUB_ALLOWED_USERS") ?? ""),
|
|
101
|
+
githubAllowedFrom: get("GITHUB_ALLOWED_USERS_FROM") ? parseAllowedFrom(get("GITHUB_ALLOWED_USERS_FROM") ) : null,
|
|
75
102
|
};
|
|
76
103
|
}
|