183 lines
5.3 KiB
Elixir
183 lines
5.3 KiB
Elixir
defmodule Towerops.Workers.FirmwareVersionFetcherWorker do
|
|
@moduledoc """
|
|
Oban worker that fetches the latest MikroTik RouterOS firmware version from RSS feed.
|
|
|
|
Runs daily to check for new firmware releases and updates the firmware_releases table.
|
|
"""
|
|
|
|
use Oban.Worker, queue: :maintenance, max_attempts: 10
|
|
|
|
import SweetXml
|
|
|
|
alias Towerops.Devices.Firmware
|
|
alias Towerops.HTTP
|
|
|
|
require Logger
|
|
|
|
@impl Oban.Worker
|
|
def backoff(%Oban.Job{attempt: attempt}) do
|
|
min(attempt * attempt * 60, 7_200)
|
|
end
|
|
|
|
@rss_url "https://cdn.mikrotik.com/routeros/latest-stable.rss"
|
|
@changelog_url "https://mikrotik.com/download/changelogs"
|
|
|
|
@impl Oban.Worker
|
|
@spec perform(Oban.Job.t()) :: :ok | {:error, term()}
|
|
def perform(%Oban.Job{}) do
|
|
Logger.info("Fetching MikroTik RouterOS firmware version from RSS feed")
|
|
|
|
with {:ok, rss_body} <- fetch_rss_feed(),
|
|
{:ok, release_data} <- parse_rss_feed(rss_body),
|
|
{:ok, _release} <- Firmware.upsert_firmware_release(release_data) do
|
|
Logger.info("Successfully updated firmware release: #{release_data.version}")
|
|
:ok
|
|
else
|
|
{:error, reason} = error ->
|
|
Logger.error("Failed to fetch firmware version: #{inspect(reason)}")
|
|
error
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Fetches the RSS feed from MikroTik CDN.
|
|
|
|
Returns `{:ok, body}` on success or `{:error, reason}` on failure.
|
|
"""
|
|
def fetch_rss_feed do
|
|
case HTTP.get(__MODULE__, @rss_url, receive_timeout: 10_000) do
|
|
{:ok, %Req.Response{status: 200, body: body}} ->
|
|
{:ok, body}
|
|
|
|
{:ok, %Req.Response{status: status}} ->
|
|
{:error, {:http_error, status}}
|
|
|
|
{:error, %Req.TransportError{}} ->
|
|
{:error, :network_error}
|
|
|
|
{:error, reason} ->
|
|
{:error, reason}
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Parses RSS feed XML and extracts firmware release information.
|
|
|
|
## Examples
|
|
|
|
iex> parse_rss_feed(valid_rss_xml)
|
|
{:ok, %{vendor: "mikrotik", version: "7.14.1", ...}}
|
|
|
|
iex> parse_rss_feed(invalid_xml)
|
|
{:error, :parse_error}
|
|
"""
|
|
def parse_rss_feed(xml_string) do
|
|
# Parse channel title and first item
|
|
channel_title = xpath(xml_string, ~x"//channel/title/text()"s)
|
|
|
|
# Check if any items exist
|
|
items = xpath(xml_string, ~x"//item"l)
|
|
|
|
if Enum.empty?(items) do
|
|
{:error, :no_items}
|
|
else
|
|
# Extract data from first item
|
|
version = xpath(xml_string, ~x"//item/title/text()"s)
|
|
download_url = xpath(xml_string, ~x"//item/link/text()"s)
|
|
description = xpath(xml_string, ~x"//item/description/text()"s)
|
|
pub_date_string = xpath(xml_string, ~x"//item/pubDate/text()"s)
|
|
|
|
# Validate required fields
|
|
if version == "" or is_nil(version) do
|
|
{:error, :invalid_data}
|
|
else
|
|
# Extract clean version number from RSS title
|
|
# "RouterOS 7.21.2 [stable]" -> "7.21.2"
|
|
clean_version = extract_version_number(version)
|
|
|
|
# Parse RFC822 date
|
|
release_date = parse_rfc822_date(pub_date_string)
|
|
|
|
{:ok,
|
|
%{
|
|
vendor: "mikrotik",
|
|
product_line: "routeros",
|
|
version: clean_version,
|
|
release_date: release_date,
|
|
download_url: download_url,
|
|
changelog_url: @changelog_url,
|
|
fetched_at: DateTime.utc_now(),
|
|
metadata: %{
|
|
"rss_title" => channel_title,
|
|
"rss_description" => description,
|
|
"raw_version" => version
|
|
}
|
|
}}
|
|
end
|
|
end
|
|
rescue
|
|
_ ->
|
|
{:error, :parse_error}
|
|
catch
|
|
:exit, _ ->
|
|
{:error, :parse_error}
|
|
end
|
|
|
|
# Extract clean version number from RSS title
|
|
# Examples:
|
|
# "RouterOS 7.21.2 [stable]" -> "7.21.2"
|
|
# "7.14.1" -> "7.14.1"
|
|
defp extract_version_number(version_string) do
|
|
# Match pattern: one or more digits, followed by dot, followed by digits
|
|
# This extracts semantic version numbers like "7.21.2"
|
|
case Regex.run(~r/\d+\.\d+(?:\.\d+)?/, version_string) do
|
|
[version] -> version
|
|
# Fallback to original if no match
|
|
_ -> version_string
|
|
end
|
|
end
|
|
|
|
# Parse RFC822 date format used in RSS feeds
|
|
# Example: "Wed, 15 Jan 2025 12:00:00 +0000"
|
|
defp parse_rfc822_date(date_string) when is_binary(date_string) and date_string != "" do
|
|
# RFC822 format: "Day, DD Mon YYYY HH:MM:SS +ZZZZ"
|
|
# We only care about DD Mon YYYY
|
|
case Regex.run(~r/\d{1,2}\s+\w{3}\s+\d{4}/, date_string) do
|
|
[date_part] ->
|
|
parse_date_part(date_part)
|
|
|
|
_ ->
|
|
nil
|
|
end
|
|
end
|
|
|
|
defp parse_rfc822_date(_), do: nil
|
|
|
|
# Parse the date portion "15 Jan 2025"
|
|
defp parse_date_part(date_string) do
|
|
with [day_str, month_str, year_str] <- String.split(date_string),
|
|
{day, ""} <- Integer.parse(day_str),
|
|
{year, ""} <- Integer.parse(year_str),
|
|
month when is_integer(month) <- parse_month(month_str),
|
|
{:ok, date} <- Date.new(year, month, day) do
|
|
date
|
|
else
|
|
_ -> nil
|
|
end
|
|
end
|
|
|
|
# Convert three-letter month abbreviation to month number
|
|
defp parse_month("Jan"), do: 1
|
|
defp parse_month("Feb"), do: 2
|
|
defp parse_month("Mar"), do: 3
|
|
defp parse_month("Apr"), do: 4
|
|
defp parse_month("May"), do: 5
|
|
defp parse_month("Jun"), do: 6
|
|
defp parse_month("Jul"), do: 7
|
|
defp parse_month("Aug"), do: 8
|
|
defp parse_month("Sep"), do: 9
|
|
defp parse_month("Oct"), do: 10
|
|
defp parse_month("Nov"), do: 11
|
|
defp parse_month("Dec"), do: 12
|
|
defp parse_month(_), do: nil
|
|
end
|