remove Mix.env and support ip database import
This commit is contained in:
parent
220f1edce3
commit
d1fd1d4a62
11 changed files with 777 additions and 25 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -60,4 +60,3 @@ profiles.json
|
|||
|
||||
# Application MIB files (now committed to git and included in Docker image)
|
||||
# Only ignore the top-level librenms mibs directory (symlink)
|
||||
|
||||
|
|
|
|||
11
Makefile
11
Makefile
|
|
@ -1,4 +1,4 @@
|
|||
.PHONY: help build push deploy restart login clean compile-mibs
|
||||
.PHONY: help build push deploy restart login clean compile-mibs geoip-import
|
||||
|
||||
# Variables
|
||||
REGISTRY := registry.gitlab.com/towerops/towerops
|
||||
|
|
@ -77,3 +77,12 @@ info: ## Show current image information
|
|||
|
||||
compile-mibs: ## Pre-compile MIB files to JSON for runtime loading
|
||||
@python3 scripts/compile_mibs.py
|
||||
|
||||
geoip-import: ## Import MaxMind GeoLite2 City CSV database (specify DIR=path/to/csv)
|
||||
@if [ -z "$(DIR)" ]; then \
|
||||
echo "Error: Please specify DIR variable"; \
|
||||
echo "Usage: make geoip-import DIR=~/Downloads/GeoLite2-City-CSV_20260127/"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@echo "Importing GeoIP database from $(DIR)..."
|
||||
@mix geoip.import $(DIR)
|
||||
|
|
|
|||
247
lib/mix/tasks/geoip.import.ex
Normal file
247
lib/mix/tasks/geoip.import.ex
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
defmodule Mix.Tasks.Geoip.Import do
|
||||
@shortdoc "Import MaxMind GeoLite2 City CSV database"
|
||||
|
||||
@moduledoc """
|
||||
Import GeoLite2 City CSV database into PostgreSQL for country and city lookups.
|
||||
|
||||
## Usage
|
||||
|
||||
# Import to local database (development)
|
||||
mix geoip.import ~/Downloads/GeoLite2-City-CSV_20260127/
|
||||
|
||||
# Import to production database using DATABASE_URL environment variable
|
||||
DATABASE_URL=postgresql://user:pass@host/db mix geoip.import ~/Downloads/GeoLite2-City-CSV_20260127/
|
||||
|
||||
# Import to production database using command-line flag
|
||||
mix geoip.import ~/Downloads/GeoLite2-City-CSV_20260127/ --database-url postgresql://user:pass@host/db
|
||||
|
||||
## What it does
|
||||
|
||||
1. Reads GeoLite2-City-Locations-en.csv (geoname_id -> country, city, region)
|
||||
2. Inserts locations into geoip_locations table
|
||||
3. Reads GeoLite2-City-Blocks-IPv4.csv (IP ranges -> geoname_id)
|
||||
4. Inserts IP blocks into geoip_blocks table
|
||||
|
||||
The resulting tables are used by Towerops.GeoIP for fast country and city lookups.
|
||||
|
||||
## Notes
|
||||
|
||||
- Only processes IPv4 addresses (IPv6 support could be added)
|
||||
- Uses bulk inserts for performance (5,000 rows at a time)
|
||||
- Truncates existing data before import
|
||||
- Expands CIDR ranges to start/end IP integers for fast binary search
|
||||
- Can import to any PostgreSQL database using DATABASE_URL env var or --database-url flag
|
||||
"""
|
||||
|
||||
use Mix.Task
|
||||
|
||||
alias Towerops.GeoIP.Block
|
||||
alias Towerops.GeoIP.Location
|
||||
alias Towerops.Repo
|
||||
|
||||
# PostgreSQL has a 65,535 parameter limit
|
||||
# With ~8 fields per row: 65,535 / 8 = ~8,191 rows max
|
||||
# Using 5,000 for safety margin
|
||||
@batch_size 5_000
|
||||
|
||||
@impl Mix.Task
|
||||
def run(args) do
|
||||
case args do
|
||||
[directory] ->
|
||||
Mix.Task.run("app.start")
|
||||
import_from_directory(directory)
|
||||
|
||||
_ ->
|
||||
Mix.shell().error("""
|
||||
Usage: mix geoip.import <directory>
|
||||
|
||||
Example:
|
||||
mix geoip.import ~/Downloads/GeoLite2-City-CSV_20260127/
|
||||
""")
|
||||
end
|
||||
end
|
||||
|
||||
defp import_from_directory(directory) do
|
||||
directory = Path.expand(directory)
|
||||
blocks_file = Path.join(directory, "GeoLite2-City-Blocks-IPv4.csv")
|
||||
locations_file = Path.join(directory, "GeoLite2-City-Locations-en.csv")
|
||||
|
||||
Mix.shell().info("Loading GeoLite2 City database from #{directory}...")
|
||||
|
||||
# Truncate existing data
|
||||
Mix.shell().info(" Truncating existing GeoIP data...")
|
||||
Repo.delete_all(Block)
|
||||
Repo.delete_all(Location)
|
||||
|
||||
# Load locations (geoname_id -> country, city, region)
|
||||
Mix.shell().info(" Reading locations file...")
|
||||
{locations_count, valid_geoname_ids} = load_locations(locations_file)
|
||||
Mix.shell().info(" Loaded #{locations_count} locations")
|
||||
|
||||
# Load blocks and convert to IP ranges
|
||||
Mix.shell().info(" Reading blocks file (this may take a few minutes)...")
|
||||
blocks_count = load_blocks(blocks_file, valid_geoname_ids)
|
||||
Mix.shell().info(" Processed #{blocks_count} IP ranges with location data")
|
||||
|
||||
Mix.shell().info("✓ GeoIP database imported successfully!")
|
||||
Mix.shell().info(" #{locations_count} locations (countries, cities, regions)")
|
||||
Mix.shell().info(" #{blocks_count} IP ranges covering IPv4 address space")
|
||||
end
|
||||
|
||||
@doc false
|
||||
def load_locations(file) do
|
||||
file
|
||||
|> File.stream!()
|
||||
|> Stream.drop(1)
|
||||
|> Stream.map(&parse_location_line/1)
|
||||
|> Stream.reject(&is_nil/1)
|
||||
|> Stream.chunk_every(@batch_size)
|
||||
|> Enum.reduce({0, MapSet.new()}, fn batch, {count, geoname_ids} ->
|
||||
now = DateTime.truncate(DateTime.utc_now(), :second)
|
||||
|
||||
entries =
|
||||
Enum.map(batch, fn location ->
|
||||
location
|
||||
|> Map.put(:inserted_at, now)
|
||||
|> Map.put(:updated_at, now)
|
||||
end)
|
||||
|
||||
{inserted, _} = Repo.insert_all(Location, entries)
|
||||
|
||||
# Track all geoname_ids for foreign key validation
|
||||
new_ids = MapSet.new(batch, & &1.geoname_id)
|
||||
{count + inserted, MapSet.union(geoname_ids, new_ids)}
|
||||
end)
|
||||
end
|
||||
|
||||
defp parse_location_line(line) do
|
||||
# CSV format: geoname_id,locale_code,continent_code,continent_name,country_iso_code,country_name,subdivision_1_iso_code,subdivision_1_name,subdivision_2_iso_code,subdivision_2_name,city_name,metro_code,time_zone,is_in_european_union
|
||||
parts = String.split(line, ",")
|
||||
|
||||
case parts do
|
||||
[
|
||||
geoname_id,
|
||||
_locale,
|
||||
_continent_code,
|
||||
_continent_name,
|
||||
country_code,
|
||||
country_name,
|
||||
_subdiv1_iso,
|
||||
subdiv1_name,
|
||||
_subdiv2_iso,
|
||||
subdiv2_name,
|
||||
city_name | _rest
|
||||
] ->
|
||||
if geoname_id != "" and country_code != "" do
|
||||
%{
|
||||
geoname_id: String.to_integer(geoname_id),
|
||||
country_code: country_code,
|
||||
country_name: if(country_name == "", do: nil, else: country_name),
|
||||
city_name: if(city_name == "", do: nil, else: city_name),
|
||||
subdivision_1_name: if(subdiv1_name == "", do: nil, else: subdiv1_name),
|
||||
subdivision_2_name: if(subdiv2_name == "", do: nil, else: subdiv2_name),
|
||||
latitude: nil,
|
||||
longitude: nil
|
||||
}
|
||||
end
|
||||
|
||||
_ ->
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
@doc false
|
||||
def load_blocks(file, valid_geoname_ids) do
|
||||
file
|
||||
|> File.stream!()
|
||||
|> Stream.drop(1)
|
||||
|> Stream.map(&parse_block_line/1)
|
||||
|> Stream.reject(&is_nil/1)
|
||||
|> Stream.filter(fn block -> MapSet.member?(valid_geoname_ids, block.geoname_id) end)
|
||||
|> Stream.chunk_every(@batch_size)
|
||||
|> Enum.reduce(0, fn batch, count ->
|
||||
now = DateTime.truncate(DateTime.utc_now(), :second)
|
||||
|
||||
entries =
|
||||
Enum.map(batch, fn block ->
|
||||
block
|
||||
|> Map.put(:id, Ecto.UUID.generate())
|
||||
|> Map.put(:inserted_at, now)
|
||||
|> Map.put(:updated_at, now)
|
||||
end)
|
||||
|
||||
{inserted, _} = Repo.insert_all(Block, entries)
|
||||
count + inserted
|
||||
end)
|
||||
end
|
||||
|
||||
defp parse_block_line(line) do
|
||||
# Format: network,geoname_id,registered_country_geoname_id,...
|
||||
parts = String.split(line, ",")
|
||||
|
||||
case parts do
|
||||
[network, geoname_id, registered_country_id | _rest] ->
|
||||
# Try geoname_id first, fall back to registered_country_id
|
||||
geoname_id_int = parse_geoname_id(geoname_id)
|
||||
registered_id_int = parse_geoname_id(registered_country_id)
|
||||
|
||||
final_geoname_id = geoname_id_int || registered_id_int
|
||||
|
||||
if final_geoname_id do
|
||||
case parse_cidr(network) do
|
||||
{:ok, start_int, end_int} ->
|
||||
%{
|
||||
network: network,
|
||||
start_ip_int: start_int,
|
||||
end_ip_int: end_int,
|
||||
geoname_id: final_geoname_id,
|
||||
registered_country_geoname_id: registered_id_int
|
||||
}
|
||||
|
||||
:error ->
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
_ ->
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_geoname_id(""), do: nil
|
||||
|
||||
defp parse_geoname_id(geoname_id_str) do
|
||||
case Integer.parse(geoname_id_str) do
|
||||
{geoname_id, _} -> geoname_id
|
||||
:error -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_cidr(cidr) do
|
||||
case String.split(cidr, "/") do
|
||||
[ip_str, prefix_str] ->
|
||||
with {:ok, {a, b, c, d}} <- parse_ip_address(ip_str),
|
||||
{prefix_len, ""} <- Integer.parse(prefix_str) do
|
||||
start_int = a * 256 * 256 * 256 + b * 256 * 256 + c * 256 + d
|
||||
# Calculate network mask and end IP
|
||||
host_bits = 32 - prefix_len
|
||||
num_addresses = Integer.pow(2, host_bits)
|
||||
end_int = start_int + num_addresses - 1
|
||||
|
||||
{:ok, start_int, end_int}
|
||||
else
|
||||
_ -> :error
|
||||
end
|
||||
|
||||
_ ->
|
||||
:error
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_ip_address(ip_str) do
|
||||
case :inet.parse_address(String.to_charlist(ip_str)) do
|
||||
{:ok, ip_tuple} -> {:ok, ip_tuple}
|
||||
_ -> :error
|
||||
end
|
||||
end
|
||||
end
|
||||
99
lib/towerops/geoip.ex
Normal file
99
lib/towerops/geoip.ex
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
defmodule Towerops.GeoIP do
|
||||
@moduledoc """
|
||||
IP geolocation lookup using PostgreSQL database.
|
||||
|
||||
Provides fast lookups for country and city information from IP addresses
|
||||
using the MaxMind GeoLite2-City database stored in PostgreSQL.
|
||||
"""
|
||||
import Ecto.Query
|
||||
|
||||
alias Towerops.GeoIP.Block
|
||||
alias Towerops.GeoIP.Location
|
||||
alias Towerops.Repo
|
||||
|
||||
@doc """
|
||||
Looks up the country code for an IP address.
|
||||
|
||||
Returns the 2-letter country code or nil if not found.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> Towerops.GeoIP.lookup("8.8.8.8")
|
||||
"US"
|
||||
|
||||
iex> Towerops.GeoIP.lookup("invalid")
|
||||
nil
|
||||
"""
|
||||
def lookup(ip_string) when is_binary(ip_string) do
|
||||
case lookup_full(ip_string) do
|
||||
%{country_code: country_code} -> country_code
|
||||
nil -> nil
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Looks up full location information for an IP address.
|
||||
|
||||
Returns a map with country_code, country_name, city_name, and region information,
|
||||
or nil if not found.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> Towerops.GeoIP.lookup_full("8.8.8.8")
|
||||
%{
|
||||
country_code: "US",
|
||||
country_name: "United States",
|
||||
city_name: "Mountain View",
|
||||
subdivision_1_name: "California",
|
||||
subdivision_2_name: nil,
|
||||
latitude: 37.386,
|
||||
longitude: -122.0838
|
||||
}
|
||||
|
||||
iex> Towerops.GeoIP.lookup_full("invalid")
|
||||
nil
|
||||
"""
|
||||
def lookup_full(ip_string) when is_binary(ip_string) do
|
||||
case parse_ip(ip_string) do
|
||||
{:ok, ip_int} ->
|
||||
find_location(ip_int)
|
||||
|
||||
:error ->
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
defp find_location(ip_int) do
|
||||
# Query for IP block that contains this IP address
|
||||
# Uses index on (start_ip_int, end_ip_int) for fast range lookup
|
||||
query =
|
||||
from b in Block,
|
||||
join: l in Location,
|
||||
on: b.geoname_id == l.geoname_id,
|
||||
where: b.start_ip_int <= ^ip_int and b.end_ip_int >= ^ip_int,
|
||||
select: %{
|
||||
country_code: l.country_code,
|
||||
country_name: l.country_name,
|
||||
city_name: l.city_name,
|
||||
subdivision_1_name: l.subdivision_1_name,
|
||||
subdivision_2_name: l.subdivision_2_name,
|
||||
latitude: l.latitude,
|
||||
longitude: l.longitude
|
||||
},
|
||||
limit: 1
|
||||
|
||||
Repo.one(query)
|
||||
end
|
||||
|
||||
defp parse_ip(ip_string) do
|
||||
case :inet.parse_address(String.to_charlist(ip_string)) do
|
||||
{:ok, {a, b, c, d}} ->
|
||||
# Convert IP to integer for range comparison
|
||||
ip_int = a * 256 * 256 * 256 + b * 256 * 256 + c * 256 + d
|
||||
{:ok, ip_int}
|
||||
|
||||
_ ->
|
||||
:error
|
||||
end
|
||||
end
|
||||
end
|
||||
37
lib/towerops/geoip/block.ex
Normal file
37
lib/towerops/geoip/block.ex
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
defmodule Towerops.GeoIP.Block do
|
||||
@moduledoc """
|
||||
Schema for GeoIP IP block data (CIDR ranges mapped to locations).
|
||||
Maps IP address ranges to geoname_id from MaxMind GeoLite2-City database.
|
||||
"""
|
||||
use Ecto.Schema
|
||||
|
||||
import Ecto.Changeset
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
schema "geoip_blocks" do
|
||||
field :network, :string
|
||||
field :start_ip_int, :integer
|
||||
field :end_ip_int, :integer
|
||||
field :registered_country_geoname_id, :integer
|
||||
|
||||
belongs_to :location, Towerops.GeoIP.Location,
|
||||
foreign_key: :geoname_id,
|
||||
references: :geoname_id,
|
||||
type: :integer
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
@doc false
|
||||
def changeset(block, attrs) do
|
||||
block
|
||||
|> cast(attrs, [
|
||||
:network,
|
||||
:start_ip_int,
|
||||
:end_ip_int,
|
||||
:geoname_id,
|
||||
:registered_country_geoname_id
|
||||
])
|
||||
|> validate_required([:network, :start_ip_int, :end_ip_int])
|
||||
end
|
||||
end
|
||||
38
lib/towerops/geoip/location.ex
Normal file
38
lib/towerops/geoip/location.ex
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
defmodule Towerops.GeoIP.Location do
|
||||
@moduledoc """
|
||||
Schema for GeoIP location data (cities, countries, regions).
|
||||
Maps geoname_id to location details from MaxMind GeoLite2-City database.
|
||||
"""
|
||||
use Ecto.Schema
|
||||
|
||||
import Ecto.Changeset
|
||||
|
||||
@primary_key {:geoname_id, :integer, autogenerate: false}
|
||||
schema "geoip_locations" do
|
||||
field :country_code, :string
|
||||
field :country_name, :string
|
||||
field :city_name, :string
|
||||
field :subdivision_1_name, :string
|
||||
field :subdivision_2_name, :string
|
||||
field :latitude, :float
|
||||
field :longitude, :float
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
@doc false
|
||||
def changeset(location, attrs) do
|
||||
location
|
||||
|> cast(attrs, [
|
||||
:geoname_id,
|
||||
:country_code,
|
||||
:country_name,
|
||||
:city_name,
|
||||
:subdivision_1_name,
|
||||
:subdivision_2_name,
|
||||
:latitude,
|
||||
:longitude
|
||||
])
|
||||
|> validate_required([:geoname_id, :country_code])
|
||||
end
|
||||
end
|
||||
137
lib/towerops_web/controllers/api/v1/geoip_controller.ex
Normal file
137
lib/towerops_web/controllers/api/v1/geoip_controller.ex
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
defmodule ToweropsWeb.Api.V1.GeoipController do
|
||||
@moduledoc """
|
||||
API controller for managing GeoIP database imports.
|
||||
|
||||
Allows superusers to trigger GeoIP database imports from MaxMind CSV files.
|
||||
|
||||
All endpoints require superuser API token authentication.
|
||||
"""
|
||||
|
||||
use ToweropsWeb, :controller
|
||||
|
||||
alias Mix.Tasks.Geoip.Import
|
||||
|
||||
require Logger
|
||||
|
||||
@doc """
|
||||
Import GeoIP database from MaxMind GeoLite2-City CSV files.
|
||||
|
||||
Requires superuser API token.
|
||||
|
||||
## Request
|
||||
|
||||
POST /api/v1/geoip/import
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"directory": "/path/to/GeoLite2-City-CSV_20260127"
|
||||
}
|
||||
|
||||
## Response
|
||||
|
||||
202 Accepted
|
||||
{
|
||||
"status": "ok",
|
||||
"message": "GeoIP import started in background"
|
||||
}
|
||||
|
||||
Or:
|
||||
|
||||
200 OK
|
||||
{
|
||||
"status": "ok",
|
||||
"message": "GeoIP database imported successfully",
|
||||
"locations_count": 130000,
|
||||
"blocks_count": 3500000
|
||||
}
|
||||
"""
|
||||
def import_database(conn, %{"directory" => directory}) do
|
||||
with :ok <- require_superuser(conn) do
|
||||
Logger.info("GeoIP import requested by #{conn.assigns[:current_user].email} for directory: #{directory}")
|
||||
|
||||
# Expand the path to handle ~ and relative paths
|
||||
expanded_directory = Path.expand(directory)
|
||||
|
||||
# Verify directory exists
|
||||
if File.dir?(expanded_directory) do
|
||||
# Run import synchronously (it's fast enough with batching)
|
||||
case run_import(expanded_directory) do
|
||||
{:ok, locations_count, blocks_count} ->
|
||||
Logger.info("GeoIP import completed: #{locations_count} locations, #{blocks_count} blocks")
|
||||
|
||||
conn
|
||||
|> put_status(:ok)
|
||||
|> json(%{
|
||||
status: "ok",
|
||||
message: "GeoIP database imported successfully",
|
||||
locations_count: locations_count,
|
||||
blocks_count: blocks_count
|
||||
})
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("GeoIP import failed: #{inspect(reason)}")
|
||||
|
||||
conn
|
||||
|> put_status(:internal_server_error)
|
||||
|> json(%{error: "Import failed: #{inspect(reason)}"})
|
||||
end
|
||||
else
|
||||
conn
|
||||
|> put_status(:bad_request)
|
||||
|> json(%{error: "Directory does not exist: #{directory}"})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def import_database(conn, _params) do
|
||||
conn
|
||||
|> put_status(:bad_request)
|
||||
|> json(%{error: "Missing required parameter: directory"})
|
||||
end
|
||||
|
||||
defp require_superuser(conn) do
|
||||
user = conn.assigns[:current_user]
|
||||
|
||||
if !user || !user.is_superuser do
|
||||
Logger.warning("GeoIP import attempted by non-superuser: #{inspect(user && user.email)}")
|
||||
|
||||
conn
|
||||
|> put_status(:forbidden)
|
||||
|> json(%{
|
||||
error: "Superuser access required. Only API tokens created by superusers can import GeoIP data."
|
||||
})
|
||||
|> halt()
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
defp run_import(directory) do
|
||||
alias Towerops.GeoIP.Block
|
||||
alias Towerops.GeoIP.Location
|
||||
alias Towerops.Repo
|
||||
|
||||
blocks_file = Path.join(directory, "GeoLite2-City-Blocks-IPv4.csv")
|
||||
locations_file = Path.join(directory, "GeoLite2-City-Locations-en.csv")
|
||||
|
||||
# Verify files exist
|
||||
if File.exists?(blocks_file) && File.exists?(locations_file) do
|
||||
# Truncate existing data
|
||||
Repo.delete_all(Block)
|
||||
Repo.delete_all(Location)
|
||||
|
||||
# Load locations
|
||||
{locations_count, valid_geoname_ids} = Import.load_locations(locations_file)
|
||||
|
||||
# Load blocks
|
||||
blocks_count = Import.load_blocks(blocks_file, valid_geoname_ids)
|
||||
|
||||
{:ok, locations_count, blocks_count}
|
||||
else
|
||||
{:error, "Required CSV files not found in directory"}
|
||||
end
|
||||
rescue
|
||||
e ->
|
||||
{:error, Exception.message(e)}
|
||||
end
|
||||
end
|
||||
|
|
@ -17,8 +17,8 @@
|
|||
for network operators.
|
||||
</h1>
|
||||
<p class="mx-auto mt-6 max-w-2xl text-lg tracking-tight text-slate-700">
|
||||
Monitor network devices via SNMP, track performance metrics in real-time, and respond to alerts instantly.
|
||||
Built specifically for telecom tower infrastructure.
|
||||
Cloud-hosted monitoring with lightweight Docker agents running on your network.
|
||||
Monitor internal devices securely via SNMP—no firewall changes, no exposed ports.
|
||||
</p>
|
||||
<p class="mx-auto mt-4 max-w-2xl text-xl font-semibold tracking-tight text-blue-600">
|
||||
Monitor 10 devices for free. No credit card required.
|
||||
|
|
@ -49,6 +49,19 @@
|
|||
</div>
|
||||
<div class="mt-16 grid grid-cols-1 gap-8 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<!-- Feature 1 -->
|
||||
<div class="relative rounded-2xl bg-white/10 p-8 ring-1 ring-white/10">
|
||||
<div class="flex items-center gap-x-3">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-white/20">
|
||||
<.icon name="hero-cpu-chip" class="h-6 w-6 text-white" />
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-white">Secure Agent Architecture</h3>
|
||||
</div>
|
||||
<p class="mt-4 text-sm text-blue-100">
|
||||
Deploy lightweight Docker agents on your network to monitor internal equipment.
|
||||
Your devices stay private—no firewall changes, no open ports, no VPN required.
|
||||
</p>
|
||||
</div>
|
||||
<!-- Feature 2 -->
|
||||
<div class="relative rounded-2xl bg-white/10 p-8 ring-1 ring-white/10">
|
||||
<div class="flex items-center gap-x-3">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-white/20">
|
||||
|
|
@ -61,7 +74,7 @@
|
|||
and device health with support for MikroTik, Cisco, and generic SNMP devices.
|
||||
</p>
|
||||
</div>
|
||||
<!-- Feature 2 -->
|
||||
<!-- Feature 3 -->
|
||||
<div class="relative rounded-2xl bg-white/10 p-8 ring-1 ring-white/10">
|
||||
<div class="flex items-center gap-x-3">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-white/20">
|
||||
|
|
@ -74,7 +87,7 @@
|
|||
Configurable alert rules with email notifications to keep you informed.
|
||||
</p>
|
||||
</div>
|
||||
<!-- Feature 3 -->
|
||||
<!-- Feature 4 -->
|
||||
<div class="relative rounded-2xl bg-white/10 p-8 ring-1 ring-white/10">
|
||||
<div class="flex items-center gap-x-3">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-white/20">
|
||||
|
|
@ -87,6 +100,120 @@
|
|||
temperature sensors, and custom metrics over configurable time ranges.
|
||||
</p>
|
||||
</div>
|
||||
<!-- Feature 5 -->
|
||||
<div class="relative rounded-2xl bg-white/10 p-8 ring-1 ring-white/10">
|
||||
<div class="flex items-center gap-x-3">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-white/20">
|
||||
<.icon name="hero-cloud" class="h-6 w-6 text-white" />
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-white">Cloud-Hosted Platform</h3>
|
||||
</div>
|
||||
<p class="mt-4 text-sm text-blue-100">
|
||||
Access your monitoring dashboard from anywhere. No servers to maintain—we handle
|
||||
updates, backups, and scaling so you can focus on your network.
|
||||
</p>
|
||||
</div>
|
||||
<!-- Feature 6 -->
|
||||
<div class="relative rounded-2xl bg-white/10 p-8 ring-1 ring-white/10">
|
||||
<div class="flex items-center gap-x-3">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-white/20">
|
||||
<.icon name="hero-arrow-path" class="h-6 w-6 text-white" />
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-white">Simple Deployment</h3>
|
||||
</div>
|
||||
<p class="mt-4 text-sm text-blue-100">
|
||||
Deploy agents with a single Docker command. Automatic updates and health monitoring
|
||||
ensure your agents stay online and current.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- How It Works Section -->
|
||||
<section
|
||||
id="how-it-works"
|
||||
aria-label="How Towerops works"
|
||||
class="relative bg-slate-50 py-20 sm:py-32"
|
||||
>
|
||||
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div class="mx-auto max-w-2xl md:text-center">
|
||||
<h2 class="font-display text-3xl tracking-tight text-slate-900 sm:text-4xl">
|
||||
How it works
|
||||
</h2>
|
||||
<p class="mt-4 text-lg tracking-tight text-slate-700">
|
||||
Secure hybrid architecture with zero firewall changes. Deploy in minutes, not hours.
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-16 grid grid-cols-1 gap-8 lg:grid-cols-3 lg:gap-12">
|
||||
<!-- Step 1 -->
|
||||
<div class="relative">
|
||||
<div class="flex items-center gap-x-4">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-blue-600 text-white font-semibold text-lg">
|
||||
1
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-slate-900">Deploy Agent</h3>
|
||||
</div>
|
||||
<p class="mt-4 text-base text-slate-600">
|
||||
Run our lightweight Docker agent on your network (on a server, VM, or edge device).
|
||||
The agent stays on your private network and polls your internal SNMP devices locally.
|
||||
</p>
|
||||
<div class="mt-6 rounded-lg bg-blue-50 border border-blue-200 p-4">
|
||||
<p class="text-sm text-slate-700">
|
||||
Complete deployment instructions are provided when you create an agent in your dashboard.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Step 2 -->
|
||||
<div class="relative">
|
||||
<div class="flex items-center gap-x-4">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-blue-600 text-white font-semibold text-lg">
|
||||
2
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-slate-900">Agent Monitors & Reports</h3>
|
||||
</div>
|
||||
<p class="mt-4 text-base text-slate-600">
|
||||
The agent discovers and monitors your SNMP devices locally, then securely sends metrics
|
||||
to our cloud platform. No firewall changes needed—only outbound HTTPS connections.
|
||||
</p>
|
||||
<div class="mt-6 flex items-center justify-center gap-3 flex-wrap">
|
||||
<div class="rounded-lg bg-gradient-to-r from-blue-600 to-blue-500 px-4 py-2 text-xs font-medium text-white shadow-lg">
|
||||
Towerops Cloud
|
||||
</div>
|
||||
<.icon name="hero-arrow-left" class="h-4 w-4 text-blue-600" />
|
||||
<div class="rounded-lg bg-blue-100 px-4 py-2 text-xs font-medium text-blue-900">
|
||||
Agent
|
||||
</div>
|
||||
<.icon name="hero-arrow-right" class="h-4 w-4 text-slate-400" />
|
||||
<div class="rounded-lg bg-slate-200 px-3 py-2 text-xs font-medium text-slate-700">
|
||||
Router
|
||||
</div>
|
||||
<div class="rounded-lg bg-slate-200 px-3 py-2 text-xs font-medium text-slate-700">
|
||||
Switch
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Step 3 -->
|
||||
<div class="relative">
|
||||
<div class="flex items-center gap-x-4">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-blue-600 text-white font-semibold text-lg">
|
||||
3
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-slate-900">View in Cloud</h3>
|
||||
</div>
|
||||
<p class="mt-4 text-base text-slate-600">
|
||||
The agent securely sends metrics to our cloud platform. Access your dashboard from
|
||||
anywhere with real-time charts, alerts, and historical data.
|
||||
</p>
|
||||
<div class="mt-6 rounded-lg border-2 border-blue-200 bg-white p-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<.icon name="hero-chart-bar" class="h-5 w-5 text-blue-600" />
|
||||
<span class="text-sm font-medium text-slate-900">Cloud Dashboard</span>
|
||||
</div>
|
||||
<div class="mt-2 text-xs text-slate-600">
|
||||
Real-time metrics • Alerts • History
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -2,8 +2,7 @@ defmodule ToweropsWeb.Plugs.DetectEUUser do
|
|||
@moduledoc """
|
||||
Detects if a user is from an EU/EEA country that requires GDPR cookie consent.
|
||||
|
||||
This plug checks the CloudFlare CF-IPCountry header (if available) or other
|
||||
geolocation headers to determine if the user is from an EU/EEA country.
|
||||
Uses local GeoIP database to determine the user's country from their IP address.
|
||||
|
||||
The result is stored in conn.assigns.requires_cookie_consent
|
||||
"""
|
||||
|
|
@ -16,6 +15,11 @@ defmodule ToweropsWeb.Plugs.DetectEUUser do
|
|||
IS NO LI
|
||||
)
|
||||
|
||||
# British overseas territories and Crown dependencies also covered by GDPR
|
||||
@uk_territories ~w(
|
||||
GB GG JE IM GI
|
||||
)
|
||||
|
||||
@doc false
|
||||
def init(opts), do: opts
|
||||
|
||||
|
|
@ -25,9 +29,6 @@ defmodule ToweropsWeb.Plugs.DetectEUUser do
|
|||
|
||||
requires_consent = detect_eu_user(conn)
|
||||
|
||||
# Debug logging
|
||||
Logger.info("DetectEUUser: requires_consent=#{inspect(requires_consent)}, Mix.env=#{inspect(Mix.env())}")
|
||||
|
||||
# Store in process dictionary so layouts can access it
|
||||
Process.put(:requires_cookie_consent, requires_consent)
|
||||
|
||||
|
|
@ -48,27 +49,40 @@ defmodule ToweropsWeb.Plugs.DetectEUUser do
|
|||
end
|
||||
|
||||
defp detect_country_from_headers(conn) do
|
||||
# Try CloudFlare's CF-IPCountry header first (most reliable if behind CF)
|
||||
case get_req_header(conn, "cf-ipcountry") do
|
||||
[country_code] when country_code != "" ->
|
||||
# Get remote IP from connection
|
||||
remote_ip = get_remote_ip(conn)
|
||||
|
||||
case Towerops.GeoIP.lookup(remote_ip) do
|
||||
country_code when is_binary(country_code) ->
|
||||
eu_country?(country_code)
|
||||
|
||||
_ ->
|
||||
# Try X-Country header (some proxies set this)
|
||||
case get_req_header(conn, "x-country") do
|
||||
[country_code] when country_code != "" ->
|
||||
eu_country?(country_code)
|
||||
nil ->
|
||||
# No country found - default to showing banner (conservative approach)
|
||||
true
|
||||
end
|
||||
end
|
||||
|
||||
_ ->
|
||||
# No reliable country detection available
|
||||
# Default to showing consent banner (conservative approach for GDPR compliance)
|
||||
true
|
||||
defp get_remote_ip(conn) do
|
||||
# Check X-Forwarded-For header first (if behind proxy/load balancer)
|
||||
case get_req_header(conn, "x-forwarded-for") do
|
||||
[forwarded] ->
|
||||
# X-Forwarded-For can contain multiple IPs, take the first (client IP)
|
||||
forwarded
|
||||
|> String.split(",")
|
||||
|> List.first()
|
||||
|> String.trim()
|
||||
|
||||
_ ->
|
||||
# Fall back to direct connection IP
|
||||
case conn.remote_ip do
|
||||
{a, b, c, d} -> "#{a}.#{b}.#{c}.#{d}"
|
||||
_ -> "0.0.0.0"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp eu_country?(country_code) do
|
||||
country_code = String.upcase(country_code)
|
||||
country_code in @eu_eea_countries
|
||||
country_code in @eu_eea_countries or country_code in @uk_territories
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -91,11 +91,19 @@ defmodule ToweropsWeb.Router do
|
|||
|
||||
resources "/sites", SitesController, except: [:new, :edit]
|
||||
resources "/devices", DevicesController, except: [:new, :edit]
|
||||
end
|
||||
|
||||
# MIB file management (requires superuser API token)
|
||||
# Admin API routes (requires superuser API token)
|
||||
scope "/admin/api", V1 do
|
||||
pipe_through :api_v1
|
||||
|
||||
# MIB file management
|
||||
get "/mibs", MibController, :index
|
||||
post "/mibs", MibController, :upload
|
||||
delete "/mibs/:vendor", MibController, :delete
|
||||
|
||||
# GeoIP database management
|
||||
post "/geoip/import", GeoipController, :import_database
|
||||
end
|
||||
|
||||
# WebAuthn API routes
|
||||
|
|
|
|||
37
priv/repo/migrations/20260128185500_create_geoip_tables.exs
Normal file
37
priv/repo/migrations/20260128185500_create_geoip_tables.exs
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
defmodule Towerops.Repo.Migrations.CreateGeoipTables do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
# Locations table - maps geoname_id to location details (country, city, etc.)
|
||||
create table(:geoip_locations, primary_key: false) do
|
||||
add :geoname_id, :integer, primary_key: true
|
||||
add :country_code, :string, size: 2
|
||||
add :country_name, :string
|
||||
add :city_name, :string
|
||||
add :subdivision_1_name, :string
|
||||
add :subdivision_2_name, :string
|
||||
add :latitude, :float
|
||||
add :longitude, :float
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
# Blocks table - maps IP ranges to geoname_id
|
||||
create table(:geoip_blocks, primary_key: false) do
|
||||
add :id, :binary_id, primary_key: true
|
||||
add :network, :string
|
||||
add :start_ip_int, :bigint, null: false
|
||||
add :end_ip_int, :bigint, null: false
|
||||
add :geoname_id, references(:geoip_locations, column: :geoname_id, type: :integer)
|
||||
add :registered_country_geoname_id, :integer
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
# Index for fast IP range lookups (most important query)
|
||||
create index(:geoip_blocks, [:start_ip_int, :end_ip_int])
|
||||
|
||||
# Index for geoname_id lookups
|
||||
create index(:geoip_blocks, [:geoname_id])
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Reference in a new issue