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

@ -16,8 +16,9 @@ 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
@ -56,15 +57,17 @@ defmodule Systant.CommandExecutor do
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
@ -74,8 +77,7 @@ defmodule Systant.CommandExecutor do
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"}
@ -92,14 +94,16 @@ defmodule Systant.CommandExecutor 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
@ -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
@ -151,14 +156,16 @@ defmodule Systant.CommandExecutor 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
@ -173,14 +180,18 @@ defmodule Systant.CommandExecutor do
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
@ -188,8 +199,8 @@ defmodule Systant.CommandExecutor 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
@ -197,6 +208,7 @@ defmodule Systant.CommandExecutor do
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
@ -210,20 +222,25 @@ defmodule Systant.CommandExecutor do
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
@ -231,7 +248,8 @@ defmodule Systant.CommandExecutor do
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()
@ -259,38 +277,41 @@ defmodule Systant.CommandExecutor do
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
@ -303,13 +324,14 @@ defmodule Systant.CommandExecutor do
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)
@ -325,27 +347,29 @@ defmodule Systant.CommandExecutor 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} ->
@ -368,33 +392,34 @@ defmodule Systant.CommandExecutor do
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}")
@ -408,10 +433,12 @@ defmodule Systant.CommandExecutor 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
@ -440,7 +467,6 @@ defmodule Systant.CommandExecutor do
{: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
@ -455,6 +481,7 @@ defmodule Systant.CommandExecutor do
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

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