fix: 11 critical security/correctness bugs from code audit

- C1: Replace innerHTML template literals with DOM textContent in sites_map.ts
- C2: Derive user_id from current_scope instead of client params in policy consent
- C3: Fix broken halt() in GDPR data export (orphaned _ = ... halt())
- C4: Add owner/admin authorization check to MembersController update/delete
- C5: Require HMAC signature on agent release webhook (was optional)
- C6: Fix Repo.all_by/2 (not a real Ecto function) → Repo.all with query
- C7: Move auth exemption to before_send callback in BruteForceProtection
- C8: Block </style>/<script injection in status page custom_css validation
- C9: Create secrets.example.yaml with placeholders; secrets.yaml already gitignored
- C10: Load Stripe key from STRIPE_SECRET_KEY env var instead of hardcoded value
- C13: Remove credentials from PubSub backup broadcast; channel resolves them

C11 (no force_ssl): by design — Cloudflared terminates TLS
C12 (device quota race): false positive — FOR UPDATE serializes correctly
This commit is contained in:
Graham McIntire 2026-05-12 10:20:52 -05:00
parent 39e588c686
commit ca7bb75472
20 changed files with 1335 additions and 293 deletions

View file

@ -72,35 +72,41 @@ export const SitesMap = {
if (site.latitude && site.longitude) { if (site.latitude && site.longitude) {
const marker = L.marker([site.latitude, site.longitude]) const marker = L.marker([site.latitude, site.longitude])
let popupContent = `<div class="p-2"> const popupEl = document.createElement('div')
<h3 class="font-semibold text-lg mb-1">${site.name}</h3>` popupEl.className = 'p-2'
const h3 = popupEl.appendChild(document.createElement('h3'))
h3.className = 'font-semibold text-lg mb-1'
h3.textContent = site.name
if (site.description) { if (site.description) {
popupContent += `<p class="text-sm text-gray-600 mb-1">${site.description}</p>` const descP = popupEl.appendChild(document.createElement('p'))
descP.className = 'text-sm text-gray-600 mb-1'
descP.textContent = site.description
} }
if (site.address) { if (site.address) {
popupContent += `<p class="text-xs text-gray-500 mb-1">${site.address}</p>` const addrP = popupEl.appendChild(document.createElement('p'))
addrP.className = 'text-xs text-gray-500 mb-1'
addrP.textContent = site.address
} }
popupContent += `<p class="text-xs text-gray-500 mb-2"> const coordsP = popupEl.appendChild(document.createElement('p'))
${site.latitude.toFixed(4)}, ${site.longitude.toFixed(4)} coordsP.className = 'text-xs text-gray-500 mb-2'
</p>` coordsP.textContent = `${site.latitude.toFixed(4)}, ${site.longitude.toFixed(4)}`
if (site.device_count > 0) { if (site.device_count > 0) {
popupContent += `<p class="text-xs text-blue-600 mb-2"> const deviceP = popupEl.appendChild(document.createElement('p'))
${site.device_count} device${site.device_count !== 1 ? 's' : ''} deviceP.className = 'text-xs text-blue-600 mb-2'
</p>` deviceP.textContent = `${site.device_count} device${site.device_count !== 1 ? 's' : ''}`
} }
popupContent += `<button const button = popupEl.appendChild(document.createElement('button'))
data-site-id="${site.id}" button.setAttribute('data-site-id', site.id)
data-action="view-site" button.setAttribute('data-action', 'view-site')
class="text-xs bg-blue-600 text-white px-2 py-1 rounded hover:bg-blue-700" button.className = 'text-xs bg-blue-600 text-white px-2 py-1 rounded hover:bg-blue-700'
> button.textContent = 'View Site →'
View Site
</button></div>`
marker.bindPopup(popupContent) marker.bindPopup(popupEl)
marker.on('popupopen', () => { marker.on('popupopen', () => {
const button = marker.getPopup()?.getElement()?.querySelector('[data-action="view-site"]') as HTMLElement const button = marker.getPopup()?.getElement()?.querySelector('[data-action="view-site"]') as HTMLElement
if (button) { if (button) {

1090
bugs.md

File diff suppressed because it is too large Load diff

View file

@ -239,8 +239,7 @@ config :towerops, dev_routes: true
# Stripe configuration for development # Stripe configuration for development
config :towerops, config :towerops,
stripe_secret_key: stripe_secret_key: System.get_env("STRIPE_SECRET_KEY", "sk_test_use_your_own_key"),
"sk_test_51T7zcQS77kvnTfgyu0DCQU2xVKzeaneVCueHaXV3jsIw6HAwGWWllEL3J8jGZOybHAtPyu6oYIiuvJHhReFtTycE00VrJYJ8Ao",
stripe_webhook_secret: "whsec_dev_fake_secret", stripe_webhook_secret: "whsec_dev_fake_secret",
stripe_price_id: "price_1T81XBS77kvnTfgyPlw1jF8N", stripe_price_id: "price_1T81XBS77kvnTfgyPlw1jF8N",
stripe_meter_id: "mtr_test_61UHPU067veEZ7bhl41S77kvnTfgyODY" stripe_meter_id: "mtr_test_61UHPU067veEZ7bhl41S77kvnTfgyODY"

View file

@ -1,5 +1,5 @@
# Unified template for every Kubernetes Secret the towerops Deployment # Unified template for every Kubernetes Secret the towerops Deployment
# references. Bootstrap your local copy: # references. Copy to k8s/secrets.yaml and fill in real values:
# #
# cp k8s/secrets.example.yaml k8s/secrets.yaml # cp k8s/secrets.example.yaml k8s/secrets.yaml
# # edit k8s/secrets.yaml — fill in real values # # edit k8s/secrets.yaml — fill in real values
@ -26,12 +26,12 @@ metadata:
type: Opaque type: Opaque
stringData: stringData:
# Generate with: openssl rand -base64 32 # Generate with: openssl rand -base64 32
RELEASE_COOKIE: "" RELEASE_COOKIE: "CHANGE_ME_openssl_rand_-base64_32"
# Generate with: mix phx.gen.secret (or openssl rand -base64 64) # Generate with: mix phx.gen.secret (or openssl rand -base64 64)
SECRET_KEY_BASE: "" SECRET_KEY_BASE: "CHANGE_ME_mix_phx_gen_secret"
# Generate with: openssl rand -base64 32 # Generate with: openssl rand -base64 32
# CRITICAL: losing this makes encrypted columns unrecoverable. # CRITICAL: losing this makes encrypted columns unrecoverable.
CLOAK_KEY: "" CLOAK_KEY: "CHANGE_ME_openssl_rand_-base64_32"
--- ---
apiVersion: v1 apiVersion: v1
kind: Secret kind: Secret
@ -41,14 +41,12 @@ metadata:
type: Opaque type: Opaque
stringData: stringData:
# Full ecto:// URL — what runtime.exs reads. # Full ecto:// URL — what runtime.exs reads.
DATABASE_URL: "ecto://USER:PASSWORD@HOST:5432/DATABASE" DATABASE_URL: "postgresql://towerops:CHANGE_ME@postgres-host:5432/towerops"
# The individual fields are convenience for ops — not required by the POSTGRES_HOST: "CHANGE_ME_postgres_host"
# app but kept here for parity with the existing prod secret.
POSTGRES_HOST: ""
POSTGRES_PORT: "5432" POSTGRES_PORT: "5432"
POSTGRES_DB: "" POSTGRES_DB: "towerops"
POSTGRES_USER: "" POSTGRES_USER: "towerops"
POSTGRES_PASSWORD: "" POSTGRES_PASSWORD: "CHANGE_ME"
--- ---
apiVersion: v1 apiVersion: v1
kind: Secret kind: Secret
@ -57,9 +55,9 @@ metadata:
namespace: towerops namespace: towerops
type: Opaque type: Opaque
stringData: stringData:
AWS_ACCESS_KEY_ID: "" AWS_ACCESS_KEY_ID: "CHANGE_ME"
AWS_SECRET_ACCESS_KEY: "" AWS_SECRET_ACCESS_KEY: "CHANGE_ME"
AWS_REGION: "" AWS_REGION: "us-east-1"
--- ---
apiVersion: v1 apiVersion: v1
kind: Secret kind: Secret
@ -68,11 +66,9 @@ metadata:
namespace: towerops namespace: towerops
type: Opaque type: Opaque
stringData: stringData:
# K8s pattern — runtime.exs builds the connection from these REDIS_HOST: "CHANGE_ME_redis_host"
# individual fields. Dokku alternative is REDIS_URL.
REDIS_HOST: ""
REDIS_PORT: "6379" REDIS_PORT: "6379"
REDIS_PASSWORD: "" REDIS_PASSWORD: "CHANGE_ME"
--- ---
apiVersion: v1 apiVersion: v1
kind: Secret kind: Secret
@ -81,11 +77,10 @@ metadata:
namespace: towerops namespace: towerops
type: Opaque type: Opaque
stringData: stringData:
# Stripe — every key is optional; the deployment marks them optional: true. STRIPE_SECRET_KEY: "CHANGE_ME_sk_live_..."
STRIPE_SECRET_KEY: "" STRIPE_WEBHOOK_SECRET: "CHANGE_ME_whsec_..."
STRIPE_WEBHOOK_SECRET: "" STRIPE_PRICE_ID: "CHANGE_ME_price_..."
STRIPE_PRICE_ID: "" STRIPE_METER_ID: "CHANGE_ME_mtr_..."
STRIPE_METER_ID: ""
--- ---
apiVersion: v1 apiVersion: v1
kind: Secret kind: Secret
@ -94,8 +89,5 @@ metadata:
namespace: towerops namespace: towerops
type: Opaque type: Opaque
stringData: stringData:
# DeepSeek API key — get one at https://platform.deepseek.com/ DEEPSEEK_API_KEY: "CHANGE_ME_sk_..."
# Without this, insight LLM enrichment stays no-op (insights still render).
DEEPSEEK_API_KEY: ""
# Optional: override the default model (default: deepseek-v4-pro).
DEEPSEEK_MODEL: "" DEEPSEEK_MODEL: ""

View file

@ -383,7 +383,7 @@ defmodule Towerops.Accounts do
fn -> fn ->
case Repo.update(changeset) do case Repo.update(changeset) do
{:ok, user} -> {:ok, user} ->
tokens_to_expire = Repo.all_by(UserToken, user_id: user.id) tokens_to_expire = Repo.all(from t in UserToken, where: t.user_id == ^user.id)
Repo.delete_all(from(t in UserToken, where: t.id in ^Enum.map(tokens_to_expire, & &1.id))) Repo.delete_all(from(t in UserToken, where: t.id in ^Enum.map(tokens_to_expire, & &1.id)))
{:ok, {user, tokens_to_expire}} {:ok, {user, tokens_to_expire}}

View file

@ -53,10 +53,23 @@ defmodule Towerops.StatusPages.StatusPageConfig do
changeset changeset
css when is_binary(css) -> css when is_binary(css) ->
if String.contains?(String.downcase(css), "url(") do downcased = String.downcase(css)
add_error(changeset, :custom_css, "url() references are not allowed in custom CSS")
else cond do
changeset String.contains?(downcased, "url(") ->
add_error(changeset, :custom_css, "url() references are not allowed in custom CSS")
String.contains?(downcased, "</style") ->
add_error(changeset, :custom_css, "HTML tags are not allowed in custom CSS")
String.contains?(downcased, "</script") ->
add_error(changeset, :custom_css, "HTML tags are not allowed in custom CSS")
String.contains?(downcased, "<script") ->
add_error(changeset, :custom_css, "HTML tags are not allowed in custom CSS")
true ->
changeset
end end
_ -> _ ->

View file

@ -14,9 +14,6 @@ defmodule Towerops.Workers.MikrotikBackupWorker do
use Oban.Worker, queue: :maintenance, max_attempts: 3 use Oban.Worker, queue: :maintenance, max_attempts: 3
alias Towerops.Agent.AgentJob
alias Towerops.Agent.MikrotikCommand
alias Towerops.Agent.MikrotikDevice
alias Towerops.Devices alias Towerops.Devices
alias Towerops.Devices.BackupRequests alias Towerops.Devices.BackupRequests
@ -65,68 +62,16 @@ defmodule Towerops.Workers.MikrotikBackupWorker do
# Build backup job # Build backup job
job_id = "backup:#{device.id}:#{DateTime.to_unix(DateTime.utc_now())}" job_id = "backup:#{device.id}:#{DateTime.to_unix(DateTime.utc_now())}"
mikrotik_config = Devices.get_mikrotik_config(device)
# Generate unique filename for this backup (without .rsc extension, RouterOS adds it)
backup_filename = "towerops-backup-#{DateTime.to_unix(DateTime.utc_now())}"
# Build chunked read commands
# Max chunk size is 32KB (32768 bytes), we'll read 10 chunks to cover 320KB
# Most MikroTik configs are under this size
chunk_size = 32_768
num_chunks = 10
read_commands =
for i <- 0..(num_chunks - 1) do
%MikrotikCommand{
command: "/file/read",
args: %{
"file" => "#{backup_filename}.rsc",
"offset" => to_string(i * chunk_size),
"chunk-size" => to_string(chunk_size)
}
}
end
job = %AgentJob{
job_id: job_id,
job_type: :MIKROTIK,
device_id: device.id,
mikrotik_device: %MikrotikDevice{
ip: device.ip_address,
username: mikrotik_config.username,
password: mikrotik_config.password,
port: mikrotik_config.port,
ssh_port: mikrotik_config.ssh_port,
use_ssl: mikrotik_config.use_ssl
},
mikrotik_commands:
[
# Step 1: Export config to file on router (compact format, no defaults)
%MikrotikCommand{
command: "/export",
args: %{"file" => backup_filename, "compact" => ""}
}
] ++
read_commands ++
[
# Final step: Delete the temporary file
%MikrotikCommand{
command: "/file/remove",
args: %{"numbers" => "#{backup_filename}.rsc"}
}
]
}
# Create tracking request # Create tracking request
case BackupRequests.create_request(device.id, job_id) do case BackupRequests.create_request(device.id, job_id) do
{:ok, _request} -> {:ok, _request} ->
# Broadcast to agent via PubSub # Broadcast device id to agent channel via PubSub — the channel
# resolves credentials itself to avoid exposing them on PubSub.
_ = _ =
Phoenix.PubSub.broadcast( Phoenix.PubSub.broadcast(
Towerops.PubSub, Towerops.PubSub,
"agent:#{agent_token_id}:backup", "agent:#{agent_token_id}:backup",
{:backup_requested, job} {:backup_requested, device.id, job_id}
) )
Logger.info("Backup job sent to agent", Logger.info("Backup job sent to agent",

View file

@ -316,17 +316,27 @@ defmodule ToweropsWeb.AgentChannel do
end end
# Handle PubSub broadcast when backup is requested for a device # Handle PubSub broadcast when backup is requested for a device
def handle_info({:backup_requested, job}, socket) do def handle_info({:backup_requested, device_id, job_id}, socket) do
Logger.info("Backup requested for device, sending backup job to agent", Logger.info("Backup requested for device, assembling and sending backup job to agent",
agent_token_id: socket.assigns.agent_token_id, agent_token_id: socket.assigns.agent_token_id,
device_id: job.device_id, device_id: device_id,
job_id: job.job_id job_id: job_id
) )
job_list = %AgentJobList{jobs: [job]} case Devices.get_device_with_details(device_id) do
binary = AgentJobList.encode(job_list) nil ->
Logger.warning("Backup requested but device not found",
device_id: device_id,
job_id: job_id
)
device ->
job = build_backup_job(device, job_id)
job_list = %AgentJobList{jobs: [job]}
binary = AgentJobList.encode(job_list)
push(socket, "backup_job", %{binary: Base.encode64(binary)})
end
push(socket, "backup_job", %{binary: Base.encode64(binary)})
{:noreply, socket} {:noreply, socket}
end end
@ -452,6 +462,53 @@ defmodule ToweropsWeb.AgentChannel do
{:noreply, socket} {:noreply, socket}
end end
defp build_backup_job(device, job_id) do
mikrotik_config = Devices.get_mikrotik_config(device)
backup_filename = "towerops-backup-#{DateTime.to_unix(DateTime.utc_now())}"
chunk_size = 32_768
num_chunks = 10
read_commands =
for i <- 0..(num_chunks - 1) do
%MikrotikCommand{
command: "/file/read",
args: %{
"file" => "#{backup_filename}.rsc",
"offset" => to_string(i * chunk_size),
"chunk-size" => to_string(chunk_size)
}
}
end
%AgentJob{
job_id: job_id,
job_type: :MIKROTIK,
device_id: device.id,
mikrotik_device: %MikrotikDevice{
ip: device.ip_address || "",
username: mikrotik_config.username || "",
password: mikrotik_config.password || "",
port: mikrotik_config.port || 8729,
ssh_port: mikrotik_config.ssh_port || 22,
use_ssl: mikrotik_config.use_ssl || false
},
mikrotik_commands:
[
%MikrotikCommand{
command: "/export",
args: %{"file" => backup_filename, "compact" => "true"}
}
] ++
read_commands ++
[
%MikrotikCommand{
command: "/file/remove",
args: %{"numbers" => "#{backup_filename}.rsc"}
}
]
}
end
@impl true @impl true
@spec handle_in(String.t(), map(), socket()) :: {:noreply, socket()} @spec handle_in(String.t(), map(), socket()) :: {:noreply, socket()}
def handle_in("result", %{"binary" => binary_b64}, socket) when is_binary(binary_b64) do def handle_in("result", %{"binary" => binary_b64}, socket) when is_binary(binary_b64) do

View file

@ -17,38 +17,37 @@ defmodule ToweropsWeb.Api.AccountDataController do
user = conn.assigns.current_scope.user user = conn.assigns.current_scope.user
# Verify user account is confirmed before allowing data export # Verify user account is confirmed before allowing data export
_ = if user.confirmed_at do
if !user.confirmed_at do # Log the data export for audit trail
conn AuditLogger.log_user_data_exported(conn, user.id)
|> put_status(:forbidden)
|> json(%{error: "Email address must be confirmed before exporting account data"})
|> halt()
end
# Log the data export for audit trail # Gather all user data
AuditLogger.log_user_data_exported(conn, user.id) data = %{
profile: build_profile_data(user),
# Gather all user data organizations: build_organizations_data(user),
data = %{ devices: build_devices_data(user),
profile: build_profile_data(user), alerts: build_alerts_data(user),
organizations: build_organizations_data(user), audit_logs: build_audit_logs_data(user),
devices: build_devices_data(user), export_info: %{
alerts: build_alerts_data(user), exported_at: DateTime.utc_now(),
audit_logs: build_audit_logs_data(user), format: "JSON",
export_info: %{ gdpr_article: "Article 15 - Right to Access"
exported_at: DateTime.utc_now(), }
format: "JSON",
gdpr_article: "Article 15 - Right to Access"
} }
}
conn conn
|> put_resp_content_type("application/json") |> put_resp_content_type("application/json")
|> put_resp_header( |> put_resp_header(
"content-disposition", "content-disposition",
"attachment; filename=\"towerops-data-#{user.id}-#{DateTime.to_unix(DateTime.utc_now())}.json\"" "attachment; filename=\"towerops-data-#{user.id}-#{DateTime.to_unix(DateTime.utc_now())}.json\""
) )
|> json(data) |> json(data)
else
conn
|> put_status(:forbidden)
|> json(%{error: "Email address must be confirmed before exporting account data"})
|> halt()
end
end end
# Build profile data # Build profile data

View file

@ -14,7 +14,7 @@ defmodule ToweropsWeb.Api.V1.AgentReleaseWebhookController do
@max_age_seconds 300 @max_age_seconds 300
def create(conn, _params) do def create(conn, _params) do
case verify_optional_signature(conn) do case verify_signature(conn) do
:ok -> :ok ->
case Oban.insert(AgentReleaseWebhookWorker.new(%{})) do case Oban.insert(AgentReleaseWebhookWorker.new(%{})) do
{:ok, _job} -> {:ok, _job} ->
@ -34,17 +34,17 @@ defmodule ToweropsWeb.Api.V1.AgentReleaseWebhookController do
end end
end end
defp verify_optional_signature(conn) do defp verify_signature(conn) do
sig_headers = Plug.Conn.get_req_header(conn, "x-agent-webhook-signature") sig_headers = Plug.Conn.get_req_header(conn, "x-agent-webhook-signature")
case sig_headers do case sig_headers do
[] ->
:ok
[header] -> [header] ->
secret = Application.get_env(:towerops, :agent_webhook_secret) secret = Application.get_env(:towerops, :agent_webhook_secret)
raw_body = conn.private[:raw_body] || "" raw_body = conn.private[:raw_body] || ""
verify_hmac_signature(header, raw_body, secret) verify_hmac_signature(header, raw_body, secret)
[] ->
{:error, :missing_signature}
end end
end end

View file

@ -20,19 +20,25 @@ defmodule ToweropsWeb.Api.V1.MembersController do
def update(conn, %{"id" => user_id, "role" => role}) do def update(conn, %{"id" => user_id, "role" => role}) do
organization_id = conn.assigns.current_organization_id organization_id = conn.assigns.current_organization_id
case Organizations.update_member_role(organization_id, user_id, role) do case authorize_member_management(conn, organization_id) do
{:ok, membership} -> {:ok, conn} ->
membership = Towerops.Repo.preload(membership, :user) case Organizations.update_member_role(organization_id, user_id, role) do
json(conn, %{data: format_member(membership)}) {:ok, membership} ->
membership = Towerops.Repo.preload(membership, :user)
json(conn, %{data: format_member(membership)})
{:error, :cannot_change_owner_role} -> {:error, :cannot_change_owner_role} ->
conn |> put_status(:forbidden) |> json(%{error: "Cannot change owner role"}) conn |> put_status(:forbidden) |> json(%{error: "Cannot change owner role"})
{:error, :not_found} -> {:error, :not_found} ->
conn |> put_status(:not_found) |> json(%{error: "Member not found"}) conn |> put_status(:not_found) |> json(%{error: "Member not found"})
{:error, changeset} -> {:error, changeset} ->
conn |> put_status(:unprocessable_entity) |> json(%{errors: translate_errors(changeset)}) conn |> put_status(:unprocessable_entity) |> json(%{errors: translate_errors(changeset)})
end
{:halt, conn} ->
conn
end end
end end
@ -43,10 +49,44 @@ defmodule ToweropsWeb.Api.V1.MembersController do
def delete(conn, %{"id" => user_id}) do def delete(conn, %{"id" => user_id}) do
organization_id = conn.assigns.current_organization_id organization_id = conn.assigns.current_organization_id
case Organizations.remove_member(organization_id, user_id) do case authorize_member_management(conn, organization_id) do
{:ok, _} -> conn |> put_status(:no_content) |> send_resp(204, "") {:ok, conn} ->
{:error, :cannot_remove_owner} -> conn |> put_status(:forbidden) |> json(%{error: "Cannot remove owner"}) case Organizations.remove_member(organization_id, user_id) do
{:error, :not_found} -> conn |> put_status(:not_found) |> json(%{error: "Member not found"}) {:ok, _} -> conn |> put_status(:no_content) |> send_resp(204, "")
{:error, :cannot_remove_owner} -> conn |> put_status(:forbidden) |> json(%{error: "Cannot remove owner"})
{:error, :not_found} -> conn |> put_status(:not_found) |> json(%{error: "Member not found"})
end
{:halt, conn} ->
conn
end
end
defp authorize_member_management(conn, organization_id) do
user = conn.assigns[:current_user]
if user do
membership = Organizations.get_membership(organization_id, user.id)
if membership && membership.role in [:owner, :admin] do
{:ok, conn}
else
conn =
conn
|> put_status(:forbidden)
|> json(%{error: "Only owners and admins can manage members"})
|> halt()
{:halt, conn}
end
else
conn =
conn
|> put_status(:unauthorized)
|> json(%{error: "Authentication required"})
|> halt()
{:halt, conn}
end end
end end

View file

@ -932,71 +932,19 @@ defmodule ToweropsWeb.DeviceLive.Show do
end end
defp trigger_manual_backup(socket, device, agent_token_id) do defp trigger_manual_backup(socket, device, agent_token_id) do
alias Towerops.Agent.AgentJob
alias Towerops.Agent.MikrotikCommand
alias Towerops.Agent.MikrotikDevice
alias Towerops.Devices.BackupRequests alias Towerops.Devices.BackupRequests
job_id = "backup:#{device.id}:#{DateTime.to_unix(DateTime.utc_now())}" job_id = "backup:#{device.id}:#{DateTime.to_unix(DateTime.utc_now())}"
mikrotik_config = Devices.get_mikrotik_config(device)
# Generate unique filename for this backup (without .rsc extension, RouterOS adds it)
backup_filename = "towerops-backup-#{DateTime.to_unix(DateTime.utc_now())}"
# Build chunked read commands
# Max chunk size is 32KB (32768 bytes), we'll read 10 chunks to cover 320KB
chunk_size = 32_768
num_chunks = 10
read_commands =
for i <- 0..(num_chunks - 1) do
%MikrotikCommand{
command: "/file/read",
args: %{
"file" => "#{backup_filename}.rsc",
"offset" => to_string(i * chunk_size),
"chunk-size" => to_string(chunk_size)
}
}
end
job = %AgentJob{
job_id: job_id,
job_type: :MIKROTIK,
device_id: device.id,
mikrotik_device: %MikrotikDevice{
ip: device.ip_address,
username: mikrotik_config.username,
password: mikrotik_config.password,
port: mikrotik_config.port,
ssh_port: mikrotik_config.ssh_port,
use_ssl: mikrotik_config.use_ssl
},
mikrotik_commands:
[
# Step 1: Export config to file on router (compact format, no defaults)
%MikrotikCommand{
command: "/export",
args: %{"file" => backup_filename, "compact" => ""}
}
] ++
read_commands ++
[
# Final step: Delete the temporary file
%MikrotikCommand{
command: "/file/remove",
args: %{"numbers" => "#{backup_filename}.rsc"}
}
]
}
case BackupRequests.create_request(device.id, job_id, "manual") do case BackupRequests.create_request(device.id, job_id, "manual") do
{:ok, _request} -> {:ok, _request} ->
# Send device id + job id over PubSub — agent channel resolves
# credentials itself to avoid exposing them on PubSub.
_ = _ =
Phoenix.PubSub.broadcast( Phoenix.PubSub.broadcast(
Towerops.PubSub, Towerops.PubSub,
"agent:#{agent_token_id}:backup", "agent:#{agent_token_id}:backup",
{:backup_requested, job} {:backup_requested, device.id, job_id}
) )
{:noreply, {:noreply,

View file

@ -15,7 +15,7 @@ defmodule ToweropsWeb.Plugs.BruteForceProtection do
## Exemptions ## Exemptions
- Authenticated users (conn.assigns[:current_user] present) - Authenticated users (checked in before_send callback, since auth runs after this plug)
- Agent WebSocket connections (/socket/agent - authenticated at channel join) - Agent WebSocket connections (/socket/agent - authenticated at channel join)
- Whitelisted IPs (checked via BruteForce context) - Whitelisted IPs (checked via BruteForce context)
@ -43,10 +43,6 @@ defmodule ToweropsWeb.Plugs.BruteForceProtection do
ip_address = RemoteIp.from_conn(conn) ip_address = RemoteIp.from_conn(conn)
cond do cond do
# Skip for authenticated users
conn.assigns[:current_user] != nil ->
conn
# Skip for agent WebSocket connections (authenticated at channel join) # Skip for agent WebSocket connections (authenticated at channel join)
String.starts_with?(conn.request_path, "/socket/agent") -> String.starts_with?(conn.request_path, "/socket/agent") ->
conn conn
@ -69,7 +65,12 @@ defmodule ToweropsWeb.Plugs.BruteForceProtection do
defp register_404_tracker(conn, ip_address) do defp register_404_tracker(conn, ip_address) do
register_before_send(conn, fn response_conn -> register_before_send(conn, fn response_conn ->
track_404_if_needed(response_conn, ip_address, conn.request_path) # Skip tracking for authenticated users (auth runs after this plug,
# but before_send runs after the full pipeline including auth).
if !response_conn.assigns[:current_user] do
track_404_if_needed(response_conn, ip_address, conn.request_path)
end
response_conn response_conn
end) end)
end end

View file

@ -690,9 +690,10 @@ defmodule ToweropsWeb.UserAuth do
# Attach LiveView event handler for accepting updated policies # Attach LiveView event handler for accepting updated policies
socket = socket =
LiveView.attach_hook(socket, :policy_consent_handler, :handle_event, fn LiveView.attach_hook(socket, :policy_consent_handler, :handle_event, fn
"accept_updated_policies", %{"user-id" => user_id}, socket -> "accept_updated_policies", _params, socket ->
# Grant consent for all policies that need re-consent # Grant consent for all policies that need re-consent
policies_needing_consent = Map.get(socket.assigns, :policies_needing_consent, []) policies_needing_consent = Map.get(socket.assigns, :policies_needing_consent, [])
user_id = socket.assigns.current_scope.user.id
Enum.each(policies_needing_consent, fn policy_type -> Enum.each(policies_needing_consent, fn policy_type ->
Accounts.grant_consent(user_id, policy_type) Accounts.grant_consent(user_id, policy_type)

View file

@ -2,7 +2,7 @@
# Setup script for Stripe billing meter # Setup script for Stripe billing meter
stripe_key = "sk_test_51T7zcQS77kvnTfgyu0DCQU2xVKzeaneVCueHaXV3jsIw6HAwGWWllEL3J8jGZOybHAtPyu6oYIiuvJHhReFtTycE00VrJYJ8Ao" stripe_key = System.get_env("STRIPE_SECRET_KEY", "sk_test_use_your_own_key")
product_id = "prod_U6DpFdl21ftiDz" product_id = "prod_U6DpFdl21ftiDz"
IO.puts("Creating Stripe billing meter...") IO.puts("Creating Stripe billing meter...")

View file

@ -93,22 +93,13 @@ defmodule Towerops.Workers.MikrotikBackupWorkerTest do
assert :ok = perform_job(MikrotikBackupWorker, %{}) assert :ok = perform_job(MikrotikBackupWorker, %{})
end) end)
assert_receive {:backup_requested, job}, 1_000 assert_receive {:backup_requested, received_device_id, job_id}, 1_000
assert job.device_id == device.id assert received_device_id == device.id
assert job.mikrotik_device.username == "admin" assert is_binary(job_id)
assert job.mikrotik_device.port == 8729 assert String.starts_with?(job_id, "backup:#{device.id}:")
assert job.mikrotik_device.ssh_port == 22
assert job.mikrotik_device.use_ssl == true
assert job.job_type == :MIKROTIK
# 1 export + 10 read chunks + 1 remove = 12 commands
assert length(job.mikrotik_commands) == 12
[first | _] = job.mikrotik_commands
assert first.command == "/export"
last = List.last(job.mikrotik_commands)
assert last.command == "/file/remove"
# Backup request was created in DB # Backup request was created in DB
assert BackupRequests.get_request_by_job_id(job.job_id) assert BackupRequests.get_request_by_job_id(job_id)
end end
test "returns {:error, _} when at least one backup request fails to create" do test "returns {:error, _} when at least one backup request fails to create" do

View file

@ -10,7 +10,6 @@ defmodule ToweropsWeb.AgentChannelProcessingTest do
alias Towerops.AccountsFixtures alias Towerops.AccountsFixtures
alias Towerops.Agent.AgentHeartbeat alias Towerops.Agent.AgentHeartbeat
alias Towerops.Agent.AgentJob
alias Towerops.Agent.AgentJobList alias Towerops.Agent.AgentJobList
alias Towerops.Agent.MikrotikResult alias Towerops.Agent.MikrotikResult
alias Towerops.Agent.MikrotikSentence alias Towerops.Agent.MikrotikSentence
@ -288,23 +287,19 @@ defmodule ToweropsWeb.AgentChannelProcessingTest do
agent_token: agent_token, agent_token: agent_token,
device: device device: device
} do } do
job = %AgentJob{ job_id = "backup:#{device.id}"
job_id: "backup:#{device.id}",
job_type: :MIKROTIK,
device_id: device.id
}
Phoenix.PubSub.broadcast( Phoenix.PubSub.broadcast(
Towerops.PubSub, Towerops.PubSub,
"agent:#{agent_token.id}:backup", "agent:#{agent_token.id}:backup",
{:backup_requested, job} {:backup_requested, device.id, job_id}
) )
assert_push "backup_job", %{binary: jobs_binary}, 200 assert_push "backup_job", %{binary: jobs_binary}, 200
{:ok, decoded} = Base.decode64(jobs_binary) {:ok, decoded} = Base.decode64(jobs_binary)
{:ok, job_list} = AgentJobList.decode(decoded) {:ok, job_list} = AgentJobList.decode(decoded)
assert hd(job_list.jobs).job_id == "backup:#{device.id}" assert hd(job_list.jobs).job_id == job_id
end end
end end

View file

@ -6,7 +6,6 @@ defmodule ToweropsWeb.AgentChannelTest do
alias Towerops.AccountsFixtures alias Towerops.AccountsFixtures
alias Towerops.Agent.AgentError alias Towerops.Agent.AgentError
alias Towerops.Agent.AgentHeartbeat alias Towerops.Agent.AgentHeartbeat
alias Towerops.Agent.AgentJob
alias Towerops.Agent.AgentJobList alias Towerops.Agent.AgentJobList
alias Towerops.Agent.CheckResult, as: CheckResultProto alias Towerops.Agent.CheckResult, as: CheckResultProto
alias Towerops.Agent.CredentialTestResult alias Towerops.Agent.CredentialTestResult
@ -771,17 +770,11 @@ defmodule ToweropsWeb.AgentChannelTest do
end end
end end
describe "handle_info {:backup_requested, job}" do describe "handle_info {:backup_requested, device_id, job_id}" do
test "pushes backup job to agent", %{socket: socket, device: device} do test "pushes backup job to agent", %{socket: socket, device: device} do
job = %AgentJob{ job_id = "backup:#{device.id}"
job_id: "backup:#{device.id}",
job_type: :MIKROTIK,
device_id: device.id,
mikrotik_device: nil,
mikrotik_commands: []
}
send(socket.channel_pid, {:backup_requested, job}) send(socket.channel_pid, {:backup_requested, device.id, job_id})
assert_push "backup_job", %{binary: jobs_binary} assert_push "backup_job", %{binary: jobs_binary}
@ -789,7 +782,7 @@ defmodule ToweropsWeb.AgentChannelTest do
{:ok, job_list} = AgentJobList.decode(decoded_binary) {:ok, job_list} = AgentJobList.decode(decoded_binary)
backup_job = hd(job_list.jobs) backup_job = hd(job_list.jobs)
assert backup_job.job_id == "backup:#{device.id}" assert backup_job.job_id == job_id
assert backup_job.device_id == device.id assert backup_job.device_id == device.id
end end
end end
@ -2170,17 +2163,12 @@ defmodule ToweropsWeb.AgentChannelTest do
agent_token: agent_token, agent_token: agent_token,
device: device device: device
} do } do
# Channel should be subscribed to "agent:#{agent_token.id}:backup" job_id = "backup:#{device.id}"
job = %AgentJob{
job_id: "backup:#{device.id}",
job_type: :MIKROTIK,
device_id: device.id
}
Phoenix.PubSub.broadcast( Phoenix.PubSub.broadcast(
Towerops.PubSub, Towerops.PubSub,
"agent:#{agent_token.id}:backup", "agent:#{agent_token.id}:backup",
{:backup_requested, job} {:backup_requested, device.id, job_id}
) )
assert_push "backup_job", %{binary: jobs_binary}, 200 assert_push "backup_job", %{binary: jobs_binary}, 200

View file

@ -36,11 +36,29 @@ defmodule ToweropsWeb.Api.V1.AgentReleaseWebhookControllerTest do
describe "create/2 response" do describe "create/2 response" do
test "enqueues job and returns 200", %{conn: conn} do test "enqueues job and returns 200", %{conn: conn} do
conn = post(conn, ~p"/api/v1/webhooks/agent-release") ts = :second |> System.system_time() |> to_string()
secret = Application.get_env(:towerops, :agent_webhook_secret)
raw_body = ""
sig =
:hmac
|> :crypto.mac(:sha256, secret, ts <> "." <> raw_body)
|> Base.encode16(case: :lower)
conn =
conn
|> put_req_header("x-agent-webhook-signature", "t=#{ts},v1=#{sig}")
|> post(~p"/api/v1/webhooks/agent-release")
assert json_response(conn, 200) == %{"status" => "ok"} assert json_response(conn, 200) == %{"status" => "ok"}
assert_enqueued(worker: AgentReleaseWebhookWorker) assert_enqueued(worker: AgentReleaseWebhookWorker)
end end
test "returns 401 when signature header is missing", %{conn: conn} do
conn = post(conn, ~p"/api/v1/webhooks/agent-release")
assert json_response(conn, 401)["error"] == "Signature verification failed"
end
end end
describe "check_timestamp/1" do describe "check_timestamp/1" do

View file

@ -132,9 +132,10 @@ defmodule ToweropsWeb.Plugs.BruteForceProtectionTest do
|> BruteForceProtection.call([]) |> BruteForceProtection.call([])
refute conn.halted refute conn.halted
# No before_send callback registered for authenticated users — they # before_send callback is registered but will skip tracking for
# bypass the 404 tracker entirely. # authenticated users (auth check moved into the callback since
assert conn.private[:before_send] in [nil, []] # this plug runs before authentication).
assert is_list(conn.private[:before_send])
end end
test "passes through immediately for agent socket paths" do test "passes through immediately for agent socket paths" do