From 29d589b0401b3351461c64502f2353e13a2896ce Mon Sep 17 00:00:00 2001 From: ChrispyBacon-dev Date: Tue, 1 Sep 2026 09:26:47 +0200 Subject: [PATCH] notifications --- .gitignore | 1 + CHANGELOG.md | 2 +- dockflare/app/core/agent_decommission.py | 42 +++++++++- dockflare/app/core/notification_manager.py | 58 ++++++++++++- dockflare/app/core/reusable_policies.py | 7 +- dockflare/app/i18n/en.json | 14 ++++ dockflare/app/templates/settings.html | 68 ++++++++++----- dockflare/app/web/api_v2_routes.py | 96 +++++++++++++++++----- dockflare/app/web/forms.py | 9 ++ dockflare/app/web/routes.py | 80 ++++++++++++++++-- 10 files changed, 321 insertions(+), 56 deletions(-) diff --git a/.gitignore b/.gitignore index 29b2fd0..67243d1 100644 --- a/.gitignore +++ b/.gitignore @@ -84,6 +84,7 @@ $RECYCLE.BIN/ .env .claude .docs +/dockflare/tests/ dockflare-agent/overview.json dockflare-agent/data/ data/state.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 1dff727..27670f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/dockflare/app/core/agent_decommission.py b/dockflare/app/core/agent_decommission.py index 4f1f2af..7845407 100644 --- a/dockflare/app/core/agent_decommission.py +++ b/dockflare/app/core/agent_decommission.py @@ -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 diff --git a/dockflare/app/core/notification_manager.py b/dockflare/app/core/notification_manager.py index 1c8d212..21ed80b 100644 --- a/dockflare/app/core/notification_manager.py +++ b/dockflare/app/core/notification_manager.py @@ -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: diff --git a/dockflare/app/core/reusable_policies.py b/dockflare/app/core/reusable_policies.py index bc66a2d..fb24abf 100644 --- a/dockflare/app/core/reusable_policies.py +++ b/dockflare/app/core/reusable_policies.py @@ -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 diff --git a/dockflare/app/i18n/en.json b/dockflare/app/i18n/en.json index 24c58d5..24df4ca 100644 --- a/dockflare/app/i18n/en.json +++ b/dockflare/app/i18n/en.json @@ -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.", diff --git a/dockflare/app/templates/settings.html b/dockflare/app/templates/settings.html index ae9967a..18ed227 100644 --- a/dockflare/app/templates/settings.html +++ b/dockflare/app/templates/settings.html @@ -507,30 +507,54 @@ - {% set notification_events = [ - ('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'), - ('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'), - ('agent_offline', 'settings.notification_event_agent_offline'), - ('agent_online', 'settings.notification_event_agent_online'), - ('tunnel_down', 'settings.notification_event_tunnel_down'), - ('tunnel_recovered', 'settings.notification_event_tunnel_recovered') + {% 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') + ]), + ('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') + ]), + ('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') + ]) ] %} -
+
{{ t('settings.notification_events') }} -
- {% for field_name, label_key in notification_events %} - - {% endfor %} -
+ {% for group_label_key, notification_events in notification_event_groups %} +
+

{{ t(group_label_key) }}

+
+ {% for field_name, label_key in notification_events %} + + {% endfor %} +
+
+ {% endfor %}
diff --git a/dockflare/app/web/api_v2_routes.py b/dockflare/app/web/api_v2_routes.py index b974475..81cd596 100644 --- a/dockflare/app/web/api_v2_routes.py +++ b/dockflare/app/web/api_v2_routes.py @@ -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//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,34 +2262,55 @@ 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, { - "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() - }) - + 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.now(timezone.utc).isoformat(), + }) rules_updated = False - 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: - rule["tunnel_name"] = tunnel_name - rule["tunnel_id"] = tunnel_id - rule["tunnel_sync_pending"] = True - rule["lifecycle_generation"] = int(rule.get("lifecycle_generation") or 0) + 1 - rules_updated = True + 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 + rule["lifecycle_generation"] = int(rule.get("lifecycle_generation") or 0) + 1 + 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//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//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//remove', methods=['POST']) diff --git a/dockflare/app/web/forms.py b/dockflare/app/web/forms.py index 5e29dea..c17af57 100644 --- a/dockflare/app/web/forms.py +++ b/dockflare/app/web/forms.py @@ -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') diff --git a/dockflare/app/web/routes.py b/dockflare/app/web/routes.py index 10d8ac2..da5a1c9 100644 --- a/dockflare/app/web/routes.py +++ b/dockflare/app/web/routes.py @@ -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) - logging.info(f"Deleted access group '{group_id}' and associated Cloudflare policy") + 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'))