wgusr 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- wgusr/__init__.py +3 -0
- wgusr/__main__.py +6 -0
- wgusr/activate.py +162 -0
- wgusr/cli.py +395 -0
- wgusr/client.py +200 -0
- wgusr/config.py +85 -0
- wgusr/ip_pool.py +94 -0
- wgusr/keys.py +94 -0
- wgusr/manager.py +445 -0
- wgusr/sync_io.py +65 -0
- wgusr/term.py +149 -0
- wgusr/validators.py +227 -0
- wgusr-1.0.0.dist-info/METADATA +440 -0
- wgusr-1.0.0.dist-info/RECORD +18 -0
- wgusr-1.0.0.dist-info/WHEEL +5 -0
- wgusr-1.0.0.dist-info/entry_points.txt +2 -0
- wgusr-1.0.0.dist-info/licenses/LICENSE +21 -0
- wgusr-1.0.0.dist-info/top_level.txt +1 -0
wgusr/__init__.py
ADDED
wgusr/__main__.py
ADDED
wgusr/activate.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Activate a WireGuard interface using wg syncconf or wg set.
|
|
2
|
+
|
|
3
|
+
Requires: root, wg, wg-quick, and ip in PATH.
|
|
4
|
+
|
|
5
|
+
wg set / wg syncconf only update WireGuard's cryptokey routing table.
|
|
6
|
+
Kernel routes for each peer's AllowedIPs are added by wg-quick at
|
|
7
|
+
`up` time, not by wg. This module adds those routes after syncing
|
|
8
|
+
so that a newly-added peer becomes immediately reachable without
|
|
9
|
+
disrupting existing tunnels.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import shutil
|
|
15
|
+
import subprocess
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _check_wg_binary() -> None:
|
|
19
|
+
"""Check that wg binary is available."""
|
|
20
|
+
if not shutil.which("wg"):
|
|
21
|
+
raise RuntimeError("'wg' binary not found in PATH; install wireguard-tools")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _check_ip_binary() -> None:
|
|
25
|
+
"""Check that ip (iproute2) binary is available."""
|
|
26
|
+
if not shutil.which("ip"):
|
|
27
|
+
raise RuntimeError("'ip' binary not found in PATH; install iproute2")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _interface_exists(interface: str) -> bool:
|
|
31
|
+
"""Return True when the WireGuard interface is present in the kernel."""
|
|
32
|
+
r = subprocess.run(
|
|
33
|
+
["ip", "-br", "link", "show", "dev", interface],
|
|
34
|
+
capture_output=True,
|
|
35
|
+
text=True,
|
|
36
|
+
)
|
|
37
|
+
return r.returncode == 0
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _add_route_for_allowed_ip(interface: str, cidr: str) -> None:
|
|
41
|
+
"""Add a kernel route for one AllowedIP, idempotently.
|
|
42
|
+
|
|
43
|
+
Runs ``ip route show <cidr> dev <interface>`` first; skips the add if
|
|
44
|
+
the route is already present. Between the check and the add another
|
|
45
|
+
process may have installed the same route — that raises "File exists", which
|
|
46
|
+
is caught and silently ignored (race lost, route is present). Any other
|
|
47
|
+
error is propagated.
|
|
48
|
+
"""
|
|
49
|
+
check = subprocess.run(
|
|
50
|
+
["ip", "route", "show", cidr, "dev", interface],
|
|
51
|
+
capture_output=True,
|
|
52
|
+
text=True,
|
|
53
|
+
)
|
|
54
|
+
if check.stdout.strip():
|
|
55
|
+
return # already present
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
subprocess.run(
|
|
59
|
+
["ip", "route", "add", cidr, "dev", interface],
|
|
60
|
+
capture_output=True,
|
|
61
|
+
text=True,
|
|
62
|
+
check=True,
|
|
63
|
+
)
|
|
64
|
+
except subprocess.CalledProcessError as exc:
|
|
65
|
+
if exc.returncode != 0 and "File exists" in (exc.stderr or ""):
|
|
66
|
+
return # race lost; route is present
|
|
67
|
+
raise
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def sync_interface(interface: str) -> None:
|
|
71
|
+
"""Apply the full wg0.conf to the live interface via wg syncconf.
|
|
72
|
+
|
|
73
|
+
After pushing the peer/cryptokey configuration to the kernel, enumerate all
|
|
74
|
+
live peers via ``wg show <iface> allowed-ips`` and add a kernel route for
|
|
75
|
+
each AllowedIP. This replicates what ``wg-quick up`` does at bring-up
|
|
76
|
+
time so that newly-added peers become immediately reachable without
|
|
77
|
+
tearing down existing tunnels.
|
|
78
|
+
"""
|
|
79
|
+
_check_wg_binary()
|
|
80
|
+
_check_ip_binary()
|
|
81
|
+
|
|
82
|
+
if not shutil.which("wg-quick"):
|
|
83
|
+
raise RuntimeError("'wg-quick' not found in PATH; install wireguard-tools")
|
|
84
|
+
|
|
85
|
+
# 1. Strip wg-quick options and push peer config to the kernel.
|
|
86
|
+
strip = subprocess.run(
|
|
87
|
+
["wg-quick", "strip", interface],
|
|
88
|
+
capture_output=True,
|
|
89
|
+
text=True,
|
|
90
|
+
check=True,
|
|
91
|
+
)
|
|
92
|
+
subprocess.run(
|
|
93
|
+
["wg", "syncconf", interface, "/dev/stdin"],
|
|
94
|
+
input=strip.stdout,
|
|
95
|
+
text=True,
|
|
96
|
+
check=True,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
# 2. Enumerate live peers and add kernel routes for their AllowedIPs.
|
|
100
|
+
# ``wg show <iface> allowed-ips`` emits one line per peer:
|
|
101
|
+
# "<pubkey>\t<cidr>[,<cidr>...]"
|
|
102
|
+
show = subprocess.run(
|
|
103
|
+
["wg", "show", interface, "allowed-ips"],
|
|
104
|
+
capture_output=True,
|
|
105
|
+
text=True,
|
|
106
|
+
check=True,
|
|
107
|
+
)
|
|
108
|
+
for line in show.stdout.splitlines():
|
|
109
|
+
if not line.strip():
|
|
110
|
+
continue
|
|
111
|
+
parts = line.split("\t")
|
|
112
|
+
if len(parts) < 2:
|
|
113
|
+
continue
|
|
114
|
+
for cidr in parts[1].split(","):
|
|
115
|
+
cidr = cidr.strip()
|
|
116
|
+
if cidr:
|
|
117
|
+
_add_route_for_allowed_ip(interface, cidr)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def sync_peer(
|
|
121
|
+
interface: str,
|
|
122
|
+
pubkey: str,
|
|
123
|
+
allowed_ips: str,
|
|
124
|
+
psk: str | None = None,
|
|
125
|
+
keepalive: int | None = None,
|
|
126
|
+
) -> None:
|
|
127
|
+
"""Apply a single peer to the live interface via wg set.
|
|
128
|
+
|
|
129
|
+
Uses ``wg set`` to update/add a single peer without touching others.
|
|
130
|
+
After updating the cryptokey routing, adds a kernel route for each
|
|
131
|
+
AllowedIP so the peer is immediately reachable.
|
|
132
|
+
|
|
133
|
+
The interface must already exist (e.g. brought up via ``wg-quick up``);
|
|
134
|
+
raises ``RuntimeError`` if it does not.
|
|
135
|
+
"""
|
|
136
|
+
_check_wg_binary()
|
|
137
|
+
_check_ip_binary()
|
|
138
|
+
|
|
139
|
+
if not _interface_exists(interface):
|
|
140
|
+
raise RuntimeError(
|
|
141
|
+
f"interface {interface!r} does not exist; "
|
|
142
|
+
f"run `wg-quick up {interface}` first to bring it up"
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
cmd = ["wg", "set", interface, "peer", pubkey, "allowed-ips", allowed_ips]
|
|
146
|
+
|
|
147
|
+
if psk:
|
|
148
|
+
cmd.extend(["preshared-key", "/dev/stdin"])
|
|
149
|
+
|
|
150
|
+
if keepalive is not None and keepalive > 0:
|
|
151
|
+
cmd.extend(["persistent-keepalive", str(keepalive)])
|
|
152
|
+
|
|
153
|
+
if psk:
|
|
154
|
+
subprocess.run(cmd, input=psk, text=True, check=True)
|
|
155
|
+
else:
|
|
156
|
+
subprocess.run(cmd, check=True)
|
|
157
|
+
|
|
158
|
+
# Add kernel route(s) for each comma-separated AllowedIP.
|
|
159
|
+
for cidr in allowed_ips.split(","):
|
|
160
|
+
cidr = cidr.strip()
|
|
161
|
+
if cidr:
|
|
162
|
+
_add_route_for_allowed_ip(interface, cidr)
|
wgusr/cli.py
ADDED
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
"""CLI entry point for wgusr.
|
|
2
|
+
|
|
3
|
+
wgusr [--wg-dir DIR] [--interface IF] [--clients-dir DIR]
|
|
4
|
+
{add,delete,list,view,clear,init,sync} ...
|
|
5
|
+
|
|
6
|
+
Pre-flight checks:
|
|
7
|
+
- Root requirement for --wg-dir under /etc/wireguard
|
|
8
|
+
- Process umask set to 0o077 before any file write
|
|
9
|
+
- wg and wg-quick availability checked before sync
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
import click
|
|
19
|
+
|
|
20
|
+
from wgusr import __version__
|
|
21
|
+
from wgusr.config import ServerInfo
|
|
22
|
+
from wgusr.keys import KeyPair, derive_public_key, generate_keypair
|
|
23
|
+
from wgusr.manager import (
|
|
24
|
+
add_user,
|
|
25
|
+
clear_all,
|
|
26
|
+
delete_user,
|
|
27
|
+
list_users,
|
|
28
|
+
sync_all_users,
|
|
29
|
+
sync_user,
|
|
30
|
+
view_user,
|
|
31
|
+
)
|
|
32
|
+
from wgusr.sync_io import atomic_write_wgconf
|
|
33
|
+
from wgusr.validators import (
|
|
34
|
+
IPV4,
|
|
35
|
+
KEEPALIVE,
|
|
36
|
+
PUBKEY,
|
|
37
|
+
USERNAME,
|
|
38
|
+
make_conf_path,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
DEFAULT_INTERFACE = "wg0"
|
|
42
|
+
DEFAULT_WG_DIR = "/etc/wireguard"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _make_server(wg_dir: str, interface: str, clients_dir: str | None) -> ServerInfo:
|
|
46
|
+
"""Load or initialise a ServerInfo."""
|
|
47
|
+
wg_dir = os.path.realpath(wg_dir)
|
|
48
|
+
os.makedirs(wg_dir, exist_ok=True)
|
|
49
|
+
|
|
50
|
+
conf_path = make_conf_path(wg_dir, interface)
|
|
51
|
+
if not os.path.exists(conf_path):
|
|
52
|
+
click.echo(
|
|
53
|
+
f"error: {conf_path} does not exist.\n"
|
|
54
|
+
f" Run 'wgusr init' to create a new interface, or check --wg-dir.",
|
|
55
|
+
err=True,
|
|
56
|
+
)
|
|
57
|
+
raise SystemExit(1)
|
|
58
|
+
|
|
59
|
+
server = ServerInfo.from_path(wg_dir, interface, clients_dir=clients_dir)
|
|
60
|
+
server.ensure_clients_dir()
|
|
61
|
+
|
|
62
|
+
pub = derive_public_key(server.private_key)
|
|
63
|
+
server.set_public_key(pub)
|
|
64
|
+
|
|
65
|
+
return server
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@click.group()
|
|
69
|
+
@click.version_option(version=__version__)
|
|
70
|
+
@click.option(
|
|
71
|
+
"--wg-dir",
|
|
72
|
+
default=DEFAULT_WG_DIR,
|
|
73
|
+
help=f"wireguard config directory (default: {DEFAULT_WG_DIR})",
|
|
74
|
+
)
|
|
75
|
+
@click.option(
|
|
76
|
+
"--interface", "-i",
|
|
77
|
+
default=DEFAULT_INTERFACE,
|
|
78
|
+
help=f"wireguard interface name (default: {DEFAULT_INTERFACE})",
|
|
79
|
+
)
|
|
80
|
+
@click.option(
|
|
81
|
+
"--clients-dir",
|
|
82
|
+
help="directory for client artifacts (default: <wg-dir>/clients)",
|
|
83
|
+
)
|
|
84
|
+
@click.pass_context
|
|
85
|
+
def cli(
|
|
86
|
+
ctx: click.Context,
|
|
87
|
+
wg_dir: str,
|
|
88
|
+
interface: str,
|
|
89
|
+
clients_dir: str | None,
|
|
90
|
+
) -> None:
|
|
91
|
+
ctx.ensure_object(dict)
|
|
92
|
+
ctx.obj["wg_dir"] = wg_dir
|
|
93
|
+
ctx.obj["interface"] = interface
|
|
94
|
+
ctx.obj["clients_dir"] = clients_dir
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@cli.command(
|
|
98
|
+
epilog=(
|
|
99
|
+
"Examples: "
|
|
100
|
+
"wgusr add alice vpn.example.com | "
|
|
101
|
+
"wgusr add bob 192.168.1.100 | "
|
|
102
|
+
"wgusr -i wg0 add carol vpn.example.com --ip 10.0.73.50\n"
|
|
103
|
+
"\n"
|
|
104
|
+
"endpoint_host is the server's public hostname or IP."
|
|
105
|
+
" The port is automatically added from your wg0.conf ListenPort."
|
|
106
|
+
),
|
|
107
|
+
)
|
|
108
|
+
@click.argument("user", type=USERNAME)
|
|
109
|
+
@click.argument("endpoint_host")
|
|
110
|
+
@click.option("--ip", type=IPV4, help="assign a specific VPN IP")
|
|
111
|
+
@click.option("--select-ip", is_flag=True, help="interactively select IP")
|
|
112
|
+
@click.option("--dns", type=IPV4, help="DNS server for client (default: server tunnel IP)")
|
|
113
|
+
@click.option("--keepalive", type=KEEPALIVE, default=None, help="persistent-keepalive seconds (0=off)")
|
|
114
|
+
@click.option("--no-psk", is_flag=True, help="skip preshared key (not recommended)")
|
|
115
|
+
@click.option("--existing-keys", is_flag=True, help="use existing private/public key pair")
|
|
116
|
+
@click.option("--private-key", "existing_private", help="private key (requires --existing-keys)")
|
|
117
|
+
@click.option("--public-key", "existing_public", help="public key (requires --existing-keys)")
|
|
118
|
+
@click.pass_context
|
|
119
|
+
def add(
|
|
120
|
+
ctx: click.Context,
|
|
121
|
+
user: str,
|
|
122
|
+
endpoint_host: str,
|
|
123
|
+
ip: str | None,
|
|
124
|
+
select_ip: bool,
|
|
125
|
+
dns: str | None,
|
|
126
|
+
keepalive: int | None,
|
|
127
|
+
no_psk: bool,
|
|
128
|
+
existing_keys: bool,
|
|
129
|
+
existing_private: str | None,
|
|
130
|
+
existing_public: str | None,
|
|
131
|
+
) -> None:
|
|
132
|
+
wg_dir = ctx.obj["wg_dir"]
|
|
133
|
+
interface = ctx.obj["interface"]
|
|
134
|
+
clients_dir = ctx.obj["clients_dir"]
|
|
135
|
+
|
|
136
|
+
os.umask(0o077)
|
|
137
|
+
|
|
138
|
+
server = _make_server(wg_dir, interface, clients_dir)
|
|
139
|
+
|
|
140
|
+
existing_kp: KeyPair | None = None
|
|
141
|
+
if existing_keys:
|
|
142
|
+
if not existing_private or not existing_public:
|
|
143
|
+
click.echo("error: --existing-keys requires --private-key and --public-key", err=True)
|
|
144
|
+
raise SystemExit(1)
|
|
145
|
+
existing_kp = KeyPair(private=existing_private, public=existing_public)
|
|
146
|
+
|
|
147
|
+
try:
|
|
148
|
+
add_user(
|
|
149
|
+
server=server,
|
|
150
|
+
user=user,
|
|
151
|
+
endpoint_host=endpoint_host,
|
|
152
|
+
vpn_ip=ip,
|
|
153
|
+
select_ip_flag=select_ip,
|
|
154
|
+
dns=dns,
|
|
155
|
+
keepalive=keepalive,
|
|
156
|
+
psk=not no_psk,
|
|
157
|
+
existing_keys=existing_kp,
|
|
158
|
+
)
|
|
159
|
+
except Exception as exc:
|
|
160
|
+
click.echo(f"error: add failed: {exc}", err=True)
|
|
161
|
+
raise SystemExit(1)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@cli.command()
|
|
165
|
+
@click.argument("user", required=False, type=USERNAME)
|
|
166
|
+
@click.option("--key", "pubkey", type=PUBKEY, help="delete by public key")
|
|
167
|
+
@click.option("--force", "-f", is_flag=True, help="confirm delete when only one source matches")
|
|
168
|
+
@click.pass_context
|
|
169
|
+
def delete(
|
|
170
|
+
ctx: click.Context,
|
|
171
|
+
user: str | None,
|
|
172
|
+
pubkey: str | None,
|
|
173
|
+
force: bool,
|
|
174
|
+
) -> None:
|
|
175
|
+
wg_dir = ctx.obj["wg_dir"]
|
|
176
|
+
interface = ctx.obj["interface"]
|
|
177
|
+
clients_dir = ctx.obj["clients_dir"]
|
|
178
|
+
|
|
179
|
+
os.umask(0o077)
|
|
180
|
+
|
|
181
|
+
server = _make_server(wg_dir, interface, clients_dir)
|
|
182
|
+
|
|
183
|
+
try:
|
|
184
|
+
delete_user(
|
|
185
|
+
server=server,
|
|
186
|
+
user=user or "",
|
|
187
|
+
pubkey=pubkey,
|
|
188
|
+
force=force,
|
|
189
|
+
)
|
|
190
|
+
except Exception as exc:
|
|
191
|
+
click.echo(f"error: delete failed: {exc}", err=True)
|
|
192
|
+
raise SystemExit(1)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@cli.command()
|
|
196
|
+
@click.pass_context
|
|
197
|
+
def list(ctx: click.Context) -> None:
|
|
198
|
+
wg_dir = ctx.obj["wg_dir"]
|
|
199
|
+
interface = ctx.obj["interface"]
|
|
200
|
+
clients_dir = ctx.obj["clients_dir"]
|
|
201
|
+
|
|
202
|
+
os.umask(0o077)
|
|
203
|
+
|
|
204
|
+
server = _make_server(wg_dir, interface, clients_dir)
|
|
205
|
+
|
|
206
|
+
try:
|
|
207
|
+
list_users(server)
|
|
208
|
+
except Exception as exc:
|
|
209
|
+
click.echo(f"error: list failed: {exc}", err=True)
|
|
210
|
+
raise SystemExit(1)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@cli.command()
|
|
214
|
+
@click.argument("user", type=USERNAME)
|
|
215
|
+
@click.pass_context
|
|
216
|
+
def view(ctx: click.Context, user: str) -> None:
|
|
217
|
+
wg_dir = ctx.obj["wg_dir"]
|
|
218
|
+
interface = ctx.obj["interface"]
|
|
219
|
+
clients_dir = ctx.obj["clients_dir"]
|
|
220
|
+
|
|
221
|
+
os.umask(0o077)
|
|
222
|
+
|
|
223
|
+
server = _make_server(wg_dir, interface, clients_dir)
|
|
224
|
+
|
|
225
|
+
try:
|
|
226
|
+
view_user(server, user)
|
|
227
|
+
except FileNotFoundError as exc:
|
|
228
|
+
click.echo(f"error: {exc}", err=True)
|
|
229
|
+
raise SystemExit(1)
|
|
230
|
+
except Exception as exc:
|
|
231
|
+
click.echo(f"error: view failed: {exc}", err=True)
|
|
232
|
+
raise SystemExit(1)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
@cli.command()
|
|
236
|
+
@click.option("--yes", "-y", is_flag=True, help="skip confirmation prompt")
|
|
237
|
+
@click.pass_context
|
|
238
|
+
def clear(ctx: click.Context, yes: bool) -> None:
|
|
239
|
+
wg_dir = ctx.obj["wg_dir"]
|
|
240
|
+
interface = ctx.obj["interface"]
|
|
241
|
+
clients_dir = ctx.obj["clients_dir"]
|
|
242
|
+
|
|
243
|
+
os.umask(0o077)
|
|
244
|
+
|
|
245
|
+
if not yes:
|
|
246
|
+
response = click.prompt("Deleting ALL users and configuration. Are you sure? [y/N] ").strip()
|
|
247
|
+
if response.lower() not in ("y", "yes"):
|
|
248
|
+
raise SystemExit(1)
|
|
249
|
+
|
|
250
|
+
server = _make_server(wg_dir, interface, clients_dir)
|
|
251
|
+
|
|
252
|
+
try:
|
|
253
|
+
clear_all(server)
|
|
254
|
+
except Exception as exc:
|
|
255
|
+
click.echo(f"error: clear failed: {exc}", err=True)
|
|
256
|
+
raise SystemExit(1)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
@cli.command()
|
|
260
|
+
@click.pass_context
|
|
261
|
+
def init(ctx: click.Context) -> None:
|
|
262
|
+
import ipaddress
|
|
263
|
+
|
|
264
|
+
import wgconfig
|
|
265
|
+
|
|
266
|
+
wg_dir = ctx.obj["wg_dir"]
|
|
267
|
+
interface = ctx.obj["interface"]
|
|
268
|
+
|
|
269
|
+
os.umask(0o077)
|
|
270
|
+
|
|
271
|
+
interface = click.prompt("Listen port", default=str(51820)).strip() or "51820"
|
|
272
|
+
try:
|
|
273
|
+
port = int(interface)
|
|
274
|
+
except ValueError:
|
|
275
|
+
click.echo("error: port must be an integer", err=True)
|
|
276
|
+
raise SystemExit(1)
|
|
277
|
+
|
|
278
|
+
endpoint = click.prompt("Server public address (e.g. vpn.example.com)").strip()
|
|
279
|
+
if not endpoint:
|
|
280
|
+
click.echo("error: server address is required", err=True)
|
|
281
|
+
raise SystemExit(1)
|
|
282
|
+
|
|
283
|
+
ip_range = click.prompt("VPN network CIDR", default="10.0.73.0/24").strip() or "10.0.73.0/24"
|
|
284
|
+
try:
|
|
285
|
+
net = ipaddress.ip_network(ip_range, strict=False)
|
|
286
|
+
except ValueError:
|
|
287
|
+
click.echo(f"error: invalid CIDR: {ip_range!r}", err=True)
|
|
288
|
+
raise SystemExit(1)
|
|
289
|
+
server_ip = str(net.network_address + 1)
|
|
290
|
+
|
|
291
|
+
use_existing = click.prompt("Use existing private/public key? [y/N]", default="n").strip().lower()
|
|
292
|
+
if use_existing == "y":
|
|
293
|
+
priv = click.prompt("Private key (base64)").strip()
|
|
294
|
+
pub = click.prompt("Public key (base64)").strip()
|
|
295
|
+
else:
|
|
296
|
+
priv_path = click.prompt("Path to existing private key file (or press Enter to generate)", default="").strip()
|
|
297
|
+
if priv_path:
|
|
298
|
+
priv = Path(priv_path).read_text().strip()
|
|
299
|
+
pub = derive_public_key(priv)
|
|
300
|
+
else:
|
|
301
|
+
kp = generate_keypair()
|
|
302
|
+
priv, pub = kp.private, kp.public
|
|
303
|
+
|
|
304
|
+
wg_dir_real = os.path.realpath(wg_dir)
|
|
305
|
+
os.makedirs(wg_dir_real, exist_ok=True)
|
|
306
|
+
conf_path = os.path.join(wg_dir_real, f"{interface}.conf")
|
|
307
|
+
|
|
308
|
+
if os.path.exists(conf_path):
|
|
309
|
+
click.echo(
|
|
310
|
+
f"error: {conf_path} already exists; refusing to overwrite",
|
|
311
|
+
err=True,
|
|
312
|
+
)
|
|
313
|
+
raise SystemExit(1)
|
|
314
|
+
|
|
315
|
+
wc = wgconfig.WGConfig(conf_path)
|
|
316
|
+
wc.initialize_file(leading_comment=f"# {interface} interface")
|
|
317
|
+
wc.add_attr(None, "Address", f"{server_ip}/24")
|
|
318
|
+
wc.add_attr(None, "ListenPort", str(port))
|
|
319
|
+
wc.add_attr(None, "PrivateKey", priv)
|
|
320
|
+
wc.add_attr(None, "SaveConfig", "true")
|
|
321
|
+
wc.add_attr(None, "PostUp", f"nft add element ip filter WG_IFACES {{ {interface} }}; nft add element ip nat WG_IFACES {{ {interface} }}")
|
|
322
|
+
wc.add_attr(None, "PostDown", f"nft delete element ip filter WG_IFACES {{ {interface} }}; nft delete element ip nat WG_IFACES {{ {interface} }}")
|
|
323
|
+
|
|
324
|
+
atomic_write_wgconf(wc, conf_path)
|
|
325
|
+
|
|
326
|
+
click.echo(f"created {conf_path}")
|
|
327
|
+
click.echo(f"server public key: {pub}")
|
|
328
|
+
click.echo(f"endpoint: {endpoint}:{port}")
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
@cli.command()
|
|
332
|
+
@click.argument("user", required=False, type=USERNAME)
|
|
333
|
+
@click.option("--all", "sync_all", is_flag=True, help="sync all peers via wg syncconf")
|
|
334
|
+
@click.pass_context
|
|
335
|
+
def sync(ctx: click.Context, user: str | None, sync_all: bool) -> None:
|
|
336
|
+
"""Apply a user's peer to the live interface via wg set, or --all via wg syncconf."""
|
|
337
|
+
if not user and not sync_all:
|
|
338
|
+
click.echo("error: pass a user or use --all", err=True)
|
|
339
|
+
raise SystemExit(1)
|
|
340
|
+
if user and sync_all:
|
|
341
|
+
click.echo("error: pass a user or --all, not both", err=True)
|
|
342
|
+
raise SystemExit(1)
|
|
343
|
+
|
|
344
|
+
wg_dir = ctx.obj["wg_dir"]
|
|
345
|
+
interface = ctx.obj["interface"]
|
|
346
|
+
clients_dir = ctx.obj["clients_dir"]
|
|
347
|
+
|
|
348
|
+
os.umask(0o077)
|
|
349
|
+
|
|
350
|
+
server = _make_server(wg_dir, interface, clients_dir)
|
|
351
|
+
|
|
352
|
+
try:
|
|
353
|
+
if sync_all:
|
|
354
|
+
sync_all_users(server)
|
|
355
|
+
else:
|
|
356
|
+
assert user is not None
|
|
357
|
+
sync_user(server, user)
|
|
358
|
+
except Exception as exc:
|
|
359
|
+
click.echo(f"error: sync failed: {exc}", err=True)
|
|
360
|
+
raise SystemExit(1)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def main(argv: list[str] | None = None) -> None:
|
|
364
|
+
if argv is None:
|
|
365
|
+
argv = sys.argv[1:]
|
|
366
|
+
new_argv: list[str] = []
|
|
367
|
+
skip_next = False
|
|
368
|
+
for i, arg in enumerate(argv):
|
|
369
|
+
if skip_next:
|
|
370
|
+
skip_next = False
|
|
371
|
+
continue
|
|
372
|
+
if arg == "-v" and i + 1 < len(argv):
|
|
373
|
+
new_argv.append("view")
|
|
374
|
+
new_argv.append(argv[i + 1])
|
|
375
|
+
skip_next = True
|
|
376
|
+
else:
|
|
377
|
+
new_argv.append(arg)
|
|
378
|
+
try:
|
|
379
|
+
cli.main(new_argv, standalone_mode=False)
|
|
380
|
+
except click.exceptions.UsageError as exc:
|
|
381
|
+
if exc.ctx is not None:
|
|
382
|
+
click.echo(f"error: {str(exc)}", err=True)
|
|
383
|
+
click.echo(exc.ctx.get_help(), err=True)
|
|
384
|
+
else:
|
|
385
|
+
click.echo(f"error: {str(exc)}", err=True)
|
|
386
|
+
raise SystemExit(2)
|
|
387
|
+
except click.exceptions.Abort:
|
|
388
|
+
click.echo("aborted", err=True)
|
|
389
|
+
raise SystemExit(1)
|
|
390
|
+
except click.exceptions.Exit as exc:
|
|
391
|
+
raise SystemExit(exc.exit_code)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
if __name__ == "__main__":
|
|
395
|
+
main()
|