""" Sopel plugin: WireGuard VPN peer management !vpn add - add peer, returns client config URL !vpn remove - remove peer !vpn list - list all peers with IPs !vpn pubkey - print server public key """ import json, os, subprocess, tempfile import requests from sopel import plugin PEERS = "/etc/wireguard/peers.json" PUBKEY = "/etc/wireguard/server.pub" FHOST = "http://127.0.0.1:5000" SUBNET = "10.0.0." def _owner(bot, trigger): return trigger.nick == bot.settings.core.owner def _peers(): if os.path.exists(PEERS): with open(PEERS) as f: return json.load(f) return {} def _save(peers): with open(PEERS, "w") as f: json.dump(peers, f, indent=2) def _next_ip(peers): used = {p["ip"] for p in peers.values()} for i in range(2, 255): ip = f"{SUBNET}{i}" if ip not in used: return ip return None def _server_pub(): with open(PUBKEY) as f: return f.read().strip() def _wg(*args): subprocess.run(["/usr/bin/wg"] + list(args), check=True) def _rewrite_conf(peers): conf_path = "/etc/wireguard/wg0.conf" with open(conf_path) as f: lines = f.readlines() new_lines, skip = [], False for line in lines: if line.strip() == "[Peer]": skip = True elif line.strip().startswith("[") and line.strip() != "[Peer]": skip = False if not skip: new_lines.append(line) conf = "".join(new_lines).rstrip() + "\n" for name, p in peers.items(): conf += f"\n[Peer]\n# {name}\nPublicKey = {p['pubkey']}\nAllowedIPs = {p['ip']}/32\n" with open(conf_path, "w") as f: f.write(conf) def _upload(content): with tempfile.NamedTemporaryFile(mode="w", suffix=".conf", delete=False) as f: f.write(content) name = f.name try: with open(name, "rb") as fh: r = requests.post(FHOST, files={"file": fh}, timeout=10) r.raise_for_status() return r.text.strip() finally: os.unlink(name) @plugin.command("vpn") def cmd_vpn(bot, trigger): if not _owner(bot, trigger): return args = (trigger.group(2) or "").split() if not args: bot.say("usage: !vpn ") return action = args[0] peers = _peers() if action == "pubkey": bot.say(_server_pub()) elif action == "list": if not peers: bot.say("no peers") return for name, p in peers.items(): bot.say(f"{name}: {p['ip']} {p['pubkey'][:24]}...") elif action == "add": if len(args) < 3: bot.say("usage: !vpn add ") return name, pubkey = args[1], args[2] if name in peers: bot.say(f"{name}: already exists") return ip = _next_ip(peers) if not ip: bot.say("no IPs available") return peers[name] = {"ip": ip, "pubkey": pubkey} _save(peers) try: _wg("set", "wg0", "peer", pubkey, "allowed-ips", f"{ip}/32") _rewrite_conf(peers) except subprocess.CalledProcessError as e: bot.say(f"error: {e}") return pub = _server_pub() cfg = ( f"[Interface]\n" f"PrivateKey = \n" f"Address = {ip}/32\n" f"DNS = 1.1.1.1\n\n" f"[Peer]\n" f"PublicKey = {pub}\n" f"Endpoint = wk.fo:51820\n" f"AllowedIPs = 0.0.0.0/0\n" f"PersistentKeepalive = 25\n" ) try: url = _upload(cfg) bot.say(f"{name}: {ip} — {url}") except Exception: bot.say(f"{name}: {ip} — upload failed. server pubkey: {pub}") elif action == "remove": if len(args) < 2: bot.say("usage: !vpn remove ") return name = args[1] if name not in peers: bot.say(f"{name}: not found") return pubkey = peers[name]["pubkey"] try: _wg("set", "wg0", "peer", pubkey, "remove") except subprocess.CalledProcessError as e: bot.say(f"warning: live removal failed: {e}") del peers[name] _save(peers) try: _rewrite_conf(peers) except Exception: pass bot.say(f"{name}: removed") else: bot.say("usage: !vpn ")