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:
commit
9d8306a64b
4
.formatter.exs
Normal file
4
.formatter.exs
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
# Used by "mix format"
|
||||||
|
[
|
||||||
|
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
|
||||||
|
]
|
||||||
23
.gitignore
vendored
Normal file
23
.gitignore
vendored
Normal 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
60
README.md
Normal 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
15
config/config.exs
Normal 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]
|
||||||
18
lib/system_stats_daemon.ex
Normal file
18
lib/system_stats_daemon.ex
Normal 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
|
||||||
19
lib/system_stats_daemon/application.ex
Normal file
19
lib/system_stats_daemon/application.ex
Normal 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
|
||||||
92
lib/system_stats_daemon/mqtt_client.ex
Normal file
92
lib/system_stats_daemon/mqtt_client.ex
Normal 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
39
mix.exs
Normal 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
10
mix.lock
Normal 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
97
nixos-module.nix
Normal 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
5
rel/env.sh.eex
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
# Configure environment for release
|
||||||
|
export MIX_ENV=prod
|
||||||
|
export RELEASE_DISTRIBUTION=none
|
||||||
26
system_stats_daemon.service
Normal file
26
system_stats_daemon.service
Normal 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
|
||||||
8
test/system_stats_daemon_test.exs
Normal file
8
test/system_stats_daemon_test.exs
Normal 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
1
test/test_helper.exs
Normal file
@ -0,0 +1 @@
|
|||||||
|
ExUnit.start()
|
||||||
Loading…
Reference in New Issue
Block a user