total-recall-brain 3.22.0 → 3.23.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/bin/total-recall.mjs +2 -0
- package/frontend/src/api/headscale.ts +68 -1
- package/frontend/src/api/mesh.spec.ts +27 -0
- package/frontend/src/api/mesh.ts +85 -0
- package/frontend/src/components/EmbeddingProviderPanel.tsx +21 -3
- package/frontend/src/pages/MeshPage.spec.tsx +164 -0
- package/frontend/src/pages/MeshPage.tsx +294 -6
- package/frontend/src/pages/NetworkPage.spec.tsx +19 -4
- package/frontend/src/pages/OnboardingPage.spec.tsx +14 -2
- package/frontend/src/pages/SkillsPage.spec.tsx +1 -1
- package/package.json +2 -1
- package/scaffold/.agent/skills/total-recall/references/api-reference.md +1 -1
- package/scaffold/.agent/skills/total-recall/references/cli-reference.md +2 -1
- package/scaffold/.agent/skills/total-recall/skills-registry/index.yaml +6 -2574
- package/src/cli/mesh.mjs +416 -0
- package/src/cli/repo-expert-generate.mjs +5 -1
- package/src/cli/reset-password.mjs +75 -6
- package/src/cli/secret.mjs +250 -7
- package/src/core/browser-session.mjs +68 -13
- package/src/core/headscale-client.mjs +87 -0
- package/src/core/mesh-access.mjs +261 -0
- package/src/core/mesh-enroll.mjs +74 -32
- package/src/core/mesh.mjs +164 -12
- package/src/core/schema.mjs +36 -0
- package/src/core/secrets-keychain.mjs +83 -0
- package/src/core/secrets-rekey.mjs +0 -0
- package/src/core/secrets-store.mjs +26 -6
- package/src/core/tailscale-cli.mjs +69 -0
- package/src/server/index.mjs +23 -4
- package/src/server/pid-lock.mjs +64 -0
- package/src/server/route-inventory.mjs +62 -0
- package/src/server/route-manifest.json +20 -0
- package/src/server/routes/headscale.mjs +32 -0
- package/src/server/routes/mesh.mjs +118 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Express route inventory.
|
|
3
|
+
*
|
|
4
|
+
* The committed `route-manifest.json` is a tripwire for accidental route
|
|
5
|
+
* changes: anything added or removed shows up as a failing spec rather than as
|
|
6
|
+
* a silent change to the public API surface.
|
|
7
|
+
*
|
|
8
|
+
* Extraction lives here, rather than inside the spec, because the manifest has
|
|
9
|
+
* to be regenerated whenever a route legitimately changes. When the spec owned
|
|
10
|
+
* the only copy of this logic the manifest could only be updated by hand, which
|
|
11
|
+
* is how two real routes came to be missing from it — the tripwire failed, and
|
|
12
|
+
* the failure was invisible to anyone running a subset of the suite.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
|
|
18
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
|
|
20
|
+
/** The single source of truth both the spec and the generator read/write. */
|
|
21
|
+
export const MANIFEST_PATH = path.join(__dirname, 'route-manifest.json');
|
|
22
|
+
|
|
23
|
+
/** Flatten an Express router stack into `{ method, path }` entries. */
|
|
24
|
+
export function extractRoutes(router) {
|
|
25
|
+
const routes = [];
|
|
26
|
+
|
|
27
|
+
function traverse(stack) {
|
|
28
|
+
if (!stack || !Array.isArray(stack)) return;
|
|
29
|
+
|
|
30
|
+
for (const layer of stack) {
|
|
31
|
+
if (layer.route) {
|
|
32
|
+
const pathStr = layer.route.path;
|
|
33
|
+
const methods = Object.keys(layer.route.methods)
|
|
34
|
+
.filter((method) => layer.route.methods[method])
|
|
35
|
+
.map((method) => method.toUpperCase());
|
|
36
|
+
|
|
37
|
+
for (const method of methods) {
|
|
38
|
+
routes.push({ method, path: pathStr });
|
|
39
|
+
}
|
|
40
|
+
} else if (layer.name === 'router' && layer.handle && layer.handle.stack) {
|
|
41
|
+
// Sub-router mounted via router.use()
|
|
42
|
+
traverse(layer.handle.stack);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
traverse(router.stack);
|
|
48
|
+
return routes;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Order routes deterministically.
|
|
53
|
+
*
|
|
54
|
+
* Router registration order is an implementation detail that shifts whenever
|
|
55
|
+
* imports are reordered, so both sides sort before comparing.
|
|
56
|
+
*/
|
|
57
|
+
export function sortRoutes(routes) {
|
|
58
|
+
return [...routes].sort((a, b) => {
|
|
59
|
+
const byPath = a.path.localeCompare(b.path);
|
|
60
|
+
return byPath !== 0 ? byPath : a.method.localeCompare(b.method);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
@@ -187,6 +187,14 @@
|
|
|
187
187
|
"method": "DELETE",
|
|
188
188
|
"path": "/api/headscale/node/:id"
|
|
189
189
|
},
|
|
190
|
+
{
|
|
191
|
+
"method": "GET",
|
|
192
|
+
"path": "/api/headscale/policy"
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
"method": "PUT",
|
|
196
|
+
"path": "/api/headscale/policy"
|
|
197
|
+
},
|
|
190
198
|
{
|
|
191
199
|
"method": "GET",
|
|
192
200
|
"path": "/api/headscale/preauthkey"
|
|
@@ -275,6 +283,18 @@
|
|
|
275
283
|
"method": "GET",
|
|
276
284
|
"path": "/api/memory/stats"
|
|
277
285
|
},
|
|
286
|
+
{
|
|
287
|
+
"method": "POST",
|
|
288
|
+
"path": "/api/mesh/access"
|
|
289
|
+
},
|
|
290
|
+
{
|
|
291
|
+
"method": "POST",
|
|
292
|
+
"path": "/api/mesh/access/import"
|
|
293
|
+
},
|
|
294
|
+
{
|
|
295
|
+
"method": "GET",
|
|
296
|
+
"path": "/api/mesh/access/proposals"
|
|
297
|
+
},
|
|
278
298
|
{
|
|
279
299
|
"method": "GET",
|
|
280
300
|
"path": "/api/mesh/election/history"
|
|
@@ -5,6 +5,8 @@ import { logger } from '../../core/logger.mjs';
|
|
|
5
5
|
import {
|
|
6
6
|
headscaleFetch,
|
|
7
7
|
headscaleFetchWithLegacyFallback,
|
|
8
|
+
getHeadscalePolicy,
|
|
9
|
+
setHeadscalePolicy,
|
|
8
10
|
} from '../../core/headscale-client.mjs';
|
|
9
11
|
|
|
10
12
|
const router = express.Router();
|
|
@@ -79,4 +81,34 @@ router.get('/api/headscale/user', requireScope('config:read'), async (req, res)
|
|
|
79
81
|
}
|
|
80
82
|
});
|
|
81
83
|
|
|
84
|
+
// The ACL policy is what lets the control server authorise SSH itself instead
|
|
85
|
+
// of every host falling back to its own authorized_keys. Requires the control
|
|
86
|
+
// server to run with `policy.mode: database`.
|
|
87
|
+
router.get('/api/headscale/policy', requireScope('config:read'), async (req, res) => {
|
|
88
|
+
try {
|
|
89
|
+
res.json(await getHeadscalePolicy(BRAIN_DIR));
|
|
90
|
+
} catch (err) {
|
|
91
|
+
logger.error('headscale', `GET /policy failed: ${err.message}`);
|
|
92
|
+
// A file-mode server is a configuration problem the caller can fix, so it
|
|
93
|
+
// is a 409 rather than an opaque 500.
|
|
94
|
+
const status = err.code === 'POLICY_MODE_FILE' ? 409 : 500;
|
|
95
|
+
res.status(status).json({ error: err.message, code: err.code });
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
router.put('/api/headscale/policy', requireScope('config:write'), async (req, res) => {
|
|
100
|
+
try {
|
|
101
|
+
const policy = req.body?.policy;
|
|
102
|
+
if (!policy) {
|
|
103
|
+
res.status(400).json({ error: 'A `policy` field is required' });
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
res.json(await setHeadscalePolicy(policy, BRAIN_DIR));
|
|
107
|
+
} catch (err) {
|
|
108
|
+
logger.error('headscale', `PUT /policy failed: ${err.message}`);
|
|
109
|
+
const status = err.code === 'POLICY_MODE_FILE' ? 409 : 500;
|
|
110
|
+
res.status(status).json({ error: err.message, code: err.code });
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
82
114
|
export default router;
|
|
@@ -9,7 +9,13 @@ import {
|
|
|
9
9
|
listMeshNodeEntities,
|
|
10
10
|
attachSelfInterfaces,
|
|
11
11
|
normalizeHostname,
|
|
12
|
+
setMeshNodeAccess,
|
|
12
13
|
} from '../../core/mesh.mjs';
|
|
14
|
+
import {
|
|
15
|
+
proposeAccessFromSshConfig,
|
|
16
|
+
readSshConfig,
|
|
17
|
+
resolveNodeAccess,
|
|
18
|
+
} from '../../core/mesh-access.mjs';
|
|
13
19
|
import { throttledFetch } from '../../core/throttled-fetch.mjs';
|
|
14
20
|
import { defaultVaultRoot } from '../../core/vfs-documents.mjs';
|
|
15
21
|
import {
|
|
@@ -104,13 +110,125 @@ router.get('/api/mesh/nodes', requireAuth, requireScope('config:read'), async (r
|
|
|
104
110
|
} catch {
|
|
105
111
|
// io optional
|
|
106
112
|
}
|
|
113
|
+
// Resolve "how do I actually reach this" server-side. The precedence rules
|
|
114
|
+
// (explicit host over mesh address over LAN address) are domain logic; having
|
|
115
|
+
// the UI reimplement them is how two answers to the same question appear.
|
|
116
|
+
nodes = nodes.map((node) => ({ ...node, access_resolved: resolveNodeAccess(node) }));
|
|
107
117
|
const entities = listMeshNodeEntities(vaultRoot);
|
|
108
118
|
res.json({
|
|
109
119
|
nodes,
|
|
110
120
|
entity_count: entities.length,
|
|
121
|
+
missing_access_count: nodes.filter((node) => !node.access_resolved.complete).length,
|
|
111
122
|
});
|
|
112
123
|
});
|
|
113
124
|
|
|
125
|
+
/**
|
|
126
|
+
* Turn a request body into an access patch.
|
|
127
|
+
*
|
|
128
|
+
* A key that is present but empty clears the stored value; a key that is absent
|
|
129
|
+
* leaves it untouched. Without that distinction a value could be set from the
|
|
130
|
+
* UI but never taken back.
|
|
131
|
+
*/
|
|
132
|
+
function accessPatchFromBody(body = {}) {
|
|
133
|
+
const patch = { source: 'manual' };
|
|
134
|
+
const text = (value) => String(value ?? '').trim() || null;
|
|
135
|
+
if ('ssh_user' in body) patch.ssh_user = text(body.ssh_user);
|
|
136
|
+
if ('ssh_host' in body) patch.ssh_host = text(body.ssh_host);
|
|
137
|
+
if ('identity_file' in body) patch.identity_file = text(body.identity_file);
|
|
138
|
+
if ('ssh_port' in body) {
|
|
139
|
+
const raw = text(body.ssh_port);
|
|
140
|
+
patch.ssh_port = raw === null ? null : Number.parseInt(raw, 10);
|
|
141
|
+
}
|
|
142
|
+
return patch;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Record how to reach a node — the one fact the control server cannot supply.
|
|
147
|
+
* Body: { node, ssh_user?, ssh_port?, ssh_host?, identity_file? }
|
|
148
|
+
*/
|
|
149
|
+
router.post('/api/mesh/access', requireAuth, requireScope('config:write'), async (req, res) => {
|
|
150
|
+
const target = String(req.body?.node || '').trim();
|
|
151
|
+
if (!target) {
|
|
152
|
+
res.status(400).json({ success: false, message: 'node is required' });
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const patch = accessPatchFromBody(req.body || {});
|
|
157
|
+
if (patch.ssh_port != null && !(Number.isInteger(patch.ssh_port) && patch.ssh_port > 0 && patch.ssh_port <= 65535)) {
|
|
158
|
+
res.status(400).json({ success: false, message: 'ssh_port must be between 1 and 65535' });
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
const result = await setMeshNodeAccess(target, patch, { vaultRoot: defaultVaultRoot() });
|
|
164
|
+
if (!result.written) {
|
|
165
|
+
const notFound = result.reason === 'node-not-found';
|
|
166
|
+
res.status(notFound ? 404 : 500).json({
|
|
167
|
+
success: false,
|
|
168
|
+
message: notFound ? `No mesh node matches "${target}".` : 'The vault write was rejected.',
|
|
169
|
+
reason: result.reason || null,
|
|
170
|
+
});
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
res.json({ success: true, path: result.path, access: result.access });
|
|
174
|
+
} catch (err) {
|
|
175
|
+
res.status(500).json({ success: false, message: err.message || 'access write failed' });
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Which nodes could be given a login account from this host's ssh config.
|
|
181
|
+
*
|
|
182
|
+
* Read-only, and deliberately separate from applying: importing a config is a
|
|
183
|
+
* judgement call, and a wrong login written to the vault is worse than none at
|
|
184
|
+
* all — it fails exactly like an unreachable host, only now it looks authored.
|
|
185
|
+
*/
|
|
186
|
+
router.get('/api/mesh/access/proposals', requireAuth, requireScope('config:read'), async (_req, res) => {
|
|
187
|
+
try {
|
|
188
|
+
const nodes = listEnrichedMeshNodes(defaultVaultRoot());
|
|
189
|
+
res.json({
|
|
190
|
+
proposals: proposeAccessFromSshConfig(nodes, readSshConfig()),
|
|
191
|
+
missing_access: nodes
|
|
192
|
+
.filter((node) => !resolveNodeAccess(node).complete)
|
|
193
|
+
.map((node) => node.hostname),
|
|
194
|
+
checked_at: new Date().toISOString(),
|
|
195
|
+
});
|
|
196
|
+
} catch (err) {
|
|
197
|
+
res.status(500).json({ success: false, message: err.message || 'access proposal failed' });
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
/** Apply every proposal from this host's ssh config to the node entities. */
|
|
202
|
+
router.post('/api/mesh/access/import', requireAuth, requireScope('config:write'), async (_req, res) => {
|
|
203
|
+
try {
|
|
204
|
+
const vaultRoot = defaultVaultRoot();
|
|
205
|
+
const proposals = proposeAccessFromSshConfig(listEnrichedMeshNodes(vaultRoot), readSshConfig());
|
|
206
|
+
const results = [];
|
|
207
|
+
for (const proposal of proposals) {
|
|
208
|
+
const result = await setMeshNodeAccess(proposal.hostname, proposal.access, { vaultRoot });
|
|
209
|
+
results.push({
|
|
210
|
+
hostname: proposal.hostname,
|
|
211
|
+
ssh_user: proposal.access.ssh_user,
|
|
212
|
+
matched_host: proposal.matched_host,
|
|
213
|
+
written: Boolean(result.written),
|
|
214
|
+
reason: result.reason || null,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
const saved = results.filter((r) => r.written).length;
|
|
218
|
+
// Counting attempts as successes is how a failed write once got announced
|
|
219
|
+
// as a success; the caller gets both numbers.
|
|
220
|
+
res.json({
|
|
221
|
+
success: saved === proposals.length,
|
|
222
|
+
attempted: proposals.length,
|
|
223
|
+
saved,
|
|
224
|
+
failed: proposals.length - saved,
|
|
225
|
+
results,
|
|
226
|
+
});
|
|
227
|
+
} catch (err) {
|
|
228
|
+
res.status(500).json({ success: false, message: err.message || 'access import failed' });
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
|
|
114
232
|
/**
|
|
115
233
|
* Enrollment state of THIS node on the mesh control server.
|
|
116
234
|
* Reports whether an unattended enrollment is possible and, when it is not,
|