towerops/lib/towerops_web/controllers/api/v1/mib_controller.ex
Graham McIntire 91e3181bbc dialyzer: fix all unmatched_return warnings (154 → 0)
Prefix fire-and-forget calls (PubSub.broadcast, Oban enqueue, Logger,
update_*, etc.) with `_ =` so dialyzer knows the return value is
intentionally discarded. Wrap a few if/case expressions whose
branches produce mixed types the same way.

No behavior changes — only explicit acknowledgement of discarded
returns.

Warnings: 242 → 88.
2026-04-21 10:03:55 -05:00

435 lines
13 KiB
Elixir

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: <mib_file>
vendor: <vendor_name> (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 ->
handle_upload_with_vendor(conn, upload, params)
_ ->
conn
|> put_status(:bad_request)
|> json(%{error: "Missing file parameter"})
end
end
end
defp handle_upload_with_vendor(conn, upload, params) do
vendor = params["vendor"] || "custom"
# Validate vendor name to prevent directory traversal
case validate_vendor_name(vendor) do
:ok ->
handle_upload(conn, upload, vendor)
{:error, reason} ->
conn
|> put_status(:bad_request)
|> json(%{error: reason})
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} ->
Logger.error("MIB vendor list failed: #{inspect(reason)}")
conn
|> put_status(:internal_server_error)
|> json(%{error: "Failed to list MIB vendors"})
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
# Validate vendor name to prevent directory traversal
case validate_vendor_name(vendor) do
:ok ->
vendor_dir = Path.join(@mib_dir, vendor)
delete_vendor_mibs(conn, vendor, vendor_dir)
{:error, reason} ->
conn
|> put_status(:bad_request)
|> json(%{error: reason})
end
end
end
defp delete_vendor_mibs(conn, vendor, vendor_dir) do
if File.exists?(vendor_dir) do
remove_vendor_directory(conn, vendor, vendor_dir)
else
conn
|> put_status(:not_found)
|> json(%{error: "Vendor not found: #{vendor}"})
end
end
defp remove_vendor_directory(conn, vendor, vendor_dir) do
# Vendor directory is safe - constructed from validated vendor name and base MIB directory
# Validation ensures no path traversal characters (., /, \, :) are present
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
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
# Extract to temporary directory first to validate contents
random_suffix = 16 |> :crypto.strong_rand_bytes() |> Base.encode16(case: :lower)
temp_dir = Path.join(System.tmp_dir!(), "mib_extract_#{random_suffix}")
File.mkdir_p!(temp_dir)
try do
case System.cmd("tar", ["-xzf", upload.path, "-C", temp_dir]) do
{_output, 0} ->
# Validate all extracted paths are safe (no directory traversal)
case validate_extracted_paths(temp_dir) do
:ok ->
# Safe to copy to vendor directory - vendor_dir constructed from validated vendor name
# and all paths in temp_dir have been validated by validate_extracted_paths/1
_ = File.cp_r!(temp_dir, vendor_dir)
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, reason} ->
Logger.warning("Archive validation failed for vendor #{vendor}: #{reason}")
conn
|> put_status(:bad_request)
|> json(%{error: reason})
end
{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
after
File.rm_rf!(temp_dir)
end
end
defp extract_zip(conn, upload, vendor_dir, vendor) do
# Extract to temporary directory first to validate contents
random_suffix = 16 |> :crypto.strong_rand_bytes() |> Base.encode16(case: :lower)
temp_dir = Path.join(System.tmp_dir!(), "mib_extract_#{random_suffix}")
File.mkdir_p!(temp_dir)
try do
case System.cmd("unzip", ["-o", upload.path, "-d", temp_dir]) do
{_output, 0} ->
# Validate all extracted paths are safe (no directory traversal)
case validate_extracted_paths(temp_dir) do
:ok ->
# Safe to copy to vendor directory - vendor_dir constructed from validated vendor name
# and all paths in temp_dir have been validated by validate_extracted_paths/1
_ = File.cp_r!(temp_dir, vendor_dir)
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, reason} ->
Logger.warning("Archive validation failed for vendor #{vendor}: #{reason}")
conn
|> put_status(:bad_request)
|> json(%{error: reason})
end
{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
after
File.rm_rf!(temp_dir)
end
end
defp copy_single_file(conn, upload, vendor_dir, vendor) do
# Validate filename to prevent directory traversal
case validate_filename(upload.filename) do
:ok ->
copy_validated_file(conn, upload, vendor_dir, vendor)
{:error, reason} ->
Logger.warning("Invalid filename rejected: #{upload.filename} for vendor: #{vendor}")
conn
|> put_status(:bad_request)
|> json(%{error: reason})
end
end
defp copy_validated_file(conn, upload, vendor_dir, vendor) do
# Use Path.basename to ensure we only use the filename, not any path components
safe_filename = Path.basename(upload.filename)
target_path = Path.join(vendor_dir, safe_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: #{safe_filename} for vendor: #{vendor}")
conn
|> put_status(:bad_request)
|> json(%{error: "Cannot upload: filename conflicts with existing directory"})
else
# upload.path is safe - it's controlled by Plug.Upload, not user input
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"})
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
# Validate that extracted archive contents don't contain path traversal attacks
defp validate_extracted_paths(extract_dir) do
# Get canonical path of extraction directory
canonical_extract_dir = Path.expand(extract_dir)
# Check all extracted files/directories
extract_dir
|> Path.join("**/*")
|> Path.wildcard()
|> Enum.all?(fn path ->
canonical_path = Path.expand(path)
String.starts_with?(canonical_path, canonical_extract_dir)
end)
|> case do
true ->
:ok
false ->
{:error, "Archive contains files with invalid paths (possible directory traversal attack)"}
end
rescue
e ->
Logger.error("Failed to validate extracted paths: #{inspect(e)}")
{:error, "Failed to validate archive contents"}
end
# Validate vendor name to prevent directory traversal attacks
# Only allow alphanumeric characters, hyphens, and underscores
defp validate_vendor_name(vendor) when is_binary(vendor) do
# Check for directory traversal sequences
if String.contains?(vendor, [".", "/", "\\", ":"]) do
{:error, "Invalid vendor name: cannot contain path separators or dots"}
else
# Check if vendor name matches safe pattern (alphanumeric, hyphen, underscore)
if vendor =~ ~r/^[a-zA-Z0-9_-]+$/ do
:ok
else
{:error, "Invalid vendor name: must contain only letters, numbers, hyphens, and underscores"}
end
end
end
defp validate_vendor_name(_), do: {:error, "Invalid vendor name"}
# Validate filename to prevent directory traversal
defp validate_filename(filename) when is_binary(filename) do
# Check for directory traversal sequences
if String.contains?(filename, ["..", "/", "\\"]) or String.starts_with?(filename, ".") do
{:error, "Invalid filename: cannot contain path separators or parent directory references"}
else
:ok
end
end
defp validate_filename(_), do: {:error, "Invalid filename"}
end