Add ~/.local/bin to command PATH
This commit is contained in:
parent
b67824c1f6
commit
aa2bacc0cc
@ -1,13 +1,13 @@
|
|||||||
defmodule Systant.CommandExecutor do
|
defmodule Systant.CommandExecutor do
|
||||||
@moduledoc """
|
@moduledoc """
|
||||||
Secure command execution system for Systant.
|
Secure command execution system for Systant.
|
||||||
|
|
||||||
Executes only predefined commands from the configuration with strict validation,
|
Executes only predefined commands from the configuration with strict validation,
|
||||||
parameter checking, timeouts, and comprehensive logging.
|
parameter checking, timeouts, and comprehensive logging.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Execute a command based on MQTT command message
|
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, command_config} <- find_command_config(parsed_command.trigger, config),
|
||||||
{:ok, validated_params} <- validate_parameters(parsed_command.params, command_config),
|
{:ok, validated_params} <- validate_parameters(parsed_command.params, command_config),
|
||||||
{:ok, final_command} <- build_command(command_config, validated_params) do
|
{:ok, final_command} <- build_command(command_config, validated_params) do
|
||||||
|
Logger.info(
|
||||||
Logger.info("Executing command: #{command_config["name"]} with params: #{inspect(validated_params)}")
|
"Executing command: #{command_config["name"]} with params: #{inspect(validated_params)}"
|
||||||
|
)
|
||||||
|
|
||||||
execute_system_command(final_command, command_config, parsed_command)
|
execute_system_command(final_command, command_config, parsed_command)
|
||||||
else
|
else
|
||||||
{:error, reason} ->
|
{:error, reason} ->
|
||||||
@ -26,16 +27,16 @@ defmodule Systant.CommandExecutor do
|
|||||||
{:error, reason}
|
{:error, reason}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
List all available commands from configuration
|
List all available commands from configuration
|
||||||
"""
|
"""
|
||||||
def list_available_commands(config) do
|
def list_available_commands(config) do
|
||||||
commands_config = Systant.Config.get(config, ["commands"]) || %{}
|
commands_config = Systant.Config.get(config, ["commands"]) || %{}
|
||||||
|
|
||||||
if commands_config["enabled"] do
|
if commands_config["enabled"] do
|
||||||
available = commands_config["available"] || []
|
available = commands_config["available"] || []
|
||||||
|
|
||||||
Enum.map(available, fn cmd ->
|
Enum.map(available, fn cmd ->
|
||||||
%{
|
%{
|
||||||
name: cmd["name"],
|
name: cmd["name"],
|
||||||
@ -50,79 +51,82 @@ defmodule Systant.CommandExecutor do
|
|||||||
[]
|
[]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Private functions
|
# Private functions
|
||||||
|
|
||||||
defp parse_command(command_data) do
|
defp parse_command(command_data) do
|
||||||
case command_data do
|
case command_data do
|
||||||
%{"command" => trigger} = data when is_binary(trigger) ->
|
%{"command" => trigger} = data when is_binary(trigger) ->
|
||||||
{:ok, %{
|
{:ok,
|
||||||
trigger: trigger,
|
%{
|
||||||
params: data["params"] || [],
|
trigger: trigger,
|
||||||
request_id: data["request_id"] || generate_request_id(),
|
params: data["params"] || [],
|
||||||
timestamp: data["timestamp"] || DateTime.utc_now() |> DateTime.to_iso8601()
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
defp find_command_config(trigger, config) do
|
defp find_command_config(trigger, config) do
|
||||||
commands_config = Systant.Config.get(config, ["commands"]) || %{}
|
commands_config = Systant.Config.get(config, ["commands"]) || %{}
|
||||||
|
|
||||||
unless commands_config["enabled"] do
|
unless commands_config["enabled"] do
|
||||||
{:error, "Command execution is disabled in configuration"}
|
{:error, "Command execution is disabled in configuration"}
|
||||||
else
|
else
|
||||||
|
available = commands_config["available"] || []
|
||||||
available = commands_config["available"] || []
|
|
||||||
|
|
||||||
case Enum.find(available, fn cmd -> cmd["trigger"] == trigger end) do
|
case Enum.find(available, fn cmd -> cmd["trigger"] == trigger end) do
|
||||||
nil -> {:error, "Command '#{trigger}' not found in configuration"}
|
nil -> {:error, "Command '#{trigger}' not found in configuration"}
|
||||||
command_config -> {:ok, command_config}
|
command_config -> {:ok, command_config}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp validate_parameters(params, command_config) when is_list(params) do
|
defp validate_parameters(params, command_config) when is_list(params) do
|
||||||
allowed_params = command_config["allowed_params"] || []
|
allowed_params = command_config["allowed_params"] || []
|
||||||
|
|
||||||
# If no parameters are allowed, params must be empty
|
# If no parameters are allowed, params must be empty
|
||||||
if Enum.empty?(allowed_params) and not Enum.empty?(params) do
|
if Enum.empty?(allowed_params) and not Enum.empty?(params) do
|
||||||
{:error, "Command '#{command_config["trigger"]}' does not accept parameters"}
|
{:error, "Command '#{command_config["trigger"]}' does not accept parameters"}
|
||||||
else
|
else
|
||||||
# Validate each parameter against allowed list
|
# Validate each parameter against allowed list
|
||||||
invalid_params = Enum.reject(params, fn param ->
|
invalid_params =
|
||||||
Enum.member?(allowed_params, param)
|
Enum.reject(params, fn param ->
|
||||||
end)
|
Enum.member?(allowed_params, param)
|
||||||
|
end)
|
||||||
|
|
||||||
if Enum.empty?(invalid_params) do
|
if Enum.empty?(invalid_params) do
|
||||||
{:ok, params}
|
{:ok, params}
|
||||||
else
|
else
|
||||||
{:error, "Invalid parameters: #{inspect(invalid_params)}. Allowed: #{inspect(allowed_params)}"}
|
{:error,
|
||||||
|
"Invalid parameters: #{inspect(invalid_params)}. Allowed: #{inspect(allowed_params)}"}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp validate_parameters(_, _), do: {:error, "Parameters must be a list"}
|
defp validate_parameters(_, _), do: {:error, "Parameters must be a list"}
|
||||||
|
|
||||||
defp build_command(command_config, params) do
|
defp build_command(command_config, params) do
|
||||||
base_command = command_config["command"]
|
base_command = command_config["command"]
|
||||||
|
|
||||||
if is_binary(base_command) do
|
if is_binary(base_command) do
|
||||||
# Substitute parameters in the command string
|
# Substitute parameters in the command string
|
||||||
final_command_string = substitute_parameters_in_string(base_command, params)
|
final_command_string = substitute_parameters_in_string(base_command, params)
|
||||||
|
|
||||||
# If running as root and this looks like a Wayland command, wrap with sudo
|
# 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)
|
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
|
# Return the command string directly - we'll handle shell execution in execute_regular_command
|
||||||
{:ok, final_command_with_user}
|
{:ok, final_command_with_user}
|
||||||
else
|
else
|
||||||
{:error, "Command configuration must be a string"}
|
{:error, "Command configuration must be a string"}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp maybe_wrap_with_sudo(command_string, command_config) do
|
defp maybe_wrap_with_sudo(command_string, command_config) do
|
||||||
# Check if we're running as root and this command needs user privileges
|
# 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
|
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
|
case find_user_uid() do
|
||||||
{:ok, uid} ->
|
{:ok, uid} ->
|
||||||
"sudo -u '##{uid}' #{command_string}"
|
"sudo -u '##{uid}' #{command_string}"
|
||||||
|
|
||||||
{:error, _reason} ->
|
{:error, _reason} ->
|
||||||
command_string
|
command_string
|
||||||
end
|
end
|
||||||
@ -137,66 +142,73 @@ defmodule Systant.CommandExecutor do
|
|||||||
command_string
|
command_string
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp needs_user_privileges?(command_string, command_config) do
|
defp needs_user_privileges?(command_string, command_config) do
|
||||||
# Check if this is a Wayland command that needs user session
|
# Check if this is a Wayland command that needs user session
|
||||||
wayland_commands = ["grim", "hyprctl", "swaymsg", "wlr-", "waybar", "wofi"]
|
wayland_commands = ["grim", "hyprctl", "swaymsg", "wlr-", "waybar", "wofi"]
|
||||||
|
|
||||||
Enum.any?(wayland_commands, fn cmd ->
|
Enum.any?(wayland_commands, fn cmd ->
|
||||||
String.contains?(command_string, cmd)
|
String.contains?(command_string, cmd)
|
||||||
end) or command_config["run_as_user"] == true
|
end) or command_config["run_as_user"] == true
|
||||||
end
|
end
|
||||||
|
|
||||||
defp find_user_uid() do
|
defp find_user_uid() do
|
||||||
# Look for the first non-root user in /run/user/
|
# Look for the first non-root user in /run/user/
|
||||||
case File.ls("/run/user") do
|
case File.ls("/run/user") do
|
||||||
{:ok, dirs} ->
|
{:ok, dirs} ->
|
||||||
user_dirs = Enum.filter(dirs, fn dir ->
|
user_dirs =
|
||||||
String.match?(dir, ~r/^\d+$/) and dir != "0"
|
Enum.filter(dirs, fn dir ->
|
||||||
end)
|
String.match?(dir, ~r/^\d+$/) and dir != "0"
|
||||||
|
end)
|
||||||
|
|
||||||
case user_dirs do
|
case user_dirs do
|
||||||
[uid | _] -> {:ok, uid}
|
[uid | _] -> {:ok, uid}
|
||||||
[] -> {:error, "No user sessions found"}
|
[] -> {:error, "No user sessions found"}
|
||||||
end
|
end
|
||||||
|
|
||||||
{:error, reason} ->
|
{:error, reason} ->
|
||||||
{:error, "Cannot access /run/user: #{reason}"}
|
{:error, "Cannot access /run/user: #{reason}"}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp substitute_parameters_in_string(command_string, params) do
|
defp substitute_parameters_in_string(command_string, params) do
|
||||||
param_map = build_param_map(params)
|
param_map = build_param_map(params)
|
||||||
|
|
||||||
# Replace $VARIABLE patterns in the command string
|
# Replace $VARIABLE patterns in the command string
|
||||||
Enum.reduce(param_map, command_string, fn {var_name, value}, acc ->
|
Enum.reduce(param_map, command_string, fn {var_name, value}, acc ->
|
||||||
String.replace(acc, "$#{var_name}", value)
|
String.replace(acc, "$#{var_name}", value)
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
||||||
defp build_param_map(params) do
|
defp build_param_map(params) do
|
||||||
# For now, use simple mapping: first param is $SERVICE, $PATH, $PROCESS, $HOST, etc.
|
# For now, use simple mapping: first param is $SERVICE, $PATH, $PROCESS, $HOST, etc.
|
||||||
# In the future, could support named parameters
|
# In the future, could support named parameters
|
||||||
case params do
|
case params do
|
||||||
[param1] -> %{"SERVICE" => param1, "PATH" => param1, "PROCESS" => param1, "HOST" => param1}
|
[param1] ->
|
||||||
[param1, param2] -> %{"SERVICE" => param1, "PATH" => param2, "PROCESS" => param1, "HOST" => param1}
|
%{"SERVICE" => param1, "PATH" => param1, "PROCESS" => param1, "HOST" => param1}
|
||||||
_ -> %{}
|
|
||||||
|
[param1, param2] ->
|
||||||
|
%{"SERVICE" => param1, "PATH" => param2, "PROCESS" => param1, "HOST" => param1}
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
%{}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp build_command_environment() do
|
defp build_command_environment() do
|
||||||
# Get current environment
|
# Get current environment
|
||||||
env = System.get_env()
|
env = System.get_env()
|
||||||
|
|
||||||
# Start with current environment
|
# Start with current environment, but inject user's ~/.local/bin
|
||||||
enhanced_env = env
|
enhanced_env = Map.put(env, "PATH", "#{env["HOME"]}/.local/bin:#{env["PATH"]}")
|
||||||
|
|
||||||
# If running as root, add Wayland session environment for user commands
|
# If running as root, add Wayland session environment for user commands
|
||||||
if System.get_env("USER") == "root" do
|
if System.get_env("USER") == "root" do
|
||||||
# Find the user's Wayland session info
|
# Find the user's Wayland session info
|
||||||
case find_user_wayland_session() do
|
case find_user_wayland_session() do
|
||||||
{:ok, wayland_env} ->
|
{:ok, wayland_env} ->
|
||||||
Map.merge(enhanced_env, wayland_env)
|
Map.merge(enhanced_env, wayland_env)
|
||||||
|
|
||||||
{:error, _reason} ->
|
{:error, _reason} ->
|
||||||
enhanced_env
|
enhanced_env
|
||||||
end
|
end
|
||||||
@ -204,49 +216,55 @@ defmodule Systant.CommandExecutor do
|
|||||||
enhanced_env
|
enhanced_env
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp find_user_wayland_session() do
|
defp find_user_wayland_session() do
|
||||||
# Look for active Wayland sessions in /run/user/
|
# Look for active Wayland sessions in /run/user/
|
||||||
case File.ls("/run/user") do
|
case File.ls("/run/user") do
|
||||||
{:ok, dirs} ->
|
{:ok, dirs} ->
|
||||||
# Find the first user directory (typically 1000 for first user)
|
# Find the first user directory (typically 1000 for first user)
|
||||||
user_dirs = Enum.filter(dirs, fn dir ->
|
user_dirs =
|
||||||
String.match?(dir, ~r/^\d+$/) and File.exists?("/run/user/#{dir}/wayland-1")
|
Enum.filter(dirs, fn dir ->
|
||||||
end)
|
String.match?(dir, ~r/^\d+$/) and File.exists?("/run/user/#{dir}/wayland-1")
|
||||||
|
end)
|
||||||
|
|
||||||
case user_dirs do
|
case user_dirs do
|
||||||
[uid | _] ->
|
[uid | _] ->
|
||||||
runtime_dir = "/run/user/#{uid}"
|
runtime_dir = "/run/user/#{uid}"
|
||||||
{:ok, %{
|
|
||||||
"XDG_RUNTIME_DIR" => runtime_dir,
|
{:ok,
|
||||||
"WAYLAND_DISPLAY" => "wayland-1"
|
%{
|
||||||
}}
|
"XDG_RUNTIME_DIR" => runtime_dir,
|
||||||
|
"WAYLAND_DISPLAY" => "wayland-1"
|
||||||
|
}}
|
||||||
|
|
||||||
[] ->
|
[] ->
|
||||||
{:error, "No active Wayland sessions found"}
|
{:error, "No active Wayland sessions found"}
|
||||||
end
|
end
|
||||||
|
|
||||||
{:error, reason} ->
|
{:error, reason} ->
|
||||||
{:error, "Cannot access /run/user: #{reason}"}
|
{:error, "Cannot access /run/user: #{reason}"}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp execute_system_command(final_command, command_config, parsed_command) do
|
defp execute_system_command(final_command, command_config, parsed_command) do
|
||||||
is_detached = command_config["detached"] || false
|
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
|
# Build environment for command execution
|
||||||
env = build_command_environment()
|
env = build_command_environment()
|
||||||
|
|
||||||
if is_detached do
|
if is_detached do
|
||||||
Logger.info("Executing detached command: #{inspect(final_command)}")
|
Logger.info("Executing detached command: #{inspect(final_command)}")
|
||||||
else
|
else
|
||||||
Logger.info("Executing system command: #{inspect(final_command)} (timeout: #{timeout}ms)")
|
Logger.info("Executing system command: #{inspect(final_command)} (timeout: #{timeout}ms)")
|
||||||
end
|
end
|
||||||
|
|
||||||
Logger.debug("Environment PATH: #{Map.get(env, "PATH")}")
|
Logger.debug("Environment PATH: #{Map.get(env, "PATH")}")
|
||||||
Logger.debug("Environment USER: #{Map.get(env, "USER")}")
|
Logger.debug("Environment USER: #{Map.get(env, "USER")}")
|
||||||
Logger.debug("Environment HOME: #{Map.get(env, "HOME")}")
|
Logger.debug("Environment HOME: #{Map.get(env, "HOME")}")
|
||||||
Logger.debug("Environment XDG_RUNTIME_DIR: #{Map.get(env, "XDG_RUNTIME_DIR")}")
|
Logger.debug("Environment XDG_RUNTIME_DIR: #{Map.get(env, "XDG_RUNTIME_DIR")}")
|
||||||
|
|
||||||
if is_detached do
|
if is_detached do
|
||||||
# For detached processes, spawn and immediately return success
|
# For detached processes, spawn and immediately return success
|
||||||
execute_detached_command(final_command, env, parsed_command)
|
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)
|
execute_regular_command(final_command, env, timeout, parsed_command)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp execute_detached_command(command_string, env, parsed_command) do
|
defp execute_detached_command(command_string, env, parsed_command) do
|
||||||
try do
|
try do
|
||||||
# Use spawn to start process without waiting
|
# Use spawn to start process without waiting
|
||||||
port = Port.open({:spawn_executable, "/bin/sh"}, [
|
port =
|
||||||
:binary,
|
Port.open({:spawn_executable, "/bin/sh"}, [
|
||||||
:exit_status,
|
:binary,
|
||||||
args: ["-c", command_string],
|
:exit_status,
|
||||||
env: Enum.map(env, fn {k, v} -> {String.to_charlist(k), String.to_charlist(v)} end)
|
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
|
# Close the port immediately to detach
|
||||||
Port.close(port)
|
Port.close(port)
|
||||||
|
|
||||||
Logger.info("Detached command started successfully")
|
Logger.info("Detached command started successfully")
|
||||||
|
|
||||||
{:ok, %{
|
{:ok,
|
||||||
request_id: parsed_command.request_id,
|
%{
|
||||||
command: parsed_command.trigger,
|
request_id: parsed_command.request_id,
|
||||||
status: "success",
|
command: parsed_command.trigger,
|
||||||
output: "Command started in detached mode",
|
status: "success",
|
||||||
detached: true,
|
output: "Command started in detached mode",
|
||||||
timestamp: DateTime.utc_now() |> DateTime.to_iso8601()
|
detached: true,
|
||||||
}}
|
timestamp: DateTime.utc_now() |> DateTime.to_iso8601()
|
||||||
|
}}
|
||||||
rescue
|
rescue
|
||||||
error ->
|
error ->
|
||||||
Logger.error("Failed to start detached command: #{inspect(error)}")
|
Logger.error("Failed to start detached command: #{inspect(error)}")
|
||||||
|
|
||||||
{:ok, %{
|
{:ok,
|
||||||
request_id: parsed_command.request_id,
|
%{
|
||||||
command: parsed_command.trigger,
|
request_id: parsed_command.request_id,
|
||||||
status: "error",
|
command: parsed_command.trigger,
|
||||||
output: "",
|
status: "error",
|
||||||
error: "Failed to start detached command: #{inspect(error)}",
|
output: "",
|
||||||
timestamp: DateTime.utc_now() |> DateTime.to_iso8601()
|
error: "Failed to start detached command: #{inspect(error)}",
|
||||||
}}
|
timestamp: DateTime.utc_now() |> DateTime.to_iso8601()
|
||||||
|
}}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp execute_regular_command(command_string, env, timeout, parsed_command) do
|
defp execute_regular_command(command_string, env, timeout, parsed_command) do
|
||||||
start_time = System.monotonic_time(:millisecond)
|
start_time = System.monotonic_time(:millisecond)
|
||||||
|
|
||||||
# Wrap the command with PID tracking
|
# Wrap the command with PID tracking
|
||||||
wrapper_script = """
|
wrapper_script = """
|
||||||
echo "SYSTANT_PID:$$"
|
echo "SYSTANT_PID:$$"
|
||||||
exec #{command_string}
|
exec #{command_string}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
port = Port.open({:spawn_executable, "/bin/sh"}, [
|
port =
|
||||||
:binary,
|
Port.open({:spawn_executable, "/bin/sh"}, [
|
||||||
:exit_status,
|
:binary,
|
||||||
:stderr_to_stdout,
|
:exit_status,
|
||||||
args: ["-c", wrapper_script],
|
:stderr_to_stdout,
|
||||||
env: Enum.map(env, fn {k, v} -> {String.to_charlist(k), String.to_charlist(v)} end)
|
args: ["-c", wrapper_script],
|
||||||
])
|
env: Enum.map(env, fn {k, v} -> {String.to_charlist(k), String.to_charlist(v)} end)
|
||||||
|
])
|
||||||
|
|
||||||
# Set up monitoring
|
# Set up monitoring
|
||||||
ref = Port.monitor(port)
|
ref = Port.monitor(port)
|
||||||
|
|
||||||
# Collect output with PID extraction
|
# Collect output with PID extraction
|
||||||
output = collect_port_output_with_pid(port, ref, timeout, "", nil)
|
output = collect_port_output_with_pid(port, ref, timeout, "", nil)
|
||||||
|
|
||||||
case output do
|
case output do
|
||||||
{:ok, data, exit_status, _pid} ->
|
{:ok, data, exit_status, _pid} ->
|
||||||
execution_time = System.monotonic_time(:millisecond) - start_time
|
execution_time = System.monotonic_time(:millisecond) - start_time
|
||||||
|
|
||||||
case exit_status do
|
case exit_status do
|
||||||
0 ->
|
0 ->
|
||||||
Logger.info("Command completed successfully in #{execution_time}ms")
|
Logger.info("Command completed successfully in #{execution_time}ms")
|
||||||
|
|
||||||
{:ok, %{
|
{:ok,
|
||||||
request_id: parsed_command.request_id,
|
%{
|
||||||
command: parsed_command.trigger,
|
request_id: parsed_command.request_id,
|
||||||
status: "success",
|
command: parsed_command.trigger,
|
||||||
output: String.trim(data),
|
status: "success",
|
||||||
execution_time: execution_time / 1000.0,
|
output: String.trim(data),
|
||||||
timestamp: DateTime.utc_now() |> DateTime.to_iso8601()
|
execution_time: execution_time / 1000.0,
|
||||||
}}
|
timestamp: DateTime.utc_now() |> DateTime.to_iso8601()
|
||||||
|
}}
|
||||||
|
|
||||||
code ->
|
code ->
|
||||||
Logger.warning("Command failed with exit code #{code} in #{execution_time}ms")
|
Logger.warning("Command failed with exit code #{code} in #{execution_time}ms")
|
||||||
|
|
||||||
{:ok, %{
|
{:ok,
|
||||||
request_id: parsed_command.request_id,
|
%{
|
||||||
command: parsed_command.trigger,
|
request_id: parsed_command.request_id,
|
||||||
status: "error",
|
command: parsed_command.trigger,
|
||||||
output: String.trim(data),
|
status: "error",
|
||||||
error: "Command exited with code #{code}",
|
output: String.trim(data),
|
||||||
execution_time: execution_time / 1000.0,
|
error: "Command exited with code #{code}",
|
||||||
timestamp: DateTime.utc_now() |> DateTime.to_iso8601()
|
execution_time: execution_time / 1000.0,
|
||||||
}}
|
timestamp: DateTime.utc_now() |> DateTime.to_iso8601()
|
||||||
|
}}
|
||||||
end
|
end
|
||||||
|
|
||||||
{:timeout, partial_output, pid} ->
|
{:timeout, partial_output, pid} ->
|
||||||
execution_time = System.monotonic_time(:millisecond) - start_time
|
execution_time = System.monotonic_time(:millisecond) - start_time
|
||||||
|
|
||||||
# First, close the port to prevent more data
|
# First, close the port to prevent more data
|
||||||
try do
|
try do
|
||||||
Port.close(port)
|
Port.close(port)
|
||||||
rescue
|
rescue
|
||||||
_ -> :ok
|
_ -> :ok
|
||||||
end
|
end
|
||||||
|
|
||||||
# Kill the process group if we have a PID
|
# Kill the process group if we have a PID
|
||||||
if pid do
|
if pid do
|
||||||
kill_process_group(pid)
|
kill_process_group(pid)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Flush any remaining port messages to prevent them from going to other processes
|
# Flush any remaining port messages to prevent them from going to other processes
|
||||||
flush_port_messages(port)
|
flush_port_messages(port)
|
||||||
|
|
||||||
Logger.error("Command timed out after #{timeout}ms and was terminated")
|
Logger.error("Command timed out after #{timeout}ms and was terminated")
|
||||||
|
|
||||||
{:ok, %{
|
{:ok,
|
||||||
request_id: parsed_command.request_id,
|
%{
|
||||||
command: parsed_command.trigger,
|
request_id: parsed_command.request_id,
|
||||||
status: "error",
|
command: parsed_command.trigger,
|
||||||
output: String.trim(partial_output),
|
status: "error",
|
||||||
error: "Command timed out after #{timeout / 1000} seconds and was terminated",
|
output: String.trim(partial_output),
|
||||||
execution_time: execution_time / 1000.0,
|
error: "Command timed out after #{timeout / 1000} seconds and was terminated",
|
||||||
timestamp: DateTime.utc_now() |> DateTime.to_iso8601()
|
execution_time: execution_time / 1000.0,
|
||||||
}}
|
timestamp: DateTime.utc_now() |> DateTime.to_iso8601()
|
||||||
|
}}
|
||||||
|
|
||||||
{:error, reason} ->
|
{:error, reason} ->
|
||||||
execution_time = System.monotonic_time(:millisecond) - start_time
|
execution_time = System.monotonic_time(:millisecond) - start_time
|
||||||
Logger.error("Command execution failed: #{inspect(reason)}")
|
Logger.error("Command execution failed: #{inspect(reason)}")
|
||||||
|
|
||||||
{:ok, %{
|
{:ok,
|
||||||
request_id: parsed_command.request_id,
|
%{
|
||||||
command: parsed_command.trigger,
|
request_id: parsed_command.request_id,
|
||||||
status: "error",
|
command: parsed_command.trigger,
|
||||||
output: "",
|
status: "error",
|
||||||
error: "Execution failed: #{inspect(reason)}",
|
output: "",
|
||||||
execution_time: execution_time / 1000.0,
|
error: "Execution failed: #{inspect(reason)}",
|
||||||
timestamp: DateTime.utc_now() |> DateTime.to_iso8601()
|
execution_time: execution_time / 1000.0,
|
||||||
}}
|
timestamp: DateTime.utc_now() |> DateTime.to_iso8601()
|
||||||
|
}}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
||||||
defp kill_process_group(pid) when is_integer(pid) do
|
defp kill_process_group(pid) when is_integer(pid) do
|
||||||
# Kill the entire process group
|
# Kill the entire process group
|
||||||
Logger.info("Killing process group for PID #{pid}")
|
Logger.info("Killing process group for PID #{pid}")
|
||||||
System.cmd("kill", ["-TERM", "-#{pid}"], stderr_to_stdout: true)
|
System.cmd("kill", ["-TERM", "-#{pid}"], stderr_to_stdout: true)
|
||||||
|
|
||||||
# Give it a moment to terminate gracefully
|
# Give it a moment to terminate gracefully
|
||||||
Process.sleep(100)
|
Process.sleep(100)
|
||||||
|
|
||||||
# Force kill if still alive
|
# Force kill if still alive
|
||||||
case System.cmd("kill", ["-0", "#{pid}"], stderr_to_stdout: true) do
|
case System.cmd("kill", ["-0", "#{pid}"], stderr_to_stdout: true) do
|
||||||
{_, 0} ->
|
{_, 0} ->
|
||||||
Logger.warning("Process #{pid} still alive, sending SIGKILL")
|
Logger.warning("Process #{pid} still alive, sending SIGKILL")
|
||||||
System.cmd("kill", ["-KILL", "-#{pid}"], stderr_to_stdout: true)
|
System.cmd("kill", ["-KILL", "-#{pid}"], stderr_to_stdout: true)
|
||||||
|
|
||||||
_ ->
|
_ ->
|
||||||
:ok
|
:ok
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp kill_process_group(_), do: :ok
|
defp kill_process_group(_), do: :ok
|
||||||
|
|
||||||
defp flush_port_messages(port) do
|
defp flush_port_messages(port) do
|
||||||
receive do
|
receive do
|
||||||
{^port, _} ->
|
{^port, _} ->
|
||||||
@ -425,22 +452,21 @@ defmodule Systant.CommandExecutor do
|
|||||||
:ok
|
:ok
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp collect_port_output_with_pid(port, ref, timeout, acc, pid) do
|
defp collect_port_output_with_pid(port, ref, timeout, acc, pid) do
|
||||||
receive do
|
receive do
|
||||||
{^port, {:data, data}} ->
|
{^port, {:data, data}} ->
|
||||||
# Extract PID if we see it in the output
|
# Extract PID if we see it in the output
|
||||||
{new_pid, cleaned_data} = extract_pid(data, pid)
|
{new_pid, cleaned_data} = extract_pid(data, pid)
|
||||||
collect_port_output_with_pid(port, ref, timeout, acc <> cleaned_data, new_pid)
|
collect_port_output_with_pid(port, ref, timeout, acc <> cleaned_data, new_pid)
|
||||||
|
|
||||||
{^port, {:exit_status, status}} ->
|
{^port, {:exit_status, status}} ->
|
||||||
# Demonitor to avoid receiving DOWN message
|
# Demonitor to avoid receiving DOWN message
|
||||||
Port.demonitor(ref, [:flush])
|
Port.demonitor(ref, [:flush])
|
||||||
{:ok, acc, status, pid}
|
{:ok, acc, status, pid}
|
||||||
|
|
||||||
{:DOWN, ^ref, :port, ^port, reason} ->
|
{:DOWN, ^ref, :port, ^port, reason} ->
|
||||||
{:error, reason}
|
{:error, reason}
|
||||||
|
|
||||||
after
|
after
|
||||||
timeout ->
|
timeout ->
|
||||||
# Demonitor to avoid receiving DOWN message after timeout
|
# Demonitor to avoid receiving DOWN message after timeout
|
||||||
@ -448,19 +474,20 @@ defmodule Systant.CommandExecutor do
|
|||||||
{:timeout, acc, pid}
|
{:timeout, acc, pid}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp extract_pid(data, current_pid) do
|
defp extract_pid(data, current_pid) do
|
||||||
case Regex.run(~r/SYSTANT_PID:(\d+)\n/, data) do
|
case Regex.run(~r/SYSTANT_PID:(\d+)\n/, data) do
|
||||||
[full_match, pid_str] ->
|
[full_match, pid_str] ->
|
||||||
pid = String.to_integer(pid_str)
|
pid = String.to_integer(pid_str)
|
||||||
cleaned = String.replace(data, full_match, "")
|
cleaned = String.replace(data, full_match, "")
|
||||||
{pid, cleaned}
|
{pid, cleaned}
|
||||||
|
|
||||||
nil ->
|
nil ->
|
||||||
{current_pid, data}
|
{current_pid, data}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp generate_request_id do
|
defp generate_request_id do
|
||||||
:crypto.strong_rand_bytes(16) |> Base.encode16(case: :lower)
|
:crypto.strong_rand_bytes(16) |> Base.encode16(case: :lower)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@ -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"
|
|
||||||
1
server/systant.toml
Symbolic link
1
server/systant.toml
Symbolic link
@ -0,0 +1 @@
|
|||||||
|
/home/ryan/.config/systant/systant.toml
|
||||||
Loading…
Reference in New Issue
Block a user