@xynogen/pix-ssh 0.4.1 → 0.5.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/README.md +1 -1
- package/package.json +1 -1
- package/src/index.ts +33 -2
- package/src/lib.ts +78 -0
package/README.md
CHANGED
|
@@ -26,7 +26,7 @@ Lets the agent discover hosts without reading `~/.ssh/config` itself. Read-only:
|
|
|
26
26
|
### Authentication
|
|
27
27
|
|
|
28
28
|
- **SSH login** — tries key/agent/existing-master auth first via a `BatchMode=yes` probe. If that succeeds, no login password is needed. If it fails, a masked overlay (`●` per character) collects the login password, fed to `sshpass -e` through the child's `SSHPASS` env var — never as an argv (no `ps` leak), never written to disk.
|
|
29
|
-
- **Remote sudo** (`sudo: true`) — a separate masked prompt collects the remote sudo password, piped to the remote `sudo -S -p ''` on stdin, so it travels inside the encrypted SSH channel, not as an argv.
|
|
29
|
+
- **Remote sudo** (`sudo: true`) — when the SSH connection is passwordless, `ssh_run` first probes `sudo -n true`; if the remote sudoers is `NOPASSWD` the overlay shows a plain Allow/Deny with no sudo prompt. Otherwise a separate masked prompt collects the remote sudo password, piped to the remote `sudo -S -p ''` on stdin, so it travels inside the encrypted SSH channel, not as an argv.
|
|
30
30
|
|
|
31
31
|
Both passwords are cached **in-memory per host** for the session (keyed by `user@host:port`), never persisted. A wrong login password drops the login cache; a wrong sudo password drops the sudo cache — the next call re-prompts.
|
|
32
32
|
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -66,6 +66,8 @@ import {
|
|
|
66
66
|
MAX_OUTPUT_LINES,
|
|
67
67
|
parseHost,
|
|
68
68
|
probeKeyAuth,
|
|
69
|
+
probePasswordAuth,
|
|
70
|
+
probeSudoNoPassword,
|
|
69
71
|
readSshConfigAliases,
|
|
70
72
|
resolveHostInfo,
|
|
71
73
|
resolveSshHost,
|
|
@@ -100,6 +102,23 @@ function cacheKey(spec: HostSpec): string {
|
|
|
100
102
|
return `${spec.user ?? ""}@${spec.host}:${spec.port ?? 22}`;
|
|
101
103
|
}
|
|
102
104
|
|
|
105
|
+
/**
|
|
106
|
+
* Overlay password validator for a prompt stage. The login stage tests the
|
|
107
|
+
* password against the host (wrong one re-prompts up to MAX_PASSWORD_ATTEMPTS);
|
|
108
|
+
* the sudo stage can't be probed before login, so it only rejects a blank entry
|
|
109
|
+
* and surfaces a real failure on the run.
|
|
110
|
+
*/
|
|
111
|
+
export function validatorFor(
|
|
112
|
+
stage: "login" | "sudo",
|
|
113
|
+
spec: HostSpec,
|
|
114
|
+
controlPath: string,
|
|
115
|
+
sig?: AbortSignal,
|
|
116
|
+
): (pw: string) => Promise<boolean> {
|
|
117
|
+
if (stage !== "login") return (pw) => Promise.resolve(pw.trim().length > 0);
|
|
118
|
+
return (pw) =>
|
|
119
|
+
pw.trim().length === 0 ? Promise.resolve(false) : probePasswordAuth(spec, controlPath, pw, sig);
|
|
120
|
+
}
|
|
121
|
+
|
|
103
122
|
type SshOutcome =
|
|
104
123
|
| "awaiting-approval"
|
|
105
124
|
| "running"
|
|
@@ -536,7 +555,15 @@ export default function (pi: ExtensionAPI): void {
|
|
|
536
555
|
const keyOk = probe === "ok";
|
|
537
556
|
// Which passwords must the overlay collect this call?
|
|
538
557
|
const needLogin = probe === "auth" && !creds.loginPassword;
|
|
539
|
-
|
|
558
|
+
// Remote sudo password only when NOT already cached, NOT NOPASSWD, and
|
|
559
|
+
// the SSH connection is passwordless (so the probe can open its own
|
|
560
|
+
// channel without prompting). When login still needs a password we
|
|
561
|
+
// can't probe yet, so fall back to prompting for the sudo password.
|
|
562
|
+
const sudoNoPassword =
|
|
563
|
+
sudo && !creds.sudoPassword && !needLogin && (keyOk || Boolean(creds.loginPassword))
|
|
564
|
+
? await probeSudoNoPassword(spec, controlPath, sig)
|
|
565
|
+
: false;
|
|
566
|
+
const needSudo = sudo && !creds.sudoPassword && !sudoNoPassword;
|
|
540
567
|
// The overlay stage pipeline: any password we still need is prompted
|
|
541
568
|
// (login first, then sudo). Confirm-only when nothing is missing.
|
|
542
569
|
const promptFor: ("login" | "sudo")[] = [
|
|
@@ -638,7 +665,11 @@ export default function (pi: ExtensionAPI): void {
|
|
|
638
665
|
timeoutMs: PROMPT_TIMEOUT_MS,
|
|
639
666
|
maxPasswordAttempts: MAX_PASSWORD_ATTEMPTS,
|
|
640
667
|
passwordLabel: `${label}:`,
|
|
641
|
-
|
|
668
|
+
// Login stage: actually test the password against the host so a
|
|
669
|
+
// wrong one re-prompts (up to MAX_PASSWORD_ATTEMPTS), mirroring
|
|
670
|
+
// pix-sudo. Sudo stage can't be validated until login succeeds, so
|
|
671
|
+
// it keeps the non-empty check and surfaces failures on the run.
|
|
672
|
+
validatePassword: validatorFor(stage, spec, controlPath, sig),
|
|
642
673
|
choices: [
|
|
643
674
|
{
|
|
644
675
|
value: "yes",
|
package/src/lib.ts
CHANGED
|
@@ -454,6 +454,84 @@ export function probeKeyAuth(
|
|
|
454
454
|
});
|
|
455
455
|
}
|
|
456
456
|
|
|
457
|
+
/**
|
|
458
|
+
* Validate an SSH *login* password by attempting one real password auth. Forces
|
|
459
|
+
* password auth (no pubkey/agent) with a single prompt, fed via `sshpass -e`
|
|
460
|
+
* (env, never argv). Returns true only on a clean login; an auth rejection
|
|
461
|
+
* returns false so the overlay can re-prompt. Unreachable/spawn errors return
|
|
462
|
+
* true — a password can't fix connectivity, so defer to the real run instead of
|
|
463
|
+
* burning retry attempts. ponytail: each call is one fresh connection (its own
|
|
464
|
+
* MaxAuthTries counter, NumberOfPasswordPrompts=1), so retries don't stack
|
|
465
|
+
* toward a per-connection lockout; a host with fail2ban-style IP banning is the
|
|
466
|
+
* ceiling — then drop back to a single prompt.
|
|
467
|
+
*/
|
|
468
|
+
export function probePasswordAuth(
|
|
469
|
+
spec: HostSpec,
|
|
470
|
+
controlPath: string,
|
|
471
|
+
password: string,
|
|
472
|
+
signal?: AbortSignal,
|
|
473
|
+
): Promise<boolean> {
|
|
474
|
+
const args = [
|
|
475
|
+
"-e",
|
|
476
|
+
"ssh",
|
|
477
|
+
...baseSshArgs(spec, controlPath),
|
|
478
|
+
"-o",
|
|
479
|
+
"BatchMode=no",
|
|
480
|
+
"-o",
|
|
481
|
+
"PubkeyAuthentication=no",
|
|
482
|
+
"-o",
|
|
483
|
+
"PreferredAuthentications=password",
|
|
484
|
+
"-o",
|
|
485
|
+
"NumberOfPasswordPrompts=1",
|
|
486
|
+
hostTarget(spec),
|
|
487
|
+
"true",
|
|
488
|
+
];
|
|
489
|
+
return new Promise((resolve) => {
|
|
490
|
+
let stderr = "";
|
|
491
|
+
const proc = spawn("sshpass", args, {
|
|
492
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
493
|
+
env: { ...process.env, SSHPASS: password },
|
|
494
|
+
});
|
|
495
|
+
proc.stderr.on("data", (c: Buffer) => {
|
|
496
|
+
stderr += c.toString();
|
|
497
|
+
});
|
|
498
|
+
proc.on("error", () => resolve(true));
|
|
499
|
+
proc.on("close", (code) => {
|
|
500
|
+
if (code === 0) return resolve(true);
|
|
501
|
+
if (isUnreachable(stderr)) return resolve(true);
|
|
502
|
+
resolve(false);
|
|
503
|
+
});
|
|
504
|
+
signal?.addEventListener("abort", () => proc.kill("SIGTERM"), { once: true });
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Probe whether remote sudo runs without a password (NOPASSWD sudoers) via
|
|
510
|
+
* `sudo -n true`. Returns true when no sudo password is needed. Requires a
|
|
511
|
+
* passwordless SSH connection (key/agent/existing-master or a cached login
|
|
512
|
+
* password) — the probe opens its own channel with `BatchMode=yes`, so callers
|
|
513
|
+
* must only probe when SSH login auth already succeeds without prompting.
|
|
514
|
+
*/
|
|
515
|
+
export function probeSudoNoPassword(
|
|
516
|
+
spec: HostSpec,
|
|
517
|
+
controlPath: string,
|
|
518
|
+
signal?: AbortSignal,
|
|
519
|
+
): Promise<boolean> {
|
|
520
|
+
const args = [
|
|
521
|
+
...baseSshArgs(spec, controlPath),
|
|
522
|
+
"-o",
|
|
523
|
+
"BatchMode=yes",
|
|
524
|
+
hostTarget(spec),
|
|
525
|
+
"sudo -n true",
|
|
526
|
+
];
|
|
527
|
+
return new Promise((resolve) => {
|
|
528
|
+
const proc = spawn("ssh", args, { stdio: ["ignore", "ignore", "ignore"] });
|
|
529
|
+
proc.on("error", () => resolve(false));
|
|
530
|
+
proc.on("close", (code) => resolve(code === 0));
|
|
531
|
+
signal?.addEventListener("abort", () => proc.kill("SIGTERM"), { once: true });
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
|
|
457
535
|
/** Connection/DNS-level failure (not an auth rejection) — a password can't fix it. */
|
|
458
536
|
export function isUnreachable(stderr: string): boolean {
|
|
459
537
|
const lower = stderr.toLowerCase();
|