mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-05 18:24:49 +03:00
Compare commits
153 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b6cb818a51 | |||
| 83ea59fb69 | |||
| 19f2f21d81 | |||
| fee7b1621d | |||
| e093854f5b | |||
| 728c2fb94f | |||
| 129e4f5292 | |||
| eae4725d99 | |||
| 1796352600 | |||
| 54a509fd67 | |||
| 09799ff734 | |||
| 121123eaaa | |||
| 43af7ada96 | |||
| 81059edc91 | |||
| a712bf1413 | |||
| 77b56a2178 | |||
| 6d9e836d61 | |||
| 4d583cebf3 | |||
| d2450e103c | |||
| 4180899f15 | |||
| 4cc742bf6f | |||
| ce44d2e772 | |||
| 34a762f7c8 | |||
| 05199dda00 | |||
| 14f2bb5e2c | |||
| 1bdb517b88 | |||
| cbf596d889 | |||
| d94a63d8bc | |||
| 6f37433d43 | |||
| 757444d505 | |||
| 62d27d54d5 | |||
| 87749c1ee4 | |||
| ba27b74be8 | |||
| 7982794291 | |||
| e789583b63 | |||
| 72c9092d4a | |||
| 9938d0b7a6 | |||
| 4dfd008edb | |||
| 99f386820d | |||
| 8f3dd70db4 | |||
| 846dfe3d74 | |||
| 54339661a6 | |||
| e02f51320a | |||
| d63583caaf | |||
| 72fb4d9358 | |||
| 6c74fccbf7 | |||
| 8b525b5be6 | |||
| 3471cecbda | |||
| 5a6d40e3ff | |||
| 2e41f908db | |||
| cc3aff2434 | |||
| 4d8e2b48ff | |||
| 8bbfda0510 | |||
| 06cb1742a1 | |||
| f99c6c664d | |||
| 93d1e63554 | |||
| 1d373cf67e | |||
| 828855cdb5 | |||
| 4cddcff12b | |||
| 255c658468 | |||
| 0394996a30 | |||
| 9c0bf6938c | |||
| 5c6cbb5680 | |||
| 082d7ac091 | |||
| ecd817658c | |||
| efaa73db3a | |||
| 7163c6e521 | |||
| dda44c1e72 | |||
| 878d37c2fc | |||
| 1507fd4edf | |||
| fac9009398 | |||
| a9191cb468 | |||
| 0972ac31eb | |||
| f8ceede46f | |||
| bd36053356 | |||
| 2024d34928 | |||
| 7f64a67ba5 | |||
| 67820dc20d | |||
| 30f17ef5a3 | |||
| 6dbd219a14 | |||
| 2467b9d542 | |||
| fc8efc0745 | |||
| f9f21fd8a4 | |||
| 3526a65f5d | |||
| 1c748adadf | |||
| 9a95153f0e | |||
| 2b9adaa6ff | |||
| 6a0c983c89 | |||
| c2250dbe57 | |||
| bebe1b911d | |||
| c92c7718bf | |||
| 68e06a174b | |||
| 152c84efa2 | |||
| 776ca554bd | |||
| c3a32c8343 | |||
| 07bbf4788a | |||
| c0bd572085 | |||
| bc2c64d901 | |||
| 1810996db6 | |||
| 5e6ffebdf2 | |||
| 05abd00bf6 | |||
| d3df3e9ea7 | |||
| daf5b16eda | |||
| d114845e44 | |||
| 376ab34c0f | |||
| 7907843941 | |||
| f66e369e41 | |||
| e1950b7575 | |||
| f988e9677f | |||
| ac896a9f95 | |||
| 0ea11e1643 | |||
| 53ee53ed9c | |||
| 6d90dcfe0c | |||
| e51a32b045 | |||
| 838923058b | |||
| 5cf2ab4f69 | |||
| 425a2bbf08 | |||
| dcdc56c64b | |||
| d69b924baf | |||
| 351a01ff0c | |||
| 47e9ef1a07 | |||
| fe5d704d67 | |||
| 44a502a407 | |||
| a69f2dee13 | |||
| 4d27313c5e | |||
| d005d89a8b | |||
| 469758837a | |||
| f67111a08a | |||
| a203056949 | |||
| 8ca321138b | |||
| fd3fa86982 | |||
| d4b80b5d80 | |||
| 87f3f54ea6 | |||
| 9cf8c87e8d | |||
| 5a1ea42785 | |||
| 08482154c2 | |||
| f1e1b0fb0c | |||
| e3736564a3 | |||
| cea69a4117 | |||
| b1108a9e81 | |||
| ce532e8df7 | |||
| 2e14a2a51a | |||
| 8e8541a5d8 | |||
| 661a169045 | |||
| 8359a0f72e | |||
| 534afe13ab | |||
| 3cdb7e2b49 | |||
| 61ee202750 | |||
| db20e491e6 | |||
| f1444bec3f | |||
| 2d72d9c715 | |||
| 1fa8cc785d | |||
| 4167597a15 |
@@ -9,6 +9,8 @@ on:
|
||||
- "frontend/**"
|
||||
- ".nvmrc"
|
||||
- "Makefile"
|
||||
- "install.sh"
|
||||
- "deploy/test/installer-panel-port.sh"
|
||||
- ".github/workflows/ci.yml"
|
||||
push:
|
||||
branches:
|
||||
@@ -20,6 +22,8 @@ on:
|
||||
- "frontend/**"
|
||||
- ".nvmrc"
|
||||
- "Makefile"
|
||||
- "install.sh"
|
||||
- "deploy/test/installer-panel-port.sh"
|
||||
- ".github/workflows/ci.yml"
|
||||
|
||||
permissions:
|
||||
@@ -71,12 +75,12 @@ jobs:
|
||||
- name: PostgreSQL durable-first tests
|
||||
run: |
|
||||
set -o pipefail
|
||||
go test ./internal/web/service -run 'PostgresCommitFailure|^TestSyncInboundClientsOnPostgres$|^TestSetClientLimitHwidIsSerializedWithSyncInbound$' -count=1 -v | tee /tmp/postgres-durable-first.log
|
||||
go test ./internal/web/service -run 'PostgresCommitFailure|^TestSyncInboundClientsOnPostgres$|^TestSetClientLimitHwidIsSerializedWithSyncInbound$|^TestVlessManagedFallbackConflictPostgres$' -count=1 -v | tee /tmp/postgres-durable-first.log
|
||||
# Count passes rather than assert no SKIP: a renamed or deleted test
|
||||
# prints "no tests to run" and exits 0, leaving the step green for nothing.
|
||||
passed=$(grep -c -- '^--- PASS' /tmp/postgres-durable-first.log || true)
|
||||
if [ "$passed" -lt 4 ]; then
|
||||
echo "expected at least 4 passing durable-first tests, got $passed" >&2
|
||||
if [ "$passed" -lt 5 ]; then
|
||||
echo "expected at least 5 passing durable-first tests, got $passed" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -191,6 +195,15 @@ jobs:
|
||||
echo "expected at least 4 passing PostgreSQL migration tests, got $passed" >&2
|
||||
exit 1
|
||||
fi
|
||||
- name: SQLite import host settings
|
||||
run: |
|
||||
set -o pipefail
|
||||
go test ./internal/web/service -run '^TestImportSQLiteIntoPostgresHostSettings$' -count=1 -v | tee /tmp/postgres-import-host-settings.log
|
||||
passed=$(grep -c -- '^--- PASS: TestImportSQLiteIntoPostgresHostSettings ' /tmp/postgres-import-host-settings.log || true)
|
||||
if [ "$passed" -ne 1 ]; then
|
||||
echo "expected the SQLite import integration test to pass, got $passed" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
codegen:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -314,8 +327,10 @@ jobs:
|
||||
run: sudo apt-get update && sudo apt-get install -y shellcheck
|
||||
- name: shellcheck
|
||||
run: |
|
||||
shellcheck --severity=style DockerInit.sh DockerEntrypoint.sh deploy/test/smoke-noninteractive.sh
|
||||
shellcheck --severity=style DockerInit.sh DockerEntrypoint.sh deploy/test/smoke-noninteractive.sh deploy/test/installer-panel-port.sh
|
||||
shellcheck --severity=error install.sh update.sh x-ui.sh
|
||||
- name: Installer panel port regression tests
|
||||
run: bash deploy/test/installer-panel-port.sh
|
||||
|
||||
frontend:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -115,7 +115,7 @@ jobs:
|
||||
cd x-ui/bin
|
||||
|
||||
# Download dependencies
|
||||
Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v26.10.21/"
|
||||
Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v26.10.31/"
|
||||
if [ "${{ matrix.platform }}" == "amd64" ]; then
|
||||
fetch ${Xray_URL}Xray-linux-64.zip
|
||||
unzip Xray-linux-64.zip
|
||||
@@ -283,7 +283,7 @@ jobs:
|
||||
cd x-ui\bin
|
||||
|
||||
# Download Xray for Windows
|
||||
$Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v26.10.21/"
|
||||
$Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v26.10.31/"
|
||||
Invoke-WebRequest @retry -Uri "${Xray_URL}Xray-windows-64.zip" -OutFile "Xray-windows-64.zip"
|
||||
Expand-Archive -Path "Xray-windows-64.zip" -DestinationPath .
|
||||
Remove-Item "Xray-windows-64.zip"
|
||||
|
||||
+1
-1
@@ -52,7 +52,7 @@ if [ -z "$MTG_MULTI_VER" ]; then
|
||||
fi
|
||||
mkdir -p build/bin
|
||||
cd build/bin
|
||||
curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v26.10.21/Xray-linux-${XRAY_ZIP}.zip"
|
||||
curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v26.10.31/Xray-linux-${XRAY_ZIP}.zip"
|
||||
unzip "Xray-linux-${XRAY_ZIP}.zip"
|
||||
rm -f "Xray-linux-${XRAY_ZIP}.zip" geoip.dat geosite.dat
|
||||
mv xray "${XRAY_BIN}"
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
#!/usr/bin/env bash
|
||||
# Exercise only installer function definitions, never its installation entry point.
|
||||
set -euo pipefail
|
||||
|
||||
repo_root=$(cd "$(dirname "$0")/../.." && pwd)
|
||||
installer=${INSTALLER_UNDER_TEST:-"$repo_root/install.sh"}
|
||||
test_dir=$(mktemp -d)
|
||||
trap 'rm -rf -- "$test_dir"' EXIT
|
||||
|
||||
load_function() {
|
||||
local definition
|
||||
definition=$(awk -v name="$1" '
|
||||
$0 == name "() {" {copy = 1}
|
||||
copy {print}
|
||||
copy && /^}$/ {exit}
|
||||
' "$installer")
|
||||
[[ -n "$definition" ]] || { echo "Missing installer function: $1" >&2; return 1; }
|
||||
eval "$definition"
|
||||
}
|
||||
|
||||
load_function is_port_in_use
|
||||
load_function config_after_install
|
||||
# The original installer has no selection helpers; its integration tests still run.
|
||||
if grep -q '^select_panel_port() {' "$installer"; then
|
||||
load_function normalize_panel_port
|
||||
load_function select_panel_port
|
||||
fi
|
||||
|
||||
# These commands are invoked by the extracted installer functions in a subshell.
|
||||
# shellcheck disable=SC2317,SC2329
|
||||
curl() { printf '192.0.2.1\n200\n'; }
|
||||
# shellcheck disable=SC2317,SC2329
|
||||
gen_random_string() { printf 'test-credential-%s\n' "$1"; }
|
||||
# shellcheck disable=SC2317,SC2329
|
||||
prompt_and_setup_ssl() {
|
||||
echo ssl >> "$CASE_DIR/events"
|
||||
export SSL_SCHEME=http SSL_HOST=192.0.2.1
|
||||
}
|
||||
# shellcheck disable=SC2317,SC2329
|
||||
write_install_result() { printf '%s\n' "$@" > "$CASE_DIR/result"; }
|
||||
# shellcheck disable=SC2317,SC2329
|
||||
read() {
|
||||
echo read >> "$CASE_DIR/events"
|
||||
[[ "$NONINTERACTIVE" != 1 ]] || return 90
|
||||
builtin read -r "$@"
|
||||
}
|
||||
# shellcheck disable=SC2317,SC2329
|
||||
shuf() {
|
||||
echo random >> "$CASE_DIR/events"
|
||||
local count=0
|
||||
[[ ! -f "$CASE_DIR/random-count" ]] || count=$(cat "$CASE_DIR/random-count")
|
||||
echo "$((count + 1))" > "$CASE_DIR/random-count"
|
||||
[[ "${SHUF_FAIL:-0}" == 0 ]] || return 1
|
||||
if ((count > 0)) && [[ -n "${RANDOM_NEXT:-}" ]]; then
|
||||
printf '%s\n' "$RANDOM_NEXT"
|
||||
else
|
||||
printf '54321\n'
|
||||
fi
|
||||
}
|
||||
# Select the availability backend deterministically, regardless of host packages.
|
||||
# shellcheck disable=SC2317,SC2329
|
||||
command() {
|
||||
if [[ "${1:-}" == -v && "${2:-}" =~ ^(ss|netstat|lsof)$ ]]; then
|
||||
[[ "$2" == "${PORT_BACKEND:-ss}" ]]
|
||||
else
|
||||
builtin command "$@"
|
||||
fi
|
||||
}
|
||||
# shellcheck disable=SC2317,SC2329
|
||||
ss() {
|
||||
printf 'State Recv-Q Send-Q Local\ Address:Port Peer\ Address:Port\n'
|
||||
if [[ -n "${BUSY_PORT:-}" ]]; then
|
||||
printf 'LISTEN 0 128 0.0.0.0:%s 0.0.0.0:*\n' "$BUSY_PORT"
|
||||
printf 'LISTEN 0 128 [::]:%s [::]:*\n' "$BUSY_PORT"
|
||||
fi
|
||||
}
|
||||
# shellcheck disable=SC2317,SC2329
|
||||
netstat() {
|
||||
if [[ -n "${BUSY_PORT:-}" ]]; then
|
||||
printf 'tcp 0 0 0.0.0.0:%s 0.0.0.0:* LISTEN\n' "$BUSY_PORT"
|
||||
printf 'tcp6 0 0 :::%s :::* LISTEN\n' "$BUSY_PORT"
|
||||
fi
|
||||
}
|
||||
# shellcheck disable=SC2317,SC2329
|
||||
lsof() { [[ -n "${BUSY_PORT:-}" && "$*" == *"-iTCP:$BUSY_PORT "* ]]; }
|
||||
|
||||
new_case() {
|
||||
CASE_DIR=$(mktemp -d "$test_dir/case.XXXXXXXX")
|
||||
export CASE_DIR
|
||||
: > "$CASE_DIR/events"
|
||||
xui_folder="$CASE_DIR/bin"
|
||||
mkdir -p "$xui_folder"
|
||||
cat > "$xui_folder/x-ui" <<'MOCK'
|
||||
#!/usr/bin/env bash
|
||||
set -eu
|
||||
if [[ "${1:-}" == migrate ]]; then
|
||||
echo migrate >> "$CASE_DIR/events"
|
||||
exit 0
|
||||
fi
|
||||
case "${2:-}" in
|
||||
-show) printf 'hasDefaultCredential: true\nwebBasePath: /\nport: 2053\n' ;;
|
||||
-getCert) printf 'cert: \n' ;;
|
||||
-getApiToken) printf 'apiToken: test-token\n' ;;
|
||||
-username)
|
||||
printf '%s\n' "$@" > "$CASE_DIR/settings-args"
|
||||
case "${SETTING_FAILURE:-}" in
|
||||
exit) echo 'controlled settings failure' >&2; exit 23 ;;
|
||||
zero) echo 'Database initialization failed: controlled failure'; exit 0 ;;
|
||||
esac
|
||||
while (($#)); do
|
||||
case "$1" in -port) port=$2 ;; esac
|
||||
shift
|
||||
done
|
||||
printf 'Port set successfully: %s\n' "$port"
|
||||
[[ "${SETTING_FAILURE:-}" != partial ]] || exit 0
|
||||
printf 'Username and password updated successfully\nBase URI path set successfully\n'
|
||||
;;
|
||||
*) echo 'Unexpected configuration command' >&2; exit 1 ;;
|
||||
esac
|
||||
MOCK
|
||||
chmod +x "$xui_folder/x-ui"
|
||||
export red='' green='' blue='' yellow='' plain='' release=ubuntu
|
||||
export XUI_USERNAME=test-user XUI_PASSWORD=test-pass XUI_WEB_BASE_PATH=test-path
|
||||
unset XUI_PANEL_PORT SETTING_FAILURE BUSY_PORT RANDOM_NEXT SHUF_FAIL
|
||||
PORT_BACKEND=ss
|
||||
NONINTERACTIVE=0
|
||||
}
|
||||
|
||||
run_config() {
|
||||
local input=$1
|
||||
# The initial newline selects the existing SQLite default. All later input
|
||||
# belongs to the port prompts; SSL and persistence are side-effect-free mocks.
|
||||
printf '\n%s' "$input" > "$CASE_DIR/input"
|
||||
status=0
|
||||
(config_after_install) < "$CASE_DIR/input" > "$CASE_DIR/output" 2>&1 || status=$?
|
||||
}
|
||||
|
||||
expect_port() {
|
||||
[[ "$status" == 0 ]] || { cat "$CASE_DIR/output"; return 1; }
|
||||
local actual
|
||||
actual=$(awk 'last == "-port" {print; exit} {last = $0}' "$CASE_DIR/settings-args")
|
||||
[[ "$actual" == "$1" ]] || { echo "Port $actual, expected $1"; return 1; }
|
||||
grep -Fxq 'Panel Installation Complete!' < <(sed 's/^[[:space:]]*//;s/[[:space:]]*$//' "$CASE_DIR/output")
|
||||
[[ -f "$CASE_DIR/result" ]] || return 1
|
||||
[[ $(sed -n '3p' "$CASE_DIR/result") == "$1" ]] || return 1
|
||||
[[ $(sed -n '1p' "$CASE_DIR/result") == test-user ]] || return 1
|
||||
[[ $(sed -n '2p' "$CASE_DIR/result") == test-pass ]] || return 1
|
||||
[[ $(sed -n '4p' "$CASE_DIR/result") == test-path ]] || return 1
|
||||
}
|
||||
|
||||
expect_failure() {
|
||||
[[ "$status" != 0 ]] || { echo 'Configuration unexpectedly succeeded'; return 1; }
|
||||
[[ ! -f "$CASE_DIR/result" ]] || return 1
|
||||
! grep -q 'Panel Installation Complete!\|SSL Certificate Setup\|^ssl$' "$CASE_DIR/output" "$CASE_DIR/events"
|
||||
}
|
||||
|
||||
affirmative() {
|
||||
new_case
|
||||
run_config "$1"$'\n2053\n'
|
||||
expect_port 2053
|
||||
! grep -q '^random$' "$CASE_DIR/events"
|
||||
}
|
||||
negative() {
|
||||
new_case
|
||||
run_config "$1"$'\n'
|
||||
expect_port 54321
|
||||
[[ $(grep -c '^random$' "$CASE_DIR/events") == 1 ]]
|
||||
}
|
||||
invalid_port() {
|
||||
new_case
|
||||
run_config $'y\n'"$1"$'\n2053\n'
|
||||
expect_port 2053
|
||||
grep -q 'Invalid port' "$CASE_DIR/output"
|
||||
! grep -q '^random$' "$CASE_DIR/events"
|
||||
}
|
||||
valid_port() {
|
||||
new_case
|
||||
run_config $'y\n'"$1"$'\n'
|
||||
expect_port "$2"
|
||||
}
|
||||
confirmation_retry() {
|
||||
new_case
|
||||
run_config $'maybe\nyes please\nYES\n2053\n'
|
||||
expect_port 2053
|
||||
grep -q 'Please answer yes or no' "$CASE_DIR/output"
|
||||
! grep -q '^random$' "$CASE_DIR/events"
|
||||
}
|
||||
busy_port() {
|
||||
new_case
|
||||
BUSY_PORT=2053
|
||||
PORT_BACKEND=$1
|
||||
run_config $'y\n2053\n2054\n'
|
||||
expect_port 2054
|
||||
grep -q 'already in use' "$CASE_DIR/output"
|
||||
}
|
||||
random_retry() {
|
||||
new_case
|
||||
BUSY_PORT=54321 RANDOM_NEXT=54322
|
||||
run_config $'no\n'
|
||||
expect_port 54322
|
||||
[[ $(grep -c '^random$' "$CASE_DIR/events") == 2 ]]
|
||||
}
|
||||
random_failure() {
|
||||
new_case
|
||||
SHUF_FAIL=1
|
||||
run_config $'no\n'
|
||||
expect_failure
|
||||
[[ ! -f "$CASE_DIR/settings-args" ]]
|
||||
}
|
||||
random_exhaustion() {
|
||||
new_case
|
||||
BUSY_PORT=54321
|
||||
run_config $'no\n'
|
||||
expect_failure
|
||||
[[ ! -f "$CASE_DIR/settings-args" ]]
|
||||
[[ $(grep -c '^random$' "$CASE_DIR/events") == 100 ]]
|
||||
}
|
||||
noninteractive() {
|
||||
new_case
|
||||
NONINTERACTIVE=1
|
||||
case "$1" in unset) ;; *) export XUI_PANEL_PORT=$1 ;; esac
|
||||
run_config ''
|
||||
expect_port "$2"
|
||||
! grep -q '^read$' "$CASE_DIR/events"
|
||||
}
|
||||
noninteractive_invalid() {
|
||||
new_case
|
||||
NONINTERACTIVE=1
|
||||
export XUI_PANEL_PORT=$1
|
||||
run_config ''
|
||||
expect_failure
|
||||
[[ ! -f "$CASE_DIR/settings-args" ]]
|
||||
! grep -q '^read$\|^random$' "$CASE_DIR/events"
|
||||
}
|
||||
noninteractive_busy() {
|
||||
new_case
|
||||
NONINTERACTIVE=1 BUSY_PORT=2053
|
||||
export XUI_PANEL_PORT=2053
|
||||
run_config ''
|
||||
expect_failure
|
||||
[[ ! -f "$CASE_DIR/settings-args" ]]
|
||||
! grep -q '^read$\|^random$' "$CASE_DIR/events"
|
||||
}
|
||||
settings_failure() {
|
||||
new_case
|
||||
export SETTING_FAILURE=$1
|
||||
run_config $'y\n2053\n'
|
||||
expect_failure
|
||||
[[ -f "$CASE_DIR/settings-args" ]]
|
||||
grep -q 'Failed to configure panel settings' "$CASE_DIR/output"
|
||||
}
|
||||
input_eof() {
|
||||
new_case
|
||||
run_config "$1"
|
||||
expect_failure
|
||||
[[ ! -f "$CASE_DIR/settings-args" ]]
|
||||
}
|
||||
|
||||
passed=0 failed=0
|
||||
check() {
|
||||
local name=$1
|
||||
shift
|
||||
# Keep the test body out of an if/|| condition: those disable errexit
|
||||
# throughout called functions and can hide a failed intermediate assertion.
|
||||
set +e
|
||||
(set -e; "$@")
|
||||
local result=$?
|
||||
set -e
|
||||
if [[ "$result" == 0 ]]; then
|
||||
printf 'PASS %s\n' "$name"
|
||||
passed=$((passed + 1))
|
||||
else
|
||||
printf 'FAIL %s\n' "$name" >&2
|
||||
failed=$((failed + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
for answer in y Y yes Yes YES ' yEs ' $'\tYES\t'; do check "affirmative [$answer]" affirmative "$answer"; done
|
||||
for answer in n N no No NO '' ' nO ' $' \t \r '; do check "negative [$answer]" negative "$answer"; done
|
||||
check 'invalid confirmation retries' confirmation_retry
|
||||
for port in '' abc 0 -1 65536 00065536 70000 1.5 +2053 '20 53' 0x805 999999999999999999999999 00000 '1+2' 'a[0]'; do
|
||||
check "invalid port [$port]" invalid_port "$port"
|
||||
done
|
||||
for port in 1 2053 65535; do check "valid port [$port]" valid_port "$port" "$port"; done
|
||||
check 'leading zeros use decimal' valid_port 00002053 2053
|
||||
check 'leading zeros containing eight use decimal' valid_port 008080 8080
|
||||
check 'leading-zero boundary uses decimal' valid_port 065535 65535
|
||||
for backend in ss netstat lsof; do check "busy port via $backend" busy_port "$backend"; done
|
||||
check 'busy random port retried' random_retry
|
||||
check 'random generator failure' random_failure
|
||||
check 'random search exhaustion' random_exhaustion
|
||||
check 'noninteractive supplied port' noninteractive 2053 2053
|
||||
check 'noninteractive decimal normalization' noninteractive 00002053 2053
|
||||
check 'noninteractive random default' noninteractive unset 54321
|
||||
for port in '' abc 0 -1 65536 1.5 999999999999999999999; do
|
||||
check "noninteractive invalid [$port]" noninteractive_invalid "$port"
|
||||
done
|
||||
check 'noninteractive busy port fails' noninteractive_busy
|
||||
for failure in exit zero partial; do check "settings failure [$failure]" settings_failure "$failure"; done
|
||||
check 'EOF at confirmation' input_eof ''
|
||||
check 'EOF at custom port' input_eof $'y\n'
|
||||
|
||||
printf '%s passed, %s failed\n' "$passed" "$failed"
|
||||
[[ "$failed" == 0 ]]
|
||||
@@ -23,7 +23,7 @@ network writes its own settings key on the wire (`tcpSettings`, `kcpSettings`,
|
||||
| **HTTPUpgrade** | `httpupgradeSettings` | CDN-friendly HTTP/1.1 `Upgrade`; lighter than full WebSocket. |
|
||||
| **XHTTP** | `xhttpSettings` | Modern stream-multiplexed HTTP transport; CDN-friendly and REALITY-capable. |
|
||||
| **Hysteria** | `hysteriaSettings` | QUIC-based transport — only for the **Hysteria2** protocol. |
|
||||
| **XDRIVE** | `xdriveSettings` | Tunnels the stream through files in shared cloud storage (Google Drive, a local folder or any HTTP storage API). |
|
||||
| **XDRIVE** | `xdriveSettings` | Tunnels the stream through files in shared cloud storage (Google Drive, S3, a local folder or any HTTP storage API). |
|
||||
| **MASQUE** | `masqueSettings` | CONNECT-IP over HTTP/3 or HTTP/2 — only for the **MASQUE** protocol. See [MASQUE](/docs/config/masque). |
|
||||
|
||||
<Callout type="info">
|
||||
@@ -132,10 +132,11 @@ enabled. The panel offers XDRIVE only for VLESS, refuses to save an inbound with
|
||||
|
||||
| Field | Default | Meaning |
|
||||
| -------------- | -------------- | ----------------------------------------------------------------------- |
|
||||
| `service` | `google-drive` | `google-drive`, `local` (a folder on disk) or `template` (any HTTP storage API). The old `Google Drive` spelling is still read. |
|
||||
| `service` | `google-drive` | `google-drive`, `local` (a folder on disk) `template` (any HTTP storage API) or `s3` (S3-compatible storage). The old `Google Drive` spelling is still read. |
|
||||
| `remoteFolder` | — | Folder that holds the session files. |
|
||||
| `secrets` | — | Google Drive: ClientID, ClientSecret, RefreshToken — exactly three, in that order. Template: values read as `{secret0}`, `{secret1}`, …. |
|
||||
| `secrets` | — | Google Drive: ClientID, ClientSecret, RefreshToken — exactly three, in that order. Template: values read as `{secret0}`, `{secret1}`, …. S3: AccessKeyID, SecretAccessKey and an optional SessionToken. |
|
||||
| `template` | — | Template service only: the storage API's `auth`, `put`, `get`, `list` and `delete` operations. |
|
||||
| `s3` | — | S3 service only: `endpoint` (an http(s) URL), `region`, `bucket` and `pathStyle` (default `true`; `false` puts the bucket in the host name). `remoteFolder` is the object prefix. |
|
||||
| tuning | core defaults | `segmentBytes` (512 KiB), `flushIntervalMs` (20), `pollIntervalMs`/`maxPollIntervalMs` (50/500), `eagerWindowMs` (2000), `holeTimeoutMs` (30000), `sessionTtlSeconds` (300), `concurrency` (8). |
|
||||
|
||||
The optional **Front address/port** (the stream-level `address`/`port`) is a domain
|
||||
|
||||
@@ -76,8 +76,11 @@ admins are notified when one runs out.
|
||||
|
||||
Admins also get inline-button flows for server usage, sorted traffic reports,
|
||||
resetting traffic, DB backups, ban logs, listing inbounds/clients, online
|
||||
clients, "depleting soon", and a full **add-client** wizard. Regular users get
|
||||
buttons for their own usage, subscription links, individual links, and QR codes.
|
||||
clients, "depleting soon", and a full **add-client** wizard. The admin's
|
||||
**client card** carries the client's subscription link, individual links and QR
|
||||
code, so a config can be handed to a client who cannot reach Telegram yet.
|
||||
Regular users get buttons for their own usage, subscription links, individual
|
||||
links, and QR codes.
|
||||
|
||||
## Reports & alerts
|
||||
|
||||
|
||||
@@ -182,10 +182,11 @@ _openapi:
|
||||
url: '#mirror-of-bulkattach-detach-many-existing-clients-from-many-inbounds-in-one-call-for-each-email-intersects-the-clients-current-inbounds-with-the-requested-set-and-detaches-from-those-only-email-inbound-pairs-where-the-client-is-not-currently-attached-are-silently-no-ops-emails-not-attached-to-any-of-the-requested-inbounds-are-reported-under-skipped-client-records-are-kept-even-if-they-become-orphaned--use-bulkdel-for-full-removal-returns-per-email-detachedskippederrors-lists-and-triggers-a-single-xray-restart-if-any-target-inbound-was-running'
|
||||
- depth: 2
|
||||
title: Zero up/down counters for many clients in one call. Loops the
|
||||
single-reset path so each client is re-enabled across its attached
|
||||
inbounds and pushed to Xray/remote nodes. Returns the count of
|
||||
successfully reset clients.
|
||||
url: '#zero-updown-counters-for-many-clients-in-one-call-loops-the-single-reset-path-so-each-client-is-re-enabled-across-its-attached-inbounds-and-pushed-to-xrayremote-nodes-returns-the-count-of-successfully-reset-clients'
|
||||
single-reset path so each client its quota had disabled is re-enabled
|
||||
across its attached inbounds and pushed to Xray/remote nodes; a client
|
||||
disabled by hand stays disabled. Returns the count of successfully reset
|
||||
clients.
|
||||
url: '#zero-updown-counters-for-many-clients-in-one-call-loops-the-single-reset-path-so-each-client-its-quota-had-disabled-is-re-enabled-across-its-attached-inbounds-and-pushed-to-xrayremote-nodes-a-client-disabled-by-hand-stays-disabled-returns-the-count-of-successfully-reset-clients'
|
||||
- depth: 2
|
||||
title: List all client groups with their member counts. Merges persisted groups
|
||||
(rows in client_groups, including empty placeholders) with the distinct
|
||||
@@ -223,10 +224,11 @@ _openapi:
|
||||
client_groups row if the group exists only as a derived label.
|
||||
url: '#reset-only-the-group-level-traffic-counter-shown-on-the-groups-page-snapshots-the-current-updown-sum-of-the-groups-members-as-a-baseline-so-the-group-total-reads-zero-while-leaving-each-clients-own-counters-and-their-quotas-untouched-no-xray-restart-is-triggered-creates-the-client_groups-row-if-the-group-exists-only-as-a-derived-label'
|
||||
- depth: 2
|
||||
title: Zero out a single client’s up/down counters. Re-enables the client across
|
||||
every attached inbound and pushes the change to Xray (or the remote
|
||||
node) so depleted users can connect again immediately.
|
||||
url: '#zero-out-a-single-clients-updown-counters-re-enables-the-client-across-every-attached-inbound-and-pushes-the-change-to-xray-or-the-remote-node-so-depleted-users-can-connect-again-immediately'
|
||||
title: Zero out a single client’s up/down counters. Re-enables a client its
|
||||
quota had disabled across every attached inbound and pushes the change
|
||||
to Xray (or the remote node) so depleted users can connect again
|
||||
immediately; a client disabled by hand stays disabled.
|
||||
url: '#zero-out-a-single-clients-updown-counters-re-enables-a-client-its-quota-had-disabled-across-every-attached-inbound-and-pushes-the-change-to-xray-or-the-remote-node-so-depleted-users-can-connect-again-immediately-a-client-disabled-by-hand-stays-disabled'
|
||||
- depth: 2
|
||||
title: Manually adjust a client’s upload + download counters. Useful for
|
||||
migrations from external accounting systems.
|
||||
@@ -457,10 +459,11 @@ _openapi:
|
||||
any target inbound was running."
|
||||
id: mirror-of-bulkattach-detach-many-existing-clients-from-many-inbounds-in-one-call-for-each-email-intersects-the-clients-current-inbounds-with-the-requested-set-and-detaches-from-those-only-email-inbound-pairs-where-the-client-is-not-currently-attached-are-silently-no-ops-emails-not-attached-to-any-of-the-requested-inbounds-are-reported-under-skipped-client-records-are-kept-even-if-they-become-orphaned--use-bulkdel-for-full-removal-returns-per-email-detachedskippederrors-lists-and-triggers-a-single-xray-restart-if-any-target-inbound-was-running
|
||||
- content: Zero up/down counters for many clients in one call. Loops the
|
||||
single-reset path so each client is re-enabled across its attached
|
||||
inbounds and pushed to Xray/remote nodes. Returns the count of
|
||||
successfully reset clients.
|
||||
id: zero-updown-counters-for-many-clients-in-one-call-loops-the-single-reset-path-so-each-client-is-re-enabled-across-its-attached-inbounds-and-pushed-to-xrayremote-nodes-returns-the-count-of-successfully-reset-clients
|
||||
single-reset path so each client its quota had disabled is re-enabled
|
||||
across its attached inbounds and pushed to Xray/remote nodes; a client
|
||||
disabled by hand stays disabled. Returns the count of successfully
|
||||
reset clients.
|
||||
id: zero-updown-counters-for-many-clients-in-one-call-loops-the-single-reset-path-so-each-client-its-quota-had-disabled-is-re-enabled-across-its-attached-inbounds-and-pushed-to-xrayremote-nodes-a-client-disabled-by-hand-stays-disabled-returns-the-count-of-successfully-reset-clients
|
||||
- content: List all client groups with their member counts. Merges persisted
|
||||
groups (rows in client_groups, including empty placeholders) with the
|
||||
distinct group_name values currently set on clients. Sorted
|
||||
@@ -492,10 +495,11 @@ _openapi:
|
||||
Creates the client_groups row if the group exists only as a derived
|
||||
label.
|
||||
id: reset-only-the-group-level-traffic-counter-shown-on-the-groups-page-snapshots-the-current-updown-sum-of-the-groups-members-as-a-baseline-so-the-group-total-reads-zero-while-leaving-each-clients-own-counters-and-their-quotas-untouched-no-xray-restart-is-triggered-creates-the-client_groups-row-if-the-group-exists-only-as-a-derived-label
|
||||
- content: Zero out a single client’s up/down counters. Re-enables the client
|
||||
across every attached inbound and pushes the change to Xray (or the
|
||||
remote node) so depleted users can connect again immediately.
|
||||
id: zero-out-a-single-clients-updown-counters-re-enables-the-client-across-every-attached-inbound-and-pushes-the-change-to-xray-or-the-remote-node-so-depleted-users-can-connect-again-immediately
|
||||
- content: Zero out a single client’s up/down counters. Re-enables a client its
|
||||
quota had disabled across every attached inbound and pushes the change
|
||||
to Xray (or the remote node) so depleted users can connect again
|
||||
immediately; a client disabled by hand stays disabled.
|
||||
id: zero-out-a-single-clients-updown-counters-re-enables-a-client-its-quota-had-disabled-across-every-attached-inbound-and-pushes-the-change-to-xray-or-the-remote-node-so-depleted-users-can-connect-again-immediately-a-client-disabled-by-hand-stays-disabled
|
||||
- content: Manually adjust a client’s upload + download counters. Useful for
|
||||
migrations from external accounting systems.
|
||||
id: manually-adjust-a-clients-upload--download-counters-useful-for-migrations-from-external-accounting-systems
|
||||
@@ -609,9 +613,11 @@ _openapi:
|
||||
different client of that same inbound holds, or `... used by a client
|
||||
on <inbound>` when the holder sits on another WireGuard or AmneziaWG
|
||||
inbound. Ranges are compared, not strings, so `10.0.0.9/24` collides
|
||||
with `10.0.0.5/32`; a `0.0.0.0/0` or `::/0` default route claims no
|
||||
address. Allocation likewise skips every address inside a prefix
|
||||
another client holds. The same validation runs on POST
|
||||
with `10.0.0.5/32`; a `/0` entry collides with every address of its
|
||||
family another client holds, and only a `0.0.0.0/0` or `::/0` default
|
||||
route that is already stored (legacy peers) keeps nobody out.
|
||||
Allocation likewise skips every address inside a prefix another client
|
||||
holds. The same validation runs on POST
|
||||
/panel/api/clients/{email}/attach, where a client that already carries
|
||||
an address brings it along.
|
||||
|
||||
|
||||
@@ -56,8 +56,10 @@ _openapi:
|
||||
title: Create a new inbound. Send the full inbound payload (protocol, port,
|
||||
settings, streamSettings, sniffing, remark, expiryTime, total, enable).
|
||||
settings, streamSettings, and sniffing may be sent as nested JSON
|
||||
objects (preferred) or as JSON-encoded strings (legacy).
|
||||
url: '#create-a-new-inbound-send-the-full-inbound-payload-protocol-port-settings-streamsettings-sniffing-remark-expirytime-total-enable-settings-streamsettings-and-sniffing-may-be-sent-as-nested-json-objects-preferred-or-as-json-encoded-strings-legacy'
|
||||
objects (preferred) or as JSON-encoded strings (legacy). VLESS empty
|
||||
fallback arrays are normalized away; nonempty fallbacks cannot be
|
||||
combined with active server decryption.
|
||||
url: '#create-a-new-inbound-send-the-full-inbound-payload-protocol-port-settings-streamsettings-sniffing-remark-expirytime-total-enable-settings-streamsettings-and-sniffing-may-be-sent-as-nested-json-objects-preferred-or-as-json-encoded-strings-legacy-vless-empty-fallback-arrays-are-normalized-away-nonempty-fallbacks-cannot-be-combined-with-active-server-decryption'
|
||||
- depth: 2
|
||||
title: Delete an inbound by ID. Also removes its associated client stats rows.
|
||||
url: '#delete-an-inbound-by-id-also-removes-its-associated-client-stats-rows'
|
||||
@@ -70,8 +72,10 @@ _openapi:
|
||||
title: 'Replace an inbound’s configuration. Body shape mirrors /add, but the
|
||||
inbound keeps its stored client list and enable flag: settings.clients
|
||||
and enable in the body are ignored. Manage clients through the
|
||||
/panel/api/clients endpoints and toggle the inbound with /setEnable.'
|
||||
url: '#replace-an-inbounds-configuration-body-shape-mirrors-add-but-the-inbound-keeps-its-stored-client-list-and-enable-flag-settingsclients-and-enable-in-the-body-are-ignored-manage-clients-through-the-panelapiclients-endpoints-and-toggle-the-inbound-with-setenable'
|
||||
/panel/api/clients endpoints and toggle the inbound with /setEnable.
|
||||
Remove effective fallback rules before enabling VLESS server
|
||||
decryption.'
|
||||
url: '#replace-an-inbounds-configuration-body-shape-mirrors-add-but-the-inbound-keeps-its-stored-client-list-and-enable-flag-settingsclients-and-enable-in-the-body-are-ignored-manage-clients-through-the-panelapiclients-endpoints-and-toggle-the-inbound-with-setenable-remove-effective-fallback-rules-before-enabling-vless-server-decryption'
|
||||
- depth: 2
|
||||
title: Toggle only the enable flag without serialising the whole settings JSON.
|
||||
Recommended for UI switches on large inbounds.
|
||||
@@ -96,8 +100,9 @@ _openapi:
|
||||
url: '#reset-upload--download-counters-on-every-inbound-destructive--accounting-history-is-lost'
|
||||
- depth: 2
|
||||
title: Bulk-import an inbound from a JSON blob (e.g. one exported via the UI).
|
||||
The body uses form encoding with a single "data" field.
|
||||
url: '#bulk-import-an-inbound-from-a-json-blob-eg-one-exported-via-the-ui-the-body-uses-form-encoding-with-a-single-data-field'
|
||||
The body uses form encoding with a single "data" field. VLESS fallback
|
||||
validation is identical to /add.
|
||||
url: '#bulk-import-an-inbound-from-a-json-blob-eg-one-exported-via-the-ui-the-body-uses-form-encoding-with-a-single-data-field-vless-fallback-validation-is-identical-to-add'
|
||||
- depth: 2
|
||||
title: Receive a master panel's aggregated per-client usage, keyed by the
|
||||
master's GUID. Stored in a side table used only for the UI display
|
||||
@@ -113,8 +118,10 @@ _openapi:
|
||||
url: '#list-the-fallback-rules-attached-to-a-master-vlesstrojan-tcp-tls-inbound-each-rule-links-one-child-inbound-the-dest-to-optional-snialpnpathdestxver-match-criteria-when-dest-is-empty-the-child-inbounds-listenport-is-used'
|
||||
- depth: 2
|
||||
title: Replace the entire fallback list for a master inbound. Body is JSON.
|
||||
Triggers an Xray restart.
|
||||
url: '#replace-the-entire-fallback-list-for-a-master-inbound-body-is-json-triggers-an-xray-restart'
|
||||
Nonempty lists require TCP with TLS/REALITY and no active VLESS server
|
||||
decryption. An empty list can remove rules even from an incompatible
|
||||
inbound. Triggers an Xray restart.
|
||||
url: '#replace-the-entire-fallback-list-for-a-master-inbound-body-is-json-nonempty-lists-require-tcp-with-tlsreality-and-no-active-vless-server-decryption-an-empty-list-can-remove-rules-even-from-an-incompatible-inbound-triggers-an-xray-restart'
|
||||
structuredData:
|
||||
headings:
|
||||
- content: List every inbound owned by the authenticated user, including each
|
||||
@@ -157,8 +164,10 @@ _openapi:
|
||||
- content: Create a new inbound. Send the full inbound payload (protocol, port,
|
||||
settings, streamSettings, sniffing, remark, expiryTime, total,
|
||||
enable). settings, streamSettings, and sniffing may be sent as nested
|
||||
JSON objects (preferred) or as JSON-encoded strings (legacy).
|
||||
id: create-a-new-inbound-send-the-full-inbound-payload-protocol-port-settings-streamsettings-sniffing-remark-expirytime-total-enable-settings-streamsettings-and-sniffing-may-be-sent-as-nested-json-objects-preferred-or-as-json-encoded-strings-legacy
|
||||
JSON objects (preferred) or as JSON-encoded strings (legacy). VLESS
|
||||
empty fallback arrays are normalized away; nonempty fallbacks cannot
|
||||
be combined with active server decryption.
|
||||
id: create-a-new-inbound-send-the-full-inbound-payload-protocol-port-settings-streamsettings-sniffing-remark-expirytime-total-enable-settings-streamsettings-and-sniffing-may-be-sent-as-nested-json-objects-preferred-or-as-json-encoded-strings-legacy-vless-empty-fallback-arrays-are-normalized-away-nonempty-fallbacks-cannot-be-combined-with-active-server-decryption
|
||||
- content: Delete an inbound by ID. Also removes its associated client stats rows.
|
||||
id: delete-an-inbound-by-id-also-removes-its-associated-client-stats-rows
|
||||
- content: Delete many inbounds in one call. Processes the list sequentially;
|
||||
@@ -168,8 +177,10 @@ _openapi:
|
||||
- content: 'Replace an inbound’s configuration. Body shape mirrors /add, but the
|
||||
inbound keeps its stored client list and enable flag: settings.clients
|
||||
and enable in the body are ignored. Manage clients through the
|
||||
/panel/api/clients endpoints and toggle the inbound with /setEnable.'
|
||||
id: replace-an-inbounds-configuration-body-shape-mirrors-add-but-the-inbound-keeps-its-stored-client-list-and-enable-flag-settingsclients-and-enable-in-the-body-are-ignored-manage-clients-through-the-panelapiclients-endpoints-and-toggle-the-inbound-with-setenable
|
||||
/panel/api/clients endpoints and toggle the inbound with /setEnable.
|
||||
Remove effective fallback rules before enabling VLESS server
|
||||
decryption.'
|
||||
id: replace-an-inbounds-configuration-body-shape-mirrors-add-but-the-inbound-keeps-its-stored-client-list-and-enable-flag-settingsclients-and-enable-in-the-body-are-ignored-manage-clients-through-the-panelapiclients-endpoints-and-toggle-the-inbound-with-setenable-remove-effective-fallback-rules-before-enabling-vless-server-decryption
|
||||
- content: Toggle only the enable flag without serialising the whole settings
|
||||
JSON. Recommended for UI switches on large inbounds.
|
||||
id: toggle-only-the-enable-flag-without-serialising-the-whole-settings-json-recommended-for-ui-switches-on-large-inbounds
|
||||
@@ -188,8 +199,9 @@ _openapi:
|
||||
accounting history is lost.
|
||||
id: reset-upload--download-counters-on-every-inbound-destructive--accounting-history-is-lost
|
||||
- content: Bulk-import an inbound from a JSON blob (e.g. one exported via the UI).
|
||||
The body uses form encoding with a single "data" field.
|
||||
id: bulk-import-an-inbound-from-a-json-blob-eg-one-exported-via-the-ui-the-body-uses-form-encoding-with-a-single-data-field
|
||||
The body uses form encoding with a single "data" field. VLESS fallback
|
||||
validation is identical to /add.
|
||||
id: bulk-import-an-inbound-from-a-json-blob-eg-one-exported-via-the-ui-the-body-uses-form-encoding-with-a-single-data-field-vless-fallback-validation-is-identical-to-add
|
||||
- content: Receive a master panel's aggregated per-client usage, keyed by the
|
||||
master's GUID. Stored in a side table used only for the UI display
|
||||
overlay and local quota enforcement — never folded into the local
|
||||
@@ -202,8 +214,10 @@ _openapi:
|
||||
inbound's listen+port is used.
|
||||
id: list-the-fallback-rules-attached-to-a-master-vlesstrojan-tcp-tls-inbound-each-rule-links-one-child-inbound-the-dest-to-optional-snialpnpathdestxver-match-criteria-when-dest-is-empty-the-child-inbounds-listenport-is-used
|
||||
- content: Replace the entire fallback list for a master inbound. Body is JSON.
|
||||
Triggers an Xray restart.
|
||||
id: replace-the-entire-fallback-list-for-a-master-inbound-body-is-json-triggers-an-xray-restart
|
||||
Nonempty lists require TCP with TLS/REALITY and no active VLESS server
|
||||
decryption. An empty list can remove rules even from an incompatible
|
||||
inbound. Triggers an Xray restart.
|
||||
id: replace-the-entire-fallback-list-for-a-master-inbound-body-is-json-nonempty-lists-require-tcp-with-tlsreality-and-no-active-vless-server-decryption-an-empty-list-can-remove-rules-even-from-an-incompatible-inbound-triggers-an-xray-restart
|
||||
contents: []
|
||||
---
|
||||
|
||||
|
||||
@@ -133,10 +133,11 @@ inbound به مقدار `decryption` نیاز دارد و outbound به `encrypt
|
||||
|
||||
| فیلد | پیشفرض | معنا |
|
||||
| -------------- | -------------- | ---- |
|
||||
| `service` | `google-drive` | `google-drive`، `local` (پوشهای روی دیسک) یا `template` (هر API ذخیرهسازی HTTP). نگارش قدیمی `Google Drive` همچنان خوانده میشود. |
|
||||
| `service` | `google-drive` | `google-drive`، `local` (پوشهای روی دیسک)، `template` (هر API ذخیرهسازی HTTP) یا `s3` (ذخیرهساز سازگار با S3). نگارش قدیمی `Google Drive` همچنان خوانده میشود. |
|
||||
| `remoteFolder` | — | پوشهای که فایلهای نشست در آن قرار میگیرند. |
|
||||
| `secrets` | — | Google Drive: دقیقاً سه مقدار ClientID، ClientSecret و RefreshToken به همین ترتیب. Template: مقادیری که با `{secret0}`، `{secret1}`، … خوانده میشوند. |
|
||||
| `secrets` | — | Google Drive: دقیقاً سه مقدار ClientID، ClientSecret و RefreshToken به همین ترتیب. Template: مقادیری که با `{secret0}`، `{secret1}`، … خوانده میشوند. S3: AccessKeyID، SecretAccessKey و در صورت نیاز SessionToken. |
|
||||
| `template` | — | فقط برای سرویس template: عملیات `auth`، `put`، `get`، `list` و `delete` در API ذخیرهساز. |
|
||||
| `s3` | — | فقط برای سرویس s3: `endpoint` (یک URL از نوع http(s))، `region`، `bucket` و `pathStyle` (پیشفرض `true`؛ با `false` نام باکت در نام میزبان میآید). `remoteFolder` پیشوند اشیاء است. |
|
||||
| تنظیمات دقیق | پیشفرض هسته | `segmentBytes` (512 KiB)، `flushIntervalMs` (20)، `pollIntervalMs`/`maxPollIntervalMs` (50/500)، `eagerWindowMs` (2000)، `holeTimeoutMs` (30000)، `sessionTtlSeconds` (300)، `concurrency` (8). |
|
||||
|
||||
**آدرس/پورت Front** اختیاری (کلیدهای `address`/`port` در سطح stream) یک domain front
|
||||
|
||||
@@ -77,6 +77,8 @@ icon: Send
|
||||
ادمینها همچنین جریانهای دکمهی درونخطی برای مصرف سرور، گزارشهای ترافیک مرتبشده،
|
||||
بازنشانی ترافیک، پشتیبانگیری از DB، گزارشهای مسدودسازی، فهرست کردن ورودیها/کلاینتها،
|
||||
کلاینتهای آنلاین، «بهزودی تمامشونده» و یک جادوگر کامل **افزودن کلاینت** را در اختیار دارند.
|
||||
کارت **کلاینت** در اختیار ادمین است و لینک اشتراک، لینکهای منفرد و کد QR کلاینت را
|
||||
نشان میدهد، تا کانفیگ را بتوان به کلاینتی که هنوز به Telegram دسترسی ندارد هم داد.
|
||||
کاربران عادی دکمههایی برای مصرف خودشان، لینکهای اشتراک، لینکهای منفرد و کدهای QR دارند.
|
||||
|
||||
## گزارشها و هشدارها
|
||||
|
||||
@@ -135,10 +135,11 @@ Mux. Панель предлагает XDRIVE только для VLESS, не с
|
||||
|
||||
| Поле | По умолчанию | Значение |
|
||||
| -------------- | -------------- | -------- |
|
||||
| `service` | `google-drive` | `google-drive`, `local` (папка на диске) или `template` (любой HTTP API хранилища). Старое написание `Google Drive` по-прежнему читается. |
|
||||
| `service` | `google-drive` | `google-drive`, `local` (папка на диске), `template` (любой HTTP API хранилища) или `s3` (S3-совместимое хранилище). Старое написание `Google Drive` по-прежнему читается. |
|
||||
| `remoteFolder` | — | Папка с файлами сессий. |
|
||||
| `secrets` | — | Google Drive: ровно три значения — ClientID, ClientSecret, RefreshToken, в этом порядке. Template: значения, доступные как `{secret0}`, `{secret1}`, …. |
|
||||
| `secrets` | — | Google Drive: ровно три значения — ClientID, ClientSecret, RefreshToken, в этом порядке. Template: значения, доступные как `{secret0}`, `{secret1}`, …. S3: AccessKeyID, SecretAccessKey и при необходимости SessionToken. |
|
||||
| `template` | — | Только для template: операции `auth`, `put`, `get`, `list` и `delete` API хранилища. |
|
||||
| `s3` | — | Только для s3: `endpoint` (URL http(s)), `region`, `bucket` и `pathStyle` (по умолчанию `true`; `false` переносит бакет в имя хоста). `remoteFolder` — префикс объектов. |
|
||||
| тонкая настройка | значения ядра | `segmentBytes` (512 KiB), `flushIntervalMs` (20), `pollIntervalMs`/`maxPollIntervalMs` (50/500), `eagerWindowMs` (2000), `holeTimeoutMs` (30000), `sessionTtlSeconds` (300), `concurrency` (8). |
|
||||
|
||||
Необязательные **адрес/порт фронтинга** (`address`/`port` на уровне stream) — домен-фронт,
|
||||
|
||||
@@ -80,9 +80,11 @@ ID темы после двоеточия, например `-1001234567890:42`.
|
||||
Администраторам также доступны сценарии со встроенными кнопками: использование
|
||||
сервера, отсортированные отчёты по трафику, сброс трафика, резервные копии БД,
|
||||
журналы блокировок, список входящих подключений/клиентов, онлайн-клиенты,
|
||||
«скоро исчерпают» и полноценный мастер **добавления клиента**. Обычным
|
||||
пользователям доступны кнопки для своего расхода, ссылок на подписку, отдельных
|
||||
ссылок и QR-кодов.
|
||||
«скоро исчерпают» и полноценный мастер **добавления клиента**. В карточке
|
||||
клиента администратор получает ссылку на подписку, отдельные ссылки и QR-код —
|
||||
конфигурацию можно передать клиенту, который ещё не может открыть Telegram.
|
||||
Обычным пользователям доступны кнопки для своего расхода, ссылок на подписку,
|
||||
отдельных ссылок и QR-кодов.
|
||||
|
||||
## Отчёты и оповещения
|
||||
|
||||
|
||||
@@ -124,10 +124,11 @@ XDRIVE 把数据流作为文件存放在双方都能访问的文件夹中:服
|
||||
|
||||
| 字段 | 默认值 | 含义 |
|
||||
| -------------- | -------------- | ---- |
|
||||
| `service` | `google-drive` | `google-drive`、`local`(磁盘上的文件夹)或 `template`(任意 HTTP 存储 API)。旧写法 `Google Drive` 仍可读取。 |
|
||||
| `service` | `google-drive` | `google-drive`、`local`(磁盘上的文件夹)、`template`(任意 HTTP 存储 API)或 `s3`(S3 兼容存储)。旧写法 `Google Drive` 仍可读取。 |
|
||||
| `remoteFolder` | — | 存放会话文件的文件夹。 |
|
||||
| `secrets` | — | Google Drive:恰好三项,依次为 ClientID、ClientSecret、RefreshToken。Template:以 `{secret0}`、`{secret1}`… 引用的值。 |
|
||||
| `secrets` | — | Google Drive:恰好三项,依次为 ClientID、ClientSecret、RefreshToken。Template:以 `{secret0}`、`{secret1}`… 引用的值。S3:AccessKeyID、SecretAccessKey,以及可选的 SessionToken。 |
|
||||
| `template` | — | 仅用于 template 服务:存储 API 的 `auth`、`put`、`get`、`list` 和 `delete` 操作。 |
|
||||
| `s3` | — | 仅用于 s3 服务:`endpoint`(http(s) URL)、`region`、`bucket` 和 `pathStyle`(默认 `true`;为 `false` 时存储桶名放在主机名中)。`remoteFolder` 为对象前缀。 |
|
||||
| 调优参数 | 内核默认值 | `segmentBytes`(512 KiB)、`flushIntervalMs`(20)、`pollIntervalMs`/`maxPollIntervalMs`(50/500)、`eagerWindowMs`(2000)、`holeTimeoutMs`(30000)、`sessionTtlSeconds`(300)、`concurrency`(8)。 |
|
||||
|
||||
可选的**前置地址/端口**(stream 级的 `address`/`port`)是连接存储 API 时使用的域前置;TLS 中仍使用 API 主机名。
|
||||
|
||||
@@ -71,7 +71,9 @@ ID 的所有客户端。订阅 ID 就是邀请码,因此请保持其足够长
|
||||
|
||||
管理员还可通过内联按钮使用一系列功能:服务器用量、按流量排序的报告、
|
||||
重置流量、数据库备份、封禁日志、列出入站/客户端、在线客户端、
|
||||
“即将耗尽”,以及完整的**添加客户端**向导。普通用户则可以使用按钮查看
|
||||
“即将耗尽”,以及完整的**添加客户端**向导。管理员的**客户端卡片**上还带有该客户端的
|
||||
订阅链接、单条链接和 QR 码,因此可以把配置交给暂时还无法打开 Telegram 的客户端。
|
||||
普通用户则可以使用按钮查看
|
||||
自己的用量、订阅链接、单条链接和 QR 码。
|
||||
|
||||
## 报告与告警
|
||||
|
||||
+9
-9
@@ -18,12 +18,12 @@
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"fumadocs-core": "^16.17.8",
|
||||
"fumadocs-core": "^16.17.9",
|
||||
"fumadocs-docgen": "^3.2.0",
|
||||
"fumadocs-mdx": "^15.4.10",
|
||||
"fumadocs-openapi": "^12.5.5",
|
||||
"fumadocs-ui": "^16.17.8",
|
||||
"lucide-react": "^1.61.0",
|
||||
"fumadocs-openapi": "^12.5.7",
|
||||
"fumadocs-ui": "^16.17.9",
|
||||
"lucide-react": "^1.66.0",
|
||||
"mermaid": "^12.2.0",
|
||||
"next": "16.4.3",
|
||||
"next-themes": "^0.4.6",
|
||||
@@ -37,15 +37,15 @@
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.3.3",
|
||||
"@types/mdx": "^2.0.14",
|
||||
"@types/node": "^26.6.5",
|
||||
"@types/node": "^26.6.6",
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"oxfmt": "0.74.0",
|
||||
"oxlint": "1.89.0",
|
||||
"postcss": "^8.5.31",
|
||||
"oxfmt": "0.75.0",
|
||||
"oxlint": "1.90.0",
|
||||
"postcss": "^8.5.32",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "7.0.2",
|
||||
"vitest": "^5.0.5"
|
||||
},
|
||||
"packageManager": "pnpm@12.15.0"
|
||||
"packageManager": "pnpm@12.16.1"
|
||||
}
|
||||
|
||||
Generated
+288
-288
File diff suppressed because it is too large
Load Diff
@@ -11,8 +11,6 @@ overrides:
|
||||
'lightningcss@<1.33.0': '^1.33.0'
|
||||
minimumReleaseAgeExcludePrune: true
|
||||
minimumReleaseAgeExclude:
|
||||
- fumadocs-core@16.17.8
|
||||
- fumadocs-ui@16.17.8
|
||||
- '@fumadocs/json-schema@0.3.5'
|
||||
- '@next/env@16.4.3'
|
||||
- '@next/swc-darwin-arm64@16.4.3'
|
||||
@@ -24,5 +22,4 @@ minimumReleaseAgeExclude:
|
||||
- '@next/swc-win32-arm64-msvc@16.4.3'
|
||||
- '@next/swc-win32-x64-msvc@16.4.3'
|
||||
- fumadocs-mdx@15.4.10
|
||||
- fumadocs-openapi@12.5.5
|
||||
- next@16.4.3
|
||||
|
||||
+11
-12
@@ -1785,7 +1785,8 @@
|
||||
"type": "string"
|
||||
},
|
||||
"forwardedPorts": {
|
||||
"description": "AmneziaWG per-client port-forwarding spec, e.g. \"80,443,8000-8100\"",
|
||||
"description": "AmneziaWG port-forwarding spec, e.g. \"80,443,8000-8100\"; \"\" withdraws the forwards, omit to keep them",
|
||||
"nullable": true,
|
||||
"type": "string"
|
||||
},
|
||||
"group": {
|
||||
@@ -5650,7 +5651,7 @@
|
||||
"tags": [
|
||||
"Inbounds"
|
||||
],
|
||||
"summary": "Create a new inbound. Send the full inbound payload (protocol, port, settings, streamSettings, sniffing, remark, expiryTime, total, enable). settings, streamSettings, and sniffing may be sent as nested JSON objects (preferred) or as JSON-encoded strings (legacy).",
|
||||
"summary": "Create a new inbound. Send the full inbound payload (protocol, port, settings, streamSettings, sniffing, remark, expiryTime, total, enable). settings, streamSettings, and sniffing may be sent as nested JSON objects (preferred) or as JSON-encoded strings (legacy). VLESS empty fallback arrays are normalized away; nonempty fallbacks cannot be combined with active server decryption.",
|
||||
"operationId": "post_panel_api_inbounds_add",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
@@ -5674,8 +5675,7 @@
|
||||
"email": "user1"
|
||||
}
|
||||
],
|
||||
"decryption": "none",
|
||||
"fallbacks": []
|
||||
"decryption": "none"
|
||||
},
|
||||
"streamSettings": {
|
||||
"network": "tcp",
|
||||
@@ -5846,7 +5846,7 @@
|
||||
"tags": [
|
||||
"Inbounds"
|
||||
],
|
||||
"summary": "Replace an inbound’s configuration. Body shape mirrors /add, but the inbound keeps its stored client list and enable flag: settings.clients and enable in the body are ignored. Manage clients through the /panel/api/clients endpoints and toggle the inbound with /setEnable.",
|
||||
"summary": "Replace an inbound’s configuration. Body shape mirrors /add, but the inbound keeps its stored client list and enable flag: settings.clients and enable in the body are ignored. Manage clients through the /panel/api/clients endpoints and toggle the inbound with /setEnable. Remove effective fallback rules before enabling VLESS server decryption.",
|
||||
"operationId": "post_panel_api_inbounds_update_id",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -5881,8 +5881,7 @@
|
||||
"email": "user1"
|
||||
}
|
||||
],
|
||||
"decryption": "none",
|
||||
"fallbacks": []
|
||||
"decryption": "none"
|
||||
},
|
||||
"streamSettings": {
|
||||
"network": "tcp",
|
||||
@@ -6157,7 +6156,7 @@
|
||||
"tags": [
|
||||
"Inbounds"
|
||||
],
|
||||
"summary": "Bulk-import an inbound from a JSON blob (e.g. one exported via the UI). The body uses form encoding with a single \"data\" field.",
|
||||
"summary": "Bulk-import an inbound from a JSON blob (e.g. one exported via the UI). The body uses form encoding with a single \"data\" field. VLESS fallback validation is identical to /add.",
|
||||
"operationId": "post_panel_api_inbounds_import",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
@@ -6331,7 +6330,7 @@
|
||||
"tags": [
|
||||
"Inbounds"
|
||||
],
|
||||
"summary": "Replace the entire fallback list for a master inbound. Body is JSON. Triggers an Xray restart.",
|
||||
"summary": "Replace the entire fallback list for a master inbound. Body is JSON. Nonempty lists require TCP with TLS/REALITY and no active VLESS server decryption. An empty list can remove rules even from an incompatible inbound. Triggers an Xray restart.",
|
||||
"operationId": "post_panel_api_inbounds_id_fallbacks",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -8846,7 +8845,7 @@
|
||||
],
|
||||
"summary": "Create a new client and attach it to one or more inbounds in a single call. Body is JSON. Per-protocol secrets are generated server-side when omitted, so callers can send only the universal fields.",
|
||||
"operationId": "post_panel_api_clients_add",
|
||||
"description": "Fields the server fills in when they are omitted — a valid value sent by the caller is never overwritten. Re-adding an email that already exists, with its stored `subId`, reuses the stored `id`, `password`, `auth` and `secret` instead of minting new ones, so the identity stays in sync across its inbounds.\n\n- **VLESS / VMess** — `id`, a fresh UUID\n- **Trojan** — `password`\n- **Shadowsocks** — `password`. On a `2022-blake3-*` inbound a supplied password that does not base64-decode to the key length of the cipher (16 or 32 bytes) is replaced by a generated key and the call still succeeds, so read the client back if you did not let the server pick. Legacy ciphers keep any non-empty password\n- **Hysteria** — `auth`\n- **mtproto** — `secret`, a FakeTLS secret derived from the fronting domain of the inbound, or from `www.cloudflare.com` when it has none\n- **WireGuard** — `privateKey` and `publicKey` when both are blank, or `publicKey` alone when only a `privateKey` was sent, plus `allowedIPs`: one free `/32` taken from the /24 the existing peers of that inbound already sit in, or from `10.0.0.0/24` when it has none\n\nAccepted on the same body but never generated: `preSharedKey` and `keepAlive` (WireGuard), `adTag` (mtproto).\n\nWireGuard is the only one of these that can fail. Allocation widens the search to the containing /16 before giving up with `inbound <id>: wireguard: no free address available in <scope>`, and an `allowedIPs` supplied by the caller is validated instead of allocated: `inbound <id>: wireguard: allowedIPs entry <entry> overlaps <address> used by another client` when its range overlaps an address or prefix a different client of that same inbound holds, or `... used by a client on <inbound>` when the holder sits on another WireGuard or AmneziaWG inbound. Ranges are compared, not strings, so `10.0.0.9/24` collides with `10.0.0.5/32`; a `0.0.0.0/0` or `::/0` default route claims no address. Allocation likewise skips every address inside a prefix another client holds. The same validation runs on POST /panel/api/clients/{email}/attach, where a client that already carries an address brings it along.\n\nAn `inboundIds` entry that names no existing inbound rejects the whole call before anything is written. Past that, the inbounds are applied concurrently and independently: one that fails no longer stops the others, so a `success:false` response can still have created the client on the rest. Every error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. `limitHwid` is applied only when every inbound succeeded, so re-run the call after fixing the failure.",
|
||||
"description": "Fields the server fills in when they are omitted — a valid value sent by the caller is never overwritten. Re-adding an email that already exists, with its stored `subId`, reuses the stored `id`, `password`, `auth` and `secret` instead of minting new ones, so the identity stays in sync across its inbounds.\n\n- **VLESS / VMess** — `id`, a fresh UUID\n- **Trojan** — `password`\n- **Shadowsocks** — `password`. On a `2022-blake3-*` inbound a supplied password that does not base64-decode to the key length of the cipher (16 or 32 bytes) is replaced by a generated key and the call still succeeds, so read the client back if you did not let the server pick. Legacy ciphers keep any non-empty password\n- **Hysteria** — `auth`\n- **mtproto** — `secret`, a FakeTLS secret derived from the fronting domain of the inbound, or from `www.cloudflare.com` when it has none\n- **WireGuard** — `privateKey` and `publicKey` when both are blank, or `publicKey` alone when only a `privateKey` was sent, plus `allowedIPs`: one free `/32` taken from the /24 the existing peers of that inbound already sit in, or from `10.0.0.0/24` when it has none\n\nAccepted on the same body but never generated: `preSharedKey` and `keepAlive` (WireGuard), `adTag` (mtproto).\n\nWireGuard is the only one of these that can fail. Allocation widens the search to the containing /16 before giving up with `inbound <id>: wireguard: no free address available in <scope>`, and an `allowedIPs` supplied by the caller is validated instead of allocated: `inbound <id>: wireguard: allowedIPs entry <entry> overlaps <address> used by another client` when its range overlaps an address or prefix a different client of that same inbound holds, or `... used by a client on <inbound>` when the holder sits on another WireGuard or AmneziaWG inbound. Ranges are compared, not strings, so `10.0.0.9/24` collides with `10.0.0.5/32`; a `/0` entry collides with every address of its family another client holds, and only a `0.0.0.0/0` or `::/0` default route that is already stored (legacy peers) keeps nobody out. Allocation likewise skips every address inside a prefix another client holds. The same validation runs on POST /panel/api/clients/{email}/attach, where a client that already carries an address brings it along.\n\nAn `inboundIds` entry that names no existing inbound rejects the whole call before anything is written. Past that, the inbounds are applied concurrently and independently: one that fails no longer stops the others, so a `success:false` response can still have created the client on the rest. Every error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. `limitHwid` is applied only when every inbound succeeded, so re-run the call after fixing the failure.",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
@@ -10163,7 +10162,7 @@
|
||||
"tags": [
|
||||
"Clients"
|
||||
],
|
||||
"summary": "Zero up/down counters for many clients in one call. Loops the single-reset path so each client is re-enabled across its attached inbounds and pushed to Xray/remote nodes. Returns the count of successfully reset clients.",
|
||||
"summary": "Zero up/down counters for many clients in one call. Loops the single-reset path so each client its quota had disabled is re-enabled across its attached inbounds and pushed to Xray/remote nodes; a client disabled by hand stays disabled. Returns the count of successfully reset clients.",
|
||||
"operationId": "post_panel_api_clients_bulkResetTraffic",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
@@ -10504,7 +10503,7 @@
|
||||
"tags": [
|
||||
"Clients"
|
||||
],
|
||||
"summary": "Zero out a single client’s up/down counters. Re-enables the client across every attached inbound and pushes the change to Xray (or the remote node) so depleted users can connect again immediately.",
|
||||
"summary": "Zero out a single client’s up/down counters. Re-enables a client its quota had disabled across every attached inbound and pushes the change to Xray (or the remote node) so depleted users can connect again immediately; a client disabled by hand stays disabled.",
|
||||
"operationId": "post_panel_api_clients_resetTraffic_email",
|
||||
"parameters": [
|
||||
{
|
||||
|
||||
+11
-4
@@ -11,7 +11,8 @@
|
||||
"jsPlugins": [
|
||||
"./tools/oxlint/input-number-guard.mjs",
|
||||
"./tools/oxlint/control-label-guard.mjs",
|
||||
"./tools/oxlint/icon-a11y-guard.mjs"
|
||||
"./tools/oxlint/icon-a11y-guard.mjs",
|
||||
"./tools/oxlint/antd-a11y-guard.mjs"
|
||||
],
|
||||
"categories": {
|
||||
"correctness": "error"
|
||||
@@ -52,7 +53,9 @@
|
||||
"input-number/no-synthetic-clear": "off",
|
||||
"jsx-a11y/prefer-tag-over-role": "off",
|
||||
"control-label/has-name": "off",
|
||||
"antd-icon/explicit-a11y": "off"
|
||||
"antd-icon/explicit-a11y": "off",
|
||||
"antd-a11y/spin-content-not-live": "off",
|
||||
"antd-a11y/select-tag-remove": "off"
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
@@ -78,7 +81,9 @@
|
||||
],
|
||||
"rules": {
|
||||
"control-label/has-name": "error",
|
||||
"antd-icon/explicit-a11y": "error"
|
||||
"antd-icon/explicit-a11y": "error",
|
||||
"antd-a11y/spin-content-not-live": "error",
|
||||
"antd-a11y/select-tag-remove": "error"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -88,7 +93,9 @@
|
||||
],
|
||||
"rules": {
|
||||
"control-label/has-name": "off",
|
||||
"antd-icon/explicit-a11y": "off"
|
||||
"antd-icon/explicit-a11y": "off",
|
||||
"antd-a11y/spin-content-not-live": "off",
|
||||
"antd-a11y/select-tag-remove": "off"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -5,6 +5,7 @@ import i18next from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
|
||||
import { buildAntdThemeConfig } from '@/hooks/useTheme';
|
||||
import { INTERPOLATION } from '@/i18n/react';
|
||||
import enUS from '../../internal/web/translation/en-US.json';
|
||||
|
||||
if (!i18next.isInitialized) {
|
||||
@@ -12,7 +13,7 @@ if (!i18next.isInitialized) {
|
||||
lng: 'en-US',
|
||||
fallbackLng: 'en-US',
|
||||
resources: { 'en-US': { translation: enUS } },
|
||||
interpolation: { escapeValue: false, prefix: '{', suffix: '}' },
|
||||
interpolation: INTERPOLATION,
|
||||
returnNull: false,
|
||||
});
|
||||
}
|
||||
|
||||
Generated
+604
-588
File diff suppressed because it is too large
Load Diff
+23
-19
@@ -38,47 +38,47 @@
|
||||
"@ant-design/icons": "^6.3.4",
|
||||
"@codemirror/lang-json": "^6.0.2",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@hookform/resolvers": "^6.0.0",
|
||||
"@hookform/resolvers": "^7.0.1",
|
||||
"@noble/hashes": "^2.4.0",
|
||||
"@tanstack/react-query": "^5.104.5",
|
||||
"@tanstack/react-query-devtools": "^5.104.5",
|
||||
"antd": "^6.6.7",
|
||||
"@tanstack/react-query": "^5.105.1",
|
||||
"@tanstack/react-query-devtools": "^5.105.1",
|
||||
"antd": "^6.6.8",
|
||||
"codemirror": "^6.0.2",
|
||||
"dayjs": "^1.11.23",
|
||||
"i18next": "^26.5.6",
|
||||
"i18next": "^26.6.0",
|
||||
"otpauth": "^9.5.2",
|
||||
"persian-calendar-suite": "^1.5.6",
|
||||
"react": "^19.3.1",
|
||||
"react-dom": "^19.3.1",
|
||||
"react-hook-form": "^7.90.0",
|
||||
"react-i18next": "^17.1.3",
|
||||
"react-router": "^8.5.0",
|
||||
"swagger-ui-react": "^5.34.0",
|
||||
"react-hook-form": "^7.91.0",
|
||||
"react-i18next": "^17.1.10",
|
||||
"react-router": "^8.5.1",
|
||||
"swagger-ui-react": "^5.34.1",
|
||||
"uplot": "^1.6.32",
|
||||
"zod": "^4.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@storybook/addon-a11y": "^10.6.3",
|
||||
"@storybook/addon-docs": "^10.6.3",
|
||||
"@storybook/addon-vitest": "^10.6.3",
|
||||
"@storybook/react-vite": "^10.6.3",
|
||||
"@storybook/addon-a11y": "^10.6.5",
|
||||
"@storybook/addon-docs": "^10.6.5",
|
||||
"@storybook/addon-vitest": "^10.6.5",
|
||||
"@storybook/react-vite": "^10.6.5",
|
||||
"@testing-library/dom": "^10.4.2",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"@types/swagger-ui-react": "^5.18.0",
|
||||
"@vitejs/plugin-react": "^6.1.2",
|
||||
"@vitejs/plugin-react": "^6.2.0",
|
||||
"@vitest/browser-playwright": "5.0.5",
|
||||
"@vitest/coverage-v8": "^5.0.5",
|
||||
"husky": "^9.1.7",
|
||||
"jsdom": "^30.1.3",
|
||||
"lint-staged": "^17.6.0",
|
||||
"msw": "^3.0.3",
|
||||
"oxfmt": "0.74.0",
|
||||
"oxlint": "1.89.0",
|
||||
"oxlint-tsgolint": "^7.0.2004",
|
||||
"oxfmt": "0.75.0",
|
||||
"oxlint": "1.90.0",
|
||||
"oxlint-tsgolint": "^7.0.2005",
|
||||
"playwright": "^1.64.1",
|
||||
"storybook": "^10.6.3",
|
||||
"storybook": "^10.6.5",
|
||||
"typescript": "7.0.2",
|
||||
"vite": "8.3.5",
|
||||
"vitest": "^5.0.5"
|
||||
@@ -94,7 +94,11 @@
|
||||
},
|
||||
"swagger-ui-react": {
|
||||
"js-yaml": "^4.3.2",
|
||||
"brace-expansion": "^5.0.9"
|
||||
"brace-expansion": "^5.0.9",
|
||||
"minimatch": "^11.0.3"
|
||||
},
|
||||
"@joshwooding/vite-plugin-react-docgen-typescript": {
|
||||
"glob": "^14.0.3"
|
||||
},
|
||||
"@typeschema/valibot": {
|
||||
"valibot": "^1.1.0"
|
||||
|
||||
@@ -1785,7 +1785,8 @@
|
||||
"type": "string"
|
||||
},
|
||||
"forwardedPorts": {
|
||||
"description": "AmneziaWG per-client port-forwarding spec, e.g. \"80,443,8000-8100\"",
|
||||
"description": "AmneziaWG port-forwarding spec, e.g. \"80,443,8000-8100\"; \"\" withdraws the forwards, omit to keep them",
|
||||
"nullable": true,
|
||||
"type": "string"
|
||||
},
|
||||
"group": {
|
||||
@@ -5650,7 +5651,7 @@
|
||||
"tags": [
|
||||
"Inbounds"
|
||||
],
|
||||
"summary": "Create a new inbound. Send the full inbound payload (protocol, port, settings, streamSettings, sniffing, remark, expiryTime, total, enable). settings, streamSettings, and sniffing may be sent as nested JSON objects (preferred) or as JSON-encoded strings (legacy).",
|
||||
"summary": "Create a new inbound. Send the full inbound payload (protocol, port, settings, streamSettings, sniffing, remark, expiryTime, total, enable). settings, streamSettings, and sniffing may be sent as nested JSON objects (preferred) or as JSON-encoded strings (legacy). VLESS empty fallback arrays are normalized away; nonempty fallbacks cannot be combined with active server decryption.",
|
||||
"operationId": "post_panel_api_inbounds_add",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
@@ -5674,8 +5675,7 @@
|
||||
"email": "user1"
|
||||
}
|
||||
],
|
||||
"decryption": "none",
|
||||
"fallbacks": []
|
||||
"decryption": "none"
|
||||
},
|
||||
"streamSettings": {
|
||||
"network": "tcp",
|
||||
@@ -5846,7 +5846,7 @@
|
||||
"tags": [
|
||||
"Inbounds"
|
||||
],
|
||||
"summary": "Replace an inbound’s configuration. Body shape mirrors /add, but the inbound keeps its stored client list and enable flag: settings.clients and enable in the body are ignored. Manage clients through the /panel/api/clients endpoints and toggle the inbound with /setEnable.",
|
||||
"summary": "Replace an inbound’s configuration. Body shape mirrors /add, but the inbound keeps its stored client list and enable flag: settings.clients and enable in the body are ignored. Manage clients through the /panel/api/clients endpoints and toggle the inbound with /setEnable. Remove effective fallback rules before enabling VLESS server decryption.",
|
||||
"operationId": "post_panel_api_inbounds_update_id",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -5881,8 +5881,7 @@
|
||||
"email": "user1"
|
||||
}
|
||||
],
|
||||
"decryption": "none",
|
||||
"fallbacks": []
|
||||
"decryption": "none"
|
||||
},
|
||||
"streamSettings": {
|
||||
"network": "tcp",
|
||||
@@ -6157,7 +6156,7 @@
|
||||
"tags": [
|
||||
"Inbounds"
|
||||
],
|
||||
"summary": "Bulk-import an inbound from a JSON blob (e.g. one exported via the UI). The body uses form encoding with a single \"data\" field.",
|
||||
"summary": "Bulk-import an inbound from a JSON blob (e.g. one exported via the UI). The body uses form encoding with a single \"data\" field. VLESS fallback validation is identical to /add.",
|
||||
"operationId": "post_panel_api_inbounds_import",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
@@ -6331,7 +6330,7 @@
|
||||
"tags": [
|
||||
"Inbounds"
|
||||
],
|
||||
"summary": "Replace the entire fallback list for a master inbound. Body is JSON. Triggers an Xray restart.",
|
||||
"summary": "Replace the entire fallback list for a master inbound. Body is JSON. Nonempty lists require TCP with TLS/REALITY and no active VLESS server decryption. An empty list can remove rules even from an incompatible inbound. Triggers an Xray restart.",
|
||||
"operationId": "post_panel_api_inbounds_id_fallbacks",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -8846,7 +8845,7 @@
|
||||
],
|
||||
"summary": "Create a new client and attach it to one or more inbounds in a single call. Body is JSON. Per-protocol secrets are generated server-side when omitted, so callers can send only the universal fields.",
|
||||
"operationId": "post_panel_api_clients_add",
|
||||
"description": "Fields the server fills in when they are omitted — a valid value sent by the caller is never overwritten. Re-adding an email that already exists, with its stored `subId`, reuses the stored `id`, `password`, `auth` and `secret` instead of minting new ones, so the identity stays in sync across its inbounds.\n\n- **VLESS / VMess** — `id`, a fresh UUID\n- **Trojan** — `password`\n- **Shadowsocks** — `password`. On a `2022-blake3-*` inbound a supplied password that does not base64-decode to the key length of the cipher (16 or 32 bytes) is replaced by a generated key and the call still succeeds, so read the client back if you did not let the server pick. Legacy ciphers keep any non-empty password\n- **Hysteria** — `auth`\n- **mtproto** — `secret`, a FakeTLS secret derived from the fronting domain of the inbound, or from `www.cloudflare.com` when it has none\n- **WireGuard** — `privateKey` and `publicKey` when both are blank, or `publicKey` alone when only a `privateKey` was sent, plus `allowedIPs`: one free `/32` taken from the /24 the existing peers of that inbound already sit in, or from `10.0.0.0/24` when it has none\n\nAccepted on the same body but never generated: `preSharedKey` and `keepAlive` (WireGuard), `adTag` (mtproto).\n\nWireGuard is the only one of these that can fail. Allocation widens the search to the containing /16 before giving up with `inbound <id>: wireguard: no free address available in <scope>`, and an `allowedIPs` supplied by the caller is validated instead of allocated: `inbound <id>: wireguard: allowedIPs entry <entry> overlaps <address> used by another client` when its range overlaps an address or prefix a different client of that same inbound holds, or `... used by a client on <inbound>` when the holder sits on another WireGuard or AmneziaWG inbound. Ranges are compared, not strings, so `10.0.0.9/24` collides with `10.0.0.5/32`; a `0.0.0.0/0` or `::/0` default route claims no address. Allocation likewise skips every address inside a prefix another client holds. The same validation runs on POST /panel/api/clients/{email}/attach, where a client that already carries an address brings it along.\n\nAn `inboundIds` entry that names no existing inbound rejects the whole call before anything is written. Past that, the inbounds are applied concurrently and independently: one that fails no longer stops the others, so a `success:false` response can still have created the client on the rest. Every error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. `limitHwid` is applied only when every inbound succeeded, so re-run the call after fixing the failure.",
|
||||
"description": "Fields the server fills in when they are omitted — a valid value sent by the caller is never overwritten. Re-adding an email that already exists, with its stored `subId`, reuses the stored `id`, `password`, `auth` and `secret` instead of minting new ones, so the identity stays in sync across its inbounds.\n\n- **VLESS / VMess** — `id`, a fresh UUID\n- **Trojan** — `password`\n- **Shadowsocks** — `password`. On a `2022-blake3-*` inbound a supplied password that does not base64-decode to the key length of the cipher (16 or 32 bytes) is replaced by a generated key and the call still succeeds, so read the client back if you did not let the server pick. Legacy ciphers keep any non-empty password\n- **Hysteria** — `auth`\n- **mtproto** — `secret`, a FakeTLS secret derived from the fronting domain of the inbound, or from `www.cloudflare.com` when it has none\n- **WireGuard** — `privateKey` and `publicKey` when both are blank, or `publicKey` alone when only a `privateKey` was sent, plus `allowedIPs`: one free `/32` taken from the /24 the existing peers of that inbound already sit in, or from `10.0.0.0/24` when it has none\n\nAccepted on the same body but never generated: `preSharedKey` and `keepAlive` (WireGuard), `adTag` (mtproto).\n\nWireGuard is the only one of these that can fail. Allocation widens the search to the containing /16 before giving up with `inbound <id>: wireguard: no free address available in <scope>`, and an `allowedIPs` supplied by the caller is validated instead of allocated: `inbound <id>: wireguard: allowedIPs entry <entry> overlaps <address> used by another client` when its range overlaps an address or prefix a different client of that same inbound holds, or `... used by a client on <inbound>` when the holder sits on another WireGuard or AmneziaWG inbound. Ranges are compared, not strings, so `10.0.0.9/24` collides with `10.0.0.5/32`; a `/0` entry collides with every address of its family another client holds, and only a `0.0.0.0/0` or `::/0` default route that is already stored (legacy peers) keeps nobody out. Allocation likewise skips every address inside a prefix another client holds. The same validation runs on POST /panel/api/clients/{email}/attach, where a client that already carries an address brings it along.\n\nAn `inboundIds` entry that names no existing inbound rejects the whole call before anything is written. Past that, the inbounds are applied concurrently and independently: one that fails no longer stops the others, so a `success:false` response can still have created the client on the rest. Every error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. `limitHwid` is applied only when every inbound succeeded, so re-run the call after fixing the failure.",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
@@ -10163,7 +10162,7 @@
|
||||
"tags": [
|
||||
"Clients"
|
||||
],
|
||||
"summary": "Zero up/down counters for many clients in one call. Loops the single-reset path so each client is re-enabled across its attached inbounds and pushed to Xray/remote nodes. Returns the count of successfully reset clients.",
|
||||
"summary": "Zero up/down counters for many clients in one call. Loops the single-reset path so each client its quota had disabled is re-enabled across its attached inbounds and pushed to Xray/remote nodes; a client disabled by hand stays disabled. Returns the count of successfully reset clients.",
|
||||
"operationId": "post_panel_api_clients_bulkResetTraffic",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
@@ -10504,7 +10503,7 @@
|
||||
"tags": [
|
||||
"Clients"
|
||||
],
|
||||
"summary": "Zero out a single client’s up/down counters. Re-enables the client across every attached inbound and pushes the change to Xray (or the remote node) so depleted users can connect again immediately.",
|
||||
"summary": "Zero out a single client’s up/down counters. Re-enables a client its quota had disabled across every attached inbound and pushes the change to Xray (or the remote node) so depleted users can connect again immediately; a client disabled by hand stays disabled.",
|
||||
"operationId": "post_panel_api_clients_resetTraffic_email",
|
||||
"parameters": [
|
||||
{
|
||||
|
||||
@@ -20,14 +20,19 @@ export function useHostMutations() {
|
||||
|
||||
const updateMut = useMutation({
|
||||
mutationFn: ({ groupId, payload }: { groupId: string; payload: BulkAddHostValues }) =>
|
||||
HttpUtil.post(`/panel/api/hosts/update/${groupId}`, payload, JSON_HEADERS),
|
||||
HttpUtil.post(
|
||||
`/panel/api/hosts/update/${encodeURIComponent(groupId)}`,
|
||||
payload,
|
||||
JSON_HEADERS,
|
||||
),
|
||||
onSuccess: (msg) => {
|
||||
if (msg?.success) invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const removeMut = useMutation({
|
||||
mutationFn: (groupId: string) => HttpUtil.post(`/panel/api/hosts/del/${groupId}`),
|
||||
mutationFn: (groupId: string) =>
|
||||
HttpUtil.post(`/panel/api/hosts/del/${encodeURIComponent(groupId)}`),
|
||||
onSuccess: (msg) => {
|
||||
if (msg?.success) invalidate();
|
||||
},
|
||||
@@ -35,7 +40,7 @@ export function useHostMutations() {
|
||||
|
||||
const setEnableMut = useMutation({
|
||||
mutationFn: ({ groupId, enable }: { groupId: string; enable: boolean }) =>
|
||||
HttpUtil.post(`/panel/api/hosts/setEnable/${groupId}`, { enable }),
|
||||
HttpUtil.post(`/panel/api/hosts/setEnable/${encodeURIComponent(groupId)}`, { enable }),
|
||||
onSuccess: (msg) => {
|
||||
if (msg?.success) invalidate();
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { SelectProps } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { TLS_CIPHER_OPTION, TLS_CIPHER_PRESETS } from '@/schemas/primitives';
|
||||
import { selectTagRender } from './SelectTag';
|
||||
|
||||
const CIPHER_SUITE_OPTIONS = Object.values(TLS_CIPHER_OPTION).map((v) => ({ value: v, label: v }));
|
||||
|
||||
@@ -46,6 +47,7 @@ export default function CipherSuitesSelect({
|
||||
tokenSeparators={[':', ',']}
|
||||
{...rest}
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
options={[
|
||||
{ label: t('pages.inbounds.form.presets'), options: PRESET_OPTIONS },
|
||||
{ label: t('pages.inbounds.form.cipherSuites'), options: CIPHER_SUITE_OPTIONS },
|
||||
|
||||
@@ -4,6 +4,7 @@ import { expect } from 'storybook/test';
|
||||
import { Select } from 'antd';
|
||||
|
||||
import SelectAllClearButtons from './SelectAllClearButtons';
|
||||
import { selectTagRender } from './SelectTag';
|
||||
|
||||
const inboundOptions: Array<{ value: number; label: string }> = [
|
||||
{ value: 1, label: 'VLESS Reality — 443' },
|
||||
@@ -64,13 +65,13 @@ function InboundPickerDemo() {
|
||||
<SelectAllClearButtons options={inboundOptions} value={selected} onChange={setSelected} />
|
||||
<Select
|
||||
mode="multiple"
|
||||
tagRender={selectTagRender}
|
||||
style={{ width: '100%' }}
|
||||
value={selected}
|
||||
onChange={setSelected}
|
||||
options={inboundOptions}
|
||||
placeholder="Select inbounds"
|
||||
aria-label="Select inbounds"
|
||||
maxTagCount="responsive"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -89,13 +90,13 @@ function ClientEmailsDemo() {
|
||||
/>
|
||||
<Select
|
||||
mode="multiple"
|
||||
tagRender={selectTagRender}
|
||||
style={{ width: '100%' }}
|
||||
value={selected}
|
||||
onChange={setSelected}
|
||||
options={clientEmailOptions}
|
||||
placeholder="Select clients"
|
||||
aria-label="Select clients"
|
||||
maxTagCount="responsive"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
.select-tag-remove {
|
||||
border: 0;
|
||||
background: none;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.select-tag-remove:focus-visible {
|
||||
outline: 2px solid var(--ant-color-primary);
|
||||
outline-offset: 1px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useState } from 'react';
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { expect, waitFor } from 'storybook/test';
|
||||
import { Select } from 'antd';
|
||||
|
||||
import { selectTagRender } from './SelectTag';
|
||||
|
||||
const meta = {
|
||||
title: 'Form/SelectTag',
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
layout: 'padded',
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Tag renderer for every `mode="tags"` / `mode="multiple"` Select. Ant Design draws the tag "x" as an aria-hidden, unfocusable span; `tagRender={selectTagRender}` swaps in a real button named "Remove <value>", so keyboard and screen-reader users can remove any tag, and focus moves to the next one.',
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Meta;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
function SniDemo() {
|
||||
const [value, setValue] = useState(['www.microsoft.com', 'www.apple.com', 'dl.google.com']);
|
||||
return (
|
||||
<div style={{ maxWidth: 420 }}>
|
||||
<Select
|
||||
aria-label="SNI"
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
tokenSeparators={[',']}
|
||||
style={{ width: '100%' }}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InboundDemo() {
|
||||
const options = [
|
||||
{ value: 1, label: 'VLESS Reality — 443' },
|
||||
{ value: 2, label: 'VMess WS — 8443' },
|
||||
{ value: 3, label: 'Trojan TCP — 2053' },
|
||||
];
|
||||
const [value, setValue] = useState([1, 2, 3]);
|
||||
return (
|
||||
<div style={{ maxWidth: 420 }}>
|
||||
<Select
|
||||
aria-label="Inbounds"
|
||||
mode="multiple"
|
||||
tagRender={selectTagRender}
|
||||
style={{ width: '100%' }}
|
||||
options={options}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const FreeTextTags: Story = {
|
||||
render: () => <SniDemo />,
|
||||
play: async ({ canvas, userEvent }) => {
|
||||
await userEvent.tab();
|
||||
await userEvent.tab();
|
||||
await expect(canvas.getByRole('button', { name: 'Remove www.apple.com' })).toHaveFocus();
|
||||
await userEvent.keyboard('{Enter}');
|
||||
await expect(canvas.queryByText('www.apple.com')).toBeNull();
|
||||
await waitFor(() =>
|
||||
expect(canvas.getByRole('button', { name: 'Remove dl.google.com' })).toHaveFocus(),
|
||||
);
|
||||
await expect(canvas.getByRole('combobox', { name: 'SNI' })).toHaveAttribute(
|
||||
'aria-expanded',
|
||||
'false',
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const OptionLabels: Story = {
|
||||
render: () => <InboundDemo />,
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useContext } from 'react';
|
||||
import type { KeyboardEvent, MouseEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ConfigProvider } from 'antd';
|
||||
import type { SelectProps } from 'antd';
|
||||
import { CloseOutlined } from '@ant-design/icons';
|
||||
import './SelectTag.css';
|
||||
|
||||
type SelectTagProps = Parameters<NonNullable<SelectProps['tagRender']>>[0];
|
||||
|
||||
const REMOVE_CLASS = 'select-tag-remove';
|
||||
|
||||
// Stops rc-select from opening the dropdown or eating Enter/Space before the click fires.
|
||||
function keepFromSelect(e: KeyboardEvent | MouseEvent) {
|
||||
if ('key' in e && e.key === 'Tab') return;
|
||||
e.stopPropagation();
|
||||
if (e.type === 'mousedown') e.preventDefault();
|
||||
}
|
||||
|
||||
function SelectTag({ label, value, closable, onClose }: SelectTagProps) {
|
||||
const { t } = useTranslation();
|
||||
const { getPrefixCls } = useContext(ConfigProvider.ConfigContext);
|
||||
const selectCls = getPrefixCls('select');
|
||||
const itemCls = `${selectCls}-selection-item`;
|
||||
const text =
|
||||
typeof label === 'string' || typeof label === 'number' ? String(label) : String(value);
|
||||
|
||||
const remove = (e: MouseEvent<HTMLButtonElement>) => {
|
||||
const button = e.currentTarget;
|
||||
const root = button.closest(`.${selectCls}`);
|
||||
const hadFocus = document.activeElement === button;
|
||||
const index = root ? [...root.querySelectorAll(`.${REMOVE_CLASS}`)].indexOf(button) : -1;
|
||||
onClose(e);
|
||||
if (!root || !hadFocus) return;
|
||||
// Keyboard users would otherwise land on <body> once this tag unmounts.
|
||||
requestAnimationFrame(() => {
|
||||
const rest = root.querySelectorAll<HTMLElement>(`.${REMOVE_CLASS}`);
|
||||
const next = rest[Math.min(index, rest.length - 1)] ?? root.querySelector('input');
|
||||
next?.focus();
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={itemCls} title={text}>
|
||||
<span className={`${itemCls}-content`}>{label}</span>
|
||||
{closable && (
|
||||
<button
|
||||
type="button"
|
||||
className={`${itemCls}-remove ${REMOVE_CLASS}`}
|
||||
aria-label={t('removeItem', { item: text })}
|
||||
onMouseDown={keepFromSelect}
|
||||
onKeyDown={keepFromSelect}
|
||||
onClick={remove}
|
||||
>
|
||||
<CloseOutlined aria-hidden />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Ant Design renders each tag's "x" as an aria-hidden, unfocusable span, so screen-reader
|
||||
// and keyboard users cannot remove a tag; this swaps in a real, named button.
|
||||
export function selectTagRender(props: SelectTagProps) {
|
||||
return <SelectTag {...props} />;
|
||||
}
|
||||
@@ -7,3 +7,4 @@ export { default as CipherSuitesSelect } from './CipherSuitesSelect';
|
||||
export { default as RemarkTemplateField } from './RemarkTemplateField';
|
||||
export { default as RemarkVarPicker } from './RemarkVarPicker';
|
||||
export { default as CustomSockoptList } from '../../lib/xray/forms/transport/CustomSockoptList';
|
||||
export { selectTagRender } from './SelectTag';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { lazy, useState } from 'react';
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { Alert, Button, Card, Skeleton, Space, Switch, Tag, Typography } from 'antd';
|
||||
import { Alert, Button, Card, Layout, Modal, Skeleton, Space, Switch, Tag, Typography } from 'antd';
|
||||
import { expect, userEvent, within } from 'storybook/test';
|
||||
|
||||
import LazyMount from './LazyMount';
|
||||
|
||||
@@ -21,7 +22,10 @@ const meta = {
|
||||
when: {
|
||||
description: 'Children mount the first time this becomes true and stay mounted afterwards.',
|
||||
},
|
||||
fallback: { description: 'Suspense fallback shown while a React.lazy child is still loading.' },
|
||||
fallback: {
|
||||
description:
|
||||
'Suspense fallback shown while a React.lazy child is loading. The default fullscreen spinner does not occupy space in the surrounding layout; custom fallbacks render as supplied.',
|
||||
},
|
||||
children: { description: 'Content to mount on demand, typically a lazily imported modal.' },
|
||||
},
|
||||
} satisfies Meta<typeof LazyMount>;
|
||||
@@ -145,3 +149,53 @@ export const MountedImmediately: Story = {
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
function LazyModalInLayoutDemo() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [SlowModal] = useState(() =>
|
||||
lazy(
|
||||
() =>
|
||||
new Promise<{ default: typeof Modal }>((resolve) => {
|
||||
setTimeout(() => resolve({ default: Modal }), 1200);
|
||||
}),
|
||||
),
|
||||
);
|
||||
return (
|
||||
<Layout hasSider style={{ minHeight: 240 }}>
|
||||
<Layout.Sider width={96} style={{ color: '#fff' }}>
|
||||
Navigation
|
||||
</Layout.Sider>
|
||||
<Layout.Content data-testid="lazy-modal-content" style={{ padding: 16 }}>
|
||||
<Button onClick={() => setOpen(true)}>Open lazy dialog</Button>
|
||||
<Typography.Paragraph>
|
||||
The content width stays fixed while the dialog loads.
|
||||
</Typography.Paragraph>
|
||||
</Layout.Content>
|
||||
<LazyMount when={open}>
|
||||
<SlowModal title="Lazy dialog" open={open} onCancel={() => setOpen(false)} footer={null}>
|
||||
Dialog loaded without resizing the page.
|
||||
</SlowModal>
|
||||
</LazyMount>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
export const LazyModalInLayout: Story = {
|
||||
args: placeholderArgs,
|
||||
render: () => <LazyModalInLayoutDemo />,
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const content = canvas.getByTestId('lazy-modal-content');
|
||||
const width = content.getBoundingClientRect().width;
|
||||
await userEvent.click(canvas.getByRole('button', { name: 'Open lazy dialog' }));
|
||||
const widths: number[] = [];
|
||||
for (let frame = 0; frame < 12; frame++) {
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
||||
widths.push(content.getBoundingClientRect().width);
|
||||
}
|
||||
expect(Math.min(...widths)).toBeCloseTo(width, 1);
|
||||
const page = within(canvasElement.ownerDocument.body);
|
||||
await page.findByRole('dialog', { name: 'Lazy dialog' }, { timeout: 5000 });
|
||||
await userEvent.click(page.getByRole('button', { name: 'Close' }));
|
||||
},
|
||||
};
|
||||
|
||||
@@ -11,7 +11,11 @@ interface LazyMountProps {
|
||||
// thereafter, so React.lazy modals get loaded on demand but their close
|
||||
// animations still play out. Pair with `lazy(() => import(...))` modal imports
|
||||
// on heavy list pages to keep the initial bundle small.
|
||||
export default function LazyMount({ when, fallback = <Spin />, children }: LazyMountProps) {
|
||||
export default function LazyMount({
|
||||
when,
|
||||
fallback = <Spin fullscreen />,
|
||||
children,
|
||||
}: LazyMountProps) {
|
||||
const [mounted, setMounted] = useState(when);
|
||||
if (when && !mounted) setMounted(true);
|
||||
if (!mounted) return null;
|
||||
|
||||
@@ -431,7 +431,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"enable": false,
|
||||
"expiryTime": 0,
|
||||
"flow": "",
|
||||
"forwardedPorts": "",
|
||||
"forwardedPorts": null,
|
||||
"group": "",
|
||||
"id": "",
|
||||
"keepAlive": 0,
|
||||
|
||||
@@ -1759,7 +1759,8 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"type": "string"
|
||||
},
|
||||
"forwardedPorts": {
|
||||
"description": "AmneziaWG per-client port-forwarding spec, e.g. \"80,443,8000-8100\"",
|
||||
"description": "AmneziaWG port-forwarding spec, e.g. \"80,443,8000-8100\"; \"\" withdraws the forwards, omit to keep them",
|
||||
"nullable": true,
|
||||
"type": "string"
|
||||
},
|
||||
"group": {
|
||||
|
||||
@@ -426,7 +426,7 @@ export interface Client {
|
||||
enable: boolean;
|
||||
expiryTime: number;
|
||||
flow?: string;
|
||||
forwardedPorts?: string;
|
||||
forwardedPorts?: string | null;
|
||||
group?: string;
|
||||
id?: string;
|
||||
keepAlive?: number | string;
|
||||
|
||||
@@ -450,7 +450,7 @@ export const ClientSchema = z.object({
|
||||
enable: z.boolean(),
|
||||
expiryTime: z.number().int(),
|
||||
flow: z.string().optional(),
|
||||
forwardedPorts: z.string().optional(),
|
||||
forwardedPorts: z.string().nullable().optional(),
|
||||
group: z.string().optional(),
|
||||
id: z.string().optional(),
|
||||
keepAlive: z.union([z.number().int(), z.string()]).optional(),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import i18next from 'i18next';
|
||||
import type { InterpolationOptions } from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
|
||||
import { LanguageManager } from '@/utils';
|
||||
@@ -8,6 +9,16 @@ import enUS from '../../../internal/web/translation/en-US.json';
|
||||
|
||||
const FALLBACK = 'en-US';
|
||||
|
||||
// No locale uses $t(...) nesting, and its regex backtracks exponentially on an unclosed
|
||||
// "$t(" in untrusted text (node-reported names, remarks); no off switch, so never match.
|
||||
export const INTERPOLATION: InterpolationOptions = {
|
||||
escapeValue: false,
|
||||
prefix: '{',
|
||||
suffix: '}',
|
||||
nestingPrefix: '',
|
||||
nestingPrefixEscaped: '(?!)',
|
||||
};
|
||||
|
||||
const lazyModules = import.meta.glob([
|
||||
'../../../internal/web/translation/*.json',
|
||||
'!../../../internal/web/translation/en-US.json',
|
||||
@@ -30,7 +41,7 @@ export async function readyI18n(scope: LanguageScope = 'panel') {
|
||||
lng: active,
|
||||
fallbackLng: FALLBACK,
|
||||
resources: { [FALLBACK]: { translation: enUS } },
|
||||
interpolation: { escapeValue: false, prefix: '{', suffix: '}' },
|
||||
interpolation: INTERPOLATION,
|
||||
returnNull: false,
|
||||
});
|
||||
if (active !== FALLBACK) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Form, Select, Switch } from 'antd';
|
||||
import type { FormInstance } from 'antd/es/form';
|
||||
|
||||
import { selectTagRender } from '@/components/form/SelectTag';
|
||||
import { SNIFFING_OPTION } from '@/schemas/primitives';
|
||||
|
||||
const DEST_OPTIONS = Object.entries(SNIFFING_OPTION).map(([label, value]) => ({ value, label }));
|
||||
@@ -36,6 +37,7 @@ export default function SniffingFields({ name, form, enableLabel }: SniffingFiel
|
||||
<Form.Item name={[...name, 'destOverride']} wrapperCol={{ md: { span: 14, offset: 8 } }}>
|
||||
<Select
|
||||
mode="multiple"
|
||||
tagRender={selectTagRender}
|
||||
className="sniffing-options"
|
||||
aria-label={t('pages.inbounds.sniffingDestOverride')}
|
||||
options={DEST_OPTIONS}
|
||||
@@ -61,6 +63,7 @@ export default function SniffingFields({ name, form, enableLabel }: SniffingFiel
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
tokenSeparators={[',']}
|
||||
placeholder="IP/CIDR/geoip:*/ext:*"
|
||||
style={{ width: '100%' }}
|
||||
@@ -72,6 +75,7 @@ export default function SniffingFields({ name, form, enableLabel }: SniffingFiel
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
tokenSeparators={[',']}
|
||||
placeholder="domain:*/ext:*"
|
||||
style={{ width: '100%' }}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import type { FormInstance } from 'antd/es/form';
|
||||
import type { NamePath } from 'antd/es/form/interface';
|
||||
|
||||
import { selectTagRender } from '@/components/form/SelectTag';
|
||||
import { RandomUtil } from '@/utils';
|
||||
import { activateOnKey } from '@/utils/a11y';
|
||||
import { OutboundProtocols, UTLS_FINGERPRINT } from '@/schemas/primitives';
|
||||
@@ -608,6 +609,7 @@ function FilterMaskSettings({ fieldName }: { fieldName: number }) {
|
||||
<Select
|
||||
aria-describedby={`${uid}-allowed-hint`}
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
style={{ width: '100%' }}
|
||||
tokenSeparators={[',', ' ']}
|
||||
placeholder="10.0.0.0/8, geoip:ir"
|
||||
@@ -624,6 +626,7 @@ function FilterMaskSettings({ fieldName }: { fieldName: number }) {
|
||||
<Select
|
||||
aria-describedby={`${uid}-blocked-hint`}
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
style={{ width: '100%' }}
|
||||
tokenSeparators={[',', ' ']}
|
||||
placeholder="203.0.113.7, geoip:cn"
|
||||
@@ -649,7 +652,12 @@ function SudokuSettings({ fieldName }: { fieldName: number }) {
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label="Custom Tables" name={[fieldName, 'settings', 'customTables']}>
|
||||
<Select mode="tags" style={{ width: '100%' }} tokenSeparators={[',']} />
|
||||
<Select
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
style={{ width: '100%' }}
|
||||
tokenSeparators={[',']}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="Padding Min" name={[fieldName, 'settings', 'paddingMin']}>
|
||||
<InputNumber min={0} />
|
||||
@@ -1175,7 +1183,12 @@ function UdpMaskItem({
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item label="IPs" name={[fieldName, 'settings', 'ips']}>
|
||||
<Select mode="tags" style={{ width: '100%' }} tokenSeparators={[',']} />
|
||||
<Select
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
style={{ width: '100%' }}
|
||||
tokenSeparators={[',']}
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
);
|
||||
@@ -1189,6 +1202,7 @@ function UdpMaskItem({
|
||||
<Form.Item label="STUN Servers" name={[fieldName, 'settings', 'stunServers']}>
|
||||
<Select
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
style={{ width: '100%' }}
|
||||
tokenSeparators={[',']}
|
||||
placeholder="host:port"
|
||||
@@ -1236,6 +1250,7 @@ function UdpMaskItem({
|
||||
<Form.Item label="ALPN" name={[fieldName, 'settings', 'tlsConfig', 'alpn']}>
|
||||
<Select
|
||||
mode="multiple"
|
||||
tagRender={selectTagRender}
|
||||
style={{ width: '100%' }}
|
||||
options={[
|
||||
{ value: 'h3', label: 'h3' },
|
||||
@@ -1529,6 +1544,7 @@ function XdnsSettings({ udpFieldName }: { udpFieldName: number }) {
|
||||
<Form.Item label="Names" name={[domain.name, 'names']}>
|
||||
<Select
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
style={{ width: '100%' }}
|
||||
tokenSeparators={[',']}
|
||||
placeholder="t.example.com"
|
||||
@@ -1537,6 +1553,7 @@ function XdnsSettings({ udpFieldName }: { udpFieldName: number }) {
|
||||
<Form.Item label="Record Types" name={[domain.name, 'types']}>
|
||||
<Select
|
||||
mode="multiple"
|
||||
tagRender={selectTagRender}
|
||||
options={XDNS_RECORD_TYPE_OPTIONS}
|
||||
placeholder="TXT (client), all (server)"
|
||||
/>
|
||||
@@ -1583,6 +1600,7 @@ function XdnsSettings({ udpFieldName }: { udpFieldName: number }) {
|
||||
<Form.Item label="Addresses" name={[resolver.name, 'addrs']}>
|
||||
<Select
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
style={{ width: '100%' }}
|
||||
tokenSeparators={[',', ' ']}
|
||||
placeholder="udp://8.8.8.8:53, https://dns.google/dns-query"
|
||||
@@ -1670,7 +1688,7 @@ function UdpHopSettings({ udpFieldName }: { udpFieldName: number }) {
|
||||
normalize={(value: string[]) => value.join(',')}
|
||||
rules={[{ required: true, message: 'pick at least one mode' }]}
|
||||
>
|
||||
<Select mode="multiple" options={UDP_HOP_MODES} />
|
||||
<Select mode="multiple" tagRender={selectTagRender} options={UDP_HOP_MODES} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="Interval (s)"
|
||||
@@ -1683,7 +1701,12 @@ function UdpHopSettings({ udpFieldName }: { udpFieldName: number }) {
|
||||
<Input placeholder="20000-50000" />
|
||||
</Form.Item>
|
||||
<Form.Item label="Remote IPs" name={[udpFieldName, 'settings', 'remoteIPs']}>
|
||||
<Select mode="tags" style={{ width: '100%' }} tokenSeparators={[',', ' ']} />
|
||||
<Select
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
style={{ width: '100%' }}
|
||||
tokenSeparators={[',', ' ']}
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Input, InputNumber, Select, Typography } from 'antd';
|
||||
import { Input, InputNumber, Select, Switch, Typography } from 'antd';
|
||||
import { useFormContext, useWatch } from 'react-hook-form';
|
||||
|
||||
import { JsonEditor } from '@/components/form';
|
||||
import { selectTagRender } from '@/components/form/SelectTag';
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
import { XDriveServiceSchema } from '@/schemas/protocols/stream/xdrive';
|
||||
|
||||
@@ -13,7 +14,7 @@ const SERVICE_LABEL_KEYS: Partial<Record<string, string>> = {
|
||||
local: 'pages.inbounds.form.xdrive.serviceLocal',
|
||||
template: 'pages.inbounds.form.xdrive.serviceTemplate',
|
||||
};
|
||||
const SERVICE_NAMES: Partial<Record<string, string>> = { 'google-drive': 'Google Drive' };
|
||||
const SERVICE_NAMES: Partial<Record<string, string>> = { 'google-drive': 'Google Drive', s3: 'S3' };
|
||||
|
||||
// Core defaults (transport/internet/xdrive/params.go), shown as placeholders so an
|
||||
// empty field keeps meaning "use the core's value".
|
||||
@@ -29,6 +30,11 @@ const TUNING_FIELDS = [
|
||||
] as const;
|
||||
|
||||
const GOOGLE_DRIVE_SECRETS = ['clientId', 'clientSecret', 'refreshToken'] as const;
|
||||
const S3_SECRETS = [
|
||||
['accessKeyId', true],
|
||||
['secretAccessKey', true],
|
||||
['sessionToken', false],
|
||||
] as const;
|
||||
|
||||
function TemplateEditor({
|
||||
value,
|
||||
@@ -90,7 +96,11 @@ export default function XDriveForm() {
|
||||
name={[...BASE, 'remoteFolder']}
|
||||
required
|
||||
>
|
||||
<Input placeholder={service === 'local' ? '/var/lib/xdrive' : 'xray-tunnel'} />
|
||||
<Input
|
||||
placeholder={
|
||||
service === 'local' ? '/var/lib/xdrive' : service === 's3' ? 'xdrive' : 'xray-tunnel'
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
{service === 'google-drive' &&
|
||||
GOOGLE_DRIVE_SECRETS.map((key, index) => (
|
||||
@@ -103,6 +113,48 @@ export default function XDriveForm() {
|
||||
{key === 'clientId' ? <Input /> : <Input.Password />}
|
||||
</FormField>
|
||||
))}
|
||||
{service === 's3' && (
|
||||
<>
|
||||
{S3_SECRETS.map(([key, required], index) => (
|
||||
<FormField
|
||||
key={key}
|
||||
label={t(`pages.inbounds.form.xdrive.${key}`)}
|
||||
name={[...BASE, 'secrets', index]}
|
||||
required={required}
|
||||
>
|
||||
{key === 'accessKeyId' ? <Input /> : <Input.Password />}
|
||||
</FormField>
|
||||
))}
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.xdrive.s3Endpoint')}
|
||||
name={[...BASE, 's3', 'endpoint']}
|
||||
required
|
||||
>
|
||||
<Input placeholder="https://s3.amazonaws.com" />
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.xdrive.s3Region')}
|
||||
name={[...BASE, 's3', 'region']}
|
||||
>
|
||||
<Input placeholder="us-east-1" />
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.xdrive.s3Bucket')}
|
||||
name={[...BASE, 's3', 'bucket']}
|
||||
required
|
||||
>
|
||||
<Input />
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.xdrive.s3PathStyle')}
|
||||
tooltip={t('pages.inbounds.form.xdrive.s3PathStyleDesc')}
|
||||
name={[...BASE, 's3', 'pathStyle']}
|
||||
transform={{ input: (value) => value !== false }}
|
||||
>
|
||||
<Switch />
|
||||
</FormField>
|
||||
</>
|
||||
)}
|
||||
{service === 'template' && (
|
||||
<>
|
||||
<FormField
|
||||
@@ -110,7 +162,7 @@ export default function XDriveForm() {
|
||||
tooltip={t('pages.inbounds.form.xdrive.secretsDesc')}
|
||||
name={[...BASE, 'secrets']}
|
||||
>
|
||||
<Select mode="tags" tokenSeparators={[',']} open={false} />
|
||||
<Select mode="tags" tagRender={selectTagRender} tokenSeparators={[',']} open={false} />
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.xdrive.template')}
|
||||
|
||||
@@ -859,12 +859,31 @@ export function parseWireguardLink(link: string): Raw | null {
|
||||
export function parseOutboundLink(link: string): Raw | null {
|
||||
const trimmed = link.trim();
|
||||
if (!trimmed) return null;
|
||||
return (
|
||||
const outbound =
|
||||
parseVmessLink(trimmed) ??
|
||||
parseVlessLink(trimmed) ??
|
||||
parseTrojanLink(trimmed) ??
|
||||
parseShadowsocksLink(trimmed) ??
|
||||
parseHysteria2Link(trimmed) ??
|
||||
parseWireguardLink(trimmed)
|
||||
);
|
||||
parseWireguardLink(trimmed);
|
||||
if (outbound) dropHostLocalKeys(outbound);
|
||||
return outbound;
|
||||
}
|
||||
|
||||
// Keys that redirect the core's dial or name files on the importing host; extra=, fm= and
|
||||
// vmess JSON copy provider objects verbatim, and the core binds keys case-folded.
|
||||
const HOST_LOCAL_KEYS = new Set(['dialerproxy', 'certificatefile', 'keyfile', 'masterkeylog']);
|
||||
|
||||
function dropHostLocalKeys(value: unknown): void {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach(dropHostLocalKeys);
|
||||
return;
|
||||
}
|
||||
if (!value || typeof value !== 'object') return;
|
||||
const obj = value as Raw;
|
||||
for (const key of Object.keys(obj)) {
|
||||
// Upper-casing first folds the long s and the Kelvin sign the way Go's decoder does.
|
||||
if (HOST_LOCAL_KEYS.has(key.toUpperCase().toLowerCase())) delete obj[key];
|
||||
else dropHostLocalKeys(obj[key]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ export interface Section {
|
||||
|
||||
// /inbounds/update replaces the whole row, so it takes the same payload as /add.
|
||||
const inboundBody =
|
||||
'{\n "enable": true,\n "remark": "VLESS-443",\n "listen": "",\n "port": 443,\n "protocol": "vless",\n "expiryTime": 0,\n "total": 0,\n "settings": {\n "clients": [{ "id": "...", "email": "user1" }],\n "decryption": "none",\n "fallbacks": []\n },\n "streamSettings": {\n "network": "tcp",\n "security": "reality",\n "realitySettings": { "show": false, "dest": "..." }\n },\n "sniffing": {\n "enabled": true,\n "destOverride": ["http", "tls"]\n }\n}';
|
||||
'{\n "enable": true,\n "remark": "VLESS-443",\n "listen": "",\n "port": 443,\n "protocol": "vless",\n "expiryTime": 0,\n "total": 0,\n "settings": {\n "clients": [{ "id": "...", "email": "user1" }],\n "decryption": "none"\n },\n "streamSettings": {\n "network": "tcp",\n "security": "reality",\n "realitySettings": { "show": false, "dest": "..." }\n },\n "sniffing": {\n "enabled": true,\n "destOverride": ["http", "tls"]\n }\n}';
|
||||
|
||||
const outboundSubscriptionBodyParams: EndpointParam[] = [
|
||||
{
|
||||
@@ -309,7 +309,7 @@ export const sections: readonly Section[] = [
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/add',
|
||||
summary:
|
||||
'Create a new inbound. Send the full inbound payload (protocol, port, settings, streamSettings, sniffing, remark, expiryTime, total, enable). settings, streamSettings, and sniffing may be sent as nested JSON objects (preferred) or as JSON-encoded strings (legacy).',
|
||||
'Create a new inbound. Send the full inbound payload (protocol, port, settings, streamSettings, sniffing, remark, expiryTime, total, enable). settings, streamSettings, and sniffing may be sent as nested JSON objects (preferred) or as JSON-encoded strings (legacy). VLESS empty fallback arrays are normalized away; nonempty fallbacks cannot be combined with active server decryption.',
|
||||
body: inboundBody,
|
||||
errorResponse: '{\n "success": false,\n "msg": "Port 443 is already in use"\n}',
|
||||
},
|
||||
@@ -332,7 +332,7 @@ export const sections: readonly Section[] = [
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/update/:id',
|
||||
summary:
|
||||
'Replace an inbound’s configuration. Body shape mirrors /add, but the inbound keeps its stored client list and enable flag: settings.clients and enable in the body are ignored. Manage clients through the /panel/api/clients endpoints and toggle the inbound with /setEnable.',
|
||||
'Replace an inbound’s configuration. Body shape mirrors /add, but the inbound keeps its stored client list and enable flag: settings.clients and enable in the body are ignored. Manage clients through the /panel/api/clients endpoints and toggle the inbound with /setEnable. Remove effective fallback rules before enabling VLESS server decryption.',
|
||||
params: [{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' }],
|
||||
body: inboundBody,
|
||||
},
|
||||
@@ -377,7 +377,7 @@ export const sections: readonly Section[] = [
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/import',
|
||||
summary:
|
||||
'Bulk-import an inbound from a JSON blob (e.g. one exported via the UI). The body uses form encoding with a single "data" field.',
|
||||
'Bulk-import an inbound from a JSON blob (e.g. one exported via the UI). The body uses form encoding with a single "data" field. VLESS fallback validation is identical to /add.',
|
||||
params: [
|
||||
{
|
||||
name: 'data',
|
||||
@@ -422,7 +422,7 @@ export const sections: readonly Section[] = [
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/:id/fallbacks',
|
||||
summary:
|
||||
'Replace the entire fallback list for a master inbound. Body is JSON. Triggers an Xray restart.',
|
||||
'Replace the entire fallback list for a master inbound. Body is JSON. Nonempty lists require TCP with TLS/REALITY and no active VLESS server decryption. An empty list can remove rules even from an incompatible inbound. Triggers an Xray restart.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Master inbound ID.' },
|
||||
{
|
||||
@@ -1195,7 +1195,7 @@ export const sections: readonly Section[] = [
|
||||
summary:
|
||||
'Create a new client and attach it to one or more inbounds in a single call. Body is JSON. Per-protocol secrets are generated server-side when omitted, so callers can send only the universal fields.',
|
||||
description:
|
||||
'Fields the server fills in when they are omitted — a valid value sent by the caller is never overwritten. Re-adding an email that already exists, with its stored `subId`, reuses the stored `id`, `password`, `auth` and `secret` instead of minting new ones, so the identity stays in sync across its inbounds.\n\n- **VLESS / VMess** — `id`, a fresh UUID\n- **Trojan** — `password`\n- **Shadowsocks** — `password`. On a `2022-blake3-*` inbound a supplied password that does not base64-decode to the key length of the cipher (16 or 32 bytes) is replaced by a generated key and the call still succeeds, so read the client back if you did not let the server pick. Legacy ciphers keep any non-empty password\n- **Hysteria** — `auth`\n- **mtproto** — `secret`, a FakeTLS secret derived from the fronting domain of the inbound, or from `www.cloudflare.com` when it has none\n- **WireGuard** — `privateKey` and `publicKey` when both are blank, or `publicKey` alone when only a `privateKey` was sent, plus `allowedIPs`: one free `/32` taken from the /24 the existing peers of that inbound already sit in, or from `10.0.0.0/24` when it has none\n\nAccepted on the same body but never generated: `preSharedKey` and `keepAlive` (WireGuard), `adTag` (mtproto).\n\nWireGuard is the only one of these that can fail. Allocation widens the search to the containing /16 before giving up with `inbound <id>: wireguard: no free address available in <scope>`, and an `allowedIPs` supplied by the caller is validated instead of allocated: `inbound <id>: wireguard: allowedIPs entry <entry> overlaps <address> used by another client` when its range overlaps an address or prefix a different client of that same inbound holds, or `... used by a client on <inbound>` when the holder sits on another WireGuard or AmneziaWG inbound. Ranges are compared, not strings, so `10.0.0.9/24` collides with `10.0.0.5/32`; a `0.0.0.0/0` or `::/0` default route claims no address. Allocation likewise skips every address inside a prefix another client holds. The same validation runs on POST /panel/api/clients/{email}/attach, where a client that already carries an address brings it along.\n\nAn `inboundIds` entry that names no existing inbound rejects the whole call before anything is written. Past that, the inbounds are applied concurrently and independently: one that fails no longer stops the others, so a `success:false` response can still have created the client on the rest. Every error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. `limitHwid` is applied only when every inbound succeeded, so re-run the call after fixing the failure.',
|
||||
'Fields the server fills in when they are omitted — a valid value sent by the caller is never overwritten. Re-adding an email that already exists, with its stored `subId`, reuses the stored `id`, `password`, `auth` and `secret` instead of minting new ones, so the identity stays in sync across its inbounds.\n\n- **VLESS / VMess** — `id`, a fresh UUID\n- **Trojan** — `password`\n- **Shadowsocks** — `password`. On a `2022-blake3-*` inbound a supplied password that does not base64-decode to the key length of the cipher (16 or 32 bytes) is replaced by a generated key and the call still succeeds, so read the client back if you did not let the server pick. Legacy ciphers keep any non-empty password\n- **Hysteria** — `auth`\n- **mtproto** — `secret`, a FakeTLS secret derived from the fronting domain of the inbound, or from `www.cloudflare.com` when it has none\n- **WireGuard** — `privateKey` and `publicKey` when both are blank, or `publicKey` alone when only a `privateKey` was sent, plus `allowedIPs`: one free `/32` taken from the /24 the existing peers of that inbound already sit in, or from `10.0.0.0/24` when it has none\n\nAccepted on the same body but never generated: `preSharedKey` and `keepAlive` (WireGuard), `adTag` (mtproto).\n\nWireGuard is the only one of these that can fail. Allocation widens the search to the containing /16 before giving up with `inbound <id>: wireguard: no free address available in <scope>`, and an `allowedIPs` supplied by the caller is validated instead of allocated: `inbound <id>: wireguard: allowedIPs entry <entry> overlaps <address> used by another client` when its range overlaps an address or prefix a different client of that same inbound holds, or `... used by a client on <inbound>` when the holder sits on another WireGuard or AmneziaWG inbound. Ranges are compared, not strings, so `10.0.0.9/24` collides with `10.0.0.5/32`; a `/0` entry collides with every address of its family another client holds, and only a `0.0.0.0/0` or `::/0` default route that is already stored (legacy peers) keeps nobody out. Allocation likewise skips every address inside a prefix another client holds. The same validation runs on POST /panel/api/clients/{email}/attach, where a client that already carries an address brings it along.\n\nAn `inboundIds` entry that names no existing inbound rejects the whole call before anything is written. Past that, the inbounds are applied concurrently and independently: one that fails no longer stops the others, so a `success:false` response can still have created the client on the rest. Every error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. `limitHwid` is applied only when every inbound succeeded, so re-run the call after fixing the failure.',
|
||||
params: [
|
||||
{
|
||||
name: 'client',
|
||||
@@ -1497,7 +1497,7 @@ export const sections: readonly Section[] = [
|
||||
method: 'POST',
|
||||
path: '/panel/api/clients/bulkResetTraffic',
|
||||
summary:
|
||||
'Zero up/down counters for many clients in one call. Loops the single-reset path so each client is re-enabled across its attached inbounds and pushed to Xray/remote nodes. Returns the count of successfully reset clients.',
|
||||
'Zero up/down counters for many clients in one call. Loops the single-reset path so each client its quota had disabled is re-enabled across its attached inbounds and pushed to Xray/remote nodes; a client disabled by hand stays disabled. Returns the count of successfully reset clients.',
|
||||
body: '{\n "emails": ["alice", "bob"]\n}',
|
||||
response: '{\n "success": true,\n "obj": {\n "affected": 2\n }\n}',
|
||||
},
|
||||
@@ -1553,7 +1553,7 @@ export const sections: readonly Section[] = [
|
||||
method: 'POST',
|
||||
path: '/panel/api/clients/resetTraffic/:email',
|
||||
summary:
|
||||
'Zero out a single client’s up/down counters. Re-enables the client across every attached inbound and pushes the change to Xray (or the remote node) so depleted users can connect again immediately.',
|
||||
'Zero out a single client’s up/down counters. Re-enables a client its quota had disabled across every attached inbound and pushes the change to Xray (or the remote node) so depleted users can connect again immediately; a client disabled by hand stays disabled.',
|
||||
params: [{ name: 'email', in: 'path', type: 'string', desc: 'Client email.' }],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Alert, Modal, Select, Typography, message } from 'antd';
|
||||
|
||||
import { SelectAllClearButtons } from '@/components/form';
|
||||
import { SelectAllClearButtons, selectTagRender } from '@/components/form';
|
||||
import type { InboundOption } from '@/hooks/useClients';
|
||||
import { formatInboundLabel } from '@/lib/inbounds/label';
|
||||
import type { BulkAttachResult } from '@/schemas/client';
|
||||
@@ -106,6 +106,7 @@ export default function BulkAttachInboundsModal({
|
||||
/>
|
||||
<Select
|
||||
mode="multiple"
|
||||
tagRender={selectTagRender}
|
||||
style={{ width: '100%' }}
|
||||
value={targetIds}
|
||||
onChange={setTargetIds}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Alert, Modal, Select, Typography, message } from 'antd';
|
||||
|
||||
import { SelectAllClearButtons } from '@/components/form';
|
||||
import { SelectAllClearButtons, selectTagRender } from '@/components/form';
|
||||
import type { InboundOption } from '@/hooks/useClients';
|
||||
import { formatInboundLabel } from '@/lib/inbounds/label';
|
||||
import type { BulkDetachResult } from '@/schemas/client';
|
||||
@@ -106,6 +106,7 @@ export default function BulkDetachInboundsModal({
|
||||
/>
|
||||
<Select
|
||||
mode="multiple"
|
||||
tagRender={selectTagRender}
|
||||
style={{ width: '100%' }}
|
||||
value={targetIds}
|
||||
onChange={setTargetIds}
|
||||
|
||||
@@ -21,7 +21,7 @@ import { FormProvider, useForm, useWatch } from 'react-hook-form';
|
||||
import { RandomUtil, SizeFormatter } from '@/utils';
|
||||
import { formatInboundLabel } from '@/lib/inbounds/label';
|
||||
import { TLS_FLOW_CONTROL, TRAFFIC_RESETS } from '@/schemas/primitives';
|
||||
import { DateTimePicker, SelectAllClearButtons } from '@/components/form';
|
||||
import { DateTimePicker, SelectAllClearButtons, selectTagRender } from '@/components/form';
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
import { useClients, type InboundOption } from '@/hooks/useClients';
|
||||
import { useFail2banStatusQuery, getLimitIpNotice } from '@/api/queries/useFail2banStatusQuery';
|
||||
@@ -268,6 +268,7 @@ export default function ClientBulkAddModal({
|
||||
<Select
|
||||
id={`${uid}-inbounds`}
|
||||
mode="multiple"
|
||||
tagRender={selectTagRender}
|
||||
value={inboundIds}
|
||||
onChange={(v) => methods.setValue('inboundIds', v)}
|
||||
options={inboundOptions}
|
||||
|
||||
@@ -38,7 +38,7 @@ import { resolveExternalLinkExpiry } from '@/lib/clients/external-link';
|
||||
import { useDatepicker } from '@/hooks/useDatepicker';
|
||||
import { useClientHwids } from '@/hooks/useClientHwids';
|
||||
import { useRawHostsQuery } from '@/api/queries/useRawHostsQuery';
|
||||
import { DateTimePicker, SelectAllClearButtons } from '@/components/form';
|
||||
import { DateTimePicker, SelectAllClearButtons, selectTagRender } from '@/components/form';
|
||||
import { FormField, rhfZodValidate } from '@/components/form/rhf';
|
||||
import ClientHwidListModal from '@/components/clients/ClientHwidList';
|
||||
import { TLS_FLOW_CONTROL, TRAFFIC_RESETS } from '@/schemas/primitives';
|
||||
@@ -1168,11 +1168,11 @@ export default function ClientFormModal({
|
||||
<Select
|
||||
id={`${uid}-host-rule-override`}
|
||||
mode="multiple"
|
||||
tagRender={selectTagRender}
|
||||
value={clientHostRuleIds || []}
|
||||
onChange={(v) => methods.setValue('clientHostRuleIds', v)}
|
||||
options={hostRuleOptions}
|
||||
placeholder={t('pages.clients.hostRuleOverridePlaceholder')}
|
||||
maxTagCount="responsive"
|
||||
placement="topLeft"
|
||||
listHeight={220}
|
||||
allowClear
|
||||
@@ -1226,11 +1226,11 @@ export default function ClientFormModal({
|
||||
<Select
|
||||
id={`${uid}-inbounds`}
|
||||
mode="multiple"
|
||||
tagRender={selectTagRender}
|
||||
value={inboundIds}
|
||||
onChange={handleInboundChange}
|
||||
options={inboundOptions}
|
||||
placeholder={t('pages.clients.selectInbound')}
|
||||
maxTagCount="responsive"
|
||||
placement="topLeft"
|
||||
listHeight={220}
|
||||
showSearch={{
|
||||
|
||||
@@ -163,7 +163,7 @@ function SubscriptionQrPresentation({
|
||||
showIcon
|
||||
title={t('pages.clients.happLinkDisclosure')}
|
||||
/>
|
||||
<Spin spinning={happLoading}>
|
||||
<Spin aria-live="off" spinning={happLoading}>
|
||||
<div style={{ minHeight: happLoading ? 48 : undefined }}>
|
||||
{happLink ? (
|
||||
<>
|
||||
@@ -563,7 +563,7 @@ function ClientQrModalContent({
|
||||
centered
|
||||
onCancel={() => onOpenChange(false)}
|
||||
>
|
||||
<Spin spinning={loading}>
|
||||
<Spin aria-live="off" spinning={loading}>
|
||||
{!client?.subId && !loading && (
|
||||
<div style={{ padding: 24, textAlign: 'center', opacity: 0.6 }}>
|
||||
{t('pages.clients.noSubId')}
|
||||
|
||||
@@ -1305,6 +1305,7 @@ export default function ClientsPage() {
|
||||
<Layout className="content-shell">
|
||||
<Layout.Content id="content-layout" className="content-area">
|
||||
<Spin
|
||||
aria-live="off"
|
||||
spinning={!fetched || !hostsFetched}
|
||||
delay={200}
|
||||
description={t('loading')}
|
||||
@@ -1724,7 +1725,7 @@ export default function ClientsPage() {
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Spin spinning={transitioning}>
|
||||
<Spin aria-live="off" spinning={transitioning}>
|
||||
<div className="client-cards">
|
||||
{filteredClients.length > 0 && (
|
||||
<div className="card-bulk-bar">
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import dayjs from 'dayjs';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
|
||||
import { selectTagRender } from '@/components/form';
|
||||
import type { InboundOption } from '@/hooks/useClients';
|
||||
import type { NodeRecord } from '@/schemas/node';
|
||||
import { formatInboundLabel } from '@/lib/inbounds/label';
|
||||
@@ -118,11 +119,11 @@ export default function FilterDrawer({
|
||||
<Select
|
||||
id={`${uid}-protocols`}
|
||||
mode="multiple"
|
||||
tagRender={selectTagRender}
|
||||
value={filters.protocols}
|
||||
onChange={(v) => patch('protocols', v as string[])}
|
||||
options={protocolOptions}
|
||||
placeholder={t('pages.inbounds.protocol')}
|
||||
maxTagCount="responsive"
|
||||
allowClear
|
||||
/>
|
||||
</Form.Item>
|
||||
@@ -131,11 +132,11 @@ export default function FilterDrawer({
|
||||
<Select
|
||||
id={`${uid}-inbounds`}
|
||||
mode="multiple"
|
||||
tagRender={selectTagRender}
|
||||
value={filters.inboundIds}
|
||||
onChange={(v) => patch('inboundIds', v as number[])}
|
||||
options={inboundOptions}
|
||||
placeholder={t('inbounds')}
|
||||
maxTagCount="responsive"
|
||||
allowClear
|
||||
showSearch={{ optionFilterProp: 'label' }}
|
||||
listHeight={220}
|
||||
@@ -147,11 +148,11 @@ export default function FilterDrawer({
|
||||
<Select
|
||||
id={`${uid}-nodes`}
|
||||
mode="multiple"
|
||||
tagRender={selectTagRender}
|
||||
value={filters.nodeIds}
|
||||
onChange={(v) => patch('nodeIds', v as number[])}
|
||||
options={nodeOptions}
|
||||
placeholder={t('pages.clients.filters.nodes')}
|
||||
maxTagCount="responsive"
|
||||
allowClear
|
||||
showSearch={{ optionFilterProp: 'label' }}
|
||||
listHeight={220}
|
||||
@@ -163,11 +164,11 @@ export default function FilterDrawer({
|
||||
<Select
|
||||
id={`${uid}-groups`}
|
||||
mode="multiple"
|
||||
tagRender={selectTagRender}
|
||||
value={filters.groups}
|
||||
onChange={(v) => patch('groups', v as string[])}
|
||||
options={groupOptions}
|
||||
placeholder={t('pages.clients.groupPlaceholder')}
|
||||
maxTagCount="responsive"
|
||||
allowClear
|
||||
showSearch={{ optionFilterProp: 'label' }}
|
||||
listHeight={220}
|
||||
|
||||
@@ -496,7 +496,13 @@ export default function GroupsPage() {
|
||||
<AppSidebar />
|
||||
<Layout className="content-shell">
|
||||
<Layout.Content id="content-layout" className="content-area">
|
||||
<Spin spinning={!fetched} delay={200} description={t('loading')} size="large">
|
||||
<Spin
|
||||
aria-live="off"
|
||||
spinning={!fetched}
|
||||
delay={200}
|
||||
description={t('loading')}
|
||||
size="large"
|
||||
>
|
||||
{!fetched ? (
|
||||
<div className="loading-spacer" />
|
||||
) : fetchError ? (
|
||||
|
||||
@@ -28,7 +28,7 @@ import type { HostRecord } from '@/api/queries/useHostsQuery';
|
||||
import { BulkAddHostSchema, type BulkAddHostValues } from '@/schemas/api/host';
|
||||
import type { InboundOption } from '@/schemas/client';
|
||||
import { ALPN_OPTION, UTLS_FINGERPRINT } from '@/schemas/primitives';
|
||||
import { CipherSuitesSelect } from '@/components/form';
|
||||
import { CipherSuitesSelect, selectTagRender } from '@/components/form';
|
||||
import DynamicEchHelper from '@/components/form/DynamicEchHelper';
|
||||
import { FormField, rhfZodValidate } from '@/components/form/rhf';
|
||||
import { useNodesQuery } from '@/api/queries/useNodesQuery';
|
||||
@@ -291,6 +291,7 @@ export default function HostFormModal({
|
||||
>
|
||||
<Select
|
||||
mode="multiple"
|
||||
tagRender={selectTagRender}
|
||||
options={inboundSelectOptions}
|
||||
showSearch={{ optionFilterProp: 'label' }}
|
||||
placeholder={t('pages.hosts.selectInbound')}
|
||||
@@ -304,6 +305,7 @@ export default function HostFormModal({
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
options={hostOptions}
|
||||
tokenSeparators={[',', ';', ' ']}
|
||||
placeholder="cdn.example.com, cdn2.example.com:443"
|
||||
@@ -326,7 +328,12 @@ export default function HostFormModal({
|
||||
label={t('pages.hosts.fields.tags')}
|
||||
tooltip={t('pages.hosts.hints.tags')}
|
||||
>
|
||||
<Select mode="tags" allowClear tokenSeparators={[',']} />
|
||||
<Select
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
allowClear
|
||||
tokenSeparators={[',']}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
name="nodeGuids"
|
||||
@@ -335,6 +342,7 @@ export default function HostFormModal({
|
||||
>
|
||||
<Select
|
||||
mode="multiple"
|
||||
tagRender={selectTagRender}
|
||||
allowClear
|
||||
options={nodeSelectOptions}
|
||||
showSearch={{ optionFilterProp: 'label' }}
|
||||
@@ -377,6 +385,7 @@ export default function HostFormModal({
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
options={sniOptions}
|
||||
tokenSeparators={[',', ';', ' ']}
|
||||
placeholder="sni.example.com, sni2.example.com"
|
||||
@@ -413,7 +422,12 @@ export default function HostFormModal({
|
||||
{showTlsExtras && (
|
||||
<>
|
||||
<FormField name="alpn" label={t('pages.hosts.fields.alpn')}>
|
||||
<Select mode="multiple" allowClear options={alpnOptions} />
|
||||
<Select
|
||||
mode="multiple"
|
||||
tagRender={selectTagRender}
|
||||
allowClear
|
||||
options={alpnOptions}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
name="cipherSuites"
|
||||
@@ -425,7 +439,12 @@ export default function HostFormModal({
|
||||
/>
|
||||
</FormField>
|
||||
<FormField name="pinnedPeerCertSha256" label={t('pages.hosts.fields.pins')}>
|
||||
<Select mode="tags" allowClear tokenSeparators={[',']} />
|
||||
<Select
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
allowClear
|
||||
tokenSeparators={[',']}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
name="verifyPeerCertByName"
|
||||
@@ -519,6 +538,7 @@ export default function HostFormModal({
|
||||
>
|
||||
<Select
|
||||
mode="multiple"
|
||||
tagRender={selectTagRender}
|
||||
allowClear
|
||||
options={['raw', 'json', 'clash'].map((v) => ({
|
||||
value: v,
|
||||
|
||||
@@ -180,7 +180,7 @@ export default function HostsPage() {
|
||||
<AppSidebar />
|
||||
<Layout className="content-shell">
|
||||
<Layout.Content id="content-layout" className="content-area">
|
||||
<Spin spinning={!fetched} delay={200} size="large">
|
||||
<Spin aria-live="off" spinning={!fetched} delay={200} size="large">
|
||||
{!fetched ? (
|
||||
<div className="loading-spacer" />
|
||||
) : fetchError ? (
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Modal, Select, Typography, message } from 'antd';
|
||||
|
||||
import { HttpUtil } from '@/utils';
|
||||
import { SelectAllClearButtons } from '@/components/form';
|
||||
import { SelectAllClearButtons, selectTagRender } from '@/components/form';
|
||||
import { buildClonePayload, pickClonePort } from '@/lib/xray/inbound-clone';
|
||||
import type { NodeRecord } from '@/api/queries/useNodesQuery';
|
||||
import type { DBInbound } from '@/models/dbinbound';
|
||||
@@ -132,6 +132,7 @@ export default function CloneInboundModal({
|
||||
<Select
|
||||
aria-label={t('pages.inbounds.deployTo')}
|
||||
mode="multiple"
|
||||
tagRender={selectTagRender}
|
||||
style={{ width: '100%' }}
|
||||
value={targets}
|
||||
onChange={setTargets}
|
||||
|
||||
@@ -294,6 +294,7 @@ export default function InboundsPage() {
|
||||
if (!dbInbound?.listen?.startsWith?.('@')) return dbInbound;
|
||||
for (const candidate of dbInbounds) {
|
||||
if (candidate.id === dbInbound.id) continue;
|
||||
if ((candidate.nodeId ?? 0) !== (dbInbound.nodeId ?? 0)) continue;
|
||||
if (!['trojan', 'vless'].includes(candidate.protocol)) continue;
|
||||
const candStream = coerceInboundJsonField(candidate.streamSettings) as { network?: string };
|
||||
if (candStream.network !== 'tcp') continue;
|
||||
@@ -719,6 +720,7 @@ export default function InboundsPage() {
|
||||
<Layout className="content-shell">
|
||||
<Layout.Content id="content-layout" className="content-area">
|
||||
<Spin
|
||||
aria-live="off"
|
||||
spinning={!fetched || !hostsFetched}
|
||||
delay={200}
|
||||
description={t('loading')}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Alert, Input, Modal, Select, Space, Table, Tag, Typography, message } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
|
||||
import { selectTagRender } from '@/components/form';
|
||||
import { HttpUtil } from '@/utils';
|
||||
import { formatInboundLabel } from '@/lib/inbounds/label';
|
||||
import { coerceInboundJsonField, type DBInbound } from '@/models/dbinbound';
|
||||
@@ -207,6 +208,7 @@ export default function AttachClientsModal({
|
||||
) : (
|
||||
<Select
|
||||
mode="multiple"
|
||||
tagRender={selectTagRender}
|
||||
aria-label={t('pages.inbounds.attachClientsTargets')}
|
||||
style={{ width: '100%' }}
|
||||
value={targetIds}
|
||||
|
||||
@@ -200,7 +200,7 @@ export default function AttachExistingClientsModal({
|
||||
{noClients ? (
|
||||
<Alert type="info" showIcon title={t('pages.inbounds.attachExistingNoClients')} />
|
||||
) : (
|
||||
<Spin spinning={loading}>
|
||||
<Spin aria-live="off" spinning={loading}>
|
||||
<Space orientation="vertical" size="small" style={{ width: '100%' }}>
|
||||
<Space style={{ width: '100%', justifyContent: 'space-between' }} wrap>
|
||||
<Space wrap>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import type { FallbackRow } from '@/schemas/forms/inbound-form';
|
||||
|
||||
interface FallbacksCardProps {
|
||||
canAdd?: boolean;
|
||||
fallbacks: FallbackRow[];
|
||||
fallbackChildOptions: { label: string; value: number }[];
|
||||
addFallback: () => void;
|
||||
@@ -20,6 +21,7 @@ interface FallbacksCardProps {
|
||||
}
|
||||
|
||||
export default function FallbacksCard({
|
||||
canAdd = true,
|
||||
fallbacks,
|
||||
fallbackChildOptions,
|
||||
addFallback,
|
||||
@@ -38,6 +40,7 @@ export default function FallbacksCard({
|
||||
size="small"
|
||||
icon={<PlusOutlined aria-hidden />}
|
||||
onClick={addFallback}
|
||||
disabled={!canAdd}
|
||||
>
|
||||
{t('pages.inbounds.fallbacks.add') || 'Add fallback'}
|
||||
</Button>
|
||||
@@ -45,7 +48,9 @@ export default function FallbacksCard({
|
||||
size="small"
|
||||
onClick={addAllFallbacks}
|
||||
disabled={
|
||||
fallbackChildOptions.length === 0 || fallbacks.length >= fallbackChildOptions.length
|
||||
!canAdd ||
|
||||
fallbackChildOptions.length === 0 ||
|
||||
fallbacks.length >= fallbackChildOptions.length
|
||||
}
|
||||
title={t('pages.inbounds.form.addAllFallbackTooltip')}
|
||||
>
|
||||
|
||||
@@ -226,11 +226,14 @@ export default function InboundFormModal({
|
||||
const getV = methods.getValues as unknown as (name?: string) => unknown;
|
||||
const control = methods.control;
|
||||
const [saving, setSaving] = useState(false);
|
||||
const savedInboundId = useRef<number | null>(null);
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [scanResult, setScanResult] = useState<RealityScanResult | null>(null);
|
||||
const [activeTab, setActiveTab] = useState('basic');
|
||||
const {
|
||||
fallbacks,
|
||||
hasStoredFallbacks,
|
||||
fallbacksLoaded,
|
||||
fallbackChildOptions,
|
||||
loadFallbacks,
|
||||
saveFallbacks,
|
||||
@@ -250,6 +253,7 @@ export default function InboundFormModal({
|
||||
* always empty. Offer it only then; `listen`/`custom` work for local inbounds.
|
||||
*/
|
||||
const nodeShareOptionAvailable = selectableNodes.length > 0 && isNodeEligible;
|
||||
const vlessDecryption = useWatch({ control, name: 'settings.decryption' }) ?? '';
|
||||
const vlessEncryption = useWatch({ control, name: 'settings.encryption' }) ?? '';
|
||||
const ssMethod = useWatch({ control, name: 'settings.method' });
|
||||
const isSSWith2022 = isSS2022({
|
||||
@@ -295,7 +299,10 @@ export default function InboundFormModal({
|
||||
streamSettings: { network },
|
||||
settings: { network: wSsNetwork, allowedNetwork: wTunnelNetwork, udp: mixedUdpOn },
|
||||
});
|
||||
const hasVlessDecryption =
|
||||
protocol === Protocols.VLESS && vlessDecryption !== '' && vlessDecryption !== 'none';
|
||||
const isFallbackHost =
|
||||
!hasVlessDecryption &&
|
||||
(protocol === Protocols.VLESS || protocol === Protocols.TROJAN) &&
|
||||
network === 'tcp' &&
|
||||
(security === 'tls' || security === 'reality');
|
||||
@@ -437,6 +444,7 @@ export default function InboundFormModal({
|
||||
? withoutClients(rawInboundToFormValues(dbInbound))
|
||||
: buildAddModeValues();
|
||||
methods.reset(initial);
|
||||
savedInboundId.current = null;
|
||||
setScanResult(null);
|
||||
setActiveTab('basic');
|
||||
const initialTag = (initial.tag ?? '') as string;
|
||||
@@ -573,19 +581,46 @@ export default function InboundFormModal({
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (saving) return;
|
||||
if (!fallbacksLoaded) {
|
||||
messageApi.error(t('pages.inbounds.fallbacks.saveFailed'));
|
||||
return;
|
||||
}
|
||||
if (!isFallbackHost && fallbacks.length > 0) {
|
||||
setActiveTab('protocol');
|
||||
messageApi.error(
|
||||
t(
|
||||
hasVlessDecryption
|
||||
? 'pages.inbounds.fallbacks.decryptionConflict'
|
||||
: 'pages.inbounds.fallbacks.needsTls',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = formValuesToWirePayload(parsed.data, { omitClients: mode === 'edit' });
|
||||
const url =
|
||||
mode === 'edit' && dbInbound
|
||||
? `/panel/api/inbounds/update/${dbInbound.id}`
|
||||
: '/panel/api/inbounds/add';
|
||||
// Persist an explicit removal before the backend checks the new decryption setting.
|
||||
if (!isFallbackHost && hasStoredFallbacks && dbInbound) {
|
||||
if (!(await saveFallbacks(dbInbound.id))) {
|
||||
messageApi.error(t('pages.inbounds.fallbacks.saveFailed'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
const existingId = mode === 'edit' ? dbInbound?.id : savedInboundId.current;
|
||||
const payload = formValuesToWirePayload(parsed.data, { omitClients: !!existingId });
|
||||
const url = existingId
|
||||
? `/panel/api/inbounds/update/${existingId}`
|
||||
: '/panel/api/inbounds/add';
|
||||
const msg = await HttpUtil.post(url, payload);
|
||||
if (msg?.success) {
|
||||
if (isFallbackHost) {
|
||||
const obj = msg.obj as { id?: number; Id?: number } | null;
|
||||
const masterId = mode === 'edit' ? dbInbound!.id : (obj?.id ?? obj?.Id ?? 0);
|
||||
if (masterId) await saveFallbacks(masterId);
|
||||
const masterId = existingId ?? obj?.id ?? obj?.Id ?? 0;
|
||||
if (masterId) savedInboundId.current = masterId;
|
||||
if (masterId && !(await saveFallbacks(masterId))) {
|
||||
messageApi.error(t('pages.inbounds.fallbacks.saveFailed'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
onSaved();
|
||||
onClose();
|
||||
@@ -783,6 +818,7 @@ export default function InboundFormModal({
|
||||
|
||||
const fallbacksCard = (
|
||||
<FallbacksCard
|
||||
canAdd={isFallbackHost && fallbacksLoaded}
|
||||
fallbacks={fallbacks}
|
||||
fallbackChildOptions={fallbackChildOptions}
|
||||
addFallback={addFallback}
|
||||
@@ -834,9 +870,18 @@ export default function InboundFormModal({
|
||||
/>
|
||||
)}
|
||||
|
||||
{isFallbackHost && fallbacksCard}
|
||||
{(isFallbackHost || fallbacks.length > 0) && fallbacksCard}
|
||||
{hasVlessDecryption && (
|
||||
<Alert
|
||||
className="mt-12"
|
||||
type="warning"
|
||||
showIcon
|
||||
title={t('pages.inbounds.fallbacks.decryptionConflict')}
|
||||
/>
|
||||
)}
|
||||
{(protocol === Protocols.VLESS || protocol === Protocols.TROJAN) &&
|
||||
network === 'tcp' &&
|
||||
!hasVlessDecryption &&
|
||||
!isFallbackHost && (
|
||||
<Alert
|
||||
className="mt-12"
|
||||
@@ -1175,7 +1220,9 @@ export default function InboundFormModal({
|
||||
Protocols.TUIC,
|
||||
Protocols.MASQUE,
|
||||
] as string[]
|
||||
).includes(protocol) || isFallbackHost
|
||||
).includes(protocol) ||
|
||||
isFallbackHost ||
|
||||
fallbacks.length > 0
|
||||
? [
|
||||
{
|
||||
key: 'protocol',
|
||||
|
||||
@@ -239,7 +239,7 @@ export default function AmneziawgFields({
|
||||
<Alert
|
||||
type={kernelModeOn ? 'warning' : 'info'}
|
||||
showIcon
|
||||
message={t('pages.xray.amneziawg.kernelUnavailable')}
|
||||
title={t('pages.xray.amneziawg.kernelUnavailable')}
|
||||
description={
|
||||
<>
|
||||
<ul style={{ margin: '0 0 8px', paddingInlineStart: 20 }}>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Input, InputNumber, Select } from 'antd';
|
||||
|
||||
import { selectTagRender } from '@/components/form';
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
import { MASQUE_DEFAULT_PATH } from '@/schemas/protocols/stream/masque';
|
||||
|
||||
@@ -14,7 +15,7 @@ export function MasqueSettingsFields() {
|
||||
name={['settings', 'address']}
|
||||
required
|
||||
>
|
||||
<Select mode="tags" tokenSeparators={[',', ' ']} open={false} />
|
||||
<Select mode="tags" tagRender={selectTagRender} tokenSeparators={[',', ' ']} open={false} />
|
||||
</FormField>
|
||||
<FormField label={t('pages.inbounds.info.mtu')} name={['settings', 'mtu']}>
|
||||
<InputNumber min={1280} max={65535} placeholder="1280" style={{ width: '100%' }} />
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import { CloudDownloadOutlined, SyncOutlined } from '@ant-design/icons';
|
||||
import { useFormContext, useWatch } from 'react-hook-form';
|
||||
|
||||
import { selectTagRender } from '@/components/form';
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
import { HttpUtil } from '@/utils';
|
||||
|
||||
@@ -210,6 +211,7 @@ export default function TuicFields() {
|
||||
<FormField name={['settings', 'server', 'alpn']} label={t('pages.xray.tuic.alpn')}>
|
||||
<Select
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
tokenSeparators={[',']}
|
||||
options={[
|
||||
{ label: 'h3', value: 'h3' },
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Button, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
|
||||
import { MinusOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import { useFieldArray, useFormContext } from 'react-hook-form';
|
||||
|
||||
import { selectTagRender } from '@/components/form';
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
|
||||
interface StringListProps {
|
||||
@@ -46,9 +47,10 @@ function StringList({ name, label, tooltip, placeholder }: StringListProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// xray-core 26.9.30 accepts exactly these autoSystemWfpBlockLeak values.
|
||||
// Named autoSystemWfpBlockLeak values; xray-core 26.10.31 also takes typed "tcp/<port>" or "udp/<port>".
|
||||
const WFP_BLOCK_LEAK_OPTIONS = [
|
||||
{ value: 'dns', label: 'dns' },
|
||||
{ value: 'windnsclient', label: 'windnsclient' },
|
||||
{ value: 'misconfigtun', label: 'misconfigtun' },
|
||||
];
|
||||
|
||||
@@ -110,7 +112,9 @@ export default function TunFields() {
|
||||
>
|
||||
<Select
|
||||
id="tun-wfp-block-leak"
|
||||
mode="multiple"
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
tokenSeparators={[',', ' ']}
|
||||
allowClear
|
||||
options={WFP_BLOCK_LEAK_OPTIONS}
|
||||
/>
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import { RadarChartOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { selectTagRender } from '@/components/form';
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
import { UTLS_FINGERPRINT } from '@/schemas/primitives';
|
||||
import {
|
||||
@@ -208,7 +209,12 @@ export default function RealityForm({
|
||||
</Form.Item>
|
||||
)}
|
||||
<FormField label="SNI" name={['streamSettings', 'realitySettings', 'serverNames']}>
|
||||
<Select mode="tags" tokenSeparators={[',']} style={{ width: '100%' }} />
|
||||
<Select
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
tokenSeparators={[',']}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
name={['streamSettings', 'realitySettings', 'maxTimediff']}
|
||||
@@ -251,6 +257,7 @@ export default function RealityForm({
|
||||
<Select
|
||||
id={`${uid}-shortIds`}
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
tokenSeparators={[',']}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
} from '@ant-design/icons';
|
||||
import { useFieldArray, useFormContext, useWatch } from 'react-hook-form';
|
||||
|
||||
import { CipherSuitesSelect } from '@/components/form';
|
||||
import { CipherSuitesSelect, selectTagRender } from '@/components/form';
|
||||
import DynamicEchHelper from '@/components/form/DynamicEchHelper';
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
import {
|
||||
@@ -380,6 +380,7 @@ export default function TlsForm({
|
||||
<FormField name={['streamSettings', 'tlsSettings', 'alpn']} label="ALPN">
|
||||
<Select
|
||||
mode="multiple"
|
||||
tagRender={selectTagRender}
|
||||
tokenSeparators={[',']}
|
||||
style={{ width: '100%' }}
|
||||
options={Object.values(ALPN_OPTION).map((a) => ({ value: a, label: a }))}
|
||||
@@ -392,6 +393,7 @@ export default function TlsForm({
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
tokenSeparators={[',', ' ']}
|
||||
style={{ width: '100%' }}
|
||||
options={['X25519MLKEM768', 'X25519', 'P-256', 'P-384', 'P-521'].map((c) => ({
|
||||
@@ -478,6 +480,7 @@ export default function TlsForm({
|
||||
id={`${uid}-pinnedPeerCertSha256`}
|
||||
aria-describedby={`${uid}-pinnedPeerCertSha256-hint`}
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
tokenSeparators={[',', ' ']}
|
||||
placeholder={t('pages.inbounds.form.pinnedPeerCertSha256Placeholder')}
|
||||
style={{ width: 'calc(100% - 64px)' }}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Alert, Form, InputNumber, Segmented, Select, Switch } from 'antd';
|
||||
import { Controller, useFormContext, useWatch } from 'react-hook-form';
|
||||
|
||||
import { selectTagRender } from '@/components/form';
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
import { SockoptCustomField } from '@/lib/xray/forms/fields';
|
||||
import { TCP_CONGESTION_OPTION } from '@/schemas/primitives';
|
||||
@@ -228,6 +229,7 @@ export default function SockoptForm({
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
style={{ width: '100%' }}
|
||||
tokenSeparators={[',']}
|
||||
options={[
|
||||
|
||||
@@ -10,6 +10,8 @@ import { coerceInboundJsonField, type DBInbound } from '@/models/dbinbound';
|
||||
export function useInboundFallbacks(dbInbound: DBInbound | null, dbInbounds: DBInbound[]) {
|
||||
const fallbackKeyRef = useRef(0);
|
||||
const [fallbacks, setFallbacks] = useState<FallbackRow[]>([]);
|
||||
const [hasStoredFallbacks, setHasStoredFallbacks] = useState(false);
|
||||
const [fallbacksLoaded, setFallbacksLoaded] = useState(false);
|
||||
|
||||
const fallbackChildOptions = (dbInbounds || [])
|
||||
.filter((ib) => ib.id !== dbInbound?.id)
|
||||
@@ -19,7 +21,10 @@ export function useInboundFallbacks(dbInbound: DBInbound | null, dbInbounds: DBI
|
||||
}));
|
||||
|
||||
const loadFallbacks = async (masterId: number | null) => {
|
||||
setFallbacksLoaded(false);
|
||||
setHasStoredFallbacks(false);
|
||||
if (!masterId) {
|
||||
setFallbacksLoaded(true);
|
||||
setFallbacks([]);
|
||||
return;
|
||||
}
|
||||
@@ -28,6 +33,8 @@ export function useInboundFallbacks(dbInbound: DBInbound | null, dbInbounds: DBI
|
||||
setFallbacks([]);
|
||||
return;
|
||||
}
|
||||
setFallbacksLoaded(true);
|
||||
setHasStoredFallbacks(msg.obj.length > 0);
|
||||
setFallbacks(
|
||||
(
|
||||
msg.obj as {
|
||||
@@ -68,6 +75,7 @@ export function useInboundFallbacks(dbInbound: DBInbound | null, dbInbounds: DBI
|
||||
const msg = await HttpUtil.post(`/panel/api/inbounds/${masterId}/fallbacks`, payload, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
if (msg?.success) setHasStoredFallbacks(payload.fallbacks.length > 0);
|
||||
return !!msg?.success;
|
||||
};
|
||||
|
||||
@@ -181,6 +189,8 @@ export function useInboundFallbacks(dbInbound: DBInbound | null, dbInbounds: DBI
|
||||
|
||||
return {
|
||||
fallbacks,
|
||||
hasStoredFallbacks,
|
||||
fallbacksLoaded,
|
||||
fallbackChildOptions,
|
||||
loadFallbacks,
|
||||
saveFallbacks,
|
||||
|
||||
@@ -69,7 +69,9 @@ export default function InboundInfoModal({
|
||||
if (!clientStats?.email) return;
|
||||
setRefreshing(true);
|
||||
try {
|
||||
const msg = await HttpUtil.post(`/panel/api/clients/ips/${clientStats.email}`);
|
||||
const msg = await HttpUtil.post(
|
||||
`/panel/api/clients/ips/${encodeURIComponent(clientStats.email)}`,
|
||||
);
|
||||
if (!msg?.success) {
|
||||
setClientIpsText((msg?.obj as string) || 'No IP record');
|
||||
setClientIpsArray([]);
|
||||
@@ -101,7 +103,9 @@ export default function InboundInfoModal({
|
||||
|
||||
const clearClientIps = useCallback(async () => {
|
||||
if (!clientStats?.email) return;
|
||||
const msg = await HttpUtil.post(`/panel/api/clients/clearIps/${clientStats.email}`);
|
||||
const msg = await HttpUtil.post(
|
||||
`/panel/api/clients/clearIps/${encodeURIComponent(clientStats.email)}`,
|
||||
);
|
||||
if (msg?.success) {
|
||||
setClientIpsArray([]);
|
||||
setClientIpsText(t('tgbot.noIpRecord'));
|
||||
@@ -223,7 +227,8 @@ export default function InboundInfoModal({
|
||||
setClientIpsText('');
|
||||
|
||||
if (ipLimitEnable && (clientSet?.limitIp ?? 0) > 0 && stats?.email) {
|
||||
void HttpUtil.post(`/panel/api/clients/ips/${stats.email}`).then((msg) => {
|
||||
const email = encodeURIComponent(stats.email);
|
||||
void HttpUtil.post(`/panel/api/clients/ips/${email}`).then((msg) => {
|
||||
if (!msg?.success) {
|
||||
setClientIpsText((msg?.obj as string) || 'No IP record');
|
||||
return;
|
||||
|
||||
@@ -19,8 +19,8 @@ interface GeodataSectionProps {
|
||||
}
|
||||
|
||||
const DEFAULT_CRON = '0 4 * * *';
|
||||
// Xray resolves `file` inside its asset directory; plain file names only.
|
||||
const FILE_NAME_PATTERN = /^[A-Za-z0-9._-]+$/;
|
||||
// Xray resolves `file` inside its asset directory; since 26.10.29 a scheduled one must be .dat.
|
||||
const FILE_NAME_PATTERN = /^[A-Za-z0-9._-]+\.dat$/i;
|
||||
|
||||
function fileNameFromUrl(url: string): string {
|
||||
try {
|
||||
@@ -178,7 +178,7 @@ export default function GeodataSection({ active, onBusy, onClose }: GeodataSecti
|
||||
<div>
|
||||
{modalContextHolder}
|
||||
{messageContextHolder}
|
||||
<Spin spinning={loading}>
|
||||
<Spin aria-live="off" spinning={loading}>
|
||||
<Alert type="info" className="mb-12" title={t('pages.index.geodataHint')} showIcon />
|
||||
<Form layout="vertical">
|
||||
<Form.Item
|
||||
|
||||
@@ -174,6 +174,7 @@ export default function IndexPage() {
|
||||
<Layout className="content-shell">
|
||||
<Layout.Content className="content-area">
|
||||
<Spin
|
||||
aria-live="off"
|
||||
spinning={loading || !fetched}
|
||||
delay={200}
|
||||
description={loading ? loadingTip : t('loading')}
|
||||
|
||||
@@ -103,7 +103,7 @@ export default function VersionModal({ open, status, onClose, onBusy }: VersionM
|
||||
return (
|
||||
<Modal open={open} title={t('pages.index.xrayUpdates')} footer={null} onCancel={onClose}>
|
||||
{modalContextHolder}
|
||||
<Spin spinning={loading}>
|
||||
<Spin aria-live="off" spinning={loading}>
|
||||
<Collapse
|
||||
accordion
|
||||
activeKey={activeKey}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useId, useMemo } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Card, theme } from 'antd';
|
||||
|
||||
@@ -31,6 +31,7 @@ export default function VitalTile({
|
||||
isMobile,
|
||||
}: VitalTileProps) {
|
||||
const { token } = theme.useToken();
|
||||
const labelId = useId();
|
||||
const meanColor = token.colorTextTertiary;
|
||||
|
||||
const referenceLines = useMemo(
|
||||
@@ -39,10 +40,18 @@ export default function VitalTile({
|
||||
);
|
||||
|
||||
return (
|
||||
<Card hoverable className="ov-tile" styles={{ body: { padding: 0 } }}>
|
||||
<Card
|
||||
hoverable
|
||||
className="ov-tile"
|
||||
styles={{ body: { padding: 0 } }}
|
||||
role="group"
|
||||
aria-labelledby={labelId}
|
||||
>
|
||||
<div className="ov-tile-head">
|
||||
<span className="ov-tile-icon">{icon}</span>
|
||||
<span className="ov-kicker">{label}</span>
|
||||
<span className="ov-kicker" id={labelId}>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="ov-tile-value">
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
message,
|
||||
} from 'antd';
|
||||
import { FormProvider, useForm, useWatch } from 'react-hook-form';
|
||||
import { selectTagRender } from '@/components/form';
|
||||
import type { NodeRecord } from '@/api/queries/useNodesQuery';
|
||||
import type { RemoteInboundOption } from '@/api/queries/useNodeMutations';
|
||||
import type { Msg } from '@/utils';
|
||||
@@ -431,6 +432,7 @@ export default function NodeFormModal({
|
||||
>
|
||||
<Select
|
||||
mode="multiple"
|
||||
tagRender={selectTagRender}
|
||||
allowClear
|
||||
loading={fetchingInbounds}
|
||||
placeholder={t('pages.nodes.inboundTagsPlaceholder')}
|
||||
|
||||
@@ -127,7 +127,14 @@ function StatusLabel({ status, xrayState }: HealthProps) {
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <span>{t(`pages.nodes.statusValues.${status || 'unknown'}`)}</span>;
|
||||
// A sub-node's status is free text from its parent node: show it as unknown, not as a key.
|
||||
return (
|
||||
<span>
|
||||
{t(`pages.nodes.statusValues.${status || 'unknown'}`, {
|
||||
defaultValue: t('pages.nodes.statusValues.unknown'),
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function formatPct(p?: number, available = true): string {
|
||||
@@ -188,11 +195,11 @@ export default function NodeList({
|
||||
const [statsNode, setStatsNode] = useState<NodeRow | null>(null);
|
||||
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());
|
||||
|
||||
// Map a node GUID to its display name so a transitive sub-node can show which
|
||||
// parent it is reached through (#4983).
|
||||
// Map a direct node's GUID to its display name so a transitive sub-node can show
|
||||
// which parent it is reached through (#4983); the master walks one level only.
|
||||
const nameByGuid = useMemo(() => {
|
||||
const m = new Map<string, string>();
|
||||
for (const n of nodes) if (n.guid) m.set(n.guid, n.name || n.guid);
|
||||
for (const n of nodes) if (n.guid && !n.transitive) m.set(n.guid, n.name || n.guid);
|
||||
return m;
|
||||
}, [nodes]);
|
||||
|
||||
|
||||
@@ -290,7 +290,13 @@ export default function NodesPage() {
|
||||
|
||||
<Layout className="content-shell">
|
||||
<Layout.Content id="content-layout" className="content-area">
|
||||
<Spin spinning={!fetched} delay={200} description={t('loading')} size="large">
|
||||
<Spin
|
||||
aria-live="off"
|
||||
spinning={!fetched}
|
||||
delay={200}
|
||||
description={t('loading')}
|
||||
size="large"
|
||||
>
|
||||
{!fetched ? (
|
||||
<div className="loading-spacer" />
|
||||
) : fetchError ? (
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
SafetyCertificateOutlined,
|
||||
SettingOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { selectTagRender } from '@/components/form';
|
||||
import type { AllSetting } from '@/models/setting';
|
||||
import { isOutboundProtocol } from '@/schemas/primitives';
|
||||
import { HttpUtil, LanguageManager } from '@/utils';
|
||||
@@ -637,6 +638,7 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
|
||||
<>
|
||||
<Select
|
||||
mode="multiple"
|
||||
tagRender={selectTagRender}
|
||||
value={ldapInboundTagList}
|
||||
onChange={setLdapInboundTagList}
|
||||
style={{ width: '100%' }}
|
||||
|
||||
@@ -381,7 +381,7 @@ export default function SecurityTab({ allSetting, updateSetting, saveSetting }:
|
||||
+ {t('pages.settings.security.apiTokenNew') || 'New token'}
|
||||
</Button>
|
||||
</div>
|
||||
<Spin spinning={apiTokensLoading}>
|
||||
<Spin aria-live="off" spinning={apiTokensLoading}>
|
||||
{!apiTokens.length && !apiTokensLoading && (
|
||||
<Empty
|
||||
description={t('pages.settings.security.apiTokenEmpty') || 'No tokens yet'}
|
||||
|
||||
@@ -242,6 +242,7 @@ export default function SettingsPage() {
|
||||
<Layout className="content-shell">
|
||||
<Layout.Content id="content-layout" className="content-area">
|
||||
<Spin
|
||||
aria-live="off"
|
||||
spinning={spinning || !fetched}
|
||||
delay={200}
|
||||
description={t('loading')}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Form, Input, InputNumber, Modal, Select, Switch, message } from 'antd';
|
||||
import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form';
|
||||
|
||||
import { selectTagRender } from '@/components/form';
|
||||
import { FormField, rhfZodValidate } from '@/components/form/rhf';
|
||||
import SelectAllClearButtons from '@/components/form/SelectAllClearButtons';
|
||||
import { useInboundOptions } from '@/api/queries/useInboundOptions';
|
||||
@@ -166,8 +167,8 @@ export default function SubBalancerFormModal({
|
||||
>
|
||||
<Select
|
||||
mode="multiple"
|
||||
tagRender={selectTagRender}
|
||||
options={inboundOptions}
|
||||
maxTagCount="responsive"
|
||||
listHeight={220}
|
||||
showSearch={{ optionFilterProp: 'label' }}
|
||||
/>
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
import type { AllSetting } from '@/models/setting';
|
||||
import { onNumber } from '@/utils/onNumber';
|
||||
import { SettingListItem } from '@/components/ui';
|
||||
import { GoRegexInput } from '@/components/form';
|
||||
import { GoRegexInput, selectTagRender } from '@/components/form';
|
||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||
import { catTabLabel } from './catTabLabel';
|
||||
import { sanitizePath, normalizePath } from './uriPath';
|
||||
@@ -418,6 +418,7 @@ export default function SubscriptionFormatsTab({
|
||||
<SettingListItem paddings="small" title={<>{t('pages.settings.direct')} IPs</>}>
|
||||
<Select
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
value={ruleValues('direct', 'ip')}
|
||||
style={{ width: '100%' }}
|
||||
onChange={(v) => setRuleValues('direct', 'ip', DEFAULT_DIRECT_RULES[1], v)}
|
||||
@@ -434,6 +435,7 @@ export default function SubscriptionFormatsTab({
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
value={ruleValues('direct', 'domain')}
|
||||
style={{ width: '100%' }}
|
||||
onChange={(v) =>
|
||||
@@ -467,6 +469,7 @@ export default function SubscriptionFormatsTab({
|
||||
<SettingListItem paddings="small" title={t('pages.xray.blockdomains')}>
|
||||
<Select
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
value={ruleValues('block', 'domain')}
|
||||
style={{ width: '100%' }}
|
||||
onChange={(v) => setRuleValues('block', 'domain', DEFAULT_BLOCK_RULES[0], v)}
|
||||
@@ -476,6 +479,7 @@ export default function SubscriptionFormatsTab({
|
||||
<SettingListItem paddings="small" title={t('pages.xray.blockips')}>
|
||||
<Select
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
value={ruleValues('block', 'ip')}
|
||||
style={{ width: '100%' }}
|
||||
onChange={(v) => setRuleValues('block', 'ip', BLOCK_IP_RULE, v)}
|
||||
|
||||
@@ -72,7 +72,7 @@ export default function SponsorsPage() {
|
||||
<BecomeButton contact={data.contact} />
|
||||
</div>
|
||||
|
||||
<Spin spinning={!fetched} delay={200}>
|
||||
<Spin aria-live="off" spinning={!fetched} delay={200}>
|
||||
{sponsors.length === 0 ? (
|
||||
fetched && <YourBrandCard contact={data.contact} />
|
||||
) : (
|
||||
|
||||
@@ -319,6 +319,7 @@ export default function XrayPage() {
|
||||
<Layout className="content-shell">
|
||||
<Layout.Content id="content-layout" className="content-area">
|
||||
<Spin
|
||||
aria-live="off"
|
||||
spinning={spinning || !fetched}
|
||||
delay={200}
|
||||
description={t('loading')}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { MinusOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form';
|
||||
import type { Path } from 'react-hook-form';
|
||||
|
||||
import { selectTagRender } from '@/components/form';
|
||||
import { InputAddon } from '@/components/ui';
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
import type { XraySettingsValue } from '@/hooks/useXraySetting';
|
||||
@@ -253,6 +254,7 @@ export default function BalancerFormModal({
|
||||
<FormField name="selector" label={t('pages.xray.balancer.selector')} required>
|
||||
<Select
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
tokenSeparators={[',']}
|
||||
options={outboundTags.map((tg) => ({ value: tg, label: tg }))}
|
||||
/>
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
SettingOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
import { selectTagRender } from '@/components/form';
|
||||
import { onNumber } from '@/utils/onNumber';
|
||||
import { SettingListItem } from '@/components/ui';
|
||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||
@@ -417,6 +418,7 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab
|
||||
/>
|
||||
<Select
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
value={row.values}
|
||||
aria-label={t('pages.xray.dns.hostsValues')}
|
||||
placeholder={t('pages.xray.dns.hostsValues')}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { AutoComplete, Button, Form, Input, InputNumber, Select, Switch } from '
|
||||
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import { useFieldArray, useFormContext, useWatch } from 'react-hook-form';
|
||||
|
||||
import { selectTagRender } from '@/components/form';
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
import { activateOnKey } from '@/utils/a11y';
|
||||
import { OutboundDomainStrategies } from '@/schemas/primitives';
|
||||
@@ -274,6 +275,7 @@ export default function FreedomFields() {
|
||||
<FormField label="IP / CIDR / geoip" name={['settings', 'finalRules', index, 'ip']}>
|
||||
<Select
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
tokenSeparators={[',', ' ']}
|
||||
placeholder="e.g. 10.0.0.0/8, geoip:private"
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Select } from 'antd';
|
||||
|
||||
import { selectTagRender } from '@/components/form';
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
|
||||
export default function MasqueFields() {
|
||||
@@ -9,6 +10,7 @@ export default function MasqueFields() {
|
||||
<FormField label={t('pages.xray.outboundForm.remoteDNS')} name={['settings', 'remoteDNS']}>
|
||||
<Select
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
tokenSeparators={[',', ' ']}
|
||||
open={false}
|
||||
placeholder="1.1.1.1, 2606:4700:4700::1111"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Input, Select, Switch } from 'antd';
|
||||
|
||||
import { selectTagRender } from '@/components/form';
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
|
||||
import { ALPN_OPTIONS, UTLS_OPTIONS } from '../outbound-form-constants';
|
||||
@@ -20,7 +21,7 @@ export default function TlsForm() {
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="ALPN" name={['streamSettings', 'tlsSettings', 'alpn']}>
|
||||
<Select mode="multiple" options={ALPN_OPTIONS} />
|
||||
<Select mode="multiple" tagRender={selectTagRender} options={ALPN_OPTIONS} />
|
||||
</FormField>
|
||||
<FormField label="ECH" name={['streamSettings', 'tlsSettings', 'echConfigList']}>
|
||||
<Input />
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Form, Input, Select, Switch } from 'antd';
|
||||
import { useFormContext, useWatch } from 'react-hook-form';
|
||||
|
||||
import { HeaderMapEditor } from '@/components/form';
|
||||
import { HeaderMapEditor, selectTagRender } from '@/components/form';
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
import { MASQUE_DEFAULT_PATH } from '@/schemas/protocols/stream/masque';
|
||||
|
||||
@@ -79,6 +79,7 @@ export default function MasqueForm() {
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
tokenSeparators={[',', ' ']}
|
||||
open={false}
|
||||
placeholder="172.16.0.2, 2606:4700:110:8a36::2"
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Alert, Select, Switch } from 'antd';
|
||||
|
||||
import { selectTagRender } from '@/components/form';
|
||||
import { SettingListItem } from '@/components/ui';
|
||||
import type { XraySettingsValue, SetTemplate } from '@/hooks/useXraySetting';
|
||||
import {
|
||||
@@ -105,6 +106,7 @@ export default function RoutingBasic({ templateSettings, setTemplateSettings }:
|
||||
control={
|
||||
<Select
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
value={blockedIPs}
|
||||
style={{ width: '100%' }}
|
||||
options={IPS_OPTIONS}
|
||||
@@ -119,6 +121,7 @@ export default function RoutingBasic({ templateSettings, setTemplateSettings }:
|
||||
control={
|
||||
<Select
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
value={blockedDomains}
|
||||
style={{ width: '100%' }}
|
||||
options={BLOCK_DOMAINS_OPTIONS}
|
||||
@@ -140,6 +143,7 @@ export default function RoutingBasic({ templateSettings, setTemplateSettings }:
|
||||
control={
|
||||
<Select
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
value={directIPs}
|
||||
style={{ width: '100%' }}
|
||||
options={IPS_OPTIONS}
|
||||
@@ -159,6 +163,7 @@ export default function RoutingBasic({ templateSettings, setTemplateSettings }:
|
||||
control={
|
||||
<Select
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
value={directDomains}
|
||||
style={{ width: '100%' }}
|
||||
options={DOMAINS_OPTIONS}
|
||||
@@ -179,6 +184,7 @@ export default function RoutingBasic({ templateSettings, setTemplateSettings }:
|
||||
control={
|
||||
<Select
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
value={ipv4Domains}
|
||||
style={{ width: '100%' }}
|
||||
options={SERVICES_OPTIONS}
|
||||
|
||||
@@ -152,9 +152,7 @@ export default function RuleCardList({
|
||||
<span className="flow-arrow">→</span>
|
||||
<div className="flow-side flow-side-target">
|
||||
<span className="flow-label">
|
||||
{rule.balancerTag
|
||||
? t('pages.xray.balancer') || 'Balancer'
|
||||
: t('pages.xray.Outbounds')}
|
||||
{rule.balancerTag ? t('pages.xray.rules.balancer') : t('pages.xray.Outbounds')}
|
||||
</span>
|
||||
{rule.outboundTag ? (
|
||||
<Tag color="green" className="flow-tag">
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Button, Form, Input, Modal, Select, Space, Switch } from 'antd';
|
||||
import { PlusOutlined, MinusOutlined } from '@ant-design/icons';
|
||||
import { FormProvider, useForm, useWatch } from 'react-hook-form';
|
||||
import { selectTagRender } from '@/components/form';
|
||||
import { InputAddon } from '@/components/ui';
|
||||
import { GeoTokenInput } from '@/components/geodata';
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
@@ -222,7 +223,11 @@ export default function RuleFormModal({
|
||||
</FormField>
|
||||
|
||||
<FormField name="protocol" label={t('pages.inbounds.protocol')}>
|
||||
<Select mode="multiple" options={PROTOCOLS.map((p) => ({ value: p, label: p }))} />
|
||||
<Select
|
||||
mode="multiple"
|
||||
tagRender={selectTagRender}
|
||||
options={PROTOCOLS.map((p) => ({ value: p, label: p }))}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<Form.Item label={t('pages.xray.ruleForm.attributes')}>
|
||||
@@ -292,6 +297,7 @@ export default function RuleFormModal({
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
tokenSeparators={[',']}
|
||||
allowClear
|
||||
loading={clientsLoading}
|
||||
@@ -319,6 +325,7 @@ export default function RuleFormModal({
|
||||
<FormField name="inboundTag" label={t('pages.xray.ruleForm.inboundTags')}>
|
||||
<Select
|
||||
mode="multiple"
|
||||
tagRender={selectTagRender}
|
||||
options={inboundTags.map((tag) => ({
|
||||
value: tag,
|
||||
label: formatInboundTag(tag, remarkByTag),
|
||||
|
||||
@@ -54,7 +54,7 @@ export const AmneziawgServerSchema = z.object({
|
||||
publicKey: z.string().optional(),
|
||||
subnetIp: z.string().default('10.8.1.0'),
|
||||
subnetCidr: clearedToDefault(z.number().int().min(1).max(32).default(24)),
|
||||
mtu: optionalClearedInt(z.number().int().min(1)),
|
||||
mtu: optionalClearedInt(z.number().int().min(1).max(65535)),
|
||||
primaryDns: z.string().default('8.8.8.8'),
|
||||
secondaryDns: z.string().default('8.8.4.4'),
|
||||
externalInterface: z.string().default(''),
|
||||
@@ -71,10 +71,10 @@ export const AmneziawgServerSchema = z.object({
|
||||
// embedded userspace stack -- no Xray routing, sniffing, IPv6 or port forwarding.
|
||||
kernelMode: z.boolean().default(false).optional(),
|
||||
// Upper bounds match amneziawg-go's own UAPI parsers (device/uapi.go):
|
||||
// jc/jmin/jmax are uint32, s1-s4 uint16. Wider values make IpcSet fail.
|
||||
jc: clearedToDefault(z.number().int().min(0).max(4294967295).default(5)),
|
||||
jmin: clearedToDefault(z.number().int().min(0).max(4294967295).default(10)),
|
||||
jmax: clearedToDefault(z.number().int().min(0).max(4294967295).default(50)),
|
||||
// Every handshake allocates jc packets of jmin..jmax bytes; s1-s4 are uint16.
|
||||
jc: clearedToDefault(z.number().int().min(0).max(128).default(5)),
|
||||
jmin: clearedToDefault(z.number().int().min(0).max(65535).default(10)),
|
||||
jmax: clearedToDefault(z.number().int().min(0).max(65535).default(50)),
|
||||
s1: clearedToDefault(z.number().int().min(0).max(1552).default(30)),
|
||||
s2: clearedToDefault(z.number().int().min(0).max(1608).default(45)),
|
||||
s3: clearedToDefault(z.number().int().min(0).max(1636).default(10)),
|
||||
|
||||
@@ -10,7 +10,7 @@ export const TunInboundSettingsSchema = z.object({
|
||||
userLevel: z.number().int().min(0).default(0),
|
||||
autoSystemRoutingTable: z.array(z.string()).default([]),
|
||||
autoOutboundsInterface: z.string().default('auto'),
|
||||
// xray-core 26.9.30: the first acts on Linux, the second ("dns", "misconfigtun") on Windows.
|
||||
// xray-core 26.9.30: the first acts on Linux, the second (named values or "tcp/<port>") on Windows.
|
||||
autoSystemDnsToGateway: z.boolean().optional(),
|
||||
autoSystemWfpBlockLeak: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
@@ -14,14 +14,14 @@ export type AmneziaWGOutboundPeer = z.infer<typeof AmneziaWGOutboundPeerSchema>;
|
||||
export const AmneziaWGOutboundSettingsSchema = z.object({
|
||||
// 0 = unset, so the backend derives MTU from S4 (amneziawg.EffectiveMTU);
|
||||
// a pinned 1420 fragments every full-size packet once S4 exceeds 20.
|
||||
mtu: z.number().int().min(0).default(0),
|
||||
mtu: z.number().int().min(0).max(65535).default(0),
|
||||
secretKey: z.string().default(''),
|
||||
address: z.array(z.string()).default([]),
|
||||
listenPort: z.number().int().min(0).max(65535).default(0),
|
||||
dns: z.string().default(''),
|
||||
jc: z.number().int().min(0).default(0),
|
||||
jmin: z.number().int().min(0).default(40),
|
||||
jmax: z.number().int().min(0).default(100),
|
||||
jc: z.number().int().min(0).max(128).default(0),
|
||||
jmin: z.number().int().min(0).max(65535).default(40),
|
||||
jmax: z.number().int().min(0).max(65535).default(100),
|
||||
// The remote server sets S1-S3; only amneziawg-go's uint16 UAPI width bounds them here.
|
||||
s1: z.number().int().min(0).max(65535).default(15),
|
||||
s2: z.number().int().min(0).max(65535).default(80),
|
||||
|
||||
@@ -2,7 +2,7 @@ import { z } from 'zod';
|
||||
|
||||
// Both XDRIVE ends carry the same service, folder and secrets; an absent tuning knob takes
|
||||
// the core's default (segmentBytes and concurrency are capped there at 16 MiB / 64).
|
||||
export const XDriveServiceSchema = z.enum(['google-drive', 'local', 'template']);
|
||||
export const XDriveServiceSchema = z.enum(['google-drive', 'local', 'template', 's3']);
|
||||
export type XDriveService = z.infer<typeof XDriveServiceSchema>;
|
||||
|
||||
// xray-core 26.10.17 (#7182) renamed "Google Drive" and reads the old name only as a deprecated alias.
|
||||
@@ -22,10 +22,19 @@ export function liftLegacyXDriveService(stream: Record<string, unknown>): void {
|
||||
|
||||
const MillisSchema = z.number().int().min(0);
|
||||
|
||||
// S3-compatible storage (xray-core 26.10.31, #7231); an unset pathStyle means path-style addressing.
|
||||
export const XDriveS3Schema = z.object({
|
||||
endpoint: z.string().optional(),
|
||||
region: z.string().optional(),
|
||||
bucket: z.string().optional(),
|
||||
pathStyle: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const XDriveStreamSettingsSchema = z.object({
|
||||
service: XDriveServiceInputSchema.default('google-drive'),
|
||||
remoteFolder: z.string().default(''),
|
||||
// Google Drive takes exactly three, in order: ClientID, ClientSecret, RefreshToken.
|
||||
// Google Drive takes exactly three, in order: ClientID, ClientSecret, RefreshToken; S3 takes
|
||||
// AccessKeyID, SecretAccessKey and an optional SessionToken.
|
||||
secrets: z.array(z.string()).default([]),
|
||||
segmentBytes: z.number().int().min(0).optional(),
|
||||
flushIntervalMs: MillisSchema.optional(),
|
||||
@@ -36,6 +45,7 @@ export const XDriveStreamSettingsSchema = z.object({
|
||||
eagerWindowMs: MillisSchema.optional(),
|
||||
holeTimeoutMs: MillisSchema.optional(),
|
||||
template: z.record(z.string(), z.unknown()).optional(),
|
||||
s3: XDriveS3Schema.optional(),
|
||||
// Client XMUX (xray-core 26.10.21); left unset, the core sizes it per service, so no
|
||||
// defaults are filled in here. A JSON subscription hands an inbound's value to clients.
|
||||
xmux: z.record(z.string(), z.unknown()).optional(),
|
||||
|
||||
@@ -108,6 +108,8 @@ export const XHttpStreamSettingsSchema = z.preprocess(
|
||||
serverMaxHeaderBytes: z.number().int().min(0).default(0),
|
||||
uplinkHTTPMethod: z.string().default(''),
|
||||
headers: WsHeaderMapSchema.default({}),
|
||||
// Server-only Go net/http HTTP2Config (xray-core 26.10.31); never put into share links.
|
||||
h2Params: z.record(z.string(), z.unknown()).optional(),
|
||||
// Client-side fields stored on inbound for subscription propagation.
|
||||
// The server listener ignores them at runtime, but the panel embeds
|
||||
// them in share-link `extra` blobs so the same xhttp config can
|
||||
|
||||
@@ -42,9 +42,10 @@ describe('AmneziawgServerSchema obfuscation bounds', () => {
|
||||
['s2', 1609],
|
||||
['s3', 1637],
|
||||
['s4', 33],
|
||||
['jc', 4294967296],
|
||||
['jmin', 4294967296],
|
||||
['jmax', 5000000000],
|
||||
['jc', 129],
|
||||
['jmin', 65536],
|
||||
['jmax', 65536],
|
||||
['mtu', 65536],
|
||||
];
|
||||
|
||||
it.each(overWidth)('rejects %s past what amneziawg-go can apply or receive', (field, value) => {
|
||||
@@ -56,7 +57,9 @@ describe('AmneziawgServerSchema obfuscation bounds', () => {
|
||||
['s2', 1608],
|
||||
['s3', 1636],
|
||||
['s4', 32],
|
||||
['jc', 4294967295],
|
||||
['jc', 128],
|
||||
['jmax', 65535],
|
||||
['mtu', 65535],
|
||||
];
|
||||
|
||||
it.each(atLimit)('accepts %s exactly at its limit', (field, value) => {
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
// The Spin/Select screen-reader guards walk oxlint's AST by hand, so an oxlint
|
||||
// bump can kill them silently. These tests fail loudly when it does.
|
||||
const FIXTURES = 'tools/oxlint/__fixtures__';
|
||||
const SPIN_RULE = 'antd-a11y(spin-content-not-live)';
|
||||
const SELECT_RULE = 'antd-a11y(select-tag-remove)';
|
||||
|
||||
function runGuard(target: string): string {
|
||||
try {
|
||||
execFileSync(
|
||||
process.execPath,
|
||||
[
|
||||
'node_modules/oxlint/bin/oxlint',
|
||||
'-c',
|
||||
`${FIXTURES}/antd-a11y.oxlintrc.json`,
|
||||
'-f',
|
||||
'unix',
|
||||
target,
|
||||
],
|
||||
{ encoding: 'utf8', stdio: 'pipe' },
|
||||
);
|
||||
return '';
|
||||
} catch (error) {
|
||||
const { status, stdout } = error as { status?: number | null; stdout?: string };
|
||||
if (typeof status !== 'number') throw error;
|
||||
return String(stdout ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
function hitLines(output: string, rule: string): number[] {
|
||||
return output
|
||||
.split('\n')
|
||||
.filter((line) => line.includes(rule))
|
||||
.map((line) => Number(line.split(':')[1]));
|
||||
}
|
||||
|
||||
describe('antd-a11y-guard oxlint plugin', () => {
|
||||
it('flags a Spin whose content would be announced on every update', () => {
|
||||
expect(hitLines(runGuard(`${FIXTURES}/antd-a11y-flagged.tsx`), SPIN_RULE)).toEqual([5]);
|
||||
});
|
||||
|
||||
it('flags tag Selects without a focusable remove button or with hidden tags', () => {
|
||||
expect(hitLines(runGuard(`${FIXTURES}/antd-a11y-flagged.tsx`), SELECT_RULE)).toEqual([6, 7]);
|
||||
});
|
||||
|
||||
it('accepts quiet Spins, bare indicators, single Selects, tagRender and spreads', () => {
|
||||
const output = runGuard(`${FIXTURES}/antd-a11y-ok.tsx`);
|
||||
expect(output).not.toContain(SPIN_RULE);
|
||||
expect(output).not.toContain(SELECT_RULE);
|
||||
});
|
||||
});
|
||||
|
||||
describe('antd-a11y-guard wiring', () => {
|
||||
const config = JSON.parse(readFileSync('.oxlintrc.json', 'utf8')) as {
|
||||
jsPlugins: string[];
|
||||
overrides: { files: string[]; rules: Record<string, unknown> }[];
|
||||
};
|
||||
|
||||
it('enforces both rules on app sources but not on stories or tests', () => {
|
||||
expect(config.jsPlugins).toContain('./tools/oxlint/antd-a11y-guard.mjs');
|
||||
for (const rule of ['antd-a11y/spin-content-not-live', 'antd-a11y/select-tag-remove']) {
|
||||
expect(config.overrides.find((o) => o.rules[rule] === 'error')?.files).toEqual([
|
||||
'src/**/*.tsx',
|
||||
]);
|
||||
expect(config.overrides.find((o) => o.rules[rule] === 'off')?.files).toEqual([
|
||||
'src/**/*.stories.tsx',
|
||||
'src/test/**',
|
||||
]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -76,4 +76,33 @@ describe('GeodataSection', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// xray-core 26.10.29 refuses a scheduled asset whose file is not .dat when it starts.
|
||||
it('refuses to save a scheduled asset whose file is not .dat', async () => {
|
||||
const post = vi.spyOn(HttpUtil, 'post').mockResolvedValue(
|
||||
new Msg(
|
||||
true,
|
||||
'',
|
||||
JSON.stringify({
|
||||
xraySetting: {
|
||||
outbounds: [],
|
||||
geodata: { cron: '0 4 * * *', assets: [CUSTOM_SOURCE] },
|
||||
},
|
||||
geodataSources: [],
|
||||
}),
|
||||
),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<GeodataSection active onBusy={vi.fn()} onClose={vi.fn()} />);
|
||||
|
||||
const file = await screen.findByDisplayValue(CUSTOM_SOURCE.file);
|
||||
await user.clear(file);
|
||||
await user.type(file, 'geosite_custom.mmdb');
|
||||
await user.click(screen.getByRole('button', { name: 'Save & Restart Xray' }));
|
||||
|
||||
expect(await screen.findByText(/File names must be/)).toBeTruthy();
|
||||
expect(screen.queryByText('Save geodata settings?')).toBeNull();
|
||||
expect(post).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import { join, resolve } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/*
|
||||
* Guards the 14-locale translation set two ways: every key in en-US must be
|
||||
* Guards the 14-locale translation set: every key in en-US must be
|
||||
* referenced somewhere in the frontend or Go sources (dead keys accumulate
|
||||
* silently — this test deleted over two hundred of them when it was
|
||||
* introduced), and every locale must carry exactly the en-US key set
|
||||
@@ -82,6 +82,17 @@ describe('i18n keys', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('no literal t() key resolves to an object', () => {
|
||||
const objectKeys = Array.from(
|
||||
blob.matchAll(/\bt\(\s*(['"`])([\w.-]+)\1\s*[,)]/g),
|
||||
(m) => m[2],
|
||||
).filter((key) => enKeys.some((leaf) => leaf.startsWith(`${key}.`)));
|
||||
expect(
|
||||
objectKeys,
|
||||
`t() keys naming a group, rendered as "key '…' returned an object instead of string.":\n ${objectKeys.join('\n ')}`,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('every locale carries exactly the en-US key set', () => {
|
||||
const enSet = new Set(enKeys);
|
||||
for (const file of readdirSync(translationDir)) {
|
||||
|
||||
@@ -228,6 +228,21 @@ describe('formValuesToWirePayload', () => {
|
||||
expect(payload.sniffing).toBe('');
|
||||
});
|
||||
|
||||
// xray-core 26.10.31 (#7295): the XHTTP server reads h2Params; the form must not strip it.
|
||||
it('keeps an XHTTP inbound h2Params set through JSON', () => {
|
||||
const h2Params = { MaxConcurrentStreams: 512, MaxReadFrameSize: 1048576 };
|
||||
const values = rawInboundToFormValues({
|
||||
...vlessRow,
|
||||
streamSettings: {
|
||||
network: 'xhttp',
|
||||
security: 'none',
|
||||
xhttpSettings: { path: '/x', mode: 'auto', h2Params },
|
||||
},
|
||||
});
|
||||
const payload = formValuesToWirePayload(values);
|
||||
expect(JSON.parse(payload.streamSettings).xhttpSettings.h2Params).toEqual(h2Params);
|
||||
});
|
||||
|
||||
it('omits nodeId when null', () => {
|
||||
const values = rawInboundToFormValues({ ...vlessRow, nodeId: null });
|
||||
const payload = formValuesToWirePayload(values);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user