remove Mix.env and support ip database import

This commit is contained in:
Graham McIntire 2026-01-28 13:08:06 -06:00
parent 220f1edce3
commit d1fd1d4a62
11 changed files with 777 additions and 25 deletions

1
.gitignore vendored
View file

@ -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)

View file

@ -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)

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

View 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

View 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

View 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

View file

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

View file

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

View file

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

View 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