use std::io::{Read, Write};
use std::os::unix::net::UnixStream;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use anyhow::{Context, Result, bail};
use crate::protocol::{self, ClientMsg, Request, Response, ServerMsg};
use crate::{paths, process};
pub fn ensure_server(session: &str) -> Result<PathBuf> {
paths::private_runtime_dir().context("checking the runtime directory")?;
let socket = paths::socket_path(session);
if UnixStream::connect(&socket).is_ok() {
return Ok(socket);
}
let exe = std::env::current_exe().context("locating the kraal binary")?;
let log = paths::log_path(session);
if let Some(dir) = log.parent() {
paths::ensure_private_dir(dir)?;
}
let out = crate::runbooks::open_private_append(&log)?;
let mut cmd = std::process::Command::new(exe);
cmd.args(["server", "--session", session])
.stdin(std::process::Stdio::null())
.stdout(out.try_clone()?)
.stderr(out)
.current_dir(dirs::home_dir().unwrap_or_else(|| "/".into()));
unsafe {
use std::os::unix::process::CommandExt;
cmd.pre_exec(|| {
libc::setsid();
Ok(())
});
}
let mut child = cmd.spawn().context("starting the kraal server")?;
let start = Instant::now();
while start.elapsed() < Duration::from_secs(5) {
if UnixStream::connect(&socket).is_ok() {
return Ok(socket);
}
if let Ok(Some(status)) = child.try_wait() {
bail!("the server exited ({status}); see {}", log.display());
}
std::thread::sleep(Duration::from_millis(20));
}
bail!("the server did not start in time; see {}", log.display())
}
pub fn control_socket(session: Option<&str>) -> PathBuf {
match session {
Some(s) => paths::socket_path(s),
None => std::env::var_os("KRAAL_SOCKET").map(PathBuf::from).unwrap_or_else(|| paths::socket_path("main")),
}
}
pub fn request(socket: &Path, req: Request) -> Result<Response> {
if socket.starts_with(paths::runtime_dir()) {
paths::private_runtime_dir().context("checking the runtime directory")?;
}
let mut stream = UnixStream::connect(socket)
.with_context(|| format!("no kraal server at {} (is it running?)", socket.display()))?;
exchange(&mut stream, req)
}
fn exchange(stream: &mut UnixStream, req: Request) -> Result<Response> {
protocol::write_json(stream, &ClientMsg::Request { version: protocol::PROTOCOL_VERSION, request: req })?;
loop {
match protocol::read_frame(stream)? {
None => bail!("the server closed the connection"),
Some((protocol::JSON, payload)) => {
let msg: ServerMsg = serde_json::from_slice(&payload)?;
match msg {
ServerMsg::Response { result } => return result.map_err(|e| anyhow::anyhow!(e)),
ServerMsg::Exit { reason } => bail!("{reason}"),
ServerMsg::Open { .. } | ServerMsg::Event { .. } | ServerMsg::Events { .. } => continue,
}
}
Some(_) => continue,
}
}
}
#[derive(Debug, PartialEq)]
pub enum Saved {
Yes,
No(String),
Unknown,
}
pub fn kill_server(socket: &Path) -> Result<Saved> {
let mut stream = UnixStream::connect(socket)
.with_context(|| format!("no kraal server at {} (is it running?)", socket.display()))?;
let (pid, uid) = process::socket_peer(&stream).context("finding the server process")?;
if uid != unsafe { libc::getuid() } {
bail!("{} belongs to another user", socket.display());
}
let saved = match exchange(&mut stream, Request::KillServer) {
Ok(Response::Text(why)) => Saved::No(why),
Ok(_) => Saved::Yes,
Err(_) => {
if unsafe { libc::kill(pid, libc::SIGTERM) } != 0 {
let err = std::io::Error::last_os_error();
if err.raw_os_error() != Some(libc::ESRCH) {
return Err(err).context(format!("stopping the server (pid {pid})"));
}
}
Saved::Unknown
}
};
drop(stream);
let start = Instant::now();
while process::running(pid) {
if start.elapsed() > Duration::from_secs(15) {
bail!("the server (pid {pid}) is still running after 15 s");
}
std::thread::sleep(Duration::from_millis(50));
}
Ok(saved)
}
pub fn subscribe(
socket: &Path,
filters: Vec<String>,
since: Option<(u64, Option<u64>)>,
on_ready: impl FnOnce(),
mut on_event: impl FnMut(&serde_json::Value) -> bool,
) -> Result<()> {
let mut on_ready = Some(on_ready);
let mut stream = UnixStream::connect(socket)
.with_context(|| format!("no kraal server at {} (is it running?)", socket.display()))?;
let request = Request::Subscribe { events: filters, since: since.map(|s| s.0), boot: since.and_then(|s| s.1) };
protocol::write_json(&mut stream, &ClientMsg::Request { version: protocol::PROTOCOL_VERSION, request })?;
loop {
let Some((kind, payload)) = protocol::read_frame(&mut stream)? else { return Ok(()) };
if kind != protocol::JSON {
continue;
}
match serde_json::from_slice::<ServerMsg>(&payload)? {
ServerMsg::Response { result: Err(e) } => bail!(e),
ServerMsg::Response { result: Ok(_) } => {
if let Some(f) = on_ready.take() {
f();
}
}
ServerMsg::Event { event } => {
if !on_event(&event) {
return Ok(());
}
}
ServerMsg::Events { events } => {
for event in &events {
if !on_event(event) {
return Ok(());
}
}
}
ServerMsg::Exit { reason } => bail!("{reason}"),
_ => {}
}
}
}
pub fn request_quick(socket: &Path, req: Request) -> Result<()> {
let mut stream = UnixStream::connect(socket)?;
stream.set_write_timeout(Some(Duration::from_millis(500)))?;
stream.set_read_timeout(Some(Duration::from_millis(1000)))?;
protocol::write_json(&mut stream, &ClientMsg::Request { version: protocol::PROTOCOL_VERSION, request: req })?;
let _ = protocol::read_frame(&mut stream);
Ok(())
}
struct TerminalGuard;
const SETUP_NO_MOUSE: &str = "\x1b[?1049h\x1b[?25l";
const KEYS_ON: &str = "\x1b[?2004h\x1b[?1004h\x1b[>5u";
const KEYS_OFF: &str = "\x1b[<u\x1b[?1004l\x1b[?2004l";
pub(crate) const MOUSE_ON: &str = "\x1b[?1000h\x1b[?1002h\x1b[?1006h";
pub(crate) const MOUSE_OFF: &str = "\x1b[?1006l\x1b[?1003l\x1b[?1002l\x1b[?1000l";
fn set_modes(on: bool, mouse: bool) {
let mut out = std::io::stdout();
let _ = out.write_all(if on { KEYS_ON } else { KEYS_OFF }.as_bytes());
if mouse || !on {
let _ = out.write_all(if on { MOUSE_ON } else { MOUSE_OFF }.as_bytes());
}
let _ = out.flush();
}
fn is_ctrl_c(keys: &[u8]) -> bool {
let mut parser = crate::input::InputParser::new();
let mut events = parser.feed(keys);
events.extend(parser.flush());
let c = crate::input::KeyCode::Char('c');
events.iter().any(|e| {
matches!(e, crate::input::InputEvent::Key(k)
if k.mods.ctrl && !k.mods.alt && (k.code == c || k.by_place().is_some_and(|p| p.code == c)))
})
}
const RESTORE: &str = "\x1b[?2026l\x1b[<u\x1b[?1006l\x1b[?1003l\x1b[?1002l\x1b[?1000l\x1b[?1004l\x1b[?2004l\x1b[0m\x1b[0 q\x1b[?25h\x1b[?1049l";
static LEAVING: AtomicBool = AtomicBool::new(false);
static SSH_PID: AtomicU32 = AtomicU32::new(0);
fn restore_terminal() {
let _ = crossterm::terminal::disable_raw_mode();
let mut out: &[u8] = RESTORE.as_bytes();
while !out.is_empty() {
let n = unsafe { libc::write(1, out.as_ptr().cast(), out.len()) };
if n <= 0 {
break;
}
out = &out[n as usize..];
}
}
impl Drop for TerminalGuard {
fn drop(&mut self) {
let _ = std::io::stdout().flush();
restore_terminal();
}
}
#[derive(Debug, Clone)]
pub enum Target {
Local {
session: String,
},
Remote {
ssh: Vec<String>,
session: String,
label: String,
},
}
struct Conn {
reader: Box<dyn Read + Send>,
writer: Box<dyn Write + Send>,
child: Option<std::process::Child>,
ssh_said: Arc<Mutex<String>>,
}
impl Conn {
fn why_lost(&mut self, what: &str) -> String {
if let Some(child) = &mut self.child {
let start = Instant::now();
while matches!(child.try_wait(), Ok(None)) && start.elapsed() < Duration::from_secs(1) {
std::thread::sleep(Duration::from_millis(20));
}
std::thread::sleep(Duration::from_millis(50));
}
let said = self.ssh_said.lock().map(|s| s.trim().to_string()).unwrap_or_default();
if said.is_empty() { what.to_string() } else { format!("{what}: {said}") }
}
}
impl Drop for Conn {
fn drop(&mut self) {
if let Some(c) = &mut self.child {
let _ = c.kill();
let _ = c.wait();
}
}
}
fn connect(target: &Target) -> Result<Conn> {
match target {
Target::Local { session } => {
let socket = ensure_server(session)?;
let s = UnixStream::connect(&socket)?;
Ok(Conn {
reader: Box::new(s.try_clone()?),
writer: Box::new(s),
child: None,
ssh_said: Default::default(),
})
}
Target::Remote { ssh, session, .. } => {
let remote = crate::shell::sh_c(&format!(
"k=$(command -v kraal || echo \"$HOME/.local/bin/kraal\"); exec \"$k\" proxy -s {session}"
));
let mut child = std::process::Command::new("ssh")
.args(ssh)
.arg(remote)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.context("starting ssh")?;
SSH_PID.store(child.id(), Ordering::SeqCst);
let reader = Box::new(child.stdout.take().context("ssh stdout")?);
let writer = Box::new(child.stdin.take().context("ssh stdin")?);
let ssh_said = match child.stderr.take() {
Some(stderr) => crate::exec::last_line(stderr),
None => Default::default(),
};
Ok(Conn { reader, writer, child: Some(child), ssh_said })
}
}
}
fn status_line(msg: &str) {
let (cols, rows) = crossterm::terminal::size().unwrap_or((80, 24));
let text: String = msg.chars().take((cols as usize).saturating_sub(2)).collect();
let mut out = std::io::stdout();
let _ = write!(out, "\x1b7\x1b[?7l\x1b[{rows};1H\x1b[0m\x1b[41;97m {text}\x1b[K\x1b[0m\x1b[?7h\x1b8");
let _ = out.flush();
}
struct Greeted {
conn: Conn,
frames: std::io::BufReader<Box<dyn Read + Send>>,
first: (u8, Vec<u8>),
}
fn greet(target: &Target, workspace: &Option<String>) -> Result<Greeted, String> {
let mut conn = connect(target).map_err(|e| format!("{e:#}"))?;
let (cols, rows) = crossterm::terminal::size().unwrap_or((80, 24));
let hello = ClientMsg::Attach {
version: protocol::PROTOCOL_VERSION,
cols,
rows,
workspace: workspace.clone(),
remote: matches!(target, Target::Remote { .. }),
};
if protocol::write_json(&mut conn.writer, &hello).is_err() {
return Err(conn.why_lost("could not talk to the server"));
}
let mut frames =
std::io::BufReader::with_capacity(256 * 1024, std::mem::replace(&mut conn.reader, Box::new(std::io::empty())));
match protocol::read_frame(&mut frames) {
Ok(Some(first)) => Ok(Greeted { conn, frames, first }),
Ok(None) => Err(conn.why_lost("the connection closed")),
Err(e) => Err(conn.why_lost(&format!("connection error: {e}"))),
}
}
#[derive(Default)]
struct KeysPause {
paused: AtomicBool,
epoch: AtomicU64,
parked: AtomicU64,
}
impl KeysPause {
fn pause(&self) -> bool {
self.paused.store(true, Ordering::SeqCst);
let epoch = self.epoch.fetch_add(1, Ordering::SeqCst) + 1;
let start = Instant::now();
while self.parked.load(Ordering::SeqCst) != epoch {
if start.elapsed() > Duration::from_secs(5) {
return false;
}
std::thread::sleep(Duration::from_millis(5));
}
true
}
fn resume(&self) {
self.paused.store(false, Ordering::SeqCst);
}
}
fn read_keys(fd: std::os::unix::io::RawFd, pause: &KeysPause, mut forward: impl FnMut(&[u8])) {
let mut buf = [0u8; 16 * 1024];
loop {
if pause.paused.load(Ordering::SeqCst) {
pause.parked.store(pause.epoch.load(Ordering::SeqCst), Ordering::SeqCst);
std::thread::sleep(Duration::from_millis(20));
continue;
}
let mut pfd = libc::pollfd { fd, events: libc::POLLIN, revents: 0 };
let ready = unsafe { libc::poll(&mut pfd, 1, 100) };
if ready < 0 && std::io::Error::last_os_error().kind() != std::io::ErrorKind::Interrupted {
return;
}
if ready <= 0 || pause.paused.load(Ordering::SeqCst) {
continue;
}
let mut waiting: libc::c_int = 0;
if unsafe { libc::ioctl(fd, libc::FIONREAD, &mut waiting) } != 0 {
return;
}
if waiting <= 0 {
if pfd.revents & (libc::POLLHUP | libc::POLLERR | libc::POLLNVAL) != 0 {
return;
}
continue;
}
let want = (waiting as usize).min(buf.len());
let n = unsafe { libc::read(fd, buf.as_mut_ptr().cast(), want) };
match n {
0 => return,
n if n < 0 => {
if std::io::Error::last_os_error().kind() != std::io::ErrorKind::Interrupted {
return;
}
}
n => forward(&buf[..n as usize]),
}
}
}
pub fn attach(target: Target, workspace: Option<String>, mouse: bool) -> Result<()> {
if std::env::var_os("KRAAL_PANE").is_some() {
bail!("already inside kraal (unset KRAAL_PANE to nest anyway)");
}
use std::io::IsTerminal;
if !std::io::stdin().is_terminal() {
bail!("kraal needs a terminal");
}
let first = greet(&target, &workspace).map_err(anyhow::Error::msg)?;
{
use signal_hook::consts::{SIGHUP, SIGINT, SIGQUIT, SIGTERM};
let mut signals = signal_hook::iterator::Signals::new([SIGTERM, SIGHUP, SIGINT, SIGQUIT])?;
std::thread::Builder::new().name("signals".into()).spawn(move || {
if let Some(sig) = signals.forever().next() {
LEAVING.store(true, Ordering::SeqCst);
let pid = SSH_PID.load(Ordering::SeqCst);
if pid != 0 {
unsafe { libc::kill(pid as libc::pid_t, libc::SIGTERM) };
}
unsafe { libc::write(1, b"\x18".as_ptr().cast(), 1) };
restore_terminal();
std::process::exit(128 + sig);
}
})?;
}
crossterm::terminal::enable_raw_mode().context("switching the terminal to raw mode")?;
let guard = TerminalGuard;
{
let mut out = std::io::stdout();
out.write_all(SETUP_NO_MOUSE.as_bytes())?;
out.flush()?;
}
set_modes(true, mouse);
let writer: Arc<Mutex<Option<Box<dyn Write + Send>>>> = Arc::new(Mutex::new(None));
let quit = Arc::new(AtomicBool::new(false));
let pause = Arc::new(KeysPause::default());
{
let writer = writer.clone();
let quit = quit.clone();
let pause = pause.clone();
std::thread::Builder::new().name("stdin".into()).spawn(move || {
read_keys(0, &pause, |keys| {
let Ok(mut w) = writer.lock() else { return };
match w.as_mut() {
Some(w) => {
let _ = protocol::write_frame(w, protocol::BYTES, keys);
}
None if is_ctrl_c(keys) => quit.store(true, Ordering::SeqCst),
None => {}
}
});
})?;
}
{
let writer = writer.clone();
let mut signals = signal_hook::iterator::Signals::new([signal_hook::consts::SIGWINCH])?;
std::thread::Builder::new().name("winch".into()).spawn(move || {
for _ in signals.forever() {
if let Ok((cols, rows)) = crossterm::terminal::size()
&& let Ok(mut w) = writer.lock()
&& let Some(w) = w.as_mut()
{
let _ = protocol::write_json(w, &ClientMsg::Resize { cols, rows });
}
}
})?;
}
let mut greeted = Some(first);
let mut delay = Duration::from_secs(1);
let reason = 'session: loop {
let session = match greeted.take() {
Some(s) => Ok(s),
None => {
if pause.pause() {
let _ = crossterm::terminal::disable_raw_mode();
let greeted = greet(&target, &workspace);
let _ = crossterm::terminal::enable_raw_mode();
if greeted.is_ok() {
set_modes(true, mouse);
}
pause.resume();
greeted
} else {
pause.resume();
Err("the keyboard could not be handed to ssh".to_string())
}
}
};
let lost = match session {
Ok(Greeted { conn: mut c, mut frames, first }) => {
*writer.lock().unwrap() = Some(std::mem::replace(&mut c.writer, Box::new(std::io::sink())));
delay = Duration::from_secs(1);
let mut out = std::io::stdout().lock();
let mut next = Some(Ok(Some(first)));
let lost = loop {
match next.take().unwrap_or_else(|| protocol::read_frame(&mut frames)) {
Ok(Some((protocol::BYTES, bytes))) => {
if LEAVING.load(Ordering::SeqCst) {
break 'session "leaving".to_string();
}
if out.write_all(&bytes).and_then(|_| out.flush()).is_err() {
break 'session "output closed".to_string();
}
}
Ok(Some((_, payload))) => match serde_json::from_slice::<ServerMsg>(&payload) {
Ok(ServerMsg::Exit { reason }) => break 'session reason,
Ok(ServerMsg::Open { url }) => {
let _ = crate::pane::open_url(&url);
}
_ => {}
},
Ok(None) => break c.why_lost("the connection closed"),
Err(e) => break c.why_lost(&format!("connection error: {e}")),
}
};
*writer.lock().unwrap() = None;
lost
}
Err(lost) => lost,
};
let Target::Remote { label, .. } = &target else {
break 'session format!("the server went away ({lost})");
};
set_modes(false, mouse);
let until = Instant::now() + delay;
while Instant::now() < until {
if quit.load(Ordering::SeqCst) {
break 'session format!("gave up reconnecting to {label}");
}
let left = until.saturating_duration_since(Instant::now()).as_secs() + 1;
status_line(&format!(
"kraal: connection to {label} lost ({lost}). Reconnecting in {left}s… ctrl+c to quit"
));
std::thread::sleep(Duration::from_millis(200));
}
status_line(&format!("kraal: reconnecting to {label}… (ssh may ask for a password; ctrl+c to quit)"));
delay = (delay * 2).min(Duration::from_secs(15));
};
drop(guard);
eprintln!("[kraal: {reason}]");
Ok(())
}
pub fn proxy(session: &str) -> Result<()> {
let socket = ensure_server(session)?;
let stream = UnixStream::connect(&socket)?;
let mut to_server = stream.try_clone()?;
let mut from_server = stream;
let up = std::thread::spawn(move || {
let _ = std::io::copy(&mut std::io::stdin().lock(), &mut to_server);
let _ = to_server.shutdown(std::net::Shutdown::Write);
});
let mut out = std::io::stdout().lock();
let mut buf = vec![0u8; 64 * 1024];
loop {
match from_server.read(&mut buf) {
Ok(0) | Err(_) => break,
Ok(n) => {
if out.write_all(&buf[..n]).and_then(|_| out.flush()).is_err() {
break;
}
}
}
}
drop(up);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ctrl_c_is_seen_with_or_without_the_kitty_protocol() {
assert!(is_ctrl_c(b"\x03"));
assert!(is_ctrl_c(b"\x1b[99;5u"));
assert!(is_ctrl_c(b"ab\x1b[99;5:1u"));
assert!(!is_ctrl_c(b"c"));
assert!(!is_ctrl_c(b"\x1b[99;7u"), "ctrl+alt+c is something else");
assert!(is_ctrl_c(b"\x1b[1089::99;5u"));
}
#[test]
fn nothing_typed_after_a_pause_is_read() {
use std::os::unix::io::AsRawFd;
let (keyboard_end, mut typing) = std::os::unix::net::UnixStream::pair().unwrap();
let keyboard = keyboard_end.as_raw_fd();
let mut write = |bytes: &[u8]| typing.write_all(bytes).unwrap();
let pause = Arc::new(KeysPause::default());
let got = Arc::new(Mutex::new(Vec::new()));
let reader = {
let (pause, got) = (pause.clone(), got.clone());
std::thread::spawn(move || read_keys(keyboard, &pause, |k| got.lock().unwrap().extend_from_slice(k)))
};
let mut left_for_ssh = 0;
for round in 0..20 {
write(b"k");
assert!(pause.pause(), "round {round}: the reader did not stop");
let before = got.lock().unwrap().len();
write(b"secret");
std::thread::sleep(Duration::from_millis(150));
assert_eq!(got.lock().unwrap().len(), before, "round {round}: read while paused");
let mut waiting = Vec::new();
while !waiting.ends_with(b"secret") {
let mut buf = [0u8; 64];
let n = unsafe { libc::read(keyboard, buf.as_mut_ptr().cast(), buf.len()) };
assert!(n > 0);
waiting.extend_from_slice(&buf[..n as usize]);
}
left_for_ssh += waiting.len() - b"secret".len();
pause.resume();
}
write(b"after");
let start = Instant::now();
while !got.lock().unwrap().ends_with(b"after") {
assert!(start.elapsed() < Duration::from_secs(5), "{:?}", got.lock().unwrap());
std::thread::sleep(Duration::from_millis(5));
}
let got_keys = got.lock().unwrap().iter().filter(|b| **b == b'k').count();
assert_eq!(got_keys + left_for_ssh, 20);
assert!(!got.lock().unwrap().windows(6).any(|w| w == b"secret"));
drop(typing);
reader.join().unwrap();
drop(keyboard_end);
}
}