ci: proper Rust+Python CI workflow; cargo fmt + clippy clean

Replace the stale Django CI template with a CI workflow that builds and
tests both codebases: cargo fmt/clippy/build/test for the hh client and
pytest across Python 3.10-3.12 for the server. Apply cargo fmt and fix
all clippy lints so the gates pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
leetcrypt 2026-06-01 00:52:20 -07:00
parent cf92b358c4
commit 8eacf4d27b
10 changed files with 498 additions and 175 deletions

42
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,42 @@
name: CI
on:
push:
branches: [main, hack-house]
pull_request:
branches: [main]
jobs:
rust:
name: rust client (hh)
runs-on: ubuntu-latest
defaults:
run:
working-directory: hh
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy, rustfmt
- uses: Swatinem/rust-cache@v2
with:
workspaces: hh
- run: cargo fmt --all --check
- run: cargo clippy --all-targets -- -D warnings
- run: cargo build --verbose
- run: cargo test --verbose
python:
name: python server
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: pip
- run: pip install -r requirements.txt
- run: pytest -q

View File

@ -1,30 +0,0 @@
name: Django CI
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
jobs:
build:
runs-on: ubuntu-latest
strategy:
max-parallel: 4
matrix:
python-version: [3.7, 3.8, 3.9]
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v3
with:
python-version: ${{ matrix.python-version }}
- name: Install Dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Run Tests
run: |
python manage.py test

View File

@ -59,16 +59,37 @@ struct ActiveSend {
/// Decoded events arriving from the websocket reader task.
pub enum Net {
Init { lines: Vec<ChatLine>, users: Vec<User> },
Init {
lines: Vec<ChatLine>,
users: Vec<User>,
},
Message(ChatLine),
Roster { users: Vec<User>, capacity: usize },
Roster {
users: Vec<User>,
capacity: usize,
},
Joined(String),
Left(String),
SbxStatus { backend: String, ready: bool, rows: u16, cols: u16 },
SbxResize { rows: u16, cols: u16 },
SbxStatus {
backend: String,
ready: bool,
rows: u16,
cols: u16,
},
SbxResize {
rows: u16,
cols: u16,
},
SbxData(Vec<u8>),
SbxInput { from: String, bytes: Vec<u8> },
Perm { owner: String, drivers: Vec<String>, sudoers: Vec<String> },
SbxInput {
from: String,
bytes: Vec<u8>,
},
Perm {
owner: String,
drivers: Vec<String>,
sudoers: Vec<String>,
},
Ft(ft::Ft),
Err(String),
Closed,
@ -195,7 +216,12 @@ impl App {
self.sys(format!("{name} left"));
}
}
Net::SbxStatus { backend, ready, rows, cols } => {
Net::SbxStatus {
backend,
ready,
rows,
cols,
} => {
if ready {
self.sandbox = Some(SbxView {
parser: vt100::Parser::new(rows.max(1), cols.max(1), 2000),
@ -223,19 +249,29 @@ impl App {
}
}
Net::SbxInput { .. } => {} // broker enforces + writes in the run loop
Net::Perm { owner, drivers, sudoers } => {
Net::Perm {
owner,
drivers,
sudoers,
} => {
let new: std::collections::HashSet<String> = drivers.into_iter().collect();
let sudo: std::collections::HashSet<String> = sudoers.into_iter().collect();
if !owner.is_empty() && self.owner.as_deref() != Some(owner.as_str()) {
self.sys(format!("{owner} is the superuser (sandbox owner)"));
}
if new.contains(&self.me) && !self.drivers.contains(&self.me) && self.owner.is_some() {
if new.contains(&self.me)
&& !self.drivers.contains(&self.me)
&& self.owner.is_some()
{
self.sys("⛧ you were granted drive (F2 to take the shell)");
} else if !new.contains(&self.me) && self.drivers.contains(&self.me) {
self.driving = false;
self.sys("⛧ your drive permission was revoked");
}
if sudo.contains(&self.me) && !self.sudoers.contains(&self.me) && self.owner.is_some() {
if sudo.contains(&self.me)
&& !self.sudoers.contains(&self.me)
&& self.owner.is_some()
{
self.sys("⛧ you were granted sudo (superuser) in the VM");
}
self.owner = Some(owner).filter(|o| !o.is_empty());
@ -255,7 +291,10 @@ impl App {
fn sbx_dims(term_w: u16, term_h: u16) -> (u16, u16) {
let body_h = term_h.saturating_sub(4);
let sbx_h = (body_h as u32 * 55 / 100) as u16;
(sbx_h.saturating_sub(2).max(1), term_w.saturating_sub(2).max(1))
(
sbx_h.saturating_sub(2).max(1),
term_w.saturating_sub(2).max(1),
)
}
/// One page of sandbox scrollback = the visible grid height (defaults to 10 if
@ -297,17 +336,29 @@ fn send_frame(out: &UnboundedSender<WsMsg>, room: &fernet::Fernet, value: serde_
fn broadcast_acl(out: &UnboundedSender<WsMsg>, room: &fernet::Fernet, app: &App) {
let drivers: Vec<&String> = app.drivers.iter().collect();
let sudoers: Vec<&String> = app.sudoers.iter().collect();
send_frame(out, room, json!({
"_perm":"acl","owner": app.owner, "drivers": drivers, "sudoers": sudoers
}));
send_frame(
out,
room,
json!({
"_perm":"acl","owner": app.owner, "drivers": drivers, "sudoers": sudoers
}),
);
}
/// Stream a payload to the clergy as `_ft` chunks (background, paced).
fn spawn_send(id: String, payload: Arc<Vec<u8>>, out: UnboundedSender<WsMsg>, room: Arc<fernet::Fernet>) {
fn spawn_send(
id: String,
payload: Arc<Vec<u8>>,
out: UnboundedSender<WsMsg>,
room: Arc<fernet::Fernet>,
) {
tokio::spawn(async move {
for (seq, chunk) in payload.chunks(ft::CHUNK).enumerate() {
let frame = json!({"_ft":"chunk","id": id,"seq": seq,"data": STANDARD.encode(chunk)});
if out.send(WsMsg::Text(room.encrypt(frame.to_string().as_bytes()))).is_err() {
if out
.send(WsMsg::Text(room.encrypt(frame.to_string().as_bytes())))
.is_err()
{
return;
}
tokio::time::sleep(Duration::from_millis(2)).await;
@ -331,10 +382,19 @@ fn handle_ft(
}
app.sys(format!(
"⛧ {} offers {} ({}{}) — /accept or /reject",
o.from, o.name, ft::human(o.size as usize),
o.from,
o.name,
ft::human(o.size as usize),
if o.dir { ", directory" } else { "" }
));
app.transfers.insert(o.id.clone(), Transfer { meta: o.clone(), buf: Vec::new(), accepted: false });
app.transfers.insert(
o.id.clone(),
Transfer {
meta: o.clone(),
buf: Vec::new(),
accepted: false,
},
);
app.pending_offer = Some(o);
}
ft::Ft::Accept(id) => {
@ -366,13 +426,22 @@ fn handle_ft(
app.err(format!("{} — SHA-256 mismatch, discarded", t.meta.name));
} else {
match ft::save(downloads, &t.meta, &t.buf) {
Ok(p) => app.sys(format!("⛧ saved {} ({}) — verified ✓", p.display(), ft::human(t.buf.len()))),
Ok(p) => app.sys(format!(
"⛧ saved {} ({}) — verified ✓",
p.display(),
ft::human(t.buf.len())
)),
Err(e) => app.err(format!("save failed: {e}")),
}
}
}
}
if app.pending_offer.as_ref().map(|o| o.id == id).unwrap_or(false) {
if app
.pending_offer
.as_ref()
.map(|o| o.id == id)
.unwrap_or(false)
{
app.pending_offer = None;
}
}
@ -410,7 +479,8 @@ pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme
// All outgoing frames funnel through here so background tasks (file chunks,
// PTY relay) can transmit without owning the socket.
let (out_tx, mut out_rx) = unbounded_channel::<WsMsg>();
let (pty_tx, mut pty_rx): (UnboundedSender<Vec<u8>>, UnboundedReceiver<Vec<u8>>) = unbounded_channel();
let (pty_tx, mut pty_rx): (UnboundedSender<Vec<u8>>, UnboundedReceiver<Vec<u8>>) =
unbounded_channel();
let (broker_tx, mut broker_rx) = unbounded_channel::<BrokerMsg>();
let mut broker: Option<sbx::Sandbox> = None;
let mut broker_meta: Option<(sbx::Backend, String)> = None;
@ -472,7 +542,11 @@ pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme
if let Some(sb) = &broker {
let _ = sb.resize(dims.0, dims.1);
}
send_frame(&out_tx, &session.room, json!({"_sbx":"resize","rows":dims.0,"cols":dims.1}));
send_frame(
&out_tx,
&session.room,
json!({"_sbx":"resize","rows":dims.0,"cols":dims.1}),
);
}
}
}
@ -702,22 +776,19 @@ pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme
}
}
outgoing = out_rx.recv() => {
match outgoing {
Some(first) => {
// Flush a batch to keep file-chunk bursts from redrawing per frame.
let mut batch = vec![first];
while let Ok(m) = out_rx.try_recv() {
batch.push(m);
if batch.len() >= 64 { break; }
}
for m in batch {
if write.send(m).await.is_err() {
app.connected = false;
break;
}
if let Some(first) = outgoing {
// Flush a batch to keep file-chunk bursts from redrawing per frame.
let mut batch = vec![first];
while let Ok(m) = out_rx.try_recv() {
batch.push(m);
if batch.len() >= 64 { break; }
}
for m in batch {
if write.send(m).await.is_err() {
app.connected = false;
break;
}
}
None => {}
}
}
_ = sigterm.recv() => { break Ok(()); }
@ -733,13 +804,23 @@ pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme
}
}
disable_raw_mode()?;
execute!(term.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?;
execute!(
term.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
term.show_cursor()?;
result
}
enum BrokerMsg {
Ready { sb: sbx::Sandbox, backend: sbx::Backend, name: String, rows: u16, cols: u16 },
Ready {
sb: sbx::Sandbox,
backend: sbx::Backend,
name: String,
rows: u16,
cols: u16,
},
Failed,
}
@ -777,7 +858,10 @@ fn handle_command(
// Live vestment switch: `/theme <name>`, or bare `/theme` to list options.
let name = rest.trim();
if name.is_empty() {
app.sys(format!("vestments: {} — /theme <name>", Theme::available().join(" · ")));
app.sys(format!(
"vestments: {} — /theme <name>",
Theme::available().join(" · ")
));
} else {
match Theme::by_name(name) {
Ok(t) => {
@ -800,18 +884,33 @@ fn handle_command(
} else {
app.sys("you don't have drive permission — the owner can /grant you");
}
} else if let Some(path) = line.strip_prefix("/sendd ").or_else(|| line.strip_prefix("/send ")) {
} else if let Some(path) = line
.strip_prefix("/sendd ")
.or_else(|| line.strip_prefix("/send "))
{
let path = path.trim();
match ft::read_payload(path) {
Ok((name, bytes, dir)) => {
*send_seq += 1;
let id = format!("{}-{}", app.me, send_seq);
let (size, sha) = (bytes.len(), ft::sha256_hex(&bytes));
*active_send = Some(ActiveSend { id: id.clone(), payload: Arc::new(bytes), sending: false });
send_frame(out_tx, room, json!({
"_ft":"offer","id": id,"name": name,"size": size,"sha256": sha,"dir": dir
}));
app.sys(format!("offered {} ({}) — waiting for an /accept", name, ft::human(size)));
*active_send = Some(ActiveSend {
id: id.clone(),
payload: Arc::new(bytes),
sending: false,
});
send_frame(
out_tx,
room,
json!({
"_ft":"offer","id": id,"name": name,"size": size,"sha256": sha,"dir": dir
}),
);
app.sys(format!(
"offered {} ({}) — waiting for an /accept",
name,
ft::human(size)
));
}
Err(e) => app.err(format!("send failed: {e}")),
}
@ -843,21 +942,44 @@ fn handle_command(
// `--start` (alias `--start-daemon` / `-y`) opts in to booting
// a stopped Docker daemon; everything else is positional.
let args: Vec<&str> = p.collect();
let start_daemon = args.iter().any(|a| matches!(*a, "--start" | "--start-daemon" | "-y"));
let start_daemon = args
.iter()
.any(|a| matches!(*a, "--start" | "--start-daemon" | "-y"));
let mut pos = args.iter().copied().filter(|a| !a.starts_with('-'));
let backend = pos.next().and_then(sbx::Backend::parse).unwrap_or(sbx::Backend::Local);
let image = pos.next().map(str::to_string).unwrap_or_else(|| backend.default_image().to_string());
let backend = pos
.next()
.and_then(sbx::Backend::parse)
.unwrap_or(sbx::Backend::Local);
let image = pos
.next()
.map(str::to_string)
.unwrap_or_else(|| backend.default_image().to_string());
if backend == sbx::Backend::Docker && !start_daemon && !sbx::docker_daemon_up() {
if backend == sbx::Backend::Docker && !start_daemon && !sbx::docker_daemon_up()
{
app.err("docker daemon is not running — retry with `/sbx launch docker --start` to boot it (sudo), or run ./ensure-docker.sh in a terminal first");
} else {
let sz = term.size().map(|s| (s.width, s.height)).unwrap_or((80, 24));
let (rows, cols) = sbx_dims(sz.0, sz.1);
*launching = true;
let members: Vec<String> = app.users.iter().map(|u| u.username.clone()).collect();
app.sys(format!("summoning {} sandbox… (provisioning unix users; multipass boot ~30s)", backend.label()));
spawn_launch(backend, image, app.me.clone(), members, rows, cols, start_daemon,
pty_tx.clone(), broker_tx.clone(), app_tx.clone());
let members: Vec<String> =
app.users.iter().map(|u| u.username.clone()).collect();
app.sys(format!(
"summoning {} sandbox… (provisioning unix users; multipass boot ~30s)",
backend.label()
));
spawn_launch(
backend,
image,
app.me.clone(),
members,
rows,
cols,
start_daemon,
pty_tx.clone(),
broker_tx.clone(),
app_tx.clone(),
);
}
}
}
@ -975,7 +1097,13 @@ fn spawn_launch(
}
}
});
let _ = broker_tx.send(BrokerMsg::Ready { sb, backend, name, rows, cols });
let _ = broker_tx.send(BrokerMsg::Ready {
sb,
backend,
name,
rows,
cols,
});
}
Err(e) => {
let _ = app_tx.send(Net::Err(format!("sandbox launch failed: {e}")));

View File

@ -133,11 +133,7 @@ impl SrpClient {
/// Process the server challenge (salt, B). Returns (M, K, H_AMK_expected).
/// `M` is sent to the server; `h_amk` is compared to the server's reply.
pub fn process_challenge(
&self,
salt: &[u8],
b_bytes: &[u8],
) -> anyhow::Result<Challenge> {
pub fn process_challenge(&self, salt: &[u8], b_bytes: &[u8]) -> anyhow::Result<Challenge> {
let n = &self.n;
let width = long_to_bytes(n).len();
let big_b = bytes_to_long(b_bytes);
@ -218,7 +214,7 @@ mod tests {
fn a_bytes() -> Vec<u8> {
let mut v = vec![0x80u8];
v.extend(std::iter::repeat(0x22u8).take(31));
v.extend(std::iter::repeat_n(0x22u8, 31));
v
}
@ -245,7 +241,9 @@ mod fernet_interop {
#[test]
fn rust_decrypts_python_fernet() {
let f = fernet::Fernet::new(KEY).unwrap();
let pt = f.decrypt(TOK).expect("rust must decrypt python fernet token");
let pt = f
.decrypt(TOK)
.expect("rust must decrypt python fernet token");
assert_eq!(pt, b"hello from python fernet");
}
}

View File

@ -55,13 +55,24 @@ pub fn read_payload(path: &str) -> Result<(String, Vec<u8>, bool)> {
let meta = std::fs::metadata(p).with_context(|| format!("not found: {path}"))?;
if meta.is_dir() {
let bytes = tar_dir(p)?;
anyhow::ensure!(bytes.len() <= MAX_SIZE, "directory too large ({})", human(bytes.len()));
anyhow::ensure!(
bytes.len() <= MAX_SIZE,
"directory too large ({})",
human(bytes.len())
);
let base = p.file_name().and_then(|s| s.to_str()).unwrap_or("dir");
Ok((format!("{base}.tar"), bytes, true))
} else {
anyhow::ensure!(meta.len() as usize <= MAX_SIZE, "file too large (max 50 MB)");
anyhow::ensure!(
meta.len() as usize <= MAX_SIZE,
"file too large (max 50 MB)"
);
let bytes = std::fs::read(p)?;
let name = p.file_name().and_then(|s| s.to_str()).unwrap_or("file").to_string();
let name = p
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("file")
.to_string();
Ok((name, bytes, false))
}
}
@ -89,17 +100,29 @@ pub fn save(downloads: &Path, offer: &Offer, data: &[u8]) -> Result<PathBuf> {
let mut e = entry?;
let path = e.path()?.into_owned();
// Explicit zip-slip guard (belt-and-suspenders; unpack_in also refuses).
anyhow::ensure!(safe_entry(&path), "unsafe tar entry rejected: {}", path.display());
anyhow::ensure!(
safe_entry(&path),
"unsafe tar entry rejected: {}",
path.display()
);
if top.is_none() {
top = path.components().next().map(|c| c.as_os_str().to_owned());
}
e.unpack_in(downloads)
.with_context(|| format!("extract {}", path.display()))?;
}
Ok(top.map(|t| downloads.join(t)).unwrap_or_else(|| downloads.to_path_buf()))
Ok(top
.map(|t| downloads.join(t))
.unwrap_or_else(|| downloads.to_path_buf()))
} else {
let stem = Path::new(&offer.name).file_stem().and_then(|s| s.to_str()).unwrap_or("file");
let ext = Path::new(&offer.name).extension().and_then(|s| s.to_str()).unwrap_or("");
let stem = Path::new(&offer.name)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("file");
let ext = Path::new(&offer.name)
.extension()
.and_then(|s| s.to_str())
.unwrap_or("");
let dest = unique(downloads, stem, ext);
std::fs::write(&dest, data)?;
Ok(dest)
@ -113,8 +136,16 @@ fn safe_entry(path: &Path) -> bool {
fn unique(dir: &Path, stem: &str, ext: &str) -> PathBuf {
let mk = |n: usize| {
let base = if n == 0 { stem.to_string() } else { format!("{stem}_{n}") };
if ext.is_empty() { dir.join(base) } else { dir.join(format!("{base}.{ext}")) }
let base = if n == 0 {
stem.to_string()
} else {
format!("{stem}_{n}")
};
if ext.is_empty() {
dir.join(base)
} else {
dir.join(format!("{base}.{ext}"))
}
};
(0..).map(mk).find(|p| !p.exists()).unwrap()
}
@ -157,8 +188,14 @@ mod tests {
let (name, bytes, is_dir) = read_payload(src.to_str().unwrap()).unwrap();
assert_eq!(name, "note.txt");
assert!(!is_dir);
let offer = Offer { id: "1".into(), name, size: bytes.len() as u64,
sha256: sha256_hex(&bytes), dir: false, from: "x".into() };
let offer = Offer {
id: "1".into(),
name,
size: bytes.len() as u64,
sha256: sha256_hex(&bytes),
dir: false,
from: "x".into(),
};
let dl = dir.join("dl");
let out = save(&dl, &offer, &bytes).unwrap();
assert_eq!(std::fs::read(&out).unwrap(), b"offering to the clergy");
@ -176,8 +213,14 @@ mod tests {
let (name, bytes, is_dir) = read_payload(proj.to_str().unwrap()).unwrap();
assert_eq!(name, "proj.tar");
assert!(is_dir);
let offer = Offer { id: "1".into(), name, size: bytes.len() as u64,
sha256: sha256_hex(&bytes), dir: true, from: "x".into() };
let offer = Offer {
id: "1".into(),
name,
size: bytes.len() as u64,
sha256: sha256_hex(&bytes),
dir: true,
from: "x".into(),
};
let dl = dir.join("dl");
let out = save(&dl, &offer, &bytes).unwrap(); // -> dl/proj
assert!(out.ends_with("proj"));

View File

@ -45,7 +45,10 @@ enum Cmd {
theme: Option<String>,
},
/// Debug: print the derived room Fernet key for a password + room_salt(hex).
Roomkey { password: String, room_salt_hex: String },
Roomkey {
password: String,
room_salt_hex: String,
},
/// Run the offline SRP golden-vector self-test.
Selftest,
/// Debug: compute A and M from explicit a/salt/B hex (parity check vs python).
@ -98,7 +101,10 @@ fn main() -> Result<()> {
};
tokio::runtime::Runtime::new()?.block_on(app::run(params, session, theme))
}
Cmd::Roomkey { password, room_salt_hex } => {
Cmd::Roomkey {
password,
room_salt_hex,
} => {
let salt = hex::decode(room_salt_hex)?;
let f = crypto::room_fernet(password.as_bytes(), &salt)?;
// fernet crate doesn't expose the key; re-derive + print the b64 key.
@ -143,7 +149,7 @@ fn selftest() -> Result<()> {
// Re-derive the golden vectors at runtime as a smoke check.
let c = crypto::SrpClient::with_a(b"chat", b"labtest", &{
let mut v = vec![0x80u8];
v.extend(std::iter::repeat(0x22u8).take(31));
v.extend(std::iter::repeat_n(0x22u8, 31));
v
});
let a = hex::encode(c.a_bytes());
@ -212,7 +218,10 @@ fn handshake(
let server_hamk = STD.decode(verify["H_AMK"].as_str().context("no H_AMK")?)?;
anyhow::ensure!(server_hamk == ch.h_amk, "server H_AMK mismatch — MITM?");
let ws_token = verify["ws_token"].as_str().context("no ws_token")?.to_string();
let ws_token = verify["ws_token"]
.as_str()
.context("no ws_token")?
.to_string();
println!("/srp/verify ok — server identity proven (H_AMK ✓)");
// Room key + encrypt a message the Python clients can read.
@ -221,9 +230,7 @@ fn handshake(
// Connect WS and send the ciphertext.
let ws_scheme = if no_tls { "ws" } else { "wss" };
let ws_url = format!(
"{ws_scheme}://{ip}:{port}/ws/chat?user_id={user_id}&ws_token={ws_token}"
);
let ws_url = format!("{ws_scheme}://{ip}:{port}/ws/chat?user_id={user_id}&ws_token={ws_token}");
let (mut sock, _resp) =
tungstenite::connect(&ws_url).context("ws connect (insecure wss not yet wired)")?;
println!("websocket attached to the house");

View File

@ -84,13 +84,15 @@ pub fn authenticate(
.json()?;
let server_hamk = STANDARD.decode(verify["H_AMK"].as_str().context("no H_AMK")?)?;
anyhow::ensure!(server_hamk == ch.h_amk, "server identity check failed (H_AMK) — MITM?");
anyhow::ensure!(
server_hamk == ch.h_amk,
"server identity check failed (H_AMK) — MITM?"
);
let ws_token = verify["ws_token"].as_str().context("no ws_token")?;
let fernet = crypto::room_fernet(password.as_bytes(), &room_salt)?;
let ws_scheme = if no_tls { "ws" } else { "wss" };
let ws_url =
format!("{ws_scheme}://{ip}:{port}/ws/chat?user_id={user_id}&ws_token={ws_token}");
let ws_url = format!("{ws_scheme}://{ip}:{port}/ws/chat?user_id={user_id}&ws_token={ws_token}");
Ok(Session {
username: user.to_string(),
@ -160,14 +162,18 @@ fn decode_msg(room: &fernet::Fernet, m: &Value, live: bool) -> Decoded {
// Control frames are live-only — never replayed from the stored snapshot.
if t.starts_with("{\"_sbx\":") {
return if live {
parse_sbx(&t, sender).map(Decoded::Sbx).unwrap_or(Decoded::Skip)
parse_sbx(&t, sender)
.map(Decoded::Sbx)
.unwrap_or(Decoded::Skip)
} else {
Decoded::Skip
};
}
if t.starts_with("{\"_ft\":") {
return if live {
crate::ft::parse(&t, sender).map(|f| Decoded::Sbx(Net::Ft(f))).unwrap_or(Decoded::Skip)
crate::ft::parse(&t, sender)
.map(|f| Decoded::Sbx(Net::Ft(f)))
.unwrap_or(Decoded::Skip)
} else {
Decoded::Skip
};
@ -177,7 +183,11 @@ fn decode_msg(room: &fernet::Fernet, m: &Value, live: bool) -> Decoded {
Err(_) => ("[unreadable — wrong room password?]".to_string(), true),
};
let stamp = m["timestamp"].as_str().unwrap_or("");
let ts = if stamp.len() >= 19 { stamp[11..19].to_string() } else { String::new() };
let ts = if stamp.len() >= 19 {
stamp[11..19].to_string()
} else {
String::new()
};
Decoded::Chat(ChatLine {
ts,
username: m["username"].as_str().unwrap_or("?").to_string(),
@ -232,7 +242,11 @@ fn parse_perm(text: &str) -> Option<Net> {
}
/// Read websocket frames forever, forwarding decoded `Net` events to the UI.
pub async fn reader(mut read: impl StreamExt<Item = Result<WsMsg, tokio_tungstenite::tungstenite::Error>> + Unpin, room: Arc<fernet::Fernet>, tx: UnboundedSender<Net>) {
pub async fn reader(
mut read: impl StreamExt<Item = Result<WsMsg, tokio_tungstenite::tungstenite::Error>> + Unpin,
room: Arc<fernet::Fernet>,
tx: UnboundedSender<Net>,
) {
while let Some(frame) = read.next().await {
let txt = match frame {
Ok(WsMsg::Text(t)) => t,
@ -254,7 +268,10 @@ pub async fn reader(mut read: impl StreamExt<Item = Result<WsMsg, tokio_tungsten
_ => None,
})
.collect();
tx.send(Net::Init { lines, users: parse_users(&v["users"]) })
tx.send(Net::Init {
lines,
users: parse_users(&v["users"]),
})
}
"message" => match decode_msg(&room, &v["data"], true) {
Decoded::Chat(l) => tx.send(Net::Message(l)),
@ -265,7 +282,9 @@ pub async fn reader(mut read: impl StreamExt<Item = Result<WsMsg, tokio_tungsten
users: parse_users(&v["users"]),
capacity: v["capacity"].as_u64().unwrap_or(0) as usize,
}),
"user_joined" => tx.send(Net::Joined(v["username"].as_str().unwrap_or("?").to_string())),
"user_joined" => tx.send(Net::Joined(
v["username"].as_str().unwrap_or("?").to_string(),
)),
"user_left" => tx.send(Net::Left(v["user_id"].as_str().unwrap_or("").to_string())),
_ => Ok(()),
};

View File

@ -36,7 +36,10 @@ fn start_docker_daemon() -> Result<()> {
.context("running ensure-docker.sh")?;
if !out.status.success() {
let err = String::from_utf8_lossy(&out.stderr);
let last = err.lines().last().unwrap_or("could not start the docker daemon");
let last = err
.lines()
.last()
.unwrap_or("could not start the docker daemon");
anyhow::bail!("{last}");
}
Ok(())
@ -93,16 +96,25 @@ pub fn prepare(backend: Backend, name: &str, image: &str, start_daemon: bool) ->
// Capture output so it can't bleed onto the TUI surface; surface
// the failure reason through the returned error instead.
let out = Command::new("multipass")
.args(["launch", "--name", name, "--cpus", "1", "--memory", "1G", "--disk", "5G", image])
.args([
"launch", "--name", name, "--cpus", "1", "--memory", "1G", "--disk", "5G",
image,
])
.output()
.context("multipass launch (is multipass installed?)")?;
if !out.status.success() {
let err = String::from_utf8_lossy(&out.stderr);
anyhow::bail!("multipass launch failed: {}", err.lines().last().unwrap_or("").trim());
anyhow::bail!(
"multipass launch failed: {}",
err.lines().last().unwrap_or("").trim()
);
}
} else {
let _ = Command::new("multipass").args(["start", name])
.stdout(Stdio::null()).stderr(Stdio::null()).status();
let _ = Command::new("multipass")
.args(["start", name])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
}
Ok(())
}
@ -120,17 +132,35 @@ pub fn prepare(backend: Backend, name: &str, image: &str, start_daemon: bool) ->
}
}
// Persistent container so we can exec in to provision users + shells.
let _ = Command::new("docker").args(["rm", "-f", name])
.stdout(Stdio::null()).stderr(Stdio::null()).status();
let _ = Command::new("docker")
.args(["rm", "-f", name])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
// Capture output so a failure can't paint over the TUI; the reason is
// surfaced through the returned error (shown in the error popup).
let out = Command::new("docker")
.args(["run", "-d", "--name", name, "--hostname", name, "-w", "/root", image, "sleep", "infinity"])
.args([
"run",
"-d",
"--name",
name,
"--hostname",
name,
"-w",
"/root",
image,
"sleep",
"infinity",
])
.output()
.context("docker run (is docker installed?)")?;
if !out.status.success() {
let err = String::from_utf8_lossy(&out.stderr);
anyhow::bail!("docker run failed: {}", err.lines().last().unwrap_or("").trim());
anyhow::bail!(
"docker run failed: {}",
err.lines().last().unwrap_or("").trim()
);
}
Ok(())
}
@ -142,12 +172,18 @@ pub fn prepare(backend: Backend, name: &str, image: &str, start_daemon: bool) ->
pub fn teardown(backend: Backend, name: &str) {
match backend {
Backend::Multipass => {
let _ = Command::new("multipass").args(["delete", name, "--purge"])
.stdout(Stdio::null()).stderr(Stdio::null()).status();
let _ = Command::new("multipass")
.args(["delete", name, "--purge"])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
}
Backend::Docker => {
let _ = Command::new("docker").args(["rm", "-f", name])
.stdout(Stdio::null()).stderr(Stdio::null()).status();
let _ = Command::new("docker")
.args(["rm", "-f", name])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
}
Backend::Local => {}
}
@ -163,7 +199,11 @@ fn command_for(backend: Backend, name: &str, run_user: &str) -> CommandBuilder {
c
}
Backend::Docker => {
let user = if run_user.is_empty() { "root" } else { run_user };
let user = if run_user.is_empty() {
"root"
} else {
run_user
};
let mut c = CommandBuilder::new("docker");
c.args(["exec", "-it", "-u", user, name, "bash", "-il"]);
c
@ -196,14 +236,20 @@ fn mp(name: &str, args: &[&str]) {
let mut a = vec!["exec", name, "--"];
a.extend_from_slice(args);
// Null stdio so provisioning chatter never bleeds onto the TUI surface.
let _ = Command::new("multipass").args(a)
.stdout(Stdio::null()).stderr(Stdio::null()).status();
let _ = Command::new("multipass")
.args(a)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
}
fn dk(name: &str, args: &[&str]) {
let mut a = vec!["exec", name];
a.extend_from_slice(args);
let _ = Command::new("docker").args(a)
.stdout(Stdio::null()).stderr(Stdio::null()).status();
let _ = Command::new("docker")
.args(a)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
}
/// Grant a Multipass user real *passwordless* sudo (group + sudoers.d drop-in)
@ -362,8 +408,8 @@ mod tests {
#[test]
fn local_shell_pty_roundtrip() {
let (tx, rx) = mpsc::channel();
let mut sb = Sandbox::launch(Backend::Local, "test", "", 24, 80, tx)
.expect("launch local shell");
let mut sb =
Sandbox::launch(Backend::Local, "test", "", 24, 80, tx).expect("launch local shell");
sb.write_input(b"echo HELLO_PTY_42\n").unwrap();
let mut acc = String::new();
@ -401,8 +447,7 @@ mod relay_tests {
use base64::Engine;
let (tx, rx) = mpsc::channel();
let mut sb = Sandbox::launch(Backend::Local, "test", "", 24, 80, tx)
.expect("launch");
let mut sb = Sandbox::launch(Backend::Local, "test", "", 24, 80, tx).expect("launch");
sb.write_input(b"echo RELAY_MARKER_7\n").unwrap();
// Remote client side: a vt100 parser fed from decoded data frames.

View File

@ -41,18 +41,18 @@ impl Default for Theme {
fn default() -> Self {
Self {
name: "crypt".into(),
border: Color::Rgb(0x6e, 0x6e, 0x7a), // gray chrome, defined against the surface
title: Color::Rgb(0xff, 0xff, 0xff), // white
accent: Color::Rgb(0xff, 0xff, 0xff), // white (sigil / prompt)
dim: Color::Rgb(0x9a, 0x9a, 0xa6), // timestamps — readable mid-gray
me: Color::Rgb(0xff, 0xff, 0xff), // your messages = white
other: Color::Rgb(0xc8, 0xc8, 0xd0), // others = bright gray
system: Color::Rgb(0xb0, 0xb0, 0xbc), // system / occult = legible muted gray
border: Color::Rgb(0x6e, 0x6e, 0x7a), // gray chrome, defined against the surface
title: Color::Rgb(0xff, 0xff, 0xff), // white
accent: Color::Rgb(0xff, 0xff, 0xff), // white (sigil / prompt)
dim: Color::Rgb(0x9a, 0x9a, 0xa6), // timestamps — readable mid-gray
me: Color::Rgb(0xff, 0xff, 0xff), // your messages = white
other: Color::Rgb(0xc8, 0xc8, 0xd0), // others = bright gray
system: Color::Rgb(0xb0, 0xb0, 0xbc), // system / occult = legible muted gray
input: Color::Rgb(0xff, 0xff, 0xff),
roster_me: Color::Rgb(0xff, 0xff, 0xff), // you / owner = white
bg: Color::Rgb(0x1c, 0x1c, 0x22), // slate panel lifts text off pure-black
roster_width: 22,
sigil: "".into(), // inverted cross / crypt
sigil: "".into(), // inverted cross / crypt
}
}
}

View File

@ -12,7 +12,10 @@ pub fn draw(f: &mut Frame, app: &App, theme: &Theme) {
// Paint the whole frame in the theme background first so every panel (and the
// gaps between them) sits on the same surface. With bg = Reset this is a no-op
// and we ride the terminal's own colour.
f.render_widget(Block::default().style(Style::default().bg(theme.bg)), f.area());
f.render_widget(
Block::default().style(Style::default().bg(theme.bg)),
f.area(),
);
let rows = Layout::vertical([
Constraint::Length(1),
@ -53,22 +56,33 @@ pub fn draw(f: &mut Frame, app: &App, theme: &Theme) {
/// onto the input box. Cleared by the next keypress (see the run loop).
fn draw_error(f: &mut Frame, area: Rect, theme: &Theme, msg: &str) {
let text = format!("{msg}");
let w = area.width.saturating_sub(2).min(48).max(16);
let w = area.width.saturating_sub(2).clamp(16, 48);
let inner = w.saturating_sub(2).max(1);
let rows = (text.chars().count() as u16).div_ceil(inner) + 2;
let h = rows.min(area.height.saturating_sub(2)).max(3);
let x = area.x + area.width.saturating_sub(w + 1); // hug the right edge
let y = area.y + 1; // just under the top bar, over the clergy
let rect = Rect { x, y, width: w, height: h };
let rect = Rect {
x,
y,
width: w,
height: h,
};
f.render_widget(Clear, rect);
let popup = Paragraph::new(text)
.style(Style::default().fg(theme.title).bg(theme.bg))
.block(
Block::bordered()
.border_style(Style::default().fg(theme.accent).add_modifier(Modifier::BOLD))
.border_style(
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD),
)
.title(Span::styled(
format!(" {} error · any key ", theme.sigil),
Style::default().fg(theme.accent).add_modifier(Modifier::BOLD),
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD),
)),
)
.wrap(Wrap { trim: false });
@ -93,7 +107,9 @@ fn centered(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
}
fn draw_help(f: &mut Frame, area: Rect, theme: &Theme) {
let acc = Style::default().fg(theme.accent).add_modifier(Modifier::BOLD);
let acc = Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD);
let key = Style::default().fg(theme.title);
let dim = Style::default().fg(theme.system);
let kv = |k: &str, v: &str| {
@ -106,17 +122,35 @@ fn draw_help(f: &mut Frame, area: Rect, theme: &Theme) {
let head = |s: &str| Line::from(Span::styled(format!("{sig} {s}"), acc));
let lines = vec![
head("COMMANDS (type in the input bar)"),
kv("/sbx launch [backend]", "summon a sandbox: local | docker | multipass"),
kv(
"/sbx launch [backend]",
"summon a sandbox: local | docker | multipass",
),
kv("/sbx stop", "tear down the sandbox (purges the VM)"),
kv("/drive", "type into the shared shell (Esc releases)"),
kv("/grant <user>", "let a member drive the shell (owner)"),
kv("/revoke <user>", "take back drive permission (owner)"),
kv("/sudo <user>", "delegate VM superuser (real sudo) (owner)"),
kv("/unsudo <user>", "revoke VM superuser (owner)"),
kv(
"/grant <user>",
"let a member drive the shell (owner)",
),
kv(
"/revoke <user>",
"take back drive permission (owner)",
),
kv(
"/sudo <user>",
"delegate VM superuser (real sudo) (owner)",
),
kv(
"/unsudo <user>",
"revoke VM superuser (owner)",
),
kv("/send <file>", "offer a file to the room"),
kv("/sendd <dir>", "offer a directory (sent as a tar)"),
kv("/accept · /reject", "respond to an incoming file offer"),
kv("/theme [name]", "change vestments live: church | neon | crypt"),
kv(
"/theme [name]",
"change vestments live: church | neon | crypt",
),
kv("/pw", "show this room's password (local only)"),
kv("/help", "show / hide this menu"),
Line::from(""),
@ -126,17 +160,31 @@ fn draw_help(f: &mut Frame, area: Rect, theme: &Theme) {
kv("F2 · /drive", "take the shell · Esc releases it"),
kv("Ctrl-C (while driving)", "interrupt the running command"),
kv("PgUp / PgDn", "scroll chat · Home/End = oldest/live"),
kv("PgUp / PgDn (driving)", "scroll the sandbox terminal's scrollback"),
kv("Up / Down · wheel", "scroll the sandbox terminal (mouse works while driving)"),
kv("Ctrl-R (when closed)", "reconnect to the house after a drop / AFK"),
kv(
"PgUp / PgDn (driving)",
"scroll the sandbox terminal's scrollback",
),
kv(
"Up / Down · wheel",
"scroll the sandbox terminal (mouse works while driving)",
),
kv(
"Ctrl-R (when closed)",
"reconnect to the house after a drop / AFK",
),
kv("Ctrl-C · Ctrl-Q", "quit hack-house"),
Line::from(""),
head("ROSTER GLYPHS"),
kv(&format!("{} owner ⚡ sudoer", theme.sigil), "◆ may drive • member"),
kv(
&format!("{} owner ⚡ sudoer", theme.sigil),
"◆ may drive • member",
),
Line::from(""),
Line::from(Span::styled(
" malware bless · press any key to close",
Style::default().fg(theme.dim).add_modifier(Modifier::ITALIC),
Style::default()
.fg(theme.dim)
.add_modifier(Modifier::ITALIC),
)),
];
let w = centered(78, 90, area);
@ -148,7 +196,9 @@ fn draw_help(f: &mut Frame, area: Rect, theme: &Theme) {
.border_style(Style::default().fg(theme.accent))
.title(Span::styled(
format!(" {0} hack-house — help {0} ", theme.sigil),
Style::default().fg(theme.title).add_modifier(Modifier::BOLD),
Style::default()
.fg(theme.title)
.add_modifier(Modifier::BOLD),
)),
)
.wrap(Wrap { trim: false });
@ -173,7 +223,11 @@ fn draw_sandbox(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &T
" · /drive (or F2) · ↑/↓/wheel scroll".to_string()
};
let title = format!(" sandbox · {}{} ", sv.backend, drive);
let border = if app.driving { theme.accent } else { theme.border };
let border = if app.driving {
theme.accent
} else {
theme.border
};
let pane = Paragraph::new(lines).block(
Block::bordered()
.border_style(Style::default().fg(border))
@ -183,7 +237,11 @@ fn draw_sandbox(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &T
}
fn draw_top(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &Theme) {
let cap = if app.capacity > 0 { app.capacity } else { app.users.len() };
let cap = if app.capacity > 0 {
app.capacity
} else {
app.users.len()
};
let status = if app.connected {
"🔒 e2e"
} else if app.reconnecting {
@ -194,7 +252,9 @@ fn draw_top(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &Theme
let bar = Line::from(vec![
Span::styled(
format!(" {0} hack-house {0} ", theme.sigil),
Style::default().fg(theme.accent).add_modifier(Modifier::BOLD),
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD),
),
Span::styled(format!("· {status} "), Style::default().fg(theme.dim)),
Span::styled(
@ -213,10 +273,16 @@ fn fmt_line<'a>(l: &'a ChatLine, app: &App, theme: &Theme) -> Line<'a> {
let text = l.text.replace('⛧', &theme.sigil);
return Line::from(Span::styled(
format!(" {} {}", theme.sigil, text),
Style::default().fg(theme.system).add_modifier(Modifier::ITALIC),
Style::default()
.fg(theme.system)
.add_modifier(Modifier::ITALIC),
));
}
let name_color = if l.username == app.me { theme.me } else { theme.other };
let name_color = if l.username == app.me {
theme.me
} else {
theme.other
};
Line::from(vec![
Span::styled(format!("{} ", l.ts), Style::default().fg(theme.dim)),
Span::styled(
@ -305,8 +371,13 @@ fn draw_input(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &The
}))
.title(Span::styled(
match &app.pending_offer {
Some(o) => format!(" {} incoming: {} — /accept or /reject ", theme.sigil, o.name),
None if app.driving => format!(" {} DRIVING the shell — Esc to release ", theme.sigil),
Some(o) => format!(
" {} incoming: {} — /accept or /reject ",
theme.sigil, o.name
),
None if app.driving => {
format!(" {} DRIVING the shell — Esc to release ", theme.sigil)
}
None => " message · enter send · /drive for shell · ctrl-q quit ".to_string(),
},
Style::default().fg(theme.title),