diff --git a/.gitignore b/.gitignore index f6c313c..a727c0a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,2 @@ /target -/data \ No newline at end of file +/data diff --git a/Cargo.lock b/Cargo.lock index 2b4a0b9..bcb1360 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1382,7 +1382,7 @@ dependencies = [ [[package]] name = "labdeck" -version = "0.1.0" +version = "0.1.1" dependencies = [ "argon2", "axum", diff --git a/Cargo.toml b/Cargo.toml index 4516a8b..419707a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "labdeck" -version = "0.1.0" +version = "0.1.1" edition = "2024" [dependencies] diff --git a/src/term.rs b/src/term.rs index f8dac7b..aa62f9f 100644 --- a/src/term.rs +++ b/src/term.rs @@ -13,8 +13,9 @@ use axum::{ http::{HeaderMap, StatusCode}, response::{IntoResponse, Response}, }; +use russh::client::KeyboardInteractiveAuthResponse as Kbd; use russh::keys::{HashAlg, PrivateKeyWithHashAlg, PublicKeyOrCertificate, decode_secret_key}; -use russh::{ChannelMsg, client}; +use russh::{ChannelMsg, MethodKind, client}; use serde::Deserialize; use std::sync::{ Arc, Mutex, @@ -87,6 +88,90 @@ impl client::Handler for Handler { } } +/// keyboard-interactive: PAM prompts (password, "Verification code: " ...) are relayed to the +/// browser terminal. A stored password answers the first "password" prompt automatically. +async fn kbd_auth( + handle: &mut client::Handle, + user: &str, + auto_pw: Option<&str>, + sock: &mut WebSocket, +) -> Result { + let mut resp = handle + .authenticate_keyboard_interactive_start(user.to_string(), None) + .await + .map_err(|e| e.to_string())?; + let mut auto_used = false; + for _round in 0..8 { + match resp { + Kbd::Success => return Ok(true), + Kbd::Failure { .. } => return Ok(false), + Kbd::InfoRequest { name, instructions, prompts } => { + for line in [name.trim(), instructions.trim()] { + if !line.is_empty() { + ctl(sock, "info", line).await; + } + } + let mut answers = Vec::with_capacity(prompts.len()); + for p in prompts { + let low = p.prompt.to_lowercase(); + if let (Some(pw), false, true) = (auto_pw, auto_used, low.contains("password")) { + auto_used = true; + answers.push(pw.to_string()); + } else { + answers.push(ask(sock, &p.prompt, p.echo).await?); + } + } + resp = handle + .authenticate_keyboard_interactive_respond(answers) + .await + .map_err(|e| e.to_string())?; + } + } + } + Err("too many keyboard-interactive rounds".into()) +} + +/// Reads one line typed in the browser terminal. Echoes characters unless `echo` is false (like ssh). +async fn ask(sock: &mut WebSocket, prompt: &str, echo: bool) -> Result { + ctl(sock, "prompt", prompt).await; + let mut buf = String::new(); + let deadline = tokio::time::sleep(Duration::from_secs(110)); + tokio::pin!(deadline); + loop { + tokio::select! { + _ = &mut deadline => return Err("timed out waiting for input".into()), + m = sock.recv() => match m { + Some(Ok(Message::Binary(b))) => { + for ch in String::from_utf8_lossy(&b).chars() { + match ch { + '\r' | '\n' => { + let _ = sock.send(Message::Binary(Bytes::from_static(b"\r\n"))).await; + return Ok(buf); + } + '\u{7f}' | '\u{8}' => { + if buf.pop().is_some() && echo { + let _ = sock.send(Message::Binary(Bytes::from_static(b"\x08 \x08"))).await; + } + } + '\u{3}' => return Err("cancelled".into()), + '\u{1b}' => break, // arrow keys etc.: drop the rest of this frame + c if !c.is_control() && buf.len() < 256 => { + buf.push(c); + if echo { + let _ = sock.send(Message::Binary(Bytes::copy_from_slice(c.to_string().as_bytes()))).await; + } + } + _ => {} + } + } + } + Some(Ok(Message::Close(_))) | None | Some(Err(_)) => return Err("browser closed the connection".into()), + Some(Ok(_)) => {} + } + } + } +} + async fn session(app: &Arc, id: i64, cols: u32, rows: u32, sock: &mut WebSocket) -> Result { let (host, blob) = { let c = app.db.lock().unwrap(); @@ -159,7 +244,11 @@ async fn session(app: &Arc, id: i64, cols: u32, rows: u32, sock: &mut WebSo } // ---- authentication - let authed = if host.auth == "key" { + // 1) the configured method, 2) if the server wants more (e.g. publickey,keyboard-interactive + // with PAM / Google Authenticator) or only offers keyboard-interactive: ask via the terminal. + let is_key = host.auth == "key"; + let mut auto_pw: Option = None; + let first = if is_key { let pem = secret.get("key").and_then(|v| v.as_str()).ok_or("no private key stored")?; let pass = secret.get("passphrase").and_then(|v| v.as_str()).filter(|p| !p.is_empty()); let pk = decode_secret_key(pem, pass).map_err(|e| format!("cannot read private key: {e}"))?; @@ -172,17 +261,45 @@ async fn session(app: &Arc, id: i64, cols: u32, rows: u32, sock: &mut WebSo .authenticate_publickey(host.username.clone(), PrivateKeyWithHashAlg::new(Arc::new(pk), hash)) .await .map_err(|e| e.to_string())? - .success() } else { let pw = secret.get("password").and_then(|v| v.as_str()).ok_or("no password stored")?; + auto_pw = Some(pw.to_string()); handle .authenticate_password(host.username.clone(), pw.to_string()) .await .map_err(|e| e.to_string())? - .success() + }; + let authed = match first { + client::AuthResult::Success => true, + client::AuthResult::Failure { remaining_methods, partial_success } => { + let offered = remaining_methods + .iter() + .map(|m| <&str>::from(m)) + .collect::>() + .join(", "); + if remaining_methods.iter().any(|m| matches!(m, MethodKind::KeyboardInteractive)) { + if partial_success { + ctl(sock, "info", "first factor accepted, server asks for more").await; + } else if is_key { + ctl(sock, "info", "public key not accepted, trying keyboard-interactive").await; + } else { + ctl(sock, "info", "password login not accepted, trying keyboard-interactive").await; + } + kbd_auth(&mut handle, &host.username, auto_pw.as_deref(), sock).await? + } else if partial_success { + return Err(format!( + "the server needs further authentication steps this client cannot do (offered: {offered})" + )); + } else { + return Err(format!( + "authentication failed: wrong credentials, or the server does not allow this method (server offers: {})", + if offered.is_empty() { "nothing" } else { &offered } + )); + } + } }; if !authed { - return Err("authentication failed (wrong credentials, or the server does not allow this method)".into()); + return Err("authentication failed (wrong verification code or password?)".into()); } // ---- shell diff --git a/web/app.js b/web/app.js index 8b1ea74..559fda1 100644 --- a/web/app.js +++ b/web/app.js @@ -126,12 +126,13 @@ function renderAuth(mode) { err, btn, ); - $('#app').replaceChildren(h('div', { class: 'login' }, - h('h1', null, 'lab', h('b', null, 'deck'), ' ' + S.version), + $('#app').replaceChildren(h('div', { class: 'authpage' }, h('div', { class: 'login' }, + h('h1', null, 'lab', h('b', null, 'deck')), h('p', null, setup ? 'First start. Choose a master password. It encrypts every SSH password and key stored here, and it cannot be recovered if you lose it.' : 'Enter the master password to unlock the vault.'), - form)); + form)), + h('footer', { class: 'status' }, h('span', { class: 'grow' }), versionTag())); } async function startApp() { @@ -202,6 +203,10 @@ async function loadServices() { async function loadHosts() { S.hosts = await api('GET', '/api/hosts'); } async function loadSnippets() { S.snippets = await api('GET', '/api/snippets'); } +function versionTag() { + return h('span', { class: 'ver', title: 'labdeck ' + S.version }, 'v' + S.version); +} + // ------------------------------------------------------------------ status bar function updateStatus() { const el = $('#status'); @@ -220,6 +225,7 @@ function updateStatus() { n ? h('span', { class: 'hide-s' }, n + ' ssh session' + (n > 1 ? 's' : '')) : null, h('span', { class: 'hide-s' }, 'vault unlocked'), h('span', { id: 'clock' }, new Date().toLocaleTimeString()), + versionTag(), ].filter(Boolean)); const tc = $('#termcount'); if (tc) tc.textContent = S.sessions.length ? String(S.sessions.length) : ''; @@ -770,6 +776,7 @@ async function openSocket(s) { gotCtl = true; const line = (c, txt) => t.write(`\r\n\x1b[${c}m${txt.replace(/\r?\n/g, '\r\n')}\x1b[0m`); if (m.t === 'info') line('2', '# ' + m.msg); + else if (m.t === 'prompt') t.write('\r\n\x1b[33m' + m.msg.replace(/\r?\n/g, '\r\n') + '\x1b[0m'); else if (m.t === 'error') { s.status = 'error'; line('31', m.msg); t.write('\r\n\x1b[2m# press Enter to reconnect\x1b[0m\r\n'); } else if (m.t === 'closed') { s.status = 'closed'; line('2', '# ' + m.msg + ' - press Enter to reconnect'); t.write('\r\n'); } else if (m.t === 'ready') { s.status = 'connected'; t.write('\r\n'); fitSession(s); if (s.ws === ws && ws.readyState === 1) ws.send(JSON.stringify({ t: 'resize', cols: t.cols, rows: t.rows })); } diff --git a/web/style.css b/web/style.css index 0073558..af2eed3 100644 --- a/web/style.css +++ b/web/style.css @@ -82,6 +82,8 @@ footer.status { border-top: 1px solid var(--line); background: var(--bg2); color: var(--dim); white-space: nowrap; overflow: hidden; } footer.status .grow { flex: 1; } +footer.status .ver { color: var(--dim); opacity: 0.8; font-variant-numeric: tabular-nums; padding-left: 14px; border-left: 1px solid var(--line); line-height: 14px; } +.authpage { flex: 1; overflow: auto; } /* ---------- controls ---------- */ .btn {