p2p-envsync 0.1.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/LICENSE +21 -0
- package/README.md +201 -0
- package/backup.js +211 -0
- package/cli.js +469 -0
- package/lib.js +355 -0
- package/mesh.js +94 -0
- package/net.js +335 -0
- package/notify.js +48 -0
- package/package.json +33 -0
- package/signal.js +83 -0
- package/tray/icons.js +136 -0
- package/tray/main.js +281 -0
- package/tray/prompt.js +53 -0
package/lib.js
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
const crypto = require('crypto');
|
|
7
|
+
|
|
8
|
+
const ROOT = path.join(os.homedir(), '.envsync', 'rooms');
|
|
9
|
+
|
|
10
|
+
function roomDir(name) {
|
|
11
|
+
return path.join(ROOT, name);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function configFile(name) {
|
|
15
|
+
return path.join(roomDir(name), 'config.json');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function loadConfig(name) {
|
|
19
|
+
const file = configFile(name);
|
|
20
|
+
if (!fs.existsSync(file)) {
|
|
21
|
+
throw new Error(`Room "${name}" not found. Run: envsync create <name> <file>`);
|
|
22
|
+
}
|
|
23
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function saveConfig(name, config) {
|
|
27
|
+
const file = configFile(name);
|
|
28
|
+
fs.writeFileSync(file, JSON.stringify(config, null, 2));
|
|
29
|
+
fs.chmodSync(file, 0o600);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function parseEnv(text) {
|
|
33
|
+
const out = {};
|
|
34
|
+
for (const line of text.split('\n')) {
|
|
35
|
+
const trimmed = line.trim();
|
|
36
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
37
|
+
const eq = trimmed.indexOf('=');
|
|
38
|
+
if (eq === -1) continue;
|
|
39
|
+
out[trimmed.slice(0, eq).trim()] = trimmed.slice(eq + 1).trim();
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function serializeEnv(values) {
|
|
45
|
+
return Object.entries(values)
|
|
46
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
47
|
+
.join('\n') + (Object.keys(values).length ? '\n' : '');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function diffValues(prev, next) {
|
|
51
|
+
const diff = {};
|
|
52
|
+
const keys = new Set([...Object.keys(prev), ...Object.keys(next)]);
|
|
53
|
+
for (const key of keys) {
|
|
54
|
+
if (!(key in prev)) diff[key] = { type: 'added', value: next[key] };
|
|
55
|
+
else if (!(key in next)) diff[key] = { type: 'removed' };
|
|
56
|
+
else if (prev[key] !== next[key]) diff[key] = { type: 'changed', from: prev[key], to: next[key] };
|
|
57
|
+
}
|
|
58
|
+
return diff;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ponytail: room key sits in plaintext local config (single trust boundary =
|
|
62
|
+
// this device). Real risk once a peer's key material could be exfiltrated
|
|
63
|
+
// remotely -- move to OS keychain (keytar) then, not before.
|
|
64
|
+
function encrypt(keyHex, obj) {
|
|
65
|
+
const iv = crypto.randomBytes(12);
|
|
66
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', Buffer.from(keyHex, 'hex'), iv);
|
|
67
|
+
const enc = Buffer.concat([cipher.update(JSON.stringify(obj), 'utf8'), cipher.final()]);
|
|
68
|
+
return { iv: iv.toString('hex'), tag: cipher.getAuthTag().toString('hex'), data: enc.toString('hex') };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function decrypt(keyHex, rec) {
|
|
72
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', Buffer.from(keyHex, 'hex'), Buffer.from(rec.iv, 'hex'));
|
|
73
|
+
decipher.setAuthTag(Buffer.from(rec.tag, 'hex'));
|
|
74
|
+
const dec = Buffer.concat([decipher.update(Buffer.from(rec.data, 'hex')), decipher.final()]);
|
|
75
|
+
return JSON.parse(dec.toString('utf8'));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function appendHistory(name, entry) {
|
|
79
|
+
const config = loadConfig(name);
|
|
80
|
+
const record = encrypt(config.key, entry);
|
|
81
|
+
const file = path.join(roomDir(name), 'history.jsonl');
|
|
82
|
+
fs.appendFileSync(file, JSON.stringify(record) + '\n');
|
|
83
|
+
fs.chmodSync(file, 0o600);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function readHistory(name) {
|
|
87
|
+
const config = loadConfig(name);
|
|
88
|
+
const file = path.join(roomDir(name), 'history.jsonl');
|
|
89
|
+
if (!fs.existsSync(file)) return [];
|
|
90
|
+
return fs.readFileSync(file, 'utf8')
|
|
91
|
+
.split('\n')
|
|
92
|
+
.filter(Boolean)
|
|
93
|
+
.map((line) => decrypt(config.key, JSON.parse(line)));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// merged.json: { [key]: { value, ts, peer } } -- last-write-wins per key.
|
|
97
|
+
function mergedFile(name) {
|
|
98
|
+
return path.join(roomDir(name), 'merged.json');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function loadMerged(name) {
|
|
102
|
+
const file = mergedFile(name);
|
|
103
|
+
if (!fs.existsSync(file)) return {};
|
|
104
|
+
const config = loadConfig(name);
|
|
105
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
106
|
+
// Pre-encryption merged.json files were plain {key: {value, ts, peer}}.
|
|
107
|
+
// The encrypted envelope shape is always {iv, tag, data}; anything else
|
|
108
|
+
// is old plaintext -- read it as-is, and it'll be re-saved encrypted on
|
|
109
|
+
// the next write.
|
|
110
|
+
if (!('iv' in parsed && 'tag' in parsed && 'data' in parsed)) return parsed;
|
|
111
|
+
return decrypt(config.key, parsed);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function saveMerged(name, merged) {
|
|
115
|
+
const config = loadConfig(name);
|
|
116
|
+
const file = mergedFile(name);
|
|
117
|
+
fs.writeFileSync(file, JSON.stringify(encrypt(config.key, merged)));
|
|
118
|
+
fs.chmodSync(file, 0o600);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function mergedToValues(merged) {
|
|
122
|
+
const values = {};
|
|
123
|
+
for (const [k, entry] of Object.entries(merged)) values[k] = entry.value;
|
|
124
|
+
return values;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// peers.json: { [peerId]: { label, firstSeen, lastSeen } } -- lets history
|
|
128
|
+
// and notifications show "Sarah's MacBook" instead of a raw public key.
|
|
129
|
+
function peersFile(name) {
|
|
130
|
+
return path.join(roomDir(name), 'peers.json');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function loadPeers(name) {
|
|
134
|
+
const file = peersFile(name);
|
|
135
|
+
return fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, 'utf8')) : {};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function savePeers(name, peers) {
|
|
139
|
+
const file = peersFile(name);
|
|
140
|
+
fs.writeFileSync(file, JSON.stringify(peers, null, 2));
|
|
141
|
+
fs.chmodSync(file, 0o600);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function recordPeer(name, peerId, label) {
|
|
145
|
+
const peers = loadPeers(name);
|
|
146
|
+
const now = Date.now();
|
|
147
|
+
peers[peerId] = { label, firstSeen: peers[peerId]?.firstSeen || now, lastSeen: now };
|
|
148
|
+
savePeers(name, peers);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function peerLabel(name, peerId) {
|
|
152
|
+
if (!peerId) return 'unknown';
|
|
153
|
+
const peers = loadPeers(name);
|
|
154
|
+
return peers[peerId]?.label || `${peerId.slice(0, 12)}...`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Public identifier for a room on the LAN -- never the key itself.
|
|
158
|
+
function roomHash(keyHex) {
|
|
159
|
+
return crypto.createHmac('sha256', Buffer.from(keyHex, 'hex')).update('envsync-room-id').digest('hex');
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// --- Per-device identity (X25519) + envelope encryption ---
|
|
163
|
+
// One keypair per device, shared across all rooms. The public half is safe
|
|
164
|
+
// to hand to anyone; the private half never leaves this machine. Used to
|
|
165
|
+
// wrap a room's symmetric key for one specific recipient device, so the
|
|
166
|
+
// raw room key never has to be typed/pasted/posted anywhere -- only the
|
|
167
|
+
// target device's private key can unwrap it.
|
|
168
|
+
|
|
169
|
+
function identityFile() {
|
|
170
|
+
return path.join(path.dirname(ROOT), 'identity.json');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function getDeviceIdentity() {
|
|
174
|
+
const file = identityFile();
|
|
175
|
+
if (fs.existsSync(file)) {
|
|
176
|
+
const identity = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
177
|
+
if (!identity.label) {
|
|
178
|
+
identity.label = os.hostname();
|
|
179
|
+
fs.writeFileSync(file, JSON.stringify(identity, null, 2));
|
|
180
|
+
}
|
|
181
|
+
// Always re-assert 0600 here too, not just on write -- heals any file
|
|
182
|
+
// created before this permission fix existed.
|
|
183
|
+
fs.chmodSync(file, 0o600);
|
|
184
|
+
return identity;
|
|
185
|
+
}
|
|
186
|
+
const { publicKey, privateKey } = crypto.generateKeyPairSync('x25519', {
|
|
187
|
+
publicKeyEncoding: { type: 'spki', format: 'der' },
|
|
188
|
+
privateKeyEncoding: { type: 'pkcs8', format: 'der' },
|
|
189
|
+
});
|
|
190
|
+
const identity = {
|
|
191
|
+
publicKey: publicKey.toString('hex'),
|
|
192
|
+
privateKey: privateKey.toString('hex'),
|
|
193
|
+
label: os.hostname(),
|
|
194
|
+
};
|
|
195
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
196
|
+
fs.writeFileSync(file, JSON.stringify(identity, null, 2));
|
|
197
|
+
fs.chmodSync(file, 0o600);
|
|
198
|
+
return identity;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function setDeviceAlias(label) {
|
|
202
|
+
const identity = getDeviceIdentity();
|
|
203
|
+
identity.label = label;
|
|
204
|
+
const file = identityFile();
|
|
205
|
+
fs.writeFileSync(file, JSON.stringify(identity, null, 2));
|
|
206
|
+
fs.chmodSync(file, 0o600);
|
|
207
|
+
return identity;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function sharedSecretKey(myPrivateKeyHex, theirPublicKeyHex) {
|
|
211
|
+
const priv = crypto.createPrivateKey({ key: Buffer.from(myPrivateKeyHex, 'hex'), format: 'der', type: 'pkcs8' });
|
|
212
|
+
const pub = crypto.createPublicKey({ key: Buffer.from(theirPublicKeyHex, 'hex'), format: 'der', type: 'spki' });
|
|
213
|
+
return crypto.createHash('sha256').update(crypto.diffieHellman({ privateKey: priv, publicKey: pub })).digest();
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function wrapRoomKey(roomKeyHex, recipientPublicKeyHex) {
|
|
217
|
+
const me = getDeviceIdentity();
|
|
218
|
+
const aesKey = sharedSecretKey(me.privateKey, recipientPublicKeyHex);
|
|
219
|
+
const iv = crypto.randomBytes(12);
|
|
220
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', aesKey, iv);
|
|
221
|
+
const enc = Buffer.concat([cipher.update(Buffer.from(roomKeyHex, 'hex')), cipher.final()]);
|
|
222
|
+
return {
|
|
223
|
+
from: me.publicKey,
|
|
224
|
+
iv: iv.toString('hex'),
|
|
225
|
+
tag: cipher.getAuthTag().toString('hex'),
|
|
226
|
+
data: enc.toString('hex'),
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function unwrapRoomKey(envelope) {
|
|
231
|
+
const me = getDeviceIdentity();
|
|
232
|
+
const aesKey = sharedSecretKey(me.privateKey, envelope.from);
|
|
233
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', aesKey, Buffer.from(envelope.iv, 'hex'));
|
|
234
|
+
decipher.setAuthTag(Buffer.from(envelope.tag, 'hex'));
|
|
235
|
+
const dec = Buffer.concat([decipher.update(Buffer.from(envelope.data, 'hex')), decipher.final()]);
|
|
236
|
+
return dec.toString('hex');
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// .envsync.yml: metadata only (room name + tracked file), never secrets.
|
|
240
|
+
// Hand-rolled two-key parser -- not general YAML, just enough for this fixed shape.
|
|
241
|
+
function findProjectConfig(startDir) {
|
|
242
|
+
let dir = startDir;
|
|
243
|
+
while (true) {
|
|
244
|
+
const file = path.join(dir, '.envsync.yml');
|
|
245
|
+
if (fs.existsSync(file)) {
|
|
246
|
+
const out = {};
|
|
247
|
+
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
|
|
248
|
+
const m = line.match(/^(\w+):\s*(.+)$/);
|
|
249
|
+
if (m) out[m[1]] = m[2].trim();
|
|
250
|
+
}
|
|
251
|
+
return out;
|
|
252
|
+
}
|
|
253
|
+
const parent = path.dirname(dir);
|
|
254
|
+
if (parent === dir) return null;
|
|
255
|
+
dir = parent;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function writeProjectConfig(dir, { room, file }) {
|
|
260
|
+
fs.writeFileSync(path.join(dir, '.envsync.yml'), `room: ${room}\nfile: ${file}\n`);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function currentValues(name) {
|
|
264
|
+
const merged = loadMerged(name);
|
|
265
|
+
if (Object.keys(merged).length) return mergedToValues(merged);
|
|
266
|
+
const config = loadConfig(name);
|
|
267
|
+
return config.filePath && fs.existsSync(config.filePath) ? parseEnv(fs.readFileSync(config.filePath, 'utf8')) : {};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Direct vault edits, for rooms with no on-disk file at all -- the only
|
|
271
|
+
// way to change a value in a vault-only room, since there's no file to
|
|
272
|
+
// watch. Also usable for file-mode rooms (a running `sync` daemon picks
|
|
273
|
+
// these up the same way it picks up a remote peer's change, see net.js).
|
|
274
|
+
function setValue(name, key, value) {
|
|
275
|
+
const config = loadConfig(name);
|
|
276
|
+
const merged = loadMerged(name);
|
|
277
|
+
const prev = merged[key]?.value;
|
|
278
|
+
merged[key] = { value, ts: Date.now(), peer: config.peerId || getDeviceIdentity().publicKey };
|
|
279
|
+
saveMerged(name, merged);
|
|
280
|
+
appendHistory(name, {
|
|
281
|
+
ts: Date.now(),
|
|
282
|
+
values: mergedToValues(merged),
|
|
283
|
+
diff: { [key]: prev === undefined ? { type: 'added', value } : { type: 'changed', from: prev, to: value } },
|
|
284
|
+
source: config.peerId || getDeviceIdentity().publicKey,
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function unsetValue(name, key) {
|
|
289
|
+
const config = loadConfig(name);
|
|
290
|
+
const merged = loadMerged(name);
|
|
291
|
+
if (!(key in merged)) return;
|
|
292
|
+
delete merged[key];
|
|
293
|
+
saveMerged(name, merged);
|
|
294
|
+
appendHistory(name, {
|
|
295
|
+
ts: Date.now(),
|
|
296
|
+
values: mergedToValues(merged),
|
|
297
|
+
diff: { [key]: { type: 'removed' } },
|
|
298
|
+
source: config.peerId || getDeviceIdentity().publicKey,
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Keep a still-used on-disk tracked file out of git even if someone forgets
|
|
303
|
+
// -- cheap insurance against a plaintext .env ending up in a commit.
|
|
304
|
+
function ensureGitignored(filePath) {
|
|
305
|
+
const dir = path.dirname(filePath);
|
|
306
|
+
const gitignorePath = path.join(dir, '.gitignore');
|
|
307
|
+
const basename = path.basename(filePath);
|
|
308
|
+
const existing = fs.existsSync(gitignorePath) ? fs.readFileSync(gitignorePath, 'utf8') : '';
|
|
309
|
+
if (existing.split('\n').map((l) => l.trim()).includes(basename)) return;
|
|
310
|
+
fs.writeFileSync(gitignorePath, existing && !existing.endsWith('\n') ? `${existing}\n${basename}\n` : `${existing}${basename}\n`);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function listRoomStatuses() {
|
|
314
|
+
if (!fs.existsSync(ROOT)) return [];
|
|
315
|
+
const statuses = [];
|
|
316
|
+
for (const name of fs.readdirSync(ROOT)) {
|
|
317
|
+
let config;
|
|
318
|
+
try { config = loadConfig(name); } catch { continue; }
|
|
319
|
+
const merged = loadMerged(name);
|
|
320
|
+
const mergedValues = mergedToValues(merged);
|
|
321
|
+
const onDisk = fs.existsSync(config.filePath) ? parseEnv(fs.readFileSync(config.filePath, 'utf8')) : {};
|
|
322
|
+
const pending = Object.keys(diffValues(mergedValues, onDisk)).length > 0;
|
|
323
|
+
const history = readHistory(name);
|
|
324
|
+
const lastTs = history.length ? history[history.length - 1].ts : null;
|
|
325
|
+
statuses.push({ name, filePath: config.filePath, pending, lastTs });
|
|
326
|
+
}
|
|
327
|
+
return statuses;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function rotateRoomKey(name) {
|
|
331
|
+
const config = loadConfig(name);
|
|
332
|
+
const merged = loadMerged(name);
|
|
333
|
+
const newKey = crypto.randomBytes(32).toString('hex');
|
|
334
|
+
config.key = newKey;
|
|
335
|
+
saveConfig(name, config);
|
|
336
|
+
saveMerged(name, merged);
|
|
337
|
+
const mergedValues = mergedToValues(merged);
|
|
338
|
+
appendHistory(name, { ts: Date.now(), values: mergedValues, diff: {}, rotated: true });
|
|
339
|
+
return newKey;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function mask(value) {
|
|
343
|
+
return value.length <= 2 ? '••' : `${value[0]}${'•'.repeat(Math.min(value.length - 1, 8))}`;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
module.exports = {
|
|
347
|
+
ROOT, roomDir, configFile, loadConfig, saveConfig,
|
|
348
|
+
parseEnv, serializeEnv, diffValues,
|
|
349
|
+
encrypt, decrypt, appendHistory, readHistory,
|
|
350
|
+
loadMerged, saveMerged, mergedToValues, roomHash,
|
|
351
|
+
findProjectConfig, writeProjectConfig, mask, listRoomStatuses, currentValues,
|
|
352
|
+
setValue, unsetValue, ensureGitignored, mergedFile,
|
|
353
|
+
getDeviceIdentity, setDeviceAlias, wrapRoomKey, unwrapRoomKey,
|
|
354
|
+
loadPeers, savePeers, recordPeer, peerLabel, rotateRoomKey,
|
|
355
|
+
};
|
package/mesh.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { execFileSync } = require('child_process');
|
|
4
|
+
|
|
5
|
+
function run(cmd, args) {
|
|
6
|
+
return execFileSync(cmd, args, { encoding: 'utf8' });
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function hasTailscale() {
|
|
10
|
+
try {
|
|
11
|
+
run('tailscale', ['version']);
|
|
12
|
+
return true;
|
|
13
|
+
} catch (err) {
|
|
14
|
+
return err.code !== 'ENOENT';
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function hasZerotier() {
|
|
19
|
+
try {
|
|
20
|
+
run('zerotier-cli', ['info']);
|
|
21
|
+
return true;
|
|
22
|
+
} catch (err) {
|
|
23
|
+
return err.code !== 'ENOENT';
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function getTailscaleStatus() {
|
|
28
|
+
try {
|
|
29
|
+
return JSON.parse(run('tailscale', ['status', '--json']));
|
|
30
|
+
} catch (err) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function getZerotierJson(args) {
|
|
36
|
+
try {
|
|
37
|
+
return JSON.parse(run('zerotier-cli', args));
|
|
38
|
+
} catch (err) {
|
|
39
|
+
return [];
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function getTailscalePeers() {
|
|
44
|
+
const status = getTailscaleStatus();
|
|
45
|
+
if (!status || !status.Peer) return [];
|
|
46
|
+
const peers = [];
|
|
47
|
+
for (const key of Object.keys(status.Peer)) {
|
|
48
|
+
const peer = status.Peer[key];
|
|
49
|
+
if (peer.Online && Array.isArray(peer.TailscaleIPs)) {
|
|
50
|
+
for (const ip of peer.TailscaleIPs) peers.push({ ip, source: 'tailscale' });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return peers;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function getTailscaleSelfIps() {
|
|
57
|
+
const status = getTailscaleStatus();
|
|
58
|
+
const ips = status && status.Self && status.Self.TailscaleIPs;
|
|
59
|
+
return Array.isArray(ips) ? ips.map((ip) => ({ ip, source: 'tailscale' })) : [];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function getZerotierSelfIps() {
|
|
63
|
+
const networks = getZerotierJson(['listnetworks', '-j']);
|
|
64
|
+
const ips = [];
|
|
65
|
+
for (const net of networks) {
|
|
66
|
+
for (const addr of net.assignedAddresses || []) {
|
|
67
|
+
ips.push({ ip: addr.split('/')[0], source: 'zerotier' });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return ips;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function getZerotierPeers() {
|
|
74
|
+
const peers = getZerotierJson(['listpeers', '-j']);
|
|
75
|
+
const ips = [];
|
|
76
|
+
for (const peer of peers) {
|
|
77
|
+
for (const path of peer.paths || []) {
|
|
78
|
+
if (path.active && path.address) {
|
|
79
|
+
ips.push({ ip: path.address.split('/')[0], source: 'zerotier' });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return ips;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function getMeshPeers() {
|
|
87
|
+
return [...getTailscalePeers(), ...getZerotierPeers()];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function getMyMeshIps() {
|
|
91
|
+
return [...getTailscaleSelfIps(), ...getZerotierSelfIps()];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
module.exports = { hasTailscale, hasZerotier, getMeshPeers, getMyMeshIps };
|