diff --git a/README.md b/README.md index d193f0d..b307b8c 100644 --- a/README.md +++ b/README.md @@ -249,8 +249,8 @@ first start: sysmon-web -agent-names sysmon-web.example.net ``` -**2. Make a token for the site.** Open **Admin -> Monitoring boxes -> -Add a box**. Give it a site name and a label. The page then shows the +**2. Make a token for the site.** Open **Admin -> Agents & alerters -> +Add credential**. Give it a site name and a label. The page then shows the complete set of config lines, with the token in them, and a button that copies them. The server keeps only a hash, so it shows the token one time. diff --git a/android/app/src/main/java/com/sysmon/app/Models.kt b/android/app/src/main/java/com/sysmon/app/Models.kt index 90b4e3b..ce31d7c 100644 --- a/android/app/src/main/java/com/sysmon/app/Models.kt +++ b/android/app/src/main/java/com/sysmon/app/Models.kt @@ -165,6 +165,11 @@ data class TestPushResponse(val status: String = "", val warning: String? = null @Serializable data class HistoryEvent( + // The store's immutable sequence number; row identity for lists. + // Timestamps only carry second precision, so two same-status alerts + // in one second would collide without it. 0 on rows from servers + // that predate the field. + val id: Long = 0, val timestamp: String = "", @SerialName("object_name") val objectName: String = "", @SerialName("local_name") val localName: String = "", diff --git a/android/app/src/main/java/com/sysmon/app/ui/HistoryScreen.kt b/android/app/src/main/java/com/sysmon/app/ui/HistoryScreen.kt index f9df180..43ab535 100644 --- a/android/app/src/main/java/com/sysmon/app/ui/HistoryScreen.kt +++ b/android/app/src/main/java/com/sysmon/app/ui/HistoryScreen.kt @@ -39,7 +39,7 @@ import kotlinx.coroutines.launch private enum class HistoryFilter(val label: String) { ALL("ALL"), - DOWNS("DOWNS"), + DOWNS("PROBLEMS"), RECOVERIES("RECOVERIES") } @@ -177,7 +177,10 @@ fun HistoryScreen() { else "No events match the filter" ) } - else -> itemsIndexed(filtered) { _, ev -> + else -> itemsIndexed( + filtered, + key = { idx, ev -> if (ev.id != 0L) ev.id else "row-" + idx } + ) { _, ev -> HistoryRow(ev, clock) } } @@ -226,20 +229,35 @@ private fun HistoryRow(ev: HistoryEvent, clock: Long) { color = MaterialTheme.colorScheme.onSurfaceVariant ) } + // The alert's own message (an alerter's text), when there + // is one - it is the payload, not decoration. + if (ev.description.isNotEmpty()) { + Text( + text = ev.description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp) ) { - Text( - text = ev.prevStatus, - style = MaterialTheme.typography.labelMedium, - color = statusColor(ev.prevStatus) - ) - Text( - text = "→", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) + // An object's first-ever event has no previous state: + // no empty badge, no dangling arrow. + if (ev.prevStatus.isNotEmpty()) { + Text( + text = ev.prevStatus, + style = MaterialTheme.typography.labelMedium, + color = statusColor(ev.prevStatus) + ) + Text( + text = "→", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } Text( text = ev.newStatus, style = MaterialTheme.typography.labelMedium, diff --git a/docs/ALERTERS.md b/docs/ALERTERS.md index 36f55a3..abca71c 100644 --- a/docs/ALERTERS.md +++ b/docs/ALERTERS.md @@ -14,14 +14,20 @@ 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`. +**Admin -> Agents & alerters -> Add credential**, with the credential +type set to **External alerter** - the panel then shows the greeting +line below instead of sysmond config. Mint the 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. +The type is part of the credential: a token minted for an alerter is +refused if something greets with it as a sysmond, and the other way +around. + +Revoking the token on the same page cuts the alerter off immediately - +the live connection is closed and the next attempt is refused. ## Connecting @@ -40,9 +46,12 @@ connection attempt. ## 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). +line may carry at most 4096 bytes; a longer line is refused with +`444 line too long` rather than processed as something shorter than +what was sent. After authentication the connection survives the +refusal; an overlong (or otherwise malformed) **greeting** gets the +444 and then the socket closes, like any other failed handshake. +Every reply is one line starting `333 ` (success) or `444 ` (refusal). ### Handshake (first line, within 20 seconds of connecting) @@ -51,9 +60,10 @@ line is discarded, not buffered. Every reply is one line starting - `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. +- `444 this token belongs to a sysmond` - the token was minted for a + monitoring box. New credentials are permanently typed when minted; + only legacy records with no stored kind are claimed by their first + successful greeting. Mint a separate alerter credential. Everything after the token is what the application calls itself - free text up to 128 characters, e.g. `Bacula 15.0 nightly backups`. @@ -75,9 +85,31 @@ does the talking. plain "name reports object STATUS" is generated. - Reply is `333 ok` once accepted, or `444 ` 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. +- `444 could not record the alert - ...` means the history write + failed and the alert was **not** accepted; retry after a short + delay. `444 alert history unavailable ...` means this server cannot + record alerts at all. `333 ok` is the only reply that means the + alert was taken. +What `333 ok` promises, exactly: the alert is recorded in the web +UI's **alert history** - written to disk before the reply, visible on +the History page, surviving server restarts - and, when push is +enabled, queued for immediate phone delivery in order with everything +else this alerter has sent. The history record is the delivery +guarantee; push is the extra channel on top, attempted only after the +history commit. Phone-side delivery is best-effort and its failures +never appear on the wire (a refusal would invite a retry that +duplicates the recorded event): a provider outage after acceptance +shows in the server log and the admin Push Log, a saturated push +queue in the server log. If an alert matters, keep re-sending +transitions as the condition changes rather than treating one 333 as +the end of the story. + +Ingestion is rate limited per alerter: a burst of 30 alerts, refilling +at one per second. Past that, `444 rate limited - ...` refuses the +line before anything is recorded - the alert history is shared with +the fleet's host transitions and bounded, and a looping script must +not be able to churn it. Back off and retry; a well-behaved alerter +sending state *transitions* never notices this limit. Semantics, identical to a sysmond's transitions: @@ -88,9 +120,10 @@ Semantics, identical to a sysmond's transitions: replaces the earlier alert on the phones rather than stacking a second notification, because `:` 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. +- The master push switch in the admin UI only governs the phones: + alerts sent while push is disabled are still accepted, recorded, and + shown in the web UI - they just page nobody, and the server log says + so. ### Keepalive and goodbye @@ -114,17 +147,22 @@ logs, the registry - so renaming a nickname never re-keys anything. ## What the web UI does with alerts +- Records each alert in the **History** page's log, alongside host + transitions, as `:` - with the status it changed + from and how long the previous state lasted, once this server has + seen the object before. - Push notifications to every subscribed phone, with the priority - routing above. + routing above, when push is enabled. - 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. +Alerts are events, not tracked state: they do not appear on the +dashboard's host board and are not replayed to phones that subscribe +later. If a thing needs its state *tracked* - polled, colored, +acknowledged - it wants to be a monitored host on a sysmond, not an +alerter. ## Example: shell @@ -143,17 +181,25 @@ to be a monitored host on a sysmond, not an alerter. ## Example: Python ```python -import socket, ssl, time +import os, socket, ssl, time HOST, PORT = "sysmon-web.example.net", 1347 -NAME, TOKEN = "backupd", "tok-abc123..." - +NAME = "backupd" +# A credential stays out of source and argv: a mode-0600 file or the +# environment of the service unit that runs this. +TOKEN = os.environ["SYSMON_TOKEN"] + +# Verify the certificate AND its name. sysmon-web puts the names the +# daemons dial it by into its generated certificate (the -agent-names +# flag); start it with the name you use here in that list. Only fall +# back to ctx.check_hostname = False against an old certificate that +# carries no usable name - it weakens the check to "any holder of a +# CA-signed cert", so regenerate the certificate instead if you can. 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) + tls = ctx.wrap_socket(raw, server_hostname=HOST) 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"): diff --git a/docs/WEB_DEPLOYMENT.md b/docs/WEB_DEPLOYMENT.md index 589d301..e80982e 100644 --- a/docs/WEB_DEPLOYMENT.md +++ b/docs/WEB_DEPLOYMENT.md @@ -22,14 +22,15 @@ returns your shell prompt. | Invocation | Behaviour | |---|---| -| `sysmon-web …` | Daemonizes, **silent** (no logs). | +| `sysmon-web …` | Daemonizes, **silent** (nothing is watching stderr). | | `sysmon-web -debug …` | Stays in the foreground, logs to **stderr**. Use this to find out why something won't start. | -| `sysmon-web -foreground …` | Stays in the foreground, still silent. For process supervisors that track the PID themselves (systemd `Type=simple`, OpenBSD `rc.d`). Add `-debug` to also get logs. | +| `sysmon-web -foreground …` | Stays in the foreground, logs to **stderr**. For process supervisors that track the PID themselves (systemd `Type=simple`, OpenBSD `rc.d`) - normal warnings and errors land in the journal / rc log, where an operator can find them. | -Logs are **off unless `-debug`** is given - a daemon shouldn't chatter. -If the service won't come up, the move is always: stop it, run it once -in the foreground with `-debug`, read the error, fix, restart under the -supervisor. +Under a supervisor, ordinary logs are always on: a monitoring server +that drops a page must not also drop the log line saying so. Only the +self-daemonized mode is silent, because its stderr goes nowhere. If +the service won't come up, run it once with `-debug`, read the error, +fix, restart under the supervisor. > Under a supervisor you almost always want `-foreground`. If you let it > self-daemonize under `Type=simple`, systemd sees the parent exit @@ -90,10 +91,13 @@ Use the shipped unit (`web-ui/sysmon-web.service`); the important bits: Type=simple User=www-data Group=www-data -# www-data can't mkdir under root-owned /var/www, so create the socket -# dir as root first (the "+" runs these as root despite User=www-data). +# The binary prepares its own directories only when it starts as root; +# under User=www-data it never does, so the unit prepares every path +# the unprivileged process cannot create (the "+" runs these as root). ExecStartPre=+/bin/mkdir -p /var/www/run ExecStartPre=+/bin/chown www-data:www-data /var/www/run +ExecStartPre=+/usr/bin/install -d -o www-data -g www-data /var/backups/sysmon +ExecStartPre=+/bin/sh -c 'touch /var/log/sysmon-web-audit.log && chown www-data:www-data /var/log/sysmon-web-audit.log' ExecStart=/usr/local/bin/sysmon-web \ -foreground \ -socket /var/www/run/sysmon-web.sock \ @@ -105,9 +109,11 @@ Restart=always Because it runs as `www-data`, the socket is already owned by nginx's user - no `-socket-*` flags needed. The `ExecStartPre` lines create -`/var/www/run` (which `ProtectSystem=strict` also lists in -`ReadWritePaths`). Add `-debug` to the `ExecStart` line temporarily to -get logs in the journal (`journalctl -u sysmon-web -f`). +the socket directory, the backup directory, and the audit log, all +owned by `www-data` (`/var/lib/sysmon` is `StateDirectory=`, which +systemd itself prepares). With `-foreground`, normal logs already go +to the journal (`journalctl -u sysmon-web -f`); `-debug` adds verbose +diagnostics on top. ```sh cp web-ui/sysmon-web.service /etc/systemd/system/ diff --git a/ios/Sysmon/HistoryView.swift b/ios/Sysmon/HistoryView.swift index 23c7f5e..2d59ad0 100644 --- a/ios/Sysmon/HistoryView.swift +++ b/ios/Sysmon/HistoryView.swift @@ -1,7 +1,7 @@ import SwiftUI // Alert history: every host state transition the server has observed, -// newest first, with All / Downs / Recoveries filtering. +// newest first, with All / Problems / Recoveries filtering. struct HistoryView: View { @EnvironmentObject var session: Session @State private var events: [HistoryEvent] = [] @@ -17,7 +17,7 @@ struct HistoryView: View { enum HistoryFilter: String, CaseIterable { case all = "All" - case downs = "Downs" + case downs = "Problems" case recoveries = "Recoveries" } @@ -128,20 +128,32 @@ struct HistoryRow: View { .font(.system(size: 11)) .foregroundColor(Theme.subtle) } + // The alert's own message (an alerter's text) is the + // payload - show it, not just the transition. + if let desc = event.description, !desc.isEmpty { + Text(desc) + .font(.system(size: 11)) + .foregroundColor(Theme.subtle) + .lineLimit(2) + } HStack(spacing: 6) { - Text(event.prevStatus) - .font(.system(size: 9, weight: .bold)) - .tracking(0.5) - .foregroundColor(statusColor(event.prevStatus)) - Image(systemName: "arrow.right") - .font(.system(size: 8, weight: .semibold)) - .foregroundColor(Theme.faint) + // An object's first-ever event has no previous state: + // no empty badge, no dangling arrow. + if !event.prevStatus.isEmpty { + Text(event.prevStatus) + .font(.system(size: 9, weight: .bold)) + .tracking(0.5) + .foregroundColor(statusColor(event.prevStatus)) + Image(systemName: "arrow.right") + .font(.system(size: 8, weight: .semibold)) + .foregroundColor(Theme.faint) + } Text(event.newStatus) .font(.system(size: 9, weight: .bold)) .tracking(0.5) .foregroundColor(statusColor(event.newStatus)) Spacer() - if let dur = event.prevDuration, dur > 0 { + if let dur = event.prevDuration, dur > 0, !event.prevStatus.isEmpty { Text("was \(event.prevStatus.lowercased()) \(formatUptime(dur))") .font(.system(size: 10)) .foregroundColor(Theme.subtle) diff --git a/ios/Sysmon/MainView.swift b/ios/Sysmon/MainView.swift index b420b27..2289165 100644 --- a/ios/Sysmon/MainView.swift +++ b/ios/Sysmon/MainView.swift @@ -266,9 +266,15 @@ struct HostRow: View { StatusDot(status: host.overallStatus, pulse: host.isDown && !host.isPaused) VStack(alignment: .leading, spacing: 3) { HStack(spacing: 6) { + // A long hostname ellipsizes rather than wrapping or + // squeezing the tags out of the row; layoutPriority + // makes the badges yield space before the name does. Text(host.hostname) .font(.system(size: 15, weight: .semibold)) .foregroundColor(Theme.ink) + .lineLimit(1) + .truncationMode(.tail) + .layoutPriority(1) if !host.siteTag.isEmpty { SiteTag(name: host.siteTag) } diff --git a/ios/Sysmon/Models.swift b/ios/Sysmon/Models.swift index 10568b3..6086198 100644 --- a/ios/Sysmon/Models.swift +++ b/ios/Sysmon/Models.swift @@ -212,6 +212,11 @@ struct StatusDelta: Codable { // One observed host state transition, from /api/monitoring/history. struct HistoryEvent: Codable, Identifiable, Equatable { + // The store's immutable sequence number. Timestamps only carry + // second precision, so two same-status alerts within one second + // would collide as list identity without it. Absent on rows from + // servers that predate the field. + let eventID: UInt64? let timestamp: String let objectName: String let localName: String? @@ -222,7 +227,10 @@ struct HistoryEvent: Codable, Identifiable, Equatable { let newStatus: String let prevDuration: Int64? - var id: String { timestamp + objectName + prevStatus + newStatus } + var id: String { + if let n = eventID { return String(n) } + return timestamp + objectName + prevStatus + newStatus + } // Bare name plus a separate site tag; the qualified objectName is // only the fallback against a server that predates the split. @@ -236,6 +244,7 @@ struct HistoryEvent: Codable, Identifiable, Equatable { } enum CodingKeys: String, CodingKey { + case eventID = "id" case timestamp case objectName = "object_name" case localName = "local_name" diff --git a/ios/Sysmon/Theme.swift b/ios/Sysmon/Theme.swift index cb05c82..43628c9 100644 --- a/ios/Sysmon/Theme.swift +++ b/ios/Sysmon/Theme.swift @@ -81,10 +81,15 @@ struct SiteTag: View { Text(name) .font(.system(size: 9, design: .monospaced)) .foregroundColor(Theme.subtle) + .lineLimit(1) + // Context, not the subject: rows give the name layout + // priority, so when space runs out it is this tag that + // shrinks - truncating in the middle keeps both ends of a + // long site name readable. + .truncationMode(.middle) .padding(.horizontal, 5) .padding(.vertical, 2) .background(Capsule().fill(Theme.surfaceSubtle)) - .lineLimit(1) } } diff --git a/src/syswatch.c b/src/syswatch.c index cc5c88a..a0a58c8 100644 --- a/src/syswatch.c +++ b/src/syswatch.c @@ -1782,7 +1782,15 @@ void confgen_prepare_as_root(void) confgen_prepare(pw->pw_uid, pw->pw_gid); } -void revoke_root_if_necessary() +/* + * Drop root, entirely, or say so. Returns 0 when the process is no + * longer root (or never was); -1 when a root-started process could not + * complete the drop - identity missing, setgroups, setgid or setuid + * refused. The CALLER treats -1 as fatal: a monitoring daemon that was + * asked to shed root and cannot must not start monitoring as root + * instead, and a warning in a log nobody is reading yet is not consent. + */ +int revoke_root_if_necessary(void) { uid_t current_uid; uid_t current_euid; @@ -1799,15 +1807,15 @@ void revoke_root_if_necessary() { print_err(0, "revoke_root: Not running as root (euid=%d), no privileges to drop", current_euid); } - return; + return 0; } pw = sysmon_drop_user(); if (pw == NULL) { - print_err(1, "WARNING: Cannot drop root privileges - neither " + print_err(1, "CRITICAL: Cannot drop root privileges - neither " "'nobody' nor 'daemon' exists"); - return; + return -1; } drop_user = pw->pw_name; @@ -1871,27 +1879,32 @@ void revoke_root_if_necessary() * also makes the helper probe above honest - from here on, pw_gid * really is the only group this process holds, so a helper the * probe called unusable genuinely is. + * + * Any failure from here on is fatal to the caller: a partial drop + * (uid shed, a group kept, or the reverse) leaves the daemon with + * privileges nobody chose, and monitoring must not start on top of + * that. */ if (setgroups(1, &pw->pw_gid) != 0) { perror("revoke_root: setgroups"); - print_err(1, "WARNING: Failed to drop supplementary groups"); - return; + print_err(1, "CRITICAL: Failed to drop supplementary groups"); + return -1; } /* Drop privileges */ if (setgid(pw->pw_gid) != 0) { perror("revoke_root: setgid"); - print_err(1, "WARNING: Failed to drop group privileges to gid=%d", pw->pw_gid); - return; + print_err(1, "CRITICAL: Failed to drop group privileges to gid=%d", pw->pw_gid); + return -1; } if (setuid(pw->pw_uid) != 0) { perror("revoke_root: setuid"); - print_err(1, "WARNING: Failed to drop user privileges to uid=%d", pw->pw_uid); - return; + print_err(1, "CRITICAL: Failed to drop user privileges to uid=%d", pw->pw_uid); + return -1; } /* Verify privileges were actually dropped */ @@ -1899,12 +1912,12 @@ void revoke_root_if_necessary() { print_err(1, "CRITICAL: Failed to drop root privileges! Still running as root (uid=%d, euid=%d)", getuid(), geteuid()); - print_err(1, "CRITICAL: This is a security risk. Exiting."); - exit(1); + return -1; } print_err(0, "Successfully dropped root privileges to user '%s' (uid=%d, gid=%d)", drop_user, getuid(), getgid()); + return 0; } /* @@ -1966,7 +1979,12 @@ do_watch(char *cmdname, int listenport, char *myhostname) */ confgen_prepare_as_root(); write_pid_file(); - revoke_root_if_necessary(); + if (revoke_root_if_necessary() != 0) + { + print_err(1, "CRITICAL: cannot drop root privileges - refusing to monitor as root. Exiting."); + unlink(sysmon_pidfile()); + exit(1); + } while (1) { @@ -2033,8 +2051,12 @@ do_watch(char *cmdname, int listenport, char *myhostname) statuschanged = FALSE; } - /* */ - while (paused && (!gotsighup)) + /* stop_daemon must break this loop too: SIGTERM during a + pause otherwise sets the flag and then waits for an unpause + or a SIGHUP that may never come, and the supervisor's + graceful stop times out into a SIGKILL - skipping the very + state save a graceful stop exists for. */ + while (paused && !gotsighup && !stop_daemon) { time(&now_t); service_checks(now_t); diff --git a/web-ui/backend/api/openapi.yaml b/web-ui/backend/api/openapi.yaml index e141a5b..703c1fb 100644 --- a/web-ui/backend/api/openapi.yaml +++ b/web-ui/backend/api/openapi.yaml @@ -6,15 +6,20 @@ info: real-time monitoring data, administrative commands, and bulk operations. ## Rate Limiting - All API endpoints are rate limited to 60 requests per minute per IP address. - Rate limit information is returned in response headers: + Requests are limited per client IP: 10 per minute for `/api/auth/login`, + 300 per minute for everything else. `/api/auth/logout` is never limited - + it needs a valid session, and refusing a logout helps nobody. Rate limit + information is returned in response headers: - `X-RateLimit-Limit`: Maximum requests allowed in the time window - `X-RateLimit-Remaining`: Number of requests remaining in current window - `X-RateLimit-Reset`: Unix timestamp when the rate limit resets ## Authentication - Most write operations and all admin endpoints require authentication via the `X-Auth-Key` header. - The auth key must match the key configured in the sysmon daemon. + Session-based: log in via `POST /api/auth/login` to receive a session + token, then present it either as `Authorization: Bearer ` or in + the `sysmon_session` cookie (the browser UI uses the cookie). Sessions + renew on use with a 30-day sliding expiry. Admin-only endpoints + additionally require the session's user to hold the admin role. version: 1.0.0 contact: name: Sysmon API Support @@ -27,6 +32,12 @@ servers: - url: https://api.sysmon.example.com description: Production server +# Every endpoint requires a session unless it explicitly opts out +# (login does; it is how a session is obtained). +security: + - SessionAuth: [] + - CookieAuth: [] + tags: - name: Configuration description: Configuration file management @@ -42,6 +53,52 @@ tags: description: Raw XML data access (legacy) paths: + /api/auth/login: + post: + tags: [Admin] + summary: Log in + description: | + Exchanges credentials for a session token. Rate limited to 10 + requests per minute per IP. The token goes in either the + Authorization Bearer header or the sysmon_session cookie on + every later request; sessions renew on use with a 30-day + sliding expiry. + security: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [username, password] + properties: + username: { type: string } + password: { type: string } + responses: + '200': + description: Session created + content: + application/json: + schema: + type: object + properties: + token: { type: string } + username: { type: string } + role: { type: string } + '401': + description: Bad credentials + '429': + description: Rate limited + + /api/auth/logout: + post: + tags: [Admin] + summary: Log out + description: Ends the presented session. Never rate limited. + responses: + '200': + description: Session ended + # Configuration endpoints /api/config: get: @@ -110,7 +167,8 @@ paths: summary: Reload daemon configuration description: Sends SIGHUP to sysmond to reload configuration security: - - ApiKeyAuth: [] + - SessionAuth: [] + - CookieAuth: [] responses: '200': description: Reload triggered successfully @@ -272,7 +330,8 @@ paths: summary: Acknowledge host alert description: Acknowledges an alert for a specific host security: - - ApiKeyAuth: [] + - SessionAuth: [] + - CookieAuth: [] parameters: - name: hostname in: path @@ -302,7 +361,8 @@ paths: summary: Update host status note description: Updates the status note for a host security: - - ApiKeyAuth: [] + - SessionAuth: [] + - CookieAuth: [] parameters: - name: hostname in: path @@ -319,8 +379,6 @@ paths: note: type: string example: Investigating connectivity issue - auth_key: - type: string required: - note responses: @@ -346,7 +404,8 @@ paths: summary: Toggle trace for host description: Enables or disables debug tracing for a host security: - - ApiKeyAuth: [] + - SessionAuth: [] + - CookieAuth: [] parameters: - name: hostname in: path @@ -377,7 +436,8 @@ paths: summary: Acknowledge multiple hosts description: Acknowledges alerts for multiple hosts in a single request security: - - ApiKeyAuth: [] + - SessionAuth: [] + - CookieAuth: [] requestBody: required: true content: @@ -390,8 +450,6 @@ paths: items: type: string example: ["web1", "web2", "db1"] - auth_key: - type: string required: - hostnames responses: @@ -408,7 +466,8 @@ paths: summary: Update multiple hosts description: Updates status note for multiple hosts with the same message security: - - ApiKeyAuth: [] + - SessionAuth: [] + - CookieAuth: [] requestBody: required: true content: @@ -424,8 +483,6 @@ paths: note: type: string example: Maintenance window - planned outage - auth_key: - type: string required: - hostnames - note @@ -448,7 +505,8 @@ paths: summary: Toggle trace for multiple hosts description: Enables or disables tracing for multiple hosts security: - - ApiKeyAuth: [] + - SessionAuth: [] + - CookieAuth: [] requestBody: required: true content: @@ -464,8 +522,6 @@ paths: enable: type: boolean example: true - auth_key: - type: string required: - hostnames - enable @@ -504,7 +560,8 @@ paths: summary: Restore configuration backup description: Restores configuration from a backup file security: - - ApiKeyAuth: [] + - SessionAuth: [] + - CookieAuth: [] parameters: - name: filename in: path @@ -530,7 +587,8 @@ paths: summary: Get daemon version description: Returns the sysmon daemon version security: - - ApiKeyAuth: [] + - SessionAuth: [] + - CookieAuth: [] responses: '200': description: Version retrieved @@ -548,7 +606,8 @@ paths: summary: Toggle debug mode description: Toggles debug logging in the daemon security: - - ApiKeyAuth: [] + - SessionAuth: [] + - CookieAuth: [] responses: '200': description: Debug mode toggled @@ -566,7 +625,8 @@ paths: summary: Toggle SNMP debug description: Toggles SNMP debug logging security: - - ApiKeyAuth: [] + - SessionAuth: [] + - CookieAuth: [] responses: '200': description: SNMP debug toggled @@ -584,7 +644,8 @@ paths: summary: Expire DNS cache description: Forces expiration of the DNS cache security: - - ApiKeyAuth: [] + - SessionAuth: [] + - CookieAuth: [] responses: '200': description: DNS cache expired @@ -602,7 +663,8 @@ paths: summary: Print check queue description: Returns the current check queue status security: - - ApiKeyAuth: [] + - SessionAuth: [] + - CookieAuth: [] responses: '200': description: Queue status retrieved @@ -620,7 +682,8 @@ paths: summary: Get next file descriptor info description: Returns next file descriptor allocation info (debugging) security: - - ApiKeyAuth: [] + - SessionAuth: [] + - CookieAuth: [] responses: '200': description: FD info retrieved @@ -638,7 +701,8 @@ paths: summary: Shutdown daemon description: Gracefully shuts down the sysmon daemon security: - - ApiKeyAuth: [] + - SessionAuth: [] + - CookieAuth: [] responses: '200': description: Shutdown initiated @@ -700,6 +764,111 @@ paths: '200': description: Nickname stored + /api/settings/agents: + get: + tags: [Admin] + summary: List agent credentials + description: Every minted credential - monitoring boxes and alerters - without the secrets (tokens are stored hashed). + security: + - SessionAuth: [] + - CookieAuth: [] + responses: + '200': + description: Credential list + content: + application/json: + schema: + type: object + properties: + agents: + type: array + items: + $ref: '#/components/schemas/AgentToken' + post: + tags: [Admin] + summary: Mint an agent credential + description: | + Mints a token for a site. The credential's kind is part of the record + from the first write - a sysmond credential gets a sysmon.conf block + in the response, an alerter credential gets the ALERTER greeting line. + The plaintext token appears in this response once and is never + recoverable afterwards. Re-minting with replace=true disconnects + whatever currently holds the old token. + security: + - SessionAuth: [] + - CookieAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [site] + properties: + site: + type: string + description: Letters, digits, - and _ only; max 64 + label: + type: string + description: Human label; for alerters, also the display nickname + kind: + type: string + enum: [sysmond, alerter] + description: Credential type; defaults to sysmond + replace: + type: boolean + description: Required to replace a live token + responses: + '200': + description: The credential, shown once + content: + application/json: + schema: + type: object + properties: + site: { type: string } + token: { type: string } + kind: { type: string } + config: + type: string + description: sysmon.conf block (sysmond credentials only) + greeting: + type: string + description: ALERTER greeting line (alerter credentials only) + dial: + type: string + description: host:port the peer dials (alerter credentials only) + note: { type: string } + '409': + description: Site already has a live token and replace was not set + + /api/settings/agents/revoke/{site}: + post: + tags: [Admin] + summary: Revoke an agent credential + description: Marks the token revoked and closes the credential's live connection, daemon or alerter. + security: + - SessionAuth: [] + - CookieAuth: [] + parameters: + - name: site + in: path + required: true + schema: { type: string } + responses: + '200': + description: Revoked + content: + application/json: + schema: + type: object + properties: + site: { type: string } + revoked: { type: boolean } + disconnected: + type: boolean + description: Whether a live connection was closed now + /api/admin/session-log: get: tags: [Admin] @@ -766,13 +935,31 @@ paths: components: securitySchemes: - ApiKeyAuth: + SessionAuth: + type: http + scheme: bearer + description: Session token from POST /api/auth/login, as Authorization Bearer + CookieAuth: type: apiKey - in: header - name: X-Auth-Key - description: Authentication key for protected endpoints + in: cookie + name: sysmon_session + description: The same session token, as the browser UI's cookie schemas: + AgentToken: + type: object + properties: + site: { type: string } + label: { type: string } + kind: + type: string + enum: [sysmond, alerter] + description: Set at mint; legacy blank-kind records take the kind of their first greeting + created: { type: string, format: date-time } + last_seen: { type: string, format: date-time } + last_addr: { type: string } + revoked: { type: boolean } + Config: type: object properties: diff --git a/web-ui/backend/cmd/sysmon-web/cli.go b/web-ui/backend/cmd/sysmon-web/cli.go index f50b695..5a05b52 100644 --- a/web-ui/backend/cmd/sysmon-web/cli.go +++ b/web-ui/backend/cmd/sysmon-web/cli.go @@ -1,6 +1,7 @@ package main import ( + "errors" "fmt" "os" "path/filepath" @@ -124,12 +125,15 @@ func mintAgent(store *settings.Store, site, label string, replace bool, agentNam return 1 } - // The same rule the API applies. One token per site, so a second mint - // stops the box holding the first, and that is not something to do - // because a script ran twice. - if existing, held := store.GetAgentToken(site); held && !existing.Revoked && !replace { + // The CLI mints sysmond credentials - it prints a sysmon.conf block, + // so that is what the caller is provisioning. Alerter credentials + // come from the web UI, where the panel shows the ALERTER greeting. + // The exists-check and the write are one store transaction, so a + // script that ran twice concurrently mints exactly one token. + token, err := store.MintAgentToken(site, label, settings.KindSysmond, replace) + if errors.Is(err, settings.ErrTokenExists) { fmt.Fprintf(os.Stderr, "sysmon-web: %s already has a live token", site) - if !existing.LastSeen.IsZero() { + if existing, held := store.GetAgentToken(site); held && !existing.LastSeen.IsZero() { fmt.Fprintf(os.Stderr, " (last seen %s from %s)", existing.LastSeen.Format("2006-01-02 15:04:05"), existing.LastAddr) } @@ -137,8 +141,6 @@ func mintAgent(store *settings.Store, site, label string, replace bool, agentNam "Add -replace-agent if that is what you want.\n") return 1 } - - token, err := store.NewAgentToken(site, label) if err != nil { fmt.Fprintf(os.Stderr, "sysmon-web: %v\n", err) return 1 diff --git a/web-ui/backend/cmd/sysmon-web/main.go b/web-ui/backend/cmd/sysmon-web/main.go index aa165e6..0dae476 100644 --- a/web-ui/backend/cmd/sysmon-web/main.go +++ b/web-ui/backend/cmd/sysmon-web/main.go @@ -227,10 +227,17 @@ func main() { // Logging destination: // -debug -> stderr, verbose, stays (foreground). + // -foreground -> stderr. A supervisor is watching, so + // normal warnings and errors belong in + // the journal / rc log - a monitoring + // server that silently drops pages must + // not also silently drop the log line + // saying so. -debug is extra detail, + // not the price of having logs at all. // daemon child (startup) -> the diag pipe, so the parent can relay // a startup failure; signalReady() later // silences us for the rest of the run. - // everything else -> discard (quiet). + // self-daemonized -> discard (nothing is watching stderr). switch { case *debug: // leave log going to stderr @@ -245,6 +252,8 @@ func main() { } else { log.SetOutput(io.Discard) } + case *foreground: + // leave log going to stderr default: log.SetOutput(io.Discard) } @@ -525,8 +534,16 @@ func main() { al, aerr := monitoring.ListenForAgents(*agentListen, certFile, keyFile, monitoringService, - func(site, token, addr string) bool { - return settingsStore.CheckAgentToken(site, token, addr) + func(site, token, addr string) (bool, string) { + ok, credID, err := settingsStore.CheckAgentToken(site, token, addr) + if err != nil { + // Fail closed: an authenticator whose store is + // broken refuses, and says so where the operator + // can find it. + log.Printf("agents: token check for %s failed: %v", site, err) + return false, "" + } + return ok, credID }) if aerr != nil { log.Printf("WARNING: %v", aerr) diff --git a/web-ui/backend/internal/api/confdist.go b/web-ui/backend/internal/api/confdist.go index fa26911..dadf2ec 100644 --- a/web-ui/backend/internal/api/confdist.go +++ b/web-ui/backend/internal/api/confdist.go @@ -4,6 +4,7 @@ import ( "encoding/base64" "encoding/json" "encoding/pem" + "errors" "net/http" "os" "strconv" @@ -353,6 +354,11 @@ func (r *Router) handleAgentTokens(w http.ResponseWriter, req *http.Request) { var body struct { Site string `json:"site"` Label string `json:"label"` + // Kind decides at mint time whether this credential belongs + // to a monitoring box or an alert-only peer, instead of + // letting whoever holds it decide with their first greeting. + // Empty means sysmond, which keeps every existing caller. + Kind string `json:"kind"` // Replace has to be asked for. One token per site, so minting // again for a site that already has one silently stops the box // holding the old token - it keeps monitoring and paging, and @@ -371,8 +377,24 @@ func (r *Router) handleAgentTokens(w http.ResponseWriter, req *http.Request) { "a site name is letters, digits, - and _ (no colon, which would make site:object ambiguous)") return } - if existing, held := r.settings.GetAgentToken(body.Site); held && - !existing.Revoked && !body.Replace { + kind := body.Kind + switch kind { + case "": + kind = settings.KindSysmond + case settings.KindSysmond, settings.KindAlerter: + default: + r.sendError(w, http.StatusBadRequest, "kind must be sysmond or alerter") + return + } + + // One transaction mints or refuses: the live-token check and + // the write of hash, label, kind and credential epoch commit + // together, so two racing first mints cannot both return 200, + // and the plaintext token is never handed out claiming a type + // the record failed to store. + token, err := r.settings.MintAgentToken(body.Site, body.Label, kind, body.Replace) + if errors.Is(err, settings.ErrTokenExists) { + existing, _ := r.settings.GetAgentToken(body.Site) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusConflict) json.NewEncoder(w).Encode(map[string]interface{}{ @@ -386,23 +408,36 @@ func (r *Router) handleAgentTokens(w http.ResponseWriter, req *http.Request) { }) return } - - token, err := r.settings.NewAgentToken(body.Site, body.Label) if err != nil { r.sendError(w, http.StatusInternalServerError, err.Error()) return } - // The whole config block, not just the token. The person doing - // this is setting up a box, and the next thing they need is the - // text that goes in its config - with the right server name and - // port in it, which only this process knows. - r.sendJSON(w, map[string]interface{}{ + // A replaced token's current holder is cut off now. Waiting for + // its next reconnect means never for a link that stays up. + if body.Replace && r.monitoring != nil { + r.monitoring.DisconnectSite(body.Site) + } + + resp := map[string]interface{}{ "site": body.Site, "token": token, - "config": monitoring.AgentConfigBlock(body.Site, body.Label, - monitoring.AgentDialTarget(), token), - "note": "copy this now - it is stored hashed and cannot be shown again", - }) + "kind": kind, + "note": "copy this now - it is stored hashed and cannot be shown again", + } + if kind == settings.KindAlerter { + // An alerter needs the greeting line and the CA, not a + // sysmon.conf block. + resp["greeting"] = "ALERTER " + body.Site + " " + token + " [application name...]" + resp["dial"] = monitoring.AgentDialTarget() + } else { + // The whole config block, not just the token. The person + // doing this is setting up a box, and the next thing they + // need is the text that goes in its config - with the right + // server name and port in it, which only this process knows. + resp["config"] = monitoring.AgentConfigBlock(body.Site, body.Label, + monitoring.AgentDialTarget(), token) + } + r.sendJSON(w, resp) default: r.sendError(w, http.StatusMethodNotAllowed, "GET or POST") @@ -431,7 +466,14 @@ func (r *Router) handleAgentRevoke(w http.ResponseWriter, req *http.Request) { r.sendError(w, http.StatusInternalServerError, err.Error()) return } - r.sendJSON(w, map[string]interface{}{"site": site, "revoked": true}) + // The revoked credential's live connection dies with it. Revocation + // that only bites at the next reconnect never bites a peer that + // keeps its socket up. + disconnected := false + if r.monitoring != nil { + disconnected = r.monitoring.DisconnectSite(site) + } + r.sendJSON(w, map[string]interface{}{"site": site, "revoked": true, "disconnected": disconnected}) } // GET /api/settings/agents/ca diff --git a/web-ui/backend/internal/api/router.go b/web-ui/backend/internal/api/router.go index 370d77e..3f53bf4 100644 --- a/web-ui/backend/internal/api/router.go +++ b/web-ui/backend/internal/api/router.go @@ -771,7 +771,11 @@ func (r *Router) handleAlerterNickname(w http.ResponseWriter, req *http.Request) return } body.Nickname = monitoring.TruncateRunes(body.Nickname, 128) - r.settings.SetAgentLabel(body.Name, body.Nickname) + if err := r.settings.SetAgentLabel(body.Name, body.Nickname); err != nil { + // Never confirm a rename the disk refused. + r.sendError(w, http.StatusInternalServerError, "could not store the nickname: "+err.Error()) + return + } r.sendJSON(w, map[string]string{"name": body.Name, "nickname": body.Nickname}) } @@ -970,7 +974,14 @@ func (r *Router) handleMonitoringHistory(w http.ResponseWriter, req *http.Reques if req.URL.Query().Get("window") == "30d" { window = 30 * 24 * time.Hour } - events := hist.Recent(limit, window) + events, err := hist.Recent(limit, window) + if err != nil { + // A broken history store must not masquerade as an empty, + // healthy one - especially now that history is the alerter + // protocol's delivery guarantee. + r.sendError(w, http.StatusServiceUnavailable, "history store unreadable: "+err.Error()) + return + } r.sendJSON(w, map[string]interface{}{"events": events, "count": len(events), "available": true, "window": req.URL.Query().Get("window")}) } diff --git a/web-ui/backend/internal/monitoring/agent.go b/web-ui/backend/internal/monitoring/agent.go index 36b3239..b9a6aac 100644 --- a/web-ui/backend/internal/monitoring/agent.go +++ b/web-ui/backend/internal/monitoring/agent.go @@ -27,8 +27,10 @@ import ( // AgentAuth decides whether a token may claim a site. Returning false // closes the connection; the daemon backs off and tries again, so a -// revoked token costs the fleet nothing but that one box. -type AgentAuth func(site, token, remoteAddr string) bool +// revoked token costs the fleet nothing but that one box. On success it +// also names the credential epoch the token matched (the record's +// CredentialID), which the connection re-checks after registering. +type AgentAuth func(site, token, remoteAddr string) (ok bool, credentialID string) // AgentListener accepts daemons dialling in over TLS. type AgentListener struct { @@ -113,11 +115,17 @@ func (a *AgentListener) handshake(conn net.Conn) { // check, so an unauthenticated peer streaming a newline-free flood // must cost at most maxLineBytes of memory, not everything it can // push before the deadline. - line, err := readLineBounded(reader) + line, truncated, err := readLineBounded(reader) if err != nil { conn.Close() return } + if truncated { + fmt.Fprintf(conn, "444 line too long - maximum %d bytes\r\n", maxLineBytes) + conn.Close() + log.Printf("agents: %s sent an overlong greeting", remote) + return + } fields := strings.Fields(strings.TrimSpace(line)) if len(fields) < 2 || (fields[0] != "HELLO" && fields[0] != "ALERTER") { @@ -140,7 +148,14 @@ func (a *AgentListener) handshake(conn net.Conn) { return } - if a.auth == nil || !a.auth(site, token, remote) { + if a.auth == nil { + fmt.Fprintf(conn, "444 rejected\r\n") + conn.Close() + log.Printf("agents: rejected %s claiming site %q", remote, site) + return + } + ok, credID := a.auth(site, token, remote) + if !ok { fmt.Fprintf(conn, "444 rejected\r\n") conn.Close() log.Printf("agents: rejected %s claiming site %q", remote, site) @@ -180,11 +195,30 @@ func (a *AgentListener) handshake(conn net.Conn) { app = TruncateRunes(strings.Join(fields[3:], " "), 128) } log.Printf("agents: alerter %s connected from %s", site, remote) - a.svc.runAlerter(site, app, remote, conn, reader) + a.svc.runAlerter(site, app, remote, credID, conn, reader) return } - a.svc.adoptAgent(site, remote, conn, reader) + if !a.svc.adoptAgent(site, remote, credID, conn, reader) { + // Stale epoch, refused before the registry was touched: the + // legitimate current connection was never disturbed. + conn.Close() + log.Printf("agents: site %s (%s): credential revoked or replaced during handshake - dropping", site, remote) + return + } + // Registered; re-check the credential for a store write that landed + // between the in-lock check and here. An admin's revoke or re-mint + // writes the store and then sweeps registered connections; this + // order guarantees one side always sees the other - if their write + // beat this check, the check fails, and if this registration beat + // their sweep, the sweep finds it. Detach conditionally: only this + // exact socket, never a connection that has since replaced it. + if !a.svc.credentialCurrent(site, credID) { + a.svc.detachDaemonConn(site, conn) + conn.Close() + log.Printf("agents: site %s (%s): credential revoked or replaced during handshake - dropping", site, remote) + return + } log.Printf("agents: site %s connected from %s", site, remote) // The operator who put this box's token in its sysmon.conf already @@ -225,21 +259,123 @@ func (s *Service) claimKind(site, kind string) string { if st == nil { return "" } - if owner := st.ClaimAgentKind(site, kind); owner != "" { + owner, err := st.ClaimAgentKind(site, kind) + if err != nil { + // Fail closed: a peer admitted while its kind claim silently + // failed to stick is a peer of no recorded kind - the exact + // state this check exists to prevent. + log.Printf("agents: kind claim for %s failed: %v", site, err) + return "temporary credential-store failure - try again" + } + if owner != "" { return "this token belongs to a " + owner } return "" } +// credentialCurrent reports whether the site's stored credential still +// matches the epoch a connection authenticated under. Called AFTER the +// connection registers, which is what closes the revoke-mid-handshake +// race: revocation/re-mint writes the store first and sweeps registered +// connections second, so a handshake either registers early enough for +// the sweep to find it, or checks late enough to see the write. A +// missing or revoked record fails; a record whose CredentialID changed +// (re-mint) fails; a legacy record with no epoch matches only the empty +// epoch its authentication returned. +func (s *Service) credentialCurrent(site, credID string) bool { + st := s.Generations() + if st == nil { + return true // no credential store: nothing to be current against + } + tok, ok := st.GetAgentToken(site) + if !ok || tok.Revoked { + return false + } + return tok.CredentialID == credID +} + +// DisconnectSite closes whatever live connection the named identity +// holds - daemon or alerter - and reports whether one was closed. +// +// Authentication happens once, at the greeting, and the connection is +// long-lived by design; without this, revoking or re-minting a token +// changes nothing for a peer that simply never hangs up. The revoke and +// re-mint paths call it after the store is updated, so the next +// connection attempt fails authentication and the current one dies now. +// (A CLI revoke runs in its own process and cannot reach in here; the +// running server still cuts the link at its next protocol exchange +// failure or restart.) +func (s *Service) DisconnectSite(site string) bool { + closed := false + + s.fleetMu.Lock() + for _, d := range s.daemons { + d.mu.Lock() + if d.site == site && d.conn != nil { + d.conn.Close() + // Detached, not just closed: nothing may keep treating + // this socket as the site's connection. + d.conn = nil + closed = true + } + d.mu.Unlock() + } + s.fleetMu.Unlock() + + s.alertersMu.Lock() + a, ok := s.alerters[site] + s.alertersMu.Unlock() + if ok { + // The disconnect takes the acceptance lock and invalidates the + // connection UNDER it, exactly like a reconnect replacement. + // That gives revocation a real ordering against an alert the + // connection had already read: either the alert's acceptance + // held the lock first and its commit completes before this + // returns, or this wins and the alert finds its connection + // gone and is refused. Closing the socket alone left a.conn + // intact, and one already-parsed event could commit AFTER the + // revoke API had reported success. + a.acceptMu.Lock() + a.mu.Lock() + oldConn := a.conn + a.conn = nil + if a.info.Connected { + closed = true + } + a.info.Connected = false + a.mu.Unlock() + if oldConn != nil { + oldConn.Close() + } + a.acceptMu.Unlock() + } + + if closed { + log.Printf("agents: disconnected %s (token revoked or replaced)", site) + } + return closed +} + // adoptAgent puts a dialled-in daemon into the fleet. // // A site reconnecting replaces its old entry rather than adding a second: // a daemon that restarted, or whose link dropped and came back, is the // same site, and two entries would double every host it reports. -func (s *Service) adoptAgent(site, remote string, conn net.Conn, reader *bufio.Reader) { +// +// Like registerAlerter, the credential epoch is checked inside the +// registry lock BEFORE the current connection is touched, and false is +// returned without any mutation for a stale epoch: a handshake that +// slept through a re-mint must not evict the legitimate new-epoch +// daemon (and wipe its sequence state and host cache) on its way to +// being refused. +func (s *Service) adoptAgent(site, remote, credID string, conn net.Conn, reader *bufio.Reader) bool { s.fleetMu.Lock() defer s.fleetMu.Unlock() + if !s.credentialCurrent(site, credID) { + return false + } + for _, d := range s.daemons { d.mu.Lock() same := d.site == site @@ -260,8 +396,9 @@ func (s *Service) adoptAgent(site, remote string, conn net.Conn, reader *bufio.R // anything. d.confSeq, d.trapSeq = 0, 0 d.hostCache = nil + d.credID = credID d.mu.Unlock() - return + return true } s.daemons = append(s.daemons, &daemon{ @@ -269,5 +406,23 @@ func (s *Service) adoptAgent(site, remote string, conn net.Conn, reader *bufio.R site: site, conn: conn, reader: reader, + credID: credID, }) + return true +} + +// detachDaemonConn drops the site's connection only if it is still this +// exact socket - the conditional matters: a stale handshake that lost +// its post-registration recheck must not tear down a connection that +// has since replaced it. +func (s *Service) detachDaemonConn(site string, conn net.Conn) { + s.fleetMu.Lock() + defer s.fleetMu.Unlock() + for _, d := range s.daemons { + d.mu.Lock() + if d.site == site && d.conn == conn { + d.conn = nil + } + d.mu.Unlock() + } } diff --git a/web-ui/backend/internal/monitoring/alerters.go b/web-ui/backend/internal/monitoring/alerters.go index fad624e..1313c69 100644 --- a/web-ui/backend/internal/monitoring/alerters.go +++ b/web-ui/backend/internal/monitoring/alerters.go @@ -36,12 +36,33 @@ const maxAlertText = 512 // the read itself must not be a way to spend this server's memory. const maxLineBytes = 4096 -// alertQueueDepth is how many alerts may wait on the push pipeline -// before new ones are refused with "444 busy" so the client knows to -// retry. Alerts are rare and the queue exists only to keep the -// protocol reply from waiting on FCM/APNs. +// alertQueueDepth is how many alerts may wait on the push pipeline. +// Once an alert is committed to history it is accepted - so a full +// queue skips the phone delivery (logged) rather than refusing. +// Alerts are rare and the queue exists only to keep the protocol reply +// from waiting on FCM/APNs. const alertQueueDepth = 64 +// The ingestion rate limit: a token bucket per alerter identity. The +// history store is shared with the hosts' transitions and bounded, so +// one broken cron script looping "ALERT CRITICAL backup failed" must +// not be able to churn the whole fleet's history out of it. The burst +// covers any honest storm (a UPS narrating an outage); the refill is +// far above what a well-behaved alerter sends. +var ( + alertRateBurst = 30.0 + alertRatePerSec = 1.0 +) + +// Test barriers. Nil in production; a test sets one to pin an +// interleaving that scheduling luck (and -race) cannot force - each is +// called with the alerter's name at the boundary it names. Set before +// the connection goroutine starts and cleared after it is joined. +var ( + testHookAfterParse func(name string) // parsed + validated, before acceptance + testHookAfterRegister func(name string) // registered, before the epoch recheck +) + // TruncateRunes cuts s to at most n runes, never splitting one - a cut // at a byte offset turns multi-byte text into U+FFFD garbage downstream. func TruncateRunes(s string, n int) string { @@ -78,6 +99,35 @@ type alerter struct { mu sync.Mutex info AlerterInfo conn net.Conn + // acceptMu serializes alert acceptance against connection + // replacement. An old connection that has already read a line could + // otherwise commit its (stale) alert after the replacement's newer + // one: acceptance takes this lock, checks its connection is still + // the record's current one, and only then commits - and a reconnect + // swaps the connection under the same lock, so there are exactly + // two outcomes: the old alert commits first, or it is refused. + acceptMu sync.Mutex + // pending is this identity's one delivery queue, drained by one + // goroutine for the life of the process. It belongs to the NAME, + // not the socket: when the alerter reconnects, the replacement + // connection feeds the same queue, so an OK sent after a reconnect + // can never overtake a CRITICAL accepted before it - with one + // queue and one dispatcher per socket, two dispatchers raced and + // the collapse key could leave the phone stuck on the stale state. + // It also caps the identity at one queue's worth of backlog, where + // per-connection queues let every reconnect abandon another 64. + pending chan pendingAlert + // lastStatus remembers each object's previous status, so the + // history row for an alert can say what it changed FROM - the same + // transition shape host history has. Guarded by mu. + lastStatus map[string]string + // credID is the credential epoch the current connection + // authenticated under; kept so registration can refuse a stale + // epoch before touching the connection. Guarded by mu. + credID string + // The ingestion token bucket (see alertRateBurst). Guarded by mu. + rateTokens float64 + rateStamp time.Time } // pendingAlert is one parsed alert waiting on the push pipeline. @@ -107,8 +157,7 @@ func (s *Service) alerterDisplayName(a *alerter) string { // SetAlertSink names the function alerter traffic is delivered to - // in practice push.Service.ExternalAlert, re-pointed whenever the push -// service is hot-swapped. Nil means alerts are acknowledged and dropped, -// which is correct for a deployment that has not configured push. +// service is hot-swapped. func (s *Service) SetAlertSink(fn func(source, display, object, status, text string)) { s.alertSinkMu.Lock() s.alertSink = fn @@ -139,14 +188,16 @@ func (s *Service) Alerters() []AlerterInfo { // readLineBounded returns the next line, holding at most maxLineBytes // of it in memory - the rest of an overlong line is read and discarded, -// never buffered. One hostile line costs at most its own truncation, -// not the server's memory. -func readLineBounded(r *bufio.Reader) (string, error) { +// never buffered, and the caller is told it happened. One hostile line +// costs at most its own truncation, not the server's memory; and a +// protocol line the server did not read in full must be refused, not +// silently processed as something shorter than what was sent. +func readLineBounded(r *bufio.Reader) (line string, truncated bool, err error) { var buf []byte for { - chunk, isPrefix, err := r.ReadLine() - if err != nil { - return "", err + chunk, isPrefix, rerr := r.ReadLine() + if rerr != nil { + return "", false, rerr } if len(buf) < maxLineBytes { take := maxLineBytes - len(buf) @@ -154,9 +205,14 @@ func readLineBounded(r *bufio.Reader) (string, error) { take = len(chunk) } buf = append(buf, chunk[:take]...) + if take < len(chunk) { + truncated = true + } + } else if len(chunk) > 0 { + truncated = true } if !isPrefix { - return string(buf), nil + return string(buf), truncated, nil } } } @@ -180,26 +236,25 @@ func readLineBounded(r *bufio.Reader) (string, error) { // Delivery is decoupled from the reply: a push fan-out can take tens of // seconds against a slow provider, and holding the 333 back that long // makes a well-behaved client time out, reconnect, and resend the same -// page. One dispatcher goroutine per connection keeps alerts in order; -// it drains what is queued even after the connection drops. -func (s *Service) runAlerter(name, application, remote string, conn net.Conn, reader *bufio.Reader) { - a := s.registerAlerter(name, application, remote, conn) - - pending := make(chan pendingAlert, alertQueueDepth) - go func() { - for p := range pending { - if sink := s.alertSinkFn(); sink != nil { - sink(p.source, p.display, p.object, p.status, p.text) - } else { - log.Printf("agents: alerter %s sent %s %s with no push service configured - dropped", - p.source, p.status, p.object) - } - } - }() +// page. The queue and its dispatcher belong to the alerter's identity +// (see registerAlerter), so a reconnect keeps one ordered stream. +// +// credID is the credential epoch this connection authenticated under; +// after registering, the credential is checked again (register first, +// re-check second - see credentialCurrent for why that order closes +// the revoke-during-handshake race). +func (s *Service) runAlerter(name, application, remote, credID string, conn net.Conn, reader *bufio.Reader) { + a, ok := s.registerAlerter(name, application, remote, credID, conn) + if !ok { + // Stale epoch: refused before the registry was touched, so the + // legitimate current connection (if any) was never disturbed. + conn.Close() + log.Printf("agents: alerter %s (%s): credential revoked or replaced during handshake - dropping", name, remote) + return + } defer func() { conn.Close() - close(pending) // A reconnect replaces this connection on the shared record // before this goroutine notices its read failing - only the // record's CURRENT connection may declare it disconnected, or @@ -215,11 +270,30 @@ func (s *Service) runAlerter(name, application, remote string, conn net.Conn, re } }() + // Registered; re-check the credential for a store write that landed + // between the in-lock check and here. The defer detaches only if + // this connection is still the record's current one, so a stale + // handshake failing here can never tear down a later connection. + if hook := testHookAfterRegister; hook != nil { + hook(name) + } + if !s.credentialCurrent(name, credID) { + log.Printf("agents: alerter %s (%s): credential revoked or replaced during handshake - dropping", name, remote) + return + } + for { - line, err := readLineBounded(reader) + line, truncated, err := readLineBounded(reader) if err != nil { return } + if truncated { + // The server did not read what the peer sent; processing + // the readable prefix would silently accept a different + // message. Refuse it and keep the connection. + fmt.Fprintf(conn, "444 line too long - maximum %d bytes\r\n", maxLineBytes) + continue + } line = strings.TrimSpace(line) if line == "" { continue @@ -241,7 +315,7 @@ func (s *Service) runAlerter(name, application, remote string, conn net.Conn, re fmt.Fprintf(conn, "333 bye\r\n") return case "ALERT": - if msg := s.handleAlertLine(a, name, line, pending); msg == "" { + if msg := s.handleAlertLine(a, name, line, conn); msg == "" { fmt.Fprintf(conn, "333 ok\r\n") } else { fmt.Fprintf(conn, "444 %s\r\n", msg) @@ -252,9 +326,14 @@ func (s *Service) runAlerter(name, application, remote string, conn net.Conn, re } } -// handleAlertLine parses "ALERT " and queues -// it for delivery. Returns "" on success, else the complaint for the 444. -func (s *Service) handleAlertLine(a *alerter, name, line string, pending chan<- pendingAlert) string { +// handleAlertLine parses "ALERT " and +// accepts it: the history write IS the acceptance boundary - it must +// commit before the 333, and push is attempted only afterwards, as the +// optional extra channel it is. conn is the connection the line arrived +// on; acceptance re-checks it is still the identity's current one, so a +// replaced connection cannot commit a stale event after its +// replacement's newer one. Returns "" on success, else the 444 text. +func (s *Service) handleAlertLine(a *alerter, name, line string, conn net.Conn) string { fields := strings.SplitN(line, " ", 4) if len(fields) < 3 { return "usage: ALERT " @@ -277,61 +356,147 @@ func (s *Service) handleAlertLine(a *alerter, name, line string, pending chan<- text = fmt.Sprintf("%s reports %s %s", name, object, status) } - p := pendingAlert{ - source: name, - display: s.alerterDisplayName(a), - object: object, - status: status, - text: text, + // The history store is the acceptance boundary: 333 means, exactly, + // "committed to alert history". A server without one refuses - + // push alone cannot honor the promise, and an ack that a crash can + // erase is not an ack. + hist := s.History() + if hist == nil { + return "alert history unavailable on this server - alert not accepted" } - select { - case pending <- p: - default: - // The push pipeline is badly backed up. The alert is NOT - // accepted, and the client must hear that: a 333 here would - // tell a compliant alerter its page was delivered when it was - // dropped, and the one page that matters would be lost with - // only a server-side log line to show for it. 444 never - // closes the connection, so the client just retries. - log.Printf("agents: alerter %s: delivery queue full - refusing %s %s", name, status, object) - return "busy - delivery queue is full, retry shortly" + + if hook := testHookAfterParse; hook != nil { + hook(name) } - // Bookkeeping counts accepted alerts only; a refused one never - // happened as far as the Fleet page is concerned. - now := time.Now().UTC() + // Acceptance is serialized against connection replacement AND + // administrative disconnection (revoke/re-mint), both of which take + // the same lock and invalidate a.conn: a connection that already + // parsed a line either commits it here before it was replaced or + // cut off, or finds itself no longer current and is refused - never + // commits after a revocation has returned or after a replacement's + // newer event. + a.acceptMu.Lock() + defer a.acceptMu.Unlock() a.mu.Lock() + current := a.conn == conn + prev := a.lastStatus[object] + // The ingestion bucket refills continuously up to the burst; an + // empty bucket refuses BEFORE anything commits, so a flooding + // alerter cannot churn the fleet's shared history out of the store. + now := time.Now() + a.rateTokens += now.Sub(a.rateStamp).Seconds() * alertRatePerSec + if a.rateTokens > alertRateBurst { + a.rateTokens = alertRateBurst + } + a.rateStamp = now + allowed := a.rateTokens >= 1 + if allowed && current { + a.rateTokens-- + } + a.mu.Unlock() + if !current { + return "connection superseded by a newer one - alert not accepted" + } + if !allowed { + return "rate limited - too many alerts, slow down" + } + + if err := hist.Append([]HistoryEvent{{ + ObjectName: name + ":" + object, + Site: name, + LocalName: object, + Description: text, + PrevStatus: prev, + NewStatus: status, + }}); err != nil { + // Nothing was accepted, so nothing else may advance: the retry + // this 444 asks for must see the same prev status and must not + // duplicate anything. + log.Printf("agents: alerter %s: recording %s %s to history failed: %v", name, status, object, err) + return "could not record the alert - try again" + } + + // Committed: everything after this honors the 333 rather than + // gating it. Bookkeeping counts accepted alerts only. + acceptedAt := time.Now().UTC() + a.mu.Lock() + a.lastStatus[object] = status a.info.Alerts++ - a.info.LastAlertAt = &now + a.info.LastAlertAt = &acceptedAt a.info.LastAlert = fmt.Sprintf("%s %s: %s", status, object, text) a.mu.Unlock() + + // Push is the optional extra channel, attempted only after the + // commit. A saturated queue cannot refuse an accepted alert - the + // retry would duplicate the history row - so the skipped phone + // delivery is a logged delivery failure instead. + if sink := s.alertSinkFn(); sink != nil { + p := pendingAlert{ + source: name, + display: s.alerterDisplayName(a), + object: object, + status: status, + text: text, + } + select { + case a.pending <- p: + default: + log.Printf("agents: alerter %s: push queue full - %s %s is recorded in history but phone delivery was skipped", + name, status, object) + } + } return "" } // registerAlerter puts a connection into the registry. A name // reconnecting replaces its old link rather than adding a second, the -// same rule adoptAgent applies to daemons. -func (s *Service) registerAlerter(name, application, remote string, conn net.Conn) *alerter { +// same rule adoptAgent applies to daemons. The first sight of a name +// also starts its dispatcher: one goroutine per identity, for the life +// of the process, so every connection that ever speaks for this name +// feeds one ordered queue. +// +// The credential epoch is checked INSIDE the registry lock, before the +// current connection is touched: a handshake that authenticated under +// an old epoch and then slept through a re-mint must not get to evict +// the legitimate new-epoch connection on its way to being refused. +// Registrations are serialized by alertersMu, so the check and the +// install are one step relative to any competing registration. +func (s *Service) registerAlerter(name, application, remote, credID string, conn net.Conn) (*alerter, bool) { s.alertersMu.Lock() defer s.alertersMu.Unlock() + if !s.credentialCurrent(name, credID) { + return nil, false + } if s.alerters == nil { s.alerters = make(map[string]*alerter) } if old, ok := s.alerters[name]; ok { + // The swap holds the acceptance lock: an alert the old + // connection already parsed either commits before this point + // or sees itself superseded after it - see alerter.acceptMu. + old.acceptMu.Lock() old.mu.Lock() if old.conn != nil && old.info.Connected { old.conn.Close() } old.conn = conn + old.credID = credID old.info.Application = application old.info.Addr = remote old.info.Connected = true old.info.ConnectedAt = time.Now().UTC() old.mu.Unlock() - return old + old.acceptMu.Unlock() + return old, true } a := &alerter{ - conn: conn, + conn: conn, + credID: credID, + pending: make(chan pendingAlert, alertQueueDepth), + lastStatus: make(map[string]string), + rateTokens: alertRateBurst, + rateStamp: time.Now(), info: AlerterInfo{ Name: name, Application: application, @@ -341,5 +506,24 @@ func (s *Service) registerAlerter(name, application, remote string, conn net.Con }, } s.alerters[name] = a - return a + go s.dispatchAlerts(a) + return a, true +} + +// dispatchAlerts is an alerter identity's one delivery worker. It never +// exits: the registry keeps the record (and this goroutine) for the +// life of the process, and the count of identities is bounded by the +// count of minted tokens. +func (s *Service) dispatchAlerts(a *alerter) { + for p := range a.pending { + if sink := s.alertSinkFn(); sink != nil { + sink(p.source, p.display, p.object, p.status, p.text) + } else { + // The alert is already committed to history; only its + // phone delivery is lost, because the sink was unset + // between accept and dispatch. + log.Printf("agents: alerter %s sent %s %s with no push service configured - phone delivery skipped", + p.source, p.status, p.object) + } + } } diff --git a/web-ui/backend/internal/monitoring/alerters_test.go b/web-ui/backend/internal/monitoring/alerters_test.go index 17dcb0d..1876140 100644 --- a/web-ui/backend/internal/monitoring/alerters_test.go +++ b/web-ui/backend/internal/monitoring/alerters_test.go @@ -44,12 +44,21 @@ func TestAlerterSession(t *testing.T) { t.Fatal(err) } defer store.Close() - if _, err := store.NewAgentToken("backupd", ""); err != nil { + if _, err := store.MintAgentToken("backupd", "", settings.KindAlerter, false); err != nil { t.Fatal(err) } + // The connection carries the credential epoch it authenticated + // under; the real handshake gets it from CheckAgentToken. + tok, _ := store.GetAgentToken("backupd") svc := NewService() svc.SetGenerations(store) + hist, err := OpenHistory(filepath.Join(t.TempDir(), "history.db")) + if err != nil { + t.Fatal(err) + } + defer hist.Close() + svc.SetHistory(hist) var mu sync.Mutex var sunk []sunkAlert @@ -67,7 +76,7 @@ func TestAlerterSession(t *testing.T) { server, client := net.Pipe() done := make(chan struct{}) go func() { - svc.runAlerter("backupd", "Bacula 15.0 nightly backups", "pipe", server, bufio.NewReader(server)) + svc.runAlerter("backupd", "Bacula 15.0 nightly backups", "pipe", tok.CredentialID, server, bufio.NewReader(server)) close(done) }() @@ -152,11 +161,11 @@ func TestAlerterSession(t *testing.T) { t.Errorf("after QUIT, Alerters() = %+v, want the record kept but disconnected", list) } - // The token record learns its kind at handshake time in the real - // path (claimKind -> ClaimAgentKind) - prove the recorded kind - // sticks and that labels round-trip beside it. - if got := store.ClaimAgentKind("backupd", settings.KindAlerter); got != "" { - t.Errorf("ClaimAgentKind refused a fresh token: %q", got) + // The kind was minted into the record; the handshake's claim of the + // same kind is the steady state - prove it stands and that labels + // round-trip beside it. + if got, err := store.ClaimAgentKind("backupd", settings.KindAlerter); got != "" || err != nil { + t.Errorf("ClaimAgentKind refused the minted kind: %q, %v", got, err) } tokens, err := store.ListAgentTokens() if err != nil || len(tokens) != 1 { @@ -177,7 +186,7 @@ func TestAlerterReconnectKeepsNewConnection(t *testing.T) { server1, _ := net.Pipe() done1 := make(chan struct{}) go func() { - svc.runAlerter("upsd", "apcupsd", "pipe-1", server1, bufio.NewReader(server1)) + svc.runAlerter("upsd", "apcupsd", "pipe-1", "", server1, bufio.NewReader(server1)) close(done1) }() waitFor(t, "the first connection to register", func() bool { @@ -190,7 +199,7 @@ func TestAlerterReconnectKeepsNewConnection(t *testing.T) { server2, client2 := net.Pipe() done2 := make(chan struct{}) go func() { - svc.runAlerter("upsd", "apcupsd", "pipe-2", server2, bufio.NewReader(server2)) + svc.runAlerter("upsd", "apcupsd", "pipe-2", "", server2, bufio.NewReader(server2)) close(done2) }() @@ -239,7 +248,7 @@ func TestAlerterOverlongLine(t *testing.T) { server, client := net.Pipe() done := make(chan struct{}) go func() { - svc.runAlerter("chatty", "", "pipe", server, bufio.NewReader(server)) + svc.runAlerter("chatty", "", "pipe", "", server, bufio.NewReader(server)) close(done) }() @@ -251,30 +260,26 @@ func TestAlerterOverlongLine(t *testing.T) { }() r := bufio.NewReader(client) reply, err := r.ReadString('\n') - if err != nil || strings.TrimSpace(reply) != "333 ok" { - t.Fatalf("overlong ALERT = %q, %v, want 333 ok", strings.TrimSpace(reply), err) - } - - waitFor(t, "the truncated alert to reach the sink", func() bool { - mu.Lock() - defer mu.Unlock() - return len(texts) == 1 - }) - mu.Lock() - text := texts[0] - mu.Unlock() - if got := len([]rune(text)); got > maxAlertText { - t.Errorf("alert text is %d runes, want at most %d", got, maxAlertText) + if err != nil || !strings.HasPrefix(strings.TrimSpace(reply), "444 line too long") { + t.Fatalf("overlong ALERT = %q, %v, want a 444 line too long refusal", strings.TrimSpace(reply), err) } // The line after the flood still parses - nothing of the overflow - // leaked into the next read. + // leaked into the next read, and the refusal did not cost the + // connection. if _, err := client.Write([]byte("PING\n")); err != nil { t.Fatalf("write after flood: %v", err) } if reply, err := r.ReadString('\n'); err != nil || strings.TrimSpace(reply) != "333 pong" { t.Fatalf("PING after flood = %q, %v", strings.TrimSpace(reply), err) } + // Nothing of the refused line reached delivery: the server must not + // silently accept a different message than the sender transmitted. + mu.Lock() + if len(texts) != 0 { + t.Errorf("sink saw %d alerts from a refused line, want 0", len(texts)) + } + mu.Unlock() client.Close() <-done } @@ -308,7 +313,12 @@ func TestClaimKind(t *testing.T) { t.Fatal(err) } defer store.Close() - if _, err := store.NewAgentToken("box1", ""); err != nil { + if _, err := store.MintAgentToken("box1", "", settings.KindSysmond, false); err != nil { + t.Fatal(err) + } + // Minting records the kind now; blank it to simulate a record from + // before kinds existed, whose first greeting claims it. + if err := store.SetAgentKind("box1", ""); err != nil { t.Fatal(err) } @@ -345,8 +355,19 @@ func TestClaimKind(t *testing.T) { // A full delivery queue must refuse the alert, not acknowledge it: // "333 ok" is a delivery promise, and a compliant client only resends // what was refused. Refused alerts also must not count as sent. -func TestAlerterQueueFullRefuses(t *testing.T) { +func TestAlerterPushQueueFullStillRecordsAndAccepts(t *testing.T) { + // This test's flood is deliberate; park the ingestion limit. + oldBurst, oldRate := alertRateBurst, alertRatePerSec + alertRateBurst, alertRatePerSec = 100000, 100000 + defer func() { alertRateBurst, alertRatePerSec = oldBurst, oldRate }() + svc := NewService() + hist, err := OpenHistory(filepath.Join(t.TempDir(), "history.db")) + if err != nil { + t.Fatal(err) + } + defer hist.Close() + svc.SetHistory(hist) release := make(chan struct{}) starts := make(chan struct{}, alertQueueDepth+8) @@ -358,7 +379,7 @@ func TestAlerterQueueFullRefuses(t *testing.T) { server, client := net.Pipe() done := make(chan struct{}) go func() { - svc.runAlerter("floody", "", "pipe", server, bufio.NewReader(server)) + svc.runAlerter("floody", "", "pipe", "", server, bufio.NewReader(server)) close(done) }() @@ -387,25 +408,37 @@ func TestAlerterQueueFullRefuses(t *testing.T) { t.Fatalf("filler %d answered %q", i, got) } } - // The next one has nowhere to go: it must be a 444, not a false ok. - if got := send("ALERT CRITICAL disk the one that matters"); !strings.HasPrefix(got, "444 busy") { - t.Fatalf("overflow alert answered %q, want a 444 busy refusal", got) + // The next one finds the push queue full - but history committed, + // so it is STILL a 333: a 444 would make the client retry and + // duplicate the durable record. The skipped phone delivery is a + // logged delivery failure, not an acceptance failure. + if got := send("ALERT CRITICAL disk the one that matters"); got != "333 ok" { + t.Fatalf("overflow alert answered %q, want 333 (recorded; push skipped)", got) } - // Unblock delivery, let the backlog drain, and the same line is - // accepted on retry - the connection survived the refusal. close(release) for i := 0; i < alertQueueDepth; i++ { <-starts } - if got := send("ALERT CRITICAL disk the one that matters"); got != "333 ok" { - t.Fatalf("retry after drain answered %q", got) - } - // Only accepted alerts counted: 1 + depth + 1, not the refusal. - want := uint64(alertQueueDepth + 2) - if list := svc.Alerters(); len(list) != 1 || list[0].Alerts != want { - t.Errorf("Alerts = %d, want %d (refused alerts must not count)", list[0].Alerts, want) + // Every accepted alert is in history - including the one whose + // phone delivery was skipped - and the counter agrees. + total := alertQueueDepth + 2 + events, _ := hist.Recent(total+10, 0) + if len(events) != total { + t.Fatalf("history holds %d events, want %d", len(events), total) + } + if events[0].NewStatus != "CRITICAL" || events[0].LocalName != "disk" { + t.Errorf("newest history row = %+v, want the overflow CRITICAL", events[0]) + } + if list := svc.Alerters(); len(list) != 1 || list[0].Alerts != uint64(total) { + t.Errorf("Alerts = %d, want %d", list[0].Alerts, total) + } + // The sink saw everything except the skipped one. + select { + case <-starts: + t.Error("sink saw the skipped alert") + default: } client.Close() @@ -427,7 +460,12 @@ func TestClaimKindConcurrentFirstUse(t *testing.T) { for i := 0; i < 10; i++ { site := fmt.Sprintf("box%d", i) - if _, err := store.NewAgentToken(site, ""); err != nil { + if _, err := store.MintAgentToken(site, "", settings.KindSysmond, false); err != nil { + t.Fatal(err) + } + // The race only exists for records without a kind - which since + // mint-time kinds means legacy records; simulate one. + if err := store.SetAgentKind(site, ""); err != nil { t.Fatal(err) } results := make(chan string, 2) @@ -446,3 +484,798 @@ func TestClaimKindConcurrentFirstUse(t *testing.T) { } } } + +// The queue and dispatcher belong to the alerter's identity, not the +// socket: an OK accepted after a reconnect must never reach the sink +// before a CRITICAL accepted earlier on the old connection - two +// per-connection dispatchers raced exactly that way, and the shared +// collapse key then left the phones stuck on the stale CRITICAL. +func TestAlerterReconnectPreservesOrder(t *testing.T) { + svc := NewService() + hist, err := OpenHistory(filepath.Join(t.TempDir(), "history.db")) + if err != nil { + t.Fatal(err) + } + defer hist.Close() + svc.SetHistory(hist) + + release := make(chan struct{}) + starts := make(chan struct{}, 8) + var mu sync.Mutex + var order []string + svc.SetAlertSink(func(_, _, _, status, _ string) { + starts <- struct{}{} + <-release // delivery is stuck (slow provider) until released + mu.Lock() + order = append(order, status) + mu.Unlock() + }) + + // Connection one accepts two alerts: the first occupies the + // dispatcher (stuck in the sink), the second waits in the queue. + server1, client1 := net.Pipe() + done1 := make(chan struct{}) + go func() { + svc.runAlerter("upsd", "apcupsd", "pipe-1", "", server1, bufio.NewReader(server1)) + close(done1) + }() + r1 := bufio.NewReader(client1) + send := func(c net.Conn, r *bufio.Reader, line string) string { + t.Helper() + if _, err := c.Write([]byte(line + "\n")); err != nil { + t.Fatalf("write %q: %v", line, err) + } + reply, err := r.ReadString('\n') + if err != nil { + t.Fatalf("no reply to %q: %v", line, err) + } + return strings.TrimSpace(reply) + } + if got := send(client1, r1, "ALERT WARNING battery on battery power"); got != "333 ok" { + t.Fatalf("first alert answered %q", got) + } + <-starts // dispatcher is now holding WARNING inside the sink + if got := send(client1, r1, "ALERT CRITICAL battery battery low"); got != "333 ok" { + t.Fatalf("second alert answered %q", got) + } + + // The alerter reconnects and reports the recovery on the new link. + server2, client2 := net.Pipe() + done2 := make(chan struct{}) + go func() { + svc.runAlerter("upsd", "apcupsd", "pipe-2", "", server2, bufio.NewReader(server2)) + close(done2) + }() + <-done1 // old connection fully torn down + r2 := bufio.NewReader(client2) + if got := send(client2, r2, "ALERT OK battery mains power restored"); got != "333 ok" { + t.Fatalf("post-reconnect alert answered %q", got) + } + + close(release) + waitFor(t, "all three alerts to be delivered", func() bool { + mu.Lock() + defer mu.Unlock() + return len(order) == 3 + }) + mu.Lock() + got := strings.Join(order, ",") + mu.Unlock() + if got != "WARNING,CRITICAL,OK" { + t.Fatalf("delivery order = %s, want WARNING,CRITICAL,OK", got) + } + + client2.Close() + <-done2 +} + +// The 333 is honored by either delivery path: the alert history alone +// is enough (push off or absent - the alert still shows in the web UI), +// and only a server with neither history nor push refuses. +func TestAlerterHistoryIsADeliveryPath(t *testing.T) { + svc := NewService() + // A push sink exists from the start: it must NOT be enough. The + // history store is the acceptance boundary, and a 333 backed by + // nothing durable is the lie this design exists to prevent. + svc.SetAlertSink(func(_, _, _, _, _ string) {}) + + server, client := net.Pipe() + done := make(chan struct{}) + go func() { + svc.runAlerter("backupd", "", "pipe", "", server, bufio.NewReader(server)) + close(done) + }() + r := bufio.NewReader(client) + send := func(line string) string { + t.Helper() + if _, err := client.Write([]byte(line + "\n")); err != nil { + t.Fatalf("write %q: %v", line, err) + } + reply, err := r.ReadString('\n') + if err != nil { + t.Fatalf("no reply to %q: %v", line, err) + } + return strings.TrimSpace(reply) + } + + // Push alone: refused - there is nothing durable to ack against. + if got := send("ALERT CRITICAL disk full"); !strings.HasPrefix(got, "444 alert history unavailable") { + t.Fatalf("ALERT with push but no history answered %q, want a 444", got) + } + + // With a history store the same line is accepted and recorded. + hist, err := OpenHistory(filepath.Join(t.TempDir(), "history.db")) + if err != nil { + t.Fatal(err) + } + defer hist.Close() + svc.SetHistory(hist) + + if got := send("ALERT CRITICAL tape jam in drive 2"); got != "333 ok" { + t.Fatalf("ALERT with history only answered %q", got) + } + if got := send("ALERT OK tape cleared by operator"); got != "333 ok" { + t.Fatalf("second ALERT answered %q", got) + } + + // Recorded like host transitions: keyed source:object with the + // halves split out, the text as the description, and the second + // row knowing what the object changed from. + events, _ := hist.Recent(10, 0) + if len(events) != 2 { + t.Fatalf("history holds %d events, want 2: %+v", len(events), events) + } + newest, oldest := events[0], events[1] + if oldest.ObjectName != "backupd:tape" || oldest.Site != "backupd" || + oldest.LocalName != "tape" || oldest.NewStatus != "CRITICAL" || + oldest.PrevStatus != "" || oldest.Description != "jam in drive 2" { + t.Errorf("first recorded alert = %+v", oldest) + } + if newest.NewStatus != "OK" || newest.PrevStatus != "CRITICAL" { + t.Errorf("second recorded alert = %+v, want OK from CRITICAL", newest) + } + // The transition duration machinery applies to alerter objects too. + if newest.PrevDuration < 0 { + t.Errorf("PrevDuration = %d", newest.PrevDuration) + } + + if list := svc.Alerters(); len(list) != 1 || list[0].Alerts != 2 { + t.Errorf("Alerts = %d, want 2 (the refused alert must not count)", list[0].Alerts) + } + + client.Close() + <-done +} + +// Revoking or re-minting a token must cut the live connection, not just +// the next one: both registries - daemons and alerters - answer to +// DisconnectSite. +func TestDisconnectSiteCutsLiveConnections(t *testing.T) { + svc := NewService() + + // An alerter with its socket up. + aServer, aClient := net.Pipe() + aDone := make(chan struct{}) + go func() { + svc.runAlerter("backupd", "", "pipe-a", "", aServer, bufio.NewReader(aServer)) + close(aDone) + }() + waitFor(t, "the alerter to register", func() bool { + l := svc.Alerters() + return len(l) == 1 && l[0].Connected + }) + + // A daemon with its socket up. + dServer, dClient := net.Pipe() + svc.adoptAgent("branch2", "pipe-d", "", dServer, bufio.NewReader(dServer)) + + if !svc.DisconnectSite("backupd") { + t.Error("DisconnectSite(backupd) found nothing to close") + } + select { + case <-aDone: // read loop died with the closed socket + case <-time.After(5 * time.Second): + t.Fatal("alerter connection survived DisconnectSite") + } + if l := svc.Alerters(); len(l) != 1 || l[0].Connected { + t.Errorf("after DisconnectSite, Alerters() = %+v, want disconnected", l) + } + + if !svc.DisconnectSite("branch2") { + t.Error("DisconnectSite(branch2) found nothing to close") + } + // The daemon's socket really is dead: its far end reads EOF. + dClient.SetReadDeadline(time.Now().Add(5 * time.Second)) + if _, err := dClient.Read(make([]byte, 1)); err == nil { + t.Error("daemon connection still readable after DisconnectSite") + } + + if svc.DisconnectSite("ghost") { + t.Error("DisconnectSite(ghost) claimed to close something") + } + aClient.Close() + dClient.Close() +} + +// A broken credential store must fail closed: a kind claim that cannot +// be read or written refuses the handshake instead of admitting a peer +// whose kind never stuck, and label writes report their failure. +func TestStorageFailureFailsClosed(t *testing.T) { + store, err := settings.NewStore(filepath.Join(t.TempDir(), "settings.db")) + if err != nil { + t.Fatal(err) + } + if _, err := store.MintAgentToken("box1", "", settings.KindSysmond, false); err != nil { + t.Fatal(err) + } + + svc := NewService() + svc.SetGenerations(store) + + // Sanity: works while the store is healthy. + if got := svc.claimKind("box1", settings.KindSysmond); got != "" { + t.Fatalf("healthy claim refused: %q", got) + } + + store.Close() // the "disk failure": every transaction now errors + + if got := svc.claimKind("box1", settings.KindAlerter); got == "" { + t.Error("claimKind admitted a peer over a dead store") + } + if err := store.SetAgentLabel("box1", "new name"); err == nil { + t.Error("SetAgentLabel reported success against a dead store") + } + if _, _, err := store.CheckAgentToken("box1", "whatever", "addr"); err == nil { + t.Error("CheckAgentToken reported a verdict without an error against a dead store") + } +} + +// A history write failure refuses the alert even when push is +// available: the 333 means "history committed", and a push-only +// delivery cannot honor that. +func TestAlerterHistoryFailureWithPushStillRefuses(t *testing.T) { + svc := NewService() + svc.SetAlertSink(func(_, _, _, _, _ string) { + t.Error("push sink ran for an alert whose history write failed") + }) + hist, err := OpenHistory(filepath.Join(t.TempDir(), "history.db")) + if err != nil { + t.Fatal(err) + } + svc.SetHistory(hist) + hist.Close() // the "disk failure": every append now errors + + server, client := net.Pipe() + done := make(chan struct{}) + go func() { + svc.runAlerter("backupd", "", "pipe", "", server, bufio.NewReader(server)) + close(done) + }() + r := bufio.NewReader(client) + if _, err := client.Write([]byte("ALERT CRITICAL disk full\n")); err != nil { + t.Fatal(err) + } + reply, err := r.ReadString('\n') + if err != nil || !strings.HasPrefix(strings.TrimSpace(reply), "444 could not record") { + t.Fatalf("ALERT with a dead history store answered %q, %v", strings.TrimSpace(reply), err) + } + // Nothing was accepted: not counted, and push never ran. + if list := svc.Alerters(); len(list) != 1 || list[0].Alerts != 0 { + t.Errorf("Alerts = %d, want 0", list[0].Alerts) + } + client.Close() + <-done +} + +// A failed write must not advance the object's remembered status: the +// retry the 444 asks for has to record the same first-sighting (or +// same-previous-status) transition the failed attempt tried to. +func TestAlerterRetryAfterHistoryFailurePreservesPreviousStatus(t *testing.T) { + svc := NewService() + dead, err := OpenHistory(filepath.Join(t.TempDir(), "dead.db")) + if err != nil { + t.Fatal(err) + } + svc.SetHistory(dead) + dead.Close() + + server, client := net.Pipe() + done := make(chan struct{}) + go func() { + svc.runAlerter("upsd", "", "pipe", "", server, bufio.NewReader(server)) + close(done) + }() + r := bufio.NewReader(client) + send := func(line string) string { + t.Helper() + if _, err := client.Write([]byte(line + "\n")); err != nil { + t.Fatalf("write %q: %v", line, err) + } + reply, err := r.ReadString('\n') + if err != nil { + t.Fatalf("no reply to %q: %v", line, err) + } + return strings.TrimSpace(reply) + } + + if got := send("ALERT CRITICAL battery low"); !strings.HasPrefix(got, "444") { + t.Fatalf("first attempt answered %q, want a 444", got) + } + + // The store recovers; the retry must record a FIRST sighting - a + // failed write that had advanced lastStatus would make this row + // read CRITICAL -> CRITICAL. + hist, err := OpenHistory(filepath.Join(t.TempDir(), "history.db")) + if err != nil { + t.Fatal(err) + } + defer hist.Close() + svc.SetHistory(hist) + + if got := send("ALERT CRITICAL battery low"); got != "333 ok" { + t.Fatalf("retry answered %q", got) + } + events, _ := hist.Recent(10, 0) + if len(events) != 1 { + t.Fatalf("history holds %d events, want 1", len(events)) + } + if events[0].PrevStatus != "" || events[0].NewStatus != "CRITICAL" { + t.Errorf("retried row = prev %q new %q, want a first sighting", events[0].PrevStatus, events[0].NewStatus) + } + client.Close() + <-done +} + +// A replaced connection that already parsed a line must not commit it +// after the replacement's newer alert: acceptance re-checks that the +// line's connection is still the record's current one, under the same +// lock the reconnect swap takes. +func TestAlerterReconnectRejectsParsedEventFromObsoleteConnection(t *testing.T) { + svc := NewService() + hist, err := OpenHistory(filepath.Join(t.TempDir(), "history.db")) + if err != nil { + t.Fatal(err) + } + defer hist.Close() + svc.SetHistory(hist) + + server1, _ := net.Pipe() + done1 := make(chan struct{}) + go func() { + svc.runAlerter("upsd", "", "pipe-1", "", server1, bufio.NewReader(server1)) + close(done1) + }() + waitFor(t, "the first connection to register", func() bool { + l := svc.Alerters() + return len(l) == 1 && l[0].Connected + }) + + // The replacement arrives and accepts the recovery. + server2, client2 := net.Pipe() + done2 := make(chan struct{}) + go func() { + svc.runAlerter("upsd", "", "pipe-2", "", server2, bufio.NewReader(server2)) + close(done2) + }() + <-done1 + r2 := bufio.NewReader(client2) + if _, err := client2.Write([]byte("ALERT OK battery mains restored\n")); err != nil { + t.Fatal(err) + } + if reply, _ := r2.ReadString('\n'); strings.TrimSpace(reply) != "333 ok" { + t.Fatalf("replacement's alert answered %q", strings.TrimSpace(reply)) + } + + // The old connection "resumes" with a line it parsed before it was + // replaced - modeled by calling the acceptance path directly with + // the obsolete connection, exactly what runAlerter would do. + svc.alertersMu.Lock() + a := svc.alerters["upsd"] + svc.alertersMu.Unlock() + if msg := svc.handleAlertLine(a, "upsd", "ALERT CRITICAL battery battery low", server1); msg == "" { + t.Fatal("stale event from the replaced connection was accepted") + } + + // History holds only the replacement's OK; the stale CRITICAL never + // landed after it. + events, _ := hist.Recent(10, 0) + if len(events) != 1 || events[0].NewStatus != "OK" { + t.Fatalf("history = %+v, want only the OK", events) + } + client2.Close() + <-done2 +} + +// A credential revoked before registration never touches the registry; +// one revoked BETWEEN registration and the post-registration re-check +// (the barrier pins exactly that window) is caught by the re-check. +// Together with DisconnectSite's own sweep, whichever of the admin's +// write and this handshake finishes last sees the other. +func TestRevokeDuringHandshakeCannotRegister(t *testing.T) { + store, err := settings.NewStore(filepath.Join(t.TempDir(), "settings.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + if _, err := store.MintAgentToken("backupd", "", settings.KindAlerter, false); err != nil { + t.Fatal(err) + } + tok, _ := store.GetAgentToken("backupd") + + svc := NewService() + svc.SetGenerations(store) + + // Case 1: the revoke lands after authentication but before + // registration. The in-lock pre-check refuses without ever + // creating a registry record. + if err := store.RevokeAgentToken("backupd"); err != nil { + t.Fatal(err) + } + server, client := net.Pipe() + done := make(chan struct{}) + go func() { + svc.runAlerter("backupd", "", "pipe", tok.CredentialID, server, bufio.NewReader(server)) + close(done) + }() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("revoked-before-registration connection was admitted") + } + if l := svc.Alerters(); len(l) != 0 { + t.Errorf("Alerters() = %+v, want nothing registered", l) + } + client.SetReadDeadline(time.Now().Add(5 * time.Second)) + if _, err := client.Read(make([]byte, 1)); err == nil { + t.Error("connection still open after pre-registration revocation") + } + + // Case 2: the revoke lands in the window between registration and + // the re-check - the barrier holds the handshake exactly there. + if _, err := store.MintAgentToken("backupd", "", settings.KindAlerter, true); err != nil { + t.Fatal(err) + } + tok2, _ := store.GetAgentToken("backupd") + + registered := make(chan struct{}) + proceed := make(chan struct{}) + testHookAfterRegister = func(string) { + close(registered) + <-proceed + } + server2, client2 := net.Pipe() + done2 := make(chan struct{}) + go func() { + svc.runAlerter("backupd", "", "pipe-2", tok2.CredentialID, server2, bufio.NewReader(server2)) + close(done2) + }() + <-registered // the connection is in the registry, re-check not yet run + if err := store.RevokeAgentToken("backupd"); err != nil { + t.Fatal(err) + } + close(proceed) + select { + case <-done2: + case <-time.After(5 * time.Second): + t.Fatal("revoked-mid-handshake connection was admitted") + } + testHookAfterRegister = nil + if l := svc.Alerters(); len(l) != 1 || l[0].Connected { + t.Errorf("Alerters() = %+v, want the record disconnected", l) + } + client2.SetReadDeadline(time.Now().Add(5 * time.Second)) + if _, err := client2.Read(make([]byte, 1)); err == nil { + t.Error("connection still open after mid-handshake revocation") + } +} + +// A re-mint changes the credential epoch, so a connection still holding +// the OLD token's epoch cannot finish registering after the replacement +// even though the record is not revoked. +func TestRemintDuringHandshakeCannotRegisterOldToken(t *testing.T) { + store, err := settings.NewStore(filepath.Join(t.TempDir(), "settings.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + if _, err := store.MintAgentToken("backupd", "", settings.KindAlerter, false); err != nil { + t.Fatal(err) + } + oldTok, _ := store.GetAgentToken("backupd") + + // The re-mint lands mid-handshake. + if _, err := store.MintAgentToken("backupd", "", settings.KindAlerter, true); err != nil { + t.Fatal(err) + } + + svc := NewService() + svc.SetGenerations(store) + + server, client := net.Pipe() + done := make(chan struct{}) + go func() { + svc.runAlerter("backupd", "", "pipe", oldTok.CredentialID, server, bufio.NewReader(server)) + close(done) + }() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("old-epoch connection was admitted after a re-mint") + } + client.SetReadDeadline(time.Now().Add(5 * time.Second)) + if _, err := client.Read(make([]byte, 1)); err == nil { + t.Error("connection still open after mid-handshake re-mint") + } +} + +// The mint's exists-check and write are one transaction: a second +// non-replacement mint is refused, and racing first mints produce +// exactly one token. +func TestMintAgentTokenAtomicConflict(t *testing.T) { + store, err := settings.NewStore(filepath.Join(t.TempDir(), "settings.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + + if _, err := store.MintAgentToken("box1", "", settings.KindSysmond, false); err != nil { + t.Fatal(err) + } + if _, err := store.MintAgentToken("box1", "", settings.KindSysmond, false); err != settings.ErrTokenExists { + t.Fatalf("second mint = %v, want ErrTokenExists", err) + } + // replace:true still works, and revoked records may be re-minted + // without replace. + if _, err := store.MintAgentToken("box1", "", settings.KindSysmond, true); err != nil { + t.Fatalf("replace mint failed: %v", err) + } + if err := store.RevokeAgentToken("box1"); err != nil { + t.Fatal(err) + } + if _, err := store.MintAgentToken("box1", "", settings.KindSysmond, false); err != nil { + t.Fatalf("re-mint over a revoked record failed: %v", err) + } + + // Ten racing first mints on a fresh site: exactly one wins. + var wg sync.WaitGroup + wins := make(chan struct{}, 10) + for i := 0; i < 10; i++ { + wg.Add(1) + go func() { + defer wg.Done() + if _, err := store.MintAgentToken("fresh", "", settings.KindSysmond, false); err == nil { + wins <- struct{}{} + } + }() + } + wg.Wait() + close(wins) + won := 0 + for range wins { + won++ + } + if won != 1 { + t.Fatalf("%d concurrent first mints succeeded, want exactly 1", won) + } +} + +// The revocation race the acceptance lock exists for: a line is read +// and parsed, the goroutine stalls, the admin revokes and +// DisconnectSite returns - and the stalled event must then be refused, +// not committed after the revoke API reported success. The barrier +// holds the goroutine exactly between parse and acceptance. +func TestRevokeAfterLineReadBeforeAcceptanceCannotCommit(t *testing.T) { + svc := NewService() + hist, err := OpenHistory(filepath.Join(t.TempDir(), "history.db")) + if err != nil { + t.Fatal(err) + } + defer hist.Close() + svc.SetHistory(hist) + + parsed := make(chan struct{}) + proceed := make(chan struct{}) + testHookAfterParse = func(string) { + close(parsed) + <-proceed + } + + server, client := net.Pipe() + done := make(chan struct{}) + go func() { + svc.runAlerter("backupd", "", "pipe", "", server, bufio.NewReader(server)) + close(done) + }() + if _, err := client.Write([]byte("ALERT CRITICAL disk full\n")); err != nil { + t.Fatal(err) + } + <-parsed // the line is parsed and validated; acceptance has not begun + + // The admin's revocation completes: store write, then the sweep. + if !svc.DisconnectSite("backupd") { + t.Fatal("DisconnectSite found nothing to close") + } + + close(proceed) // the stalled goroutine resumes + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("connection goroutine never exited") + } + testHookAfterParse = nil + + // The already-read event did NOT land after the revocation. + if events, _ := hist.Recent(10, 0); len(events) != 0 { + t.Fatalf("history = %+v, want empty - the event committed after revocation returned", events) + } + if l := svc.Alerters(); len(l) != 1 || l[0].Connected || l[0].Alerts != 0 { + t.Errorf("Alerters() = %+v, want disconnected with zero alerts", l) + } + client.Close() +} + +// A stale-epoch handshake must not evict the legitimate current-epoch +// connection on its way to being refused: the epoch is checked inside +// the registry lock, before the current connection is touched. +func TestStaleAlerterHandshakeDoesNotEvictCurrentEpoch(t *testing.T) { + store, err := settings.NewStore(filepath.Join(t.TempDir(), "settings.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + if _, err := store.MintAgentToken("backupd", "", settings.KindAlerter, false); err != nil { + t.Fatal(err) + } + oldTok, _ := store.GetAgentToken("backupd") + if _, err := store.MintAgentToken("backupd", "", settings.KindAlerter, true); err != nil { + t.Fatal(err) + } + newTok, _ := store.GetAgentToken("backupd") + + svc := NewService() + svc.SetGenerations(store) + + // The legitimate new-epoch connection is up and answering. + serverB, clientB := net.Pipe() + doneB := make(chan struct{}) + go func() { + svc.runAlerter("backupd", "", "pipe-B", newTok.CredentialID, serverB, bufio.NewReader(serverB)) + close(doneB) + }() + rB := bufio.NewReader(clientB) + if _, err := clientB.Write([]byte("PING\n")); err != nil { + t.Fatal(err) + } + if reply, _ := rB.ReadString('\n'); strings.TrimSpace(reply) != "333 pong" { + t.Fatalf("epoch-B PING answered %q", strings.TrimSpace(reply)) + } + + // The stale epoch-A handshake resumes now. It must be refused + // without touching the epoch-B connection. + serverA, _ := net.Pipe() + doneA := make(chan struct{}) + go func() { + svc.runAlerter("backupd", "", "pipe-A", oldTok.CredentialID, serverA, bufio.NewReader(serverA)) + close(doneA) + }() + select { + case <-doneA: + case <-time.After(5 * time.Second): + t.Fatal("stale handshake never exited") + } + + // Epoch B is still the registered, answering connection. + if l := svc.Alerters(); len(l) != 1 || !l[0].Connected || l[0].Addr != "pipe-B" { + t.Errorf("Alerters() = %+v, want epoch B still connected", l) + } + if _, err := clientB.Write([]byte("PING\n")); err != nil { + t.Fatalf("epoch-B write after stale handshake: %v", err) + } + if reply, _ := rB.ReadString('\n'); strings.TrimSpace(reply) != "333 pong" { + t.Fatalf("epoch-B PING after stale handshake answered %q", strings.TrimSpace(reply)) + } + clientB.Close() + <-doneB +} + +// The daemon registry has the same guarantee: a stale-epoch adoptAgent +// returns false without closing the current connection or wiping its +// sequence state. +func TestStaleDaemonHandshakeDoesNotEvictCurrentEpoch(t *testing.T) { + store, err := settings.NewStore(filepath.Join(t.TempDir(), "settings.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + if _, err := store.MintAgentToken("branch2", "", settings.KindSysmond, false); err != nil { + t.Fatal(err) + } + oldTok, _ := store.GetAgentToken("branch2") + if _, err := store.MintAgentToken("branch2", "", settings.KindSysmond, true); err != nil { + t.Fatal(err) + } + newTok, _ := store.GetAgentToken("branch2") + + svc := NewService() + svc.SetGenerations(store) + + serverB, clientB := net.Pipe() + if !svc.adoptAgent("branch2", "pipe-B", newTok.CredentialID, serverB, bufio.NewReader(serverB)) { + t.Fatal("current-epoch adoption refused") + } + + serverA, _ := net.Pipe() + if svc.adoptAgent("branch2", "pipe-A", oldTok.CredentialID, serverA, bufio.NewReader(serverA)) { + t.Fatal("stale-epoch adoption succeeded") + } + + // The B connection was never closed: its far end still times out on + // read (an eviction would have delivered EOF immediately). + clientB.SetReadDeadline(time.Now().Add(100 * time.Millisecond)) + buf := make([]byte, 1) + if _, err := clientB.Read(buf); err == nil { + t.Error("unexpected data on the epoch-B daemon connection") + } else if !strings.Contains(err.Error(), "timeout") && !strings.Contains(err.Error(), "deadline") { + t.Errorf("epoch-B daemon connection is dead: %v", err) + } + clientB.Close() +} + +// The ingestion limit: a flooding alerter is refused with a 444 before +// anything commits, so it cannot churn the fleet's shared history. +func TestAlerterRateLimited(t *testing.T) { + svc := NewService() + hist, err := OpenHistory(filepath.Join(t.TempDir(), "history.db")) + if err != nil { + t.Fatal(err) + } + defer hist.Close() + svc.SetHistory(hist) + + server, client := net.Pipe() + done := make(chan struct{}) + go func() { + svc.runAlerter("cronjob", "", "pipe", "", server, bufio.NewReader(server)) + close(done) + }() + r := bufio.NewReader(client) + send := func(line string) string { + t.Helper() + if _, err := client.Write([]byte(line + "\n")); err != nil { + t.Fatalf("write %q: %v", line, err) + } + reply, err := r.ReadString('\n') + if err != nil { + t.Fatalf("no reply to %q: %v", line, err) + } + return strings.TrimSpace(reply) + } + + // The burst is accepted; the flood beyond it is refused. A little + // slack on the boundary allows for bucket refill during the run. + accepted, refused := 0, 0 + for i := 0; i < int(alertRateBurst)+10; i++ { + got := send("ALERT CRITICAL backup failed") + switch { + case got == "333 ok": + accepted++ + case strings.HasPrefix(got, "444 rate limited"): + refused++ + default: + t.Fatalf("alert %d answered %q", i, got) + } + } + if refused == 0 { + t.Fatal("the flood was never rate limited") + } + if accepted < int(alertRateBurst) { + t.Errorf("only %d alerts accepted, want at least the burst of %d", accepted, int(alertRateBurst)) + } + // History holds exactly what was accepted - refusals committed + // nothing. + if events, _ := hist.Recent(1000, 0); len(events) != accepted { + t.Errorf("history holds %d events, accepted %d", len(events), accepted) + } + client.Close() + <-done +} diff --git a/web-ui/backend/internal/monitoring/history.go b/web-ui/backend/internal/monitoring/history.go index 8f9d5da..9332aa5 100644 --- a/web-ui/backend/internal/monitoring/history.go +++ b/web-ui/backend/internal/monitoring/history.go @@ -24,6 +24,12 @@ const ( // HistoryEvent is one observed host state transition - the raw material // of "what has been going up and down" over the last 48 hours. type HistoryEvent struct { + // ID is the store's sequence number: immutable, unique, assigned at + // append. Clients use it as row identity - timestamps only carry + // second precision, so two same-status alerts within one second + // would otherwise be indistinguishable. Zero on rows written before + // the field existed. + ID uint64 `json:"id,omitempty"` Timestamp string `json:"timestamp"` // RFC3339 ObjectName string `json:"object_name"` // Site and LocalName are ObjectName's two halves, carried separately @@ -72,38 +78,54 @@ func (h *HistoryStore) Close() error { } // Append records a batch of transitions, filling in how long each host -// had been in its previous state where we know it. -func (h *HistoryStore) Append(events []HistoryEvent) { +// had been in its previous state where we know it. The returned error +// matters to callers that promised the record to someone - the alerter +// protocol acks against it - and is advisory for the poller. +func (h *HistoryStore) Append(events []HistoryEvent) error { if len(events) == 0 { - return + return nil } h.mu.Lock() defer h.mu.Unlock() now := time.Now().UTC() - h.db.Update(func(tx *bolt.Tx) error { + // Durations are read here; the lastChange clock only advances after + // the transaction commits - a rolled-back append must not move it, + // or the retry's duration measures against a write that never was. + for i := range events { + ev := &events[i] + ev.Timestamp = now.Format(time.RFC3339) + if last, ok := h.lastChange[ev.ObjectName]; ok { + ev.PrevDuration = int64(now.Sub(last).Seconds()) + } + } + err := h.db.Update(func(tx *bolt.Tx) error { b := tx.Bucket(bucketHistoryEvents) for i := range events { - ev := &events[i] - ev.Timestamp = now.Format(time.RFC3339) - if last, ok := h.lastChange[ev.ObjectName]; ok { - ev.PrevDuration = int64(now.Sub(last).Seconds()) + id, err := b.NextSequence() + if err != nil { + return err } - h.lastChange[ev.ObjectName] = now - data, err := json.Marshal(ev) + events[i].ID = id + data, err := json.Marshal(&events[i]) if err != nil { - continue + return err } - id, _ := b.NextSequence() if err := b.Put([]byte(fmt.Sprintf("%012d", id)), data); err != nil { return err } } return nil }) + if err == nil { + for i := range events { + h.lastChange[events[i].ObjectName] = now + } + } // Transitions are rare, so pruning on every append is cheap. h.prune(now) + return err } // PruneNow ages the store on a clock. Append prunes too, but a system @@ -151,7 +173,12 @@ func (h *HistoryStore) prune(now time.Time) { // first. The age check happens here too, not just in prune, so a quiet // system never serves stale events between prunes. A window outside // (0, retention] is clamped to the default. -func (h *HistoryStore) Recent(limit int, window time.Duration) []HistoryEvent { +// +// The error matters: history is the alerter protocol's delivery +// guarantee, and a store that has become unreadable must say so - an +// empty list with a swallowed error reads as "nothing happened", which +// is the one thing a broken guarantee must never claim. +func (h *HistoryStore) Recent(limit int, window time.Duration) ([]HistoryEvent, error) { h.mu.Lock() defer h.mu.Unlock() @@ -160,7 +187,7 @@ func (h *HistoryStore) Recent(limit int, window time.Duration) []HistoryEvent { } cutoff := time.Now().UTC().Add(-window) out := make([]HistoryEvent, 0, limit) - h.db.View(func(tx *bolt.Tx) error { + err := h.db.View(func(tx *bolt.Tx) error { c := tx.Bucket(bucketHistoryEvents).Cursor() for k, v := c.Last(); k != nil && len(out) < limit; k, v = c.Prev() { var ev HistoryEvent @@ -188,5 +215,5 @@ func (h *HistoryStore) Recent(limit int, window time.Duration) []HistoryEvent { } return nil }) - return out + return out, err } diff --git a/web-ui/backend/internal/monitoring/history_test.go b/web-ui/backend/internal/monitoring/history_test.go index dbe51ec..fc6f5e9 100644 --- a/web-ui/backend/internal/monitoring/history_test.go +++ b/web-ui/backend/internal/monitoring/history_test.go @@ -33,7 +33,7 @@ func TestHistoryWindowAndRetention(t *testing.T) { }) // Recent must not serve it even before any prune runs. - if got := h.Recent(10, 0); len(got) != 0 { + if got, _ := h.Recent(10, 0); len(got) != 0 { t.Fatalf("expected stale event filtered from Recent, got %d events", len(got)) } @@ -45,7 +45,7 @@ func TestHistoryWindowAndRetention(t *testing.T) { NewStatus: "WARNING", }}) - got := h.Recent(10, 0) + got, _ := h.Recent(10, 0) if len(got) != 1 || got[0].ObjectName != "fresh-host" { t.Fatalf("expected only the fresh event, got %+v", got) } @@ -81,10 +81,10 @@ func TestRecentWindow(t *testing.T) { return b.Put(k, nv) }) - if got := h.Recent(10, 0); len(got) != 0 { + if got, _ := h.Recent(10, 0); len(got) != 0 { t.Fatalf("default window served a 72h-old event: %d rows", len(got)) } - if got := h.Recent(10, 30*24*time.Hour); len(got) != 1 { + if got, _ := h.Recent(10, 30*24*time.Hour); len(got) != 1 { t.Fatalf("30d window missed the 72h-old event: %d rows", len(got)) } } @@ -127,7 +127,7 @@ func TestRecentBackfillsSiteAndLocalName(t *testing.T) { NewStatus: "OK", }}) - got := h.Recent(10, 0) + got, _ := h.Recent(10, 0) if len(got) != 3 { t.Fatalf("Recent returned %d events, want 3: %+v", len(got), got) } diff --git a/web-ui/backend/internal/monitoring/service.go b/web-ui/backend/internal/monitoring/service.go index 972d6c2..b4eee6c 100644 --- a/web-ui/backend/internal/monitoring/service.go +++ b/web-ui/backend/internal/monitoring/service.go @@ -5,6 +5,7 @@ import ( "encoding/xml" "fmt" "hash/fnv" + "log" "net" "regexp" "sort" @@ -58,6 +59,10 @@ type daemon struct { version string versConn net.Conn + // credID is the credential epoch this connection authenticated + // under (see settings.AgentToken.CredentialID). + credID string + // Incremental fetch state. // // confSeq is the highest object sequence this daemon has reported. It @@ -613,7 +618,9 @@ func (s *Service) GetStatus() (*models.SysmonStatus, error) { s.cacheMu.Unlock() if hist != nil { - hist.Append(events) + if err := hist.Append(events); err != nil { + log.Printf("history: recording transitions failed: %v", err) + } } if err != nil { return nil, err @@ -651,7 +658,9 @@ func (s *Service) Refresh() { s.cacheMu.Unlock() if hist != nil { - hist.Append(events) + if err := hist.Append(events); err != nil { + log.Printf("history: recording transitions failed: %v", err) + } } } diff --git a/web-ui/backend/internal/monitoring/stale_test.go b/web-ui/backend/internal/monitoring/stale_test.go index ed10edc..810ef23 100644 --- a/web-ui/backend/internal/monitoring/stale_test.go +++ b/web-ui/backend/internal/monitoring/stale_test.go @@ -141,7 +141,7 @@ func connect(t *testing.T, svc *Service, f *fakeSysmond) { f.mu.Lock() f.live = c f.mu.Unlock() - svc.adoptAgent(f.site, c.RemoteAddr().String(), c, bufio.NewReader(c)) + svc.adoptAgent(f.site, c.RemoteAddr().String(), "", c, bufio.NewReader(c)) } func TestUnreachableSiteGoesStaleNotRemoved(t *testing.T) { diff --git a/web-ui/backend/internal/push/service.go b/web-ui/backend/internal/push/service.go index 8d1c1d1..792eb08 100644 --- a/web-ui/backend/internal/push/service.go +++ b/web-ui/backend/internal/push/service.go @@ -1041,7 +1041,10 @@ func (s *Service) notifyAll(title, subtitle, body string, data fcmData, prevStat // so a rename never re-keys anything. func (s *Service) ExternalAlert(source, display, object, status, text string) { if !s.Enabled() { - log.Printf("push: alerter %s sent %s %s but push is disabled - not delivered", source, status, object) + // Not a loss: the alert is already recorded in the web UI's + // alert history by the accept path - push is the additional + // channel, and it is switched off. + log.Printf("push: alerter %s sent %s %s but push is disabled - recorded in history only", source, status, object) return } status = strings.ToUpper(status) @@ -1059,8 +1062,11 @@ func (s *Service) ExternalAlert(source, display, object, status, text string) { } log.Printf("push: alerter %s: %s %s - notifying subscribers", source, status, object) + // Hostname is what the push log records; the qualified name keeps + // two alerters that both report an object called "disk" apart there, + // the same way host alerts log as "site:host". s.notifyAll(title, display, body, fcmData{ - Hostname: object, + Hostname: collapse, Object: collapse, Status: status, Type: "alerter", diff --git a/web-ui/backend/internal/settings/agents.go b/web-ui/backend/internal/settings/agents.go index 7eb42f9..85a206a 100644 --- a/web-ui/backend/internal/settings/agents.go +++ b/web-ui/backend/internal/settings/agents.go @@ -6,6 +6,7 @@ import ( "crypto/subtle" "encoding/hex" "encoding/json" + "errors" "fmt" "time" @@ -22,10 +23,11 @@ func hashToken(t string) string { return hex.EncodeToString(sum[:]) } -// What a token's peer turned out to be. A token is minted before its -// box ever connects, so the kind is recorded at first handshake - a -// sysmond says HELLO, an alerter says ALERTER - and stays empty for a -// token nothing has used yet. +// What a token's peer is. Recorded at mint time (MintAgentToken) - the +// admin chooses whether a credential belongs to a monitoring box or an +// alert-only peer, and the greeting verb must match. Only records +// minted before kinds existed are empty, and those take the kind of +// their first successful greeting (ClaimAgentKind). const ( KindSysmond = "sysmond" KindAlerter = "alerter" @@ -37,20 +39,33 @@ const ( // than being dialled: the alternative has this process holding a key to // every box in the fleet, so compromising the UI compromises the lot. type AgentToken struct { - Site string `json:"site"` - Token string `json:"-"` // never leaves this process after creation - Label string `json:"label,omitempty"` - Kind string `json:"kind,omitempty"` // KindSysmond/KindAlerter, "" until first seen - Created time.Time `json:"created"` - LastSeen time.Time `json:"last_seen,omitempty"` - LastAddr string `json:"last_addr,omitempty"` - Revoked bool `json:"revoked,omitempty"` + Site string `json:"site"` + Token string `json:"-"` // never leaves this process after creation + Label string `json:"label,omitempty"` + Kind string `json:"kind,omitempty"` // KindSysmond/KindAlerter; "" only on pre-kind records + // CredentialID is this mint's epoch: a random value regenerated + // every time the site's token is minted. A connection remembers the + // ID it authenticated under and re-checks it after registering, so + // a revoke or re-mint that lands mid-handshake still catches the + // connection - whichever side finishes second sees the other. + // Empty on records minted before the field existed. + CredentialID string `json:"credential_id,omitempty"` + Created time.Time `json:"created"` + LastSeen time.Time `json:"last_seen,omitempty"` + LastAddr string `json:"last_addr,omitempty"` + Revoked bool `json:"revoked,omitempty"` } +// ErrTokenExists is MintAgentToken's refusal to silently invalidate a +// live credential: the site already has one and replace was not asked. +var ErrTokenExists = errors.New("site already has a live token") + // SetAgentLabel renames a token's human label - for alerters this is -// the nickname alerts display. Missing records are left missing. -func (s *Store) SetAgentLabel(site, label string) { - _ = s.db.Update(func(tx *bolt.Tx) error { +// the nickname alerts display. Missing records are left missing; a +// storage failure is the caller's to report, not to swallow - the UI +// must never confirm a rename the disk refused. +func (s *Store) SetAgentLabel(site, label string) error { + return s.db.Update(func(tx *bolt.Tx) error { b := tx.Bucket(bucketAgents) if b == nil { return nil @@ -60,12 +75,12 @@ func (s *Store) SetAgentLabel(site, label string) { return nil } var stored map[string]json.RawMessage - if json.Unmarshal(blob, &stored) != nil { - return nil + if err := json.Unmarshal(blob, &stored); err != nil { + return fmt.Errorf("agent record %s is unreadable: %w", site, err) } enc, err := json.Marshal(label) if err != nil { - return nil + return err } if label == "" { delete(stored, "label") @@ -74,17 +89,19 @@ func (s *Store) SetAgentLabel(site, label string) { } updated, err := json.Marshal(stored) if err != nil { - return nil + return err } return b.Put([]byte(site), updated) }) } -// SetAgentKind records what a token's peer identified as at handshake. -// Idempotent; a missing record is left missing (the handshake already -// authenticated against it, so this only races a concurrent revoke). -func (s *Store) SetAgentKind(site, kind string) { - _ = s.db.Update(func(tx *bolt.Tx) error { +// SetAgentKind overwrites a record's kind. Minting sets the kind +// atomically (NewAgentToken) and the handshake claims it for legacy +// records (ClaimAgentKind); this raw setter exists for migrations and +// tests - simulating a pre-kind record takes writing an empty kind. +// Missing records are left missing. +func (s *Store) SetAgentKind(site, kind string) error { + return s.db.Update(func(tx *bolt.Tx) error { b := tx.Bucket(bucketAgents) if b == nil { return nil @@ -94,17 +111,17 @@ func (s *Store) SetAgentKind(site, kind string) { return nil } var stored map[string]json.RawMessage - if json.Unmarshal(blob, &stored) != nil { - return nil + if err := json.Unmarshal(blob, &stored); err != nil { + return fmt.Errorf("agent record %s is unreadable: %w", site, err) } enc, err := json.Marshal(kind) if err != nil { - return nil + return err } stored["kind"] = enc updated, err := json.Marshal(stored) if err != nil { - return nil + return err } return b.Put([]byte(site), updated) }) @@ -113,14 +130,16 @@ func (s *Store) SetAgentKind(site, kind string) { // ClaimAgentKind records what a token's peer identified as, first // claim wins forever: read, check and write happen inside one bolt // transaction, so two concurrent first handshakes with the same fresh -// token cannot both succeed as different kinds. Returns "" when the -// claim stands (recorded now, already recorded, or no record to claim -// against - the handshake already authenticated, so a missing record -// only races a concurrent revoke), else the kind the token already -// belongs to. -func (s *Store) ClaimAgentKind(site, kind string) string { +// token cannot both succeed as different kinds. Returns ("", nil) when +// the claim stands (recorded now, already recorded, or no record to +// claim against - the handshake already authenticated, so a missing +// record only races a concurrent revoke), the owning kind when the +// token already belongs to the other class, and a non-nil error when +// storage failed - in which case the claim did NOT stick and the +// caller must fail closed rather than admit an unbound peer. +func (s *Store) ClaimAgentKind(site, kind string) (string, error) { owner := "" - _ = s.db.Update(func(tx *bolt.Tx) error { + err := s.db.Update(func(tx *bolt.Tx) error { b := tx.Bucket(bucketAgents) if b == nil { return nil @@ -130,8 +149,8 @@ func (s *Store) ClaimAgentKind(site, kind string) string { return nil } var stored map[string]json.RawMessage - if json.Unmarshal(blob, &stored) != nil { - return nil + if err := json.Unmarshal(blob, &stored); err != nil { + return fmt.Errorf("agent record %s is unreadable: %w", site, err) } existing := "" if raw, ok := stored["kind"]; ok { @@ -146,16 +165,19 @@ func (s *Store) ClaimAgentKind(site, kind string) string { } enc, err := json.Marshal(kind) if err != nil { - return nil + return err } stored["kind"] = enc updated, err := json.Marshal(stored) if err != nil { - return nil + return err } return b.Put([]byte(site), updated) }) - return owner + if err != nil { + return "", err + } + return owner, nil } // GetAgentToken returns the record for a site, without the secret. @@ -185,21 +207,39 @@ func (s *Store) GetAgentToken(site string) (AgentToken, bool) { return stored.AgentToken, found } -// NewAgentToken mints a credential for a site. The token is returned once -// and only once - it is stored hashed, so a leaked database does not leak -// the fleet's credentials, and "show me the token again" is deliberately -// impossible rather than merely discouraged. -func (s *Store) NewAgentToken(site, label string) (string, error) { +// MintAgentToken mints a credential for a site. The token is returned +// once and only once - it is stored hashed, so a leaked database does +// not leak the fleet's credentials, and "show me the token again" is +// deliberately impossible rather than merely discouraged. +// +// The whole mint is one transaction: the does-a-live-token-exist check +// and the write of hash, label, kind and credential epoch commit +// together, so two racing first mints cannot both hand out a token +// (exactly one wins; the other gets ErrTokenExists), and the caller +// never holds a plaintext token whose stored type failed to stick. +func (s *Store) MintAgentToken(site, label, kind string, replace bool) (string, error) { + switch kind { + case KindSysmond, KindAlerter: + default: + return "", fmt.Errorf("kind must be %q or %q", KindSysmond, KindAlerter) + } + raw := make([]byte, 32) if _, err := rand.Read(raw); err != nil { return "", err } token := hex.EncodeToString(raw) + epoch := make([]byte, 8) + if _, err := rand.Read(epoch); err != nil { + return "", err + } rec := AgentToken{ - Site: site, - Label: label, - Created: time.Now().UTC(), + Site: site, + Label: label, + Kind: kind, + CredentialID: hex.EncodeToString(epoch), + Created: time.Now().UTC(), } blob, err := json.Marshal(struct { AgentToken @@ -214,6 +254,14 @@ func (s *Store) NewAgentToken(site, label string) (string, error) { if err != nil { return err } + if existing := b.Get([]byte(site)); existing != nil && !replace { + var old AgentToken + // An unreadable record blocks a silent overwrite too: the + // caller asked to create, not to destroy whatever this is. + if json.Unmarshal(existing, &old) != nil || !old.Revoked { + return ErrTokenExists + } + } return b.Put([]byte(site), blob) }); err != nil { return "", err @@ -222,15 +270,19 @@ func (s *Store) NewAgentToken(site, label string) (string, error) { } // CheckAgentToken reports whether a token may claim a site, and records -// the sighting when it may. -func (s *Store) CheckAgentToken(site, token, addr string) bool { +// the sighting when it may. On success it also returns the credential +// epoch the token authenticated under, which the connection re-checks +// after registering (see AgentToken.CredentialID). A storage failure +// fails closed: an authenticator that cannot read or update its own +// store must refuse, not guess. +func (s *Store) CheckAgentToken(site, token, addr string) (bool, string, error) { var stored struct { AgentToken Hash string `json:"hash"` } ok := false - _ = s.db.Update(func(tx *bolt.Tx) error { + err := s.db.Update(func(tx *bolt.Tx) error { b := tx.Bucket(bucketAgents) if b == nil { return nil @@ -240,7 +292,7 @@ func (s *Store) CheckAgentToken(site, token, addr string) bool { return nil } if err := json.Unmarshal(blob, &stored); err != nil { - return nil + return fmt.Errorf("agent record %s is unreadable: %w", site, err) } if stored.Revoked { return nil @@ -256,11 +308,17 @@ func (s *Store) CheckAgentToken(site, token, addr string) bool { stored.LastAddr = addr updated, err := json.Marshal(stored) if err != nil { - return nil + return err } return b.Put([]byte(site), updated) }) - return ok + if err != nil { + return false, "", err + } + if !ok { + return false, "", nil + } + return true, stored.CredentialID, nil } // ListAgentTokens returns what is known about each box, without the diff --git a/web-ui/backend/static/app.css b/web-ui/backend/static/app.css index 628b2e1..c553588 100644 --- a/web-ui/backend/static/app.css +++ b/web-ui/backend/static/app.css @@ -1 +1 @@ -*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:Inter,system-ui,-apple-system,sans-serif;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:JetBrains Mono,ui-monospace,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.container{width:100%}@media (min-width:640px){.container{max-width:640px}}@media (min-width:768px){.container{max-width:768px}}@media (min-width:1024px){.container{max-width:1024px}}@media (min-width:1280px){.container{max-width:1280px}}@media (min-width:1536px){.container{max-width:1536px}}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.pointer-events-none{pointer-events:none}.\!visible{visibility:visible!important}.visible{visibility:visible}.collapse{visibility:collapse}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.inset-y-0{top:0;bottom:0}.-top-2{top:-.5rem}.bottom-4{bottom:1rem}.bottom-6{bottom:1.5rem}.left-0{left:0}.left-1\/2{left:50%}.left-2{left:.5rem}.left-full{left:100%}.right-0{right:0}.right-4{right:1rem}.top-0{top:0}.top-14{top:3.5rem}.top-2{top:.5rem}.top-20{top:5rem}.top-4{top:1rem}.z-10{z-index:10}.z-20{z-index:20}.z-50{z-index:50}.col-span-2{grid-column:span 2/span 2}.mx-1{margin-left:.25rem;margin-right:.25rem}.mx-5{margin-left:1.25rem;margin-right:1.25rem}.mx-auto{margin-left:auto;margin-right:auto}.my-1{margin-top:.25rem;margin-bottom:.25rem}.-mb-px{margin-bottom:-1px}.-mt-0\.5{margin-top:-.125rem}.-mt-1{margin-top:-.25rem}.-mt-2{margin-top:-.5rem}.mb-1{margin-bottom:.25rem}.mb-1\.5{margin-bottom:.375rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.ml-3{margin-left:.75rem}.ml-4{margin-left:1rem}.ml-auto{margin-left:auto}.mr-0\.5{margin-right:.125rem}.mr-1{margin-right:.25rem}.mr-2{margin-right:.5rem}.mr-3{margin-right:.75rem}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-16{margin-top:4rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-6{margin-top:1.5rem}.block{display:block}.inline-block{display:inline-block}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-10{height:2.5rem}.h-12{height:3rem}.h-14{height:3.5rem}.h-4{height:1rem}.h-6{height:1.5rem}.h-7{height:1.75rem}.h-8{height:2rem}.h-\[600px\]{height:600px}.max-h-24{max-height:6rem}.max-h-40{max-height:10rem}.max-h-48{max-height:12rem}.max-h-80{max-height:20rem}.max-h-96{max-height:24rem}.max-h-\[36rem\]{max-height:36rem}.max-h-\[85vh\]{max-height:85vh}.max-h-\[90vh\]{max-height:90vh}.min-h-screen{min-height:100vh}.w-10{width:2.5rem}.w-11{width:2.75rem}.w-12{width:3rem}.w-20{width:5rem}.w-4{width:1rem}.w-40{width:10rem}.w-44{width:11rem}.w-48{width:12rem}.w-5{width:1.25rem}.w-64{width:16rem}.w-7{width:1.75rem}.w-8{width:2rem}.w-96{width:24rem}.w-full{width:100%}.w-px{width:1px}.min-w-0{min-width:0}.min-w-\[16rem\]{min-width:16rem}.min-w-full{min-width:100%}.max-w-2xl{max-width:42rem}.max-w-3xl{max-width:48rem}.max-w-4xl{max-width:56rem}.max-w-7xl{max-width:80rem}.max-w-\[14rem\]{max-width:14rem}.max-w-\[150px\]{max-width:150px}.max-w-\[15rem\]{max-width:15rem}.max-w-\[180px\]{max-width:180px}.max-w-\[18rem\]{max-width:18rem}.max-w-\[200px\]{max-width:200px}.max-w-lg{max-width:32rem}.max-w-md{max-width:28rem}.max-w-sm{max-width:24rem}.flex-1{flex:1 1 0%}.flex-\[2\]{flex:2}.flex-shrink-0{flex-shrink:0}.shrink{flex-shrink:1}.border-collapse{border-collapse:collapse}.-translate-x-1\/2{--tw-translate-x:-50%}.-translate-x-1\/2,.translate-y-0{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.translate-y-0{--tw-translate-y:0px}.translate-y-4{--tw-translate-y:1rem}.transform,.translate-y-4{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes spin{to{transform:rotate(1turn)}}.animate-spin{animation:spin 1s linear infinite}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.resize{resize:both}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.items-baseline{align-items:baseline}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.gap-x-2{-moz-column-gap:.5rem;column-gap:.5rem}.gap-x-6{-moz-column-gap:1.5rem;column-gap:1.5rem}.gap-y-0\.5{row-gap:.125rem}.gap-y-1{row-gap:.25rem}.space-x-1>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.25rem*var(--tw-space-x-reverse));margin-left:calc(.25rem*(1 - var(--tw-space-x-reverse)))}.space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.5rem*var(--tw-space-x-reverse));margin-left:calc(.5rem*(1 - var(--tw-space-x-reverse)))}.space-x-3>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.75rem*var(--tw-space-x-reverse));margin-left:calc(.75rem*(1 - var(--tw-space-x-reverse)))}.space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(1rem*var(--tw-space-x-reverse));margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)))}.space-x-6>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(1.5rem*var(--tw-space-x-reverse));margin-left:calc(1.5rem*(1 - var(--tw-space-x-reverse)))}.space-x-8>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(2rem*var(--tw-space-x-reverse));margin-left:calc(2rem*(1 - var(--tw-space-x-reverse)))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem*var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem*var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.75rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem*var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem*var(--tw-space-y-reverse))}.space-y-5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.25rem*var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem*var(--tw-space-y-reverse))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px*var(--tw-divide-y-reverse))}.divide-gray-100>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:rgb(243 244 246/var(--tw-divide-opacity,1))}.divide-gray-200>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:rgb(229 231 235/var(--tw-divide-opacity,1))}.self-end{align-self:flex-end}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.whitespace-pre-line{white-space:pre-line}.break-words{overflow-wrap:break-word}.rounded{border-radius:.25rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-xl{border-radius:.75rem}.rounded-b-lg{border-bottom-right-radius:.5rem;border-bottom-left-radius:.5rem}.rounded-r{border-top-right-radius:.25rem;border-bottom-right-radius:.25rem}.border{border-width:1px}.border-2{border-width:2px}.border-y{border-top-width:1px}.border-b,.border-y{border-bottom-width:1px}.border-b-2{border-bottom-width:2px}.border-l-4{border-left-width:4px}.border-t{border-top-width:1px}.border-amber-200{--tw-border-opacity:1;border-color:rgb(253 230 138/var(--tw-border-opacity,1))}.border-amber-300{--tw-border-opacity:1;border-color:rgb(252 211 77/var(--tw-border-opacity,1))}.border-amber-500{--tw-border-opacity:1;border-color:rgb(245 158 11/var(--tw-border-opacity,1))}.border-blue-200{--tw-border-opacity:1;border-color:rgb(191 219 254/var(--tw-border-opacity,1))}.border-blue-400{--tw-border-opacity:1;border-color:rgb(96 165 250/var(--tw-border-opacity,1))}.border-blue-500{--tw-border-opacity:1;border-color:rgb(59 130 246/var(--tw-border-opacity,1))}.border-emerald-200{--tw-border-opacity:1;border-color:rgb(167 243 208/var(--tw-border-opacity,1))}.border-gray-100{--tw-border-opacity:1;border-color:rgb(243 244 246/var(--tw-border-opacity,1))}.border-gray-200{--tw-border-opacity:1;border-color:rgb(229 231 235/var(--tw-border-opacity,1))}.border-gray-300{--tw-border-opacity:1;border-color:rgb(209 213 219/var(--tw-border-opacity,1))}.border-gray-400{--tw-border-opacity:1;border-color:rgb(156 163 175/var(--tw-border-opacity,1))}.border-gray-50{--tw-border-opacity:1;border-color:rgb(249 250 251/var(--tw-border-opacity,1))}.border-gray-700{--tw-border-opacity:1;border-color:rgb(55 65 81/var(--tw-border-opacity,1))}.border-green-200{--tw-border-opacity:1;border-color:rgb(187 247 208/var(--tw-border-opacity,1))}.border-green-500{--tw-border-opacity:1;border-color:rgb(34 197 94/var(--tw-border-opacity,1))}.border-red-100{--tw-border-opacity:1;border-color:rgb(254 226 226/var(--tw-border-opacity,1))}.border-red-200{--tw-border-opacity:1;border-color:rgb(254 202 202/var(--tw-border-opacity,1))}.border-red-300{--tw-border-opacity:1;border-color:rgb(252 165 165/var(--tw-border-opacity,1))}.border-red-500{--tw-border-opacity:1;border-color:rgb(239 68 68/var(--tw-border-opacity,1))}.border-red-600{--tw-border-opacity:1;border-color:rgb(220 38 38/var(--tw-border-opacity,1))}.border-transparent{border-color:transparent}.border-yellow-300{--tw-border-opacity:1;border-color:rgb(253 224 71/var(--tw-border-opacity,1))}.border-yellow-400{--tw-border-opacity:1;border-color:rgb(250 204 21/var(--tw-border-opacity,1))}.border-yellow-500{--tw-border-opacity:1;border-color:rgb(234 179 8/var(--tw-border-opacity,1))}.border-l-amber-400{--tw-border-opacity:1;border-left-color:rgb(251 191 36/var(--tw-border-opacity,1))}.border-l-blue-300{--tw-border-opacity:1;border-left-color:rgb(147 197 253/var(--tw-border-opacity,1))}.border-l-red-500{--tw-border-opacity:1;border-left-color:rgb(239 68 68/var(--tw-border-opacity,1))}.border-t-gray-900{--tw-border-opacity:1;border-top-color:rgb(17 24 39/var(--tw-border-opacity,1))}.bg-amber-100{--tw-bg-opacity:1;background-color:rgb(254 243 199/var(--tw-bg-opacity,1))}.bg-amber-200{--tw-bg-opacity:1;background-color:rgb(253 230 138/var(--tw-bg-opacity,1))}.bg-amber-50{--tw-bg-opacity:1;background-color:rgb(255 251 235/var(--tw-bg-opacity,1))}.bg-amber-50\/40{background-color:rgba(255,251,235,.4)}.bg-amber-600{--tw-bg-opacity:1;background-color:rgb(217 119 6/var(--tw-bg-opacity,1))}.bg-black{--tw-bg-opacity:1;background-color:rgb(0 0 0/var(--tw-bg-opacity,1))}.bg-black\/50{background-color:rgba(0,0,0,.5)}.bg-blue-100{--tw-bg-opacity:1;background-color:rgb(219 234 254/var(--tw-bg-opacity,1))}.bg-blue-50{--tw-bg-opacity:1;background-color:rgb(239 246 255/var(--tw-bg-opacity,1))}.bg-blue-600{--tw-bg-opacity:1;background-color:rgb(37 99 235/var(--tw-bg-opacity,1))}.bg-cyan-600{--tw-bg-opacity:1;background-color:rgb(8 145 178/var(--tw-bg-opacity,1))}.bg-emerald-100{--tw-bg-opacity:1;background-color:rgb(209 250 229/var(--tw-bg-opacity,1))}.bg-emerald-50{--tw-bg-opacity:1;background-color:rgb(236 253 245/var(--tw-bg-opacity,1))}.bg-emerald-600{--tw-bg-opacity:1;background-color:rgb(5 150 105/var(--tw-bg-opacity,1))}.bg-gray-100{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity,1))}.bg-gray-200{--tw-bg-opacity:1;background-color:rgb(229 231 235/var(--tw-bg-opacity,1))}.bg-gray-300{--tw-bg-opacity:1;background-color:rgb(209 213 219/var(--tw-bg-opacity,1))}.bg-gray-400{--tw-bg-opacity:1;background-color:rgb(156 163 175/var(--tw-bg-opacity,1))}.bg-gray-50{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity,1))}.bg-gray-500{--tw-bg-opacity:1;background-color:rgb(107 114 128/var(--tw-bg-opacity,1))}.bg-gray-600{--tw-bg-opacity:1;background-color:rgb(75 85 99/var(--tw-bg-opacity,1))}.bg-gray-800{--tw-bg-opacity:1;background-color:rgb(31 41 55/var(--tw-bg-opacity,1))}.bg-gray-900{--tw-bg-opacity:1;background-color:rgb(17 24 39/var(--tw-bg-opacity,1))}.bg-green-100{--tw-bg-opacity:1;background-color:rgb(220 252 231/var(--tw-bg-opacity,1))}.bg-green-50{--tw-bg-opacity:1;background-color:rgb(240 253 244/var(--tw-bg-opacity,1))}.bg-green-50\/40{background-color:rgba(240,253,244,.4)}.bg-green-600{--tw-bg-opacity:1;background-color:rgb(22 163 74/var(--tw-bg-opacity,1))}.bg-indigo-50{--tw-bg-opacity:1;background-color:rgb(238 242 255/var(--tw-bg-opacity,1))}.bg-indigo-600{--tw-bg-opacity:1;background-color:rgb(79 70 229/var(--tw-bg-opacity,1))}.bg-orange-50{--tw-bg-opacity:1;background-color:rgb(255 247 237/var(--tw-bg-opacity,1))}.bg-purple-100{--tw-bg-opacity:1;background-color:rgb(243 232 255/var(--tw-bg-opacity,1))}.bg-purple-50{--tw-bg-opacity:1;background-color:rgb(250 245 255/var(--tw-bg-opacity,1))}.bg-purple-600{--tw-bg-opacity:1;background-color:rgb(147 51 234/var(--tw-bg-opacity,1))}.bg-red-100{--tw-bg-opacity:1;background-color:rgb(254 226 226/var(--tw-bg-opacity,1))}.bg-red-200{--tw-bg-opacity:1;background-color:rgb(254 202 202/var(--tw-bg-opacity,1))}.bg-red-50{--tw-bg-opacity:1;background-color:rgb(254 242 242/var(--tw-bg-opacity,1))}.bg-red-50\/50{background-color:hsla(0,86%,97%,.5)}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.bg-red-600{--tw-bg-opacity:1;background-color:rgb(220 38 38/var(--tw-bg-opacity,1))}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.bg-white\/60{background-color:hsla(0,0%,100%,.6)}.bg-yellow-100{--tw-bg-opacity:1;background-color:rgb(254 249 195/var(--tw-bg-opacity,1))}.bg-yellow-50{--tw-bg-opacity:1;background-color:rgb(254 252 232/var(--tw-bg-opacity,1))}.bg-yellow-600{--tw-bg-opacity:1;background-color:rgb(202 138 4/var(--tw-bg-opacity,1))}.bg-opacity-40{--tw-bg-opacity:0.4}.bg-opacity-50{--tw-bg-opacity:0.5}.bg-opacity-75{--tw-bg-opacity:0.75}.bg-gradient-to-r{background-image:linear-gradient(to right,var(--tw-gradient-stops))}.from-blue-50{--tw-gradient-from:#eff6ff var(--tw-gradient-from-position);--tw-gradient-to:rgba(239,246,255,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-green-400{--tw-gradient-from:#4ade80 var(--tw-gradient-from-position);--tw-gradient-to:rgba(74,222,128,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-slate-50{--tw-gradient-from:#f8fafc var(--tw-gradient-from-position);--tw-gradient-to:rgba(248,250,252,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.to-gray-50{--tw-gradient-to:#f9fafb var(--tw-gradient-to-position)}.to-green-600{--tw-gradient-to:#16a34a var(--tw-gradient-to-position)}.to-indigo-50{--tw-gradient-to:#eef2ff var(--tw-gradient-to-position)}.p-0\.5{padding:.125rem}.p-1{padding:.25rem}.p-10{padding:2.5rem}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.px-8{padding-left:2rem;padding-right:2rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-12{padding-top:3rem;padding-bottom:3rem}.py-16{padding-top:4rem;padding-bottom:4rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-2\.5{padding-top:.625rem;padding-bottom:.625rem}.py-20{padding-top:5rem;padding-bottom:5rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-5{padding-top:1.25rem;padding-bottom:1.25rem}.py-6{padding-top:1.5rem;padding-bottom:1.5rem}.py-8{padding-top:2rem;padding-bottom:2rem}.pb-1\.5{padding-bottom:.375rem}.pb-4{padding-bottom:1rem}.pl-10{padding-left:2.5rem}.pl-3{padding-left:.75rem}.pl-8{padding-left:2rem}.pr-3{padding-right:.75rem}.pt-1{padding-top:.25rem}.pt-2{padding-top:.5rem}.pt-4{padding-top:1rem}.pt-6{padding-top:1.5rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.align-middle{vertical-align:middle}.font-mono{font-family:JetBrains Mono,ui-monospace,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-5xl{font-size:3rem;line-height:1}.text-6xl{font-size:3.75rem;line-height:1}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-\[8px\]{font-size:8px}.text-\[9px\]{font-size:9px}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-extrabold{font-weight:800}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.italic{font-style:italic}.leading-tight{line-height:1.25}.tracking-tight{letter-spacing:-.025em}.tracking-wide{letter-spacing:.025em}.tracking-wider{letter-spacing:.05em}.text-amber-400{--tw-text-opacity:1;color:rgb(251 191 36/var(--tw-text-opacity,1))}.text-amber-500{--tw-text-opacity:1;color:rgb(245 158 11/var(--tw-text-opacity,1))}.text-amber-600{--tw-text-opacity:1;color:rgb(217 119 6/var(--tw-text-opacity,1))}.text-amber-700{--tw-text-opacity:1;color:rgb(180 83 9/var(--tw-text-opacity,1))}.text-amber-800{--tw-text-opacity:1;color:rgb(146 64 14/var(--tw-text-opacity,1))}.text-amber-900{--tw-text-opacity:1;color:rgb(120 53 15/var(--tw-text-opacity,1))}.text-blue-500{--tw-text-opacity:1;color:rgb(59 130 246/var(--tw-text-opacity,1))}.text-blue-600{--tw-text-opacity:1;color:rgb(37 99 235/var(--tw-text-opacity,1))}.text-blue-700{--tw-text-opacity:1;color:rgb(29 78 216/var(--tw-text-opacity,1))}.text-blue-800{--tw-text-opacity:1;color:rgb(30 64 175/var(--tw-text-opacity,1))}.text-blue-900{--tw-text-opacity:1;color:rgb(30 58 138/var(--tw-text-opacity,1))}.text-emerald-600{--tw-text-opacity:1;color:rgb(5 150 105/var(--tw-text-opacity,1))}.text-emerald-700{--tw-text-opacity:1;color:rgb(4 120 87/var(--tw-text-opacity,1))}.text-emerald-800{--tw-text-opacity:1;color:rgb(6 95 70/var(--tw-text-opacity,1))}.text-emerald-900{--tw-text-opacity:1;color:rgb(6 78 59/var(--tw-text-opacity,1))}.text-gray-300{--tw-text-opacity:1;color:rgb(209 213 219/var(--tw-text-opacity,1))}.text-gray-400{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity,1))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity,1))}.text-gray-600{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity,1))}.text-gray-700{--tw-text-opacity:1;color:rgb(55 65 81/var(--tw-text-opacity,1))}.text-gray-800{--tw-text-opacity:1;color:rgb(31 41 55/var(--tw-text-opacity,1))}.text-gray-900{--tw-text-opacity:1;color:rgb(17 24 39/var(--tw-text-opacity,1))}.text-green-400{--tw-text-opacity:1;color:rgb(74 222 128/var(--tw-text-opacity,1))}.text-green-500{--tw-text-opacity:1;color:rgb(34 197 94/var(--tw-text-opacity,1))}.text-green-600{--tw-text-opacity:1;color:rgb(22 163 74/var(--tw-text-opacity,1))}.text-green-700{--tw-text-opacity:1;color:rgb(21 128 61/var(--tw-text-opacity,1))}.text-green-800{--tw-text-opacity:1;color:rgb(22 101 52/var(--tw-text-opacity,1))}.text-indigo-600{--tw-text-opacity:1;color:rgb(79 70 229/var(--tw-text-opacity,1))}.text-orange-600{--tw-text-opacity:1;color:rgb(234 88 12/var(--tw-text-opacity,1))}.text-orange-800{--tw-text-opacity:1;color:rgb(154 52 18/var(--tw-text-opacity,1))}.text-purple-500{--tw-text-opacity:1;color:rgb(168 85 247/var(--tw-text-opacity,1))}.text-purple-600{--tw-text-opacity:1;color:rgb(147 51 234/var(--tw-text-opacity,1))}.text-red-400{--tw-text-opacity:1;color:rgb(248 113 113/var(--tw-text-opacity,1))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.text-red-600{--tw-text-opacity:1;color:rgb(220 38 38/var(--tw-text-opacity,1))}.text-red-700{--tw-text-opacity:1;color:rgb(185 28 28/var(--tw-text-opacity,1))}.text-red-800{--tw-text-opacity:1;color:rgb(153 27 27/var(--tw-text-opacity,1))}.text-red-900{--tw-text-opacity:1;color:rgb(127 29 29/var(--tw-text-opacity,1))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.text-yellow-400{--tw-text-opacity:1;color:rgb(250 204 21/var(--tw-text-opacity,1))}.text-yellow-500{--tw-text-opacity:1;color:rgb(234 179 8/var(--tw-text-opacity,1))}.text-yellow-600{--tw-text-opacity:1;color:rgb(202 138 4/var(--tw-text-opacity,1))}.text-yellow-700{--tw-text-opacity:1;color:rgb(161 98 7/var(--tw-text-opacity,1))}.text-yellow-800{--tw-text-opacity:1;color:rgb(133 77 14/var(--tw-text-opacity,1))}.text-yellow-900{--tw-text-opacity:1;color:rgb(113 63 18/var(--tw-text-opacity,1))}.underline{text-decoration-line:underline}.no-underline{text-decoration-line:none}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.opacity-0{opacity:0}.opacity-100{opacity:1}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-75{opacity:.75}.shadow{--tw-shadow:0 1px 3px 0 rgba(0,0,0,.1),0 1px 2px -1px rgba(0,0,0,.1);--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color),0 1px 2px -1px var(--tw-shadow-color)}.shadow,.shadow-2xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-2xl{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color)}.shadow-lg{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.shadow-lg,.shadow-md{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px rgba(0,0,0,.1),0 2px 4px -2px rgba(0,0,0,.1);--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color),0 2px 4px -2px var(--tw-shadow-color)}.shadow-sm{--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color)}.shadow-sm,.shadow-xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color)}.ring{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.ring,.ring-1{box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.ring-1{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.ring-2{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.ring-blue-200{--tw-ring-opacity:1;--tw-ring-color:rgb(191 219 254/var(--tw-ring-opacity,1))}.ring-blue-300{--tw-ring-opacity:1;--tw-ring-color:rgb(147 197 253/var(--tw-ring-opacity,1))}.ring-red-200{--tw-ring-opacity:1;--tw-ring-color:rgb(254 202 202/var(--tw-ring-opacity,1))}.ring-red-400{--tw-ring-opacity:1;--tw-ring-color:rgb(248 113 113/var(--tw-ring-opacity,1))}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-opacity{transition-property:opacity;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-shadow{transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-200{transition-duration:.2s}.duration-300{transition-duration:.3s}.ease-out{transition-timing-function:cubic-bezier(0,0,.2,1)}.after\:absolute:after{content:var(--tw-content);position:absolute}.after\:left-\[2px\]:after{content:var(--tw-content);left:2px}.after\:top-\[2px\]:after{content:var(--tw-content);top:2px}.after\:h-5:after{content:var(--tw-content);height:1.25rem}.after\:w-5:after{content:var(--tw-content);width:1.25rem}.after\:rounded-full:after{content:var(--tw-content);border-radius:9999px}.after\:border:after{content:var(--tw-content);border-width:1px}.after\:border-gray-300:after{content:var(--tw-content);--tw-border-opacity:1;border-color:rgb(209 213 219/var(--tw-border-opacity,1))}.after\:bg-white:after{content:var(--tw-content);--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.after\:transition-all:after{content:var(--tw-content);transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.after\:content-\[\'\'\]:after{--tw-content:"";content:var(--tw-content)}.hover\:border-gray-300:hover{--tw-border-opacity:1;border-color:rgb(209 213 219/var(--tw-border-opacity,1))}.hover\:bg-amber-200:hover{--tw-bg-opacity:1;background-color:rgb(253 230 138/var(--tw-bg-opacity,1))}.hover\:bg-amber-700:hover{--tw-bg-opacity:1;background-color:rgb(180 83 9/var(--tw-bg-opacity,1))}.hover\:bg-blue-100:hover{--tw-bg-opacity:1;background-color:rgb(219 234 254/var(--tw-bg-opacity,1))}.hover\:bg-blue-200:hover{--tw-bg-opacity:1;background-color:rgb(191 219 254/var(--tw-bg-opacity,1))}.hover\:bg-blue-50:hover{--tw-bg-opacity:1;background-color:rgb(239 246 255/var(--tw-bg-opacity,1))}.hover\:bg-blue-700:hover{--tw-bg-opacity:1;background-color:rgb(29 78 216/var(--tw-bg-opacity,1))}.hover\:bg-cyan-700:hover{--tw-bg-opacity:1;background-color:rgb(14 116 144/var(--tw-bg-opacity,1))}.hover\:bg-emerald-50:hover{--tw-bg-opacity:1;background-color:rgb(236 253 245/var(--tw-bg-opacity,1))}.hover\:bg-emerald-700:hover{--tw-bg-opacity:1;background-color:rgb(4 120 87/var(--tw-bg-opacity,1))}.hover\:bg-gray-100:hover{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity,1))}.hover\:bg-gray-200:hover{--tw-bg-opacity:1;background-color:rgb(229 231 235/var(--tw-bg-opacity,1))}.hover\:bg-gray-300:hover{--tw-bg-opacity:1;background-color:rgb(209 213 219/var(--tw-bg-opacity,1))}.hover\:bg-gray-50:hover{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity,1))}.hover\:bg-gray-500:hover{--tw-bg-opacity:1;background-color:rgb(107 114 128/var(--tw-bg-opacity,1))}.hover\:bg-gray-600:hover{--tw-bg-opacity:1;background-color:rgb(75 85 99/var(--tw-bg-opacity,1))}.hover\:bg-gray-700:hover{--tw-bg-opacity:1;background-color:rgb(55 65 81/var(--tw-bg-opacity,1))}.hover\:bg-gray-800:hover{--tw-bg-opacity:1;background-color:rgb(31 41 55/var(--tw-bg-opacity,1))}.hover\:bg-green-700:hover{--tw-bg-opacity:1;background-color:rgb(21 128 61/var(--tw-bg-opacity,1))}.hover\:bg-indigo-700:hover{--tw-bg-opacity:1;background-color:rgb(67 56 202/var(--tw-bg-opacity,1))}.hover\:bg-purple-700:hover{--tw-bg-opacity:1;background-color:rgb(126 34 206/var(--tw-bg-opacity,1))}.hover\:bg-red-100:hover{--tw-bg-opacity:1;background-color:rgb(254 226 226/var(--tw-bg-opacity,1))}.hover\:bg-red-200:hover{--tw-bg-opacity:1;background-color:rgb(254 202 202/var(--tw-bg-opacity,1))}.hover\:bg-red-50:hover{--tw-bg-opacity:1;background-color:rgb(254 242 242/var(--tw-bg-opacity,1))}.hover\:bg-red-600:hover{--tw-bg-opacity:1;background-color:rgb(220 38 38/var(--tw-bg-opacity,1))}.hover\:bg-red-700:hover{--tw-bg-opacity:1;background-color:rgb(185 28 28/var(--tw-bg-opacity,1))}.hover\:bg-yellow-700:hover{--tw-bg-opacity:1;background-color:rgb(161 98 7/var(--tw-bg-opacity,1))}.hover\:text-blue-800:hover{--tw-text-opacity:1;color:rgb(30 64 175/var(--tw-text-opacity,1))}.hover\:text-blue-900:hover{--tw-text-opacity:1;color:rgb(30 58 138/var(--tw-text-opacity,1))}.hover\:text-gray-600:hover{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity,1))}.hover\:text-gray-700:hover{--tw-text-opacity:1;color:rgb(55 65 81/var(--tw-text-opacity,1))}.hover\:text-gray-900:hover{--tw-text-opacity:1;color:rgb(17 24 39/var(--tw-text-opacity,1))}.hover\:text-red-600:hover{--tw-text-opacity:1;color:rgb(220 38 38/var(--tw-text-opacity,1))}.hover\:text-red-900:hover{--tw-text-opacity:1;color:rgb(127 29 29/var(--tw-text-opacity,1))}.hover\:underline:hover{text-decoration-line:underline}.hover\:shadow-md:hover{--tw-shadow:0 4px 6px -1px rgba(0,0,0,.1),0 2px 4px -2px rgba(0,0,0,.1);--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color),0 2px 4px -2px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.focus\:border-blue-500:focus{--tw-border-opacity:1;border-color:rgb(59 130 246/var(--tw-border-opacity,1))}.focus\:border-transparent:focus{border-color:transparent}.focus\:ring-1:focus{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.focus\:ring-1:focus,.focus\:ring-2:focus{box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.focus\:ring-2:focus{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.focus\:ring-blue-500:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(59 130 246/var(--tw-ring-opacity,1))}.focus\:ring-gray-900:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(17 24 39/var(--tw-ring-opacity,1))}.focus\:ring-green-500:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(34 197 94/var(--tw-ring-opacity,1))}.disabled\:text-gray-400:disabled{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity,1))}.disabled\:opacity-40:disabled{opacity:.4}.disabled\:opacity-50:disabled{opacity:.5}.group:hover .group-hover\:block{display:block}.group:hover .group-hover\:opacity-100{opacity:1}.peer:checked~.peer-checked\:bg-green-600{--tw-bg-opacity:1;background-color:rgb(22 163 74/var(--tw-bg-opacity,1))}.peer:checked~.peer-checked\:after\:translate-x-full:after{content:var(--tw-content);--tw-translate-x:100%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.peer:checked~.peer-checked\:after\:border-white:after{content:var(--tw-content);--tw-border-opacity:1;border-color:rgb(255 255 255/var(--tw-border-opacity,1))}.peer:focus~.peer-focus\:ring-2{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.peer:focus~.peer-focus\:ring-blue-500{--tw-ring-opacity:1;--tw-ring-color:rgb(59 130 246/var(--tw-ring-opacity,1))}@media (min-width:640px){.sm\:ml-8{margin-left:2rem}.sm\:inline{display:inline}.sm\:flex{display:flex}.sm\:hidden{display:none}.sm\:space-x-5>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(1.25rem*var(--tw-space-x-reverse));margin-left:calc(1.25rem*(1 - var(--tw-space-x-reverse)))}.sm\:p-8{padding:2rem}.sm\:px-6{padding-left:1.5rem;padding-right:1.5rem}}@media (min-width:768px){.md\:col-span-2{grid-column:span 2/span 2}.md\:col-span-4{grid-column:span 4/span 4}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}@media (min-width:1024px){.lg\:col-span-1{grid-column:span 1/span 1}.lg\:col-span-2{grid-column:span 2/span 2}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:px-8{padding-left:2rem;padding-right:2rem}}@media (min-width:1280px){.xl\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.xl\:grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}} \ No newline at end of file +*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:Inter,system-ui,-apple-system,sans-serif;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:JetBrains Mono,ui-monospace,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.container{width:100%}@media (min-width:640px){.container{max-width:640px}}@media (min-width:768px){.container{max-width:768px}}@media (min-width:1024px){.container{max-width:1024px}}@media (min-width:1280px){.container{max-width:1280px}}@media (min-width:1536px){.container{max-width:1536px}}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.pointer-events-none{pointer-events:none}.\!visible{visibility:visible!important}.visible{visibility:visible}.collapse{visibility:collapse}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.inset-y-0{top:0;bottom:0}.-top-2{top:-.5rem}.bottom-4{bottom:1rem}.bottom-6{bottom:1.5rem}.left-0{left:0}.left-1\/2{left:50%}.left-2{left:.5rem}.left-full{left:100%}.right-0{right:0}.right-4{right:1rem}.top-0{top:0}.top-14{top:3.5rem}.top-2{top:.5rem}.top-20{top:5rem}.top-4{top:1rem}.z-10{z-index:10}.z-20{z-index:20}.z-50{z-index:50}.col-span-2{grid-column:span 2/span 2}.mx-1{margin-left:.25rem;margin-right:.25rem}.mx-5{margin-left:1.25rem;margin-right:1.25rem}.mx-auto{margin-left:auto;margin-right:auto}.my-1{margin-top:.25rem;margin-bottom:.25rem}.-mb-px{margin-bottom:-1px}.-mt-0\.5{margin-top:-.125rem}.-mt-1{margin-top:-.25rem}.-mt-2{margin-top:-.5rem}.mb-1{margin-bottom:.25rem}.mb-1\.5{margin-bottom:.375rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.ml-3{margin-left:.75rem}.ml-4{margin-left:1rem}.ml-auto{margin-left:auto}.mr-0\.5{margin-right:.125rem}.mr-1{margin-right:.25rem}.mr-2{margin-right:.5rem}.mr-3{margin-right:.75rem}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-16{margin-top:4rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-6{margin-top:1.5rem}.block{display:block}.inline-block{display:inline-block}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-10{height:2.5rem}.h-12{height:3rem}.h-14{height:3.5rem}.h-4{height:1rem}.h-6{height:1.5rem}.h-7{height:1.75rem}.h-8{height:2rem}.h-\[600px\]{height:600px}.max-h-24{max-height:6rem}.max-h-40{max-height:10rem}.max-h-48{max-height:12rem}.max-h-80{max-height:20rem}.max-h-96{max-height:24rem}.max-h-\[36rem\]{max-height:36rem}.max-h-\[85vh\]{max-height:85vh}.max-h-\[90vh\]{max-height:90vh}.min-h-screen{min-height:100vh}.w-10{width:2.5rem}.w-11{width:2.75rem}.w-12{width:3rem}.w-20{width:5rem}.w-4{width:1rem}.w-40{width:10rem}.w-44{width:11rem}.w-48{width:12rem}.w-5{width:1.25rem}.w-64{width:16rem}.w-7{width:1.75rem}.w-8{width:2rem}.w-96{width:24rem}.w-full{width:100%}.w-px{width:1px}.min-w-0{min-width:0}.min-w-\[16rem\]{min-width:16rem}.min-w-full{min-width:100%}.max-w-2xl{max-width:42rem}.max-w-3xl{max-width:48rem}.max-w-4xl{max-width:56rem}.max-w-7xl{max-width:80rem}.max-w-\[14rem\]{max-width:14rem}.max-w-\[150px\]{max-width:150px}.max-w-\[15rem\]{max-width:15rem}.max-w-\[180px\]{max-width:180px}.max-w-\[18rem\]{max-width:18rem}.max-w-\[200px\]{max-width:200px}.max-w-lg{max-width:32rem}.max-w-md{max-width:28rem}.max-w-sm{max-width:24rem}.flex-1{flex:1 1 0%}.flex-\[2\]{flex:2}.flex-shrink-0{flex-shrink:0}.shrink{flex-shrink:1}.border-collapse{border-collapse:collapse}.-translate-x-1\/2{--tw-translate-x:-50%}.-translate-x-1\/2,.translate-y-0{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.translate-y-0{--tw-translate-y:0px}.translate-y-4{--tw-translate-y:1rem}.transform,.translate-y-4{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes spin{to{transform:rotate(1turn)}}.animate-spin{animation:spin 1s linear infinite}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.resize{resize:both}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.items-baseline{align-items:baseline}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.gap-x-2{-moz-column-gap:.5rem;column-gap:.5rem}.gap-x-6{-moz-column-gap:1.5rem;column-gap:1.5rem}.gap-y-0\.5{row-gap:.125rem}.gap-y-1{row-gap:.25rem}.space-x-1>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.25rem*var(--tw-space-x-reverse));margin-left:calc(.25rem*(1 - var(--tw-space-x-reverse)))}.space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.5rem*var(--tw-space-x-reverse));margin-left:calc(.5rem*(1 - var(--tw-space-x-reverse)))}.space-x-3>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.75rem*var(--tw-space-x-reverse));margin-left:calc(.75rem*(1 - var(--tw-space-x-reverse)))}.space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(1rem*var(--tw-space-x-reverse));margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)))}.space-x-6>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(1.5rem*var(--tw-space-x-reverse));margin-left:calc(1.5rem*(1 - var(--tw-space-x-reverse)))}.space-x-8>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(2rem*var(--tw-space-x-reverse));margin-left:calc(2rem*(1 - var(--tw-space-x-reverse)))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem*var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem*var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.75rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem*var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem*var(--tw-space-y-reverse))}.space-y-5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.25rem*var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem*var(--tw-space-y-reverse))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px*var(--tw-divide-y-reverse))}.divide-gray-100>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:rgb(243 244 246/var(--tw-divide-opacity,1))}.divide-gray-200>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:rgb(229 231 235/var(--tw-divide-opacity,1))}.self-end{align-self:flex-end}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.whitespace-pre-line{white-space:pre-line}.break-words{overflow-wrap:break-word}.rounded{border-radius:.25rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-xl{border-radius:.75rem}.rounded-b-lg{border-bottom-right-radius:.5rem;border-bottom-left-radius:.5rem}.rounded-r{border-top-right-radius:.25rem;border-bottom-right-radius:.25rem}.border{border-width:1px}.border-2{border-width:2px}.border-y{border-top-width:1px}.border-b,.border-y{border-bottom-width:1px}.border-b-2{border-bottom-width:2px}.border-l-4{border-left-width:4px}.border-t{border-top-width:1px}.border-amber-200{--tw-border-opacity:1;border-color:rgb(253 230 138/var(--tw-border-opacity,1))}.border-amber-300{--tw-border-opacity:1;border-color:rgb(252 211 77/var(--tw-border-opacity,1))}.border-amber-500{--tw-border-opacity:1;border-color:rgb(245 158 11/var(--tw-border-opacity,1))}.border-blue-200{--tw-border-opacity:1;border-color:rgb(191 219 254/var(--tw-border-opacity,1))}.border-blue-400{--tw-border-opacity:1;border-color:rgb(96 165 250/var(--tw-border-opacity,1))}.border-blue-500{--tw-border-opacity:1;border-color:rgb(59 130 246/var(--tw-border-opacity,1))}.border-emerald-200{--tw-border-opacity:1;border-color:rgb(167 243 208/var(--tw-border-opacity,1))}.border-gray-100{--tw-border-opacity:1;border-color:rgb(243 244 246/var(--tw-border-opacity,1))}.border-gray-200{--tw-border-opacity:1;border-color:rgb(229 231 235/var(--tw-border-opacity,1))}.border-gray-300{--tw-border-opacity:1;border-color:rgb(209 213 219/var(--tw-border-opacity,1))}.border-gray-400{--tw-border-opacity:1;border-color:rgb(156 163 175/var(--tw-border-opacity,1))}.border-gray-50{--tw-border-opacity:1;border-color:rgb(249 250 251/var(--tw-border-opacity,1))}.border-gray-700{--tw-border-opacity:1;border-color:rgb(55 65 81/var(--tw-border-opacity,1))}.border-green-200{--tw-border-opacity:1;border-color:rgb(187 247 208/var(--tw-border-opacity,1))}.border-green-500{--tw-border-opacity:1;border-color:rgb(34 197 94/var(--tw-border-opacity,1))}.border-red-100{--tw-border-opacity:1;border-color:rgb(254 226 226/var(--tw-border-opacity,1))}.border-red-200{--tw-border-opacity:1;border-color:rgb(254 202 202/var(--tw-border-opacity,1))}.border-red-300{--tw-border-opacity:1;border-color:rgb(252 165 165/var(--tw-border-opacity,1))}.border-red-500{--tw-border-opacity:1;border-color:rgb(239 68 68/var(--tw-border-opacity,1))}.border-red-600{--tw-border-opacity:1;border-color:rgb(220 38 38/var(--tw-border-opacity,1))}.border-transparent{border-color:transparent}.border-yellow-300{--tw-border-opacity:1;border-color:rgb(253 224 71/var(--tw-border-opacity,1))}.border-yellow-400{--tw-border-opacity:1;border-color:rgb(250 204 21/var(--tw-border-opacity,1))}.border-yellow-500{--tw-border-opacity:1;border-color:rgb(234 179 8/var(--tw-border-opacity,1))}.border-l-amber-400{--tw-border-opacity:1;border-left-color:rgb(251 191 36/var(--tw-border-opacity,1))}.border-l-blue-300{--tw-border-opacity:1;border-left-color:rgb(147 197 253/var(--tw-border-opacity,1))}.border-l-red-500{--tw-border-opacity:1;border-left-color:rgb(239 68 68/var(--tw-border-opacity,1))}.border-t-gray-900{--tw-border-opacity:1;border-top-color:rgb(17 24 39/var(--tw-border-opacity,1))}.bg-amber-100{--tw-bg-opacity:1;background-color:rgb(254 243 199/var(--tw-bg-opacity,1))}.bg-amber-200{--tw-bg-opacity:1;background-color:rgb(253 230 138/var(--tw-bg-opacity,1))}.bg-amber-50{--tw-bg-opacity:1;background-color:rgb(255 251 235/var(--tw-bg-opacity,1))}.bg-amber-50\/40{background-color:rgba(255,251,235,.4)}.bg-amber-600{--tw-bg-opacity:1;background-color:rgb(217 119 6/var(--tw-bg-opacity,1))}.bg-black{--tw-bg-opacity:1;background-color:rgb(0 0 0/var(--tw-bg-opacity,1))}.bg-black\/50{background-color:rgba(0,0,0,.5)}.bg-blue-100{--tw-bg-opacity:1;background-color:rgb(219 234 254/var(--tw-bg-opacity,1))}.bg-blue-50{--tw-bg-opacity:1;background-color:rgb(239 246 255/var(--tw-bg-opacity,1))}.bg-blue-600{--tw-bg-opacity:1;background-color:rgb(37 99 235/var(--tw-bg-opacity,1))}.bg-cyan-600{--tw-bg-opacity:1;background-color:rgb(8 145 178/var(--tw-bg-opacity,1))}.bg-emerald-100{--tw-bg-opacity:1;background-color:rgb(209 250 229/var(--tw-bg-opacity,1))}.bg-emerald-50{--tw-bg-opacity:1;background-color:rgb(236 253 245/var(--tw-bg-opacity,1))}.bg-emerald-600{--tw-bg-opacity:1;background-color:rgb(5 150 105/var(--tw-bg-opacity,1))}.bg-gray-100{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity,1))}.bg-gray-200{--tw-bg-opacity:1;background-color:rgb(229 231 235/var(--tw-bg-opacity,1))}.bg-gray-300{--tw-bg-opacity:1;background-color:rgb(209 213 219/var(--tw-bg-opacity,1))}.bg-gray-400{--tw-bg-opacity:1;background-color:rgb(156 163 175/var(--tw-bg-opacity,1))}.bg-gray-50{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity,1))}.bg-gray-500{--tw-bg-opacity:1;background-color:rgb(107 114 128/var(--tw-bg-opacity,1))}.bg-gray-600{--tw-bg-opacity:1;background-color:rgb(75 85 99/var(--tw-bg-opacity,1))}.bg-gray-800{--tw-bg-opacity:1;background-color:rgb(31 41 55/var(--tw-bg-opacity,1))}.bg-gray-900{--tw-bg-opacity:1;background-color:rgb(17 24 39/var(--tw-bg-opacity,1))}.bg-green-100{--tw-bg-opacity:1;background-color:rgb(220 252 231/var(--tw-bg-opacity,1))}.bg-green-50{--tw-bg-opacity:1;background-color:rgb(240 253 244/var(--tw-bg-opacity,1))}.bg-green-50\/40{background-color:rgba(240,253,244,.4)}.bg-green-600{--tw-bg-opacity:1;background-color:rgb(22 163 74/var(--tw-bg-opacity,1))}.bg-indigo-50{--tw-bg-opacity:1;background-color:rgb(238 242 255/var(--tw-bg-opacity,1))}.bg-indigo-600{--tw-bg-opacity:1;background-color:rgb(79 70 229/var(--tw-bg-opacity,1))}.bg-orange-50{--tw-bg-opacity:1;background-color:rgb(255 247 237/var(--tw-bg-opacity,1))}.bg-purple-100{--tw-bg-opacity:1;background-color:rgb(243 232 255/var(--tw-bg-opacity,1))}.bg-purple-50{--tw-bg-opacity:1;background-color:rgb(250 245 255/var(--tw-bg-opacity,1))}.bg-purple-600{--tw-bg-opacity:1;background-color:rgb(147 51 234/var(--tw-bg-opacity,1))}.bg-red-100{--tw-bg-opacity:1;background-color:rgb(254 226 226/var(--tw-bg-opacity,1))}.bg-red-200{--tw-bg-opacity:1;background-color:rgb(254 202 202/var(--tw-bg-opacity,1))}.bg-red-50{--tw-bg-opacity:1;background-color:rgb(254 242 242/var(--tw-bg-opacity,1))}.bg-red-50\/50{background-color:hsla(0,86%,97%,.5)}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.bg-red-600{--tw-bg-opacity:1;background-color:rgb(220 38 38/var(--tw-bg-opacity,1))}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.bg-white\/60{background-color:hsla(0,0%,100%,.6)}.bg-yellow-100{--tw-bg-opacity:1;background-color:rgb(254 249 195/var(--tw-bg-opacity,1))}.bg-yellow-50{--tw-bg-opacity:1;background-color:rgb(254 252 232/var(--tw-bg-opacity,1))}.bg-yellow-600{--tw-bg-opacity:1;background-color:rgb(202 138 4/var(--tw-bg-opacity,1))}.bg-opacity-40{--tw-bg-opacity:0.4}.bg-opacity-50{--tw-bg-opacity:0.5}.bg-opacity-75{--tw-bg-opacity:0.75}.bg-gradient-to-r{background-image:linear-gradient(to right,var(--tw-gradient-stops))}.from-blue-50{--tw-gradient-from:#eff6ff var(--tw-gradient-from-position);--tw-gradient-to:rgba(239,246,255,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-green-400{--tw-gradient-from:#4ade80 var(--tw-gradient-from-position);--tw-gradient-to:rgba(74,222,128,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-slate-50{--tw-gradient-from:#f8fafc var(--tw-gradient-from-position);--tw-gradient-to:rgba(248,250,252,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.to-gray-50{--tw-gradient-to:#f9fafb var(--tw-gradient-to-position)}.to-green-600{--tw-gradient-to:#16a34a var(--tw-gradient-to-position)}.to-indigo-50{--tw-gradient-to:#eef2ff var(--tw-gradient-to-position)}.p-0\.5{padding:.125rem}.p-1{padding:.25rem}.p-10{padding:2.5rem}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.px-8{padding-left:2rem;padding-right:2rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-12{padding-top:3rem;padding-bottom:3rem}.py-16{padding-top:4rem;padding-bottom:4rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-2\.5{padding-top:.625rem;padding-bottom:.625rem}.py-20{padding-top:5rem;padding-bottom:5rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-5{padding-top:1.25rem;padding-bottom:1.25rem}.py-6{padding-top:1.5rem;padding-bottom:1.5rem}.py-8{padding-top:2rem;padding-bottom:2rem}.pb-1\.5{padding-bottom:.375rem}.pb-4{padding-bottom:1rem}.pl-10{padding-left:2.5rem}.pl-3{padding-left:.75rem}.pl-8{padding-left:2rem}.pr-3{padding-right:.75rem}.pt-1{padding-top:.25rem}.pt-2{padding-top:.5rem}.pt-4{padding-top:1rem}.pt-6{padding-top:1.5rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.align-middle{vertical-align:middle}.font-mono{font-family:JetBrains Mono,ui-monospace,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-5xl{font-size:3rem;line-height:1}.text-6xl{font-size:3.75rem;line-height:1}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-\[8px\]{font-size:8px}.text-\[9px\]{font-size:9px}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-extrabold{font-weight:800}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.italic{font-style:italic}.leading-tight{line-height:1.25}.tracking-tight{letter-spacing:-.025em}.tracking-wide{letter-spacing:.025em}.tracking-wider{letter-spacing:.05em}.text-amber-400{--tw-text-opacity:1;color:rgb(251 191 36/var(--tw-text-opacity,1))}.text-amber-500{--tw-text-opacity:1;color:rgb(245 158 11/var(--tw-text-opacity,1))}.text-amber-600{--tw-text-opacity:1;color:rgb(217 119 6/var(--tw-text-opacity,1))}.text-amber-700{--tw-text-opacity:1;color:rgb(180 83 9/var(--tw-text-opacity,1))}.text-amber-800{--tw-text-opacity:1;color:rgb(146 64 14/var(--tw-text-opacity,1))}.text-amber-900{--tw-text-opacity:1;color:rgb(120 53 15/var(--tw-text-opacity,1))}.text-blue-500{--tw-text-opacity:1;color:rgb(59 130 246/var(--tw-text-opacity,1))}.text-blue-600{--tw-text-opacity:1;color:rgb(37 99 235/var(--tw-text-opacity,1))}.text-blue-700{--tw-text-opacity:1;color:rgb(29 78 216/var(--tw-text-opacity,1))}.text-blue-800{--tw-text-opacity:1;color:rgb(30 64 175/var(--tw-text-opacity,1))}.text-blue-900{--tw-text-opacity:1;color:rgb(30 58 138/var(--tw-text-opacity,1))}.text-emerald-600{--tw-text-opacity:1;color:rgb(5 150 105/var(--tw-text-opacity,1))}.text-emerald-700{--tw-text-opacity:1;color:rgb(4 120 87/var(--tw-text-opacity,1))}.text-emerald-800{--tw-text-opacity:1;color:rgb(6 95 70/var(--tw-text-opacity,1))}.text-emerald-900{--tw-text-opacity:1;color:rgb(6 78 59/var(--tw-text-opacity,1))}.text-gray-300{--tw-text-opacity:1;color:rgb(209 213 219/var(--tw-text-opacity,1))}.text-gray-400{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity,1))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity,1))}.text-gray-600{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity,1))}.text-gray-700{--tw-text-opacity:1;color:rgb(55 65 81/var(--tw-text-opacity,1))}.text-gray-800{--tw-text-opacity:1;color:rgb(31 41 55/var(--tw-text-opacity,1))}.text-gray-900{--tw-text-opacity:1;color:rgb(17 24 39/var(--tw-text-opacity,1))}.text-green-400{--tw-text-opacity:1;color:rgb(74 222 128/var(--tw-text-opacity,1))}.text-green-500{--tw-text-opacity:1;color:rgb(34 197 94/var(--tw-text-opacity,1))}.text-green-600{--tw-text-opacity:1;color:rgb(22 163 74/var(--tw-text-opacity,1))}.text-green-700{--tw-text-opacity:1;color:rgb(21 128 61/var(--tw-text-opacity,1))}.text-green-800{--tw-text-opacity:1;color:rgb(22 101 52/var(--tw-text-opacity,1))}.text-indigo-600{--tw-text-opacity:1;color:rgb(79 70 229/var(--tw-text-opacity,1))}.text-orange-600{--tw-text-opacity:1;color:rgb(234 88 12/var(--tw-text-opacity,1))}.text-orange-800{--tw-text-opacity:1;color:rgb(154 52 18/var(--tw-text-opacity,1))}.text-purple-500{--tw-text-opacity:1;color:rgb(168 85 247/var(--tw-text-opacity,1))}.text-purple-600{--tw-text-opacity:1;color:rgb(147 51 234/var(--tw-text-opacity,1))}.text-red-400{--tw-text-opacity:1;color:rgb(248 113 113/var(--tw-text-opacity,1))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.text-red-600{--tw-text-opacity:1;color:rgb(220 38 38/var(--tw-text-opacity,1))}.text-red-700{--tw-text-opacity:1;color:rgb(185 28 28/var(--tw-text-opacity,1))}.text-red-800{--tw-text-opacity:1;color:rgb(153 27 27/var(--tw-text-opacity,1))}.text-red-900{--tw-text-opacity:1;color:rgb(127 29 29/var(--tw-text-opacity,1))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.text-yellow-400{--tw-text-opacity:1;color:rgb(250 204 21/var(--tw-text-opacity,1))}.text-yellow-500{--tw-text-opacity:1;color:rgb(234 179 8/var(--tw-text-opacity,1))}.text-yellow-600{--tw-text-opacity:1;color:rgb(202 138 4/var(--tw-text-opacity,1))}.text-yellow-700{--tw-text-opacity:1;color:rgb(161 98 7/var(--tw-text-opacity,1))}.text-yellow-800{--tw-text-opacity:1;color:rgb(133 77 14/var(--tw-text-opacity,1))}.text-yellow-900{--tw-text-opacity:1;color:rgb(113 63 18/var(--tw-text-opacity,1))}.underline{text-decoration-line:underline}.no-underline{text-decoration-line:none}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.opacity-0{opacity:0}.opacity-100{opacity:1}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-75{opacity:.75}.shadow{--tw-shadow:0 1px 3px 0 rgba(0,0,0,.1),0 1px 2px -1px rgba(0,0,0,.1);--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color),0 1px 2px -1px var(--tw-shadow-color)}.shadow,.shadow-2xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-2xl{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color)}.shadow-lg{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.shadow-lg,.shadow-md{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px rgba(0,0,0,.1),0 2px 4px -2px rgba(0,0,0,.1);--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color),0 2px 4px -2px var(--tw-shadow-color)}.shadow-sm{--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color)}.shadow-sm,.shadow-xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color)}.ring{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.ring,.ring-1{box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.ring-1{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.ring-2{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.ring-blue-200{--tw-ring-opacity:1;--tw-ring-color:rgb(191 219 254/var(--tw-ring-opacity,1))}.ring-blue-300{--tw-ring-opacity:1;--tw-ring-color:rgb(147 197 253/var(--tw-ring-opacity,1))}.ring-red-200{--tw-ring-opacity:1;--tw-ring-color:rgb(254 202 202/var(--tw-ring-opacity,1))}.ring-red-400{--tw-ring-opacity:1;--tw-ring-color:rgb(248 113 113/var(--tw-ring-opacity,1))}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-opacity{transition-property:opacity;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-shadow{transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-200{transition-duration:.2s}.duration-300{transition-duration:.3s}.ease-out{transition-timing-function:cubic-bezier(0,0,.2,1)}.after\:absolute:after{content:var(--tw-content);position:absolute}.after\:left-\[2px\]:after{content:var(--tw-content);left:2px}.after\:top-\[2px\]:after{content:var(--tw-content);top:2px}.after\:h-5:after{content:var(--tw-content);height:1.25rem}.after\:w-5:after{content:var(--tw-content);width:1.25rem}.after\:rounded-full:after{content:var(--tw-content);border-radius:9999px}.after\:border:after{content:var(--tw-content);border-width:1px}.after\:border-gray-300:after{content:var(--tw-content);--tw-border-opacity:1;border-color:rgb(209 213 219/var(--tw-border-opacity,1))}.after\:bg-white:after{content:var(--tw-content);--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.after\:transition-all:after{content:var(--tw-content);transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.after\:content-\[\'\'\]:after{--tw-content:"";content:var(--tw-content)}.hover\:border-gray-300:hover{--tw-border-opacity:1;border-color:rgb(209 213 219/var(--tw-border-opacity,1))}.hover\:bg-amber-200:hover{--tw-bg-opacity:1;background-color:rgb(253 230 138/var(--tw-bg-opacity,1))}.hover\:bg-amber-700:hover{--tw-bg-opacity:1;background-color:rgb(180 83 9/var(--tw-bg-opacity,1))}.hover\:bg-blue-100:hover{--tw-bg-opacity:1;background-color:rgb(219 234 254/var(--tw-bg-opacity,1))}.hover\:bg-blue-200:hover{--tw-bg-opacity:1;background-color:rgb(191 219 254/var(--tw-bg-opacity,1))}.hover\:bg-blue-50:hover{--tw-bg-opacity:1;background-color:rgb(239 246 255/var(--tw-bg-opacity,1))}.hover\:bg-blue-700:hover{--tw-bg-opacity:1;background-color:rgb(29 78 216/var(--tw-bg-opacity,1))}.hover\:bg-cyan-700:hover{--tw-bg-opacity:1;background-color:rgb(14 116 144/var(--tw-bg-opacity,1))}.hover\:bg-emerald-50:hover{--tw-bg-opacity:1;background-color:rgb(236 253 245/var(--tw-bg-opacity,1))}.hover\:bg-emerald-700:hover{--tw-bg-opacity:1;background-color:rgb(4 120 87/var(--tw-bg-opacity,1))}.hover\:bg-gray-100:hover{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity,1))}.hover\:bg-gray-200:hover{--tw-bg-opacity:1;background-color:rgb(229 231 235/var(--tw-bg-opacity,1))}.hover\:bg-gray-300:hover{--tw-bg-opacity:1;background-color:rgb(209 213 219/var(--tw-bg-opacity,1))}.hover\:bg-gray-50:hover{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity,1))}.hover\:bg-gray-500:hover{--tw-bg-opacity:1;background-color:rgb(107 114 128/var(--tw-bg-opacity,1))}.hover\:bg-gray-600:hover{--tw-bg-opacity:1;background-color:rgb(75 85 99/var(--tw-bg-opacity,1))}.hover\:bg-gray-700:hover{--tw-bg-opacity:1;background-color:rgb(55 65 81/var(--tw-bg-opacity,1))}.hover\:bg-gray-800:hover{--tw-bg-opacity:1;background-color:rgb(31 41 55/var(--tw-bg-opacity,1))}.hover\:bg-green-700:hover{--tw-bg-opacity:1;background-color:rgb(21 128 61/var(--tw-bg-opacity,1))}.hover\:bg-indigo-700:hover{--tw-bg-opacity:1;background-color:rgb(67 56 202/var(--tw-bg-opacity,1))}.hover\:bg-purple-700:hover{--tw-bg-opacity:1;background-color:rgb(126 34 206/var(--tw-bg-opacity,1))}.hover\:bg-red-100:hover{--tw-bg-opacity:1;background-color:rgb(254 226 226/var(--tw-bg-opacity,1))}.hover\:bg-red-200:hover{--tw-bg-opacity:1;background-color:rgb(254 202 202/var(--tw-bg-opacity,1))}.hover\:bg-red-50:hover{--tw-bg-opacity:1;background-color:rgb(254 242 242/var(--tw-bg-opacity,1))}.hover\:bg-red-600:hover{--tw-bg-opacity:1;background-color:rgb(220 38 38/var(--tw-bg-opacity,1))}.hover\:bg-red-700:hover{--tw-bg-opacity:1;background-color:rgb(185 28 28/var(--tw-bg-opacity,1))}.hover\:bg-yellow-700:hover{--tw-bg-opacity:1;background-color:rgb(161 98 7/var(--tw-bg-opacity,1))}.hover\:text-blue-800:hover{--tw-text-opacity:1;color:rgb(30 64 175/var(--tw-text-opacity,1))}.hover\:text-blue-900:hover{--tw-text-opacity:1;color:rgb(30 58 138/var(--tw-text-opacity,1))}.hover\:text-gray-600:hover{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity,1))}.hover\:text-gray-700:hover{--tw-text-opacity:1;color:rgb(55 65 81/var(--tw-text-opacity,1))}.hover\:text-gray-900:hover{--tw-text-opacity:1;color:rgb(17 24 39/var(--tw-text-opacity,1))}.hover\:text-red-600:hover{--tw-text-opacity:1;color:rgb(220 38 38/var(--tw-text-opacity,1))}.hover\:text-red-900:hover{--tw-text-opacity:1;color:rgb(127 29 29/var(--tw-text-opacity,1))}.hover\:underline:hover{text-decoration-line:underline}.hover\:shadow-md:hover{--tw-shadow:0 4px 6px -1px rgba(0,0,0,.1),0 2px 4px -2px rgba(0,0,0,.1);--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color),0 2px 4px -2px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.focus\:border-blue-500:focus{--tw-border-opacity:1;border-color:rgb(59 130 246/var(--tw-border-opacity,1))}.focus\:border-transparent:focus{border-color:transparent}.focus\:ring-1:focus{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.focus\:ring-1:focus,.focus\:ring-2:focus{box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.focus\:ring-2:focus{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.focus\:ring-blue-500:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(59 130 246/var(--tw-ring-opacity,1))}.focus\:ring-gray-900:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(17 24 39/var(--tw-ring-opacity,1))}.focus\:ring-green-500:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(34 197 94/var(--tw-ring-opacity,1))}.disabled\:text-gray-400:disabled{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity,1))}.disabled\:opacity-40:disabled{opacity:.4}.disabled\:opacity-50:disabled{opacity:.5}.group:hover .group-hover\:block{display:block}.group:hover .group-hover\:opacity-100{opacity:1}.peer:checked~.peer-checked\:bg-green-600{--tw-bg-opacity:1;background-color:rgb(22 163 74/var(--tw-bg-opacity,1))}.peer:checked~.peer-checked\:after\:translate-x-full:after{content:var(--tw-content);--tw-translate-x:100%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.peer:checked~.peer-checked\:after\:border-white:after{content:var(--tw-content);--tw-border-opacity:1;border-color:rgb(255 255 255/var(--tw-border-opacity,1))}.peer:focus~.peer-focus\:ring-2{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.peer:focus~.peer-focus\:ring-blue-500{--tw-ring-opacity:1;--tw-ring-color:rgb(59 130 246/var(--tw-ring-opacity,1))}@media (min-width:640px){.sm\:inline{display:inline}.sm\:p-8{padding:2rem}.sm\:px-6{padding-left:1.5rem;padding-right:1.5rem}}@media (min-width:768px){.md\:col-span-2{grid-column:span 2/span 2}.md\:col-span-4{grid-column:span 4/span 4}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}@media (min-width:1024px){.lg\:col-span-1{grid-column:span 1/span 1}.lg\:col-span-2{grid-column:span 2/span 2}.lg\:ml-8{margin-left:2rem}.lg\:flex{display:flex}.lg\:hidden{display:none}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:space-x-5>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(1.25rem*var(--tw-space-x-reverse));margin-left:calc(1.25rem*(1 - var(--tw-space-x-reverse)))}.lg\:px-8{padding-left:2rem;padding-right:2rem}}@media (min-width:1280px){.xl\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.xl\:grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}} \ No newline at end of file diff --git a/web-ui/backend/templates/admin.html b/web-ui/backend/templates/admin.html index 64c7b03..ace9824 100644 --- a/web-ui/backend/templates/admin.html +++ b/web-ui/backend/templates/admin.html @@ -77,10 +77,10 @@

Diagnostics

- + diff --git a/web-ui/backend/templates/agents.html b/web-ui/backend/templates/agents.html index fe6cc11..913d214 100644 --- a/web-ui/backend/templates/agents.html +++ b/web-ui/backend/templates/agents.html @@ -1,14 +1,14 @@ {{template "base" .}} -{{define "title"}}Monitoring boxes{{end}} +{{define "title"}}Agents & alerters{{end}} {{define "content"}}
@@ -48,11 +48,13 @@

Monitoring boxes

- Site is ready + + is ready

-

- Do these two things on the box, then start sysmond. -

+

-
+
- Not a sysmond? The same token and CA serve an - alert-only peer: skip the - sysmon.conf lines and greet with - ALERTER <name> <token> - instead - see docs/ALERTERS.md. + Alert-only peers get their own credential type - mint with + External alerter selected + and this panel shows the + ALERTER greeting instead. + See docs/ALERTERS.md.
@@ -122,7 +126,7 @@

:class="noticeBad ? 'bg-red-50 border border-red-200 text-red-900' : 'bg-emerald-50 border border-emerald-200 text-emerald-900'" x-text="notice">

- +
@@ -139,7 +143,22 @@

- For people. Write down which machine this is - nothing else records it. + For people. Which machine this is - and for an alerter, the + name its alerts display. +

+

+
+ +
+ + +
+

+ Decided here, not by whoever connects first.

- A token proves a box to this server; the certificate proves this server - to the box. Both are needed. Only an administrator sees this page. + A token proves a box or alerter to this server; the certificate proves + this server to it. Both are needed. Only an administrator sees this page. The same jobs from a terminal: sysmon-web -mint-agent <site>, -list-agents, @@ -212,7 +234,7 @@

return { agents: [], showAdd: false, - newAgent: { site: '', label: '' }, + newAgent: { site: '', label: '', kind: 'sysmond' }, fresh: {}, copied: false, copiedOnce: false, @@ -258,6 +280,14 @@

// Alpine evaluates this while the modal is hidden and fresh // is {}, so an empty answer has to come first. if (!this.fresh.token) return ''; + if (this.fresh.kind === 'alerter') { + // The greeting line, plus where to send it. The server + // supplies both; the fallback builds them the same way. + return (this.fresh.greeting || + 'ALERTER ' + this.fresh.site + ' ' + this.fresh.token + ' [application name...]') + + '\n\n# TLS to ' + (this.fresh.dial || (window.location.hostname + ':1347')) + + ', verified against aggregator-ca.pem'; + } if (this.fresh.config) return this.fresh.config; const label = (this.fresh.label || this.fresh.site || '') .replace(/"/g, "'").replace(/;/g, ',').replace(/[\x00-\x1f\x7f]/g, ' '); @@ -308,14 +338,16 @@

async mint(replace) { const site = (this.newAgent.site || '').trim(); const label = (this.newAgent.label || '').trim(); + const kind = this.newAgent.kind === 'alerter' ? 'alerter' : 'sysmond'; try { const d = await this.post('/api/settings/agents', - {site: site, label: label, replace: !!replace}); + {site: site, label: label, kind: kind, replace: !!replace}); this.fresh = {site: d.site, token: d.token, label: label, + kind: d.kind, greeting: d.greeting, dial: d.dial, config: d.config}; this.copiedOnce = false; this.showAdd = false; - this.newAgent = {site: '', label: ''}; + this.newAgent = {site: '', label: '', kind: 'sysmond'}; this.say(''); } catch (e) { this.say(e.message, true); } }, @@ -325,18 +357,25 @@

// and when it last reported. async remint(a) { const when = this.seen(a); + const what = a.kind === 'alerter' + ? 'The alerter using the current one is disconnected and cannot ' + + 'send alerts until you give it the new token.' + : 'The box using the current one stops reporting until you put ' + + 'the new token on it. It keeps monitoring and paging.'; if (!await uiConfirm('Mint a new token for ' + a.site + '?\n\n' + - 'The box using the current one stops reporting until you put ' + - 'the new token on it. It keeps monitoring and paging.\n\n' + - 'Last seen: ' + when)) return; - this.newAgent = {site: a.site, label: a.label || ''}; + what + '\n\nLast seen: ' + when)) return; + // A replacement keeps the credential's recorded type. + this.newAgent = {site: a.site, label: a.label || '', + kind: a.kind === 'alerter' ? 'alerter' : 'sysmond'}; await this.mint(true); }, async revoke(a) { - if (!await uiConfirm('Revoke the token for ' + a.site + '?\n\n' + - 'That box stops reporting here at once. It keeps monitoring ' + - 'and paging.')) return; + const what = a.kind === 'alerter' + ? 'That alerter is disconnected at once and cannot send alerts here.' + : 'That box stops reporting here at once. It keeps monitoring and paging.'; + if (!await uiConfirm('Revoke the token for ' + a.site + '?\n\n' + what, + {danger: true, okText: 'Revoke'})) return; try { await this.post('/api/settings/agents/revoke/' + encodeURIComponent(a.site)); this.say(a.site + ': token revoked'); diff --git a/web-ui/backend/templates/base.html b/web-ui/backend/templates/base.html index 9e32235..3e1e1cd 100644 --- a/web-ui/backend/templates/base.html +++ b/web-ui/backend/templates/base.html @@ -31,7 +31,7 @@
sysmon
-