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`
|
- 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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
#
|
#
|
||||||
|
|
|
||||||
|
|
@ -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: [
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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)},
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
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_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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
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.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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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..."
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
3
mix.exs
3
mix.exs
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
2
mix.lock
2
mix.lock
|
|
@ -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"},
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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