Compare commits

..
3 Commits
Author SHA1 Message Date
ryanandClaude Opus 4.5 08bf13cc32 fix: pass src from flake to package.nix
Relative paths in package.nix don't resolve correctly when
the flake is used as an input in another flake. Pass src
explicitly from the flake instead.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 20:30:06 -08:00
ryanandClaude Opus 4.5 af4606c40b feat: add NixOS module and proper Nix packaging
- nix/package.nix: two-phase build with fixed-output derivation for deps
- nix/nixos-module.nix: systemd service with systant.enable and systant.configFile
- flake.nix: expose nixosModules.default and overlays.default

Usage in NixOS config:
  systant.enable = true;
  systant.configFile = ./systant.toml;

When deps change, update hash: nix build .#systant 2>&1 | grep 'got:'

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 20:19:27 -08:00
ryanandClaude Opus 4.5 ae778ebdab Initial systant implementation in Bun/TypeScript
A lightweight system monitoring agent that:
- Collects metrics via configurable shell commands
- Publishes to MQTT with Home Assistant auto-discovery
- Supports entity types: sensor, binary_sensor, light, switch, button
- Responds to commands over MQTT for controllable entities

Architecture:
- src/config.ts: TOML config loading and validation
- src/mqtt.ts: MQTT client with HA discovery
- src/entities.ts: Entity state polling and command handling
- index.ts: CLI entry point (run, check, once commands)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 19:52:47 -08:00
42 changed files with 1567 additions and 697 deletions
+40
View File
@@ -0,0 +1,40 @@
{
"$schema": "https://claude.ai/claude-code/settings.schema.json",
"permissions": {
"allow": [
"Bash(bun test*)",
"Bash(bun run*)",
"Bash(bun build*)",
"Bash(bun install*)",
"Bash(bunx tsc --noEmit*)",
"Bash(git status*)",
"Bash(git diff*)",
"Bash(git log*)",
"Bash(git add*)",
"Bash(git commit*)",
"Bash(ls*)",
"Bash(cat /proc/*)",
"Bash(cat /sys/*)"
],
"deny": [
"Bash(rm -rf /)*",
"Bash(sudo *)",
"Bash(*--force*)"
]
},
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "bunx tsc --noEmit --pretty 2>&1 | head -20 || true",
"description": "Type check after file changes",
"timeout": 10000
}
]
}
]
}
}
+28
View File
@@ -0,0 +1,28 @@
# /build
Build the systant CLI binary.
## Instructions
1. Run type checking first: `bunx tsc --noEmit`
2. If types pass, build the binary: `bun build index.ts --compile --outfile dist/systant`
3. Report the binary size and location
4. If there are errors, show them clearly and suggest fixes
## Nix Build
For NixOS deployment, the binary is built by Nix using:
```bash
nix build .#systant
```
If you update dependencies (bun.lock), update the hash in `nix/package.nix`:
```bash
nix build .#systant 2>&1 | grep 'got:'
```
## Success Criteria
- No TypeScript errors
- Binary created at `dist/systant`
- Binary is executable
+26
View File
@@ -0,0 +1,26 @@
# /plan
Enter planning mode to design an implementation approach.
## Instructions
1. Enter plan mode using EnterPlanMode tool
2. Explore the codebase to understand current state
3. Identify affected files and components
4. Design the implementation approach
5. Present the plan for user approval before coding
## When to Use
- New features
- Architectural changes
- Complex bug fixes
- Refactoring tasks
## Output
A clear plan including:
- Files to create/modify
- Key implementation steps
- Potential risks or considerations
- Testing approach
+28
View File
@@ -0,0 +1,28 @@
# /release
Prepare a release of systant.
## Instructions
1. Ensure working directory is clean (`git status`)
2. Run tests: `bun test`
3. Type check: `bunx tsc --noEmit`
4. Build binary: `bun build index.ts --compile --outfile dist/systant`
5. Ask user for version bump type (patch/minor/major)
6. Update version in package.json
7. Create git commit with message: "release: v{version}"
8. Create git tag: `v{version}`
9. Report next steps (push, publish, etc.)
## Prerequisites
- All tests must pass
- No TypeScript errors
- Clean git working directory (or user confirms to proceed)
## Success Criteria
- Binary built successfully
- Version bumped in package.json
- Git commit and tag created
- Clear instructions for next steps
+22
View File
@@ -0,0 +1,22 @@
# /test
Run the test suite.
## Instructions
1. Run `bun test` to execute all tests
2. If tests fail:
- Analyze the failure messages
- Identify the root cause
- Suggest specific fixes
3. If tests pass, report the summary
## Options
- `/test <pattern>` - Run tests matching a pattern (e.g., `/test metrics`)
- `/test --watch` - Run in watch mode
## Success Criteria
- All tests pass
- Clear reporting of any failures with actionable suggestions
+16
View File
@@ -0,0 +1,16 @@
# /typecheck
Run TypeScript type checking.
## Instructions
1. Run `bunx tsc --noEmit --pretty`
2. If errors found:
- List each error with file, line, and message
- Provide suggested fixes for each
3. If no errors, confirm success
## Success Criteria
- Report all type errors clearly
- Suggest actionable fixes
+1
View File
@@ -0,0 +1 @@
use flake
-4
View File
@@ -1,4 +0,0 @@
# Used by "mix format"
[
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
]
+39 -16
View File
@@ -1,23 +1,46 @@
# The directory Mix will write compiled artifacts to.
/_build/
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# If you run "mix test --cover", coverage assets end up here.
/cover/
# Dependencies
node_modules
.pnp
.pnp.js
# The directory Mix downloads your dependencies sources to.
/deps/
# Local env files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Where third-party dependencies like ExDoc output generated docs.
/doc/
# Testing
coverage
# If the VM crashes, it generates a dump, let's ignore it too.
erl_crash.dump
# Turbo
.turbo
# Also ignore archive artifacts (built via "mix archive.build").
*.ez
# Vercel
.vercel
# Ignore package tarball (built via "mix hex.build").
system_stats_daemon-*.tar
# Build Outputs
.next/
out/
build
dist
# Temporary files, for example, from tests.
/tmp/
# Debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Misc
.DS_Store
*.pem
auth_failures.log
# Nix
.direnv
.direnv/*
# Local config (use systant.toml.example as template)
systant.toml
+3
View File
@@ -0,0 +1,3 @@
{
"terminal.integrated.fontFamily": "Fira Code"
}
+103
View File
@@ -0,0 +1,103 @@
# Agents
Specialized sub-agents for systant development tasks.
## test-runner
Use this agent after writing or modifying code to run the test suite and verify changes.
**Responsibilities:**
- Run `bun test` and report results
- Identify failing tests and their root causes
- Suggest fixes for test failures
- Run specific test files when targeted testing is needed
**Trigger:** After implementing features, fixing bugs, or modifying existing code.
## code-reviewer
Use this agent to review code changes before committing.
**Responsibilities:**
- Check for Bun best practices (no Node.js patterns)
- Verify type safety and explicit return types
- Look for potential bugs or edge cases
- Ensure code follows project conventions
- Flag any security concerns (especially in command execution)
**Trigger:** Before creating commits or PRs.
## metrics-specialist
Use this agent when working on system metric collection.
**Responsibilities:**
- Understand Linux /proc and /sys interfaces
- Know cross-platform metric collection strategies
- Ensure metrics are properly typed and documented
- Validate metric units and normalization
**Context:** Systant collects CPU, memory, disk, and network metrics. Metrics should be normalized (percentages 0-100, bytes for sizes) and include metadata for Home Assistant discovery.
## mqtt-specialist
Use this agent when working on MQTT publishing or Home Assistant integration.
**Responsibilities:**
- Understand MQTT topic conventions
- Know Home Assistant discovery protocol
- Ensure proper QoS and retain flag usage
- Handle connection lifecycle (connect, reconnect, disconnect)
- Design topic hierarchies for commands and events
**Context:** Systant publishes to MQTT with Home Assistant auto-discovery. Topics follow the pattern `systant/{hostname}/{metric_type}`. Command topics use `systant/{hostname}/command/{action}`.
## events-specialist
Use this agent when working on the event/command system.
**Responsibilities:**
- Design secure command execution with allowlists
- Implement event handlers and action dispatching
- Ensure proper input validation and sanitization
- Handle timeouts and error reporting
- Consider security implications of remote command execution
**Context:** Systant listens for MQTT commands and executes configured actions. Security is paramount - all commands must be validated against an allowlist, inputs sanitized, and execution sandboxed where possible.
## debug-investigator
Use this agent when troubleshooting issues or unexpected behavior.
**Responsibilities:**
- Add strategic logging to trace execution
- Isolate the problem to specific components
- Form and test hypotheses
- Propose minimal fixes
**Trigger:** When something isn't working as expected.
## architect
Use this agent for design decisions and architectural questions.
**Responsibilities:**
- Evaluate trade-offs between approaches
- Consider future extensibility
- Maintain consistency with existing patterns
- Document decisions in code comments or CLAUDE.md
**Trigger:** When facing design choices or planning new features.
## security-auditor
Use this agent when reviewing security-sensitive code.
**Responsibilities:**
- Review command execution paths for injection vulnerabilities
- Validate input sanitization
- Check allowlist/denylist implementations
- Ensure proper authentication for MQTT commands
- Review file system access patterns
**Context:** Systant executes commands based on MQTT messages. This is a critical attack surface that requires careful security review.
+120
View File
@@ -0,0 +1,120 @@
# Systant
A system monitoring agent that collects metrics, monitors services, and reports to MQTT/Home Assistant; and responds to events over MQTT to trigger commands or other behavior.
## Project Overview
Systant is a lightweight CLI tool written in Bun/TypeScript that:
- Collects system metrics (CPU, memory, disk, network)
- Monitors service health
- Publishes data to MQTT brokers
- Supports Home Assistant auto-discovery
- **Listens for MQTT commands** to trigger actions (run scripts, restart services, etc.)
- **Responds to events** with configurable handlers
- Runs as a daemon or one-shot command
### Architecture
```
index.ts # CLI entry point (yargs)
src/
commands/ # CLI command handlers
metrics/ # System metric collectors
mqtt/ # MQTT client and publishing
events/ # MQTT event listeners and handlers
actions/ # Executable actions (shell, service, notify)
ha/ # Home Assistant discovery
config/ # Configuration loading
```
### Event/Command System
Systant subscribes to MQTT topics and executes configured actions:
```
Topic: systant/{hostname}/command/{action}
Payload: { "args": [...], "timeout": 30 }
Topic: systant/{hostname}/event/{event_name}
Payload: { ... event data ... }
```
Actions are sandboxed and configurable via allowlists in the config file. Security is critical - never execute arbitrary commands without validation.
### Key Design Decisions
- **Single binary**: Compiles to standalone executable via `bun build --compile`
- **No external services**: Uses Bun built-ins (sqlite, file, etc.)
- **Config-driven**: TOML configuration for flexibility
- **Typed throughout**: Full TypeScript with strict mode
## Tech Stack
- **Runtime**: Bun (not Node.js)
- **CLI**: yargs
- **Config**: TOML
- **MQTT**: mqtt.js or Bun-native when available
- **Package**: Nix flake for reproducible builds
## Bun Conventions
Default to using Bun instead of Node.js.
- Use `bun <file>` instead of `node <file>` or `ts-node <file>`
- Use `bun test` instead of `jest` or `vitest`
- Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild`
- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install`
- Use `bun run <script>` instead of `npm run <script>`
- Use `bunx <package> <command>` instead of `npx <package> <command>`
- Bun automatically loads .env, so don't use dotenv.
### Bun APIs
- `Bun.serve()` for HTTP/WebSocket servers
- `bun:sqlite` for SQLite (not better-sqlite3)
- `Bun.file()` for file I/O (not node:fs readFile/writeFile)
- `Bun.$\`cmd\`` for shell commands (not execa)
- Native `WebSocket` (not ws)
### Testing
```ts
import { test, expect, describe, beforeEach } from "bun:test";
describe("MetricCollector", () => {
test("collects CPU metrics", async () => {
const metrics = await collectCPU();
expect(metrics.usage).toBeGreaterThanOrEqual(0);
});
});
```
Run tests: `bun test`
Run specific: `bun test src/metrics`
Watch mode: `bun test --watch`
## Code Style
- Prefer `async/await` over callbacks
- Use explicit return types on public functions
- Prefer `interface` over `type` for object shapes
- Use `const` by default, `let` only when reassignment needed
- No classes unless state encapsulation is genuinely needed
- Prefer pure functions and composition
## Commands
```bash
bun run start # Run in development
bun run dist # Build standalone binary
bun test # Run tests
bun test --watch # Watch mode
```
## Planning Protocol
When implementing features:
1. Discuss the approach before writing code
2. Start with types/interfaces
3. Write tests alongside implementation
4. Keep PRs focused and small
+6 -51
View File
@@ -1,60 +1,15 @@
# Systant
# systant
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 :systant, Systant.MqttClient,
host: "localhost",
port: 1883,
client_id: "systant",
username: nil,
password: nil,
stats_topic: "system/stats",
command_topic: "system/commands",
publish_interval: 30_000
```
## Building
To install dependencies:
```bash
mix deps.get
mix compile
bun install
```
## Running
To run:
```bash
# Development
mix run --no-halt
# Production release
MIX_ENV=prod mix release
_build/prod/rel/systant/bin/systant start
bun run index.ts
```
## Systemd Installation
1. Build production release
2. Copy binary to `/usr/local/bin/`
3. Copy `systant.service` to `/etc/systemd/system/`
4. Enable and start:
```bash
sudo systemctl enable systant
sudo systemctl start systant
```
## Features
- Publishes "Hello from systant" 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
This project was created using `bun init` in bun v1.3.6. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.
+152
View File
@@ -0,0 +1,152 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "systant",
"dependencies": {
"mqtt": "^5.14.1",
"smol-toml": "^1.6.0",
"yargs": "^18.0.0",
},
"devDependencies": {
"@types/bun": "latest",
"@types/yargs": "^17.0.35",
},
"peerDependencies": {
"typescript": "^5",
},
},
},
"packages": {
"@babel/runtime": ["@babel/runtime@7.28.6", "", {}, "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA=="],
"@types/bun": ["@types/bun@1.3.6", "", { "dependencies": { "bun-types": "1.3.6" } }, "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA=="],
"@types/node": ["@types/node@25.0.9", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw=="],
"@types/readable-stream": ["@types/readable-stream@4.0.23", "", { "dependencies": { "@types/node": "*" } }, "sha512-wwXrtQvbMHxCbBgjHaMGEmImFTQxxpfMOR/ZoQnXxB1woqkUbdLGFDgauo00Py9IudiaqSeiBiulSV9i6XIPig=="],
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
"@types/yargs": ["@types/yargs@17.0.35", "", { "dependencies": { "@types/yargs-parser": "*" } }, "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg=="],
"@types/yargs-parser": ["@types/yargs-parser@21.0.3", "", {}, "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ=="],
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
"ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
"ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
"bl": ["bl@6.1.6", "", { "dependencies": { "@types/readable-stream": "^4.0.0", "buffer": "^6.0.3", "inherits": "^2.0.4", "readable-stream": "^4.2.0" } }, "sha512-jLsPgN/YSvPUg9UX0Kd73CXpm2Psg9FxMeCSXnk3WBO3CMT10JMwijubhGfHCnFu6TPn1ei3b975dxv7K2pWVg=="],
"broker-factory": ["broker-factory@3.1.13", "", { "dependencies": { "@babel/runtime": "^7.28.6", "fast-unique-numbers": "^9.0.26", "tslib": "^2.8.1", "worker-factory": "^7.0.48" } }, "sha512-H2VALe31mEtO/SRcNp4cUU5BAm1biwhc/JaF77AigUuni/1YT0FLCJfbUxwIEs9y6Kssjk2fmXgf+Y9ALvmKlw=="],
"buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="],
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
"bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="],
"cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="],
"commist": ["commist@3.2.0", "", {}, "sha512-4PIMoPniho+LqXmpS5d3NuGYncG6XWlkBSVGiWycL22dd42OYdUGil2CWuzklaJoNxyxUSpO4MKIBU94viWNAw=="],
"concat-stream": ["concat-stream@2.0.0", "", { "dependencies": { "buffer-from": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.0.2", "typedarray": "^0.0.6" } }, "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="],
"events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="],
"fast-unique-numbers": ["fast-unique-numbers@9.0.26", "", { "dependencies": { "@babel/runtime": "^7.28.6", "tslib": "^2.8.1" } }, "sha512-3Mtq8p1zQinjGyWfKeuBunbuFoixG72AUkk4VvzbX4ykCW9Q4FzRaNyIlfQhUjnKw2ARVP+/CKnoyr6wfHftig=="],
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
"get-east-asian-width": ["get-east-asian-width@1.4.0", "", {}, "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q=="],
"help-me": ["help-me@5.0.0", "", {}, "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg=="],
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="],
"js-sdsl": ["js-sdsl@4.3.0", "", {}, "sha512-mifzlm2+5nZ+lEcLJMoBK0/IH/bDg8XnJfd/Wq6IP+xoCjLZsTOnV2QpxlVbX9bMnkl5PdEjNtBJ9Cj1NjifhQ=="],
"lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
"mqtt": ["mqtt@5.14.1", "", { "dependencies": { "@types/readable-stream": "^4.0.21", "@types/ws": "^8.18.1", "commist": "^3.2.0", "concat-stream": "^2.0.0", "debug": "^4.4.1", "help-me": "^5.0.0", "lru-cache": "^10.4.3", "minimist": "^1.2.8", "mqtt-packet": "^9.0.2", "number-allocator": "^1.0.14", "readable-stream": "^4.7.0", "rfdc": "^1.4.1", "socks": "^2.8.6", "split2": "^4.2.0", "worker-timers": "^8.0.23", "ws": "^8.18.3" }, "bin": { "mqtt_pub": "build/bin/pub.js", "mqtt_sub": "build/bin/sub.js", "mqtt": "build/bin/mqtt.js" } }, "sha512-NxkPxE70Uq3Ph7goefQa7ggSsVzHrayCD0OyxlJgITN/EbzlZN+JEPmaAZdxP1LsIT5FamDyILoQTF72W7Nnbw=="],
"mqtt-packet": ["mqtt-packet@9.0.2", "", { "dependencies": { "bl": "^6.0.8", "debug": "^4.3.4", "process-nextick-args": "^2.0.1" } }, "sha512-MvIY0B8/qjq7bKxdN1eD+nrljoeaai+qjLJgfRn3TiMuz0pamsIWY2bFODPZMSNmabsLANXsLl4EMoWvlaTZWA=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"number-allocator": ["number-allocator@1.0.14", "", { "dependencies": { "debug": "^4.3.1", "js-sdsl": "4.3.0" } }, "sha512-OrL44UTVAvkKdOdRQZIJpLkAdjXGTRda052sN4sO77bKEzYYqWKMBjQvrJFzqygI99gL6Z4u2xctPW1tB8ErvA=="],
"process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="],
"process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="],
"readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="],
"rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="],
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="],
"smol-toml": ["smol-toml@1.6.0", "", {}, "sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw=="],
"socks": ["socks@2.8.7", "", { "dependencies": { "ip-address": "^10.0.1", "smart-buffer": "^4.2.0" } }, "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A=="],
"split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
"string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
"strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"typedarray": ["typedarray@0.0.6", "", {}, "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"worker-factory": ["worker-factory@7.0.48", "", { "dependencies": { "@babel/runtime": "^7.28.6", "fast-unique-numbers": "^9.0.26", "tslib": "^2.8.1" } }, "sha512-CGmBy3tJvpBPjUvb0t4PrpKubUsfkI1Ohg0/GGFU2RvA9j/tiVYwKU8O7yu7gH06YtzbeJLzdUR29lmZKn5pag=="],
"worker-timers": ["worker-timers@8.0.29", "", { "dependencies": { "@babel/runtime": "^7.28.6", "tslib": "^2.8.1", "worker-timers-broker": "^8.0.15", "worker-timers-worker": "^9.0.13" } }, "sha512-9jk0MWHhWAZ2xlJPXr45oe5UF/opdpfZrY0HtyPizWuJ+ce1M3IYk/4IIdGct3kn9Ncfs+tkZt3w1tU6KW2Fsg=="],
"worker-timers-broker": ["worker-timers-broker@8.0.15", "", { "dependencies": { "@babel/runtime": "^7.28.6", "broker-factory": "^3.1.13", "fast-unique-numbers": "^9.0.26", "tslib": "^2.8.1", "worker-timers-worker": "^9.0.13" } }, "sha512-Te+EiVUMzG5TtHdmaBZvBrZSFNauym6ImDaCAnzQUxvjnw+oGjMT2idmAOgDy30vOZMLejd0bcsc90Axu6XPWA=="],
"worker-timers-worker": ["worker-timers-worker@9.0.13", "", { "dependencies": { "@babel/runtime": "^7.28.6", "tslib": "^2.8.1", "worker-factory": "^7.0.48" } }, "sha512-qjn18szGb1kjcmh2traAdki1eiIS5ikFo+L90nfMOvSRpuDw1hAcR1nzkP2+Hkdqz5thIRnfuWx7QSpsEUsA6Q=="],
"wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
"ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="],
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
"yargs": ["yargs@18.0.0", "", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^7.2.0", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg=="],
"yargs-parser": ["yargs-parser@22.0.0", "", {}, "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw=="],
"concat-stream/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
}
}
-15
View File
@@ -1,15 +0,0 @@
import Config
config :systant, Systant.MqttClient,
host: "mqtt.home",
port: 1883,
client_id: "systant",
username: "mqtt",
password: "pleasework",
stats_topic_base: "systant", # Will become systant/{hostname}/stats
command_topic_base: "systant", # Will become systant/{hostname}/commands
publish_interval: 30_000
config :logger, :console,
format: "$time $metadata[$level] $message\n",
metadata: [:request_id]
-18
View File
@@ -1,18 +0,0 @@
import Config
# Get hostname for topic construction
hostname = case :inet.gethostname() do
{:ok, hostname} -> List.to_string(hostname)
_ -> "unknown"
end
# Runtime configuration that can use environment variables
config :systant, Systant.MqttClient,
host: System.get_env("SYSTANT_MQTT_HOST", "localhost"),
port: String.to_integer(System.get_env("SYSTANT_MQTT_PORT", "1883")),
client_id: System.get_env("SYSTANT_CLIENT_ID", "systant"),
username: System.get_env("SYSTANT_MQTT_USERNAME"),
password: System.get_env("SYSTANT_MQTT_PASSWORD"),
stats_topic: System.get_env("SYSTANT_STATS_TOPIC", "systant/#{hostname}/stats"),
command_topic: System.get_env("SYSTANT_COMMAND_TOPIC", "systant/#{hostname}/commands"),
publish_interval: String.to_integer(System.get_env("SYSTANT_PUBLISH_INTERVAL", "30000"))
-39
View File
@@ -1,39 +0,0 @@
#!/bin/bash
# Debug script to check what's in the Nix store
STORE_PATH="/nix/store/i3fa1p0y8mcyljzfsj6k65qfpmd4yyaf-systant-0.1.0"
echo "=== Checking Nix store contents ==="
echo "Store path: $STORE_PATH"
echo
echo "=== Top level contents ==="
ls -la "$STORE_PATH/"
echo
echo "=== Looking for COOKIE files ==="
find "$STORE_PATH" -name "*COOKIE*" -o -name "*cookie*" 2>/dev/null || echo "No COOKIE files found"
echo
echo "=== releases directory ==="
if [ -d "$STORE_PATH/releases" ]; then
ls -la "$STORE_PATH/releases/"
else
echo "No releases directory found"
fi
echo
echo "=== bin directory ==="
if [ -d "$STORE_PATH/bin" ]; then
ls -la "$STORE_PATH/bin/"
else
echo "No bin directory found"
fi
echo
echo "=== Checking startup script ==="
if [ -f "$STORE_PATH/bin/systant" ]; then
echo "Startup script exists, checking for COOKIE references:"
grep -n "COOKIE" "$STORE_PATH/bin/systant" || echo "No COOKIE references in startup script"
else
echo "No startup script found"
fi
Generated
+61
View File
@@ -0,0 +1,61 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1768395095,
"narHash": "sha256-ZhuYJbwbZT32QA95tSkXd9zXHcdZj90EzHpEXBMabaw=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "13868c071cc73a5e9f610c47d7bb08e5da64fdd5",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}
+59
View File
@@ -0,0 +1,59 @@
{
description = "Systant - System monitoring agent with MQTT and Home Assistant integration";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
inputs.flake-utils.url = "github:numtide/flake-utils";
outputs =
{
self,
nixpkgs,
flake-utils,
...
}:
{
# NixOS module (system-independent)
nixosModules.default = import ./nix/nixos-module.nix;
# Overlay to add systant to pkgs
overlays.default = final: prev: {
systant = final.callPackage ./nix/package.nix { src = self; };
};
}
//
flake-utils.lib.eachDefaultSystem (
system:
let
pkgs = import nixpkgs {
inherit system;
config.allowUnfree = true;
overlays = [ self.overlays.default ];
};
in
{
packages = {
systant = pkgs.systant;
default = pkgs.systant;
};
apps.default = {
type = "app";
program = "${pkgs.systant}/bin/systant";
};
devShells.default = pkgs.mkShell {
buildInputs = with pkgs; [
bashInteractive
glibcLocales
git
bun
inotify-tools
];
shellHook = ''
export PROJECT_ROOT=$PWD
'';
};
}
);
}
+130
View File
@@ -0,0 +1,130 @@
import yargs from "yargs";
import { hideBin } from "yargs/helpers";
import { loadConfig } from "./src/config";
import { connect } from "./src/mqtt";
import { createEntityManager } from "./src/entities";
const DEFAULT_CONFIG_PATH = "./systant.toml";
yargs(hideBin(process.argv))
.scriptName("systant")
.usage("$0 <cmd> [args]")
.command(
"run",
"Start the systant daemon",
(yargs) => {
return yargs.option("config", {
alias: "c",
type: "string",
default: DEFAULT_CONFIG_PATH,
describe: "Path to config file",
});
},
async (argv) => {
await run(argv.config);
}
)
.command(
"check",
"Check config and connectivity",
(yargs) => {
return yargs.option("config", {
alias: "c",
type: "string",
default: DEFAULT_CONFIG_PATH,
describe: "Path to config file",
});
},
async (argv) => {
await check(argv.config);
}
)
.command(
"once",
"Poll all entity states once, then exit",
(yargs) => {
return yargs.option("config", {
alias: "c",
type: "string",
default: DEFAULT_CONFIG_PATH,
describe: "Path to config file",
});
},
async (argv) => {
await once(argv.config);
}
)
.demandCommand(1, "\nError: You need to specify a command!")
.help()
.parse();
async function run(configPath: string): Promise<void> {
const config = await loadConfig(configPath);
console.log(`Starting systant on ${config.systant.hostname}`);
const mqtt = await connect(config, config.systant.hostname);
const entities = createEntityManager(config, mqtt);
await entities.start();
// Handle shutdown
const shutdown = async () => {
console.log("\nShutting down...");
entities.stop();
await mqtt.disconnect();
process.exit(0);
};
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
console.log("Systant running. Press Ctrl+C to stop.");
}
async function check(configPath: string): Promise<void> {
console.log(`Checking config: ${configPath}`);
try {
const config = await loadConfig(configPath);
const entityCount = Object.keys(config.entities).length;
console.log("Config loaded successfully");
console.log(` MQTT broker: ${config.mqtt.broker}`);
console.log(` Entities: ${entityCount} configured (default interval: ${config.systant.defaultInterval}s)`);
console.log(` HA discovery: ${config.homeassistant.discovery}`);
console.log("\nTesting MQTT connection...");
const hostname = config.systant.hostname;
const mqtt = await connect(config, hostname);
console.log("MQTT connection successful");
await mqtt.disconnect();
console.log("\nAll checks passed!");
} catch (err) {
console.error("Check failed:", err instanceof Error ? err.message : err);
process.exit(1);
}
}
async function once(configPath: string): Promise<void> {
const config = await loadConfig(configPath);
const hostname = config.systant.hostname;
const mqtt = await connect(config, hostname);
const entities = createEntityManager(config, mqtt);
// Start will do initial poll of all entities
await entities.start();
// Wait a moment for the initial polls to complete
await new Promise((resolve) => setTimeout(resolve, 1000));
entities.stop();
await mqtt.disconnect();
console.log("Entity states published successfully");
}
-18
View File
@@ -1,18 +0,0 @@
defmodule Systant do
@moduledoc """
Documentation for `Systant`.
"""
@doc """
Hello world.
## Examples
iex> Systant.hello()
:world
"""
def hello do
:world
end
end
-19
View File
@@ -1,19 +0,0 @@
defmodule Systant.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 = [
{Systant.MqttClient, []}
]
# See https://hexdocs.pm/elixir/Supervisor.html
# for other strategies and supported options
opts = [strategy: :one_for_one, name: Systant.Supervisor]
Supervisor.start_link(children, opts)
end
end
-92
View File
@@ -1,92 +0,0 @@
defmodule Systant.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(:systant, __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 - systant 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 systant",
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
-41
View File
@@ -1,41 +0,0 @@
defmodule SystemStatsDaemon.MixProject do
use Mix.Project
def project do
[
app: :systant,
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: {Systant.Application, []}
]
end
# Run "mix help deps" to learn about dependencies.
defp deps do
[
{:tortoise, "~> 0.9.5"},
{:jason, "~> 1.4"}
]
end
defp releases do
[
systant: [
include_executables_for: [:unix],
applications: [runtime_tools: :permanent],
include_erts: true,
strip_beams: false
]
]
end
end
-10
View File
@@ -1,10 +0,0 @@
%{
"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"},
}
+123
View File
@@ -0,0 +1,123 @@
{ config, lib, pkgs, ... }:
let
cfg = config.systant;
settingsFormat = pkgs.formats.toml { };
in
{
options.systant = {
enable = lib.mkEnableOption "systant system monitoring agent";
package = lib.mkOption {
type = lib.types.package;
default = pkgs.systant;
defaultText = lib.literalExpression "pkgs.systant";
description = "The systant package to use.";
};
configFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = ''
Path to the systant configuration file (TOML).
If set, this takes precedence over the settings option.
'';
};
settings = lib.mkOption {
type = settingsFormat.type;
default = { };
description = ''
Configuration for systant in Nix attribute set form.
Will be converted to TOML. Ignored if configFile is set.
'';
example = lib.literalExpression ''
{
mqtt = {
broker = "mqtt://localhost:1883";
topicPrefix = "systant";
};
entities = {
cpu_usage = {
type = "sensor";
state_command = "awk '/^cpu / {u=$2+$4; t=$2+$4+$5; print int(u*100/t)}' /proc/stat";
unit = "%";
icon = "mdi:cpu-64-bit";
name = "CPU Usage";
};
};
homeassistant = {
discovery = true;
discoveryPrefix = "homeassistant";
};
}
'';
};
user = lib.mkOption {
type = lib.types.str;
default = "systant";
description = "User account under which systant runs.";
};
group = lib.mkOption {
type = lib.types.str;
default = "systant";
description = "Group under which systant runs.";
};
};
config = lib.mkIf cfg.enable {
# Create systant user/group if using defaults
users.users.${cfg.user} = lib.mkIf (cfg.user == "systant") {
isSystemUser = true;
group = cfg.group;
description = "Systant service user";
};
users.groups.${cfg.group} = lib.mkIf (cfg.group == "systant") { };
# Generate config file from settings if configFile not provided
environment.etc."systant/config.toml" = lib.mkIf (cfg.configFile == null && cfg.settings != { }) {
source = settingsFormat.generate "systant-config.toml" cfg.settings;
};
systemd.services.systant = {
description = "Systant system monitoring agent";
wantedBy = [ "multi-user.target" ];
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
serviceConfig = {
Type = "simple";
User = cfg.user;
Group = cfg.group;
ExecStart =
let
configPath =
if cfg.configFile != null
then cfg.configFile
else "/etc/systant/config.toml";
in
"${cfg.package}/bin/systant run --config ${configPath}";
Restart = "on-failure";
RestartSec = "5s";
# Hardening
NoNewPrivileges = true;
ProtectSystem = "strict";
ProtectHome = true;
PrivateTmp = true;
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectControlGroups = true;
# Allow reading system metrics
ReadOnlyPaths = [
"/proc"
"/sys"
];
};
};
};
}
+66
View File
@@ -0,0 +1,66 @@
{
lib,
stdenvNoCC,
stdenv,
bun,
cacert,
src, # passed from flake.nix
}:
let
# Fixed-output derivation to fetch npm dependencies
# Update the hash when bun.lock changes by running:
# nix build .#systant 2>&1 | grep 'got:'
deps = stdenvNoCC.mkDerivation {
pname = "systant-deps";
version = "0.1.0";
inherit src;
buildPhase = ''
export HOME=$TMPDIR
bun install --frozen-lockfile
'';
installPhase = ''
cp -r node_modules $out
'';
nativeBuildInputs = [ bun cacert ];
outputHashMode = "recursive";
outputHashAlgo = "sha256";
# To update: nix build .#systant 2>&1 | grep 'got:'
outputHash = "sha256-hQ1ZzOFOHHeaAtyfCXxX6jpqB7poFLwavgMW8yMwaHw=";
};
in
stdenv.mkDerivation {
pname = "systant";
version = "0.1.0";
inherit src;
nativeBuildInputs = [ bun ];
buildPhase = ''
export HOME=$TMPDIR
cp -r ${deps} node_modules
chmod -R u+w node_modules
bun build index.ts --compile --outfile systant
'';
installPhase = ''
mkdir -p $out/bin
cp systant $out/bin/systant
'';
# Bun's compiled binaries don't like being stripped
dontStrip = true;
meta = with lib; {
description = "System monitoring agent with MQTT and Home Assistant integration";
homepage = "https://git.ryanpandya.com/ryan/systant";
license = licenses.mit;
platforms = platforms.linux;
};
}
-99
View File
@@ -1,99 +0,0 @@
{ config, lib, pkgs, ... }:
with lib;
let
cfg = config.services.systant;
in
{
options.services.systant = {
enable = mkEnableOption "Systant MQTT Daemon";
package = mkOption {
type = types.package;
description = "The systant 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 = "systant/${config.networking.hostName}/stats";
description = "MQTT topic for publishing stats";
};
commandTopic = mkOption {
type = types.str;
default = "systant/${config.networking.hostName}/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.systant = {
description = "Systant MQTT Daemon";
after = [ "network.target" ];
wantedBy = [ "multi-user.target" ];
environment = {
SYSTANT_MQTT_HOST = cfg.mqttHost;
SYSTANT_MQTT_PORT = toString cfg.mqttPort;
SYSTANT_MQTT_USERNAME = mkIf (cfg.mqttUsername != null) cfg.mqttUsername;
SYSTANT_MQTT_PASSWORD = mkIf (cfg.mqttPassword != null) cfg.mqttPassword;
SYSTANT_STATS_TOPIC = cfg.statsTopic;
SYSTANT_COMMAND_TOPIC = cfg.commandTopic;
SYSTANT_PUBLISH_INTERVAL = toString cfg.publishInterval;
# Override RELEASE_COOKIE to bypass file reading
RELEASE_COOKIE = "systant-bypass-cookie";
};
serviceConfig = {
Type = "exec";
User = "root";
Group = "root";
ExecStart = "${cfg.package}/bin/systant start";
ExecStop = "${cfg.package}/bin/systant stop";
Restart = "always";
RestartSec = 5;
StandardOutput = "journal";
StandardError = "journal";
SyslogIdentifier = "systant";
WorkingDirectory = "${cfg.package}";
# Security settings
NoNewPrivileges = true;
PrivateTmp = true;
ProtectHome = true;
ProtectSystem = false; # Need access to system stats
};
};
};
}
+23
View File
@@ -0,0 +1,23 @@
{
"name": "systant",
"version": "0.1.0",
"module": "index.ts",
"devDependencies": {
"@types/bun": "latest",
"@types/yargs": "^17.0.35"
},
"peerDependencies": {
"typescript": "^5"
},
"private": true,
"scripts": {
"start": "bun run index.ts",
"dist": "bun build index.ts --compile --outfile dist/systant"
},
"type": "module",
"dependencies": {
"mqtt": "^5.14.1",
"smol-toml": "^1.6.0",
"yargs": "^18.0.0"
}
}
-6
View File
@@ -1,6 +0,0 @@
#!/bin/sh
# Configure environment for release
export MIX_ENV=prod
export RELEASE_DISTRIBUTION=none
export RELEASE_NODE=nonode@nohost
+130
View File
@@ -0,0 +1,130 @@
import { parse } from "smol-toml";
export interface SystantConfig {
hostname: string;
defaultInterval: number;
}
export interface MqttConfig {
broker: string;
username?: string;
password?: string;
clientId?: string;
topicPrefix: string;
}
export type EntityType = "sensor" | "binary_sensor" | "light" | "switch" | "button";
export interface EntityConfig {
type: EntityType;
state_command?: string; // for stateful entities (not button)
on_command?: string; // for light/switch
off_command?: string; // for light/switch
press_command?: string; // for button
interval?: number; // override default interval
unit?: string; // for sensor
device_class?: string; // for sensor (timestamp, etc.) or binary_sensor
icon?: string;
name?: string;
availability?: boolean; // default true; set false to keep last value when offline
}
export interface HomeAssistantConfig {
discovery: boolean;
discoveryPrefix: string;
}
export interface Config {
systant: SystantConfig;
mqtt: MqttConfig;
entities: Record<string, EntityConfig>;
homeassistant: HomeAssistantConfig;
}
const defaults = {
systant: {
defaultInterval: 30,
hostname: process.env.HOSTNAME || require("os").hostname(),
},
mqtt: {
broker: "mqtt://localhost:1883",
topicPrefix: "systant",
},
homeassistant: {
discovery: true,
discoveryPrefix: "homeassistant",
},
};
export async function loadConfig(path: string): Promise<Config> {
const file = Bun.file(path);
if (!(await file.exists())) {
throw new Error(`Config file not found: ${path}`);
}
const content = await file.text();
const parsed = parse(content) as Record<string, unknown>;
return buildConfig(parsed);
}
function buildConfig(parsed: Record<string, unknown>): Config {
const config: Config = {
systant: { ...defaults.systant },
mqtt: { ...defaults.mqtt },
entities: {},
homeassistant: { ...defaults.homeassistant },
};
// MQTT settings
if (parsed.mqtt && typeof parsed.mqtt === "object") {
Object.assign(config.mqtt, parsed.mqtt);
}
// Systant settings
if (parsed.systant && typeof parsed.systant === "object") {
Object.assign(config.systant, parsed.systant);
}
// Entities
if (parsed.entities && typeof parsed.entities === "object") {
const entities = parsed.entities as Record<string, unknown>;
for (const [key, value] of Object.entries(entities)) {
if (key === "interval") continue; // skip the default interval
if (value && typeof value === "object" && "type" in value) {
const e = value as Record<string, unknown>;
const entityType = String(e.type) as EntityType;
// Buttons need press_command, others need state_command
const hasRequiredCommand = entityType === "button"
? "press_command" in e
: "state_command" in e;
if (!hasRequiredCommand) continue;
config.entities[key] = {
type: entityType,
state_command: typeof e.state_command === "string" ? e.state_command : undefined,
on_command: typeof e.on_command === "string" ? e.on_command : undefined,
off_command: typeof e.off_command === "string" ? e.off_command : undefined,
press_command: typeof e.press_command === "string" ? e.press_command : undefined,
interval: typeof e.interval === "number" ? e.interval : undefined,
unit: typeof e.unit === "string" ? e.unit : undefined,
device_class: typeof e.device_class === "string" ? e.device_class : undefined,
icon: typeof e.icon === "string" ? e.icon : undefined,
name: typeof e.name === "string" ? e.name : undefined,
availability: typeof e.availability === "boolean" ? e.availability : true,
};
}
}
}
// Home Assistant
if (parsed.homeassistant && typeof parsed.homeassistant === "object") {
Object.assign(config.homeassistant, parsed.homeassistant);
}
return config;
}
+110
View File
@@ -0,0 +1,110 @@
import type { Config, EntityConfig } from "./config";
import type { MqttConnection } from "./mqtt";
export interface EntityManager {
start(): Promise<void>;
stop(): void;
}
export function createEntityManager(config: Config, mqtt: MqttConnection): EntityManager {
const timers: Timer[] = [];
async function setupStatefulEntity(id: string, entity: EntityConfig): Promise<void> {
const interval = entity.interval ?? config.systant.defaultInterval;
const isControllable = entity.type === "light" || entity.type === "switch";
if (!entity.state_command) return;
// State polling
const pollState = async () => {
try {
const output = await Bun.$`sh -c ${entity.state_command}`.text();
const value = output.trim();
console.debug(`[${id}] state: ${value}`);
await mqtt.publish(`${id}/state`, value, true); // retain state
} catch (err) {
console.error(`[${id}] state poll failed:`, err instanceof Error ? err.message : err);
}
};
// Initial state poll
await pollState();
// Schedule periodic polling
const timer = setInterval(pollState, interval * 1000);
timers.push(timer);
// Command subscription for controllable entities
if (isControllable && (entity.on_command || entity.off_command)) {
await mqtt.subscribe(`${id}/set`, async (_topic, payload) => {
const command = payload.toString().toUpperCase();
console.log(`[${id}] received command: ${command}`);
try {
if (command === "ON" && entity.on_command) {
await Bun.$`sh -c ${entity.on_command}`;
console.log(`[${id}] executed on_command`);
} else if (command === "OFF" && entity.off_command) {
await Bun.$`sh -c ${entity.off_command}`;
console.log(`[${id}] executed off_command`);
} else {
console.warn(`[${id}] unknown command or no handler: ${command}`);
return;
}
// Re-poll state after command execution
await new Promise((r) => setTimeout(r, 500)); // brief delay for state to settle
await pollState();
} catch (err) {
console.error(`[${id}] command failed:`, err instanceof Error ? err.message : err);
}
});
}
const typeLabel = isControllable ? `${entity.type} (controllable)` : entity.type;
console.log(` ${id}: ${typeLabel}, poll every ${interval}s`);
}
async function setupButton(id: string, entity: EntityConfig): Promise<void> {
if (!entity.press_command) return;
await mqtt.subscribe(`${id}/press`, async (_topic, _payload) => {
console.log(`[${id}] button pressed`);
try {
await Bun.$`sh -c ${entity.press_command}`;
console.log(`[${id}] executed press_command`);
} catch (err) {
console.error(`[${id}] press_command failed:`, err instanceof Error ? err.message : err);
}
});
console.log(` ${id}: button`);
}
return {
async start(): Promise<void> {
const entityCount = Object.keys(config.entities).length;
if (entityCount === 0) {
console.log("No entities configured");
return;
}
console.log(`Starting ${entityCount} entity manager(s):`);
for (const [id, entity] of Object.entries(config.entities)) {
if (entity.type === "button") {
await setupButton(id, entity);
} else {
await setupStatefulEntity(id, entity);
}
}
},
stop(): void {
for (const timer of timers) {
clearInterval(timer);
}
timers.length = 0;
},
};
}
+177
View File
@@ -0,0 +1,177 @@
import mqtt, { type MqttClient, type IClientOptions } from "mqtt";
import type { Config, EntityConfig } from "./config";
export interface MqttConnection {
client: MqttClient;
publish(topic: string, payload: string | object, retain?: boolean): Promise<void>;
subscribe(topic: string, handler: (topic: string, payload: Buffer) => void): Promise<void>;
disconnect(): Promise<void>;
}
export async function connect(config: Config, hostname: string): Promise<MqttConnection> {
const options: IClientOptions = {
clientId: config.mqtt.clientId || `systant-${hostname}`,
username: config.mqtt.username,
password: config.mqtt.password,
will: {
topic: `${config.mqtt.topicPrefix}/${hostname}/status`,
payload: Buffer.from("offline"),
qos: 1,
retain: true,
},
};
const client = mqtt.connect(config.mqtt.broker, options);
const handlers = new Map<string, (topic: string, payload: Buffer) => void>();
await new Promise<void>((resolve, reject) => {
client.on("connect", () => {
console.log(`Connected to MQTT broker: ${config.mqtt.broker}`);
resolve();
});
client.on("error", reject);
});
client.on("message", (topic, payload) => {
for (const [pattern, handler] of handlers) {
if (topicMatches(pattern, topic)) {
handler(topic, payload);
}
}
});
// Publish online status
await publishAsync(client, `${config.mqtt.topicPrefix}/${hostname}/status`, "online", true);
// Publish HA discovery if enabled
if (config.homeassistant.discovery) {
await publishDiscovery(client, config, hostname);
}
return {
client,
async publish(topic: string, payload: string | object, retain = false): Promise<void> {
const fullTopic = `${config.mqtt.topicPrefix}/${hostname}/${topic}`;
const data = typeof payload === "object" ? JSON.stringify(payload) : payload;
await publishAsync(client, fullTopic, data, retain);
},
async subscribe(topic: string, handler: (topic: string, payload: Buffer) => void): Promise<void> {
const fullTopic = `${config.mqtt.topicPrefix}/${hostname}/${topic}`;
handlers.set(fullTopic, handler);
await new Promise<void>((resolve, reject) => {
client.subscribe(fullTopic, { qos: 1 }, (err) => {
if (err) reject(err);
else resolve();
});
});
console.log(`Subscribed to: ${fullTopic}`);
},
async disconnect(): Promise<void> {
await publishAsync(client, `${config.mqtt.topicPrefix}/${hostname}/status`, "offline", true);
await new Promise<void>((resolve) => client.end(false, {}, () => resolve()));
},
};
}
function publishAsync(client: MqttClient, topic: string, payload: string, retain: boolean): Promise<void> {
return new Promise((resolve, reject) => {
client.publish(topic, payload, { qos: 1, retain }, (err) => {
if (err) reject(err);
else resolve();
});
});
}
function topicMatches(pattern: string, topic: string): boolean {
if (pattern === topic) return true;
if (pattern.endsWith("#")) {
return topic.startsWith(pattern.slice(0, -1));
}
return false;
}
async function publishDiscovery(client: MqttClient, config: Config, hostname: string): Promise<void> {
const prefix = config.homeassistant.discoveryPrefix;
const topicPrefix = config.mqtt.topicPrefix;
const entityCount = Object.keys(config.entities).length;
if (entityCount === 0) {
console.log("No entities configured, skipping HA discovery");
return;
}
for (const [id, entity] of Object.entries(config.entities)) {
const payload = buildDiscoveryPayload(id, entity, hostname, topicPrefix);
const discoveryTopic = `${prefix}/${entity.type}/${hostname}_${id}/config`;
await publishAsync(client, discoveryTopic, JSON.stringify(payload), true);
}
console.log(`Published Home Assistant discovery for ${entityCount} entity/entities`);
}
function buildDiscoveryPayload(
id: string,
entity: EntityConfig,
hostname: string,
topicPrefix: string
): Record<string, unknown> {
const displayName = entity.name || id.replace(/_/g, " ");
const payload: Record<string, unknown> = {
name: displayName,
unique_id: `systant_${hostname}_${id}`,
device: {
identifiers: [`systant_${hostname}`],
name: `${hostname}`,
manufacturer: "Systant",
},
};
// Stateful entities have a state_topic (buttons don't)
if (entity.type !== "button") {
payload.state_topic = `${topicPrefix}/${hostname}/${id}/state`;
}
// Add availability tracking unless explicitly disabled
if (entity.availability !== false) {
payload.availability_topic = `${topicPrefix}/${hostname}/status`;
payload.payload_available = "online";
payload.payload_not_available = "offline";
}
// Common optional fields
if (entity.icon) payload.icon = entity.icon;
// Type-specific fields
switch (entity.type) {
case "sensor":
if (entity.unit) payload.unit_of_measurement = entity.unit;
if (entity.device_class) payload.device_class = entity.device_class;
break;
case "binary_sensor":
payload.payload_on = "ON";
payload.payload_off = "OFF";
if (entity.device_class) payload.device_class = entity.device_class;
break;
case "light":
case "switch":
payload.command_topic = `${topicPrefix}/${hostname}/${id}/set`;
payload.payload_on = "ON";
payload.payload_off = "OFF";
payload.state_on = "ON";
payload.state_off = "OFF";
break;
case "button":
payload.command_topic = `${topicPrefix}/${hostname}/${id}/press`;
payload.payload_press = "PRESS";
break;
}
return payload;
}
-62
View File
@@ -1,62 +0,0 @@
{ lib, stdenv, fetchgit, elixir, erlang, rebar3, git }:
stdenv.mkDerivation rec {
pname = "systant";
version = "0.1.0";
src = fetchgit {
url = "https://git.ryanpandya.com/ryan/systant.git";
rev = "f8173a1afb03623039ff504c587d7322a9876f3d";
sha256 = "sha256-1cRfSoH+JdO4a7q4hRZSkoDMk2wMCYRIyCIN56FSUgg=";
};
nativeBuildInputs = [ elixir erlang rebar3 git ];
# Disable network access - we'll handle deps manually
MIX_ENV = "prod";
configurePhase = ''
export MIX_HOME=$TMPDIR/mix
export HEX_HOME=$TMPDIR/hex
export REBAR_CACHE_DIR=$TMPDIR/rebar3
# Install hex and rebar locally
mix local.hex --force
mix local.rebar --force
'';
buildPhase = ''
# This will fail but that's OK - we're using it to get deps info
mix deps.get || true
# Manual dependency installation (you'd need to add each dep here)
# For now, let's try without deps to see if it builds
mix deps.compile --skip-deps || true
mix compile
mix release
echo "=== Build phase debug ==="
find _build/prod/rel/ -name "*COOKIE*" || echo "No COOKIE in build"
'';
installPhase = ''
mkdir -p $out
cp -r _build/prod/rel/systant/* $out/
# Force create COOKIE file
mkdir -p $out/releases
echo "systant-cookie-change-in-production" > $out/releases/COOKIE
echo "=== Install phase debug ==="
echo "COOKIE created manually in installPhase" > /tmp/manual-nix-debug
ls -la $out/releases/
cat $out/releases/COOKIE
'';
meta = with lib; {
description = "Systant - System stats MQTT daemon";
homepage = "https://git.ryanpandya.com/ryan/systant";
license = licenses.mit;
platforms = platforms.linux;
};
}
-73
View File
@@ -1,73 +0,0 @@
{ lib
, stdenv
, fetchgit
, elixir
, erlang
, rebar3
, git
, cacert
, glibcLocales
}:
stdenv.mkDerivation rec {
pname = "systant";
version = "0.1.0";
src = fetchgit {
url = "https://git.ryanpandya.com/ryan/systant.git";
rev = "92fc90e3b470dd2d11ba3a84745e33195e8e9db3";
sha256 = lib.fakeSha256; # Replace with actual hash after first build attempt
};
nativeBuildInputs = [
elixir
erlang
rebar3
git
cacert
glibcLocales
];
buildPhase = ''
runHook preBuild
# Set up environment for Mix/Hex
export MIX_ENV=prod
export MIX_HOME=$TMPDIR/mix
export HEX_HOME=$TMPDIR/hex
export REBAR_CACHE_DIR=$TMPDIR/rebar3
# SSL and locale configuration
export SSL_CERT_FILE=${cacert}/etc/ssl/certs/ca-bundle.crt
export LOCALE_ARCHIVE=${glibcLocales}/lib/locale/locale-archive
export LC_ALL=en_US.UTF-8
export ELIXIR_ERL_OPTIONS="+fnu"
# Install hex and rebar locally
mix local.hex --force
mix local.rebar --force
# Get dependencies and build release
mix deps.get --only=prod
mix release
runHook postBuild
'';
installPhase = ''
runHook preInstall
mkdir -p $out
cp -r _build/prod/rel/systant/* $out/
runHook postInstall
'';
meta = with lib; {
description = "Systant - System stats MQTT daemon for monitoring system metrics";
homepage = "https://git.ryanpandya.com/ryan/systant";
license = licenses.mit; # Update if different
maintainers = [ ]; # Add your maintainer info if desired
platforms = platforms.linux;
};
}
-67
View File
@@ -1,67 +0,0 @@
{ lib, stdenv, fetchgit, elixir, erlang, rebar3, git, writeShellScript }:
let
systantBase = stdenv.mkDerivation rec {
pname = "systant-base";
version = "0.1.0";
src = fetchgit {
url = "https://git.ryanpandya.com/ryan/systant.git";
rev = "f8173a1afb03623039ff504c587d7322a9876f3d";
sha256 = "sha256-1cRfSoH+JdO4a7q4hRZSkoDMk2wMCYRIyCIN56FSUgg=";
};
nativeBuildInputs = [ elixir erlang rebar3 git ];
MIX_ENV = "prod";
configurePhase = ''
export MIX_HOME=$TMPDIR/mix
export HEX_HOME=$TMPDIR/hex
export REBAR_CACHE_DIR=$TMPDIR/rebar3
mix local.hex --force
mix local.rebar --force
'';
buildPhase = ''
mix deps.get || true
mix deps.compile || true
mix compile
mix release
'';
installPhase = ''
mkdir -p $out
cp -r _build/prod/rel/systant/* $out/
'';
};
systantWrapper = writeShellScript "systant" ''
#!/bin/bash
export RELEASE_COOKIE="systant-cookie-change-in-production"
exec ${systantBase}/bin/systant "$@"
'';
in stdenv.mkDerivation {
pname = "systant";
version = "0.1.0";
buildCommand = ''
mkdir -p $out/bin
cp ${systantWrapper} $out/bin/systant
chmod +x $out/bin/systant
# Copy the rest of the release
cp -r ${systantBase}/* $out/
echo "Wrapper created - RELEASE_COOKIE will be set by wrapper script"
'';
meta = with lib; {
description = "Systant - System stats MQTT daemon";
homepage = "https://git.ryanpandya.com/ryan/systant";
license = licenses.mit;
platforms = platforms.linux;
};
}
-32
View File
@@ -1,32 +0,0 @@
{
lib,
beamPackages,
fetchgit,
}:
beamPackages.mixRelease rec {
pname = "systant";
version = "0.1.0";
src = fetchgit {
url = "https://git.ryanpandya.com/ryan/systant.git";
rev = "54f8d23945bcea17127631a81f8ac318bf7047a6";
sha256 = "sha256-1cRfSoH+JdO4a7q4hRZSkoDMk2wMCYRIyCIN56FSUgg=";
};
# Mix dependencies will be automatically fetched and cached by Nix
mixFodDeps = beamPackages.fetchMixDeps {
pname = "systant-mix-deps";
inherit src version;
sha256 = "sha256-g8L/ZzCaXznrd+YLCMgvV94NVTKoFnK/Y/RXXPIMAjg=";
};
meta = with lib; {
description = "Systant - System stats MQTT daemon for monitoring system metrics";
homepage = "https://git.ryanpandya.com/ryan/systant";
license = licenses.mit;
maintainers = [ ];
platforms = platforms.linux;
};
}
-26
View File
@@ -1,26 +0,0 @@
[Unit]
Description=Systant MQTT Daemon
After=network.target
[Service]
Type=exec
User=root
Group=root
ExecStart=/opt/systant/bin/systant start
ExecStop=/opt/systant/bin/systant stop
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal
SyslogIdentifier=systant
WorkingDirectory=/opt/systant
# 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
+75
View File
@@ -0,0 +1,75 @@
# Systant Configuration
# Copy this to systant.toml and customize for your system
[systant]
# hostname = "myhost" # defaults to system hostname
[mqtt]
broker = "mqtt://localhost:1883"
# username = "user"
# password = "secret"
# clientId = "systant-myhost" # defaults to systant-{hostname}
topicPrefix = "systant"
[entities]
interval = 30 # default interval in seconds
# Sensor examples
[entities.cpu_usage]
type = "sensor"
state_command = "awk '/^cpu / {u=$2+$4; t=$2+$4+$5; print int(u*100/t)}' /proc/stat"
unit = "%"
icon = "mdi:cpu-64-bit"
name = "CPU Usage"
[entities.memory]
type = "sensor"
state_command = "awk '/MemTotal/{t=$2} /MemAvailable/{a=$2} END {print int((t-a)/t*100)}' /proc/meminfo"
unit = "%"
icon = "mdi:memory"
name = "Memory Usage"
[entities.last_seen]
type = "sensor"
state_command = "date -Iseconds"
device_class = "timestamp"
icon = "mdi:clock-check"
name = "Last Seen"
availability = false # keeps last value when offline
# Binary sensor example (read-only on/off)
# [entities.service_running]
# type = "binary_sensor"
# state_command = "systemctl is-active myservice >/dev/null && echo ON || echo OFF"
# device_class = "running"
# icon = "mdi:cog"
# name = "My Service"
# Light example (controllable, for things like monitors)
# [entities.screen]
# type = "light"
# state_command = "xrandr | grep -q 'connected primary' && echo ON || echo OFF"
# on_command = "xrandr --output DP-1 --auto"
# off_command = "xrandr --output DP-1 --off"
# icon = "mdi:monitor"
# name = "Screen"
# Switch example (controllable on/off)
# [entities.vpn]
# type = "switch"
# state_command = "systemctl is-active openvpn >/dev/null && echo ON || echo OFF"
# on_command = "systemctl start openvpn"
# off_command = "systemctl stop openvpn"
# icon = "mdi:vpn"
# name = "VPN"
# Button example (just executes a command)
# [entities.sync_time]
# type = "button"
# press_command = "ntpdate pool.ntp.org"
# icon = "mdi:clock-sync"
# name = "Sync Time"
[homeassistant]
discovery = true
discoveryPrefix = "homeassistant"
-8
View File
@@ -1,8 +0,0 @@
defmodule SystantTest do
use ExUnit.Case
doctest Systant
test "greets the world" do
assert Systant.hello() == :world
end
end
-1
View File
@@ -1 +0,0 @@
ExUnit.start()
+29
View File
@@ -0,0 +1,29 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
}
}