handle mikrotik ssh

This commit is contained in:
Graham McIntire 2026-02-02 16:42:18 -06:00
parent 17348bb7e3
commit aaf8c38b78
No known key found for this signature in database
18 changed files with 660 additions and 117 deletions

View file

@ -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 ||

View file

@ -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

View file

@ -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{}

View file

@ -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

View file

@ -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

View file

@ -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
])

View file

@ -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

View file

@ -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
])

View file

@ -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}

View file

@ -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

View file

@ -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

View file

@ -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" />

View file

@ -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">

View file

@ -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"

View 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

View file

@ -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;
}

View file

@ -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

View file

@ -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