release: ship lesavka 0.16.1

This commit is contained in:
Brad Stein 2026-04-30 11:38:16 -03:00
parent 9ec915f91c
commit ec3edf525a
21 changed files with 330 additions and 62 deletions

6
Cargo.lock generated
View File

@ -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",

View File

@ -4,7 +4,7 @@ path = "src/main.rs"
[package]
name = "lesavka_client"
version = "0.16.0"
version = "0.16.1"
edition = "2024"
[dependencies]

View File

@ -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<PathBuf, String> {
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();

View File

@ -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,

View File

@ -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(),

View File

@ -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,

View File

@ -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(&gtk::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,

View File

@ -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;

View File

@ -39,9 +39,22 @@ pub fn selected_server_addr(entry: &gtk::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}")
}
}

View File

@ -39,30 +39,49 @@ fn client_tls_config(server_addr: &str) -> Result<ClientTlsConfig> {
.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<PathBuf>,
cert_path: &Option<PathBuf>,
key_path: &Option<PathBuf>,
) -> 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<PathBuf>) -> 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"));
},
);
}
}

View File

@ -1,6 +1,6 @@
[package]
name = "lesavka_common"
version = "0.16.0"
version = "0.16.1"
edition = "2024"
build = "build.rs"

View File

@ -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 |

View File

@ -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,

View File

@ -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,

View File

@ -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:"

View File

@ -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)}"

View File

@ -10,7 +10,7 @@ bench = false
[package]
name = "lesavka_server"
version = "0.16.0"
version = "0.16.1"
edition = "2024"
autobins = false

View File

@ -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"
);
}

View File

@ -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);")
);
}

View File

@ -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("));

View File

@ -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",
] {