mirror of
https://github.com/ChrispyBacon-dev/DockFlare.git
synced 2026-10-19 07:33:16 +00:00
Some checks failed
Docker Image - DockFlare / build_self_hosted (push) Waiting to run
Docker Image - DockFlare / build_github_hosted_fallback (push) Blocked by required conditions
Docker Image - DockFlare Agent / build_self_hosted (push) Has been cancelled
Docker Image - DockFlare Agent / build_github_hosted_fallback (push) Has been cancelled
929 lines
38 KiB
Python
929 lines
38 KiB
Python
import os
|
|
import re
|
|
import time
|
|
import json
|
|
import docker
|
|
import requests
|
|
import logging
|
|
import tempfile
|
|
import http.server
|
|
import socketserver
|
|
import random
|
|
from threading import Thread, Lock
|
|
from datetime import datetime, timezone
|
|
from dotenv import load_dotenv
|
|
import cloudflare_api
|
|
import decommission as decommission_runtime
|
|
|
|
DEFAULT_CLOUDFLARED_IMAGE = "cloudflare/cloudflared:2025.9.0"
|
|
_SHA256_HEX_RE = re.compile(r"^[A-Fa-f0-9]{64}$")
|
|
|
|
|
|
def is_dockflare_enabled(labels):
|
|
"""Check if container has DockFlare enabled using new or legacy labels."""
|
|
if not labels:
|
|
return False
|
|
values = (labels.get("dockflare.enable"), labels.get("cloudflare.tunnel.enable"))
|
|
return any(isinstance(value, str) and value.lower() in {"true", "1", "t", "yes"} for value in values)
|
|
|
|
|
|
def _normalize_cloudflared_image(raw_value, default_image):
|
|
"""Sanitize the configured cloudflared image reference."""
|
|
if not raw_value:
|
|
return default_image
|
|
|
|
candidate = raw_value.strip()
|
|
if not candidate:
|
|
logging.warning("CLOUDFLARED_IMAGE is blank after trimming; falling back to default %s", default_image)
|
|
return default_image
|
|
|
|
# Split on whitespace to drop inline comments or accidental extra tokens.
|
|
candidate = candidate.split()[0]
|
|
|
|
if "#" in candidate:
|
|
candidate = candidate.split("#", 1)[0].strip()
|
|
if not candidate:
|
|
logging.warning("CLOUDFLARED_IMAGE only contained a comment; falling back to default %s", default_image)
|
|
return default_image
|
|
|
|
if "@sha256:" in candidate:
|
|
repository, digest = candidate.split("@sha256:", 1)
|
|
if not repository:
|
|
logging.error("CLOUDFLARED_IMAGE is missing the repository before @sha256; falling back to default %s", default_image)
|
|
return default_image
|
|
if not _SHA256_HEX_RE.fullmatch(digest):
|
|
logging.error("CLOUDFLARED_IMAGE digest is not a valid 64 char hex string; falling back to default %s", default_image)
|
|
return default_image
|
|
return f"{repository}@sha256:{digest.lower()}"
|
|
|
|
return candidate
|
|
|
|
# Basic Logging Configuration (configurable via LOG_LEVEL env var; default INFO)
|
|
_LOG_LEVEL_STR = os.getenv('LOG_LEVEL', 'INFO').upper()
|
|
_LOG_LEVEL = getattr(logging, _LOG_LEVEL_STR, logging.INFO)
|
|
logging.basicConfig(level=_LOG_LEVEL, format='%(asctime)s - %(levelname)s - %(message)s')
|
|
logging.getLogger().info(f"Logging initialized at level: {_LOG_LEVEL_STR}")
|
|
|
|
# Load environment variables from .env file
|
|
load_dotenv()
|
|
|
|
MASTER_URL = os.getenv("DOCKFLARE_MASTER_URL")
|
|
API_KEY = os.getenv("DOCKFLARE_API_KEY")
|
|
AGENT_VERSION = os.getenv("DOCKFLARE_AGENT_VERSION", "1.1.0")
|
|
CF_ACCESS_CLIENT_ID = os.getenv("CF_ACCESS_CLIENT_ID", "")
|
|
CF_ACCESS_CLIENT_SECRET = os.getenv("CF_ACCESS_CLIENT_SECRET", "")
|
|
|
|
|
|
def get_headers(content_type=True):
|
|
headers = {"Authorization": f"Bearer {API_KEY}"}
|
|
if content_type:
|
|
headers["Content-Type"] = "application/json"
|
|
if CF_ACCESS_CLIENT_ID:
|
|
headers["CF-Access-Client-Id"] = CF_ACCESS_CLIENT_ID
|
|
headers["CF-Access-Client-Secret"] = CF_ACCESS_CLIENT_SECRET
|
|
return headers
|
|
CLOUDFLARED_NETWORK_NAME = os.getenv("CLOUDFLARED_NETWORK_NAME", "cloudflare-net")
|
|
CLOUDFLARED_IMAGE = _normalize_cloudflared_image(os.getenv("CLOUDFLARED_IMAGE"), DEFAULT_CLOUDFLARED_IMAGE)
|
|
if CLOUDFLARED_IMAGE != DEFAULT_CLOUDFLARED_IMAGE:
|
|
logging.info(f"Using configured cloudflared image: {CLOUDFLARED_IMAGE}")
|
|
else:
|
|
logging.info(f"Using default cloudflared image: {DEFAULT_CLOUDFLARED_IMAGE}")
|
|
AGENT_ID_FILE = "/app/data/agent_id.txt"
|
|
TUNNEL_STATE_FILE = "/app/data/tunnel_state.json"
|
|
AGENT_ID = None # Will be assigned by the master
|
|
PROTOCOL_VERSION = None
|
|
AGENT_SESSION_ID = None
|
|
_event_sequence = 0
|
|
_report_sequence = 0
|
|
_protocol_lock = Lock()
|
|
_event_send_lock = Lock()
|
|
_report_send_lock = Lock()
|
|
_registration_lock = Lock()
|
|
|
|
# --- Tunnel Management Globals ---
|
|
tunnel_container = None
|
|
current_tunnel_token = None
|
|
current_tunnel_id = None
|
|
current_tunnel_version = None
|
|
current_tunnel_name = None
|
|
desired_tunnel_state = "unknown"
|
|
decommission_tombstone = None
|
|
|
|
# --- Health / Monitoring Globals ---
|
|
thread_health_status = {}
|
|
last_successful_master_contact = None
|
|
|
|
|
|
def _configured_health_port():
|
|
try:
|
|
value = int(os.getenv("HEALTH_CHECK_PORT", "8080"))
|
|
return value if 1 <= value <= 65535 else 8080
|
|
except ValueError:
|
|
return 8080
|
|
|
|
|
|
HEALTH_CHECK_PORT = _configured_health_port()
|
|
|
|
|
|
def filter_reportable_labels(labels):
|
|
return {
|
|
key: value for key, value in (labels or {}).items()
|
|
if isinstance(key, str)
|
|
and isinstance(value, str)
|
|
and key.startswith(("dockflare.", "cloudflare.tunnel."))
|
|
}
|
|
|
|
|
|
def normalize_docker_event_type(action):
|
|
if action == "start":
|
|
return "container_start"
|
|
if action in {"stop", "die"}:
|
|
return "container_stop"
|
|
return None
|
|
|
|
|
|
def apply_registration_response(data):
|
|
global AGENT_ID, PROTOCOL_VERSION, AGENT_SESSION_ID, _event_sequence, _report_sequence
|
|
agent_id = data.get("agent_id") if isinstance(data, dict) else None
|
|
protocol = data.get("protocol_version") if isinstance(data, dict) else None
|
|
session_id = data.get("agent_session_id") if isinstance(data, dict) else None
|
|
if not agent_id:
|
|
return False
|
|
if protocol == 2 and not session_id:
|
|
return False
|
|
if protocol not in (None, 1, 2):
|
|
return False
|
|
with _protocol_lock:
|
|
AGENT_ID = agent_id
|
|
PROTOCOL_VERSION = protocol or 1
|
|
AGENT_SESSION_ID = session_id if protocol == 2 else None
|
|
_event_sequence = 0
|
|
_report_sequence = 0
|
|
return True
|
|
|
|
|
|
def next_event_sequence():
|
|
global _event_sequence
|
|
with _protocol_lock:
|
|
_event_sequence += 1
|
|
return _event_sequence
|
|
|
|
|
|
def next_report_sequence():
|
|
global _report_sequence
|
|
with _protocol_lock:
|
|
_report_sequence += 1
|
|
return _report_sequence
|
|
|
|
|
|
def collect_complete_inventory(docker_client):
|
|
containers = []
|
|
for container in docker_client.containers.list():
|
|
labels = getattr(container, "labels", None) or {}
|
|
if not is_dockflare_enabled(labels):
|
|
continue
|
|
containers.append({
|
|
"id": str(container.id),
|
|
"name": str(container.name),
|
|
"labels": filter_reportable_labels(labels),
|
|
"status": str(getattr(container, "status", "unknown")),
|
|
})
|
|
return containers
|
|
|
|
|
|
def send_inventory_report(docker_client):
|
|
"""Send one inventory report without ever claiming a failed scan is complete."""
|
|
try:
|
|
containers = collect_complete_inventory(docker_client)
|
|
except Exception as exc:
|
|
logging.error("Docker inventory scan failed: %s", type(exc).__name__)
|
|
return send_status_report(inventory_complete=False)
|
|
return send_status_report(containers, True)
|
|
|
|
|
|
def fetch_cloudflared_version(container):
|
|
try:
|
|
exec_result = container.exec_run("cloudflared --version")
|
|
output = getattr(exec_result, 'output', b'') or b''
|
|
version_text = output.decode('utf-8', errors='ignore').strip()
|
|
if version_text:
|
|
first_line = version_text.splitlines()[0]
|
|
logging.info(f"Detected cloudflared version: {first_line}")
|
|
return first_line
|
|
except Exception as e:
|
|
logging.error(f"Failed to fetch cloudflared version: {e}")
|
|
return None
|
|
|
|
|
|
def load_tunnel_state():
|
|
global current_tunnel_token, current_tunnel_id, current_tunnel_name, desired_tunnel_state
|
|
if not os.path.exists(TUNNEL_STATE_FILE):
|
|
return
|
|
try:
|
|
with open(TUNNEL_STATE_FILE, 'r') as f:
|
|
data = json.load(f)
|
|
current_tunnel_token = data.get("token")
|
|
current_tunnel_id = data.get("id")
|
|
current_tunnel_name = data.get("name")
|
|
desired_tunnel_state = data.get("desired_state", "unknown")
|
|
logging.info("Loaded tunnel state from disk.")
|
|
except Exception as e:
|
|
logging.error(f"Failed to load tunnel state file: {e}")
|
|
|
|
|
|
def save_tunnel_state():
|
|
data = {
|
|
"token": current_tunnel_token,
|
|
"id": current_tunnel_id,
|
|
"name": current_tunnel_name,
|
|
"desired_state": desired_tunnel_state
|
|
}
|
|
try:
|
|
_write_secure_file(TUNNEL_STATE_FILE, lambda fh: json.dump(data, fh))
|
|
except Exception as e:
|
|
logging.error(f"Failed to persist tunnel state: {e}")
|
|
|
|
|
|
def _write_secure_file(path, writer):
|
|
directory = os.path.dirname(path)
|
|
os.makedirs(directory, exist_ok=True)
|
|
temp_path = None
|
|
try:
|
|
with tempfile.NamedTemporaryFile('w', dir=directory, delete=False) as tmp_file:
|
|
writer(tmp_file)
|
|
tmp_file.flush()
|
|
try:
|
|
os.fsync(tmp_file.fileno())
|
|
except (AttributeError, OSError):
|
|
pass
|
|
temp_path = tmp_file.name
|
|
try:
|
|
os.chmod(temp_path, 0o600)
|
|
except OSError:
|
|
pass
|
|
os.replace(temp_path, path)
|
|
except Exception:
|
|
if temp_path and os.path.exists(temp_path):
|
|
try:
|
|
os.remove(temp_path)
|
|
except OSError:
|
|
pass
|
|
raise
|
|
|
|
|
|
def ensure_cloudflared_running(client):
|
|
global tunnel_container, current_tunnel_version
|
|
if decommission_tombstone or desired_tunnel_state != "running" or not current_tunnel_token or not current_tunnel_name:
|
|
return
|
|
try:
|
|
existing = client.containers.get('dockflare-agent-tunnel')
|
|
existing.reload()
|
|
if existing.status == 'running':
|
|
tunnel_container = existing
|
|
if not current_tunnel_version:
|
|
current_tunnel_version = fetch_cloudflared_version(existing)
|
|
return
|
|
logging.warning(f"Detected cloudflared container in status '{existing.status}'. Redeploying.")
|
|
except docker.errors.NotFound:
|
|
logging.warning("cloudflared container missing. Redeploying.")
|
|
except Exception as e:
|
|
logging.error(f"Error inspecting cloudflared container: {e}")
|
|
|
|
_run_cloudflared_container(client, current_tunnel_name, current_tunnel_token)
|
|
|
|
|
|
def _remove_existing_container(client):
|
|
global tunnel_container, current_tunnel_version
|
|
try:
|
|
existing_container = client.containers.get('dockflare-agent-tunnel')
|
|
logging.info(f"Stopping existing cloudflared container '{existing_container.name}' ({existing_container.short_id}).")
|
|
existing_container.stop()
|
|
existing_container.remove()
|
|
if tunnel_container and tunnel_container.id == existing_container.id:
|
|
tunnel_container = None
|
|
except docker.errors.NotFound:
|
|
logging.info("No existing cloudflared container to remove.")
|
|
except Exception as e:
|
|
logging.error(f"Failed to remove existing cloudflared container: {e}")
|
|
finally:
|
|
current_tunnel_version = None
|
|
|
|
|
|
def _run_cloudflared_container(client, tunnel_name, tunnel_token):
|
|
global tunnel_container, current_tunnel_version
|
|
if not tunnel_token:
|
|
logging.error("Cannot start cloudflared: missing tunnel token.")
|
|
return False
|
|
try:
|
|
tunnel_container = client.containers.run(
|
|
CLOUDFLARED_IMAGE,
|
|
command=["tunnel", "--no-autoupdate", "run"],
|
|
detach=True,
|
|
name="dockflare-agent-tunnel",
|
|
network=CLOUDFLARED_NETWORK_NAME,
|
|
restart_policy={"Name": "unless-stopped"},
|
|
environment={"TUNNEL_TOKEN": tunnel_token}
|
|
)
|
|
time.sleep(2)
|
|
current_tunnel_version = fetch_cloudflared_version(tunnel_container)
|
|
logging.info(f"cloudflared container started: {tunnel_container.short_id}")
|
|
report_event_to_master("tunnel_status", {
|
|
"name": tunnel_name,
|
|
"status": "running",
|
|
"version": current_tunnel_version
|
|
})
|
|
return True
|
|
except Exception as e:
|
|
logging.error(f"Failed to start cloudflared container: {e}")
|
|
return False
|
|
|
|
def load_agent_id():
|
|
"""Loads agent ID from the filesystem."""
|
|
global AGENT_ID
|
|
if os.path.exists(AGENT_ID_FILE):
|
|
try:
|
|
with open(AGENT_ID_FILE, 'r') as f:
|
|
agent_id = f.read().strip()
|
|
if agent_id:
|
|
AGENT_ID = agent_id
|
|
logging.info(f"Loaded existing Agent ID: {AGENT_ID}")
|
|
except IOError as e:
|
|
logging.error(f"Could not read agent ID file: {e}")
|
|
|
|
def save_agent_id(agent_id):
|
|
"""Saves agent ID to the filesystem."""
|
|
try:
|
|
_write_secure_file(AGENT_ID_FILE, lambda fh: fh.write(agent_id))
|
|
logging.info(f"Saved Agent ID to {AGENT_ID_FILE}")
|
|
except IOError as e:
|
|
logging.error(f"Could not save agent ID file: {e}")
|
|
|
|
def register_with_master():
|
|
"""
|
|
Registers the agent with the DockFlare Master instance and retrieves an agent ID.
|
|
"""
|
|
global AGENT_ID, last_successful_master_contact
|
|
if not MASTER_URL or not API_KEY:
|
|
logging.error("Error: DOCKFLARE_MASTER_URL and DOCKFLARE_API_KEY must be set.")
|
|
return False
|
|
|
|
endpoint = f"{MASTER_URL}/api/v2/agents/register"
|
|
while True:
|
|
logging.info(f"Attempting to register with master at {endpoint}")
|
|
try:
|
|
headers = get_headers()
|
|
custom_display_name = os.getenv("AGENT_DISPLAY_NAME", "").strip()
|
|
default_display_name = f"agent-{AGENT_ID[:8]}" if AGENT_ID else "dockflare-agent"
|
|
payload = {
|
|
"display_name": custom_display_name or default_display_name,
|
|
"version": AGENT_VERSION,
|
|
"agent_version": AGENT_VERSION,
|
|
"supported_protocol_versions": [2, 1],
|
|
"capabilities": ["decommission.v1", "tunnel_stop.v1", "self_stop.v1"],
|
|
}
|
|
if AGENT_ID:
|
|
payload["agent_id"] = AGENT_ID
|
|
|
|
response = requests.post(endpoint, json=payload, headers=headers, timeout=15)
|
|
response.raise_for_status()
|
|
data = response.json()
|
|
new_agent_id = data.get("agent_id")
|
|
if not apply_registration_response(data):
|
|
logging.error("Master did not provide an agent_id. Retrying in 60s...")
|
|
time.sleep(60)
|
|
continue
|
|
|
|
if AGENT_ID and AGENT_ID != new_agent_id:
|
|
logging.warning(f"Master assigned a new Agent ID ({new_agent_id}). Overwriting old one ({AGENT_ID}).")
|
|
|
|
AGENT_ID = new_agent_id
|
|
save_agent_id(AGENT_ID)
|
|
last_successful_master_contact = datetime.now(timezone.utc)
|
|
logging.info(f"Successfully registered with master. Agent ID: {AGENT_ID}")
|
|
return True
|
|
except requests.exceptions.RequestException as e:
|
|
logging.error(f"Error registering with master: {e}. Retrying in 60s...")
|
|
time.sleep(60)
|
|
|
|
def _safe_response_code(response):
|
|
try:
|
|
data = response.json()
|
|
return str(data.get("code") or "unknown")[:64]
|
|
except (ValueError, AttributeError):
|
|
return "invalid_response"
|
|
|
|
|
|
def _post_agent_payload(payload):
|
|
global last_successful_master_contact
|
|
endpoint = f"{MASTER_URL}/api/v2/agents/{AGENT_ID}/events"
|
|
for attempt in range(3):
|
|
try:
|
|
response = requests.post(endpoint, json=payload, headers=get_headers(), timeout=15)
|
|
code = _safe_response_code(response)
|
|
accepted_code = code in {"accepted", "accepted_noop", "duplicate_sequence", "inventory_incomplete"}
|
|
if response.status_code in (200, 202) and (accepted_code or PROTOCOL_VERSION == 1):
|
|
last_successful_master_contact = datetime.now(timezone.utc)
|
|
return True
|
|
if response.status_code == 409 and code == "registration_required":
|
|
with _registration_lock:
|
|
if register_with_master():
|
|
return False
|
|
if response.status_code == 429 or response.status_code >= 500:
|
|
if attempt < 2:
|
|
retry_after = response.headers.get("Retry-After") if hasattr(response, "headers") else None
|
|
try:
|
|
retry_delay = min(60.0, max(0.0, float(retry_after)))
|
|
except (TypeError, ValueError):
|
|
retry_delay = (2 ** attempt) + random.random()
|
|
time.sleep(retry_delay)
|
|
continue
|
|
logging.error("Master rejected Agent request (status=%s, code=%s)", response.status_code, code)
|
|
return False
|
|
except requests.exceptions.RequestException as exc:
|
|
if attempt == 2:
|
|
logging.error("Agent request failed after bounded retries: %s", type(exc).__name__)
|
|
return False
|
|
time.sleep((2 ** attempt) + random.random())
|
|
return False
|
|
|
|
|
|
def report_event_to_master(event_type, container_data=None):
|
|
"""
|
|
Sends a JSON payload to the master's reporting endpoint.
|
|
"""
|
|
if not AGENT_ID or decommission_tombstone:
|
|
logging.debug("report_event_to_master called but AGENT_ID is missing; skipping.")
|
|
return
|
|
try:
|
|
payload = {
|
|
"type": event_type,
|
|
"timestamp": datetime.now(timezone.utc).isoformat()
|
|
}
|
|
if container_data:
|
|
safe_container = dict(container_data)
|
|
safe_container["labels"] = filter_reportable_labels(safe_container.get("labels", {}))
|
|
payload["container"] = safe_container
|
|
with _event_send_lock:
|
|
if PROTOCOL_VERSION == 2:
|
|
payload.update({
|
|
"protocol_version": 2,
|
|
"agent_session_id": AGENT_SESSION_ID,
|
|
"event_sequence": next_event_sequence(),
|
|
})
|
|
return _post_agent_payload(payload)
|
|
except Exception as exc:
|
|
logging.error("Could not build Agent event: %s", type(exc).__name__)
|
|
return False
|
|
|
|
|
|
def send_status_report(containers=None, inventory_complete=True):
|
|
if not AGENT_ID or decommission_tombstone:
|
|
return False
|
|
with _report_send_lock:
|
|
if PROTOCOL_VERSION != 2:
|
|
return report_event_to_master("status_report", {"containers": containers or []})
|
|
payload = {
|
|
"type": "status_report",
|
|
"protocol_version": 2,
|
|
"agent_session_id": AGENT_SESSION_ID,
|
|
"report_sequence": next_report_sequence(),
|
|
"inventory_complete": bool(inventory_complete),
|
|
"inventory_scope": "dockflare_enabled_running",
|
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
}
|
|
if inventory_complete:
|
|
payload["containers"] = containers or []
|
|
return _post_agent_payload(payload)
|
|
|
|
|
|
def get_docker_event_container_id(event):
|
|
if not isinstance(event, dict):
|
|
return None
|
|
|
|
actor = event.get("Actor")
|
|
actor_id = actor.get("ID") if isinstance(actor, dict) else None
|
|
for value in (actor_id, event.get("id"), event.get("ID")):
|
|
if isinstance(value, str) and value.strip():
|
|
return value.strip()
|
|
return None
|
|
|
|
|
|
def listen_for_docker_events(client, label_prefix, initial_scan=True):
|
|
thread_health_status[f"events_listener_{label_prefix.strip('.')}"] = "running"
|
|
if initial_scan:
|
|
logging.info("Performing one initial scan of running DockFlare containers...")
|
|
try:
|
|
for container in client.containers.list():
|
|
if is_dockflare_enabled(container.labels):
|
|
report_event_to_master("container_start", {
|
|
"id": container.id,
|
|
"name": container.name,
|
|
"labels": container.labels,
|
|
})
|
|
except Exception as e:
|
|
logging.error(f"Error during initial container scan: {e}")
|
|
|
|
logging.info(f"Listening for Docker events with prefix {label_prefix}...")
|
|
event_filters = {
|
|
"type": "container",
|
|
"label": f"{label_prefix}enable=true"
|
|
}
|
|
try:
|
|
for event in client.events(decode=True, filters=event_filters):
|
|
try:
|
|
if event.get("Type") == "container" and event.get("Action") in ["start", "stop", "die"]:
|
|
action = event["Action"]
|
|
container_id = get_docker_event_container_id(event)
|
|
if not container_id:
|
|
logging.warning(
|
|
"Ignoring Docker '%s' event without a container identifier (%s)",
|
|
action,
|
|
label_prefix,
|
|
)
|
|
continue
|
|
try:
|
|
container = client.containers.get(container_id)
|
|
event_type = normalize_docker_event_type(action)
|
|
logging.info(f"Detected event '{action}' for container {container.name} ({label_prefix})")
|
|
report_event_to_master(event_type, {
|
|
"id": container_id,
|
|
"name": container.name,
|
|
"labels": container.labels
|
|
})
|
|
except docker.errors.NotFound:
|
|
logging.warning(f"Container {container_id[:12]} not found after event '{action}' ({label_prefix}). Reporting to master.")
|
|
event_type = normalize_docker_event_type(action)
|
|
actor = event.get("Actor", {}).get("Attributes", {}) or {}
|
|
report_event_to_master(event_type, {
|
|
"id": container_id,
|
|
"name": actor.get("name", "unknown"),
|
|
"labels": filter_reportable_labels(actor),
|
|
})
|
|
except Exception as e:
|
|
logging.error(f"An error occurred in the event loop for {label_prefix}: {e}")
|
|
except Exception as e:
|
|
logging.error(f"Failed to connect to Docker event stream for {label_prefix}: {e}")
|
|
thread_health_status[f"events_listener_{label_prefix.strip('.')}"] = "failed"
|
|
|
|
def start_event_listeners(client):
|
|
threads = []
|
|
label_prefixes = ["dockflare.", "cloudflare.tunnel."]
|
|
for index, prefix in enumerate(label_prefixes):
|
|
thread = Thread(target=listen_for_docker_events, args=(client, prefix, index == 0), daemon=True)
|
|
threads.append(thread)
|
|
return threads
|
|
|
|
|
|
def post_decommission_ack(operation_id, payload):
|
|
global last_successful_master_contact
|
|
endpoint = f"{MASTER_URL}/api/v2/agents/{AGENT_ID}/decommission/{operation_id}/ack"
|
|
body = dict(payload)
|
|
body["operation_id"] = operation_id
|
|
if PROTOCOL_VERSION == 2:
|
|
body["agent_session_id"] = AGENT_SESSION_ID
|
|
try:
|
|
response = requests.post(endpoint, json=body, headers=get_headers(), timeout=60)
|
|
if response.status_code == 200 and _safe_response_code(response) == "acknowledged":
|
|
last_successful_master_contact = datetime.now(timezone.utc)
|
|
return True
|
|
logging.error(
|
|
"Master rejected decommission acknowledgement (status=%s, code=%s)",
|
|
response.status_code,
|
|
_safe_response_code(response),
|
|
)
|
|
except requests.exceptions.RequestException as exc:
|
|
logging.error("Decommission acknowledgement failed: %s", type(exc).__name__)
|
|
return False
|
|
|
|
|
|
def prepare_decommission(client, command):
|
|
global desired_tunnel_state, decommission_tombstone
|
|
operation_id = command.get("operation_id")
|
|
command_id = command.get("command_id")
|
|
if not operation_id or not command_id or command.get("requested_self_action") != "stop":
|
|
return False
|
|
try:
|
|
decommission_tombstone = decommission_runtime.persist_tombstone(operation_id, AGENT_ID, "prepared")
|
|
desired_tunnel_state = "stopped"
|
|
save_tunnel_state()
|
|
except Exception:
|
|
logging.error("Could not persist decommission preparation state.")
|
|
return post_decommission_ack(operation_id, {
|
|
"command_id": command_id,
|
|
"phase": "prepared",
|
|
"tombstone_persisted": False,
|
|
"tunnel_container": "failed",
|
|
"error_code": "tombstone_persistence_failed",
|
|
})
|
|
|
|
tunnel_result, tunnel_image = decommission_runtime.stop_tunnel_container(client)
|
|
self_image = decommission_runtime.self_image_reference(client)
|
|
payload = {
|
|
"command_id": command_id,
|
|
"phase": "prepared",
|
|
"tombstone_persisted": True,
|
|
"tunnel_container": tunnel_result,
|
|
"self_stop_capability": "supported" if self_image else "manual",
|
|
"agent_image": self_image,
|
|
"cloudflared_image": tunnel_image or CLOUDFLARED_IMAGE,
|
|
}
|
|
if tunnel_result == "failed":
|
|
payload["error_code"] = "tunnel_stop_failed"
|
|
return post_decommission_ack(operation_id, payload)
|
|
|
|
|
|
def finalize_decommission(client, command):
|
|
global current_tunnel_token, current_tunnel_id, current_tunnel_name
|
|
global desired_tunnel_state, decommission_tombstone
|
|
operation_id = command.get("operation_id")
|
|
command_id = command.get("command_id")
|
|
if not operation_id or not command_id:
|
|
return False
|
|
try:
|
|
decommission_tombstone = decommission_runtime.persist_tombstone(operation_id, AGENT_ID, "finalized")
|
|
current_tunnel_token = None
|
|
current_tunnel_id = None
|
|
current_tunnel_name = None
|
|
desired_tunnel_state = "stopped"
|
|
save_tunnel_state()
|
|
except Exception:
|
|
logging.error("Could not persist final decommission state.")
|
|
return False
|
|
self_stop_supported = decommission_runtime.resolve_self_container(client) is not None
|
|
acknowledged = post_decommission_ack(operation_id, {
|
|
"command_id": command_id,
|
|
"phase": "shutdown_scheduled",
|
|
"self_stop_capability": "supported" if self_stop_supported else "manual",
|
|
})
|
|
if acknowledged and self_stop_supported:
|
|
return decommission_runtime.schedule_self_stop(client)
|
|
return acknowledged
|
|
|
|
def manage_tunnels(client):
|
|
"""
|
|
Periodically polls for commands from the master and manages a cloudflared container.
|
|
"""
|
|
global tunnel_container, current_tunnel_token, current_tunnel_id, current_tunnel_version, current_tunnel_name, desired_tunnel_state, last_successful_master_contact
|
|
logging.info("Tunnel management thread started.")
|
|
|
|
while True:
|
|
if decommission_tombstone and decommission_tombstone.get("phase") == "finalized":
|
|
logging.info("Agent decommission is finalized; command polling is stopped.")
|
|
return
|
|
if not AGENT_ID:
|
|
time.sleep(10)
|
|
continue
|
|
try:
|
|
headers = get_headers(content_type=False)
|
|
endpoint = f"{MASTER_URL}/api/v2/agents/{AGENT_ID}/commands"
|
|
response = requests.get(endpoint, headers=headers, timeout=15)
|
|
response.raise_for_status()
|
|
last_successful_master_contact = datetime.now(timezone.utc)
|
|
commands = response.json().get("commands", [])
|
|
|
|
for cmd in commands:
|
|
action = cmd.get("action")
|
|
if action == "prepare_decommission":
|
|
prepare_decommission(client, cmd)
|
|
|
|
elif action == "finalize_decommission":
|
|
finalize_decommission(client, cmd)
|
|
|
|
elif decommission_tombstone:
|
|
logging.warning("Ignoring tunnel command while Agent decommission is active.")
|
|
|
|
elif action == "start_tunnel":
|
|
tunnel_token = cmd.get("token")
|
|
tunnel_name = cmd.get("tunnel_name")
|
|
tunnel_id = cmd.get("tunnel_id")
|
|
token_changed = tunnel_token and tunnel_token != current_tunnel_token
|
|
id_changed = tunnel_id and tunnel_id != current_tunnel_id
|
|
if token_changed or id_changed:
|
|
reason_parts = []
|
|
if token_changed:
|
|
reason_parts.append("token change")
|
|
if id_changed:
|
|
reason_parts.append("tunnel id change")
|
|
reason_text = ", ".join(reason_parts) if reason_parts else "assignment update"
|
|
logging.info(f"Received command to start tunnel '{tunnel_name}' ({reason_text}).")
|
|
|
|
_remove_existing_container(client)
|
|
logging.info("Starting new cloudflared tunnel container...")
|
|
_run_cloudflared_container(client, tunnel_name, tunnel_token)
|
|
current_tunnel_token = tunnel_token
|
|
current_tunnel_id = tunnel_id
|
|
current_tunnel_name = tunnel_name
|
|
desired_tunnel_state = "running"
|
|
save_tunnel_state()
|
|
else:
|
|
logging.info("Received start_tunnel command but tunnel token/id unchanged; ensuring cloudflared is running.")
|
|
current_tunnel_name = tunnel_name or current_tunnel_name
|
|
desired_tunnel_state = "running"
|
|
save_tunnel_state()
|
|
ensure_cloudflared_running(client)
|
|
|
|
elif action == "restart_tunnel":
|
|
tunnel_token = cmd.get("tunnel_token")
|
|
tunnel_name = cmd.get("tunnel_name")
|
|
tunnel_id = cmd.get("tunnel_id")
|
|
if tunnel_token and tunnel_name and tunnel_id:
|
|
logging.info(f"Received command to restart tunnel '{tunnel_name}'.")
|
|
_remove_existing_container(client)
|
|
logging.info("Restarting cloudflared tunnel container...")
|
|
_run_cloudflared_container(client, tunnel_name, tunnel_token)
|
|
current_tunnel_token = tunnel_token
|
|
current_tunnel_id = tunnel_id
|
|
current_tunnel_name = tunnel_name
|
|
desired_tunnel_state = "running"
|
|
save_tunnel_state()
|
|
else:
|
|
logging.warning("Received restart_tunnel command with missing parameters.")
|
|
|
|
elif action == "stop_tunnel":
|
|
logging.info("Received command to stop cloudflared tunnel container.")
|
|
desired_tunnel_state = "stopped"
|
|
save_tunnel_state()
|
|
_remove_existing_container(client)
|
|
report_event_to_master("tunnel_status", {
|
|
"name": current_tunnel_name,
|
|
"status": "stopped"
|
|
})
|
|
current_tunnel_token = None
|
|
current_tunnel_id = None
|
|
current_tunnel_name = None
|
|
save_tunnel_state()
|
|
|
|
elif action == "update_tunnel_config":
|
|
if not current_tunnel_id:
|
|
logging.error("Cannot update tunnel config: no tunnel ID available.")
|
|
continue
|
|
rules = cmd.get("rules", {})
|
|
ingress_rules = cloudflare_api.generate_ingress_rules(rules)
|
|
success = cloudflare_api.update_tunnel_config(MASTER_URL, API_KEY, current_tunnel_id, ingress_rules)
|
|
if success:
|
|
logging.info("Tunnel configuration updated successfully")
|
|
else:
|
|
logging.error("Failed to update tunnel configuration")
|
|
|
|
except requests.exceptions.RequestException as e:
|
|
logging.error(f"Could not poll commands from master: {e}")
|
|
except docker.errors.ImageNotFound:
|
|
logging.error("Could not start tunnel: cloudflare/cloudflared image not found. Please pull it.")
|
|
except Exception as e:
|
|
logging.error(f"An error in tunnel management: {e}")
|
|
time.sleep(30) # Poll every 30 seconds
|
|
|
|
|
|
def tunnel_health_monitor(client):
|
|
logging.info("Tunnel health monitor thread started.")
|
|
while True:
|
|
try:
|
|
ensure_cloudflared_running(client)
|
|
except Exception as e:
|
|
logging.error(f"Tunnel health monitor encountered an error: {e}")
|
|
time.sleep(30)
|
|
|
|
REPORT_INTERVAL_SECONDS = int(os.getenv("REPORT_INTERVAL_SECONDS", "30"))
|
|
|
|
def periodic_status_reporter(client):
|
|
"""
|
|
Periodic reporter: sends heartbeat and full status_report of enabled containers.
|
|
"""
|
|
logging.info("Status reporter thread started.")
|
|
while True:
|
|
if decommission_tombstone:
|
|
time.sleep(REPORT_INTERVAL_SECONDS)
|
|
continue
|
|
if not AGENT_ID:
|
|
time.sleep(5)
|
|
continue
|
|
try:
|
|
|
|
logging.info("Sending heartbeat to master.")
|
|
report_event_to_master("heartbeat")
|
|
|
|
send_inventory_report(client)
|
|
except Exception as e:
|
|
logging.error(f"Periodic reporter error: {e}")
|
|
time.sleep(REPORT_INTERVAL_SECONDS)
|
|
|
|
def cleanup():
|
|
"""
|
|
Gracefully shuts down the agent and its resources.
|
|
"""
|
|
global tunnel_container, current_tunnel_version
|
|
logging.info("Shutting down agent.")
|
|
if tunnel_container:
|
|
logging.info("Stopping tunnel container...")
|
|
try:
|
|
tunnel_container.stop()
|
|
tunnel_container.remove()
|
|
except docker.errors.NotFound:
|
|
pass
|
|
except Exception as e:
|
|
logging.error(f"Could not stop tunnel container: {e}")
|
|
finally:
|
|
tunnel_container = None
|
|
current_tunnel_version = None
|
|
# --- Health Check HTTP Server ---
|
|
class HealthCheckHandler(http.server.SimpleHTTPRequestHandler):
|
|
def do_GET(self):
|
|
global last_successful_master_contact, thread_health_status
|
|
|
|
if self.path == '/health':
|
|
seconds_since_contact = None
|
|
if last_successful_master_contact:
|
|
seconds_since_contact = int((datetime.now(timezone.utc) - last_successful_master_contact).total_seconds())
|
|
|
|
lifecycle = decommission_tombstone.get("phase") if decommission_tombstone else "active"
|
|
status = "decommissioned" if lifecycle == "finalized" else "prepared" if lifecycle == "prepared" else "unhealthy"
|
|
if AGENT_ID and not decommission_tombstone:
|
|
if seconds_since_contact is not None and seconds_since_contact < 120:
|
|
status = "healthy"
|
|
elif seconds_since_contact is not None and seconds_since_contact < 300:
|
|
status = "degraded"
|
|
|
|
tunnel_container_running = False
|
|
if tunnel_container:
|
|
try:
|
|
tunnel_container.reload()
|
|
tunnel_container_running = tunnel_container.status == 'running'
|
|
except:
|
|
pass
|
|
|
|
response_data = {
|
|
"status": status,
|
|
"agent_id": AGENT_ID,
|
|
"registered": AGENT_ID is not None,
|
|
"lifecycle": lifecycle,
|
|
"tunnel": {
|
|
"state": desired_tunnel_state,
|
|
"name": current_tunnel_name,
|
|
"id": current_tunnel_id,
|
|
"container_running": tunnel_container_running,
|
|
"version": current_tunnel_version
|
|
},
|
|
"master_connection": {
|
|
"url": MASTER_URL,
|
|
"last_successful_report": last_successful_master_contact.isoformat() if last_successful_master_contact else None,
|
|
"seconds_since_contact": seconds_since_contact
|
|
},
|
|
"threads": thread_health_status.copy()
|
|
}
|
|
|
|
http_status = 200 if status in {"healthy", "prepared", "decommissioned"} else 503
|
|
self.send_response(http_status)
|
|
self.send_header('Content-type', 'application/json')
|
|
self.end_headers()
|
|
self.wfile.write(json.dumps(response_data).encode('utf-8'))
|
|
else:
|
|
self.send_response(404)
|
|
self.end_headers()
|
|
self.wfile.write(b'404 Not Found')
|
|
|
|
def run_health_check_server():
|
|
thread_health_status["health_server"] = "running"
|
|
try:
|
|
with socketserver.TCPServer(('', HEALTH_CHECK_PORT), HealthCheckHandler) as httpd:
|
|
logging.info(f"Health check server running on port {HEALTH_CHECK_PORT}")
|
|
httpd.serve_forever()
|
|
except Exception as e:
|
|
logging.error(f"Health check server error: {e}")
|
|
thread_health_status["health_server"] = "failed"
|
|
|
|
if __name__ == "__main__":
|
|
decommission_tombstone = decommission_runtime.load_tombstone()
|
|
load_agent_id()
|
|
load_tunnel_state()
|
|
if decommission_tombstone and decommission_tombstone.get("phase") == "finalized":
|
|
logging.warning("Agent is finalized and will remain dormant until its deployment is removed or reset.")
|
|
Thread(target=run_health_check_server, daemon=True).start()
|
|
while True:
|
|
time.sleep(60)
|
|
elif register_with_master():
|
|
docker_client = docker.from_env()
|
|
try:
|
|
if not decommission_tombstone:
|
|
ensure_cloudflared_running(docker_client)
|
|
tunnel_thread = Thread(target=manage_tunnels, args=(docker_client,), daemon=True)
|
|
tunnel_thread.start()
|
|
if not decommission_tombstone:
|
|
status_thread = Thread(target=periodic_status_reporter, args=(docker_client,), daemon=True)
|
|
status_thread.start()
|
|
event_threads = start_event_listeners(docker_client)
|
|
for t in event_threads:
|
|
t.start()
|
|
monitor_thread = Thread(target=tunnel_health_monitor, args=(docker_client,), daemon=True)
|
|
monitor_thread.start()
|
|
health_thread = Thread(target=run_health_check_server, daemon=True)
|
|
health_thread.start()
|
|
while True:
|
|
time.sleep(1)
|
|
|
|
except KeyboardInterrupt:
|
|
cleanup()
|
|
except Exception as e:
|
|
logging.critical(f"A critical error occurred: {e}")
|
|
cleanup()
|
|
else:
|
|
logging.critical("Agent startup failed. Could not register with master.")
|