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/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, } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5d0d252..100b827 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; @@ -62,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, @@ -88,6 +90,14 @@ 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, + 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 { @@ -316,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/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/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/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 new file mode 100644 index 0000000..2d0b5a0 --- /dev/null +++ b/src-tauri/src/proxy/mod.rs @@ -0,0 +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` = start/stop/reload +//! proses caddy + pemasangan sertifikat. + +pub mod binary; +pub mod caddyfile; +pub mod lifecycle; 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 @@