@patimweb/pi-ssh 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.node-version +1 -0
- package/LICENSE +21 -0
- package/README.md +146 -0
- package/index.ts +99 -0
- package/package.json +52 -0
- package/release.config.cjs +16 -0
- package/skills/ssh-key-setup/SKILL.md +71 -0
- package/skills/ssh-remote-work/SKILL.md +81 -0
- package/src/authorize.ts +237 -0
- package/src/clients/ssh-client.ts +490 -0
- package/src/config.ts +189 -0
- package/src/doctor.ts +212 -0
- package/src/formatting/formatters.ts +133 -0
- package/src/keys.ts +191 -0
- package/src/known-hosts.ts +189 -0
- package/src/tools/ssh-authorize.ts +88 -0
- package/src/tools/ssh-doctor.ts +26 -0
- package/src/tools/ssh-download.ts +51 -0
- package/src/tools/ssh-exec.ts +76 -0
- package/src/tools/ssh-keygen.ts +102 -0
- package/src/tools/ssh-list.ts +49 -0
- package/src/tools/ssh-profile.ts +77 -0
- package/src/tools/ssh-setup.ts +96 -0
- package/src/tools/ssh-status.ts +70 -0
- package/src/tools/ssh-upload.ts +51 -0
- package/src/types.ts +144 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host key verification against known_hosts.
|
|
3
|
+
*
|
|
4
|
+
* Trust on first use: an unknown host is recorded with its fingerprint, and
|
|
5
|
+
* from then on a changed key is a hard failure rather than a prompt. That is
|
|
6
|
+
* the only part of SSH that protects against someone sitting between you and
|
|
7
|
+
* the server, so it is not something to skip for convenience.
|
|
8
|
+
*
|
|
9
|
+
* OpenSSH's own file is used by default, which means hosts already visited
|
|
10
|
+
* with `ssh` are recognised, and hosts recorded here are recognised by `ssh`.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import * as crypto from "node:crypto";
|
|
14
|
+
import * as fs from "node:fs";
|
|
15
|
+
import * as os from "node:os";
|
|
16
|
+
import * as path from "node:path";
|
|
17
|
+
import { keyBlobType, keyFingerprint } from "./keys.ts";
|
|
18
|
+
|
|
19
|
+
export type HostKeyVerdict = "match" | "unknown" | "changed" | "revoked";
|
|
20
|
+
|
|
21
|
+
export interface HostKeyCheck {
|
|
22
|
+
readonly verdict: HostKeyVerdict;
|
|
23
|
+
readonly fingerprint: string;
|
|
24
|
+
readonly keyType: string;
|
|
25
|
+
/** Fingerprints already on record for this host, when the key changed. */
|
|
26
|
+
readonly knownFingerprints: ReadonlyArray<string>;
|
|
27
|
+
readonly file: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface KnownHostEntry {
|
|
31
|
+
readonly hosts: string;
|
|
32
|
+
readonly keyType: string;
|
|
33
|
+
readonly blob: Buffer;
|
|
34
|
+
readonly marker?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function defaultKnownHostsPath(): string {
|
|
38
|
+
const home = process.env.HOME || process.env.USERPROFILE || os.homedir();
|
|
39
|
+
return path.join(home, ".ssh", "known_hosts");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** How OpenSSH writes a host: bare, or [host]:port for a non-default port. */
|
|
43
|
+
export function hostPattern(host: string, port: number): string {
|
|
44
|
+
return port === 22 ? host : `[${host}]:${port}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// --- Parsing --------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
export function parseKnownHosts(content: string): KnownHostEntry[] {
|
|
50
|
+
const entries: KnownHostEntry[] = [];
|
|
51
|
+
|
|
52
|
+
for (const rawLine of content.split(/\r?\n/)) {
|
|
53
|
+
const line = rawLine.trim();
|
|
54
|
+
if (!line || line.startsWith("#")) continue;
|
|
55
|
+
|
|
56
|
+
let parts = line.split(/\s+/);
|
|
57
|
+
let marker: string | undefined;
|
|
58
|
+
|
|
59
|
+
// A line may start with @cert-authority or @revoked.
|
|
60
|
+
if (parts[0]?.startsWith("@")) {
|
|
61
|
+
marker = parts[0].slice(1);
|
|
62
|
+
parts = parts.slice(1);
|
|
63
|
+
}
|
|
64
|
+
if (parts.length < 3) continue;
|
|
65
|
+
|
|
66
|
+
const [hosts, keyType, encoded] = parts;
|
|
67
|
+
let blob: Buffer;
|
|
68
|
+
try {
|
|
69
|
+
blob = Buffer.from(encoded, "base64");
|
|
70
|
+
} catch {
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (blob.length === 0) continue;
|
|
74
|
+
|
|
75
|
+
entries.push({ hosts, keyType, blob, marker });
|
|
76
|
+
}
|
|
77
|
+
return entries;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Does this entry cover the given host?
|
|
82
|
+
*
|
|
83
|
+
* Handles the three forms OpenSSH writes: a comma-separated list of plain
|
|
84
|
+
* patterns, and hashed entries of the form |1|salt|hash.
|
|
85
|
+
*/
|
|
86
|
+
export function entryMatchesHost(entryHosts: string, pattern: string): boolean {
|
|
87
|
+
if (entryHosts.startsWith("|1|")) {
|
|
88
|
+
const [, salt, hash] = entryHosts.split("|").filter((part) => part.length > 0);
|
|
89
|
+
if (!salt || !hash) return false;
|
|
90
|
+
try {
|
|
91
|
+
const digest = crypto
|
|
92
|
+
.createHmac("sha1", Buffer.from(salt, "base64"))
|
|
93
|
+
.update(pattern)
|
|
94
|
+
.digest("base64");
|
|
95
|
+
return digest === hash;
|
|
96
|
+
} catch {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return entryHosts.split(",").some((candidate) => candidate.trim() === pattern);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// --- Verification ---------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
export function readKnownHosts(file: string): KnownHostEntry[] {
|
|
107
|
+
try {
|
|
108
|
+
return parseKnownHosts(fs.readFileSync(file, "utf-8"));
|
|
109
|
+
} catch {
|
|
110
|
+
// A missing file simply means nothing is known yet.
|
|
111
|
+
return [];
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Compare a host key offered during the handshake against what is on record.
|
|
117
|
+
*/
|
|
118
|
+
export function checkHostKey(
|
|
119
|
+
host: string,
|
|
120
|
+
port: number,
|
|
121
|
+
key: Buffer,
|
|
122
|
+
file = defaultKnownHostsPath(),
|
|
123
|
+
): HostKeyCheck {
|
|
124
|
+
const pattern = hostPattern(host, port);
|
|
125
|
+
const fingerprint = keyFingerprint(key);
|
|
126
|
+
const keyType = keyBlobType(key);
|
|
127
|
+
const entries = readKnownHosts(file).filter((entry) =>
|
|
128
|
+
entryMatchesHost(entry.hosts, pattern),
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
const base = { fingerprint, keyType, file };
|
|
132
|
+
|
|
133
|
+
if (entries.some((entry) => entry.marker === "revoked" && entry.blob.equals(key))) {
|
|
134
|
+
return { ...base, verdict: "revoked", knownFingerprints: [] };
|
|
135
|
+
}
|
|
136
|
+
if (entries.some((entry) => entry.marker !== "revoked" && entry.blob.equals(key))) {
|
|
137
|
+
return { ...base, verdict: "match", knownFingerprints: [] };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Only keys of the same type count as a conflict: a host legitimately
|
|
141
|
+
// offers an ed25519 key even when only its RSA key was recorded.
|
|
142
|
+
const sameType = entries.filter((entry) => entry.keyType === keyType);
|
|
143
|
+
if (sameType.length > 0) {
|
|
144
|
+
return {
|
|
145
|
+
...base,
|
|
146
|
+
verdict: "changed",
|
|
147
|
+
knownFingerprints: sameType.map((entry) => keyFingerprint(entry.blob)),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return { ...base, verdict: "unknown", knownFingerprints: [] };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Append a host key, creating the file and its directory if needed. */
|
|
155
|
+
export function addKnownHost(
|
|
156
|
+
host: string,
|
|
157
|
+
port: number,
|
|
158
|
+
key: Buffer,
|
|
159
|
+
file = defaultKnownHostsPath(),
|
|
160
|
+
): void {
|
|
161
|
+
const line = `${hostPattern(host, port)} ${keyBlobType(key)} ${key.toString("base64")}\n`;
|
|
162
|
+
const dir = path.dirname(file);
|
|
163
|
+
|
|
164
|
+
if (!fs.existsSync(dir)) {
|
|
165
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
166
|
+
}
|
|
167
|
+
// A known_hosts that already exists keeps its permissions; a new one is
|
|
168
|
+
// created the way OpenSSH would.
|
|
169
|
+
const needsNewline =
|
|
170
|
+
fs.existsSync(file) && fs.statSync(file).size > 0 && !fs.readFileSync(file, "utf-8").endsWith("\n");
|
|
171
|
+
|
|
172
|
+
fs.appendFileSync(file, `${needsNewline ? "\n" : ""}${line}`, { mode: 0o600 });
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** The message shown when a host's key does not match what was recorded. */
|
|
176
|
+
export function describeChangedKey(check: HostKeyCheck, host: string): string {
|
|
177
|
+
return [
|
|
178
|
+
`HOST KEY CHANGED for ${host}.`,
|
|
179
|
+
"",
|
|
180
|
+
`Offered: ${check.keyType} ${check.fingerprint}`,
|
|
181
|
+
`Recorded: ${check.knownFingerprints.join(", ")}`,
|
|
182
|
+
`In: ${check.file}`,
|
|
183
|
+
"",
|
|
184
|
+
"This is what a machine-in-the-middle looks like. It is also what a",
|
|
185
|
+
"legitimately reinstalled server looks like. Do not connect until you know",
|
|
186
|
+
"which one it is: confirm the fingerprint out of band, then remove the old",
|
|
187
|
+
`line with: ssh-keygen -R ${host}`,
|
|
188
|
+
].join("\n");
|
|
189
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ssh_authorize tool -- Replace a password login with a key login.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { Type } from "typebox";
|
|
6
|
+
import { resolveProfile } from "../config.ts";
|
|
7
|
+
import { authorizeKey } from "../authorize.ts";
|
|
8
|
+
import { formatAuthorizeResult } from "../formatting/formatters.ts";
|
|
9
|
+
|
|
10
|
+
export const SshAuthorizeTool = {
|
|
11
|
+
name: "ssh_authorize",
|
|
12
|
+
label: "SSH Install Key",
|
|
13
|
+
description:
|
|
14
|
+
"Set up passwordless login: generate an SSH key if the profile has none, install its public key in the remote authorized_keys, point the profile at it, and verify that a key-only login works. This is what ssh-copy-id does, but it runs in process, so no ssh-keygen or ssh-copy-id has to be installed. Run it once after configuring a host with a password.",
|
|
15
|
+
parameters: Type.Object({
|
|
16
|
+
profile: Type.Optional(
|
|
17
|
+
Type.String({ description: "SSH profile to set up. Defaults to the active one." }),
|
|
18
|
+
),
|
|
19
|
+
keyPath: Type.Optional(
|
|
20
|
+
Type.String({
|
|
21
|
+
description:
|
|
22
|
+
"Key to use or create. Default ~/.ssh/id_ed25519_pi_<profile>. An existing key at this path is reused, never overwritten.",
|
|
23
|
+
}),
|
|
24
|
+
),
|
|
25
|
+
comment: Type.Optional(
|
|
26
|
+
Type.String({ description: "Comment for a newly generated key." }),
|
|
27
|
+
),
|
|
28
|
+
authorizedKeysPath: Type.Optional(
|
|
29
|
+
Type.String({
|
|
30
|
+
description:
|
|
31
|
+
"Absolute path of authorized_keys on the remote host. Only needed when its sshd uses a non-standard AuthorizedKeysFile; by default the remote account's ~/.ssh/authorized_keys is used.",
|
|
32
|
+
}),
|
|
33
|
+
),
|
|
34
|
+
removePassword: Type.Optional(
|
|
35
|
+
Type.Boolean({
|
|
36
|
+
description:
|
|
37
|
+
"Delete the stored password once the key login is proven to work. Default false, which keeps it as a fallback.",
|
|
38
|
+
default: false,
|
|
39
|
+
}),
|
|
40
|
+
),
|
|
41
|
+
acceptNewHostKey: Type.Optional(
|
|
42
|
+
Type.Boolean({
|
|
43
|
+
description: "Record the host key if this host is not yet known.",
|
|
44
|
+
default: false,
|
|
45
|
+
}),
|
|
46
|
+
),
|
|
47
|
+
}),
|
|
48
|
+
|
|
49
|
+
async execute(
|
|
50
|
+
_toolCallId: string,
|
|
51
|
+
params: {
|
|
52
|
+
profile?: string;
|
|
53
|
+
keyPath?: string;
|
|
54
|
+
comment?: string;
|
|
55
|
+
authorizedKeysPath?: string;
|
|
56
|
+
removePassword?: boolean;
|
|
57
|
+
acceptNewHostKey?: boolean;
|
|
58
|
+
},
|
|
59
|
+
signal: AbortSignal,
|
|
60
|
+
) {
|
|
61
|
+
const { name, profile } = resolveProfile(params.profile);
|
|
62
|
+
|
|
63
|
+
const result = await authorizeKey({
|
|
64
|
+
profileName: name,
|
|
65
|
+
profile,
|
|
66
|
+
keyPath: params.keyPath,
|
|
67
|
+
comment: params.comment,
|
|
68
|
+
authorizedKeysPath: params.authorizedKeysPath,
|
|
69
|
+
removePassword: params.removePassword,
|
|
70
|
+
acceptNewHostKey: params.acceptNewHostKey,
|
|
71
|
+
signal,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const note =
|
|
75
|
+
result.verified && params.removePassword
|
|
76
|
+
? "\n\nThe stored password has been removed from the profile."
|
|
77
|
+
: result.verified && profile.password
|
|
78
|
+
? "\n\nThe password is still stored as a fallback. Re-run with removePassword: true to drop it."
|
|
79
|
+
: "";
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
content: [
|
|
83
|
+
{ type: "text" as const, text: `${formatAuthorizeResult(result)}${note}` },
|
|
84
|
+
],
|
|
85
|
+
details: { ...result, passwordRemoved: Boolean(result.verified && params.removePassword) },
|
|
86
|
+
};
|
|
87
|
+
},
|
|
88
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ssh_doctor tool -- Report what the environment can do and what it cannot.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { Type } from "typebox";
|
|
6
|
+
import { formatChecks, runChecks } from "../doctor.ts";
|
|
7
|
+
|
|
8
|
+
export const SshDoctorTool = {
|
|
9
|
+
name: "ssh_doctor",
|
|
10
|
+
label: "SSH Doctor",
|
|
11
|
+
description:
|
|
12
|
+
"Check whether this machine can use the SSH tools and report anything that needs fixing or installing. Run it when a connection fails for reasons that are not about the remote host, or when the user asks what they need to install. Nothing external is normally required.",
|
|
13
|
+
parameters: Type.Object({}),
|
|
14
|
+
|
|
15
|
+
execute(_toolCallId: string, _params: {}, _signal: AbortSignal) {
|
|
16
|
+
const checks = runChecks();
|
|
17
|
+
return {
|
|
18
|
+
content: [{ type: "text" as const, text: formatChecks(checks) }],
|
|
19
|
+
details: {
|
|
20
|
+
platform: process.platform,
|
|
21
|
+
problems: checks.filter((check) => check.status === "problem").map((c) => c.name),
|
|
22
|
+
checks: checks.map((check) => ({ name: check.name, status: check.status })),
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
},
|
|
26
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ssh_download tool -- Copy a remote file to this machine over SFTP.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { Type } from "typebox";
|
|
6
|
+
import { resolveProfile } from "../config.ts";
|
|
7
|
+
import { downloadFile, withConnection } from "../clients/ssh-client.ts";
|
|
8
|
+
import { formatTransfer } from "../formatting/formatters.ts";
|
|
9
|
+
|
|
10
|
+
export const SshDownloadTool = {
|
|
11
|
+
name: "ssh_download",
|
|
12
|
+
label: "SSH Download File",
|
|
13
|
+
description:
|
|
14
|
+
"Copy a file from the remote host to this machine over SFTP. Missing local directories are created. Prefer this over cat-ing a file through ssh_exec: it handles binary content and does not pass the file through the model.",
|
|
15
|
+
parameters: Type.Object({
|
|
16
|
+
remotePath: Type.String({ description: "File on the remote host." }),
|
|
17
|
+
localPath: Type.String({
|
|
18
|
+
description: "Destination on this machine, including the file name.",
|
|
19
|
+
}),
|
|
20
|
+
profile: Type.Optional(
|
|
21
|
+
Type.String({ description: "SSH profile to use. Defaults to the active one." }),
|
|
22
|
+
),
|
|
23
|
+
acceptNewHostKey: Type.Optional(
|
|
24
|
+
Type.Boolean({ description: "Record an unknown host key.", default: false }),
|
|
25
|
+
),
|
|
26
|
+
}),
|
|
27
|
+
|
|
28
|
+
async execute(
|
|
29
|
+
_toolCallId: string,
|
|
30
|
+
params: {
|
|
31
|
+
remotePath: string;
|
|
32
|
+
localPath: string;
|
|
33
|
+
profile?: string;
|
|
34
|
+
acceptNewHostKey?: boolean;
|
|
35
|
+
},
|
|
36
|
+
signal: AbortSignal,
|
|
37
|
+
) {
|
|
38
|
+
const { name, profile } = resolveProfile(params.profile);
|
|
39
|
+
|
|
40
|
+
const result = await withConnection(
|
|
41
|
+
profile,
|
|
42
|
+
{ signal, acceptNewHostKey: params.acceptNewHostKey },
|
|
43
|
+
(connection) => downloadFile(connection, params.remotePath, params.localPath),
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
content: [{ type: "text" as const, text: formatTransfer(result, "down") }],
|
|
48
|
+
details: { profile: name, ...result },
|
|
49
|
+
};
|
|
50
|
+
},
|
|
51
|
+
};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ssh_exec tool -- Run a command on the remote host.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { Type } from "typebox";
|
|
6
|
+
import { resolveProfile } from "../config.ts";
|
|
7
|
+
import { execCommand, withConnection } from "../clients/ssh-client.ts";
|
|
8
|
+
import { formatExecResult } from "../formatting/formatters.ts";
|
|
9
|
+
|
|
10
|
+
export const SshExecTool = {
|
|
11
|
+
name: "ssh_exec",
|
|
12
|
+
label: "SSH Run Command",
|
|
13
|
+
description:
|
|
14
|
+
"Run a shell command on the configured remote host and return its output and exit code. The connection is opened for this command and closed again. Commands run non-interactively, so anything that expects input or a TTY (sudo with a password prompt, an editor, top) will hang until the timeout.",
|
|
15
|
+
parameters: Type.Object({
|
|
16
|
+
command: Type.String({ description: "The command line to run on the remote host." }),
|
|
17
|
+
cwd: Type.Optional(
|
|
18
|
+
Type.String({ description: "Directory to run it in. Default: the login directory." }),
|
|
19
|
+
),
|
|
20
|
+
timeoutSeconds: Type.Optional(
|
|
21
|
+
Type.Number({ description: "Give up after this long. Default 120.", default: 120 }),
|
|
22
|
+
),
|
|
23
|
+
profile: Type.Optional(
|
|
24
|
+
Type.String({ description: "SSH profile to use. Defaults to the active one." }),
|
|
25
|
+
),
|
|
26
|
+
acceptNewHostKey: Type.Optional(
|
|
27
|
+
Type.Boolean({
|
|
28
|
+
description:
|
|
29
|
+
"Record the host key if this host is not yet known. Only pass this once the fingerprint has been checked.",
|
|
30
|
+
default: false,
|
|
31
|
+
}),
|
|
32
|
+
),
|
|
33
|
+
}),
|
|
34
|
+
|
|
35
|
+
async execute(
|
|
36
|
+
_toolCallId: string,
|
|
37
|
+
params: {
|
|
38
|
+
command: string;
|
|
39
|
+
cwd?: string;
|
|
40
|
+
timeoutSeconds?: number;
|
|
41
|
+
profile?: string;
|
|
42
|
+
acceptNewHostKey?: boolean;
|
|
43
|
+
},
|
|
44
|
+
signal: AbortSignal,
|
|
45
|
+
) {
|
|
46
|
+
if (!params.command?.trim()) throw new Error("command must not be empty.");
|
|
47
|
+
|
|
48
|
+
const { name, profile } = resolveProfile(params.profile);
|
|
49
|
+
const timeoutMs = Math.min(Math.max(params.timeoutSeconds ?? 120, 1), 3600) * 1000;
|
|
50
|
+
|
|
51
|
+
const result = await withConnection(
|
|
52
|
+
profile,
|
|
53
|
+
{ signal, acceptNewHostKey: params.acceptNewHostKey },
|
|
54
|
+
(connection) =>
|
|
55
|
+
execCommand(connection, params.command, {
|
|
56
|
+
cwd: params.cwd,
|
|
57
|
+
timeoutMs,
|
|
58
|
+
signal,
|
|
59
|
+
}),
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
content: [{ type: "text" as const, text: formatExecResult(result) }],
|
|
64
|
+
details: {
|
|
65
|
+
profile: name,
|
|
66
|
+
host: profile.host,
|
|
67
|
+
exitCode: result.code,
|
|
68
|
+
signal: result.signal,
|
|
69
|
+
durationMs: result.durationMs,
|
|
70
|
+
truncated: result.truncated,
|
|
71
|
+
stdoutBytes: result.stdout.length,
|
|
72
|
+
stderrBytes: result.stderr.length,
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
},
|
|
76
|
+
};
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ssh_keygen tool -- Create an SSH key pair without ssh-keygen.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { Type } from "typebox";
|
|
6
|
+
import * as fs from "node:fs";
|
|
7
|
+
import * as os from "node:os";
|
|
8
|
+
import * as path from "node:path";
|
|
9
|
+
import { expandPath, getProfile, updateProfile } from "../config.ts";
|
|
10
|
+
import { defaultKeyComment, generateKeyPair } from "../keys.ts";
|
|
11
|
+
|
|
12
|
+
export const SshKeygenTool = {
|
|
13
|
+
name: "ssh_keygen",
|
|
14
|
+
label: "SSH Generate Key",
|
|
15
|
+
description:
|
|
16
|
+
"Create an ed25519 SSH key pair on this machine. Works on Windows, macOS and Linux alike because the key is generated in process -- ssh-keygen does not need to be installed. The result is a normal OpenSSH key that the ssh command can use too. To also install it on a host, use ssh_authorize instead.",
|
|
17
|
+
parameters: Type.Object({
|
|
18
|
+
path: Type.Optional(
|
|
19
|
+
Type.String({
|
|
20
|
+
description:
|
|
21
|
+
"Where to write the private key. Default ~/.ssh/id_ed25519_pi. The public key goes next to it with a .pub suffix.",
|
|
22
|
+
}),
|
|
23
|
+
),
|
|
24
|
+
comment: Type.Optional(
|
|
25
|
+
Type.String({ description: "Comment stored in the key, e.g. an email or host name." }),
|
|
26
|
+
),
|
|
27
|
+
profile: Type.Optional(
|
|
28
|
+
Type.String({
|
|
29
|
+
description: "Record the new key in this SSH profile so it is used for logins.",
|
|
30
|
+
}),
|
|
31
|
+
),
|
|
32
|
+
overwrite: Type.Optional(
|
|
33
|
+
Type.Boolean({
|
|
34
|
+
description:
|
|
35
|
+
"Replace an existing key at that path. Default false: overwriting invalidates every host that already trusts the old key.",
|
|
36
|
+
default: false,
|
|
37
|
+
}),
|
|
38
|
+
),
|
|
39
|
+
}),
|
|
40
|
+
|
|
41
|
+
execute(
|
|
42
|
+
_toolCallId: string,
|
|
43
|
+
params: { path?: string; comment?: string; profile?: string; overwrite?: boolean },
|
|
44
|
+
_signal: AbortSignal,
|
|
45
|
+
) {
|
|
46
|
+
const home = process.env.HOME || process.env.USERPROFILE || os.homedir();
|
|
47
|
+
const privateKeyPath = expandPath(
|
|
48
|
+
params.path ?? path.join(home, ".ssh", "id_ed25519_pi"),
|
|
49
|
+
);
|
|
50
|
+
const publicKeyPath = `${privateKeyPath}.pub`;
|
|
51
|
+
|
|
52
|
+
if (fs.existsSync(privateKeyPath) && !params.overwrite) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
`A key already exists at ${privateKeyPath}. Pass overwrite: true to replace it, but note that every host trusting the old key will stop accepting it.`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (params.profile && !getProfile(params.profile)) {
|
|
59
|
+
throw new Error(`Profile "${params.profile}" does not exist.`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const comment =
|
|
63
|
+
params.comment ?? defaultKeyComment(os.userInfo().username, os.hostname());
|
|
64
|
+
const pair = generateKeyPair(comment);
|
|
65
|
+
|
|
66
|
+
fs.mkdirSync(path.dirname(privateKeyPath), { recursive: true, mode: 0o700 });
|
|
67
|
+
fs.writeFileSync(privateKeyPath, pair.privateKey, { mode: 0o600 });
|
|
68
|
+
fs.chmodSync(privateKeyPath, 0o600);
|
|
69
|
+
fs.writeFileSync(publicKeyPath, `${pair.publicKey}\n`, { mode: 0o644 });
|
|
70
|
+
|
|
71
|
+
if (params.profile) {
|
|
72
|
+
updateProfile(params.profile, { privateKeyPath });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
content: [
|
|
77
|
+
{
|
|
78
|
+
type: "text" as const,
|
|
79
|
+
text: [
|
|
80
|
+
`Created an ed25519 key pair.`,
|
|
81
|
+
`Private: ${privateKeyPath} (owner only)`,
|
|
82
|
+
`Public: ${publicKeyPath}`,
|
|
83
|
+
`Fingerprint: ${pair.fingerprint}`,
|
|
84
|
+
params.profile ? `Recorded in profile "${params.profile}".` : "",
|
|
85
|
+
"",
|
|
86
|
+
"Public key to install on a host:",
|
|
87
|
+
pair.publicKey,
|
|
88
|
+
]
|
|
89
|
+
.filter(Boolean)
|
|
90
|
+
.join("\n"),
|
|
91
|
+
},
|
|
92
|
+
],
|
|
93
|
+
details: {
|
|
94
|
+
privateKeyPath,
|
|
95
|
+
publicKeyPath,
|
|
96
|
+
fingerprint: pair.fingerprint,
|
|
97
|
+
publicKey: pair.publicKey,
|
|
98
|
+
profile: params.profile ?? null,
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
},
|
|
102
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ssh_list tool -- List a directory on the remote host over SFTP.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { Type } from "typebox";
|
|
6
|
+
import { resolveProfile } from "../config.ts";
|
|
7
|
+
import { listDirectory, withConnection } from "../clients/ssh-client.ts";
|
|
8
|
+
import { formatDirectory } from "../formatting/formatters.ts";
|
|
9
|
+
|
|
10
|
+
export const SshListTool = {
|
|
11
|
+
name: "ssh_list",
|
|
12
|
+
label: "SSH List Directory",
|
|
13
|
+
description:
|
|
14
|
+
"List a directory on the remote host with sizes, permissions and dates, over SFTP. Use this instead of running ls, because the result is structured.",
|
|
15
|
+
parameters: Type.Object({
|
|
16
|
+
path: Type.String({ description: "Remote directory, e.g. /var/log or ." }),
|
|
17
|
+
profile: Type.Optional(
|
|
18
|
+
Type.String({ description: "SSH profile to use. Defaults to the active one." }),
|
|
19
|
+
),
|
|
20
|
+
acceptNewHostKey: Type.Optional(
|
|
21
|
+
Type.Boolean({ description: "Record an unknown host key.", default: false }),
|
|
22
|
+
),
|
|
23
|
+
}),
|
|
24
|
+
|
|
25
|
+
async execute(
|
|
26
|
+
_toolCallId: string,
|
|
27
|
+
params: { path: string; profile?: string; acceptNewHostKey?: boolean },
|
|
28
|
+
signal: AbortSignal,
|
|
29
|
+
) {
|
|
30
|
+
const { name, profile } = resolveProfile(params.profile);
|
|
31
|
+
const remotePath = params.path?.trim() || ".";
|
|
32
|
+
|
|
33
|
+
const entries = await withConnection(
|
|
34
|
+
profile,
|
|
35
|
+
{ signal, acceptNewHostKey: params.acceptNewHostKey },
|
|
36
|
+
(connection) => listDirectory(connection, remotePath),
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
content: [{ type: "text" as const, text: formatDirectory(entries, remotePath) }],
|
|
41
|
+
details: {
|
|
42
|
+
profile: name,
|
|
43
|
+
path: remotePath,
|
|
44
|
+
count: entries.length,
|
|
45
|
+
directories: entries.filter((entry) => entry.type === "directory").length,
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
},
|
|
49
|
+
};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ssh_profile tool -- List, switch or delete stored hosts.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { Type } from "typebox";
|
|
6
|
+
import {
|
|
7
|
+
deleteProfile,
|
|
8
|
+
getActiveProfile,
|
|
9
|
+
getProfiles,
|
|
10
|
+
setActiveProfile,
|
|
11
|
+
} from "../config.ts";
|
|
12
|
+
import { formatProfileStatus } from "../formatting/formatters.ts";
|
|
13
|
+
|
|
14
|
+
export const SshProfileTool = {
|
|
15
|
+
name: "ssh_profile",
|
|
16
|
+
label: "Manage SSH Profiles",
|
|
17
|
+
description:
|
|
18
|
+
"List configured SSH hosts, switch the active one, or delete one. Without arguments it lists them.",
|
|
19
|
+
parameters: Type.Object({
|
|
20
|
+
action: Type.Optional(
|
|
21
|
+
Type.String({ description: "One of: list, use, delete. Defaults to list." }),
|
|
22
|
+
),
|
|
23
|
+
name: Type.Optional(
|
|
24
|
+
Type.String({ description: "Profile name for 'use' and 'delete'." }),
|
|
25
|
+
),
|
|
26
|
+
}),
|
|
27
|
+
|
|
28
|
+
execute(
|
|
29
|
+
_toolCallId: string,
|
|
30
|
+
params: { action?: string; name?: string },
|
|
31
|
+
_signal: AbortSignal,
|
|
32
|
+
) {
|
|
33
|
+
const action = (params.action || "list").toLowerCase();
|
|
34
|
+
|
|
35
|
+
if (action === "list") {
|
|
36
|
+
return {
|
|
37
|
+
content: [
|
|
38
|
+
{
|
|
39
|
+
type: "text" as const,
|
|
40
|
+
text: formatProfileStatus(getProfiles(), getActiveProfile()),
|
|
41
|
+
},
|
|
42
|
+
],
|
|
43
|
+
details: {
|
|
44
|
+
profiles: Object.keys(getProfiles()),
|
|
45
|
+
activeProfile: getActiveProfile(),
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (!params.name) {
|
|
51
|
+
throw new Error(`The "${action}" action requires a profile name.`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (action === "use") {
|
|
55
|
+
setActiveProfile(params.name);
|
|
56
|
+
return {
|
|
57
|
+
content: [
|
|
58
|
+
{ type: "text" as const, text: `Active SSH profile is now "${params.name}".` },
|
|
59
|
+
],
|
|
60
|
+
details: { activeProfile: params.name },
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (action === "delete") {
|
|
65
|
+
const removed = deleteProfile(params.name);
|
|
66
|
+
const text = removed
|
|
67
|
+
? `Profile "${params.name}" deleted. Active profile is now ${getActiveProfile() ? `"${getActiveProfile()}"` : "unset"}.`
|
|
68
|
+
: `No profile named "${params.name}".`;
|
|
69
|
+
return {
|
|
70
|
+
content: [{ type: "text" as const, text }],
|
|
71
|
+
details: { deleted: removed, activeProfile: getActiveProfile() },
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
throw new Error(`Unknown action "${params.action}". Use one of: list, use, delete.`);
|
|
76
|
+
},
|
|
77
|
+
};
|