@akms/mcp-ssh 0.1.1 → 0.1.2
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 +1 -1
- package/lib/cli.cjs +682 -337
- package/lib/cli.cjs.map +1 -1
- package/lib/cli.js +682 -337
- package/lib/cli.js.map +1 -1
- package/lib/index.cjs +1 -1
- package/lib/index.cjs.map +1 -1
- package/lib/index.d.cts +1 -1
- package/lib/index.d.ts +1 -1
- package/lib/index.js +1 -1
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
package/lib/cli.js
CHANGED
|
@@ -26,7 +26,7 @@ var CWD_MARKER = "__AKMS_SSH_CWD__";
|
|
|
26
26
|
var MAX_DIRECTORY_ENTRIES = 1e3;
|
|
27
27
|
var SERVER_NAME = "akms-mcp-ssh";
|
|
28
28
|
var PACKAGE_NAME = "@akms/mcp-ssh";
|
|
29
|
-
var SERVER_VERSION = "0.1.
|
|
29
|
+
var SERVER_VERSION = "0.1.2";
|
|
30
30
|
|
|
31
31
|
// src/_defs/command-rules.ts
|
|
32
32
|
var CRITICAL_PATH_PATTERN = new RegExp(
|
|
@@ -2359,6 +2359,23 @@ async function downloadFile(connection, remotePath, localPath) {
|
|
|
2359
2359
|
}
|
|
2360
2360
|
|
|
2361
2361
|
// src/modules/manage/connection-check.ts
|
|
2362
|
+
function profileFromEnv(output, env) {
|
|
2363
|
+
try {
|
|
2364
|
+
const profile = loadHostProfile(env);
|
|
2365
|
+
if (profile == null) {
|
|
2366
|
+
output.write(" The entry has no host.\n");
|
|
2367
|
+
}
|
|
2368
|
+
return profile;
|
|
2369
|
+
} catch (ex) {
|
|
2370
|
+
let detail = String(ex);
|
|
2371
|
+
if (ex instanceof Error) {
|
|
2372
|
+
detail = ex.message;
|
|
2373
|
+
}
|
|
2374
|
+
output.write(` The server would refuse to start with this entry: ${detail}
|
|
2375
|
+
`);
|
|
2376
|
+
return null;
|
|
2377
|
+
}
|
|
2378
|
+
}
|
|
2362
2379
|
function describeAuth(profile) {
|
|
2363
2380
|
if (profile.privateKeyPath != null) {
|
|
2364
2381
|
return `key ${profile.privateKeyPath}`;
|
|
@@ -2617,6 +2634,654 @@ function summarizeEntry(entry) {
|
|
|
2617
2634
|
notes: notes.join(", ")
|
|
2618
2635
|
};
|
|
2619
2636
|
}
|
|
2637
|
+
var PREFERRED_KEY_NAMES = ["id_ed25519", "id_ecdsa", "id_rsa"];
|
|
2638
|
+
var NON_KEY_FILE_NAMES = ["config", "known_hosts", "known_hosts.old", "authorized_keys", "environment"];
|
|
2639
|
+
var MAX_KEY_FILE_BYTES = 64 * 1024;
|
|
2640
|
+
var CLEAR_ANSWER = "-";
|
|
2641
|
+
var OTHER_KEY_PATH = "\0other";
|
|
2642
|
+
var FIELD_NAME_WIDTH = 17;
|
|
2643
|
+
var SUB_FIELDS = ["key", "passphrase", "password", "jump-host", "jump-port", "jump-user", "proxy"];
|
|
2644
|
+
var FIELD_NAMES = {
|
|
2645
|
+
"host": "Host",
|
|
2646
|
+
"port": "Port",
|
|
2647
|
+
"user": "User",
|
|
2648
|
+
"name": "Server name",
|
|
2649
|
+
"auth": "Authentication",
|
|
2650
|
+
"key": "Private key",
|
|
2651
|
+
"passphrase": "Passphrase",
|
|
2652
|
+
"password": "Password",
|
|
2653
|
+
"route": "Route",
|
|
2654
|
+
"jump-host": "Jump host",
|
|
2655
|
+
"jump-port": "Jump port",
|
|
2656
|
+
"jump-user": "Jump user",
|
|
2657
|
+
"proxy": "Proxy command",
|
|
2658
|
+
"description": "Description",
|
|
2659
|
+
"readonly": "Read-only",
|
|
2660
|
+
"paths": "File paths",
|
|
2661
|
+
"host-key": "Host key",
|
|
2662
|
+
"jump-host-key": "Jump host key"
|
|
2663
|
+
};
|
|
2664
|
+
var AUTH_LABELS = {
|
|
2665
|
+
key: "Private key file",
|
|
2666
|
+
password: "Password",
|
|
2667
|
+
agent: "SSH agent"
|
|
2668
|
+
};
|
|
2669
|
+
var ROUTE_LABELS = {
|
|
2670
|
+
"direct": "Directly",
|
|
2671
|
+
"jump": "Through a jump host",
|
|
2672
|
+
"proxy": "Through a proxy command",
|
|
2673
|
+
"jump-proxy": "Proxy command, then a jump host"
|
|
2674
|
+
};
|
|
2675
|
+
var REQUIRED_MARK = "(required)";
|
|
2676
|
+
function findPrivateKeys() {
|
|
2677
|
+
const sshDirectory = path.join(os5.homedir(), ".ssh");
|
|
2678
|
+
let names = [];
|
|
2679
|
+
try {
|
|
2680
|
+
names = fs6.readdirSync(sshDirectory);
|
|
2681
|
+
} catch {
|
|
2682
|
+
return [];
|
|
2683
|
+
}
|
|
2684
|
+
const keys = [];
|
|
2685
|
+
for (const name of names) {
|
|
2686
|
+
if (name.endsWith(".pub") == true || NON_KEY_FILE_NAMES.includes(name) == true) {
|
|
2687
|
+
continue;
|
|
2688
|
+
}
|
|
2689
|
+
const fullPath = path.join(sshDirectory, name);
|
|
2690
|
+
let head = "";
|
|
2691
|
+
try {
|
|
2692
|
+
const stat = fs6.statSync(fullPath);
|
|
2693
|
+
if (stat.isFile() == false || stat.size > MAX_KEY_FILE_BYTES) {
|
|
2694
|
+
continue;
|
|
2695
|
+
}
|
|
2696
|
+
const descriptor = fs6.openSync(fullPath, "r");
|
|
2697
|
+
const buffer = Buffer.alloc(64);
|
|
2698
|
+
const bytesRead = fs6.readSync(descriptor, buffer, 0, buffer.length, 0);
|
|
2699
|
+
fs6.closeSync(descriptor);
|
|
2700
|
+
head = buffer.subarray(0, bytesRead).toString("utf8");
|
|
2701
|
+
} catch {
|
|
2702
|
+
continue;
|
|
2703
|
+
}
|
|
2704
|
+
const isPemKey = head.startsWith("-----BEGIN") == true && head.includes("PRIVATE KEY") == true;
|
|
2705
|
+
const isPuttyKey = head.startsWith("PuTTY-User-Key-File") == true;
|
|
2706
|
+
if (isPemKey == false && isPuttyKey == false) {
|
|
2707
|
+
continue;
|
|
2708
|
+
}
|
|
2709
|
+
let hint = "";
|
|
2710
|
+
try {
|
|
2711
|
+
const publicKey = fs6.readFileSync(`${fullPath}.pub`, "utf8").trim().split(/\s+/);
|
|
2712
|
+
hint = publicKey[0].replace(/^ssh-/, "");
|
|
2713
|
+
if (publicKey.length > 2) {
|
|
2714
|
+
hint += ` \xB7 ${publicKey.slice(2).join(" ")}`;
|
|
2715
|
+
}
|
|
2716
|
+
} catch {
|
|
2717
|
+
}
|
|
2718
|
+
keys.push({ path: `~/.ssh/${name}`, hint });
|
|
2719
|
+
}
|
|
2720
|
+
const rank = (key) => {
|
|
2721
|
+
const index = PREFERRED_KEY_NAMES.indexOf(path.basename(key.path));
|
|
2722
|
+
if (index < 0) {
|
|
2723
|
+
return PREFERRED_KEY_NAMES.length;
|
|
2724
|
+
}
|
|
2725
|
+
return index;
|
|
2726
|
+
};
|
|
2727
|
+
return keys.sort((left, right) => rank(left) - rank(right) || left.path.localeCompare(right.path));
|
|
2728
|
+
}
|
|
2729
|
+
async function askPort(prompter, question, current) {
|
|
2730
|
+
for (; ; ) {
|
|
2731
|
+
const answer = await prompter.ask(question, String(current));
|
|
2732
|
+
const parsed = Number(answer);
|
|
2733
|
+
if (Number.isInteger(parsed) == true && parsed > 0 && parsed <= 65535) {
|
|
2734
|
+
return parsed;
|
|
2735
|
+
}
|
|
2736
|
+
prompter.write(" Enter a port number between 1 and 65535.\n");
|
|
2737
|
+
}
|
|
2738
|
+
}
|
|
2739
|
+
async function askOptional(prompter, question, current) {
|
|
2740
|
+
const answer = await prompter.ask(question, current);
|
|
2741
|
+
if (answer === "" || answer === CLEAR_ANSWER) {
|
|
2742
|
+
return void 0;
|
|
2743
|
+
}
|
|
2744
|
+
return answer;
|
|
2745
|
+
}
|
|
2746
|
+
async function askOptionalSecret(prompter, question, current) {
|
|
2747
|
+
let suffix = "(empty for none)";
|
|
2748
|
+
if (current != null) {
|
|
2749
|
+
suffix = `(enter keeps current, ${CLEAR_ANSWER} clears)`;
|
|
2750
|
+
}
|
|
2751
|
+
const answer = await prompter.askSecret(`${question} ${prompter.paint.dim(suffix)}`);
|
|
2752
|
+
if (answer === CLEAR_ANSWER) {
|
|
2753
|
+
return void 0;
|
|
2754
|
+
}
|
|
2755
|
+
if (answer === "") {
|
|
2756
|
+
return current;
|
|
2757
|
+
}
|
|
2758
|
+
return answer;
|
|
2759
|
+
}
|
|
2760
|
+
async function askKeyPath(prompter, current) {
|
|
2761
|
+
const found = findPrivateKeys();
|
|
2762
|
+
let picked = OTHER_KEY_PATH;
|
|
2763
|
+
if (found.length > 0 || current != null) {
|
|
2764
|
+
const items = [];
|
|
2765
|
+
if (current != null && found.some((key) => key.path === current) == false) {
|
|
2766
|
+
items.push({ value: current, label: current, hint: "current" });
|
|
2767
|
+
}
|
|
2768
|
+
for (const key of found) {
|
|
2769
|
+
items.push({ value: key.path, label: key.path, hint: key.hint });
|
|
2770
|
+
}
|
|
2771
|
+
items.push({ separator: true });
|
|
2772
|
+
items.push({ value: OTHER_KEY_PATH, key: "other", label: "Another path\u2026" });
|
|
2773
|
+
picked = await prompter.select("Private key", items, { defaultValue: current ?? found[0]?.path });
|
|
2774
|
+
}
|
|
2775
|
+
if (picked !== OTHER_KEY_PATH) {
|
|
2776
|
+
return picked;
|
|
2777
|
+
}
|
|
2778
|
+
for (; ; ) {
|
|
2779
|
+
const typed = await prompter.askRequired("Private key path");
|
|
2780
|
+
if (fs6.existsSync(expandHomePath(typed)) == true) {
|
|
2781
|
+
return typed;
|
|
2782
|
+
}
|
|
2783
|
+
prompter.write(` ${expandHomePath(typed)} does not exist.
|
|
2784
|
+
`);
|
|
2785
|
+
const useAnyway = await prompter.askYesNo("Use this path anyway?", false);
|
|
2786
|
+
if (useAnyway == true) {
|
|
2787
|
+
return typed;
|
|
2788
|
+
}
|
|
2789
|
+
}
|
|
2790
|
+
}
|
|
2791
|
+
function routeUsesJump(route) {
|
|
2792
|
+
return route === "jump" || route === "jump-proxy";
|
|
2793
|
+
}
|
|
2794
|
+
function routeUsesProxy(route) {
|
|
2795
|
+
return route === "proxy" || route === "jump-proxy";
|
|
2796
|
+
}
|
|
2797
|
+
function deriveServerName(host) {
|
|
2798
|
+
return `ssh-${host}`.replace(/[^A-Za-z0-9_-]+/g, "-").slice(0, 48);
|
|
2799
|
+
}
|
|
2800
|
+
function finalizeAnswers(state) {
|
|
2801
|
+
const answers = { ...state.answers };
|
|
2802
|
+
if (routeUsesJump(state.route) == false) {
|
|
2803
|
+
answers.jumpHost = void 0;
|
|
2804
|
+
answers.jumpPort = void 0;
|
|
2805
|
+
answers.jumpUser = void 0;
|
|
2806
|
+
answers.jumpHostKeyFingerprint = void 0;
|
|
2807
|
+
}
|
|
2808
|
+
if (routeUsesProxy(state.route) == false) {
|
|
2809
|
+
answers.proxyCommand = void 0;
|
|
2810
|
+
}
|
|
2811
|
+
if (answers.auth !== "key") {
|
|
2812
|
+
answers.keyPath = void 0;
|
|
2813
|
+
answers.passphrase = void 0;
|
|
2814
|
+
}
|
|
2815
|
+
if (answers.auth !== "password") {
|
|
2816
|
+
answers.password = void 0;
|
|
2817
|
+
}
|
|
2818
|
+
return answers;
|
|
2819
|
+
}
|
|
2820
|
+
function snapshotOf(state) {
|
|
2821
|
+
return JSON.stringify(finalizeAnswers(state));
|
|
2822
|
+
}
|
|
2823
|
+
function missingFields(state) {
|
|
2824
|
+
const { answers } = state;
|
|
2825
|
+
const missing = [];
|
|
2826
|
+
if (answers.host === "") {
|
|
2827
|
+
missing.push("host");
|
|
2828
|
+
}
|
|
2829
|
+
if (answers.username === "") {
|
|
2830
|
+
missing.push("user");
|
|
2831
|
+
}
|
|
2832
|
+
if (answers.auth === "key" && answers.keyPath == null) {
|
|
2833
|
+
missing.push("key");
|
|
2834
|
+
}
|
|
2835
|
+
if (answers.auth === "password" && answers.password == null) {
|
|
2836
|
+
missing.push("password");
|
|
2837
|
+
}
|
|
2838
|
+
if (routeUsesJump(state.route) == true && answers.jumpHost == null) {
|
|
2839
|
+
missing.push("jump-host");
|
|
2840
|
+
}
|
|
2841
|
+
if (routeUsesProxy(state.route) == true && answers.proxyCommand == null) {
|
|
2842
|
+
missing.push("proxy");
|
|
2843
|
+
}
|
|
2844
|
+
return missing;
|
|
2845
|
+
}
|
|
2846
|
+
function describePin(fingerprint) {
|
|
2847
|
+
if (fingerprint == null) {
|
|
2848
|
+
return "not pinned";
|
|
2849
|
+
}
|
|
2850
|
+
return `pinned \xB7 ${fingerprint.slice(0, 19)}\u2026`;
|
|
2851
|
+
}
|
|
2852
|
+
function valueOrRequired(value) {
|
|
2853
|
+
if (value == null || value === "") {
|
|
2854
|
+
return REQUIRED_MARK;
|
|
2855
|
+
}
|
|
2856
|
+
return value;
|
|
2857
|
+
}
|
|
2858
|
+
function fieldRow(id, value, hint) {
|
|
2859
|
+
let name = FIELD_NAMES[id];
|
|
2860
|
+
if (SUB_FIELDS.includes(id) == true) {
|
|
2861
|
+
name = ` ${name}`;
|
|
2862
|
+
}
|
|
2863
|
+
return {
|
|
2864
|
+
value: id,
|
|
2865
|
+
key: id,
|
|
2866
|
+
label: `${name.padEnd(FIELD_NAME_WIDTH)}${value}`,
|
|
2867
|
+
hint
|
|
2868
|
+
};
|
|
2869
|
+
}
|
|
2870
|
+
function buildRows(state, destination) {
|
|
2871
|
+
const { answers } = state;
|
|
2872
|
+
const rows = [];
|
|
2873
|
+
rows.push(fieldRow("host", valueOrRequired(answers.host)));
|
|
2874
|
+
rows.push(fieldRow("port", String(answers.port)));
|
|
2875
|
+
rows.push(fieldRow("user", valueOrRequired(answers.username)));
|
|
2876
|
+
let nameValue = answers.name;
|
|
2877
|
+
let nameHint = "what the agent sees";
|
|
2878
|
+
if (state.nameTouched == false) {
|
|
2879
|
+
nameHint = "follows the host until you change it";
|
|
2880
|
+
}
|
|
2881
|
+
if (nameValue === "") {
|
|
2882
|
+
nameValue = "ssh-<host>";
|
|
2883
|
+
}
|
|
2884
|
+
rows.push(fieldRow("name", nameValue, nameHint));
|
|
2885
|
+
rows.push(fieldRow("auth", AUTH_LABELS[answers.auth]));
|
|
2886
|
+
if (answers.auth === "key") {
|
|
2887
|
+
rows.push(fieldRow("key", valueOrRequired(answers.keyPath)));
|
|
2888
|
+
let passphrase = "none";
|
|
2889
|
+
if (answers.passphrase != null) {
|
|
2890
|
+
passphrase = "set";
|
|
2891
|
+
}
|
|
2892
|
+
rows.push(fieldRow("passphrase", passphrase));
|
|
2893
|
+
}
|
|
2894
|
+
if (answers.auth === "password") {
|
|
2895
|
+
let password = REQUIRED_MARK;
|
|
2896
|
+
if (answers.password != null) {
|
|
2897
|
+
password = "set";
|
|
2898
|
+
}
|
|
2899
|
+
rows.push(fieldRow("password", password, "stored in the config file as plain text"));
|
|
2900
|
+
}
|
|
2901
|
+
rows.push(fieldRow("route", ROUTE_LABELS[state.route]));
|
|
2902
|
+
if (routeUsesJump(state.route) == true) {
|
|
2903
|
+
rows.push(fieldRow("jump-host", valueOrRequired(answers.jumpHost)));
|
|
2904
|
+
rows.push(fieldRow("jump-port", String(answers.jumpPort ?? DEFAULT_SSH_PORT)));
|
|
2905
|
+
let jumpUser = answers.jumpUser;
|
|
2906
|
+
if (jumpUser == null || jumpUser === answers.username) {
|
|
2907
|
+
let loginUser = answers.username;
|
|
2908
|
+
if (loginUser === "") {
|
|
2909
|
+
loginUser = "\u2026";
|
|
2910
|
+
}
|
|
2911
|
+
jumpUser = `same as login (${loginUser})`;
|
|
2912
|
+
}
|
|
2913
|
+
rows.push(fieldRow("jump-user", jumpUser));
|
|
2914
|
+
}
|
|
2915
|
+
if (routeUsesProxy(state.route) == true) {
|
|
2916
|
+
rows.push(fieldRow("proxy", valueOrRequired(answers.proxyCommand), "%h %p %r \u2192 first hop"));
|
|
2917
|
+
}
|
|
2918
|
+
rows.push(fieldRow("description", answers.description ?? "none", "shown to the agent"));
|
|
2919
|
+
let readonly = "no";
|
|
2920
|
+
if (answers.readonly == true) {
|
|
2921
|
+
readonly = "yes";
|
|
2922
|
+
}
|
|
2923
|
+
rows.push(fieldRow("readonly", readonly, "enter toggles"));
|
|
2924
|
+
let paths = "anywhere";
|
|
2925
|
+
if (answers.allowedPaths.length > 0) {
|
|
2926
|
+
paths = answers.allowedPaths.join(", ");
|
|
2927
|
+
}
|
|
2928
|
+
rows.push(fieldRow("paths", paths, "where the file tools may read and write"));
|
|
2929
|
+
rows.push(fieldRow("host-key", describePin(answers.hostKeyFingerprint)));
|
|
2930
|
+
if (routeUsesJump(state.route) == true) {
|
|
2931
|
+
rows.push(fieldRow("jump-host-key", describePin(answers.jumpHostKeyFingerprint)));
|
|
2932
|
+
}
|
|
2933
|
+
rows.push({ separator: true });
|
|
2934
|
+
let testHint = "not run yet";
|
|
2935
|
+
if (state.lastTest === "ok") {
|
|
2936
|
+
testHint = "last run: OK";
|
|
2937
|
+
} else if (state.lastTest === "failed") {
|
|
2938
|
+
testHint = "last run: failed";
|
|
2939
|
+
}
|
|
2940
|
+
rows.push({ value: "test", key: "test", label: "Test the connection", hint: testHint });
|
|
2941
|
+
rows.push({ value: "save", key: "save", label: "Save", hint: `to ${destination}` });
|
|
2942
|
+
rows.push({ value: "cancel", key: "cancel", label: "Cancel" });
|
|
2943
|
+
return rows;
|
|
2944
|
+
}
|
|
2945
|
+
function nextCursor(state, destination, edited) {
|
|
2946
|
+
if (edited === "readonly") {
|
|
2947
|
+
return edited;
|
|
2948
|
+
}
|
|
2949
|
+
const values = buildRows(state, destination).flatMap((row) => {
|
|
2950
|
+
if ("value" in row) {
|
|
2951
|
+
return [row.value];
|
|
2952
|
+
}
|
|
2953
|
+
return [];
|
|
2954
|
+
});
|
|
2955
|
+
let index = values.indexOf(edited) + 1;
|
|
2956
|
+
if (SUB_FIELDS.includes(edited) == false) {
|
|
2957
|
+
while (index < values.length && SUB_FIELDS.includes(values[index]) == true) {
|
|
2958
|
+
index += 1;
|
|
2959
|
+
}
|
|
2960
|
+
}
|
|
2961
|
+
const next = values[index];
|
|
2962
|
+
if (next == null || next === "test") {
|
|
2963
|
+
return "save";
|
|
2964
|
+
}
|
|
2965
|
+
return next;
|
|
2966
|
+
}
|
|
2967
|
+
async function testConnection(prompter, output, state) {
|
|
2968
|
+
const answers = finalizeAnswers(state);
|
|
2969
|
+
output.write("\n");
|
|
2970
|
+
const profile = profileFromEnv(output, answersToEntry(answers).env);
|
|
2971
|
+
if (profile == null) {
|
|
2972
|
+
output.write("\n");
|
|
2973
|
+
state.lastTest = "failed";
|
|
2974
|
+
return false;
|
|
2975
|
+
}
|
|
2976
|
+
const result = await runConnectionCheck(profile, output);
|
|
2977
|
+
output.write("\n");
|
|
2978
|
+
if (result.connected == true) {
|
|
2979
|
+
if (result.hostKeyFingerprint != null && state.answers.hostKeyFingerprint == null) {
|
|
2980
|
+
const pin = await prompter.askYesNo("Pin this host key? (a changed key then aborts the connection)", true);
|
|
2981
|
+
if (pin == true) {
|
|
2982
|
+
state.answers.hostKeyFingerprint = result.hostKeyFingerprint;
|
|
2983
|
+
}
|
|
2984
|
+
}
|
|
2985
|
+
if (result.jumpHostKeyFingerprint != null && routeUsesJump(state.route) == true && state.answers.jumpHostKeyFingerprint == null) {
|
|
2986
|
+
const pin = await prompter.askYesNo("Pin the jump host key too?", true);
|
|
2987
|
+
if (pin == true) {
|
|
2988
|
+
state.answers.jumpHostKeyFingerprint = result.jumpHostKeyFingerprint;
|
|
2989
|
+
}
|
|
2990
|
+
}
|
|
2991
|
+
state.lastTest = "ok";
|
|
2992
|
+
state.testedSnapshot = snapshotOf(state);
|
|
2993
|
+
return true;
|
|
2994
|
+
}
|
|
2995
|
+
const hostKeyChanged = result.hostKeyFingerprint != null && state.answers.hostKeyFingerprint != null && result.hostKeyFingerprint !== state.answers.hostKeyFingerprint;
|
|
2996
|
+
if (hostKeyChanged == true) {
|
|
2997
|
+
output.write(" The host presented a different key than the pinned one. Re-pin only if you know it was rebuilt.\n");
|
|
2998
|
+
const repin = await prompter.askYesNo(`Pin ${result.hostKeyFingerprint} instead?`, false);
|
|
2999
|
+
if (repin == true) {
|
|
3000
|
+
state.answers.hostKeyFingerprint = result.hostKeyFingerprint ?? void 0;
|
|
3001
|
+
output.write(" Pinned the new key \u2014 test again to confirm.\n");
|
|
3002
|
+
}
|
|
3003
|
+
}
|
|
3004
|
+
const jumpKeyChanged = result.jumpHostKeyFingerprint != null && state.answers.jumpHostKeyFingerprint != null && result.jumpHostKeyFingerprint !== state.answers.jumpHostKeyFingerprint;
|
|
3005
|
+
if (jumpKeyChanged == true) {
|
|
3006
|
+
output.write(" The jump host presented a different key than the pinned one. Re-pin only if you know it was rebuilt.\n");
|
|
3007
|
+
const repin = await prompter.askYesNo(`Pin ${result.jumpHostKeyFingerprint} instead?`, false);
|
|
3008
|
+
if (repin == true) {
|
|
3009
|
+
state.answers.jumpHostKeyFingerprint = result.jumpHostKeyFingerprint ?? void 0;
|
|
3010
|
+
output.write(" Pinned the new key \u2014 test again to confirm.\n");
|
|
3011
|
+
}
|
|
3012
|
+
}
|
|
3013
|
+
state.lastTest = "failed";
|
|
3014
|
+
return false;
|
|
3015
|
+
}
|
|
3016
|
+
async function editField(prompter, output, state, id) {
|
|
3017
|
+
const { answers } = state;
|
|
3018
|
+
if (id === "host") {
|
|
3019
|
+
let current;
|
|
3020
|
+
if (answers.host !== "") {
|
|
3021
|
+
current = answers.host;
|
|
3022
|
+
}
|
|
3023
|
+
answers.host = await prompter.askRequired("Host (hostname or IP)", current);
|
|
3024
|
+
if (state.nameTouched == false) {
|
|
3025
|
+
answers.name = deriveServerName(answers.host);
|
|
3026
|
+
}
|
|
3027
|
+
return;
|
|
3028
|
+
}
|
|
3029
|
+
if (id === "port") {
|
|
3030
|
+
answers.port = await askPort(prompter, "Port", answers.port);
|
|
3031
|
+
return;
|
|
3032
|
+
}
|
|
3033
|
+
if (id === "user") {
|
|
3034
|
+
let current;
|
|
3035
|
+
if (answers.username !== "") {
|
|
3036
|
+
current = answers.username;
|
|
3037
|
+
}
|
|
3038
|
+
answers.username = await prompter.askRequired("Login user", current);
|
|
3039
|
+
return;
|
|
3040
|
+
}
|
|
3041
|
+
if (id === "name") {
|
|
3042
|
+
let current;
|
|
3043
|
+
if (answers.name !== "") {
|
|
3044
|
+
current = answers.name;
|
|
3045
|
+
}
|
|
3046
|
+
for (; ; ) {
|
|
3047
|
+
const name = await prompter.askRequired("Server name the agent sees", current);
|
|
3048
|
+
if (SERVER_NAME_PATTERN.test(name) == true) {
|
|
3049
|
+
answers.name = name;
|
|
3050
|
+
state.nameTouched = true;
|
|
3051
|
+
return;
|
|
3052
|
+
}
|
|
3053
|
+
prompter.write(" Use letters, digits, '-' and '_' only (max 48) \u2014 the name becomes part of every tool name.\n");
|
|
3054
|
+
}
|
|
3055
|
+
}
|
|
3056
|
+
if (id === "auth") {
|
|
3057
|
+
answers.auth = await prompter.select(
|
|
3058
|
+
"Authentication",
|
|
3059
|
+
[
|
|
3060
|
+
{ value: "key", label: AUTH_LABELS.key, hint: "only the path is stored \u2014 recommended" },
|
|
3061
|
+
{ value: "password", label: AUTH_LABELS.password, hint: "stored in the config file as plain text" },
|
|
3062
|
+
{ value: "agent", label: AUTH_LABELS.agent, hint: "keys already loaded in ssh-agent" }
|
|
3063
|
+
],
|
|
3064
|
+
{ defaultValue: answers.auth }
|
|
3065
|
+
);
|
|
3066
|
+
if (answers.auth === "key" && answers.keyPath == null) {
|
|
3067
|
+
answers.keyPath = await askKeyPath(prompter, void 0);
|
|
3068
|
+
}
|
|
3069
|
+
if (answers.auth === "password" && answers.password == null) {
|
|
3070
|
+
answers.password = await askOptionalSecret(prompter, "Password", void 0);
|
|
3071
|
+
}
|
|
3072
|
+
return;
|
|
3073
|
+
}
|
|
3074
|
+
if (id === "key") {
|
|
3075
|
+
answers.keyPath = await askKeyPath(prompter, answers.keyPath);
|
|
3076
|
+
return;
|
|
3077
|
+
}
|
|
3078
|
+
if (id === "passphrase") {
|
|
3079
|
+
answers.passphrase = await askOptionalSecret(prompter, "Key passphrase", answers.passphrase);
|
|
3080
|
+
return;
|
|
3081
|
+
}
|
|
3082
|
+
if (id === "password") {
|
|
3083
|
+
answers.password = await askOptionalSecret(prompter, "Password", answers.password);
|
|
3084
|
+
return;
|
|
3085
|
+
}
|
|
3086
|
+
if (id === "route") {
|
|
3087
|
+
state.route = await prompter.select(
|
|
3088
|
+
"Route",
|
|
3089
|
+
[
|
|
3090
|
+
{ value: "direct", label: ROUTE_LABELS.direct },
|
|
3091
|
+
{ value: "jump", label: ROUTE_LABELS.jump, hint: "ssh -J" },
|
|
3092
|
+
{ value: "proxy", label: ROUTE_LABELS.proxy, hint: "e.g. cloudflared access ssh" },
|
|
3093
|
+
{ value: "jump-proxy", label: ROUTE_LABELS["jump-proxy"], hint: "cloudflared to the bastion, then on to the host" }
|
|
3094
|
+
],
|
|
3095
|
+
{ defaultValue: state.route }
|
|
3096
|
+
);
|
|
3097
|
+
if (routeUsesJump(state.route) == true && answers.jumpHost == null) {
|
|
3098
|
+
answers.jumpHost = await prompter.askRequired("Jump host");
|
|
3099
|
+
}
|
|
3100
|
+
if (routeUsesProxy(state.route) == true && answers.proxyCommand == null) {
|
|
3101
|
+
answers.proxyCommand = await prompter.askRequired("Proxy command", "cloudflared access ssh --hostname %h");
|
|
3102
|
+
}
|
|
3103
|
+
return;
|
|
3104
|
+
}
|
|
3105
|
+
if (id === "jump-host") {
|
|
3106
|
+
answers.jumpHost = await prompter.askRequired("Jump host", answers.jumpHost);
|
|
3107
|
+
return;
|
|
3108
|
+
}
|
|
3109
|
+
if (id === "jump-port") {
|
|
3110
|
+
answers.jumpPort = await askPort(prompter, "Jump host port", answers.jumpPort ?? DEFAULT_SSH_PORT);
|
|
3111
|
+
return;
|
|
3112
|
+
}
|
|
3113
|
+
if (id === "jump-user") {
|
|
3114
|
+
answers.jumpUser = await askOptional(prompter, "Jump host user (empty = same as the login user)", answers.jumpUser);
|
|
3115
|
+
return;
|
|
3116
|
+
}
|
|
3117
|
+
if (id === "proxy") {
|
|
3118
|
+
output.write(prompter.paint.dim(" %h / %p / %r expand to the first hop's host, port and user.\n"));
|
|
3119
|
+
answers.proxyCommand = await prompter.askRequired("Proxy command", answers.proxyCommand ?? "cloudflared access ssh --hostname %h");
|
|
3120
|
+
return;
|
|
3121
|
+
}
|
|
3122
|
+
if (id === "description") {
|
|
3123
|
+
answers.description = await askOptional(prompter, "Description shown to the agent", answers.description);
|
|
3124
|
+
return;
|
|
3125
|
+
}
|
|
3126
|
+
if (id === "readonly") {
|
|
3127
|
+
answers.readonly = answers.readonly == false;
|
|
3128
|
+
return;
|
|
3129
|
+
}
|
|
3130
|
+
if (id === "paths") {
|
|
3131
|
+
let current;
|
|
3132
|
+
if (answers.allowedPaths.length > 0) {
|
|
3133
|
+
current = answers.allowedPaths.join(",");
|
|
3134
|
+
}
|
|
3135
|
+
const typed = await askOptional(prompter, "Limit file tools to path prefixes (comma-separated, empty = anywhere)", current);
|
|
3136
|
+
answers.allowedPaths = [];
|
|
3137
|
+
if (typed != null) {
|
|
3138
|
+
answers.allowedPaths = typed.split(",").map((item) => item.trim()).filter((item) => item !== "");
|
|
3139
|
+
}
|
|
3140
|
+
return;
|
|
3141
|
+
}
|
|
3142
|
+
const isJump = id === "jump-host-key";
|
|
3143
|
+
let pinned = answers.hostKeyFingerprint;
|
|
3144
|
+
if (isJump == true) {
|
|
3145
|
+
pinned = answers.jumpHostKeyFingerprint;
|
|
3146
|
+
}
|
|
3147
|
+
if (pinned != null) {
|
|
3148
|
+
const unpin = await prompter.askYesNo(`Unpin ${pinned}?`, false);
|
|
3149
|
+
if (unpin == true && isJump == true) {
|
|
3150
|
+
answers.jumpHostKeyFingerprint = void 0;
|
|
3151
|
+
} else if (unpin == true) {
|
|
3152
|
+
answers.hostKeyFingerprint = void 0;
|
|
3153
|
+
}
|
|
3154
|
+
return;
|
|
3155
|
+
}
|
|
3156
|
+
const missing = missingFields(state);
|
|
3157
|
+
if (missing.length > 0) {
|
|
3158
|
+
output.write(` Fill in ${missing.map((field) => FIELD_NAMES[field]).join(", ")} first \u2014 the key is pinned by testing the connection.
|
|
3159
|
+
`);
|
|
3160
|
+
return;
|
|
3161
|
+
}
|
|
3162
|
+
await testConnection(prompter, output, state);
|
|
3163
|
+
}
|
|
3164
|
+
async function runHostForm(prompter, output, options) {
|
|
3165
|
+
const { existing, takenNames, destination } = options;
|
|
3166
|
+
let state;
|
|
3167
|
+
if (existing != null) {
|
|
3168
|
+
let route = "direct";
|
|
3169
|
+
if (existing.jumpHost != null && existing.proxyCommand != null) {
|
|
3170
|
+
route = "jump-proxy";
|
|
3171
|
+
} else if (existing.jumpHost != null) {
|
|
3172
|
+
route = "jump";
|
|
3173
|
+
} else if (existing.proxyCommand != null) {
|
|
3174
|
+
route = "proxy";
|
|
3175
|
+
}
|
|
3176
|
+
state = { answers: { ...existing }, route, nameTouched: true, lastTest: "never", testedSnapshot: null };
|
|
3177
|
+
} else {
|
|
3178
|
+
const foundKeys = findPrivateKeys();
|
|
3179
|
+
state = {
|
|
3180
|
+
answers: {
|
|
3181
|
+
name: "",
|
|
3182
|
+
host: "",
|
|
3183
|
+
port: DEFAULT_SSH_PORT,
|
|
3184
|
+
username: "",
|
|
3185
|
+
auth: "key",
|
|
3186
|
+
keyPath: foundKeys[0]?.path,
|
|
3187
|
+
readonly: false,
|
|
3188
|
+
allowedPaths: [],
|
|
3189
|
+
extraEnv: {}
|
|
3190
|
+
},
|
|
3191
|
+
route: "direct",
|
|
3192
|
+
nameTouched: false,
|
|
3193
|
+
lastTest: "never",
|
|
3194
|
+
testedSnapshot: null
|
|
3195
|
+
};
|
|
3196
|
+
}
|
|
3197
|
+
const initialSnapshot = snapshotOf(state);
|
|
3198
|
+
const originalName = existing?.name;
|
|
3199
|
+
let title = "New host";
|
|
3200
|
+
if (existing != null) {
|
|
3201
|
+
title = `Editing ${existing.name}`;
|
|
3202
|
+
}
|
|
3203
|
+
output.write(prompter.paint.dim(`
|
|
3204
|
+
Pick a setting to change it. In a text field, Enter keeps the value shown and ${CLEAR_ANSWER} clears it.
|
|
3205
|
+
|
|
3206
|
+
`));
|
|
3207
|
+
const previousTransient = prompter.transient;
|
|
3208
|
+
prompter.transient = true;
|
|
3209
|
+
try {
|
|
3210
|
+
let cursor = "host";
|
|
3211
|
+
for (; ; ) {
|
|
3212
|
+
const picked = await prompter.select(title, buildRows(state, destination), { defaultValue: cursor, cancelValue: "cancel" });
|
|
3213
|
+
if (picked === "cancel") {
|
|
3214
|
+
if (snapshotOf(state) !== initialSnapshot) {
|
|
3215
|
+
const discard = await prompter.askYesNo("Discard the changes?", false);
|
|
3216
|
+
if (discard == false) {
|
|
3217
|
+
continue;
|
|
3218
|
+
}
|
|
3219
|
+
}
|
|
3220
|
+
return null;
|
|
3221
|
+
}
|
|
3222
|
+
const missing = missingFields(state);
|
|
3223
|
+
if (picked === "test") {
|
|
3224
|
+
if (missing.length > 0) {
|
|
3225
|
+
output.write(` Fill in ${missing.map((field) => FIELD_NAMES[field]).join(", ")} first.
|
|
3226
|
+
`);
|
|
3227
|
+
cursor = missing[0];
|
|
3228
|
+
continue;
|
|
3229
|
+
}
|
|
3230
|
+
const connected = await testConnection(prompter, output, state);
|
|
3231
|
+
cursor = "test";
|
|
3232
|
+
if (connected == true) {
|
|
3233
|
+
cursor = "save";
|
|
3234
|
+
}
|
|
3235
|
+
continue;
|
|
3236
|
+
}
|
|
3237
|
+
if (picked === "save") {
|
|
3238
|
+
if (missing.length > 0) {
|
|
3239
|
+
output.write(` Fill in ${missing.map((field) => FIELD_NAMES[field]).join(", ")} first.
|
|
3240
|
+
`);
|
|
3241
|
+
cursor = missing[0];
|
|
3242
|
+
continue;
|
|
3243
|
+
}
|
|
3244
|
+
const name = state.answers.name;
|
|
3245
|
+
if (name !== originalName && takenNames.includes(name) == true) {
|
|
3246
|
+
const replace = await prompter.askYesNo(`"${name}" already exists in this file. Replace it?`, false);
|
|
3247
|
+
if (replace == false) {
|
|
3248
|
+
cursor = "name";
|
|
3249
|
+
continue;
|
|
3250
|
+
}
|
|
3251
|
+
}
|
|
3252
|
+
if (snapshotOf(state) !== state.testedSnapshot) {
|
|
3253
|
+
const testFirst = await prompter.askYesNo("Test the connection before saving?", true);
|
|
3254
|
+
if (testFirst == true) {
|
|
3255
|
+
const connected = await testConnection(prompter, output, state);
|
|
3256
|
+
if (connected == false) {
|
|
3257
|
+
const next = await prompter.select(
|
|
3258
|
+
"The check failed. What now?",
|
|
3259
|
+
[
|
|
3260
|
+
{ value: "back", key: "back", label: "Back to the settings" },
|
|
3261
|
+
{ value: "save", key: "save", label: "Save anyway", hint: "the host may be unreachable from here" },
|
|
3262
|
+
{ value: "discard", key: "discard", label: "Discard", hint: "nothing is written" }
|
|
3263
|
+
],
|
|
3264
|
+
{ defaultValue: "back", cancelValue: "back" }
|
|
3265
|
+
);
|
|
3266
|
+
if (next === "discard") {
|
|
3267
|
+
return null;
|
|
3268
|
+
}
|
|
3269
|
+
if (next === "back") {
|
|
3270
|
+
cursor = "test";
|
|
3271
|
+
continue;
|
|
3272
|
+
}
|
|
3273
|
+
}
|
|
3274
|
+
}
|
|
3275
|
+
}
|
|
3276
|
+
return finalizeAnswers(state);
|
|
3277
|
+
}
|
|
3278
|
+
await editField(prompter, output, state, picked);
|
|
3279
|
+
cursor = nextCursor(state, destination, picked);
|
|
3280
|
+
}
|
|
3281
|
+
} finally {
|
|
3282
|
+
prompter.transient = previousTransient;
|
|
3283
|
+
}
|
|
3284
|
+
}
|
|
2620
3285
|
|
|
2621
3286
|
// src/modules/manage/prompter.ts
|
|
2622
3287
|
var PromptClosedError = class extends Error {
|
|
@@ -2731,6 +3396,12 @@ var Prompter = class {
|
|
|
2731
3396
|
this.inputEnded = false;
|
|
2732
3397
|
/** Set while a key-driven prompt (select, secret) owns the input. */
|
|
2733
3398
|
this.keyHandler = null;
|
|
3399
|
+
/**
|
|
3400
|
+
* When true, an answered prompt leaves nothing behind on a TTY instead of its
|
|
3401
|
+
* `✔ question · answer` line. The settings form sets it: the form itself shows every
|
|
3402
|
+
* value, so it can redraw in place rather than scroll a trail of edits.
|
|
3403
|
+
*/
|
|
3404
|
+
this.transient = false;
|
|
2734
3405
|
const ttyInput = input;
|
|
2735
3406
|
const ttyOutput = output;
|
|
2736
3407
|
this.interactive = ttyInput.isTTY == true && ttyInput.setRawMode != null && ttyOutput.isTTY == true;
|
|
@@ -3030,6 +3701,9 @@ var Prompter = class {
|
|
|
3030
3701
|
}
|
|
3031
3702
|
/** The one-line record an answered prompt leaves behind on a TTY. */
|
|
3032
3703
|
writeAnswered(question, answer) {
|
|
3704
|
+
if (this.transient == true) {
|
|
3705
|
+
return;
|
|
3706
|
+
}
|
|
3033
3707
|
this.output.write(`${this.style.success(this.symbols.tick)} ${question} ${this.style.dim("\xB7")} ${this.style.accent(answer)}
|
|
3034
3708
|
`);
|
|
3035
3709
|
}
|
|
@@ -3162,242 +3836,6 @@ var Prompter = class {
|
|
|
3162
3836
|
});
|
|
3163
3837
|
}
|
|
3164
3838
|
};
|
|
3165
|
-
var PREFERRED_KEY_NAMES = ["id_ed25519", "id_ecdsa", "id_rsa"];
|
|
3166
|
-
var NON_KEY_FILE_NAMES = ["config", "known_hosts", "known_hosts.old", "authorized_keys", "environment"];
|
|
3167
|
-
var MAX_KEY_FILE_BYTES = 64 * 1024;
|
|
3168
|
-
var CLEAR_ANSWER = "-";
|
|
3169
|
-
var OTHER_KEY_PATH = "\0other";
|
|
3170
|
-
function findPrivateKeys() {
|
|
3171
|
-
const sshDirectory = path.join(os5.homedir(), ".ssh");
|
|
3172
|
-
let names = [];
|
|
3173
|
-
try {
|
|
3174
|
-
names = fs6.readdirSync(sshDirectory);
|
|
3175
|
-
} catch {
|
|
3176
|
-
return [];
|
|
3177
|
-
}
|
|
3178
|
-
const keys = [];
|
|
3179
|
-
for (const name of names) {
|
|
3180
|
-
if (name.endsWith(".pub") == true || NON_KEY_FILE_NAMES.includes(name) == true) {
|
|
3181
|
-
continue;
|
|
3182
|
-
}
|
|
3183
|
-
const fullPath = path.join(sshDirectory, name);
|
|
3184
|
-
let head = "";
|
|
3185
|
-
try {
|
|
3186
|
-
const stat = fs6.statSync(fullPath);
|
|
3187
|
-
if (stat.isFile() == false || stat.size > MAX_KEY_FILE_BYTES) {
|
|
3188
|
-
continue;
|
|
3189
|
-
}
|
|
3190
|
-
const descriptor = fs6.openSync(fullPath, "r");
|
|
3191
|
-
const buffer = Buffer.alloc(64);
|
|
3192
|
-
const bytesRead = fs6.readSync(descriptor, buffer, 0, buffer.length, 0);
|
|
3193
|
-
fs6.closeSync(descriptor);
|
|
3194
|
-
head = buffer.subarray(0, bytesRead).toString("utf8");
|
|
3195
|
-
} catch {
|
|
3196
|
-
continue;
|
|
3197
|
-
}
|
|
3198
|
-
const isPemKey = head.startsWith("-----BEGIN") == true && head.includes("PRIVATE KEY") == true;
|
|
3199
|
-
const isPuttyKey = head.startsWith("PuTTY-User-Key-File") == true;
|
|
3200
|
-
if (isPemKey == false && isPuttyKey == false) {
|
|
3201
|
-
continue;
|
|
3202
|
-
}
|
|
3203
|
-
let hint = "";
|
|
3204
|
-
try {
|
|
3205
|
-
const publicKey = fs6.readFileSync(`${fullPath}.pub`, "utf8").trim().split(/\s+/);
|
|
3206
|
-
hint = publicKey[0].replace(/^ssh-/, "");
|
|
3207
|
-
if (publicKey.length > 2) {
|
|
3208
|
-
hint += ` \xB7 ${publicKey.slice(2).join(" ")}`;
|
|
3209
|
-
}
|
|
3210
|
-
} catch {
|
|
3211
|
-
}
|
|
3212
|
-
keys.push({ path: `~/.ssh/${name}`, hint });
|
|
3213
|
-
}
|
|
3214
|
-
const rank = (key) => {
|
|
3215
|
-
const index = PREFERRED_KEY_NAMES.indexOf(path.basename(key.path));
|
|
3216
|
-
if (index < 0) {
|
|
3217
|
-
return PREFERRED_KEY_NAMES.length;
|
|
3218
|
-
}
|
|
3219
|
-
return index;
|
|
3220
|
-
};
|
|
3221
|
-
return keys.sort((left, right) => rank(left) - rank(right) || left.path.localeCompare(right.path));
|
|
3222
|
-
}
|
|
3223
|
-
async function askPort(prompter, question, current) {
|
|
3224
|
-
for (; ; ) {
|
|
3225
|
-
const answer = await prompter.ask(question, String(current));
|
|
3226
|
-
const parsed = Number(answer);
|
|
3227
|
-
if (Number.isInteger(parsed) == true && parsed > 0 && parsed <= 65535) {
|
|
3228
|
-
return parsed;
|
|
3229
|
-
}
|
|
3230
|
-
prompter.write(" Enter a port number between 1 and 65535.\n");
|
|
3231
|
-
}
|
|
3232
|
-
}
|
|
3233
|
-
async function askOptional(prompter, question, current) {
|
|
3234
|
-
const answer = await prompter.ask(question, current);
|
|
3235
|
-
if (answer === "" || answer === CLEAR_ANSWER) {
|
|
3236
|
-
return void 0;
|
|
3237
|
-
}
|
|
3238
|
-
return answer;
|
|
3239
|
-
}
|
|
3240
|
-
async function askOptionalSecret(prompter, question, current) {
|
|
3241
|
-
let suffix = "(empty for none)";
|
|
3242
|
-
if (current != null) {
|
|
3243
|
-
suffix = `(enter keeps current, ${CLEAR_ANSWER} clears)`;
|
|
3244
|
-
}
|
|
3245
|
-
const answer = await prompter.askSecret(`${question} ${prompter.paint.dim(suffix)}`);
|
|
3246
|
-
if (answer === CLEAR_ANSWER) {
|
|
3247
|
-
return void 0;
|
|
3248
|
-
}
|
|
3249
|
-
if (answer === "") {
|
|
3250
|
-
return current;
|
|
3251
|
-
}
|
|
3252
|
-
return answer;
|
|
3253
|
-
}
|
|
3254
|
-
async function askKeyPath(prompter, current) {
|
|
3255
|
-
const found = findPrivateKeys();
|
|
3256
|
-
let picked = OTHER_KEY_PATH;
|
|
3257
|
-
if (found.length > 0 || current != null) {
|
|
3258
|
-
const items = [];
|
|
3259
|
-
if (current != null && found.some((key) => key.path === current) == false) {
|
|
3260
|
-
items.push({ value: current, label: current, hint: "current" });
|
|
3261
|
-
}
|
|
3262
|
-
for (const key of found) {
|
|
3263
|
-
items.push({ value: key.path, label: key.path, hint: key.hint });
|
|
3264
|
-
}
|
|
3265
|
-
items.push({ separator: true });
|
|
3266
|
-
items.push({ value: OTHER_KEY_PATH, key: "other", label: "Another path\u2026" });
|
|
3267
|
-
picked = await prompter.select("Private key", items, { defaultValue: current ?? found[0]?.path });
|
|
3268
|
-
}
|
|
3269
|
-
if (picked !== OTHER_KEY_PATH) {
|
|
3270
|
-
return picked;
|
|
3271
|
-
}
|
|
3272
|
-
for (; ; ) {
|
|
3273
|
-
const typed = await prompter.askRequired("Private key path");
|
|
3274
|
-
if (fs6.existsSync(expandHomePath(typed)) == true) {
|
|
3275
|
-
return typed;
|
|
3276
|
-
}
|
|
3277
|
-
prompter.write(` ${expandHomePath(typed)} does not exist.
|
|
3278
|
-
`);
|
|
3279
|
-
const useAnyway = await prompter.askYesNo("Use this path anyway?", false);
|
|
3280
|
-
if (useAnyway == true) {
|
|
3281
|
-
return typed;
|
|
3282
|
-
}
|
|
3283
|
-
}
|
|
3284
|
-
}
|
|
3285
|
-
async function askHostQuestions(prompter, existing) {
|
|
3286
|
-
if (existing != null) {
|
|
3287
|
-
prompter.write(prompter.paint.dim(` Enter keeps the value in brackets; ${CLEAR_ANSWER} clears an optional one.
|
|
3288
|
-
|
|
3289
|
-
`));
|
|
3290
|
-
}
|
|
3291
|
-
const host = await prompter.askRequired("Host (hostname or IP)", existing?.host);
|
|
3292
|
-
const port = await askPort(prompter, "Port", existing?.port ?? DEFAULT_SSH_PORT);
|
|
3293
|
-
const username = await prompter.askRequired("Login user", existing?.username);
|
|
3294
|
-
let name = "";
|
|
3295
|
-
const defaultName = existing?.name ?? `ssh-${host}`.replace(/[^A-Za-z0-9_-]+/g, "-").slice(0, 48);
|
|
3296
|
-
for (; ; ) {
|
|
3297
|
-
name = await prompter.askRequired("Server name the agent sees", defaultName);
|
|
3298
|
-
if (SERVER_NAME_PATTERN.test(name) == true) {
|
|
3299
|
-
break;
|
|
3300
|
-
}
|
|
3301
|
-
prompter.write(" Use letters, digits, '-' and '_' only (max 48) \u2014 the name becomes part of every tool name.\n");
|
|
3302
|
-
}
|
|
3303
|
-
const auth = await prompter.select(
|
|
3304
|
-
"Authentication",
|
|
3305
|
-
[
|
|
3306
|
-
{ value: "key", label: "Private key file", hint: "only the path is stored \u2014 recommended" },
|
|
3307
|
-
{ value: "password", label: "Password", hint: "stored in the config file as plain text" },
|
|
3308
|
-
{ value: "agent", label: "SSH agent", hint: "keys already loaded in ssh-agent" }
|
|
3309
|
-
],
|
|
3310
|
-
{ defaultValue: existing?.auth ?? "key" }
|
|
3311
|
-
);
|
|
3312
|
-
let keyPath;
|
|
3313
|
-
let passphrase;
|
|
3314
|
-
let password;
|
|
3315
|
-
if (auth === "key") {
|
|
3316
|
-
keyPath = await askKeyPath(prompter, existing?.keyPath);
|
|
3317
|
-
passphrase = await askOptionalSecret(prompter, "Key passphrase", existing?.passphrase);
|
|
3318
|
-
}
|
|
3319
|
-
if (auth === "password") {
|
|
3320
|
-
password = await askOptionalSecret(prompter, "Password", existing?.password);
|
|
3321
|
-
if (password == null) {
|
|
3322
|
-
prompter.write(" No password given \u2014 falling back to the ssh agent.\n");
|
|
3323
|
-
}
|
|
3324
|
-
}
|
|
3325
|
-
let currentRoute = "direct";
|
|
3326
|
-
if (existing?.jumpHost != null && existing.proxyCommand != null) {
|
|
3327
|
-
currentRoute = "jump-proxy";
|
|
3328
|
-
} else if (existing?.jumpHost != null) {
|
|
3329
|
-
currentRoute = "jump";
|
|
3330
|
-
} else if (existing?.proxyCommand != null) {
|
|
3331
|
-
currentRoute = "proxy";
|
|
3332
|
-
}
|
|
3333
|
-
const route = await prompter.select(
|
|
3334
|
-
"How is it reached?",
|
|
3335
|
-
[
|
|
3336
|
-
{ value: "direct", label: "Directly" },
|
|
3337
|
-
{ value: "jump", label: "Through a jump host", hint: "ssh -J" },
|
|
3338
|
-
{ value: "proxy", label: "Through a proxy command", hint: "e.g. cloudflared access ssh" },
|
|
3339
|
-
{ value: "jump-proxy", label: "Proxy command, then a jump host", hint: "cloudflared to the bastion, then on to the host" }
|
|
3340
|
-
],
|
|
3341
|
-
{ defaultValue: currentRoute }
|
|
3342
|
-
);
|
|
3343
|
-
let jumpHost;
|
|
3344
|
-
let jumpPort;
|
|
3345
|
-
let jumpUser;
|
|
3346
|
-
let proxyCommand;
|
|
3347
|
-
if (route === "jump" || route === "jump-proxy") {
|
|
3348
|
-
jumpHost = await prompter.askRequired("Jump host", existing?.jumpHost);
|
|
3349
|
-
jumpPort = await askPort(prompter, "Jump host port", existing?.jumpPort ?? DEFAULT_SSH_PORT);
|
|
3350
|
-
jumpUser = await prompter.askRequired("Jump host user", existing?.jumpUser ?? username);
|
|
3351
|
-
}
|
|
3352
|
-
if (route === "proxy" || route === "jump-proxy") {
|
|
3353
|
-
prompter.write(prompter.paint.dim(" %h / %p / %r expand to the first hop's host, port and user.\n"));
|
|
3354
|
-
proxyCommand = await prompter.askRequired("Proxy command", existing?.proxyCommand ?? "cloudflared access ssh --hostname %h");
|
|
3355
|
-
}
|
|
3356
|
-
const description = await askOptional(prompter, "Description shown to the agent", existing?.description);
|
|
3357
|
-
const readonly = await prompter.askYesNo("Read-only? (rejects writes, redirections, uploads)", existing?.readonly ?? false);
|
|
3358
|
-
let currentAllowedPaths;
|
|
3359
|
-
if (existing != null && existing.allowedPaths.length > 0) {
|
|
3360
|
-
currentAllowedPaths = existing.allowedPaths.join(",");
|
|
3361
|
-
}
|
|
3362
|
-
let allowedPaths = [];
|
|
3363
|
-
const allowedPathsAnswer = await askOptional(
|
|
3364
|
-
prompter,
|
|
3365
|
-
"Limit file tools to path prefixes (comma-separated, empty = anywhere)",
|
|
3366
|
-
currentAllowedPaths
|
|
3367
|
-
);
|
|
3368
|
-
if (allowedPathsAnswer != null) {
|
|
3369
|
-
allowedPaths = allowedPathsAnswer.split(",").map((item) => item.trim()).filter((item) => item !== "");
|
|
3370
|
-
}
|
|
3371
|
-
let resolvedAuth = auth;
|
|
3372
|
-
if (auth === "password" && password == null) {
|
|
3373
|
-
resolvedAuth = "agent";
|
|
3374
|
-
}
|
|
3375
|
-
const answers = {
|
|
3376
|
-
name,
|
|
3377
|
-
host,
|
|
3378
|
-
port,
|
|
3379
|
-
username,
|
|
3380
|
-
auth: resolvedAuth,
|
|
3381
|
-
keyPath,
|
|
3382
|
-
passphrase,
|
|
3383
|
-
password,
|
|
3384
|
-
description,
|
|
3385
|
-
jumpHost,
|
|
3386
|
-
jumpPort,
|
|
3387
|
-
jumpUser,
|
|
3388
|
-
proxyCommand,
|
|
3389
|
-
readonly,
|
|
3390
|
-
allowedPaths,
|
|
3391
|
-
extraEnv: existing?.extraEnv ?? {}
|
|
3392
|
-
};
|
|
3393
|
-
if (existing != null && existing.host === host && existing.port === port) {
|
|
3394
|
-
answers.hostKeyFingerprint = existing.hostKeyFingerprint;
|
|
3395
|
-
}
|
|
3396
|
-
if (existing != null && existing.jumpHost === jumpHost && existing.jumpPort === jumpPort) {
|
|
3397
|
-
answers.jumpHostKeyFingerprint = existing.jumpHostKeyFingerprint;
|
|
3398
|
-
}
|
|
3399
|
-
return answers;
|
|
3400
|
-
}
|
|
3401
3839
|
function toMcpServerEntry(value) {
|
|
3402
3840
|
if (typeof value !== "object" || value == null) {
|
|
3403
3841
|
return null;
|
|
@@ -4125,113 +4563,20 @@ function reportWrite(prompter, result, action) {
|
|
|
4125
4563
|
|
|
4126
4564
|
`);
|
|
4127
4565
|
}
|
|
4128
|
-
function profileFromEntry(output, entry) {
|
|
4129
|
-
try {
|
|
4130
|
-
const profile = loadHostProfile(entry.env);
|
|
4131
|
-
if (profile == null) {
|
|
4132
|
-
output.write(" The entry has no host.\n");
|
|
4133
|
-
}
|
|
4134
|
-
return profile;
|
|
4135
|
-
} catch (ex) {
|
|
4136
|
-
output.write(` The server would refuse to start with this entry: ${errorMessage(ex)}
|
|
4137
|
-
`);
|
|
4138
|
-
return null;
|
|
4139
|
-
}
|
|
4140
|
-
}
|
|
4141
|
-
async function verifyAnswers(prompter, output, initial) {
|
|
4142
|
-
let answers = initial;
|
|
4143
|
-
for (; ; ) {
|
|
4144
|
-
const testNow = await prompter.askYesNo("Test the connection now?", true);
|
|
4145
|
-
if (testNow == false) {
|
|
4146
|
-
return answers;
|
|
4147
|
-
}
|
|
4148
|
-
output.write("\n");
|
|
4149
|
-
const profile = profileFromEntry(output, answersToEntry(answers));
|
|
4150
|
-
if (profile != null) {
|
|
4151
|
-
const result = await runConnectionCheck(profile, output);
|
|
4152
|
-
output.write("\n");
|
|
4153
|
-
if (result.connected == true) {
|
|
4154
|
-
if (result.hostKeyFingerprint != null && answers.hostKeyFingerprint == null) {
|
|
4155
|
-
const pin = await prompter.askYesNo("Pin this host key? (a changed key then aborts the connection)", true);
|
|
4156
|
-
if (pin == true) {
|
|
4157
|
-
answers = { ...answers, hostKeyFingerprint: result.hostKeyFingerprint };
|
|
4158
|
-
}
|
|
4159
|
-
}
|
|
4160
|
-
if (result.jumpHostKeyFingerprint != null && answers.jumpHost != null && answers.jumpHostKeyFingerprint == null) {
|
|
4161
|
-
const pin = await prompter.askYesNo("Pin the jump host key too?", true);
|
|
4162
|
-
if (pin == true) {
|
|
4163
|
-
answers = { ...answers, jumpHostKeyFingerprint: result.jumpHostKeyFingerprint };
|
|
4164
|
-
}
|
|
4165
|
-
}
|
|
4166
|
-
return answers;
|
|
4167
|
-
}
|
|
4168
|
-
const hostKeyChanged = result.hostKeyFingerprint != null && answers.hostKeyFingerprint != null && result.hostKeyFingerprint !== answers.hostKeyFingerprint;
|
|
4169
|
-
if (hostKeyChanged == true) {
|
|
4170
|
-
output.write(" The host presented a different key than the pinned one. Re-pin only if you know it was rebuilt.\n");
|
|
4171
|
-
const repin = await prompter.askYesNo(`Pin ${result.hostKeyFingerprint} instead?`, false);
|
|
4172
|
-
if (repin == true) {
|
|
4173
|
-
answers = { ...answers, hostKeyFingerprint: result.hostKeyFingerprint ?? void 0 };
|
|
4174
|
-
continue;
|
|
4175
|
-
}
|
|
4176
|
-
}
|
|
4177
|
-
const jumpKeyChanged = result.jumpHostKeyFingerprint != null && answers.jumpHostKeyFingerprint != null && result.jumpHostKeyFingerprint !== answers.jumpHostKeyFingerprint;
|
|
4178
|
-
if (jumpKeyChanged == true) {
|
|
4179
|
-
output.write(" The jump host presented a different key than the pinned one. Re-pin only if you know it was rebuilt.\n");
|
|
4180
|
-
const repin = await prompter.askYesNo(`Pin ${result.jumpHostKeyFingerprint} instead?`, false);
|
|
4181
|
-
if (repin == true) {
|
|
4182
|
-
answers = { ...answers, jumpHostKeyFingerprint: result.jumpHostKeyFingerprint ?? void 0 };
|
|
4183
|
-
continue;
|
|
4184
|
-
}
|
|
4185
|
-
}
|
|
4186
|
-
}
|
|
4187
|
-
const next = await prompter.select(
|
|
4188
|
-
"The check failed. What now?",
|
|
4189
|
-
[
|
|
4190
|
-
{ value: "edit", key: "e", label: "Change the answers" },
|
|
4191
|
-
{ value: "retry", key: "r", label: "Try again", hint: "e.g. after starting a tunnel" },
|
|
4192
|
-
{ value: "write", key: "w", label: "Write it anyway", hint: "the host may be unreachable from here" },
|
|
4193
|
-
{ value: "cancel", key: "c", label: "Cancel", hint: "nothing is written" }
|
|
4194
|
-
],
|
|
4195
|
-
{ defaultValue: "edit", cancelValue: "cancel" }
|
|
4196
|
-
);
|
|
4197
|
-
if (next === "cancel") {
|
|
4198
|
-
return null;
|
|
4199
|
-
}
|
|
4200
|
-
if (next === "write") {
|
|
4201
|
-
return answers;
|
|
4202
|
-
}
|
|
4203
|
-
if (next === "edit") {
|
|
4204
|
-
output.write("\n");
|
|
4205
|
-
answers = await askHostQuestions(prompter, answers);
|
|
4206
|
-
}
|
|
4207
|
-
}
|
|
4208
|
-
}
|
|
4209
4566
|
async function addOrEditHost(prompter, output, target, entries, existingName) {
|
|
4210
4567
|
let existing;
|
|
4211
4568
|
if (existingName != null) {
|
|
4212
4569
|
existing = entryToAnswers(existingName, entries[existingName]);
|
|
4213
|
-
output.write(`
|
|
4214
|
-
${prompter.paint.bold(`Editing ${existingName}`)}
|
|
4215
|
-
`);
|
|
4216
|
-
} else {
|
|
4217
|
-
output.write(`
|
|
4218
|
-
${prompter.paint.bold("Adding a host")}
|
|
4219
|
-
`);
|
|
4220
4570
|
}
|
|
4221
|
-
const
|
|
4222
|
-
|
|
4223
|
-
|
|
4571
|
+
const answers = await runHostForm(prompter, output, {
|
|
4572
|
+
existing,
|
|
4573
|
+
takenNames: Object.keys(entries),
|
|
4574
|
+
destination: target.label
|
|
4575
|
+
});
|
|
4224
4576
|
if (answers == null) {
|
|
4225
4577
|
output.write(" Cancelled \u2014 nothing was written.\n\n");
|
|
4226
4578
|
return;
|
|
4227
4579
|
}
|
|
4228
|
-
if (answers.name !== existingName && entries[answers.name] != null) {
|
|
4229
|
-
const replace = await prompter.askYesNo(`"${answers.name}" already exists in this file. Replace it?`, false);
|
|
4230
|
-
if (replace == false) {
|
|
4231
|
-
output.write(" Cancelled \u2014 nothing was written.\n\n");
|
|
4232
|
-
return;
|
|
4233
|
-
}
|
|
4234
|
-
}
|
|
4235
4580
|
const entry = answersToEntry(answers);
|
|
4236
4581
|
output.write(`
|
|
4237
4582
|
${prompter.paint.dim(`This goes into ${target.location}:`)}
|
|
@@ -4258,7 +4603,7 @@ ${prompter.paint.bold("Adding a host")}
|
|
|
4258
4603
|
}
|
|
4259
4604
|
async function checkHost(prompter, output, target, name, entry) {
|
|
4260
4605
|
output.write("\n");
|
|
4261
|
-
const profile =
|
|
4606
|
+
const profile = profileFromEnv(output, entry.env);
|
|
4262
4607
|
if (profile == null) {
|
|
4263
4608
|
output.write("\n");
|
|
4264
4609
|
return;
|
|
@@ -4332,7 +4677,7 @@ async function manageHost(prompter, output, target, entries, name) {
|
|
|
4332
4677
|
const action = await prompter.select(
|
|
4333
4678
|
name,
|
|
4334
4679
|
[
|
|
4335
|
-
{ value: "edit", key: "e", label: "Edit", hint: "
|
|
4680
|
+
{ value: "edit", key: "e", label: "Edit", hint: "open its settings" },
|
|
4336
4681
|
{ value: "check", key: "c", label: "Check the connection", hint: "connect now and offer to pin the host key" },
|
|
4337
4682
|
{ value: "print", key: "p", label: "Print the entry", hint: "JSON, Codex TOML and a claude command, ready to paste" },
|
|
4338
4683
|
{ value: "remove", key: "r", label: "Remove", hint: `from ${target.label}` },
|