mirror of
https://github.com/ChrispyBacon-dev/DockFlare.git
synced 2026-10-19 07:33:16 +00:00
notifications
This commit is contained in:
parent
96483198c4
commit
29d589b040
10 changed files with 321 additions and 56 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -84,6 +84,7 @@ $RECYCLE.BIN/
|
|||
.env
|
||||
.claude
|
||||
.docs
|
||||
/dockflare/tests/
|
||||
dockflare-agent/overview.json
|
||||
dockflare-agent/data/
|
||||
data/state.json
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file.
|
|||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **Apprise notifications:** Added opt-in, encrypted notification destinations for rule lifecycle, Cloudflare failures, Docker listener failures, Agent health, and tunnel health. Delivery uses a bounded background queue with cooldowns, startup and intentional-action suppression, redacted settings/status output, and authenticated test delivery. Addresses [#302](https://github.com/ChrispyBacon-dev/DockFlare/issues/302).
|
||||
- **Apprise notifications:** Added opt-in, encrypted notification destinations for rule lifecycle, Cloudflare failures, Docker listener failures, Agent enrollment, decommission lifecycle, Agent health, tunnel health, and explicit Access Policy management. Event controls are grouped by area, with routine administrative events opt-in and failure/terminal observability enabled by default. Delivery uses a bounded background queue with cooldowns, startup and intentional-action suppression, redacted settings/status output, and authenticated test delivery. Addresses [#302](https://github.com/ChrispyBacon-dev/DockFlare/issues/302).
|
||||
- **Safe Agent decommissioning:** Replaced immediate Agent deletion with a durable prepare, ownership-safe Cloudflare cleanup, and final Agent shutdown workflow. Decommissioned Agents persist a local tombstone, stop their managed tunnel without Docker delete permission, revoke their API key only after final acknowledgement, and cannot silently recreate the tunnel after restart. The Agents UI now shows retryable progress and provides separately acknowledged host cleanup commands that remove the Agent Docker deployment while preserving the shared external `cloudflare-net` network.
|
||||
|
||||
### Fixed
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import re
|
|||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from app import tunnel_state
|
||||
from app import config, tunnel_state
|
||||
from app.core.access_manager import delete_cloudflare_access_application
|
||||
from app.core.cloudflare_api import delete_cloudflare_dns_record, delete_tunnel_via_api
|
||||
from app.core.state_manager import (
|
||||
|
|
@ -21,6 +21,7 @@ from app.core.state_manager import (
|
|||
state_lock,
|
||||
)
|
||||
from app.core.tunnel_manager import update_cloudflare_config
|
||||
from app.core import notification_manager
|
||||
|
||||
|
||||
TERMINAL_STATES = {"completed", "forced_completed"}
|
||||
|
|
@ -42,6 +43,43 @@ def _bounded_int_env(name, default, minimum, maximum):
|
|||
DEFAULT_TIMEOUT_SECONDS = _bounded_int_env("AGENT_DECOMMISSION_TIMEOUT_SECONDS", 180, 30, 1800)
|
||||
|
||||
|
||||
def notification_context(operation):
|
||||
plan = operation.get("resource_plan") or {}
|
||||
results = operation.get("cleanup_results") or {}
|
||||
return {
|
||||
"agent_name": operation.get("display_name"),
|
||||
"agent_id": str(operation.get("agent_id") or "")[:12],
|
||||
"tunnel_id": str(plan.get("tunnel_id") or "")[:12],
|
||||
"operation_id": str(operation.get("operation_id") or "")[:12],
|
||||
"operation": "decommission",
|
||||
"rules_count": results.get("rules_removed", len(plan.get("rule_keys") or [])),
|
||||
"source": "admin",
|
||||
"public_url": config.DOCKFLARE_PUBLIC_URL,
|
||||
}
|
||||
|
||||
|
||||
def notify_transition(operation, previous_state=None):
|
||||
"""Emit a safe notification for a committed decommission state transition."""
|
||||
if not isinstance(operation, dict):
|
||||
return False
|
||||
state = operation.get("state")
|
||||
if state == previous_state:
|
||||
return False
|
||||
if state in TERMINAL_STATES:
|
||||
event_type = "agent.decommission_completed"
|
||||
elif state == "timed_out":
|
||||
event_type = "agent.decommission_stalled"
|
||||
elif state in {"prepare_failed", "cleanup_failed", "finalize_failed", "force_failed"}:
|
||||
event_type = "agent.decommission_failed"
|
||||
else:
|
||||
return False
|
||||
return notification_manager.emit(
|
||||
event_type,
|
||||
str(operation.get("operation_id") or operation.get("agent_id") or "unknown"),
|
||||
notification_context(operation),
|
||||
)
|
||||
|
||||
|
||||
class DecommissionError(RuntimeError):
|
||||
"""Stable decommission failure safe to map to an API response."""
|
||||
|
||||
|
|
@ -721,6 +759,8 @@ def expire_due_operations(now=None):
|
|||
agents[agent_id].clear()
|
||||
agents[agent_id].update(previous)
|
||||
return []
|
||||
for operation_id in changed:
|
||||
notify_transition(get_operation(operation_id), "waiting")
|
||||
return changed
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -21,8 +21,17 @@ EVENT_DEFINITIONS = {
|
|||
"docker.listener_failure": ("docker_listener_failure", "failure", True),
|
||||
"agent.offline": ("agent_offline", "failure", True),
|
||||
"agent.online": ("agent_online", "success", True),
|
||||
"agent.enrolled": ("agent_enrolled", "success", True),
|
||||
"agent.enrollment_failed": ("agent_enrollment_failed", "failure", True),
|
||||
"agent.decommission_started": ("agent_decommission_started", "warning", False),
|
||||
"agent.decommission_completed": ("agent_decommission_completed", "success", True),
|
||||
"agent.decommission_failed": ("agent_decommission_failed", "failure", True),
|
||||
"agent.decommission_stalled": ("agent_decommission_stalled", "warning", True),
|
||||
"tunnel.down": ("tunnel_down", "failure", True),
|
||||
"tunnel.recovered": ("tunnel_recovered", "success", True),
|
||||
"access.policy_created": ("access_policy_created", "success", False),
|
||||
"access.policy_updated": ("access_policy_updated", "info", False),
|
||||
"access.policy_deleted": ("access_policy_deleted", "info", False),
|
||||
}
|
||||
|
||||
DEFAULT_NOTIFICATION_CONFIG = {
|
||||
|
|
@ -38,7 +47,13 @@ BOOTSTRAP_SUPPRESSED_EVENTS = {
|
|||
"rule.pending_deletion",
|
||||
"rule.deleted",
|
||||
"agent.online",
|
||||
"agent.enrolled",
|
||||
"agent.decommission_started",
|
||||
"agent.decommission_completed",
|
||||
"tunnel.recovered",
|
||||
"access.policy_created",
|
||||
"access.policy_updated",
|
||||
"access.policy_deleted",
|
||||
}
|
||||
|
||||
COOLDOWN_EVENTS = {
|
||||
|
|
@ -47,6 +62,9 @@ COOLDOWN_EVENTS = {
|
|||
"cloudflare.access_failure",
|
||||
"docker.listener_failure",
|
||||
"agent.offline",
|
||||
"agent.enrollment_failed",
|
||||
"agent.decommission_failed",
|
||||
"agent.decommission_stalled",
|
||||
"tunnel.down",
|
||||
}
|
||||
|
||||
|
|
@ -66,8 +84,17 @@ TITLE_MAP = {
|
|||
"docker.listener_failure": "❌ DockFlare — Docker listener stopped",
|
||||
"agent.offline": "🔴 DockFlare — Agent offline",
|
||||
"agent.online": "🟢 DockFlare — Agent recovered",
|
||||
"agent.enrolled": "✅ DockFlare — Agent enrolled",
|
||||
"agent.enrollment_failed": "❌ DockFlare — Agent enrollment failed",
|
||||
"agent.decommission_started": "⚠️ DockFlare — Agent decommission started",
|
||||
"agent.decommission_completed": "✅ DockFlare — Agent decommission completed",
|
||||
"agent.decommission_failed": "❌ DockFlare — Agent decommission failed",
|
||||
"agent.decommission_stalled": "⚠️ DockFlare — Agent decommission stalled",
|
||||
"tunnel.down": "🔴 DockFlare — Tunnel down",
|
||||
"tunnel.recovered": "🟢 DockFlare — Tunnel recovered",
|
||||
"access.policy_created": "✅ DockFlare — Access Policy created",
|
||||
"access.policy_updated": "ℹ️ DockFlare — Access Policy updated",
|
||||
"access.policy_deleted": "ℹ️ DockFlare — Access Policy deleted",
|
||||
"notification.test": "🔔 DockFlare — Test notification",
|
||||
}
|
||||
|
||||
|
|
@ -76,6 +103,15 @@ STATUS_MAP = {
|
|||
"agent.online": "Online",
|
||||
"tunnel.down": "Down",
|
||||
"tunnel.recovered": "Running",
|
||||
"agent.enrolled": "Enrolled",
|
||||
"agent.enrollment_failed": "Enrollment failed",
|
||||
"agent.decommission_started": "In progress",
|
||||
"agent.decommission_completed": "Completed",
|
||||
"agent.decommission_failed": "Failed",
|
||||
"agent.decommission_stalled": "Timed out",
|
||||
"access.policy_created": "Created",
|
||||
"access.policy_updated": "Updated",
|
||||
"access.policy_deleted": "Deleted",
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -520,6 +556,18 @@ class NotificationManager:
|
|||
if context.get(key) not in (None, ""):
|
||||
lines.append(f"{label}: {context[key]}")
|
||||
|
||||
if context.get("policy_name"):
|
||||
lines.append(f"Access Policy: {context['policy_name']}")
|
||||
if context.get("policy_type"):
|
||||
lines.append(f"Policy type: {context['policy_type']}")
|
||||
if context.get("identity_provider_count"):
|
||||
lines.append(f"Identity providers: {context['identity_provider_count']}")
|
||||
if context.get("rules_count"):
|
||||
rules_label = "Managed rules" if event_type.startswith("agent.decommission_") else "Policy rules"
|
||||
lines.append(f"{rules_label}: {context['rules_count']}")
|
||||
if context.get("affected_service_count"):
|
||||
lines.append(f"Affected services: {context['affected_service_count']}")
|
||||
|
||||
if context.get("delete_at"):
|
||||
lines.append(f"Deletion deadline: {context['delete_at']}")
|
||||
if context.get("grace_period_seconds"):
|
||||
|
|
@ -533,7 +581,11 @@ class NotificationManager:
|
|||
|
||||
technical_fields = [
|
||||
(label, context[key])
|
||||
for key, label in (("container_id", "Container ID"), ("agent_id", "Agent ID"), ("tunnel_id", "Tunnel ID"))
|
||||
for key, label in (
|
||||
("container_id", "Container ID"), ("agent_id", "Agent ID"),
|
||||
("tunnel_id", "Tunnel ID"), ("operation_id", "Operation ID"),
|
||||
("policy_id", "Policy ID"),
|
||||
)
|
||||
if context.get(key) not in (None, "")
|
||||
]
|
||||
if technical_fields:
|
||||
|
|
@ -560,7 +612,9 @@ class NotificationManager:
|
|||
"message", "operation", "hostname", "path", "service", "source",
|
||||
"container_name", "container_id", "agent_name", "agent_id",
|
||||
"tunnel_name", "tunnel_id", "delete_at", "grace_period_seconds",
|
||||
"retry_count", "resources", "public_url",
|
||||
"retry_count", "resources", "public_url", "operation_id",
|
||||
"policy_name", "policy_id", "policy_type", "identity_provider_count",
|
||||
"rules_count", "affected_service_count",
|
||||
}
|
||||
sanitized = {}
|
||||
for key in allowed:
|
||||
|
|
|
|||
|
|
@ -432,7 +432,7 @@ def delete_access_group_and_policy(group_id):
|
|||
logging.warning(f"Access group '{group_id}' not found in state")
|
||||
return False
|
||||
|
||||
group = access_groups[group_id]
|
||||
group = copy.deepcopy(access_groups[group_id])
|
||||
policy_id = group.get("cloudflare_policy_id")
|
||||
|
||||
if policy_id:
|
||||
|
|
@ -444,6 +444,9 @@ def delete_access_group_and_policy(group_id):
|
|||
logging.info(f"No Cloudflare policy ID found for access group '{group_id}', removing from state only")
|
||||
|
||||
del access_groups[group_id]
|
||||
save_state()
|
||||
if not save_state():
|
||||
access_groups[group_id] = group
|
||||
logging.error(f"Failed to persist removal of access group '{group_id}'")
|
||||
return False
|
||||
logging.info(f"Removed access group '{group_id}' from state")
|
||||
return True
|
||||
|
|
|
|||
|
|
@ -177,6 +177,11 @@
|
|||
"settings.notification_cooldown": "Failure cooldown (seconds)",
|
||||
"settings.notification_cooldown_help": "Suppresses repeated alerts for the same incident for 60 to 86400 seconds.",
|
||||
"settings.notification_events": "Events",
|
||||
"settings.notification_group_rules": "Rule lifecycle",
|
||||
"settings.notification_group_failures": "Cloudflare and system failures",
|
||||
"settings.notification_group_agents": "Agent lifecycle",
|
||||
"settings.notification_group_tunnels": "Tunnel health",
|
||||
"settings.notification_group_access_policies": "Access Policy management",
|
||||
"settings.notification_event_rule_activated": "Rule activated",
|
||||
"settings.notification_event_rule_restored": "Rule restored",
|
||||
"settings.notification_event_rule_pending_deletion": "Rule pending deletion",
|
||||
|
|
@ -187,8 +192,17 @@
|
|||
"settings.notification_event_docker_listener_failure": "Docker listener stopped",
|
||||
"settings.notification_event_agent_offline": "Agent offline",
|
||||
"settings.notification_event_agent_online": "Agent recovered",
|
||||
"settings.notification_event_agent_enrolled": "Agent enrolled",
|
||||
"settings.notification_event_agent_enrollment_failed": "Agent enrollment failed",
|
||||
"settings.notification_event_agent_decommission_started": "Agent decommission started",
|
||||
"settings.notification_event_agent_decommission_completed": "Agent decommission completed",
|
||||
"settings.notification_event_agent_decommission_failed": "Agent decommission failed",
|
||||
"settings.notification_event_agent_decommission_stalled": "Agent decommission timed out",
|
||||
"settings.notification_event_tunnel_down": "Tunnel down",
|
||||
"settings.notification_event_tunnel_recovered": "Tunnel recovered",
|
||||
"settings.notification_event_access_policy_created": "Access Policy created",
|
||||
"settings.notification_event_access_policy_updated": "Access Policy updated",
|
||||
"settings.notification_event_access_policy_deleted": "Access Policy deleted",
|
||||
"settings.save_notification_settings": "Save notification settings",
|
||||
"settings.send_test_notification": "Send test notification",
|
||||
"settings.notification_security_warning": "Apprise URLs often contain secrets and can make DockFlare connect to external or internal addresses. Configure only destinations you trust. Backups contain the encrypted configuration and its key and must be stored securely.",
|
||||
|
|
|
|||
|
|
@ -507,22 +507,44 @@
|
|||
<label class="label"><span class="label-text-alt">{{ t('settings.notification_cooldown_help') }}</span></label>
|
||||
</div>
|
||||
|
||||
{% set notification_events = [
|
||||
{% set notification_event_groups = [
|
||||
('settings.notification_group_rules', [
|
||||
('rule_activated', 'settings.notification_event_rule_activated'),
|
||||
('rule_restored', 'settings.notification_event_rule_restored'),
|
||||
('rule_pending_deletion', 'settings.notification_event_rule_pending_deletion'),
|
||||
('rule_deleted', 'settings.notification_event_rule_deleted'),
|
||||
('rule_deleted', 'settings.notification_event_rule_deleted')
|
||||
]),
|
||||
('settings.notification_group_failures', [
|
||||
('cloudflare_tunnel_failure', 'settings.notification_event_tunnel_failure'),
|
||||
('cloudflare_dns_failure', 'settings.notification_event_dns_failure'),
|
||||
('cloudflare_access_failure', 'settings.notification_event_access_failure'),
|
||||
('docker_listener_failure', 'settings.notification_event_docker_listener_failure'),
|
||||
('docker_listener_failure', 'settings.notification_event_docker_listener_failure')
|
||||
]),
|
||||
('settings.notification_group_agents', [
|
||||
('agent_enrolled', 'settings.notification_event_agent_enrolled'),
|
||||
('agent_enrollment_failed', 'settings.notification_event_agent_enrollment_failed'),
|
||||
('agent_offline', 'settings.notification_event_agent_offline'),
|
||||
('agent_online', 'settings.notification_event_agent_online'),
|
||||
('agent_decommission_started', 'settings.notification_event_agent_decommission_started'),
|
||||
('agent_decommission_completed', 'settings.notification_event_agent_decommission_completed'),
|
||||
('agent_decommission_failed', 'settings.notification_event_agent_decommission_failed'),
|
||||
('agent_decommission_stalled', 'settings.notification_event_agent_decommission_stalled')
|
||||
]),
|
||||
('settings.notification_group_tunnels', [
|
||||
('tunnel_down', 'settings.notification_event_tunnel_down'),
|
||||
('tunnel_recovered', 'settings.notification_event_tunnel_recovered')
|
||||
]),
|
||||
('settings.notification_group_access_policies', [
|
||||
('access_policy_created', 'settings.notification_event_access_policy_created'),
|
||||
('access_policy_updated', 'settings.notification_event_access_policy_updated'),
|
||||
('access_policy_deleted', 'settings.notification_event_access_policy_deleted')
|
||||
])
|
||||
] %}
|
||||
<fieldset>
|
||||
<fieldset class="space-y-4">
|
||||
<legend class="font-semibold mb-3">{{ t('settings.notification_events') }}</legend>
|
||||
{% for group_label_key, notification_events in notification_event_groups %}
|
||||
<div>
|
||||
<h4 class="text-sm font-semibold mb-2">{{ t(group_label_key) }}</h4>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{% for field_name, label_key in notification_events %}
|
||||
<label class="label cursor-pointer justify-start gap-3 rounded-md bg-base-200/50 px-3">
|
||||
|
|
@ -531,6 +553,8 @@
|
|||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</fieldset>
|
||||
|
||||
<div class="card-actions pt-2 gap-3">
|
||||
|
|
|
|||
|
|
@ -2064,17 +2064,20 @@ def agents_decommission_ack(agent_id, operation_id):
|
|||
if agent.get("protocol_version") == 2 and payload.get("agent_session_id") != agent.get("agent_session_id"):
|
||||
return _agent_json_response(409, "registration_required")
|
||||
try:
|
||||
previous_state = operation.get("state") if operation else None
|
||||
operation, should_cleanup = agent_decommission.record_ack(agent_id, operation_id, payload)
|
||||
if should_cleanup:
|
||||
operation = agent_decommission.run_master_cleanup(operation_id)
|
||||
elif operation.get("state") == "shutdown_scheduled":
|
||||
operation = agent_decommission.complete_finalization(operation_id)
|
||||
agent_decommission.notify_transition(operation, previous_state)
|
||||
return _agent_json_response(
|
||||
200,
|
||||
"acknowledged",
|
||||
operation=agent_decommission.serialize_operation(operation),
|
||||
)
|
||||
except agent_decommission.DecommissionError as error:
|
||||
agent_decommission.notify_transition(agent_decommission.get_operation(operation_id), previous_state)
|
||||
return _agent_json_response(error.http_status, error.code)
|
||||
|
||||
@api_v2_bp.route('/agents/<agent_id>/events', methods=['POST'])
|
||||
|
|
@ -2236,6 +2239,15 @@ def agents_enroll(agent_id):
|
|||
return jsonify({"status": "error", "message": "Agent decommission is in progress."}), 409
|
||||
|
||||
old_tunnel_id = agent.get("assigned_tunnel_id")
|
||||
was_new_enrollment = agent.get("status") != "enrolled"
|
||||
enrollment_context = {
|
||||
"agent_name": agent.get("display_name") or f"agent-{agent_id[:8]}",
|
||||
"agent_id": agent_id[:12],
|
||||
"tunnel_name": tunnel_name,
|
||||
"operation": "enroll",
|
||||
"source": "admin",
|
||||
"public_url": config.DOCKFLARE_PUBLIC_URL,
|
||||
}
|
||||
try:
|
||||
from app.core.cloudflare_api import find_tunnel_via_api, create_tunnel_via_api
|
||||
found_id, found_token = find_tunnel_via_api(tunnel_name)
|
||||
|
|
@ -2250,26 +2262,35 @@ def agents_enroll(agent_id):
|
|||
tunnel_ownership = "adopted"
|
||||
|
||||
if not tunnel_id:
|
||||
notification_manager.emit("agent.enrollment_failed", agent_id, enrollment_context)
|
||||
return jsonify({"status": "error", "message": "Failed to create/find tunnel."}), 500
|
||||
|
||||
cmd = {"action": "start_tunnel", "tunnel_name": tunnel_name, "tunnel_id": tunnel_id, "token": token}
|
||||
existing_cmds = agent.get("commands", [])
|
||||
existing_cmds = list(agent.get("commands", []))
|
||||
existing_cmds.append(cmd)
|
||||
update_agent(agent_id, {
|
||||
persistence_failed = False
|
||||
with state_lock:
|
||||
current_agent = agents.get(agent_id)
|
||||
if not current_agent:
|
||||
persistence_failed = True
|
||||
else:
|
||||
previous_agent = copy.deepcopy(current_agent)
|
||||
current_agent.update({
|
||||
"assigned_tunnel_name": tunnel_name,
|
||||
"assigned_tunnel_id": tunnel_id,
|
||||
"assigned_tunnel_token": token,
|
||||
"assigned_tunnel_ownership": tunnel_ownership,
|
||||
"status": "enrolled",
|
||||
"commands": existing_cmds,
|
||||
"last_enrolled_at": datetime.utcnow().replace(tzinfo=timezone.utc).isoformat()
|
||||
"last_enrolled_at": datetime.now(timezone.utc).isoformat(),
|
||||
})
|
||||
|
||||
with state_lock:
|
||||
rules_updated = False
|
||||
previous_rules = {}
|
||||
if not persistence_failed:
|
||||
for rule in managed_rules.values():
|
||||
if rule.get("source") == "agent" and rule.get("agent_id") == agent_id:
|
||||
if rule.get("tunnel_name") != tunnel_name or rule.get("tunnel_id") != tunnel_id:
|
||||
previous_rules[id(rule)] = copy.deepcopy(rule)
|
||||
rule["tunnel_name"] = tunnel_name
|
||||
rule["tunnel_id"] = tunnel_id
|
||||
rule["tunnel_sync_pending"] = True
|
||||
|
|
@ -2277,7 +2298,19 @@ def agents_enroll(agent_id):
|
|||
rules_updated = True
|
||||
if rules_updated:
|
||||
logging.info(f"Updated {len([r for r in managed_rules.values() if r.get('agent_id') == agent_id])} rules for agent {agent_id} with tunnel name '{tunnel_name}'.")
|
||||
save_state()
|
||||
if not persistence_failed and not save_state():
|
||||
current_agent.clear()
|
||||
current_agent.update(previous_agent)
|
||||
for rule in managed_rules.values():
|
||||
previous_rule = previous_rules.get(id(rule))
|
||||
if previous_rule is not None:
|
||||
rule.clear()
|
||||
rule.update(previous_rule)
|
||||
persistence_failed = True
|
||||
|
||||
if persistence_failed:
|
||||
notification_manager.emit("agent.enrollment_failed", agent_id, enrollment_context)
|
||||
return jsonify({"status": "error", "message": "Failed to persist agent enrollment."}), 503
|
||||
|
||||
old_updated = not old_tunnel_id or old_tunnel_id == tunnel_id or update_cloudflare_config(old_tunnel_id)
|
||||
new_updated = update_cloudflare_config(tunnel_id)
|
||||
|
|
@ -2296,15 +2329,28 @@ def agents_enroll(agent_id):
|
|||
if not hostname.startswith("*."):
|
||||
create_cloudflare_dns_record(zone_id, hostname, tunnel_id)
|
||||
|
||||
if was_new_enrollment:
|
||||
notification_manager.emit(
|
||||
"agent.enrolled",
|
||||
agent_id,
|
||||
{**enrollment_context, "tunnel_id": str(tunnel_id)[:12]},
|
||||
)
|
||||
return jsonify({"status": "success", "message": "Agent enrolled and command queued.", "command": cmd}), 200
|
||||
except Exception as e:
|
||||
logging.error(f"Error enrolling agent {agent_id}: {e}", exc_info=True)
|
||||
notification_manager.emit("agent.enrollment_failed", agent_id, enrollment_context)
|
||||
return jsonify({"status": "error", "message": f"Exception during enrollment: {e}"}), 500
|
||||
|
||||
|
||||
def _start_agent_decommission_response(agent_id):
|
||||
try:
|
||||
operation, _created = agent_decommission.start_decommission(agent_id)
|
||||
operation, created = agent_decommission.start_decommission(agent_id)
|
||||
if created:
|
||||
notification_manager.emit(
|
||||
"agent.decommission_started",
|
||||
operation["operation_id"],
|
||||
agent_decommission.notification_context(operation),
|
||||
)
|
||||
public_operation = agent_decommission.serialize_operation(operation)
|
||||
return jsonify({
|
||||
"status": "accepted",
|
||||
|
|
@ -2342,19 +2388,27 @@ def agents_decommission_status(operation_id):
|
|||
|
||||
@api_v2_bp.route('/agent-decommissions/<operation_id>/retry', methods=['POST'])
|
||||
def agents_decommission_retry(operation_id):
|
||||
previous = agent_decommission.get_operation(operation_id)
|
||||
previous_state = previous.get("state") if previous else None
|
||||
try:
|
||||
operation = agent_decommission.retry_operation(operation_id)
|
||||
agent_decommission.notify_transition(operation, previous_state)
|
||||
return jsonify({"status": "accepted", "operation": agent_decommission.serialize_operation(operation)}), 202
|
||||
except agent_decommission.DecommissionError as error:
|
||||
agent_decommission.notify_transition(agent_decommission.get_operation(operation_id), previous_state)
|
||||
return jsonify({"status": "error", "code": error.code, "message": error.code}), error.http_status
|
||||
|
||||
|
||||
@api_v2_bp.route('/agent-decommissions/<operation_id>/force', methods=['POST'])
|
||||
def agents_decommission_force(operation_id):
|
||||
previous = agent_decommission.get_operation(operation_id)
|
||||
previous_state = previous.get("state") if previous else None
|
||||
try:
|
||||
operation = agent_decommission.force_cleanup(operation_id)
|
||||
agent_decommission.notify_transition(operation, previous_state)
|
||||
return jsonify({"status": "success", "operation": agent_decommission.serialize_operation(operation)}), 200
|
||||
except agent_decommission.DecommissionError as error:
|
||||
agent_decommission.notify_transition(agent_decommission.get_operation(operation_id), previous_state)
|
||||
return jsonify({"status": "error", "code": error.code, "message": error.code}), error.http_status
|
||||
|
||||
@api_v2_bp.route('/agents/<agent_id>/remove', methods=['POST'])
|
||||
|
|
|
|||
|
|
@ -130,6 +130,15 @@ class NotificationSettingsForm(FlaskForm):
|
|||
docker_listener_failure = BooleanField('Docker listener failure')
|
||||
agent_offline = BooleanField('Agent offline')
|
||||
agent_online = BooleanField('Agent recovered')
|
||||
agent_enrolled = BooleanField('Agent enrolled')
|
||||
agent_enrollment_failed = BooleanField('Agent enrollment failed')
|
||||
agent_decommission_started = BooleanField('Agent decommission started')
|
||||
agent_decommission_completed = BooleanField('Agent decommission completed')
|
||||
agent_decommission_failed = BooleanField('Agent decommission failed')
|
||||
agent_decommission_stalled = BooleanField('Agent decommission stalled')
|
||||
tunnel_down = BooleanField('Tunnel down')
|
||||
tunnel_recovered = BooleanField('Tunnel recovered')
|
||||
access_policy_created = BooleanField('Access Policy created')
|
||||
access_policy_updated = BooleanField('Access Policy updated')
|
||||
access_policy_deleted = BooleanField('Access Policy deleted')
|
||||
submit_notifications = SubmitField('Save notification settings')
|
||||
|
|
|
|||
|
|
@ -2162,7 +2162,10 @@ def create_access_group():
|
|||
"policies": policies
|
||||
}
|
||||
access_groups[group_id] = new_group
|
||||
save_state()
|
||||
if not save_state():
|
||||
access_groups.pop(group_id, None)
|
||||
flash(_t('flash.access_group.create_error', error='persistence_failed'), "error")
|
||||
return redirect(url_for('web.access_policies_page'))
|
||||
|
||||
from app import config
|
||||
if config.USE_REUSABLE_POLICIES and len(effective_access_policies(new_group)) == 1:
|
||||
|
|
@ -2176,6 +2179,19 @@ def create_access_group():
|
|||
|
||||
publish_state_event('snapshot_refresh')
|
||||
|
||||
notification_manager.emit(
|
||||
"access.policy_created",
|
||||
group_id,
|
||||
{
|
||||
"policy_name": display_name,
|
||||
"policy_id": group_id,
|
||||
"policy_type": "Reusable" if config.USE_REUSABLE_POLICIES else "Inline",
|
||||
"identity_provider_count": len(new_group.get("allowed_idps") or []),
|
||||
"rules_count": len(effective_access_policies(new_group)),
|
||||
"source": "admin",
|
||||
"public_url": config.DOCKFLARE_PUBLIC_URL,
|
||||
},
|
||||
)
|
||||
flash(_t('flash.access_group.created', displayName=display_name), "success")
|
||||
return redirect(url_for('web.access_policies_page'))
|
||||
|
||||
|
|
@ -2216,6 +2232,7 @@ def edit_access_group(group_id):
|
|||
flash(_t('flash.access_group.update_error', error=str(e)), "error")
|
||||
return redirect(url_for('web.access_policies_page'))
|
||||
|
||||
previous_group = copy.deepcopy(access_groups[group_id])
|
||||
updated_group = {
|
||||
"id": group_id,
|
||||
"display_name": display_name,
|
||||
|
|
@ -2227,7 +2244,20 @@ def edit_access_group(group_id):
|
|||
"policies": policies
|
||||
}
|
||||
access_groups[group_id] = updated_group
|
||||
save_state()
|
||||
if not save_state():
|
||||
access_groups[group_id] = previous_group
|
||||
flash(_t('flash.access_group.update_error', error='persistence_failed'), "error")
|
||||
return redirect(url_for('web.access_policies_page'))
|
||||
|
||||
affected_service_count = sum(
|
||||
1
|
||||
for rule in managed_rules.values()
|
||||
if group_id in (
|
||||
rule.get('access_group_id')
|
||||
if isinstance(rule.get('access_group_id'), list)
|
||||
else [rule.get('access_group_id')]
|
||||
)
|
||||
)
|
||||
|
||||
from app import config
|
||||
if config.USE_REUSABLE_POLICIES and len(effective_access_policies(updated_group)) == 1:
|
||||
|
|
@ -2241,6 +2271,20 @@ def edit_access_group(group_id):
|
|||
|
||||
publish_state_event('snapshot_refresh')
|
||||
|
||||
notification_manager.emit(
|
||||
"access.policy_updated",
|
||||
group_id,
|
||||
{
|
||||
"policy_name": display_name,
|
||||
"policy_id": group_id,
|
||||
"policy_type": "Reusable" if config.USE_REUSABLE_POLICIES else "Inline",
|
||||
"identity_provider_count": len(updated_group.get("allowed_idps") or []),
|
||||
"rules_count": len(effective_access_policies(updated_group)),
|
||||
"affected_service_count": affected_service_count,
|
||||
"source": "admin",
|
||||
"public_url": config.DOCKFLARE_PUBLIC_URL,
|
||||
},
|
||||
)
|
||||
flash(_t('flash.access_group.updated', displayName=display_name), "success")
|
||||
reconcile_state_threaded()
|
||||
return redirect(url_for('web.access_policies_page'))
|
||||
|
|
@ -2266,24 +2310,46 @@ def delete_access_group(group_id):
|
|||
flash(_t('flash.access_group.delete_in_use', displayName=access_groups[group_id]['display_name']), "error")
|
||||
return redirect(url_for('web.access_policies_page'))
|
||||
|
||||
display_name = access_groups[group_id]['display_name']
|
||||
previous_group = copy.deepcopy(access_groups[group_id])
|
||||
display_name = previous_group['display_name']
|
||||
|
||||
from app import config
|
||||
deletion_persisted = True
|
||||
if config.USE_REUSABLE_POLICIES:
|
||||
from app.core import reusable_policies
|
||||
try:
|
||||
reusable_policies.delete_access_group_and_policy(group_id)
|
||||
deletion_persisted = reusable_policies.delete_access_group_and_policy(group_id)
|
||||
if deletion_persisted:
|
||||
logging.info(f"Deleted access group '{group_id}' and associated Cloudflare policy")
|
||||
except Exception as e:
|
||||
logging.error(f"Error deleting Cloudflare policy for '{group_id}': {e}", exc_info=True)
|
||||
del access_groups[group_id]
|
||||
save_state()
|
||||
deletion_persisted = save_state()
|
||||
if not deletion_persisted:
|
||||
access_groups[group_id] = previous_group
|
||||
else:
|
||||
del access_groups[group_id]
|
||||
save_state()
|
||||
deletion_persisted = save_state()
|
||||
if not deletion_persisted:
|
||||
access_groups[group_id] = previous_group
|
||||
|
||||
if not deletion_persisted:
|
||||
flash(_t('flash.access_group.update_error', error='persistence_failed'), "error")
|
||||
return redirect(url_for('web.access_policies_page'))
|
||||
|
||||
publish_state_event('snapshot_refresh')
|
||||
|
||||
notification_manager.emit(
|
||||
"access.policy_deleted",
|
||||
group_id,
|
||||
{
|
||||
"policy_name": display_name,
|
||||
"policy_id": group_id,
|
||||
"policy_type": "Reusable" if config.USE_REUSABLE_POLICIES else "Inline",
|
||||
"source": "admin",
|
||||
"public_url": config.DOCKFLARE_PUBLIC_URL,
|
||||
},
|
||||
)
|
||||
flash(_t('flash.access_group.deleted', displayName=display_name), "success")
|
||||
return redirect(url_for('web.access_policies_page'))
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue