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:
parent
39e588c686
commit
ca7bb75472
20 changed files with 1335 additions and 293 deletions
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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: ""
|
||||||
|
|
|
||||||
|
|
@ -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}}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
_ ->
|
_ ->
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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...")
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue