189 lines
7.1 KiB
JavaScript
189 lines
7.1 KiB
JavaScript
// Drives `s3d login` through a pseudo-terminal. s3d reads the recovery phrase
|
|
// with term.ReadPassword, which needs a real console, so plain pipes fail.
|
|
// The phrase only ever lives in this process's memory: it is written to the
|
|
// pty and never logged or emitted except the one-time display of a phrase
|
|
// s3d generated, which the user has to copy down.
|
|
|
|
import { EventEmitter } from 'node:events';
|
|
import { spawn } from 'node:child_process';
|
|
import { loadPty } from './pty.js';
|
|
|
|
const ANSI = /\x1b\[[0-9;?]*[A-Za-z]|\x1b\][^\x07]*\x07/g;
|
|
|
|
export class LoginSession extends EventEmitter {
|
|
// registration: async () => ({ registered, indexerUrl }), used to learn the
|
|
// outcome when login ran in a console window we cannot read.
|
|
// ptyDir: where terminal support may be downloaded if the host lacks it.
|
|
constructor(daemon, { registration, ptyDir } = {}) {
|
|
super();
|
|
this.daemon = daemon;
|
|
this.registration = registration;
|
|
this.ptyDir = ptyDir;
|
|
this.term = null;
|
|
this.buffer = '';
|
|
this.state = 'idle';
|
|
// idle | indexer | phrase | confirm | approve | registering | done | already | failed
|
|
this.info = {};
|
|
}
|
|
|
|
snapshot() { return { state: this.state, ...this.info }; }
|
|
|
|
set(state, info = {}) {
|
|
this.state = state;
|
|
Object.assign(this.info, info);
|
|
this.emit('state', this.snapshot());
|
|
}
|
|
|
|
async start({ indexerUrl, phrase }) {
|
|
if (this.term) throw new Error('a login is already in progress');
|
|
this.answers = { indexerUrl: indexerUrl || '', phrase: phrase || '' };
|
|
// Kept in the state so "done" says which indexer this s3d now uses
|
|
// (a blank answer means s3d's default, sia.storage).
|
|
this.info = { indexerUrl: indexerUrl || 'https://sia.storage' };
|
|
this.set('preparing');
|
|
let pty = null;
|
|
try {
|
|
pty = await loadPty({ installDir: this.ptyDir });
|
|
} catch (e) {
|
|
this.lastError = e.message;
|
|
}
|
|
// Without terminal support, run s3d login in its own console window.
|
|
if (!pty) return this.startInConsole();
|
|
this.buffer = '';
|
|
this.generated = null;
|
|
this.lastError = null;
|
|
const term = pty.spawn(this.daemon.binary, ['login'], {
|
|
name: 'xterm', cols: 200, rows: 50, cwd: this.daemon.cwd(), env: this.daemon.env(),
|
|
});
|
|
this.term = term;
|
|
this.set('starting');
|
|
// A cancelled session's pty can still flush output or exit after a new
|
|
// one started; only the current term may drive the state machine.
|
|
term.onData((d) => {
|
|
if (this.term !== term) return;
|
|
try { this.onData(d); } catch (e) { this.fail(e.message); }
|
|
});
|
|
term.onExit(({ exitCode }) => {
|
|
if (this.term !== term) return;
|
|
this.term = null;
|
|
this.answers = null;
|
|
if (!['done', 'already'].includes(this.state)) {
|
|
this.set('failed', { error: this.lastError || `s3d login exited with code ${exitCode}` });
|
|
}
|
|
});
|
|
return this.snapshot();
|
|
}
|
|
|
|
// Fallback when the pty module is not shipped (the Theseus add-on): run
|
|
// s3d login in its own console window and check the result when it closes.
|
|
startInConsole() {
|
|
if (process.platform !== 'win32') {
|
|
throw new Error('run "s3d login" in a terminal on this machine, then reload this page');
|
|
}
|
|
// detached gives a console app its own visible console window on Windows.
|
|
const child = spawn(this.daemon.binary, ['login'], {
|
|
cwd: this.daemon.cwd(), env: this.daemon.env(), detached: true, stdio: 'ignore', windowsHide: false,
|
|
});
|
|
this.term = { kill: () => child.kill(), write() {} };
|
|
const term = this.term;
|
|
this.answers = null;
|
|
this.set('terminal');
|
|
child.on('error', (e) => { if (this.term === term) this.fail(e.message); });
|
|
child.on('exit', async () => {
|
|
if (this.term !== term) return;
|
|
this.term = null;
|
|
try {
|
|
const r = await this.registration();
|
|
if (r.registered) this.set('done', { indexerUrl: r.indexerUrl });
|
|
else this.set('failed', { error: 'the login window closed before registration finished' });
|
|
} catch (e) { this.set('failed', { error: e.message }); }
|
|
});
|
|
return this.snapshot();
|
|
}
|
|
|
|
// The user confirms they stored a phrase s3d generated; we echo it back.
|
|
confirmGenerated() {
|
|
if (this.state !== 'confirm' || !this.generated) throw new Error('nothing to confirm');
|
|
this.write(this.generated);
|
|
this.generated = null;
|
|
this.info.generatedPhrase = undefined;
|
|
this.set('approve-wait');
|
|
}
|
|
|
|
cancel() {
|
|
if (this.term) { try { this.term.kill(); } catch {} }
|
|
this.term = null;
|
|
this.answers = null;
|
|
this.generated = null;
|
|
this.set('idle', { error: undefined, approvalUrl: undefined, generatedPhrase: undefined });
|
|
}
|
|
|
|
write(s) { this.term?.write(s + '\r'); }
|
|
|
|
onData(chunk) {
|
|
this.buffer += chunk.replace(ANSI, '');
|
|
// Work line-by-line, but prompts have no trailing newline, so also look at
|
|
// the unterminated tail.
|
|
const text = this.buffer;
|
|
|
|
// Checked first: s3d prints the error and re-prompts in the same chunk,
|
|
// and answering that prompt with a blank would make it generate a phrase.
|
|
const bad = text.match(/Invalid recovery phrase: (.+)$/m);
|
|
if (bad) {
|
|
// s3d would re-prompt forever; stop and let the user retype.
|
|
return this.fail(`invalid recovery phrase: ${bad[1].trim()}`);
|
|
}
|
|
if (/already registered with (\S+?)\.?\s*$/m.test(text)) {
|
|
const m = text.match(/already registered with (\S+?)\.?\s*$/m);
|
|
this.buffer = '';
|
|
return this.set('already', { indexerUrl: m[1] });
|
|
}
|
|
if (/Indexer URL \(default: [^)]*\):\s*$/.test(text)) {
|
|
this.buffer = '';
|
|
this.set('indexer');
|
|
return this.write(this.answers.indexerUrl);
|
|
}
|
|
if (/Recovery Phrase:\s*(.+)$/m.test(text) && !this.generated) {
|
|
// s3d generated a phrase because we sent a blank one.
|
|
this.generated = text.match(/Recovery Phrase:\s*(.+)$/m)[1].trim();
|
|
}
|
|
if (/Confirm recovery phrase:\s*$/.test(text)) {
|
|
this.buffer = '';
|
|
if (!this.generated) return this.fail('could not read the generated recovery phrase');
|
|
return this.set('confirm', { generatedPhrase: this.generated });
|
|
}
|
|
if (/Enter recovery phrase:\s*$/.test(text)) {
|
|
this.buffer = '';
|
|
this.set('phrase');
|
|
const p = this.answers.phrase;
|
|
this.answers.phrase = '';
|
|
return this.write(p);
|
|
}
|
|
const url = text.match(/visiting the following URL:\s*(\S+)/);
|
|
if (url) {
|
|
this.buffer = text.slice(url.index + url[0].length);
|
|
return this.set('approve', { approvalUrl: url[1] });
|
|
}
|
|
if (/Login successful/.test(text)) {
|
|
this.buffer = '';
|
|
return this.set('done');
|
|
}
|
|
if (/app connection was declined/.test(text)) {
|
|
this.buffer = '';
|
|
return this.fail('the connection was declined on the indexer');
|
|
}
|
|
const fatal = text.match(/^(failed to [^\n]+)$/m);
|
|
if (fatal) this.lastError = fatal[1].trim();
|
|
// Keep the tail bounded; anything we care about is short.
|
|
if (this.buffer.length > 8192) this.buffer = this.buffer.slice(-2048);
|
|
}
|
|
|
|
fail(error) {
|
|
const term = this.term;
|
|
this.term = null;
|
|
this.answers = null;
|
|
this.generated = null;
|
|
this.set('failed', { error, generatedPhrase: undefined });
|
|
if (term) { try { term.kill(); } catch {} }
|
|
}
|
|
}
|