Version bump to 0.1.1, visual, and term update
This commit is contained in:
1 parent
459e6022bf
commit
7a7ecf08b3
6 files changed
+137
-11
No files matched your search
+1
-1
@@ -1,2 +1,2 @@
|
||||
/target
|
||||
/data
|
||||
/data
|
||||
Generated
+1
-1
@@ -1382,7 +1382,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "labdeck"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"axum",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "labdeck"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
+122
-5
@@ -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
@@ -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 })); }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user