Files
the-other-dude/backend/app/services/firmware_service.py
Jason Staack b840047e19 feat: The Other Dude v9.0.1 — full-featured email system
ci: add GitHub Pages deployment workflow for docs site

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 19:30:44 -05:00

304 lines
11 KiB
Python

"""Firmware version cache service and NPK downloader.
Responsibilities:
- check_latest_versions(): fetch latest RouterOS versions from download.mikrotik.com
- download_firmware(): download NPK packages to local PVC cache
- get_firmware_overview(): return fleet firmware status for a tenant
- schedule_firmware_checks(): register daily firmware check job with APScheduler
Version discovery comes from two sources:
1. Go poller runs /system/package/update per device (rate-limited to once/day)
and publishes via NATS -> firmware_subscriber processes these events
2. check_latest_versions() fetches LATEST.7 / LATEST.6 from download.mikrotik.com
"""
import logging
import os
from pathlib import Path
import httpx
from sqlalchemy import text
from app.config import settings
from app.database import AdminAsyncSessionLocal
logger = logging.getLogger(__name__)
# Architectures supported by RouterOS v7 and v6
_V7_ARCHITECTURES = ["arm", "arm64", "mipsbe", "mmips", "smips", "tile", "ppc", "x86"]
_V6_ARCHITECTURES = ["mipsbe", "mmips", "smips", "tile", "ppc", "x86"]
# Version source files on download.mikrotik.com
_VERSION_SOURCES = [
("LATEST.7", "stable", 7),
("LATEST.7long", "long-term", 7),
("LATEST.6", "stable", 6),
("LATEST.6long", "long-term", 6),
]
async def check_latest_versions() -> list[dict]:
"""Fetch latest RouterOS versions from download.mikrotik.com.
Checks LATEST.7, LATEST.7long, LATEST.6, and LATEST.6long files for
version strings, then upserts into firmware_versions table for each
architecture/channel combination.
Returns list of discovered version dicts.
"""
results: list[dict] = []
async with httpx.AsyncClient(timeout=30.0) as client:
for channel_file, channel, major in _VERSION_SOURCES:
try:
resp = await client.get(
f"https://download.mikrotik.com/routeros/{channel_file}"
)
if resp.status_code != 200:
logger.warning(
"MikroTik version check returned %d for %s",
resp.status_code, channel_file,
)
continue
version = resp.text.strip()
if not version or not version[0].isdigit():
logger.warning("Invalid version string from %s: %r", channel_file, version)
continue
architectures = _V7_ARCHITECTURES if major == 7 else _V6_ARCHITECTURES
for arch in architectures:
npk_url = (
f"https://download.mikrotik.com/routeros/"
f"{version}/routeros-{version}-{arch}.npk"
)
results.append({
"architecture": arch,
"channel": channel,
"version": version,
"npk_url": npk_url,
})
except Exception as e:
logger.warning("Failed to check %s: %s", channel_file, e)
# Upsert into firmware_versions table
if results:
async with AdminAsyncSessionLocal() as session:
for r in results:
await session.execute(
text("""
INSERT INTO firmware_versions (id, architecture, channel, version, npk_url, checked_at)
VALUES (gen_random_uuid(), :arch, :channel, :version, :npk_url, NOW())
ON CONFLICT (architecture, channel, version) DO UPDATE SET checked_at = NOW()
"""),
{
"arch": r["architecture"],
"channel": r["channel"],
"version": r["version"],
"npk_url": r["npk_url"],
},
)
await session.commit()
logger.info("Firmware version check complete — %d versions discovered", len(results))
return results
async def download_firmware(architecture: str, channel: str, version: str) -> str:
"""Download an NPK package to the local firmware cache.
Returns the local file path. Skips download if file already exists
and size matches.
"""
cache_dir = Path(settings.FIRMWARE_CACHE_DIR) / version
cache_dir.mkdir(parents=True, exist_ok=True)
filename = f"routeros-{version}-{architecture}.npk"
local_path = cache_dir / filename
npk_url = f"https://download.mikrotik.com/routeros/{version}/{filename}"
# Check if already cached
if local_path.exists() and local_path.stat().st_size > 0:
logger.info("Firmware already cached: %s", local_path)
return str(local_path)
logger.info("Downloading firmware: %s", npk_url)
async with httpx.AsyncClient(timeout=300.0) as client:
async with client.stream("GET", npk_url) as response:
response.raise_for_status()
with open(local_path, "wb") as f:
async for chunk in response.aiter_bytes(chunk_size=65536):
f.write(chunk)
file_size = local_path.stat().st_size
logger.info("Firmware downloaded: %s (%d bytes)", local_path, file_size)
# Update firmware_versions table with local path and size
async with AdminAsyncSessionLocal() as session:
await session.execute(
text("""
UPDATE firmware_versions
SET npk_local_path = :path, npk_size_bytes = :size
WHERE architecture = :arch AND channel = :channel AND version = :version
"""),
{
"path": str(local_path),
"size": file_size,
"arch": architecture,
"channel": channel,
"version": version,
},
)
await session.commit()
return str(local_path)
async def get_firmware_overview(tenant_id: str) -> dict:
"""Return fleet firmware status for a tenant.
Returns devices grouped by firmware version, annotated with up-to-date status
based on the latest known version for each device's architecture and preferred channel.
"""
async with AdminAsyncSessionLocal() as session:
# Get all devices for tenant
devices_result = await session.execute(
text("""
SELECT id, hostname, ip_address, routeros_version, architecture,
preferred_channel, routeros_major_version,
serial_number, firmware_version, model
FROM devices
WHERE tenant_id = CAST(:tenant_id AS uuid)
ORDER BY hostname
"""),
{"tenant_id": tenant_id},
)
devices = devices_result.fetchall()
# Get latest firmware versions per architecture/channel
versions_result = await session.execute(
text("""
SELECT DISTINCT ON (architecture, channel)
architecture, channel, version, npk_url
FROM firmware_versions
ORDER BY architecture, channel, checked_at DESC
""")
)
latest_versions = {
(row[0], row[1]): {"version": row[2], "npk_url": row[3]}
for row in versions_result.fetchall()
}
# Build per-device status
device_list = []
version_groups: dict[str, list] = {}
summary = {"total": 0, "up_to_date": 0, "outdated": 0, "unknown": 0}
for dev in devices:
dev_id = str(dev[0])
hostname = dev[1]
current_version = dev[3]
arch = dev[4]
channel = dev[5] or "stable"
latest = latest_versions.get((arch, channel)) if arch else None
latest_version = latest["version"] if latest else None
is_up_to_date = False
if not current_version or not arch:
summary["unknown"] += 1
elif latest_version and current_version == latest_version:
is_up_to_date = True
summary["up_to_date"] += 1
else:
summary["outdated"] += 1
summary["total"] += 1
dev_info = {
"id": dev_id,
"hostname": hostname,
"ip_address": dev[2],
"routeros_version": current_version,
"architecture": arch,
"latest_version": latest_version,
"channel": channel,
"is_up_to_date": is_up_to_date,
"serial_number": dev[7],
"firmware_version": dev[8],
"model": dev[9],
}
device_list.append(dev_info)
# Group by version
ver_key = current_version or "unknown"
if ver_key not in version_groups:
version_groups[ver_key] = []
version_groups[ver_key].append(dev_info)
# Build version groups with is_latest flag
groups = []
for ver, devs in sorted(version_groups.items()):
# A version is "latest" if it matches the latest for any arch/channel combo
is_latest = any(
v["version"] == ver for v in latest_versions.values()
)
groups.append({
"version": ver,
"count": len(devs),
"is_latest": is_latest,
"devices": devs,
})
return {
"devices": device_list,
"version_groups": groups,
"summary": summary,
}
async def get_cached_firmware() -> list[dict]:
"""List all locally cached NPK files with their sizes."""
cache_dir = Path(settings.FIRMWARE_CACHE_DIR)
cached = []
if not cache_dir.exists():
return cached
for version_dir in sorted(cache_dir.iterdir()):
if not version_dir.is_dir():
continue
for npk_file in sorted(version_dir.iterdir()):
if npk_file.suffix == ".npk":
cached.append({
"path": str(npk_file),
"version": version_dir.name,
"filename": npk_file.name,
"size_bytes": npk_file.stat().st_size,
})
return cached
def schedule_firmware_checks() -> None:
"""Register daily firmware version check with APScheduler.
Called from FastAPI lifespan startup to schedule check_latest_versions()
at 3am UTC daily.
"""
from apscheduler.triggers.cron import CronTrigger
from app.services.backup_scheduler import backup_scheduler
backup_scheduler.add_job(
check_latest_versions,
trigger=CronTrigger(hour=3, minute=0, timezone="UTC"),
id="firmware_version_check",
name="Check for new RouterOS firmware versions",
max_instances=1,
replace_existing=True,
)
logger.info("Firmware version check scheduled — daily at 3am UTC")