feat(desktop): add ACME certificate issuance with multi-cloud DNS-01 validation

This commit is contained in:
lxien
2026-09-27 21:57:00 +08:00
parent 8ff2db8553
commit ebd4b063fd
28 changed files with 3466 additions and 75 deletions
Generated
+323 -7
View File
@@ -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"
+13
View File
@@ -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"
+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1790414959115" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="23782" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24"><path d="M512 85.333333c72.533333 0 139.808 8.362667 200.202667 22.325334 21.258667 4.906667 40.192 10.208 56.586666 15.52 9.301333 3.018667 16.106667 5.472 20.245334 7.114666C831.733333 144.906667 864 192.106667 864 238.624v385.034667c0 43.402667-25.130667 95.52-58.837333 121.109333l-227.626667 172.810667c-36.992 28.085333-94.005333 28.149333-131.072 0l-227.626667-172.810667C185.28 719.285333 160 666.912 160 623.658667V238.624c0-46.826667 32.170667-94.218667 75.157333-108.405333 4.16-1.642667 10.901333-4.074667 20.053334-7.04a684.437333 684.437333 0 0 1 56.586666-15.52C372.192 93.685333 439.466667 85.333333 512 85.333333z m0 64c-67.456 0-129.930667 7.765333-185.781333 20.682667-19.424 4.48-36.586667 9.28-51.274667 14.048-5.056 1.632-9.418667 3.146667-13.056 4.48-2.058667 0.746667-3.349333 1.248-3.84 1.450667a32 32 0 0 1-2.485333 0.885333c-16.074667 5.034667-31.562667 27.626667-31.562667 47.744v385.034667c0 23.498667 16.128 56.917333 33.536 70.133333l227.626667 172.810667c14.176 10.773333 39.530667 10.741333 53.674666 0l227.626667-172.810667C784 680.469333 800 647.306667 800 623.658667V238.624c0-19.84-15.626667-42.506667-32-47.882667l-2.048-0.746666a143.829333 143.829333 0 0 0-3.84-1.450667c-3.637333-1.333333-8-2.848-13.056-4.48a620.778667 620.778667 0 0 0-51.274667-14.048C641.930667 157.098667 579.456 149.333333 512 149.333333z m137.354667 190.72a32 32 0 0 1 45.290666 45.226667l-213.013333 213.333333a32 32 0 0 1-45.226667 0.064l-106.986666-106.549333a32 32 0 1 1 45.162666-45.344l84.341334 83.989333z" fill="#000000" p-id="23783"></path></svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

+56 -3
View File
@@ -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
+55 -3
View File
@@ -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=已复制到剪贴板
+183
View File
@@ -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<Sha1>;
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<Self> {
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, String>) -> String {
let canonical: String = params
.iter()
.map(|(k, v)| format!("{}={}", enc(k), enc(v)))
.collect::<Vec<_>>()
.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<String, String>) -> Result<serde_json::Value> {
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(&params);
params.insert("Signature".into(), signature);
let query: String = params
.iter()
.map(|(k, v)| format!("{}={}", enc(k), enc(v)))
.collect::<Vec<_>>()
.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<String> {
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<serde_json::Value> {
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()
}
+155
View File
@@ -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<Self> {
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<Vec<CfZone>> = 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<String> {
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<Vec<CfRecord>> = 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<CfRecord> = 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<serde_json::Value> = 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<T> {
success: bool,
#[serde(default)]
result: T,
#[serde(default)]
errors: Vec<serde_json::Value>,
}
#[derive(Debug, Deserialize)]
struct CfZone {
id: String,
name: String,
}
#[derive(Debug, Deserialize, Default)]
struct CfRecord {
#[serde(default)]
id: String,
#[serde(default)]
content: String,
}
+117
View File
@@ -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<String>;
async fn delete_txt(&self, record_id: &str) -> Result<()>;
}
pub fn build_provider(provider: &Provider, secret: &str) -> Result<Box<dyn DnsProvider>> {
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<Vec<String>> {
let resp = client
.get(url)
.header("Accept", "application/dns-json")
.send()
.await
.context("doh request")?
.error_for_status()
.context("doh status")?
.json::<serde_json::Value>()
.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)
}
+250
View File
@@ -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<Sha256>;
pub struct TencentDns {
secret_id: String,
secret_key: String,
client: reqwest::Client,
}
impl TencentDns {
pub fn new(secret_id: &str, secret_key: &str) -> Result<Self> {
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<serde_json::Value> {
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<String> {
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::<u64>().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<u8> {
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<TcError>,
#[serde(flatten)]
extra: serde_json::Value,
}
#[derive(Debug, Deserialize)]
struct TcError {
#[serde(rename = "Code")]
code: String,
#[serde(rename = "Message")]
message: String,
}
+267
View File
@@ -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<String>,
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<T, E>(
cancel: &AtomicBool,
fut: impl Future<Output = Result<T, E>>,
) -> Result<T>
where
E: Into<anyhow::Error>,
{
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<AtomicBool>,
mut on_step: impl FnMut(IssueStep) + Send,
) -> Result<Cert> {
let domains: Vec<String> = 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<Identifier> = 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<PendingTxt> = 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<String> = 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<Account> {
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)
}
+26
View File
@@ -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<Mutex<Store>> = OnceLock::new();
pub fn store() -> &'static Mutex<Store> {
STORE.get_or_init(|| {
let s = Store::open(config_bridge::data_dir()).expect("open cert store");
Mutex::new(s)
})
}
pub fn with_store<R>(f: impl FnOnce(&Store) -> Result<R>) -> Result<R> {
let guard = store().lock().unwrap_or_else(|e| e.into_inner());
f(&guard)
}
+73
View File
@@ -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<Self> {
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<Provider>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Cert {
pub id: String,
pub domains: Vec<String>,
#[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<Cert>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SecretsFile {
#[serde(default)]
pub secrets: std::collections::HashMap<String, String>,
}
+235
View File
@@ -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<Self> {
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<Vec<Provider>> {
Ok(self.load_providers()?.providers)
}
pub fn list_certs(&self) -> Result<Vec<Cert>> {
Ok(self.load_certs()?.certs)
}
pub fn get_provider(&self, id: &str) -> Result<Option<Provider>> {
Ok(self.list_providers()?.into_iter().find(|p| p.id == id))
}
pub fn get_cert(&self, id: &str) -> Result<Option<Cert>> {
Ok(self.list_certs()?.into_iter().find(|c| c.id == id))
}
pub fn get_secret(&self, provider_id: &str) -> Result<Option<String>> {
let file = self.load_secrets()?;
Ok(file.secrets.get(provider_id).cloned())
}
pub fn upsert_provider(
&self,
mut provider: Provider,
secret: Option<&str>,
) -> Result<Provider> {
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<ProviderFile> {
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<CertFile> {
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<SecretsFile> {
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<T: serde::de::DeserializeOwned + Default>(path: &Path) -> Result<T> {
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<T: serde::Serialize>(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(())
}
+232
View File
@@ -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<crate::AppWindow>,
last: AtomicI32,
}
impl IssueProgress {
pub fn new(ui: slint::Weak<crate::AppWindow>) -> Arc<Self> {
Arc::new(Self {
ui,
last: AtomicI32::new(-1),
})
}
pub fn report(self: &Arc<Self>, 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<crate::CertRow> = 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<crate::KeyRow> = 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<slint::SharedString> = key_rows
.iter()
.map(|k| format!("{} · {}", k.name, k.vendor).into())
.collect();
let lib_labels: Vec<slint::SharedString> = 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<i32> {
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<String> {
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<char> = 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<Utc>> {
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::<Utc>::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
}
+4 -1
View File
@@ -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;
}
+45 -14
View File
@@ -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
+44 -4
View File
@@ -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")
}
+362 -13
View File
@@ -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<AtomicBool> = 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<String> = 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<TunnelRow> = {
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 || {
+7
View File
@@ -58,6 +58,13 @@ pub fn reload_async(
});
}
pub fn spawn<F>(fut: F)
where
F: std::future::Future<Output = ()> + Send + 'static,
{
runtime().spawn(fut);
}
pub fn stop_async(on_done: impl FnOnce() + Send + 'static) {
let h = handle();
runtime().spawn(async move {
+80 -2
View File
@@ -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 <int> tunnel-edit-bandwidth-side-index: 0;
in-out property <int> tunnel-edit-proxy-protocol-index: 0;
in-out property <bool> tunnel-edit-plugin-tls-term: false;
in-out property <string> tunnel-edit-plugin-local-addr: "127.0.0.1:80";
in-out property <string> tunnel-edit-plugin-local-addr: "127.0.0.1:8080";
in-out property <int> tunnel-edit-cert-source-index: 1;
in-out property <int> tunnel-edit-cert-library-index: 0;
in-out property <[string]> cert-library-labels;
in-out property <string> tunnel-edit-plugin-cert-file: "";
in-out property <string> tunnel-edit-plugin-key-file: "";
in-out property <string> tunnel-edit-plugin-host-rewrite: "";
@@ -128,6 +132,29 @@ export component AppWindow inherits Window {
in-out property <int> tunnel-edit-type-index: 0;
in-out property <bool> 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 <int> cert-tab-index: 0;
in-out property <int> cert-editor-mode: 0;
in-out property <bool> cert-issuing: false;
in-out property <int> cert-issue-step: -1;
in-out property <bool> cert-issue-failed: false;
in-out property <string> cert-apply-domains: "";
in-out property <int> cert-apply-key-index: 0;
in-out property <string> cert-key-edit-id: "";
in-out property <string> cert-key-edit-name: "";
in-out property <int> cert-key-edit-vendor-index: 0;
in-out property <string> cert-key-edit-access-key-id: "";
in-out property <string> cert-key-edit-secret: "";
in-out property <string> cert-detail-domains: "";
in-out property <string> cert-detail-not-after: "";
in-out property <string> cert-detail-provider-name: "";
in-out property <string> cert-detail-cert-path: "";
in-out property <string> cert-detail-key-path: "";
in-out property <string> cert-detail-id: "";
// config
in-out property <string> server-addr: "127.0.0.1";
in-out property <string> 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;
+4
View File
@@ -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");
}
+871
View File
@@ -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 <bool> 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 <CertRow> 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 <length> gap: 14px;
private property <length> card-h: 118px;
private property <int> cols: self.width >= 900px ? 3 : 2;
private property <length> card-w: (self.width - root.gap * (root.cols - 1)) / root.cols;
private property <int> 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 <KeyRow> 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 <length> gap: 14px;
private property <length> card-h: 118px;
private property <int> cols: self.width >= 900px ? 3 : 2;
private property <length> card-w: (self.width - root.gap * (root.cols - 1)) / root.cols;
private property <int> 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 <string> label;
in property <int> index;
in property <int> current;
in property <bool> failed: false;
spacing: 10px;
vertical-stretch: 0;
height: 34px;
private property <bool> done: root.current > root.index;
private property <bool> 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 <int> current: -1;
in property <bool> failed: false;
in property <bool> 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 <int> tab-index: 0;
in-out property <int> editor-mode: 0;
in-out property <bool> issuing: false;
in-out property <int> issue-step: -1;
in-out property <bool> issue-failed: false;
in-out property <string> apply-domains: "";
in-out property <int> apply-key-index: 0;
in-out property <string> key-edit-id: "";
in-out property <string> key-edit-name: "";
in-out property <int> key-edit-vendor-index: 0;
in-out property <string> key-edit-access-key-id: "";
in-out property <string> key-edit-secret: "";
in-out property <string> detail-domains: "";
in-out property <string> detail-not-after: "";
in-out property <string> detail-provider-name: "";
in-out property <string> detail-cert-path: "";
in-out property <string> 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 <bool> show-list: root.editor-mode == 0;
private property <bool> show-apply: root.editor-mode == 1;
private property <bool> show-key-form: root.editor-mode == 2;
private property <bool> show-detail: root.editor-mode == 3;
private property <bool> 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;
}
}
}
}
}
}
+2 -2
View File
@@ -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;
+6
View File
@@ -25,6 +25,9 @@ export component TunnelPage inherits Rectangle {
in-out property <int> edit-proxy-protocol-index: 0;
in-out property <bool> edit-plugin-tls-term: false;
in-out property <string> edit-plugin-local-addr: "127.0.0.1:8080";
in-out property <int> edit-cert-source-index: 1;
in-out property <int> edit-cert-library-index: 0;
in property <[string]> cert-library-labels;
in-out property <string> edit-plugin-cert-file: "";
in-out property <string> edit-plugin-key-file: "";
in-out property <string> 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;
+2 -2
View File
@@ -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;
+34 -22
View File
@@ -18,7 +18,11 @@ export component TunnelEditor inherits Rectangle {
in-out property <int> edit-bandwidth-side-index: 0;
in-out property <int> edit-proxy-protocol-index: 0;
in-out property <bool> edit-plugin-tls-term: false;
in-out property <string> edit-plugin-local-addr: "127.0.0.1:80";
in-out property <string> edit-plugin-local-addr: "127.0.0.1:8080";
in-out property <int> edit-cert-source-index: 1;
in-out property <int> edit-cert-library-index: 0;
in property <[string]> cert-library-labels;
in-out property <string> edit-plugin-cert-file: "";
in-out property <string> edit-plugin-key-file: "";
in-out property <string> edit-plugin-host-rewrite: "";
@@ -33,6 +37,8 @@ export component TunnelEditor inherits Rectangle {
private property <bool> is-https: root.edit-type-index == 3;
private property <bool> is-domain: root.edit-type-index == 2 || root.edit-type-index == 3;
private property <bool> use-plugin: root.is-https && root.edit-plugin-tls-term;
private property <bool> use-cert-library: root.use-plugin && root.edit-cert-source-index == 0;
private property <bool> 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;
+2
View File
@@ -6,6 +6,7 @@ export component FormField inherits Rectangle {
in property <string> label;
in-out property <string> value;
in property <string> placeholder: "";
in property <bool> 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;
}
}
+17 -2
View File
@@ -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 {