Add rustls TLS FFI module for jsh remote mux

ober

03e07ca68b4820fb7bc16f8646abbdf841487525

diff --git a/jerboa-native-rs/Cargo.lock b/jerboa-native-rs/Cargo.lock
index 0e36b3b..1958f77 100644
--- a/jerboa-native-rs/Cargo.lock
+++ b/jerboa-native-rs/Cargo.lock
@@ -324,7 +324,11 @@ dependencies = [
  "regex",
  "ring",
  "rusqlite",
+ "rustls",
+ "rustls-pemfile",
+ "rustls-pki-types",
  "scrypt",
+ "webpki-roots 0.26.11",
 ]
 
 [[package]]
@@ -701,6 +705,50 @@ dependencies = [
 ]
 
 [[package]]
+name = "rustls"
+version = "0.23.37"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
+dependencies = [
+ "log",
+ "once_cell",
+ "ring",
+ "rustls-pki-types",
+ "rustls-webpki",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-pemfile"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
+dependencies = [
+ "rustls-pki-types",
+]
+
+[[package]]
+name = "rustls-pki-types"
+version = "1.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
+dependencies = [
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-webpki"
+version = "0.103.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef"
+dependencies = [
+ "ring",
+ "rustls-pki-types",
+ "untrusted",
+]
+
+[[package]]
 name = "rustversion"
 version = "1.0.22"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1043,6 +1091,24 @@ dependencies = [
 ]
 
 [[package]]
+name = "webpki-roots"
+version = "0.26.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
+dependencies = [
+ "webpki-roots 1.0.6",
+]
+
+[[package]]
+name = "webpki-roots"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed"
+dependencies = [
+ "rustls-pki-types",
+]
+
+[[package]]
 name = "whoami"
 version = "2.1.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1168,3 +1234,9 @@ dependencies = [
  "quote",
  "syn",
 ]
+
+[[package]]
+name = "zeroize"
+version = "1.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
diff --git a/jerboa-native-rs/Cargo.toml b/jerboa-native-rs/Cargo.toml
index d42386b..92ccfb2 100644
--- a/jerboa-native-rs/Cargo.toml
+++ b/jerboa-native-rs/Cargo.toml
@@ -15,6 +15,10 @@ libc = "0.2"
 rusqlite = { version = "0.32", features = ["bundled"] }
 postgres = "0.19"
 inotify = { version = "0.11", default-features = false }
+rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] }
+rustls-pki-types = "1"
+rustls-pemfile = "2"
+webpki-roots = "0.26"
 
 [profile.release]
 lto = true
diff --git a/jerboa-native-rs/src/lib.rs b/jerboa-native-rs/src/lib.rs
index 7f6040c..ff27ee3 100644
--- a/jerboa-native-rs/src/lib.rs
+++ b/jerboa-native-rs/src/lib.rs
@@ -8,3 +8,4 @@ mod postgres_native;
 mod epoll;
 mod inotify_native;
 mod landlock;
+mod tls;
diff --git a/jerboa-native-rs/src/tls.rs b/jerboa-native-rs/src/tls.rs
new file mode 100644
index 0000000..d81ec3e
--- /dev/null
+++ b/jerboa-native-rs/src/tls.rs
@@ -0,0 +1,521 @@
+use std::collections::HashMap;
+use std::io::{Read, Write};
+use std::net::TcpStream;
+use std::sync::Arc;
+use std::sync::Mutex;
+
+use rustls::pki_types::{CertificateDer, PrivateKeyDer, ServerName};
+use rustls::{ClientConfig, ClientConnection, ServerConfig, ServerConnection, StreamOwned};
+
+use crate::panic::{ffi_wrap, set_last_error};
+
+// ============================================================
+// Connection handle management
+// ============================================================
+
+/// Opaque handle wrapping a TLS connection + TCP stream.
+enum TlsConn {
+    Client(StreamOwned<ClientConnection, TcpStream>),
+    Server(StreamOwned<ServerConnection, TcpStream>),
+}
+
+/// Opaque handle for a TLS server config (reused across accepts).
+struct TlsServerCtx {
+    config: Arc<ServerConfig>,
+}
+
+// Macro to create lazy-initialized mutex-protected handle maps
+macro_rules! lazy_static_handles {
+    ($($name:ident: $type:ty),* $(,)?) => {
+        $(
+            fn $name() -> &'static Mutex<$type> {
+                use std::sync::OnceLock;
+                static INSTANCE: OnceLock<Mutex<$type>> = OnceLock::new();
+                INSTANCE.get_or_init(|| Mutex::new(HashMap::new()))
+            }
+        )*
+    };
+}
+
+lazy_static_handles! {
+    conns: HashMap<u64, TlsConn>,
+    server_ctxs: HashMap<u64, TlsServerCtx>,
+}
+
+static NEXT_HANDLE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
+
+fn next_handle() -> u64 {
+    NEXT_HANDLE.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
+}
+
+// ============================================================
+// Client: connect to a remote TLS server
+// ============================================================
+
+/// Connect to host:port over TLS with system CA trust store.
+/// Returns handle ID (>0) on success, 0 on error.
+#[no_mangle]
+pub extern "C" fn jerboa_tls_connect(
+    host: *const u8,
+    host_len: usize,
+    port: u16,
+) -> u64 {
+    match std::panic::catch_unwind(|| {
+        if host.is_null() {
+            set_last_error("null host".to_string());
+            return 0;
+        }
+        let host_str = unsafe {
+            std::str::from_utf8(std::slice::from_raw_parts(host, host_len))
+        };
+        let host_str = match host_str {
+            Ok(s) => s,
+            Err(_) => {
+                set_last_error("invalid UTF-8 hostname".to_string());
+                return 0;
+            }
+        };
+
+        // Build client config with webpki root certificates
+        let root_store = rustls::RootCertStore::from_iter(
+            webpki_roots::TLS_SERVER_ROOTS.iter().cloned(),
+        );
+        let config = ClientConfig::builder()
+            .with_root_certificates(root_store)
+            .with_no_client_auth();
+
+        let server_name = match ServerName::try_from(host_str.to_string()) {
+            Ok(sn) => sn,
+            Err(e) => {
+                set_last_error(format!("invalid server name: {}", e));
+                return 0;
+            }
+        };
+
+        let conn = match ClientConnection::new(Arc::new(config), server_name) {
+            Ok(c) => c,
+            Err(e) => {
+                set_last_error(format!("TLS client init: {}", e));
+                return 0;
+            }
+        };
+
+        let addr = format!("{}:{}", host_str, port);
+        let tcp = match TcpStream::connect(&addr) {
+            Ok(s) => s,
+            Err(e) => {
+                set_last_error(format!("TCP connect {}: {}", addr, e));
+                return 0;
+            }
+        };
+
+        let stream = StreamOwned::new(conn, tcp);
+        let handle = next_handle();
+        conns().lock().unwrap().insert(handle, TlsConn::Client(stream));
+        handle
+    }) {
+        Ok(h) => h,
+        Err(_) => {
+            set_last_error("panic in tls_connect".to_string());
+            0
+        }
+    }
+}
+
+/// Connect to host:port with certificate pinning (no CA verification).
+/// pin_sha256 is the expected SHA-256 hash of the server's certificate (DER).
+/// Returns handle ID (>0) on success, 0 on error.
+#[no_mangle]
+pub extern "C" fn jerboa_tls_connect_pinned(
+    host: *const u8,
+    host_len: usize,
+    port: u16,
+    pin_sha256: *const u8,
+    pin_len: usize,
+) -> u64 {
+    match std::panic::catch_unwind(|| {
+        if host.is_null() {
+            set_last_error("null host".to_string());
+            return 0;
+        }
+        let host_str = unsafe {
+            match std::str::from_utf8(std::slice::from_raw_parts(host, host_len)) {
+                Ok(s) => s.to_string(),
+                Err(_) => {
+                    set_last_error("invalid UTF-8 hostname".to_string());
+                    return 0;
+                }
+            }
+        };
+
+        let expected_pin = if !pin_sha256.is_null() && pin_len == 32 {
+            Some(unsafe { std::slice::from_raw_parts(pin_sha256, pin_len) }.to_vec())
+        } else {
+            None
+        };
+
+        // Build config that skips CA verification (we verify via pin)
+        let config = ClientConfig::builder()
+            .dangerous()
+            .with_custom_certificate_verifier(Arc::new(PinVerifier {
+                expected_sha256: expected_pin,
+            }))
+            .with_no_client_auth();
+
+        let server_name = match ServerName::try_from(host_str.clone()) {
+            Ok(sn) => sn,
+            Err(e) => {
+                set_last_error(format!("invalid server name: {}", e));
+                return 0;
+            }
+        };
+
+        let conn = match ClientConnection::new(Arc::new(config), server_name) {
+            Ok(c) => c,
+            Err(e) => {
+                set_last_error(format!("TLS client init: {}", e));
+                return 0;
+            }
+        };
+
+        let addr = format!("{}:{}", host_str, port);
+        let tcp = match TcpStream::connect(&addr) {
+            Ok(s) => s,
+            Err(e) => {
+                set_last_error(format!("TCP connect {}: {}", addr, e));
+                return 0;
+            }
+        };
+
+        let stream = StreamOwned::new(conn, tcp);
+        let handle = next_handle();
+        conns().lock().unwrap().insert(handle, TlsConn::Client(stream));
+        handle
+    }) {
+        Ok(h) => h,
+        Err(_) => {
+            set_last_error("panic in tls_connect_pinned".to_string());
+            0
+        }
+    }
+}
+
+// Certificate pin verifier — accepts any cert whose SHA-256 matches
+#[derive(Debug)]
+struct PinVerifier {
+    expected_sha256: Option<Vec<u8>>,
+}
+
+impl rustls::client::danger::ServerCertVerifier for PinVerifier {
+    fn verify_server_cert(
+        &self,
+        end_entity: &CertificateDer<'_>,
+        _intermediates: &[CertificateDer<'_>],
+        _server_name: &ServerName<'_>,
+        _ocsp_response: &[u8],
+        _now: rustls::pki_types::UnixTime,
+    ) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
+        if let Some(ref expected) = self.expected_sha256 {
+            let digest = ring::digest::digest(&ring::digest::SHA256, end_entity.as_ref());
+            if digest.as_ref() == expected.as_slice() {
+                Ok(rustls::client::danger::ServerCertVerified::assertion())
+            } else {
+                Err(rustls::Error::General("certificate pin mismatch".to_string()))
+            }
+        } else {
+            // No pin — accept anything (insecure, for testing only)
+            Ok(rustls::client::danger::ServerCertVerified::assertion())
+        }
+    }
+
+    fn verify_tls12_signature(
+        &self,
+        _message: &[u8],
+        _cert: &CertificateDer<'_>,
+        _dss: &rustls::DigitallySignedStruct,
+    ) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
+        Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
+    }
+
+    fn verify_tls13_signature(
+        &self,
+        _message: &[u8],
+        _cert: &CertificateDer<'_>,
+        _dss: &rustls::DigitallySignedStruct,
+    ) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
+        Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
+    }
+
+    fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
+        rustls::crypto::ring::default_provider()
+            .signature_verification_algorithms
+            .supported_schemes()
+    }
+}
+
+// ============================================================
+// Server: create TLS server context and accept connections
+// ============================================================
+
+/// Create a TLS server context from cert and key PEM files.
+/// Returns context handle (>0) on success, 0 on error.
+#[no_mangle]
+pub extern "C" fn jerboa_tls_server_new(
+    cert_path: *const u8,
+    cert_path_len: usize,
+    key_path: *const u8,
+    key_path_len: usize,
+) -> u64 {
+    match std::panic::catch_unwind(|| {
+        if cert_path.is_null() || key_path.is_null() {
+            set_last_error("null cert/key path".to_string());
+            return 0;
+        }
+        let cert_str = unsafe {
+            match std::str::from_utf8(std::slice::from_raw_parts(cert_path, cert_path_len)) {
+                Ok(s) => s,
+                Err(_) => { set_last_error("invalid cert path".to_string()); return 0; }
+            }
+        };
+        let key_str = unsafe {
+            match std::str::from_utf8(std::slice::from_raw_parts(key_path, key_path_len)) {
+                Ok(s) => s,
+                Err(_) => { set_last_error("invalid key path".to_string()); return 0; }
+            }
+        };
+
+        // Read cert chain
+        let cert_file = match std::fs::File::open(cert_str) {
+            Ok(f) => f,
+            Err(e) => { set_last_error(format!("open cert: {}", e)); return 0; }
+        };
+        let certs: Vec<CertificateDer<'static>> = rustls_pemfile::certs(&mut std::io::BufReader::new(cert_file))
+            .filter_map(|r| r.ok())
+            .collect();
+        if certs.is_empty() {
+            set_last_error("no certificates found in cert file".to_string());
+            return 0;
+        }
+
+        // Read private key
+        let key_file = match std::fs::File::open(key_str) {
+            Ok(f) => f,
+            Err(e) => { set_last_error(format!("open key: {}", e)); return 0; }
+        };
+        let key = match rustls_pemfile::private_key(&mut std::io::BufReader::new(key_file)) {
+            Ok(Some(k)) => k,
+            Ok(None) => { set_last_error("no private key found".to_string()); return 0; }
+            Err(e) => { set_last_error(format!("read key: {}", e)); return 0; }
+        };
+
+        let config = match ServerConfig::builder()
+            .with_no_client_auth()
+            .with_single_cert(certs, PrivateKeyDer::from(key))
+        {
+            Ok(c) => c,
+            Err(e) => { set_last_error(format!("server config: {}", e)); return 0; }
+        };
+
+        let handle = next_handle();
+        server_ctxs().lock().unwrap().insert(handle, TlsServerCtx {
+            config: Arc::new(config),
+        });
+        handle
+    }) {
+        Ok(h) => h,
+        Err(_) => {
+            set_last_error("panic in tls_server_new".to_string());
+            0
+        }
+    }
+}
+
+/// Accept a TLS connection on an already-accepted TCP fd.
+/// Takes the server context handle and a raw fd (from accept()).
+/// Returns a connection handle (>0) on success, 0 on error.
+#[no_mangle]
+pub extern "C" fn jerboa_tls_accept(
+    server_ctx: u64,
+    fd: i32,
+) -> u64 {
+    match std::panic::catch_unwind(|| {
+        let config = {
+            let ctxs = server_ctxs().lock().unwrap();
+            match ctxs.get(&server_ctx) {
+                Some(ctx) => ctx.config.clone(),
+                None => {
+                    set_last_error("invalid server context handle".to_string());
+                    return 0;
+                }
+            }
+        };
+
+        let conn = match ServerConnection::new(config) {
+            Ok(c) => c,
+            Err(e) => {
+                set_last_error(format!("TLS server connection: {}", e));
+                return 0;
+            }
+        };
+
+        // Wrap the raw fd in a TcpStream
+        use std::os::unix::io::FromRawFd;
+        let tcp = unsafe { TcpStream::from_raw_fd(fd) };
+
+        let stream = StreamOwned::new(conn, tcp);
+        let handle = next_handle();
+        conns().lock().unwrap().insert(handle, TlsConn::Server(stream));
+        handle
+    }) {
+        Ok(h) => h,
+        Err(_) => {
+            set_last_error("panic in tls_accept".to_string());
+            0
+        }
+    }
+}
+
+// ============================================================
+// Read / Write / Close
+// ============================================================
+
+/// Read up to max_len bytes from a TLS connection.
+/// Returns bytes read (>0), 0 on EOF, -1 on error.
+#[no_mangle]
+pub extern "C" fn jerboa_tls_read(
+    handle: u64,
+    buf: *mut u8,
+    max_len: usize,
+) -> i32 {
+    ffi_wrap(|| {
+        if buf.is_null() { return -1; }
+        let out = unsafe { std::slice::from_raw_parts_mut(buf, max_len) };
+        let mut map = conns().lock().unwrap();
+        let conn = match map.get_mut(&handle) {
+            Some(c) => c,
+            None => {
+                set_last_error("invalid TLS handle".to_string());
+                return -1;
+            }
+        };
+        let n = match conn {
+            TlsConn::Client(ref mut s) => s.read(out),
+            TlsConn::Server(ref mut s) => s.read(out),
+        };
+        match n {
+            Ok(0) => 0,
+            Ok(n) => n as i32,
+            Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => 0,
+            Err(e) => {
+                set_last_error(format!("TLS read: {}", e));
+                -1
+            }
+        }
+    })
+}
+
+/// Write bytes to a TLS connection.
+/// Returns bytes written (>0), or -1 on error.
+#[no_mangle]
+pub extern "C" fn jerboa_tls_write(
+    handle: u64,
+    buf: *const u8,
+    len: usize,
+) -> i32 {
+    ffi_wrap(|| {
+        if buf.is_null() { return -1; }
+        let data = unsafe { std::slice::from_raw_parts(buf, len) };
+        let mut map = conns().lock().unwrap();
+        let conn = match map.get_mut(&handle) {
+            Some(c) => c,
+            None => {
+                set_last_error("invalid TLS handle".to_string());
+                return -1;
+            }
+        };
+        let n = match conn {
+            TlsConn::Client(ref mut s) => s.write(data),
+            TlsConn::Server(ref mut s) => s.write(data),
+        };
+        match n {
+            Ok(n) => n as i32,
+            Err(e) => {
+                set_last_error(format!("TLS write: {}", e));
+                -1
+            }
+        }
+    })
+}
+
+/// Flush pending TLS data.
+/// Returns 0 on success, -1 on error.
+#[no_mangle]
+pub extern "C" fn jerboa_tls_flush(handle: u64) -> i32 {
+    ffi_wrap(|| {
+        let mut map = conns().lock().unwrap();
+        let conn = match map.get_mut(&handle) {
+            Some(c) => c,
+            None => return -1,
+        };
+        let result = match conn {
+            TlsConn::Client(ref mut s) => s.flush(),
+            TlsConn::Server(ref mut s) => s.flush(),
+        };
+        match result {
+            Ok(()) => 0,
+            Err(e) => {
+                set_last_error(format!("TLS flush: {}", e));
+                -1
+            }
+        }
+    })
+}
+
+/// Close and free a TLS connection.
+#[no_mangle]
+pub extern "C" fn jerboa_tls_close(handle: u64) {
+    let _ = conns().lock().unwrap().remove(&handle);
+}
+
+/// Free a TLS server context.
+#[no_mangle]
+pub extern "C" fn jerboa_tls_server_free(handle: u64) {
+    let _ = server_ctxs().lock().unwrap().remove(&handle);
+}
+
+/// Set the underlying TCP stream to nonblocking mode.
+/// Returns 0 on success, -1 on error.
+#[no_mangle]
+pub extern "C" fn jerboa_tls_set_nonblock(handle: u64, nonblock: i32) -> i32 {
+    ffi_wrap(|| {
+        let mut map = conns().lock().unwrap();
+        let conn = match map.get_mut(&handle) {
+            Some(c) => c,
+            None => return -1,
+        };
+        let tcp = match conn {
+            TlsConn::Client(ref s) => s.get_ref(),
+            TlsConn::Server(ref s) => s.get_ref(),
+        };
+        match tcp.set_nonblocking(nonblock != 0) {
+            Ok(()) => 0,
+            Err(e) => {
+                set_last_error(format!("set_nonblocking: {}", e));
+                -1
+            }
+        }
+    })
+}
+
+/// Get the raw fd from a TLS connection (for poll/select).
+/// Returns fd (>=0) or -1 on error.
+#[no_mangle]
+pub extern "C" fn jerboa_tls_get_fd(handle: u64) -> i32 {
+    use std::os::unix::io::AsRawFd;
+    let map = conns().lock().unwrap();
+    match map.get(&handle) {
+        Some(TlsConn::Client(ref s)) => s.get_ref().as_raw_fd(),
+        Some(TlsConn::Server(ref s)) => s.get_ref().as_raw_fd(),
+        None => -1,
+    }
+}