masscan: active RTSP (554) camera discovery service
Continuous whole-IPv4 rolling sweep for open TCP 554, feeding the same
cameras table as the passive scraper (discovery_source=masscan).
- masscan_config.py: env-driven knobs (range, ports, rate, retries, excludes)
- masscan_scanner.py: JSON-lines parser, rtsp://IP/ URL + url_hash dedupe,
ip-api geolocation, insert/refresh, NATS publish for new finds
- run_masscan_service.py: long-lived rolling-sweep runner (streams results
in, restarts on pass completion); fails closed without an excludefile
- deploy/: systemd unit + README + excludes file for the Pi host
- .env.example: masscan section
Verified end-to-end against a local Postgres: parse, insert, and dedupe
(0 new on re-ingest) all pass.
2026-08-24 22:23:36 -04:00
|
|
|
"""masscan result parsing + ingestion for the OSINT dashboard.
|
|
|
|
|
|
|
|
|
|
Turns a stream of masscan JSON-lines (open port 554 hosts) into rows in the
|
|
|
|
|
`cameras` table with discovery_source='masscan', deduped by URL hash against
|
|
|
|
|
whatever the passive scraper already found. Newly discovered hosts are also
|
|
|
|
|
published to NATS (`events.camera`) so the shared ingester pipeline persists
|
|
|
|
|
them exactly like scraper finds.
|
|
|
|
|
|
|
|
|
|
Scope: detection of OPEN hosts only. No credentials, no banners, no feed
|
|
|
|
|
access. Private/reserved ranges never enter masscan (see excludefile).
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import asyncio
|
|
|
|
|
import json
|
|
|
|
|
import logging
|
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
|
|
|
|
|
|
from camera_models import cameras
|
|
|
|
|
from camera_scraper import url_hash, geolocate_ips
|
|
|
|
|
from database import async_session
|
|
|
|
|
|
|
|
|
|
from masscan_config import (
|
|
|
|
|
MASSCAN_NATS_SUBJECT, MASSCAN_DISCOVERY_SOURCE,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger("osint.masscan_scanner")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── URL building ──────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
def build_rtsp_url(ip: str) -> str:
|
|
|
|
|
"""Canonical URL for an open-RTSP host. Used as the dedupe key."""
|
|
|
|
|
return f"rtsp://{ip}/"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── masscan JSON parsing ──────────────────────────────────────────────────
|
|
|
|
|
# masscan --output-format=json --output-file=- emits line-delimited JSON on a
|
|
|
|
|
# pipe (a bare object per open host), not the array form used for seekable
|
|
|
|
|
# files. We parse per-line and tolerate an accidental leading '['.
|
|
|
|
|
|
|
|
|
|
def parse_masscan_line(line: str) -> list[dict]:
|
|
|
|
|
"""Parse one masscan stdout line into a list of host records.
|
|
|
|
|
|
|
|
|
|
A line may contain one JSON object or, defensively, be wrapped in an
|
|
|
|
|
array. Returns [] on anything unparseable (harmless — the sweep repeats).
|
|
|
|
|
"""
|
|
|
|
|
s = line.strip()
|
|
|
|
|
if not s:
|
|
|
|
|
return []
|
|
|
|
|
s = s.lstrip("[").rstrip("]").strip()
|
|
|
|
|
if not s:
|
|
|
|
|
return []
|
|
|
|
|
# Multiple records may share a line separated by '},{'.
|
|
|
|
|
if s.endswith(","):
|
|
|
|
|
s = s[:-1].rstrip()
|
|
|
|
|
out: list[dict] = []
|
|
|
|
|
for cand in _split_records(s):
|
|
|
|
|
try:
|
|
|
|
|
obj = json.loads(cand)
|
|
|
|
|
except (json.JSONDecodeError, ValueError):
|
|
|
|
|
continue
|
|
|
|
|
if isinstance(obj, dict) and obj.get("ip"):
|
|
|
|
|
out.append(obj)
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _split_records(s: str) -> list[str]:
|
|
|
|
|
"""Split a buffer into individual JSON object strings, honoring nesting."""
|
|
|
|
|
records, depth, start = [], 0, 0
|
|
|
|
|
for i, ch in enumerate(s):
|
|
|
|
|
if ch == "{":
|
|
|
|
|
if depth == 0:
|
|
|
|
|
start = i
|
|
|
|
|
depth += 1
|
|
|
|
|
elif ch == "}":
|
|
|
|
|
depth -= 1
|
|
|
|
|
if depth == 0:
|
|
|
|
|
records.append(s[start:i + 1])
|
|
|
|
|
return records
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def extract_open_ips(records: list[dict], port: int) -> list[str]:
|
|
|
|
|
"""Return the list of IPs from records that have `port` open."""
|
|
|
|
|
ips: list[str] = []
|
|
|
|
|
for rec in records:
|
|
|
|
|
for p in rec.get("ports", []):
|
|
|
|
|
if p.get("port") == port and p.get("status") == "open":
|
|
|
|
|
ips.append(rec["ip"])
|
|
|
|
|
break
|
|
|
|
|
return ips
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── Persistence ───────────────────────────────────────────────────────────
|
|
|
|
|
|
2026-08-25 22:05:15 -04:00
|
|
|
async def ingest_open_hosts(ips: list[str]) -> tuple[int, list[str]]:
|
|
|
|
|
"""Insert-or-refresh camera rows for open RTSP hosts that have a public feed.
|
masscan: active RTSP (554) camera discovery service
Continuous whole-IPv4 rolling sweep for open TCP 554, feeding the same
cameras table as the passive scraper (discovery_source=masscan).
- masscan_config.py: env-driven knobs (range, ports, rate, retries, excludes)
- masscan_scanner.py: JSON-lines parser, rtsp://IP/ URL + url_hash dedupe,
ip-api geolocation, insert/refresh, NATS publish for new finds
- run_masscan_service.py: long-lived rolling-sweep runner (streams results
in, restarts on pass completion); fails closed without an excludefile
- deploy/: systemd unit + README + excludes file for the Pi host
- .env.example: masscan section
Verified end-to-end against a local Postgres: parse, insert, and dedupe
(0 new on re-ingest) all pass.
2026-08-24 22:23:36 -04:00
|
|
|
|
2026-08-25 22:05:15 -04:00
|
|
|
A host only lands in the table (and therefore on the map) if an
|
|
|
|
|
unauthenticated HTTP still or MJPEG URL responds. Port-554-only hosts
|
|
|
|
|
are skipped. Returns (newly_inserted, hosts_with_working_feed).
|
masscan: active RTSP (554) camera discovery service
Continuous whole-IPv4 rolling sweep for open TCP 554, feeding the same
cameras table as the passive scraper (discovery_source=masscan).
- masscan_config.py: env-driven knobs (range, ports, rate, retries, excludes)
- masscan_scanner.py: JSON-lines parser, rtsp://IP/ URL + url_hash dedupe,
ip-api geolocation, insert/refresh, NATS publish for new finds
- run_masscan_service.py: long-lived rolling-sweep runner (streams results
in, restarts on pass completion); fails closed without an excludefile
- deploy/: systemd unit + README + excludes file for the Pi host
- .env.example: masscan section
Verified end-to-end against a local Postgres: parse, insert, and dedupe
(0 new on re-ingest) all pass.
2026-08-24 22:23:36 -04:00
|
|
|
"""
|
|
|
|
|
if not ips:
|
2026-08-25 22:05:15 -04:00
|
|
|
return 0, []
|
|
|
|
|
from camera_preview import probe_public_feed
|
|
|
|
|
|
|
|
|
|
unique = list(dict.fromkeys(ips))
|
|
|
|
|
sem = asyncio.Semaphore(20)
|
|
|
|
|
|
|
|
|
|
async def _probe(ip: str) -> tuple[str, str | None]:
|
|
|
|
|
async with sem:
|
|
|
|
|
return ip, await probe_public_feed(ip)
|
|
|
|
|
|
|
|
|
|
probed = await asyncio.gather(*(_probe(ip) for ip in unique))
|
|
|
|
|
live = [(ip, feed) for ip, feed in probed if feed]
|
|
|
|
|
if not live:
|
|
|
|
|
logger.info("masscan ingest: 0 working feeds of %d open-554 hosts",
|
|
|
|
|
len(unique))
|
|
|
|
|
return 0, []
|
|
|
|
|
|
masscan: active RTSP (554) camera discovery service
Continuous whole-IPv4 rolling sweep for open TCP 554, feeding the same
cameras table as the passive scraper (discovery_source=masscan).
- masscan_config.py: env-driven knobs (range, ports, rate, retries, excludes)
- masscan_scanner.py: JSON-lines parser, rtsp://IP/ URL + url_hash dedupe,
ip-api geolocation, insert/refresh, NATS publish for new finds
- run_masscan_service.py: long-lived rolling-sweep runner (streams results
in, restarts on pass completion); fails closed without an excludefile
- deploy/: systemd unit + README + excludes file for the Pi host
- .env.example: masscan section
Verified end-to-end against a local Postgres: parse, insert, and dedupe
(0 new on re-ingest) all pass.
2026-08-24 22:23:36 -04:00
|
|
|
now = datetime.now(timezone.utc)
|
2026-08-25 22:05:15 -04:00
|
|
|
coords = await geolocate_ips([ip for ip, _ in live])
|
masscan: active RTSP (554) camera discovery service
Continuous whole-IPv4 rolling sweep for open TCP 554, feeding the same
cameras table as the passive scraper (discovery_source=masscan).
- masscan_config.py: env-driven knobs (range, ports, rate, retries, excludes)
- masscan_scanner.py: JSON-lines parser, rtsp://IP/ URL + url_hash dedupe,
ip-api geolocation, insert/refresh, NATS publish for new finds
- run_masscan_service.py: long-lived rolling-sweep runner (streams results
in, restarts on pass completion); fails closed without an excludefile
- deploy/: systemd unit + README + excludes file for the Pi host
- .env.example: masscan section
Verified end-to-end against a local Postgres: parse, insert, and dedupe
(0 new on re-ingest) all pass.
2026-08-24 22:23:36 -04:00
|
|
|
new = 0
|
|
|
|
|
async with async_session() as session:
|
2026-08-25 22:05:15 -04:00
|
|
|
for ip, feed in live:
|
masscan: active RTSP (554) camera discovery service
Continuous whole-IPv4 rolling sweep for open TCP 554, feeding the same
cameras table as the passive scraper (discovery_source=masscan).
- masscan_config.py: env-driven knobs (range, ports, rate, retries, excludes)
- masscan_scanner.py: JSON-lines parser, rtsp://IP/ URL + url_hash dedupe,
ip-api geolocation, insert/refresh, NATS publish for new finds
- run_masscan_service.py: long-lived rolling-sweep runner (streams results
in, restarts on pass completion); fails closed without an excludefile
- deploy/: systemd unit + README + excludes file for the Pi host
- .env.example: masscan section
Verified end-to-end against a local Postgres: parse, insert, and dedupe
(0 new on re-ingest) all pass.
2026-08-24 22:23:36 -04:00
|
|
|
url = build_rtsp_url(ip)
|
|
|
|
|
h = url_hash(url)
|
|
|
|
|
lat, lon = coords.get(ip, (None, None))
|
|
|
|
|
existing = (await session.execute(
|
|
|
|
|
cameras.select().where(cameras.c.url_hash == h)
|
|
|
|
|
)).one_or_none()
|
|
|
|
|
if existing is None:
|
|
|
|
|
await session.execute(cameras.insert().values(
|
|
|
|
|
url_hash=h,
|
|
|
|
|
source_url=url,
|
2026-08-25 22:05:15 -04:00
|
|
|
snapshot_url=feed,
|
masscan: active RTSP (554) camera discovery service
Continuous whole-IPv4 rolling sweep for open TCP 554, feeding the same
cameras table as the passive scraper (discovery_source=masscan).
- masscan_config.py: env-driven knobs (range, ports, rate, retries, excludes)
- masscan_scanner.py: JSON-lines parser, rtsp://IP/ URL + url_hash dedupe,
ip-api geolocation, insert/refresh, NATS publish for new finds
- run_masscan_service.py: long-lived rolling-sweep runner (streams results
in, restarts on pass completion); fails closed without an excludefile
- deploy/: systemd unit + README + excludes file for the Pi host
- .env.example: masscan section
Verified end-to-end against a local Postgres: parse, insert, and dedupe
(0 new on re-ingest) all pass.
2026-08-24 22:23:36 -04:00
|
|
|
discovery_source=MASSCAN_DISCOVERY_SOURCE,
|
|
|
|
|
location_lat=lat,
|
|
|
|
|
location_lon=lon,
|
|
|
|
|
location_name=f"{ip} (IP-geo)" if lat is not None else None,
|
|
|
|
|
vendor=None,
|
|
|
|
|
device_type="rtsp",
|
|
|
|
|
first_seen=now,
|
|
|
|
|
last_seen=now,
|
2026-08-25 22:05:15 -04:00
|
|
|
raw={"discovered_via": "masscan", "port": 554,
|
|
|
|
|
"public_feed": feed},
|
masscan: active RTSP (554) camera discovery service
Continuous whole-IPv4 rolling sweep for open TCP 554, feeding the same
cameras table as the passive scraper (discovery_source=masscan).
- masscan_config.py: env-driven knobs (range, ports, rate, retries, excludes)
- masscan_scanner.py: JSON-lines parser, rtsp://IP/ URL + url_hash dedupe,
ip-api geolocation, insert/refresh, NATS publish for new finds
- run_masscan_service.py: long-lived rolling-sweep runner (streams results
in, restarts on pass completion); fails closed without an excludefile
- deploy/: systemd unit + README + excludes file for the Pi host
- .env.example: masscan section
Verified end-to-end against a local Postgres: parse, insert, and dedupe
(0 new on re-ingest) all pass.
2026-08-24 22:23:36 -04:00
|
|
|
))
|
|
|
|
|
new += 1
|
|
|
|
|
else:
|
|
|
|
|
await session.execute(cameras.update().where(
|
|
|
|
|
cameras.c.url_hash == h
|
|
|
|
|
).values(
|
|
|
|
|
last_seen=now,
|
2026-08-25 22:05:15 -04:00
|
|
|
snapshot_url=feed,
|
masscan: active RTSP (554) camera discovery service
Continuous whole-IPv4 rolling sweep for open TCP 554, feeding the same
cameras table as the passive scraper (discovery_source=masscan).
- masscan_config.py: env-driven knobs (range, ports, rate, retries, excludes)
- masscan_scanner.py: JSON-lines parser, rtsp://IP/ URL + url_hash dedupe,
ip-api geolocation, insert/refresh, NATS publish for new finds
- run_masscan_service.py: long-lived rolling-sweep runner (streams results
in, restarts on pass completion); fails closed without an excludefile
- deploy/: systemd unit + README + excludes file for the Pi host
- .env.example: masscan section
Verified end-to-end against a local Postgres: parse, insert, and dedupe
(0 new on re-ingest) all pass.
2026-08-24 22:23:36 -04:00
|
|
|
location_lat=lat,
|
|
|
|
|
location_lon=lon,
|
|
|
|
|
location_name=f"{ip} (IP-geo)" if lat is not None else None,
|
|
|
|
|
))
|
|
|
|
|
await session.commit()
|
2026-08-25 22:05:15 -04:00
|
|
|
logger.info("masscan ingest: %d new working feeds (%d probed, %d open-554)",
|
|
|
|
|
new, len(live), len(unique))
|
|
|
|
|
return new, [ip for ip, _ in live]
|
masscan: active RTSP (554) camera discovery service
Continuous whole-IPv4 rolling sweep for open TCP 554, feeding the same
cameras table as the passive scraper (discovery_source=masscan).
- masscan_config.py: env-driven knobs (range, ports, rate, retries, excludes)
- masscan_scanner.py: JSON-lines parser, rtsp://IP/ URL + url_hash dedupe,
ip-api geolocation, insert/refresh, NATS publish for new finds
- run_masscan_service.py: long-lived rolling-sweep runner (streams results
in, restarts on pass completion); fails closed without an excludefile
- deploy/: systemd unit + README + excludes file for the Pi host
- .env.example: masscan section
Verified end-to-end against a local Postgres: parse, insert, and dedupe
(0 new on re-ingest) all pass.
2026-08-24 22:23:36 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── NATS publish ──────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
async def publish_new_hosts(ips: list[str]) -> int:
|
|
|
|
|
"""Publish newly-found open hosts to NATS for the shared ingester.
|
|
|
|
|
|
|
|
|
|
Returns the number of messages published (0 if NATS is down).
|
|
|
|
|
"""
|
|
|
|
|
import json as _json
|
|
|
|
|
import nats
|
|
|
|
|
from config import NATS_URL
|
|
|
|
|
|
|
|
|
|
if not ips:
|
|
|
|
|
return 0
|
|
|
|
|
try:
|
|
|
|
|
nc = await nats.connect(NATS_URL)
|
|
|
|
|
except Exception: # noqa: BLE001
|
|
|
|
|
logger.warning("NATS unavailable — skipping publish pass")
|
|
|
|
|
return 0
|
|
|
|
|
published = 0
|
|
|
|
|
try:
|
|
|
|
|
js = nc.jetstream()
|
|
|
|
|
for ip in dict.fromkeys(ips):
|
|
|
|
|
url = build_rtsp_url(ip)
|
|
|
|
|
msg = {
|
|
|
|
|
"source_type": "camera",
|
|
|
|
|
"title": f"Open RTSP camera ({ip})",
|
|
|
|
|
"url": url,
|
|
|
|
|
"location_lat": None,
|
|
|
|
|
"location_lon": None,
|
|
|
|
|
"location_name": None,
|
|
|
|
|
"tags": ["osint", "camera", MASSCAN_DISCOVERY_SOURCE],
|
|
|
|
|
"raw": {
|
|
|
|
|
"url_hash": url_hash(url),
|
|
|
|
|
"source_url": url,
|
|
|
|
|
"snapshot_url": None,
|
|
|
|
|
"vendor": None,
|
|
|
|
|
"device_type": "rtsp",
|
|
|
|
|
"discovered_via": "masscan",
|
|
|
|
|
"port": 554,
|
|
|
|
|
},
|
|
|
|
|
"source_timestamp": datetime.now(timezone.utc).isoformat(),
|
|
|
|
|
}
|
|
|
|
|
await js.publish(MASSCAN_NATS_SUBJECT, _json.dumps(msg).encode())
|
|
|
|
|
published += 1
|
|
|
|
|
finally:
|
|
|
|
|
await nc.close()
|
|
|
|
|
logger.info("published %d masscan finds to %s", published, MASSCAN_NATS_SUBJECT)
|
|
|
|
|
return published
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── Batch drain helper used by the runner ─────────────────────────────────
|
|
|
|
|
|
|
|
|
|
async def flush(seen: set[str], new_accum: int) -> tuple[int, int]:
|
|
|
|
|
"""Ingest + publish the accumulated host set; return (new, published)."""
|
|
|
|
|
if not seen:
|
|
|
|
|
return 0, 0
|
|
|
|
|
ips = list(seen)
|
2026-08-25 22:05:15 -04:00
|
|
|
new, live = await ingest_open_hosts(ips)
|
|
|
|
|
published = await publish_new_hosts(live)
|
masscan: active RTSP (554) camera discovery service
Continuous whole-IPv4 rolling sweep for open TCP 554, feeding the same
cameras table as the passive scraper (discovery_source=masscan).
- masscan_config.py: env-driven knobs (range, ports, rate, retries, excludes)
- masscan_scanner.py: JSON-lines parser, rtsp://IP/ URL + url_hash dedupe,
ip-api geolocation, insert/refresh, NATS publish for new finds
- run_masscan_service.py: long-lived rolling-sweep runner (streams results
in, restarts on pass completion); fails closed without an excludefile
- deploy/: systemd unit + README + excludes file for the Pi host
- .env.example: masscan section
Verified end-to-end against a local Postgres: parse, insert, and dedupe
(0 new on re-ingest) all pass.
2026-08-24 22:23:36 -04:00
|
|
|
seen.clear()
|
|
|
|
|
return new, published
|