defmodule ToweropsWeb.Api.V1.MibController do @moduledoc """ API controller for managing SNMP MIB files. Allows uploading MIB files to the persistent storage for use with MIB name translation. All endpoints require superuser API token authentication. """ use ToweropsWeb, :controller require Logger @mib_dir Application.compile_env(:towerops, :mib_dir, "/app/mibs") @doc """ Upload a MIB file or archive (.tar.gz, .zip) containing MIB files. Requires superuser API token. ## Request POST /api/v1/mibs Content-Type: multipart/form-data file: vendor: (optional) ## Response 201 Created { "status": "ok", "message": "Successfully uploaded MIB file", "vendor": "mikrotik", "files_count": 15 } """ def upload(conn, params) do with :ok <- require_superuser(conn) do case params["file"] do %Plug.Upload{} = upload -> vendor = params["vendor"] || "custom" handle_upload(conn, upload, vendor) _ -> conn |> put_status(:bad_request) |> json(%{error: "Missing file parameter"}) end end end @doc """ List all available MIB vendors. Requires superuser API token. ## Response 200 OK { "vendors": ["mikrotik", "cisco", "ubiquiti", ...], "total_files": 1234 } """ def index(conn, _params) do with :ok <- require_superuser(conn) do case list_mib_vendors() do {:ok, vendors, total_files} -> json(conn, %{ vendors: vendors, total_files: total_files }) {:error, reason} -> conn |> put_status(:internal_server_error) |> json(%{error: inspect(reason)}) end end end @doc """ Delete MIB files for a specific vendor. Requires superuser API token. ## Request DELETE /api/v1/mibs/:vendor ## Response 200 OK { "status": "ok", "message": "Deleted MIB files for vendor: mikrotik" } """ def delete(conn, %{"vendor" => vendor}) do with :ok <- require_superuser(conn) do vendor_dir = Path.join(@mib_dir, vendor) if File.exists?(vendor_dir) do case File.rm_rf(vendor_dir) do {:ok, _files} -> Logger.info("Deleted MIB files for vendor: #{vendor}") json(conn, %{ status: "ok", message: "Deleted MIB files for vendor: #{vendor}" }) {:error, reason, file} -> Logger.error("Failed to delete MIB files for #{vendor}: #{file} - #{inspect(reason)}") conn |> put_status(:internal_server_error) |> json(%{error: "Failed to delete MIB files"}) end else conn |> put_status(:not_found) |> json(%{error: "Vendor not found: #{vendor}"}) end end end # Private functions defp require_superuser(conn) do user = conn.assigns[:current_user] if !user || !user.is_superuser do Logger.warning("MIB management attempted by non-superuser: #{inspect(user && user.email)}") conn |> put_status(:forbidden) |> json(%{error: "Superuser access required. Only API tokens created by superusers can manage MIB files."}) |> halt() else :ok end end defp handle_upload(conn, upload, vendor) do vendor_dir = Path.join(@mib_dir, vendor) File.mkdir_p!(vendor_dir) cond do String.ends_with?(upload.filename, ".tar.gz") or String.ends_with?(upload.filename, ".tgz") -> extract_tarball(conn, upload, vendor_dir, vendor) String.ends_with?(upload.filename, ".zip") -> extract_zip(conn, upload, vendor_dir, vendor) true -> copy_single_file(conn, upload, vendor_dir, vendor) end end defp extract_tarball(conn, upload, vendor_dir, vendor) do case System.cmd("tar", ["-xzf", upload.path, "-C", vendor_dir]) do {_output, 0} -> files_count = count_files(vendor_dir) Logger.info("Extracted #{files_count} MIB files for vendor: #{vendor}") conn |> put_status(:created) |> json(%{ status: "ok", message: "Successfully extracted MIB archive", vendor: vendor, files_count: files_count }) {error, exit_code} -> Logger.error("Failed to extract tarball: #{error}") conn |> put_status(:bad_request) |> json(%{error: "Failed to extract archive (exit code: #{exit_code})"}) end end defp extract_zip(conn, upload, vendor_dir, vendor) do case System.cmd("unzip", ["-o", upload.path, "-d", vendor_dir]) do {_output, 0} -> files_count = count_files(vendor_dir) Logger.info("Extracted #{files_count} MIB files for vendor: #{vendor}") conn |> put_status(:created) |> json(%{ status: "ok", message: "Successfully extracted MIB archive", vendor: vendor, files_count: files_count }) {error, exit_code} -> Logger.error("Failed to extract zip: #{error}") conn |> put_status(:bad_request) |> json(%{error: "Failed to extract archive (exit code: #{exit_code})"}) end end defp copy_single_file(conn, upload, vendor_dir, vendor) do target_path = Path.join(vendor_dir, upload.filename) # Check if target already exists as a directory if File.dir?(target_path) do Logger.warning("Cannot upload MIB file: target path is a directory: #{upload.filename} for vendor: #{vendor}") conn |> put_status(:bad_request) |> json(%{error: "Cannot upload: filename conflicts with existing directory"}) else case File.cp(upload.path, target_path) do :ok -> Logger.info("Uploaded MIB file: #{upload.filename} for vendor: #{vendor}") conn |> put_status(:created) |> json(%{ status: "ok", message: "Successfully uploaded MIB file", vendor: vendor, filename: upload.filename }) {:error, :eisdir} -> Logger.error("Failed to copy MIB file - target is a directory: #{upload.filename} for vendor: #{vendor}") conn |> put_status(:bad_request) |> json(%{error: "Cannot upload: filename conflicts with existing directory"}) {:error, reason} -> Logger.error("Failed to copy MIB file: #{inspect(reason)}") conn |> put_status(:internal_server_error) |> json(%{error: "Failed to upload file: #{inspect(reason)}"}) end end end defp list_mib_vendors do if File.exists?(@mib_dir) do vendors = @mib_dir |> File.ls!() |> Enum.filter(fn name -> path = Path.join(@mib_dir, name) File.dir?(path) and name != "lost+found" end) |> Enum.sort() total_files = count_files(@mib_dir) {:ok, vendors, total_files} else {:ok, [], 0} end rescue e -> Logger.error("Failed to list MIB vendors: #{inspect(e)}") {:error, e} end defp count_files(dir) do dir |> Path.join("**/*") |> Path.wildcard() |> Enum.count(&File.regular?/1) end end