Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions scripts/generate-client.py
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,13 @@ def generate_ovpn_config(
# ===============================================================================


_CLIENT_CONFIG_SUFFIX = re.compile(
r"(?:udp|tcp|https|proxy)-(?:split|full)\.ovpn"
r"|(?:proxy-)?stunnel\.conf"
r"|wg\d*(?:-https)?-(?:split|full)\.conf"
)


def _bundle_client_zip(client_name: str, output_dir: Path) -> Path | None:
"""Zip a single client's generated files into <name>.zip (0600).

Expand All @@ -458,10 +465,13 @@ def _bundle_client_zip(client_name: str, output_dir: Path) -> Path | None:
"""
import zipfile

prefix = f"{client_name}-"
members = sorted(
p
for p in output_dir.glob(f"{client_name}-*")
if p.is_file() and p.suffix != ".zip"
for p in output_dir.iterdir()
if p.is_file()
and p.name.startswith(prefix)
and _CLIENT_CONFIG_SUFFIX.fullmatch(p.name[len(prefix) :])
)
if not members:
return None
Expand Down
20 changes: 17 additions & 3 deletions scripts/revoke-client.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,17 @@
PKI_DIR = Path("/etc/vpn/pki")
OUTPUT_DIR = Path("/etc/vpn/clients")

OPENVPN_CONFIG_SUFFIXES = (
"udp-split.ovpn",
"udp-full.ovpn",
"tcp-split.ovpn",
"tcp-full.ovpn",
"https-split.ovpn",
"https-full.ovpn",
"proxy-split.ovpn",
"proxy-full.ovpn",
)


class RevocationError(RuntimeError):
"""Revocation could not be completed, so the client still has access."""
Expand Down Expand Up @@ -160,9 +171,12 @@ def revoke_client(client_name: str, missing_ok: bool = False) -> bool:
PKI_DIR / "reqs" / f"{client_name}.req",
]

# Remove .ovpn files
for ovpn_file in OUTPUT_DIR.glob(f"{client_name}-*.ovpn"):
files_to_remove.append(ovpn_file)
# Remove only filenames generate-client can create for this exact client.
# A prefix glob also matches another client such as foo-bar when revoking
# foo, silently deleting that client's private configuration.
files_to_remove.extend(
OUTPUT_DIR / f"{client_name}-{suffix}" for suffix in OPENVPN_CONFIG_SUFFIXES
)

removed = 0
for f in files_to_remove:
Expand Down
25 changes: 25 additions & 0 deletions tests/unit/test_revoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,31 @@ def fake_from_settings(*args, **kwargs):
)


class TestOpenVpnConfigRemoval:
"""Revoking one client must not delete another client's credentials."""

def test_client_name_prefix_preserves_longer_clients_configs(
self, revoke, monkeypatch
):
issued = revoke.PKI_DIR / "issued"
issued.mkdir()
(issued / "foo.crt").write_text("certificate", encoding="utf-8")
own_config = revoke.OUTPUT_DIR / "foo-udp-split.ovpn"
other_config = revoke.OUTPUT_DIR / "foo-bar-udp-split.ovpn"
own_config.write_text("foo key", encoding="utf-8")
other_config.write_text("foo-bar key", encoding="utf-8")
monkeypatch.setattr(
revoke.subprocess,
"run",
lambda *a, **kw: subprocess.CompletedProcess(a[0], 0, "", ""),
)

assert revoke.revoke_client("foo") is True

assert not own_config.exists()
assert other_config.exists()


class TestInterfaceDetection:
""" "wg is not installed" and "wg0 refused the change" are different faults."""

Expand Down
24 changes: 24 additions & 0 deletions tests/unit/test_wg_config_gen.py
Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,30 @@ def test_bundles_only_this_clients_files(self, tmp_path: Path) -> None:
assert names == {"alice-udp-split.ovpn", "alice-wg-split.conf"}
assert stat.S_IMODE(zip_path.stat().st_mode) == 0o600

def test_client_name_prefix_does_not_bundle_another_clients_keys(
self, tmp_path: Path
) -> None:
"""A client named foo must not receive foo-bar's private configs."""
import zipfile

(tmp_path / "foo-udp-split.ovpn").write_text("foo key", encoding="utf-8")
(tmp_path / "foo-wg-split.conf").write_text("foo wg key", encoding="utf-8")
(tmp_path / "foo-bar-udp-split.ovpn").write_text(
"foo-bar key", encoding="utf-8"
)
(tmp_path / "foo-bar-wg-split.conf").write_text(
"foo-bar wg key", encoding="utf-8"
)

zip_path = self._module()._bundle_client_zip("foo", tmp_path)

assert zip_path is not None
with zipfile.ZipFile(zip_path) as zf:
assert set(zf.namelist()) == {
"foo-udp-split.ovpn",
"foo-wg-split.conf",
}

def test_excludes_existing_zip_and_returns_none_when_empty(
self, tmp_path: Path
) -> None:
Expand Down