routedel 1.0.3__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.
- routedel/__init__.py +10 -0
- routedel/__main__.py +12 -0
- routedel/cli.py +150 -0
- routedel/display.py +109 -0
- routedel/notify.py +137 -0
- routedel/routes.py +148 -0
- routedel-1.0.3.dist-info/METADATA +111 -0
- routedel-1.0.3.dist-info/RECORD +11 -0
- routedel-1.0.3.dist-info/WHEEL +4 -0
- routedel-1.0.3.dist-info/entry_points.txt +2 -0
- routedel-1.0.3.dist-info/licenses/LICENSE +21 -0
routedel/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
# File: src/routedel/__init__.py
|
|
4
|
+
# Author: Hadi Cahyadi <cumulus13@gmail.com>
|
|
5
|
+
# Date: 2026-10-26
|
|
6
|
+
# Description: routedel - delete / add / list / monitor IPv4 routes with ntfy + Growl notifications.
|
|
7
|
+
# License: MIT
|
|
8
|
+
|
|
9
|
+
"""routedel - delete / add / list / monitor IPv4 routes with ntfy + Growl notifications."""
|
|
10
|
+
__version__ = "1.0.0"
|
routedel/__main__.py
ADDED
routedel/cli.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
# File: src/routedel/cli.py
|
|
4
|
+
# Author: Hadi Cahyadi <cumulus13@gmail.com>
|
|
5
|
+
# Date: 2026-10-26
|
|
6
|
+
# Description: Command line interface.
|
|
7
|
+
# License: MIT
|
|
8
|
+
|
|
9
|
+
"""Command line interface."""
|
|
10
|
+
import argparse
|
|
11
|
+
import ipaddress
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
from rchf import CustomRichHelpFormatter
|
|
16
|
+
from richcolorlog import setup_logging
|
|
17
|
+
|
|
18
|
+
from . import __version__
|
|
19
|
+
from .display import console, create_route_table, monitor_routes
|
|
20
|
+
from .notify import APP_NAME, build_notifier
|
|
21
|
+
from .routes import (
|
|
22
|
+
delete_default,
|
|
23
|
+
delete_entry,
|
|
24
|
+
find_by_metric,
|
|
25
|
+
get_routes,
|
|
26
|
+
parse_route_table,
|
|
27
|
+
route_action,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
32
|
+
p = argparse.ArgumentParser(formatter_class=CustomRichHelpFormatter, prog=APP_NAME)
|
|
33
|
+
p.add_argument('data', nargs="*", help='Route string, or "c" to read from clipboard')
|
|
34
|
+
p.add_argument('-V', '--version', action='version', version=f"{APP_NAME} {__version__}")
|
|
35
|
+
p.add_argument('-m', '--metric', help='Remove route(s) by metric')
|
|
36
|
+
p.add_argument('-l', '--list', action='store_true', help='List current routes')
|
|
37
|
+
p.add_argument('-a', '--add', action='store_true', help='Add route (needs -g and -i; DATA[0]=CIDR unless -d)')
|
|
38
|
+
p.add_argument('-d', '--default', action='store_true', help='Add as default route')
|
|
39
|
+
p.add_argument('-g', '--gateway', help='Gateway IP for adding route')
|
|
40
|
+
p.add_argument('-i', '--interface', help='Interface name for adding route')
|
|
41
|
+
p.add_argument('--met', type=int, default=0, help='Metric for adding route')
|
|
42
|
+
p.add_argument('--monitor', action='store_true', help='Monitor routing table in real-time')
|
|
43
|
+
p.add_argument('--refresh', type=float, default=2.0, help='Monitor refresh seconds (default: 2.0)')
|
|
44
|
+
p.add_argument('--ntfy', action='append', metavar='URL', help='ntfy topic URL, e.g. https://ntfy.sh/topic (repeatable)')
|
|
45
|
+
p.add_argument('--growl', action='append', metavar='HOST[:PORT]', help='Growl/GNTP server (repeatable)')
|
|
46
|
+
p.add_argument('--config', help='Notification config JSON file')
|
|
47
|
+
p.add_argument('--no-notify', action='store_true', help='Disable all notifications')
|
|
48
|
+
p.add_argument('--test-notify', action='store_true', help='Send a test notification and exit')
|
|
49
|
+
return p
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def main(argv=None):
|
|
53
|
+
log = setup_logging(exceptions=['pika', 'urllib3', 'urllib2', 'urllib', 'clipboard'])
|
|
54
|
+
p = build_parser()
|
|
55
|
+
argv = sys.argv[1:] if argv is None else argv
|
|
56
|
+
if not argv:
|
|
57
|
+
p.print_help()
|
|
58
|
+
sys.exit(0)
|
|
59
|
+
args = p.parse_args(argv)
|
|
60
|
+
notifier = build_notifier(args.no_notify, args.config, args.ntfy, args.growl)
|
|
61
|
+
|
|
62
|
+
if args.test_notify:
|
|
63
|
+
if not notifier:
|
|
64
|
+
log.error("No notification targets configured.")
|
|
65
|
+
sys.exit(1)
|
|
66
|
+
notifier.send("Test notification", "info")
|
|
67
|
+
return
|
|
68
|
+
|
|
69
|
+
if args.monitor:
|
|
70
|
+
monitor_routes(args.refresh, notifier)
|
|
71
|
+
return
|
|
72
|
+
|
|
73
|
+
if args.list:
|
|
74
|
+
console.print(create_route_table(get_routes()))
|
|
75
|
+
return
|
|
76
|
+
|
|
77
|
+
if (args.add or args.data or args.metric) and hasattr(os, "geteuid") and os.geteuid() != 0:
|
|
78
|
+
log.warning("Not running as root; route changes will likely fail.")
|
|
79
|
+
|
|
80
|
+
if args.add:
|
|
81
|
+
if not (args.gateway and args.interface):
|
|
82
|
+
log.error("--add requires -g GATEWAY and -i INTERFACE")
|
|
83
|
+
sys.exit(1)
|
|
84
|
+
if args.default:
|
|
85
|
+
dest, mask = "0.0.0.0", "0.0.0.0"
|
|
86
|
+
elif args.data:
|
|
87
|
+
try:
|
|
88
|
+
net = ipaddress.ip_network(args.data[0], strict=False)
|
|
89
|
+
except ValueError as e:
|
|
90
|
+
log.error("Invalid network '%s': %s", args.data[0], e)
|
|
91
|
+
sys.exit(1)
|
|
92
|
+
dest, mask = str(net.network_address), str(net.netmask)
|
|
93
|
+
else:
|
|
94
|
+
log.error("Provide DATA (CIDR) or use -d for default route")
|
|
95
|
+
sys.exit(1)
|
|
96
|
+
sys.exit(0 if route_action("add", dest, mask, args.gateway, args.interface,
|
|
97
|
+
str(args.met), notifier) else 1)
|
|
98
|
+
|
|
99
|
+
if args.data:
|
|
100
|
+
if args.data == ['c']:
|
|
101
|
+
try:
|
|
102
|
+
import clipboard
|
|
103
|
+
except ImportError:
|
|
104
|
+
log.error("Clipboard support missing: pip install 'routedel[clipboard]'")
|
|
105
|
+
sys.exit(1)
|
|
106
|
+
tokens = clipboard.paste().split()
|
|
107
|
+
else:
|
|
108
|
+
tokens = args.data
|
|
109
|
+
if len(tokens) in (8, 9): # full row, optional leading index
|
|
110
|
+
e = parse_route_table(" ".join(tokens)).get(0)
|
|
111
|
+
if not e:
|
|
112
|
+
log.error("Could not parse route row")
|
|
113
|
+
sys.exit(1)
|
|
114
|
+
sys.exit(0 if delete_entry(e, notifier) else 1)
|
|
115
|
+
if len(tokens) == 4: # legacy: X GW METRIC DEV
|
|
116
|
+
sys.exit(0 if delete_default(tokens[1], tokens[2], tokens[3], notifier) else 1)
|
|
117
|
+
log.error("Unsupported data format (%d tokens)", len(tokens))
|
|
118
|
+
sys.exit(1)
|
|
119
|
+
|
|
120
|
+
if args.metric:
|
|
121
|
+
matches = find_by_metric(get_routes(), args.metric)
|
|
122
|
+
if not matches:
|
|
123
|
+
log.error("No route found with metric %s", args.metric)
|
|
124
|
+
sys.exit(1)
|
|
125
|
+
to_delete = matches
|
|
126
|
+
if len(matches) > 1:
|
|
127
|
+
log.warning("Multiple routes found with metric %s:", args.metric)
|
|
128
|
+
for idx, e in matches:
|
|
129
|
+
print(f" [{idx}] {e}")
|
|
130
|
+
q = input("Index to delete, 'a' = all, 'c' = cancel: ").strip().lower()
|
|
131
|
+
if q == 'c':
|
|
132
|
+
log.info("Operation cancelled.")
|
|
133
|
+
return
|
|
134
|
+
if q == 'a':
|
|
135
|
+
pass
|
|
136
|
+
elif q.isdigit() and any(m[0] == int(q) for m in matches):
|
|
137
|
+
to_delete = [m for m in matches if m[0] == int(q)]
|
|
138
|
+
else:
|
|
139
|
+
log.error("Invalid input, operation cancelled.")
|
|
140
|
+
sys.exit(1)
|
|
141
|
+
ok = [delete_entry(e, notifier) for _, e in to_delete]
|
|
142
|
+
sys.exit(0 if all(ok) else 1)
|
|
143
|
+
|
|
144
|
+
log.error("Invalid arguments. Provide route details, -m METRIC, -a, -l or --monitor.")
|
|
145
|
+
p.print_help()
|
|
146
|
+
sys.exit(1)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
if __name__ == "__main__":
|
|
150
|
+
main()
|
routedel/display.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
# File: src/routedel/display.py
|
|
4
|
+
# Author: Hadi Cahyadi <cumulus13@gmail.com>
|
|
5
|
+
# Date: 2026-10-26
|
|
6
|
+
# Description: Rich display and live monitor.
|
|
7
|
+
# License: MIT
|
|
8
|
+
|
|
9
|
+
"""Rich display and live monitor."""
|
|
10
|
+
import signal
|
|
11
|
+
import threading
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
|
|
14
|
+
from rich.align import Align
|
|
15
|
+
from rich.console import Console
|
|
16
|
+
from rich.layout import Layout
|
|
17
|
+
from rich.live import Live
|
|
18
|
+
from rich.panel import Panel
|
|
19
|
+
from rich.table import Table
|
|
20
|
+
from rich.text import Text
|
|
21
|
+
|
|
22
|
+
from .notify import Notifier
|
|
23
|
+
from .routes import COLS, Route, Routes, get_routes, is_default, prefixlen
|
|
24
|
+
|
|
25
|
+
console = Console()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def create_route_table(routes: Routes, title: str = "Current Routes") -> Table:
|
|
29
|
+
t = Table(title=title, title_style="bold magenta", expand=True, padding=(0, 1))
|
|
30
|
+
styles = [("Idx", "cyan", 3), ("Destination", "bold cyan", 15), ("Gateway", "bold yellow", 15),
|
|
31
|
+
("Genmask", "bold blue", 15), ("Flags", "bold magenta", 5), ("Metric", "bold green", 6),
|
|
32
|
+
("Ref", "bold red", 3), ("Use", "bold white", 3), ("Iface", "bold cyan", 8)]
|
|
33
|
+
for name, style, width in styles:
|
|
34
|
+
t.add_column(name, style=style, no_wrap=True, min_width=width)
|
|
35
|
+
for idx, e in routes.items():
|
|
36
|
+
t.add_row(f"[{'bold red' if is_default(e) else 'cyan'}]{idx}[/]", *(e.get(c, "") for c in COLS))
|
|
37
|
+
return t
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def create_monitor_layout(routes: Routes, last_update: str, interval: float, n_targets: int) -> Layout:
|
|
41
|
+
head = Text()
|
|
42
|
+
head.append("Route Monitor", style="bold magenta")
|
|
43
|
+
head.append(f" | {last_update} | refresh {interval}s | notify targets: {n_targets} | Ctrl+C to exit", style="dim")
|
|
44
|
+
ifaces = sorted({e["Iface"] for e in routes.values() if e.get("Iface")})
|
|
45
|
+
stats = Text()
|
|
46
|
+
stats.append(f"Routes: {len(routes)}", style="bold white")
|
|
47
|
+
stats.append(f" | Default: {sum(map(is_default, routes.values()))}", style="bold red")
|
|
48
|
+
stats.append(f" | Interfaces: {', '.join(ifaces)}", style="cyan")
|
|
49
|
+
layout = Layout()
|
|
50
|
+
layout.split_column(
|
|
51
|
+
Layout(Panel(Align.center(head), style="bold blue"), size=3),
|
|
52
|
+
Layout(create_route_table(routes, f"Routing Table ({len(routes)} routes)")),
|
|
53
|
+
Layout(Panel(Align.center(stats), title="Statistics", style="dim"), size=3),
|
|
54
|
+
)
|
|
55
|
+
return layout
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def route_key(e: Route) -> tuple[str, ...]:
|
|
59
|
+
return tuple(e.get(c, "") for c in ("Destination", "Gateway", "Genmask", "Metric", "Iface"))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def fmt_key(k: tuple[str, ...]) -> str:
|
|
63
|
+
d, g, m, met, i = k
|
|
64
|
+
return f"{d}/{prefixlen(m)} via {g} dev {i} metric {met}"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def diff_routes(prev: dict, cur: dict):
|
|
68
|
+
"""Return (message_lines, default_changed) for two {key: route} maps; ([], False) if equal."""
|
|
69
|
+
added, removed = cur.keys() - prev.keys(), prev.keys() - cur.keys()
|
|
70
|
+
if not (added or removed):
|
|
71
|
+
return [], False
|
|
72
|
+
lines = [f"+ {fmt_key(k)}" for k in sorted(added)] + [f"- {fmt_key(k)}" for k in sorted(removed)]
|
|
73
|
+
if len(lines) > 10:
|
|
74
|
+
lines = lines[:10] + [f"... +{len(lines) - 10} more"]
|
|
75
|
+
dflt = any(k[0] == "0.0.0.0" and k[2] == "0.0.0.0" for k in added | removed)
|
|
76
|
+
return lines, dflt
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def monitor_routes(interval: float, notifier: Notifier):
|
|
80
|
+
stop = threading.Event()
|
|
81
|
+
handler = lambda s, f: stop.set()
|
|
82
|
+
for name in ("SIGINT", "SIGTERM", "SIGHUP"):
|
|
83
|
+
if hasattr(signal, name):
|
|
84
|
+
signal.signal(getattr(signal, name), handler)
|
|
85
|
+
|
|
86
|
+
prev = {route_key(e): e for e in get_routes().values()}
|
|
87
|
+
notifier.send(f"Monitor started ({len(prev)} routes)", "info", wait=False)
|
|
88
|
+
try:
|
|
89
|
+
with Live(console=console, screen=True, auto_refresh=False) as live:
|
|
90
|
+
while not stop.is_set():
|
|
91
|
+
try:
|
|
92
|
+
routes = get_routes()
|
|
93
|
+
cur = {route_key(e): e for e in routes.values()}
|
|
94
|
+
lines, dflt = diff_routes(prev, cur)
|
|
95
|
+
if lines:
|
|
96
|
+
notifier.send("Routing table changed:\n" + "\n".join(lines),
|
|
97
|
+
"warning" if dflt else "info", wait=False)
|
|
98
|
+
prev = cur
|
|
99
|
+
live.update(create_monitor_layout(routes, datetime.now().strftime("%H:%M:%S"),
|
|
100
|
+
interval, len(notifier.targets)))
|
|
101
|
+
live.refresh()
|
|
102
|
+
except Exception as e: # noqa
|
|
103
|
+
live.update(Panel(Align.center(Text(f"Error: {e}\nRetrying in {interval}s", style="bold red")),
|
|
104
|
+
title="Error", style="bold red"))
|
|
105
|
+
live.refresh()
|
|
106
|
+
stop.wait(interval)
|
|
107
|
+
finally:
|
|
108
|
+
notifier.send("Monitor stopped", "info", wait=True)
|
|
109
|
+
console.print("[green]Route monitoring stopped.[/green]")
|
routedel/notify.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""ntfy (HTTP) and Growl (GNTP 1.0) notifications. Stdlib only. Multiple servers, sent in parallel."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import base64
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import socket
|
|
11
|
+
import sys
|
|
12
|
+
import threading
|
|
13
|
+
import urllib.request
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
APP_NAME = "routedel"
|
|
17
|
+
HOST = socket.gethostname()
|
|
18
|
+
log = logging.getLogger(APP_NAME)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _ntfy_send(cfg: dict, title: str, message: str, level: str):
|
|
22
|
+
url = cfg["url"].rstrip("/")
|
|
23
|
+
if cfg.get("topic"):
|
|
24
|
+
url = f"{url}/{cfg['topic']}"
|
|
25
|
+
headers = {
|
|
26
|
+
"Title": title.encode("ascii", "replace").decode(),
|
|
27
|
+
"Priority": {"info": "3", "warning": "4", "error": "5"}.get(level, "3"),
|
|
28
|
+
"Tags": {"info": "information_source", "warning": "warning", "error": "rotating_light"}.get(level, ""),
|
|
29
|
+
}
|
|
30
|
+
if cfg.get("token"):
|
|
31
|
+
headers["Authorization"] = f"Bearer {cfg['token']}"
|
|
32
|
+
elif cfg.get("user"):
|
|
33
|
+
cred = base64.b64encode(f"{cfg['user']}:{cfg.get('password', '')}".encode()).decode()
|
|
34
|
+
headers["Authorization"] = f"Basic {cred}"
|
|
35
|
+
req = urllib.request.Request(url, data=message.encode("utf-8"), headers=headers, method="POST")
|
|
36
|
+
with urllib.request.urlopen(req, timeout=cfg.get("timeout", 5)) as r:
|
|
37
|
+
if r.status >= 300:
|
|
38
|
+
raise RuntimeError(f"HTTP {r.status}")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _gntp_header(msg_type: str, password: str, algo: str = "SHA256") -> str:
|
|
42
|
+
if not password:
|
|
43
|
+
return f"GNTP/1.0 {msg_type} NONE\r\n"
|
|
44
|
+
salt = os.urandom(16)
|
|
45
|
+
key = hashlib.new(algo.lower(), password.encode("utf-8") + salt).digest()
|
|
46
|
+
key_hash = hashlib.new(algo.lower(), key).hexdigest().upper()
|
|
47
|
+
return f"GNTP/1.0 {msg_type} NONE {algo}:{key_hash}.{salt.hex().upper()}\r\n"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _gntp_roundtrip(host: str, port: int, payload: str, timeout: float):
|
|
51
|
+
with socket.create_connection((host, port), timeout=timeout) as s:
|
|
52
|
+
s.sendall(payload.encode("utf-8"))
|
|
53
|
+
buf = b""
|
|
54
|
+
while b"\r\n\r\n" not in buf:
|
|
55
|
+
chunk = s.recv(4096)
|
|
56
|
+
if not chunk:
|
|
57
|
+
break
|
|
58
|
+
buf += chunk
|
|
59
|
+
text = buf.decode("utf-8", "replace")
|
|
60
|
+
if "-OK" not in text.split("\r\n", 1)[0]:
|
|
61
|
+
raise RuntimeError(text.strip().replace("\r\n", " | ") or "empty response")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _gntp_send(cfg: dict, title: str, message: str, level: str):
|
|
65
|
+
host, port = cfg["host"], int(cfg.get("port", 23053))
|
|
66
|
+
pw, timeout = cfg.get("password", ""), cfg.get("timeout", 5)
|
|
67
|
+
one = lambda s: re.sub(r"[\r\n]+", "; ", str(s)) # GNTP values are single-line
|
|
68
|
+
|
|
69
|
+
reg = (_gntp_header("REGISTER", pw)
|
|
70
|
+
+ f"Application-Name: {APP_NAME}\r\nNotifications-Count: 1\r\n\r\n"
|
|
71
|
+
+ "Notification-Name: route-event\r\nNotification-Display-Name: Route Event\r\n"
|
|
72
|
+
+ "Notification-Enabled: True\r\n\r\n")
|
|
73
|
+
_gntp_roundtrip(host, port, reg, timeout)
|
|
74
|
+
|
|
75
|
+
prio = {"info": 0, "warning": 1, "error": 2}.get(level, 0)
|
|
76
|
+
note = (_gntp_header("NOTIFY", pw)
|
|
77
|
+
+ f"Application-Name: {APP_NAME}\r\nNotification-Name: route-event\r\n"
|
|
78
|
+
+ f"Notification-Title: {one(title)}\r\nNotification-Text: {one(message)}\r\n"
|
|
79
|
+
+ f"Notification-Priority: {prio}\r\n\r\n")
|
|
80
|
+
_gntp_roundtrip(host, port, note, timeout)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class Notifier:
|
|
84
|
+
def __init__(self, ntfy: list[dict], growl: list[dict]):
|
|
85
|
+
self.targets: list[tuple[str, dict]] = [("ntfy", c) for c in ntfy] + [("growl", c) for c in growl]
|
|
86
|
+
|
|
87
|
+
def __bool__(self):
|
|
88
|
+
return bool(self.targets)
|
|
89
|
+
|
|
90
|
+
@staticmethod
|
|
91
|
+
def _one(kind: str, cfg: dict, title: str, msg: str, level: str):
|
|
92
|
+
label = cfg.get("url") if kind == "ntfy" else f"{cfg.get('host')}:{cfg.get('port', 23053)}"
|
|
93
|
+
try:
|
|
94
|
+
(_ntfy_send if kind == "ntfy" else _gntp_send)(cfg, title, msg, level)
|
|
95
|
+
log.info("[%s] sent -> %s", kind, label)
|
|
96
|
+
except Exception as e: # noqa
|
|
97
|
+
log.error("[%s] failed -> %s: %s", kind, label, e)
|
|
98
|
+
|
|
99
|
+
def send(self, message: str, level: str = "info", wait: bool = True, title: str | None = None):
|
|
100
|
+
if not self.targets:
|
|
101
|
+
return
|
|
102
|
+
title = title or f"{APP_NAME}@{HOST}"
|
|
103
|
+
threads = [threading.Thread(target=self._one, args=(k, c, title, message, level), daemon=True)
|
|
104
|
+
for k, c in self.targets]
|
|
105
|
+
for t in threads:
|
|
106
|
+
t.start()
|
|
107
|
+
if wait:
|
|
108
|
+
for t in threads:
|
|
109
|
+
t.join(timeout=15)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def load_config(path: str | None) -> dict:
|
|
113
|
+
candidates = [path, os.environ.get("ROUTEDEL_CONFIG"),
|
|
114
|
+
str(Path.home() / ".config/routedel/config.json"), str(Path.home() / ".routedel.json")]
|
|
115
|
+
for p in filter(None, candidates):
|
|
116
|
+
if Path(p).is_file():
|
|
117
|
+
try:
|
|
118
|
+
return json.loads(Path(p).read_text(encoding="utf-8"))
|
|
119
|
+
except Exception as e: # noqa
|
|
120
|
+
log.error("Bad config %s: %s", p, e)
|
|
121
|
+
sys.exit(1)
|
|
122
|
+
return {}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def build_notifier(no_notify: bool = False, config: str | None = None,
|
|
126
|
+
ntfy_urls: list[str] | None = None, growl_hosts: list[str] | None = None) -> Notifier:
|
|
127
|
+
if no_notify:
|
|
128
|
+
return Notifier([], [])
|
|
129
|
+
cfg = load_config(config)
|
|
130
|
+
ntfy, growl = list(cfg.get("ntfy", [])), list(cfg.get("growl", []))
|
|
131
|
+
for u in ntfy_urls or []: # https://host/topic
|
|
132
|
+
base, _, topic = u.rstrip("/").rpartition("/")
|
|
133
|
+
ntfy.append({"url": base, "topic": topic})
|
|
134
|
+
for g in growl_hosts or []: # host[:port]
|
|
135
|
+
host, _, port = g.partition(":")
|
|
136
|
+
growl.append({"host": host, "port": int(port or 23053)})
|
|
137
|
+
return Notifier(ntfy, growl)
|
routedel/routes.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
# File: src/routedel/routes.py
|
|
4
|
+
# Author: Hadi Cahyadi <cumulus13@gmail.com>
|
|
5
|
+
# Date: 2026-10-26
|
|
6
|
+
# Description: Route table reading, parsing and modification (Linux `ip` / `route`).
|
|
7
|
+
# License: MIT
|
|
8
|
+
|
|
9
|
+
"""Route table reading, parsing and modification (Linux `ip` / `route`)."""
|
|
10
|
+
import ipaddress
|
|
11
|
+
import logging
|
|
12
|
+
import re
|
|
13
|
+
import shutil
|
|
14
|
+
import subprocess
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from .notify import Notifier
|
|
18
|
+
|
|
19
|
+
log = logging.getLogger("routedel")
|
|
20
|
+
|
|
21
|
+
Route = dict[str, str]
|
|
22
|
+
Routes = dict[int, Route]
|
|
23
|
+
COLS = ["Destination", "Gateway", "Genmask", "Flags", "Metric", "Ref", "Use", "Iface"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def netmask(prefix: int) -> str:
|
|
27
|
+
return str(ipaddress.ip_network(f"0.0.0.0/{prefix}").netmask)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def prefixlen(mask: str) -> int:
|
|
31
|
+
return ipaddress.ip_network(f"0.0.0.0/{mask}").prefixlen
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def is_default(r: Route) -> bool:
|
|
35
|
+
return r.get("Destination") == "0.0.0.0" and r.get("Genmask") == "0.0.0.0"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def parse_route_table(text: str) -> Routes:
|
|
39
|
+
"""Parse `route -n` style text (optional leading '#' / index column)."""
|
|
40
|
+
lines = [ln for ln in text.splitlines() if ln.strip()]
|
|
41
|
+
hdr = next((i for i, ln in enumerate(lines) if "Destination" in ln and "Gateway" in ln), None)
|
|
42
|
+
out: Routes = {}
|
|
43
|
+
for ln in (lines[hdr + 1:] if hdr is not None else lines):
|
|
44
|
+
parts = re.sub(r"^\s*#\s*", "", ln).split()
|
|
45
|
+
if len(parts) == len(COLS) + 1 and parts[0].isdigit():
|
|
46
|
+
parts = parts[1:]
|
|
47
|
+
if len(parts) != len(COLS):
|
|
48
|
+
continue
|
|
49
|
+
out[len(out)] = dict(zip(COLS, parts))
|
|
50
|
+
return out
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def parse_ip_route(text: str) -> Routes:
|
|
54
|
+
"""Parse `ip -4 route show` output into the same shape as parse_route_table."""
|
|
55
|
+
out: Routes = {}
|
|
56
|
+
for ln in text.splitlines():
|
|
57
|
+
p = ln.split()
|
|
58
|
+
if not p or p[0] in ("blackhole", "unreachable", "prohibit", "throw"):
|
|
59
|
+
continue
|
|
60
|
+
dest = p[0]
|
|
61
|
+
kv = {p[i]: p[i + 1] for i in range(len(p) - 1) if p[i] in ("via", "dev", "metric")}
|
|
62
|
+
try:
|
|
63
|
+
if dest == "default":
|
|
64
|
+
dest_ip, mask = "0.0.0.0", "0.0.0.0"
|
|
65
|
+
elif "/" in dest:
|
|
66
|
+
ip, pre = dest.split("/")
|
|
67
|
+
dest_ip, mask = ip, netmask(int(pre))
|
|
68
|
+
else:
|
|
69
|
+
dest_ip, mask = dest, "255.255.255.255"
|
|
70
|
+
ipaddress.ip_address(dest_ip)
|
|
71
|
+
except ValueError:
|
|
72
|
+
continue
|
|
73
|
+
out[len(out)] = {"Destination": dest_ip, "Gateway": kv.get("via", "0.0.0.0"), "Genmask": mask,
|
|
74
|
+
"Flags": "UG" if "via" in kv else "U", "Metric": kv.get("metric", "0"),
|
|
75
|
+
"Ref": "0", "Use": "0", "Iface": kv.get("dev", "")}
|
|
76
|
+
return out
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def get_routes() -> Routes:
|
|
80
|
+
try:
|
|
81
|
+
if shutil.which("ip"):
|
|
82
|
+
p = subprocess.run(["ip", "-4", "route", "show"], capture_output=True, text=True) # noqa
|
|
83
|
+
if p.returncode == 0:
|
|
84
|
+
return parse_ip_route(p.stdout)
|
|
85
|
+
if shutil.which("route"):
|
|
86
|
+
p = subprocess.run(["route", "-n"], capture_output=True, text=True) # noqa
|
|
87
|
+
return parse_route_table(p.stdout)
|
|
88
|
+
except Exception as e: # noqa
|
|
89
|
+
log.error("Failed to read routes: %s", e)
|
|
90
|
+
return {}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def find_by_metric(data: Routes, metric: Any) -> list[tuple[int, Route]]:
|
|
94
|
+
return [(k, e) for k, e in data.items() if str(e.get("Metric", "")) == str(metric)]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _run(cmd: list[str]) -> tuple[bool, str]:
|
|
98
|
+
p = subprocess.run(cmd, capture_output=True, text=True) # noqa
|
|
99
|
+
return p.returncode == 0, (p.stderr or p.stdout).strip()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def build_cmd(action: str, dest: str, mask: str, gw: str, iface: str, metric: str) -> list[str]:
|
|
103
|
+
"""Build the add/del command. action: 'add' | 'del'."""
|
|
104
|
+
has_gw = gw not in ("", "0.0.0.0")
|
|
105
|
+
has_metric = metric not in ("", "0", None)
|
|
106
|
+
default = dest == "0.0.0.0" and mask == "0.0.0.0"
|
|
107
|
+
if shutil.which("ip"):
|
|
108
|
+
cmd = ["ip", "route", action, "default" if default else f"{dest}/{prefixlen(mask)}"]
|
|
109
|
+
if has_gw:
|
|
110
|
+
cmd += ["via", gw]
|
|
111
|
+
cmd += ["dev", iface]
|
|
112
|
+
if has_metric:
|
|
113
|
+
cmd += ["metric", str(metric)]
|
|
114
|
+
return cmd
|
|
115
|
+
cmd = ["route", action]
|
|
116
|
+
cmd += ["default"] if default else ["-net", dest, "netmask", mask]
|
|
117
|
+
if has_gw:
|
|
118
|
+
cmd += ["gw", gw]
|
|
119
|
+
if has_metric:
|
|
120
|
+
cmd += ["metric", str(metric)]
|
|
121
|
+
return cmd + ["dev", iface]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def route_action(action: str, dest: str, mask: str, gw: str, iface: str, metric: str,
|
|
125
|
+
notifier: Notifier) -> bool:
|
|
126
|
+
desc = f"{dest}/{prefixlen(mask)} via {gw} dev {iface} metric {metric or 0}"
|
|
127
|
+
cmd = build_cmd(action, dest, mask, gw, iface, metric)
|
|
128
|
+
log.warning("%s: %s", action, " ".join(cmd))
|
|
129
|
+
ok, msg = _run(cmd)
|
|
130
|
+
verb = "added" if action == "add" else "deleted"
|
|
131
|
+
if ok:
|
|
132
|
+
log.info("Route %s: %s", verb, desc)
|
|
133
|
+
notifier.send(f"Route {verb}: {desc}", "info")
|
|
134
|
+
else:
|
|
135
|
+
log.error("Failed to %s route: %s", action, msg)
|
|
136
|
+
notifier.send(f"FAILED to {action} route: {desc}\n{msg}", "error")
|
|
137
|
+
return ok
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def delete_entry(e: Route, notifier: Notifier) -> bool:
|
|
141
|
+
if not e.get("Iface"):
|
|
142
|
+
log.error("Entry missing Iface, skipping: %s", e)
|
|
143
|
+
return False
|
|
144
|
+
return route_action("del", e["Destination"], e["Genmask"], e["Gateway"], e["Iface"], e["Metric"], notifier)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def delete_default(gw: str, metric: str, iface: str, notifier: Notifier) -> bool:
|
|
148
|
+
return route_action("del", "0.0.0.0", "0.0.0.0", gw, iface, metric, notifier)
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: routedel
|
|
3
|
+
Version: 1.0.3
|
|
4
|
+
Summary: Delete, add, list and monitor IPv4 routes, with ntfy and Growl (GNTP) notifications to multiple servers.
|
|
5
|
+
Project-URL: Homepage, https://github.com/cumulus13/routedel
|
|
6
|
+
Project-URL: Issues, https://github.com/cumulus13/routedel/issues
|
|
7
|
+
Author-email: Hadi Cahyadi <cumulus13@gmail.com>
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: cli,gntp,growl,monitor,network,ntfy,route,routing
|
|
11
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: System Administrators
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Topic :: System :: Networking
|
|
18
|
+
Requires-Python: >=3.9
|
|
19
|
+
Requires-Dist: rchf
|
|
20
|
+
Requires-Dist: rich>=13.0
|
|
21
|
+
Requires-Dist: richcolorlog
|
|
22
|
+
Provides-Extra: clipboard
|
|
23
|
+
Requires-Dist: clipboard; extra == 'clipboard'
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: build; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
27
|
+
Requires-Dist: twine; extra == 'dev'
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# routedel
|
|
31
|
+
|
|
32
|
+
Delete, add, list and monitor IPv4 routes on Linux, with notifications to **multiple ntfy and Growl (GNTP) servers**.
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install routedel
|
|
38
|
+
pip install "routedel[clipboard]" # optional: read route rows from the clipboard
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Usage
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
routedel -l # list routes
|
|
45
|
+
routedel -m 100 # delete route(s) by metric
|
|
46
|
+
routedel 0.0.0.0 192.168.1.1 0.0.0.0 UG 100 0 0 eth0 # delete a full `route -n` row
|
|
47
|
+
routedel 0.0.0.0 192.168.1.1 100 eth0 # legacy: X GW METRIC DEV (default route)
|
|
48
|
+
routedel c # delete using a route row from the clipboard
|
|
49
|
+
sudo routedel -a 10.0.0.0/8 -g 192.168.1.1 -i eth0 --met 50 # add route
|
|
50
|
+
sudo routedel -a -d -g 192.168.1.1 -i eth0 --met 100 # add default route
|
|
51
|
+
routedel --monitor --refresh 2 # live monitor, notifies on changes
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Route changes need root.
|
|
55
|
+
|
|
56
|
+
## Notifications
|
|
57
|
+
|
|
58
|
+
Any number of servers of each type. Via CLI (repeatable):
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
routedel -m 100 --ntfy https://ntfy.sh/my-routes --ntfy https://ntfy.example.com/alerts \
|
|
62
|
+
--growl 192.168.1.10 --growl 192.168.1.20:23053
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Or via a JSON config (first found wins): `--config FILE`, `$ROUTEDEL_CONFIG`,
|
|
66
|
+
`~/.config/routedel/config.json`, `~/.routedel.json`. CLI targets are added to the config targets.
|
|
67
|
+
|
|
68
|
+
```json
|
|
69
|
+
{
|
|
70
|
+
"ntfy": [{"url": "https://ntfy.sh", "topic": "my-routes", "token": "tk_xxx"},
|
|
71
|
+
{"url": "https://ntfy.example.com", "topic": "alerts", "user": "u", "password": "p"}],
|
|
72
|
+
"growl": [{"host": "192.168.1.10", "port": 23053, "password": "secret"},
|
|
73
|
+
{"host": "192.168.1.20"}]
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Test with `routedel --test-notify`. Disable with `--no-notify`.
|
|
78
|
+
Servers are contacted in parallel; one failing server does not affect the others.
|
|
79
|
+
Dependencies for notifications: none (stdlib `urllib` + raw GNTP 1.0 over sockets).
|
|
80
|
+
|
|
81
|
+
## License
|
|
82
|
+
|
|
83
|
+
MIT
|
|
84
|
+
|
|
85
|
+
## Development & release
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
pip install -e ".[dev]" && pytest
|
|
89
|
+
python -m build && twine check dist/*
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Release to PyPI (GitHub Actions, Trusted Publishing — no token needed):
|
|
93
|
+
|
|
94
|
+
1. On pypi.org → *Publishing* → add a pending publisher: owner `cumulus13`, repo `routedel`,
|
|
95
|
+
workflow `publish.yml`, environment `pypi`. (Repeat on test.pypi.org with environment `testpypi` if wanted.)
|
|
96
|
+
2. In the GitHub repo create environments `pypi` (and `testpypi`).
|
|
97
|
+
3. Bump `version` in `pyproject.toml` and `src/routedel/__init__.py`, commit, then publish a
|
|
98
|
+
GitHub Release tagged `vX.Y.Z`. The workflow tests, checks tag == version, builds and uploads.
|
|
99
|
+
|
|
100
|
+
Manual alternative: `twine upload dist/*` (API token).
|
|
101
|
+
|
|
102
|
+
## 👤 Author
|
|
103
|
+
|
|
104
|
+
[Hadi Cahyadi](mailto:cumulus13@gmail.com)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
[](https://www.buymeacoffee.com/cumulus13)
|
|
108
|
+
|
|
109
|
+
[](https://ko-fi.com/cumulus13)
|
|
110
|
+
|
|
111
|
+
[Support me on Patreon](https://www.patreon.com/cumulus13)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
routedel/__init__.py,sha256=PjvpmQ3ltXOSyRuWe5k7Lfy9PbUex09TZNae7u9YKiI,352
|
|
2
|
+
routedel/__main__.py,sha256=Y2Gbd0_Q-ijV93xOzfeJz1MAlsn_wi2PKrA80Wkxias,214
|
|
3
|
+
routedel/cli.py,sha256=h5_c9GCaXyjQ8Ju7BV1ojuiQ40CzbBfJ8j6PT3yELo0,5970
|
|
4
|
+
routedel/display.py,sha256=Spmwcq7bC-8aR4i0oauAHK3H2Sv67jeJC-h2zBWB6rE,4710
|
|
5
|
+
routedel/notify.py,sha256=t_lXt99ihP7tYhxJODhNMSIIVgWHT7iScNP2ZEtONVA,5558
|
|
6
|
+
routedel/routes.py,sha256=oG2CRFwHQ3k6FbrfxqyA1e8xk0lSpFjLOZboFSvspeo,5419
|
|
7
|
+
routedel-1.0.3.dist-info/METADATA,sha256=TLhwKfNOu3OSyhq-YEF6lgT4pZVY-GgXYzunI9UZ3TA,4115
|
|
8
|
+
routedel-1.0.3.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
9
|
+
routedel-1.0.3.dist-info/entry_points.txt,sha256=ruBy7nibv9uhgWVktVFlbi636ljyG2QarpUXHEkW4lY,47
|
|
10
|
+
routedel-1.0.3.dist-info/licenses/LICENSE,sha256=IVqSwOi6obYt2Sixm_KTFYa3kf4P7IQM8Y8fHC_huvg,1069
|
|
11
|
+
routedel-1.0.3.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Hadi Cahyadi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|