From 9d8306a64b7893ea0d25e2b08f470541fb4db7c8 Mon Sep 17 00:00:00 2001 From: ryan Date: Sat, 2 Aug 2025 16:56:10 -0700 Subject: [PATCH] Initial commit: Elixir MQTT system stats daemon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MQTT client with configurable broker connection - Periodic system stats publishing (30s interval) - Command listening on MQTT topic with logging - Systemd service configuration - NixOS module for declarative deployment 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .formatter.exs | 4 ++ .gitignore | 23 ++++++ README.md | 60 ++++++++++++++++ config/config.exs | 15 ++++ lib/system_stats_daemon.ex | 18 +++++ lib/system_stats_daemon/application.ex | 19 +++++ lib/system_stats_daemon/mqtt_client.ex | 92 ++++++++++++++++++++++++ mix.exs | 39 +++++++++++ mix.lock | 10 +++ nixos-module.nix | 97 ++++++++++++++++++++++++++ rel/env.sh.eex | 5 ++ system_stats_daemon.service | 26 +++++++ test/system_stats_daemon_test.exs | 8 +++ test/test_helper.exs | 1 + 14 files changed, 417 insertions(+) create mode 100644 .formatter.exs create mode 100644 .gitignore create mode 100644 README.md create mode 100644 config/config.exs create mode 100644 lib/system_stats_daemon.ex create mode 100644 lib/system_stats_daemon/application.ex create mode 100644 lib/system_stats_daemon/mqtt_client.ex create mode 100644 mix.exs create mode 100644 mix.lock create mode 100644 nixos-module.nix create mode 100644 rel/env.sh.eex create mode 100644 system_stats_daemon.service create mode 100644 test/system_stats_daemon_test.exs create mode 100644 test/test_helper.exs diff --git a/.formatter.exs b/.formatter.exs new file mode 100644 index 0000000..d2cda26 --- /dev/null +++ b/.formatter.exs @@ -0,0 +1,4 @@ +# Used by "mix format" +[ + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] +] diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c65532c --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +# The directory Mix will write compiled artifacts to. +/_build/ + +# If you run "mix test --cover", coverage assets end up here. +/cover/ + +# The directory Mix downloads your dependencies sources to. +/deps/ + +# Where third-party dependencies like ExDoc output generated docs. +/doc/ + +# If the VM crashes, it generates a dump, let's ignore it too. +erl_crash.dump + +# Also ignore archive artifacts (built via "mix archive.build"). +*.ez + +# Ignore package tarball (built via "mix hex.build"). +system_stats_daemon-*.tar + +# Temporary files, for example, from tests. +/tmp/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..dea7211 --- /dev/null +++ b/README.md @@ -0,0 +1,60 @@ +# System Stats Daemon + +An Elixir application that runs as a systemd daemon to: +1. Publish system stats to MQTT every 30 seconds +2. Listen for commands over MQTT and log them to syslog + +## Configuration + +Edit `config/config.exs` to configure MQTT connection: + +```elixir +config :system_stats_daemon, SystemStatsDaemon.MqttClient, + host: "localhost", + port: 1883, + client_id: "system_stats_daemon", + username: nil, + password: nil, + stats_topic: "system/stats", + command_topic: "system/commands", + publish_interval: 30_000 +``` + +## Building + +```bash +mix deps.get +mix compile +``` + +## Running + +```bash +# Development +mix run --no-halt + +# Production release +MIX_ENV=prod mix release +_build/prod/rel/system_stats_daemon/bin/system_stats_daemon start +``` + +## Systemd Installation + +1. Build production release +2. Copy binary to `/usr/local/bin/` +3. Copy `system_stats_daemon.service` to `/etc/systemd/system/` +4. Enable and start: + +```bash +sudo systemctl enable system_stats_daemon +sudo systemctl start system_stats_daemon +``` + +## Features + +- Publishes "Hello world" stats every 30 seconds to `system/stats` topic +- Listens on `system/commands` topic and logs received messages +- Configurable MQTT connection settings +- Runs as systemd daemon with auto-restart +- Logs to system journal + diff --git a/config/config.exs b/config/config.exs new file mode 100644 index 0000000..ea54911 --- /dev/null +++ b/config/config.exs @@ -0,0 +1,15 @@ +import Config + +config :system_stats_daemon, SystemStatsDaemon.MqttClient, + host: "mqtt.home", + port: 1883, + client_id: "system_stats_daemon", + username: "mqtt", + password: "pleasework", + stats_topic: "system/stats", + command_topic: "system/commands", + publish_interval: 30_000 + +config :logger, :console, + format: "$time $metadata[$level] $message\n", + metadata: [:request_id] \ No newline at end of file diff --git a/lib/system_stats_daemon.ex b/lib/system_stats_daemon.ex new file mode 100644 index 0000000..d026f5d --- /dev/null +++ b/lib/system_stats_daemon.ex @@ -0,0 +1,18 @@ +defmodule SystemStatsDaemon do + @moduledoc """ + Documentation for `SystemStatsDaemon`. + """ + + @doc """ + Hello world. + + ## Examples + + iex> SystemStatsDaemon.hello() + :world + + """ + def hello do + :world + end +end diff --git a/lib/system_stats_daemon/application.ex b/lib/system_stats_daemon/application.ex new file mode 100644 index 0000000..cd99cf2 --- /dev/null +++ b/lib/system_stats_daemon/application.ex @@ -0,0 +1,19 @@ +defmodule SystemStatsDaemon.Application do + # See https://hexdocs.pm/elixir/Application.html + # for more information on OTP Applications + @moduledoc false + + use Application + + @impl true + def start(_type, _args) do + children = [ + {SystemStatsDaemon.MqttClient, []} + ] + + # See https://hexdocs.pm/elixir/Supervisor.html + # for other strategies and supported options + opts = [strategy: :one_for_one, name: SystemStatsDaemon.Supervisor] + Supervisor.start_link(children, opts) + end +end diff --git a/lib/system_stats_daemon/mqtt_client.ex b/lib/system_stats_daemon/mqtt_client.ex new file mode 100644 index 0000000..7a66d67 --- /dev/null +++ b/lib/system_stats_daemon/mqtt_client.ex @@ -0,0 +1,92 @@ +defmodule SystemStatsDaemon.MqttClient do + use GenServer + require Logger + + @moduledoc """ + MQTT client for publishing system stats and handling commands + """ + + def start_link(opts) do + GenServer.start_link(__MODULE__, opts, name: __MODULE__) + end + + def init(_opts) do + config = Application.get_env(:system_stats_daemon, __MODULE__) + Logger.info("Starting MQTT client with config: #{inspect(config)}") + + # Use a unique client ID to avoid conflicts + client_id = "#{config[:client_id]}_#{:rand.uniform(1000)}" + + connection_opts = [ + client_id: client_id, + server: {Tortoise.Transport.Tcp, host: to_charlist(config[:host]), port: config[:port]}, + handler: {Tortoise.Handler.Logger, []}, + user_name: config[:username], + password: config[:password], + subscriptions: [{config[:command_topic], 0}] + ] + + case Tortoise.Connection.start_link(connection_opts) do + {:ok, _pid} -> + Logger.info("MQTT client connected successfully") + + # Send immediate hello message + hello_msg = %{ + message: "Hello - system_stats_daemon started", + timestamp: DateTime.utc_now() |> DateTime.to_iso8601(), + hostname: get_hostname() + } + Tortoise.publish(client_id, config[:stats_topic], Jason.encode!(hello_msg), qos: 0) + + schedule_stats_publish(config[:publish_interval]) + {:ok, %{config: config, client_id: client_id}} + + {:error, reason} -> + Logger.error("Failed to connect to MQTT broker: #{inspect(reason)}") + {:stop, reason} + end + end + + def handle_info(:publish_stats, state) do + publish_stats(state.config, state.client_id) + schedule_stats_publish(state.config[:publish_interval]) + {:noreply, state} + end + + def handle_info(_msg, state) do + {:noreply, state} + end + + def terminate(reason, state) do + Logger.info("MQTT client terminating: #{inspect(reason)}") + :ok + end + + defp publish_stats(config, client_id) do + stats = %{ + message: "Hello from system_stats_daemon", + timestamp: DateTime.utc_now() |> DateTime.to_iso8601(), + hostname: get_hostname() + } + + payload = Jason.encode!(stats) + + case Tortoise.publish(client_id, config[:stats_topic], payload, qos: 0) do + :ok -> + Logger.info("Published stats: #{payload}") + {:error, reason} -> + Logger.error("Failed to publish stats: #{inspect(reason)}") + end + end + + defp schedule_stats_publish(interval) do + Process.send_after(self(), :publish_stats, interval) + end + + defp get_hostname do + case :inet.gethostname() do + {:ok, hostname} -> List.to_string(hostname) + _ -> "unknown" + end + end +end \ No newline at end of file diff --git a/mix.exs b/mix.exs new file mode 100644 index 0000000..8b8be29 --- /dev/null +++ b/mix.exs @@ -0,0 +1,39 @@ +defmodule SystemStatsDaemon.MixProject do + use Mix.Project + + def project do + [ + app: :system_stats_daemon, + version: "0.1.0", + elixir: "~> 1.18", + start_permanent: Mix.env() == :prod, + deps: deps(), + releases: releases() + ] + end + + # Run "mix help compile.app" to learn about applications. + def application do + [ + extra_applications: [:logger], + mod: {SystemStatsDaemon.Application, []} + ] + end + + # Run "mix help deps" to learn about dependencies. + defp deps do + [ + {:tortoise, "~> 0.9.5"}, + {:jason, "~> 1.4"} + ] + end + + defp releases do + [ + system_stats_daemon: [ + include_executables_for: [:unix], + applications: [runtime_tools: :permanent] + ] + ] + end +end diff --git a/mix.lock b/mix.lock new file mode 100644 index 0000000..0125822 --- /dev/null +++ b/mix.lock @@ -0,0 +1,10 @@ +%{ + "cowlib": {:hex, :cowlib, "2.13.0", "db8f7505d8332d98ef50a3ef34b34c1afddec7506e4ee4dd4a3a266285d282ca", [:make, :rebar3], [], "hexpm", "e1e1284dc3fc030a64b1ad0d8382ae7e99da46c3246b815318a4b848873800a4"}, + "emqtt": {:hex, :emqtt, "1.14.4", "f34fd1e612e3138e61e9a2d27b0f9674e1da87cc794d30b7916d96f6ee7eef71", [:rebar3], [{:cowlib, "2.13.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:getopt, "1.0.3", [hex: :getopt, repo: "hexpm", optional: false]}, {:gun, "2.1.0", [hex: :gun, repo: "hexpm", optional: false]}], "hexpm", "9065ba581ea899fde316b7eafd03f3c945044c151480bf3adabc6b62b0e60dad"}, + "gen_state_machine": {:hex, :gen_state_machine, "3.0.0", "1e57f86a494e5c6b14137ebef26a7eb342b3b0070c7135f2d6768ed3f6b6cdff", [:mix], [], "hexpm", "0a59652574bebceb7309f6b749d2a41b45fdeda8dbb4da0791e355dd19f0ed15"}, + "getopt": {:hex, :getopt, "1.0.3", "4f3320c1f6f26b2bec0f6c6446b943eb927a1e6428ea279a1c6c534906ee79f1", [:rebar3], [], "hexpm", "7e01de90ac540f21494ff72792b1e3162d399966ebbfc674b4ce52cb8f49324f"}, + "gun": {:hex, :gun, "2.1.0", "b4e4cbbf3026d21981c447e9e7ca856766046eff693720ba43114d7f5de36e87", [:make, :rebar3], [{:cowlib, "2.13.0", [hex: :cowlib, repo: "hexpm", optional: false]}], "hexpm", "52fc7fc246bfc3b00e01aea1c2854c70a366348574ab50c57dfe796d24a0101d"}, + "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, + "syslog": {:hex, :syslog, "1.1.0", "6419a232bea84f07b56dc575225007ffe34d9fdc91abe6f1b2f254fd71d8efc2", [:rebar3], [], "hexpm", "4c6a41373c7e20587be33ef841d3de6f3beba08519809329ecc4d27b15b659e1"}, + "tortoise": {:hex, :tortoise, "0.9.9", "2e467570ef1d342d4de8fdc6ba3861f841054ab524080ec3d7052ee07c04501d", [:mix], [{:gen_state_machine, "~> 2.0 or ~> 3.0", [hex: :gen_state_machine, repo: "hexpm", optional: false]}], "hexpm", "4a316220b4b443c2497f42702f0c0616af3e4b2cbc6c150ebebb51657a773797"}, +} diff --git a/nixos-module.nix b/nixos-module.nix new file mode 100644 index 0000000..d6bdf98 --- /dev/null +++ b/nixos-module.nix @@ -0,0 +1,97 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + cfg = config.services.system-stats-daemon; +in +{ + options.services.system-stats-daemon = { + enable = mkEnableOption "System Stats MQTT Daemon"; + + package = mkOption { + type = types.package; + description = "The system-stats-daemon package to use"; + }; + + mqttHost = mkOption { + type = types.str; + default = "localhost"; + description = "MQTT broker hostname"; + }; + + mqttPort = mkOption { + type = types.int; + default = 1883; + description = "MQTT broker port"; + }; + + mqttUsername = mkOption { + type = types.nullOr types.str; + default = null; + description = "MQTT username (null for no auth)"; + }; + + mqttPassword = mkOption { + type = types.nullOr types.str; + default = null; + description = "MQTT password (null for no auth)"; + }; + + statsTopic = mkOption { + type = types.str; + default = "system/stats"; + description = "MQTT topic for publishing stats"; + }; + + commandTopic = mkOption { + type = types.str; + default = "system/commands"; + description = "MQTT topic for receiving commands"; + }; + + publishInterval = mkOption { + type = types.int; + default = 30000; + description = "Interval between stats publications (milliseconds)"; + }; + }; + + config = mkIf cfg.enable { + systemd.services.system-stats-daemon = { + description = "System Stats MQTT Daemon"; + after = [ "network.target" ]; + wantedBy = [ "multi-user.target" ]; + + environment = { + SYSTEM_STATS_MQTT_HOST = cfg.mqttHost; + SYSTEM_STATS_MQTT_PORT = toString cfg.mqttPort; + SYSTEM_STATS_MQTT_USERNAME = mkIf (cfg.mqttUsername != null) cfg.mqttUsername; + SYSTEM_STATS_MQTT_PASSWORD = mkIf (cfg.mqttPassword != null) cfg.mqttPassword; + SYSTEM_STATS_STATS_TOPIC = cfg.statsTopic; + SYSTEM_STATS_COMMAND_TOPIC = cfg.commandTopic; + SYSTEM_STATS_PUBLISH_INTERVAL = toString cfg.publishInterval; + }; + + serviceConfig = { + Type = "exec"; + User = "root"; + Group = "root"; + ExecStart = "${cfg.package}/bin/system_stats_daemon start"; + ExecStop = "${cfg.package}/bin/system_stats_daemon stop"; + Restart = "always"; + RestartSec = 5; + StandardOutput = "journal"; + StandardError = "journal"; + SyslogIdentifier = "system_stats_daemon"; + 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/rel/env.sh.eex b/rel/env.sh.eex new file mode 100644 index 0000000..a5fce74 --- /dev/null +++ b/rel/env.sh.eex @@ -0,0 +1,5 @@ +#!/bin/sh + +# Configure environment for release +export MIX_ENV=prod +export RELEASE_DISTRIBUTION=none \ No newline at end of file diff --git a/system_stats_daemon.service b/system_stats_daemon.service new file mode 100644 index 0000000..5a2df27 --- /dev/null +++ b/system_stats_daemon.service @@ -0,0 +1,26 @@ +[Unit] +Description=System Stats MQTT Daemon +After=network.target + +[Service] +Type=exec +User=root +Group=root +ExecStart=/opt/system_stats_daemon/bin/system_stats_daemon start +ExecStop=/opt/system_stats_daemon/bin/system_stats_daemon stop +Restart=always +RestartSec=5 +StandardOutput=journal +StandardError=journal +SyslogIdentifier=system_stats_daemon +WorkingDirectory=/opt/system_stats_daemon + +# Security settings - still apply restrictions where possible +NoNewPrivileges=true +PrivateTmp=true +ProtectHome=true +# Don't use ProtectSystem=strict since we need to read system stats +ProtectSystem=false + +[Install] +WantedBy=multi-user.target \ No newline at end of file diff --git a/test/system_stats_daemon_test.exs b/test/system_stats_daemon_test.exs new file mode 100644 index 0000000..f67ef9d --- /dev/null +++ b/test/system_stats_daemon_test.exs @@ -0,0 +1,8 @@ +defmodule SystemStatsDaemonTest do + use ExUnit.Case + doctest SystemStatsDaemon + + test "greets the world" do + assert SystemStatsDaemon.hello() == :world + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs new file mode 100644 index 0000000..869559e --- /dev/null +++ b/test/test_helper.exs @@ -0,0 +1 @@ +ExUnit.start()