diff --git a/images/connections.svg b/images/connections.svg index fca9cbf..b24f23e 100644 --- a/images/connections.svg +++ b/images/connections.svg @@ -19,257 +19,257 @@ font-weight: 700; } - .terminal-1821002665-matrix { + .terminal-3562616864-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1821002665-title { + .terminal-3562616864-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1821002665-r1 { fill: #c5c8c6 } -.terminal-1821002665-r2 { fill: #50a0ff;font-weight: bold } -.terminal-1821002665-r3 { fill: #3a6aab;font-weight: bold } -.terminal-1821002665-r4 { fill: #0a0a1a } -.terminal-1821002665-r5 { fill: #606080 } -.terminal-1821002665-r6 { fill: #c0c0d0 } -.terminal-1821002665-r7 { fill: #555563 } -.terminal-1821002665-r8 { fill: #303050 } -.terminal-1821002665-r9 { fill: #50a0ff } -.terminal-1821002665-r10 { fill: #2d2d3f } -.terminal-1821002665-r11 { fill: #8080a0;font-weight: bold } -.terminal-1821002665-r12 { fill: #0e0e20 } -.terminal-1821002665-r13 { fill: #ffffff } -.terminal-1821002665-r14 { fill: #c080ff } -.terminal-1821002665-r15 { fill: #ffa050 } -.terminal-1821002665-r16 { fill: #50ffa0 } -.terminal-1821002665-r17 { fill: #404060 } -.terminal-1821002665-r18 { fill: #8080a0 } -.terminal-1821002665-r19 { fill: #000000 } -.terminal-1821002665-r20 { fill: #003054 } -.terminal-1821002665-r21 { fill: #ffa62b;font-weight: bold } -.terminal-1821002665-r22 { fill: #e0e0e0 } -.terminal-1821002665-r23 { fill: #414151 } + .terminal-3562616864-r1 { fill: #c5c8c6 } +.terminal-3562616864-r2 { fill: #50a0ff;font-weight: bold } +.terminal-3562616864-r3 { fill: #3a6aab;font-weight: bold } +.terminal-3562616864-r4 { fill: #0a0a1a } +.terminal-3562616864-r5 { fill: #606080 } +.terminal-3562616864-r6 { fill: #c0c0d0 } +.terminal-3562616864-r7 { fill: #555563 } +.terminal-3562616864-r8 { fill: #303050 } +.terminal-3562616864-r9 { fill: #50a0ff } +.terminal-3562616864-r10 { fill: #2d2d3f } +.terminal-3562616864-r11 { fill: #8080a0;font-weight: bold } +.terminal-3562616864-r12 { fill: #0e0e20 } +.terminal-3562616864-r13 { fill: #ffffff } +.terminal-3562616864-r14 { fill: #c080ff } +.terminal-3562616864-r15 { fill: #ffa050 } +.terminal-3562616864-r16 { fill: #50ffa0 } +.terminal-3562616864-r17 { fill: #404060 } +.terminal-3562616864-r18 { fill: #8080a0 } +.terminal-3562616864-r19 { fill: #000000 } +.terminal-3562616864-r20 { fill: #003054 } +.terminal-3562616864-r21 { fill: #ffa62b;font-weight: bold } +.terminal-3562616864-r22 { fill: #e0e0e0 } +.terminal-3562616864-r23 { fill: #414151 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - fwtop + fwtop - - - - fwtop v0.2.0 — 0.1s [DEMO DNS] -OverviewConnectionsFirewallDrops -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - Connections ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── -WAN (68)LAN (21) -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - Proto   Source                                Destination                           NAT →                         State         Packets      Bytes      - TCP     192.168.1.42:1136                     198.51.100.23:22                      203.0.113.2                   FIN_WAIT      1,366        4.6 MB     -TCP192.168.1.42:4117 198.51.100.23:123                    203.0.113.2FIN_WAIT 837          4.6 MB     -UDP192.168.1.77:62255 198.51.100.23:22                     203.0.113.2 3,002        4.5 MB     -TCP192.168.1.42:12745 198.51.100.23:80                     203.0.113.2FIN_WAIT 739          4.5 MB     -TCP192.168.1.10:12194 dns.google:3478                      203.0.113.2ESTABLISHED 3,185        4.5 MB     -UDP192.168.1.10:48766 dns.google:51820                     203.0.113.2 4,665        4.5 MB     -TCP10.0.10.5:64244 lb-140-82-121-4-fra.github.com:123   203.0.113.2ESTABLISHED 1,013        4.4 MB     -ICMP192.168.1.77:19036 198.51.100.23:51820                  203.0.113.2 2,812        4.4 MB     -UDP10.0.10.5:11900 lb-140-82-121-4-fra.github.com:22    203.0.113.2 2,661        4.2 MB     -UDP192.168.1.10:6862 203.0.113.9:51820                    203.0.113.2 1,553        4.1 MB     -UDP192.168.1.77:7165 lb-140-82-121-4-fra.github.com:3478  203.0.113.2 688          4.0 MB     -TCP10.0.10.5:64270 dns.google:53                        203.0.113.2TIME_WAIT 767          4.0 MB     -TCP192.168.1.10:18912 dns.google:53                        203.0.113.2TIME_WAIT 4,308        3.9 MB     -UDP10.0.10.5:61956 one.one.one.one:80                   203.0.113.2 3,725        3.7 MB     -TCP192.168.1.77:41159 dns.google:51820                     203.0.113.2ESTABLISHED 363          3.7 MB     -TCP10.0.10.5:59987 198.51.100.23:443                    203.0.113.2ESTABLISHED 4,595        3.7 MB     -TCP10.0.10.5:27562 lb-140-82-121-4-fra.github.com:443   203.0.113.2ESTABLISHED 4,330        3.6 MB     -TCP192.168.1.10:56478 one.one.one.one:22                   203.0.113.2ESTABLISHED 144          3.5 MB     -ICMP10.0.10.5:14185 203.0.113.9:3478                     203.0.113.2 3,354        3.5 MB     -TCP192.168.1.10:32343 one.one.one.one:3478                 203.0.113.2TIME_WAIT 4,220        3.5 MB     -TCP192.168.1.42:56072 203.0.113.9:51820                    203.0.113.2ESTABLISHED 2,672        3.4 MB     -TCP192.168.1.77:24425 dns.google:443                       203.0.113.2SYN_SENT 4,016        3.4 MB     -TCP10.0.10.5:46586 198.51.100.23:443                    203.0.113.2ESTABLISHED 1,282        3.2 MB     -TCP192.168.1.42:44853 dns.google:53                        203.0.113.2TIME_WAIT 2,670        3.1 MB    ▅▅ -TCP192.168.1.10:6866 one.one.one.one:123                  203.0.113.2ESTABLISHED 3,097        3.1 MB     -UDP10.0.10.5:3616 198.51.100.23:443                    203.0.113.2 2,265        3.1 MB     -UDP192.168.1.10:19703 198.51.100.23:80                     203.0.113.2 2,056        3.0 MB     -UDP10.0.10.5:47140 dns.google:123                       203.0.113.2 3,287        2.9 MB     -TCP192.168.1.77:29911 203.0.113.9:443                      203.0.113.2ESTABLISHED 601          2.8 MB     -TCP192.168.1.77:53967 lb-140-82-121-4-fra.github.com:3478  203.0.113.2ESTABLISHED 4,586        2.7 MB     -UDP10.0.10.5:20854 lb-140-82-121-4-fra.github.com:3478  203.0.113.2 2,842        2.5 MB     -TCP192.168.1.10:43208 one.one.one.one:51820                203.0.113.2ESTABLISHED 42           2.5 MB     -ICMP192.168.1.42:38153 203.0.113.9:123                      203.0.113.2 1,820        2.5 MB     -UDP10.0.10.5:27822 198.51.100.23:3478                   203.0.113.2 3,844        2.5 MB     -TCP192.168.1.10:42871 lb-140-82-121-4-fra.github.com:123   203.0.113.2SYN_SENT 4,852        2.4 MB     -UDP192.168.1.10:3219 198.51.100.23:3478                   203.0.113.2 3,526        2.4 MB     -ICMP192.168.1.42:63955 203.0.113.9:80                       203.0.113.2 1,059        2.3 MB     -TCP10.0.10.5:27154 lb-140-82-121-4-fra.github.com:51820 203.0.113.2ESTABLISHED 4,333        2.2 MB     -TCP192.168.1.10:32453 203.0.113.9:51820                    203.0.113.2CLOSE_WAIT 1,137        2.1 MB     -UDP192.168.1.42:33726 one.one.one.one:3478                 203.0.113.2 3,850        2.1 MB     - -────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── -Quit  p Pause  r Resolve  n Interval  z Zones  1 Overview  2 Connections  3 Firewall  4 Drops  w WAN conns  l LAN conns ^p palette▇▇ + + + + fwtop v0.2.1 — 0.2s [DEMO DNS] +OverviewConnectionsFirewallDrops +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + Connections ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +WAN (63)LAN (23) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + Proto   Source                                Destination                           NAT →                         State         Packets      Bytes      + TCP     desktop.lan:45487                     dns.google:443                        router.wan                    ESTABLISHED   537          4.7 MB     +TCPnas.lan:55367 one.one.one.one:80                   router.wanFIN_WAIT 4,122        4.3 MB     +UDPdesktop.lan:9066 mail.example.org:3478                router.wan 4,611        4.1 MB     +TCPdesktop.lan:17644 vpn-peer.example.net:53              router.wanESTABLISHED 802          4.0 MB     +TCPdesktop.lan:32305 vpn-peer.example.net:51820           router.wanFIN_WAIT 706          4.0 MB     +ICMPlaptop.lan:63369 dns.google:51820                     router.wan 566          3.9 MB     +UDPdesktop.lan:43240 dns.google:3478                      router.wan 3,592        3.9 MB     +UDPlaptop.lan:52865 mail.example.org:22                  router.wan 543          3.9 MB     +TCPiot-hub.lan:1094 lb-140-82-121-4-fra.github.com:53    router.wanFIN_WAIT 1,812        3.9 MB     +ICMPdesktop.lan:32282 dns.google:80                        router.wan 863          3.8 MB     +TCPlaptop.lan:6831 vpn-peer.example.net:443             router.wanESTABLISHED 4,262        3.7 MB     +UDPiot-hub.lan:64474 one.one.one.one:80                   router.wan 1,765        3.7 MB     +UDPlaptop.lan:46100 vpn-peer.example.net:22              router.wan 2,354        3.6 MB     +TCPlaptop.lan:49347 mail.example.org:443                 router.wanTIME_WAIT 2,254        3.6 MB     +UDPdesktop.lan:26144 one.one.one.one:80                   router.wan 4,315        3.6 MB     +TCPlaptop.lan:21568 lb-140-82-121-4-fra.github.com:53    router.wanESTABLISHED 4,637        3.5 MB     +UDPlaptop.lan:28056 vpn-peer.example.net:51820           router.wan 3,973        3.4 MB     +TCPnas.lan:23963 vpn-peer.example.net:443             router.wanESTABLISHED 1,595        3.4 MB     +UDPnas.lan:27177 vpn-peer.example.net:53              router.wan 1,291        3.3 MB     +UDPiot-hub.lan:58488 one.one.one.one:3478                 router.wan 1,119        3.3 MB     +UDPlaptop.lan:45016 lb-140-82-121-4-fra.github.com:53    router.wan 3,542        3.3 MB     +TCPdesktop.lan:58538 vpn-peer.example.net:123             router.wanESTABLISHED 187          3.2 MB     +UDPdesktop.lan:31406 vpn-peer.example.net:123             router.wan 4,600        3.2 MB     +TCPnas.lan:7560 mail.example.org:3478                router.wanSYN_SENT 2,556        3.0 MB     +UDPlaptop.lan:50807 one.one.one.one:443                  router.wan 1,405        3.0 MB     +TCPnas.lan:3769 mail.example.org:51820               router.wanESTABLISHED 41           2.7 MB    ▅▅ +UDPlaptop.lan:39692 vpn-peer.example.net:3478            router.wan 1,119        2.4 MB     +TCPdesktop.lan:57156 lb-140-82-121-4-fra.github.com:22    router.wanTIME_WAIT 4,667        2.3 MB     +UDPnas.lan:12499 dns.google:53                        router.wan 4,848        2.1 MB     +TCPnas.lan:57695 vpn-peer.example.net:22              router.wanESTABLISHED 3,967        2.1 MB     +UDPiot-hub.lan:42190 one.one.one.one:443                  router.wan 2,687        2.0 MB     +TCPlaptop.lan:12965 vpn-peer.example.net:51820           router.wanSYN_SENT 340          1.9 MB     +TCPnas.lan:23106 mail.example.org:53                  router.wanESTABLISHED 555          1.9 MB     +TCPlaptop.lan:22924 dns.google:53                        router.wanESTABLISHED 4,963        1.8 MB     +TCPlaptop.lan:53086 vpn-peer.example.net:80              router.wanFIN_WAIT 1,045        1.7 MB     +ICMPdesktop.lan:57626 lb-140-82-121-4-fra.github.com:3478  router.wan 366          1.6 MB     +ICMPdesktop.lan:38607 vpn-peer.example.net:22              router.wan 1,848        1.6 MB     +TCPdesktop.lan:38437 one.one.one.one:123                  router.wanESTABLISHED 3,412        1.6 MB     +ICMPdesktop.lan:11593 mail.example.org:80                  router.wan 1,750        1.5 MB     +TCPnas.lan:29268 lb-140-82-121-4-fra.github.com:51820 router.wanESTABLISHED 2,956        1.4 MB     + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +Quit  p Pause  r Resolve  n Interval  z Zones  1 Overview  2 Connections  3 Firewall  4 Drops  w WAN conns  l LAN conns ^p palette▇▇ diff --git a/pyproject.toml b/pyproject.toml index ae7f1b3..6be66a0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "fwtop" -version = "0.2.0" +version = "0.2.1" description = "Real-time router/firewall traffic visualizer TUI for Linux (interfaces, conntrack, firewall drops)" readme = "README.md" authors = [ diff --git a/src/fwtop/app.py b/src/fwtop/app.py index 4178462..9a0d6b7 100644 --- a/src/fwtop/app.py +++ b/src/fwtop/app.py @@ -12,6 +12,7 @@ from fwtop.collector import Collector from fwtop.config import ZONE_CYCLE, Config from fwtop.resolve import Resolver +from fwtop.sources.demo import demo_ptr_lookup from fwtop.widgets import ( ConnectionsTable, ConntrackPanel, @@ -197,9 +198,17 @@ def compose(self) -> ComposeResult: yield Label("", id="status-label", markup=False) yield Footer() + def _make_resolver(self) -> Resolver: + # Production resolves every address (local or public) through the + # system DNS resolver. In demo mode the synthetic addresses don't + # exist, so swap in a fake DNS backend that returns their PTR records; + # the cache/queue path is identical either way. + lookup = demo_ptr_lookup if self.collector.demo else None + return Resolver(lookup=lookup) + def on_mount(self) -> None: if self.resolve_enabled: - self.resolver = Resolver() + self.resolver = self._make_resolver() self._start_time = monotonic() self._update_subtitle() @@ -306,7 +315,7 @@ def action_toggle_pause(self) -> None: def action_toggle_resolve(self) -> None: self.resolve_enabled = not self.resolve_enabled if self.resolve_enabled and self.resolver is None: - self.resolver = Resolver() + self.resolver = self._make_resolver() self._update_subtitle() def action_change_interval(self) -> None: diff --git a/src/fwtop/resolve.py b/src/fwtop/resolve.py index 47be60a..8e3cc40 100644 --- a/src/fwtop/resolve.py +++ b/src/fwtop/resolve.py @@ -3,20 +3,47 @@ import queue import socket import threading +from typing import Callable, Optional + + +def _ptr_lookup(ip: str) -> str: + """Resolve ``ip`` to a hostname via the system resolver (reverse DNS). + + Returns the IP unchanged when there is no PTR record or the lookup fails. + This goes through the OS resolver, so on a router whose local DNS serves + PTR records for its DHCP leases, private addresses resolve here too — no + hosts file or static map required. + """ + try: + return socket.gethostbyaddr(ip)[0] + except Exception: + return ip class Resolver: - """Background reverse-DNS resolver with a cache. + """Background reverse-DNS resolver with a program-local cache. - ``display(ip)`` returns the resolved hostname if it is already known, + ``display(ip)`` returns the resolved hostname if it is already cached, otherwise the IP itself while scheduling a lookup in the background so a later call can show the name. Lookups never block the caller — the UI keeps - showing the raw IP until the PTR record (if any) comes back. + showing the raw IP until the PTR record (if any) comes back. Every result + (including "no PTR -> the IP itself") is cached in an in-memory map, so each + address is looked up at most once. + + All resolution goes through DNS via ``lookup`` (the system resolver by + default). The hook exists only so tests and the synthetic ``--demo`` mode + can stand in a fake DNS backend for addresses that don't exist for real; it + is not a static names table. """ - def __init__(self, max_workers: int = 4) -> None: + def __init__( + self, + max_workers: int = 4, + lookup: Optional[Callable[[str], str]] = None, + ) -> None: self._cache: dict[str, str] = {} # ip -> hostname (or ip if no PTR) self._pending: set[str] = set() + self._lookup = lookup or _ptr_lookup self._lock = threading.Lock() self._queue: queue.Queue[str] = queue.Queue() self._stop = threading.Event() @@ -45,10 +72,7 @@ def _worker(self) -> None: ip = self._queue.get(timeout=0.5) except queue.Empty: continue - try: - host = socket.gethostbyaddr(ip)[0] - except Exception: - host = ip # no PTR record or lookup failed -> fall back to IP + host = self._lookup(ip) with self._lock: self._cache[ip] = host self._pending.discard(ip) diff --git a/src/fwtop/sources/demo.py b/src/fwtop/sources/demo.py index 52e3087..f74dbb1 100644 --- a/src/fwtop/sources/demo.py +++ b/src/fwtop/sources/demo.py @@ -20,6 +20,28 @@ _WAN_HOSTS = ("203.0.113.9", "198.51.100.23", "8.8.8.8", "1.1.1.1", "140.82.121.4") _WAN_IP = "203.0.113.2" # the router's public address (NAT egress) +# PTR answers a router's local DNS would return for the synthetic hosts. The +# demo's addresses are fictional (TEST-NET / RFC1918), so a real reverse-DNS +# lookup can't resolve them; demo_ptr_lookup() stands in as that DNS backend +# so the resolver still exercises its real cache/lookup path. +_DEMO_PTR = { + "192.168.1.10": "desktop.lan", + "192.168.1.42": "laptop.lan", + "192.168.1.77": "nas.lan", + "10.0.10.5": "iot-hub.lan", + "203.0.113.2": "router.wan", + "203.0.113.9": "vpn-peer.example.net", + "198.51.100.23": "mail.example.org", + "8.8.8.8": "dns.google", + "1.1.1.1": "one.one.one.one", + "140.82.121.4": "lb-140-82-121-4-fra.github.com", +} + + +def demo_ptr_lookup(ip: str) -> str: + """Stand-in DNS backend for the demo: return the simulated PTR or the IP.""" + return _DEMO_PTR.get(ip, ip) + class DemoSource: """Synthetic data source producing plausible, time-varying router metrics. diff --git a/uv.lock b/uv.lock index bd35982..f41091a 100644 --- a/uv.lock +++ b/uv.lock @@ -13,7 +13,7 @@ wheels = [ [[package]] name = "fwtop" -version = "0.2.0" +version = "0.2.1" source = { editable = "." } dependencies = [ { name = "psutil" },