Fix commands
This commit is contained in:
parent
988a38b1f9
commit
2d948073b2
@ -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,12 +92,6 @@ 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
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
@ -30,6 +30,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
|
||||||
Logger.info("Home Assistant discovery disabled in configuration")
|
Logger.info("Home Assistant discovery disabled in configuration")
|
||||||
@ -82,6 +88,31 @@ defmodule Systant.HaDiscovery do
|
|||||||
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
|
||||||
%{
|
%{
|
||||||
identifiers: ["systant_#{hostname}"],
|
identifiers: ["systant_#{hostname}"],
|
||||||
@ -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(),
|
||||||
|
|||||||
@ -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 = [
|
||||||
|
|||||||
@ -3,7 +3,16 @@
|
|||||||
|
|
||||||
[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
|
||||||
@ -109,46 +118,13 @@ 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
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user