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) {
|
||||
const marker = L.marker([site.latitude, site.longitude])
|
||||
|
||||
let popupContent = `<div class="p-2">
|
||||
<h3 class="font-semibold text-lg mb-1">${site.name}</h3>`
|
||||
const popupEl = document.createElement('div')
|
||||
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) {
|
||||
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) {
|
||||
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">
|
||||
${site.latitude.toFixed(4)}, ${site.longitude.toFixed(4)}
|
||||
</p>`
|
||||
const coordsP = popupEl.appendChild(document.createElement('p'))
|
||||
coordsP.className = 'text-xs text-gray-500 mb-2'
|
||||
coordsP.textContent = `${site.latitude.toFixed(4)}, ${site.longitude.toFixed(4)}`
|
||||
|
||||
if (site.device_count > 0) {
|
||||
popupContent += `<p class="text-xs text-blue-600 mb-2">
|
||||
${site.device_count} device${site.device_count !== 1 ? 's' : ''}
|
||||
</p>`
|
||||
const deviceP = popupEl.appendChild(document.createElement('p'))
|
||||
deviceP.className = 'text-xs text-blue-600 mb-2'
|
||||
deviceP.textContent = `${site.device_count} device${site.device_count !== 1 ? 's' : ''}`
|
||||
}
|
||||
|
||||
popupContent += `<button
|
||||
data-site-id="${site.id}"
|
||||
data-action="view-site"
|
||||
class="text-xs bg-blue-600 text-white px-2 py-1 rounded hover:bg-blue-700"
|
||||
>
|
||||
View Site →
|
||||
</button></div>`
|
||||
const button = popupEl.appendChild(document.createElement('button'))
|
||||
button.setAttribute('data-site-id', site.id)
|
||||
button.setAttribute('data-action', 'view-site')
|
||||
button.className = 'text-xs bg-blue-600 text-white px-2 py-1 rounded hover:bg-blue-700'
|
||||
button.textContent = 'View Site →'
|
||||
|
||||
marker.bindPopup(popupContent)
|
||||
marker.bindPopup(popupEl)
|
||||
marker.on('popupopen', () => {
|
||||
const button = marker.getPopup()?.getElement()?.querySelector('[data-action="view-site"]') as HTMLElement
|
||||
if (button) {
|
||||
|
|
|
|||
|
|
@ -239,8 +239,7 @@ config :towerops, dev_routes: true
|
|||
|
||||
# Stripe configuration for development
|
||||
config :towerops,
|
||||
stripe_secret_key:
|
||||
"sk_test_51T7zcQS77kvnTfgyu0DCQU2xVKzeaneVCueHaXV3jsIw6HAwGWWllEL3J8jGZOybHAtPyu6oYIiuvJHhReFtTycE00VrJYJ8Ao",
|
||||
stripe_secret_key: System.get_env("STRIPE_SECRET_KEY", "sk_test_use_your_own_key"),
|
||||
stripe_webhook_secret: "whsec_dev_fake_secret",
|
||||
stripe_price_id: "price_1T81XBS77kvnTfgyPlw1jF8N",
|
||||
stripe_meter_id: "mtr_test_61UHPU067veEZ7bhl41S77kvnTfgyODY"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# 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
|
||||
# # edit k8s/secrets.yaml — fill in real values
|
||||
|
|
@ -26,12 +26,12 @@ metadata:
|
|||
type: Opaque
|
||||
stringData:
|
||||
# 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)
|
||||
SECRET_KEY_BASE: ""
|
||||
SECRET_KEY_BASE: "CHANGE_ME_mix_phx_gen_secret"
|
||||
# Generate with: openssl rand -base64 32
|
||||
# CRITICAL: losing this makes encrypted columns unrecoverable.
|
||||
CLOAK_KEY: ""
|
||||
CLOAK_KEY: "CHANGE_ME_openssl_rand_-base64_32"
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
|
|
@ -41,14 +41,12 @@ metadata:
|
|||
type: Opaque
|
||||
stringData:
|
||||
# Full ecto:// URL — what runtime.exs reads.
|
||||
DATABASE_URL: "ecto://USER:PASSWORD@HOST:5432/DATABASE"
|
||||
# The individual fields are convenience for ops — not required by the
|
||||
# app but kept here for parity with the existing prod secret.
|
||||
POSTGRES_HOST: ""
|
||||
DATABASE_URL: "postgresql://towerops:CHANGE_ME@postgres-host:5432/towerops"
|
||||
POSTGRES_HOST: "CHANGE_ME_postgres_host"
|
||||
POSTGRES_PORT: "5432"
|
||||
POSTGRES_DB: ""
|
||||
POSTGRES_USER: ""
|
||||
POSTGRES_PASSWORD: ""
|
||||
POSTGRES_DB: "towerops"
|
||||
POSTGRES_USER: "towerops"
|
||||
POSTGRES_PASSWORD: "CHANGE_ME"
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
|
|
@ -57,9 +55,9 @@ metadata:
|
|||
namespace: towerops
|
||||
type: Opaque
|
||||
stringData:
|
||||
AWS_ACCESS_KEY_ID: ""
|
||||
AWS_SECRET_ACCESS_KEY: ""
|
||||
AWS_REGION: ""
|
||||
AWS_ACCESS_KEY_ID: "CHANGE_ME"
|
||||
AWS_SECRET_ACCESS_KEY: "CHANGE_ME"
|
||||
AWS_REGION: "us-east-1"
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
|
|
@ -68,11 +66,9 @@ metadata:
|
|||
namespace: towerops
|
||||
type: Opaque
|
||||
stringData:
|
||||
# K8s pattern — runtime.exs builds the connection from these
|
||||
# individual fields. Dokku alternative is REDIS_URL.
|
||||
REDIS_HOST: ""
|
||||
REDIS_HOST: "CHANGE_ME_redis_host"
|
||||
REDIS_PORT: "6379"
|
||||
REDIS_PASSWORD: ""
|
||||
REDIS_PASSWORD: "CHANGE_ME"
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
|
|
@ -81,11 +77,10 @@ metadata:
|
|||
namespace: towerops
|
||||
type: Opaque
|
||||
stringData:
|
||||
# Stripe — every key is optional; the deployment marks them optional: true.
|
||||
STRIPE_SECRET_KEY: ""
|
||||
STRIPE_WEBHOOK_SECRET: ""
|
||||
STRIPE_PRICE_ID: ""
|
||||
STRIPE_METER_ID: ""
|
||||
STRIPE_SECRET_KEY: "CHANGE_ME_sk_live_..."
|
||||
STRIPE_WEBHOOK_SECRET: "CHANGE_ME_whsec_..."
|
||||
STRIPE_PRICE_ID: "CHANGE_ME_price_..."
|
||||
STRIPE_METER_ID: "CHANGE_ME_mtr_..."
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
|
|
@ -94,8 +89,5 @@ metadata:
|
|||
namespace: towerops
|
||||
type: Opaque
|
||||
stringData:
|
||||
# DeepSeek API key — get one at https://platform.deepseek.com/
|
||||
# Without this, insight LLM enrichment stays no-op (insights still render).
|
||||
DEEPSEEK_API_KEY: ""
|
||||
# Optional: override the default model (default: deepseek-v4-pro).
|
||||
DEEPSEEK_API_KEY: "CHANGE_ME_sk_..."
|
||||
DEEPSEEK_MODEL: ""
|
||||
|
|
|
|||
|
|
@ -383,7 +383,7 @@ defmodule Towerops.Accounts do
|
|||
fn ->
|
||||
case Repo.update(changeset) do
|
||||
{: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)))
|
||||
{:ok, {user, tokens_to_expire}}
|
||||
|
||||
|
|
|
|||
|
|
@ -53,10 +53,23 @@ defmodule Towerops.StatusPages.StatusPageConfig do
|
|||
changeset
|
||||
|
||||
css when is_binary(css) ->
|
||||
if String.contains?(String.downcase(css), "url(") do
|
||||
add_error(changeset, :custom_css, "url() references are not allowed in custom CSS")
|
||||
else
|
||||
changeset
|
||||
downcased = String.downcase(css)
|
||||
|
||||
cond do
|
||||
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
|
||||
|
||||
_ ->
|
||||
|
|
|
|||
|
|
@ -14,9 +14,6 @@ defmodule Towerops.Workers.MikrotikBackupWorker do
|
|||
|
||||
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.BackupRequests
|
||||
|
||||
|
|
@ -65,68 +62,16 @@ defmodule Towerops.Workers.MikrotikBackupWorker do
|
|||
# Build backup job
|
||||
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
|
||||
case BackupRequests.create_request(device.id, job_id) do
|
||||
{: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(
|
||||
Towerops.PubSub,
|
||||
"agent:#{agent_token_id}:backup",
|
||||
{:backup_requested, job}
|
||||
{:backup_requested, device.id, job_id}
|
||||
)
|
||||
|
||||
Logger.info("Backup job sent to agent",
|
||||
|
|
|
|||
|
|
@ -316,17 +316,27 @@ defmodule ToweropsWeb.AgentChannel do
|
|||
end
|
||||
|
||||
# Handle PubSub broadcast when backup is requested for a device
|
||||
def handle_info({:backup_requested, job}, socket) do
|
||||
Logger.info("Backup requested for device, sending backup job to agent",
|
||||
def handle_info({:backup_requested, device_id, job_id}, socket) do
|
||||
Logger.info("Backup requested for device, assembling and sending backup job to agent",
|
||||
agent_token_id: socket.assigns.agent_token_id,
|
||||
device_id: job.device_id,
|
||||
job_id: job.job_id
|
||||
device_id: device_id,
|
||||
job_id: job_id
|
||||
)
|
||||
|
||||
job_list = %AgentJobList{jobs: [job]}
|
||||
binary = AgentJobList.encode(job_list)
|
||||
case Devices.get_device_with_details(device_id) do
|
||||
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}
|
||||
end
|
||||
|
||||
|
|
@ -452,6 +462,53 @@ defmodule ToweropsWeb.AgentChannel do
|
|||
{:noreply, socket}
|
||||
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
|
||||
@spec handle_in(String.t(), map(), socket()) :: {:noreply, socket()}
|
||||
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
|
||||
|
||||
# Verify user account is confirmed before allowing data export
|
||||
_ =
|
||||
if !user.confirmed_at do
|
||||
conn
|
||||
|> put_status(:forbidden)
|
||||
|> json(%{error: "Email address must be confirmed before exporting account data"})
|
||||
|> halt()
|
||||
end
|
||||
if user.confirmed_at do
|
||||
# Log the data export for audit trail
|
||||
AuditLogger.log_user_data_exported(conn, user.id)
|
||||
|
||||
# Log the data export for audit trail
|
||||
AuditLogger.log_user_data_exported(conn, user.id)
|
||||
|
||||
# Gather all user data
|
||||
data = %{
|
||||
profile: build_profile_data(user),
|
||||
organizations: build_organizations_data(user),
|
||||
devices: build_devices_data(user),
|
||||
alerts: build_alerts_data(user),
|
||||
audit_logs: build_audit_logs_data(user),
|
||||
export_info: %{
|
||||
exported_at: DateTime.utc_now(),
|
||||
format: "JSON",
|
||||
gdpr_article: "Article 15 - Right to Access"
|
||||
# Gather all user data
|
||||
data = %{
|
||||
profile: build_profile_data(user),
|
||||
organizations: build_organizations_data(user),
|
||||
devices: build_devices_data(user),
|
||||
alerts: build_alerts_data(user),
|
||||
audit_logs: build_audit_logs_data(user),
|
||||
export_info: %{
|
||||
exported_at: DateTime.utc_now(),
|
||||
format: "JSON",
|
||||
gdpr_article: "Article 15 - Right to Access"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
conn
|
||||
|> put_resp_content_type("application/json")
|
||||
|> put_resp_header(
|
||||
"content-disposition",
|
||||
"attachment; filename=\"towerops-data-#{user.id}-#{DateTime.to_unix(DateTime.utc_now())}.json\""
|
||||
)
|
||||
|> json(data)
|
||||
conn
|
||||
|> put_resp_content_type("application/json")
|
||||
|> put_resp_header(
|
||||
"content-disposition",
|
||||
"attachment; filename=\"towerops-data-#{user.id}-#{DateTime.to_unix(DateTime.utc_now())}.json\""
|
||||
)
|
||||
|> json(data)
|
||||
else
|
||||
conn
|
||||
|> put_status(:forbidden)
|
||||
|> json(%{error: "Email address must be confirmed before exporting account data"})
|
||||
|> halt()
|
||||
end
|
||||
end
|
||||
|
||||
# Build profile data
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ defmodule ToweropsWeb.Api.V1.AgentReleaseWebhookController do
|
|||
@max_age_seconds 300
|
||||
|
||||
def create(conn, _params) do
|
||||
case verify_optional_signature(conn) do
|
||||
case verify_signature(conn) do
|
||||
:ok ->
|
||||
case Oban.insert(AgentReleaseWebhookWorker.new(%{})) do
|
||||
{:ok, _job} ->
|
||||
|
|
@ -34,17 +34,17 @@ defmodule ToweropsWeb.Api.V1.AgentReleaseWebhookController do
|
|||
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")
|
||||
|
||||
case sig_headers do
|
||||
[] ->
|
||||
:ok
|
||||
|
||||
[header] ->
|
||||
secret = Application.get_env(:towerops, :agent_webhook_secret)
|
||||
raw_body = conn.private[:raw_body] || ""
|
||||
verify_hmac_signature(header, raw_body, secret)
|
||||
|
||||
[] ->
|
||||
{:error, :missing_signature}
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -20,19 +20,25 @@ defmodule ToweropsWeb.Api.V1.MembersController do
|
|||
def update(conn, %{"id" => user_id, "role" => role}) do
|
||||
organization_id = conn.assigns.current_organization_id
|
||||
|
||||
case Organizations.update_member_role(organization_id, user_id, role) do
|
||||
{:ok, membership} ->
|
||||
membership = Towerops.Repo.preload(membership, :user)
|
||||
json(conn, %{data: format_member(membership)})
|
||||
case authorize_member_management(conn, organization_id) do
|
||||
{:ok, conn} ->
|
||||
case Organizations.update_member_role(organization_id, user_id, role) do
|
||||
{:ok, membership} ->
|
||||
membership = Towerops.Repo.preload(membership, :user)
|
||||
json(conn, %{data: format_member(membership)})
|
||||
|
||||
{:error, :cannot_change_owner_role} ->
|
||||
conn |> put_status(:forbidden) |> json(%{error: "Cannot change owner role"})
|
||||
{:error, :cannot_change_owner_role} ->
|
||||
conn |> put_status(:forbidden) |> json(%{error: "Cannot change owner role"})
|
||||
|
||||
{:error, :not_found} ->
|
||||
conn |> put_status(:not_found) |> json(%{error: "Member not found"})
|
||||
{:error, :not_found} ->
|
||||
conn |> put_status(:not_found) |> json(%{error: "Member not found"})
|
||||
|
||||
{:error, changeset} ->
|
||||
conn |> put_status(:unprocessable_entity) |> json(%{errors: translate_errors(changeset)})
|
||||
{:error, changeset} ->
|
||||
conn |> put_status(:unprocessable_entity) |> json(%{errors: translate_errors(changeset)})
|
||||
end
|
||||
|
||||
{:halt, conn} ->
|
||||
conn
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -43,10 +49,44 @@ defmodule ToweropsWeb.Api.V1.MembersController do
|
|||
def delete(conn, %{"id" => user_id}) do
|
||||
organization_id = conn.assigns.current_organization_id
|
||||
|
||||
case Organizations.remove_member(organization_id, user_id) do
|
||||
{: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"})
|
||||
case authorize_member_management(conn, organization_id) do
|
||||
{:ok, conn} ->
|
||||
case Organizations.remove_member(organization_id, user_id) do
|
||||
{: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
|
||||
|
||||
|
|
|
|||
|
|
@ -932,71 +932,19 @@ defmodule ToweropsWeb.DeviceLive.Show do
|
|||
end
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
{:ok, _request} ->
|
||||
# Send device id + job id over PubSub — agent channel resolves
|
||||
# credentials itself to avoid exposing them on PubSub.
|
||||
_ =
|
||||
Phoenix.PubSub.broadcast(
|
||||
Towerops.PubSub,
|
||||
"agent:#{agent_token_id}:backup",
|
||||
{:backup_requested, job}
|
||||
{:backup_requested, device.id, job_id}
|
||||
)
|
||||
|
||||
{:noreply,
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ defmodule ToweropsWeb.Plugs.BruteForceProtection do
|
|||
|
||||
## 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)
|
||||
- Whitelisted IPs (checked via BruteForce context)
|
||||
|
||||
|
|
@ -43,10 +43,6 @@ defmodule ToweropsWeb.Plugs.BruteForceProtection do
|
|||
ip_address = RemoteIp.from_conn(conn)
|
||||
|
||||
cond do
|
||||
# Skip for authenticated users
|
||||
conn.assigns[:current_user] != nil ->
|
||||
conn
|
||||
|
||||
# Skip for agent WebSocket connections (authenticated at channel join)
|
||||
String.starts_with?(conn.request_path, "/socket/agent") ->
|
||||
conn
|
||||
|
|
@ -69,7 +65,12 @@ defmodule ToweropsWeb.Plugs.BruteForceProtection do
|
|||
|
||||
defp register_404_tracker(conn, ip_address) do
|
||||
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
|
||||
end)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -690,9 +690,10 @@ defmodule ToweropsWeb.UserAuth do
|
|||
# Attach LiveView event handler for accepting updated policies
|
||||
socket =
|
||||
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
|
||||
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 ->
|
||||
Accounts.grant_consent(user_id, policy_type)
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
# 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"
|
||||
|
||||
IO.puts("Creating Stripe billing meter...")
|
||||
|
|
|
|||
|
|
@ -93,22 +93,13 @@ defmodule Towerops.Workers.MikrotikBackupWorkerTest do
|
|||
assert :ok = perform_job(MikrotikBackupWorker, %{})
|
||||
end)
|
||||
|
||||
assert_receive {:backup_requested, job}, 1_000
|
||||
assert job.device_id == device.id
|
||||
assert job.mikrotik_device.username == "admin"
|
||||
assert job.mikrotik_device.port == 8729
|
||||
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"
|
||||
assert_receive {:backup_requested, received_device_id, job_id}, 1_000
|
||||
assert received_device_id == device.id
|
||||
assert is_binary(job_id)
|
||||
assert String.starts_with?(job_id, "backup:#{device.id}:")
|
||||
|
||||
# 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
|
||||
|
||||
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.Agent.AgentHeartbeat
|
||||
alias Towerops.Agent.AgentJob
|
||||
alias Towerops.Agent.AgentJobList
|
||||
alias Towerops.Agent.MikrotikResult
|
||||
alias Towerops.Agent.MikrotikSentence
|
||||
|
|
@ -288,23 +287,19 @@ defmodule ToweropsWeb.AgentChannelProcessingTest do
|
|||
agent_token: agent_token,
|
||||
device: device
|
||||
} do
|
||||
job = %AgentJob{
|
||||
job_id: "backup:#{device.id}",
|
||||
job_type: :MIKROTIK,
|
||||
device_id: device.id
|
||||
}
|
||||
job_id = "backup:#{device.id}"
|
||||
|
||||
Phoenix.PubSub.broadcast(
|
||||
Towerops.PubSub,
|
||||
"agent:#{agent_token.id}:backup",
|
||||
{:backup_requested, job}
|
||||
{:backup_requested, device.id, job_id}
|
||||
)
|
||||
|
||||
assert_push "backup_job", %{binary: jobs_binary}, 200
|
||||
|
||||
{:ok, decoded} = Base.decode64(jobs_binary)
|
||||
{: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
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ defmodule ToweropsWeb.AgentChannelTest do
|
|||
alias Towerops.AccountsFixtures
|
||||
alias Towerops.Agent.AgentError
|
||||
alias Towerops.Agent.AgentHeartbeat
|
||||
alias Towerops.Agent.AgentJob
|
||||
alias Towerops.Agent.AgentJobList
|
||||
alias Towerops.Agent.CheckResult, as: CheckResultProto
|
||||
alias Towerops.Agent.CredentialTestResult
|
||||
|
|
@ -771,17 +770,11 @@ defmodule ToweropsWeb.AgentChannelTest do
|
|||
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
|
||||
job = %AgentJob{
|
||||
job_id: "backup:#{device.id}",
|
||||
job_type: :MIKROTIK,
|
||||
device_id: device.id,
|
||||
mikrotik_device: nil,
|
||||
mikrotik_commands: []
|
||||
}
|
||||
job_id = "backup:#{device.id}"
|
||||
|
||||
send(socket.channel_pid, {:backup_requested, job})
|
||||
send(socket.channel_pid, {:backup_requested, device.id, job_id})
|
||||
|
||||
assert_push "backup_job", %{binary: jobs_binary}
|
||||
|
||||
|
|
@ -789,7 +782,7 @@ defmodule ToweropsWeb.AgentChannelTest do
|
|||
{:ok, job_list} = AgentJobList.decode(decoded_binary)
|
||||
|
||||
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
|
||||
end
|
||||
end
|
||||
|
|
@ -2170,17 +2163,12 @@ defmodule ToweropsWeb.AgentChannelTest do
|
|||
agent_token: agent_token,
|
||||
device: device
|
||||
} do
|
||||
# Channel should be subscribed to "agent:#{agent_token.id}:backup"
|
||||
job = %AgentJob{
|
||||
job_id: "backup:#{device.id}",
|
||||
job_type: :MIKROTIK,
|
||||
device_id: device.id
|
||||
}
|
||||
job_id = "backup:#{device.id}"
|
||||
|
||||
Phoenix.PubSub.broadcast(
|
||||
Towerops.PubSub,
|
||||
"agent:#{agent_token.id}:backup",
|
||||
{:backup_requested, job}
|
||||
{:backup_requested, device.id, job_id}
|
||||
)
|
||||
|
||||
assert_push "backup_job", %{binary: jobs_binary}, 200
|
||||
|
|
|
|||
|
|
@ -36,11 +36,29 @@ defmodule ToweropsWeb.Api.V1.AgentReleaseWebhookControllerTest do
|
|||
|
||||
describe "create/2 response" 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_enqueued(worker: AgentReleaseWebhookWorker)
|
||||
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
|
||||
|
||||
describe "check_timestamp/1" do
|
||||
|
|
|
|||
|
|
@ -132,9 +132,10 @@ defmodule ToweropsWeb.Plugs.BruteForceProtectionTest do
|
|||
|> BruteForceProtection.call([])
|
||||
|
||||
refute conn.halted
|
||||
# No before_send callback registered for authenticated users — they
|
||||
# bypass the 404 tracker entirely.
|
||||
assert conn.private[:before_send] in [nil, []]
|
||||
# before_send callback is registered but will skip tracking for
|
||||
# authenticated users (auth check moved into the callback since
|
||||
# this plug runs before authentication).
|
||||
assert is_list(conn.private[:before_send])
|
||||
end
|
||||
|
||||
test "passes through immediately for agent socket paths" do
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue