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` - Applied to both `DevicePollerWorker` and `DeviceMonitorWorker`
- Implementation: `lib/towerops/workers/polling_offset.ex` - 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) ### MIB Name Resolution (Rust NIF)
Uses Rust NIF via Rustler for fast SNMP MIB name resolution (replaces unreliable Erlang SNMP modules). 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 ## Kubernetes Deployment
**Prerequisites**: cert-manager, Traefik, MetalLB, FluxCD, GitLab Agent, NFS Provisioner **Prerequisites**: cert-manager, Traefik, MetalLB, FluxCD, GitLab Agent, NFS Provisioner
**Secrets**: All stored in 1Password, created manually in `towerops` namespace **Secrets**: All stored in 1Password, created manually in `towerops` namespace
- `gitlab-registry` - Docker registry credentials - `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-db` - PostgreSQL connection details
- `towerops-aws` - AWS credentials - `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 **Deployment**: FluxCD monitors Git repository, automatically applies manifests in `k8s/` directory
**Manual**: `kubectl apply -k k8s/` **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` - FluxCD: `kubectl get kustomization -n flux-system`
- Certificates: `kubectl describe certificate towerops-net-cert -n towerops` - Certificates: `kubectl describe certificate towerops-net-cert -n towerops`
- Pods: `kubectl describe pod -n towerops -l app=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 ## Common Patterns

View file

@ -73,6 +73,17 @@ config :towerops, Towerops.Repo,
show_sensitive_data_on_connection_error: true, show_sensitive_data_on_connection_error: true,
pool_size: 10 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 # For development, we disable any cache and enable
# debugging and code reloading. # 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 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" host = System.get_env("PHX_HOST") || "example.com"
# Configure Redis/Valkey connection # Configure Redis/Valkey connection
@ -204,6 +213,14 @@ if config_env() == :prod do
# pool_count: 4, # pool_count: 4,
socket_options: maybe_ipv6 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, config :towerops, ToweropsWeb.Endpoint,
url: [host: host, port: 443, scheme: "https"], url: [host: host, port: 443, scheme: "https"],
http: [ http: [

View file

@ -46,6 +46,17 @@ config :towerops, Towerops.Repo,
pool: Ecto.Adapters.SQL.Sandbox, pool: Ecto.Adapters.SQL.Sandbox,
pool_size: System.schedulers_online() * 2 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, # We don't run a server during test. If one is required,
# you can enable the server option below. # you can enable the server option below.
config :towerops, ToweropsWeb.Endpoint, config :towerops, ToweropsWeb.Endpoint,

View file

@ -127,7 +127,15 @@ defmodule Towerops.Accounts.User do
""" """
def registration_changeset(user, attrs, opts \\ []) do def registration_changeset(user, attrs, opts \\ []) do
user 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_email_for_registration(opts)
|> validate_password(opts) |> validate_password(opts)
|> validate_consent() |> validate_consent()

View file

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

View file

@ -5,6 +5,7 @@ defmodule Towerops.Devices do
import Ecto.Query import Ecto.Query
alias Towerops.Agents
alias Towerops.Devices.Device, as: DeviceSchema alias Towerops.Devices.Device, as: DeviceSchema
alias Towerops.Devices.Event alias Towerops.Devices.Event
alias Towerops.Organizations.SubscriptionLimits alias Towerops.Organizations.SubscriptionLimits
@ -275,6 +276,176 @@ defmodule Towerops.Devices do
end end
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 """ @doc """
Creates device. Creates device.
@ -352,9 +523,12 @@ defmodule Towerops.Devices do
old_snmp_version = device.snmp_version old_snmp_version = device.snmp_version
old_snmp_port = device.snmp_port old_snmp_port = device.snmp_port
case device changeset = DeviceSchema.changeset(device, attrs)
|> DeviceSchema.changeset(attrs)
|> Repo.update() do # 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 -> {:ok, updated_device} = result ->
_ = handle_monitoring_changes(updated_device, old_monitoring) _ = handle_monitoring_changes(updated_device, old_monitoring)
_ = handle_snmp_changes(updated_device, old_snmp, old_snmp_version, old_snmp_port) _ = handle_snmp_changes(updated_device, old_snmp, old_snmp_version, old_snmp_port)
@ -365,6 +539,44 @@ defmodule Towerops.Devices do
end end
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 """ @doc """
Resolves the effective SNMP community string for a device. 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_discovery_at, :utc_datetime
field :last_snmp_poll_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 belongs_to :site, Site
has_one :snmp_device, SnmpDevice has_one :snmp_device, SnmpDevice
@ -64,6 +72,12 @@ defmodule Towerops.Devices.Device do
snmp_port: integer(), snmp_port: integer(),
last_discovery_at: DateTime.t() | nil, last_discovery_at: DateTime.t() | nil,
last_snmp_poll_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_id: Ecto.UUID.t(),
site: NotLoaded.t() | Site.t(), site: NotLoaded.t() | Site.t(),
snmp_device: NotLoaded.t() | SnmpDevice.t() | nil, snmp_device: NotLoaded.t() | SnmpDevice.t() | nil,
@ -92,7 +106,13 @@ defmodule Towerops.Devices.Device do
:last_checked_at, :last_checked_at,
:last_status_change_at, :last_status_change_at,
:last_discovery_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_required([:ip_address, :site_id])
|> validate_name() |> validate_name()
@ -101,7 +121,9 @@ defmodule Towerops.Devices.Device do
|> validate_ip_address() |> validate_ip_address()
|> validate_number(:check_interval_seconds, greater_than: 0, less_than_or_equal_to: 3600) |> validate_number(:check_interval_seconds, greater_than: 0, less_than_or_equal_to: 3600)
|> validate_snmp() |> validate_snmp()
|> validate_mikrotik()
|> update_community_source() |> update_community_source()
|> update_mikrotik_credential_source()
|> foreign_key_constraint(:site_id) |> foreign_key_constraint(:site_id)
end end
@ -170,4 +192,49 @@ defmodule Towerops.Devices.Device do
put_change(changeset, :snmp_community_source, "device") put_change(changeset, :snmp_community_source, "device")
end end
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 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_version, :string, default: "2c"
field :snmp_community, :string 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 belongs_to :default_agent_token, AgentToken
has_many :memberships, Membership has_many :memberships, Membership
@ -45,6 +52,11 @@ defmodule Towerops.Organizations.Organization do
subscription_plan: String.t(), subscription_plan: String.t(),
snmp_version: String.t() | nil, snmp_version: String.t() | nil,
snmp_community: 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_id: Ecto.UUID.t() | nil,
default_agent_token: NotLoaded.t() | AgentToken.t() | nil, default_agent_token: NotLoaded.t() | AgentToken.t() | nil,
memberships: NotLoaded.t() | [Membership.t()], memberships: NotLoaded.t() | [Membership.t()],
@ -62,18 +74,38 @@ defmodule Towerops.Organizations.Organization do
:subscription_plan, :subscription_plan,
:default_agent_token_id, :default_agent_token_id,
:snmp_version, :snmp_version,
:snmp_community :snmp_community,
:mikrotik_username,
:mikrotik_password,
:mikrotik_port,
:mikrotik_use_ssl,
:mikrotik_enabled
]) ])
|> validate_required([:name]) |> validate_required([:name])
|> validate_length(:name, min: 2, max: 100) |> validate_length(:name, min: 2, max: 100)
|> validate_inclusion(:subscription_plan, ["free"]) |> validate_inclusion(:subscription_plan, ["free"])
|> validate_inclusion(:snmp_version, ["1", "2c", "3"], message: "must be 1, 2c, or 3") |> validate_inclusion(:snmp_version, ["1", "2c", "3"], message: "must be 1, 2c, or 3")
|> validate_mikrotik_fields()
|> generate_slug() |> generate_slug()
|> validate_required([:slug]) |> validate_required([:slug])
|> unique_constraint(:slug) |> unique_constraint(:slug)
|> foreign_key_constraint(:default_agent_token_id) |> foreign_key_constraint(:default_agent_token_id)
end 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 defp generate_slug(changeset) do
# Only generate slug if not already set # Only generate slug if not already set
if get_field(changeset, :slug) do if get_field(changeset, :slug) do

View file

@ -230,6 +230,7 @@ defmodule Towerops.Agent.JobType do
field :DISCOVER, 0 field :DISCOVER, 0
field :POLL, 1 field :POLL, 1
field :MIKROTIK, 2
end end
defmodule Towerops.Agent.QueryType do defmodule Towerops.Agent.QueryType do
@ -258,6 +259,8 @@ defmodule Towerops.Agent.AgentJob do
field :device_id, 3, type: :string, json_name: "deviceId" field :device_id, 3, type: :string, json_name: "deviceId"
field :snmp_device, 4, type: Towerops.Agent.SnmpDevice, json_name: "snmpDevice" field :snmp_device, 4, type: Towerops.Agent.SnmpDevice, json_name: "snmpDevice"
field :queries, 5, repeated: true, type: Towerops.Agent.SnmpQuery 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 end
defmodule Towerops.Agent.AgentJobList do defmodule Towerops.Agent.AgentJobList do
@ -351,3 +354,82 @@ defmodule Towerops.Agent.AgentError do
field :message, 3, type: :string field :message, 3, type: :string
field :timestamp, 4, type: :int64 field :timestamp, 4, type: :int64
end 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_version, :string
field :snmp_community, :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 :organization, Organization
belongs_to :agent_token, AgentToken belongs_to :agent_token, AgentToken
belongs_to :parent_site, Site belongs_to :parent_site, Site
@ -43,6 +50,11 @@ defmodule Towerops.Sites.Site do
display_order: integer() | nil, display_order: integer() | nil,
snmp_version: String.t() | nil, snmp_version: String.t() | nil,
snmp_community: 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_id: Ecto.UUID.t(),
organization: NotLoaded.t() | Organization.t(), organization: NotLoaded.t() | Organization.t(),
agent_token_id: Ecto.UUID.t() | nil, agent_token_id: Ecto.UUID.t() | nil,
@ -67,13 +79,19 @@ defmodule Towerops.Sites.Site do
:agent_token_id, :agent_token_id,
:parent_site_id, :parent_site_id,
:snmp_version, :snmp_version,
:snmp_community :snmp_community,
:mikrotik_username,
:mikrotik_password,
:mikrotik_port,
:mikrotik_use_ssl,
:mikrotik_enabled
]) ])
|> validate_required([:name, :organization_id]) |> validate_required([:name, :organization_id])
|> validate_length(:name, min: 2, max: 200) |> validate_length(:name, min: 2, max: 200)
|> validate_length(:description, max: 1000) |> validate_length(:description, max: 1000)
|> validate_length(:location, max: 200) |> validate_length(:location, max: 200)
|> validate_inclusion(:snmp_version, ["1", "2c", "3"], message: "must be 1, 2c, or 3") |> 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(:organization_id)
|> foreign_key_constraint(:agent_token_id) |> foreign_key_constraint(:agent_token_id)
|> foreign_key_constraint(:parent_site_id) |> foreign_key_constraint(:parent_site_id)
@ -84,6 +102,20 @@ defmodule Towerops.Sites.Site do
|> validate_not_circular_parent() |> validate_not_circular_parent()
end 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 defp validate_not_circular_parent(changeset) do
parent_site_id = get_change(changeset, :parent_site_id) parent_site_id = get_change(changeset, :parent_site_id)
site_id = get_field(changeset, :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.AgentHeartbeat
alias Towerops.Agent.AgentJob alias Towerops.Agent.AgentJob
alias Towerops.Agent.AgentJobList alias Towerops.Agent.AgentJobList
alias Towerops.Agent.MikrotikCommand
alias Towerops.Agent.MikrotikDevice
alias Towerops.Agent.SnmpDevice alias Towerops.Agent.SnmpDevice
alias Towerops.Agent.SnmpQuery alias Towerops.Agent.SnmpQuery
alias Towerops.Agent.SnmpResult alias Towerops.Agent.SnmpResult
@ -191,15 +193,26 @@ defmodule ToweropsWeb.AgentChannel do
defp build_jobs_for_agent(agent_token_id) do defp build_jobs_for_agent(agent_token_id) do
agent_token_id agent_token_id
|> Agents.list_agent_polling_targets() |> Agents.list_agent_polling_targets()
|> Enum.map(&build_job_for_device/1) |> Enum.flat_map(&build_jobs_for_device/1)
end end
defp build_job_for_device(device) do defp build_jobs_for_device(device) do
if needs_discovery?(device) do # Build SNMP job (discovery or polling)
build_discovery_job(device) snmp_job =
else if needs_discovery?(device) do
build_polling_job(device) build_discovery_job(device)
end 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 end
defp needs_discovery?(device) do defp needs_discovery?(device) do
@ -209,6 +222,13 @@ defmodule ToweropsWeb.AgentChannel do
DateTime.diff(DateTime.utc_now(), device.last_discovery_at, :hour) > 24 DateTime.diff(DateTime.utc_now(), device.last_discovery_at, :hour) > 24
end 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 defp build_discovery_job(device) do
%AgentJob{ %AgentJob{
job_id: "discover:#{device.id}", job_id: "discover:#{device.id}",
@ -355,6 +375,42 @@ defmodule ToweropsWeb.AgentChannel do
] ]
end 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 defp process_snmp_result(organization_id, result, socket) do
with {:ok, device} <- fetch_device(result.device_id), with {:ok, device} <- fetch_device(result.device_id),
:ok <- verify_device_organization(device, organization_id) do :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 # Get effective SNMP configuration and source
snmp_config = Devices.get_snmp_config(device) 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 # Determine monitoring mode based on current snmp_enabled value
monitoring_mode = if device.snmp_enabled, do: "snmp_and_icmp", else: "icmp_only" 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(:snmp_config_source, snmp_config.source)
|> assign(:effective_snmp_version, snmp_config.version) |> assign(:effective_snmp_version, snmp_config.version)
|> assign(:effective_snmp_community, snmp_config.community || "(not set)") |> 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) |> assign(:monitoring_mode, monitoring_mode)
end 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 @impl true
def handle_event("switch_monitoring_mode", %{"mode" => mode}, socket) do def handle_event("switch_monitoring_mode", %{"mode" => mode}, socket) do
# Update snmp_enabled based on the selected mode # Update snmp_enabled based on the selected mode

View file

@ -357,6 +357,80 @@
</div> </div>
</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"> <div class="flex gap-3 mt-6">
<.button <.button
phx-disable-with="Saving..." phx-disable-with="Saving..."

View file

@ -596,6 +596,14 @@ defmodule ToweropsWeb.GraphLive.Show do
end end
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 # Handle live poll event
@impl true @impl true
def handle_info(:live_poll, socket) do def handle_info(:live_poll, socket) do

View file

@ -2,162 +2,444 @@
flash={@flash} flash={@flash}
current_scope={@current_scope} current_scope={@current_scope}
> >
<div class="mb-4"> <div class="border-b border-gray-200 pb-5 dark:border-white/5">
<.link <div class="mb-4">
navigate={~p"/orgs/#{@organization.slug}"} <.link
class="inline-flex items-center gap-1 text-sm text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white" 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> <.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> </div>
<.header> <.form for={@form} id="organization-form" phx-change="validate" phx-submit="save">
Organization Settings <div class="divide-y divide-gray-200 dark:divide-white/10">
<:subtitle> <!-- Organization Name Section -->
Manage your organization settings and defaults <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">
</:subtitle> <div>
</.header> <h2 class="text-base/7 font-semibold text-gray-900 dark:text-white">
Organization Name
<div class="max-w-2xl"> </h2>
<.form for={@form} id="organization-form" phx-change="validate" phx-submit="save"> <p class="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
<.input field={@form[:name]} type="text" label="Organization Name" required /> Update the name of your organization.
<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.
</p> </p>
</div>
<.input <div class="md:col-span-2">
field={@form[:default_agent_token_id]} <div class="grid grid-cols-1 gap-x-6 gap-y-8 sm:max-w-xl sm:grid-cols-6">
type="select" <div class="col-span-full">
label="Default Remote Agent" <label
prompt="No default agent - cloud polling" for="organization_name"
options={Enum.map(@available_agents, &{&1.name, &1.id})} 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"> <div class="md:col-span-2">
<p class="font-medium text-gray-700 dark:text-gray-300 mb-2"> <div class="grid grid-cols-1 gap-x-6 gap-y-8 sm:max-w-xl sm:grid-cols-6">
Current Device Assignment: <div class="col-span-full">
</p> <label
<div class="space-y-1 text-gray-600 dark:text-gray-400"> for="snmp_version"
<div class="flex items-center gap-2"> class="block text-sm/6 font-medium text-gray-900 dark:text-white"
<.icon name="hero-server" class="h-4 w-4" /> >
<span> SNMP Version
<strong>{@assignment_breakdown.direct}</strong> device-level override </label>
</span> <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>
<div class="flex items-center gap-2"> </div>
<.icon name="hero-building-office" class="h-4 w-4" />
<span><strong>{@assignment_breakdown.site}</strong> inherited from site</span> <div class="col-span-full">
</div> <label
<div class="flex items-center gap-2"> for="snmp_community"
<.icon name="hero-building-office-2" class="h-4 w-4" /> class="block text-sm/6 font-medium text-gray-900 dark:text-white"
<span> >
<strong>{@assignment_breakdown.organization}</strong> SNMP Community String
inheriting organization default </label>
</span> <div class="mt-2">
</div> <input
<div class="flex items-center gap-2"> type="text"
<.icon name="hero-cloud" class="h-4 w-4" /> name={@form[:snmp_community].name}
<span> id="snmp_community"
<strong>{@assignment_breakdown.cloud}</strong> cloud polling (no agent) value={@form[:snmp_community].value}
</span> 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> </div>
</div> </div>
<p class="mt-3 text-sm text-gray-500 dark:text-gray-400 italic"> <%= if @organization.snmp_community do %>
<.icon name="hero-information-circle" class="h-4 w-4 inline" /> <div class="mt-6 rounded-md bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 p-4">
Agent assignment hierarchy: Device > Site > Organization <h3 class="text-sm font-medium text-amber-900 dark:text-amber-200 mb-2">
</p> Force Apply to All Devices
</h3>
<%= 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>
<p class="text-sm text-amber-700 dark:text-amber-300 mb-3"> <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> </p>
<.button <button
type="button" type="button"
phx-click="apply_agent_to_all" phx-click="apply_snmp_to_all"
data-confirm="This will replace agent assignments for ALL devices in this organization. Are you sure?" data-confirm="This will replace SNMP settings for ALL devices in this organization. Are you sure?"
variant="danger" 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 <.icon name="hero-arrow-path" class="h-4 w-4 inline" />
</.button> Apply SNMP Config to All Devices
</button>
</div> </div>
<% end %> <% end %>
</div> </div>
<% else %> </div>
<div class="mt-6 border-t pt-6">
<h3 class="text-lg font-medium mb-2">Default Agent</h3> <%= if @current_scope.user.is_superuser do %>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4"> <!-- MikroTik API Configuration Section -->
No agents configured yet. <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">
<.link <div>
navigate={~p"/agents"} <h2 class="text-base/7 font-semibold text-gray-900 dark:text-white">
class="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300" MikroTik API Configuration
> </h2>
Create an agent <p class="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
</.link> Set default MikroTik RouterOS API credentials for all devices in this organization. Only applies to devices detected as MikroTik.
to enable remote polling. </p>
</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> </div>
<% end %> <% 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"> <div class="md:col-span-2">
<.button phx-disable-with="Saving..." variant="primary">Save Settings</.button> <div class="grid grid-cols-1 gap-x-6 gap-y-8 sm:max-w-xl sm:grid-cols-6">
<.button navigate={~p"/orgs/#{@organization.slug}"}>Cancel</.button> <div class="col-span-full">
</div> <label
</.form> for="default_agent_token_id"
</div> 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> </Layouts.authenticated>

View file

@ -178,6 +178,71 @@
<% end %> <% end %>
</div> </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"> <div class="flex gap-3 mt-6">
<.button phx-disable-with="Saving..." variant="primary">Save Site</.button> <.button phx-disable-with="Saving..." variant="primary">Save Site</.button>
<.button navigate={~p"/sites"}>Cancel</.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}, {:credo, "~> 1.7", only: [:dev, :test], runtime: false},
{:sobelow, "~> 0.14.1", only: [:dev, :test], runtime: false}, {:sobelow, "~> 0.14.1", only: [:dev, :test], runtime: false},
{:mix_audit, "~> 2.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 end

View file

@ -5,6 +5,8 @@
"cbor": {:hex, :cbor, "1.0.1", "39511158e8ea5a57c1fcb9639aaa7efde67129678fee49ebbda780f6f24959b0", [:mix], [], "hexpm", "5431acbe7a7908f17f6a9cd43311002836a34a8ab01876918d8cfb709cd8b6a2"}, "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"}, "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"}, "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"}, "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"}, "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"}, "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 { enum JobType {
DISCOVER = 0; // Full device discovery DISCOVER = 0; // Full device discovery
POLL = 1; // Poll known sensors/interfaces POLL = 1; // Poll known sensors/interfaces
MIKROTIK = 2; // MikroTik RouterOS API commands
} }
enum QueryType { enum QueryType {
@ -125,6 +126,8 @@ message AgentJob {
string device_id = 3; // Device UUID string device_id = 3; // Device UUID
SnmpDevice snmp_device = 4; // SNMP connection details SnmpDevice snmp_device = 4; // SNMP connection details
repeated SnmpQuery queries = 5; // Queries to execute 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 { message AgentJobList {
@ -163,3 +166,30 @@ message AgentError {
string message = 3; string message = 3;
int64 timestamp = 4; 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