Initialize Clash Web source and deployment documentation

This commit is contained in:
lhc committed 2026-10-05 17:32:00 +08:00
commit 8e0fa00a73
66 files changed
+8532

No files matched your search

+106
View File
@@ -0,0 +1,106 @@
#!/usr/bin/env python3
"""Explicit, reversible network preparation on the service VM; never manages DHCP."""
import argparse
import ipaddress
import json
import os
import re
import subprocess
from pathlib import Path
parser = argparse.ArgumentParser()
parser.add_argument('action', choices=['check', 'apply', 'rollback'])
parser.add_argument('--target-vm', action='store_true')
parser.add_argument('--interface')
parser.add_argument('--lan-cidr')
args = parser.parse_args()
backup = Path('/var/lib/clashweb-network/backup.json')
sysctl_file = Path('/etc/sysctl.d/90-clashweb.conf')
nft_file = Path('/etc/clashweb/network.nft')
service_file = Path('/etc/systemd/system/clashweb-network.service')
def run(command, **kwargs):
return subprocess.run(command, check=True, text=True, capture_output=True, **kwargs).stdout
def sysctl_get(key):
return run(['sysctl', '-n', key]).strip()
if args.action == 'check':
print(run(['ip', '-br', '-4', 'address']))
print(run(['ip', '-4', 'route']))
print('IPv4 forwarding:', sysctl_get('net.ipv4.ip_forward'))
print('/dev/net/tun:', Path('/dev/net/tun').exists())
print('No DHCP changes are performed. Use manual client gateway/DNS configuration.')
raise SystemExit(0)
if os.geteuid() != 0 or not args.target_vm:
raise SystemExit('Mutations require root and --target-vm on the PVE service VM.')
if args.action == 'rollback':
if not backup.exists():
raise SystemExit('No Clash Web network backup found.')
data = json.loads(backup.read_text())
# Also recover an interrupted apply that never finished installing the unit.
subprocess.run(['systemctl', 'disable', '--now', 'clashweb-network.service'], capture_output=True)
subprocess.run(['nft', 'delete', 'table', 'ip', 'clashweb_nat'], capture_output=True)
for path in (sysctl_file, nft_file, service_file):
path.unlink(missing_ok=True)
# ip_forward changes reset some IPv4 settings, so restore it first.
for key, value in data['sysctls'].items():
run(['sysctl', '-w', key + '=' + value])
run(['systemctl', 'daemon-reload'])
backup.unlink()
print('Own network rules removed; saved sysctls restored. Restore client gateway/DNS to the modem.')
raise SystemExit(0)
if not args.interface or not re.fullmatch(r'[a-zA-Z0-9_.:-]{1,15}', args.interface):
raise SystemExit('Provide a valid --interface.')
if not args.lan_cidr:
raise SystemExit('Provide --lan-cidr, for example 192.168.1.0/24.')
network = ipaddress.ip_network(args.lan_cidr, strict=True)
if network.version != 4 or not network.is_private or not 8 <= network.prefixlen <= 30:
raise SystemExit('Use a private IPv4 subnet with prefix 8–30.')
addresses = json.loads(run(['ip', '-j', '-4', 'address', 'show', 'dev', args.interface]))
if not any(ipaddress.ip_address(a['local']) in network for i in addresses for a in i['addr_info']):
raise SystemExit('The interface does not have an IPv4 address inside this subnet.')
if backup.exists():
raise SystemExit('Network preparation already applied. Roll back before changing interface/subnet.')
for path in (sysctl_file, nft_file, service_file):
if path.exists():
raise SystemExit(f'Refusing to overwrite existing {path}.')
if subprocess.run(['nft', 'list', 'table', 'ip', 'clashweb_nat'], check=False, capture_output=True).returncode == 0:
parser.error('Table clashweb_nat already exists.')
keys = ['net.ipv4.ip_forward', 'net.ipv4.conf.all.send_redirects', 'net.ipv4.conf.default.send_redirects', f'net.ipv4.conf.{args.interface}.send_redirects', 'net.ipv4.conf.all.rp_filter', f'net.ipv4.conf.{args.interface}.rp_filter']
backup.parent.mkdir(parents=True, exist_ok=True)
backup.write_text(json.dumps({'sysctls': {key: sysctl_get(key) for key in keys}}, indent=2))
os.chmod(backup, 0o600)
values = {key: ('1' if key.endswith('ip_forward') else '2' if key.endswith('rp_filter') else '0') for key in keys}
sysctl_file.write_text('\n'.join(f'{key}={value}' for key, value in values.items()) + '\n')
nft_file.parent.mkdir(parents=True, exist_ok=True)
nft_file.write_text(f'''table ip clashweb_nat {{
chain postrouting {{
type nat hook postrouting priority srcnat; policy accept;
ip saddr {network} ip daddr != {network} oifname "{args.interface}" masquerade
}}
}}
''')
run(['nft', '-c', '-f', str(nft_file)])
service_file.write_text('''[Unit]
Description=Clash Web single-arm gateway NAT
After=network-online.target
Before=clashweb-mihomo.service
Wants=network-online.target
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/sbin/nft -f /etc/clashweb/network.nft
ExecStop=/usr/sbin/nft delete table ip clashweb_nat
[Install]
WantedBy=multi-user.target
''')
for key, value in values.items():
run(['sysctl', '-w', key + '=' + value])
run(['systemctl', 'daemon-reload'])
run(['systemctl', 'enable', '--now', 'clashweb-network.service'])
print('VM forwarding and own NAT table configured. TUN is still controlled by the UI.')
print('Do not enable DHCP on this VM. Configure a test client manually.')