add mikrotik device handling
This commit is contained in:
parent
bcd2570b10
commit
23af86ba73
25 changed files with 1386 additions and 154 deletions
90
CLAUDE.md
90
CLAUDE.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
#
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)},
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
36
lib/towerops/ecto_types/encrypted_binary.ex
Normal file
36
lib/towerops/ecto_types/encrypted_binary.ex
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
58
lib/towerops/vault.ex
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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..."
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
3
mix.exs
3
mix.exs
|
|
@ -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
|
||||
|
||||
|
|
|
|||
2
mix.lock
2
mix.lock
|
|
@ -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"},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
Loading…
Add table
Reference in a new issue