handle mikrotik ssh
This commit is contained in:
parent
17348bb7e3
commit
aaf8c38b78
18 changed files with 660 additions and 117 deletions
|
|
@ -139,19 +139,55 @@ defmodule Towerops.Devices do
|
|||
Returns devices that have MikroTik API enabled, credentials, and an assigned agent.
|
||||
"""
|
||||
def list_mikrotik_devices_with_api do
|
||||
# Get all MikroTik devices with API enabled and credentials configured
|
||||
# Agent assignment is resolved hierarchically in backup_device/1 (includes global default)
|
||||
Repo.all(
|
||||
from(d in DeviceSchema,
|
||||
left_join: s in assoc(d, :site),
|
||||
left_join: o in assoc(s, :organization),
|
||||
left_join: aa in assoc(d, :agent_assignments),
|
||||
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}]
|
||||
preload: [site: {s, organization: o}, agent_assignments: aa],
|
||||
distinct: d.id
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Resolves the agent token ID for a device using hierarchical resolution:
|
||||
1. Direct agent assignment (via agent_assignments table)
|
||||
2. Site-level agent assignment
|
||||
3. Organization default agent
|
||||
4. Global default cloud poller
|
||||
|
||||
Returns nil if no agent is assigned.
|
||||
"""
|
||||
def resolve_agent_token_id(device) do
|
||||
# Ensure site and organization are preloaded
|
||||
device = Repo.preload(device, site: :organization, agent_assignments: [])
|
||||
|
||||
# Try each level in order
|
||||
get_direct_agent(device) ||
|
||||
get_site_agent(device) ||
|
||||
get_org_default_agent(device) ||
|
||||
get_global_default_agent()
|
||||
end
|
||||
|
||||
defp get_direct_agent(device) do
|
||||
case Enum.find(device.agent_assignments, & &1.enabled) do
|
||||
%{agent_token_id: id} when not is_nil(id) -> id
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp get_site_agent(device), do: device.site.agent_token_id
|
||||
|
||||
defp get_org_default_agent(device), do: device.site.organization.default_agent_token_id
|
||||
|
||||
defp get_global_default_agent, do: Towerops.Settings.get_global_default_cloud_poller()
|
||||
|
||||
@doc """
|
||||
Gets a single device by ID, returns nil if not found.
|
||||
"""
|
||||
|
|
@ -344,6 +380,7 @@ defmodule Towerops.Devices do
|
|||
username: resolve_mikrotik_username(device),
|
||||
password: resolve_mikrotik_password(device),
|
||||
port: resolve_mikrotik_port(device),
|
||||
ssh_port: resolve_mikrotik_ssh_port(device),
|
||||
use_ssl: resolve_mikrotik_use_ssl(device),
|
||||
enabled: resolve_mikrotik_enabled(device),
|
||||
source: determine_mikrotik_source(device)
|
||||
|
|
@ -369,6 +406,13 @@ defmodule Towerops.Devices do
|
|||
8729
|
||||
end
|
||||
|
||||
defp resolve_mikrotik_ssh_port(device) do
|
||||
device.mikrotik_ssh_port ||
|
||||
device.site.mikrotik_ssh_port ||
|
||||
device.site.organization.mikrotik_ssh_port ||
|
||||
22
|
||||
end
|
||||
|
||||
defp resolve_mikrotik_use_ssl(device) do
|
||||
if device.mikrotik_use_ssl == nil do
|
||||
device.site.mikrotik_use_ssl ||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ defmodule Towerops.Devices.BackupRequest do
|
|||
field :completed_at, :utc_datetime
|
||||
field :status, :string
|
||||
field :error_message, :string
|
||||
field :source, :string
|
||||
|
||||
belongs_to :device, Towerops.Devices.Device
|
||||
|
||||
|
|
@ -30,10 +31,12 @@ defmodule Towerops.Devices.BackupRequest do
|
|||
:requested_at,
|
||||
:completed_at,
|
||||
:status,
|
||||
:error_message
|
||||
:error_message,
|
||||
:source
|
||||
])
|
||||
|> validate_required([:device_id, :job_id, :requested_at, :status])
|
||||
|> validate_required([:device_id, :job_id, :requested_at, :status, :source])
|
||||
|> validate_inclusion(:status, ["pending", "success", "failed", "timeout"])
|
||||
|> validate_inclusion(:source, ["manual", "daily_cron"])
|
||||
|> foreign_key_constraint(:device_id)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -11,12 +11,13 @@ defmodule Towerops.Devices.BackupRequests do
|
|||
@doc """
|
||||
Creates a new backup request for a device.
|
||||
"""
|
||||
def create_request(device_id, job_id) do
|
||||
def create_request(device_id, job_id, source \\ "daily_cron") do
|
||||
attrs = %{
|
||||
device_id: device_id,
|
||||
job_id: job_id,
|
||||
requested_at: DateTime.utc_now(),
|
||||
status: "pending"
|
||||
status: "pending",
|
||||
source: source
|
||||
}
|
||||
|
||||
%BackupRequest{}
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ defmodule Towerops.Devices.Device do
|
|||
field :mikrotik_username, :string
|
||||
field :mikrotik_password, Towerops.Encrypted.Binary
|
||||
field :mikrotik_port, :integer
|
||||
field :mikrotik_ssh_port, :integer
|
||||
field :mikrotik_use_ssl, :boolean
|
||||
field :mikrotik_enabled, :boolean
|
||||
field :mikrotik_credential_source, :string, default: "site"
|
||||
|
|
@ -75,6 +76,7 @@ defmodule Towerops.Devices.Device do
|
|||
mikrotik_username: String.t() | nil,
|
||||
mikrotik_password: String.t() | nil,
|
||||
mikrotik_port: integer() | nil,
|
||||
mikrotik_ssh_port: integer() | nil,
|
||||
mikrotik_use_ssl: boolean() | nil,
|
||||
mikrotik_enabled: boolean() | nil,
|
||||
mikrotik_credential_source: String.t() | nil,
|
||||
|
|
@ -110,6 +112,7 @@ defmodule Towerops.Devices.Device do
|
|||
:mikrotik_username,
|
||||
:mikrotik_password,
|
||||
:mikrotik_port,
|
||||
:mikrotik_ssh_port,
|
||||
:mikrotik_use_ssl,
|
||||
:mikrotik_enabled,
|
||||
:mikrotik_credential_source
|
||||
|
|
|
|||
|
|
@ -118,4 +118,11 @@ defmodule Towerops.Devices.MikrotikBackups do
|
|||
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
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ defmodule Towerops.Organizations.Organization do
|
|||
field :mikrotik_username, :string
|
||||
field :mikrotik_password, Towerops.Encrypted.Binary
|
||||
field :mikrotik_port, :integer, default: 8729
|
||||
field :mikrotik_ssh_port, :integer, default: 22
|
||||
field :mikrotik_use_ssl, :boolean, default: true
|
||||
field :mikrotik_enabled, :boolean, default: false
|
||||
|
||||
|
|
@ -55,6 +56,7 @@ defmodule Towerops.Organizations.Organization do
|
|||
mikrotik_username: String.t() | nil,
|
||||
mikrotik_password: String.t() | nil,
|
||||
mikrotik_port: integer() | nil,
|
||||
mikrotik_ssh_port: integer() | nil,
|
||||
mikrotik_use_ssl: boolean() | nil,
|
||||
mikrotik_enabled: boolean() | nil,
|
||||
default_agent_token_id: Ecto.UUID.t() | nil,
|
||||
|
|
@ -78,6 +80,7 @@ defmodule Towerops.Organizations.Organization do
|
|||
:mikrotik_username,
|
||||
:mikrotik_password,
|
||||
:mikrotik_port,
|
||||
:mikrotik_ssh_port,
|
||||
:mikrotik_use_ssl,
|
||||
:mikrotik_enabled
|
||||
])
|
||||
|
|
|
|||
|
|
@ -368,6 +368,7 @@ defmodule Towerops.Agent.MikrotikDevice do
|
|||
field :username, 3, type: :string
|
||||
field :password, 4, type: :string
|
||||
field :use_ssl, 5, type: :bool, json_name: "useSsl"
|
||||
field :ssh_port, 6, type: :uint32, json_name: "sshPort"
|
||||
end
|
||||
|
||||
defmodule Towerops.Agent.MikrotikCommand.ArgsEntry do
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ defmodule Towerops.Sites.Site do
|
|||
field :mikrotik_username, :string
|
||||
field :mikrotik_password, Towerops.Encrypted.Binary
|
||||
field :mikrotik_port, :integer
|
||||
field :mikrotik_ssh_port, :integer
|
||||
field :mikrotik_use_ssl, :boolean
|
||||
field :mikrotik_enabled, :boolean
|
||||
|
||||
|
|
@ -53,6 +54,7 @@ defmodule Towerops.Sites.Site do
|
|||
mikrotik_username: String.t() | nil,
|
||||
mikrotik_password: String.t() | nil,
|
||||
mikrotik_port: integer() | nil,
|
||||
mikrotik_ssh_port: integer() | nil,
|
||||
mikrotik_use_ssl: boolean() | nil,
|
||||
mikrotik_enabled: boolean() | nil,
|
||||
organization_id: Ecto.UUID.t(),
|
||||
|
|
@ -83,6 +85,7 @@ defmodule Towerops.Sites.Site do
|
|||
:mikrotik_username,
|
||||
:mikrotik_password,
|
||||
:mikrotik_port,
|
||||
:mikrotik_ssh_port,
|
||||
:mikrotik_use_ssl,
|
||||
:mikrotik_enabled
|
||||
])
|
||||
|
|
|
|||
|
|
@ -54,8 +54,10 @@ defmodule Towerops.Workers.MikrotikBackupWorker do
|
|||
end
|
||||
|
||||
defp backup_device(device) do
|
||||
# Check if device has an agent assigned
|
||||
if is_nil(device.agent_token_id) do
|
||||
# Resolve which agent this device should use (hierarchical: device -> site -> org -> global)
|
||||
agent_token_id = Devices.resolve_agent_token_id(device)
|
||||
|
||||
if is_nil(agent_token_id) do
|
||||
Logger.warning("Device has no agent assigned, skipping backup", device_id: device.id)
|
||||
{:skipped, :no_agent}
|
||||
else
|
||||
|
|
@ -64,6 +66,27 @@ defmodule Towerops.Workers.MikrotikBackupWorker do
|
|||
|
||||
mikrotik_config = Devices.get_mikrotik_config(device)
|
||||
|
||||
# Generate unique filename for this backup (without .rsc extension, RouterOS adds it)
|
||||
backup_filename = "towerops-backup-#{DateTime.to_unix(DateTime.utc_now())}"
|
||||
|
||||
# Build chunked read commands
|
||||
# Max chunk size is 32KB (32768 bytes), we'll read 10 chunks to cover 320KB
|
||||
# Most MikroTik configs are under this size
|
||||
chunk_size = 32_768
|
||||
num_chunks = 10
|
||||
|
||||
read_commands =
|
||||
for i <- 0..(num_chunks - 1) do
|
||||
%MikrotikCommand{
|
||||
command: "/file/read",
|
||||
args: %{
|
||||
"file" => "#{backup_filename}.rsc",
|
||||
"offset" => to_string(i * chunk_size),
|
||||
"chunk-size" => to_string(chunk_size)
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
job = %AgentJob{
|
||||
job_id: job_id,
|
||||
job_type: :MIKROTIK,
|
||||
|
|
@ -73,14 +96,25 @@ defmodule Towerops.Workers.MikrotikBackupWorker do
|
|||
username: mikrotik_config.username,
|
||||
password: mikrotik_config.password,
|
||||
port: mikrotik_config.port,
|
||||
ssh_port: mikrotik_config.ssh_port,
|
||||
use_ssl: mikrotik_config.use_ssl
|
||||
},
|
||||
mikrotik_commands: [
|
||||
%MikrotikCommand{
|
||||
command: "/export",
|
||||
args: %{"compact" => "yes"}
|
||||
}
|
||||
]
|
||||
mikrotik_commands:
|
||||
[
|
||||
# Step 1: Export config to file on router (compact format, no defaults)
|
||||
%MikrotikCommand{
|
||||
command: "/export",
|
||||
args: %{"file" => backup_filename, "compact" => ""}
|
||||
}
|
||||
] ++
|
||||
read_commands ++
|
||||
[
|
||||
# Final step: Delete the temporary file
|
||||
%MikrotikCommand{
|
||||
command: "/file/remove",
|
||||
args: %{"numbers" => "#{backup_filename}.rsc"}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
# Create tracking request
|
||||
|
|
@ -89,14 +123,14 @@ defmodule Towerops.Workers.MikrotikBackupWorker do
|
|||
# Broadcast to agent via PubSub
|
||||
Phoenix.PubSub.broadcast(
|
||||
Towerops.PubSub,
|
||||
"agent:#{device.agent_token_id}:backup",
|
||||
"agent:#{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
|
||||
agent_token_id: agent_token_id
|
||||
)
|
||||
|
||||
{:ok, job_id}
|
||||
|
|
|
|||
|
|
@ -678,14 +678,19 @@ defmodule ToweropsWeb.AgentChannel do
|
|||
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
|
||||
case extract_config_from_result(result) do
|
||||
{:ok, config_text} ->
|
||||
# Manual backups always create a snapshot, even if config unchanged
|
||||
# Automated backups (daily_cron) use deduplication to save storage
|
||||
should_create_backup =
|
||||
request.source == "manual" || backup_needed?(result.device_id, config_text)
|
||||
|
||||
if should_create_backup do
|
||||
create_and_save_backup(request, result, config_text)
|
||||
else
|
||||
mark_backup_skipped(request, result)
|
||||
end
|
||||
|
||||
{:error, reason} ->
|
||||
mark_backup_failed(request, result, "Failed to extract config: #{reason}")
|
||||
end
|
||||
|
|
@ -696,16 +701,21 @@ defmodule ToweropsWeb.AgentChannel do
|
|||
end
|
||||
|
||||
defp create_and_save_backup(request, result, config_text) do
|
||||
case MikrotikBackups.create_backup(result.device_id, config_text, "daily_cron") do
|
||||
case MikrotikBackups.create_backup(result.device_id, config_text, request.source) do
|
||||
{:ok, _backup} ->
|
||||
BackupRequests.update_request_status(request.id, "success", nil)
|
||||
Logger.info("Backup created", device_id: result.device_id)
|
||||
Logger.info("Backup created", device_id: result.device_id, source: request.source)
|
||||
|
||||
{:error, reason} ->
|
||||
mark_backup_failed(request, result, "Failed to create backup: #{inspect(reason)}")
|
||||
end
|
||||
end
|
||||
|
||||
defp backup_needed?(device_id, config_text) do
|
||||
{:ok, needs_backup?} = MikrotikBackups.needs_backup?(device_id, config_text)
|
||||
needs_backup?
|
||||
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)
|
||||
|
|
@ -717,31 +727,73 @@ defmodule ToweropsWeb.AgentChannel do
|
|||
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)
|
||||
# SSH backups return a single sentence with "config" attribute (new method)
|
||||
# API backups use /file/read which returns chunks with "data" attribute (old method)
|
||||
|
||||
case config_sentences do
|
||||
[sentence | _] ->
|
||||
{:ok, Map.get(sentence.attributes, ".after")}
|
||||
# Try SSH backup format first (single sentence with "config" attribute)
|
||||
config_direct =
|
||||
Enum.find_value(result.sentences, fn sentence -> Map.get(sentence.attributes, "config") end)
|
||||
|
||||
case config_direct do
|
||||
nil ->
|
||||
# Try chunked API backup format
|
||||
extract_config_from_chunked_data(result)
|
||||
|
||||
config_text when is_binary(config_text) and config_text != "" ->
|
||||
{:ok, config_text}
|
||||
|
||||
_ ->
|
||||
{:error, "Config attribute found but empty or invalid"}
|
||||
end
|
||||
end
|
||||
|
||||
defp extract_config_from_chunked_data(result) do
|
||||
# The old API backup workflow uses /file/read which returns chunks with "data" attribute
|
||||
data_chunks =
|
||||
result.sentences
|
||||
|> Enum.filter(fn sentence -> Map.has_key?(sentence.attributes, "data") end)
|
||||
|> Enum.map(fn sentence -> Map.get(sentence.attributes, "data") end)
|
||||
|
||||
case data_chunks do
|
||||
[] ->
|
||||
# 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")
|
||||
# Fallback: try other methods
|
||||
extract_config_from_fallback(result)
|
||||
|
||||
chunks ->
|
||||
# Combine all chunks in order
|
||||
config_text = Enum.join(chunks, "")
|
||||
|
||||
if config_text == "" do
|
||||
{:error, "No config data in result"}
|
||||
{:error, "No config data in result - all chunks were empty"}
|
||||
else
|
||||
{:ok, config_text}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp extract_config_from_fallback(result) do
|
||||
# Fallback: try to find any sentence with configuration-like content
|
||||
# Look for sentences with common RouterOS config markers
|
||||
config_text =
|
||||
Enum.find_value(result.sentences, fn sentence ->
|
||||
find_config_in_attributes(sentence.attributes)
|
||||
end)
|
||||
|
||||
case config_text do
|
||||
nil -> {:error, "No config data in result - received #{length(result.sentences)} sentences"}
|
||||
text -> {:ok, text}
|
||||
end
|
||||
end
|
||||
|
||||
defp find_config_in_attributes(attributes) do
|
||||
Enum.find_value(attributes, fn {_key, value} ->
|
||||
if routeros_config?(value), do: value
|
||||
end)
|
||||
end
|
||||
|
||||
defp routeros_config?(value) when is_binary(value) do
|
||||
String.contains?(value, ["# ", "/", "set "]) and String.length(value) > 100
|
||||
end
|
||||
|
||||
defp routeros_config?(_), do: false
|
||||
end
|
||||
|
|
|
|||
|
|
@ -62,11 +62,15 @@ defmodule ToweropsWeb.DeviceLive.Show do
|
|||
maybe_subscribe_and_schedule_refresh(socket, id)
|
||||
|
||||
tab = Map.get(params, "tab", "overview")
|
||||
page = params |> Map.get("page", "1") |> String.to_integer()
|
||||
|
||||
socket
|
||||
|> load_equipment_data(id)
|
||||
|> assign(:active_tab, tab)
|
||||
|> then(&{:noreply, &1})
|
||||
socket =
|
||||
socket
|
||||
|> load_equipment_data(id)
|
||||
|> assign(:active_tab, tab)
|
||||
|> apply_backups_pagination(tab, page)
|
||||
|
||||
{:noreply, socket}
|
||||
else
|
||||
{:noreply,
|
||||
socket
|
||||
|
|
@ -283,6 +287,7 @@ defmodule ToweropsWeb.DeviceLive.Show do
|
|||
|> assign(:agent_info, agent_info)
|
||||
|> assign(:available_firmware, available_firmware)
|
||||
|> assign(:mikrotik_backups, mikrotik_backups)
|
||||
|> assign(:selected_backup_ids, MapSet.new())
|
||||
end
|
||||
|
||||
defp get_available_firmware(nil), do: nil
|
||||
|
|
@ -951,7 +956,7 @@ defmodule ToweropsWeb.DeviceLive.Show do
|
|||
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)
|
||||
config_text = MikrotikBackups.decompress_config(backup.config_compressed)
|
||||
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"})}
|
||||
|
|
@ -961,32 +966,54 @@ defmodule ToweropsWeb.DeviceLive.Show do
|
|||
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)
|
||||
def handle_event("toggle_backup_selection", %{"id" => backup_id}, socket) do
|
||||
selected = socket.assigns.selected_backup_ids
|
||||
|
||||
case MikrotikBackups.get_latest_backup(device_id) do
|
||||
nil ->
|
||||
{:noreply, put_flash(socket, :error, t_equipment("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, t_equipment("No other backups available for comparison"))}
|
||||
updated_selected =
|
||||
if MapSet.member?(selected, backup_id) do
|
||||
MapSet.delete(selected, backup_id)
|
||||
else
|
||||
# Limit to 2 selections max
|
||||
if MapSet.size(selected) >= 2 do
|
||||
selected
|
||||
else
|
||||
MapSet.put(selected, backup_id)
|
||||
end
|
||||
end
|
||||
|
||||
latest ->
|
||||
{:noreply, assign(socket, :selected_backup_ids, updated_selected)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("compare_selected", _params, socket) do
|
||||
device_id = socket.assigns.device.id
|
||||
selected = MapSet.to_list(socket.assigns.selected_backup_ids)
|
||||
|
||||
case selected do
|
||||
[backup_a, backup_b] ->
|
||||
{:noreply,
|
||||
push_navigate(socket,
|
||||
to: ~p"/devices/#{device_id}/backups/compare?backup_a=#{backup_id}&backup_b=#{latest.id}"
|
||||
)}
|
||||
socket
|
||||
|> assign(:selected_backup_ids, MapSet.new())
|
||||
|> push_navigate(to: ~p"/devices/#{device_id}/backups/compare?backup_a=#{backup_a}&backup_b=#{backup_b}")}
|
||||
|
||||
_ ->
|
||||
{:noreply, put_flash(socket, :error, t_equipment("Please select exactly 2 backups to compare"))}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("clear_selection", _params, socket) do
|
||||
{:noreply, assign(socket, :selected_backup_ids, MapSet.new())}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("delete_backup", %{"id" => backup_id}, socket) do
|
||||
import ToweropsWeb.Permissions
|
||||
|
||||
if owner?(socket) do
|
||||
do_delete_backup(backup_id, socket)
|
||||
else
|
||||
{:noreply, put_flash(socket, :error, t_equipment("Only organization owners can delete backups"))}
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -1017,6 +1044,26 @@ defmodule ToweropsWeb.DeviceLive.Show do
|
|||
job_id = "backup:#{device.id}:#{DateTime.to_unix(DateTime.utc_now())}"
|
||||
mikrotik_config = Devices.get_mikrotik_config(device)
|
||||
|
||||
# Generate unique filename for this backup (without .rsc extension, RouterOS adds it)
|
||||
backup_filename = "towerops-backup-#{DateTime.to_unix(DateTime.utc_now())}"
|
||||
|
||||
# Build chunked read commands
|
||||
# Max chunk size is 32KB (32768 bytes), we'll read 10 chunks to cover 320KB
|
||||
chunk_size = 32_768
|
||||
num_chunks = 10
|
||||
|
||||
read_commands =
|
||||
for i <- 0..(num_chunks - 1) do
|
||||
%MikrotikCommand{
|
||||
command: "/file/read",
|
||||
args: %{
|
||||
"file" => "#{backup_filename}.rsc",
|
||||
"offset" => to_string(i * chunk_size),
|
||||
"chunk-size" => to_string(chunk_size)
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
job = %AgentJob{
|
||||
job_id: job_id,
|
||||
job_type: :MIKROTIK,
|
||||
|
|
@ -1026,17 +1073,28 @@ defmodule ToweropsWeb.DeviceLive.Show do
|
|||
username: mikrotik_config.username,
|
||||
password: mikrotik_config.password,
|
||||
port: mikrotik_config.port,
|
||||
ssh_port: mikrotik_config.ssh_port,
|
||||
use_ssl: mikrotik_config.use_ssl
|
||||
},
|
||||
mikrotik_commands: [
|
||||
%MikrotikCommand{
|
||||
command: "/export",
|
||||
args: %{"compact" => "yes"}
|
||||
}
|
||||
]
|
||||
mikrotik_commands:
|
||||
[
|
||||
# Step 1: Export config to file on router (compact format, no defaults)
|
||||
%MikrotikCommand{
|
||||
command: "/export",
|
||||
args: %{"file" => backup_filename, "compact" => ""}
|
||||
}
|
||||
] ++
|
||||
read_commands ++
|
||||
[
|
||||
# Final step: Delete the temporary file
|
||||
%MikrotikCommand{
|
||||
command: "/file/remove",
|
||||
args: %{"numbers" => "#{backup_filename}.rsc"}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
case BackupRequests.create_request(device.id, job_id) do
|
||||
case BackupRequests.create_request(device.id, job_id, "manual") do
|
||||
{:ok, _request} ->
|
||||
Phoenix.PubSub.broadcast(
|
||||
Towerops.PubSub,
|
||||
|
|
@ -1053,4 +1111,54 @@ defmodule ToweropsWeb.DeviceLive.Show do
|
|||
{:noreply, put_flash(socket, :error, t_equipment("Failed to create backup request. Please try again."))}
|
||||
end
|
||||
end
|
||||
|
||||
# Private helper for deleting backups
|
||||
defp do_delete_backup(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
|
||||
case MikrotikBackups.delete_backup(backup) do
|
||||
{:ok, _} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, t_equipment("Backup deleted successfully"))
|
||||
|> load_equipment_data(socket.assigns.device.id)}
|
||||
|
||||
{:error, _changeset} ->
|
||||
{:noreply, put_flash(socket, :error, t_equipment("Failed to delete backup"))}
|
||||
end
|
||||
else
|
||||
{:noreply, put_flash(socket, :error, t_equipment("You don't have permission to access this backup"))}
|
||||
end
|
||||
end
|
||||
|
||||
# Apply pagination to backups list if on the backups tab
|
||||
defp apply_backups_pagination(socket, "backups", page) when is_map_key(socket.assigns, :mikrotik_backups) do
|
||||
per_page = 20
|
||||
all_backups = socket.assigns.mikrotik_backups
|
||||
total_count = length(all_backups)
|
||||
total_pages = ceil(total_count / per_page)
|
||||
|
||||
# Ensure page is within valid range
|
||||
page = max(1, min(page, max(1, total_pages)))
|
||||
|
||||
# Slice backups for current page
|
||||
offset = (page - 1) * per_page
|
||||
backups_page = Enum.slice(all_backups, offset, per_page)
|
||||
|
||||
socket
|
||||
|> assign(:mikrotik_backups, backups_page)
|
||||
|> assign(:pagination, %{
|
||||
page: page,
|
||||
per_page: per_page,
|
||||
total_count: total_count,
|
||||
total_pages: total_pages
|
||||
})
|
||||
end
|
||||
|
||||
defp apply_backups_pagination(socket, _tab, _page), do: socket
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1484,10 +1484,25 @@
|
|||
</div>
|
||||
</div>
|
||||
<%= if Enum.any?(@mikrotik_backups) do %>
|
||||
<%!-- Instructions for comparing backups --%>
|
||||
<%= if length(@mikrotik_backups) > 1 do %>
|
||||
<div class="px-4 py-3 bg-blue-50 dark:bg-blue-950/30 border-b border-blue-100 dark:border-blue-900/50">
|
||||
<div class="flex items-start gap-2">
|
||||
<.icon name="hero-information-circle" class="h-5 w-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" />
|
||||
<div class="text-sm text-blue-900 dark:text-blue-200">
|
||||
<span class="font-medium">Compare configurations:</span>
|
||||
Select any 2 backups using the checkboxes to compare their differences
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-white/10">
|
||||
<thead class="bg-gray-50 dark:bg-gray-800/75">
|
||||
<tr class="text-xs text-gray-600 dark:text-gray-400">
|
||||
<th class="px-4 py-3 text-center font-medium w-12" title="Select backups to compare">
|
||||
<.icon name="hero-check-circle" class="h-4 w-4 mx-auto" />
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left font-medium">Date</th>
|
||||
<th class="px-4 py-3 text-left font-medium">Original Size</th>
|
||||
<th class="px-4 py-3 text-left font-medium">Compressed Size</th>
|
||||
|
|
@ -1508,8 +1523,30 @@
|
|||
0.0
|
||||
end %>
|
||||
<tr class="text-sm hover:bg-gray-50 dark:hover:bg-gray-800">
|
||||
<td class="px-4 py-3 text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
phx-click="toggle_backup_selection"
|
||||
phx-value-id={backup.id}
|
||||
checked={MapSet.member?(@selected_backup_ids, backup.id)}
|
||||
disabled={
|
||||
!MapSet.member?(@selected_backup_ids, backup.id) &&
|
||||
MapSet.size(@selected_backup_ids) >= 2
|
||||
}
|
||||
title={
|
||||
if MapSet.member?(@selected_backup_ids, backup.id),
|
||||
do: "Selected for comparison",
|
||||
else:
|
||||
if(MapSet.size(@selected_backup_ids) >= 2,
|
||||
do: "Maximum 2 backups can be selected",
|
||||
else: "Select to compare"
|
||||
)
|
||||
}
|
||||
class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-600 disabled:opacity-50 disabled:cursor-not-allowed dark:border-gray-600 dark:bg-gray-700"
|
||||
/>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-900 dark:text-white whitespace-nowrap">
|
||||
{Calendar.strftime(backup.backed_up_at, "%Y-%m-%d %H:%M:%S")}
|
||||
{ToweropsWeb.TimeHelpers.format_iso8601(backup.backed_up_at, @timezone)}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-600 dark:text-gray-400 font-mono">
|
||||
{format_bytes(backup.config_size_bytes)}
|
||||
|
|
@ -1543,13 +1580,14 @@
|
|||
>
|
||||
<.icon name="hero-arrow-down-tray" class="h-4 w-4" /> Download
|
||||
</button>
|
||||
<%= if length(@mikrotik_backups) > 1 do %>
|
||||
<%= if ToweropsWeb.Permissions.owner?(@current_scope) do %>
|
||||
<button
|
||||
phx-click="compare_backup"
|
||||
phx-click="delete_backup"
|
||||
phx-value-id={backup.id}
|
||||
class="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-300"
|
||||
data-confirm="Are you sure you want to delete this backup? This action cannot be undone."
|
||||
class="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300"
|
||||
>
|
||||
<.icon name="hero-arrows-right-left" class="h-4 w-4" /> Compare
|
||||
<.icon name="hero-trash" class="h-4 w-4" /> Delete
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
|
|
@ -1559,6 +1597,58 @@
|
|||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<%!-- Pagination --%>
|
||||
<%= if assigns[:pagination] do %>
|
||||
<div class="px-4 py-3 border-t border-gray-200 dark:border-white/10">
|
||||
<.pagination
|
||||
meta={@pagination}
|
||||
path={~p"/devices/#{@device.id}"}
|
||||
params={%{"tab" => "backups"}}
|
||||
/>
|
||||
</div>
|
||||
<% end %>
|
||||
<%!-- Floating Compare Button --%>
|
||||
<%= if MapSet.size(@selected_backup_ids) > 0 do %>
|
||||
<div class="fixed bottom-8 right-8 z-50">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-white/10 p-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<%!-- Selection status --%>
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<.icon name="hero-check-circle" class="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||||
<div class="text-gray-700 dark:text-gray-300">
|
||||
<span class="font-medium">{MapSet.size(@selected_backup_ids)}</span>
|
||||
of 2 backups selected
|
||||
</div>
|
||||
</div>
|
||||
<%!-- Instructions when only 1 selected --%>
|
||||
<%= if MapSet.size(@selected_backup_ids) == 1 do %>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 pl-7">
|
||||
Select one more backup to compare
|
||||
</div>
|
||||
<% end %>
|
||||
<%!-- Action buttons --%>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<button
|
||||
phx-click="clear_selection"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 border border-gray-300 rounded-lg transition-colors dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-600"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
<button
|
||||
phx-click="compare_selected"
|
||||
disabled={MapSet.size(@selected_backup_ids) != 2}
|
||||
class="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed rounded-lg transition-colors shadow-sm"
|
||||
>
|
||||
<.icon name="hero-arrows-right-left" class="h-4 w-4" />
|
||||
{if MapSet.size(@selected_backup_ids) == 2,
|
||||
do: "Compare Selected",
|
||||
else: "Compare"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<div class="p-8 text-center">
|
||||
<.icon name="hero-document-text" class="mx-auto h-12 w-12 text-gray-400" />
|
||||
|
|
|
|||
|
|
@ -192,6 +192,10 @@ defmodule ToweropsWeb.HelpLive.Index do
|
|||
interface that makes complex monitoring workflows simple and accessible.
|
||||
</p>
|
||||
|
||||
<p class="text-gray-600 dark:text-gray-400 mt-4">
|
||||
Towerops is not and has no plans to be a replacement for a full WISP/network billing system.
|
||||
</p>
|
||||
|
||||
<div class="mt-6 grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div class="p-4 bg-gray-50 dark:bg-gray-800/50 border border-gray-200 dark:border-white/10 rounded-lg">
|
||||
<h4 class="text-sm font-semibold text-gray-900 dark:text-white mb-2">
|
||||
|
|
|
|||
|
|
@ -56,8 +56,8 @@ defmodule ToweropsWeb.MikrotikBackupLive.Compare do
|
|||
|
||||
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)
|
||||
config_a = MikrotikBackups.decompress_config(backup_a.config_compressed)
|
||||
config_b = MikrotikBackups.decompress_config(backup_b.config_compressed)
|
||||
|
||||
# Mask sensitive data
|
||||
config_a_masked = Differ.mask_sensitive_data(config_a)
|
||||
|
|
@ -88,7 +88,7 @@ defmodule ToweropsWeb.MikrotikBackupLive.Compare do
|
|||
@impl true
|
||||
def handle_event("download_config", %{"backup" => "a"}, socket) do
|
||||
backup = socket.assigns.backup_a
|
||||
config_text = MikrotikBackups.decompress_config(backup)
|
||||
config_text = MikrotikBackups.decompress_config(backup.config_compressed)
|
||||
device = socket.assigns.device
|
||||
|
||||
filename = "#{device.name}_backup_#{Calendar.strftime(backup.backed_up_at, "%Y%m%d_%H%M%S")}.rsc"
|
||||
|
|
@ -99,7 +99,7 @@ defmodule ToweropsWeb.MikrotikBackupLive.Compare do
|
|||
@impl true
|
||||
def handle_event("download_config", %{"backup" => "b"}, socket) do
|
||||
backup = socket.assigns.backup_b
|
||||
config_text = MikrotikBackups.decompress_config(backup)
|
||||
config_text = MikrotikBackups.decompress_config(backup.config_compressed)
|
||||
device = socket.assigns.device
|
||||
|
||||
filename = "#{device.name}_backup_#{Calendar.strftime(backup.backed_up_at, "%Y%m%d_%H%M%S")}.rsc"
|
||||
|
|
|
|||
161
lib/towerops_web/permissions.ex
Normal file
161
lib/towerops_web/permissions.ex
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
defmodule ToweropsWeb.Permissions do
|
||||
@moduledoc """
|
||||
Authorization helpers for checking user permissions in LiveViews, controllers, and templates.
|
||||
|
||||
Integrates with `Towerops.Organizations.Policy` to provide a simple, reusable API
|
||||
for permission checks based on organization membership roles.
|
||||
|
||||
## Usage in LiveViews
|
||||
|
||||
import ToweropsWeb.Permissions
|
||||
|
||||
def handle_event("delete_backup", _params, socket) do
|
||||
if can?(socket, :delete, :backup) do
|
||||
# Delete backup
|
||||
else
|
||||
{:noreply, put_flash(socket, :error, "You don't have permission to delete backups")}
|
||||
end
|
||||
end
|
||||
|
||||
## Usage in Templates
|
||||
|
||||
<%= if can?(@current_scope, :delete, :backup) do %>
|
||||
<button phx-click="delete_backup">Delete</button>
|
||||
<% end %>
|
||||
|
||||
## Usage in Controllers
|
||||
|
||||
import ToweropsWeb.Permissions
|
||||
|
||||
def delete(conn, _params) do
|
||||
if can?(conn.assigns.current_scope, :delete, :backup) do
|
||||
# Delete backup
|
||||
else
|
||||
conn
|
||||
|> put_flash(:error, "You don't have permission to delete backups")
|
||||
|> redirect(to: ~p"/")
|
||||
end
|
||||
end
|
||||
"""
|
||||
|
||||
alias Phoenix.LiveView.Socket
|
||||
alias Towerops.Accounts.Scope
|
||||
alias Towerops.Organizations.Policy
|
||||
|
||||
@doc """
|
||||
Checks if the current user can perform an action on a resource.
|
||||
|
||||
Accepts either a socket (LiveView) or a Scope struct directly.
|
||||
|
||||
Returns `true` if:
|
||||
- User is a superuser (can do everything)
|
||||
- User's organization membership role allows the action per Policy
|
||||
|
||||
## Examples
|
||||
|
||||
iex> can?(socket, :delete, :backup)
|
||||
true
|
||||
|
||||
iex> can?(%Scope{}, :edit, :device)
|
||||
false
|
||||
"""
|
||||
def can?(%Socket{} = socket, action, resource) do
|
||||
can?(socket.assigns.current_scope, action, resource)
|
||||
end
|
||||
|
||||
def can?(%Scope{} = scope, action, resource) do
|
||||
cond do
|
||||
# Superusers can do everything
|
||||
Scope.superuser?(scope) ->
|
||||
true
|
||||
|
||||
# No organization context - cannot perform action
|
||||
is_nil(scope.organization) ->
|
||||
false
|
||||
|
||||
# Check organization membership role permissions
|
||||
true ->
|
||||
membership = get_membership(scope)
|
||||
Policy.can?(membership, action, resource)
|
||||
end
|
||||
end
|
||||
|
||||
def can?(nil, _action, _resource), do: false
|
||||
|
||||
@doc """
|
||||
Checks if the current user is an organization owner.
|
||||
|
||||
Accepts either a socket (LiveView) or a Scope struct directly.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> owner?(socket)
|
||||
true
|
||||
|
||||
iex> owner?(%Scope{})
|
||||
false
|
||||
"""
|
||||
def owner?(%Socket{} = socket) do
|
||||
owner?(socket.assigns.current_scope)
|
||||
end
|
||||
|
||||
def owner?(%Scope{} = scope) do
|
||||
cond do
|
||||
Scope.superuser?(scope) ->
|
||||
true
|
||||
|
||||
is_nil(scope.organization) ->
|
||||
false
|
||||
|
||||
true ->
|
||||
membership = get_membership(scope)
|
||||
membership && membership.role == :owner
|
||||
end
|
||||
end
|
||||
|
||||
def owner?(nil), do: false
|
||||
|
||||
@doc """
|
||||
Checks if the current user is an organization owner or admin.
|
||||
|
||||
Accepts either a socket (LiveView) or a Scope struct directly.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> admin?(socket)
|
||||
true
|
||||
|
||||
iex> admin?(%Scope{})
|
||||
false
|
||||
"""
|
||||
def admin?(%Socket{} = socket) do
|
||||
admin?(socket.assigns.current_scope)
|
||||
end
|
||||
|
||||
def admin?(%Scope{} = scope) do
|
||||
cond do
|
||||
Scope.superuser?(scope) ->
|
||||
true
|
||||
|
||||
is_nil(scope.organization) ->
|
||||
false
|
||||
|
||||
true ->
|
||||
membership = get_membership(scope)
|
||||
membership && membership.role in [:owner, :admin]
|
||||
end
|
||||
end
|
||||
|
||||
def admin?(nil), do: false
|
||||
|
||||
# Private helper to extract membership from scope
|
||||
defp get_membership(%Scope{organization: org, user: user}) when not is_nil(org) and not is_nil(user) do
|
||||
# Organization should be preloaded with memberships
|
||||
if Ecto.assoc_loaded?(org.memberships) do
|
||||
Enum.find(org.memberships, &(&1.user_id == user.id))
|
||||
# If memberships not preloaded, return nil (permission check will fail safely)
|
||||
end
|
||||
end
|
||||
|
||||
defp get_membership(_scope), do: nil
|
||||
end
|
||||
|
|
@ -107,64 +107,63 @@ message HeartbeatResponse {
|
|||
string status = 1;
|
||||
}
|
||||
|
||||
// Channel-based communication messages
|
||||
// WebSocket-specific messages
|
||||
|
||||
enum JobType {
|
||||
DISCOVER = 0; // Full device discovery
|
||||
POLL = 1; // Poll known sensors/interfaces
|
||||
MIKROTIK = 2; // MikroTik RouterOS API commands
|
||||
DISCOVER = 0;
|
||||
POLL = 1;
|
||||
MIKROTIK = 2;
|
||||
}
|
||||
|
||||
enum QueryType {
|
||||
GET = 0; // SNMP GET operation
|
||||
WALK = 1; // SNMP WALK operation
|
||||
}
|
||||
|
||||
message AgentJob {
|
||||
string job_id = 1; // Unique job identifier (e.g., "discover:eq123")
|
||||
JobType job_type = 2; // DISCOVER or POLL
|
||||
string device_id = 3; // Device UUID
|
||||
SnmpDevice snmp_device = 4; // SNMP connection details
|
||||
repeated SnmpQuery queries = 5; // Queries to execute
|
||||
MikrotikDevice mikrotik_device = 6; // MikroTik API connection details
|
||||
repeated MikrotikCommand mikrotik_commands = 7; // MikroTik commands to execute
|
||||
GET = 0;
|
||||
WALK = 1;
|
||||
}
|
||||
|
||||
message AgentJobList {
|
||||
repeated AgentJob jobs = 1;
|
||||
}
|
||||
|
||||
message AgentJob {
|
||||
string job_id = 1;
|
||||
JobType job_type = 2;
|
||||
string device_id = 3;
|
||||
SnmpDevice snmp_device = 4;
|
||||
repeated SnmpQuery queries = 5;
|
||||
MikrotikDevice mikrotik_device = 6;
|
||||
repeated MikrotikCommand mikrotik_commands = 7;
|
||||
}
|
||||
|
||||
message SnmpDevice {
|
||||
string ip = 1;
|
||||
string community = 2;
|
||||
string version = 3; // "1", "2c", or "3"
|
||||
string version = 3;
|
||||
uint32 port = 4;
|
||||
}
|
||||
|
||||
message SnmpQuery {
|
||||
QueryType query_type = 1;
|
||||
repeated string oids = 2; // OIDs to query (GET) or walk (WALK)
|
||||
repeated string oids = 2;
|
||||
}
|
||||
|
||||
message SnmpResult {
|
||||
string device_id = 1;
|
||||
JobType job_type = 2;
|
||||
map<string, string> oid_values = 3; // OID → value mapping
|
||||
int64 timestamp = 4; // Unix timestamp in seconds
|
||||
map<string, string> oid_values = 3;
|
||||
int64 timestamp = 4;
|
||||
}
|
||||
|
||||
message AgentHeartbeat {
|
||||
string version = 1;
|
||||
string hostname = 2;
|
||||
uint64 uptime_seconds = 3;
|
||||
string ip_address = 4; // Agent's IP address
|
||||
string ip_address = 4;
|
||||
}
|
||||
|
||||
message AgentError {
|
||||
string device_id = 1;
|
||||
string job_id = 2;
|
||||
string message = 3;
|
||||
int64 timestamp = 4;
|
||||
string error_message = 2;
|
||||
int64 timestamp = 3;
|
||||
}
|
||||
|
||||
// MikroTik RouterOS API messages
|
||||
|
|
@ -174,22 +173,23 @@ message MikrotikDevice {
|
|||
uint32 port = 2;
|
||||
string username = 3;
|
||||
string password = 4;
|
||||
bool use_ssl = 5; // true = API-SSL (port 8729), false = plain API (port 8728)
|
||||
bool use_ssl = 5;
|
||||
uint32 ssh_port = 6;
|
||||
}
|
||||
|
||||
message MikrotikCommand {
|
||||
string command = 1; // RouterOS command path (e.g., "/system/identity/print")
|
||||
map<string, string> args = 2; // Command arguments
|
||||
string command = 1;
|
||||
map<string, string> args = 2;
|
||||
}
|
||||
|
||||
message MikrotikResult {
|
||||
string device_id = 1;
|
||||
string job_id = 2;
|
||||
repeated MikrotikSentence sentences = 3; // Response sentences from RouterOS
|
||||
string error = 4; // Error message if command failed
|
||||
int64 timestamp = 5; // Unix timestamp in seconds
|
||||
repeated MikrotikSentence sentences = 3;
|
||||
string error = 4;
|
||||
int64 timestamp = 5;
|
||||
}
|
||||
|
||||
message MikrotikSentence {
|
||||
map<string, string> attributes = 1; // Key-value pairs from RouterOS response
|
||||
map<string, string> attributes = 1;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
defmodule Towerops.Repo.Migrations.AddMikrotikSshPort do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
# Add SSH port to organizations (default 22 for new orgs)
|
||||
alter table(:organizations) do
|
||||
add :mikrotik_ssh_port, :integer, default: 22
|
||||
end
|
||||
|
||||
# Add SSH port to sites (nullable for inheritance)
|
||||
alter table(:sites) do
|
||||
add :mikrotik_ssh_port, :integer
|
||||
end
|
||||
|
||||
# Add SSH port to devices (nullable for inheritance)
|
||||
alter table(:devices) do
|
||||
add :mikrotik_ssh_port, :integer
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
defmodule Towerops.Repo.Migrations.AddSourceToBackupRequests do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
alter table(:device_backup_requests) do
|
||||
add :source, :string, default: "daily_cron"
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Reference in a new issue