diff --git a/Cargo.lock b/Cargo.lock index 2f4bf1e..53547a8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -301,6 +301,45 @@ dependencies = [ "zbus", ] +[[package]] +name = "asn1-rs" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom 7.1.3", + "num-traits", + "rusticata-macros", + "thiserror 2.0.19", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -616,6 +655,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "bincode" version = "2.0.1" @@ -646,6 +691,15 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +dependencies = [ + "serde", +] + [[package]] name = "bit_field" version = "0.10.3" @@ -1242,6 +1296,20 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom 7.1.3", + "num-bigint", + "num-traits", + "rusticata-macros", +] + [[package]] name = "deranged" version = "0.5.8" @@ -2044,6 +2112,25 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b40ca9252762c466af32d0b1002e91e4e1bc5398f77455e55474deb466355ff5" +[[package]] +name = "h2" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "half" version = "2.7.1" @@ -2207,6 +2294,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", @@ -2215,6 +2303,25 @@ dependencies = [ "pin-project-lite", "smallvec", "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", ] [[package]] @@ -2223,13 +2330,21 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ + "base64 0.22.1", "bytes", + "futures-channel", + "futures-util", "http", "http-body", "hyper", + "ipnet", + "libc", + "percent-encoding", "pin-project-lite", + "socket2 0.6.5", "tokio", "tower-service", + "tracing", ] [[package]] @@ -2814,6 +2929,32 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "36eee07d8e02bd95bf52b2e642cf13d33701b94c6e4b04fbf1d1fb07e9cb19e7" +[[package]] +name = "instant-acme" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f05ad37c421b962354c358d347d4a6130151df9407978372d3ad7f0c8f71a64" +dependencies = [ + "async-trait", + "base64 0.22.1", + "bytes", + "http", + "http-body", + "http-body-util", + "httpdate", + "hyper", + "hyper-rustls", + "hyper-util", + "rcgen 0.14.10", + "ring", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "thiserror 2.0.19", + "tokio", +] + [[package]] name = "integer-sqrt" version = "0.1.5" @@ -2845,6 +2986,12 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + [[package]] name = "itertools" version = "0.13.0" @@ -3911,6 +4058,15 @@ dependencies = [ "objc2-foundation 0.2.2", ] +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -3963,14 +4119,14 @@ name = "orbien-core" version = "3.7.0" dependencies = [ "anyhow", - "base64", + "base64 0.22.1", "bytes", "futures-util", "hex", "hmac", "kcp-tokio", "quinn", - "rcgen", + "rcgen 0.13.2", "rustls", "rustls-pemfile", "rustls-pki-types", @@ -3994,12 +4150,25 @@ name = "orbien-desktop" version = "3.7.0" dependencies = [ "anyhow", + "async-trait", + "base64 0.22.1", + "chrono", + "hex", + "hmac", + "instant-acme", "libc", "objc2 0.6.4", "orbien-client", "orbien-core", + "percent-encoding", + "rcgen 0.13.2", + "reqwest", "rfd", + "rustls", "serde", + "serde_json", + "sha1", + "sha2 0.10.9", "slint", "slint-build", "tokio", @@ -4016,7 +4185,7 @@ version = "3.7.0" dependencies = [ "anyhow", "axum", - "base64", + "base64 0.22.1", "chrono", "clap", "httparse", @@ -4137,7 +4306,17 @@ version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" dependencies = [ - "base64", + "base64 0.22.1", + "serde_core", +] + +[[package]] +name = "pem" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d354a98a3d1251555de99e8fdd8afda05573c31b82f59063a7b0a29b5527f120" +dependencies = [ + "base64 0.23.1", "serde_core", ] @@ -4743,11 +4922,25 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" dependencies = [ - "pem", + "pem 3.0.6", "ring", "rustls-pki-types", "time", - "yasna", + "yasna 0.5.2", +] + +[[package]] +name = "rcgen" +version = "0.14.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8774e05a7d0de114588e6a28fe7e71694b82614ed569d86d8b389dfbc98b8ad8" +dependencies = [ + "pem 4.0.0", + "ring", + "rustls-pki-types", + "time", + "x509-parser", + "yasna 0.6.0", ] [[package]] @@ -4833,6 +5026,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 = "resvg" version = "0.47.0" @@ -5804,6 +6035,9 @@ name = "sync_wrapper" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] [[package]] name = "synstructure" @@ -5953,6 +6187,7 @@ dependencies = [ "powerfmt", "serde_core", "time-core", + "time-macros", ] [[package]] @@ -5961,6 +6196,16 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tiny-skia" version = "0.11.4" @@ -6136,6 +6381,7 @@ dependencies = [ "futures-io", "futures-sink", "futures-util", + "libc", "pin-project-lite", "tokio", ] @@ -6258,6 +6504,24 @@ dependencies = [ "tracing", ] +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.1", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + [[package]] name = "tower-layer" version = "0.3.3" @@ -6332,6 +6596,12 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "ttf-parser" version = "0.25.1" @@ -6506,7 +6776,7 @@ version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d46cf96c5f498d36b7a9693bc6a7075c0bb9303189d61b2249b0dc3d309c07de" dependencies = [ - "base64", + "base64 0.22.1", "data-url", "flate2", "fontdb", @@ -6608,6 +6878,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -6858,6 +7137,15 @@ dependencies = [ "rustls-pki-types", ] +[[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 = "weezl" version = "0.1.12" @@ -7286,6 +7574,24 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" +[[package]] +name = "x509-parser" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom 7.1.3", + "oid-registry", + "ring", + "rusticata-macros", + "thiserror 2.0.19", + "time", +] + [[package]] name = "xattr" version = "1.6.1" @@ -7375,6 +7681,16 @@ dependencies = [ "time", ] +[[package]] +name = "yasna" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" +dependencies = [ + "bit-vec", + "time", +] + [[package]] name = "yazi" version = "0.2.1" diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index 52b42d7..08e51ac 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -21,9 +21,22 @@ tracing = { workspace = true } tracing-subscriber = { workspace = true } toml = { workspace = true } serde = { workspace = true } +serde_json = { workspace = true } rfd = "0.15" uuid = { workspace = true } libc = "0.2" +async-trait = { workspace = true } +chrono = { workspace = true } +base64 = { workspace = true } +hmac = { workspace = true } +sha2 = { workspace = true } +hex = { workspace = true } +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +instant-acme = { version = "0.8.5", default-features = false, features = ["hyper-rustls", "ring"] } +sha1 = "0.10" +percent-encoding = "2" +rcgen = { workspace = true } +rustls = { workspace = true } [target.'cfg(target_os = "macos")'.dependencies] objc2 = "0.6" diff --git a/desktop/assets/icon/cert.svg b/desktop/assets/icon/cert.svg new file mode 100644 index 0000000..2e3047d --- /dev/null +++ b/desktop/assets/icon/cert.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/desktop/i18n/en_US.properties b/desktop/i18n/en_US.properties index 6f19efd..2c6a044 100644 --- a/desktop/i18n/en_US.properties +++ b/desktop/i18n/en_US.properties @@ -36,7 +36,7 @@ tunnel-backend=Backend service tunnel-routing=Domain Routing tunnel-name=Name tunnel-name-hint=e.g. web, mysql -tunnel-type=Type +tunnel-type=Protocol tunnel-local-port=Service port tunnel-remote-port=Remote port tunnel-domains=Domains @@ -57,10 +57,64 @@ tunnel-tls-term=TLS terminate tunnel-plugin-local-addr=Local address tunnel-plugin-cert=Certificate path tunnel-plugin-key=Key path +tunnel-cert-source=Certificate type +tunnel-cert-library=Certificate store +tunnel-cert-file=Own certificate +tunnel-cert-self=Self-signed +tunnel-cert-pick=Select certificate tunnel-plugin-username=Username tunnel-plugin-password=Password tunnel-copied=Copied to clipboard +# —— cert —— +cert-title=Certificates +cert-tab-certs=Certificates +cert-tab-keys=Keys +cert-empty=No certificates +cert-key-empty=No keys +cert-valid=Valid +cert-expired=Expired +cert-detail=Details +cert-apply-title=Request certificate +cert-apply=Request +cert-issuing=Requesting… +cert-step-title=Progress +cert-step-prepare=Prepare account +cert-step-order=Create order +cert-step-dns=Write DNS challenge +cert-step-wait=Wait for DNS +cert-step-validate=ACME validation +cert-step-finalize=Issue certificate +cert-step-cleanup=Clean up DNS +cert-issuer=Issuer +cert-domains=Domains +cert-domains-hint=Comma-separated domains +cert-key=Key +cert-no-key=Add a key first +cert-key-add=Add key +cert-key-edit=Edit key +cert-key-name=Name +cert-key-vendor=Provider +cert-api-token=API Token +cert-access-key-id=AccessKey ID +cert-access-key-secret=AccessKey Secret +cert-secret-id=SecretId +cert-secret-key=SecretKey +cert-secret-keep=Leave blank to keep +cert-detail-title=Certificate details +cert-not-after=Expires +cert-saved=Saved +cert-deleted=Deleted +cert-applied=Certificate issued +cert-in-use=Certificate is used by a tunnel +cert-need-key=Add a key first +cert-domains-required=Domains are required +cert-key-name-required=Name is required +cert-secret-required=Secret is required +cert-issue-failed=Request failed: {err} +cert-issue-timeout=Request timed out, please retry +cert-issue-cancelled=Request cancelled + # —— config —— config-title=Config config-reset=Reset defaults @@ -131,7 +185,7 @@ about-slint-link=About Slint… about-license-note=UI built with Slint under the Royalty-free Desktop/Mobile/Web Applications License about-close=Close -# —— Rust / runtime messages —— +# runtime messages msg.running-label-zero=0s msg.tunnel-name-required=Tunnel name is required msg.tunnel-domain-required=Enter at least one domain @@ -141,7 +195,6 @@ msg.tunnel-local-port-invalid=Invalid service port msg.tunnel-remote-port-invalid=Invalid remote port msg.tunnel-name-exists=Tunnel name already exists msg.tunnel-persist-failed=Failed to persist tunnels: {err} -msg.tunnel-plugin-addr-required=Local address is required msg.tunnel-plugin-username-required=Username is required msg.tunnel-plugin-password-required=Password is required msg.tunnel-copied=Copied to clipboard diff --git a/desktop/i18n/zh_CN.properties b/desktop/i18n/zh_CN.properties index 6e955a6..bc64f62 100644 --- a/desktop/i18n/zh_CN.properties +++ b/desktop/i18n/zh_CN.properties @@ -34,7 +34,7 @@ tunnel-backend=后端服务 tunnel-routing=域名路由 tunnel-name=名称 tunnel-name-hint=例如 web、mysql -tunnel-type=类型 +tunnel-type=协议 tunnel-local-port=服务端口 tunnel-remote-port=远程端口 tunnel-domains=域名 @@ -55,9 +55,62 @@ tunnel-tls-term=TLS 终止 tunnel-plugin-local-addr=本地地址 tunnel-plugin-cert=证书路径 tunnel-plugin-key=私钥路径 +tunnel-cert-source=证书类型 +tunnel-cert-library=证书库 +tunnel-cert-file=自备证书 +tunnel-cert-self=临时自签 +tunnel-cert-pick=选择证书 tunnel-plugin-username=用户名 tunnel-plugin-password=密码 tunnel-copied=已复制到剪贴板 +# —— cert —— +cert-title=证书 +cert-tab-certs=证书 +cert-tab-keys=密钥 +cert-empty=暂无证书 +cert-key-empty=暂无密钥 +cert-valid=有效 +cert-expired=过期 +cert-detail=详情 +cert-apply-title=申请证书 +cert-apply=申请 +cert-issuing=申请中… +cert-step-title=申请进度 +cert-step-prepare=准备账号 +cert-step-order=创建订单 +cert-step-dns=写入 DNS 验证 +cert-step-wait=等待 DNS 生效 +cert-step-validate=ACME 校验 +cert-step-finalize=签发证书 +cert-step-cleanup=清理验证记录 +cert-issuer=证书品牌 +cert-domains=域名 +cert-domains-hint=多个域名用逗号分隔 +cert-key=密钥 +cert-no-key=请先添加密钥 +cert-key-add=添加密钥 +cert-key-edit=编辑密钥 +cert-key-name=名称 +cert-key-vendor=厂商 +cert-api-token=API Token +cert-access-key-id=AccessKey ID +cert-access-key-secret=AccessKey Secret +cert-secret-id=SecretId +cert-secret-key=SecretKey +cert-secret-keep=留空则不修改 +cert-detail-title=证书详情 +cert-not-after=到期时间 +cert-saved=已保存 +cert-deleted=已删除 +cert-applied=申请成功 +cert-in-use=证书正在被隧道使用 +cert-need-key=请先添加密钥 +cert-domains-required=请填写域名 +cert-key-name-required=请填写名称 +cert-secret-required=请填写密钥 +cert-issue-failed=申请失败: {err} +cert-issue-timeout=申请超时,请重试 +cert-issue-cancelled=已取消申请 # —— config —— config-title=配置 config-reset=重置默认 @@ -125,7 +178,7 @@ about-feedback=反馈 about-slint-link=关于界面框架 Slint… about-license-note=本界面使用 Slint 构建,并按 Slint Royalty-free Desktop/Mobile/Web Applications License 使用 about-close=关闭 -# —— Rust / runtime messages +# runtime messages msg.running-label-zero=0 秒 msg.tunnel-name-required=请填写隧道名称 msg.tunnel-domain-required=请填写至少一个域名 @@ -135,7 +188,6 @@ msg.tunnel-local-port-invalid=服务端口无效 msg.tunnel-remote-port-invalid=远程端口无效 msg.tunnel-name-exists=隧道名称已存在 msg.tunnel-persist-failed=隧道写入失败: {err} -msg.tunnel-plugin-addr-required=请填写本地地址 msg.tunnel-plugin-username-required=请填写用户名 msg.tunnel-plugin-password-required=请填写密码 msg.tunnel-copied=已复制到剪贴板 diff --git a/desktop/src/cert/dns/aliyun.rs b/desktop/src/cert/dns/aliyun.rs new file mode 100644 index 0000000..846c07f --- /dev/null +++ b/desktop/src/cert/dns/aliyun.rs @@ -0,0 +1,183 @@ +use super::DnsProvider; +use anyhow::{anyhow, bail, Context, Result}; +use async_trait::async_trait; +use base64::{engine::general_purpose::STANDARD as B64, Engine}; +use chrono::Utc; +use hmac::{Hmac, Mac}; +use percent_encoding::{utf8_percent_encode, AsciiSet, NON_ALPHANUMERIC}; +use sha1::Sha1; +use std::collections::BTreeMap; +use uuid::Uuid; + +type HmacSha1 = Hmac; + +const ENCODE_SET: &AsciiSet = &NON_ALPHANUMERIC + .remove(b'-') + .remove(b'_') + .remove(b'.') + .remove(b'~'); + +pub struct AliyunDns { + access_key_id: String, + access_key_secret: String, + client: reqwest::Client, +} + +impl AliyunDns { + pub fn new(access_key_id: &str, access_key_secret: &str) -> Result { + Ok(Self { + access_key_id: access_key_id.to_string(), + access_key_secret: access_key_secret.to_string(), + client: reqwest::Client::new(), + }) + } + + fn sign(&self, params: &BTreeMap) -> String { + let canonical: String = params + .iter() + .map(|(k, v)| format!("{}={}", enc(k), enc(v))) + .collect::>() + .join("&"); + let string_to_sign = format!("GET&{}&{}", enc("/"), enc(&canonical)); + let mut mac = HmacSha1::new_from_slice(format!("{}&", self.access_key_secret).as_bytes()) + .expect("hmac"); + mac.update(string_to_sign.as_bytes()); + B64.encode(&mac.finalize().into_bytes()) + } + + async fn call(&self, mut params: BTreeMap) -> Result { + params.insert("Format".into(), "JSON".into()); + params.insert("Version".into(), "2015-01-09".into()); + params.insert("AccessKeyId".into(), self.access_key_id.clone()); + params.insert("SignatureMethod".into(), "HMAC-SHA1".into()); + params.insert( + "Timestamp".into(), + Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(), + ); + params.insert("SignatureVersion".into(), "1.0".into()); + params.insert("SignatureNonce".into(), Uuid::new_v4().to_string()); + let signature = self.sign(¶ms); + params.insert("Signature".into(), signature); + + let query: String = params + .iter() + .map(|(k, v)| format!("{}={}", enc(k), enc(v))) + .collect::>() + .join("&"); + let url = format!("https://alidns.aliyuncs.com/?{query}"); + let text = self + .client + .get(&url) + .send() + .await + .context("aliyun dns request")? + .text() + .await + .context("aliyun dns body")?; + let v: serde_json::Value = + serde_json::from_str(&text).with_context(|| format!("aliyun dns json: {text}"))?; + if v.get("Code").is_some() { + bail!("aliyun dns error: {text}"); + } + Ok(v) + } + + async fn find_domain(&self, host: &str) -> Result<(String, String)> { + let host = host.trim_end_matches('.'); + let mut name = host.to_string(); + loop { + let mut params = BTreeMap::new(); + params.insert("Action".into(), "DescribeDomainInfo".into()); + params.insert("DomainName".into(), name.clone()); + match self.call(params).await { + Ok(_) => { + let rr = if host == name { + "@".to_string() + } else { + host.strip_suffix(&format!(".{name}")) + .unwrap_or(host) + .to_string() + }; + return Ok((name, rr)); + } + Err(_) => { + if let Some((_, rest)) = name.split_once('.') { + name = rest.to_string(); + } else { + break; + } + } + } + } + Err(anyhow!("aliyun domain not found for {host}")) + } +} + +#[async_trait] +impl DnsProvider for AliyunDns { + async fn upsert_txt(&self, host: &str, value: &str) -> Result { + let (domain, rr) = self.find_domain(host).await?; + tracing::info!(%host, %domain, %rr, "cert: aliyun resolve zone"); + + let mut params = BTreeMap::new(); + params.insert("Action".into(), "DescribeDomainRecords".into()); + params.insert("DomainName".into(), domain.clone()); + params.insert("RRKeyWord".into(), rr.clone()); + params.insert("Type".into(), "TXT".into()); + let listed = self.call(params).await?; + let records = domain_records(&listed); + for r in &records { + let content = r.get("Value").and_then(|v| v.as_str()).unwrap_or(""); + let id = r.get("RecordId").and_then(|v| v.as_str()).unwrap_or(""); + let record_rr = r.get("RR").and_then(|v| v.as_str()).unwrap_or(""); + if record_rr != rr || id.is_empty() { + continue; + } + if content == value { + tracing::info!(%host, %id, "cert: aliyun txt already present"); + return Ok(id.to_string()); + } + let mut del = BTreeMap::new(); + del.insert("Action".into(), "DeleteDomainRecord".into()); + del.insert("RecordId".into(), id.to_string()); + let _ = self.call(del).await; + tracing::info!(%id, "cert: aliyun stale txt deleted"); + } + + let mut params = BTreeMap::new(); + params.insert("Action".into(), "AddDomainRecord".into()); + params.insert("DomainName".into(), domain); + params.insert("RR".into(), rr); + params.insert("Type".into(), "TXT".into()); + params.insert("Value".into(), value.to_string()); + params.insert("TTL".into(), "600".into()); + let created = self.call(params).await?; + let id = created + .get("RecordId") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow!("aliyun missing RecordId"))? + .to_string(); + tracing::info!(%host, %id, "cert: aliyun txt upserted"); + Ok(id) + } + + async fn delete_txt(&self, record_id: &str) -> Result<()> { + let mut params = BTreeMap::new(); + params.insert("Action".into(), "DeleteDomainRecord".into()); + params.insert("RecordId".into(), record_id.to_string()); + self.call(params).await.context("aliyun delete txt")?; + Ok(()) + } +} + +fn domain_records(listed: &serde_json::Value) -> Vec { + match listed.pointer("/DomainRecords/Record") { + Some(serde_json::Value::Array(arr)) => arr.clone(), + Some(obj) if obj.is_object() => vec![obj.clone()], + _ => Vec::new(), + } +} + +fn enc(s: &str) -> String { + utf8_percent_encode(s, ENCODE_SET).to_string() +} diff --git a/desktop/src/cert/dns/cloudflare.rs b/desktop/src/cert/dns/cloudflare.rs new file mode 100644 index 0000000..cfaf5a3 --- /dev/null +++ b/desktop/src/cert/dns/cloudflare.rs @@ -0,0 +1,155 @@ +use super::DnsProvider; +use anyhow::{anyhow, bail, Context, Result}; +use async_trait::async_trait; +use serde::Deserialize; +use serde_json::json; + +pub struct CloudflareDns { + token: String, + client: reqwest::Client, +} + +impl CloudflareDns { + pub fn new(token: &str) -> Result { + Ok(Self { + token: token.to_string(), + client: reqwest::Client::new(), + }) + } + + async fn find_zone(&self, domain: &str) -> Result<(String, String)> { + let mut name = domain.trim_end_matches('.').to_string(); + loop { + let url = + format!("https://api.cloudflare.com/client/v4/zones?name={name}&status=active"); + let resp: CfResp> = self + .client + .get(&url) + .bearer_auth(&self.token) + .send() + .await + .context("cloudflare list zones")? + .json() + .await + .context("cloudflare zones json")?; + if !resp.success { + bail!("cloudflare zones error: {:?}", resp.errors); + } + if let Some(z) = resp.result.into_iter().next() { + return Ok((z.id, z.name)); + } + if let Some((_, rest)) = name.split_once('.') { + name = rest.to_string(); + } else { + break; + } + } + Err(anyhow!("cloudflare zone not found for {domain}")) + } +} + +#[async_trait] +impl DnsProvider for CloudflareDns { + async fn upsert_txt(&self, host: &str, value: &str) -> Result { + let host = host.trim_end_matches('.'); + let (zone_id, zone_name) = self.find_zone(host).await?; + let relative = if host == zone_name { + "@".to_string() + } else if let Some(prefix) = host.strip_suffix(&format!(".{zone_name}")) { + prefix.to_string() + } else { + host.to_string() + }; + + let list_url = format!( + "https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records?type=TXT&name={host}" + ); + let listed: CfResp> = self + .client + .get(&list_url) + .bearer_auth(&self.token) + .send() + .await + .context("cloudflare list txt")? + .json() + .await + .context("cloudflare list txt json")?; + + if let Some(existing) = listed + .result + .iter() + .find(|r| r.content.trim_matches('"') == value) + { + return Ok(format!("{zone_id}:{}", existing.id)); + } + + let body = json!({ + "type": "TXT", + "name": relative, + "content": value, + "ttl": 120, + }); + let create_url = + format!("https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records"); + let created: CfResp = self + .client + .post(&create_url) + .bearer_auth(&self.token) + .json(&body) + .send() + .await + .context("cloudflare create txt")? + .json() + .await + .context("cloudflare create txt json")?; + if !created.success { + bail!("cloudflare create txt failed: {:?}", created.errors); + } + tracing::info!(%host, "cert: cloudflare txt upserted"); + Ok(format!("{zone_id}:{}", created.result.id)) + } + + async fn delete_txt(&self, record_id: &str) -> Result<()> { + let Some((zone_id, id)) = record_id.split_once(':') else { + bail!("invalid cloudflare record id"); + }; + let url = format!("https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records/{id}"); + let resp: CfResp = self + .client + .delete(&url) + .bearer_auth(&self.token) + .send() + .await + .context("cloudflare delete txt")? + .json() + .await + .context("cloudflare delete txt json")?; + if !resp.success { + tracing::warn!(?resp.errors, "cert: cloudflare delete txt failed"); + } + Ok(()) + } +} + +#[derive(Debug, Deserialize)] +struct CfResp { + success: bool, + #[serde(default)] + result: T, + #[serde(default)] + errors: Vec, +} + +#[derive(Debug, Deserialize)] +struct CfZone { + id: String, + name: String, +} + +#[derive(Debug, Deserialize, Default)] +struct CfRecord { + #[serde(default)] + id: String, + #[serde(default)] + content: String, +} diff --git a/desktop/src/cert/dns/mod.rs b/desktop/src/cert/dns/mod.rs new file mode 100644 index 0000000..f2d6df2 --- /dev/null +++ b/desktop/src/cert/dns/mod.rs @@ -0,0 +1,117 @@ +mod aliyun; +mod cloudflare; +mod tencent; + +use crate::cert::model::{Provider, Vendor}; +use anyhow::{bail, Context, Result}; +use async_trait::async_trait; + +#[async_trait] +pub trait DnsProvider: Send + Sync { + async fn upsert_txt(&self, host: &str, value: &str) -> Result; + async fn delete_txt(&self, record_id: &str) -> Result<()>; +} + +pub fn build_provider(provider: &Provider, secret: &str) -> Result> { + let secret = secret.trim(); + if secret.is_empty() { + bail!("dns secret is empty"); + } + match provider.vendor { + Vendor::Cloudflare => Ok(Box::new(cloudflare::CloudflareDns::new(secret)?)), + Vendor::Aliyun => { + if provider.access_key_id.trim().is_empty() { + bail!("accessKeyId is required for aliyun"); + } + Ok(Box::new(aliyun::AliyunDns::new( + &provider.access_key_id, + secret, + )?)) + } + Vendor::Tencent => { + if provider.access_key_id.trim().is_empty() { + bail!("secretId is required for tencent"); + } + Ok(Box::new(tencent::TencentDns::new( + &provider.access_key_id, + secret, + )?)) + } + } +} + +pub fn challenge_name(domain: &str) -> String { + format!("_acme-challenge.{domain}") +} + +pub async fn wait_txt_propagated( + host: &str, + expected: &str, + cancel: &std::sync::atomic::AtomicBool, +) -> Result<()> { + let host = host.trim_end_matches('.'); + let expected = expected.trim().trim_matches('"'); + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(8)) + .build()?; + let endpoints = [ + format!("https://dns.alidns.com/resolve?name={host}&type=TXT"), + format!("https://doh.pub/dns-query?name={host}&type=TXT"), + format!("https://cloudflare-dns.com/dns-query?name={host}&type=TXT"), + ]; + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(150); + let mut attempt = 0u32; + loop { + if cancel.load(std::sync::atomic::Ordering::Relaxed) { + anyhow::bail!("cancelled"); + } + attempt += 1; + for url in &endpoints { + match fetch_txt_answers(&client, url).await { + Ok(answers) if answers.iter().any(|a| a.trim_matches('"') == expected) => { + tracing::info!(%host, attempt, "acme: dns txt visible"); + return Ok(()); + } + Ok(_) => {} + Err(e) => { + tracing::debug!(error = %e, %url, "acme: doh lookup failed"); + } + } + } + if tokio::time::Instant::now() >= deadline { + anyhow::bail!( + "DNS TXT for {host} not visible after 150s (value may not have propagated)" + ); + } + if attempt == 1 || attempt % 5 == 0 { + tracing::info!(%host, attempt, "acme: waiting dns txt propagation"); + } + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + } +} + +async fn fetch_txt_answers(client: &reqwest::Client, url: &str) -> Result> { + let resp = client + .get(url) + .header("Accept", "application/dns-json") + .send() + .await + .context("doh request")? + .error_for_status() + .context("doh status")? + .json::() + .await + .context("doh json")?; + let mut out = Vec::new(); + if let Some(arr) = resp.get("Answer").and_then(|v| v.as_array()) { + for a in arr { + if a.get("type").and_then(|t| t.as_u64()) != Some(16) { + continue; + } + if let Some(data) = a.get("data").and_then(|d| d.as_str()) { + out.push(data.to_string()); + } + } + } + Ok(out) +} diff --git a/desktop/src/cert/dns/tencent.rs b/desktop/src/cert/dns/tencent.rs new file mode 100644 index 0000000..99a8719 --- /dev/null +++ b/desktop/src/cert/dns/tencent.rs @@ -0,0 +1,250 @@ +use super::DnsProvider; +use anyhow::{anyhow, bail, Context, Result}; +use async_trait::async_trait; +use chrono::Utc; +use hmac::{Hmac, Mac}; +use serde::Deserialize; +use serde_json::json; +use sha2::{Digest, Sha256}; + +type HmacSha256 = Hmac; + +pub struct TencentDns { + secret_id: String, + secret_key: String, + client: reqwest::Client, +} + +impl TencentDns { + pub fn new(secret_id: &str, secret_key: &str) -> Result { + Ok(Self { + secret_id: secret_id.to_string(), + secret_key: secret_key.to_string(), + client: reqwest::Client::new(), + }) + } + + async fn call(&self, action: &str, payload: serde_json::Value) -> Result { + let host = "dnspod.tencentcloudapi.com"; + let service = "dnspod"; + let version = "2021-03-23"; + let timestamp = Utc::now().timestamp(); + let date = Utc::now().format("%Y-%m-%d").to_string(); + let body = payload.to_string(); + let hashed_payload = hex::encode(Sha256::digest(body.as_bytes())); + + let canonical_headers = format!( + "content-type:application/json; charset=utf-8\nhost:{host}\nx-tc-action:{}\n", + action.to_lowercase() + ); + let signed_headers = "content-type;host;x-tc-action"; + let canonical_request = + format!("POST\n/\n\n{canonical_headers}\n{signed_headers}\n{hashed_payload}"); + let hashed_canonical = hex::encode(Sha256::digest(canonical_request.as_bytes())); + let credential_scope = format!("{date}/{service}/tc3_request"); + let string_to_sign = + format!("TC3-HMAC-SHA256\n{timestamp}\n{credential_scope}\n{hashed_canonical}"); + + let secret_date = hmac_sha256( + format!("TC3{}", self.secret_key).as_bytes(), + date.as_bytes(), + ); + let secret_service = hmac_sha256(&secret_date, service.as_bytes()); + let secret_signing = hmac_sha256(&secret_service, b"tc3_request"); + let signature = hex::encode(hmac_sha256(&secret_signing, string_to_sign.as_bytes())); + + let authorization = format!( + "TC3-HMAC-SHA256 Credential={}/{}, SignedHeaders={signed_headers}, Signature={signature}", + self.secret_id, credential_scope + ); + + let url = format!("https://{host}"); + let resp = self + .client + .post(&url) + .header("Authorization", authorization) + .header("Content-Type", "application/json; charset=utf-8") + .header("Host", host) + .header("X-TC-Action", action) + .header("X-TC-Timestamp", timestamp.to_string()) + .header("X-TC-Version", version) + .body(body) + .send() + .await + .context("tencent dns request")?; + let text = resp.text().await.context("tencent dns body")?; + let v: TcResp = + serde_json::from_str(&text).with_context(|| format!("tencent dns json: {text}"))?; + if let Some(err) = v.response.error { + bail!("tencent dns error: {} - {}", err.code, err.message); + } + let mut data = v.response.extra; + data.as_object_mut().map(|m| { + m.remove("RequestId"); + m.remove("Error"); + }); + Ok(data) + } + + async fn find_domain(&self, host: &str) -> Result<(u64, String, String)> { + let host = host.trim_end_matches('.'); + let listed = self + .call("DescribeDomainList", json!({"Limit": 100, "Offset": 0})) + .await?; + let domains = listed + .get("DomainList") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + let mut name = host.to_string(); + loop { + for d in &domains { + let dn = d.get("Name").and_then(|v| v.as_str()).unwrap_or(""); + let id = d + .get("DomainId") + .and_then(|v| v.as_u64()) + .or_else(|| { + d.get("DomainId") + .and_then(|v| v.as_str()) + .and_then(|s| s.parse().ok()) + }) + .unwrap_or(0); + if !dn.is_empty() && dn.eq_ignore_ascii_case(&name) { + let rr = if host.eq_ignore_ascii_case(&name) { + "@".to_string() + } else { + host.strip_suffix(&format!(".{name}")) + .or_else(|| { + let lower = name.to_ascii_lowercase(); + host.strip_suffix(&format!(".{lower}")) + }) + .unwrap_or(host) + .to_string() + }; + tracing::info!(%host, domain = %dn, domain_id = id, %rr, "cert: tencent resolve zone"); + return Ok((id, dn.to_string(), rr)); + } + } + if let Some((_, rest)) = name.split_once('.') { + name = rest.to_string(); + } else { + break; + } + } + Err(anyhow!("tencent domain not found for {host}")) + } +} + +#[async_trait] +impl DnsProvider for TencentDns { + async fn upsert_txt(&self, host: &str, value: &str) -> Result { + let (domain_id, domain, rr) = self.find_domain(host).await?; + + let mut list_body = json!({ + "Domain": domain, + "Subdomain": rr, + "RecordType": "TXT", + "Limit": 100, + }); + if domain_id > 0 { + list_body["DomainId"] = json!(domain_id); + } + let listed = self.call("DescribeRecordList", list_body).await; + if let Ok(listed) = listed { + if let Some(records) = listed.get("RecordList").and_then(|v| v.as_array()) { + for r in records { + let content = r.get("Value").and_then(|v| v.as_str()).unwrap_or(""); + let id = r.get("RecordId").and_then(|v| v.as_u64()).unwrap_or(0); + let record_rr = r.get("Name").and_then(|v| v.as_str()).unwrap_or(""); + if !record_rr.is_empty() && record_rr != rr { + continue; + } + if content == value && id > 0 { + tracing::info!(%host, %id, "cert: tencent txt already present"); + return Ok(format!("{domain}:{id}")); + } + if id > 0 { + let mut del = json!({ + "Domain": domain, + "RecordId": id, + }); + if domain_id > 0 { + del["DomainId"] = json!(domain_id); + } + let _ = self.call("DeleteRecord", del).await; + tracing::info!(%id, "cert: tencent stale txt deleted"); + } + } + } + } + + let mut create_body = json!({ + "Domain": domain, + "SubDomain": rr, + "RecordType": "TXT", + "RecordLine": "默认", + "Value": value, + "TTL": 600, + }); + if domain_id > 0 { + create_body["DomainId"] = json!(domain_id); + } + let created = self.call("CreateRecord", create_body).await?; + let id = created + .get("RecordId") + .and_then(|v| v.as_u64()) + .ok_or_else(|| anyhow!("tencent missing RecordId"))?; + tracing::info!(%host, %id, %domain, "cert: tencent txt upserted"); + Ok(format!("{domain}:{id}")) + } + + async fn delete_txt(&self, record_id: &str) -> Result<()> { + let (domain, id) = match record_id.split_once(':') { + Some((d, i)) => ( + d.to_string(), + i.parse::().context("tencent record id")?, + ), + None => { + bail!("tencent record id missing domain prefix"); + } + }; + self.call( + "DeleteRecord", + json!({ + "Domain": domain, + "RecordId": id, + }), + ) + .await + .context("tencent delete txt")?; + Ok(()) + } +} + +fn hmac_sha256(key: &[u8], data: &[u8]) -> Vec { + let mut mac = HmacSha256::new_from_slice(key).expect("hmac"); + mac.update(data); + mac.finalize().into_bytes().to_vec() +} + +#[derive(Debug, Deserialize)] +struct TcResp { + #[serde(rename = "Response")] + response: TcResponse, +} + +#[derive(Debug, Deserialize)] +struct TcResponse { + #[serde(rename = "Error")] + error: Option, + #[serde(flatten)] + extra: serde_json::Value, +} + +#[derive(Debug, Deserialize)] +struct TcError { + #[serde(rename = "Code")] + code: String, + #[serde(rename = "Message")] + message: String, +} diff --git a/desktop/src/cert/issue.rs b/desktop/src/cert/issue.rs new file mode 100644 index 0000000..4a7e6d3 --- /dev/null +++ b/desktop/src/cert/issue.rs @@ -0,0 +1,267 @@ +use super::dns::{self, DnsProvider}; +use super::model::Cert; +use super::store::{self, Store}; +use anyhow::{bail, Context, Result}; +use instant_acme::{ + Account, AuthorizationStatus, ChallengeType, Identifier, LetsEncrypt, NewAccount, NewOrder, + OrderStatus, RetryPolicy, +}; +use std::future::Future; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +pub const ISSUE_TIMEOUT: Duration = Duration::from_secs(360); + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(i32)] +pub enum IssueStep { + Prepare = 0, + Order = 1, + DnsWrite = 2, + DnsWait = 3, + Validate = 4, + Finalize = 5, + Cleanup = 6, +} + +pub struct IssueRequest { + pub domains: Vec, + pub provider_id: String, +} + +struct PendingTxt { + host: String, + value: String, + record_id: String, +} + +fn check_cancel(cancel: &AtomicBool) -> Result<()> { + if cancel.load(Ordering::Relaxed) { + bail!("cancelled"); + } + Ok(()) +} + +async fn until_cancel( + cancel: &AtomicBool, + fut: impl Future>, +) -> Result +where + E: Into, +{ + tokio::pin!(fut); + loop { + tokio::select! { + biased; + r = &mut fut => return r.map_err(Into::into), + _ = tokio::time::sleep(Duration::from_secs(1)) => { + check_cancel(cancel)?; + } + } + } +} + +pub async fn issue( + store: &Store, + req: IssueRequest, + cancel: Arc, + mut on_step: impl FnMut(IssueStep) + Send, +) -> Result { + let domains: Vec = req + .domains + .iter() + .map(|d| d.trim().trim_end_matches('.').to_ascii_lowercase()) + .filter(|d| !d.is_empty()) + .collect(); + if domains.is_empty() { + bail!("domains is required"); + } + for d in &domains { + if d.contains('*') || d.contains(' ') || !d.contains('.') { + bail!("invalid domain: {d}"); + } + } + + let provider = store + .get_provider(&req.provider_id)? + .ok_or_else(|| anyhow::anyhow!("provider not found"))?; + let secret = store + .get_secret(&req.provider_id)? + .ok_or_else(|| anyhow::anyhow!("provider secret not found"))?; + let dns = dns::build_provider(&provider, &secret)?; + + tracing::info!(domains = ?domains, provider = %provider.id, "acme: issue start"); + + on_step(IssueStep::Prepare); + check_cancel(&cancel)?; + let account = load_or_create_account(store).await?; + check_cancel(&cancel)?; + + on_step(IssueStep::Order); + let identifiers: Vec = domains.iter().map(|d| Identifier::Dns(d.clone())).collect(); + let mut order = account + .new_order(&NewOrder::new(&identifiers)) + .await + .context("acme new_order")?; + check_cancel(&cancel)?; + + on_step(IssueStep::DnsWrite); + let mut pending: Vec = Vec::new(); + let result = async { + { + let mut authorizations = order.authorizations(); + while let Some(result) = authorizations.next().await { + check_cancel(&cancel)?; + let mut authz = result.context("acme authorizations")?; + match authz.status { + AuthorizationStatus::Pending => {} + AuthorizationStatus::Valid => continue, + other => bail!("unexpected authorization status: {other:?}"), + } + + let challenge = authz + .challenge(ChallengeType::Dns01) + .ok_or_else(|| anyhow::anyhow!("dns-01 challenge missing"))?; + + let identifier = challenge.identifier().to_string(); + let value = challenge.key_authorization().dns_value(); + let host = dns::challenge_name(&identifier); + tracing::info!(%host, "acme: upsert dns txt"); + let record_id = dns.upsert_txt(&host, &value).await?; + tracing::info!(%host, %record_id, "acme: dns txt upserted"); + pending.push(PendingTxt { + host, + value, + record_id, + }); + } + } + + on_step(IssueStep::DnsWait); + for p in &pending { + check_cancel(&cancel)?; + dns::wait_txt_propagated(&p.host, &p.value, &cancel) + .await + .context("wait dns propagation")?; + } + + on_step(IssueStep::Validate); + { + let mut authorizations = order.authorizations(); + while let Some(result) = authorizations.next().await { + check_cancel(&cancel)?; + let mut authz = result.context("acme authorizations")?; + if authz.status == AuthorizationStatus::Valid { + continue; + } + let Some(mut challenge) = authz.challenge(ChallengeType::Dns01) else { + continue; + }; + challenge + .set_ready() + .await + .context("acme set_challenge_ready")?; + } + } + + let status = until_cancel( + &cancel, + order.poll_ready(&RetryPolicy::new().timeout(Duration::from_secs(180))), + ) + .await + .context("acme poll_ready")?; + if status != OrderStatus::Ready { + bail!("acme order not ready: {status:?}"); + } + check_cancel(&cancel)?; + + on_step(IssueStep::Finalize); + let private_key_pem = until_cancel(&cancel, order.finalize()) + .await + .context("acme finalize")?; + let cert_chain_pem = until_cancel( + &cancel, + order.poll_certificate(&RetryPolicy::new().timeout(Duration::from_secs(120))), + ) + .await + .context("acme certificate")?; + + let not_after = (chrono::Utc::now() + chrono::Duration::days(90)) + .to_rfc3339_opts(chrono::SecondsFormat::Secs, true); + let cert = Cert { + id: store::new_id("c"), + domains, + provider_id: req.provider_id, + not_after, + }; + store.add_cert(cert.clone(), &cert_chain_pem, &private_key_pem)?; + tracing::info!(id = %cert.id, "acme: issue ok"); + Ok(cert) + } + .await; + + let record_ids: Vec = pending.iter().map(|p| p.record_id.clone()).collect(); + if !record_ids.is_empty() { + on_step(IssueStep::Cleanup); + cleanup_dns(dns.as_ref(), &record_ids).await; + } + result +} + +async fn cleanup_dns(dns: &dyn DnsProvider, ids: &[String]) { + if ids.is_empty() { + return; + } + tracing::info!(count = ids.len(), "acme: cleanup dns txt"); + for id in ids { + match dns.delete_txt(id).await { + Ok(()) => tracing::info!(record = %id, "acme: dns txt deleted"), + Err(e) => tracing::warn!(error = %e, record = %id, "acme: cleanup txt failed"), + } + } +} + +async fn load_or_create_account(store: &Store) -> Result { + let path = store.acme_account_path(); + let directory_url = LetsEncrypt::Production.url().to_owned(); + + let builder = Account::builder().context("acme account builder")?; + + if path.exists() { + let raw = std::fs::read_to_string(&path).context("read acme account")?; + let creds: instant_acme::AccountCredentials = + serde_json::from_str(&raw).context("parse acme account")?; + let account = builder + .from_credentials(creds) + .await + .context("acme from_credentials")?; + tracing::info!("acme: production account loaded"); + return Ok(account); + } + + let (account, creds) = builder + .create( + &NewAccount { + contact: &[], + terms_of_service_agreed: true, + only_return_existing: false, + }, + directory_url, + None, + ) + .await + .context("acme create account")?; + + let raw = serde_json::to_string_pretty(&creds).context("serialize acme account")?; + std::fs::write(&path, raw).context("write acme account")?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = std::fs::metadata(&path)?.permissions(); + perms.set_mode(0o600); + std::fs::set_permissions(&path, perms)?; + } + tracing::info!("acme: production account created"); + Ok(account) +} diff --git a/desktop/src/cert/mod.rs b/desktop/src/cert/mod.rs new file mode 100644 index 0000000..db4a0b3 --- /dev/null +++ b/desktop/src/cert/mod.rs @@ -0,0 +1,26 @@ +mod dns; +mod issue; +mod model; +mod store; + +pub use issue::{issue, IssueRequest, IssueStep, ISSUE_TIMEOUT}; +pub use model::{Provider, Vendor}; +pub use store::Store; + +use crate::config_bridge; +use anyhow::Result; +use std::sync::{Mutex, OnceLock}; + +static STORE: OnceLock> = OnceLock::new(); + +pub fn store() -> &'static Mutex { + STORE.get_or_init(|| { + let s = Store::open(config_bridge::data_dir()).expect("open cert store"); + Mutex::new(s) + }) +} + +pub fn with_store(f: impl FnOnce(&Store) -> Result) -> Result { + let guard = store().lock().unwrap_or_else(|e| e.into_inner()); + f(&guard) +} diff --git a/desktop/src/cert/model.rs b/desktop/src/cert/model.rs new file mode 100644 index 0000000..6d4db25 --- /dev/null +++ b/desktop/src/cert/model.rs @@ -0,0 +1,73 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Vendor { + Cloudflare, + Aliyun, + Tencent, +} + +impl Vendor { + pub fn as_str(self) -> &'static str { + match self { + Self::Cloudflare => "cloudflare", + Self::Aliyun => "aliyun", + Self::Tencent => "tencent", + } + } + + pub fn from_index(i: i32) -> Option { + match i { + 0 => Some(Self::Aliyun), + 1 => Some(Self::Tencent), + 2 => Some(Self::Cloudflare), + _ => None, + } + } + + pub fn index(self) -> i32 { + match self { + Self::Aliyun => 0, + Self::Tencent => 1, + Self::Cloudflare => 2, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Provider { + pub id: String, + pub name: String, + pub vendor: Vendor, + #[serde(default, rename = "accessKeyId")] + pub access_key_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ProviderFile { + #[serde(default)] + pub providers: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Cert { + pub id: String, + pub domains: Vec, + #[serde(rename = "providerId")] + pub provider_id: String, + #[serde(rename = "notAfter")] + pub not_after: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct CertFile { + #[serde(default)] + pub certs: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct SecretsFile { + #[serde(default)] + pub secrets: std::collections::HashMap, +} diff --git a/desktop/src/cert/store.rs b/desktop/src/cert/store.rs new file mode 100644 index 0000000..ec0174b --- /dev/null +++ b/desktop/src/cert/store.rs @@ -0,0 +1,235 @@ +use super::model::{Cert, CertFile, Provider, ProviderFile, SecretsFile}; +use anyhow::{bail, Context, Result}; +use std::fs; +use std::path::{Path, PathBuf}; + +pub struct Store { + root: PathBuf, +} + +impl Clone for Store { + fn clone(&self) -> Self { + Self { + root: self.root.clone(), + } + } +} + +impl Store { + pub fn open(root: PathBuf) -> Result { + fs::create_dir_all(root.join("certs")).context("create certs dir")?; + fs::create_dir_all(root.join("acme")).context("create acme dir")?; + Ok(Self { root }) + } + + fn providers_path(&self) -> PathBuf { + self.root.join("providers.json") + } + + fn secrets_path(&self) -> PathBuf { + self.root.join("secrets.json") + } + + fn certs_index_path(&self) -> PathBuf { + self.root.join("certs").join("index.json") + } + + pub fn cert_dir(&self, id: &str) -> PathBuf { + self.root.join("certs").join(id) + } + + pub fn cert_pem_path(&self, id: &str) -> PathBuf { + self.cert_dir(id).join("fullchain.pem") + } + + pub fn key_pem_path(&self, id: &str) -> PathBuf { + self.cert_dir(id).join("privkey.pem") + } + + pub fn acme_account_path(&self) -> PathBuf { + self.root.join("acme").join("account.json") + } + + #[allow(dead_code)] + pub fn acme_staging_account_path(&self) -> PathBuf { + self.root.join("acme").join("account-staging.json") + } + + pub fn list_providers(&self) -> Result> { + Ok(self.load_providers()?.providers) + } + + pub fn list_certs(&self) -> Result> { + Ok(self.load_certs()?.certs) + } + + pub fn get_provider(&self, id: &str) -> Result> { + Ok(self.list_providers()?.into_iter().find(|p| p.id == id)) + } + + pub fn get_cert(&self, id: &str) -> Result> { + Ok(self.list_certs()?.into_iter().find(|c| c.id == id)) + } + + pub fn get_secret(&self, provider_id: &str) -> Result> { + let file = self.load_secrets()?; + Ok(file.secrets.get(provider_id).cloned()) + } + + pub fn upsert_provider( + &self, + mut provider: Provider, + secret: Option<&str>, + ) -> Result { + if provider.name.trim().is_empty() { + bail!("name is required"); + } + let mut file = self.load_providers()?; + if provider.id.is_empty() { + provider.id = new_id("p"); + if secret.map(|s| s.trim().is_empty()).unwrap_or(true) { + bail!("secret is required"); + } + file.providers.push(provider.clone()); + } else if let Some(slot) = file.providers.iter_mut().find(|p| p.id == provider.id) { + *slot = provider.clone(); + } else { + bail!("provider not found"); + } + + if let Some(s) = secret { + let s = s.trim(); + if !s.is_empty() { + let mut secrets = self.load_secrets()?; + secrets.secrets.insert(provider.id.clone(), s.to_string()); + self.save_secrets(&secrets)?; + } + } + self.save_providers(&file)?; + tracing::info!(id = %provider.id, vendor = %provider.vendor.as_str(), "cert: provider saved"); + Ok(provider) + } + + pub fn delete_provider(&self, id: &str) -> Result<()> { + let mut file = self.load_providers()?; + let before = file.providers.len(); + file.providers.retain(|p| p.id != id); + if file.providers.len() == before { + bail!("provider not found"); + } + self.save_providers(&file)?; + let mut secrets = self.load_secrets()?; + secrets.secrets.remove(id); + self.save_secrets(&secrets)?; + tracing::info!(%id, "cert: provider deleted"); + Ok(()) + } + + pub fn add_cert(&self, cert: Cert, fullchain_pem: &str, key_pem: &str) -> Result<()> { + let dir = self.cert_dir(&cert.id); + fs::create_dir_all(&dir).with_context(|| format!("mkdir {}", dir.display()))?; + let cert_path = self.cert_pem_path(&cert.id); + let key_path = self.key_pem_path(&cert.id); + fs::write(&cert_path, fullchain_pem) + .with_context(|| format!("write {}", cert_path.display()))?; + fs::write(&key_path, key_pem).with_context(|| format!("write {}", key_path.display()))?; + set_private_mode(&key_path)?; + + let mut file = self.load_certs()?; + file.certs.retain(|c| c.id != cert.id); + file.certs.insert(0, cert.clone()); + self.save_certs(&file)?; + tracing::info!( + id = %cert.id, + domains = ?cert.domains, + "cert: certificate saved" + ); + Ok(()) + } + + pub fn delete_cert(&self, id: &str) -> Result<()> { + let mut file = self.load_certs()?; + let before = file.certs.len(); + file.certs.retain(|c| c.id != id); + if file.certs.len() == before { + bail!("cert not found"); + } + self.save_certs(&file)?; + let dir = self.cert_dir(id); + if dir.exists() { + let _ = fs::remove_dir_all(&dir); + } + tracing::info!(%id, "cert: certificate deleted"); + Ok(()) + } + + fn load_providers(&self) -> Result { + read_json_or_default(&self.providers_path()) + } + + fn save_providers(&self, file: &ProviderFile) -> Result<()> { + write_json_atomic(&self.providers_path(), file) + } + + fn load_certs(&self) -> Result { + read_json_or_default(&self.certs_index_path()) + } + + fn save_certs(&self, file: &CertFile) -> Result<()> { + write_json_atomic(&self.certs_index_path(), file) + } + + fn load_secrets(&self) -> Result { + read_json_or_default(&self.secrets_path()) + } + + fn save_secrets(&self, file: &SecretsFile) -> Result<()> { + write_json_atomic(&self.secrets_path(), file)?; + set_private_mode(&self.secrets_path())?; + Ok(()) + } +} + +pub fn new_id(prefix: &str) -> String { + format!( + "{prefix}_{}", + &uuid::Uuid::new_v4().simple().to_string()[..12] + ) +} + +fn read_json_or_default(path: &Path) -> Result { + if !path.exists() { + return Ok(T::default()); + } + let raw = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?; + if raw.trim().is_empty() { + return Ok(T::default()); + } + serde_json::from_str(&raw).with_context(|| format!("parse {}", path.display())) +} + +fn write_json_atomic(path: &Path, value: &T) -> Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let raw = serde_json::to_string_pretty(value).context("serialize json")?; + let tmp = path.with_extension("tmp"); + fs::write(&tmp, &raw).with_context(|| format!("write {}", tmp.display()))?; + fs::rename(&tmp, path).with_context(|| format!("rename {}", path.display()))?; + Ok(()) +} + +fn set_private_mode(path: &Path) -> Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(path)?.permissions(); + perms.set_mode(0o600); + fs::set_permissions(path, perms)?; + } + #[cfg(windows)] + { + let _ = path; + } + Ok(()) +} diff --git a/desktop/src/cert_bridge.rs b/desktop/src/cert_bridge.rs new file mode 100644 index 0000000..aa76813 --- /dev/null +++ b/desktop/src/cert_bridge.rs @@ -0,0 +1,232 @@ +use crate::cert::{self, IssueStep, Provider, Vendor}; +use anyhow::Result; +use chrono::{DateTime, Utc}; +use std::sync::atomic::{AtomicI32, Ordering}; +use std::sync::Arc; + +pub struct IssueProgress { + ui: slint::Weak, + last: AtomicI32, +} + +impl IssueProgress { + pub fn new(ui: slint::Weak) -> Arc { + Arc::new(Self { + ui, + last: AtomicI32::new(-1), + }) + } + + pub fn report(self: &Arc, step: IssueStep) { + let step_i = step as i32; + let prev = self.last.fetch_max(step_i, Ordering::Relaxed); + if prev >= step_i { + return; + } + let ui = self.ui.clone(); + let _ = slint::invoke_from_event_loop(move || { + if let Some(ui) = ui.upgrade() { + if ui.get_cert_issuing() { + ui.set_cert_issue_step(step_i); + } + } + }); + } +} + +pub fn refresh(ui: &crate::AppWindow) { + let (certs, keys) = match ( + cert::with_store(|s| s.list_certs()), + cert::with_store(|s| s.list_providers()), + ) { + (Ok(c), Ok(k)) => (c, k), + (Err(e), _) | (_, Err(e)) => { + tracing::warn!(error = %e, "cert: refresh failed"); + (Vec::new(), Vec::new()) + } + }; + + let providers = keys; + let cert_rows: Vec = certs + .iter() + .map(|c| { + let provider_name = providers + .iter() + .find(|p| p.id == c.provider_id) + .map(|p| format!("{} · {}", p.name, vendor_label(p.vendor))) + .unwrap_or_default(); + crate::CertRow { + id: c.id.clone().into(), + domains: c.domains.join(", ").into(), + not_after: display_not_after(&c.not_after).into(), + provider_id: c.provider_id.clone().into(), + provider_name: provider_name.into(), + expired: is_expired(&c.not_after), + } + }) + .collect(); + + let key_rows: Vec = providers + .iter() + .map(|p| { + let mask = cert::with_store(|s| s.get_secret(&p.id)) + .ok() + .flatten() + .map(|s| mask_secret(&s)) + .unwrap_or_else(|| "••••••••".into()); + crate::KeyRow { + id: p.id.clone().into(), + name: p.name.clone().into(), + vendor: vendor_label(p.vendor).into(), + vendor_index: p.vendor.index(), + access_key_id: p.access_key_id.clone().into(), + secret_mask: mask.into(), + } + }) + .collect(); + + let key_labels: Vec = key_rows + .iter() + .map(|k| format!("{} · {}", k.name, k.vendor).into()) + .collect(); + + let lib_labels: Vec = cert_rows + .iter() + .map(|c| format!("{} ({})", c.domains, c.not_after).into()) + .collect(); + + ui.set_cert_rows(slint::ModelRc::new(slint::VecModel::from(cert_rows))); + ui.set_key_rows(slint::ModelRc::new(slint::VecModel::from(key_rows))); + ui.set_cert_key_labels(slint::ModelRc::new(slint::VecModel::from(key_labels))); + ui.set_cert_library_labels(slint::ModelRc::new(slint::VecModel::from(lib_labels))); +} + +pub fn resolve_library_paths(cert_id: &str) -> Result<(String, String)> { + cert::with_store(|s| { + let _ = s + .get_cert(cert_id)? + .ok_or_else(|| anyhow::anyhow!("cert not found"))?; + Ok(( + s.cert_pem_path(cert_id).display().to_string(), + s.key_pem_path(cert_id).display().to_string(), + )) + }) +} + +pub fn find_library_index(cert_file: &str, key_file: &str) -> Option { + let certs = cert::with_store(|s| s.list_certs()).ok()?; + for (i, c) in certs.iter().enumerate() { + let (cp, kp) = cert::with_store(|s| { + Ok(( + s.cert_pem_path(&c.id).display().to_string(), + s.key_pem_path(&c.id).display().to_string(), + )) + }) + .ok()?; + if paths_eq(cert_file, &cp) && paths_eq(key_file, &kp) { + return Some(i as i32); + } + } + None +} + +pub fn library_cert_id_at(index: i32) -> Option { + let certs = cert::with_store(|s| s.list_certs()).ok()?; + certs.get(index as usize).map(|c| c.id.clone()) +} + +pub fn cert_paths_used_by_tunnels(cert_id: &str, tunnels: &[crate::TunnelRow]) -> bool { + let Ok((cp, kp)) = resolve_library_paths(cert_id) else { + return false; + }; + tunnels.iter().any(|t| { + t.plugin_tls_term + && paths_eq(t.plugin_cert_file.as_str(), &cp) + && paths_eq(t.plugin_key_file.as_str(), &kp) + }) +} + +pub fn save_provider( + id: &str, + name: &str, + vendor_index: i32, + access_key_id: &str, + secret: &str, +) -> Result<()> { + let vendor = + Vendor::from_index(vendor_index).ok_or_else(|| anyhow::anyhow!("invalid vendor"))?; + let provider = Provider { + id: id.to_string(), + name: name.trim().to_string(), + vendor, + access_key_id: access_key_id.trim().to_string(), + }; + let secret_opt = if secret.trim().is_empty() { + None + } else { + Some(secret.trim()) + }; + cert::with_store(|s| { + s.upsert_provider(provider, secret_opt)?; + Ok(()) + }) +} + +pub fn delete_provider(id: &str) -> Result<()> { + cert::with_store(|s| s.delete_provider(id)) +} + +pub fn delete_cert(id: &str) -> Result<()> { + cert::with_store(|s| s.delete_cert(id)) +} + +fn vendor_label(v: Vendor) -> &'static str { + match v { + Vendor::Cloudflare => "Cloudflare", + Vendor::Aliyun => "Aliyun", + Vendor::Tencent => "Tencent", + } +} + +fn mask_secret(s: &str) -> String { + const HEAD: usize = 4; + const TAIL: usize = 4; + const MID: &str = "••••••••"; + let chars: Vec = s.trim().chars().collect(); + if chars.len() <= HEAD + TAIL { + return MID.into(); + } + let head: String = chars[..HEAD].iter().collect(); + let tail: String = chars[chars.len() - TAIL..].iter().collect(); + format!("{head}{MID}{tail}") +} + +fn is_expired(not_after: &str) -> bool { + parse_time(not_after) + .map(|t| t < Utc::now()) + .unwrap_or(false) +} + +fn display_not_after(not_after: &str) -> String { + parse_time(not_after) + .map(|t| t.format("%Y-%m-%d").to_string()) + .unwrap_or_else(|| not_after.to_string()) +} + +fn parse_time(s: &str) -> Option> { + DateTime::parse_from_rfc3339(s) + .ok() + .map(|t| t.with_timezone(&Utc)) + .or_else(|| { + chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d") + .ok() + .and_then(|d| d.and_hms_opt(0, 0, 0)) + .map(|n| DateTime::::from_naive_utc_and_offset(n, Utc)) + }) +} + +fn paths_eq(a: &str, b: &str) -> bool { + let na = std::path::Path::new(a.trim()); + let nb = std::path::Path::new(b.trim()); + na == nb +} diff --git a/desktop/src/client_log_layer.rs b/desktop/src/client_log_layer.rs index 92a67df..aa27ae9 100644 --- a/desktop/src/client_log_layer.rs +++ b/desktop/src/client_log_layer.rs @@ -25,7 +25,10 @@ where return; } let target = meta.target(); - if !(target.starts_with("orbien_client") || target.starts_with("orbien_core")) { + if !(target.starts_with("orbien_client") + || target.starts_with("orbien_core") + || target.starts_with("orbien_desktop")) + { return; } diff --git a/desktop/src/config_bridge.rs b/desktop/src/config_bridge.rs index 271b438..db58834 100644 --- a/desktop/src/config_bridge.rs +++ b/desktop/src/config_bridge.rs @@ -7,25 +7,42 @@ use orbien_core::config::{ use std::fs; use std::path::{Path, PathBuf}; -pub fn default_config_path() -> PathBuf { - let base = std::env::var_os("HOME") - .map(PathBuf::from) - .or_else(|| std::env::current_dir().ok()) - .unwrap_or_else(|| PathBuf::from(".")); +pub fn data_dir() -> PathBuf { #[cfg(target_os = "macos")] { + let base = std::env::var_os("HOME") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(".")); let dir = base.join("Library/Application Support/com.orbien.desktop"); let _ = fs::create_dir_all(&dir); - return dir.join("orbien.toml"); + return dir; } - #[cfg(not(target_os = "macos"))] + #[cfg(target_os = "windows")] { + let base = std::env::var_os("APPDATA") + .map(PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(PathBuf::from)) + .unwrap_or_else(|| PathBuf::from(".")); + let dir = base.join("orbien"); + let _ = fs::create_dir_all(&dir); + return dir; + } + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + { + let base = std::env::var_os("HOME") + .map(PathBuf::from) + .or_else(|| std::env::current_dir().ok()) + .unwrap_or_else(|| PathBuf::from(".")); let dir = base.join(".config").join("orbien"); let _ = fs::create_dir_all(&dir); - dir.join("orbien.toml") + dir } } +pub fn default_config_path() -> PathBuf { + data_dir().join("orbien.toml") +} + pub fn resolve_path(config_path: &str) -> PathBuf { let trimmed = config_path.trim(); if trimmed.is_empty() { @@ -358,9 +375,16 @@ pub fn tunnel_from_parts( ..Default::default() }) } else if ty == "https" && plugin_tls_term { + let service = if !local_port.trim().is_empty() || !local_ip.trim().is_empty() { + assemble_service(local_ip, local_port)? + } else if !plugin_local_addr.trim().is_empty() { + plugin_local_addr.trim().into() + } else { + assemble_service("127.0.0.1", "8080")? + }; Some(PluginConfig { plugin_type: "tls-term".into(), - service: plugin_local_addr.trim().into(), + service, cert_file: plugin_cert_file.trim().into(), key_file: plugin_key_file.trim().into(), host_header_rewrite: plugin_host_rewrite.trim().into(), @@ -424,6 +448,11 @@ pub fn tunnel_to_parts(p: &TunnelConfig) -> TunnelParts { let (local_ip, local_port) = if socks5.is_some() { (String::new(), String::new()) + } else if let Some(pl) = tls_term { + match parse_host_port(&pl.service, 8080) { + Ok((host, port)) => (host, port.to_string()), + Err(_) => ("127.0.0.1".into(), "8080".into()), + } } else if p.service.trim().is_empty() { ("127.0.0.1".into(), "0".into()) } else { @@ -436,8 +465,8 @@ pub fn tunnel_to_parts(p: &TunnelConfig) -> TunnelParts { TunnelParts { name: p.name.clone(), tunnel_type, - local_ip, - local_port, + local_ip: local_ip.clone(), + local_port: local_port.clone(), remote_port: p.remote_port.to_string(), domains: p.domains.join(","), locations: p.locations.join(","), @@ -448,9 +477,11 @@ pub fn tunnel_to_parts(p: &TunnelConfig) -> TunnelParts { bandwidth_limit_side: p.transport.bandwidth_limit_side.clone(), proxy_protocol_version: p.transport.proxy_protocol_version.clone(), plugin_tls_term: tls_term.is_some(), - plugin_local_addr: tls_term - .map(|pl| pl.service.clone()) - .unwrap_or_else(|| "127.0.0.1:8080".into()), + plugin_local_addr: if tls_term.is_some() { + format!("{local_ip}:{local_port}") + } else { + String::new() + }, plugin_cert_file: tls_term.map(|pl| pl.cert_file.clone()).unwrap_or_default(), plugin_key_file: tls_term.map(|pl| pl.key_file.clone()).unwrap_or_default(), plugin_host_rewrite: tls_term diff --git a/desktop/src/i18n.rs b/desktop/src/i18n.rs index dcd11eb..0841c72 100644 --- a/desktop/src/i18n.rs +++ b/desktop/src/i18n.rs @@ -90,10 +90,6 @@ pub fn tunnel_persist_failed(locale: Locale, err: &str) -> String { msg_fmt(locale, "msg.tunnel-persist-failed", &[("{err}", err)]) } -pub fn tunnel_plugin_addr_required(locale: Locale) -> String { - msg(locale, "msg.tunnel-plugin-addr-required") -} - pub fn tunnel_plugin_username_required(locale: Locale) -> String { msg(locale, "msg.tunnel-plugin-username-required") } @@ -102,6 +98,50 @@ pub fn tunnel_plugin_password_required(locale: Locale) -> String { msg(locale, "msg.tunnel-plugin-password-required") } +pub fn cert_saved(locale: Locale) -> String { + msg(locale, "cert-saved") +} + +pub fn cert_deleted(locale: Locale) -> String { + msg(locale, "cert-deleted") +} + +pub fn cert_applied(locale: Locale) -> String { + msg(locale, "cert-applied") +} + +pub fn cert_in_use(locale: Locale) -> String { + msg(locale, "cert-in-use") +} + +pub fn cert_need_key(locale: Locale) -> String { + msg(locale, "cert-need-key") +} + +pub fn cert_domains_required(locale: Locale) -> String { + msg(locale, "cert-domains-required") +} + +pub fn cert_key_name_required(locale: Locale) -> String { + msg(locale, "cert-key-name-required") +} + +pub fn cert_secret_required(locale: Locale) -> String { + msg(locale, "cert-secret-required") +} + +pub fn cert_issue_failed(locale: Locale, err: &str) -> String { + msg_fmt(locale, "cert-issue-failed", &[("{err}", err)]) +} + +pub fn cert_issue_timeout(locale: Locale) -> String { + msg(locale, "cert-issue-timeout") +} + +pub fn cert_issue_cancelled(locale: Locale) -> String { + msg(locale, "cert-issue-cancelled") +} + pub fn tunnel_copied(locale: Locale) -> String { msg(locale, "msg.tunnel-copied") } diff --git a/desktop/src/main.rs b/desktop/src/main.rs index e51e184..e642756 100644 --- a/desktop/src/main.rs +++ b/desktop/src/main.rs @@ -1,4 +1,6 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +mod cert; +mod cert_bridge; mod client_log_layer; mod config_bridge; mod i18n; @@ -375,7 +377,9 @@ fn reset_tunnel_form(ui: &AppWindow) { ui.set_tunnel_edit_bandwidth_side_index(0); ui.set_tunnel_edit_proxy_protocol_index(0); ui.set_tunnel_edit_plugin_tls_term(false); - ui.set_tunnel_edit_plugin_local_addr("127.0.0.1:80".into()); + ui.set_tunnel_edit_plugin_local_addr("127.0.0.1:8080".into()); + ui.set_tunnel_edit_cert_source_index(1); + ui.set_tunnel_edit_cert_library_index(0); ui.set_tunnel_edit_plugin_cert_file("".into()); ui.set_tunnel_edit_plugin_key_file("".into()); ui.set_tunnel_edit_plugin_host_rewrite("".into()); @@ -407,12 +411,56 @@ fn fill_tunnel_form(ui: &AppWindow, row: &TunnelRow) { ui.set_tunnel_edit_plugin_local_addr(row.plugin_local_addr.clone()); ui.set_tunnel_edit_plugin_cert_file(row.plugin_cert_file.clone()); ui.set_tunnel_edit_plugin_key_file(row.plugin_key_file.clone()); + + let cert = row.plugin_cert_file.as_str().trim(); + let key = row.plugin_key_file.as_str().trim(); + if !row.plugin_tls_term { + ui.set_tunnel_edit_cert_source_index(1); + ui.set_tunnel_edit_cert_library_index(0); + } else if cert.is_empty() && key.is_empty() { + ui.set_tunnel_edit_cert_source_index(2); + ui.set_tunnel_edit_cert_library_index(0); + } else if let Some(idx) = cert_bridge::find_library_index(cert, key) { + ui.set_tunnel_edit_cert_source_index(0); + ui.set_tunnel_edit_cert_library_index(idx); + } else { + ui.set_tunnel_edit_cert_source_index(1); + ui.set_tunnel_edit_cert_library_index(0); + } ui.set_tunnel_edit_plugin_host_rewrite(row.plugin_host_rewrite.clone()); ui.set_tunnel_edit_plugin_username(row.plugin_username.clone()); ui.set_tunnel_edit_plugin_password(row.plugin_password.clone()); ui.set_tunnel_show_advanced(false); } +fn resolve_tunnel_cert_paths(ui: &AppWindow) -> (String, String) { + match ui.get_tunnel_edit_cert_source_index() { + 0 => { + if let Some(id) = + cert_bridge::library_cert_id_at(ui.get_tunnel_edit_cert_library_index()) + { + if let Ok((c, k)) = cert_bridge::resolve_library_paths(&id) { + return (c, k); + } + } + (String::new(), String::new()) + } + 2 => (String::new(), String::new()), + _ => ( + ui.get_tunnel_edit_plugin_cert_file().to_string(), + ui.get_tunnel_edit_plugin_key_file().to_string(), + ), + } +} + +fn resolve_tunnel_cert_file(ui: &AppWindow) -> String { + resolve_tunnel_cert_paths(ui).0 +} + +fn resolve_tunnel_key_file(ui: &AppWindow) -> String { + resolve_tunnel_cert_paths(ui).1 +} + fn collect_tunnel_form(ui: &AppWindow) -> TunnelRow { let ty = type_name(ui.get_tunnel_edit_type_index()); let is_socks5 = ty == "socks5"; @@ -422,12 +470,12 @@ fn collect_tunnel_form(ui: &AppWindow) -> TunnelRow { TunnelRow { name: ui.get_tunnel_edit_name(), tunnel_type: ty.into(), - local_ip: if plugin || is_socks5 { + local_ip: if is_socks5 { "".into() } else { ui.get_tunnel_edit_local_ip() }, - local_port: if plugin || is_socks5 { + local_port: if is_socks5 { "".into() } else { ui.get_tunnel_edit_local_port() @@ -472,17 +520,24 @@ fn collect_tunnel_form(ui: &AppWindow) -> TunnelRow { }, plugin_tls_term: plugin, plugin_local_addr: if plugin { - ui.get_tunnel_edit_plugin_local_addr() + let ip = ui.get_tunnel_edit_local_ip(); + let port = ui.get_tunnel_edit_local_port(); + let host = if ip.trim().is_empty() { + "127.0.0.1" + } else { + ip.trim() + }; + format!("{host}:{}", port.trim()).into() } else { "".into() }, plugin_cert_file: if plugin { - ui.get_tunnel_edit_plugin_cert_file() + resolve_tunnel_cert_file(ui).into() } else { "".into() }, plugin_key_file: if plugin { - ui.get_tunnel_edit_plugin_key_file() + resolve_tunnel_key_file(ui).into() } else { "".into() }, @@ -688,6 +743,8 @@ fn main() -> Result<(), slint::PlatformError> { .with(client_log_layer::ClientUiLogLayer::new(runtime::handle())) .init(); + let _ = rustls::crypto::ring::default_provider().install_default(); + let ui = AppWindow::new()?; let prefs = ui_prefs::load(); ui.set_locale_index(prefs.locale_index()); @@ -997,6 +1054,9 @@ fn main() -> Result<(), slint::PlatformError> { reset_log_ui_cursor(); ui.set_log_lines(slint::ModelRc::from(log_buffer::make_model())); } + if page == AppPage::Cert { + cert_bridge::refresh(&ui); + } }); wire_tunnel_and_config(&ui, remotes_gen.clone(), tray_weak); @@ -1120,7 +1180,6 @@ fn wire_tunnel_and_config( let ty_idx = ui.get_tunnel_edit_type_index(); let is_domain = ty_idx == 2 || ty_idx == 3; let is_socks5 = ty_idx == 4; - let use_plugin = ty_idx == 3 && ui.get_tunnel_edit_plugin_tls_term(); let local_port = ui.get_tunnel_edit_local_port(); let remote_port = ui.get_tunnel_edit_remote_port(); @@ -1146,12 +1205,7 @@ fn wire_tunnel_and_config( toast_err(&ui, i18n::tunnel_domain_required(loc)); return; } - if use_plugin { - if ui.get_tunnel_edit_plugin_local_addr().trim().is_empty() { - toast_err(&ui, i18n::tunnel_plugin_addr_required(loc)); - return; - } - } else if let Err(msg) = require_port_field( + if let Err(msg) = require_port_field( local_port.as_str(), i18n::tunnel_local_port_required(loc), i18n::tunnel_local_port_invalid(loc), @@ -1289,6 +1343,301 @@ fn wire_tunnel_and_config( } }); + cert_bridge::refresh(&ui); + + let ui_weak = ui.as_weak(); + ui.on_cert_add(move || { + let Some(ui) = ui_weak.upgrade() else { + return; + }; + let loc = locale_of(&ui); + if ui.get_cert_tab_index() == 1 { + ui.set_cert_key_edit_id("".into()); + ui.set_cert_key_edit_name("".into()); + ui.set_cert_key_edit_vendor_index(0); + ui.set_cert_key_edit_access_key_id("".into()); + ui.set_cert_key_edit_secret("".into()); + ui.set_cert_editor_mode(2); + } else { + let keys = ui.get_key_rows(); + if keys.row_count() == 0 { + toast_err(&ui, i18n::cert_need_key(loc)); + ui.set_cert_tab_index(1); + return; + } + ui.set_cert_apply_domains("".into()); + ui.set_cert_apply_key_index(0); + ui.set_cert_issue_step(-1); + ui.set_cert_issue_failed(false); + ui.set_cert_editor_mode(1); + } + }); + + let ui_weak = ui.as_weak(); + let issue_cancel: Arc = Arc::new(AtomicBool::new(false)); + let issue_cancel_for_cancel = issue_cancel.clone(); + ui.on_cert_apply_cancel(move || { + if let Some(ui) = ui_weak.upgrade() { + if ui.get_cert_issuing() { + issue_cancel_for_cancel.store(true, Ordering::SeqCst); + ui.set_cert_issuing(false); + ui.set_cert_issue_step(-1); + ui.set_cert_issue_failed(false); + ui.set_cert_editor_mode(0); + let loc = locale_of(&ui); + toast_ok(&ui, i18n::cert_issue_cancelled(loc)); + push_log(&ui, "INFO acme: issue cancelled by user"); + return; + } + ui.set_cert_issue_step(-1); + ui.set_cert_issue_failed(false); + ui.set_cert_editor_mode(0); + } + }); + + let ui_weak = ui.as_weak(); + let issue_cancel_for_submit = issue_cancel.clone(); + ui.on_cert_apply_submit(move || { + let Some(ui) = ui_weak.upgrade() else { + return; + }; + let loc = locale_of(&ui); + if ui.get_cert_issuing() { + return; + } + let domains_raw = ui.get_cert_apply_domains().to_string(); + let domains: Vec = domains_raw + .split(|c: char| c == ',' || c == ',' || c.is_whitespace()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + if domains.is_empty() { + toast_err(&ui, i18n::cert_domains_required(loc)); + return; + } + let keys = ui.get_key_rows(); + let Some(key) = keys.row_data(ui.get_cert_apply_key_index() as usize) else { + toast_err(&ui, i18n::cert_need_key(loc)); + return; + }; + let provider_id = key.id.to_string(); + issue_cancel_for_submit.store(false, Ordering::SeqCst); + ui.set_cert_issue_failed(false); + ui.set_cert_issue_step(0); + ui.set_cert_issuing(true); + push_log( + &ui, + &format!("INFO acme: issue start domains={}", domains.join(",")), + ); + let ui_weak2 = ui.as_weak(); + let progress = cert_bridge::IssueProgress::new(ui.as_weak()); + let cancel = issue_cancel_for_submit.clone(); + let store = { + let g = cert::store().lock().unwrap_or_else(|e| e.into_inner()); + g.clone() + }; + runtime::spawn(async move { + let progress = progress; + let cancel_flag = cancel.clone(); + let timeout_cancel = cancel.clone(); + let issue = cert::issue( + &store, + cert::IssueRequest { + domains, + provider_id, + }, + cancel, + move |step| progress.report(step), + ); + tokio::pin!(issue); + let result = tokio::select! { + r = &mut issue => r, + _ = tokio::time::sleep(cert::ISSUE_TIMEOUT) => { + timeout_cancel.store(true, Ordering::SeqCst); + match issue.await { + Ok(cert) => Ok(cert), + Err(e) if e.to_string().contains("cancelled") => { + Err(anyhow::anyhow!("timeout")) + } + Err(e) => Err(e), + } + } + }; + + let _ = slint::invoke_from_event_loop(move || { + let Some(ui) = ui_weak2.upgrade() else { + return; + }; + if cancel_flag.load(Ordering::SeqCst) && !ui.get_cert_issuing() { + return; + } + let loc = locale_of(&ui); + ui.set_cert_issuing(false); + match result { + Ok(_) => { + ui.set_cert_issue_step(-1); + ui.set_cert_issue_failed(false); + cert_bridge::refresh(&ui); + ui.set_cert_editor_mode(0); + toast_ok(&ui, i18n::cert_applied(loc)); + push_log(&ui, "INFO acme: issue ok"); + } + Err(e) => { + let msg = e.to_string(); + if msg.contains("cancelled") { + ui.set_cert_issue_step(-1); + ui.set_cert_issue_failed(false); + ui.set_cert_editor_mode(0); + return; + } + if msg.contains("timeout") { + ui.set_cert_issue_failed(true); + toast_err(&ui, i18n::cert_issue_timeout(loc)); + push_log(&ui, "ERROR acme: issue timed out"); + tracing::error!("acme: issue timed out"); + return; + } + ui.set_cert_issue_failed(true); + let detail = format!("{e:#}"); + toast_err(&ui, i18n::cert_issue_failed(loc, &detail)); + push_log(&ui, &format!("ERROR acme: issue failed: {detail}")); + tracing::error!(error = %detail, "acme: issue failed"); + } + } + }); + }); + }); + + let ui_weak = ui.as_weak(); + ui.on_cert_key_save(move || { + let Some(ui) = ui_weak.upgrade() else { + return; + }; + let loc = locale_of(&ui); + let name = ui.get_cert_key_edit_name().to_string(); + if name.trim().is_empty() { + toast_err(&ui, i18n::cert_key_name_required(loc)); + return; + } + let id = ui.get_cert_key_edit_id().to_string(); + let secret = ui.get_cert_key_edit_secret().to_string(); + if id.is_empty() && secret.trim().is_empty() { + toast_err(&ui, i18n::cert_secret_required(loc)); + return; + } + match cert_bridge::save_provider( + &id, + &name, + ui.get_cert_key_edit_vendor_index(), + ui.get_cert_key_edit_access_key_id().as_str(), + &secret, + ) { + Ok(()) => { + cert_bridge::refresh(&ui); + ui.set_cert_editor_mode(0); + toast_ok(&ui, i18n::cert_saved(loc)); + } + Err(e) => toast_err(&ui, e.to_string()), + } + }); + + let ui_weak = ui.as_weak(); + ui.on_cert_key_cancel(move || { + if let Some(ui) = ui_weak.upgrade() { + ui.set_cert_editor_mode(0); + } + }); + + let ui_weak = ui.as_weak(); + ui.on_cert_key_edit(move |index| { + let Some(ui) = ui_weak.upgrade() else { + return; + }; + let keys = ui.get_key_rows(); + let Some(k) = keys.row_data(index as usize) else { + return; + }; + ui.set_cert_key_edit_id(k.id.clone()); + ui.set_cert_key_edit_name(k.name.clone()); + ui.set_cert_key_edit_vendor_index(k.vendor_index); + ui.set_cert_key_edit_access_key_id(k.access_key_id.clone()); + ui.set_cert_key_edit_secret("".into()); + ui.set_cert_editor_mode(2); + }); + + let ui_weak = ui.as_weak(); + ui.on_cert_key_delete(move |index| { + let Some(ui) = ui_weak.upgrade() else { + return; + }; + let loc = locale_of(&ui); + let keys = ui.get_key_rows(); + let Some(k) = keys.row_data(index as usize) else { + return; + }; + match cert_bridge::delete_provider(k.id.as_str()) { + Ok(()) => { + cert_bridge::refresh(&ui); + toast_ok(&ui, i18n::cert_deleted(loc)); + } + Err(e) => toast_err(&ui, e.to_string()), + } + }); + + let ui_weak = ui.as_weak(); + ui.on_cert_detail(move |index| { + let Some(ui) = ui_weak.upgrade() else { + return; + }; + let certs = ui.get_cert_rows(); + let Some(c) = certs.row_data(index as usize) else { + return; + }; + let id = c.id.to_string(); + let (cert_path, key_path) = cert_bridge::resolve_library_paths(&id).unwrap_or_default(); + ui.set_cert_detail_id(c.id.clone()); + ui.set_cert_detail_domains(c.domains.clone()); + ui.set_cert_detail_not_after(c.not_after.clone()); + ui.set_cert_detail_provider_name(c.provider_name.clone()); + ui.set_cert_detail_cert_path(cert_path.into()); + ui.set_cert_detail_key_path(key_path.into()); + ui.set_cert_editor_mode(3); + }); + + let ui_weak = ui.as_weak(); + ui.on_cert_delete(move |index| { + let Some(ui) = ui_weak.upgrade() else { + return; + }; + let loc = locale_of(&ui); + let certs = ui.get_cert_rows(); + let Some(c) = certs.row_data(index as usize) else { + return; + }; + let tunnels: Vec = { + let m = ui.get_tunnels(); + (0..m.row_count()).filter_map(|i| m.row_data(i)).collect() + }; + if cert_bridge::cert_paths_used_by_tunnels(c.id.as_str(), &tunnels) { + toast_err(&ui, i18n::cert_in_use(loc)); + return; + } + match cert_bridge::delete_cert(c.id.as_str()) { + Ok(()) => { + cert_bridge::refresh(&ui); + toast_ok(&ui, i18n::cert_deleted(loc)); + } + Err(e) => toast_err(&ui, e.to_string()), + } + }); + + let ui_weak = ui.as_weak(); + ui.on_cert_detail_close(move || { + if let Some(ui) = ui_weak.upgrade() { + ui.set_cert_editor_mode(0); + } + }); + let ui_weak = ui.as_weak(); let remotes_gen_cfg = remotes_gen.clone(); ui.on_config_save(move || { diff --git a/desktop/src/runtime.rs b/desktop/src/runtime.rs index edd8663..f361364 100644 --- a/desktop/src/runtime.rs +++ b/desktop/src/runtime.rs @@ -58,6 +58,13 @@ pub fn reload_async( }); } +pub fn spawn(fut: F) +where + F: std::future::Future + Send + 'static, +{ + runtime().spawn(fut); +} + pub fn stop_async(on_done: impl FnOnce() + Send + 'static) { let h = handle(); runtime().spawn(async move { diff --git a/desktop/ui/app.slint b/desktop/ui/app.slint index 08056bb..239b0ed 100644 --- a/desktop/ui/app.slint +++ b/desktop/ui/app.slint @@ -5,6 +5,7 @@ import { Theme } from "theme.slint"; import { Sidebar, AppPage } from "sidebar.slint"; import { LaunchPage } from "pages/launch.slint"; import { TunnelPage, TunnelRow } from "pages/tunnel.slint"; +import { CertPage, CertRow, KeyRow } from "pages/cert.slint"; import { ConfigPage } from "pages/config.slint"; import { LoggerPage, LogLine } from "pages/logger.slint"; import { AboutPage } from "pages/about_page.slint"; @@ -14,7 +15,7 @@ import { Tr } from "i18n.slint"; import { AppMeta } from "version.slint"; import { OrbienTray } from "tray.slint"; -export { AppPage, TunnelRow, LogLine, Tr, AboutDialog, AppMeta, Theme, OrbienTray } +export { AppPage, TunnelRow, CertRow, KeyRow, LogLine, Tr, AboutDialog, AppMeta, Theme, OrbienTray } export component AppWindow inherits Window { title: "Orbien Desktop"; default-font-family: "Noto Sans SC"; @@ -119,7 +120,10 @@ export component AppWindow inherits Window { in-out property tunnel-edit-bandwidth-side-index: 0; in-out property tunnel-edit-proxy-protocol-index: 0; in-out property tunnel-edit-plugin-tls-term: false; - in-out property tunnel-edit-plugin-local-addr: "127.0.0.1:80"; + in-out property tunnel-edit-plugin-local-addr: "127.0.0.1:8080"; + in-out property tunnel-edit-cert-source-index: 1; + in-out property tunnel-edit-cert-library-index: 0; + in-out property <[string]> cert-library-labels; in-out property tunnel-edit-plugin-cert-file: ""; in-out property tunnel-edit-plugin-key-file: ""; in-out property tunnel-edit-plugin-host-rewrite: ""; @@ -128,6 +132,29 @@ export component AppWindow inherits Window { in-out property tunnel-edit-type-index: 0; in-out property tunnel-show-advanced: false; + // cert + in-out property <[CertRow]> cert-rows; + in-out property <[KeyRow]> key-rows; + in-out property <[string]> cert-key-labels; + in-out property cert-tab-index: 0; + in-out property cert-editor-mode: 0; + in-out property cert-issuing: false; + in-out property cert-issue-step: -1; + in-out property cert-issue-failed: false; + in-out property cert-apply-domains: ""; + in-out property cert-apply-key-index: 0; + in-out property cert-key-edit-id: ""; + in-out property cert-key-edit-name: ""; + in-out property cert-key-edit-vendor-index: 0; + in-out property cert-key-edit-access-key-id: ""; + in-out property cert-key-edit-secret: ""; + in-out property cert-detail-domains: ""; + in-out property cert-detail-not-after: ""; + in-out property cert-detail-provider-name: ""; + in-out property cert-detail-cert-path: ""; + in-out property cert-detail-key-path: ""; + in-out property cert-detail-id: ""; + // config in-out property server-addr: "127.0.0.1"; in-out property server-port: "9527"; @@ -183,6 +210,16 @@ export component AppWindow inherits Window { callback pick-file(string); callback logs-clear(); callback log-query-edited(string); + callback cert-add(); + callback cert-apply-submit(); + callback cert-apply-cancel(); + callback cert-key-save(); + callback cert-key-cancel(); + callback cert-key-edit(int); + callback cert-key-delete(int); + callback cert-detail(int); + callback cert-delete(int); + callback cert-detail-close(); changed locale-index => { Tr.locale-index = root.locale-index; @@ -259,6 +296,9 @@ export component AppWindow inherits Window { edit-proxy-protocol-index <=> root.tunnel-edit-proxy-protocol-index; edit-plugin-tls-term <=> root.tunnel-edit-plugin-tls-term; edit-plugin-local-addr <=> root.tunnel-edit-plugin-local-addr; + edit-cert-source-index <=> root.tunnel-edit-cert-source-index; + edit-cert-library-index <=> root.tunnel-edit-cert-library-index; + cert-library-labels: root.cert-library-labels; edit-plugin-cert-file <=> root.tunnel-edit-plugin-cert-file; edit-plugin-key-file <=> root.tunnel-edit-plugin-key-file; edit-plugin-host-rewrite <=> root.tunnel-edit-plugin-host-rewrite; @@ -275,6 +315,44 @@ export component AppWindow inherits Window { pick-file(target) => { root.pick-file(target); } } + CertPage { + x: parent.content-x; + y: parent.content-y; + width: parent.content-w; + height: parent.content-h; + visible: root.page == AppPage.cert; + certs: root.cert-rows; + keys: root.key-rows; + key-labels: root.cert-key-labels; + tab-index <=> root.cert-tab-index; + editor-mode <=> root.cert-editor-mode; + issuing <=> root.cert-issuing; + issue-step <=> root.cert-issue-step; + issue-failed <=> root.cert-issue-failed; + apply-domains <=> root.cert-apply-domains; + apply-key-index <=> root.cert-apply-key-index; + key-edit-id <=> root.cert-key-edit-id; + key-edit-name <=> root.cert-key-edit-name; + key-edit-vendor-index <=> root.cert-key-edit-vendor-index; + key-edit-access-key-id <=> root.cert-key-edit-access-key-id; + key-edit-secret <=> root.cert-key-edit-secret; + detail-domains <=> root.cert-detail-domains; + detail-not-after <=> root.cert-detail-not-after; + detail-provider-name <=> root.cert-detail-provider-name; + detail-cert-path <=> root.cert-detail-cert-path; + detail-key-path <=> root.cert-detail-key-path; + add-clicked => { root.cert-add(); } + apply-submit => { root.cert-apply-submit(); } + apply-cancel => { root.cert-apply-cancel(); } + key-save => { root.cert-key-save(); } + key-cancel => { root.cert-key-cancel(); } + key-edit(i) => { root.cert-key-edit(i); } + key-delete(i) => { root.cert-key-delete(i); } + cert-detail(i) => { root.cert-detail(i); } + cert-delete(i) => { root.cert-delete(i); } + detail-close => { root.cert-detail-close(); } + } + ConfigPage { x: parent.content-x; y: parent.content-y; diff --git a/desktop/ui/icons.slint b/desktop/ui/icons.slint index b366c19..2fb8518 100644 --- a/desktop/ui/icons.slint +++ b/desktop/ui/icons.slint @@ -30,6 +30,10 @@ export component IconAbout inherits AppIcon { source: @image-url("../assets/icon/about.svg"); } +export component IconCert inherits AppIcon { + source: @image-url("../assets/icon/cert.svg"); +} + export component IconGithub inherits AppIcon { source: @image-url("../assets/icon/github.svg"); } diff --git a/desktop/ui/pages/cert.slint b/desktop/ui/pages/cert.slint new file mode 100644 index 0000000..fef61ea --- /dev/null +++ b/desktop/ui/pages/cert.slint @@ -0,0 +1,871 @@ +import { ScrollView, Button } from "std-widgets.slint"; +import { Theme } from "../theme.slint"; +import { IconPlus } from "../icons.slint"; +import { Tr } from "../i18n.slint"; +import { FormField, FormCombo, FormRow } from "tunnel/fields.slint"; + +export struct CertRow { + id: string, + domains: string, + not-after: string, + provider-id: string, + provider-name: string, + expired: bool, +} + +export struct KeyRow { + id: string, + name: string, + vendor: string, + vendor-index: int, + access-key-id: string, + secret-mask: string, +} + +component StatusBadge inherits Rectangle { + in property expired: false; + border-radius: 3px; + background: root.expired ? Theme.danger-soft : Theme.accent-soft; + horizontal-stretch: 0; + vertical-stretch: 0; + height: 22px; + + HorizontalLayout { + padding-left: 7px; + padding-right: 7px; + Text { + text: root.expired ? Tr.cert-expired : Tr.cert-valid; + color: root.expired ? Theme.danger : Theme.accent-strong; + font-size: 12px; + font-weight: 700; + vertical-alignment: center; + } + } +} + +component CertCard inherits Rectangle { + in property item; + callback detail-clicked; + callback delete-clicked; + + background: Theme.panel; + border-radius: Theme.radius; + border-width: 1px; + border-color: Theme.line; + drop-shadow-blur: 12px; + drop-shadow-color: Theme.shadow-soft; + drop-shadow-offset-y: 3px; + clip: true; + + VerticalLayout { + width: 100%; + height: 100%; + padding: 12px; + spacing: 8px; + alignment: stretch; + + HorizontalLayout { + alignment: space-between; + vertical-stretch: 0; + spacing: 12px; + + HorizontalLayout { + spacing: 8px; + alignment: start; + horizontal-stretch: 1; + + Text { + text: root.item.domains; + color: Theme.text; + font-size: 17px; + font-weight: 700; + vertical-alignment: center; + overflow: elide; + horizontal-stretch: 1; + } + + StatusBadge { + expired: root.item.expired; + } + } + + HorizontalLayout { + spacing: 14px; + alignment: start; + horizontal-stretch: 0; + + TouchArea { + mouse-cursor: pointer; + clicked => { root.detail-clicked(); } + Text { + text: Tr.cert-detail; + color: Theme.accent; + font-size: 14px; + font-weight: 600; + vertical-alignment: center; + } + } + + TouchArea { + mouse-cursor: pointer; + clicked => { root.delete-clicked(); } + Text { + text: Tr.delete; + color: Theme.danger; + font-size: 14px; + font-weight: 600; + vertical-alignment: center; + } + } + } + } + + VerticalLayout { + spacing: 6px; + vertical-stretch: 1; + alignment: stretch; + + HorizontalLayout { + spacing: 12px; + alignment: start; + vertical-stretch: 0; + + Text { + text: Tr.cert-issuer; + color: Theme.muted; + font-size: 14px; + width: 64px; + horizontal-stretch: 0; + vertical-alignment: center; + } + + Text { + text: "Let's Encrypt"; + color: Theme.text; + font-size: 15px; + font-weight: 700; + horizontal-stretch: 1; + overflow: elide; + vertical-alignment: center; + } + } + + HorizontalLayout { + spacing: 12px; + alignment: start; + vertical-stretch: 0; + + Text { + text: Tr.cert-not-after; + color: Theme.muted; + font-size: 14px; + width: 64px; + horizontal-stretch: 0; + vertical-alignment: center; + } + + Text { + text: root.item.not-after; + color: root.item.expired ? Theme.danger : Theme.text; + font-size: 15px; + font-weight: 700; + horizontal-stretch: 1; + overflow: elide; + vertical-alignment: center; + } + } + } + } +} + +export component CertCardGrid inherits Rectangle { + in property <[CertRow]> certs; + callback detail-row(int); + callback delete-row(int); + + private property gap: 14px; + private property card-h: 118px; + private property cols: self.width >= 900px ? 3 : 2; + private property card-w: (self.width - root.gap * (root.cols - 1)) / root.cols; + private property row-count: + root.certs.length == 0 ? 0 + : Math.floor((root.certs.length + root.cols - 1) / root.cols); + + height: root.row-count == 0 ? 0 + : root.row-count * root.card-h + (root.row-count - 1) * root.gap; + background: transparent; + + for c[i] in root.certs: CertCard { + x: Math.mod(i, root.cols) * (root.card-w + root.gap); + y: Math.floor(i / root.cols) * (root.card-h + root.gap); + width: root.card-w; + height: root.card-h; + item: c; + detail-clicked => { root.detail-row(i); } + delete-clicked => { root.delete-row(i); } + } +} + +component KeyCard inherits Rectangle { + in property item; + callback edit-clicked; + callback delete-clicked; + + background: Theme.panel; + border-radius: Theme.radius; + border-width: 1px; + border-color: Theme.line; + drop-shadow-blur: 12px; + drop-shadow-color: Theme.shadow-soft; + drop-shadow-offset-y: 3px; + clip: true; + + VerticalLayout { + width: 100%; + height: 100%; + padding: 12px; + spacing: 8px; + alignment: stretch; + + HorizontalLayout { + alignment: space-between; + vertical-stretch: 0; + spacing: 12px; + + Text { + text: root.item.name; + color: Theme.text; + font-size: 17px; + font-weight: 700; + vertical-alignment: center; + overflow: elide; + horizontal-stretch: 1; + } + + HorizontalLayout { + spacing: 14px; + alignment: start; + horizontal-stretch: 0; + + TouchArea { + mouse-cursor: pointer; + clicked => { root.edit-clicked(); } + Text { + text: Tr.modify; + color: Theme.accent; + font-size: 14px; + font-weight: 600; + vertical-alignment: center; + } + } + + TouchArea { + mouse-cursor: pointer; + clicked => { root.delete-clicked(); } + Text { + text: Tr.delete; + color: Theme.danger; + font-size: 14px; + font-weight: 600; + vertical-alignment: center; + } + } + } + } + + VerticalLayout { + spacing: 6px; + vertical-stretch: 1; + alignment: stretch; + + HorizontalLayout { + spacing: 12px; + alignment: start; + vertical-stretch: 0; + + Text { + text: Tr.cert-key-vendor; + color: Theme.muted; + font-size: 14px; + width: 40px; + horizontal-stretch: 0; + vertical-alignment: center; + } + + Text { + text: root.item.vendor; + color: Theme.text; + font-size: 15px; + font-weight: 700; + horizontal-stretch: 1; + overflow: elide; + vertical-alignment: center; + } + } + + HorizontalLayout { + spacing: 12px; + alignment: start; + vertical-stretch: 0; + + Text { + text: Tr.cert-key; + color: Theme.muted; + font-size: 14px; + width: 40px; + horizontal-stretch: 0; + vertical-alignment: center; + } + + Text { + text: root.item.secret-mask; + color: Theme.muted; + font-size: 15px; + font-weight: 700; + horizontal-stretch: 1; + overflow: elide; + vertical-alignment: center; + } + } + } + } +} + +export component KeyCardGrid inherits Rectangle { + in property <[KeyRow]> keys; + callback edit-row(int); + callback delete-row(int); + + private property gap: 14px; + private property card-h: 118px; + private property cols: self.width >= 900px ? 3 : 2; + private property card-w: (self.width - root.gap * (root.cols - 1)) / root.cols; + private property row-count: + root.keys.length == 0 ? 0 + : Math.floor((root.keys.length + root.cols - 1) / root.cols); + + height: root.row-count == 0 ? 0 + : root.row-count * root.card-h + (root.row-count - 1) * root.gap; + background: transparent; + + for k[i] in root.keys: KeyCard { + x: Math.mod(i, root.cols) * (root.card-w + root.gap); + y: Math.floor(i / root.cols) * (root.card-h + root.gap); + width: root.card-w; + height: root.card-h; + item: k; + edit-clicked => { root.edit-row(i); } + delete-clicked => { root.delete-row(i); } + } +} + +component CertEditor inherits Rectangle { + background: Theme.panel; + border-radius: Theme.radius; + border-width: 1px; + border-color: Theme.line; + drop-shadow-blur: 14px; + drop-shadow-color: Theme.shadow; + drop-shadow-offset-y: 4px; + vertical-stretch: 0; + width: 100%; + + VerticalLayout { + padding: 20px; + padding-bottom: 12px; + spacing: 12px; + alignment: start; + @children + } +} + +component ApplyStepRow inherits HorizontalLayout { + in property label; + in property index; + in property current; + in property failed: false; + spacing: 10px; + vertical-stretch: 0; + height: 34px; + + private property done: root.current > root.index; + private property active: root.current == root.index; + + Rectangle { + width: 8px; + height: 8px; + border-radius: 4px; + vertical-stretch: 0; + y: 13px; + background: root.active && root.failed ? Theme.danger + : (root.done || root.active ? Theme.accent : Theme.line); + } + Text { + text: root.label; + color: root.active && root.failed ? Theme.danger + : (root.active ? Theme.text + : (root.done ? Theme.accent-strong : Theme.muted)); + font-size: 13px; + font-weight: root.active ? 700 : 500; + vertical-alignment: center; + horizontal-stretch: 1; + overflow: elide; + } +} + +component ApplyProgressPanel inherits Rectangle { + in property current: -1; + in property failed: false; + in property issuing: false; + vertical-stretch: 0; + horizontal-stretch: 1; + background: Theme.bg; + border-radius: Theme.radius; + border-width: 1px; + border-color: Theme.line; + + VerticalLayout { + padding: 16px; + padding-bottom: 20px; + spacing: 2px; + alignment: start; + + Text { + text: root.issuing ? Tr.cert-issuing : Tr.cert-step-title; + color: root.failed ? Theme.danger : Theme.text; + font-size: 13px; + font-weight: 700; + height: 22px; + vertical-stretch: 0; + vertical-alignment: center; + } + Rectangle { + height: 10px; + vertical-stretch: 0; + background: transparent; + } + ApplyStepRow { + label: Tr.cert-step-prepare; + index: 0; + current: root.current; + failed: root.failed; + } + ApplyStepRow { + label: Tr.cert-step-order; + index: 1; + current: root.current; + failed: root.failed; + } + ApplyStepRow { + label: Tr.cert-step-dns; + index: 2; + current: root.current; + failed: root.failed; + } + ApplyStepRow { + label: Tr.cert-step-wait; + index: 3; + current: root.current; + failed: root.failed; + } + ApplyStepRow { + label: Tr.cert-step-validate; + index: 4; + current: root.current; + failed: root.failed; + } + ApplyStepRow { + label: Tr.cert-step-finalize; + index: 5; + current: root.current; + failed: root.failed; + } + ApplyStepRow { + label: Tr.cert-step-cleanup; + index: 6; + current: root.current; + failed: root.failed; + } + } +} + +export component CertPage inherits Rectangle { + in property <[CertRow]> certs; + in property <[KeyRow]> keys; + in property <[string]> key-labels; + in-out property tab-index: 0; + in-out property editor-mode: 0; + in-out property issuing: false; + in-out property issue-step: -1; + in-out property issue-failed: false; + + in-out property apply-domains: ""; + in-out property apply-key-index: 0; + + in-out property key-edit-id: ""; + in-out property key-edit-name: ""; + in-out property key-edit-vendor-index: 0; + in-out property key-edit-access-key-id: ""; + in-out property key-edit-secret: ""; + + in-out property detail-domains: ""; + in-out property detail-not-after: ""; + in-out property detail-provider-name: ""; + in-out property detail-cert-path: ""; + in-out property detail-key-path: ""; + + callback add-clicked; + callback apply-submit; + callback apply-cancel; + callback key-save; + callback key-cancel; + callback key-edit(int); + callback key-delete(int); + callback cert-detail(int); + callback cert-delete(int); + callback detail-close; + + private property show-list: root.editor-mode == 0; + private property show-apply: root.editor-mode == 1; + private property show-key-form: root.editor-mode == 2; + private property show-detail: root.editor-mode == 3; + private property vendor-needs-ak: root.key-edit-vendor-index != 2; + + background: transparent; + min-height: Theme.page-min-height; + + VerticalLayout { + width: 100%; + height: 100%; + spacing: 16px; + alignment: stretch; + + HorizontalLayout { + height: 38px; + vertical-stretch: 0; + spacing: 10px; + Text { + text: Tr.cert-title; + color: Theme.accent; + font-size: 22px; + font-weight: 700; + vertical-alignment: center; + horizontal-stretch: 1; + } + if root.show-list: TouchArea { + width: 38px; + height: 38px; + mouse-cursor: pointer; + clicked => { root.add-clicked(); } + Rectangle { + width: 100%; + height: 100%; + border-radius: Theme.radius; + background: Theme.accent; + IconPlus { + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + size: 16px; + tint: white; + } + } + } + } + + if root.show-list: HorizontalLayout { + height: 34px; + vertical-stretch: 0; + spacing: 4px; + TouchArea { + mouse-cursor: pointer; + clicked => { root.tab-index = 0; } + VerticalLayout { + HorizontalLayout { + padding-left: 12px; + padding-right: 12px; + Text { + text: Tr.cert-tab-certs; + color: root.tab-index == 0 ? Theme.accent : Theme.muted; + font-size: 13px; + font-weight: 600; + } + } + Rectangle { + height: 2px; + background: root.tab-index == 0 ? Theme.accent : transparent; + } + } + } + TouchArea { + mouse-cursor: pointer; + clicked => { root.tab-index = 1; } + VerticalLayout { + HorizontalLayout { + padding-left: 12px; + padding-right: 12px; + Text { + text: Tr.cert-tab-keys; + color: root.tab-index == 1 ? Theme.accent : Theme.muted; + font-size: 13px; + font-weight: 600; + } + } + Rectangle { + height: 2px; + background: root.tab-index == 1 ? Theme.accent : transparent; + } + } + } + } + + if root.show-list && root.tab-index == 0: ScrollView { + vertical-stretch: 1; + width: 100%; + viewport-width: self.width; + VerticalLayout { + width: 100%; + spacing: 14px; + padding-right: 4px; + padding-bottom: 8px; + alignment: stretch; + if root.certs.length == 0: Rectangle { + background: Theme.panel; + border-radius: Theme.radius; + border-width: 1px; + border-color: Theme.line; + height: 120px; + vertical-stretch: 0; + VerticalLayout { + padding: 24px; + alignment: center; + Text { + text: Tr.cert-empty; + color: Theme.muted; + horizontal-alignment: center; + } + } + } + if root.certs.length > 0: CertCardGrid { + width: 100%; + vertical-stretch: 0; + certs: root.certs; + detail-row(i) => { root.cert-detail(i); } + delete-row(i) => { root.cert-delete(i); } + } + } + } + + if root.show-list && root.tab-index == 1: ScrollView { + vertical-stretch: 1; + width: 100%; + viewport-width: self.width; + VerticalLayout { + width: 100%; + spacing: 14px; + padding-right: 4px; + padding-bottom: 8px; + alignment: stretch; + if root.keys.length == 0: Rectangle { + background: Theme.panel; + border-radius: Theme.radius; + border-width: 1px; + border-color: Theme.line; + height: 120px; + vertical-stretch: 0; + VerticalLayout { + padding: 24px; + alignment: center; + Text { + text: Tr.cert-key-empty; + color: Theme.muted; + horizontal-alignment: center; + } + } + } + if root.keys.length > 0: KeyCardGrid { + width: 100%; + vertical-stretch: 0; + keys: root.keys; + edit-row(i) => { root.key-edit(i); } + delete-row(i) => { root.key-delete(i); } + } + } + } + + if !root.show-list: ScrollView { + vertical-stretch: 1; + width: 100%; + viewport-width: self.width; + VerticalLayout { + width: 100%; + spacing: 0; + padding-bottom: 12px; + alignment: start; + + if root.show-apply: CertEditor { + vertical-stretch: 0; + HorizontalLayout { + height: 36px; + vertical-stretch: 0; + spacing: 12px; + Text { + text: Tr.cert-apply-title; + color: Theme.text; + font-size: 16px; + font-weight: 700; + vertical-alignment: center; + horizontal-stretch: 1; + } + Button { + text: Tr.cancel; + height: 32px; + vertical-stretch: 0; + clicked => { root.apply-cancel(); } + } + Button { + text: root.issuing ? Tr.cert-issuing : Tr.cert-apply; + primary: true; + height: 32px; + vertical-stretch: 0; + enabled: !root.issuing; + clicked => { root.apply-submit(); } + } + } + + FormRow { + FormCombo { + label: Tr.cert-issuer; + model: ["Let's Encrypt"]; + current-index: 0; + } + FormCombo { + label: Tr.cert-key; + model: root.key-labels; + current-index <=> root.apply-key-index; + } + } + FormField { + label: Tr.cert-domains; + value <=> root.apply-domains; + placeholder: Tr.cert-domains-hint; + read-only: root.issuing; + } + + if root.issue-step >= 0: ApplyProgressPanel { + current: root.issue-step; + failed: root.issue-failed; + issuing: root.issuing; + } + } + + if root.show-key-form: CertEditor { + HorizontalLayout { + height: 36px; + vertical-stretch: 0; + spacing: 12px; + Text { + text: root.key-edit-id == "" ? Tr.cert-key-add : Tr.cert-key-edit; + color: Theme.text; + font-size: 16px; + font-weight: 700; + vertical-alignment: center; + horizontal-stretch: 1; + } + Button { + text: Tr.cancel; + height: 32px; + vertical-stretch: 0; + clicked => { root.key-cancel(); } + } + Button { + text: Tr.save; + primary: true; + height: 32px; + vertical-stretch: 0; + clicked => { root.key-save(); } + } + } + + FormRow { + FormField { + label: Tr.cert-key-name; + value <=> root.key-edit-name; + } + FormCombo { + label: Tr.cert-key-vendor; + model: ["Aliyun", "Tencent", "Cloudflare"]; + current-index <=> root.key-edit-vendor-index; + } + } + if root.vendor-needs-ak: FormField { + label: root.key-edit-vendor-index == 0 ? Tr.cert-access-key-id : Tr.cert-secret-id; + value <=> root.key-edit-access-key-id; + } + FormField { + label: root.key-edit-vendor-index == 2 ? Tr.cert-api-token + : (root.key-edit-vendor-index == 0 ? Tr.cert-access-key-secret : Tr.cert-secret-key); + value <=> root.key-edit-secret; + placeholder: root.key-edit-id == "" ? "" : Tr.cert-secret-keep; + } + } + + if root.show-detail: CertEditor { + HorizontalLayout { + height: 36px; + vertical-stretch: 0; + spacing: 12px; + Text { + text: Tr.cert-detail-title; + color: Theme.text; + font-size: 16px; + font-weight: 700; + vertical-alignment: center; + horizontal-stretch: 1; + } + Button { + text: Tr.cancel; + height: 32px; + vertical-stretch: 0; + clicked => { root.detail-close(); } + } + } + + FormRow { + FormField { + label: Tr.cert-domains; + value <=> root.detail-domains; + read-only: true; + } + FormField { + label: Tr.cert-issuer; + value: "Let's Encrypt"; + read-only: true; + } + } + FormRow { + FormField { + label: Tr.cert-not-after; + value <=> root.detail-not-after; + read-only: true; + } + FormField { + label: Tr.cert-key; + value <=> root.detail-provider-name; + read-only: true; + } + } + FormField { + label: Tr.tunnel-plugin-cert; + value <=> root.detail-cert-path; + read-only: true; + } + FormField { + label: Tr.tunnel-plugin-key; + value <=> root.detail-key-path; + read-only: true; + } + } + } + } + } +} diff --git a/desktop/ui/pages/logger.slint b/desktop/ui/pages/logger.slint index 010b3c8..3b1f718 100644 --- a/desktop/ui/pages/logger.slint +++ b/desktop/ui/pages/logger.slint @@ -22,7 +22,7 @@ component ConsoleSearch inherits Rectangle { height: 34px; horizontal-stretch: 1; vertical-stretch: 0; - border-radius: 6px; + border-radius: Theme.radius; background: #ffffff12; border-width: 1px; border-color: root.focused ? #ffffff44 : #ffffff22; @@ -297,7 +297,7 @@ export component LoggerPage inherits Rectangle { clicked => { root.clear-clicked(); } Rectangle { - border-radius: 6px; + border-radius: Theme.radius; background: parent.has-hover ? #ffffff24 : #ffffff14; border-width: 1px; border-color: #ffffff2e; diff --git a/desktop/ui/pages/tunnel.slint b/desktop/ui/pages/tunnel.slint index af2ad90..d66d888 100644 --- a/desktop/ui/pages/tunnel.slint +++ b/desktop/ui/pages/tunnel.slint @@ -25,6 +25,9 @@ export component TunnelPage inherits Rectangle { in-out property edit-proxy-protocol-index: 0; in-out property edit-plugin-tls-term: false; in-out property edit-plugin-local-addr: "127.0.0.1:8080"; + in-out property edit-cert-source-index: 1; + in-out property edit-cert-library-index: 0; + in property <[string]> cert-library-labels; in-out property edit-plugin-cert-file: ""; in-out property edit-plugin-key-file: ""; in-out property edit-plugin-host-rewrite: ""; @@ -170,6 +173,9 @@ export component TunnelPage inherits Rectangle { edit-proxy-protocol-index <=> root.edit-proxy-protocol-index; edit-plugin-tls-term <=> root.edit-plugin-tls-term; edit-plugin-local-addr <=> root.edit-plugin-local-addr; + edit-cert-source-index <=> root.edit-cert-source-index; + edit-cert-library-index <=> root.edit-cert-library-index; + cert-library-labels: root.cert-library-labels; edit-plugin-cert-file <=> root.edit-plugin-cert-file; edit-plugin-key-file <=> root.edit-plugin-key-file; edit-plugin-host-rewrite <=> root.edit-plugin-host-rewrite; diff --git a/desktop/ui/pages/tunnel/card.slint b/desktop/ui/pages/tunnel/card.slint index 6214126..611a686 100644 --- a/desktop/ui/pages/tunnel/card.slint +++ b/desktop/ui/pages/tunnel/card.slint @@ -47,7 +47,7 @@ export component TunnelCard inherits Rectangle { : root.item.local-port != "" ? root.item.local-port : "—"; background: Theme.panel; - border-radius: 12px; + border-radius: Theme.radius; border-width: 1px; border-color: Theme.line; drop-shadow-blur: 12px; @@ -180,7 +180,7 @@ export component TunnelCard inherits Rectangle { clicked => { root.copy-clicked(root.remote-text); } Rectangle { height: 22px; - border-radius: 3px; + border-radius: Theme.radius; background: Theme.accent-soft; HorizontalLayout { padding-left: 8px; diff --git a/desktop/ui/pages/tunnel/editor.slint b/desktop/ui/pages/tunnel/editor.slint index 9a01fd5..5f308bb 100644 --- a/desktop/ui/pages/tunnel/editor.slint +++ b/desktop/ui/pages/tunnel/editor.slint @@ -18,7 +18,11 @@ export component TunnelEditor inherits Rectangle { in-out property edit-bandwidth-side-index: 0; in-out property edit-proxy-protocol-index: 0; in-out property edit-plugin-tls-term: false; - in-out property edit-plugin-local-addr: "127.0.0.1:80"; + in-out property edit-plugin-local-addr: "127.0.0.1:8080"; + + in-out property edit-cert-source-index: 1; + in-out property edit-cert-library-index: 0; + in property <[string]> cert-library-labels; in-out property edit-plugin-cert-file: ""; in-out property edit-plugin-key-file: ""; in-out property edit-plugin-host-rewrite: ""; @@ -33,6 +37,8 @@ export component TunnelEditor inherits Rectangle { private property is-https: root.edit-type-index == 3; private property is-domain: root.edit-type-index == 2 || root.edit-type-index == 3; private property use-plugin: root.is-https && root.edit-plugin-tls-term; + private property use-cert-library: root.use-plugin && root.edit-cert-source-index == 0; + private property use-cert-file: root.use-plugin && root.edit-cert-source-index == 1; callback save-clicked; callback cancel-clicked; @@ -108,21 +114,21 @@ export component TunnelEditor inherits Rectangle { } } + if root.is-socks5: FormField { + label: Tr.tunnel-remote-port; + value <=> root.edit-remote-port; + placeholder: "9000"; + } + if root.is-socks5: FormRow { - FormField { - label: Tr.tunnel-remote-port; - value <=> root.edit-remote-port; - placeholder: "9000"; - } FormField { label: Tr.tunnel-plugin-username; value <=> root.edit-plugin-username; } - } - - if root.is-socks5: FormField { - label: Tr.tunnel-plugin-password; - value <=> root.edit-plugin-password; + FormField { + label: Tr.tunnel-plugin-password; + value <=> root.edit-plugin-password; + } } if root.is-http: FormRow { @@ -138,11 +144,11 @@ export component TunnelEditor inherits Rectangle { } } - if root.is-https && !root.use-plugin: FormRow { + if root.is-https: FormRow { FormField { label: Tr.tunnel-local-port; value <=> root.edit-local-port; - placeholder: "443"; + placeholder: root.use-plugin ? "8080" : "443"; } FormField { label: Tr.tunnel-domains; @@ -151,12 +157,6 @@ export component TunnelEditor inherits Rectangle { } } - if root.is-https && root.use-plugin: FormField { - label: Tr.tunnel-domains; - value <=> root.edit-domains; - placeholder: Tr.tunnel-domains-hint; - } - TouchArea { mouse-cursor: pointer; vertical-stretch: 0; @@ -246,9 +246,9 @@ export component TunnelEditor inherits Rectangle { alignment: stretch; FormRow { FormField { - label: Tr.tunnel-plugin-local-addr; - value <=> root.edit-plugin-local-addr; - placeholder: "127.0.0.1:80"; + label: Tr.tunnel-local-ip; + value <=> root.edit-local-ip; + placeholder: "127.0.0.1"; } FormField { label: Tr.tunnel-host-rewrite; @@ -257,6 +257,18 @@ export component TunnelEditor inherits Rectangle { } } FormRow { + FormCombo { + label: Tr.tunnel-cert-source; + model: [Tr.tunnel-cert-library, Tr.tunnel-cert-file, Tr.tunnel-cert-self]; + current-index <=> root.edit-cert-source-index; + } + if root.use-cert-library: FormCombo { + label: Tr.tunnel-cert-pick; + model: root.cert-library-labels; + current-index <=> root.edit-cert-library-index; + } + } + if root.use-cert-file: FormRow { FormPathField { label: Tr.tunnel-plugin-cert; value <=> root.edit-plugin-cert-file; diff --git a/desktop/ui/pages/tunnel/fields.slint b/desktop/ui/pages/tunnel/fields.slint index 7e7856e..3e1d11c 100644 --- a/desktop/ui/pages/tunnel/fields.slint +++ b/desktop/ui/pages/tunnel/fields.slint @@ -6,6 +6,7 @@ export component FormField inherits Rectangle { in property label; in-out property value; in property placeholder: ""; + in property read-only: false; vertical-stretch: 0; horizontal-stretch: 1; height: 60px; @@ -30,6 +31,7 @@ export component FormField inherits Rectangle { height: 36px; text <=> root.value; placeholder-text: root.placeholder; + read-only: root.read-only; } } diff --git a/desktop/ui/sidebar.slint b/desktop/ui/sidebar.slint index d7a2826..71181bf 100644 --- a/desktop/ui/sidebar.slint +++ b/desktop/ui/sidebar.slint @@ -2,6 +2,7 @@ import { Theme } from "theme.slint"; import { IconRocket, IconCloud, + IconCert, IconSetting, IconLog, IconAbout, @@ -12,6 +13,7 @@ import { AppMeta } from "version.slint"; export enum AppPage { launch, tunnel, + cert, config, logger, about, @@ -144,6 +146,19 @@ export component Sidebar inherits Rectangle { NavButton { x: (parent.width - root.nav-size) / 2; y: root.nav-top + (root.nav-size + root.nav-gap) * 2; + active: root.page == AppPage.cert; + clicked => { root.page = AppPage.cert; } + IconCert { + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + size: 22px; + tint: root.page == AppPage.cert ? Theme.accent : Theme.nav-idle; + } + } + + NavButton { + x: (parent.width - root.nav-size) / 2; + y: root.nav-top + (root.nav-size + root.nav-gap) * 3; active: root.page == AppPage.config; clicked => { root.page = AppPage.config; } IconSetting { @@ -156,7 +171,7 @@ export component Sidebar inherits Rectangle { NavButton { x: (parent.width - root.nav-size) / 2; - y: root.nav-top + (root.nav-size + root.nav-gap) * 3; + y: root.nav-top + (root.nav-size + root.nav-gap) * 4; active: root.page == AppPage.logger; clicked => { root.page = AppPage.logger; } IconLog { @@ -169,7 +184,7 @@ export component Sidebar inherits Rectangle { NavButton { x: (parent.width - root.nav-size) / 2; - y: root.nav-top + (root.nav-size + root.nav-gap) * 4; + y: root.nav-top + (root.nav-size + root.nav-gap) * 5; active: root.page == AppPage.about; clicked => { root.page = AppPage.about; } IconAbout {