Fix commands

This commit is contained in:
ryan 2025-08-10 20:34:22 -07:00
parent 988a38b1f9
commit 2d948073b2
5 changed files with 208 additions and 74 deletions

View File

@ -1,4 +1,10 @@
{ config, lib, pkgs, ... }: {
config,
lib,
pkgs,
inputs,
...
}:
with lib; with lib;
@ -11,6 +17,7 @@ in
package = mkOption { package = mkOption {
type = types.package; type = types.package;
default = pkgs.callPackage ./package.nix { inherit inputs; };
description = "The systant package to use"; description = "The systant package to use";
}; };
@ -58,10 +65,10 @@ in
}; };
config = mkIf cfg.enable { config = mkIf cfg.enable {
systemd.services.systant = { systemd.user.services.systant = {
description = "Systant MQTT Daemon"; description = "Systant MQTT Daemon";
after = [ "network.target" ]; after = [ "network.target" ];
wantedBy = [ "multi-user.target" ]; wantedBy = [ "default.target" ];
environment = { environment = {
SYSTANT_MQTT_HOST = cfg.mqttHost; SYSTANT_MQTT_HOST = cfg.mqttHost;
@ -77,8 +84,6 @@ in
serviceConfig = { serviceConfig = {
Type = "exec"; Type = "exec";
User = "root";
Group = "root";
ExecStart = "${cfg.package}/bin/systant start"; ExecStart = "${cfg.package}/bin/systant start";
ExecStop = "${cfg.package}/bin/systant stop"; ExecStop = "${cfg.package}/bin/systant stop";
Restart = "always"; Restart = "always";
@ -87,13 +92,7 @@ in
StandardError = "journal"; StandardError = "journal";
SyslogIdentifier = "systant"; SyslogIdentifier = "systant";
WorkingDirectory = "${cfg.package}"; WorkingDirectory = "${cfg.package}";
# Security settings
NoNewPrivileges = true;
PrivateTmp = true;
ProtectHome = true;
ProtectSystem = false; # Need access to system stats
}; };
}; };
}; };
} }

View File

@ -42,7 +42,6 @@ defmodule Systant.CommandExecutor do
description: cmd["description"], description: cmd["description"],
trigger: cmd["trigger"], trigger: cmd["trigger"],
allowed_params: cmd["allowed_params"] || [], allowed_params: cmd["allowed_params"] || [],
requires_confirmation: cmd["requires_confirmation"] || false,
timeout: cmd["timeout"] || 10 timeout: cmd["timeout"] || 10
} }
end) end)
@ -109,14 +108,70 @@ defmodule Systant.CommandExecutor do
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_list(base_command) do if is_binary(base_command) do
final_command = substitute_parameters(base_command, params) # Substitute parameters in the command string
{:ok, final_command} 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)
# Always wrap in sh -c for shell features (variables, pipes, etc.)
{:ok, ["sh", "-c", final_command_with_user]}
else else
{:error, "Command configuration must be a list"} {:error, "Command configuration must be a string"}
end end
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
# Get the first non-root user ID (typically 1000)
case find_user_uid() do
{:ok, uid} ->
"sudo -u '##{uid}' #{command_string}"
{:error, _reason} ->
command_string
end
else
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)
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 substitute_parameters(command_parts, params) do defp substitute_parameters(command_parts, params) do
param_map = build_param_map(params) param_map = build_param_map(params)
@ -140,14 +195,60 @@ defmodule Systant.CommandExecutor do
end end
end end
defp build_command_environment() do
# Get current environment
env = System.get_env()
# 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(env, wayland_env)
{:error, _reason} ->
env
end
else
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)
case user_dirs do
[uid | _] ->
runtime_dir = "/run/user/#{uid}"
{: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 defp execute_system_command(final_command, command_config, parsed_command) do
timeout = (command_config["timeout"] || 10) * 1000 # Convert to milliseconds timeout = (command_config["timeout"] || 10) * 1000 # Convert to milliseconds
start_time = System.monotonic_time(:millisecond) start_time = System.monotonic_time(:millisecond)
# Build environment for command execution
env = build_command_environment()
Logger.info("Executing system command: #{inspect(final_command)} (timeout: #{timeout}ms)") Logger.info("Executing system command: #{inspect(final_command)} (timeout: #{timeout}ms)")
try do try do
case System.cmd(List.first(final_command), Enum.drop(final_command, 1), stderr_to_stdout: true) do case System.cmd(List.first(final_command), Enum.drop(final_command, 1),
stderr_to_stdout: true, timeout: timeout, env: env) do
{output, 0} -> {output, 0} ->
execution_time = System.monotonic_time(:millisecond) - start_time execution_time = System.monotonic_time(:millisecond) - start_time
Logger.info("Command completed successfully in #{execution_time}ms") Logger.info("Command completed successfully in #{execution_time}ms")

View File

@ -29,6 +29,12 @@ defmodule Systant.HaDiscovery do
# Publish sensor discoveries # Publish sensor discoveries
publish_sensor_discoveries(client_pid, hostname, device_config, discovery_prefix) publish_sensor_discoveries(client_pid, hostname, device_config, discovery_prefix)
# Publish command buttons if commands are enabled
commands_config = Systant.Config.get(app_config, ["commands"]) || %{}
if commands_config["enabled"] do
publish_command_discoveries(client_pid, hostname, device_config, discovery_prefix, app_config)
end
Logger.info("Published Home Assistant discovery for #{hostname}") Logger.info("Published Home Assistant discovery for #{hostname}")
else else
@ -81,6 +87,31 @@ defmodule Systant.HaDiscovery do
Tortoise.publish(client_pid, topic, payload, retain: true) Tortoise.publish(client_pid, topic, payload, retain: true)
end) end)
end end
defp publish_command_discoveries(client_pid, hostname, device_config, discovery_prefix, app_config) do
commands_config = Systant.Config.get(app_config, ["commands"]) || %{}
available_commands = commands_config["available"] || []
# Clear stale command slots (up to 2x current command count, minimum 10)
max_slots = max(length(available_commands) * 2, 10)
for i <- 0..(max_slots - 1) do
topic = "#{discovery_prefix}/button/#{hostname}/command_#{i}/config"
Tortoise.publish(client_pid, topic, "", retain: true)
end
# Publish actual command buttons
available_commands
|> Enum.with_index()
|> Enum.each(fn {cmd, index} ->
button_config = build_command_button_config(cmd, hostname, device_config)
topic = "#{discovery_prefix}/button/#{hostname}/command_#{index}/config"
payload = Jason.encode!(button_config)
Tortoise.publish(client_pid, topic, payload, retain: true)
end)
Logger.info("Published #{length(available_commands)} command buttons for #{hostname}")
end
defp build_device_config(hostname) do defp build_device_config(hostname) do
%{ %{
@ -264,9 +295,9 @@ defmodule Systant.HaDiscovery do
"{{ (value_json.network[0].tx_throughput_bps | float / 1024 / 1024) | round(2) if value_json.network and value_json.network|length > 0 else 0 }}" "{{ (value_json.network[0].tx_throughput_bps | float / 1024 / 1024) | round(2) if value_json.network and value_json.network|length > 0 else 0 }}"
)}, )},
# Binary Sensors for status # Status sensors
{"binary_sensor", "system_online", {"sensor", "last_seen",
build_binary_sensor_config("System Online", "#{base_topic}", "mdi:server", "connectivity")} build_sensor_config("Last Seen", "#{base_topic}", "timestamp", nil, "mdi:clock-outline", "{{ value_json.timestamp }}")}
] ]
end end
@ -278,6 +309,7 @@ defmodule Systant.HaDiscovery do
icon, icon,
custom_template \\ nil custom_template \\ nil
) do ) do
base_config = %{ base_config = %{
name: name, name: name,
state_topic: state_topic, state_topic: state_topic,
@ -285,6 +317,14 @@ defmodule Systant.HaDiscovery do
icon: icon, icon: icon,
unique_id: unique_id:
"systant_#{String.replace(state_topic, "/", "_")}_#{String.replace(value_template_path, ".", "_")}", "systant_#{String.replace(state_topic, "/", "_")}_#{String.replace(value_template_path, ".", "_")}",
availability: %{
topic: state_topic,
value_template: """
{% set last_seen = as_timestamp(value_json.timestamp) %}
{% set now = as_timestamp(now()) %}
{{ 'online' if (now - last_seen) < 180 else 'offline' }}
"""
},
origin: %{ origin: %{
name: "Systant", name: "Systant",
sw_version: Application.spec(:systant, :vsn) |> to_string(), sw_version: Application.spec(:systant, :vsn) |> to_string(),
@ -299,14 +339,27 @@ defmodule Systant.HaDiscovery do
end end
end end
defp build_binary_sensor_config(name, state_topic, icon, device_class) do
defp build_command_button_config(cmd, hostname, device_config) do
trigger = cmd["trigger"]
name = cmd["description"] || "#{String.capitalize(trigger)} Command"
icon = cmd["icon"] || "mdi:console-line"
%{ %{
name: name, name: name,
state_topic: state_topic, command_topic: "systant/#{hostname}/commands",
value_template: "{{ 'ON' if value_json.timestamp else 'OFF' }}", payload_press: Jason.encode!(%{command: trigger}),
device_class: device_class, availability: %{
topic: "systant/#{hostname}/stats",
value_template: """
{% set last_seen = as_timestamp(value_json.timestamp) %}
{% set now = as_timestamp(now()) %}
{{ 'online' if (now - last_seen) < 180 else 'offline' }}
"""
},
device: device_config,
icon: icon, icon: icon,
unique_id: "systant_#{String.replace(state_topic, "/", "_")}_online", unique_id: "systant_#{hostname}_command_#{trigger}",
origin: %{ origin: %{
name: "Systant", name: "Systant",
sw_version: Application.spec(:systant, :vsn) |> to_string(), sw_version: Application.spec(:systant, :vsn) |> to_string(),

View File

@ -18,11 +18,16 @@ defmodule Systant.MqttClient do
Logger.info("Starting MQTT client with config: #{inspect(mqtt_config)}") Logger.info("Starting MQTT client with config: #{inspect(mqtt_config)}")
Logger.info("Attempting to connect to MQTT broker at #{mqtt_config.host}:#{mqtt_config.port}") Logger.info("Attempting to connect to MQTT broker at #{mqtt_config.host}:#{mqtt_config.port}")
# Get hostname using same method as SystemMetrics
{:ok, hostname_charlist} = :inet.gethostname()
hostname = List.to_string(hostname_charlist)
# Store both configs for later use # Store both configs for later use
state_config = %{ state_config = %{
app_config: app_config, app_config: app_config,
mqtt_config: mqtt_config, mqtt_config: mqtt_config,
previous_network_stats: nil previous_network_stats: nil,
hostname: hostname
} }
connection_opts = [ connection_opts = [

View File

@ -3,11 +3,20 @@
[general] [general]
# Enable/disable entire metric categories # Enable/disable entire metric categories
enabled_modules = ["cpu", "memory", "disk", "gpu", "network", "temperature", "processes", "system"] enabled_modules = [
"cpu",
"memory",
"disk",
"gpu",
"network",
"temperature",
"processes",
"system",
]
# Collection intervals (in milliseconds) # Collection intervals (in milliseconds)
collection_interval = 30000 # 30 seconds collection_interval = 30000 # 30 seconds
startup_delay = 5000 # 5 seconds startup_delay = 5000 # 5 seconds
[cpu] [cpu]
# CPU metrics are always lightweight, no specific options needed # CPU metrics are always lightweight, no specific options needed
@ -90,8 +99,8 @@ qos = 0
# Home Assistant MQTT Discovery Configuration # Home Assistant MQTT Discovery Configuration
[homeassistant] [homeassistant]
discovery_enabled = true # Enable/disable HA auto-discovery discovery_enabled = true # Enable/disable HA auto-discovery
discovery_prefix = "homeassistant" # HA discovery topic prefix discovery_prefix = "homeassistant" # HA discovery topic prefix
[logging] [logging]
# Log level: "debug", "info", "warning", "error" # Log level: "debug", "info", "warning", "error"
@ -104,51 +113,18 @@ log_metric_collection = false
[commands] [commands]
enabled = true enabled = true
# Security: only allow predefined commands, no arbitrary shell execution # Security: only allow predefined commands, no arbitrary shell execution
max_execution_time = 30 # seconds max_execution_time = 30 # seconds
log_all_commands = true log_all_commands = true
# Define your custom commands here - these are examples, customize for your system # Define your custom commands here - these are examples, customize for your system
[[commands.available]] [[commands.available]]
name = "restart_service" name = "screenshot"
description = "Restart a system service" trigger = "screenshot"
trigger = "restart" description = "Take a screenshot and save to home directory"
command = ["systemctl", "restart", "$SERVICE"] command = "grim /home/ryan/screenshot-$(date +%Y%m%d-%H%M%S).png"
allowed_params = ["nginx", "postgresql", "redis", "docker", "ssh"]
timeout = 30
requires_confirmation = true
[[commands.available]] [[commands.available]]
name = "system_info" name = "lock_screen"
description = "Get system information" trigger = "lock"
trigger = "info" description = "Lock the screen immediately"
command = ["uname", "-a"] command = "hyprctl dispatch exec swaylock"
allowed_params = []
timeout = 10
requires_confirmation = false
[[commands.available]]
name = "disk_usage"
description = "Check disk usage for specific paths"
trigger = "df"
command = ["df", "-h", "$PATH"]
allowed_params = ["/", "/home", "/var", "/tmp"]
timeout = 5
requires_confirmation = false
[[commands.available]]
name = "process_status"
description = "Check if a process is running"
trigger = "ps"
command = ["pgrep", "-f", "$PROCESS"]
allowed_params = ["nginx", "postgres", "redis", "docker", "systemd"]
timeout = 5
requires_confirmation = false
[[commands.available]]
name = "network_test"
description = "Test network connectivity"
trigger = "ping"
command = ["ping", "-c", "3", "$HOST"]
allowed_params = ["google.com", "1.1.1.1", "localhost"]
timeout = 15
requires_confirmation = false