Skip to content

Commit e90598f

Browse files
committed
Recognize reviewed 3x-ui-pro topology and test complete upstream configs
1 parent fb88afc commit e90598f

14 files changed

Lines changed: 407 additions & 34 deletions

File tree

‎.github/workflows/checks.yml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,5 @@ jobs:
2424
run: bash tests/upstream.sh
2525
- name: Real Nginx stream and TLS fixture
2626
run: bash tests/nginx.sh
27+
- name: Current reviewed 3x-ui-pro installer and patcher topologies
28+
run: bash tests/three-x-ui.sh

‎lib/safety.py‎

Lines changed: 99 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -56,31 +56,26 @@ def __init__(self, root):
5656
self.root = Path(root).resolve()
5757
self.sources = {}
5858
self.stack = []
59+
self.reads = 0
5960

6061
def read(self, path):
6162
path = Path(path).resolve()
6263
require(not any(c in str(path) for c in "\t\r\n"), "unsupported Nginx filename")
6364
external_module = path.is_relative_to(Path("/usr/share/nginx/modules-available"))
6465
require(path.is_relative_to(self.root) or external_module, "Nginx include escapes config directory")
65-
require(path not in self.stack and path not in self.sources,
66-
"repeated or cyclic Nginx include")
66+
# A shared HTTP snippet can be included by several vhosts. Only an
67+
# active recursion is a cycle; each inclusion gets its own AST nodes.
68+
self.reads += 1
69+
require(path not in self.stack and self.reads <= 256 and len(self.stack) < 32,
70+
"cyclic or excessive Nginx includes")
6771
self.stack.append(path)
6872
source = path.read_bytes().decode("utf-8")
69-
require("\\" not in source, "escaped Nginx syntax needs manual review")
73+
require(len(source) <= 1024 * 1024, "Nginx source exceeds parser limit")
7074
self.sources[path] = source
71-
pattern = r'''\s+|\#[^\n]*|"[^"\n]*"|'[^'\n]*'|[{};]|[^\s{};"'\#]+'''
72-
tokens = []
73-
end = 0
74-
for match in re.finditer(pattern, source):
75-
require(match.start() == end)
76-
end = match.end()
77-
token = match.group()
78-
if not token.isspace() and not token.startswith("#"):
79-
tokens.append((token, match.start()))
80-
require(end == len(source))
75+
tokens = nginx_tokens(source)
8176
position = 0
8277

83-
def parse(nested=False):
78+
def parse(nested=False, map_values=False):
8479
nonlocal position
8580
nodes = []
8681
while position < len(tokens):
@@ -96,16 +91,20 @@ def parse(nested=False):
9691
args.append(token[1:-1] if token.startswith(('"', "'")) else token)
9792
position += 1
9893
require(args and position < len(tokens))
94+
if not map_values:
95+
require(re.fullmatch(r"[a-zA-Z_][a-zA-Z_0-9]*", args[0]),
96+
"escaped or unknown Nginx directive name")
9997
terminator, close = tokens[position]
10098
position += 1
10199
require(terminator != "}")
102100
children = None
103101
if terminator == "{":
104-
children, close = parse(True)
102+
children, close = parse(True, args[0] == "map")
105103
node = Node(args, children, path, start, close)
106104
nodes.append(node)
107105
if args[0] == "include":
108-
require(children is None and len(args) == 2 and "$" not in args[1])
106+
require(children is None and len(args) == 2
107+
and not any(c in args[1] for c in "$\\"))
109108
include = Path(args[1])
110109
if not include.is_absolute():
111110
include = self.root / include
@@ -143,6 +142,64 @@ def exact(nodes, key):
143142
return [n for n in expand(nodes) if n.args[0] == key]
144143

145144

145+
def nginx_tokens(source):
146+
"""Track syntax boundaries without interpreting regex/escape expressions.
147+
148+
Preserve escaped pairs in token values: critical routing directives below
149+
accept only exact literals. Quotes, escaped delimiters and ${variables} in
150+
unrelated HTTP directives cannot manufacture a block or hide a listener.
151+
"""
152+
tokens = []
153+
i = 0
154+
while i < len(source):
155+
if source[i].isspace():
156+
i += 1
157+
continue
158+
if source[i] == "#":
159+
end = source.find("\n", i)
160+
i = len(source) if end < 0 else end + 1
161+
continue
162+
start = i
163+
if source[i] in "{};":
164+
tokens.append((source[i], i))
165+
i += 1
166+
continue
167+
chars = []
168+
quote = source[i] if source[i] in "\"'" else None
169+
if quote:
170+
chars.append(quote)
171+
i += 1
172+
closed = not quote
173+
while i < len(source):
174+
c = source[i]
175+
if c == "\\":
176+
require(i + 1 < len(source), "incomplete Nginx escape")
177+
chars.append(source[i:i + 2])
178+
i += 2
179+
elif quote and c == quote:
180+
chars.append(c)
181+
i += 1
182+
closed = True
183+
break
184+
elif not quote and source.startswith("${", i):
185+
variable = re.match(r"\$\{[a-zA-Z_][a-zA-Z_0-9]*\}", source[i:])
186+
require(variable is not None, "unsupported Nginx variable syntax")
187+
chars.append(variable.group())
188+
i += len(variable.group())
189+
elif not quote and (c.isspace() or c in "{};#"):
190+
break
191+
else:
192+
require(quote or c not in "\"'", "mixed Nginx quote syntax")
193+
chars.append(c)
194+
i += 1
195+
require(closed and chars, "unterminated Nginx token")
196+
if quote:
197+
require(i == len(source) or source[i].isspace() or source[i] in ";{}#)",
198+
"concatenated Nginx tokens")
199+
tokens.append(("".join(chars), start))
200+
return tokens
201+
202+
146203
def nginx_plan(root, host, output):
147204
domain(host)
148205
parser = Nginx(root)
@@ -158,19 +215,34 @@ def nginx_plan(root, host, output):
158215
and re.fullmatch(r"\$[a-zA-Z_][a-zA-Z_0-9]*", mapping.args[2]))
159216
directives = [n.args for n in expand(router.children)]
160217
allowed = {"listen", "proxy_pass", "ssl_preread", "proxy_protocol", "proxy_timeout",
161-
"proxy_connect_timeout", "access_log", "error_log", "tcp_nodelay"}
162-
require(all(d[0] in allowed for d in directives))
218+
"proxy_connect_timeout", "access_log", "error_log", "tcp_nodelay",
219+
"set_real_ip_from"}
220+
require(all(d[0] in allowed for d in directives)
221+
and all(n.children is None for n in expand(router.children)))
222+
trusted = [d for d in directives if d[0] == "set_real_ip_from"]
223+
require(trusted in ([], [["set_real_ip_from", "unix:"]]),
224+
"unexpected stream PROXY trust boundary")
163225
for required in (["ssl_preread", "on"], ["proxy_protocol", "on"],
164226
["proxy_pass", mapping.args[2]]):
165-
require(directives.count(required) == 1)
227+
require([d for d in directives if d[0] == required[0]] == [required])
166228
listens = [d[1:] for d in directives if d[0] == "listen"]
167-
require(listens == [["443"]] or listens == [["0.0.0.0:443"]])
229+
# Existing IPv6 ingress does not enable IPv6 Telemt egress or permit an AAAA
230+
# record for the new WEB hostname. Never edit these existing listen lines.
231+
require(len(listens) in (1, 2) and len({tuple(x) for x in listens}) == len(listens))
232+
require(sum(x in (["443"], ["0.0.0.0:443"]) for x in listens) == 1)
233+
require(all(x in (["443"], ["0.0.0.0:443"], ["[::]:443"]) for x in listens))
168234
entries = list(expand(mapping.children))
235+
hostname_flags = [n for n in entries if n.args[0] == "hostnames"]
236+
require(not hostname_flags or (len(hostname_flags) == 1 and entries[0] == hostname_flags[0]
237+
and hostname_flags[0].args == ["hostnames"]
238+
and hostname_flags[0].children is None))
239+
entries = [n for n in entries if n.args[0] != "hostnames"]
169240
require(sum(n.args[0] == "default" for n in entries) == 1)
170241
require(len({n.args[0] for n in entries}) == len(entries))
171242
for n in entries:
172243
require(n.children is None and len(n.args) == 2)
173-
require(n.args[0] == "default" or re.fullmatch(r"[a-z0-9.-]+", n.args[0]))
244+
if n.args[0] != "default":
245+
domain(n.args[0]) # Exact names only, even with the hostnames flag.
174246
require(re.fullmatch(r"[A-Za-z0-9_.:-]+", n.args[1]))
175247
upstreams = exact(stream.children, "upstream")
176248
owned = [n for n in upstreams if n.args == ["upstream", "twm_frontend"]]
@@ -187,7 +259,12 @@ def nginx_plan(root, host, output):
187259
if n.args[0] == "server_name":
188260
require(host not in n.args[1:])
189261
if n.args[0] == "listen" and n not in exact(router.children, "listen"):
190-
require(not any(re.search(r"(?:^|:)(443|7444|18080)$", a) for a in n.args[1:]))
262+
require(len(n.args) >= 2)
263+
address = n.args[1]
264+
require(re.fullmatch(r"[0-9]+|[0-9.]+:[0-9]+|\[[0-9a-fA-F:]+\]:[0-9]+|unix:/[^\s\\$]+", address),
265+
"ambiguous Nginx listen address")
266+
if not address.startswith("unix:"):
267+
require(int(address.rsplit(":", 1)[-1]) not in (443, 7444, 18080))
191268
edits = {}
192269
if existing or owned or vhost.exists():
193270
require(len(existing) == len(owned) == 1 and vhost.exists())
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
events { worker_connections 1024; }
2+
http {
3+
include conf.d/*.conf;
4+
include sites-enabled/*;
5+
}
6+
stream { include stream-enabled/*.conf; }
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
server {
2+
server_name panel.example.com;
3+
listen 7443 ssl http2 proxy_protocol;
4+
listen [::]:7443 ssl http2 proxy_protocol;
5+
real_ip_header proxy_protocol;
6+
set_real_ip_from 127.0.0.1;
7+
if ($host !~* ^(.+\.)?panel.example.com$) { return 444; }
8+
if ($safe = 10) { return 444; }
9+
if ($request_uri ~ "(\"|'|;|\{|\})") { set $hack 1; }
10+
include snippets/includes.conf;
11+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
server {
2+
server_name reality.example.com;
3+
listen 9443 ssl http2;
4+
listen [::]:9443 ssl http2;
5+
include snippets/includes.conf;
6+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# A shared HTTP snippet is loaded once per vhost, not a recursion cycle.
2+
location ~ ^/(?<fwdport>\d+)/(?<fwdpath>\w+)/ {
3+
proxy_pass http://127.0.0.1:$fwdport$is_args$args;
4+
}
5+
location / { try_files $uri $uri/ =404; }
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Stream layout from both scripts at mozaroc/3x-ui-pro a2c430cd.
2+
map $ssl_preread_server_name $sni_name {
3+
hostnames;
4+
reality.example.com xray;
5+
panel.example.com www;
6+
default xray;
7+
}
8+
9+
upstream xray { server 127.0.0.1:8443; }
10+
upstream www { server 127.0.0.1:7443; }
11+
12+
server {
13+
proxy_protocol on;
14+
set_real_ip_from unix:;
15+
listen 443;
16+
listen [::]:443;
17+
proxy_pass $sni_name;
18+
ssl_preread on;
19+
}

‎tests/fresh.sh‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,10 @@ BACKUP_ROOT="$SANDBOX/backups" NGINX_ROOT="$SANDBOX/nginx" TMP="$SANDBOX/tmp"
1313
RENEW_HOOK="$SANDBOX/hooks/deploy/telemt-web-manager"
1414
DOMAIN=proxy.example.com PUBLIC_IP=203.0.113.10
1515
mkdir "$TMP"
16-
cp -r "$ROOT/tests/fixtures/nginx" "$NGINX_ROOT"
17-
mkdir "$NGINX_ROOT/conf.d"
16+
cp -r "${1:-$ROOT/tests/fixtures/nginx}" "$NGINX_ROOT"
17+
mkdir -p "$NGINX_ROOT/conf.d"
18+
stream_file="$NGINX_ROOT/stream.conf"
19+
if [[ -f $NGINX_ROOT/stream-enabled/stream.conf ]]; then stream_file="$NGINX_ROOT/stream-enabled/stream.conf"; fi
1820
systemctl() {
1921
if [[ $* == *FragmentPath* && -f $UNIT ]]; then printf '%s\n' "$UNIT"; fi
2022
return 0
@@ -50,7 +52,7 @@ import pathlib, sys, tomllib
5052
c = tomllib.loads(pathlib.Path(sys.argv[1]).read_text())
5153
assert c['access']['users']['web-user'] not in pathlib.Path(sys.argv[2]).read_text()
5254
PY
53-
before=$(sha256sum "$CONFIG" "$BIN" "$UNIT" "$NGINX_ROOT/stream.conf")
55+
before=$(sha256sum "$CONFIG" "$BIN" "$UNIT" "$stream_file")
5456
install_manager >"$SANDBOX/rerun.log" 2>&1
55-
[[ $(sha256sum "$CONFIG" "$BIN" "$UNIT" "$NGINX_ROOT/stream.conf") == "$before" ]]
57+
[[ $(sha256sum "$CONFIG" "$BIN" "$UNIT" "$stream_file") == "$before" ]]
5658
printf 'ok - full fresh install and idempotent rerun in mocked filesystem\n'

‎tests/nginx-rollback.sh‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
#!/usr/bin/env bash
2+
# Roll back the real upstream include tree with only nginx validation mocked.
3+
set -Eeuo pipefail
4+
cd -- "$(dirname -- "$0")/.."
5+
fixture=${1:?Fixture directory required}
6+
# shellcheck source=telemt-web-manager.sh
7+
source ./telemt-web-manager.sh
8+
sandbox=$(mktemp -d)
9+
trap 'rm -rf -- "$sandbox"' EXIT
10+
NGINX_ROOT="$sandbox/nginx" TMP="$sandbox/tmp" BACKUP_ROOT="$sandbox/backups"
11+
DOMAIN=proxy.example.com
12+
cp -r "$fixture" "$NGINX_ROOT"
13+
mkdir "$TMP"
14+
cp -r "$NGINX_ROOT" "$sandbox/original"
15+
nginx_plan
16+
nginx_test() { [[ ! -e $NGINX_ROOT/conf.d/telemt-web-manager.conf ]]; }
17+
nginx_reload() { return 0; }
18+
systemctl() { return 0; }
19+
set +e
20+
(trap cleanup EXIT; backup_begin; ARMED=1 INSTALLING=1; apply_nginx) >"$sandbox/rollback.log" 2>&1
21+
rc=$?
22+
set -e
23+
[[ $rc != 0 ]]
24+
diff -r "$sandbox/original" "$NGINX_ROOT"
25+
printf 'ok - upstream topology validation failure restores every config byte\n'

‎tests/nginx.sh‎

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ finish() {
1111
rm -rf -- "$test_dir"
1212
}
1313
trap finish EXIT
14-
cp -r tests/fixtures/nginx "$test_dir/nginx"
15-
mkdir "$test_dir/nginx/conf.d" "$test_dir/logs"
14+
cp -r "${1:-tests/fixtures/nginx}" "$test_dir/nginx"
15+
mkdir -p "$test_dir/nginx/conf.d" "$test_dir/logs"
1616
python3 lib/safety.py nginx-plan "$test_dir/nginx" proxy.example.com "$test_dir/plan.json"
1717
python3 - "$test_dir/plan.json" <<'PY'
1818
import json, pathlib, sys
@@ -24,13 +24,17 @@ openssl req -x509 -newkey rsa:2048 -nodes -days 1 -subj /CN=proxy.example.com \
2424
-keyout "$test_dir/key.pem" -out "$test_dir/cert.pem" >/dev/null 2>&1
2525
python3 - "$test_dir" <<'PY'
2626
from pathlib import Path
27-
import sys
27+
import re, sys
2828
t = Path(sys.argv[1])
29-
for p in (t/'nginx').rglob('*.conf'):
30-
text = p.read_text().replace('listen 443;', 'listen 127.0.0.1:14443;')
29+
for p in (t/'nginx').rglob('*'):
30+
if not p.is_file():
31+
continue
32+
text = re.sub(r'listen\s+443;', 'listen 127.0.0.1:14443;', p.read_text())
33+
text = re.sub(r'listen\s+\[::\]:443;', 'listen [::1]:14443;', text)
34+
text = re.sub(r'listen\s+80;', 'listen 127.0.0.1:18082;', text)
3135
text = text.replace(':7444', ':17444').replace(':18080', ':18081')
32-
text = text.replace('/etc/letsencrypt/live/proxy.example.com/fullchain.pem', str(t/'cert.pem'))
33-
text = text.replace('/etc/letsencrypt/live/proxy.example.com/privkey.pem', str(t/'key.pem'))
36+
text = re.sub(r'(ssl_certificate\s+)[^;]+;', lambda m: m[1] + str(t/'cert.pem') + ';', text)
37+
text = re.sub(r'(ssl_certificate_key\s+)[^;]+;', lambda m: m[1] + str(t/'key.pem') + ';', text)
3438
p.write_text(text)
3539
p = t/'nginx/nginx.conf'
3640
p.write_text('load_module /usr/lib/nginx/modules/ngx_stream_module.so;\n'
@@ -53,4 +57,11 @@ for (( attempt=0; attempt<50; attempt++ )); do
5357
sleep 0.1
5458
done
5559
[[ $ready == 1 ]]
60+
if grep -q '\[::1\]:14443' "$test_dir/nginx/stream-enabled/stream.conf" 2>/dev/null; then
61+
curl --noproxy '*' -fsS --max-time 5 --cacert "$test_dir/cert.pem" \
62+
--resolve 'proxy.example.com:14443:[::1]' \
63+
-H 'X-Forwarded-For: 203.0.113.10, 203.0.113.11' \
64+
https://proxy.example.com:14443/ -o "$test_dir/ipv6-body"
65+
grep -qx canonical "$test_dir/ipv6-body"
66+
fi
5667
printf 'ok - real Nginx stream/PROXY/TLS/HTTP1.1 and canonical X-Forwarded-For\n'

0 commit comments

Comments
 (0)