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