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 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
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env python3
2
+
3
+ # File: src/routedel/__main__.py
4
+ # Author: Hadi Cahyadi <cumulus13@gmail.com>
5
+ # Date: 2026-10-26
6
+ # Description:
7
+ # License: MIT
8
+
9
+ from .cli import main
10
+
11
+ if __name__ == "__main__":
12
+ main()
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
+ [![Buy Me a Coffee](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://www.buymeacoffee.com/cumulus13)
108
+
109
+ [![Donate via Ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ routedel = routedel.cli:main
@@ -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.