chore(ubuntu): pin xray runtime and simplify tests

This commit is contained in:
chuan
2026-05-17 23:49:31 +08:00
Unverified
parent 9d7c39b6a2
commit 3d1c413432
11 changed files with 421 additions and 298 deletions
+2
View File
@@ -1 +1,3 @@
logs
.env
.test-case-*.sh
+2
View File
@@ -0,0 +1,2 @@
- 除非处于YOLO模式,不然你应该每一步都是先告诉我思路和原理或者问题,等我确认之后再下一步
- 代码保持简洁明了的风格,禁止过度设计
-4
View File
@@ -1,4 +0,0 @@
IMAGE=ubuntu-xray-transparent-test
XRAY_URL=vless://07e41bec-d78a-4e17-9c1f-682c70ef256b@xui.akko.pchuan.site:25748?encryption=none&flow=xtls-rprx-vision&security=reality&sni=yahoo.com&fp=firefox&pbk=n2Y247czvV-3_DbiAy4mmk6s8QWaeKCmB5KI5E_831w&sid=f0&spx=%2F&type=tcp&headerType=none#5-pc
XRAY_IPV6_ENABLED=0
XRAY_DEBUG_CONFIG=0
+4
View File
@@ -0,0 +1,4 @@
IMAGE=ubuntu-xray-transparent-test
XRAY_URL=vless://00000000-0000-0000-0000-000000000000@example.com:443?encryption=none&flow=xtls-rprx-vision&security=reality&type=tcp&headerType=none&sni=example.com&fp=chrome&pbk=REPLACE_WITH_PUBLIC_KEY&sid=00&spx=%2F
XRAY_IPV6_ENABLED=0
XRAY_DEBUG_CONFIG=0
+10 -8
View File
@@ -1,9 +1,12 @@
FROM ubuntu:24.04
ARG XRAY_VERSION=latest
ARG XRAY_VERSION=v26.3.27
ARG XRAY_ZIP=Xray-linux-64.zip
ARG S6_OVERLAY_VERSION=3.2.1.0
ARG XRAY_ZIP_SHA256=23cd9af937744d97776ee35ecad4972cf4b2109d1e0fe6be9930467608f7c8ae
ARG S6_OVERLAY_VERSION=3.2.3.0
ARG S6_OVERLAY_ARCH=x86_64
ARG S6_OVERLAY_NOARCH_SHA256=b720f9d9340efc8bb07528b9743813c836e4b02f8693d90241f047998b4c53cf
ARG S6_OVERLAY_ARCH_SHA256=a93f02882c6ed46b21e7adb5c0add86154f01236c93cd82c7d682722e8840563
ARG SOURCE_VERSION=unknown
# Use USTC Ubuntu mirrors and install runtime tools.
@@ -21,7 +24,7 @@ RUN set -eux; \
{} +; \
fi; \
apt-get update; \
apt-get install -y --no-install-recommends ca-certificates curl iptables unzip xz-utils; \
apt-get install -y --no-install-recommends ca-certificates curl iptables jq unzip xz-utils; \
rm -rf /var/lib/apt/lists/*
# Install s6-overlay as PID 1 / service supervisor.
@@ -29,6 +32,8 @@ RUN set -eux; \
s6_base="https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}"; \
curl -fsSL "${s6_base}/s6-overlay-noarch.tar.xz" -o /tmp/s6-overlay-noarch.tar.xz; \
curl -fsSL "${s6_base}/s6-overlay-${S6_OVERLAY_ARCH}.tar.xz" -o /tmp/s6-overlay-${S6_OVERLAY_ARCH}.tar.xz; \
printf '%s %s\n' "${S6_OVERLAY_NOARCH_SHA256}" /tmp/s6-overlay-noarch.tar.xz | sha256sum -c -; \
printf '%s %s\n' "${S6_OVERLAY_ARCH_SHA256}" /tmp/s6-overlay-${S6_OVERLAY_ARCH}.tar.xz | sha256sum -c -; \
tar -C / -Jxpf /tmp/s6-overlay-noarch.tar.xz; \
tar -C / -Jxpf /tmp/s6-overlay-${S6_OVERLAY_ARCH}.tar.xz; \
rm -f /tmp/s6-overlay-noarch.tar.xz /tmp/s6-overlay-${S6_OVERLAY_ARCH}.tar.xz
@@ -36,12 +41,9 @@ RUN set -eux; \
# Install Xray from the official release archive.
RUN set -eux; \
download_base="https://github.com/XTLS/Xray-core/releases"; \
if [ "$XRAY_VERSION" = "latest" ]; then \
download_url="$download_base/latest/download/$XRAY_ZIP"; \
else \
download_url="$download_base/download/$XRAY_VERSION/$XRAY_ZIP"; \
fi; \
download_url="$download_base/download/$XRAY_VERSION/$XRAY_ZIP"; \
curl -fsSL "$download_url" -o /tmp/xray.zip; \
printf '%s %s\n' "${XRAY_ZIP_SHA256}" /tmp/xray.zip | sha256sum -c -; \
unzip /tmp/xray.zip -d /tmp/xray; \
install -m 0755 /tmp/xray/xray /usr/local/bin/xray; \
mkdir -p /usr/local/share/xray /etc/xray; \
+24 -14
View File
@@ -33,7 +33,7 @@ flowchart LR
```mermaid
flowchart TD
Base[ubuntu:24.04] --> Mirror[替换 apt 源为中科大镜像]
Mirror --> Packages[安装 curl / iptables / unzip / xz-utils]
Mirror --> Packages[安装 curl / iptables / jq / unzip / xz-utils]
Packages --> S6Install[安装 s6-overlay]
S6Install --> XrayInstall[下载并安装 Xray-core]
XrayInstall --> User[创建 xray 系统用户]
@@ -49,8 +49,8 @@ flowchart TD
Ubuntu[Ubuntu/] --> EnvFile[.env]
Ubuntu --> ComposeFile[compose.yml]
Ubuntu --> Dockerfile[Dockerfile]
Ubuntu --> EnvExample[.env.example]
Ubuntu --> Test[test.bat]
Ubuntu --> Runner[test-runner.sh]
Ubuntu --> Scripts[scripts/]
Ubuntu --> Rootfs[rootfs/]
@@ -73,11 +73,15 @@ flowchart TD
`.env`
运行时配置文件。默认包含 `IMAGE``XRAY_URL``XRAY_IPV6_ENABLED``XRAY_DEBUG_CONFIG`
运行时配置文件。默认包含 `IMAGE``XRAY_URL``XRAY_IPV6_ENABLED``XRAY_DEBUG_CONFIG`真实 `.env` 不入库,使用 `.env.example` 复制后填写真实节点。
`.env.example`
可提交的配置模板,不包含真实节点密钥。
`compose.yml`
定义测试服务,并把 `test-runner.sh` 只读挂载到容器内。测试脚本不会进入最终镜像
定义测试服务和运行时环境。测试命令由 `test.bat` 传入,不再维护额外的测试 runner 文件
`Dockerfile`
@@ -85,15 +89,11 @@ flowchart TD
`test.bat`
本地测试脚本。调用 Docker Compose 构建镜像,并依次运行 `proxy-on``proxy-off`
`test-runner.sh`
容器测试入口。只通过 Docker Compose 临时挂载到 `/tmp/test-runner.sh`,不被 `Dockerfile` 复制进镜像。
本地测试脚本。调用 Docker Compose 构建镜像,并依次运行 IPv6 与代理开关测试
`scripts/xray-url-to-config.sh`
把受支持的 `vless://...` 转换成 Xray JSON 配置,写到 `/tmp/xray.generated.json`。当前不是通用转换器,只支持下面两类链接:
把受支持的 `vless://...` 转换成 Xray JSON 配置,写到 `/tmp/xray.generated.json`配置由 `jq` 生成,并先写入临时文件,成功后再原子替换目标文件。当前不是通用转换器,只支持下面两类链接:
```text
vless + tcp + reality + flow=xtls-rprx-vision + headerType=none
@@ -283,8 +283,6 @@ Ubuntu\test.bat
```mermaid
flowchart TD
Build[docker compose build] --> On[proxy-on]
Mount[test-runner.sh 只读挂载] --> On
Mount --> Off
On --> OnRun[读取 .env 并启用透明代理]
OnRun --> OnCurl[curl google.com]
OnCurl --> Off[proxy-off]
@@ -297,8 +295,8 @@ flowchart TD
```bat
docker compose --env-file .env -f compose.yml build
docker compose --env-file .env -f compose.yml run --rm proxy-on
docker compose --env-file .env -f compose.yml run --rm --no-deps proxy-off
docker compose --env-file .env -f compose.yml run --rm --no-deps proxy-on bash
docker compose --env-file .env -f compose.yml run --rm --no-deps proxy-off bash
```
`proxy-on` 日志里应该出现:
@@ -315,6 +313,18 @@ docker compose --env-file .env -f compose.yml run --rm --no-deps proxy-off
Xray disabled by XRAY_ENABLED=0.
```
如果当前网络允许直连,`proxy-off` 会输出:
```text
[INFO] HTTP request without xray succeeded directly
```
如果当前网络不允许直连,`proxy-off` 会输出:
```text
[PASS] HTTP request without xray is blocked
```
测试脚本内部使用:
```sh
-18
View File
@@ -4,8 +4,6 @@ x-test-image: &test-image
args:
SOURCE_VERSION: ${SOURCE_VERSION:-local}
image: ${IMAGE:-ubuntu-xray-transparent-test}
volumes:
- ./test-runner.sh:/tmp/test-runner.sh:ro
services:
ipv6-default-off:
@@ -16,10 +14,6 @@ services:
XRAY_ENABLED: "0"
XRAY_IPV6_ENABLED: "0"
S6_LOGGING: "0"
command:
- bash
- /tmp/test-runner.sh
- ipv6-default-off
ipv6-enabled:
<<: *test-image
@@ -27,10 +21,6 @@ services:
XRAY_ENABLED: "0"
XRAY_IPV6_ENABLED: "1"
S6_LOGGING: "0"
command:
- bash
- /tmp/test-runner.sh
- ipv6-enabled
proxy-on:
<<: *test-image
@@ -40,10 +30,6 @@ services:
- .env
environment:
S6_LOGGING: "0"
command:
- bash
- /tmp/test-runner.sh
- proxy-on
proxy-off:
<<: *test-image
@@ -51,7 +37,3 @@ services:
XRAY_ENABLED: "0"
XRAY_IPV6_ENABLED: ${XRAY_IPV6_ENABLED:-0}
S6_LOGGING: "0"
command:
- bash
- /tmp/test-runner.sh
- proxy-off
+5 -5
View File
@@ -6,11 +6,6 @@ if [ -f "${XRAY_CONFIG}" ]; then
exit 0
fi
if [ -f "${XRAY_GENERATED_CONFIG:-}" ]; then
printf '%s\n' "${XRAY_GENERATED_CONFIG}"
exit 0
fi
if [ -n "${XRAY_URL:-}" ]; then
config="$(/usr/local/bin/scripts/xray-url-to-config.sh "${XRAY_URL}")"
if [ "${XRAY_DEBUG_CONFIG:-0}" = "1" ]; then
@@ -21,6 +16,11 @@ if [ -n "${XRAY_URL:-}" ]; then
exit 0
fi
if [ -f "${XRAY_GENERATED_CONFIG:-}" ]; then
printf '%s\n' "${XRAY_GENERATED_CONFIG}"
exit 0
fi
echo "Xray config not found: ${XRAY_CONFIG}" >&2
echo "Set XRAY_URL or mount a config file to ${XRAY_CONFIG}." >&2
exit 1
+146 -130
View File
@@ -27,13 +27,6 @@ fail() {
exit 1
}
json_escape() {
local value="$1"
value="${value//\\/\\\\}"
value="${value//\"/\\\"}"
printf '%s' "${value}"
}
validate_port() {
[[ "$1" =~ ^[0-9]+$ ]] || fail "Invalid port: $1"
[ "$1" -ge 1 ] && [ "$1" -le 65535 ] || fail "Invalid port: $1"
@@ -71,131 +64,152 @@ validate_path() {
[[ "${value}" != *$'\r'* ]] || fail "Invalid ${name}: carriage return is not allowed"
}
write_common_prefix() {
validate_port "${XRAY_REDIRECT_PORT}"
cat >"${generated_config}" <<EOF
{
"log": {
"loglevel": "warning"
},
"inbounds": [
{
"tag": "transparent",
"listen": "127.0.0.1",
"port": ${XRAY_REDIRECT_PORT},
"protocol": "dokodemo-door",
"settings": {
"network": "tcp",
"followRedirect": true
},
"sniffing": {
"enabled": true,
"destOverride": ["http", "tls"]
}
}
],
"outbounds": [
EOF
}
write_common_suffix() {
cat >>"${generated_config}" <<'EOF'
,
{
"tag": "direct",
"protocol": "freedom"
},
{
"tag": "block",
"protocol": "blackhole"
}
],
"routing": {
"domainStrategy": "AsIs",
"rules": [
{
"type": "field",
"ip": ["geoip:private"],
"outboundTag": "direct"
}
]
}
}
EOF
}
write_reality_tcp_outbound() {
cat >>"${generated_config}" <<EOF
{
"tag": "proxy",
"protocol": "vless",
"settings": {
"vnext": [
{
"address": "$(json_escape "${address}")",
"port": ${port},
"users": [
build_reality_tcp_outbound() {
jq -n \
--arg address "${address}" \
--argjson port "${port}" \
--arg uuid "${uuid}" \
--arg encryption "${encryption}" \
--arg flow "${flow}" \
--arg sni "${sni}" \
--arg fp "${fp}" \
--arg pbk "${pbk}" \
--arg sid "${sid}" \
--arg spx "${spx}" \
'{
tag: "proxy",
protocol: "vless",
settings: {
vnext: [
{
"id": "$(json_escape "${uuid}")",
"encryption": "$(json_escape "${encryption}")",
"flow": "$(json_escape "${flow}")"
address: $address,
port: $port,
users: [
{
id: $uuid,
encryption: $encryption,
flow: $flow
}
]
}
]
},
streamSettings: {
network: "tcp",
security: "reality",
realitySettings: {
serverName: $sni,
fingerprint: $fp,
publicKey: $pbk,
shortId: $sid,
spiderX: $spx
}
}
}'
}
build_plain_ws_outbound() {
jq -n \
--arg address "${address}" \
--argjson port "${port}" \
--arg uuid "${uuid}" \
--arg encryption "${encryption}" \
--arg path "${path}" \
--arg host "${host}" \
'{
tag: "proxy",
protocol: "vless",
settings: {
vnext: [
{
address: $address,
port: $port,
users: [
{
id: $uuid,
encryption: $encryption
}
]
}
]
},
streamSettings: {
network: "ws",
security: "none",
wsSettings: {
path: $path,
host: $host
}
}
}'
}
write_config() {
local proxy_json="$1"
jq -n \
--argjson redirect_port "${XRAY_REDIRECT_PORT}" \
--argjson proxy "${proxy_json}" \
'{
log: {
loglevel: "warning"
},
inbounds: [
{
tag: "transparent",
listen: "127.0.0.1",
port: $redirect_port,
protocol: "dokodemo-door",
settings: {
network: "tcp",
followRedirect: true
},
sniffing: {
enabled: true,
destOverride: ["http", "tls"]
}
}
],
outbounds: [
$proxy,
{
tag: "direct",
protocol: "freedom"
},
{
tag: "block",
protocol: "blackhole"
}
],
routing: {
domainStrategy: "AsIs",
rules: [
{
type: "field",
ip: ["geoip:private"],
outboundTag: "direct"
}
]
}
]
},
"streamSettings": {
"network": "tcp",
"security": "reality",
"realitySettings": {
"serverName": "$(json_escape "${sni}")",
"fingerprint": "$(json_escape "${fp}")",
"publicKey": "$(json_escape "${pbk}")",
"shortId": "$(json_escape "${sid}")",
"spiderX": "$(json_escape "${spx}")"
}
}
}
EOF
}
write_plain_ws_outbound() {
cat >>"${generated_config}" <<EOF
{
"tag": "proxy",
"protocol": "vless",
"settings": {
"vnext": [
{
"address": "$(json_escape "${address}")",
"port": ${port},
"users": [
{
"id": "$(json_escape "${uuid}")",
"encryption": "$(json_escape "${encryption}")"
}
]
}
]
},
"streamSettings": {
"network": "ws",
"security": "none",
"wsSettings": {
"path": "$(json_escape "${path}")",
"host": "$(json_escape "${host}")"
}
}
}
EOF
}' >"${tmp_config}"
}
url="${1:?XRAY_URL is required}"
generated_config="${XRAY_GENERATED_CONFIG:-/tmp/xray.generated.json}"
generated_dir="$(dirname "${generated_config}")"
tmp_config=""
mkdir -p "${generated_dir}"
tmp_config="$(mktemp "${generated_config}.XXXXXX")"
cleanup_tmp() {
if [ -n "${tmp_config:-}" ] && [ -f "${tmp_config}" ]; then
rm -f "${tmp_config}"
fi
}
trap cleanup_tmp EXIT
if [[ "${url}" != vless://* ]]; then
echo "Unsupported XRAY_URL: only vless:// is supported." >&2
exit 1
fail "Unsupported XRAY_URL: only vless:// is supported."
fi
body="${url#vless://}"
@@ -225,15 +239,13 @@ host="$(query_param "${query}" host || true)"
path="$(query_param "${query}" path || printf '/')"
if [ -z "${uuid}" ] || [ -z "${address}" ] || [ -z "${port}" ]; then
echo "Invalid XRAY_URL: missing uuid, address, or port." >&2
exit 1
fail "Invalid XRAY_URL: missing uuid, address, or port."
fi
validate_uuid "${uuid}"
validate_plain_token "address" "${address}"
validate_port "${port}"
write_common_prefix
validate_port "${XRAY_REDIRECT_PORT}"
if [ "${network}" = "tcp" ] && \
[ "${security}" = "reality" ] && \
@@ -249,7 +261,7 @@ if [ "${network}" = "tcp" ] && \
validate_base64url_token "publicKey" "${pbk}"
validate_hex_token "shortId" "${sid}"
validate_path "spiderX" "${spx}"
write_reality_tcp_outbound
proxy_json="$(build_reality_tcp_outbound)"
elif [ "${network}" = "ws" ] && \
[ "${security}" = "none" ] && \
[ "${encryption}" = "none" ] && \
@@ -257,9 +269,8 @@ elif [ "${network}" = "ws" ] && \
[ -n "${path}" ]; then
validate_plain_token "host" "${host}"
validate_path "path" "${path}"
write_plain_ws_outbound
proxy_json="$(build_plain_ws_outbound)"
else
rm -f "${generated_config}"
echo "Unsupported XRAY_URL shape." >&2
echo "Supported shapes:" >&2
echo " 1. vless + tcp + reality + flow=xtls-rprx-vision + headerType=none" >&2
@@ -267,5 +278,10 @@ else
exit 1
fi
write_common_suffix
write_config "${proxy_json}"
chown xray:xray "${tmp_config}"
chmod 0640 "${tmp_config}"
mv -f "${tmp_config}" "${generated_config}"
tmp_config=""
trap - EXIT
printf '%s\n' "${generated_config}"
-79
View File
@@ -1,79 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
case_name="${1:?test case is required}"
pass() {
printf '[PASS] %s\n' "$1"
}
no() {
printf '[NO] %s\n' "$1"
shift || true
if [ "$#" -gt 0 ]; then
printf 'reason: %s\n' "$*"
fi
exit 1
}
curl_check() {
local label="$1"
local url="$2"
shift 2
if curl --noproxy '*' "$@" -fsSIL --connect-timeout 10 --max-time 20 "${url}" >/tmp/curl.out 2>/tmp/curl.err; then
pass "${label}"
return 0
fi
printf '[NO] %s\n' "${label}"
printf 'reason: '
sed -n '1p' /tmp/curl.err || true
return 1
}
case "${case_name}" in
ipv6-default-off)
# PASS means an IPv6-only curl cannot complete when IPv6 is disabled by default.
if curl --noproxy '*' -6 -fsSIL --connect-timeout 5 --max-time 10 http://google.com >/tmp/curl.out 2>/tmp/curl.err; then
no "IPv6 request is blocked by default" "IPv6 request unexpectedly succeeded"
fi
pass "IPv6 request is blocked by default"
;;
ipv6-enabled)
# PASS means the container policy did not disable IPv6 via sysctl.
if [ -r /proc/sys/net/ipv6/conf/all/disable_ipv6 ]; then
value="$(cat /proc/sys/net/ipv6/conf/all/disable_ipv6)"
[ "${value}" = "0" ] || no "IPv6 is allowed by container policy" "disable_ipv6=${value}"
fi
pass "IPv6 is allowed by container policy"
;;
proxy-on)
sleep 3
# PASS means the transparent proxy NAT chain exists and redirects TCP to Xray.
if ! iptables -t nat -S XRAY_OUTPUT >/tmp/iptables.out 2>/tmp/iptables.err; then
no "transparent proxy iptables chain exists" "$(sed -n '1p' /tmp/iptables.err)"
fi
if ! grep -q -- "--to-ports ${XRAY_REDIRECT_PORT:-12345}" /tmp/iptables.out; then
no "transparent proxy redirect rule exists" "XRAY_OUTPUT has no REDIRECT to ${XRAY_REDIRECT_PORT:-12345}"
fi
pass "transparent proxy redirect rule exists"
# PASS means curl reaches Google while transparent proxy rules are active.
curl_check "HTTP request through transparent proxy" "http://google.com" || exit 1
;;
proxy-off)
# PASS means no Xray process exists when XRAY_ENABLED=0.
if pgrep -x xray >/dev/null 2>&1; then
no "xray is not running" "xray process exists"
fi
pass "xray is not running"
# PASS means direct curl cannot reach Google when Xray is disabled.
if curl --noproxy '*' -fsSIL --connect-timeout 3 --max-time 3 http://google.com >/tmp/curl.out 2>/tmp/curl.err; then
no "HTTP request without xray is blocked" "direct request unexpectedly succeeded"
fi
pass "HTTP request without xray is blocked"
;;
*)
no "known test case" "unknown test case: ${case_name}"
;;
esac
+227 -39
View File
@@ -1,52 +1,240 @@
@echo off
setlocal
set "TEST_BAT_PATH=%~f0"
powershell -NoProfile -ExecutionPolicy Bypass -Command "$path=$env:TEST_BAT_PATH; $raw=Get-Content -Raw -LiteralPath $path; $marker='# POWERSHELL'; $start=$raw.LastIndexOf($marker); if($start -lt 0){ throw 'PowerShell marker not found' }; $ps=$raw.Substring($start + $marker.Length); Set-Location -LiteralPath (Split-Path -Parent $path); Invoke-Expression $ps"
exit /b %ERRORLEVEL%
REM FORCE_BUILD=1: rebuild image with --no-cache before running tests.
REM FORCE_BUILD=0: reuse existing image; build only when the image is missing.
set "FORCE_BUILD=0"
# POWERSHELL
$ErrorActionPreference = 'Stop'
if (Get-Variable -Name PSNativeCommandUseErrorActionPreference -ErrorAction SilentlyContinue) {
$PSNativeCommandUseErrorActionPreference = $false
}
REM VERBOSE=1: print full stdout/stderr logs for every test.
REM VERBOSE=0: print only [PASS], [NO], and reason lines; full logs stay in logs\.
set "VERBOSE=0"
$forceBuild = if ($env:FORCE_BUILD) { $env:FORCE_BUILD } else { '0' }
$verbose = if ($env:VERBOSE) { $env:VERBOSE } else { '0' }
$composeBase = @('compose', '--env-file', '.env', '-f', 'compose.yml')
cd /d "%~dp0"
function Write-TaggedLine {
param([string] $Line)
if not exist logs mkdir logs
if ($Line.StartsWith('[PASS]')) {
Write-Host $Line -ForegroundColor Green
} elseif ($Line.StartsWith('[NO]') -or $Line.StartsWith('reason:')) {
Write-Host $Line -ForegroundColor Red
} elseif ($Line.StartsWith('[RUN]')) {
Write-Host $Line -ForegroundColor Cyan
} elseif ($Line.StartsWith('[INFO]')) {
Write-Host $Line -ForegroundColor DarkCyan
}
}
for /f %%i in ('powershell -NoProfile -ExecutionPolicy Bypass -Command "$files = Get-ChildItem -Recurse -File scripts,rootfs,Dockerfile,compose.yml,.env; ($files | Sort-Object LastWriteTimeUtc -Descending | Select-Object -First 1).LastWriteTimeUtc.Ticks"') do set "SOURCE_VERSION=%%i"
function Invoke-Docker {
param([string[]] $Arguments)
if "%FORCE_BUILD%"=="1" (
docker compose --env-file .env -f compose.yml build --quiet --no-cache >logs\build.out 2>logs\build.err
if errorlevel 1 exit /b %errorlevel%
) else (
docker compose --env-file .env -f compose.yml build --quiet >logs\build.out 2>logs\build.err
if errorlevel 1 (
docker image inspect ubuntu-xray-transparent-test >nul 2>nul
if errorlevel 1 exit /b 1
)
$previousErrorActionPreference = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
try {
$output = & docker @Arguments 2>&1
$code = $LASTEXITCODE
} finally {
$ErrorActionPreference = $previousErrorActionPreference
}
$lines = @(
$output | ForEach-Object {
if ($_ -is [System.Management.Automation.ErrorRecord]) {
$_.Exception.Message
} else {
$_.ToString()
}
} | Where-Object {
$_ -and $_ -ne 'System.Management.Automation.RemoteException'
}
)
[pscustomobject]@{
Code = $code
Lines = $lines
}
}
function Print-FilteredOutput {
param([string[]] $Lines)
foreach ($line in $Lines) {
if ($verbose -eq '1') {
Write-Host $line
} else {
Write-TaggedLine $line
}
}
}
function Print-FailureDiagnostics {
param([string[]] $Lines)
$hasNoLine = $Lines | Where-Object { $_.StartsWith('[NO]') } | Select-Object -First 1
if (-not $hasNoLine) {
Write-Host '--- raw docker output ---' -ForegroundColor Yellow
$Lines | Select-Object -Last 80 | ForEach-Object { Write-Host $_ }
}
}
function Build-Image {
Write-Host ''
Write-TaggedLine '[RUN] Build image'
$sourceFiles = Get-ChildItem -Recurse -File scripts, rootfs, Dockerfile, compose.yml, .env, .env.example
$env:SOURCE_VERSION = ($sourceFiles | Sort-Object LastWriteTimeUtc -Descending | Select-Object -First 1).LastWriteTimeUtc.Ticks
$dockerArgs = $composeBase + @('build', '--quiet')
if ($forceBuild -eq '1') {
$dockerArgs += '--no-cache'
}
$result = Invoke-Docker $dockerArgs
if ($result.Code -ne 0) {
Write-TaggedLine '[NO] Build image'
$result.Lines | ForEach-Object { Write-Host $_ }
exit $result.Code
}
Write-TaggedLine '[PASS] Build image'
if ($verbose -eq '1') {
$result.Lines | ForEach-Object { Write-Host $_ }
}
}
function Run-Case {
param(
[string] $Title,
[string] $Service,
[string] $Script
)
Write-Host ''
Write-TaggedLine "[RUN] $Title"
$casePath = Join-Path (Get-Location) ('.test-case-{0}.sh' -f ([guid]::NewGuid().ToString('N')))
Set-Content -LiteralPath $casePath -Value $Script -NoNewline -Encoding Ascii
try {
$volumeArg = "${casePath}:/tmp/test-case.sh:ro"
$dockerArgs = $composeBase + @('run', '--rm', '--no-deps', '-T', '--volume', $volumeArg, $Service, 'bash', '/tmp/test-case.sh')
$result = Invoke-Docker $dockerArgs
} finally {
Remove-Item -LiteralPath $casePath -Force -ErrorAction SilentlyContinue
}
Print-FilteredOutput $result.Lines
if ($result.Code -ne 0) {
Print-FailureDiagnostics $result.Lines
}
return $result.Code
}
if (-not (Test-Path -LiteralPath '.env')) {
Write-TaggedLine '[NO] .env exists'
Write-TaggedLine 'reason: create Ubuntu\.env from Ubuntu\.env.example and set XRAY_URL.'
exit 1
}
$tests = @(
@{
Title = 'IPv6 default off'
Service = 'ipv6-default-off'
Script = @'
set -euo pipefail
if curl --noproxy '*' -6 -fsSIL --connect-timeout 5 --max-time 10 http://google.com >/tmp/curl.out 2>/tmp/curl.err; then
echo '[NO] IPv6 request is blocked by default'
echo 'reason: IPv6 request unexpectedly succeeded'
exit 1
fi
echo '[PASS] IPv6 request is blocked by default'
'@
}
@{
Title = 'IPv6 enabled'
Service = 'ipv6-enabled'
Script = @'
set -euo pipefail
if [ -r /proc/sys/net/ipv6/conf/all/disable_ipv6 ]; then
value="$(cat /proc/sys/net/ipv6/conf/all/disable_ipv6)"
if [ "${value}" != "0" ]; then
echo '[NO] IPv6 is allowed by container policy'
echo "reason: disable_ipv6=${value}"
exit 1
fi
fi
echo '[PASS] IPv6 is allowed by container policy'
'@
}
@{
Title = 'Proxy on'
Service = 'proxy-on'
Script = @'
set -euo pipefail
sleep 3
if ! iptables -t nat -S XRAY_OUTPUT >/tmp/iptables.out 2>/tmp/iptables.err; then
echo '[NO] transparent proxy iptables chain exists'
echo "reason: $(sed -n '1p' /tmp/iptables.err)"
exit 1
fi
if ! grep -q -- "--to-ports ${XRAY_REDIRECT_PORT:-12345}" /tmp/iptables.out; then
echo '[NO] transparent proxy redirect rule exists'
echo "reason: XRAY_OUTPUT has no REDIRECT to ${XRAY_REDIRECT_PORT:-12345}"
exit 1
fi
echo '[PASS] transparent proxy redirect rule exists'
if curl --noproxy '*' -fsSIL --connect-timeout 10 --max-time 20 http://google.com >/tmp/curl.out 2>/tmp/curl.err; then
echo '[PASS] HTTP request through transparent proxy'
else
echo '[NO] HTTP request through transparent proxy'
echo "reason: $(sed -n '1p' /tmp/curl.err)"
exit 1
fi
'@
}
@{
Title = 'Proxy off'
Service = 'proxy-off'
Script = @'
set -euo pipefail
if pgrep -x xray >/dev/null 2>&1; then
echo '[NO] xray is not running'
echo 'reason: xray process exists'
exit 1
fi
echo '[PASS] xray is not running'
if curl --noproxy '*' -fsSIL --connect-timeout 3 --max-time 3 http://google.com >/tmp/curl.out 2>/tmp/curl.err; then
echo '[INFO] HTTP request without xray succeeded directly'
exit 0
fi
echo '[PASS] HTTP request without xray is blocked'
'@
}
)
docker compose --env-file .env -f compose.yml run --rm --no-deps ipv6-default-off >logs\ipv6-default-off.out 2>logs\ipv6-default-off.err
set "IPV6_OFF_CODE=%errorlevel%"
call :print_result logs\ipv6-default-off.out logs\ipv6-default-off.err
Build-Image
docker compose --env-file .env -f compose.yml run --rm --no-deps ipv6-enabled >logs\ipv6-enabled.out 2>logs\ipv6-enabled.err
set "IPV6_ON_CODE=%errorlevel%"
call :print_result logs\ipv6-enabled.out logs\ipv6-enabled.err
$failed = $false
foreach ($test in $tests) {
$code = Run-Case -Title $test.Title -Service $test.Service -Script $test.Script
if ($code -ne 0) {
$failed = $true
}
}
docker compose --env-file .env -f compose.yml run --rm --no-deps proxy-on >logs\proxy-on.out 2>logs\proxy-on.err
set "PROXY_ON_CODE=%errorlevel%"
call :print_result logs\proxy-on.out logs\proxy-on.err
Write-Host ''
if ($failed) {
Write-TaggedLine '[NO] one or more tests failed'
exit 1
}
docker compose --env-file .env -f compose.yml run --rm --no-deps proxy-off >logs\proxy-off.out 2>logs\proxy-off.err
set "PROXY_OFF_CODE=%errorlevel%"
call :print_result logs\proxy-off.out logs\proxy-off.err
if not "%IPV6_OFF_CODE%"=="0" exit /b %IPV6_OFF_CODE%
if not "%IPV6_ON_CODE%"=="0" exit /b %IPV6_ON_CODE%
if not "%PROXY_ON_CODE%"=="0" exit /b %PROXY_ON_CODE%
exit /b %PROXY_OFF_CODE%
:print_result
powershell -NoProfile -ExecutionPolicy Bypass -Command "$verbose='%VERBOSE%'; $paths=@('%~1','%~2'); foreach($path in $paths){ if(!(Test-Path -LiteralPath $path)){ continue }; foreach($line in Get-Content -LiteralPath $path){ if($verbose -eq '1'){ Write-Host $line; continue }; if($line.StartsWith('[PASS]')){ Write-Host $line -ForegroundColor Green } elseif($line.StartsWith('[NO]') -or $line.StartsWith('reason:')){ Write-Host $line -ForegroundColor Red } } }"
exit /b 0
Write-TaggedLine '[PASS] all tests passed'
exit 0