Version bump to 0.1.1, visual, and term update
This commit is contained in:
1 parent
459e6022bf
commit
7a7ecf08b3
5 files changed
+136
-10
No files matched your search
Generated
+1
-1
@@ -1382,7 +1382,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "labdeck"
|
name = "labdeck"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"argon2",
|
"argon2",
|
||||||
"axum",
|
"axum",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "labdeck"
|
name = "labdeck"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
+122
-5
@@ -13,8 +13,9 @@ use axum::{
|
|||||||
http::{HeaderMap, StatusCode},
|
http::{HeaderMap, StatusCode},
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
};
|
};
|
||||||
|
use russh::client::KeyboardInteractiveAuthResponse as Kbd;
|
||||||
use russh::keys::{HashAlg, PrivateKeyWithHashAlg, PublicKeyOrCertificate, decode_secret_key};
|
use russh::keys::{HashAlg, PrivateKeyWithHashAlg, PublicKeyOrCertificate, decode_secret_key};
|
||||||
use russh::{ChannelMsg, client};
|
use russh::{ChannelMsg, MethodKind, client};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use std::sync::{
|
use std::sync::{
|
||||||
Arc, Mutex,
|
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> {
|
async fn session(app: &Arc<App>, id: i64, cols: u32, rows: u32, sock: &mut WebSocket) -> Result<String, String> {
|
||||||
let (host, blob) = {
|
let (host, blob) = {
|
||||||
let c = app.db.lock().unwrap();
|
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
|
// ---- 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 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 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}"))?;
|
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))
|
.authenticate_publickey(host.username.clone(), PrivateKeyWithHashAlg::new(Arc::new(pk), hash))
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?
|
.map_err(|e| e.to_string())?
|
||||||
.success()
|
|
||||||
} else {
|
} else {
|
||||||
let pw = secret.get("password").and_then(|v| v.as_str()).ok_or("no password stored")?;
|
let pw = secret.get("password").and_then(|v| v.as_str()).ok_or("no password stored")?;
|
||||||
|
auto_pw = Some(pw.to_string());
|
||||||
handle
|
handle
|
||||||
.authenticate_password(host.username.clone(), pw.to_string())
|
.authenticate_password(host.username.clone(), pw.to_string())
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?
|
.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 {
|
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
|
// ---- shell
|
||||||
|
|||||||
+10
-3
@@ -126,12 +126,13 @@ function renderAuth(mode) {
|
|||||||
err,
|
err,
|
||||||
btn,
|
btn,
|
||||||
);
|
);
|
||||||
$('#app').replaceChildren(h('div', { class: 'login' },
|
$('#app').replaceChildren(h('div', { class: 'authpage' }, h('div', { class: 'login' },
|
||||||
h('h1', null, 'lab', h('b', null, 'deck'), ' ' + S.version),
|
h('h1', null, 'lab', h('b', null, 'deck')),
|
||||||
h('p', null, setup
|
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.'
|
? '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.'),
|
: 'Enter the master password to unlock the vault.'),
|
||||||
form));
|
form)),
|
||||||
|
h('footer', { class: 'status' }, h('span', { class: 'grow' }), versionTag()));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startApp() {
|
async function startApp() {
|
||||||
@@ -202,6 +203,10 @@ async function loadServices() {
|
|||||||
async function loadHosts() { S.hosts = await api('GET', '/api/hosts'); }
|
async function loadHosts() { S.hosts = await api('GET', '/api/hosts'); }
|
||||||
async function loadSnippets() { S.snippets = await api('GET', '/api/snippets'); }
|
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
|
// ------------------------------------------------------------------ status bar
|
||||||
function updateStatus() {
|
function updateStatus() {
|
||||||
const el = $('#status');
|
const el = $('#status');
|
||||||
@@ -220,6 +225,7 @@ function updateStatus() {
|
|||||||
n ? h('span', { class: 'hide-s' }, n + ' ssh session' + (n > 1 ? 's' : '')) : null,
|
n ? h('span', { class: 'hide-s' }, n + ' ssh session' + (n > 1 ? 's' : '')) : null,
|
||||||
h('span', { class: 'hide-s' }, 'vault unlocked'),
|
h('span', { class: 'hide-s' }, 'vault unlocked'),
|
||||||
h('span', { id: 'clock' }, new Date().toLocaleTimeString()),
|
h('span', { id: 'clock' }, new Date().toLocaleTimeString()),
|
||||||
|
versionTag(),
|
||||||
].filter(Boolean));
|
].filter(Boolean));
|
||||||
const tc = $('#termcount');
|
const tc = $('#termcount');
|
||||||
if (tc) tc.textContent = S.sessions.length ? String(S.sessions.length) : '';
|
if (tc) tc.textContent = S.sessions.length ? String(S.sessions.length) : '';
|
||||||
@@ -770,6 +776,7 @@ async function openSocket(s) {
|
|||||||
gotCtl = true;
|
gotCtl = true;
|
||||||
const line = (c, txt) => t.write(`\r\n\x1b[${c}m${txt.replace(/\r?\n/g, '\r\n')}\x1b[0m`);
|
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);
|
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 === '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 === '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 })); }
|
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;
|
border-top: 1px solid var(--line); background: var(--bg2); color: var(--dim); white-space: nowrap; overflow: hidden;
|
||||||
}
|
}
|
||||||
footer.status .grow { flex: 1; }
|
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 ---------- */
|
/* ---------- controls ---------- */
|
||||||
.btn {
|
.btn {
|
||||||
|
|||||||
Reference in new issue
Block a user