Self-hosted local network monitor with 24-hour speed charts & sarcastic AI commentary delivered straight to Telegram or Discord.
A lightweight local bot that runs a speed test on your network every 30 minutes, scans active devices on your LAN using nmap, and logs everything to a local SQLite database.
Every 4 hours, it delivers a detailed report complete with a 24-hour trend graph and a sarcastic, LLM-generated commentary on your network's behavior ("someone's hogging the bandwidth again").
It also serves a local web dashboard (latest metrics, the 24-hour graph, a history table, an on-demand "run speed test now" button, and CSV export) that runs alongside Telegram/Discord — and ships with a Dockerfile + Compose file for one-command deployment.
Note
100% Private & Self-Hosted: No external metric servers involved — everything runs locally on your machine or Raspberry Pi. Only text reports and graph images are dispatched to your chosen notifier (Telegram or Discord).
Every 30 minutes (SLEEP_TIME in main.py, default 1800 seconds):
- Speed Test: Measures download/upload speeds, ping latency, ISP, and test server details using
speedtest-cli(see the note on measurement mode). - LAN Scan: Scans the local subnet using
nmapARP scan to count active connected devices. - Local Storage: Saves metrics & device tallies directly to a local
metrics.sqlSQLite database. - Status Alert: Sends a concise status update to your chosen notifier ("all good" or "line is dying").
- 24h AI Report: Every 8th cycle (every 4h), generates a 24-hour trend graph via
matplotlibalongside a sarcastic LLM analysis of network load and speed fluctuations.
| Technology | Purpose |
|---|---|
Python 3.13+ (via uv) |
Core runtime |
| SQLite | Local metrics persistence (metrics.sql) |
speedtest-cli |
Network bandwidth and ping measurements |
nmap |
Subnet ARP scanning for device discovery |
matplotlib |
24-hour metrics visualization |
| OpenAI-compatible API | Sarcastic report & trend analysis (cloud OpenAI or a local LLM) |
| Telegram API / Discord Webhooks | Alert and graph report delivery |
- OS: macOS or Linux (
nmap --iflistrequired; Windows not supported out of the box). - uv — manages the Python version, virtualenv, and locked dependencies for you. No manual
python3/venv/pipjuggling. - System Binaries:
nmapandspeedtest-cliinstalled system-wide. - Passwordless
sudofornmap— device counting needs a real ARP scan (raw sockets), which requires root; see one-time setup below. - Tokens: either a Telegram Bot Token + Chat ID, or a Discord Webhook URL (see Notifications), plus an API key for your OpenAI-compatible provider (not needed if you point
AI_BASE_URLat a local LLM server).
macOS (Homebrew):
brew install nmap speedtest-cliLinux (Debian/Ubuntu):
sudo apt update && sudo apt install -y nmap speedtest-cliDevice counting runs nmap as root for a real ARP scan — without it, host discovery silently falls back to ordinary TCP probing and undercounts devices that don't answer on common ports. Since the bot runs unattended, sudo needs to work without a password prompt on every cycle:
echo "$(whoami) ALL=(root) NOPASSWD: $(command -v nmap)" | sudo tee /etc/sudoers.d/netmon-nmap
sudo chmod 440 /etc/sudoers.d/netmon-nmapThis grants passwordless sudo only for the nmap binary — not your whole account.
Install uv if you don't have it yet:
curl -LsSf https://astral.sh/uv/install.sh | shThen:
git clone https://github.com/VincentFlagg/netmon.git
cd netmon
uv syncuv sync downloads the pinned Python version (see .python-version) if you don't already have it, creates .venv, and installs the exact locked dependency versions from uv.lock. No system python3, no manual venv activation.
Copy the template file and fill in your secrets:
cp .env.example .env.env variables:
| Variable | Description |
|---|---|
AI_API_KEY |
Optional (all-or-nothing). Your LLM provider API key (any string works for most local servers) |
AI_MODEL |
Optional. Model name (e.g. gpt-4o-mini, or a local model name — see below) |
AI_BASE_URL |
Optional. Base API URL (e.g., https://api.openai.com/v1, or your local server's URL) |
NOTIFIER |
telegram (default), discord, ntfy, or none (dashboard-only — see below) |
TG_BOT_TOKEN |
Telegram bot token from @BotFather — required if NOTIFIER=telegram |
TG_CHAT_ID |
Your Telegram Chat ID — required if NOTIFIER=telegram |
DISCORD_WEBHOOK_URL |
Discord channel webhook URL — required if NOTIFIER=discord |
NTFY_URL |
Full ntfy topic URL (e.g. https://ntfy.sh/my-topic) — required if NOTIFIER=ntfy |
NTFY_TOKEN |
Optional. ntfy bearer access token for protected topics |
NTFY_USER / NTFY_PASSWORD |
Optional. ntfy username + password (HTTP Basic auth) — an alternative to NTFY_TOKEN |
DB_PATH |
Required. SQLite database file path (e.g. metrics.sql) |
REQUEST_TIMEOUT |
Optional. HTTP timeout in seconds for Telegram/Discord requests (positive integer, default 30) |
WEB_ENABLED |
Optional. Serve the web dashboard (true/false, default true) |
WEB_HOST |
Optional. Dashboard bind address (default 0.0.0.0; use 127.0.0.1 for localhost-only) |
WEB_PORT |
Optional. Dashboard port (default 8080) |
ADMIN_USER / ADMIN_PASSWORD |
Optional. Set both to enable the authenticated /admin page (schedule, templates, AI prompt). Unset = admin disabled |
Tip
You're not locked into OpenAI. ai.py talks to any OpenAI-compatible endpoint, so a local inference server (e.g. Ollama, LM Studio) works too — just point AI_BASE_URL at it. For report quality that holds up, use a model with at least ~7B parameters; a solid local pick is Gemma 4 12B at 4-bit (QAT) quantization (gemma4:12b-it-qat via Ollama), which fits comfortably on 16GB of RAM.
The report runs only every 4 hours (~6 calls/day), so a free tier easily covers it. netmon needs an OpenAI-compatible endpoint — set all three AI_* variables (they're all-or-nothing). Good free choices:
| Provider | AI_BASE_URL |
Example AI_MODEL |
AI_API_KEY |
|---|---|---|---|
| Groq (fast, generous free tier) | https://api.groq.com/openai/v1 |
a current Groq model — see the note below | from console.groq.com |
| Google Gemini (free tier) | https://generativelanguage.googleapis.com/v1beta/openai/ |
gemini-2.0-flash |
from aistudio.google.com |
| OpenRouter (free models) | https://openrouter.ai/api/v1 |
meta-llama/llama-3.3-70b-instruct:free |
from openrouter.ai |
| Ollama (local, fully private) | http://<host>:11434/v1 |
gemma2:9b (or smaller on weak hardware) |
any non-empty string, e.g. ollama |
Example (Groq) in a compose environment: block:
AI_API_KEY: "gsk_your_groq_key"
AI_MODEL: "llama-3.1-8b-instant" # pick a model from your provider's current list
AI_BASE_URL: "https://api.groq.com/openai/v1"Notes:
- Model names change — providers deprecate and rename models, so a name that worked before can start returning
model_not_found. The example models above are illustrative, not guaranteed current. List what your key can actually use, e.g. for Groq:then pick one. The admin page's Test connection button reports the exact error (includingcurl -s https://api.groq.com/openai/v1/models -H "Authorization: Bearer YOUR_KEY" | grep '"id"'
model_not_found) so you can tell a bad model from a bad key or an unreachable endpoint. - Local (Ollama):
AI_API_KEYis ignored by Ollama but must still be non-empty. Withnetwork_mode: host, pointAI_BASE_URLat the Ollama host on your LAN (e.g.http://192.168.0.24:11434/v1). A low-power NAS (no GPU) can only run small models slowly — for a UGREEN N100, a cloud free tier is the easier path. - Only the 4-hour detailed report uses AI; the 30-minute status updates never call it. Leave the
AI_*variables unset to run with no AI commentary at all.
uv run main.pyuv run always uses this project's own .venv and pinned Python version, so it can't accidentally run against your system python3.
Tip
Run the bot inside tmux/screen or set it up as a system service (systemd/launchd) to keep it running 24/7 in the background.
Alongside the Telegram/Discord alerts, netmon serves a lightweight local web dashboard — no extra dependencies, it's built on Python's standard library. Once the bot is running, open:
http://<host>:8080
The dashboard shows:
- Latest metrics — a prominent ISP banner plus download, upload, ping, and device-count cards, next to the 24-hour graph.
- 24-hour graph — the same
matplotlibchart that goes to your notifier, refreshed automatically. - Device list — click the Devices card to see the devices discovered over the last 24 hours: online/offline, name (hostname), MAC address and vendor (from the
nmapARP scan), IP, latency, and when each was last seen. - History — the latest 10 measurements, with a View all link to a full
/historypage. - Run speed test now — a button that triggers an immediate measurement on demand (it stores the result and sends a mini report, without disturbing the scheduled 4-hour detailed-report cadence).
- Run AI report now (when AI is configured) — generate and send a detailed AI report on demand; the latest report is also shown on the dashboard.
- Download CSV — export the recent metrics as a spreadsheet-friendly file.
The page auto-refreshes every 30 seconds. Configure it with WEB_ENABLED, WEB_HOST, and WEB_PORT (see the .env table above), or set WEB_ENABLED=false to turn it off entirely.
Warning
The dashboard has no authentication and exposes a button that triggers speed tests plus a data export. Keep it on a trusted LAN. To restrict it to the local machine, set WEB_HOST=127.0.0.1; to expose it more widely, put it behind a reverse proxy that adds authentication. (The admin page below is separately password-protected.)
Set ADMIN_USER and ADMIN_PASSWORD to enable an authenticated /admin page (an ⚙ Admin link appears on the dashboard). It's protected by HTTP Basic auth and lets you change, without restarting the container:
- Schedule — the interval between runs, and an optional active window (only run during certain hours and days).
- Connection & plan — your subscribed download/upload speeds and ping thresholds, so the status line and AI judge performance relative to your plan (e.g. 50 Mbps on a 50 Mbps line is "good") instead of assuming a fast connection. Leave the plan at
0for the old absolute thresholds. - Report frequency — how many runs between detailed AI reports.
- Status message — the wording/format of the per-run update (with placeholders like
{download:.1f},{device_count},{status_text}). - AI connection — the base URL, model, and API key for the AI (any OpenAI-compatible endpoint). Blank fields fall back to the
AI_*env vars, so you can set up or fix the AI here without restarting. The stored key is never shown back — the page only indicates whether one is set. A Test connection button makes one real call and shows the exact result (success, or the actual error) so you can diagnose a failing AI from the UI. - AI behaviour — the system prompt that shapes the AI report (tone, structure, length).
Settings are stored in the database (on your volume), so they survive restarts and image updates. Changes take effect on the next run. If ADMIN_USER/ADMIN_PASSWORD are unset, the admin page is disabled entirely.
Warning
The admin page changes how netmon behaves, so protect it: use a strong ADMIN_PASSWORD, and since Basic-auth credentials cross the network in the clear over plain HTTP, keep it on a trusted LAN or behind an HTTPS reverse proxy.
netmon ships with a Dockerfile and docker-compose.yml. The image bundles nmap, speedtest-cli, and the locked Python dependencies, so you don't install anything on the host except Docker itself.
cp .env.example .env # fill in your tokens / API key
docker compose up -d --buildThen open http://localhost:8080. The SQLite database is persisted in a named volume (netmon-data), so your history survives rebuilds.
Pre-built images are published to ghcr.io/vincentflagg/netmon and tagged with semantic versions:
| Tag | Points to |
|---|---|
1.0.0 |
that exact release (pin this for reproducible deploys) |
1.0 |
the latest 1.0.x patch |
1 |
the latest 1.x release |
latest |
the newest release |
In an image-based stack (e.g. dockge-stack.yml), pin a version to control upgrades:
image: ghcr.io/vincentflagg/netmon:1.0.0 # instead of :latestSee CHANGELOG.md for what changed in each version. To publish a new version: bump version in pyproject.toml (and uv.lock), add a CHANGELOG entry, and push to main — the GitHub Action reads that version and tags the image X.Y.Z / X.Y / X / latest to match. (Pushing a v* git tag also triggers a matching build.)
Container image scanners (Grype, Trivy) report CVEs in the Debian base-image packages (openssl, gnutls, perl, glibc, …) — this is normal for any Debian/Ubuntu-based image, and none of them are in netmon's own code. In netmon's intended use (self-hosted on a trusted LAN, not exposed to the internet) the practical risk is low; the main rule is don't expose the dashboard to the internet.
The image applies available Debian security patches at build time (apt-get upgrade), and the publish workflow rebuilds weekly to pick up new fixes as Debian releases them. So:
- For automatic patches, track
:latestor the rolling:1/:1.6tags — the weekly rebuild refreshes these. - An exact tag like
:1.6.0is an immutable snapshot and is not re-patched. Pin it for reproducibility, but move it forward periodically for security.
CVEs marked with no fixed version can't be resolved until Debian patches them upstream — that's true of every current Debian-based image.
For a NAS, use the ready-made docker-compose.nas.yml variant instead — it swaps the named volume for a bind mount (./data, so the database is visible on your NAS shares and easy to back up) and makes the dashboard port overridable via WEB_PORT:
# in your stack manager's stacks dir, e.g. /opt/stacks or /volume1/docker/stacks
git clone https://github.com/VincentFlagg/netmon.git netmon
cd netmon && cp .env.example .env # then edit .env
docker compose -f docker-compose.nas.yml up -d --buildIn Dockge/Portainer, point the stack at docker-compose.nas.yml and deploy from the UI. Set WEB_PORT in .env (e.g. WEB_PORT=8081) if 8080 clashes with your NAS UI. Host networking still applies — see the note above.
UGREEN NAS + Dockge users: there's a full click-by-click walkthrough in INSTALL-NAS.md.
If you'd rather create a stack in the Dockge/Portainer UI and just paste a compose file, use dockge-stack.yml. It pulls a pre-built image from GHCR instead of building from source, and carries its config inline under environment: so there's no separate .env to manage — paste it, edit the placeholder values, deploy.
This requires the image to be published first. The included GitHub Action (.github/workflows/docker-publish.yml) builds and pushes it to ghcr.io/<owner>/netmon on every push (and on v* tags). After the first run, set the GHCR package to Public so your NAS can pull it without logging in.
Important
Host networking is required. The Compose file uses network_mode: host plus the NET_RAW/NET_ADMIN capabilities so the nmap ARP scan can see real devices on your LAN. A container on Docker's default bridge network is behind NAT and can only scan the bridge subnet, which would make device counts meaningless.
Host networking is Linux-only — it does not work on Docker Desktop for macOS/Windows. On those platforms, run netmon directly with uv run main.py instead (the LAN scan needs to be on the same network as your devices anyway).
Because the container already runs as root, the passwordless-sudo setup from the Quick Start is not needed inside Docker.
A real-world stack — pre-built image, ntfy notifier, AI disabled, and the database bind-mounted to a NAS folder:
services:
netmon: # service name
image: ghcr.io/vincentflagg/netmon:latest # pre-built image; pin e.g. :1.0.0 for reproducible upgrades
container_name: netmon # fixed container name shown in Docker/Dockge
restart: unless-stopped # auto-restart on crash or NAS reboot
network_mode: host # REQUIRED: lets the nmap LAN scan see real devices
cap_add: # raw-socket permissions the ARP scan needs
- NET_RAW
- NET_ADMIN
environment:
# --- AI commentary (OPTIONAL — set all three, or comment out all three) ---
# AI_API_KEY: "sk-..." # commented out here => reports have no AI text
# AI_MODEL: "gpt-4o-mini"
# AI_BASE_URL: "https://api.openai.com/v1"
# --- Notifier: keep only the block for your choice ---
# ("telegram" | "discord" | "ntfy" | "none")
NOTIFIER: "ntfy"
NTFY_URL: "http://192.168.0.24:8085/Netmon" # full ntfy topic URL (http:// if your server has no TLS)
NTFY_USER: "user" # omit NTFY_USER/PASSWORD for a public/unprotected topic
NTFY_PASSWORD: "password"
# --- Storage & web dashboard ---
DB_PATH: "/data/metrics.sql" # DB path INSIDE the container (not the host path)
WEB_HOST: "0.0.0.0" # bind on all interfaces
WEB_PORT: "8080" # dashboard at http://<nas-ip>:8080
volumes:
- /volume2/docker/data:/data # <host folder>:<container folder>What each line does:
| Line | Meaning / how to use it |
|---|---|
image: |
Which published image to run. :latest follows the newest release; pin :1.0.0 (or :1.0) so upgrades only happen when you change this. |
container_name: |
The name Docker/Dockge shows. Cosmetic. |
restart: unless-stopped |
Restarts the container automatically unless you stop it by hand. Leave as-is. |
network_mode: host |
Mandatory. The container shares the NAS's network so nmap can scan your real LAN. Do not remove; with it, WEB_PORT binds directly on the NAS and no ports: mapping is used. |
cap_add: NET_RAW / NET_ADMIN |
Grants the raw-socket access the ARP scan needs. Leave as-is. |
AI_API_KEY / AI_MODEL / AI_BASE_URL |
Optional, all-or-nothing. Set all three for the sarcastic AI report text; comment out all three (as here) to run without AI. |
NOTIFIER |
Which service gets alerts: telegram, discord, ntfy, or none (dashboard-only). Keep only the credential lines for the one you pick — the others are ignored, but it's cleaner to delete them. |
NTFY_URL |
The full ntfy topic URL. Use http:// if your ntfy server has no TLS (a https:// typo on a plain-HTTP server gives an SSL "wrong version number" error). |
NTFY_USER / NTFY_PASSWORD |
ntfy Basic-auth credentials, only for protected topics. Omit both for a public topic, or use NTFY_TOKEN instead. |
DB_PATH |
Where the SQLite file lives inside the container. Keep it under /data so it lands on the mounted volume. |
WEB_HOST / WEB_PORT |
Dashboard bind address and port. Change WEB_PORT if 8080 clashes with something on the NAS. |
volumes: - /volume2/docker/data:/data |
Maps a host folder (left) to the container's /data (right). Combined with DB_PATH=/data/metrics.sql, your database is at /volume2/docker/data/metrics.sql on the NAS — that's where to look for it (plus its -wal / -shm WAL companions). Change the left side to any real path on your NAS. |
Tip
Where's my database file? With the volume above, it's /volume2/docker/data/metrics.sql on the NAS. DB_PATH is the path inside the container; the volumes: mapping is what puts it on disk. netmon uses SQLite WAL mode, so expect three files: metrics.sql, metrics.sql-wal, metrics.sql-shm. Check with docker exec netmon ls -la /data.
netmon supports three notification backends, selected via the NOTIFIER variable in .env. Only one is needed — or you can turn notifications off entirely.
Don't want any push service? Set NOTIFIER=none. netmon still runs its speed tests and LAN scans on schedule and stores everything, but pushes nothing out — you read the results on the web dashboard instead. In this mode you don't need any notifier values, and the AI variables are optional too (leave all three AI_* empty to skip the AI commentary). NOTIFIER=none requires WEB_ENABLED=true (otherwise there'd be no output at all).
ntfy is a dead-simple pub-sub push service — great for a NAS, and self-hostable. There's no bot or webhook setup: just pick a hard-to-guess topic name.
- Install the ntfy app (Android/iOS) or open the web app, and subscribe to a topic — any unique name, e.g.
my-netmon-a8f3z2. - In
.env:NOTIFIER=ntfy NTFY_URL=https://ntfy.sh/my-netmon-a8f3z2
Protected topics (a reserved topic on ntfy.sh, or a server with auth) need credentials — use one of:
NTFY_TOKEN=tk_... # a bearer access token, OR
NTFY_USER=myuser # username + password (HTTP Basic auth)
NTFY_PASSWORD=mypassword
A public/unreserved topic needs no auth at all. Point NTFY_URL at your own server instead (e.g. https://ntfy.example.com/netmon) if you self-host. Reports are sent as plain text (the HTML formatting is stripped), and the 4-hour graph arrives as an image attachment.
- Message
@BotFatheron Telegram and send/newbot, following the prompts to get a bot token. - Get your Chat ID — the simplest way is to message your new bot, then visit
https://api.telegram.org/bot<YOUR_TOKEN>/getUpdatesin a browser and read thechat.idfield from the JSON response.
- Message
@BotFatheron Telegram and send/newbot, following the prompts to get a bot token. - Get your Chat ID — the simplest way is to message your new bot, then visit
https://api.telegram.org/bot<YOUR_TOKEN>/getUpdatesin a browser and read thechat.idfield from the JSON response. - In
.env:NOTIFIER=telegram TG_BOT_TOKEN=123456789:AAHfoo... TG_CHAT_ID=987654321
If NOTIFIER is left unset, netmon defaults to Telegram, so existing setups keep working with no changes.
- In your target Discord channel: Server Settings → Integrations → Webhooks → New Webhook, then copy the webhook URL. No bot invite or permissions setup needed.
- In
.env:NOTIFIER=discord DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/xxxx/yyyy
Discord delivery reuses the same report content as Telegram — the existing HTML formatting (<b>, <code>, <pre>) is automatically converted to Discord markdown, so reports render correctly in either service without any changes to the AI prompt.
Warning
Treat both the Telegram bot token and the Discord webhook URL as secrets — anyone with either can post messages through your bot/webhook. Don't commit them to version control (.env is already git-ignored).
netmon runs speedtest --secure --single --json (see runner.py) — the --single flag means the test uses one TCP connection. This is deliberate: a single stream approximates what one real application on your network would actually get, since it is subject to the same window-size and packet-loss limits any ordinary download faces.
Multi-threaded speed tests (including Ookla's official CLI, and the speedtest.net web UI) open many parallel connections instead. That measures something different — the practical ceiling of your line — and will report noticeably higher numbers on fast connections. Neither figure is "wrong"; they answer different questions.
Two consequences worth knowing:
- Don't compare netmon's numbers directly against speedtest.net in a browser. The browser test is multi-threaded and will read higher. That gap is methodology, not a fault in your line.
- On very fast links (roughly 500 Mbps+), expect single-stream figures to sit well below your subscribed speed. Beyond the methodology gap,
speedtest-cliis pure Python, so at gigabit speeds its own CPU overhead starts contributing too.
Since netmon exists to track trends, consistency matters more than peak numbers: keep one measurement method for the lifetime of your database. Swapping the backend mid-history puts a step change in your 24-hour graph that the AI commentary will faithfully report as a real speed jump.
Network Status Update
Time: 2026-07-21 14:00:00
ISP: MyISP | Server: New York
Devices online: 7
Download: 145.2 Mbps
Upload: 62.1 Mbps
Latency: 14.8 ms
Traffic used: 160.0 MB down / 70.0 MB up
Current status: Good speed and low latency
Every 4 hours, the bot sends a 24-hour matplotlib graph accompanied by a sarcastic LLM-generated report:
<b>Network Speed Test Report (24h Analysis)</b>
Client: <b>MyISP</b>
Server: <b>New York</b>
<b>Latest Test Metrics</b>
<pre>
Download: 178.5 Mbps
Upload: 45.2 Mbps
Ping: 23.1 ms
Devices Online: 9
</pre>
<b>24-Hour Dynamics Analysis</b>
Over the last 24 hours, the download speed averaged <code>140 Mbps</code>, but we saw a massive drop to <code>20 Mbps</code> at 8:00 PM right as device count jumped from <code>4</code> to <code>11 devices</code>. Clearly, someone's hogging the bandwidth or the ISP's mice were busy chewing on the fiber line again. Latency remained stable except for a brief spike during peak hours.
<b>Data Transfer (Latest Test)</b>
<pre>
Downloaded: 160.0 MB
Uploaded: 70.0 MB
</pre>
<b>Conclusion</b>
Expect periodic speed drops whenever local freeloaders stream 4K movies or the ISP potato infrastructure struggles.netmon/
├── assets/ # Logo & documentation media assets
├── graphs/ # Generated 24h matplotlib graph images
├── main.py # Entry point: wires everything & runs the scheduler loop
├── service.py # Monitor: one measurement cycle, shared by loop & web
├── settings.py # Runtime-editable settings (admin page), DB-backed
├── templates.py # Default message templates & AI system prompt
├── webapp.py # Standard-library web dashboard + admin page
├── runner.py # Speedtest-cli and nmap scan execution & parsing
├── sqlite.py # SQLite database operations & schema management
├── models.py # Domain data models (NetworkMetric, SpeedTest)
├── graphs.py # Matplotlib graph rendering engine
├── ai.py # OpenAI API client & sarcastic text generator
├── tg.py # Telegram bot dispatch helper
├── discord_hook.py # Discord webhook dispatch helper
├── ntfy_hook.py # ntfy topic dispatch helper
├── config.py # Environment variable validation & config
├── notifier.py # Notifier protocol & shared chat-action enum
├── Dockerfile # Container image (bundles nmap + speedtest-cli)
├── docker-compose.yml # One-command deploy with host networking & volume
├── pyproject.toml # Project metadata & dependencies
├── uv.lock # Locked, reproducible dependency versions
└── LICENSE # MIT License file
Distributed under the MIT License. See LICENSE for more details.




