diff --git a/Cargo.lock b/Cargo.lock index 88f9d18..37e0caf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1652,7 +1652,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "lesavka_client" -version = "0.22.49" +version = "0.22.50" dependencies = [ "anyhow", "async-stream", @@ -1686,7 +1686,7 @@ dependencies = [ [[package]] name = "lesavka_common" -version = "0.22.49" +version = "0.22.50" dependencies = [ "anyhow", "base64", @@ -1698,7 +1698,7 @@ dependencies = [ [[package]] name = "lesavka_server" -version = "0.22.49" +version = "0.22.50" dependencies = [ "anyhow", "base64", diff --git a/client/Cargo.toml b/client/Cargo.toml index 7afae2c..b748fa3 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ path = "src/main.rs" [package] name = "lesavka_client" -version = "0.22.49" +version = "0.22.50" edition = "2024" [dependencies] diff --git a/common/Cargo.toml b/common/Cargo.toml index 48796e6..4809218 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lesavka_common" -version = "0.22.49" +version = "0.22.50" edition = "2024" build = "build.rs" diff --git a/docs/operational-env.md b/docs/operational-env.md index 64f123b..b8ae8f3 100644 --- a/docs/operational-env.md +++ b/docs/operational-env.md @@ -254,6 +254,7 @@ from `LESAVKA_CLIENT_PKI_SSH_SOURCE` over SSH. Runtime clients require the insta | `LESAVKA_TLS_DOMAIN` | client transport SNI/domain override when dialing by IP | | `LESAVKA_TLS_KEY` | server TLS private-key path override | | `LESAVKA_TLS_SAN` | server installer extra certificate SAN list for additional relay hostnames/IPs | +| `LESAVKA_SYNTHETIC_PKI_DIR` | server installer destination for the SSH user's local synthetic-probe mTLS identity; defaults to `~/.config/lesavka/pki` | | `LESAVKA_UAC_BUFFER_TIME_US` | server audio sink latency override | | `LESAVKA_UAC_COMPENSATION_US` | server audio sink latency override | | `LESAVKA_UAC_DEV` | server hardware/device override | diff --git a/scripts/install/server.sh b/scripts/install/server.sh index 9edd930..a96d34f 100755 --- a/scripts/install/server.sh +++ b/scripts/install/server.sh @@ -84,6 +84,7 @@ INSTALL_UVC_FRAME_META_LOG_PATH=${LESAVKA_INSTALL_UVC_FRAME_META_LOG_PATH:-${LES INSTALL_SERVER_BIND_ADDR=${LESAVKA_INSTALL_SERVER_BIND_ADDR:-0.0.0.0:50051} LESAVKA_TLS_DIR=${LESAVKA_TLS_DIR:-/etc/lesavka/pki} LESAVKA_CLIENT_BUNDLE=${LESAVKA_CLIENT_BUNDLE:-/etc/lesavka/lesavka-client-pki.tar.gz} +LESAVKA_SYNTHETIC_PKI_DIR=${LESAVKA_SYNTHETIC_PKI_DIR:-$USER_HOME/.config/lesavka/pki} LESAVKA_PASTE_KEY_FILE=${LESAVKA_PASTE_KEY_FILE:-/etc/lesavka/paste-key} DEFAULT_MJPEG_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US=0 DEFAULT_MJPEG_UPSTREAM_VIDEO_PLAYOUT_OFFSET_US=135090 @@ -505,6 +506,13 @@ ensure_server_tls_pki() { sudo tar -C "$bundle_tmp" -czf "$LESAVKA_CLIENT_BUNDLE" ca.crt client.crt client.key paste-key sudo chown "$ORIG_USER":"$ORIG_USER" "$LESAVKA_CLIENT_BUNDLE" sudo chmod 0600 "$LESAVKA_CLIENT_BUNDLE" + if [[ -n $USER_HOME && $ORIG_USER != root ]]; then + sudo install -d -m 0700 -o "$ORIG_USER" -g "$ORIG_USER" "$LESAVKA_SYNTHETIC_PKI_DIR" + sudo install -m 0644 -o "$ORIG_USER" -g "$ORIG_USER" "$LESAVKA_TLS_DIR/ca.crt" "$LESAVKA_SYNTHETIC_PKI_DIR/ca.crt" + sudo install -m 0644 -o "$ORIG_USER" -g "$ORIG_USER" "$LESAVKA_TLS_DIR/client.crt" "$LESAVKA_SYNTHETIC_PKI_DIR/client.crt" + sudo install -m 0600 -o "$ORIG_USER" -g "$ORIG_USER" "$LESAVKA_TLS_DIR/client.key" "$LESAVKA_SYNTHETIC_PKI_DIR/client.key" + echo " ↪ local synthetic probe TLS identity: $LESAVKA_SYNTHETIC_PKI_DIR" + fi sudo rm -rf "$bundle_tmp" echo " ↪ client enrollment bundle: $LESAVKA_CLIENT_BUNDLE" } diff --git a/scripts/manual/run_synthetic_rct_uvc_probe.py b/scripts/manual/run_synthetic_rct_uvc_probe.py index e058774..0161f26 100755 --- a/scripts/manual/run_synthetic_rct_uvc_probe.py +++ b/scripts/manual/run_synthetic_rct_uvc_probe.py @@ -31,7 +31,7 @@ def parse_args() -> argparse.Namespace: ) parser.add_argument("--inject-host", default="", help="Theia SSH host, e.g. titan-jh") parser.add_argument("--rct-host", default="", help="RCT SSH host, e.g. tethys") - parser.add_argument("--server", default="http://127.0.0.1:50051") + parser.add_argument("--server", default="https://127.0.0.1:50051") parser.add_argument("--inject-binary", default="/usr/local/bin/lesavka-synthetic-uplink") parser.add_argument("--mode", default="1280x720@30", help=f"one mode; baseline set is {DEFAULT_MODES}") parser.add_argument("--width", type=int, default=0, help="override capture width") diff --git a/server/Cargo.toml b/server/Cargo.toml index d16a390..f45361f 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -16,7 +16,7 @@ bench = false [package] name = "lesavka_server" -version = "0.22.49" +version = "0.22.50" edition = "2024" autobins = false diff --git a/server/src/bin/lesavka-synthetic-uplink.rs b/server/src/bin/lesavka-synthetic-uplink.rs index 30f2d9d..de86ac7 100755 --- a/server/src/bin/lesavka-synthetic-uplink.rs +++ b/server/src/bin/lesavka-synthetic-uplink.rs @@ -12,6 +12,7 @@ use lesavka_common::lesavka::{ use tokio::sync::mpsc; use tokio_stream::wrappers::ReceiverStream; use tonic::Request; +use tonic::transport::{Certificate, Channel, ClientTlsConfig, Identity}; const DEFAULT_SERVER: &str = "http://127.0.0.1:50051"; const DEFAULT_SAMPLE_RATE: u32 = 48_000; @@ -31,6 +32,10 @@ struct Args { session_id: u64, artifact_dir: Option, print_every: u64, + tls_ca: Option, + tls_client_cert: Option, + tls_client_key: Option, + tls_domain: Option, } impl Args { @@ -45,6 +50,14 @@ impl Args { session_id: unix_millis(), artifact_dir: None, print_every: 150, + tls_ca: env_path("LESAVKA_TLS_CA").or_else(|| default_pki_path("ca.crt")), + tls_client_cert: env_path("LESAVKA_TLS_CLIENT_CERT") + .or_else(|| default_pki_path("client.crt")), + tls_client_key: env_path("LESAVKA_TLS_CLIENT_KEY") + .or_else(|| default_pki_path("client.key")), + tls_domain: std::env::var("LESAVKA_TLS_DOMAIN") + .ok() + .filter(|value| !value.trim().is_empty()), }; let mut it = std::env::args().skip(1); while let Some(flag) = it.next() { @@ -63,6 +76,14 @@ impl Args { args.artifact_dir = Some(PathBuf::from(next_value(&mut it, &flag)?)) } "--print-every" => args.print_every = parse_next(&mut it, &flag)?, + "--tls-ca" => args.tls_ca = Some(PathBuf::from(next_value(&mut it, &flag)?)), + "--tls-client-cert" => { + args.tls_client_cert = Some(PathBuf::from(next_value(&mut it, &flag)?)) + } + "--tls-client-key" => { + args.tls_client_key = Some(PathBuf::from(next_value(&mut it, &flag)?)) + } + "--tls-domain" => args.tls_domain = Some(next_value(&mut it, &flag)?), "--mode" => { let value = next_value(&mut it, &flag)?; let (width, height, fps) = parse_mode(&value)?; @@ -207,6 +228,85 @@ impl Drop for MjpegEncoder { } } +async fn connect_channel(args: &Args) -> Result { + let mut endpoint = + tonic::transport::Channel::from_shared(args.server.clone())?.tcp_nodelay(true); + if is_https(&args.server) { + endpoint = endpoint + .tls_config(client_tls_config(args)?) + .context("configuring synthetic uplink TLS")?; + } + endpoint + .connect() + .await + .with_context(|| format!("connecting to {}", args.server)) +} + +fn client_tls_config(args: &Args) -> Result { + let mut tls = ClientTlsConfig::new().domain_name( + args.tls_domain + .clone() + .or_else(|| host_from_uri(&args.server)) + .unwrap_or_else(|| "lesavka-server".to_string()), + ); + let ca_path = args + .tls_ca + .as_ref() + .context("https synthetic uplink requires --tls-ca or LESAVKA_TLS_CA")?; + let cert_path = args + .tls_client_cert + .as_ref() + .context("https synthetic uplink requires --tls-client-cert or LESAVKA_TLS_CLIENT_CERT")?; + let key_path = args + .tls_client_key + .as_ref() + .context("https synthetic uplink requires --tls-client-key or LESAVKA_TLS_CLIENT_KEY")?; + 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()))?; + Ok(tls.identity(Identity::from_pem(cert, key))) +} + +fn is_https(server: &str) -> bool { + server.trim_start().starts_with("https://") +} + +fn host_from_uri(server: &str) -> Option { + let rest = server.split_once("://")?.1; + let host_port = rest.split('/').next().unwrap_or(rest); + let host = host_port + .rsplit_once('@') + .map(|(_, host)| host) + .unwrap_or(host_port); + if host.starts_with('[') { + return host + .split_once(']') + .map(|(value, _)| value.trim_start_matches('[').to_string()); + } + Some(host.split(':').next().unwrap_or(host).to_string()).filter(|host| !host.is_empty()) +} + +fn env_path(name: &str) -> Option { + std::env::var_os(name) + .filter(|value| !value.is_empty()) + .map(PathBuf::from) +} + +fn default_pki_path(file_name: &str) -> Option { + let home = std::env::var_os("HOME")?; + Some( + PathBuf::from(home) + .join(".config") + .join("lesavka") + .join("pki") + .join(file_name), + ) +} + #[tokio::main(flavor = "current_thread")] async fn main() -> Result<()> { let args = Args::parse()?; @@ -219,11 +319,7 @@ async fn main() -> Result<()> { std::fs::write(dir.join("summary.json"), args_summary_json(&args) + "\n")?; } - let channel = tonic::transport::Channel::from_shared(args.server.clone())? - .tcp_nodelay(true) - .connect() - .await - .with_context(|| format!("connecting to {}", args.server))?; + let channel = connect_channel(&args).await?; let mut client = RelayClient::new(channel); let (tx, rx) = mpsc::channel::(8); let response_task = tokio::spawn(async move { @@ -484,13 +580,14 @@ fn unix_millis() -> u64 { fn args_summary_json(args: &Args) -> String { format!( - "{{\"schema\":\"lesavka.synthetic-uplink.v1\",\"server\":{server:?},\"width\":{width},\"height\":{height},\"fps\":{fps},\"duration_s\":{duration:.3},\"session_id\":{session}}}", + "{{\"schema\":\"lesavka.synthetic-uplink.v1\",\"server\":{server:?},\"width\":{width},\"height\":{height},\"fps\":{fps},\"duration_s\":{duration:.3},\"session_id\":{session},\"tls\":{tls}}}", server = args.server, width = args.width, height = args.height, fps = args.fps, duration = args.duration.as_secs_f64(), session = args.session_id, + tls = is_https(&args.server), ) } diff --git a/tests/installer/scripts/install/server_install_script_contract.rs b/tests/installer/scripts/install/server_install_script_contract.rs index c38e3ef..d3166bb 100644 --- a/tests/installer/scripts/install/server_install_script_contract.rs +++ b/tests/installer/scripts/install/server_install_script_contract.rs @@ -56,6 +56,7 @@ fn server_install_pins_hdmi_camera_and_display_defaults() { "LESAVKA_TLS_CERT=%s", "LESAVKA_TLS_KEY=%s", "LESAVKA_TLS_CLIENT_CA=%s", + "LESAVKA_SYNTHETIC_PKI_DIR", "LESAVKA_PASTE_KEY_FILE=%s", ] { assert!( @@ -708,6 +709,8 @@ fn server_install_generates_mtls_identity_and_client_bundle() { "Client TLS bundle:", "Client install can use:", "sudo chown \"$ORIG_USER\":\"$ORIG_USER\" \"$LESAVKA_CLIENT_BUNDLE\"", + "local synthetic probe TLS identity", + "$USER_HOME/.config/lesavka/pki", "LESAVKA_REQUIRE_TLS=%s", "LESAVKA_TLS_CLIENT_CA=%s", ] { diff --git a/tests/manual/server/rct/synthetic_rct_uvc_probe_manual_contract.rs b/tests/manual/server/rct/synthetic_rct_uvc_probe_manual_contract.rs index 8c63611..b65e973 100644 --- a/tests/manual/server/rct/synthetic_rct_uvc_probe_manual_contract.rs +++ b/tests/manual/server/rct/synthetic_rct_uvc_probe_manual_contract.rs @@ -33,6 +33,7 @@ fn synthetic_probe_keeps_bundled_network_ingress_and_rct_comparison_markers() { "lesavka.synthetic-rct-probe.orchestrator.v1", "StreamWebcamMedia", "lesavka-synthetic-uplink", + "https://127.0.0.1:50051", "--inject-host", "--rct-host", "--capture-only", @@ -80,6 +81,10 @@ fn synthetic_injector_enters_the_public_bundled_media_rpc() { "jpegenc", "client_capture_pts_us: pts_us", "client_send_pts_us: pts_us", + "ClientTlsConfig", + "--tls-ca", + "--tls-client-cert", + "--tls-client-key", "StreamWebcamMedia closed before accepting synthetic frame", ] { assert!(