Compare commits

..

5 Commits

Author SHA1 Message Date
infosave baa3ef5f76 feat(awg2): add server obfuscation downgrade script for older router clients
Some router AmneziaWG implementations only support "classic" AmneziaWG 1.0
obfuscation and reject AWG 2.0 configs (range H1-H4, S3/S4, I1-I5 magic
packets) that the Amnezia app and newer servers use — the config imports/
handshakes fine on phones but fails on the router.

scripts/awg_downgrade_obfuscation.sh converts a server's wg0/awg0.conf to a
router-compatible classic set: keeps Jc/Jmin/Jmax/S1/S2, collapses H1-H4
ranges to single values, drops S3/S4 and I1-I5, then reloads the interface
(auto-detecting awg/wg). After running it, regenerate client configs in the
panel (which mirrors the server's params) and re-import on all devices.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 22:17:47 +03:00
infosave 24a6cb276f fix(awg2): clamp TCP MSS on server so traffic actually flows (issue #50)
Final piece of "connects but no traffic": with the reduced client MTU (1280)
the upload direction fits, but full-size download packets (web pages, TLS
responses) still exceeded the AmneziaWG tunnel and were dropped — handshake
and small packets worked, browsing stalled. Confirmed on a live server: the
client's encrypted packets reached the server but large return packets never
made it back. Adding a server-side TCP MSS clamp to 1240 (= 1280 - 40) made
real traffic flow (verified: 1.6 MiB transferred, FORWARD/MASQUERADE counters
incrementing).

- VpnClient::addClientToServer(): after applying the peer, idempotently ensure
  net.ipv4.ip_forward=1 and a `mangle FORWARD ... TCPMSS --set-mss 1240` rule
  (-C then -A). Re-applied on every client creation, so it survives container
  restarts/reinstalls and covers adopted native Amnezia containers.
- migrations/072 + 064: add the same MSS clamp to the awg2 install script
  PostUp (and remove it in PostDown) for panel-installed servers.

Verified end-to-end: removing the rule and creating a client via the panel
re-adds it automatically; the live phone client now browses normally.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 15:33:11 +03:00
infosave 222953049d fix(awg2): restore client MTU=1280 (connects but no traffic)
Issue #50: AWG2 clients connect (handshake OK) but no traffic flows. The
awg2 client output_template lost its "MTU = 1280" line when migration 064
rewrote it (migration 058 had it). With no explicit MTU the client defaults
to 1420, which is too large once AmneziaWG obfuscation overhead (Jc junk
packets, S1/S2 padding) is added on top of WireGuard's: small packets (the
handshake) pass, larger packets (TLS, web pages) are dropped — tunnel
"connected" but unusable. 1280 is the official Amnezia app default.

- migrations/071: add "MTU = 1280" to the awg2 output_template (existing DBs).
- migrations/064: add the MTU line to the template source (fresh installs).
- buildClientConfig(): emit MTU = 1280 in the fallback path too.

Server-side NAT/forwarding/ip_forward were verified correct on a live server,
so this is purely a client-config regression. Generated client config now
contains "MTU = 1280" and mirrors the server's obfuscation params exactly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 14:23:41 +03:00
infosave d771af866c fix(awg2): mirror server obfuscation params in client config (fixes no-connect)
Clients were created successfully but could not connect: the AmneziaWG
handshake requires the client's obfuscation params (Jc/Jmin/Jmax/S1-S4/
H1-H4/I1-I5) to EXACTLY match the server's, and they did not.

Two causes, both fixed:
- syncServerKeysFromContainer() read params from `wg show` first and only
  accepted H1-H4 in the AWG-2.0 "a-b" range format, dropping the single-value
  H1-H4 used by classic AmneziaWG servers (the official Amnezia image). It
  also skipped the complete wg0.conf read once `wg show` returned partial
  data. Now the server config file (awg0.conf/wg0.conf) is the primary,
  format-agnostic source; `wg show` is a fallback that accepts single values
  and ranges.
- create() filled any param missing from the (incomplete) sync with awg2
  defaults — injecting H1-H4 ranges, S3/S4 and I1 onto a classic server that
  uses none of them. Now client params mirror the server's synced params
  verbatim; defaults are used only when nothing was synced at all. Empty
  AWG lines (params the server does not use) are stripped from the rendered
  config so the client carries exactly the server's set.

Verified end-to-end on a live server: a real amneziawg-go client built from
the generated config completes the handshake
("latest handshake: 14 seconds ago", bidirectional transfer) — params
(jc/s1/s2/h1-h4) match the server exactly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 13:41:24 +03:00
infosave 0d72579edd fix(awg2): auto-detect wg/awg tool inside container (real cause of issue #50)
Live testing against an AmneziaWG 2.0 server revealed the actual root cause
of "Failed to generate client keys": the official Amnezia container image
ships the userspace tool only as `wg` (a patched AmneziaWG binary) and has
NO `awg` binary, while the panel hardcoded `awg` for AWG2. `awg genkey` then
failed with "sh: awg: not found". (amneziawg-go ships `awg` with `wg`
symlinked, so both names work there — but the Amnezia image does not.)

- generateClientKeys(): detect the tool inside the container
  (`command -v awg || command -v wg`) instead of hardcoding `awg`.
- addClientToServer(): resolve the tool via new resolveWgTool() helper so
  `wg set` / `wg-quick up` (peer apply) also work on the Amnezia image.
- executeServerCommand(): delegate to VpnServer::executeCommand so SSH key
  auth + docker sudo auto-detection apply to all 19 call sites (it was
  password-only before).

Verified end-to-end on a live AWG2 server: pre-fix code fails with
"Failed to generate client keys: sh: awg: not found"; fixed code creates
the client, generates keys, and the peer appears in `wg show wg0`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 12:13:15 +03:00
5 changed files with 227 additions and 49 deletions
+116 -46
View File
@@ -199,12 +199,22 @@ class VpnClient
$defaultAwgParams = self::getAwgParamDefaults($slug);
// Add AWG parameters (use UPPERCASE keys internal logic)
foreach (array_keys($defaultAwgParams) as $key) {
if (isset($cleanAwgParams[$key])) {
$vars[$key] = $cleanAwgParams[$key];
// AmneziaWG requires the client's obfuscation params to EXACTLY match
// the server's. When the server's params are known (synced from its
// config), mirror them verbatim and leave anything the server does not
// use empty (those lines are stripped from the rendered config below).
// Only fall back to protocol defaults when NO params were synced at all
// (best effort) — injecting AWG 2.0 defaults (H1-H4 ranges, S3/S4, I1)
// onto a classic AmneziaWG server silently breaks the handshake.
// (issue #50)
$awgKeys = ['JC', 'JMIN', 'JMAX', 'S1', 'S2', 'S3', 'S4', 'H1', 'H2', 'H3', 'H4', 'I1', 'I2', 'I3', 'I4', 'I5'];
if (!empty($cleanAwgParams)) {
foreach ($awgKeys as $key) {
$vars[$key] = array_key_exists($key, $cleanAwgParams) ? $cleanAwgParams[$key] : '';
}
} else {
$vars[$key] = $defaultAwgParams[$key];
foreach ($awgKeys as $key) {
$vars[$key] = $defaultAwgParams[$key] ?? '';
}
}
@@ -243,6 +253,18 @@ class VpnClient
);
}
// Drop AWG obfuscation lines that ended up empty (params the server
// does not use, e.g. S3/S4/I1-I5 on a classic AmneziaWG server). An
// empty "S3 =" / "I1 =" line is invalid, and any param the client
// carries but the server lacks breaks the handshake. (issue #50)
if ($isWireguard) {
$config = preg_replace(
'/^[ \t]*(?:Jc|Jmin|Jmax|S1|S2|S3|S4|H1|H2|H3|H4|I1|I2|I3|I4|I5)[ \t]*=[ \t]*\r?\n/mi',
'',
$config
);
}
self::addClientToServer($serverData, $keys['public'], $clientIP);
$qrCode = self::generateQRCode($config, $slug);
$priv = $keys['private'];
@@ -797,19 +819,20 @@ class VpnClient
private static function generateClientKeys(array $serverData, string $clientName): array
{
$containerName = $serverData['container_name'];
$protocolSlug = (string) ($serverData['install_protocol'] ?? '');
$isAwg2 = (stripos($containerName, 'awg2') !== false || $protocolSlug === 'awg2');
// The amneziawg-go image ships `awg` and a `wg -> awg` symlink, so either
// tool works there. Use `awg` for AWG2 and `wg` otherwise.
$wgTool = $isAwg2 ? 'awg' : 'wg';
// Inner script that runs inside the container shell. Generates a private
// key, derives the public key and prints them separated by a "---" marker.
$script = sprintf(
'set -e; umask 077; priv=$(%s genkey | tr -d "\r\n"); [ -n "$priv" ] || { echo empty_private_key; exit 1; }; pub=$(printf "%%s\n" "$priv" | %s pubkey | tr -d "\r\n"); [ -n "$pub" ] || { echo empty_public_key; exit 1; }; printf "%%s\n---\n%%s\n" "$priv" "$pub"',
$wgTool,
$wgTool
);
// Detect the WireGuard userspace tool INSIDE the container instead of
// hardcoding it. Different AWG2 images expose it under different names:
// the official Amnezia image ships only `wg` (a patched AmneziaWG binary),
// while amneziawg-go provides `awg` (with `wg` symlinked to it). Hardcoding
// `awg` made `awg genkey` fail with "awg: not found" on the Amnezia image,
// which is the actual cause of the "Failed to generate client keys" error
// in issue #50. Prefer `awg`, fall back to `wg`.
$script = 'set -e; umask 077; '
. 'tool=$(command -v awg 2>/dev/null || command -v wg 2>/dev/null); '
. '[ -n "$tool" ] || { echo no_wg_tool; exit 1; }; '
. 'priv=$("$tool" genkey | tr -d "\r\n"); [ -n "$priv" ] || { echo empty_private_key; exit 1; }; '
. 'pub=$(printf "%s\n" "$priv" | "$tool" pubkey | tr -d "\r\n"); [ -n "$pub" ] || { echo empty_public_key; exit 1; }; '
. 'printf "%s\n---\n%s\n" "$priv" "$pub"';
$cmd = sprintf(
'docker exec -i %s sh -lc %s',
@@ -1063,37 +1086,32 @@ class VpnClient
$dns = '1.1.1.1, 1.0.0.1';
}
// Extract AWG parameters.
// NOTE: amnezia-awg does not expose these via `wg show` in many builds,
// so we primarily read them from /opt/amnezia/awg/wg0.conf.
// Extract AWG obfuscation parameters. The server config file is the
// source of truth: it holds the EXACT params awg-quick applied, in the
// server's own format (single-value H1-H4 for classic AmneziaWG, or
// "a-b" ranges for AWG 2.0) and only the params the server actually
// uses. Client configs must mirror these exactly or the AmneziaWG
// handshake silently fails (issue #50). Inside the container the
// config always lives under /opt/amnezia/awg/.
$awgParams = [];
foreach (['/opt/amnezia/awg/awg0.conf', '/opt/amnezia/awg/wg0.conf', '/etc/wireguard/wg0.conf'] as $confPath) {
$awgParams = self::extractAwgParamsFromWg0Conf($server, $containerName, $confPath);
if (!empty($awgParams)) {
break;
}
}
// Legacy attempt: some builds print jc/jmin/... in `wg show` output.
// Fallback: some builds only expose params via `wg show` (no readable
// config file). Accept both single integers and "a-b" ranges so
// classic AmneziaWG H1-H4 values are not dropped.
if (empty($awgParams)) {
$wgShowCmd = "docker exec $containerName wg show wg0 2>/dev/null";
$wgOutput = (string) $server->executeCommand($wgShowCmd, true);
$paramNames = ['jc', 'jmin', 'jmax', 's1', 's2', 's3', 's4', 'h1', 'h2', 'h3', 'h4', 'i1', 'i2', 'i3', 'i4', 'i5'];
foreach ($paramNames as $param) {
// For H1-H4 parameters, expect format like "1443912531-1981073285" (two values with dash)
// For other parameters, expect single integer value
if (in_array($param, ['h1', 'h2', 'h3', 'h4'], true)) {
if (preg_match('/^\s*' . preg_quote($param, '/') . ':\s*(\d+-\d+)/mi', $wgOutput, $matches)) {
$awgParams[strtoupper($param)] = $matches[1];
foreach (['jc', 'jmin', 'jmax', 's1', 's2', 's3', 's4', 'h1', 'h2', 'h3', 'h4', 'i1', 'i2', 'i3', 'i4', 'i5'] as $param) {
if (preg_match('/^\s*' . preg_quote($param, '/') . ':\s*(\d+(?:-\d+)?)/mi', $wgOutput, $matches)) {
$val = $matches[1];
$awgParams[strtoupper($param)] = ctype_digit($val) ? (int) $val : $val;
}
} else {
if (preg_match('/^\s*' . preg_quote($param, '/') . ':\s*(\d+)/mi', $wgOutput, $matches)) {
$awgParams[strtoupper($param)] = (int) $matches[1];
}
}
}
// Primary source: wg0.conf
if (empty($awgParams)) {
$awgParams = self::extractAwgParamsFromWg0Conf($server, $containerName, $primaryConfigDir . '/wg0.conf');
if (empty($awgParams) && $primaryConfigDir !== '/opt/amnezia/awg') {
$awgParams = self::extractAwgParamsFromWg0Conf($server, $containerName, '/opt/amnezia/awg/wg0.conf');
}
if (empty($awgParams)) {
$awgParams = self::extractAwgParamsFromWg0Conf($server, $containerName, '/etc/wireguard/wg0.conf');
}
}
@@ -1164,6 +1182,10 @@ class VpnClient
$config .= "Address = {$clientIP}/32\n";
$config .= "DNS = 1.1.1.1, 1.0.0.1\n";
$config .= "PrivateKey = {$privateKey}\n";
// AmneziaWG obfuscation adds per-packet overhead; without a reduced MTU
// the tunnel connects but large packets are dropped (no usable traffic).
// 1280 matches the official Amnezia app default. (issue #50)
$config .= "MTU = 1280\n";
// Add AWG parameters (in the order used by Amnezia app)
// For awg2 include I1-I5, S3, S4; for regular awg only H1-H4, Jc, Jmin, Jmax, S1, S2
@@ -1223,9 +1245,12 @@ class VpnClient
throw new Exception('Refusing to add client with empty public key');
}
// Determine correct tool names (awg for AWG2, wg for standard)
$wgTool = $isAwg2 ? 'awg' : 'wg';
$wgQuickTool = $isAwg2 ? 'awg-quick' : 'wg-quick';
// Determine correct tool names by probing the container. The official
// Amnezia image exposes only `wg`/`wg-quick`; amneziawg-go provides
// `awg`/`awg-quick`. Hardcoding `awg` broke peer setup on the Amnezia
// image (issue #50). Prefer `awg`, fall back to `wg`.
$wgTool = $isAwg2 ? self::resolveWgTool($serverData, $containerName) : 'wg';
$wgQuickTool = $wgTool . '-quick';
// 1. Create temp file for PSK (to avoid shell escaping issues)
$pskFile = '/tmp/' . bin2hex(random_bytes(8)) . '.psk';
@@ -1265,6 +1290,22 @@ class VpnClient
// Without this, the interface uses standard WireGuard without Jc/S1/S2/H1-H4
$cmd5 = sprintf("docker exec -i %s sh -c 'ip link del %s 2>/dev/null || true; %s up %s/%s 2>&1'", $containerName, $ifaceName, $wgQuickTool, $configDir, $configFile);
self::executeServerCommand($serverData, $cmd5, true);
// 7. CRITICAL: Clamp TCP MSS so download-direction packets fit the reduced
// AmneziaWG tunnel MTU (clients use MTU 1280 -> MSS 1240). Without this the
// handshake succeeds and small packets flow, but large packets (web pages,
// TLS responses) exceed the tunnel and are silently dropped — the classic
// "connected but no traffic" symptom (issue #50). Idempotent (-C then -A),
// and ip_forward is ensured for good measure. Re-applied on every client
// creation so it survives container restarts/reinstalls.
$mssRule = "-p tcp --tcp-flags SYN,RST SYN -j TCPMSS --set-mss 1240";
$cmd6 = sprintf(
"docker exec -i %s sh -c 'sysctl -w net.ipv4.ip_forward=1 >/dev/null 2>&1 || true; iptables -t mangle -C FORWARD %s 2>/dev/null || iptables -t mangle -A FORWARD %s'",
$containerName,
$mssRule,
$mssRule
);
self::executeServerCommand($serverData, $cmd6, true);
}
/**
@@ -1302,11 +1343,40 @@ class VpnClient
self::executeServerCommand($serverData, $updateCmd, true);
}
/**
* Resolve the WireGuard userspace tool name available inside a container.
* Returns 'awg' when present, otherwise 'wg'. Used so AWG2 works on both the
* official Amnezia image (ships `wg`) and amneziawg-go (ships `awg`).
*/
private static function resolveWgTool(array $serverData, string $containerName): string
{
$probe = sprintf(
"docker exec -i %s sh -lc 'command -v awg >/dev/null 2>&1 && echo awg || echo wg'",
escapeshellarg($containerName)
);
$tool = trim(self::executeServerCommand($serverData, $probe, true));
return $tool === 'awg' ? 'awg' : 'wg';
}
/**
* Execute command on server
*/
private static function executeServerCommand(array $serverData, string $command, bool $sudo = false): string
{
// Delegate to VpnServer::executeCommand so SSH key authentication, docker
// sudo auto-detection and retry logic are shared with the rest of the
// panel. The previous inline implementation was password-only and failed
// on key-based servers (contributing to issue #50).
if (!empty($serverData['id'])) {
try {
$server = new VpnServer((int) $serverData['id']);
return $server->executeCommand($command, $sudo ? null : false);
} catch (Exception $e) {
error_log('executeServerCommand: delegate failed, using legacy path: ' . $e->getMessage());
}
}
// Legacy fallback (no server id in $serverData): password-only SSH.
$needsSudo = $sudo && strtolower((string) ($serverData['username'] ?? '')) !== 'root';
$baseCommand = $command;
@@ -5,6 +5,7 @@ SET output_template = '[Interface]
Address = {{client_ip}}/32
DNS = {{dns_servers}}
PrivateKey = {{private_key}}
MTU = 1280
Jc = {{Jc}}
Jmin = {{Jmin}}
Jmax = {{Jmax}}
@@ -144,8 +145,8 @@ echo "H2 = $H2_VAL"
echo "H3 = $H3_VAL"
echo "H4 = $H4_VAL"
echo "I1 = $I1_VAL"
echo "PostUp = iptables -A FORWARD -i %i -j ACCEPT; iptables -A FORWARD -o %i -j ACCEPT; iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE"
echo "PostDown = iptables -D FORWARD -i %i -j ACCEPT; iptables -D FORWARD -o %i -j ACCEPT; iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE"
echo "PostUp = iptables -A FORWARD -i %i -j ACCEPT; iptables -A FORWARD -o %i -j ACCEPT; iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE; iptables -t mangle -A FORWARD -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --set-mss 1240"
echo "PostDown = iptables -D FORWARD -i %i -j ACCEPT; iptables -D FORWARD -o %i -j ACCEPT; iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE; iptables -t mangle -D FORWARD -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --set-mss 1240"
} > /opt/amnezia/awg2/awg0.conf
echo "$PRIVATE_KEY" > /opt/amnezia/awg2/wireguard_server_private_key.key
+23
View File
@@ -0,0 +1,23 @@
-- =====================================================================
-- Migration 071: Restore client MTU for AmneziaWG 2.0 (awg2)
--
-- Issue #50: clients connect (handshake succeeds) but no traffic flows.
-- Root cause: the awg2 client output_template lost its "MTU = 1280" line
-- when migration 064 rewrote it (migration 058 had it). With no explicit
-- MTU the client defaults to 1420, which is too large once AmneziaWG
-- obfuscation overhead (Jc junk packets, S1/S2 padding) is added on top of
-- WireGuard's own overhead: the handshake (small packets) succeeds, but
-- larger packets (TLS, web pages) exceed the path and are dropped — so the
-- tunnel is "connected" yet carries no usable traffic. 1280 is the value the
-- official Amnezia app uses for AmneziaWG clients.
-- =====================================================================
UPDATE protocols
SET output_template = REPLACE(
output_template,
'PrivateKey = {{private_key}}\n',
'PrivateKey = {{private_key}}\nMTU = 1280\n'
)
WHERE slug = 'awg2'
AND output_template LIKE '%PrivateKey = {{private_key}}%'
AND output_template NOT LIKE '%MTU%';
+25
View File
@@ -0,0 +1,25 @@
-- =====================================================================
-- Migration 072: TCP MSS clamping for AmneziaWG 2.0 (awg2)
--
-- Issue #50: clients connect (handshake OK) but no traffic flows. With the
-- reduced tunnel MTU (clients use 1280), TCP must also negotiate a small
-- enough MSS, otherwise full-size download packets (web pages, TLS responses)
-- exceed the tunnel and are dropped — the handshake and small packets work,
-- but browsing stalls. Clamping MSS to 1240 (1280 - 40) on the server's
-- FORWARD path fixes the download direction.
--
-- This appends the clamp to the awg2 install script's PostUp so panel-installed
-- servers get it on every interface bring-up. (Adopted native containers are
-- handled at runtime by VpnClient::addClientToServer(), which applies the same
-- rule idempotently on each client creation.)
-- =====================================================================
UPDATE protocols
SET install_script = REPLACE(
install_script,
'iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE',
'iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE; iptables -t mangle -A FORWARD -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --set-mss 1240'
)
WHERE slug = 'awg2'
AND install_script LIKE '%-A POSTROUTING -o eth0 -j MASQUERADE%'
AND install_script NOT LIKE '%TCPMSS%';
+59
View File
@@ -0,0 +1,59 @@
#!/bin/sh
# =====================================================================
# Downgrade an AmneziaWG server's obfuscation to a "classic" (AmneziaWG 1.0)
# set that older router AmneziaWG implementations accept.
#
# Keeps : Jc, Jmin, Jmax, S1, S2 (widely supported AWG 1.0 junk params)
# Converts: H1-H4 from "a-b" ranges -> single value "a"
# Drops : S3, S4 and I1-I5 (AWG 1.5/2.0-only padding & magic packets)
#
# After running this you MUST regenerate every client config in the panel
# (create new clients / re-export) and re-import them on phones too — the old
# AWG 2.0 client configs no longer match the server and will stop connecting.
#
# Usage (on the VPS host that runs the container):
# sh awg_downgrade_obfuscation.sh [container_name]
# Defaults to container "amnezia-awg2".
# =====================================================================
set -e
CONTAINER="${1:-amnezia-awg2}"
if ! docker inspect "$CONTAINER" >/dev/null 2>&1; then
echo "Container '$CONTAINER' not found. Pass the correct name as the 1st arg." >&2
exit 1
fi
# Locate the config inside the container (awg0.conf for AWG2, wg0.conf legacy).
CONF=""
for f in /opt/amnezia/awg/awg0.conf /opt/amnezia/awg/wg0.conf /etc/wireguard/wg0.conf; do
if docker exec "$CONTAINER" test -f "$f" 2>/dev/null; then CONF="$f"; break; fi
done
[ -n "$CONF" ] || { echo "WireGuard config not found inside $CONTAINER" >&2; exit 1; }
echo "Container : $CONTAINER"
echo "Config : $CONF"
echo "Before:"
docker exec "$CONTAINER" sh -c "grep -E '^(Jc|Jmin|Jmax|S[0-9]|H[0-9]|I[0-9])[[:space:]]*=' '$CONF' || true"
# Rewrite the [Interface] obfuscation params, then reload the interface using
# whichever tool the image provides (awg on amneziawg-go, wg on the Amnezia image).
docker exec "$CONTAINER" sh -c '
set -e
CONF="'"$CONF"'"
IFACE="$(basename "$CONF" .conf)"
cp "$CONF" "${CONF}.bak" 2>/dev/null || true
# H1-H4: "a-b" -> "a"
sed -i -E "s/^([[:space:]]*H[1-4][[:space:]]*=[[:space:]]*[0-9]+)-[0-9]+/\1/" "$CONF"
# Drop S3, S4 and I1-I5 lines entirely
sed -i -E "/^[[:space:]]*(S3|S4|I[1-5])[[:space:]]*=/d" "$CONF"
QUICK="$(command -v awg-quick || command -v wg-quick)"
"$QUICK" down "$CONF" 2>/dev/null || "$QUICK" down "$IFACE" 2>/dev/null || true
"$QUICK" up "$CONF"
'
echo "After:"
docker exec "$CONTAINER" sh -c "grep -E '^(Jc|Jmin|Jmax|S[0-9]|H[0-9]|I[0-9])[[:space:]]*=' '$CONF' || true"
echo "Done. Now regenerate all client configs in the panel and re-import them."