From d1fd1d4a62b7728eda4cd20ef8c69a27e96fc3e3 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Wed, 28 Jan 2026 13:08:06 -0600 Subject: [PATCH] remove Mix.env and support ip database import --- .gitignore | 1 - Makefile | 11 +- lib/mix/tasks/geoip.import.ex | 247 ++++++++++++++++++ lib/towerops/geoip.ex | 99 +++++++ lib/towerops/geoip/block.ex | 37 +++ lib/towerops/geoip/location.ex | 38 +++ .../controllers/api/v1/geoip_controller.ex | 137 ++++++++++ .../controllers/page_html/home.html.heex | 135 +++++++++- lib/towerops_web/plugs/detect_eu_user.ex | 50 ++-- lib/towerops_web/router.ex | 10 +- .../20260128185500_create_geoip_tables.exs | 37 +++ 11 files changed, 777 insertions(+), 25 deletions(-) create mode 100644 lib/mix/tasks/geoip.import.ex create mode 100644 lib/towerops/geoip.ex create mode 100644 lib/towerops/geoip/block.ex create mode 100644 lib/towerops/geoip/location.ex create mode 100644 lib/towerops_web/controllers/api/v1/geoip_controller.ex create mode 100644 priv/repo/migrations/20260128185500_create_geoip_tables.exs diff --git a/.gitignore b/.gitignore index 0d207ddd..929b42ab 100644 --- a/.gitignore +++ b/.gitignore @@ -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) - diff --git a/Makefile b/Makefile index fd4c01e5..a3c2c2f7 100644 --- a/Makefile +++ b/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) diff --git a/lib/mix/tasks/geoip.import.ex b/lib/mix/tasks/geoip.import.ex new file mode 100644 index 00000000..d00a5b4e --- /dev/null +++ b/lib/mix/tasks/geoip.import.ex @@ -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 + + 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 diff --git a/lib/towerops/geoip.ex b/lib/towerops/geoip.ex new file mode 100644 index 00000000..b8871313 --- /dev/null +++ b/lib/towerops/geoip.ex @@ -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 diff --git a/lib/towerops/geoip/block.ex b/lib/towerops/geoip/block.ex new file mode 100644 index 00000000..3fa04f5b --- /dev/null +++ b/lib/towerops/geoip/block.ex @@ -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 diff --git a/lib/towerops/geoip/location.ex b/lib/towerops/geoip/location.ex new file mode 100644 index 00000000..b010f290 --- /dev/null +++ b/lib/towerops/geoip/location.ex @@ -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 diff --git a/lib/towerops_web/controllers/api/v1/geoip_controller.ex b/lib/towerops_web/controllers/api/v1/geoip_controller.ex new file mode 100644 index 00000000..420c262e --- /dev/null +++ b/lib/towerops_web/controllers/api/v1/geoip_controller.ex @@ -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 diff --git a/lib/towerops_web/controllers/page_html/home.html.heex b/lib/towerops_web/controllers/page_html/home.html.heex index 1aa454bd..7a2dcc13 100644 --- a/lib/towerops_web/controllers/page_html/home.html.heex +++ b/lib/towerops_web/controllers/page_html/home.html.heex @@ -17,8 +17,8 @@ for network operators.

- 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.

Monitor 10 devices for free. No credit card required. @@ -49,6 +49,19 @@

+
+
+
+ <.icon name="hero-cpu-chip" class="h-6 w-6 text-white" /> +
+

Secure Agent Architecture

+
+

+ Deploy lightweight Docker agents on your network to monitor internal equipment. + Your devices stay private—no firewall changes, no open ports, no VPN required. +

+
+
@@ -61,7 +74,7 @@ and device health with support for MikroTik, Cisco, and generic SNMP devices.

- +
@@ -74,7 +87,7 @@ Configurable alert rules with email notifications to keep you informed.

- +
@@ -87,6 +100,120 @@ temperature sensors, and custom metrics over configurable time ranges.

+ +
+
+
+ <.icon name="hero-cloud" class="h-6 w-6 text-white" /> +
+

Cloud-Hosted Platform

+
+

+ Access your monitoring dashboard from anywhere. No servers to maintain—we handle + updates, backups, and scaling so you can focus on your network. +

+
+ +
+
+
+ <.icon name="hero-arrow-path" class="h-6 w-6 text-white" /> +
+

Simple Deployment

+
+

+ Deploy agents with a single Docker command. Automatic updates and health monitoring + ensure your agents stay online and current. +

+
+
+
+ + +
+
+
+

+ How it works +

+

+ Secure hybrid architecture with zero firewall changes. Deploy in minutes, not hours. +

+
+
+ +
+
+
+ 1 +
+

Deploy Agent

+
+

+ 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. +

+
+

+ Complete deployment instructions are provided when you create an agent in your dashboard. +

+
+
+ +
+
+
+ 2 +
+

Agent Monitors & Reports

+
+

+ 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. +

+
+
+ Towerops Cloud +
+ <.icon name="hero-arrow-left" class="h-4 w-4 text-blue-600" /> +
+ Agent +
+ <.icon name="hero-arrow-right" class="h-4 w-4 text-slate-400" /> +
+ Router +
+
+ Switch +
+
+
+ +
+
+
+ 3 +
+

View in Cloud

+
+

+ The agent securely sends metrics to our cloud platform. Access your dashboard from + anywhere with real-time charts, alerts, and historical data. +

+
+
+ <.icon name="hero-chart-bar" class="h-5 w-5 text-blue-600" /> + Cloud Dashboard +
+
+ Real-time metrics • Alerts • History +
+
+
diff --git a/lib/towerops_web/plugs/detect_eu_user.ex b/lib/towerops_web/plugs/detect_eu_user.ex index 35ced026..f27a6a02 100644 --- a/lib/towerops_web/plugs/detect_eu_user.ex +++ b/lib/towerops_web/plugs/detect_eu_user.ex @@ -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 diff --git a/lib/towerops_web/router.ex b/lib/towerops_web/router.ex index 92b97252..0c8f9822 100644 --- a/lib/towerops_web/router.ex +++ b/lib/towerops_web/router.ex @@ -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 diff --git a/priv/repo/migrations/20260128185500_create_geoip_tables.exs b/priv/repo/migrations/20260128185500_create_geoip_tables.exs new file mode 100644 index 00000000..511aa277 --- /dev/null +++ b/priv/repo/migrations/20260128185500_create_geoip_tables.exs @@ -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