This codebase is a FastAPI web app + APScheduler service that periodically scans a configured TARGET_HOST for open TCP/UDP ports (Rustscan for fast TCP discovery, nmap for UDP and optional service/version enrichment), stores results and diffs in an SQLite database, exposes a dashboard + REST API to view scan history/progress, and sends notifications (email/webhook) when ports change or expected ports go missing. It also runs lightweight host online checks between full scans and includes special handling for “silent” UDP services like WireGuard (stealth/probe verification).
- Full spectrum port scanning: TCP (1-65535) and UDP (top 1000 ports)
- Online/offline detection: Quick checks every 15 minutes
- Automated scheduling: Configurable scan intervals (default: 2 hours)
- Expected ports monitoring: Alert when critical services go down
- Stealth service detection: Track services like WireGuard that don't respond to probes
- WireGuard verification: Optional handshake probe to verify VPN is running
- Email notifications: SMTP alerts with HTML reports
- Webhook notifications: Discord and Slack compatible
- Web dashboard: Real-time status and scan history
- REST API: Programmatic access to scan data
- Docker deployment: Easy setup with docker-compose
# Clone the repository
git clone git@github.com:luukverhoeven/ld-host-scanner.git
cd ld-host-scanner
# Copy example environment file
cp .env.example .env
# Edit configuration
nano .envEdit .env to add your notification settings:
# Email (SMTP)
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=your-email@gmail.com
SMTP_PASSWORD=your-app-password
SMTP_FROM=LD Host Scanner <your-email@gmail.com>
SMTP_TO=alerts@example.com
# Discord webhook
WEBHOOK_URL=https://discord.com/api/webhooks/xxx/yyy
# Or Slack webhook
WEBHOOK_URL=https://hooks.slack.com/services/xxx/yyy/zzz# Build and start the container
docker-compose build && docker-compose up -d
# View logs
docker-compose logs -f
# Stop the container
docker-compose down# Build with version metadata
docker build -f docker/Dockerfile \
--build-arg VERSION=1.1.0 \
--build-arg BUILD_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ") \
--build-arg GIT_COMMIT=$(git rev-parse --short HEAD) \
-t ld-host-scanner:1.1.0 .Open your browser to: http://localhost:8080
| Variable | Default | Description |
|---|---|---|
TARGET_HOST |
example.com |
Host to scan (must be configured) |
SCAN_INTERVAL_HOURS |
2 |
Full scan frequency |
HOST_CHECK_INTERVAL_MINUTES |
15 |
Host online check frequency |
SMTP_HOST |
- | SMTP server hostname |
SMTP_PORT |
587 |
SMTP server port |
SMTP_USER |
- | SMTP username |
SMTP_PASSWORD |
- | SMTP password |
SMTP_FROM |
- | Email sender address |
SMTP_TO |
- | Email recipient address |
WEBHOOK_URL |
- | Discord/Slack webhook URL |
TZ |
Europe/Amsterdam |
Timezone |
LOG_LEVEL |
INFO |
Logging level |
TCP_SERVICE_ENRICHMENT |
true |
Run targeted TCP nmap -sV on Rustscan hits |
TCP_SERVICE_ENRICHMENT_INTENSITY |
light |
TCP version detection intensity (`light |
TCP_SERVICE_ENRICHMENT_PORTS_LIMIT |
200 |
Max TCP ports to version-scan per run |
UDP_TOP_PORTS |
1000 |
UDP nmap --top-ports count when no range is set |
UDP_VERSION_DETECTION |
true |
Run UDP version detection on prioritized ports |
UDP_VERSION_DETECTION_INTENSITY |
light |
UDP version detection intensity (`light |
UDP_VERSION_DETECTION_PORTS_LIMIT |
50 |
Max UDP ports to version-scan per run |
EXPECTED_PORTS |
- | Ports that should be open (e.g., 80/tcp,443/tcp,448/udp) |
WIREGUARD_PUBLIC_KEY |
- | WireGuard server public key (base64) for probe verification |
WIREGUARD_PROBE_PORTS |
- | Ports to probe for WireGuard (e.g., 448,51820) |
WIREGUARD_SCANNER_PRIVATE_KEY |
- | Scanner's private key (base64) for authenticated probes |
- Full scan: Every 2 hours (configurable via
SCAN_INTERVAL_HOURS) - Host check: Every 15 minutes (quick online/offline check)
- Initial scan: Runs immediately on container startup
Monitor critical services and get alerts when they go down:
# In .env
EXPECTED_PORTS=80/tcp,443/tcp,22/tcp,448/udpFeatures:
- UDP ports are explicitly scanned even if not in nmap's top-N (e.g., port 448)
- Alerts only on state change - notifies when a port goes from open to closed
- Dashboard shows status - expected ports card with open/missing indicators
Some services like WireGuard are designed to be "silent" - they don't respond to port scans. The scanner handles these with special detection:
Stealth Detection:
- Ports that return "open|filtered" AND are in your expected list are marked as "stealth"
- Dashboard shows a yellow "stealth" badge with tooltip
- This means: "We expect this service to be running, but can't verify it"
WireGuard Verification (Recommended):
For reliable WireGuard monitoring, set up authenticated probes. This requires:
- The WireGuard server's public key
- A scanner keypair (the scanner must be added as a peer on the server)
Step 1: Generate scanner keypair
docker-compose run --rm --no-deps ld-host-scanner python3 -c "
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
import base64
private_key = X25519PrivateKey.generate()
print('Private (for .env):', base64.b64encode(private_key.private_bytes_raw()).decode())
print('Public (for server):', base64.b64encode(private_key.public_key().public_bytes_raw()).decode())
"Step 2: Add scanner as peer on WireGuard server
# On your WireGuard server
sudo wg set wg0 peer <SCANNER_PUBLIC_KEY> allowed-ips 10.255.255.254/32Step 3: Configure scanner .env
# WireGuard monitoring
EXPECTED_PORTS=446/udp
WIREGUARD_PROBE_PORTS=446
WIREGUARD_PUBLIC_KEY="<server-public-key-base64>"
WIREGUARD_SCANNER_PRIVATE_KEY="<scanner-private-key-base64>"How it works:
- Scanner sends a cryptographically valid WireGuard handshake initiation
- Server recognizes the scanner as a known peer and responds
- Response confirms WireGuard is running (marked as "verified")
- If no response, port is marked "stealth" and alerts are sent
Without scanner keypair: The scanner can only detect "no ICMP rejection" which indicates something is listening, but can't confirm it's actually WireGuard responding. Firewalls that drop packets silently will cause false positives.
| Endpoint | Method | Description |
|---|---|---|
/ |
GET | Dashboard |
/history |
GET | Scan history |
/health |
GET | Health check (includes version) |
/version |
GET | Version information |
/api/status |
GET | Current target status |
/api/scans |
GET | List recent scans |
/api/scans/{id} |
GET | Get scan details |
/api/scans/trigger |
POST | Trigger manual scan |
/api/changes |
GET | Port change history |
/api/jobs |
GET | Scheduled jobs info |
/api/host-status-history |
GET | Host uptime history (for chart) |
/api/port-history |
GET | Open port count history (for chart) |
/docs |
GET | API documentation (Swagger) |
curl http://localhost:8080/version
# {"version":"1.1.0","build_date":null,"git_commit":null}
# Or via health endpoint
curl http://localhost:8080/health
# {"status":"healthy","scheduler_running":true,"version":"1.1.0","message":"OK"}curl -X POST http://localhost:8080/api/scans/triggercurl http://localhost:8080/api/statusThe container requires specific Linux capabilities for nmap to function:
NET_RAW: TCP SYN scans, ICMP pingNET_ADMIN: OS detection, advanced scans
These are configured in docker-compose.yml.
Tests run inside Docker to ensure the correct environment with all dependencies:
# Run all tests
docker-compose run --rm --no-deps -v "$(pwd)/tests:/app/tests" ld-host-scanner \
sh -c "pip install -q pytest pytest-asyncio && python -m pytest tests/ -v"
# Run with coverage
docker-compose run --rm --no-deps -v "$(pwd)/tests:/app/tests" ld-host-scanner \
sh -c "pip install -q pytest pytest-asyncio pytest-cov && python -m pytest tests/ --cov=src --cov-report=term-missing"
# Run a single test file
docker-compose run --rm --no-deps -v "$(pwd)/tests:/app/tests" ld-host-scanner \
sh -c "pip install -q pytest pytest-asyncio && python -m pytest tests/test_port_scanner.py -v"ld-host-scanner/
├── docker/
│ └── Dockerfile
├── docker-compose.yml
├── src/
│ ├── main.py # Entry point
│ ├── config.py # Configuration
│ ├── scanner/ # Port scanning logic
│ ├── storage/ # Database layer
│ ├── notifications/ # Email & webhook alerts
│ ├── scheduler/ # Job scheduling
│ └── web/ # FastAPI dashboard
├── tests/ # Test suite (pytest)
├── data/ # Persistent data (SQLite)
├── requirements.txt
├── .env.example
└── README.md
HTML-formatted emails include:
- Host status (online/offline)
- List of open ports with service detection
- Port changes (newly opened/closed)
Rich embeds showing:
- Host status
- Open port count
- Port changes with color coding (red = opened, green = closed)
- Port details with service names
Check logs:
docker-compose logs ld-host-scannerVerify the scheduler is running:
curl http://localhost:8080/api/jobs- Check SMTP settings in
.env - For Gmail, use an App Password
- Check logs for SMTP errors
Ensure the container has proper capabilities:
cap_add:
- NET_RAW
- NET_ADMIN- Container runs as non-root user with limited capabilities
- Credentials stored in environment variables (not in code)
- SQLite database persisted in mounted volume
- No scanning of private/internal networks by default
MIT
