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 │
-│▏│
-╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
-q 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 │
+│▏│
+╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
+q 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" },