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