431 lines
12 KiB
Elixir
431 lines
12 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} ->
|
|
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
|
|
# 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
|
|
temp_dir = Path.join(System.tmp_dir!(), "mib_extract_#{:rand.uniform(999_999_999)}")
|
|
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
|
|
temp_dir = Path.join(System.tmp_dir!(), "mib_extract_#{:rand.uniform(999_999_999)}")
|
|
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: #{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
|
|
|
|
# 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
|