- Add comprehensive command configuration to systant.toml with user-defined commands
- Create Systant.CommandExecutor module with strict security validation:
* Whitelist-only command execution (no arbitrary shell commands)
* Parameter validation against allowed lists
* Command timeouts and confirmation requirements
* Full audit logging and response tracking
- Implement Systant.MqttHandler for processing command messages:
* JSON command parsing and validation
* Response publishing to systant/{hostname}/responses topic
* Built-in "list" command to show available commands
* Error handling with detailed response messages
- Update MqttClient to use custom handler instead of Logger
- Security features:
* Only predefined commands from TOML config
* Parameter substitution with validation ($SERVICE, $PATH, etc.)
* Execution timeouts and comprehensive logging
* Structured response format with request tracking
Example commands configured: restart services, system info, disk usage, process status, network tests.
Users can customize commands in their systant.toml file.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>
199 lines
6.6 KiB
Elixir
199 lines
6.6 KiB
Elixir
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
|
|
"""
|
|
def execute_command(command_data, config) do
|
|
with {:ok, parsed_command} <- parse_command(command_data),
|
|
{: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)}")
|
|
|
|
execute_system_command(final_command, command_config, parsed_command)
|
|
else
|
|
{:error, reason} ->
|
|
Logger.warning("Command execution failed: #{reason}")
|
|
{: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"],
|
|
description: cmd["description"],
|
|
trigger: cmd["trigger"],
|
|
allowed_params: cmd["allowed_params"] || [],
|
|
requires_confirmation: cmd["requires_confirmation"] || false,
|
|
timeout: cmd["timeout"] || 10
|
|
}
|
|
end)
|
|
else
|
|
[]
|
|
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"]
|
|
}}
|
|
|
|
_ ->
|
|
{: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"] || []
|
|
|
|
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)
|
|
|
|
if Enum.empty?(invalid_params) do
|
|
{:ok, params}
|
|
else
|
|
{: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_list(base_command) do
|
|
final_command = substitute_parameters(base_command, params)
|
|
{:ok, final_command}
|
|
else
|
|
{:error, "Command configuration must be a list"}
|
|
end
|
|
end
|
|
|
|
defp substitute_parameters(command_parts, params) do
|
|
param_map = build_param_map(params)
|
|
|
|
Enum.map(command_parts, fn part ->
|
|
case part do
|
|
"$" <> var_name ->
|
|
Map.get(param_map, var_name, part)
|
|
_ ->
|
|
part
|
|
end
|
|
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}
|
|
_ -> %{}
|
|
end
|
|
end
|
|
|
|
defp execute_system_command(final_command, command_config, parsed_command) do
|
|
timeout = (command_config["timeout"] || 10) * 1000 # Convert to milliseconds
|
|
start_time = System.monotonic_time(:millisecond)
|
|
|
|
Logger.info("Executing system command: #{inspect(final_command)} (timeout: #{timeout}ms)")
|
|
|
|
try do
|
|
case System.cmd(List.first(final_command), Enum.drop(final_command, 1), stderr_to_stdout: true) do
|
|
{output, 0} ->
|
|
execution_time = System.monotonic_time(:millisecond) - start_time
|
|
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(output),
|
|
error: nil,
|
|
execution_time: execution_time / 1000.0,
|
|
timestamp: DateTime.utc_now() |> DateTime.to_iso8601()
|
|
}}
|
|
|
|
{output, exit_code} ->
|
|
execution_time = System.monotonic_time(:millisecond) - start_time
|
|
Logger.warning("Command failed with exit code #{exit_code} in #{execution_time}ms")
|
|
|
|
{:ok, %{
|
|
request_id: parsed_command.request_id,
|
|
command: parsed_command.trigger,
|
|
status: "error",
|
|
output: String.trim(output),
|
|
error: "Command exited with code #{exit_code}",
|
|
execution_time: execution_time / 1000.0,
|
|
timestamp: DateTime.utc_now() |> DateTime.to_iso8601()
|
|
}}
|
|
end
|
|
rescue
|
|
error ->
|
|
execution_time = System.monotonic_time(:millisecond) - start_time
|
|
Logger.error("Command execution failed: #{inspect(error)}")
|
|
|
|
{:ok, %{
|
|
request_id: parsed_command.request_id,
|
|
command: parsed_command.trigger,
|
|
status: "error",
|
|
output: "",
|
|
error: "Execution failed: #{inspect(error)}",
|
|
execution_time: execution_time / 1000.0,
|
|
timestamp: DateTime.utc_now() |> DateTime.to_iso8601()
|
|
}}
|
|
end
|
|
end
|
|
|
|
defp generate_request_id do
|
|
:crypto.strong_rand_bytes(16) |> Base.encode16(case: :lower)
|
|
end
|
|
end |