towerops/lib/towerops/devices/mikrotik_backups.ex

128 lines
3.5 KiB
Elixir

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
@doc """
Deletes a backup.
"""
def delete_backup(%MikrotikBackup{} = backup) do
Repo.delete(backup)
end
end