Add ~/.local/bin to command PATH

This commit is contained in:
2025-08-30 18:53:20 -07:00
parent b67824c1f6
commit aa2bacc0cc
2 changed files with 198 additions and 313 deletions
+197 -170
View File
@@ -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
end