diff --git a/.gitignore b/.gitignore index 0b6872c3..1dacaf9e 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,7 @@ profiles.json # Elixir language server and tooling .elixir-tools/ .expert/ + +# MIB files directory (keep directory in git, ignore contents) +/mibs/* +!/mibs/.gitkeep diff --git a/config/dev.exs b/config/dev.exs index 635b2804..a82cbddc 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -95,6 +95,9 @@ config :towerops, ToweropsWeb.Endpoint, # Set environment identifier for runtime checks config :towerops, :env, :dev +# Configure MIB directory for development +config :towerops, :mib_dir, Path.expand("mibs", Path.dirname(__DIR__)) + # Configure Redis/Valkey for development config :towerops, :redis, host: "localhost", diff --git a/config/runtime.exs b/config/runtime.exs index 1f32b9ac..c1408877 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -154,6 +154,9 @@ if config_env() == :prod do # Set default sender for all emails config :towerops, :mailer_from, {"Towerops", "hi@towerops.net"} + # Configure MIB directory for production (persistent volume mount) + config :towerops, :mib_dir, "/app/mibs" + config :towerops, :redis, host: redis_host, port: redis_port diff --git a/lib/mix/tasks/upload_mibs.ex b/lib/mix/tasks/upload_mibs.ex new file mode 100644 index 00000000..863653ec --- /dev/null +++ b/lib/mix/tasks/upload_mibs.ex @@ -0,0 +1,228 @@ +defmodule Mix.Tasks.UploadMibs do + @shortdoc "Upload MIB files to production API" + + @moduledoc """ + Uploads MIB files from a source directory to the production API. + + ## Usage + + # Upload all vendor MIB directories + mix upload_mibs --source-path ~/librenms/mibs --token YOUR_SUPERUSER_TOKEN + + # Upload to specific environment + mix upload_mibs --source-path ~/librenms/mibs --token TOKEN --api-url https://towerops.net + + # Upload specific vendors only + mix upload_mibs --source-path ~/librenms/mibs --token TOKEN --vendors mikrotik,cisco,ubiquiti + + ## Options + + * `--source-path` - Path to the directory containing MIB files (required) + * `--token` - Superuser API token for authentication (required) + * `--api-url` - API base URL (default: https://towerops.net) + * `--vendors` - Comma-separated list of vendor directories to upload (default: all) + + ## Notes + + * Creates temporary tar.gz archives for each vendor directory + * Uploads via multipart/form-data to POST /api/v1/mibs + * Requires a superuser API token + * Cleans up temporary archives after upload + """ + + use Mix.Task + + require Logger + + @default_api_url "https://towerops.net" + + def run(args) do + # Start required applications + {:ok, _} = Application.ensure_all_started(:req) + + {opts, _} = + OptionParser.parse!(args, + strict: [ + source_path: :string, + token: :string, + api_url: :string, + vendors: :string + ] + ) + + source_path = opts[:source_path] || raise "Missing --source-path argument" + token = opts[:token] || raise "Missing --token argument" + api_url = opts[:api_url] || @default_api_url + vendors = parse_vendors(opts[:vendors]) + + if !File.exists?(source_path) do + raise "Source directory not found: #{source_path}" + end + + case vendors do + :all -> + upload_all_vendors(source_path, api_url, token) + + vendor_list -> + upload_specific_vendors(source_path, api_url, token, vendor_list) + end + end + + defp parse_vendors(nil), do: :all + defp parse_vendors(""), do: :all + + defp parse_vendors(vendors_string) do + vendors_string + |> String.split(",") + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + end + + defp upload_all_vendors(source_path, api_url, token) do + Logger.info("Discovering vendor directories in #{source_path}...") + + vendors = + source_path + |> File.ls!() + |> Enum.filter(fn name -> + path = Path.join(source_path, name) + File.dir?(path) and name != "lost+found" and !String.starts_with?(name, ".") + end) + |> Enum.sort() + + Logger.info("Found #{length(vendors)} vendor directories") + upload_vendor_list(source_path, api_url, token, vendors) + end + + defp upload_specific_vendors(source_path, api_url, token, vendors) do + Logger.info("Uploading #{length(vendors)} specified vendors") + upload_vendor_list(source_path, api_url, token, vendors) + end + + defp upload_vendor_list(source_path, api_url, token, vendors) do + results = + Enum.map(vendors, fn vendor -> + vendor_path = Path.join(source_path, vendor) + + if File.exists?(vendor_path) do + upload_vendor(api_url, token, vendor, vendor_path) + else + Logger.warning("Vendor directory not found: #{vendor}") + {:error, vendor, "Directory not found"} + end + end) + + # Summary + successes = Enum.count(results, &match?({:ok, _, _}, &1)) + failures = Enum.count(results, &match?({:error, _, _}, &1)) + + Logger.info("Upload complete: #{successes} succeeded, #{failures} failed") + + if failures > 0 do + Logger.error("Failed uploads:") + + Enum.each(results, fn + {:error, vendor, reason} -> + Logger.error(" - #{vendor}: #{reason}") + + _ -> + :ok + end) + end + end + + defp upload_vendor(api_url, token, vendor, vendor_path) do + Logger.info("Uploading #{vendor}...") + + # Create temporary tar.gz archive + temp_dir = System.tmp_dir!() + archive_name = "#{vendor}.tar.gz" + archive_path = Path.join(temp_dir, archive_name) + + try do + # Create tar.gz archive + case create_tarball(vendor_path, archive_path) do + :ok -> + # Upload to API + upload_tarball(api_url, token, vendor, archive_path, archive_name) + + {:error, reason} -> + Logger.error("Failed to create archive for #{vendor}: #{inspect(reason)}") + {:error, vendor, "Archive creation failed"} + end + after + # Clean up temporary archive + File.rm(archive_path) + end + end + + defp create_tarball(source_dir, archive_path) do + parent_dir = Path.dirname(source_dir) + vendor_name = Path.basename(source_dir) + + case System.cmd( + "tar", + ["-czf", archive_path, "-C", parent_dir, vendor_name], + stderr_to_stdout: true + ) do + {_output, 0} -> + :ok + + {error, exit_code} -> + Logger.error("tar failed (exit #{exit_code}): #{error}") + {:error, :tar_failed} + end + rescue + e in ErlangError -> + Logger.error("tar command not found: #{inspect(e)}") + {:error, :tar_not_found} + end + + defp upload_tarball(api_url, token, vendor, archive_path, archive_name) do + url = "#{api_url}/api/v1/mibs" + + Logger.debug("Uploading #{archive_name} to #{url}") + + # Create multipart form with file + file_content = File.read!(archive_path) + + form = [ + {"vendor", vendor}, + {"file", {file_content, filename: archive_name, content_type: "application/gzip"}} + ] + + case Req.post(url, + auth: {:bearer, token}, + form_multipart: form, + receive_timeout: 120_000 + ) do + {:ok, %{status: 201, body: body}} when is_map(body) -> + files_count = Map.get(body, "files_count", "unknown") + Logger.info("✓ #{vendor}: uploaded #{files_count} files") + {:ok, vendor, files_count} + + {:ok, %{status: status, body: body}} when is_map(body) -> + error = Map.get(body, "error", "Unknown error") + + # For 403, log the full error message since it's likely a permission issue + if status == 403 do + Logger.error("✗ #{vendor}: HTTP #{status} - #{error}") + Logger.error(" Make sure your API token has superuser permissions!") + else + Logger.error("✗ #{vendor}: HTTP #{status} - #{error}") + end + + {:error, vendor, "HTTP #{status}: #{error}"} + + {:ok, %{status: status, body: body}} -> + # Non-JSON response (likely HTML error page) + error_preview = body |> to_string() |> String.slice(0, 100) + Logger.error("✗ #{vendor}: HTTP #{status} - #{error_preview}...") + {:error, vendor, "HTTP #{status}: Non-JSON response"} + + {:error, reason} -> + Logger.error("✗ #{vendor}: Request failed - #{inspect(reason)}") + {:error, vendor, "Request failed: #{inspect(reason)}"} + end + end +end diff --git a/mibs/.gitkeep b/mibs/.gitkeep new file mode 100644 index 00000000..e69de29b