From 4ee54cf9f4954251b35e96407feba41ee57b3f8e Mon Sep 17 00:00:00 2001
From: zhom <2717306+zhom@users.noreply.github.com>
Date: Mon, 5 Oct 2026 20:13:47 +0400
Subject: [PATCH] feat: support vmess, trojan, and hysteria2
---
.github/workflows/publish-sidecars.yml | 140 +-
AGENTS.md | 2 +-
assets/readme/automation.svg | 2 +-
e2e/tests/browser.test.mjs | 9 +
e2e/tests/entities.test.mjs | 49 +-
e2e/tests/integrations.test.mjs | 2 +-
e2e/tests/network.test.mjs | 2 +-
e2e/tests/ui.test.mjs | 43 +-
src-tauri/src/api_server.rs | 12 +-
src-tauri/src/app_dirs.rs | 43 +
src-tauri/src/bin/proxy_server.rs | 124 ++
src-tauri/src/browser.rs | 2 +
src-tauri/src/browser_runner.rs | 2 +-
src-tauri/src/extension_manager.rs | 64 +-
src-tauri/src/fingerprint_consistency.rs | 2 +-
src-tauri/src/lib.rs | 30 +-
src-tauri/src/mcp_server.rs | 55 +-
src-tauri/src/profile/manager.rs | 8 +-
src-tauri/src/profile_importer.rs | 33 +-
src-tauri/src/proxy_manager.rs | 116 +-
src-tauri/src/proxy_server.rs | 102 +-
src-tauri/src/remote_exit.rs | 118 +-
src-tauri/src/socks5_local.rs | 13 +-
src-tauri/src/sync/engine.rs | 16 +
src-tauri/src/wayfern_manager.rs | 43 +-
src-tauri/src/xray/client.rs | 354 ++++-
src-tauri/src/xray/error.rs | 21 +-
src-tauri/src/xray/mod.rs | 6 +-
src-tauri/src/xray/model.rs | 905 +++++++++++--
src-tauri/src/xray/uri.rs | 1407 +++++++++++++-------
src-tauri/src/xray/uri_tests.rs | 593 +++++++++
src-tauri/src/xray_worker_runner.rs | 28 +-
src-tauri/tests/donut_proxy_integration.rs | 263 +++-
src/components/proxy-form-dialog.tsx | 186 +--
src/components/proxy-import-dialog.tsx | 4 +-
src/components/proxy-management-dialog.tsx | 6 +-
src/i18n/locales/de.json | 44 +-
src/i18n/locales/en.json | 44 +-
src/i18n/locales/es.json | 44 +-
src/i18n/locales/fr.json | 44 +-
src/i18n/locales/ja.json | 44 +-
src/i18n/locales/ko.json | 44 +-
src/i18n/locales/pt.json | 44 +-
src/i18n/locales/ru.json | 44 +-
src/i18n/locales/tr.json | 44 +-
src/i18n/locales/vi.json | 44 +-
src/i18n/locales/zh.json | 44 +-
src/lib/backend-errors.ts | 8 +-
src/lib/proxy-string.test.mjs | 79 +-
src/lib/proxy-string.ts | 94 +-
src/lib/proxy-type.test.mjs | 41 +-
src/lib/proxy-type.ts | 5 +-
52 files changed, 4325 insertions(+), 1191 deletions(-)
create mode 100644 src-tauri/src/xray/uri_tests.rs
diff --git a/.github/workflows/publish-sidecars.yml b/.github/workflows/publish-sidecars.yml
index f45cbb39..b39030fe 100644
--- a/.github/workflows/publish-sidecars.yml
+++ b/.github/workflows/publish-sidecars.yml
@@ -1,7 +1,9 @@
name: Publish sidecars to R2
-# Publishes the `donut-proxy` sidecar to the bucket behind
-# https://download.wayfern.com, where remote hosts fetch it from.
+# Publishes the `donut-proxy` sidecar, and the pinned Xray-core binary it runs
+# for VLESS, VMess, Trojan and Hysteria2 proxies, to the bucket behind
+# https://download.wayfern.com, where remote hosts fetch them from. A host
+# installs both in one directory: donut-proxy finds `xray` beside itself.
#
# WHY THIS EXISTS SEPARATELY FROM release.yml
# The desktop app ships donut-proxy INSIDE the bundle as a Tauri sidecar, so a
@@ -42,6 +44,7 @@ on:
- "src-tauri/src/vpn_worker_storage.rs"
- "src-tauri/src/xray_worker_runner.rs"
- "src-tauri/src/xray/**"
+ - "src-tauri/download-xray.mjs"
- "src-tauri/build.rs"
- "src-tauri/Cargo.toml"
- "src-tauri/Cargo.lock"
@@ -74,10 +77,12 @@ jobs:
- platform: macos-latest
target: aarch64-apple-darwin
artifact: donut-proxy-aarch64-apple-darwin
+ xray_artifact: xray-aarch64-apple-darwin
# Windows x86_64 remote host.
- platform: windows-latest
target: x86_64-pc-windows-msvc
artifact: donut-proxy-x86_64-pc-windows-msvc.exe
+ xray_artifact: xray-x86_64-pc-windows-msvc.exe
# Linux x86_64 remote host. Pinned to 22.04, not -latest: the
# deployment target is glibc 2.35, and a binary linked on 24.04
# (glibc 2.39) refuses to load there. The stage step proves the pin
@@ -85,6 +90,7 @@ jobs:
- platform: ubuntu-22.04
target: x86_64-unknown-linux-gnu
artifact: donut-proxy-x86_64-unknown-linux-gnu
+ xray_artifact: xray-x86_64-unknown-linux-gnu
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
@@ -95,6 +101,11 @@ jobs:
# version, which is what an operator reads to tell builds apart.
fetch-depth: 0
+ - name: Setup Node.js
+ uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 #v7.0.0
+ with:
+ node-version-file: .node-version
+
- name: Install Rust
uses: dtolnay/rust-toolchain@7e38f4b43b4db5c8dd498af069a4f6196df1d067 # master
with:
@@ -195,6 +206,38 @@ jobs:
printf '%s %s\n' "$digest" "${{ matrix.artifact }}" \
> "$RUNNER_TEMP/sidecars/${{ matrix.artifact }}.sha256"
+ # The same pinned upstream build the desktop bundles, checked against the
+ # archive digest in download-xray.mjs, so a host runs exactly the Xray-core
+ # this donut-proxy writes its configuration for.
+ - name: Stage Xray-core and record its digest
+ id: stage_xray
+ shell: bash
+ run: |
+ set -euo pipefail
+ node src-tauri/download-xray.mjs --target ${{ matrix.target }}
+ src="src-tauri/binaries/xray-${{ matrix.target }}"
+ [ -f "$src.exe" ] && src="$src.exe"
+ dest="$RUNNER_TEMP/sidecars/${{ matrix.xray_artifact }}"
+ cp "$src" "$dest"
+ chmod +x "$dest"
+ cp src-tauri/binaries/xray-LICENSE.txt "$RUNNER_TEMP/sidecars/xray-LICENSE.txt"
+
+ version="$("$dest" version | head -n 1)"
+ case "$version" in
+ "Xray "*) ;;
+ *) echo "::error::unexpected xray version output: $version"; exit 1 ;;
+ esac
+
+ if command -v sha256sum >/dev/null; then
+ digest="$(sha256sum --binary < "$dest" | cut -d' ' -f1)"
+ else
+ digest="$(shasum -a 256 -b < "$dest" | cut -d' ' -f1)"
+ fi
+ echo "digest=$digest" >> "$GITHUB_OUTPUT"
+ echo "version=$version" >> "$GITHUB_OUTPUT"
+ printf '%s %s\n' "$digest" "${{ matrix.xray_artifact }}" \
+ > "$RUNNER_TEMP/sidecars/${{ matrix.xray_artifact }}.sha256"
+
- name: Publish to R2
shell: bash
env:
@@ -212,6 +255,8 @@ jobs:
WAYFERN_R2_BUCKET: ${{ secrets.WAYFERN_R2_BUCKET }}
ARTIFACT: ${{ matrix.artifact }}
DIGEST: ${{ steps.stage.outputs.digest }}
+ XRAY_ARTIFACT: ${{ matrix.xray_artifact }}
+ XRAY_DIGEST: ${{ steps.stage_xray.outputs.digest }}
run: |
set -euo pipefail
@@ -261,40 +306,56 @@ jobs:
export AWS_REQUEST_CHECKSUM_CALCULATION="WHEN_REQUIRED"
export AWS_RESPONSE_CHECKSUM_VALIDATION="WHEN_REQUIRED"
- src="$RUNNER_TEMP/sidecars/$ARTIFACT"
+ publish() {
+ local artifact="$1" expected="$2"
+ local src="$RUNNER_TEMP/sidecars/$artifact"
- # no-cache, not a long max-age: this key is deliberately overwritten in
- # place, and a cached copy of the previous build would make a host
- # fail its integrity check, which reads as a corrupt download rather
- # than a stale cache.
- aws s3 cp "$src" "s3://${bucket}/${ARTIFACT}" \
- --endpoint-url "$endpoint" \
- --content-type application/octet-stream \
- --cache-control "no-cache, must-revalidate" \
- --only-show-errors
- aws s3 cp "$src.sha256" "s3://${bucket}/${ARTIFACT}.sha256" \
- --endpoint-url "$endpoint" \
- --content-type text/plain \
- --cache-control "no-cache, must-revalidate" \
- --only-show-errors
+ # no-cache, not a long max-age: this key is deliberately overwritten in
+ # place, and a cached copy of the previous build would make a host
+ # fail its integrity check, which reads as a corrupt download rather
+ # than a stale cache.
+ aws s3 cp "$src" "s3://${bucket}/${artifact}" \
+ --endpoint-url "$endpoint" \
+ --content-type application/octet-stream \
+ --cache-control "no-cache, must-revalidate" \
+ --only-show-errors
+ aws s3 cp "$src.sha256" "s3://${bucket}/${artifact}.sha256" \
+ --endpoint-url "$endpoint" \
+ --content-type text/plain \
+ --cache-control "no-cache, must-revalidate" \
+ --only-show-errors
- # Read it back and compare. Without this, "published" is an assumption:
- # a truncated upload or a write to the wrong bucket both look like
- # success, and the failure would surface days later on a remote host
- # as an unexplained checksum mismatch.
- verify="$RUNNER_TEMP/verify-$ARTIFACT"
- aws s3 cp "s3://${bucket}/${ARTIFACT}" "$verify" \
- --endpoint-url "$endpoint" --only-show-errors
- if command -v sha256sum >/dev/null; then
- got="$(sha256sum --binary < "$verify" | cut -d' ' -f1)"
- else
- got="$(shasum -a 256 -b < "$verify" | cut -d' ' -f1)"
+ # Read it back and compare. Without this, "published" is an assumption:
+ # a truncated upload or a write to the wrong bucket both look like
+ # success, and the failure would surface days later on a remote host
+ # as an unexplained checksum mismatch.
+ local verify="$RUNNER_TEMP/verify-$artifact" got
+ aws s3 cp "s3://${bucket}/${artifact}" "$verify" \
+ --endpoint-url "$endpoint" --only-show-errors
+ if command -v sha256sum >/dev/null; then
+ got="$(sha256sum --binary < "$verify" | cut -d' ' -f1)"
+ else
+ got="$(shasum -a 256 -b < "$verify" | cut -d' ' -f1)"
+ fi
+ if [ "$got" != "$expected" ]; then
+ echo "::error::Round-trip mismatch for $artifact: uploaded $expected, bucket returned $got"
+ exit 1
+ fi
+ echo "Published and verified $artifact ($expected)"
+ }
+
+ publish "$ARTIFACT" "$DIGEST"
+ publish "$XRAY_ARTIFACT" "$XRAY_DIGEST"
+
+ # Xray-core is MPL-2.0. Every target carries the same text, so one job
+ # publishes it.
+ if [ "$RUNNER_OS" = "Linux" ]; then
+ aws s3 cp "$RUNNER_TEMP/sidecars/xray-LICENSE.txt" "s3://${bucket}/xray-LICENSE.txt" \
+ --endpoint-url "$endpoint" \
+ --content-type text/plain \
+ --cache-control "no-cache, must-revalidate" \
+ --only-show-errors
fi
- if [ "$got" != "$DIGEST" ]; then
- echo "::error::Round-trip mismatch for $ARTIFACT: uploaded $DIGEST, bucket returned $got"
- exit 1
- fi
- echo "Published and verified $ARTIFACT ($DIGEST)"
- name: Summarise
if: always() && steps.stage.outputs.digest != ''
@@ -303,6 +364,9 @@ jobs:
ARTIFACT: ${{ matrix.artifact }}
DIGEST: ${{ steps.stage.outputs.digest }}
VERSION: ${{ steps.stage.outputs.version }}
+ XRAY_ARTIFACT: ${{ matrix.xray_artifact }}
+ XRAY_DIGEST: ${{ steps.stage_xray.outputs.digest }}
+ XRAY_VERSION: ${{ steps.stage_xray.outputs.version }}
run: |
{
echo "### $ARTIFACT"
@@ -311,7 +375,15 @@ jobs:
echo "- sha256: \`$DIGEST\`"
echo "- url: https://download.wayfern.com/$ARTIFACT"
echo ""
- echo "The fleet bootstrap takes this digest as an argument, so copy it"
+ if [ -n "$XRAY_DIGEST" ]; then
+ echo "### $XRAY_ARTIFACT"
+ echo ""
+ echo "- version: \`$XRAY_VERSION\`"
+ echo "- sha256: \`$XRAY_DIGEST\`"
+ echo "- url: https://download.wayfern.com/$XRAY_ARTIFACT"
+ echo ""
+ fi
+ echo "The fleet bootstrap takes these digests as pins, so copy them"
echo "from here rather than fetching the published \`.sha256\` — a hash"
echo "served by the same bucket as the binary verifies transport only."
} >> "$GITHUB_STEP_SUMMARY"
diff --git a/AGENTS.md b/AGENTS.md
index eaa0dffe..015a38b2 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -58,7 +58,7 @@ donutbrowser/
│ │ ├── recipes.rs # Thin client for cloud recipes; storage and replay stay in the cloud
│ │ ├── sync/ # Cloud sync (engine, encryption, manifest, scheduler)
│ │ ├── vpn/ # WireGuard tunnels
-│ │ ├── xray/ # VLESS + XTLS Vision + REALITY config/URI support
+│ │ ├── xray/ # Xray share links (VLESS, VMess, Trojan, Hysteria2): parse, export, client config
│ │ ├── xray_worker_runner.rs # Xray-core sidecar lifecycle
│ │ ├── xray_worker_storage.rs # Private Xray worker state and runtime files
│ │ ├── wayfern_manager.rs # Wayfern (Chromium) browser management
diff --git a/assets/readme/automation.svg b/assets/readme/automation.svg
index 3e319efe..5585f212 100644
--- a/assets/readme/automation.svg
+++ b/assets/readme/automation.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/e2e/tests/browser.test.mjs b/e2e/tests/browser.test.mjs
index 908b03d1..27a58f0c 100644
--- a/e2e/tests/browser.test.mjs
+++ b/e2e/tests/browser.test.mjs
@@ -916,6 +916,7 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc
browser: "wayfern",
version: prepared.version,
temporary: true,
+ tags: ["e2e-rest-tag"],
},
});
assert.equal(
@@ -923,6 +924,14 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc
200,
JSON.stringify(temporary.value),
);
+ // Tags given on create are stored, not only echoed: the handler once
+ // looked the new profile up by name where an id was needed, and every
+ // create with tags answered 500.
+ const storedTemporary = await request(
+ `${base}/v1/profiles/${temporary.value.profile.id}`,
+ { token: saved.api_token },
+ );
+ assert.deepEqual(storedTemporary.value.profile.tags, ["e2e-rest-tag"]);
assert.equal(temporary.value.profile.temporary, true);
assert.equal(
temporary.value.profile.ephemeral,
diff --git a/e2e/tests/entities.test.mjs b/e2e/tests/entities.test.mjs
index 0627713c..b433d975 100644
--- a/e2e/tests/entities.test.mjs
+++ b/e2e/tests/entities.test.mjs
@@ -164,22 +164,42 @@ test("profile, group, proxy, tag, metadata, clone, and bulk-delete lifecycle", a
[],
);
- // Donut accepts one VLESS shape (REALITY + XTLS Vision over TCP). The form
- // uses this to tell the user WHICH part of their setup is unsupported
- // instead of implying they mistyped, so the reason must survive the IPC hop.
+ // The form uses this to tell the user WHICH part of their setup is
+ // unsupported instead of implying they mistyped, so the reason must
+ // survive the IPC hop. A link Donut can use comes back as the type and
+ // endpoint the form shows, VMess included, which hides them in base64.
const goodVless =
"vless://6d6e21a1-4829-4d2b-bc7f-1b25707b61e4@example.com:443" +
"?security=reality&flow=xtls-rprx-vision&encryption=none&type=tcp" +
"&sni=a.com&pbk=mQB9jxUDHO7g49VaNXLEdcNQ_jLhTbLolUsMUNwb6W4&sid=00&fp=chrome";
- assert.equal(
+ assert.deepEqual(
await app.invoke("validate_vless_uri", { uri: goodVless }),
- null,
+ { proxy_type: "vless", host: "example.com", port: 443 },
);
+ const vmess = `vmess://${Buffer.from(
+ JSON.stringify({
+ add: "vm.example.com",
+ port: "8443",
+ id: "6d6e21a1-4829-4d2b-bc7f-1b25707b61e4",
+ net: "ws",
+ path: "/vm",
+ tls: "tls",
+ }),
+ ).toString("base64")}`;
+ assert.deepEqual(await app.invoke("validate_vless_uri", { uri: vmess }), {
+ proxy_type: "vmess",
+ host: "vm.example.com",
+ port: 8443,
+ });
for (const [uri, reason] of [
- [goodVless.replace("security=reality", "security=tls"), "security"],
- [goodVless.replace("type=tcp", "type=ws"), "transport"],
- [goodVless.replace("flow=xtls-rprx-vision", "flow=none"), "flow"],
+ [goodVless.replace("security=reality", "security=xtls"), "security"],
+ [goodVless.replace("type=tcp", "type=kcp"), "transport"],
+ [
+ goodVless.replace("flow=xtls-rprx-vision", "flow=xtls-rprx-direct"),
+ "flow",
+ ],
+ ["hysteria2://pw@hy.example.com:443/?obfs=gost", "obfs"],
]) {
// invokeError returns the command's error wrapped in a message, so match
// rather than JSON.parse the whole string.
@@ -217,6 +237,19 @@ test("profile, group, proxy, tag, metadata, clone, and bulk-delete lifecycle", a
});
assert.equal(importResult.imported_count, 1);
+ // A second batch under the same prefix continues the numbering. It used
+ // to restart at 1, collide with every name of the first batch, and be
+ // skipped whole as duplicates.
+ const secondBatch = await app.invoke("import_proxies_from_parsed", {
+ parsedProxies: [
+ { ...parsedProxyFields, port: parsedProxyFields.port + 1 },
+ ],
+ namePrefix: "Parsed",
+ });
+ assert.equal(secondBatch.imported_count, 1);
+ assert.equal(secondBatch.skipped_count, 0);
+ assert.equal(secondBatch.proxies[0].name, "Parsed Proxy 2");
+
const profile = await createProfile(app);
assert.equal(profile.name, "Entity Profile");
assert.equal(
diff --git a/e2e/tests/integrations.test.mjs b/e2e/tests/integrations.test.mjs
index ac7d6c8c..3e9bf59b 100644
--- a/e2e/tests/integrations.test.mjs
+++ b/e2e/tests/integrations.test.mjs
@@ -254,7 +254,7 @@ test("authenticated REST API serves its complete OpenAPI contract and CRUD lifec
body: {
proxy_settings: {
...createdVless.value.proxy_settings,
- vless_uri: VLESS_URI.replace("security=reality", "security=tls"),
+ vless_uri: VLESS_URI.replace("type=tcp", "type=kcp"),
},
},
},
diff --git a/e2e/tests/network.test.mjs b/e2e/tests/network.test.mjs
index 96afed98..238463ef 100644
--- a/e2e/tests/network.test.mjs
+++ b/e2e/tests/network.test.mjs
@@ -472,7 +472,7 @@ test("VLESS Reality persists, imports, routes through Xray-core, records traffic
await app.restart();
}
- const invalidUri = vlessUri.replace("security=reality", "security=tls");
+ const invalidUri = vlessUri.replace("type=tcp", "type=kcp");
const invalidCreate = await app.invokeError("create_stored_proxy", {
name: "Invalid VLESS",
proxySettings: {
diff --git a/e2e/tests/ui.test.mjs b/e2e/tests/ui.test.mjs
index b31b38d5..b6f08820 100644
--- a/e2e/tests/ui.test.mjs
+++ b/e2e/tests/ui.test.mjs
@@ -620,7 +620,7 @@ test("every custom theme keeps tabs, counts, group pills, and rail states readab
});
});
-test("VLESS proxy form keeps the share URI as one clear, validated input", async () => {
+test("Xray proxy form keeps the share link as one clear, validated input", async () => {
await withApp("ui-vless-proxy-form", async (app) => {
const uri =
"vless://6d6e21a1-4829-4d2b-bc7f-1b25707b61e4@vpn.example.com:443?encryption=none&flow=xtls-rprx-vision&security=reality&sni=www.example.com&fp=chrome&pbk=BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc&sid=0123456789abcdef&type=tcp#E2E";
@@ -630,9 +630,8 @@ test("VLESS proxy form keeps the share URI as one clear, validated input", async
await app.clickSelector('[aria-label="New proxy"]');
await app.waitForText("Add Proxy");
await app.fillSelector("#proxy-name", "E2E VLESS");
- // "VLESS (REALITY)" since the type list was regrouped by whether the
- // first hop is encrypted; chooseSelectOption matches the label exactly.
- await chooseSelectOption(app, "#proxy-type", "VLESS (REALITY)");
+ // chooseSelectOption matches the label exactly.
+ await chooseSelectOption(app, "#proxy-type", "VLESS");
assert.equal(
await app.execute(
@@ -670,27 +669,49 @@ test("VLESS proxy form keeps the share URI as one clear, validated input", async
true,
);
- // A well-formed URI for a setup Donut cannot use must say WHICH part is
+ // A well-formed link for a setup Donut cannot use must say WHICH part is
// unsupported, rather than implying the user mistyped it.
await app.fillSelector(
"#proxy-vless-uri",
- `${uri.replace("type=tcp", "type=ws")}&path=%2Fray`,
+ `${uri.replace("type=tcp", "type=kcp")}&seed=x`,
);
+ // The hint below the field also says "transport", so only the backend's
+ // refusal, which marks the field invalid, counts here.
await app.waitFor(
() =>
app.execute(
- `return /only|TCP|transport/i.test(
- document.querySelector("#proxy-vless-uri-help")?.textContent || ""
- );`,
+ `return document.querySelector("#proxy-vless-uri")?.getAttribute("aria-invalid") === "true" &&
+ /uses a transport/i.test(
+ document.querySelector("#proxy-vless-uri-help")?.textContent || ""
+ );`,
),
{ description: "transport-specific unsupported message" },
);
- await app.fillSelector("#proxy-vless-uri", uri);
+ // The link names its protocol, and the backend stores the proxy under it,
+ // so the form follows the link before anything is saved.
+ await app.fillSelector(
+ "#proxy-vless-uri",
+ "trojan://pw@tj.example.com:443?sni=tj.example.com",
+ );
await app.waitFor(
() =>
app.execute(
- `return document.querySelector("#proxy-vless-uri")?.getAttribute("aria-invalid") === "false";`,
+ `return document.querySelector("#proxy-type")?.textContent?.trim() === "Trojan";`,
+ ),
+ { description: "type follows a Trojan link" },
+ );
+
+ await app.fillSelector("#proxy-vless-uri", uri);
+ // The field is not marked invalid while the backend is still reading the
+ // link, so the enabled button is what proves it was accepted.
+ await app.waitFor(
+ () =>
+ app.execute(
+ `return document.querySelector("#proxy-vless-uri")?.getAttribute("aria-invalid") === "false" &&
+ [...document.querySelectorAll("[role='dialog'] button")]
+ .find((button) => button.textContent?.trim() === "Add Proxy")
+ ?.disabled === false;`,
),
{ description: "valid VLESS endpoint feedback" },
);
diff --git a/src-tauri/src/api_server.rs b/src-tauri/src/api_server.rs
index 7e40b3d1..a7f2be3e 100644
--- a/src-tauri/src/api_server.rs
+++ b/src-tauri/src/api_server.rs
@@ -1338,6 +1338,9 @@ fn manager_error_response(err: impl std::fmt::Display) -> (StatusCode, String) {
// two things that don't go together, not a fault of this machine.
|| lower.contains("is not compatible with")
|| lower.contains("not supported for browser")
+ // A proxy import body that is not JSON is the caller's input, not a fault
+ // here; the spec for /v1/proxies/import already promises 400 for it.
+ || lower.contains("invalid json format")
{
StatusCode::BAD_REQUEST
} else {
@@ -1587,7 +1590,7 @@ async fn create_profile(
// Apply tags if provided
if let Some(tags) = &request.tags {
if profile_manager
- .update_profile_tags(&state.app_handle, &profile.name, tags.clone())
+ .update_profile_tags(&state.app_handle, &profile.id.to_string(), tags.clone())
.is_err()
{
return Err((
@@ -6103,6 +6106,13 @@ mod tests {
assert_eq!(status, StatusCode::NOT_FOUND);
}
+ #[test]
+ fn a_proxy_import_body_that_is_not_json_is_a_bad_request() {
+ let (status, _) =
+ manager_error_response("Invalid JSON format: expected value at line 1 column 1");
+ assert_eq!(status, StatusCode::BAD_REQUEST);
+ }
+
#[test]
fn an_extension_write_names_exactly_one_source() {
use base64::Engine as _;
diff --git a/src-tauri/src/app_dirs.rs b/src-tauri/src/app_dirs.rs
index ad6e2660..3b1008a2 100644
--- a/src-tauri/src/app_dirs.rs
+++ b/src-tauri/src/app_dirs.rs
@@ -239,6 +239,25 @@ pub fn settings_dir() -> PathBuf {
data_dir().join("settings")
}
+/// Whether a name read from sync or an automation client can become one path
+/// part under a data directory.
+///
+/// Ids and file names such as an extension's `file_name` or a proxy's `id`
+/// arrive in JSON from the sync server and from the REST API. `..`, a separator
+/// or an absolute path would read, write or delete outside the entity's own
+/// directory: a crafted extension `file_name` wrote anywhere the user can
+/// write, and an extension id of `..` led a remote tombstone to remove the
+/// whole data directory.
+pub fn is_plain_path_part(value: &str) -> bool {
+ !value.is_empty()
+ && value != "."
+ && value != ".."
+ && !value.contains(['/', '\\', '\0'])
+ // `C:name` carries a drive prefix on Windows; elsewhere `:` is an ordinary
+ // character, which a linked folder on macOS may well have.
+ && !(cfg!(windows) && value.contains(':'))
+}
+
pub fn proxies_dir() -> PathBuf {
data_dir().join("proxies")
}
@@ -363,6 +382,30 @@ pub fn write_owner_only(path: &std::path::Path, content: &[u8]) -> std::io::Resu
mod tests {
use super::*;
+ #[test]
+ fn only_a_single_plain_name_is_a_plain_path_part() {
+ for plain in [
+ "6d6e21a1-4829-4d2b-bc7f-1b25707b61e4",
+ "ublock.zip",
+ "a b.crx",
+ "..x",
+ ] {
+ assert!(is_plain_path_part(plain), "{plain}");
+ }
+ for unsafe_part in [
+ "",
+ ".",
+ "..",
+ "../x",
+ "a/b",
+ "a\\b",
+ "/etc/passwd",
+ "nul\0byte",
+ ] {
+ assert!(!is_plain_path_part(unsafe_part), "{unsafe_part:?}");
+ }
+ }
+
#[test]
fn test_app_name() {
let name = app_name();
diff --git a/src-tauri/src/bin/proxy_server.rs b/src-tauri/src/bin/proxy_server.rs
index 842b2cc0..7546f489 100644
--- a/src-tauri/src/bin/proxy_server.rs
+++ b/src-tauri/src/bin/proxy_server.rs
@@ -167,6 +167,41 @@ async fn main() {
)
.subcommand(Command::new("list").about("List all proxy servers")),
)
+ .subcommand(
+ Command::new("xray")
+ .about("Manage Xray-core workers for share-link proxies (VLESS, VMess, Trojan, Hysteria2)")
+ .subcommand(
+ Command::new("start")
+ .about(
+ "Start a worker serving authenticated SOCKS5 on 127.0.0.1. The share link is \
+ read from DONUT_PROXY_SHARE_LINK, never from the command line",
+ )
+ .arg(
+ Arg::new("owner-pid")
+ .long("owner-pid")
+ .value_parser(clap::value_parser!(u32))
+ .required(true)
+ .help("The worker exits when this process exits"),
+ ),
+ )
+ .subcommand(
+ Command::new("bind")
+ .about("Make a worker exit with this process instead of its current owner")
+ .arg(Arg::new("id").long("id").required(true).help("Worker ID"))
+ .arg(
+ Arg::new("pid")
+ .long("pid")
+ .value_parser(clap::value_parser!(u32))
+ .required(true)
+ .help("New owner process ID"),
+ ),
+ )
+ .subcommand(
+ Command::new("stop")
+ .about("Stop a worker")
+ .arg(Arg::new("id").long("id").required(true).help("Worker ID")),
+ ),
+ )
.subcommand(
Command::new("proxy-worker")
.about("Run a proxy worker process (internal use)")
@@ -350,6 +385,95 @@ async fn main() {
log::error!("Invalid action. Use 'start', 'stop', or 'list'");
process::exit(1);
}
+ } else if let Some(xray_matches) = matches.subcommand_matches("xray") {
+ if let Some(start_matches) = xray_matches.subcommand_matches("start") {
+ let owner_pid = *start_matches
+ .get_one::("owner-pid")
+ .expect("owner-pid is required");
+ let share_link = std::env::var("DONUT_PROXY_SHARE_LINK")
+ .ok()
+ .filter(|link| !link.trim().is_empty());
+ // The worker processes started below inherit this environment.
+ std::env::remove_var("DONUT_PROXY_SHARE_LINK");
+ let Some(share_link) = share_link else {
+ eprintln!("DONUT_PROXY_SHARE_LINK is not set");
+ process::exit(2);
+ };
+
+ // The detached worker would otherwise inherit the caller's stdout and
+ // stderr pipes, and a caller that reads to EOF would wait for the worker
+ // to exit. Same fix as `proxy start` (proxy_runner.rs).
+ #[cfg(windows)]
+ {
+ use std::os::windows::io::AsRawHandle;
+ use windows::Win32::Foundation::{SetHandleInformation, HANDLE, HANDLE_FLAGS};
+ const HANDLE_FLAG_INHERIT: u32 = 0x00000001;
+ for handle in [
+ std::io::stdout().as_raw_handle(),
+ std::io::stderr().as_raw_handle(),
+ ] {
+ if !handle.is_null() {
+ unsafe {
+ let _ = SetHandleInformation(HANDLE(handle), HANDLE_FLAG_INHERIT, HANDLE_FLAGS(0));
+ }
+ }
+ }
+ }
+
+ match donutbrowser_lib::xray_worker_runner::start_xray_worker_for_owner(
+ None,
+ share_link.trim(),
+ owner_pid,
+ )
+ .await
+ {
+ Ok(worker) => {
+ // Use println! here because this needs to go to stdout for parsing
+ println!(
+ "{}",
+ serde_json::json!({
+ "id": worker.id,
+ "localPort": worker.local_port,
+ "localUrl": format!("socks5://127.0.0.1:{}", worker.local_port),
+ "username": worker.username,
+ "password": worker.password,
+ })
+ );
+ process::exit(0);
+ }
+ Err(e) => {
+ eprintln!("Failed to start Xray-core worker: {e}");
+ process::exit(1);
+ }
+ }
+ } else if let Some(bind_matches) = xray_matches.subcommand_matches("bind") {
+ let id = bind_matches
+ .get_one::("id")
+ .expect("id is required");
+ let pid = *bind_matches.get_one::("pid").expect("pid is required");
+ let success = donutbrowser_lib::xray_worker_runner::set_browser_pid(id, pid);
+ // Use println! here because this needs to go to stdout for parsing
+ println!("{}", serde_json::json!({ "success": success }));
+ process::exit(0);
+ } else if let Some(stop_matches) = xray_matches.subcommand_matches("stop") {
+ let id = stop_matches
+ .get_one::("id")
+ .expect("id is required");
+ match donutbrowser_lib::xray_worker_runner::stop_xray_worker_now(id) {
+ Ok(success) => {
+ // Use println! here because this needs to go to stdout for parsing
+ println!("{}", serde_json::json!({ "success": success }));
+ process::exit(0);
+ }
+ Err(e) => {
+ eprintln!("Failed to stop Xray-core worker: {e}");
+ process::exit(1);
+ }
+ }
+ } else {
+ log::error!("Invalid action. Use 'start', 'bind', or 'stop'");
+ process::exit(1);
+ }
} else if let Some(worker_matches) = matches.subcommand_matches("proxy-worker") {
let id = worker_matches
.get_one::("id")
diff --git a/src-tauri/src/browser.rs b/src-tauri/src/browser.rs
index 00187281..81ca9614 100644
--- a/src-tauri/src/browser.rs
+++ b/src-tauri/src/browser.rs
@@ -9,6 +9,8 @@ pub struct ProxySettings {
pub port: u16,
pub username: Option,
pub password: Option,
+ /// The share link of a `vless`, `vmess`, `trojan` or `hysteria2` proxy. It
+ /// holds the whole server configuration; the name predates the other three.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vless_uri: Option,
}
diff --git a/src-tauri/src/browser_runner.rs b/src-tauri/src/browser_runner.rs
index d9d20cf5..537e6d4a 100644
--- a/src-tauri/src/browser_runner.rs
+++ b/src-tauri/src/browser_runner.rs
@@ -309,7 +309,7 @@ impl BrowserRunner {
if upstream_proxy
.as_ref()
- .is_some_and(|proxy| proxy.proxy_type.eq_ignore_ascii_case("vless"))
+ .is_some_and(|proxy| crate::xray::is_xray_proxy_type(&proxy.proxy_type))
{
let vless_uri = upstream_proxy
.as_ref()
diff --git a/src-tauri/src/extension_manager.rs b/src-tauri/src/extension_manager.rs
index e77baec9..e4980ca3 100644
--- a/src-tauri/src/extension_manager.rs
+++ b/src-tauri/src/extension_manager.rs
@@ -5,6 +5,7 @@ use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
use utoipa::ToSchema;
+use crate::app_dirs::is_plain_path_part;
use crate::events;
/// Where an extension's payload came from. `archive` is a user-supplied
@@ -636,7 +637,7 @@ impl ExtensionManager {
source_kind: String,
) -> Result> {
let browser_compatibility = determine_browser_compatibility(&file_type);
- if browser_compatibility.is_empty() {
+ if browser_compatibility.is_empty() || !is_plain_path_part(&file_name) {
return Err(err_code("EXTENSION_UNSUPPORTED_FILE_TYPE"));
}
let now = now_secs();
@@ -771,6 +772,9 @@ impl ExtensionManager {
}
pub fn get_extension(&self, id: &str) -> Result> {
+ if !is_plain_path_part(id) {
+ return Err(format!("Extension with id '{id}' not found").into());
+ }
let metadata_path = self.get_metadata_path(id);
if !metadata_path.exists() {
return Err(format!("Extension with id '{id}' not found").into());
@@ -914,7 +918,7 @@ impl ExtensionManager {
explicit_name_provided: bool,
) -> Result<(), Box> {
let browser_compatibility = determine_browser_compatibility(&file_type);
- if browser_compatibility.is_empty() {
+ if browser_compatibility.is_empty() || !is_plain_path_part(&file_name) {
return Err(err_code("EXTENSION_UNSUPPORTED_FILE_TYPE"));
}
@@ -1374,6 +1378,9 @@ impl ExtensionManager {
&self,
ext: &Extension,
) -> Result<(), Box> {
+ if !is_plain_path_part(&ext.id) || !is_plain_path_part(&ext.file_name) {
+ return Err(format!("Extension '{}' has an unsafe id or file name", ext.id).into());
+ }
let metadata_path = self.get_metadata_path(&ext.id);
if let Some(parent) = metadata_path.parent() {
fs::create_dir_all(parent)?;
@@ -1391,6 +1398,9 @@ impl ExtensionManager {
}
pub fn delete_extension_internal(&self, id: &str) -> Result<(), Box> {
+ if !is_plain_path_part(id) {
+ return Err(format!("Extension with id '{id}' not found").into());
+ }
let ext_dir = self.get_extension_dir(id);
if ext_dir.exists() {
fs::remove_dir_all(&ext_dir)?;
@@ -2365,6 +2375,56 @@ mod tests {
assert_eq!(restored.updated_at, ext.updated_at);
}
+ #[test]
+ fn ids_and_file_names_from_sync_or_rest_cannot_leave_the_extension_directory() {
+ let tmp = tempfile::tempdir().unwrap();
+ let _guard = crate::app_dirs::set_test_data_dir(tmp.path().to_path_buf());
+ let mgr = ExtensionManager::new();
+ let ext = mgr
+ .add_extension(
+ "fallback".to_string(),
+ "ublock.zip".to_string(),
+ localized_extension_zip(),
+ )
+ .unwrap();
+ let canary = tmp.path().join("canary.txt");
+ std::fs::write(&canary, "still here").unwrap();
+
+ // What a crafted sync object would upsert.
+ for (id, file_name) in [
+ ("..", "ublock.zip"),
+ ("../..", "ublock.zip"),
+ (ext.id.as_str(), "../../../escaped.zip"),
+ (ext.id.as_str(), "/tmp/escaped.zip"),
+ (ext.id.as_str(), ".."),
+ ] {
+ let mut crafted = ext.clone();
+ crafted.id = id.to_string();
+ crafted.file_name = file_name.to_string();
+ assert!(
+ mgr.update_extension_internal(&crafted).is_err(),
+ "{id} / {file_name}"
+ );
+ }
+
+ // A remote tombstone for `..` must not reach the parent directory.
+ assert!(mgr.get_extension("..").is_err());
+ assert!(mgr.delete_extension_internal("..").is_err());
+ assert!(canary.exists());
+
+ // The REST and UI add paths refuse a file name with path parts too.
+ for file_name in ["../escaped.zip", "nested/ublock.zip"] {
+ assert!(mgr
+ .add_extension(
+ "x".to_string(),
+ file_name.to_string(),
+ localized_extension_zip()
+ )
+ .is_err());
+ }
+ assert!(!tmp.path().join("escaped.zip").exists());
+ }
+
#[test]
fn a_stored_placeholder_is_repaired_rather_than_shown_to_the_user() {
let tmp = tempfile::tempdir().unwrap();
diff --git a/src-tauri/src/fingerprint_consistency.rs b/src-tauri/src/fingerprint_consistency.rs
index d16c4ad4..0d1343b6 100644
--- a/src-tauri/src/fingerprint_consistency.rs
+++ b/src-tauri/src/fingerprint_consistency.rs
@@ -75,7 +75,7 @@ pub fn exit_cache_key(profile: &BrowserProfile) -> Option {
let settings = PROXY_MANAGER
.resolve_proxy_for_profile(proxy_id, &profile.id.to_string())
.or_else(|| PROXY_MANAGER.get_proxy_settings_by_id(proxy_id))?;
- // build_proxy_url returns the VLESS URI verbatim for vless proxies, so one
+ // build_proxy_url returns the share link verbatim for Xray proxies, so one
// call covers every transport.
return Some(ExitCacheKey {
scope: format!("proxy:{proxy_id}"),
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index e0f774b8..393c749a 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -23,8 +23,9 @@ pub(crate) fn backend_error_with_detail(code: &str, detail: impl std::fmt::Displ
serde_json::json!({ "code": code, "params": { "detail": detail.to_string() } }).to_string()
}
-/// A VLESS URI Donut cannot use, carrying which part is unsupported so the UI
-/// can say so instead of implying a typo.
+/// A share link Donut cannot use, carrying which part is unsupported so the UI
+/// can say so instead of implying a typo. The code keeps its VLESS name because
+/// REST clients already match on it.
pub(crate) fn vless_config_error(error: &crate::xray::XrayError) -> String {
serde_json::json!({
"code": "VLESS_CONFIG_INVALID",
@@ -383,13 +384,26 @@ async fn create_stored_proxy(
}
}
-/// Validate a VLESS URI without touching the network, so the proxy form can
-/// tell the user their setup is unsupported while they are still editing it
-/// rather than only after they try to save or launch.
+/// What the proxy form shows for a share link it cannot read itself: VMess
+/// hides its server inside base64.
+#[derive(serde::Serialize)]
+struct ShareLinkSummary {
+ proxy_type: &'static str,
+ host: String,
+ port: u16,
+}
+
+/// Validate an Xray share link without touching the network, so the proxy
+/// form can tell the user their setup is unsupported while they are still
+/// editing it rather than only after they try to save or launch.
#[tauri::command]
-fn validate_vless_uri(uri: String) -> Result<(), String> {
- crate::xray::parse_vless_uri(uri.trim())
- .map(|_| ())
+fn validate_vless_uri(uri: String) -> Result {
+ crate::xray::parse_share_link(uri.trim())
+ .map(|parsed| ShareLinkSummary {
+ proxy_type: parsed.config.protocol().proxy_type(),
+ host: parsed.config.address,
+ port: parsed.config.port,
+ })
.map_err(|error| vless_config_error(&error))
}
diff --git a/src-tauri/src/mcp_server.rs b/src-tauri/src/mcp_server.rs
index bff11bd7..178742e8 100644
--- a/src-tauri/src/mcp_server.rs
+++ b/src-tauri/src/mcp_server.rs
@@ -217,6 +217,20 @@ fn explain_engine_refusal(mut error: McpError, device_refusal: Option) -
const DEFAULT_MCP_PORT: u16 = 51080;
+/// The proxy types the proxy tools accept. The last four are Xray share links
+/// passed in `vless_uri`.
+const MCP_PROXY_TYPES: [&str; 9] = [
+ "http",
+ "https",
+ "httpstls",
+ "socks4",
+ "socks5",
+ "vless",
+ "vmess",
+ "trojan",
+ "hysteria2",
+];
+
/// The event the desktop turns into the "local MCP is being removed" dialog.
///
/// Emitted by the loopback tombstone (below) when anything still tries to reach
@@ -3329,7 +3343,7 @@ impl McpServer {
},
"proxy_type": {
"type": "string",
- "enum": ["http", "https", "httpstls", "socks4", "socks5", "vless"],
+ "enum": MCP_PROXY_TYPES,
"description": "The proxy protocol"
},
"host": {
@@ -3350,7 +3364,7 @@ impl McpServer {
},
"vless_uri": {
"type": "string",
- "description": "VLESS + XTLS Vision + REALITY share URI"
+ "description": "Share link for vless, vmess, trojan and hysteria2 proxies (vless://, vmess://, trojan://, hysteria2:// or hy2://). It holds the whole server configuration, and the link's own scheme decides the stored type."
}
},
"required": ["name", "proxy_type"]
@@ -3372,7 +3386,7 @@ impl McpServer {
},
"proxy_type": {
"type": "string",
- "enum": ["http", "https", "httpstls", "socks4", "socks5", "vless"],
+ "enum": MCP_PROXY_TYPES,
"description": "The proxy protocol"
},
"host": {
@@ -3393,7 +3407,7 @@ impl McpServer {
},
"vless_uri": {
"type": "string",
- "description": "VLESS + XTLS Vision + REALITY share URI"
+ "description": "Share link for vless, vmess, trojan and hysteria2 proxies (vless://, vmess://, trojan://, hysteria2:// or hy2://). It holds the whole server configuration, and the link's own scheme decides the stored type."
}
},
"required": ["proxy_id"]
@@ -5777,8 +5791,11 @@ impl McpServer {
}
if let Some(tags) = tags {
- let _ =
- ProfileManager::instance().update_profile_tags(&app_handle, &profile.name, tags.clone());
+ let _ = ProfileManager::instance().update_profile_tags(
+ &app_handle,
+ &profile.id.to_string(),
+ tags.clone(),
+ );
profile.tags = tags;
if let Ok(profiles) = ProfileManager::instance().list_profiles() {
let _ = crate::tag_manager::TAG_MANAGER
@@ -6580,14 +6597,10 @@ impl McpServer {
// The tool schema declares an enum, but JSON-Schema enums are advisory only;
// enforce it here so a bad value can't produce a non-functional proxy.
- if !matches!(
- proxy_type,
- "http" | "https" | "httpstls" | "socks4" | "socks5" | "vless"
- ) {
+ if !MCP_PROXY_TYPES.contains(&proxy_type) {
return Err(McpError {
code: -32602,
- message: "proxy_type must be one of: http, https, httpstls, socks4, socks5, vless"
- .to_string(),
+ message: format!("proxy_type must be one of: {}", MCP_PROXY_TYPES.join(", ")),
data: None,
});
}
@@ -6596,7 +6609,7 @@ impl McpServer {
.get("vless_uri")
.and_then(|value| value.as_str())
.map(str::to_string);
- let (host, port) = if proxy_type == "vless" {
+ let (host, port) = if crate::xray::is_xray_proxy_type(proxy_type) {
if vless_uri.is_none() {
return Err(McpError {
code: -32602,
@@ -6705,14 +6718,10 @@ impl McpServer {
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| existing.proxy_settings.proxy_type.clone());
- if !matches!(
- proxy_type.as_str(),
- "http" | "https" | "httpstls" | "socks4" | "socks5" | "vless"
- ) {
+ if !MCP_PROXY_TYPES.contains(&proxy_type.as_str()) {
return Err(McpError {
code: -32602,
- message: "proxy_type must be one of: http, https, httpstls, socks4, socks5, vless"
- .to_string(),
+ message: format!("proxy_type must be one of: {}", MCP_PROXY_TYPES.join(", ")),
data: None,
});
}
@@ -12096,9 +12105,11 @@ mod tests {
.find(|tool| tool.name == "create_proxy")
.expect("create_proxy tool");
let properties = &create.input_schema["properties"];
- assert!(properties["proxy_type"]["enum"]
- .as_array()
- .is_some_and(|values| values.iter().any(|value| value == "vless")));
+ for xray_type in ["vless", "vmess", "trojan", "hysteria2"] {
+ assert!(properties["proxy_type"]["enum"]
+ .as_array()
+ .is_some_and(|values| values.iter().any(|value| value == xray_type)));
+ }
assert!(properties["vless_uri"].is_object());
let required = create.input_schema["required"]
diff --git a/src-tauri/src/profile/manager.rs b/src-tauri/src/profile/manager.rs
index 0d74b13c..129afb70 100644
--- a/src-tauri/src/profile/manager.rs
+++ b/src-tauri/src/profile/manager.rs
@@ -1455,7 +1455,13 @@ impl ProfileManager {
let dest_dir = profiles_dir.join(new_id.to_string());
if source_dir.exists() {
- crate::profile_importer::ProfileImporter::copy_directory_recursive(&source_dir, &dest_dir)?;
+ if let Err(error) =
+ crate::profile_importer::ProfileImporter::copy_directory_recursive(&source_dir, &dest_dir)
+ {
+ // A half-copied folder would otherwise stay behind with no profile.
+ let _ = fs::remove_dir_all(&dest_dir);
+ return Err(error);
+ }
} else {
fs::create_dir_all(&dest_dir)?;
}
diff --git a/src-tauri/src/profile_importer.rs b/src-tauri/src/profile_importer.rs
index c7aa1c81..db544a09 100644
--- a/src-tauri/src/profile_importer.rs
+++ b/src-tauri/src/profile_importer.rs
@@ -1144,7 +1144,15 @@ impl ProfileImporter {
let source_path = entry.path();
let dest_path = destination.join(entry.file_name());
- if source_path.is_dir() {
+ // Symlinks are not followed, as in `profile_import::copy`: Chromium
+ // leaves `SingletonLock` pointing at nothing after a crash, which made
+ // every clone fail, and a link to a folder would copy content from
+ // outside the profile.
+ let file_type = entry.file_type()?;
+ if file_type.is_symlink() {
+ continue;
+ }
+ if file_type.is_dir() {
Self::copy_directory_recursive(&source_path, &dest_path)?;
} else {
fs::copy(&source_path, &dest_path)?;
@@ -1311,6 +1319,29 @@ mod tests {
assert_eq!(content2, "content2", "file2 content should match");
}
+ #[cfg(unix)]
+ #[test]
+ fn a_copy_skips_symlinks_such_as_a_stale_singleton_lock() {
+ let temp_dir = TempDir::new().expect("Failed to create temp directory");
+ let source_dir = temp_dir.path().join("source");
+ let outside = temp_dir.path().join("outside");
+ fs::create_dir_all(&source_dir).unwrap();
+ fs::create_dir_all(&outside).unwrap();
+ fs::write(source_dir.join("Preferences"), "{}").unwrap();
+ fs::write(outside.join("secret.txt"), "not part of the profile").unwrap();
+ // What Chromium leaves after a crash, and a link out of the profile.
+ std::os::unix::fs::symlink("host-12345", source_dir.join("SingletonLock")).unwrap();
+ std::os::unix::fs::symlink(&outside, source_dir.join("linked")).unwrap();
+
+ let dest_dir = temp_dir.path().join("dest");
+ ProfileImporter::copy_directory_recursive(&source_dir, &dest_dir)
+ .expect("a dangling lock link must not stop the copy");
+
+ assert!(dest_dir.join("Preferences").exists());
+ assert!(fs::symlink_metadata(dest_dir.join("SingletonLock")).is_err());
+ assert!(!dest_dir.join("linked").exists());
+ }
+
#[test]
fn test_get_default_version_for_browser_no_versions() {
let (importer, _temp_dir) = create_test_profile_importer();
diff --git a/src-tauri/src/proxy_manager.rs b/src-tauri/src/proxy_manager.rs
index c3852cf7..f290c6d5 100644
--- a/src-tauri/src/proxy_manager.rs
+++ b/src-tauri/src/proxy_manager.rs
@@ -548,8 +548,11 @@ impl ProxyManager {
}
}
+ /// Xray-carried proxies keep their whole configuration in the share link
+ /// (`vless_uri`, named before VMess, Trojan and Hysteria2 joined). The link
+ /// decides the type, so a VMess link saved under "VLESS" is stored as VMess.
fn normalize_proxy_settings(mut proxy_settings: ProxySettings) -> Result {
- if !proxy_settings.proxy_type.eq_ignore_ascii_case("vless") {
+ if !crate::xray::is_xray_proxy_type(&proxy_settings.proxy_type) {
proxy_settings.vless_uri = None;
return Ok(proxy_settings);
}
@@ -560,11 +563,11 @@ impl ProxyManager {
.filter(|uri| !uri.is_empty())
.ok_or_else(|| crate::backend_error("VLESS_CONFIG_INVALID"))?;
let parsed =
- crate::xray::parse_vless_uri(uri).map_err(|error| crate::vless_config_error(&error))?;
- let canonical_uri = crate::xray::export_vless_uri(&parsed.config, parsed.name.as_deref())
+ crate::xray::parse_share_link(uri).map_err(|error| crate::vless_config_error(&error))?;
+ let canonical_uri = crate::xray::export_share_link(&parsed.config, parsed.name.as_deref())
.map_err(|error| crate::vless_config_error(&error))?;
- proxy_settings.proxy_type = "vless".to_string();
+ proxy_settings.proxy_type = parsed.config.protocol().proxy_type().to_string();
proxy_settings.host = parsed.config.address;
proxy_settings.port = parsed.config.port;
proxy_settings.username = None;
@@ -1224,7 +1227,7 @@ impl ProxyManager {
// a password containing `/`, `#`, `?` or `@` otherwise breaks the URL
// authority and silently retargets the request at the wrong host.
pub fn build_proxy_url(proxy_settings: &ProxySettings) -> String {
- if proxy_settings.proxy_type.eq_ignore_ascii_case("vless") {
+ if crate::xray::is_xray_proxy_type(&proxy_settings.proxy_type) {
return proxy_settings.vless_uri.clone().unwrap_or_default();
}
@@ -1317,7 +1320,7 @@ impl ProxyManager {
proxy_settings: &ProxySettings,
) -> Result {
let mut xray_worker_id = None;
- let effective_proxy_settings = if proxy_settings.proxy_type.eq_ignore_ascii_case("vless") {
+ let effective_proxy_settings = if crate::xray::is_xray_proxy_type(&proxy_settings.proxy_type) {
let uri = proxy_settings
.vless_uri
.as_deref()
@@ -1646,10 +1649,10 @@ impl ProxyManager {
// Try to parse URL format: protocol://username:password@host:port
fn try_parse_url_format(line: &str) -> Option {
- if line.starts_with("vless://") {
- return Some(match crate::xray::parse_vless_uri(line) {
+ if crate::xray::XrayProtocol::from_share_link(line).is_some() {
+ return Some(match crate::xray::parse_share_link(line) {
Ok(parsed) => ProxyParseResult::Parsed(ParsedProxyLine {
- proxy_type: "vless".to_string(),
+ proxy_type: parsed.config.protocol().proxy_type().to_string(),
host: parsed.config.address,
port: parsed.config.port,
username: None,
@@ -1842,9 +1845,25 @@ impl ProxyManager {
let mut skipped = 0;
let mut errors = Vec::new();
let prefix = name_prefix.unwrap_or_else(|| "Imported".to_string());
+ // Numbered from the first free name, not from 1: a second batch under the
+ // same prefix otherwise collided with every name of the first and was
+ // skipped whole as duplicates.
+ let mut taken: std::collections::HashSet = self
+ .get_stored_proxies()
+ .into_iter()
+ .map(|proxy| proxy.name)
+ .collect();
+ let mut number = 0;
- for (i, parsed) in parsed_proxies.into_iter().enumerate() {
- let proxy_name = format!("{} Proxy {}", prefix, i + 1);
+ for parsed in parsed_proxies {
+ let proxy_name = loop {
+ number += 1;
+ let candidate = format!("{} Proxy {}", prefix, number);
+ if !taken.contains(&candidate) {
+ break candidate;
+ }
+ };
+ taken.insert(proxy_name.clone());
let proxy_settings = ProxySettings {
proxy_type: parsed.proxy_type,
host: parsed.host,
@@ -3920,7 +3939,7 @@ mod tests {
assert!(normalized.password.is_none());
assert_eq!(normalized.vless_uri.as_deref(), Some(uri.as_str()));
- let invalid = uri.replace("security=reality", "security=tls");
+ let invalid = uri.replace("type=tcp", "type=kcp");
let error = ProxyManager::normalize_proxy_settings(ProxySettings {
proxy_type: "vless".to_string(),
host: "127.0.0.1".to_string(),
@@ -3934,6 +3953,79 @@ mod tests {
assert!(!error.contains(&invalid));
}
+ #[test]
+ fn every_xray_share_link_is_stored_under_the_type_its_link_names() {
+ use base64::{engine::general_purpose::STANDARD, Engine as _};
+
+ let id = "6d6e21a1-4829-4d2b-bc7f-1b25707b61e4";
+ let vmess = serde_json::json!({
+ "v": "2", "ps": "VM", "add": "vm.example.com", "port": "443", "id": id, "aid": "0",
+ "scy": "auto", "net": "ws", "type": "none", "host": "vm.example.com", "path": "/vm",
+ "tls": "tls"
+ });
+ let cases = [
+ (
+ "vless",
+ format!("vless://{id}@a.example.com:8080?type=ws&path=%2F"),
+ "vless",
+ "a.example.com",
+ 8080,
+ ),
+ // The link decides: a VMess link saved under the VLESS type is VMess.
+ (
+ "vless",
+ format!("vmess://{}", STANDARD.encode(vmess.to_string())),
+ "vmess",
+ "vm.example.com",
+ 443,
+ ),
+ (
+ "trojan",
+ "trojan://pw@tj.example.com:443?sni=tj.example.com".to_string(),
+ "trojan",
+ "tj.example.com",
+ 443,
+ ),
+ (
+ "HY2",
+ "hy2://pw@hy.example.com:8443".to_string(),
+ "hysteria2",
+ "hy.example.com",
+ 8443,
+ ),
+ ];
+ for (picked_type, link, expected_type, host, port) in cases {
+ let settings = |uri: String| ProxySettings {
+ proxy_type: picked_type.to_string(),
+ host: String::new(),
+ port: 0,
+ username: Some("unused".to_string()),
+ password: None,
+ vless_uri: Some(uri),
+ };
+ let normalized = ProxyManager::normalize_proxy_settings(settings(link.clone())).unwrap();
+ assert_eq!(normalized.proxy_type, expected_type, "{link}");
+ assert_eq!(normalized.host, host);
+ assert_eq!(normalized.port, port);
+ assert!(normalized.username.is_none());
+
+ let canonical = normalized.vless_uri.clone().unwrap();
+ assert_eq!(
+ ProxyManager::build_proxy_url(&normalized),
+ canonical,
+ "an Xray proxy is exported as its link"
+ );
+ let again = ProxyManager::normalize_proxy_settings(settings(canonical.clone())).unwrap();
+ assert_eq!(again.vless_uri.as_deref(), Some(canonical.as_str()));
+
+ assert!(matches!(
+ ProxyManager::parse_txt_proxies(&canonical).as_slice(),
+ [ProxyParseResult::Parsed(proxy)]
+ if proxy.proxy_type == expected_type && proxy.host == host && proxy.port == port
+ ));
+ }
+ }
+
fn history_entry(timestamp: u64, ok: bool) -> ProxyCheckHistoryEntry {
ProxyCheckHistoryEntry {
timestamp,
diff --git a/src-tauri/src/proxy_server.rs b/src-tauri/src/proxy_server.rs
index 20e9bacc..ccd896c0 100644
--- a/src-tauri/src/proxy_server.rs
+++ b/src-tauri/src/proxy_server.rs
@@ -129,7 +129,9 @@ impl BlocklistMatcher {
if self.domains.is_empty() {
return false;
}
- let host_lower = host.to_lowercase();
+ // `tracker.com.` is the same name as `tracker.com`; without the trim the
+ // fully qualified spelling walked past every list entry.
+ let host_lower = host.trim_end_matches('.').to_lowercase();
let in_set = self.set_contains(&host_lower);
if self.allowlist_mode {
// Allow only listed domains; block everything else.
@@ -443,25 +445,40 @@ async fn connect_via_socks(
}
} else {
let mut stream = stream;
- // SOCKS4 - simplified implementation
- let ip: std::net::IpAddr = target_host.parse()?;
-
let mut request = vec![0x04, 0x01]; // SOCKS4, CONNECT
request.extend_from_slice(&target_port.to_be_bytes());
- match ip {
- std::net::IpAddr::V4(ipv4) => {
+ match target_host.parse::() {
+ Ok(std::net::IpAddr::V4(ipv4)) => {
request.extend_from_slice(&ipv4.octets());
+ request.push(0); // empty userid, NULL-terminated
}
- std::net::IpAddr::V6(_) => {
+ Ok(std::net::IpAddr::V6(_)) => {
return Err("SOCKS4 does not support IPv6".into());
}
+ // The browser sends names, not addresses. Resolving one here would leak
+ // the destination to this machine's DNS, so it goes as SOCKS4a: the
+ // marker address 0.0.0.1, the userid, then the name for the proxy to
+ // resolve. A proxy that speaks only plain SOCKS4 refuses it, as it
+ // refused every name before.
+ Err(_) if !target_host.is_empty() && !target_host.contains('\0') => {
+ request.extend_from_slice(&[0, 0, 0, 1]);
+ request.push(0); // empty userid, NULL-terminated
+ request.extend_from_slice(target_host.as_bytes());
+ request.push(0); // NULL-terminated hostname
+ }
+ Err(_) => return Err("SOCKS4 target host is invalid".into()),
}
- request.push(0); // NULL terminator for userid
- stream.write_all(&request).await?;
-
- let mut response = [0u8; 8];
- stream.read_exact(&mut response).await?;
+ // A proxy that accepts the connection and never answers would otherwise
+ // hold this slot forever.
+ let response = tokio::time::timeout(UPSTREAM_DIAL_TIMEOUT, async {
+ stream.write_all(&request).await?;
+ let mut response = [0u8; 8];
+ stream.read_exact(&mut response).await?;
+ Ok::<_, std::io::Error>(response)
+ })
+ .await
+ .map_err(|_| "SOCKS4 upstream handshake timed out")??;
if response[1] != 0x5A {
return Err("SOCKS4 connection failed".into());
@@ -2195,6 +2212,17 @@ async fn read_upstream_connect_response(
/// only place `Proxy-Authorization` is written, so whether those credentials
/// cross the network in the clear is decided entirely by which stream the
/// caller hands in, nothing here can weaken it.
+/// `host:port` for a CONNECT line. An IPv6 literal (a SOCKS5 client sends one
+/// for `http://[2001:db8::1]/`) needs brackets, or the proxy reads its last
+/// group as the port.
+fn connect_authority(host: &str, port: u16) -> String {
+ if host.parse::().is_ok() {
+ format!("[{host}]:{port}")
+ } else {
+ format!("{host}:{port}")
+ }
+}
+
async fn connect_via_http_proxy(
mut proxy_stream: S,
proxy_host: &str,
@@ -2203,10 +2231,8 @@ async fn connect_via_http_proxy(
target_port: u16,
upstream: &Url,
) -> Result> {
- let mut connect_req = format!(
- "CONNECT {}:{} HTTP/1.1\r\nHost: {}:{}\r\n",
- target_host, target_port, target_host, target_port
- );
+ let authority = connect_authority(target_host, target_port);
+ let mut connect_req = format!("CONNECT {authority} HTTP/1.1\r\nHost: {authority}\r\n");
let (username, password) = upstream_userpass(upstream);
if !username.is_empty() {
@@ -2582,6 +2608,50 @@ mod tests {
assert!(matcher.is_blocked("a.b.c.example.com"));
}
+ #[tokio::test]
+ async fn a_socks4_upstream_receives_a_hostname_as_socks4a() {
+ use tokio::io::{AsyncReadExt, AsyncWriteExt};
+
+ let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
+ let address = listener.local_addr().unwrap().to_string();
+ let server = tokio::spawn(async move {
+ let (mut socket, _) = listener.accept().await.unwrap();
+ let mut request = vec![0_u8; 8 + 1 + "example.com".len() + 1];
+ socket.read_exact(&mut request).await.unwrap();
+ socket
+ .write_all(&[0, 0x5A, 0, 0, 0, 0, 0, 0])
+ .await
+ .unwrap();
+ request
+ });
+
+ connect_via_socks(&address, "example.com", 443, false, None)
+ .await
+ .expect("SOCKS4a connect");
+ let request = server.await.unwrap();
+ assert_eq!(&request[..8], &[4, 1, 1, 187, 0, 0, 0, 1]);
+ assert_eq!(&request[8..], b"\0example.com\0");
+ }
+
+ #[test]
+ fn a_connect_authority_brackets_ipv6_literals_only() {
+ assert_eq!(connect_authority("2001:db8::1", 443), "[2001:db8::1]:443");
+ assert_eq!(connect_authority("203.0.113.5", 80), "203.0.113.5:80");
+ assert_eq!(connect_authority("example.com", 443), "example.com:443");
+ }
+
+ #[test]
+ fn a_fully_qualified_name_with_a_trailing_dot_is_still_blocked() {
+ let mut matcher = BlocklistMatcher::new();
+ let mut domains = HashSet::new();
+ domains.insert("tracker.com".to_string());
+ matcher.domains = Arc::new(domains);
+
+ assert!(matcher.is_blocked("tracker.com."));
+ assert!(matcher.is_blocked("ads.TRACKER.com."));
+ assert!(!matcher.is_blocked("safe.com."));
+ }
+
#[test]
fn test_blocklist_no_false_positives() {
let mut matcher = BlocklistMatcher::new();
diff --git a/src-tauri/src/remote_exit.rs b/src-tauri/src/remote_exit.rs
index e7e0305e..37e33297 100644
--- a/src-tauri/src/remote_exit.rs
+++ b/src-tauri/src/remote_exit.rs
@@ -18,8 +18,8 @@
//! it did not check.
//!
//! Local proxies are not an exotic case. A local MITM proxy, an SSH tunnel, a
-//! locally-run SOCKS client and Donut's own VLESS support all present to the
-//! browser as `127.0.0.1:`.
+//! locally-run SOCKS client and Donut's own Xray support (VLESS, VMess, Trojan,
+//! Hysteria2) all present to the browser as `127.0.0.1:`.
//!
//! This module is the single answer, shared by every caller, and it FAILS
//! CLOSED: anything it cannot parse is reported as unreachable. Refusing a
@@ -50,7 +50,8 @@ pub enum ExitReachability {
/// needs a sidecar that is not available there. That is a permanent refusal
/// wearing a transient one's clothes, and every nightly retry pays for it.
UnsupportedKind {
- /// The protocol, as the user would name it: "VLESS".
+ /// The protocol, as the user would name it: "VLESS", "VMess", "Trojan" or
+ /// "Hysteria2".
kind: String,
/// Which part of the config it came from: "proxy" or "VPN".
source: &'static str,
@@ -229,43 +230,25 @@ fn normalize_host(raw: &str) -> String {
host.trim().trim_end_matches('.').to_string()
}
-/// The host a VLESS URI actually dials.
-///
-/// Load-bearing because of an asymmetry that is easy to get backwards: a VLESS
-/// proxy presents to the browser as `127.0.0.1:` — Donut runs a local xray
-/// worker and points the browser at it — but the address that decides whether
-/// anyone else could use this config is the SERVER inside the URI. The local
-/// port is an implementation detail of this machine; the URI is the exit.
-pub fn vless_uri_host(uri: &str) -> Option {
- let rest = uri.trim().strip_prefix("vless://")?;
- // Cut the fragment (`#label`) and query (`?type=...`) before looking for the
- // authority — either may contain '@' or ':'.
- let rest = rest.split('#').next()?;
- let rest = rest.split('?').next()?;
- // `uuid@host:port/...`
- let authority = rest.split('/').next()?;
- let host_port = authority
- .rsplit_once('@')
- .map(|(_, h)| h)
- .unwrap_or(authority);
- let host = normalize_host(host_port);
- if host.is_empty() {
- None
- } else {
- Some(host)
- }
-}
-
/// The exit host a stored proxy represents, as a remote host would have to dial
/// it.
+///
+/// Load-bearing because of an asymmetry that is easy to get backwards: an Xray
+/// proxy presents to the browser as `127.0.0.1:` — Donut runs a local xray
+/// worker and points the browser at it — but the address that decides whether
+/// anyone else could use this config is the SERVER inside the share link. The
+/// local port is an implementation detail of this machine; the link is the exit.
pub fn proxy_exit_host(settings: &crate::browser::ProxySettings) -> Result {
- if settings.proxy_type.eq_ignore_ascii_case("vless") {
+ if crate::xray::is_xray_proxy_type(&settings.proxy_type) {
let uri = settings
.vless_uri
.as_deref()
.filter(|uri| !uri.trim().is_empty())
- .ok_or_else(|| "VLESS proxy has no server URI".to_string())?;
- return vless_uri_host(uri).ok_or_else(|| "VLESS server URI is malformed".to_string());
+ .ok_or_else(|| "Xray proxy has no share link".to_string())?;
+ return crate::xray::share_link_host(uri)
+ .map(|host| normalize_host(&host))
+ .filter(|host| !host.is_empty())
+ .ok_or_else(|| "Xray share link is malformed".to_string());
}
let host = normalize_host(&settings.host);
@@ -277,20 +260,26 @@ pub fn proxy_exit_host(settings: &crate::browser::ProxySettings) -> Result Option {
- let has_uri = settings
+ if let Some(protocol) = crate::xray::XrayProtocol::from_scheme(&settings.proxy_type) {
+ return Some(protocol.display_name().to_string());
+ }
+ let uri = settings
.vless_uri
.as_deref()
- .is_some_and(|uri| !uri.trim().is_empty());
- if settings.proxy_type.eq_ignore_ascii_case("vless") || has_uri {
- return Some("VLESS".to_string());
- }
- None
+ .filter(|uri| !uri.trim().is_empty())?;
+ // A link Donut cannot even name still needs the sidecar, so it is refused all
+ // the same.
+ Some(
+ crate::xray::XrayProtocol::from_share_link(uri)
+ .map_or("Xray", |protocol| protocol.display_name())
+ .to_string(),
+ )
}
/// Classify a stored proxy.
@@ -519,16 +508,37 @@ mod tests {
}
#[test]
- fn vless_host_parsing_survives_query_and_fragment() {
- assert_eq!(
- vless_uri_host("vless://uuid@example.com:443?sni=a@b.com&x=1#my@label"),
- Some("example.com".to_string())
- );
- assert_eq!(
- vless_uri_host("vless://uuid@[2606:4700::1111]:443?type=ws"),
- Some("2606:4700::1111".to_string())
- );
- assert_eq!(vless_uri_host("not-a-vless-uri"), None);
+ fn every_xray_protocol_is_refused_under_its_own_name() {
+ for (proxy_type, uri, kind) in [
+ ("vmess", "vmess://e30=", "VMess"),
+ ("trojan", "trojan://pw@tj.example.com:443", "Trojan"),
+ (
+ "hysteria2",
+ "hysteria2://pw@hy.example.com:443/",
+ "Hysteria2",
+ ),
+ // The type field disagrees: the link still names the protocol.
+ ("socks5", "trojan://pw@tj.example.com:443", "Trojan"),
+ ] {
+ let mut settings = proxy(proxy_type, "127.0.0.1");
+ settings.vless_uri = Some(uri.into());
+ assert_eq!(
+ classify_proxy(&settings),
+ ExitReachability::UnsupportedKind {
+ kind: kind.to_string(),
+ source: "proxy",
+ }
+ );
+ }
+ }
+
+ #[test]
+ fn an_xray_exit_host_is_the_server_inside_its_link() {
+ let mut settings = proxy("trojan", "127.0.0.1");
+ settings.vless_uri = Some("trojan://pw@TJ.example.com.:443?type=ws#x@y".into());
+ assert_eq!(proxy_exit_host(&settings), Ok("tj.example.com".to_string()));
+ settings.vless_uri = Some("trojan://".into());
+ assert!(proxy_exit_host(&settings).is_err());
}
#[test]
diff --git a/src-tauri/src/socks5_local.rs b/src-tauri/src/socks5_local.rs
index 488f919a..b12bcefd 100644
--- a/src-tauri/src/socks5_local.rs
+++ b/src-tauri/src/socks5_local.rs
@@ -20,7 +20,8 @@
//! unfiltered, unmetered side channel around the TCP filter.
use crate::proxy_server::{
- connect_to_target_via_upstream, tunnel_streams, BlocklistMatcher, BypassMatcher,
+ connect_to_target_via_upstream, redacted_upstream, tunnel_streams, BlocklistMatcher,
+ BypassMatcher,
};
use crate::traffic_stats::{get_traffic_tracker, LiveTrafficTracker};
use async_socks5::{AddrKind, Auth, SocksDatagram};
@@ -303,6 +304,12 @@ async fn read_request(stream: &mut TcpStream) -> std::io::Result
})
}
+/// The upstream as a log line may show it: the URL carries the proxy's
+/// username and password, and the worker log is a plain file in the temp dir.
+fn upstream_label(upstream_url: Option<&str>) -> String {
+ upstream_url.map_or_else(|| "DIRECT".to_string(), redacted_upstream)
+}
+
/// Read a SOCKS5 address of the given type into a host string (an IP literal or
/// a domain name; `connect_to_target_via_upstream` handles both).
async fn read_addr(stream: &mut TcpStream, atyp: u8) -> std::io::Result {
@@ -377,7 +384,7 @@ async fn handle_connect(
"SOCKS5 CONNECT {}:{} (upstream={})",
host,
port,
- upstream_url.as_deref().unwrap_or("DIRECT")
+ upstream_label(upstream_url.as_deref())
);
// Resolve to the target stream, logging and dropping the (non-Send) dial
@@ -436,7 +443,7 @@ async fn handle_udp_associate(
if mode == UdpMode::Refuse {
log::info!(
"SOCKS5 UDP ASSOCIATE refused: upstream ({}) cannot carry UDP without leaking; Chromium will use proxied TCP",
- upstream_url.as_deref().unwrap_or("DIRECT")
+ upstream_label(upstream_url.as_deref())
);
let _ = send_reply(&mut control, REP_COMMAND_NOT_SUPPORTED, unspecified()).await;
return;
diff --git a/src-tauri/src/sync/engine.rs b/src-tauri/src/sync/engine.rs
index 6ab12d35..4afb8534 100644
--- a/src-tauri/src/sync/engine.rs
+++ b/src-tauri/src/sync/engine.rs
@@ -1908,6 +1908,13 @@ impl SyncEngine {
.as_secs(),
);
+ // The id names the file below, and the JSON came from the server.
+ if !crate::app_dirs::is_plain_path_part(&proxy.id) {
+ return Err(SyncError::InvalidData(
+ "Remote proxy has an unsafe id".to_string(),
+ ));
+ }
+
let proxy_manager = &crate::proxy_manager::PROXY_MANAGER;
let proxy_file = proxy_manager.get_proxy_file_path(&proxy.id);
if let Some(parent) = proxy_file.parent() {
@@ -2479,6 +2486,15 @@ impl SyncEngine {
);
ext.sync_enabled = true;
+ // Both become path parts below, and the JSON came from the server.
+ if !crate::app_dirs::is_plain_path_part(&ext.id)
+ || !crate::app_dirs::is_plain_path_part(&ext.file_name)
+ {
+ return Err(SyncError::InvalidData(
+ "Remote extension has an unsafe id or file name".to_string(),
+ ));
+ }
+
// Download the extension file
let file_remote_key = format!("extensions/{}/file/{}", ext.id, ext.file_name);
let file_stat = self.client.stat(&file_remote_key).await?;
diff --git a/src-tauri/src/wayfern_manager.rs b/src-tauri/src/wayfern_manager.rs
index ce7d09bb..3052e424 100644
--- a/src-tauri/src/wayfern_manager.rs
+++ b/src-tauri/src/wayfern_manager.rs
@@ -1210,10 +1210,11 @@ enum ProbeRoute {
/// A temporary local `donut-proxy` worker dials this upstream. Carries the
/// URL the WORKER should dial, which is not always the stored one.
Worker(String),
- /// The upstream is VLESS, which no `donut-proxy` worker can speak. An
- /// Xray-core sidecar carries the VLESS hop and a `donut-proxy` worker fronts
- /// its loopback SOCKS5 endpoint, the same two-stage path `browser_runner`
- /// builds for a VLESS launch. Carries the VLESS URI.
+ /// The upstream is an Xray share link (VLESS, VMess, Trojan, Hysteria2),
+ /// which no `donut-proxy` worker can speak. An Xray-core sidecar carries the
+ /// hop and a `donut-proxy` worker fronts its loopback SOCKS5 endpoint, the
+ /// same two-stage path `browser_runner` builds for such a launch. Carries the
+ /// share link.
Xray(String),
/// Nothing available here carries this upstream. The probe is skipped and
/// the fingerprint keeps no location at all, which is the only honest
@@ -2214,14 +2215,11 @@ impl WayfernManager {
return ProbeRoute::Worker(upstream);
}
- if url
- .split_once("://")
- .is_some_and(|(scheme, _)| scheme.eq_ignore_ascii_case("vless"))
- {
- return match crate::xray::parse_vless_uri(url) {
+ if crate::xray::XrayProtocol::from_share_link(url).is_some() {
+ return match crate::xray::parse_share_link(url) {
Ok(_) => ProbeRoute::Xray(url.to_string()),
- // A `vless://host:port` with no id, flow or security parameters is what
- // a stored VLESS proxy collapses to when it is rendered as
+ // A `vless://host:port` with no id or security parameters is what a
+ // stored Xray proxy collapses to when it is rendered as
// `type://host:port`. Xray cannot dial that, so there is nothing to
// probe through and the launch-time refresh does the location instead -
// by then the upstream is the loopback SOCKS5 endpoint of a real Xray
@@ -4105,7 +4103,7 @@ mod tests {
));
}
- /// A complete VLESS + XTLS Vision + REALITY URI, the only shape Donut takes.
+ /// A complete VLESS + XTLS Vision + REALITY URI.
fn valid_vless_uri() -> String {
"vless://6d6e21a1-4829-4d2b-bc7f-1b25707b61e4@vpn.example.com:443\
?security=reality&flow=xtls-rprx-vision&encryption=none&type=tcp&sni=a.com\
@@ -4184,6 +4182,17 @@ mod tests {
WayfernManager::probe_route(&uri),
ProbeRoute::Xray(uri.clone())
);
+ // Every other protocol the sidecar speaks takes the same route.
+ for link in [
+ "vless://6d6e21a1-4829-4d2b-bc7f-1b25707b61e4@a.com:443?security=tls&type=ws",
+ "trojan://pw@tj.example.com:443?sni=tj.example.com",
+ "hysteria2://pw@hy.example.com:443/",
+ ] {
+ assert_eq!(
+ WayfernManager::probe_route(link),
+ ProbeRoute::Xray(link.to_string())
+ );
+ }
// A `vless://host:port` is what a stored VLESS proxy collapses to when it
// is rendered as `type://host:port`: no id, no flow, no REALITY key. Xray
@@ -4193,7 +4202,8 @@ mod tests {
"vless://vpn.example.com:443",
"vless://uuid@vpn.example.com:443",
// Right shape, unsupported transport, still nothing that can carry it.
- "vless://6d6e21a1-4829-4d2b-bc7f-1b25707b61e4@a.com:443?security=tls&type=ws",
+ "vless://6d6e21a1-4829-4d2b-bc7f-1b25707b61e4@a.com:443?security=tls&type=kcp",
+ "trojan://gw.example.com:443",
] {
assert_eq!(
WayfernManager::probe_route(lossy),
@@ -4256,9 +4266,10 @@ mod tests {
ProbeRoute::Worker("socks5://1.2.3.4:1080".into())
);
- // Schemes nothing here speaks. `proxy_type` is free text through the REST
- // API and MCP, so these are reachable without a code change, and each one
- // must end in a skipped probe rather than a fabricated location.
+ // Schemes nothing here speaks, and Xray links with no credential to dial.
+ // `proxy_type` is free text through the REST API and MCP, so these are
+ // reachable without a code change, and each one must end in a skipped
+ // probe rather than a fabricated location.
for unroutable in [
"trojan://gw.example.com:443",
"hysteria2://gw.example.com:443",
diff --git a/src-tauri/src/xray/client.rs b/src-tauri/src/xray/client.rs
index f56736aa..01fe6c65 100644
--- a/src-tauri/src/xray/client.rs
+++ b/src-tauri/src/xray/client.rs
@@ -1,7 +1,10 @@
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
-use super::{VlessRealityConfig, XrayError, XrayResult};
+use super::{
+ GrpcMode, Security, StreamSettings, TlsSettings, Transport, XrayConfig, XrayError, XrayOutbound,
+ XrayResult,
+};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
@@ -24,10 +27,7 @@ impl XrayClientRuntime {
}
}
-pub fn build_client_config(
- config: &VlessRealityConfig,
- runtime: &XrayClientRuntime,
-) -> XrayResult {
+pub fn build_client_config(config: &XrayConfig, runtime: &XrayClientRuntime) -> XrayResult {
config.validate()?;
runtime.validate()?;
@@ -50,42 +50,173 @@ pub fn build_client_config(
"ip": "127.0.0.1"
}
}],
- "outbounds": [{
+ "outbounds": [outbound(config)]
+ }))
+}
+
+fn outbound(config: &XrayConfig) -> Value {
+ match &config.outbound {
+ XrayOutbound::Vless { id, flow, stream } => {
+ let mut user = json!({ "id": id, "encryption": "none" });
+ if let Some(flow) = flow {
+ user["flow"] = json!(flow.as_str());
+ }
+ json!({
+ "tag": "proxy",
+ "protocol": "vless",
+ "settings": {
+ "vnext": [{ "address": config.address, "port": config.port, "users": [user] }]
+ },
+ "streamSettings": stream_settings(stream)
+ })
+ }
+ XrayOutbound::Vmess {
+ id,
+ security,
+ stream,
+ } => json!({
"tag": "proxy",
- "protocol": "vless",
+ "protocol": "vmess",
"settings": {
"vnext": [{
"address": config.address,
"port": config.port,
- "users": [{
- "id": config.id,
- "encryption": "none",
- "flow": config.flow.as_str()
- }]
+ "users": [{ "id": id, "security": security.as_str() }]
}]
},
- "streamSettings": {
- "network": "tcp",
- "security": "reality",
- "realitySettings": {
- "show": false,
- "fingerprint": config.reality.fingerprint.as_str(),
- "serverName": config.reality.server_name,
- "publicKey": config.reality.public_key,
- "shortId": config.reality.short_id,
- "spiderX": config.reality.spider_x
- },
- "sockopt": {
- "tcpKeepAliveIdle": 30,
- "tcpKeepAliveInterval": 15
- }
+ "streamSettings": stream_settings(stream)
+ }),
+ XrayOutbound::Trojan { password, stream } => json!({
+ "tag": "proxy",
+ "protocol": "trojan",
+ "settings": {
+ "servers": [{ "address": config.address, "port": config.port, "password": password }]
+ },
+ "streamSettings": stream_settings(stream)
+ }),
+ XrayOutbound::Hysteria2 {
+ auth,
+ tls,
+ obfs_password,
+ } => {
+ // HTTP/3 is the only protocol a Hysteria2 server answers, and Xray-core
+ // offers h2 and http/1.1 unless told otherwise.
+ let mut tls_settings = tls_settings(tls);
+ tls_settings["alpn"] = json!(["h3"]);
+ let mut stream = json!({
+ "network": "hysteria",
+ "hysteriaSettings": { "version": 2, "auth": auth },
+ "security": "tls",
+ "tlsSettings": tls_settings
+ });
+ if let Some(password) = obfs_password {
+ stream["finalmask"] = json!({
+ "udp": [{ "type": "salamander", "settings": { "password": password } }]
+ });
}
- }]
- }))
+ json!({
+ "tag": "proxy",
+ "protocol": "hysteria",
+ "settings": { "version": 2, "address": config.address, "port": config.port },
+ "streamSettings": stream
+ })
+ }
+ }
+}
+
+fn stream_settings(stream: &StreamSettings) -> Value {
+ let mut settings = json!({
+ "network": stream.transport.as_str(),
+ "security": stream.security.as_str(),
+ "sockopt": {
+ "tcpKeepAliveIdle": 30,
+ "tcpKeepAliveInterval": 15
+ }
+ });
+ match &stream.security {
+ Security::None => {}
+ Security::Tls(tls) => settings["tlsSettings"] = tls_settings(tls),
+ Security::Reality(reality) => {
+ settings["realitySettings"] = json!({
+ "show": false,
+ "fingerprint": reality.fingerprint.as_str(),
+ "serverName": reality.server_name,
+ "publicKey": reality.public_key,
+ "shortId": reality.short_id,
+ "spiderX": reality.spider_x
+ });
+ }
+ }
+ match &stream.transport {
+ Transport::Raw { http_header: None } => {}
+ Transport::Raw {
+ http_header: Some(header),
+ } => {
+ let mut request = json!({ "path": [header.path] });
+ if !header.hosts.is_empty() {
+ request["headers"] = json!({ "Host": header.hosts });
+ }
+ settings["tcpSettings"] = json!({ "header": { "type": "http", "request": request } });
+ }
+ Transport::WebSocket { host, path } => {
+ settings["wsSettings"] = path_and_host(path, host.as_deref());
+ }
+ Transport::HttpUpgrade { host, path } => {
+ settings["httpupgradeSettings"] = path_and_host(path, host.as_deref());
+ }
+ Transport::Xhttp { host, path, mode } => {
+ let mut xhttp = path_and_host(path, host.as_deref());
+ xhttp["mode"] = json!(mode.as_str());
+ settings["xhttpSettings"] = xhttp;
+ }
+ Transport::Grpc {
+ service_name,
+ authority,
+ mode,
+ } => {
+ let mut grpc = json!({
+ "serviceName": service_name,
+ "multiMode": *mode == GrpcMode::Multi
+ });
+ if let Some(authority) = authority {
+ grpc["authority"] = json!(authority);
+ }
+ settings["grpcSettings"] = grpc;
+ }
+ }
+ settings
+}
+
+fn path_and_host(path: &str, host: Option<&str>) -> Value {
+ let mut value = json!({ "path": path });
+ if let Some(host) = host {
+ value["host"] = json!(host);
+ }
+ value
+}
+
+fn tls_settings(tls: &TlsSettings) -> Value {
+ let mut value = json!({});
+ if let Some(server_name) = &tls.server_name {
+ value["serverName"] = json!(server_name);
+ }
+ if !tls.alpn.is_empty() {
+ value["alpn"] = json!(tls.alpn);
+ }
+ if let Some(fingerprint) = tls.fingerprint {
+ value["fingerprint"] = json!(fingerprint.as_str());
+ }
+ if !tls.pinned_peer_cert_sha256.is_empty() {
+ value["pinnedPeerCertSha256"] = json!(tls.pinned_peer_cert_sha256.join(","));
+ }
+ if !tls.verify_peer_cert_by_name.is_empty() {
+ value["verifyPeerCertByName"] = json!(tls.verify_peer_cert_by_name.join(","));
+ }
+ value
}
pub fn build_client_config_json(
- config: &VlessRealityConfig,
+ config: &XrayConfig,
runtime: &XrayClientRuntime,
) -> XrayResult {
serde_json::to_string_pretty(&build_client_config(config, runtime)?)
@@ -115,23 +246,24 @@ fn validate_socks_credential(field: &'static str, value: &str) -> XrayResult<()>
mod tests {
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
- use super::super::{RealityFingerprint, RealitySettings, VlessFlow};
+ use super::super::parse_share_link;
use super::*;
- fn valid_config() -> VlessRealityConfig {
- VlessRealityConfig {
- address: "vpn.example.com".to_string(),
- port: 443,
- id: "6d6e21a1-4829-4d2b-bc7f-1b25707b61e4".to_string(),
- flow: VlessFlow::Vision,
- reality: RealitySettings {
- server_name: "www.example.com".to_string(),
- public_key: URL_SAFE_NO_PAD.encode([7_u8; 32]),
- short_id: "0123456789abcdef".to_string(),
- fingerprint: RealityFingerprint::Chrome,
- spider_x: "/".to_string(),
- },
- }
+ const ID: &str = "6d6e21a1-4829-4d2b-bc7f-1b25707b61e4";
+
+ fn valid_config() -> XrayConfig {
+ let key = URL_SAFE_NO_PAD.encode([7_u8; 32]);
+ parse_share_link(&format!(
+ "vless://{ID}@vpn.example.com:443?encryption=none&flow=xtls-rprx-vision&security=reality\
+&sni=www.example.com&fp=chrome&pbk={key}&sid=0123456789abcdef&spx=%2F&type=tcp&headerType=none"
+ ))
+ .unwrap()
+ .config
+ }
+
+ fn outbound_for(link: &str) -> Value {
+ let config = parse_share_link(link).unwrap().config;
+ build_client_config(&config, &runtime()).unwrap()["outbounds"][0].clone()
}
fn runtime() -> XrayClientRuntime {
@@ -196,6 +328,124 @@ mod tests {
);
}
+ #[test]
+ fn plain_vless_over_websocket_and_tls_has_no_flow() {
+ let outbound = outbound_for(&format!(
+ "vless://{ID}@203.0.113.7:443?encryption=none&security=tls&type=ws\
+&host=cdn.example.com&path=%2Fray%3Fed%3D2048&alpn=http%2F1.1&fp=firefox"
+ ));
+ let user = &outbound["settings"]["vnext"][0]["users"][0];
+ assert_eq!(user["encryption"], "none");
+ assert!(user.get("flow").is_none());
+ let stream = &outbound["streamSettings"];
+ assert_eq!(stream["network"], "ws");
+ assert_eq!(stream["wsSettings"]["path"], "/ray?ed=2048");
+ assert_eq!(stream["wsSettings"]["host"], "cdn.example.com");
+ assert_eq!(stream["security"], "tls");
+ // The Host header doubles as the server name when the link names none.
+ assert_eq!(stream["tlsSettings"]["serverName"], "cdn.example.com");
+ assert_eq!(stream["tlsSettings"]["alpn"], json!(["http/1.1"]));
+ assert_eq!(stream["tlsSettings"]["fingerprint"], "firefox");
+ assert!(stream["tlsSettings"].get("allowInsecure").is_none());
+ }
+
+ #[test]
+ fn plain_vless_without_security_sends_no_tls_settings() {
+ let outbound = outbound_for(&format!("vless://{ID}@a.example.com:8080?type=tcp"));
+ assert_eq!(outbound["streamSettings"]["security"], "none");
+ assert!(outbound["streamSettings"].get("tlsSettings").is_none());
+ assert!(outbound["streamSettings"].get("realitySettings").is_none());
+ }
+
+ #[test]
+ fn vmess_carries_its_cipher_and_grpc_service() {
+ let outbound = outbound_for(&format!(
+ "vmess://{ID}@a.example.com:443?encryption=chacha20-poly1305&security=tls\
+&type=grpc&serviceName=gun-svc&mode=multi&authority=grpc.example.com&sni=a.example.com"
+ ));
+ assert_eq!(outbound["protocol"], "vmess");
+ assert_eq!(
+ outbound["settings"]["vnext"][0]["users"][0]["security"],
+ "chacha20-poly1305"
+ );
+ let grpc = &outbound["streamSettings"]["grpcSettings"];
+ assert_eq!(grpc["serviceName"], "gun-svc");
+ assert_eq!(grpc["multiMode"], true);
+ assert_eq!(grpc["authority"], "grpc.example.com");
+ }
+
+ #[test]
+ fn trojan_pins_a_certificate_instead_of_skipping_the_check() {
+ let pin = "AB:".repeat(31) + "AB";
+ let outbound = outbound_for(&format!(
+ "trojan://p%40ss@a.example.com:443?sni=a.example.com&pcs={pin}&allowInsecure=1&type=tcp"
+ ));
+ assert_eq!(outbound["protocol"], "trojan");
+ assert_eq!(outbound["settings"]["servers"][0]["password"], "p@ss");
+ let tls = &outbound["streamSettings"]["tlsSettings"];
+ assert_eq!(tls["pinnedPeerCertSha256"], "ab".repeat(32));
+ assert!(tls.get("allowInsecure").is_none());
+ }
+
+ #[test]
+ fn raw_http_header_disguise_is_written_as_a_request() {
+ let outbound = outbound_for(&format!(
+ "vmess://{ID}@a.example.com:80?type=tcp&headerType=http&host=a.com,b.com&path=%2Findex"
+ ));
+ let header = &outbound["streamSettings"]["tcpSettings"]["header"];
+ assert_eq!(header["type"], "http");
+ assert_eq!(header["request"]["path"], json!(["/index"]));
+ assert_eq!(
+ header["request"]["headers"]["Host"],
+ json!(["a.com", "b.com"])
+ );
+ }
+
+ #[test]
+ fn xhttp_and_httpupgrade_keep_path_host_and_mode() {
+ let xhttp = outbound_for(&format!(
+ "vless://{ID}@a.example.com:443?security=tls&type=xhttp&path=%2Fx&host=h.example.com&mode=stream-one"
+ ));
+ assert_eq!(xhttp["streamSettings"]["network"], "xhttp");
+ assert_eq!(
+ xhttp["streamSettings"]["xhttpSettings"]["mode"],
+ "stream-one"
+ );
+ assert_eq!(xhttp["streamSettings"]["xhttpSettings"]["path"], "/x");
+ let upgrade = outbound_for(&format!(
+ "vless://{ID}@a.example.com:80?type=httpupgrade&path=%2Fu&host=h.example.com"
+ ));
+ assert_eq!(
+ upgrade["streamSettings"]["httpupgradeSettings"],
+ json!({ "path": "/u", "host": "h.example.com" })
+ );
+ }
+
+ #[test]
+ fn hysteria2_speaks_h3_over_quic_with_salamander() {
+ let pin = "cd".repeat(32);
+ let outbound = outbound_for(&format!(
+ "hysteria2://letmein@hy.example.com:8443/?obfs=salamander&obfs-password=salt&pinSHA256={pin}"
+ ));
+ assert_eq!(outbound["protocol"], "hysteria");
+ assert_eq!(outbound["settings"]["version"], 2);
+ assert_eq!(outbound["settings"]["address"], "hy.example.com");
+ assert_eq!(outbound["settings"]["port"], 8443);
+ let stream = &outbound["streamSettings"];
+ assert_eq!(stream["network"], "hysteria");
+ assert_eq!(stream["hysteriaSettings"]["version"], 2);
+ assert_eq!(stream["hysteriaSettings"]["auth"], "letmein");
+ assert_eq!(stream["security"], "tls");
+ assert_eq!(stream["tlsSettings"]["alpn"], json!(["h3"]));
+ assert_eq!(stream["tlsSettings"]["serverName"], "hy.example.com");
+ assert_eq!(
+ stream["finalmask"]["udp"][0],
+ json!({ "type": "salamander", "settings": { "password": "salt" } })
+ );
+ assert_eq!(stream["tlsSettings"]["pinnedPeerCertSha256"], pin);
+ assert!(stream.get("sockopt").is_none());
+ }
+
#[test]
fn does_not_add_bypass_or_observability_surfaces() {
let value = build_client_config(&valid_config(), &runtime()).unwrap();
@@ -243,12 +493,22 @@ mod tests {
#[test]
fn invalid_model_is_rejected_before_generation() {
let mut config = valid_config();
- config.reality.public_key = "secret-invalid-key".to_string();
+ let secret = "secret-invalid-key";
+ if let XrayOutbound::Vless {
+ stream: StreamSettings {
+ security: Security::Reality(reality),
+ ..
+ },
+ ..
+ } = &mut config.outbound
+ {
+ reality.public_key = secret.to_string();
+ }
let error = build_client_config(&config, &runtime()).unwrap_err();
assert!(matches!(
error,
XrayError::InvalidField { field: "pbk", .. }
));
- assert!(!error.to_string().contains(&config.reality.public_key));
+ assert!(!error.to_string().contains(secret));
}
}
diff --git a/src-tauri/src/xray/error.rs b/src-tauri/src/xray/error.rs
index bad19d73..bcf9425c 100644
--- a/src-tauri/src/xray/error.rs
+++ b/src-tauri/src/xray/error.rs
@@ -4,9 +4,9 @@ pub type XrayResult = Result;
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum XrayError {
- #[error("invalid VLESS URI")]
+ #[error("invalid share link")]
InvalidUri,
- #[error("URI scheme must be vless")]
+ #[error("share link scheme must be vless, vmess, trojan, hysteria2 or hy2")]
UnsupportedScheme,
#[error("missing required field: {0}")]
MissingField(&'static str),
@@ -29,26 +29,27 @@ pub enum XrayError {
}
impl XrayError {
- /// A stable, translatable identifier for *why* a URI was rejected.
+ /// A stable, translatable identifier for *why* a share link was rejected.
///
- /// Donut supports one VLESS shape — REALITY + XTLS Vision over TCP — so most
- /// rejections are "your setup is a kind we do not support", not "you made a
- /// typo". The frontend turns these into a sentence naming the unsupported
- /// part; without them every rejection reads as a malformed URI and a user
- /// with a working WebSocket or plain-TLS server has no idea why it failed.
+ /// Most rejections are "your setup is a kind we do not support", not "you
+ /// made a typo". The frontend turns these into a sentence naming the
+ /// unsupported part; without them every rejection reads as a malformed link
+ /// and a user with a working mKCP or port-hopping server has no idea why it
+ /// failed.
pub fn reason_code(&self) -> &'static str {
match self {
Self::UnsupportedScheme => "scheme",
Self::UnsupportedValue { field, .. } | Self::InvalidField { field, .. } => match *field {
"security" => "security",
"flow" => "flow",
- "type" => "transport",
+ "type" | "mode" => "transport",
"encryption" => "encryption",
"headerType" => "headerType",
"fp" => "fingerprint",
+ "obfs" => "obfs",
// A malformed sni/public key is the same user-facing problem as a
// missing one, so it earns the same specific help rather than the
- // generic "invalid URI".
+ // generic "invalid link".
"sni" | "server_name" => "sni",
"pbk" | "public_key" => "publicKey",
_ => "malformed",
diff --git a/src-tauri/src/xray/mod.rs b/src-tauri/src/xray/mod.rs
index a7d5ab0c..a9a94834 100644
--- a/src-tauri/src/xray/mod.rs
+++ b/src-tauri/src/xray/mod.rs
@@ -6,6 +6,8 @@ mod uri;
pub use client::{build_client_config, build_client_config_json, XrayClientRuntime};
pub use error::{XrayError, XrayResult};
pub use model::{
- ParsedVlessUri, RealityFingerprint, RealitySettings, VlessFlow, VlessRealityConfig,
+ is_xray_proxy_type, Fingerprint, GrpcMode, HttpHeader, ParsedShareLink, RealitySettings,
+ Security, StreamSettings, TlsSettings, Transport, VlessFlow, VmessSecurity, XhttpMode,
+ XrayConfig, XrayOutbound, XrayProtocol,
};
-pub use uri::{export_vless_uri, parse_vless_uri};
+pub use uri::{export_share_link, parse_share_link, share_link_host};
diff --git a/src-tauri/src/xray/model.rs b/src-tauri/src/xray/model.rs
index af232bf1..31b72672 100644
--- a/src-tauri/src/xray/model.rs
+++ b/src-tauri/src/xray/model.rs
@@ -1,103 +1,451 @@
use std::net::IpAddr;
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
-use serde::{Deserialize, Serialize};
use url::Host;
use uuid::Uuid;
use super::{XrayError, XrayResult};
const MAX_SPIDER_X_BYTES: usize = 2048;
+const MAX_PATH_BYTES: usize = 2048;
+const MAX_SECRET_BYTES: usize = 1024;
+const MAX_ALPN_BYTES: usize = 255;
-#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
-pub enum VlessFlow {
+/// A protocol an Xray-core sidecar carries for Donut. Each one is at the same
+/// time the stored `proxy_type` and the scheme of its share link.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum XrayProtocol {
+ Vless,
+ Vmess,
+ Trojan,
+ Hysteria2,
+}
+
+impl XrayProtocol {
+ pub const fn proxy_type(self) -> &'static str {
+ match self {
+ Self::Vless => "vless",
+ Self::Vmess => "vmess",
+ Self::Trojan => "trojan",
+ Self::Hysteria2 => "hysteria2",
+ }
+ }
+
+ /// The protocol as the user would name it in a sentence.
+ pub const fn display_name(self) -> &'static str {
+ match self {
+ Self::Vless => "VLESS",
+ Self::Vmess => "VMess",
+ Self::Trojan => "Trojan",
+ Self::Hysteria2 => "Hysteria2",
+ }
+ }
+
+ /// Also reads a stored `proxy_type`, which the REST API keeps verbatim.
+ /// `hy2` is the short scheme Hysteria2 clients export.
+ pub fn from_scheme(scheme: &str) -> Option {
+ match scheme.trim().to_ascii_lowercase().as_str() {
+ "vless" => Some(Self::Vless),
+ "vmess" => Some(Self::Vmess),
+ "trojan" => Some(Self::Trojan),
+ "hysteria2" | "hy2" => Some(Self::Hysteria2),
+ _ => None,
+ }
+ }
+
+ /// The protocol a share link names, read from its scheme alone.
+ pub fn from_share_link(uri: &str) -> Option {
+ let (scheme, _) = uri.trim().split_once("://")?;
+ Self::from_scheme(scheme)
+ }
+}
+
+/// Whether a stored proxy is carried by an Xray-core sidecar rather than by a
+/// `donut-proxy` worker.
+pub fn is_xray_proxy_type(proxy_type: &str) -> bool {
+ XrayProtocol::from_scheme(proxy_type).is_some()
+}
+
+/// The uTLS presets Xray-core offers to GUI clients. `unsafe` is left out on
+/// purpose: it turns uTLS off and gives a Go TLS hello that is easy to spot.
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
+pub enum Fingerprint {
#[default]
- #[serde(rename = "xtls-rprx-vision")]
+ Chrome,
+ Firefox,
+ Safari,
+ Ios,
+ Android,
+ Edge,
+ Browser360,
+ Qq,
+ Random,
+ Randomized,
+ RandomizedNoAlpn,
+}
+
+impl Fingerprint {
+ pub const fn as_str(self) -> &'static str {
+ match self {
+ Self::Chrome => "chrome",
+ Self::Firefox => "firefox",
+ Self::Safari => "safari",
+ Self::Ios => "ios",
+ Self::Android => "android",
+ Self::Edge => "edge",
+ Self::Browser360 => "360",
+ Self::Qq => "qq",
+ Self::Random => "random",
+ Self::Randomized => "randomized",
+ Self::RandomizedNoAlpn => "randomizednoalpn",
+ }
+ }
+
+ pub(crate) fn parse(value: &str) -> XrayResult {
+ match value.to_ascii_lowercase().as_str() {
+ "chrome" => Ok(Self::Chrome),
+ "firefox" => Ok(Self::Firefox),
+ "safari" => Ok(Self::Safari),
+ "ios" => Ok(Self::Ios),
+ "android" => Ok(Self::Android),
+ "edge" => Ok(Self::Edge),
+ "360" => Ok(Self::Browser360),
+ "qq" => Ok(Self::Qq),
+ "random" => Ok(Self::Random),
+ "randomized" => Ok(Self::Randomized),
+ "randomizednoalpn" => Ok(Self::RandomizedNoAlpn),
+ _ => Err(XrayError::UnsupportedValue {
+ field: "fp",
+ expected: "chrome, firefox, safari, ios, android, edge, 360, qq, random, randomized or randomizednoalpn",
+ }),
+ }
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum VlessFlow {
Vision,
+ VisionUdp443,
}
impl VlessFlow {
pub const fn as_str(self) -> &'static str {
match self {
Self::Vision => "xtls-rprx-vision",
+ Self::VisionUdp443 => "xtls-rprx-vision-udp443",
}
}
}
-#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
-#[serde(rename_all = "lowercase")]
-pub enum RealityFingerprint {
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
+pub enum VmessSecurity {
#[default]
- Chrome,
- Firefox,
- Safari,
- Edge,
- Ios,
- Android,
+ Auto,
+ Aes128Gcm,
+ Chacha20Poly1305,
+ None,
+ Zero,
}
-impl RealityFingerprint {
+impl VmessSecurity {
pub const fn as_str(self) -> &'static str {
match self {
- Self::Chrome => "chrome",
- Self::Firefox => "firefox",
- Self::Safari => "safari",
- Self::Edge => "edge",
- Self::Ios => "ios",
- Self::Android => "android",
+ Self::Auto => "auto",
+ Self::Aes128Gcm => "aes-128-gcm",
+ Self::Chacha20Poly1305 => "chacha20-poly1305",
+ Self::None => "none",
+ Self::Zero => "zero",
}
}
pub(crate) fn parse(value: &str) -> XrayResult {
- match value {
- "chrome" => Ok(Self::Chrome),
- "firefox" => Ok(Self::Firefox),
- "safari" => Ok(Self::Safari),
- "edge" => Ok(Self::Edge),
- "ios" => Ok(Self::Ios),
- "android" => Ok(Self::Android),
+ match value.to_ascii_lowercase().as_str() {
+ "auto" => Ok(Self::Auto),
+ "aes-128-gcm" => Ok(Self::Aes128Gcm),
+ "chacha20-poly1305" => Ok(Self::Chacha20Poly1305),
+ "none" => Ok(Self::None),
+ "zero" => Ok(Self::Zero),
_ => Err(XrayError::UnsupportedValue {
- field: "fp",
- expected: "chrome, firefox, safari, edge, ios, or android",
+ field: "encryption",
+ expected: "auto, aes-128-gcm, chacha20-poly1305, none or zero",
}),
}
}
}
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-#[serde(deny_unknown_fields)]
+/// TLS towards the server. Certificate checks always stay on: Xray-core
+/// refuses `allowInsecure` since 2026-06-01, and a self-signed server is
+/// reached with a pinned certificate hash instead.
+#[derive(Debug, Clone, Default, PartialEq, Eq)]
+pub struct TlsSettings {
+ pub server_name: Option,
+ pub alpn: Vec,
+ pub fingerprint: Option,
+ /// Lowercase hex SHA-256 of a certificate in the server's chain.
+ pub pinned_peer_cert_sha256: Vec,
+ pub verify_peer_cert_by_name: Vec,
+}
+
+impl TlsSettings {
+ pub fn validate(&self) -> XrayResult<()> {
+ if let Some(server_name) = &self.server_name {
+ validate_tls_server_name(server_name)?;
+ }
+ for protocol in &self.alpn {
+ validate_alpn(protocol)?;
+ }
+ for pin in &self.pinned_peer_cert_sha256 {
+ validate_certificate_pin(pin)?;
+ }
+ for name in &self.verify_peer_cert_by_name {
+ validate_dns_name(name, "vcn")?;
+ }
+ Ok(())
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RealitySettings {
pub server_name: String,
pub public_key: String,
- #[serde(default)]
pub short_id: String,
- #[serde(default)]
- pub fingerprint: RealityFingerprint,
- #[serde(default = "default_spider_x")]
+ pub fingerprint: Fingerprint,
pub spider_x: String,
}
impl RealitySettings {
pub fn validate(&self) -> XrayResult<()> {
- validate_server_name(&self.server_name)?;
+ validate_dns_name(&self.server_name, "sni")?;
validate_public_key(&self.public_key)?;
validate_short_id(&self.short_id)?;
validate_spider_x(&self.spider_x)
}
}
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-#[serde(deny_unknown_fields)]
-pub struct VlessRealityConfig {
- pub address: String,
- pub port: u16,
- pub id: String,
- #[serde(default)]
- pub flow: VlessFlow,
- pub reality: RealitySettings,
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum Security {
+ None,
+ Tls(TlsSettings),
+ Reality(RealitySettings),
}
-impl VlessRealityConfig {
+impl Security {
+ pub const fn as_str(&self) -> &'static str {
+ match self {
+ Self::None => "none",
+ Self::Tls(_) => "tls",
+ Self::Reality(_) => "reality",
+ }
+ }
+}
+
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
+pub enum GrpcMode {
+ #[default]
+ Gun,
+ Multi,
+}
+
+impl GrpcMode {
+ pub const fn as_str(self) -> &'static str {
+ match self {
+ Self::Gun => "gun",
+ Self::Multi => "multi",
+ }
+ }
+}
+
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
+pub enum XhttpMode {
+ #[default]
+ Auto,
+ PacketUp,
+ StreamUp,
+ StreamOne,
+}
+
+impl XhttpMode {
+ pub const fn as_str(self) -> &'static str {
+ match self {
+ Self::Auto => "auto",
+ Self::PacketUp => "packet-up",
+ Self::StreamUp => "stream-up",
+ Self::StreamOne => "stream-one",
+ }
+ }
+}
+
+/// The HTTP/1.1 request disguise RAW can put in front of a connection
+/// (`headerType=http`).
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct HttpHeader {
+ pub hosts: Vec,
+ pub path: String,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum Transport {
+ Raw {
+ http_header: Option,
+ },
+ WebSocket {
+ host: Option,
+ path: String,
+ },
+ HttpUpgrade {
+ host: Option,
+ path: String,
+ },
+ Grpc {
+ service_name: String,
+ authority: Option,
+ mode: GrpcMode,
+ },
+ Xhttp {
+ host: Option,
+ path: String,
+ mode: XhttpMode,
+ },
+}
+
+impl Transport {
+ /// The share-link `type` value.
+ pub const fn as_str(&self) -> &'static str {
+ match self {
+ Self::Raw { .. } => "tcp",
+ Self::WebSocket { .. } => "ws",
+ Self::HttpUpgrade { .. } => "httpupgrade",
+ Self::Grpc { .. } => "grpc",
+ Self::Xhttp { .. } => "xhttp",
+ }
+ }
+
+ /// The Host header the transport sends, which CDN fronted links also expect
+ /// as the TLS server name when they do not give one.
+ pub fn host(&self) -> Option<&str> {
+ match self {
+ Self::WebSocket { host, .. } | Self::HttpUpgrade { host, .. } | Self::Xhttp { host, .. } => {
+ host.as_deref()
+ }
+ Self::Raw { .. } | Self::Grpc { .. } => None,
+ }
+ }
+
+ fn validate(&self) -> XrayResult<()> {
+ match self {
+ Self::Raw { http_header: None } => Ok(()),
+ Self::Raw {
+ http_header: Some(header),
+ } => {
+ for host in &header.hosts {
+ validate_host_header(host)?;
+ }
+ validate_path(&header.path)
+ }
+ Self::WebSocket { host, path }
+ | Self::HttpUpgrade { host, path }
+ | Self::Xhttp { host, path, .. } => {
+ if let Some(host) = host {
+ validate_host_header(host)?;
+ }
+ validate_path(path)
+ }
+ Self::Grpc {
+ service_name,
+ authority,
+ ..
+ } => {
+ if service_name.len() > MAX_PATH_BYTES
+ || service_name
+ .chars()
+ .any(|c| c.is_control() || c.is_whitespace())
+ {
+ return Err(XrayError::InvalidField {
+ field: "serviceName",
+ reason: "must not contain whitespace or exceed 2048 bytes",
+ });
+ }
+ if let Some(authority) = authority {
+ validate_host_header(authority)?;
+ }
+ Ok(())
+ }
+ }
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct StreamSettings {
+ pub transport: Transport,
+ pub security: Security,
+}
+
+impl StreamSettings {
+ pub fn validate(&self) -> XrayResult<()> {
+ self.transport.validate()?;
+ match &self.security {
+ Security::None => {}
+ Security::Tls(tls) => tls.validate()?,
+ Security::Reality(reality) => {
+ // REALITY borrows a real site's handshake, so it only rides the
+ // transports Xray-core lets it wrap.
+ if !matches!(
+ self.transport,
+ Transport::Raw { http_header: None } | Transport::Grpc { .. } | Transport::Xhttp { .. }
+ ) {
+ return Err(XrayError::UnsupportedValue {
+ field: "security",
+ expected: "REALITY only over RAW, gRPC or XHTTP",
+ });
+ }
+ reality.validate()?;
+ }
+ }
+ Ok(())
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum XrayOutbound {
+ Vless {
+ id: String,
+ flow: Option,
+ stream: StreamSettings,
+ },
+ Vmess {
+ id: String,
+ security: VmessSecurity,
+ stream: StreamSettings,
+ },
+ Trojan {
+ password: String,
+ stream: StreamSettings,
+ },
+ Hysteria2 {
+ auth: String,
+ tls: TlsSettings,
+ /// Salamander obfuscation password.
+ obfs_password: Option,
+ },
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct XrayConfig {
+ pub address: String,
+ pub port: u16,
+ pub outbound: XrayOutbound,
+}
+
+impl XrayConfig {
+ pub const fn protocol(&self) -> XrayProtocol {
+ match self.outbound {
+ XrayOutbound::Vless { .. } => XrayProtocol::Vless,
+ XrayOutbound::Vmess { .. } => XrayProtocol::Vmess,
+ XrayOutbound::Trojan { .. } => XrayProtocol::Trojan,
+ XrayOutbound::Hysteria2 { .. } => XrayProtocol::Hysteria2,
+ }
+ }
+
pub fn validate(&self) -> XrayResult<()> {
validate_endpoint_address(&self.address)?;
if self.port == 0 {
@@ -106,22 +454,61 @@ impl VlessRealityConfig {
reason: "must be between 1 and 65535",
});
}
- Uuid::parse_str(&self.id).map_err(|_| XrayError::InvalidField {
- field: "id",
- reason: "must be a UUID",
- })?;
- self.reality.validate()
+ match &self.outbound {
+ XrayOutbound::Vless { id, flow, stream } => {
+ validate_uuid(id)?;
+ stream.validate()?;
+ // Vision splices the inner TLS stream, which only works when the
+ // connection is a bare TLS or REALITY stream over RAW.
+ if flow.is_some()
+ && !(matches!(stream.transport, Transport::Raw { http_header: None })
+ && matches!(stream.security, Security::Tls(_) | Security::Reality(_)))
+ {
+ return Err(XrayError::UnsupportedValue {
+ field: "flow",
+ expected: "XTLS Vision only over RAW with TLS or REALITY",
+ });
+ }
+ Ok(())
+ }
+ XrayOutbound::Vmess { id, stream, .. } => {
+ validate_uuid(id)?;
+ stream.validate()
+ }
+ XrayOutbound::Trojan { password, stream } => {
+ validate_secret(password, "password")?;
+ stream.validate()
+ }
+ XrayOutbound::Hysteria2 {
+ auth,
+ tls,
+ obfs_password,
+ } => {
+ validate_secret(auth, "auth")?;
+ if let Some(password) = obfs_password {
+ validate_secret(password, "obfs-password")?;
+ // Xray-core rejects a shorter Salamander key only when it dials,
+ // which left a link that saved fine and never connected.
+ if password.len() < 4 {
+ return Err(XrayError::InvalidField {
+ field: "obfs-password",
+ reason: "must contain at least 4 bytes",
+ });
+ }
+ }
+ tls.validate()
+ }
+ }
}
}
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-#[serde(deny_unknown_fields)]
-pub struct ParsedVlessUri {
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct ParsedShareLink {
pub name: Option,
- pub config: VlessRealityConfig,
+ pub config: XrayConfig,
}
-impl ParsedVlessUri {
+impl ParsedShareLink {
pub fn validate(&self) -> XrayResult<()> {
if let Some(name) = &self.name {
validate_display_name(name)?;
@@ -130,10 +517,6 @@ impl ParsedVlessUri {
}
}
-pub(crate) fn default_spider_x() -> String {
- "/".to_string()
-}
-
pub(crate) fn validate_display_name(name: &str) -> XrayResult<()> {
if name.is_empty() {
return Err(XrayError::InvalidField {
@@ -156,6 +539,25 @@ pub(crate) fn validate_display_name(name: &str) -> XrayResult<()> {
Ok(())
}
+fn validate_uuid(id: &str) -> XrayResult<()> {
+ Uuid::parse_str(id)
+ .map(|_| ())
+ .map_err(|_| XrayError::InvalidField {
+ field: "id",
+ reason: "must be a UUID",
+ })
+}
+
+fn validate_secret(value: &str, field: &'static str) -> XrayResult<()> {
+ if value.is_empty() || value.len() > MAX_SECRET_BYTES || value.chars().any(char::is_control) {
+ return Err(XrayError::InvalidField {
+ field,
+ reason: "must contain 1 to 1024 bytes and no control characters",
+ });
+ }
+ Ok(())
+}
+
fn validate_endpoint_address(address: &str) -> XrayResult<()> {
if address.is_empty()
|| address.trim() != address
@@ -183,22 +585,80 @@ fn validate_endpoint_address(address: &str) -> XrayResult<()> {
}
}
-fn validate_server_name(server_name: &str) -> XrayResult<()> {
- if server_name.is_empty() || server_name.trim() != server_name {
+fn validate_dns_name(name: &str, field: &'static str) -> XrayResult<()> {
+ if name.is_empty() || name.trim() != name {
return Err(XrayError::InvalidField {
- field: "sni",
+ field,
reason: "must be a valid DNS name",
});
}
- match Host::parse(server_name) {
- Ok(Host::Domain(_)) => Ok(()),
+ match Host::parse(name) {
+ Ok(Host::Domain(domain)) if domain.eq_ignore_ascii_case(name) => Ok(()),
_ => Err(XrayError::InvalidField {
- field: "sni",
+ field,
reason: "must be a valid DNS name",
}),
}
}
+/// A TLS server name may also be an IP address: Go then checks the
+/// certificate's IP entries and sends no SNI.
+fn validate_tls_server_name(server_name: &str) -> XrayResult<()> {
+ if server_name.parse::().is_ok() {
+ return Ok(());
+ }
+ validate_dns_name(server_name, "sni")
+}
+
+fn validate_host_header(host: &str) -> XrayResult<()> {
+ if host.parse::().is_ok() {
+ return Ok(());
+ }
+ validate_dns_name(host, "host")
+}
+
+fn validate_path(path: &str) -> XrayResult<()> {
+ if !path.starts_with('/')
+ || path.len() > MAX_PATH_BYTES
+ || path.chars().any(|c| c.is_control() || c.is_whitespace())
+ {
+ return Err(XrayError::InvalidField {
+ field: "path",
+ reason: "must start with / and contain no whitespace, at most 2048 bytes",
+ });
+ }
+ Ok(())
+}
+
+fn validate_alpn(protocol: &str) -> XrayResult<()> {
+ if protocol.is_empty()
+ || protocol.len() > MAX_ALPN_BYTES
+ || !protocol
+ .bytes()
+ .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'/' | b'-' | b'_'))
+ {
+ return Err(XrayError::InvalidField {
+ field: "alpn",
+ reason: "must be a comma-separated list of protocol names such as h2,http/1.1",
+ });
+ }
+ Ok(())
+}
+
+fn validate_certificate_pin(pin: &str) -> XrayResult<()> {
+ if pin.len() != 64
+ || !pin
+ .bytes()
+ .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
+ {
+ return Err(XrayError::InvalidField {
+ field: "pcs",
+ reason: "must be SHA-256 hashes in hexadecimal",
+ });
+ }
+ Ok(())
+}
+
fn validate_public_key(public_key: &str) -> XrayResult<()> {
let decoded = URL_SAFE_NO_PAD
.decode(public_key)
@@ -255,27 +715,88 @@ mod tests {
URL_SAFE_NO_PAD.encode([7_u8; 32])
}
- fn valid_config() -> VlessRealityConfig {
- VlessRealityConfig {
+ fn reality() -> RealitySettings {
+ RealitySettings {
+ server_name: "www.example.com".to_string(),
+ public_key: public_key(),
+ short_id: "0123456789abcdef".to_string(),
+ fingerprint: Fingerprint::Chrome,
+ spider_x: "/".to_string(),
+ }
+ }
+
+ fn vless(flow: Option, transport: Transport, security: Security) -> XrayConfig {
+ XrayConfig {
address: "vpn.example.com".to_string(),
port: 443,
- id: "6d6e21a1-4829-4d2b-bc7f-1b25707b61e4".to_string(),
- flow: VlessFlow::Vision,
- reality: RealitySettings {
- server_name: "www.example.com".to_string(),
- public_key: public_key(),
- short_id: "0123456789abcdef".to_string(),
- fingerprint: RealityFingerprint::Chrome,
- spider_x: "/".to_string(),
+ outbound: XrayOutbound::Vless {
+ id: "6d6e21a1-4829-4d2b-bc7f-1b25707b61e4".to_string(),
+ flow,
+ stream: StreamSettings {
+ transport,
+ security,
+ },
},
}
}
+ fn valid_config() -> XrayConfig {
+ vless(
+ Some(VlessFlow::Vision),
+ Transport::Raw { http_header: None },
+ Security::Reality(reality()),
+ )
+ }
+
+ fn ws(path: &str) -> Transport {
+ Transport::WebSocket {
+ host: Some("cdn.example.com".to_string()),
+ path: path.to_string(),
+ }
+ }
+
+ fn with_reality(mutate: impl FnOnce(&mut RealitySettings)) -> XrayConfig {
+ let mut settings = reality();
+ mutate(&mut settings);
+ vless(
+ Some(VlessFlow::Vision),
+ Transport::Raw { http_header: None },
+ Security::Reality(settings),
+ )
+ }
+
#[test]
fn valid_model_passes_validation() {
assert_eq!(valid_config().validate(), Ok(()));
}
+ #[test]
+ fn protocol_names_round_trip_through_schemes() {
+ for protocol in [
+ XrayProtocol::Vless,
+ XrayProtocol::Vmess,
+ XrayProtocol::Trojan,
+ XrayProtocol::Hysteria2,
+ ] {
+ assert_eq!(
+ XrayProtocol::from_scheme(protocol.proxy_type()),
+ Some(protocol)
+ );
+ }
+ assert_eq!(
+ XrayProtocol::from_scheme(" HY2 "),
+ Some(XrayProtocol::Hysteria2)
+ );
+ for other in ["ss", "socks5", "http", "", "vless2"] {
+ assert_eq!(XrayProtocol::from_scheme(other), None, "{other}");
+ }
+ assert_eq!(
+ XrayProtocol::from_share_link("trojan://secret@a.com:443"),
+ Some(XrayProtocol::Trojan)
+ );
+ assert_eq!(XrayProtocol::from_share_link("not a link"), None);
+ }
+
#[test]
fn endpoint_accepts_ipv4_ipv6_and_dns() {
for address in ["198.51.100.4", "2001:db8::1", "vpn.example.com"] {
@@ -338,7 +859,9 @@ mod tests {
#[test]
fn id_must_be_a_uuid() {
let mut config = valid_config();
- config.id = "not-a-uuid".to_string();
+ if let XrayOutbound::Vless { id, .. } = &mut config.outbound {
+ *id = "not-a-uuid".to_string();
+ }
assert_eq!(
config.validate(),
Err(XrayError::InvalidField {
@@ -349,10 +872,9 @@ mod tests {
}
#[test]
- fn server_name_must_be_dns_name() {
+ fn reality_server_name_must_be_dns_name() {
for server_name in ["", "203.0.113.5", "bad server"] {
- let mut config = valid_config();
- config.reality.server_name = server_name.to_string();
+ let config = with_reality(|reality| reality.server_name = server_name.to_string());
assert!(matches!(
config.validate(),
Err(XrayError::InvalidField { field: "sni", .. })
@@ -360,6 +882,21 @@ mod tests {
}
}
+ #[test]
+ fn tls_server_name_may_be_an_ip_address() {
+ for server_name in ["203.0.113.5", "2001:db8::1", "cdn.example.com"] {
+ let config = vless(
+ None,
+ ws("/ray"),
+ Security::Tls(TlsSettings {
+ server_name: Some(server_name.to_string()),
+ ..TlsSettings::default()
+ }),
+ );
+ assert_eq!(config.validate(), Ok(()), "{server_name}");
+ }
+ }
+
#[test]
fn public_key_must_be_canonical_base64url_and_32_bytes() {
let short_key = URL_SAFE_NO_PAD.encode([1_u8; 31]);
@@ -368,8 +905,7 @@ mod tests {
short_key.as_str(),
"BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc=",
] {
- let mut config = valid_config();
- config.reality.public_key = public_key.to_string();
+ let config = with_reality(|reality| reality.public_key = public_key.to_string());
let error = config.validate().unwrap_err();
assert!(matches!(
error,
@@ -382,8 +918,7 @@ mod tests {
#[test]
fn short_id_accepts_empty_or_even_hex_up_to_sixteen_chars() {
for short_id in ["", "ab", "0123456789abcdef", "ABCDEF"] {
- let mut config = valid_config();
- config.reality.short_id = short_id.to_string();
+ let config = with_reality(|reality| reality.short_id = short_id.to_string());
assert_eq!(config.validate(), Ok(()), "{short_id}");
}
}
@@ -391,8 +926,7 @@ mod tests {
#[test]
fn short_id_rejects_odd_non_hex_and_overlong_values() {
for short_id in ["a", "xz", "0123456789abcdef00"] {
- let mut config = valid_config();
- config.reality.short_id = short_id.to_string();
+ let config = with_reality(|reality| reality.short_id = short_id.to_string());
assert!(matches!(
config.validate(),
Err(XrayError::InvalidField { field: "sid", .. })
@@ -403,8 +937,7 @@ mod tests {
#[test]
fn spider_x_must_be_safe_relative_path() {
for spider_x in ["relative", "/line\nbreak"] {
- let mut config = valid_config();
- config.reality.spider_x = spider_x.to_string();
+ let config = with_reality(|reality| reality.spider_x = spider_x.to_string());
assert!(matches!(
config.validate(),
Err(XrayError::InvalidField { field: "spx", .. })
@@ -413,35 +946,177 @@ mod tests {
}
#[test]
- fn serde_defaults_preserve_the_supported_profile() {
- let value = serde_json::json!({
- "address": "vpn.example.com",
- "port": 443,
- "id": "6d6e21a1-4829-4d2b-bc7f-1b25707b61e4",
- "reality": {
- "server_name": "www.example.com",
- "public_key": public_key()
- }
- });
- let config: VlessRealityConfig = serde_json::from_value(value).unwrap();
- assert_eq!(config.flow, VlessFlow::Vision);
- assert_eq!(config.reality.fingerprint, RealityFingerprint::Chrome);
- assert_eq!(config.reality.short_id, "");
- assert_eq!(config.reality.spider_x, "/");
+ fn vision_needs_bare_raw_with_tls_or_reality() {
+ let tls = || Security::Tls(TlsSettings::default());
+ assert_eq!(
+ vless(
+ Some(VlessFlow::Vision),
+ Transport::Raw { http_header: None },
+ tls()
+ )
+ .validate(),
+ Ok(())
+ );
+ for config in [
+ vless(Some(VlessFlow::Vision), ws("/"), tls()),
+ vless(
+ Some(VlessFlow::VisionUdp443),
+ Transport::Raw { http_header: None },
+ Security::None,
+ ),
+ vless(
+ Some(VlessFlow::Vision),
+ Transport::Raw {
+ http_header: Some(HttpHeader {
+ hosts: vec![],
+ path: "/".to_string(),
+ }),
+ },
+ tls(),
+ ),
+ ] {
+ assert!(
+ matches!(
+ config.validate(),
+ Err(XrayError::UnsupportedValue { field: "flow", .. })
+ ),
+ "{config:?}"
+ );
+ }
}
#[test]
- fn serde_rejects_unknown_configuration_fields() {
- let value = serde_json::json!({
- "address": "vpn.example.com",
- "port": 443,
- "id": "6d6e21a1-4829-4d2b-bc7f-1b25707b61e4",
- "transport": "websocket",
- "reality": {
- "server_name": "www.example.com",
- "public_key": public_key()
- }
- });
- assert!(serde_json::from_value::(value).is_err());
+ fn reality_rides_only_raw_grpc_or_xhttp() {
+ assert!(matches!(
+ vless(None, ws("/"), Security::Reality(reality())).validate(),
+ Err(XrayError::UnsupportedValue {
+ field: "security",
+ ..
+ })
+ ));
+ for transport in [
+ Transport::Grpc {
+ service_name: "gun".to_string(),
+ authority: None,
+ mode: GrpcMode::Gun,
+ },
+ Transport::Xhttp {
+ host: None,
+ path: "/".to_string(),
+ mode: XhttpMode::Auto,
+ },
+ ] {
+ assert_eq!(
+ vless(None, transport, Security::Reality(reality())).validate(),
+ Ok(())
+ );
+ }
+ }
+
+ #[test]
+ fn transport_paths_hosts_and_service_names_are_checked() {
+ for (transport, field) in [
+ (ws("relative"), "path"),
+ (ws("/with space"), "path"),
+ (
+ Transport::HttpUpgrade {
+ host: Some("bad host".to_string()),
+ path: "/".to_string(),
+ },
+ "host",
+ ),
+ (
+ Transport::Grpc {
+ service_name: "gun\nname".to_string(),
+ authority: None,
+ mode: GrpcMode::Multi,
+ },
+ "serviceName",
+ ),
+ ] {
+ let error = vless(None, transport, Security::None)
+ .validate()
+ .unwrap_err();
+ assert!(
+ matches!(error, XrayError::InvalidField { field: f, .. } if f == field),
+ "{error:?}"
+ );
+ }
+ }
+
+ #[test]
+ fn tls_options_are_checked() {
+ for (tls, field) in [
+ (
+ TlsSettings {
+ alpn: vec!["h2 ".to_string()],
+ ..TlsSettings::default()
+ },
+ "alpn",
+ ),
+ (
+ TlsSettings {
+ pinned_peer_cert_sha256: vec!["abcd".to_string()],
+ ..TlsSettings::default()
+ },
+ "pcs",
+ ),
+ (
+ TlsSettings {
+ verify_peer_cert_by_name: vec!["203.0.113.5".to_string()],
+ ..TlsSettings::default()
+ },
+ "vcn",
+ ),
+ ] {
+ let error = vless(None, ws("/"), Security::Tls(tls))
+ .validate()
+ .unwrap_err();
+ assert!(
+ matches!(error, XrayError::InvalidField { field: f, .. } if f == field),
+ "{error:?}"
+ );
+ }
+ }
+
+ #[test]
+ fn secrets_must_be_present_and_printable() {
+ let stream = || StreamSettings {
+ transport: Transport::Raw { http_header: None },
+ security: Security::Tls(TlsSettings::default()),
+ };
+ for password in ["", "line\nbreak"] {
+ let config = XrayConfig {
+ address: "a.example.com".to_string(),
+ port: 443,
+ outbound: XrayOutbound::Trojan {
+ password: password.to_string(),
+ stream: stream(),
+ },
+ };
+ assert!(matches!(
+ config.validate(),
+ Err(XrayError::InvalidField {
+ field: "password",
+ ..
+ })
+ ));
+ }
+ let config = XrayConfig {
+ address: "a.example.com".to_string(),
+ port: 443,
+ outbound: XrayOutbound::Hysteria2 {
+ auth: "secret".to_string(),
+ tls: TlsSettings::default(),
+ obfs_password: Some(String::new()),
+ },
+ };
+ assert!(matches!(
+ config.validate(),
+ Err(XrayError::InvalidField {
+ field: "obfs-password",
+ ..
+ })
+ ));
}
}
diff --git a/src-tauri/src/xray/uri.rs b/src-tauri/src/xray/uri.rs
index edbed486..db279afc 100644
--- a/src-tauri/src/xray/uri.rs
+++ b/src-tauri/src/xray/uri.rs
@@ -1,154 +1,776 @@
use std::collections::HashMap;
-
use std::net::IpAddr;
+use base64::{
+ engine::general_purpose::{STANDARD, STANDARD_NO_PAD, URL_SAFE, URL_SAFE_NO_PAD},
+ Engine as _,
+};
+use serde_json::{Map, Value};
use url::{Host, Url};
use uuid::Uuid;
use super::{
- model::validate_display_name, ParsedVlessUri, RealityFingerprint, RealitySettings, VlessFlow,
- VlessRealityConfig, XrayError, XrayResult,
+ model::validate_display_name, Fingerprint, GrpcMode, HttpHeader, ParsedShareLink,
+ RealitySettings, Security, StreamSettings, TlsSettings, Transport, VlessFlow, VmessSecurity,
+ XhttpMode, XrayConfig, XrayError, XrayOutbound, XrayProtocol, XrayResult,
};
-const SUPPORTED_PARAMETERS: &[&str] = &[
- "encryption",
- "flow",
+/// Options that change nothing on the wire and are dropped on import.
+///
+/// `allowInsecure`/`insecure` asks to skip the certificate check. Xray-core
+/// refuses that since 2026-06-01, and many panels add it to every link by
+/// habit, so the check simply stays on: a server with a valid certificate
+/// still works, and a self-signed one needs a pinned hash (`pcs`).
+/// `upmbps`/`downmbps` only pick Hysteria's congestion control; without them
+/// the client uses BBR, which every server accepts.
+const IGNORED_PARAMETERS: &[&str] = &["allowInsecure", "insecure", "upmbps", "downmbps"];
+
+const STREAM_PARAMETERS: &[&str] = &[
"security",
"sni",
+ "alpn",
"fp",
+ "pcs",
+ "vcn",
"pbk",
"sid",
"spx",
"type",
"headerType",
+ "host",
+ "path",
+ "serviceName",
+ "authority",
+ "mode",
];
-pub fn parse_vless_uri(input: &str) -> XrayResult {
+const HYSTERIA2_PARAMETERS: &[&str] = &["sni", "pinSHA256", "obfs", "obfs-password"];
+
+/// Parse a `vless://`, `vmess://`, `trojan://`, `hysteria2://` or `hy2://`
+/// share link into the configuration an Xray-core sidecar dials.
+pub fn parse_share_link(input: &str) -> XrayResult {
if input.trim() != input {
return Err(XrayError::InvalidUri);
}
- let url = Url::parse(input).map_err(|_| XrayError::InvalidUri)?;
- if url.scheme() != "vless" {
- return Err(XrayError::UnsupportedScheme);
- }
- if url.password().is_some() {
- return Err(XrayError::InvalidField {
- field: "id",
- reason: "password-style user information is not supported",
- });
- }
- if !matches!(url.path(), "" | "/") {
- return Err(XrayError::InvalidField {
- field: "path",
- reason: "VLESS TCP URIs must not contain a path",
- });
- }
-
- let raw_id = url.username();
- if raw_id.is_empty() {
- return Err(XrayError::MissingField("id"));
- }
- let id = Uuid::parse_str(raw_id)
- .map_err(|_| XrayError::InvalidField {
- field: "id",
- reason: "must be a UUID",
- })?
- .to_string();
- let address = match url.host().ok_or(XrayError::MissingField("address"))? {
- // `vless` is not a special scheme, so `Url` keeps the host exactly as
- // written, percent-escapes included, and that string is what the sidecar
- // dials. Re-parsing canonicalizes an internationalized host into the
- // punycode form that actually resolves.
- Host::Domain(value) => Host::parse(value)
- .map_err(|_| XrayError::InvalidField {
- field: "address",
- reason: "must be a valid hostname or IP address",
- })?
- .to_string(),
- Host::Ipv4(value) => value.to_string(),
- Host::Ipv6(value) => value.to_string(),
- };
- let port = url.port().ok_or(XrayError::MissingField("port"))?;
-
- let (parameters, unsupported) = parse_parameters(&url)?;
-
- // Transport first: it is the most common reason a real-world VLESS server is
- // unusable here, and it explains the stray parameters that come with it.
- match parameters.get("type").map(String::as_str) {
- None | Some("tcp" | "raw") => {}
- Some(_) => {
- return Err(XrayError::UnsupportedValue {
- field: "type",
- expected: "tcp",
- });
+ let (scheme, _) = input.split_once("://").ok_or(XrayError::InvalidUri)?;
+ let protocol = XrayProtocol::from_scheme(scheme).ok_or(XrayError::UnsupportedScheme)?;
+ let parsed = match protocol {
+ XrayProtocol::Vmess if !is_url_form(input) => parse_vmess_json(input)?,
+ XrayProtocol::Hysteria2 => parse_hysteria2(input)?,
+ XrayProtocol::Vless | XrayProtocol::Vmess | XrayProtocol::Trojan => {
+ parse_url_link(input, protocol)?
}
- }
- require_value(¶meters, "security", "reality")?;
- require_value(¶meters, "flow", VlessFlow::Vision.as_str())?;
- optional_value(¶meters, "encryption", "none")?;
- optional_value(¶meters, "headerType", "none")?;
-
- // Only once the shape is known-good does an unrecognized parameter become
- // the most useful thing to report.
- if let Some(name) = unsupported.into_iter().next() {
- return Err(XrayError::UnsupportedParameter(name));
- }
-
- let server_name = required_parameter(¶meters, "sni")?.to_string();
- let public_key = required_parameter(¶meters, "pbk")?.to_string();
- let short_id = parameters.get("sid").cloned().unwrap_or_default();
- let spider_x = parameters
- .get("spx")
- .cloned()
- .unwrap_or_else(|| "/".to_string());
- let fingerprint = parameters
- .get("fp")
- .map(|value| RealityFingerprint::parse(value))
- .transpose()?
- .unwrap_or_default();
-
- let name = url
- .fragment()
- .filter(|fragment| !fragment.is_empty())
- .map(|fragment| {
- urlencoding::decode(fragment)
- .map(|value| value.into_owned())
- .map_err(|_| XrayError::InvalidField {
- field: "name",
- reason: "must use valid percent encoding",
- })
- })
- .transpose()?;
-
- let parsed = ParsedVlessUri {
- name,
- config: VlessRealityConfig {
- address,
- port,
- id,
- flow: VlessFlow::Vision,
- reality: RealitySettings {
- server_name,
- public_key,
- short_id,
- fingerprint,
- spider_x,
- },
- },
};
parsed.validate()?;
Ok(parsed)
}
-pub fn export_vless_uri(config: &VlessRealityConfig, name: Option<&str>) -> XrayResult {
+/// `vmess://` is either v2rayN's base64 JSON or the URL form the other
+/// schemes use; only the URL form has a user part.
+fn is_url_form(input: &str) -> bool {
+ input
+ .split_once("://")
+ .and_then(|(_, rest)| rest.split('#').next())
+ .is_some_and(|body| body.contains('@'))
+}
+
+/// The host a share link dials, read without validating the rest of it.
+///
+/// For callers that must name the server even when Donut cannot use the link,
+/// such as the check that refuses an Xray exit for a remote host.
+pub fn share_link_host(uri: &str) -> Option {
+ if let Ok(parsed) = parse_share_link(uri.trim()) {
+ return Some(parsed.config.address);
+ }
+ let (scheme, rest) = uri.trim().split_once("://")?;
+ if XrayProtocol::from_scheme(scheme) == Some(XrayProtocol::Vmess) && !is_url_form(uri.trim()) {
+ let json = decode_vmess_body(rest.split('#').next()?).ok()?;
+ let address = json_string(&json, "add").ok()??;
+ return Some(address.trim_matches(['[', ']']).to_string()).filter(|host| !host.is_empty());
+ }
+ // Cut the fragment (`#label`) and query (`?type=...`) before looking for the
+ // authority — either may contain '@' or ':'.
+ let rest = rest.split('#').next()?;
+ let rest = rest.split('?').next()?;
+ let authority = rest.split('/').next()?;
+ let host_port = authority
+ .rsplit_once('@')
+ .map(|(_, host)| host)
+ .unwrap_or(authority);
+ let host = if let Some(bracketed) = host_port.strip_prefix('[') {
+ bracketed.split(']').next().unwrap_or_default()
+ } else {
+ host_port
+ .rsplit_once(':')
+ .map_or(host_port, |(host, _)| host)
+ };
+ let host = host.trim().trim_end_matches('.').to_ascii_lowercase();
+ Some(host).filter(|host| !host.is_empty())
+}
+
+/// Write the canonical share link for a configuration. Parsing the result
+/// gives the same configuration back.
+pub fn export_share_link(config: &XrayConfig, name: Option<&str>) -> XrayResult {
config.validate()?;
if let Some(name) = name {
validate_display_name(name)?;
}
+ match &config.outbound {
+ XrayOutbound::Vmess {
+ id,
+ security,
+ stream,
+ } => export_vmess_json(config, id, *security, stream, name),
+ XrayOutbound::Vless { id, flow, stream } => {
+ let mut url = base_url(config, "vless", id)?;
+ {
+ let mut query = url.query_pairs_mut();
+ query.append_pair("encryption", "none");
+ if let Some(flow) = flow {
+ query.append_pair("flow", flow.as_str());
+ }
+ append_stream(&mut query, stream);
+ }
+ Ok(finish_url(url, name))
+ }
+ XrayOutbound::Trojan { password, stream } => {
+ let mut url = base_url(config, "trojan", password)?;
+ append_stream(&mut url.query_pairs_mut(), stream);
+ Ok(finish_url(url, name))
+ }
+ XrayOutbound::Hysteria2 {
+ auth,
+ tls,
+ obfs_password,
+ } => {
+ let mut url = base_url(config, "hysteria2", auth)?;
+ url.set_path("/");
+ {
+ let mut query = url.query_pairs_mut();
+ if let Some(server_name) = &tls.server_name {
+ query.append_pair("sni", server_name);
+ }
+ if let Some(password) = obfs_password {
+ query.append_pair("obfs", "salamander");
+ query.append_pair("obfs-password", password);
+ }
+ if !tls.pinned_peer_cert_sha256.is_empty() {
+ query.append_pair("pinSHA256", &tls.pinned_peer_cert_sha256.join(","));
+ }
+ }
+ Ok(finish_url(url, name))
+ }
+ }
+}
- let mut url = Url::parse("vless://placeholder@127.0.0.1").expect("static VLESS URL is valid");
+/// The recognized query of a share link.
+///
+/// Unrecognized names are kept rather than rejected on the spot so the caller
+/// can report the *shape* problem first. An mKCP link always carries `seed`,
+/// and naming that key instead of the transport sends the user deleting
+/// parameters when the real answer is that Donut does not speak mKCP.
+struct Query {
+ values: HashMap,
+ unsupported: Vec,
+}
+
+impl Query {
+ fn new() -> Self {
+ Self {
+ values: HashMap::new(),
+ unsupported: Vec::new(),
+ }
+ }
+
+ fn from_url(url: &Url, supported: &[&[&str]]) -> XrayResult {
+ let mut query = Self::new();
+ for (name, value) in url.query_pairs() {
+ query.insert(&name, value.into_owned(), supported)?;
+ }
+ Ok(query)
+ }
+
+ fn insert(&mut self, name: &str, value: String, supported: &[&[&str]]) -> XrayResult<()> {
+ if self.values.contains_key(name) {
+ return Err(XrayError::DuplicateParameter(name.to_string()));
+ }
+ if IGNORED_PARAMETERS.contains(&name) {
+ self.values.insert(name.to_string(), String::new());
+ } else if supported.iter().any(|names| names.contains(&name)) {
+ self.values.insert(name.to_string(), value);
+ } else if !value.is_empty() {
+ // An empty option asks for nothing, so it is not worth refusing a link
+ // over.
+ self.unsupported.push(name.to_string());
+ }
+ Ok(())
+ }
+
+ /// An empty value means the option is absent.
+ fn get(&self, name: &str) -> Option<&str> {
+ self
+ .values
+ .get(name)
+ .map(String::as_str)
+ .filter(|value| !value.is_empty())
+ }
+
+ fn required(&self, name: &'static str) -> XrayResult<&str> {
+ self.get(name).ok_or(XrayError::MissingField(name))
+ }
+
+ fn reject_unsupported(&self) -> XrayResult<()> {
+ match self.unsupported.first() {
+ Some(name) => Err(XrayError::UnsupportedParameter(name.clone())),
+ None => Ok(()),
+ }
+ }
+}
+
+fn parse_url_link(input: &str, protocol: XrayProtocol) -> XrayResult {
+ let url = Url::parse(input).map_err(|_| XrayError::InvalidUri)?;
+ reject_path(&url)?;
+ let (user, has_password) = userinfo(&url)?;
+ let address = canonical_host(&url)?;
+ let port = url.port().ok_or(XrayError::MissingField("port"))?;
+ let protocol_parameters: &[&str] = match protocol {
+ XrayProtocol::Vless => &["encryption", "flow"],
+ XrayProtocol::Vmess => &["encryption"],
+ XrayProtocol::Trojan => &["flow", "peer"],
+ XrayProtocol::Hysteria2 => &[],
+ };
+ let query = Query::from_url(&url, &[STREAM_PARAMETERS, protocol_parameters])?;
+ let default_security = if protocol == XrayProtocol::Trojan {
+ "tls"
+ } else {
+ "none"
+ };
+ let shape = parse_stream_shape(&query, default_security)?;
+
+ let outbound = match protocol {
+ XrayProtocol::Vless => {
+ let id = parse_uuid(&user, has_password)?;
+ let flow = match query.get("flow") {
+ None | Some("none") => None,
+ Some("xtls-rprx-vision") => Some(VlessFlow::Vision),
+ Some("xtls-rprx-vision-udp443") => Some(VlessFlow::VisionUdp443),
+ Some(_) => {
+ return Err(XrayError::UnsupportedValue {
+ field: "flow",
+ expected: "xtls-rprx-vision",
+ })
+ }
+ };
+ if query.get("encryption").is_some_and(|value| value != "none") {
+ return Err(XrayError::UnsupportedValue {
+ field: "encryption",
+ expected: "none",
+ });
+ }
+ query.reject_unsupported()?;
+ XrayOutbound::Vless {
+ id,
+ flow,
+ stream: shape.finish(&query)?,
+ }
+ }
+ XrayProtocol::Vmess => {
+ let id = parse_uuid(&user, has_password)?;
+ let security = query
+ .get("encryption")
+ .map(VmessSecurity::parse)
+ .transpose()?
+ .unwrap_or_default();
+ query.reject_unsupported()?;
+ XrayOutbound::Vmess {
+ id,
+ security,
+ stream: shape.finish(&query)?,
+ }
+ }
+ XrayProtocol::Trojan => {
+ if query.get("flow").is_some() {
+ return Err(XrayError::UnsupportedValue {
+ field: "flow",
+ expected: "no flow for Trojan",
+ });
+ }
+ query.reject_unsupported()?;
+ XrayOutbound::Trojan {
+ password: user,
+ stream: shape.finish(&query)?,
+ }
+ }
+ XrayProtocol::Hysteria2 => unreachable!("Hysteria2 links have their own parser"),
+ };
+
+ Ok(ParsedShareLink {
+ name: fragment_name(&url)?,
+ config: XrayConfig {
+ address,
+ port,
+ outbound,
+ },
+ })
+}
+
+fn parse_hysteria2(input: &str) -> XrayResult {
+ let url = Url::parse(input).map_err(|_| XrayError::InvalidUri)?;
+ reject_path(&url)?;
+ let (auth, _) = userinfo(&url)?;
+ let address = canonical_host(&url)?;
+ let port = url.port().unwrap_or(443);
+ let query = Query::from_url(&url, &[HYSTERIA2_PARAMETERS])?;
+
+ let obfs_password = match query.get("obfs") {
+ None => None,
+ Some(obfs) if obfs.eq_ignore_ascii_case("salamander") => {
+ Some(query.required("obfs-password")?.to_string())
+ }
+ Some(_) => {
+ return Err(XrayError::UnsupportedValue {
+ field: "obfs",
+ expected: "salamander",
+ })
+ }
+ };
+ query.reject_unsupported()?;
+
+ if auth.is_empty() {
+ return Err(XrayError::MissingField("auth"));
+ }
+ // The QUIC handshake has no destination to fall back on, so the server name
+ // is always explicit, exactly as the Hysteria client itself defaults it.
+ let server_name = canonical_name(query.get("sni").unwrap_or(&address));
+ let tls = TlsSettings {
+ server_name: Some(server_name),
+ pinned_peer_cert_sha256: query
+ .get("pinSHA256")
+ .map(parse_certificate_pins)
+ .unwrap_or_default(),
+ ..TlsSettings::default()
+ };
+
+ Ok(ParsedShareLink {
+ name: fragment_name(&url)?,
+ config: XrayConfig {
+ address,
+ port,
+ outbound: XrayOutbound::Hysteria2 {
+ auth,
+ tls,
+ obfs_password,
+ },
+ },
+ })
+}
+
+/// The v2rayN `vmess://` form, which is what VMess providers
+/// actually hand out. Its keys are mapped onto the query names the other links
+/// use so one stream parser reads both.
+fn parse_vmess_json(input: &str) -> XrayResult {
+ let (_, body) = input.split_once("://").ok_or(XrayError::InvalidUri)?;
+ let (encoded, fragment) = match body.split_once('#') {
+ Some((encoded, fragment)) => (encoded, Some(fragment)),
+ None => (body, None),
+ };
+ let json = decode_vmess_body(encoded)?;
+
+ let net = json_string(&json, "net")?
+ .unwrap_or_default()
+ .to_ascii_lowercase();
+ let mut query = Query::new();
+ let mut address = None;
+ let mut port = None;
+ let mut id = None;
+ let mut name = None;
+ for (key, value) in &json {
+ let value = json_value_string(value).ok_or(XrayError::InvalidField {
+ field: "vmess",
+ reason: "fields must be strings or numbers",
+ })?;
+ // `type` and `host`/`path` mean different things per transport, exactly
+ // as v2rayN writes them.
+ let name_for_query = match (key.as_str(), net.as_str()) {
+ ("add", _) => {
+ address = Some(value);
+ continue;
+ }
+ ("port", _) => {
+ port = Some(value);
+ continue;
+ }
+ ("id", _) => {
+ id = Some(value);
+ continue;
+ }
+ ("ps", _) => {
+ name = Some(value);
+ continue;
+ }
+ // Xray-core speaks only AEAD VMess, which every server that still lists
+ // an alterId also accepts.
+ ("v" | "aid", _) => continue,
+ ("scy", _) => "encryption",
+ ("tls", _) => "security",
+ ("net", _) => "type",
+ ("type", "grpc" | "xhttp" | "splithttp") => "mode",
+ ("type", _) => "headerType",
+ ("host", "grpc") => "authority",
+ ("path", "grpc") => "serviceName",
+ (other, _) => other,
+ };
+ query.insert(name_for_query, value, &[STREAM_PARAMETERS, &["encryption"]])?;
+ }
+ // v2rayN writes `"type": "none"` for every transport and `"tls": ""` for no
+ // security.
+ if query.get("mode") == Some("none") {
+ query.values.remove("mode");
+ }
+
+ let shape = parse_stream_shape(&query, "none")?;
+ let security = query
+ .get("encryption")
+ .map(VmessSecurity::parse)
+ .transpose()?
+ .unwrap_or_default();
+ query.reject_unsupported()?;
+ let stream = shape.finish(&query)?;
+
+ let address = canonical_address(&address.ok_or(XrayError::MissingField("address"))?)?;
+ let port = port
+ .ok_or(XrayError::MissingField("port"))?
+ .parse::()
+ .map_err(|_| XrayError::InvalidField {
+ field: "port",
+ reason: "must be between 1 and 65535",
+ })?;
+ let id = parse_uuid(&id.ok_or(XrayError::MissingField("id"))?, false)?;
+ let name = match name.filter(|name| !name.is_empty()) {
+ Some(name) => Some(name),
+ None => fragment
+ .filter(|fragment| !fragment.is_empty())
+ .map(decode_fragment)
+ .transpose()?,
+ };
+
+ Ok(ParsedShareLink {
+ name,
+ config: XrayConfig {
+ address,
+ port,
+ outbound: XrayOutbound::Vmess {
+ id,
+ security,
+ stream,
+ },
+ },
+ })
+}
+
+fn decode_vmess_body(encoded: &str) -> XrayResult
{canonicalType === "httpstls" && (
- {isVless ? (
+ {isXray ? (
) : (
diff --git a/src/components/proxy-import-dialog.tsx b/src/components/proxy-import-dialog.tsx
index 5e0171cd..e9c1dec7 100644
--- a/src/components/proxy-import-dialog.tsx
+++ b/src/components/proxy-import-dialog.tsx
@@ -286,7 +286,8 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
// as a warning, and `isFirstHopEncrypted`, the fail-closed guard, is
// untouched.
const notEncrypted = parsedProxies.filter(
- (proxy) => !isFirstHopEncrypted(proxy.proxy_type, proxy.username),
+ (proxy) =>
+ !isFirstHopEncrypted(proxy.proxy_type, proxy.username, proxy.vless_uri),
);
const cipherUndecidedCount = notEncrypted.filter(
isFirstHopCipherUndecided,
@@ -469,6 +470,7 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
isFirstHopEncrypted(
proxy.proxy_type,
proxy.username,
+ proxy.vless_uri,
)
? "text-primary-text"
: "text-warning-text"
diff --git a/src/components/proxy-management-dialog.tsx b/src/components/proxy-management-dialog.tsx
index d48b5211..28c293cb 100644
--- a/src/components/proxy-management-dialog.tsx
+++ b/src/components/proxy-management-dialog.tsx
@@ -532,7 +532,11 @@ export function ProxyManagementDialog({
// Shadowsocks keeps its cipher in `username`, and the cipher is what
// decides this hop.
const cipher = row.original.proxy_settings.username;
- const encrypted = isFirstHopEncrypted(proxyType, cipher);
+ const encrypted = isFirstHopEncrypted(
+ proxyType,
+ cipher,
+ row.original.proxy_settings.vless_uri,
+ );
// A stored Shadowsocks proxy can carry no cipher at all -
// `parse_txt_proxies` reads `ss://host:8388` as username None, and
// `import_proxies_json` stores proxy_type and username verbatim, and
diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json
index 001900be..9b204200 100644
--- a/src/i18n/locales/de.json
+++ b/src/i18n/locales/de.json
@@ -576,15 +576,17 @@
"ssCipherRequired": "Für Shadowsocks sind Verschlüsselungsmethode und Passwort erforderlich",
"selectType": "Proxytyp auswählen",
"saveFailed": "Der Proxy konnte nicht gespeichert werden: {{error}}",
- "vlessUri": "VLESS-URI",
- "vlessUriPlaceholder": "vless://…",
- "vlessUriHint": "Erfordert XTLS Vision und REALITY.",
- "vlessUriRequired": "Geben Sie eine VLESS-URI ein.",
- "vlessUriInvalid": "Geben Sie eine gültige VLESS-URI mit Host und Port ein.",
+ "vlessUri": "Freigabelink",
+ "vlessUriPlaceholder": "{{scheme}}://…",
+ "vlessUriHint": "Fügen Sie den vollständigen Link Ihres Anbieters ein. Er enthält den Server, die Zugangsdaten und den Transport.",
+ "vlessUriRequired": "Geben Sie einen Freigabelink ein.",
+ "vlessUriInvalid": "Geben Sie einen gültigen Freigabelink mit Host und Port ein.",
"firstHopGroupEncrypted": "Erster Hop verschlüsselt",
+ "firstHopGroupLink": "Erster Hop wird vom Link festgelegt",
"firstHopGroupPlaintext": "Erster Hop unverschlüsselt",
"firstHopGroupCipher": "Erster Hop hängt von der Verschlüsselungsmethode ab",
"firstHopCipherNote": "Ob die Verbindung von diesem Rechner zum Proxy verschlüsselt ist, hängt von der Verschlüsselungsmethode unten ab. Ohne Methode oder mit einer Null-Methode wie none bleibt der Datenverkehr für den Anbieter und für alle im Netzwerk dazwischen lesbar.",
+ "firstHopLinkNote": "Ob die Verbindung von diesem Rechner zum Proxy verschlüsselt ist, legt der Freigabelink fest: TLS, REALITY oder die eigene Verschlüsselung des Protokolls. Ohne diese bleibt der Datenverkehr für den Anbieter und für alle im Netzwerk dazwischen lesbar.",
"firstHopEncryptedNote": "Die Verbindung von diesem Rechner zum Proxy ist verschlüsselt. Der Anbieter sieht trotzdem jede Seite, die Sie aufrufen, und alles auf Seiten ohne HTTPS.",
"firstHopPlaintextNote": "Die Verbindung von diesem Rechner zum Proxy ist nicht verschlüsselt. Der Anbieter und alle im Netzwerk zwischen diesem Rechner und dem Proxy können den Datenverkehr mitlesen.",
"credentialsInClearHeading": "Diese Zugangsdaten werden lesbar über das Netzwerk übertragen",
@@ -600,7 +602,10 @@
"socks5": "SOCKS5",
"ss": "Shadowsocks",
"httpstls": "HTTP über TLS",
- "vless": "VLESS (REALITY)"
+ "vless": "VLESS",
+ "vmess": "VMess",
+ "trojan": "Trojan",
+ "hysteria2": "Hysteria2"
},
"tabs": {
"regular": "Normal",
@@ -2218,9 +2223,9 @@
"mcpAgentUnknown": "Dieser MCP-Client wird nicht unterstützt.",
"mcpAgentInstallFailed": "Donut Browser konnte dem MCP-Client nicht hinzugefügt werden: {{detail}}",
"mcpAgentRemoveFailed": "Donut Browser konnte nicht aus dem MCP-Client entfernt werden: {{detail}}",
- "vlessConfigInvalid": "Die VLESS-URI ist ungültig.",
+ "vlessConfigInvalid": "Der Freigabelink ist ungültig.",
"xrayUnavailable": "Xray-core ist auf diesem System nicht verfügbar.",
- "xrayUnsupportedOs": "VLESS erfordert macOS 12 oder neuer.",
+ "xrayUnsupportedOs": "VLESS-, VMess-, Trojan- und Hysteria2-Proxys erfordern macOS 12 oder neuer.",
"xrayStartFailed": "Xray-core konnte nicht gestartet werden.",
"cloudNotSignedIn": "Melden Sie sich bei Ihrem Donut Browser-Konto an, um dies zu nutzen.",
"cloudUnreachable": "Die Server von Donut Browser sind nicht erreichbar. Prüfen Sie Ihre Verbindung und versuchen Sie es erneut.",
@@ -2265,17 +2270,18 @@
"vpnWorkerStartFailed": "Die VPN-Verbindung konnte nicht gestartet werden: {{detail}}",
"exitProbeFailed": "Der Exit-Node des Proxys war nicht erreichbar, um seinen Standort zu prüfen.",
"vlessUnsupported": {
- "security": "Dieser VLESS-Server verwendet kein REALITY. Donut unterstützt VLESS nur mit REALITY.",
- "flow": "Dieser VLESS-Server verwendet keinen XTLS-Vision-Flow, den Donut benötigt.",
- "transport": "Donut unterstützt VLESS nur über reines TCP. Dieser Server verwendet einen anderen Transport (z. B. WebSocket oder gRPC).",
- "encryption": "Dieser VLESS-Server verwendet eine Verschlüsselungseinstellung, die Donut nicht unterstützt.",
- "headerType": "Dieser VLESS-Server verwendet eine Header-Verschleierung, die Donut nicht unterstützt.",
- "fingerprint": "Diese VLESS-URI fordert einen TLS-Fingerprint an, den Donut nicht unterstützt.",
- "sni": "Der VLESS-URI fehlt die für REALITY benötigte SNI (sni).",
- "publicKey": "Der VLESS-URI fehlt der öffentliche REALITY-Schlüssel (pbk).",
- "scheme": "Das ist kein VLESS-Link. Er muss mit vless:// beginnen.",
- "parameter": "Die VLESS-URI enthält eine Option, die Donut nicht unterstützt.",
- "malformed": "Die VLESS-URI ist ungültig."
+ "security": "Dieser Link verwendet eine Sicherheitseinstellung, die Donut nicht unterstützt. Donut unterstützt none, TLS und REALITY, und REALITY nur über RAW (TCP), gRPC oder XHTTP.",
+ "flow": "Dieser Link verwendet einen Flow, den Donut nicht unterstützt. XTLS Vision funktioniert nur mit VLESS über RAW (TCP) mit TLS oder REALITY.",
+ "transport": "Dieser Link verwendet einen Transport oder Transportmodus, den Donut nicht unterstützt. Donut unterstützt RAW (TCP), WebSocket, gRPC, HTTPUpgrade und XHTTP, aber nicht mKCP, HTTP/2 oder QUIC.",
+ "encryption": "Dieser Link verwendet eine Verschlüsselungseinstellung, die Donut nicht unterstützt.",
+ "headerType": "Dieser Link verwendet eine Header-Verschleierung, die Donut nicht unterstützt.",
+ "fingerprint": "Dieser Link fordert einen TLS-Fingerprint an, den Donut nicht unterstützt.",
+ "sni": "Dem Link fehlt ein gültiger Servername (sni).",
+ "publicKey": "Dem Link fehlt der öffentliche REALITY-Schlüssel (pbk).",
+ "obfs": "Dieser Hysteria2-Link verwendet eine Verschleierung, die Donut nicht unterstützt. Donut unterstützt nur Salamander.",
+ "scheme": "Das ist kein unterstützter Freigabelink. Er muss mit vless://, vmess://, trojan://, hysteria2:// oder hy2:// beginnen.",
+ "parameter": "Der Link enthält eine Option, die Donut nicht unterstützt.",
+ "malformed": "Der Freigabelink ist ungültig."
},
"camoufoxRemoved": "Camoufox wird nicht mehr unterstützt. Erstellen Sie dieses Profil mit Wayfern neu.",
"noE2ePasswordSet": "Es ist kein Passwort für die Ende-zu-Ende-Verschlüsselung festgelegt. Legen Sie eines fest, bevor Sie verschlüsselte Daten synchronisieren.",
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json
index 0781133f..75de840a 100644
--- a/src/i18n/locales/en.json
+++ b/src/i18n/locales/en.json
@@ -576,15 +576,17 @@
"ssCipherRequired": "Cipher and password are required for Shadowsocks",
"selectType": "Select proxy type",
"saveFailed": "Failed to save proxy: {{error}}",
- "vlessUri": "VLESS URI",
- "vlessUriPlaceholder": "vless://…",
- "vlessUriHint": "Requires XTLS Vision and REALITY.",
- "vlessUriRequired": "Enter a VLESS URI.",
- "vlessUriInvalid": "Enter a valid VLESS URI with a host and port.",
+ "vlessUri": "Share link",
+ "vlessUriPlaceholder": "{{scheme}}://…",
+ "vlessUriHint": "Paste the full link from your provider. It holds the server, the credentials and the transport.",
+ "vlessUriRequired": "Enter a share link.",
+ "vlessUriInvalid": "Enter a valid share link with a host and port.",
"firstHopGroupEncrypted": "First hop encrypted",
+ "firstHopGroupLink": "First hop set by the link",
"firstHopGroupPlaintext": "First hop in the clear",
"firstHopGroupCipher": "First hop depends on the cipher",
"firstHopCipherNote": "Whether the connection from this machine to the proxy is encrypted is decided by the cipher below. An empty cipher, or a null one such as none, leaves the traffic readable to the provider and to anyone on the network in between.",
+ "firstHopLinkNote": "Whether the connection from this machine to the proxy is encrypted is decided by the share link: TLS, REALITY or the protocol's own encryption. A link without them leaves the traffic readable to the provider and to anyone on the network in between.",
"firstHopEncryptedNote": "The connection from this machine to the proxy is encrypted. The provider still sees every site you reach, and everything on pages that are not HTTPS.",
"firstHopPlaintextNote": "The connection from this machine to the proxy is not encrypted. The provider, and anyone on the network between this machine and it, can read the traffic.",
"credentialsInClearHeading": "These credentials cross the network readable",
@@ -600,7 +602,10 @@
"socks5": "SOCKS5",
"ss": "Shadowsocks",
"httpstls": "HTTP over TLS",
- "vless": "VLESS (REALITY)"
+ "vless": "VLESS",
+ "vmess": "VMess",
+ "trojan": "Trojan",
+ "hysteria2": "Hysteria2"
},
"tabs": {
"regular": "Regular",
@@ -2218,9 +2223,9 @@
"mcpAgentUnknown": "This MCP client is not supported.",
"mcpAgentInstallFailed": "Could not add Donut Browser to the MCP client: {{detail}}",
"mcpAgentRemoveFailed": "Could not remove Donut Browser from the MCP client: {{detail}}",
- "vlessConfigInvalid": "The VLESS URI is invalid.",
+ "vlessConfigInvalid": "The share link is invalid.",
"xrayUnavailable": "Xray-core is unavailable on this system.",
- "xrayUnsupportedOs": "VLESS requires macOS 12 or newer.",
+ "xrayUnsupportedOs": "VLESS, VMess, Trojan and Hysteria2 proxies require macOS 12 or newer.",
"xrayStartFailed": "Xray-core could not start.",
"cloudNotSignedIn": "Sign in to your Donut Browser account to use this.",
"cloudUnreachable": "Could not reach Donut Browser's servers. Check your connection and try again.",
@@ -2265,17 +2270,18 @@
"vpnWorkerStartFailed": "Couldn't start the VPN connection: {{detail}}",
"exitProbeFailed": "Couldn't reach the proxy exit node to check its location.",
"vlessUnsupported": {
- "security": "This VLESS server does not use REALITY. Donut supports VLESS with REALITY only.",
- "flow": "This VLESS server does not use XTLS Vision flow, which Donut requires.",
- "transport": "Donut supports VLESS over plain TCP only — this server uses a different transport (such as WebSocket or gRPC).",
- "encryption": "This VLESS server uses an encryption setting Donut does not support.",
- "headerType": "This VLESS server uses a header obfuscation Donut does not support.",
- "fingerprint": "This VLESS URI requests a TLS fingerprint Donut does not support.",
- "sni": "The VLESS URI is missing the SNI (sni) needed for REALITY.",
- "publicKey": "The VLESS URI is missing the REALITY public key (pbk).",
- "scheme": "That is not a VLESS link. It must start with vless://.",
- "parameter": "The VLESS URI contains an option Donut does not support.",
- "malformed": "The VLESS URI is invalid."
+ "security": "This link uses a security setting Donut does not support. Donut supports none, TLS and REALITY, and REALITY only over RAW (TCP), gRPC or XHTTP.",
+ "flow": "This link uses a flow Donut does not support. XTLS Vision works only with VLESS over RAW (TCP) with TLS or REALITY.",
+ "transport": "This link uses a transport, or a transport mode, that Donut does not support. Donut supports RAW (TCP), WebSocket, gRPC, HTTPUpgrade and XHTTP, but not mKCP, HTTP/2 or QUIC.",
+ "encryption": "This link uses an encryption setting Donut does not support.",
+ "headerType": "This link uses a header obfuscation Donut does not support.",
+ "fingerprint": "This link asks for a TLS fingerprint Donut does not support.",
+ "sni": "The link is missing a valid server name (sni).",
+ "publicKey": "The link is missing the REALITY public key (pbk).",
+ "obfs": "This Hysteria2 link uses an obfuscation Donut does not support. Donut supports Salamander only.",
+ "scheme": "That is not a supported share link. It must start with vless://, vmess://, trojan://, hysteria2:// or hy2://.",
+ "parameter": "The link contains an option Donut does not support.",
+ "malformed": "The share link is invalid."
},
"camoufoxRemoved": "Camoufox is no longer supported. Recreate this profile with Wayfern.",
"noE2ePasswordSet": "No end-to-end encryption password is set. Set one before syncing encrypted data.",
diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json
index e74f3720..f6672308 100644
--- a/src/i18n/locales/es.json
+++ b/src/i18n/locales/es.json
@@ -578,15 +578,17 @@
"ssCipherRequired": "Para Shadowsocks se requieren cifrado y contraseña",
"selectType": "Selecciona el tipo de proxy",
"saveFailed": "Error al guardar el proxy: {{error}}",
- "vlessUri": "URI de VLESS",
- "vlessUriPlaceholder": "vless://…",
- "vlessUriHint": "Requiere XTLS Vision y REALITY.",
- "vlessUriRequired": "Introduce un URI de VLESS.",
- "vlessUriInvalid": "Introduce un URI de VLESS válido con host y puerto.",
+ "vlessUri": "Enlace para compartir",
+ "vlessUriPlaceholder": "{{scheme}}://…",
+ "vlessUriHint": "Pega el enlace completo de tu proveedor. Contiene el servidor, las credenciales y el transporte.",
+ "vlessUriRequired": "Introduce un enlace para compartir.",
+ "vlessUriInvalid": "Introduce un enlace para compartir válido con host y puerto.",
"firstHopGroupEncrypted": "Primer salto cifrado",
+ "firstHopGroupLink": "El enlace define el primer salto",
"firstHopGroupPlaintext": "Primer salto sin cifrar",
"firstHopGroupCipher": "El primer salto depende del cifrado",
"firstHopCipherNote": "Que la conexión de este equipo al proxy esté cifrada lo decide el cifrado de abajo. Un cifrado vacío, o uno nulo como none, deja el tráfico legible para el proveedor y para cualquiera en la red intermedia.",
+ "firstHopLinkNote": "Que la conexión de este equipo al proxy esté cifrada lo decide el enlace para compartir: TLS, REALITY o el cifrado propio del protocolo. Un enlace sin ellos deja el tráfico legible para el proveedor y para cualquiera en la red intermedia.",
"firstHopEncryptedNote": "La conexión de este equipo al proxy está cifrada. El proveedor sigue viendo todos los sitios que visitas y todo el contenido de las páginas que no usan HTTPS.",
"firstHopPlaintextNote": "La conexión de este equipo al proxy no está cifrada. El proveedor, y cualquiera en la red entre este equipo y él, puede leer el tráfico.",
"credentialsInClearHeading": "Estas credenciales viajan legibles por la red",
@@ -602,7 +604,10 @@
"socks5": "SOCKS5",
"ss": "Shadowsocks",
"httpstls": "HTTP over TLS",
- "vless": "VLESS (REALITY)"
+ "vless": "VLESS",
+ "vmess": "VMess",
+ "trojan": "Trojan",
+ "hysteria2": "Hysteria2"
},
"tabs": {
"regular": "Regular",
@@ -2228,9 +2233,9 @@
"mcpAgentUnknown": "Este cliente MCP no es compatible.",
"mcpAgentInstallFailed": "No se pudo añadir Donut Browser al cliente MCP: {{detail}}",
"mcpAgentRemoveFailed": "No se pudo eliminar Donut Browser del cliente MCP: {{detail}}",
- "vlessConfigInvalid": "El URI de VLESS no es válido.",
+ "vlessConfigInvalid": "El enlace para compartir no es válido.",
"xrayUnavailable": "Xray-core no está disponible en este sistema.",
- "xrayUnsupportedOs": "VLESS requiere macOS 12 o posterior.",
+ "xrayUnsupportedOs": "Los proxies VLESS, VMess, Trojan y Hysteria2 requieren macOS 12 o posterior.",
"xrayStartFailed": "No se pudo iniciar Xray-core.",
"cloudNotSignedIn": "Inicia sesión en tu cuenta de Donut Browser para usar esto.",
"cloudUnreachable": "No se pudo contactar con los servidores de Donut Browser. Comprueba tu conexión e inténtalo de nuevo.",
@@ -2275,17 +2280,18 @@
"vpnWorkerStartFailed": "No se pudo iniciar la conexión VPN: {{detail}}",
"exitProbeFailed": "No se pudo contactar con el nodo de salida del proxy para comprobar su ubicación.",
"vlessUnsupported": {
- "security": "Este servidor VLESS no usa REALITY. Donut solo admite VLESS con REALITY.",
- "flow": "Este servidor VLESS no usa el flujo XTLS Vision, que Donut requiere.",
- "transport": "Donut solo admite VLESS sobre TCP simple: este servidor usa otro transporte (como WebSocket o gRPC).",
- "encryption": "Este servidor VLESS usa un cifrado que Donut no admite.",
- "headerType": "Este servidor VLESS usa una ofuscación de cabecera que Donut no admite.",
- "fingerprint": "Esta URI VLESS solicita una huella TLS que Donut no admite.",
- "sni": "A la URI VLESS le falta el SNI (sni) necesario para REALITY.",
- "publicKey": "A la URI VLESS le falta la clave pública de REALITY (pbk).",
- "scheme": "Eso no es un enlace VLESS. Debe empezar por vless://.",
- "parameter": "La URI VLESS contiene una opción que Donut no admite.",
- "malformed": "La URI VLESS no es válida."
+ "security": "Este enlace usa un ajuste de seguridad que Donut no admite. Donut admite none, TLS y REALITY, y REALITY solo sobre RAW (TCP), gRPC o XHTTP.",
+ "flow": "Este enlace usa un flow que Donut no admite. XTLS Vision solo funciona con VLESS sobre RAW (TCP) con TLS o REALITY.",
+ "transport": "Este enlace usa un transporte, o un modo de transporte, que Donut no admite. Donut admite RAW (TCP), WebSocket, gRPC, HTTPUpgrade y XHTTP, pero no mKCP, HTTP/2 ni QUIC.",
+ "encryption": "Este enlace usa un ajuste de cifrado que Donut no admite.",
+ "headerType": "Este enlace usa una ofuscación de cabecera que Donut no admite.",
+ "fingerprint": "Este enlace pide una huella TLS que Donut no admite.",
+ "sni": "Al enlace le falta un nombre de servidor (sni) válido.",
+ "publicKey": "Al enlace le falta la clave pública de REALITY (pbk).",
+ "obfs": "Este enlace de Hysteria2 usa una ofuscación que Donut no admite. Donut solo admite Salamander.",
+ "scheme": "Eso no es un enlace para compartir admitido. Debe empezar por vless://, vmess://, trojan://, hysteria2:// o hy2://.",
+ "parameter": "El enlace contiene una opción que Donut no admite.",
+ "malformed": "El enlace para compartir no es válido."
},
"camoufoxRemoved": "Camoufox ya no es compatible. Vuelve a crear este perfil con Wayfern.",
"noE2ePasswordSet": "No hay contraseña de cifrado de extremo a extremo. Establece una antes de sincronizar datos cifrados.",
diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json
index 94aeb287..55052845 100644
--- a/src/i18n/locales/fr.json
+++ b/src/i18n/locales/fr.json
@@ -578,15 +578,17 @@
"ssCipherRequired": "Le chiffrement et le mot de passe sont requis pour Shadowsocks",
"selectType": "Sélectionnez le type de proxy",
"saveFailed": "Échec de la sauvegarde du proxy : {{error}}",
- "vlessUri": "URI VLESS",
- "vlessUriPlaceholder": "vless://…",
- "vlessUriHint": "Nécessite XTLS Vision et REALITY.",
- "vlessUriRequired": "Saisissez une URI VLESS.",
- "vlessUriInvalid": "Saisissez une URI VLESS valide avec un hôte et un port.",
+ "vlessUri": "Lien de partage",
+ "vlessUriPlaceholder": "{{scheme}}://…",
+ "vlessUriHint": "Collez le lien complet de votre fournisseur. Il contient le serveur, les identifiants et le transport.",
+ "vlessUriRequired": "Saisissez un lien de partage.",
+ "vlessUriInvalid": "Saisissez un lien de partage valide avec un hôte et un port.",
"firstHopGroupEncrypted": "Premier saut chiffré",
+ "firstHopGroupLink": "Le premier saut dépend du lien",
"firstHopGroupPlaintext": "Premier saut en clair",
"firstHopGroupCipher": "Le premier saut dépend du chiffrement",
"firstHopCipherNote": "C'est le chiffrement ci-dessous qui décide si la connexion de cette machine au proxy est chiffrée. Un chiffrement vide, ou un chiffrement nul comme none, laisse le trafic lisible par le fournisseur et par toute personne sur le réseau intermédiaire.",
+ "firstHopLinkNote": "C'est le lien de partage qui décide si la connexion de cette machine au proxy est chiffrée : TLS, REALITY ou le chiffrement propre au protocole. Un lien sans aucun d'eux laisse le trafic lisible par le fournisseur et par toute personne sur le réseau intermédiaire.",
"firstHopEncryptedNote": "La connexion de cette machine au proxy est chiffrée. Le fournisseur voit toujours chaque site que vous atteignez, ainsi que tout le contenu des pages qui ne sont pas en HTTPS.",
"firstHopPlaintextNote": "La connexion de cette machine au proxy n'est pas chiffrée. Le fournisseur, et toute personne sur le réseau entre cette machine et lui, peut lire le trafic.",
"credentialsInClearHeading": "Ces identifiants traversent le réseau en clair",
@@ -602,7 +604,10 @@
"socks5": "SOCKS5",
"ss": "Shadowsocks",
"httpstls": "HTTP over TLS",
- "vless": "VLESS (REALITY)"
+ "vless": "VLESS",
+ "vmess": "VMess",
+ "trojan": "Trojan",
+ "hysteria2": "Hysteria2"
},
"tabs": {
"regular": "Standard",
@@ -2228,9 +2233,9 @@
"mcpAgentUnknown": "Ce client MCP n’est pas pris en charge.",
"mcpAgentInstallFailed": "Impossible d’ajouter Donut Browser au client MCP : {{detail}}",
"mcpAgentRemoveFailed": "Impossible de retirer Donut Browser du client MCP : {{detail}}",
- "vlessConfigInvalid": "L’URI VLESS n’est pas valide.",
+ "vlessConfigInvalid": "Le lien de partage n'est pas valide.",
"xrayUnavailable": "Xray-core n’est pas disponible sur ce système.",
- "xrayUnsupportedOs": "VLESS nécessite macOS 12 ou une version ultérieure.",
+ "xrayUnsupportedOs": "Les proxys VLESS, VMess, Trojan et Hysteria2 nécessitent macOS 12 ou une version ultérieure.",
"xrayStartFailed": "Impossible de démarrer Xray-core.",
"cloudNotSignedIn": "Connectez-vous à votre compte Donut Browser pour utiliser cette fonction.",
"cloudUnreachable": "Impossible de joindre les serveurs de Donut Browser. Vérifiez votre connexion et réessayez.",
@@ -2275,17 +2280,18 @@
"vpnWorkerStartFailed": "Impossible de démarrer la connexion VPN : {{detail}}",
"exitProbeFailed": "Impossible de joindre le nœud de sortie du proxy pour vérifier sa localisation.",
"vlessUnsupported": {
- "security": "Ce serveur VLESS n'utilise pas REALITY. Donut ne prend en charge que VLESS avec REALITY.",
- "flow": "Ce serveur VLESS n'utilise pas le flux XTLS Vision, requis par Donut.",
- "transport": "Donut ne prend en charge que VLESS sur TCP simple — ce serveur utilise un autre transport (WebSocket ou gRPC, par exemple).",
- "encryption": "Ce serveur VLESS utilise un chiffrement non pris en charge par Donut.",
- "headerType": "Ce serveur VLESS utilise une obfuscation d'en-tête non prise en charge par Donut.",
- "fingerprint": "Cette URI VLESS demande une empreinte TLS non prise en charge par Donut.",
- "sni": "Il manque le SNI (sni) nécessaire à REALITY dans l'URI VLESS.",
- "publicKey": "Il manque la clé publique REALITY (pbk) dans l'URI VLESS.",
- "scheme": "Ce n'est pas un lien VLESS. Il doit commencer par vless://.",
- "parameter": "L'URI VLESS contient une option non prise en charge par Donut.",
- "malformed": "L'URI VLESS n'est pas valide."
+ "security": "Ce lien utilise un réglage de sécurité que Donut ne prend pas en charge. Donut prend en charge none, TLS et REALITY, et REALITY uniquement sur RAW (TCP), gRPC ou XHTTP.",
+ "flow": "Ce lien utilise un flow que Donut ne prend pas en charge. XTLS Vision fonctionne uniquement avec VLESS sur RAW (TCP) avec TLS ou REALITY.",
+ "transport": "Ce lien utilise un transport, ou un mode de transport, que Donut ne prend pas en charge. Donut prend en charge RAW (TCP), WebSocket, gRPC, HTTPUpgrade et XHTTP, mais pas mKCP, HTTP/2 ni QUIC.",
+ "encryption": "Ce lien utilise un réglage de chiffrement que Donut ne prend pas en charge.",
+ "headerType": "Ce lien utilise une obfuscation d'en-tête que Donut ne prend pas en charge.",
+ "fingerprint": "Ce lien demande une empreinte TLS que Donut ne prend pas en charge.",
+ "sni": "Il manque au lien un nom de serveur (sni) valide.",
+ "publicKey": "Il manque au lien la clé publique REALITY (pbk).",
+ "obfs": "Ce lien Hysteria2 utilise une obfuscation que Donut ne prend pas en charge. Donut prend en charge Salamander uniquement.",
+ "scheme": "Ce n'est pas un lien de partage pris en charge. Il doit commencer par vless://, vmess://, trojan://, hysteria2:// ou hy2://.",
+ "parameter": "Le lien contient une option que Donut ne prend pas en charge.",
+ "malformed": "Le lien de partage n'est pas valide."
},
"camoufoxRemoved": "Camoufox n'est plus pris en charge. Recréez ce profil avec Wayfern.",
"noE2ePasswordSet": "Aucun mot de passe de chiffrement de bout en bout n'est défini. Définissez-en un avant de synchroniser des données chiffrées.",
diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json
index 075cfe43..55c39de7 100644
--- a/src/i18n/locales/ja.json
+++ b/src/i18n/locales/ja.json
@@ -576,15 +576,17 @@
"ssCipherRequired": "Shadowsocks には暗号とパスワードが必要です",
"selectType": "プロキシの種類を選択",
"saveFailed": "プロキシの保存に失敗しました: {{error}}",
- "vlessUri": "VLESS URI",
- "vlessUriPlaceholder": "vless://…",
- "vlessUriHint": "XTLS Vision と REALITY が必要です。",
- "vlessUriRequired": "VLESS URI を入力してください。",
- "vlessUriInvalid": "ホストとポートを含む有効な VLESS URI を入力してください。",
+ "vlessUri": "共有リンク",
+ "vlessUriPlaceholder": "{{scheme}}://…",
+ "vlessUriHint": "プロバイダーのリンクをそのまま貼り付けてください。リンクにはサーバー、認証情報、トランスポートが含まれます。",
+ "vlessUriRequired": "共有リンクを入力してください。",
+ "vlessUriInvalid": "ホストとポートを含む有効な共有リンクを入力してください。",
"firstHopGroupEncrypted": "第一ホップは暗号化",
+ "firstHopGroupLink": "第一ホップはリンク次第",
"firstHopGroupPlaintext": "第一ホップは平文",
"firstHopGroupCipher": "第一ホップは暗号方式次第",
"firstHopCipherNote": "このマシンからプロキシまでの接続が暗号化されるかどうかは、下の暗号方式で決まります。暗号方式が空欄の場合や、none のように何も暗号化しない方式の場合、通信はプロバイダーや途中のネットワーク上の誰にでも読み取れるままです。",
+ "firstHopLinkNote": "このマシンからプロキシまでの接続が暗号化されるかどうかは、共有リンクで決まります(TLS、REALITY、またはプロトコル自身の暗号化)。どれも含まないリンクでは、通信はプロバイダーや途中のネットワーク上の誰にでも読み取れるままです。",
"firstHopEncryptedNote": "このマシンからプロキシまでの接続は暗号化されています。ただしプロバイダーは、アクセスするすべてのサイトと、HTTPS ではないページの内容をすべて見ることができます。",
"firstHopPlaintextNote": "このマシンからプロキシまでの接続は暗号化されていません。プロバイダー、およびこのマシンとプロキシの間のネットワーク上にいる誰もが通信を読み取れます。",
"credentialsInClearHeading": "この認証情報は読み取り可能な状態でネットワークを通過します",
@@ -600,7 +602,10 @@
"socks5": "SOCKS5",
"ss": "Shadowsocks",
"httpstls": "HTTP over TLS",
- "vless": "VLESS (REALITY)"
+ "vless": "VLESS",
+ "vmess": "VMess",
+ "trojan": "Trojan",
+ "hysteria2": "Hysteria2"
},
"tabs": {
"regular": "通常",
@@ -2218,9 +2223,9 @@
"mcpAgentUnknown": "このMCPクライアントはサポートされていません。",
"mcpAgentInstallFailed": "MCPクライアントにDonut Browserを追加できませんでした:{{detail}}",
"mcpAgentRemoveFailed": "MCPクライアントからDonut Browserを削除できませんでした:{{detail}}",
- "vlessConfigInvalid": "VLESS URI が無効です。",
+ "vlessConfigInvalid": "共有リンクが無効です。",
"xrayUnavailable": "このシステムでは Xray-core を利用できません。",
- "xrayUnsupportedOs": "VLESS には macOS 12 以降が必要です。",
+ "xrayUnsupportedOs": "VLESS、VMess、Trojan、Hysteria2のプロキシにはmacOS 12以降が必要です。",
"xrayStartFailed": "Xray-core を起動できませんでした。",
"cloudNotSignedIn": "この機能を使うには Donut Browser アカウントにサインインしてください。",
"cloudUnreachable": "Donut Browser のサーバーに接続できませんでした。接続を確認してもう一度お試しください。",
@@ -2265,17 +2270,18 @@
"vpnWorkerStartFailed": "VPN接続を開始できませんでした: {{detail}}",
"exitProbeFailed": "プロキシの出口ノードに接続できず、所在地を確認できませんでした。",
"vlessUnsupported": {
- "security": "このVLESSサーバーはREALITYを使用していません。DonutはREALITY付きのVLESSのみに対応しています。",
- "flow": "このVLESSサーバーは、Donutが必要とするXTLS Visionフローを使用していません。",
- "transport": "Donutは素のTCP上のVLESSのみに対応しています。このサーバーは別のトランスポート(WebSocketやgRPCなど)を使用しています。",
- "encryption": "このVLESSサーバーは、Donutが対応していない暗号化設定を使用しています。",
- "headerType": "このVLESSサーバーは、Donutが対応していないヘッダー難読化を使用しています。",
- "fingerprint": "このVLESS URIは、Donutが対応していないTLSフィンガープリントを要求しています。",
- "sni": "VLESS URIに、REALITYに必要なSNI(sni)がありません。",
- "publicKey": "VLESS URIに、REALITYの公開鍵(pbk)がありません。",
- "scheme": "これはVLESSリンクではありません。vless:// で始まる必要があります。",
- "parameter": "VLESS URIに、Donutが対応していないオプションが含まれています。",
- "malformed": "VLESS URIが無効です。"
+ "security": "このリンクはDonutが対応していないセキュリティ設定を使用しています。Donutはnone、TLS、REALITYに対応しており、REALITYはRAW (TCP)、gRPC、XHTTP上でのみ使用できます。",
+ "flow": "このリンクはDonutが対応していないflowを使用しています。XTLS Visionは、TLSまたはREALITYを使うRAW (TCP)上のVLESSでのみ動作します。",
+ "transport": "このリンクはDonutが対応していないトランスポートまたはトランスポートモードを使用しています。DonutはRAW (TCP)、WebSocket、gRPC、HTTPUpgrade、XHTTPに対応していますが、mKCP、HTTP/2、QUICには対応していません。",
+ "encryption": "このリンクはDonutが対応していない暗号化設定を使用しています。",
+ "headerType": "このリンクはDonutが対応していないヘッダー難読化を使用しています。",
+ "fingerprint": "このリンクはDonutが対応していないTLSフィンガープリントを要求しています。",
+ "sni": "リンクに有効なサーバー名 (sni) がありません。",
+ "publicKey": "リンクにREALITYの公開鍵 (pbk) がありません。",
+ "obfs": "このHysteria2リンクはDonutが対応していない難読化を使用しています。DonutはSalamanderのみに対応しています。",
+ "scheme": "対応している共有リンクではありません。vless://、vmess://、trojan://、hysteria2:// または hy2:// で始まる必要があります。",
+ "parameter": "リンクにDonutが対応していないオプションが含まれています。",
+ "malformed": "共有リンクが無効です。"
},
"camoufoxRemoved": "Camoufoxはサポートされなくなりました。Wayfernでこのプロファイルを作り直してください。",
"noE2ePasswordSet": "エンドツーエンド暗号化のパスワードが設定されていません。暗号化データを同期する前に設定してください。",
diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json
index e6453f62..9e2fa97d 100644
--- a/src/i18n/locales/ko.json
+++ b/src/i18n/locales/ko.json
@@ -576,15 +576,17 @@
"ssCipherRequired": "Shadowsocks에는 암호화와 비밀번호가 필요합니다",
"selectType": "프록시 유형 선택",
"saveFailed": "프록시 저장 실패: {{error}}",
- "vlessUri": "VLESS URI",
- "vlessUriPlaceholder": "vless://…",
- "vlessUriHint": "XTLS Vision 및 REALITY가 필요합니다.",
- "vlessUriRequired": "VLESS URI를 입력하세요.",
- "vlessUriInvalid": "호스트와 포트가 포함된 올바른 VLESS URI를 입력하세요.",
+ "vlessUri": "공유 링크",
+ "vlessUriPlaceholder": "{{scheme}}://…",
+ "vlessUriHint": "제공업체의 링크 전체를 붙여 넣으세요. 링크에는 서버, 인증 정보, 전송 방식이 들어 있습니다.",
+ "vlessUriRequired": "공유 링크를 입력하세요.",
+ "vlessUriInvalid": "호스트와 포트가 포함된 올바른 공유 링크를 입력하세요.",
"firstHopGroupEncrypted": "첫 구간 암호화됨",
+ "firstHopGroupLink": "첫 구간은 링크가 결정",
"firstHopGroupPlaintext": "첫 구간 평문",
"firstHopGroupCipher": "첫 구간은 암호화 방식에 달림",
"firstHopCipherNote": "이 컴퓨터에서 프록시까지의 연결이 암호화되는지는 아래 암호화 방식이 결정합니다. 암호화 방식이 비어 있거나 none처럼 아무것도 암호화하지 않는 방식이면, 트래픽은 제공업체와 중간 네트워크에 있는 누구에게나 읽히는 상태로 남습니다.",
+ "firstHopLinkNote": "이 컴퓨터에서 프록시까지의 연결이 암호화되는지는 공유 링크가 결정합니다: TLS, REALITY 또는 프로토콜 자체의 암호화. 이 중 어느 것도 없는 링크라면, 트래픽은 제공업체와 중간 네트워크에 있는 누구에게나 읽히는 상태로 남습니다.",
"firstHopEncryptedNote": "이 컴퓨터에서 프록시까지의 연결은 암호화됩니다. 다만 제공업체는 접속하는 모든 사이트와 HTTPS가 아닌 페이지의 모든 내용을 여전히 볼 수 있습니다.",
"firstHopPlaintextNote": "이 컴퓨터에서 프록시까지의 연결은 암호화되지 않습니다. 제공업체와 이 컴퓨터와 프록시 사이 네트워크에 있는 누구나 트래픽을 읽을 수 있습니다.",
"credentialsInClearHeading": "이 자격 증명은 읽을 수 있는 상태로 네트워크를 지나갑니다",
@@ -600,7 +602,10 @@
"socks5": "SOCKS5",
"ss": "Shadowsocks",
"httpstls": "HTTP over TLS",
- "vless": "VLESS (REALITY)"
+ "vless": "VLESS",
+ "vmess": "VMess",
+ "trojan": "Trojan",
+ "hysteria2": "Hysteria2"
},
"tabs": {
"regular": "일반",
@@ -2218,9 +2223,9 @@
"mcpAgentUnknown": "이 MCP 클라이언트는 지원되지 않습니다.",
"mcpAgentInstallFailed": "MCP 클라이언트에 Donut Browser를 추가하지 못했습니다: {{detail}}",
"mcpAgentRemoveFailed": "MCP 클라이언트에서 Donut Browser를 제거하지 못했습니다: {{detail}}",
- "vlessConfigInvalid": "VLESS URI가 올바르지 않습니다.",
+ "vlessConfigInvalid": "공유 링크가 올바르지 않습니다.",
"xrayUnavailable": "이 시스템에서는 Xray-core를 사용할 수 없습니다.",
- "xrayUnsupportedOs": "VLESS를 사용하려면 macOS 12 이상이 필요합니다.",
+ "xrayUnsupportedOs": "VLESS, VMess, Trojan, Hysteria2 프록시는 macOS 12 이상이 필요합니다.",
"xrayStartFailed": "Xray-core를 시작할 수 없습니다.",
"cloudNotSignedIn": "이 기능을 사용하려면 Donut Browser 계정에 로그인하세요.",
"cloudUnreachable": "Donut Browser 서버에 연결하지 못했습니다. 연결 상태를 확인하고 다시 시도하세요.",
@@ -2265,17 +2270,18 @@
"vpnWorkerStartFailed": "VPN 연결을 시작하지 못했습니다: {{detail}}",
"exitProbeFailed": "프록시 출구 노드에 연결할 수 없어 위치를 확인하지 못했습니다.",
"vlessUnsupported": {
- "security": "이 VLESS 서버는 REALITY를 사용하지 않습니다. Donut은 REALITY를 사용하는 VLESS만 지원합니다.",
- "flow": "이 VLESS 서버는 Donut이 요구하는 XTLS Vision 플로우를 사용하지 않습니다.",
- "transport": "Donut은 일반 TCP 기반 VLESS만 지원합니다. 이 서버는 다른 전송 방식(WebSocket, gRPC 등)을 사용합니다.",
- "encryption": "이 VLESS 서버는 Donut이 지원하지 않는 암호화 설정을 사용합니다.",
- "headerType": "이 VLESS 서버는 Donut이 지원하지 않는 헤더 난독화를 사용합니다.",
- "fingerprint": "이 VLESS URI는 Donut이 지원하지 않는 TLS 지문을 요청합니다.",
- "sni": "VLESS URI에 REALITY에 필요한 SNI(sni)가 없습니다.",
- "publicKey": "VLESS URI에 REALITY 공개 키(pbk)가 없습니다.",
- "scheme": "VLESS 링크가 아닙니다. vless:// 로 시작해야 합니다.",
- "parameter": "VLESS URI에 Donut이 지원하지 않는 옵션이 있습니다.",
- "malformed": "VLESS URI가 올바르지 않습니다."
+ "security": "이 링크는 Donut이 지원하지 않는 보안 설정을 사용합니다. Donut은 none, TLS, REALITY를 지원하며, REALITY는 RAW (TCP), gRPC, XHTTP에서만 사용할 수 있습니다.",
+ "flow": "이 링크는 Donut이 지원하지 않는 flow를 사용합니다. XTLS Vision은 TLS 또는 REALITY를 사용하는 RAW (TCP) 위의 VLESS에서만 작동합니다.",
+ "transport": "이 링크는 Donut이 지원하지 않는 전송 방식 또는 전송 모드를 사용합니다. Donut은 RAW (TCP), WebSocket, gRPC, HTTPUpgrade, XHTTP를 지원하지만 mKCP, HTTP/2, QUIC은 지원하지 않습니다.",
+ "encryption": "이 링크는 Donut이 지원하지 않는 암호화 설정을 사용합니다.",
+ "headerType": "이 링크는 Donut이 지원하지 않는 헤더 난독화를 사용합니다.",
+ "fingerprint": "이 링크는 Donut이 지원하지 않는 TLS 지문을 요청합니다.",
+ "sni": "링크에 올바른 서버 이름(sni)이 없습니다.",
+ "publicKey": "링크에 REALITY 공개 키(pbk)가 없습니다.",
+ "obfs": "이 Hysteria2 링크는 Donut이 지원하지 않는 난독화를 사용합니다. Donut은 Salamander만 지원합니다.",
+ "scheme": "지원되는 공유 링크가 아닙니다. vless://, vmess://, trojan://, hysteria2:// 또는 hy2:// 로 시작해야 합니다.",
+ "parameter": "링크에 Donut이 지원하지 않는 옵션이 포함되어 있습니다.",
+ "malformed": "공유 링크가 올바르지 않습니다."
},
"camoufoxRemoved": "Camoufox는 더 이상 지원되지 않습니다. Wayfern으로 이 프로필을 다시 만드세요.",
"noE2ePasswordSet": "종단 간 암호화 비밀번호가 설정되지 않았습니다. 암호화된 데이터를 동기화하기 전에 설정하세요.",
diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json
index f0f0518e..e52d7f27 100644
--- a/src/i18n/locales/pt.json
+++ b/src/i18n/locales/pt.json
@@ -578,15 +578,17 @@
"ssCipherRequired": "Cifra e senha são obrigatórias para Shadowsocks",
"selectType": "Selecione o tipo de proxy",
"saveFailed": "Falha ao salvar o proxy: {{error}}",
- "vlessUri": "URI VLESS",
- "vlessUriPlaceholder": "vless://…",
- "vlessUriHint": "Requer XTLS Vision e REALITY.",
- "vlessUriRequired": "Insira um URI VLESS.",
- "vlessUriInvalid": "Insira um URI VLESS válido com host e porta.",
+ "vlessUri": "Link de compartilhamento",
+ "vlessUriPlaceholder": "{{scheme}}://…",
+ "vlessUriHint": "Cole o link completo do seu fornecedor. Ele contém o servidor, as credenciais e o transporte.",
+ "vlessUriRequired": "Insira um link de compartilhamento.",
+ "vlessUriInvalid": "Insira um link de compartilhamento válido com host e porta.",
"firstHopGroupEncrypted": "Primeiro salto cifrado",
+ "firstHopGroupLink": "O primeiro salto é definido pelo link",
"firstHopGroupPlaintext": "Primeiro salto em texto simples",
"firstHopGroupCipher": "O primeiro salto depende da cifra",
"firstHopCipherNote": "Se a ligação deste computador ao proxy é cifrada depende da cifra abaixo. Uma cifra vazia, ou uma cifra nula como none, deixa o tráfego legível para o fornecedor e para qualquer pessoa na rede pelo meio.",
+ "firstHopLinkNote": "Se a ligação deste computador ao proxy é cifrada depende do link de compartilhamento: TLS, REALITY ou a cifra própria do protocolo. Um link sem nenhum deles deixa o tráfego legível para o fornecedor e para qualquer pessoa na rede pelo meio.",
"firstHopEncryptedNote": "A ligação deste computador ao proxy é cifrada. O fornecedor continua a ver todos os sites que visita e todo o conteúdo das páginas que não usam HTTPS.",
"firstHopPlaintextNote": "A ligação deste computador ao proxy não é cifrada. O fornecedor, e qualquer pessoa na rede entre este computador e ele, consegue ler o tráfego.",
"credentialsInClearHeading": "Estas credenciais atravessam a rede legíveis",
@@ -602,7 +604,10 @@
"socks5": "SOCKS5",
"ss": "Shadowsocks",
"httpstls": "HTTP over TLS",
- "vless": "VLESS (REALITY)"
+ "vless": "VLESS",
+ "vmess": "VMess",
+ "trojan": "Trojan",
+ "hysteria2": "Hysteria2"
},
"tabs": {
"regular": "Regular",
@@ -2228,9 +2233,9 @@
"mcpAgentUnknown": "Este cliente MCP não é compatível.",
"mcpAgentInstallFailed": "Não foi possível adicionar o Donut Browser ao cliente MCP: {{detail}}",
"mcpAgentRemoveFailed": "Não foi possível remover o Donut Browser do cliente MCP: {{detail}}",
- "vlessConfigInvalid": "O URI VLESS é inválido.",
+ "vlessConfigInvalid": "O link de compartilhamento é inválido.",
"xrayUnavailable": "O Xray-core não está disponível neste sistema.",
- "xrayUnsupportedOs": "O VLESS requer macOS 12 ou posterior.",
+ "xrayUnsupportedOs": "Os proxies VLESS, VMess, Trojan e Hysteria2 requerem macOS 12 ou mais recente.",
"xrayStartFailed": "Não foi possível iniciar o Xray-core.",
"cloudNotSignedIn": "Entre na sua conta do Donut Browser para usar este recurso.",
"cloudUnreachable": "Não foi possível contatar os servidores do Donut Browser. Verifique sua conexão e tente novamente.",
@@ -2275,17 +2280,18 @@
"vpnWorkerStartFailed": "Não foi possível iniciar a conexão VPN: {{detail}}",
"exitProbeFailed": "Não foi possível alcançar o nó de saída do proxy para verificar sua localização.",
"vlessUnsupported": {
- "security": "Este servidor VLESS não usa REALITY. O Donut só oferece suporte a VLESS com REALITY.",
- "flow": "Este servidor VLESS não usa o fluxo XTLS Vision, exigido pelo Donut.",
- "transport": "O Donut só oferece suporte a VLESS sobre TCP simples — este servidor usa outro transporte (como WebSocket ou gRPC).",
- "encryption": "Este servidor VLESS usa uma criptografia sem suporte no Donut.",
- "headerType": "Este servidor VLESS usa uma ofuscação de cabeçalho sem suporte no Donut.",
- "fingerprint": "Esta URI VLESS solicita uma impressão digital TLS sem suporte no Donut.",
- "sni": "Falta na URI VLESS o SNI (sni) necessário para o REALITY.",
- "publicKey": "Falta na URI VLESS a chave pública do REALITY (pbk).",
- "scheme": "Isso não é um link VLESS. Ele precisa começar com vless://.",
- "parameter": "A URI VLESS contém uma opção sem suporte no Donut.",
- "malformed": "A URI VLESS é inválida."
+ "security": "Este link usa uma definição de segurança que o Donut não suporta. O Donut suporta none, TLS e REALITY, e REALITY apenas sobre RAW (TCP), gRPC ou XHTTP.",
+ "flow": "Este link usa um flow que o Donut não suporta. O XTLS Vision só funciona com VLESS sobre RAW (TCP) com TLS ou REALITY.",
+ "transport": "Este link usa um transporte, ou um modo de transporte, que o Donut não suporta. O Donut suporta RAW (TCP), WebSocket, gRPC, HTTPUpgrade e XHTTP, mas não mKCP, HTTP/2 nem QUIC.",
+ "encryption": "Este link usa uma definição de cifra que o Donut não suporta.",
+ "headerType": "Este link usa uma ofuscação de cabeçalho que o Donut não suporta.",
+ "fingerprint": "Este link pede uma impressão digital TLS que o Donut não suporta.",
+ "sni": "Falta ao link um nome de servidor (sni) válido.",
+ "publicKey": "Falta ao link a chave pública do REALITY (pbk).",
+ "obfs": "Este link Hysteria2 usa uma ofuscação que o Donut não suporta. O Donut suporta apenas Salamander.",
+ "scheme": "Isso não é um link de compartilhamento suportado. Ele precisa começar com vless://, vmess://, trojan://, hysteria2:// ou hy2://.",
+ "parameter": "O link contém uma opção que o Donut não suporta.",
+ "malformed": "O link de compartilhamento é inválido."
},
"camoufoxRemoved": "O Camoufox não é mais compatível. Recrie este perfil com o Wayfern.",
"noE2ePasswordSet": "Nenhuma senha de criptografia de ponta a ponta foi definida. Defina uma antes de sincronizar dados criptografados.",
diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json
index 19732eeb..8e0fb45e 100644
--- a/src/i18n/locales/ru.json
+++ b/src/i18n/locales/ru.json
@@ -580,15 +580,17 @@
"ssCipherRequired": "Для Shadowsocks требуется шифр и пароль",
"selectType": "Выберите тип прокси",
"saveFailed": "Не удалось сохранить прокси: {{error}}",
- "vlessUri": "URI VLESS",
- "vlessUriPlaceholder": "vless://…",
- "vlessUriHint": "Требуются XTLS Vision и REALITY.",
- "vlessUriRequired": "Введите URI VLESS.",
- "vlessUriInvalid": "Введите корректный URI VLESS с адресом и портом.",
+ "vlessUri": "Ссылка для подключения",
+ "vlessUriPlaceholder": "{{scheme}}://…",
+ "vlessUriHint": "Вставьте полную ссылку от провайдера. В ней есть сервер, учётные данные и транспорт.",
+ "vlessUriRequired": "Введите ссылку для подключения.",
+ "vlessUriInvalid": "Введите корректную ссылку для подключения с хостом и портом.",
"firstHopGroupEncrypted": "Первый узел зашифрован",
+ "firstHopGroupLink": "Первый узел задаёт ссылка",
"firstHopGroupPlaintext": "Первый узел без шифрования",
"firstHopGroupCipher": "Первый узел зависит от шифра",
"firstHopCipherNote": "Будет ли соединение с этого компьютера до прокси зашифровано, решает шифр ниже. Пустой шифр или пустышка вроде none оставляет трафик читаемым для провайдера и для любого в сети между ними.",
+ "firstHopLinkNote": "Будет ли соединение с этого компьютера до прокси зашифровано, решает ссылка для подключения: TLS, REALITY или собственное шифрование протокола. Ссылка без них оставляет трафик читаемым для провайдера и для любого в сети между ними.",
"firstHopEncryptedNote": "Соединение с этого компьютера до прокси зашифровано. Провайдер всё равно видит каждый сайт, к которому вы обращаетесь, и всё содержимое страниц без HTTPS.",
"firstHopPlaintextNote": "Соединение с этого компьютера до прокси не зашифровано. Провайдер и любой в сети между этим компьютером и прокси может прочитать трафик.",
"credentialsInClearHeading": "Эти учётные данные проходят по сети в читаемом виде",
@@ -604,7 +606,10 @@
"socks5": "SOCKS5",
"ss": "Shadowsocks",
"httpstls": "HTTP over TLS",
- "vless": "VLESS (REALITY)"
+ "vless": "VLESS",
+ "vmess": "VMess",
+ "trojan": "Trojan",
+ "hysteria2": "Hysteria2"
},
"tabs": {
"regular": "Обычный",
@@ -2238,9 +2243,9 @@
"mcpAgentUnknown": "Этот клиент MCP не поддерживается.",
"mcpAgentInstallFailed": "Не удалось добавить Donut Browser в клиент MCP: {{detail}}",
"mcpAgentRemoveFailed": "Не удалось удалить Donut Browser из клиента MCP: {{detail}}",
- "vlessConfigInvalid": "URI VLESS недействителен.",
+ "vlessConfigInvalid": "Ссылка для подключения недействительна.",
"xrayUnavailable": "Xray-core недоступен в этой системе.",
- "xrayUnsupportedOs": "Для VLESS требуется macOS 12 или новее.",
+ "xrayUnsupportedOs": "Для прокси VLESS, VMess, Trojan и Hysteria2 нужна macOS 12 или новее.",
"xrayStartFailed": "Не удалось запустить Xray-core.",
"cloudNotSignedIn": "Войдите в аккаунт Donut Browser, чтобы использовать эту функцию.",
"cloudUnreachable": "Не удалось связаться с серверами Donut Browser. Проверьте подключение и попробуйте снова.",
@@ -2285,17 +2290,18 @@
"vpnWorkerStartFailed": "Не удалось запустить VPN-подключение: {{detail}}",
"exitProbeFailed": "Не удалось связаться с выходным узлом прокси, чтобы определить его местоположение.",
"vlessUnsupported": {
- "security": "Этот сервер VLESS не использует REALITY. Donut поддерживает только VLESS с REALITY.",
- "flow": "Этот сервер VLESS не использует поток XTLS Vision, который требуется Donut.",
- "transport": "Donut поддерживает VLESS только поверх обычного TCP — этот сервер использует другой транспорт (например, WebSocket или gRPC).",
- "encryption": "Этот сервер VLESS использует шифрование, которое Donut не поддерживает.",
- "headerType": "Этот сервер VLESS использует обфускацию заголовков, которую Donut не поддерживает.",
- "fingerprint": "Этот VLESS URI запрашивает отпечаток TLS, который Donut не поддерживает.",
- "sni": "В VLESS URI отсутствует SNI (sni), необходимый для REALITY.",
- "publicKey": "В VLESS URI отсутствует открытый ключ REALITY (pbk).",
- "scheme": "Это не ссылка VLESS. Она должна начинаться с vless://.",
- "parameter": "VLESS URI содержит параметр, который Donut не поддерживает.",
- "malformed": "VLESS URI недействителен."
+ "security": "Эта ссылка использует настройку безопасности, которую Donut не поддерживает. Donut поддерживает none, TLS и REALITY, причём REALITY только поверх RAW (TCP), gRPC или XHTTP.",
+ "flow": "Эта ссылка использует flow, который Donut не поддерживает. XTLS Vision работает только с VLESS поверх RAW (TCP) с TLS или REALITY.",
+ "transport": "Эта ссылка использует транспорт или режим транспорта, который Donut не поддерживает. Donut поддерживает RAW (TCP), WebSocket, gRPC, HTTPUpgrade и XHTTP, но не mKCP, HTTP/2 и QUIC.",
+ "encryption": "Эта ссылка использует настройку шифрования, которую Donut не поддерживает.",
+ "headerType": "Эта ссылка использует маскировку заголовков, которую Donut не поддерживает.",
+ "fingerprint": "Эта ссылка запрашивает отпечаток TLS, который Donut не поддерживает.",
+ "sni": "В ссылке нет корректного имени сервера (sni).",
+ "publicKey": "В ссылке нет открытого ключа REALITY (pbk).",
+ "obfs": "Эта ссылка Hysteria2 использует обфускацию, которую Donut не поддерживает. Donut поддерживает только Salamander.",
+ "scheme": "Это не поддерживаемая ссылка для подключения. Она должна начинаться с vless://, vmess://, trojan://, hysteria2:// или hy2://.",
+ "parameter": "Ссылка содержит параметр, который Donut не поддерживает.",
+ "malformed": "Ссылка для подключения недействительна."
},
"camoufoxRemoved": "Camoufox больше не поддерживается. Создайте этот профиль заново с Wayfern.",
"noE2ePasswordSet": "Пароль сквозного шифрования не задан. Задайте его перед синхронизацией зашифрованных данных.",
diff --git a/src/i18n/locales/tr.json b/src/i18n/locales/tr.json
index 54eeef3b..bfdcb927 100644
--- a/src/i18n/locales/tr.json
+++ b/src/i18n/locales/tr.json
@@ -576,15 +576,17 @@
"ssCipherRequired": "Shadowsocks için şifreleme algoritması ve parola zorunludur",
"selectType": "Proxy türünü seçin",
"saveFailed": "Proxy kaydedilemedi: {{error}}",
- "vlessUri": "VLESS URI'si",
- "vlessUriPlaceholder": "vless://…",
- "vlessUriHint": "XTLS Vision ve REALITY gerektirir.",
- "vlessUriRequired": "Bir VLESS URI'si girin.",
- "vlessUriInvalid": "Sunucu ve bağlantı noktası içeren geçerli bir VLESS URI'si girin.",
+ "vlessUri": "Paylaşım bağlantısı",
+ "vlessUriPlaceholder": "{{scheme}}://…",
+ "vlessUriHint": "Sağlayıcınızdan gelen bağlantının tamamını yapıştırın. Bağlantı sunucuyu, kimlik bilgilerini ve aktarımı içerir.",
+ "vlessUriRequired": "Bir paylaşım bağlantısı girin.",
+ "vlessUriInvalid": "Ana bilgisayar ve port içeren geçerli bir paylaşım bağlantısı girin.",
"firstHopGroupEncrypted": "İlk adım şifreli",
+ "firstHopGroupLink": "İlk adımı bağlantı belirler",
"firstHopGroupPlaintext": "İlk adım şifresiz",
"firstHopGroupCipher": "İlk adım şifreleme algoritmasına bağlı",
"firstHopCipherNote": "Bu makineden proxy'ye giden bağlantının şifreli olup olmadığına aşağıdaki şifreleme algoritması karar verir. Boş bir algoritma ya da none gibi hiçbir şey şifrelemeyen bir algoritma, trafiği sağlayıcı ve aradaki ağda bulunan herkes için okunabilir bırakır.",
+ "firstHopLinkNote": "Bu makineden proxy'ye giden bağlantının şifreli olup olmadığına paylaşım bağlantısı karar verir: TLS, REALITY ya da protokolün kendi şifrelemesi. Bunların hiçbirini içermeyen bir bağlantı, trafiği sağlayıcı ve aradaki ağda bulunan herkes için okunabilir bırakır.",
"firstHopEncryptedNote": "Bu makineden proxy'ye giden bağlantı şifrelidir. Sağlayıcı yine de eriştiğiniz her siteyi ve HTTPS olmayan sayfalardaki her şeyi görür.",
"firstHopPlaintextNote": "Bu makineden proxy'ye giden bağlantı şifreli değildir. Sağlayıcı ve bu makine ile proxy arasındaki ağdaki herkes trafiği okuyabilir.",
"credentialsInClearHeading": "Bu kimlik bilgileri ağ üzerinden okunabilir biçimde geçer",
@@ -600,7 +602,10 @@
"socks5": "SOCKS5",
"ss": "Shadowsocks",
"httpstls": "HTTP over TLS",
- "vless": "VLESS (REALITY)"
+ "vless": "VLESS",
+ "vmess": "VMess",
+ "trojan": "Trojan",
+ "hysteria2": "Hysteria2"
},
"tabs": {
"regular": "Normal",
@@ -2218,9 +2223,9 @@
"mcpAgentUnknown": "Bu MCP istemcisi desteklenmiyor.",
"mcpAgentInstallFailed": "Donut Browser MCP istemcisine eklenemedi: {{detail}}",
"mcpAgentRemoveFailed": "Donut Browser MCP istemcisinden kaldırılamadı: {{detail}}",
- "vlessConfigInvalid": "VLESS URI'si geçersiz.",
+ "vlessConfigInvalid": "Paylaşım bağlantısı geçersiz.",
"xrayUnavailable": "Xray-core bu sistemde kullanılamıyor.",
- "xrayUnsupportedOs": "VLESS için macOS 12 veya sonraki bir sürüm gerekir.",
+ "xrayUnsupportedOs": "VLESS, VMess, Trojan ve Hysteria2 proxy'leri macOS 12 veya daha yenisini gerektirir.",
"xrayStartFailed": "Xray-core başlatılamadı.",
"cloudNotSignedIn": "Bunu kullanmak için Donut Browser hesabınıza giriş yapın.",
"cloudUnreachable": "Donut Browser sunucularına ulaşılamadı. Bağlantınızı kontrol edip tekrar deneyin.",
@@ -2265,17 +2270,18 @@
"vpnWorkerStartFailed": "VPN bağlantısı başlatılamadı: {{detail}}",
"exitProbeFailed": "Konumunu denetlemek için proxy çıkış düğümüne ulaşılamadı.",
"vlessUnsupported": {
- "security": "Bu VLESS sunucusu REALITY kullanmıyor. Donut yalnızca REALITY ile VLESS'i destekler.",
- "flow": "Bu VLESS sunucusu, Donut'ın gerektirdiği XTLS Vision akışını kullanmıyor.",
- "transport": "Donut yalnızca düz TCP üzerinden VLESS'i destekler — bu sunucu farklı bir taşıma (WebSocket veya gRPC gibi) kullanıyor.",
- "encryption": "Bu VLESS sunucusu, Donut'ın desteklemediği bir şifreleme kullanıyor.",
- "headerType": "Bu VLESS sunucusu, Donut'ın desteklemediği bir başlık gizlemesi kullanıyor.",
- "fingerprint": "Bu VLESS URI'si, Donut'ın desteklemediği bir TLS parmak izi istiyor.",
- "sni": "VLESS URI'sinde REALITY için gereken SNI (sni) eksik.",
- "publicKey": "VLESS URI'sinde REALITY genel anahtarı (pbk) eksik.",
- "scheme": "Bu bir VLESS bağlantısı değil. vless:// ile başlamalı.",
- "parameter": "VLESS URI'si, Donut'ın desteklemediği bir seçenek içeriyor.",
- "malformed": "VLESS URI'si geçersiz."
+ "security": "Bu bağlantı, Donut'un desteklemediği bir güvenlik ayarı kullanıyor. Donut none, TLS ve REALITY'yi destekler; REALITY yalnızca RAW (TCP), gRPC veya XHTTP üzerinde çalışır.",
+ "flow": "Bu bağlantı, Donut'un desteklemediği bir flow kullanıyor. XTLS Vision yalnızca TLS veya REALITY ile RAW (TCP) üzerinden VLESS ile çalışır.",
+ "transport": "Bu bağlantı, Donut'un desteklemediği bir aktarım veya aktarım modu kullanıyor. Donut RAW (TCP), WebSocket, gRPC, HTTPUpgrade ve XHTTP'yi destekler; mKCP, HTTP/2 ve QUIC'i desteklemez.",
+ "encryption": "Bu bağlantı, Donut'un desteklemediği bir şifreleme ayarı kullanıyor.",
+ "headerType": "Bu bağlantı, Donut'un desteklemediği bir başlık gizlemesi kullanıyor.",
+ "fingerprint": "Bu bağlantı, Donut'un desteklemediği bir TLS parmak izi istiyor.",
+ "sni": "Bağlantıda geçerli bir sunucu adı (sni) yok.",
+ "publicKey": "Bağlantıda REALITY açık anahtarı (pbk) yok.",
+ "obfs": "Bu Hysteria2 bağlantısı, Donut'un desteklemediği bir gizleme kullanıyor. Donut yalnızca Salamander'ı destekler.",
+ "scheme": "Bu desteklenen bir paylaşım bağlantısı değil. vless://, vmess://, trojan://, hysteria2:// veya hy2:// ile başlamalı.",
+ "parameter": "Bağlantı, Donut'un desteklemediği bir seçenek içeriyor.",
+ "malformed": "Paylaşım bağlantısı geçersiz."
},
"camoufoxRemoved": "Camoufox artık desteklenmiyor. Bu profili Wayfern ile yeniden oluşturun.",
"noE2ePasswordSet": "Uçtan uca şifreleme parolası ayarlanmamış. Şifreli veriyi eşitlemeden önce bir parola belirleyin.",
diff --git a/src/i18n/locales/vi.json b/src/i18n/locales/vi.json
index b9fe2441..f2bb01d5 100644
--- a/src/i18n/locales/vi.json
+++ b/src/i18n/locales/vi.json
@@ -576,15 +576,17 @@
"ssCipherRequired": "Cipher và mật khẩu là bắt buộc cho Shadowsocks",
"selectType": "Chọn loại proxy",
"saveFailed": "Lưu proxy thất bại: {{error}}",
- "vlessUri": "URI VLESS",
- "vlessUriPlaceholder": "vless://…",
- "vlessUriHint": "Yêu cầu XTLS Vision và REALITY.",
- "vlessUriRequired": "Nhập URI VLESS.",
- "vlessUriInvalid": "Nhập URI VLESS hợp lệ có máy chủ và cổng.",
+ "vlessUri": "Liên kết chia sẻ",
+ "vlessUriPlaceholder": "{{scheme}}://…",
+ "vlessUriHint": "Dán toàn bộ liên kết từ nhà cung cấp. Liên kết chứa máy chủ, thông tin xác thực và phương thức truyền tải.",
+ "vlessUriRequired": "Nhập một liên kết chia sẻ.",
+ "vlessUriInvalid": "Nhập một liên kết chia sẻ hợp lệ có host và cổng.",
"firstHopGroupEncrypted": "Chặng đầu được mã hóa",
+ "firstHopGroupLink": "Chặng đầu do liên kết quyết định",
"firstHopGroupPlaintext": "Chặng đầu không mã hóa",
"firstHopGroupCipher": "Chặng đầu tùy thuộc vào cipher",
"firstHopCipherNote": "Kết nối từ máy này đến proxy có được mã hóa hay không là do cipher bên dưới quyết định. Cipher để trống, hoặc một cipher rỗng như none, khiến lưu lượng vẫn đọc được với nhà cung cấp và với bất kỳ ai trên đường mạng ở giữa.",
+ "firstHopLinkNote": "Kết nối từ máy này đến proxy có được mã hóa hay không là do liên kết chia sẻ quyết định: TLS, REALITY hoặc mã hóa riêng của giao thức. Liên kết không có những thứ đó khiến lưu lượng vẫn đọc được với nhà cung cấp và với bất kỳ ai trên đường mạng ở giữa.",
"firstHopEncryptedNote": "Kết nối từ máy này đến proxy được mã hóa. Nhà cung cấp vẫn thấy mọi trang bạn truy cập và toàn bộ nội dung của các trang không dùng HTTPS.",
"firstHopPlaintextNote": "Kết nối từ máy này đến proxy không được mã hóa. Nhà cung cấp, và bất kỳ ai trên đường mạng giữa máy này và proxy, đều đọc được lưu lượng.",
"credentialsInClearHeading": "Thông tin đăng nhập này đi qua mạng ở dạng đọc được",
@@ -600,7 +602,10 @@
"socks5": "SOCKS5",
"ss": "Shadowsocks",
"httpstls": "HTTP over TLS",
- "vless": "VLESS (REALITY)"
+ "vless": "VLESS",
+ "vmess": "VMess",
+ "trojan": "Trojan",
+ "hysteria2": "Hysteria2"
},
"tabs": {
"regular": "Thông thường",
@@ -2218,9 +2223,9 @@
"mcpAgentUnknown": "Ứng dụng MCP này không được hỗ trợ.",
"mcpAgentInstallFailed": "Không thể thêm Donut Browser vào ứng dụng MCP: {{detail}}",
"mcpAgentRemoveFailed": "Không thể xóa Donut Browser khỏi ứng dụng MCP: {{detail}}",
- "vlessConfigInvalid": "URI VLESS không hợp lệ.",
+ "vlessConfigInvalid": "Liên kết chia sẻ không hợp lệ.",
"xrayUnavailable": "Xray-core không khả dụng trên hệ thống này.",
- "xrayUnsupportedOs": "VLESS yêu cầu macOS 12 trở lên.",
+ "xrayUnsupportedOs": "Proxy VLESS, VMess, Trojan và Hysteria2 yêu cầu macOS 12 trở lên.",
"xrayStartFailed": "Không thể khởi động Xray-core.",
"cloudNotSignedIn": "Hãy đăng nhập tài khoản Donut Browser để dùng tính năng này.",
"cloudUnreachable": "Không kết nối được tới máy chủ Donut Browser. Hãy kiểm tra kết nối và thử lại.",
@@ -2265,17 +2270,18 @@
"vpnWorkerStartFailed": "Không thể khởi động kết nối VPN: {{detail}}",
"exitProbeFailed": "Không thể kết nối tới nút thoát của proxy để kiểm tra vị trí.",
"vlessUnsupported": {
- "security": "Máy chủ VLESS này không dùng REALITY. Donut chỉ hỗ trợ VLESS kèm REALITY.",
- "flow": "Máy chủ VLESS này không dùng luồng XTLS Vision mà Donut yêu cầu.",
- "transport": "Donut chỉ hỗ trợ VLESS trên TCP thuần — máy chủ này dùng phương thức truyền khác (như WebSocket hoặc gRPC).",
- "encryption": "Máy chủ VLESS này dùng thiết lập mã hóa mà Donut không hỗ trợ.",
- "headerType": "Máy chủ VLESS này dùng cách che giấu tiêu đề mà Donut không hỗ trợ.",
- "fingerprint": "URI VLESS này yêu cầu một dấu vân tay TLS mà Donut không hỗ trợ.",
- "sni": "URI VLESS thiếu SNI (sni) cần cho REALITY.",
- "publicKey": "URI VLESS thiếu khóa công khai REALITY (pbk).",
- "scheme": "Đây không phải liên kết VLESS. Nó phải bắt đầu bằng vless://.",
- "parameter": "URI VLESS chứa một tùy chọn mà Donut không hỗ trợ.",
- "malformed": "URI VLESS không hợp lệ."
+ "security": "Liên kết này dùng một thiết lập bảo mật mà Donut không hỗ trợ. Donut hỗ trợ none, TLS và REALITY, và REALITY chỉ qua RAW (TCP), gRPC hoặc XHTTP.",
+ "flow": "Liên kết này dùng một flow mà Donut không hỗ trợ. XTLS Vision chỉ hoạt động với VLESS qua RAW (TCP) kèm TLS hoặc REALITY.",
+ "transport": "Liên kết này dùng một phương thức truyền tải, hoặc một chế độ truyền tải, mà Donut không hỗ trợ. Donut hỗ trợ RAW (TCP), WebSocket, gRPC, HTTPUpgrade và XHTTP, nhưng không hỗ trợ mKCP, HTTP/2 hay QUIC.",
+ "encryption": "Liên kết này dùng một thiết lập mã hóa mà Donut không hỗ trợ.",
+ "headerType": "Liên kết này dùng một kiểu làm rối header mà Donut không hỗ trợ.",
+ "fingerprint": "Liên kết này yêu cầu một dấu vân tay TLS mà Donut không hỗ trợ.",
+ "sni": "Liên kết thiếu tên máy chủ (sni) hợp lệ.",
+ "publicKey": "Liên kết thiếu khóa công khai REALITY (pbk).",
+ "obfs": "Liên kết Hysteria2 này dùng một kiểu làm rối mà Donut không hỗ trợ. Donut chỉ hỗ trợ Salamander.",
+ "scheme": "Đây không phải liên kết chia sẻ được hỗ trợ. Nó phải bắt đầu bằng vless://, vmess://, trojan://, hysteria2:// hoặc hy2://.",
+ "parameter": "Liên kết chứa một tùy chọn mà Donut không hỗ trợ.",
+ "malformed": "Liên kết chia sẻ không hợp lệ."
},
"camoufoxRemoved": "Camoufox không còn được hỗ trợ. Hãy tạo lại hồ sơ này bằng Wayfern.",
"noE2ePasswordSet": "Chưa đặt mật khẩu mã hóa đầu cuối. Hãy đặt trước khi đồng bộ dữ liệu đã mã hóa.",
diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json
index 0c9b1034..f674b366 100644
--- a/src/i18n/locales/zh.json
+++ b/src/i18n/locales/zh.json
@@ -576,15 +576,17 @@
"ssCipherRequired": "Shadowsocks 需要密码学和密码",
"selectType": "选择代理类型",
"saveFailed": "保存代理失败: {{error}}",
- "vlessUri": "VLESS URI",
- "vlessUriPlaceholder": "vless://…",
- "vlessUriHint": "需要 XTLS Vision 和 REALITY。",
- "vlessUriRequired": "请输入 VLESS URI。",
- "vlessUriInvalid": "请输入包含主机和端口的有效 VLESS URI。",
+ "vlessUri": "分享链接",
+ "vlessUriPlaceholder": "{{scheme}}://…",
+ "vlessUriHint": "粘贴服务商提供的完整链接。链接中包含服务器、凭据和传输方式。",
+ "vlessUriRequired": "请输入分享链接。",
+ "vlessUriInvalid": "请输入包含主机和端口的有效分享链接。",
"firstHopGroupEncrypted": "第一跳已加密",
+ "firstHopGroupLink": "第一跳由链接决定",
"firstHopGroupPlaintext": "第一跳未加密",
"firstHopGroupCipher": "第一跳取决于加密方式",
"firstHopCipherNote": "本机到代理之间的连接是否加密,由下方的加密方式决定。加密方式留空,或选用 none 这类不加密的方式,流量对服务商以及中间网络上的任何人都是可读的。",
+ "firstHopLinkNote": "本机到代理之间的连接是否加密,由分享链接决定:TLS、REALITY 或协议自身的加密。链接中都没有时,流量对服务商以及中间网络上的任何人都是可读的。",
"firstHopEncryptedNote": "本机到代理之间的连接已加密。但服务商仍然能看到你访问的每一个网站,以及所有非 HTTPS 页面的全部内容。",
"firstHopPlaintextNote": "本机到代理之间的连接未加密。服务商,以及本机与代理之间网络上的任何人,都能读取流量。",
"credentialsInClearHeading": "这些凭据会以可读形式经过网络",
@@ -600,7 +602,10 @@
"socks5": "SOCKS5",
"ss": "Shadowsocks",
"httpstls": "HTTP over TLS",
- "vless": "VLESS (REALITY)"
+ "vless": "VLESS",
+ "vmess": "VMess",
+ "trojan": "Trojan",
+ "hysteria2": "Hysteria2"
},
"tabs": {
"regular": "常规",
@@ -2218,9 +2223,9 @@
"mcpAgentUnknown": "不支持此 MCP 客户端。",
"mcpAgentInstallFailed": "无法将 Donut Browser 添加到 MCP 客户端:{{detail}}",
"mcpAgentRemoveFailed": "无法从 MCP 客户端移除 Donut Browser:{{detail}}",
- "vlessConfigInvalid": "VLESS URI 无效。",
+ "vlessConfigInvalid": "分享链接无效。",
"xrayUnavailable": "此系统无法使用 Xray-core。",
- "xrayUnsupportedOs": "VLESS 需要 macOS 12 或更高版本。",
+ "xrayUnsupportedOs": "VLESS、VMess、Trojan 和 Hysteria2 代理需要 macOS 12 或更高版本。",
"xrayStartFailed": "无法启动 Xray-core。",
"cloudNotSignedIn": "请先登录你的 Donut Browser 账号再使用此功能。",
"cloudUnreachable": "无法连接 Donut Browser 服务器。请检查网络后重试。",
@@ -2265,17 +2270,18 @@
"vpnWorkerStartFailed": "无法启动 VPN 连接:{{detail}}",
"exitProbeFailed": "无法连接代理出口节点以检查其位置。",
"vlessUnsupported": {
- "security": "此 VLESS 服务器未使用 REALITY。Donut 仅支持搭配 REALITY 的 VLESS。",
- "flow": "此 VLESS 服务器未使用 Donut 所需的 XTLS Vision 流控。",
- "transport": "Donut 仅支持基于普通 TCP 的 VLESS —— 此服务器使用了其他传输方式(如 WebSocket 或 gRPC)。",
- "encryption": "此 VLESS 服务器使用了 Donut 不支持的加密设置。",
- "headerType": "此 VLESS 服务器使用了 Donut 不支持的头部混淆。",
- "fingerprint": "此 VLESS URI 请求了 Donut 不支持的 TLS 指纹。",
- "sni": "VLESS URI 缺少 REALITY 所需的 SNI(sni)。",
- "publicKey": "VLESS URI 缺少 REALITY 公钥(pbk)。",
- "scheme": "这不是 VLESS 链接,必须以 vless:// 开头。",
- "parameter": "VLESS URI 含有 Donut 不支持的选项。",
- "malformed": "VLESS URI 无效。"
+ "security": "此链接使用了 Donut 不支持的安全设置。Donut 支持 none、TLS 和 REALITY,且 REALITY 只能用于 RAW (TCP)、gRPC 或 XHTTP。",
+ "flow": "此链接使用了 Donut 不支持的 flow。XTLS Vision 只能用于搭配 TLS 或 REALITY 的 RAW (TCP) 上的 VLESS。",
+ "transport": "此链接使用了 Donut 不支持的传输方式或传输模式。Donut 支持 RAW (TCP)、WebSocket、gRPC、HTTPUpgrade 和 XHTTP,不支持 mKCP、HTTP/2 和 QUIC。",
+ "encryption": "此链接使用了 Donut 不支持的加密设置。",
+ "headerType": "此链接使用了 Donut 不支持的头部伪装。",
+ "fingerprint": "此链接要求的 TLS 指纹 Donut 不支持。",
+ "sni": "链接缺少有效的服务器名称 (sni)。",
+ "publicKey": "链接缺少 REALITY 公钥 (pbk)。",
+ "obfs": "此 Hysteria2 链接使用了 Donut 不支持的混淆方式。Donut 仅支持 Salamander。",
+ "scheme": "这不是受支持的分享链接,必须以 vless://、vmess://、trojan://、hysteria2:// 或 hy2:// 开头。",
+ "parameter": "链接中包含 Donut 不支持的选项。",
+ "malformed": "分享链接无效。"
},
"camoufoxRemoved": "Camoufox 已不再受支持。请使用 Wayfern 重新创建此配置文件。",
"noE2ePasswordSet": "尚未设置端到端加密密码。请先设置后再同步加密数据。",
diff --git a/src/lib/backend-errors.ts b/src/lib/backend-errors.ts
index f092406f..10438ea4 100644
--- a/src/lib/backend-errors.ts
+++ b/src/lib/backend-errors.ts
@@ -563,9 +563,10 @@ export function translateBackendError(t: TFunction, err: unknown): string {
return t("backendErrors.mcpAgentRemoveFailed", {
detail: parsed.params?.detail ?? "",
});
- // Donut supports exactly one VLESS shape (REALITY + XTLS Vision over TCP),
- // so most rejections mean "your server is a kind we do not support", not
- // "you mistyped". Name the unsupported part instead of implying a typo.
+ // Any Xray share link (VLESS, VMess, Trojan, Hysteria2); the code keeps its
+ // old name because REST clients match on it. Most rejections mean "your
+ // server is a kind we do not support", not "you mistyped". Name the
+ // unsupported part instead of implying a typo.
case "VLESS_CONFIG_INVALID": {
const reason = parsed.params?.reason;
const known = [
@@ -577,6 +578,7 @@ export function translateBackendError(t: TFunction, err: unknown): string {
"fingerprint",
"sni",
"publicKey",
+ "obfs",
"scheme",
"parameter",
"malformed",
diff --git a/src/lib/proxy-string.test.mjs b/src/lib/proxy-string.test.mjs
index 954c241a..c5f00a90 100644
--- a/src/lib/proxy-string.test.mjs
+++ b/src/lib/proxy-string.test.mjs
@@ -4,6 +4,8 @@ import {
CREDENTIALS_FIRST_FORMAT,
HOST_FIRST_FORMAT,
isFirstHopEncrypted,
+ isShareLinkEncrypted,
+ isXrayProxyType,
pickParsedProxy,
proxyProtocolToken,
resolveAmbiguousProxyLine,
@@ -11,6 +13,11 @@ import {
} from "./proxy-string.ts";
import { canonicalProxyType } from "./proxy-type.ts";
+const ID = "6d6e21a1-4829-4d2b-bc7f-1b25707b61e4";
+const TLS_LINK = `vless://${ID}@a.example.com:443?security=tls&type=ws`;
+const vmess = (fields) =>
+ `vmess://${Buffer.from(JSON.stringify(fields)).toString("base64")}`;
+
/**
* The formats themselves are exercised in Rust
* (`proxy_manager::tests::test_proxy_txt_parsing_various_formats`). What is
@@ -207,7 +214,7 @@ test("the TLS-wrapped scheme survives a paste", () => {
test("only the types that actually encrypt the first hop say they do", () => {
// `ss` is deliberately NOT in this list: its answer depends on the cipher,
// and a type-only query carries no cipher, so it cannot claim encryption.
- for (const encrypted of ["httpstls", "vless", "HTTPSTLS", "VLESS"]) {
+ for (const encrypted of ["httpstls", "HTTPSTLS"]) {
assert.equal(isFirstHopEncrypted(encrypted), true, encrypted);
}
assert.equal(isFirstHopEncrypted("ss", "aes-256-gcm"), true);
@@ -241,8 +248,70 @@ test("only the types that actually encrypt the first hop say they do", () => {
}
// An unspecified cipher is not a claim of safety either.
assert.equal(isFirstHopEncrypted("ss", null), false);
- // Types whose encryption does not depend on a cipher are unaffected.
- assert.equal(isFirstHopEncrypted("vless", null), true);
+ // An Xray type answers from its share link, never from a cipher.
+ assert.equal(isFirstHopEncrypted("vless", null, TLS_LINK), true);
+ assert.equal(isFirstHopEncrypted("vless", "aes-256-gcm"), false);
+});
+
+test("an Xray share link decides whether its first hop is encrypted", () => {
+ const cases = [
+ // Plain VLESS is the "isolation only" setup, and it is in the clear.
+ [`vless://${ID}@a.example.com:80?security=none&type=tcp`, false],
+ [`vless://${ID}@a.example.com:80?type=tcp`, false],
+ [TLS_LINK, true],
+ [`vless://${ID}@a.example.com:443?security=REALITY&pbk=k`, true],
+ // Trojan is TLS unless the link says otherwise.
+ ["trojan://pw@a.example.com:443?peer=a.example.com#x", true],
+ ["trojan://pw@a.example.com:80?security=none", false],
+ // VMess encrypts with its own cipher even without TLS, unless the cipher
+ // is one of the two that do not.
+ [vmess({ add: "a.example.com", port: "443", id: ID, tls: "tls" }), true],
+ [vmess({ add: "a.example.com", port: "80", id: ID, scy: "auto" }), true],
+ [vmess({ add: "a.example.com", port: "80", id: ID, scy: "none" }), false],
+ [vmess({ add: "a.example.com", port: "80", id: ID, scy: "zero" }), false],
+ [`vmess://${ID}@a.example.com:80?encryption=zero`, false],
+ [`vmess://${ID}@a.example.com:80?encryption=aes-128-gcm`, true],
+ // Hysteria2 is QUIC, which is always TLS 1.3.
+ ["hysteria2://pw@a.example.com:443/?sni=a.example.com", true],
+ ["HY2://pw@a.example.com", true],
+ // A second `?` inside a WebSocket path must not hide a later option.
+ [
+ "trojan://pw@a.example.com:80?type=ws&path=/ws?ed=2048&security=none",
+ false,
+ ],
+ [`vmess://${ID}@a.example.com:80?path=/v?ed=1&encryption=none`, false],
+ // VMess JSON values are trimmed, and `encryption` stands in for `scy`.
+ [vmess({ add: "a.example.com", port: "80", id: ID, scy: " none " }), false],
+ [
+ vmess({ add: "a.example.com", port: "80", id: ID, encryption: "none" }),
+ false,
+ ],
+ [vmess({ add: "a.example.com", port: "443", id: ID, tls: " TLS " }), true],
+ // Unreadable is not a claim of safety.
+ ["vmess://not-base64!", false],
+ ["vmess://bnVsbA==", false],
+ ["", false],
+ ["a.example.com:443", false],
+ ];
+ for (const [link, encrypted] of cases) {
+ assert.equal(isShareLinkEncrypted(link), encrypted, link);
+ }
+ assert.equal(isFirstHopEncrypted("trojan", null, cases[4][0]), true);
+ assert.equal(isFirstHopEncrypted("vless", null, ""), false);
+ assert.equal(isFirstHopEncrypted("hy2", null, cases[12][0]), true);
+});
+
+test("the Xray types are the ones configured by a share link", () => {
+ for (const type of ["vless", "VMess", " trojan ", "hysteria2", "hy2"]) {
+ assert.equal(isXrayProxyType(type), true, type);
+ }
+ for (const type of ["ss", "socks5", "http", "", "__proto__"]) {
+ assert.equal(isXrayProxyType(type), false, type);
+ }
+ assert.equal(
+ splitProxyScheme("hy2://pw@a.example.com").proxyType,
+ "hysteria2",
+ );
});
test("a padded stored type answers the same as an unpadded one", () => {
@@ -256,7 +325,7 @@ test("a padded stored type answers the same as an unpadded one", () => {
assert.equal(isFirstHopEncrypted("ss ", "aes-256-gcm"), true);
assert.equal(isFirstHopEncrypted(" shadowsocks", "aes-256-gcm"), true);
assert.equal(isFirstHopEncrypted("\thttpstls\n"), true);
- assert.equal(isFirstHopEncrypted(" VLESS "), true);
+ assert.equal(isFirstHopEncrypted(" VLESS ", null, TLS_LINK), true);
// Padding does not buy encryption either: the cipher still decides, and the
// types that never encrypt still never do.
assert.equal(isFirstHopEncrypted(" ss ", ""), false);
@@ -282,6 +351,8 @@ test("the first-hop answer never disagrees with the canonical type", () => {
" HTTPSTLS ",
"vless",
" VLess ",
+ "hy2",
+ " Hysteria2 ",
"http",
" HTTPS ",
"socks5",
diff --git a/src/lib/proxy-string.ts b/src/lib/proxy-string.ts
index 1bcd1737..f2d91918 100644
--- a/src/lib/proxy-string.ts
+++ b/src/lib/proxy-string.ts
@@ -34,8 +34,30 @@ const PROXY_SCHEMES = new Map([
["ss", "ss"],
["shadowsocks", "ss"],
["vless", "vless"],
+ ["vmess", "vmess"],
+ ["trojan", "trojan"],
+ ["hysteria2", "hysteria2"],
+ ["hy2", "hysteria2"],
]);
+/**
+ * Types an Xray-core sidecar carries. Their whole configuration is the share
+ * link stored in `vless_uri`, a name from before VMess, Trojan and Hysteria2
+ * joined VLESS.
+ */
+const XRAY_PROXY_TYPES = new Set([
+ "vless",
+ "vmess",
+ "trojan",
+ "hysteria2",
+ "hy2",
+]);
+
+/** Whether a stored `proxy_type` is configured by a share link. */
+export function isXrayProxyType(proxyType: string): boolean {
+ return XRAY_PROXY_TYPES.has(normalizeProxyType(proxyType));
+}
+
/**
* Protocol identifiers, shortened where the raw stored type would not fit the
* management table's column. Not translated: these are wire protocol names,
@@ -98,7 +120,6 @@ const ENCRYPTED_FIRST_HOP = new Set([
// clear, the same class of wrong claim this feature exists to remove, only
// inverted.
"shadowsocks",
- "vless",
]);
/**
@@ -123,8 +144,10 @@ const NULL_CIPHERS = new Set(["none", "plain"]);
export function isFirstHopEncrypted(
proxyType: string,
cipher?: string | null,
+ shareLink?: string | null,
): boolean {
const type = normalizeProxyType(proxyType);
+ if (XRAY_PROXY_TYPES.has(type)) return isShareLinkEncrypted(shareLink ?? "");
if (!ENCRYPTED_FIRST_HOP.has(type)) return false;
if (type === "ss" || type === "shadowsocks") {
// Fail closed. An absent cipher is not evidence of encryption, and the
@@ -137,6 +160,75 @@ export function isFirstHopEncrypted(
return true;
}
+const NULL_VMESS_CIPHERS = new Set(["none", "zero"]);
+
+/**
+ * Whether an Xray share link encrypts the hop to its server.
+ *
+ * The link decides, not the protocol: plain VLESS (`security=none`) carries
+ * its payload in the clear, and so does VMess with the `none` or `zero`
+ * cipher. Hysteria2 always runs over QUIC, which is TLS 1.3. A link this cannot
+ * read is reported as not encrypted, for the same fail-closed reason as the
+ * Shadowsocks cipher above.
+ */
+export function isShareLinkEncrypted(shareLink: string): boolean {
+ const link = shareLink.trim();
+ const separator = link.indexOf("://");
+ if (separator === -1) return false;
+ const scheme = link.slice(0, separator).toLowerCase();
+ const body = link.slice(separator + 3).split("#")[0] ?? "";
+ if (scheme === "hysteria2" || scheme === "hy2") return true;
+
+ if (scheme === "vmess" && !body.includes("@")) {
+ const json = decodeVmessJson(body);
+ if (!json) return false;
+ // Read as `uri.rs` reads it: values trimmed, and `encryption` taken as
+ // the cipher when `scy` is absent.
+ const field = (key: string) =>
+ String(json[key] ?? "")
+ .trim()
+ .toLowerCase();
+ const security = field("tls");
+ if (security === "tls" || security === "reality") return true;
+ const cipher = field("scy") || field("encryption") || "auto";
+ return !NULL_VMESS_CIPHERS.has(cipher);
+ }
+
+ // Everything after the FIRST `?` is the query. A WebSocket path such as
+ // `/ws?ed=2048` may carry a second one, and cutting there hid a later
+ // `security=none` and called a plaintext link encrypted.
+ const queryStart = body.indexOf("?");
+ const query = new URLSearchParams(
+ queryStart === -1 ? "" : body.slice(queryStart + 1),
+ );
+ const defaultSecurity = scheme === "trojan" ? "tls" : "none";
+ const security = (query.get("security") || defaultSecurity).toLowerCase();
+ if (security === "tls" || security === "reality") return true;
+ if (scheme === "vmess") {
+ const cipher = (query.get("encryption") || "auto").toLowerCase();
+ return !NULL_VMESS_CIPHERS.has(cipher);
+ }
+ return false;
+}
+
+/** v2rayN's `vmess://` body, or null when it is not one. */
+function decodeVmessJson(body: string): Record | null {
+ try {
+ const base64 = decodeURIComponent(body)
+ .replace(/\s/g, "")
+ .replace(/-/g, "+")
+ .replace(/_/g, "/");
+ const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "=");
+ const bytes = Uint8Array.from(atob(padded), (c) => c.charCodeAt(0));
+ const json: unknown = JSON.parse(new TextDecoder().decode(bytes));
+ return json !== null && typeof json === "object" && !Array.isArray(json)
+ ? (json as Record)
+ : null;
+ } catch {
+ return null;
+ }
+}
+
export const HOST_FIRST_FORMAT = "host:port:username:password";
export const CREDENTIALS_FIRST_FORMAT = "username:password:host:port";
diff --git a/src/lib/proxy-type.test.mjs b/src/lib/proxy-type.test.mjs
index 59272f7f..db26de2a 100644
--- a/src/lib/proxy-type.test.mjs
+++ b/src/lib/proxy-type.test.mjs
@@ -28,10 +28,22 @@ test("both Shadowsocks spellings answer as one type", () => {
});
test("every other type keeps its own identity", () => {
- for (const type of ["http", "https", "httpstls", "socks4", "socks5", "vless"])
+ for (const type of [
+ "http",
+ "https",
+ "httpstls",
+ "socks4",
+ "socks5",
+ "vless",
+ "vmess",
+ "trojan",
+ "hysteria2",
+ ])
assert.equal(canonicalProxyType(type), type);
assert.equal(canonicalProxyType("HTTPSTLS"), "httpstls");
assert.equal(canonicalProxyType("VLESS"), "vless");
+ // Hysteria clients export `hy2://`, and the backend stores it as hysteria2.
+ assert.equal(canonicalProxyType(" HY2 "), "hysteria2");
// `socks` is not `socks5` to `Url::scheme()`, so folding it here would have
// the UI describe a hop the Rust worker never dials.
assert.equal(canonicalProxyType("socks"), "socks");
@@ -145,6 +157,27 @@ test("Shadowsocks is not filed under a heading that promises encryption", () =>
);
});
+test("no Xray type is filed under a heading that promises encryption", () => {
+ // Until the backend accepts a share link, the note under the Select says the
+ // link decides. Under "First hop encrypted" that note contradicts the heading,
+ // the same way an empty Shadowsocks cipher did.
+ const group = (name) =>
+ source.match(new RegExp(`const ${name} = \\[([^\\]]*)\\]`))?.[1];
+ const always = group("ALWAYS_ENCRYPTED_FIRST_HOP_TYPES");
+ const link = group("LINK_DEPENDENT_FIRST_HOP_TYPES");
+ assert.ok(always && link, "the link-dependent group must exist");
+ for (const type of ["vless", "vmess", "trojan", "hysteria2"]) {
+ assert.ok(
+ !always.includes(`"${type}"`),
+ `${type} is under the encrypted heading`,
+ );
+ assert.ok(
+ link.includes(`"${type}"`),
+ `${type} is missing from the link group`,
+ );
+ }
+});
+
test("an empty cipher is described as undecided, and still fails closed", () => {
assert.match(
source,
@@ -177,8 +210,10 @@ test("an empty cipher is described as undecided, and still fails closed", () =>
2,
"cipherUndecided may only be declared and used to pick that one sentence",
);
- assert.ok(
- stripped.includes("isFirstHopEncrypted(canonicalType, form.username)"),
+ // The third argument is the share link, which decides for the Xray types.
+ assert.match(
+ stripped,
+ /isFirstHopEncrypted\(\s*canonicalType,\s*form\.username,/,
"the encryption answer must come from the same canonical type the field " +
"labels use, so a padded stored type cannot make them disagree",
);
diff --git a/src/lib/proxy-type.ts b/src/lib/proxy-type.ts
index 59cadc96..434cd394 100644
--- a/src/lib/proxy-type.ts
+++ b/src/lib/proxy-type.ts
@@ -34,7 +34,10 @@
* `Url::scheme()`) must stay distinct, or the UI describes a wire that does not
* exist.
*/
-const TYPE_ALIASES = new Map([["shadowsocks", "ss"]]);
+const TYPE_ALIASES = new Map([
+ ["shadowsocks", "ss"],
+ ["hy2", "hysteria2"],
+]);
/**
* The spelling the UI branches on, for a `proxy_type` that may carry any of