Fix commands
This commit is contained in:
parent
988a38b1f9
commit
2d948073b2
@ -1,4 +1,10 @@
|
||||
{ config, lib, pkgs, ... }:
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
inputs,
|
||||
...
|
||||
}:
|
||||
|
||||
with lib;
|
||||
|
||||
@ -11,6 +17,7 @@ in
|
||||
|
||||
package = mkOption {
|
||||
type = types.package;
|
||||
default = pkgs.callPackage ./package.nix { inherit inputs; };
|
||||
description = "The systant package to use";
|
||||
};
|
||||
|
||||
@ -58,10 +65,10 @@ in
|
||||
};
|
||||
|
||||
config = mkIf cfg.enable {
|
||||
systemd.services.systant = {
|
||||
systemd.user.services.systant = {
|
||||
description = "Systant MQTT Daemon";
|
||||
after = [ "network.target" ];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
wantedBy = [ "default.target" ];
|
||||
|
||||
environment = {
|
||||
SYSTANT_MQTT_HOST = cfg.mqttHost;
|
||||
@ -77,8 +84,6 @@ in
|
||||
|
||||
serviceConfig = {
|
||||
Type = "exec";
|
||||
User = "root";
|
||||
Group = "root";
|
||||
ExecStart = "${cfg.package}/bin/systant start";
|
||||
ExecStop = "${cfg.package}/bin/systant stop";
|
||||
Restart = "always";
|
||||
@ -87,12 +92,6 @@ in
|
||||
StandardError = "journal";
|
||||
SyslogIdentifier = "systant";
|
||||
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"],
|
||||
trigger: cmd["trigger"],
|
||||
allowed_params: cmd["allowed_params"] || [],
|
||||
requires_confirmation: cmd["requires_confirmation"] || false,
|
||||
timeout: cmd["timeout"] || 10
|
||||
}
|
||||
end)
|
||||
@ -109,14 +108,70 @@ defmodule Systant.CommandExecutor do
|
||||
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}
|
||||
if is_binary(base_command) do
|
||||
# Substitute parameters in the command string
|
||||
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
|
||||
{:error, "Command configuration must be a list"}
|
||||
{:error, "Command configuration must be a string"}
|
||||
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
|
||||
param_map = build_param_map(params)
|
||||
|
||||
@ -140,14 +195,60 @@ defmodule Systant.CommandExecutor do
|
||||
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
|
||||
timeout = (command_config["timeout"] || 10) * 1000 # Convert to milliseconds
|
||||
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)")
|
||||
|
||||
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} ->
|
||||
execution_time = System.monotonic_time(:millisecond) - start_time
|
||||
Logger.info("Command completed successfully in #{execution_time}ms")
|
||||
|
||||
@ -30,6 +30,12 @@ defmodule Systant.HaDiscovery do
|
||||
# Publish sensor discoveries
|
||||
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}")
|
||||
else
|
||||
Logger.info("Home Assistant discovery disabled in configuration")
|
||||
@ -82,6 +88,31 @@ defmodule Systant.HaDiscovery do
|
||||
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
|
||||
%{
|
||||
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 }}"
|
||||
)},
|
||||
|
||||
# Binary Sensors for status
|
||||
{"binary_sensor", "system_online",
|
||||
build_binary_sensor_config("System Online", "#{base_topic}", "mdi:server", "connectivity")}
|
||||
# Status sensors
|
||||
{"sensor", "last_seen",
|
||||
build_sensor_config("Last Seen", "#{base_topic}", "timestamp", nil, "mdi:clock-outline", "{{ value_json.timestamp }}")}
|
||||
]
|
||||
end
|
||||
|
||||
@ -278,6 +309,7 @@ defmodule Systant.HaDiscovery do
|
||||
icon,
|
||||
custom_template \\ nil
|
||||
) do
|
||||
|
||||
base_config = %{
|
||||
name: name,
|
||||
state_topic: state_topic,
|
||||
@ -285,6 +317,14 @@ defmodule Systant.HaDiscovery do
|
||||
icon: icon,
|
||||
unique_id:
|
||||
"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: %{
|
||||
name: "Systant",
|
||||
sw_version: Application.spec(:systant, :vsn) |> to_string(),
|
||||
@ -299,14 +339,27 @@ defmodule Systant.HaDiscovery do
|
||||
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,
|
||||
state_topic: state_topic,
|
||||
value_template: "{{ 'ON' if value_json.timestamp else 'OFF' }}",
|
||||
device_class: device_class,
|
||||
command_topic: "systant/#{hostname}/commands",
|
||||
payload_press: Jason.encode!(%{command: trigger}),
|
||||
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,
|
||||
unique_id: "systant_#{String.replace(state_topic, "/", "_")}_online",
|
||||
unique_id: "systant_#{hostname}_command_#{trigger}",
|
||||
origin: %{
|
||||
name: "Systant",
|
||||
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("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
|
||||
state_config = %{
|
||||
app_config: app_config,
|
||||
mqtt_config: mqtt_config,
|
||||
previous_network_stats: nil
|
||||
previous_network_stats: nil,
|
||||
hostname: hostname
|
||||
}
|
||||
|
||||
connection_opts = [
|
||||
|
||||
@ -3,7 +3,16 @@
|
||||
|
||||
[general]
|
||||
# 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_interval = 30000 # 30 seconds
|
||||
@ -109,46 +118,13 @@ log_all_commands = true
|
||||
|
||||
# Define your custom commands here - these are examples, customize for your system
|
||||
[[commands.available]]
|
||||
name = "restart_service"
|
||||
description = "Restart a system service"
|
||||
trigger = "restart"
|
||||
command = ["systemctl", "restart", "$SERVICE"]
|
||||
allowed_params = ["nginx", "postgresql", "redis", "docker", "ssh"]
|
||||
timeout = 30
|
||||
requires_confirmation = true
|
||||
name = "screenshot"
|
||||
trigger = "screenshot"
|
||||
description = "Take a screenshot and save to home directory"
|
||||
command = "grim /home/ryan/screenshot-$(date +%Y%m%d-%H%M%S).png"
|
||||
|
||||
[[commands.available]]
|
||||
name = "system_info"
|
||||
description = "Get system information"
|
||||
trigger = "info"
|
||||
command = ["uname", "-a"]
|
||||
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
|
||||
name = "lock_screen"
|
||||
trigger = "lock"
|
||||
description = "Lock the screen immediately"
|
||||
command = "hyprctl dispatch exec swaylock"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user