feat: Initial code
All checks were successful
Check Code Quality / RuffCheck (push) Successful in 1m4s
Build Docker / BuildImage (push) Successful in 1m26s

This commit is contained in:
2026-03-26 23:07:05 +11:00
parent d8ede00901
commit 0ce79935d7
24 changed files with 2527 additions and 143 deletions

284
server.py
View File

@@ -1,20 +1,33 @@
from flask import (
Flask,
make_response,
request,
jsonify,
render_template,
send_from_directory,
send_file,
)
import logging
import os
import requests
from urllib.parse import urlparse
from datetime import datetime
import dotenv
import requests
from flask import Flask, Request, jsonify, make_response, render_template, request, send_file, send_from_directory
from collectors import InventoryCollectorOrchestrator
from config import load_config
from database import InventoryStore
from services import CollectionScheduler, should_autostart_scheduler
dotenv.load_dotenv()
logging.basicConfig(level=os.getenv("LOG_LEVEL", "INFO"), format="%(asctime)s %(levelname)s %(name)s: %(message)s")
LOGGER = logging.getLogger(__name__)
app = Flask(__name__)
config = load_config()
store = InventoryStore(config.database_path)
store.init()
orchestrator = InventoryCollectorOrchestrator(config, store)
scheduler = CollectionScheduler(config, orchestrator)
if should_autostart_scheduler():
scheduler.start()
if os.getenv("INITIAL_COLLECT_ON_STARTUP", "true").strip().lower() in {"1", "true", "yes", "on"}:
LOGGER.info("Running initial inventory collection")
orchestrator.collect_once()
def find(name, path):
@@ -71,13 +84,8 @@ def wellknown(path):
# region Main routes
@app.route("/")
def index():
# Print the IP address of the requester
print(f"Request from IP: {request.remote_addr}")
# And the headers
print(f"Request headers: {request.headers}")
# Get current time in the format "dd MMM YYYY hh:mm AM/PM"
current_datetime = datetime.now().strftime("%d %b %Y %I:%M %p")
return render_template("index.html", datetime=current_datetime)
return render_template("index.html", datetime=current_datetime, app_name=config.app_name)
@app.route("/<path:path>")
@@ -107,25 +115,239 @@ def catch_all(path: str):
# region API routes
api_requests = 0
def _authorized(req: Request) -> bool:
if not config.admin_token:
return True
return req.headers.get("X-Admin-Token", "") == config.admin_token
@app.route("/api/v1/summary", methods=["GET"])
def api_summary():
payload = {
"app_name": config.app_name,
"summary": store.summary(),
"last_run": store.last_run(),
"timestamp": datetime.now().isoformat(),
}
return jsonify(payload)
@app.route("/api/v1/topology", methods=["GET"])
def api_topology():
return jsonify(store.topology())
@app.route("/api/v1/assets", methods=["GET"])
def api_assets():
assets = store.list_assets()
assets = _link_assets_to_proxmox(assets)
source = request.args.get("source")
status = request.args.get("status")
search = (request.args.get("search") or "").strip().lower()
filtered = []
for asset in assets:
if source and asset.get("source") != source:
continue
if status and (asset.get("status") or "") != status:
continue
if search:
haystack = " ".join(
[
asset.get("name") or "",
asset.get("hostname") or "",
asset.get("asset_type") or "",
asset.get("source") or "",
asset.get("subnet") or "",
asset.get("public_ip") or "",
]
).lower()
if search not in haystack:
continue
filtered.append(asset)
return jsonify({"count": len(filtered), "assets": filtered})
def _extract_target_hosts(asset: dict) -> list[str]:
metadata = asset.get("metadata") or {}
raw_values: list[str] = []
for field in ("proxy_pass_resolved", "inferred_targets", "proxy_pass", "upstream_servers"):
values = metadata.get(field) or []
if isinstance(values, list):
raw_values.extend([str(value) for value in values])
if asset.get("hostname"):
raw_values.append(str(asset.get("hostname")))
hosts: list[str] = []
for raw in raw_values:
parts = [part.strip() for part in raw.split(",") if part.strip()]
for part in parts:
parsed_host = ""
if part.startswith("http://") or part.startswith("https://"):
parsed_host = urlparse(part).hostname or ""
else:
candidate = part.split("/", 1)[0]
if ":" in candidate:
candidate = candidate.split(":", 1)[0]
parsed_host = candidate.strip()
if parsed_host:
hosts.append(parsed_host.lower())
return hosts
def _link_assets_to_proxmox(assets: list[dict]) -> list[dict]:
proxmox_assets = [
asset for asset in assets if asset.get("source") == "proxmox" and asset.get("asset_type") in {"vm", "lxc"}
]
by_ip: dict[str, list[dict]] = {}
by_name: dict[str, list[dict]] = {}
for asset in proxmox_assets:
for ip in asset.get("ip_addresses") or []:
by_ip.setdefault(str(ip).lower(), []).append(asset)
for key in (asset.get("name"), asset.get("hostname")):
if key:
by_name.setdefault(str(key).lower(), []).append(asset)
for asset in assets:
if asset.get("source") == "proxmox":
continue
hosts = _extract_target_hosts(asset)
linked = []
seen = set()
for host in hosts:
matches = by_ip.get(host, []) + by_name.get(host, [])
for match in matches:
match_key = f"{match.get('source')}:{match.get('asset_type')}:{match.get('external_id')}"
if match_key in seen:
continue
seen.add(match_key)
linked.append(
{
"source": match.get("source"),
"asset_type": match.get("asset_type"),
"external_id": match.get("external_id"),
"name": match.get("name"),
"hostname": match.get("hostname"),
"ip_addresses": match.get("ip_addresses") or [],
}
)
metadata = dict(asset.get("metadata") or {})
metadata["linked_proxmox_assets"] = linked
asset["metadata"] = metadata
return assets
@app.route("/api/v1/sources", methods=["GET"])
def api_sources():
return jsonify({"sources": store.source_health()})
@app.route("/api/v1/nginx/routes", methods=["GET"])
def api_nginx_routes():
assets = store.list_assets()
nginx_assets = [
asset
for asset in assets
if asset.get("source") == "nginx" and asset.get("asset_type") == "nginx_site"
]
routes = []
for asset in nginx_assets:
metadata = asset.get("metadata") or {}
inferred_targets = metadata.get("inferred_targets") or []
proxy_targets_resolved = metadata.get("proxy_pass_resolved") or []
proxy_targets = metadata.get("proxy_pass") or []
upstreams = metadata.get("upstreams") or []
upstream_servers = metadata.get("upstream_servers") or []
listens = metadata.get("listens") or []
route_targets = (
proxy_targets_resolved
if proxy_targets_resolved
else (
inferred_targets
if inferred_targets
else (proxy_targets if proxy_targets else (upstream_servers if upstream_servers else upstreams))
)
)
routes.append(
{
"server_name": asset.get("name") or asset.get("hostname") or "unknown",
"target": ", ".join(route_targets) if route_targets else "-",
"listen": ", ".join(listens) if listens else "-",
"source_host": asset.get("node") or "-",
"config_path": metadata.get("path") or "-",
"status": asset.get("status") or "unknown",
}
)
routes.sort(key=lambda item: (item["server_name"], item["source_host"]))
return jsonify({"count": len(routes), "routes": routes})
@app.route("/api/v1/health", methods=["GET"])
def api_health():
last_run = store.last_run()
healthy = bool(last_run) and last_run.get("status") in {"ok", "running"}
return jsonify(
{
"healthy": healthy,
"last_run": last_run,
"scheduler_enabled": config.scheduler_enabled,
"poll_interval_seconds": config.poll_interval_seconds,
}
)
@app.route("/api/v1/collect/trigger", methods=["POST"])
def api_collect_trigger():
if not _authorized(request):
return jsonify({"error": "Unauthorized"}), 401
report = orchestrator.collect_once()
status_code = 200
if report.status == "running":
status_code = 409
elif report.status == "error":
status_code = 500
return (
jsonify(
{
"run_id": report.run_id,
"status": report.status,
"results": [
{
"source": result.source,
"status": result.status,
"asset_count": len(result.assets),
"error": result.error,
}
for result in report.results
],
}
),
status_code,
)
@app.route("/api/v1/data", methods=["GET"])
def api_data():
"""
Example API endpoint that returns some data.
You can modify this to return whatever data you need.
"""
global api_requests
api_requests += 1
data = {
"header": "Sample API Response",
"content": f"Hello, this is a sample API response! You have called this endpoint {api_requests} times.",
"timestamp": datetime.now().isoformat(),
}
return jsonify(data)
payload = orchestrator.current_data()
payload["header"] = config.app_name
payload["content"] = "Inventory snapshot"
payload["timestamp"] = datetime.now().isoformat()
return jsonify(payload)
# endregion