docs(step1): add wireguard server/client scripts + reset guide

This commit is contained in:
u1
2026-02-07 10:51:38 +01:00
parent 49132b8414
commit f44e7e7f8f
4 changed files with 629 additions and 1 deletions

View File

@@ -0,0 +1,116 @@
# Step 001A: WireGuard tools (server/client) + instrukcje reset
Ten step dodaje dwa skrypty pomocnicze do zarzadzania WireGuard:
- server: `scripts/wireguard/wg_serverctl.py`
- client: `scripts/wireguard/wg_client.py`
Skrypty trzymaja stan na dysku, zgodnie z konwencja:
- server keys: `/etc/wireguard/server.key`, `/etc/wireguard/server.pub`
- clients: `/etc/wireguard/clients/<name>.key|.pub|.ip|.conf`
- generated server config: `/etc/wireguard/wg0.conf`
Wymagania: `wireguard-tools` (czyli `wg`, `wg-quick`) + `systemd`.
## Instalacja (server)
Na bare metalu:
```bash
apt-get update
apt-get install -y wireguard
```
Z repo (na hoście) zainstaluj skrypt jako root-only:
```bash
install -m 700 scripts/wireguard/wg_serverctl.py /usr/local/sbin/wg_serverctl.py
```
## Instalacja (client)
Na kliencie (laptop/VPS):
```bash
apt-get update
apt-get install -y wireguard
install -m 700 scripts/wireguard/wg_client.py /usr/local/sbin/wg_client.py
```
## Typowy flow (server)
```bash
# 1) wygeneruj klucze serwera
wg_serverctl.py gen-server --force
# 2) wygeneruj klucze klienta (trzymane na serwerze w /etc/wireguard/clients)
wg_serverctl.py gen-client client1
# 3) dodaj klienta do puli, wygeneruj wg0.conf i zrestartuj wg-quick@wg0
wg_serverctl.py add client1
# 4) wyeksportuj client config (do przekazania na klienta)
wg_serverctl.py export client1
# 5) podglad
wg_serverctl.py list
wg_serverctl.py show client1
```
Plik konfiguracyjny klienta powstaje jako:
- `/etc/wireguard/clients/client1.conf`
## Typowy flow (client)
Skopiuj `client1.conf` na klienta, a potem:
```bash
wg_client.py install ./client1.conf
wg_client.py up
wg_client.py status
```
## Stop / disable / reset wg0 (Ubuntu 24.04)
Stop (zostaw config, tylko down):
```bash
systemctl stop wg-quick@wg0
```
Disable autostart:
```bash
systemctl disable wg-quick@wg0
```
Flush (pewny down + usuniecie interfejsu):
```bash
systemctl stop wg-quick@wg0
ip link del wg0 2>/dev/null || true
ip a show wg0 2>/dev/null || echo "wg0 not present"
```
Hard reset (usuniecie kluczy + konfiguracji):
```bash
systemctl stop wg-quick@wg0
systemctl disable wg-quick@wg0
ip link del wg0 2>/dev/null || true
rm -f /etc/wireguard/wg0.conf
rm -f /etc/wireguard/server.key /etc/wireguard/server.pub
rm -rf /etc/wireguard/clients
```
Reset peerow bez kasowania server key:
```bash
systemctl stop wg-quick@wg0
rm -f /etc/wireguard/clients/*.pub /etc/wireguard/clients/*.ip
systemctl start wg-quick@wg0
```

View File

@@ -194,5 +194,5 @@ EOF
## 5) Co dalej
- Instalacja i uruchomienie Agave: patrz `doc/etap-007-manual-baremetal-rpc-commands.md`.
- Narzedzia do generowania konfiguracji WireGuard (server/client) + reset wg0: patrz `doc/step-001-wireguard-tools.md`.
- Docelowo: bind RPC/WS do WG IP (zamiast `0.0.0.0`) i spójne, minimalne reguły firewall.

93
scripts/wireguard/wg_client.py Executable file
View File

@@ -0,0 +1,93 @@
#!/usr/bin/env python3
import argparse
import os
import shutil
import subprocess
import sys
from pathlib import Path
from datetime import datetime
WG_DIR = Path("/etc/wireguard")
WG0 = WG_DIR / "wg0.conf"
def require_root():
if os.geteuid() != 0:
print("ERROR: run as root (sudo).", file=sys.stderr)
sys.exit(1)
def run(cmd, check=True, capture=False):
return subprocess.run(
cmd,
check=check,
text=True,
stdout=subprocess.PIPE if capture else None,
stderr=subprocess.PIPE if capture else None,
)
def backup_if_exists(p: Path):
if p.exists():
ts = datetime.now().strftime("%Y-%m-%d_%H%M%S")
b = p.with_name(p.name + f".bak.{ts}")
shutil.copy2(p, b)
def cmd_install(src: str):
srcp = Path(src)
if not srcp.exists():
raise RuntimeError(f"Missing source config: {srcp}")
WG_DIR.mkdir(parents=True, exist_ok=True)
os.chmod(WG_DIR, 0o700)
backup_if_exists(WG0)
shutil.copy2(srcp, WG0)
os.chmod(WG0, 0o600)
os.chown(WG0, 0, 0)
print(f"OK: installed {WG0}")
def cmd_up():
run(["wg-quick", "up", "wg0"], check=True)
def cmd_down():
run(["wg-quick", "down", "wg0"], check=False)
def cmd_status():
run(["wg", "show"], check=False)
run(["ip", "a", "show", "wg0"], check=False)
def main():
require_root()
p = argparse.ArgumentParser(prog="wg_client.py")
sub = p.add_subparsers(dest="cmd", required=True)
i = sub.add_parser("install")
i.add_argument("config", help="Path to clientX.conf from server export")
sub.add_parser("up")
sub.add_parser("down")
sub.add_parser("status")
args = p.parse_args()
try:
if args.cmd == "install":
cmd_install(args.config)
elif args.cmd == "up":
cmd_up()
elif args.cmd == "down":
cmd_down()
elif args.cmd == "status":
cmd_status()
except Exception as e:
print(f"ERROR: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()

419
scripts/wireguard/wg_serverctl.py Executable file
View File

@@ -0,0 +1,419 @@
#!/usr/bin/env python3
import argparse
import os
import re
import shutil
import subprocess
import sys
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Optional, List
WG_KEY_RE = re.compile(r"^[A-Za-z0-9+/]{43}=$")
@dataclass
class Cfg:
wg_dir: Path = Path("/etc/wireguard")
clients_dir: Path = Path("/etc/wireguard/clients")
server_key: Path = Path("/etc/wireguard/server.key")
server_pub: Path = Path("/etc/wireguard/server.pub")
wg0_conf: Path = Path("/etc/wireguard/wg0.conf")
wg_addr: str = "10.66.66.1/24"
wg_port: int = 51820
keepalive: int = 25
client_net_prefix: str = "10.66.66."
client_ip_start: int = 2
client_ip_end: int = 254
def require_root():
if os.geteuid() != 0:
print("ERROR: run as root (sudo).", file=sys.stderr)
sys.exit(1)
def run(
cmd: List[str],
capture: bool = False,
check: bool = True,
input_text: Optional[str] = None,
) -> subprocess.CompletedProcess:
return subprocess.run(
cmd,
text=True,
input=input_text,
stdout=subprocess.PIPE if capture else None,
stderr=subprocess.PIPE if capture else None,
check=check,
)
def ensure_dirs(cfg: Cfg):
cfg.wg_dir.mkdir(parents=True, exist_ok=True)
cfg.clients_dir.mkdir(parents=True, exist_ok=True)
os.chmod(cfg.wg_dir, 0o700)
os.chmod(cfg.clients_dir, 0o700)
def read_first_nonempty_line(p: Path) -> str:
if not p.exists():
return ""
for line in p.read_text(encoding="utf-8", errors="ignore").splitlines():
line = line.strip().replace("\r", "")
if line:
return line
return ""
def validate_key(k: str, what: str):
if not WG_KEY_RE.match(k):
raise ValueError(f"{what} has invalid WireGuard key format: {k!r}")
def chmod600_root(p: Path):
os.chmod(p, 0o600)
os.chown(p, 0, 0)
def backup_if_exists(p: Path):
if p.exists():
ts = datetime.now().strftime("%Y-%m-%d_%H%M%S")
b = p.with_name(p.name + f".bak.{ts}")
shutil.copy2(p, b)
def gen_priv_key_to(path: Path):
priv = run(["wg", "genkey"], capture=True).stdout.strip()
validate_key(priv, "generated private key")
path.write_text(priv + "\n", encoding="utf-8")
chmod600_root(path)
def pubkey_from_priv(priv: str) -> str:
validate_key(priv, "private key")
pub = run(["wg", "pubkey"], capture=True, input_text=priv + "\n").stdout.strip()
validate_key(pub, "generated public key")
return pub
def detect_public_ip() -> str:
# Works on Ubuntu/Debian in typical setups
out = run(
[
"bash",
"-lc",
"ip route get 1.1.1.1 | awk '{for (i=1;i<=NF;i++) if ($i==\"src\") {print $(i+1); exit}}'",
],
capture=True,
).stdout.strip()
if not out:
raise RuntimeError("Could not detect server public IP.")
return out
def restart_wg(unit: str = "wg-quick@wg0"):
run(["systemctl", "restart", unit], capture=False, check=True)
# optional status (non-fatal)
try:
run(["systemctl", "--no-pager", "-l", "status", unit], capture=False, check=False)
except Exception:
pass
def list_clients(cfg: Cfg) -> List[str]:
pubs = sorted(cfg.clients_dir.glob("*.pub"))
return [p.stem for p in pubs]
def used_octets(cfg: Cfg) -> set:
s = set()
for f in cfg.clients_dir.glob("*.ip"):
v = read_first_nonempty_line(f)
if v.isdigit():
s.add(int(v))
return s
def alloc_octet(cfg: Cfg) -> int:
used = used_octets(cfg)
for i in range(cfg.client_ip_start, cfg.client_ip_end + 1):
if i not in used:
return i
raise RuntimeError("No free IPs left in pool.")
def gen_server(cfg: Cfg, force: bool):
ensure_dirs(cfg)
if not force and (cfg.server_key.exists() or cfg.server_pub.exists()):
raise RuntimeError("Server key already exists. Use --force to overwrite.")
gen_priv_key_to(cfg.server_key)
priv = read_first_nonempty_line(cfg.server_key)
pub = pubkey_from_priv(priv)
cfg.server_pub.write_text(pub + "\n", encoding="utf-8")
chmod600_root(cfg.server_pub)
print("OK: generated server keys:")
print(f" - {cfg.server_key} (PRIVATE)")
print(f" - {cfg.server_pub} (PUBLIC)")
print("Server public key:")
print(pub)
def gen_client(cfg: Cfg, name: str, force: bool):
ensure_dirs(cfg)
keyf = cfg.clients_dir / f"{name}.key"
pubf = cfg.clients_dir / f"{name}.pub"
if not force and (keyf.exists() or pubf.exists()):
raise RuntimeError(f"Client {name} exists. Use --force to overwrite.")
gen_priv_key_to(keyf)
priv = read_first_nonempty_line(keyf)
pub = pubkey_from_priv(priv)
pubf.write_text(pub + "\n", encoding="utf-8")
chmod600_root(pubf)
print("OK: generated client keys:")
print(f" - {keyf} (PRIVATE)")
print(f" - {pubf} (PUBLIC)")
print("Client public key:")
print(pub)
def generate_wg0_conf(cfg: Cfg):
ensure_dirs(cfg)
if not cfg.server_key.exists():
raise RuntimeError("Missing server.key. Run gen-server first.")
server_priv = read_first_nonempty_line(cfg.server_key)
validate_key(server_priv, "server private key")
backup_if_exists(cfg.wg0_conf)
lines: List[str] = []
lines += [
"[Interface]",
f"Address = {cfg.wg_addr}",
f"ListenPort = {cfg.wg_port}",
f"PrivateKey = {server_priv}",
]
for name in sorted(list_clients(cfg)):
pubf = cfg.clients_dir / f"{name}.pub"
ipf = cfg.clients_dir / f"{name}.ip"
if not ipf.exists():
raise RuntimeError(f"Client {name} has no .ip file (run add {name}).")
pub = read_first_nonempty_line(pubf)
validate_key(pub, f"client {name} pubkey")
octet = read_first_nonempty_line(ipf)
if not octet.isdigit():
raise RuntimeError(f"Invalid ip octet in {ipf}: {octet!r}")
o = int(octet)
if o < 2 or o > 254:
raise RuntimeError(f"Invalid ip octet in {ipf}: {o}")
lines += [
"",
"[Peer]",
f"# {name}",
f"PublicKey = {pub}",
f"AllowedIPs = {cfg.client_net_prefix}{o}/32",
f"PersistentKeepalive = {cfg.keepalive}",
]
cfg.wg0_conf.write_text("\n".join(lines) + "\n", encoding="utf-8")
chmod600_root(cfg.wg0_conf)
print(f"OK: generated {cfg.wg0_conf}")
def add_client(cfg: Cfg, name: str, pubkey: Optional[str], ip: Optional[str], no_restart: bool):
ensure_dirs(cfg)
pubf = cfg.clients_dir / f"{name}.pub"
ipf = cfg.clients_dir / f"{name}.ip"
# resolve pubkey
if pubkey:
pubkey = pubkey.strip()
validate_key(pubkey, "provided client public key")
pubf.write_text(pubkey + "\n", encoding="utf-8")
chmod600_root(pubf)
else:
if not pubf.exists():
raise RuntimeError(f"Missing {pubf}. Run gen-client {name} or provide --pubkey.")
pubkey = read_first_nonempty_line(pubf)
validate_key(pubkey, f"client {name} pubkey")
# resolve ip octet
if ip is None:
octet = alloc_octet(cfg)
else:
ip = ip.strip()
if ip.startswith(cfg.client_net_prefix):
ip = ip[len(cfg.client_net_prefix) :]
if not ip.isdigit():
raise RuntimeError("--ip must be an octet (2..254) or full IP like 10.66.66.X")
octet = int(ip)
if octet < 2 or octet > 254:
raise RuntimeError("--ip must be 2..254")
if octet in used_octets(cfg):
raise RuntimeError(f"IP octet {octet} already used")
ipf.write_text(f"{octet}\n", encoding="utf-8")
chmod600_root(ipf)
print(f"OK: added peer {name} -> {cfg.client_net_prefix}{octet}/32")
generate_wg0_conf(cfg)
if not no_restart:
restart_wg()
def del_client(cfg: Cfg, name: str, no_restart: bool):
ensure_dirs(cfg)
pubf = cfg.clients_dir / f"{name}.pub"
ipf = cfg.clients_dir / f"{name}.ip"
# keep .key on purpose
if pubf.exists():
pubf.unlink()
if ipf.exists():
ipf.unlink()
print(f"OK: removed peer {name} (pub/ip). Private key kept if existed.")
generate_wg0_conf(cfg)
if not no_restart:
restart_wg()
def export_client_conf(cfg: Cfg, name: str, endpoint: Optional[str]):
ensure_dirs(cfg)
keyf = cfg.clients_dir / f"{name}.key"
ipf = cfg.clients_dir / f"{name}.ip"
if not keyf.exists():
raise RuntimeError(f"Missing {keyf}. Run gen-client {name} on server or create key on client.")
if not ipf.exists():
raise RuntimeError(f"Missing {ipf}. Run add {name} first.")
if not cfg.server_pub.exists():
raise RuntimeError("Missing server.pub. Run gen-server first.")
client_priv = read_first_nonempty_line(keyf)
validate_key(client_priv, f"client {name} private key")
server_pub = read_first_nonempty_line(cfg.server_pub)
validate_key(server_pub, "server public key")
octet = int(read_first_nonempty_line(ipf))
if endpoint is None:
endpoint = f"{detect_public_ip()}:{cfg.wg_port}"
out = cfg.clients_dir / f"{name}.conf"
content = "\n".join(
[
"[Interface]",
f"Address = {cfg.client_net_prefix}{octet}/32",
f"PrivateKey = {client_priv}",
"",
"[Peer]",
f"PublicKey = {server_pub}",
f"Endpoint = {endpoint}",
# Default: only reach the server's WG IP via tunnel.
"AllowedIPs = 10.66.66.1/32",
f"PersistentKeepalive = {cfg.keepalive}",
"",
]
)
out.write_text(content, encoding="utf-8")
chmod600_root(out)
print(f"OK: wrote {out}")
def cmd_list(cfg: Cfg):
ensure_dirs(cfg)
names = list_clients(cfg)
if not names:
print("(no clients)")
return
for n in names:
ipf = cfg.clients_dir / f"{n}.ip"
octet = read_first_nonempty_line(ipf) if ipf.exists() else "?"
print(f"{n}\t{cfg.client_net_prefix}{octet}/32")
def cmd_show(cfg: Cfg, name: str):
ensure_dirs(cfg)
pubf = cfg.clients_dir / f"{name}.pub"
ipf = cfg.clients_dir / f"{name}.ip"
keyf = cfg.clients_dir / f"{name}.key"
print(f"Name: {name}")
if ipf.exists():
print(f"IP: {cfg.client_net_prefix}{read_first_nonempty_line(ipf)}/32")
else:
print("IP: (not assigned)")
if pubf.exists():
print(f"PUB: {read_first_nonempty_line(pubf)}")
else:
print("PUB: (missing)")
print(f"KEY: {'exists' if keyf.exists() else 'missing'} ({keyf})")
def main():
require_root()
cfg = Cfg()
p = argparse.ArgumentParser(prog="wg_serverctl.py")
sub = p.add_subparsers(dest="cmd", required=True)
s = sub.add_parser("gen-server")
s.add_argument("--force", action="store_true")
g = sub.add_parser("gen-client")
g.add_argument("name")
g.add_argument("--force", action="store_true")
a = sub.add_parser("add")
a.add_argument("name")
a.add_argument("--pubkey", help="Client public key (base64, ends with =)")
a.add_argument("--ip", help="IP octet (2..254) or full 10.66.66.X")
a.add_argument("--no-restart", action="store_true")
d = sub.add_parser("del")
d.add_argument("name")
d.add_argument("--no-restart", action="store_true")
ap = sub.add_parser("apply")
ap.add_argument("--no-restart", action="store_true")
sub.add_parser("list")
sh = sub.add_parser("show")
sh.add_argument("name")
ex = sub.add_parser("export")
ex.add_argument("name")
ex.add_argument("--endpoint", help="Override endpoint ip:port (default: auto-detect public ip)")
args = p.parse_args()
try:
if args.cmd == "gen-server":
gen_server(cfg, args.force)
elif args.cmd == "gen-client":
gen_client(cfg, args.name, args.force)
elif args.cmd == "add":
add_client(cfg, args.name, args.pubkey, args.ip, args.no_restart)
elif args.cmd == "del":
del_client(cfg, args.name, args.no_restart)
elif args.cmd == "apply":
generate_wg0_conf(cfg)
if not args.no_restart:
restart_wg()
elif args.cmd == "list":
cmd_list(cfg)
elif args.cmd == "show":
cmd_show(cfg, args.name)
elif args.cmd == "export":
export_client_conf(cfg, args.name, args.endpoint)
else:
p.print_help()
except Exception as e:
print(f"ERROR: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()