diff --git a/server/lib/systant/command_executor.ex b/server/lib/systant/command_executor.ex index 3cf9098..f7748dc 100644 --- a/server/lib/systant/command_executor.ex +++ b/server/lib/systant/command_executor.ex @@ -1,13 +1,13 @@ defmodule Systant.CommandExecutor do @moduledoc """ Secure command execution system for Systant. - + Executes only predefined commands from the configuration with strict validation, parameter checking, timeouts, and comprehensive logging. """ - + require Logger - + @doc """ Execute a command based on MQTT command message """ @@ -16,9 +16,10 @@ defmodule Systant.CommandExecutor do {:ok, command_config} <- find_command_config(parsed_command.trigger, config), {:ok, validated_params} <- validate_parameters(parsed_command.params, command_config), {:ok, final_command} <- build_command(command_config, validated_params) do - - Logger.info("Executing command: #{command_config["name"]} with params: #{inspect(validated_params)}") - + Logger.info( + "Executing command: #{command_config["name"]} with params: #{inspect(validated_params)}" + ) + execute_system_command(final_command, command_config, parsed_command) else {:error, reason} -> @@ -26,16 +27,16 @@ defmodule Systant.CommandExecutor do {:error, reason} end end - + @doc """ List all available commands from configuration """ def list_available_commands(config) do commands_config = Systant.Config.get(config, ["commands"]) || %{} - + if commands_config["enabled"] do available = commands_config["available"] || [] - + Enum.map(available, fn cmd -> %{ name: cmd["name"], @@ -50,79 +51,82 @@ defmodule Systant.CommandExecutor do [] end end - + # Private functions - + defp parse_command(command_data) do case command_data do %{"command" => trigger} = data when is_binary(trigger) -> - {:ok, %{ - trigger: trigger, - params: data["params"] || [], - request_id: data["request_id"] || generate_request_id(), - timestamp: data["timestamp"] || DateTime.utc_now() |> DateTime.to_iso8601() - }} - + {:ok, + %{ + trigger: trigger, + params: data["params"] || [], + request_id: data["request_id"] || generate_request_id(), + timestamp: data["timestamp"] || DateTime.utc_now() |> DateTime.to_iso8601() + }} + _ -> - {:error, "Invalid command format. Expected: {\"command\": \"trigger\", \"params\": [...]}"} + {:error, + "Invalid command format. Expected: {\"command\": \"trigger\", \"params\": [...]}"} end end - + defp find_command_config(trigger, config) do commands_config = Systant.Config.get(config, ["commands"]) || %{} - + unless commands_config["enabled"] do {:error, "Command execution is disabled in configuration"} else - - available = commands_config["available"] || [] - + available = commands_config["available"] || [] + case Enum.find(available, fn cmd -> cmd["trigger"] == trigger end) do nil -> {:error, "Command '#{trigger}' not found in configuration"} command_config -> {:ok, command_config} end end end - + defp validate_parameters(params, command_config) when is_list(params) do allowed_params = command_config["allowed_params"] || [] - + # If no parameters are allowed, params must be empty if Enum.empty?(allowed_params) and not Enum.empty?(params) do {:error, "Command '#{command_config["trigger"]}' does not accept parameters"} else # Validate each parameter against allowed list - invalid_params = Enum.reject(params, fn param -> - Enum.member?(allowed_params, param) - end) - + invalid_params = + Enum.reject(params, fn param -> + Enum.member?(allowed_params, param) + end) + if Enum.empty?(invalid_params) do {:ok, params} else - {:error, "Invalid parameters: #{inspect(invalid_params)}. Allowed: #{inspect(allowed_params)}"} + {:error, + "Invalid parameters: #{inspect(invalid_params)}. Allowed: #{inspect(allowed_params)}"} end end end - + defp validate_parameters(_, _), do: {:error, "Parameters must be a list"} - + defp build_command(command_config, params) do base_command = command_config["command"] - + if is_binary(base_command) do # Substitute parameters in the command string final_command_string = substitute_parameters_in_string(base_command, params) - + # If running as root and this looks like a Wayland command, wrap with sudo final_command_with_user = maybe_wrap_with_sudo(final_command_string, command_config) - + # Return the command string directly - we'll handle shell execution in execute_regular_command {:ok, final_command_with_user} else {:error, "Command configuration must be a string"} end end - + defp maybe_wrap_with_sudo(command_string, command_config) do # Check if we're running as root and this command needs user privileges if System.get_env("USER") == "root" and needs_user_privileges?(command_string, command_config) do @@ -130,6 +134,7 @@ defmodule Systant.CommandExecutor do case find_user_uid() do {:ok, uid} -> "sudo -u '##{uid}' #{command_string}" + {:error, _reason} -> command_string end @@ -137,66 +142,73 @@ defmodule Systant.CommandExecutor do command_string end end - + defp needs_user_privileges?(command_string, command_config) do # Check if this is a Wayland command that needs user session wayland_commands = ["grim", "hyprctl", "swaymsg", "wlr-", "waybar", "wofi"] - + Enum.any?(wayland_commands, fn cmd -> String.contains?(command_string, cmd) end) or command_config["run_as_user"] == true end - + defp find_user_uid() do # Look for the first non-root user in /run/user/ case File.ls("/run/user") do {:ok, dirs} -> - user_dirs = Enum.filter(dirs, fn dir -> - String.match?(dir, ~r/^\d+$/) and dir != "0" - end) - + user_dirs = + Enum.filter(dirs, fn dir -> + String.match?(dir, ~r/^\d+$/) and dir != "0" + end) + case user_dirs do [uid | _] -> {:ok, uid} [] -> {:error, "No user sessions found"} end + {:error, reason} -> {:error, "Cannot access /run/user: #{reason}"} end end - + defp substitute_parameters_in_string(command_string, params) do param_map = build_param_map(params) - + # Replace $VARIABLE patterns in the command string Enum.reduce(param_map, command_string, fn {var_name, value}, acc -> String.replace(acc, "$#{var_name}", value) end) end - - + defp build_param_map(params) do # For now, use simple mapping: first param is $SERVICE, $PATH, $PROCESS, $HOST, etc. # In the future, could support named parameters case params do - [param1] -> %{"SERVICE" => param1, "PATH" => param1, "PROCESS" => param1, "HOST" => param1} - [param1, param2] -> %{"SERVICE" => param1, "PATH" => param2, "PROCESS" => param1, "HOST" => param1} - _ -> %{} + [param1] -> + %{"SERVICE" => param1, "PATH" => param1, "PROCESS" => param1, "HOST" => param1} + + [param1, param2] -> + %{"SERVICE" => param1, "PATH" => param2, "PROCESS" => param1, "HOST" => param1} + + _ -> + %{} end end - + defp build_command_environment() do # Get current environment env = System.get_env() - - # Start with current environment - enhanced_env = env - + + # Start with current environment, but inject user's ~/.local/bin + enhanced_env = Map.put(env, "PATH", "#{env["HOME"]}/.local/bin:#{env["PATH"]}") + # If running as root, add Wayland session environment for user commands if System.get_env("USER") == "root" do # Find the user's Wayland session info case find_user_wayland_session() do {:ok, wayland_env} -> Map.merge(enhanced_env, wayland_env) + {:error, _reason} -> enhanced_env end @@ -204,49 +216,55 @@ defmodule Systant.CommandExecutor do enhanced_env end end - + defp find_user_wayland_session() do # Look for active Wayland sessions in /run/user/ case File.ls("/run/user") do {:ok, dirs} -> # Find the first user directory (typically 1000 for first user) - user_dirs = Enum.filter(dirs, fn dir -> - String.match?(dir, ~r/^\d+$/) and File.exists?("/run/user/#{dir}/wayland-1") - end) - + user_dirs = + Enum.filter(dirs, fn dir -> + String.match?(dir, ~r/^\d+$/) and File.exists?("/run/user/#{dir}/wayland-1") + end) + case user_dirs do [uid | _] -> runtime_dir = "/run/user/#{uid}" - {:ok, %{ - "XDG_RUNTIME_DIR" => runtime_dir, - "WAYLAND_DISPLAY" => "wayland-1" - }} + + {:ok, + %{ + "XDG_RUNTIME_DIR" => runtime_dir, + "WAYLAND_DISPLAY" => "wayland-1" + }} + [] -> {:error, "No active Wayland sessions found"} end + {:error, reason} -> {:error, "Cannot access /run/user: #{reason}"} end end - + defp execute_system_command(final_command, command_config, parsed_command) do is_detached = command_config["detached"] || false - timeout = (command_config["timeout"] || 10) * 1000 # Convert to milliseconds - + # Convert to milliseconds + timeout = (command_config["timeout"] || 10) * 1000 + # Build environment for command execution env = build_command_environment() - + if is_detached do Logger.info("Executing detached command: #{inspect(final_command)}") else Logger.info("Executing system command: #{inspect(final_command)} (timeout: #{timeout}ms)") end - + Logger.debug("Environment PATH: #{Map.get(env, "PATH")}") Logger.debug("Environment USER: #{Map.get(env, "USER")}") Logger.debug("Environment HOME: #{Map.get(env, "HOME")}") Logger.debug("Environment XDG_RUNTIME_DIR: #{Map.get(env, "XDG_RUNTIME_DIR")}") - + if is_detached do # For detached processes, spawn and immediately return success execute_detached_command(final_command, env, parsed_command) @@ -255,165 +273,174 @@ defmodule Systant.CommandExecutor do execute_regular_command(final_command, env, timeout, parsed_command) end end - + defp execute_detached_command(command_string, env, parsed_command) do try do # Use spawn to start process without waiting - port = Port.open({:spawn_executable, "/bin/sh"}, [ - :binary, - :exit_status, - args: ["-c", command_string], - env: Enum.map(env, fn {k, v} -> {String.to_charlist(k), String.to_charlist(v)} end) - ]) - + port = + Port.open({:spawn_executable, "/bin/sh"}, [ + :binary, + :exit_status, + args: ["-c", command_string], + env: Enum.map(env, fn {k, v} -> {String.to_charlist(k), String.to_charlist(v)} end) + ]) + # Close the port immediately to detach Port.close(port) - + Logger.info("Detached command started successfully") - - {:ok, %{ - request_id: parsed_command.request_id, - command: parsed_command.trigger, - status: "success", - output: "Command started in detached mode", - detached: true, - timestamp: DateTime.utc_now() |> DateTime.to_iso8601() - }} + + {:ok, + %{ + request_id: parsed_command.request_id, + command: parsed_command.trigger, + status: "success", + output: "Command started in detached mode", + detached: true, + timestamp: DateTime.utc_now() |> DateTime.to_iso8601() + }} rescue error -> Logger.error("Failed to start detached command: #{inspect(error)}") - - {:ok, %{ - request_id: parsed_command.request_id, - command: parsed_command.trigger, - status: "error", - output: "", - error: "Failed to start detached command: #{inspect(error)}", - timestamp: DateTime.utc_now() |> DateTime.to_iso8601() - }} + + {:ok, + %{ + request_id: parsed_command.request_id, + command: parsed_command.trigger, + status: "error", + output: "", + error: "Failed to start detached command: #{inspect(error)}", + timestamp: DateTime.utc_now() |> DateTime.to_iso8601() + }} end end - + defp execute_regular_command(command_string, env, timeout, parsed_command) do start_time = System.monotonic_time(:millisecond) - + # Wrap the command with PID tracking wrapper_script = """ echo "SYSTANT_PID:$$" exec #{command_string} """ - - port = Port.open({:spawn_executable, "/bin/sh"}, [ - :binary, - :exit_status, - :stderr_to_stdout, - args: ["-c", wrapper_script], - env: Enum.map(env, fn {k, v} -> {String.to_charlist(k), String.to_charlist(v)} end) - ]) - + + port = + Port.open({:spawn_executable, "/bin/sh"}, [ + :binary, + :exit_status, + :stderr_to_stdout, + args: ["-c", wrapper_script], + env: Enum.map(env, fn {k, v} -> {String.to_charlist(k), String.to_charlist(v)} end) + ]) + # Set up monitoring ref = Port.monitor(port) - + # Collect output with PID extraction output = collect_port_output_with_pid(port, ref, timeout, "", nil) - + case output do {:ok, data, exit_status, _pid} -> execution_time = System.monotonic_time(:millisecond) - start_time - + case exit_status do 0 -> Logger.info("Command completed successfully in #{execution_time}ms") - - {:ok, %{ - request_id: parsed_command.request_id, - command: parsed_command.trigger, - status: "success", - output: String.trim(data), - execution_time: execution_time / 1000.0, - timestamp: DateTime.utc_now() |> DateTime.to_iso8601() - }} - + + {:ok, + %{ + request_id: parsed_command.request_id, + command: parsed_command.trigger, + status: "success", + output: String.trim(data), + execution_time: execution_time / 1000.0, + timestamp: DateTime.utc_now() |> DateTime.to_iso8601() + }} + code -> Logger.warning("Command failed with exit code #{code} in #{execution_time}ms") - - {:ok, %{ - request_id: parsed_command.request_id, - command: parsed_command.trigger, - status: "error", - output: String.trim(data), - error: "Command exited with code #{code}", - execution_time: execution_time / 1000.0, - timestamp: DateTime.utc_now() |> DateTime.to_iso8601() - }} + + {:ok, + %{ + request_id: parsed_command.request_id, + command: parsed_command.trigger, + status: "error", + output: String.trim(data), + error: "Command exited with code #{code}", + execution_time: execution_time / 1000.0, + timestamp: DateTime.utc_now() |> DateTime.to_iso8601() + }} end - + {:timeout, partial_output, pid} -> execution_time = System.monotonic_time(:millisecond) - start_time - + # First, close the port to prevent more data try do Port.close(port) rescue _ -> :ok end - + # Kill the process group if we have a PID if pid do kill_process_group(pid) end - + # Flush any remaining port messages to prevent them from going to other processes flush_port_messages(port) - + Logger.error("Command timed out after #{timeout}ms and was terminated") - - {:ok, %{ - request_id: parsed_command.request_id, - command: parsed_command.trigger, - status: "error", - output: String.trim(partial_output), - error: "Command timed out after #{timeout / 1000} seconds and was terminated", - execution_time: execution_time / 1000.0, - timestamp: DateTime.utc_now() |> DateTime.to_iso8601() - }} - + + {:ok, + %{ + request_id: parsed_command.request_id, + command: parsed_command.trigger, + status: "error", + output: String.trim(partial_output), + error: "Command timed out after #{timeout / 1000} seconds and was terminated", + execution_time: execution_time / 1000.0, + timestamp: DateTime.utc_now() |> DateTime.to_iso8601() + }} + {:error, reason} -> execution_time = System.monotonic_time(:millisecond) - start_time Logger.error("Command execution failed: #{inspect(reason)}") - - {:ok, %{ - request_id: parsed_command.request_id, - command: parsed_command.trigger, - status: "error", - output: "", - error: "Execution failed: #{inspect(reason)}", - execution_time: execution_time / 1000.0, - timestamp: DateTime.utc_now() |> DateTime.to_iso8601() - }} + + {:ok, + %{ + request_id: parsed_command.request_id, + command: parsed_command.trigger, + status: "error", + output: "", + error: "Execution failed: #{inspect(reason)}", + execution_time: execution_time / 1000.0, + timestamp: DateTime.utc_now() |> DateTime.to_iso8601() + }} end end - - + defp kill_process_group(pid) when is_integer(pid) do # Kill the entire process group Logger.info("Killing process group for PID #{pid}") System.cmd("kill", ["-TERM", "-#{pid}"], stderr_to_stdout: true) - + # Give it a moment to terminate gracefully Process.sleep(100) - + # Force kill if still alive case System.cmd("kill", ["-0", "#{pid}"], stderr_to_stdout: true) do {_, 0} -> Logger.warning("Process #{pid} still alive, sending SIGKILL") System.cmd("kill", ["-KILL", "-#{pid}"], stderr_to_stdout: true) + _ -> :ok end end + defp kill_process_group(_), do: :ok - + defp flush_port_messages(port) do receive do {^port, _} -> @@ -425,22 +452,21 @@ defmodule Systant.CommandExecutor do :ok end end - + defp collect_port_output_with_pid(port, ref, timeout, acc, pid) do receive do {^port, {:data, data}} -> # Extract PID if we see it in the output {new_pid, cleaned_data} = extract_pid(data, pid) collect_port_output_with_pid(port, ref, timeout, acc <> cleaned_data, new_pid) - + {^port, {:exit_status, status}} -> # Demonitor to avoid receiving DOWN message Port.demonitor(ref, [:flush]) {:ok, acc, status, pid} - + {:DOWN, ^ref, :port, ^port, reason} -> {:error, reason} - after timeout -> # Demonitor to avoid receiving DOWN message after timeout @@ -448,19 +474,20 @@ defmodule Systant.CommandExecutor do {:timeout, acc, pid} end end - + defp extract_pid(data, current_pid) do case Regex.run(~r/SYSTANT_PID:(\d+)\n/, data) do [full_match, pid_str] -> pid = String.to_integer(pid_str) cleaned = String.replace(data, full_match, "") {pid, cleaned} + nil -> {current_pid, data} end end - + defp generate_request_id do :crypto.strong_rand_bytes(16) |> Base.encode16(case: :lower) end -end \ No newline at end of file +end diff --git a/server/systant.toml b/server/systant.toml deleted file mode 100644 index 2155287..0000000 --- a/server/systant.toml +++ /dev/null @@ -1,143 +0,0 @@ -# Systant Configuration File -# This file controls which metrics are collected and how they're reported - -[general] -# Enable/disable entire metric categories -enabled_modules = [ - "cpu", - "memory", - "disk", - "gpu", - "network", - "temperature", - "processes", - "system", -] - -# Collection intervals (in milliseconds) -collection_interval = 30000 # 30 seconds -startup_delay = 5000 # 5 seconds - -[cpu] -# CPU metrics are always lightweight, no specific options needed -enabled = true - -[memory] -enabled = true -# Show detailed breakdown (buffers, cached, etc.) -show_detailed = true - -[disk] -enabled = true -# Specific mount points to monitor (empty = all) -include_mounts = [] -# Mount points to exclude -exclude_mounts = ["/snap", "/boot", "/dev", "/sys", "/proc", "/run", "/tmp"] -# Filesystem types to exclude -exclude_types = ["tmpfs", "devtmpfs", "squashfs", "overlay"] -# Only show disks above this usage percentage -min_usage_percent = 1 - -[gpu] -enabled = true -# Enable NVIDIA GPU monitoring (requires nvidia-smi) -nvidia_enabled = true -# Enable AMD GPU monitoring (requires rocm-smi or sysfs) -amd_enabled = true -# Maximum number of GPUs to report -max_gpus = 8 - -[network] -enabled = true -# Specific interfaces to monitor (empty = all) -include_interfaces = [] -# Interfaces to exclude (common virtual/loopback interfaces) -exclude_interfaces = ["lo", "docker0", "br-", "veth", "virbr"] -# Only show interfaces with traffic above this threshold (bytes) -min_bytes_threshold = 1024 - -[temperature] -enabled = true -# Enable CPU temperature monitoring -cpu_temp_enabled = true -# Enable lm-sensors integration (requires 'sensors' command) -sensors_enabled = true -# Temperature units: "celsius" or "fahrenheit" -temp_unit = "celsius" - -[processes] -enabled = true -# Number of top processes to report -max_processes = 10 -# Sort by: "cpu" or "memory" -sort_by = "cpu" -# Minimum CPU percentage to include process -min_cpu_percent = 0.1 -# Minimum memory percentage to include process -min_memory_percent = 0.1 -# Truncate command names to this length -max_command_length = 50 - -[system] -enabled = true -# Additional system info to collect -include_uptime = true -include_load_average = true -include_kernel_version = true -include_os_info = true - -# MQTT Configuration (can be overridden by environment variables) -[mqtt] -host = "mqtt.home" -port = 1883 -client_id_prefix = "systant" -username = "" -password = "" -# Topics are auto-generated as: systant/{hostname}/stats and systant/{hostname}/commands -# QoS level (0, 1, or 2) -qos = 0 - -# Home Assistant MQTT Discovery Configuration -[homeassistant] -discovery_enabled = true # Enable/disable HA auto-discovery -discovery_prefix = "homeassistant" # HA discovery topic prefix - -[logging] -# Log level: "debug", "info", "warning", "error" -level = "info" -# Log configuration loading and metric collection details -log_config_changes = true -log_metric_collection = false - -# Command Execution Configuration -[commands] -enabled = true -# Security: only allow predefined commands, no arbitrary shell execution -max_execution_time = 30 # seconds -log_all_commands = true - -# Define your custom commands here - these are examples, customize for your system -[[commands.available]] -name = "screenshot" -trigger = "screenshot" -description = "Take a screenshot and save to ~/Pictures/Screenshots" -command = "grim /home/ryan/Pictures/Screenshots/screenshot-$(date +%Y%m%d-%H%M%S).png" - -[[commands.available]] -name = "lock_screen" -trigger = "lock" -description = "Lock the screen immediately" -command = "hyprctl dispatch exec swaylock" - -[[commands.available]] -name = "android_mirror_start" -trigger = "android_mirror_start" -description = "Start Android screen mirroring" -command = "scrcpy --tcpip=luna.home --window-title luna.scrcpy -S --audio-source=playback --audio-dup" -detached = true - -[[commands.available]] -name = "android_mirror_stop" -trigger = "android_mirror_stop" -description = "Stop Android screen mirroring" -command = "hyprctl clients -j | jq '. | map(select(.title == \"luna.scrcpy\")).[0].pid' | xargs kill" diff --git a/server/systant.toml b/server/systant.toml new file mode 120000 index 0000000..ae062ed --- /dev/null +++ b/server/systant.toml @@ -0,0 +1 @@ +/home/ryan/.config/systant/systant.toml \ No newline at end of file