Add ~/.local/bin to command PATH

This commit is contained in:
ryan 2025-08-30 18:53:20 -07:00
parent b67824c1f6
commit aa2bacc0cc
2 changed files with 198 additions and 313 deletions

View File

@ -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

View File

@ -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
View File

@ -0,0 +1 @@
/home/ryan/.config/systant/systant.toml