@augmentd-labs/canvas-edge 0.4.1 → 0.5.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/README.md +9 -0
- package/package.json +4 -3
- package/src/EdgeClient.js +21 -13
- package/src/env.js +4 -2
- package/src/mirror-runtime.js +10 -4
- package/src/remote-config.js +5 -2
- package/src/runtime.js +1 -0
package/README.md
CHANGED
|
@@ -75,3 +75,12 @@ mirrors.json). `CANVAS_DIRECTION=pull` makes the container a backup target
|
|
|
75
75
|
|
|
76
76
|
Protocol: canvas-server `docs/canvas-edge-protocol.md` (tunnel) and
|
|
77
77
|
`docs/sync-protocol.md` (file plane).
|
|
78
|
+
|
|
79
|
+
### Client certificates (0.5.0+)
|
|
80
|
+
|
|
81
|
+
Hub remotes retain `tls: { certFile, keyFile }` from the shared client config.
|
|
82
|
+
Workspace remotes can carry the same optional fields. Pairing, tunnel sockets,
|
|
83
|
+
mirror status calls, and object transfers all use the identity. Container bootstrap
|
|
84
|
+
accepts `CANVAS_TLS_CERT`/`CANVAS_TLS_KEY` as a complete pair of file paths; mount
|
|
85
|
+
these files read-only. Use leaf-first PEM chains and protected unencrypted RSA/EC
|
|
86
|
+
keys. Server verification is enabled. Restart the daemon/container after renewal.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@augmentd-labs/canvas-edge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Canvas device runtime: folder mirrors (canvas-stored Mirror engine) + edge tunnel client (dist artifact)",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"type": "module",
|
|
@@ -36,7 +36,8 @@
|
|
|
36
36
|
"picomatch": "^2.3.1",
|
|
37
37
|
"fastify": "^4.29.1",
|
|
38
38
|
"pino": "^10.3.1",
|
|
39
|
-
"socket.io-client": "^4.8.3"
|
|
39
|
+
"socket.io-client": "^4.8.3",
|
|
40
|
+
"@augmentd-labs/canvas-api-client": "^0.4.0"
|
|
40
41
|
},
|
|
41
42
|
"repository": {
|
|
42
43
|
"type": "git",
|
|
@@ -46,6 +47,6 @@
|
|
|
46
47
|
"bundleDependencies": [
|
|
47
48
|
"canvas-stored"
|
|
48
49
|
],
|
|
49
|
-
"canvasRev": "
|
|
50
|
+
"canvasRev": "61917c2",
|
|
50
51
|
"canvasSource": "runtimes/edge"
|
|
51
52
|
}
|
package/src/EdgeClient.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
import { createTlsTransport, resolveTls } from '@augmentd-labs/canvas-api-client/tls';
|
|
3
4
|
import { io } from 'socket.io-client';
|
|
4
5
|
|
|
5
6
|
const CHUNK_SIZE = 256 * 1024;
|
|
@@ -15,6 +16,7 @@ const CHUNK_SIZE = 256 * 1024;
|
|
|
15
16
|
* See docs/canvas-edge-protocol.md for the frame protocol.
|
|
16
17
|
*/
|
|
17
18
|
export default class EdgeClient {
|
|
19
|
+
#transport;
|
|
18
20
|
#serverUrl;
|
|
19
21
|
#token;
|
|
20
22
|
#localApp;
|
|
@@ -23,10 +25,11 @@ export default class EdgeClient {
|
|
|
23
25
|
#announced = false;
|
|
24
26
|
#forwarded = [];
|
|
25
27
|
|
|
26
|
-
constructor({ serverUrl, token, localApp, announce }) {
|
|
28
|
+
constructor({ serverUrl, token, localApp, announce, tls }) {
|
|
27
29
|
if (!serverUrl || !token || !localApp?.inject || !announce?.instanceId) {
|
|
28
30
|
throw new Error('EdgeClient requires serverUrl, token, localApp (fastify) and announce.instanceId');
|
|
29
31
|
}
|
|
32
|
+
this.#transport = createTlsTransport(serverUrl, resolveTls(tls));
|
|
30
33
|
this.#serverUrl = serverUrl.replace(/\/+$/, '');
|
|
31
34
|
this.#token = token;
|
|
32
35
|
this.#localApp = localApp;
|
|
@@ -38,17 +41,20 @@ export default class EdgeClient {
|
|
|
38
41
|
* existing device registration endpoint. Persist the result locally and
|
|
39
42
|
* never touch the user token again.
|
|
40
43
|
*/
|
|
41
|
-
static async pair({ serverUrl, userToken, name, type = 'edge', ...deviceInfo }) {
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
44
|
+
static async pair({ serverUrl, userToken, name, type = 'edge', tls, ...deviceInfo }) {
|
|
45
|
+
const transport = createTlsTransport(serverUrl, resolveTls(tls));
|
|
46
|
+
try {
|
|
47
|
+
const res = await transport.fetch(`${serverUrl.replace(/\/+$/, '')}/rest/v2/auth/devices/register`, {
|
|
48
|
+
method: 'POST',
|
|
49
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${userToken}` },
|
|
50
|
+
body: JSON.stringify({ name, type, ...deviceInfo }),
|
|
51
|
+
});
|
|
52
|
+
const json = await res.json().catch(() => null);
|
|
53
|
+
if (!res.ok || !json?.payload?.token) {
|
|
54
|
+
throw new Error(`Edge pairing failed: ${json?.message || res.statusText}`);
|
|
55
|
+
}
|
|
56
|
+
return { token: json.payload.token, deviceId: json.payload.deviceId || json.payload.id };
|
|
57
|
+
} finally { await transport.dispose(); }
|
|
52
58
|
}
|
|
53
59
|
|
|
54
60
|
get connected() {
|
|
@@ -58,8 +64,9 @@ export default class EdgeClient {
|
|
|
58
64
|
connect() {
|
|
59
65
|
if (this.#socket) return this;
|
|
60
66
|
this.#socket = io(this.#serverUrl, {
|
|
67
|
+
...this.#transport.socketOptions,
|
|
61
68
|
auth: { token: this.#token },
|
|
62
|
-
transports: ['websocket'],
|
|
69
|
+
transports: this.#transport.socketOptions.transports || ['websocket'],
|
|
63
70
|
});
|
|
64
71
|
// Announce on every (re)connect — announce is idempotent full state.
|
|
65
72
|
this.#socket.on('connect', () => this.#socket.emit('edge:announce', this.#announce));
|
|
@@ -121,6 +128,7 @@ export default class EdgeClient {
|
|
|
121
128
|
}
|
|
122
129
|
|
|
123
130
|
close() {
|
|
131
|
+
void this.#transport.dispose().catch(() => {});
|
|
124
132
|
for (const [emitter, listener] of this.#forwarded) emitter.off('**', listener);
|
|
125
133
|
this.#forwarded = [];
|
|
126
134
|
this.#socket?.disconnect();
|
package/src/env.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
import { resolveTls } from '@augmentd-labs/canvas-api-client/tls';
|
|
3
4
|
import fs from 'fs';
|
|
4
5
|
import os from 'os';
|
|
5
6
|
import path from 'path';
|
|
@@ -81,7 +82,7 @@ export function ensureEnvConfig(env = process.env) {
|
|
|
81
82
|
const prior = remotes[hubId] || {};
|
|
82
83
|
// A device token minted for this remote stays authoritative; env only
|
|
83
84
|
// seeds the user/API token it started from.
|
|
84
|
-
remotes[hubId] = { ...prior, url, apiBase: prior.apiBase || '/rest/v2', auth: { ...(prior.auth || {}), token }, source: 'env' };
|
|
85
|
+
remotes[hubId] = { ...prior, url, apiBase: prior.apiBase || '/rest/v2', auth: { ...(prior.auth || {}), token }, source: 'env', ...(resolveTls(prior.url && new URL(prior.url).origin !== new URL(url).origin ? undefined : prior.tls, env) ? { tls: resolveTls(prior.url && new URL(prior.url).origin !== new URL(url).origin ? undefined : prior.tls, env) } : { tls: undefined }) };
|
|
85
86
|
writeJson(EDGE_PATHS.remotes, remotes);
|
|
86
87
|
|
|
87
88
|
const cfg = readJson(EDGE_PATHS.mirrors, { version: 1, mirrors: [] }) || { version: 1, mirrors: [] };
|
|
@@ -142,7 +143,7 @@ export async function ensureDeviceToken(remoteId, { logger = null } = {}) {
|
|
|
142
143
|
if (!r?.url || r.source !== 'env' || r.device?.token || !r.auth?.token) return hubFor(remoteId);
|
|
143
144
|
const identity = deviceIdentity(null);
|
|
144
145
|
const paired = await EdgeClient.pair({
|
|
145
|
-
serverUrl: r.url, userToken: r.auth.token, name: identity.deviceName, type: 'edge',
|
|
146
|
+
serverUrl: r.url, tls: resolveTls(r.tls), userToken: r.auth.token, name: identity.deviceName, type: 'edge',
|
|
146
147
|
deviceId: identity.deviceId, hostname: os.hostname(), platform: process.platform, arch: process.arch,
|
|
147
148
|
});
|
|
148
149
|
remotes[remoteId] = { ...r, device: { deviceId: paired.deviceId || identity.deviceId, token: paired.token, registeredAt: new Date().toISOString() } };
|
|
@@ -161,6 +162,7 @@ export function hubFor(remoteId) {
|
|
|
161
162
|
return {
|
|
162
163
|
id: remoteId,
|
|
163
164
|
url: String(r.url).replace(/\/+$/, ''),
|
|
165
|
+
tls: resolveTls(r.tls),
|
|
164
166
|
apiBase: r.apiBase || '/rest/v2',
|
|
165
167
|
token,
|
|
166
168
|
deviceId: deviceToken && r.device?.deviceId ? String(r.device.deviceId) : null,
|
package/src/mirror-runtime.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
import { createTlsTransport } from '@augmentd-labs/canvas-api-client/tls';
|
|
3
4
|
import fs from 'fs';
|
|
4
5
|
import path from 'path';
|
|
5
6
|
import { io } from 'socket.io-client';
|
|
@@ -29,6 +30,7 @@ const STATUS_HEARTBEAT_MS = 60000;
|
|
|
29
30
|
* ledger as `full` so a hub that lost its replica table rebuilds it.
|
|
30
31
|
*/
|
|
31
32
|
export class MirrorRuntime {
|
|
33
|
+
#transport;
|
|
32
34
|
#mirror;
|
|
33
35
|
#hub;
|
|
34
36
|
#identity;
|
|
@@ -45,6 +47,7 @@ export class MirrorRuntime {
|
|
|
45
47
|
constructor({ mirror, hub, identity, logger }) {
|
|
46
48
|
this.#mirror = mirror;
|
|
47
49
|
this.#hub = hub;
|
|
50
|
+
this.#transport = createTlsTransport(hub.url, hub.tls);
|
|
48
51
|
this.#identity = identity;
|
|
49
52
|
this.#logger = logger;
|
|
50
53
|
}
|
|
@@ -87,6 +90,7 @@ export class MirrorRuntime {
|
|
|
87
90
|
this.#stored.addBackend('trash', { driver: 'file', root: path.join(internal, 'trash'), watch: false });
|
|
88
91
|
this.#stored.addBackend('conflicts', { driver: 'file', root: path.join(internal, 'conflicts'), watch: false });
|
|
89
92
|
this.#stored.addBackend('canvas:hub', {
|
|
93
|
+
fetch: this.#transport.fetch,
|
|
90
94
|
driver: 'canvas', url: this.#hub.url, apiBase: this.#hub.apiBase, token: this.#hub.token,
|
|
91
95
|
workspaceId: this.#mirror.workspaceId || this.#mirror.workspaceName, backend: 'workspace:home',
|
|
92
96
|
deviceId: this.#identity.deviceId, deviceName: this.#identity.deviceName, prefixes: this.#mirror.pins || [], pollInterval: 30000,
|
|
@@ -113,6 +117,7 @@ export class MirrorRuntime {
|
|
|
113
117
|
}
|
|
114
118
|
|
|
115
119
|
async stop() {
|
|
120
|
+
// Close pooled TLS connections after stopping mirror activity below.
|
|
116
121
|
clearInterval(this.#reporter);
|
|
117
122
|
clearTimeout(this.#reportTimer);
|
|
118
123
|
this.#socket?.close();
|
|
@@ -121,6 +126,7 @@ export class MirrorRuntime {
|
|
|
121
126
|
await this.#stored?.stop().catch(() => {});
|
|
122
127
|
this.#engine = null;
|
|
123
128
|
this.#stored = null;
|
|
129
|
+
await this.#transport.dispose();
|
|
124
130
|
}
|
|
125
131
|
|
|
126
132
|
status() {
|
|
@@ -133,7 +139,7 @@ export class MirrorRuntime {
|
|
|
133
139
|
|
|
134
140
|
async #fetchHubExclusions() {
|
|
135
141
|
try {
|
|
136
|
-
const res = await fetch(`${this.#hub.url}${this.#hub.apiBase}/workspaces/${encodeURIComponent(this.#mirror.workspaceName)}/backends/file/workspace%3Ahome`, {
|
|
142
|
+
const res = await this.#transport.fetch(`${this.#hub.url}${this.#hub.apiBase}/workspaces/${encodeURIComponent(this.#mirror.workspaceName)}/backends/file/workspace%3Ahome`, {
|
|
137
143
|
headers: { authorization: `Bearer ${this.#hub.token}` },
|
|
138
144
|
});
|
|
139
145
|
const json = await res.json();
|
|
@@ -147,7 +153,7 @@ export class MirrorRuntime {
|
|
|
147
153
|
async #resolveWorkspaceId() {
|
|
148
154
|
if (this.#mirror.workspaceId && /^[0-9a-f-]{36}$/i.test(this.#mirror.workspaceId)) return this.#mirror.workspaceId;
|
|
149
155
|
try {
|
|
150
|
-
const res = await fetch(`${this.#hub.url}${this.#hub.apiBase}/workspaces/${encodeURIComponent(this.#mirror.workspaceName)}`, { headers: { authorization: `Bearer ${this.#hub.token}` } });
|
|
156
|
+
const res = await this.#transport.fetch(`${this.#hub.url}${this.#hub.apiBase}/workspaces/${encodeURIComponent(this.#mirror.workspaceName)}`, { headers: { authorization: `Bearer ${this.#hub.token}` } });
|
|
151
157
|
const json = await res.json();
|
|
152
158
|
return json?.payload?.workspace?.id || json?.payload?.id || this.#mirror.workspaceId || this.#mirror.workspaceName;
|
|
153
159
|
} catch { return this.#mirror.workspaceId || this.#mirror.workspaceName; }
|
|
@@ -156,7 +162,7 @@ export class MirrorRuntime {
|
|
|
156
162
|
async #connectSocket() {
|
|
157
163
|
try {
|
|
158
164
|
const wsId = await this.#resolveWorkspaceId();
|
|
159
|
-
const socket = io(this.#hub.url, { auth: { token: this.#hub.token }, transports: ['websocket'], reconnection: true, reconnectionDelay: 1000, reconnectionDelayMax: 30000 });
|
|
165
|
+
const socket = io(this.#hub.url, { ...this.#transport.socketOptions, auth: { token: this.#hub.token }, transports: this.#transport.socketOptions.transports || ['websocket'], reconnection: true, reconnectionDelay: 1000, reconnectionDelayMax: 30000 });
|
|
160
166
|
this.#socket = socket;
|
|
161
167
|
socket.on('connect', () => { socket.emit('subscribe', { channel: `workspace:${wsId}` }); this.resync().catch(() => {}); });
|
|
162
168
|
socket.on('backend.changed', (e) => { if (!e?.backend || e.backend === 'workspace:home') this.nudge(); });
|
|
@@ -188,7 +194,7 @@ export class MirrorRuntime {
|
|
|
188
194
|
this.#pendingApplied = full ? [] : mergePairs(this.#pendingApplied, delta);
|
|
189
195
|
const applied = full ? (typeof engine.appliedSnapshot === 'function' ? engine.appliedSnapshot() : []) : this.#pendingApplied;
|
|
190
196
|
try {
|
|
191
|
-
const res = await fetch(`${this.#hub.url}${this.#hub.apiBase}/workspaces/${ws}/mirrors/${encodeURIComponent(this.#identity.deviceId)}/status`, {
|
|
197
|
+
const res = await this.#transport.fetch(`${this.#hub.url}${this.#hub.apiBase}/workspaces/${ws}/mirrors/${encodeURIComponent(this.#identity.deviceId)}/status`, {
|
|
192
198
|
method: 'POST',
|
|
193
199
|
headers: { authorization: `Bearer ${this.#hub.token}`, 'content-type': 'application/json' },
|
|
194
200
|
body: JSON.stringify({
|
package/src/remote-config.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
import { normalizeTls } from '@augmentd-labs/canvas-api-client/tls';
|
|
3
4
|
import fs from 'node:fs';
|
|
4
5
|
import path from 'node:path';
|
|
5
6
|
|
|
@@ -35,11 +36,12 @@ export function listRemotes(config = {}) {
|
|
|
35
36
|
url: remote.url.replace(/\/+$/, ''),
|
|
36
37
|
token: remote.token,
|
|
37
38
|
enabled: remote.enabled !== false,
|
|
39
|
+
...(remote.tls ? { tls: normalizeTls(remote.tls) } : {}),
|
|
38
40
|
}));
|
|
39
41
|
}
|
|
40
42
|
|
|
41
43
|
/** Add or update (matched by url) a remote entry, persisting workspace.json. */
|
|
42
|
-
export function saveRemote(dir, { url, token, enabled = true }) {
|
|
44
|
+
export function saveRemote(dir, { url, token, enabled = true, tls }) {
|
|
43
45
|
if (!url || !token) throw new Error('remote requires url and token');
|
|
44
46
|
const file = workspaceConfigPath(dir);
|
|
45
47
|
const config = readWorkspaceConfig(dir);
|
|
@@ -50,8 +52,9 @@ export function saveRemote(dir, { url, token, enabled = true }) {
|
|
|
50
52
|
existing.url = normalizedUrl;
|
|
51
53
|
existing.token = token;
|
|
52
54
|
existing.enabled = enabled;
|
|
55
|
+
if (tls !== undefined) existing.tls = normalizeTls(tls);
|
|
53
56
|
} else {
|
|
54
|
-
remotes.push({ url: normalizedUrl, token, enabled });
|
|
57
|
+
remotes.push({ url: normalizedUrl, token, enabled, ...(tls ? { tls: normalizeTls(tls) } : {}) });
|
|
55
58
|
}
|
|
56
59
|
config.remotes = remotes;
|
|
57
60
|
fs.writeFileSync(file, JSON.stringify(config, null, 2) + '\n');
|