diff --git a/Cargo.lock b/Cargo.lock
index 2f4bf1e..53547a8 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -301,6 +301,45 @@ dependencies = [
"zbus",
]
+[[package]]
+name = "asn1-rs"
+version = "0.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8"
+dependencies = [
+ "asn1-rs-derive",
+ "asn1-rs-impl",
+ "displaydoc",
+ "nom 7.1.3",
+ "num-traits",
+ "rusticata-macros",
+ "thiserror 2.0.19",
+ "time",
+]
+
+[[package]]
+name = "asn1-rs-derive"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "synstructure",
+]
+
+[[package]]
+name = "asn1-rs-impl"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
[[package]]
name = "async-broadcast"
version = "0.7.2"
@@ -616,6 +655,12 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+[[package]]
+name = "base64"
+version = "0.23.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
+
[[package]]
name = "bincode"
version = "2.0.1"
@@ -646,6 +691,15 @@ dependencies = [
"syn 2.0.119",
]
+[[package]]
+name = "bit-vec"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51"
+dependencies = [
+ "serde",
+]
+
[[package]]
name = "bit_field"
version = "0.10.3"
@@ -1242,6 +1296,20 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"
+[[package]]
+name = "der-parser"
+version = "10.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6"
+dependencies = [
+ "asn1-rs",
+ "displaydoc",
+ "nom 7.1.3",
+ "num-bigint",
+ "num-traits",
+ "rusticata-macros",
+]
+
[[package]]
name = "deranged"
version = "0.5.8"
@@ -2044,6 +2112,25 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b40ca9252762c466af32d0b1002e91e4e1bc5398f77455e55474deb466355ff5"
+[[package]]
+name = "h2"
+version = "0.4.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16"
+dependencies = [
+ "atomic-waker",
+ "bytes",
+ "fnv",
+ "futures-core",
+ "futures-sink",
+ "http",
+ "indexmap",
+ "slab",
+ "tokio",
+ "tokio-util",
+ "tracing",
+]
+
[[package]]
name = "half"
version = "2.7.1"
@@ -2207,6 +2294,7 @@ dependencies = [
"bytes",
"futures-channel",
"futures-core",
+ "h2",
"http",
"http-body",
"httparse",
@@ -2215,6 +2303,25 @@ dependencies = [
"pin-project-lite",
"smallvec",
"tokio",
+ "want",
+]
+
+[[package]]
+name = "hyper-rustls"
+version = "0.27.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53"
+dependencies = [
+ "http",
+ "hyper",
+ "hyper-util",
+ "rustls",
+ "rustls-native-certs",
+ "rustls-platform-verifier",
+ "tokio",
+ "tokio-rustls",
+ "tower-service",
+ "webpki-roots",
]
[[package]]
@@ -2223,13 +2330,21 @@ version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
dependencies = [
+ "base64 0.22.1",
"bytes",
+ "futures-channel",
+ "futures-util",
"http",
"http-body",
"hyper",
+ "ipnet",
+ "libc",
+ "percent-encoding",
"pin-project-lite",
+ "socket2 0.6.5",
"tokio",
"tower-service",
+ "tracing",
]
[[package]]
@@ -2814,6 +2929,32 @@ version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "36eee07d8e02bd95bf52b2e642cf13d33701b94c6e4b04fbf1d1fb07e9cb19e7"
+[[package]]
+name = "instant-acme"
+version = "0.8.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f05ad37c421b962354c358d347d4a6130151df9407978372d3ad7f0c8f71a64"
+dependencies = [
+ "async-trait",
+ "base64 0.22.1",
+ "bytes",
+ "http",
+ "http-body",
+ "http-body-util",
+ "httpdate",
+ "hyper",
+ "hyper-rustls",
+ "hyper-util",
+ "rcgen 0.14.10",
+ "ring",
+ "rustls",
+ "rustls-pki-types",
+ "serde",
+ "serde_json",
+ "thiserror 2.0.19",
+ "tokio",
+]
+
[[package]]
name = "integer-sqrt"
version = "0.1.5"
@@ -2845,6 +2986,12 @@ dependencies = [
"windows-sys 0.48.0",
]
+[[package]]
+name = "ipnet"
+version = "2.12.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
+
[[package]]
name = "itertools"
version = "0.13.0"
@@ -3911,6 +4058,15 @@ dependencies = [
"objc2-foundation 0.2.2",
]
+[[package]]
+name = "oid-registry"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7"
+dependencies = [
+ "asn1-rs",
+]
+
[[package]]
name = "once_cell"
version = "1.21.4"
@@ -3963,14 +4119,14 @@ name = "orbien-core"
version = "3.7.0"
dependencies = [
"anyhow",
- "base64",
+ "base64 0.22.1",
"bytes",
"futures-util",
"hex",
"hmac",
"kcp-tokio",
"quinn",
- "rcgen",
+ "rcgen 0.13.2",
"rustls",
"rustls-pemfile",
"rustls-pki-types",
@@ -3994,12 +4150,25 @@ name = "orbien-desktop"
version = "3.7.0"
dependencies = [
"anyhow",
+ "async-trait",
+ "base64 0.22.1",
+ "chrono",
+ "hex",
+ "hmac",
+ "instant-acme",
"libc",
"objc2 0.6.4",
"orbien-client",
"orbien-core",
+ "percent-encoding",
+ "rcgen 0.13.2",
+ "reqwest",
"rfd",
+ "rustls",
"serde",
+ "serde_json",
+ "sha1",
+ "sha2 0.10.9",
"slint",
"slint-build",
"tokio",
@@ -4016,7 +4185,7 @@ version = "3.7.0"
dependencies = [
"anyhow",
"axum",
- "base64",
+ "base64 0.22.1",
"chrono",
"clap",
"httparse",
@@ -4137,7 +4306,17 @@ version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
dependencies = [
- "base64",
+ "base64 0.22.1",
+ "serde_core",
+]
+
+[[package]]
+name = "pem"
+version = "4.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d354a98a3d1251555de99e8fdd8afda05573c31b82f59063a7b0a29b5527f120"
+dependencies = [
+ "base64 0.23.1",
"serde_core",
]
@@ -4743,11 +4922,25 @@ version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2"
dependencies = [
- "pem",
+ "pem 3.0.6",
"ring",
"rustls-pki-types",
"time",
- "yasna",
+ "yasna 0.5.2",
+]
+
+[[package]]
+name = "rcgen"
+version = "0.14.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8774e05a7d0de114588e6a28fe7e71694b82614ed569d86d8b389dfbc98b8ad8"
+dependencies = [
+ "pem 4.0.0",
+ "ring",
+ "rustls-pki-types",
+ "time",
+ "x509-parser",
+ "yasna 0.6.0",
]
[[package]]
@@ -4833,6 +5026,44 @@ version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
+[[package]]
+name = "reqwest"
+version = "0.12.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
+dependencies = [
+ "base64 0.22.1",
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "http-body-util",
+ "hyper",
+ "hyper-rustls",
+ "hyper-util",
+ "js-sys",
+ "log",
+ "percent-encoding",
+ "pin-project-lite",
+ "quinn",
+ "rustls",
+ "rustls-pki-types",
+ "serde",
+ "serde_json",
+ "serde_urlencoded",
+ "sync_wrapper",
+ "tokio",
+ "tokio-rustls",
+ "tower",
+ "tower-http",
+ "tower-service",
+ "url",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+ "webpki-roots",
+]
+
[[package]]
name = "resvg"
version = "0.47.0"
@@ -5804,6 +6035,9 @@ name = "sync_wrapper"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
+dependencies = [
+ "futures-core",
+]
[[package]]
name = "synstructure"
@@ -5953,6 +6187,7 @@ dependencies = [
"powerfmt",
"serde_core",
"time-core",
+ "time-macros",
]
[[package]]
@@ -5961,6 +6196,16 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
+[[package]]
+name = "time-macros"
+version = "0.2.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
+dependencies = [
+ "num-conv",
+ "time-core",
+]
+
[[package]]
name = "tiny-skia"
version = "0.11.4"
@@ -6136,6 +6381,7 @@ dependencies = [
"futures-io",
"futures-sink",
"futures-util",
+ "libc",
"pin-project-lite",
"tokio",
]
@@ -6258,6 +6504,24 @@ dependencies = [
"tracing",
]
+[[package]]
+name = "tower-http"
+version = "0.6.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
+dependencies = [
+ "bitflags 2.13.1",
+ "bytes",
+ "futures-util",
+ "http",
+ "http-body",
+ "pin-project-lite",
+ "tower",
+ "tower-layer",
+ "tower-service",
+ "url",
+]
+
[[package]]
name = "tower-layer"
version = "0.3.3"
@@ -6332,6 +6596,12 @@ dependencies = [
"tracing-log",
]
+[[package]]
+name = "try-lock"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
+
[[package]]
name = "ttf-parser"
version = "0.25.1"
@@ -6506,7 +6776,7 @@ version = "0.47.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d46cf96c5f498d36b7a9693bc6a7075c0bb9303189d61b2249b0dc3d309c07de"
dependencies = [
- "base64",
+ "base64 0.22.1",
"data-url",
"flate2",
"fontdb",
@@ -6608,6 +6878,15 @@ dependencies = [
"winapi-util",
]
+[[package]]
+name = "want"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
+dependencies = [
+ "try-lock",
+]
+
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
@@ -6858,6 +7137,15 @@ dependencies = [
"rustls-pki-types",
]
+[[package]]
+name = "webpki-roots"
+version = "1.0.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
+dependencies = [
+ "rustls-pki-types",
+]
+
[[package]]
name = "weezl"
version = "0.1.12"
@@ -7286,6 +7574,24 @@ version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
+[[package]]
+name = "x509-parser"
+version = "0.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202"
+dependencies = [
+ "asn1-rs",
+ "data-encoding",
+ "der-parser",
+ "lazy_static",
+ "nom 7.1.3",
+ "oid-registry",
+ "ring",
+ "rusticata-macros",
+ "thiserror 2.0.19",
+ "time",
+]
+
[[package]]
name = "xattr"
version = "1.6.1"
@@ -7375,6 +7681,16 @@ dependencies = [
"time",
]
+[[package]]
+name = "yasna"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282"
+dependencies = [
+ "bit-vec",
+ "time",
+]
+
[[package]]
name = "yazi"
version = "0.2.1"
diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml
index 52b42d7..08e51ac 100644
--- a/desktop/Cargo.toml
+++ b/desktop/Cargo.toml
@@ -21,9 +21,22 @@ tracing = { workspace = true }
tracing-subscriber = { workspace = true }
toml = { workspace = true }
serde = { workspace = true }
+serde_json = { workspace = true }
rfd = "0.15"
uuid = { workspace = true }
libc = "0.2"
+async-trait = { workspace = true }
+chrono = { workspace = true }
+base64 = { workspace = true }
+hmac = { workspace = true }
+sha2 = { workspace = true }
+hex = { workspace = true }
+reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
+instant-acme = { version = "0.8.5", default-features = false, features = ["hyper-rustls", "ring"] }
+sha1 = "0.10"
+percent-encoding = "2"
+rcgen = { workspace = true }
+rustls = { workspace = true }
[target.'cfg(target_os = "macos")'.dependencies]
objc2 = "0.6"
diff --git a/desktop/assets/icon/cert.svg b/desktop/assets/icon/cert.svg
new file mode 100644
index 0000000..2e3047d
--- /dev/null
+++ b/desktop/assets/icon/cert.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/desktop/i18n/en_US.properties b/desktop/i18n/en_US.properties
index 6f19efd..2c6a044 100644
--- a/desktop/i18n/en_US.properties
+++ b/desktop/i18n/en_US.properties
@@ -36,7 +36,7 @@ tunnel-backend=Backend service
tunnel-routing=Domain Routing
tunnel-name=Name
tunnel-name-hint=e.g. web, mysql
-tunnel-type=Type
+tunnel-type=Protocol
tunnel-local-port=Service port
tunnel-remote-port=Remote port
tunnel-domains=Domains
@@ -57,10 +57,64 @@ tunnel-tls-term=TLS terminate
tunnel-plugin-local-addr=Local address
tunnel-plugin-cert=Certificate path
tunnel-plugin-key=Key path
+tunnel-cert-source=Certificate type
+tunnel-cert-library=Certificate store
+tunnel-cert-file=Own certificate
+tunnel-cert-self=Self-signed
+tunnel-cert-pick=Select certificate
tunnel-plugin-username=Username
tunnel-plugin-password=Password
tunnel-copied=Copied to clipboard
+# —— cert ——
+cert-title=Certificates
+cert-tab-certs=Certificates
+cert-tab-keys=Keys
+cert-empty=No certificates
+cert-key-empty=No keys
+cert-valid=Valid
+cert-expired=Expired
+cert-detail=Details
+cert-apply-title=Request certificate
+cert-apply=Request
+cert-issuing=Requesting…
+cert-step-title=Progress
+cert-step-prepare=Prepare account
+cert-step-order=Create order
+cert-step-dns=Write DNS challenge
+cert-step-wait=Wait for DNS
+cert-step-validate=ACME validation
+cert-step-finalize=Issue certificate
+cert-step-cleanup=Clean up DNS
+cert-issuer=Issuer
+cert-domains=Domains
+cert-domains-hint=Comma-separated domains
+cert-key=Key
+cert-no-key=Add a key first
+cert-key-add=Add key
+cert-key-edit=Edit key
+cert-key-name=Name
+cert-key-vendor=Provider
+cert-api-token=API Token
+cert-access-key-id=AccessKey ID
+cert-access-key-secret=AccessKey Secret
+cert-secret-id=SecretId
+cert-secret-key=SecretKey
+cert-secret-keep=Leave blank to keep
+cert-detail-title=Certificate details
+cert-not-after=Expires
+cert-saved=Saved
+cert-deleted=Deleted
+cert-applied=Certificate issued
+cert-in-use=Certificate is used by a tunnel
+cert-need-key=Add a key first
+cert-domains-required=Domains are required
+cert-key-name-required=Name is required
+cert-secret-required=Secret is required
+cert-issue-failed=Request failed: {err}
+cert-issue-timeout=Request timed out, please retry
+cert-issue-cancelled=Request cancelled
+
# —— config ——
config-title=Config
config-reset=Reset defaults
@@ -131,7 +185,7 @@ about-slint-link=About Slint…
about-license-note=UI built with Slint under the Royalty-free Desktop/Mobile/Web Applications License
about-close=Close
-# —— Rust / runtime messages ——
+# runtime messages
msg.running-label-zero=0s
msg.tunnel-name-required=Tunnel name is required
msg.tunnel-domain-required=Enter at least one domain
@@ -141,7 +195,6 @@ msg.tunnel-local-port-invalid=Invalid service port
msg.tunnel-remote-port-invalid=Invalid remote port
msg.tunnel-name-exists=Tunnel name already exists
msg.tunnel-persist-failed=Failed to persist tunnels: {err}
-msg.tunnel-plugin-addr-required=Local address is required
msg.tunnel-plugin-username-required=Username is required
msg.tunnel-plugin-password-required=Password is required
msg.tunnel-copied=Copied to clipboard
diff --git a/desktop/i18n/zh_CN.properties b/desktop/i18n/zh_CN.properties
index 6e955a6..bc64f62 100644
--- a/desktop/i18n/zh_CN.properties
+++ b/desktop/i18n/zh_CN.properties
@@ -34,7 +34,7 @@ tunnel-backend=后端服务
tunnel-routing=域名路由
tunnel-name=名称
tunnel-name-hint=例如 web、mysql
-tunnel-type=类型
+tunnel-type=协议
tunnel-local-port=服务端口
tunnel-remote-port=远程端口
tunnel-domains=域名
@@ -55,9 +55,62 @@ tunnel-tls-term=TLS 终止
tunnel-plugin-local-addr=本地地址
tunnel-plugin-cert=证书路径
tunnel-plugin-key=私钥路径
+tunnel-cert-source=证书类型
+tunnel-cert-library=证书库
+tunnel-cert-file=自备证书
+tunnel-cert-self=临时自签
+tunnel-cert-pick=选择证书
tunnel-plugin-username=用户名
tunnel-plugin-password=密码
tunnel-copied=已复制到剪贴板
+# —— cert ——
+cert-title=证书
+cert-tab-certs=证书
+cert-tab-keys=密钥
+cert-empty=暂无证书
+cert-key-empty=暂无密钥
+cert-valid=有效
+cert-expired=过期
+cert-detail=详情
+cert-apply-title=申请证书
+cert-apply=申请
+cert-issuing=申请中…
+cert-step-title=申请进度
+cert-step-prepare=准备账号
+cert-step-order=创建订单
+cert-step-dns=写入 DNS 验证
+cert-step-wait=等待 DNS 生效
+cert-step-validate=ACME 校验
+cert-step-finalize=签发证书
+cert-step-cleanup=清理验证记录
+cert-issuer=证书品牌
+cert-domains=域名
+cert-domains-hint=多个域名用逗号分隔
+cert-key=密钥
+cert-no-key=请先添加密钥
+cert-key-add=添加密钥
+cert-key-edit=编辑密钥
+cert-key-name=名称
+cert-key-vendor=厂商
+cert-api-token=API Token
+cert-access-key-id=AccessKey ID
+cert-access-key-secret=AccessKey Secret
+cert-secret-id=SecretId
+cert-secret-key=SecretKey
+cert-secret-keep=留空则不修改
+cert-detail-title=证书详情
+cert-not-after=到期时间
+cert-saved=已保存
+cert-deleted=已删除
+cert-applied=申请成功
+cert-in-use=证书正在被隧道使用
+cert-need-key=请先添加密钥
+cert-domains-required=请填写域名
+cert-key-name-required=请填写名称
+cert-secret-required=请填写密钥
+cert-issue-failed=申请失败: {err}
+cert-issue-timeout=申请超时,请重试
+cert-issue-cancelled=已取消申请
# —— config ——
config-title=配置
config-reset=重置默认
@@ -125,7 +178,7 @@ about-feedback=反馈
about-slint-link=关于界面框架 Slint…
about-license-note=本界面使用 Slint 构建,并按 Slint Royalty-free Desktop/Mobile/Web Applications License 使用
about-close=关闭
-# —— Rust / runtime messages
+# runtime messages
msg.running-label-zero=0 秒
msg.tunnel-name-required=请填写隧道名称
msg.tunnel-domain-required=请填写至少一个域名
@@ -135,7 +188,6 @@ msg.tunnel-local-port-invalid=服务端口无效
msg.tunnel-remote-port-invalid=远程端口无效
msg.tunnel-name-exists=隧道名称已存在
msg.tunnel-persist-failed=隧道写入失败: {err}
-msg.tunnel-plugin-addr-required=请填写本地地址
msg.tunnel-plugin-username-required=请填写用户名
msg.tunnel-plugin-password-required=请填写密码
msg.tunnel-copied=已复制到剪贴板
diff --git a/desktop/src/cert/dns/aliyun.rs b/desktop/src/cert/dns/aliyun.rs
new file mode 100644
index 0000000..846c07f
--- /dev/null
+++ b/desktop/src/cert/dns/aliyun.rs
@@ -0,0 +1,183 @@
+use super::DnsProvider;
+use anyhow::{anyhow, bail, Context, Result};
+use async_trait::async_trait;
+use base64::{engine::general_purpose::STANDARD as B64, Engine};
+use chrono::Utc;
+use hmac::{Hmac, Mac};
+use percent_encoding::{utf8_percent_encode, AsciiSet, NON_ALPHANUMERIC};
+use sha1::Sha1;
+use std::collections::BTreeMap;
+use uuid::Uuid;
+
+type HmacSha1 = Hmac;
+
+const ENCODE_SET: &AsciiSet = &NON_ALPHANUMERIC
+ .remove(b'-')
+ .remove(b'_')
+ .remove(b'.')
+ .remove(b'~');
+
+pub struct AliyunDns {
+ access_key_id: String,
+ access_key_secret: String,
+ client: reqwest::Client,
+}
+
+impl AliyunDns {
+ pub fn new(access_key_id: &str, access_key_secret: &str) -> Result {
+ Ok(Self {
+ access_key_id: access_key_id.to_string(),
+ access_key_secret: access_key_secret.to_string(),
+ client: reqwest::Client::new(),
+ })
+ }
+
+ fn sign(&self, params: &BTreeMap) -> String {
+ let canonical: String = params
+ .iter()
+ .map(|(k, v)| format!("{}={}", enc(k), enc(v)))
+ .collect::>()
+ .join("&");
+ let string_to_sign = format!("GET&{}&{}", enc("/"), enc(&canonical));
+ let mut mac = HmacSha1::new_from_slice(format!("{}&", self.access_key_secret).as_bytes())
+ .expect("hmac");
+ mac.update(string_to_sign.as_bytes());
+ B64.encode(&mac.finalize().into_bytes())
+ }
+
+ async fn call(&self, mut params: BTreeMap) -> Result {
+ params.insert("Format".into(), "JSON".into());
+ params.insert("Version".into(), "2015-01-09".into());
+ params.insert("AccessKeyId".into(), self.access_key_id.clone());
+ params.insert("SignatureMethod".into(), "HMAC-SHA1".into());
+ params.insert(
+ "Timestamp".into(),
+ Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(),
+ );
+ params.insert("SignatureVersion".into(), "1.0".into());
+ params.insert("SignatureNonce".into(), Uuid::new_v4().to_string());
+ let signature = self.sign(¶ms);
+ params.insert("Signature".into(), signature);
+
+ let query: String = params
+ .iter()
+ .map(|(k, v)| format!("{}={}", enc(k), enc(v)))
+ .collect::>()
+ .join("&");
+ let url = format!("https://alidns.aliyuncs.com/?{query}");
+ let text = self
+ .client
+ .get(&url)
+ .send()
+ .await
+ .context("aliyun dns request")?
+ .text()
+ .await
+ .context("aliyun dns body")?;
+ let v: serde_json::Value =
+ serde_json::from_str(&text).with_context(|| format!("aliyun dns json: {text}"))?;
+ if v.get("Code").is_some() {
+ bail!("aliyun dns error: {text}");
+ }
+ Ok(v)
+ }
+
+ async fn find_domain(&self, host: &str) -> Result<(String, String)> {
+ let host = host.trim_end_matches('.');
+ let mut name = host.to_string();
+ loop {
+ let mut params = BTreeMap::new();
+ params.insert("Action".into(), "DescribeDomainInfo".into());
+ params.insert("DomainName".into(), name.clone());
+ match self.call(params).await {
+ Ok(_) => {
+ let rr = if host == name {
+ "@".to_string()
+ } else {
+ host.strip_suffix(&format!(".{name}"))
+ .unwrap_or(host)
+ .to_string()
+ };
+ return Ok((name, rr));
+ }
+ Err(_) => {
+ if let Some((_, rest)) = name.split_once('.') {
+ name = rest.to_string();
+ } else {
+ break;
+ }
+ }
+ }
+ }
+ Err(anyhow!("aliyun domain not found for {host}"))
+ }
+}
+
+#[async_trait]
+impl DnsProvider for AliyunDns {
+ async fn upsert_txt(&self, host: &str, value: &str) -> Result {
+ let (domain, rr) = self.find_domain(host).await?;
+ tracing::info!(%host, %domain, %rr, "cert: aliyun resolve zone");
+
+ let mut params = BTreeMap::new();
+ params.insert("Action".into(), "DescribeDomainRecords".into());
+ params.insert("DomainName".into(), domain.clone());
+ params.insert("RRKeyWord".into(), rr.clone());
+ params.insert("Type".into(), "TXT".into());
+ let listed = self.call(params).await?;
+ let records = domain_records(&listed);
+ for r in &records {
+ let content = r.get("Value").and_then(|v| v.as_str()).unwrap_or("");
+ let id = r.get("RecordId").and_then(|v| v.as_str()).unwrap_or("");
+ let record_rr = r.get("RR").and_then(|v| v.as_str()).unwrap_or("");
+ if record_rr != rr || id.is_empty() {
+ continue;
+ }
+ if content == value {
+ tracing::info!(%host, %id, "cert: aliyun txt already present");
+ return Ok(id.to_string());
+ }
+ let mut del = BTreeMap::new();
+ del.insert("Action".into(), "DeleteDomainRecord".into());
+ del.insert("RecordId".into(), id.to_string());
+ let _ = self.call(del).await;
+ tracing::info!(%id, "cert: aliyun stale txt deleted");
+ }
+
+ let mut params = BTreeMap::new();
+ params.insert("Action".into(), "AddDomainRecord".into());
+ params.insert("DomainName".into(), domain);
+ params.insert("RR".into(), rr);
+ params.insert("Type".into(), "TXT".into());
+ params.insert("Value".into(), value.to_string());
+ params.insert("TTL".into(), "600".into());
+ let created = self.call(params).await?;
+ let id = created
+ .get("RecordId")
+ .and_then(|v| v.as_str())
+ .ok_or_else(|| anyhow!("aliyun missing RecordId"))?
+ .to_string();
+ tracing::info!(%host, %id, "cert: aliyun txt upserted");
+ Ok(id)
+ }
+
+ async fn delete_txt(&self, record_id: &str) -> Result<()> {
+ let mut params = BTreeMap::new();
+ params.insert("Action".into(), "DeleteDomainRecord".into());
+ params.insert("RecordId".into(), record_id.to_string());
+ self.call(params).await.context("aliyun delete txt")?;
+ Ok(())
+ }
+}
+
+fn domain_records(listed: &serde_json::Value) -> Vec {
+ match listed.pointer("/DomainRecords/Record") {
+ Some(serde_json::Value::Array(arr)) => arr.clone(),
+ Some(obj) if obj.is_object() => vec![obj.clone()],
+ _ => Vec::new(),
+ }
+}
+
+fn enc(s: &str) -> String {
+ utf8_percent_encode(s, ENCODE_SET).to_string()
+}
diff --git a/desktop/src/cert/dns/cloudflare.rs b/desktop/src/cert/dns/cloudflare.rs
new file mode 100644
index 0000000..cfaf5a3
--- /dev/null
+++ b/desktop/src/cert/dns/cloudflare.rs
@@ -0,0 +1,155 @@
+use super::DnsProvider;
+use anyhow::{anyhow, bail, Context, Result};
+use async_trait::async_trait;
+use serde::Deserialize;
+use serde_json::json;
+
+pub struct CloudflareDns {
+ token: String,
+ client: reqwest::Client,
+}
+
+impl CloudflareDns {
+ pub fn new(token: &str) -> Result {
+ Ok(Self {
+ token: token.to_string(),
+ client: reqwest::Client::new(),
+ })
+ }
+
+ async fn find_zone(&self, domain: &str) -> Result<(String, String)> {
+ let mut name = domain.trim_end_matches('.').to_string();
+ loop {
+ let url =
+ format!("https://api.cloudflare.com/client/v4/zones?name={name}&status=active");
+ let resp: CfResp> = self
+ .client
+ .get(&url)
+ .bearer_auth(&self.token)
+ .send()
+ .await
+ .context("cloudflare list zones")?
+ .json()
+ .await
+ .context("cloudflare zones json")?;
+ if !resp.success {
+ bail!("cloudflare zones error: {:?}", resp.errors);
+ }
+ if let Some(z) = resp.result.into_iter().next() {
+ return Ok((z.id, z.name));
+ }
+ if let Some((_, rest)) = name.split_once('.') {
+ name = rest.to_string();
+ } else {
+ break;
+ }
+ }
+ Err(anyhow!("cloudflare zone not found for {domain}"))
+ }
+}
+
+#[async_trait]
+impl DnsProvider for CloudflareDns {
+ async fn upsert_txt(&self, host: &str, value: &str) -> Result {
+ let host = host.trim_end_matches('.');
+ let (zone_id, zone_name) = self.find_zone(host).await?;
+ let relative = if host == zone_name {
+ "@".to_string()
+ } else if let Some(prefix) = host.strip_suffix(&format!(".{zone_name}")) {
+ prefix.to_string()
+ } else {
+ host.to_string()
+ };
+
+ let list_url = format!(
+ "https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records?type=TXT&name={host}"
+ );
+ let listed: CfResp> = self
+ .client
+ .get(&list_url)
+ .bearer_auth(&self.token)
+ .send()
+ .await
+ .context("cloudflare list txt")?
+ .json()
+ .await
+ .context("cloudflare list txt json")?;
+
+ if let Some(existing) = listed
+ .result
+ .iter()
+ .find(|r| r.content.trim_matches('"') == value)
+ {
+ return Ok(format!("{zone_id}:{}", existing.id));
+ }
+
+ let body = json!({
+ "type": "TXT",
+ "name": relative,
+ "content": value,
+ "ttl": 120,
+ });
+ let create_url =
+ format!("https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records");
+ let created: CfResp = self
+ .client
+ .post(&create_url)
+ .bearer_auth(&self.token)
+ .json(&body)
+ .send()
+ .await
+ .context("cloudflare create txt")?
+ .json()
+ .await
+ .context("cloudflare create txt json")?;
+ if !created.success {
+ bail!("cloudflare create txt failed: {:?}", created.errors);
+ }
+ tracing::info!(%host, "cert: cloudflare txt upserted");
+ Ok(format!("{zone_id}:{}", created.result.id))
+ }
+
+ async fn delete_txt(&self, record_id: &str) -> Result<()> {
+ let Some((zone_id, id)) = record_id.split_once(':') else {
+ bail!("invalid cloudflare record id");
+ };
+ let url = format!("https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records/{id}");
+ let resp: CfResp = self
+ .client
+ .delete(&url)
+ .bearer_auth(&self.token)
+ .send()
+ .await
+ .context("cloudflare delete txt")?
+ .json()
+ .await
+ .context("cloudflare delete txt json")?;
+ if !resp.success {
+ tracing::warn!(?resp.errors, "cert: cloudflare delete txt failed");
+ }
+ Ok(())
+ }
+}
+
+#[derive(Debug, Deserialize)]
+struct CfResp {
+ success: bool,
+ #[serde(default)]
+ result: T,
+ #[serde(default)]
+ errors: Vec,
+}
+
+#[derive(Debug, Deserialize)]
+struct CfZone {
+ id: String,
+ name: String,
+}
+
+#[derive(Debug, Deserialize, Default)]
+struct CfRecord {
+ #[serde(default)]
+ id: String,
+ #[serde(default)]
+ content: String,
+}
diff --git a/desktop/src/cert/dns/mod.rs b/desktop/src/cert/dns/mod.rs
new file mode 100644
index 0000000..f2d6df2
--- /dev/null
+++ b/desktop/src/cert/dns/mod.rs
@@ -0,0 +1,117 @@
+mod aliyun;
+mod cloudflare;
+mod tencent;
+
+use crate::cert::model::{Provider, Vendor};
+use anyhow::{bail, Context, Result};
+use async_trait::async_trait;
+
+#[async_trait]
+pub trait DnsProvider: Send + Sync {
+ async fn upsert_txt(&self, host: &str, value: &str) -> Result;
+ async fn delete_txt(&self, record_id: &str) -> Result<()>;
+}
+
+pub fn build_provider(provider: &Provider, secret: &str) -> Result> {
+ let secret = secret.trim();
+ if secret.is_empty() {
+ bail!("dns secret is empty");
+ }
+ match provider.vendor {
+ Vendor::Cloudflare => Ok(Box::new(cloudflare::CloudflareDns::new(secret)?)),
+ Vendor::Aliyun => {
+ if provider.access_key_id.trim().is_empty() {
+ bail!("accessKeyId is required for aliyun");
+ }
+ Ok(Box::new(aliyun::AliyunDns::new(
+ &provider.access_key_id,
+ secret,
+ )?))
+ }
+ Vendor::Tencent => {
+ if provider.access_key_id.trim().is_empty() {
+ bail!("secretId is required for tencent");
+ }
+ Ok(Box::new(tencent::TencentDns::new(
+ &provider.access_key_id,
+ secret,
+ )?))
+ }
+ }
+}
+
+pub fn challenge_name(domain: &str) -> String {
+ format!("_acme-challenge.{domain}")
+}
+
+pub async fn wait_txt_propagated(
+ host: &str,
+ expected: &str,
+ cancel: &std::sync::atomic::AtomicBool,
+) -> Result<()> {
+ let host = host.trim_end_matches('.');
+ let expected = expected.trim().trim_matches('"');
+ let client = reqwest::Client::builder()
+ .timeout(std::time::Duration::from_secs(8))
+ .build()?;
+ let endpoints = [
+ format!("https://dns.alidns.com/resolve?name={host}&type=TXT"),
+ format!("https://doh.pub/dns-query?name={host}&type=TXT"),
+ format!("https://cloudflare-dns.com/dns-query?name={host}&type=TXT"),
+ ];
+ let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(150);
+ let mut attempt = 0u32;
+ loop {
+ if cancel.load(std::sync::atomic::Ordering::Relaxed) {
+ anyhow::bail!("cancelled");
+ }
+ attempt += 1;
+ for url in &endpoints {
+ match fetch_txt_answers(&client, url).await {
+ Ok(answers) if answers.iter().any(|a| a.trim_matches('"') == expected) => {
+ tracing::info!(%host, attempt, "acme: dns txt visible");
+ return Ok(());
+ }
+ Ok(_) => {}
+ Err(e) => {
+ tracing::debug!(error = %e, %url, "acme: doh lookup failed");
+ }
+ }
+ }
+ if tokio::time::Instant::now() >= deadline {
+ anyhow::bail!(
+ "DNS TXT for {host} not visible after 150s (value may not have propagated)"
+ );
+ }
+ if attempt == 1 || attempt % 5 == 0 {
+ tracing::info!(%host, attempt, "acme: waiting dns txt propagation");
+ }
+ tokio::time::sleep(std::time::Duration::from_secs(3)).await;
+ }
+}
+
+async fn fetch_txt_answers(client: &reqwest::Client, url: &str) -> Result> {
+ let resp = client
+ .get(url)
+ .header("Accept", "application/dns-json")
+ .send()
+ .await
+ .context("doh request")?
+ .error_for_status()
+ .context("doh status")?
+ .json::()
+ .await
+ .context("doh json")?;
+ let mut out = Vec::new();
+ if let Some(arr) = resp.get("Answer").and_then(|v| v.as_array()) {
+ for a in arr {
+ if a.get("type").and_then(|t| t.as_u64()) != Some(16) {
+ continue;
+ }
+ if let Some(data) = a.get("data").and_then(|d| d.as_str()) {
+ out.push(data.to_string());
+ }
+ }
+ }
+ Ok(out)
+}
diff --git a/desktop/src/cert/dns/tencent.rs b/desktop/src/cert/dns/tencent.rs
new file mode 100644
index 0000000..99a8719
--- /dev/null
+++ b/desktop/src/cert/dns/tencent.rs
@@ -0,0 +1,250 @@
+use super::DnsProvider;
+use anyhow::{anyhow, bail, Context, Result};
+use async_trait::async_trait;
+use chrono::Utc;
+use hmac::{Hmac, Mac};
+use serde::Deserialize;
+use serde_json::json;
+use sha2::{Digest, Sha256};
+
+type HmacSha256 = Hmac;
+
+pub struct TencentDns {
+ secret_id: String,
+ secret_key: String,
+ client: reqwest::Client,
+}
+
+impl TencentDns {
+ pub fn new(secret_id: &str, secret_key: &str) -> Result {
+ Ok(Self {
+ secret_id: secret_id.to_string(),
+ secret_key: secret_key.to_string(),
+ client: reqwest::Client::new(),
+ })
+ }
+
+ async fn call(&self, action: &str, payload: serde_json::Value) -> Result {
+ let host = "dnspod.tencentcloudapi.com";
+ let service = "dnspod";
+ let version = "2021-03-23";
+ let timestamp = Utc::now().timestamp();
+ let date = Utc::now().format("%Y-%m-%d").to_string();
+ let body = payload.to_string();
+ let hashed_payload = hex::encode(Sha256::digest(body.as_bytes()));
+
+ let canonical_headers = format!(
+ "content-type:application/json; charset=utf-8\nhost:{host}\nx-tc-action:{}\n",
+ action.to_lowercase()
+ );
+ let signed_headers = "content-type;host;x-tc-action";
+ let canonical_request =
+ format!("POST\n/\n\n{canonical_headers}\n{signed_headers}\n{hashed_payload}");
+ let hashed_canonical = hex::encode(Sha256::digest(canonical_request.as_bytes()));
+ let credential_scope = format!("{date}/{service}/tc3_request");
+ let string_to_sign =
+ format!("TC3-HMAC-SHA256\n{timestamp}\n{credential_scope}\n{hashed_canonical}");
+
+ let secret_date = hmac_sha256(
+ format!("TC3{}", self.secret_key).as_bytes(),
+ date.as_bytes(),
+ );
+ let secret_service = hmac_sha256(&secret_date, service.as_bytes());
+ let secret_signing = hmac_sha256(&secret_service, b"tc3_request");
+ let signature = hex::encode(hmac_sha256(&secret_signing, string_to_sign.as_bytes()));
+
+ let authorization = format!(
+ "TC3-HMAC-SHA256 Credential={}/{}, SignedHeaders={signed_headers}, Signature={signature}",
+ self.secret_id, credential_scope
+ );
+
+ let url = format!("https://{host}");
+ let resp = self
+ .client
+ .post(&url)
+ .header("Authorization", authorization)
+ .header("Content-Type", "application/json; charset=utf-8")
+ .header("Host", host)
+ .header("X-TC-Action", action)
+ .header("X-TC-Timestamp", timestamp.to_string())
+ .header("X-TC-Version", version)
+ .body(body)
+ .send()
+ .await
+ .context("tencent dns request")?;
+ let text = resp.text().await.context("tencent dns body")?;
+ let v: TcResp =
+ serde_json::from_str(&text).with_context(|| format!("tencent dns json: {text}"))?;
+ if let Some(err) = v.response.error {
+ bail!("tencent dns error: {} - {}", err.code, err.message);
+ }
+ let mut data = v.response.extra;
+ data.as_object_mut().map(|m| {
+ m.remove("RequestId");
+ m.remove("Error");
+ });
+ Ok(data)
+ }
+
+ async fn find_domain(&self, host: &str) -> Result<(u64, String, String)> {
+ let host = host.trim_end_matches('.');
+ let listed = self
+ .call("DescribeDomainList", json!({"Limit": 100, "Offset": 0}))
+ .await?;
+ let domains = listed
+ .get("DomainList")
+ .and_then(|v| v.as_array())
+ .cloned()
+ .unwrap_or_default();
+ let mut name = host.to_string();
+ loop {
+ for d in &domains {
+ let dn = d.get("Name").and_then(|v| v.as_str()).unwrap_or("");
+ let id = d
+ .get("DomainId")
+ .and_then(|v| v.as_u64())
+ .or_else(|| {
+ d.get("DomainId")
+ .and_then(|v| v.as_str())
+ .and_then(|s| s.parse().ok())
+ })
+ .unwrap_or(0);
+ if !dn.is_empty() && dn.eq_ignore_ascii_case(&name) {
+ let rr = if host.eq_ignore_ascii_case(&name) {
+ "@".to_string()
+ } else {
+ host.strip_suffix(&format!(".{name}"))
+ .or_else(|| {
+ let lower = name.to_ascii_lowercase();
+ host.strip_suffix(&format!(".{lower}"))
+ })
+ .unwrap_or(host)
+ .to_string()
+ };
+ tracing::info!(%host, domain = %dn, domain_id = id, %rr, "cert: tencent resolve zone");
+ return Ok((id, dn.to_string(), rr));
+ }
+ }
+ if let Some((_, rest)) = name.split_once('.') {
+ name = rest.to_string();
+ } else {
+ break;
+ }
+ }
+ Err(anyhow!("tencent domain not found for {host}"))
+ }
+}
+
+#[async_trait]
+impl DnsProvider for TencentDns {
+ async fn upsert_txt(&self, host: &str, value: &str) -> Result {
+ let (domain_id, domain, rr) = self.find_domain(host).await?;
+
+ let mut list_body = json!({
+ "Domain": domain,
+ "Subdomain": rr,
+ "RecordType": "TXT",
+ "Limit": 100,
+ });
+ if domain_id > 0 {
+ list_body["DomainId"] = json!(domain_id);
+ }
+ let listed = self.call("DescribeRecordList", list_body).await;
+ if let Ok(listed) = listed {
+ if let Some(records) = listed.get("RecordList").and_then(|v| v.as_array()) {
+ for r in records {
+ let content = r.get("Value").and_then(|v| v.as_str()).unwrap_or("");
+ let id = r.get("RecordId").and_then(|v| v.as_u64()).unwrap_or(0);
+ let record_rr = r.get("Name").and_then(|v| v.as_str()).unwrap_or("");
+ if !record_rr.is_empty() && record_rr != rr {
+ continue;
+ }
+ if content == value && id > 0 {
+ tracing::info!(%host, %id, "cert: tencent txt already present");
+ return Ok(format!("{domain}:{id}"));
+ }
+ if id > 0 {
+ let mut del = json!({
+ "Domain": domain,
+ "RecordId": id,
+ });
+ if domain_id > 0 {
+ del["DomainId"] = json!(domain_id);
+ }
+ let _ = self.call("DeleteRecord", del).await;
+ tracing::info!(%id, "cert: tencent stale txt deleted");
+ }
+ }
+ }
+ }
+
+ let mut create_body = json!({
+ "Domain": domain,
+ "SubDomain": rr,
+ "RecordType": "TXT",
+ "RecordLine": "默认",
+ "Value": value,
+ "TTL": 600,
+ });
+ if domain_id > 0 {
+ create_body["DomainId"] = json!(domain_id);
+ }
+ let created = self.call("CreateRecord", create_body).await?;
+ let id = created
+ .get("RecordId")
+ .and_then(|v| v.as_u64())
+ .ok_or_else(|| anyhow!("tencent missing RecordId"))?;
+ tracing::info!(%host, %id, %domain, "cert: tencent txt upserted");
+ Ok(format!("{domain}:{id}"))
+ }
+
+ async fn delete_txt(&self, record_id: &str) -> Result<()> {
+ let (domain, id) = match record_id.split_once(':') {
+ Some((d, i)) => (
+ d.to_string(),
+ i.parse::().context("tencent record id")?,
+ ),
+ None => {
+ bail!("tencent record id missing domain prefix");
+ }
+ };
+ self.call(
+ "DeleteRecord",
+ json!({
+ "Domain": domain,
+ "RecordId": id,
+ }),
+ )
+ .await
+ .context("tencent delete txt")?;
+ Ok(())
+ }
+}
+
+fn hmac_sha256(key: &[u8], data: &[u8]) -> Vec {
+ let mut mac = HmacSha256::new_from_slice(key).expect("hmac");
+ mac.update(data);
+ mac.finalize().into_bytes().to_vec()
+}
+
+#[derive(Debug, Deserialize)]
+struct TcResp {
+ #[serde(rename = "Response")]
+ response: TcResponse,
+}
+
+#[derive(Debug, Deserialize)]
+struct TcResponse {
+ #[serde(rename = "Error")]
+ error: Option,
+ #[serde(flatten)]
+ extra: serde_json::Value,
+}
+
+#[derive(Debug, Deserialize)]
+struct TcError {
+ #[serde(rename = "Code")]
+ code: String,
+ #[serde(rename = "Message")]
+ message: String,
+}
diff --git a/desktop/src/cert/issue.rs b/desktop/src/cert/issue.rs
new file mode 100644
index 0000000..4a7e6d3
--- /dev/null
+++ b/desktop/src/cert/issue.rs
@@ -0,0 +1,267 @@
+use super::dns::{self, DnsProvider};
+use super::model::Cert;
+use super::store::{self, Store};
+use anyhow::{bail, Context, Result};
+use instant_acme::{
+ Account, AuthorizationStatus, ChallengeType, Identifier, LetsEncrypt, NewAccount, NewOrder,
+ OrderStatus, RetryPolicy,
+};
+use std::future::Future;
+use std::sync::atomic::{AtomicBool, Ordering};
+use std::sync::Arc;
+use std::time::Duration;
+
+pub const ISSUE_TIMEOUT: Duration = Duration::from_secs(360);
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+#[repr(i32)]
+pub enum IssueStep {
+ Prepare = 0,
+ Order = 1,
+ DnsWrite = 2,
+ DnsWait = 3,
+ Validate = 4,
+ Finalize = 5,
+ Cleanup = 6,
+}
+
+pub struct IssueRequest {
+ pub domains: Vec,
+ pub provider_id: String,
+}
+
+struct PendingTxt {
+ host: String,
+ value: String,
+ record_id: String,
+}
+
+fn check_cancel(cancel: &AtomicBool) -> Result<()> {
+ if cancel.load(Ordering::Relaxed) {
+ bail!("cancelled");
+ }
+ Ok(())
+}
+
+async fn until_cancel(
+ cancel: &AtomicBool,
+ fut: impl Future