Initial commit: Elixir MQTT system stats daemon

- 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 <noreply@anthropic.com>
This commit is contained in:
ryan 2025-08-02 16:56:10 -07:00
commit 9d8306a64b
14 changed files with 417 additions and 0 deletions

4
.formatter.exs Normal file
View File

@ -0,0 +1,4 @@
# Used by "mix format"
[
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
]

23
.gitignore vendored Normal file
View File

@ -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/

60
README.md Normal file
View File

@ -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

15
config/config.exs Normal file
View File

@ -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]

View File

@ -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

View File

@ -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

View File

@ -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

39
mix.exs Normal file
View File

@ -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

10
mix.lock Normal file
View File

@ -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"},
}

97
nixos-module.nix Normal file
View File

@ -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
};
};
};
}

5
rel/env.sh.eex Normal file
View File

@ -0,0 +1,5 @@
#!/bin/sh
# Configure environment for release
export MIX_ENV=prod
export RELEASE_DISTRIBUTION=none

View File

@ -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

View File

@ -0,0 +1,8 @@
defmodule SystemStatsDaemonTest do
use ExUnit.Case
doctest SystemStatsDaemon
test "greets the world" do
assert SystemStatsDaemon.hello() == :world
end
end

1
test/test_helper.exs Normal file
View File

@ -0,0 +1 @@
ExUnit.start()