Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,13 @@ local user. Without it the daemon still runs, but it has no fallback
if the kernel refuses to send on a raw socket after dropping
privileges.

To start at boot: `misc/sysmond.service` (systemd) and
`misc/rc.d/sysmond` (OpenBSD rc.d) are ready to copy into place; the
web UI's counterparts are `web-ui/sysmon-web.service` and
`web-ui/rc.d/sysmon_web`. The comments at the top of each say where it
goes and what to enable. sysmond's files set no user on purpose: it
must start as root (raw ICMP sockets) and drops privileges on its own.

`examples/` also has `sysmon.conf.full`, which shows every directive, and
`sysmon.conf.fleet` - the 500-object wireless ISP in the screenshots
above. The fleet file is addressed entirely on loopback, so
Expand Down
16 changes: 15 additions & 1 deletion android/app/src/main/java/com/sysmon/app/Models.kt
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ data class LoginResponse(
@Serializable
data class Host(
@SerialName("object_name") val objectName: String = "",
// objectName's two halves: the bare name the owning daemon knows,
// and which daemon that is. Shown separately - a name and a small
// site tag - never re-joined into "site:host".
@SerialName("local_name") val localName: String = "",
val site: String = "",
val hostname: String,
val description: String = "",
@SerialName("ipv4_address") val ipv4: String = "",
Expand All @@ -41,6 +46,8 @@ data class Host(
val isDown: Boolean get() = overallStatus == "CRITICAL"
val isWarning: Boolean get() = overallStatus == "WARNING"
val isOK: Boolean get() = overallStatus == "OK"
// "local" is the single-box case, where naming the site says nothing.
val siteTag: String get() = if (site == "local") "" else site
}

/**
Expand Down Expand Up @@ -160,12 +167,19 @@ data class TestPushResponse(val status: String = "", val warning: String? = null
data class HistoryEvent(
val timestamp: String = "",
@SerialName("object_name") val objectName: String = "",
@SerialName("local_name") val localName: String = "",
val site: String = "",
val hostname: String = "",
val description: String = "",
@SerialName("prev_status") val prevStatus: String = "",
@SerialName("new_status") val newStatus: String = "",
@SerialName("prev_duration_seconds") val prevDuration: Long = 0
)
) {
// Bare name plus a separate site tag; the qualified objectName is
// only the fallback against a server that predates the split.
val displayName: String get() = localName.ifEmpty { objectName.ifEmpty { hostname } }
val siteTag: String get() = if (site == "local") "" else site
}

@Serializable
data class HistoryResponse(
Expand Down
31 changes: 30 additions & 1 deletion android/app/src/main/java/com/sysmon/app/ui/Components.kt
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
Expand Down Expand Up @@ -342,6 +343,26 @@ fun StatsRow(stats: Stats?) {
}
}

/**
* The owning sysmond's name as a small muted pill. Kept deliberately
* quiet - it is context, not the subject - and absent entirely on a
* single-box install, where the row looks exactly as it always did.
*/
@Composable
fun SiteTag(name: String) {
Text(
text = name,
fontSize = 9.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
modifier = Modifier
.padding(start = 6.dp)
.clip(RoundedCornerShape(50))
.background(MaterialTheme.colorScheme.surfaceVariant)
.padding(horizontal = 5.dp, vertical = 1.dp)
)
}

@Composable
fun HostRow(host: Host, onClick: (() -> Unit)? = null) {
Card {
Expand All @@ -359,8 +380,16 @@ fun HostRow(host: Host, onClick: (() -> Unit)? = null) {
Text(
text = host.hostname,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onBackground
color = MaterialTheme.colorScheme.onBackground,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
// A long hostname ellipsizes rather than pushing the
// site tag (and PAUSED pill) out of the row.
modifier = Modifier.weight(1f, fill = false)
)
if (host.siteTag.isNotEmpty()) {
SiteTag(host.siteTag)
}
if (host.paused) {
Text(
text = "PAUSED",
Expand Down
28 changes: 22 additions & 6 deletions android/app/src/main/java/com/sysmon/app/ui/HistoryScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect
Expand Down Expand Up @@ -196,12 +197,27 @@ private fun HistoryRow(ev: HistoryEvent, clock: Long) {
StatusDot(ev.newStatus)
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = ev.objectName.ifEmpty { ev.hostname },
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.weight(1f)
)
// Bare name with the owning box as its own quiet tag,
// not the overloaded "site:host" string.
Row(
modifier = Modifier.weight(1f),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = ev.displayName,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onBackground,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
// fill=false: take only what the name needs, but
// never more than leaves the site tag visible - a
// long name must ellipsize, not shove the tag out.
modifier = Modifier.weight(1f, fill = false)
)
if (ev.siteTag.isNotEmpty()) {
SiteTag(ev.siteTag)
}
}
Text(
// clock in the expression makes the age
// recompose on the ticker, not only on new data.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ fun HostDetailSheet(host: Host, onDismiss: () -> Unit) {
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onBackground
)
if (host.siteTag.isNotEmpty()) {
SiteTag(host.siteTag)
}
}

Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Expand Down
175 changes: 175 additions & 0 deletions docs/ALERTERS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
# Alerters: sending alerts to sysmon-web without being a sysmond

An alerter is a daemon with something to say and no fleet of hosts
behind it - a backup job, a UPS script, a RAID monitor, a cron
watchdog. It connects to the same TLS listener the monitoring boxes
use, proves itself with the same kind of minted token, and then sends
alerts. Those alerts ride the exact push pipeline a sysmond's host
transitions ride, with the same priorities: CRITICAL is loud on the
phones, WARNING and OK are quiet.

An alerter is never part of the fleet. It has no config to manage, no
hosts to poll, no generations to roll out. The Fleet page lists it in
its own "Alerters" section; the config editor and the map never see it.

## Getting a token

Same as a monitoring box: **Admin -> Monitoring boxes -> Add a box**.
Mint a token under the name the alerter will use (letters, digits,
`-`, `_`; max 64 chars). The name is the alerter's identity - it
appears in notifications and on the Fleet page - so name the thing,
not the machine: `backupd`, not `server3`.

Revoking the token on the same page cuts the alerter off at its next
connection attempt.

## Connecting

- **Transport**: TLS to sysmon-web's agent port (default `1347`, the
`-agent-listen` flag). TLS is required, not negotiable - the first
line carries a bearer token.
- **Server certificate**: sysmon-web generates a self-signed
certificate on first start (or uses `-agent-cert`/`-agent-key`).
Verify against that certificate - the same `aggregator-ca.pem` a
monitoring box pins. Skipping verification hands your token to
whoever answers the port first.
- The connection is long-lived. Stay connected and send alerts as they
happen; reconnect with backoff when the link drops. The server never
polls an alerter, so a silent alerter costs nothing.

## Protocol

Text lines, terminated by `\n` (a trailing `\r` is tolerated). One
line may carry at most 4096 bytes; anything past that on the same
line is discarded, not buffered. Every reply is one line starting
`333 ` (success) or `444 ` (refusal).

### Handshake (first line, within 20 seconds of connecting)

ALERTER <name> <token> [application name...]

- `333 welcome` - authenticated; send alerts from here on.
- `444 rejected` - bad name/token pair, or the token is revoked. The
socket closes; back off before retrying.
- `444 this token belongs to a sysmond` - the token was minted for (and
first used by) a monitoring box; a token keeps the kind of its first
handshake forever. Mint a separate token for the alerter.

Everything after the token is what the application calls itself -
free text up to 128 characters, e.g. `Bacula 15.0 nightly backups`.
It shows on the Fleet page and in alerts. Optional but worth sending:
the token name identifies, this describes.

The greeting verb is what separates an alerter from a monitoring box:
a sysmond says `HELLO` and gets polled, an alerter says `ALERTER` and
does the talking.

### Sending an alert

ALERT <CRITICAL|WARNING|OK> <object> <text...>

- `<object>` names the thing the alert is about (same character rules
as the alerter name). One alerter can alert about many objects.
- `<text>` is free-form to end of line, up to 512 characters
(anything longer is truncated, not refused). Optional - omitted, a
plain "name reports object STATUS" is generated.
- Reply is `333 ok` once accepted, or `444 <reason>` for a malformed
line. A `444` never closes the connection; fix the line and carry on.
- `444 busy - ...` means the server's delivery pipeline is backed up
and the alert was **not** accepted. Retry the same line after a short
delay; `333 ok` is the only reply that means the alert was taken.

Semantics, identical to a sysmond's transitions:

- **CRITICAL** delivers loud: sound, heads-up on Android, time-sensitive
on iOS.
- **WARNING** and **OK** deliver quiet: they land in the notification
shade without a sound. Send `OK` when the condition clears - it
replaces the earlier alert on the phones rather than stacking a
second notification, because `<alerter>:<object>` is the collapse
key, exactly as host alerts collapse per host.
- Delivery honors the master push switch in the admin UI; alerts sent
while push is disabled are acknowledged and dropped, and the server
log says so.

### Keepalive and goodbye

PING -> 333 pong
QUIT -> 333 bye (server closes)

Send `PING` every minute or so if your network kills idle
connections; the server does not require it.

## Names, nicknames, and what an alert shows

Three names are in play, in order of what alerts display:

1. **Nickname** - optional, set by an admin on the Fleet page's
Alerters card (the pencil next to "Shows as"). Wins when set.
2. **Application name** - what the alerter declared at handshake.
3. **Token name** - the identity; the fallback when nothing else is set.

The token name is what keys everything internally - collapse keys,
logs, the registry - so renaming a nickname never re-keys anything.

## What the web UI does with alerts

- Push notifications to every subscribed phone, with the priority
routing above.
- The admin **Push Log** records each fan-out like any other.
- The **Fleet page** shows the alerter: connected or gone, what it
shows as (nickname or application name), its address, how many
alerts it has sent, and the last one.

Alerts are fire-and-forget by design: they are not stored as host
state, do not appear on the dashboard, and are not replayed to phones
that subscribe later. If a thing needs its state *tracked*, it wants
to be a monitored host on a sysmond, not an alerter.

## Example: shell

```sh
# One-shot alert via openssl s_client (BSD echo; adjust for your shell).
{
echo 'ALERTER backupd tok-abc123... Bacula 15.0 nightly backups'
sleep 1
echo 'ALERT CRITICAL nightly-backup tape jam in drive 2'
sleep 1
echo 'QUIT'
} | openssl s_client -quiet -connect sysmon-web.example.net:1347 \
-CAfile aggregator-ca.pem -verify_return_error
```

## Example: Python

```python
import socket, ssl, time

HOST, PORT = "sysmon-web.example.net", 1347
NAME, TOKEN = "backupd", "tok-abc123..."

ctx = ssl.create_default_context(cafile="aggregator-ca.pem")
ctx.check_hostname = False # self-signed cert carries no hostname

def connect():
raw = socket.create_connection((HOST, PORT), timeout=20)
tls = ctx.wrap_socket(raw)
f = tls.makefile("rw", newline="\n")
f.write(f"ALERTER {NAME} {TOKEN} Bacula 15.0 nightly backups\n"); f.flush()
if not f.readline().startswith("333"):
raise RuntimeError("rejected")
return f

def alert(f, status, obj, text):
f.write(f"ALERT {status} {obj} {text}\n"); f.flush()
return f.readline().startswith("333")

f = connect()
alert(f, "CRITICAL", "nightly-backup", "tape jam in drive 2")
# ... later, when it clears:
alert(f, "OK", "nightly-backup", "backup completed after operator fixed the jam")
```

Reconnect on any read/write error, with backoff (the monitoring boxes
use a few seconds, doubling to a minute; do the same). The token is a
credential - keep it out of argv and world-readable files.
9 changes: 9 additions & 0 deletions docs/WEB_DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,15 @@ systemctl daemon-reload
systemctl enable --now sysmon-web
```

One deliberate limitation: the shipped unit's `ProtectSystem=strict`
sandbox leaves `/etc` read-only, so saving the **local**
`/etc/sysmon.conf` from the config editor fails under it (the save
writes `/etc/sysmon.conf.tmp` and renames, which needs a writable
`/etc` directory). Editing per-box configs over the agent link - the
normal fleet flow - is unaffected. If you want local editor saves on
this host, add a drop-in with `ReadWritePaths=-/etc` and make the file
and directory writable by `www-data`.

### 3.2 nginx server block

See `web-ui/nginx.conf.example` for the full TLS version. Minimum:
Expand Down
8 changes: 7 additions & 1 deletion ios/Sysmon/HistoryView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -114,9 +114,15 @@ struct HistoryRow: View {
StatusDot(status: event.newStatus)
VStack(alignment: .leading, spacing: 3) {
HStack {
Text(event.objectName.isEmpty ? event.hostname : event.objectName)
// Bare name with the owning box as its own quiet tag,
// not the overloaded "site:host" string.
Text(event.displayName)
.font(.system(size: 15, weight: .semibold))
.foregroundColor(Theme.ink)
.lineLimit(1)
if !event.siteTag.isEmpty {
SiteTag(name: event.siteTag)
}
Spacer()
Text(relativeTime(event.timestamp))
.font(.system(size: 11))
Expand Down
3 changes: 3 additions & 0 deletions ios/Sysmon/MainView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,9 @@ struct HostRow: View {
Text(host.hostname)
.font(.system(size: 15, weight: .semibold))
.foregroundColor(Theme.ink)
if !host.siteTag.isEmpty {
SiteTag(name: host.siteTag)
}
if host.isPaused {
Text("PAUSED")
.font(.system(size: 8, weight: .bold))
Expand Down
Loading
Loading