Skip to content

Commit fe4c697

Browse files
committed
Add hooks
1 parent ca65c49 commit fe4c697

7 files changed

Lines changed: 128 additions & 7 deletions

File tree

‎.gitignore‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,7 @@ activemq-data/
156156
/inventory-path
157157
/.secrets/
158158
/.generated/
159+
/.hooks/
159160
env/
160161
venv/
161162
ENV/

‎README.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,12 @@ authorized-key files from every SSH-enabled host.
7979
Use `--limit HOST` and `--tags common,admin,firewall,ssh,https` with plan, apply, or verify. Always run
8080
`plan` before applying to an existing server and keep the current administrative SSH session open.
8181

82+
After a successful `apply`, executable `.hooks/post-config-change` and files in
83+
`.hooks/post-config-change.d/` are run in name order. The ignored `.hooks` directory is intended for
84+
machine-local integrations and secrets. Hooks receive `MEGAPROXY_INVENTORY`,
85+
`MEGAPROXY_ANSIBLE_INVENTORY`, `MEGAPROXY_ROOT`, and `MEGAPROXY_EVENT`. Use `--no-hooks` before or
86+
after the command to skip them, for example `./mega-proxy apply --no-hooks`.
87+
8288
## HTTPS
8389

8490
HTTPS uses a pinned GOST v3 container, TLS 1.2/1.3, Basic authentication and HTTP/2 CONNECT. Invalid

‎src/megaproxy_server/cli.py‎

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
from .export import export_profiles, jump_candidates
1313
from .generate import generate
14+
from .hooks import run_hooks
1415
from .inventory import DEFAULT_INVENTORY, ROOT, discover, load, save, write_ansible_inventory
1516
from .summary import render_summary
1617
from .users import active_users, remove_users
@@ -20,24 +21,32 @@
2021
def parser() -> argparse.ArgumentParser:
2122
result = argparse.ArgumentParser(description="Configure MegaProxy HTTPS and SSH servers")
2223
result.add_argument("--inventory", "-i", help="Inventory configuration path")
24+
result.add_argument("--no-hooks", action="store_true", help="Do not run local hooks")
2325
sub = result.add_subparsers(dest="command")
24-
sub.add_parser("inventory", help="Create a new inventory")
25-
sub.add_parser("validate", help="Validate inventory and Ansible syntax")
26-
sub.add_parser("check", help="Run the complete local/CI test suite")
26+
commands = []
27+
commands.append(sub.add_parser("inventory", help="Create a new inventory"))
28+
commands.append(sub.add_parser("validate", help="Validate inventory and Ansible syntax"))
29+
commands.append(sub.add_parser("check", help="Run the complete local/CI test suite"))
2730
for name in ("plan", "apply", "verify"):
2831
command = sub.add_parser(name)
32+
commands.append(command)
2933
command.add_argument("--limit")
3034
command.add_argument("--tags")
3135
export = sub.add_parser("export")
36+
commands.append(export)
3237
export.add_argument("--output", default=".generated/megaproxy-profiles.json")
3338
export.add_argument("--all-jumps", action="store_true")
3439
configs = sub.add_parser("configs", help="Generate all supported client configuration formats")
40+
commands.append(configs)
3541
configs.add_argument("--output-dir", default=".generated/configs")
3642
summary = sub.add_parser("summary", help="Print credentials for a password manager")
43+
commands.append(summary)
3744
summary.add_argument("login", nargs="?", help="Show only this login")
38-
sub.add_parser("remove-users", help="Remove one or more proxy user accounts")
39-
sub.add_parser("vault-secrets", help="Encrypt only secret inventory fields with Ansible Vault")
40-
sub.add_parser("jumps", help="List all possible SSH jump chains")
45+
commands.append(sub.add_parser("remove-users", help="Remove one or more proxy user accounts"))
46+
commands.append(sub.add_parser("vault-secrets", help="Encrypt only secret inventory fields with Ansible Vault"))
47+
commands.append(sub.add_parser("jumps", help="List all possible SSH jump chains"))
48+
for command in commands:
49+
command.add_argument("--no-hooks", action="store_true", default=argparse.SUPPRESS, help=argparse.SUPPRESS)
4150
return result
4251

4352

@@ -196,6 +205,10 @@ def main() -> None:
196205
applied_admin = tags is None or "admin" in {tag.strip() for tag in tags.split(",")}
197206
if code == 0 and command == "apply" and applied_admin:
198207
finish_bootstrap(path, inventory, limit)
208+
if code == 0 and command == "apply" and not args.no_hooks:
209+
inventory = load(path)
210+
generated = write_ansible_inventory(path, inventory)
211+
code = run_hooks(ROOT, "post-config-change", path, generated)
199212
raise SystemExit(code)
200213
except (ValidationError, FileNotFoundError, ValueError) as error:
201214
print(f"Configuration error: {error}", file=sys.stderr)

‎src/megaproxy_server/hooks.py‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
from __future__ import annotations
2+
3+
import os
4+
import subprocess
5+
from pathlib import Path
6+
7+
8+
def run_hooks(root: Path, event: str, inventory: Path, ansible_inventory: Path) -> int:
9+
hooks_dir = root / ".hooks"
10+
candidates = [hooks_dir / event]
11+
event_dir = hooks_dir / f"{event}.d"
12+
if event_dir.is_dir():
13+
candidates.extend(sorted(path for path in event_dir.iterdir() if path.is_file()))
14+
15+
environment = os.environ.copy()
16+
environment.update(
17+
{
18+
"MEGAPROXY_EVENT": event,
19+
"MEGAPROXY_INVENTORY": str(inventory.resolve()),
20+
"MEGAPROXY_ANSIBLE_INVENTORY": str(ansible_inventory.resolve()),
21+
"MEGAPROXY_ROOT": str(root.resolve()),
22+
}
23+
)
24+
for hook in candidates:
25+
if not hook.is_file() or not os.access(hook, os.X_OK):
26+
continue
27+
print(f"Running hook: {hook}", flush=True)
28+
result = subprocess.run([str(hook)], cwd=root, env=environment)
29+
if result.returncode:
30+
return result.returncode
31+
return 0

‎src/megaproxy_server/inventory.py‎

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,8 +161,16 @@ def _dns_label(value: str) -> str:
161161
return re.sub(r"[^a-z0-9-]+", "-", value.lower()).strip("-")
162162

163163

164+
def _host_title(name: str) -> str:
165+
parts = name.split("_")
166+
if parts[-1:] == ["proxy"]:
167+
parts.pop()
168+
return " ".join([parts[0].upper(), *(part.title() for part in parts[1:])])
169+
170+
164171
def ansible_inventory(inventory: Inventory) -> dict[str, Any]:
165172
hosts: dict[str, Any] = {}
173+
public_https_hosts: list[dict[str, Any]] = []
166174
for name, host in inventory.hosts.items():
167175
services = host.services.model_dump(mode="json", exclude_none=True)
168176
routes: list[dict[str, Any]] = []
@@ -178,6 +186,16 @@ def ansible_inventory(inventory: Inventory) -> dict[str, Any]:
178186
{"username": https.chain_username, "password": https.chain_password}
179187
if inventory.settings.https_chains_enabled and https.chain_exit else None
180188
)
189+
for route in routes:
190+
public_https_hosts.append(
191+
{
192+
"name": name if route["name"] == "direct" else f"{name}_{route['name']}",
193+
"host": route["hostname"],
194+
"port": https.port,
195+
"code": name.split("_", 1)[0].upper(),
196+
"title": _host_title(name),
197+
}
198+
)
181199
variables: dict[str, Any] = {
182200
"ansible_host": host.address,
183201
"ansible_user": host.admin.bootstrap_user or host.admin.user,
@@ -196,7 +214,15 @@ def ansible_inventory(inventory: Inventory) -> dict[str, Any]:
196214
except ValueError:
197215
variables["megaproxy_https_san_type"] = "DNS"
198216
hosts[name] = variables
199-
return {"all": {"hosts": hosts}}
217+
return {
218+
"all": {
219+
"vars": {
220+
"megaproxy_users": inventory.users.model_dump(mode="json"),
221+
"megaproxy_public_https_hosts": public_https_hosts,
222+
},
223+
"hosts": hosts,
224+
}
225+
}
200226

201227

202228
def write_ansible_inventory(source: Path, inventory: Inventory) -> Path:

‎tests/test_hooks.py‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
from __future__ import annotations
2+
3+
import os
4+
from pathlib import Path
5+
6+
from megaproxy_server.hooks import run_hooks
7+
8+
9+
def test_runs_executable_hook_with_inventory_environment(tmp_path: Path) -> None:
10+
hooks = tmp_path / ".hooks"
11+
hooks.mkdir()
12+
output = tmp_path / "hook-output"
13+
hook = hooks / "post-config-change"
14+
hook.write_text(f"#!/bin/sh\nprintf '%s' \"$MEGAPROXY_INVENTORY\" > {output}\n")
15+
hook.chmod(0o700)
16+
inventory = tmp_path / "inventory.yml"
17+
generated = tmp_path / "inventory.ansible.yml"
18+
19+
assert run_hooks(tmp_path, "post-config-change", inventory, generated) == 0
20+
assert output.read_text() == str(inventory.resolve())
21+
22+
23+
def test_skips_non_executable_hook(tmp_path: Path) -> None:
24+
hooks = tmp_path / ".hooks"
25+
hooks.mkdir()
26+
hook = hooks / "post-config-change"
27+
hook.write_text("#!/bin/sh\nexit 42\n")
28+
hook.chmod(0o600)
29+
30+
assert not os.access(hook, os.X_OK)
31+
assert run_hooks(tmp_path, "post-config-change", tmp_path / "one", tmp_path / "two") == 0

‎tests/test_inventory.py‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,3 +95,16 @@ def test_https_sni_routes_are_generated_for_every_exit() -> None:
9595
assert [route["hostname"] for route in routes] == ["ru.example", "proxy-ru-via-proxy-nl.chains.example"]
9696
projected = ansible_inventory(inventory)["all"]["hosts"]["proxy_ru"]["megaproxy_services"]["https"]
9797
assert projected["routes"][1]["chain"]["host"] == "nl.example"
98+
public_hosts = ansible_inventory(inventory)["all"]["vars"]["megaproxy_public_https_hosts"]
99+
assert public_hosts == [
100+
{"name": "proxy_ru", "host": "ru.example", "port": 443, "code": "PROXY", "title": "PROXY Ru"},
101+
{
102+
"name": "proxy_ru_via-proxy_nl",
103+
"host": "proxy-ru-via-proxy-nl.chains.example",
104+
"port": 443,
105+
"code": "PROXY",
106+
"title": "PROXY Ru",
107+
},
108+
{"name": "proxy_nl", "host": "nl.example", "port": 443, "code": "PROXY", "title": "PROXY Nl"},
109+
]
110+
assert ansible_inventory(inventory)["all"]["vars"]["megaproxy_users"]["https"][0]["name"] == "alice"

0 commit comments

Comments
 (0)