Version bump to 0.1.1, visual, and term update

This commit is contained in:
nke4 committed 2026-10-28 10:48:25 +01:00
1 parent 459e6022bf
commit 7a7ecf08b3
5 files changed
+136 -10

No files matched your search

Generated
+1 -1
View File
@@ -1382,7 +1382,7 @@ dependencies = [
[[package]]
name = "labdeck"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"argon2",
"axum",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "labdeck"
version = "0.1.0"
version = "0.1.1"
edition = "2024"
[dependencies]
+122 -5
View File
@@ -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<Handler>,
user: &str,
auto_pw: Option<&str>,
sock: &mut WebSocket,
) -> Result<bool, String> {
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<String, String> {
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<App>, id: i64, cols: u32, rows: u32, sock: &mut WebSocket) -> Result<String, String> {
let (host, blob) = {
let c = app.db.lock().unwrap();
@@ -159,7 +244,11 @@ async fn session(app: &Arc<App>, 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<String> = 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<App>, 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::<Vec<_>>()
.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
+10 -3
View File
@@ -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 })); }
+2
View File
@@ -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 {