doc update

This commit is contained in:
Graham McIntire 2026-02-03 09:07:06 -06:00
parent e181c17f78
commit 5b84844e97
No known key found for this signature in database
7 changed files with 461 additions and 10 deletions

View file

@ -940,4 +940,5 @@ When writing new LiveView code or JavaScript hooks:
- Compare - memory should not continuously grow
- assets are rebuilt automatically on filesystem change
- never try to run npm, it's not included in phoenix
- assets build themselves on file change, you do not need to ever run mix assets.build
- assets build themselves on file change, you do not need to ever run mix assets.build
- never write custom one-off tests, if you're going to write a test, write a proper exunit test

View file

@ -14,6 +14,7 @@ defmodule Towerops.Devices.Device do
alias Ecto.Association.NotLoaded
alias Towerops.Agents.AgentAssignment
alias Towerops.Encrypted.Binary
alias Towerops.Sites.Site
alias Towerops.Snmp.Device, as: SnmpDevice
@ -40,8 +41,8 @@ defmodule Towerops.Devices.Device do
field :last_snmp_poll_at, :utc_datetime
# MikroTik API credentials (device-level overrides)
field :mikrotik_username, :string
field :mikrotik_password, Towerops.Encrypted.Binary
field :mikrotik_username, Binary
field :mikrotik_password, Binary
field :mikrotik_port, :integer
field :mikrotik_ssh_port, :integer
field :mikrotik_use_ssl, :boolean

View file

@ -15,6 +15,7 @@ defmodule Towerops.Organizations.Organization do
alias Ecto.Association.NotLoaded
alias Towerops.Agents.AgentToken
alias Towerops.Encrypted.Binary
alias Towerops.Organizations.Invitation
alias Towerops.Organizations.Membership
@ -30,8 +31,8 @@ defmodule Towerops.Organizations.Organization do
field :snmp_community, :string
# MikroTik API credentials (organization-level defaults cascade to site → device)
field :mikrotik_username, :string
field :mikrotik_password, Towerops.Encrypted.Binary
field :mikrotik_username, Binary
field :mikrotik_password, Binary
field :mikrotik_port, :integer, default: 8729
field :mikrotik_ssh_port, :integer, default: 22
field :mikrotik_use_ssl, :boolean, default: true

View file

@ -11,6 +11,7 @@ defmodule Towerops.Sites.Site do
alias Ecto.Association.NotLoaded
alias Towerops.Agents.AgentToken
alias Towerops.Devices.Device
alias Towerops.Encrypted.Binary
alias Towerops.Organizations.Organization
alias Towerops.Sites.Site
@ -27,8 +28,8 @@ defmodule Towerops.Sites.Site do
field :snmp_community, :string
# MikroTik API credentials (overrides organization default)
field :mikrotik_username, :string
field :mikrotik_password, Towerops.Encrypted.Binary
field :mikrotik_username, Binary
field :mikrotik_password, Binary
field :mikrotik_port, :integer
field :mikrotik_ssh_port, :integer
field :mikrotik_use_ssl, :boolean

View file

@ -36,6 +36,13 @@ defmodule ToweropsWeb.HelpLive.Index do
|> assign(:is_authenticated, is_authenticated)
|> assign(:current_scope, current_scope)
|> assign(:timezone, if(current_user, do: current_user.timezone, else: "UTC"))
|> assign(:generated_password, nil)
|> assign(:password_generating, false)
# Generate initial password
if connected?(socket) do
send(self(), :fetch_random_password)
end
{:ok, socket}
end
@ -43,7 +50,58 @@ defmodule ToweropsWeb.HelpLive.Index do
@impl true
def handle_params(params, _url, socket) do
section = Map.get(params, "section", "about")
{:noreply, assign(socket, active_section: section)}
socket =
socket
|> assign(:active_section, section)
|> assign(:generated_password, nil)
|> assign(:password_generating, false)
{:noreply, socket}
end
@impl true
def handle_event("generate_password", _params, socket) do
socket = assign(socket, :password_generating, true)
send(self(), :fetch_random_password)
{:noreply, socket}
end
@impl true
def handle_info(:fetch_random_password, socket) do
case generate_random_password() do
{:ok, password} ->
{:noreply,
socket
|> assign(:generated_password, password)
|> assign(:password_generating, false)
|> put_flash(:info, "Password generated successfully from random.org")}
{:error, reason} ->
{:noreply,
socket
|> assign(:password_generating, false)
|> put_flash(:error, "Failed to generate password: #{reason}")}
end
end
defp generate_random_password do
# Generate a 24-character truly random password from random.org
# Format: uppercase, lowercase, and digits
url =
"https://www.random.org/strings/?num=1&len=24&digits=on&upperalpha=on&loweralpha=on&unique=on&format=plain&rnd=new"
case Req.get(url) do
{:ok, %{status: 200, body: body}} ->
password = String.trim(body)
{:ok, password}
{:ok, %{status: status}} ->
{:error, "random.org returned status #{status}"}
{:error, error} ->
{:error, Exception.message(error)}
end
end
defp get_current_user(socket, session) do
@ -69,6 +127,8 @@ defmodule ToweropsWeb.HelpLive.Index do
end
attr :active_section, :string, required: true
attr :generated_password, :string, default: nil
attr :password_generating, :boolean, default: false
defp help_content(assigns) do
~H"""
@ -148,6 +208,21 @@ defmodule ToweropsWeb.HelpLive.Index do
Remote Pollers
</.link>
</li>
<li>
<.link
patch={~p"/help?section=mikrotik"}
class={[
"block px-3 py-2 rounded-md text-sm font-medium transition-colors",
if @active_section == "mikrotik" do
"bg-blue-50 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300"
else
"text-gray-700 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-gray-800"
end
]}
>
MikroTik
</.link>
</li>
</ul>
</nav>
</div>
@ -626,6 +701,338 @@ defmodule ToweropsWeb.HelpLive.Index do
</div>
</div>
</div>
<% "mikrotik" -> %>
<div class="p-6">
<h2 class="text-xl font-bold text-gray-900 dark:text-white mb-4">
MikroTik Configuration
</h2>
<div class="prose prose-sm dark:prose-invert max-w-none">
<p class="text-gray-600 dark:text-gray-400">
Towerops supports read-only monitoring of MikroTik RouterOS devices via SSH.
In addition to SNMP polling, SSH access allows Towerops to retrieve detailed system information
and create configuration backups of your MikroTik devices for disaster recovery and audit purposes.
</p>
<div class="mt-4 space-y-4">
<div class="p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<div class="flex gap-3">
<.icon
name="hero-information-circle"
class="h-5 w-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5"
/>
<div>
<h4 class="text-sm font-semibold text-blue-900 dark:text-blue-300 mb-1">
Read-Only Access
</h4>
<p class="text-sm text-blue-800 dark:text-blue-300">
Towerops currently operates in read-only mode for MikroTik devices. Configuration changes,
reboots, and other administrative actions are not supported. This ensures your device
configurations remain unchanged by monitoring operations.
</p>
</div>
</div>
</div>
<div class="p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<div class="flex gap-3">
<.icon
name="hero-server"
class="h-5 w-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5"
/>
<div>
<h4 class="text-sm font-semibold text-blue-900 dark:text-blue-300 mb-1">
Cloud or Agent-Based Connections
</h4>
<p class="text-sm text-blue-800 dark:text-blue-300">
Both SNMP polling and SSH connections to MikroTik devices can be performed from either
Towerops cloud infrastructure or your remote poller (agent). For publicly accessible devices,
cloud polling is the simplest option. For devices on private networks or when you prefer
to keep SSH credentials on your local network, deploy a remote poller. The device assignment
determines which poller handles both SNMP and SSH operations.
</p>
</div>
</div>
</div>
</div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mt-6 mb-3">
Security Best Practices
</h3>
<p class="text-gray-600 dark:text-gray-400">
For security purposes, it is strongly recommended to create a dedicated read-only user account
for Towerops rather than using an administrator account. This follows the principle of least
privilege and minimizes potential security risks. Even though Towerops only performs read-only
operations, limiting the account permissions provides defense in depth.
</p>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mt-6 mb-3">
Creating a Read-Only User
</h3>
<p class="text-gray-600 dark:text-gray-400">
Connect to your MikroTik device via SSH or terminal and execute the following commands to
create a read-only user named <.code>towerops</.code>:
</p>
<div class="mt-4 space-y-3">
<div>
<p class="text-sm text-gray-700 dark:text-gray-300 mb-2 font-medium">
Step 1: Create a new user group with read-only permissions
</p>
<div class="p-4 bg-black rounded-lg not-prose">
<div class="flex items-start gap-2">
<code class="text-sm text-white font-mono break-all flex-1">
/user group add name=readonly policy=ssh,read,test,api
</code>
<button
type="button"
phx-click={JS.dispatch("phx:copy", to: "#step1-command")}
class="flex-shrink-0 p-1 text-blue-400 hover:text-blue-300"
title="Copy to clipboard"
>
<.icon name="hero-clipboard-document" class="h-4 w-4" />
</button>
<input
type="hidden"
id="step1-command"
value="/user group add name=readonly policy=ssh,read,test,api"
/>
</div>
</div>
</div>
<div>
<p class="text-sm text-gray-700 dark:text-gray-300 mb-2 font-medium">
Step 2: Create the monitoring user with a strong password
</p>
<div class="p-4 bg-black rounded-lg not-prose">
<div class="flex items-start gap-2">
<code class="text-sm text-white font-mono break-all flex-1">
/user add name=towerops password={if @generated_password,
do: @generated_password,
else: "YOUR_STRONG_PASSWORD"} group=readonly
</code>
<%= if @generated_password do %>
<button
type="button"
phx-click={JS.dispatch("phx:copy", to: "#generated-password-value")}
class="flex-shrink-0 p-1 text-blue-400 hover:text-blue-300"
title="Copy to clipboard"
>
<.icon name="hero-clipboard-document" class="h-4 w-4" />
</button>
<input
type="hidden"
id="generated-password-value"
value={@generated_password}
/>
<% end %>
</div>
</div>
<div class="mt-3 flex items-start gap-3">
<.button
type="button"
phx-click="generate_password"
disabled={@password_generating}
>
<%= if @password_generating do %>
<.icon name="hero-arrow-path" class="h-4 w-4 mr-1 animate-spin" />
Generating...
<% else %>
<.icon name="hero-sparkles" class="h-4 w-4 mr-1" />
Regenerate Random Password
<% end %>
</.button>
<%= if @generated_password do %>
<p class="text-xs text-yellow-600 dark:text-yellow-400 font-medium mt-1.5">
This truly random password from random.org will only be shown once!
</p>
<% end %>
</div>
</div>
<div>
<p class="text-sm text-gray-700 dark:text-gray-300 mb-2 font-medium">
Step 3: Verify the user was created successfully
</p>
<div class="p-4 bg-black rounded-lg not-prose">
<div class="flex items-start gap-2">
<code class="text-sm text-white font-mono break-all flex-1">
/user print detail where name=towerops
</code>
<button
type="button"
phx-click={JS.dispatch("phx:copy", to: "#step3-command")}
class="flex-shrink-0 p-1 text-blue-400 hover:text-blue-300"
title="Copy to clipboard"
>
<.icon name="hero-clipboard-document" class="h-4 w-4" />
</button>
<input
type="hidden"
id="step3-command"
value="/user print detail where name=towerops"
/>
</div>
</div>
</div>
</div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mt-8 mb-3">
Permissions Explained
</h3>
<p class="text-gray-600 dark:text-gray-400">
The
<.code>readonly</.code>
group includes the following permissions. Note that while these
permissions are granted to the user account, Towerops only performs read operations and does
not make any configuration changes:
</p>
<div class="mt-4 bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10 overflow-hidden">
<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>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Permission
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Description
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-white/10">
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800">
<td class="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-900 dark:text-white">
ssh
</td>
<td class="px-6 py-4 text-sm text-gray-600 dark:text-gray-400">
Allow SSH access to the device
</td>
</tr>
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800">
<td class="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-900 dark:text-white">
read
</td>
<td class="px-6 py-4 text-sm text-gray-600 dark:text-gray-400">
Allow viewing configuration and status (read-only)
</td>
</tr>
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800">
<td class="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-900 dark:text-white">
test
</td>
<td class="px-6 py-4 text-sm text-gray-600 dark:text-gray-400">
Allow executing diagnostic commands (ping, traceroute, etc.)
</td>
</tr>
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800">
<td class="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-900 dark:text-white">
api
</td>
<td class="px-6 py-4 text-sm text-gray-600 dark:text-gray-400">
Allow API access for automated monitoring
</td>
</tr>
</tbody>
</table>
</div>
<div class="mt-6 p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<div class="flex gap-3">
<.icon
name="hero-information-circle"
class="h-5 w-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5"
/>
<div>
<h4 class="text-sm font-semibold text-blue-900 dark:text-blue-300 mb-1">
Important Notes
</h4>
<ul class="text-sm text-blue-800 dark:text-blue-300 space-y-1 list-disc list-inside">
<li>The read-only user cannot modify device configuration</li>
<li>No write, reboot, or sensitive permissions are granted</li>
<li>Use a strong, unique password for the monitoring account</li>
<li>Consider restricting SSH access by source IP if possible</li>
</ul>
</div>
</div>
</div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mt-8 mb-3">
Configuring in Towerops
</h3>
<p class="text-gray-600 dark:text-gray-400">
After creating the read-only user, configure SSH credentials in Towerops:
</p>
<div class="mt-4 space-y-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">
Organization-Level Configuration
</h4>
<p class="text-sm text-gray-600 dark:text-gray-400">
Navigate to
<.code>Settings</.code>
<.code>Organization</.code>
to set default
SSH credentials for all MikroTik devices in your organization.
</p>
</div>
<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">
Site-Level Configuration
</h4>
<p class="text-sm text-gray-600 dark:text-gray-400">
Navigate to
<.code>Sites</.code>
select a site
<.code>Edit</.code>
to override
credentials for all devices at a specific location.
</p>
</div>
<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">
Device-Level Configuration
</h4>
<p class="text-sm text-gray-600 dark:text-gray-400">
When editing a MikroTik device, you can specify unique SSH credentials that override
organization and site defaults.
</p>
</div>
</div>
<div class="mt-6 p-4 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
<div class="flex gap-3">
<.icon
name="hero-shield-check"
class="h-5 w-5 text-yellow-600 dark:text-yellow-400 flex-shrink-0 mt-0.5"
/>
<div>
<h4 class="text-sm font-semibold text-yellow-900 dark:text-yellow-300 mb-1">
Security Recommendations
</h4>
<ul class="text-sm text-yellow-800 dark:text-yellow-300 space-y-1 list-disc list-inside">
<li>Always use SSL/TLS for SSH connections (API-SSL on port 8729)</li>
<li>Store credentials at the organization or site level when possible</li>
<li>Rotate passwords periodically following your security policies</li>
<li>Monitor access logs for unauthorized SSH connection attempts</li>
<li>
Consider using SSH keys instead of passwords (if supported by your setup)
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<% _ -> %>
<div class="p-6">
<h2 class="text-xl font-bold text-gray-900 dark:text-white mb-4">

View file

@ -4,12 +4,20 @@
current_scope={@current_scope}
active_page="help"
>
<.help_content active_section={@active_section} />
<.help_content
active_section={@active_section}
generated_password={@generated_password}
password_generating={@password_generating}
/>
</Layouts.authenticated>
<% else %>
<Layouts.app flash={@flash}>
<div class="py-8">
<.help_content active_section={@active_section} />
<.help_content
active_section={@active_section}
generated_password={@generated_password}
password_generating={@password_generating}
/>
</div>
</Layouts.app>
<% end %>

View file

@ -0,0 +1,32 @@
defmodule Towerops.Repo.Migrations.EncryptMikrotikUsernames do
use Ecto.Migration
def up do
# Change mikrotik_username from string to binary for encryption
# Note: This will clear any existing usernames since we're moving from
# plaintext string to encrypted binary. Users will need to re-enter credentials.
# Organizations
execute "ALTER TABLE organizations ALTER COLUMN mikrotik_username TYPE bytea USING NULL"
# Sites
execute "ALTER TABLE sites ALTER COLUMN mikrotik_username TYPE bytea USING NULL"
# Devices
execute "ALTER TABLE devices ALTER COLUMN mikrotik_username TYPE bytea USING NULL"
end
def down do
# Revert back to string type
# Note: Encrypted data will be cleared when reverting
# Organizations
execute "ALTER TABLE organizations ALTER COLUMN mikrotik_username TYPE varchar USING NULL"
# Sites
execute "ALTER TABLE sites ALTER COLUMN mikrotik_username TYPE varchar USING NULL"
# Devices
execute "ALTER TABLE devices ALTER COLUMN mikrotik_username TYPE varchar USING NULL"
end
end