Template
Fix Cascade egress verify to bind awg0 IP (v2.2.1).
Use client-subnet policy routing for curl checks instead of cascade peer Address; soft-warn when echo services fail but the route is OK.
This commit is contained in:
@@ -227,10 +227,14 @@ GitHub Actions workflows in `.github/workflows/`:
|
|||||||
|
|
||||||
## 📋 Fix / changelog (this fork)
|
## 📋 Fix / changelog (this fork)
|
||||||
|
|
||||||
|
### v2.2.1
|
||||||
|
* **Cascade egress verify fix**: bind curl to ENTRY `awg0`/`wg0` IP (client-subnet policy route), not cascade peer Address.
|
||||||
|
* Soft-warn (no rollback) when policy route via `cascade` is OK but public-IP echo services fail.
|
||||||
|
|
||||||
### v2.2.0
|
### v2.2.0
|
||||||
* **Cascade (double VPN) restored** — stable entry→exit tunnel for AmneziaWG / WireGuard.
|
* **Cascade (double VPN) restored** — stable entry→exit tunnel for AmneziaWG / WireGuard.
|
||||||
* Inspired by [ryderams/amneziawg-cascade](https://github.com/ryderams/amneziawg-cascade); logic runs over panel SSH (**no remote curl|bash**).
|
* Inspired by [ryderams/amneziawg-cascade](https://github.com/ryderams/amneziawg-cascade); logic runs over panel SSH (**no remote curl|bash**).
|
||||||
* Safety: require exit **handshake** when enabled; optional **egress verify** via cascade Address; subnet conflict check; exit `/32` return route with start.sh markers.
|
* Safety: require exit **handshake** when enabled; optional **egress verify**; subnet conflict check; exit `/32` return route with start.sh markers.
|
||||||
* UI on the server page with per-step settings (pin exit route, MSS clamp, verify egress, …). Defaults prefer **awg2**.
|
* UI on the server page with per-step settings (pin exit route, MSS clamp, verify egress, …). Defaults prefer **awg2**.
|
||||||
|
|
||||||
### v2.1.0
|
### v2.1.0
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ else:
|
|||||||
application_path = os.path.dirname(__file__)
|
application_path = os.path.dirname(__file__)
|
||||||
|
|
||||||
DATA_FILE = os.path.join(application_path, 'data.json') # legacy JSON; used only for one-shot import / export
|
DATA_FILE = os.path.join(application_path, 'data.json') # legacy JSON; used only for one-shot import / export
|
||||||
CURRENT_VERSION = "v2.2.0"
|
CURRENT_VERSION = "v2.2.1"
|
||||||
RELEASES_REPO_URL = "https://git.evilfox.cc/test2/Amnezia-Web-Panel-main"
|
RELEASES_REPO_URL = "https://git.evilfox.cc/test2/Amnezia-Web-Panel-main"
|
||||||
RELEASES_API_LATEST = "https://git.evilfox.cc/api/v1/repos/test2/Amnezia-Web-Panel-main/releases/latest"
|
RELEASES_API_LATEST = "https://git.evilfox.cc/api/v1/repos/test2/Amnezia-Web-Panel-main/releases/latest"
|
||||||
BIN_DIR = os.environ.get('TUNNEL_BIN_DIR', os.path.join(application_path, 'bin'))
|
BIN_DIR = os.environ.get('TUNNEL_BIN_DIR', os.path.join(application_path, 'bin'))
|
||||||
@@ -3476,7 +3476,12 @@ async def api_cascade_setup(request: Request, server_id: int, req: CascadeSetupR
|
|||||||
}
|
}
|
||||||
async with DATA_LOCK:
|
async with DATA_LOCK:
|
||||||
await asyncio.to_thread(save_data, data)
|
await asyncio.to_thread(save_data, data)
|
||||||
return {'status': 'success', 'cascade': entry['cascade'], 'applied': applied}
|
return {
|
||||||
|
'status': 'success',
|
||||||
|
'cascade': entry['cascade'],
|
||||||
|
'applied': applied,
|
||||||
|
'warning': applied.get('warning'),
|
||||||
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("Error setting up cascade")
|
logger.exception("Error setting up cascade")
|
||||||
return JSONResponse({'error': str(e)}, status_code=500)
|
return JSONResponse({'error': str(e)}, status_code=500)
|
||||||
|
|||||||
+143
-13
@@ -261,6 +261,137 @@ class CascadeManager:
|
|||||||
'raw': text.strip()[:3000],
|
'raw': text.strip()[:3000],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _vpn_server_iface(self, entry_protocol: str) -> str:
|
||||||
|
base = self.proto_base(entry_protocol)
|
||||||
|
if base in ('awg_legacy', 'wireguard'):
|
||||||
|
return 'wg0'
|
||||||
|
return 'awg0'
|
||||||
|
|
||||||
|
def _vpn_server_ip(self, entry_protocol: str, container: str) -> str:
|
||||||
|
"""IPv4 on awg0/wg0 — traffic from this IP is policy-routed through cascade."""
|
||||||
|
iface = self._vpn_server_iface(entry_protocol)
|
||||||
|
out, _, _ = self.ssh.run_sudo_command(
|
||||||
|
f"docker exec {shlex.quote(container)} sh -c "
|
||||||
|
f"{shlex.quote(f'ip -o -4 addr show dev {iface} 2>/dev/null')}",
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
for line in (out or '').splitlines():
|
||||||
|
m = re.search(r'\binet\s+(\d+\.\d+\.\d+\.\d+)/\d+', line)
|
||||||
|
if m:
|
||||||
|
return m.group(1)
|
||||||
|
return ''
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_public_ipv4(text: str) -> str:
|
||||||
|
raw = (text or '').strip()
|
||||||
|
if not raw:
|
||||||
|
return ''
|
||||||
|
for line in reversed(raw.splitlines()):
|
||||||
|
line = line.strip()
|
||||||
|
if re.fullmatch(r'\d+\.\d+\.\d+\.\d+', line):
|
||||||
|
return line
|
||||||
|
m = re.search(r'\b(\d{1,3}(?:\.\d{1,3}){3})\b', raw)
|
||||||
|
return m.group(1) if m else ''
|
||||||
|
|
||||||
|
def _verify_egress(self, container: str, entry_protocol: str, subnet: str) -> dict:
|
||||||
|
"""Confirm client-subnet traffic exits via cascade (ryderams-style).
|
||||||
|
|
||||||
|
Bind curl to the VPN server IP on awg0/wg0 (NOT the cascade peer Address).
|
||||||
|
Policy routing only applies to the client subnet — cascade Address must
|
||||||
|
not be used as --interface.
|
||||||
|
"""
|
||||||
|
server_ip = self._vpn_server_ip(entry_protocol, container)
|
||||||
|
iface = self._vpn_server_iface(entry_protocol)
|
||||||
|
if not server_ip:
|
||||||
|
return {
|
||||||
|
'ok': False,
|
||||||
|
'skipped': False,
|
||||||
|
'detail': f'Could not read IPv4 on {iface} for egress verify',
|
||||||
|
'bind': '',
|
||||||
|
}
|
||||||
|
|
||||||
|
route_out, _, _ = self.ssh.run_sudo_command(
|
||||||
|
f"docker exec {shlex.quote(container)} sh -c "
|
||||||
|
f"{shlex.quote(f'ip route get 1.1.1.1 from {server_ip} 2>/dev/null | head -1')}",
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
route_line = (route_out or '').strip()
|
||||||
|
route_via_cascade = bool(re.search(r'\bdev\s+cascade\b', route_line))
|
||||||
|
|
||||||
|
if not route_via_cascade:
|
||||||
|
return {
|
||||||
|
'ok': False,
|
||||||
|
'skipped': False,
|
||||||
|
'detail': (
|
||||||
|
f'Policy route from {server_ip} does not use cascade iface. '
|
||||||
|
f'Got: {route_line or "empty"}'
|
||||||
|
),
|
||||||
|
'bind': server_ip,
|
||||||
|
'route': route_line,
|
||||||
|
'subnet': subnet,
|
||||||
|
}
|
||||||
|
|
||||||
|
has_curl, _, _ = self.ssh.run_sudo_command(
|
||||||
|
f"docker exec {shlex.quote(container)} sh -c "
|
||||||
|
f"{shlex.quote('command -v curl >/dev/null 2>&1 && echo YES')}",
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
if 'YES' not in (has_curl or ''):
|
||||||
|
return {
|
||||||
|
'ok': True,
|
||||||
|
'skipped': True,
|
||||||
|
'warning': True,
|
||||||
|
'detail': 'curl not installed in container — egress IP check skipped (policy route OK)',
|
||||||
|
'bind': server_ip,
|
||||||
|
'route': route_line,
|
||||||
|
}
|
||||||
|
|
||||||
|
urls = (
|
||||||
|
'https://api.ipify.org',
|
||||||
|
'https://icanhazip.com',
|
||||||
|
'https://ifconfig.me/ip',
|
||||||
|
'http://api.ipify.org',
|
||||||
|
'http://icanhazip.com',
|
||||||
|
)
|
||||||
|
binds = [server_ip, 'cascade']
|
||||||
|
last_detail = 'empty response'
|
||||||
|
for bind in binds:
|
||||||
|
for url in urls:
|
||||||
|
inner = (
|
||||||
|
f'curl --interface {shlex.quote(bind)} -4 -sS --max-time 12 '
|
||||||
|
f'{shlex.quote(url)} 2>&1 || true'
|
||||||
|
)
|
||||||
|
egress_out, _, _ = self.ssh.run_sudo_command(
|
||||||
|
f"docker exec {shlex.quote(container)} sh -c {shlex.quote(inner)}",
|
||||||
|
timeout=25,
|
||||||
|
)
|
||||||
|
egress_ip = self._parse_public_ipv4(egress_out or '')
|
||||||
|
if egress_ip:
|
||||||
|
return {
|
||||||
|
'ok': True,
|
||||||
|
'skipped': False,
|
||||||
|
'egress_ip': egress_ip,
|
||||||
|
'bind': bind,
|
||||||
|
'url': url,
|
||||||
|
'route': route_line,
|
||||||
|
'detail': f'{egress_ip} via {bind}',
|
||||||
|
}
|
||||||
|
last_detail = ((egress_out or '') or 'empty response').strip()[:300]
|
||||||
|
|
||||||
|
# Handshake + policy route OK; echo services often fail from VPS — keep cascade up.
|
||||||
|
return {
|
||||||
|
'ok': True,
|
||||||
|
'skipped': False,
|
||||||
|
'warning': True,
|
||||||
|
'detail': (
|
||||||
|
f'Policy route OK via cascade, but public-IP curl failed '
|
||||||
|
f'(bind {server_ip}). Last: {last_detail or "empty response"}'
|
||||||
|
),
|
||||||
|
'bind': server_ip,
|
||||||
|
'route': route_line,
|
||||||
|
'subnet': subnet,
|
||||||
|
}
|
||||||
|
|
||||||
def _vpn_subnet(self, entry_protocol: str, container: str, override: str = '') -> str:
|
def _vpn_subnet(self, entry_protocol: str, container: str, override: str = '') -> str:
|
||||||
if override and re.match(r'^\d+\.\d+\.\d+\.\d+/\d+$', override.strip()):
|
if override and re.match(r'^\d+\.\d+\.\d+\.\d+/\d+$', override.strip()):
|
||||||
return override.strip()
|
return override.strip()
|
||||||
@@ -507,31 +638,28 @@ echo CASCADE_UP_OK
|
|||||||
st = self.status(entry_protocol)
|
st = self.status(entry_protocol)
|
||||||
handshake_ok = bool(st.get('handshake'))
|
handshake_ok = bool(st.get('handshake'))
|
||||||
|
|
||||||
if opts.get('verify_egress', True) and handshake_ok and cascade_peer_ip:
|
egress_info = {}
|
||||||
curl_cmd = (
|
if opts.get('verify_egress', True) and handshake_ok:
|
||||||
f"curl --interface {shlex.quote(cascade_peer_ip)} -4 -s --max-time 12 "
|
egress_info = self._verify_egress(container, entry_protocol, subnet)
|
||||||
f"https://ifconfig.me || true"
|
if not egress_info.get('ok'):
|
||||||
)
|
|
||||||
egress_out, egress_err, _egress_code = self.ssh.run_sudo_command(
|
|
||||||
f"docker exec {shlex.quote(container)} bash -lc {shlex.quote(curl_cmd)}",
|
|
||||||
timeout=30,
|
|
||||||
)
|
|
||||||
egress_ip = (egress_out or '').strip().splitlines()[-1].strip() if (egress_out or '').strip() else ''
|
|
||||||
if not egress_ip or not re.match(r'^\d+\.\d+\.\d+\.\d+$', egress_ip):
|
|
||||||
try:
|
try:
|
||||||
self.disable(entry_protocol, settings=opts)
|
self.disable(entry_protocol, settings=opts)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
detail = ((egress_err or egress_out) or 'empty response').strip()[:400]
|
bind = egress_info.get('bind') or 'awg0'
|
||||||
|
detail = egress_info.get('detail') or 'empty response'
|
||||||
|
route = egress_info.get('route') or ''
|
||||||
return {
|
return {
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': (
|
'message': (
|
||||||
'Cascade handshake OK but egress verify failed '
|
'Cascade handshake OK but egress verify failed '
|
||||||
f'(curl --interface {cascade_peer_ip} → ifconfig.me). '
|
f'(curl --interface {bind} via client subnet {subnet}). '
|
||||||
f'Detail: {detail}'
|
f'Detail: {detail}'
|
||||||
|
+ (f' Route: {route}' if route else '')
|
||||||
),
|
),
|
||||||
'handshake': True,
|
'handshake': True,
|
||||||
'up': bool(st.get('up')),
|
'up': bool(st.get('up')),
|
||||||
|
'egress': egress_info,
|
||||||
'log': combined[:1500],
|
'log': combined[:1500],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -544,6 +672,8 @@ echo CASCADE_UP_OK
|
|||||||
'cascade_address': cascade_addr_cidr,
|
'cascade_address': cascade_addr_cidr,
|
||||||
'up': bool(st.get('up')),
|
'up': bool(st.get('up')),
|
||||||
'handshake': handshake_ok,
|
'handshake': handshake_ok,
|
||||||
|
'egress': egress_info or None,
|
||||||
|
'warning': (egress_info or {}).get('detail') if (egress_info or {}).get('warning') else None,
|
||||||
'settings': opts,
|
'settings': opts,
|
||||||
'applied_at': datetime.now().isoformat(timespec='seconds'),
|
'applied_at': datetime.now().isoformat(timespec='seconds'),
|
||||||
'diagnostics': st.get('raw', '')[:1500],
|
'diagnostics': st.get('raw', '')[:1500],
|
||||||
|
|||||||
@@ -3274,6 +3274,9 @@
|
|||||||
handshake: !!(res.cascade && res.cascade.handshake),
|
handshake: !!(res.cascade && res.cascade.handshake),
|
||||||
});
|
});
|
||||||
showToast(enable ? _('cascade_enabled') : _('cascade_disabled'), 'success');
|
showToast(enable ? _('cascade_enabled') : _('cascade_disabled'), 'success');
|
||||||
|
if (enable && res.warning) {
|
||||||
|
showToast(res.warning, 'info');
|
||||||
|
}
|
||||||
await loadCascade();
|
await loadCascade();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast(_('error') + ': ' + err.message, 'error');
|
showToast(_('error') + ': ' + err.message, 'error');
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"nav_servers": "Servers",
|
"nav_servers": "Servers",
|
||||||
"nav_users": "Users",
|
"nav_users": "Users",
|
||||||
"nav_invites": "Invite links",
|
"nav_invites": "Invite links",
|
||||||
@@ -611,7 +611,7 @@
|
|||||||
"cascade_opt_wait_handshake": "Wait for exit handshake",
|
"cascade_opt_wait_handshake": "Wait for exit handshake",
|
||||||
"cascade_opt_wait_handshake_hint": "Do not mark cascade active if the exit peer never answers.",
|
"cascade_opt_wait_handshake_hint": "Do not mark cascade active if the exit peer never answers.",
|
||||||
"cascade_verify_egress": "Verify egress IP",
|
"cascade_verify_egress": "Verify egress IP",
|
||||||
"cascade_verify_egress_hint": "After handshake, curl ifconfig.me via the cascade Address; fail and roll back if empty.",
|
"cascade_verify_egress_hint": "After handshake, verify policy route from awg0 IP via cascade, then curl public-IP services. Soft-warn if curl fails but route is OK.",
|
||||||
"cascade_opt_keep_dns": "Keep DNS from exit config",
|
"cascade_opt_keep_dns": "Keep DNS from exit config",
|
||||||
"cascade_opt_keep_dns_hint": "Usually off — the client sets DNS itself.",
|
"cascade_opt_keep_dns_hint": "Usually off — the client sets DNS itself.",
|
||||||
"cascade_opt_handshake_timeout": "Handshake timeout (sec)",
|
"cascade_opt_handshake_timeout": "Handshake timeout (sec)",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"nav_servers": "سرورها",
|
"nav_servers": "سرورها",
|
||||||
"nav_users": "کاربران",
|
"nav_users": "کاربران",
|
||||||
"nav_invites": "لینک دعوت",
|
"nav_invites": "لینک دعوت",
|
||||||
@@ -554,7 +554,7 @@
|
|||||||
"cascade_opt_wait_handshake": "Wait for exit handshake",
|
"cascade_opt_wait_handshake": "Wait for exit handshake",
|
||||||
"cascade_opt_wait_handshake_hint": "Do not mark cascade active if the exit peer never answers.",
|
"cascade_opt_wait_handshake_hint": "Do not mark cascade active if the exit peer never answers.",
|
||||||
"cascade_verify_egress": "Verify egress IP",
|
"cascade_verify_egress": "Verify egress IP",
|
||||||
"cascade_verify_egress_hint": "After handshake, curl ifconfig.me via the cascade Address; fail and roll back if empty.",
|
"cascade_verify_egress_hint": "After handshake, verify policy route from awg0 IP via cascade, then curl public-IP services. Soft-warn if curl fails but route is OK.",
|
||||||
"cascade_opt_keep_dns": "Keep DNS from exit config",
|
"cascade_opt_keep_dns": "Keep DNS from exit config",
|
||||||
"cascade_opt_keep_dns_hint": "Usually off — the client sets DNS itself.",
|
"cascade_opt_keep_dns_hint": "Usually off — the client sets DNS itself.",
|
||||||
"cascade_opt_handshake_timeout": "Handshake timeout (sec)",
|
"cascade_opt_handshake_timeout": "Handshake timeout (sec)",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"nav_servers": "Serveurs",
|
"nav_servers": "Serveurs",
|
||||||
"nav_users": "Utilisateurs",
|
"nav_users": "Utilisateurs",
|
||||||
"nav_invites": "Liens d\u0027invitation",
|
"nav_invites": "Liens d\u0027invitation",
|
||||||
@@ -554,7 +554,7 @@
|
|||||||
"cascade_opt_wait_handshake": "Wait for exit handshake",
|
"cascade_opt_wait_handshake": "Wait for exit handshake",
|
||||||
"cascade_opt_wait_handshake_hint": "Do not mark cascade active if the exit peer never answers.",
|
"cascade_opt_wait_handshake_hint": "Do not mark cascade active if the exit peer never answers.",
|
||||||
"cascade_verify_egress": "Verify egress IP",
|
"cascade_verify_egress": "Verify egress IP",
|
||||||
"cascade_verify_egress_hint": "After handshake, curl ifconfig.me via the cascade Address; fail and roll back if empty.",
|
"cascade_verify_egress_hint": "After handshake, verify policy route from awg0 IP via cascade, then curl public-IP services. Soft-warn if curl fails but route is OK.",
|
||||||
"cascade_opt_keep_dns": "Keep DNS from exit config",
|
"cascade_opt_keep_dns": "Keep DNS from exit config",
|
||||||
"cascade_opt_keep_dns_hint": "Usually off — the client sets DNS itself.",
|
"cascade_opt_keep_dns_hint": "Usually off — the client sets DNS itself.",
|
||||||
"cascade_opt_handshake_timeout": "Handshake timeout (sec)",
|
"cascade_opt_handshake_timeout": "Handshake timeout (sec)",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"nav_servers": "Серверы",
|
"nav_servers": "Серверы",
|
||||||
"nav_users": "Пользователи",
|
"nav_users": "Пользователи",
|
||||||
"nav_invites": "Ссылки",
|
"nav_invites": "Ссылки",
|
||||||
@@ -611,7 +611,7 @@
|
|||||||
"cascade_opt_wait_handshake": "Ждать handshake с выходом",
|
"cascade_opt_wait_handshake": "Ждать handshake с выходом",
|
||||||
"cascade_opt_wait_handshake_hint": "Не помечать каскад «активным», если выход не отвечает.",
|
"cascade_opt_wait_handshake_hint": "Не помечать каскад «активным», если выход не отвечает.",
|
||||||
"cascade_verify_egress": "Проверять внешний IP",
|
"cascade_verify_egress": "Проверять внешний IP",
|
||||||
"cascade_verify_egress_hint": "После handshake — curl ifconfig.me через Address каскада; при пустом ответе откат.",
|
"cascade_verify_egress_hint": "После handshake — проверка policy route с IP awg0 через cascade, затем curl. При сбое curl (при OK маршруте) — предупреждение, не откат.",
|
||||||
"cascade_opt_keep_dns": "Оставить DNS из конфига выхода",
|
"cascade_opt_keep_dns": "Оставить DNS из конфига выхода",
|
||||||
"cascade_opt_keep_dns_hint": "Обычно выключено — DNS берёт клиент сам.",
|
"cascade_opt_keep_dns_hint": "Обычно выключено — DNS берёт клиент сам.",
|
||||||
"cascade_opt_handshake_timeout": "Таймаут handshake (сек)",
|
"cascade_opt_handshake_timeout": "Таймаут handshake (сек)",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"nav_servers": "服务器",
|
"nav_servers": "服务器",
|
||||||
"nav_users": "用户列表",
|
"nav_users": "用户列表",
|
||||||
"nav_invites": "邀请链接",
|
"nav_invites": "邀请链接",
|
||||||
@@ -554,7 +554,7 @@
|
|||||||
"cascade_opt_wait_handshake": "Wait for exit handshake",
|
"cascade_opt_wait_handshake": "Wait for exit handshake",
|
||||||
"cascade_opt_wait_handshake_hint": "Do not mark cascade active if the exit peer never answers.",
|
"cascade_opt_wait_handshake_hint": "Do not mark cascade active if the exit peer never answers.",
|
||||||
"cascade_verify_egress": "Verify egress IP",
|
"cascade_verify_egress": "Verify egress IP",
|
||||||
"cascade_verify_egress_hint": "After handshake, curl ifconfig.me via the cascade Address; fail and roll back if empty.",
|
"cascade_verify_egress_hint": "After handshake, verify policy route from awg0 IP via cascade, then curl public-IP services. Soft-warn if curl fails but route is OK.",
|
||||||
"cascade_opt_keep_dns": "Keep DNS from exit config",
|
"cascade_opt_keep_dns": "Keep DNS from exit config",
|
||||||
"cascade_opt_keep_dns_hint": "Usually off — the client sets DNS itself.",
|
"cascade_opt_keep_dns_hint": "Usually off — the client sets DNS itself.",
|
||||||
"cascade_opt_handshake_timeout": "Handshake timeout (sec)",
|
"cascade_opt_handshake_timeout": "Handshake timeout (sec)",
|
||||||
|
|||||||
Reference in New Issue
Block a user