@xynogen/pix-ssh 0.4.2 → 0.5.1

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.
Files changed (3) hide show
  1. package/package.json +2 -2
  2. package/src/index.ts +23 -1
  3. package/src/lib.ts +51 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xynogen/pix-ssh",
3
- "version": "0.4.2",
3
+ "version": "0.5.1",
4
4
  "description": "Pi tool — ssh_run: run remote commands over SSH with password/key auth and remote sudo",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -38,7 +38,7 @@
38
38
  },
39
39
  "dependencies": {
40
40
  "typebox": "^1.1.38",
41
- "@xynogen/pix-pretty": "^1.19.0",
41
+ "@xynogen/pix-pretty": "^1.25.0",
42
42
  "@xynogen/pix-runtime": "^0.8.0"
43
43
  },
44
44
  "peerDependencies": {
package/src/index.ts CHANGED
@@ -66,6 +66,7 @@ import {
66
66
  MAX_OUTPUT_LINES,
67
67
  parseHost,
68
68
  probeKeyAuth,
69
+ probePasswordAuth,
69
70
  probeSudoNoPassword,
70
71
  readSshConfigAliases,
71
72
  resolveHostInfo,
@@ -101,6 +102,23 @@ function cacheKey(spec: HostSpec): string {
101
102
  return `${spec.user ?? ""}@${spec.host}:${spec.port ?? 22}`;
102
103
  }
103
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
+
104
122
  type SshOutcome =
105
123
  | "awaiting-approval"
106
124
  | "running"
@@ -647,7 +665,11 @@ export default function (pi: ExtensionAPI): void {
647
665
  timeoutMs: PROMPT_TIMEOUT_MS,
648
666
  maxPasswordAttempts: MAX_PASSWORD_ATTEMPTS,
649
667
  passwordLabel: `${label}:`,
650
- validatePassword: (pw) => Promise.resolve(pw.trim().length > 0),
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),
651
673
  choices: [
652
674
  {
653
675
  value: "yes",
package/src/lib.ts CHANGED
@@ -454,6 +454,57 @@ 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
+
457
508
  /**
458
509
  * Probe whether remote sudo runs without a password (NOPASSWD sudoers) via
459
510
  * `sudo -n true`. Returns true when no sudo password is needed. Requires a