diff --git a/e2e/test_cli_serve.py b/e2e/test_cli_serve.py new file mode 100644 index 0000000..67d555d --- /dev/null +++ b/e2e/test_cli_serve.py @@ -0,0 +1,394 @@ +""" +E2E Tests for Shellshare CLI - Serve Mode + +Tests for the `shellshare serve` command, which starts a local server +and broadcasts the terminal to it in a single command. + +Each test starts its own `shellshare serve` on a unique port, so these +tests are independent from the main server running on port 3000. +""" + +import socket +import subprocess +import time +import urllib.request + +import pytest + +from conftest import ( + CLI_COMMAND, + SocketListener, + wait_for_content, + wait_for_server, +) + + +def get_free_port(): + """Find a free TCP port to use for testing.""" + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("", 0)) + return s.getsockname()[1] + + +def run_serve_stdin(message, port, extra_args=None, timeout=10): + """ + Run `shellshare serve --stdin` with the given message piped to stdin. + + Returns (returncode, stdout, stderr). + """ + args = CLI_COMMAND + ["serve", "--stdin", "--port", str(port)] + if extra_args: + args.extend(extra_args) + + proc = subprocess.Popen( + args, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + + try: + stdout, stderr = proc.communicate(input=message, timeout=timeout) + except subprocess.TimeoutExpired: + proc.kill() + stdout, stderr = proc.communicate() + + return proc.returncode, stdout, stderr + + +def start_serve(port, extra_args=None, stdin_mode=False): + """ + Start `shellshare serve` on the given port. + + Returns the subprocess.Popen handle. + """ + args = CLI_COMMAND + ["serve", "--port", str(port)] + if stdin_mode: + args.append("--stdin") + if extra_args: + args.extend(extra_args) + + return subprocess.Popen( + args, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + + +def stop_serve(proc, send_exit=False, timeout=15): + """ + Stop a `shellshare serve` process and return (stdout, stderr). + + If send_exit is True, sends 'exit' to stdin first for a clean exit. + Otherwise terminates the process. + """ + try: + if send_exit and proc.stdin: + proc.stdin.write("exit\n") + proc.stdin.flush() + stdout, stderr = proc.communicate(timeout=timeout) + else: + proc.terminate() + stdout, stderr = proc.communicate(timeout=timeout) + except subprocess.TimeoutExpired: + proc.kill() + stdout, stderr = proc.communicate() + return stdout, stderr + + +class TestServeStdinBasic: + """Basic tests for serve --stdin mode.""" + + def test_serve_stdin_message_received(self): + """Messages piped to serve --stdin should be received by viewers.""" + port = get_free_port() + server_url = f"http://localhost:{port}" + + proc = start_serve(port, stdin_mode=True) + + try: + wait_for_server(server_url, timeout_seconds=15) + + listener = SocketListener("terminal", server_url=server_url) + listener.connect() + + # Send a message to stdin + proc.stdin.write("hello from serve\n") + proc.stdin.flush() + + # Verify it was received + msg = listener.wait_for_message(timeout=5, containing="hello from serve") + assert msg is not None, "Message not received via Socket.IO" + assert "hello from serve" in msg + + listener.disconnect() + finally: + stop_serve(proc) + + def test_serve_stdin_prints_url_to_stderr(self): + """Serve --stdin should print the base sharing URL to stderr.""" + port = get_free_port() + returncode, stdout, stderr = run_serve_stdin("test\n", port) + + assert returncode == 0 + assert "Sharing terminal in" in stderr + assert str(port) in stderr + # URL should be the base URL (no /r/terminal suffix) + assert f"http://localhost:{port}" in stderr + assert "/r/terminal" not in stderr + + def test_serve_stdin_prints_end_of_transmission(self): + """Serve --stdin should print end message on clean exit.""" + port = get_free_port() + returncode, stdout, stderr = run_serve_stdin("done\n", port) + + assert returncode == 0 + assert "End of transmission." in stderr + + def test_serve_stdin_custom_port(self): + """Serve should listen on the specified port.""" + port = get_free_port() + server_url = f"http://localhost:{port}" + + proc = start_serve(port, stdin_mode=True) + + try: + wait_for_server(server_url, timeout_seconds=15) + + response = urllib.request.urlopen(server_url, timeout=5) + assert response.status == 200 + finally: + stop_serve(proc) + + def test_serve_room_page_accessible(self): + """The /r/terminal room page should still be accessible.""" + port = get_free_port() + server_url = f"http://localhost:{port}" + + proc = start_serve(port, stdin_mode=True) + + try: + wait_for_server(server_url, timeout_seconds=15) + + response = urllib.request.urlopen( + f"{server_url}/r/terminal", timeout=5 + ) + assert response.status == 200 + finally: + stop_serve(proc) + + def test_serve_root_shows_terminal(self): + """GET / should serve the room viewer page, not the home page.""" + port = get_free_port() + server_url = f"http://localhost:{port}" + + proc = start_serve(port, stdin_mode=True) + + try: + wait_for_server(server_url, timeout_seconds=15) + + response = urllib.request.urlopen(server_url, timeout=5) + assert response.status == 200 + content = response.read().decode() + # Should contain the room config with /r/terminal + assert "/r/terminal" in content + # Should contain the terminal container (room page, not home page) + assert 'id="terminal"' in content + finally: + stop_serve(proc) + + +class TestServeStdinStreaming: + """Tests for message streaming via serve --stdin.""" + + def test_multiple_messages_received_in_order(self): + """Multiple messages should be received in order.""" + port = get_free_port() + server_url = f"http://localhost:{port}" + + proc = start_serve(port, stdin_mode=True) + + try: + wait_for_server(server_url, timeout_seconds=15) + + listener = SocketListener("terminal", server_url=server_url) + listener.connect() + + proc.stdin.write("FIRST\n") + proc.stdin.flush() + proc.stdin.write("SECOND\n") + proc.stdin.flush() + + assert wait_for_content( + listener, lambda s: "FIRST" in s and "SECOND" in s, timeout=5 + ), "Both messages not received" + + accumulated = listener.get_accumulated_messages() + first_idx = accumulated.index("FIRST") + second_idx = accumulated.index("SECOND") + assert first_idx < second_idx, "Messages received out of order" + + listener.disconnect() + finally: + stop_serve(proc) + + def test_late_joiner_sees_history(self): + """A viewer joining after messages were sent should see history.""" + port = get_free_port() + server_url = f"http://localhost:{port}" + + proc = start_serve(port, stdin_mode=True) + + try: + wait_for_server(server_url, timeout_seconds=15) + + # Send message before any viewer connects + proc.stdin.write("early-message\n") + proc.stdin.flush() + time.sleep(1) + + # Late joiner connects + listener = SocketListener("terminal", server_url=server_url) + listener.connect() + + msg = listener.wait_for_message(timeout=5, containing="early-message") + assert msg is not None, "Late joiner did not receive history" + + listener.disconnect() + finally: + stop_serve(proc) + + +class TestServeTerminalSize: + """Tests for terminal size handling in serve mode.""" + + def test_size_event_sent(self): + """Serve --stdin should send terminal size events.""" + port = get_free_port() + server_url = f"http://localhost:{port}" + + proc = start_serve(port, stdin_mode=True) + + try: + wait_for_server(server_url, timeout_seconds=15) + + listener = SocketListener("terminal", server_url=server_url) + listener.connect() + + # Send a message to trigger a POST with size + proc.stdin.write("size-test\n") + proc.stdin.flush() + + # Wait for message first (size is sent with each POST) + listener.wait_for_message(timeout=5, containing="size-test") + + assert listener.wait_for_size(timeout=5), "No size event received" + + size = listener.get_last_size() + assert size is not None + assert "cols" in size + assert "rows" in size + + listener.disconnect() + finally: + stop_serve(proc) + + +class TestServeCleanExit: + """Tests for serve mode clean exit.""" + + def test_serve_stdin_exits_on_eof(self): + """Serve --stdin should exit cleanly when stdin closes.""" + port = get_free_port() + returncode, stdout, stderr = run_serve_stdin("goodbye\n", port) + + assert returncode == 0 + assert "End of transmission." in stderr + + def test_serve_server_stops_after_exit(self): + """Server should stop accepting connections after serve exits.""" + port = get_free_port() + server_url = f"http://localhost:{port}" + + returncode, stdout, stderr = run_serve_stdin("done\n", port) + assert returncode == 0 + + # Give OS time to release the socket + time.sleep(1) + + # Server should no longer be accessible + with pytest.raises(Exception): + urllib.request.urlopen(server_url, timeout=2) + + def test_serve_sends_delete_on_exit(self): + """Room should be cleaned up (DELETE sent) on exit.""" + port = get_free_port() + server_url = f"http://localhost:{port}" + + proc = start_serve(port, stdin_mode=True) + + try: + wait_for_server(server_url, timeout_seconds=15) + + listener = SocketListener("terminal", server_url=server_url) + listener.connect() + + proc.stdin.write("cleanup-test\n") + proc.stdin.flush() + + listener.wait_for_message(timeout=5, containing="cleanup-test") + listener.disconnect() + finally: + stop_serve(proc) + + # After exit, a new serve on the same port should work + # (room was cleaned up, not locked by stale password) + port2 = get_free_port() + returncode, stdout, stderr = run_serve_stdin("new-session\n", port2) + assert returncode == 0 + + +class TestServeScriptMode: + """Tests for serve in default (PTY) mode.""" + + def test_serve_script_starts_and_streams(self): + """Serve mode (PTY) should start a server and stream output.""" + port = get_free_port() + server_url = f"http://localhost:{port}" + proc = start_serve(port) + + try: + wait_for_server(server_url, timeout_seconds=15) + + listener = SocketListener("terminal", server_url=server_url) + listener.connect() + + # PTY should produce output (shell prompt, etc.) + assert wait_for_content(listener, lambda s: len(s) > 0, timeout=10), \ + "No output received from serve mode PTY" + + listener.disconnect() + finally: + stop_serve(proc) + + def test_serve_script_prints_sharing_url(self): + """Serve mode (PTY) should print base sharing URL to stdout.""" + port = get_free_port() + proc = start_serve(port) + + try: + wait_for_server(f"http://localhost:{port}", timeout_seconds=15) + time.sleep(1) + + stdout, stderr = stop_serve(proc, send_exit=True) + output = stdout + stderr + + assert "Sharing terminal in" in output + assert str(port) in output + except subprocess.TimeoutExpired: + proc.kill() + proc.communicate() + pytest.fail("Serve process timed out") diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 1ee3aad..41fad11 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -6,6 +6,7 @@ mod encoding; mod http; mod script; +pub mod serve; mod terminal; use std::io::{self, Read}; @@ -87,39 +88,40 @@ pub fn run(args: ClientArgs) -> Result<(), Box> { let _ = client_for_cleanup.delete_room(); })?; - if args.stdin { - // Stdin mode - print to stderr - eprintln!("Sharing terminal in {server}/{room_path}"); + let url = format!("{server}/{room_path}"); + stream_and_cleanup(&client, &running, args.stdin, &url)?; - // Read from stdin and stream to server - stream_stdin(&client, &running)?; + Ok(()) +} - // Cleanup +/// Run the appropriate streaming mode (stdin or PTY), then clean up the room. +pub fn stream_and_cleanup( + client: &http::Client, + running: &Arc, + stdin_mode: bool, + sharing_url: &str, +) -> Result<(), Box> { + if stdin_mode { + eprintln!("Sharing terminal in {sharing_url}"); + stream_stdin(client, running)?; let _ = client.delete_room(); eprintln!("End of transmission."); } else { - // Script mode - print to stdout let size = terminal::get_terminal_size(); if size.rows > 30 || size.cols > 160 { println!("Current terminal size is {}x{}.", size.rows, size.cols); println!("It's too big to be viewed on smaller screens."); println!("You can resize it anytime."); } - - println!("Sharing terminal in {server}/{room_path}"); - - // Run script mode with PTY - script::run_script_mode(&client, &running)?; - - // Cleanup + println!("Sharing terminal in {sharing_url}"); + script::run_script_mode(client, running)?; let _ = client.delete_room(); println!("End of transmission."); } - Ok(()) } -/// Stream stdin to the server (for testing) +/// Stream stdin to the server fn stream_stdin( client: &http::Client, running: &Arc, diff --git a/src/cli/serve.rs b/src/cli/serve.rs new file mode 100644 index 0000000..6213611 --- /dev/null +++ b/src/cli/serve.rs @@ -0,0 +1,159 @@ +//! Serve mode - combined server and client in a single command +//! +//! Starts a local shellshare server and immediately connects to it, +//! providing a single command to share your terminal via a local URL. + +use super::http; + +use std::net::TcpStream; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use rand::Rng; + +/// Configuration for the serve command +pub struct ServeArgs { + pub host: String, + pub port: u16, + pub stdin: bool, +} + +/// Fixed room name for serve mode (only one room needed) +const ROOM_NAME: &str = "terminal"; + +/// Run shellshare in serve mode: start a local server and stream to it +pub fn run(args: &ServeArgs) -> Result<(), Box> { + let runtime = tokio::runtime::Runtime::new()?; + + // Spawn the server in the background + let server_config = crate::server::ServerConfig { + host: args.host.clone(), + port: args.port, + cleanup_interval_secs: 3600, + room_ttl_secs: 21600, + serve_room: Some(ROOM_NAME.to_string()), + }; + runtime.spawn(async move { + if let Err(e) = crate::server::run(&server_config).await { + eprintln!("Server error: {e}"); + } + }); + + // Wait for the server to accept connections + let connect_host = connectable_host(&args.host); + wait_for_server(connect_host, args.port)?; + + // Build URLs + let display_host = display_host(&args.host); + let base_url = format!("http://{display_host}:{}", args.port); + let server_url = format!("http://{connect_host}:{}", args.port); + + // Create HTTP client pointing to the local server + let room_path = format!("r/{ROOM_NAME}"); + let password = generate_internal_password(); + let client = http::Client::new(&server_url, &room_path, &password)?; + + // Setup Ctrl+C handler for cleanup + let running = Arc::new(AtomicBool::new(true)); + let running_clone = running.clone(); + let client_for_cleanup = client.clone(); + + ctrlc::set_handler(move || { + running_clone.store(false, Ordering::SeqCst); + let _ = client_for_cleanup.delete_room(); + })?; + + // Stream (reuses the shared stdin/script logic from cli module) + let result = super::stream_and_cleanup(&client, &running, args.stdin, &base_url); + + // Always shut down the runtime, even on error + runtime.shutdown_background(); + + result +} + +/// Generate a random password for internal client-server auth. +fn generate_internal_password() -> String { + rand::thread_rng().gen::().to_string() +} + +/// Get a connectable host address from the bind address. +/// `0.0.0.0` isn't connectable on all platforms, so map it to `127.0.0.1`. +fn connectable_host(host: &str) -> &str { + if host == "0.0.0.0" { + "127.0.0.1" + } else { + host + } +} + +/// Get a human-friendly display host for URLs. +fn display_host(host: &str) -> &str { + if host == "0.0.0.0" || host == "127.0.0.1" { + "localhost" + } else { + host + } +} + +/// Wait for the server to be ready by attempting TCP connections. +fn wait_for_server(host: &str, port: u16) -> Result<(), Box> { + let addr = format!("{host}:{port}"); + + for _ in 0..50 { + if TcpStream::connect(&addr).is_ok() { + return Ok(()); + } + std::thread::sleep(Duration::from_millis(100)); + } + + Err(format!("Server failed to start (could not connect to {addr})").into()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_connectable_host_maps_wildcard() { + assert_eq!(connectable_host("0.0.0.0"), "127.0.0.1"); + } + + #[test] + fn test_connectable_host_preserves_specific() { + assert_eq!(connectable_host("127.0.0.1"), "127.0.0.1"); + assert_eq!(connectable_host("192.168.1.1"), "192.168.1.1"); + } + + #[test] + fn test_display_host_localhost_variants() { + assert_eq!(display_host("0.0.0.0"), "localhost"); + assert_eq!(display_host("127.0.0.1"), "localhost"); + } + + #[test] + fn test_display_host_preserves_specific() { + assert_eq!(display_host("192.168.1.1"), "192.168.1.1"); + assert_eq!(display_host("10.0.0.1"), "10.0.0.1"); + } + + #[test] + fn test_generate_internal_password_is_nonempty() { + let pw = generate_internal_password(); + assert!(!pw.is_empty()); + } + + #[test] + fn test_generate_internal_password_is_numeric() { + let pw = generate_internal_password(); + assert!(pw.chars().all(|c| c.is_ascii_digit())); + } + + #[test] + fn test_wait_for_server_fails_on_unroutable_addr() { + // Use a non-routable address with a short timeout to reliably test failure + let result = wait_for_server("192.0.2.1", 1); + assert!(result.is_err()); + } +} diff --git a/src/main.rs b/src/main.rs index 2f0c8bf..2c59c04 100644 --- a/src/main.rs +++ b/src/main.rs @@ -65,6 +65,21 @@ enum Commands { #[arg(long, default_value = "21600")] room_ttl: u64, }, + + /// Start a local server and share your terminal in one command + Serve { + /// Host to bind to + #[arg(short = 'H', long, default_value = "127.0.0.1")] + host: String, + + /// Port to listen on + #[arg(short, long, default_value = "5000")] + port: u16, + + /// Read from stdin instead of spawning a shell + #[arg(long)] + stdin: bool, + }, } fn main() -> Result<(), Box> { @@ -85,7 +100,19 @@ fn main() -> Result<(), Box> { // Create runtime only for server mode let runtime = tokio::runtime::Runtime::new()?; - runtime.block_on(server::run(&host, port, cleanup_interval, room_ttl))?; + let config = server::ServerConfig { + host, + port, + cleanup_interval_secs: cleanup_interval, + room_ttl_secs: room_ttl, + serve_room: None, + }; + runtime.block_on(server::run(&config))?; + } + Some(Commands::Serve { host, port, stdin }) => { + // Run combined server + client mode + let args = cli::serve::ServeArgs { host, port, stdin }; + cli::serve::run(&args)?; } None => { // Run client mode diff --git a/src/server/mod.rs b/src/server/mod.rs index fb324e0..006f1f1 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -122,13 +122,23 @@ struct AppState { /// Request body for POST /r/:room - using Value to preserve null vs missing distinction type BroadcastRequest = serde_json::Value; +/// Server configuration +pub struct ServerConfig { + pub host: String, + pub port: u16, + pub cleanup_interval_secs: u64, + pub room_ttl_secs: u64, + /// When set, `GET /` serves the room viewer for this room instead of the home page. + /// Used by `shellshare serve` so that `http://localhost:5000` shows the terminal directly. + pub serve_room: Option, +} + /// Run the shellshare server -pub async fn run( - host: &str, - port: u16, - cleanup_interval_secs: u64, - room_ttl_secs: u64, -) -> Result<(), Box> { +pub async fn run(config: &ServerConfig) -> Result<(), Box> { + let host = &config.host; + let port = config.port; + let cleanup_interval_secs = config.cleanup_interval_secs; + let room_ttl_secs = config.room_ttl_secs; info!("Starting shellshare server on {}:{}", host, port); info!( "Room cleanup: interval={}s, TTL={}s", @@ -161,10 +171,18 @@ pub async fn run( // Spawn background cleanup task for abandoned rooms spawn_cleanup_task(app_state.clone()); - // Build router + // Build router - in serve mode, GET / shows the room viewer directly + let index_route = config.serve_room.as_ref().map_or_else( + || get(index_handler), + |room| { + let room = room.clone(); + get(move |headers| serve_room_index_handler(headers, room.clone())) + }, + ); + let app = Router::new() // API routes - .route("/", get(index_handler)) + .route("/", index_route) .route("/r/{*room}", get(room_page_handler)) .route("/r/{*room}", post(broadcast_handler)) .route("/r/{*room}", delete(delete_room_handler)) @@ -362,14 +380,24 @@ async fn index_handler(headers: HeaderMap) -> impl IntoResponse { } } -/// GET /r/:room - Room page -async fn room_page_handler(Path(_room): Path) -> impl IntoResponse { +/// Render room.html with an optional room override in the config block. +/// When `room` is `Some`, the JS client joins that room instead of using `location.pathname`. +fn render_room_page(room: Option<&str>) -> Response { match Templates::get("room.html") { - Some(content) => Response::builder() - .status(StatusCode::OK) - .header(header::CONTENT_TYPE, "text/html; charset=utf-8") - .body(Body::from(content.data.into_owned())) - .unwrap(), + Some(content) => { + let html = String::from_utf8_lossy(&content.data); + let config = room.map_or_else( + || r#"{"room": null}"#.to_string(), + |r| serde_json::json!({ "room": format!("/r/{r}") }).to_string(), + ); + let html = html.replace("{{ROOM_CONFIG}}", &config); + + Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "text/html; charset=utf-8") + .body(Body::from(html)) + .unwrap() + } None => Response::builder() .status(StatusCode::INTERNAL_SERVER_ERROR) .header(header::CONTENT_TYPE, "text/plain; charset=utf-8") @@ -378,6 +406,18 @@ async fn room_page_handler(Path(_room): Path) -> impl IntoResponse { } } +/// GET / in serve mode - serves room.html with the room name injected +#[allow(clippy::unused_async)] // Must be async for axum Handler trait +async fn serve_room_index_handler(_headers: HeaderMap, room: String) -> impl IntoResponse { + render_room_page(Some(&room)) +} + +/// GET /r/:room - Room page +#[allow(clippy::unused_async)] // Must be async for axum Handler trait +async fn room_page_handler(Path(_room): Path) -> impl IntoResponse { + render_room_page(None) +} + /// POST /r/:room - Broadcast message to room #[allow(clippy::significant_drop_tightening)] // Lock must span room_data mutations async fn broadcast_handler( diff --git a/templates/room.html b/templates/room.html index 3a7aa8a..f3d480c 100644 --- a/templates/room.html +++ b/templates/room.html @@ -36,12 +36,14 @@

+