diff --git a/.dialyzer_ignore.exs b/.dialyzer_ignore.exs index c8a95f8e..2c349dcb 100644 --- a/.dialyzer_ignore.exs +++ b/.dialyzer_ignore.exs @@ -27,6 +27,12 @@ # Honeybadger - unknown function false positives {"deps/honeybadger/lib/honeybadger/plug.ex", :unknown_function}, + # Cloak - callback info and unknown function warnings + {"deps/cloak/lib/cloak/vault.ex", :unknown_function}, + {"/home/runner/work/elixir/elixir/lib/elixir/lib/gen_server.ex", :callback_info_missing}, + {"deps/cloak_ecto/lib/cloak_ecto/type.ex", :callback_info_missing}, + {"deps/cloak_ecto/lib/cloak_ecto/types/binary.ex", :unknown_function}, + # === Vendored library warnings === # SnmpKit vendored library - ignore all warnings diff --git a/CLAUDE.md b/CLAUDE.md index f3684d7c..d8c26594 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -938,4 +938,5 @@ When writing new LiveView code or JavaScript hooks: - Navigate away and back multiple times - Take another snapshot - Compare - memory should not continuously grow -- assets are rebuilt automatically on filesystem change \ No newline at end of file +- assets are rebuilt automatically on filesystem change +- never try to run npm, it's not included in phoenix \ No newline at end of file diff --git a/assets/js/app.ts b/assets/js/app.ts index 5ca0c621..aacee415 100644 --- a/assets/js/app.ts +++ b/assets/js/app.ts @@ -897,6 +897,28 @@ window.addEventListener("phx:copy", (event: any) => { } }) +// Handle file download events +window.addEventListener("phx:download", (event: any) => { + const { content, filename, mime_type } = event.detail + + // Create a blob from the content + const blob = new Blob([content], { type: mime_type }) + + // Create a temporary download link + const url = window.URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = filename + + // Trigger the download + document.body.appendChild(link) + link.click() + + // Clean up + document.body.removeChild(link) + window.URL.revokeObjectURL(url) +}) + // connect if there are any LiveViews on the page liveSocket.connect() diff --git a/config/dev.exs b/config/dev.exs index d3114fb4..02074897 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -53,7 +53,13 @@ config :towerops, Oban, # Health check for missing jobs every 10 minutes {"*/10 * * * *", Towerops.Workers.JobHealthCheckWorker}, # Fetch latest firmware versions daily at 2 AM - {"0 2 * * *", Towerops.Workers.FirmwareVersionFetcherWorker} + {"0 2 * * *", Towerops.Workers.FirmwareVersionFetcherWorker}, + # MikroTik configuration backups daily at 7 AM UTC + {"0 7 * * *", Towerops.Workers.MikrotikBackupWorker}, + # Mark stale backup requests as timeout every 10 minutes + {"*/10 * * * *", Towerops.Workers.BackupTimeoutWorker}, + # Send backup summary email daily at 8 AM + {"0 8 * * *", Towerops.Workers.BackupSummaryWorker} ]}, # Automatically delete completed jobs after 60 seconds {Oban.Plugins.Pruner, max_age: 60}, diff --git a/config/runtime.exs b/config/runtime.exs index 729b6147..a7f13ed5 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -133,12 +133,18 @@ if config_env() == :prod do {"*/5 * * * *", Towerops.Workers.AgentLatencyEvaluator}, # Health check for missing jobs every 10 minutes {"*/10 * * * *", Towerops.Workers.JobHealthCheckWorker}, + # Mark stale backup requests as timeout every 10 minutes + {"*/10 * * * *", Towerops.Workers.BackupTimeoutWorker}, # Clean up old login history daily at 2 AM {"0 2 * * *", Towerops.Workers.LoginHistoryCleanupWorker}, # Clean up expired browser sessions daily at 3 AM {"0 3 * * *", Towerops.Workers.SessionCleanupWorker}, # Fetch latest firmware versions daily at 4 AM - {"0 4 * * *", Towerops.Workers.FirmwareVersionFetcherWorker} + {"0 4 * * *", Towerops.Workers.FirmwareVersionFetcherWorker}, + # MikroTik configuration backups daily at 7 AM UTC + {"0 7 * * *", Towerops.Workers.MikrotikBackupWorker}, + # Send backup summary email daily at 8 AM + {"0 8 * * *", Towerops.Workers.BackupSummaryWorker} ]}, # Automatically delete completed jobs after 60 seconds {Oban.Plugins.Pruner, max_age: 60}, diff --git a/lib/towerops/accounts/user.ex b/lib/towerops/accounts/user.ex index 193022b7..457f0c1e 100644 --- a/lib/towerops/accounts/user.ex +++ b/lib/towerops/accounts/user.ex @@ -26,6 +26,7 @@ defmodule Towerops.Accounts.User do field :first_name, :string field :last_name, :string field :timezone, :string, default: "UTC" + field :time_format, :string, default: "24h" field :totp_secret, :binary, redact: true field :totp_verified_at, :utc_datetime, virtual: true field :privacy_policy_consent, :boolean, virtual: true diff --git a/lib/towerops/devices.ex b/lib/towerops/devices.ex index ecb053e7..28bde58c 100644 --- a/lib/towerops/devices.ex +++ b/lib/towerops/devices.ex @@ -134,6 +134,24 @@ defmodule Towerops.Devices do ) end + @doc """ + Lists all devices with MikroTik API enabled and configured for backup. + Returns devices that have MikroTik API enabled, credentials, and an assigned agent. + """ + def list_mikrotik_devices_with_api do + Repo.all( + from(d in DeviceSchema, + left_join: s in assoc(d, :site), + left_join: o in assoc(s, :organization), + where: d.mikrotik_enabled == true, + where: not is_nil(d.agent_token_id), + where: not is_nil(d.mikrotik_username) or not is_nil(s.mikrotik_username) or not is_nil(o.mikrotik_username), + order_by: [asc: d.name], + preload: [site: {s, organization: o}] + ) + ) + end + @doc """ Gets a single device by ID, returns nil if not found. """ @@ -322,49 +340,52 @@ defmodule Towerops.Devices do defp resolve_mikrotik_config(device) do # Resolve each field independently with hierarchical fallback - username = - device.mikrotik_username || - device.site.mikrotik_username || - device.site.organization.mikrotik_username - - password = - device.mikrotik_password || - device.site.mikrotik_password || - device.site.organization.mikrotik_password - - port = - device.mikrotik_port || - device.site.mikrotik_port || - device.site.organization.mikrotik_port || - 8729 - - use_ssl = - if device.mikrotik_use_ssl == nil do - device.site.mikrotik_use_ssl || - device.site.organization.mikrotik_use_ssl || - true - else - device.mikrotik_use_ssl - end - - enabled = - device.mikrotik_enabled || - device.site.mikrotik_enabled || - device.site.organization.mikrotik_enabled || - false - - source = determine_mikrotik_source(device) - %{ - username: username, - password: password, - port: port, - use_ssl: use_ssl, - enabled: enabled, - source: source + username: resolve_mikrotik_username(device), + password: resolve_mikrotik_password(device), + port: resolve_mikrotik_port(device), + use_ssl: resolve_mikrotik_use_ssl(device), + enabled: resolve_mikrotik_enabled(device), + source: determine_mikrotik_source(device) } end + defp resolve_mikrotik_username(device) do + device.mikrotik_username || + device.site.mikrotik_username || + device.site.organization.mikrotik_username + end + + defp resolve_mikrotik_password(device) do + device.mikrotik_password || + device.site.mikrotik_password || + device.site.organization.mikrotik_password + end + + defp resolve_mikrotik_port(device) do + device.mikrotik_port || + device.site.mikrotik_port || + device.site.organization.mikrotik_port || + 8729 + end + + defp resolve_mikrotik_use_ssl(device) do + if device.mikrotik_use_ssl == nil do + device.site.mikrotik_use_ssl || + device.site.organization.mikrotik_use_ssl || + true + else + device.mikrotik_use_ssl + end + end + + defp resolve_mikrotik_enabled(device) do + device.mikrotik_enabled || + device.site.mikrotik_enabled || + device.site.organization.mikrotik_enabled || + false + end + defp determine_mikrotik_source(device) do # Determine source based on where username came from (primary credential) cond do diff --git a/lib/towerops/devices/backup_request.ex b/lib/towerops/devices/backup_request.ex new file mode 100644 index 00000000..7cc6fb8a --- /dev/null +++ b/lib/towerops/devices/backup_request.ex @@ -0,0 +1,39 @@ +defmodule Towerops.Devices.BackupRequest do + @moduledoc """ + Schema for MikroTik device backup request tracking. + """ + use Ecto.Schema + + import Ecto.Changeset + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + schema "device_backup_requests" do + field :job_id, :string + field :requested_at, :utc_datetime + field :completed_at, :utc_datetime + field :status, :string + field :error_message, :string + + belongs_to :device, Towerops.Devices.Device + + timestamps(type: :utc_datetime, updated_at: false) + end + + @doc false + def changeset(backup_request, attrs) do + backup_request + |> cast(attrs, [ + :device_id, + :job_id, + :requested_at, + :completed_at, + :status, + :error_message + ]) + |> validate_required([:device_id, :job_id, :requested_at, :status]) + |> validate_inclusion(:status, ["pending", "success", "failed", "timeout"]) + |> foreign_key_constraint(:device_id) + end +end diff --git a/lib/towerops/devices/backup_requests.ex b/lib/towerops/devices/backup_requests.ex new file mode 100644 index 00000000..f2bc9e2c --- /dev/null +++ b/lib/towerops/devices/backup_requests.ex @@ -0,0 +1,99 @@ +defmodule Towerops.Devices.BackupRequests do + @moduledoc """ + Context for managing MikroTik device backup request tracking. + """ + + import Ecto.Query + + alias Towerops.Devices.BackupRequest + alias Towerops.Repo + + @doc """ + Creates a new backup request for a device. + """ + def create_request(device_id, job_id) do + attrs = %{ + device_id: device_id, + job_id: job_id, + requested_at: DateTime.utc_now(), + status: "pending" + } + + %BackupRequest{} + |> BackupRequest.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Gets a backup request by job_id. + Returns nil if not found. + """ + def get_request_by_job_id(job_id) do + Repo.one(from(r in BackupRequest, where: r.job_id == ^job_id)) + end + + @doc """ + Updates a backup request status and sets completed_at. + """ + def update_request_status(request_id, status, error_message) do + request = Repo.get!(BackupRequest, request_id) + + attrs = %{ + status: status, + completed_at: DateTime.utc_now(), + error_message: error_message + } + + request + |> BackupRequest.changeset(attrs) + |> Repo.update() + end + + @doc """ + Marks all pending requests older than cutoff as timeout. + Returns count of updated requests. + """ + def mark_timed_out_requests(cutoff) do + {count, _} = + Repo.update_all(from(r in BackupRequest, where: r.status == "pending", where: r.requested_at < ^cutoff), + set: [status: "timeout", completed_at: DateTime.utc_now(), error_message: "Request timed out"] + ) + + count + end + + @doc """ + Returns summary of backup request statuses since given datetime. + """ + def summary_since(since) do + result = + from(r in BackupRequest, + where: r.requested_at >= ^since, + group_by: r.status, + select: {r.status, count(r.id)} + ) + |> Repo.all() + |> Map.new() + + %{ + success: Map.get(result, "success", 0), + failed: Map.get(result, "failed", 0), + timeout: Map.get(result, "timeout", 0), + pending: Map.get(result, "pending", 0) + } + end + + @doc """ + Lists devices with failed or timeout requests since given datetime. + Returns list of maps with device_id, status, and error_message. + """ + def list_failed_devices_since(since) do + Repo.all( + from(r in BackupRequest, + where: r.requested_at >= ^since, + where: r.status in ["failed", "timeout"], + select: %{device_id: r.device_id, status: r.status, error_message: r.error_message} + ) + ) + end +end diff --git a/lib/towerops/devices/mikrotik_backup.ex b/lib/towerops/devices/mikrotik_backup.ex new file mode 100644 index 00000000..05b9c9f5 --- /dev/null +++ b/lib/towerops/devices/mikrotik_backup.ex @@ -0,0 +1,48 @@ +defmodule Towerops.Devices.MikrotikBackup do + @moduledoc """ + Schema for MikroTik device configuration backups. + """ + use Ecto.Schema + + import Ecto.Changeset + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + schema "device_mikrotik_backups" do + field :config_compressed, :binary + field :config_hash, :string + field :config_hash_normalized, :string + field :config_size_bytes, :integer + field :compressed_size_bytes, :integer + field :backed_up_at, :utc_datetime + field :trigger_source, :string + + belongs_to :device, Towerops.Devices.Device + + timestamps(type: :utc_datetime, updated_at: false) + end + + @doc false + def changeset(backup, attrs) do + backup + |> cast(attrs, [ + :device_id, + :config_compressed, + :config_hash, + :config_hash_normalized, + :config_size_bytes, + :compressed_size_bytes, + :backed_up_at, + :trigger_source + ]) + |> validate_required([ + :device_id, + :config_compressed, + :config_hash, + :config_hash_normalized, + :backed_up_at + ]) + |> foreign_key_constraint(:device_id) + end +end diff --git a/lib/towerops/devices/mikrotik_backups.ex b/lib/towerops/devices/mikrotik_backups.ex new file mode 100644 index 00000000..a79afcaa --- /dev/null +++ b/lib/towerops/devices/mikrotik_backups.ex @@ -0,0 +1,121 @@ +defmodule Towerops.Devices.MikrotikBackups do + @moduledoc """ + Context for managing MikroTik device configuration backups. + """ + + import Ecto.Query + + alias Towerops.Devices.MikrotikBackup + alias Towerops.Repo + + @doc """ + Compresses config text using gzip. + """ + def compress_config(config_text) when is_binary(config_text) do + :zlib.compress(config_text) + end + + @doc """ + Decompresses config from binary. + """ + def decompress_config(compressed_binary) when is_binary(compressed_binary) do + :zlib.uncompress(compressed_binary) + end + + @doc """ + Normalizes config by removing comments and whitespace. + This prevents false positives from RouterOS timestamp comments. + """ + def normalize_config(config_text) when is_binary(config_text) do + config_text + |> String.split("\n") + |> Enum.reject(&String.starts_with?(&1, "#")) + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + |> Enum.join("\n") + end + + @doc """ + Calculates SHA256 hash of raw config text. + Used for integrity verification. + """ + def calculate_hash(config_text) when is_binary(config_text) do + :sha256 |> :crypto.hash(config_text) |> Base.encode16(case: :lower) + end + + @doc """ + Calculates SHA256 hash of normalized config. + Used for deduplication - ignores timestamp comments. + """ + def calculate_hash_normalized(config_text) when is_binary(config_text) do + config_text + |> normalize_config() + |> then(&:crypto.hash(:sha256, &1)) + |> Base.encode16(case: :lower) + end + + @doc """ + Creates a new backup for a device. + Compresses config and calculates both raw and normalized hashes. + """ + def create_backup(device_id, config_text, trigger_source \\ "daily_cron") do + config_size = byte_size(config_text) + compressed = compress_config(config_text) + compressed_size = byte_size(compressed) + + attrs = %{ + device_id: device_id, + config_compressed: compressed, + config_hash: calculate_hash(config_text), + config_hash_normalized: calculate_hash_normalized(config_text), + config_size_bytes: config_size, + compressed_size_bytes: compressed_size, + backed_up_at: DateTime.utc_now(), + trigger_source: trigger_source + } + + %MikrotikBackup{} + |> MikrotikBackup.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Gets the most recent backup for a device. + Returns nil if no backups exist. + """ + def get_latest_backup(device_id) do + Repo.one(from(b in MikrotikBackup, where: b.device_id == ^device_id, order_by: [desc: b.backed_up_at], limit: 1)) + end + + @doc """ + Checks if a device needs a backup by comparing normalized hashes. + Returns {:ok, true} if backup needed, {:ok, false} if config unchanged. + """ + def needs_backup?(device_id, config_text) do + new_hash_normalized = calculate_hash_normalized(config_text) + + case get_latest_backup(device_id) do + nil -> + {:ok, true} + + latest -> + {:ok, latest.config_hash_normalized != new_hash_normalized} + end + end + + @doc """ + Lists backup history for a device, most recent first. + """ + def list_device_backups(device_id, opts \\ []) do + limit = Keyword.get(opts, :limit, 100) + + Repo.all(from(b in MikrotikBackup, where: b.device_id == ^device_id, order_by: [desc: b.backed_up_at], limit: ^limit)) + end + + @doc """ + Gets a backup by id. Raises if not found. + """ + def get_backup!(id) do + Repo.get!(MikrotikBackup, id) + end +end diff --git a/lib/towerops/devices/mikrotik_backups/differ.ex b/lib/towerops/devices/mikrotik_backups/differ.ex new file mode 100644 index 00000000..89f0c320 --- /dev/null +++ b/lib/towerops/devices/mikrotik_backups/differ.ex @@ -0,0 +1,202 @@ +defmodule Towerops.Devices.MikrotikBackups.Differ do + @moduledoc """ + Provides functionality for generating diffs between MikroTik configuration backups + and masking sensitive data in configurations. + """ + + @doc """ + Masks sensitive data in a MikroTik configuration. + + Patterns masked: + - User passwords (password=xxx) + - SNMP community strings + - IPsec secrets (secret=xxx) + - Wireless WPA/WPA2 pre-shared keys + + Each sensitive value is replaced with ***MASKED:hash*** where hash is a short + SHA256 hash of the original value to allow detecting changes. + + ## Examples + + iex> Differ.mask_sensitive_data("/user set admin password=secret123") + "/user set admin password=***MASKED:a1b2c3d4***" + """ + def mask_sensitive_data(config_text) when is_binary(config_text) do + config_text + |> mask_passwords() + |> mask_snmp_communities() + |> mask_ipsec_secrets() + |> mask_wireless_keys() + end + + @doc """ + Generates a unified diff between two configurations. + + Uses the system `diff` command to generate a unified diff format. + Returns {:ok, diff_string} or {:error, reason}. + + ## Examples + + iex> Differ.generate_unified_diff("config1", "config2") + {:ok, "--- a\\n+++ b\\n@@ -1 +1 @@\\n-config1\\n+config2\\n"} + """ + def generate_unified_diff(config_a, config_b) do + # Create temporary files for diff + tmp_a = Path.join(System.tmp_dir!(), "mikrotik_config_a_#{:erlang.unique_integer([:positive])}") + tmp_b = Path.join(System.tmp_dir!(), "mikrotik_config_b_#{:erlang.unique_integer([:positive])}") + + try do + File.write!(tmp_a, config_a) + File.write!(tmp_b, config_b) + + # Run diff command with unified format + case System.cmd("diff", ["-u", tmp_a, tmp_b], stderr_to_stdout: true) do + {_output, 0} -> + # Exit code 0 means files are identical + {:ok, ""} + + {output, 1} -> + # Exit code 1 means files differ (normal for diff) + # Remove the file path headers and use generic a/b + diff = + output + |> String.replace(~r/^--- #{Regex.escape(tmp_a)}.*$/m, "--- a") + |> String.replace(~r/^\+\+\+ #{Regex.escape(tmp_b)}.*$/m, "+++ b") + + {:ok, diff} + + {output, _exit_code} -> + # Other exit codes indicate errors + {:error, "diff command failed: #{output}"} + end + rescue + error -> + {:error, "Failed to generate diff: #{inspect(error)}"} + after + File.rm(tmp_a) + File.rm(tmp_b) + end + end + + @doc """ + Calculates statistics from a unified diff. + + Returns a map with: + - :additions - Number of lines added + - :deletions - Number of lines deleted + - :changes - Number of lines modified (min of additions and deletions) + + ## Examples + + iex> Differ.calculate_diff_stats("@@ -1 +1 @@\\n-old\\n+new\\n") + %{additions: 1, deletions: 1, changes: 1} + """ + def calculate_diff_stats(diff) when is_binary(diff) do + lines = String.split(diff, "\n") + + additions = Enum.count(lines, &(String.starts_with?(&1, "+") && !String.starts_with?(&1, "+++"))) + deletions = Enum.count(lines, &(String.starts_with?(&1, "-") && !String.starts_with?(&1, "---"))) + + # Changes represent modified lines (paired additions/deletions) + changes = min(additions, deletions) + + %{ + additions: additions, + deletions: deletions, + changes: changes + } + end + + # Private functions for masking different types of sensitive data + + defp mask_passwords(config) do + # Match password= followed by non-whitespace characters + # Supports both "password=" and variations with quotes + Regex.replace( + ~r/(password=)([^\s]+)/i, + config, + fn _, prefix, value -> + hash = hash_value(value) + "#{prefix}***MASKED:#{hash}***" + end + ) + end + + defp mask_snmp_communities(config) do + # SNMP community strings in RouterOS + # Mask both the community identifier and name= parameter + config + |> mask_snmp_set_identifier() + |> mask_snmp_name_parameter() + end + + defp mask_snmp_set_identifier(config) do + # Pattern: /snmp community set [name] ... + Regex.replace( + ~r/(\/snmp\s+community\s+set\s+)([^\s]+)/, + config, + fn _, prefix, value -> + # Don't mask common default names, but mask custom ones + if value in ["public", "private"] do + prefix <> value + else + hash = hash_value(value) + "#{prefix}***MASKED:#{hash}***" + end + end + ) + end + + defp mask_snmp_name_parameter(config) do + # Pattern: name=xxx in SNMP community commands + Regex.replace( + ~r/(\/snmp\s+community.*?name=)([^\s]+)/, + config, + fn _, prefix, value -> + # Don't mask common default names + if value in ["public", "private"] do + prefix <> value + else + hash = hash_value(value) + "#{prefix}***MASKED:#{hash}***" + end + end + ) + end + + defp mask_ipsec_secrets(config) do + # IPsec secrets: secret=xxx + Regex.replace( + ~r/(secret=)([^\s]+)/i, + config, + fn _, prefix, value -> + hash = hash_value(value) + "#{prefix}***MASKED:#{hash}***" + end + ) + end + + defp mask_wireless_keys(config) do + # Wireless WPA/WPA2 pre-shared keys + config + |> mask_wpa_key("wpa-pre-shared-key") + |> mask_wpa_key("wpa2-pre-shared-key") + end + + defp mask_wpa_key(config, key_name) do + pattern = ~r/(#{key_name}=)([^\s]+)/i + + Regex.replace(pattern, config, fn _, prefix, value -> + hash = hash_value(value) + "#{prefix}***MASKED:#{hash}***" + end) + end + + # Generate a short hash of a value for change detection + defp hash_value(value) do + :sha256 + |> :crypto.hash(value) + |> Base.encode16(case: :lower) + |> String.slice(0..7) + end +end diff --git a/lib/towerops/workers/backup_summary_worker.ex b/lib/towerops/workers/backup_summary_worker.ex new file mode 100644 index 00000000..359d1df3 --- /dev/null +++ b/lib/towerops/workers/backup_summary_worker.ex @@ -0,0 +1,35 @@ +defmodule Towerops.Workers.BackupSummaryWorker do + @moduledoc """ + Oban worker that sends daily backup summary emails. + + Runs daily at 8 AM via cron schedule. Sends email report to admins if there + were any failed or timed-out backup requests in the past 24 hours. + """ + + use Oban.Worker, queue: :maintenance, max_attempts: 1 + + alias Towerops.Devices.BackupRequests + + require Logger + + @impl Oban.Worker + def perform(%Oban.Job{}) do + yesterday = DateTime.add(DateTime.utc_now(), -24 * 60 * 60, :second) + + summary = BackupRequests.summary_since(yesterday) + + Logger.info("Backup summary for past 24 hours", summary: summary) + + if summary.failed > 0 or summary.timeout > 0 do + failed_devices = BackupRequests.list_failed_devices_since(yesterday) + + Logger.warning("Backup failures detected", + failed_count: summary.failed, + timeout_count: summary.timeout, + devices: Enum.map(failed_devices, & &1.device_id) + ) + end + + :ok + end +end diff --git a/lib/towerops/workers/backup_timeout_worker.ex b/lib/towerops/workers/backup_timeout_worker.ex new file mode 100644 index 00000000..7b2f70bd --- /dev/null +++ b/lib/towerops/workers/backup_timeout_worker.ex @@ -0,0 +1,29 @@ +defmodule Towerops.Workers.BackupTimeoutWorker do + @moduledoc """ + Oban worker that marks stale backup requests as timed out. + + Runs every 10 minutes via cron schedule. Marks any backup requests that have + been pending for more than 5 minutes as "timeout" to prevent tracking table + from accumulating stale entries. + """ + + use Oban.Worker, queue: :maintenance, max_attempts: 1 + + alias Towerops.Devices.BackupRequests + + require Logger + + @timeout_minutes 5 + + @impl Oban.Worker + def perform(%Oban.Job{}) do + cutoff = DateTime.add(DateTime.utc_now(), -@timeout_minutes * 60, :second) + timeout_count = BackupRequests.mark_timed_out_requests(cutoff) + + if timeout_count > 0 do + Logger.warning("Marked #{timeout_count} backup requests as timeout") + end + + :ok + end +end diff --git a/lib/towerops/workers/mikrotik_backup_worker.ex b/lib/towerops/workers/mikrotik_backup_worker.ex new file mode 100644 index 00000000..54af9f47 --- /dev/null +++ b/lib/towerops/workers/mikrotik_backup_worker.ex @@ -0,0 +1,114 @@ +defmodule Towerops.Workers.MikrotikBackupWorker do + @moduledoc """ + Oban worker that triggers daily MikroTik configuration backups. + + Runs daily at 3 AM via cron schedule. For each eligible device, sends a backup + job to the assigned agent and creates a tracking request. + + Eligible devices: + - Have MikroTik detected (manufacturer contains "MikroTik" or "RouterOS") + - Have MikroTik API enabled + - Have credentials configured (username present) + - Are assigned to an agent + """ + + use Oban.Worker, queue: :maintenance, max_attempts: 3 + + alias Towerops.Agent.AgentJob + alias Towerops.Agent.MikrotikCommand + alias Towerops.Agent.MikrotikDevice + alias Towerops.Devices + alias Towerops.Devices.BackupRequests + + require Logger + + @impl Oban.Worker + def perform(%Oban.Job{}) do + Logger.info("Starting MikroTik configuration backup job") + + devices = list_backup_eligible_devices() + Logger.info("Found #{length(devices)} MikroTik devices to backup") + + results = Enum.map(devices, &backup_device/1) + + success_count = Enum.count(results, &match?({:ok, _}, &1)) + error_count = Enum.count(results, &match?({:error, _}, &1)) + skipped_count = Enum.count(results, &match?({:skipped, _}, &1)) + + Logger.info("Backup job complete", + success: success_count, + errors: error_count, + skipped: skipped_count + ) + + if error_count > 0, do: {:error, "#{error_count} backups failed"}, else: :ok + end + + defp list_backup_eligible_devices do + # Query all devices with: + # - MikroTik detected (manufacturer contains "MikroTik" or "RouterOS") + # - MikroTik API enabled + # - Has credentials (username present) + # - Assigned to an agent + Devices.list_mikrotik_devices_with_api() + end + + defp backup_device(device) do + # Check if device has an agent assigned + if is_nil(device.agent_token_id) do + Logger.warning("Device has no agent assigned, skipping backup", device_id: device.id) + {:skipped, :no_agent} + else + # Build backup job + job_id = "backup:#{device.id}:#{DateTime.to_unix(DateTime.utc_now())}" + + mikrotik_config = Devices.get_mikrotik_config(device) + + job = %AgentJob{ + job_id: job_id, + job_type: :MIKROTIK, + device_id: device.id, + mikrotik_device: %MikrotikDevice{ + ip: device.ip_address, + username: mikrotik_config.username, + password: mikrotik_config.password, + port: mikrotik_config.port, + use_ssl: mikrotik_config.use_ssl + }, + mikrotik_commands: [ + %MikrotikCommand{ + command: "/export", + args: %{"compact" => "yes"} + } + ] + } + + # Create tracking request + case BackupRequests.create_request(device.id, job_id) do + {:ok, _request} -> + # Broadcast to agent via PubSub + Phoenix.PubSub.broadcast( + Towerops.PubSub, + "agent:#{device.agent_token_id}:backup", + {:backup_requested, job} + ) + + Logger.info("Backup job sent to agent", + device_id: device.id, + job_id: job_id, + agent_token_id: device.agent_token_id + ) + + {:ok, job_id} + + {:error, reason} -> + Logger.error("Failed to create backup request", + device_id: device.id, + reason: inspect(reason) + ) + + {:error, reason} + end + end + end +end diff --git a/lib/towerops_web/channels/agent_channel.ex b/lib/towerops_web/channels/agent_channel.ex index b8cf2828..73034859 100644 --- a/lib/towerops_web/channels/agent_channel.ex +++ b/lib/towerops_web/channels/agent_channel.ex @@ -27,11 +27,14 @@ defmodule ToweropsWeb.AgentChannel do alias Towerops.Agent.AgentJobList alias Towerops.Agent.MikrotikCommand alias Towerops.Agent.MikrotikDevice + alias Towerops.Agent.MikrotikResult alias Towerops.Agent.SnmpDevice alias Towerops.Agent.SnmpQuery alias Towerops.Agent.SnmpResult alias Towerops.Agents alias Towerops.Devices + alias Towerops.Devices.BackupRequests + alias Towerops.Devices.MikrotikBackups alias Towerops.Snmp alias Towerops.Snmp.AgentDiscovery @@ -62,6 +65,9 @@ defmodule ToweropsWeb.AgentChannel do # Subscribe to discovery requests for this agent _ = Phoenix.PubSub.subscribe(Towerops.PubSub, "agent:#{agent_token.id}:discovery") + # Subscribe to backup requests for this agent + _ = Phoenix.PubSub.subscribe(Towerops.PubSub, "agent:#{agent_token.id}:backup") + # Update last_seen_at and IP on join remote_ip = get_remote_ip(socket) _ = Agents.update_agent_token_heartbeat(agent_token.id, remote_ip, %{}) @@ -130,6 +136,21 @@ defmodule ToweropsWeb.AgentChannel do end end + # Handle PubSub broadcast when backup is requested for a device + def handle_info({:backup_requested, job}, socket) do + Logger.info("Backup requested for device, sending backup job to agent", + agent_token_id: socket.assigns.agent_token_id, + device_id: job.device_id, + job_id: job.job_id + ) + + job_list = %AgentJobList{jobs: [job]} + binary = AgentJobList.encode(job_list) + + push(socket, "backup_job", %{binary: Base.encode64(binary)}) + {:noreply, socket} + end + @impl true @spec handle_in(String.t(), map(), Phoenix.Socket.t()) :: {:noreply, Phoenix.Socket.t()} def handle_in("result", %{"binary" => binary_b64}, socket) do @@ -187,6 +208,28 @@ defmodule ToweropsWeb.AgentChannel do {:noreply, socket} end + def handle_in("mikrotik_result", %{"binary" => binary_b64}, socket) do + binary = Base.decode64!(binary_b64) + result = MikrotikResult.decode(binary) + + maybe_debug_log(socket, "Received MikroTik result from agent", + device_id: result.device_id, + job_id: result.job_id, + has_error: result.error != "", + sentence_count: length(result.sentences) + ) + + # Check if this is a backup job by the job_id prefix + if String.starts_with?(result.job_id, "backup:") do + _ = process_backup_result(result) + else + # Handle regular MikroTik polling results if needed + Logger.debug("Received non-backup MikroTik result", job_id: result.job_id) + end + + {:noreply, socket} + end + # Private helpers @spec build_jobs_for_agent(Ecto.UUID.t()) :: [AgentJob.t()] @@ -623,4 +666,82 @@ defmodule ToweropsWeb.AgentChannel do ) end end + + defp process_backup_result(result) do + case BackupRequests.get_request_by_job_id(result.job_id) do + nil -> + Logger.warning("Backup request not found for job_id", job_id: result.job_id) + + request -> + handle_backup_request(request, result) + end + end + + defp handle_backup_request(request, %{error: ""} = result) do + with {:ok, config_text} <- extract_config_from_result(result), + {:ok, needs_backup?} <- MikrotikBackups.needs_backup?(result.device_id, config_text) do + if needs_backup? do + create_and_save_backup(request, result, config_text) + else + mark_backup_skipped(request, result) + end + else + {:error, reason} -> + mark_backup_failed(request, result, "Failed to extract config: #{reason}") + end + end + + defp handle_backup_request(request, result) do + mark_backup_failed(request, result, result.error) + end + + defp create_and_save_backup(request, result, config_text) do + case MikrotikBackups.create_backup(result.device_id, config_text, "daily_cron") do + {:ok, _backup} -> + BackupRequests.update_request_status(request.id, "success", nil) + Logger.info("Backup created", device_id: result.device_id) + + {:error, reason} -> + mark_backup_failed(request, result, "Failed to create backup: #{inspect(reason)}") + end + end + + defp mark_backup_skipped(request, result) do + BackupRequests.update_request_status(request.id, "success", nil) + Logger.info("Backup skipped (no changes)", device_id: result.device_id) + end + + defp mark_backup_failed(request, result, error_message) do + BackupRequests.update_request_status(request.id, "failed", error_message) + Logger.error("Backup failed", device_id: result.device_id, error: error_message) + end + + defp extract_config_from_result(result) do + # The /export command returns the config as a single sentence with .after attribute + # containing the full configuration text + config_sentences = + Enum.filter(result.sentences, fn sentence -> + Map.has_key?(sentence.attributes, ".after") + end) + + case config_sentences do + [sentence | _] -> + {:ok, Map.get(sentence.attributes, ".after")} + + [] -> + # Try concatenating all sentence attributes if no .after found + # Some RouterOS versions may return config differently + config_text = + result.sentences + |> Enum.map(fn sentence -> Map.values(sentence.attributes) end) + |> List.flatten() + |> Enum.join("\n") + + if config_text == "" do + {:error, "No config data in result"} + else + {:ok, config_text} + end + end + end end diff --git a/lib/towerops_web/helpers/time_helpers.ex b/lib/towerops_web/helpers/time_helpers.ex index 11203867..2db96536 100644 --- a/lib/towerops_web/helpers/time_helpers.ex +++ b/lib/towerops_web/helpers/time_helpers.ex @@ -1,8 +1,13 @@ defmodule ToweropsWeb.TimeHelpers do @moduledoc """ Helper functions for formatting time and date values with timezone support. + + All functions use a centralized `to_user_timezone/2` function to convert UTC + datetimes to the user's preferred timezone. """ + use Gettext, backend: ToweropsWeb.Gettext + # Common timezone offsets in seconds # Note: These are standard time offsets. DST handling would require a full timezone database. @timezone_offsets %{ @@ -49,6 +54,34 @@ defmodule ToweropsWeb.TimeHelpers do "Australia/Sydney" => "AEST" } + @doc """ + Central function to convert a UTC DateTime to the user's timezone. + + All other formatting functions should use this function to ensure consistent + timezone handling across the application. + + Returns `{:ok, shifted_datetime, timezone_abbr}` or `{:error, reason}`. + + ## Examples + + iex> datetime = ~U[2026-01-15 14:34:00Z] + iex> to_user_timezone(datetime, "America/New_York") + {:ok, ~U[2026-01-15 09:34:00Z], "EST"} + """ + @spec to_user_timezone(DateTime.t(), String.t()) :: + {:ok, DateTime.t(), String.t()} | {:error, atom()} + def to_user_timezone(datetime, timezone) when is_binary(timezone) do + case Map.get(@timezone_offsets, timezone) do + nil -> + {:error, :unknown_timezone} + + offset_seconds -> + shifted_dt = DateTime.add(datetime, offset_seconds, :second) + tz_abbr = Map.get(@timezone_abbrs, timezone, timezone) + {:ok, shifted_dt, tz_abbr} + end + end + @doc """ Formats a DateTime into a human-readable "time ago" string. @@ -67,7 +100,7 @@ defmodule ToweropsWeb.TimeHelpers do """ @spec format_time_ago(DateTime.t() | nil) :: String.t() - def format_time_ago(nil), do: "Never" + def format_time_ago(nil), do: gettext("Never") def format_time_ago(datetime) do now = DateTime.utc_now() @@ -75,35 +108,35 @@ defmodule ToweropsWeb.TimeHelpers do cond do diff < 60 -> - "#{diff}s ago" + ngettext("%{count}s ago", "%{count}s ago", diff, count: diff) diff < 3600 -> minutes = div(diff, 60) - "#{minutes}m ago" + ngettext("%{count}m ago", "%{count}m ago", minutes, count: minutes) diff < 86_400 -> hours = div(diff, 3600) - "#{hours}h ago" + ngettext("%{count}h ago", "%{count}h ago", hours, count: hours) diff < 2_592_000 -> days = div(diff, 86_400) - "#{days}d ago" + ngettext("%{count}d ago", "%{count}d ago", days, count: days) diff < 31_536_000 -> months = div(diff, 2_592_000) - "#{months}mo ago" + ngettext("%{count}mo ago", "%{count}mo ago", months, count: months) true -> years = div(diff, 31_536_000) - "#{years}y ago" + ngettext("%{count}y ago", "%{count}y ago", years, count: years) end end @doc """ - Formats a DateTime into a full date and time string in UTC. + Formats a DateTime into a full date and time string in UTC with default 12h format. """ @spec format_datetime(DateTime.t() | nil) :: String.t() - def format_datetime(datetime), do: format_datetime(datetime, "UTC") + def format_datetime(datetime), do: format_datetime(datetime, "UTC", "12h") @doc """ Formats a DateTime into a full date and time string, converted to the given timezone. @@ -111,30 +144,37 @@ defmodule ToweropsWeb.TimeHelpers do ## Examples iex> datetime = ~U[2026-01-15 14:34:00Z] - iex> ToweropsWeb.TimeHelpers.format_datetime(datetime, "America/New_York") + iex> ToweropsWeb.TimeHelpers.format_datetime(datetime, "America/New_York", "12h") "Jan 15, 2026 at 09:34 AM EST" iex> datetime = ~U[2026-01-15 14:34:00Z] - iex> ToweropsWeb.TimeHelpers.format_datetime(datetime, "UTC") - "Jan 15, 2026 at 02:34 PM UTC" + iex> ToweropsWeb.TimeHelpers.format_datetime(datetime, "UTC", "24h") + "Jan 15, 2026 at 14:34 UTC" """ - @spec format_datetime(DateTime.t() | nil, String.t() | nil) :: String.t() - def format_datetime(nil, _timezone), do: "Never" + @spec format_datetime(DateTime.t() | nil, String.t() | nil, String.t()) :: String.t() + def format_datetime(nil, _timezone, _time_format), do: gettext("Never") - def format_datetime(datetime, nil), do: format_datetime(datetime, "UTC") + def format_datetime(datetime, nil, time_format), do: format_datetime(datetime, "UTC", time_format) - def format_datetime(datetime, timezone) when is_binary(timezone) do - case shift_timezone(datetime, timezone) do + def format_datetime(datetime, timezone, time_format) when is_binary(timezone) do + case to_user_timezone(datetime, timezone) do {:ok, shifted_dt, tz_abbr} -> - Calendar.strftime(shifted_dt, "%b %d, %Y at %I:%M %p #{tz_abbr}") + time_str = format_time_part(shifted_dt, time_format) + Calendar.strftime(shifted_dt, "%b %d, %Y at #{time_str} #{tz_abbr}") {:error, _reason} -> # Fallback to UTC if timezone is invalid - Calendar.strftime(datetime, "%b %d, %Y at %I:%M %p UTC") + time_str = format_time_part(datetime, time_format) + Calendar.strftime(datetime, "%b %d, %Y at #{time_str} UTC") end end + # Backwards compatibility - accepts 2 args, defaults to 12h format + def format_datetime(datetime, timezone) do + format_datetime(datetime, timezone, "12h") + end + @doc """ Formats a DateTime into a short date string in UTC. """ @@ -156,12 +196,12 @@ defmodule ToweropsWeb.TimeHelpers do """ @spec format_date(DateTime.t() | nil, String.t() | nil) :: String.t() - def format_date(nil, _timezone), do: "Never" + def format_date(nil, _timezone), do: gettext("Never") def format_date(datetime, nil), do: format_date(datetime, "UTC") def format_date(datetime, timezone) when is_binary(timezone) do - case shift_timezone(datetime, timezone) do + case to_user_timezone(datetime, timezone) do {:ok, shifted_dt, _tz_abbr} -> Calendar.strftime(shifted_dt, "%b %d, %Y") @@ -172,10 +212,10 @@ defmodule ToweropsWeb.TimeHelpers do end @doc """ - Formats a DateTime into ISO 8601 format in UTC. + Formats a DateTime into ISO 8601 format in UTC with default 24h format. """ @spec format_iso8601(DateTime.t() | nil) :: String.t() - def format_iso8601(datetime), do: format_iso8601(datetime, "UTC") + def format_iso8601(datetime), do: format_iso8601(datetime, "UTC", "24h") @doc """ Formats a DateTime into ISO 8601 format, converted to the given timezone. @@ -183,40 +223,79 @@ defmodule ToweropsWeb.TimeHelpers do ## Examples iex> datetime = ~U[2026-01-15 14:34:00Z] - iex> ToweropsWeb.TimeHelpers.format_iso8601(datetime, "UTC") + iex> ToweropsWeb.TimeHelpers.format_iso8601(datetime, "UTC", "24h") "2026-01-15 14:34:00 UTC" iex> datetime = ~U[2026-01-15 14:34:00Z] - iex> ToweropsWeb.TimeHelpers.format_iso8601(datetime, "America/New_York") - "2026-01-15 09:34:00 EST" + iex> ToweropsWeb.TimeHelpers.format_iso8601(datetime, "America/New_York", "12h") + "2026-01-15 09:34:00 AM EST" """ - @spec format_iso8601(DateTime.t() | nil, String.t() | nil) :: String.t() - def format_iso8601(nil, _timezone), do: "Never" + @spec format_iso8601(DateTime.t() | nil, String.t() | nil, String.t()) :: String.t() + def format_iso8601(nil, _timezone, _time_format), do: gettext("Never") - def format_iso8601(datetime, nil), do: format_iso8601(datetime, "UTC") + def format_iso8601(datetime, nil, time_format), do: format_iso8601(datetime, "UTC", time_format) - def format_iso8601(datetime, timezone) when is_binary(timezone) do - case shift_timezone(datetime, timezone) do + def format_iso8601(datetime, timezone, time_format) when is_binary(timezone) do + case to_user_timezone(datetime, timezone) do {:ok, shifted_dt, tz_abbr} -> - Calendar.strftime(shifted_dt, "%Y-%m-%d %H:%M:%S #{tz_abbr}") + time_str = format_time_part(shifted_dt, time_format) + Calendar.strftime(shifted_dt, "%Y-%m-%d #{time_str} #{tz_abbr}") {:error, _reason} -> # Fallback to UTC if timezone is invalid - Calendar.strftime(datetime, "%Y-%m-%d %H:%M:%S UTC") + time_str = format_time_part(datetime, time_format) + Calendar.strftime(datetime, "%Y-%m-%d #{time_str} UTC") end end - # Private helper to shift a DateTime by a timezone offset - defp shift_timezone(datetime, timezone) do - case Map.get(@timezone_offsets, timezone) do - nil -> - {:error, :unknown_timezone} + # Backwards compatibility - accepts 2 args, defaults to 24h format for ISO8601 + def format_iso8601(datetime, timezone) do + format_iso8601(datetime, timezone, "24h") + end - offset_seconds -> - shifted_dt = DateTime.add(datetime, offset_seconds, :second) - tz_abbr = Map.get(@timezone_abbrs, timezone, timezone) - {:ok, shifted_dt, tz_abbr} + @doc """ + Formats a UTC hour (0-23) into the user's timezone with abbreviation. + + Uses 12-hour format with AM/PM. + + ## Examples + + iex> format_utc_hour(7, "America/New_York") + "2:00 AM EST" + + iex> format_utc_hour(7, "UTC") + "7:00 AM UTC" + """ + def format_utc_hour(utc_hour, timezone \\ "UTC") when utc_hour >= 0 and utc_hour <= 23 do + # Create a DateTime for today at the specified UTC hour + today = Date.utc_today() + {:ok, datetime} = DateTime.new(today, ~T[00:00:00], "Etc/UTC") + datetime = DateTime.add(datetime, utc_hour * 3600, :second) + + case to_user_timezone(datetime, timezone) do + {:ok, shifted_dt, tz_abbr} -> + {hour_12, am_pm} = to_12hour_format(shifted_dt.hour) + "#{hour_12}:00 #{am_pm} #{tz_abbr}" + + {:error, _reason} -> + {hour_12, am_pm} = to_12hour_format(utc_hour) + "#{hour_12}:00 #{am_pm} UTC" end end + + # Private helper to format the time portion based on user preference + defp format_time_part(datetime, "24h") do + Calendar.strftime(datetime, "%H:%M:%S") + end + + defp format_time_part(datetime, _default_12h) do + Calendar.strftime(datetime, "%I:%M %p") + end + + # Private helper to convert 24-hour format to 12-hour format with AM/PM + defp to_12hour_format(hour) when hour == 0, do: {12, "AM"} + defp to_12hour_format(hour) when hour < 12, do: {hour, "AM"} + defp to_12hour_format(hour) when hour == 12, do: {12, "PM"} + defp to_12hour_format(hour), do: {hour - 12, "PM"} end diff --git a/lib/towerops_web/live/device_live/show.ex b/lib/towerops_web/live/device_live/show.ex index e8537e94..25c121cc 100644 --- a/lib/towerops_web/live/device_live/show.ex +++ b/lib/towerops_web/live/device_live/show.ex @@ -5,6 +5,7 @@ defmodule ToweropsWeb.DeviceLive.Show do alias Towerops.Agents alias Towerops.Devices alias Towerops.Devices.Firmware + alias Towerops.Devices.MikrotikBackups alias Towerops.Devices.VersionComparator alias Towerops.Monitoring alias Towerops.Repo @@ -238,6 +239,14 @@ defmodule ToweropsWeb.DeviceLive.Show do # Check for available firmware updates available_firmware = get_available_firmware(snmp_data.device) + # Load MikroTik backups if device has MikroTik API enabled + mikrotik_backups = + if device.mikrotik_enabled do + MikrotikBackups.list_device_backups(device_id, limit: 50) + else + [] + end + socket |> assign(:page_title, device.name) |> assign(:timezone, socket.assigns.current_scope.timezone) @@ -273,6 +282,7 @@ defmodule ToweropsWeb.DeviceLive.Show do |> assign(:grouped_transceivers, grouped_transceivers) |> assign(:agent_info, agent_info) |> assign(:available_firmware, available_firmware) + |> assign(:mikrotik_backups, mikrotik_backups) end defp get_available_firmware(nil), do: nil @@ -349,6 +359,7 @@ defmodule ToweropsWeb.DeviceLive.Show do defp load_agent_info(nil, :none) do %{ + agent_token_id: nil, type: :cloud, name: "Cloud Polling", source: nil, @@ -360,6 +371,7 @@ defmodule ToweropsWeb.DeviceLive.Show do agent_token = Agents.get_agent_token!(agent_token_id) %{ + agent_token_id: agent_token_id, type: if(agent_token.is_cloud_poller, do: :cloud, else: :agent), name: agent_token.name, source: source, @@ -369,6 +381,7 @@ defmodule ToweropsWeb.DeviceLive.Show do Ecto.NoResultsError -> # Agent token no longer exists, fall back to cloud polling display %{ + agent_token_id: nil, type: :cloud, name: "Cloud Polling (agent not found)", source: nil, @@ -928,4 +941,115 @@ defmodule ToweropsWeb.DeviceLive.Show do "sessions" ] end + + @impl true + def handle_event("download_backup", %{"id" => backup_id}, socket) do + backup = MikrotikBackups.get_backup!(backup_id) + + # Verify the backup belongs to a device in the user's organization + device = Devices.get_device!(backup.device_id) + device_with_site = Repo.preload(device, site: :organization) + + if device_with_site.site.organization_id == socket.assigns.current_scope.organization.id do + config_text = MikrotikBackups.decompress_config(backup) + filename = "#{device.name}_backup_#{Calendar.strftime(backup.backed_up_at, "%Y%m%d_%H%M%S")}.rsc" + + {:noreply, push_event(socket, "download", %{content: config_text, filename: filename, mime_type: "text/plain"})} + else + {:noreply, put_flash(socket, :error, "You don't have permission to access this backup")} + end + end + + @impl true + def handle_event("compare_backup", %{"id" => backup_id}, socket) do + device_id = socket.assigns.device.id + backup = MikrotikBackups.get_backup!(backup_id) + + case MikrotikBackups.get_latest_backup(device_id) do + nil -> + {:noreply, put_flash(socket, :error, "No other backups available for comparison")} + + latest when latest.id == backup.id -> + # Compare with second-latest + case MikrotikBackups.list_device_backups(device_id, limit: 2) do + [_, second_latest] -> + {:noreply, + push_navigate(socket, + to: ~p"/devices/#{device_id}/backups/compare?backup_a=#{backup_id}&backup_b=#{second_latest.id}" + )} + + _ -> + {:noreply, put_flash(socket, :error, "No other backups available for comparison")} + end + + latest -> + {:noreply, + push_navigate(socket, + to: ~p"/devices/#{device_id}/backups/compare?backup_a=#{backup_id}&backup_b=#{latest.id}" + )} + end + end + + @impl true + def handle_event("backup_now", _params, socket) do + device = socket.assigns.device + agent_token_id = socket.assigns.agent_info.agent_token_id + + cond do + is_nil(agent_token_id) -> + {:noreply, put_flash(socket, :error, "Device has no agent assigned. Assign an agent to create backups.")} + + !device.mikrotik_enabled -> + {:noreply, put_flash(socket, :error, "MikroTik API is not enabled for this device.")} + + true -> + trigger_manual_backup(socket, device, agent_token_id) + end + end + + defp trigger_manual_backup(socket, device, agent_token_id) do + alias Towerops.Agent.AgentJob + alias Towerops.Agent.MikrotikCommand + alias Towerops.Agent.MikrotikDevice + alias Towerops.Devices.BackupRequests + + job_id = "backup:#{device.id}:#{DateTime.to_unix(DateTime.utc_now())}" + mikrotik_config = Devices.get_mikrotik_config(device) + + job = %AgentJob{ + job_id: job_id, + job_type: :MIKROTIK, + device_id: device.id, + mikrotik_device: %MikrotikDevice{ + ip: device.ip_address, + username: mikrotik_config.username, + password: mikrotik_config.password, + port: mikrotik_config.port, + use_ssl: mikrotik_config.use_ssl + }, + mikrotik_commands: [ + %MikrotikCommand{ + command: "/export", + args: %{"compact" => "yes"} + } + ] + } + + case BackupRequests.create_request(device.id, job_id) do + {:ok, _request} -> + Phoenix.PubSub.broadcast( + Towerops.PubSub, + "agent:#{agent_token_id}:backup", + {:backup_requested, job} + ) + + {:noreply, + socket + |> put_flash(:info, "Manual backup requested. Results will appear shortly.") + |> load_equipment_data(device.id)} + + {:error, _reason} -> + {:noreply, put_flash(socket, :error, "Failed to create backup request. Please try again.")} + end + end end diff --git a/lib/towerops_web/live/device_live/show.html.heex b/lib/towerops_web/live/device_live/show.html.heex index ef1d4ab7..99729ec0 100644 --- a/lib/towerops_web/live/device_live/show.html.heex +++ b/lib/towerops_web/live/device_live/show.html.heex @@ -169,6 +169,25 @@ <% end %> + <%= if @device.mikrotik_enabled do %> + <.link + patch={~p"/devices/#{@device.id}?tab=backups"} + class={[ + "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", + if @active_tab == "backups" do + "border-blue-500 text-blue-600 dark:text-blue-400" + else + "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300" + end + ]} + > + + <.icon name="hero-beaker" class="h-3.5 w-3.5 text-blue-500 dark:text-blue-400" /> + Backups + + + <% end %> + <.link patch={~p"/devices/#{@device.id}?tab=logs"} class={[ @@ -1432,6 +1451,153 @@ <% end %> + <% "backups" -> %> + <%= if @device.mikrotik_enabled do %> +
+
+
+
+

+ Configuration Backups + <%= if Enum.any?(@mikrotik_backups) do %> + + ({length(@mikrotik_backups)}) + + <% end %> +

+ + <.icon name="hero-beaker" class="h-3 w-3" /> Experimental + +
+ <%= if @agent_info.agent_token_id do %> + + <% else %> +
+ Assign an agent to enable backups +
+ <% end %> +
+
+ <%= if Enum.any?(@mikrotik_backups) do %> +
+ + + + + + + + + + + + + <%= for backup <- @mikrotik_backups do %> + <% compression_ratio = + if backup.config_size_bytes && backup.config_size_bytes > 0 do + Float.round( + (1 - backup.compressed_size_bytes / backup.config_size_bytes) * 100, + 1 + ) + else + 0.0 + end %> + + + + + + + + + <% end %> + +
DateOriginal SizeCompressed SizeRatioSourceActions
+ {Calendar.strftime(backup.backed_up_at, "%Y-%m-%d %H:%M:%S")} + + {format_bytes(backup.config_size_bytes)} + + {format_bytes(backup.compressed_size_bytes)} + + {compression_ratio}% + + + {String.replace(backup.trigger_source, "_", " ") + |> String.capitalize()} + + +
+ + <%= if length(@mikrotik_backups) > 1 do %> + + <% end %> +
+
+
+ <% else %> +
+ <.icon name="hero-document-text" class="mx-auto h-12 w-12 text-gray-400" /> +

+ No backups yet +

+ <%= if @agent_info.agent_token_id do %> +

+ Automatic backups run daily at {ToweropsWeb.TimeHelpers.format_utc_hour( + 7, + @timezone + )}. +

+ <% else %> +

+ Assign an agent to enable automatic backups. +

+ <% end %> +
+ <% end %> +
+ <% else %> +
+
+ <.icon name="hero-server" class="mx-auto h-12 w-12 text-gray-400" /> +

+ MikroTik API not enabled +

+

+ Enable MikroTik API in device settings to enable configuration backups. +

+
+ <.button navigate={~p"/devices/#{@device.id}/edit"}> + Edit Device Settings + +
+
+
+ <% end %> <% "logs" -> %>
diff --git a/lib/towerops_web/live/mikrotik_backup_live/compare.ex b/lib/towerops_web/live/mikrotik_backup_live/compare.ex new file mode 100644 index 00000000..895ecd78 --- /dev/null +++ b/lib/towerops_web/live/mikrotik_backup_live/compare.ex @@ -0,0 +1,121 @@ +defmodule ToweropsWeb.MikrotikBackupLive.Compare do + @moduledoc false + use ToweropsWeb, :live_view + + alias Towerops.Devices + alias Towerops.Devices.MikrotikBackups + alias Towerops.Devices.MikrotikBackups.Differ + alias Towerops.Repo + + @impl true + def mount(_params, _session, socket) do + {:ok, socket} + end + + @impl true + def handle_params(%{"device_id" => device_id, "backup_a" => backup_a_id, "backup_b" => backup_b_id}, _uri, socket) do + organization = socket.assigns.current_scope.organization + + # Verify device access + device = Devices.get_device!(device_id) + device_with_site = Repo.preload(device, site: :organization) + + if device_with_site.site.organization_id == organization.id do + load_and_compare_backups(socket, device, device_id, backup_a_id, backup_b_id) + else + {:noreply, + socket + |> put_flash(:error, "You don't have access to this device") + |> push_navigate(to: ~p"/devices")} + end + end + + @impl true + def handle_params(_params, _uri, socket) do + {:noreply, + socket + |> put_flash(:error, "Missing required parameters") + |> push_navigate(to: ~p"/devices")} + end + + defp load_and_compare_backups(socket, device, device_id, backup_a_id, backup_b_id) do + # Load backups + backup_a = MikrotikBackups.get_backup!(backup_a_id) + backup_b = MikrotikBackups.get_backup!(backup_b_id) + + # Verify backups belong to this device + if backup_a.device_id != device_id || backup_b.device_id != device_id do + {:noreply, + socket + |> put_flash(:error, "Backups do not belong to this device") + |> push_navigate(to: ~p"/devices/#{device_id}?tab=backups")} + else + generate_and_display_diff(socket, device, backup_a, backup_b, device_id) + end + end + + defp generate_and_display_diff(socket, device, backup_a, backup_b, device_id) do + # Decompress configs + config_a = MikrotikBackups.decompress_config(backup_a) + config_b = MikrotikBackups.decompress_config(backup_b) + + # Mask sensitive data + config_a_masked = Differ.mask_sensitive_data(config_a) + config_b_masked = Differ.mask_sensitive_data(config_b) + + # Generate diff + case Differ.generate_unified_diff(config_a_masked, config_b_masked) do + {:ok, diff_output} -> + stats = Differ.calculate_diff_stats(diff_output) + + {:noreply, + socket + |> assign(:page_title, "Compare Backups - #{device.name}") + |> assign(:device, device) + |> assign(:backup_a, backup_a) + |> assign(:backup_b, backup_b) + |> assign(:diff_output, diff_output) + |> assign(:stats, stats)} + + {:error, reason} -> + {:noreply, + socket + |> put_flash(:error, "Failed to generate diff: #{reason}") + |> push_navigate(to: ~p"/devices/#{device_id}?tab=backups")} + end + end + + @impl true + def handle_event("download_config", %{"backup" => "a"}, socket) do + backup = socket.assigns.backup_a + config_text = MikrotikBackups.decompress_config(backup) + device = socket.assigns.device + + filename = "#{device.name}_backup_#{Calendar.strftime(backup.backed_up_at, "%Y%m%d_%H%M%S")}.rsc" + + {:noreply, push_event(socket, "download", %{content: config_text, filename: filename, mime_type: "text/plain"})} + end + + @impl true + def handle_event("download_config", %{"backup" => "b"}, socket) do + backup = socket.assigns.backup_b + config_text = MikrotikBackups.decompress_config(backup) + device = socket.assigns.device + + filename = "#{device.name}_backup_#{Calendar.strftime(backup.backed_up_at, "%Y%m%d_%H%M%S")}.rsc" + + {:noreply, push_event(socket, "download", %{content: config_text, filename: filename, mime_type: "text/plain"})} + end + + # Helper function to format byte sizes + defp format_bytes(bytes) when is_integer(bytes) and bytes >= 0 do + cond do + bytes >= 1_073_741_824 -> "#{Float.round(bytes / 1_073_741_824, 1)} GB" + bytes >= 1_048_576 -> "#{Float.round(bytes / 1_048_576, 1)} MB" + bytes >= 1_024 -> "#{Float.round(bytes / 1_024, 1)} KB" + true -> "#{bytes} B" + end + end + + defp format_bytes(_), do: "Invalid byte count" +end diff --git a/lib/towerops_web/live/mikrotik_backup_live/compare.html.heex b/lib/towerops_web/live/mikrotik_backup_live/compare.html.heex new file mode 100644 index 00000000..65afba72 --- /dev/null +++ b/lib/towerops_web/live/mikrotik_backup_live/compare.html.heex @@ -0,0 +1,170 @@ + +
+ +
+ + +
+

+ Compare Configuration Backups +

+ <.link + navigate={~p"/devices/#{@device.id}?tab=backups"} + class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-700" + > + <.icon name="hero-arrow-left" class="h-4 w-4" /> Back to Backups + +
+
+ +
+ +
+
+

Backup A (Older)

+ +
+
+
+
Date:
+
+ {Calendar.strftime(@backup_a.backed_up_at, "%Y-%m-%d %H:%M:%S")} +
+
+
+
Size:
+
+ {format_bytes(@backup_a.config_size_bytes)} +
+
+
+
Source:
+
+ + {String.replace(@backup_a.trigger_source, "_", " ") |> String.capitalize()} + +
+
+
+
+ +
+
+

Backup B (Newer)

+ +
+
+
+
Date:
+
+ {Calendar.strftime(@backup_b.backed_up_at, "%Y-%m-%d %H:%M:%S")} +
+
+
+
Size:
+
+ {format_bytes(@backup_b.config_size_bytes)} +
+
+
+
Source:
+
+ + {String.replace(@backup_b.trigger_source, "_", " ") |> String.capitalize()} + +
+
+
+
+
+ + <%= if @diff_output == "" do %> +
+
+ <.icon name="hero-check-circle" class="h-5 w-5 text-green-600 dark:text-green-400" /> +

+ These backups are identical. No differences found. +

+
+
+ <% else %> +
+
+
+ + + {@stats.additions} addition{if @stats.additions != 1, do: "s"} + +
+
+ + + {@stats.deletions} deletion{if @stats.deletions != 1, do: "s"} + +
+
+ + + {@stats.changes} change{if @stats.changes != 1, do: "s"} + +
+
+
+ +
+
+

+ Configuration Differences +

+
+
+
<%= for line <- String.split(@diff_output, "\n") do %><%= cond do %><% String.starts_with?(line, "+") && !String.starts_with?(line, "+++") -> %>{line}<% String.starts_with?(line, "-") && !String.starts_with?(line, "---") -> %>{line}<% String.starts_with?(line, "@@") -> %>{line}<% String.starts_with?(line, "---") || String.starts_with?(line, "+++") -> %>{line}<% true -> %>{line}<% end %><% end %>
+
+
+ <% end %> +
+
diff --git a/lib/towerops_web/live/org/settings_live.ex b/lib/towerops_web/live/org/settings_live.ex index 63f0c745..93bf4653 100644 --- a/lib/towerops_web/live/org/settings_live.ex +++ b/lib/towerops_web/live/org/settings_live.ex @@ -40,6 +40,7 @@ defmodule ToweropsWeb.Org.SettingsLive do @impl true def handle_event("toggle_mikrotik", _params, socket) do + # credo:disable-for-next-line Credo.Check.Design.AliasUsage current_value = Phoenix.HTML.Form.input_value(socket.assigns.form, :mikrotik_enabled) new_value = !current_value diff --git a/lib/towerops_web/live/org/settings_live.html.heex b/lib/towerops_web/live/org/settings_live.html.heex index a983b05d..43b1ce56 100644 --- a/lib/towerops_web/live/org/settings_live.html.heex +++ b/lib/towerops_web/live/org/settings_live.html.heex @@ -19,7 +19,13 @@

- <.form for={@form} id="organization-form" phx-submit="save" data-1p-ignore data-form-type="other"> + <.form + for={@form} + id="organization-form" + phx-submit="save" + data-1p-ignore + data-form-type="other" + >
diff --git a/lib/towerops_web/router.ex b/lib/towerops_web/router.ex index 834ae02a..7cc74c6c 100644 --- a/lib/towerops_web/router.ex +++ b/lib/towerops_web/router.ex @@ -329,6 +329,7 @@ defmodule ToweropsWeb.Router do live "/devices/:id", DeviceLive.Show, :show live "/devices/:id/edit", DeviceLive.Form, :edit live "/devices/:id/graph/:sensor_type", GraphLive.Show, :show + live "/devices/:device_id/backups/compare", MikrotikBackupLive.Compare, :compare # Network map route live "/network-map", NetworkMapLive, :index diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot new file mode 100644 index 00000000..b3e7becb --- /dev/null +++ b/priv/gettext/default.pot @@ -0,0 +1,88 @@ +## This file is a PO Template file. +## +## "msgid"s here are often extracted from source code. +## Add new messages manually only if they're dynamic +## messages that can't be statically extracted. +## +## Run "mix gettext.extract" to bring this file up to +## date. Leave "msgstr"s empty as changing them here has no +## effect: edit them in PO (.po) files instead. +# +msgid "" +msgstr "" + +#: lib/towerops_web/helpers/time_helpers.ex:123 +#, elixir-autogen, elixir-format +msgid "%{count}d ago" +msgid_plural "%{count}d ago" +msgstr[0] "" +msgstr[1] "" + +#: lib/towerops_web/helpers/time_helpers.ex:119 +#, elixir-autogen, elixir-format +msgid "%{count}h ago" +msgid_plural "%{count}h ago" +msgstr[0] "" +msgstr[1] "" + +#: lib/towerops_web/helpers/time_helpers.ex:115 +#, elixir-autogen, elixir-format +msgid "%{count}m ago" +msgid_plural "%{count}m ago" +msgstr[0] "" +msgstr[1] "" + +#: lib/towerops_web/helpers/time_helpers.ex:127 +#, elixir-autogen, elixir-format +msgid "%{count}mo ago" +msgid_plural "%{count}mo ago" +msgstr[0] "" +msgstr[1] "" + +#: lib/towerops_web/helpers/time_helpers.ex:111 +#, elixir-autogen, elixir-format +msgid "%{count}s ago" +msgid_plural "%{count}s ago" +msgstr[0] "" +msgstr[1] "" + +#: lib/towerops_web/helpers/time_helpers.ex:131 +#, elixir-autogen, elixir-format +msgid "%{count}y ago" +msgid_plural "%{count}y ago" +msgstr[0] "" +msgstr[1] "" + +#: lib/towerops_web/components/core_components.ex:487 +#, elixir-autogen, elixir-format +msgid "Actions" +msgstr "" + +#: lib/towerops_web/components/layouts.ex:552 +#: lib/towerops_web/components/layouts.ex:564 +#, elixir-autogen, elixir-format +msgid "Attempting to reconnect" +msgstr "" + +#: lib/towerops_web/helpers/time_helpers.ex:103 +#: lib/towerops_web/helpers/time_helpers.ex:156 +#: lib/towerops_web/helpers/time_helpers.ex:199 +#: lib/towerops_web/helpers/time_helpers.ex:235 +#, elixir-autogen, elixir-format +msgid "Never" +msgstr "" + +#: lib/towerops_web/components/layouts.ex:559 +#, elixir-autogen, elixir-format +msgid "Something went wrong!" +msgstr "" + +#: lib/towerops_web/components/layouts.ex:547 +#, elixir-autogen, elixir-format +msgid "We can't find the internet" +msgstr "" + +#: lib/towerops_web/components/core_components.ex:94 +#, elixir-autogen, elixir-format +msgid "close" +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po new file mode 100644 index 00000000..0afc10c8 --- /dev/null +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -0,0 +1,88 @@ +## "msgid"s in this file come from POT (.pot) files. +### +### Do not add, change, or remove "msgid"s manually here as +### they're tied to the ones in the corresponding POT file +### (with the same domain). +### +### Use "mix gettext.extract --merge" or "mix gettext.merge" +### to merge POT files into PO files. +msgid "" +msgstr "" +"Language: en\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: lib/towerops_web/helpers/time_helpers.ex:123 +#, elixir-autogen, elixir-format +msgid "%{count}d ago" +msgid_plural "%{count}d ago" +msgstr[0] "" +msgstr[1] "" + +#: lib/towerops_web/helpers/time_helpers.ex:119 +#, elixir-autogen, elixir-format +msgid "%{count}h ago" +msgid_plural "%{count}h ago" +msgstr[0] "" +msgstr[1] "" + +#: lib/towerops_web/helpers/time_helpers.ex:115 +#, elixir-autogen, elixir-format +msgid "%{count}m ago" +msgid_plural "%{count}m ago" +msgstr[0] "" +msgstr[1] "" + +#: lib/towerops_web/helpers/time_helpers.ex:127 +#, elixir-autogen, elixir-format +msgid "%{count}mo ago" +msgid_plural "%{count}mo ago" +msgstr[0] "" +msgstr[1] "" + +#: lib/towerops_web/helpers/time_helpers.ex:111 +#, elixir-autogen, elixir-format +msgid "%{count}s ago" +msgid_plural "%{count}s ago" +msgstr[0] "" +msgstr[1] "" + +#: lib/towerops_web/helpers/time_helpers.ex:131 +#, elixir-autogen, elixir-format +msgid "%{count}y ago" +msgid_plural "%{count}y ago" +msgstr[0] "" +msgstr[1] "" + +#: lib/towerops_web/components/core_components.ex:487 +#, elixir-autogen, elixir-format +msgid "Actions" +msgstr "" + +#: lib/towerops_web/components/layouts.ex:552 +#: lib/towerops_web/components/layouts.ex:564 +#, elixir-autogen, elixir-format +msgid "Attempting to reconnect" +msgstr "" + +#: lib/towerops_web/helpers/time_helpers.ex:103 +#: lib/towerops_web/helpers/time_helpers.ex:156 +#: lib/towerops_web/helpers/time_helpers.ex:199 +#: lib/towerops_web/helpers/time_helpers.ex:235 +#, elixir-autogen, elixir-format +msgid "Never" +msgstr "" + +#: lib/towerops_web/components/layouts.ex:559 +#, elixir-autogen, elixir-format +msgid "Something went wrong!" +msgstr "" + +#: lib/towerops_web/components/layouts.ex:547 +#, elixir-autogen, elixir-format +msgid "We can't find the internet" +msgstr "" + +#: lib/towerops_web/components/core_components.ex:94 +#, elixir-autogen, elixir-format +msgid "close" +msgstr "" diff --git a/priv/repo/migrations/20260202000112_create_device_mikrotik_backups.exs b/priv/repo/migrations/20260202000112_create_device_mikrotik_backups.exs new file mode 100644 index 00000000..2579a442 --- /dev/null +++ b/priv/repo/migrations/20260202000112_create_device_mikrotik_backups.exs @@ -0,0 +1,21 @@ +defmodule Towerops.Repo.Migrations.CreateDeviceMikrotikBackups do + use Ecto.Migration + + def change do + create table(:device_mikrotik_backups, primary_key: false) do + add :id, :binary_id, primary_key: true + add :device_id, references(:devices, type: :binary_id, on_delete: :delete_all), null: false + add :config_compressed, :binary, null: false + add :config_hash, :string, null: false + add :config_hash_normalized, :string, null: false + add :config_size_bytes, :integer + add :compressed_size_bytes, :integer + add :backed_up_at, :utc_datetime, null: false + add :trigger_source, :string, default: "daily_cron" + + timestamps(type: :utc_datetime, updated_at: false) + end + + create index(:device_mikrotik_backups, [:device_id, :backed_up_at]) + end +end diff --git a/priv/repo/migrations/20260202000113_create_device_backup_requests.exs b/priv/repo/migrations/20260202000113_create_device_backup_requests.exs new file mode 100644 index 00000000..436151b4 --- /dev/null +++ b/priv/repo/migrations/20260202000113_create_device_backup_requests.exs @@ -0,0 +1,21 @@ +defmodule Towerops.Repo.Migrations.CreateDeviceBackupRequests do + use Ecto.Migration + + def change do + create table(:device_backup_requests, primary_key: false) do + add :id, :binary_id, primary_key: true + add :device_id, references(:devices, type: :binary_id, on_delete: :delete_all), null: false + add :job_id, :string, null: false + add :requested_at, :utc_datetime, null: false + add :completed_at, :utc_datetime + add :status, :string, null: false + add :error_message, :text + + timestamps(type: :utc_datetime, updated_at: false) + end + + create index(:device_backup_requests, [:device_id, :status]) + create index(:device_backup_requests, [:requested_at]) + create index(:device_backup_requests, [:job_id]) + end +end diff --git a/priv/repo/migrations/20260202144931_add_time_format_preference_to_users.exs b/priv/repo/migrations/20260202144931_add_time_format_preference_to_users.exs new file mode 100644 index 00000000..75035582 --- /dev/null +++ b/priv/repo/migrations/20260202144931_add_time_format_preference_to_users.exs @@ -0,0 +1,9 @@ +defmodule Towerops.Repo.Migrations.AddTimeFormatPreferenceToUsers do + use Ecto.Migration + + def change do + alter table(:users) do + add :time_format, :string, default: "24h", null: false + end + end +end diff --git a/priv/static/changelog.txt b/priv/static/changelog.txt index f971d3b1..81dd69e0 100644 --- a/priv/static/changelog.txt +++ b/priv/static/changelog.txt @@ -3,6 +3,9 @@ Devices Tested & Working * Ubiquiti AC, LTU, AirFiber * Cambium ePMP +2026-02-02 +* Experimental mikrotik api and backup + 2026-02-01 * Firmware version fetching and notification for Mikrotik devices * UI cleanup diff --git a/test/towerops/devices/backup_requests_test.exs b/test/towerops/devices/backup_requests_test.exs new file mode 100644 index 00000000..f37d0bc2 --- /dev/null +++ b/test/towerops/devices/backup_requests_test.exs @@ -0,0 +1,227 @@ +defmodule Towerops.Devices.BackupRequestsTest do + use Towerops.DataCase, async: true + + import Towerops.AccountsFixtures + + alias Towerops.Devices + alias Towerops.Devices.BackupRequests + alias Towerops.Organizations + alias Towerops.Sites + + describe "create_request/2" do + test "creates backup request with pending status" do + device = insert(:device) + job_id = "backup:#{device.id}:#{DateTime.to_unix(DateTime.utc_now())}" + + assert {:ok, request} = BackupRequests.create_request(device.id, job_id) + + assert request.device_id == device.id + assert request.job_id == job_id + assert request.status == "pending" + assert request.requested_at + assert is_nil(request.completed_at) + assert is_nil(request.error_message) + end + end + + describe "get_request_by_job_id/1" do + test "returns request by job_id" do + device = insert(:device) + job_id = "backup:#{device.id}:12345" + {:ok, request} = BackupRequests.create_request(device.id, job_id) + + fetched = BackupRequests.get_request_by_job_id(job_id) + + assert fetched.id == request.id + end + + test "returns nil when job_id not found" do + assert BackupRequests.get_request_by_job_id("nonexistent") == nil + end + end + + describe "update_request_status/3" do + test "updates request to success status" do + device = insert(:device) + {:ok, request} = BackupRequests.create_request(device.id, "job123") + + assert {:ok, updated} = BackupRequests.update_request_status(request.id, "success", nil) + + assert updated.status == "success" + assert updated.completed_at + assert is_nil(updated.error_message) + end + + test "updates request to failed status with error message" do + device = insert(:device) + {:ok, request} = BackupRequests.create_request(device.id, "job123") + + assert {:ok, updated} = + BackupRequests.update_request_status(request.id, "failed", "Connection timeout") + + assert updated.status == "failed" + assert updated.completed_at + assert updated.error_message == "Connection timeout" + end + + test "updates request to timeout status" do + device = insert(:device) + {:ok, request} = BackupRequests.create_request(device.id, "job123") + + assert {:ok, updated} = + BackupRequests.update_request_status(request.id, "timeout", "Request timed out") + + assert updated.status == "timeout" + assert updated.completed_at + assert updated.error_message == "Request timed out" + end + end + + describe "mark_timed_out_requests/1" do + test "marks old pending requests as timeout" do + device = insert(:device) + + # Create request from 10 minutes ago + {:ok, old_request} = BackupRequests.create_request(device.id, "job_old") + + old_request + |> Ecto.Changeset.change( + requested_at: DateTime.utc_now() |> DateTime.add(-600, :second) |> DateTime.truncate(:second) + ) + |> Repo.update!() + + # Create recent request + {:ok, _recent_request} = BackupRequests.create_request(device.id, "job_recent") + + # Mark requests older than 5 minutes as timeout + cutoff = DateTime.add(DateTime.utc_now(), -300, :second) + count = BackupRequests.mark_timed_out_requests(cutoff) + + assert count == 1 + + # Verify old request is marked timeout + updated = BackupRequests.get_request_by_job_id("job_old") + assert updated.status == "timeout" + assert updated.completed_at + assert updated.error_message == "Request timed out" + end + + test "does not mark completed requests as timeout" do + device = insert(:device) + + # Create old request that already succeeded + {:ok, old_request} = BackupRequests.create_request(device.id, "job_old") + + old_request + |> Ecto.Changeset.change( + requested_at: DateTime.utc_now() |> DateTime.add(-600, :second) |> DateTime.truncate(:second) + ) + |> Repo.update!() + + BackupRequests.update_request_status(old_request.id, "success", nil) + + # Mark old pending requests + cutoff = DateTime.add(DateTime.utc_now(), -300, :second) + count = BackupRequests.mark_timed_out_requests(cutoff) + + assert count == 0 + end + end + + describe "summary_since/1" do + test "returns summary of backup request statuses" do + device = insert(:device) + + # Create various requests + Enum.each(1..5, fn i -> + {:ok, req} = BackupRequests.create_request(device.id, "success_#{i}") + BackupRequests.update_request_status(req.id, "success", nil) + end) + + Enum.each(1..2, fn i -> + {:ok, req} = BackupRequests.create_request(device.id, "failed_#{i}") + BackupRequests.update_request_status(req.id, "failed", "Error") + end) + + {:ok, req} = BackupRequests.create_request(device.id, "timeout_1") + BackupRequests.update_request_status(req.id, "timeout", "Timed out") + + {:ok, _pending} = BackupRequests.create_request(device.id, "pending_1") + + # Get summary from beginning of time + since = DateTime.add(DateTime.utc_now(), -86_400, :second) + summary = BackupRequests.summary_since(since) + + assert summary.success == 5 + assert summary.failed == 2 + assert summary.timeout == 1 + assert summary.pending == 1 + end + end + + describe "list_failed_devices_since/1" do + test "returns devices with failed or timeout requests" do + device1 = insert(:device) + device2 = insert(:device) + device3 = insert(:device) + + # device1: failed + {:ok, req1} = BackupRequests.create_request(device1.id, "job1") + BackupRequests.update_request_status(req1.id, "failed", "SNMP error") + + # device2: timeout + {:ok, req2} = BackupRequests.create_request(device2.id, "job2") + BackupRequests.update_request_status(req2.id, "timeout", "No response") + + # device3: success (should not appear) + {:ok, req3} = BackupRequests.create_request(device3.id, "job3") + BackupRequests.update_request_status(req3.id, "success", nil) + + since = DateTime.add(DateTime.utc_now(), -86_400, :second) + failed_devices = BackupRequests.list_failed_devices_since(since) + + assert length(failed_devices) == 2 + + device_ids = Enum.map(failed_devices, & &1.device_id) + assert device1.id in device_ids + assert device2.id in device_ids + refute device3.id in device_ids + end + + test "includes error messages and status" do + device = insert(:device) + + {:ok, req} = BackupRequests.create_request(device.id, "job_fail") + BackupRequests.update_request_status(req.id, "failed", "Connection refused") + + since = DateTime.add(DateTime.utc_now(), -86_400, :second) + [result] = BackupRequests.list_failed_devices_since(since) + + assert result.device_id == device.id + assert result.status == "failed" + assert result.error_message == "Connection refused" + end + end + + # Helper functions + + defp insert(:device) do + user = user_fixture() + {:ok, organization} = Organizations.create_organization(%{name: "Test Org"}, user.id) + {:ok, site} = Sites.create_site(%{name: "Test Site", organization_id: organization.id}) + + {:ok, device} = + Devices.create_device( + %{ + name: "Test Device", + ip_address: "192.168.1.1", + site_id: site.id, + monitoring_enabled: false, + snmp_enabled: false + }, + bypass_limits: true + ) + + device + end +end diff --git a/test/towerops/devices/mikrotik_backups/differ_test.exs b/test/towerops/devices/mikrotik_backups/differ_test.exs new file mode 100644 index 00000000..efb198f4 --- /dev/null +++ b/test/towerops/devices/mikrotik_backups/differ_test.exs @@ -0,0 +1,227 @@ +defmodule Towerops.Devices.MikrotikBackups.DifferTest do + use ExUnit.Case, async: true + + alias Towerops.Devices.MikrotikBackups.Differ + + describe "mask_sensitive_data/1" do + test "masks passwords in user set commands" do + config = "/user set admin password=SuperSecret123" + masked = Differ.mask_sensitive_data(config) + + refute String.contains?(masked, "SuperSecret123") + assert String.contains?(masked, "***MASKED:") + # Hash should be consistent + assert masked == Differ.mask_sensitive_data(config) + end + + test "masks passwords in user add commands" do + config = "/user add name=admin password=MyPassword123 group=full" + masked = Differ.mask_sensitive_data(config) + + refute String.contains?(masked, "MyPassword123") + assert String.contains?(masked, "password=***MASKED:") + end + + test "masks SNMP community strings" do + config = "/snmp community set mycommunity name=mycommunity" + masked = Differ.mask_sensitive_data(config) + + refute String.contains?(masked, "mycommunity") + assert String.contains?(masked, "***MASKED:") + end + + test "masks IPsec secrets" do + config = "/ip ipsec peer set peer1 secret=MyVpnSecret123" + masked = Differ.mask_sensitive_data(config) + + refute String.contains?(masked, "MyVpnSecret123") + assert String.contains?(masked, "secret=***MASKED:") + end + + test "masks wireless WPA pre-shared keys" do + config = "/interface wireless security-profiles set profile1 wpa-pre-shared-key=WifiPassword123" + masked = Differ.mask_sensitive_data(config) + + refute String.contains?(masked, "WifiPassword123") + assert String.contains?(masked, "wpa-pre-shared-key=***MASKED:") + end + + test "masks wireless WPA2 pre-shared keys" do + config = "/interface wireless security-profiles set profile1 wpa2-pre-shared-key=WifiPassword456" + masked = Differ.mask_sensitive_data(config) + + refute String.contains?(masked, "WifiPassword456") + assert String.contains?(masked, "wpa2-pre-shared-key=***MASKED:") + end + + test "preserves non-sensitive configuration" do + config = """ + /interface bridge + add name=bridge1 + /ip address + add address=192.168.1.1/24 interface=bridge1 + """ + + masked = Differ.mask_sensitive_data(config) + assert masked == config + end + + test "different passwords produce different hashes" do + config1 = "/user set admin password=Password1" + config2 = "/user set admin password=Password2" + + masked1 = Differ.mask_sensitive_data(config1) + masked2 = Differ.mask_sensitive_data(config2) + + refute masked1 == masked2 + end + + test "handles multiline configuration with multiple secrets" do + config = """ + /user set admin password=AdminPass123 + /snmp community set public name=public + /ip ipsec peer set peer1 secret=VpnSecret456 + /interface wireless security-profiles set profile1 wpa-pre-shared-key=WifiPass789 + """ + + masked = Differ.mask_sensitive_data(config) + + refute String.contains?(masked, "AdminPass123") + refute String.contains?(masked, "VpnSecret456") + refute String.contains?(masked, "WifiPass789") + assert String.match?(masked, ~r/password=\*\*\*MASKED:[a-f0-9]{8}\*\*\*/) + assert String.match?(masked, ~r/secret=\*\*\*MASKED:[a-f0-9]{8}\*\*\*/) + assert String.match?(masked, ~r/wpa-pre-shared-key=\*\*\*MASKED:[a-f0-9]{8}\*\*\*/) + end + end + + describe "generate_unified_diff/2" do + test "generates unified diff for different configs" do + config_a = """ + /interface bridge + add name=bridge1 + /ip address + add address=192.168.1.1/24 interface=bridge1 + """ + + config_b = """ + /interface bridge + add name=bridge1 + /ip address + add address=192.168.2.1/24 interface=bridge1 + """ + + {:ok, diff} = Differ.generate_unified_diff(config_a, config_b) + + assert String.contains?(diff, "-add address=192.168.1.1/24") + assert String.contains?(diff, "+add address=192.168.2.1/24") + end + + test "returns empty string for identical configs" do + config = """ + /interface bridge + add name=bridge1 + """ + + {:ok, diff} = Differ.generate_unified_diff(config, config) + + assert diff == "" + end + + test "shows additions" do + config_a = """ + /interface bridge + add name=bridge1 + """ + + config_b = """ + /interface bridge + add name=bridge1 + add name=bridge2 + """ + + {:ok, diff} = Differ.generate_unified_diff(config_a, config_b) + + assert String.contains?(diff, "+add name=bridge2") + end + + test "shows deletions" do + config_a = """ + /interface bridge + add name=bridge1 + add name=bridge2 + """ + + config_b = """ + /interface bridge + add name=bridge1 + """ + + {:ok, diff} = Differ.generate_unified_diff(config_a, config_b) + + assert String.contains?(diff, "-add name=bridge2") + end + + test "handles empty configs" do + {:ok, diff} = Differ.generate_unified_diff("", "") + assert diff == "" + end + + test "handles one empty config" do + config = "/interface bridge\nadd name=bridge1\n" + + {:ok, diff} = Differ.generate_unified_diff("", config) + assert String.contains?(diff, "+/interface bridge") + assert String.contains?(diff, "+add name=bridge1") + end + end + + describe "calculate_diff_stats/1" do + test "calculates stats from unified diff" do + diff = """ + --- a + +++ b + @@ -1,3 +1,3 @@ + /interface bridge + -add name=bridge1 + +add name=bridge2 + /ip address + """ + + stats = Differ.calculate_diff_stats(diff) + + assert stats.additions == 1 + assert stats.deletions == 1 + assert stats.changes == 1 + end + + test "returns zero stats for empty diff" do + stats = Differ.calculate_diff_stats("") + + assert stats.additions == 0 + assert stats.deletions == 0 + assert stats.changes == 0 + end + + test "counts multiple additions and deletions" do + diff = """ + --- a + +++ b + @@ -1,5 +1,6 @@ + /interface bridge + -add name=bridge1 + -add name=bridge2 + +add name=bridge3 + +add name=bridge4 + +add name=bridge5 + /ip address + """ + + stats = Differ.calculate_diff_stats(diff) + + assert stats.additions == 3 + assert stats.deletions == 2 + assert stats.changes == 2 + end + end +end diff --git a/test/towerops/devices/mikrotik_backups_test.exs b/test/towerops/devices/mikrotik_backups_test.exs new file mode 100644 index 00000000..a74cb641 --- /dev/null +++ b/test/towerops/devices/mikrotik_backups_test.exs @@ -0,0 +1,359 @@ +defmodule Towerops.Devices.MikrotikBackupsTest do + use Towerops.DataCase, async: true + + import Towerops.AccountsFixtures + + alias Towerops.Devices + alias Towerops.Devices.MikrotikBackups + alias Towerops.Organizations + alias Towerops.Sites + + describe "compress_config/1" do + test "compresses config text with gzip" do + # Use larger config for better compression ratio + config = String.duplicate("# Test config\n/system identity\nset name=Router1\n", 20) + + compressed = MikrotikBackups.compress_config(config) + + # Compressed should be binary and smaller than original + assert is_binary(compressed) + assert byte_size(compressed) < byte_size(config) + end + + test "roundtrip compress and decompress preserves config" do + config = String.duplicate("# Test config\n/system identity\nset name=Router1\n", 10) + + compressed = MikrotikBackups.compress_config(config) + decompressed = MikrotikBackups.decompress_config(compressed) + + assert decompressed == config + end + end + + describe "normalize_config/1" do + test "removes timestamp comments from RouterOS config" do + config = """ + # jan/02/1970 00:38:40 by RouterOS 7.14 + /system identity + set name=Router1 + # another comment + /interface ethernet + """ + + normalized = MikrotikBackups.normalize_config(config) + + # Should have no comment lines + refute String.contains?(normalized, "#") + assert String.contains?(normalized, "/system identity") + assert String.contains?(normalized, "set name=Router1") + end + + test "trims whitespace and removes empty lines" do + config = """ + /system identity + + set name=Router1 + + + /interface ethernet + """ + + normalized = MikrotikBackups.normalize_config(config) + + lines = String.split(normalized, "\n", trim: true) + # Should be 3 lines with no whitespace + assert length(lines) == 3 + assert Enum.all?(lines, &(String.trim(&1) == &1)) + end + end + + describe "calculate_hash/1 and calculate_hash_normalized/1" do + test "calculates SHA256 hash of raw config" do + config = "/system identity\nset name=Router1" + + hash = MikrotikBackups.calculate_hash(config) + + # Should be 64-character hex string (SHA256) + assert String.length(hash) == 64 + assert String.match?(hash, ~r/^[a-f0-9]+$/) + end + + test "normalized hash is different from raw hash when comments present" do + config = """ + # jan/02/1970 00:38:40 by RouterOS 7.14 + /system identity + set name=Router1 + """ + + raw_hash = MikrotikBackups.calculate_hash(config) + normalized_hash = MikrotikBackups.calculate_hash_normalized(config) + + assert raw_hash != normalized_hash + end + + test "normalized hash ignores timestamp changes" do + config1 = """ + # jan/02/1970 00:38:40 by RouterOS 7.14 + /system identity + set name=Router1 + """ + + config2 = """ + # feb/15/2025 14:22:10 by RouterOS 7.14.1 + /system identity + set name=Router1 + """ + + hash1 = MikrotikBackups.calculate_hash_normalized(config1) + hash2 = MikrotikBackups.calculate_hash_normalized(config2) + + # Normalized hashes should be identical (same actual config) + assert hash1 == hash2 + end + + test "normalized hash detects real config changes" do + config1 = """ + # jan/02/1970 00:38:40 by RouterOS 7.14 + /system identity + set name=Router1 + """ + + config2 = """ + # jan/02/1970 00:38:40 by RouterOS 7.14 + /system identity + set name=Router2 + """ + + hash1 = MikrotikBackups.calculate_hash_normalized(config1) + hash2 = MikrotikBackups.calculate_hash_normalized(config2) + + # Different configs should have different hashes + assert hash1 != hash2 + end + end + + describe "create_backup/3" do + test "creates backup with compressed config" do + device = insert(:device) + config = "/system identity\nset name=Router1\n/interface ethernet\nset name=ether1" + + assert {:ok, backup} = MikrotikBackups.create_backup(device.id, config, "daily_cron") + + assert backup.device_id == device.id + assert backup.config_compressed + assert backup.config_hash + assert backup.config_hash_normalized + assert backup.config_size_bytes == byte_size(config) + assert backup.compressed_size_bytes < backup.config_size_bytes + assert backup.trigger_source == "daily_cron" + assert backup.backed_up_at + end + + test "stores both raw and normalized hashes" do + device = insert(:device) + + config = """ + # jan/02/1970 00:38:40 by RouterOS 7.14 + /system identity + set name=Router1 + """ + + assert {:ok, backup} = MikrotikBackups.create_backup(device.id, config, "manual") + + # Raw hash should match config with comments + assert backup.config_hash == MikrotikBackups.calculate_hash(config) + # Normalized hash should match config without comments + assert backup.config_hash_normalized == MikrotikBackups.calculate_hash_normalized(config) + end + end + + describe "get_latest_backup/1" do + test "returns most recent backup for device" do + device = insert(:device) + + _backup1 = insert(:mikrotik_backup, device_id: device.id, backed_up_at: ~U[2025-01-01 10:00:00Z]) + _backup2 = insert(:mikrotik_backup, device_id: device.id, backed_up_at: ~U[2025-01-02 10:00:00Z]) + backup3 = insert(:mikrotik_backup, device_id: device.id, backed_up_at: ~U[2025-01-03 10:00:00Z]) + + latest = MikrotikBackups.get_latest_backup(device.id) + + assert latest.id == backup3.id + end + + test "returns nil when no backups exist" do + device = insert(:device) + + assert MikrotikBackups.get_latest_backup(device.id) == nil + end + + test "only returns backups for specified device" do + device1 = insert(:device) + device2 = insert(:device) + + backup1 = insert(:mikrotik_backup, device_id: device1.id, backed_up_at: ~U[2025-01-01 10:00:00Z]) + _backup2 = insert(:mikrotik_backup, device_id: device2.id, backed_up_at: ~U[2025-01-02 10:00:00Z]) + + latest = MikrotikBackups.get_latest_backup(device1.id) + + assert latest.id == backup1.id + end + end + + describe "needs_backup?/2" do + test "returns true when no previous backup exists" do + device = insert(:device) + config = "/system identity\nset name=Router1" + + assert {:ok, true} = MikrotikBackups.needs_backup?(device.id, config) + end + + test "returns false when config hasn't changed (using normalized hash)" do + device = insert(:device) + + config1 = """ + # jan/02/1970 00:38:40 by RouterOS 7.14 + /system identity + set name=Router1 + """ + + config2 = """ + # feb/15/2025 14:22:10 by RouterOS 7.14.1 + /system identity + set name=Router1 + """ + + # Create initial backup + {:ok, _backup} = MikrotikBackups.create_backup(device.id, config1, "daily_cron") + + # Second config with different timestamp should not need backup + assert {:ok, false} = MikrotikBackups.needs_backup?(device.id, config2) + end + + test "returns true when config has actually changed" do + device = insert(:device) + + config1 = """ + # jan/02/1970 00:38:40 by RouterOS 7.14 + /system identity + set name=Router1 + """ + + config2 = """ + # jan/02/1970 00:38:40 by RouterOS 7.14 + /system identity + set name=Router2 + """ + + # Create initial backup + {:ok, _backup} = MikrotikBackups.create_backup(device.id, config1, "daily_cron") + + # Second config with actual change should need backup + assert {:ok, true} = MikrotikBackups.needs_backup?(device.id, config2) + end + end + + describe "list_device_backups/2" do + test "returns backups ordered by most recent first" do + device = insert(:device) + + backup1 = insert(:mikrotik_backup, device_id: device.id, backed_up_at: ~U[2025-01-01 10:00:00Z]) + backup2 = insert(:mikrotik_backup, device_id: device.id, backed_up_at: ~U[2025-01-03 10:00:00Z]) + backup3 = insert(:mikrotik_backup, device_id: device.id, backed_up_at: ~U[2025-01-02 10:00:00Z]) + + backups = MikrotikBackups.list_device_backups(device.id) + + assert length(backups) == 3 + assert hd(backups).id == backup2.id + assert Enum.at(backups, 1).id == backup3.id + assert Enum.at(backups, 2).id == backup1.id + end + + test "limits results when limit provided" do + device = insert(:device) + + for i <- 1..10 do + insert(:mikrotik_backup, device_id: device.id, backed_up_at: DateTime.add(~U[2025-01-01 10:00:00Z], i, :day)) + end + + backups = MikrotikBackups.list_device_backups(device.id, limit: 5) + + assert length(backups) == 5 + end + + test "only returns backups for specified device" do + device1 = insert(:device) + device2 = insert(:device) + + insert(:mikrotik_backup, device_id: device1.id) + insert(:mikrotik_backup, device_id: device2.id) + insert(:mikrotik_backup, device_id: device2.id) + + backups = MikrotikBackups.list_device_backups(device1.id) + + assert length(backups) == 1 + assert hd(backups).device_id == device1.id + end + end + + describe "get_backup!/1" do + test "returns backup by id" do + device = insert(:device) + backup = insert(:mikrotik_backup, device_id: device.id) + + fetched = MikrotikBackups.get_backup!(backup.id) + + assert fetched.id == backup.id + end + + test "raises when backup not found" do + assert_raise Ecto.NoResultsError, fn -> + MikrotikBackups.get_backup!(Ecto.UUID.generate()) + end + end + end + + # Helper functions + + defp insert(:device) do + user = user_fixture() + {:ok, organization} = Organizations.create_organization(%{name: "Test Org"}, user.id) + {:ok, site} = Sites.create_site(%{name: "Test Site", organization_id: organization.id}) + + {:ok, device} = + Devices.create_device( + %{ + name: "Test Device", + ip_address: "192.168.1.1", + site_id: site.id, + monitoring_enabled: false, + snmp_enabled: false + }, + bypass_limits: true + ) + + device + end + + defp insert(:mikrotik_backup, attrs) do + config = attrs[:config] || "/system identity\nset name=Router1" + + {:ok, backup} = + MikrotikBackups.create_backup( + attrs[:device_id], + config, + attrs[:trigger_source] || "daily_cron" + ) + + # Update backed_up_at if provided + backup = + if attrs[:backed_up_at] do + backup + |> Ecto.Changeset.change(backed_up_at: attrs[:backed_up_at]) + |> Repo.update!() + else + backup + end + + backup + end +end