diff --git a/pkg/lib/cockpit.d.ts b/pkg/lib/cockpit.d.ts index a3fb4a00f871..5cd85316e111 100644 --- a/pkg/lib/cockpit.d.ts +++ b/pkg/lib/cockpit.d.ts @@ -150,6 +150,13 @@ declare module 'cockpit' { /* === cockpit.{spawn,script} ============================= */ + class ProcessError { + problem: string | null; + exit_status: number | null; + exit_signal: number | null; + message: string; + } + interface Spawn extends DeferredPromise { input(message?: T | null, stream?: boolean): DeferredPromise; stream(callback: (data: T) => void): DeferredPromise; diff --git a/pkg/lib/cockpit.js b/pkg/lib/cockpit.js index c40e4a479492..fd612ed35120 100644 --- a/pkg/lib/cockpit.js +++ b/pkg/lib/cockpit.js @@ -1233,6 +1233,8 @@ function factory() { }; } + cockpit.ProcessError = ProcessError; + function spawn_debug() { if (window.debugging == "all" || window.debugging?.includes("spawn")) console.debug.apply(console, arguments); diff --git a/pkg/lib/credentials.js b/pkg/lib/credentials.ts similarity index 61% rename from pkg/lib/credentials.js rename to pkg/lib/credentials.ts index bca8c94056e8..70be1df125b6 100644 --- a/pkg/lib/credentials.js +++ b/pkg/lib/credentials.ts @@ -17,67 +17,91 @@ * along with Cockpit; If not, see . */ -import cockpit from "cockpit"; +import cockpit, { SpawnOptions } from "cockpit"; +// @ts-expect-error: magic verbatim string import, not a JS module import lister from "credentials-ssh-private-keys.sh"; +// @ts-expect-error: magic verbatim string import, not a JS module import remove_key from "credentials-ssh-remove-key.sh"; const _ = cockpit.gettext; -function Keys() { - const self = this; +export interface Key { + type: string; + comment: string; + data: string; + name?: string; + loaded?: boolean; + agent_only?: boolean; + size?: number | null; + fingerprint?: string; +} - self.path = null; - self.items = { }; +export class KeyLoadError extends Error { + sent_password: boolean; - let proc = null; - let timeout = null; + constructor(sent_password: boolean, message: string) { + super(message); + this.sent_password = sent_password; + } +} - cockpit.event_target(this); +class Keys extends EventTarget { + path: string | null = null; + items: Record = { }; - self.p_have_path = cockpit.user() - .then(user => { - self.path = user.home + '/.ssh'; - refresh(); - }); + #p_have_path: Promise; - function refresh() { - if (proc) + constructor() { + super(); + this.#p_have_path = cockpit.user() + .then(user => { + this.path = user.home + '/.ssh'; + this.#refresh(); + }); + } + + #proc: cockpit.Spawn | null = null; + #timeout: number | null = null; + + #refresh(): void { + if (this.#proc || !this.path) return; - window.clearTimeout(timeout); - timeout = null; + if (this.#timeout) + window.clearTimeout(this.#timeout); + this.#timeout = null; - proc = cockpit.script(lister, [self.path], { err: "message" }); - proc - .then(data => process(data)) + this.#proc = cockpit.script(lister, [this.path], { err: "message" }); + this.#proc + .then(data => this.#process(data)) .catch(ex => console.warn("failed to list keys in home directory: " + ex.message)) .finally(() => { - proc = null; + this.#proc = null; - if (!timeout) - timeout = window.setTimeout(refresh, 5000); + if (!this.#timeout) + this.#timeout = window.setTimeout(() => this.#refresh(), 5000); }); } - function process(data) { + #process(data: string): void { const blocks = data.split('\v'); - let key; + let key: Key | undefined; const items = { }; /* First block is the data from ssh agent */ blocks[0].trim().split("\n") - .forEach(function(line) { - key = parse_key(line, items); + .forEach(line => { + key = this.#parse_key(line, items); if (key) key.loaded = true; }); /* Next come individual triples of blocks */ - blocks.slice(1).forEach(function(block, i) { + blocks.slice(1).forEach((block, i) => { switch (i % 3) { case 0: - key = parse_key(block, items); + key = this.#parse_key(block, items); break; case 1: if (key) { @@ -92,16 +116,16 @@ function Keys() { break; case 2: if (key) - parse_info(block, key); + this.#parse_info(block, key); break; } }); - self.items = items; - self.dispatchEvent("changed"); + this.items = items; + this.dispatchEvent(new CustomEvent("changed")); } - function parse_key(line, items) { + #parse_key(line: string, items: Record): Key | undefined { const parts = line.trim().split(" "); let id, type, comment; @@ -123,16 +147,22 @@ function Keys() { } let key = items[id]; - if (!key) - key = items[id] = { }; + if (key) { + key.type = type; + key.comment = comment; + key.data = line; + } else { + key = items[id] = { + type, + comment, + data: line, + }; + } - key.type = type; - key.comment = comment; - key.data = line; return key; } - function parse_info(line, key) { + #parse_info(line: string, key: Key): void { const parts = line.trim().split(" ") .filter(n => !!n); @@ -146,7 +176,7 @@ function Keys() { key.name = parts[2]; } - async function run_keygen(file, new_type, old_pass, new_pass) { + async #run_keygen(file: string, new_type: string | null, old_pass: string | null, new_pass: string): Promise { const old_exps = [/.*Enter old passphrase: $/]; const new_exps = [/.*Enter passphrase.*/, /.*Enter new passphrase.*/, /.*Enter same passphrase again: $/]; const bad_exps = [/.*failed: passphrase is too short.*/]; @@ -164,9 +194,10 @@ function Keys() { else cmd.push("-p"); - await self.p_have_path; + await this.#p_have_path; + cockpit.assert(this.path); - const proc = cockpit.spawn(cmd, { pty: true, environ: ["LC_ALL=C"], err: "out", directory: self.path }); + const proc = cockpit.spawn(cmd, { pty: true, environ: ["LC_ALL=C"], err: "out", directory: this.path }); proc.stream(data => { buffer += data; @@ -198,7 +229,7 @@ function Keys() { try { await proc; } catch (ex) { - if (ex.exit_status) + if (ex instanceof cockpit.ProcessError && ex.exit_status) throw new Error(failure); throw ex; } finally { @@ -206,17 +237,21 @@ function Keys() { } } - self.change = (name, old_pass, new_pass) => run_keygen(name, null, old_pass, new_pass); + async change(name: string, old_pass: string, new_pass: string): Promise { + await this.#run_keygen(name, null, old_pass, new_pass); + } - self.create = async (name, type, new_pass) => { + async create(name: string, type: string, new_pass: string): Promise { // ensure ~/.ssh directory exists await cockpit.script('dir=$(dirname "$1"); test -e "$dir" || mkdir -m 700 "$dir"', [name]); - await run_keygen(name, type, null, new_pass); - }; + await this.#run_keygen(name, type, null, new_pass); + } - self.get_pubkey = name => cockpit.file(name + ".pub").read(); + async get_pubkey(name: string): Promise { + return await cockpit.file(name + ".pub").read(); + } - self.load = async function(name, password) { + async load(name: string, password: string): Promise { const ask_exp = /.*Enter passphrase for .*/; const perm_exp = /.*UNPROTECTED PRIVATE KEY FILE.*/; const bad_exp = /.*Bad passphrase.*/; @@ -226,10 +261,11 @@ function Keys() { let failure = _("Not a valid private key"); let sent_password = false; - await self.p_have_path; + await this.#p_have_path; + cockpit.assert(this.path); const proc = cockpit.spawn(["ssh-add", name], - { pty: true, environ: ["LC_ALL=C"], err: "out", directory: self.path }); + { pty: true, environ: ["LC_ALL=C"], err: "out", directory: this.path }); const timeout = window.setTimeout(() => { failure = _("Prompting via ssh-add timed out"); @@ -255,35 +291,44 @@ function Keys() { try { await proc; - refresh(); + this.#refresh(); } catch (error) { console.log(output); - const ex = error.exit_status ? new Error(failure) : error; - ex.sent_password = sent_password; + let ex: KeyLoadError | unknown; + if (error instanceof cockpit.ProcessError && error.exit_status) { + ex = new KeyLoadError(sent_password, failure); + } else if (error instanceof Error) { + ex = new KeyLoadError(sent_password, error.message); + } else { + ex = error; + } throw ex; } finally { window.clearTimeout(timeout); } - }; + } + + async unload(key: Key): Promise { + await this.#p_have_path; + cockpit.assert(this.path); - self.unload = async function(key) { - await self.p_have_path; - const options = { pty: true, err: "message", directory: self.path }; + const options: SpawnOptions & { binary?: false; } = { pty: true, err: "message", directory: this.path }; if (key.name && !key.agent_only) await cockpit.spawn(["ssh-add", "-d", key.name], options); else await cockpit.script(remove_key, [key.data], options); - await refresh(); - }; + this.#refresh(); + } - self.close = function() { - if (proc) - proc.close(); - window.clearTimeout(timeout); - timeout = null; - }; + close() { + if (this.#proc) + this.#proc.close(); + if (this.#timeout) + window.clearTimeout(this.#timeout); + this.#timeout = null; + } } export function keys_instance() { diff --git a/pkg/shell/credentials.jsx b/pkg/shell/credentials.jsx index d859691365ed..d17f2ab45540 100644 --- a/pkg/shell/credentials.jsx +++ b/pkg/shell/credentials.jsx @@ -88,7 +88,7 @@ export const CredentialsModal = ({ dialogResult }) => { {_("Add key")} - {addNewKey && setAddNewKey(false)} />} + {addNewKey && setAddNewKey(false)} />} { renderer: PublicKey, }, { - data: { currentKey, change: keys.change, setDialogError }, + data: { currentKey, keys, setDialogError }, name: _("Password"), renderer: KeyPassword, }, @@ -140,22 +140,22 @@ export const CredentialsModal = ({ dialogResult }) => { })} /> - {unlockKey && { setUnlockKey(undefined); setAddNewKey(false) }} />} + {unlockKey && { setUnlockKey(undefined); setAddNewKey(false) }} />} ); }; -const AddNewKey = ({ load, unlockKey, onClose }) => { +const AddNewKey = ({ keys, unlockKey, onClose }) => { const [addNewKeyLoading, setAddNewKeyLoading] = useState(false); const [newKeyPath, setNewKeyPath] = useState(""); const [newKeyPathError, setNewKeyPathError] = useState(); const addCustomKey = () => { setAddNewKeyLoading(true); - load(newKeyPath) + keys.load(newKeyPath) .then(onClose) .catch(ex => { - if (!ex.sent_password) + if (!(ex instanceof credentials.KeyLoadError) || !ex.sent_password) setNewKeyPathError(ex.message); else unlockKey(newKeyPath); @@ -213,7 +213,7 @@ const PublicKey = ({ currentKey }) => { ); }; -const KeyPassword = ({ currentKey, change, setDialogError }) => { +const KeyPassword = ({ currentKey, keys, setDialogError }) => { const [confirmPassword, setConfirmPassword] = useState(''); const [inProgress, setInProgress] = useState(undefined); const [newPassword, setNewPassword] = useState(''); @@ -229,7 +229,7 @@ const KeyPassword = ({ currentKey, change, setDialogError }) => { if (oldPassword === undefined || newPassword === undefined || confirmPassword === undefined) setDialogError("Invalid password fields"); - change(currentKey.name, oldPassword, newPassword, confirmPassword) + keys.change(currentKey.name, oldPassword, newPassword) .then(() => { setOldPassword(''); setNewPassword(''); @@ -285,7 +285,7 @@ const KeyPassword = ({ currentKey, change, setDialogError }) => { ); }; -const UnlockKey = ({ keyName, load, onClose }) => { +const UnlockKey = ({ keyName, keys, onClose }) => { const [password, setPassword] = useState(); const [dialogError, setDialogError] = useState(); @@ -293,7 +293,7 @@ const UnlockKey = ({ keyName, load, onClose }) => { if (!keyName) return; - load(keyName, password) + keys.load(keyName, password) .then(onClose) .catch(ex => { setDialogError(ex.message);