mindwire 0.1.34 → 0.1.36
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/dist/binary-cache-CG52TXCZ.js +3 -0
- package/dist/{binary-cache-VM7VHCEU.js.map → binary-cache-CG52TXCZ.js.map} +1 -1
- package/dist/chunk-67BNDGRK.js +20 -0
- package/dist/chunk-67BNDGRK.js.map +1 -0
- package/dist/{chunk-3FR7BFAW.js → chunk-PFYKJ6LX.js} +56 -4
- package/dist/chunk-PFYKJ6LX.js.map +1 -0
- package/dist/{chunk-3QFDSYKC.js → chunk-RTGZDLSG.js} +523 -172
- package/dist/chunk-RTGZDLSG.js.map +1 -0
- package/dist/cli.js +763 -32
- package/dist/cli.js.map +1 -1
- package/dist/computer/connection-change.d.ts +13 -0
- package/dist/computer/connection-flow.d.ts +3 -3
- package/dist/computer/directory-http.d.ts +8 -0
- package/dist/computer/discovery.d.ts +13 -0
- package/dist/computer/lifecycle.d.ts +2 -1
- package/dist/computer/managed-relay.d.ts +27 -0
- package/dist/computer/oblien-auth.d.ts +25 -0
- package/dist/computer/provider-errors.d.ts +9 -0
- package/dist/computer/provider-info.d.ts +7 -0
- package/dist/computer/provider-setup.d.ts +20 -0
- package/dist/computer/relay-health.d.ts +2 -2
- package/dist/computer/relay.d.ts +11 -3
- package/dist/computer/setup-prompt.d.ts +9 -0
- package/dist/computer.d.ts +27 -1
- package/dist/connection-change-IBNL42BL.js +41 -0
- package/dist/connection-change-IBNL42BL.js.map +1 -0
- package/dist/{controller-AGT6MCJG.js → controller-2SJLZMJZ.js} +199 -279
- package/dist/controller-2SJLZMJZ.js.map +1 -0
- package/dist/{controller-handoff-3LAYBJUL.js → controller-handoff-RUUHODNC.js} +4 -4
- package/dist/{controller-handoff-3LAYBJUL.js.map → controller-handoff-RUUHODNC.js.map} +1 -1
- package/dist/index.cjs +12 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +12 -3
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
- package/dist/binary-cache-VM7VHCEU.js +0 -4
- package/dist/chunk-3FR7BFAW.js.map +0 -1
- package/dist/chunk-3QFDSYKC.js.map +0 -1
- package/dist/controller-AGT6MCJG.js.map +0 -1
package/dist/cli.js
CHANGED
|
@@ -1,18 +1,22 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
3
|
-
import './chunk-
|
|
2
|
+
import { connectionAddress, connectionInfo, connectionDescription } from './chunk-67BNDGRK.js';
|
|
3
|
+
import { defaultStateDirectory, superviseComputer, computerClient, ensureComputer, readJSON, defaultComputerConfig, computerPairingURI, stopComputer, SDK_VERSION, providerFailure, writeJSON, currentProcessIdentity, processStateAlive, acquireProcessLock, websocketURL, providerNeedsAction, cloudflareCommand, computerPairingQRData, ProviderConnectionError } from './chunk-RTGZDLSG.js';
|
|
4
|
+
import './chunk-PFYKJ6LX.js';
|
|
4
5
|
import { promisify, parseArgs } from 'util';
|
|
5
6
|
import { fileURLToPath, pathToFileURL } from 'url';
|
|
6
7
|
import { realpathSync, mkdtempSync, chmodSync, writeFileSync, rmSync } from 'fs';
|
|
7
8
|
import * as path from 'path';
|
|
8
9
|
import { join } from 'path';
|
|
9
|
-
import { createHash } from 'crypto';
|
|
10
|
+
import { createHash, randomUUID } from 'crypto';
|
|
10
11
|
import { createInterface } from 'readline/promises';
|
|
11
12
|
import { setTimeout as setTimeout$1 } from 'timers/promises';
|
|
12
13
|
import { execFile, spawn } from 'child_process';
|
|
13
|
-
import { homedir, tmpdir } from 'os';
|
|
14
|
+
import { homedir, hostname, tmpdir } from 'os';
|
|
14
15
|
import * as fs from 'fs/promises';
|
|
15
|
-
import {
|
|
16
|
+
import { rm } from 'fs/promises';
|
|
17
|
+
import { cursorTo, clearLine, createInterface as createInterface$1 } from 'readline';
|
|
18
|
+
import { TunnelClient, Oblien } from 'oblien';
|
|
19
|
+
import { Writable } from 'stream';
|
|
16
20
|
|
|
17
21
|
var enterScreen = "\x1B[?1049h\x1B[?25l";
|
|
18
22
|
var leaveScreen = "\x1B[0m\x1B[?25h\x1B[?1049l";
|
|
@@ -425,8 +429,10 @@ function approvalCommand(pairingId, requestId) {
|
|
|
425
429
|
return `mindwire approve -- ${pairingId} ${requestId}`;
|
|
426
430
|
}
|
|
427
431
|
async function chooseConnectionAction(options) {
|
|
428
|
-
if (options.requested === "pair"
|
|
429
|
-
|
|
432
|
+
if (options.requested === "pair") return "pair";
|
|
433
|
+
const recoverable = options.persistent || options.devices.some((device) => !device.revoked && device.addressRecovery);
|
|
434
|
+
if (options.requested === "reconnect" && !recoverable) return "pair";
|
|
435
|
+
return options.devices.some((device) => !device.revoked && (device.connected || recoverable)) ? "resume" : "pair";
|
|
430
436
|
}
|
|
431
437
|
async function choosePairingDecision(options) {
|
|
432
438
|
const replacements = options.replacements.filter((device) => !device.revoked);
|
|
@@ -508,13 +514,664 @@ var ConnectionProgress = class {
|
|
|
508
514
|
}
|
|
509
515
|
};
|
|
510
516
|
|
|
517
|
+
// src/computer/directory-http.ts
|
|
518
|
+
var DirectoryRequestError = class extends Error {
|
|
519
|
+
constructor(status, code, retryAfter = 0) {
|
|
520
|
+
super(
|
|
521
|
+
status === 0 ? "Couldn't reach the address directory securely. Check its HTTPS address and retry." : `The address directory rejected the request (${code}).`
|
|
522
|
+
);
|
|
523
|
+
this.status = status;
|
|
524
|
+
this.code = code;
|
|
525
|
+
this.retryAfter = retryAfter;
|
|
526
|
+
}
|
|
527
|
+
status;
|
|
528
|
+
code;
|
|
529
|
+
retryAfter;
|
|
530
|
+
};
|
|
531
|
+
async function directoryRequest(url, path5, body) {
|
|
532
|
+
let response;
|
|
533
|
+
try {
|
|
534
|
+
response = await fetch(url + path5, {
|
|
535
|
+
method: "POST",
|
|
536
|
+
redirect: "error",
|
|
537
|
+
signal: AbortSignal.timeout(1e4),
|
|
538
|
+
headers: {
|
|
539
|
+
"Content-Type": "application/json",
|
|
540
|
+
Accept: "application/json"
|
|
541
|
+
},
|
|
542
|
+
body: JSON.stringify(body)
|
|
543
|
+
});
|
|
544
|
+
} catch {
|
|
545
|
+
throw new DirectoryRequestError(0, "unreachable");
|
|
546
|
+
}
|
|
547
|
+
const reader = response.body?.getReader();
|
|
548
|
+
const chunks = [];
|
|
549
|
+
let length = 0;
|
|
550
|
+
try {
|
|
551
|
+
if (reader)
|
|
552
|
+
for (; ; ) {
|
|
553
|
+
const { done, value } = await reader.read();
|
|
554
|
+
if (done) break;
|
|
555
|
+
length += value.byteLength;
|
|
556
|
+
if (length > 64 * 1024) throw new Error("The address directory returned an oversized response.");
|
|
557
|
+
chunks.push(value);
|
|
558
|
+
}
|
|
559
|
+
} finally {
|
|
560
|
+
void reader?.cancel().catch(() => {
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
let data;
|
|
564
|
+
try {
|
|
565
|
+
data = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
566
|
+
} catch {
|
|
567
|
+
throw new DirectoryRequestError(response.status, "invalid_response");
|
|
568
|
+
}
|
|
569
|
+
if (!response.ok) {
|
|
570
|
+
const error = data?.error;
|
|
571
|
+
const code = typeof error === "string" ? error : error?.code;
|
|
572
|
+
const retryAfter = Number(response.headers.get("Retry-After"));
|
|
573
|
+
throw new DirectoryRequestError(
|
|
574
|
+
response.status,
|
|
575
|
+
typeof code === "string" && /^[a-z_]{1,64}$/.test(code) ? code : "directory_error",
|
|
576
|
+
Number.isFinite(retryAfter) ? Math.max(0, Math.min(60, retryAfter)) : 0
|
|
577
|
+
);
|
|
578
|
+
}
|
|
579
|
+
return data;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// src/computer/discovery.ts
|
|
583
|
+
var DIRECTORY_API_PATH = "/api/computer-directory/v1";
|
|
584
|
+
var DIRECTORY_URL = `https://console.mindwire.sh${DIRECTORY_API_PATH}`;
|
|
585
|
+
function directoryURL(value) {
|
|
586
|
+
let url;
|
|
587
|
+
try {
|
|
588
|
+
url = new URL(value);
|
|
589
|
+
} catch {
|
|
590
|
+
throw new Error("Enter the directory's HTTPS API URL.");
|
|
591
|
+
}
|
|
592
|
+
if (value.length > 2048 || /[\s\x00]/.test(value) || url.username || url.password || url.search || url.hash || url.protocol !== "https:" && !(url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname))) {
|
|
593
|
+
throw new Error("Use an HTTPS directory URL without credentials, a query, or a fragment.");
|
|
594
|
+
}
|
|
595
|
+
if (url.pathname === "/") url.pathname = DIRECTORY_API_PATH;
|
|
596
|
+
if (url.pathname.endsWith("/") || url.pathname.includes("//") || url.pathname.includes("%")) {
|
|
597
|
+
throw new Error("Use the exact directory API URL without a trailing slash.");
|
|
598
|
+
}
|
|
599
|
+
return url.href;
|
|
600
|
+
}
|
|
601
|
+
async function enableAddressDiscovery(options) {
|
|
602
|
+
const lock = await acquireProcessLock(join(options.directory, "discovery-setup.lock"));
|
|
603
|
+
try {
|
|
604
|
+
let status;
|
|
605
|
+
try {
|
|
606
|
+
status = await options.computer.computer.discovery();
|
|
607
|
+
} catch (error) {
|
|
608
|
+
if (error.status === 404)
|
|
609
|
+
throw new Error("Update the Mindwire service before enabling automatic address recovery.");
|
|
610
|
+
throw error;
|
|
611
|
+
}
|
|
612
|
+
const url = directoryURL(
|
|
613
|
+
options.url ?? process.env.MINDWIRE_DIRECTORY_URL ?? status.url ?? DIRECTORY_URL
|
|
614
|
+
);
|
|
615
|
+
if (status.url && status.url !== url && (status.enabled || (status.sequence ?? 0) !== (status.publishedSequence ?? 0))) {
|
|
616
|
+
throw new Error(
|
|
617
|
+
"Disable the current directory with mindwire discovery disable and wait for its withdrawal before changing the directory URL."
|
|
618
|
+
);
|
|
619
|
+
}
|
|
620
|
+
if (!status.enabled || status.url !== url || status.error?.includes("enrollment")) {
|
|
621
|
+
options.prompt.print(`Enabling encrypted address recovery with ${new URL(url).host}\u2026`);
|
|
622
|
+
try {
|
|
623
|
+
const challenge = await directoryRequest(url, "/registrations/challenge", {
|
|
624
|
+
directoryId: status.directoryId
|
|
625
|
+
});
|
|
626
|
+
if (typeof challenge?.challenge !== "string" || challenge.challenge.length < 16 || challenge.challenge.length > 2048) {
|
|
627
|
+
throw new Error("The address directory returned an invalid enrollment challenge.");
|
|
628
|
+
}
|
|
629
|
+
const proof = await options.computer.computer.directoryEnrollment(url, challenge.challenge);
|
|
630
|
+
const enrolled = await directoryRequest(url, "/registrations", proof);
|
|
631
|
+
if (enrolled?.directoryId !== status.directoryId)
|
|
632
|
+
throw new Error("The directory acknowledged a different computer.");
|
|
633
|
+
} catch (error) {
|
|
634
|
+
if (error instanceof DirectoryRequestError) {
|
|
635
|
+
if (error.status === 404 || error.status === 501)
|
|
636
|
+
throw new Error(
|
|
637
|
+
"The address directory isn't available at this URL. Your existing connection still works; retry after the backend is deployed."
|
|
638
|
+
);
|
|
639
|
+
if (error.status === 409)
|
|
640
|
+
throw new Error(
|
|
641
|
+
"This computer's directory registration is revoked or conflicts with its saved identity. Contact the directory operator."
|
|
642
|
+
);
|
|
643
|
+
if (error.code === "account_required")
|
|
644
|
+
throw new Error(
|
|
645
|
+
"This directory still requires an account. Update its Mindwire backend to enable key-based registration, or choose another directory with --directory-url."
|
|
646
|
+
);
|
|
647
|
+
if (error.status === 401 || error.status === 403)
|
|
648
|
+
throw new Error(
|
|
649
|
+
"The directory couldn't verify this computer's key or challenge. Retry enrollment."
|
|
650
|
+
);
|
|
651
|
+
if (error.status === 429)
|
|
652
|
+
throw new Error(
|
|
653
|
+
"The directory's registration limit was reached. Retry later or use another directory."
|
|
654
|
+
);
|
|
655
|
+
if (error.status === 503)
|
|
656
|
+
throw new Error(
|
|
657
|
+
"The directory is temporarily unavailable or at capacity. Your existing connection still works."
|
|
658
|
+
);
|
|
659
|
+
}
|
|
660
|
+
throw error;
|
|
661
|
+
}
|
|
662
|
+
status = await options.computer.computer.configureDiscovery(true, url);
|
|
663
|
+
}
|
|
664
|
+
const deadline = Date.now() + 15e3;
|
|
665
|
+
while (Date.now() < deadline) {
|
|
666
|
+
status = await options.computer.computer.discovery();
|
|
667
|
+
if (status.enabled && status.sequence && status.publishedSequence === status.sequence) return status;
|
|
668
|
+
if (status.error?.includes("enrollment") || status.error?.includes("older revision"))
|
|
669
|
+
throw new Error(status.error);
|
|
670
|
+
await setTimeout$1(250);
|
|
671
|
+
}
|
|
672
|
+
throw new Error(
|
|
673
|
+
"Address recovery is configured and will retry in the background. Run mindwire discovery status to check publication."
|
|
674
|
+
);
|
|
675
|
+
} finally {
|
|
676
|
+
await lock?.close();
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
function discoveryDescription(status) {
|
|
680
|
+
if (!status?.enabled) return "Automatic address recovery is off. Enable it with mindwire discovery enable.";
|
|
681
|
+
if (status.error) return status.error;
|
|
682
|
+
return status.sequence && status.publishedSequence === status.sequence ? "Encrypted address recovery is on. Saved phones can find this computer when its tunnel address changes." : "Publishing the computer's encrypted address\u2026";
|
|
683
|
+
}
|
|
684
|
+
var BASE = "https://api.oblien.com";
|
|
685
|
+
var RENEW_BEFORE_MS = 24 * 60 * 6e4;
|
|
686
|
+
var ProviderSignInRequired = class extends Error {
|
|
687
|
+
code = "provider_sign_in";
|
|
688
|
+
constructor(provider = "Oblien") {
|
|
689
|
+
super(`Sign in to ${provider} again with mindwire connection ${provider.toLowerCase()}. Your computer and phone keys are unchanged.`);
|
|
690
|
+
}
|
|
691
|
+
};
|
|
692
|
+
function tokenExpiry(token) {
|
|
693
|
+
try {
|
|
694
|
+
const value = JSON.parse(Buffer.from(token.split(".")[1] ?? "", "base64url").toString());
|
|
695
|
+
return typeof value.exp === "number" && Number.isFinite(value.exp) ? value.exp * 1e3 : void 0;
|
|
696
|
+
} catch {
|
|
697
|
+
return void 0;
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
function oblienClient(credentials) {
|
|
701
|
+
const baseUrl = credentials.baseUrl ?? BASE;
|
|
702
|
+
const url = new URL(baseUrl);
|
|
703
|
+
if (url.protocol !== "https:" || url.username || url.password) throw new Error("Oblien requires an HTTPS API address without embedded credentials.");
|
|
704
|
+
if (credentials.clientId && credentials.clientSecret) return boundedOblienClient(new Oblien({ baseUrl, clientId: credentials.clientId, clientSecret: credentials.clientSecret }));
|
|
705
|
+
if (credentials.token) return boundedOblienClient(new Oblien({ baseUrl, token: credentials.token }));
|
|
706
|
+
throw new ProviderSignInRequired();
|
|
707
|
+
}
|
|
708
|
+
function boundedOblienClient(client, timeoutMs = 15e3) {
|
|
709
|
+
const request = client._http.request.bind(client._http);
|
|
710
|
+
client._http.request = async (options) => {
|
|
711
|
+
const controller = new AbortController();
|
|
712
|
+
const cancel = () => controller.abort(options.signal?.reason);
|
|
713
|
+
const timer = setTimeout(() => controller.abort(new Error("Oblien's API did not respond in time.")), timeoutMs);
|
|
714
|
+
options.signal?.addEventListener("abort", cancel, { once: true });
|
|
715
|
+
if (options.signal?.aborted) cancel();
|
|
716
|
+
try {
|
|
717
|
+
return await request({ ...options, signal: controller.signal });
|
|
718
|
+
} finally {
|
|
719
|
+
clearTimeout(timer);
|
|
720
|
+
options.signal?.removeEventListener("abort", cancel);
|
|
721
|
+
}
|
|
722
|
+
};
|
|
723
|
+
return client;
|
|
724
|
+
}
|
|
725
|
+
function requiresSignIn(error) {
|
|
726
|
+
return error instanceof ProviderSignInRequired || [401, 403].includes(error?.status ?? 0);
|
|
727
|
+
}
|
|
728
|
+
async function authenticatedOblien(file, options = {}) {
|
|
729
|
+
const lock = await acquireProcessLock(file + ".lock");
|
|
730
|
+
try {
|
|
731
|
+
const credentials = await readJSON(file);
|
|
732
|
+
if (!credentials) throw new ProviderSignInRequired();
|
|
733
|
+
const client = (options.client ?? oblienClient)(credentials);
|
|
734
|
+
if (!credentials.token || credentials.clientId && credentials.clientSecret) return client;
|
|
735
|
+
const now = (options.now ?? Date.now)(), expires = tokenExpiry(credentials.token);
|
|
736
|
+
if (expires !== void 0 && expires <= now) throw new ProviderSignInRequired();
|
|
737
|
+
if (expires !== void 0 && expires - now > RENEW_BEFORE_MS) return client;
|
|
738
|
+
try {
|
|
739
|
+
const renewed = await client._http.request({
|
|
740
|
+
method: "POST",
|
|
741
|
+
path: "/auth/token",
|
|
742
|
+
body: { old_token: credentials.token },
|
|
743
|
+
signal: AbortSignal.timeout(15e3)
|
|
744
|
+
});
|
|
745
|
+
if (!renewed.token) throw new ProviderSignInRequired();
|
|
746
|
+
const next = (options.client ?? oblienClient)({ ...credentials, token: renewed.token });
|
|
747
|
+
await next._http.request({ method: "GET", path: "/settings/profile", signal: AbortSignal.timeout(15e3) });
|
|
748
|
+
await writeJSON(file, { ...credentials, token: renewed.token });
|
|
749
|
+
return next;
|
|
750
|
+
} catch (error) {
|
|
751
|
+
if (requiresSignIn(error)) throw new ProviderSignInRequired();
|
|
752
|
+
throw error;
|
|
753
|
+
}
|
|
754
|
+
} finally {
|
|
755
|
+
await lock?.close();
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
async function signInOblien(file, prompt) {
|
|
759
|
+
const candidates = await Promise.all([
|
|
760
|
+
readJSON(file),
|
|
761
|
+
readJSON(path.join(homedir(), ".oblien", "credentials.json"))
|
|
762
|
+
]);
|
|
763
|
+
for (const existing of candidates) {
|
|
764
|
+
if (!existing || !(existing.token || existing.clientId && existing.clientSecret)) continue;
|
|
765
|
+
const temporary = `${file}.login-${randomUUID()}.json`;
|
|
766
|
+
await writeJSON(temporary, existing);
|
|
767
|
+
try {
|
|
768
|
+
const client2 = await authenticatedOblien(temporary);
|
|
769
|
+
await client2._http.request({ method: "GET", path: "/settings/profile", signal: AbortSignal.timeout(15e3) });
|
|
770
|
+
await writeJSON(file, await readJSON(temporary));
|
|
771
|
+
return client2;
|
|
772
|
+
} catch (error) {
|
|
773
|
+
if (!requiresSignIn(error)) throw error;
|
|
774
|
+
} finally {
|
|
775
|
+
await rm(temporary, { force: true });
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
const client = boundedOblienClient(new Oblien({ token: "", baseUrl: BASE }));
|
|
779
|
+
const session = await client._http.request({ method: "POST", path: "/auth/token", signal: AbortSignal.timeout(15e3) });
|
|
780
|
+
if (!session.token) throw new Error("Oblien couldn't start sign-in. Try again shortly.");
|
|
781
|
+
client.setToken(session.token);
|
|
782
|
+
const login = await client._http.request({
|
|
783
|
+
method: "POST",
|
|
784
|
+
path: "/auth/session",
|
|
785
|
+
body: { app: "oblien", redirect_url: "https://oblien.com/cli?login=success" },
|
|
786
|
+
signal: AbortSignal.timeout(15e3)
|
|
787
|
+
});
|
|
788
|
+
const url = new URL(login.url);
|
|
789
|
+
if (url.protocol !== "https:" || url.hostname !== "auth.oblien.com" || url.username || url.password) throw new Error("Oblien returned an invalid sign-in link.");
|
|
790
|
+
const id = url.pathname.split("/").filter(Boolean).pop();
|
|
791
|
+
if (!id || !/^[\w-]+$/.test(id)) throw new Error("Oblien returned an invalid sign-in session.");
|
|
792
|
+
prompt.print(`
|
|
793
|
+
Sign in to Oblien in your browser:
|
|
794
|
+
${url.href}
|
|
795
|
+
|
|
796
|
+
Waiting for sign-in\u2026`);
|
|
797
|
+
prompt.open(url.href);
|
|
798
|
+
const deadline = Date.now() + 5e3 * 60;
|
|
799
|
+
while (Date.now() < deadline) {
|
|
800
|
+
await setTimeout$1(2e3);
|
|
801
|
+
const result = await client._http.request({ method: "GET", path: `/auth/session/${id}/poll`, signal: AbortSignal.timeout(15e3) });
|
|
802
|
+
if (result.status === "expired") throw new Error("Oblien sign-in expired. Run mindwire connection oblien to retry.");
|
|
803
|
+
if (result.status !== "authenticated") continue;
|
|
804
|
+
await client._http.request({ method: "GET", path: "/settings/profile", signal: AbortSignal.timeout(15e3) });
|
|
805
|
+
await writeJSON(file, { token: session.token });
|
|
806
|
+
prompt.print("Signed in to Oblien.");
|
|
807
|
+
return client;
|
|
808
|
+
}
|
|
809
|
+
throw new Error("Oblien sign-in timed out. Run mindwire connection oblien to retry.");
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// src/computer/provider-setup.ts
|
|
813
|
+
var execute2 = promisify(execFile);
|
|
814
|
+
async function chooseProvider(prompt) {
|
|
815
|
+
prompt.print("\nConnect this computer\n\n 1 Automatic \u2014 free Cloudflare tunnel + Mindwire address recovery, no account\n 2 Oblien \u2014 persistent address, browser sign-in\n 3 Cloudflare \u2014 your account and domain\n 4 ngrok \u2014 your account and reserved domain\n 5 Your VPN \u2014 direct SSH\n 6 Existing secure tunnel\n 7 Temporary Cloudflare connection \u2014 no address recovery\n");
|
|
816
|
+
for (; ; ) {
|
|
817
|
+
const answer = (await prompt.question("Connection [1]: ")).trim();
|
|
818
|
+
const provider = ["automatic", "oblien", "cloudflare", "ngrok", "vpn", "custom", "temporary"][Number(answer || "1") - 1];
|
|
819
|
+
if (provider) return provider;
|
|
820
|
+
prompt.print("Choose 1\u20137.");
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
function connectionProvider(value) {
|
|
824
|
+
if (["oblien", "cloudflare", "ngrok", "vpn", "custom", "temporary", "automatic"].includes(value)) return value;
|
|
825
|
+
throw new Error("Choose automatic, oblien, cloudflare, ngrok, vpn, custom or temporary.");
|
|
826
|
+
}
|
|
827
|
+
function providerHostname(value) {
|
|
828
|
+
const url = new URL(websocketURL(value.includes("://") ? value : `https://${value}`));
|
|
829
|
+
if (url.port || url.search || url.pathname !== "/ssh" || !url.hostname.includes(".") || /[\s]/.test(value)) {
|
|
830
|
+
throw new Error("Enter the configured public hostname, for example computer.example.com.");
|
|
831
|
+
}
|
|
832
|
+
return url.hostname;
|
|
833
|
+
}
|
|
834
|
+
async function prepareOblienTunnel(client, stateFile, port) {
|
|
835
|
+
let state = await readJSON(stateFile);
|
|
836
|
+
if (!state) {
|
|
837
|
+
state = { installationId: randomUUID() };
|
|
838
|
+
await writeJSON(stateFile, state);
|
|
839
|
+
}
|
|
840
|
+
if (state.oblien) {
|
|
841
|
+
try {
|
|
842
|
+
const { tunnel: tunnel2 } = await client.edgeTunnel.get(state.oblien.id);
|
|
843
|
+
if (tunnel2.status !== "active") throw new Error("This computer's Oblien tunnel is disabled. Enable it in your Oblien dashboard, then retry.");
|
|
844
|
+
if (tunnel2.port !== port) await client.edgeTunnel.update(tunnel2.id, { port });
|
|
845
|
+
const saved2 = { id: tunnel2.id, url: websocketURL(tunnel2.url) };
|
|
846
|
+
await writeJSON(stateFile, { ...state, oblien: saved2 });
|
|
847
|
+
return saved2;
|
|
848
|
+
} catch (error) {
|
|
849
|
+
if (error?.status !== 404) throw error;
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
const slug = `mw-${state.installationId.replaceAll("-", "")}`;
|
|
853
|
+
const { tunnels } = await client.edgeTunnel.list();
|
|
854
|
+
const existing = tunnels.find((tunnel2) => tunnel2.slug === slug);
|
|
855
|
+
const tunnel = existing ?? (await client.edgeTunnel.create({ name: `Mindwire \xB7 ${hostname()}`, slug, port })).tunnel;
|
|
856
|
+
if (tunnel.status !== "active") throw new Error("This computer's tunnel is disabled in Oblien.");
|
|
857
|
+
if (tunnel.port !== port) await client.edgeTunnel.update(tunnel.id, { port });
|
|
858
|
+
const saved = { id: tunnel.id, url: websocketURL(tunnel.url) };
|
|
859
|
+
await writeJSON(stateFile, { ...state, oblien: saved });
|
|
860
|
+
return saved;
|
|
861
|
+
}
|
|
862
|
+
async function runVisible(command, args) {
|
|
863
|
+
await new Promise((resolve2, reject) => {
|
|
864
|
+
const child = spawn(command, args, { stdio: "inherit", windowsHide: true });
|
|
865
|
+
child.once("error", () => reject(new Error("Couldn't open the provider's login helper. Try again.")));
|
|
866
|
+
child.once("exit", (code) => code === 0 ? resolve2() : reject(new Error("Provider setup didn't finish. Your existing connection is unchanged.")));
|
|
867
|
+
});
|
|
868
|
+
}
|
|
869
|
+
async function setupCloudflare(directory, stateFile, prompt) {
|
|
870
|
+
const state = await readJSON(stateFile);
|
|
871
|
+
const domain = providerHostname(await prompt.question("Cloudflare hostname (for example computer.example.com): "));
|
|
872
|
+
const command = await cloudflareCommand({ cacheDir: path.join(directory, "tools"), onProgress: (text) => prompt.print(text) });
|
|
873
|
+
let saved = state.cloudflare;
|
|
874
|
+
if (!saved) {
|
|
875
|
+
const certificates = process.env.TUNNEL_ORIGIN_CERT ? [process.env.TUNNEL_ORIGIN_CERT] : [path.join(homedir(), ".cloudflared", "cert.pem"), path.join(homedir(), ".cloudflare-warp", "cert.pem")];
|
|
876
|
+
const hasLogin = (await Promise.all(certificates.map((file) => fs.stat(file).then((value) => value.isFile(), () => false)))).some(Boolean);
|
|
877
|
+
if (!hasLogin) {
|
|
878
|
+
prompt.print("\nSign in to Cloudflare and select the zone for this hostname.\nCloudflare will store its login certificate on this computer.");
|
|
879
|
+
await runVisible(command, ["tunnel", "login"]);
|
|
880
|
+
} else prompt.print("Using your saved Cloudflare login.");
|
|
881
|
+
const name = `mindwire-${state.installationId}`;
|
|
882
|
+
const credentialsFile = path.join(directory, "providers", "cloudflare-tunnel.json");
|
|
883
|
+
let credentials = await readJSON(credentialsFile);
|
|
884
|
+
if (!credentials?.TunnelID) {
|
|
885
|
+
try {
|
|
886
|
+
await execute2(command, ["tunnel", "create", "--credentials-file", credentialsFile, name], { timeout: 6e4, maxBuffer: 65536 });
|
|
887
|
+
} catch {
|
|
888
|
+
throw new Error("Cloudflare couldn't create the named tunnel. Check your account and run mindwire connection cloudflare again.");
|
|
889
|
+
}
|
|
890
|
+
credentials = await readJSON(credentialsFile);
|
|
891
|
+
}
|
|
892
|
+
if (!credentials?.TunnelID || !/^[a-f0-9-]{36}$/i.test(credentials.TunnelID)) throw new Error("Cloudflare didn't save the tunnel credentials. Retry setup.");
|
|
893
|
+
await fs.chmod(credentialsFile, 384);
|
|
894
|
+
saved = { id: credentials.TunnelID, credentialsFile };
|
|
895
|
+
await writeJSON(stateFile, { ...state, cloudflare: saved });
|
|
896
|
+
}
|
|
897
|
+
try {
|
|
898
|
+
await execute2(command, ["tunnel", "route", "dns", saved.id, domain], { timeout: 6e4, maxBuffer: 65536 });
|
|
899
|
+
} catch {
|
|
900
|
+
throw new Error("Cloudflare couldn't assign this hostname. Check the zone and existing DNS record, then retry. Mindwire does not overwrite another service's DNS.");
|
|
901
|
+
}
|
|
902
|
+
return { kind: "cloudflare", url: `wss://${domain}/ssh`, cloudflareTunnelId: saved.id, cloudflareCredentialsFile: saved.credentialsFile };
|
|
903
|
+
}
|
|
904
|
+
async function setupProvider(options) {
|
|
905
|
+
const { directory, provider, prompt } = options;
|
|
906
|
+
await fs.mkdir(path.join(directory, "providers"), { recursive: true, mode: 448 });
|
|
907
|
+
const lock = await acquireProcessLock(path.join(directory, "connection-setup.lock"));
|
|
908
|
+
try {
|
|
909
|
+
const config = options.config ?? defaultComputerConfig();
|
|
910
|
+
const stateFile = path.join(directory, "computer-providers.json");
|
|
911
|
+
if (!await readJSON(stateFile)) await writeJSON(stateFile, { installationId: randomUUID() });
|
|
912
|
+
switch (provider) {
|
|
913
|
+
case "automatic":
|
|
914
|
+
prompt.print("No Mindwire or Oblien account is needed. Cloudflare carries encrypted SSH traffic; the Mindwire directory helps your paired phone find this computer when its address changes. Use --directory-url or MINDWIRE_DIRECTORY_URL for a self-hosted directory.");
|
|
915
|
+
return { relay: { kind: "cloudflare" } };
|
|
916
|
+
case "temporary":
|
|
917
|
+
return { relay: { kind: "cloudflare" } };
|
|
918
|
+
case "vpn": {
|
|
919
|
+
prompt.print("Use a hostname reachable from your phone's VPN, such as your Tailscale or WireGuard network. Mindwire still checks your paired SSH keys.");
|
|
920
|
+
const host = (await prompt.question("Computer's VPN hostname or IP: ")).trim();
|
|
921
|
+
if (!host || host.length > 255 || /[\s/\\?#@]/.test(host)) throw new Error("Enter a valid VPN hostname or IP address.");
|
|
922
|
+
return { relay: { kind: "none" }, host };
|
|
923
|
+
}
|
|
924
|
+
case "custom": {
|
|
925
|
+
prompt.print(`Forward WebSocket traffic to http://127.0.0.1:${config.websocketPort || 8792}. Keep your tunnel running at a persistent HTTPS hostname.`);
|
|
926
|
+
return { relay: { kind: "custom", url: websocketURL(await prompt.question("Secure tunnel URL: ")) } };
|
|
927
|
+
}
|
|
928
|
+
case "cloudflare":
|
|
929
|
+
return { relay: await setupCloudflare(directory, stateFile, prompt) };
|
|
930
|
+
case "ngrok": {
|
|
931
|
+
const url = "https://dashboard.ngrok.com/get-started/your-authtoken";
|
|
932
|
+
prompt.print(`
|
|
933
|
+
Sign in to ngrok to get this computer's authtoken:
|
|
934
|
+
${url}
|
|
935
|
+
The token stays on this computer and is never sent to your phone.`);
|
|
936
|
+
prompt.open(url);
|
|
937
|
+
const token = await prompt.secret("ngrok authtoken (hidden): ");
|
|
938
|
+
if (!token || token.length > 4096 || /\s/.test(token)) throw new Error("Enter a valid ngrok authtoken.");
|
|
939
|
+
prompt.print("Use the dev domain or reserved domain shown at https://dashboard.ngrok.com/domains. Each computer needs its own address.");
|
|
940
|
+
const host = providerHostname(await prompt.question("ngrok domain: "));
|
|
941
|
+
const tokenFile = path.join(directory, "providers", `ngrok-${randomUUID()}.json`);
|
|
942
|
+
await writeJSON(tokenFile, { token });
|
|
943
|
+
return { relay: { kind: "ngrok", url: `wss://${host}/ssh`, ngrokTokenFile: tokenFile } };
|
|
944
|
+
}
|
|
945
|
+
case "oblien": {
|
|
946
|
+
const credentialsFile = path.join(directory, "providers", `oblien-${randomUUID()}.json`);
|
|
947
|
+
const existing = config.relay.oblienCredentialsFile && await readJSON(config.relay.oblienCredentialsFile);
|
|
948
|
+
if (existing) await writeJSON(credentialsFile, existing);
|
|
949
|
+
try {
|
|
950
|
+
const client = await signInOblien(credentialsFile, prompt);
|
|
951
|
+
const runtime = await readJSON(path.join(directory, "computer-runtime.json"));
|
|
952
|
+
const tunnel = await prepareOblienTunnel(client, stateFile, runtime?.websocketPort || config.websocketPort || 8792);
|
|
953
|
+
return { relay: { kind: "oblien", url: tunnel.url, oblienTunnelId: tunnel.id, oblienCredentialsFile: credentialsFile } };
|
|
954
|
+
} catch (error) {
|
|
955
|
+
await fs.rm(credentialsFile, { force: true });
|
|
956
|
+
throw error;
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
throw new Error("Unsupported connection provider.");
|
|
961
|
+
} finally {
|
|
962
|
+
await lock?.close();
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
function openSetupURL(value) {
|
|
966
|
+
const url = new URL(value);
|
|
967
|
+
if (url.protocol !== "https:" || url.username || url.password) throw new Error("Invalid provider login URL.");
|
|
968
|
+
const [command, args] = process.platform === "darwin" ? ["open", [url.href]] : process.platform === "win32" ? ["rundll32.exe", ["url.dll,FileProtocolHandler", url.href]] : ["xdg-open", [url.href]];
|
|
969
|
+
const child = spawn(command, [...args], { stdio: "ignore", windowsHide: true });
|
|
970
|
+
child.on("error", () => {
|
|
971
|
+
});
|
|
972
|
+
child.unref();
|
|
973
|
+
}
|
|
974
|
+
function question(text, hidden) {
|
|
975
|
+
if (!process.stdin.isTTY) throw new Error("Connection setup needs an interactive terminal, or explicit provider options.");
|
|
976
|
+
process.stderr.write(text);
|
|
977
|
+
const output = hidden ? new Writable({ write(_chunk, _encoding, done) {
|
|
978
|
+
done();
|
|
979
|
+
} }) : process.stderr;
|
|
980
|
+
const input = createInterface$1({ input: process.stdin, output, terminal: true });
|
|
981
|
+
return new Promise((resolve2, reject) => {
|
|
982
|
+
let answered = false;
|
|
983
|
+
input.once("SIGINT", () => {
|
|
984
|
+
input.close();
|
|
985
|
+
});
|
|
986
|
+
input.once("close", () => {
|
|
987
|
+
if (!answered) reject(new Error("Connection setup cancelled."));
|
|
988
|
+
});
|
|
989
|
+
input.question("", (value) => {
|
|
990
|
+
answered = true;
|
|
991
|
+
input.close();
|
|
992
|
+
if (hidden) process.stderr.write("\n");
|
|
993
|
+
resolve2(value.trim());
|
|
994
|
+
});
|
|
995
|
+
});
|
|
996
|
+
}
|
|
997
|
+
var terminalSetupPrompt = {
|
|
998
|
+
question: (text) => question(text, false),
|
|
999
|
+
secret: (text) => question(text, true),
|
|
1000
|
+
print: (text) => {
|
|
1001
|
+
process.stderr.write(text + "\n");
|
|
1002
|
+
},
|
|
1003
|
+
open: openSetupURL
|
|
1004
|
+
};
|
|
1005
|
+
var OblienTunnelLease = class {
|
|
1006
|
+
constructor(tunnelId, client, now = Date.now) {
|
|
1007
|
+
this.tunnelId = tunnelId;
|
|
1008
|
+
this.client = client;
|
|
1009
|
+
this.now = now;
|
|
1010
|
+
}
|
|
1011
|
+
tunnelId;
|
|
1012
|
+
client;
|
|
1013
|
+
now;
|
|
1014
|
+
value;
|
|
1015
|
+
pending;
|
|
1016
|
+
get(force = false) {
|
|
1017
|
+
if (this.pending) return this.pending;
|
|
1018
|
+
const expires = this.value && (Date.parse(this.value.expires_at) || tokenExpiry(this.value.token) || 0);
|
|
1019
|
+
if (!force && this.value && expires - this.now() > 5 * 6e4) return Promise.resolve(this.value);
|
|
1020
|
+
const pending = (async () => {
|
|
1021
|
+
const client = await this.client();
|
|
1022
|
+
const value = await client.edgeTunnel.issueToken(this.tunnelId);
|
|
1023
|
+
const url = new URL(value.connect_url);
|
|
1024
|
+
if (url.protocol !== "wss:" || url.username || url.password || url.hash) {
|
|
1025
|
+
throw new ProviderConnectionError("provider_configuration", "Oblien returned an invalid broker address.");
|
|
1026
|
+
}
|
|
1027
|
+
const expiry = Date.parse(value.expires_at) || tokenExpiry(value.token) || 0;
|
|
1028
|
+
if (!value.token || expiry - this.now() < 3e4) throw new Error("Oblien returned a token too close to expiry. Retry shortly.");
|
|
1029
|
+
this.value = value;
|
|
1030
|
+
return value;
|
|
1031
|
+
})();
|
|
1032
|
+
this.pending = pending;
|
|
1033
|
+
void pending.finally(() => {
|
|
1034
|
+
if (this.pending === pending) this.pending = void 0;
|
|
1035
|
+
}).catch(() => {
|
|
1036
|
+
});
|
|
1037
|
+
return pending;
|
|
1038
|
+
}
|
|
1039
|
+
};
|
|
1040
|
+
async function runOblien(options, port, signal) {
|
|
1041
|
+
if (!options.oblienCredentialsFile || !options.oblienTunnelId || !options.url) throw new Error("Oblien setup is incomplete. Run mindwire connection oblien.");
|
|
1042
|
+
const credentialsFile = options.oblienCredentialsFile;
|
|
1043
|
+
const lease = new OblienTunnelLease(options.oblienTunnelId, () => authenticatedOblien(credentialsFile));
|
|
1044
|
+
let failures = 0, authFailures = 0, forceToken = false;
|
|
1045
|
+
while (!signal.aborted) {
|
|
1046
|
+
let carrier;
|
|
1047
|
+
try {
|
|
1048
|
+
const token = await lease.get(forceToken);
|
|
1049
|
+
forceToken = false;
|
|
1050
|
+
signal.throwIfAborted();
|
|
1051
|
+
carrier = new TunnelClient({ connectUrl: token.connect_url, token: token.token, localPort: port, tokenExpiresAt: token.expires_at });
|
|
1052
|
+
const tunnel = carrier;
|
|
1053
|
+
let opened = false;
|
|
1054
|
+
let connectionError;
|
|
1055
|
+
let finish;
|
|
1056
|
+
const ended = new Promise((resolve2) => {
|
|
1057
|
+
finish = resolve2;
|
|
1058
|
+
});
|
|
1059
|
+
let endedValue = false;
|
|
1060
|
+
const end = () => {
|
|
1061
|
+
endedValue = true;
|
|
1062
|
+
finish();
|
|
1063
|
+
};
|
|
1064
|
+
tunnel.on("open", () => {
|
|
1065
|
+
opened = true;
|
|
1066
|
+
failures = 0;
|
|
1067
|
+
authFailures = 0;
|
|
1068
|
+
process.stdout.write(JSON.stringify({ event: "relay_ready", url: options.url }) + "\n");
|
|
1069
|
+
});
|
|
1070
|
+
tunnel.on("disconnect", end);
|
|
1071
|
+
tunnel.on("auth-failed", () => {
|
|
1072
|
+
connectionError = new ProviderSignInRequired();
|
|
1073
|
+
end();
|
|
1074
|
+
});
|
|
1075
|
+
tunnel.on("error", (error) => {
|
|
1076
|
+
connectionError = error;
|
|
1077
|
+
end();
|
|
1078
|
+
});
|
|
1079
|
+
const abort = () => {
|
|
1080
|
+
tunnel.close();
|
|
1081
|
+
end();
|
|
1082
|
+
};
|
|
1083
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
1084
|
+
try {
|
|
1085
|
+
tunnel.connect();
|
|
1086
|
+
const started = Date.now();
|
|
1087
|
+
while (!endedValue && !signal.aborted) {
|
|
1088
|
+
const tick = new AbortController();
|
|
1089
|
+
try {
|
|
1090
|
+
await Promise.race([ended, setTimeout$1(opened ? 6e4 : 1e3, void 0, { signal: tick.signal })]);
|
|
1091
|
+
} finally {
|
|
1092
|
+
tick.abort();
|
|
1093
|
+
}
|
|
1094
|
+
if (!opened && Date.now() - started > 2e4) break;
|
|
1095
|
+
if (opened && !endedValue) {
|
|
1096
|
+
await lease.get().catch(() => {
|
|
1097
|
+
});
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
if (connectionError) throw connectionError;
|
|
1101
|
+
} finally {
|
|
1102
|
+
signal.removeEventListener("abort", abort);
|
|
1103
|
+
}
|
|
1104
|
+
} catch (error) {
|
|
1105
|
+
if (signal.aborted) return;
|
|
1106
|
+
const failure = providerFailure(error, "Oblien");
|
|
1107
|
+
if (failure.code === "provider_sign_in" && carrier && ++authFailures === 1) forceToken = true;
|
|
1108
|
+
else if (providerNeedsAction(failure.code)) throw failure;
|
|
1109
|
+
} finally {
|
|
1110
|
+
carrier?.close();
|
|
1111
|
+
}
|
|
1112
|
+
if (!signal.aborted) await setTimeout$1(Math.min(3e4, 1e3 * 2 ** Math.min(5, failures++)), void 0, { signal });
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
async function runNgrok(options, port, signal) {
|
|
1116
|
+
if (!options.ngrokTokenFile || !options.url) throw new Error("ngrok setup is incomplete. Run mindwire connection ngrok.");
|
|
1117
|
+
const credentials = await readJSON(options.ngrokTokenFile);
|
|
1118
|
+
if (!credentials?.token) throw new ProviderSignInRequired("ngrok");
|
|
1119
|
+
const ngrok = await import('@ngrok/ngrok');
|
|
1120
|
+
const domain = new URL(websocketURL(options.url)).hostname;
|
|
1121
|
+
const listener = await ngrok.forward({
|
|
1122
|
+
addr: `http://127.0.0.1:${port}`,
|
|
1123
|
+
authtoken: credentials.token,
|
|
1124
|
+
domain,
|
|
1125
|
+
schemes: ["https"],
|
|
1126
|
+
onStatusChange: () => {
|
|
1127
|
+
}
|
|
1128
|
+
});
|
|
1129
|
+
try {
|
|
1130
|
+
if (websocketURL(listener.url() ?? "") !== websocketURL(options.url)) throw new Error("ngrok returned a different address.");
|
|
1131
|
+
process.stdout.write(JSON.stringify({ event: "relay_ready", url: options.url }) + "\n");
|
|
1132
|
+
if (!signal.aborted) await new Promise((resolve2) => signal.addEventListener("abort", () => resolve2(), { once: true }));
|
|
1133
|
+
} finally {
|
|
1134
|
+
await listener.close();
|
|
1135
|
+
await ngrok.disconnect();
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
async function runManagedRelay(file) {
|
|
1139
|
+
const config = await readJSON(file);
|
|
1140
|
+
if (!config || !Number.isInteger(config.port) || config.port < 1 || config.port > 65535) throw new Error("Invalid relay configuration.");
|
|
1141
|
+
const abort = new AbortController(), stop = () => abort.abort();
|
|
1142
|
+
process.on("SIGTERM", stop);
|
|
1143
|
+
process.on("SIGINT", stop);
|
|
1144
|
+
try {
|
|
1145
|
+
if (config.options.kind === "oblien") await runOblien(config.options, config.port, abort.signal);
|
|
1146
|
+
else if (config.options.kind === "ngrok") await runNgrok(config.options, config.port, abort.signal);
|
|
1147
|
+
else throw new Error("Unsupported managed relay.");
|
|
1148
|
+
} catch (error) {
|
|
1149
|
+
if (!abort.signal.aborted) {
|
|
1150
|
+
const failure = providerFailure(error, config.options.kind === "oblien" ? "Oblien" : "ngrok");
|
|
1151
|
+
process.stdout.write(JSON.stringify({ event: "relay_error", message: failure.message, code: failure.code }) + "\n");
|
|
1152
|
+
process.exitCode = 1;
|
|
1153
|
+
}
|
|
1154
|
+
} finally {
|
|
1155
|
+
process.off("SIGTERM", stop);
|
|
1156
|
+
process.off("SIGINT", stop);
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
|
|
511
1160
|
// src/cli.ts
|
|
512
1161
|
var help = `Mindwire \u2014 connect this computer to your phone
|
|
513
1162
|
|
|
514
1163
|
npm install -g mindwire
|
|
515
1164
|
mindwire connect Connect your phone; saved keys reconnect automatically
|
|
516
|
-
mindwire reconnect
|
|
517
|
-
mindwire connect pair Pair
|
|
1165
|
+
mindwire reconnect Retry the saved connection
|
|
1166
|
+
mindwire connect pair Pair a phone, or repair a lost phone key/address
|
|
1167
|
+
mindwire connection Choose a persistent connection provider
|
|
1168
|
+
mindwire connection automatic Cloudflare traffic with encrypted Mindwire address recovery
|
|
1169
|
+
mindwire connection oblien Sign in and use an Oblien tunnel
|
|
1170
|
+
mindwire connection cloudflare Sign in and configure your Cloudflare domain
|
|
1171
|
+
mindwire connection ngrok Connect your ngrok account and reserved domain
|
|
1172
|
+
mindwire discovery enable Enable encrypted address recovery (no account)
|
|
1173
|
+
mindwire discovery status Check encrypted address recovery
|
|
1174
|
+
mindwire discovery disable Withdraw the saved address directory
|
|
518
1175
|
mindwire connect --relay none --host laptop.tailnet Use your existing VPN
|
|
519
1176
|
mindwire connect --relay none Direct SSH only (reachable network required)
|
|
520
1177
|
mindwire connect --relay cloudflare Cloudflare quick tunnel
|
|
@@ -533,9 +1190,13 @@ var help = `Mindwire \u2014 connect this computer to your phone
|
|
|
533
1190
|
Options:
|
|
534
1191
|
--directory PATH Initial workspace directory (default: home)
|
|
535
1192
|
--host HOST Reachable LAN, public or VPN hostname
|
|
536
|
-
--relay none|cloudflare|ngrok|custom
|
|
1193
|
+
--relay none|oblien|cloudflare|ngrok|custom Explicit provider (skips the setup menu)
|
|
537
1194
|
--relay-url URL Stable configured WebSocket hostname
|
|
538
1195
|
--cloudflare-token-file PATH Named Cloudflare tunnel token file
|
|
1196
|
+
--ngrok-token-file PATH Private JSON file containing {"token":"\u2026"}
|
|
1197
|
+
--oblien-credentials-file PATH Private Oblien session/API credential JSON
|
|
1198
|
+
--oblien-tunnel-id ID This computer's existing Oblien tunnel ID
|
|
1199
|
+
--directory-url URL Directory HTTPS API URL (or MINDWIRE_DIRECTORY_URL)
|
|
539
1200
|
--state-dir PATH Private state directory
|
|
540
1201
|
--json Structured output (QR invitation includes a secret)
|
|
541
1202
|
--qr auto|terminal|browser Fit the QR to your terminal or open a local page
|
|
@@ -544,10 +1205,10 @@ Options:
|
|
|
544
1205
|
--no-startup Skip automatic startup setup
|
|
545
1206
|
--replace-device DEVICE_ID With approve: replace a lost phone key (repeatable)
|
|
546
1207
|
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
1208
|
+
Interactive setup offers persistent addresses across Wi-Fi and mobile networks.
|
|
1209
|
+
Provider logins and tokens stay on your computer. Relays carry encrypted SSH;
|
|
1210
|
+
they can see connection metadata, but cannot decrypt files, commands or chats.
|
|
1211
|
+
Quick Cloudflare tunnels remain available for temporary use. Direct SSH needs a reachable network or VPN.
|
|
551
1212
|
Nothing changes your system SSH login.
|
|
552
1213
|
`;
|
|
553
1214
|
function safeName(text) {
|
|
@@ -565,6 +1226,11 @@ async function main(args = process.argv.slice(2)) {
|
|
|
565
1226
|
relay: { type: "string" },
|
|
566
1227
|
"relay-url": { type: "string" },
|
|
567
1228
|
"cloudflare-token-file": { type: "string" },
|
|
1229
|
+
"ngrok-token-file": { type: "string" },
|
|
1230
|
+
"oblien-credentials-file": { type: "string" },
|
|
1231
|
+
"oblien-tunnel-id": { type: "string" },
|
|
1232
|
+
"relay-config": { type: "string" },
|
|
1233
|
+
"directory-url": { type: "string" },
|
|
568
1234
|
"daemon-bin": { type: "string" },
|
|
569
1235
|
bind: { type: "string" },
|
|
570
1236
|
port: { type: "string" },
|
|
@@ -583,6 +1249,7 @@ async function main(args = process.argv.slice(2)) {
|
|
|
583
1249
|
if (values.qr && !["auto", "terminal", "browser"].includes(values.qr)) throw new Error("Choose --qr auto, terminal or browser.");
|
|
584
1250
|
if (values.startup && values["no-startup"]) throw new Error("Choose either --startup or --no-startup.");
|
|
585
1251
|
const directory = path.resolve(values["state-dir"] ?? defaultStateDirectory());
|
|
1252
|
+
const discoveryOptions = { url: values["directory-url"] };
|
|
586
1253
|
const emit = (event, data = {}, text) => {
|
|
587
1254
|
if (values.json) process.stdout.write(JSON.stringify({ event, ...data }) + "\n");
|
|
588
1255
|
else if (text) process.stdout.write(text + "\n");
|
|
@@ -591,6 +1258,11 @@ async function main(args = process.argv.slice(2)) {
|
|
|
591
1258
|
await superviseComputer(directory);
|
|
592
1259
|
return;
|
|
593
1260
|
}
|
|
1261
|
+
if (command === "_relay") {
|
|
1262
|
+
if (!values["relay-config"]) throw new Error("Missing relay configuration.");
|
|
1263
|
+
await runManagedRelay(path.resolve(values["relay-config"]));
|
|
1264
|
+
return;
|
|
1265
|
+
}
|
|
594
1266
|
if (command === "_watch") {
|
|
595
1267
|
await watchComputer(directory, fileURLToPath(import.meta.url));
|
|
596
1268
|
return;
|
|
@@ -602,9 +1274,20 @@ async function main(args = process.argv.slice(2)) {
|
|
|
602
1274
|
emit("startup", state, state.enabled ? "Mindwire starts after you sign in to this computer and recovers automatically." : "Automatic startup is off. Use mindwire start to run the service.");
|
|
603
1275
|
return;
|
|
604
1276
|
}
|
|
605
|
-
if (
|
|
606
|
-
const
|
|
1277
|
+
if (command === "discovery") {
|
|
1278
|
+
const action = positionals[1] ?? "status";
|
|
1279
|
+
if (!["enable", "disable", "status"].includes(action)) throw new Error("Use mindwire discovery enable, disable or status.");
|
|
1280
|
+
const client2 = action === "status" ? await computerClient(directory) : await ensureComputer(directory, fileURLToPath(import.meta.url), {}, void 0, { waitForRoutes: false });
|
|
1281
|
+
const status = action === "enable" ? await enableAddressDiscovery({ directory, computer: client2, prompt: terminalSetupPrompt, ...discoveryOptions }) : action === "disable" ? await client2.computer.configureDiscovery(false) : await client2.computer.discovery();
|
|
1282
|
+
emit("discovery", status, discoveryDescription(status));
|
|
1283
|
+
if (action === "enable") emit("discovery_hint", {}, "Open each saved phone once while its current address works. Pair new phones with mindwire connect pair.");
|
|
1284
|
+
return;
|
|
1285
|
+
}
|
|
1286
|
+
if (["connect", "start", "reconnect", "connection"].includes(command)) {
|
|
1287
|
+
let requestedAction = command === "reconnect" ? "reconnect" : command === "connect" ? connectionAction(positionals[1]) : void 0;
|
|
1288
|
+
let automaticDiscovery = false, addressChanged = false;
|
|
607
1289
|
const patch = {};
|
|
1290
|
+
const previous = await readJSON(path.join(directory, "computer-config.json"));
|
|
608
1291
|
if (values.directory) patch.directory = path.resolve(values.directory);
|
|
609
1292
|
if (values.host) patch.host = values.host;
|
|
610
1293
|
if (values.bind) patch.bind = values.bind;
|
|
@@ -617,33 +1300,64 @@ async function main(args = process.argv.slice(2)) {
|
|
|
617
1300
|
}
|
|
618
1301
|
if (values["daemon-bin"] || process.env.MINDWIRE_DAEMON) patch.daemonBin = path.resolve(values["daemon-bin"] ?? process.env.MINDWIRE_DAEMON);
|
|
619
1302
|
if (values.version) patch.version = values.version.replace(/^v/, "");
|
|
620
|
-
if (values.relay || values["relay-url"] || values["cloudflare-token-file"]) {
|
|
1303
|
+
if (values.relay || values["relay-url"] || values["cloudflare-token-file"] || values["ngrok-token-file"] || values["oblien-credentials-file"]) {
|
|
621
1304
|
const kind = values.relay ?? "custom";
|
|
622
|
-
if (!["none", "cloudflare", "ngrok", "custom"].includes(kind)) throw new Error("Choose relay none, cloudflare, ngrok or custom.");
|
|
1305
|
+
if (!["none", "oblien", "cloudflare", "ngrok", "custom"].includes(kind)) throw new Error("Choose relay none, oblien, cloudflare, ngrok or custom.");
|
|
1306
|
+
const tunnelId = values["oblien-tunnel-id"] === void 0 ? void 0 : Number(values["oblien-tunnel-id"]);
|
|
1307
|
+
if (tunnelId !== void 0 && (!Number.isSafeInteger(tunnelId) || tunnelId <= 0)) throw new Error("Invalid Oblien tunnel ID.");
|
|
623
1308
|
patch.relay = {
|
|
624
1309
|
kind,
|
|
625
1310
|
url: values["relay-url"],
|
|
626
|
-
cloudflareTokenFile: values["cloudflare-token-file"] ? path.resolve(values["cloudflare-token-file"]) : void 0
|
|
1311
|
+
cloudflareTokenFile: values["cloudflare-token-file"] ? path.resolve(values["cloudflare-token-file"]) : void 0,
|
|
1312
|
+
ngrokTokenFile: values["ngrok-token-file"] ? path.resolve(values["ngrok-token-file"]) : void 0,
|
|
1313
|
+
oblienCredentialsFile: values["oblien-credentials-file"] ? path.resolve(values["oblien-credentials-file"]) : void 0,
|
|
1314
|
+
oblienTunnelId: tunnelId
|
|
627
1315
|
};
|
|
628
1316
|
}
|
|
1317
|
+
const interactive = !values.json && !!process.stdin.isTTY;
|
|
1318
|
+
const needsSetup = command === "connection" || command === "connect" && !previous && !patch.relay && interactive || patch.relay?.kind === "oblien" && !patch.relay.oblienCredentialsFile;
|
|
1319
|
+
if (needsSetup) {
|
|
1320
|
+
const selected = command === "connection" && positionals[1] ? connectionProvider(positionals[1]) : void 0;
|
|
1321
|
+
if (!interactive && selected !== "automatic" && selected !== "temporary")
|
|
1322
|
+
throw new Error("Run mindwire connection in an interactive terminal, or supply the provider's saved URL and credential-file options.");
|
|
1323
|
+
const provider = selected ?? (patch.relay?.kind === "oblien" ? "oblien" : await chooseProvider(terminalSetupPrompt));
|
|
1324
|
+
automaticDiscovery = provider === "automatic";
|
|
1325
|
+
Object.assign(patch, await setupProvider({
|
|
1326
|
+
directory,
|
|
1327
|
+
provider,
|
|
1328
|
+
prompt: terminalSetupPrompt,
|
|
1329
|
+
config: { ...defaultComputerConfig(), ...previous, ...patch }
|
|
1330
|
+
}));
|
|
1331
|
+
if (!previous || connectionAddress(patch.relay ?? previous.relay, patch.host ?? previous.host) !== connectionAddress(previous.relay, previous.host)) addressChanged = true;
|
|
1332
|
+
}
|
|
1333
|
+
if (automaticDiscovery && previous) {
|
|
1334
|
+
const running = await ensureComputer(directory, fileURLToPath(import.meta.url), {}, void 0, { waitForRoutes: false });
|
|
1335
|
+
await enableAddressDiscovery({ directory, computer: running, prompt: terminalSetupPrompt, ...discoveryOptions });
|
|
1336
|
+
}
|
|
629
1337
|
const progress = !values.json && process.stderr.isTTY && process.env.TERM !== "dumb" ? new ConnectionProgress(process.stderr) : void 0;
|
|
630
1338
|
emit("starting", {}, progress ? void 0 : "Starting Mindwire\u2026");
|
|
631
1339
|
const client2 = await ensureComputer(directory, fileURLToPath(import.meta.url), patch, (phase) => {
|
|
632
1340
|
if (progress) progress.update(phase);
|
|
633
1341
|
else emit("progress", { message: phase }, phase);
|
|
634
1342
|
}).finally(() => progress?.close());
|
|
1343
|
+
if (automaticDiscovery && !previous) await enableAddressDiscovery({ directory, computer: client2, prompt: terminalSetupPrompt, ...discoveryOptions });
|
|
635
1344
|
const info = await client2.computer.info();
|
|
636
|
-
|
|
1345
|
+
const savedConfig = await readJSON(path.join(directory, "computer-config.json"));
|
|
1346
|
+
const connection = connectionInfo(savedConfig?.relay ?? { kind: "cloudflare" }, savedConfig?.host);
|
|
1347
|
+
emit("ready", { computerId: info.computerId, routes: info.routes, connection }, "Mindwire is running in the background.");
|
|
1348
|
+
emit("connection", connection, connectionDescription(connection));
|
|
637
1349
|
if (command === "start") return;
|
|
638
1350
|
if (info.routes.some((route) => route.kind === "websocket")) {
|
|
639
1351
|
emit("secure_connection", {}, "Your phone connects over end-to-end encrypted SSH. The tunnel carries encrypted traffic; only phones you approve can connect.");
|
|
640
1352
|
}
|
|
641
1353
|
const devices = await client2.computer.devices();
|
|
642
1354
|
const saved = devices.filter((device) => !device.revoked);
|
|
1355
|
+
if (addressChanged && !saved.some((device) => device.addressRecovery)) requestedAction = "pair";
|
|
1356
|
+
if (info.discovery?.enabled) emit("discovery", info.discovery, discoveryDescription(info.discovery));
|
|
643
1357
|
if (saved.length) emit("saved_devices", { devices: saved }, `
|
|
644
1358
|
Saved approvals on this computer
|
|
645
1359
|
${deviceSummary(saved)}`);
|
|
646
|
-
const
|
|
1360
|
+
const question2 = !values.json && process.stdin.isTTY ? async (prompt) => {
|
|
647
1361
|
const input = createInterface({ input: process.stdin, output: process.stderr });
|
|
648
1362
|
try {
|
|
649
1363
|
return await input.question(prompt);
|
|
@@ -651,13 +1365,13 @@ ${deviceSummary(saved)}`);
|
|
|
651
1365
|
input.close();
|
|
652
1366
|
}
|
|
653
1367
|
} : void 0;
|
|
654
|
-
const action = await chooseConnectionAction({ requested: requestedAction, devices });
|
|
1368
|
+
const action = await chooseConnectionAction({ requested: requestedAction, devices, persistent: connection.address === "persistent" });
|
|
655
1369
|
const startupPreference = await startupStatus(directory);
|
|
656
1370
|
const startupAction = await chooseStartupAction({
|
|
657
1371
|
state: startupPreference,
|
|
658
1372
|
startup: values.startup,
|
|
659
1373
|
noStartup: values["no-startup"],
|
|
660
|
-
question: action === "pair" && saved.length === 0 ?
|
|
1374
|
+
question: action === "pair" && saved.length === 0 ? question2 : void 0
|
|
661
1375
|
});
|
|
662
1376
|
if (startupAction !== void 0) {
|
|
663
1377
|
try {
|
|
@@ -671,11 +1385,12 @@ ${deviceSummary(saved)}`);
|
|
|
671
1385
|
}
|
|
672
1386
|
if (action === "resume") {
|
|
673
1387
|
const connected = saved.filter((device) => device.connected);
|
|
674
|
-
emit("connected", { computerId: info.computerId, devices: connected }, `
|
|
1388
|
+
if (connected.length) emit("connected", { computerId: info.computerId, devices: connected }, `
|
|
675
1389
|
Connected:
|
|
676
1390
|
${deviceSummary(connected)}
|
|
677
1391
|
Mindwire continues running in the background.`);
|
|
678
|
-
emit("
|
|
1392
|
+
else emit("waiting_for_phone", { computerId: info.computerId }, "\nReady for your saved phone. Open Mindwire; it will retry this computer's saved connection.");
|
|
1393
|
+
emit("connect_hint", {}, "If the connection was removed from your phone, run mindwire connect pair to scan again.");
|
|
679
1394
|
return;
|
|
680
1395
|
}
|
|
681
1396
|
const invitation = await client2.computer.invite((await client2.computer.info()).routes);
|
|
@@ -732,12 +1447,12 @@ ${safeName(pairing.request.name)} wants to connect.
|
|
|
732
1447
|
Device key: ${fingerprint}
|
|
733
1448
|
Access: files and commands as your computer account.`
|
|
734
1449
|
);
|
|
735
|
-
if (!
|
|
1450
|
+
if (!question2) {
|
|
736
1451
|
const command2 = approvalCommand(invitation.pairingId, pairing.request.id);
|
|
737
1452
|
emit("approval_command", { command: command2 }, `Review this device, then run:
|
|
738
1453
|
${command2}`);
|
|
739
1454
|
} else {
|
|
740
|
-
const decision = await choosePairingDecision({ replacements, question });
|
|
1455
|
+
const decision = await choosePairingDecision({ replacements, question: question2 });
|
|
741
1456
|
await client2.computer.decide(
|
|
742
1457
|
invitation.pairingId,
|
|
743
1458
|
pairing.request.id,
|
|
@@ -761,21 +1476,37 @@ ${command2}`);
|
|
|
761
1476
|
const client = await computerClient(directory);
|
|
762
1477
|
switch (command) {
|
|
763
1478
|
case "status": {
|
|
764
|
-
const [info, health, work, controller, startup] = await Promise.all([
|
|
1479
|
+
const [info, health, work, controller, startup, config, change] = await Promise.all([
|
|
765
1480
|
client.computer.info(),
|
|
766
1481
|
client.health(),
|
|
767
1482
|
client.service.updateStatus(),
|
|
768
1483
|
readJSON(path.join(directory, "computer-controller.json")),
|
|
769
|
-
startupStatus(directory)
|
|
1484
|
+
startupStatus(directory),
|
|
1485
|
+
readJSON(path.join(directory, "computer-config.json")),
|
|
1486
|
+
readJSON(path.join(directory, "computer-connection-change.json"))
|
|
770
1487
|
]);
|
|
1488
|
+
const connection = info.connection ?? (config && connectionInfo(config.relay, config.host));
|
|
1489
|
+
const connectionChange = change && { status: change.status, message: change.message };
|
|
771
1490
|
emit(
|
|
772
1491
|
"status",
|
|
773
|
-
{
|
|
1492
|
+
{
|
|
1493
|
+
...info,
|
|
1494
|
+
connection,
|
|
1495
|
+
daemonVersion: health.version,
|
|
1496
|
+
...work,
|
|
1497
|
+
startup,
|
|
1498
|
+
connectionChange,
|
|
1499
|
+
recovering: controller?.recovering,
|
|
1500
|
+
relayError: controller?.error,
|
|
1501
|
+
relayErrorCode: controller?.errorCode
|
|
1502
|
+
},
|
|
774
1503
|
`Mindwire ${health.version} \xB7 ${work.idle ? "idle" : "working"}
|
|
775
1504
|
Startup: ${startup.enabled ? "on" : "off"}
|
|
776
|
-
${info.routes.map((route) => route.kind === "ssh" ? `SSH ${route.host}:${route.port}` : route.url).join("\n")}${controller?.error ? `
|
|
777
|
-
${controller.error}` : ""}`
|
|
1505
|
+
${connection ? connectionDescription(connection) + "\n" : ""}${info.routes.map((route) => route.kind === "ssh" ? `SSH ${route.host}:${route.port}` : route.url).join("\n")}${controller?.error ? `
|
|
1506
|
+
${controller.error}` : ""}${change && change.status !== "complete" ? `
|
|
1507
|
+
Connection change: ${change.status}${change.message ? " \xB7 " + change.message : ""}` : ""}`
|
|
778
1508
|
);
|
|
1509
|
+
if (info.discovery?.enabled && !values.json) emit("discovery", info.discovery, discoveryDescription(info.discovery));
|
|
779
1510
|
break;
|
|
780
1511
|
}
|
|
781
1512
|
case "devices": {
|