diff --git a/.gitea/workflows/loxide_lib.yml b/.gitea/workflows/loxide_lib.yml index 0a21747..a8b5039 100644 --- a/.gitea/workflows/loxide_lib.yml +++ b/.gitea/workflows/loxide_lib.yml @@ -14,7 +14,7 @@ jobs: - name: Install Prerequisites run: | apt update - apt install curl git python3 pip pkg-config openssl libssl-dev patchelf binutils-mingw-w64-x86-64 mingw-w64 -y + apt install curl git python3 pip pkg-config openssl libssl-dev patchelf binutils-mingw-w64-x86-64 mingw-w64 protobuf-compiler -y curl https://sh.rustup.rs -sSf | sh -s -- -y pip install maturin twine --break-system-packages diff --git a/AirlockTools_Server.py b/AirlockTools_Server.py deleted file mode 100644 index 183635c..0000000 --- a/AirlockTools_Server.py +++ /dev/null @@ -1,93 +0,0 @@ -# Copyright (C) 2025 James Brotosky, Brandon Wickline -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published -# by the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - - -# TODO Add CSV injection prevention -# TODO Continue OTP and Local approval rewrites -# TODO Explore pywin32 -# TODO Fix Requirements.txt -# TODO Create Generic system_config.json for gitea - - -import logging -import os - -import dotenv -import urllib3 - -import flows.localApproval as la -from Server.scheduler_async import ( - recurring_job, - register_function, - reload_jobs, - start_scheduler, -) -from services.API import AirlockAPIWrapper -from services.policyhandler import updateAuditPoliciesFromEnforcementPolices -from services.security import getAPI -from utils.setup import setup - -urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) - - -def main(): - - # Determine working directory, setup directory, configure logging, sent env, get API and URL if not already stored - - working_dir = setup() - - logger = logging.getLogger(__name__) - - dotenv.load_dotenv(dotenv_path=working_dir / ".env") - - try: - url = os.getenv("URL") - username = os.getenv("USERNAME") - - if not url: - raise ValueError("Missing URL in environment variables.") - if not username: - raise ValueError("Missing USERNAME in environment variables.") - - logger.debug(f"Retrieved URL: {url}") - logger.debug(f"Retrieved Username: {username}") - - except ValueError as e: - logger.error(f"Configuration error: {e}", exc_info=True) - raise - - api = AirlockAPIWrapper( - base_url=str(os.getenv("URL")), - api_key=getAPI(username, "AirlockTools"), - ) - - logger.info("Running non-interactively to start monitoring Airlock Changes") - - register_function("monitorLA", la.scheduleAddingLAHashes) - register_function("updateAuditPolicies", updateAuditPoliciesFromEnforcementPolices) - - if not os.path.exists("scheduling\\jobs.json"): - recurring_job("monitorLA", "monitorLA", interval=50, unit="seconds", args=[api]) - recurring_job( - "updateAuditPolicies", "updateAudit", interval=5, unit="minutes", args=[api] - ) - else: - reload_jobs() - - start_scheduler() - - -if __name__ == "__main__": - main() diff --git a/IRT_icon_32-512.ico b/IRT_icon_32-512.ico deleted file mode 100644 index 103b72e..0000000 Binary files a/IRT_icon_32-512.ico and /dev/null differ diff --git a/AirlockTools_Client.py b/Loxide.py similarity index 81% rename from AirlockTools_Client.py rename to Loxide.py index 47c9562..fae9cc8 100644 --- a/AirlockTools_Client.py +++ b/Loxide.py @@ -23,39 +23,28 @@ import logging import os -import tempfile -import dotenv import urllib3 from services.API import AirlockAPIWrapper from services.security import getAPI +from TUI.TUI import run_Loxide +from utils.configmanager import get_system_value from utils.setup import get_base_directory, setup -from utils.TUI import run_Loxide from utils.utils import irtang urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) def main(): - - if "NUITKA_ONEFILE_PARENT" in os.environ: - splash_filename = os.path.join( - tempfile.gettempdir(), - f"onefile_{int(os.environ['NUITKA_ONEFILE_PARENT'])}_splash_feedback.tmp", - ) - if os.path.exists(splash_filename): - os.unlink(splash_filename) - irtang() # Determine working directory, setup directory, configure logging, sent env, get API and URL if not already stored setup() base_dir = get_base_directory() logger = logging.getLogger(__name__) - dotenv.load_dotenv(dotenv_path=base_dir / ".env") try: - url = os.getenv("URL") + url = get_system_value("URL") username = os.getenv("USERNAME") if not url: @@ -75,7 +64,7 @@ def main(): raise ValueError("API key for Loxide is missing.") api = AirlockAPIWrapper( - base_url=str(os.getenv("URL")), + base_url=str(url), api_key=api_key, ) run_Loxide(api) diff --git a/Loxide_Icon.ico b/Loxide_Icon.ico new file mode 100644 index 0000000..dce491f Binary files /dev/null and b/Loxide_Icon.ico differ diff --git a/Server/scheduler_async.py b/Server/scheduler_async.py deleted file mode 100644 index 87e8c80..0000000 --- a/Server/scheduler_async.py +++ /dev/null @@ -1,215 +0,0 @@ -# Copyright (C) 2025 James Brotosky, Brandon Wickline -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published -# by the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -import asyncio -import json -import logging -import os -from typing import Any, Callable, Dict, List - -logger = logging.getLogger(__name__) - -# Registry of functions that can be scheduled -FUNCTION_MAP: Dict[str, Callable] = {} - -# Dictionary to manually track scheduled jobs by ID -scheduled_jobs: Dict[str, asyncio.TimerHandle] = {} - -# Path to the JSON file for job persistence TODO - pin this to the correct place -JOBS_FILE = os.path.join(os.getcwd(), "jobs.json") - - -def register_function(name: str, func: Callable): - """ - Register a function so it can be called by name later. - Example: - register_function("say_hello", say_hello) - """ - FUNCTION_MAP[name] = func - - -def load_jobs() -> List[Dict[str, Any]]: - """ - Load jobs from the JSON file, or return [] if none exist. - """ - if not os.path.exists(JOBS_FILE): - return [] - with open(JOBS_FILE, "r") as f: - return json.load(f) - - -def save_jobs(jobs: List[Dict[str, Any]]): - """ - Save jobs to the JSON file (overwrite). - """ - with open(JOBS_FILE, "w") as f: - json.dump(jobs, f, indent=4) - - -def cancel_job(job_id: str): - """ - Cancel a scheduled job by ID and remove it from the registry and persistence. - """ - handle = scheduled_jobs.pop(job_id, None) - if handle: - handle.cancel() - logger.info(f"Cancelled job '{job_id}'") - - jobs = [j for j in load_jobs() if j.get("id") != job_id] - save_jobs(jobs) - - -def run_once_job( - job_id: str, - func_name: str, - delay_seconds: float, - args=None, - kwargs=None, - persist=True, -): - """ - Schedule a job to run once after a delay (in seconds). - """ - args = args or [] - kwargs = kwargs or {} - - def job_wrapper(): - func = FUNCTION_MAP.get(func_name) - if func is None: - logger.error(f"Function '{func_name}' is not registered.") - return - func(*args, **kwargs) - cancel_job(job_id) - - loop = asyncio.get_event_loop() - handle = loop.call_later(delay_seconds, job_wrapper) - scheduled_jobs[job_id] = handle - - if persist: - jobs = [j for j in load_jobs() if j.get("id") != job_id] - jobs.append( - { - "id": job_id, - "type": "once", - "delay": delay_seconds, - "function": func_name, - "args": args, - "kwargs": kwargs, - } - ) - save_jobs(jobs) - logger.info( - f"Scheduled one-time job '{job_id}' to run in {delay_seconds} seconds." - ) - - -def recurring_job( - job_id: str, func_name: str, interval: float, args=None, kwargs=None, persist=True -): - """ - Schedule a recurring job. - """ - args = args or [] - kwargs = kwargs or {} - - def job_wrapper(): - func = FUNCTION_MAP.get(func_name) - if func is None: - logger.error(f"Function '{func_name}' is not registered.") - return - func(*args, **kwargs) - # Reschedule the job - handle = asyncio.get_event_loop().call_later(interval, job_wrapper) - scheduled_jobs[job_id] = handle - - cancel_job(job_id) - handle = asyncio.get_event_loop().call_later(interval, job_wrapper) - scheduled_jobs[job_id] = handle - - if persist: - jobs = [j for j in load_jobs() if j.get("id") != job_id] - jobs.append( - { - "id": job_id, - "type": "recurring", - "interval": interval, - "function": func_name, - "args": args, - "kwargs": kwargs, - } - ) - save_jobs(jobs) - logger.info(f"Scheduled recurring job '{job_id}' every {interval} seconds.") - - -def reload_jobs(): - """ - Reload jobs from JSON and reschedule them. - """ - jobs = load_jobs() - for job in jobs: - if job["type"] == "once": - run_once_job( - job["id"], - job["function"], - job["delay"], - job.get("args"), - job.get("kwargs"), - persist=False, - ) - elif job["type"] == "recurring": - recurring_job( - job["id"], - job["function"], - job["interval"], - job.get("args"), - job.get("kwargs"), - persist=False, - ) - - -async def start_scheduler(): - """ - Start the asynchronous scheduler loop. - - This function is a placeholder to keep the event loop alive. - Jobs are scheduled using asyncio.call_later and do not require polling. - """ - try: - await asyncio.Event().wait() - except asyncio.CancelledError: - logger.critical("Scheduler stopped.") - - """ - Start the asynchronous scheduler loop. - - This function is a placeholder for compatibility. Since we use asyncio.call_later, - jobs are scheduled directly on the event loop and no polling is required. - - Usage: - # In an async app (e.g., Textual) - asyncio.create_task(start_scheduler()) - - # Or in a standalone script - async def main(): - await start_scheduler() - - asyncio.run(main()) - """ - try: - while True: - await asyncio.sleep(3600) # Sleep indefinitely; jobs run via call_later - except asyncio.CancelledError: - logger.critical("Scheduler stopped.") diff --git a/widgets/OTP_generate.py b/TUI/OTP_generate.py similarity index 94% rename from widgets/OTP_generate.py rename to TUI/OTP_generate.py index b35b39d..4ca7977 100644 --- a/widgets/OTP_generate.py +++ b/TUI/OTP_generate.py @@ -1,12 +1,21 @@ import logging -from typing import List +from typing import List, Optional from textual.containers import Horizontal, Vertical from textual.css.query import NoMatches from textual.message import Message from textual.reactive import reactive from textual.widget import Widget -from textual.widgets import Button, Input, RadioButton, RadioSet, Static, TextArea +from textual.widgets import ( + Button, + Footer, + Header, + Input, + RadioButton, + RadioSet, + Static, + TextArea, +) from models.agent import Agent @@ -22,7 +31,11 @@ class OTPGenerator(Widget): class OTPInfo(Message): def __init__( - self, devices: List[Agent], requestor: str, reasoning: str, duration: int + self, + devices: Optional[List[Agent]], + requestor: str, + reasoning: str, + duration: int, ): super().__init__() self.devices = devices @@ -70,6 +83,7 @@ class OTPGenerator(Widget): pass def compose(self): + yield Header(show_clock=True, icon="⚙") title_text = Static( f"🎫 Generate One Time Passes for {len(self.devices)} device(s)", id="otpgen_title", @@ -165,6 +179,7 @@ class OTPGenerator(Widget): copy_button.styles.margin = (1, 0, 0, 0) copy_button.styles.display = "none" yield copy_button + yield Footer() def on_mount(self) -> None: """Set initial button state.""" @@ -183,7 +198,10 @@ class OTPGenerator(Widget): btn_id = event.button.id if btn_id == "back_button": - self.app.pop_screen() + + while len(self.app.screen_stack) > 2: + self.app.pop_screen() + event.stop() elif btn_id == "copy_clipboard_button": @@ -229,12 +247,11 @@ class OTPGenerator(Widget): self.otp_generated = True # Access API from the app - this is the key change! - api = self.app.api + api = self.app.api # type: ignore output_lines = [ - "=" * 60, - "OTP GENERATION RESULTS", - "=" * 60, + "Requested OTP Codes:", + "=" * 25, ] otp_dict = {} @@ -250,9 +267,9 @@ class OTPGenerator(Widget): ) for hostname, otp_code in otp_dict.items(): - output_lines.append(f"{hostname:30} | {otp_code}") + output_lines.append(f"{hostname} | {otp_code}") - output_lines.append("=" * 60) + output_lines.append("=" * 25) result_text = "\n".join(output_lines) self._show_result(result_text) diff --git a/utils/tui.py b/TUI/TUI.py similarity index 63% rename from utils/tui.py rename to TUI/TUI.py index 6eb070d..2c5d4fc 100644 --- a/utils/tui.py +++ b/TUI/TUI.py @@ -1,11 +1,12 @@ import logging import os import sys +from typing import Optional import dotenv -from dotenv import set_key from textual.app import App, ComposeResult from textual.containers import Vertical +from textual.message import Message from textual.reactive import reactive from textual.screen import Screen from textual.widgets import ( @@ -18,22 +19,27 @@ from textual.widgets import ( Tabs, ) -from flows.otp import otp_activities_by_agent, otp_revoke +from flows.otp import otp_revoke from flows.prepPolicy import menu_policy_enforce -from flows.quietAgent import findQuietAgents from models.agent import Agent from models.policy import Policy -from screens.otpworkflowscreen import OTPWorkflowScreen -from services.agenthandler import findAgents, moveAgents, toggleEnforcement from services.API import AirlockAPIWrapper from services.policyhandler import confirmUpdateAfromE -from utils.configmanager import load_env -from utils.setup import get_base_directory, load_user_config +from TUI.agentmoveoperations import AgentMoveOperations +from TUI.moveagentworkflowscreen import MoveAgentWorkflowScreen +from TUI.multiagentselector import MultiAgentSelector +from TUI.OTP_generate import OTPGenerator +from TUI.otpactivityscreen import OTPActivitiesScreen +from TUI.otpworkflowscreen import OTPWorkflowScreen +from TUI.policytreewidget import PolicyTreeWidget +from TUI.quietagentworkflowscreen import QuietAgentWorkflowScreen +from TUI.resultsdisplay import ResultsDisplay +from TUI.theme_amber_terminal import get_amber_terminal_theme +from TUI.theme_retro_terminal import get_retro_terminal_theme +from TUI.themeselector import ThemeSelector +from utils.configmanager import get_user_value, load_env, save_user_config +from utils.setup import get_base_directory from utils.utils import open_directory -from widgets.multiagentselector import MultiAgentSelector -from widgets.OTP_generate import OTPGenerator -from widgets.policytreewidget import PolicyTreeWidget -from widgets.themeselector import ThemeSelector dotenv.load_dotenv() @@ -51,80 +57,45 @@ logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- def _persist_user_theme(theme_name: str) -> None: """ - Store the chosen Textual theme in the user's config: - /config/user_config.json - and also mirror to /.env so load_env(...) sees it. + Store the chosen Textual theme in the user's config using the config manager. + No need to touch .env - config manager handles everything. """ base_dir = get_base_directory() config_dir = base_dir / "config" - user_config_path = config_dir / "user_config.json" - env_path = base_dir / ".env" - # ensure dirs / files exist similarly to setup() - config_dir.mkdir(parents=True, exist_ok=True) - if not user_config_path.exists(): - # minimal default like your load_user_config does - user_config_path.write_text( - '{"URL": "", "LOG_LEVEL": "INFO"}\n', encoding="utf-8" - ) - - # load existing user config - user_conf = load_user_config(config_dir) - user_conf["TEXTUAL_THEME"] = theme_name - - # write it back - user_config_path.write_text( - # pretty print so it stays human-readable - __import__("json").dumps(user_conf, indent=4), - encoding="utf-8", - ) - logger.debug("Updated user_config.json with TEXTUAL_THEME=%s", theme_name) - - # mirror to .env (like setup.write_config_to_env does) - env_path.parent.mkdir(parents=True, exist_ok=True) - if not env_path.exists(): - env_path.touch() try: - set_key(str(env_path), "TEXTUAL_THEME", theme_name) - except Exception as exc: # keep going even if .env write fails - logger.warning("Failed to mirror TEXTUAL_THEME to .env: %s", exc) - - # reload so load_env(...) sees the new value right now - dotenv.load_dotenv(dotenv_path=env_path, override=True) - logger.debug("Reloaded .env from %s", env_path) + save_user_config(config_dir, {"TEXTUAL_THEME": theme_name}) + logger.debug("Updated user config with TEXTUAL_THEME=%s", theme_name) + except Exception as exc: + logger.error("Failed to save TEXTUAL_THEME: %s", exc) # --------------------------------------------------------------------------- # 1) SCREEN # --------------------------------------------------------------------------- class MainMenuScreen(Screen): + api: AirlockAPIWrapper current_tab = reactive("") BUTTON_DEFS = { - "find": [ - ("🔍 - Device Search", "find_device_button"), - ("🔇 - Find Quiet Hosts", "find_quiet_button"), - ], - "move": [ - ("✅ - Move to local approval", "move_local_button"), - ("🔄 - Move to Audit/Enforcement", "move_audit_button"), - ("🔀 - Move - Other", "move_other_button"), - ], - "otp": [ - ("🎫 - Generate OTPs", "otp_generate_button"), - ("📊 - OTP Activities By Agent", "otp_activities_button"), - ("❌ - Revoke OTPs", "otp_revoke_button"), + "agent_actions": [ + ( + "🖥️ - Find, Move, or Generate OTP for Agents", + "move_agent_workflow_button", + ), + ("📊 - Review and appove OTP Activities", "otp_activities_button"), + ("📇 - Find and Move Quiet Hosts to Enforcement", "find_quiet_button"), ], "policy": [ ("🔒 - Prepare Policy For Enforcement", "policy_prep_button"), ("🔄 - Update Audit Policies", "policy_audit_update_button"), + ("❌ - Revoke OTPs", "otp_revoke_button"), ], } - def __init__(self, api: AirlockAPIWrapper) -> None: + def __init__(self) -> None: super().__init__() - self.api = api - self.extras = load_env("EXTRAS") + self.extras = get_user_value("EXTRAS", str, "NOTTODAY") wd = load_env("WORKING_DIR") or os.getcwd() if not os.path.isdir(wd): wd = os.getcwd() @@ -143,23 +114,22 @@ class MainMenuScreen(Screen): yield Header(show_clock=True, icon="⚙") tabs = [ - Tab("Policy Tree", id="p_tree"), - Tab("Device Search", id="find"), - Tab("Move Agent", id="move"), - Tab("OTP", id="otp"), + Tab("Tree View", id="p_tree"), + Tab("Agents", id="agent_actions"), Tab("Directory", id="dir"), Tab("Settings", id="settings"), ] if self.extras == "POLICYPREP": - tabs.insert(3, Tab("Policy Prep", id="policy")) + tabs.insert(2, Tab("Policy Prep", id="policy")) yield Tabs(*tabs, id="tabs") yield Vertical(id="content") yield Footer() def on_mount(self) -> None: - self.switch_tab("find") + api = self.app.api + self.switch_tab("agent_actions") # focus helpers def _get_content_buttons(self) -> list[Button]: @@ -220,7 +190,7 @@ class MainMenuScreen(Screen): def on_multi_agent_selector_agents_selected( self, message: MultiAgentSelector.AgentsSelected ) -> None: - """Handle selected agents from MultiAgentSelector.""" + """Handle selected agents from AgentSelector.""" global _PENDING_JOB selected_agents = message.selected_agents logger.info("Selected agents: %s", selected_agents) @@ -241,7 +211,6 @@ class MainMenuScreen(Screen): """Handle OTP generation request from the workflow.""" global _PENDING_JOB - # Log what we received logger.info( "OTP Generation requested: %d devices, requestor=%s, reason=%s, duration=%d", len(message.devices), @@ -250,7 +219,6 @@ class MainMenuScreen(Screen): message.duration, ) - # Set up the job to run the OTP generation _PENDING_JOB = ( "otp_workflow", message.devices, @@ -261,6 +229,47 @@ class MainMenuScreen(Screen): self.app.exit() + def on_agent_move_operations_operation_complete( + self, message: AgentMoveOperations.OperationComplete + ) -> None: + """Handle completion of agent move operation - show results.""" + logger.info( + "Agent move operation completed: %s, %d successful, %d unsuccessful", + message.operation, + len(message.successful), + len(message.unsuccessful), + ) + + # Format results for display + successful_text = "\n".join( + [f"{agent.hostname}" for agent, _ in message.successful] + ) + unsuccessful_text = "\n".join( + [f"{agent.hostname}: {error}" for agent, error in message.unsuccessful] + ) + + # Remove the operations widget + try: + ops_widget = self.query_one(AgentMoveOperations) + ops_widget.remove() + except Exception: + pass + + # Show results + self.query_one("#content", Vertical).mount( + ResultsDisplay(message.operation, successful_text, unsuccessful_text) + ) + + def on_results_display_go_back(self, message: ResultsDisplay.GoBack) -> None: + """Handle back button from results display.""" + try: + results_widget = self.query_one(ResultsDisplay) + results_widget.remove() + except Exception: + pass + # Return to main menu + self.app.pop_screen() + def on_directory_tree_file_selected( self, event: DirectoryTree.FileSelected ) -> None: @@ -278,47 +287,58 @@ class MainMenuScreen(Screen): logger.debug("Button pressed: %s", button_id) match button_id: - case "find_device_button": - _PENDING_JOB = ("legacy", findAgents, (self.app.api, False), {}) - case "find_quiet_button": - _PENDING_JOB = ("legacy", findQuietAgents, (self.app.api,), {}) - case "move_local_button": - _PENDING_JOB = ( - "legacy", - print, - ("Move to local approval (placeholder)",), - {}, - ) - case "move_audit_button": - _PENDING_JOB = ("legacy", toggleEnforcement, (self.app.api,), {}) - case "move_other_button": - _PENDING_JOB = ("legacy", moveAgents, (self.app.api,), {}) + case "move_agent_workflow_button": + self.app.push_screen(MoveAgentWorkflowScreen(self.app.devices)) + event.stop() + case "otp_generate_button": - # NEW: Push OTP workflow screen instead of legacy function self.app.push_screen(OTPWorkflowScreen(self.app.devices)) event.stop() - return # Don't exit the app + + case "find_quiet_button": + self.app.push_screen( + QuietAgentWorkflowScreen(self.app.api, self.app.policies) + ) + event.stop() + return + case "otp_activities_button": - _PENDING_JOB = ("legacy", otp_activities_by_agent, (self.app.api,), {}) + self.app.push_screen(OTPActivitiesScreen()) + event.stop() + return + case "otp_revoke_button": _PENDING_JOB = ("legacy", otp_revoke, (self.app.api,), {}) + case "policy_prep_button": _PENDING_JOB = ("legacy", menu_policy_enforce, (self.app.api,), {}) + case "policy_audit_update_button": _PENDING_JOB = ("legacy", confirmUpdateAfromE, (self.app.api,), {}) + case _: self.app.bell() logger.warning("Unknown button pressed: %s", button_id) return + # Only exit the UI loop when we explicitly queued a legacy job. + # The original flow used `self.app.exit()` after setting _PENDING_JOB so + # the outer loop could run legacy code. Keep that behavior only for legacy jobs. logger.debug("Set _PENDING_JOB = %r", _PENDING_JOB) - self.app.exit() + if _PENDING_JOB and _PENDING_JOB[0] == "legacy": + # let the main loop pick up the legacy job + self.app.exit() # --------------------------------------------------------------------------- # 2) APP # --------------------------------------------------------------------------- -class Loxide(App): +class Loxide(App[Message]): + api: AirlockAPIWrapper + working_dir: str + policies: Optional[list[Policy]] + devices: Optional[list[Agent]] + CSS = """ #logo { width: 100%; @@ -326,48 +346,67 @@ class Loxide(App): text-align: center; } """ - BINDINGS = [ ("q", "quit", "Quit"), - ("d", "open_dir", "Open Directory"), + ("f", "open_fe", "Launch Explorer"), + ("r", "refresh", "Refresh"), ] def __init__(self, api: AirlockAPIWrapper): - self._textual_theme = load_env("TEXTUAL_THEME") or "nord" + self._textual_theme = get_user_value("TEXTUAL_THEME", str, "nord") super().__init__() self.api = api wd = load_env("WORKING_DIR") or os.getcwd() if not os.path.isdir(wd): wd = os.getcwd() self.working_dir = wd + # Initial data load + self.refresh_data() - # Add error handling for API calls + def refresh_data(self) -> None: + """Public method to refresh policies and devices from the API.""" try: self.policies = [ - Policy(**row.to_dict()) for _, row in api.policy_find_all().iterrows() + Policy(**row.to_dict()) + for _, row in self.api.policy_find_all().iterrows() ] self.devices = [ - Agent(**row.to_dict()) for _, row in api.agent_find_all().iterrows() + Agent(**row.to_dict()) + for _, row in self.api.agent_find_all().iterrows() ] + if self.policies and self.devices: + for agent in self.devices: + agent.enrich_with_policies(self.policies) + logger.debug( + f"Enriched {len(self.devices)} agents with policy information" + ) except Exception as exc: logger.error("Failed to load policies/devices: %s", exc) self.policies = None self.devices = None def on_mount(self, api: AirlockAPIWrapper) -> None: + self.register_theme(get_retro_terminal_theme()) + self.register_theme(get_amber_terminal_theme()) self.theme = self._textual_theme - self.push_screen(MainMenuScreen(api)) + self.push_screen(MainMenuScreen()) + + def action_refresh(self) -> None: + self.refresh_data() def action_quit(self) -> None: global _PENDING_JOB _PENDING_JOB = None self.exit() - def action_open_dir(self) -> None: - screen = self.screen_stack[-1] - if isinstance(screen, MainMenuScreen): - if screen.current_tab != "dir": - screen.switch_tab("dir") + def action_open_fe(self) -> None: + """Open the working directory in the OS file manager (footer binding).""" + path_to_open = self.working_dir or os.getcwd() + try: + open_directory(path_to_open) + except Exception as exc: + logger.error("Failed to open directory %s: %s", path_to_open, exc) + self.bell() # optional feedback # --------------------------------------------------------------------------- @@ -380,7 +419,6 @@ def _restore_terminal_for_legacy() -> None: sys.stdout.write("\033[?1000l\033[?1002l\033[?1003l\033[?1006l") sys.stdout.write("\033[2J\033[H") sys.stdout.flush() - if os.name == "nt": try: import ctypes @@ -397,7 +435,6 @@ def _restore_terminal_for_legacy() -> None: def _run_legacy_job(func, args, kwargs) -> None: logger.debug("Running legacy job: %s", getattr(func, "__name__", func)) _restore_terminal_for_legacy() - try: func(*args, **kwargs) finally: @@ -412,24 +449,31 @@ def _run_legacy_job(func, args, kwargs) -> None: # --------------------------------------------------------------------------- def run_Loxide(api: AirlockAPIWrapper) -> None: global _PENDING_JOB + base_dir = get_base_directory() + env_path = base_dir / ".env" + dotenv.load_dotenv(dotenv_path=env_path, override=True) - while True: - base_dir = get_base_directory() - env_path = base_dir / ".env" - dotenv.load_dotenv(dotenv_path=env_path, override=True) + max_attempts = 5 + attempts = 0 + while attempts < max_attempts: + attempts += 1 + logger.debug("Starting job loop iteration (attempt %d)", attempts) _PENDING_JOB = None app = Loxide(api) try: app.run() except SystemExit as exc: - logger.debug("Caught SystemExit from Textual: %s", exc) + if exc.code != 0: + logger.debug("Caught SystemExit from Textual: %s", exc) + raise job = _PENDING_JOB logger.debug("After app.run(), _PENDING_JOB = %r", job) if not job: + logger.debug("No job pending, exiting loop") break if job[0] == "legacy": @@ -438,49 +482,24 @@ def run_Loxide(api: AirlockAPIWrapper) -> None: continue if job[0] == "restart": - # just loop again; fresh .env was already loaded at the top + logger.debug("Restarting job loop") continue if job[0] == "multi_agent_action": - # Handle multi-agent selection logger.info("Multi-agent action with selected agents: %s", job[1]) continue - # NEW: Handle OTP workflow if job[0] == "otp_workflow": _, devices, requestor, reasoning, duration = job - # Call your OTP generation with the parameters def otp_generate_with_params(): - - print(f"\n{'='*60}") - print("OTP GENERATION") - print(f"{'='*60}") - print(f"Requestor: {requestor}") - print(f"Reasoning: {reasoning}") - print(f"Duration: {duration} minutes") - print(f"\nGenerating OTPs for {len(devices)} devices:") - print(f"{'='*60}\n") - - # Call your actual OTP generation function - # You'll need to adapt otp_generate to accept these parameters - # For now, this is a placeholder showing the structure - for device in devices: - print(f"Device: {device}") - print(f" Requestor: {requestor}") - print(f" Reason: {reasoning}") - print(f" Duration: {duration} minutes") - # TODO: Actually call your API to generate OTP - # result = api.generate_otp(device, requestor, reasoning, duration) - print() - - print(f"{'='*60}") - print("OTP Generation Complete!") - print(f"{'='*60}") + # Your OTP logic here + pass _run_legacy_job(otp_generate_with_params, (), {}) continue + logger.error("Unknown job type: %r", job) break diff --git a/TUI/agentmoveoperations.py b/TUI/agentmoveoperations.py new file mode 100644 index 0000000..26032a0 --- /dev/null +++ b/TUI/agentmoveoperations.py @@ -0,0 +1,733 @@ +from dataclasses import asdict +from datetime import datetime +import logging +import os +from typing import List + +import pandas as pd +from textual.containers import Horizontal, Vertical +from textual.css.query import NoMatches +from textual.message import Message +from textual.reactive import reactive +from textual.widget import Widget +from textual.widgets import Button, DataTable, Header, Static, TextArea + +from models.agent import Agent +from TUI.OTP_generate import OTPGenerator +from TUI.otpworkflowscreen import OTPWorkflowScreen +from TUI.policyselectorscreen import PolicySelectorScreen + +logger = logging.getLogger(__name__) + + +class AgentMoveOperations(Widget): + """ + A Textual widget for managing bulk agent operations and policy migrations. + + This widget provides a comprehensive UI for performing operations on multiple + selected agents. It displays the list of target agents and provides buttons to + trigger various bulk operations like toggling policy modes or enabling local approval. + + The widget manages its own state through reactive properties and provides real-time + feedback on operation progress and results. Operations are executed sequentially + per agent with error handling that tracks both successful and failed operations. + + Attributes: + operation_in_progress (reactive[bool]): Tracks whether an operation is currently + executing. Used to disable buttons during execution. + selected_operation (reactive[str]): Tracks which operation type is currently + selected or in progress (e.g., "local_approval", "toggle_enforcement"). + + Example: + ```python + agents = [agent1, agent2, agent3] + widget = AgentMoveOperations(agents) + ``` + """ + + # Reactive property to track if an operation is in progress + operation_in_progress = reactive(False) + # Tracks the currently selected operation type + selected_operation = reactive("") + + class OperationComplete(Message): + """ + Message posted when a bulk operation completes. + + This message is broadcast to parent widgets/screens to notify them of + operation completion along with detailed results. It contains the list + of agents that were processed and the outcome for each. + + Attributes: + operation (str): Name of the operation that completed (e.g., "Local Approval Mode"). + agents (List[Agent]): List of all agents that were targeted by the operation. + successful (List[tuple]): List of (Agent, result_data) tuples for successfully + processed agents. Result data varies by operation type. + unsuccessful (List[tuple]): List of (Agent, error_message) tuples for agents + where the operation failed. Error message is a string explaining the failure. + """ + + def __init__( + self, + operation: str, + agents: List[Agent], + successful: List[tuple], + unsuccessful: List[tuple], + ): + super().__init__() + self.operation = operation + self.agents = agents + self.successful = successful # List of (agent, result) tuples + self.unsuccessful = unsuccessful # List of (agent, error) tuples + + def __init__(self, agents: List[Agent]): + """ + Initialize the AgentMoveOperations widget. + + Args: + agents (List[Agent]): List of Agent objects to perform operations on. + These agents will be displayed in the widget's agent table. + """ + super().__init__() + self.agents = agents + + def watch_operation_in_progress(self, old_value: bool, new_value: bool) -> None: + """ + React to changes in the operation_in_progress reactive property. + + This is called automatically by Textual when operation_in_progress changes. + It updates the button states to reflect whether an operation is running. + + Args: + old_value (bool): Previous value of operation_in_progress. + new_value (bool): New value of operation_in_progress. + """ + self._update_button_states() + + def _update_button_states(self) -> None: + """ + Update the enabled/disabled state of operation buttons based on current status. + + This method implements the following logic: + - If an operation is in progress: disable all buttons + - If an operation is selected: disable only that operation's button + - If no operation is selected: enable all buttons + + The state transitions prevent users from starting multiple operations + simultaneously and provide visual feedback on which operation is active. + + Handles NoMatches exceptions gracefully in case buttons are not yet rendered. + """ + try: + export_csv_btn = self.query_one("#export_csv_btn", Button) + local_approval_btn = self.query_one("#local_approval_btn", Button) + toggle_enforcement_btn = self.query_one("#toggle_enforcement_btn", Button) + other_policy_btn = self.query_one("#other_policy_btn", Button) + otp_gen_btn = self.query_one("#otp_gen_btn", Button) + + # If operation in progress, disable all + if self.operation_in_progress: + otp_gen_btn = True + export_csv_btn.disabled = True + local_approval_btn.disabled = True + toggle_enforcement_btn.disabled = True + other_policy_btn.disabled = True + else: + # If an operation was selected, disable + if self.selected_operation: + otp_gen_btn.disabled = self.selected_operation == "otp_gen" + export_csv_btn.disabled = self.selected_operation == "export_csv" + local_approval_btn.disabled = ( + self.selected_operation == "local_approval" + ) + toggle_enforcement_btn.disabled = ( + self.selected_operation == "toggle_enforcement" + ) + other_policy_btn.disabled = ( + self.selected_operation == "other_policy" + ) + else: + # Enable all buttons + otp_gen_btn = False + export_csv_btn = False + local_approval_btn.disabled = False + toggle_enforcement_btn.disabled = False + other_policy_btn.disabled = False + + except NoMatches: + pass + + def _display_results( + self, operation_name: str, successful: list, unsuccessful: list + ) -> None: + """ + Display operation results in the results text area. + + Formats the results into a human-readable summary including: + - Operation name and separator + - List of successful operations with agent hostnames + - List of failed operations with agent hostnames and error messages + - Summary statistics (total successful/failed count) + + The results are displayed in the results_text TextArea widget and the + results container is made visible after being initially hidden. + + Args: + operation_name (str): Human-readable name of the operation (e.g., "Local Approval Mode"). + successful (list): List of (Agent, result_data) tuples for successful operations. + unsuccessful (list): List of (Agent, error_message) tuples for failed operations. + """ + try: + # Build results text + results_lines = [ + f"Operation: {operation_name}", + f"{'=' * 50}", + "", + f"✅ Successful ({len(successful)}):", + ] + + if successful: + for agent, result in successful: + results_lines.append(f" ✅ {agent.hostname}") + else: + results_lines.append(" (none)") + + results_lines.append("") + results_lines.append(f"❌ Failed ({len(unsuccessful)}):") + + if unsuccessful: + for agent, error in unsuccessful: + results_lines.append(f" ❌ {agent.hostname}: {error}") + else: + results_lines.append(" (none)") + + results_lines.append("") + results_lines.append(f"{'=' * 50}") + results_lines.append( + f"Total: {len(successful)} successful, {len(unsuccessful)} failed" + ) + + results_text_widget = self.query_one("#results_text", TextArea) + results_text_widget.text = "\n".join(results_lines) + + # Show results container + results_container = self.query_one("#results_container", Vertical) + results_container.styles.display = "block" + + except Exception as e: + logger.error(f"Error displaying results: {e}") + + def compose(self): + """ + Build the UI layout for the AgentMoveOperations widget. + + This method is called by Textual to create the widget's UI structure. + It builds a two-column layout with: + - Left side: Agent table showing selected agents and their current policies + - Right side: Operation buttons and results display area + - Bottom: Navigation buttons (Back) + + The layout is responsive with: + - Agent table: 2/3 width + - Operations panel: 1/3 width + - Results area: Initially hidden, shown after operation completion + """ + yield Header(show_clock=True, icon="âš™") + title_text = Static( + f"🖥️ Agent Operations - {len(self.agents)} device(s) selected", + id="move_ops_title", + ) + title_text.styles.margin = (0, 0, 1, 0) + yield title_text + + with Horizontal() as main_layout: + main_layout.styles.height = "auto" + + # Left side - Agent list + with Vertical() as left_side: + left_side.styles.width = "3fr" + left_side.styles.height = "auto" + + agents_label = Static("Selected Agents:") + agents_label.styles.margin = (0, 0, 0, 0) + yield agents_label + + # Create a DataTable to show agents with their current policies + agent_table = DataTable(id="agent_table") + agent_table.styles.height = "1fr" + agent_table.styles.margin = (1, 0, 1, 0) + yield agent_table + + # Right side - Operation buttons + with Vertical() as right_side: + right_side.styles.width = "2fr" + right_side.styles.margin = (0, 1, 0, 1) + right_side.styles.height = "auto" + + operations_label = Static("Operations:") + operations_label.styles.margin = (0, 0, 1, 0) + yield operations_label + + # Operation buttons + export_csv_btn = Button("📈 Export CSV", id="export_csv_btn") + export_csv_btn.styles.width = "100%" + export_csv_btn.styles.margin = (0, 0, 1, 0) + yield export_csv_btn + + local_approval_btn = Button( + "✔️ Local Approval Mode", id="local_approval_btn" + ) + local_approval_btn.styles.width = "100%" + local_approval_btn.styles.margin = (0, 0, 1, 0) + yield local_approval_btn + + otp_gen_btn = Button("🎫 Generate One Time Passes", id="otp_gen_btn") + otp_gen_btn.styles.width = "100%" + otp_gen_btn.styles.margin = (0, 0, 1, 0) + yield otp_gen_btn + + toggle_enforcement_btn = Button( + "🔄 Toggle Audit/Enforcement", id="toggle_enforcement_btn" + ) + toggle_enforcement_btn.styles.width = "100%" + toggle_enforcement_btn.styles.margin = (0, 0, 1, 0) + yield toggle_enforcement_btn + + other_policy_btn = Button( + "🔀 Move to Other Policy", id="other_policy_btn" + ) + other_policy_btn.styles.width = "100%" + other_policy_btn.styles.margin = (0, 0, 1, 0) + yield other_policy_btn + + # Status label + status_label = Static("", id="status_label") + status_label.styles.margin = (2, 0, 0, 0) + yield status_label + + back_button = Button("← Back", id="back_button") + back_button.styles.width = "50%" + back_button.styles.margin = (0, 1, 1, 0) + yield back_button + + def on_mount(self) -> None: + """ + Initialize widget after it has been mounted on the screen. + + This Textual lifecycle method is called after the widget is added to the DOM. + It performs initialization tasks: + - Populates the agent table with columns for Hostname, Policy, and Status + - Adds rows to the table for each agent in self.agents + - Initializes button states based on current widget state + + The agent table displays agent.hostname, agent.groupname (or "Unknown"), + and agent.status_text (or "Unknown") for each agent. + """ + table = self.query_one("#agent_table", DataTable) + table.add_columns("Hostname", "Current Policy", "Status") + + for agent in self.agents: + table.add_row( + agent.hostname, + agent.groupname or "Unknown", + agent.status_text or "Unknown", + ) + + self._update_button_states() + + def on_otp_generator_otp_info(self, message: OTPGenerator.OTPInfo) -> None: + """Handle OTP generation request - call the actual OTP generation function.""" + + def on_button_pressed(self, event: Button.Pressed): + """ + Handle button press events from the widget. + + This Textual event handler routes button presses to appropriate actions: + - back_button: Pop this screen (return to parent) + - copy_results_btn: Copy results text to clipboard (requires pyperclip) + - local_approval_btn: Start local approval operation + - toggle_enforcement_btn: Start toggle audit/enforcement operation + - other_policy_btn: Start move to other policy operation + + After handling, event.stop() is called to prevent event propagation. + + Args: + event (Button.Pressed): The button press event containing the button reference. + """ + + btn_id = event.button.id + + if btn_id == "back_button": + while len(self.app.screen_stack) > 2: + self.app.pop_screen() + event.stop() + + elif btn_id == "copy_results_btn": + try: + results_text = self.query_one("#results_text", TextArea) + import pyperclip + + pyperclip.copy(results_text.text) + self.app.notify( + "📋✅ Results copied to clipboard!", + severity="information", + timeout=2, + ) + except ImportError: + self.app.notify( + "❌ pyperclip not installed. Run: pip install pyperclip", + severity="warning", + ) + except Exception as e: + self.app.notify(f"❌ Failed to copy: {str(e)}", severity="error") + event.stop() + elif btn_id == "export_csv_btn": + self._start_export_csv_operation() + event.stop() + + elif btn_id == "local_approval_btn": + self._start_local_approval_operation() + event.stop() + + elif btn_id == "toggle_enforcement_btn": + self._start_toggle_enforcement_operation() + event.stop() + + elif btn_id == "other_policy_btn": + self._start_other_policy_operation() + event.stop() + elif btn_id == "otp_gen_btn": + self._start_OTP_gen_operation() + event.stop() + + def _start_local_approval_operation(self) -> None: + """ + Execute the local approval mode operation on all selected agents. + + This operation performs the following steps for each agent: + 1. Generate a unique batch ID (current Unix timestamp) + 2. Create a local approval OTP with default duration of 360 minutes (6 hours) + 3. Move the agent to its related audit policy mode + + The operation: + - Sets operation state flags (selected_operation, operation_in_progress) + - Updates the status label with progress indicator + - Iterates through all agents, tracking successful and unsuccessful operations + - Displays formatted results via _display_results() + - Posts an OperationComplete message for parent widget handling + + Agents that fail are logged and added to the unsuccessful list with error details. + The operation completes and returns to a non-busy state regardless of individual + agent success/failure. + + Note: The OTP duration (360 minutes) is currently hardcoded and could be + made configurable in future versions. + """ + self.selected_operation = "local_approval" + self.operation_in_progress = True + + status_label = self.query_one("#status_label", Static) + status_label.update("✔️ Moving agents to local approval...") + + # Get API from app + api = self.app.api + + successful = [] + unsuccessful = [] + + try: + import time + + from services.agenthandler import moveAgentToRelatedPolicy + + # Generate batch ID + batch = int(time.time()) + duration = 360 # Default 6 hours, could make this configurable + + for agent in self.agents: + try: + # Add local approval OTP + addLocalApproval(api, batch, duration, agent.agentid) + # Move to audit mode + result = moveAgentToRelatedPolicy(api, agent, "audit") + successful.append((agent, result)) + logger.info( + f"Successfully moved {agent.hostname} to local approval" + ) + except Exception as e: + unsuccessful.append((agent, str(e))) + logger.error( + f"Failed to move {agent.hostname} to local approval: {e}" + ) + + except Exception as e: + logger.error(f"Error during local approval operation: {e}") + status_label.update(f"❌ Error: {str(e)}") + self.operation_in_progress = False + return + + self.operation_in_progress = False + status_label.update("✅ Operation complete!") + + # Display results in the widget + self._display_results("Local Approval Mode", successful, unsuccessful) + + # Also post message for potential parent handling + self.post_message( + self.OperationComplete( + "Local Approval Mode", self.agents, successful, unsuccessful + ) + ) + + def _start_export_csv_operation(self) -> None: + self.selected_operation = "export_csv" + self.operation_in_progress = True + successful = [] + unsuccessful = [] + status_label = self.query_one("#status_label", Static) + status_label.update("Exporting CSV...") + self.app.refresh_data() + agents = self.agents + policies = self.app.policies + path = self.app.working_dir + + try: + # Enrich each agent with policies and status text + for agent in agents: + agent.enrich_with_policies(policies) + + # Convert each Agent to a dictionary, including all fields + data = [] + for agent in agents: + row = asdict(agent) + # Remove the class-level status_map from the row + row.pop("status_map", None) + data.append(row) + + # Create DataFrame + df = pd.DataFrame(data) + timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + filename = f"agentsearch_{timestamp}.csv" + file_path = os.path.join(str(path), filename) + df.to_csv(file_path, index=False) + successful.append(file_path) + status_label.update(f"✅ Exported to {file_path}") + except Exception: + status_label.update("❌ Failed") + + self.operation_in_progress = False + + """ + # Display results in the widget + self._display_results("CSV Export", successful, unsuccessful) + + # Also post message for potential parent handling + self.post_message( + self.OperationComplete( + "CSV Export", self.agents, successful, unsuccessful + ) + ) + """ + + def _start_toggle_enforcement_operation(self) -> None: + """ + Toggle agents between enforcement and audit policy modes. + + This operation intelligently switches each agent between enforcement and + audit modes based on its current state: + - If agent.groupid is in POLICY_MAP_ENF_AUD: currently enforcing , move to audit + - Otherwise: currently in audit, move to enforcement + + The operation: + - Retrieves the enforcement/audit policy relationship map from protected config + - Sets operation state flags and updates status label + - Iterates through agents, determining current mode and toggling to opposite + - Tracks successful toggles with the new mode in the result message + - Logs both successes and failures + - Displays results and posts OperationComplete message + + The policy relationship map (POLICY_MAP_ENF_AUD) must be present in protected + configuration and maps enforcement policy IDs to audit policy IDs. If the map + is empty or not found, all agents are assumed to be in audit mode and will + be moved to enforcement. + + Returns to a non-busy state after completion regardless of individual results. + """ + self.selected_operation = "toggle_enforcement" + self.operation_in_progress = True + + status_label = self.query_one("#status_label", Static) + status_label.update("⏳ Toggling enforcement mode...") + + # Get API from app + api = self.app.api + + successful = [] + unsuccessful = [] + + try: + from services.agenthandler import moveAgentToRelatedPolicy + from utils.configmanager import get_system_json + + policy_relationship_map = get_system_json("POLICY_MAP_ENF_AUD", "{}") + + for agent in self.agents: + try: + # Determine current mode and toggle + if agent.groupid in policy_relationship_map: + # Currently in enforcement, move to audit + result = moveAgentToRelatedPolicy(api, agent, "audit") + mode = "audit" + else: + # Currently in audit, move to enforcement + result = moveAgentToRelatedPolicy(api, agent, "enforcement") + mode = "enforcement" + + successful.append((agent, f"Moved to {mode}: {result}")) + logger.info(f"Successfully toggled {agent.hostname} to {mode}") + self.app.refresh_data() + + except Exception as e: + unsuccessful.append((agent, str(e))) + logger.error(f"Failed to toggle {agent.hostname}: {e}") + + except Exception as e: + logger.error(f"Error during toggle enforcement operation: {e}") + status_label.update(f"❌ Error: {str(e)}") + self.operation_in_progress = False + return + + self.operation_in_progress = False + status_label.update("✅ Operation complete!") + + # Display results in the widget + self._display_results("Toggle Audit/Enforcement", successful, unsuccessful) + + # Also post message for potential parent handling + self.post_message( + self.OperationComplete( + "Toggle Audit/Enforcement", self.agents, successful, unsuccessful + ) + ) + + def _start_other_policy_operation(self) -> None: + """ + Move agents to a user-selected policy (currently unimplemented). + + This operation is intended to allow bulk movement of selected agents to any + alternative policy via a policy selection dialog. Currently, this feature + is not fully implemented. + + Planned Implementation: + 1. Push a new policy selector screen (TUI modal/overlay) + 2. Allow user to choose target policy from available options + 3. Move all selected agents to the chosen policy + 4. Display results like other operations + + Current Behavior: + - Sets selected_operation to "other_policy" + - Displays "Policy selection not yet implemented" status message + - Clears selected_operation without performing any action + + TODO: Complete implementation by: + - Creating a policy selector screen component + - Implementing the policy selection logic + - Integrating with moveAgentToPolicy API call + - Adding proper result tracking and display + """ + self.selected_operation = "other_policy" + self.operation_in_progress = True + + status_label = self.query_one("#status_label", Static) + status_label.update("Loading available policies...") + + try: + # Fetch all policies from API + api = self.app.api + + # Fetch all available policies + all_policies_df = api.policy_find_all() + + if all_policies_df.empty: + status_label.update("No policies available") + self.operation_in_progress = False + self.selected_operation = "" + return + + # Create and push the policy selector screen + policy_selector_screen = PolicySelectorScreen( + policies=all_policies_df, + agent_move_operations=self, + ) + self.app.push_screen(policy_selector_screen) + + except Exception as e: + logger.error(f"Error loading policies: {e}") + status_label.update(f"❌ Error: {str(e)}") + self.operation_in_progress = False + self.selected_operation = "" + self.app.notify(f"Failed to load policies: {str(e)}", severity="error") + + def _start_OTP_gen_operation(self) -> None: + status_label = self.query_one("#status_label", Static) + status_label.update("Generating OTP.") + + self.app.push_screen(OTPWorkflowScreen(self.agents)) + + def _execute_move_to_policy(self, target_policy) -> None: + """ + Execute the actual move of agents to the selected policy. + + Moves each agent sequentially to the target policy, tracking success/failure. + Updates the status label and displays results upon completion. + + Args: + target_policy: The Policy object selected by the user. + """ + status_label = self.query_one("#status_label", Static) + status_label.update(f"Moving agents to {target_policy.name}...") + + api = self.app.api + successful = [] + unsuccessful = [] + + try: + for agent in self.agents: + try: + # Move agent to target policy + result = api.agent_move(agent.agentid, target_policy.groupid) + successful.append((agent, f"Moved to {target_policy.name}")) + logger.info( + f"Successfully moved {agent.hostname} to policy {target_policy.name}" + ) + except Exception as e: + unsuccessful.append((agent, str(e))) + logger.error( + f"Failed to move {agent.hostname} to policy {target_policy.name}: {e}" + ) + + except Exception as e: + logger.error(f"Error during move to policy operation: {e}") + status_label.update(f"Error: {str(e)}") + self.operation_in_progress = False + return + self.app.refresh_data() + self.operation_in_progress = False + status_label.update("Operation complete!") + + # Display results in the widget + self._display_results( + f"Move to {target_policy.name}", + successful, + unsuccessful, + ) + + # Also post message for potential parent handling + self.post_message( + self.OperationComplete( + f"Move to {target_policy.name}", + self.agents, + successful, + unsuccessful, + ) + ) diff --git a/TUI/allowlistselectionscreen.py b/TUI/allowlistselectionscreen.py new file mode 100644 index 0000000..72426f2 --- /dev/null +++ b/TUI/allowlistselectionscreen.py @@ -0,0 +1,657 @@ +from __future__ import annotations + +import logging +from typing import Optional + +import pandas as pd +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Horizontal, Vertical +from textual.screen import Screen +from textual.widgets import ( + Button, + DataTable, + Footer, + Header, + Static, + TextArea, +) + +logger = logging.getLogger(__name__) + + +class AllowlistSelectionWidget(Static): + """ + Widget for selecting an allowlist and adding hashes to it. + Can be reused in different workflows. + """ + + DEFAULT_CSS = """ + AllowlistSelectionWidget { + height: 1fr; + layout: vertical; + } + #allowlist_main { + height: 1fr; + width: 100%; + } + #left_panel { + width: 50%; + padding: 1; + border: solid $primary; + } + #right_panel { + width: 50%; + padding: 1; + border: solid $primary; + } + #allowlist_table { + height: 70%; + margin: 1 0; + } + #allowlist_table > .datatable--header { + text-style: bold; + background: $boost; + } + #allowlist_table Row { + height: 1; + } + #preview_area { + height: 60%; + margin: 1 0; + } + #action_buttons { + height: auto; + min-height: 3; + padding: 1; + content-align: center middle; + } + .panel-title { + text-style: bold; + margin: 0 0 1 0; + } + .info-text { + margin: 1 0; + } + """ + + def __init__( + self, + selected_data: pd.DataFrame, + api=None, + hostname: Optional[str] = None, + otpid: Optional[str] = None, + hash_column: str = "sha256", # Default hash column name + ): + """ + Initialize the allowlist selection widget. + + Args: + selected_data: DataFrame containing the selected activities + api: API instance for making allowlist calls + hostname: Optional hostname for context + otpid: Optional OTP ID for context + hash_column: Name of the column containing hashes (default: "sha256") + """ + super().__init__() + self.selected_data = selected_data + self.api = api + self.hostname = hostname + self.otpid = otpid + self.hash_column = hash_column + self.allowlists = [] + self.selected_allowlist = None + self.hashes_to_add = [] + + def compose(self) -> ComposeResult: + with Horizontal(id="allowlist_main"): + # Left panel - Allowlist selection + with Vertical(id="left_panel"): + yield Static("Select Allowlist", classes="panel-title") + yield Static( + f"Choose an allowlist to add {len(self.selected_data)} selected items", + classes="info-text", + ) + + # Allowlist table + self.allowlist_table = DataTable(id="allowlist_table") + self.allowlist_table.cursor_type = "row" + yield self.allowlist_table + + # Refresh button + self.refresh_btn = Button( + "🔄 Refresh Allowlists", id="refresh_allowlists_btn" + ) + yield self.refresh_btn + + # Right panel - Preview and actions + with Vertical(id="right_panel"): + yield Static("Preview", classes="panel-title") + + # Context information + context_text = [] + if self.hostname: + context_text.append(f"Host: {self.hostname}") + if self.otpid: + context_text.append(f"OTP: {self.otpid}") + context_text.append(f"Selected Activities: {len(self.selected_data)}") + + yield Static(" | ".join(context_text), classes="info-text") + + # Preview text area + self.preview_area = TextArea( + id="preview_area", read_only=True, language="markdown" + ) + yield self.preview_area + + # Hash statistics + self.stats_label = Static("", id="stats_label", classes="info-text") + yield self.stats_label + + # Action buttons at bottom + with Horizontal(id="action_buttons"): + self.back_btn = Button("⬅ Back", id="back_btn") + self.add_btn = Button("➕ Add to Allowlist", id="add_to_allowlist_btn") + + self.back_btn.styles.width = "50%" + self.add_btn.styles.width = "50%" + self.add_btn.disabled = True # Disabled until allowlist selected + + yield self.back_btn + yield self.add_btn + + async def on_mount(self) -> None: + """Load allowlists when widget mounts.""" + await self.load_allowlists() + await self.extract_and_preview_hashes() + + async def load_allowlists(self) -> None: + """Load available allowlists from API, grouped by policy association.""" + if not self.api: + logger.error("No API available") + self.allowlist_table.add_column("Error") + self.allowlist_table.add_row("No API available") + return + + try: + # First, try to get the host's policy if hostname is provided + host_policy_allowlists = [] + host_policy_ids = set() + policy_name = "Unknown Policy" # Default value + group_id = None + + if self.hostname: + try: + # Get agent info to find its policy + agents_df = self.api.agent_find_by_hostname(self.hostname) + if not agents_df.empty: + # Get the policy group ID for this host + group_id = agents_df.iloc[0].get("groupid") + + # Look up the policy name from app's cached policies + if ( + group_id + and hasattr(self.app, "policies") + and self.app.policies + ): + for policy in self.app.policies: + if policy.groupid == group_id: + policy_name = policy.name + logger.info( + f"Found policy name: '{policy_name}' for group_id: {group_id}" + ) + break + + logger.info( + f"Found host '{self.hostname}' in policy '{policy_name}' (group_id: {group_id})" + ) + + if group_id: + # Get allowlists for this policy + policy_allowlists_df = self.api.policy_list_allowlists( + group_id + ) + if not policy_allowlists_df.empty: + host_policy_allowlists = policy_allowlists_df.to_dict( + orient="records" + ) + host_policy_ids = { + al.get("applicationid") + for al in host_policy_allowlists + } + logger.info( + f"Found {len(host_policy_allowlists)} allowlists for host's policy" + ) + except Exception as e: + logger.warning(f"Could not get host's policy allowlists: {e}") + + # If we still don't have a policy name, try to get it from the first allowlist or use a default + if not policy_name: + # Get all policies and try to find which one has allowlists + try: + all_policies_df = self.api.policy_find_all() + if not all_policies_df.empty: + # If we have a group_id from somewhere, use it + if group_id: + policy_row = all_policies_df[ + all_policies_df["groupid"] == group_id + ] + if not policy_row.empty: + policy_name = policy_row.iloc[0].get( + "groupname", "Unknown Policy" + ) + else: + # Use the first policy as fallback + policy_name = all_policies_df.iloc[0].get( + "groupname", "Default Policy" + ) + logger.info(f"Using first available policy: {policy_name}") + else: + policy_name = "Unknown Policy" + except Exception as e: + logger.warning(f"Could not fetch policies: {e}") + policy_name = "Unknown Policy" + + # Get all allowlists + all_allowlists_df = self.api.allowlist_find_all() + + if all_allowlists_df.empty: + self.allowlist_table.add_column("No Allowlists") + self.allowlist_table.add_row("No allowlists found") + return + + all_allowlists = all_allowlists_df.to_dict(orient="records") + + # Separate into two groups: policy-associated and others + other_allowlists = [ + al + for al in all_allowlists + if al.get("applicationid") not in host_policy_ids + ] + + # Sort each group alphabetically by name + host_policy_allowlists.sort(key=lambda x: x.get("name", "").lower()) + other_allowlists.sort(key=lambda x: x.get("name", "").lower()) + + # Combine lists with policy-associated first + self.allowlists = host_policy_allowlists + other_allowlists + + # Setup table columns + self.allowlist_table.clear() + self.allowlist_table.add_columns("Name", "Application ID", "Type") + + # Track which rows are headers vs actual allowlists + self._row_to_allowlist_map = {} + current_row = 0 + + # Add policy-associated allowlists if any + if host_policy_allowlists: + # Add section header + header_text = f"=== Policy: {policy_name or 'Host Policy'} ===" + self.allowlist_table.add_row(header_text, "", "", key="header_policy") + current_row += 1 + + # Add policy allowlists + for idx, allowlist in enumerate(host_policy_allowlists): + name = allowlist.get("name", "Unknown") + app_id = allowlist.get("applicationid", "Unknown") + + self.allowlist_table.add_row( + f" {name}", # Indent to show grouping + app_id, + "Policy", + key=f"policy_{idx}", + ) + self._row_to_allowlist_map[current_row] = idx + current_row += 1 + + # Add other allowlists + if other_allowlists: + # Add section header + if host_policy_allowlists: + # Add spacer if we have policy allowlists above + self.allowlist_table.add_row("", "", "", key="spacer") + current_row += 1 + + self.allowlist_table.add_row( + "=== Other Available Allowlists ===", "", "", key="header_other" + ) + current_row += 1 + + # Add other allowlists + for idx, allowlist in enumerate(other_allowlists): + name = allowlist.get("name", "Unknown") + app_id = allowlist.get("applicationid", "Unknown") + + self.allowlist_table.add_row( + f" {name}", # Indent to show grouping + app_id, + "General", + key=f"other_{idx}", + ) + # Map to the correct index in the combined list + actual_idx = len(host_policy_allowlists) + idx + self._row_to_allowlist_map[current_row] = actual_idx + current_row += 1 + + # Log summary + logger.info( + f"Loaded {len(self.allowlists)} total allowlists: " + f"{len(host_policy_allowlists)} policy-associated, " + f"{len(other_allowlists)} others" + ) + + # Update stats label if no allowlists in policy + if self.hostname and not host_policy_allowlists: + self.stats_label.update( + f"Note: No allowlists found for {self.hostname}'s policy | " + + self.stats_label.content.plain + ) + + except Exception as exc: + logger.exception(f"Failed to load allowlists: {exc}") + self.allowlist_table.add_column("Error") + self.allowlist_table.add_row(f"Failed to load: {str(exc)}") + + async def extract_and_preview_hashes(self) -> None: + """Extract hashes from selected data and show preview.""" + preview_lines = ["## Hash Extraction Summary\n"] + + # Check for hash column + if self.hash_column not in self.selected_data.columns: + # Try to find a hash column + possible_hash_cols = [ + "sha256", + "SHA256", + "hash", + "Hash", + "sha1", + "SHA1", + "md5", + "MD5", + "filehash", + "file_hash", + ] + found_col = None + for col in possible_hash_cols: + if col in self.selected_data.columns: + found_col = col + break + + if found_col: + self.hash_column = found_col + preview_lines.append(f"✓ Found hash column: **{found_col}**\n") + else: + preview_lines.append("⚠️ **No hash column found**\n") + preview_lines.append("Available columns:\n") + for col in self.selected_data.columns: + if col != "_row_id": + preview_lines.append(f" - {col}\n") + + self.preview_area.text = "".join(preview_lines) + self.stats_label.update("No hashes to add") + return + + # Extract unique hashes + hashes = self.selected_data[self.hash_column].dropna().unique() + self.hashes_to_add = [h for h in hashes if h and str(h).strip()] + + # Build preview + preview_lines.append(f"### Found {len(self.hashes_to_add)} unique hashes\n\n") + + # Show sample of hashes (first 10) + preview_lines.append("**Sample hashes to be added:**\n```\n") + for i, hash_val in enumerate(self.hashes_to_add[:10]): + preview_lines.append(f"{i+1}. {hash_val}\n") + if len(self.hashes_to_add) > 10: + preview_lines.append(f"... and {len(self.hashes_to_add) - 10} more\n") + preview_lines.append("```\n\n") + + # Show sample of source data + preview_lines.append("**Sample source activities:**\n") + sample_cols = [ + col + for col in self.selected_data.columns + if col not in ["_row_id"] and col in ["filename", "path", "action", "user"] + ] + if not sample_cols: + sample_cols = [ + col for col in self.selected_data.columns if col != "_row_id" + ][:3] + + if sample_cols: + preview_lines.append("```\n") + for i, row in self.selected_data[sample_cols].head(5).iterrows(): + row_text = " | ".join([f"{col}: {row[col]}" for col in sample_cols]) + preview_lines.append(f"{row_text}\n") + preview_lines.append("```\n") + + self.preview_area.text = "".join(preview_lines) + + # Update statistics + self.stats_label.update( + f"Ready to add {len(self.hashes_to_add)} unique hashes | " + f"From {len(self.selected_data)} selected activities" + ) + + async def on_data_table_row_selected(self, event) -> None: + """Handle allowlist selection.""" + try: + # Extract row index from event - handle different event structures + row_index = None + + # Try to get row index from coordinate + if hasattr(event, "coordinate") and hasattr(event.coordinate, "row"): + row_index = event.coordinate.row + # Try cursor_row as fallback + elif hasattr(event, "cursor_row"): + row_index = event.cursor_row + # Try getting from the table itself + else: + table = self.allowlist_table + if hasattr(table, "cursor_row"): + row_index = table.cursor_row + + # Validate row index + if row_index is not None and isinstance(row_index, int): + # Account for group headers in the row count + actual_allowlist_index = self._get_allowlist_index_from_row(row_index) + + if ( + actual_allowlist_index is not None + and 0 <= actual_allowlist_index < len(self.allowlists) + ): + self.selected_allowlist = self.allowlists[actual_allowlist_index] + self.add_btn.disabled = False + self.add_btn.label = ( + f"➕ Add to '{self.selected_allowlist.get('name', 'Unknown')}'" + ) + + # Update preview with selection + await self._update_preview_with_selection() + + logger.info( + f"Selected allowlist: {self.selected_allowlist.get('name')}" + ) + else: + logger.debug(f"Row {row_index} is a header or invalid") + else: + logger.warning(f"Could not extract valid row index from event: {event}") + + except Exception as exc: + logger.exception(f"Failed to select allowlist: {exc}") + + def _get_allowlist_index_from_row(self, row_index: int) -> Optional[int]: + """Convert table row index to allowlist list index, accounting for group headers.""" + # This will be updated when we have group headers + if hasattr(self, "_row_to_allowlist_map"): + return self._row_to_allowlist_map.get(row_index) + return row_index + + async def _update_preview_with_selection(self) -> None: + """Update preview when an allowlist is selected.""" + if not self.selected_allowlist: + return + + current_text = self.preview_area.text + # Remove any existing selection header + if "### Selected Allowlist:" in current_text: + lines = current_text.split("\n") + # Find and remove the selection lines + new_lines = [] + skip_next = False + for line in lines: + if line.startswith("### Selected Allowlist:"): + skip_next = True + continue + if skip_next and line.startswith("Application ID:"): + skip_next = False + continue + if not skip_next: + new_lines.append(line) + current_text = "\n".join(new_lines) + + # Add new selection at the top + selection_text = ( + f"### Selected Allowlist: **{self.selected_allowlist.get('name')}**\n" + f"Application ID: {self.selected_allowlist.get('applicationid')}\n\n" + ) + self.preview_area.text = selection_text + current_text + + async def on_button_pressed(self, event) -> None: + """Handle button presses.""" + btn = getattr(event, "button", None) or getattr(event, "sender", None) + btn_id = getattr(btn, "id", None) or getattr(event, "button_id", None) + + if btn is self.back_btn or btn_id == "back_btn": + await self.app.pop_screen() + event.stop() + return + + if btn is self.refresh_btn or btn_id == "refresh_allowlists_btn": + await self.load_allowlists() + event.stop() + return + + if btn is self.add_btn or btn_id == "add_to_allowlist_btn": + await self.add_hashes_to_allowlist() + event.stop() + return + + async def add_hashes_to_allowlist(self) -> None: + """Add the extracted hashes to the selected allowlist.""" + if not self.selected_allowlist or not self.hashes_to_add: + self.app.notify( + "No allowlist selected or no hashes to add", severity="warning" + ) + return + + if not self.api: + self.app.notify("API not available", severity="error") + return + + try: + # Disable button during operation + self.add_btn.disabled = True + self.add_btn.label = "⏳ Adding hashes..." + + # Call API to add hashes + app_id = self.selected_allowlist.get("applicationid") + allowlist_name = self.selected_allowlist.get("name", "Unknown") + + logger.info( + f"Adding {len(self.hashes_to_add)} hashes to allowlist {allowlist_name} (ID: {app_id})" + ) + + result = self.api.hash_add_to_allowlist(app_id, self.hashes_to_add) + + # Success notification + self.app.notify( + f"✅ Successfully added {len(self.hashes_to_add)} hashes to '{allowlist_name}'", + title="Success", + severity="information", + timeout=5, + ) + + # Update preview to show success + self.preview_area.text = ( + f"## ✅ SUCCESS\n\n" + f"Added **{len(self.hashes_to_add)} hashes** to allowlist:\n" + f"**{allowlist_name}** (ID: {app_id})\n\n" + f"### Operation Details:\n" + f"- Source: {self.hostname or 'Multiple hosts'}\n" + f"- OTP ID: {self.otpid or 'N/A'}\n" + f"- Activities processed: {len(self.selected_data)}\n" + f"- Unique hashes added: {len(self.hashes_to_add)}\n" + ) + + # Change button to "Done" + self.add_btn.label = "✅ Done" + self.add_btn.disabled = True + + except Exception as exc: + logger.exception(f"Failed to add hashes to allowlist: {exc}") + self.app.notify( + f"❌ Failed to add hashes: {str(exc)}", + title="Error", + severity="error", + timeout=10, + ) + + # Re-enable button + self.add_btn.disabled = False + self.add_btn.label = "⟳ Retry Add to Allowlist" + + +class AllowlistSelectionScreen(Screen): + """ + Screen wrapper for the AllowlistSelectionWidget. + """ + + BINDINGS = [ + Binding("b", "back", "Back"), + Binding("r", "refresh", "Refresh Allowlists"), + Binding("enter", "confirm", "Add to Allowlist"), + ] + + def __init__( + self, + selected_data: pd.DataFrame, + api=None, + hostname: Optional[str] = None, + otpid: Optional[str] = None, + hash_column: str = "sha256", + ): + super().__init__() + self.selected_data = selected_data + self.api = api + self.hostname = hostname + self.otpid = otpid + self.hash_column = hash_column + + def compose(self) -> ComposeResult: + yield Header(show_clock=True) + self.widget = AllowlistSelectionWidget( + self.selected_data, + api=self.api, + hostname=self.hostname, + otpid=self.otpid, + hash_column=self.hash_column, + ) + yield self.widget + yield Footer() + + async def action_back(self) -> None: + """Go back to previous screen.""" + await self.app.pop_screen() + + async def action_refresh(self) -> None: + """Refresh the allowlists.""" + if hasattr(self, "widget") and self.widget: + await self.widget.load_allowlists() + + async def action_confirm(self) -> None: + """Confirm and add to allowlist.""" + if hasattr(self, "widget") and self.widget: + if self.widget.selected_allowlist and self.widget.hashes_to_add: + await self.widget.add_hashes_to_allowlist() diff --git a/TUI/moveagentworkflowscreen.py b/TUI/moveagentworkflowscreen.py new file mode 100644 index 0000000..8398ddf --- /dev/null +++ b/TUI/moveagentworkflowscreen.py @@ -0,0 +1,61 @@ +from typing import List, Optional + +from textual.app import ComposeResult +from textual.screen import Screen + +from models.agent import Agent +from TUI.agentmoveoperations import AgentMoveOperations +from TUI.multiagentselector import MultiAgentSelector +from TUI.resultsdisplay import ResultsDisplay + + +class MoveAgentWorkflowScreen(Screen): + """Screen that handles the agent movement workflow.""" + + def __init__(self, all_agents: Optional[List[Agent]]): + super().__init__() + self.all_agents = all_agents + self.selected_agents = None + + def compose(self) -> ComposeResult: + """Start with the multi-agent selector.""" + yield MultiAgentSelector(self.all_agents) + + def on_multi_agent_selector_agents_selected( + self, message: MultiAgentSelector.AgentsSelected + ) -> None: + """Handle selected agents - switch to operations screen.""" + self.selected_agents = message.selected_agents + + # Remove the MultiAgentSelector + selector = self.query_one(MultiAgentSelector) + selector.remove() + + # Mount the AgentMoveOperations with the selected Agent objects + self.mount(AgentMoveOperations(self.selected_agents)) + + def on_agent_move_operations_operation_complete( + self, message: AgentMoveOperations.OperationComplete + ) -> None: + """Handle completion of move operation - transition to results screen.""" + # Format successful results + success_lines = [] + for agent, result in message.successful: + success_lines.append(f"✓ {agent.hostname}") + + # Format unsuccessful results + failure_lines = [] + for agent, error in message.unsuccessful: + failure_lines.append(f"✗ {agent.hostname}: {error}") + + successful_text = "\n".join(success_lines) if success_lines else "(none)" + unsuccessful_text = "\n".join(failure_lines) if failure_lines else "(none)" + + # Remove the operations widget + ops_widget = self.query_one(AgentMoveOperations) + ops_widget.remove() + + # Mount the results display + self.mount( + ResultsDisplay(message.operation, successful_text, unsuccessful_text) + ) diff --git a/widgets/multiagentselector.py b/TUI/multiagentselector.py similarity index 92% rename from widgets/multiagentselector.py rename to TUI/multiagentselector.py index e46c636..afe8e14 100644 --- a/widgets/multiagentselector.py +++ b/TUI/multiagentselector.py @@ -1,12 +1,20 @@ import difflib import re -from typing import List +from typing import List, Optional from textual.containers import Horizontal, Vertical from textual.css.query import NoMatches from textual.message import Message from textual.widget import Widget -from textual.widgets import Button, SelectionList, Static, Switch, TextArea +from textual.widgets import ( + Button, + Footer, + Header, + SelectionList, + Static, + Switch, + TextArea, +) from models.agent import Agent @@ -17,7 +25,7 @@ class MultiAgentSelector(Widget): super().__init__() self.selected_agents = selected_agents - def __init__(self, all_agents: List[Agent]): + def __init__(self, all_agents: Optional[List[Agent]]): super().__init__() self.all_agents = all_agents self._match_type = "exact" @@ -31,7 +39,8 @@ class MultiAgentSelector(Widget): self._match_type = value def compose(self): - title_text = Static("🖧 Multi-Agent Selector", id="selector_title") + yield Header(show_clock=True, icon="⚙") + title_text = Static("🖧 Agent Selector", id="selector_title") title_text.styles.margin = (0, 0, 0, 1) yield title_text @@ -70,20 +79,21 @@ class MultiAgentSelector(Widget): with Horizontal() as select_buttons: select_buttons.styles.margin = (0, 0, 0, 0) - select_all_button = Button("✅ Select All", id="select_all") - select_all_button.styles.margin = (1, 1, 0, 1) - yield select_all_button - select_none_button = Button("🚫 Select None", id="select_none") - select_none_button.styles.margin = (1, 0, 0, 1) + select_none_button.styles.margin = (1, 1, 0, 1) yield select_none_button + select_all_button = Button("✅ Select All", id="select_all") + select_all_button.styles.margin = (1, 0, 0, 1) + yield select_all_button + with Horizontal() as button_row: button_row.styles.height = "auto" button_row.styles.margin = (1, 0, 0, 0) back_button = Button("← Back", id="back_button") back_button.styles.width = "1fr" + back_button.styles.margin = (0, 0, 0, 1) yield back_button submit_button = Button( @@ -98,6 +108,7 @@ class MultiAgentSelector(Widget): right_pane.styles.width = "2fr" yield SelectionList(id="match_results") yield Static(id="unmatched_label") + yield Footer() def on_switch_changed(self, event: Switch.Changed): self.match_type = "fuzzy" if event.value else "exact" diff --git a/TUI/otpactivityscreen.py b/TUI/otpactivityscreen.py new file mode 100644 index 0000000..5947cee --- /dev/null +++ b/TUI/otpactivityscreen.py @@ -0,0 +1,897 @@ +from __future__ import annotations + +from datetime import datetime +import logging +import os + +import pandas as pd +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Horizontal, Vertical +from textual.screen import Screen +from textual.widgets import Button, DataTable, Footer, Header, Static + +from TUI.allowlistselectionscreen import AllowlistSelectionScreen +from utils.configmanager import load_env + +logger = logging.getLogger(__name__) + + +def _load_working_dir() -> str: + """ + Load the working directory from environment variables or use the current working directory. + """ + wd = os.environ.get("WORKING_DIR") + if wd: + return wd + return os.getcwd() + + +class OTPActivitiesWidget(Static): + """ + Reusable widget that contains the sessions table (left) and an Activity Preview (right). + The right side shows an Activity Preview that takes ~75% vertical space, and a lower area + with Back and Continue buttons. The Continue button pushes ActivityDetailScreen with the + currently-loaded activities. + """ + + DEFAULT_CSS = """ + OTPActivitiesWidget { + height: 1fr; + } + #main_row { + width: 100%; + height: 100%; + layout: horizontal; + } + #left_panel { + width: 60%; + min-width: 60; + border: none; + } + #right_panel { + width: 40%; + min-width: 40; + border: none; + layout: vertical; + } + #activity_preview_container { + height: 1fr; + border: none; + padding: 1 1; + } + #activity_buttons { + height: auto; + min-height: 3; + padding: 1 1; + content-align: center middle; + } + """ + + def compose(self) -> ComposeResult: + # Layout: horizontal main row with left & right panels + with Horizontal(id="main_row"): + # Left: sessions area + with Vertical(id="left_panel"): + yield Static("OTP Sessions", classes="panel-title") + with Vertical(id="sessions_table_container"): + self.sessions_table = DataTable(id="sessions_table") + self.sessions_table.styles.width = "100%" + yield self.sessions_table + # Right: Activity Preview (top 3/4) + buttons (bottom 1/4) + with Vertical(id="right_panel"): + # Activity preview area (takes ~75% of right panel) + yield Static("Activity Preview", classes="panel-title") + with Vertical(id="activity_preview_container"): + self.activities_table = DataTable(id="activity_preview_table") + yield self.activities_table + # Buttons area at the bottom (Back, Continue) + with Horizontal(id="activity_buttons"): + # Back takes left side, Continue right side + self.back_btn = Button("Back", id="activity_back_btn") + self.continue_btn = Button("Continue", id="activity_continue_btn") + # Stretch buttons nicely + self.back_btn.styles.width = "50%" + self.continue_btn.styles.width = "50%" + yield self.back_btn + yield self.continue_btn + + async def on_mount(self) -> None: + # Configure sessions table and activities preview + self.sessions_table.clear() + self.sessions_table.add_columns( + "otpid", "hostname", "status", "purpose", "granted" + ) + self.activities_table.clear() + # activities_table columns are dynamically added when activities are loaded. + # Selection behavior + self.sessions_table.cursor_type = "row" + try: + self.sessions_table.zebra_stripes = True + except Exception: + pass + self.activities_table.cursor_type = "row" + try: + self.activities_table.zebra_stripes = True + except Exception: + pass + # Store state + self._sessions_df: pd.DataFrame | None = None + self._activities_df: pd.DataFrame | None = None + self._selected_session_otpid: str | int | None = None + + async def on_button_pressed(self, event) -> None: # type: ignore[override] + """ + Handle Back / Continue buttons for the Activity Preview area. + """ + # Try to resolve the button object from the event + btn = ( + getattr(event, "button", None) + or getattr(event, "sender", None) + or getattr(event, "control", None) + or getattr(event, "widget", None) + ) + btn_id = ( + getattr(btn, "id", None) + or getattr(event, "button_id", None) + or getattr(event, "id", None) + ) + # ---- Back ---- + if btn is self.back_btn or btn_id == getattr(self.back_btn, "id", None): + while len(self.app.screen_stack) > 2: + self.app.pop_screen() + event.stop() + return + # ---- Continue ---- + if btn is self.continue_btn or btn_id == getattr(self.continue_btn, "id", None): + if self._activities_df is None or self._activities_df.empty: + logger.info("Continue pressed but no activities loaded.") + await self.post_message( + Static("No activities loaded to continue with.") + ) + return + # Copy activities DataFrame to pass to new screen + activities_copy = self._activities_df.copy() + otpid = self._selected_session_otpid + # Optionally include hostname if available + hostname = None + try: + if self._sessions_df is not None: + df = self._sessions_df.reset_index(drop=True) + match = df[df["otpid"] == otpid] + if not match.empty: + hostname = match.iloc[0].get("hostname") + except Exception: + hostname = None + # Create and push ActivityDetailScreen, handing the data + try: + detail_screen = ActivityDetailScreen( + activities_copy, otpid=otpid, hostname=hostname + ) + await self.app.push_screen(detail_screen) + except Exception as exc: + logger.exception("Failed to push ActivityDetailScreen: %s", exc) + return + # Unknown button on widget + logger.debug( + "Unhandled OTPActivitiesWidget button pressed (resolved btn=%r, id=%r)", + btn, + btn_id, + ) + + async def on_data_table_row_selected(self, event) -> None: # type: ignore[override] + """ + Robust handler for DataTable row-selection across Textual micro-versions. + Tries many attribute names and shapes: + - numeric index (row_key, row_index, index) + - coordinate object or tuple (coordinate.row or (row, col)) + - direct row values (row, values, cells) -> we try to map those back to the sessions DF + - table.cursor_row fallback + """ + # 1) Determine the sending table (best-effort) + sender = None + for attr in ("sender", "table", "data_table", "control"): + sender = getattr(event, attr, None) + if sender is not None: + break + if sender is None: + sender = self.sessions_table # Assume sessions_table if unknown + # Only respond to selections in the sessions table + if sender is not self.sessions_table: + return + + # Helper to log and return + def _bad(msg: str, *args): + logger.warning(msg, *args) + return None + + # 2) Try to extract a numeric index + row_key = None + for attr in ("row_key", "row", "row_index", "index"): + row_key = getattr(event, attr, None) + if row_key is not None: + break + # If coordinate: try to extract .row or tuple[0] + if row_key is None: + coord = getattr(event, "coordinate", None) or getattr( + event, "cursor_coordinate", None + ) + if coord is not None: + if hasattr(coord, "row"): + row_key = coord.row + elif isinstance(coord, (tuple, list)) and len(coord) >= 1: + row_key = coord[0] + # If still nothing, maybe the event provides the row's cell values directly + row_values = None + for attr in ("values", "cells", "row", "row_values", "selected_row_values"): + val = getattr(event, attr, None) + if val: + # Prefer actual sequence of cell values + row_values = val + break + # If we have row_values, try to map them back to the sessions DataFrame + if row_values is not None: + # Normalize into list of strings for comparison + try: + vals = [ + "" if pd.isna(v) else str(v) + for v in ( + list(row_values) + if not isinstance(row_values, str) + else [row_values] + ) + ] + except Exception: + vals = [str(row_values)] + # Try to match against the expected columns order we render + if self._sessions_df is None or self._sessions_df.empty: + logger.warning( + "Sessions DataFrame is empty; cannot map selected row values." + ) + return + df_ordered = self._sessions_df.reset_index(drop=True) + expected_cols = ["otpid", "hostname", "status", "purpose", "granted"] + + # Build stringified candidates for each row in df using the same columns we show + def _row_to_vals(sr): + out = [] + for c in expected_cols: + if c in sr: + v = sr[c] + out.append("" if pd.isna(v) else str(v)) + else: + out.append("") + return out + + match_idx = None + for i, sr in df_ordered.iterrows(): + cand = _row_to_vals(sr) + # Compare prefix: row values might be a subset (e.g. only first 3 cols), so compare prefix only + if len(vals) <= len(cand) and all( + vals[j] == cand[j] for j in range(len(vals)) + ): + match_idx = i + break + if match_idx is None: + # Try looser match: compare first cell only (otpid) + first = vals[0] if vals else None + if first is not None: + for i, sr in df_ordered.iterrows(): + cand0 = "" if pd.isna(sr.get("otpid")) else str(sr.get("otpid")) + if cand0 == first: + match_idx = i + break + if match_idx is None: + logger.warning( + "Unable to locate DataFrame row matching selected row values: %r", + vals, + ) + return + idx = int(match_idx) + else: + # 3) If we have a row_key, try to normalize to an int index + if row_key is not None: + try: + idx = int(row_key) + except Exception: + # Try converting via string + try: + idx = int(str(row_key)) + except Exception: + idx = None + if idx is None: + # Final numeric fallback: use sessions_table.cursor_row if present + try: + idx = getattr(self.sessions_table, "cursor_row") + except Exception: + idx = None + if idx is None: + _bad("Failed to normalize row/key from event: %r", row_key) + return + else: + # 4) Try table cursor_row as last resort + try: + idx = getattr(self.sessions_table, "cursor_row") + except Exception: + logger.warning( + "Could not determine selected row from event: %r", event + ) + # Helpful debug hint for you to paste back if still failing: + logger.debug("Event repr for debugging: %r", event) + return + # At this point we should have an integer idx + try: + idx = int(idx) + except Exception: + logger.exception( + "Final normalization of selected row index failed: %r", idx + ) + return + # Validate sessions df + if self._sessions_df is None or self._sessions_df.empty: + logger.warning("Sessions DataFrame empty; nothing to select.") + return + df_ordered = self._sessions_df.reset_index(drop=True) + if idx < 0 or idx >= len(df_ordered): + logger.warning( + "Selected row index %s out of range (0..%d)", idx, len(df_ordered) - 1 + ) + return + row_series = df_ordered.iloc[idx] + otpid = row_series.get("otpid") + hostname = row_series.get("hostname") + # Store selected session and fetch activities + self._selected_session_otpid = otpid + # Obtain api from app (try multiple places) + api = ( + getattr(self.app, "api", None) + or getattr(self, "api", None) + or getattr(self.app, "airlock_api", None) + ) + if api is None: + logger.error("No API available on self.app.api - cannot fetch activities") + return + logger.info( + "Fetching activities for otpid=%s host=%s (selected row=%s)", + otpid, + hostname, + idx, + ) + await self._fetch_activities_for_otpid(api, otpid, hostname=hostname) + + async def load_sessions_from_api(self, api) -> None: + """ + Pulls OTP session lists, adds status column, concatenates and populates the sessions table. + """ + try: + active = api.otp_find_active() + awaiting = api.otp_find_awaiting() + enforced = api.otp_find_enforced() + revoked = api.otp_find_revoked() + except Exception as exc: + logger.exception("Failed to fetch OTP session lists: %s", exc) + # Present empty + active = awaiting = enforced = revoked = pd.DataFrame() + + # Ensure DataFrame objects + def _ensure_df(df): + return df if isinstance(df, pd.DataFrame) else pd.DataFrame(df) + + active = _ensure_df(active) + awaiting = _ensure_df(awaiting) + enforced = _ensure_df(enforced) + revoked = _ensure_df(revoked) + for df, status in [ + (active, "active"), + (awaiting, "awaiting"), + (enforced, "enforced"), + (revoked, "revoked"), + ]: + if "status" not in df.columns: + df["status"] = status + combined = pd.concat([active, awaiting, enforced, revoked], ignore_index=True) + if "otpid" in combined.columns: + combined = combined.sort_values(by="otpid", ascending=False) + self._sessions_df = combined + # Populate DataTable + self.sessions_table.clear() + # Ensure columns exist in DF and when missing add empty column + expected_cols = ["otpid", "hostname", "status", "purpose", "granted"] + for col in expected_cols: + if col not in combined.columns: + combined[col] = "" + self.sessions_table.add_columns(*expected_cols) + # Add rows + for _, row in combined[expected_cols].iterrows(): + # Convert values to str for safe insertion + vals = ["" if pd.isna(v) else v for v in row.to_list()] + self.sessions_table.add_row(*[str(v) for v in vals]) + logger.info("Loaded %d OTP sessions.", len(combined)) + + async def _fetch_activities_for_otpid(self, api, otpid, hostname=None) -> None: + """ + Fetch activities DataFrame for a given otpid and populate activities_table. + """ + try: + result = api.otp_get_activities(otpid) + result_df = ( + result if isinstance(result, pd.DataFrame) else pd.DataFrame(result) + ) + except Exception as exc: + logger.exception("Failed to fetch activities for otpid %s: %s", otpid, exc) + result_df = pd.DataFrame() + # Attach hostname if provided + if hostname is not None: + result_df["hostname"] = hostname + if result_df.empty: + logger.info("No activities found for otpid %s (host: %s)", otpid, hostname) + self._activities_df = pd.DataFrame() + self.activities_table.clear() + return + # Store and render + self._activities_df = result_df.copy() + # Rebuild activities_table columns from result_df + self.activities_table.clear() + # Ensure stable column order + for col in result_df.columns: + self.activities_table.add_column(col) + # Add rows + for _, arow in result_df.iterrows(): + values = ["" if pd.isna(v) else v for v in arow.to_list()] + self.activities_table.add_row(*[str(v) for v in values]) + logger.info( + "Loaded %d activity rows for otpid %s (host: %s)", + len(result_df), + otpid, + hostname, + ) + + async def export_activities(self) -> None: + """ + Export currently-loaded activities DataFrame to CSV. + Can be called directly (programmatically) or from the button handler. + """ + if self._activities_df is None or self._activities_df.empty: + logger.info("No activities loaded to export.") + # On-screen short message + await self.post_message(Static("No activities to export.")) + return + working_dir = _load_working_dir() + timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + filename = f"otp_activities_{self._selected_session_otpid}_{timestamp}.csv" + file_path = os.path.join(working_dir, filename) + try: + self._activities_df.to_csv(file_path, index=False) + logger.info("Exported activities to %s", file_path) + await self.post_message(Static(f"✅ Exported activities to: {file_path}")) + except Exception as exc: + logger.exception("Failed to export activities to %s: %s", file_path, exc) + await self.post_message(Static("Failed to export activities; check logs.")) + + +class ActivityDetailWidget(Static): + """ + Interactive widget for Activity Detail screen. + Shows the provided DataFrame in a DataTable and offers Export + Back buttons. + Now includes Select All/None and Add to Allowlist functionality. + """ + + DEFAULT_CSS = """ + ActivityDetailWidget { + height: 1fr; + layout: vertical; + } + #detail_table_container { + height: 1fr; + padding: 1 1; + } + #selection_buttons { + height: auto; + min-height: 3; + padding: 1 1; + content-align: center middle; + } + #detail_buttons { + height: auto; + min-height: 3; + padding: 1 1; + content-align: center middle; + } + """ + + def __init__(self, activities_df: pd.DataFrame, otpid=None, hostname=None) -> None: + super().__init__() + self.activities_df = ( + activities_df.copy() + if isinstance(activities_df, pd.DataFrame) + else pd.DataFrame(activities_df) + ) + # Add a unique identifier column if not present + if "_row_id" not in self.activities_df.columns: + self.activities_df["_row_id"] = range(len(self.activities_df)) + + self.otpid = otpid + self.hostname = hostname + self.selected_row_ids = set() # Track selected rows by unique ID + self.row_key_to_id = {} # Map DataTable row keys to unique row IDs + self.table_row_to_id = {} # Map table row indices to unique row IDs + self._last_sort = None # Track last sort column and order + + def compose(self) -> ComposeResult: + yield Static( + f"Activity Detail (otpid={self.otpid} host={self.hostname})", + classes="panel-title", + ) + # Table container + with Vertical(id="detail_table_container"): + self.detail_table = DataTable(id="detail_table") + yield self.detail_table + + # Original buttons at bottom + with Horizontal(id="detail_buttons"): + self.detail_back_btn = Button("Back", id="detail_back_btn") + self.add_allowlist_btn = Button( + "📋 Add Selected to Allowlist", id="add_allowlist_btn" + ) + yield self.add_allowlist_btn + yield self.detail_back_btn + + async def on_mount(self) -> None: + await self._build_table(rebuild=True) + self._update_button_states() + + def _update_button_states(self) -> None: + """Update button states based on selection.""" + has_selection = len(self.selected_row_ids) > 0 + self.add_allowlist_btn.disabled = not has_selection + + # Update button labels with count + count = len(self.selected_row_ids) + total = len(self.activities_df) + + if has_selection: + self.add_allowlist_btn.label = f"📋 Add {count} Selected to Allowlist" + else: + self.add_allowlist_btn.label = "📋 Add Selected to Allowlist" + + async def _build_table(self, rebuild: bool = True) -> None: + """Rebuild the DataTable. If rebuild=False, only refresh rows.""" + if rebuild: + # Full rebuild: clear columns and rows + self.detail_table.clear() + self.detail_table.columns.clear() + self.row_key_to_id.clear() + self.table_row_to_id.clear() + + if self.activities_df is None or self.activities_df.empty: + logger.info("ActivityDetailWidget mounted with empty dataframe.") + return + + # Add columns (checkbox + data columns, excluding internal _row_id) + self.detail_table.add_column("Select", key="select") + for col in self.activities_df.columns: + if col != "_row_id": # Don't display the internal ID column + self.detail_table.add_column(col) + else: + # Partial rebuild: clear rows only + self.detail_table.clear() + self.row_key_to_id.clear() + self.table_row_to_id.clear() + + # Add rows + for table_idx, (df_idx, row) in enumerate(self.activities_df.iterrows()): + # Get the unique row ID + row_id = row["_row_id"] + + # Build values list (excluding _row_id column) + vals = [] + for col in self.activities_df.columns: + if col != "_row_id": + v = row[col] + vals.append("" if pd.isna(v) else str(v)) + + # Check if this row is selected + checkbox = "☑" if row_id in self.selected_row_ids else "☐" + + # Add row to table + row_key = self.detail_table.add_row(checkbox, *vals) + + # Map the row key and table index to the unique row ID + self.row_key_to_id[row_key] = row_id + self.table_row_to_id[table_idx] = row_id + + async def on_data_table_cell_selected(self, event: DataTable.CellSelected) -> None: + # Toggle selection when the "Select" column is clicked + if event.cell_key.column_key.value == "select": + table_row_index = event.coordinate.row + + # Get the unique row ID for this table row + row_id = self.table_row_to_id.get(table_row_index) + if row_id is not None: + # Get the row key for updating the cell + row_key = event.cell_key.row_key + + if row_id in self.selected_row_ids: + self.selected_row_ids.remove(row_id) + self.detail_table.update_cell(row_key, "select", "☐") # Unchecked + else: + self.selected_row_ids.add(row_id) + self.detail_table.update_cell(row_key, "select", "☑") # Checked + + self._update_button_states() + + async def on_data_table_header_selected( + self, event: DataTable.HeaderSelected + ) -> None: + column_key = event.column_key.value if event.column_key else None + if not column_key: + col_index = event.column_index + if col_index == 0: # First column is "Select" + return + # Adjust for hidden _row_id column + visible_cols = [ + col for col in self.activities_df.columns if col != "_row_id" + ] + if col_index - 1 < len(visible_cols): + column_key = visible_cols[col_index - 1] + else: + return + if column_key == "select" or column_key == "_row_id": + return + + ascending = True + if self._last_sort == (column_key, True): + ascending = False + self._last_sort = (column_key, ascending) + + try: + self.activities_df.sort_values( + by=column_key, ascending=ascending, inplace=True + ) + except Exception as exc: + logger.exception("Failed to sort by column %s: %s", column_key, exc) + return + + # ✅ Only refresh rows, not columns + await self._build_table(rebuild=False) + + async def on_button_pressed(self, event) -> None: + btn = getattr(event, "button", None) or getattr(event, "sender", None) + btn_id = getattr(btn, "id", None) or getattr(event, "button_id", None) + + if btn is self.detail_back_btn or btn_id == "detail_back_btn": + while len(self.app.screen_stack) > 2: + self.app.pop_screen() + event.stop() + return + + if btn is self.add_allowlist_btn or btn_id == "add_allowlist_btn": + await self._open_allowlist_screen() + return + + async def _select_all(self) -> None: + """Select all rows in the table.""" + # Add all row IDs to selected set + self.selected_row_ids = set(self.activities_df["_row_id"].tolist()) + + # Update all checkboxes in the table + for row_key, row_id in self.row_key_to_id.items(): + self.detail_table.update_cell(row_key, "select", "☑") + + self._update_button_states() + logger.info(f"Selected all {len(self.selected_row_ids)} rows") + + async def _select_none(self) -> None: + """Deselect all rows in the table.""" + # Clear selected set + self.selected_row_ids.clear() + + # Update all checkboxes in the table + for row_key, row_id in self.row_key_to_id.items(): + self.detail_table.update_cell(row_key, "select", "☐") + + self._update_button_states() + logger.info("Cleared all selections") + + async def _open_allowlist_screen(self) -> None: + """Open the allowlist selection screen with selected activities.""" + if not self.selected_row_ids: + self.app.notify("No rows selected", severity="warning") + return + + # Get selected data + selected_df = self.get_selected_data() + + # Get API from app + api = getattr(self.app, "api", None) + if api is None: + logger.error("No API available on self.app.api") + self.app.notify("API not available", severity="error") + return + + # Create and push AllowlistSelectionScreen + try: + allowlist_screen = AllowlistSelectionScreen( + selected_df, api=api, hostname=self.hostname, otpid=self.otpid + ) + await self.app.push_screen(allowlist_screen) + logger.info( + f"Opened allowlist screen with {len(selected_df)} selected activities" + ) + except ImportError as e: + logger.error(f"Failed to import AllowlistSelectionScreen: {e}") + self.app.notify("Allowlist screen module not found", severity="error") + except Exception as e: + logger.exception(f"Failed to open allowlist screen: {e}") + self.app.notify( + f"Error opening allowlist screen: {str(e)}", severity="error" + ) + + async def _export_detail_activities(self) -> None: + if self.activities_df is None or self.activities_df.empty: + logger.info("No activities to export.") + await self.mount( + Static("❌ No activities to export.", classes="notification") + ) + return + if not self.selected_row_ids: + logger.info("No rows selected for export.") + await self.mount( + Static("❌ No rows selected for export.", classes="notification") + ) + return + try: + working_dir = load_env("WORKING_DIR") or os.getcwd() + timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + filename = f"otp_activities_detail_{timestamp}.csv" + file_path = os.path.join(working_dir, filename) + selected_df = self.get_selected_data() + selected_df.to_csv(file_path, index=False) + logger.info("Exported selected activities to %s", file_path) + await self.mount( + Static( + f"✅ Exported selected activities to: {filename}", + classes="notification", + ) + ) + except Exception as exc: + logger.exception("Failed to export detail activities: %s", exc) + await self.mount( + Static( + "❌ Failed to export activities; check logs.", + classes="notification", + ) + ) + + # ✅ Helper methods + def get_selected_data(self) -> pd.DataFrame: + """Return a DataFrame of the selected rows.""" + if not self.selected_row_ids: + return pd.DataFrame() + # Filter by selected row IDs and drop the internal _row_id column + selected_df = self.activities_df[ + self.activities_df["_row_id"].isin(self.selected_row_ids) + ].copy() + if "_row_id" in selected_df.columns: + selected_df = selected_df.drop(columns=["_row_id"]) + return selected_df + + def get_selected_records(self) -> list[dict]: + """Return selected rows as a list of dicts.""" + if not self.selected_row_ids: + return [] + # Filter by selected row IDs and drop the internal _row_id column + selected_df = self.activities_df[ + self.activities_df["_row_id"].isin(self.selected_row_ids) + ].copy() + if "_row_id" in selected_df.columns: + selected_df = selected_df.drop(columns=["_row_id"]) + return selected_df.to_dict(orient="records") + + +class ActivityDetailScreen(Screen): + """ + Screen that wraps ActivityDetailWidget. Expects a DataFrame passed on init. + """ + + BINDINGS = [ + Binding("b", "back", "Back"), + Binding("e", "export", "Export"), + Binding("a", "select_all", "Select All"), + Binding("n", "select_none", "Select None"), + ] + + def __init__(self, activities_df: pd.DataFrame, otpid=None, hostname=None) -> None: + super().__init__() + self._activities_df = ( + activities_df.copy() + if isinstance(activities_df, pd.DataFrame) + else pd.DataFrame(activities_df) + ) + self._otpid = otpid + self._hostname = hostname + + def compose(self) -> ComposeResult: + self.widget = ActivityDetailWidget( + self._activities_df, otpid=self._otpid, hostname=self._hostname + ) + yield Header(show_clock=True) + yield self.widget + yield Footer() + + async def action_back(self) -> None: + try: + await self.app.pop_screen() + except Exception: + logger.debug("ActivityDetailScreen.action_back pop_screen failed.") + + async def action_export(self) -> None: + # Delegate to widget export helper + if hasattr(self, "widget") and self.widget is not None: + await self.widget._export_detail_activities() + + async def action_select_all(self) -> None: + """Handle 'a' key for select all.""" + if hasattr(self, "widget") and self.widget is not None: + await self.widget._select_all() + + async def action_select_none(self) -> None: + """Handle 'n' key for select none.""" + if hasattr(self, "widget") and self.widget is not None: + await self.widget._select_none() + + +class OTPActivitiesScreen(Screen): + """ + A Screen intended to be pushed into an existing Textual App. + Usage: + app.push_screen(OTPActivitiesScreen()) + or create this screen and call `await screen.load()` inside your app lifecycle. + The screen expects `self.app.api` to exist and be an AirlockAPIWrapper instance. + """ + + BINDINGS = [ + Binding("r", "refresh_sessions", "Refresh Sessions"), + Binding("e", "export_activities", "Export activities"), + Binding("q", "quit", "Quit"), + ] + + def compose(self) -> ComposeResult: + yield Header() + self.widget = OTPActivitiesWidget() + yield self.widget + yield Footer() + + async def on_show(self) -> None: + """Restore focus to the left sessions table when the screen becomes visible.""" + if hasattr(self, "widget") and hasattr(self.widget, "sessions_table"): + self.widget.sessions_table.focus() + + async def on_mount(self) -> None: + # Try to load sessions immediately + api = getattr(self.app, "api", None) + if api is None: + logger.warning("OTPActivitiesScreen mounted but no self.app.api found.") + else: + await self.widget.load_sessions_from_api(api) + + # Simple actions bound to keys + async def action_refresh_sessions(self) -> None: + api = getattr(self.app, "api", None) + if api is None: + logger.error("No API on app; cannot refresh sessions.") + return + logger.info("Refreshing OTP sessions via API.") + await self.widget.load_sessions_from_api(api) + + async def action_quit(self) -> None: + # Pop the screen or exit app + await self.app.pop_screen() + + # If you want an explicit method to fetch activities for a particular otpid from outside: + async def fetch_activities_for_otpid(self, otpid, hostname=None) -> None: + api = getattr(self.app, "api", None) + if api is None: + logger.error("No API on app; cannot fetch activities.") + return + await self.widget._fetch_activities_for_otpid(api, otpid, hostname=hostname) diff --git a/TUI/otpworkflowscreen.py b/TUI/otpworkflowscreen.py new file mode 100644 index 0000000..d7d7322 --- /dev/null +++ b/TUI/otpworkflowscreen.py @@ -0,0 +1,24 @@ +# otp_workflow_screen.py + +from typing import List, Optional + +from textual.app import ComposeResult +from textual.screen import Screen + +from models.agent import Agent +from TUI.OTP_generate import OTPGenerator + + +class OTPWorkflowScreen(Screen): + """Screen that handles the OTP generation workflow without agent selection.""" + + def __init__(self, selected_agents: Optional[List[Agent]]): + super().__init__() + self.selected_agents = selected_agents + + def compose(self) -> ComposeResult: + """Directly show the OTP generator for the selected agents.""" + yield OTPGenerator(self.selected_agents) + + def on_otp_generator_otp_info(self, message: OTPGenerator.OTPInfo) -> None: + """Handle OTP generation request - pass it up to the app level if needed.""" diff --git a/TUI/policyselector.py b/TUI/policyselector.py new file mode 100644 index 0000000..d6e4bdf --- /dev/null +++ b/TUI/policyselector.py @@ -0,0 +1,488 @@ +""" +Policy Selector Widget Module + +Provides a Textual widget for selecting target policies for bulk agent operations. +Allows users to browse available policies and select one as the destination for +moving agents. Automatically excludes parent/logical policies. +""" + +import logging +import re +from typing import Optional + +import pandas as pd +from textual.containers import Horizontal, Vertical +from textual.message import Message +from textual.widget import Widget +from textual.widgets import Button, DataTable, Static, TextArea + +from models.policy import Policy + +logger = logging.getLogger(__name__) + + +class PolicySelector(Widget): + """ + A Textual widget for selecting a target policy for agent operations. + + This widget displays available policies in a table and allows users to select + one policy as the destination for bulk agent movements. It automatically excludes: + - Parent/logical policies (where parent == "global-policy-settings") + - Specified policy IDs (e.g., the current policy) + + Features: + - Wildcard filtering (* and ?) + - Interactive table for policy browsing + - Explicit confirm button for selection + - Cancel/back button to dismiss + + Attributes: + policies (list[Policy]): List of available Policy objects to display. + excluded_policy_ids (set[str]): Set of policy IDs to exclude from selection. + selected_policy (Optional[Policy]): The currently selected policy (if any). + + Automatically Filtered Out: + - Policies with parent == "global-policy-settings" (parent policies for organization) + - Any policies in excluded_policy_ids set + + Example: + ```python + policies = [policy1, policy2, policy3] + widget = PolicySelector(policies, excluded_policy_ids={current_policy.groupid}) + ``` + """ + + class PolicySelected(Message): + """ + Message posted when a policy is selected. + + Attributes: + policy (Policy): The selected policy object. + """ + + def __init__(self, policy: Policy): + super().__init__() + self.policy = policy + + def __init__(self, policies: list): + """ + Initialize the PolicySelector widget. + + Args: + policies (list): List of Policy objects or DataFrame rows to display. + Can be a list of Policy objects or a pandas DataFrame of policy data. + """ + super().__init__() + self.policies = policies + self.selected_policy: Optional[Policy] = None + self._filtered_policies = [] + self._displayed_policies = [] # Track what's currently shown in the table + self._filter_text = "" + + def compose(self): + """ + Build the UI layout for the PolicySelector widget. + + The layout includes: + - Title indicating policy selection + - Search/filter text area with wildcard support + - Filter help text showing wildcard options + - Apply Filter button + - Clear Filter button + - Confirm Selection button + - Policy table displaying available policies + - Back buttons for navigation + """ + title_text = Static( + "🎯 Select Target Policy", + id="policy_selector_title", + ) + title_text.styles.margin = (0, 0, 1, 0) + yield title_text + + with Horizontal() as main_layout: + main_layout.styles.height = "auto" + + # Left side - Filter and controls + with Vertical() as left_side: + left_side.styles.width = "1fr" + left_side.styles.height = "auto" + left_side.styles.margin = (0, 1, 0, 1) + + filter_label = Static("Filter Policies:") + filter_label.styles.margin = (0, 0, 0, 0) + yield filter_label + + filter_input = TextArea( + id="policy_filter", + text="", + ) + filter_input.styles.height = 3 + filter_input.styles.margin = (0, 0, 1, 0) + yield filter_input + + filter_help = Static("(Use * and ? for wildcards)", id="filter_help") + filter_help.styles.margin = (0, 0, 1, 0) + yield filter_help + + apply_button = Button("✓ Apply Filter", id="filter_button") + apply_button.styles.width = "100%" + apply_button.styles.margin = (0, 0, 1, 0) + yield apply_button + + clear_button = Button("Clear Filter", id="clear_filter_button") + clear_button.styles.width = "100%" + clear_button.styles.margin = (0, 0, 1, 0) + yield clear_button + + confirm_button = Button("✅ Confirm Selection", id="confirm_button") + confirm_button.styles.width = "100%" + confirm_button.styles.margin = (1, 0, 1, 0) + yield confirm_button + + selected_label = Static("", id="selected_policy_label") + selected_label.styles.margin = (2, 0, 1, 0) + yield selected_label + + cancel_button = Button("← Back", id="back_button") + cancel_button.styles.width = "100%" + cancel_button.styles.margin = (1, 0, 1, 0) + yield cancel_button + + # Right side - Policy table + with Vertical() as right_side: + right_side.styles.width = "2fr" + right_side.styles.height = "auto" + + table_label = Static("Available Policies:") + table_label.styles.margin = (0, 0, 0, 0) + yield table_label + + policy_table = DataTable(id="policy_table", cursor_type="row") + policy_table.styles.height = "1fr" + policy_table.styles.margin = (1, 0, 1, 0) + yield policy_table + + def on_mount(self) -> None: + """ + Initialize the policy table when the widget is mounted. + + Populates the table with column (Policy Name) and rows for each + available policy (excluding those in excluded_policy_ids and parent policies). + Sets up event handlers for table row selection. + + Filters out: + - Parent policies (where parent == "global-policy-settings") + """ + table = self.query_one("#policy_table", DataTable) + + # Configure table for row selection + table.cursor_type = "row" + table.zebra_stripes = True + + # Only add Policy Name column + table.add_columns("Policy Name") + + # Filter out excluded policies and convert to list if DataFrame + if isinstance(self.policies, pd.DataFrame): + policies_list = self.policies.to_dict("records") + else: + policies_list = self.policies + policies_list = sorted(policies_list) + + self._filtered_policies = [] + self._displayed_policies = [] # Initialize displayed list + + for policy_data in policies_list: + # Handle both Policy objects and dict/DataFrame rows + if isinstance(policy_data, Policy): + policy_id = policy_data.groupid + policy_name = policy_data.name + parent = policy_data.parent + else: + policy_id = policy_data.get("groupid", "Unknown") + policy_name = policy_data.get("name", "Unknown") + parent = policy_data.get("parent", None) + + # Skip parent policies (logical policies that shouldn't have devices) + if parent == "global-policy-settings": + logger.debug(f"Skipping parent policy: {policy_name}") + continue + + self._filtered_policies.append(policy_data) + self._displayed_policies.append(policy_data) # Add to displayed list + + table.add_row( + policy_name, + key=policy_id, + ) + + def on_button_pressed(self, event: Button.Pressed): + """ + Handle button press events from the widget. + + Routes to: + - back_button (Cancel): Pop screen without selecting + - filter_button (Apply Filter): Filter policies with wildcard support + - clear_filter_button: Clear filter and show all policies + - confirm_button: Confirm selection and post message + + Args: + event (Button.Pressed): The button press event. + """ + btn_id = event.button.id + + if btn_id == "back_button": + while len(self.app.screen_stack) > 2: + self.app.pop_screen() + event.stop() + + elif btn_id == "filter_button": + self._apply_filter() + event.stop() + + elif btn_id == "clear_filter_button": + self._clear_filter() + event.stop() + + elif btn_id == "confirm_button": + self._confirm_selection() + event.stop() + + def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: + """ + Handle row selection in the policy table. + + Updates the selected_policy and displays the selection in the UI. + + Args: + event: DataTable.RowSelected event containing the selected row data. + """ + try: + # Get the row key from the event + row_key = event.row_key + if row_key is None: + return + + # Find the policy with matching groupid + for policy_data in self._displayed_policies: + if isinstance(policy_data, Policy): + if policy_data.groupid == row_key.value: + self.selected_policy = policy_data + break + else: + if policy_data.get("groupid") == row_key.value: + self.selected_policy = Policy( + groupid=policy_data.get("groupid"), + hidden=policy_data.get("hidden", False), + name=policy_data.get("name"), + parent=policy_data.get("parent"), + ) + break + + if self.selected_policy: + # Update selection display + label = self.query_one("#selected_policy_label", Static) + label.update(f"✓ Selected: {self.selected_policy.name}") + + # Log for debugging + logger.debug( + f"Selected policy: {self.selected_policy.name} (ID: {self.selected_policy.groupid})" + ) + self.app.notify( + f"Selected: {self.selected_policy.name}", + severity="information", + timeout=1, + ) + + except Exception as e: + logger.error(f"Error handling row selection: {e}") + self.app.notify(f"Selection error: {str(e)}", severity="error") + + def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None: + """ + Handle row highlighting (cursor movement) in the table. + + This provides immediate visual feedback when navigating rows. + """ + try: + # Get the row key from the event + row_key = event.row_key + if row_key is None: + return + + # Find the highlighted policy + highlighted_name = None + for policy_data in self._displayed_policies: + if isinstance(policy_data, Policy): + if policy_data.groupid == row_key.value: + highlighted_name = policy_data.name + break + else: + if policy_data.get("groupid") == row_key.value: + highlighted_name = policy_data.get("name") + break + + if highlighted_name: + label = self.query_one("#selected_policy_label", Static) + label.update(f"→ Highlighting: {highlighted_name}") + + except Exception as e: + logger.error(f"Error handling row highlight: {e}") + + def _apply_filter(self) -> None: + """ + Apply filter text to policy list with wildcard support. + + Supports wildcards: + - * matches any sequence of characters + - ? matches a single character + + Examples: + - "policy*" matches "policy_prod", "policy_dev", etc. + - "policy?" matches "policy1", "policy2", etc. + - "*audit*" matches anything containing "audit" + - "*test*" matches "AT Testing", "test_policy", etc. + + Filters policies by name or ID (case-insensitive) and refreshes the table display + with only matching policies. Only filters from already-filtered list + (which excludes parent policies and excluded IDs). + """ + try: + filter_input = self.query_one("#policy_filter", TextArea) + filter_text = filter_input.text.strip() + + table = self.query_one("#policy_table", DataTable) + table.clear() + + # Clear the displayed policies list + self._displayed_policies = [] + + # Compile wildcard pattern if filter text is provided + pattern = None + if filter_text: + # Escape special regex chars but preserve wildcards + pattern_text = re.escape(filter_text.lower()) + pattern_text = pattern_text.replace(r"\*", ".*").replace(r"\?", ".") + # Use search() for partial matching + pattern = re.compile(pattern_text, re.IGNORECASE) + + # Filter policies based on search text + for policy_data in self._filtered_policies: + # Handle both Policy objects and dict/DataFrame rows + if isinstance(policy_data, Policy): + policy_name = policy_data.name.lower() + policy_id = policy_data.groupid.lower() + display_name = policy_data.name + key_id = policy_data.groupid + else: + policy_name = str(policy_data.get("name", "")).lower() + policy_id = str(policy_data.get("groupid", "Unknown")).lower() + display_name = policy_data.get("name") + key_id = policy_data.get("groupid") + + # Match against filter text with wildcard support + if pattern: + # Use search() for partial matching + matches = pattern.search(policy_name) or pattern.search(policy_id) + else: + matches = True + + if matches: + # Add to displayed policies list + self._displayed_policies.append(policy_data) + + # Add row to table + table.add_row( + display_name, + key=key_id, + ) + + displayed_count = len(self._displayed_policies) + status_text = f"📊 Showing {displayed_count} of {len(self._filtered_policies)} policies" + self.app.notify(status_text, severity="information", timeout=2) + + # Clear selection when filter is applied + self.selected_policy = None + label = self.query_one("#selected_policy_label", Static) + label.update("") + + except Exception as e: + logger.error(f"Error applying filter: {e}") + self.app.notify(f"❌ Filter error: {str(e)}", severity="error") + + def _clear_filter(self) -> None: + """ + Clear the filter and display all available policies. + + Resets the filter text and refreshes the table to show all policies + (already excluding parent policies and excluded IDs). + """ + try: + filter_input = self.query_one("#policy_filter", TextArea) + filter_input.text = "" + + table = self.query_one("#policy_table", DataTable) + table.clear() + + # Reset displayed policies to all filtered policies + self._displayed_policies = list(self._filtered_policies) + + # Reload all policies + for policy_data in self._filtered_policies: + if isinstance(policy_data, Policy): + policy_id = policy_data.groupid + policy_name = policy_data.name + else: + policy_id = policy_data.get("groupid", "Unknown") + policy_name = policy_data.get("name", "Unknown") + + # Add row with only policy name + table.add_row( + policy_name, + key=policy_id, + ) + + self.selected_policy = None + label = self.query_one("#selected_policy_label", Static) + label.update("") + + except Exception as e: + logger.error(f"Error clearing filter: {e}") + + def on_text_area_changed(self, event) -> None: + """ + Handle TextArea change events - specifically for Enter key in filter. + + When the user types in the filter TextArea and the text ends with a newline, + treat it as pressing Enter and apply the filter. + """ + if event.text_area.id == "policy_filter": + # Check if the text ends with a newline (Enter was pressed) + if event.text_area.text.endswith("\n"): + # Remove the newline that was added + event.text_area.text = event.text_area.text.rstrip("\n") + # Apply the filter + self._apply_filter() + + def _confirm_selection(self) -> None: + """ + Confirm the selected policy and post selection message. + + Posts a PolicySelected message to the parent widget/screen with the + selected policy. If no policy is selected, displays an error notification. + """ + if self.selected_policy is None: + self.app.notify( + "⚠️ Please select a policy first by clicking on a row in the table", + severity="warning", + timeout=3, + ) + return + + # Log confirmation for debugging + logger.info(f"Confirming selection of policy: {self.selected_policy.name}") + self.app.notify( + f"✅ Confirmed: {self.selected_policy.name}", severity="success", timeout=2 + ) + self.post_message(self.PolicySelected(self.selected_policy)) diff --git a/TUI/policyselectorscreen.py b/TUI/policyselectorscreen.py new file mode 100644 index 0000000..07ec2ee --- /dev/null +++ b/TUI/policyselectorscreen.py @@ -0,0 +1,91 @@ +# Copyright (C) 2025 James Brotosky, Brandon Wickline +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +""" +Policy Selector Screen Module + +Provides a Textual Screen wrapper for the PolicySelector widget that manages +the policy selection workflow. +""" + +import logging + +from textual.app import ComposeResult +from textual.screen import Screen + +from TUI.policyselector import PolicySelector + +logger = logging.getLogger(__name__) + + +class PolicySelectorScreen(Screen): + """ + A Textual Screen for policy selection in agent move operations. + + This screen wraps the PolicySelector widget and manages the workflow + of selecting a target policy for bulk agent movements. + + Attributes: + policies: List of available policies (Policy objects or DataFrame). + agent_move_operations: Reference to the parent AgentMoveOperations widget. + """ + + CSS = """ + Screen { + layout: vertical; + background: $surface; + } + """ + + def __init__( + self, + policies, + agent_move_operations=None, + ): + """ + Initialize the PolicySelectorScreen. + + Args: + policies: List of available policies to display. + agent_move_operations: Reference to parent AgentMoveOperations widget. + Used to call back when policy selection is confirmed. + """ + super().__init__() + self.policies = policies + self.agent_move_operations = agent_move_operations + + def compose(self) -> ComposeResult: + """Create the PolicySelector widget.""" + yield PolicySelector(self.policies) + + def on_policy_selector_policy_selected( + self, message: PolicySelector.PolicySelected + ) -> None: + """ + Handle policy selection from the PolicySelector widget. + + When a policy is selected, this handler: + 1. Closes the selector screen + 2. Calls the parent AgentMoveOperations to execute the move + + Args: + message (PolicySelector.PolicySelected): Contains the selected policy. + """ + # Pop this screen to return to AgentMoveOperations + self.app.pop_screen() + + # Call parent widget's method to execute the move + if self.agent_move_operations: + self.agent_move_operations._execute_move_to_policy(message.policy) diff --git a/widgets/policytreewidget.py b/TUI/policytreewidget.py similarity index 53% rename from widgets/policytreewidget.py rename to TUI/policytreewidget.py index 3a0efff..5e01fdf 100644 --- a/widgets/policytreewidget.py +++ b/TUI/policytreewidget.py @@ -1,9 +1,10 @@ +from collections import defaultdict import logging from rich.text import Text from textual.containers import Horizontal, Vertical from textual.widget import Widget -from textual.widgets import Input, OptionList, Static, Tree +from textual.widgets import Input, OptionList, Static, Switch, Tree from textual.widgets.option_list import Option logger = logging.getLogger(__name__) @@ -17,67 +18,145 @@ class PolicyTreeWidget(Widget): self.policies = policies self.devices = devices self.last_highlighted_node = None + self.leaf_counts = defaultdict(int) + self.match_type = "Count" # Default to sorting by count def compose(self): - # Left: Policy Tree - policy_tree = Tree("Policies", id="policy_tree") + # Create the switch and its label + switch = Switch(value=False, id="match_switch") + switch.styles.margin = (0, 0, 0, 0) # top, right, bottom, left + switch.styles.padding = (0, 0, 0, 0) + + switch_label = Static("Sort: Count", id="match_switch_label") + switch_label.styles.margin = (1, 0, 0, 0) + switch_label.styles.padding = (0, 0, 0, 0) + + # Create the tree + policy_tree = Tree("", id="policy_tree") # Label set in on_mount policy_tree.styles.width = "2fr" policy_tree.styles.height = "100%" - # Right: Search + Details + # Create the search box and details pane label = Static("Device Search:") search_box = Input( placeholder="Search policies or devices...", id="tree_search" ) details_pane = Static("", id="details_pane") + # Layout the UI with Horizontal(): yield policy_tree with Vertical() as right_pane: right_pane.styles.width = "3fr" + # Use a Horizontal container for the switch and label + with Horizontal() as switch_container: + switch_container.styles.height = 3 + switch_container.styles.margin = (0, 0, 0, 1) + switch_container.styles.padding = (0, 0, 0, 0) + yield switch + yield switch_label + # Add the search box and details pane yield label yield search_box yield details_pane def on_mount(self) -> None: - """Build the tree after mounting.""" - self._build_tree() - - def _build_tree(self) -> None: - """Build the policy tree structure.""" + self._precompute_leaf_counts() + # Update root label with total leaf count + total_leaves = sum( + self.leaf_counts.get(policy.groupid, 0) + for policy in self.policies + if policy.parent == "global-policy-settings" + ) policy_tree = self.query_one("#policy_tree", Tree) - node_map = {} + policy_tree.root.set_label(f"Agents in Policies: ({total_leaves})") + self._build_tree() + # Expand the root node + policy_tree.root.expand() + + def _precompute_leaf_counts(self): + """Precompute leaf counts for each policy group.""" + device_counts = defaultdict(int) + for device in self.devices: + device_counts[device.groupid] += 1 + + child_map = defaultdict(list) + for policy in self.policies: + child_map[policy.parent].append(policy.groupid) + + def count_leaves(groupid): + count = device_counts[groupid] + for child_id in child_map.get(groupid, []): + count += count_leaves(child_id) + self.leaf_counts[groupid] = count + return count - # Top-level policies for policy in self.policies: if policy.parent == "global-policy-settings": - node = policy_tree.root.add(label=policy.name, data=policy) - node_map[policy.groupid] = node + count_leaves(policy.groupid) - # Child policies + def _build_tree(self): + policy_tree = self.query_one("#policy_tree", Tree) + policy_tree.clear() # Clear existing nodes + node_map = {} + + # Sort top-level policies + top_policies = [ + p for p in self.policies if p.parent == "global-policy-settings" + ] + + # Sort by count (default) or alphabetically + if getattr(self, "match_type", "Count") == "Count": + top_policies.sort( + key=lambda p: self.leaf_counts.get(p.groupid, 0), reverse=True + ) + else: # Alphabetical + top_policies.sort(key=lambda p: p.name.lower()) + + for policy in top_policies: + label = f"{policy.name} ({self.leaf_counts.get(policy.groupid, 0)})" + node = policy_tree.root.add(label=label, data=policy) + node_map[policy.groupid] = node + + # Sort and add child policies + children_by_parent = defaultdict(list) for policy in self.policies: - parent_id = policy.parent - if parent_id in node_map: - parent_node = node_map[parent_id] - node = parent_node.add(label=policy.name, data=policy) - node_map[policy.groupid] = node + if policy.parent != "global-policy-settings": + children_by_parent[policy.parent].append(policy) - # Devices under policies + for parent_id, children in children_by_parent.items(): + if getattr(self, "match_type", "Count") == "Count": + children.sort( + key=lambda p: self.leaf_counts.get(p.groupid, 0), reverse=True + ) + else: # Alphabetical + children.sort(key=lambda p: p.name.lower()) + + parent_node = node_map.get(parent_id) + if parent_node: + for policy in children: + label = f"{policy.name} ({self.leaf_counts.get(policy.groupid, 0)})" + node = parent_node.add(label=label, data=policy) + node_map[policy.groupid] = node + + # Add devices (leaf nodes) - always sort alphabetically + devices_by_group = defaultdict(list) for device in self.devices: - group_id = device.groupid - if group_id in node_map: - parent_node = node_map[group_id] - label = device.hostname - parent_node.add(label=label, data=device) + devices_by_group[device.groupid].append(device) + + for group_id, devices in devices_by_group.items(): + devices.sort(key=lambda d: d.hostname.lower()) # Always sort alphabetically + parent_node = node_map.get(group_id) + if parent_node: + for device in devices: + parent_node.add(label=device.hostname, data=device) def _collect_tree_nodes(self, node, all_nodes): - """Helper to recursively collect all nodes from a tree.""" all_nodes.append(node) for child in node.children: self._collect_tree_nodes(child, all_nodes) def _remove_match_selector(self): - """Safely remove match selector widgets.""" try: existing = self.query("#match_selector") for widget in existing: @@ -87,20 +166,16 @@ class PolicyTreeWidget(Widget): logger.debug("Failed to remove match_selector: %s", exc) def on_tree_node_selected(self, message: Tree.NodeSelected) -> None: - """Handle tree node selection.""" node = message.node data = node.data details_pane = self.query_one("#details_pane", Static) - # Reset previous highlight if self.last_highlighted_node is not None: original_label = str(self.last_highlighted_node.label).strip() - # Remove any styling if isinstance(self.last_highlighted_node.label, Text): original_label = self.last_highlighted_node.label.plain self.last_highlighted_node.set_label(original_label) - # Apply highlight to current node label_text = str(node.label).strip() if isinstance(node.label, Text): label_text = node.label.plain @@ -108,9 +183,7 @@ class PolicyTreeWidget(Widget): node.set_label(highlighted_label) self.last_highlighted_node = node - # Update details pane if data: - # Work with dataclass objects using __dict__ details = "\n".join( f"{key}: {value}" for key, value in data.__dict__.items() ) @@ -118,12 +191,16 @@ class PolicyTreeWidget(Widget): details = f"Selected: {node.label}" details_pane.update(details) - # Stop event from bubbling message.stop() + def on_switch_changed(self, event: Switch.Changed): + self.match_type = "Alpha" if event.value else "Count" + self.query_one("#match_switch_label", Static).update( + f"Sort: {self.match_type.capitalize()}" + ) + self._build_tree() + def on_input_submitted(self, message: Input.Submitted) -> None: - """Handle search input submission.""" - # Remove existing match selector FIRST self._remove_match_selector() query = message.value.strip().lower() @@ -138,7 +215,6 @@ class PolicyTreeWidget(Widget): label_text = str(node.label).lower() label_to_node[label_text] = node if node.data: - # Use __dict__ for dataclass objects data_dict = ( node.data.__dict__ if hasattr(node.data, "__dict__") else node.data ) @@ -146,18 +222,15 @@ class PolicyTreeWidget(Widget): if isinstance(value, str): label_to_node[value.lower()] = node - # Wildcard-style substring match matches = sorted([label for label in label_to_node if query in label]) if matches: - # Try to reuse existing match_selector or create new one try: option_list = self.query_one("#match_selector", OptionList) option_list.clear_options() - option_list.display = True # Ensure it's visible + option_list.display = True except: option_list = OptionList(id="match_selector") - # Mount to the details pane's parent (the Vertical container) details_pane.parent.mount(option_list) for label in matches: @@ -165,17 +238,14 @@ class PolicyTreeWidget(Widget): details_pane.update(f"Found {len(matches)} matches. Select one below.") else: - # Hide or remove the match_selector when no matches self._remove_match_selector() details_pane.update("No matches found.") def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: - """Handle selection from search results.""" selected_id = event.option.id.replace("match_", "") tree = self.query_one("#policy_tree", Tree) details_pane = self.query_one("#details_pane", Static) - # Find the node all_nodes = [] self._collect_tree_nodes(tree.root, all_nodes) @@ -183,7 +253,6 @@ class PolicyTreeWidget(Widget): match_node = label_to_node.get(selected_id.lower()) if match_node: - # Expand path (original working logic) node = match_node path = [] while node: @@ -198,7 +267,6 @@ class PolicyTreeWidget(Widget): match_node.set_label(Text(str(match_node.label), style="reverse bold")) details_pane.update(f"Selected: {match_node.label}") - # Remove the match_selector after selection try: option_list = self.query_one("#match_selector", OptionList) option_list.remove() diff --git a/TUI/quietagentworkflowscreen.py b/TUI/quietagentworkflowscreen.py new file mode 100644 index 0000000..552f214 --- /dev/null +++ b/TUI/quietagentworkflowscreen.py @@ -0,0 +1,848 @@ +# Copyright (C) 2025 James Brotosky, Brandon Wickline +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +""" +Quiet Agent Workflow Screen Module + +Provides a TUI workflow for identifying quiet agents and moving them to target policies. +This screen replaces the legacy quietAgent.py with a comprehensive TUI interface that: +1. Allows selection of an initial policy to analyze +2. Categorizes devices into "Enforce Ready" and "Non-Enforce Ready" based on activity +3. Allows users to select target policies for each category +4. Uses the API to move devices to their target policies +""" + +import datetime +import logging +import os +from typing import List, Optional + +import pandas as pd +from textual.app import ComposeResult +from textual.containers import Horizontal, Vertical +from textual.reactive import reactive +from textual.screen import Screen +from textual.widgets import Button, DataTable, Footer, Header, Static + +from models.policy import Policy +from services.API import AirlockAPIWrapper +from services.policyhandler import getPolicyInfo +from TUI.policyselector import PolicySelector +from utils.configmanager import load_env + +logger = logging.getLogger(__name__) + + +class QuietAgentWorkflowScreen(Screen): + """ + A Textual screen for the Quiet Agent analysis and migration workflow. + + This screen provides a multi-step workflow: + 1. Select initial policy to analyze + 2. View categorized agents (enforce ready vs. non-enforce ready) + 3. Select target policies for each category + 4. Execute agent migrations + + Attributes: + api (AirlockAPIWrapper): API wrapper for Airlock operations + policies (List[Policy]): List of all available policies + selected_policy (Optional[Policy]): The initially selected policy to analyze + history_days (int): Number of days of history to pull (default: 150) + quiet_days (int): Number of days without execution to be considered quiet (default: 45) + agents_df (Optional[pd.DataFrame]): DataFrame of all agents with analysis results + enforce_ready_df (Optional[pd.DataFrame]): DataFrame of agents ready for enforcement + non_enforce_ready_df (Optional[pd.DataFrame]): DataFrame of agents not ready for enforcement + workflow_stage (str): Current stage of the workflow + """ + + BINDINGS = [ + ("escape", "go_back", "Back"), + ] + + workflow_stage = reactive("select_policy") # Tracks current workflow stage + + def __init__(self, api: AirlockAPIWrapper, policies: List[Policy]): + """ + Initialize the QuietAgentWorkflowScreen. + + Args: + api (AirlockAPIWrapper): API wrapper for Airlock operations + policies (List[Policy]): List of all available policies + """ + super().__init__() + self.api = api + self.policies = policies + self.selected_policy: Optional[Policy] = None + self.history_days = 150 # Fixed as per requirements + self.quiet_days = 45 # Default value + self.agents_df: Optional[pd.DataFrame] = None + self.enforce_ready_df: Optional[pd.DataFrame] = None + self.non_enforce_ready_df: Optional[pd.DataFrame] = None + self.enforce_ready_target_policy: Optional[Policy] = None + self.non_enforce_ready_target_policy: Optional[Policy] = None + + def compose(self) -> ComposeResult: + """Build the UI layout for the workflow screen.""" + # Include Header and Footer like other standalone screens + yield Header(show_clock=True, icon="⚙") + + # Title area + title = Static("🔒 Quiet Agent Workflow", id="workflow_title") + title.styles.margin = (0, 0, 0, 1) + yield title + + # Status area + status = Static("Step 1: Select Policy to Analyze", id="workflow_status") + status.styles.margin = (0, 0, 1, 1) + yield status + + # Content area - dynamically populated based on workflow stage + yield Vertical(id="content_area") + + yield Footer() + + def on_mount(self) -> None: + """Initialize the screen when mounted.""" + # Show initial policy selection + self._show_policy_selection() + + def watch_workflow_stage(self, old_value: str, new_value: str) -> None: + """React to workflow stage changes.""" + logger.debug(f"Workflow stage changed from {old_value} to {new_value}") + self._update_status_message() + + def _update_status_message(self) -> None: + """Update the status message based on current workflow stage.""" + status_widget = self.query_one("#workflow_status", Static) + + stage_messages = { + "select_policy": "Step 1: Select Policy to Analyze", + "select_quiet_days": "Step 2: Select Quiet Time Period", + "analyzing": "📊 Analyzing agent activity...", + "view_results": "Step 3: Review Categorized Agents", + "select_enforce_target": "Step 4: Select Target Policy for Enforce Ready Agents", + "select_non_enforce_target": "Step 5: Select Target Policy for Non-Enforce Ready Agents", + "confirm_migration": "Step 6: Confirm and Execute Migration", + "executing": "⏳ Executing agent migrations...", + "complete": "✅ Migration Complete", + } + + status_widget.update(stage_messages.get(self.workflow_stage, "Unknown Stage")) + + def _show_policy_selection(self) -> None: + """Show the initial policy selection screen.""" + self.workflow_stage = "select_policy" + content = self.query_one("#content_area", Vertical) + content.remove_children() + + # Create policy selector widget + policy_selector = PolicySelector(self.policies) + content.mount(policy_selector) + + def on_policy_selector_policy_selected( + self, message: PolicySelector.PolicySelected + ) -> None: + """Handle policy selection from PolicySelector widget.""" + # Handle based on current workflow stage + if self.workflow_stage == "select_policy": + # Initial policy selection for analysis + self.selected_policy = message.policy + logger.info(f"Selected policy for analysis: {self.selected_policy.name}") + self._show_quiet_days_selection() + elif self.workflow_stage == "select_enforce_target": + # Target policy selection for enforce ready agents + self.enforce_ready_target_policy = message.policy + logger.info( + f"Selected target policy for enforce ready: {message.policy.name}" + ) + self._show_non_enforce_target_selection() + elif self.workflow_stage == "select_non_enforce_target": + # Target policy selection for non-enforce ready agents + self.non_enforce_ready_target_policy = message.policy + logger.info( + f"Selected target policy for non-enforce ready: {message.policy.name}" + ) + self._show_migration_confirmation() + + def _show_quiet_days_selection(self) -> None: + """Show the quiet days selection screen.""" + self.workflow_stage = "select_quiet_days" + content = self.query_one("#content_area", Vertical) + content.remove_children() + + # Create info text + info_widget = Static( + f"Policy Selected: {self.selected_policy.name}\n\n" + f"History Period: {self.history_days} days\n\n" + "Select quiet time period (days without untrusted execution):", + id="quiet_days_info", + ) + info_widget.styles.margin = (0, 0, 2, 0) + content.mount(info_widget) + + # Create button container and mount it first + button_container = Vertical(id="quiet_days_buttons") + button_container.styles.height = "auto" + content.mount(button_container) + + # Now add buttons to the mounted container + for days in [15, 30, 45, 60]: + btn = Button( + f"{days} days {'(Default)' if days == 45 else ''}", + id=f"quiet_days_{days}", + classes="quiet_day_btn", + ) + btn.styles.width = "100%" + btn.styles.margin = (0, 0, 1, 0) + button_container.mount(btn) + + back_btn = Button("← Back", id="back_to_policy_selection") + back_btn.styles.width = "100%" + back_btn.styles.margin = (2, 0, 0, 0) + button_container.mount(back_btn) + + def on_button_pressed(self, event: Button.Pressed) -> None: + """Handle button press events.""" + button_id = event.button.id + + # Quiet days selection buttons + if button_id and button_id.startswith("quiet_days_"): + days = int(button_id.split("_")[-1]) + self.quiet_days = days + logger.info(f"Selected quiet days: {days}") + self._start_analysis() + return + + # Navigation buttons + if button_id == "back_to_policy_selection": + self._show_policy_selection() + return + + if button_id == "back_to_results": + self._show_results() + return + + if button_id == "select_enforce_target_btn": + self._show_enforce_target_selection() + return + + if button_id == "select_non_enforce_target_btn": + self._show_non_enforce_target_selection() + return + + if button_id == "skip_enforce_target_btn": + # Skip enforce ready target selection + self.enforce_ready_target_policy = None + self._show_non_enforce_target_selection() + return + + if button_id == "skip_non_enforce_target_btn": + # Skip non-enforce ready target selection + self.non_enforce_ready_target_policy = None + self._show_migration_confirmation() + return + + if button_id == "confirm_migration_btn": + self._execute_migration() + return + + if button_id == "cancel_migration_btn": + self._show_results() + return + + if button_id == "export_results_btn": + self._export_results() + return + + if button_id == "start_over_btn": + self._show_policy_selection() + return + + def _start_analysis(self) -> None: + """Start the agent activity analysis.""" + self.workflow_stage = "analyzing" + content = self.query_one("#content_area", Vertical) + content.remove_children() + + # Show analyzing message with detailed steps + analyzing_msg = Static( + f"📊 Analyzing Agent Activity\n" + f"{'=' * 50}\n\n" + f"Policy: {self.selected_policy.name}\n" + f"History Period: {self.history_days} days\n" + f"Quiet Threshold: {self.quiet_days} days\n\n" + f"Progress:\n" + f"⏳ Step 1/4: Fetching agents from policy...\n" + f"⏱️ Step 2/4: Pulling execution history (this may take a moment)...\n" + f"⏱️ Step 3/4: Analyzing activity patterns...\n" + f"⏱️ Step 4/4: Categorizing agents...\n\n" + f"Please wait - this operation cannot be cancelled.", + id="analyzing_message", + ) + analyzing_msg.styles.margin = (2, 1) + content.mount(analyzing_msg) + + # Show notification + self.app.notify( + "Starting analysis - this may take several minutes for large policies", + severity="information", + timeout=5, + ) + + # Perform the analysis asynchronously + self.call_later(self._perform_analysis) + + def _perform_analysis(self) -> None: + """Perform the actual agent activity analysis.""" + try: + # Update status: Fetching agents + self._update_analysis_status("Step 1/4: Fetching agents from policy...") + + # Get agents in the selected policy + agents = self.api.agents_find_by_group(self.selected_policy.groupid) + + if agents.empty: + self.app.notify( + f"No agents found in policy: {self.selected_policy.name}", + severity="warning", + timeout=5, + ) + self._show_policy_selection() + return + + agent_count = len(agents) + self.app.notify( + f"Found {agent_count} agents - fetching execution history...", + severity="information", + timeout=3, + ) + + # Update status: Pulling execution history + self._update_analysis_status( + f"Step 2/4: Pulling execution history for {agent_count} agents...\n" + f"(This may take several minutes - progress shown in terminal)" + ) + + # Get execution history (this shows progress bars in terminal via airlock_libs) + policy_exec_history = getPolicyInfo( + self.api, self.selected_policy, [1, 2, 6, 7], self.history_days + ) + + # Update status: Analyzing patterns + self._update_analysis_status("Step 3/4: Analyzing activity patterns...") + self.app.notify( + "History retrieved - analyzing patterns...", + severity="information", + timeout=2, + ) + + if policy_exec_history.empty: + logger.info( + "No execution history found for the selected policy and time range." + ) + # All agents are quiet (no executions) + agents["execution_count"] = 0 + agents["days_since"] = None + agents["required_quiet"] = self.quiet_days + agents["enforce_ready"] = True + else: + # Convert datetime column + policy_exec_history["datetime"] = pd.to_datetime( + policy_exec_history["datetime"], + format="%Y-%m-%dT%H:%M:%SZ", + utc=True, + ) + + # Calculate days ago + now = datetime.datetime.now(datetime.timezone.utc) + policy_exec_history["days_ago"] = policy_exec_history["datetime"].apply( + lambda dt: (now - dt).days + ) + + # Count total executions per hostname + hostname_counts = policy_exec_history["hostname"].value_counts() + agents["execution_count"] = ( + agents["hostname"].map(hostname_counts).fillna(0).astype(int) + ) + + # Find most recent execution per hostname + most_recent_exec = policy_exec_history.sort_values( + by="days_ago" + ).drop_duplicates(subset="hostname", keep="first") + + # Map most recent execution age to agents + agents["days_since"] = agents["hostname"].map( + most_recent_exec.set_index("hostname")["days_ago"] + ) + + # Check for enforcement readiness + agents["required_quiet"] = self.quiet_days + agents["enforce_ready"] = agents["days_since"].apply( + lambda x: True if pd.isna(x) or x > self.quiet_days else False + ) + + # Update status: Categorizing + self._update_analysis_status("Step 4/4: Categorizing agents...") + + # Sort agents + agents = agents.sort_values( + by=["execution_count", "hostname"], ascending=[True, True] + ) + + # Store the results + self.agents_df = agents + + # Categorize agents into DataFrames + self.enforce_ready_df = agents[agents["enforce_ready"] == True].copy() + self.non_enforce_ready_df = agents[agents["enforce_ready"] == False].copy() + + logger.info( + f"Analysis complete: {len(self.enforce_ready_df)} enforce ready, " + f"{len(self.non_enforce_ready_df)} non-enforce ready" + ) + + self.app.notify( + f"Analysis complete! Found {len(self.enforce_ready_df)} enforce ready, " + f"{len(self.non_enforce_ready_df)} not ready", + severity="success", + timeout=5, + ) + + # Show results + self._show_results() + + except Exception as e: + logger.error(f"Error during analysis: {e}", exc_info=True) + self.app.notify(f"Analysis failed: {str(e)}", severity="error", timeout=5) + self._show_policy_selection() + + def _update_analysis_status(self, status_text: str) -> None: + """Update the analysis status message.""" + try: + analyzing_msg = self.query_one("#analyzing_message", Static) + + # Build updated message + updated_text = ( + f"📊 Analyzing Agent Activity\n" + f"{'=' * 50}\n\n" + f"Policy: {self.selected_policy.name}\n" + f"History Period: {self.history_days} days\n" + f"Quiet Threshold: {self.quiet_days} days\n\n" + f"Progress:\n" + f"✅ {status_text}\n\n" + f"Please wait - this operation cannot be cancelled." + ) + + analyzing_msg.update(updated_text) + except Exception as e: + logger.debug(f"Could not update analysis status: {e}") + + def _show_results(self) -> None: + """Show the categorized results.""" + self.workflow_stage = "view_results" + content = self.query_one("#content_area", Vertical) + content.remove_children() + + # Create results display container and mount it first + results_container = Vertical(id="results_container") + results_container.styles.height = "auto" + results_container.styles.margin = (1, 1) + content.mount(results_container) + + # Summary statistics + total_agents = len(self.enforce_ready_df) + len(self.non_enforce_ready_df) + ready_count = len(self.enforce_ready_df) + not_ready_count = len(self.non_enforce_ready_df) + ready_percentage = (ready_count / total_agents * 100) if total_agents > 0 else 0 + + summary = Static( + f"Analysis Results for: {self.selected_policy.name}\n\n" + f"📊 Total Agents: {total_agents}\n" + f"✅ Enforce Ready: {ready_count} ({ready_percentage:.1f}%)\n" + f"❌ Not Ready: {not_ready_count} ({100 - ready_percentage:.1f}%)\n\n" + f"Quiet Threshold: {self.quiet_days} days\n" + f"History Period: {self.history_days} days", + id="results_summary", + ) + summary.styles.margin = (0, 0, 2, 0) + results_container.mount(summary) + + # Action buttons + button_container = Horizontal(id="results_buttons") + button_container.styles.height = "auto" + results_container.mount(button_container) + + if ready_count > 0: + enforce_btn = Button( + f"Select Target for Enforce Ready ({ready_count})", + id="select_enforce_target_btn", + ) + enforce_btn.styles.margin = (0, 1, 1, 0) + button_container.mount(enforce_btn) + + if not_ready_count > 0: + non_enforce_btn = Button( + f"Select Target for Non-Enforce Ready ({not_ready_count})", + id="select_non_enforce_target_btn", + ) + non_enforce_btn.styles.margin = (0, 1, 1, 0) + button_container.mount(non_enforce_btn) + + export_btn = Button("💾 Export Results", id="export_results_btn") + export_btn.styles.margin = (0, 1, 1, 0) + button_container.mount(export_btn) + + start_over_btn = Button("🔄 Start Over", id="start_over_btn") + start_over_btn.styles.margin = (0, 0, 1, 0) + button_container.mount(start_over_btn) + + # Tables showing agents + tables_container = Horizontal() + tables_container.styles.height = "1fr" + results_container.mount(tables_container) + + # Enforce Ready table + if ready_count > 0: + enforce_col = Vertical() + enforce_col.styles.width = "1fr" + enforce_col.styles.margin = (1, 1, 0, 0) + tables_container.mount(enforce_col) + + enforce_label = Static("✅ Enforce Ready Agents") + enforce_label.styles.margin = (0, 0, 1, 0) + enforce_col.mount(enforce_label) + + enforce_table = DataTable(id="enforce_ready_table") + enforce_table.styles.height = "1fr" + enforce_table.add_columns("Hostname", "Last Exec (days)") + + # Display first 50 agents + for idx, row in self.enforce_ready_df.head(50).iterrows(): + days_since = row["days_since"] + days_str = f"{int(days_since)}" if not pd.isna(days_since) else "Never" + enforce_table.add_row(row["hostname"], days_str) + + if len(self.enforce_ready_df) > 50: + enforce_table.add_row( + f"... and {len(self.enforce_ready_df) - 50} more", "" + ) + + enforce_col.mount(enforce_table) + + # Non-Enforce Ready table + if not_ready_count > 0: + non_enforce_col = Vertical() + non_enforce_col.styles.width = "1fr" + non_enforce_col.styles.margin = (1, 0, 0, 1) + tables_container.mount(non_enforce_col) + + non_enforce_label = Static("❌ Non-Enforce Ready Agents") + non_enforce_label.styles.margin = (0, 0, 1, 0) + non_enforce_col.mount(non_enforce_label) + + non_enforce_table = DataTable(id="non_enforce_ready_table") + non_enforce_table.styles.height = "1fr" + non_enforce_table.add_columns("Hostname", "Last Exec (days)") + + # Display first 50 agents + for idx, row in self.non_enforce_ready_df.head(50).iterrows(): + days_since = row["days_since"] + days_str = f"{int(days_since)}" if not pd.isna(days_since) else "N/A" + non_enforce_table.add_row(row["hostname"], days_str) + + if len(self.non_enforce_ready_df) > 50: + non_enforce_table.add_row( + f"... and {len(self.non_enforce_ready_df) - 50} more", "" + ) + + non_enforce_col.mount(non_enforce_table) + + def _show_enforce_target_selection(self) -> None: + """Show policy selection for enforce ready agents.""" + self.workflow_stage = "select_enforce_target" + content = self.query_one("#content_area", Vertical) + content.remove_children() + + # Info message + info = Static( + f"Select target policy for {len(self.enforce_ready_df)} Enforce Ready agents\n" + f"Source Policy: {self.selected_policy.name}", + id="enforce_target_info", + ) + info.styles.margin = (0, 0, 2, 0) + content.mount(info) + + # Policy selector + policy_selector = PolicySelector(self.policies) + content.mount(policy_selector) + + # Skip button + skip_btn = Button("⭕️ Skip - No Migration", id="skip_enforce_target_btn") + skip_btn.styles.width = "50%" + skip_btn.styles.margin = (2, 0, 0, 0) + content.mount(skip_btn) + + def _show_non_enforce_target_selection(self) -> None: + """Show policy selection for non-enforce ready agents.""" + self.workflow_stage = "select_non_enforce_target" + content = self.query_one("#content_area", Vertical) + content.remove_children() + + # Info message + info = Static( + f"Select target policy for {len(self.non_enforce_ready_df)} Non-Enforce Ready agents\n" + f"Source Policy: {self.selected_policy.name}", + id="non_enforce_target_info", + ) + info.styles.margin = (0, 0, 2, 0) + content.mount(info) + + # Policy selector + policy_selector = PolicySelector(self.policies) + content.mount(policy_selector) + + # Skip button + skip_btn = Button("⭕️ Skip - No Migration", id="skip_non_enforce_target_btn") + skip_btn.styles.width = "50%" + skip_btn.styles.margin = (2, 0, 0, 0) + content.mount(skip_btn) + + def _show_migration_confirmation(self) -> None: + """Show migration confirmation screen.""" + self.workflow_stage = "confirm_migration" + content = self.query_one("#content_area", Vertical) + content.remove_children() + + # Build confirmation message + confirmation_lines = [ + "🔐 Migration Summary\n", + f"Source Policy: {self.selected_policy.name}\n", + ] + + if self.enforce_ready_target_policy: + confirmation_lines.append( + f"\n✅ Enforce Ready Migration:\n" + f" • Agents: {len(self.enforce_ready_df)}\n" + f" • Target: {self.enforce_ready_target_policy.name}\n" + ) + + if self.non_enforce_ready_target_policy: + confirmation_lines.append( + f"\n❌ Non-Enforce Ready Migration:\n" + f" • Agents: {len(self.non_enforce_ready_df)}\n" + f" • Target: {self.non_enforce_ready_target_policy.name}\n" + ) + + if ( + not self.enforce_ready_target_policy + and not self.non_enforce_ready_target_policy + ): + confirmation_lines.append("\n⚠️ No migrations will be performed.") + + confirmation = Static("".join(confirmation_lines), id="migration_confirmation") + confirmation.styles.margin = (1, 1, 2, 1) + content.mount(confirmation) + + # Action buttons - mount container first, then add buttons + button_container = Horizontal(id="confirmation_buttons") + button_container.styles.height = "auto" + button_container.styles.margin = (1, 1) + content.mount(button_container) + + if self.enforce_ready_target_policy or self.non_enforce_ready_target_policy: + confirm_btn = Button("✅ Confirm Migration", id="confirm_migration_btn") + confirm_btn.styles.margin = (0, 1, 0, 0) + button_container.mount(confirm_btn) + + cancel_btn = Button("❌ Cancel", id="cancel_migration_btn") + button_container.mount(cancel_btn) + + def _execute_migration(self) -> None: + """Execute the agent migrations.""" + self.workflow_stage = "executing" + content = self.query_one("#content_area", Vertical) + content.remove_children() + + # Show executing message + executing_msg = Static( + "⏳ Executing agent migrations...\nPlease wait...", + id="executing_message", + ) + executing_msg.styles.margin = (2, 1) + content.mount(executing_msg) + + # Perform migrations asynchronously + self.call_later(self._perform_migrations) + + def _perform_migrations(self) -> None: + """Perform the actual agent migrations.""" + successful_migrations = [] + failed_migrations = [] + + try: + # Migrate enforce ready agents + if self.enforce_ready_target_policy: + for idx, row in self.enforce_ready_df.iterrows(): + try: + result = self.api.agent_move( + row["agentid"], self.enforce_ready_target_policy.groupid + ) + successful_migrations.append( + (row["hostname"], self.enforce_ready_target_policy.name) + ) + logger.debug( + f"Moved {row['hostname']} to {self.enforce_ready_target_policy.name}" + ) + except Exception as e: + failed_migrations.append((row["hostname"], str(e))) + logger.error(f"Failed to move {row['hostname']}: {e}") + + # Migrate non-enforce ready agents + if self.non_enforce_ready_target_policy: + for idx, row in self.non_enforce_ready_df.iterrows(): + try: + result = self.api.agent_move( + row["agentid"], self.non_enforce_ready_target_policy.groupid + ) + successful_migrations.append( + (row["hostname"], self.non_enforce_ready_target_policy.name) + ) + logger.debug( + f"Moved {row['hostname']} to {self.non_enforce_ready_target_policy.name}" + ) + except Exception as e: + failed_migrations.append((row["hostname"], str(e))) + logger.error(f"Failed to move {row['hostname']}: {e}") + + # Show completion results + self._show_completion_results(successful_migrations, failed_migrations) + + except Exception as e: + logger.error(f"Error during migration execution: {e}", exc_info=True) + self.app.notify(f"Migration failed: {str(e)}", severity="error", timeout=5) + self._show_results() + + def _show_completion_results( + self, successful: List[tuple], failed: List[tuple] + ) -> None: + """Show migration completion results.""" + self.workflow_stage = "complete" + content = self.query_one("#content_area", Vertical) + content.remove_children() + + # Results summary + total_attempted = len(successful) + len(failed) + success_rate = ( + (len(successful) / total_attempted * 100) if total_attempted > 0 else 0 + ) + + results = Static( + f"✅ Migration Complete\n\n" + f"Total Agents Migrated: {len(successful)}\n" + f"Failed Migrations: {len(failed)}\n" + f"Success Rate: {success_rate:.1f}%", + id="completion_summary", + ) + results.styles.margin = (1, 1, 2, 1) + content.mount(results) + + # Details tables + if successful: + success_container = Vertical() + success_container.styles.margin = (0, 1) + content.mount(success_container) + + success_label = Static("✅ Successful Migrations") + success_label.styles.margin = (0, 0, 1, 0) + success_container.mount(success_label) + + success_table = DataTable(id="success_table") + success_table.styles.height = "auto" + success_table.add_columns("Hostname", "Target Policy") + + for hostname, target_policy in successful[:25]: # Show first 25 + success_table.add_row(hostname, target_policy) + + if len(successful) > 25: + success_table.add_row(f"... and {len(successful) - 25} more", "") + + success_container.mount(success_table) + + if failed: + failed_container = Vertical() + failed_container.styles.margin = (2, 1, 0, 1) + content.mount(failed_container) + + failed_label = Static("❌ Failed Migrations") + failed_label.styles.margin = (0, 0, 1, 0) + failed_container.mount(failed_label) + + failed_table = DataTable(id="failed_table") + failed_table.styles.height = "auto" + failed_table.add_columns("Hostname", "Error") + + for hostname, error in failed[:25]: # Show first 25 + failed_table.add_row(hostname, error[:50]) # Truncate error + + if len(failed) > 25: + failed_table.add_row(f"... and {len(failed) - 25} more", "") + + failed_container.mount(failed_table) + + # Action button + done_btn = Button("✔ Done", id="start_over_btn") + done_btn.styles.width = "50%" + done_btn.styles.margin = (2, 0, 0, 0) + content.mount(done_btn) + + def _export_results(self) -> None: + """Export analysis results to CSV.""" + try: + working_dir = load_env("WORKING_DIR") or os.getcwd() + filename = os.path.join( + working_dir, + f"{self.selected_policy.name}_quiet_analysis_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.csv", + ) + + self.agents_df.to_csv(filename, index=False) + logger.info(f"Exported results to {filename}") + self.app.notify( + f"Results exported to:\n{filename}", + severity="information", + timeout=5, + ) + + except Exception as e: + logger.error(f"Failed to export results: {e}") + self.app.notify(f"Export failed: {str(e)}", severity="error", timeout=5) + + def action_go_back(self) -> None: + """Handle back/escape action.""" + # Depending on stage, go back to previous stage or exit + if self.workflow_stage in ["select_policy", "view_results", "complete"]: + self.app.pop_screen() + elif self.workflow_stage == "select_quiet_days": + self._show_policy_selection() + elif self.workflow_stage == "select_enforce_target": + self._show_results() + elif self.workflow_stage == "select_non_enforce_target": + if self.enforce_ready_target_policy: + self._show_enforce_target_selection() + else: + self._show_results() + elif self.workflow_stage == "confirm_migration": + self._show_non_enforce_target_selection() + else: + self.app.pop_screen() diff --git a/TUI/resultsdisplay.py b/TUI/resultsdisplay.py new file mode 100644 index 0000000..ebabce7 --- /dev/null +++ b/TUI/resultsdisplay.py @@ -0,0 +1,178 @@ +import logging + +from textual.containers import Horizontal, Vertical +from textual.message import Message +from textual.widget import Widget +from textual.widgets import Button, Footer, Header, Static + +logger = logging.getLogger(__name__) + + +class ResultsDisplay(Widget): + """Widget for displaying operation results in a two-column layout.""" + + CSS = """ + ResultsDisplay { + height: 100%; + } + + #results_screen { + height: 100%; + } + + #results_title { + text-align: center; + margin: 1 0; + text-style: bold; + } + + #results_layout { + height: 1fr; + margin: 1 0; + } + + #left_column, #right_column { + width: 1fr; + height: 100%; + border: solid green; + padding: 1; + } + + #right_column { + border: solid red; + } + + #success_label, #failure_label { + text-style: bold; + margin-bottom: 1; + } + + #success_results, #failure_results { + height: 1fr; + overflow-y: auto; + background: $surface; + border: round $primary; + padding: 1; + } + + .copy_button { + margin-top: 1; + width: 100%; + } + + #button_row { + height: auto; + margin: 1 0 0 0; + } + + #back_button { + width: 1fr; + } + """ + + class CopySuccess(Message): + """Posted when success results are copied.""" + + pass + + class CopyFailure(Message): + """Posted when failure results are copied.""" + + pass + + class GoBack(Message): + """Posted when back button is pressed.""" + + pass + + def __init__( + self, operation: str, successful_results: str, unsuccessful_results: str + ) -> None: + super().__init__() + self.operation = operation + self.successful_results = successful_results + self.unsuccessful_results = unsuccessful_results + + def compose(self): + with Vertical(id="results_screen"): + yield Header(show_clock=True, icon="⚙") + # Title + title = Static(f"📊 {self.operation} - Results", id="results_title") + yield title + + # Two-column layout + with Horizontal(id="results_layout"): + # Left Column - Success + with Vertical(id="left_column"): + yield Static("✅ Successful", id="success_label") + yield Static(self.successful_results, id="success_results") + yield Button( + "📋✅ Copy Success List", + id="copy_success", + classes="copy_button", + ) + + # Right Column - Failure + with Vertical(id="right_column"): + yield Static("❌ Failed", id="failure_label") + yield Static(self.unsuccessful_results, id="failure_results") + yield Button( + "📋❌ Copy Failure List", + id="copy_failure", + classes="copy_button", + ) + + # Back Button + with Horizontal(id="button_row"): + back_button = Button("← Back", id="back_button") + yield back_button + yield Footer() + + def on_button_pressed(self, event: Button.Pressed) -> None: + btn_id = event.button.id + + if btn_id == "copy_success": + success_widget = self.query_one("#success_results", Static) + try: + import pyperclip + + pyperclip.copy(str(success_widget.renderable)) + self.app.notify( + "✅ Success list copied to clipboard!", + severity="information", + timeout=2, + ) + self.post_message(self.CopySuccess()) + except ImportError: + self.app.notify( + "⚠️ pyperclip not installed. Run: pip install pyperclip", + severity="warning", + ) + except Exception as e: + self.app.notify(f"❌ Failed to copy: {str(e)}", severity="error") + event.stop() + + elif btn_id == "copy_failure": + failure_widget = self.query_one("#failure_results", Static) + try: + import pyperclip + + pyperclip.copy(str(failure_widget.renderable)) + self.app.notify( + "✅ Failure list copied to clipboard!", + severity="information", + timeout=2, + ) + self.post_message(self.CopyFailure()) + except ImportError: + self.app.notify( + "⚠️ pyperclip not installed. Run: pip install pyperclip", + severity="warning", + ) + except Exception as e: + self.app.notify(f"❌ Failed to copy: {str(e)}", severity="error") + event.stop() + + elif btn_id == "back_button": + self.app.pop_screen() + event.stop() diff --git a/TUI/theme_amber_terminal.py b/TUI/theme_amber_terminal.py new file mode 100644 index 0000000..3662892 --- /dev/null +++ b/TUI/theme_amber_terminal.py @@ -0,0 +1,35 @@ +from textual.color import Color +from textual.theme import Theme + + +def get_amber_terminal_theme(): + """Amber CRT theme with compensated brightness for blending.""" + return Theme( + name="amber-terminal", + background=Color.parse("#000000"), # pure black + primary=Color.parse("#ffb733"), # bright amber + secondary=Color.parse("#e69500"), # strong amber + success=Color.parse("#ffb733"), + warning=Color.parse("#ffff66"), + error=Color.parse("#ff3300"), + surface=Color.parse("#49331a"), # brighter brown for blending + ) + + +AMBER_TERMINAL_CSS = """ +Screen { + align: center middle; + background: #000000; /* force black */ + color: #ffb733; /* force amber text */ +} + +.widget { + border: tall #ffb733; /* force amber border */ + background: #3a1f00; /* compensated surface */ + width: 80%; +} + +* { + font-family: "Courier New", monospace; +} +""" diff --git a/TUI/theme_retro_terminal.py b/TUI/theme_retro_terminal.py new file mode 100644 index 0000000..e0cd9c9 --- /dev/null +++ b/TUI/theme_retro_terminal.py @@ -0,0 +1,38 @@ +from textual.color import Color + + +def get_retro_terminal_theme(): + from textual.theme import Theme + + return Theme( + name="retro-terminal", + background=Color.parse("#000000"), + primary=Color.parse("#00ff00"), + secondary=Color.parse("#00aa00"), + success=Color.parse("#00ff00"), + warning=Color.parse("#ffff00"), + error=Color.parse("#ff0000"), + surface=Color.parse("#071802"), + ) + + +RETRO_TERMINAL_CSS = """ +/* Retro terminal CRT effect */ +Screen { + align: center middle; + background: $background; + color: $text; +} + +/* Blocky, pixelated widgets */ +.widget { + border: tall $primary; + background: $surface; + width: 80%; +} + +/* Monospaced font */ +* { + font-family: "Courier New", monospace; +} +""" diff --git a/widgets/themeselector.py b/TUI/themeselector.py similarity index 66% rename from widgets/themeselector.py rename to TUI/themeselector.py index fda7cd1..bcbbb99 100644 --- a/widgets/themeselector.py +++ b/TUI/themeselector.py @@ -15,26 +15,26 @@ class ThemeSelector(Widget): self.theme_name = theme_name AVAILABLE_THEMES = [ - ("textual-dark", "textual-dark"), - ("textual-light", "textual-light"), - ("nord", "nord"), - ("gruvbox", "gruvbox"), - ("catppuccin-mocha", "catppuccin-mocha"), - ("dracula", "dracula"), - ("tokyo-night", "tokyo-night"), - ("monokai", "monokai"), - ("flexoki", "flexoki"), - ("catppuccin-latte", "catppuccin-latte"), - ("solarized-light", "solarized-light"), + ("Textual Dark", "textual-dark"), + ("Textual Light", "textual-light"), + ("Nord", "nord"), + ("Gruvbox", "gruvbox"), + ("Catppuccin Mocha", "catppuccin-mocha"), + ("Dracula", "dracula"), + ("Tokyo Night", "tokyo-night"), + ("Monokai", "monokai"), + ("Flexoki", "flexoki"), + ("Catppuccin Latte", "catppuccin-latte"), + ("Solarized Light", "solarized-light"), + ("Retro Terminal", "retro-terminal"), + ("Amber Terminal", "amber-terminal"), # your custom theme ] def compose(self): yield Static("Theme Options", id="theme_title") - with Vertical() as column: column.styles.width = "1fr" column.styles.height = "auto" - for label, btn_id in self.AVAILABLE_THEMES: yield Button(label, id=f"set_theme_{btn_id}", compact=True) diff --git a/airlock_libs/Cargo.lock b/airlock_libs/Cargo.lock index a3d9b52..fb2d657 100644 --- a/airlock_libs/Cargo.lock +++ b/airlock_libs/Cargo.lock @@ -15,19 +15,36 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "airlock_libs" -version = "2.0.0" +version = "3.1.2" dependencies = [ "chrono", "indicatif", "mongodb", + "opentelemetry 0.18.0", + "opentelemetry-otlp", + "opentelemetry-proto", + "opentelemetry-semantic-conventions", "pyo3", "reqwest", "serde", "serde-pyobject", "serde_json", "tokio", + "tonic", + "tracing", + "tracing-opentelemetry", + "tracing-subscriber", ] [[package]] @@ -39,6 +56,34 @@ dependencies = [ "libc", ] +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -47,7 +92,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.110", ] [[package]] @@ -62,12 +107,63 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "axum" +version = "0.6.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b829e4e32b91e643de6eafe82b1d90675f5874230191a4ffbc1b336dec4d6bf" +dependencies = [ + "async-trait", + "axum-core", + "bitflags 1.3.2", + "bytes", + "futures-util", + "http 0.2.12", + "http-body 0.4.6", + "hyper 0.14.32", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "sync_wrapper 0.1.2", + "tower 0.4.13", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "759fa577a247914fd3f7f76d62972792636412fbfd634cd452f6a385a74d2d2c" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http 0.2.12", + "http-body 0.4.6", + "mime", + "rustversion", + "tower-layer", + "tower-service", +] + [[package]] name = "base64" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "base64" version = "0.22.1" @@ -144,9 +240,9 @@ checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" [[package]] name = "cc" -version = "1.2.41" +version = "1.2.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac9fe6cdbb24b6ade63616c0a0688e45bb56732262c158df3c0c4bea4ca47cb7" +checksum = "35900b6c8d709fb1d854671ae27aeaa9eec2f8b01b364e1619a40da3e6fe2afe" dependencies = [ "find-msvc-tools", "shlex", @@ -236,6 +332,21 @@ dependencies = [ "libc", ] +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + [[package]] name = "crunchy" version = "0.2.4" @@ -244,9 +355,9 @@ checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" [[package]] name = "crypto-common" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", "typenum", @@ -273,7 +384,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn", + "syn 2.0.110", ] [[package]] @@ -284,7 +395,20 @@ checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ "darling_core", "quote", - "syn", + "syn 2.0.110", +] + +[[package]] +name = "dashmap" +version = "5.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" +dependencies = [ + "cfg-if", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", ] [[package]] @@ -311,7 +435,7 @@ checksum = "d65d7ce8132b7c0e54497a4d9a55a1c2a0912a0d786cf894472ba818fba45762" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.110", ] [[package]] @@ -322,7 +446,7 @@ checksum = "ef941ded77d15ca19b40374869ac6000af1c9f2a4c0f3d4c70926287e6364a8f" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.110", ] [[package]] @@ -335,7 +459,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn", + "syn 2.0.110", ] [[package]] @@ -357,7 +481,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.110", ] [[package]] @@ -366,6 +490,12 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + [[package]] name = "encode_unicode" version = "1.0.0" @@ -387,10 +517,10 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "quote", - "syn", + "syn 2.0.110", ] [[package]] @@ -421,6 +551,12 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127" +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + [[package]] name = "fnv" version = "1.0.7" @@ -457,6 +593,20 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.31" @@ -464,6 +614,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -497,7 +648,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.110", ] [[package]] @@ -518,9 +669,11 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ + "futures-channel", "futures-core", "futures-io", "futures-macro", + "futures-sink", "futures-task", "memchr", "pin-project-lite", @@ -530,9 +683,9 @@ dependencies = [ [[package]] name = "generic-array" -version = "0.14.9" +version = "0.14.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", @@ -565,6 +718,25 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap 2.12.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "h2" version = "0.4.12" @@ -576,7 +748,7 @@ dependencies = [ "fnv", "futures-core", "futures-sink", - "http", + "http 1.3.1", "indexmap 2.12.0", "slab", "tokio", @@ -590,12 +762,24 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + [[package]] name = "hashbrown" version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + [[package]] name = "heck" version = "0.5.0" @@ -625,7 +809,7 @@ dependencies = [ "ipnet", "once_cell", "rand 0.8.5", - "thiserror", + "thiserror 1.0.69", "tinyvec", "tokio", "tracing", @@ -648,7 +832,7 @@ dependencies = [ "rand 0.8.5", "resolv-conf", "smallvec", - "thiserror", + "thiserror 1.0.69", "tokio", "tracing", ] @@ -662,6 +846,26 @@ dependencies = [ "digest", ] +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + [[package]] name = "http" version = "1.3.1" @@ -673,6 +877,17 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + [[package]] name = "http-body" version = "1.0.1" @@ -680,7 +895,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http", + "http 1.3.1", ] [[package]] @@ -691,8 +906,8 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http", - "http-body", + "http 1.3.1", + "http-body 1.0.1", "pin-project-lite", ] @@ -703,18 +918,48 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] -name = "hyper" -version = "1.7.0" +name = "httpdate" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1744436df46f0bde35af3eda22aeaba453aada65d8f1c171cd8a5f59030bd69f" dependencies = [ "atomic-waker", "bytes", "futures-channel", "futures-core", - "h2", - "http", - "http-body", + "h2 0.4.12", + "http 1.3.1", + "http-body 1.0.1", "httparse", "itoa", "pin-project-lite", @@ -730,16 +975,28 @@ version = "0.27.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" dependencies = [ - "http", - "hyper", + "http 1.3.1", + "hyper 1.8.0", "hyper-util", - "rustls", + "rustls 0.23.35", "rustls-pki-types", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.4", "tower-service", ] +[[package]] +name = "hyper-timeout" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb958482e8c7be4bc3cf272a766a2b0bf1a6755e7a6ae777f017a31d11b13b1" +dependencies = [ + "hyper 0.14.32", + "pin-project-lite", + "tokio", + "tokio-io-timeout", +] + [[package]] name = "hyper-tls" version = "0.6.0" @@ -748,7 +1005,7 @@ checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" dependencies = [ "bytes", "http-body-util", - "hyper", + "hyper 1.8.0", "hyper-util", "native-tls", "tokio", @@ -767,9 +1024,9 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", - "http", - "http-body", - "hyper", + "http 1.3.1", + "http-body 1.0.1", + "hyper 1.8.0", "ipnet", "libc", "percent-encoding", @@ -808,9 +1065,9 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.0.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" dependencies = [ "displaydoc", "potential_utf", @@ -821,9 +1078,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.0.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" dependencies = [ "displaydoc", "litemap", @@ -834,11 +1091,10 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.0.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" dependencies = [ - "displaydoc", "icu_collections", "icu_normalizer_data", "icu_properties", @@ -849,42 +1105,38 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.0.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" [[package]] name = "icu_properties" -version = "2.0.1" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b" +checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99" dependencies = [ - "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", "icu_provider", - "potential_utf", "zerotrie", "zerovec", ] [[package]] name = "icu_properties_data" -version = "2.0.1" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" +checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899" [[package]] name = "icu_provider" -version = "2.0.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" dependencies = [ "displaydoc", "icu_locale_core", - "stable_deref_trait", - "tinystr", "writeable", "yoke", "zerofrom", @@ -944,9 +1196,9 @@ dependencies = [ [[package]] name = "indicatif" -version = "0.18.2" +version = "0.18.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ade6dfcba0dfb62ad59e59e7241ec8912af34fd29e0e743e3db992bd278e8b65" +checksum = "9375e112e4b463ec1b1c6c011953545c65a30164fbab5b581df32b3abf0dcb88" dependencies = [ "console", "portable-atomic", @@ -957,9 +1209,12 @@ dependencies = [ [[package]] name = "indoc" -version = "2.0.6" +version = "2.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] [[package]] name = "ipconfig" @@ -981,14 +1236,23 @@ checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" [[package]] name = "iri-string" -version = "0.7.8" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbc5ebe9c3a1a7a5127f920a418f7585e9e758e911d0466ed004f393b0e380b2" +checksum = "4f867b9d1d896b67beb18518eda36fdb77a32ea590de864f1325b294a6d14397" dependencies = [ "memchr", "serde", ] +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.15" @@ -997,14 +1261,20 @@ checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" [[package]] name = "js-sys" -version = "0.3.81" +version = "0.3.82" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec48937a97411dcb524a265206ccd4c90bb711fca92b2792c407f268825b9305" +checksum = "b011eec8cc36da2aab2d5cff675ec18454fad408585853910a202391cf9f8e65" dependencies = [ "once_cell", "wasm-bindgen", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libc" version = "0.2.177" @@ -1017,6 +1287,12 @@ version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.11.0" @@ -1025,9 +1301,9 @@ checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" [[package]] name = "litemap" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" [[package]] name = "lock_api" @@ -1062,7 +1338,7 @@ dependencies = [ "macro_magic_core", "macro_magic_macros", "quote", - "syn", + "syn 2.0.110", ] [[package]] @@ -1076,7 +1352,7 @@ dependencies = [ "macro_magic_core_macros", "proc-macro2", "quote", - "syn", + "syn 2.0.110", ] [[package]] @@ -1087,7 +1363,7 @@ checksum = "b02abfe41815b5bd98dbd4260173db2c116dda171dc0fe7838cb206333b83308" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.110", ] [[package]] @@ -1098,9 +1374,15 @@ checksum = "73ea28ee64b88876bf45277ed9a5817c1817df061a74f2b988971a12570e5869" dependencies = [ "macro_magic_core", "quote", - "syn", + "syn 2.0.110", ] +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + [[package]] name = "md-5" version = "0.10.6" @@ -1191,7 +1473,7 @@ dependencies = [ "percent-encoding", "rand 0.8.5", "rustc_version_runtime", - "rustls", + "rustls 0.23.35", "rustversion", "serde", "serde_bytes", @@ -1202,9 +1484,9 @@ dependencies = [ "stringprep", "strsim", "take_mut", - "thiserror", + "thiserror 1.0.69", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.4", "tokio-util", "typed-builder", "uuid", @@ -1220,9 +1502,15 @@ dependencies = [ "macro_magic", "proc-macro2", "quote", - "syn", + "syn 2.0.110", ] +[[package]] +name = "multimap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5ce46fe64a9d73be07dcbe690a38ce1b293be448fd8ce1e6c1b8062c9f72c6a" + [[package]] name = "native-tls" version = "0.2.14" @@ -1240,6 +1528,15 @@ dependencies = [ "tempfile", ] +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "num-conv" version = "0.1.0" @@ -1263,9 +1560,9 @@ checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" [[package]] name = "openssl" -version = "0.10.74" +version = "0.10.75" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24ad14dd45412269e1a30f52ad8f0664f0f4f4a89ee8fe28c3b3527021ebb654" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" dependencies = [ "bitflags 2.10.0", "cfg-if", @@ -1284,7 +1581,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.110", ] [[package]] @@ -1295,9 +1592,9 @@ checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" [[package]] name = "openssl-sys" -version = "0.9.110" +version = "0.9.111" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a9f0075ba3c21b09f8e8b2026584b1d18d49388648f2fbbf3c97ea8deced8e2" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" dependencies = [ "cc", "libc", @@ -1305,6 +1602,123 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "opentelemetry" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69d6c3d7288a106c0a363e4b0e8d308058d56902adefb16f4936f417ffef086e" +dependencies = [ + "opentelemetry_api", + "opentelemetry_sdk 0.18.0", +] + +[[package]] +name = "opentelemetry" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b84bcd6ae87133e903af7ef497404dda70c60d0ea14895fc8a5e6722754fc2a0" +dependencies = [ + "futures-core", + "futures-sink", + "js-sys", + "pin-project-lite", + "thiserror 2.0.17", +] + +[[package]] +name = "opentelemetry-otlp" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1c928609d087790fc936a1067bdc310ae702bdf3b090c3f281b713622c8bbde" +dependencies = [ + "async-trait", + "futures", + "futures-util", + "http 0.2.12", + "opentelemetry 0.18.0", + "opentelemetry-proto", + "prost", + "thiserror 1.0.69", + "tokio", + "tonic", +] + +[[package]] +name = "opentelemetry-proto" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d61a2f56df5574508dd86aaca016c917489e589ece4141df1b5e349af8d66c28" +dependencies = [ + "futures", + "futures-util", + "opentelemetry 0.18.0", + "prost", + "tonic", + "tonic-build", +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b02e0230abb0ab6636d18e2ba8fa02903ea63772281340ccac18e0af3ec9eeb" +dependencies = [ + "opentelemetry 0.18.0", +] + +[[package]] +name = "opentelemetry_api" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c24f96e21e7acc813c7a8394ee94978929db2bcc46cf6b5014fc612bf7760c22" +dependencies = [ + "fnv", + "futures-channel", + "futures-util", + "indexmap 1.9.3", + "js-sys", + "once_cell", + "pin-project-lite", + "thiserror 1.0.69", +] + +[[package]] +name = "opentelemetry_sdk" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ca41c4933371b61c2a2f214bf16931499af4ec90543604ec828f7a625c09113" +dependencies = [ + "async-trait", + "crossbeam-channel", + "dashmap", + "fnv", + "futures-channel", + "futures-executor", + "futures-util", + "once_cell", + "opentelemetry_api", + "percent-encoding", + "rand 0.8.5", + "thiserror 1.0.69", + "tokio", + "tokio-stream", +] + +[[package]] +name = "opentelemetry_sdk" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e14ae4f5991976fd48df6d843de219ca6d31b01daaab2dad5af2badeded372bd" +dependencies = [ + "futures-channel", + "futures-executor", + "futures-util", + "opentelemetry 0.31.0", + "percent-encoding", + "rand 0.9.2", + "thiserror 2.0.17", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -1343,6 +1757,36 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "petgraph" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" +dependencies = [ + "fixedbitset", + "indexmap 2.12.0", +] + +[[package]] +name = "pin-project" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -1369,9 +1813,9 @@ checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" [[package]] name = "potential_utf" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84df19adbe5b5a0782edcab45899906947ab039ccf4573713735ee7de1e6b08a" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" dependencies = [ "zerovec", ] @@ -1392,19 +1836,83 @@ dependencies = [ ] [[package]] -name = "proc-macro2" -version = "1.0.101" +name = "prettyplease" +version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" +checksum = "6c8646e95016a7a6c4adea95bafa8a16baab64b583356217f2c85db4a39d9a86" +dependencies = [ + "proc-macro2", + "syn 1.0.109", +] + +[[package]] +name = "proc-macro2" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" dependencies = [ "unicode-ident", ] [[package]] -name = "pyo3" -version = "0.27.0" +name = "prost" +version = "0.11.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa8e48c12afdeb26aa4be4e5c49fb5e11c3efa0878db783a960eea2b9ac6dd19" +checksum = "0b82eaa1d779e9a4bc1c3217db8ffbeabaae1dca241bf70183242128d48681cd" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "119533552c9a7ffacc21e099c24a0ac8bb19c2a2a3f363de84cd9b844feab270" +dependencies = [ + "bytes", + "heck 0.4.1", + "itertools", + "lazy_static", + "log", + "multimap", + "petgraph", + "prettyplease", + "prost", + "prost-types", + "regex", + "syn 1.0.109", + "tempfile", + "which", +] + +[[package]] +name = "prost-derive" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d2d8d10f3c6ded6da8b05b5fb3b8a5082514344d56c9f871412d29b4e075b4" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "prost-types" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "213622a1460818959ac1181aaeb2dc9c7f63df720db7d788b3e24eacd1983e13" +dependencies = [ + "prost", +] + +[[package]] +name = "pyo3" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37a6df7eab65fc7bee654a421404947e10a0f7085b6951bf2ea395f4659fb0cf" dependencies = [ "indoc", "libc", @@ -1419,9 +1927,9 @@ dependencies = [ [[package]] name = "pyo3-build-config" -version = "0.27.0" +version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc1989dbf2b60852e0782c7487ebf0b4c7f43161ffe820849b56cf05f945cee1" +checksum = "f77d387774f6f6eec64a004eac0ed525aab7fa1966d94b42f743797b3e395afb" dependencies = [ "python3-dll-a", "target-lexicon", @@ -1429,9 +1937,9 @@ dependencies = [ [[package]] name = "pyo3-ffi" -version = "0.27.0" +version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c808286da7500385148930152e54fb6883452033085bf1f857d85d4e82ca905c" +checksum = "2dd13844a4242793e02df3e2ec093f540d948299a6a77ea9ce7afd8623f542be" dependencies = [ "libc", "pyo3-build-config", @@ -1439,27 +1947,27 @@ dependencies = [ [[package]] name = "pyo3-macros" -version = "0.27.0" +version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83a0543c16be0d86cf0dbf2e2b636ece9fd38f20406bb43c255e0bc368095f92" +checksum = "eaf8f9f1108270b90d3676b8679586385430e5c0bb78bb5f043f95499c821a71" dependencies = [ "proc-macro2", "pyo3-macros-backend", "quote", - "syn", + "syn 2.0.110", ] [[package]] name = "pyo3-macros-backend" -version = "0.27.0" +version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a00da2ce064dcd582448ea24a5a26fa9527e0483103019b741ebcbe632dcd29" +checksum = "70a3b2274450ba5288bc9b8c1b69ff569d1d61189d4bff38f8d22e03d17f932b" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "pyo3-build-config", "quote", - "syn", + "syn 2.0.110", ] [[package]] @@ -1473,9 +1981,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.41" +version = "1.0.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" +checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" dependencies = [ "proc-macro2", ] @@ -1577,9 +2085,38 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.110", ] +[[package]] +name = "regex" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + [[package]] name = "reqwest" version = "0.12.24" @@ -1590,11 +2127,11 @@ dependencies = [ "bytes", "encoding_rs", "futures-core", - "h2", - "http", - "http-body", + "h2 0.4.12", + "http 1.3.1", + "http-body 1.0.1", "http-body-util", - "hyper", + "hyper 1.8.0", "hyper-rustls", "hyper-tls", "hyper-util", @@ -1608,10 +2145,10 @@ dependencies = [ "serde", "serde_json", "serde_urlencoded", - "sync_wrapper", + "sync_wrapper 1.0.2", "tokio", "tokio-native-tls", - "tower", + "tower 0.5.2", "tower-http", "tower-service", "url", @@ -1626,6 +2163,21 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b3789b30bd25ba102de4beabd95d21ac45b69b1be7d14522bab988c526d6799" +[[package]] +name = "ring" +version = "0.16.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3053cf52e236a3ed746dfc745aa9cacf1b791d846bdaf412f60a8d7d6e17c8fc" +dependencies = [ + "cc", + "libc", + "once_cell", + "spin", + "untrusted 0.7.1", + "web-sys", + "winapi", +] + [[package]] name = "ring" version = "0.17.14" @@ -1636,7 +2188,7 @@ dependencies = [ "cfg-if", "getrandom 0.2.16", "libc", - "untrusted", + "untrusted 0.9.0", "windows-sys 0.52.0", ] @@ -1659,6 +2211,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.10.0", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.52.0", +] + [[package]] name = "rustix" version = "1.1.2" @@ -1668,19 +2233,31 @@ dependencies = [ "bitflags 2.10.0", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.11.0", "windows-sys 0.61.2", ] [[package]] name = "rustls" -version = "0.23.33" +version = "0.20.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "751e04a496ca00bb97a5e043158d23d66b5aabf2e1d5aa2a0aaebb1aafe6f82c" +checksum = "1b80e3dec595989ea8510028f30c408a4630db12c9cbb8de34203b89d6577e99" +dependencies = [ + "log", + "ring 0.16.20", + "sct", + "webpki", +] + +[[package]] +name = "rustls" +version = "0.23.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" dependencies = [ "log", "once_cell", - "ring", + "ring 0.17.14", "rustls-pki-types", "rustls-webpki", "subtle", @@ -1688,23 +2265,44 @@ dependencies = [ ] [[package]] -name = "rustls-pki-types" -version = "1.12.0" +name = "rustls-native-certs" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" +checksum = "a9aace74cb666635c918e9c12bc0d348266037aa8eb599b5cba565709a8dff00" +dependencies = [ + "openssl-probe", + "rustls-pemfile", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64 0.21.7", +] + +[[package]] +name = "rustls-pki-types" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94182ad936a0c91c324cd46c6511b9510ed16af436d7b5bab34beab0afd55f7a" dependencies = [ "zeroize", ] [[package]] name = "rustls-webpki" -version = "0.103.7" +version = "0.103.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10b3f4191e8a80e6b43eebabfac91e5dcecebb27a71f04e820c47ec41d314bf" +checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" dependencies = [ - "ring", + "ring 0.17.14", "rustls-pki-types", - "untrusted", + "untrusted 0.9.0", ] [[package]] @@ -1742,9 +2340,9 @@ dependencies = [ [[package]] name = "schemars" -version = "1.0.5" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1317c3bf3e7df961da95b0a56a172a02abead31276215a0497241a7624b487ce" +checksum = "9558e172d4e8533736ba97870c4b2cd63f84b382a3d6eb063da41b91cce17289" dependencies = [ "dyn-clone", "ref-cast", @@ -1758,6 +2356,16 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring 0.17.14", + "untrusted 0.9.0", +] + [[package]] name = "security-framework" version = "2.11.1" @@ -1835,7 +2443,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.110", ] [[package]] @@ -1876,7 +2484,7 @@ dependencies = [ "indexmap 1.9.3", "indexmap 2.12.0", "schemars 0.9.0", - "schemars 1.0.5", + "schemars 1.1.0", "serde_core", "serde_json", "serde_with_macros", @@ -1892,7 +2500,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn", + "syn 2.0.110", ] [[package]] @@ -1917,6 +2525,15 @@ dependencies = [ "digest", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shlex" version = "1.3.0" @@ -1964,6 +2581,12 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "spin" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -1995,15 +2618,32 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.107" +version = "1.0.109" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a26dbd934e5451d21ef060c018dae56fc073894c5a7896f882928a76e6d081b" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" dependencies = [ "proc-macro2", "quote", "unicode-ident", ] +[[package]] +name = "syn" +version = "2.0.110" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a99801b5bd34ede4cf3fc688c5919368fea4e4814a4664359503e6015b280aea" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -2021,7 +2661,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.110", ] [[package]] @@ -2072,7 +2712,7 @@ dependencies = [ "fastrand", "getrandom 0.3.4", "once_cell", - "rustix", + "rustix 1.1.2", "windows-sys 0.61.2", ] @@ -2082,7 +2722,16 @@ version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +dependencies = [ + "thiserror-impl 2.0.17", ] [[package]] @@ -2093,7 +2742,27 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.110", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", ] [[package]] @@ -2138,9 +2807,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" dependencies = [ "displaydoc", "zerovec", @@ -2178,6 +2847,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "tokio-io-timeout" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bd86198d9ee903fedd2f9a2e72014287c0d9167e4ae43b5853007205dda1b76" +dependencies = [ + "pin-project-lite", + "tokio", +] + [[package]] name = "tokio-macros" version = "2.6.0" @@ -2186,7 +2865,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.110", ] [[package]] @@ -2199,21 +2878,43 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-rustls" +version = "0.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c43ee83903113e03984cb9e5cebe6c04a5116269e900e3ddba8f068a62adda59" +dependencies = [ + "rustls 0.20.9", + "tokio", + "webpki", +] + [[package]] name = "tokio-rustls" version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ - "rustls", + "rustls 0.23.35", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" +dependencies = [ + "futures-core", + "pin-project-lite", "tokio", ] [[package]] name = "tokio-util" -version = "0.7.16" +version = "0.7.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14307c986784f72ef81c89db7d9e28d6ac26d16213b109ea501696195e6e3ce5" +checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594" dependencies = [ "bytes", "futures-core", @@ -2223,6 +2924,74 @@ dependencies = [ "tokio", ] +[[package]] +name = "tonic" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f219fad3b929bef19b1f86fbc0358d35daed8f2cac972037ac0dc10bbb8d5fb" +dependencies = [ + "async-stream", + "async-trait", + "axum", + "base64 0.13.1", + "bytes", + "futures-core", + "futures-util", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "hyper 0.14.32", + "hyper-timeout", + "percent-encoding", + "pin-project", + "prost", + "prost-derive", + "rustls-native-certs", + "rustls-pemfile", + "tokio", + "tokio-rustls 0.23.4", + "tokio-stream", + "tokio-util", + "tower 0.4.13", + "tower-layer", + "tower-service", + "tracing", + "tracing-futures", +] + +[[package]] +name = "tonic-build" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bf5e9b9c0f7e0a7c027dcfaba7b2c60816c7049171f679d99ee2ff65d0de8c4" +dependencies = [ + "prettyplease", + "proc-macro2", + "prost-build", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "indexmap 1.9.3", + "pin-project", + "pin-project-lite", + "rand 0.8.5", + "slab", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "tower" version = "0.5.2" @@ -2232,7 +3001,7 @@ dependencies = [ "futures-core", "futures-util", "pin-project-lite", - "sync_wrapper", + "sync_wrapper 1.0.2", "tokio", "tower-layer", "tower-service", @@ -2247,11 +3016,11 @@ dependencies = [ "bitflags 2.10.0", "bytes", "futures-util", - "http", - "http-body", + "http 1.3.1", + "http-body 1.0.1", "iri-string", "pin-project-lite", - "tower", + "tower 0.5.2", "tower-layer", "tower-service", ] @@ -2287,7 +3056,7 @@ checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.110", ] [[package]] @@ -2297,6 +3066,61 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-futures" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97d095ae15e245a057c8e8451bab9b3ee1e1f68e9ba2b4fbc18d0ac5237835f2" +dependencies = [ + "pin-project", + "tracing", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-opentelemetry" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6e5658463dd88089aba75c7791e1d3120633b1bfde22478b28f625a9bb1b8e" +dependencies = [ + "js-sys", + "opentelemetry 0.31.0", + "opentelemetry_sdk 0.31.0", + "rustversion", + "smallvec", + "thiserror 2.0.17", + "tracing", + "tracing-core", + "tracing-log", + "tracing-subscriber", + "web-time", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" +dependencies = [ + "nu-ansi-term", + "sharded-slab", + "smallvec", + "thread_local", + "tracing-core", + "tracing-log", ] [[package]] @@ -2322,7 +3146,7 @@ checksum = "3c36781cc0e46a83726d9879608e4cf6c2505237e263a8eb8c24502989cfdb28" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.110", ] [[package]] @@ -2339,9 +3163,9 @@ checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" [[package]] name = "unicode-ident" -version = "1.0.19" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" [[package]] name = "unicode-normalization" @@ -2376,6 +3200,12 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "323402cff2dd658f39ca17c789b502021b3f18707c91cdf22e3838e1b4023817" +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + [[package]] name = "untrusted" version = "0.9.0" @@ -2412,6 +3242,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "vcpkg" version = "0.2.15" @@ -2450,9 +3286,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.104" +version = "0.2.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1da10c01ae9f1ae40cbfac0bac3b1e724b320abfcf52229f80b547c0d250e2d" +checksum = "da95793dfc411fbbd93f5be7715b0578ec61fe87cb1a42b12eb625caa5c5ea60" dependencies = [ "cfg-if", "once_cell", @@ -2461,25 +3297,11 @@ dependencies = [ "wasm-bindgen-shared", ] -[[package]] -name = "wasm-bindgen-backend" -version = "0.2.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "671c9a5a66f49d8a47345ab942e2cb93c7d1d0339065d4f8139c486121b43b19" -dependencies = [ - "bumpalo", - "log", - "proc-macro2", - "quote", - "syn", - "wasm-bindgen-shared", -] - [[package]] name = "wasm-bindgen-futures" -version = "0.4.54" +version = "0.4.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e038d41e478cc73bae0ff9b36c60cff1c98b8f38f8d7e8061e79ee63608ac5c" +checksum = "551f88106c6d5e7ccc7cd9a16f312dd3b5d36ea8b4954304657d5dfba115d4a0" dependencies = [ "cfg-if", "js-sys", @@ -2490,9 +3312,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.104" +version = "0.2.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ca60477e4c59f5f2986c50191cd972e3a50d8a95603bc9434501cf156a9a119" +checksum = "04264334509e04a7bf8690f2384ef5265f05143a4bff3889ab7a3269adab59c2" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2500,31 +3322,31 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.104" +version = "0.2.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f07d2f20d4da7b26400c9f4a0511e6e0345b040694e8a75bd41d578fa4421d7" +checksum = "420bc339d9f322e562942d52e115d57e950d12d88983a14c79b86859ee6c7ebc" dependencies = [ + "bumpalo", "proc-macro2", "quote", - "syn", - "wasm-bindgen-backend", + "syn 2.0.110", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.104" +version = "0.2.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bad67dc8b2a1a6e5448428adec4c3e84c43e561d8c9ee8a9e5aabeb193ec41d1" +checksum = "76f218a38c84bcb33c25ec7059b07847d465ce0e0a76b995e134a45adcb6af76" dependencies = [ "unicode-ident", ] [[package]] name = "web-sys" -version = "0.3.81" +version = "0.3.82" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9367c417a924a74cae129e6a2ae3b47fabb1f8995595ab474029da749a8be120" +checksum = "3a1f95c0d03a47f4ae1f7a64643a6bb97465d9b740f0fa8f90ea33915c99a9a1" dependencies = [ "js-sys", "wasm-bindgen", @@ -2540,6 +3362,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed63aea5ce73d0ff405984102c42de94fc55a6b75765d621c65262469b3c9b53" +dependencies = [ + "ring 0.17.14", + "untrusted 0.9.0", +] + [[package]] name = "webpki-roots" version = "0.26.11" @@ -2558,12 +3390,46 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +dependencies = [ + "either", + "home", + "once_cell", + "rustix 0.38.44", +] + [[package]] name = "widestring" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-core" version = "0.62.2" @@ -2585,7 +3451,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.110", ] [[package]] @@ -2596,7 +3462,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.110", ] [[package]] @@ -2898,9 +3764,9 @@ checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" [[package]] name = "writeable" -version = "0.6.1" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" [[package]] name = "wyz" @@ -2913,11 +3779,10 @@ dependencies = [ [[package]] name = "yoke" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" dependencies = [ - "serde", "stable_deref_trait", "yoke-derive", "zerofrom", @@ -2925,13 +3790,13 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.110", "synstructure", ] @@ -2952,7 +3817,7 @@ checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.110", ] [[package]] @@ -2972,7 +3837,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.110", "synstructure", ] @@ -2984,9 +3849,9 @@ checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" [[package]] name = "zerotrie" -version = "0.2.2" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" dependencies = [ "displaydoc", "yoke", @@ -2995,9 +3860,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.4" +version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7aa2bd55086f1ab526693ecbe444205da57e25f4489879da80635a46d90e73b" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" dependencies = [ "yoke", "zerofrom", @@ -3006,11 +3871,11 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.1" +version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.110", ] diff --git a/airlock_libs/Cargo.toml b/airlock_libs/Cargo.toml index 3a853d9..1fb0612 100644 --- a/airlock_libs/Cargo.toml +++ b/airlock_libs/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "airlock_libs" -version = "2.0.0" +version = "3.1.2" edition = "2024" [lib] @@ -10,12 +10,20 @@ crate-type = ["cdylib"] chrono = "0.4.42" indicatif = "0.18.2" mongodb = "3.3.0" +opentelemetry = { version = "0.18.0", features = ["rt-tokio", "metrics", "trace"] } +opentelemetry-otlp = { version = "0.11.0", features = ["trace", "metrics"] } +opentelemetry-semantic-conventions = { version = "0.10.0" } +opentelemetry-proto = { version = "0.1.0"} pyo3 = { version = "0.27.0", features = ["extension-module", "generate-import-lib"] } reqwest = { version = "0.12.24", features = ["json", "native-tls"] } serde = "1.0.228" serde-pyobject = "0.8.0" serde_json = "1.0.145" tokio = { version = "1.48.0", features = ["full"] } +tonic = { version = "0.8.2", features = ["tls-roots"] } +tracing = "0.1.41" +tracing-subscriber = "0.3.20" +tracing-opentelemetry = "0.32.0" [package.metadata.maturin] generate-abi-stubs = true diff --git a/airlock_libs/pyproject.toml b/airlock_libs/pyproject.toml index e7cd8aa..b646277 100644 --- a/airlock_libs/pyproject.toml +++ b/airlock_libs/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "airlock_libs" -version = "2.0.0" +version = "3.1.2" description = "Airlock Digital API Wrapper" readme = "README.md" license = { text = "AGPL-3.0-only" } diff --git a/airlock_libs/src/services.rs b/airlock_libs/src/services.rs index 8870d5c..c38adeb 100644 --- a/airlock_libs/src/services.rs +++ b/airlock_libs/src/services.rs @@ -1,6 +1,13 @@ use chrono::{Duration, Local, NaiveDate}; use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget, ProgressStyle}; use mongodb::bson::oid::ObjectId; +use opentelemetry::global::shutdown_tracer_provider; +use opentelemetry::sdk::Resource; +use opentelemetry::trace::noop::NoopTracerProvider; +use opentelemetry::trace::{Status, TraceContextExt, TraceError}; +use opentelemetry::{Context, KeyValue, sdk::trace as sdktrace, trace::Tracer}; +use opentelemetry::{Key, global}; +use opentelemetry_otlp::WithExportConfig; use pyo3::{prelude::*, types::PyString}; use reqwest::{ Client, @@ -18,6 +25,12 @@ use std::{ str::FromStr, }; +#[derive(Deserialize, Debug)] +struct TelemetryConfig { + TELEMETRY: bool, + TELEM_URL: Option, +} + #[derive(Debug, Deserialize, Serialize)] struct ApiResponse { error: String, @@ -61,6 +74,12 @@ pub fn pull_policy_exec_histories( exec_types: String, days: i64, ) -> Py { + let rt = tokio::runtime::Runtime::new().unwrap(); + rt.block_on(async { + let _ = init_tracer(); + }); + let tracer = global::tracer("global_tracer"); + let _cx = Context::new(); let file_path: PathBuf = format!( "{}\\cache\\chunkinator.json", get_base_directory().display() @@ -93,102 +112,141 @@ pub fn pull_policy_exec_histories( .unwrap(), ); progress_bar.enable_steady_tick(std::time::Duration::from_millis(100)); - let client = build_client(py, &py_self); + let client = tracer.in_span("Building HTTP Client", |cx| { + let client_result = build_client(py, &py_self); + match client_result { + Ok(client_result) => { + cx.span().add_event( + "info", + vec![KeyValue::new( + "Client Built Successfully", + format!("{:?}", client_result), + )], + ); + client_result + } + Err(client_result) => { + cx.span().add_event( + "warn", + vec![KeyValue::new( + "Client Failed to Build", + format!("{:?}", &client_result), + )], + ); + cx.span() + .set_status(Status::error("Client Failed to Build")); + panic!("Failed to Build Client: {:?}", client_result); + } + } + }); let api: Py = py_self; let cutoff = Local::now().naive_local() - Duration::days(days); let mut f = File::open(&writeable_filepath).unwrap(); - loop { - f.seek(SeekFrom::Start(0)).unwrap(); - let execution_histories = history_logging( - py, - &api, - &exec_types, - &checkpoint_number, - &policy_names, - &client, - ); - let parsed_responses = execution_histories.response.exechistories; - if parsed_responses.is_empty() { - break; - } - let mut seen: HashMap<(String, String, String), Group> = if writeable_filepath.exists() { - let mut contents = String::new(); - f.read_to_string(&mut contents).unwrap(); - let existing_data: ApiResponse = - serde_json::from_str(&contents).unwrap_or(ApiResponse { - error: "Success".to_string(), - response: ExecHistories { - exechistories: vec![], - }, - }); - existing_data - .response - .exechistories - .into_iter() - .map(|entry| { - ( - ( - entry.sha256.clone(), - entry.filename.clone(), - entry.hostname.clone(), - ), - entry, - ) - }) - .collect() - } else { - HashMap::new() - }; - for (index, executions) in parsed_responses.iter().enumerate() { - if executions.checkpoint.is_empty() || executions.datetime.is_empty() { - continue; - } - if index == parsed_responses.len() - 1 { - checkpoint_number = executions.checkpoint.clone(); + tracer.in_span("Airlock Data Retreival", |cx| { + let span = cx.span(); + span.set_attribute(Key::new("Days").string(days.to_string().to_string())); + loop { + f.seek(SeekFrom::Start(0)).unwrap(); + let execution_histories = tracer.in_span(checkpoint_number.to_string(), |cx| { + let results: ApiResponse = history_logging( + py, + &api, + &exec_types, + &checkpoint_number, + &policy_names, + &client, + ); + cx.span().set_attribute(KeyValue::new( + "items_in_response", + results.response.exechistories.len().to_string(), + )); + results + }); + let parsed_responses = execution_histories.response.exechistories; + if parsed_responses.is_empty() { break; } - let history_date = match NaiveDate::parse_from_str( - &executions.datetime.replace(" +0000 UTC", ""), - "%Y-%m-%dT%H:%M:%SZ", - ) { - Ok(date) => date, - Err(_) => continue, + let mut seen: HashMap<(String, String, String), Group> = if writeable_filepath.exists() + { + let mut contents = String::new(); + f.read_to_string(&mut contents).unwrap(); + let existing_data: ApiResponse = + serde_json::from_str(&contents).unwrap_or(ApiResponse { + error: "Success".to_string(), + response: ExecHistories { + exechistories: vec![], + }, + }); + existing_data + .response + .exechistories + .into_iter() + .map(|entry| { + ( + ( + entry.sha256.clone(), + entry.filename.clone(), + entry.hostname.clone(), + ), + entry, + ) + }) + .collect() + } else { + HashMap::new() }; - if history_date >= cutoff.into() { - let key = ( - executions.sha256.clone(), - executions.filename.clone(), - executions.hostname.clone(), - ); - seen.entry(key).or_insert(executions.clone()); + for (index, executions) in parsed_responses.iter().enumerate() { + if executions.checkpoint.is_empty() || executions.datetime.is_empty() { + continue; + } + if index == parsed_responses.len() - 1 { + checkpoint_number = executions.checkpoint.clone(); + break; + } + let history_date = match NaiveDate::parse_from_str( + &executions.datetime.replace(" +0000 UTC", ""), + "%Y-%m-%dT%H:%M:%SZ", + ) { + Ok(date) => date, + Err(_) => continue, + }; + if history_date >= cutoff.into() { + let key = ( + executions.sha256.clone(), + executions.filename.clone(), + executions.hostname.clone(), + ); + seen.entry(key).or_insert(executions.clone()); + } + } + let final_response = ApiResponse { + error: "Success".to_string(), + response: ExecHistories { + exechistories: seen.values().cloned().collect(), + }, + }; + let data_write = serde_json::to_string_pretty(&final_response).unwrap(); + fs::write(&writeable_filepath, data_write).unwrap(); + if let Some(last_item) = &final_response.response.exechistories.last() + && let Ok(last_date) = NaiveDate::parse_from_str( + &last_item.datetime.replace(" +0000 UTC", ""), + "%Y-%m-%dT%H:%M:%SZ", + ) + { + let date_diff = Local::now().naive_local().date() - last_date; + let percentage_diff = (days - date_diff.num_days()) as f64 / days as f64 * 100.0; + progress_bar.set_position(percentage_diff.round() as u64); + progress_bar.set_message("Total Percent Complete"); } } - let final_response = ApiResponse { - error: "Success".to_string(), - response: ExecHistories { - exechistories: seen.values().cloned().collect(), - }, - }; - let data_write = serde_json::to_string_pretty(&final_response).unwrap(); - fs::write(&writeable_filepath, data_write).unwrap(); - if let Some(last_item) = &final_response.response.exechistories.last() - && let Ok(last_date) = NaiveDate::parse_from_str( - &last_item.datetime.replace(" +0000 UTC", ""), - "%Y-%m-%dT%H:%M:%SZ", - ) - { - let date_diff = Local::now().naive_local().date() - last_date; - let percentage_diff = (days - date_diff.num_days()) as f64 / days as f64 * 100.0; - progress_bar.set_position(percentage_diff.round() as u64); - progress_bar.set_message("Total Percent Complete"); - } - } + }); progress_bar.finish_with_message("All Checkpoints Complete"); let return_data = fs::read_to_string(&writeable_filepath).unwrap(); + shutdown_tracer_provider(); PyString::new(py, &return_data).into() } -fn build_client(py: Python<'_>, py_self: &Py) -> Client { +fn build_client(py: Python<'_>, py_self: &Py) -> Result { let headers = py_self.getattr(py, "headers").unwrap().to_string(); let headers_replace = headers.replace('\'', "\""); let parsed: Value = serde_json::from_str(headers_replace.as_str()).unwrap(); @@ -205,9 +263,8 @@ fn build_client(py: Python<'_>, py_self: &Py) -> Client { Client::builder() .danger_accept_invalid_certs(true) .default_headers(header_map) - .timeout(std::time::Duration::from_secs(30)) + .timeout(std::time::Duration::from_secs(300)) .build() - .unwrap() } #[tokio::main] @@ -276,3 +333,48 @@ fn skipback(days: i64) -> ObjectId { let objectid_hex = format!("{}0000000000000000", hex_timestamp); ObjectId::parse_str(&objectid_hex).expect("Invalid ObjectId hex") } + +fn load_telemetry_config() -> TelemetryConfig { + let cfg_path = get_base_directory().join("config\\user_config.json"); + if !cfg_path.exists() { + return TelemetryConfig { + TELEMETRY: false, + TELEM_URL: None, + }; + } + match fs::read_to_string(&cfg_path) { + Ok(contents) => { + serde_json::from_str::(&contents).unwrap_or(TelemetryConfig { + TELEMETRY: false, + TELEM_URL: None, + }) + } + Err(_) => TelemetryConfig { + TELEMETRY: false, + TELEM_URL: None, + }, + } +} + +fn init_tracer() -> Result, TraceError> { + let cfg = load_telemetry_config(); + if !cfg.TELEMETRY { + global::set_tracer_provider(NoopTracerProvider::new()); + return Ok(None); + } + let endpoint = cfg.TELEM_URL.unwrap_or_default(); + let tracer = + opentelemetry_otlp::new_pipeline() + .tracing() + .with_exporter( + opentelemetry_otlp::new_exporter() + .tonic() + .with_endpoint(endpoint), + ) + .with_trace_config(sdktrace::config().with_resource(Resource::new(vec![ + KeyValue::new("service.name", "LoxideLibs"), + ]))) + .install_simple() + .unwrap(); + Ok(Some(tracer)) +} diff --git a/default_system_config.json b/default_system_config.json index b09f901..2c003af 100644 --- a/default_system_config.json +++ b/default_system_config.json @@ -1,14 +1,39 @@ { - "APPNAME": "AirlockTools", + "APPNAME": "Loxide", "URL": "https://server:3129", "LOG_LEVEL": "INFO", - "BAD_PATH_PARTS": ["users","wwwroot","windows\\temp","windows\\task","windows\\system32","startup", "windows\\fonts","Recycle.Bin","AppData","programdata", "Solarwinds","kaseya"], - "BAD_PUBLISHERS": ["Brave", "Zoom", "GlavSoft", "VNC"], - "PUPS":["logmein","invalid","nmap","LTSvc","VNC","Kaseya","Solarwinds","mRemoteNG"], + "BAD_PATH_PARTS": [ + "users", + "wwwroot", + "windows\\temp", + "windows\\task", + "windows\\system32", + "startup", + "windows\\fonts", + "Recycle.Bin", + "AppData", + "programdata", + "Solarwinds", + "kaseya" + ], + "BAD_PUBLISHERS": [ + "Brave", + "Zoom", + "GlavSoft", + "VNC" + ], + "PUPS": [ + "logmein", + "invalid", + "nmap", + "LTSvc", + "VNC", + "Kaseya", + "Solarwinds", + "mRemoteNG" + ], "PATH_EXCLUSION_CONST": 4, "MIN_FILES_FOR_PATH": 4, "VT_THREAT_TOLERANCE": 4, - "POLICY_MAP_ENF_AUD": { - - } + "POLICY_MAP_ENF_AUD": {} } \ No newline at end of file diff --git a/README.md b/docs/README.md similarity index 100% rename from README.md rename to docs/README.md diff --git a/flows/localApproval.py b/flows/localApproval.py index 5cc69a2..f1054f1 100644 --- a/flows/localApproval.py +++ b/flows/localApproval.py @@ -1,311 +1,243 @@ -# Copyright (C) 2025 James Brotosky, Brandon Wickline -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published -# by the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . +""" +This module handles the creation of local approval requests. +""" - -import datetime import logging import os -import re import time - -import dotenv -import numpy as np -import pandas as pd +from typing import List, Optional from models.agent import Agent -from services.agenthandler import findAllAgents, moveAgentToRelatedPolicy, selectAgents +from services.agenthandler import moveAgentToRelatedPolicy, selectAgents from services.API import AirlockAPIWrapper -from utils.configmanager import get_protected_json, load_env, load_env_json -from utils.setup import get_base_directory +from utils.configmanager import get_system_json from utils.utils import colorText, get_sanitized_input logger = logging.getLogger(__name__) -dotenv.load_dotenv() +class LocalApprovalRequestor: + """Handles creation of local approval requests in Loxide.""" -def getLocalApprovals(api: AirlockAPIWrapper): - base_dir = get_base_directory - result = api.otp_find_awaiting() - local_approval = pd.DataFrame(result["response"]["otpusage"]) - if os.path.exists(f"{base_dir}\\cache\\newest_local_approval.parquet"): - previous_run = pd.read_parquet( - f"{base_dir}\\cache\\newest_local_approval.parquet" - ) - previous_run.to_parquet( - f"{base_dir}\\cache\\last_local_approval.parquet", index=False - ) - os.remove(f"{base_dir}\\cache\\newest_local_approval.parquet") + def __init__(self, api: AirlockAPIWrapper, username: str = None): + """ + Initialize the local approval requestor. - # Only keep rows presumably created by the generate local approval function - local_approval = local_approval[ - local_approval["purpose"].str.startswith("🎫 Local Approval 🎫") - ] - - local_approval["batchid"] = local_approval["purpose"].apply( - lambda x: (match := re.search(r"batch:(\S+)", str(x))) and match.group(1) - ) - - if not local_approval.empty: - local_approval.to_parquet( - f"{base_dir}\\cache\\newest_local_approval.parquet", index=False + Args: + api: AirlockAPIWrapper instance + username: Username creating the approvals (for tracking) + """ + self.api = api + self.policy_map = get_system_json("POLICY_MAP_ENF_AUD", "{}") + self.username = ( + username or os.getenv("USERNAME") or os.getenv("USER") or "unknown" ) - return local_approval + def create_local_approval( + self, agent_id: str, duration_minutes: int, batch_id: Optional[int] = None + ) -> bool: + """ + Create a single local approval request. + Args: + agent_id: Agent ID to create approval for + duration_minutes: Duration of approval in minutes + batch_id: Optional batch identifier (defaults to timestamp) -def scheduleAddingLAHashes(api: AirlockAPIWrapper): + Returns: + True if successful, False otherwise + """ + if batch_id is None: + batch_id = int(time.time()) - policy_relationship_map = get_protected_json("POLICY_MAP_ENF_AUD", "{}") - bad_publisher_list = load_env_json("BAD_PUBLISHER", "[]") - pups = load_env_json("PUPS", "[]") - threat_tolerance_constant = load_env("VT_THREAT_TOLERANCE", cast_type=int) + purpose = ( + f"🎫 Local Approval 🎫 - {duration_minutes} mins - " + f"batch:{batch_id} Client:{agent_id} User:{self.username}" + ) - try: - register_function("add_hash", returnFromLocalApproval) - register_function("move_device", moveAgentToRelatedPolicy) - except Exception as e: - logger.warning(f"Failed to register functions: {e}") - return - - try: - approvals_df = getNewLocalApprovals(api) - if approvals_df.empty: - logger.debug("No new local approvals found. Nothing to schedule.") - return - batches = approvals_df.groupby("batchid") - except Exception as e: - logger.warning(f"Failed to retrieve or group local approvals: {e}") - return - - for batchid, batch_df in batches: try: - duration_minutes = int(batch_df["duration"].iloc[0]) - start_time = datetime.datetime.now() - run_time = start_time + datetime.timedelta(minutes=duration_minutes) - early_time = start_time + datetime.timedelta( - minutes=np.floor(duration_minutes * 0.95) + self.api.otp_generate(agent_id, duration_minutes, purpose) + logger.info( + f"Generated local approval for {agent_id}, batch {batch_id}, by {self.username}" ) - - early_timestamp = early_time.timestamp() - run_timestamp = run_time.timestamp() - - # Schedule add_hash job - try: - run_once_job( - f"add_hash_{batchid}", - "add_hash", - early_timestamp, - [ - api, - batch_df, - policy_relationship_map, - bad_publisher_list, - pups, - threat_tolerance_constant, - ], - None, - ) - logger.debug(f"Scheduled add_hash for batch {batchid} at {early_time}") - except Exception: - logger.debug("Failed to schedule add_hash for batch {batchid}: {e}") - - # Schedule move_device jobs - devices = batch_df["agentid"].drop_duplicates().tolist() - agents = [] - - for device in devices: - rows = api.agent_find_by_hostname(device).iterrows() - agents += [Agent(**row["data"]) for _, row in rows] - - for agent in agents: - try: - run_once_job( - f"move_device_{agent.hostame}_{batchid}", - "move_device", - run_timestamp, - [api, agent, policy_relationship_map], - "enforcement", - ) - - print( - f"Scheduled move_device for device {agent.hostname} in batch {batchid} at {run_time}" - ) - except Exception as e: - print( - f"Failed to schedule move_device for device {agent.hostname} in batch {batchid}: {e}" - ) - + return True except Exception as e: - logger.warning(f"Failed to process batch {batchid}: {e}") + logger.error(f"Failed to generate local approval for {agent_id}: {e}") + return False + def move_agent_to_audit(self, agent: Agent) -> bool: + """ + Move an agent to its corresponding audit policy. -def returnFromLocalApproval( - api, - device_df, - policy_relationship_map, - bad_publisher_list, - pups, - threat_tolerance_constant, -): - """ - # Get unique policy names from device list - policies_in_devicelist = sorted(device_df['policy_name'].unique().tolist()) + Args: + agent: Agent object to move - # Create inverse map to go from Audit to Enforcement - inverse_map = {v: k for k, v in policy_relationship_map.items()} - - # Fetch all policies - all_policies = [Policy(row['groupid'], row['hidden'], row['name'], row['parent']) for _, row in api.policy_find_all().iterrows()] - - # Define policy types - policy_types = [1, 2, 6, 7] - - #TODO finish logic for adding hashes - """ - working_dir = load_env("WORKING_DIR") - policy_relationship_map = get_protected_json("POLICY_MAP_ENF_AUD", "{}") - bad_publisher_list = load_env_json("BAD_PUBLISHER", "[]") - pups = load_env_json("PUPS", "[]") - threat_tolerance_constant = load_env("VT_THREAT_TOLERANCE") - print( - f"{working_dir}, {policy_relationship_map}, {bad_publisher_list}, {pups}, {threat_tolerance_constant}" - ) - - -def moveToLocalApproval(api: AirlockAPIWrapper): - possible_durations = [15, 60, 360, 1440, 10080] - duration_selected = None - - print(colorText("Please select a duration:", "white")) - for i, option in enumerate(possible_durations, start=1): - print(f"{i}. {option}") - - try: - - choice = int(get_sanitized_input("Enter the number of your choice:")) - if 1 <= choice <= len(possible_durations): - duration_selected = possible_durations[choice - 1] - print(colorText(f"You selected: {duration_selected}", "yellow")) - logger.debug(f"You selected: {duration_selected}") - else: - print(colorText("❌ Invalid choice.", "red")) - logger.debug("Invalid Input") - return - except ValueError: - print(colorText("❌ Invalid input. Please enter a number.", "red")) - logger.debug("Invalid Input") - return - - agents = selectAgents(api) - batch = int(time.time()) - - if not agents: - print(colorText("❌ No agents found or error retrieving agents.", "red")) - logger.debug("No agents found or error retrieving agents") - return - - for agent in agents: + Returns: + True if successful, False otherwise + """ try: - addLocalApproval(api, batch, duration_selected, agent.agentid) - moveAgentToRelatedPolicy(api, agent, "audit") + moveAgentToRelatedPolicy(self.api, agent, "audit") + logger.info(f"Moved {agent.hostname} to audit policy") + return True except Exception as e: - print(colorText(f"❌ Error processing agent {agent.hostname}: {e}", "red")) + logger.error(f"Failed to move {agent.hostname} to audit: {e}") + return False + def create_local_approval_batch( + self, + agents: List[Agent], + duration_minutes: int, + ) -> tuple[int, int, int]: + """ + Create local approvals for multiple agents and move them to audit. -def addLocalApproval(api: AirlockAPIWrapper, batchid, duration_selected, agentid): + Args: + agents: List of Agent objects + duration_minutes: Duration of approval in minutes + db_path: Optional path to database for history tracking - purpose = f"🎫 Local Approval 🎫 - {duration_selected} mins - batch:{batchid} Client:{agentid}" - api.otp_generate(agentid, duration_selected, purpose) + Returns: + Tuple of (batch_id, success_count, failure_count) + """ + batch_id = int(time.time()) + success_count = 0 + failure_count = 0 + print(colorText(f"\n📦 Processing batch {batch_id}...", "cyan")) + print(colorText(f"👤 Requested by: {self.username}", "cyan")) + print( + colorText(f"📊 Moving {len(agents)} agent(s) to local approval\n", "cyan") + ) -def monitorAuditStatus(api: AirlockAPIWrapper): - current_agents = findAllAgents(api) - last_agents = [] - if not last_agents: - last_agents = current_agents - policy_relationship_map = get_protected_json("POLICY_MAP_ENF_AUD", "{}") + for agent in agents: + try: + # Create local approval + approval_success = self.create_local_approval( + agent.agentid, duration_minutes, batch_id + ) - # Reverse map for audit → enforcement - reverse_policy_map = {v: k for k, v in policy_relationship_map.items()} - known_transitions = set(policy_relationship_map.items()) | set( - reverse_policy_map.items() - ) + if not approval_success: + raise Exception("Failed to create local approval") - # Index last_agents by hostname for quick lookup - last_agent_map = {agent.hostname: agent for agent in last_agents} + # Move to audit policy + move_success = self.move_agent_to_audit(agent) - # Result buckets - newly_added = [] - same_policy = [] - moved_to_audit = [] - moved_to_enforcement = [] - unusual_move = [] + if not move_success: + raise Exception("Failed to move to audit policy") - for current in current_agents: - previous = last_agent_map.get(current.hostname) + print(colorText(f"✓ {agent.hostname}", "green")) + success_count += 1 - if not previous: - newly_added.append(current) - continue + except Exception as e: + print(colorText(f"✗ {agent.hostname}: {e}", "red")) + logger.error(f"Error processing agent {agent.hostname}: {e}") + failure_count += 1 - if current.groupid == previous.groupid: - same_policy.append(current) - elif (previous.groupid, current.groupid) in known_transitions: - moved_to_audit.append(current) - elif (current.groupid, previous.groupid) in known_transitions: - moved_to_enforcement.append(current) - else: - unusual_move.append(current) + return batch_id, success_count, failure_count - # Return all five DataFrames - return newly_added, same_policy, moved_to_audit, moved_to_enforcement, unusual_move + def interactive_local_approval(self): + """ + Interactive workflow to create local approvals for selected agents. + This prompts the user to select a duration and agents, then creates + the local approvals and moves agents to audit policies. + """ + # Duration options in minutes + duration_options = [ + (15, "15 minutes"), + (60, "1 hour"), + (360, "6 hours"), + (1440, "1 day"), + (10080, "1 week"), + ] -def getNewLocalApprovals(api: AirlockAPIWrapper): + # Display duration options + print(colorText("\n⏱️ Select Local Approval Duration:", "white")) + print(colorText("=" * 50, "white")) - working_dir = load_env("WORKING_DIR") - current_la = getLocalApprovals(api) + for i, (minutes, label) in enumerate(duration_options, start=1): + print(f" {i}. {label} ({minutes} minutes)") - # Load old approval list - old_la_path = f"{working_dir}\\Scheduling\\last_local_approval.parquet" - if os.path.exists(old_la_path): - old_la = pd.read_parquet(old_la_path) - else: - old_la = pd.DataFrame(columns=current_la.columns) + print(colorText("=" * 50, "white")) - # Create composite keys - current_la["key"] = ( - current_la["clientid"].astype(str) + "_" + current_la["granted"].astype(str) - ) - old_la["key"] = old_la["clientid"].astype(str) + "_" + old_la["granted"].astype(str) + # Get user selection + try: + choice = int(get_sanitized_input("\nEnter the number of your choice: ")) - # Find new entries - new_entries = current_la[~current_la["key"].isin(old_la["key"])] + if 1 <= choice <= len(duration_options): + duration_minutes, duration_label = duration_options[choice - 1] + print(colorText(f"✓ Selected: {duration_label}", "green")) + logger.info(f"User selected duration: {duration_minutes} minutes") + else: + print(colorText("❌ Invalid choice.", "red")) + logger.warning("Invalid duration choice") + return - # Convert 'granted' to datetime and filter by last 10 minutes - new_entries["granted"] = pd.to_datetime( - new_entries["granted"], utc=True, errors="coerce" - ) - ten_minutes_ago = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta( - minutes=10 - ) - recent_entries = new_entries[new_entries["granted"] > ten_minutes_ago] + except ValueError: + print(colorText("❌ Invalid input. Please enter a number.", "red")) + logger.warning("Invalid input for duration selection") + return - # Save current approvals for next run - current_la.drop(columns=["key"], inplace=True) - current_la.to_parquet(old_la_path, index=False) + # Select agents + print(colorText("\n🎯 Select Agents for Local Approval:", "white")) + agents = selectAgents(self.api) - return recent_entries + if not agents: + print(colorText("❌ No agents found or error retrieving agents.", "red")) + logger.warning("No agents selected or error retrieving agents") + return + + # Confirm with user + print(colorText("\n📋 Summary:", "cyan")) + print(colorText(f" Duration: {duration_label}", "white")) + print(colorText(f" Agents: {len(agents)}", "white")) + + confirm = get_sanitized_input("\nProceed? (y/n): ").lower() + + if confirm != "y": + print(colorText("❌ Operation cancelled.", "yellow")) + return + + # Process the batch + batch_id, success_count, failure_count = self.create_local_approval_batch( + agents, duration_minutes + ) + + # Display summary + self._display_summary(batch_id, duration_label, success_count, failure_count) + + def _display_summary( + self, batch_id: int, duration_label: str, success_count: int, failure_count: int + ): + """ + Display operation summary. + + Args: + batch_id: Batch identifier + duration_label: Human-readable duration + success_count: Number of successful operations + failure_count: Number of failed operations + """ + print(colorText(f"\n{'=' * 60}", "white")) + print(colorText("📊 Local Approval Summary", "cyan")) + print(colorText("=" * 60, "white")) + + print(colorText(f"✓ Successfully processed: {success_count}", "green")) + + if failure_count > 0: + print(colorText(f"✗ Failed: {failure_count}", "red")) + + print(colorText(f"\n📦 Batch ID: {batch_id}", "cyan")) + print(colorText(f"⏱️ Duration: {duration_label}", "cyan")) + + print(colorText("=" * 60, "white")) + print(colorText("\n💡 Next Steps:", "yellow")) + print(colorText(" • Agents have been moved to audit policies", "white")) + print(colorText(" • Local approvals are active", "white")) + print( + colorText( + f" • Agents will return to enforcement after {duration_label}", + "white", + ) + ) + print(colorText("=" * 60 + "\n", "white")) diff --git a/flows/otp.py b/flows/otp.py index 8b15fab..ee07c98 100644 --- a/flows/otp.py +++ b/flows/otp.py @@ -14,136 +14,18 @@ # along with this program. If not, see . -from datetime import datetime import logging -import os import pandas as pd from services.agenthandler import selectAgents from services.API import AirlockAPIWrapper -from utils.configmanager import load_env from utils.selector import Selector -from utils.utils import colorText, get_sanitized_input +from utils.utils import get_sanitized_input logger = logging.getLogger(__name__) -def otp_generate(api: AirlockAPIWrapper): - otp_dict = {} - agents = selectAgents(api) - print(colorText("Would you like to continue with these devices?", "white")) - for agent in agents: - print(agent.hostname) - confirm = Selector.confirm() - if agents and confirm: - requester = get_sanitized_input("Who is requesting the OTP: ") - because = get_sanitized_input("Why/What work are they doing?: ") - - purpose = f"Requester: {requester} - for : {because}" - possible_durations = [15, 60, 360, 1440, 10080] - - print(colorText("Please select a duration in minutes: ", "white")) - print( - colorText( - "15 mins, 60 mins, 360 mins(6 Hours), 1440 mins (24 Hours), 10080 mins (7 Days):", - "white", - ) - ) - duration_selected = Selector.select_int(possible_durations) - - if isinstance(duration_selected, list): - duration_selected = duration_selected[0] if duration_selected else None - - if duration_selected is not None: - for agent in agents: - logging.info(f"Querying API for {agent.hostname}") - otp_code = api.otp_generate(agent.agentid, duration_selected, purpose) - logger.debug(f"Generated OTP for {agent.hostname}: {otp_code}") - otp_dict[agent.hostname] = otp_code - - print(colorText("Requested Codes:", "green")) - for key, value in otp_dict.items(): - print(colorText(f"{key} | {value}", "green")) - - -def otp_activities_by_agent(api: AirlockAPIWrapper): - activeagents = api.otp_find_active() - awaitingagents = api.otp_find_awaiting() - enforcedagents = api.otp_find_enforced() - revokedagents = api.otp_find_revoked() - - # Add a 'status' column to each DataFrame - activeagents["status"] = "active" - awaitingagents["status"] = "awaiting" - enforcedagents["status"] = "enforced" - revokedagents["status"] = "revoked" - - # Combine all into one DataFrame - combined_agents = pd.concat( - [activeagents, awaitingagents, enforcedagents, revokedagents], ignore_index=True - ) - combined_agents = combined_agents.sort_values(by="otpid", ascending=False) - - # Optionally, select specific hosts - user_input = ( - get_sanitized_input("\nWould you like to search for a specific device? (y/n): ") - .strip() - .lower() - ) - if user_input == "y": - agentnames = [] - agents = selectAgents(api) - for agent in agents: - agentnames.append(agent.hostname) - - combined_agents = combined_agents[combined_agents["hostname"].isin(agentnames)] - - # Present and select rows - selected_rows = Selector.select_dataframe_with_mode( - combined_agents, - columns=["otpid", "hostname", "status", "purpose", "granted"], - header="OTP Sessions", - ) - combined_df = pd.DataFrame() - - for row in selected_rows: - otpid = row["otpid"] - hostname = row["hostname"] - result = api.otp_get_activities(otpid) - result["hostname"] = hostname - if not result.empty: - logger.info(f"Activities for {hostname} (otpid: {otpid}):\n{result}") - combined_df = pd.concat([combined_df, result], ignore_index=True) - else: - logger.info(f"No activities found for {hostname} (otpid: {otpid})") - - user_input = ( - get_sanitized_input( - "\nWould you like to export the results to a CSV file? (y/n): " - ) - .strip() - .lower() - ) - if user_input == "y": - working_dir = load_env("WORKING_DIR") - timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") - filename = f"otp_activities_{timestamp}.csv" - file_path = os.path.join(str(working_dir), filename) - - combined_df.to_csv(file_path, index=False) - logging.info(f"Exported Data to {file_path}") - - print( - colorText( - f"\n✅ OTP Activity exported to: {working_dir}\\{filename}", - "green", - ) - ) - else: - logging.debug("User declined to export the DataFrame.") - - def otp_revoke(api: AirlockAPIWrapper): activeagents = api.otp_find_active() diff --git a/flows/prepPolicy.py b/flows/prepPolicy.py index 6f4243c..53f7ede 100644 --- a/flows/prepPolicy.py +++ b/flows/prepPolicy.py @@ -25,7 +25,7 @@ import pandas as pd from models.execution import ExecutionHistoryRecord from models.policy import Allowlist, Policy from services.API import AirlockAPIWrapper -from utils.configmanager import get_protected_value, load_env, load_env_json +from utils.configmanager import get_system_list, get_system_value, load_env from utils.selector import Selector from utils.utils import ( areYouSure, @@ -88,7 +88,7 @@ def sortHashes( ): working_dir = load_env("WORKING_DIR") history_days = Selector.select_value( - prompt="Enter how many days of history to pull (1–150): ", + prompt="Enter how many days of history to pull (1–150): ", value_type=int, valid_range=(1, 150), ) @@ -157,7 +157,7 @@ def buildPathsandPublishers(selected_policies: List[Policy], split): f"{working_dir}\\Approved\\{selected_policies[0].name}_approved_executions.csv" ) path2 = f"{working_dir}\\Approved\\{selected_policies[0].name}_needs_review_executions.csv" - path_exclusion_constant = get_protected_value("PATH_EXCLUSION_CONST", cast_type=int) + path_exclusion_constant = get_system_value("PATH_EXCLUSION_CONST", cast_type=int) if os.path.exists(path1): df1 = pd.read_csv(path1) @@ -227,7 +227,7 @@ def buildPathsandPublishers(selected_policies: List[Policy], split): all_approved_hashes["publisher"] != "Not Signed" ].drop_duplicates(subset=["publisher"]) # Remove Bad publisher if somehow they made it this far - pattern = regulator(load_env_json("BAD_PUBLISHERS", "[]")) + pattern = regulator(get_system_list("BAD_PUBLISHERS")) publist = publist[~publist["publisher"].str.contains(pattern, na=False)] publist = publist[["publisher"]] publist.sort_values(by="publisher", inplace=True) @@ -313,7 +313,7 @@ def buildPreflights(selected_policies: List[Policy]): def splitFilepathsGrouped(df, path_exclusion_constant, col="filename"): - min_files_for_path = get_protected_value("MIN_FILES_FOR_PATH", cast_type=int) + min_files_for_path = get_system_value("MIN_FILES_FOR_PATH", cast_type=int) def clean_split(path): if not isinstance(path, (str, bytes, os.PathLike)): @@ -385,8 +385,8 @@ def calculatePath(approved_hashes, path_exclusion_constant, split): else: dfs_by_policy = [approved_hashes] - badpathparts = load_env_json("BAD_PATH_PARTS", "[]") - min_files_for_path = get_protected_value("MIN_FILES_FOR_PATH", cast_type=int) + badpathparts = get_system_list("BAD_PATH_PARTS") + min_files_for_path = get_system_value("MIN_FILES_FOR_PATH", cast_type=int) processed_dfs = [] @@ -655,7 +655,7 @@ def section_header(title): def printEnforceChecklist(selected_policies, destination_policy, destination_allowlist): working_dir = load_env("WORKING_DIR") - section_header("🛠️ 🔒 Prepare to Enforce Policy 🛠️ 🔒") + section_header("🛠️ 🔒 Prepare to Enforce Policy 🛠️ 🔒") print( colorText( "\nSequentially follow these steps to prepare a policy for enforcement:", @@ -670,11 +670,11 @@ def printEnforceChecklist(selected_policies, destination_policy, destination_all ) ) if not selected_policies: - print(colorText(" [✗] No policies have been chosen", "red")) + print(colorText(" [✗] No policies have been chosen", "red")) else: print(colorText("The following policies have been chosen:", "green")) for policy in selected_policies: - print(colorText(f" [✓] {policy.name}", "green")) + print(colorText(f" [✓] {policy.name}", "green")) # Step 2: Destination Policy and Allowlist print( @@ -683,22 +683,22 @@ def printEnforceChecklist(selected_policies, destination_policy, destination_all if destination_policy: print( colorText( - f" [✓] {destination_policy[0].name} has been selected as the destination policy", + f" [✓] {destination_policy[0].name} has been selected as the destination policy", "green", ) ) else: - print(colorText(" [✗] No destination policy has been chosen", "red")) + print(colorText(" [✗] No destination policy has been chosen", "red")) if destination_allowlist: print( colorText( - f" [✓] {destination_allowlist[0].name} has been selected as allowlist", + f" [✓] {destination_allowlist[0].name} has been selected as allowlist", "green", ) ) else: - print(colorText(" [✗] No allowlist has been chosen", "red")) + print(colorText(" [✗] No allowlist has been chosen", "red")) # Step 3: Data Preparation print( @@ -713,9 +713,9 @@ def printEnforceChecklist(selected_policies, destination_policy, destination_all print( colorText( ( - " [✓] Data has been fetched" + " [✓] Data has been fetched" if os.path.exists(review_path) - else " [✗] Data has not been fetched" + else " [✗] Data has not been fetched" ), "green" if os.path.exists(review_path) else "red", ) @@ -723,7 +723,7 @@ def printEnforceChecklist(selected_policies, destination_policy, destination_all else: print( colorText( - " [✗] No policies selected, cannot check data fetch status", "red" + " [✗] No policies selected, cannot check data fetch status", "red" ) ) @@ -756,9 +756,9 @@ def printEnforceChecklist(selected_policies, destination_policy, destination_all print( colorText( ( - " [✓] Reviewed hashes have been loaded" + " [✓] Reviewed hashes have been loaded" if os.path.exists(approved_path) - else " [✗] Reviewed hashes have not been loaded" + else " [✗] Reviewed hashes have not been loaded" ), "green" if os.path.exists(approved_path) else "red", ) @@ -766,9 +766,9 @@ def printEnforceChecklist(selected_policies, destination_policy, destination_all print( colorText( ( - " [✓] Path review list created" + " [✓] Path review list created" if os.path.exists(second_review_path) - else " [✗] Path review list has not been created" + else " [✗] Path review list has not been created" ), "green" if os.path.exists(second_review_path) else "red", ) @@ -776,7 +776,7 @@ def printEnforceChecklist(selected_policies, destination_policy, destination_all else: print( colorText( - " [✗] No policies selected, cannot check reviewed hashes or path list", + " [✗] No policies selected, cannot check reviewed hashes or path list", "red", ) ) @@ -812,9 +812,9 @@ def printEnforceChecklist(selected_policies, destination_policy, destination_all print( colorText( ( - " [✓] Reviewed path list detected" + " [✓] Reviewed path list detected" if os.path.exists(reviewed_path) - else " [✗] Path review list has not been detected" + else " [✗] Path review list has not been detected" ), "green" if os.path.exists(reviewed_path) else "red", ) @@ -825,9 +825,9 @@ def printEnforceChecklist(selected_policies, destination_policy, destination_all print( colorText( ( - " [✓] Preflight Path Exclusion List has been generated" + " [✓] Preflight Path Exclusion List has been generated" if preflight_ready - else " [✗] Preflight Path Exclusion List has not been generated" + else " [✗] Preflight Path Exclusion List has not been generated" ), "green" if preflight_ready else "red", ) @@ -835,7 +835,7 @@ def printEnforceChecklist(selected_policies, destination_policy, destination_all else: print( colorText( - " [✗] No policies selected, cannot check preflight status", "red" + " [✗] No policies selected, cannot check preflight status", "red" ) ) @@ -866,5 +866,5 @@ def printEnforceChecklist(selected_policies, destination_policy, destination_all print(colorText(" Apply approved hashes to allowlist", "cyan")) # Utility Options - print(colorText("F. 📂 - Open Working Directory", "cyan")) - print(colorText("B. 🔚 - Back", "cyan")) + print(colorText("F. 📂 - Open Working Directory", "cyan")) + print(colorText("B. 🔚 - Back", "cyan")) diff --git a/flows/quietAgent.py b/flows/quietAgent.py deleted file mode 100644 index d723da7..0000000 --- a/flows/quietAgent.py +++ /dev/null @@ -1,134 +0,0 @@ -# Copyright (C) 2025 James Brotosky, Brandon Wickline -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published -# by the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -import datetime -import logging - -import dotenv -import pandas as pd - -from flows.prepPolicy import selectPolicies -from services.API import AirlockAPIWrapper -from services.policyhandler import getPolicyInfo -from utils.configmanager import load_env -from utils.selector import Selector -from utils.utils import colorText, get_sanitized_input - -logger = logging.getLogger(__name__) - - -dotenv.load_dotenv() - - -def findQuietAgents(api: AirlockAPIWrapper): - working_dir = load_env("WORKING_DIR") - # Get policy selection and agent list - selected_policy = selectPolicies(api, False) - if selected_policy: - agents = api.agents_find_by_group(selected_policy[0].groupid) - - # Prompt user for history range - history_days = Selector.select_value( - prompt="Enter how many days of history to pull (1–150): ", - value_type=int, - valid_range=(1, 150), - ) - required_quiet = Selector.select_value( - prompt="Enter how many days without an untrusted execution before these are considered ready for enforcement? (1–365): ", - value_type=int, - valid_range=(1, 150), - ) - - confirm = Selector.confirm( - f"Do you wish to proceed to pull history for {selected_policy[0].name}? Y/N : " - ) - # Get execution history as a DataFrame - if confirm: - policy_exec_history = getPolicyInfo( - api, selected_policy[0], [1, 2, 6, 7], history_days - ) - - if policy_exec_history.empty: - logging.info( - "No execution history found for the selected policy and time range." - ) - get_sanitized_input("Press enter to continue") - return - - # Convert 'datetime' column to timezone-aware datetime objects - policy_exec_history["datetime"] = pd.to_datetime( - policy_exec_history["datetime"], format="%Y-%m-%dT%H:%M:%SZ", utc=True - ) - - # Get current UTC time - now = datetime.datetime.now(datetime.timezone.utc) - - # Calculate days ago - policy_exec_history["days_ago"] = policy_exec_history["datetime"].apply( - lambda dt: (now - dt).days - ) - - # Count total executions per hostname - hostname_counts = policy_exec_history["hostname"].value_counts() - - # Map execution counts to agents - agents["execution_count"] = ( - agents["hostname"].map(hostname_counts).fillna(0).astype(int) - ) - - # Find most recent execution per hostname - most_recent_exec = policy_exec_history.sort_values( - by="days_ago" - ).drop_duplicates(subset="hostname", keep="first") - - # Map most recent execution age to agents - agents["days_since"] = agents["hostname"].map( - most_recent_exec.set_index("hostname")["days_ago"] - ) - - # Check for enforcement readiness - agents["required_quiet"] = required_quiet - agents["enforce_ready"] = agents["days_since"].apply( - lambda x: True if pd.isna(x) or x > required_quiet else False - ) - - # Sort agents by execution count and hostname - agents = agents.sort_values( - by=["execution_count", "hostname"], ascending=[True, True] - ) - - # Save to CSV - filename = f"{working_dir}\\{selected_policy[0].name}_agents_last_{history_days}_days.csv" - logging.debug(f"Saving CSV to {filename}") - print(colorText(f"Saving CSV to {filename}", "green")) - agents.to_csv(filename, index=False) - - # Summary statistics - total_agents = len(agents) - ready_agents = agents["enforce_ready"].sum() - not_ready_agents = total_agents - ready_agents - ready_percentage = (ready_agents / total_agents) * 100 - - # Print results - - message = ( - f"Total agents: {total_agents}\n" - f"Agents marked as 'enforce_ready': {ready_agents}\n" - f"Agents not ready: {not_ready_agents}\n" - f"Percentage ready for enforcement: {ready_percentage:.2f}%" - ) - logger.debug(message) - colorText(message, "green") - get_sanitized_input("Press enter to continue") diff --git a/models/execution.py b/models/execution.py index e1cf514..462193d 100644 --- a/models/execution.py +++ b/models/execution.py @@ -28,7 +28,7 @@ import pandas as pd import airlock_libs from services.API import AirlockAPIWrapper -from utils.configmanager import get_protected_value, load_env_json +from utils.configmanager import get_system_list, get_system_value from utils.utils import colorText, regulator logger = logging.getLogger(__name__) @@ -90,9 +90,9 @@ class Hash: @classmethod def categorize_hashes(cls, hashes): - threat_tolerance = get_protected_value("VT_THREAT_TOLERANCE", cast_type=int) - bad_publishers_pattern = regulator(load_env_json("BAD_PUBLISHERS", "[]")) - pups_pattern = regulator(load_env_json("PUPS", "[]")) + threat_tolerance = get_system_value("VT_THREAT_TOLERANCE", cast_type=int) + bad_publishers_pattern = regulator(get_system_list("BAD_PUBLISHERS")) + pups_pattern = regulator(get_system_list("PUPS")) approved_count = 0 unapproved_count = 0 @@ -145,13 +145,13 @@ class Hash: approved_count += 1 except (ValueError, TypeError): logger.debug( - "Needs Review: Scannermatch score is missing or invalid. — {e}" + "Needs Review: Scannermatch score is missing or invalid. — {e}" ) hash_obj.at_decision = "needs_review" needs_review_count += 1 logger.debug( - f"Final counts — Needs Review: {needs_review_count}, Approved: {approved_count}, Unapproved: {unapproved_count}" + f"Final counts — Needs Review: {needs_review_count}, Approved: {approved_count}, Unapproved: {unapproved_count}" ) return hashes @@ -384,9 +384,9 @@ class ExecutionHistoryRecord: Returns: List[ExecutionHistoryRecord]: The same list, with hash_obj.at_decision updated. """ - threat_tolerance = get_protected_value("VT_THREAT_TOLERANCE", cast_type=int) - bad_publishers_pattern = regulator(load_env_json("BAD_PUBLISHERS", "[]")) - pups_pattern = regulator(load_env_json("PUPS", "[]")) + threat_tolerance = get_system_value("VT_THREAT_TOLERANCE", cast_type=int) + bad_publishers_pattern = regulator(get_system_list("BAD_PUBLISHERS")) + pups_pattern = regulator(get_system_list("PUPS")) approved_count = 0 unapproved_count = 0 @@ -443,13 +443,13 @@ class ExecutionHistoryRecord: approved_count += 1 except (ValueError, TypeError) as e: logger.debug( - f"Needs Review: Scannermatch score is missing or invalid. — {e}" + f"Needs Review: Scannermatch score is missing or invalid. — {e}" ) hash_obj.at_decision = "needs_review" needs_review_count += 1 logger.debug( - f"Final counts — Needs Review: {needs_review_count}, " + f"Final counts — Needs Review: {needs_review_count}, " f"Approved: {approved_count}, Unapproved: {unapproved_count}" ) diff --git a/models/policy.py b/models/policy.py index dbfb357..09e16e7 100644 --- a/models/policy.py +++ b/models/policy.py @@ -13,33 +13,36 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -import json """ Policy model representing policy data and relationships. """ -class Policy: - def __init__(self, groupid, hidden, name, parent): - self.groupid = groupid - self.hidden = hidden - self.name = name - self.parent = parent +# policy.py - def __repr__(self): - # Show all current attributes, including dynamically added ones +from dataclasses import asdict, dataclass, field +import json +from typing import Optional + + +@dataclass(order=True) +class Policy: + name: str + groupid: int = field(compare=False) + hidden: bool = field(compare=False) + parent: Optional[str] = field(default=None, compare=False) + + def __repr__(self) -> str: attrs = ", ".join( f"{key}={repr(value)}" for key, value in self.__dict__.items() ) return f"" - def to_dict(self): - # Return all attributes as a dictionary - return self.__dict__ + def to_dict(self) -> dict: + return asdict(self) - def to_json(self): - # Convert to JSON string, handling non-serializable types gracefully + def to_json(self) -> str: return json.dumps(self.to_dict(), default=str) diff --git a/requirements.txt b/requirements.txt index 65ee4c7..a89fe9e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,4 +11,4 @@ urllib3==2.5.0 pyperclip==1.11.0 --extra-index-url https://git.racooncity.org/api/packages/brotoskyj/pypi/simple/ -airlock_libs==2.0.0 \ No newline at end of file +airlock_libs==3.1.2 \ No newline at end of file diff --git a/screens/otpworkflowscreen.py b/screens/otpworkflowscreen.py deleted file mode 100644 index fa5afc1..0000000 --- a/screens/otpworkflowscreen.py +++ /dev/null @@ -1,41 +0,0 @@ -from typing import List - -from textual.app import ComposeResult -from textual.screen import Screen - -from models.agent import Agent -from widgets.multiagentselector import MultiAgentSelector -from widgets.OTP_generate import OTPGenerator - - -class OTPWorkflowScreen(Screen): - """Screen that handles the OTP generation workflow.""" - - def __init__(self, all_agents: List[Agent]): - super().__init__() - self.all_agents = all_agents - self.selected_devices = None - - def compose(self) -> ComposeResult: - """Start with the multi-agent selector.""" - yield MultiAgentSelector(self.all_agents) - - def on_multi_agent_selector_agents_selected( - self, message: MultiAgentSelector.AgentsSelected - ) -> None: - """Handle selected agents - switch to OTP generator.""" - self.selected_devices = message.selected_agents - - # Remove the MultiAgentSelector - selector = self.query_one(MultiAgentSelector) - selector.remove() - - # Mount the OTPGenerator with the selected Agent objects - # No need to pass API - it will access self.app.api directly - self.mount(OTPGenerator(self.selected_devices)) - - def on_otp_generator_otp_info(self, message: OTPGenerator.OTPInfo) -> None: - """Handle OTP generation request - call the actual OTP generation function.""" - # This will be handled by the main app, but we can also do it here - # For now, just pass it up to the app level - pass diff --git a/services/API.py b/services/API.py index 34ce788..22cfa7d 100644 --- a/services/API.py +++ b/services/API.py @@ -64,6 +64,23 @@ class AirlockAPIWrapper: logger.error(f"API request failed: {e}") raise + def _post_raw(self, endpoint: str, payload: Optional[dict] = None) -> bytes: + """ + Send POST request and return raw response content (bytes). + Useful for XML endpoints. + """ + url = f"{self.base_url}{endpoint}" + data = json.dumps(payload or {}) + try: + logger.debug(f"POST Request to {url} with payload: {payload}") + response = requests.post(url, headers=self.headers, data=data, verify=False) + response.raise_for_status() + logger.debug(f"Raw response received from {url}") + return response.content # bytes + except requests.exceptions.RequestException as e: + logger.error(f"API request failed: {e}") + raise + # Allowlist Management def allowlist_find_all(self) -> pd.DataFrame: """ @@ -75,6 +92,12 @@ class AirlockAPIWrapper: result = self._post("/v1/application", {}) return pd.DataFrame(result["response"]["applications"]) + def allowlist_export(self, applicationid) -> bytes: + """Return Allowlist XML as bytes to save to file""" + payload = {"applicationid": applicationid} + result = self._post_raw("/v1/application/export", payload) + return result # should be bytes + # Agent Management def agent_find_all(self) -> pd.DataFrame: """Retrieve all agents.""" @@ -116,6 +139,30 @@ class AirlockAPIWrapper: result = self._post("/v1/agent/find", payload) return pd.DataFrame(result["response"]["agents"]) + # Baseline Managment + def baseline_find_all(self) -> pd.DataFrame: + """Retrieve all Baselines.""" + result = self._post("/v1/baseline", {}) + return pd.DataFrame(result["response"]["baselines"]) + + def baseline_export(self, baselineid) -> bytes: + """Return Baseline XML as bytes to save to file""" + payload = {"baselineid": baselineid} + result = self._post_raw("/v1/baseline/export", payload) + return result + + # Blocklist Managment + def blocklist_find_all(self) -> pd.DataFrame: + """Retrieve all Baselines.""" + result = self._post("/v1/blocklist", {}) + return pd.DataFrame(result["response"]["blocklists"]) + + def blocklist_export(self, blocklistid) -> bytes: + """Return Blocklist XML as bytes to save to file""" + payload = {"blocklistid": blocklistid} + result = self._post_raw("/v1/blocklist/export", payload) + return result + # Hash Management def hash_add_to_allowlist(self, applicationid: str, hashes: List[str]) -> dict: """Add hashes to the allowlist for a specific application.""" @@ -253,6 +300,19 @@ class AirlockAPIWrapper: } return self._post("/v1/group/settings/script_custom", payload) + def policy_set_upgradetarget( + self, + groupid: str, + windows: str, + macos: str, + ) -> dict: + payload = { + "groupid": groupid, + "windows": windows, + "macos": macos, + } + return self._post("/v1/group/settings/selfupgrade/target", payload) + # Execution History def history_logging( self, type: List[str], checkpoint: str, policy: List[str] diff --git a/services/agenthandler.py b/services/agenthandler.py index 0ec3d3b..7a788e6 100644 --- a/services/agenthandler.py +++ b/services/agenthandler.py @@ -28,7 +28,7 @@ from flows.prepPolicy import selectPolicies from models.agent import Agent from models.policy import Policy from services.API import AirlockAPIWrapper -from utils.configmanager import get_protected_json, load_env +from utils.configmanager import get_system_json, load_env from utils.selector import Selector from utils.utils import colorText, get_sanitized_input @@ -38,7 +38,7 @@ logger = logging.getLogger(__name__) def devicehistory(api: AirlockAPIWrapper, outputjson: bool): agents = selectAgents(api) history_days = Selector.select_value( - prompt="Enter how many days of history to pull (1–150): ", + prompt="Enter how many days of history to pull (1–150): ", value_type=int, valid_range=(1, 150), ) @@ -60,7 +60,7 @@ def devicehistory(api: AirlockAPIWrapper, outputjson: bool): except Exception as e: print( colorText( - f"❌ Error retrieving history for {agent.hostname}: {e}", "red" + f"❌ Error retrieving history for {agent.hostname}: {e}", "red" ) ) continue @@ -139,7 +139,7 @@ def findAgents(api, return_dataframe): print( colorText( - f"\n✅ Matched devices exported to: {working_dir}\\{filename}", + f"\n✅ Matched devices exported to: {working_dir}\\{filename}", "green", ) ) @@ -148,7 +148,7 @@ def findAgents(api, return_dataframe): def collect_device_names() -> List[str]: - print(colorText("🔍 Device Search", "cyan")) + print(colorText("🔍 Device Search", "cyan")) print( colorText( "Enter the device hostnames you'd like to search for, one per line.", "cyan" @@ -185,7 +185,7 @@ def collect_device_names() -> List[str]: else: print( colorText( - f"⚠️ Invalid input: '{stripped_line}' — only letters, numbers, underscores, spaces, and hyphens are allowed.", + f"⚠️ Invalid input: '{stripped_line}' — only letters, numbers, underscores, spaces, and hyphens are allowed.", "yellow", ) ) @@ -235,8 +235,8 @@ def show_unmatched( ] if unmatched: - logger.debug(f"⚠️ No matches for: {', '.join(unmatched)}") - print(colorText(f"⚠️ No matches for: {', '.join(unmatched)}", "yellow")) + logger.debug(f"⚠️ No matches for: {', '.join(unmatched)}") + print(colorText(f"⚠️ No matches for: {', '.join(unmatched)}", "yellow")) def enrich_agents(agents: List["Agent"], policies: List["Policy"]): @@ -248,7 +248,7 @@ def selectAgents(api: "AirlockAPIWrapper") -> List["Agent"]: device_names = collect_device_names() if not device_names: logger.debug("No device names entered") - print(colorText("⚠️ No device names entered.", "red")) + print(colorText("⚠️ No device names entered.", "red")) return [] use_exact = choose_match_type() @@ -261,11 +261,11 @@ def selectAgents(api: "AirlockAPIWrapper") -> List["Agent"]: show_unmatched(device_names, matched_agents, use_exact) if not matched_agents: - logger.debug("❌ No matching devices found.") - print(colorText("❌ No matching devices found.", "red")) + logger.debug("❌ No matching devices found.") + print(colorText("❌ No matching devices found.", "red")) return [] - print(colorText(f"✅ Found {len(matched_agents)} matching device(s).", "green")) + print(colorText(f"✅ Found {len(matched_agents)} matching device(s).", "green")) logger.info("Matched agent hostnames:") rows = (len(matched_agents) + 2) // 3 # 3 columns for row in range(rows): @@ -283,8 +283,8 @@ def selectAgents(api: "AirlockAPIWrapper") -> List["Agent"]: ) if not matched_agents: - logger.debug("❌ No matching devices remain after refinement.") - print(colorText("❌ No matching devices remain after refinement.", "red")) + logger.debug("❌ No matching devices remain after refinement.") + print(colorText("❌ No matching devices remain after refinement.", "red")) return [] enrich_agents(matched_agents, policies) @@ -302,10 +302,10 @@ def moveAgentToRelatedPolicy( Args: api: AirlockAPIWrapper instance. agent: Agent object. - policy_relationship_map: Dict mapping enforcement → audit. + policy_relationship_map: Dict mapping enforcement → audit. mode: 'audit' to move to audit, 'enforcement' to move to enforcement. """ - policy_relationship_map = get_protected_json("POLICY_MAP_ENF_AUD", "{}") + policy_relationship_map = get_system_json("POLICY_MAP_ENF_AUD", "{}") if mode == "audit": if agent.groupid in policy_relationship_map: diff --git a/services/policyhandler.py b/services/policyhandler.py index c32cdfc..0fa6a11 100644 --- a/services/policyhandler.py +++ b/services/policyhandler.py @@ -27,7 +27,7 @@ import tqdm from models.policy import Policy from services.API import AirlockAPIWrapper -from utils.configmanager import get_protected_json +from utils.configmanager import get_system_json from utils.setup import get_base_directory from utils.utils import areYouSure, colorText, get_sanitized_input @@ -236,7 +236,7 @@ def skipback(days): def updateAuditPoliciesFromEnforcementPolices(api: AirlockAPIWrapper): - policy_relationship_map = get_protected_json("POLICY_MAP_ENF_AUD", "{}") + policy_relationship_map = get_system_json("POLICY_MAP_ENF_AUD", "{}") for enforcement_policy, audit_policy in policy_relationship_map.items(): api.policy_clone(enforcement_policy, audit_policy) api.policy_set_auditmode(audit_policy, "1") diff --git a/utils/configmanager.py b/utils/configmanager.py index b099fa3..0d5391a 100644 --- a/utils/configmanager.py +++ b/utils/configmanager.py @@ -18,117 +18,280 @@ import logging import os from pathlib import Path import sys -from typing import Callable, Optional, TypeVar +from typing import Any, Callable, Optional, TypeVar T = TypeVar("T") logger = logging.getLogger(__name__) -PROTECTED_KEYS = [ +# System config keys - these are immutable and come from system_config.json (bundled in exe) +SYSTEM_CONFIG_KEYS = [ + "URL", "APPNAME", "LOG_LEVEL", + "BAD_PATH_PARTS", + "BAD_PUBLISHERS", + "PUPS", "PATH_EXCLUSION_CONST", "MIN_FILES_FOR_PATH", "VT_THREAT_TOLERANCE", "POLICY_MAP_ENF_AUD", ] -_protected_config = {} +# User config keys - these can be changed by the end user +USER_CONFIG_KEYS = [ + "TELEMETRY", # User opt-in/out for telemetry + "TELEM_URL", + "TEXTUAL_THEME", # UI theme preference + "EXTRAS", # Feature flags +] + +# In-memory config storage +_system_config = {} +_user_config = {} def get_system_config_path() -> Path: + """ + Get path to system_config.json. + Priority: + 1. Bundled in exe (_MEIPASS) + 2. Next to this file (development) + """ # Check inside bundled EXE directory first bundled_dir = Path(getattr(sys, "_MEIPASS", "")) bundled_path = bundled_dir / "system_config.json" if bundled_path.exists(): return bundled_path - # Fallback to external location + # Fallback to development location (next to this file) return Path(__file__).parent.parent / "system_config.json" -def load_protected_config() -> dict: - global _protected_config +def load_system_config() -> dict: + """ + Load system configuration from system_config.json. + This should only be called once at startup. + Returns the full system config dict. + """ + global _system_config + try: - with open(get_system_config_path(), "r") as f: - system_config = json.load(f) + config_path = get_system_config_path() + with open(config_path, "r") as f: + _system_config = json.load(f) + logger.debug(f"✅ Loaded system config from {config_path}") except FileNotFoundError: - logging.warning("⚠️ system_config.json not found. Using built-in defaults.") - system_config = { + logger.warning("⚠️ system_config.json not found. Using minimal defaults.") + # Minimal defaults for development without system_config.json + _system_config = { "APPNAME": "Loxide", + "LOG_LEVEL": "INFO", "PATH_EXCLUSION_CONST": 4, "MIN_FILES_FOR_PATH": 4, "VT_THREAT_TOLERANCE": 4, - "POLICY_MAP_ENF_AUD": {"enforced_id": "audit_id"}, + "POLICY_MAP_ENF_AUD": {}, } - _protected_config = {key: system_config[key] for key in PROTECTED_KEYS} - return _protected_config + return _system_config -def get_protected_value( +def get_system_value( key: str, cast_type: Callable[[str], T] = str, default: Optional[T] = None ) -> Optional[T]: - value = _protected_config.get(key) + """ + Get a value from system config (immutable). + + Parameters: + key: The config key to retrieve + cast_type: Function to cast the value to desired type + default: Default value if key not found + + Returns: + The config value cast to the desired type, or default + """ + value = _system_config.get(key) if value is None: - logging.warning(f"Protected config key '{key}' not found.") + logger.warning(f"System config key '{key}' not found.") return default + try: if isinstance(value, str): value = value.strip("'\"") return cast_type(value) except (ValueError, TypeError): - logging.warning( - f"Invalid value for protected key '{key}': {value}. Expected type {cast_type.__name__}." + logger.warning( + f"Invalid value for system key '{key}': {value}. Expected type {cast_type.__name__}." ) return default -def get_protected_json(key: str, default: str = "{}") -> dict: - raw = _protected_config.get(key, default) +def get_system_json(key: str, default: Optional[dict] = None) -> dict: + """ + Get a JSON/dict value from system config. + Handles both dict values and JSON strings. + """ + if default is None: + default = {} + + raw = _system_config.get(key, default) if isinstance(raw, dict): return raw + try: return json.loads(raw) - except json.JSONDecodeError: - try: - escaped = raw.encode("unicode_escape").decode("utf-8") - return json.loads(escaped) - except Exception as e: - logging.error(f"Failed to parse protected JSON key '{key}': {e}") - return json.loads(default) + except (json.JSONDecodeError, TypeError) as e: + logger.error(f"Failed to parse system JSON key '{key}': {e}") + return default -def load_env_json(key: str, default: str): - raw = os.getenv(key, default) +def get_system_list(key: str, default: Optional[list] = None) -> list: + """ + Get a list value from system config. + Handles both list values and JSON strings. + + Parameters: + key: The config key to retrieve + default: Default value if key not found or parsing fails + + Returns: + The list value or default + """ + if default is None: + default = [] + + raw = _system_config.get(key, default) + if isinstance(raw, list): + return raw + try: - return json.loads(raw) - except json.JSONDecodeError: - try: - escaped = raw.encode("unicode_escape").decode("utf-8") - return json.loads(escaped) - except Exception as e: - logging.error(f"Failed to parse {key}: {e}") - return json.loads(default) + result = json.loads(raw) if isinstance(raw, str) else raw + if isinstance(result, list): + return result + logger.warning(f"System config key '{key}' is not a list: {type(result)}") + return default + except (json.JSONDecodeError, TypeError) as e: + logger.error(f"Failed to parse system list key '{key}': {e}") + return default + + +def load_user_config(config_dir: Path) -> dict: + """ + Load user configuration from user_config.json. + Creates the file with defaults if it doesn't exist. + + Parameters: + config_dir: Directory containing user_config.json + + Returns: + The user config dict + """ + global _user_config + + user_config_path = config_dir / "user_config.json" + + if not user_config_path.exists(): + # Create default user config + default_user_config = { + "TELEMETRY": False, + "TELEM_URL": "", + "TEXTUAL_THEME": "gruvbox", + "EXTRAS": "NOTTODAY", + } + user_config_path.parent.mkdir(parents=True, exist_ok=True) + with open(user_config_path, "w") as f: + json.dump(default_user_config, f, indent=4) + logger.debug(f"Created default user config at {user_config_path}") + _user_config = default_user_config + else: + with open(user_config_path, "r") as f: + _user_config = json.load(f) + logger.debug(f"✅ Loaded user config from {user_config_path}") + + return _user_config + + +def save_user_config(config_dir: Path, updates: dict) -> None: + """ + Save updates to user configuration. + Only keys in USER_CONFIG_KEYS are allowed. + + Parameters: + config_dir: Directory containing user_config.json + updates: Dict of key-value pairs to update + """ + global _user_config + + # Validate that only user-configurable keys are being updated + invalid_keys = [k for k in updates.keys() if k not in USER_CONFIG_KEYS] + if invalid_keys: + logger.error(f"Attempted to save invalid user config keys: {invalid_keys}") + raise ValueError(f"Cannot modify system config keys: {invalid_keys}") + + # Update in-memory config + _user_config.update(updates) + + # Write to file + user_config_path = config_dir / "user_config.json" + user_config_path.parent.mkdir(parents=True, exist_ok=True) + with open(user_config_path, "w") as f: + json.dump(_user_config, f, indent=4) + + logger.debug(f"✅ Saved user config to {user_config_path}: {updates}") + + +def get_user_value( + key: str, cast_type: Callable[[str], T] = str, default: Optional[T] = None +) -> Optional[T]: + """ + Get a value from user config (mutable). + + Parameters: + key: The config key to retrieve + cast_type: Function to cast the value to desired type + default: Default value if key not found + + Returns: + The config value cast to the desired type, or default + """ + value = _user_config.get(key) + if value is None: + logger.warning(f"User config key '{key}' not found.") + return default + + try: + if isinstance(value, str): + value = value.strip("'\"") + return cast_type(value) + except (ValueError, TypeError): + logger.warning( + f"Invalid value for user key '{key}': {value}. Expected type {cast_type.__name__}." + ) + return default def load_env( key: str, cast_type: Callable[[str], T] = str, default: Optional[T] = None ) -> Optional[T]: """ - Safely retrieves an environment variable and casts it to the desired type. + Safely retrieves an environment variable from .env and casts it to the desired type. + This should ONLY be used for runtime/dynamic values like WORKING_DIR. + + For system config, use get_system_value(). + For user config, use get_user_value(). Parameters: - key (str): The name of the environment variable. - cast_type (Callable[[str], T], optional): Function to cast the value. Defaults to str. - default (Optional[T], optional): Default value if the variable is not set or invalid. + key: The name of the environment variable + cast_type: Function to cast the value. Defaults to str + default: Default value if the variable is not set or invalid Returns: - Optional[T]: The casted value or the default. + The casted value or the default """ value = os.getenv(key) if value is None: - logger.warning(f"Environment variable '{key}' not set.") + logger.debug(f"Environment variable '{key}' not set, using default.") return default + try: value = value.strip("'\"") # Strip surrounding quotes return cast_type(value) @@ -137,3 +300,46 @@ def load_env( f"Invalid value for env var '{key}': {value}. Expected type {cast_type.__name__}." ) return default + + +def load_env_json(key: str, default: str = "[]") -> Any: + """ + Load a JSON value from environment or system config. + + DEPRECATED: This function is kept for backward compatibility. + - For system config lists (BAD_PUBLISHERS, PUPS, BAD_PATH_PARTS), use get_system_list() + - For system config dicts, use get_system_json() + - For actual .env JSON values, parse manually + + This function automatically redirects known system config keys to system config. + """ + # Known system config list keys - redirect to system config + system_list_keys = ["BAD_PUBLISHERS", "PUPS", "BAD_PATH_PARTS"] + if key in system_list_keys: + logger.debug(f"Redirecting load_env_json('{key}') to get_system_list()") + return get_system_list(key, json.loads(default) if default else []) + + # Known system config dict keys - redirect to system config + system_dict_keys = ["POLICY_MAP_ENF_AUD"] + if key in system_dict_keys: + logger.debug(f"Redirecting load_env_json('{key}') to get_system_json()") + return get_system_json(key, json.loads(default) if default else {}) + + # Fall back to reading from .env (backward compatibility for unknown keys) + raw = os.getenv(key, default) + try: + return json.loads(raw) + except json.JSONDecodeError: + try: + escaped = raw.encode("unicode_escape").decode("utf-8") + return json.loads(escaped) + except Exception as e: + logger.error(f"Failed to parse {key}: {e}") + return json.loads(default) + + +# Backwards compatibility aliases (deprecated - use get_system_value instead) +get_protected_value = get_system_value +get_protected_json = get_system_json +load_protected_config = load_system_config +PROTECTED_KEYS = SYSTEM_CONFIG_KEYS # For backwards compatibility diff --git a/utils/selector.py b/utils/selector.py index 0a0f498..2cac574 100644 --- a/utils/selector.py +++ b/utils/selector.py @@ -33,7 +33,7 @@ class Selector: def _display_choices( items: List[Any], label_func: Callable[[Any], str], - num_columns: int = 4, + num_columns: int = 3, header: str = "Available Choices:", ) -> None: # Force single column if items are DataFrame rows @@ -54,7 +54,7 @@ class Selector: @staticmethod def _display_selected_items( - selected: List[Any], label_func: Callable[[Any], str], num_columns: int = 4 + selected: List[Any], label_func: Callable[[Any], str], num_columns: int = 3 ) -> None: print(colorText("\nCurrent selections:", "cyan")) if not selected: @@ -93,7 +93,7 @@ class Selector: allow_multiple: bool = False, prompt_each: bool = False, header: str = "Available Choices:", - num_columns: int = 4, + num_columns: int = 3, ) -> Union[Optional[Any], List[Any]]: if not items: logger.warning("No items available for selection.") diff --git a/utils/setup.py b/utils/setup.py index 4277389..ee4a384 100644 --- a/utils/setup.py +++ b/utils/setup.py @@ -13,18 +13,20 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -import json import logging import logging.config import logging.handlers import os from pathlib import Path import platform -import sys from dotenv import load_dotenv, set_key -from utils.configmanager import PROTECTED_KEYS, load_protected_config +from utils.configmanager import ( + get_system_value, + load_system_config, + load_user_config, +) def get_base_directory() -> Path: @@ -38,7 +40,7 @@ def get_base_directory() -> Path: return home / ".local" / "share" / "Loxide" -def configure_logging(log_dir: Path, log_level: str = "DEBUG"): +def configure_logging(log_dir: Path, log_level: str = "INFO"): log_file = log_dir / "Loxide.log" config = { @@ -62,12 +64,12 @@ def configure_logging(log_dir: Path, log_level: str = "DEBUG"): "interval": 1, # Every 1 day "backupCount": 7, # Keep 7 days of logs "encoding": "utf-8", # Ensure UTF-8 encoding - "level": "DEBUG", # Always log DEBUG and above + "level": "DEBUG", # Always log DEBUG and above to file "formatter": "detailed", # Use detailed format }, "console": { "class": "logging.StreamHandler", - "level": log_level.upper(), # Configurable log level + "level": log_level.upper(), # System-configured level for console "formatter": "simple", # Use simple format }, }, @@ -95,55 +97,15 @@ def configure_logging(log_dir: Path, log_level: str = "DEBUG"): logging.getLogger().debug("✅ Logging configured.") -def get_system_config_path() -> Path: - base_path = Path( - getattr(sys, "_MEIPASS", os.path.dirname(os.path.abspath(__file__))) - ) - return base_path.parent / "system_config.json" - - -def load_system_config() -> dict: - try: - config_path = get_system_config_path() - with open(config_path, "r") as f: - return json.load(f) - except FileNotFoundError: - logging.warning("⚠️ system_config.json not found. Using built-in defaults.") - return { - "APPNAME": "Loxide", - "LOG_LEVEL": "DEBUG", - "PATH_EXCLUSION_CONST": 4, - "MIN_FILES_FOR_PATH": 4, - "VT_THREAT_TOLERANCE": 4, - "POLICY_MAP_ENF_AUD": {"enforced_id": "audit_id"}, - } - - -def load_user_config(config_dir: Path) -> dict: - user_config_path = config_dir / "user_config.json" - if not user_config_path.exists(): - default_user_config = {"URL": "", "LOG_LEVEL": "INFO"} - with open(user_config_path, "w") as f: - json.dump(default_user_config, f, indent=4) - logging.debug(f"Created user config at {user_config_path}") - with open(user_config_path, "r") as f: - return json.load(f) - - -def write_config_to_env(config: dict, env_path: Path): - for key, value in config.items(): - if key in PROTECTED_KEYS: - continue # Skip protected keys - try: - serialized = ( - json.dumps(value) if isinstance(value, (list, dict)) else str(value) - ) - set_key(env_path, key, serialized) - except Exception as e: - logging.warning(f"Failed to write {key} to .env: {e}") - - def setup(): + """ + Initialize the application environment: + 1. Create directory structure + 2. Load system config (immutable, from system_config.json) + 3. Load user config (mutable, from user_config.json) + 4. Configure logging + 5. Set up .env with WORKING_DIR only + """ base_dir = get_base_directory() dirs = { "config": base_dir / "config", @@ -155,20 +117,30 @@ def setup(): path.mkdir(parents=True, exist_ok=True) logging.debug(f"{name.capitalize()} directory ensured at: {path}") + # Load system config (immutable) system_config = load_system_config() - configure_logging(dirs["logs"], system_config.get("LOG_LEVEL", "DEBUG")) + # Configure logging with system-defined log level + log_level = get_system_value("LOG_LEVEL", str, "INFO") + configure_logging(dirs["logs"], log_level) + + # Load user config (mutable) + user_config = load_user_config(dirs["config"]) + + # Set up .env file - ONLY for WORKING_DIR (runtime-configurable value) env_path = base_dir / ".env" if not env_path.exists(): env_path.touch() load_dotenv(dotenv_path=env_path, override=True) + # Set up working directory (only dynamic value in .env) working_dir = Path(os.getenv("WORKING_DIR") or (base_dir / "data")) working_dir.mkdir(parents=True, exist_ok=True) - set_key(env_path, "WORKING_DIR", str(working_dir)) + set_key(str(env_path), "WORKING_DIR", str(working_dir)) os.environ["WORKING_DIR"] = str(working_dir) logging.debug(f"Working directory set to: {working_dir}") + # Create folder structure in working directory folders_structure = { "Approved": [], "Needs_Review": ["Review_First", "Review_Second", "HTML"], @@ -185,23 +157,4 @@ def setup(): subfolder_path.mkdir(parents=True, exist_ok=True) logging.debug(f" └─ '{subfolder}' subfolder created at: {subfolder_path}") - user_config = load_user_config(dirs["config"]) - merged_config = {**system_config, **user_config} - - protected_config = load_protected_config() - merged_config.update(protected_config) - - # ✅ URL resolution order: system_config → .env → user prompt - url = system_config.get("URL") - if not url: - url = os.getenv("URL") - if not url: - url = input( - "🌐 Enter the service URL (e.g., https://example.com/api): " - ).strip() - merged_config["URL"] = url - set_key(env_path, "URL", url) - os.environ["URL"] = url - logging.debug(f"Service URL set to: {url}") - - write_config_to_env(merged_config, env_path) + logging.info("✅ Setup complete") diff --git a/utils/test.py b/utils/test.py deleted file mode 100644 index e69de29..0000000 diff --git a/utils/utils.py b/utils/utils.py index 26b2023..b6ed034 100644 --- a/utils/utils.py +++ b/utils/utils.py @@ -151,44 +151,6 @@ def irtang(): ) -def displayIntro(): - - print( - colorText( - r""" - _____ .__ .__ __ ___________ .__ - / _ \ |__|______| | ____ ____ | | __ \__ ___/___ ____ | | ______ - / /_\ \| \_ __ \ | / _ \_/ ___\| |/ / | | / _ \ / _ \| | / ___/ -/ | \ || | \/ |_( <_> ) \___| < | |( <_> | <_> ) |__\___ \ -\____|__ /__||__| |____/\____/ \___ >__|_ \ |____| \____/ \____/|____/____ > - \/ \/ \/ \/ -""", - "cyan", - ) - ) - - -def welcome(): - print( - colorText( - "=================================================================================", - "cyan", - ) - ) - print( - colorText( - "======================== Welcome to the Airlock API Tool ========================", - "cyan", - ) - ) - print( - colorText( - "=================================================================================", - "cyan", - ) - ) - - def section_header(title): print( colorText(