talon-agent 3.31.0 → 3.32.0
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/core/mesh/companion-pairing.ts +181 -0
- package/src/core/mesh/service.ts +114 -0
- package/src/frontend/native/index.ts +1 -0
- package/src/frontend/native/server.ts +33 -0
- package/src/frontend/telegram/commands/definitions.ts +4 -1
- package/src/frontend/telegram/commands/info.ts +51 -3
- package/src/frontend/telegram/helpers/diagnostics.ts +80 -1
package/package.json
CHANGED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CompanionPairStore — one-time grants that connect a phone to this bridge
|
|
3
|
+
* without anyone typing a host, a port, or a bearer token.
|
|
4
|
+
*
|
|
5
|
+
* The problem it removes: a companion joins the mesh by being told the
|
|
6
|
+
* bridge's address, its bearer token, and (over TLS) the certificate
|
|
7
|
+
* fingerprint to pin. Reading those off a terminal and typing them into a
|
|
8
|
+
* phone is the single worst step in setting Talon up, and a mistyped token
|
|
9
|
+
* fails in a way that looks like a network problem.
|
|
10
|
+
*
|
|
11
|
+
* So the daemon mints a grant and hands out ONE link. The bridge serves it
|
|
12
|
+
* on a route that must work pre-auth — the phone holds no credential yet, so
|
|
13
|
+
* the grant token IS the authorization, the same trust model as node
|
|
14
|
+
* provisioning (node-provision.ts) and streamed transfers (transfers.ts):
|
|
15
|
+
* random 192-bit, single-use, expiring unused.
|
|
16
|
+
*
|
|
17
|
+
* GET /pair?grant=<token> → the pairing page (once)
|
|
18
|
+
* GET /pair?grant=<token>&format=json → the same payload as JSON
|
|
19
|
+
*
|
|
20
|
+
* Opening the link on the phone lands on a page whose button is a
|
|
21
|
+
* `talon://pair` deep link carrying the credentials, so the companion fills
|
|
22
|
+
* its own connection form. The page also prints the values for anyone who
|
|
23
|
+
* would rather type them, because a deep link is exactly the thing that
|
|
24
|
+
* silently does nothing when the app isn't installed.
|
|
25
|
+
*
|
|
26
|
+
* The grant is consumed by whichever leg is served first, and both legs
|
|
27
|
+
* render the same claimed grant — by the time a page has rendered, the
|
|
28
|
+
* credentials are already on the phone, so a second serve would only widen
|
|
29
|
+
* the window for someone else to replay the URL.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { randomBytes } from "node:crypto";
|
|
33
|
+
|
|
34
|
+
/** Unclaimed grants die after this long. A pairing link is used at once. */
|
|
35
|
+
const GRANT_TTL_MS = 10 * 60 * 1000;
|
|
36
|
+
|
|
37
|
+
export type CompanionPairGrant = {
|
|
38
|
+
token: string;
|
|
39
|
+
/** Bridge base URL as reachable from the phone. */
|
|
40
|
+
bridgeUrl: string;
|
|
41
|
+
/** Bearer token the companion will authenticate with. */
|
|
42
|
+
bearerToken: string;
|
|
43
|
+
/** Bridge TLS certificate fingerprint to pre-pin (absent over plain HTTP). */
|
|
44
|
+
fingerprint?: string;
|
|
45
|
+
/** What this daemon calls itself, so the phone can label the connection. */
|
|
46
|
+
label?: string;
|
|
47
|
+
createdAt: number;
|
|
48
|
+
used: boolean;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** What the phone needs to connect, as served to the companion. */
|
|
52
|
+
export type CompanionPairPayload = {
|
|
53
|
+
url: string;
|
|
54
|
+
token: string;
|
|
55
|
+
fingerprint?: string;
|
|
56
|
+
label?: string;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export class CompanionPairStore {
|
|
60
|
+
private readonly grants = new Map<string, CompanionPairGrant>();
|
|
61
|
+
|
|
62
|
+
constructor(private readonly ttlMs = GRANT_TTL_MS) {}
|
|
63
|
+
|
|
64
|
+
create(
|
|
65
|
+
grant: Omit<CompanionPairGrant, "token" | "createdAt" | "used">,
|
|
66
|
+
): CompanionPairGrant {
|
|
67
|
+
this.sweep();
|
|
68
|
+
const full: CompanionPairGrant = {
|
|
69
|
+
...grant,
|
|
70
|
+
// The label is interpolated into generated HTML and a URL query —
|
|
71
|
+
// keep it to characters that can't carry markup out of either.
|
|
72
|
+
...(grant.label
|
|
73
|
+
? { label: grant.label.replace(/[^\w .-]+/g, "").slice(0, 64) }
|
|
74
|
+
: {}),
|
|
75
|
+
token: randomBytes(24).toString("base64url"),
|
|
76
|
+
createdAt: Date.now(),
|
|
77
|
+
used: false,
|
|
78
|
+
};
|
|
79
|
+
this.grants.set(full.token, full);
|
|
80
|
+
return full;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Resolve a live grant — once. */
|
|
84
|
+
claim(token: string): CompanionPairPayload | null {
|
|
85
|
+
this.sweep();
|
|
86
|
+
const grant = this.grants.get(token);
|
|
87
|
+
if (!grant || grant.used) return null;
|
|
88
|
+
grant.used = true;
|
|
89
|
+
this.grants.delete(token);
|
|
90
|
+
return {
|
|
91
|
+
url: grant.bridgeUrl,
|
|
92
|
+
token: grant.bearerToken,
|
|
93
|
+
...(grant.fingerprint ? { fingerprint: grant.fingerprint } : {}),
|
|
94
|
+
...(grant.label ? { label: grant.label } : {}),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private sweep(): void {
|
|
99
|
+
const cutoff = Date.now() - this.ttlMs;
|
|
100
|
+
for (const [token, grant] of this.grants) {
|
|
101
|
+
if (grant.createdAt < cutoff) this.grants.delete(token);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The link a human opens on the phone. */
|
|
107
|
+
export function pairLink(grant: CompanionPairGrant): string {
|
|
108
|
+
return `${grant.bridgeUrl}/pair?grant=${grant.token}`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The `talon://pair` deep link the companion registers for. Carries the
|
|
113
|
+
* credentials themselves rather than the grant, because the grant is spent
|
|
114
|
+
* by the time this reaches the phone — and because a deep link that needed
|
|
115
|
+
* another round trip would fail on exactly the flaky first connection it
|
|
116
|
+
* exists to make painless.
|
|
117
|
+
*/
|
|
118
|
+
export function pairDeepLink(payload: CompanionPairPayload): string {
|
|
119
|
+
const q = new URLSearchParams({ u: payload.url, t: payload.token });
|
|
120
|
+
if (payload.fingerprint) q.set("f", payload.fingerprint);
|
|
121
|
+
if (payload.label) q.set("n", payload.label);
|
|
122
|
+
return `talon://pair?${q.toString()}`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Minimal HTML escape for the values interpolated into the pairing page. */
|
|
126
|
+
function esc(s: string): string {
|
|
127
|
+
return s
|
|
128
|
+
.replace(/&/g, "&")
|
|
129
|
+
.replace(/</g, "<")
|
|
130
|
+
.replace(/>/g, ">")
|
|
131
|
+
.replace(/"/g, """);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The page the phone lands on. Deliberately one self-contained file with no
|
|
136
|
+
* external assets: it is served by a bridge the browser has usually just
|
|
137
|
+
* warned about (self-signed cert), often over a LAN with no internet route,
|
|
138
|
+
* and a page that half-loads there is worse than no page at all.
|
|
139
|
+
*/
|
|
140
|
+
export function pairPage(payload: CompanionPairPayload): string {
|
|
141
|
+
const deep = pairDeepLink(payload);
|
|
142
|
+
const fp = payload.fingerprint;
|
|
143
|
+
return `<!doctype html>
|
|
144
|
+
<html lang="en">
|
|
145
|
+
<head>
|
|
146
|
+
<meta charset="utf-8">
|
|
147
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
148
|
+
<title>Connect to Talon</title>
|
|
149
|
+
<style>
|
|
150
|
+
:root { color-scheme: light dark; }
|
|
151
|
+
body { margin: 0; padding: 24px; font: 15px/1.5 system-ui, sans-serif; }
|
|
152
|
+
main { max-width: 32rem; margin: 0 auto; }
|
|
153
|
+
h1 { font-size: 1.25rem; margin: 0 0 .25rem; }
|
|
154
|
+
p.sub { margin: 0 0 1.5rem; opacity: .7; }
|
|
155
|
+
a.go { display: block; text-align: center; padding: 14px; border-radius: 10px;
|
|
156
|
+
background: #2f6fed; color: #fff; text-decoration: none; font-weight: 600; }
|
|
157
|
+
dl { margin: 1.5rem 0 0; }
|
|
158
|
+
dt { font-size: .8rem; text-transform: uppercase; letter-spacing: .04em; opacity: .6; margin-top: 1rem; }
|
|
159
|
+
dd { margin: .2rem 0 0; font-family: ui-monospace, monospace; font-size: .85rem; word-break: break-all; }
|
|
160
|
+
footer { margin-top: 2rem; font-size: .8rem; opacity: .6; }
|
|
161
|
+
</style>
|
|
162
|
+
</head>
|
|
163
|
+
<body>
|
|
164
|
+
<main>
|
|
165
|
+
<h1>Connect to Talon</h1>
|
|
166
|
+
<p class="sub">${payload.label ? esc(payload.label) : "This daemon"} is ready to pair.</p>
|
|
167
|
+
<a class="go" href="${esc(deep)}">Open in Talon</a>
|
|
168
|
+
<dl>
|
|
169
|
+
<dt>Bridge</dt><dd>${esc(payload.url)}</dd>
|
|
170
|
+
<dt>Token</dt><dd>${esc(payload.token)}</dd>
|
|
171
|
+
${fp ? `<dt>Certificate</dt><dd>${esc(fp)}</dd>` : ""}
|
|
172
|
+
</dl>
|
|
173
|
+
<footer>
|
|
174
|
+
If the button does nothing, the companion isn't installed yet — install it,
|
|
175
|
+
then enter the values above by hand. This link is single-use and is now spent.
|
|
176
|
+
</footer>
|
|
177
|
+
</main>
|
|
178
|
+
</body>
|
|
179
|
+
</html>
|
|
180
|
+
`;
|
|
181
|
+
}
|
package/src/core/mesh/service.ts
CHANGED
|
@@ -43,6 +43,12 @@ import {
|
|
|
43
43
|
resolveNodeBinary,
|
|
44
44
|
type NodeBinaryResolver,
|
|
45
45
|
} from "./node-binaries.js";
|
|
46
|
+
import {
|
|
47
|
+
CompanionPairStore,
|
|
48
|
+
pairLink,
|
|
49
|
+
pairPage,
|
|
50
|
+
type CompanionPairPayload,
|
|
51
|
+
} from "./companion-pairing.js";
|
|
46
52
|
import { installOneLiner, NodeProvisionStore } from "./node-provision.js";
|
|
47
53
|
import { MeshRegistry } from "./registry.js";
|
|
48
54
|
import { TransferStore } from "./transfers.js";
|
|
@@ -174,6 +180,8 @@ export class MeshService {
|
|
|
174
180
|
private readonly transfers = new TransferStore();
|
|
175
181
|
/** One-time grants for bridge-served node installers. */
|
|
176
182
|
private readonly provision = new NodeProvisionStore();
|
|
183
|
+
/** One-time grants that hand a phone this bridge's connection details. */
|
|
184
|
+
private readonly companionPairs = new CompanionPairStore();
|
|
177
185
|
private readonly resolveNode: NodeBinaryResolver;
|
|
178
186
|
private bridgeInfo: MeshBridgeInfo | null = null;
|
|
179
187
|
private readonly freshFixTimeoutMs: number;
|
|
@@ -1162,6 +1170,112 @@ export class MeshService {
|
|
|
1162
1170
|
};
|
|
1163
1171
|
}
|
|
1164
1172
|
|
|
1173
|
+
/**
|
|
1174
|
+
* Mint a single-use link that connects a phone to this bridge without
|
|
1175
|
+
* anyone typing an address or a token — the companion half of
|
|
1176
|
+
* {@link makeNodeInstallLink}.
|
|
1177
|
+
*
|
|
1178
|
+
* Returns the link plus the values it carries, so a caller (a `/mesh`
|
|
1179
|
+
* reply, say) can print both: the link is the one-tap path, the values are
|
|
1180
|
+
* what someone falls back to when the app isn't installed yet.
|
|
1181
|
+
*/
|
|
1182
|
+
makeCompanionPairLink(
|
|
1183
|
+
label?: unknown,
|
|
1184
|
+
bridgeUrl?: unknown,
|
|
1185
|
+
):
|
|
1186
|
+
| {
|
|
1187
|
+
ok: true;
|
|
1188
|
+
link: string;
|
|
1189
|
+
url: string;
|
|
1190
|
+
token: string;
|
|
1191
|
+
fingerprint?: string;
|
|
1192
|
+
}
|
|
1193
|
+
| { ok: false; text: string } {
|
|
1194
|
+
const info = this.bridgeInfo;
|
|
1195
|
+
if (!info) {
|
|
1196
|
+
return {
|
|
1197
|
+
ok: false,
|
|
1198
|
+
text: "The native bridge isn't running, so there is nothing for a phone to connect to. Enable the native frontend first.",
|
|
1199
|
+
};
|
|
1200
|
+
}
|
|
1201
|
+
if (!info.token) {
|
|
1202
|
+
return {
|
|
1203
|
+
ok: false,
|
|
1204
|
+
text: "The bridge has no bearer token (loopback-only bind), and companions authenticate with one. Set native.host to a reachable address (a token is auto-minted) and restart.",
|
|
1205
|
+
};
|
|
1206
|
+
}
|
|
1207
|
+
const base = this.bridgeBaseUrl(info, bridgeUrl);
|
|
1208
|
+
if (typeof base !== "string") return { ok: false, text: base.error };
|
|
1209
|
+
const grant = this.companionPairs.create({
|
|
1210
|
+
bridgeUrl: base,
|
|
1211
|
+
bearerToken: info.token,
|
|
1212
|
+
...(info.fingerprint ? { fingerprint: info.fingerprint } : {}),
|
|
1213
|
+
...(typeof label === "string" && label.trim()
|
|
1214
|
+
? { label: label.trim() }
|
|
1215
|
+
: {}),
|
|
1216
|
+
});
|
|
1217
|
+
return {
|
|
1218
|
+
ok: true,
|
|
1219
|
+
link: pairLink(grant),
|
|
1220
|
+
url: base,
|
|
1221
|
+
token: info.token,
|
|
1222
|
+
...(info.fingerprint ? { fingerprint: info.fingerprint } : {}),
|
|
1223
|
+
};
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
/**
|
|
1227
|
+
* GET /pair — serve a pairing grant (single-use), as the landing page or
|
|
1228
|
+
* as the JSON the companion reads.
|
|
1229
|
+
*/
|
|
1230
|
+
openCompanionPair(
|
|
1231
|
+
token: string,
|
|
1232
|
+
format: "html" | "json",
|
|
1233
|
+
): { contentType: string; body: string } | null {
|
|
1234
|
+
const payload = this.companionPairs.claim(token);
|
|
1235
|
+
if (!payload) return null;
|
|
1236
|
+
return format === "json"
|
|
1237
|
+
? {
|
|
1238
|
+
contentType: "application/json; charset=utf-8",
|
|
1239
|
+
body: JSON.stringify(payload satisfies CompanionPairPayload),
|
|
1240
|
+
}
|
|
1241
|
+
: {
|
|
1242
|
+
contentType: "text/html; charset=utf-8",
|
|
1243
|
+
body: pairPage(payload),
|
|
1244
|
+
};
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
/**
|
|
1248
|
+
* How this bridge is reachable, for an operator asking "what do I point a
|
|
1249
|
+
* device at?".
|
|
1250
|
+
*
|
|
1251
|
+
* The bearer token comes back with it. Deciding who may see a credential is
|
|
1252
|
+
* the caller's job, not this seam's — the Telegram `/mesh` shows it to the
|
|
1253
|
+
* configured admin and withholds it from everyone else — and an operator who
|
|
1254
|
+
* asked their own daemon for its own connection details should get an
|
|
1255
|
+
* answer, not a lecture.
|
|
1256
|
+
*/
|
|
1257
|
+
bridgeReachability():
|
|
1258
|
+
| {
|
|
1259
|
+
ok: true;
|
|
1260
|
+
url: string;
|
|
1261
|
+
authRequired: boolean;
|
|
1262
|
+
token?: string;
|
|
1263
|
+
fingerprint?: string;
|
|
1264
|
+
}
|
|
1265
|
+
| { ok: false; text: string } {
|
|
1266
|
+
const info = this.bridgeInfo;
|
|
1267
|
+
if (!info) return { ok: false, text: "The native bridge isn't running." };
|
|
1268
|
+
const base = this.bridgeBaseUrl(info);
|
|
1269
|
+
if (typeof base !== "string") return { ok: false, text: base.error };
|
|
1270
|
+
return {
|
|
1271
|
+
ok: true,
|
|
1272
|
+
url: base,
|
|
1273
|
+
authRequired: Boolean(info.token),
|
|
1274
|
+
...(info.token ? { token: info.token } : {}),
|
|
1275
|
+
...(info.fingerprint ? { fingerprint: info.fingerprint } : {}),
|
|
1276
|
+
};
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1165
1279
|
/** GET /node/install — serve a grant's installer script (single-use). */
|
|
1166
1280
|
openNodeInstall(token: string): { script: string; filename: string } | null {
|
|
1167
1281
|
return this.provision.openScript(token);
|
|
@@ -1263,6 +1263,7 @@ export function createNativeFrontend(
|
|
|
1263
1263
|
mesh.acceptFileUpload(token, body, fromDeviceId),
|
|
1264
1264
|
openFileDownload: (token, fromDeviceId) =>
|
|
1265
1265
|
mesh.openFileDownload(token, fromDeviceId),
|
|
1266
|
+
openCompanionPair: (token, format) => mesh.openCompanionPair(token, format),
|
|
1266
1267
|
openNodeInstall: (token) => mesh.openNodeInstall(token),
|
|
1267
1268
|
openNodeBinary: (token) => mesh.openNodeBinary(token),
|
|
1268
1269
|
};
|
|
@@ -145,6 +145,11 @@ export type BridgeServerHandlers = {
|
|
|
145
145
|
token: string,
|
|
146
146
|
fromDeviceId?: string,
|
|
147
147
|
): Promise<{ path: string; size: number } | null>;
|
|
148
|
+
/** Resolve a companion-pairing grant to its page/payload, or null. */
|
|
149
|
+
openCompanionPair(
|
|
150
|
+
token: string,
|
|
151
|
+
format: "html" | "json",
|
|
152
|
+
): { contentType: string; body: string } | null;
|
|
148
153
|
/** Resolve a node-provisioning token to its installer script, or null. */
|
|
149
154
|
openNodeInstall(token: string): { script: string; filename: string } | null;
|
|
150
155
|
/** Resolve a node-provisioning token to the binary to stream, or null. */
|
|
@@ -464,6 +469,34 @@ export class BridgeServer {
|
|
|
464
469
|
});
|
|
465
470
|
}
|
|
466
471
|
|
|
472
|
+
// Companion pairing is PRE-AUTH for the same reason as node
|
|
473
|
+
// provisioning below: the phone holds no bridge credential yet, and the
|
|
474
|
+
// single-use grant is what it comes to collect. Serving the page IS the
|
|
475
|
+
// handover, so the grant is spent whichever leg is hit.
|
|
476
|
+
if (method === "GET" && path === "/pair") {
|
|
477
|
+
const token = url.searchParams.get("grant") ?? "";
|
|
478
|
+
const wantsJson =
|
|
479
|
+
url.searchParams.get("format") === "json" ||
|
|
480
|
+
(req.headers.accept ?? "").includes("application/json");
|
|
481
|
+
const served = token
|
|
482
|
+
? this.handlers.openCompanionPair(token, wantsJson ? "json" : "html")
|
|
483
|
+
: null;
|
|
484
|
+
if (!served) {
|
|
485
|
+
return this.json(res, 404, {
|
|
486
|
+
ok: false,
|
|
487
|
+
error: "Unknown, expired, or already-used pairing link",
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
res.writeHead(200, {
|
|
491
|
+
"Content-Type": served.contentType,
|
|
492
|
+
// A pairing payload is a credential; nothing may keep a copy.
|
|
493
|
+
"Cache-Control": "no-store",
|
|
494
|
+
...this.corsHeaders(),
|
|
495
|
+
});
|
|
496
|
+
res.end(served.body);
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
|
|
467
500
|
// Node provisioning runs PRE-AUTH by design: the target host holds no
|
|
468
501
|
// bridge credential yet — the single-use grant token (minted by
|
|
469
502
|
// make_node_install_link, expiring, one serve per leg) is the entire
|
|
@@ -22,7 +22,10 @@ export const TELEGRAM_COMMANDS: ReadonlyArray<{
|
|
|
22
22
|
},
|
|
23
23
|
{ command: "status", description: "Session info, usage, and stats" },
|
|
24
24
|
{ command: "ping", description: "Health check with latency" },
|
|
25
|
-
{
|
|
25
|
+
{
|
|
26
|
+
command: "mesh",
|
|
27
|
+
description: "Mesh devices; /mesh link pairs a new one",
|
|
28
|
+
},
|
|
26
29
|
{ command: "model", description: "Show or change model" },
|
|
27
30
|
{ command: "effort", description: "Set thinking effort level" },
|
|
28
31
|
{ command: "pulse", description: "Conversation engagement settings" },
|
|
@@ -2,10 +2,16 @@
|
|
|
2
2
|
* Informational commands — /start, /help, /ping, /plugins.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import type { Bot } from "grammy";
|
|
5
|
+
import type { Bot, Context } from "grammy";
|
|
6
6
|
import { isUserClientReady } from "../userbot.js";
|
|
7
7
|
import { escapeHtml } from "../formatting.js";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
formatDuration,
|
|
10
|
+
renderMeshPairLink,
|
|
11
|
+
renderMeshReport,
|
|
12
|
+
type MeshReachability,
|
|
13
|
+
} from "../helpers/index.js";
|
|
14
|
+
import { isAuthorizedAdmin } from "./state.js";
|
|
9
15
|
import { getLoadedPlugins } from "../../../core/plugin/index.js";
|
|
10
16
|
import { getMeshService } from "../../../core/mesh/index.js";
|
|
11
17
|
import type { MeshPingResult } from "../../../core/mesh/service.js";
|
|
@@ -104,7 +110,49 @@ export function registerInfoCommands(bot: Bot): void {
|
|
|
104
110
|
}
|
|
105
111
|
});
|
|
106
112
|
|
|
113
|
+
/**
|
|
114
|
+
* The bridge footer `/mesh` prints for this caller.
|
|
115
|
+
*
|
|
116
|
+
* The admin gets the whole connection profile — URL, bearer token,
|
|
117
|
+
* certificate — because an operator asking their own daemon how to reach
|
|
118
|
+
* itself should get an answer rather than a scavenger hunt through config
|
|
119
|
+
* files. Everyone else gets the address only: a group member reading the
|
|
120
|
+
* fleet has no business holding the key to it.
|
|
121
|
+
*/
|
|
122
|
+
const bridgeFor = (ctx: Context): MeshReachability => {
|
|
123
|
+
const reach = getMeshService().bridgeReachability();
|
|
124
|
+
if (!reach.ok || isAuthorizedAdmin(ctx)) return reach;
|
|
125
|
+
return {
|
|
126
|
+
ok: true,
|
|
127
|
+
url: reach.url,
|
|
128
|
+
authRequired: reach.authRequired,
|
|
129
|
+
};
|
|
130
|
+
};
|
|
131
|
+
|
|
107
132
|
bot.command("mesh", async (ctx) => {
|
|
133
|
+
const arg = (ctx.match ?? "").toString().trim();
|
|
134
|
+
// `/mesh link` mints a bridge credential and posts it into the chat, so
|
|
135
|
+
// it is admin-gated even though plain `/mesh` is not — reading the fleet
|
|
136
|
+
// is not the same act as handing out the keys to it.
|
|
137
|
+
if (/^(link|pair)\b/i.test(arg)) {
|
|
138
|
+
if (!isAuthorizedAdmin(ctx)) {
|
|
139
|
+
await ctx.reply("Only the configured admin can mint a pairing link.");
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
// `/mesh link Car` names the connection on the phone; with no name it
|
|
143
|
+
// inherits the bot's, which is what the operator already calls this
|
|
144
|
+
// daemon everywhere else.
|
|
145
|
+
const named = arg.replace(/^(link|pair)\b/i, "").trim();
|
|
146
|
+
const minted = getMeshService().makeCompanionPairLink(
|
|
147
|
+
named || ctx.me.first_name,
|
|
148
|
+
);
|
|
149
|
+
await ctx.reply(renderMeshPairLink(minted), {
|
|
150
|
+
parse_mode: "HTML",
|
|
151
|
+
link_preview_options: { is_disabled: true },
|
|
152
|
+
});
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
108
156
|
const sent = await ctx.reply("Pinging mesh devices…");
|
|
109
157
|
let results: MeshPingResult[];
|
|
110
158
|
try {
|
|
@@ -122,7 +170,7 @@ export function registerInfoCommands(bot: Bot): void {
|
|
|
122
170
|
bot,
|
|
123
171
|
ctx.chat.id,
|
|
124
172
|
sent.message_id,
|
|
125
|
-
renderMeshReport(results),
|
|
173
|
+
renderMeshReport(results, Date.now(), bridgeFor(ctx)),
|
|
126
174
|
);
|
|
127
175
|
});
|
|
128
176
|
|
|
@@ -310,9 +310,18 @@ function meshDeviceLine(r: MeshPingResult, now: number): string {
|
|
|
310
310
|
export function renderMeshReport(
|
|
311
311
|
results: MeshPingResult[],
|
|
312
312
|
now = Date.now(),
|
|
313
|
+
bridge?: MeshReachability,
|
|
313
314
|
): string {
|
|
314
315
|
if (results.length === 0) {
|
|
315
|
-
|
|
316
|
+
// An empty fleet is the one case where "what do I point a device at?" is
|
|
317
|
+
// the only useful thing to say, so the bridge line carries the answer
|
|
318
|
+
// instead of leaving the operator to hunt for host and port.
|
|
319
|
+
return [
|
|
320
|
+
"<b>Mesh</b>",
|
|
321
|
+
"",
|
|
322
|
+
"<i>No devices have registered yet.</i>",
|
|
323
|
+
...bridgeLines(bridge),
|
|
324
|
+
].join("\n");
|
|
316
325
|
}
|
|
317
326
|
|
|
318
327
|
const responding = results
|
|
@@ -344,6 +353,76 @@ export function renderMeshReport(
|
|
|
344
353
|
section("Responding", responding);
|
|
345
354
|
section("Unreachable", unreachable);
|
|
346
355
|
section("Offline", offline);
|
|
356
|
+
lines.push(...bridgeLines(bridge));
|
|
347
357
|
|
|
348
358
|
return lines.join("\n");
|
|
349
359
|
}
|
|
360
|
+
|
|
361
|
+
/** What `/mesh` says about the bridge a new device would dial. */
|
|
362
|
+
export type MeshReachability =
|
|
363
|
+
| {
|
|
364
|
+
ok: true;
|
|
365
|
+
url: string;
|
|
366
|
+
authRequired: boolean;
|
|
367
|
+
token?: string;
|
|
368
|
+
fingerprint?: string;
|
|
369
|
+
}
|
|
370
|
+
| { ok: false; text: string };
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* The bridge footer.
|
|
374
|
+
*
|
|
375
|
+
* The bearer token and certificate appear when the caller passed them, which
|
|
376
|
+
* `/mesh` does for the configured admin and not for anyone else: an operator
|
|
377
|
+
* asking their own daemon how to reach itself should get the answer, while a
|
|
378
|
+
* group member reading the fleet has no business holding the key to it.
|
|
379
|
+
*/
|
|
380
|
+
function bridgeLines(bridge?: MeshReachability): string[] {
|
|
381
|
+
if (!bridge) return [];
|
|
382
|
+
if (!bridge.ok) return ["", `<b>Bridge</b> — ${escapeHtml(bridge.text)}`];
|
|
383
|
+
const auth = bridge.authRequired ? "token required" : "no token";
|
|
384
|
+
return [
|
|
385
|
+
"",
|
|
386
|
+
`<b>Bridge</b> <code>${escapeHtml(bridge.url)}</code> · ${auth}`,
|
|
387
|
+
...(bridge.token ? [`Token <code>${escapeHtml(bridge.token)}</code>`] : []),
|
|
388
|
+
...(bridge.fingerprint
|
|
389
|
+
? [`Certificate <code>${escapeHtml(bridge.fingerprint)}</code>`]
|
|
390
|
+
: []),
|
|
391
|
+
"Run <code>/mesh link</code> for a one-tap pairing link.",
|
|
392
|
+
];
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Render a minted pairing link, or why one couldn't be minted.
|
|
397
|
+
*
|
|
398
|
+
* The values are printed alongside the link because the link's whole payoff
|
|
399
|
+
* — the phone configuring itself — depends on the companion already being
|
|
400
|
+
* installed, and the fallback needs to be right there when it isn't.
|
|
401
|
+
*/
|
|
402
|
+
export function renderMeshPairLink(
|
|
403
|
+
minted:
|
|
404
|
+
| {
|
|
405
|
+
ok: true;
|
|
406
|
+
link: string;
|
|
407
|
+
url: string;
|
|
408
|
+
token: string;
|
|
409
|
+
fingerprint?: string;
|
|
410
|
+
}
|
|
411
|
+
| { ok: false; text: string },
|
|
412
|
+
): string {
|
|
413
|
+
if (!minted.ok) {
|
|
414
|
+
return `<b>Pairing</b>\n\n${escapeHtml(minted.text)}`;
|
|
415
|
+
}
|
|
416
|
+
return [
|
|
417
|
+
"<b>Pair a device</b>",
|
|
418
|
+
"",
|
|
419
|
+
`Open on the phone: ${escapeHtml(minted.link)}`,
|
|
420
|
+
"",
|
|
421
|
+
"Single-use, expires in 10 minutes. If the companion isn't installed yet:",
|
|
422
|
+
` Bridge <code>${escapeHtml(minted.url)}</code>`,
|
|
423
|
+
` Token <code>${escapeHtml(minted.token)}</code>`,
|
|
424
|
+
...(minted.fingerprint
|
|
425
|
+
? [` Certificate <code>${escapeHtml(minted.fingerprint)}</code>`]
|
|
426
|
+
: []),
|
|
427
|
+
].join("\n");
|
|
428
|
+
}
|