fix: improve VLESS link compatibility and add Reality repair script
Only encode SNI dots, keep pbk/sid raw, copy links via API, prefer xray keygen, and add repair-reality.sh for server-side fixes. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+25
-12
@@ -21,14 +21,12 @@ def load_env() -> dict[str, str]:
|
|||||||
return env
|
return env
|
||||||
|
|
||||||
|
|
||||||
def _qs(value: str) -> str:
|
def _sni_qs(value: str) -> str:
|
||||||
"""URL-encode query / userinfo values for share links."""
|
"""Encode dots in SNI; some clients misparsed sni=www.microsoft.com."""
|
||||||
# quote() leaves unreserved chars like '.' unencoded; some clients (v2rayNG)
|
return value.replace(".", "%2E")
|
||||||
# misparsed sni=www.microsoft.com — force dots encoded for compatibility.
|
|
||||||
return quote(value, safe="").replace(".", "%2E")
|
|
||||||
|
|
||||||
|
|
||||||
def build_links(node: dict, env: dict | None = None) -> dict[str, str]:
|
def build_links(node: dict, env: dict | None = None) -> dict[str, str | dict[str, str]]:
|
||||||
env = env or load_env()
|
env = env or load_env()
|
||||||
vps_ip = env["VPS_IP"]
|
vps_ip = env["VPS_IP"]
|
||||||
domain = env["DOMAIN"]
|
domain = env["DOMAIN"]
|
||||||
@@ -38,14 +36,29 @@ def build_links(node: dict, env: dict | None = None) -> dict[str, str]:
|
|||||||
name = quote(node["name"])
|
name = quote(node["name"])
|
||||||
port = hy2_port(node, list_nodes())
|
port = hy2_port(node, list_nodes())
|
||||||
|
|
||||||
|
# Parameter order follows Xray VLESS share-link convention; pbk/sid stay raw.
|
||||||
vless = (
|
vless = (
|
||||||
f"vless://{node['uuid']}@{vps_ip}:443"
|
f"vless://{node['uuid']}@{vps_ip}:443"
|
||||||
f"?encryption=none&flow=xtls-rprx-vision&security=reality"
|
f"?type=tcp&security=reality&encryption=none&flow=xtls-rprx-vision"
|
||||||
f"&sni={_qs(reality_sni)}&fp=chrome&pbk={_qs(public_key)}&sid={_qs(short_id)}"
|
f"&sni={_sni_qs(reality_sni)}&fp=chrome&pbk={public_key}&sid={short_id}"
|
||||||
f"&spx=%2F&type=tcp#{name}"
|
f"&spx=%2F#{name}"
|
||||||
)
|
)
|
||||||
hy2 = (
|
hy2 = (
|
||||||
f"hy2://{_qs(node['hy2_password'])}@{domain}:{port}"
|
f"hy2://{quote(node['hy2_password'], safe='')}@{domain}:{port}"
|
||||||
f"?sni={_qs(domain)}#{name}-Hy2"
|
f"?sni={_sni_qs(domain)}#{name}-Hy2"
|
||||||
)
|
)
|
||||||
return {"vless": vless, "hy2": hy2}
|
return {
|
||||||
|
"vless": vless,
|
||||||
|
"hy2": hy2,
|
||||||
|
"meta": {
|
||||||
|
"address": vps_ip,
|
||||||
|
"port": "443",
|
||||||
|
"uuid": node["uuid"],
|
||||||
|
"sni": reality_sni,
|
||||||
|
"pbk": public_key,
|
||||||
|
"sid": short_id,
|
||||||
|
"spx": "/",
|
||||||
|
"fp": "chrome",
|
||||||
|
"flow": "xtls-rprx-vision",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|||||||
+33
-3
@@ -45,6 +45,20 @@ function copyText(text) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let nodeLinksCache = null;
|
||||||
|
|
||||||
|
async function fetchNodeLinks() {
|
||||||
|
if (nodeLinksCache) {
|
||||||
|
return nodeLinksCache;
|
||||||
|
}
|
||||||
|
const res = await fetch(apiUrl("/api/nodes"), { credentials: "same-origin" });
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
nodeLinksCache = await res.json();
|
||||||
|
return nodeLinksCache;
|
||||||
|
}
|
||||||
|
|
||||||
document.querySelectorAll(".copy-row").forEach((row) => {
|
document.querySelectorAll(".copy-row").forEach((row) => {
|
||||||
const input = row.querySelector(".copy-input");
|
const input = row.querySelector(".copy-input");
|
||||||
const btn = row.querySelector(".copy-btn");
|
const btn = row.querySelector(".copy-btn");
|
||||||
@@ -56,17 +70,33 @@ document.querySelectorAll(".copy-row").forEach((row) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
btn.addEventListener("click", async () => {
|
btn.addEventListener("click", async () => {
|
||||||
const text = input.value;
|
const card = row.closest(".node-card");
|
||||||
|
const nodeId = card?.dataset.id;
|
||||||
|
const kind = btn.dataset.linkKind || input.dataset.linkKind || "vless";
|
||||||
|
let text = input.value;
|
||||||
|
|
||||||
|
if (nodeId) {
|
||||||
|
try {
|
||||||
|
const nodes = await fetchNodeLinks();
|
||||||
|
const node = nodes.find((item) => String(item.id) === String(nodeId));
|
||||||
|
if (node?.links) {
|
||||||
|
text = kind === "hy2" ? node.links.hy2 : node.links.vless;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* fall back to input.value */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!text) {
|
if (!text) {
|
||||||
toast("没有可复制的内容");
|
toast("没有可复制的内容");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
input.select();
|
|
||||||
input.setSelectionRange(0, text.length);
|
|
||||||
await copyText(text);
|
await copyText(text);
|
||||||
toast("已复制到剪贴板");
|
toast("已复制到剪贴板");
|
||||||
} catch {
|
} catch {
|
||||||
|
input.select();
|
||||||
|
input.setSelectionRange(0, text.length);
|
||||||
toast("复制失败,请选中上方链接后 Ctrl+C");
|
toast("复制失败,请选中上方链接后 Ctrl+C");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -85,6 +85,13 @@ input.copy-input {
|
|||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.link-hint {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
background: #111827;
|
background: #111827;
|
||||||
|
|||||||
@@ -75,15 +75,18 @@
|
|||||||
<div class="field">
|
<div class="field">
|
||||||
<label>VLESS + Reality</label>
|
<label>VLESS + Reality</label>
|
||||||
<div class="copy-row">
|
<div class="copy-row">
|
||||||
<input class="copy-input" readonly value="{{ node.links.vless }}">
|
<input class="copy-input" readonly value="{{ node.links.vless }}" data-link-kind="vless">
|
||||||
<button type="button" class="btn copy-btn">复制</button>
|
<button type="button" class="btn copy-btn" data-link-kind="vless">复制</button>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="link-hint muted">
|
||||||
|
手动核对:SNI={{ node.links.meta.sni }} · pbk={{ node.links.meta.pbk[:20] }}… · sid={{ node.links.meta.sid }} · SpiderX=/
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Hysteria2</label>
|
<label>Hysteria2</label>
|
||||||
<div class="copy-row">
|
<div class="copy-row">
|
||||||
<input class="copy-input" readonly value="{{ node.links.hy2 }}">
|
<input class="copy-input" readonly value="{{ node.links.hy2 }}" data-link-kind="hy2">
|
||||||
<button type="button" class="btn copy-btn">复制</button>
|
<button type="button" class="btn copy-btn" data-link-kind="hy2">复制</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="node-actions">
|
<div class="node-actions">
|
||||||
|
|||||||
@@ -22,9 +22,15 @@ else
|
|||||||
SB="sing-box"
|
SB="sing-box"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if command -v xray &>/dev/null; then
|
||||||
|
KEYPAIR="$(xray x25519)"
|
||||||
|
REALITY_PRIVATE_KEY="$(echo "$KEYPAIR" | awk '/Private key/ {print $3; exit} /PrivateKey/ {print $3; exit}')"
|
||||||
|
REALITY_PUBLIC_KEY="$(echo "$KEYPAIR" | awk '/Public key/ {print $3; exit} /Password/ {print $3; exit}')"
|
||||||
|
else
|
||||||
KEYPAIR="$("$SB" generate reality-keypair)"
|
KEYPAIR="$("$SB" generate reality-keypair)"
|
||||||
REALITY_PRIVATE_KEY="$(echo "$KEYPAIR" | grep 'PrivateKey:' | awk '{print $2}')"
|
REALITY_PRIVATE_KEY="$(echo "$KEYPAIR" | grep 'PrivateKey:' | awk '{print $2}')"
|
||||||
REALITY_PUBLIC_KEY="$(echo "$KEYPAIR" | grep 'PublicKey:' | awk '{print $2}')"
|
REALITY_PUBLIC_KEY="$(echo "$KEYPAIR" | grep 'PublicKey:' | awk '{print $2}')"
|
||||||
|
fi
|
||||||
REALITY_SHORT_ID="$("$SB" generate rand --hex 8)"
|
REALITY_SHORT_ID="$("$SB" generate rand --hex 8)"
|
||||||
|
|
||||||
GENERATE_PANEL_PASSWORD=1
|
GENERATE_PANEL_PASSWORD=1
|
||||||
|
|||||||
@@ -18,13 +18,13 @@ done
|
|||||||
mkdir -p "$OUT_DIR"
|
mkdir -p "$OUT_DIR"
|
||||||
|
|
||||||
urlencode() {
|
urlencode() {
|
||||||
python3 -c "import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1], safe='').replace('.', '%2E'))" "$1"
|
python3 -c "import sys; print(sys.argv[1].replace('.', '%2E'))" "$1"
|
||||||
}
|
}
|
||||||
|
|
||||||
REALITY_SNI_ENC="$(urlencode "$REALITY_SERVER_NAME")"
|
REALITY_SNI_ENC="$(urlencode "$REALITY_SERVER_NAME")"
|
||||||
REALITY_PBK_ENC="$(urlencode "$REALITY_PUBLIC_KEY")"
|
REALITY_PBK_ENC="$REALITY_PUBLIC_KEY"
|
||||||
REALITY_SID_ENC="$(urlencode "$REALITY_SHORT_ID")"
|
REALITY_SID_ENC="$REALITY_SHORT_ID"
|
||||||
HY2_PASSWORD_ENC="$(urlencode "$HY2_PASSWORD")"
|
HY2_PASSWORD_ENC="$(python3 -c "import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=''))" "$HY2_PASSWORD")"
|
||||||
DOMAIN_SNI_ENC="$(urlencode "$DOMAIN")"
|
DOMAIN_SNI_ENC="$(urlencode "$DOMAIN")"
|
||||||
|
|
||||||
sed -e "s|\${VPS_IP}|${VPS_IP}|g" \
|
sed -e "s|\${VPS_IP}|${VPS_IP}|g" \
|
||||||
@@ -38,7 +38,7 @@ sed -e "s|\${VPS_IP}|${VPS_IP}|g" \
|
|||||||
|
|
||||||
cat > "$OUT_DIR/share-links.txt" <<EOF
|
cat > "$OUT_DIR/share-links.txt" <<EOF
|
||||||
========== VLESS + Reality (主力) ==========
|
========== VLESS + Reality (主力) ==========
|
||||||
vless://${UUID}@${VPS_IP}:443?encryption=none&flow=xtls-rprx-vision&security=reality&sni=${REALITY_SNI_ENC}&fp=chrome&pbk=${REALITY_PBK_ENC}&sid=${REALITY_SID_ENC}&spx=%2F&type=tcp#Reality-Main
|
vless://${UUID}@${VPS_IP}:443?type=tcp&security=reality&encryption=none&flow=xtls-rprx-vision&sni=${REALITY_SNI_ENC}&fp=chrome&pbk=${REALITY_PBK_ENC}&sid=${REALITY_SID_ENC}&spx=%2F#Reality-Main
|
||||||
|
|
||||||
========== Hysteria2 (备用) ==========
|
========== Hysteria2 (备用) ==========
|
||||||
hy2://${HY2_PASSWORD_ENC}@${DOMAIN}:8443?sni=${DOMAIN_SNI_ENC}#Hysteria2-Backup
|
hy2://${HY2_PASSWORD_ENC}@${DOMAIN}:8443?sni=${DOMAIN_SNI_ENC}#Hysteria2-Backup
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ def build_config(env: dict[str, str], nodes: list[dict]) -> dict:
|
|||||||
},
|
},
|
||||||
"inbounds": [
|
"inbounds": [
|
||||||
{
|
{
|
||||||
|
"tag": "vless-reality-in",
|
||||||
"listen": "0.0.0.0",
|
"listen": "0.0.0.0",
|
||||||
"port": 443,
|
"port": 443,
|
||||||
"protocol": "vless",
|
"protocol": "vless",
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# 一键修复 VLESS Reality:拉代码、重渲染配置、重启服务、诊断
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
|
||||||
|
[[ $EUID -eq 0 ]] || { echo "请使用 root 运行: sudo bash scripts/repair-reality.sh"; exit 1; }
|
||||||
|
|
||||||
|
export JIEDIAN_ROOT="$ROOT"
|
||||||
|
|
||||||
|
echo "[1/5] 更新代码 ..."
|
||||||
|
if [[ -d "$ROOT/.git" ]]; then
|
||||||
|
git -C "$ROOT" pull --ff-only || echo "(git pull 跳过,请手动同步代码)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[2/5] 检查 443 监听进程 ..."
|
||||||
|
if ss -tlnp 2>/dev/null | grep -q ':443'; then
|
||||||
|
ss -tlnp | grep ':443' || true
|
||||||
|
else
|
||||||
|
echo " 443 端口未监听"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -f /etc/sing-box/config.json ]] && grep -q vless-reality /etc/sing-box/config.json 2>/dev/null; then
|
||||||
|
echo "[!] sing-box 仍含 VLESS Reality,执行迁移 ..."
|
||||||
|
bash "$ROOT/scripts/migrate-xray-reality.sh"
|
||||||
|
else
|
||||||
|
echo "[3/5] 渲染 Xray + sing-box 配置 ..."
|
||||||
|
python3 "$ROOT/scripts/render-xray.py"
|
||||||
|
python3 "$ROOT/scripts/render-server.py"
|
||||||
|
echo "[4/5] 重启服务 ..."
|
||||||
|
systemctl restart xray sing-box jiedian-panel
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[5/5] 诊断 ..."
|
||||||
|
bash "$ROOT/scripts/verify-reality.sh"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "完成。请在面板重新复制 VLESS 链接,删除 v2rayNG 旧节点后重新导入。"
|
||||||
|
echo "若仍失败,请执行: journalctl -u xray -n 50 --no-pager"
|
||||||
@@ -57,7 +57,7 @@ fi
|
|||||||
if command -v xray &>/dev/null && [[ -n "${REALITY_PRIVATE_KEY:-}" ]]; then
|
if command -v xray &>/dev/null && [[ -n "${REALITY_PRIVATE_KEY:-}" ]]; then
|
||||||
echo ""
|
echo ""
|
||||||
echo "========== 公钥配对 =========="
|
echo "========== 公钥配对 =========="
|
||||||
DERIVED="$(xray x25519 -i "$REALITY_PRIVATE_KEY" 2>/dev/null | awk '/Public key/ {print $3}')"
|
DERIVED="$(xray x25519 -i "$REALITY_PRIVATE_KEY" 2>/dev/null | awk '/Public key/ {print $3; exit} /Password/ {print $3; exit}')"
|
||||||
if [[ -n "$DERIVED" ]]; then
|
if [[ -n "$DERIVED" ]]; then
|
||||||
if [[ "$DERIVED" == "${REALITY_PUBLIC_KEY:-}" ]]; then
|
if [[ "$DERIVED" == "${REALITY_PUBLIC_KEY:-}" ]]; then
|
||||||
echo "公钥与私钥配对: 是"
|
echo "公钥与私钥配对: 是"
|
||||||
|
|||||||
Reference in New Issue
Block a user