From 20204ee3840c71d2c039dd1c3ed69c3a3106f3bc Mon Sep 17 00:00:00 2001 From: Irfan Hardiyanto <52022757+devhardiyanto@users.noreply.github.com> Date: Mon, 27 Jul 2026 03:34:07 +0700 Subject: [PATCH 1/5] feat(infra): resolve dan pasang binary Caddy on-first-run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fondasi Phase 12 (Sites Proxy): modul `proxy::binary` yang mengunduh binary Caddy dari GitHub release resmi saat pertama dibutuhkan, lalu memasangnya di `/bin`. Versi dipin (2.11.4) dengan digest SHA-512 hard-code per target OS+arch, jadi verifikasi bukan trust-on-first-use — arsip yang tidak cocok dibatalkan sebelum menyentuh disk. Rilis Caddy memublikasikan SHA-512, bukan SHA-256. Penulisan lewat file sementara + rename supaya tak pernah ada binary setengah-tertulis yang terlihat sudah terpasang. `caddy_command()` membuang `JAVA_HOME` dari environment proses anak: bila ter-set, truststore Caddy mencoba menulis ke cacerts JDK lewat keytool (butuh admin), gagal, lalu membatalkan seluruh instalasi root CA termasuk ke trust store OS. Terbukti empiris di spike T0. Download-on-first-run dipilih ketimbang bundling: dampak ke ukuran binary hanya ~2 MB (reqwest/rustls + sha2 + zip) dibanding ~50 MB per-OS kalau caddy ikut dibundel. Command baru: proxy_binary_status, proxy_binary_install. Event baru: proxy-binary-progress. devhardiyanto --- src-tauri/Cargo.lock | 375 +++++++++++++++++++++++++++++++++- src-tauri/Cargo.toml | 9 + src-tauri/src/lib.rs | 3 + src-tauri/src/proxy/binary.rs | 355 ++++++++++++++++++++++++++++++++ src-tauri/src/proxy/mod.rs | 6 + 5 files changed, 746 insertions(+), 2 deletions(-) create mode 100644 src-tauri/src/proxy/binary.rs create mode 100644 src-tauri/src/proxy/mod.rs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 0f0867c..8244f28 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -47,6 +47,15 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -453,6 +462,23 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core", +] + [[package]] name = "chrono" version = "0.4.45" @@ -543,6 +569,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -671,6 +706,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "derive_more" version = "2.1.1" @@ -1244,8 +1290,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -1267,10 +1315,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", + "rand_core", "wasip2", "wasip3", + "wasm-bindgen", ] [[package]] @@ -1535,6 +1586,22 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -2012,6 +2079,12 @@ version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "markup5ever" version = "0.38.0" @@ -2722,6 +2795,62 @@ dependencies = [ "memchr", ] +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "bytes", + "getrandom 0.4.2", + "lru-slab", + "rand", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + [[package]] name = "quote" version = "1.0.45" @@ -2743,6 +2872,32 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core", +] + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -2818,6 +2973,44 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + [[package]] name = "reqwest" version = "0.13.4" @@ -2876,6 +3069,20 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rustc-hash" version = "2.1.2" @@ -2904,12 +3111,53 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -3100,6 +3348,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "serde_with" version = "3.21.0" @@ -3158,16 +3418,21 @@ dependencies = [ name = "servel" version = "1.4.3" dependencies = [ + "flate2", "notify", "notify-debouncer-mini", + "reqwest 0.12.28", "serde", "serde_json", + "sha2", + "tar", "tauri", "tauri-build", "tauri-plugin-dialog", "tauri-plugin-opener", "tokio", "windows-sys 0.59.0", + "zip", ] [[package]] @@ -3186,7 +3451,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -3324,6 +3589,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "swift-rs" version = "1.0.7" @@ -3440,6 +3711,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "target-lexicon" version = "0.12.16" @@ -3476,7 +3758,7 @@ dependencies = [ "percent-encoding", "plist", "raw-window-handle", - "reqwest", + "reqwest 0.13.4", "serde", "serde_json", "serde_repr", @@ -3886,6 +4168,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -4205,6 +4497,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -4441,6 +4739,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "web_atoms" version = "0.2.4" @@ -4497,6 +4805,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webview2-com" version = "0.38.2" @@ -4736,6 +5053,15 @@ dependencies = [ "windows-targets 0.48.5", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -5223,6 +5549,16 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "yoke" version = "0.8.3" @@ -5328,6 +5664,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + [[package]] name = "zerotrie" version = "0.2.4" @@ -5361,12 +5703,41 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap 2.14.0", + "memchr", + "thiserror 2.0.18", + "zopfli", +] + [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + [[package]] name = "zvariant" version = "5.12.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 42dc720..781a932 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -26,7 +26,16 @@ serde_json = "1" tokio = { version = "1", features = ["full"] } notify = "6" notify-debouncer-mini = "0.4" +# Unduh + verifikasi binary Caddy (reverse proxy Sites). rustls dipilih supaya +# tidak menarik OpenSSL sebagai dependensi build di Windows. +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } +sha2 = "0.10" [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.59", features = ["Win32_System_Diagnostics_Debug"] } +zip = { version = "2", default-features = false, features = ["deflate"] } + +[target.'cfg(not(windows))'.dependencies] +flate2 = "1" +tar = "0.4" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5d0d252..dcd3afd 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,4 +1,5 @@ mod commands; +mod proxy; mod tray; mod watcher; @@ -88,6 +89,8 @@ pub fn run() { commands::hosts::sites_status, commands::hosts::sites_apply, commands::hosts::sites_restore, + proxy::binary::proxy_binary_status, + proxy::binary::proxy_binary_install, ]) .on_window_event(|window, event| { if let tauri::WindowEvent::CloseRequested { api, .. } = event { diff --git a/src-tauri/src/proxy/binary.rs b/src-tauri/src/proxy/binary.rs new file mode 100644 index 0000000..8aa35ff --- /dev/null +++ b/src-tauri/src/proxy/binary.rs @@ -0,0 +1,355 @@ +//! Resolusi & instalasi binary Caddy — engine reverse proxy Sites. +//! +//! Strategi: **download-on-first-run** dari GitHub release resmi. Versi dipin +//! (`CADDY_VERSION`) dan digest **SHA-512** di-hard-code per-target, jadi +//! verifikasi bukan trust-on-first-use — file yang tidak cocok dibuang sebelum +//! menyentuh disk. Rilis Caddy memublikasikan SHA-512, bukan SHA-256 (temuan F4 +//! di `docs/reports/impl/phase-12-t0-spike.md`). + +use std::io::{Cursor, Read, Write}; +use std::path::PathBuf; +use std::process::Stdio; + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha512}; +use tauri::{AppHandle, Emitter, Manager}; +use tokio::process::Command; + +pub const CADDY_VERSION: &str = "2.11.4"; + +const RELEASE_BASE: &str = "https://github.com/caddyserver/caddy/releases/download"; + +/// Arsip rilis untuk satu target OS+arch, beserta digest SHA-512 resminya. +struct Target { + /// Nama file arsip di GitHub release. + asset: &'static str, + /// Digest SHA-512 lowercase-hex dari `caddy__checksums.txt`. + sha512: &'static str, +} + +/// Target arsip untuk platform yang sedang dikompilasi. +/// +/// Sengaja tidak menebak arsitektur lain: kalau target belum didukung, gagal +/// eksplisit supaya tidak ada unduhan yang tak bisa diverifikasi. +fn target() -> Result { + #[cfg(all(target_os = "windows", target_arch = "x86_64"))] + return Ok(Target { + asset: "caddy_2.11.4_windows_amd64.zip", + sha512: "cd5ccfd86a4b40732cf715890d0dca5bf3f63adefec5a7914de85adf240c60ce7e5d2791631b88ef9758e46b23bb1730e020b9c5d696889740b284ffd4788e35", + }); + + #[cfg(all(target_os = "windows", target_arch = "aarch64"))] + return Ok(Target { + asset: "caddy_2.11.4_windows_arm64.zip", + sha512: "582ad4657223ecd52a627d88b9d5a0cc051a0289546427659e878c57db3b4f44cedf9edd8ad6efcf29aa20a156d72626db00dfb64caccfe207bbebea2a0773c4", + }); + + #[cfg(all(target_os = "macos", target_arch = "x86_64"))] + return Ok(Target { + asset: "caddy_2.11.4_mac_amd64.tar.gz", + sha512: "e04eb10f9ce7e2e079bc9bff1bd5d3a3164888d1edbb1a49e5d15be4eab691b57e89ed36bb29c65ba43f1ba8d9279e0967b1003991c13fe4cb78384c3caf25de", + }); + + #[cfg(all(target_os = "macos", target_arch = "aarch64"))] + return Ok(Target { + asset: "caddy_2.11.4_mac_arm64.tar.gz", + sha512: "3190ae0df98b59ab4b6021556fa35adc3c526a4f3e138776b0eaec8a037cc26121cbbb1ad53453f565551b47d37d5ba4755e2c2c3652256737fe2ce9e53c8ec0", + }); + + #[cfg(all(target_os = "linux", target_arch = "x86_64"))] + return Ok(Target { + asset: "caddy_2.11.4_linux_amd64.tar.gz", + sha512: "8220d1f013b6f27510247b2360c9e0ca9f018feebd82515f07635318b34ff9777ccc8fd0b6e6f2486ce3a33fe389fbb7db12d05baa474f4587509fb4f5ebf1c9", + }); + + #[cfg(all(target_os = "linux", target_arch = "aarch64"))] + return Ok(Target { + asset: "caddy_2.11.4_linux_arm64.tar.gz", + sha512: "d5a7c423853c24a799765e0e8210d5c7c22a8f56ed37a3cae2fb9f58be138853c02b4efd6b59d576e6d8c7c0d30b9c1592deeaa6a536ff69bcca23b8c1ea709c", + }); + + #[allow(unreachable_code)] + Err(format!( + "Platform ini belum didukung untuk reverse proxy ({} {}).", + std::env::consts::OS, + std::env::consts::ARCH + )) +} + +/// Direktori tempat binary caddy disimpan: `/bin`. +fn caddy_dir(app: &AppHandle) -> Result { + let dir = app + .path() + .app_data_dir() + .map_err(|e| format!("gagal resolve app_data_dir: {}", e))?; + Ok(dir.join("bin")) +} + +/// Path lengkap binary caddy. File-nya belum tentu ada — cek `.is_file()`. +pub fn caddy_path(app: &AppHandle) -> Result { + let name = if cfg!(target_os = "windows") { + "caddy.exe" + } else { + "caddy" + }; + Ok(caddy_dir(app)?.join(name)) +} + +/// `Command` siap pakai untuk binary caddy. +/// +/// `JAVA_HOME` **dibuang**: kalau ter-set, pustaka truststore Caddy mencoba +/// menulis ke `cacerts` JDK lewat `keytool.exe` (butuh admin), gagal, lalu +/// membatalkan seluruh instalasi root CA — termasuk ke trust store OS +/// (temuan F1 di `docs/reports/impl/phase-12-t0-spike.md`). +pub fn caddy_command(bin: &PathBuf) -> Command { + let mut cmd = Command::new(bin); + cmd.env_remove("JAVA_HOME"); + + #[cfg(target_os = "windows")] + { + const CREATE_NO_WINDOW: u32 = 0x08000000; + cmd.creation_flags(CREATE_NO_WINDOW); + } + + #[cfg(not(target_os = "windows"))] + { + // `caddy trust` memanggil tool OS (security / update-ca-certificates); + // app yang dilaunch dari GUI tak mewarisi PATH shell interaktif. + cmd.env("PATH", crate::commands::util::resolved_path()); + } + + cmd +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +#[serde(rename_all = "snake_case")] +pub struct ProxyBinaryStatus { + pub installed: bool, + /// Versi hasil `caddy version`; `None` bila binary belum ada atau gagal diprobe. + pub version: Option, + /// Versi yang diharapkan Servel (dipin). + pub expected_version: String, + pub path: String, +} + +#[derive(Serialize, Clone)] +struct DownloadProgress { + downloaded: u64, + /// 0 bila server tak mengirim `Content-Length`. + total: u64, +} + +async fn probe_version(bin: &PathBuf) -> Option { + let output = caddy_command(bin) + .arg("version") + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .output() + .await + .ok()?; + + if !output.status.success() { + return None; + } + crate::commands::util::extract_semver(&String::from_utf8_lossy(&output.stdout)) +} + +#[tauri::command] +pub async fn proxy_binary_status(app: AppHandle) -> Result { + let path = caddy_path(&app)?; + let installed = path.is_file(); + let version = if installed { + probe_version(&path).await + } else { + None + }; + + Ok(ProxyBinaryStatus { + installed, + version, + expected_version: CADDY_VERSION.to_string(), + path: path.to_string_lossy().to_string(), + }) +} + +/// Unduh arsip rilis ke memori sambil meng-emit progres. +async fn download(app: &AppHandle, url: &str) -> Result, String> { + let response = reqwest::get(url) + .await + .map_err(|e| format!("Gagal mengunduh Caddy: {}", e))?; + + if !response.status().is_success() { + return Err(format!( + "Gagal mengunduh Caddy: server menjawab {}.", + response.status() + )); + } + + let total = response.content_length().unwrap_or(0); + let mut buf: Vec = Vec::with_capacity(total as usize); + let mut response = response; + + while let Some(chunk) = response + .chunk() + .await + .map_err(|e| format!("Unduhan Caddy terputus: {}", e))? + { + buf.extend_from_slice(&chunk); + let _ = app.emit( + "proxy-binary-progress", + DownloadProgress { + downloaded: buf.len() as u64, + total, + }, + ); + } + + Ok(buf) +} + +fn verify(bytes: &[u8], expected: &str) -> Result<(), String> { + let mut hasher = Sha512::new(); + hasher.update(bytes); + let actual = hex(&hasher.finalize()); + + if actual != expected { + return Err( + "Verifikasi Caddy gagal: sidik jari file tidak cocok. Unduhan dibatalkan.".to_string(), + ); + } + Ok(()) +} + +fn hex(bytes: &[u8]) -> String { + let mut out = String::with_capacity(bytes.len() * 2); + for b in bytes { + out.push_str(&format!("{:02x}", b)); + } + out +} + +/// Ambil binary `caddy` dari arsip. Layout rilis Caddy: binary berada di root arsip. +#[cfg(target_os = "windows")] +fn extract(archive: &[u8]) -> Result, String> { + let mut zip = zip::ZipArchive::new(Cursor::new(archive)) + .map_err(|e| format!("Arsip Caddy rusak: {}", e))?; + let mut entry = zip + .by_name("caddy.exe") + .map_err(|_| "Arsip Caddy tidak berisi caddy.exe.".to_string())?; + + let mut out = Vec::with_capacity(entry.size() as usize); + entry + .read_to_end(&mut out) + .map_err(|e| format!("Gagal membaca caddy.exe dari arsip: {}", e))?; + Ok(out) +} + +#[cfg(not(target_os = "windows"))] +fn extract(archive: &[u8]) -> Result, String> { + let decoder = flate2::read::GzDecoder::new(Cursor::new(archive)); + let mut tar = tar::Archive::new(decoder); + + for entry in tar + .entries() + .map_err(|e| format!("Arsip Caddy rusak: {}", e))? + { + let mut entry = entry.map_err(|e| format!("Arsip Caddy rusak: {}", e))?; + let is_caddy = entry + .path() + .map(|p| p.as_os_str() == "caddy") + .unwrap_or(false); + + if is_caddy { + let mut out = Vec::new(); + entry + .read_to_end(&mut out) + .map_err(|e| format!("Gagal membaca caddy dari arsip: {}", e))?; + return Ok(out); + } + } + + Err("Arsip Caddy tidak berisi binary caddy.".to_string()) +} + +/// Tulis binary ke disk lewat file sementara lalu rename — supaya tak pernah ada +/// caddy setengah-tertulis yang terlihat "installed" oleh [`is_installed`]. +fn install_to_disk(dest: &PathBuf, bytes: &[u8]) -> Result<(), String> { + let dir = dest + .parent() + .ok_or_else(|| "path binary Caddy tidak valid".to_string())?; + std::fs::create_dir_all(dir).map_err(|e| format!("gagal membuat direktori bin: {}", e))?; + + let tmp = dest.with_extension("download"); + { + let mut file = + std::fs::File::create(&tmp).map_err(|e| format!("gagal menulis binary Caddy: {}", e))?; + file.write_all(bytes) + .map_err(|e| format!("gagal menulis binary Caddy: {}", e))?; + } + + #[cfg(not(target_os = "windows"))] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o755)) + .map_err(|e| format!("gagal set permission binary Caddy: {}", e))?; + } + + // Windows menolak rename ke file yang sudah ada. + if dest.exists() { + let _ = std::fs::remove_file(dest); + } + std::fs::rename(&tmp, dest).map_err(|e| format!("gagal memasang binary Caddy: {}", e))?; + Ok(()) +} + +/// Unduh + verifikasi + pasang binary Caddy. Idempoten: kalau versi yang dipin +/// sudah terpasang, tidak mengunduh ulang. +#[tauri::command] +pub async fn proxy_binary_install(app: AppHandle) -> Result { + let dest = caddy_path(&app)?; + + if dest.is_file() && probe_version(&dest).await.as_deref() == Some(CADDY_VERSION) { + return proxy_binary_status(app).await; + } + + let target = target()?; + let url = format!("{}/v{}/{}", RELEASE_BASE, CADDY_VERSION, target.asset); + + let archive = download(&app, &url).await?; + verify(&archive, target.sha512)?; + let binary = extract(&archive)?; + install_to_disk(&dest, &binary)?; + + proxy_binary_status(app).await +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hex_is_lowercase_and_padded() { + assert_eq!(hex(&[0x00, 0x0f, 0xff]), "000fff"); + } + + #[test] + fn verify_rejects_mismatch() { + let err = verify(b"halo", "deadbeef").unwrap_err(); + assert!(err.contains("Verifikasi Caddy gagal")); + } + + #[test] + fn verify_accepts_known_digest() { + // SHA-512 dari string kosong. + let empty = "cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce\ + 47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e"; + verify(b"", empty).unwrap(); + } + + #[test] + fn target_is_defined_for_this_platform() { + // Build host selalu salah satu target yang didukung; kalau tidak, ini + // memberi sinyal lebih awal daripada gagal saat runtime. + assert!(target().is_ok()); + } +} diff --git a/src-tauri/src/proxy/mod.rs b/src-tauri/src/proxy/mod.rs new file mode 100644 index 0000000..1d479c1 --- /dev/null +++ b/src-tauri/src/proxy/mod.rs @@ -0,0 +1,6 @@ +//! Lapisan reverse proxy Sites (Phase 12) — engine Caddy sebagai child-process. +//! +//! Pembagian: `binary` = resolusi/instalasi binary caddy. Generator Caddyfile +//! dan lifecycle proses menyusul di modul terpisah. + +pub mod binary; From 0a4ae9c35b9997a121f2c085ac3c37f906af690f Mon Sep 17 00:00:00 2001 From: Irfan Hardiyanto <52022757+devhardiyanto@users.noreply.github.com> Date: Mon, 27 Jul 2026 04:23:47 +0700 Subject: [PATCH 2/5] feat(tauri): tambah target proxy pada Site + migrasi config v3 ke v4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Site` kini punya `target: Option` untuk menandai ke mana domain diteruskan reverse proxy. Bentuk on-disk sengaja tagged (`{ "kind": "port", "value": 5173 }`) supaya varian lain — mis. docroot untuk mode Valet penuh yang di-descope dari v1.5 — bisa menyusul tanpa migrasi ulang. Migrasi v3 ke v4 tidak memberi target default: site lama tak punya makna proxy, jadi tetap hosts-only (`None`). Nama tetap resolve ke IP-nya, persis perilaku v1.4, dan proxy hanya menyentuh site yang ber-target. Test: migrasi v3 ke v4 mempertahankan sites apa adanya, config v4 tak kehilangan target yang sudah di-set, dan bentuk JSON target dikunci. Assertion versi di test v2 ke v3 diikat ke CURRENT_VERSION supaya tidak lapuk tiap kali skema naik. devhardiyanto --- src-tauri/src/commands/config.rs | 89 ++++++++++++++++++++++++++++++-- src-tauri/src/commands/hosts.rs | 2 + 2 files changed, 88 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/commands/config.rs b/src-tauri/src/commands/config.rs index 6e3be1f..ecd550c 100644 --- a/src-tauri/src/commands/config.rs +++ b/src-tauri/src/commands/config.rs @@ -16,6 +16,18 @@ pub struct Profile { pub service_ids: Vec, } +/// Tujuan routing sebuah site di balik reverse proxy. +/// +/// Bentuknya sengaja tagged (`{ "kind": "port", "value": 5173 }`) supaya bisa +/// tumbuh tanpa migrasi ulang — mis. varian `docroot` untuk mode Valet penuh, +/// yang di-descope dari v1.5 (lihat keputusan A1 di grooming Phase 12). +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum SiteTarget { + /// Proxy ke port lokal: `domain` → `127.0.0.1:value`. + Port { value: u16 }, +} + /// Domain lokal yang dipetakan ke sebuah IP lewat file hosts sistem. /// `id` di-generate frontend (`crypto.randomUUID`), pola sama `Profile`. /// Hosts = proyeksi dari `sites` yang `enabled`; config.json = source of truth. @@ -26,6 +38,10 @@ pub struct Site { pub domain: String, pub ip: String, pub enabled: bool, + /// `None` = site hosts-only: nama resolve ke `ip`, tapi tak ada yang + /// mem-proxy-kan. Perilaku site v1.4 ke bawah, dan default hasil migrasi. + #[serde(default)] + pub target: Option, } #[derive(Serialize, Deserialize, Clone, Debug)] @@ -70,8 +86,11 @@ fn default_true() -> bool { /// profil "Default" berisi selection existing, set `active_profile_id`. /// - v2 → v3: perkenalkan `sites` (fitur hosts M11). Config lama tanpa field /// ini di-isi `[]` oleh serde default — tak butuh rebuild, cukup bump versi. +/// - v3 → v4: perkenalkan `Site.target` (reverse proxy M12). Site lama tak +/// punya makna proxy, jadi sengaja **tidak** diberi target default — serde +/// mengisinya `None` dan site tetap hosts-only. Cukup bump versi. /// -/// Idempotent: config yang sudah v3 & punya profil valid dibiarkan apa adanya. +/// Idempotent: config yang sudah v4 & punya profil valid dibiarkan apa adanya. fn migrate(mut cfg: ConfigState) -> ConfigState { if cfg.version == 0 { cfg.version = 1; @@ -115,7 +134,7 @@ fn project_active_selection(cfg: &mut ConfigState) { const DEFAULT_PROFILE_ID: &str = "default"; /// Skema config terkini. Naikkan saat ada perubahan struktur yang butuh migrasi. -const CURRENT_VERSION: u32 = 3; +const CURRENT_VERSION: u32 = 4; impl Default for ConfigState { fn default() -> Self { @@ -311,6 +330,7 @@ mod tests { domain: "myapp.test".to_string(), ip: "127.0.0.1".to_string(), enabled: true, + target: Some(SiteTarget::Port { value: 5173 }), }], last_php_version: Some("8.3".to_string()), last_node_version: Some("20.0.0".to_string()), @@ -345,6 +365,10 @@ mod tests { assert_eq!(restored.sites[0].domain, "myapp.test"); assert_eq!(restored.sites[0].ip, "127.0.0.1"); assert!(restored.sites[0].enabled); + assert_eq!( + restored.sites[0].target, + Some(SiteTarget::Port { value: 5173 }) + ); assert_eq!(restored.last_php_version, original.last_php_version); assert_eq!(restored.last_node_version, original.last_node_version); assert_eq!(restored.watched_path, original.watched_path); @@ -453,6 +477,65 @@ mod tests { assert_eq!(cfg.profiles[0].service_ids, vec!["mysql"]); } + #[test] + fn test_migrate_v3_to_v4_keeps_sites_hosts_only() { + // Config v3 (sites sudah ada, belum kenal target) → naik ke v4 tanpa + // memberi target apa pun. Site lama tak punya makna proxy. + let json = r#"{ + "version": 3, + "selectedServiceIds": ["mysql"], + "profiles": [{ "id": "default", "name": "Default", "serviceIds": ["mysql"] }], + "activeProfileId": "default", + "sites": [ + { "id": "s-1", "domain": "myapp.test", "ip": "127.0.0.1", "enabled": true }, + { "id": "s-2", "domain": "admin.test", "ip": "127.0.0.1", "enabled": false } + ] + }"#; + + let cfg: ConfigState = serde_json::from_str(json).expect("parse gagal"); + let cfg = migrate(cfg); + + assert_eq!(cfg.version, 4); + assert_eq!(cfg.sites.len(), 2); + assert!(cfg.sites.iter().all(|s| s.target.is_none())); + // Sisa isi site tak boleh berubah. + assert_eq!(cfg.sites[0].domain, "myapp.test"); + assert!(cfg.sites[0].enabled); + assert!(!cfg.sites[1].enabled); + } + + #[test] + fn test_migrate_v4_preserves_existing_target() { + // Config yang sudah v4 tak boleh kehilangan target yang sudah di-set. + let json = r#"{ + "version": 4, + "profiles": [{ "id": "default", "name": "Default", "serviceIds": [] }], + "activeProfileId": "default", + "sites": [ + { "id": "s-1", "domain": "myapp.test", "ip": "127.0.0.1", "enabled": true, + "target": { "kind": "port", "value": 5173 } } + ] + }"#; + + let cfg: ConfigState = serde_json::from_str(json).expect("parse gagal"); + let cfg = migrate(cfg); + + assert_eq!(cfg.version, 4); + assert_eq!(cfg.sites[0].target, Some(SiteTarget::Port { value: 5173 })); + } + + #[test] + fn test_site_target_json_shape_is_tagged() { + // Bentuk on-disk dikunci: { "kind": "port", "value": N }. Varian lain + // (mis. docroot) menyusul tanpa migrasi ulang. + let target = SiteTarget::Port { value: 8000 }; + let json = serde_json::to_string(&target).expect("serialize gagal"); + assert_eq!(json, r#"{"kind":"port","value":8000}"#); + + let back: SiteTarget = serde_json::from_str(&json).expect("deserialize gagal"); + assert_eq!(back, target); + } + #[test] fn test_migrate_v2_idempotent() { // Config v2 dgn profil custom aktif tidak boleh diubah/ditimpa. @@ -516,7 +599,7 @@ mod tests { let cfg: ConfigState = serde_json::from_str(json).expect("parse gagal"); assert!(cfg.sites.is_empty()); let cfg = migrate(cfg); - assert_eq!(cfg.version, 3); + assert_eq!(cfg.version, CURRENT_VERSION); assert!(cfg.sites.is_empty()); // Profil existing dipertahankan (migrasi v2→v3 tak menyentuh profiles). assert_eq!(cfg.profiles.len(), 1); diff --git a/src-tauri/src/commands/hosts.rs b/src-tauri/src/commands/hosts.rs index 4f45a46..3c99d49 100644 --- a/src-tauri/src/commands/hosts.rs +++ b/src-tauri/src/commands/hosts.rs @@ -644,6 +644,8 @@ mod tests { domain: domain.to_string(), ip: ip.to_string(), enabled, + // Hosts tak peduli target proxy — itu urusan lapisan proxy. + target: None, } } From b8d0b1e752960cb00b38b2186ce2fd71107907e5 Mon Sep 17 00:00:00 2001 From: Irfan Hardiyanto <52022757+devhardiyanto@users.noreply.github.com> Date: Mon, 27 Jul 2026 04:28:01 +0700 Subject: [PATCH 3/5] feat(tauri): generator Caddyfile dari daftar sites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Modul murni `proxy::caddyfile` yang memproyeksikan sites menjadi config Caddy. Tanpa I/O dan tanpa state, jadi seluruh perilakunya bisa diuji tanpa menjalankan Caddy. Bentuk config mengikuti yang sudah divalidasi di spike T0: mode HTTPS memakai `skip_install_trust` supaya Caddy tidak memasang root CA sendiri saat start — pemasangan sertifikat jadi langkah eksplisit dari UI, bukan dialog yang muncul tiba-tiba. Mode HttpOnly memakai `auto_https off` sebagai fallback saat user menolak atau gagal memasang sertifikat. Hanya site yang aktif dan ber-target yang masuk; site hosts-only dilewati. Domain disaring lewat pagar karakter supaya isi config.json tak pernah bisa menyuntikkan direktif ke Caddyfile, dan port 0 ditolak. Domain kembar diringkas jadi satu blok karena Caddy menolak site-address yang sama dua kali. Output di-sort per domain, jadi config yang sama secara semantik selalu menghasilkan teks identik — reload hanya perlu saat isinya benar-benar berubah. Konstanta dan `TlsMode` sementara belum terpakai; konsumennya menyusul di lifecycle proxy. devhardiyanto --- src-tauri/src/proxy/caddyfile.rs | 195 +++++++++++++++++++++++++++++++ src-tauri/src/proxy/mod.rs | 5 +- 2 files changed, 198 insertions(+), 2 deletions(-) create mode 100644 src-tauri/src/proxy/caddyfile.rs diff --git a/src-tauri/src/proxy/caddyfile.rs b/src-tauri/src/proxy/caddyfile.rs new file mode 100644 index 0000000..7bfc155 --- /dev/null +++ b/src-tauri/src/proxy/caddyfile.rs @@ -0,0 +1,195 @@ +//! Proyeksi `sites` → Caddyfile. Murni: tanpa I/O, tanpa state, hasilnya hanya +//! bergantung pada input — jadi bisa diuji tanpa menjalankan Caddy. +//! +//! Config yang dihasilkan mengikuti bentuk yang sudah divalidasi di spike T0 +//! (`docs/reports/impl/phase-12-t0-spike.md` §3). + +use crate::commands::config::{Site, SiteTarget}; + +/// Port admin API Caddy — dipakai untuk hot-reload tanpa mematikan proses. +pub const ADMIN_ADDR: &str = "localhost:2019"; + +/// Upstream selalu loopback: v1.5 hanya melayani dev server di mesin yang sama +/// (keputusan A7 grooming Phase 12). +const UPSTREAM_HOST: &str = "127.0.0.1"; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TlsMode { + /// HTTPS dengan CA lokal Caddy. `skip_install_trust` menahan Caddy memasang + /// root CA sendiri saat start — pemasangan jadi langkah eksplisit dari UI, + /// supaya dialog sertifikat tidak muncul tiba-tiba (temuan F3 spike T0). + Https, + /// Fallback saat user menolak/gagal memasang sertifikat. `auto_https off` + /// mematikan redirect ke HTTPS, jadi domain tetap bisa dipakai lewat HTTP. + HttpOnly, +} + +/// Site yang benar-benar diproyeksikan ke Caddyfile: aktif, ber-target, domain +/// aman, dan port masuk akal. +fn routable(site: &Site) -> Option<(&str, u16)> { + if !site.enabled { + return None; + } + let SiteTarget::Port { value } = site.target.as_ref()?; + if *value == 0 || !is_safe_domain(&site.domain) { + return None; + } + Some((site.domain.as_str(), *value)) +} + +/// Domain hanya boleh berisi karakter yang tak bisa keluar dari konteksnya di +/// Caddyfile. Bukan validasi DNS penuh — ini pagar supaya isi config.json tak +/// pernah bisa menyuntikkan direktif. +fn is_safe_domain(domain: &str) -> bool { + !domain.is_empty() + && domain.len() <= 253 + && !domain.starts_with('.') + && !domain.ends_with('.') + && !domain.contains("..") + && domain + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '.') +} + +/// Render Caddyfile untuk seluruh site yang routable. +/// +/// Urutan blok di-sort per domain, jadi konfigurasi yang sama secara semantik +/// selalu menghasilkan teks yang sama — reload hanya perlu dilakukan kalau +/// isinya benar-benar berubah. +pub fn render(sites: &[Site], mode: TlsMode) -> String { + let mut routes: Vec<(&str, u16)> = sites.iter().filter_map(routable).collect(); + routes.sort_by(|a, b| a.0.cmp(b.0)); + routes.dedup_by(|a, b| a.0 == b.0); + + let mut out = String::new(); + out.push_str("# File ini di-generate oleh Servel. Perubahan manual akan ditimpa.\n"); + out.push_str("{\n"); + out.push_str(&format!("\tadmin {}\n", ADMIN_ADDR)); + match mode { + TlsMode::Https => out.push_str("\tskip_install_trust\n"), + TlsMode::HttpOnly => out.push_str("\tauto_https off\n"), + } + out.push_str("}\n"); + + for (domain, port) in routes { + out.push('\n'); + match mode { + TlsMode::Https => { + out.push_str(&format!("{} {{\n", domain)); + out.push_str("\ttls internal\n"); + } + TlsMode::HttpOnly => out.push_str(&format!("http://{} {{\n", domain)), + } + out.push_str(&format!( + "\treverse_proxy {}:{}\n", + UPSTREAM_HOST, port + )); + out.push_str("}\n"); + } + + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn site(domain: &str, enabled: bool, target: Option) -> Site { + Site { + id: domain.to_string(), + domain: domain.to_string(), + ip: "127.0.0.1".to_string(), + enabled, + target, + } + } + + fn port(domain: &str, value: u16) -> Site { + site(domain, true, Some(SiteTarget::Port { value })) + } + + #[test] + fn renders_https_block_per_site() { + let out = render(&[port("myapp.test", 5173)], TlsMode::Https); + assert!(out.contains("admin localhost:2019")); + assert!(out.contains("skip_install_trust")); + assert!(out.contains("myapp.test {")); + assert!(out.contains("tls internal")); + assert!(out.contains("reverse_proxy 127.0.0.1:5173")); + assert!(!out.contains("auto_https off")); + } + + #[test] + fn http_only_mode_disables_auto_https() { + let out = render(&[port("myapp.test", 5173)], TlsMode::HttpOnly); + assert!(out.contains("auto_https off")); + assert!(out.contains("http://myapp.test {")); + assert!(!out.contains("tls internal")); + assert!(!out.contains("skip_install_trust")); + } + + #[test] + fn skips_disabled_and_targetless_sites() { + let sites = vec![ + site("off.test", false, Some(SiteTarget::Port { value: 3000 })), + site("hosts-only.test", true, None), + port("ok.test", 8000), + ]; + let out = render(&sites, TlsMode::Https); + assert!(out.contains("ok.test {")); + assert!(!out.contains("off.test")); + assert!(!out.contains("hosts-only.test")); + } + + #[test] + fn skips_invalid_port_and_unsafe_domain() { + let sites = vec![ + port("zero.test", 0), + port("spasi buruk.test", 3000), + port("brace{.test", 3000), + port(".leading.test", 3000), + port("double..dot.test", 3000), + port("ok.test", 3000), + ]; + let out = render(&sites, TlsMode::Https); + assert!(out.contains("ok.test {")); + assert!(!out.contains("zero.test")); + assert!(!out.contains("spasi")); + assert!(!out.contains("brace")); + assert!(!out.contains("leading.test")); + assert!(!out.contains("double")); + } + + #[test] + fn zero_routable_sites_still_yields_valid_global_block() { + let out = render(&[], TlsMode::Https); + assert!(out.contains("admin localhost:2019")); + assert!(!out.contains("reverse_proxy")); + } + + #[test] + fn output_is_stable_regardless_of_input_order() { + let a = render( + &[port("b.test", 2), port("a.test", 1), port("c.test", 3)], + TlsMode::Https, + ); + let b = render( + &[port("c.test", 3), port("b.test", 2), port("a.test", 1)], + TlsMode::Https, + ); + assert_eq!(a, b); + + // Sekaligus kunci urutannya: a → b → c. + let pos = |needle: &str| a.find(needle).expect("blok domain harus ada"); + assert!(pos("a.test {") < pos("b.test {")); + assert!(pos("b.test {") < pos("c.test {")); + } + + #[test] + fn duplicate_domain_yields_single_block() { + // Caddy menolak site-address kembar; config.json bisa saja punya dua + // entri domain sama, jadi generator yang membereskan. + let out = render(&[port("dup.test", 1111), port("dup.test", 2222)], TlsMode::Https); + assert_eq!(out.matches("dup.test {").count(), 1); + } +} diff --git a/src-tauri/src/proxy/mod.rs b/src-tauri/src/proxy/mod.rs index 1d479c1..4f9921e 100644 --- a/src-tauri/src/proxy/mod.rs +++ b/src-tauri/src/proxy/mod.rs @@ -1,6 +1,7 @@ //! Lapisan reverse proxy Sites (Phase 12) — engine Caddy sebagai child-process. //! -//! Pembagian: `binary` = resolusi/instalasi binary caddy. Generator Caddyfile -//! dan lifecycle proses menyusul di modul terpisah. +//! Pembagian: `binary` = resolusi/instalasi binary caddy, `caddyfile` = +//! proyeksi sites → config (murni, tanpa I/O). Lifecycle proses menyusul. pub mod binary; +pub mod caddyfile; From fbde65cea357e187ead7e8f6b3f208c5b2596805 Mon Sep 17 00:00:00 2001 From: Irfan Hardiyanto <52022757+devhardiyanto@users.noreply.github.com> Date: Mon, 27 Jul 2026 05:11:05 +0700 Subject: [PATCH 4/5] feat(tauri): lifecycle proxy Caddy start/stop/reload + pasang sertifikat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Command baru: proxy_status, proxy_check_ports, proxy_start, proxy_stop, proxy_reload, proxy_install_cert. Semua mengembalikan Result dan meng-emit `proxy-status-changed` saat status berubah. Start memprobe :80 dan :443 lebih dulu supaya konflik dengan IIS atau web server lain muncul sebagai pesan yang bisa ditindaklanjuti, bukan proses yang mati diam-diam. Config divalidasi lewat `caddy adapt` sebelum dipakai, dengan alasan yang sama. Mode TLS ditentukan dari kondisi trust store: HTTPS hanya kalau root CA lokal sudah dipercaya OS, selain itu HTTP-only. Lebih baik jalan tanpa TLS daripada terlihat rusak di browser. Pemasangan sertifikat jadi aksi eksplisit; user menolak dialognya bukan error fatal, proxy tetap melayani lewat HTTP. Reload memakai admin API sehingga perubahan site diterapkan tanpa mematikan proses. Stop mencoba jalur admin API dulu agar port dilepas bersih, baru kill sebagai cadangan. Proses juga dihentikan saat app exit — tanpa itu :80/:443 bisa nyangkut setelah Servel ditutup. Deteksi sertifikat baru diimplementasi untuk Windows (store CurrentUser, tempat Caddy memasangnya tanpa elevation). macOS/Linux sengaja dilaporkan belum terpasang sampai jalurnya diuji empiris, jadi keduanya jalan HTTP-only yang selalu aman. devhardiyanto --- src-tauri/src/lib.rs | 16 +- src-tauri/src/proxy/lifecycle.rs | 359 +++++++++++++++++++++++++++++++ src-tauri/src/proxy/mod.rs | 4 +- 3 files changed, 377 insertions(+), 2 deletions(-) create mode 100644 src-tauri/src/proxy/lifecycle.rs diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index dcd3afd..100b827 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -63,6 +63,7 @@ pub fn run() { .manage(watcher::default_watcher_state()) .manage(Mutex::new(HashMap::::new())) .manage(Mutex::new(ConfigState::default())) + .manage(proxy::lifecycle::ProxyProcess::default()) .invoke_handler(tauri::generate_handler![ commands::prereq::check_prerequisites, commands::prereq::start_docker, @@ -91,6 +92,12 @@ pub fn run() { commands::hosts::sites_restore, proxy::binary::proxy_binary_status, proxy::binary::proxy_binary_install, + proxy::lifecycle::proxy_status, + proxy::lifecycle::proxy_check_ports, + proxy::lifecycle::proxy_start, + proxy::lifecycle::proxy_stop, + proxy::lifecycle::proxy_reload, + proxy::lifecycle::proxy_install_cert, ]) .on_window_event(|window, event| { if let tauri::WindowEvent::CloseRequested { api, .. } = event { @@ -319,9 +326,16 @@ pub fn run() { // Set flag shutdown saat app exit / Windows session-end → kedua loop polling // break di awal iterasi berikutnya, hindari spawn docker.exe saat teardown. - app.run(move |_handle, event| { + app.run(move |handle, event| { if let tauri::RunEvent::ExitRequested { .. } | tauri::RunEvent::Exit = event { run_shutting_down.store(true, Ordering::Relaxed); + + // Caddy adalah child-process yang memegang :80/:443. Tanpa stop + // eksplisit, port bisa nyangkut setelah Servel ditutup. + let handle = handle.clone(); + tauri::async_runtime::block_on(async move { + let _ = proxy::lifecycle::stop_process(&handle).await; + }); } }); } diff --git a/src-tauri/src/proxy/lifecycle.rs b/src-tauri/src/proxy/lifecycle.rs new file mode 100644 index 0000000..5e921a5 --- /dev/null +++ b/src-tauri/src/proxy/lifecycle.rs @@ -0,0 +1,359 @@ +//! Lifecycle proses Caddy: start / stop / reload / status, plus pemasangan +//! sertifikat CA lokal. +//! +//! Caddy dijalankan sebagai **child-process** (bukan Docker service) supaya +//! `127.0.0.1:PORT` milik host bisa dijangkau apa adanya. Konsekuensinya Servel +//! yang memegang lifecycle-nya — termasuk memastikan proses ikut mati saat app +//! ditutup, agar port `:80`/`:443` tidak nyangkut. + +use std::path::PathBuf; +use std::process::Stdio; +use std::sync::Mutex; + +use serde::Serialize; +use tauri::{AppHandle, Emitter, Manager}; +use tokio::process::Child; + +use super::binary::{caddy_command, caddy_path}; +use super::caddyfile::{render, TlsMode, ADMIN_ADDR}; +use crate::commands::config::load_config; +use crate::commands::services::probe_host_port; + +/// Port yang dibind Caddy. Konflik di sini bikin start gagal total, jadi +/// diprobe lebih dulu supaya pesannya jelas alih-alih "proses mati sendiri". +const HTTP_PORT: u16 = 80; +const HTTPS_PORT: u16 = 443; + +/// Handle proses caddy yang sedang berjalan. `None` = proxy mati. +#[derive(Default)] +pub struct ProxyProcess(pub Mutex>); + +#[derive(Serialize, Clone, Debug)] +#[serde(rename_all = "snake_case")] +pub struct ProxyStatus { + pub running: bool, + /// `true` saat berjalan dengan HTTPS, `false` saat fallback HTTP-only. + pub https: bool, + /// Jumlah site yang benar-benar diproyeksikan ke Caddyfile. + pub routed_sites: usize, + /// Sertifikat CA lokal Caddy sudah terpasang di trust store OS. + pub cert_installed: bool, +} + +#[derive(Serialize, Clone, Debug)] +#[serde(rename_all = "snake_case")] +pub struct PortInUse { + pub port: u16, +} + +fn caddyfile_path(app: &AppHandle) -> Result { + let dir = app + .path() + .app_data_dir() + .map_err(|e| format!("gagal resolve app_data_dir: {}", e))? + .join("proxy"); + std::fs::create_dir_all(&dir).map_err(|e| format!("gagal membuat direktori proxy: {}", e))?; + Ok(dir.join("Caddyfile")) +} + +/// Tulis Caddyfile hasil proyeksi config, kembalikan path + jumlah rute. +fn write_caddyfile(app: &AppHandle, mode: TlsMode) -> Result<(PathBuf, usize), String> { + let cfg = load_config(app)?; + let content = render(&cfg.sites, mode); + let routed = content.matches("reverse_proxy ").count(); + + let path = caddyfile_path(app)?; + std::fs::write(&path, &content).map_err(|e| format!("gagal menulis Caddyfile: {}", e))?; + Ok((path, routed)) +} + +/// Mode TLS yang dipakai: HTTPS hanya kalau CA lokal sudah dipercaya OS. +/// Tanpa itu browser akan memblokir dengan peringatan, jadi lebih baik jalan +/// HTTP-only daripada terlihat rusak. +fn mode_for(app: &AppHandle) -> TlsMode { + if cert_installed(app) { + TlsMode::Https + } else { + TlsMode::HttpOnly + } +} + +/// Root CA lokal Caddy dianggap terpasang bila ada di trust store OS. +/// Windows: cek store `CurrentUser\Root` — di situlah Caddy memasangnya, tanpa +/// elevation (temuan F2 spike T0). +#[cfg(target_os = "windows")] +fn cert_installed(_app: &AppHandle) -> bool { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x08000000; + + std::process::Command::new("powershell.exe") + .args([ + "-NoProfile", + "-NonInteractive", + "-Command", + "if (Get-ChildItem Cert:\\CurrentUser\\Root | \ + Where-Object { $_.Subject -like '*Caddy Local Authority*' }) { exit 0 } else { exit 1 }", + ]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .creation_flags(CREATE_NO_WINDOW) + .status() + .map(|s| s.success()) + .unwrap_or(false) +} + +/// macOS/Linux: jalur pemasangan CA berbeda per-distro dan belum diuji empiris +/// (lihat §5 spike T0). Sampai itu terverifikasi, anggap belum terpasang → +/// proxy jalan HTTP-only, yang selalu aman. +#[cfg(not(target_os = "windows"))] +fn cert_installed(_app: &AppHandle) -> bool { + false +} + +fn is_running(app: &AppHandle) -> bool { + let state = app.state::(); + let mut guard = state.0.lock().unwrap(); + + match guard.as_mut() { + // `try_wait` Ok(Some(_)) = proses sudah keluar sendiri (mis. crash); + // bersihkan handle supaya status tidak bohong. + Some(child) => match child.try_wait() { + Ok(Some(_)) => { + *guard = None; + false + } + Ok(None) => true, + Err(_) => false, + }, + None => false, + } +} + +fn emit_status(app: &AppHandle, status: &ProxyStatus) { + let _ = app.emit("proxy-status-changed", status.clone()); +} + +fn status_of(app: &AppHandle) -> ProxyStatus { + let running = is_running(app); + // Sekali probe saja: cek trust store men-spawn proses, dan status ini + // dipanggil berulang dari UI. + let cert = cert_installed(app); + let mode = if cert { TlsMode::Https } else { TlsMode::HttpOnly }; + let routed = load_config(app) + .map(|cfg| render(&cfg.sites, mode).matches("reverse_proxy ").count()) + .unwrap_or(0); + + ProxyStatus { + running, + https: running && cert, + routed_sites: routed, + cert_installed: cert, + } +} + +#[tauri::command] +pub async fn proxy_status(app: AppHandle) -> Result { + Ok(status_of(&app)) +} + +/// Port yang dibutuhkan proxy tapi sedang dipakai proses lain (IIS, Skype, +/// proxy lain). Dipanggil frontend sebelum start supaya pesannya actionable. +#[tauri::command] +pub async fn proxy_check_ports(app: AppHandle) -> Result, String> { + // Kalau proxy kita sendiri yang memegang port, itu bukan konflik. + if is_running(&app) { + return Ok(Vec::new()); + } + + let mut busy = Vec::new(); + for port in [HTTP_PORT, HTTPS_PORT] { + if probe_host_port(port) { + busy.push(PortInUse { port }); + } + } + Ok(busy) +} + +#[tauri::command] +pub async fn proxy_start(app: AppHandle) -> Result { + if is_running(&app) { + return Ok(status_of(&app)); + } + + let bin = caddy_path(&app)?; + if !bin.is_file() { + return Err( + "Binary Caddy belum terpasang. Pasang dulu lewat tombol di halaman Sites.".to_string(), + ); + } + + let busy = proxy_check_ports(app.clone()).await?; + if !busy.is_empty() { + let ports: Vec = busy.iter().map(|p| p.port.to_string()).collect(); + return Err(format!( + "Port {} sedang dipakai aplikasi lain. Hentikan aplikasi itu dulu \ + (biasanya IIS, Skype, atau web server lain), lalu coba lagi.", + ports.join(" dan ") + )); + } + + let mode = mode_for(&app); + let (path, routed) = write_caddyfile(&app, mode)?; + if routed == 0 { + return Err( + "Belum ada site yang bisa di-proxy. Aktifkan minimal satu site dan isi port tujuannya." + .to_string(), + ); + } + + validate(&bin, &path).await?; + + let child = caddy_command(&bin) + .args(["run", "--config"]) + .arg(&path) + .args(["--adapter", "caddyfile"]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map_err(|e| format!("Gagal menjalankan Caddy: {}", e))?; + + { + let state = app.state::(); + *state.0.lock().unwrap() = Some(child); + } + + let status = status_of(&app); + emit_status(&app, &status); + Ok(status) +} + +/// Cek config lewat `caddy adapt` sebelum dipakai. Tanpa ini, config yang salah +/// baru ketahuan sebagai proses yang mati diam-diam beberapa saat setelah start. +async fn validate(bin: &PathBuf, config: &PathBuf) -> Result<(), String> { + let output = caddy_command(bin) + .arg("adapt") + .arg("--config") + .arg(config) + .args(["--adapter", "caddyfile"]) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .output() + .await + .map_err(|e| format!("Gagal memvalidasi konfigurasi Caddy: {}", e))?; + + if output.status.success() { + return Ok(()); + } + Err(format!( + "Konfigurasi proxy tidak valid: {}", + String::from_utf8_lossy(&output.stderr).trim() + )) +} + +#[tauri::command] +pub async fn proxy_stop(app: AppHandle) -> Result { + stop_process(&app).await?; + let status = status_of(&app); + emit_status(&app, &status); + Ok(status) +} + +/// Hentikan caddy lewat admin API supaya port dilepas bersih; kalau itu gagal +/// (mis. admin API tak responsif), jatuh ke kill langsung. +pub async fn stop_process(app: &AppHandle) -> Result<(), String> { + if let Ok(bin) = caddy_path(app) { + if bin.is_file() { + let _ = caddy_command(&bin) + .args(["stop", "--address", ADMIN_ADDR]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .await; + } + } + + let mut child = { + let state = app.state::(); + let mut guard = state.0.lock().unwrap(); + guard.take() + }; + + if let Some(child) = child.as_mut() { + if matches!(child.try_wait(), Ok(None)) { + let _ = child.kill().await; + } + } + Ok(()) +} + +/// Terapkan perubahan sites ke proxy yang sedang jalan tanpa mematikan proses +/// (hot-reload lewat admin API). Kalau proxy belum jalan, tak ada yang perlu +/// dilakukan — start berikutnya sudah membaca config terbaru. +#[tauri::command] +pub async fn proxy_reload(app: AppHandle) -> Result { + if !is_running(&app) { + return Ok(status_of(&app)); + } + + let bin = caddy_path(&app)?; + let (path, _) = write_caddyfile(&app, mode_for(&app))?; + validate(&bin, &path).await?; + + let output = caddy_command(&bin) + .arg("reload") + .arg("--config") + .arg(&path) + .args(["--adapter", "caddyfile", "--address", ADMIN_ADDR]) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .output() + .await + .map_err(|e| format!("Gagal memuat ulang konfigurasi proxy: {}", e))?; + + if !output.status.success() { + return Err(format!( + "Gagal memuat ulang konfigurasi proxy: {}", + String::from_utf8_lossy(&output.stderr).trim() + )); + } + + let status = status_of(&app); + emit_status(&app, &status); + Ok(status) +} + +/// Pasang root CA lokal Caddy ke trust store OS supaya `https://` +/// dipercaya browser. Di Windows ini memunculkan dialog konfirmasi sertifikat +/// (bukan UAC) — user bisa menolak, dan penolakan bukan error fatal: proxy +/// tetap bisa jalan HTTP-only. +#[tauri::command] +pub async fn proxy_install_cert(app: AppHandle) -> Result { + let bin = caddy_path(&app)?; + if !bin.is_file() { + return Err("Binary Caddy belum terpasang.".to_string()); + } + + let output = caddy_command(&bin) + .arg("trust") + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .output() + .await + .map_err(|e| format!("Gagal memasang sertifikat: {}", e))?; + + if !output.status.success() { + return Err( + "Sertifikat tidak jadi dipasang. Servel tetap bisa melayani domain lewat HTTP." + .to_string(), + ); + } + + // Trust berubah → mode TLS ikut berubah, jadi config harus dirender ulang. + if is_running(&app) { + return proxy_reload(app).await; + } + + let status = status_of(&app); + emit_status(&app, &status); + Ok(status) +} diff --git a/src-tauri/src/proxy/mod.rs b/src-tauri/src/proxy/mod.rs index 4f9921e..2d0b5a0 100644 --- a/src-tauri/src/proxy/mod.rs +++ b/src-tauri/src/proxy/mod.rs @@ -1,7 +1,9 @@ //! Lapisan reverse proxy Sites (Phase 12) — engine Caddy sebagai child-process. //! //! Pembagian: `binary` = resolusi/instalasi binary caddy, `caddyfile` = -//! proyeksi sites → config (murni, tanpa I/O). Lifecycle proses menyusul. +//! proyeksi sites → config (murni, tanpa I/O), `lifecycle` = start/stop/reload +//! proses caddy + pemasangan sertifikat. pub mod binary; pub mod caddyfile; +pub mod lifecycle; From 4df5b9d4e1d7293945c37e706fbd98e919bb59f7 Mon Sep 17 00:00:00 2001 From: Irfan Hardiyanto <52022757+devhardiyanto@users.noreply.github.com> Date: Mon, 27 Jul 2026 05:38:05 +0700 Subject: [PATCH 5/5] feat(frontend): UI target port per site dan kontrol reverse proxy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Daftar domain kini punya kolom port tujuan. Dikosongkan berarti site hosts-only — nama tetap resolve, tapi tak ada yang mem-proxy-kan, dan baris itu ditandai "tanpa proxy" supaya bedanya terlihat. Port divalidasi 1 sampai 65535 sebelum disimpan. Section Reverse Proxy baru menampilkan tiga keadaan berurutan: engine belum terpasang (dengan progres unduhan), proxy jalan atau berhenti beserta jumlah domain yang dilayani dan mode HTTP/HTTPS-nya, lalu status sertifikat lokal. Tombol jalankan dinonaktifkan saat belum ada domain yang layak di-proxy, dengan alasannya di tooltip — lebih baik daripada membiarkan backend menolak setelah diklik. Composable `useProxy` mendengarkan `proxy-status-changed`, jadi status di UI ikut berubah tanpa polling. Setiap mutasi site memicu reload proxy kalau sedang jalan, didahului flush config supaya backend membaca versi terbaru dari disk, bukan yang tertinggal di debounce. Pesan error dari backend ditampilkan apa adanya karena sudah Bahasa Indonesia dan menyebut langkah perbaikannya. devhardiyanto --- src/composables/useConfig.ts | 6 + src/composables/useProxy.ts | 100 ++++++++++++++++ src/composables/useSites.ts | 17 ++- src/views/Settings.vue | 216 ++++++++++++++++++++++++++++++++++- 4 files changed, 331 insertions(+), 8 deletions(-) create mode 100644 src/composables/useProxy.ts diff --git a/src/composables/useConfig.ts b/src/composables/useConfig.ts index 047966c..fbb0361 100644 --- a/src/composables/useConfig.ts +++ b/src/composables/useConfig.ts @@ -9,11 +9,17 @@ export interface Profile { // Domain lokal → IP via file hosts (fitur Sites, M11). Hosts = proyeksi sites // yang enabled; config.json = source of truth. Apply ke hosts bersifat manual. +// Tujuan routing site di balik reverse proxy (mirror `SiteTarget` Rust). +// Tagged supaya varian lain bisa menyusul tanpa migrasi config ulang. +export type SiteTarget = { kind: 'port'; value: number } + export interface Site { id: string domain: string ip: string enabled: boolean + // null = site hosts-only: nama resolve, tapi tak ada yang mem-proxy-kan. + target?: SiteTarget | null } export interface ConfigState { diff --git a/src/composables/useProxy.ts b/src/composables/useProxy.ts new file mode 100644 index 0000000..088cfca --- /dev/null +++ b/src/composables/useProxy.ts @@ -0,0 +1,100 @@ +import { ref } from 'vue' +import { invoke } from '@tauri-apps/api/core' +import { useTauri } from './useTauri' + +// Status binary Caddy (mirror `ProxyBinaryStatus` Rust). +export interface ProxyBinaryStatus { + installed: boolean + version: string | null + expected_version: string + path: string +} + +// Status proxy (mirror `ProxyStatus` Rust). +export interface ProxyStatus { + running: boolean + https: boolean + routed_sites: number + cert_installed: boolean +} + +// Progres unduhan binary (mirror `DownloadProgress` Rust). `total` 0 = tak diketahui. +export interface ProxyDownloadProgress { + downloaded: number + total: number +} + +export interface PortInUse { + port: number +} + +export function useProxy() { + const { on } = useTauri() + + const status = ref(null) + const binary = ref(null) + const progress = ref(null) + + // Backend meng-emit setiap kali status berubah (start/stop/reload/cert), jadi + // UI tak perlu polling. + void on('proxy-status-changed', (payload) => { + status.value = payload + }) + + void on('proxy-binary-progress', (payload) => { + progress.value = payload + }) + + async function refresh(): Promise { + const [s, b] = await Promise.all([ + invoke('proxy_status').catch(() => null), + invoke('proxy_binary_status').catch(() => null), + ]) + status.value = s + binary.value = b + } + + // Semua aksi di bawah THROWS saat gagal — pesan error dari backend sudah + // Bahasa Indonesia dan actionable, jadi UI menampilkannya apa adanya. + async function installBinary(): Promise { + progress.value = null + binary.value = await invoke('proxy_binary_install') + progress.value = null + await refresh() + } + + async function checkPorts(): Promise { + return await invoke('proxy_check_ports') + } + + async function start(): Promise { + status.value = await invoke('proxy_start') + } + + async function stop(): Promise { + status.value = await invoke('proxy_stop') + } + + // Terapkan perubahan site ke proxy yang sedang jalan. Aman dipanggil saat + // proxy mati — backend mengabaikannya. + async function reload(): Promise { + status.value = await invoke('proxy_reload') + } + + async function installCert(): Promise { + status.value = await invoke('proxy_install_cert') + } + + return { + status, + binary, + progress, + refresh, + installBinary, + checkPorts, + start, + stop, + reload, + installCert, + } +} diff --git a/src/composables/useSites.ts b/src/composables/useSites.ts index 9f6cef2..5295d6e 100644 --- a/src/composables/useSites.ts +++ b/src/composables/useSites.ts @@ -1,6 +1,6 @@ import { computed } from 'vue' import { invoke } from '@tauri-apps/api/core' -import { useConfig, type Site } from './useConfig' +import { useConfig, type Site, type SiteTarget } from './useConfig' // Entri hosts hasil proyeksi (mirror `HostEntry` di Rust). export interface HostEntry { @@ -37,14 +37,24 @@ export function isValidDomain(domain: string): boolean { return /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*$/.test(d) } +// Port tujuan proxy: 1–65535. String kosong = site hosts-only (tanpa proxy). +export function parsePort(input: string): SiteTarget | null { + const t = input.trim() + if (t === '') return null + if (!/^\d{1,5}$/.test(t)) return null + const value = Number(t) + if (value < 1 || value > 65535) return null + return { kind: 'port', value } +} + export function useSites() { const { config, scheduleSave } = useConfig() const sites = computed(() => config.value.sites) - function addSite(domain: string, ip = '127.0.0.1'): string { + function addSite(domain: string, ip = '127.0.0.1', target: SiteTarget | null = null): string { const id = crypto.randomUUID() - config.value.sites.push({ id, domain: domain.trim(), ip: ip.trim(), enabled: true }) + config.value.sites.push({ id, domain: domain.trim(), ip: ip.trim(), enabled: true, target }) scheduleSave() return id } @@ -55,6 +65,7 @@ export function useSites() { if (patch.domain !== undefined) s.domain = patch.domain.trim() if (patch.ip !== undefined) s.ip = patch.ip.trim() if (patch.enabled !== undefined) s.enabled = patch.enabled + if (patch.target !== undefined) s.target = patch.target scheduleSave() } diff --git a/src/views/Settings.vue b/src/views/Settings.vue index 2229702..133d0b6 100644 --- a/src/views/Settings.vue +++ b/src/views/Settings.vue @@ -1,5 +1,5 @@