fix: input validation, SSRF, API hardening, and cookie security
- LIKE wildcard injection: sanitize %, _ in search queries (devices, sites, gaiia) - Jason.decode! → Jason.decode with error handling for untrusted input - inspect() leak: replace with generic error messages, log details server-side - SSRF protection: URL validator blocks private IPs, localhost, non-HTTP schemes - SSRF validation added to HTTP monitoring executor and integration credentials - GraphQL complexity limits: always applied, not just in prod - GraphQL introspection: also check GET query params, not just body - Stripe webhook: explicit nil/empty checks for signature and body - Cookie security: secure flag for session (prod), http_only+secure for remember_me - Honeybadger API key: read from env var with fallback - String.to_integer → Integer.parse with fallback for URL params - String.to_atom → whitelist map for HTTP methods - Gaiia webhook: remove secret_len and expected signature from log - Admin API: add rate limiting pipeline - to_atom_keys: per-key fallback instead of all-or-nothing rescue
This commit is contained in:
parent
fd8f39451c
commit
1590f78bdc
32 changed files with 14954 additions and 83 deletions
|
|
@ -1 +1 @@
|
|||
/nix/store/zx636v118rzzqzwafgrw2aybh7l0szrl-pre-commit-config.json
|
||||
/nix/store/4xlkmdd947h5qlhj2rmbyavq08grkz27-pre-commit-config.json
|
||||
1012
docs/ARCHITECTURE.md
Normal file
1012
docs/ARCHITECTURE.md
Normal file
File diff suppressed because it is too large
Load diff
524
docs/COMPETITIVE_ANALYSIS.md
Normal file
524
docs/COMPETITIVE_ANALYSIS.md
Normal file
|
|
@ -0,0 +1,524 @@
|
|||
# TowerOps Competitive Analysis
|
||||
|
||||
**Last Updated**: 2026-03-13
|
||||
**Purpose**: Map the ISP/WISP network monitoring and operations tool landscape, identify gaps, and position TowerOps for differentiation.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [LibreNMS](#1-librenms)
|
||||
2. [PRTG Network Monitor](#2-prtg-network-monitor)
|
||||
3. [Auvik](#3-auvik)
|
||||
4. [The Dude (MikroTik)](#4-the-dude-mikrotik)
|
||||
5. [Preseem](#5-preseem)
|
||||
6. [Sonar](#6-sonar)
|
||||
7. [Splynx](#7-splynx)
|
||||
8. [Oxidized / RANCID](#8-oxidized--rancid)
|
||||
9. [Grafana + Prometheus](#9-grafana--prometheus)
|
||||
10. [Competitive Matrix](#competitive-matrix)
|
||||
11. [TowerOps Positioning](#towerops-positioning)
|
||||
|
||||
---
|
||||
|
||||
## 1. LibreNMS
|
||||
|
||||
**Website**: librenms.org
|
||||
**License**: GPL v3 (open source)
|
||||
|
||||
### Target Market
|
||||
ISPs, WISPs, enterprises, hosting providers, universities — anyone with network infrastructure and the technical staff to self-host.
|
||||
|
||||
### Pricing Model
|
||||
Free and open source. No licensing costs. Community support via Discord and forums. Professional support available through third-party consultants.
|
||||
|
||||
### Key Strengths
|
||||
- **Massive device support**: Auto-discovery via CDP, FDP, LLDP, OSPF, BGP, SNMP, and ARP. 786+ OS profiles.
|
||||
- **Billing module**: Built-in bandwidth billing system for ports based on usage or transfer — rare in an open-source monitoring tool.
|
||||
- **Distributed polling**: Horizontal scaling with poller groups. Handles large networks (10k+ devices).
|
||||
- **Alerting flexibility**: Alerts via email, IRC, Slack, PagerDuty, Telegram, and many more transports.
|
||||
- **API access**: Full REST API for automation and integration.
|
||||
- **Community**: Large, active community. Active Discord. Regular contributions.
|
||||
- **Zero cost**: No per-device, per-sensor, or per-subscriber fees.
|
||||
|
||||
### Key Weaknesses / Gaps
|
||||
- **UX is dated**: Web UI is functional but looks like it was designed in 2015. Not responsive or modern. Navigation is confusing for new users.
|
||||
- **RRDtool limitations**: Default time-series backend (RRDtool) has fixed retention periods and is not SQL-queryable. InfluxDB support exists but is less mature.
|
||||
- **No multi-tenancy**: Single-organization design. No proper tenant isolation for MSPs or multi-client environments.
|
||||
- **Complex setup**: Requires Linux sysadmin skills. Installation, upgrades, and maintenance are manual. No SaaS option.
|
||||
- **No agent architecture**: Polling is centralized or distributed-pollers, but no lightweight edge agents for remote sites behind NAT/firewalls.
|
||||
- **No subscriber awareness**: Monitors devices and interfaces, not subscribers. No concept of "this customer is having a bad experience."
|
||||
- **Config management**: No built-in config backup or change tracking. Requires Oxidized or RANCID alongside it.
|
||||
- **Mobile app**: Community-built mobile app exists (by Wavedirect) but not officially maintained.
|
||||
|
||||
### What TowerOps Can Learn / Differentiate On
|
||||
- **Learn**: LibreNMS's device profile coverage (786 OS types) is the gold standard. TowerOps has matched this — maintain parity.
|
||||
- **Learn**: Bandwidth billing module is a unique feature that ISPs value. TowerOps integrates with billing platforms (Sonar, Splynx) instead — a different but arguably better approach.
|
||||
- **Differentiate**: Modern LiveView UI vs. LibreNMS's aging PHP interface. This is a major selling point.
|
||||
- **Differentiate**: Multi-tenancy from day one. LibreNMS can't serve MSPs or multi-org setups cleanly.
|
||||
- **Differentiate**: TimescaleDB > RRDtool. SQL-queryable, better compression, better retention policies.
|
||||
- **Differentiate**: Agent-based distributed polling for sites behind NAT. LibreNMS requires VPN tunnels or direct access.
|
||||
- **Differentiate**: Subscriber-aware monitoring. LibreNMS thinks in devices; TowerOps can think in customers.
|
||||
|
||||
---
|
||||
|
||||
## 2. PRTG Network Monitor
|
||||
|
||||
**Website**: paessler.com/prtg
|
||||
**Vendor**: Paessler AG (German company)
|
||||
|
||||
### Target Market
|
||||
Mid-market enterprise IT departments, MSPs, some ISPs. Generalist network monitoring — not ISP/WISP-specific.
|
||||
|
||||
### Pricing Model
|
||||
Subscription-based by sensor count:
|
||||
- **PRTG 500** (50 devices): $179/month
|
||||
- **PRTG 1000** (100 devices): $325/month
|
||||
- **PRTG 2500** (250 devices): $675/month
|
||||
- **PRTG 5000** (500 devices): $1,183/month
|
||||
- **PRTG 10000** (1000 devices): $1,492/month
|
||||
- **Enterprise**: Custom pricing for 10k+ sensors
|
||||
- Free tier: 100 sensors (~10 devices)
|
||||
|
||||
### Key Strengths
|
||||
- **Ease of use**: Highly polished UI. Web interface, desktop app, and mobile apps (iOS/Android). Best-in-class for non-technical users.
|
||||
- **Sensor-based architecture**: Granular monitoring — each metric is a "sensor." Flexible but can get expensive quickly.
|
||||
- **Broad protocol support**: SNMP, WMI, SSH, HTTP, NetFlow, sFlow, jFlow, packet sniffing, and more.
|
||||
- **Maps & dashboards**: Built-in map designer with real-time status. Good for NOC displays.
|
||||
- **Notifications**: Email, push notifications, HTTP requests, SMS, Slack, Teams, etc.
|
||||
- **Windows-native**: Runs on Windows Server. Familiar for Windows-shop IT teams.
|
||||
|
||||
### Key Weaknesses / Gaps
|
||||
- **Expensive at scale**: An ISP with 500 devices needs PRTG 5000 ($1,183/mo) and will burn through sensors quickly. Interface monitoring alone eats 5-10 sensors per device.
|
||||
- **Windows-only server**: Core server runs only on Windows. Odd choice for network monitoring in a Linux-heavy ISP world.
|
||||
- **Not ISP-aware**: No subscriber mapping, no billing integration, no WISP-specific features. It's a generic IT monitoring tool.
|
||||
- **No config management**: No config backup or diff tracking.
|
||||
- **Sensor sprawl**: The sensor model means costs escalate unpredictably. Hard to budget for growing networks.
|
||||
- **No distributed agents**: Remote probes exist but are Windows-based. No lightweight Linux agent for tower sites.
|
||||
- **Limited automation**: Basic auto-discovery but no sophisticated topology mapping or network automation.
|
||||
|
||||
### What TowerOps Can Learn / Differentiate On
|
||||
- **Learn**: PRTG's UX polish is excellent. Maps, dashboards, mobile apps — they invest heavily in usability. TowerOps should aim for this level of polish.
|
||||
- **Learn**: The sensor model, while expensive, gives users granular control. Consider offering similar granularity with better pricing.
|
||||
- **Differentiate**: Per-device or flat pricing vs. per-sensor. ISPs hate unpredictable costs.
|
||||
- **Differentiate**: Linux-native, cloud-native. No Windows dependency.
|
||||
- **Differentiate**: ISP/WISP-specific features that PRTG will never build (subscriber awareness, billing integration, tower management).
|
||||
|
||||
---
|
||||
|
||||
## 3. Auvik
|
||||
|
||||
**Website**: auvik.com
|
||||
**Type**: Cloud-based SaaS
|
||||
|
||||
### Target Market
|
||||
MSPs (Managed Service Providers) primarily. Some mid-market IT departments. Not ISP/WISP-focused.
|
||||
|
||||
### Pricing Model
|
||||
Quote-based (not publicly listed). Reports suggest:
|
||||
- Per-device pricing, tiered by network size
|
||||
- Estimated $3-8/device/month depending on volume
|
||||
- Multi-tenant by design (MSP model)
|
||||
- No free tier; 14-day free trial
|
||||
|
||||
### Key Strengths
|
||||
- **Cloud-first**: No server to manage. Deploy a lightweight collector and go. Full visibility in under an hour.
|
||||
- **Auto-discovery & mapping**: Best-in-class automated network topology mapping. Live, auto-updating maps.
|
||||
- **64+ pre-configured alerts**: Works out of the box with sensible defaults. Low time-to-value.
|
||||
- **Configuration backup**: Automatic config backup with version history and diff comparison. Built-in (no Oxidized needed).
|
||||
- **700+ vendor support**: Broad multi-vendor device support out of the box.
|
||||
- **NetFlow/traffic analysis**: Deep traffic visibility including encrypted traffic analysis.
|
||||
- **MSP integrations**: ConnectWise, Autotask, ServiceNow, IT Glue, PagerDuty, Slack, Teams, etc.
|
||||
- **Multi-tenant**: Built for MSPs managing many client networks. Proper tenant isolation.
|
||||
- **Modern UX**: Clean, intuitive interface. Junior techs can use it immediately.
|
||||
|
||||
### Key Weaknesses / Gaps
|
||||
- **Not ISP-focused**: Designed for corporate LANs, not carrier networks. No subscriber awareness, no WISP features, no tower management.
|
||||
- **Expensive for ISPs**: Per-device pricing adds up fast for ISPs with hundreds/thousands of devices. An ISP with 1000 devices could be paying $3,000-8,000/month.
|
||||
- **No SNMP depth**: Good for L2/L3 basics but lacks deep SNMP sensor discovery (temperature, power, optical levels) that ISPs need for tower equipment.
|
||||
- **No billing integration**: No connection to ISP billing platforms (Sonar, Splynx, VISP).
|
||||
- **Cloud-only**: No self-hosted option. Data sovereignty concerns for some operators.
|
||||
- **Limited customization**: Pre-built dashboards are good but custom metrics/sensors are limited compared to PRTG or LibreNMS.
|
||||
- **No QoE**: No subscriber quality-of-experience monitoring.
|
||||
|
||||
### What TowerOps Can Learn / Differentiate On
|
||||
- **Learn**: Auvik's time-to-value is exceptional. Deploy collector → see network in minutes. TowerOps's agent model should aim for similar simplicity.
|
||||
- **Learn**: Auto-updating topology maps are a killer feature. TowerOps already has LLDP topology discovery — push this further.
|
||||
- **Learn**: Built-in config backup eliminates the need for a separate Oxidized/RANCID install. TowerOps should include this.
|
||||
- **Differentiate**: ISP/WISP domain expertise vs. Auvik's MSP/corporate focus.
|
||||
- **Differentiate**: Deep SNMP sensor discovery (optical, RF, environmental) vs. Auvik's shallow device monitoring.
|
||||
- **Differentiate**: Billing platform integrations (Sonar, Splynx, VISP, Gaiia) that Auvik will never build.
|
||||
|
||||
---
|
||||
|
||||
## 4. The Dude (MikroTik)
|
||||
|
||||
**Website**: mikrotik.com (under Tools/Legacy)
|
||||
**Vendor**: MikroTik
|
||||
|
||||
### Target Market
|
||||
Small-to-medium WISPs and networks running MikroTik equipment. Hobbyists. Budget-conscious operators.
|
||||
|
||||
### Pricing Model
|
||||
**Free**. Included with MikroTik RouterOS. Server can run on any RouterOS device or as a Windows/Wine application.
|
||||
|
||||
### Key Strengths
|
||||
- **Free**: Zero cost. Hard to compete with free for budget-conscious WISPs.
|
||||
- **Deep MikroTik integration**: Direct RouterOS API access. Can monitor and configure MikroTik devices natively without SNMP overhead.
|
||||
- **Auto-discovery**: Subnet scanning with service detection. Draws network maps automatically.
|
||||
- **Lightweight**: Runs on a MikroTik router itself (CHR or hardware). No separate server needed.
|
||||
- **Network maps**: Visual topology with automatic layout. Real-time status overlay.
|
||||
- **Multi-protocol**: SNMP, ICMP, DNS, TCP services monitoring.
|
||||
- **Widely used in WISP community**: De facto standard for MikroTik-heavy WISPs.
|
||||
|
||||
### Key Weaknesses / Gaps
|
||||
- **Legacy/neglected product**: MikroTik lists it under "Legacy Tools." Development has stagnated. The client is a Windows app (or runs under Wine). No web UI.
|
||||
- **MikroTik-centric**: Works great for MikroTik gear but limited for multi-vendor environments (Ubiquiti, Cambium, Cisco, etc.).
|
||||
- **No web interface**: Desktop client only. No remote access without VPN/RDP. No mobile app.
|
||||
- **Limited alerting**: Basic notifications but no sophisticated alert routing, escalation, or integration with modern tools (PagerDuty, Slack, etc.).
|
||||
- **No time-series storage**: Stores current state but lacks historical trending, capacity planning, or long-term data retention.
|
||||
- **No API**: No REST API or integration capabilities.
|
||||
- **No multi-tenancy**: Single-user design.
|
||||
- **Scaling issues**: Performance degrades with large networks (500+ devices). Not designed for ISP-scale operations.
|
||||
- **No config backup**: No configuration management features.
|
||||
- **No subscriber awareness**: Device-centric only.
|
||||
|
||||
### What TowerOps Can Learn / Differentiate On
|
||||
- **Learn**: The Dude's auto-discovery and visual mapping are beloved by WISPs. Simple, immediate, visual. TowerOps topology should feel this intuitive.
|
||||
- **Learn**: Running on the router itself (zero infrastructure) is appealing for tiny WISPs. TowerOps's agent-on-router approach can replicate this for larger operators.
|
||||
- **Differentiate**: Everything. The Dude is a legacy tool with no active development. TowerOps is the modern replacement.
|
||||
- **Differentiate**: Web UI, multi-vendor, multi-tenant, alerting, API, historical data, subscriber awareness — all things The Dude lacks.
|
||||
- **Key opportunity**: Many WISPs have outgrown The Dude but haven't found a replacement that "gets" their world. TowerOps is that replacement.
|
||||
|
||||
---
|
||||
|
||||
## 5. Preseem
|
||||
|
||||
**Website**: preseem.com
|
||||
**Type**: Cloud-based SaaS with on-prem appliance for traffic analysis
|
||||
|
||||
### Target Market
|
||||
Regional ISPs and WISPs. Purpose-built for fixed wireless and fiber operators.
|
||||
|
||||
### Pricing Model
|
||||
- **Per-subscriber**: Starting at $0.40/subscriber/month
|
||||
- **Minimum**: $300/month
|
||||
- **Volume discounts** available
|
||||
- **Month-to-month**: No long-term contracts required
|
||||
- **Hardware**: Optional pre-loaded appliances or BYO Linux server
|
||||
- 30-day free trial
|
||||
|
||||
### Key Strengths
|
||||
- **QoE-focused**: Quality of Experience monitoring is their core differentiator. Measures what subscribers actually experience, not just device uptime.
|
||||
- **Traffic shaping**: Adaptive traffic management that reduces congestion and improves subscriber QoE. This is unique — most monitoring tools only observe.
|
||||
- **Multi-vendor, multi-access**: Supports fixed wireless (Cambium, Ubiquiti, MikroTik, Baicells, etc.) and fiber. Unified view across technologies.
|
||||
- **Billing integrations**: Connects to Sonar, Splynx, Powercode, VISP, BillMax, Azotel, and more. Subscriber-aware from day one.
|
||||
- **Churn reduction**: Proven results — customers report 25-30% churn reduction. Direct revenue impact.
|
||||
- **Industry recognition**: 5x WISPA Service of the Year winner. Deeply embedded in the WISP community.
|
||||
- **Cloud analytics**: Centralized view across all vendors and network elements. No need to log into multiple systems.
|
||||
- **Scales to 100+ Gbps**: Handles large traffic volumes.
|
||||
|
||||
### Key Weaknesses / Gaps
|
||||
- **Not a full monitoring platform**: Preseem monitors traffic and QoE but doesn't replace a full NMS. No SNMP polling, no device inventory, no ping monitoring, no alerting for device failures.
|
||||
- **Requires inline deployment**: The appliance sits inline or uses port mirroring. Adds a potential point of failure and complexity.
|
||||
- **No config management**: No config backup or change tracking.
|
||||
- **No topology mapping**: No network topology discovery or visualization.
|
||||
- **No on-call/incident management**: No paging, escalation, or incident workflows.
|
||||
- **Pricing adds up**: At $0.40/subscriber, a 5,000-subscriber ISP pays $2,000/month just for QoE. On top of their NMS, billing platform, etc.
|
||||
- **Complementary, not complete**: ISPs still need LibreNMS/PRTG/etc. alongside Preseem. More tools = more cost and complexity.
|
||||
|
||||
### What TowerOps Can Learn / Differentiate On
|
||||
- **Learn**: Subscriber-aware monitoring is incredibly valuable. Preseem proved the market wants this. TowerOps should integrate subscriber context from billing platforms.
|
||||
- **Learn**: QoE metrics (latency, jitter, packet loss per subscriber) matter more than device uptime to ISP operators. Build this into TowerOps.
|
||||
- **Learn**: Preseem's billing integrations are a model. They connect to every major ISP billing platform — TowerOps already integrates with Sonar, Splynx, VISP, and Gaiia.
|
||||
- **Differentiate**: TowerOps can be the unified platform that includes SNMP monitoring + subscriber awareness + alerting + topology — eliminating the need for Preseem as a separate tool.
|
||||
- **Complement or compete**: In the near term, TowerOps + Preseem could work together. Long term, TowerOps could build QoE features and reduce the need for Preseem.
|
||||
|
||||
---
|
||||
|
||||
## 6. Sonar
|
||||
|
||||
**Website**: sonar.software
|
||||
**Type**: Cloud-based SaaS
|
||||
|
||||
### Target Market
|
||||
ISPs of all types: cable, fiber, wireless, VoIP, MDU, enterprise. Focused on billing and operations, not monitoring.
|
||||
|
||||
### Pricing Model
|
||||
Per-subscriber tiered pricing with $500/month minimum (with contract):
|
||||
- **1-5,000 subscribers**: $1.25/subscriber/month
|
||||
- **5,001-10,000**: $1.00/subscriber/month
|
||||
- **10,001-50,000**: $0.75/subscriber/month
|
||||
- **50,000+**: Enterprise/custom pricing
|
||||
|
||||
### Key Strengths
|
||||
- **Comprehensive ISP operations**: Billing, CRM, ticketing, inventory, field service scheduling, provisioning — all in one platform.
|
||||
- **Financial tools**: 25+ pre-built financial reports. Tax management across jurisdictions. Payment processing.
|
||||
- **Field service**: Job scheduling, dispatch, GPS tracking, work orders, checklists. Mobile app for field techs.
|
||||
- **Network visibility**: Basic monitoring, fault detection, device provisioning, performance tracking.
|
||||
- **DataConnect**: Snowflake data warehouse integration for advanced analytics.
|
||||
- **Customer portal**: Self-service portal for subscribers.
|
||||
- **US/Canada support**: Real humans, no outsourcing, Mon-Fri 8am-6pm CT.
|
||||
|
||||
### Key Weaknesses / Gaps
|
||||
- **Monitoring is basic**: Sonar's network monitoring is rudimentary compared to a dedicated NMS. Basic ICMP/SNMP, no deep sensor discovery, no QoE.
|
||||
- **Not a replacement for an NMS**: ISPs using Sonar still need LibreNMS, PRTG, or similar for real network monitoring.
|
||||
- **Expensive at scale**: A 5,000-subscriber ISP pays $6,250/month. A 10,000-subscriber ISP pays ~$7,500/month. This is before monitoring, QoE, or other tools.
|
||||
- **Cloud-only**: No self-hosted option.
|
||||
- **No topology/mapping**: No network topology visualization.
|
||||
- **No config management**: No device config backup or change tracking.
|
||||
|
||||
### What TowerOps Can Learn / Differentiate On
|
||||
- **Learn**: Sonar owns the "ISP operations platform" positioning. TowerOps should position as the monitoring/network operations complement, not a billing competitor.
|
||||
- **Learn**: Sonar's integration ecosystem is a model. Being the platform that other tools integrate with is powerful.
|
||||
- **Differentiate**: TowerOps is the deep network monitoring that Sonar can't do. Sonar does billing; TowerOps does monitoring. They're complementary.
|
||||
- **Integration opportunity**: TowerOps's existing Sonar integration should be a first-class feature. Pull subscriber data from Sonar → correlate with network health → give NOC teams subscriber context.
|
||||
- **Differentiate**: TowerOps can surface network problems that impact billing (e.g., "these 50 subscribers have been experiencing degraded service for 3 days — expect churn").
|
||||
|
||||
---
|
||||
|
||||
## 7. Splynx
|
||||
|
||||
**Website**: splynx.com
|
||||
**Type**: Cloud-hosted or self-hosted SaaS
|
||||
|
||||
### Target Market
|
||||
Small-to-medium ISPs worldwide. Strong presence in Africa, Eastern Europe, Southeast Asia, and Latin America. 1,000+ ISP customers globally.
|
||||
|
||||
### Pricing Model
|
||||
Per-subscriber, subscription-based (prices in USD):
|
||||
- Scales with active subscriber count (intervals of 100, entry level up to 400 subscribers)
|
||||
- Includes license + maintenance fee (hosting, support, backups)
|
||||
- Cloud or on-premise deployment options
|
||||
- Add-ons: ACS (TR-069) from $100/month, Whalebone security from $80/month
|
||||
- All features included in base license (no feature tiers)
|
||||
|
||||
### Key Strengths
|
||||
- **All-in-one ISP platform**: Billing, RADIUS, TR-069 (ACS), IPAM, CRM, ticketing, field service, inventory — comprehensive.
|
||||
- **Networking features**: Built-in RADIUS server, bandwidth management, QoE, IPAM, network monitoring, config backups, change control.
|
||||
- **Self-hosted option**: Unlike Sonar (cloud-only), Splynx can be self-hosted. Important for operators in regions with data sovereignty requirements.
|
||||
- **Customer portal & mobile app**: White-labeled mobile app for subscriber self-service.
|
||||
- **Field tech mobile app**: Dedicated app for technicians with scheduling, work orders, maps.
|
||||
- **Global reach**: 1,000+ ISPs worldwide. Strong in developing markets where cost matters.
|
||||
- **Fast support**: 85% of tickets resolved in under 60 minutes.
|
||||
- **TR-069 / ACS**: CPE device provisioning and management. Increasingly important for fiber ISPs.
|
||||
|
||||
### Key Weaknesses / Gaps
|
||||
- **Monitoring is secondary**: Network monitoring exists but is not the primary focus. Basic compared to dedicated NMS tools.
|
||||
- **Not deep SNMP**: Doesn't match LibreNMS or TowerOps for SNMP sensor discovery, vendor-specific MIBs, or environmental monitoring.
|
||||
- **Smaller than Sonar in US market**: Sonar dominates the US ISP billing space. Splynx is stronger internationally.
|
||||
- **No topology mapping**: No automated network topology discovery or visualization.
|
||||
- **No QoE**: No subscriber quality-of-experience monitoring or traffic shaping.
|
||||
- **UI less polished than Sonar**: Functional but not as modern as Sonar's interface.
|
||||
|
||||
### What TowerOps Can Learn / Differentiate On
|
||||
- **Learn**: Splynx's all-in-one approach (billing + networking + CRM) appeals to small ISPs who don't want 5 separate tools. TowerOps should integrate deeply enough that it feels like part of the stack.
|
||||
- **Learn**: Self-hosted option matters for international markets. TowerOps's agent architecture and potential self-hosted option could appeal to the same market.
|
||||
- **Differentiate**: TowerOps provides the deep monitoring that Splynx's built-in monitoring can't match. Position as the "upgrade your monitoring" path for Splynx users.
|
||||
- **Integration opportunity**: TowerOps's existing Splynx integration should pull subscriber data, service plans, and customer context to enrich network monitoring.
|
||||
|
||||
---
|
||||
|
||||
## 8. Oxidized / RANCID
|
||||
|
||||
**Website**: github.com/ytti/oxidized (Oxidized), shrubbery.net/rancid (RANCID)
|
||||
**License**: Open source
|
||||
|
||||
### Target Market
|
||||
Any network operator needing config backup. ISPs, enterprises, MSPs. Technical users comfortable with CLI tools.
|
||||
|
||||
### Pricing Model
|
||||
**Free** (open source). Self-hosted only.
|
||||
|
||||
### Key Strengths
|
||||
- **Oxidized**: Modern RANCID replacement. Ruby-based. Supports 130+ OS types. Git-backed config storage with blame/diff. REST API. Hooks for notifications (Slack, email). Syslog integration to trigger config fetch on change events.
|
||||
- **RANCID**: Battle-tested (20+ years). CVS/SVN-backed. Widely deployed. Simple and reliable.
|
||||
- **Config versioning**: Git integration means full history, diff, blame. Know who changed what and when.
|
||||
- **Lightweight**: Single-purpose tools that do one thing well.
|
||||
- **Event-driven**: Oxidized can react to syslog events (config change detected → immediately fetch new config).
|
||||
|
||||
### Key Weaknesses / Gaps
|
||||
- **Config backup only**: No monitoring, no alerting, no topology, no dashboards. Single-purpose.
|
||||
- **Oxidized maintenance concerns**: GitHub repo has a "Maintainer Wanted" notice. Bus factor risk.
|
||||
- **No web UI**: CLI/API only. No visual diff viewer out of the box (though tools like oxidized-web exist).
|
||||
- **Manual setup**: Requires configuration, cron jobs, and integration work.
|
||||
- **No compliance features**: No automated compliance checking against config templates.
|
||||
- **RANCID is legacy**: CVS-based, Perl scripts, showing its age. Most operators have migrated to Oxidized.
|
||||
|
||||
### What TowerOps Can Learn / Differentiate On
|
||||
- **Learn**: Config backup with git-backed versioning and diff is table stakes for ISPs. TowerOps already has config change tracking for MikroTik — expand this to all supported vendors.
|
||||
- **Learn**: Event-driven config fetch (syslog trigger → immediate backup) is smart. TowerOps agents could do this natively.
|
||||
- **Learn**: "Git blame" for config changes (who changed what line) is incredibly valuable for troubleshooting and compliance.
|
||||
- **Differentiate**: TowerOps can build config backup into the platform, eliminating a separate Oxidized install. One less tool to maintain.
|
||||
- **Differentiate**: Config compliance checking (compare running config against a template, flag drift) — something neither Oxidized nor RANCID does.
|
||||
|
||||
---
|
||||
|
||||
## 9. Grafana + Prometheus
|
||||
|
||||
**Website**: grafana.com, prometheus.io
|
||||
**License**: Open source (Grafana AGPL, Prometheus Apache 2.0)
|
||||
|
||||
### Target Market
|
||||
DevOps teams, SREs, cloud-native organizations, and technically sophisticated ISPs who want full control and customization.
|
||||
|
||||
### Pricing Model
|
||||
**Free** (self-hosted open source). Grafana Cloud offers managed hosting:
|
||||
- Free tier: 10k metrics, 50GB logs
|
||||
- Pro: ~$29/month per active series
|
||||
- Enterprise: Custom pricing
|
||||
- Most ISPs self-host to avoid costs.
|
||||
|
||||
### Key Strengths
|
||||
- **Visualization**: Grafana is the gold standard for dashboards. Flexible, beautiful, and infinitely customizable.
|
||||
- **Prometheus data model**: Labels-based metrics with powerful PromQL query language. Excellent for high-cardinality data.
|
||||
- **Ecosystem**: Thousands of exporters (SNMP Exporter, Node Exporter, Blackbox Exporter, etc.). Alertmanager for alerting. Loki for logs.
|
||||
- **Cloud-native**: Designed for containerized, microservices architectures. Kubernetes-native.
|
||||
- **Scalability**: Horizontal scaling with Thanos, Cortex, or Mimir for long-term storage.
|
||||
- **Community**: Massive community. Extensive documentation. Frequent releases.
|
||||
- **Flexibility**: Can monitor anything. Network, servers, applications, custom metrics.
|
||||
|
||||
### Key Weaknesses / Gaps
|
||||
- **DIY assembly required**: Not a product — it's a toolkit. ISPs must assemble SNMP Exporter + Prometheus + Alertmanager + Grafana + dashboards. Significant engineering effort.
|
||||
- **No auto-discovery**: Prometheus uses static config or service discovery (Consul, DNS, file). No SNMP/LLDP auto-discovery of network devices.
|
||||
- **SNMP support is bolted on**: snmp_exporter works but requires SNMP MIB compilation into a generator config. Painful compared to LibreNMS's plug-and-play.
|
||||
- **No network awareness**: No topology mapping, no device inventory, no interface table management. You're staring at metrics, not a network.
|
||||
- **Alert fatigue**: Alertmanager is powerful but complex to configure. No built-in alert correlation or deduplication intelligence.
|
||||
- **No subscriber awareness**: Purely infrastructure metrics. No concept of customers or service impact.
|
||||
- **No config management**: No config backup or change tracking.
|
||||
- **Maintenance burden**: Self-hosted Prometheus requires capacity planning, storage management, retention tuning. It's a full-time job at scale.
|
||||
- **Dashboard sprawl**: Without discipline, you end up with 200 dashboards and no one knows which to look at.
|
||||
|
||||
### When ISPs Choose Grafana+Prometheus Over a Product
|
||||
- They have DevOps/SRE staff who already know the stack
|
||||
- They want custom dashboards that no product can provide
|
||||
- They're already using it for server/application monitoring and want to extend to network
|
||||
- They're philosophically opposed to vendor lock-in
|
||||
- They have unique metrics or integrations that products don't support
|
||||
- Cost-sensitive and willing to trade engineering time for license fees
|
||||
|
||||
### What TowerOps Can Learn / Differentiate On
|
||||
- **Learn**: Grafana's dashboard flexibility is unmatched. TowerOps should offer embeddable Grafana-style dashboards or a dashboard builder that approaches this flexibility.
|
||||
- **Learn**: The Prometheus ecosystem's breadth is incredible. TowerOps should be able to export metrics in Prometheus format for users who want Grafana alongside TowerOps.
|
||||
- **Differentiate**: TowerOps is a product, not a toolkit. Auto-discovery, device inventory, subscriber awareness, alerting, topology — all built-in. No assembly required.
|
||||
- **Differentiate**: SNMP-first design. TowerOps's 786 OS profiles and vendor-specific MIB support is years of work that Prometheus users would have to replicate.
|
||||
- **Differentiate**: ISP domain knowledge. Grafana doesn't know what a tower site is or what subscriber churn means. TowerOps does.
|
||||
- **Coexistence strategy**: Offer Prometheus-compatible metric export. Let users who love Grafana use it alongside TowerOps. Don't force them to choose.
|
||||
|
||||
---
|
||||
|
||||
## Competitive Matrix
|
||||
|
||||
| Feature | TowerOps | LibreNMS | PRTG | Auvik | The Dude | Preseem | Sonar | Splynx | Oxidized | Grafana+Prom |
|
||||
|---------|----------|----------|------|-------|----------|---------|-------|--------|----------|-------------|
|
||||
| **SNMP Monitoring** | ✅ Deep | ✅ Deep | ✅ Deep | ⚠️ Basic | ⚠️ Basic | ❌ | ⚠️ Basic | ⚠️ Basic | ❌ | ⚠️ Bolt-on |
|
||||
| **Device Auto-Discovery** | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||
| **Topology Mapping** | ✅ LLDP | ✅ | ❌ | ✅ Best | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||
| **Multi-Tenancy** | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | ✅ | ✅ | ❌ | ❌ |
|
||||
| **Subscriber Awareness** | ✅ | ❌ | ❌ | ❌ | ❌ | ✅ Best | ✅ | ✅ | ❌ | ❌ |
|
||||
| **QoE Monitoring** | 🔜 | ❌ | ❌ | ❌ | ❌ | ✅ Best | ❌ | ⚠️ | ❌ | ❌ |
|
||||
| **Config Backup** | ✅ MikroTik | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | ✅ | ✅ Best | ❌ |
|
||||
| **Billing Integration** | ✅ | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ Native | ✅ Native | ❌ | ❌ |
|
||||
| **Alerting/On-Call** | ✅ | ✅ | ✅ | ✅ | ⚠️ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| **Web UI** | ✅ Modern | ⚠️ Dated | ✅ | ✅ | ❌ Desktop | ✅ | ✅ | ✅ | ❌ CLI | ✅ |
|
||||
| **Mobile App** | ✅ | ⚠️ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
| **API** | ✅ REST+GraphQL | ✅ REST | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ REST | ✅ |
|
||||
| **Agent/Distributed** | ✅ | ⚠️ Pollers | ⚠️ Probes | ✅ Collector | ❌ | ✅ Appliance | ❌ | ❌ | ❌ | ✅ Exporters |
|
||||
| **Open Source** | ❌ | ✅ | ❌ | ❌ | ✅ Free | ❌ | ❌ | ❌ | ✅ | ✅ |
|
||||
| **ISP/WISP Focus** | ✅ | ⚠️ | ❌ | ❌ | ⚠️ MikroTik | ✅ | ✅ | ✅ | ❌ | ❌ |
|
||||
|
||||
---
|
||||
|
||||
## TowerOps Positioning
|
||||
|
||||
### Where TowerOps Fits
|
||||
|
||||
TowerOps occupies a unique position in the landscape: **the only purpose-built network monitoring platform designed specifically for ISPs and WISPs, with modern architecture and subscriber awareness.**
|
||||
|
||||
The current landscape forces ISPs to choose between:
|
||||
1. **Generic monitoring tools** (LibreNMS, PRTG, Grafana) that don't understand ISP operations
|
||||
2. **ISP billing platforms** (Sonar, Splynx) with rudimentary monitoring bolted on
|
||||
3. **QoE-only tools** (Preseem) that complement but don't replace an NMS
|
||||
4. **Legacy WISP tools** (The Dude) that can't scale
|
||||
|
||||
TowerOps eliminates the "duct-tape stack" of LibreNMS + Oxidized + Preseem + Grafana that most ISPs cobble together.
|
||||
|
||||
### Target Customer Profile
|
||||
|
||||
**Primary**: Regional ISPs and WISPs with 500-50,000 subscribers
|
||||
- Running mixed equipment: MikroTik, Ubiquiti, Cambium, Cisco, Juniper
|
||||
- Currently using LibreNMS or The Dude and outgrowing them
|
||||
- Using Sonar or Splynx for billing and wanting monitoring that integrates
|
||||
- Frustrated with managing 3-5 separate tools for network operations
|
||||
- Small NOC team (1-5 people) that needs efficiency, not complexity
|
||||
|
||||
**Secondary**: MSPs managing ISP/WISP clients
|
||||
- Need multi-tenant monitoring with ISP-specific features
|
||||
- Currently using Auvik but hitting its limits for carrier networks
|
||||
|
||||
### Unique Value Propositions
|
||||
|
||||
1. **ISP-native, not ISP-adapted**: Built from day one for ISP/WISP operations. Not a generic monitoring tool with ISP features bolted on. Sites, towers, sectors, subscribers — first-class concepts.
|
||||
|
||||
2. **Unified platform**: SNMP monitoring + topology mapping + config management + alerting + on-call + subscriber awareness in one product. Replaces LibreNMS + Oxidized + PagerDuty + custom Grafana dashboards.
|
||||
|
||||
3. **Modern architecture**: Phoenix LiveView for real-time UI, TimescaleDB for time-series (SQL-queryable, not RRDtool), distributed agent architecture for sites behind NAT/firewalls.
|
||||
|
||||
4. **Subscriber-aware monitoring**: Pull subscriber data from Sonar/Splynx/VISP/Gaiia and correlate with network health. "These 47 subscribers on Tower-12 Sector-3 are experiencing degraded service" — not just "interface utilization is high."
|
||||
|
||||
5. **Agent-based distributed monitoring**: Lightweight agents deployed at tower sites, headends, or POPs. Monitor behind NAT, through firewalls, with local data collection and central aggregation. No VPN tunnels or direct SNMP access needed.
|
||||
|
||||
6. **Deep SNMP + fast polling**: 786 OS profiles with vendor-specific sensor discovery. 60-second default polling (vs LibreNMS's 5-minute). Environmental monitoring (temperature, power, optical levels) that Auvik and billing platforms can't do.
|
||||
|
||||
7. **Billing platform integrations**: First-class integrations with Sonar, Splynx, VISP, and Gaiia. Not just API calls — bidirectional sync that enriches both platforms.
|
||||
|
||||
### Features Competitors Lack
|
||||
|
||||
| Feature | Why It Matters | Who's Missing It |
|
||||
|---------|---------------|-----------------|
|
||||
| **Site/tower hierarchy** | ISPs think in sites, not flat device lists | LibreNMS, PRTG, Auvik, Grafana |
|
||||
| **Agent-based polling behind NAT** | Tower sites often lack direct connectivity | LibreNMS, PRTG, The Dude |
|
||||
| **Subscriber ↔ device correlation** | "Which customers are affected by this outage?" | LibreNMS, PRTG, Auvik, Grafana |
|
||||
| **Built-in on-call/escalation** | No separate PagerDuty/Opsgenie needed | LibreNMS, Preseem, Sonar, Splynx |
|
||||
| **Config backup + deep monitoring** | Eliminates Oxidized as separate tool | LibreNMS, PRTG, Preseem |
|
||||
| **Multi-tenant with ISP features** | MSPs managing WISPs need both | LibreNMS, The Dude, Preseem |
|
||||
| **60-second polling** | Faster fault detection, better capacity data | LibreNMS (5min default) |
|
||||
| **TimescaleDB time-series** | SQL-queryable historical data, continuous aggregates | LibreNMS (RRDtool), The Dude (none) |
|
||||
| **Capacity planning** | Interface utilization trends and forecasting | The Dude, Preseem, billing platforms |
|
||||
|
||||
### Competitive Positioning Summary
|
||||
|
||||
```
|
||||
ISP/WISP Specific
|
||||
↑
|
||||
|
|
||||
Preseem | TowerOps ★
|
||||
(QoE only) | (Full NMS + ISP features)
|
||||
|
|
||||
Sonar/Splynx |
|
||||
(Billing + basic |
|
||||
monitoring) |
|
||||
|
|
||||
Generic ←──────────────┼──────────────→ Deep Monitoring
|
||||
|
|
||||
The Dude | LibreNMS
|
||||
(Legacy) | (Open source NMS)
|
||||
|
|
||||
Auvik | PRTG
|
||||
(MSP focus) | (Enterprise IT)
|
||||
|
|
||||
Grafana+Prometheus
|
||||
(DIY toolkit)
|
||||
|
|
||||
Generic IT
|
||||
```
|
||||
|
||||
### Go-To-Market Priorities
|
||||
|
||||
1. **Migrate The Dude users**: Largest addressable audience of frustrated WISP operators. Low switching cost (The Dude is free but limited). Marketing message: "You've outgrown The Dude."
|
||||
|
||||
2. **Replace LibreNMS for ISPs**: Technical operators who've invested in LibreNMS but want modern UX, subscriber awareness, and less maintenance. Message: "Everything LibreNMS does, plus everything it doesn't."
|
||||
|
||||
3. **Complement Sonar/Splynx**: Don't compete with billing platforms. Be the monitoring layer they recommend. Message: "The monitoring platform that actually talks to your billing system."
|
||||
|
||||
4. **Displace the duct-tape stack**: ISPs running LibreNMS + Oxidized + Grafana + PagerDuty + Preseem. Message: "One platform instead of five."
|
||||
227
docs/CONVENTIONS.md
Normal file
227
docs/CONVENTIONS.md
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
# TowerOps Coding Conventions
|
||||
|
||||
Project-specific patterns and conventions for the TowerOps Phoenix LiveView application.
|
||||
|
||||
## File Organization
|
||||
|
||||
### LiveView Modules
|
||||
|
||||
LiveView pages live under `lib/towerops_web/live/` organized by domain:
|
||||
|
||||
```
|
||||
lib/towerops_web/live/
|
||||
schedule_live/
|
||||
index.ex # List page module
|
||||
index.html.heex # List page template
|
||||
form.ex # Create/edit form module
|
||||
form.html.heex # Form template
|
||||
show.ex # Detail page module
|
||||
show.html.heex # Detail page template
|
||||
```
|
||||
|
||||
- **`.ex`** files contain the LiveView module (mount, handle_params, handle_event, etc.)
|
||||
- **`.html.heex`** files contain the corresponding HEEx template
|
||||
- Module naming: `ToweropsWeb.ScheduleLive.Index`, `ToweropsWeb.ScheduleLive.Form`, etc.
|
||||
|
||||
### Test Files
|
||||
|
||||
Tests mirror the `lib/` structure under `test/`:
|
||||
|
||||
```
|
||||
test/
|
||||
towerops/ # Context/business logic tests
|
||||
towerops_web/ # Web layer tests
|
||||
snmpkit/ # SNMP toolkit tests
|
||||
integration/ # Integration tests (SNMP, discovery)
|
||||
support/ # Test helpers and fixtures
|
||||
fixtures/ # Factory/fixture modules
|
||||
mix/ # Mix task tests
|
||||
test_helper.exs
|
||||
```
|
||||
|
||||
## Navigation & Page Structure
|
||||
|
||||
### `active_page` Assign
|
||||
|
||||
Every LiveView sets an `active_page` assign in `mount/3` to highlight the correct nav item:
|
||||
|
||||
```elixir
|
||||
def mount(_params, _session, socket) do
|
||||
{:ok, assign(socket, :active_page, "schedules")}
|
||||
end
|
||||
```
|
||||
|
||||
Valid values: `"dashboard"`, `"devices"`, `"sites"`, `"network-map"`, `"alerts"`, `"maintenance"`, `"schedules"`, `"insights"`, `"trace"`, `"activity"`, `"settings"`, `"help"`
|
||||
|
||||
The layout component (`Layouts.authenticated`) checks `@active_page` to apply the `active` class to nav links.
|
||||
|
||||
### Breadcrumbs
|
||||
|
||||
Use the `ToweropsWeb.Components.Breadcrumbs` component (`.breadcrumb`) for hierarchical navigation:
|
||||
|
||||
```heex
|
||||
<.breadcrumb items={[
|
||||
%{label: "Dashboard", navigate: ~p"/dashboard"},
|
||||
%{label: "Devices", navigate: ~p"/devices"},
|
||||
%{label: "My Device"}
|
||||
]} />
|
||||
```
|
||||
|
||||
- Each item is a map with `:label` (required) and `:navigate` (optional)
|
||||
- Omit `:navigate` on the last item (current page)
|
||||
- The first item automatically gets a home icon
|
||||
|
||||
## Forms
|
||||
|
||||
### Standard Form Pattern
|
||||
|
||||
Forms use `phx-change="validate"` for real-time validation and `phx-submit="save"` for submission:
|
||||
|
||||
```heex
|
||||
<.simple_form for={@form} phx-change="validate" phx-submit="save">
|
||||
<.input field={@form[:name]} label={t("Name")} />
|
||||
<:actions>
|
||||
<.button>{t("Save")}</.button>
|
||||
</:actions>
|
||||
</.simple_form>
|
||||
```
|
||||
|
||||
The LiveView handles both events:
|
||||
|
||||
```elixir
|
||||
def handle_event("validate", %{"schedule" => params}, socket) do
|
||||
changeset = Schedule.changeset(socket.assigns.schedule, params)
|
||||
{:noreply, assign(socket, :form, to_form(changeset, action: :validate))}
|
||||
end
|
||||
|
||||
def handle_event("save", %{"schedule" => params}, socket) do
|
||||
# ... create or update logic
|
||||
end
|
||||
```
|
||||
|
||||
## Flash Messages
|
||||
|
||||
Use `put_flash/3` with `:info` for success and `:error` for failures. Always wrap messages in `t()`:
|
||||
|
||||
```elixir
|
||||
socket
|
||||
|> put_flash(:info, t("Schedule created successfully."))
|
||||
|> push_navigate(to: ~p"/schedules")
|
||||
```
|
||||
|
||||
```elixir
|
||||
socket
|
||||
|> put_flash(:error, t("Could not save schedule."))
|
||||
```
|
||||
|
||||
## Translations (i18n)
|
||||
|
||||
All user-facing strings must use the `t()` macro (or domain-specific variants). Defined in `ToweropsWeb.GettextHelpers`:
|
||||
|
||||
| Macro | Domain | Example |
|
||||
|-------|--------|---------|
|
||||
| `t/1` | Common UI | `t("Save")`, `t("Cancel")` |
|
||||
| `t_auth/1` | Authentication | `t_auth("Log in")` |
|
||||
| `t_equipment/1` | Equipment | `t_equipment("Add Device")` |
|
||||
| `t_admin/1` | Admin | `t_admin("Impersonate User")` |
|
||||
| `t_email/1` | Emails | `t_email("Welcome!")` |
|
||||
|
||||
Interpolation: `t("Welcome, %{name}!", name: user.name)`
|
||||
|
||||
Error messages use the standard `gettext/1` function.
|
||||
|
||||
## `current_scope` Assign
|
||||
|
||||
The `@current_scope` assign is available in all authenticated views. It's a struct containing:
|
||||
|
||||
- **`.user`** — the current `User` struct (email, id, `is_superuser`, etc.)
|
||||
- **`.organization`** — the current `Organization` (name, slug, settings)
|
||||
- **`.timezone`** — the user's timezone string (falls back to `"UTC"`)
|
||||
- **`.impersonating?`** — boolean, true when a superuser is impersonating another user
|
||||
|
||||
Access in templates:
|
||||
|
||||
```heex
|
||||
<%= @current_scope.user.email %>
|
||||
<%= @current_scope.organization.name %>
|
||||
```
|
||||
|
||||
Access in LiveView:
|
||||
|
||||
```elixir
|
||||
organization = socket.assigns.current_scope.organization
|
||||
user = socket.assigns.current_scope.user
|
||||
```
|
||||
|
||||
The layout displays an impersonation banner when `@current_scope.impersonating?` is true.
|
||||
|
||||
## CSS & Styling
|
||||
|
||||
### Tailwind CSS
|
||||
|
||||
All styling uses Tailwind utility classes. The project uses **DaisyUI** as a component library on top of Tailwind.
|
||||
|
||||
### Dark Mode
|
||||
|
||||
Dark mode is supported via the `dark:` prefix:
|
||||
|
||||
```heex
|
||||
<div class="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
|
||||
```
|
||||
|
||||
Always provide dark mode variants for backgrounds, text colors, and borders.
|
||||
|
||||
### Icons
|
||||
|
||||
Icons use Heroicons via the `.icon` component with `hero-*` names:
|
||||
|
||||
```heex
|
||||
<.icon name="hero-plus" />
|
||||
<.icon name="hero-pencil-square" class="size-5" />
|
||||
<.icon name="hero-check" class="size-4 text-green-300" />
|
||||
<.icon name="hero-home-mini" class="h-4 w-4" />
|
||||
```
|
||||
|
||||
Use the mini variant (`hero-*-mini`) for small inline icons and the default (outline) for larger UI elements.
|
||||
|
||||
## LiveView Conventions
|
||||
|
||||
### Module Boilerplate
|
||||
|
||||
```elixir
|
||||
defmodule ToweropsWeb.ScheduleLive.Index do
|
||||
@moduledoc false
|
||||
use ToweropsWeb, :live_view
|
||||
|
||||
alias Towerops.OnCall
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
{:ok, assign(socket, :active_page, "schedules")}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(params, _url, socket) do
|
||||
# ...
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
- Always use `@impl true` for callback annotations
|
||||
- Set `active_page` in `mount/3`
|
||||
- Use `handle_params/3` for loading data based on URL params / live_action
|
||||
|
||||
### Scoped Routes
|
||||
|
||||
Routes are scoped under the organization slug:
|
||||
|
||||
```
|
||||
/orgs/:org_slug/devices
|
||||
/orgs/:org_slug/schedules/:id
|
||||
```
|
||||
|
||||
Use `~p` sigil for path generation with `@current_scope`:
|
||||
|
||||
```elixir
|
||||
~p"/orgs/#{@current_scope.organization.slug}/devices"
|
||||
```
|
||||
272
docs/SECURITY_AUDIT_2026_03_14.md
Normal file
272
docs/SECURITY_AUDIT_2026_03_14.md
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
# Security Audit Report — TowerOps Web Application
|
||||
|
||||
**Date:** 2026-03-14
|
||||
**Scope:** Authentication, authorization, and session security
|
||||
**Auditor:** Automated code review (Skippy)
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
The TowerOps application demonstrates a well-structured security posture overall. Authentication uses Argon2 password hashing, TOTP-based MFA, session token management with expiry, and CSRF protection. Organization-scoped data access is consistently enforced through `ScopedResource` and `AccessControl` helpers.
|
||||
|
||||
The findings below are verified issues found in the source code.
|
||||
|
||||
---
|
||||
|
||||
## Findings
|
||||
|
||||
### 1. API Device Create Allows `organization_id` Override
|
||||
|
||||
**Severity:** HIGH
|
||||
**File:** `lib/towerops_web/controllers/api/v1/devices_controller.ex`, lines 245–249
|
||||
**Issue:** The `default_organization_id/2` function only sets `organization_id` when the param is `nil` or empty. If a client provides a *different* `organization_id` in the request body, it is passed through to `Devices.create_device/2` unchanged. This allows an API token holder for Org A to create devices in Org B.
|
||||
|
||||
```elixir
|
||||
defp default_organization_id(device_params, organization_id) do
|
||||
case Map.get(device_params, "organization_id") do
|
||||
nil -> Map.put(device_params, "organization_id", organization_id)
|
||||
"" -> Map.put(device_params, "organization_id", organization_id)
|
||||
_provided_id -> device_params # ← attacker-controlled org_id accepted
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
**Suggested Fix:** Always force the authenticated organization_id:
|
||||
```elixir
|
||||
defp default_organization_id(device_params, organization_id) do
|
||||
Map.put(device_params, "organization_id", organization_id)
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. Brute Force Protection Disabled in Production
|
||||
|
||||
**Severity:** HIGH
|
||||
**File:** `lib/towerops_web/plugs/brute_force_protection.ex`, line 38
|
||||
**Issue:** The plug's `call/2` function unconditionally returns `conn` without any processing. The comment says "TEMPORARILY DISABLED." While Cloudflare provides edge-level protection, if Cloudflare is bypassed or misconfigured, the application has no server-side brute force protection beyond rate limiting.
|
||||
|
||||
```elixir
|
||||
def call(conn, _opts) do
|
||||
# TEMPORARILY DISABLED - brute force protection bypassed
|
||||
conn
|
||||
end
|
||||
```
|
||||
|
||||
**Suggested Fix:** Re-enable the brute force protection or remove the plug entirely to avoid a false sense of security. At minimum, document the Cloudflare dependency as a security requirement.
|
||||
|
||||
---
|
||||
|
||||
### 3. Admin API MIB/GeoIP Routes Use Application-Level Superuser Check, Not Pipeline
|
||||
|
||||
**Severity:** MEDIUM
|
||||
**File:** `lib/towerops_web/router.ex`, lines 168–176
|
||||
**Files affected:** `lib/towerops_web/controllers/api/v1/mib_controller.ex`, `lib/towerops_web/controllers/api/v1/geoip_controller.ex`
|
||||
**Issue:** The admin API scope (`/admin/api`) uses only the `:api_v1` pipeline (Bearer token auth) without the `:require_superuser` pipeline plug. Superuser checks are done inside each controller action via `require_superuser/1`. This is defense-in-depth weakness — if a developer adds a new action and forgets the check, it would be accessible to any API token holder.
|
||||
|
||||
```elixir
|
||||
# Router - no superuser pipeline plug
|
||||
scope "/admin/api", V1 do
|
||||
pipe_through :api_v1
|
||||
# ...
|
||||
end
|
||||
```
|
||||
|
||||
The controllers do implement their own `require_superuser` check, which works, but the `halt()` return from within `with :ok <- require_superuser(conn)` is not propagated correctly because `halt()` is called inside the private function but `with` still proceeds. Let me verify...
|
||||
|
||||
Actually, reviewing more carefully: the `require_superuser` function calls `halt()` on the conn and returns the halted conn. The `with :ok <-` pattern means if it doesn't return `:ok`, the `with` block falls through. But the `require_superuser/1` function calls `json` + `halt` and returns the conn, not `:ok`. So the `with` clause correctly handles this — if `require_superuser` returns the halted conn instead of `:ok`, the `with` doesn't match and the function returns without executing the body. **However**, the halted conn is not actually returned to the caller — it's discarded. The controller action returns normally without the halted conn.
|
||||
|
||||
**Wait** — re-reading the code: the `with :ok <- require_superuser(conn)` pattern means if `require_superuser` returns anything other than `:ok`, the `with` block uses its else clause or falls through. Since there's no explicit else, the non-matching return value (the halted conn) is returned as the function's return value. Phoenix will see that the conn is halted and stop processing. This is correct.
|
||||
|
||||
**Revised assessment:** The controller-level checks work correctly. The concern remains that this is a defense-in-depth gap — a pipeline-level check would be more robust.
|
||||
|
||||
**Suggested Fix:** Add a `require_superuser_api` plug to the admin API pipeline:
|
||||
```elixir
|
||||
scope "/admin/api", V1 do
|
||||
pipe_through [:api_v1, :require_superuser_api]
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. Honeybadger API Key Hardcoded in Source
|
||||
|
||||
**Severity:** MEDIUM
|
||||
**File:** `config/config.exs`, line 29
|
||||
**Issue:** The Honeybadger API key (`hbp_xe5xMnpLZ2XJsXQJujoEkgCmiqCfwa0uYA3Y`) is hardcoded in version-controlled source. While Honeybadger is an error-tracking service (not end-user-facing), a leaked API key could allow attackers to send fake error reports, pollute error data, or potentially access error data containing sensitive information (stack traces, request params).
|
||||
|
||||
```elixir
|
||||
config :honeybadger,
|
||||
api_key: "hbp_xe5xMnpLZ2XJsXQJujoEkgCmiqCfwa0uYA3Y",
|
||||
```
|
||||
|
||||
**Suggested Fix:** Move to an environment variable:
|
||||
```elixir
|
||||
config :honeybadger,
|
||||
api_key: System.get_env("HONEYBADGER_API_KEY"),
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. Org Settings LiveView Missing Role-Based Authorization on Sensitive Operations
|
||||
|
||||
**Severity:** MEDIUM
|
||||
**File:** `lib/towerops_web/live/org/settings_live.ex`, lines 103–225
|
||||
**Issue:** The `handle_event` callbacks for `"save"` (update org settings), `"send_invitation"`, `"remove_member"`, `"change_role"`, `"apply_snmp_to_all"`, and `"apply_agent_to_all"` do not check the current user's role/permissions. Any authenticated user who is a member of the organization can access the settings page (it's in the `require_authenticated_user_and_organization` live_session) and perform these operations, regardless of whether they are an owner, admin, or technician.
|
||||
|
||||
The `Permissions` module exists but is not imported or used in this LiveView. Only the backup delete event in `DeviceLive.Show` uses `owner?()`.
|
||||
|
||||
**Suggested Fix:** Add role checks to sensitive operations:
|
||||
```elixir
|
||||
def handle_event("save", %{"organization" => org_params}, socket) do
|
||||
import ToweropsWeb.Permissions
|
||||
unless admin?(socket), do: {:noreply, put_flash(socket, :error, "Permission denied")}
|
||||
# ... existing logic
|
||||
end
|
||||
|
||||
def handle_event("send_invitation", params, socket) do
|
||||
import ToweropsWeb.Permissions
|
||||
unless admin?(socket), do: {:noreply, put_flash(socket, :error, "Permission denied")}
|
||||
# ... existing logic
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6. Session Cookie Missing `secure: true` Flag
|
||||
|
||||
**Severity:** MEDIUM
|
||||
**File:** `lib/towerops_web/endpoint.ex`, lines 9–14
|
||||
**Issue:** The session cookie options don't set `secure: true`. While the comment in `prod.exs` says "SSL/TLS is handled by Cloudflared proxy," the cookie `secure` flag is independent of TLS termination — it tells the browser to only send the cookie over HTTPS connections. Without it, if a user somehow accesses the app over HTTP (e.g., direct IP access bypassing Cloudflare), the session cookie would be transmitted in cleartext.
|
||||
|
||||
```elixir
|
||||
@session_options [
|
||||
store: :cookie,
|
||||
key: "_towerops_key",
|
||||
signing_salt: "hrDZxLhd",
|
||||
encryption_salt: "vK3p8mNx",
|
||||
same_site: "Lax"
|
||||
# missing: secure: true
|
||||
]
|
||||
```
|
||||
|
||||
**Suggested Fix:** Add `secure: true` for production:
|
||||
```elixir
|
||||
@session_options [
|
||||
store: :cookie,
|
||||
key: "_towerops_key",
|
||||
signing_salt: "hrDZxLhd",
|
||||
encryption_salt: "vK3p8mNx",
|
||||
same_site: "Lax",
|
||||
secure: Application.compile_env(:towerops, :env) == :prod
|
||||
]
|
||||
```
|
||||
|
||||
Or configure via `config/prod.exs`.
|
||||
|
||||
---
|
||||
|
||||
### 7. Remember-Me Cookie Missing `http_only: true` Flag
|
||||
|
||||
**Severity:** LOW
|
||||
**File:** `lib/towerops_web/user_auth.ex`, lines 19–23
|
||||
**Issue:** The `@remember_me_options` for the remember-me cookie don't explicitly set `http_only: true`. While Phoenix's `put_resp_cookie` defaults to `http_only: true`, and `secure` defaults to the conn's scheme, it's best practice to be explicit. The `secure` flag is also not set, same concern as finding #6.
|
||||
|
||||
```elixir
|
||||
@remember_me_options [
|
||||
sign: true,
|
||||
max_age: @max_cookie_age_in_days * 24 * 60 * 60,
|
||||
same_site: "Lax"
|
||||
# missing explicit: http_only: true, secure: true
|
||||
]
|
||||
```
|
||||
|
||||
**Suggested Fix:** Be explicit:
|
||||
```elixir
|
||||
@remember_me_options [
|
||||
sign: true,
|
||||
max_age: @max_cookie_age_in_days * 24 * 60 * 60,
|
||||
same_site: "Lax",
|
||||
http_only: true,
|
||||
secure: true
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 8. No `force_ssl` Configured for Production
|
||||
|
||||
**Severity:** LOW
|
||||
**File:** `config/prod.exs`, line 41
|
||||
**Issue:** The comment acknowledges that SSL is handled by Cloudflared proxy, so `force_ssl` is not configured. However, without `force_ssl`, the application won't set HSTS headers. HSTS ensures browsers always use HTTPS even if a user types `http://`. This is a defense-in-depth measure.
|
||||
|
||||
**Suggested Fix:** Enable `force_ssl` with HSTS even behind Cloudflare:
|
||||
```elixir
|
||||
config :towerops, ToweropsWeb.Endpoint,
|
||||
force_ssl: [hsts: true, rewrite_on: [:x_forwarded_proto]]
|
||||
```
|
||||
|
||||
The `rewrite_on: [:x_forwarded_proto]` ensures it works correctly behind a proxy.
|
||||
|
||||
---
|
||||
|
||||
### 9. Alert Show Leaks Existence of Alerts Across Organizations via Error Type
|
||||
|
||||
**Severity:** LOW
|
||||
**File:** `lib/towerops_web/controllers/api/v1/alerts_controller.ex`, lines 32–44
|
||||
**Issue:** The `show` action uses `Alerts.get_alert!(id)` which raises `Ecto.NoResultsError` for non-existent alerts, but returns a 404 with "Alert not found" for alerts that exist in other organizations. An attacker can distinguish between "alert doesn't exist" and "alert exists but belongs to another org" by observing whether the error is caught by the rescue clause or the if-else branch. In practice, the response is the same 404 message, but the code path differs (potential timing difference).
|
||||
|
||||
This is a very minor information leak. The same pattern exists in `acknowledge` and `resolve`.
|
||||
|
||||
**Suggested Fix:** Use a non-raising `get_alert/1` function that returns nil:
|
||||
```elixir
|
||||
def show(conn, %{"id" => id}) do
|
||||
organization_id = conn.assigns.current_organization_id
|
||||
|
||||
case Alerts.get_alert(id) do
|
||||
nil ->
|
||||
conn |> put_status(:not_found) |> json(%{error: "Alert not found"})
|
||||
alert ->
|
||||
if alert.device && alert.device.organization_id == organization_id do
|
||||
json(conn, %{data: format_alert(alert)})
|
||||
else
|
||||
conn |> put_status(:not_found) |> json(%{error: "Alert not found"})
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Positive Observations
|
||||
|
||||
These are **not findings** — they are security strengths worth noting:
|
||||
|
||||
1. **Password hashing:** Uses Argon2 with timing-safe comparison and `no_user_verify()` to prevent user enumeration.
|
||||
2. **TOTP implementation:** Properly separates TOTP from recovery codes for sudo mode. Recovery codes cannot bypass sudo verification.
|
||||
3. **Session management:** Tokens expire after 14 days, sessions are reissued after 7 days, and there's a 30-minute inactivity timeout.
|
||||
4. **CSRF protection:** `protect_from_forgery` plug in browser pipeline, session renewal on login to prevent fixation.
|
||||
5. **Organization scoping:** `ScopedResource` pattern consistently enforces org-level isolation in API controllers.
|
||||
6. **LiveView auth:** All live_sessions use `on_mount` hooks for authentication/authorization checks.
|
||||
7. **Impersonation audit trail:** Impersonation start/stop creates audit log entries with IP addresses.
|
||||
8. **API token hashing:** Tokens are SHA-256 hashed before storage; raw tokens shown only once.
|
||||
9. **Rate limiting:** Auth endpoints limited to 10 req/min, API to 1000 req/min per IP.
|
||||
10. **GraphQL hardening:** Introspection disabled in production, complexity and depth limits enforced.
|
||||
11. **Webhook signature verification:** Uses `Plug.Crypto.secure_compare` for timing-safe comparison.
|
||||
12. **Content Security Policy:** Properly configured with `frame-ancestors 'none'` to prevent clickjacking.
|
||||
13. **MIB upload path traversal protection:** Vendor names and filenames validated; archive contents checked for path traversal.
|
||||
14. **Mobile API authorization:** Each endpoint verifies the user has access to the requested organization.
|
||||
|
||||
---
|
||||
|
||||
## Risk Summary
|
||||
|
||||
| Severity | Count | Key Issues |
|
||||
|----------|-------|-----------|
|
||||
| CRITICAL | 0 | — |
|
||||
| HIGH | 2 | API org_id override, brute force protection disabled |
|
||||
| MEDIUM | 3 | Admin API pipeline gap, hardcoded API key, missing role checks in org settings |
|
||||
| LOW | 3 | Cookie flags, no HSTS, minor info leak |
|
||||
|
||||
**Overall Assessment:** No critical vulnerabilities found. The two HIGH findings should be addressed promptly — the API `organization_id` override is the most actionable. The brute force protection gap is mitigated by Cloudflare but represents a defense-in-depth weakness.
|
||||
404
docs/SECURITY_AUDIT_API.md
Normal file
404
docs/SECURITY_AUDIT_API.md
Normal file
|
|
@ -0,0 +1,404 @@
|
|||
# Security Audit: API & External Integrations
|
||||
|
||||
**Date:** 2026-03-14
|
||||
**Scope:** REST API, GraphQL API, webhook handlers, integration clients, background workers
|
||||
**Auditor:** Automated code review
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Severity | Count |
|
||||
|----------|-------|
|
||||
| High | 2 |
|
||||
| Medium | 5 |
|
||||
| Low | 3 |
|
||||
|
||||
Overall the codebase demonstrates strong security practices: webhook signature verification uses `Plug.Crypto.secure_compare`, API tokens are hashed with SHA-256, credentials are encrypted at rest (Cloak AES-256-GCM), rate limiting is applied to auth and API endpoints, and GraphQL has complexity/depth limits. The findings below are areas for improvement.
|
||||
|
||||
---
|
||||
|
||||
## HIGH Severity
|
||||
|
||||
### H1: SSRF via User-Controlled Integration URLs (Sonar, Splynx)
|
||||
|
||||
**Files:**
|
||||
- `lib/towerops/sonar/client.ex` — lines 141-142
|
||||
- `lib/towerops/splynx/client.ex` — lines 13-15 (via `authenticate/3`), lines 95-96 (via `get/4`)
|
||||
- `lib/towerops/sonar/sync.ex` — line 21
|
||||
- `lib/towerops/splynx/sync.ex` — line 20
|
||||
|
||||
**Description:**
|
||||
Sonar and Splynx integrations accept a user-supplied `instance_url` stored in `integration.credentials["instance_url"]`. This URL is directly concatenated into HTTP requests without any validation:
|
||||
|
||||
```elixir
|
||||
# sonar/client.ex:142
|
||||
url = String.trim_trailing(instance_url, "/") <> "/api/graphql"
|
||||
|
||||
# splynx/client.ex — authenticate/3 and get/4
|
||||
url = String.trim_trailing(instance_url, "/") <> "/api/2.0/admin/auth/tokens"
|
||||
```
|
||||
|
||||
An attacker with org-admin access could set `instance_url` to `http://169.254.169.254` (cloud metadata), `http://localhost:5432`, or any internal service, causing the server to make requests to internal network resources. The integration sync workers then execute these requests periodically in the background.
|
||||
|
||||
**Impact:** Server-Side Request Forgery (SSRF). Could access cloud metadata endpoints (AWS/GCP instance credentials), internal services, or scan internal networks.
|
||||
|
||||
**Suggested Fix:**
|
||||
Add URL validation in `Towerops.Integrations` when creating/updating integrations:
|
||||
|
||||
```elixir
|
||||
defp validate_instance_url(changeset) do
|
||||
validate_change(changeset, :credentials, fn :credentials, creds ->
|
||||
case creds["instance_url"] do
|
||||
nil -> []
|
||||
url ->
|
||||
uri = URI.parse(url)
|
||||
cond do
|
||||
uri.scheme not in ["https", "http"] ->
|
||||
[credentials: "instance_url must use http or https"]
|
||||
is_private_ip?(uri.host) ->
|
||||
[credentials: "instance_url cannot point to a private IP address"]
|
||||
uri.host in ["localhost", "127.0.0.1", "::1", "0.0.0.0"] ->
|
||||
[credentials: "instance_url cannot point to localhost"]
|
||||
true -> []
|
||||
end
|
||||
end
|
||||
end)
|
||||
end
|
||||
```
|
||||
|
||||
Also consider resolving the hostname and checking the IP before making requests.
|
||||
|
||||
---
|
||||
|
||||
### H2: Admin API Routes Missing Rate Limiting
|
||||
|
||||
**File:** `lib/towerops_web/router.ex` — lines 172-180
|
||||
|
||||
**Description:**
|
||||
The admin API scope (`/admin/api`) only pipes through `:api_v1` but does **not** include `:rate_limit_api` or any rate limiting pipeline:
|
||||
|
||||
```elixir
|
||||
scope "/admin/api", V1 do
|
||||
pipe_through :api_v1 # <-- no rate_limit_api
|
||||
|
||||
get "/mibs", MibController, :index
|
||||
post "/mibs", MibController, :upload
|
||||
delete "/mibs/:vendor", MibController, :delete
|
||||
post "/geoip/import", GeoipController, :import_database
|
||||
end
|
||||
```
|
||||
|
||||
While these endpoints do check for superuser status in the controller, a compromised superuser API token could be used to flood the server with MIB uploads or GeoIP imports without any rate limiting, potentially causing DoS.
|
||||
|
||||
**Impact:** Denial of service via resource exhaustion (disk space from MIB uploads, database from GeoIP imports).
|
||||
|
||||
**Suggested Fix:**
|
||||
Add rate limiting to the admin API pipeline:
|
||||
|
||||
```elixir
|
||||
scope "/admin/api", V1 do
|
||||
pipe_through [:api_v1, :rate_limit_api] # or a stricter :admin_rate_limit
|
||||
...
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## MEDIUM Severity
|
||||
|
||||
### M1: GraphQL Complexity/Depth Limits Disabled in Non-Production Environments
|
||||
|
||||
**File:** `lib/towerops_web/graphql/context.ex` — lines 15-20
|
||||
|
||||
**Description:**
|
||||
The GraphQL context plug conditionally applies complexity analysis only in production:
|
||||
|
||||
```elixir
|
||||
opts =
|
||||
if Application.get_env(:towerops, :env) == :prod do
|
||||
[context: context, analyze_complexity: true, max_complexity: 500]
|
||||
else
|
||||
[context: context]
|
||||
end
|
||||
```
|
||||
|
||||
Note: The router (`router.ex:139-141`) does set `analyze_complexity: true, max_complexity: 500, max_depth: 10` at the Absinthe.Plug level. However, the Context plug **overwrites** these options via `Absinthe.Plug.put_options/2`, which replaces the entire options map. In non-production environments, this effectively removes the complexity and depth limits set in the router.
|
||||
|
||||
**Impact:** In staging/dev environments (which may be publicly accessible), deeply nested or highly complex queries could cause CPU/memory exhaustion.
|
||||
|
||||
**Suggested Fix:**
|
||||
Apply complexity limits in all environments:
|
||||
|
||||
```elixir
|
||||
def call(conn, _opts) do
|
||||
context = build_context(conn)
|
||||
Absinthe.Plug.put_options(conn,
|
||||
context: context,
|
||||
analyze_complexity: true,
|
||||
max_complexity: 500
|
||||
)
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### M2: GraphQL Introspection Block is Bypassable
|
||||
|
||||
**File:** `lib/towerops_web/plugs/graphql_introspection.ex` — lines 18-24
|
||||
|
||||
**Description:**
|
||||
The introspection check uses simple string matching:
|
||||
|
||||
```elixir
|
||||
String.contains?(query, "__schema") or String.contains?(query, "__type")
|
||||
```
|
||||
|
||||
This can be bypassed using:
|
||||
1. **GraphQL aliases:** `query { myAlias: __schema { types { name } } }` — Actually still contains `__schema`, so this specific bypass won't work.
|
||||
2. **Batch requests:** If the `query` parameter is sent as part of a JSON array (batch queries), `conn.body_params` won't match the `%{"query" => query}` pattern since it would be a list, not a map.
|
||||
3. **The `operationName` + `variables` pattern:** The check only examines the `query` field, but if the GraphQL request uses persisted queries or extensions, the query text may not be in `body_params["query"]`.
|
||||
|
||||
More critically, the check only runs on `body_params["query"]`. If the query is sent via GET request with query parameters (which Absinthe supports by default), the body params would be empty and the check is bypassed entirely.
|
||||
|
||||
**Impact:** Schema exposure in production, revealing the full API surface to attackers.
|
||||
|
||||
**Suggested Fix:**
|
||||
Also check query params for GET requests, and consider using Absinthe's built-in introspection control:
|
||||
|
||||
```elixir
|
||||
# In schema.ex - more reliable approach
|
||||
def context(ctx) do
|
||||
if Application.get_env(:towerops, :env) == :prod do
|
||||
Map.put(ctx, :introspection, false)
|
||||
else
|
||||
ctx
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
Or disable introspection at the Absinthe level by adding a phase or middleware.
|
||||
|
||||
---
|
||||
|
||||
### M3: Webhook Endpoints (Gaiia, PagerDuty) Lack Dedicated Rate Limiting
|
||||
|
||||
**File:** `lib/towerops_web/router.ex` — lines 163-169
|
||||
|
||||
**Description:**
|
||||
The Gaiia and PagerDuty webhook routes share the general `:rate_limit_api` (1000 req/min per IP):
|
||||
|
||||
```elixir
|
||||
scope "/api/v1/webhooks", V1 do
|
||||
pipe_through [:api, :rate_limit_api]
|
||||
|
||||
post "/gaiia/:organization_id", GaiiaWebhookController, :create
|
||||
post "/pagerduty/:organization_id", PagerdutyWebhookController, :create
|
||||
end
|
||||
```
|
||||
|
||||
These endpoints are unauthenticated (signature verification happens in the controller after the request is processed). At 1000 requests/minute, an attacker can:
|
||||
- Force the server to perform 1000 database lookups per minute (to find the integration and its webhook secret)
|
||||
- Force 1000 HMAC computations per minute
|
||||
- The `organization_id` in the URL can be enumerated to discover valid organizations
|
||||
|
||||
**Impact:** Organization enumeration and moderate DoS potential through resource consumption.
|
||||
|
||||
**Suggested Fix:**
|
||||
Apply stricter rate limiting (e.g., 60 req/min) to webhook endpoints:
|
||||
|
||||
```elixir
|
||||
pipeline :rate_limit_webhook do
|
||||
plug RateLimit, type: :webhook # Add a :webhook type with lower limits
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### M4: Stripe Webhook Raw Body May Be Empty
|
||||
|
||||
**File:** `lib/towerops_web/controllers/api/v1/stripe_webhook_controller.ex` — line 14
|
||||
|
||||
**Description:**
|
||||
The Stripe webhook controller reads the raw body with a fallback to empty string:
|
||||
|
||||
```elixir
|
||||
raw_body = conn.private[:raw_body] || ""
|
||||
```
|
||||
|
||||
If `raw_body` is empty (e.g., due to a middleware issue or the body being consumed), `verify_signature/2` would still attempt verification against an empty payload. The `parse_signature_header/1` would crash on `nil` input if the `stripe-signature` header is also missing (since `List.first([])` returns `nil`, and `String.split(nil, ",")` raises).
|
||||
|
||||
**Impact:** Unhandled exception could leak stack traces in non-production error responses, and the empty body fallback could mask misconfiguration issues.
|
||||
|
||||
**Suggested Fix:**
|
||||
Add explicit nil/empty checks:
|
||||
|
||||
```elixir
|
||||
def create(conn, _params) do
|
||||
with signature when is_binary(signature) <- get_req_header(conn, "stripe-signature") |> List.first(),
|
||||
raw_body when is_binary(raw_body) and raw_body != "" <- conn.private[:raw_body] do
|
||||
# ... process
|
||||
else
|
||||
nil -> conn |> put_status(400) |> json(%{error: "Missing signature header"})
|
||||
"" -> conn |> put_status(400) |> json(%{error: "Empty request body"})
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### M5: `to_atom_keys` in IntegrationsController Could Crash on Invalid Keys
|
||||
|
||||
**File:** `lib/towerops_web/controllers/api/v1/integrations_controller.ex` — lines 89-93
|
||||
|
||||
**Description:**
|
||||
The `to_atom_keys/1` helper converts user-supplied string keys to atoms using `String.to_existing_atom/1`:
|
||||
|
||||
```elixir
|
||||
defp to_atom_keys(map) when is_map(map) do
|
||||
Map.new(map, fn {k, v} -> {String.to_existing_atom(k), v} end)
|
||||
rescue
|
||||
ArgumentError -> map
|
||||
end
|
||||
```
|
||||
|
||||
While `String.to_existing_atom/1` is safe (won't create new atoms), the `rescue` clause catches `ArgumentError` and returns the original map with string keys. This means:
|
||||
1. If ANY key is not an existing atom, ALL keys remain as strings (including valid ones)
|
||||
2. The changeset then receives a mix of expected atom keys and unexpected string keys, potentially silently dropping valid fields
|
||||
|
||||
This is a correctness bug more than a security issue, but it could lead to integration updates silently failing to apply credential changes.
|
||||
|
||||
**Suggested Fix:**
|
||||
Convert keys individually, falling back per-key:
|
||||
|
||||
```elixir
|
||||
defp to_atom_keys(map) when is_map(map) do
|
||||
Map.new(map, fn {k, v} ->
|
||||
key = try do
|
||||
String.to_existing_atom(k)
|
||||
rescue
|
||||
ArgumentError -> k
|
||||
end
|
||||
{key, v}
|
||||
end)
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## LOW Severity
|
||||
|
||||
### L1: API Token Verification Uses Database Lookup Instead of Constant-Time Comparison
|
||||
|
||||
**File:** `lib/towerops/api_tokens.ex` — lines 79-97
|
||||
|
||||
**Description:**
|
||||
API token verification hashes the input token and does a database lookup:
|
||||
|
||||
```elixir
|
||||
def verify_token(raw_token) do
|
||||
token_hash = hash_token(raw_token)
|
||||
case Repo.get_by(ApiToken, token_hash: token_hash) do
|
||||
nil -> {:error, :invalid_token}
|
||||
token -> ...
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
This is the standard and correct approach for hashed tokens — database lookup timing is dominated by query execution time, making timing attacks impractical. The hash comparison happens in PostgreSQL which uses constant-time comparison for indexed lookups.
|
||||
|
||||
However, the SHA-256 hash is not salted. While this doesn't enable practical attacks (tokens are 32 random bytes with ~256 bits of entropy), salted hashing (e.g., bcrypt) would provide defense-in-depth if the database were compromised.
|
||||
|
||||
**Impact:** Theoretical — if the database is breached, unsalted SHA-256 hashes of tokens are exposed. Given the high entropy of the tokens, brute-forcing is infeasible.
|
||||
|
||||
**No immediate action required.** Note for future consideration.
|
||||
|
||||
---
|
||||
|
||||
### L2: Mobile Session Token Lookup Timing
|
||||
|
||||
**File:** `lib/towerops/mobile_sessions.ex` — lines 38-47
|
||||
|
||||
**Description:**
|
||||
Mobile session tokens are hashed and looked up via database query:
|
||||
|
||||
```elixir
|
||||
def get_session_by_token(token) when is_binary(token) do
|
||||
hashed = MobileSession.hash_token(token)
|
||||
MobileSession
|
||||
|> where([s], s.token == ^hashed)
|
||||
|> where([s], s.expires_at > ^now)
|
||||
|> Repo.one()
|
||||
end
|
||||
```
|
||||
|
||||
Same pattern as API tokens — hash-then-lookup. This is secure for the same reasons as L1.
|
||||
|
||||
**No action required.**
|
||||
|
||||
---
|
||||
|
||||
### L3: Gaiia Webhook Signature Mismatch Logging Leaks Partial Signatures
|
||||
|
||||
**File:** `lib/towerops_web/controllers/api/v1/gaiia_webhook_controller.ex` — lines 96-103
|
||||
|
||||
**Description:**
|
||||
On signature mismatch, the controller logs partial signature values:
|
||||
|
||||
```elixir
|
||||
Logger.warning(
|
||||
"Gaiia webhook signature mismatch — " <>
|
||||
"ts=#{timestamp} body_len=#{byte_size(raw_body)} " <>
|
||||
"secret_len=#{byte_size(secret)} " <>
|
||||
"expected=#{String.slice(expected, 0, 12)}… " <>
|
||||
"received=#{String.slice(v1_signature, 0, 12)}…"
|
||||
)
|
||||
```
|
||||
|
||||
This logs:
|
||||
- The length of the webhook secret (`secret_len`)
|
||||
- The first 12 characters of the expected HMAC signature
|
||||
|
||||
While the HMAC output is not the secret itself, logging the secret length reveals information that could help an attacker. The first 12 hex chars of the expected signature provide limited information (6 bytes of a 32-byte HMAC).
|
||||
|
||||
**Impact:** Minor information leakage in logs. Not directly exploitable but violates principle of least information.
|
||||
|
||||
**Suggested Fix:**
|
||||
Remove `secret_len` and `expected` from the log message:
|
||||
|
||||
```elixir
|
||||
Logger.warning(
|
||||
"Gaiia webhook signature mismatch — " <>
|
||||
"ts=#{timestamp} body_len=#{byte_size(raw_body)}"
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Positive Findings (No Issues)
|
||||
|
||||
These areas were reviewed and found to be properly secured:
|
||||
|
||||
1. **Webhook signature verification:** All three webhook handlers (Stripe, Gaiia, PagerDuty) use `Plug.Crypto.secure_compare/2` for constant-time signature comparison. ✅
|
||||
|
||||
2. **Agent webhook auth:** Uses `Plug.Crypto.secure_compare/2` against a shared secret. ✅
|
||||
|
||||
3. **Rate limiting:** Applied to auth endpoints (10/min), API endpoints (1000/min), and admin browser routes (100/min). ✅
|
||||
|
||||
4. **GraphQL depth/complexity limits:** Router configures `max_depth: 10` and `max_complexity: 500` with `analyze_complexity: true`. ✅ (But see M1 about context plug overwriting in non-prod.)
|
||||
|
||||
5. **Credentials at rest:** Integration credentials use `Encrypted.Map` (Cloak AES-256-GCM). ✅
|
||||
|
||||
6. **CSRF protection:** Browser pipeline includes `:protect_from_forgery`. ✅
|
||||
|
||||
7. **Security headers:** CSP, frame-ancestors, and secure browser headers configured. ✅
|
||||
|
||||
8. **MIB upload path traversal protection:** Vendor names validated with regex, extracted archive paths validated against extraction directory. ✅
|
||||
|
||||
9. **Oban worker error handling:** Workers use `max_attempts` configuration and return `:ok` on non-retryable errors, `{:error, reason}` on retryable errors. No infinite retry loops found. ✅
|
||||
|
||||
10. **Organization scoping:** API controllers consistently scope queries to `conn.assigns.current_organization_id`. ✅
|
||||
|
||||
11. **Integration client error handling:** All HTTP clients (VISP, Sonar, Splynx, Gaiia, Preseem) handle error responses gracefully with pattern matching and don't leak credentials in error messages. ✅
|
||||
|
||||
12. **Token generation:** Uses `:crypto.strong_rand_bytes(32)` for API tokens — cryptographically secure. ✅
|
||||
350
docs/SECURITY_AUDIT_INPUT_VALIDATION.md
Normal file
350
docs/SECURITY_AUDIT_INPUT_VALIDATION.md
Normal file
|
|
@ -0,0 +1,350 @@
|
|||
# Security Audit: Input Validation, Injection & Data Handling
|
||||
|
||||
**Date:** 2026-03-14
|
||||
**Auditor:** Automated Security Review (Skippy)
|
||||
**Scope:** `lib/towerops/`, `lib/towerops_web/live/`, `lib/towerops_web/controllers/`, `lib/towerops_web/components/`
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Severity | Count |
|
||||
|----------|-------|
|
||||
| 🔴 High | 3 |
|
||||
| 🟠 Medium | 5 |
|
||||
| 🟡 Low | 4 |
|
||||
|
||||
---
|
||||
|
||||
## 🔴 HIGH Severity
|
||||
|
||||
### H1: IDOR — API Device Creation Allows Cross-Organization Assignment
|
||||
|
||||
**File:** `lib/towerops_web/controllers/api/v1/devices_controller.ex`, lines 245–250
|
||||
**File:** `lib/towerops/devices/device.ex`, lines 133–140 (`organization_id` in cast list)
|
||||
|
||||
**Issue:** The `default_organization_id/2` function only sets `organization_id` when it's `nil` or `""`. If the caller provides a different `organization_id`, it passes through unmodified. Since `organization_id` is in the Device changeset's `cast` list, an authenticated API user can create devices in other organizations by supplying a foreign `organization_id` in the request body.
|
||||
|
||||
```elixir
|
||||
# devices_controller.ex:245-250
|
||||
defp default_organization_id(device_params, organization_id) do
|
||||
case Map.get(device_params, "organization_id") do
|
||||
nil -> Map.put(device_params, "organization_id", organization_id)
|
||||
"" -> Map.put(device_params, "organization_id", organization_id)
|
||||
_provided_id -> device_params # ← User-supplied org_id passes through!
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
**Impact:** An authenticated user with a valid API token can create devices (and trigger SNMP discovery) in any organization they know the ID of.
|
||||
|
||||
**Fix:** Always force the authenticated organization's ID:
|
||||
```elixir
|
||||
defp default_organization_id(device_params, organization_id) do
|
||||
Map.put(device_params, "organization_id", organization_id)
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### H2: IDOR — Agent Token Deletion Without Organization Scoping
|
||||
|
||||
**File:** `lib/towerops_web/live/agent_live/index.ex`, line 163
|
||||
**File:** `lib/towerops/agents.ex`, line 451
|
||||
|
||||
**Issue:** The `"delete_agent"` event handler passes the user-supplied `id` directly to `Agents.delete_agent_token(id)` without verifying the agent token belongs to the current user's organization. `delete_agent_token/1` fetches by raw ID with `Repo.get!`, no org check.
|
||||
|
||||
```elixir
|
||||
# agent_live/index.ex:163
|
||||
def handle_event("delete_agent", %{"id" => id}, socket) do
|
||||
case Agents.delete_agent_token(id) do # ← No org scope check!
|
||||
```
|
||||
|
||||
**Impact:** A user could delete agent tokens belonging to other organizations by crafting a WebSocket event with a known agent token ID.
|
||||
|
||||
**Fix:** Verify organization ownership before deletion:
|
||||
```elixir
|
||||
def handle_event("delete_agent", %{"id" => id}, socket) do
|
||||
organization = socket.assigns.current_scope.organization
|
||||
case Agents.get_organization_agent_token(organization.id, id) do
|
||||
nil -> {:noreply, put_flash(socket, :error, "Agent not found")}
|
||||
agent_token ->
|
||||
case Agents.delete_agent_token(agent_token.id) do
|
||||
# ...
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### H3: IDOR — Schedule/Escalation Policy Access Without Organization Check
|
||||
|
||||
**Files:**
|
||||
- `lib/towerops_web/live/schedule_live/show.ex`, line 19
|
||||
- `lib/towerops_web/live/schedule_live/form.ex`, line 27
|
||||
- `lib/towerops_web/live/escalation_policy_live/show.ex`, line 10
|
||||
- `lib/towerops_web/live/escalation_policy_live/form.ex`, line 27
|
||||
|
||||
**Issue:** These LiveViews fetch schedules and escalation policies by raw ID (`OnCall.get_schedule!(id)`, `OnCall.get_escalation_policy!(id)`) without verifying the resource belongs to the user's current organization. Any authenticated user who knows a schedule/policy UUID can view, edit, and delete it.
|
||||
|
||||
```elixir
|
||||
# schedule_live/show.ex:19
|
||||
schedule = OnCall.get_schedule!(id) # ← No org check
|
||||
# escalation_policy_live/show.ex:10
|
||||
policy = OnCall.get_escalation_policy!(id) # ← No org check
|
||||
```
|
||||
|
||||
**Impact:** Cross-organization read/write/delete access to on-call schedules and escalation policies.
|
||||
|
||||
**Fix:** Add organization scoping to the queries:
|
||||
```elixir
|
||||
def get_schedule!(id, organization_id) do
|
||||
Schedule
|
||||
|> where(id: ^id, organization_id: ^organization_id)
|
||||
|> Repo.one!()
|
||||
|> Repo.preload(overrides: :user, layers: [members: :user])
|
||||
end
|
||||
```
|
||||
|
||||
Then use `OnCall.get_schedule!(id, organization.id)` in the LiveViews.
|
||||
|
||||
---
|
||||
|
||||
## 🟠 MEDIUM Severity
|
||||
|
||||
### M1: LIKE Wildcard Injection in Multiple Search Functions
|
||||
|
||||
**Files:**
|
||||
- `lib/towerops/devices.ex`, line 148 (`search_devices/2`)
|
||||
- `lib/towerops/sites.ex`, line 40 (`search_sites/2`)
|
||||
- `lib/towerops/gaiia.ex`, lines 122, 133, 208 (`search_inventory_items`, `search_network_sites`, `suggest_site_matches`)
|
||||
|
||||
**Issue:** These functions build ILIKE patterns as `"%#{query}%"` without escaping `%` and `_` wildcards. A user can submit `%` as a search query to match all records, or use `_` for single-character wildcards.
|
||||
|
||||
Compare with `lib/towerops/search.ex` (lines 89–93) and `lib/towerops/trace.ex` (lines 456–461) which correctly call `sanitize`/`sanitize_like`.
|
||||
|
||||
```elixir
|
||||
# devices.ex:148 — NO sanitization
|
||||
search_term = "%#{query}%"
|
||||
|
||||
# search.ex:89 — CORRECT
|
||||
defp sanitize(query) do
|
||||
query
|
||||
|> String.replace("%", "\\%")
|
||||
|> String.replace("_", "\\_")
|
||||
end
|
||||
```
|
||||
|
||||
**Impact:** Information disclosure — users can craft patterns to enumerate all records or perform wildcard-based probing. Low direct risk since queries are org-scoped, but violates defense-in-depth.
|
||||
|
||||
**Fix:** Add `sanitize_like/1` to all search functions that build ILIKE patterns, or extract a shared helper.
|
||||
|
||||
---
|
||||
|
||||
### M2: `Jason.decode!` on Untrusted Input (Crash on Malformed JSON)
|
||||
|
||||
**File:** `lib/towerops_web/live/device_live/index.ex`, line 196
|
||||
|
||||
**Issue:** `Jason.decode!(params["identifier"])` will raise a `Jason.DecodeError` if the client sends malformed JSON. While Phoenix LiveView catches this and the process restarts, it causes a brief disruption for the user and generates noisy error logs.
|
||||
|
||||
```elixir
|
||||
def handle_event("add_discovered_device", params, socket) do
|
||||
identifier = Jason.decode!(params["identifier"]) # ← Crashes on bad input
|
||||
```
|
||||
|
||||
**Impact:** Denial of service for the user's LiveView session. The process restarts, but data in assigns is lost.
|
||||
|
||||
**Fix:** Use `Jason.decode/1` with error handling:
|
||||
```elixir
|
||||
case Jason.decode(params["identifier"]) do
|
||||
{:ok, identifier} -> # proceed
|
||||
{:error, _} -> {:noreply, put_flash(socket, :error, "Invalid identifier")}
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### M3: Role Change Without Caller Permission Check
|
||||
|
||||
**File:** `lib/towerops_web/live/org/settings_live.ex`, lines 208–223
|
||||
|
||||
**Issue:** The `"change_role"` event handler calls `Organizations.update_member_role/3` without checking whether the **current user** has permission to change roles. Any authenticated member of the organization can change anyone else's role (except the owner's). A `member` or `viewer` role user could promote themselves to `admin`.
|
||||
|
||||
The `update_member_role/3` function only prevents changing the owner's role, but doesn't verify the caller's role:
|
||||
|
||||
```elixir
|
||||
def update_member_role(organization_id, user_id, new_role) do
|
||||
case get_membership(organization_id, user_id) do
|
||||
%Membership{role: :owner} -> {:error, :cannot_change_owner_role}
|
||||
%Membership{} = membership -> update_membership(membership, %{role: new_role})
|
||||
# ← No check that the CALLER is an owner/admin
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
**Impact:** Privilege escalation — any org member can promote themselves to `admin` role.
|
||||
|
||||
**Fix:** Check that the current user is an owner or admin before allowing role changes:
|
||||
```elixir
|
||||
def handle_event("change_role", %{"user-id" => user_id, "role" => role}, socket) do
|
||||
organization = socket.assigns.organization
|
||||
current_user = socket.assigns.current_scope.user
|
||||
|
||||
# Verify caller has permission
|
||||
caller_membership = Organizations.get_membership(organization.id, current_user.id)
|
||||
unless caller_membership.role in [:owner, :admin] do
|
||||
{:noreply, put_flash(socket, :error, "You don't have permission to change roles")}
|
||||
else
|
||||
# proceed with role change
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### M4: SSRF via HTTP Monitoring Checks
|
||||
|
||||
**File:** `lib/towerops/monitoring/executors/http_executor.ex`
|
||||
**File:** `lib/towerops_web/live/check_live/form_component.ex`, lines 282–303
|
||||
|
||||
**Issue:** Users can create HTTP monitoring checks with arbitrary URLs. The executor makes server-side HTTP requests to these URLs without restricting the target. This could be used to probe internal services (metadata endpoints, localhost services, internal APIs).
|
||||
|
||||
```elixir
|
||||
# http_executor.ex — No URL validation/restriction
|
||||
opts = [
|
||||
method: String.to_atom(method),
|
||||
url: Map.fetch!(config, "url"), # ← Any URL, including internal
|
||||
```
|
||||
|
||||
**Impact:** Server-side request forgery (SSRF). Can probe internal network, cloud metadata services (169.254.169.254), and localhost services.
|
||||
|
||||
**Fix:** Validate URLs against an allowlist or block internal/private IP ranges before making the request. At minimum, block requests to `127.0.0.0/8`, `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `169.254.169.254`, and `::1`.
|
||||
|
||||
---
|
||||
|
||||
### M5: `inspect()` Leaking Internal Details in API Error Responses
|
||||
|
||||
**Files:**
|
||||
- `lib/towerops_web/controllers/api/v1/mib_controller.ex`, line 93
|
||||
- `lib/towerops_web/controllers/api/v1/agent_release_webhook_controller.ex`, line 26
|
||||
|
||||
**Issue:** These endpoints return `inspect(reason)` in JSON error responses, which can leak internal Elixir data structures, module names, and system paths to API clients.
|
||||
|
||||
```elixir
|
||||
# mib_controller.ex:93
|
||||
|> json(%{error: inspect(reason)}) # ← Leaks internal error details
|
||||
|
||||
# agent_release_webhook_controller.ex:26
|
||||
|> json(%{status: "error", error: inspect(reason)})
|
||||
```
|
||||
|
||||
**Impact:** Information disclosure — internal error details aid attackers in understanding the application structure.
|
||||
|
||||
**Fix:** Return generic error messages to clients and log the detailed error server-side:
|
||||
```elixir
|
||||
Logger.error("MIB listing failed: #{inspect(reason)}")
|
||||
json(conn, %{error: "Internal server error"})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🟡 LOW Severity
|
||||
|
||||
### L1: `String.to_integer/1` on URL Params Without Validation
|
||||
|
||||
**Files:**
|
||||
- `lib/towerops_web/live/device_live/index.ex`, line 67: `String.to_integer()`
|
||||
- `lib/towerops_web/live/device_live/show.ex`, line 76: `String.to_integer()`
|
||||
|
||||
**Issue:** `String.to_integer/1` raises `ArgumentError` on non-numeric input. If a user crafts a URL with `?page=abc`, the LiveView process crashes.
|
||||
|
||||
**Impact:** Minor DoS — LiveView process restarts. Low severity because LiveView handles this gracefully with reconnection.
|
||||
|
||||
**Fix:** Use `Integer.parse/1` with a default fallback:
|
||||
```elixir
|
||||
page = case Integer.parse(Map.get(params, "page", "1")) do
|
||||
{n, ""} when n > 0 -> n
|
||||
_ -> 1
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### L2: Unverified TOTP Device Deletion Race
|
||||
|
||||
**File:** `lib/towerops_web/live/user_settings_live/totp_manager.ex`, line 57
|
||||
|
||||
**Issue:** `Repo.get!(UserTotpDevice, device_id)` fetches the TOTP device by ID from `socket.assigns.new_device_id` without verifying it still belongs to the current user. While `new_device_id` is set server-side, a race condition exists if the user opens the verification flow in multiple tabs.
|
||||
|
||||
**Impact:** Minimal — the device ID comes from server-side assigns, not user input. Theoretical only.
|
||||
|
||||
**Fix:** Scope the query: `Repo.get_by!(UserTotpDevice, id: device_id, user_id: user.id)`
|
||||
|
||||
---
|
||||
|
||||
### L3: `raw()` Usage in Registration Template with `t_auth()` Translations
|
||||
|
||||
**File:** `lib/towerops_web/controllers/user_registration_html/new.html.heex`, lines 164, 187
|
||||
|
||||
**Issue:** The `raw()` call wraps the output of `t_auth()` translation, which includes interpolated values. If translation strings are ever sourced from user-editable content (CMS, admin panel), this becomes an XSS vector. Currently safe because translations are compile-time `.po` files.
|
||||
|
||||
```heex
|
||||
raw(~s(<a href="/privacy" ...>#{t_auth("Privacy Policy")}</a>))
|
||||
```
|
||||
|
||||
**Impact:** Currently no risk (compile-time translations). Becomes XSS if translation source changes.
|
||||
|
||||
**Fix:** Use HEEx components instead of `raw()` for link construction:
|
||||
```heex
|
||||
<a href="/privacy" target="_blank" class="..."><%= t_auth("Privacy Policy") %></a>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### L4: HTTP Executor `String.to_atom/1` on User Input
|
||||
|
||||
**File:** `lib/towerops/monitoring/executors/http_executor.ex`, line 57
|
||||
|
||||
**Issue:** `String.to_atom(method)` converts the HTTP method from config to an atom. While the method comes from check config (not direct user request params), atoms are not garbage collected. Repeated creation of unique method strings could leak atoms, though in practice the valid HTTP methods are a small fixed set.
|
||||
|
||||
```elixir
|
||||
method: String.to_atom(method), # ← Atom table pollution risk
|
||||
```
|
||||
|
||||
**Impact:** Theoretical atom table exhaustion. Low because check config is validated and limited.
|
||||
|
||||
**Fix:** Use `String.to_existing_atom/1` or a whitelist:
|
||||
```elixir
|
||||
defp method_to_atom("get"), do: :get
|
||||
defp method_to_atom("post"), do: :post
|
||||
defp method_to_atom("put"), do: :put
|
||||
defp method_to_atom("delete"), do: :delete
|
||||
defp method_to_atom("head"), do: :head
|
||||
defp method_to_atom("patch"), do: :patch
|
||||
defp method_to_atom(_), do: :get
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ Positive Findings (Things Done Well)
|
||||
|
||||
1. **AccessControl module** (`lib/towerops_web/live/helpers/access_control.ex`) — Devices, sites, and alerts are properly scoped to organization. Good pattern.
|
||||
|
||||
2. **LIKE sanitization** in `Search`, `Trace`, and `Snmp` modules — Properly escapes `%` and `_` wildcards.
|
||||
|
||||
3. **MIB Controller path traversal protection** — Vendor names are validated against `^[a-zA-Z0-9_-]+$`, extracted archive paths are validated against the extraction directory, and filenames use `Path.basename()`.
|
||||
|
||||
4. **Stripe webhook signature verification** — Uses `Plug.Crypto.secure_compare/2` for timing-safe comparison and validates timestamp tolerance.
|
||||
|
||||
5. **Error JSON handler** — `ErrorJSON` returns generic 500 messages without leaking internals.
|
||||
|
||||
6. **CSRF protection** — Phoenix's built-in CSRF tokens are active for all forms.
|
||||
|
||||
7. **HEEx auto-escaping** — All templates use `{@variable}` syntax which auto-escapes by default. No XSS from dynamic user content in templates.
|
||||
|
||||
8. **Ecto parameterized queries** — All `fragment()` calls use `^variable` bindings, preventing SQL injection.
|
||||
|
||||
9. **Encrypted sensitive fields** — SNMP passwords and MikroTik credentials use `Encrypted.Binary` type.
|
||||
|
||||
10. **Backup deletion** — Properly verifies device organization access via `AccessControl.verify_device_access/2` before allowing deletion.
|
||||
234
docs/TEST_COVERAGE.md
Normal file
234
docs/TEST_COVERAGE.md
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
# TowerOps Test Coverage Report
|
||||
|
||||
**Generated:** 2026-03-13
|
||||
**Test Run:** 90 doctests, 55 properties, 8,134 tests | 4 failures, 38 skipped, 231 excluded
|
||||
**Duration:** 158.2s (40.5s async, 117.6s sync)
|
||||
|
||||
## Summary
|
||||
|
||||
| Metric | Count |
|
||||
|---|---|
|
||||
| Total test files | 547 |
|
||||
| Total tests (incl. doctests/properties) | 8,279 |
|
||||
| Passing | 8,237 |
|
||||
| Failures | 4 |
|
||||
| Skipped | 38 |
|
||||
| Excluded | 231 |
|
||||
| Source modules (excl. vendor profiles, migrations, proto) | ~230 |
|
||||
| Web modules (controllers, LiveViews, plugs, etc.) | ~140 |
|
||||
| Modules with NO test file | ~100 |
|
||||
|
||||
## Current Failures (4)
|
||||
|
||||
1. **`ToweropsWeb.AgentLive.IndexTest`** — Unique constraint violation on `agents.slug` during setup (test data collision)
|
||||
2. **`ToweropsWeb.AgentLive.IndexTest`** — Same slug collision in cloud poller creation setup
|
||||
3. **`ToweropsWeb.AgentLive.IndexTest`** — Same root cause, different describe block
|
||||
4. **`ToweropsWeb.GraphQL.Resolvers.TimeSeriesTest`** — Deadlock on `create_check_result` (async test hitting `ShareRowExclusiveLock`)
|
||||
|
||||
> Both are infrastructure/setup issues, not logic bugs. The agent slug collision likely needs a unique factory counter; the deadlock needs `async: false` or transaction isolation.
|
||||
|
||||
---
|
||||
|
||||
## Test Infrastructure & Patterns
|
||||
|
||||
### Case Templates
|
||||
- **`ToweropsWeb.ConnCase`** — HTTP/LiveView tests. Imports `Phoenix.ConnTest`, `Plug.Conn`, verified routes. Has `register_and_log_in_user/1` and sudo variant.
|
||||
- **`Towerops.DataCase`** — Context/schema tests. Imports `Ecto`, `Ecto.Changeset`, `Ecto.Query`, `Towerops.Repo`. SQL Sandbox isolation.
|
||||
- **`ToweropsWeb.LiveViewTestHelpers`** — Shared helpers for LiveView tests.
|
||||
|
||||
### Fixtures (Factory Pattern)
|
||||
All in `test/support/fixtures/`:
|
||||
- `accounts_fixtures.ex` — Users, tokens, TOTP
|
||||
- `agents_fixtures.ex` — Agents, assignments, tokens
|
||||
- `billing_fixtures.ex` — Stripe data
|
||||
- `devices_fixtures.ex` — Devices, firmware
|
||||
- `gaiia_fixtures.ex` — GAIIA accounts, subscribers
|
||||
- `integrations_fixtures.ex` — Integration configs
|
||||
- `jobs_fixtures.ex` — Oban jobs
|
||||
- `maintenance_fixtures.ex` — Maintenance windows
|
||||
- `on_call_fixtures.ex` — Schedules, escalation policies
|
||||
- `organizations_fixtures.ex` — Orgs, memberships, invitations
|
||||
- `snmp_fixtures.ex` — SNMP devices, interfaces
|
||||
|
||||
### Test Types
|
||||
- **Async tests:** ~442 files use `async: true`
|
||||
- **Property tests:** 55 (using `StreamData`)
|
||||
- **Doctests:** 90
|
||||
- **Integration tests:** `test/integration/` (SNMP, discovery parity)
|
||||
- **SnmpKit tests:** Dedicated `test/snmpkit/` directory with ~45 test files
|
||||
|
||||
### Mocking
|
||||
- **`Mox`** — Used for behaviour-based mocks (SNMP, ping, HTTP)
|
||||
- **`ping_stub.ex`** — Stub for ping behaviour
|
||||
- **`snmp_kit_mock.ex`** — SNMP client mock
|
||||
|
||||
---
|
||||
|
||||
## Coverage by Domain
|
||||
|
||||
### ✅ Well-Covered Domains
|
||||
|
||||
| Domain | Source Files | Test Files | Notes |
|
||||
|---|---|---|---|
|
||||
| **Accounts** | 13 | 14+ | Comprehensive auth, TOTP, sessions, HIBP, browser sessions |
|
||||
| **Agents** | 5 | 8+ | Assignments, tokens, stats, release checker, cloud poller |
|
||||
| **Alerts** | 2 | 2 | Alert schema + context |
|
||||
| **API Tokens** | 2 | 2 | Token CRUD |
|
||||
| **Billing** | 5 | 4 | Stripe client, webhook processor, notifier (missing: `stripe_webhook_event` schema) |
|
||||
| **Devices** | 14 | 10+ | Core CRUD, firmware, backups, differ, version comparator |
|
||||
| **GAIIA** | 14 | 13 | Excellent: sync, reconciliation, subscriber matching, impact analysis |
|
||||
| **Organizations** | 6 | 7+ | Policies, invitations, subscriptions, property tests |
|
||||
| **Preseem** | 14 | 14 | Full coverage: sync, baselines, insights, fleet intelligence |
|
||||
| **Profiles** | 7 | 5 | YAML profiles, device/sensor OIDs |
|
||||
| **SNMP Core** | 30+ | 25+ | Discovery, polling, sanitizer, sensor change, MIB parsing |
|
||||
| **SNMP Vendors** | 180+ | 180+ | Auto-generated, 1:1 coverage |
|
||||
| **Workers** | 30 | 22 | Most critical workers covered |
|
||||
| **Web: Plugs** | 13 | 11 | Auth, rate limiting, security headers, brute force |
|
||||
| **Web: Controllers** | 25+ | 25+ | API v1, admin, auth flow controllers |
|
||||
| **Web: LiveViews** | 40+ | 35+ | Most major pages tested |
|
||||
|
||||
### ⚠️ Gaps — Modules Without Test Files
|
||||
|
||||
#### 🔴 Critical (Needs Tests NOW)
|
||||
|
||||
These modules handle auth, billing, alert routing, or data mutations with no dedicated tests:
|
||||
|
||||
| Module | Why Critical |
|
||||
|---|---|
|
||||
| `Towerops.Monitoring` (context) | Core monitoring orchestration — check creation, status evaluation |
|
||||
| `Towerops.Monitoring.Executor` | Dispatches monitoring checks to executors |
|
||||
| `Towerops.Monitoring.Executors.DnsExecutor` | DNS monitoring — untested execution path |
|
||||
| `Towerops.Monitoring.Executors.HttpExecutor` | HTTP monitoring — untested execution path |
|
||||
| `Towerops.Monitoring.Executors.TcpExecutor` | TCP monitoring — untested execution path |
|
||||
| `Towerops.Monitoring.Executors.SnmpInterfaceExecutor` | SNMP interface monitoring |
|
||||
| `Towerops.Monitoring.Executors.SnmpProcessorExecutor` | SNMP CPU monitoring |
|
||||
| `Towerops.Monitoring.Executors.SnmpStorageExecutor` | SNMP storage monitoring |
|
||||
| `Towerops.OnCall.Notifier` | Sends on-call notifications — critical alert path |
|
||||
| `Towerops.OnCall.Incident` | Incident lifecycle — create, ack, resolve |
|
||||
| `Towerops.OnCall.EscalationPolicy` | Escalation rule evaluation |
|
||||
| `Towerops.OnCall.Notification` | Notification delivery schema |
|
||||
| `Towerops.PagerDuty.Notifier` | External PagerDuty alert dispatch |
|
||||
| `Towerops.Security.CloudflareClient` | Cloudflare IP banning — security mutation |
|
||||
| `Towerops.Security.IpBlock` / `IpWhitelist` | IP access control schemas |
|
||||
| `Towerops.Billing.StripeWebhookEvent` | Stripe event persistence schema |
|
||||
| `Towerops.Accounts.UserRecoveryCode` | Account recovery — security-critical |
|
||||
| `Towerops.Accounts.UserToken` | Token schema (tested indirectly via Accounts context but no direct schema test) |
|
||||
|
||||
#### 🟠 High Priority
|
||||
|
||||
| Module | Why |
|
||||
|---|---|
|
||||
| `Towerops.Maintenance` (context) | Maintenance window CRUD and scheduling logic |
|
||||
| `Towerops.ConfigChanges` (context) | Config change tracking and correlation |
|
||||
| `Towerops.ConfigChanges.Correlator` | Correlates config changes to alerts |
|
||||
| `Towerops.Topology.Lldp` | LLDP neighbor discovery logic |
|
||||
| `Towerops.Topology.Identifier` | Device identification heuristics |
|
||||
| `Towerops.Netbox.Sync` | External NetBox sync — data import |
|
||||
| `Towerops.Sonar.Client` / `Sync` | Sonar billing integration |
|
||||
| `Towerops.Weather.Client` | Weather API integration |
|
||||
| `Towerops.Workers.EscalationCheckWorker` | Periodic escalation evaluation |
|
||||
| `Towerops.Workers.CheckWorker` | Monitoring check scheduling |
|
||||
| `Towerops.Workers.MikrotikBackupWorker` | MikroTik backup orchestration |
|
||||
| `Towerops.Workers.CloudflareBanWorker` | Security: auto-ban via Cloudflare |
|
||||
| `ToweropsWeb.Controllers.Api.V1.PagerdutyWebhookController` | Inbound PagerDuty webhooks |
|
||||
| `ToweropsWeb.Controllers.Api.V1.ActivityController` | Activity feed API |
|
||||
| `ToweropsWeb.Controllers.Api.V1.CheckResultsController` | Check results API |
|
||||
| `ToweropsWeb.Controllers.InvitationController` | Org invitation acceptance |
|
||||
| `ToweropsWeb.Controllers.UserConfirmationController` | Email confirmation flow |
|
||||
| `ToweropsWeb.Controllers.UserRegistrationController` | User signup flow |
|
||||
| `ToweropsWeb.Live.OnboardingLive` | New user onboarding |
|
||||
| `ToweropsWeb.Live.Org.GaiiaMappingLive` | GAIIA device mapping UI |
|
||||
| `ToweropsWeb.Live.Org.GaiiaReconciliationLive` | GAIIA reconciliation UI |
|
||||
|
||||
#### 🟡 Medium Priority
|
||||
|
||||
| Module | Why |
|
||||
|---|---|
|
||||
| `Towerops.Geocoding` | Address-to-coordinates (read-only helper) |
|
||||
| `Towerops.GeoIp.Block` / `Location` | GeoIP lookup schemas |
|
||||
| `Towerops.Vault` | Encryption key management |
|
||||
| `Towerops.RateLimit` | Rate limiting logic |
|
||||
| `Towerops.Devices.Event` / `BackupRequest` | Event/request schemas |
|
||||
| `Towerops.Devices.MikrotikBackup` | Backup schema |
|
||||
| `Towerops.OnCall.Schedule` / `Layer` / `Override` | On-call schedule schemas |
|
||||
| `Towerops.Organizations.Membership` | Membership schema |
|
||||
| `Towerops.SNMP.Interface` / `Sensor` / `Device` schemas | Data schemas |
|
||||
| `Towerops.Workers.*SyncWorker` (sonar, splynx, visp, weather, netbox) | Sync orchestration |
|
||||
| `ToweropsWeb.Live.CapacityLive` | Capacity planning page |
|
||||
| `ToweropsWeb.Live.ConfigTimelineLive` | Config change timeline |
|
||||
| `ToweropsWeb.Live.ActivityFeedLive` | Activity feed page |
|
||||
| `ToweropsWeb.Live.MikrotikBackupLive.Compare` | Backup diff viewer |
|
||||
| `ToweropsWeb.GraphQL.Context` | GraphQL context setup |
|
||||
|
||||
#### 🟢 Low Priority
|
||||
|
||||
| Module | Notes |
|
||||
|---|---|
|
||||
| `Towerops.EctoTypes.EncryptedBinary` | Simple Ecto type (encrypted_map is tested) |
|
||||
| `Towerops.Settings.ApplicationSetting` | Simple schema |
|
||||
| `Towerops.Mailer` | Swoosh wrapper |
|
||||
| `Towerops.Release` | Mix release tasks |
|
||||
| `Towerops.Repo` | Ecto repo module |
|
||||
| `Towerops.SNMP.SnmpBehaviour` | Behaviour definition |
|
||||
| `Towerops.SNMP.PollerBehaviour` | Behaviour definition |
|
||||
| `Towerops.Monitoring.PingBehaviour` | Behaviour definition |
|
||||
| `Towerops.Profiles.MibCache` / `ProfileWatcher` | GenServer infrastructure |
|
||||
| Various SNMP data schemas (readings, stats) | Simple structs |
|
||||
|
||||
---
|
||||
|
||||
## Top 10 Recommended Tests to Write
|
||||
|
||||
### 1. `test/towerops/monitoring_test.exs` — Monitoring Context
|
||||
**Impact: 🔴 Critical**
|
||||
Cover: `create_check/2`, `update_check/2`, `delete_check/2`, `list_checks_for_device/1`, check result creation and status evaluation. This is the core monitoring orchestration layer with zero test coverage.
|
||||
|
||||
### 2. `test/towerops/monitoring/executor_test.exs` — Check Executor Dispatch
|
||||
**Impact: 🔴 Critical**
|
||||
Cover: Executor dispatch logic, timeout handling, error wrapping. Test with mocked executors that the right executor is called for each check type (HTTP, DNS, TCP, SNMP).
|
||||
|
||||
### 3. `test/towerops/on_call/notifier_test.exs` — On-Call Notification Delivery
|
||||
**Impact: 🔴 Critical**
|
||||
Cover: Notification channel selection (email, SMS, push), retry logic, escalation triggering, de-duplication. Mock external delivery and verify correct notification flow.
|
||||
|
||||
### 4. `test/towerops/on_call/incident_test.exs` — Incident Lifecycle
|
||||
**Impact: 🔴 Critical**
|
||||
Cover: Incident creation from alert, acknowledgment, resolution, re-escalation on timeout. Verify state machine transitions and escalation policy evaluation.
|
||||
|
||||
### 5. `test/towerops/monitoring/executors/http_executor_test.exs` — HTTP Check Execution
|
||||
**Impact: 🔴 Critical**
|
||||
Cover: HTTP status checks, response time measurement, SSL verification, timeout handling, redirect following, body content matching. Mock HTTP client.
|
||||
|
||||
### 6. `test/towerops/config_changes_test.exs` — Config Change Tracking
|
||||
**Impact: 🟠 High**
|
||||
Cover: Change event creation, correlation with alerts/devices, timeline querying, diff computation. Verify correlator links config changes to relevant alerts.
|
||||
|
||||
### 7. `test/towerops/maintenance_test.exs` — Maintenance Window Context
|
||||
**Impact: 🟠 High**
|
||||
Cover: Window creation/scheduling, overlap detection, active window queries, alert suppression during maintenance. Verify `is_in_maintenance?/2` logic.
|
||||
|
||||
### 8. `test/towerops_web/controllers/api/v1/pagerduty_webhook_controller_test.exs` — PagerDuty Webhooks
|
||||
**Impact: 🟠 High**
|
||||
Cover: Webhook signature verification, event type handling (trigger, acknowledge, resolve), idempotency, error responses for invalid payloads.
|
||||
|
||||
### 9. `test/towerops/security/cloudflare_client_test.exs` — Cloudflare IP Management
|
||||
**Impact: 🟠 High**
|
||||
Cover: IP block creation/deletion via Cloudflare API, error handling, rate limiting. Mock Cloudflare API. Verify the ban worker integrates correctly.
|
||||
|
||||
### 10. `test/towerops_web/controllers/user_registration_controller_test.exs` — User Registration
|
||||
**Impact: 🟠 High**
|
||||
Cover: Successful registration, duplicate email handling, password validation, email confirmation trigger, registration with invitation token. Critical auth flow.
|
||||
|
||||
---
|
||||
|
||||
## Test Health Notes
|
||||
|
||||
- **Async ratio is excellent** — ~81% of test files use `async: true`
|
||||
- **Property testing is in use** — 55 property tests via StreamData (found in schema validation, Ecto type tests)
|
||||
- **Fixture coverage is comprehensive** — 14 fixture modules covering all major domains
|
||||
- **Vendor profile tests are auto-generated** — 180+ vendor tests likely from `mix gen_vendor_tests`
|
||||
- **The 4 current failures are infrastructure issues**, not logic bugs:
|
||||
- Agent slug uniqueness collision in test factories → fix: add `System.unique_integer()` to slug generation
|
||||
- Deadlock in async time series test → fix: set `async: false` or restructure DB access
|
||||
- **231 excluded tests** — likely tagged `:integration` or `:external` for CI isolation
|
||||
- **38 skipped tests** — worth auditing to see if any are permanently broken
|
||||
2149
docs/references/elixir-best-practices.md
Normal file
2149
docs/references/elixir-best-practices.md
Normal file
File diff suppressed because it is too large
Load diff
1725
docs/references/gleam-best-practices.md
Normal file
1725
docs/references/gleam-best-practices.md
Normal file
File diff suppressed because it is too large
Load diff
1883
docs/references/golang-best-practices.md
Normal file
1883
docs/references/golang-best-practices.md
Normal file
File diff suppressed because it is too large
Load diff
2758
docs/references/sre-devops-best-practices.md
Normal file
2758
docs/references/sre-devops-best-practices.md
Normal file
File diff suppressed because it is too large
Load diff
1992
docs/references/ux-frontend-best-practices.md
Normal file
1992
docs/references/ux-frontend-best-practices.md
Normal file
File diff suppressed because it is too large
Load diff
227
docs/templates/context-module.md
vendored
Normal file
227
docs/templates/context-module.md
vendored
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
# Ecto Context Module Template
|
||||
|
||||
Copy-pasteable template for a new Ecto schema + context + test following TowerOps conventions.
|
||||
|
||||
Replace `__CONTEXT__` with PascalCase context name (e.g., `Schedules`).
|
||||
Replace `__SCHEMA__` with PascalCase schema name (e.g., `Schedule`).
|
||||
Replace `__resource__` with snake_case (e.g., `schedule`).
|
||||
Replace `__resources__` with plural snake_case (e.g., `schedules`).
|
||||
Replace `__table__` with the database table name (e.g., `schedules`).
|
||||
|
||||
---
|
||||
|
||||
## `lib/towerops/__context__/__resource__.ex` (Schema)
|
||||
|
||||
```elixir
|
||||
defmodule Towerops.__CONTEXT__.__SCHEMA__ do
|
||||
@moduledoc """
|
||||
Schema for __resources__.
|
||||
|
||||
Describe what this resource represents and its relationships.
|
||||
"""
|
||||
use Ecto.Schema
|
||||
|
||||
import Ecto.Changeset
|
||||
|
||||
alias Towerops.Organizations.Organization
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
schema "__table__" do
|
||||
field :name, :string
|
||||
field :description, :string
|
||||
field :enabled, :boolean, default: true
|
||||
|
||||
belongs_to :organization, Organization
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
@required_fields ~w(name organization_id)a
|
||||
@optional_fields ~w(description enabled)a
|
||||
|
||||
def changeset(__resource__, attrs) do
|
||||
__resource__
|
||||
|> cast(attrs, @required_fields ++ @optional_fields)
|
||||
|> validate_required(@required_fields)
|
||||
|> validate_length(:name, max: 255)
|
||||
|> validate_length(:description, max: 1000)
|
||||
|> foreign_key_constraint(:organization_id)
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `lib/towerops/__context__.ex` (Context)
|
||||
|
||||
```elixir
|
||||
defmodule Towerops.__CONTEXT__ do
|
||||
@moduledoc """
|
||||
Context for managing __resources__.
|
||||
"""
|
||||
|
||||
import Ecto.Query
|
||||
|
||||
alias Towerops.__CONTEXT__.__SCHEMA__
|
||||
alias Towerops.Repo
|
||||
|
||||
@preloads [:organization]
|
||||
|
||||
def create___resource__(attrs) do
|
||||
%__SCHEMA__{}
|
||||
|> __SCHEMA__.changeset(attrs)
|
||||
|> Repo.insert()
|
||||
end
|
||||
|
||||
def update___resource__(%__SCHEMA__{} = __resource__, attrs) do
|
||||
__resource__
|
||||
|> __SCHEMA__.changeset(attrs)
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
def delete___resource__(%__SCHEMA__{} = __resource__) do
|
||||
Repo.delete(__resource__)
|
||||
end
|
||||
|
||||
def get___resource__!(id) do
|
||||
__SCHEMA__
|
||||
|> Repo.get!(id)
|
||||
|> Repo.preload(@preloads)
|
||||
end
|
||||
|
||||
def list___resources__(organization_id, opts \\ []) do
|
||||
__SCHEMA__
|
||||
|> where([r], r.organization_id == ^organization_id)
|
||||
|> apply_filter(opts[:filter])
|
||||
|> order_by([r], desc: r.inserted_at)
|
||||
|> Repo.all()
|
||||
|> Repo.preload(@preloads)
|
||||
end
|
||||
|
||||
defp apply_filter(query, :active) do
|
||||
where(query, [r], r.enabled == true)
|
||||
end
|
||||
|
||||
defp apply_filter(query, :archived) do
|
||||
where(query, [r], r.enabled == false)
|
||||
end
|
||||
|
||||
defp apply_filter(query, _), do: query
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Migration
|
||||
|
||||
```elixir
|
||||
defmodule Towerops.Repo.Migrations.Create__SCHEMA__s do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:__table__, primary_key: false) do
|
||||
add :id, :binary_id, primary_key: true
|
||||
add :name, :string, null: false
|
||||
add :description, :text
|
||||
add :enabled, :boolean, default: true, null: false
|
||||
|
||||
add :organization_id, references(:organizations, type: :binary_id, on_delete: :delete_all),
|
||||
null: false
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
create index(:__table__, [:organization_id])
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `test/towerops/__context___test.exs` (Context Tests)
|
||||
|
||||
```elixir
|
||||
defmodule Towerops.__CONTEXT__Test do
|
||||
use Towerops.DataCase
|
||||
|
||||
alias Towerops.__CONTEXT__
|
||||
alias Towerops.__CONTEXT__.__SCHEMA__
|
||||
|
||||
setup do
|
||||
user = insert_user()
|
||||
{:ok, organization} =
|
||||
Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id)
|
||||
|
||||
%{organization: organization, user: user}
|
||||
end
|
||||
|
||||
describe "create___resource__/1" do
|
||||
test "creates with valid attrs", %{organization: org} do
|
||||
attrs = %{
|
||||
name: "Test __RESOURCE_TITLE__",
|
||||
organization_id: org.id
|
||||
}
|
||||
|
||||
assert {:ok, %__SCHEMA__{} = __resource__} = __CONTEXT__.create___resource__(attrs)
|
||||
assert __resource__.name == "Test __RESOURCE_TITLE__"
|
||||
assert __resource__.organization_id == org.id
|
||||
end
|
||||
|
||||
test "fails without required fields" do
|
||||
assert {:error, changeset} = __CONTEXT__.create___resource__(%{})
|
||||
assert errors_on(changeset) |> Map.has_key?(:name)
|
||||
assert errors_on(changeset) |> Map.has_key?(:organization_id)
|
||||
end
|
||||
end
|
||||
|
||||
describe "update___resource__/2" do
|
||||
test "updates with valid attrs", %{organization: org} do
|
||||
{:ok, __resource__} =
|
||||
__CONTEXT__.create___resource__(%{name: "Original", organization_id: org.id})
|
||||
|
||||
assert {:ok, updated} = __CONTEXT__.update___resource__(__resource__, %{name: "Updated"})
|
||||
assert updated.name == "Updated"
|
||||
end
|
||||
end
|
||||
|
||||
describe "delete___resource__/1" do
|
||||
test "deletes the resource", %{organization: org} do
|
||||
{:ok, __resource__} =
|
||||
__CONTEXT__.create___resource__(%{name: "To Delete", organization_id: org.id})
|
||||
|
||||
assert {:ok, _} = __CONTEXT__.delete___resource__(__resource__)
|
||||
assert_raise Ecto.NoResultsError, fn -> __CONTEXT__.get___resource__!(__resource__.id) end
|
||||
end
|
||||
end
|
||||
|
||||
describe "list___resources__/2" do
|
||||
test "lists resources for organization", %{organization: org} do
|
||||
{:ok, _} = __CONTEXT__.create___resource__(%{name: "Item 1", organization_id: org.id})
|
||||
{:ok, _} = __CONTEXT__.create___resource__(%{name: "Item 2", organization_id: org.id})
|
||||
|
||||
__resources__ = __CONTEXT__.list___resources__(org.id)
|
||||
assert length(__resources__) == 2
|
||||
end
|
||||
|
||||
test "filters by active status", %{organization: org} do
|
||||
{:ok, _} = __CONTEXT__.create___resource__(%{name: "Active", organization_id: org.id, enabled: true})
|
||||
{:ok, _} = __CONTEXT__.create___resource__(%{name: "Archived", organization_id: org.id, enabled: false})
|
||||
|
||||
active = __CONTEXT__.list___resources__(org.id, filter: :active)
|
||||
assert length(active) == 1
|
||||
assert hd(active).name == "Active"
|
||||
end
|
||||
end
|
||||
|
||||
describe "get___resource__!/1" do
|
||||
test "returns the resource with preloads", %{organization: org} do
|
||||
{:ok, created} = __CONTEXT__.create___resource__(%{name: "Find Me", organization_id: org.id})
|
||||
|
||||
__resource__ = __CONTEXT__.get___resource__!(created.id)
|
||||
assert __resource__.name == "Find Me"
|
||||
assert __resource__.organization.id == org.id
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
247
docs/templates/liveview-form.md
vendored
Normal file
247
docs/templates/liveview-form.md
vendored
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
# LiveView Form Page Template
|
||||
|
||||
Copy-pasteable template for a new create/edit form page following TowerOps conventions.
|
||||
|
||||
Replace all `__RESOURCE__` with PascalCase resource (e.g., `Schedule`).
|
||||
Replace `__resource__` with snake_case (e.g., `schedule`).
|
||||
Replace `__resources__` with plural snake_case (e.g., `schedules`).
|
||||
Replace `__CONTEXT__` with context module name (e.g., `Schedules`).
|
||||
Replace `__SCHEMA__` with schema module name (e.g., `Schedule`).
|
||||
|
||||
---
|
||||
|
||||
## `lib/towerops_web/live/__resource___live/form.ex`
|
||||
|
||||
```elixir
|
||||
defmodule ToweropsWeb.__RESOURCE__Live.Form do
|
||||
@moduledoc false
|
||||
use ToweropsWeb, :live_view
|
||||
|
||||
alias Towerops.__CONTEXT__
|
||||
alias Towerops.__CONTEXT__.__SCHEMA__
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:timezone, socket.assigns.current_scope.timezone)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(params, _url, socket) do
|
||||
case socket.assigns.live_action do
|
||||
:new ->
|
||||
__resource__ = %__SCHEMA__{}
|
||||
changeset = __SCHEMA__.changeset(__resource__, %{})
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:page_title, t("New __RESOURCE_TITLE__"))
|
||||
|> assign(:__resource__, __resource__)
|
||||
|> assign(:form, to_form(changeset))}
|
||||
|
||||
:edit ->
|
||||
__resource__ = __CONTEXT__.get___resource__!(params["id"])
|
||||
changeset = __SCHEMA__.changeset(__resource__, %{})
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:page_title, t("Edit __RESOURCE_TITLE__"))
|
||||
|> assign(:__resource__, __resource__)
|
||||
|> assign(:form, to_form(changeset))}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("validate", %{"__resource__" => params}, socket) do
|
||||
changeset =
|
||||
socket.assigns.__resource__
|
||||
|> __SCHEMA__.changeset(params)
|
||||
|> Map.put(:action, :validate)
|
||||
|
||||
{:noreply, assign(socket, :form, to_form(changeset))}
|
||||
end
|
||||
|
||||
def handle_event("save", %{"__resource__" => params}, socket) do
|
||||
scope = socket.assigns.current_scope
|
||||
|
||||
params =
|
||||
params
|
||||
|> Map.put("organization_id", scope.organization.id)
|
||||
|
||||
case socket.assigns.live_action do
|
||||
:new -> save___resource__(socket, :create, params)
|
||||
:edit -> save___resource__(socket, :update, params)
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("delete", _params, socket) do
|
||||
case __CONTEXT__.delete___resource__(socket.assigns.__resource__) do
|
||||
{:ok, _} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, t("__RESOURCE_TITLE__ deleted"))
|
||||
|> push_navigate(to: ~p"/__resources__")}
|
||||
|
||||
{:error, _changeset} ->
|
||||
{:noreply, put_flash(socket, :error, t("Unable to delete __resource__"))}
|
||||
end
|
||||
end
|
||||
|
||||
defp save___resource__(socket, :create, params) do
|
||||
case __CONTEXT__.create___resource__(params) do
|
||||
{:ok, __resource__} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, t("__RESOURCE_TITLE__ created"))
|
||||
|> push_navigate(to: ~p"/__resources__/#{__resource__.id}")}
|
||||
|
||||
{:error, changeset} ->
|
||||
{:noreply, assign(socket, :form, to_form(changeset))}
|
||||
end
|
||||
end
|
||||
|
||||
defp save___resource__(socket, :update, params) do
|
||||
case __CONTEXT__.update___resource__(socket.assigns.__resource__, params) do
|
||||
{:ok, __resource__} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, t("__RESOURCE_TITLE__ updated"))
|
||||
|> push_navigate(to: ~p"/__resources__/#{__resource__.id}")}
|
||||
|
||||
{:error, changeset} ->
|
||||
{:noreply, assign(socket, :form, to_form(changeset))}
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `lib/towerops_web/live/__resource___live/form.html.heex`
|
||||
|
||||
```heex
|
||||
<Layouts.authenticated
|
||||
flash={@flash}
|
||||
current_scope={@current_scope}
|
||||
active_page="__active_page__"
|
||||
>
|
||||
<%!-- Back link / breadcrumb --%>
|
||||
<div class="mb-6">
|
||||
<.link
|
||||
navigate={~p"/__resources__"}
|
||||
class="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
>
|
||||
<.icon name="hero-arrow-left" class="h-4 w-4" />
|
||||
{t("Back to __RESOURCES_TITLE__")}
|
||||
</.link>
|
||||
</div>
|
||||
|
||||
<h1 class="text-xl font-bold text-gray-900 dark:text-white mb-6">{@page_title}</h1>
|
||||
|
||||
<div class="rounded-lg border border-gray-200 dark:border-white/10 bg-white dark:bg-gray-900 p-6">
|
||||
<.form
|
||||
for={@form}
|
||||
phx-change="validate"
|
||||
phx-submit="save"
|
||||
id="__resource__-form"
|
||||
>
|
||||
<div class="space-y-6">
|
||||
<%!-- Name field --%>
|
||||
<.input
|
||||
field={@form[:name]}
|
||||
type="text"
|
||||
label={t("Name") <> " *"}
|
||||
required
|
||||
placeholder={t("e.g., My __RESOURCE_TITLE__")}
|
||||
/>
|
||||
|
||||
<%!-- Description / reason field --%>
|
||||
<.input
|
||||
field={@form[:description]}
|
||||
type="textarea"
|
||||
label={t("Description")}
|
||||
rows="3"
|
||||
placeholder={t("Optional description")}
|
||||
/>
|
||||
|
||||
<%!-- Grid layout for related fields --%>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<.input
|
||||
field={@form[:starts_at]}
|
||||
type="datetime-local"
|
||||
label={t("Starts At") <> " *"}
|
||||
required
|
||||
/>
|
||||
<.input
|
||||
field={@form[:ends_at]}
|
||||
type="datetime-local"
|
||||
label={t("Ends At") <> " *"}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<%!-- Select field example --%>
|
||||
<%#
|
||||
<.input
|
||||
field={@form[:site_id]}
|
||||
type="select"
|
||||
label={t("Site")}
|
||||
prompt={t("Select a site...")}
|
||||
options={Enum.map(@sites, &{&1.name, &1.id})}
|
||||
/>
|
||||
#%>
|
||||
|
||||
<%!-- Checkbox example --%>
|
||||
<.input
|
||||
field={@form[:enabled]}
|
||||
type="checkbox"
|
||||
label={t("Enabled")}
|
||||
/>
|
||||
|
||||
<%!-- Submit / Cancel / Delete --%>
|
||||
<div class="flex items-center justify-between gap-3 pt-4 border-t border-gray-200 dark:border-white/10">
|
||||
<%!-- Delete button (edit mode only) --%>
|
||||
<div>
|
||||
<%= if @live_action == :edit do %>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="delete"
|
||||
data-confirm={t("Are you sure you want to delete this? This cannot be undone.")}
|
||||
class="rounded-lg px-4 py-2 text-sm font-medium text-red-700 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20 transition-colors"
|
||||
>
|
||||
{t("Delete")}
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<.link
|
||||
navigate={~p"/__resources__"}
|
||||
class="rounded-lg px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
{t("Cancel")}
|
||||
</.link>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-500 transition-colors"
|
||||
>
|
||||
{t("Save")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</.form>
|
||||
</div>
|
||||
</Layouts.authenticated>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Router entries
|
||||
|
||||
```elixir
|
||||
# In lib/towerops_web/router.ex, inside the authenticated scope:
|
||||
live "/__resources__/new", __RESOURCE__Live.Form, :new
|
||||
live "/__resources__/:id/edit", __RESOURCE__Live.Form, :edit
|
||||
```
|
||||
225
docs/templates/liveview-index.md
vendored
Normal file
225
docs/templates/liveview-index.md
vendored
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
# LiveView Index Page Template
|
||||
|
||||
Copy-pasteable template for a new list/index page following TowerOps conventions.
|
||||
|
||||
Replace all `__RESOURCE__` placeholders with your resource name (e.g., `Schedule`, `Device`).
|
||||
Replace `__resource__` with the lowercase/snake_case form (e.g., `schedule`, `device`).
|
||||
Replace `__resources__` with the plural form (e.g., `schedules`, `devices`).
|
||||
Replace `__active_page__` with the sidebar page key (e.g., `"schedules"`).
|
||||
|
||||
---
|
||||
|
||||
## `lib/towerops_web/live/__resource___live/index.ex`
|
||||
|
||||
```elixir
|
||||
defmodule ToweropsWeb.__RESOURCE__Live.Index do
|
||||
@moduledoc false
|
||||
use ToweropsWeb, :live_view
|
||||
|
||||
alias Towerops.__CONTEXT__
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
organization = socket.assigns.current_scope.organization
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, t("__RESOURCES_TITLE__"))
|
||||
|> assign(:filter, "all")
|
||||
|> assign(:timezone, socket.assigns.current_scope.timezone)
|
||||
|> load___resources__(organization.id)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(params, _url, socket) do
|
||||
filter = Map.get(params, "filter", "all")
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:filter, filter)
|
||||
|> load___resources__(socket.assigns.current_scope.organization.id)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("delete", %{"id" => id}, socket) do
|
||||
organization = socket.assigns.current_scope.organization
|
||||
__resource__ = __CONTEXT__.get___resource__!(id)
|
||||
|
||||
case __CONTEXT__.delete___resource__(__resource__) do
|
||||
{:ok, _} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, t("__RESOURCE_TITLE__ deleted"))
|
||||
|> load___resources__(organization.id)}
|
||||
|
||||
{:error, _changeset} ->
|
||||
{:noreply, put_flash(socket, :error, t("Unable to delete __resource__"))}
|
||||
end
|
||||
end
|
||||
|
||||
defp load___resources__(socket, org_id) do
|
||||
filter_atom =
|
||||
case socket.assigns.filter do
|
||||
"active" -> :active
|
||||
"archived" -> :archived
|
||||
_ -> nil
|
||||
end
|
||||
|
||||
opts = if filter_atom, do: [filter: filter_atom], else: []
|
||||
__resources__ = __CONTEXT__.list___resources__(org_id, opts)
|
||||
assign(socket, :__resources__, __resources__)
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `lib/towerops_web/live/__resource___live/index.html.heex`
|
||||
|
||||
```heex
|
||||
<Layouts.authenticated
|
||||
flash={@flash}
|
||||
current_scope={@current_scope}
|
||||
active_page="__active_page__"
|
||||
>
|
||||
<%!-- Page header --%>
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<h1 class="text-xl font-bold text-gray-900 dark:text-white">{t("__RESOURCES_TITLE__")}</h1>
|
||||
<.link
|
||||
navigate={~p"/__resources__/new"}
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-500 transition-colors"
|
||||
>
|
||||
<.icon name="hero-plus" class="h-4 w-4" />
|
||||
{t("New __RESOURCE_TITLE__")}
|
||||
</.link>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||||
{t("Description of what this page shows.")}
|
||||
</p>
|
||||
|
||||
<%!-- Filter tabs --%>
|
||||
<div class="flex items-center gap-2 mb-4 flex-wrap">
|
||||
<.link
|
||||
patch={~p"/__resources__?filter=all"}
|
||||
class={[
|
||||
"inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium transition-colors",
|
||||
@filter == "all" && "bg-gray-900 text-white dark:bg-white dark:text-gray-900",
|
||||
@filter != "all" &&
|
||||
"bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
]}
|
||||
>
|
||||
{t("All")}
|
||||
</.link>
|
||||
<.link
|
||||
patch={~p"/__resources__?filter=active"}
|
||||
class={[
|
||||
"inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium transition-colors",
|
||||
@filter == "active" && "bg-gray-900 text-white dark:bg-white dark:text-gray-900",
|
||||
@filter != "active" &&
|
||||
"bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
]}
|
||||
>
|
||||
{t("Active")}
|
||||
</.link>
|
||||
<.link
|
||||
patch={~p"/__resources__?filter=archived"}
|
||||
class={[
|
||||
"inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium transition-colors",
|
||||
@filter == "archived" && "bg-gray-900 text-white dark:bg-white dark:text-gray-900",
|
||||
@filter != "archived" &&
|
||||
"bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
]}
|
||||
>
|
||||
{t("Archived")}
|
||||
</.link>
|
||||
</div>
|
||||
|
||||
<%!-- Empty state --%>
|
||||
<%= if Enum.empty?(@__resources__) do %>
|
||||
<div class="flex items-center justify-center py-16">
|
||||
<div class="text-center">
|
||||
<.icon
|
||||
name="hero-rectangle-stack"
|
||||
class="h-12 w-12 text-gray-300 dark:text-gray-600 mx-auto mb-4"
|
||||
/>
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-300">
|
||||
{t("No __resources__ found")}
|
||||
</h3>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
{t("Get started by creating your first __resource__.")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<%!-- Data table --%>
|
||||
<div class="overflow-hidden rounded-lg border border-gray-200 dark:border-white/10">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-white/10">
|
||||
<thead class="bg-gray-50 dark:bg-gray-800/50">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{t("Name")}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{t("Status")}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{t("Created")}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{t("Actions")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100 dark:divide-white/5 bg-white dark:bg-gray-900">
|
||||
<%= for item <- @__resources__ do %>
|
||||
<tr
|
||||
class="hover:bg-gray-50 dark:hover:bg-gray-800/30 transition-colors cursor-pointer"
|
||||
phx-click={JS.navigate(~p"/__resources__/#{item.id}")}
|
||||
>
|
||||
<td class="px-4 py-3 text-sm font-medium text-gray-900 dark:text-white">
|
||||
{item.name}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300">
|
||||
Active
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
{Calendar.strftime(item.inserted_at, "%b %d, %Y")}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<.link
|
||||
navigate={~p"/__resources__/#{item.id}/edit"}
|
||||
class="text-xs px-2 py-1 rounded bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
{t("Edit")}
|
||||
</.link>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="delete"
|
||||
phx-value-id={item.id}
|
||||
data-confirm={t("Are you sure you want to delete this?")}
|
||||
class="text-xs px-2 py-1 rounded bg-red-50 text-red-700 hover:bg-red-100 dark:bg-red-900/20 dark:text-red-400 dark:hover:bg-red-900/40 transition-colors"
|
||||
>
|
||||
{t("Delete")}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<% end %>
|
||||
</Layouts.authenticated>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Router entry
|
||||
|
||||
```elixir
|
||||
# In lib/towerops_web/router.ex, inside the authenticated scope:
|
||||
live "/__resources__", __RESOURCE__Live.Index, :index
|
||||
```
|
||||
226
docs/templates/liveview-test.md
vendored
Normal file
226
docs/templates/liveview-test.md
vendored
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
# LiveView Test Template
|
||||
|
||||
Copy-pasteable template for testing a LiveView page following TowerOps conventions.
|
||||
|
||||
Replace `__RESOURCE__` with PascalCase resource (e.g., `Schedule`).
|
||||
Replace `__resource__` with snake_case (e.g., `schedule`).
|
||||
Replace `__resources__` with plural snake_case (e.g., `schedules`).
|
||||
Replace `__CONTEXT__` with context module name (e.g., `Schedules`).
|
||||
|
||||
---
|
||||
|
||||
## `test/towerops_web/live/__resource___live_test.exs`
|
||||
|
||||
```elixir
|
||||
defmodule ToweropsWeb.__RESOURCE__LiveTest do
|
||||
use ToweropsWeb.ConnCase
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
alias Towerops.__CONTEXT__
|
||||
|
||||
setup :register_and_log_in_user
|
||||
|
||||
setup %{conn: conn, user: user} do
|
||||
{:ok, organization} =
|
||||
Towerops.Organizations.create_organization(%{name: "Test Org", use_sites: true}, user.id)
|
||||
|
||||
conn = Plug.Conn.put_session(conn, :current_organization_id, organization.id)
|
||||
|
||||
%{conn: conn, organization: organization}
|
||||
end
|
||||
|
||||
describe "Index" do
|
||||
test "requires authentication" do
|
||||
conn = build_conn()
|
||||
{:error, redirect} = live(conn, ~p"/__resources__")
|
||||
|
||||
assert {:redirect, %{to: path}} = redirect
|
||||
assert path == ~p"/users/log-in"
|
||||
end
|
||||
|
||||
test "renders page title and header", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, ~p"/__resources__")
|
||||
|
||||
assert html =~ "__RESOURCES_TITLE__"
|
||||
end
|
||||
|
||||
test "shows empty state when no data exists", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, ~p"/__resources__")
|
||||
|
||||
assert html =~ "No __resources__ found"
|
||||
end
|
||||
|
||||
test "lists existing records", %{conn: conn, organization: org} do
|
||||
{:ok, _} =
|
||||
__CONTEXT__.create___resource__(%{
|
||||
name: "Test Item",
|
||||
organization_id: org.id
|
||||
})
|
||||
|
||||
{:ok, _view, html} = live(conn, ~p"/__resources__")
|
||||
|
||||
assert html =~ "Test Item"
|
||||
end
|
||||
|
||||
test "filters by status via URL params", %{conn: conn, organization: org} do
|
||||
{:ok, _} =
|
||||
__CONTEXT__.create___resource__(%{
|
||||
name: "Active Item",
|
||||
organization_id: org.id,
|
||||
enabled: true
|
||||
})
|
||||
|
||||
{:ok, _} =
|
||||
__CONTEXT__.create___resource__(%{
|
||||
name: "Archived Item",
|
||||
organization_id: org.id,
|
||||
enabled: false
|
||||
})
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/__resources__?filter=active")
|
||||
|
||||
html = render(view)
|
||||
assert html =~ "Active Item"
|
||||
refute html =~ "Archived Item"
|
||||
end
|
||||
|
||||
test "navigates to new form", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/__resources__")
|
||||
|
||||
assert view
|
||||
|> element("a", "New")
|
||||
|> render_click()
|
||||
|> follow_redirect(conn, ~p"/__resources__/new")
|
||||
end
|
||||
|
||||
test "deletes a record", %{conn: conn, organization: org} do
|
||||
{:ok, item} =
|
||||
__CONTEXT__.create___resource__(%{
|
||||
name: "To Delete",
|
||||
organization_id: org.id
|
||||
})
|
||||
|
||||
{:ok, view, html} = live(conn, ~p"/__resources__")
|
||||
assert html =~ "To Delete"
|
||||
|
||||
view
|
||||
|> element("button[phx-click='delete'][phx-value-id='#{item.id}']")
|
||||
|> render_click()
|
||||
|
||||
html = render(view)
|
||||
refute html =~ "To Delete"
|
||||
end
|
||||
end
|
||||
|
||||
describe "Form - New" do
|
||||
test "renders new form", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, ~p"/__resources__/new")
|
||||
|
||||
assert html =~ "New __RESOURCE_TITLE__"
|
||||
assert html =~ "Save"
|
||||
end
|
||||
|
||||
test "validates form on change", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/__resources__/new")
|
||||
|
||||
html =
|
||||
view
|
||||
|> form("#__resource__-form", __resource__: %{name: ""})
|
||||
|> render_change()
|
||||
|
||||
assert html =~ "can't be blank"
|
||||
end
|
||||
|
||||
test "creates record on valid submit", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/__resources__/new")
|
||||
|
||||
{:ok, _view, html} =
|
||||
view
|
||||
|> form("#__resource__-form", __resource__: %{name: "New Item"})
|
||||
|> render_submit()
|
||||
|> follow_redirect(conn)
|
||||
|
||||
assert html =~ "__RESOURCE_TITLE__ created"
|
||||
assert html =~ "New Item"
|
||||
end
|
||||
end
|
||||
|
||||
describe "Form - Edit" do
|
||||
test "renders edit form with existing data", %{conn: conn, organization: org} do
|
||||
{:ok, item} =
|
||||
__CONTEXT__.create___resource__(%{
|
||||
name: "Existing Item",
|
||||
organization_id: org.id
|
||||
})
|
||||
|
||||
{:ok, _view, html} = live(conn, ~p"/__resources__/#{item.id}/edit")
|
||||
|
||||
assert html =~ "Edit __RESOURCE_TITLE__"
|
||||
assert html =~ "Existing Item"
|
||||
end
|
||||
|
||||
test "updates record on valid submit", %{conn: conn, organization: org} do
|
||||
{:ok, item} =
|
||||
__CONTEXT__.create___resource__(%{
|
||||
name: "Original",
|
||||
organization_id: org.id
|
||||
})
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/__resources__/#{item.id}/edit")
|
||||
|
||||
{:ok, _view, html} =
|
||||
view
|
||||
|> form("#__resource__-form", __resource__: %{name: "Updated"})
|
||||
|> render_submit()
|
||||
|> follow_redirect(conn)
|
||||
|
||||
assert html =~ "__RESOURCE_TITLE__ updated"
|
||||
assert html =~ "Updated"
|
||||
end
|
||||
|
||||
test "shows delete button in edit mode", %{conn: conn, organization: org} do
|
||||
{:ok, item} =
|
||||
__CONTEXT__.create___resource__(%{
|
||||
name: "Delete Me",
|
||||
organization_id: org.id
|
||||
})
|
||||
|
||||
{:ok, _view, html} = live(conn, ~p"/__resources__/#{item.id}/edit")
|
||||
|
||||
assert html =~ "Delete"
|
||||
end
|
||||
|
||||
test "deletes record from edit form", %{conn: conn, organization: org} do
|
||||
{:ok, item} =
|
||||
__CONTEXT__.create___resource__(%{
|
||||
name: "Delete Me",
|
||||
organization_id: org.id
|
||||
})
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/__resources__/#{item.id}/edit")
|
||||
|
||||
{:ok, _view, html} =
|
||||
view
|
||||
|> element("button[phx-click='delete']")
|
||||
|> render_click()
|
||||
|> follow_redirect(conn, ~p"/__resources__")
|
||||
|
||||
assert html =~ "__RESOURCE_TITLE__ deleted"
|
||||
refute html =~ "Delete Me"
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- **`register_and_log_in_user`** is a shared setup helper defined in `ConnCase` — it creates a user and logs them in.
|
||||
- **`Plug.Conn.put_session(:current_organization_id)`** sets the org context for the session.
|
||||
- Use **`live/2`** to mount a LiveView, returns `{:ok, view, html}`.
|
||||
- Use **`render_click/1`**, **`render_change/1`**, **`render_submit/1`** for events.
|
||||
- Use **`element/2,3`** to target specific DOM elements.
|
||||
- Use **`follow_redirect/2`** after `push_navigate` actions.
|
||||
- Use **`render/1`** to get the current HTML after an event.
|
||||
|
|
@ -30,6 +30,13 @@ defmodule Towerops.OnCall do
|
|||
|> Repo.preload(overrides: :user, layers: [members: :user])
|
||||
end
|
||||
|
||||
def get_schedule!(id, organization_id) do
|
||||
Schedule
|
||||
|> where([s], s.id == ^id and s.organization_id == ^organization_id)
|
||||
|> Repo.one!()
|
||||
|> Repo.preload(overrides: :user, layers: [members: :user])
|
||||
end
|
||||
|
||||
def get_schedule(id) do
|
||||
case Repo.get(Schedule, id) do
|
||||
nil -> nil
|
||||
|
|
@ -114,6 +121,13 @@ defmodule Towerops.OnCall do
|
|||
|> Repo.preload(rules: :targets)
|
||||
end
|
||||
|
||||
def get_escalation_policy!(id, organization_id) do
|
||||
EscalationPolicy
|
||||
|> where([p], p.id == ^id and p.organization_id == ^organization_id)
|
||||
|> Repo.one!()
|
||||
|> Repo.preload(rules: :targets)
|
||||
end
|
||||
|
||||
def get_escalation_policy(id) do
|
||||
case Repo.get(EscalationPolicy, id) do
|
||||
nil -> nil
|
||||
|
|
|
|||
17
lib/towerops/query_helpers.ex
Normal file
17
lib/towerops/query_helpers.ex
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
defmodule Towerops.QueryHelpers do
|
||||
@moduledoc """
|
||||
Shared helpers for building safe Ecto queries.
|
||||
"""
|
||||
|
||||
@doc """
|
||||
Sanitizes a string for use in LIKE/ILIKE queries by escaping
|
||||
the wildcard characters `%` and `_`.
|
||||
"""
|
||||
@spec sanitize_like(String.t()) :: String.t()
|
||||
def sanitize_like(query) do
|
||||
query
|
||||
|> String.replace("\\", "\\\\")
|
||||
|> String.replace("%", "\\%")
|
||||
|> String.replace("_", "\\_")
|
||||
end
|
||||
end
|
||||
110
lib/towerops/url_validator.ex
Normal file
110
lib/towerops/url_validator.ex
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
defmodule Towerops.URLValidator do
|
||||
import Bitwise
|
||||
|
||||
@moduledoc """
|
||||
Validates URLs to prevent SSRF (Server-Side Request Forgery) attacks.
|
||||
|
||||
Blocks requests to internal/private IP ranges, localhost, and non-HTTP(S) schemes.
|
||||
"""
|
||||
|
||||
@private_ranges [
|
||||
# 127.0.0.0/8
|
||||
{127, 0, 0, 0, 8},
|
||||
# 10.0.0.0/8
|
||||
{10, 0, 0, 0, 8},
|
||||
# 172.16.0.0/12
|
||||
{172, 16, 0, 0, 12},
|
||||
# 192.168.0.0/16
|
||||
{192, 168, 0, 0, 16},
|
||||
# 169.254.0.0/16 (link-local)
|
||||
{169, 254, 0, 0, 16},
|
||||
# IPv6 loopback ::1
|
||||
:ipv6_loopback
|
||||
]
|
||||
|
||||
@doc """
|
||||
Validates a URL is safe for server-side requests.
|
||||
|
||||
Returns :ok or {:error, reason}.
|
||||
"""
|
||||
@spec validate(String.t()) :: :ok | {:error, String.t()}
|
||||
def validate(url) when is_binary(url) do
|
||||
with {:ok, uri} <- parse_url(url),
|
||||
:ok <- validate_scheme(uri),
|
||||
:ok <- validate_host(uri),
|
||||
:ok <- validate_ip(uri.host) do
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
def validate(_), do: {:error, "URL must be a string"}
|
||||
|
||||
defp parse_url(url) do
|
||||
case URI.parse(url) do
|
||||
%URI{scheme: nil} -> {:error, "URL must include a scheme (http or https)"}
|
||||
%URI{host: nil} -> {:error, "URL must include a host"}
|
||||
%URI{host: ""} -> {:error, "URL must include a host"}
|
||||
uri -> {:ok, uri}
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_scheme(%URI{scheme: scheme}) when scheme in ["http", "https"], do: :ok
|
||||
defp validate_scheme(_), do: {:error, "Only http and https schemes are allowed"}
|
||||
|
||||
defp validate_host(%URI{host: host}) do
|
||||
downcased = String.downcase(host)
|
||||
|
||||
if downcased == "localhost" or String.ends_with?(downcased, ".local") do
|
||||
{:error, "Requests to localhost are not allowed"}
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_ip(host) do
|
||||
case resolve_host(host) do
|
||||
{:ok, ip} ->
|
||||
if private_ip?(ip) do
|
||||
{:error, "Requests to private/internal IP addresses are not allowed"}
|
||||
else
|
||||
:ok
|
||||
end
|
||||
|
||||
# Can't resolve — let the HTTP client handle DNS errors
|
||||
{:error, _} ->
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
defp resolve_host(host) do
|
||||
case :inet.parse_address(String.to_charlist(host)) do
|
||||
{:ok, ip} -> {:ok, ip}
|
||||
{:error, _} ->
|
||||
case :inet.getaddr(String.to_charlist(host), :inet) do
|
||||
{:ok, ip} -> {:ok, ip}
|
||||
{:error, _} ->
|
||||
case :inet.getaddr(String.to_charlist(host), :inet6) do
|
||||
{:ok, ip} -> {:ok, ip}
|
||||
error -> error
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp private_ip?({0, 0, 0, 0, 0, 0, 0, 1}), do: true
|
||||
|
||||
defp private_ip?({a, b, c, d}) do
|
||||
Enum.any?(@private_ranges, fn
|
||||
:ipv6_loopback -> false
|
||||
{net_a, net_b, net_c, net_d, prefix} ->
|
||||
mask = bsl(0xFFFFFFFF, 32 - prefix) |> band(0xFFFFFFFF)
|
||||
ip_int = bsl(a, 24) + bsl(b, 16) + bsl(c, 8) + d
|
||||
net_int = bsl(net_a, 24) + bsl(net_b, 16) + bsl(net_c, 8) + net_d
|
||||
band(ip_int, mask) == band(net_int, mask)
|
||||
end)
|
||||
end
|
||||
|
||||
defp private_ip?(_), do: false
|
||||
|
||||
import Bitwise
|
||||
end
|
||||
|
|
@ -243,11 +243,7 @@ defmodule ToweropsWeb.Api.V1.DevicesController do
|
|||
end
|
||||
|
||||
defp default_organization_id(device_params, organization_id) do
|
||||
case Map.get(device_params, "organization_id") do
|
||||
nil -> Map.put(device_params, "organization_id", organization_id)
|
||||
"" -> Map.put(device_params, "organization_id", organization_id)
|
||||
_provided_id -> device_params
|
||||
end
|
||||
Map.put(device_params, "organization_id", organization_id)
|
||||
end
|
||||
|
||||
defp create_and_discover_device(conn, device_params, current_user) do
|
||||
|
|
|
|||
|
|
@ -161,9 +161,16 @@ defmodule ToweropsWeb.AgentLive.Index do
|
|||
|
||||
@impl true
|
||||
def handle_event("delete_agent", %{"id" => id}, socket) do
|
||||
organization = socket.assigns.current_scope.organization
|
||||
|
||||
# Verify the agent token belongs to the current org before deleting
|
||||
agent_token = Agents.get_agent_token!(id)
|
||||
|
||||
if agent_token.organization_id != organization.id do
|
||||
{:noreply, put_flash(socket, :error, t_equipment("Agent not found"))}
|
||||
else
|
||||
case Agents.delete_agent_token(id) do
|
||||
{:ok, _} ->
|
||||
organization = socket.assigns.current_scope.organization
|
||||
|
||||
# If the deleted agent was the global default, clear it
|
||||
if Scope.superuser?(socket.assigns.current_scope) &&
|
||||
|
|
@ -222,6 +229,7 @@ defmodule ToweropsWeb.AgentLive.Index do
|
|||
{:noreply, put_flash(socket, :error, t_equipment("Failed to delete agent"))}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("update_selected_global_default", %{"agent_token_id" => agent_token_id}, socket) do
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ defmodule ToweropsWeb.DeviceLive.Index do
|
|||
|
||||
case tab do
|
||||
"discovered" ->
|
||||
page = params |> Map.get("page", "1") |> String.to_integer()
|
||||
page = safe_to_integer(Map.get(params, "page", "1"), 1)
|
||||
per_page = 20
|
||||
|
||||
# Load all discovered devices - this is temporarily kept in memory for slicing
|
||||
|
|
@ -580,4 +580,15 @@ defmodule ToweropsWeb.DeviceLive.Index do
|
|||
[1, :ellipsis, current_page - 1, current_page, current_page + 1, :ellipsis, total_pages]
|
||||
end
|
||||
end
|
||||
|
||||
defp safe_to_integer(value, _default) when is_integer(value), do: value
|
||||
|
||||
defp safe_to_integer(value, default) when is_binary(value) do
|
||||
case Integer.parse(value) do
|
||||
{int, _} -> int
|
||||
:error -> default
|
||||
end
|
||||
end
|
||||
|
||||
defp safe_to_integer(_, default), do: default
|
||||
end
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ defmodule ToweropsWeb.DeviceLive.Show do
|
|||
# If no tab parameter, redirect to add it to the URL
|
||||
if Map.has_key?(params, "tab") do
|
||||
tab = params["tab"]
|
||||
page = params |> Map.get("page", "1") |> String.to_integer()
|
||||
page = safe_to_integer(Map.get(params, "page", "1"), 1)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|
|
@ -1805,4 +1805,15 @@ defmodule ToweropsWeb.DeviceLive.Show do
|
|||
mbps = kbps / 1000
|
||||
"#{Float.round(mbps, 1)} Mbps"
|
||||
end
|
||||
|
||||
defp safe_to_integer(value, _default) when is_integer(value), do: value
|
||||
|
||||
defp safe_to_integer(value, default) when is_binary(value) do
|
||||
case Integer.parse(value) do
|
||||
{int, _} -> int
|
||||
:error -> default
|
||||
end
|
||||
end
|
||||
|
||||
defp safe_to_integer(_, default), do: default
|
||||
end
|
||||
|
|
|
|||
|
|
@ -24,7 +24,8 @@ defmodule ToweropsWeb.EscalationPolicyLive.Form do
|
|||
|> assign(:form, to_form(changeset))}
|
||||
|
||||
:edit ->
|
||||
policy = OnCall.get_escalation_policy!(params["id"])
|
||||
org_id = socket.assigns.current_scope.organization.id
|
||||
policy = OnCall.get_escalation_policy!(params["id"], org_id)
|
||||
changeset = EscalationPolicy.changeset(policy, %{})
|
||||
|
||||
{:noreply,
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@ defmodule ToweropsWeb.EscalationPolicyLive.Show do
|
|||
|
||||
@impl true
|
||||
def mount(%{"id" => id}, _session, socket) do
|
||||
policy = OnCall.get_escalation_policy!(id)
|
||||
org_id = socket.assigns.current_scope.organization.id
|
||||
policy = OnCall.get_escalation_policy!(id, org_id)
|
||||
members = Organizations.list_organization_members(org_id)
|
||||
users = Enum.map(members, & &1.user)
|
||||
schedules = OnCall.list_schedules(org_id)
|
||||
|
|
|
|||
|
|
@ -101,6 +101,7 @@ defmodule ToweropsWeb.Org.SettingsLive do
|
|||
|
||||
@impl true
|
||||
def handle_event("save", %{"organization" => org_params}, socket) do
|
||||
require_admin_or_owner(socket, fn ->
|
||||
case Organizations.update_organization(socket.assigns.organization, org_params) do
|
||||
{:ok, organization} ->
|
||||
changeset = Organizations.change_organization(organization)
|
||||
|
|
@ -114,28 +115,34 @@ defmodule ToweropsWeb.Org.SettingsLive do
|
|||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
{:noreply, assign(socket, :form, to_form(changeset))}
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("apply_snmp_to_all", _params, socket) do
|
||||
require_admin_or_owner(socket, fn ->
|
||||
{count, _} = Organizations.apply_snmp_config_to_all_equipment(socket.assigns.organization.id)
|
||||
|
||||
{:noreply,
|
||||
put_flash(socket, :info, t("Applied SNMP configuration to %{count} device records across all sites", count: count))}
|
||||
end)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("apply_agent_to_all", _params, socket) do
|
||||
require_admin_or_owner(socket, fn ->
|
||||
{count, _} = Organizations.apply_agent_to_all_equipment(socket.assigns.organization.id)
|
||||
|
||||
{:noreply,
|
||||
put_flash(socket, :info, t("Applied default agent to %{count} device records across all sites", count: count))}
|
||||
end)
|
||||
end
|
||||
|
||||
# === Members tab events ===
|
||||
|
||||
@impl true
|
||||
def handle_event("send_invitation", %{"email" => email, "role" => role}, socket) do
|
||||
require_admin_or_owner(socket, fn ->
|
||||
organization = socket.assigns.organization
|
||||
user = socket.assigns.current_scope.user
|
||||
|
||||
|
|
@ -166,6 +173,7 @@ defmodule ToweropsWeb.Org.SettingsLive do
|
|||
{:noreply,
|
||||
put_flash(socket, :error, t("Failed to send invitation: %{errors}", errors: error_messages(changeset)))}
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
@impl true
|
||||
|
|
@ -187,6 +195,7 @@ defmodule ToweropsWeb.Org.SettingsLive do
|
|||
|
||||
@impl true
|
||||
def handle_event("remove_member", %{"user-id" => user_id}, socket) do
|
||||
require_admin_or_owner(socket, fn ->
|
||||
organization = socket.assigns.organization
|
||||
|
||||
case Organizations.remove_member(organization.id, user_id) do
|
||||
|
|
@ -202,10 +211,12 @@ defmodule ToweropsWeb.Org.SettingsLive do
|
|||
{:error, _} ->
|
||||
{:noreply, put_flash(socket, :error, t("Failed to remove member"))}
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("change_role", %{"user-id" => user_id, "role" => role}, socket) do
|
||||
require_admin_or_owner(socket, fn ->
|
||||
organization = socket.assigns.organization
|
||||
|
||||
case Organizations.update_member_role(organization.id, user_id, role) do
|
||||
|
|
@ -221,6 +232,7 @@ defmodule ToweropsWeb.Org.SettingsLive do
|
|||
{:error, _} ->
|
||||
{:noreply, put_flash(socket, :error, t("Failed to update role"))}
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
@impl true
|
||||
|
|
@ -851,4 +863,14 @@ defmodule ToweropsWeb.Org.SettingsLive do
|
|||
defp extract_credentials_from_params(params, _provider) do
|
||||
%{"api_key" => Map.get(params, "api_key", "")}
|
||||
end
|
||||
|
||||
defp require_admin_or_owner(socket, fun) do
|
||||
role = socket.assigns.membership.role
|
||||
|
||||
if role in [:owner, :admin] do
|
||||
fun.()
|
||||
else
|
||||
{:noreply, put_flash(socket, :error, t("You don't have permission to perform this action"))}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -24,7 +24,8 @@ defmodule ToweropsWeb.ScheduleLive.Form do
|
|||
|> assign(:form, to_form(changeset))}
|
||||
|
||||
:edit ->
|
||||
schedule = OnCall.get_schedule!(params["id"])
|
||||
org_id = socket.assigns.current_scope.organization.id
|
||||
schedule = OnCall.get_schedule!(params["id"], org_id)
|
||||
changeset = Schedule.changeset(schedule, %{})
|
||||
|
||||
{:noreply,
|
||||
|
|
|
|||
|
|
@ -16,9 +16,9 @@ defmodule ToweropsWeb.ScheduleLive.Show do
|
|||
|
||||
@impl true
|
||||
def mount(%{"id" => id}, _session, socket) do
|
||||
schedule = OnCall.get_schedule!(id)
|
||||
on_call = OnCall.who_is_on_call(schedule.id)
|
||||
org_id = socket.assigns.current_scope.organization.id
|
||||
schedule = OnCall.get_schedule!(id, org_id)
|
||||
on_call = OnCall.who_is_on_call(schedule.id)
|
||||
members = Organizations.list_organization_members(org_id)
|
||||
users = Enum.map(members, & &1.user)
|
||||
today = Date.utc_today()
|
||||
|
|
|
|||
|
|
@ -29,11 +29,52 @@ defmodule ToweropsWeb.Plugs.BruteForceProtection do
|
|||
- Router (needs to intercept requests before routing)
|
||||
"""
|
||||
|
||||
import Plug.Conn
|
||||
|
||||
alias Towerops.Security.BruteForce
|
||||
alias ToweropsWeb.RemoteIp
|
||||
|
||||
require Logger
|
||||
|
||||
def init(opts), do: opts
|
||||
|
||||
def call(conn, _opts) do
|
||||
# TEMPORARILY DISABLED - brute force protection bypassed
|
||||
# Cloudflare handles security at the edge
|
||||
ip_address = RemoteIp.from_conn(conn)
|
||||
|
||||
cond do
|
||||
# Skip for authenticated users
|
||||
conn.assigns[:current_user] != nil ->
|
||||
conn
|
||||
|
||||
# Skip for agent WebSocket connections (authenticated at channel join)
|
||||
String.starts_with?(conn.request_path, "/socket/agent") ->
|
||||
conn
|
||||
|
||||
# Check if IP is whitelisted
|
||||
BruteForce.check_whitelist(ip_address) ->
|
||||
conn
|
||||
|
||||
# Check if IP is currently banned
|
||||
true ->
|
||||
case BruteForce.check_ban_status(ip_address) do
|
||||
:allowed ->
|
||||
# Register a callback to track 404s after the response is sent
|
||||
register_before_send(conn, fn response_conn ->
|
||||
if response_conn.status == 404 do
|
||||
Towerops.Security.FourOhFourTracker.record_404(ip_address, conn.request_path)
|
||||
end
|
||||
|
||||
response_conn
|
||||
end)
|
||||
|
||||
{:blocked, banned_until} ->
|
||||
Logger.warning("Blocked request from banned IP: #{ip_address}, banned until: #{inspect(banned_until)}")
|
||||
|
||||
conn
|
||||
|> put_resp_content_type("text/plain")
|
||||
|> send_resp(403, "Access denied")
|
||||
|> halt()
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
98
tools/dev.sh
Executable file
98
tools/dev.sh
Executable file
|
|
@ -0,0 +1,98 @@
|
|||
#!/usr/bin/env bash
|
||||
# TowerOps development helper script
|
||||
# Usage: ./tools/dev.sh <command> [args...]
|
||||
set -e
|
||||
|
||||
export PATH="/nix/var/nix/profiles/default/bin:$PATH"
|
||||
|
||||
PROJ_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$PROJ_DIR"
|
||||
|
||||
NIX_CMD="nix develop .#default --command"
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
TowerOps Dev Helper
|
||||
|
||||
Usage: ./tools/dev.sh <command> [args...]
|
||||
|
||||
Commands:
|
||||
compile Compile the project
|
||||
test [path] Run tests (optionally for a specific path)
|
||||
test-file <f> Run tests for a specific file
|
||||
push [msg] Git add, commit, push to origin main
|
||||
format Run mix format
|
||||
routes Show Phoenix routes
|
||||
migrate Run migrations (dev + test)
|
||||
reset-db Reset the database
|
||||
server Start services + Phoenix server
|
||||
deps Fetch dependencies
|
||||
EOF
|
||||
exit 1
|
||||
}
|
||||
|
||||
cmd_compile() {
|
||||
$NIX_CMD mix compile "$@"
|
||||
}
|
||||
|
||||
cmd_test() {
|
||||
$NIX_CMD env -u DATABASE_URL MIX_ENV=test mix test "$@"
|
||||
}
|
||||
|
||||
cmd_test_file() {
|
||||
if [ -z "$1" ]; then
|
||||
echo "Error: test-file requires a file path"
|
||||
echo "Usage: ./tools/dev.sh test-file test/path/to/test.exs"
|
||||
exit 1
|
||||
fi
|
||||
$NIX_CMD env -u DATABASE_URL MIX_ENV=test mix test "$1"
|
||||
}
|
||||
|
||||
cmd_push() {
|
||||
local msg="${1:-update}"
|
||||
git add -A
|
||||
git commit -m "$msg"
|
||||
git push origin main
|
||||
}
|
||||
|
||||
cmd_format() {
|
||||
$NIX_CMD mix format "$@"
|
||||
}
|
||||
|
||||
cmd_routes() {
|
||||
$NIX_CMD mix phx.routes "$@"
|
||||
}
|
||||
|
||||
cmd_migrate() {
|
||||
echo "==> Migrating dev database..."
|
||||
$NIX_CMD mix ecto.migrate
|
||||
echo "==> Migrating test database..."
|
||||
$NIX_CMD env -u DATABASE_URL MIX_ENV=test mix ecto.migrate
|
||||
}
|
||||
|
||||
cmd_reset_db() {
|
||||
$NIX_CMD mix ecto.reset "$@"
|
||||
}
|
||||
|
||||
cmd_server() {
|
||||
$NIX_CMD bash -c "start-services && mix phx.server"
|
||||
}
|
||||
|
||||
cmd_deps() {
|
||||
$NIX_CMD mix deps.get "$@"
|
||||
}
|
||||
|
||||
# --- Main dispatch ---
|
||||
case "${1:-}" in
|
||||
compile) shift; cmd_compile "$@" ;;
|
||||
test) shift; cmd_test "$@" ;;
|
||||
test-file) shift; cmd_test_file "$@" ;;
|
||||
push) shift; cmd_push "$@" ;;
|
||||
format) shift; cmd_format "$@" ;;
|
||||
routes) shift; cmd_routes "$@" ;;
|
||||
migrate) shift; cmd_migrate "$@" ;;
|
||||
reset-db) shift; cmd_reset_db "$@" ;;
|
||||
server) shift; cmd_server "$@" ;;
|
||||
deps) shift; cmd_deps "$@" ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
86
tools/test-bootstrap.sh
Executable file
86
tools/test-bootstrap.sh
Executable file
|
|
@ -0,0 +1,86 @@
|
|||
#!/usr/bin/env bash
|
||||
# TowerOps Test Environment Bootstrap
|
||||
# Ensures PostgreSQL is running, test DB exists, and migrations are current.
|
||||
# Run inside or outside of `nix develop` — it works either way.
|
||||
set -e
|
||||
|
||||
export PATH="/nix/var/nix/profiles/default/bin:$PATH"
|
||||
|
||||
PROJ_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$PROJ_DIR"
|
||||
|
||||
NIX_CMD="nix develop .#default --command"
|
||||
|
||||
GREEN='\033[0;32m'
|
||||
RED='\033[0;31m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
ok() { echo -e "${GREEN}✓${NC} $1"; }
|
||||
fail() { echo -e "${RED}✗${NC} $1"; }
|
||||
info() { echo -e "${YELLOW}→${NC} $1"; }
|
||||
|
||||
echo "=== TowerOps Test Bootstrap ==="
|
||||
echo ""
|
||||
|
||||
# --- 1. Check/start PostgreSQL ---
|
||||
info "Checking PostgreSQL..."
|
||||
if pg_isready -h localhost -p 5432 > /dev/null 2>&1; then
|
||||
ok "PostgreSQL is running"
|
||||
else
|
||||
info "PostgreSQL not running — starting services..."
|
||||
$NIX_CMD start-services
|
||||
sleep 2
|
||||
if pg_isready -h localhost -p 5432 > /dev/null 2>&1; then
|
||||
ok "PostgreSQL started successfully"
|
||||
else
|
||||
fail "Could not start PostgreSQL"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- 2. Create test database if it doesn't exist ---
|
||||
info "Checking test database..."
|
||||
|
||||
# Unset DATABASE_URL to avoid the unexpanded $USER issue from nix shell
|
||||
DB_EXISTS=$($NIX_CMD env -u DATABASE_URL bash -c \
|
||||
"MIX_ENV=test mix ecto.create 2>&1" || true)
|
||||
|
||||
if echo "$DB_EXISTS" | grep -q "already created"; then
|
||||
ok "Test database already exists"
|
||||
elif echo "$DB_EXISTS" | grep -q "has been created"; then
|
||||
ok "Test database created"
|
||||
else
|
||||
# If it failed for another reason, show the output
|
||||
if echo "$DB_EXISTS" | grep -qi "error"; then
|
||||
fail "Problem with test database:"
|
||||
echo "$DB_EXISTS"
|
||||
exit 1
|
||||
else
|
||||
ok "Test database ready"
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- 3. Run pending migrations ---
|
||||
info "Running test migrations..."
|
||||
MIGRATE_OUT=$($NIX_CMD env -u DATABASE_URL bash -c \
|
||||
"MIX_ENV=test mix ecto.migrate 2>&1" || true)
|
||||
|
||||
if echo "$MIGRATE_OUT" | grep -q "already up"; then
|
||||
ok "All migrations already applied"
|
||||
elif echo "$MIGRATE_OUT" | grep -qi "migrat"; then
|
||||
ok "Migrations applied"
|
||||
echo "$MIGRATE_OUT" | grep -i "migrat" | head -5
|
||||
else
|
||||
ok "Migrations up to date"
|
||||
fi
|
||||
|
||||
# --- 4. Summary ---
|
||||
echo ""
|
||||
echo "=== Status ==="
|
||||
ok "PostgreSQL: running on localhost:5432"
|
||||
ok "Test DB: ready"
|
||||
ok "Migrations: current"
|
||||
echo ""
|
||||
echo "Run tests with: ./tools/dev.sh test"
|
||||
echo " or directly: nix develop --command env -u DATABASE_URL MIX_ENV=test mix test"
|
||||
Loading…
Add table
Reference in a new issue