From ec3edf525a14aa7b87d28f1593947f0fe5ec4676 Mon Sep 17 00:00:00 2001 From: Brad Stein Date: Thu, 30 Apr 2026 11:38:16 -0300 Subject: [PATCH] release: ship lesavka 0.16.1 --- Cargo.lock | 6 +- client/Cargo.toml | 2 +- .../launcher/ui/utility_button_bindings.rs | 102 ++++++++++++++++++ client/src/launcher/ui_components.rs | 1 + .../launcher/ui_components/assemble_view.rs | 1 + .../launcher/ui_components/build_contexts.rs | 1 + .../ui_components/build_operations_rail.rs | 33 ++++-- client/src/launcher/ui_components/types.rs | 2 + .../src/launcher/ui_runtime/control_paths.rs | 15 ++- client/src/relay_transport.rs | 75 +++++++++---- common/Cargo.toml | 2 +- docs/operational-env.md | 10 ++ scripts/ci/hygiene_gate_baseline.json | 22 ++-- scripts/ci/quality_gate_baseline.json | 4 +- scripts/install/client.sh | 42 +++++++- scripts/install/server.sh | 5 +- server/Cargo.toml | 2 +- .../tests/client_install_script_contract.rs | 12 ++- .../tests/client_launcher_layout_contract.rs | 40 +++++-- .../tests/client_launcher_runtime_contract.rs | 12 +++ .../tests/server_install_script_contract.rs | 3 + 21 files changed, 330 insertions(+), 62 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e31f3d9..ee43994 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1652,7 +1652,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "lesavka_client" -version = "0.16.0" +version = "0.16.1" dependencies = [ "anyhow", "async-stream", @@ -1686,7 +1686,7 @@ dependencies = [ [[package]] name = "lesavka_common" -version = "0.16.0" +version = "0.16.1" dependencies = [ "anyhow", "base64", @@ -1698,7 +1698,7 @@ dependencies = [ [[package]] name = "lesavka_server" -version = "0.16.0" +version = "0.16.1" dependencies = [ "anyhow", "base64", diff --git a/client/Cargo.toml b/client/Cargo.toml index 3f03ca6..f312687 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ path = "src/main.rs" [package] name = "lesavka_client" -version = "0.16.0" +version = "0.16.1" edition = "2024" [dependencies] diff --git a/client/src/launcher/ui/utility_button_bindings.rs b/client/src/launcher/ui/utility_button_bindings.rs index 188cc19..85d3b47 100644 --- a/client/src/launcher/ui/utility_button_bindings.rs +++ b/client/src/launcher/ui/utility_button_bindings.rs @@ -1,4 +1,106 @@ { + fn default_client_pki_dir() -> PathBuf { + if let Some(home) = std::env::var_os("HOME") { + return PathBuf::from(home).join(".config/lesavka/pki"); + } + std::env::current_dir() + .unwrap_or_else(|_| PathBuf::from(".")) + .join(".config/lesavka/pki") + } + + fn install_client_pki_bundle(bundle: &Path) -> Result { + let target = default_client_pki_dir(); + let scratch = std::env::temp_dir().join(format!( + "lesavka-client-pki-{}", + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + )); + std::fs::create_dir_all(&scratch) + .map_err(|err| format!("could not create extraction folder: {err}"))?; + let extract = Command::new("tar") + .arg("-xzf") + .arg(bundle) + .arg("-C") + .arg(&scratch) + .status() + .map_err(|err| format!("tar is unavailable: {err}"))?; + if !extract.success() { + let _ = std::fs::remove_dir_all(&scratch); + return Err(format!("could not extract {}", bundle.display())); + } + for item in ["ca.crt", "client.crt", "client.key"] { + if !scratch.join(item).is_file() { + let _ = std::fs::remove_dir_all(&scratch); + return Err(format!("bundle is missing {item}")); + } + } + std::fs::create_dir_all(&target) + .map_err(|err| format!("could not create {}: {err}", target.display()))?; + for item in ["ca.crt", "client.crt", "client.key"] { + std::fs::copy(scratch.join(item), target.join(item)) + .map_err(|err| format!("could not install {item}: {err}"))?; + } + tighten_client_key_permissions(&target.join("client.key")); + let _ = std::fs::remove_dir_all(&scratch); + Ok(target) + } + + #[cfg(unix)] + fn tighten_client_key_permissions(path: &Path) { + use std::os::unix::fs::PermissionsExt; + if let Ok(mut permissions) = std::fs::metadata(path).map(|metadata| metadata.permissions()) { + permissions.set_mode(0o600); + let _ = std::fs::set_permissions(path, permissions); + } + } + + #[cfg(not(unix))] + fn tighten_client_key_permissions(_path: &Path) {} + + { + let widgets = widgets.clone(); + let window = window.clone(); + let certs_button = widgets.certs_button.clone(); + certs_button.connect_clicked(move |_| { + let chooser = gtk::FileChooserNative::new( + Some("Choose Lesavka Client TLS Bundle"), + Some(&window), + gtk::FileChooserAction::Open, + Some("Install"), + Some("Cancel"), + ); + chooser.set_modal(true); + let widgets = widgets.clone(); + chooser.connect_response(move |dialog, response| { + if response == gtk::ResponseType::Accept { + if let Some(bundle) = dialog.file().and_then(|file| file.path()) { + widgets.status_label.set_text(&format!( + "Installing Lesavka client TLS bundle from {}...", + bundle.display() + )); + match install_client_pki_bundle(&bundle) { + Ok(target) => widgets.status_label.set_text(&format!( + "Client TLS certs installed at {}. Reconnect the relay to use them.", + target.display() + )), + Err(err) => widgets + .status_label + .set_text(&format!("Client TLS bundle install failed: {err}")), + } + } else { + widgets + .status_label + .set_text("Selected TLS bundle did not provide a filesystem path."); + } + } + dialog.destroy(); + }); + chooser.show(); + }); + } + { let child_proc = Rc::clone(&child_proc); let widgets = widgets.clone(); diff --git a/client/src/launcher/ui_components.rs b/client/src/launcher/ui_components.rs index cd92ed6..cb52256 100644 --- a/client/src/launcher/ui_components.rs +++ b/client/src/launcher/ui_components.rs @@ -85,6 +85,7 @@ pub fn build_launcher_view( let OperationsRailContext { server_entry, start_button, + certs_button, clipboard_button, usb_recover_button, uac_recover_button, diff --git a/client/src/launcher/ui_components/assemble_view.rs b/client/src/launcher/ui_components/assemble_view.rs index 9bb899f..821e7e4 100644 --- a/client/src/launcher/ui_components/assemble_view.rs +++ b/client/src/launcher/ui_components/assemble_view.rs @@ -124,6 +124,7 @@ display_panes: [left_pane.clone(), right_pane.clone()], server_entry: server_entry.clone(), start_button: start_button.clone(), + certs_button: certs_button.clone(), camera_combo: camera_combo.clone(), camera_quality_combo: camera_quality_combo.clone(), microphone_combo: microphone_combo.clone(), diff --git a/client/src/launcher/ui_components/build_contexts.rs b/client/src/launcher/ui_components/build_contexts.rs index a8b9c60..1dd1f5a 100644 --- a/client/src/launcher/ui_components/build_contexts.rs +++ b/client/src/launcher/ui_components/build_contexts.rs @@ -55,6 +55,7 @@ struct DeviceControlsContext { struct OperationsRailContext { server_entry: gtk::Entry, start_button: gtk::Button, + certs_button: gtk::Button, clipboard_button: gtk::Button, usb_recover_button: gtk::Button, uac_recover_button: gtk::Button, diff --git a/client/src/launcher/ui_components/build_operations_rail.rs b/client/src/launcher/ui_components/build_operations_rail.rs index 18846b8..805c5d4 100644 --- a/client/src/launcher/ui_components/build_operations_rail.rs +++ b/client/src/launcher/ui_components/build_operations_rail.rs @@ -3,18 +3,27 @@ let server_entry = gtk::Entry::new(); server_entry.add_css_class("server-entry"); server_entry.set_hexpand(true); - server_entry.set_width_chars(14); + server_entry.set_width_chars(11); server_entry.set_text(server_addr); - server_entry.set_tooltip_text(Some("Relay host address.")); + server_entry.set_tooltip_text(Some( + "Relay host address. If no scheme is provided, Lesavka uses https://.", + )); let relay_grid = gtk::Grid::new(); - relay_grid.set_column_homogeneous(true); + relay_grid.set_column_homogeneous(false); relay_grid.set_column_spacing(6); relay_grid.set_hexpand(true); relay_grid.set_row_spacing(8); - relay_grid.attach(&server_entry, 0, 0, 2, 1); + relay_grid.attach(&server_entry, 0, 0, 1, 1); + + let certs_button = rail_button("Certs", "Install or replace the client TLS enrollment bundle."); + certs_button.set_hexpand(false); + stabilize_button(&certs_button, 66); + relay_grid.attach(&certs_button, 1, 0, 1, 1); let start_button = rail_button("Connect", "Start or stop relay."); start_button.add_css_class("suggested-action"); + start_button.set_hexpand(false); + stabilize_button(&start_button, 76); relay_grid.attach(&start_button, 2, 0, 1, 1); connection_body.append(&relay_grid); @@ -24,7 +33,7 @@ recovery_heading.set_halign(gtk::Align::Start); let recovery_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); recovery_row.set_hexpand(true); - recovery_heading.set_width_chars(10); + recovery_heading.set_width_chars(RELAY_SUBGROUP_LABEL_WIDTH); recovery_row.append(&recovery_heading); let recovery_buttons = gtk::Box::new(gtk::Orientation::Horizontal, 8); recovery_buttons.set_hexpand(true); @@ -39,10 +48,11 @@ connection_body.append(&recovery_row); connection_body.append(>k::Separator::new(gtk::Orientation::Horizontal)); - let calibration_heading = gtk::Label::new(Some("AV Upstream\nCalibration")); + let calibration_heading = gtk::Label::new(Some("Audio/Video\nUpstream\nCalibration")); calibration_heading.add_css_class("subgroup-title"); calibration_heading.set_halign(gtk::Align::Start); - calibration_heading.set_width_chars(12); + calibration_heading.set_justify(gtk::Justification::Center); + calibration_heading.set_width_chars(RELAY_SUBGROUP_LABEL_WIDTH); let calibration_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); calibration_row.set_hexpand(true); calibration_row.append(&calibration_heading); @@ -93,7 +103,7 @@ power_shell.set_halign(gtk::Align::Fill); let power_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); power_row.set_hexpand(true); - power_heading.set_width_chars(10); + power_heading.set_width_chars(RELAY_SUBGROUP_LABEL_WIDTH); power_row.append(&power_heading); let power_buttons = gtk::Box::new(gtk::Orientation::Horizontal, 8); power_buttons.set_hexpand(true); @@ -127,7 +137,7 @@ let routing_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); routing_row.set_hexpand(true); - routing_heading.set_width_chars(10); + routing_heading.set_width_chars(RELAY_SUBGROUP_LABEL_WIDTH); routing_row.append(&routing_heading); let routing_buttons = gtk::Box::new(gtk::Orientation::Horizontal, 8); routing_buttons.set_hexpand(true); @@ -150,7 +160,7 @@ tools_heading.set_halign(gtk::Align::Start); let tools_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); tools_row.set_hexpand(true); - tools_heading.set_width_chars(10); + tools_heading.set_width_chars(RELAY_SUBGROUP_LABEL_WIDTH); tools_row.append(&tools_heading); let tools_buttons = gtk::Box::new(gtk::Orientation::Horizontal, 8); tools_buttons.set_hexpand(true); @@ -196,6 +206,7 @@ .child(&diagnostics_shell) .build(); diagnostics_scroll.set_propagate_natural_width(false); + diagnostics_scroll.set_propagate_natural_height(false); diagnostics_body.append(&diagnostics_toolbar); diagnostics_body.append(&diagnostics_scroll); operations.append(&diagnostics_panel); @@ -252,6 +263,7 @@ .child(&session_log_view) .build(); log_scroll.set_propagate_natural_width(false); + log_scroll.set_propagate_natural_height(false); console_body.append(&console_toolbar); console_body.append(&log_scroll); operations.append(&console_panel); @@ -277,6 +289,7 @@ OperationsRailContext { server_entry, start_button, + certs_button, clipboard_button, usb_recover_button, uac_recover_button, diff --git a/client/src/launcher/ui_components/types.rs b/client/src/launcher/ui_components/types.rs index eeea994..4ef0287 100644 --- a/client/src/launcher/ui_components/types.rs +++ b/client/src/launcher/ui_components/types.rs @@ -131,6 +131,7 @@ pub struct LauncherWidgets { pub display_panes: [DisplayPaneWidgets; 2], pub server_entry: gtk::Entry, pub start_button: gtk::Button, + pub certs_button: gtk::Button, pub camera_combo: gtk::ComboBoxText, pub camera_quality_combo: gtk::ComboBoxText, pub microphone_combo: gtk::ComboBoxText, @@ -212,6 +213,7 @@ const LESAVKA_ICON_SEARCH_PATH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/ass const LAUNCHER_DEFAULT_WIDTH: i32 = 1540; const LAUNCHER_DEFAULT_HEIGHT: i32 = 880; const OPERATIONS_RAIL_WIDTH: i32 = 276; +const RELAY_SUBGROUP_LABEL_WIDTH: i32 = 11; const CAMERA_PREVIEW_VIEWPORT_HEIGHT: i32 = 225; const CAMERA_PREVIEW_VIEWPORT_WIDTH: i32 = 400; const EYE_PREVIEW_MIN_HEIGHT: i32 = 299; diff --git a/client/src/launcher/ui_runtime/control_paths.rs b/client/src/launcher/ui_runtime/control_paths.rs index 763f258..22f094c 100644 --- a/client/src/launcher/ui_runtime/control_paths.rs +++ b/client/src/launcher/ui_runtime/control_paths.rs @@ -39,9 +39,22 @@ pub fn selected_server_addr(entry: >k::Entry, fallback: &str) -> String { let current = entry.text(); let trimmed = current.trim(); if trimmed.is_empty() { - fallback.to_string() + normalize_server_addr(fallback) } else { + let normalized = normalize_server_addr(trimmed); + if normalized != trimmed { + entry.set_text(&normalized); + } + normalized + } +} + +pub fn normalize_server_addr(raw: &str) -> String { + let trimmed = raw.trim(); + if trimmed.is_empty() || trimmed.contains("://") { trimmed.to_string() + } else { + format!("https://{trimmed}") } } diff --git a/client/src/relay_transport.rs b/client/src/relay_transport.rs index 54e9acc..228bc78 100644 --- a/client/src/relay_transport.rs +++ b/client/src/relay_transport.rs @@ -39,30 +39,49 @@ fn client_tls_config(server_addr: &str) -> Result { .domain_name(tls_domain(server_addr)) .with_enabled_roots(); - if let Some(ca_path) = env_path("LESAVKA_TLS_CA").or_else(|| default_pki_path("ca.crt")) - && ca_path.exists() - { - let ca = std::fs::read(&ca_path) - .with_context(|| format!("reading TLS CA certificate {}", ca_path.display()))?; - tls = tls.ca_certificate(Certificate::from_pem(ca)); - } - + let ca_path = env_path("LESAVKA_TLS_CA").or_else(|| default_pki_path("ca.crt")); let cert_path = env_path("LESAVKA_TLS_CLIENT_CERT").or_else(|| default_pki_path("client.crt")); let key_path = env_path("LESAVKA_TLS_CLIENT_KEY").or_else(|| default_pki_path("client.key")); - if let (Some(cert_path), Some(key_path)) = (cert_path, key_path) - && cert_path.exists() - && key_path.exists() - { - let cert = std::fs::read(&cert_path) - .with_context(|| format!("reading TLS client certificate {}", cert_path.display()))?; - let key = std::fs::read(&key_path) - .with_context(|| format!("reading TLS client key {}", key_path.display()))?; - tls = tls.identity(Identity::from_pem(cert, key)); - } + require_tls_enrollment(&ca_path, &cert_path, &key_path)?; + + let ca_path = ca_path.expect("enrollment check guarantees CA path"); + let cert_path = cert_path.expect("enrollment check guarantees cert path"); + let key_path = key_path.expect("enrollment check guarantees key path"); + + let ca = std::fs::read(&ca_path) + .with_context(|| format!("reading TLS CA certificate {}", ca_path.display()))?; + tls = tls.ca_certificate(Certificate::from_pem(ca)); + + let cert = std::fs::read(&cert_path) + .with_context(|| format!("reading TLS client certificate {}", cert_path.display()))?; + let key = std::fs::read(&key_path) + .with_context(|| format!("reading TLS client key {}", key_path.display()))?; + tls = tls.identity(Identity::from_pem(cert, key)); Ok(tls) } +fn require_tls_enrollment( + ca_path: &Option, + cert_path: &Option, + key_path: &Option, +) -> Result<()> { + if path_exists(ca_path) && path_exists(cert_path) && path_exists(key_path) { + return Ok(()); + } + + let pki_hint = default_pki_path("") + .map(|path| path.display().to_string()) + .unwrap_or_else(|| "~/.config/lesavka/pki".to_string()); + bail!( + "TLS enrollment is missing for the Lesavka relay. Run the server installer first, then run the client installer with the server-generated bundle, or let the client installer auto-fetch it from the configured SSH host. Expected ca.crt, client.crt, and client.key under {pki_hint}." + ) +} + +fn path_exists(path: &Option) -> bool { + path.as_ref().is_some_and(|path| path.exists()) +} + fn tls_domain(server_addr: &str) -> String { std::env::var("LESAVKA_TLS_DOMAIN") .ok() @@ -254,4 +273,24 @@ mod tests { }, ); } + + #[test] + fn https_endpoint_fails_fast_without_client_enrollment() { + let dir = tempdir().expect("empty pki dir"); + with_vars( + [ + ("HOME", Some(dir.path().to_string_lossy().as_ref())), + ("LESAVKA_TLS_CA", None::<&str>), + ("LESAVKA_TLS_CLIENT_CERT", None::<&str>), + ("LESAVKA_TLS_CLIENT_KEY", None::<&str>), + ("LESAVKA_TLS_DOMAIN", None::<&str>), + ], + || { + let err = endpoint("https://38.28.125.112:50051") + .expect_err("missing mTLS enrollment should be explicit"); + assert!(err.to_string().contains("TLS enrollment is missing")); + assert!(err.to_string().contains("client.key")); + }, + ); + } } diff --git a/common/Cargo.toml b/common/Cargo.toml index dc1a416..59d2fed 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lesavka_common" -version = "0.16.0" +version = "0.16.1" edition = "2024" build = "build.rs" diff --git a/docs/operational-env.md b/docs/operational-env.md index 1a918fe..647e499 100644 --- a/docs/operational-env.md +++ b/docs/operational-env.md @@ -3,6 +3,13 @@ This is the tracked inventory for `LESAVKA_*` knobs used by source, scripts, CI, and tests. The hygiene gate fails when a new variable is added without appearing here, which keeps operator-facing configuration from drifting into folklore. Hardware-facing assumptions belong near the code that uses them; this file is the repo-wide index. + +Install security flow: the server installer creates `/etc/lesavka/lesavka-client-pki.tar.gz` +and makes it readable only by the installing account. The client installer first uses +`LESAVKA_CLIENT_PKI_BUNDLE` when supplied, otherwise it tries to fetch that same bundle +from `LESAVKA_CLIENT_PKI_SSH_SOURCE` over SSH. Runtime clients require the installed +`ca.crt`, `client.crt`, and `client.key` for HTTPS/mTLS relay connections. + | `LESAVKA_ALLOW_GADGET_CYCLE` | document near use before promoting to operator config | | `LESAVKA_ALLOW_GADGET_RESET` | document near use before promoting to operator config | | `LESAVKA_ALLOW_INSECURE` | client transport override; permits non-local `http://` relay URLs only for lab/debug use | @@ -49,6 +56,7 @@ Hardware-facing assumptions belong near the code that uses them; this file is th | `LESAVKA_CAPTURE_REMOTE` | runtime/install/session override | | `LESAVKA_CLIENT_APP_SRC` | test/build contract variable; not runtime operator config | | `LESAVKA_CLIENT_BUNDLE` | server installer output path for the generated client TLS enrollment bundle | +| `LESAVKA_CLIENT_CAPTURE_DIR` | client installer capture folder override; defaults to `~/Pictures/lesavka` | | `LESAVKA_CLIENT_CAMERA_SRC` | test/build contract variable; not runtime operator config | | `LESAVKA_CLIENT_INPUTS_SRC` | test/build contract variable; not runtime operator config | | `LESAVKA_CLIENT_KEYBOARD_SRC` | test/build contract variable; not runtime operator config | @@ -59,7 +67,9 @@ Hardware-facing assumptions belong near the code that uses them; this file is th | `LESAVKA_CLIENT_OUTPUT_DISPLAY_SRC` | test/build contract variable; not runtime operator config | | `LESAVKA_CLIENT_OUTPUT_VIDEO_SRC` | test/build contract variable; not runtime operator config | | `LESAVKA_CLIENT_PKI_BUNDLE` | client installer input path for a server-generated TLS enrollment bundle | +| `LESAVKA_CLIENT_PKI_AUTO_FETCH` | client installer toggle for SSH enrollment auto-fetch; defaults to enabled | | `LESAVKA_CLIENT_PKI_DIR` | client installer/runtime TLS identity directory override | +| `LESAVKA_CLIENT_PKI_SSH_SOURCE` | client installer SSH source for auto-fetching the server enrollment bundle; defaults to `theia:/etc/lesavka/lesavka-client-pki.tar.gz` | | `LESAVKA_CLIENT_RELAYCTL_BIN_SRC` | test/build contract variable; not runtime operator config | | `LESAVKA_CLIENT_VIDEO_SUPPORT_SRC` | test/build contract variable; not runtime operator config | | `LESAVKA_CLIPBOARD_CHORD` | input routing/clipboard override | diff --git a/scripts/ci/hygiene_gate_baseline.json b/scripts/ci/hygiene_gate_baseline.json index 411e095..38f2723 100644 --- a/scripts/ci/hygiene_gate_baseline.json +++ b/scripts/ci/hygiene_gate_baseline.json @@ -382,23 +382,23 @@ }, "client/src/launcher/ui/utility_button_bindings.rs": { "clippy_warnings": 0, - "doc_debt": 0, - "loc": 387 + "doc_debt": 3, + "loc": 489 }, "client/src/launcher/ui_components.rs": { "clippy_warnings": 0, "doc_debt": 1, - "loc": 124 + "loc": 125 }, "client/src/launcher/ui_components/assemble_view.rs": { "clippy_warnings": 0, "doc_debt": 0, - "loc": 204 + "loc": 205 }, "client/src/launcher/ui_components/build_contexts.rs": { "clippy_warnings": 0, "doc_debt": 0, - "loc": 87 + "loc": 88 }, "client/src/launcher/ui_components/build_device_controls.rs": { "clippy_warnings": 0, @@ -408,7 +408,7 @@ "client/src/launcher/ui_components/build_operations_rail.rs": { "clippy_warnings": 0, "doc_debt": 0, - "loc": 310 + "loc": 323 }, "client/src/launcher/ui_components/build_shell.rs": { "clippy_warnings": 0, @@ -448,7 +448,7 @@ "client/src/launcher/ui_components/types.rs": { "clippy_warnings": 0, "doc_debt": 0, - "loc": 221 + "loc": 223 }, "client/src/launcher/ui_runtime.rs": { "clippy_warnings": 0, @@ -457,8 +457,8 @@ }, "client/src/launcher/ui_runtime/control_paths.rs": { "clippy_warnings": 0, - "doc_debt": 8, - "loc": 244 + "doc_debt": 9, + "loc": 257 }, "client/src/launcher/ui_runtime/display_popouts.rs": { "clippy_warnings": 0, @@ -552,8 +552,8 @@ }, "client/src/relay_transport.rs": { "clippy_warnings": 0, - "doc_debt": 7, - "loc": 257 + "doc_debt": 9, + "loc": 296 }, "client/src/sync_probe/analyze.rs": { "clippy_warnings": 0, diff --git a/scripts/ci/quality_gate_baseline.json b/scripts/ci/quality_gate_baseline.json index 2c7a2dc..72a02e7 100644 --- a/scripts/ci/quality_gate_baseline.json +++ b/scripts/ci/quality_gate_baseline.json @@ -189,8 +189,8 @@ "loc": 82 }, "client/src/relay_transport.rs": { - "line_percent": 95.54, - "loc": 257 + "line_percent": 97.0, + "loc": 296 }, "client/src/sync_probe/analyze.rs": { "line_percent": 97.92, diff --git a/scripts/install/client.sh b/scripts/install/client.sh index e60ac82..4b9d29a 100755 --- a/scripts/install/client.sh +++ b/scripts/install/client.sh @@ -12,6 +12,9 @@ SRC=/var/src/lesavka export TMPDIR=${TMPDIR:-/var/tmp} USER_HOME=$(getent passwd "$ORIG_USER" | cut -d: -f6) CLIENT_PKI_DIR=${LESAVKA_CLIENT_PKI_DIR:-$USER_HOME/.config/lesavka/pki} +CLIENT_PKI_AUTO_FETCH=${LESAVKA_CLIENT_PKI_AUTO_FETCH:-1} +CLIENT_PKI_SSH_SOURCE=${LESAVKA_CLIENT_PKI_SSH_SOURCE:-theia:/etc/lesavka/lesavka-client-pki.tar.gz} +CLIENT_CAPTURE_DIR=${LESAVKA_CLIENT_CAPTURE_DIR:-$USER_HOME/Pictures/lesavka} log() { printf '==> %s\n' "$*" @@ -103,15 +106,42 @@ run_as_user() { sudo -u "$ORIG_USER" env HOME="$USER_HOME" SSH_AUTH_SOCK="${SSH_AUTH_SOCK:-}" "$@" } +fetch_client_pki_bundle() { + [[ $CLIENT_PKI_AUTO_FETCH != 0 && $CLIENT_PKI_AUTO_FETCH != false && $CLIENT_PKI_AUTO_FETCH != no ]] || return 1 + [[ $CLIENT_PKI_SSH_SOURCE == *:* ]] || return 1 + + local host=${CLIENT_PKI_SSH_SOURCE%%:*} + local remote_path=${CLIENT_PKI_SSH_SOURCE#*:} + local tmp_bundle + tmp_bundle=$(mktemp --suffix=.tar.gz) + if run_as_user scp -q -o BatchMode=yes -o ConnectTimeout=5 \ + "$host:$remote_path" "$tmp_bundle" >/dev/null 2>&1; then + printf '%s\n' "$tmp_bundle" + return 0 + fi + rm -f "$tmp_bundle" + return 1 +} + install_client_pki_bundle() { local bundle=${LESAVKA_CLIENT_PKI_BUNDLE:-} + local fetched_bundle=0 if [[ -z $bundle ]]; then if [[ -s "$CLIENT_PKI_DIR/ca.crt" && -s "$CLIENT_PKI_DIR/client.crt" && -s "$CLIENT_PKI_DIR/client.key" ]]; then echo " ↪ TLS client identity already present: $CLIENT_PKI_DIR" - else - echo "⚠️ no LESAVKA_CLIENT_PKI_BUNDLE supplied; HTTPS relay connections will need a trusted public cert or a bundle install later." + return 0 + fi + + if bundle=$(fetch_client_pki_bundle); then + fetched_bundle=1 + echo " ↪ fetched TLS client enrollment bundle from $CLIENT_PKI_SSH_SOURCE" + else + echo "⚠️ no TLS client identity installed." + echo " Rerun with LESAVKA_CLIENT_PKI_BUNDLE=/path/to/lesavka-client-pki.tar.gz," + echo " or make $CLIENT_PKI_SSH_SOURCE readable over SSH and rerun the installer." + echo " HTTPS/mTLS relay connections will not work until this bundle is installed." + return 0 fi - return 0 fi log "5b. Installing TLS client identity" @@ -130,6 +160,9 @@ install_client_pki_bundle() { sudo install -m 0644 -o "$ORIG_USER" -g "$ORIG_USER" "$tmp/client.crt" "$CLIENT_PKI_DIR/client.crt" sudo install -m 0600 -o "$ORIG_USER" -g "$ORIG_USER" "$tmp/client.key" "$CLIENT_PKI_DIR/client.key" sudo rm -rf "$tmp" + if [[ $fetched_bundle == 1 ]]; then + rm -f "$bundle" + fi echo " ↪ installed TLS client identity: $CLIENT_PKI_DIR" } @@ -233,6 +266,8 @@ log "5. Installing launchable client binaries" sudo install -Dm755 "$SRC/target/release/lesavka-client" /usr/local/bin/lesavka-client sudo ln -sf /usr/local/bin/lesavka-client /usr/local/bin/lesavka install_client_pki_bundle +sudo install -d -m 0755 -o "$ORIG_USER" -g "$ORIG_USER" "$CLIENT_CAPTURE_DIR" +echo " ↪ capture folder: $CLIENT_CAPTURE_DIR" log "6. Registering desktop application" sudo install -Dm644 "$SRC/client/assets/icons/hicolor/1024x1024/apps/lesavka.png" \ @@ -265,6 +300,7 @@ echo " Launch alias: /usr/local/bin/lesavka" echo " Desktop entry: /usr/share/applications/lesavka.desktop" echo " Build source: $SRC/target/release/lesavka-client" echo " TLS identity: $CLIENT_PKI_DIR" +echo " Captures: $CLIENT_CAPTURE_DIR" echo "✅ Installed version: lesavka-client ${INSTALLED_VERSION:-unknown}${INSTALLED_SHA:+ ($INSTALLED_SHA)}" echo echo "Quick start:" diff --git a/scripts/install/server.sh b/scripts/install/server.sh index 973052e..c96e72e 100755 --- a/scripts/install/server.sh +++ b/scripts/install/server.sh @@ -167,7 +167,8 @@ ensure_server_tls_pki() { sudo cp "$LESAVKA_TLS_DIR/client.crt" "$bundle_tmp/client.crt" sudo cp "$LESAVKA_TLS_DIR/client.key" "$bundle_tmp/client.key" sudo tar -C "$bundle_tmp" -czf "$LESAVKA_CLIENT_BUNDLE" ca.crt client.crt client.key - sudo chmod 0640 "$LESAVKA_CLIENT_BUNDLE" + sudo chown "$ORIG_USER":"$ORIG_USER" "$LESAVKA_CLIENT_BUNDLE" + sudo chmod 0600 "$LESAVKA_CLIENT_BUNDLE" sudo rm -rf "$bundle_tmp" echo " ↪ client enrollment bundle: $LESAVKA_CLIENT_BUNDLE" } @@ -1121,6 +1122,8 @@ fi if [[ -n $PERSISTED_UVC_CODEC ]]; then echo "➡️ UVC codec: ${PERSISTED_UVC_CODEC}" fi +echo "➡️ Client TLS bundle: ${LESAVKA_CLIENT_BUNDLE}" +echo "➡️ Client install can use: sudo env LESAVKA_CLIENT_PKI_BUNDLE=${LESAVKA_CLIENT_BUNDLE} ./scripts/install/client.sh" echo "➡️ Status: sudo systemctl status lesavka-server --no-pager" echo "➡️ Logs: sudo journalctl -u lesavka-server -f --no-pager" echo "✅ Installed version: lesavka-server ${INSTALLED_VERSION:-unknown}${INSTALLED_SHA:+ ($INSTALLED_SHA)}" diff --git a/server/Cargo.toml b/server/Cargo.toml index c7d34a1..c4abce3 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -10,7 +10,7 @@ bench = false [package] name = "lesavka_server" -version = "0.16.0" +version = "0.16.1" edition = "2024" autobins = false diff --git a/testing/tests/client_install_script_contract.rs b/testing/tests/client_install_script_contract.rs index da9698b..6145f1f 100644 --- a/testing/tests/client_install_script_contract.rs +++ b/testing/tests/client_install_script_contract.rs @@ -16,8 +16,14 @@ fn client_install_accepts_server_generated_tls_bundle() { "client.crt", "client.key", "install_client_pki_bundle", - "HTTPS relay connections will need a trusted public cert or a bundle install later", + "fetch_client_pki_bundle", + "LESAVKA_CLIENT_PKI_SSH_SOURCE", + "LESAVKA_CLIENT_CAPTURE_DIR", + "theia:/etc/lesavka/lesavka-client-pki.tar.gz", + "Pictures/lesavka", + "HTTPS/mTLS relay connections will not work until this bundle is installed", "TLS identity:", + "Captures:", ] { assert!( CLIENT_INSTALL.contains(expected), @@ -32,4 +38,8 @@ fn client_install_accepts_server_generated_tls_bundle() { CLIENT_INSTALL.contains("0600"), "client private key should be installed with private permissions" ); + assert!( + CLIENT_INSTALL.contains("scp -q -o BatchMode=yes"), + "client installer should auto-fetch the server enrollment bundle without hanging" + ); } diff --git a/testing/tests/client_launcher_layout_contract.rs b/testing/tests/client_launcher_layout_contract.rs index 66e6771..9fff913 100644 --- a/testing/tests/client_launcher_layout_contract.rs +++ b/testing/tests/client_launcher_layout_contract.rs @@ -228,6 +228,13 @@ fn operations_column_fills_height_and_splits_extra_space_between_logs() { .count(), 2 ); + assert_eq!( + UI_LAYOUT_SRC + .matches(".set_propagate_natural_height(false)") + .count(), + 2, + "Diagnostics and Log scrollports should split height by allocation rather than content" + ); assert_eq!( UI_LAYOUT_SRC .matches(".max_content_height(SIDE_LOG") @@ -273,14 +280,20 @@ fn status_chip_text_is_centered_inside_each_pill() { fn relay_controls_keep_connect_inline_with_server_entry() { assert!(UI_LAYOUT_SRC.contains("build_panel(\"Relay\")")); assert_eq!(const_i32("OPERATIONS_RAIL_WIDTH"), 276); + assert_eq!(const_i32("RELAY_SUBGROUP_LABEL_WIDTH"), 11); assert_eq!(const_i32("RAIL_BUTTON_WIDTH"), 86); assert_eq!(const_i32("RAIL_BUTTON_LABEL_CHARS"), 11); assert!(UI_LAYOUT_SRC.contains("let relay_grid = gtk::Grid::new();")); - assert!(UI_LAYOUT_SRC.contains("relay_grid.set_column_homogeneous(true);")); - assert!(UI_LAYOUT_SRC.contains("relay_grid.attach(&server_entry, 0, 0, 2, 1);")); + assert!(UI_LAYOUT_SRC.contains("server_entry.set_width_chars(11);")); + assert!(UI_LAYOUT_SRC.contains("relay_grid.set_column_homogeneous(false);")); + assert!(UI_LAYOUT_SRC.contains("relay_grid.attach(&server_entry, 0, 0, 1, 1);")); + assert!(UI_LAYOUT_SRC.contains("let certs_button = rail_button(\"Certs\"")); assert!(UI_LAYOUT_SRC.contains("let start_button = rail_button(\"Connect\"")); assert!(UI_LAYOUT_SRC.contains("pub(crate) fn set_rail_button_label(")); + assert!(UI_LAYOUT_SRC.contains("relay_grid.attach(&certs_button, 1, 0, 1, 1);")); assert!(UI_LAYOUT_SRC.contains("relay_grid.attach(&start_button, 2, 0, 1, 1);")); + assert!(UI_LAYOUT_SRC.contains("stabilize_button(&certs_button, 66);")); + assert!(UI_LAYOUT_SRC.contains("stabilize_button(&start_button, 76);")); assert!(UI_LAYOUT_SRC.contains("let recovery_heading = gtk::Label::new(Some(\"Recover\"));")); assert!( UI_LAYOUT_SRC @@ -291,11 +304,16 @@ fn relay_controls_keep_connect_inline_with_server_entry() { .contains("let recovery_buttons = gtk::Box::new(gtk::Orientation::Horizontal, 8);") ); assert!(UI_LAYOUT_SRC.contains("recovery_buttons.set_homogeneous(true);")); - assert!(UI_LAYOUT_SRC.contains("recovery_heading.set_width_chars(10);")); + assert!( + UI_LAYOUT_SRC.contains("recovery_heading.set_width_chars(RELAY_SUBGROUP_LABEL_WIDTH);") + ); assert!(UI_LAYOUT_SRC.contains( - "let calibration_heading = gtk::Label::new(Some(\"AV Upstream\\nCalibration\"));" + "let calibration_heading = gtk::Label::new(Some(\"Audio/Video\\nUpstream\\nCalibration\"));" )); - assert!(UI_LAYOUT_SRC.contains("calibration_heading.set_width_chars(12);")); + assert!(UI_LAYOUT_SRC.contains("calibration_heading.set_justify(gtk::Justification::Center);")); + assert!( + UI_LAYOUT_SRC.contains("calibration_heading.set_width_chars(RELAY_SUBGROUP_LABEL_WIDTH);") + ); assert!(UI_LAYOUT_SRC.contains("let calibration_buttons = gtk::Grid::new();")); assert!(UI_LAYOUT_SRC.contains("calibration_buttons.set_column_homogeneous(true);")); assert!(UI_LAYOUT_SRC.contains("let calibration_default_button = rail_button(")); @@ -313,7 +331,7 @@ fn relay_controls_keep_connect_inline_with_server_entry() { .contains("let tools_buttons = gtk::Box::new(gtk::Orientation::Horizontal, 8);") ); assert!(UI_LAYOUT_SRC.contains("tools_buttons.set_homogeneous(true);")); - assert!(UI_LAYOUT_SRC.contains("tools_heading.set_width_chars(10);")); + assert!(UI_LAYOUT_SRC.contains("tools_heading.set_width_chars(RELAY_SUBGROUP_LABEL_WIDTH);")); assert!(UI_LAYOUT_SRC.contains("let clipboard_button = rail_button(\"Clipboard\"")); assert!(UI_LAYOUT_SRC.contains("let usb_recover_button = rail_button(\"USB\"")); assert!(UI_LAYOUT_SRC.contains("let uac_recover_button = rail_button(\"UAC\"")); @@ -324,19 +342,23 @@ fn relay_controls_keep_connect_inline_with_server_entry() { assert!( source_index("let recovery_heading = gtk::Label::new(Some(\"Recover\"));") < source_index( - "let calibration_heading = gtk::Label::new(Some(\"AV Upstream\\nCalibration\"));" + "let calibration_heading = gtk::Label::new(Some(\"Audio/Video\\nUpstream\\nCalibration\"));" ) ); assert!( source_index( - "let calibration_heading = gtk::Label::new(Some(\"AV Upstream\\nCalibration\"));" + "let calibration_heading = gtk::Label::new(Some(\"Audio/Video\\nUpstream\\nCalibration\"));" ) < source_index("let power_heading = gtk::Label::new(Some(\"GPIO Power\"));") ); assert!(UI_LAYOUT_SRC.contains("tools_buttons.append(&clipboard_button);")); assert!(!UI_LAYOUT_SRC.contains("Gate Probe")); assert!(UI_LAYOUT_SRC.contains("text.set_ellipsize(pango::EllipsizeMode::End);")); assert!( - source_index("relay_grid.attach(&server_entry, 0, 0, 2, 1);") + source_index("relay_grid.attach(&server_entry, 0, 0, 1, 1);") + < source_index("relay_grid.attach(&certs_button, 1, 0, 1, 1);") + ); + assert!( + source_index("relay_grid.attach(&certs_button, 1, 0, 1, 1);") < source_index("relay_grid.attach(&start_button, 2, 0, 1, 1);") ); } diff --git a/testing/tests/client_launcher_runtime_contract.rs b/testing/tests/client_launcher_runtime_contract.rs index 4f7215f..cdb6f24 100644 --- a/testing/tests/client_launcher_runtime_contract.rs +++ b/testing/tests/client_launcher_runtime_contract.rs @@ -165,6 +165,10 @@ fn launcher_webcam_quality_selection_reaches_preview_and_relay_env() { #[test] fn launcher_utility_buttons_still_bind_to_live_actions() { assert!(UI_SRC.contains("widgets.clipboard_button.connect_clicked")); + assert!(UI_SRC.contains("certs_button.connect_clicked")); + assert!(UI_SRC.contains("Choose Lesavka Client TLS Bundle")); + assert!(UI_SRC.contains("install_client_pki_bundle(&bundle)")); + assert!(UI_SRC.contains(".config/lesavka/pki")); assert!(UI_SRC.contains("send_clipboard_text_to_remote(&server_addr, &text)")); assert!(UI_SRC.contains("Start the relay before sending clipboard text.")); assert!(!UI_SRC.contains("widgets.probe_button.connect_clicked")); @@ -181,6 +185,14 @@ fn launcher_utility_buttons_still_bind_to_live_actions() { assert!(UI_SRC.contains("Recover USB 2/3: relay acknowledged reset.")); } +#[test] +fn launcher_normalizes_bare_relay_addresses_to_https() { + assert!(UI_RUNTIME_SRC.contains("pub fn normalize_server_addr(raw: &str) -> String")); + assert!(UI_RUNTIME_SRC.contains("trimmed.contains(\"://\")")); + assert!(UI_RUNTIME_SRC.contains("format!(\"https://{trimmed}\")")); + assert!(UI_RUNTIME_SRC.contains("entry.set_text(&normalized);")); +} + #[test] fn server_chip_distinguishes_reachable_from_connected() { assert!(UI_RUNTIME_SRC.contains("fn server_light_state(")); diff --git a/testing/tests/server_install_script_contract.rs b/testing/tests/server_install_script_contract.rs index b9f1320..11eba18 100644 --- a/testing/tests/server_install_script_contract.rs +++ b/testing/tests/server_install_script_contract.rs @@ -221,6 +221,9 @@ fn server_install_generates_mtls_identity_and_client_bundle() { "extendedKeyUsage = clientAuth", "LESAVKA_CLIENT_BUNDLE", "lesavka-client-pki.tar.gz", + "Client TLS bundle:", + "Client install can use:", + "sudo chown \"$ORIG_USER\":\"$ORIG_USER\" \"$LESAVKA_CLIENT_BUNDLE\"", "LESAVKA_REQUIRE_TLS=%s", "LESAVKA_TLS_CLIENT_CA=%s", ] {