From 2d948073b251593c5a756c22fffab6ccf3cb2c7f Mon Sep 17 00:00:00 2001 From: ryan Date: Sun, 10 Aug 2025 20:34:22 -0700 Subject: [PATCH] Fix commands --- nix/nixos-module.nix | 23 +++-- server/lib/systant/command_executor.ex | 113 +++++++++++++++++++++++-- server/lib/systant/ha_discovery.ex | 69 +++++++++++++-- server/lib/systant/mqtt_client.ex | 7 +- server/systant.toml | 70 +++++---------- 5 files changed, 208 insertions(+), 74 deletions(-) diff --git a/nix/nixos-module.nix b/nix/nixos-module.nix index 309cbcf..04bf27c 100644 --- a/nix/nixos-module.nix +++ b/nix/nixos-module.nix @@ -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,13 +92,7 @@ in StandardError = "journal"; SyslogIdentifier = "systant"; WorkingDirectory = "${cfg.package}"; - - # Security settings - NoNewPrivileges = true; - PrivateTmp = true; - ProtectHome = true; - ProtectSystem = false; # Need access to system stats }; }; }; -} \ No newline at end of file +} diff --git a/server/lib/systant/command_executor.ex b/server/lib/systant/command_executor.ex index 16b906c..60c8dc1 100644 --- a/server/lib/systant/command_executor.ex +++ b/server/lib/systant/command_executor.ex @@ -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") diff --git a/server/lib/systant/ha_discovery.ex b/server/lib/systant/ha_discovery.ex index 530ff27..0410f2f 100644 --- a/server/lib/systant/ha_discovery.ex +++ b/server/lib/systant/ha_discovery.ex @@ -29,6 +29,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 @@ -81,6 +87,31 @@ defmodule Systant.HaDiscovery do Tortoise.publish(client_pid, topic, payload, retain: true) 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 %{ @@ -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(), diff --git a/server/lib/systant/mqtt_client.ex b/server/lib/systant/mqtt_client.ex index 3960e46..a30ce05 100644 --- a/server/lib/systant/mqtt_client.ex +++ b/server/lib/systant/mqtt_client.ex @@ -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 = [ diff --git a/server/systant.toml b/server/systant.toml index 23f6ff6..5d37be2 100644 --- a/server/systant.toml +++ b/server/systant.toml @@ -3,11 +3,20 @@ [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 -startup_delay = 5000 # 5 seconds +collection_interval = 30000 # 30 seconds +startup_delay = 5000 # 5 seconds [cpu] # CPU metrics are always lightweight, no specific options needed @@ -90,8 +99,8 @@ qos = 0 # Home Assistant MQTT Discovery Configuration [homeassistant] -discovery_enabled = true # Enable/disable HA auto-discovery -discovery_prefix = "homeassistant" # HA discovery topic prefix +discovery_enabled = true # Enable/disable HA auto-discovery +discovery_prefix = "homeassistant" # HA discovery topic prefix [logging] # Log level: "debug", "info", "warning", "error" @@ -104,51 +113,18 @@ log_metric_collection = false [commands] enabled = true # Security: only allow predefined commands, no arbitrary shell execution -max_execution_time = 30 # seconds +max_execution_time = 30 # seconds 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 \ No newline at end of file +name = "lock_screen" +trigger = "lock" +description = "Lock the screen immediately" +command = "hyprctl dispatch exec swaylock"