diff --git a/.forgejo/workflows/pr-tests.yaml b/.forgejo/workflows/pr-tests.yaml
index 32ea08bf..81bbd2a0 100644
--- a/.forgejo/workflows/pr-tests.yaml
+++ b/.forgejo/workflows/pr-tests.yaml
@@ -73,6 +73,15 @@ jobs:
sudo apt-get update
sudo apt-get install -y libssl-dev libsnmp-dev snmp-mibs-downloader postgresql-client
+ - name: Install Gleam
+ run: |
+ curl -fsSL https://github.com/gleam-lang/gleam/releases/download/v1.15.2/gleam-v1.15.2-x86_64-unknown-linux-musl.tar.gz \
+ | sudo tar xz -C /usr/local/bin/
+ gleam --version
+
+ - name: Install mix_gleam archive
+ run: mix archive.install hex mix_gleam --force
+
- name: Install dependencies
run: mix deps.get
diff --git a/.forgejo/workflows/production.yaml b/.forgejo/workflows/production.yaml
index cdf448bd..745a8e25 100644
--- a/.forgejo/workflows/production.yaml
+++ b/.forgejo/workflows/production.yaml
@@ -67,6 +67,15 @@ jobs:
sudo apt-get update
sudo apt-get install -y libssl-dev libsnmp-dev snmp-mibs-downloader postgresql-client
+ - name: Install Gleam
+ run: |
+ curl -fsSL https://github.com/gleam-lang/gleam/releases/download/v1.15.2/gleam-v1.15.2-x86_64-unknown-linux-musl.tar.gz \
+ | sudo tar xz -C /usr/local/bin/
+ gleam --version
+
+ - name: Install mix_gleam archive
+ run: mix archive.install hex mix_gleam --force
+
- name: Install dependencies
run: mix deps.get
diff --git a/.gitignore b/.gitignore
index 770e735d..17e380b8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -100,5 +100,8 @@ profiles.json
.stride_auth.md
+# Gleam build artifacts
+/build/
+
# Generated by nix, machine-specific paths
.pre-commit-config.yaml
diff --git a/.tool-versions b/.tool-versions
index 7603161f..3a4f908c 100644
--- a/.tool-versions
+++ b/.tool-versions
@@ -1,4 +1,5 @@
erlang 28.3
elixir 1.19.5-otp-28
#elixir 1.20.0-rc.1-otp-28
+gleam 1.15.2
nodejs 22.17.1
diff --git a/Dockerfile b/Dockerfile
index ccbacf2a..7e69f31b 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -31,22 +31,32 @@ ENV TERM=dumb
# install build dependencies
RUN apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get upgrade -y \
- && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends build-essential git libsnmp-dev \
+ && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends build-essential git curl libsnmp-dev \
&& rm -rf /var/lib/apt/lists/*
+# Install Gleam compiler
+ARG GLEAM_VERSION=1.15.2
+RUN curl -fsSL https://github.com/gleam-lang/gleam/releases/download/v${GLEAM_VERSION}/gleam-v${GLEAM_VERSION}-x86_64-unknown-linux-musl.tar.gz \
+ | tar xz -C /usr/local/bin/
+
# prepare build dir
WORKDIR /app
-# install hex + rebar
-RUN mix local.hex --force \
- && mix local.rebar --force
-
# set build ENV
ENV MIX_ENV="prod"
+# install hex + rebar + mix_gleam archive (must use cache mounts so they
+# persist into subsequent cache-mounted steps)
+RUN --mount=type=cache,target=/root/.hex \
+ --mount=type=cache,target=/root/.mix \
+ mix local.hex --force \
+ && mix local.rebar --force \
+ && mix archive.install hex mix_gleam --force
+
# install mix dependencies
-COPY mix.exs mix.lock ./
+COPY mix.exs mix.lock gleam.toml ./
COPY vendor vendor
+COPY src src
RUN --mount=type=cache,target=/root/.hex \
--mount=type=cache,target=/root/.mix \
mix deps.get --only $MIX_ENV
@@ -74,18 +84,21 @@ RUN cd c_src && make
COPY lib lib
# Compile the release
-RUN mix compile
+RUN --mount=type=cache,target=/root/.mix \
+ mix compile
COPY assets assets
# compile assets
-RUN mix assets.deploy
+RUN --mount=type=cache,target=/root/.mix \
+ mix assets.deploy
# Changes to config/runtime.exs don't require recompiling the code
COPY config/runtime.exs config/
COPY rel rel
-RUN mix release
+RUN --mount=type=cache,target=/root/.mix \
+ mix release
# start a new build stage so that the final image will only contain
# the compiled release and other runtime necessities
diff --git a/gleam.toml b/gleam.toml
new file mode 100644
index 00000000..2d940f88
--- /dev/null
+++ b/gleam.toml
@@ -0,0 +1,9 @@
+name = "towerops"
+version = "0.1.0"
+target = "erlang"
+
+[dependencies]
+gleam_stdlib = ">= 0.34.0 and < 2.0.0"
+
+[dev-dependencies]
+gleeunit = ">= 1.0.0 and < 2.0.0"
diff --git a/k8s/Dockerfile b/k8s/Dockerfile
index a6ee36e3..af5c61d6 100644
--- a/k8s/Dockerfile
+++ b/k8s/Dockerfile
@@ -28,22 +28,32 @@ ENV TERM=dumb
# install build dependencies
RUN apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get upgrade -y \
- && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends build-essential git libsnmp-dev \
+ && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends build-essential git curl libsnmp-dev \
&& rm -rf /var/lib/apt/lists/*
+# Install Gleam compiler
+ARG GLEAM_VERSION=1.15.2
+RUN curl -fsSL https://github.com/gleam-lang/gleam/releases/download/v${GLEAM_VERSION}/gleam-v${GLEAM_VERSION}-x86_64-unknown-linux-musl.tar.gz \
+ | tar xz -C /usr/local/bin/
+
# prepare build dir
WORKDIR /app
-# install hex + rebar
-RUN mix local.hex --force \
- && mix local.rebar --force
-
# set build ENV
ENV MIX_ENV="prod"
+# install hex + rebar + mix_gleam archive (must use cache mounts so they
+# persist into subsequent cache-mounted steps)
+RUN --mount=type=cache,target=/root/.hex \
+ --mount=type=cache,target=/root/.mix \
+ mix local.hex --force \
+ && mix local.rebar --force \
+ && mix archive.install hex mix_gleam --force
+
# install mix dependencies
-COPY mix.exs mix.lock ./
+COPY mix.exs mix.lock gleam.toml ./
COPY vendor vendor
+COPY src src
RUN --mount=type=cache,target=/root/.hex \
--mount=type=cache,target=/root/.mix \
mix deps.get --only $MIX_ENV
@@ -71,18 +81,21 @@ RUN cd c_src && make
COPY lib lib
# Compile the release
-RUN mix compile
+RUN --mount=type=cache,target=/root/.mix \
+ mix compile
COPY assets assets
# compile assets
-RUN mix assets.deploy
+RUN --mount=type=cache,target=/root/.mix \
+ mix assets.deploy
# Changes to config/runtime.exs don't require recompiling the code
COPY config/runtime.exs config/
COPY rel rel
-RUN mix release
+RUN --mount=type=cache,target=/root/.mix \
+ mix release
# start a new build stage so that the final image will only contain
# the compiled release and other runtime necessities
diff --git a/k8s/base-image/Dockerfile b/k8s/base-image/Dockerfile
index 04e307c7..5d142ab0 100644
--- a/k8s/base-image/Dockerfile
+++ b/k8s/base-image/Dockerfile
@@ -26,12 +26,19 @@ RUN apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
build-essential \
git \
+ curl \
&& rm -rf /var/lib/apt/lists/*
-# Install hex + rebar (matches k8s/Dockerfile exactly)
+# Install Gleam compiler
+ARG GLEAM_VERSION=1.15.2
+RUN curl -fsSL https://github.com/gleam-lang/gleam/releases/download/v${GLEAM_VERSION}/gleam-v${GLEAM_VERSION}-x86_64-unknown-linux-musl.tar.gz \
+ | tar xz -C /usr/local/bin/
+
+# Install hex + rebar + mix_gleam archive (matches k8s/Dockerfile exactly)
# This step takes ~10 seconds on every deploy, so we bake it in
RUN mix local.hex --force \
- && mix local.rebar --force
+ && mix local.rebar --force \
+ && mix archive.install hex mix_gleam --force
# Set build ENV
ENV MIX_ENV="prod"
diff --git a/lib/towerops/numeric.ex b/lib/towerops/numeric.ex
deleted file mode 100644
index 8129149a..00000000
--- a/lib/towerops/numeric.ex
+++ /dev/null
@@ -1,36 +0,0 @@
-defmodule Towerops.Numeric do
- @moduledoc """
- Shared helpers for parsing optional numeric values.
- """
-
- @spec parse_integer(term()) :: integer() | nil
- def parse_integer(nil), do: nil
- def parse_integer(""), do: nil
- def parse_integer("null"), do: nil
- def parse_integer(value) when is_integer(value), do: value
-
- def parse_integer(value) when is_binary(value) do
- case Integer.parse(value) do
- {int, _} -> int
- :error -> nil
- end
- end
-
- def parse_integer(_), do: nil
-
- @spec parse_float(term()) :: float() | nil
- def parse_float(nil), do: nil
- def parse_float(""), do: nil
- def parse_float("null"), do: nil
- def parse_float(value) when is_float(value), do: value
- def parse_float(value) when is_integer(value), do: value / 1.0
-
- def parse_float(value) when is_binary(value) do
- case Float.parse(value) do
- {float, _} -> float
- :error -> nil
- end
- end
-
- def parse_float(_), do: nil
-end
diff --git a/lib/towerops/query_helpers.ex b/lib/towerops/query_helpers.ex
deleted file mode 100644
index 2560faf9..00000000
--- a/lib/towerops/query_helpers.ex
+++ /dev/null
@@ -1,17 +0,0 @@
-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
diff --git a/lib/towerops/snmp.ex b/lib/towerops/snmp.ex
index 9833aba6..b247e350 100644
--- a/lib/towerops/snmp.ex
+++ b/lib/towerops/snmp.ex
@@ -2352,12 +2352,7 @@ defmodule Towerops.Snmp do
"#{a}.#{b}.#{c}.#{d}"
end
- defp sanitize_like(str) do
- str
- |> String.replace("\\", "\\\\")
- |> String.replace("%", "\\%")
- |> String.replace("_", "\\_")
- end
+ defp sanitize_like(str), do: :towerops@query_helpers.sanitize_like(str)
# Wireless Client queries
diff --git a/lib/towerops/snmp/profiles/base.ex b/lib/towerops/snmp/profiles/base.ex
index 126dae04..1d15cf8d 100644
--- a/lib/towerops/snmp/profiles/base.ex
+++ b/lib/towerops/snmp/profiles/base.ex
@@ -9,7 +9,6 @@ defmodule Towerops.Snmp.Profiles.Base do
- Generic sensors from ENTITY-SENSOR-MIB (if available)
"""
- alias Towerops.Numeric
alias Towerops.Snmp.Client
alias Towerops.Snmp.Discovery
alias Towerops.Snmp.Sanitizer
@@ -1447,9 +1446,19 @@ defmodule Towerops.Snmp.Profiles.Base do
end
end
- defp parse_integer(value), do: Numeric.parse_integer(value)
+ defp parse_integer(value) do
+ case :towerops@numeric.parse_integer(value) do
+ {:some, i} -> i
+ :none -> nil
+ end
+ end
- defp parse_float(value), do: Numeric.parse_float(value)
+ defp parse_float(value) do
+ case :towerops@numeric.parse_float(value) do
+ {:some, f} -> f
+ :none -> nil
+ end
+ end
defp parse_if_status(1), do: "up"
defp parse_if_status(2), do: "down"
diff --git a/lib/towerops/trace.ex b/lib/towerops/trace.ex
index a6543270..cb179e91 100644
--- a/lib/towerops/trace.ex
+++ b/lib/towerops/trace.ex
@@ -453,10 +453,5 @@ defmodule Towerops.Trace do
defp format_address(_), do: nil
- defp sanitize_like(query) do
- query
- |> String.replace("\\", "\\\\")
- |> String.replace("%", "\\%")
- |> String.replace("_", "\\_")
- end
+ defp sanitize_like(query), do: :towerops@query_helpers.sanitize_like(query)
end
diff --git a/lib/towerops/url_validator.ex b/lib/towerops/url_validator.ex
deleted file mode 100644
index 1f5214e3..00000000
--- a/lib/towerops/url_validator.ex
+++ /dev/null
@@ -1,104 +0,0 @@
-defmodule Towerops.URLValidator do
- @moduledoc """
- Validates URLs to prevent SSRF (Server-Side Request Forgery) attacks.
-
- Blocks requests to internal/private IP ranges, localhost, and non-HTTP(S) schemes.
- """
-
- import Bitwise
-
- @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) do
- validate_ip(uri.host)
- 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
- charlist = String.to_charlist(host)
-
- with {:error, _} <- :inet.parse_address(charlist),
- {:error, _} <- :inet.getaddr(charlist, :inet) do
- error = :inet.getaddr(charlist, :inet6)
- error
- 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 = 0xFFFFFFFF |> bsl(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
-end
diff --git a/lib/towerops_web/channels/agent_channel.ex b/lib/towerops_web/channels/agent_channel.ex
index 31e2a656..3c62dcee 100644
--- a/lib/towerops_web/channels/agent_channel.ex
+++ b/lib/towerops_web/channels/agent_channel.ex
@@ -44,7 +44,6 @@ defmodule ToweropsWeb.AgentChannel do
alias Towerops.Devices.BackupRequests
alias Towerops.Devices.MikrotikBackups
alias Towerops.Monitoring
- alias Towerops.Numeric
alias Towerops.Snmp
alias Towerops.Snmp.AgentDiscovery
alias Towerops.Snmp.Discovery
@@ -2198,7 +2197,12 @@ defmodule ToweropsWeb.AgentChannel do
Snmp.create_interface_stats_batch(entries)
end
- defp parse_integer(value), do: Numeric.parse_integer(value)
+ defp parse_integer(value) do
+ case :towerops@numeric.parse_integer(value) do
+ {:some, i} -> i
+ :none -> nil
+ end
+ end
# Normalize OID keys to strip leading dots.
# The agent (via gosnmp/net-snmp) returns OIDs with leading dots
@@ -2210,7 +2214,12 @@ defmodule ToweropsWeb.AgentChannel do
end)
end
- defp parse_float(value), do: Numeric.parse_float(value)
+ defp parse_float(value) do
+ case :towerops@numeric.parse_float(value) do
+ {:some, f} -> f
+ :none -> nil
+ end
+ end
defp get_remote_ip(socket), do: RemoteIp.from_socket(socket)
diff --git a/lib/towerops_web/live/alert_live/index.ex b/lib/towerops_web/live/alert_live/index.ex
index 96dac9d9..3df8c9e5 100644
--- a/lib/towerops_web/live/alert_live/index.ex
+++ b/lib/towerops_web/live/alert_live/index.ex
@@ -186,6 +186,11 @@ defmodule ToweropsWeb.AlertLive.Index do
{:noreply, load_alerts(socket, socket.assigns.current_scope.organization.id)}
end
+ @impl true
+ def handle_info({:alert_changed, _org_id}, socket) do
+ {:noreply, load_alerts(socket, socket.assigns.current_scope.organization.id)}
+ end
+
defp load_alerts(socket, organization_id) do
all_alerts = Alerts.list_organization_alerts(organization_id, %{"limit" => 500})
diff --git a/lib/towerops_web/live/alert_live/index.html.heex b/lib/towerops_web/live/alert_live/index.html.heex
index 3d4d93ca..b54f61f0 100644
--- a/lib/towerops_web/live/alert_live/index.html.heex
+++ b/lib/towerops_web/live/alert_live/index.html.heex
@@ -278,14 +278,14 @@
- <%= if alert.alert_type == :device_down do %>
+ <%= if alert.alert_type == "device_down" do %>
<.icon name="hero-arrow-down" class="h-3 w-3" /> DOWN
<% else %>
<.icon name="hero-arrow-up" class="h-3 w-3" /> UP
@@ -364,7 +364,7 @@
{t("Resolved")}
<% end %>
- <%= if alert.alert_type == :device_down && is_nil(alert.acknowledged_at) && is_nil(alert.resolved_at) do %>
+ <%= if alert.alert_type == "device_down" && is_nil(alert.acknowledged_at) && is_nil(alert.resolved_at) do %>