feat(hh): graceful shutdown, crypt default theme, neutralize branding, share-prep
- Graceful shutdown: Ctrl+C quits in chat (interrupts PTY while driving), RAII TermGuard + panic hook + SIGTERM/SIGHUP always restore the terminal - Default theme is now "crypt" (neutral monochrome); theme sigil mirrored in chat/roster/help so the pentagram only renders under the "church" theme - Neutralize inverted-pentagram branding across CLI, scripts, docs, and Cargo metadata (kept only in themes/church.toml + the render-time placeholder) - Rewrite root README around hack-house; add bootstrap.sh, SECURITY.md, CODE_OF_CONDUCT.md, CHANGELOG.md, and issue/PR templates - .gitignore cleanup; stop tracking .venv Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+48
-1
@@ -379,6 +379,27 @@ fn handle_ft(
|
||||
}
|
||||
}
|
||||
|
||||
/// Put the terminal back the way we found it: leave raw mode, leave the
|
||||
/// alternate screen, stop mouse capture, show the cursor. Best-effort — every
|
||||
/// step is independent so one failing (e.g. already-restored) can't strand the
|
||||
/// rest. Safe to call more than once.
|
||||
fn restore_terminal() {
|
||||
let _ = disable_raw_mode();
|
||||
let _ = execute!(std::io::stdout(), LeaveAlternateScreen, DisableMouseCapture);
|
||||
let _ = execute!(std::io::stdout(), crossterm::cursor::Show);
|
||||
}
|
||||
|
||||
/// RAII: restores the terminal on drop. As long as a guard is alive, *any* exit
|
||||
/// from `run` — normal return, `?` error, or a panic unwinding through the
|
||||
/// frame — leaves the user's terminal (or tmux pane) usable instead of stuck in
|
||||
/// raw/alt-screen mode.
|
||||
struct TermGuard;
|
||||
impl Drop for TermGuard {
|
||||
fn drop(&mut self) {
|
||||
restore_terminal();
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme) -> Result<()> {
|
||||
let (tx, mut rx) = unbounded_channel::<Net>();
|
||||
let app_tx = tx.clone();
|
||||
@@ -404,6 +425,24 @@ pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme
|
||||
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
|
||||
let mut term = Terminal::new(CrosstermBackend::new(stdout))?;
|
||||
|
||||
// From here on the terminal is in raw/alt-screen mode. The guard restores it
|
||||
// on every exit path (return, `?`, panic); the panic hook additionally prints
|
||||
// the panic *after* restoring, so a crash leaves a readable message in the
|
||||
// pane instead of garbled raw-mode output.
|
||||
let _term_guard = TermGuard;
|
||||
let default_panic = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(move |info| {
|
||||
restore_terminal();
|
||||
default_panic(info);
|
||||
}));
|
||||
|
||||
// Graceful shutdown on signals: a `kill`, a closed tmux pane (SIGHUP), or a
|
||||
// Ctrl-C delivered while NOT in raw mode all break the loop cleanly so the
|
||||
// guard above can restore the terminal — no more being "booted" with a
|
||||
// broken pane. (In raw mode Ctrl-C arrives as a key event, handled below.)
|
||||
let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
|
||||
let mut sighup = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::hangup())?;
|
||||
|
||||
let mut app = App::new(session.username.clone());
|
||||
app.password = params.password.clone();
|
||||
let mut events = EventStream::new();
|
||||
@@ -444,7 +483,13 @@ pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme
|
||||
match maybe {
|
||||
Some(Ok(Event::Key(k))) if k.kind == KeyEventKind::Press => {
|
||||
app.error = None; // any keypress dismisses the error popup
|
||||
if k.modifiers.contains(KeyModifiers::CONTROL) && matches!(k.code, KeyCode::Char('q')) {
|
||||
// Ctrl-Q always quits. Ctrl-C quits too — *unless* we're
|
||||
// driving the sandbox, where it must reach the PTY as an
|
||||
// interrupt (handled in the `app.driving` branch below).
|
||||
if k.modifiers.contains(KeyModifiers::CONTROL)
|
||||
&& (matches!(k.code, KeyCode::Char('q'))
|
||||
|| (matches!(k.code, KeyCode::Char('c')) && !app.driving))
|
||||
{
|
||||
break Ok(());
|
||||
}
|
||||
if k.modifiers.contains(KeyModifiers::CONTROL)
|
||||
@@ -675,6 +720,8 @@ pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
_ = sigterm.recv() => { break Ok(()); }
|
||||
_ = sighup.recv() => { break Ok(()); }
|
||||
_ = tick.tick() => {}
|
||||
}
|
||||
};
|
||||
|
||||
+14
-14
@@ -1,4 +1,4 @@
|
||||
//! hack-house ⛧ — encrypted collaborative sessions with a summoned sandbox.
|
||||
//! hack-house — encrypted collaborative sessions with a summoned sandbox.
|
||||
//!
|
||||
//! This binary exposes the crypto-parity spike used to prove the Rust client
|
||||
//! speaks the same SRP / Fernet dialect as the Python Sanic server, plus the
|
||||
@@ -21,7 +21,7 @@ const STD: base64::engine::general_purpose::GeneralPurpose =
|
||||
base64::engine::general_purpose::STANDARD;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "hack-house", about = "⛧ encrypted collaborative sessions ⛧")]
|
||||
#[command(name = "hack-house", about = "encrypted collaborative sessions")]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
cmd: Cmd,
|
||||
@@ -147,14 +147,14 @@ fn selftest() -> Result<()> {
|
||||
v
|
||||
});
|
||||
let a = hex::encode(c.a_bytes());
|
||||
println!("⛧ A = {}…", &a[..32]);
|
||||
println!("A = {}…", &a[..32]);
|
||||
let salt = hex::decode("0a1b2c3d")?;
|
||||
let b = hex::decode("047426a55963c70bc385c6a51f6e9dc0bfe5e16b0d1fee4f566fb54b60fa77144f15ed1ee6ade007bd92f2b90846e1ee083ab4290239420606f48a1d861f759543d7856cbce21fd7fec98c9961a66610b412fea2efc5be78f35b18fd48176ac80c3a1cbefacac81e25e7da8079fac4012d01c47d85b783c2ea7340819bfe73d29cd0953d47c8fade77caa5459fb77d88fb918c073a77c495fa884859142a270cb0b1668de06131b150df4dbc931953a381710b7fdb98a953d6f77a4bba847c4c62c15cca8e514dc13f531427966a553c461aa4ab0caec9665612861fef03d48676e5f6551fc8ca4317f3118e0294c949bd2f5821e5900e7f695225dafa0ba2d2")?;
|
||||
let ch = c.process_challenge(&salt, &b)?;
|
||||
let want_m = "6e733ba88eb86c52e3be89207d2815a65b4dea8116f668af5de1b66ce1f047dd";
|
||||
assert_eq!(hex::encode(&ch.m), want_m, "M mismatch vs pysrp");
|
||||
println!("⛧ M matches pysrp golden vector ✓");
|
||||
println!("⛧ selftest passed — Rust SRP ≡ Python srp");
|
||||
println!("M matches pysrp golden vector ✓");
|
||||
println!("selftest passed — Rust SRP ≡ Python srp");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -192,7 +192,7 @@ fn handshake(
|
||||
let b = STD.decode(init["B"].as_str().context("no B")?)?;
|
||||
let salt = STD.decode(init["salt"].as_str().context("no salt")?)?;
|
||||
let room_salt = STD.decode(init["room_salt"].as_str().context("no room_salt")?)?;
|
||||
println!("⛧ /srp/init ok — user_id={}…", &user_id[..8]);
|
||||
println!("/srp/init ok — user_id={}…", &user_id[..8]);
|
||||
|
||||
let ch = client.process_challenge(&salt, &b)?;
|
||||
|
||||
@@ -213,11 +213,11 @@ 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();
|
||||
println!("⛧ /srp/verify ok — server identity proven (H_AMK ✓)");
|
||||
println!("/srp/verify ok — server identity proven (H_AMK ✓)");
|
||||
|
||||
// Room key + encrypt a message the Python clients can read.
|
||||
let fernet = crypto::room_fernet(password.as_bytes(), &room_salt)?;
|
||||
let ct = fernet.encrypt(b"the house is open \xE2\x9B\xA7");
|
||||
let ct = fernet.encrypt(b"the house is open");
|
||||
|
||||
// Connect WS and send the ciphertext.
|
||||
let ws_scheme = if no_tls { "ws" } else { "wss" };
|
||||
@@ -226,18 +226,18 @@ fn handshake(
|
||||
);
|
||||
let (mut sock, _resp) =
|
||||
tungstenite::connect(&ws_url).context("ws connect (insecure wss not yet wired)")?;
|
||||
println!("⛧ websocket attached to the house");
|
||||
println!("websocket attached to the house");
|
||||
|
||||
// First frame is the `init` state snapshot.
|
||||
if let Ok(msg) = sock.read() {
|
||||
if let Ok(txt) = msg.into_text() {
|
||||
let v: serde_json::Value = serde_json::from_str(&txt).unwrap_or_default();
|
||||
println!("⛧ recv: type={}", v["type"].as_str().unwrap_or("?"));
|
||||
println!("recv: type={}", v["type"].as_str().unwrap_or("?"));
|
||||
}
|
||||
}
|
||||
sock.send(tungstenite::Message::Text(ct))?;
|
||||
sock.flush()?;
|
||||
println!("⛧ sent encrypted offering");
|
||||
println!("sent encrypted offering");
|
||||
|
||||
// Read frames until we see our broadcast echo, then decrypt it to prove the
|
||||
// full round-trip (Rust encrypt → server relay → Rust decrypt).
|
||||
@@ -250,19 +250,19 @@ fn handshake(
|
||||
match fernet.decrypt(ct) {
|
||||
Ok(pt) => {
|
||||
println!(
|
||||
"⛧ round-trip ✓ decrypted: {:?}",
|
||||
"round-trip ✓ decrypted: {:?}",
|
||||
String::from_utf8_lossy(&pt)
|
||||
);
|
||||
break;
|
||||
}
|
||||
Err(_) => println!("⛧ [decrypt failed]"),
|
||||
Err(_) => println!("[decrypt failed]"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
println!("⛧ ws read ended: {e}");
|
||||
println!("ws read ended: {e}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
+23
-21
@@ -1,6 +1,6 @@
|
||||
//! Loadable colour/layout themes ("vestments"). Default is the churchofmalware
|
||||
//! occult-monochrome: black ground, white/grey ink, ⛧ accents. Override with a
|
||||
//! TOML file via `--theme <path>`.
|
||||
//! Loadable colour/layout themes ("vestments"). Default is "crypt": occult
|
||||
//! monochrome — white/grey ink on a lifted slate surface, ✝ accents. Switch to
|
||||
//! the neon "church" vestments with `/theme church` or a TOML via `--theme`.
|
||||
|
||||
use anyhow::Context;
|
||||
use ratatui::style::Color;
|
||||
@@ -29,28 +29,30 @@ pub struct Theme {
|
||||
/// Width of the roster column.
|
||||
pub roster_width: u16,
|
||||
/// Glyph flanking the "hack-house" title (and used for occult accents).
|
||||
/// Defaults to ⛧ (inverted pentagram); themes can pick their own sigil.
|
||||
/// Each theme picks its own sigil (crypt: ✝, church: ⛧, …).
|
||||
pub sigil: String,
|
||||
}
|
||||
|
||||
impl Default for Theme {
|
||||
/// "church" — Church of Malware: neon on black. Cyan window-chrome, acid-green
|
||||
/// text/prompts, purple system/occult lines, hot-magenta self/owner accents.
|
||||
/// "crypt" — occult monochrome: white ink on a lifted slate surface. Gray
|
||||
/// window-chrome, readable mid-gray timestamps, muted-gray system lines, a
|
||||
/// ✝ sigil. Neutral by default; switch to the neon "church" vestments with
|
||||
/// `/theme church` or `--theme`.
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
name: "church".into(),
|
||||
border: Color::Rgb(0x19, 0xb3, 0xff), // cyan window chrome
|
||||
title: Color::Rgb(0x7d, 0xf9, 0xff), // bright cyan
|
||||
accent: Color::Rgb(0x39, 0xff, 0x14), // acid green (⛧ glyphs, prompt)
|
||||
dim: Color::Rgb(0x47, 0x5a, 0x7a), // muted slate-blue
|
||||
me: Color::Rgb(0x39, 0xff, 0x14), // your messages = acid green
|
||||
other: Color::Rgb(0x56, 0xc8, 0xff), // others = soft cyan
|
||||
system: Color::Rgb(0xb4, 0x6c, 0xff), // system / occult = purple
|
||||
input: Color::Rgb(0x39, 0xff, 0x14),
|
||||
roster_me: Color::Rgb(0xff, 0x39, 0xc0), // you / owner = hot magenta
|
||||
bg: Color::Reset, // ride the terminal's own black
|
||||
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
|
||||
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 pentagram
|
||||
sigil: "✝".into(), // inverted cross / crypt
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -91,10 +93,10 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_is_church() {
|
||||
fn default_is_crypt() {
|
||||
let t = Theme::default();
|
||||
assert_eq!(t.name, "church");
|
||||
assert_eq!(t.accent, Color::Rgb(0x39, 0xff, 0x14));
|
||||
assert_eq!(t.name, "crypt");
|
||||
assert_eq!(t.accent, Color::Rgb(0xff, 0xff, 0xff));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+11
-7
@@ -129,10 +129,10 @@ fn draw_help(f: &mut Frame, area: Rect, theme: &Theme) {
|
||||
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-Q", "quit hack-house"),
|
||||
kv("Ctrl-C · Ctrl-Q", "quit hack-house"),
|
||||
Line::from(""),
|
||||
head("ROSTER GLYPHS"),
|
||||
kv("⛧ owner ⚡ sudoer", "◆ 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",
|
||||
@@ -207,8 +207,12 @@ fn draw_top(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &Theme
|
||||
|
||||
fn fmt_line<'a>(l: &'a ChatLine, app: &App, theme: &Theme) -> Line<'a> {
|
||||
if l.system {
|
||||
// System lines carry the canonical ⛧ as a placeholder for "the house
|
||||
// sigil"; swap it for the active theme's sigil so e.g. crypt shows ✝,
|
||||
// never a pentagram. User messages (l.system == false) are left as typed.
|
||||
let text = l.text.replace('⛧', &theme.sigil);
|
||||
return Line::from(Span::styled(
|
||||
format!(" ⛧ {}", l.text),
|
||||
format!(" {} {}", theme.sigil, text),
|
||||
Style::default().fg(theme.system).add_modifier(Modifier::ITALIC),
|
||||
));
|
||||
}
|
||||
@@ -261,10 +265,10 @@ fn draw_roster(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &Th
|
||||
.iter()
|
||||
.map(|u| {
|
||||
let me = u.username == app.me;
|
||||
// ⛧ owner · ⚡ sudoer (VM superuser) · ◆ may drive · • member
|
||||
// <sigil> owner · ⚡ sudoer (VM superuser) · ◆ may drive · • member
|
||||
let owner = app.owner.as_deref() == Some(u.username.as_str());
|
||||
let mark = if owner {
|
||||
"⛧"
|
||||
theme.sigil.as_str()
|
||||
} else if app.sudoers.contains(&u.username) {
|
||||
"⚡"
|
||||
} else if app.drivers.contains(&u.username) {
|
||||
@@ -301,8 +305,8 @@ 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 ", o.name),
|
||||
None if app.driving => " ⛧ DRIVING the shell — Esc to release ".to_string(),
|
||||
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),
|
||||
|
||||
Reference in New Issue
Block a user