Files
Your Name 66aefd6161 fix(generators): call PKG_INSTALL without '$' under 'set -u'
In bash with 'set -u', an undefined variable triggers an unbound-variable
error. Functions don't satisfy '$FUNCNAME' expansion under set -u, so
the previous fix that kept '$PKG_INSTALL pkg...' as the call syntax
broke every call site:

    docker.sh: line 53: PKG_INSTALL: unbound variable

Fix: drop the leading '$' in all 47 call sites across 6 generator
files. 'PKG_INSTALL pkg...' is a normal command/function lookup and
behaves identically under set -u or not.

Verified: web UI served docker.sh now has 'PKG_INSTALL' (no $);
'bash -n' passes; smoke run with stubbed PATH hits real line-53 call
site, mock apt-get receives correct args, exit 0, no unbound variable.
Sampled 6 other generators also syntax-OK with $PKG_INSTALL count 0.
2026-08-07 13:20:18 +08:00

271 lines
13 KiB
Python

"""
Network tools.
- Wireguard VPN
- OpenVPN
- Tailscale client
- DHCP server (isc-dhcp-server / kea)
- DNS forwarder (unbound / dnsmasq)
"""
from . import register, Generator, Field
from . import bash_header, quote, bool_str, yes
# ========================== WireGuard ==========================
class WireGuard(Generator):
id = "wireguard"
title = "WireGuard VPN"
category = "network"
icon = "🔒"
tags = ["wireguard", "vpn", "wg"]
description = "部署 WireGuard VPN server,可生成客户端配置。"
fields = [
Field("server_private_key", "Server Private Key", "password", default="",
help="留空将自动生成 (使用 wg genkey)。"),
Field("server_public_key", "Server Public Key", "text", default="",
help="留空将自动从 private key 派生。"),
Field("listen_port", "Listen 端口", "number", default="51820", min_=1, max_=65535),
Field("interface", "网卡接口", "text", default="eth0"),
Field("vpn_subnet", "VPN 子网 CIDR", "text", default="10.0.0.0/24"),
Field("server_vpn_ip", "Server VPN IP", "text", default="10.0.0.1/24"),
Field("peers", "客户端 Peer 列表", "textarea", default="client1\nclient2",
help="每行一个 client name,会为每个生成密钥对。"),
Field("peer_dns", "客户端 DNS", "text", default="1.1.1.1, 8.8.8.8"),
]
def render(self, p):
sk = p.get("server_private_key", "").strip()
pk = p.get("server_public_key", "").strip()
port = str(p.get("listen_port", "51820"))
iface = p.get("interface", "eth0")
subnet = p.get("vpn_subnet", "10.0.0.0/24")
sip = p.get("server_vpn_ip", "10.0.0.1/24")
peers = [p_.strip() for p_ in p.get("peers", "").splitlines() if p_.strip()]
dns = p.get("peer_dns", "1.1.1.1, 8.8.8.8")
out = [bash_header(self.title)]
out.append('log "Installing WireGuard..."')
out.append('PKG_INSTALL wireguard qrencode')
out.append('')
# server keypair
if not sk:
out.append('wg genkey | tee /etc/wireguard/server.key | wg pubkey > /etc/wireguard/server.pub')
else:
out.append('mkdir -p /etc/wireguard && echo "' + sk + '" > /etc/wireguard/server.key')
out.append('wg pubkey < /etc/wireguard/server.key > /etc/wireguard/server.pub')
if pk:
out.append('echo "' + pk + '" > /etc/wireguard/server.pub')
out.append('chmod 600 /etc/wireguard/server.key')
out.append('')
# generate peer configs
out.append('mkdir -p /etc/wireguard/clients')
for i, name in enumerate(peers, start=2):
ip = f'10.0.0.{i}/32'
out.append('wg genkey | tee /etc/wireguard/clients/' + name + '.key | wg pubkey > /etc/wireguard/clients/' + name + '.pub')
out.append('chmod 600 /etc/wireguard/clients/' + name + '.key')
# server conf
out.append('cat > /etc/wireguard/wg0.conf <<WG_EOF\n'
'[Interface]\n'
'Address = ' + sip + '\n'
'ListenPort = ' + port + '\n'
'PrivateKey = ' + (sk if sk else '$(cat /etc/wireguard/server.key)') + '\n'
'PostUp = iptables -A FORWARD -i %i -j ACCEPT; iptables -A FORWARD -o %i -j ACCEPT; iptables -t nat -A POSTROUTING -o ' + iface + ' -j MASQUERADE\n'
'PostDown = iptables -D FORWARD -i %i -j ACCEPT; iptables -D FORWARD -o %i -j ACCEPT; iptables -t nat -D POSTROUTING -o ' + iface + ' -j MASQUERADE\n\n')
for i, name in enumerate(peers, start=2):
out.append('[Peer]\n'
f'# {name}\n'
f'PublicKey = $(cat /etc/wireguard/clients/{name}.pub)\n'
f'AllowedIPs = 10.0.0.{i}/32\n\n')
out.append('WG_EOF')
out.append('chmod 600 /etc/wireguard/wg0.conf')
out.append('echo "net.ipv4.ip_forward=1" >> /etc/sysctl.d/99-wireguard.conf && sysctl -p /etc/sysctl.d/99-wireguard.conf')
out.append('systemctl enable --now wg-quick@wg0')
# build client confs
for i, name in enumerate(peers, start=2):
ip = f'10.0.0.{i}/32'
out.append('SERVER_PUB=$(cat /etc/wireguard/server.pub)')
out.append('CLIENT_PRIV=$(cat /etc/wireguard/clients/' + name + '.key)')
out.append('cat > /etc/wireguard/clients/' + name + '.conf <<CLI_EOF\n'
'[Interface]\n'
'Address = ' + ip + '\n'
'DNS = ' + dns + '\n'
'PrivateKey = ${CLIENT_PRIV}\n\n'
'[Peer]\n'
'PublicKey = ${SERVER_PUB}\n'
'Endpoint = YOUR_SERVER_IP:' + port + '\n'
'AllowedIPs = 0.0.0.0/0, ::/0\n'
'PersistentKeepalive = 25\n'
'CLI_EOF')
out.append('qrencode -t ansiutf8 < /etc/wireguard/clients/' + name + '.conf || true')
return "\n".join(out) + "\n"
# ========================== Tailscale ==========================
class Tailscale(Generator):
id = "tailscale"
title = "Tailscale 客户端"
category = "network"
icon = "🦀"
tags = ["tailscale", "vpn", "mesh"]
description = "安装并启动 Tailscale,可指定自定义控制服务器。"
fields = [
Field("login_server", "控制服务器", "text", default="https://controlplane.tailscale.com",
help="Headscale 自建时填入,如 https://hs.example.com。"),
Field("accept_routes", "接受子网路由", "checkbox", default="no"),
Field("advertise_exit", "作为 Exit Node", "checkbox", default="no"),
Field("auth_key", "Auth Key (Headscale/preauth)", "password", default="",
help="留空则用 sudo tailscale up 手动登录。"),
Field("hostname", "Hostname", "text", default="$(hostname)"),
]
def render(self, p):
ls = p.get("login_server", "https://controlplane.tailscale.com")
ar = bool_str(p.get("accept_routes"))
ex = bool_str(p.get("advertise_exit"))
ak = p.get("auth_key", "").strip()
hn = p.get("hostname", "$(hostname)")
out = [bash_header(self.title)]
out.append('log "Installing Tailscale..."')
out.append('curl -fsSL https://tailscale.com/install.sh | sh')
out.append('systemctl enable --now tailscaled')
out.append('sleep 2')
flags = []
if ls:
flags.append('--login-server=' + ls)
if ar:
flags.append('--accept-routes')
if ex:
flags.append('--advertise-exit-node')
if hn:
flags.append('--hostname=' + hn)
flag_str = " ".join(flags)
if ak:
out.append('tailscale up ' + flag_str + ' --authkey=' + ak)
else:
out.append('tailscale up ' + flag_str)
out.append('log "Visit the URL printed above to authenticate."')
out.append('tailscale status')
return "\n".join(out) + "\n"
# ========================== DNSMASQ ==========================
class Dnsmasq(Generator):
id = "dnsmasq"
title = "Dnsmasq (DHCP + DNS 缓存)"
category = "network"
icon = "📡"
tags = ["dns", "dhcp", "dnsmasq"]
description = "轻量级 DNS 转发 + DHCP server。"
fields = [
Field("enable_dhcp", "启用 DHCP", "checkbox", default="yes"),
Field("dhcp_range_start", "DHCP 起始 IP", "text", default="192.168.1.100"),
Field("dhcp_range_end", "DHCP 结束 IP", "text", default="192.168.1.200"),
Field("dhcp_lease", "租约时间", "text", default="12h"),
Field("dhcp_gateway", "DHCP 网关", "text", default="192.168.1.1"),
Field("dhcp_dns", "DHCP 推送的 DNS", "text", default="1.1.1.1, 8.8.8.8"),
Field("upstream_dns", "上游 DNS", "textarea", default="1.1.1.1\n8.8.8.8"),
Field("local_domain", "本地域名", "text", default="lan"),
Field("listen_address", "Listen IP", "text", default="0.0.0.0"),
]
def render(self, p):
ed = bool_str(p.get("enable_dhcp", True))
rstart = p.get("dhcp_range_start", "192.168.1.100")
rend = p.get("dhcp_range_end", "192.168.1.200")
lease = p.get("dhcp_lease", "12h")
gw = p.get("dhcp_gateway", "192.168.1.1")
dns = p.get("dhcp_dns", "1.1.1.1, 8.8.8.8")
up = [u.strip() for u in p.get("upstream_dns", "").splitlines() if u.strip()]
domain = p.get("local_domain", "lan")
listen = p.get("listen_address", "0.0.0.0")
out = [bash_header(self.title)]
out.append('log "Installing dnsmasq..."')
out.append('PKG_INSTALL dnsmasq')
out.append('cp /etc/dnsmasq.conf /etc/dnsmasq.conf.bak.$(date +%s) || true')
cfg = []
cfg.append('listen-address=' + listen)
cfg.append('bind-interfaces')
cfg.append('domain=' + domain)
cfg.append('local=/' + domain + '/')
cfg.append('cache-size=1000')
for u in up:
cfg.append('server=' + u)
if ed:
cfg.append('dhcp-range=' + rstart + ',' + rend + ',' + lease)
cfg.append('dhcp-option=3,' + gw)
cfg.append('dhcp-option=6,' + dns)
out.append('cat > /etc/dnsmasq.conf <<CFG_EOF\n' + '\n'.join(cfg) + '\nCFG_EOF')
out.append('systemctl enable --now dnsmasq')
out.append('ss -ltnup | grep :53 || warn "port 53 not listening yet"')
return "\n".join(out) + "\n"
# ========================== OpenVPN ==========================
class OpenVPN(Generator):
id = "openvpn"
title = "OpenVPN 简化部署",
category = "network"
icon = "🛡️"
tags = ["openvpn", "vpn"]
description = "安装 openvpn + easy-rsa,生成服务端 CA/密钥/客户端配置。"
warnings = ["生产环境请先生成正式 PKI,本脚本为快速原型。"]
fields = [
Field("proto", "协议", "select", default="udp", options=["udp", "tcp"]),
Field("port", "端口", "number", default="1194", min_=1, max_=65535),
Field("subnet", "VPN 子网", "text", default="10.8.0.0/24"),
Field("dns_servers", "推送的 DNS", "text", default="1.1.1.1, 8.8.8.8"),
Field("cipher", "加密算法", "select", default="AES-256-GCM",
options=["AES-256-GCM", "AES-256-CBC", "CHACHA20-POLY1305"]),
Field("clients", "生成客户端数", "number", default="1", min_=1, max_=20),
]
def render(self, p):
proto = p.get("proto", "udp")
port = str(p.get("port", "1194"))
subnet = p.get("subnet", "10.8.0.0/24")
dns = p.get("dns_servers", "1.1.1.1, 8.8.8.8")
cipher = p.get("cipher", "AES-256-GCM")
n = int(p.get("clients", "1"))
out = [bash_header(self.title)]
out.append('log "Installing openvpn + easy-rsa..."')
out.append('PKG_INSTALL openvpn easy-rsa')
out.append('make-cadir /etc/openvpn/easy-rsa')
out.append('cd /etc/openvpn/easy-rsa')
out.append('./easyrsa init-pki')
out.append('./easyrsa build-ca nopass')
out.append('./easyrsa gen-req server nopass')
out.append('./easyrsa sign-req server server <<< "yes"')
out.append('./easyrsa gen-dh')
out.append('openvpn --genkey secret /etc/openvpn/ta.key')
out.append('cp pki/ca.crt pki/private/server.key pki/issued/server.crt pki/dh.pem /etc/openvpn/')
out.append('cp /etc/openvpn/easy-rsa/ta.key /etc/openvpn/')
out.append('')
out.append('cat > /etc/openvpn/server.conf <<CFG_EOF\n'
'port ' + port + '\n'
'proto ' + proto + '\n'
'dev tun\n'
'ca ca.crt\ncert server.crt\nkey server.key\ndh dh.pem\n'
'auth SHA256\ncipher ' + cipher + '\n'
'tls-auth ta.key 0\n'
'topology subnet\n'
'server ' + subnet + '\n'
'push "redirect-gateway def1 bypass-dhcp"\n'
'push "dhcp-option DNS ' + dns.split(',')[0].strip() + '"\n'
'keepalive 10 120\npersist-key\npersist-tun\nstatus openvpn-status.log\nverb 3\n'
'CFG_EOF')
out.append('systemctl enable --now openvpn@server')
out.append('echo "net.ipv4.ip_forward=1" >> /etc/sysctl.d/99-openvpn.conf && sysctl -p')
for i in range(1, n + 1):
cn = f'client{i}'
out.append('cd /etc/openvpn/easy-rsa && ./easyrsa gen-req ' + cn + ' nopass')
out.append('cd /etc/openvpn/easy-rsa && ./easyrsa sign-req client ' + cn + ' <<< "yes"')
out.append('cat > /etc/openvpn/' + cn + '.ovpn <<OVPN_EOF\n'
'client\ndev tun\nproto ' + proto + '\n'
'remote YOUR_SERVER_IP ' + port + '\n'
'resolv-retry infinite\nnobind\n'
'persist-key\npersist-tun\nremote-cert-tls server\nauth SHA256\ncipher ' + cipher + '\n'
'verb 3\n<ca>\n$(cat /etc/openvpn/ca.crt)\n</ca>\n<cert>\n$(cat /etc/openvpn/easy-rsa/pki/issued/' + cn + '.crt)\n</cert>\n<key>\n$(cat /etc/openvpn/easy-rsa/pki/private/' + cn + '.key)\n</key>\n<tls-auth>\n$(cat /etc/openvpn/ta.key)\n</tls-auth>\nkey-direction 1\nOVPN_EOF')
return "\n".join(out) + "\n"
for _g in [WireGuard, Tailscale, Dnsmasq, OpenVPN]:
register(_g())