mirror of
https://github.com/orbien-org/orbien.git
synced 2026-09-28 08:01:45 +00:00
feat(desktop): add ACME certificate issuance with multi-cloud DNS-01 validation
This commit is contained in:
Generated
+323
-7
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 |
@@ -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
|
||||
|
||||
@@ -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=已复制到剪贴板
|
||||
|
||||
@@ -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(¶ms);
|
||||
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()
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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 || {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user