add mikrotik device handling

This commit is contained in:
Graham McIntire 2026-02-01 16:42:39 -06:00
parent bcd2570b10
commit 23af86ba73
No known key found for this signature in database
25 changed files with 1386 additions and 154 deletions

View file

@ -392,6 +392,54 @@ Two separate SNMP collection mechanisms:
- Applied to both `DevicePollerWorker` and `DeviceMonitorWorker`
- Implementation: `lib/towerops/workers/polling_offset.ex`
### MikroTik API Integration
Provides MikroTik RouterOS API access alongside SNMP for enhanced device management.
**Architecture:**
- **3-tier credential cascade**: Organization → Site → Device (identical to SNMP pattern)
- **Encryption**: Passwords encrypted at rest using Cloak (AES-256-GCM)
- **Detection**: Auto-detected from SNMP manufacturer field (contains "MikroTik" or "RouterOS")
- **Transport**: Supports both API-SSL (port 8729, default) and plain API (port 8728, insecure)
- **Access**: Superuser-only in UI (configurable)
- **Alongside SNMP**: Both can be enabled simultaneously
**Key Files:**
- `lib/towerops/vault.ex` - Cloak encryption vault
- `lib/towerops/ecto_types/encrypted_binary.ex` - Encrypted field type
- `lib/towerops/devices.ex` - `get_mikrotik_config/1`, `propagate_site_mikrotik_change/2`, `propagate_organization_mikrotik_change/2`
- `lib/towerops_web/channels/agent_channel.ex` - `build_mikrotik_job/1`, `build_mikrotik_commands/0`
- `lib/towerops_web/live/device_live/form.ex` - Conditional UI display for MikroTik devices
- `priv/proto/agent.proto` - MikrotikDevice, MikrotikCommand, MikrotikResult protobuf messages
**Database Schema:**
- `organizations.mikrotik_*` - Organization-level defaults
- `sites.mikrotik_*` - Site-level overrides (all nullable)
- `devices.mikrotik_*` - Device-level overrides (all nullable)
- `devices.mikrotik_credential_source` - Tracks inheritance ("device", "site", or default)
**Credential Resolution:**
Each field (username, password, port, use_ssl, enabled) resolves independently:
```elixir
username = device.mikrotik_username ||
device.site.mikrotik_username ||
device.site.organization.mikrotik_username
```
**Security:**
- Passwords encrypted at rest using AES-256-GCM via Cloak
- Encryption key must be set in `CLOAK_KEY` environment variable (base64-encoded 32-byte key)
- Generate key: `openssl rand -base64 32`
- Development/test use fixed key in config files (DO NOT use in production)
- **Production**: See "Kubernetes Deployment" section below for secret creation commands
- Plain API (port 8728) is blocked when using cloud pollers (enforced in backend validation)
- UI warns about plain API security risks
**Agent Integration:**
- MikroTik jobs sent via protobuf alongside SNMP jobs
- Agent executes RouterOS API commands (e.g., `/system/identity/print`, `/system/resource/print`)
- Results sent back via `MikrotikResult` protobuf message
### MIB Name Resolution (Rust NIF)
Uses Rust NIF via Rustler for fast SNMP MIB name resolution (replaces unreliable Erlang SNMP modules).
@ -508,12 +556,51 @@ Available at `/docs/api` (adapted from Tailwind UI Protocol template).
## Kubernetes Deployment
**Prerequisites**: cert-manager, Traefik, MetalLB, FluxCD, GitLab Agent, NFS Provisioner
**Secrets**: All stored in 1Password, created manually in `towerops` namespace
- `gitlab-registry` - Docker registry credentials
- `towerops-secrets` - RELEASE_COOKIE, SECRET_KEY_BASE
- `towerops-secrets` - RELEASE_COOKIE, SECRET_KEY_BASE, CLOAK_KEY
- `towerops-db` - PostgreSQL connection details
- `towerops-aws` - AWS credentials
### Creating/Updating Secrets
**Initial setup** - Generate and store encryption key:
```bash
# Generate CLOAK_KEY (store in 1Password)
openssl rand -base64 32
# Create towerops-secrets with all required keys
kubectl create secret generic towerops-secrets \
--from-literal=RELEASE_COOKIE=$(openssl rand -base64 32) \
--from-literal=SECRET_KEY_BASE=$(mix phx.gen.secret) \
--from-literal=CLOAK_KEY=$(openssl rand -base64 32) \
-n towerops
```
**Add CLOAK_KEY to existing secret**:
```bash
# Generate new encryption key
CLOAK_KEY=$(openssl rand -base64 32)
# Store in 1Password first, then add to Kubernetes
kubectl create secret generic towerops-secrets \
--from-literal=CLOAK_KEY="$CLOAK_KEY" \
--dry-run=client -o yaml | \
kubectl apply -f - -n towerops
# Or patch existing secret
kubectl patch secret towerops-secrets \
-n towerops \
--type='json' \
-p="[{'op': 'add', 'path': '/data/CLOAK_KEY', 'value': '$(echo -n "$CLOAK_KEY" | base64)'}]"
```
**Important**:
- Always store the CLOAK_KEY in 1Password before deploying
- Losing the encryption key makes encrypted data (MikroTik passwords, etc.) unrecoverable
- After adding CLOAK_KEY, restart pods: `kubectl rollout restart deployment/towerops -n towerops`
**Deployment**: FluxCD monitors Git repository, automatically applies manifests in `k8s/` directory
**Manual**: `kubectl apply -k k8s/`
@ -521,6 +608,7 @@ Available at `/docs/api` (adapted from Tailwind UI Protocol template).
- FluxCD: `kubectl get kustomization -n flux-system`
- Certificates: `kubectl describe certificate towerops-net-cert -n towerops`
- Pods: `kubectl describe pod -n towerops -l app=towerops`
- Secrets: `kubectl describe secret towerops-secrets -n towerops` (shows keys but not values)
## Common Patterns

View file

@ -73,6 +73,17 @@ config :towerops, Towerops.Repo,
show_sensitive_data_on_connection_error: true,
pool_size: 10
# Configure Cloak encryption for development
# Use a fixed key for development (DO NOT use in production!)
config :towerops, Towerops.Vault,
ciphers: [
default: {
Cloak.Ciphers.AES.GCM,
# Development-only key - replace with env var in production
tag: "AES.GCM.V1", key: Base.decode64!("nQWmgQYhoCNXA3PAxwriKxLyPHAOWH9VgpLkBOXrowM=")
}
]
# For development, we disable any cache and enable
# debugging and code reloading.
#

View file

@ -44,6 +44,15 @@ if config_env() == :prod do
You can generate one by calling: mix phx.gen.secret
"""
# Cloak encryption key for MikroTik API passwords and other sensitive data
cloak_key =
System.get_env("CLOAK_KEY") ||
raise """
environment variable CLOAK_KEY is missing.
You can generate one by calling: openssl rand -base64 32
Store the generated key in 1Password and set as environment variable.
"""
host = System.get_env("PHX_HOST") || "example.com"
# Configure Redis/Valkey connection
@ -204,6 +213,14 @@ if config_env() == :prod do
# pool_count: 4,
socket_options: maybe_ipv6
config :towerops, Towerops.Vault,
ciphers: [
default: {
Cloak.Ciphers.AES.GCM,
tag: "AES.GCM.V1", key: Base.decode64!(cloak_key)
}
]
config :towerops, ToweropsWeb.Endpoint,
url: [host: host, port: 443, scheme: "https"],
http: [

View file

@ -46,6 +46,17 @@ config :towerops, Towerops.Repo,
pool: Ecto.Adapters.SQL.Sandbox,
pool_size: System.schedulers_online() * 2
# Configure Cloak encryption for testing
# Use a fixed test key (same as dev for simplicity)
config :towerops, Towerops.Vault,
ciphers: [
default: {
Cloak.Ciphers.AES.GCM,
# Test key - same as dev for consistency
tag: "AES.GCM.V1", key: Base.decode64!("nQWmgQYhoCNXA3PAxwriKxLyPHAOWH9VgpLkBOXrowM=")
}
]
# We don't run a server during test. If one is required,
# you can enable the server option below.
config :towerops, ToweropsWeb.Endpoint,

View file

@ -127,7 +127,15 @@ defmodule Towerops.Accounts.User do
"""
def registration_changeset(user, attrs, opts \\ []) do
user
|> cast(attrs, [:email, :password, :first_name, :last_name, :timezone, :privacy_policy_consent, :terms_of_service_consent])
|> cast(attrs, [
:email,
:password,
:first_name,
:last_name,
:timezone,
:privacy_policy_consent,
:terms_of_service_consent
])
|> validate_email_for_registration(opts)
|> validate_password(opts)
|> validate_consent()

View file

@ -81,6 +81,8 @@ defmodule Towerops.Application do
{Cluster.Supervisor, [topologies, [name: Towerops.ClusterSupervisor]]},
ToweropsWeb.Telemetry,
Towerops.Repo,
# Encryption vault for sensitive data (passwords, API tokens)
Towerops.Vault,
# Rate limiting backend (ETS-based, per-node)
{Hammer.Backend.ETS, [expiry_ms: to_timeout(minute: 10), cleanup_interval_ms: to_timeout(minute: 1)]},
{Oban, Application.fetch_env!(:towerops, Oban)},

View file

@ -5,6 +5,7 @@ defmodule Towerops.Devices do
import Ecto.Query
alias Towerops.Agents
alias Towerops.Devices.Device, as: DeviceSchema
alias Towerops.Devices.Event
alias Towerops.Organizations.SubscriptionLimits
@ -275,6 +276,176 @@ defmodule Towerops.Devices do
end
end
@doc """
Gets MikroTik API configuration for a device with hierarchical fallback.
Returns a map with username, password (decrypted), port, use_ssl, enabled, and source.
## Examples
iex> get_mikrotik_config(device)
%{
username: "admin",
password: "secret",
port: 8729,
use_ssl: true,
enabled: true,
source: :device
}
"""
def get_mikrotik_config(device_id) when is_binary(device_id) do
device =
DeviceSchema
|> Repo.get!(device_id)
|> Repo.preload(site: :organization)
get_mikrotik_config(device)
end
def get_mikrotik_config(%DeviceSchema{} = device) do
device = ensure_mikrotik_config_loaded(device)
resolve_mikrotik_config(device)
end
defp ensure_mikrotik_config_loaded(device) do
# Ensure site and organization associations are loaded
needs_preload =
!Ecto.assoc_loaded?(device.site) ||
!Ecto.assoc_loaded?(device.site.organization)
if needs_preload do
Repo.preload(device, site: :organization)
else
device
end
end
defp resolve_mikrotik_config(device) do
# Resolve each field independently with hierarchical fallback
username =
device.mikrotik_username ||
device.site.mikrotik_username ||
device.site.organization.mikrotik_username
password =
device.mikrotik_password ||
device.site.mikrotik_password ||
device.site.organization.mikrotik_password
port =
device.mikrotik_port ||
device.site.mikrotik_port ||
device.site.organization.mikrotik_port ||
8729
use_ssl =
if device.mikrotik_use_ssl == nil do
device.site.mikrotik_use_ssl ||
device.site.organization.mikrotik_use_ssl ||
true
else
device.mikrotik_use_ssl
end
enabled =
device.mikrotik_enabled ||
device.site.mikrotik_enabled ||
device.site.organization.mikrotik_enabled ||
false
source = determine_mikrotik_source(device)
%{
username: username,
password: password,
port: port,
use_ssl: use_ssl,
enabled: enabled,
source: source
}
end
defp determine_mikrotik_source(device) do
# Determine source based on where username came from (primary credential)
cond do
device.mikrotik_username != nil -> :device
device.site.mikrotik_username != nil -> :site
device.site.organization.mikrotik_username != nil -> :organization
true -> :default
end
end
@doc """
Propagates MikroTik credential changes from a site to all devices in that
site that inherit credentials from the site (credential_source = "site").
Updates username, password, port, use_ssl, and enabled fields.
"""
def propagate_site_mikrotik_change(site_id, attrs) do
# Find all devices in this site that inherit credentials from site
devices_to_update =
Repo.all(
from(d in DeviceSchema,
where: d.site_id == ^site_id,
where: d.mikrotik_credential_source == "site"
)
)
# Update each device's MikroTik credentials
Enum.each(devices_to_update, fn device ->
device
|> Ecto.Changeset.change(attrs)
|> Repo.update()
# Broadcast assignment change to trigger agent job list refresh
Phoenix.PubSub.broadcast(
Towerops.PubSub,
"device:#{device.id}:assignments",
{:assignments_changed, :mikrotik_updated}
)
end)
:ok
end
@doc """
Propagates MikroTik credential changes from an organization to all devices
in sites that have no site-level credentials (credential_source = "site"
and site.mikrotik_username is nil).
Updates username, password, port, use_ssl, and enabled fields.
"""
def propagate_organization_mikrotik_change(organization_id, attrs) do
# Find all devices in this org's sites that:
# 1. Inherit from site (credential_source = "site")
# 2. Their site has no MikroTik username (so they fall back to org)
devices_to_update =
Repo.all(
from(d in DeviceSchema,
join: s in assoc(d, :site),
where: s.organization_id == ^organization_id,
where: d.mikrotik_credential_source == "site",
where: is_nil(s.mikrotik_username) or s.mikrotik_username == ""
)
)
# Update each device's MikroTik credentials
Enum.each(devices_to_update, fn device ->
device
|> Ecto.Changeset.change(attrs)
|> Repo.update()
# Broadcast assignment change to trigger agent job list refresh
Phoenix.PubSub.broadcast(
Towerops.PubSub,
"device:#{device.id}:assignments",
{:assignments_changed, :mikrotik_updated}
)
end)
:ok
end
@doc """
Creates device.
@ -352,9 +523,12 @@ defmodule Towerops.Devices do
old_snmp_version = device.snmp_version
old_snmp_port = device.snmp_port
case device
|> DeviceSchema.changeset(attrs)
|> Repo.update() do
changeset = DeviceSchema.changeset(device, attrs)
# Validate MikroTik SSL requirement for cloud pollers
changeset = validate_mikrotik_ssl_with_cloud_poller(changeset, device)
case Repo.update(changeset) do
{:ok, updated_device} = result ->
_ = handle_monitoring_changes(updated_device, old_monitoring)
_ = handle_snmp_changes(updated_device, old_snmp, old_snmp_version, old_snmp_port)
@ -365,6 +539,44 @@ defmodule Towerops.Devices do
end
end
defp validate_mikrotik_ssl_with_cloud_poller(changeset, device) do
# Check if MikroTik is enabled and SSL is being disabled
mikrotik_enabled = Ecto.Changeset.get_field(changeset, :mikrotik_enabled)
use_ssl_change = Ecto.Changeset.get_change(changeset, :mikrotik_use_ssl)
# Only validate if MikroTik is enabled and SSL is explicitly being set to false
if mikrotik_enabled && use_ssl_change == false do
validate_cloud_poller_requires_ssl(changeset, device)
else
changeset
end
end
defp validate_cloud_poller_requires_ssl(changeset, device) do
# Get effective agent to check if it's a cloud poller
device_with_site = Repo.preload(device, site: :organization)
{effective_agent_id, _source} = Agents.get_effective_agent_token_with_source(device_with_site)
using_cloud_poller =
if effective_agent_id do
agent = Agents.get_agent_token!(effective_agent_id)
agent.is_cloud_poller
else
# No agent means cloud polling by default
true
end
if using_cloud_poller do
Ecto.Changeset.add_error(
changeset,
:mikrotik_use_ssl,
"SSL is required when using cloud pollers. Plain MikroTik API sends credentials unencrypted over the internet."
)
else
changeset
end
end
@doc """
Resolves the effective SNMP community string for a device.

View file

@ -39,6 +39,14 @@ defmodule Towerops.Devices.Device do
field :last_discovery_at, :utc_datetime
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_port, :integer
field :mikrotik_use_ssl, :boolean
field :mikrotik_enabled, :boolean
field :mikrotik_credential_source, :string, default: "site"
belongs_to :site, Site
has_one :snmp_device, SnmpDevice
@ -64,6 +72,12 @@ defmodule Towerops.Devices.Device do
snmp_port: integer(),
last_discovery_at: DateTime.t() | nil,
last_snmp_poll_at: DateTime.t() | nil,
mikrotik_username: String.t() | nil,
mikrotik_password: String.t() | nil,
mikrotik_port: integer() | nil,
mikrotik_use_ssl: boolean() | nil,
mikrotik_enabled: boolean() | nil,
mikrotik_credential_source: String.t() | nil,
site_id: Ecto.UUID.t(),
site: NotLoaded.t() | Site.t(),
snmp_device: NotLoaded.t() | SnmpDevice.t() | nil,
@ -92,7 +106,13 @@ defmodule Towerops.Devices.Device do
:last_checked_at,
:last_status_change_at,
:last_discovery_at,
:last_snmp_poll_at
:last_snmp_poll_at,
:mikrotik_username,
:mikrotik_password,
:mikrotik_port,
:mikrotik_use_ssl,
:mikrotik_enabled,
:mikrotik_credential_source
])
|> validate_required([:ip_address, :site_id])
|> validate_name()
@ -101,7 +121,9 @@ defmodule Towerops.Devices.Device do
|> validate_ip_address()
|> validate_number(:check_interval_seconds, greater_than: 0, less_than_or_equal_to: 3600)
|> validate_snmp()
|> validate_mikrotik()
|> update_community_source()
|> update_mikrotik_credential_source()
|> foreign_key_constraint(:site_id)
end
@ -170,4 +192,49 @@ defmodule Towerops.Devices.Device do
put_change(changeset, :snmp_community_source, "device")
end
end
defp validate_mikrotik(changeset) do
mikrotik_enabled = get_field(changeset, :mikrotik_enabled)
if mikrotik_enabled do
changeset
|> validate_number(:mikrotik_port, greater_than: 0, less_than: 65_536)
|> validate_mikrotik_ssl_for_cloud()
else
changeset
end
end
defp validate_mikrotik_ssl_for_cloud(changeset) do
# If explicitly setting use_ssl to false at device level, ensure not using cloud poller
use_ssl = get_field(changeset, :mikrotik_use_ssl)
# Only validate if explicitly disabling SSL (nil means inherit)
if use_ssl == false do
# Note: We can't check effective agent here during changeset validation
# as it requires database queries. This validation happens at the context level.
# Here we just ensure the field is set if provided.
changeset
else
changeset
end
end
defp update_mikrotik_credential_source(changeset) do
# If user explicitly set a username, mark source as "device"
# If it's blank/nil, mark source as "site" (will inherit from site or org)
case get_change(changeset, :mikrotik_username) do
nil ->
# No change to username, keep existing source
changeset
"" ->
# Explicitly cleared, set to inherit from site
put_change(changeset, :mikrotik_credential_source, "site")
_value ->
# Explicitly set a value, mark as device-specific
put_change(changeset, :mikrotik_credential_source, "device")
end
end
end

View file

@ -0,0 +1,36 @@
defmodule Towerops.Encrypted.Binary do
@moduledoc """
Encrypted binary field using Cloak vault.
Used for sensitive credentials like MikroTik API passwords.
Data is encrypted at rest using AES-256-GCM.
## Usage
In your schema:
field :password, Towerops.Encrypted.Binary
The field will be automatically encrypted when writing to the database
and decrypted when reading from the database.
## Database Schema
The database column should be of type `:binary`:
add :password, :binary
## Examples
# In a changeset
cast(attrs, [:password])
# Password is automatically encrypted before database write
# Reading from database
user.password # Automatically decrypted
# Nil handling
nil values are preserved (not encrypted)
"""
use Cloak.Ecto.Binary, vault: Towerops.Vault
end

View file

@ -29,6 +29,13 @@ defmodule Towerops.Organizations.Organization do
field :snmp_version, :string, default: "2c"
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_port, :integer, default: 8729
field :mikrotik_use_ssl, :boolean, default: true
field :mikrotik_enabled, :boolean, default: false
belongs_to :default_agent_token, AgentToken
has_many :memberships, Membership
@ -45,6 +52,11 @@ defmodule Towerops.Organizations.Organization do
subscription_plan: String.t(),
snmp_version: String.t() | nil,
snmp_community: String.t() | nil,
mikrotik_username: String.t() | nil,
mikrotik_password: String.t() | nil,
mikrotik_port: integer() | nil,
mikrotik_use_ssl: boolean() | nil,
mikrotik_enabled: boolean() | nil,
default_agent_token_id: Ecto.UUID.t() | nil,
default_agent_token: NotLoaded.t() | AgentToken.t() | nil,
memberships: NotLoaded.t() | [Membership.t()],
@ -62,18 +74,38 @@ defmodule Towerops.Organizations.Organization do
:subscription_plan,
:default_agent_token_id,
:snmp_version,
:snmp_community
:snmp_community,
:mikrotik_username,
:mikrotik_password,
:mikrotik_port,
:mikrotik_use_ssl,
:mikrotik_enabled
])
|> validate_required([:name])
|> validate_length(:name, min: 2, max: 100)
|> validate_inclusion(:subscription_plan, ["free"])
|> validate_inclusion(:snmp_version, ["1", "2c", "3"], message: "must be 1, 2c, or 3")
|> validate_mikrotik_fields()
|> generate_slug()
|> validate_required([:slug])
|> unique_constraint(:slug)
|> foreign_key_constraint(:default_agent_token_id)
end
defp validate_mikrotik_fields(changeset) do
changeset
|> validate_number(:mikrotik_port, greater_than: 0, less_than: 65_536)
|> validate_mikrotik_required_fields()
end
defp validate_mikrotik_required_fields(changeset) do
if get_field(changeset, :mikrotik_enabled) do
validate_required(changeset, [:mikrotik_username])
else
changeset
end
end
defp generate_slug(changeset) do
# Only generate slug if not already set
if get_field(changeset, :slug) do

View file

@ -230,6 +230,7 @@ defmodule Towerops.Agent.JobType do
field :DISCOVER, 0
field :POLL, 1
field :MIKROTIK, 2
end
defmodule Towerops.Agent.QueryType do
@ -258,6 +259,8 @@ defmodule Towerops.Agent.AgentJob do
field :device_id, 3, type: :string, json_name: "deviceId"
field :snmp_device, 4, type: Towerops.Agent.SnmpDevice, json_name: "snmpDevice"
field :queries, 5, repeated: true, type: Towerops.Agent.SnmpQuery
field :mikrotik_device, 6, type: Towerops.Agent.MikrotikDevice, json_name: "mikrotikDevice"
field :mikrotik_commands, 7, repeated: true, type: Towerops.Agent.MikrotikCommand, json_name: "mikrotikCommands"
end
defmodule Towerops.Agent.AgentJobList do
@ -351,3 +354,82 @@ defmodule Towerops.Agent.AgentError do
field :message, 3, type: :string
field :timestamp, 4, type: :int64
end
defmodule Towerops.Agent.MikrotikDevice do
@moduledoc false
use Protobuf,
full_name: "towerops.agent.MikrotikDevice",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :ip, 1, type: :string
field :port, 2, type: :uint32
field :username, 3, type: :string
field :password, 4, type: :string
field :use_ssl, 5, type: :bool, json_name: "useSsl"
end
defmodule Towerops.Agent.MikrotikCommand.ArgsEntry do
@moduledoc false
use Protobuf,
full_name: "towerops.agent.MikrotikCommand.ArgsEntry",
map: true,
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :key, 1, type: :string
field :value, 2, type: :string
end
defmodule Towerops.Agent.MikrotikCommand do
@moduledoc false
use Protobuf,
full_name: "towerops.agent.MikrotikCommand",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :command, 1, type: :string
field :args, 2, repeated: true, type: Towerops.Agent.MikrotikCommand.ArgsEntry, map: true
end
defmodule Towerops.Agent.MikrotikResult do
@moduledoc false
use Protobuf,
full_name: "towerops.agent.MikrotikResult",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :device_id, 1, type: :string, json_name: "deviceId"
field :job_id, 2, type: :string, json_name: "jobId"
field :sentences, 3, repeated: true, type: Towerops.Agent.MikrotikSentence
field :error, 4, type: :string
field :timestamp, 5, type: :int64
end
defmodule Towerops.Agent.MikrotikSentence.AttributesEntry do
@moduledoc false
use Protobuf,
full_name: "towerops.agent.MikrotikSentence.AttributesEntry",
map: true,
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :key, 1, type: :string
field :value, 2, type: :string
end
defmodule Towerops.Agent.MikrotikSentence do
@moduledoc false
use Protobuf,
full_name: "towerops.agent.MikrotikSentence",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :attributes, 1, repeated: true, type: Towerops.Agent.MikrotikSentence.AttributesEntry, map: true
end

View file

@ -26,6 +26,13 @@ defmodule Towerops.Sites.Site do
field :snmp_version, :string
field :snmp_community, :string
# MikroTik API credentials (overrides organization default)
field :mikrotik_username, :string
field :mikrotik_password, Towerops.Encrypted.Binary
field :mikrotik_port, :integer
field :mikrotik_use_ssl, :boolean
field :mikrotik_enabled, :boolean
belongs_to :organization, Organization
belongs_to :agent_token, AgentToken
belongs_to :parent_site, Site
@ -43,6 +50,11 @@ defmodule Towerops.Sites.Site do
display_order: integer() | nil,
snmp_version: String.t() | nil,
snmp_community: String.t() | nil,
mikrotik_username: String.t() | nil,
mikrotik_password: String.t() | nil,
mikrotik_port: integer() | nil,
mikrotik_use_ssl: boolean() | nil,
mikrotik_enabled: boolean() | nil,
organization_id: Ecto.UUID.t(),
organization: NotLoaded.t() | Organization.t(),
agent_token_id: Ecto.UUID.t() | nil,
@ -67,13 +79,19 @@ defmodule Towerops.Sites.Site do
:agent_token_id,
:parent_site_id,
:snmp_version,
:snmp_community
:snmp_community,
:mikrotik_username,
:mikrotik_password,
:mikrotik_port,
:mikrotik_use_ssl,
:mikrotik_enabled
])
|> validate_required([:name, :organization_id])
|> validate_length(:name, min: 2, max: 200)
|> validate_length(:description, max: 1000)
|> validate_length(:location, max: 200)
|> validate_inclusion(:snmp_version, ["1", "2c", "3"], message: "must be 1, 2c, or 3")
|> validate_mikrotik_fields()
|> foreign_key_constraint(:organization_id)
|> foreign_key_constraint(:agent_token_id)
|> foreign_key_constraint(:parent_site_id)
@ -84,6 +102,20 @@ defmodule Towerops.Sites.Site do
|> validate_not_circular_parent()
end
defp validate_mikrotik_fields(changeset) do
changeset
|> validate_number(:mikrotik_port, greater_than: 0, less_than: 65_536)
|> validate_mikrotik_required_fields()
end
defp validate_mikrotik_required_fields(changeset) do
if get_field(changeset, :mikrotik_enabled) do
validate_required(changeset, [:mikrotik_username])
else
changeset
end
end
defp validate_not_circular_parent(changeset) do
parent_site_id = get_change(changeset, :parent_site_id)
site_id = get_field(changeset, :id)

58
lib/towerops/vault.ex Normal file
View file

@ -0,0 +1,58 @@
defmodule Towerops.Vault do
@moduledoc """
Encryption vault for sensitive data using Cloak.
Encrypts data using AES-256-GCM encryption. The encryption key is loaded
from the CLOAK_KEY environment variable (base64-encoded).
## Usage
Fields that use this vault are automatically encrypted/decrypted by Ecto.
See `Towerops.Encrypted.Binary` for the custom Ecto type.
## Key Generation
Generate a new encryption key:
openssl rand -base64 32
Store this key securely in 1Password and set it as the CLOAK_KEY
environment variable in production.
"""
use Cloak.Vault, otp_app: :towerops
@impl GenServer
def init(config) do
# In dev/test, the key is already configured in config files
# In production (runtime.exs), we override with env var
config =
if System.get_env("CLOAK_KEY") do
Keyword.put(config, :ciphers,
default: {
Cloak.Ciphers.AES.GCM,
tag: "AES.GCM.V1", key: decode_env!("CLOAK_KEY")
}
)
else
# Use the key from config (dev/test)
config
end
{:ok, config}
end
defp decode_env!(var) do
var
|> System.get_env()
|> case do
nil ->
raise """
Environment variable #{var} is missing.
Generate a key with: openssl rand -base64 32
"""
value ->
Base.decode64!(value)
end
end
end

View file

@ -25,6 +25,8 @@ defmodule ToweropsWeb.AgentChannel do
alias Towerops.Agent.AgentHeartbeat
alias Towerops.Agent.AgentJob
alias Towerops.Agent.AgentJobList
alias Towerops.Agent.MikrotikCommand
alias Towerops.Agent.MikrotikDevice
alias Towerops.Agent.SnmpDevice
alias Towerops.Agent.SnmpQuery
alias Towerops.Agent.SnmpResult
@ -191,15 +193,26 @@ defmodule ToweropsWeb.AgentChannel do
defp build_jobs_for_agent(agent_token_id) do
agent_token_id
|> Agents.list_agent_polling_targets()
|> Enum.map(&build_job_for_device/1)
|> Enum.flat_map(&build_jobs_for_device/1)
end
defp build_job_for_device(device) do
if needs_discovery?(device) do
build_discovery_job(device)
else
build_polling_job(device)
end
defp build_jobs_for_device(device) do
# Build SNMP job (discovery or polling)
snmp_job =
if needs_discovery?(device) do
build_discovery_job(device)
else
build_polling_job(device)
end
# Build MikroTik job if device is MikroTik and MikroTik API is enabled
mikrotik_job =
if mikrotik_device?(device) do
build_mikrotik_job(device)
end
# Return list of jobs (filter out nil)
Enum.reject([snmp_job, mikrotik_job], &is_nil/1)
end
defp needs_discovery?(device) do
@ -209,6 +222,13 @@ defmodule ToweropsWeb.AgentChannel do
DateTime.diff(DateTime.utc_now(), device.last_discovery_at, :hour) > 24
end
defp mikrotik_device?(device) do
# Check if device is MikroTik based on SNMP discovery data
device.snmp_device &&
(String.contains?(device.snmp_device.manufacturer || "", "MikroTik") ||
String.contains?(device.snmp_device.sys_descr || "", "RouterOS"))
end
defp build_discovery_job(device) do
%AgentJob{
job_id: "discover:#{device.id}",
@ -355,6 +375,42 @@ defmodule ToweropsWeb.AgentChannel do
]
end
defp build_mikrotik_job(device) do
mikrotik_config = Devices.get_mikrotik_config(device)
# Only build job if MikroTik API is enabled and has credentials
if mikrotik_config.enabled && mikrotik_config.username do
%AgentJob{
job_id: "mikrotik:#{device.id}",
job_type: :MIKROTIK,
device_id: device.id,
mikrotik_device: %MikrotikDevice{
ip: device.ip_address,
username: mikrotik_config.username,
password: mikrotik_config.password,
port: mikrotik_config.port,
use_ssl: mikrotik_config.use_ssl
},
mikrotik_commands: build_mikrotik_commands()
}
end
end
defp build_mikrotik_commands do
[
# System identity
%MikrotikCommand{
command: "/system/identity/print",
args: %{}
},
# System resources
%MikrotikCommand{
command: "/system/resource/print",
args: %{}
}
]
end
defp process_snmp_result(organization_id, result, socket) do
with {:ok, device} <- fetch_device(result.device_id),
:ok <- verify_device_organization(device, organization_id) do

View file

@ -152,6 +152,13 @@ defmodule ToweropsWeb.DeviceLive.Form do
# Get effective SNMP configuration and source
snmp_config = Devices.get_snmp_config(device)
# Get effective MikroTik configuration and source
mikrotik_config = Devices.get_mikrotik_config(device)
# Check if device is MikroTik (based on SNMP discovery)
device_with_snmp = Towerops.Repo.preload(device, :snmp_device)
is_mikrotik = mikrotik_device?(device_with_snmp)
# Determine monitoring mode based on current snmp_enabled value
monitoring_mode = if device.snmp_enabled, do: "snmp_and_icmp", else: "icmp_only"
@ -164,9 +171,20 @@ defmodule ToweropsWeb.DeviceLive.Form do
|> assign(:snmp_config_source, snmp_config.source)
|> assign(:effective_snmp_version, snmp_config.version)
|> assign(:effective_snmp_community, snmp_config.community || "(not set)")
|> assign(:mikrotik_config_source, mikrotik_config.source)
|> assign(:effective_mikrotik_username, mikrotik_config.username || "(not set)")
|> assign(:is_mikrotik_device, is_mikrotik)
|> assign(:mikrotik_test_result, nil)
|> assign(:monitoring_mode, monitoring_mode)
end
defp mikrotik_device?(device) do
# Check if device is MikroTik based on SNMP discovery data
device.snmp_device &&
(String.contains?(device.snmp_device.manufacturer || "", "MikroTik") ||
String.contains?(device.snmp_device.sys_descr || "", "RouterOS"))
end
@impl true
def handle_event("switch_monitoring_mode", %{"mode" => mode}, socket) do
# Update snmp_enabled based on the selected mode

View file

@ -357,6 +357,80 @@
</div>
</div>
<%= if @live_action == :edit and @is_mikrotik_device and @current_scope.user.is_superuser do %>
<div class="mt-8 space-y-6 border-t border-gray-200 dark:border-white/10 pt-8">
<div>
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100">
MikroTik API Configuration
</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Configure MikroTik RouterOS API access. Works alongside SNMP for enhanced device management.
</p>
</div>
<%= if Map.has_key?(assigns, :mikrotik_config_source) and @mikrotik_config_source != :device do %>
<div class="rounded-md bg-blue-50 dark:bg-blue-900/20 p-4">
<p class="text-sm text-blue-700 dark:text-blue-300">
<strong>Inheriting credentials from {@mikrotik_config_source}.</strong>
Username: <span class="font-mono">{@effective_mikrotik_username}</span>
</p>
</div>
<% end %>
<.input
field={@form[:mikrotik_enabled]}
type="checkbox"
label="Enable MikroTik API"
/>
<%= if @form[:mikrotik_enabled].value do %>
<.input
field={@form[:mikrotik_username]}
label="Username"
placeholder="Leave blank to inherit from site/organization"
/>
<.input
field={@form[:mikrotik_password]}
type="password"
label="Password"
placeholder="Leave blank to inherit from site/organization"
/>
<.input
field={@form[:mikrotik_port]}
type="number"
label="API Port"
placeholder="8729 (SSL) or 8728 (plain)"
/>
<.input
field={@form[:mikrotik_use_ssl]}
type="checkbox"
label="Use SSL (API-SSL)"
/>
<%= if @form[:mikrotik_use_ssl].value == false do %>
<div class="rounded-md bg-red-50 dark:bg-red-900/20 p-4">
<p class="text-sm text-red-700 dark:text-red-300">
<strong>🚨 Critical Security Warning:</strong>
Plain API (port 8728) sends credentials unencrypted over the network.
<strong>This setting is blocked when using cloud pollers</strong>
and should only be used with local agents on trusted networks.
</p>
</div>
<% else %>
<div class="rounded-md bg-yellow-50 dark:bg-yellow-900/20 p-4">
<p class="text-sm text-yellow-700 dark:text-yellow-300">
<strong>⚠️ Security Warning:</strong>
Plain API (port 8728) sends credentials unencrypted. Use SSL (port 8729) whenever possible.
</p>
</div>
<% end %>
<% end %>
</div>
<% end %>
<div class="flex gap-3 mt-6">
<.button
phx-disable-with="Saving..."

View file

@ -596,6 +596,14 @@ defmodule ToweropsWeb.GraphLive.Show do
end
end
# Handle device status changes from PubSub
@impl true
def handle_info({:device_status_changed, _device_id, _new_status, _response_time}, socket) do
# Reload device to get updated status
device = Devices.get_device(socket.assigns.device_id)
{:noreply, assign(socket, :device, device)}
end
# Handle live poll event
@impl true
def handle_info(:live_poll, socket) do

View file

@ -2,162 +2,444 @@
flash={@flash}
current_scope={@current_scope}
>
<div class="mb-4">
<.link
navigate={~p"/orgs/#{@organization.slug}"}
class="inline-flex items-center gap-1 text-sm text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white"
>
<.icon name="hero-arrow-left" class="h-4 w-4" /> Back to Dashboard
</.link>
<div class="border-b border-gray-200 pb-5 dark:border-white/5">
<div class="mb-4">
<.link
navigate={~p"/orgs/#{@organization.slug}"}
class="inline-flex items-center gap-1 text-sm text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white"
>
<.icon name="hero-arrow-left" class="h-4 w-4" /> Back to Dashboard
</.link>
</div>
<h1 class="text-3xl font-semibold tracking-tight text-gray-900 dark:text-white">
Organization Settings
</h1>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Manage organization defaults for SNMP, agents, and MikroTik API configuration
</p>
</div>
<.header>
Organization Settings
<:subtitle>
Manage your organization settings and defaults
</:subtitle>
</.header>
<div class="max-w-2xl">
<.form for={@form} id="organization-form" phx-change="validate" phx-submit="save">
<.input field={@form[:name]} type="text" label="Organization Name" required />
<div class="mt-6 border-t pt-6">
<h3 class="text-lg font-medium mb-4">SNMP Configuration</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
Set default SNMP settings for all devices in this organization. These can be overridden at the site or device level.
</p>
<.input
field={@form[:snmp_version]}
type="select"
label="SNMP Version"
prompt="Select SNMP version"
options={[{"SNMP v1", "1"}, {"SNMP v2c", "2c"}, {"SNMP v3", "3"}]}
/>
<.input
field={@form[:snmp_community]}
type="text"
label="SNMP Community String"
placeholder="e.g., public"
/>
<p class="mt-3 text-sm text-gray-500 dark:text-gray-400 italic">
<.icon name="hero-information-circle" class="h-4 w-4 inline" />
SNMP configuration hierarchy: Device > Site > Organization
</p>
<%= if @organization.snmp_community do %>
<div class="mt-4 p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg">
<p class="text-sm font-medium text-amber-900 dark:text-amber-200 mb-2">
Force Apply to All Device
</p>
<p class="text-sm text-amber-700 dark:text-amber-300 mb-3">
This will override SNMP settings for ALL devices across all sites in this organization.
</p>
<.button
type="button"
phx-click="apply_snmp_to_all"
data-confirm="This will replace SNMP settings for ALL devices in this organization. Are you sure?"
variant="danger"
>
<.icon name="hero-arrow-path" class="h-4 w-4" /> Apply SNMP Config to All Device
</.button>
</div>
<% end %>
</div>
<%= if @available_agents != [] do %>
<div class="mt-6 border-t pt-6">
<h3 class="text-lg font-medium mb-4">Default Agent</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
Select a default agent for SNMP polling. This will be used for all devices in this organization unless overridden at the site or device level.
<.form for={@form} id="organization-form" phx-change="validate" phx-submit="save">
<div class="divide-y divide-gray-200 dark:divide-white/10">
<!-- Organization Name Section -->
<div class="grid max-w-7xl grid-cols-1 gap-x-8 gap-y-10 px-4 py-16 sm:px-6 md:grid-cols-3 lg:px-8">
<div>
<h2 class="text-base/7 font-semibold text-gray-900 dark:text-white">
Organization Name
</h2>
<p class="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
Update the name of your organization.
</p>
</div>
<.input
field={@form[:default_agent_token_id]}
type="select"
label="Default Remote Agent"
prompt="No default agent - cloud polling"
options={Enum.map(@available_agents, &{&1.name, &1.id})}
/>
<div class="md:col-span-2">
<div class="grid grid-cols-1 gap-x-6 gap-y-8 sm:max-w-xl sm:grid-cols-6">
<div class="col-span-full">
<label
for="organization_name"
class="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Organization Name
</label>
<div class="mt-2">
<input
type="text"
name={@form[:name].name}
id="organization_name"
value={@form[:name].value}
required
class="block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500"
/>
</div>
</div>
</div>
</div>
</div>
<!-- SNMP Configuration Section -->
<div class="grid max-w-7xl grid-cols-1 gap-x-8 gap-y-10 px-4 py-16 sm:px-6 md:grid-cols-3 lg:px-8">
<div>
<h2 class="text-base/7 font-semibold text-gray-900 dark:text-white">
SNMP Configuration
</h2>
<p class="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
Set default SNMP settings for all devices in this organization. These can be overridden at the site or device level.
</p>
<p class="mt-3 text-xs text-gray-500 dark:text-gray-400 italic">
<.icon name="hero-information-circle" class="h-4 w-4 inline" />
Hierarchy: Device > Site > Organization
</p>
</div>
<div class="mt-3 p-3 bg-gray-50 dark:bg-gray-800/50 rounded-lg text-sm">
<p class="font-medium text-gray-700 dark:text-gray-300 mb-2">
Current Device Assignment:
</p>
<div class="space-y-1 text-gray-600 dark:text-gray-400">
<div class="flex items-center gap-2">
<.icon name="hero-server" class="h-4 w-4" />
<span>
<strong>{@assignment_breakdown.direct}</strong> device-level override
</span>
<div class="md:col-span-2">
<div class="grid grid-cols-1 gap-x-6 gap-y-8 sm:max-w-xl sm:grid-cols-6">
<div class="col-span-full">
<label
for="snmp_version"
class="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
SNMP Version
</label>
<div class="mt-2 grid grid-cols-1">
<select
name={@form[:snmp_version].name}
id="snmp_version"
class="col-start-1 row-start-1 w-full appearance-none rounded-md bg-white py-1.5 pr-8 pl-3 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:*:bg-gray-800 dark:focus:outline-indigo-500"
>
<option value="">Select SNMP version</option>
<option value="1" selected={@form[:snmp_version].value == "1"}>SNMP v1</option>
<option value="2c" selected={@form[:snmp_version].value == "2c"}>
SNMP v2c
</option>
<option value="3" selected={@form[:snmp_version].value == "3"}>SNMP v3</option>
</select>
<svg
viewBox="0 0 16 16"
fill="currentColor"
data-slot="icon"
aria-hidden="true"
class="pointer-events-none col-start-1 row-start-1 mr-2 size-5 self-center justify-self-end text-gray-400 sm:size-4"
>
<path
d="M4.22 6.22a.75.75 0 0 1 1.06 0L8 8.94l2.72-2.72a.75.75 0 1 1 1.06 1.06l-3.25 3.25a.75.75 0 0 1-1.06 0L4.22 7.28a.75.75 0 0 1 0-1.06Z"
clip-rule="evenodd"
fill-rule="evenodd"
/>
</svg>
</div>
<div class="flex items-center gap-2">
<.icon name="hero-building-office" class="h-4 w-4" />
<span><strong>{@assignment_breakdown.site}</strong> inherited from site</span>
</div>
<div class="flex items-center gap-2">
<.icon name="hero-building-office-2" class="h-4 w-4" />
<span>
<strong>{@assignment_breakdown.organization}</strong>
inheriting organization default
</span>
</div>
<div class="flex items-center gap-2">
<.icon name="hero-cloud" class="h-4 w-4" />
<span>
<strong>{@assignment_breakdown.cloud}</strong> cloud polling (no agent)
</span>
</div>
<div class="col-span-full">
<label
for="snmp_community"
class="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
SNMP Community String
</label>
<div class="mt-2">
<input
type="text"
name={@form[:snmp_community].name}
id="snmp_community"
value={@form[:snmp_community].value}
placeholder="e.g., public"
class="block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500"
/>
</div>
</div>
</div>
<p class="mt-3 text-sm text-gray-500 dark:text-gray-400 italic">
<.icon name="hero-information-circle" class="h-4 w-4 inline" />
Agent assignment hierarchy: Device > Site > Organization
</p>
<%= if @organization.default_agent_token_id do %>
<div class="mt-4 p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg">
<p class="text-sm font-medium text-amber-900 dark:text-amber-200 mb-2">
Force Apply to All Device
</p>
<%= if @organization.snmp_community do %>
<div class="mt-6 rounded-md bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 p-4">
<h3 class="text-sm font-medium text-amber-900 dark:text-amber-200 mb-2">
Force Apply to All Devices
</h3>
<p class="text-sm text-amber-700 dark:text-amber-300 mb-3">
This will assign the default agent to ALL devices across all sites in this organization.
This will override SNMP settings for ALL devices across all sites in this organization.
</p>
<.button
<button
type="button"
phx-click="apply_agent_to_all"
data-confirm="This will replace agent assignments for ALL devices in this organization. Are you sure?"
variant="danger"
phx-click="apply_snmp_to_all"
data-confirm="This will replace SNMP settings for ALL devices in this organization. Are you sure?"
class="rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-red-500 dark:bg-red-500 dark:shadow-none dark:hover:bg-red-400"
>
<.icon name="hero-arrow-path" class="h-4 w-4" /> Apply Default Agent to All Device
</.button>
<.icon name="hero-arrow-path" class="h-4 w-4 inline" />
Apply SNMP Config to All Devices
</button>
</div>
<% end %>
</div>
<% else %>
<div class="mt-6 border-t pt-6">
<h3 class="text-lg font-medium mb-2">Default Agent</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
No agents configured yet.
<.link
navigate={~p"/agents"}
class="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
>
Create an agent
</.link>
to enable remote polling.
</p>
</div>
<%= if @current_scope.user.is_superuser do %>
<!-- MikroTik API Configuration Section -->
<div class="grid max-w-7xl grid-cols-1 gap-x-8 gap-y-10 px-4 py-16 sm:px-6 md:grid-cols-3 lg:px-8">
<div>
<h2 class="text-base/7 font-semibold text-gray-900 dark:text-white">
MikroTik API Configuration
</h2>
<p class="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
Set default MikroTik RouterOS API credentials for all devices in this organization. Only applies to devices detected as MikroTik.
</p>
<p class="mt-3 text-xs text-gray-500 dark:text-gray-400 italic">
<.icon name="hero-information-circle" class="h-4 w-4 inline" />
Hierarchy: Device > Site > Organization
</p>
</div>
<div class="md:col-span-2">
<div class="grid grid-cols-1 gap-x-6 gap-y-8 sm:max-w-xl sm:grid-cols-6">
<div class="col-span-full">
<div class="flex items-center gap-x-3">
<input
type="checkbox"
name={@form[:mikrotik_enabled].name}
id="mikrotik_enabled"
checked={@form[:mikrotik_enabled].value}
class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600 dark:border-white/10 dark:bg-white/5 dark:focus:ring-indigo-500"
/>
<label
for="mikrotik_enabled"
class="text-sm/6 font-medium text-gray-900 dark:text-white"
>
Enable MikroTik API
</label>
</div>
</div>
<div class={"col-span-full #{unless @form[:mikrotik_enabled].value, do: "hidden"}"}>
<label
for="mikrotik_username"
class="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Username
</label>
<div class="mt-2">
<input
type="text"
name={@form[:mikrotik_username].name}
id="mikrotik_username"
value={@form[:mikrotik_username].value}
placeholder="e.g., admin"
class="block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500"
/>
</div>
</div>
<div class={"col-span-full #{unless @form[:mikrotik_enabled].value, do: "hidden"}"}>
<label
for="mikrotik_password"
class="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Password
</label>
<div class="mt-2">
<input
type="password"
name={@form[:mikrotik_password].name}
id="mikrotik_password"
value={@form[:mikrotik_password].value}
placeholder="RouterOS API password"
class="block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500"
/>
</div>
</div>
<div class={"col-span-full #{unless @form[:mikrotik_enabled].value, do: "hidden"}"}>
<label
for="mikrotik_port"
class="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
API Port
</label>
<div class="mt-2">
<input
type="number"
name={@form[:mikrotik_port].name}
id="mikrotik_port"
value={@form[:mikrotik_port].value}
placeholder="8729"
class="block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500"
/>
</div>
</div>
<div class={"col-span-full #{unless @form[:mikrotik_enabled].value, do: "hidden"}"}>
<div class="flex items-center gap-x-3">
<input
type="checkbox"
name={@form[:mikrotik_use_ssl].name}
id="mikrotik_use_ssl"
checked={@form[:mikrotik_use_ssl].value}
class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600 dark:border-white/10 dark:bg-white/5 dark:focus:ring-indigo-500"
/>
<label
for="mikrotik_use_ssl"
class="text-sm/6 font-medium text-gray-900 dark:text-white"
>
Use SSL (API-SSL)
</label>
</div>
</div>
<div class={"col-span-full #{unless @form[:mikrotik_enabled].value, do: "hidden"} #{if @form[:mikrotik_use_ssl].value != false, do: "hidden"}"}>
<div class="rounded-md bg-red-50 dark:bg-red-900/20 p-4">
<p class="text-sm text-red-700 dark:text-red-300">
<strong>Critical Security Warning:</strong>
Plain API (port 8728) sends credentials unencrypted over the network. This setting is
<strong>blocked for devices using cloud pollers</strong>
and should only be used with local agents on trusted networks.
</p>
</div>
</div>
<div class={"col-span-full #{unless @form[:mikrotik_enabled].value, do: "hidden"} #{if @form[:mikrotik_use_ssl].value == false, do: "hidden"}"}>
<div class="rounded-md bg-yellow-50 dark:bg-yellow-900/20 p-4">
<p class="text-sm text-yellow-700 dark:text-yellow-300">
<strong>Security Warning:</strong>
Plain API (port 8728) sends credentials unencrypted. Use SSL (port 8729) whenever possible.
</p>
</div>
</div>
</div>
</div>
</div>
<% end %>
<!-- Default Agent Section -->
<%= if @available_agents != [] do %>
<div class="grid max-w-7xl grid-cols-1 gap-x-8 gap-y-10 px-4 py-16 sm:px-6 md:grid-cols-3 lg:px-8">
<div>
<h2 class="text-base/7 font-semibold text-gray-900 dark:text-white">
Default Agent
</h2>
<p class="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
Select a default agent for SNMP polling. This will be used for all devices in this organization unless overridden at the site or device level.
</p>
<p class="mt-3 text-xs text-gray-500 dark:text-gray-400 italic">
<.icon name="hero-information-circle" class="h-4 w-4 inline" />
Hierarchy: Device > Site > Organization
</p>
</div>
<div class="flex gap-3 mt-6">
<.button phx-disable-with="Saving..." variant="primary">Save Settings</.button>
<.button navigate={~p"/orgs/#{@organization.slug}"}>Cancel</.button>
</div>
</.form>
</div>
<div class="md:col-span-2">
<div class="grid grid-cols-1 gap-x-6 gap-y-8 sm:max-w-xl sm:grid-cols-6">
<div class="col-span-full">
<label
for="default_agent_token_id"
class="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Default Remote Agent
</label>
<div class="mt-2 grid grid-cols-1">
<select
name={@form[:default_agent_token_id].name}
id="default_agent_token_id"
class="col-start-1 row-start-1 w-full appearance-none rounded-md bg-white py-1.5 pr-8 pl-3 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:*:bg-gray-800 dark:focus:outline-indigo-500"
>
<option value="">No default agent - cloud polling</option>
<%= for agent <- @available_agents do %>
<option
value={agent.id}
selected={@form[:default_agent_token_id].value == agent.id}
>
{agent.name}
</option>
<% end %>
</select>
<svg
viewBox="0 0 16 16"
fill="currentColor"
data-slot="icon"
aria-hidden="true"
class="pointer-events-none col-start-1 row-start-1 mr-2 size-5 self-center justify-self-end text-gray-400 sm:size-4"
>
<path
d="M4.22 6.22a.75.75 0 0 1 1.06 0L8 8.94l2.72-2.72a.75.75 0 1 1 1.06 1.06l-3.25 3.25a.75.75 0 0 1-1.06 0L4.22 7.28a.75.75 0 0 1 0-1.06Z"
clip-rule="evenodd"
fill-rule="evenodd"
/>
</svg>
</div>
</div>
<div class="col-span-full">
<div class="rounded-md bg-gray-50 dark:bg-gray-800/50 p-4">
<p class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Current Device Assignment:
</p>
<div class="space-y-1 text-sm text-gray-600 dark:text-gray-400">
<div class="flex items-center gap-2">
<.icon name="hero-server" class="h-4 w-4" />
<span>
<strong>{@assignment_breakdown.direct}</strong> device-level override
</span>
</div>
<div class="flex items-center gap-2">
<.icon name="hero-building-office" class="h-4 w-4" />
<span>
<strong>{@assignment_breakdown.site}</strong> inherited from site
</span>
</div>
<div class="flex items-center gap-2">
<.icon name="hero-building-office-2" class="h-4 w-4" />
<span>
<strong>{@assignment_breakdown.organization}</strong>
inheriting organization default
</span>
</div>
<div class="flex items-center gap-2">
<.icon name="hero-cloud" class="h-4 w-4" />
<span>
<strong>{@assignment_breakdown.cloud}</strong> cloud polling (no agent)
</span>
</div>
</div>
</div>
</div>
</div>
<%= if @organization.default_agent_token_id do %>
<div class="mt-6 rounded-md bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 p-4">
<h3 class="text-sm font-medium text-amber-900 dark:text-amber-200 mb-2">
Force Apply to All Devices
</h3>
<p class="text-sm text-amber-700 dark:text-amber-300 mb-3">
This will assign the default agent to ALL devices across all sites in this organization.
</p>
<button
type="button"
phx-click="apply_agent_to_all"
data-confirm="This will replace agent assignments for ALL devices in this organization. Are you sure?"
class="rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-red-500 dark:bg-red-500 dark:shadow-none dark:hover:bg-red-400"
>
<.icon name="hero-arrow-path" class="h-4 w-4 inline" />
Apply Default Agent to All Devices
</button>
</div>
<% end %>
</div>
</div>
<% else %>
<div class="grid max-w-7xl grid-cols-1 gap-x-8 gap-y-10 px-4 py-16 sm:px-6 md:grid-cols-3 lg:px-8">
<div>
<h2 class="text-base/7 font-semibold text-gray-900 dark:text-white">
Default Agent
</h2>
<p class="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
No agents configured yet.
<.link
navigate={~p"/agents"}
class="text-indigo-600 hover:text-indigo-700 dark:text-indigo-400 dark:hover:text-indigo-300"
>
Create an agent
</.link>
to enable remote polling.
</p>
</div>
<div class="md:col-span-2">
<!-- Empty space for layout consistency -->
</div>
</div>
<% end %>
</div>
<!-- Save Button at Bottom -->
<div class="flex items-center justify-end gap-x-6 border-t border-gray-200 px-4 py-4 sm:px-6 lg:px-8 dark:border-white/10">
<.link
navigate={~p"/orgs/#{@organization.slug}"}
class="text-sm font-semibold text-gray-900 dark:text-white"
>
Cancel
</.link>
<button
type="submit"
phx-disable-with="Saving..."
class="rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-indigo-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:bg-indigo-500 dark:shadow-none dark:hover:bg-indigo-400 dark:focus-visible:outline-indigo-500"
>
Save Settings
</button>
</div>
</.form>
</Layouts.authenticated>

View file

@ -178,6 +178,71 @@
<% end %>
</div>
<%= if @current_scope.user.is_superuser do %>
<div class="mt-6 border-t pt-6">
<h3 class="text-base font-medium mb-4">
MikroTik API Configuration
<span class="text-gray-400 dark:text-gray-500 font-normal">(optional)</span>
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
Override organization MikroTik API defaults for all devices at this site. Leave blank to inherit from organization. Only applies to MikroTik devices.
</p>
<.input
field={@form[:mikrotik_enabled]}
type="checkbox"
label="Enable MikroTik API"
/>
<%= if @form[:mikrotik_enabled].value do %>
<.input
field={@form[:mikrotik_username]}
type="text"
label="Username"
placeholder="Leave blank to inherit from organization"
/>
<.input
field={@form[:mikrotik_password]}
type="password"
label="Password"
placeholder="Leave blank to inherit from organization"
/>
<.input
field={@form[:mikrotik_port]}
type="number"
label="API Port"
placeholder="8729 (SSL) or 8728 (plain)"
/>
<.input
field={@form[:mikrotik_use_ssl]}
type="checkbox"
label="Use SSL (API-SSL)"
/>
<%= if @form[:mikrotik_use_ssl].value == false do %>
<div class="mt-3 rounded-md bg-red-50 dark:bg-red-900/20 p-4">
<p class="text-sm text-red-700 dark:text-red-300">
<strong>🚨 Critical Security Warning:</strong>
Plain API (port 8728) sends credentials unencrypted over the network. This setting is
<strong>blocked for devices using cloud pollers</strong>
and should only be used with local agents on trusted networks.
</p>
</div>
<% else %>
<div class="mt-3 rounded-md bg-yellow-50 dark:bg-yellow-900/20 p-4">
<p class="text-sm text-yellow-700 dark:text-yellow-300">
<strong>⚠️ Security Warning:</strong>
Plain API (port 8728) sends credentials unencrypted. Use SSL (port 8729) whenever possible.
</p>
</div>
<% end %>
<% end %>
</div>
<% end %>
<div class="flex gap-3 mt-6">
<.button phx-disable-with="Saving..." variant="primary">Save Site</.button>
<.button navigate={~p"/sites"}>Cancel</.button>

View file

@ -86,7 +86,8 @@ defmodule Towerops.MixProject do
{:credo, "~> 1.7", only: [:dev, :test], runtime: false},
{:sobelow, "~> 0.14.1", only: [:dev, :test], runtime: false},
{:mix_audit, "~> 2.1", only: [:dev, :test], runtime: false},
{:hammer, "~> 6.2"}
{:hammer, "~> 6.2"},
{:cloak_ecto, "~> 1.3"}
]
end

View file

@ -5,6 +5,8 @@
"cbor": {:hex, :cbor, "1.0.1", "39511158e8ea5a57c1fcb9639aaa7efde67129678fee49ebbda780f6f24959b0", [:mix], [], "hexpm", "5431acbe7a7908f17f6a9cd43311002836a34a8ab01876918d8cfb709cd8b6a2"},
"cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
"certifi": {:hex, :certifi, "2.15.0", "0e6e882fcdaaa0a5a9f2b3db55b1394dba07e8d6d9bcad08318fb604c6839712", [:rebar3], [], "hexpm", "b147ed22ce71d72eafdad94f055165c1c182f61a2ff49df28bcc71d1d5b94a60"},
"cloak": {:hex, :cloak, "1.1.4", "aba387b22ea4d80d92d38ab1890cc528b06e0e7ef2a4581d71c3fdad59e997e7", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "92b20527b9aba3d939fab0dd32ce592ff86361547cfdc87d74edce6f980eb3d7"},
"cloak_ecto": {:hex, :cloak_ecto, "1.3.0", "0de127c857d7452ba3c3367f53fb814b0410ff9c680a8d20fbe8b9a3c57a1118", [:mix], [{:cloak, "~> 1.1.1", [hex: :cloak, repo: "hexpm", optional: false]}, {:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}], "hexpm", "314beb0c123b8a800418ca1d51065b27ba3b15f085977e65c0f7b2adab2de1cc"},
"comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"},
"credo": {:hex, :credo, "1.7.16", "a9f1389d13d19c631cb123c77a813dbf16449a2aebf602f590defa08953309d4", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "d0562af33756b21f248f066a9119e3890722031b6d199f22e3cf95550e4f1579"},
"db_connection": {:hex, :db_connection, "2.9.0", "a6a97c5c958a2d7091a58a9be40caf41ab496b0701d21e1d1abff3fa27a7f371", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17d502eacaf61829db98facf6f20808ed33da6ccf495354a41e64fe42f9c509c"},

View file

@ -112,6 +112,7 @@ message HeartbeatResponse {
enum JobType {
DISCOVER = 0; // Full device discovery
POLL = 1; // Poll known sensors/interfaces
MIKROTIK = 2; // MikroTik RouterOS API commands
}
enum QueryType {
@ -125,6 +126,8 @@ message AgentJob {
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
}
message AgentJobList {
@ -163,3 +166,30 @@ message AgentError {
string message = 3;
int64 timestamp = 4;
}
// MikroTik RouterOS API messages
message MikrotikDevice {
string ip = 1;
uint32 port = 2;
string username = 3;
string password = 4;
bool use_ssl = 5; // true = API-SSL (port 8729), false = plain API (port 8728)
}
message MikrotikCommand {
string command = 1; // RouterOS command path (e.g., "/system/identity/print")
map<string, string> args = 2; // Command arguments
}
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
}
message MikrotikSentence {
map<string, string> attributes = 1; // Key-value pairs from RouterOS response
}

View file

@ -0,0 +1,13 @@
defmodule Towerops.Repo.Migrations.AddMikrotikCredentialsToOrganizations do
use Ecto.Migration
def change do
alter table(:organizations) do
add :mikrotik_username, :string
add :mikrotik_password, :binary
add :mikrotik_port, :integer, default: 8729
add :mikrotik_use_ssl, :boolean, default: true
add :mikrotik_enabled, :boolean, default: false
end
end
end

View file

@ -0,0 +1,13 @@
defmodule Towerops.Repo.Migrations.AddMikrotikCredentialsToSites do
use Ecto.Migration
def change do
alter table(:sites) do
add :mikrotik_username, :string
add :mikrotik_password, :binary
add :mikrotik_port, :integer
add :mikrotik_use_ssl, :boolean
add :mikrotik_enabled, :boolean
end
end
end

View file

@ -0,0 +1,14 @@
defmodule Towerops.Repo.Migrations.AddMikrotikCredentialsToDevices do
use Ecto.Migration
def change do
alter table(:devices) do
add :mikrotik_username, :string
add :mikrotik_password, :binary
add :mikrotik_port, :integer
add :mikrotik_use_ssl, :boolean
add :mikrotik_enabled, :boolean
add :mikrotik_credential_source, :string, default: "site"
end
end
end