Compare commits
51
Commits
v0.3.0
..
53c274dbe8
No files matched your search
+10
-1
@@ -1 +1,10 @@
|
||||
SQLX_OFFLINE=true
|
||||
# Live mode (local dev): query! macros validate every query against the real
|
||||
# schema on each build, so the .sqlx cache is only needed for DB-less builds.
|
||||
# Run scripts/prepare_sqlx_sqlite.sh once first — it creates and migrates the DB.
|
||||
# Use an absolute path so builds work from any working directory.
|
||||
# Replace the placeholder below with the DATABASE_URL printed by the script.
|
||||
DATABASE_URL=sqlite:///absolute/path/to/wakey-prepare.sqlite3
|
||||
|
||||
# Offline mode (CI / packaging): force the committed .sqlx cache.
|
||||
# Takes precedence over DATABASE_URL when set.
|
||||
#SQLX_OFFLINE=true
|
||||
@@ -18,6 +18,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Ensure toolchain and targets
|
||||
shell: pwsh
|
||||
|
||||
@@ -11,6 +11,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
|
||||
@@ -16,6 +16,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
|
||||
@@ -20,3 +20,6 @@ target/
|
||||
.env
|
||||
|
||||
.codex
|
||||
|
||||
# sqlx prepare DB (created by scripts/prepare_sqlx_sqlite.sh)
|
||||
wakey-prepare.sqlite3*
|
||||
@@ -0,0 +1,3 @@
|
||||
[submodule "vendor/vt100"]
|
||||
path = vendor/vt100
|
||||
url = https://github.com/ldlda/vt100-rust-1
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "INSERT INTO audit_events\n (event_key, event_id, ts_unix, actor_type, actor_id, agent_id, request_id,\n event_type, outcome, latency_ms, message, metadata_json)\n VALUES (?1, ?2, ?3, 'test', NULL, NULL, NULL, 'test', 'ok', NULL, 'old', '{}')",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 3
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "6ce45cc94cbfdd948aad5fb986f9b5976c79bb63759bc9ecff1a7873e2d70137"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT COUNT(*) FROM alert_transitions WHERE transition_id = ?1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "COUNT(*)",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "b324587b94ee2e98dc8eff5f6735303a2784b74f49068ce8e946cedcd8d12c4e"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT COUNT(*) FROM audit_events WHERE message = ?1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "COUNT(*)",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "bd1666cea8840df04ddcdc8a0c42b9b7771f9bf63c45613c4b6f8b86be479d70"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "DELETE FROM audit_events WHERE ts_unix < ?1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "ca612fee8c80f815b33dbc088cc7e0a7552f1f4e38390959e14ea5cc765bf2c7"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "DELETE FROM alert_transitions WHERE ts_unix < ?1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "ce850a32c8702595bbc3207bf86d53209c48be145562192b3a8f84903d39eac1"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT COUNT(*) FROM audit_events WHERE event_id = ?1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "COUNT(*)",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "dc7056b7421dcc83648cbd360273ebcce1156ce0f7da2d39f054025ab9b66b6b"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "INSERT INTO alert_transitions\n (transition_key, transition_id, ts_unix, alert_id, kind, agent_id,\n from_status, to_status, message, metadata_json)\n VALUES (?1, ?2, ?3, 'alert-old', 'test', NULL, NULL, 'active', 'old', '{}')",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 3
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "e83a43217a3bfaacd2f5e76df1fd05447558646fb2b47963751a7ab53b3a99b6"
|
||||
}
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
# Wakey
|
||||
|
||||
Wakey models devices discovered on a local network and the control-plane view used to wake and manage them across agents.
|
||||
|
||||
## Language
|
||||
|
||||
**Device**:
|
||||
An operator-facing aggregate that represents one discovered network identity candidate.
|
||||
_Avoid_: Host, machine, route
|
||||
|
||||
**Device Summary**:
|
||||
The derived names, MACs, IPs, and presence shown on a device aggregate.
|
||||
_Avoid_: Source of truth, route data
|
||||
|
||||
**Endpoint**:
|
||||
One observed network contact point for a device, including the agent/source, optional MAC, optional IP, presence, and observation time.
|
||||
_Avoid_: Raw observation, route, IP row
|
||||
|
||||
**Endpoint Source**:
|
||||
The typed origin of an endpoint, such as neighbor data, DHCP lease data, or a hotplug hook.
|
||||
_Avoid_: String kind, source label
|
||||
|
||||
**Observation Fact**:
|
||||
Debug evidence copied from local observation inputs before it is interpreted into endpoints.
|
||||
_Avoid_: Endpoint, identifier, source of truth
|
||||
|
||||
**Presence**:
|
||||
The current reachability interpretation for a device or endpoint.
|
||||
_Avoid_: Endpoint state, status
|
||||
|
||||
**Wake Route**:
|
||||
An endpoint that has enough information and agent availability to attempt waking a device.
|
||||
_Avoid_: Device, endpoint when not wakeable
|
||||
|
||||
**Known Device**:
|
||||
A device identity manually curated by the operator.
|
||||
_Avoid_: Auto-pinned device, saved observation
|
||||
|
||||
**Identifier**:
|
||||
A manually approved MAC or IP value that links an observed device or endpoint to a known device.
|
||||
_Avoid_: Observation key, route id
|
||||
|
||||
**Endpoint Key**:
|
||||
The typed identity of endpoint evidence, based on source and optional MAC/IP.
|
||||
_Avoid_: Stringly typed route id
|
||||
|
||||
**Agent Endpoint Key**:
|
||||
An endpoint key scoped to the agent that observed it.
|
||||
_Avoid_: Concatenated agent/source/MAC/IP string
|
||||
|
||||
**Agent**:
|
||||
A fleet member that observes local network state and sends device snapshots to the control plane.
|
||||
_Avoid_: Router when referring to the software actor
|
||||
|
||||
**Agent Host**:
|
||||
The machine running an agent, including host identity and runtime metadata.
|
||||
_Avoid_: Device when referring to the agent machine
|
||||
|
||||
**Agent Capability**:
|
||||
Something an agent host can observe or do on the network.
|
||||
_Avoid_: Router flag
|
||||
|
||||
**Terminal Capability**:
|
||||
An explicitly enabled agent capability that allows the agent host to create remote PTY sessions.
|
||||
_Avoid_: Command runner, implicit root access
|
||||
|
||||
**Terminal Session**:
|
||||
One operator attachment to one PTY process hosted by an agent.
|
||||
_Avoid_: Command, SSH session, stored shell
|
||||
|
||||
**Terminal Relay**:
|
||||
The control plane's in-memory pairing of one operator terminal socket with one outbound agent terminal socket.
|
||||
_Avoid_: Terminal session storage, terminal emulator
|
||||
|
||||
**Interface Telemetry**:
|
||||
Current operational measurements for a network interface visible to an agent.
|
||||
_Avoid_: Endpoint state, device presence
|
||||
|
||||
## Relationships
|
||||
|
||||
- A **Device** has zero or more **Endpoints**.
|
||||
- A **Device Summary** is derived from a **Device** and its **Endpoints**.
|
||||
- Local source rows are grouped into **Devices** by endpoint-derived **DeviceId**, not by exact **Endpoint Key**.
|
||||
- Within one complete local snapshot, MAC+IP endpoints may absorb IP-only endpoints with the same IP; this merge is not remembered across snapshots unless the operator creates an **Identifier**.
|
||||
- An **Endpoint** belongs to exactly one **Agent**.
|
||||
- An **Endpoint** has one **Endpoint Key**.
|
||||
- An **Agent Endpoint Key** combines an **Agent** with an **Endpoint Key**.
|
||||
- An **Endpoint** may have both MAC and IP, MAC only, or IP only.
|
||||
- An **Endpoint** has exactly one **Endpoint Source**.
|
||||
- An **Endpoint** has one **Presence**.
|
||||
- An **Endpoint** may name the network interface where it was observed.
|
||||
- An **Observation Fact** may explain why an **Endpoint** exists, but does not define identity or wakeability.
|
||||
- A **Wake Route** is derived from exactly one **Endpoint**.
|
||||
- A **Wake Route** may carry the interface from its source **Endpoint**.
|
||||
- A **Known Device** has zero or more **Identifiers**.
|
||||
- An **Identifier** can link observed **Devices** or **Endpoints** to one **Known Device**.
|
||||
- An **Agent** runs on one **Agent Host**.
|
||||
- An **Agent Capability** describes what an **Agent Host** can observe or do.
|
||||
- **Terminal Capability** is an **Agent Capability** and must be explicitly enabled before an agent can host a **Terminal Session**.
|
||||
- A **Terminal Session** owns one PTY and is independent of one-shot agent commands.
|
||||
- A **Terminal Relay** pairs exactly one operator socket with exactly one agent socket for a **Terminal Session**.
|
||||
- **Interface Telemetry** describes an **Agent Host** interface, not a **Device Endpoint**.
|
||||
|
||||
## Example dialogue
|
||||
|
||||
> **Dev:** "This **Device** has three IPs. Which one should the UI wake?"
|
||||
> **Domain expert:** "Do not infer that from the **Device** summary. Use the **Wake Routes** derived from its **Endpoints**."
|
||||
|
||||
## Flagged ambiguities
|
||||
|
||||
- "route" was used for both observed network contact points and wakeable paths — resolved: use **Endpoint** for observed contact points and **Wake Route** only when the endpoint can be used for wake.
|
||||
- "device" was used for both an identity aggregate and each MAC/IP pair — resolved: use **Device** for the aggregate and **Endpoint** for each observed MAC/IP/source/state tuple.
|
||||
- `Device.ips` and `Device.macs` are true summary values, but they are not authoritative for wake, freshness, or per-IP state decisions — resolved: use **Endpoint** for those decisions.
|
||||
- MAC-only and IP-only observations are still **Endpoints** — resolved: **Wake Routes** require a MAC and a connected **Agent**, while IP is optional route detail.
|
||||
- Endpoint-specific states such as "removed" or "stale" are not separate concepts yet — resolved: use **Presence** until a distinction appears that presence cannot express cleanly.
|
||||
- Endpoint origin is not a free string — resolved: use **Endpoint Source** for domain logic and reserve raw strings for debug facts only.
|
||||
- Local observation file rows map naturally to **Observation Facts** — resolved: keep them for debugging, but do not use them as the operational model.
|
||||
- Offline **Endpoints** remain part of the API model — resolved: UI may collapse them from the primary view, but the API should still expose them for details, copy, and debugging.
|
||||
- **Device Summary** IPs come only from concrete live-ish sources such as neighbor table rows and current DHCP lease rows — resolved: hotplug hook memory creates **Endpoints** and **Observation Facts**, but does not directly inflate summary IPs.
|
||||
- Router-ness is not a boolean domain concept — resolved: model **Agent Capabilities** and **Agent Host** metadata instead.
|
||||
- **Presence** ordering should treat **Unknown** as non-voting/no evidence, weaker than **Offline** — resolved: concrete offline evidence should not be overridden by unknown hook or DHCP ambiguity.
|
||||
- **Device** presence is derived from all **Endpoints**, not only summary-eligible endpoints.
|
||||
- Current DHCP leases are summary-eligible **Endpoints** with **Unknown** presence; they may support wake when a MAC is present, but they do not prove reachability.
|
||||
- DHCP hook events, including removal, indicate **Unknown** because they describe lease churn rather than reachability.
|
||||
- Neighbor table and neighbor hook events may indicate **Offline** when they report failed or removed reachability.
|
||||
- **Presence** and wakeability are separate: an **Offline** endpoint may still produce a **Wake Route** if it has a MAC and a connected **Agent**.
|
||||
- **Endpoint** does not store wakeability; wakeability is derived from endpoint data plus **Agent** runtime and capability context.
|
||||
- Preferred **Wake Routes** are ordered by connected agent, wakeability, having an IP, presence quality, recency, then source quality.
|
||||
- **Endpoint** evidence preserves source-specific rows; equivalent **Wake Routes** may ignore source when they target the same agent, MAC, and IP.
|
||||
- The control plane stores **Endpoints** as structured data so fleet search, identity matching, and wake route selection do not depend on parsing opaque device JSON.
|
||||
- Summary MAC/IP collections are derived from **Endpoints**; separate MAC/IP storage should be removed or represented as views rather than maintained as independent truth.
|
||||
- API arrays should be domain-sorted by default; UI may sort top-level fleet rows for presentation, but route ranking and recommended route selection are backend-owned.
|
||||
- The main fleet API embeds **Endpoints** in each **Device** response; a separate endpoint API is unnecessary until payload size or access patterns require it.
|
||||
- The control plane stores current per-agent endpoint snapshots, not endpoint event history.
|
||||
- Agent-local hook memory is retained by local last-seen age policy, not by control-plane acknowledgement; default retention is 7 days unless configured otherwise.
|
||||
- Live-source **Endpoints** use snapshot time as their observed time; hook-derived **Endpoints** use the hook row's recorded last-seen time.
|
||||
- **DeviceId**, **Endpoint Key**, and **Agent Endpoint Key** are typed concepts; string keys are boundary serialization only.
|
||||
- **DeviceId** is derived from a device's **Endpoints**: canonical MAC if any endpoint has a MAC, otherwise canonical IP, otherwise absent.
|
||||
- Hostnames are endpoint evidence/display data, not part of **Endpoint Key** identity.
|
||||
- Interface names are endpoint metadata, not part of **Endpoint Key** identity; multi-segment routers may require scoped endpoint identity later if the same source/MAC/IP has different meaning on different interfaces.
|
||||
- An **Endpoint Key** must contain at least one network address value: MAC, IP, or both. Facts without MAC or IP remain **Observation Facts** only.
|
||||
- **Identifiers** are source-independent ownership claims over MAC or permanent IP values, not claims over endpoint sources.
|
||||
- IP **Identifiers** are explicit operator claims; Wakey does not infer whether an IP is permanent, static, or reserved.
|
||||
- **Terminal Sessions** are not agent commands — resolved: command dispatch remains request/result, while terminals use dedicated streaming sockets.
|
||||
- **Terminal Relay** is ephemeral control-plane state — resolved: terminal bytes and scrollback are not stored or audited.
|
||||
- Terminal input, output, and terminal-generated control sequences are byte streams; only lifecycle and resize operations are structured terminal protocol messages.
|
||||
Generated
+43
-5
@@ -97,6 +97,12 @@ version = "1.0.102"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "arrayvec"
|
||||
version = "0.7.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.89"
|
||||
@@ -1976,6 +1982,16 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pty-process"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "71cec9e2670207c5ebb9e477763c74436af3b9091dd550b9fb3c1bec7f3ea266"
|
||||
dependencies = [
|
||||
"rustix",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.9"
|
||||
@@ -3596,9 +3612,19 @@ version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "vte"
|
||||
version = "0.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wakey"
|
||||
version = "0.3.0"
|
||||
version = "0.3.6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -3619,7 +3645,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wakey-agent"
|
||||
version = "0.3.0"
|
||||
version = "0.3.6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
@@ -3639,11 +3665,12 @@ dependencies = [
|
||||
"url",
|
||||
"wakey",
|
||||
"wakey-core",
|
||||
"wakey-vt100",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wakey-control-plane"
|
||||
version = "0.3.0"
|
||||
version = "0.3.6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -3672,7 +3699,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wakey-core"
|
||||
version = "0.3.0"
|
||||
version = "0.3.6"
|
||||
dependencies = [
|
||||
"macaddr",
|
||||
"serde",
|
||||
@@ -3684,12 +3711,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wakey-linux"
|
||||
version = "0.3.0"
|
||||
version = "0.3.6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"futures",
|
||||
"lda-ipjs",
|
||||
"macaddr",
|
||||
"nix 0.31.2",
|
||||
"pty-process",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serial_test",
|
||||
@@ -3697,6 +3726,15 @@ dependencies = [
|
||||
"wakey-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wakey-vt100"
|
||||
version = "0.16.2-wakey.2"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"unicode-width",
|
||||
"vte",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "walkdir"
|
||||
version = "2.5.0"
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "wakey"
|
||||
version = "0.3.0"
|
||||
version = "0.3.6"
|
||||
edition = "2024"
|
||||
publish = ["gitea"]
|
||||
|
||||
@@ -31,6 +31,7 @@ strip = true
|
||||
|
||||
[workspace]
|
||||
members = ["ipjs", "wakey-agent", "wakey-control-plane", "wakey-core", "wakey-linux"]
|
||||
exclude = ["vendor/vt100"]
|
||||
|
||||
[workspace.dependencies]
|
||||
anyhow = "1"
|
||||
|
||||
@@ -207,10 +207,23 @@ Control-plane admin API includes token management endpoints:
|
||||
- `GET /api/v1/control/audit/events?agent_id=<id>&event_type=<type>&limit=<n>`
|
||||
- `GET /api/v1/control/alerts?lookback_seconds=900`
|
||||
- `GET /api/v1/control/alerts/history?since_unix=<ts>&limit=<n>`
|
||||
- `GET /api/v1/control/alerts/ws` (websocket snapshots + recent transitions)
|
||||
- `GET /api/v1/control/alerts/ws` (websocket snapshots, recent transitions, and evaluator health)
|
||||
- `GET /api/v1/control/terminals/events/ws` (websocket session-list invalidations)
|
||||
- `DELETE /api/v1/control/agents/{agent_id}`
|
||||
- `PATCH /api/v1/control/agents/{agent_id}/nickname`
|
||||
|
||||
Alert evaluation runs once per control plane every five seconds. The alerts GET
|
||||
returns `503 alerts_unavailable` until a successful evaluation, after evaluation
|
||||
failure, or when its snapshot is stale. The existing alerts stream also sends
|
||||
`alerts_health` messages with `available` and an optional `reason`; the UI keeps
|
||||
the last results visible with a stale warning until recovery. Custom alert-rule
|
||||
query overrides are rejected with `400 unsupported_alert_override`.
|
||||
|
||||
Terminal session events carry only `sessions_changed` invalidations. Clients
|
||||
refetch the session list after changes or reconnection and retain a 15-second
|
||||
fallback refresh. Events do not grant session ownership; attachment still uses
|
||||
the existing server-side locking and scoped credentials.
|
||||
|
||||
If commands still appear silent, verify both processes are running with `-v`
|
||||
and that `RUST_LOG` is not overriding to a stricter level.
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Caddy template for wakey-control-plane with Cloudflare Access.
|
||||
#
|
||||
# Security model:
|
||||
# - Public endpoints for agents: /healthz, /api/v1/agents/enroll, /api/v1/agent/ws
|
||||
# - Public endpoints for agents: health, enrollment, control WS, and scoped terminal relay WS
|
||||
# - Private admin surface: /ui/* and /api/v1/control/* (requires CF Access headers)
|
||||
#
|
||||
# Replace cp.example.com with your public control-plane domain.
|
||||
@@ -10,7 +10,7 @@ wakey.ldlda.com {
|
||||
encode zstd gzip
|
||||
|
||||
# Public agent-facing endpoints.
|
||||
@public path /healthz /api/v1/agents/enroll /api/v1/agent/ws
|
||||
@public path /healthz /api/v1/agents/enroll /api/v1/agent/ws /api/v1/agent/terminals/*
|
||||
handle @public {
|
||||
reverse_proxy 127.0.0.1:6767
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
# Core Endpoints and Wake Routes
|
||||
|
||||
Wakey will model observed network contact points as **Endpoints** in `wakey-core`, and derive **Wake Routes** from those endpoints instead of reconstructing routes from flattened device summary MAC/IP arrays. `Device.ips` and `Device.macs` remain true summary fields for operator display and compatibility, but endpoint data is authoritative for wake, freshness, source, per-IP state, and route selection.
|
||||
|
||||
**Status**: accepted
|
||||
|
||||
## Context
|
||||
|
||||
The previous model stored a `Device` aggregate with summary `macs`, `ips`, `presence`, and raw observation facts. The control plane then built fleet wake routes from combinations like first MAC plus first IP. That lost source, presence, interface, and MAC/IP-pair evidence, especially when hook memory contained removed IPs or when several sources described the same address differently.
|
||||
|
||||
## Decision
|
||||
|
||||
Add a typed `DeviceEndpoint` concept in `wakey-core`. A device snapshot sent by an agent contains devices with endpoints already attached. The control plane stores structured endpoint rows and derives fleet summaries and wake routes from those rows.
|
||||
|
||||
Endpoint shape:
|
||||
|
||||
```rust
|
||||
DeviceEndpoint {
|
||||
key: EndpointKey,
|
||||
hostname: Option<String>,
|
||||
interface: Option<String>,
|
||||
presence: Presence,
|
||||
first_seen_unix: Option<u64>,
|
||||
last_seen_unix: Option<u64>,
|
||||
}
|
||||
|
||||
EndpointKey {
|
||||
source: EndpointSource,
|
||||
mac: Option<MacAddr>,
|
||||
ip: Option<IpAddr>,
|
||||
}
|
||||
|
||||
AgentEndpointKey {
|
||||
agent_id: String,
|
||||
endpoint: EndpointKey,
|
||||
}
|
||||
```
|
||||
|
||||
`EndpointKey` must contain at least one of MAC or IP. Hostname and interface are endpoint evidence/metadata, not identity. `AgentEndpointKey` may use `serde(flatten)` for wire shape, but hash values are never durable storage keys. String keys are boundary serialization only.
|
||||
|
||||
## Source and Presence Rules
|
||||
|
||||
Endpoint sources are typed domain variants, not free strings:
|
||||
|
||||
```text
|
||||
Neighbor
|
||||
DhcpLease
|
||||
HookNeighbor
|
||||
HookDhcp
|
||||
```
|
||||
|
||||
Presence answers “is this reachable?”, not “can this be woken?” The presence order is:
|
||||
|
||||
```text
|
||||
Unknown < Offline < LikelyOnline < Online
|
||||
```
|
||||
|
||||
Source mapping:
|
||||
|
||||
```text
|
||||
Neighbor reachable/permanent -> Online
|
||||
Neighbor stale -> LikelyOnline
|
||||
Neighbor failed/remove-like evidence -> Offline
|
||||
Other neighbor states -> Unknown
|
||||
|
||||
Current DHCP lease -> Unknown
|
||||
Hook DHCP add/update/remove -> Unknown
|
||||
Hook neighbor add/update/old -> LikelyOnline
|
||||
Hook neighbor remove -> Offline
|
||||
```
|
||||
|
||||
Current DHCP leases are summary-eligible and may support wake if they include a MAC, but they do not prove reachability. Hook memory creates endpoints and observation facts, but does not directly inflate summary IPs.
|
||||
|
||||
## Summary, Presence, and UI
|
||||
|
||||
`Device.ips` and `Device.macs` stay as summaries. Summary IPs come from concrete live-ish sources such as current neighbor rows and current DHCP lease rows. Hook-derived and offline IPs remain visible through endpoints but are collapsed out of primary UI summaries by default.
|
||||
|
||||
`Device.presence` is derived from all endpoints, not only summary-eligible endpoints. This lets concrete offline evidence matter while allowing stronger live evidence to override it.
|
||||
|
||||
The main fleet API embeds endpoints inside each fleet device. API responses expose all endpoints, including offline and unknown endpoints. The UI may collapse offline/unknown endpoints in the primary row, but details must expose them for copy/debug/history.
|
||||
|
||||
## Wakeability and Wake Route Ordering
|
||||
|
||||
Wakeability is derived, not stored on `DeviceEndpoint`.
|
||||
|
||||
An endpoint is wakeable when:
|
||||
|
||||
```text
|
||||
endpoint has a MAC
|
||||
agent is connected
|
||||
agent has wake capability
|
||||
endpoint source is allowed for wake route derivation
|
||||
```
|
||||
|
||||
For now, a connected wakey-agent is assumed to have wake capability. Presence does not gate wakeability: an `Offline` endpoint may still produce a wake route, because waking powered-off devices is the core use case.
|
||||
|
||||
Preferred wake route order:
|
||||
|
||||
```text
|
||||
connected agent
|
||||
wakeable route
|
||||
has IP
|
||||
presence quality
|
||||
newest last_seen
|
||||
source quality
|
||||
```
|
||||
|
||||
Presence quality for route ranking follows:
|
||||
|
||||
```text
|
||||
Online > LikelyOnline > Offline > Unknown
|
||||
```
|
||||
|
||||
Source quality follows:
|
||||
|
||||
```text
|
||||
Neighbor > DhcpLease > HookNeighbor > HookDhcp
|
||||
```
|
||||
|
||||
Equivalent wake routes may ignore endpoint source when they target the same agent, MAC, and IP. Endpoint evidence preserves source-specific rows; route grouping may deduplicate equivalent targets.
|
||||
|
||||
## Device and Identity Rules
|
||||
|
||||
`DeviceId` remains the typed observed identity enum:
|
||||
|
||||
```rust
|
||||
DeviceId::Mac(MacAddr)
|
||||
DeviceId::Ip(IpAddr)
|
||||
```
|
||||
|
||||
`DeviceId` is derived from endpoints: canonical MAC if any endpoint has a MAC, otherwise canonical IP, otherwise absent. Local source rows are grouped into devices by endpoint-derived `DeviceId`, not exact `EndpointKey`.
|
||||
|
||||
Within a single complete local snapshot, a MAC+IP endpoint may absorb an IP-only endpoint with the same IP. This merge is not remembered across snapshots unless the operator creates a known-device identifier.
|
||||
|
||||
Known-device identifiers are source-independent ownership claims over MAC or permanent IP values. Wakey does not infer whether an IP is permanent, static, or reserved; the UI should warn the operator when attaching an IP identifier.
|
||||
|
||||
## Control Plane Storage
|
||||
|
||||
The control plane stores current per-agent endpoint snapshots as structured columns, not opaque `Device` JSON and not endpoint event history. Summary MAC/IP collections are derived from endpoints; separate MAC/IP storage should be removed or represented as views rather than maintained as independent truth.
|
||||
|
||||
Agent-local hook memory is retained by local last-seen age policy, not by control-plane acknowledgement. The default retention is 7 days unless configured otherwise. Live-source endpoints use snapshot time as their observed time; hook-derived endpoints use the hook row's recorded last-seen time.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Wake route construction moves away from `macs.first()` / `ips.first()` guessing.
|
||||
- The agent/core layer owns source interpretation because it still has local context.
|
||||
- The control plane can search, filter, match known identifiers, and rank wake routes from structured endpoint rows.
|
||||
- UI can show clean summaries while keeping complete endpoint evidence in details.
|
||||
- This preserves “wake an offline machine by MAC” without pretending that offline means not wakeable.
|
||||
@@ -0,0 +1,155 @@
|
||||
# Remote Terminal Sessions
|
||||
|
||||
Wakey will provide opt-in remote terminal access by pairing a browser terminal WebSocket with a dedicated outbound agent terminal WebSocket. The existing agent WebSocket remains the control channel and does not carry terminal byte streams.
|
||||
|
||||
**Status**: accepted
|
||||
|
||||
## Context
|
||||
|
||||
The control plane already maintains an authenticated outbound WebSocket from each connected agent. Its command protocol is intentionally request/result shaped: the control plane sends one command, the agent executes it, and one result completes the pending request.
|
||||
|
||||
An interactive terminal has different semantics. It is long-lived, bidirectional, byte-oriented, and must handle window resizing, process exit, disconnect cleanup, and backpressure. Treating it as an ordinary command would either block the agent session loop or turn the main agent WebSocket into a general stream multiplexer. It would also allow terminal output to compete with heartbeats, snapshots, and wake commands.
|
||||
|
||||
Agents commonly run behind NAT and must not expose an inbound listener. Terminal connections therefore remain agent-initiated and outbound.
|
||||
|
||||
## Decision
|
||||
|
||||
Use one dedicated agent WebSocket and one dedicated operator WebSocket for each terminal session. The control plane holds an in-memory terminal relay that pairs those sockets.
|
||||
|
||||
Session establishment follows this sequence:
|
||||
|
||||
1. An authenticated operator requests a terminal for a connected, terminal-capable agent.
|
||||
2. The control plane creates an ephemeral terminal ID plus short-lived, single-use attachment credentials.
|
||||
3. The control plane sends an open-terminal control message over the existing authenticated agent WebSocket.
|
||||
4. The agent opens a new outbound terminal WebSocket to the control plane and authenticates it for that terminal ID.
|
||||
5. The browser opens the corresponding protected operator terminal WebSocket.
|
||||
6. The control plane pairs the sockets and relays terminal frames while an operator is attached.
|
||||
|
||||
The main agent WebSocket carries terminal creation and cancellation control only. It does not carry PTY input or output.
|
||||
|
||||
## PTY Ownership
|
||||
|
||||
The agent owns the PTY, shell process, process group, and all process cleanup. The control plane never spawns or emulates a shell.
|
||||
|
||||
Use `pty-process` with its async feature as the initial PTY abstraction because it provides:
|
||||
|
||||
- Tokio `AsyncRead` and `AsyncWrite` integration;
|
||||
- owned read and write halves for concurrent tasks;
|
||||
- terminal resizing;
|
||||
- child session-leader and controlling-terminal setup; and
|
||||
- a small implementation built on `rustix`.
|
||||
|
||||
The PTY wrapper is cross-compiled for `armv7-unknown-linux-musleabihf` and its input, output, resize, and exit test has passed on the target router. That focused test remains part of the remote compatibility workflow. Wakey does not call `forkpty` or add its own unsafe PTY implementation.
|
||||
|
||||
The executable, working directory, environment, UID, and GID are agent-controlled configuration. The browser cannot supply an arbitrary executable or process environment.
|
||||
|
||||
## Wire Protocol
|
||||
|
||||
WebSocket frame type separates terminal data from terminal control:
|
||||
|
||||
- Binary frames carry raw PTY input and output bytes.
|
||||
- Text JSON frames carry lifecycle, error, and resize messages.
|
||||
|
||||
The terminal control vocabulary is deliberately small:
|
||||
|
||||
```text
|
||||
operator -> agent: resize, close
|
||||
control plane -> agent: snapshot
|
||||
agent -> operator: ready, exited, error
|
||||
```
|
||||
|
||||
Initial terminal dimensions are part of session attachment. Resize carries rows and columns; pixel dimensions may be added later if a demonstrated program requires them.
|
||||
|
||||
Terminal behavior such as Ctrl-C, Ctrl-Z, Ctrl-D, cursor keys, mouse reporting, bracketed paste, colors, and terminal-title changes remains in the byte stream. The control plane does not parse ANSI escape sequences. The browser terminal emulator renders output and owns connected-session scrollback. The agent also feeds PTY output into a `vt100` parser so it can reconstruct the current visible screen and terminal input modes after detachment. Agent PTYs advertise `TERM=xterm-256color` and `COLORTERM=truecolor` because xterm.js is the actual frontend; applications may therefore negotiate xterm mouse reporting without a Wakey-specific mouse protocol.
|
||||
|
||||
WebSockets already provide ordered, reliable delivery, so terminal frames do not carry sequence numbers in the initial protocol.
|
||||
|
||||
## Lifecycle and Cleanup
|
||||
|
||||
Terminal sessions allow only one attached operator. Their lifetime belongs to the agent process, not to a browser route or a particular control-plane connection.
|
||||
|
||||
When the operator socket disconnects, the session becomes detached. Navigation, browser closure, and network loss do not terminate the PTY. A protected operator request may discover the live session and obtain a fresh, single-use attachment credential. The control plane does not retain terminal output.
|
||||
|
||||
The operator UI lists live sessions as tabs and stores only the active terminal ID in browser-tab-scoped `sessionStorage`. On reload it prefers that ID and briefly waits for the previous WebSocket to detach before requesting a fresh attachment credential. The stored ID is a navigation hint, not an ownership credential; another attached operator still locks the session at the control plane.
|
||||
|
||||
The protected `/api/v1/control/terminals/events/ws` stream sends `sessions_changed` invalidations when the volatile session catalog or attachment ownership changes. These events contain no terminal bytes or attachment credentials: browsers refetch the ordinary session list, coalescing bursts and repeating a fetch if another event arrives during it. Subscribing and reconnecting also invalidate the list. The 15-second refresh remains a recovery fallback; the stream is a freshness hint, never an authorization or locking mechanism.
|
||||
|
||||
The React page composes three custom hooks rather than owning every resource itself. `useXterm` owns the single renderer, its addons, fitting, fonts, and display operations. `useTerminalSessions` owns the catalog subscription, coalesced refreshes, and fallback timer. `useTerminalSession` owns active attachment, input, restoration, and session operations. The attachment hook waits for renderer readiness; stable renderer operations prevent ordinary state updates from rebuilding the terminal or reconnecting its transport. Each hook cleans up its own resources on unmount, which detaches the browser without deleting the agent-owned PTY.
|
||||
|
||||
After operator attachment, the control plane sends a `snapshot` request. The agent serializes up to 1,000 retained physical rows followed by its parsed current screen and input modes as terminal escape bytes, sends them before subsequent live output, and then signals the PTY's current foreground process group with `SIGWINCH`. Historical rows retain their terminal attributes and are replayed as ordinary terminal output so xterm builds its native scrollback; the final formatted state restores the exact live screen. The signal lets full-screen applications redraw without assuming that the original shell remains in the foreground. Signal failure is logged but never terminates the session. The connected browser independently retains up to 10,000 rows because that memory belongs to the operator device rather than the agent host.
|
||||
|
||||
Wakey carries a small, read-only extension to `vt100` because its public API exposes only the active grid. A snapshot streams bounded history from the primary grid, redraws that grid with its saved cursor and attributes, and then reconstructs the alternate grid when a full-screen application is active. The parser is never resized or switched while taking a snapshot. Consequently, reconnecting during `btop` restores the application while preserving the hidden shell history and state that reappear when it exits.
|
||||
|
||||
The agent keeps its terminal manager outside the control-WebSocket reconnect loop. If a dedicated terminal relay disconnects, the PTY continues draining output into the agent-owned terminal parser while waiting for replacement relay credentials. On every authenticated control connection, the agent reports its in-memory live terminal IDs and creation times. The control plane reconciles that inventory, adopts sessions missing from its volatile registry, and issues fresh relay credentials. This allows sessions to survive control-plane restart without persisting terminal state in SQLite.
|
||||
|
||||
Closing a session must terminate the entire PTY process group, not only the shell process. Cleanup should send a hangup first, then escalate to termination and forced kill after bounded grace periods. Explicit operator close, absolute session timeout, agent process exit, and machine reboot close the session.
|
||||
|
||||
The agent configuration sets `terminal.session_ttl_seconds` for newly created PTYs. The default is 43,200 seconds (12 hours); explicit zero disables automatic expiry. The agent owns and enforces this deadline locally because PTYs survive browser and control-plane disconnection. It advertises the policy to the control plane and includes the creation-time TTL in terminal inventory, allowing CC to mirror expiry and preserve it across CC restart. Missing fields from older agents retain the 12-hour default. Positive values are exact and values that cannot fit the platform timer are rejected rather than clamped.
|
||||
|
||||
An agent process restart or machine reboot still ends every terminal session because PTY file descriptors cannot be recovered by a new process. Surviving that boundary would require an external session host such as `tmux` or a separate terminal daemon and is not part of this design.
|
||||
|
||||
The agent reports a normal exit status or terminating signal when available. The UI must distinguish an exited shell from a transport failure.
|
||||
|
||||
## Backpressure and Resource Limits
|
||||
|
||||
Terminal transport must not use unbounded queues.
|
||||
|
||||
Every queue between PTY, agent socket, control-plane relay, and browser socket is bounded. The agent keeps draining and parsing the PTY while detached so a noisy child cannot stall the agent. Parsed state is bounded by the configured terminal dimensions and a 1,000-row reconnect scrollback limit. The control plane retains only a bounded queue of operator controls while an agent relay is reconnecting.
|
||||
|
||||
While a relay is attached, saturation applies lossless backpressure instead of dropping terminal bytes or disconnecting the relay. The agent retains the frame that discovered saturation and pauses further PTY reads until bounded relay capacity returns. Input, resize, close, lifecycle, and reconnect handling remain active while output is backpressured. Reconnect snapshots use the same ordered output path and duplicate snapshot requests are coalesced while one is pending. This is required for terminal protocols such as sixel, where dropping one output frame corrupts the rest of the stream.
|
||||
|
||||
When input/control and output are ready simultaneously, relay loops prioritize input and control. This keeps interrupt, resize, close, and snapshot traffic responsive while commands produce sustained output.
|
||||
|
||||
The implementation also enforces:
|
||||
|
||||
- maximum terminal frame size;
|
||||
- per-agent concurrent-session limits;
|
||||
- idle and absolute session timeouts;
|
||||
- bounded parsed terminal state;
|
||||
- bounded control-plane relay buffers; and
|
||||
- cleanup when any relay task exits unexpectedly.
|
||||
|
||||
Exact limits are configuration and may be tuned from router testing. The bounded behavior is an architectural requirement.
|
||||
|
||||
## Security and Audit
|
||||
|
||||
Remote terminal access is root-equivalent on agents that run as root. It is therefore an explicit agent capability and is disabled by default.
|
||||
|
||||
Operator terminal endpoints belong to the protected control API surface. Session identifiers are not credentials. Agent and operator attachment credentials are short-lived, scoped to one terminal session, and single-use. The operator WebSocket must enforce the same-origin/protected-control assumptions used when the session was created.
|
||||
|
||||
Audit records include terminal request, open, ready, disconnect, close, timeout, and exit metadata. Wakey never stores or audits terminal input, terminal output, command history, or scrollback.
|
||||
|
||||
Terminal capability and session state are separate from device inventory, known-device identity, wake routes, and the debug Command Runner.
|
||||
|
||||
## Deferred Work
|
||||
|
||||
Persistence across agent restart or machine reboot is not supported. A later design would need an external process owner, reconnectable local IPC, and explicit secret handling; persisted metadata alone cannot recover a PTY.
|
||||
|
||||
File transfer, multi-operator attachment, terminal recording, shell-history storage, and arbitrary process launch are also deferred.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
### Multiplex terminal bytes over the main agent WebSocket
|
||||
|
||||
Rejected for the initial version. It requires refactoring the current request/result session into a general concurrent writer and stream router, adds head-of-line coupling with fleet traffic, and requires terminal-byte framing within a shared connection.
|
||||
|
||||
### Model a terminal as a long-running agent command
|
||||
|
||||
Rejected. Commands have one result and a timeout; terminals have continuous bidirectional traffic and independent lifecycle.
|
||||
|
||||
### Accept an inbound connection on the agent
|
||||
|
||||
Rejected. Agents run behind NAT and should not expose a new network service.
|
||||
|
||||
### Use `portable-pty`
|
||||
|
||||
Viable, but not selected initially. Its cross-platform abstraction is broader than the Linux agent requires and its reader/writer API would need blocking-task bridges. It remains a fallback if `pty-process` does not build or behave correctly on the target router.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Each active terminal consumes two additional WebSockets and one PTY process on the agent.
|
||||
- Terminal traffic cannot delay main agent heartbeat, snapshot, or command messages.
|
||||
- Control-plane terminal state remains ephemeral; agents rebuild it from their in-memory live-session inventory after a control-plane restart.
|
||||
- The agent session protocol gains terminal-open control and capability advertisement but does not become a terminal-data multiplexer.
|
||||
- Browser and agent terminal transports can be tested independently before adding xterm.js.
|
||||
- Browser navigation and control-plane restart do not terminate an agent-owned PTY.
|
||||
@@ -1,3 +1,4 @@
|
||||
*
|
||||
!.gitignore
|
||||
!copy.ps1
|
||||
!wslfix.sh
|
||||
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# sometimes it breaks, run to fix
|
||||
|
||||
sudo sh -c 'echo :WSLInterop:M::MZ::/init:PF > /usr/lib/binfmt.d/WSLInterop.conf'
|
||||
sudo systemctl restart systemd-binfmt
|
||||
@@ -1,74 +0,0 @@
|
||||
# Report: Device State Rewrite
|
||||
|
||||
## What changed
|
||||
|
||||
The control plane now stores **complete per-agent Device snapshots** instead of flat observation rows. The agent sends `Vec<Device>` (merged from DHCP, neighbors, hooks). The control plane upserts per agent, deletes stale, fleet view merges across agents at read time.
|
||||
|
||||
## Architecture (before → after)
|
||||
|
||||
**Before**: Agent → raw hook observations → control plane flattens into rows → re-merges at fleet read → presence recomputed from flat actions
|
||||
|
||||
**After**: Agent → inventory → `Vec<Device>` with correct presence → control plane stores as-is → fleet merge at read time from typed rows
|
||||
|
||||
## Schema
|
||||
|
||||
Old tables dropped: `agent_device_observations`, `agent_device_observation_events`, `agent_observation_snapshots`, `agent_observation_snapshot_keys`
|
||||
|
||||
New tables:
|
||||
- `agent_devices` — per-agent device row (keyed by `DeviceId` serialization)
|
||||
- `agent_device_macs` — MACs per device, indexed for cross-agent merge
|
||||
- `agent_device_ips` — IPs per device, indexed for cross-agent merge
|
||||
- `agent_device_hostnames` — hostnames per device
|
||||
- `agent_device_facts` — debug/source material (JSON, not identity)
|
||||
|
||||
## Concrete types throughout
|
||||
|
||||
- `FleetDevice.macs`: `Vec<MacAddr>`, `ips`: `Vec<IpAddr>`, `presence`: `Presence`
|
||||
- `FleetWakeRoute.mac`: `Option<MacAddr>`, `ip`: `Option<IpAddr>`
|
||||
- `AgentDeviceWithChildren.macs`: `Vec<MacAddr>`, `ips`: `Vec<IpAddr>`
|
||||
- Row types (`AgentDeviceMacRow`, `AgentDeviceIpRow`) stay `String` from SQLite, convert via `TryFrom` impls
|
||||
- `AgentDeviceRow` has `.presence()`, `.first_seen()`, `.last_seen()` methods — no string matching in business code
|
||||
- `Presence` from wakey-core: `Ord`, `PartialOrd`, `From<&str>`, `.as_str()`
|
||||
|
||||
## Rust features used
|
||||
|
||||
- `TryFrom<&AgentDeviceMacRow> for MacAddr`, `TryFrom<&AgentDeviceIpRow> for IpAddr`
|
||||
- `From<&str> for Presence`
|
||||
- `#[allow(dead_code)]` where appropriate (test-only methods, debug fields)
|
||||
- Workspace dependencies for shared crates (`macaddr`, `serde`, `tokio`, etc.)
|
||||
- Shared `TestStore` with `Drop` guard for clean test teardown
|
||||
|
||||
## What was removed
|
||||
|
||||
- `AgentObservation` struct (replaced by `Device`)
|
||||
- `AgentDeviceObservation`, `AgentDeviceObservationView`, `AgentDeviceObservationEvent`, `AgentDeviceObservationInput` types
|
||||
- `inventory_result_to_observations()` — the flatten-then-rebuild function
|
||||
- `upload_agent_observations`, `list_agent_observations`, `list_agent_observation_history` API endpoints
|
||||
- `gc_stale_observations()` — no longer needed
|
||||
- `SyncObservations` CLI command from agent
|
||||
- `prune_removed_observations_from_path` calls from agent session
|
||||
- Observations page from UI
|
||||
|
||||
## What stayed
|
||||
|
||||
- Agent observation store (`/tmp/wakey_observations.json`) — agent-local memory, not forwarded
|
||||
- Hotplug hooks — still update agent-local store, feed next inventory
|
||||
- Known devices + identifiers — manual lifecycle, never auto-deleted
|
||||
- Audit events, alerts — unchanged
|
||||
- `observation_retention` config field — `#[allow(dead_code)]`, kept for config file compat
|
||||
|
||||
## Verification
|
||||
|
||||
```
|
||||
cargo fmt — clean
|
||||
cargo clippy-all — clean (0 warnings)
|
||||
cargo test-all — 89 tests pass, 6 ignored (on-device only)
|
||||
pnpm typecheck — clean
|
||||
```
|
||||
|
||||
## Remaining
|
||||
|
||||
- UI cleanup: remove observations page nav entry, observation API types from `api.ts`
|
||||
- Known device identifier UI controls (API exists, UI needs affordance)
|
||||
- The `observation_retention` config field could be removed in a future cleanup
|
||||
- `list_agent_device_rows_for_agent` is `#[allow(dead_code)]` — used in tests, could be `#[cfg(test)]`
|
||||
@@ -12,7 +12,7 @@ start_service() {
|
||||
procd_open_instance
|
||||
procd_set_param command "$BIN" serve --config "$CONFIG"
|
||||
procd_set_param file "$CONFIG"
|
||||
procd_set_param env RUST_LOG=wakey_agent=debug,wakey=debug
|
||||
# procd_set_param env RUST_LOG=wakey_agent=debug,wakey=debug
|
||||
procd_set_param respawn 5 1 0
|
||||
procd_set_param stdout 1
|
||||
procd_set_param stderr 1
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
set -eu
|
||||
|
||||
ROOT="$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)"
|
||||
DB="${SQLX_PREPARE_DB:-/tmp/wakey-sqlx-prepare.sqlite3}"
|
||||
DB="${SQLX_PREPARE_DB:-$ROOT/wakey-prepare.sqlite3}"
|
||||
DATABASE_URL="sqlite://$DB"
|
||||
|
||||
if ! cargo sqlx --version >/dev/null 2>&1; then
|
||||
@@ -23,3 +23,5 @@ cp .sqlx/*.json wakey-control-plane/.sqlx/
|
||||
|
||||
echo "SQLx metadata prepared in $ROOT/.sqlx"
|
||||
echo "SQLx package metadata mirrored in $ROOT/wakey-control-plane/.sqlx"
|
||||
echo "Prepare DB (referenced by .env for live compile-time checking): $DB"
|
||||
echo "DATABASE_URL=$DATABASE_URL"
|
||||
Executable
+111
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env -S uv run --script
|
||||
# /// script
|
||||
# requires-python = ">=3.11"
|
||||
# dependencies = [
|
||||
# "brotli==1.2.0",
|
||||
# "fonttools==4.63.0",
|
||||
# ]
|
||||
# ///
|
||||
"""Rebuild Wakey's self-hosted terminal webfont from Nerd Fonts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import shutil
|
||||
import tarfile
|
||||
import tempfile
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
from fontTools.ttLib import TTFont
|
||||
|
||||
NERD_FONTS_VERSION = "v3.4.0"
|
||||
ARCHIVE_SHA256 = "ef552a3e638f25125c6ad4c51176a6adcdce295ab1d2ffacf0db060caf8c1582"
|
||||
WOFF2_SHA256 = "50680a64466fbcc3eff68e00f84505f55681c86f8450c042416af5a8dbc1cce1"
|
||||
FONT_FILE = "JetBrainsMonoNerdFontMono-Regular"
|
||||
ARCHIVE_URL = (
|
||||
"https://github.com/ryanoasis/nerd-fonts/releases/download/"
|
||||
f"{NERD_FONTS_VERSION}/JetBrainsMono.tar.xz"
|
||||
)
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
OUTPUT_DIR = ROOT / "ui/src/assets/fonts/jetbrains-mono-nerd"
|
||||
|
||||
|
||||
def sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as source:
|
||||
for chunk in iter(lambda: source.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def verify_sha256(path: Path, expected: str) -> None:
|
||||
actual = sha256(path)
|
||||
if actual != expected:
|
||||
raise RuntimeError(
|
||||
f"checksum mismatch for {path}\nexpected: {expected}\nactual: {actual}"
|
||||
)
|
||||
|
||||
|
||||
def download(url: str, destination: Path) -> None:
|
||||
request = urllib.request.Request(url, headers={"User-Agent": "wakey-font-preparer"})
|
||||
with (
|
||||
urllib.request.urlopen(request, timeout=60) as response,
|
||||
destination.open("wb") as output,
|
||||
):
|
||||
shutil.copyfileobj(response, output)
|
||||
|
||||
|
||||
def extract_member(archive: tarfile.TarFile, name: str, destination: Path) -> None:
|
||||
source = archive.extractfile(name)
|
||||
if source is None:
|
||||
raise RuntimeError(f"missing {name} in Nerd Fonts archive")
|
||||
with source, destination.open("wb") as output:
|
||||
shutil.copyfileobj(source, output)
|
||||
|
||||
|
||||
def install_file(source: Path, destination: Path) -> None:
|
||||
"""Atomically replace an asset without exposing a partial file to Vite."""
|
||||
temporary = destination.with_name(f".{destination.name}.tmp")
|
||||
try:
|
||||
shutil.copyfile(source, temporary)
|
||||
os.chmod(temporary, 0o644)
|
||||
temporary.replace(destination)
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.TemporaryDirectory(prefix="wakey-terminal-font.") as temp:
|
||||
work_dir = Path(temp)
|
||||
archive_path = work_dir / "JetBrainsMono.tar.xz"
|
||||
ttf_path = work_dir / f"{FONT_FILE}.ttf"
|
||||
woff2_path = work_dir / f"{FONT_FILE}.woff2"
|
||||
license_path = work_dir / "OFL.txt"
|
||||
|
||||
print(f"Downloading Nerd Fonts {NERD_FONTS_VERSION} JetBrainsMono archive")
|
||||
download(ARCHIVE_URL, archive_path)
|
||||
verify_sha256(archive_path, ARCHIVE_SHA256)
|
||||
|
||||
with tarfile.open(archive_path, "r:xz") as archive:
|
||||
extract_member(archive, ttf_path.name, ttf_path)
|
||||
extract_member(archive, license_path.name, license_path)
|
||||
|
||||
# FontTools otherwise stamps the current time into the head table,
|
||||
# making equivalent rebuilds produce different WOFF2 files.
|
||||
font = TTFont(ttf_path, recalcTimestamp=False)
|
||||
font.flavor = "woff2"
|
||||
font.save(woff2_path)
|
||||
verify_sha256(woff2_path, WOFF2_SHA256)
|
||||
|
||||
install_file(woff2_path, OUTPUT_DIR / woff2_path.name)
|
||||
install_file(license_path, OUTPUT_DIR / license_path.name)
|
||||
|
||||
print(f"Prepared {OUTPUT_DIR / f'{FONT_FILE}.woff2'}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+13
-9
@@ -54,17 +54,21 @@ if ($Publish) {
|
||||
Write-Host 'No -Registry provided; defaulting publish target to registry: gitea'
|
||||
}
|
||||
|
||||
$publishOrder = @(
|
||||
'lda-ipjs',
|
||||
'wakey-core',
|
||||
'wakey-linux',
|
||||
'wakey',
|
||||
'wakey-agent',
|
||||
'wakey-control-plane'
|
||||
# Patched external crates are excluded from the workspace, so publish
|
||||
# them by manifest path before workspace packages that depend on them.
|
||||
$publishTargets = @(
|
||||
@{ Name = 'wakey-vt100'; Args = @('publish', '--manifest-path', 'vendor/vt100/Cargo.toml') },
|
||||
@{ Name = 'lda-ipjs'; Args = @('publish', '-p', 'lda-ipjs') },
|
||||
@{ Name = 'wakey-core'; Args = @('publish', '-p', 'wakey-core') },
|
||||
@{ Name = 'wakey-linux'; Args = @('publish', '-p', 'wakey-linux') },
|
||||
@{ Name = 'wakey'; Args = @('publish', '-p', 'wakey') },
|
||||
@{ Name = 'wakey-agent'; Args = @('publish', '-p', 'wakey-agent') },
|
||||
@{ Name = 'wakey-control-plane'; Args = @('publish', '-p', 'wakey-control-plane') }
|
||||
)
|
||||
|
||||
foreach ($pkg in $publishOrder) {
|
||||
$pubArgs = @('publish', '-p', $pkg)
|
||||
foreach ($target in $publishTargets) {
|
||||
$pkg = $target.Name
|
||||
$pubArgs = @($target.Args)
|
||||
if ($Registry) { $pubArgs += @('--registry', $Registry) }
|
||||
|
||||
Write-Host ("publishing {0}..." -f $pkg)
|
||||
|
||||
@@ -110,6 +110,16 @@ update_existing_repo() {
|
||||
git -C "$repo_dir" pull --ff-only origin "$ref" || fail 'git pull --ff-only failed'
|
||||
}
|
||||
|
||||
update_submodules() {
|
||||
repo_dir=$1
|
||||
|
||||
# Sync first so an updated .gitmodules URL is honored by an existing
|
||||
# checkout, then initialize and pin every nested submodule to the commit
|
||||
# recorded by the parent repository.
|
||||
git -C "$repo_dir" submodule sync --recursive || fail 'git submodule sync failed'
|
||||
git -C "$repo_dir" submodule update --init --recursive || fail 'git submodule update failed'
|
||||
}
|
||||
|
||||
install_executable_force() {
|
||||
src=$1
|
||||
dst=$2
|
||||
@@ -186,6 +196,7 @@ main() {
|
||||
git clone --depth 1 --branch "$REF" "$REPO_URL" "$WORKDIR" ||
|
||||
fail 'git clone failed'
|
||||
fi
|
||||
update_submodules "$WORKDIR"
|
||||
|
||||
cd "$WORKDIR"
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ pub use service::{
|
||||
};
|
||||
pub use wakey_linux::observations::{
|
||||
list_local_observations, observe_dhcp_event, observe_neighbor_event,
|
||||
prune_stale_observations_from_path,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+76
-21
@@ -77,7 +77,7 @@ pub fn merge_devices_with_observations(
|
||||
let key = row
|
||||
.mac
|
||||
.map(|m| m.to_string())
|
||||
.unwrap_or_else(|| format!("ip:{}", row.ip));
|
||||
.unwrap_or_else(|| format!("ip:{}", row.ip)); // key by mac or by FAILED ip
|
||||
by_key.entry(key).or_default().0.push(row);
|
||||
}
|
||||
for lease in leases {
|
||||
@@ -99,30 +99,31 @@ pub fn merge_devices_with_observations(
|
||||
Device::from_parts_with_observations(neighbors, leases, observations)
|
||||
})
|
||||
.collect();
|
||||
if !query.is_empty() {
|
||||
let mut texts = Vec::new();
|
||||
let mut devs = Vec::new();
|
||||
let mut ips = Vec::new();
|
||||
let mut macs = Vec::new();
|
||||
let mut nuds = Vec::new();
|
||||
|
||||
let mut texts = Vec::new();
|
||||
let mut devs = Vec::new();
|
||||
let mut ips = Vec::new();
|
||||
let mut macs = Vec::new();
|
||||
let mut nuds = Vec::new();
|
||||
for term in query {
|
||||
match term {
|
||||
Query::Text(v) => texts.push(v.as_str()),
|
||||
Query::Interface(v) => devs.push(v.as_str()),
|
||||
Query::Ip(v) => ips.push(*v),
|
||||
Query::Mac(v) => macs.push(*v),
|
||||
Query::NeighborState(v) => nuds.push(*v),
|
||||
};
|
||||
}
|
||||
|
||||
for term in query {
|
||||
match term {
|
||||
Query::Text(v) => texts.push(v.as_str()),
|
||||
Query::Interface(v) => devs.push(v.as_str()),
|
||||
Query::Ip(v) => ips.push(*v),
|
||||
Query::Mac(v) => macs.push(*v),
|
||||
Query::NeighborState(v) => nuds.push(*v),
|
||||
};
|
||||
}
|
||||
|
||||
devices.retain(|device| {
|
||||
(texts.is_empty() || device.names.iter().any(|n| texts.contains(&n.as_str())))
|
||||
devices.retain(|device| {
|
||||
(texts.is_empty() || device.names.iter().any(|n| texts.contains(&n.as_str())))
|
||||
&& (devs.is_empty() || device.interfaces.iter().any(|i| devs.contains(&i.as_str()))) // same pattern as iter().any
|
||||
&& (ips.is_empty() || device.ips.iter().any(|ip| ips.contains(ip)))
|
||||
&& (macs.is_empty() || device.macs.iter().any(|mac| macs.contains(mac)))
|
||||
&& (nuds.is_empty() || device.neighbors.iter().any(|n| nuds.contains(&n.state)))
|
||||
});
|
||||
});
|
||||
}
|
||||
devices.sort_by(|a, b| {
|
||||
presence_rank(b.presence)
|
||||
.cmp(&presence_rank(a.presence))
|
||||
@@ -180,7 +181,7 @@ const fn presence_rank(presence: Presence) -> u8 {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
use wakey_core::{DeviceId, DhcpLease, InventoryQueryBuilder, NeighborState};
|
||||
use wakey_core::{DeviceId, DhcpLease, EndpointSource, InventoryQueryBuilder, NeighborState};
|
||||
|
||||
fn sample_neighbors() -> Vec<NeighborEntry> {
|
||||
vec![NeighborEntry {
|
||||
@@ -278,11 +279,14 @@ mod tests {
|
||||
Some(DeviceId::Mac("aa:bb:cc:dd:ee:ff".parse().expect("mac")))
|
||||
);
|
||||
assert_eq!(out[0].observations.len(), 1);
|
||||
assert_eq!(out[0].endpoints.len(), 1);
|
||||
assert_eq!(out[0].endpoints[0].key.source, EndpointSource::HookDhcp);
|
||||
assert!(out[0].ips.is_empty());
|
||||
assert_eq!(out[0].presence, Presence::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_devices_marks_remove_only_observation_offline() {
|
||||
fn merge_devices_marks_neigh_remove_observation_offline() {
|
||||
let query = InventoryQueryBuilder::new().build();
|
||||
let out = merge_devices_with_observations(
|
||||
Vec::new(),
|
||||
@@ -301,5 +305,56 @@ mod tests {
|
||||
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].presence, Presence::Offline);
|
||||
assert_eq!(out[0].endpoints[0].key.source, EndpointSource::HookNeighbor);
|
||||
assert!(out[0].ips.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_devices_keeps_dhcp_remove_observation_unknown() {
|
||||
let query = InventoryQueryBuilder::new().build();
|
||||
let out = merge_devices_with_observations(
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
vec![DeviceObservationFact {
|
||||
kind: "dhcp".into(),
|
||||
action: "remove".into(),
|
||||
mac: Some("aa:bb:cc:dd:ee:ff".parse().expect("mac")),
|
||||
ip: Some(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 30))),
|
||||
hostname: None,
|
||||
first_seen_unix: Some(10),
|
||||
last_seen_unix: Some(20),
|
||||
}],
|
||||
&query,
|
||||
);
|
||||
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].presence, Presence::Unknown);
|
||||
assert_eq!(out[0].endpoints[0].key.source, EndpointSource::HookDhcp);
|
||||
assert!(out[0].ips.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_devices_uses_ip_id_for_ip_only_hook_endpoint_without_summary_ip() {
|
||||
let query = InventoryQueryBuilder::new().build();
|
||||
let ip = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 31));
|
||||
let out = merge_devices_with_observations(
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
vec![DeviceObservationFact {
|
||||
kind: "neigh".into(),
|
||||
action: "remove".into(),
|
||||
mac: None,
|
||||
ip: Some(ip),
|
||||
hostname: None,
|
||||
first_seen_unix: Some(10),
|
||||
last_seen_unix: Some(20),
|
||||
}],
|
||||
&query,
|
||||
);
|
||||
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].id, Some(DeviceId::Ip(ip)));
|
||||
assert!(out[0].ips.is_empty());
|
||||
assert_eq!(out[0].endpoints.len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
yarn.lock
|
||||
+18
-1
@@ -2,8 +2,25 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, interactive-widget=resizes-content"
|
||||
/>
|
||||
<title>Wakey Operator UI</title>
|
||||
<script>
|
||||
(function () {
|
||||
try {
|
||||
var t = localStorage.getItem("wakey-ui-theme");
|
||||
if (
|
||||
t === "dark" ||
|
||||
((t === "system" || !t) &&
|
||||
window.matchMedia("(prefers-color-scheme: dark)").matches)
|
||||
) {
|
||||
document.documentElement.classList.add("dark");
|
||||
}
|
||||
} catch (_) {}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -15,6 +15,13 @@
|
||||
"@base-ui/react": "^1.4.0",
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@xterm/addon-clipboard": "0.3.0-beta.301",
|
||||
"@xterm/addon-fit": "0.12.0-beta.301",
|
||||
"@xterm/addon-image": "0.10.0-beta.301",
|
||||
"@xterm/addon-unicode-graphemes": "0.5.0-beta.301",
|
||||
"@xterm/addon-unicode11": "0.10.0-beta.301",
|
||||
"@xterm/addon-web-links": "0.13.0-beta.301",
|
||||
"@xterm/xterm": "6.1.0-beta.301",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.8.0",
|
||||
|
||||
Generated
+1435
-2750
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,3 @@
|
||||
allowBuilds:
|
||||
esbuild: false
|
||||
msw: false
|
||||
+140
-26
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { lazy, Suspense, useEffect, useRef, useState } from "react";
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
setAgentNickname,
|
||||
} from "@/api";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { AppLayout } from "@/layout/AppLayout";
|
||||
import { AgentsPage } from "@/pages/AgentsPage";
|
||||
import { AlertsPage } from "@/pages/AlertsPage";
|
||||
@@ -23,6 +24,12 @@ import { CommandsPage } from "@/pages/CommandsPage";
|
||||
import { DashboardPage } from "@/pages/DashboardPage";
|
||||
import { DevicesPage } from "@/pages/DevicesPage";
|
||||
import { TokensPage } from "@/pages/TokensPage";
|
||||
|
||||
const TerminalPage = lazy(() =>
|
||||
import("@/pages/TerminalPage").then((module) => ({
|
||||
default: module.TerminalPage,
|
||||
})),
|
||||
);
|
||||
import { WakeToolsPage } from "@/pages/WakeToolsPage";
|
||||
|
||||
type LoadState = "idle" | "loading" | "ready" | "error";
|
||||
@@ -33,22 +40,48 @@ export function App() {
|
||||
const [history, setHistory] = useState<AlertTransition[]>([]);
|
||||
const [audit, setAudit] = useState<AuditEvent[]>([]);
|
||||
const [selectedAgentId, setSelectedAgentId] = useState("");
|
||||
const [alertsHealth, setAlertsHealth] = useState<{
|
||||
available: boolean;
|
||||
reason?: string;
|
||||
} | null>(null);
|
||||
const [alertsStreamConnected, setAlertsStreamConnected] = useState(false);
|
||||
const streamHealthReceived = useRef(false);
|
||||
const alertsRevision = useRef(0);
|
||||
|
||||
const [state, setState] = useState<LoadState>("idle");
|
||||
|
||||
async function loadAlertsWithoutBlocking() {
|
||||
try {
|
||||
const nextAlerts = await fetchAlerts();
|
||||
if (!streamHealthReceived.current) {
|
||||
setAlertsHealth({ available: true });
|
||||
}
|
||||
return nextAlerts;
|
||||
} catch (err) {
|
||||
if (!streamHealthReceived.current) {
|
||||
setAlertsHealth({ available: false, reason: String(err) });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
const revision = alertsRevision.current;
|
||||
setState("loading");
|
||||
try {
|
||||
const [nextAgents, nextAlerts, nextHistory, nextAudit] =
|
||||
await Promise.all([
|
||||
fetchAgents(),
|
||||
fetchAlerts(),
|
||||
loadAlertsWithoutBlocking(),
|
||||
fetchAlertHistory(20),
|
||||
fetchAudit(30),
|
||||
]);
|
||||
setAgents(nextAgents);
|
||||
setAlerts(nextAlerts);
|
||||
setHistory(nextHistory);
|
||||
// Streamed state wins over HTTP requests started before its arrival.
|
||||
if (revision === alertsRevision.current) {
|
||||
if (nextAlerts) setAlerts(nextAlerts);
|
||||
setHistory(nextHistory);
|
||||
}
|
||||
setAudit(nextAudit);
|
||||
const firstConnectedAgentId =
|
||||
nextAgents.find((agent) => agent.connected)?.agent_id ?? "";
|
||||
@@ -93,12 +126,15 @@ export function App() {
|
||||
}
|
||||
|
||||
async function refreshAlertsAndHistory() {
|
||||
const revision = alertsRevision.current;
|
||||
const [nextAlerts, nextHistory] = await Promise.all([
|
||||
fetchAlerts(),
|
||||
loadAlertsWithoutBlocking(),
|
||||
fetchAlertHistory(20),
|
||||
]);
|
||||
setAlerts(nextAlerts);
|
||||
setHistory(nextHistory);
|
||||
if (revision === alertsRevision.current) {
|
||||
if (nextAlerts) setAlerts(nextAlerts);
|
||||
setHistory(nextHistory);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@@ -106,30 +142,87 @@ export function App() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const wsUrl = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/api/v1/control/alerts/ws`;
|
||||
const ws = new WebSocket(wsUrl);
|
||||
let socket: WebSocket | null = null;
|
||||
let disposed = false;
|
||||
let attempt = 0;
|
||||
let openedAt = 0;
|
||||
let reconnectTimer: number | undefined;
|
||||
|
||||
ws.onmessage = (evt) => {
|
||||
try {
|
||||
const payload = JSON.parse(String(evt.data)) as {
|
||||
alerts?: Alert[];
|
||||
recent_transitions?: AlertTransition[];
|
||||
};
|
||||
if (payload.alerts) setAlerts(payload.alerts);
|
||||
if (payload.recent_transitions) setHistory(payload.recent_transitions);
|
||||
} catch {
|
||||
// Ignore malformed stream payloads and keep current UI state.
|
||||
}
|
||||
const resync = () => {
|
||||
const generation = alertsRevision.current;
|
||||
void Promise.all([fetchAlerts(), fetchAlertHistory(20)])
|
||||
.then(([nextAlerts, nextHistory]) => {
|
||||
// A WS snapshot delivered while this request was in flight is newer.
|
||||
if (disposed || generation !== alertsRevision.current) return;
|
||||
setAlerts(nextAlerts);
|
||||
setHistory(nextHistory);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
const id = window.setInterval(() => {
|
||||
void refreshAlertsAndHistory().catch(() => undefined);
|
||||
}, 8000);
|
||||
ws.onclose = () => window.clearInterval(id);
|
||||
const connect = () => {
|
||||
if (disposed) return;
|
||||
const wsUrl = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/api/v1/control/alerts/ws`;
|
||||
socket = new WebSocket(wsUrl);
|
||||
|
||||
socket.onopen = () => {
|
||||
if (disposed) return;
|
||||
openedAt = Date.now();
|
||||
setAlertsStreamConnected(true);
|
||||
// Pull anything missed while the stream was down.
|
||||
resync();
|
||||
};
|
||||
|
||||
socket.onmessage = (evt) => {
|
||||
if (disposed) return;
|
||||
alertsRevision.current += 1;
|
||||
try {
|
||||
const payload = JSON.parse(String(evt.data)) as {
|
||||
type?: string;
|
||||
available?: boolean;
|
||||
reason?: string;
|
||||
alerts?: Alert[];
|
||||
recent_transitions?: AlertTransition[];
|
||||
};
|
||||
if (payload.type === "alerts_health") {
|
||||
streamHealthReceived.current = true;
|
||||
setAlertsHealth({
|
||||
available: payload.available === true,
|
||||
reason: payload.reason,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (payload.alerts) setAlerts(payload.alerts);
|
||||
if (payload.recent_transitions)
|
||||
setHistory(payload.recent_transitions);
|
||||
} catch {
|
||||
// Ignore malformed stream payloads and keep current UI state.
|
||||
}
|
||||
};
|
||||
|
||||
socket.onclose = () => {
|
||||
socket = null;
|
||||
if (disposed) return;
|
||||
setAlertsStreamConnected(false);
|
||||
// Only a connection that stayed up proves the stream is healthy; an
|
||||
// accept-then-close server must not pin us to the first backoff step.
|
||||
if (openedAt && Date.now() - openedAt > 10_000) attempt = 0;
|
||||
openedAt = 0;
|
||||
// Browsers never reconnect a WebSocket on their own.
|
||||
const delay =
|
||||
Math.min(30_000, 1000 * 2 ** attempt) + Math.random() * 1000;
|
||||
attempt += 1;
|
||||
reconnectTimer = window.setTimeout(connect, delay);
|
||||
};
|
||||
};
|
||||
|
||||
return () => ws.close();
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
if (reconnectTimer !== undefined) window.clearTimeout(reconnectTimer);
|
||||
socket?.close();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
@@ -144,6 +237,7 @@ export function App() {
|
||||
selectedAgentId={selectedAgentId}
|
||||
onSelectAgent={setSelectedAgentId}
|
||||
onAfterWake={loadAll}
|
||||
onRefresh={loadAll}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -167,6 +261,8 @@ export function App() {
|
||||
transitions={history}
|
||||
audit={audit}
|
||||
loading={state === "loading"}
|
||||
alertsHealth={alertsHealth}
|
||||
alertsStreamConnected={alertsStreamConnected}
|
||||
onRefresh={loadAll}
|
||||
/>
|
||||
}
|
||||
@@ -209,11 +305,29 @@ export function App() {
|
||||
<AlertsPage
|
||||
alerts={alerts}
|
||||
transitions={history}
|
||||
alertsHealth={alertsHealth}
|
||||
alertsStreamConnected={alertsStreamConnected}
|
||||
onRefresh={refreshAlertsAndHistory}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route path="tokens" element={<TokensPage />} />
|
||||
<Route
|
||||
path="terminal"
|
||||
element={
|
||||
<Suspense
|
||||
fallback={
|
||||
<Skeleton className="h-[calc(100dvh-2.5rem)] w-full" />
|
||||
}
|
||||
>
|
||||
<TerminalPage
|
||||
agents={agents}
|
||||
selectedAgentId={selectedAgentId}
|
||||
onSelectAgent={setSelectedAgentId}
|
||||
/>
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Route>
|
||||
|
||||
@@ -2,6 +2,21 @@ export type Agent = {
|
||||
agent_id: string;
|
||||
connected: boolean;
|
||||
nickname?: string | null;
|
||||
capabilities: "terminal"[];
|
||||
capability_options?: {
|
||||
terminal?: { max_sessions: number };
|
||||
};
|
||||
};
|
||||
|
||||
export type TerminalSession = {
|
||||
terminal_id: string;
|
||||
agent_id: string;
|
||||
created_at_unix: number;
|
||||
expires_at_unix?: number | null;
|
||||
agent_attached: boolean;
|
||||
operator_attached: boolean;
|
||||
websocket_url: string;
|
||||
attachment_token?: string;
|
||||
};
|
||||
|
||||
export type Alert = {
|
||||
@@ -103,6 +118,20 @@ export type FleetDeviceAgent = {
|
||||
last_seen_unix: number;
|
||||
};
|
||||
|
||||
export type FleetDeviceEndpoint = {
|
||||
agent_id: string;
|
||||
nickname?: string | null;
|
||||
connected: boolean;
|
||||
source: string;
|
||||
mac: string | null;
|
||||
ip: string | null;
|
||||
hostname: string | null;
|
||||
interface: string | null;
|
||||
presence: string;
|
||||
first_seen_unix: number | null;
|
||||
last_seen_unix: number | null;
|
||||
};
|
||||
|
||||
export type FleetWakeRoute = {
|
||||
route_id: string;
|
||||
agent_id: string;
|
||||
@@ -111,7 +140,9 @@ export type FleetWakeRoute = {
|
||||
mac: string | null;
|
||||
ip: string | null;
|
||||
hostname: string | null;
|
||||
interface: string | null;
|
||||
source: string;
|
||||
presence: string;
|
||||
last_seen_unix: number;
|
||||
wakeable: boolean;
|
||||
};
|
||||
@@ -126,6 +157,7 @@ export type FleetDevice = {
|
||||
hostnames: string[];
|
||||
agents: FleetDeviceAgent[];
|
||||
sources: string[];
|
||||
endpoints: FleetDeviceEndpoint[];
|
||||
first_seen_unix: number | null;
|
||||
last_seen_unix: number | null;
|
||||
presence: string;
|
||||
@@ -208,6 +240,44 @@ export function fetchAgents(): Promise<Agent[]> {
|
||||
return request<Agent[]>("/api/v1/control/agents");
|
||||
}
|
||||
|
||||
export function createTerminal(
|
||||
agentId: string,
|
||||
rows: number,
|
||||
cols: number,
|
||||
): Promise<TerminalSession> {
|
||||
return request<TerminalSession>("/api/v1/control/terminals", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ agent_id: agentId, rows, cols }),
|
||||
});
|
||||
}
|
||||
|
||||
export function listTerminals(): Promise<TerminalSession[]> {
|
||||
return request<TerminalSession[]>("/api/v1/control/terminals");
|
||||
}
|
||||
|
||||
export function attachTerminal(
|
||||
terminalId: string,
|
||||
operatorId: string,
|
||||
): Promise<TerminalSession> {
|
||||
return request<TerminalSession>(
|
||||
`/api/v1/control/terminals/${encodeURIComponent(terminalId)}/attach`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ operator_id: operatorId }),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function closeTerminal(terminalId: string): Promise<void> {
|
||||
const response = await fetch(
|
||||
`/api/v1/control/terminals/${encodeURIComponent(terminalId)}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to close terminal: ${response.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function fetchAlerts(): Promise<Alert[]> {
|
||||
return request<Alert[]>("/api/v1/control/alerts");
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,16 @@
|
||||
# JetBrainsMono Nerd Font Mono
|
||||
|
||||
`JetBrainsMonoNerdFontMono-Regular.woff2` is the regular monospaced variant
|
||||
from Nerd Fonts v3.4.0 (JetBrains Mono v2.304), converted from the release TTF
|
||||
to WOFF2 without subsetting.
|
||||
|
||||
Source archive:
|
||||
https://github.com/ryanoasis/nerd-fonts/releases/download/v3.4.0/JetBrainsMono.tar.xz
|
||||
|
||||
The font is distributed under the SIL Open Font License in `OFL.txt`.
|
||||
|
||||
Rebuild the checked-in webfont with:
|
||||
|
||||
```sh
|
||||
./scripts/prepare_terminal_font.py
|
||||
```
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
FileText,
|
||||
Key,
|
||||
Terminal,
|
||||
SquareTerminal,
|
||||
PanelLeftClose,
|
||||
PanelLeft,
|
||||
} from "lucide-react";
|
||||
@@ -57,6 +58,7 @@ const navSections: NavSection[] = [
|
||||
{ to: "/audit", label: "Audit", icon: FileText },
|
||||
{ to: "/tokens", label: "Tokens", icon: Key },
|
||||
{ to: "/commands", label: "Commands", icon: Terminal },
|
||||
{ to: "/terminal", label: "Terminal", icon: SquareTerminal },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -93,7 +95,7 @@ export function AppLayout() {
|
||||
className={[
|
||||
"sidebar",
|
||||
collapsed ? "sidebar--collapsed" : "",
|
||||
"fixed inset-y-0 left-0 z-50 shadow-xl transition-all duration-300 ease-in-out",
|
||||
"fixed inset-y-0 left-0 z-50 shadow-xl transition-all duration-100 ease-in-out",
|
||||
"md:sticky md:top-0 md:z-30 md:shadow-none",
|
||||
collapsed ? "-translate-x-full md:translate-x-0" : "translate-x-0",
|
||||
]
|
||||
@@ -219,8 +221,14 @@ export function AppLayout() {
|
||||
{/* Mobile backdrop */}
|
||||
{!collapsed && (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="fixed inset-0 z-40 bg-black/20 md:hidden animate-in fade-in duration-200"
|
||||
onClick={() => setCollapsed(true)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") setCollapsed(true);
|
||||
}}
|
||||
aria-label="Close menu"
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { BrowserRouter } from "react-router-dom";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { App } from "@/App";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import "./styles.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
|
||||
+50
-11
@@ -16,10 +16,18 @@ import { Textarea } from "@/components/ui/textarea";
|
||||
type Props = {
|
||||
alerts: Alert[];
|
||||
transitions: AlertTransition[];
|
||||
alertsHealth: { available: boolean; reason?: string } | null;
|
||||
alertsStreamConnected: boolean;
|
||||
onRefresh: () => Promise<void>;
|
||||
};
|
||||
|
||||
export function AlertsPage({ alerts, transitions, onRefresh }: Props) {
|
||||
export function AlertsPage({
|
||||
alerts,
|
||||
transitions,
|
||||
alertsHealth,
|
||||
alertsStreamConnected,
|
||||
onRefresh,
|
||||
}: Props) {
|
||||
const [severity, setSeverity] = useState("all");
|
||||
const [status, setStatus] = useState("all");
|
||||
const [kind, setKind] = useState("all");
|
||||
@@ -66,13 +74,34 @@ export function AlertsPage({ alerts, transitions, onRefresh }: Props) {
|
||||
<Card>
|
||||
<CardHeader className="flex items-center justify-between gap-2">
|
||||
<CardTitle>Active Alerts</CardTitle>
|
||||
<Button size="sm" variant="outline" onClick={() => void onRefresh()}>
|
||||
Refresh
|
||||
</Button>
|
||||
<div className="flex flex-wrap items-center justify-end gap-x-3 gap-y-1">
|
||||
<div
|
||||
aria-live="polite"
|
||||
className="text-right text-xs text-muted-foreground"
|
||||
>
|
||||
{!alertsStreamConnected && <div>Alert stream disconnected</div>}
|
||||
{alertsHealth?.available === false && (
|
||||
<div>
|
||||
Alert results may be stale or unavailable
|
||||
{alertsHealth.reason ? `: ${alertsHealth.reason}` : ""}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => void onRefresh()}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="grid gap-2 sm:grid-cols-3">
|
||||
<label className="grid gap-1 text-sm text-muted-foreground">
|
||||
<label
|
||||
htmlFor="alert-severity"
|
||||
className="grid gap-1 text-sm text-muted-foreground"
|
||||
>
|
||||
Severity
|
||||
<Select
|
||||
value={severity}
|
||||
@@ -80,7 +109,7 @@ export function AlertsPage({ alerts, transitions, onRefresh }: Props) {
|
||||
if (value) setSeverity(value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectTrigger id="alert-severity" className="w-full">
|
||||
<SelectValue placeholder="Severity" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -93,7 +122,10 @@ export function AlertsPage({ alerts, transitions, onRefresh }: Props) {
|
||||
</Select>
|
||||
</label>
|
||||
|
||||
<label className="grid gap-1 text-sm text-muted-foreground">
|
||||
<label
|
||||
htmlFor="alert-status"
|
||||
className="grid gap-1 text-sm text-muted-foreground"
|
||||
>
|
||||
Status
|
||||
<Select
|
||||
value={status}
|
||||
@@ -101,7 +133,7 @@ export function AlertsPage({ alerts, transitions, onRefresh }: Props) {
|
||||
if (value) setStatus(value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectTrigger id="alert-status" className="w-full">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -114,7 +146,10 @@ export function AlertsPage({ alerts, transitions, onRefresh }: Props) {
|
||||
</Select>
|
||||
</label>
|
||||
|
||||
<label className="grid gap-1 text-sm text-muted-foreground">
|
||||
<label
|
||||
htmlFor="alert-kind"
|
||||
className="grid gap-1 text-sm text-muted-foreground"
|
||||
>
|
||||
Kind
|
||||
<Select
|
||||
value={kind}
|
||||
@@ -122,7 +157,7 @@ export function AlertsPage({ alerts, transitions, onRefresh }: Props) {
|
||||
if (value) setKind(value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectTrigger id="alert-kind" className="w-full">
|
||||
<SelectValue placeholder="Kind" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -170,9 +205,13 @@ export function AlertsPage({ alerts, transitions, onRefresh }: Props) {
|
||||
</span>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<label className="grid gap-1 text-sm text-muted-foreground">
|
||||
<label
|
||||
htmlFor="alert-transition-search"
|
||||
className="grid gap-1 text-sm text-muted-foreground"
|
||||
>
|
||||
Search
|
||||
<Input
|
||||
id="alert-transition-search"
|
||||
value={transitionQ}
|
||||
onChange={(e) => setTransitionQ(e.target.value)}
|
||||
placeholder="kind, status, message, agent"
|
||||
|
||||
@@ -70,7 +70,10 @@ export function AuditPage({ events, onRefresh }: Props) {
|
||||
|
||||
<CardContent className="space-y-3">
|
||||
<div className="grid gap-2 sm:grid-cols-3">
|
||||
<label className="grid gap-1 text-sm text-muted-foreground">
|
||||
<label
|
||||
htmlFor="audit-event-type"
|
||||
className="grid gap-1 text-sm text-muted-foreground"
|
||||
>
|
||||
Event type
|
||||
<Select
|
||||
value={eventType}
|
||||
@@ -78,7 +81,7 @@ export function AuditPage({ events, onRefresh }: Props) {
|
||||
if (value) setEventType(value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectTrigger id="audit-event-type" className="w-full">
|
||||
<SelectValue placeholder="Event type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -91,7 +94,10 @@ export function AuditPage({ events, onRefresh }: Props) {
|
||||
</Select>
|
||||
</label>
|
||||
|
||||
<label className="grid gap-1 text-sm text-muted-foreground">
|
||||
<label
|
||||
htmlFor="audit-outcome"
|
||||
className="grid gap-1 text-sm text-muted-foreground"
|
||||
>
|
||||
Outcome
|
||||
<Select
|
||||
value={outcome}
|
||||
@@ -99,7 +105,7 @@ export function AuditPage({ events, onRefresh }: Props) {
|
||||
if (value) setOutcome(value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectTrigger id="audit-outcome" className="w-full">
|
||||
<SelectValue placeholder="Outcome" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -112,9 +118,13 @@ export function AuditPage({ events, onRefresh }: Props) {
|
||||
</Select>
|
||||
</label>
|
||||
|
||||
<label className="grid gap-1 text-sm text-muted-foreground">
|
||||
<label
|
||||
htmlFor="audit-search"
|
||||
className="grid gap-1 text-sm text-muted-foreground"
|
||||
>
|
||||
Search
|
||||
<Input
|
||||
id="audit-search"
|
||||
value={needle}
|
||||
onChange={(e) => setNeedle(e.target.value)}
|
||||
placeholder="message, agent, actor"
|
||||
|
||||
@@ -66,12 +66,15 @@ export function CommandsPage({
|
||||
Command Runner
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Debug tool — send raw commands to agents and inspect JSON responses
|
||||
Debug tool: send raw commands to agents and inspect JSON responses
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form className="grid gap-3" onSubmit={submit}>
|
||||
<label className="grid gap-1 text-sm text-muted-foreground">
|
||||
<label
|
||||
htmlFor="cmd-agent"
|
||||
className="grid gap-1 text-sm text-muted-foreground"
|
||||
>
|
||||
<span>Agent</span>
|
||||
<AgentSelector
|
||||
agents={agents}
|
||||
@@ -80,13 +83,16 @@ export function CommandsPage({
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="grid gap-1 text-sm text-muted-foreground">
|
||||
<label
|
||||
htmlFor="cmd-kind"
|
||||
className="grid gap-1 text-sm text-muted-foreground"
|
||||
>
|
||||
<span>Command</span>
|
||||
<Select
|
||||
value={kind}
|
||||
onValueChange={(value) => setKind(value as CommandKind)}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectTrigger id="cmd-kind" className="w-full">
|
||||
<SelectValue placeholder="Pick command" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -98,9 +104,13 @@ export function CommandsPage({
|
||||
</Select>
|
||||
</label>
|
||||
|
||||
<label className="grid gap-1 text-sm text-muted-foreground">
|
||||
<label
|
||||
htmlFor="cmd-query"
|
||||
className="grid gap-1 text-sm text-muted-foreground"
|
||||
>
|
||||
<span>Query</span>
|
||||
<Input
|
||||
id="cmd-query"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="optional"
|
||||
|
||||
@@ -27,6 +27,8 @@ type Props = {
|
||||
transitions: AlertTransition[];
|
||||
audit: AuditEvent[];
|
||||
loading: boolean;
|
||||
alertsHealth: { available: boolean; reason?: string } | null;
|
||||
alertsStreamConnected: boolean;
|
||||
onRefresh: () => void;
|
||||
};
|
||||
|
||||
@@ -66,6 +68,8 @@ export function DashboardPage({
|
||||
transitions,
|
||||
audit,
|
||||
loading,
|
||||
alertsHealth,
|
||||
alertsStreamConnected,
|
||||
onRefresh,
|
||||
}: Props) {
|
||||
const connected = agents.filter((a) => a.connected).length;
|
||||
@@ -115,7 +119,7 @@ export function DashboardPage({
|
||||
<section className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{/* Fleet Status */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-y-0 pb-2">
|
||||
<CardDescription className="flex items-center gap-1.5">
|
||||
<Bot className="size-3.5" />
|
||||
Fleet Status
|
||||
@@ -152,7 +156,7 @@ export function DashboardPage({
|
||||
|
||||
{/* Active Alerts */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-y-0 pb-2">
|
||||
<CardDescription className="flex items-center gap-1.5">
|
||||
<Bell className="size-3.5" />
|
||||
Active Alerts
|
||||
@@ -165,6 +169,20 @@ export function DashboardPage({
|
||||
</Link>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{(!alertsStreamConnected || alertsHealth?.available === false) && (
|
||||
<div
|
||||
aria-live="polite"
|
||||
className="mb-2 text-xs text-muted-foreground"
|
||||
>
|
||||
{!alertsStreamConnected && <div>Alert stream disconnected</div>}
|
||||
{alertsHealth?.available === false && (
|
||||
<div>
|
||||
Alert results may be stale or unavailable
|
||||
{alertsHealth.reason ? `: ${alertsHealth.reason}` : ""}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-2xl font-bold tabular-nums">{alerts.length}</div>
|
||||
{Object.keys(severityBuckets).length > 0 ? (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
@@ -188,7 +206,7 @@ export function DashboardPage({
|
||||
|
||||
{/* Transitions */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-y-0 pb-2">
|
||||
<CardDescription className="flex items-center gap-1.5">
|
||||
<Activity className="size-3.5" />
|
||||
Transitions
|
||||
@@ -206,7 +224,7 @@ export function DashboardPage({
|
||||
|
||||
{/* Token Health */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-y-0 pb-2">
|
||||
<CardDescription className="flex items-center gap-1.5">
|
||||
<Key className="size-3.5" />
|
||||
Enroll Tokens
|
||||
@@ -228,7 +246,7 @@ export function DashboardPage({
|
||||
|
||||
{/* Recent Activity Feed */}
|
||||
<Card className="sm:col-span-2 lg:col-span-4">
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-3 space-y-0">
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-3 gap-y-0">
|
||||
<div>
|
||||
<CardTitle>Recent Activity</CardTitle>
|
||||
<CardDescription className="mt-1">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Inbox, MoreHorizontal, RefreshCw, Search, Zap } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -51,9 +51,10 @@ type Props = {
|
||||
selectedAgentId: string;
|
||||
onSelectAgent: (agentId: string) => void;
|
||||
onAfterWake?: () => Promise<void>;
|
||||
onRefresh?: () => Promise<void>;
|
||||
};
|
||||
|
||||
export function DevicesPage({ agents, onAfterWake }: Props) {
|
||||
export function DevicesPage({ agents, onAfterWake, onRefresh }: Props) {
|
||||
const [devices, setDevices] = useState<FleetDevice[]>([]);
|
||||
const [knownDevices, setKnownDevices] = useState<KnownDevice[]>([]);
|
||||
const [query, setQuery] = useState("");
|
||||
@@ -68,8 +69,12 @@ export function DevicesPage({ agents, onAfterWake }: Props) {
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [wakeBusyKey, setWakeBusyKey] = useState("");
|
||||
const [details, setDetails] = useState<FleetDevice | null>(null);
|
||||
const loadSeqRef = useRef(0);
|
||||
|
||||
async function loadFleet() {
|
||||
// Latest request wins: filter changes and debounced searches overlap, and
|
||||
// an older response must never overwrite fresher state.
|
||||
const seq = ++loadSeqRef.current;
|
||||
setLoading(true);
|
||||
try {
|
||||
const [nextDevices, nextKnownDevices] = await Promise.all([
|
||||
@@ -83,12 +88,14 @@ export function DevicesPage({ agents, onAfterWake }: Props) {
|
||||
}),
|
||||
fetchKnownDevices(),
|
||||
]);
|
||||
if (seq !== loadSeqRef.current) return;
|
||||
setDevices(nextDevices);
|
||||
setKnownDevices(nextKnownDevices);
|
||||
} catch (err) {
|
||||
if (seq !== loadSeqRef.current) return;
|
||||
toast.error("Failed to load fleet", { description: String(err) });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (seq === loadSeqRef.current) setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,8 +144,7 @@ export function DevicesPage({ agents, onAfterWake }: Props) {
|
||||
} catch {
|
||||
const area = document.createElement("textarea");
|
||||
area.value = text;
|
||||
area.style.position = "fixed";
|
||||
area.style.opacity = "0";
|
||||
area.style.cssText = "position:fixed;opacity:0";
|
||||
document.body.appendChild(area);
|
||||
area.select();
|
||||
document.execCommand("copy");
|
||||
@@ -152,7 +158,13 @@ export function DevicesPage({ agents, onAfterWake }: Props) {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [presence, known, agentId]);
|
||||
|
||||
// The filter effect above already loads on mount; only react to query edits.
|
||||
const mountedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!mountedRef.current) {
|
||||
mountedRef.current = true;
|
||||
return;
|
||||
}
|
||||
const timer = window.setTimeout(() => void loadFleet(), 220);
|
||||
return () => window.clearTimeout(timer);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -201,7 +213,10 @@ export function DevicesPage({ agents, onAfterWake }: Props) {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void loadFleet()}
|
||||
onClick={() => {
|
||||
void loadFleet();
|
||||
if (onRefresh) void onRefresh();
|
||||
}}
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw className="size-4" aria-hidden />
|
||||
@@ -222,11 +237,15 @@ export function DevicesPage({ agents, onAfterWake }: Props) {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="grid gap-2 lg:grid-cols-[minmax(16rem,1fr)_11rem_10rem_14rem_10rem]">
|
||||
<label className="grid gap-1 text-sm text-muted-foreground">
|
||||
<label
|
||||
htmlFor="fleet-search"
|
||||
className="grid gap-1 text-sm text-muted-foreground"
|
||||
>
|
||||
<span>Search</span>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-2.5 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="fleet-search"
|
||||
className="pl-8"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
@@ -246,7 +265,10 @@ export function DevicesPage({ agents, onAfterWake }: Props) {
|
||||
values={knownFilters}
|
||||
onChange={(value) => setKnown(value as KnownFilter)}
|
||||
/>
|
||||
<label className="grid gap-1 text-sm text-muted-foreground">
|
||||
<label
|
||||
htmlFor="fleet-agent"
|
||||
className="grid gap-1 text-sm text-muted-foreground"
|
||||
>
|
||||
<span>Agent</span>
|
||||
<Select
|
||||
value={agentId}
|
||||
@@ -334,6 +356,7 @@ export function DevicesPage({ agents, onAfterWake }: Props) {
|
||||
</Card>
|
||||
|
||||
<FleetDeviceDetailsDialog
|
||||
key={details?.device_key ?? "__none"}
|
||||
device={details}
|
||||
knownDevices={knownDevices}
|
||||
open={Boolean(details)}
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Eraser, Keyboard, RotateCcw, Terminal } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import type { Agent } from "@/api";
|
||||
import { AgentSelector, displayAgentLabel } from "@/components/AgentSelector";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { TerminalAccessoryBar } from "@/pages/terminal/TerminalAccessoryBar";
|
||||
import { TerminalSessionTabs } from "@/pages/terminal/TerminalSessionTabs";
|
||||
import { useTerminalSession } from "@/pages/terminal/useTerminalSession";
|
||||
import { useTerminalSessions } from "@/pages/terminal/useTerminalSessions";
|
||||
import { useXterm } from "@/pages/terminal/useXterm";
|
||||
|
||||
type Props = {
|
||||
agents: Agent[];
|
||||
selectedAgentId: string;
|
||||
onSelectAgent: (agentId: string) => void;
|
||||
};
|
||||
|
||||
type TerminalTouch = {
|
||||
pointerId: number;
|
||||
startX: number;
|
||||
startY: number;
|
||||
moved: boolean;
|
||||
};
|
||||
|
||||
const TERMINAL_TOUCH_SLOP_PX = 8;
|
||||
|
||||
export function TerminalPage({
|
||||
agents,
|
||||
selectedAgentId,
|
||||
onSelectAgent,
|
||||
}: Props) {
|
||||
const pageRef = useRef<HTMLElement>(null);
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
const terminalTouchRef = useRef<TerminalTouch | null>(null);
|
||||
const terminalInputFocusedBeforeAccessoryRef = useRef(false);
|
||||
const [nowUnix, setNowUnix] = useState(() => Math.floor(Date.now() / 1000));
|
||||
const [accessoriesOpen, setAccessoriesOpen] = useState(
|
||||
() => window.matchMedia("(pointer: coarse), (max-width: 640px)").matches,
|
||||
);
|
||||
|
||||
const catalog = useTerminalSessions();
|
||||
const { sessions } = catalog;
|
||||
const terminal = useXterm({
|
||||
pageRef,
|
||||
hostRef,
|
||||
});
|
||||
const terminalSession = useTerminalSession({
|
||||
terminal: terminal.terminal,
|
||||
catalog,
|
||||
selectedAgentId,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const clock = window.setInterval(
|
||||
() => setNowUnix(Math.floor(Date.now() / 1000)),
|
||||
30_000,
|
||||
);
|
||||
return () => window.clearInterval(clock);
|
||||
}, []);
|
||||
|
||||
const selectedAgent = agents.find(
|
||||
(agent) => agent.agent_id === selectedAgentId,
|
||||
);
|
||||
const selectedAgentSessionCount = sessions.filter(
|
||||
(item) => item.agent_id === selectedAgentId,
|
||||
).length;
|
||||
const selectedAgentSessionLimit = Math.max(
|
||||
1,
|
||||
selectedAgent?.capability_options?.terminal?.max_sessions ?? 2,
|
||||
);
|
||||
const agentAtSessionLimit =
|
||||
selectedAgentSessionCount >= selectedAgentSessionLimit;
|
||||
const canRequestStart = Boolean(
|
||||
selectedAgent?.connected &&
|
||||
selectedAgent.capabilities.includes("terminal") &&
|
||||
terminalSession.connection !== "connecting" &&
|
||||
terminal.terminal.ready,
|
||||
);
|
||||
const terminalInputReady = terminalSession.connection === "ready";
|
||||
|
||||
function start() {
|
||||
if (agentAtSessionLimit) {
|
||||
toast.error("Terminal session limit reached", {
|
||||
description: `${selectedAgent ? displayAgentLabel(selectedAgent) : selectedAgentId} already reached the active session limit. Close one before opening another.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
void terminalSession.start();
|
||||
}
|
||||
|
||||
function toggleTerminalKeyboard() {
|
||||
const terminalInstance = terminal.terminal;
|
||||
const blurTerminalInput = () => {
|
||||
terminalInstance.blurInput();
|
||||
const activeElement = document.activeElement;
|
||||
if (
|
||||
activeElement instanceof HTMLElement &&
|
||||
hostRef.current?.contains(activeElement)
|
||||
) {
|
||||
activeElement.blur();
|
||||
}
|
||||
};
|
||||
const shouldHide =
|
||||
terminalInputFocusedBeforeAccessoryRef.current ||
|
||||
Boolean(hostRef.current?.contains(document.activeElement));
|
||||
terminalInputFocusedBeforeAccessoryRef.current = false;
|
||||
if (shouldHide) {
|
||||
blurTerminalInput();
|
||||
// Base UI may restore the previously focused element after pointer handling.
|
||||
window.requestAnimationFrame(blurTerminalInput);
|
||||
} else {
|
||||
terminalInstance.focus();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
className="terminal-page"
|
||||
aria-label="Remote terminal"
|
||||
ref={pageRef}
|
||||
>
|
||||
<header className="terminal-toolbar">
|
||||
<div className="terminal-heading">
|
||||
<Terminal className="size-5 text-primary" aria-hidden />
|
||||
<h1>Remote Terminal</h1>
|
||||
</div>
|
||||
|
||||
<div className="terminal-controls">
|
||||
<AgentSelector
|
||||
agents={agents.filter(
|
||||
(agent) =>
|
||||
agent.connected && agent.capabilities.includes("terminal"),
|
||||
)}
|
||||
value={selectedAgentId}
|
||||
onChange={onSelectAgent}
|
||||
disabled={terminalSession.connection === "connecting"}
|
||||
className="w-full min-w-0 sm:w-64"
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{!selectedAgent && terminalSession.connection === "idle" ? (
|
||||
<p className="terminal-notice">
|
||||
No connected terminal-capable agent is selected.
|
||||
</p>
|
||||
) : selectedAgent && !selectedAgent.capabilities.includes("terminal") ? (
|
||||
<p className="terminal-notice">
|
||||
This agent has not enabled remote terminal capability.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="terminal-frame">
|
||||
<div className="terminal-framebar">
|
||||
<TerminalSessionTabs
|
||||
agents={agents}
|
||||
sessions={sessions}
|
||||
activeTerminalId={terminalSession.session?.terminal_id}
|
||||
connection={terminalSession.connection}
|
||||
sessionTitles={terminal.sessionTitles}
|
||||
nowUnix={nowUnix}
|
||||
canRequestStart={canRequestStart}
|
||||
agentAtSessionLimit={agentAtSessionLimit}
|
||||
selectedAgentSessionLimit={selectedAgentSessionLimit}
|
||||
onActivate={(item) => void terminalSession.activate(item)}
|
||||
onClose={(item) => void terminalSession.close(item)}
|
||||
onStart={start}
|
||||
/>
|
||||
|
||||
<div className="terminal-frame-actions">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="terminal-status"
|
||||
data-state={terminalSession.connection}
|
||||
>
|
||||
<span aria-hidden />
|
||||
{terminalSession.connection}
|
||||
</Badge>
|
||||
{terminalSession.connection === "disconnected" ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => void terminalSession.reconnect()}
|
||||
>
|
||||
<RotateCcw className="size-4" aria-hidden />
|
||||
Reconnect
|
||||
</Button>
|
||||
) : null}
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="terminal-accessories-toggle"
|
||||
data-active={accessoriesOpen || undefined}
|
||||
aria-label={
|
||||
accessoriesOpen
|
||||
? "Hide terminal controls"
|
||||
: "Show terminal controls"
|
||||
}
|
||||
aria-expanded={accessoriesOpen}
|
||||
onClick={() => setAccessoriesOpen((current) => !current)}
|
||||
>
|
||||
<Keyboard className="size-4" aria-hidden />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Terminal controls</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
disabled={terminalSession.connection === "idle"}
|
||||
aria-label="Clear terminal"
|
||||
onClick={terminal.terminal.clear}
|
||||
>
|
||||
<Eraser className="size-4" aria-hidden />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Clear terminal</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
{accessoriesOpen ? (
|
||||
<TerminalAccessoryBar
|
||||
inputReady={terminalInputReady}
|
||||
modifiers={terminalSession.modifiers}
|
||||
fontSize={terminal.fontSize}
|
||||
onRememberInputFocus={() => {
|
||||
terminalInputFocusedBeforeAccessoryRef.current = Boolean(
|
||||
hostRef.current?.contains(document.activeElement),
|
||||
);
|
||||
}}
|
||||
onToggleModifier={terminalSession.toggleModifier}
|
||||
onWriteKey={terminalSession.writeAccessoryKey}
|
||||
onScrollPages={terminal.terminal.scrollPages}
|
||||
onCopy={() => void terminal.terminal.copy()}
|
||||
onPaste={() =>
|
||||
void terminal.terminal.paste(terminalSession.writeTerminalData)
|
||||
}
|
||||
onToggleKeyboard={toggleTerminalKeyboard}
|
||||
onChangeFontSize={terminal.terminal.changeFontSize}
|
||||
/>
|
||||
) : null}
|
||||
<div
|
||||
className="terminal-surface"
|
||||
ref={hostRef}
|
||||
onPointerDown={(event) => {
|
||||
if (event.pointerType !== "touch") {
|
||||
terminal.terminal.focus();
|
||||
return;
|
||||
}
|
||||
terminalTouchRef.current = {
|
||||
pointerId: event.pointerId,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
moved: false,
|
||||
};
|
||||
}}
|
||||
onPointerMove={(event) => {
|
||||
const touch = terminalTouchRef.current;
|
||||
if (!touch || touch.pointerId !== event.pointerId || touch.moved) {
|
||||
return;
|
||||
}
|
||||
touch.moved =
|
||||
Math.hypot(
|
||||
event.clientX - touch.startX,
|
||||
event.clientY - touch.startY,
|
||||
) > TERMINAL_TOUCH_SLOP_PX;
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
const touch = terminalTouchRef.current;
|
||||
terminalTouchRef.current = null;
|
||||
if (
|
||||
touch?.pointerId === event.pointerId &&
|
||||
!touch.moved &&
|
||||
event.pointerType === "touch"
|
||||
) {
|
||||
terminal.terminal.focus();
|
||||
}
|
||||
}}
|
||||
onPointerCancel={() => {
|
||||
terminalTouchRef.current = null;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -118,7 +118,7 @@ export function TokensPage() {
|
||||
return (
|
||||
<section className="grid gap-4 xl:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-3 space-y-0">
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-3 gap-y-0">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Key className="size-5 text-primary" />
|
||||
@@ -219,9 +219,13 @@ export function TokensPage() {
|
||||
</Button>
|
||||
</div>
|
||||
{useCustom && (
|
||||
<label className="mt-2 grid gap-1 text-sm text-muted-foreground">
|
||||
<label
|
||||
htmlFor="token-custom-ttl"
|
||||
className="mt-2 grid gap-1 text-sm text-muted-foreground"
|
||||
>
|
||||
<span>TTL (seconds)</span>
|
||||
<Input
|
||||
id="token-custom-ttl"
|
||||
type="number"
|
||||
min={1}
|
||||
value={customTtl}
|
||||
|
||||
@@ -101,7 +101,10 @@ export function WakeToolsPage({
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<label className="grid gap-1.5 text-sm text-muted-foreground">
|
||||
<label
|
||||
htmlFor="wake-agent"
|
||||
className="grid gap-1.5 text-sm text-muted-foreground"
|
||||
>
|
||||
<span>Agent</span>
|
||||
<AgentSelector
|
||||
agents={agents}
|
||||
@@ -111,14 +114,17 @@ export function WakeToolsPage({
|
||||
</label>
|
||||
|
||||
<form onSubmit={handleWake} className="grid gap-3">
|
||||
<label className="grid gap-1.5 text-sm text-muted-foreground">
|
||||
<label
|
||||
htmlFor="wake-target"
|
||||
className="grid gap-1.5 text-sm text-muted-foreground"
|
||||
>
|
||||
<span>Target</span>
|
||||
<Input
|
||||
id="wake-target"
|
||||
value={target}
|
||||
onChange={(e) => setTarget(e.target.value)}
|
||||
placeholder="bedroom-pc, 192.168.1.100, or aa:bb:cc:dd:ee:ff"
|
||||
disabled={busy}
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
<Button
|
||||
@@ -134,7 +140,7 @@ export function WakeToolsPage({
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-2 space-y-0">
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-2 gap-y-0">
|
||||
<CardTitle>Recent Wakes</CardTitle>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -147,10 +153,10 @@ export function WakeToolsPage({
|
||||
<CardContent>
|
||||
{recentWakes.length > 0 ? (
|
||||
<div className="grid gap-0">
|
||||
{recentWakes.map((event, idx) => (
|
||||
{recentWakes.map((event, i) => (
|
||||
<div
|
||||
className={`flex items-start justify-between gap-3 py-2.5 ${idx < recentWakes.length - 1 ? "border-b border-border/50" : ""}`}
|
||||
key={`${event.ts}-${idx}`}
|
||||
className={`flex items-start justify-between gap-3 py-2.5 ${i < recentWakes.length - 1 ? "border-b border-border/50" : ""}`}
|
||||
key={`${event.ts}-${event.target}-${event.agentId}`}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Minus, Plus, Zap } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Copy, Minus, Plus, Zap } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
detachDeviceIdentifier,
|
||||
mergeKnownDevice,
|
||||
type FleetDevice,
|
||||
type FleetDeviceEndpoint,
|
||||
type KnownDevice,
|
||||
} from "@/api";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -29,7 +30,13 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { DetailBlock, PresenceBadge } from "@/pages/fleet/FleetComponents";
|
||||
import { agentLabel, identifiersFor, routeLabel } from "@/pages/fleet/utils";
|
||||
import {
|
||||
agentLabel,
|
||||
formatSeen,
|
||||
identifiersFor,
|
||||
presenceRank,
|
||||
routeLabel,
|
||||
} from "@/pages/fleet/utils";
|
||||
|
||||
type Props = {
|
||||
device: FleetDevice | null;
|
||||
@@ -52,24 +59,16 @@ export function FleetDeviceDetailsDialog({
|
||||
onCopy,
|
||||
onChanged,
|
||||
}: Props) {
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [displayName, setDisplayName] = useState(device?.display_name ?? "");
|
||||
const [targetDeviceId, setTargetDeviceId] = useState("");
|
||||
const [routeId, setRouteId] = useState("");
|
||||
const [routeId, setRouteId] = useState(
|
||||
device?.recommended_route?.route_id ?? "",
|
||||
);
|
||||
const [addKind, setAddKind] = useState<"mac" | "ip">("mac");
|
||||
const [addValue, setAddValue] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [actionBusy, setActionBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setDisplayName(device?.display_name ?? "");
|
||||
setTargetDeviceId("");
|
||||
setRouteId(device?.recommended_route?.route_id ?? "");
|
||||
setAddKind("mac");
|
||||
setAddValue("");
|
||||
setError("");
|
||||
setActionBusy(false);
|
||||
}, [device]);
|
||||
|
||||
const rowIdentifiers = device ? identifiersFor(device) : [];
|
||||
|
||||
// Resolve the full KnownDevice object if this fleet row is known
|
||||
@@ -191,7 +190,7 @@ export function FleetDeviceDetailsDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[92vh] overflow-auto sm:max-w-3xl">
|
||||
<DialogContent className="max-h-[92vh] overflow-auto sm:max-w-4xl">
|
||||
<DialogHeader>
|
||||
<div className="flex min-w-0 items-start justify-between gap-3 pr-8">
|
||||
<div className="min-w-0">
|
||||
@@ -233,6 +232,42 @@ export function FleetDeviceDetailsDialog({
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-medium">Endpoints</h3>
|
||||
<Badge variant="outline">{device.endpoints.length}</Badge>
|
||||
</div>
|
||||
{device.endpoints.length ? (
|
||||
<div className="grid gap-1.5">
|
||||
<div className="hidden grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)_8rem_2rem] items-center gap-2 px-3 py-1.5 text-xs font-medium text-muted-foreground sm:grid">
|
||||
<span>Source</span>
|
||||
<span>Target</span>
|
||||
<span className="text-right">Last seen</span>
|
||||
<span />
|
||||
</div>
|
||||
{[...device.endpoints]
|
||||
.sort((a, b) => {
|
||||
const p = presenceRank(b.presence) - presenceRank(a.presence);
|
||||
if (p !== 0) return p;
|
||||
return (b.last_seen_unix ?? 0) - (a.last_seen_unix ?? 0);
|
||||
})
|
||||
.map((endpoint, index) => (
|
||||
<EndpointRow
|
||||
key={`${endpoint.agent_id}:${endpoint.source}:${endpoint.mac ?? ""}:${endpoint.ip ?? ""}:${index}`}
|
||||
endpoint={endpoint}
|
||||
onCopy={onCopy}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="rounded-md border bg-muted/30 p-3 text-sm text-muted-foreground">
|
||||
No endpoint evidence reported.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* ── Wake route ── */}
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
@@ -279,7 +314,7 @@ export function FleetDeviceDetailsDialog({
|
||||
<span className="font-medium">{fullKnown.display_name}</span>
|
||||
{fullKnown.notes && (
|
||||
<span className="text-muted-foreground">
|
||||
— {fullKnown.notes}
|
||||
: {fullKnown.notes}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -521,3 +556,52 @@ export function FleetDeviceDetailsDialog({
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function EndpointRow({
|
||||
endpoint,
|
||||
onCopy,
|
||||
}: {
|
||||
endpoint: FleetDeviceEndpoint;
|
||||
onCopy: (label: string, value: string) => void;
|
||||
}) {
|
||||
const target = [endpoint.mac, endpoint.ip].filter(Boolean).join(" / ");
|
||||
const location = [endpoint.interface, endpoint.hostname]
|
||||
.filter(Boolean)
|
||||
.join(" / ");
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 rounded-md border bg-card px-3 py-2.5 text-sm sm:grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)_8rem_2rem] sm:items-center sm:gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
|
||||
<PresenceBadge presence={endpoint.presence} />
|
||||
<Badge variant="secondary">{endpoint.source.replace("_", " ")}</Badge>
|
||||
</div>
|
||||
<p className="mt-1 truncate text-xs text-muted-foreground">
|
||||
{agentLabel(endpoint.agent_id, endpoint.nickname)}
|
||||
{endpoint.connected ? " (connected)" : " (offline)"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-mono text-xs">{target || "-"}</p>
|
||||
<p className="mt-0.5 truncate text-xs text-muted-foreground">
|
||||
{location || "No interface or hostname"}
|
||||
</p>
|
||||
</div>
|
||||
<span className="whitespace-nowrap text-right text-xs text-muted-foreground">
|
||||
{formatSeen(endpoint.last_seen_unix)}
|
||||
</span>
|
||||
<div className="flex justify-end">
|
||||
{target && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => onCopy("Endpoint", target)}
|
||||
aria-label={`Copy endpoint ${target}`}
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,8 +14,8 @@ export const presenceFilters = [
|
||||
"all",
|
||||
"online",
|
||||
"likely_online",
|
||||
"unknown",
|
||||
"offline",
|
||||
"unknown",
|
||||
] as const;
|
||||
|
||||
export const knownFilters: KnownFilter[] = ["all", "known", "unknown"];
|
||||
@@ -40,8 +40,8 @@ export function formatSeen(value: number | null): string {
|
||||
export function presenceRank(presence: string): number {
|
||||
if (presence === "online") return 4;
|
||||
if (presence === "likely_online") return 3;
|
||||
if (presence === "unknown") return 2;
|
||||
if (presence === "offline") return 1;
|
||||
if (presence === "offline") return 2;
|
||||
if (presence === "unknown") return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ export function agentLabel(agentId: string, nickname?: string | null): string {
|
||||
}
|
||||
|
||||
export function routeLabel(route: FleetWakeRoute): string {
|
||||
const status = route.connected ? "connected" : "offline";
|
||||
const status = route.connected ? "connected" : "agent offline";
|
||||
const target = route.mac
|
||||
? `${route.mac}${route.ip ? ` / ${route.ip}` : ""}`
|
||||
: (route.ip ?? "-");
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
ArrowUp,
|
||||
ClipboardCopy,
|
||||
ClipboardPaste,
|
||||
Keyboard,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { MouseEvent, PointerEvent } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { TerminalModifiers } from "@/pages/terminal/sessionUtils";
|
||||
|
||||
export const MIN_TERMINAL_FONT_SIZE = 10;
|
||||
export const MAX_TERMINAL_FONT_SIZE = 22;
|
||||
|
||||
type Props = {
|
||||
inputReady: boolean;
|
||||
modifiers: TerminalModifiers;
|
||||
fontSize: number;
|
||||
onRememberInputFocus: () => void;
|
||||
onToggleModifier: (modifier: keyof TerminalModifiers) => void;
|
||||
onWriteKey: (data: string) => void;
|
||||
onScrollPages: (pages: number) => void;
|
||||
onCopy: () => void;
|
||||
onPaste: () => void;
|
||||
onToggleKeyboard: () => void;
|
||||
onChangeFontSize: (delta: number) => void;
|
||||
};
|
||||
|
||||
const CONTROL_KEYS: ReadonlyArray<
|
||||
readonly [label: string, sequence: string, title: string]
|
||||
> = [
|
||||
["^C", "\x03", "Interrupt"],
|
||||
["^U", "\x15", "Clear input line"],
|
||||
["^A", "\x01", "Start of line"],
|
||||
["^E", "\x05", "End of line"],
|
||||
];
|
||||
|
||||
const ARROW_KEYS: ReadonlyArray<
|
||||
readonly [label: string, sequence: string, icon: LucideIcon]
|
||||
> = [
|
||||
["Left arrow", "\x1b[D", ArrowLeft],
|
||||
["Down arrow", "\x1b[B", ArrowDown],
|
||||
["Up arrow", "\x1b[A", ArrowUp],
|
||||
["Right arrow", "\x1b[C", ArrowRight],
|
||||
];
|
||||
|
||||
function preserveInputFocus(
|
||||
event: PointerEvent<HTMLDivElement> | MouseEvent<HTMLDivElement>,
|
||||
onRememberInputFocus: () => void,
|
||||
) {
|
||||
if (event.target instanceof Element && event.target.closest("button")) {
|
||||
onRememberInputFocus();
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
export function TerminalAccessoryBar({
|
||||
inputReady,
|
||||
modifiers,
|
||||
fontSize,
|
||||
onRememberInputFocus,
|
||||
onToggleModifier,
|
||||
onWriteKey,
|
||||
onScrollPages,
|
||||
onCopy,
|
||||
onPaste,
|
||||
onToggleKeyboard,
|
||||
onChangeFontSize,
|
||||
}: Props) {
|
||||
return (
|
||||
<div
|
||||
className="terminal-accessories"
|
||||
role="toolbar"
|
||||
aria-label="Terminal controls"
|
||||
onPointerDownCapture={(event) => {
|
||||
// Preserve whether xterm was focused before the button library moves
|
||||
// focus. The keyboard button consumes this to show or hide mobile IME.
|
||||
preserveInputFocus(event, onRememberInputFocus);
|
||||
}}
|
||||
onMouseDownCapture={(event) => {
|
||||
// Firefox can take the mouse-event path without emitting pointerdown.
|
||||
preserveInputFocus(event, onRememberInputFocus);
|
||||
}}
|
||||
>
|
||||
<div className="terminal-accessory-group">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="terminal-key"
|
||||
data-active={modifiers.ctrl || undefined}
|
||||
aria-pressed={modifiers.ctrl}
|
||||
disabled={!inputReady}
|
||||
onClick={() => onToggleModifier("ctrl")}
|
||||
>
|
||||
Ctrl
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="terminal-key"
|
||||
data-active={modifiers.meta || undefined}
|
||||
aria-pressed={modifiers.meta}
|
||||
disabled={!inputReady}
|
||||
onClick={() => onToggleModifier("meta")}
|
||||
>
|
||||
Meta
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="terminal-key"
|
||||
disabled={!inputReady}
|
||||
onClick={() => onWriteKey("\x1b")}
|
||||
>
|
||||
Esc
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="terminal-key"
|
||||
disabled={!inputReady}
|
||||
onClick={() => onWriteKey("\t")}
|
||||
>
|
||||
Tab
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="terminal-accessory-group">
|
||||
{CONTROL_KEYS.map(([label, sequence, title]) => (
|
||||
<Button
|
||||
key={label}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="terminal-key terminal-key--control"
|
||||
aria-label={title}
|
||||
title={title}
|
||||
disabled={!inputReady}
|
||||
onClick={() => onWriteKey(sequence)}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="terminal-accessory-group">
|
||||
{ARROW_KEYS.map(([label, sequence, Icon]) => (
|
||||
<Button
|
||||
key={label}
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="terminal-key terminal-key--icon"
|
||||
aria-label={label}
|
||||
disabled={!inputReady}
|
||||
onClick={() => onWriteKey(sequence)}
|
||||
>
|
||||
<Icon className="size-4" aria-hidden />
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="terminal-accessory-group">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="terminal-key terminal-key--wide"
|
||||
onClick={() => onScrollPages(-1)}
|
||||
>
|
||||
PgUp
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="terminal-key terminal-key--wide"
|
||||
onClick={() => onScrollPages(1)}
|
||||
>
|
||||
PgDn
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="terminal-accessory-group">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="terminal-key terminal-key--icon"
|
||||
aria-label="Copy selection or visible screen"
|
||||
title="Copy selection or visible screen"
|
||||
onClick={onCopy}
|
||||
>
|
||||
<ClipboardCopy className="size-4" aria-hidden />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="terminal-key terminal-key--icon"
|
||||
aria-label="Paste"
|
||||
title="Paste"
|
||||
disabled={!inputReady}
|
||||
onClick={onPaste}
|
||||
>
|
||||
<ClipboardPaste className="size-4" aria-hidden />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="terminal-key terminal-key--icon"
|
||||
aria-label="Toggle terminal keyboard"
|
||||
title="Toggle terminal keyboard"
|
||||
disabled={!inputReady}
|
||||
onClick={onToggleKeyboard}
|
||||
>
|
||||
<Keyboard className="size-4" aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="terminal-accessory-group terminal-font-controls">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="terminal-key terminal-key--icon"
|
||||
disabled={fontSize <= MIN_TERMINAL_FONT_SIZE}
|
||||
aria-label="Decrease terminal text size"
|
||||
onClick={() => onChangeFontSize(-1)}
|
||||
>
|
||||
<ZoomOut className="size-4" aria-hidden />
|
||||
</Button>
|
||||
<output aria-label="Terminal text size">{fontSize}</output>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="terminal-key terminal-key--icon"
|
||||
disabled={fontSize >= MAX_TERMINAL_FONT_SIZE}
|
||||
aria-label="Increase terminal text size"
|
||||
onClick={() => onChangeFontSize(1)}
|
||||
>
|
||||
<ZoomIn className="size-4" aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import { Plus, Terminal, X } from "lucide-react";
|
||||
|
||||
import type { Agent, TerminalSession } from "@/api";
|
||||
import { displayAgentLabel } from "@/components/AgentSelector";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import type { TerminalConnectionState } from "@/pages/terminal/sessionUtils";
|
||||
|
||||
type Props = {
|
||||
agents: Agent[];
|
||||
sessions: TerminalSession[];
|
||||
activeTerminalId?: string;
|
||||
connection: TerminalConnectionState;
|
||||
sessionTitles: Record<string, string>;
|
||||
nowUnix: number;
|
||||
canRequestStart: boolean;
|
||||
agentAtSessionLimit: boolean;
|
||||
selectedAgentSessionLimit: number;
|
||||
onActivate: (session: TerminalSession) => void;
|
||||
onClose: (session: TerminalSession) => void;
|
||||
onStart: () => void;
|
||||
};
|
||||
|
||||
function terminalCreatedTime(createdAtUnix: number): string {
|
||||
return new Date(createdAtUnix * 1000).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function terminalTimestamp(timestampUnix: number): string {
|
||||
return new Date(timestampUnix * 1000).toLocaleString();
|
||||
}
|
||||
|
||||
function terminalExpiryDisplay(
|
||||
expiresAtUnix: number | null | undefined,
|
||||
nowUnix: number,
|
||||
): { label: string; warning: boolean } | null {
|
||||
if (expiresAtUnix == null) return null;
|
||||
const remainingSeconds = expiresAtUnix - nowUnix;
|
||||
if (remainingSeconds <= 0) return { label: "expired", warning: true };
|
||||
if (remainingSeconds < 60) return { label: "<1m left", warning: true };
|
||||
if (remainingSeconds < 3600) {
|
||||
return {
|
||||
label: `${Math.floor(remainingSeconds / 60)}m left`,
|
||||
warning: remainingSeconds < 10 * 60,
|
||||
};
|
||||
}
|
||||
if (remainingSeconds < 24 * 3600) {
|
||||
return {
|
||||
label: `${Math.floor(remainingSeconds / 3600)}h left`,
|
||||
warning: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
label: `${Math.floor(remainingSeconds / (24 * 3600))}d left`,
|
||||
warning: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function TerminalSessionTabs({
|
||||
agents,
|
||||
sessions,
|
||||
activeTerminalId,
|
||||
connection,
|
||||
sessionTitles,
|
||||
nowUnix,
|
||||
canRequestStart,
|
||||
agentAtSessionLimit,
|
||||
selectedAgentSessionLimit,
|
||||
onActivate,
|
||||
onClose,
|
||||
onStart,
|
||||
}: Props) {
|
||||
return (
|
||||
<div className="terminal-tabs">
|
||||
<div
|
||||
className="terminal-tab-list"
|
||||
role="tablist"
|
||||
aria-label="Terminal sessions"
|
||||
>
|
||||
{sessions.length === 0 ? (
|
||||
<div className="terminal-session-label">
|
||||
<Terminal className="size-4" aria-hidden />
|
||||
<span>No active session</span>
|
||||
</div>
|
||||
) : (
|
||||
sessions.map((item) => {
|
||||
const agent = agents.find(
|
||||
(candidate) => candidate.agent_id === item.agent_id,
|
||||
);
|
||||
const active = item.terminal_id === activeTerminalId;
|
||||
const locked = item.operator_attached && !active;
|
||||
const agentLabel = agent ? displayAgentLabel(agent) : item.agent_id;
|
||||
const sessionTitle = sessionTitles[item.terminal_id];
|
||||
const tabLabel = sessionTitle
|
||||
? `${agentLabel} · ${sessionTitle}`
|
||||
: agentLabel;
|
||||
const expiry = terminalExpiryDisplay(item.expires_at_unix, nowUnix);
|
||||
const timingTitle =
|
||||
item.expires_at_unix != null
|
||||
? `Expires ${terminalTimestamp(item.expires_at_unix)}`
|
||||
: "No expiry";
|
||||
return (
|
||||
<div
|
||||
key={item.terminal_id}
|
||||
role="presentation"
|
||||
className="terminal-tab"
|
||||
data-active={active || undefined}
|
||||
data-locked={locked || undefined}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
className="terminal-tab-select"
|
||||
disabled={locked || connection === "connecting"}
|
||||
title={`${locked ? "Attached in another browser\n" : ""}${tabLabel}\nCreated ${terminalTimestamp(item.created_at_unix)}\n${timingTitle}`}
|
||||
onClick={() => onActivate(item)}
|
||||
>
|
||||
<span
|
||||
className="terminal-tab-dot"
|
||||
data-state={
|
||||
active
|
||||
? connection
|
||||
: locked
|
||||
? "attached"
|
||||
: item.agent_attached
|
||||
? "detached"
|
||||
: "disconnected"
|
||||
}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="terminal-tab-label">{tabLabel}</span>
|
||||
<time
|
||||
className={
|
||||
expiry?.warning ? "text-amber-400" : "text-[#7f8d9a]"
|
||||
}
|
||||
dateTime={new Date(
|
||||
(item.expires_at_unix ?? item.created_at_unix) * 1000,
|
||||
).toISOString()}
|
||||
>
|
||||
{expiry?.label ?? terminalCreatedTime(item.created_at_unix)}
|
||||
</time>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="terminal-tab-close"
|
||||
disabled={locked || connection === "connecting"}
|
||||
aria-label={`Close ${agentLabel} terminal session`}
|
||||
title={
|
||||
locked
|
||||
? "Attached in another browser"
|
||||
: connection === "connecting"
|
||||
? "Connecting — wait before closing"
|
||||
: "Close session"
|
||||
}
|
||||
onClick={() => onClose(item)}
|
||||
>
|
||||
<X className="size-3.5" aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="terminal-new-tab"
|
||||
disabled={!canRequestStart}
|
||||
aria-label="New terminal session"
|
||||
onClick={onStart}
|
||||
>
|
||||
<Plus className="size-4" aria-hidden />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{agentAtSessionLimit
|
||||
? `${selectedAgentSessionLimit}-session limit reached`
|
||||
: "New session"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { Terminal as XTerm } from "@xterm/xterm";
|
||||
|
||||
import type { TerminalSession } from "@/api";
|
||||
|
||||
export type TerminalConnectionState =
|
||||
| "idle"
|
||||
| "connecting"
|
||||
| "ready"
|
||||
| "disconnected"
|
||||
| "exited";
|
||||
|
||||
export type TerminalModifiers = {
|
||||
ctrl: boolean;
|
||||
meta: boolean;
|
||||
};
|
||||
|
||||
export const NO_TERMINAL_MODIFIERS: TerminalModifiers = {
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
};
|
||||
|
||||
export function mergeTerminalSession(
|
||||
sessions: TerminalSession[],
|
||||
next: TerminalSession,
|
||||
): TerminalSession[] {
|
||||
const existingIndex = sessions.findIndex(
|
||||
(item) => item.terminal_id === next.terminal_id,
|
||||
);
|
||||
if (existingIndex >= 0) {
|
||||
// Attaching updates session state, not identity or tab position. Removing
|
||||
// and re-inserting here made same-second sessions swap places on click.
|
||||
return sessions.map((item, index) =>
|
||||
index === existingIndex ? next : item,
|
||||
);
|
||||
}
|
||||
return orderTerminalSessions([...sessions, next]);
|
||||
}
|
||||
|
||||
function compareTerminalSessions(
|
||||
left: TerminalSession,
|
||||
right: TerminalSession,
|
||||
): number {
|
||||
return (
|
||||
left.created_at_unix - right.created_at_unix ||
|
||||
left.terminal_id.localeCompare(right.terminal_id)
|
||||
);
|
||||
}
|
||||
|
||||
export function orderTerminalSessions(
|
||||
sessions: TerminalSession[],
|
||||
): TerminalSession[] {
|
||||
return [...sessions].sort(compareTerminalSessions);
|
||||
}
|
||||
|
||||
export function reconcileTerminalSessions(
|
||||
current: TerminalSession[],
|
||||
listed: TerminalSession[],
|
||||
): TerminalSession[] {
|
||||
const listedById = new Map(listed.map((item) => [item.terminal_id, item]));
|
||||
const retained = current.flatMap((item) => {
|
||||
const updated = listedById.get(item.terminal_id);
|
||||
if (!updated) return [];
|
||||
listedById.delete(item.terminal_id);
|
||||
return [updated];
|
||||
});
|
||||
return [...retained, ...orderTerminalSessions([...listedById.values()])];
|
||||
}
|
||||
|
||||
export function restoreCandidates(
|
||||
sessions: TerminalSession[],
|
||||
rememberedId: string | null,
|
||||
selectedAgentId: string,
|
||||
): TerminalSession[] {
|
||||
const remembered = sessions.find((item) => item.terminal_id === rememberedId);
|
||||
const available = sessions.filter(
|
||||
(item) => !item.operator_attached && item !== remembered,
|
||||
);
|
||||
available.sort((left, right) => {
|
||||
const leftPreferred = left.agent_id === selectedAgentId ? 1 : 0;
|
||||
const rightPreferred = right.agent_id === selectedAgentId ? 1 : 0;
|
||||
return (
|
||||
rightPreferred - leftPreferred ||
|
||||
right.created_at_unix - left.created_at_unix
|
||||
);
|
||||
});
|
||||
return remembered ? [remembered, ...available] : available;
|
||||
}
|
||||
|
||||
export function applyTerminalModifiers(
|
||||
data: string,
|
||||
modifiers: TerminalModifiers,
|
||||
): string {
|
||||
let modified = data;
|
||||
if (modifiers.ctrl && modified.length === 1) {
|
||||
const code = modified.toUpperCase().charCodeAt(0);
|
||||
if (code >= 64 && code <= 95) {
|
||||
modified = String.fromCharCode(code & 0x1f);
|
||||
}
|
||||
}
|
||||
return modifiers.meta ? `\x1b${modified}` : modified;
|
||||
}
|
||||
|
||||
export function visibleTerminalText(terminal: XTerm): string {
|
||||
const buffer = terminal.buffer.active;
|
||||
const firstLine = buffer.viewportY;
|
||||
const lastLine = Math.min(buffer.length, firstLine + terminal.rows);
|
||||
const lines: string[] = [];
|
||||
for (let index = firstLine; index < lastLine; index += 1) {
|
||||
lines.push(buffer.getLine(index)?.translateToString(true) ?? "");
|
||||
}
|
||||
while (lines.at(-1) === "") lines.pop();
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
APIError,
|
||||
attachTerminal,
|
||||
closeTerminal,
|
||||
createTerminal,
|
||||
type TerminalSession,
|
||||
} from "@/api";
|
||||
import {
|
||||
NO_TERMINAL_MODIFIERS,
|
||||
applyTerminalModifiers,
|
||||
orderTerminalSessions,
|
||||
restoreCandidates,
|
||||
type TerminalConnectionState,
|
||||
type TerminalModifiers,
|
||||
} from "@/pages/terminal/sessionUtils";
|
||||
import type { TerminalCatalog } from "@/pages/terminal/useTerminalSessions";
|
||||
import type { TerminalPort } from "@/pages/terminal/useXterm";
|
||||
|
||||
const REMEMBERED_TERMINAL_KEY = "wakey.active-terminal-id";
|
||||
const TERMINAL_OPERATOR_KEY = "wakey.terminal-operator-id";
|
||||
|
||||
// Fallback for browsers that do not implement crypto.randomUUID().
|
||||
const randomId = () =>
|
||||
(String(1e7) + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, (c: string) => {
|
||||
const num = Number(c);
|
||||
return (
|
||||
num ^
|
||||
(window.crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (num / 4)))
|
||||
).toString(16);
|
||||
});
|
||||
|
||||
function terminalOperatorId(): string {
|
||||
const remembered = window.sessionStorage.getItem(TERMINAL_OPERATOR_KEY);
|
||||
if (remembered) return remembered;
|
||||
const created = window.crypto.randomUUID?.() ?? randomId();
|
||||
window.sessionStorage.setItem(TERMINAL_OPERATOR_KEY, created);
|
||||
return created;
|
||||
}
|
||||
|
||||
function websocketUrl(path: string): string {
|
||||
const scheme = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
return `${scheme}//${window.location.host}${path}`;
|
||||
}
|
||||
|
||||
type Options = {
|
||||
terminal: TerminalPort;
|
||||
catalog: TerminalCatalog;
|
||||
selectedAgentId: string;
|
||||
};
|
||||
|
||||
export function useTerminalSession({
|
||||
terminal,
|
||||
catalog,
|
||||
selectedAgentId,
|
||||
}: Options) {
|
||||
const socketRef = useRef<WebSocket | null>(null);
|
||||
const activeSessionRef = useRef<TerminalSession | null>(null);
|
||||
const mountedRef = useRef(false);
|
||||
const selectedAgentIdRef = useRef(selectedAgentId);
|
||||
const catalogRef = useRef(catalog);
|
||||
const [session, setSession] = useState<TerminalSession | null>(null);
|
||||
const [connection, setConnection] = useState<TerminalConnectionState>("idle");
|
||||
const [modifiers, setModifiers] = useState<TerminalModifiers>(
|
||||
NO_TERMINAL_MODIFIERS,
|
||||
);
|
||||
const [operatorId] = useState(terminalOperatorId);
|
||||
|
||||
selectedAgentIdRef.current = selectedAgentId;
|
||||
catalogRef.current = catalog;
|
||||
activeSessionRef.current = session;
|
||||
|
||||
const detachTransport = useCallback(() => {
|
||||
const socket = socketRef.current;
|
||||
socketRef.current = null;
|
||||
socket?.close();
|
||||
}, []);
|
||||
|
||||
const writeTerminalData = useCallback(
|
||||
(data: string, consumeModifiers = false) => {
|
||||
const socket = socketRef.current;
|
||||
if (socket?.readyState !== WebSocket.OPEN) return;
|
||||
const output = consumeModifiers
|
||||
? applyTerminalModifiers(data, modifiersRef.current)
|
||||
: data;
|
||||
socket.send(new TextEncoder().encode(output));
|
||||
if (
|
||||
consumeModifiers &&
|
||||
(modifiersRef.current.ctrl || modifiersRef.current.meta)
|
||||
) {
|
||||
modifiersRef.current = NO_TERMINAL_MODIFIERS;
|
||||
setModifiers(NO_TERMINAL_MODIFIERS);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const modifiersRef = useRef(modifiers);
|
||||
modifiersRef.current = modifiers;
|
||||
|
||||
const connect = useCallback(
|
||||
(nextSession: TerminalSession) => {
|
||||
if (!nextSession.attachment_token) {
|
||||
throw new Error(
|
||||
"Control plane did not issue a terminal attachment token",
|
||||
);
|
||||
}
|
||||
detachTransport();
|
||||
setSession(nextSession);
|
||||
activeSessionRef.current = nextSession;
|
||||
terminal.setActiveTerminalId(nextSession.terminal_id);
|
||||
catalogRef.current.merge({ ...nextSession, operator_attached: true });
|
||||
window.sessionStorage.setItem(
|
||||
REMEMBERED_TERMINAL_KEY,
|
||||
nextSession.terminal_id,
|
||||
);
|
||||
setConnection("connecting");
|
||||
const socket = new WebSocket(websocketUrl(nextSession.websocket_url));
|
||||
socket.binaryType = "arraybuffer";
|
||||
socketRef.current = socket;
|
||||
|
||||
socket.onopen = () => {
|
||||
if (socketRef.current !== socket) return;
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "attach",
|
||||
attachment_token: nextSession.attachment_token,
|
||||
operator_id: operatorId,
|
||||
}),
|
||||
);
|
||||
terminal.fit();
|
||||
terminal.sendResize((message) => socket.send(message));
|
||||
};
|
||||
socket.onmessage = (event) => {
|
||||
if (socketRef.current !== socket) return;
|
||||
if (typeof event.data !== "string") {
|
||||
terminal.write(new Uint8Array(event.data as ArrayBuffer));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const control = JSON.parse(event.data) as {
|
||||
type: string;
|
||||
exit_code?: number | null;
|
||||
message?: string;
|
||||
};
|
||||
if (control.type === "ready") {
|
||||
setConnection("ready");
|
||||
void catalogRef.current.refresh().catch(() => undefined);
|
||||
window.requestAnimationFrame(() => {
|
||||
terminal.fit();
|
||||
terminal.sendResize((message) => socket.send(message), true);
|
||||
terminal.focus();
|
||||
});
|
||||
} else if (control.type === "exited") {
|
||||
setConnection("exited");
|
||||
terminal.writeLine(
|
||||
`\r\n[process exited${control.exit_code == null ? "" : ` ${control.exit_code}`}]`,
|
||||
);
|
||||
} else if (control.type === "error") {
|
||||
setConnection("exited");
|
||||
terminal.writeLine(
|
||||
`\r\n[terminal error: ${control.message ?? "unknown error"}]`,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
terminal.writeLine("\r\n[invalid terminal control frame]");
|
||||
}
|
||||
};
|
||||
socket.onerror = () => {
|
||||
if (socketRef.current !== socket) return;
|
||||
terminal.writeLine("\r\n[terminal transport error]");
|
||||
};
|
||||
socket.onclose = () => {
|
||||
if (socketRef.current !== socket) return;
|
||||
socketRef.current = null;
|
||||
catalogRef.current.markDetached(nextSession.terminal_id);
|
||||
setConnection((current) =>
|
||||
current === "exited" ? current : "disconnected",
|
||||
);
|
||||
void catalogRef.current.refresh().catch(() => undefined);
|
||||
};
|
||||
},
|
||||
[detachTransport, operatorId, terminal],
|
||||
);
|
||||
|
||||
const activate = useCallback(
|
||||
async (nextSession: TerminalSession) => {
|
||||
if (nextSession.terminal_id === activeSessionRef.current?.terminal_id)
|
||||
return;
|
||||
if (nextSession.operator_attached) return;
|
||||
|
||||
const previousSession = activeSessionRef.current;
|
||||
detachTransport();
|
||||
if (previousSession)
|
||||
catalogRef.current.markDetached(previousSession.terminal_id);
|
||||
setSession(null);
|
||||
activeSessionRef.current = null;
|
||||
terminal.setActiveTerminalId(undefined);
|
||||
setConnection("connecting");
|
||||
terminal.reset();
|
||||
window.sessionStorage.setItem(
|
||||
REMEMBERED_TERMINAL_KEY,
|
||||
nextSession.terminal_id,
|
||||
);
|
||||
|
||||
try {
|
||||
const attached = await attachTerminal(
|
||||
nextSession.terminal_id,
|
||||
operatorId,
|
||||
);
|
||||
if (!mountedRef.current) return;
|
||||
connect(attached);
|
||||
} catch (error) {
|
||||
if (!mountedRef.current) return;
|
||||
setConnection("idle");
|
||||
toast.error("Could not attach terminal session", {
|
||||
description: String(error),
|
||||
});
|
||||
void catalogRef.current.refresh().catch(() => undefined);
|
||||
}
|
||||
},
|
||||
[connect, detachTransport, operatorId, terminal],
|
||||
);
|
||||
|
||||
const start = useCallback(async () => {
|
||||
const dimensions = terminal.dimensions();
|
||||
if (!selectedAgentIdRef.current || !dimensions) return;
|
||||
const previousSession = activeSessionRef.current;
|
||||
detachTransport();
|
||||
if (previousSession)
|
||||
catalogRef.current.markDetached(previousSession.terminal_id);
|
||||
setConnection("connecting");
|
||||
terminal.reset();
|
||||
try {
|
||||
terminal.fit();
|
||||
const fittedDimensions = terminal.dimensions() ?? dimensions;
|
||||
const created = await createTerminal(
|
||||
selectedAgentIdRef.current,
|
||||
fittedDimensions.rows,
|
||||
fittedDimensions.cols,
|
||||
);
|
||||
if (!mountedRef.current) return;
|
||||
connect(created);
|
||||
} catch (error) {
|
||||
if (!mountedRef.current) return;
|
||||
setSession(null);
|
||||
activeSessionRef.current = null;
|
||||
terminal.setActiveTerminalId(undefined);
|
||||
setConnection("idle");
|
||||
toast.error("Could not start terminal", { description: String(error) });
|
||||
}
|
||||
}, [connect, detachTransport, terminal]);
|
||||
|
||||
const reconnect = useCallback(async () => {
|
||||
const currentSession = activeSessionRef.current;
|
||||
if (!currentSession) return;
|
||||
detachTransport();
|
||||
try {
|
||||
terminal.reset();
|
||||
setConnection("connecting");
|
||||
const attached = await attachTerminal(
|
||||
currentSession.terminal_id,
|
||||
operatorId,
|
||||
);
|
||||
if (!mountedRef.current) return;
|
||||
connect(attached);
|
||||
} catch (error) {
|
||||
if (!mountedRef.current) return;
|
||||
setConnection("disconnected");
|
||||
toast.error("Terminal session is no longer available", {
|
||||
description: String(error),
|
||||
});
|
||||
}
|
||||
}, [connect, detachTransport, operatorId, terminal]);
|
||||
|
||||
const close = useCallback(
|
||||
async (closingSession: TerminalSession) => {
|
||||
const closingId = closingSession.terminal_id;
|
||||
const closesActiveSession =
|
||||
closingId === activeSessionRef.current?.terminal_id;
|
||||
const remaining = catalogRef.current
|
||||
.getSessions()
|
||||
.filter((item) => item.terminal_id !== closingId);
|
||||
const fallback = remaining.find((item) => !item.operator_attached);
|
||||
|
||||
if (closesActiveSession) {
|
||||
if (socketRef.current?.readyState === WebSocket.OPEN) {
|
||||
socketRef.current.send(JSON.stringify({ type: "close" }));
|
||||
}
|
||||
detachTransport();
|
||||
}
|
||||
try {
|
||||
await closeTerminal(closingId);
|
||||
} catch (error) {
|
||||
if (!mountedRef.current) return;
|
||||
toast.error("Terminal cleanup failed", { description: String(error) });
|
||||
void catalogRef.current.refresh().catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
if (!mountedRef.current) return;
|
||||
|
||||
catalogRef.current.remove(closingId);
|
||||
terminal.forgetTitle(closingId);
|
||||
if (
|
||||
window.sessionStorage.getItem(REMEMBERED_TERMINAL_KEY) === closingId
|
||||
) {
|
||||
window.sessionStorage.removeItem(REMEMBERED_TERMINAL_KEY);
|
||||
}
|
||||
if (closesActiveSession) {
|
||||
setSession(null);
|
||||
activeSessionRef.current = null;
|
||||
terminal.setActiveTerminalId(undefined);
|
||||
setConnection("idle");
|
||||
// reset discards the complete screen and terminal modes; clear() does not.
|
||||
terminal.reset();
|
||||
if (fallback) void activate(fallback);
|
||||
}
|
||||
},
|
||||
[activate, detachTransport, terminal],
|
||||
);
|
||||
|
||||
const toggleModifier = useCallback(
|
||||
(modifier: keyof TerminalModifiers) => {
|
||||
setModifiers((current) => {
|
||||
const next = { ...current, [modifier]: !current[modifier] };
|
||||
modifiersRef.current = next;
|
||||
return next;
|
||||
});
|
||||
window.requestAnimationFrame(() => terminal.focus());
|
||||
},
|
||||
[terminal],
|
||||
);
|
||||
|
||||
const writeAccessoryKey = useCallback(
|
||||
(data: string) => {
|
||||
writeTerminalData(data);
|
||||
window.requestAnimationFrame(() => terminal.focus());
|
||||
},
|
||||
[terminal, writeTerminalData],
|
||||
);
|
||||
|
||||
// Transport input/resize subscriptions and restoration begin only once xterm is usable.
|
||||
useEffect(() => {
|
||||
if (!terminal.ready) return;
|
||||
mountedRef.current = true;
|
||||
const input = terminal.onInput((data) => writeTerminalData(data, true));
|
||||
const resize = terminal.onResize(() => {
|
||||
const socket = socketRef.current;
|
||||
if (socket?.readyState === WebSocket.OPEN) {
|
||||
terminal.sendResize((message) => socket.send(message));
|
||||
}
|
||||
});
|
||||
|
||||
let cancelled = false;
|
||||
async function restoreTerminalSession() {
|
||||
try {
|
||||
const listed = await catalogRef.current.refresh();
|
||||
if (cancelled) return;
|
||||
const rememberedId = window.sessionStorage.getItem(
|
||||
REMEMBERED_TERMINAL_KEY,
|
||||
);
|
||||
const candidates = restoreCandidates(
|
||||
orderTerminalSessions(listed),
|
||||
rememberedId,
|
||||
selectedAgentIdRef.current,
|
||||
);
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (cancelled) return;
|
||||
setConnection("connecting");
|
||||
try {
|
||||
const attached = await attachTerminal(
|
||||
candidate.terminal_id,
|
||||
operatorId,
|
||||
);
|
||||
if (cancelled) return;
|
||||
terminal.reset();
|
||||
connect(attached);
|
||||
return;
|
||||
} catch (error) {
|
||||
if (
|
||||
!(error instanceof APIError) ||
|
||||
error.code !== "terminal_operator_already_attached"
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!cancelled) setConnection("idle");
|
||||
} catch (error) {
|
||||
if (cancelled) return;
|
||||
setConnection("idle");
|
||||
toast.error("Could not restore terminal session", {
|
||||
description: String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void restoreTerminalSession();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
mountedRef.current = false;
|
||||
input.dispose();
|
||||
resize.dispose();
|
||||
detachTransport();
|
||||
};
|
||||
}, [
|
||||
terminal.ready,
|
||||
terminal.onInput,
|
||||
terminal.onResize,
|
||||
terminal.reset,
|
||||
terminal.sendResize,
|
||||
terminal,
|
||||
connect,
|
||||
detachTransport,
|
||||
operatorId,
|
||||
writeTerminalData,
|
||||
]);
|
||||
|
||||
return {
|
||||
session,
|
||||
connection,
|
||||
modifiers,
|
||||
start,
|
||||
activate,
|
||||
reconnect,
|
||||
close,
|
||||
writeTerminalData,
|
||||
writeAccessoryKey,
|
||||
toggleModifier,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { listTerminals, type TerminalSession } from "@/api";
|
||||
import {
|
||||
mergeTerminalSession,
|
||||
reconcileTerminalSessions,
|
||||
} from "@/pages/terminal/sessionUtils";
|
||||
|
||||
function websocketUrl(path: string): string {
|
||||
const scheme = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
return `${scheme}//${window.location.host}${path}`;
|
||||
}
|
||||
|
||||
export function useTerminalSessions() {
|
||||
const [sessions, setSessions] = useState<TerminalSession[]>([]);
|
||||
const sessionsRef = useRef(sessions);
|
||||
const refreshRef = useRef<() => Promise<TerminalSession[]>>(async () => []);
|
||||
|
||||
sessionsRef.current = sessions;
|
||||
|
||||
const refresh = useCallback(() => refreshRef.current(), []);
|
||||
const merge = useCallback((session: TerminalSession) => {
|
||||
setSessions((current) => mergeTerminalSession(current, session));
|
||||
}, []);
|
||||
const markDetached = useCallback((terminalId: string) => {
|
||||
setSessions((current) =>
|
||||
current.map((item) =>
|
||||
item.terminal_id === terminalId
|
||||
? { ...item, operator_attached: false }
|
||||
: item,
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
const remove = useCallback((terminalId: string) => {
|
||||
setSessions((current) =>
|
||||
current.filter((item) => item.terminal_id !== terminalId),
|
||||
);
|
||||
}, []);
|
||||
const reconcile = useCallback((listed: TerminalSession[]) => {
|
||||
setSessions((current) => reconcileTerminalSessions(current, listed));
|
||||
}, []);
|
||||
const getSessions = useCallback(() => sessionsRef.current, []);
|
||||
|
||||
// The catalog stream and fallback timer live for the page mount, independent of active sessions.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let refreshInFlight: Promise<TerminalSession[]> | null = null;
|
||||
let refreshRequested = false;
|
||||
let eventsSocket: WebSocket | null = null;
|
||||
let reconnectTimer = 0;
|
||||
let healthyTimer = 0;
|
||||
let reconnectAttempt = 0;
|
||||
|
||||
const refreshSessions = (): Promise<TerminalSession[]> => {
|
||||
if (refreshInFlight) {
|
||||
// Coalesce bursts and fetch again if another invalidation arrives in flight.
|
||||
refreshRequested = true;
|
||||
return refreshInFlight;
|
||||
}
|
||||
refreshInFlight = (async () => {
|
||||
let listed: TerminalSession[] = [];
|
||||
do {
|
||||
refreshRequested = false;
|
||||
listed = await listTerminals();
|
||||
if (!cancelled) {
|
||||
setSessions((current) =>
|
||||
reconcileTerminalSessions(current, listed),
|
||||
);
|
||||
}
|
||||
} while (refreshRequested && !cancelled);
|
||||
return listed;
|
||||
})().finally(() => {
|
||||
refreshInFlight = null;
|
||||
if (refreshRequested && !cancelled) {
|
||||
void refreshSessions().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
return refreshInFlight;
|
||||
};
|
||||
|
||||
refreshRef.current = refreshSessions;
|
||||
|
||||
const connectEvents = () => {
|
||||
if (cancelled) return;
|
||||
const socket = new WebSocket(
|
||||
websocketUrl("/api/v1/control/terminals/events/ws"),
|
||||
);
|
||||
eventsSocket = socket;
|
||||
socket.onmessage = (event) => {
|
||||
if (cancelled || eventsSocket !== socket) return;
|
||||
if (typeof event.data !== "string") return;
|
||||
try {
|
||||
const message = JSON.parse(event.data) as { type?: string };
|
||||
if (message.type === "sessions_changed") {
|
||||
void refreshSessions().catch(() => undefined);
|
||||
}
|
||||
} catch {
|
||||
// Invalidations are hints; the periodic recovery fetch remains active.
|
||||
}
|
||||
};
|
||||
socket.onopen = () => {
|
||||
void refreshSessions().catch(() => undefined);
|
||||
window.clearTimeout(healthyTimer);
|
||||
healthyTimer = window.setTimeout(() => {
|
||||
if (eventsSocket === socket && socket.readyState === WebSocket.OPEN) {
|
||||
reconnectAttempt = 0;
|
||||
}
|
||||
}, 10_000);
|
||||
};
|
||||
socket.onclose = () => {
|
||||
if (cancelled || eventsSocket !== socket) return;
|
||||
window.clearTimeout(healthyTimer);
|
||||
const ceiling = Math.min(500 * 2 ** reconnectAttempt, 12_000);
|
||||
const delay = Math.min(
|
||||
Math.round(ceiling * (0.8 + Math.random() * 0.4)),
|
||||
15_000,
|
||||
);
|
||||
reconnectAttempt = Math.min(reconnectAttempt + 1, 5);
|
||||
reconnectTimer = window.setTimeout(connectEvents, delay);
|
||||
};
|
||||
socket.onerror = () => socket.close();
|
||||
};
|
||||
|
||||
connectEvents();
|
||||
void refreshSessions().catch(() => undefined);
|
||||
const refreshTimer = window.setInterval(() => {
|
||||
void refreshSessions().catch(() => undefined);
|
||||
}, 15_000);
|
||||
const refreshOnFocus = () => {
|
||||
void refreshSessions().catch(() => undefined);
|
||||
};
|
||||
window.addEventListener("focus", refreshOnFocus);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(refreshTimer);
|
||||
window.removeEventListener("focus", refreshOnFocus);
|
||||
window.clearTimeout(reconnectTimer);
|
||||
window.clearTimeout(healthyTimer);
|
||||
eventsSocket?.close();
|
||||
eventsSocket = null;
|
||||
refreshRef.current = async () => [];
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
sessions,
|
||||
refresh,
|
||||
merge,
|
||||
markDetached,
|
||||
remove,
|
||||
reconcile,
|
||||
getSessions,
|
||||
};
|
||||
}
|
||||
|
||||
export type TerminalCatalog = ReturnType<typeof useTerminalSessions>;
|
||||
@@ -0,0 +1,371 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type RefObject,
|
||||
} from "react";
|
||||
import { ClipboardAddon } from "@xterm/addon-clipboard";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { ImageAddon } from "@xterm/addon-image";
|
||||
import { Unicode11Addon } from "@xterm/addon-unicode11";
|
||||
import { UnicodeGraphemesAddon } from "@xterm/addon-unicode-graphemes";
|
||||
import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||
import { Terminal as XTerm } from "@xterm/xterm";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
MAX_TERMINAL_FONT_SIZE,
|
||||
MIN_TERMINAL_FONT_SIZE,
|
||||
} from "@/pages/terminal/TerminalAccessoryBar";
|
||||
import { visibleTerminalText } from "@/pages/terminal/sessionUtils";
|
||||
import { loadTerminalFontFamily } from "@/terminal/terminalFonts";
|
||||
|
||||
const DEFAULT_TERMINAL_FONT_SIZE = 14;
|
||||
|
||||
// Stable behavioral surface shared with session transport and page controls.
|
||||
export type TerminalPort = {
|
||||
clear: () => void;
|
||||
copy: () => Promise<void>;
|
||||
dimensions: () => { rows: number; cols: number } | null;
|
||||
forgetTitle: (terminalId: string) => void;
|
||||
fit: () => void;
|
||||
focus: () => void;
|
||||
blurInput: () => void;
|
||||
onInput: (handler: (data: string) => void) => { dispose: () => void };
|
||||
onResize: (handler: () => void) => { dispose: () => void };
|
||||
paste: (write: (data: string) => void) => Promise<void>;
|
||||
reset: () => void;
|
||||
scrollPages: (pages: number) => void;
|
||||
sendResize: (send: (message: string) => void, force?: boolean) => void;
|
||||
changeFontSize: (delta: number) => void;
|
||||
setActiveTerminalId: (terminalId: string | undefined) => void;
|
||||
write: (data: Uint8Array) => void;
|
||||
writeLine: (line: string) => void;
|
||||
ready: boolean;
|
||||
};
|
||||
|
||||
type Options = {
|
||||
pageRef: RefObject<HTMLElement | null>;
|
||||
hostRef: RefObject<HTMLDivElement | null>;
|
||||
};
|
||||
|
||||
export function useXterm({ pageRef, hostRef }: Options) {
|
||||
const terminalRef = useRef<XTerm | null>(null);
|
||||
const fitRef = useRef<FitAddon | null>(null);
|
||||
const activeTerminalIdRef = useRef<string | undefined>(undefined);
|
||||
const lastSizeRef = useRef({ rows: 0, cols: 0 });
|
||||
const resizeListenersRef = useRef(new Set<() => void>());
|
||||
const [fontFamily, setFontFamily] = useState<string>();
|
||||
const [ready, setReady] = useState(false);
|
||||
const [fontSize, setFontSize] = useState(DEFAULT_TERMINAL_FONT_SIZE);
|
||||
const [sessionTitles, setSessionTitles] = useState<Record<string, string>>(
|
||||
{},
|
||||
);
|
||||
|
||||
const setActiveTerminalId = useCallback((terminalId: string | undefined) => {
|
||||
activeTerminalIdRef.current = terminalId;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void loadTerminalFontFamily().then((loadedFontFamily) => {
|
||||
if (!cancelled) setFontFamily(loadedFontFamily);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const viewport = window.visualViewport;
|
||||
const updateViewportHeight = () => {
|
||||
const height = Math.round(viewport?.height ?? window.innerHeight);
|
||||
pageRef.current?.style.setProperty(
|
||||
"--terminal-visual-viewport-height",
|
||||
`${height}px`,
|
||||
);
|
||||
};
|
||||
updateViewportHeight();
|
||||
viewport?.addEventListener("resize", updateViewportHeight);
|
||||
viewport?.addEventListener("scroll", updateViewportHeight);
|
||||
window.addEventListener("resize", updateViewportHeight);
|
||||
return () => {
|
||||
viewport?.removeEventListener("resize", updateViewportHeight);
|
||||
viewport?.removeEventListener("scroll", updateViewportHeight);
|
||||
window.removeEventListener("resize", updateViewportHeight);
|
||||
};
|
||||
}, [pageRef]);
|
||||
|
||||
const sendResize = useCallback(
|
||||
(send: (message: string) => void, force = false) => {
|
||||
const terminal = terminalRef.current;
|
||||
if (!terminal) return;
|
||||
if (
|
||||
!force &&
|
||||
lastSizeRef.current.rows === terminal.rows &&
|
||||
lastSizeRef.current.cols === terminal.cols
|
||||
) {
|
||||
return;
|
||||
}
|
||||
lastSizeRef.current = { rows: terminal.rows, cols: terminal.cols };
|
||||
send(
|
||||
JSON.stringify({
|
||||
type: "resize",
|
||||
rows: terminal.rows,
|
||||
cols: terminal.cols,
|
||||
}),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const fit = useCallback(() => fitRef.current?.fit(), []);
|
||||
const focus = useCallback(() => terminalRef.current?.focus(), []);
|
||||
const blurInput = useCallback(
|
||||
() => terminalRef.current?.textarea?.blur(),
|
||||
[],
|
||||
);
|
||||
const reset = useCallback(() => terminalRef.current?.reset(), []);
|
||||
const clear = useCallback(() => terminalRef.current?.clear(), []);
|
||||
const write = useCallback(
|
||||
(data: Uint8Array) => terminalRef.current?.write(data),
|
||||
[],
|
||||
);
|
||||
const writeLine = useCallback(
|
||||
(line: string) => terminalRef.current?.writeln(line),
|
||||
[],
|
||||
);
|
||||
const dimensions = useCallback(() => {
|
||||
const terminal = terminalRef.current;
|
||||
return terminal ? { rows: terminal.rows, cols: terminal.cols } : null;
|
||||
}, []);
|
||||
const forgetTitle = useCallback((terminalId: string) => {
|
||||
setSessionTitles((current) => {
|
||||
if (!(terminalId in current)) return current;
|
||||
const next = { ...current };
|
||||
delete next[terminalId];
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
const onInput = useCallback(
|
||||
(handler: (data: string) => void) =>
|
||||
terminalRef.current?.onData(handler) ?? { dispose: () => {} },
|
||||
[],
|
||||
);
|
||||
const onResize = useCallback((handler: () => void) => {
|
||||
resizeListenersRef.current.add(handler);
|
||||
return { dispose: () => resizeListenersRef.current.delete(handler) };
|
||||
}, []);
|
||||
const scrollPages = useCallback(
|
||||
(pages: number) => terminalRef.current?.scrollPages(pages),
|
||||
[],
|
||||
);
|
||||
const copy = useCallback(async () => {
|
||||
const terminal = terminalRef.current;
|
||||
if (!terminal) return;
|
||||
const text = terminal.hasSelection()
|
||||
? terminal.getSelection()
|
||||
: visibleTerminalText(terminal);
|
||||
if (!text) {
|
||||
toast.info("Nothing to copy");
|
||||
return;
|
||||
}
|
||||
if (!navigator.clipboard) {
|
||||
toast.error("Clipboard access requires HTTPS or localhost");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast.success(
|
||||
terminal.hasSelection() ? "Selection copied" : "Visible screen copied",
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error("Clipboard access was denied", {
|
||||
description: String(error),
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
const paste = useCallback(async (writeData: (data: string) => void) => {
|
||||
if (!navigator.clipboard) {
|
||||
toast.error("Clipboard access requires HTTPS or localhost");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
if (text) writeData(text);
|
||||
terminalRef.current?.focus();
|
||||
} catch (error) {
|
||||
toast.error("Clipboard access was denied", {
|
||||
description: String(error),
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
const changeFontSize = useCallback((delta: number) => {
|
||||
const terminal = terminalRef.current;
|
||||
if (!terminal) return;
|
||||
const current = terminal.options.fontSize ?? DEFAULT_TERMINAL_FONT_SIZE;
|
||||
const next = Math.min(
|
||||
MAX_TERMINAL_FONT_SIZE,
|
||||
Math.max(MIN_TERMINAL_FONT_SIZE, current + delta),
|
||||
);
|
||||
if (next === current) return;
|
||||
terminal.options.fontSize = next;
|
||||
setFontSize(next);
|
||||
window.requestAnimationFrame(() => {
|
||||
fitRef.current?.fit();
|
||||
resizeListenersRef.current.forEach((handler) => handler());
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Wait for the font before creating xterm; this effect owns the instance and its observers.
|
||||
useEffect(() => {
|
||||
if (!hostRef.current || !fontFamily) return;
|
||||
const terminal = new XTerm({
|
||||
cursorBlink: true,
|
||||
convertEol: false,
|
||||
fontFamily,
|
||||
fontSize: DEFAULT_TERMINAL_FONT_SIZE,
|
||||
lineHeight: 1.1,
|
||||
scrollbar: {
|
||||
width: window.matchMedia("(pointer: coarse)").matches ? 22 : 16,
|
||||
},
|
||||
scrollback: 10_000,
|
||||
theme: {
|
||||
background: "#0b1117",
|
||||
foreground: "#e5e7eb",
|
||||
cursor: "#6ee7a8",
|
||||
scrollbarSliderBackground: "#52617099",
|
||||
scrollbarSliderHoverBackground: "#6b7c8dcc",
|
||||
scrollbarSliderActiveBackground: "#8294a6",
|
||||
},
|
||||
allowProposedApi: true,
|
||||
});
|
||||
terminal.loadAddon(new Unicode11Addon());
|
||||
terminal.unicode.activeVersion = "11";
|
||||
terminal.loadAddon(new UnicodeGraphemesAddon());
|
||||
terminal.loadAddon(new WebLinksAddon());
|
||||
terminal.loadAddon(new ImageAddon({}));
|
||||
const fitAddon = new FitAddon();
|
||||
terminal.loadAddon(new ClipboardAddon());
|
||||
terminal.loadAddon(fitAddon);
|
||||
terminal.open(hostRef.current);
|
||||
terminal.attachCustomKeyEventHandler((event) => {
|
||||
const copiesTerminalSelection =
|
||||
event.type === "keydown" &&
|
||||
event.ctrlKey &&
|
||||
event.shiftKey &&
|
||||
event.code === "KeyC";
|
||||
if (!copiesTerminalSelection) return true;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (terminal.hasSelection()) {
|
||||
if (!navigator.clipboard) {
|
||||
toast.error("Clipboard access requires HTTPS or localhost");
|
||||
} else {
|
||||
void navigator.clipboard
|
||||
.writeText(terminal.getSelection())
|
||||
.catch((error) =>
|
||||
toast.error("Clipboard access was denied", {
|
||||
description: String(error),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
const titleChange = terminal.onTitleChange((title) => {
|
||||
const terminalId = activeTerminalIdRef.current;
|
||||
if (!terminalId) return;
|
||||
const normalized = title.trim();
|
||||
setSessionTitles((current) => {
|
||||
if (current[terminalId] === normalized) return current;
|
||||
if (!normalized) {
|
||||
const next = { ...current };
|
||||
delete next[terminalId];
|
||||
return next;
|
||||
}
|
||||
return { ...current, [terminalId]: normalized };
|
||||
});
|
||||
});
|
||||
fitAddon.fit();
|
||||
// Consumers subscribe only after this transition; the port methods stay stable.
|
||||
terminalRef.current = terminal;
|
||||
fitRef.current = fitAddon;
|
||||
setReady(true);
|
||||
|
||||
let resizeFrame = 0;
|
||||
let lastHostWidth = 0;
|
||||
let lastHostHeight = 0;
|
||||
const resizeObserver = new ResizeObserver(([entry]) => {
|
||||
const width = Math.round(entry.contentRect.width);
|
||||
const height = Math.round(entry.contentRect.height);
|
||||
if (width === lastHostWidth && height === lastHostHeight) return;
|
||||
lastHostWidth = width;
|
||||
lastHostHeight = height;
|
||||
window.cancelAnimationFrame(resizeFrame);
|
||||
resizeFrame = window.requestAnimationFrame(() => {
|
||||
fitAddon.fit();
|
||||
resizeListenersRef.current.forEach((handler) => handler());
|
||||
});
|
||||
});
|
||||
resizeObserver.observe(hostRef.current);
|
||||
|
||||
return () => {
|
||||
titleChange.dispose();
|
||||
resizeObserver.disconnect();
|
||||
resizeListenersRef.current.clear();
|
||||
window.cancelAnimationFrame(resizeFrame);
|
||||
terminal.dispose();
|
||||
terminalRef.current = null;
|
||||
fitRef.current = null;
|
||||
setReady(false);
|
||||
};
|
||||
}, [fontFamily, hostRef]);
|
||||
|
||||
const terminal = useMemo<TerminalPort>(
|
||||
() => ({
|
||||
clear,
|
||||
copy,
|
||||
dimensions,
|
||||
forgetTitle,
|
||||
fit,
|
||||
focus,
|
||||
blurInput,
|
||||
onInput,
|
||||
onResize,
|
||||
paste,
|
||||
reset,
|
||||
scrollPages,
|
||||
sendResize,
|
||||
changeFontSize,
|
||||
setActiveTerminalId,
|
||||
write,
|
||||
writeLine,
|
||||
ready,
|
||||
}),
|
||||
[
|
||||
clear,
|
||||
copy,
|
||||
dimensions,
|
||||
forgetTitle,
|
||||
fit,
|
||||
focus,
|
||||
blurInput,
|
||||
onInput,
|
||||
onResize,
|
||||
paste,
|
||||
reset,
|
||||
scrollPages,
|
||||
sendResize,
|
||||
changeFontSize,
|
||||
setActiveTerminalId,
|
||||
write,
|
||||
writeLine,
|
||||
ready,
|
||||
],
|
||||
);
|
||||
|
||||
return { terminal, fontSize, sessionTitles };
|
||||
}
|
||||
+409
-1
@@ -135,7 +135,7 @@
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
@apply bg-background text-foreground transition-colors duration-100 ease-in-out;
|
||||
margin: 0;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
@@ -189,6 +189,13 @@
|
||||
background-size: 100% 100%;
|
||||
overflow-y: auto;
|
||||
max-width: 100%;
|
||||
transition: background 100ms ease-in-out;
|
||||
}
|
||||
|
||||
/* xterm focuses an input near the active row. Keep that focus operation from
|
||||
scrolling the page shell and moving the terminal toolbar off-screen. */
|
||||
.app-main:has(.terminal-page) {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Sidebar internals ────────────────────────────────────────────────── */
|
||||
@@ -315,6 +322,407 @@
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
/* ── Remote terminal ─────────────────────────────────────────────────── */
|
||||
|
||||
.terminal-page {
|
||||
display: flex;
|
||||
height: calc(var(--terminal-visual-viewport-height, 100dvh) - 2.5rem);
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.terminal-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.terminal-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.terminal-heading h1 {
|
||||
margin: 0;
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.terminal-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.terminal-status {
|
||||
gap: 0.4rem;
|
||||
height: 1.4rem;
|
||||
padding-inline: 0.45rem;
|
||||
border-color: rgb(255 255 255 / 14%);
|
||||
background: rgb(255 255 255 / 5%);
|
||||
color: #cbd5e1;
|
||||
font-size: 0.6875rem;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.terminal-status > span {
|
||||
width: 0.45rem;
|
||||
height: 0.45rem;
|
||||
border-radius: 999px;
|
||||
background: var(--presence-unknown);
|
||||
}
|
||||
|
||||
.terminal-status[data-state="ready"] > span {
|
||||
background: var(--presence-online);
|
||||
}
|
||||
|
||||
.terminal-status[data-state="connecting"] > span {
|
||||
background: var(--presence-likely);
|
||||
}
|
||||
|
||||
.terminal-status[data-state="disconnected"] > span,
|
||||
.terminal-status[data-state="exited"] > span {
|
||||
background: var(--presence-offline);
|
||||
}
|
||||
|
||||
.terminal-notice {
|
||||
margin: 0;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.terminal-frame {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: #0b1117;
|
||||
}
|
||||
|
||||
.terminal-framebar {
|
||||
display: flex;
|
||||
min-height: 2.5rem;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.35rem 0.45rem 0.35rem 0.75rem;
|
||||
border-bottom: 1px solid color-mix(in oklab, white 9%, transparent);
|
||||
background: #101820;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.terminal-session-label,
|
||||
.terminal-frame-actions {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.terminal-tabs {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.terminal-tab-list {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.terminal-tab-list::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.terminal-tab {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
max-width: min(20rem, 48vw);
|
||||
height: 1.85rem;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
border: 1px solid transparent;
|
||||
border-radius: calc(var(--radius) - 2px);
|
||||
background: transparent;
|
||||
font-size: 0.75rem;
|
||||
color: #aab7c4;
|
||||
}
|
||||
|
||||
.terminal-tab-select {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
padding: 0 0.35rem 0 0.6rem;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.terminal-tab-label {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.terminal-tab time {
|
||||
flex: 0 0 auto;
|
||||
font-size: 0.6875rem;
|
||||
}
|
||||
|
||||
.terminal-tab[data-active] {
|
||||
max-width: min(30rem, 60vw);
|
||||
border-color: rgb(255 255 255 / 10%);
|
||||
background: rgb(255 255 255 / 7%);
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
.terminal-tab[data-locked] {
|
||||
opacity: 0.58;
|
||||
}
|
||||
|
||||
.terminal-tab-close {
|
||||
display: grid;
|
||||
width: 1.6rem;
|
||||
height: 100%;
|
||||
flex: 0 0 auto;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: calc(var(--radius) - 3px);
|
||||
background: transparent;
|
||||
color: #7f8d9a;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.terminal-tab-close:focus-visible,
|
||||
.terminal-tab-close:hover,
|
||||
.terminal-tab[data-active] .terminal-tab-close {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.terminal-tab-close:disabled {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.terminal-new-tab {
|
||||
width: 1.85rem;
|
||||
height: 1.85rem;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.terminal-tab-dot {
|
||||
width: 0.45rem;
|
||||
height: 0.45rem;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 999px;
|
||||
background: var(--presence-unknown);
|
||||
}
|
||||
|
||||
.terminal-tab-dot[data-state="ready"],
|
||||
.terminal-tab-dot[data-state="attached"] {
|
||||
background: var(--presence-online);
|
||||
}
|
||||
|
||||
.terminal-tab-dot[data-state="connecting"],
|
||||
.terminal-tab-dot[data-state="detached"] {
|
||||
background: var(--presence-likely);
|
||||
}
|
||||
|
||||
.terminal-tab-dot[data-state="disconnected"],
|
||||
.terminal-tab-dot[data-state="exited"] {
|
||||
background: var(--presence-offline);
|
||||
}
|
||||
|
||||
.terminal-frame-actions {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.terminal-session-label > span:not(.terminal-status) {
|
||||
overflow: hidden;
|
||||
color: #dbe4ee;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 550;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.terminal-framebar button {
|
||||
color: #aab7c4;
|
||||
}
|
||||
|
||||
.terminal-framebar button:hover {
|
||||
color: #f1f5f9;
|
||||
background: rgb(255 255 255 / 7%);
|
||||
}
|
||||
|
||||
.terminal-surface {
|
||||
order: 2;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
background: #0b1117;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.terminal-accessories-toggle[data-active] {
|
||||
color: #6ee7a8;
|
||||
background: rgb(110 231 168 / 10%);
|
||||
}
|
||||
|
||||
.terminal-accessories {
|
||||
display: flex;
|
||||
order: 3;
|
||||
min-width: 0;
|
||||
min-height: 2.75rem;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.35rem 0.45rem max(0.35rem, env(safe-area-inset-bottom));
|
||||
overflow-x: auto;
|
||||
overscroll-behavior-x: contain;
|
||||
border-top: 1px solid rgb(255 255 255 / 9%);
|
||||
background: #101820;
|
||||
color: #cbd5e1;
|
||||
scrollbar-width: thin;
|
||||
touch-action: pan-x;
|
||||
}
|
||||
|
||||
.terminal-accessory-group {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.terminal-accessory-group + .terminal-accessory-group {
|
||||
padding-left: 0.35rem;
|
||||
border-left: 1px solid rgb(255 255 255 / 10%);
|
||||
}
|
||||
|
||||
.terminal-accessories .terminal-key {
|
||||
width: auto;
|
||||
min-width: 2.4rem;
|
||||
height: 2rem;
|
||||
padding-inline: 0.55rem;
|
||||
border: 1px solid rgb(255 255 255 / 9%);
|
||||
border-radius: calc(var(--radius) - 3px);
|
||||
background: rgb(255 255 255 / 4%);
|
||||
color: #cbd5e1;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.terminal-accessories .terminal-key:hover {
|
||||
background: rgb(255 255 255 / 9%);
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
.terminal-accessories .terminal-key[data-active] {
|
||||
border-color: rgb(110 231 168 / 48%);
|
||||
background: rgb(110 231 168 / 14%);
|
||||
color: #9af0bf;
|
||||
}
|
||||
|
||||
.terminal-accessories .terminal-key--icon {
|
||||
width: 2rem;
|
||||
min-width: 2rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.terminal-accessories .terminal-key--control {
|
||||
min-width: 2.25rem;
|
||||
}
|
||||
|
||||
.terminal-accessories .terminal-key--wide {
|
||||
min-width: 3.15rem;
|
||||
}
|
||||
|
||||
.terminal-font-controls output {
|
||||
min-width: 1.5rem;
|
||||
color: #91a0ae;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 0.6875rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.terminal-surface .xterm {
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
background: #0b1117;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.terminal-surface .xterm-viewport {
|
||||
/* xterm defaults this element to pure black. It remains visible around the
|
||||
fitted canvas, so keep it on the same surface as the terminal theme. */
|
||||
background-color: #0b1117;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.terminal-surface .xterm-scrollable-element > .scrollbar,
|
||||
.terminal-surface .xterm-scrollable-element > .scrollbar > .slider {
|
||||
/* Without this, mobile browsers claim a thumb drag as page panning before
|
||||
xterm's pointer-capture scrollbar can process it. */
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.terminal-page {
|
||||
height: calc(var(--terminal-visual-viewport-height, 100dvh) - 2.5rem);
|
||||
}
|
||||
|
||||
.terminal-controls {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.terminal-surface {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.terminal-surface .xterm {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
.terminal-accessories {
|
||||
min-height: 3.25rem;
|
||||
}
|
||||
|
||||
.terminal-accessories .terminal-key {
|
||||
min-width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
}
|
||||
|
||||
.terminal-accessories .terminal-key--icon {
|
||||
width: 2.75rem;
|
||||
min-width: 2.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Presence badges ──────────────────────────────────────────────────── */
|
||||
|
||||
.presence-dot {
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
@font-face {
|
||||
font-family: "Wakey JetBrainsMono Nerd Font Mono";
|
||||
src: url("../assets/fonts/jetbrains-mono-nerd/JetBrainsMonoNerdFontMono-Regular.woff2")
|
||||
format("woff2");
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: block;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import "./terminalFonts.css";
|
||||
|
||||
const TERMINAL_WEB_FONT = "Wakey JetBrainsMono Nerd Font Mono";
|
||||
const TERMINAL_FALLBACK_FONTS =
|
||||
'"SFMono-Regular", Consolas, "Liberation Mono", monospace';
|
||||
|
||||
let terminalFontsPromise: Promise<string> | undefined;
|
||||
|
||||
/**
|
||||
* Resolves the terminal font stack only after browser glyph metrics are ready.
|
||||
* xterm caches those metrics when it opens, so a late webfont load permanently
|
||||
* misaligns cells for that terminal instance.
|
||||
*/
|
||||
export function loadTerminalFontFamily(): Promise<string> {
|
||||
terminalFontsPromise ??= document.fonts
|
||||
.load(`400 14px "${TERMINAL_WEB_FONT}"`, "\uE0B0")
|
||||
.then((faces) => {
|
||||
if (faces.length === 0) {
|
||||
throw new Error("terminal webfont was not registered");
|
||||
}
|
||||
return `"${TERMINAL_WEB_FONT}", ${TERMINAL_FALLBACK_FONTS}`;
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
console.warn(
|
||||
"Could not load terminal webfont; using system monospace",
|
||||
error,
|
||||
);
|
||||
return TERMINAL_FALLBACK_FONTS;
|
||||
});
|
||||
return terminalFontsPromise;
|
||||
}
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"ignoreDeprecations": "5.0",
|
||||
"moduleResolution": "Bundler",
|
||||
|
||||
+1
Submodule vendor/vt100 added at 60d0544b14.
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "wakey-agent"
|
||||
version = "0.3.0"
|
||||
version = "0.3.6"
|
||||
edition = "2024"
|
||||
publish = ["gitea"]
|
||||
|
||||
@@ -23,6 +23,7 @@ serde_json = "1"
|
||||
tokio = { version = "1", features = [
|
||||
"fs",
|
||||
"macros",
|
||||
"io-util",
|
||||
"rt-multi-thread",
|
||||
"time",
|
||||
"signal",
|
||||
@@ -38,5 +39,6 @@ tracing-subscriber = { version = "0.3", features = [
|
||||
] }
|
||||
time = { version = "0.3", features = ["formatting", "local-offset"] }
|
||||
url = "2"
|
||||
vt100 = { package = "wakey-vt100", path = "../vendor/vt100", registry = "gitea", version = "=0.16.2-wakey.2" }
|
||||
wakey = { path = "..", registry = "gitea", version = "0" }
|
||||
wakey-core = { path = "../wakey-core", registry = "gitea", version = "0" }
|
||||
@@ -98,6 +98,10 @@ pub struct InitConfigArgs {
|
||||
#[arg(long)]
|
||||
pub agent_token: Option<String>,
|
||||
|
||||
/// Days to keep local hook observation rows since last seen. Zero disables pruning.
|
||||
#[arg(long)]
|
||||
pub observation_retention_days: Option<u64>,
|
||||
|
||||
/// Replace an existing config file.
|
||||
#[arg(long)]
|
||||
pub force: bool,
|
||||
|
||||
+175
-2
@@ -2,6 +2,12 @@ use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use crate::protocol::{
|
||||
DEFAULT_TERMINAL_MAX_SESSIONS, DEFAULT_TERMINAL_SESSION_TTL_SECONDS,
|
||||
checked_terminal_session_ttl,
|
||||
};
|
||||
|
||||
pub const DEFAULT_CONFIG_PATH: &str = "/etc/wakey-agent/config.toml";
|
||||
pub const DEFAULT_PID_FILE: &str = "/var/run/wakey-agent.pid";
|
||||
@@ -11,6 +17,7 @@ const WAKEY_OBSERVATION_STORE_ENV: &str = "WAKEY_OBSERVATION_STORE";
|
||||
const DEFAULT_DHCP_LEASES_PATH: &str = "/tmp/dhcp.leases";
|
||||
const DEFAULT_MAC_NAME_CACHE_PATH: &str = "/tmp/wakey_mac_names.json";
|
||||
const DEFAULT_OBSERVATION_STORE_PATH: &str = "/tmp/wakey_observations.json";
|
||||
pub const DEFAULT_OBSERVATION_RETENTION_DAYS: u64 = 7;
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct AgentConfig {
|
||||
@@ -23,6 +30,8 @@ pub struct AgentConfig {
|
||||
pub reconnect_max_ms: u64,
|
||||
#[serde(default = "default_observation_sync_interval_seconds")]
|
||||
pub observation_sync_interval_seconds: u64,
|
||||
#[serde(default = "default_observation_retention_days")]
|
||||
pub observation_retention_days: u64,
|
||||
#[serde(default = "default_pid_file")]
|
||||
pub pid_file: PathBuf,
|
||||
#[serde(default = "default_dhcp_leases_path")]
|
||||
@@ -31,8 +40,56 @@ pub struct AgentConfig {
|
||||
pub mac_name_cache_path: PathBuf,
|
||||
#[serde(default = "default_observation_store_path")]
|
||||
pub observation_store_path: PathBuf,
|
||||
#[serde(default)]
|
||||
pub terminal: TerminalConfig,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TerminalConfig {
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
#[serde(default = "default_terminal_shell")]
|
||||
pub shell: PathBuf,
|
||||
#[serde(default)]
|
||||
pub args: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub current_dir: Option<PathBuf>,
|
||||
#[serde(default = "default_terminal_max_sessions")]
|
||||
pub max_sessions: usize,
|
||||
/// Maximum lifetime of a PTY process. Zero disables automatic expiry.
|
||||
#[serde(default = "default_terminal_session_ttl_seconds")]
|
||||
pub session_ttl_seconds: u64,
|
||||
}
|
||||
|
||||
impl Default for TerminalConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
shell: default_terminal_shell(),
|
||||
args: Vec::new(),
|
||||
current_dir: None,
|
||||
max_sessions: default_terminal_max_sessions(),
|
||||
session_ttl_seconds: default_terminal_session_ttl_seconds(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub static DEFAULT_CONFIG: LazyLock<AgentConfig> = LazyLock::new(|| AgentConfig {
|
||||
server_url: "https://wakey.ldlda.com".to_string(),
|
||||
agent_id: "REPLACE_ME_AGENT_ID".to_string(),
|
||||
agent_token: "REPLACE_ME_AGENT_TOKEN".to_string(),
|
||||
reconnect_base_ms: default_reconnect_base_ms(),
|
||||
reconnect_max_ms: default_reconnect_max_ms(),
|
||||
observation_sync_interval_seconds: default_observation_sync_interval_seconds(),
|
||||
observation_retention_days: default_observation_retention_days(),
|
||||
pid_file: default_pid_file(),
|
||||
dhcp_leases_path: default_dhcp_leases_path(),
|
||||
mac_name_cache_path: default_mac_name_cache_path(),
|
||||
observation_store_path: default_observation_store_path(),
|
||||
terminal: TerminalConfig::default(),
|
||||
});
|
||||
|
||||
impl fmt::Debug for AgentConfig {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("AgentConfig")
|
||||
@@ -45,10 +102,15 @@ impl fmt::Debug for AgentConfig {
|
||||
"observation_sync_interval_seconds",
|
||||
&self.observation_sync_interval_seconds,
|
||||
)
|
||||
.field(
|
||||
"observation_retention_days",
|
||||
&self.observation_retention_days,
|
||||
)
|
||||
.field("pid_file", &self.pid_file)
|
||||
.field("dhcp_leases_path", &self.dhcp_leases_path)
|
||||
.field("mac_name_cache_path", &self.mac_name_cache_path)
|
||||
.field("observation_store_path", &self.observation_store_path)
|
||||
.field("terminal", &self.terminal)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
@@ -65,6 +127,10 @@ const fn default_observation_sync_interval_seconds() -> u64 {
|
||||
60
|
||||
}
|
||||
|
||||
const fn default_observation_retention_days() -> u64 {
|
||||
DEFAULT_OBSERVATION_RETENTION_DAYS
|
||||
}
|
||||
|
||||
fn default_pid_file() -> PathBuf {
|
||||
DEFAULT_PID_FILE.into()
|
||||
}
|
||||
@@ -81,6 +147,24 @@ fn default_observation_store_path() -> PathBuf {
|
||||
DEFAULT_OBSERVATION_STORE_PATH.into()
|
||||
}
|
||||
|
||||
fn default_terminal_shell() -> PathBuf {
|
||||
"/bin/sh".into()
|
||||
}
|
||||
|
||||
const fn default_terminal_max_sessions() -> usize {
|
||||
DEFAULT_TERMINAL_MAX_SESSIONS
|
||||
}
|
||||
|
||||
const fn default_terminal_session_ttl_seconds() -> u64 {
|
||||
DEFAULT_TERMINAL_SESSION_TTL_SECONDS
|
||||
}
|
||||
|
||||
impl TerminalConfig {
|
||||
pub(crate) fn session_ttl(&self) -> Result<Option<std::time::Duration>> {
|
||||
checked_terminal_session_ttl(self.session_ttl_seconds).map_err(anyhow::Error::msg)
|
||||
}
|
||||
}
|
||||
|
||||
impl AgentConfig {
|
||||
pub fn local_path_envs(&self) -> Vec<(&'static str, &Path)> {
|
||||
vec![
|
||||
@@ -103,8 +187,10 @@ pub fn apply_local_path_env_to_command(cmd: &mut std::process::Command, config:
|
||||
pub fn load_config(path: &Path) -> Result<AgentConfig> {
|
||||
let content = std::fs::read_to_string(path)
|
||||
.with_context(|| format!("failed to read agent config {}", path.display()))?;
|
||||
toml::from_str(&content)
|
||||
.with_context(|| format!("failed to parse agent config {}", path.display()))
|
||||
let config: AgentConfig = toml::from_str(&content)
|
||||
.with_context(|| format!("failed to parse agent config {}", path.display()))?;
|
||||
config.terminal.session_ttl()?;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
pub fn save_config(path: &Path, config: &AgentConfig) -> Result<()> {
|
||||
@@ -183,10 +269,19 @@ mod tests {
|
||||
reconnect_base_ms: 123,
|
||||
reconnect_max_ms: 456,
|
||||
observation_sync_interval_seconds: 7,
|
||||
observation_retention_days: 3,
|
||||
pid_file: "/tmp/test-wakey-agent.pid".into(),
|
||||
dhcp_leases_path: "/tmp/test-dhcp.leases".into(),
|
||||
mac_name_cache_path: "/tmp/test-names.json".into(),
|
||||
observation_store_path: "/tmp/test-observations.json".into(),
|
||||
terminal: TerminalConfig {
|
||||
enabled: true,
|
||||
shell: "/bin/sh".into(),
|
||||
args: vec!["-l".into()],
|
||||
current_dir: Some("/tmp".into()),
|
||||
max_sessions: 2,
|
||||
session_ttl_seconds: 600,
|
||||
},
|
||||
};
|
||||
|
||||
save_config(&path, &config).expect("save");
|
||||
@@ -202,4 +297,82 @@ mod tests {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_without_retention_uses_current_default() {
|
||||
let config: AgentConfig = toml::from_str(
|
||||
r#"
|
||||
server_url = "https://example.com"
|
||||
agent_id = "agent-1"
|
||||
agent_token = "secret"
|
||||
"#,
|
||||
)
|
||||
.expect("config should parse");
|
||||
|
||||
assert_eq!(
|
||||
config.observation_retention_days,
|
||||
DEFAULT_OBSERVATION_RETENTION_DAYS
|
||||
);
|
||||
assert_eq!(config.terminal, TerminalConfig::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_terminal_config_defaults_optional_command_fields() {
|
||||
let config: AgentConfig = toml::from_str(
|
||||
r#"
|
||||
server_url = "https://example.com"
|
||||
agent_id = "agent-1"
|
||||
agent_token = "secret"
|
||||
|
||||
[terminal]
|
||||
enabled = true
|
||||
shell = "/bin/sh"
|
||||
max_sessions = 2
|
||||
"#,
|
||||
)
|
||||
.expect("existing terminal config should parse");
|
||||
|
||||
assert!(config.terminal.args.is_empty());
|
||||
assert!(config.terminal.current_dir.is_none());
|
||||
assert_eq!(
|
||||
config.terminal.session_ttl_seconds,
|
||||
DEFAULT_TERMINAL_SESSION_TTL_SECONDS
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_config_rejects_misspelled_fields() {
|
||||
let error = toml::from_str::<AgentConfig>(
|
||||
r#"
|
||||
server_url = "https://example.com"
|
||||
agent_id = "agent-1"
|
||||
agent_token = "secret"
|
||||
|
||||
[terminal]
|
||||
enabled = true
|
||||
max_session = 67
|
||||
"#,
|
||||
)
|
||||
.expect_err("unknown terminal fields must not silently use defaults");
|
||||
|
||||
assert!(error.to_string().contains("max_session"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_ttl_zero_is_unlimited_and_positive_is_exact() {
|
||||
let mut terminal = TerminalConfig {
|
||||
session_ttl_seconds: 0,
|
||||
..TerminalConfig::default()
|
||||
};
|
||||
assert_eq!(terminal.session_ttl().expect("unlimited TTL"), None);
|
||||
|
||||
terminal.session_ttl_seconds = 67;
|
||||
assert_eq!(
|
||||
terminal.session_ttl().expect("finite TTL"),
|
||||
Some(std::time::Duration::from_secs(67))
|
||||
);
|
||||
|
||||
terminal.session_ttl_seconds = u64::MAX;
|
||||
assert!(terminal.session_ttl().is_err());
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,15 @@ async fn dispatch_devs(req: DevsRequest) -> Result<CommandResult> {
|
||||
}
|
||||
|
||||
async fn dispatch_inventory(req: InventoryRequest, config: &AgentConfig) -> Result<CommandResult> {
|
||||
Ok(CommandResult::Inventory(
|
||||
inventory_for_config(req, config).await?,
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn inventory_for_config(
|
||||
req: InventoryRequest,
|
||||
config: &AgentConfig,
|
||||
) -> Result<wakey_core::DeviceInventory> {
|
||||
let query = req.into_inventory_query();
|
||||
let neighbors = wakey::wakey_linux::devices::query_neighbors(&query).await?;
|
||||
let leases = wakey::wakey_linux::dhcp::read_dhcp_leases_with_names_from_paths(
|
||||
@@ -72,6 +81,20 @@ async fn dispatch_inventory(req: InventoryRequest, config: &AgentConfig) -> Resu
|
||||
&config.mac_name_cache_path,
|
||||
)
|
||||
.await?;
|
||||
match wakey::wakey_linux::observations::prune_stale_observations_from_path(
|
||||
&config.observation_store_path,
|
||||
config.observation_retention_days,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(removed) if removed > 0 => {
|
||||
debug!(removed, "pruned stale local hook observations");
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(err) => {
|
||||
warn!(error = %err, "failed pruning stale local hook observations");
|
||||
}
|
||||
}
|
||||
let observations = match wakey::wakey_linux::observations::list_local_observations_from_path(
|
||||
&config.observation_store_path,
|
||||
)
|
||||
@@ -104,7 +127,7 @@ async fn dispatch_inventory(req: InventoryRequest, config: &AgentConfig) -> Resu
|
||||
rows = inventory.devices.len(),
|
||||
"dispatched inventory command"
|
||||
);
|
||||
Ok(CommandResult::Inventory(inventory))
|
||||
Ok(inventory)
|
||||
}
|
||||
|
||||
async fn dispatch_wake(req: WakeRequest) -> Result<CommandResult> {
|
||||
|
||||
+18
-27
@@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::config::{AgentConfig, save_config_with_backup};
|
||||
use crate::config::{AgentConfig, DEFAULT_CONFIG, save_config_with_backup};
|
||||
|
||||
pub struct EnrollOutcome {
|
||||
pub config: AgentConfig,
|
||||
@@ -55,32 +55,12 @@ pub async fn enroll(
|
||||
.await
|
||||
.context("failed to decode enrollment response")?;
|
||||
|
||||
let config = AgentConfig {
|
||||
server_url: payload.server_url.unwrap_or(server_url),
|
||||
agent_id: payload.agent_id,
|
||||
agent_token: payload.agent_token,
|
||||
reconnect_base_ms: base_config
|
||||
.map(|config| config.reconnect_base_ms)
|
||||
.unwrap_or(1_000),
|
||||
reconnect_max_ms: base_config
|
||||
.map(|config| config.reconnect_max_ms)
|
||||
.unwrap_or(30_000),
|
||||
observation_sync_interval_seconds: base_config
|
||||
.map(|config| config.observation_sync_interval_seconds)
|
||||
.unwrap_or(60),
|
||||
pid_file: base_config
|
||||
.map(|config| config.pid_file.clone())
|
||||
.unwrap_or_else(|| crate::config::DEFAULT_PID_FILE.into()),
|
||||
dhcp_leases_path: base_config
|
||||
.map(|config| config.dhcp_leases_path.clone())
|
||||
.unwrap_or_else(|| "/tmp/dhcp.leases".into()),
|
||||
mac_name_cache_path: base_config
|
||||
.map(|config| config.mac_name_cache_path.clone())
|
||||
.unwrap_or_else(|| "/tmp/wakey_mac_names.json".into()),
|
||||
observation_store_path: base_config
|
||||
.map(|config| config.observation_store_path.clone())
|
||||
.unwrap_or_else(|| "/tmp/wakey_observations.json".into()),
|
||||
};
|
||||
let mut config = base_config
|
||||
.cloned()
|
||||
.unwrap_or_else(|| (*DEFAULT_CONFIG).clone());
|
||||
config.server_url = payload.server_url.unwrap_or(server_url);
|
||||
config.agent_id = payload.agent_id;
|
||||
config.agent_token = payload.agent_token;
|
||||
let backup_path = save_config_with_backup(config_path, &config)?;
|
||||
info!(agent_id = %config.agent_id, config_path = %config_path.display(), "agent enrollment succeeded and config was written");
|
||||
Ok(EnrollOutcome {
|
||||
@@ -166,10 +146,19 @@ mod tests {
|
||||
reconnect_base_ms: 2_000,
|
||||
reconnect_max_ms: 60_000,
|
||||
observation_sync_interval_seconds: 30,
|
||||
observation_retention_days: 11,
|
||||
pid_file: "/tmp/custom-wakey-agent.pid".into(),
|
||||
dhcp_leases_path: "/tmp/custom-dhcp.leases".into(),
|
||||
mac_name_cache_path: "/tmp/custom-names.json".into(),
|
||||
observation_store_path: "/tmp/custom-observations.json".into(),
|
||||
terminal: crate::config::TerminalConfig {
|
||||
enabled: true,
|
||||
shell: "/bin/ash".into(),
|
||||
args: vec!["-l".into()],
|
||||
current_dir: Some("/root".into()),
|
||||
max_sessions: 2,
|
||||
session_ttl_seconds: 600,
|
||||
},
|
||||
};
|
||||
|
||||
let outcome = enroll(&server_url, "enroll-abc", &path, Some(&base_config))
|
||||
@@ -180,8 +169,10 @@ mod tests {
|
||||
assert_eq!(config.agent_id, "agent-123");
|
||||
assert_eq!(config.agent_token, "token-xyz");
|
||||
assert_eq!(config.server_url, "https://control.example.com");
|
||||
assert_eq!(config.observation_retention_days, 11);
|
||||
assert_eq!(config.pid_file, base_config.pid_file);
|
||||
assert_eq!(config.dhcp_leases_path, base_config.dhcp_leases_path);
|
||||
assert_eq!(config.terminal, base_config.terminal);
|
||||
assert!(outcome.backup_path.is_none());
|
||||
|
||||
let persisted = crate::config::load_config(&path).expect("load persisted config");
|
||||
|
||||
+6
-13
@@ -2,14 +2,15 @@ mod cli;
|
||||
mod config;
|
||||
mod dispatch;
|
||||
mod enroll;
|
||||
mod protocol;
|
||||
mod serve;
|
||||
mod session;
|
||||
mod terminal;
|
||||
mod tracing;
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::Parser;
|
||||
use cli::{Cli, Command, InitConfigArgs, ObserveCommand};
|
||||
use wakey_agent::protocol;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
@@ -248,18 +249,7 @@ fn init_config(args: InitConfigArgs) -> Result<()> {
|
||||
let mut cfg = if let Some(from_config) = &args.from_config {
|
||||
config::load_config(from_config)?
|
||||
} else {
|
||||
config::AgentConfig {
|
||||
server_url: "https://wakey.ldlda.com".to_string(),
|
||||
agent_id: "REPLACE_ME_AGENT_ID".to_string(),
|
||||
agent_token: "REPLACE_ME_AGENT_TOKEN".to_string(),
|
||||
reconnect_base_ms: 1_000,
|
||||
reconnect_max_ms: 30_000,
|
||||
observation_sync_interval_seconds: 60,
|
||||
pid_file: config::DEFAULT_PID_FILE.into(),
|
||||
dhcp_leases_path: "/tmp/dhcp.leases".into(),
|
||||
mac_name_cache_path: "/tmp/wakey_mac_names.json".into(),
|
||||
observation_store_path: "/tmp/wakey_observations.json".into(),
|
||||
}
|
||||
(*config::DEFAULT_CONFIG).clone()
|
||||
};
|
||||
|
||||
if let Some(server_url) = args.server_url {
|
||||
@@ -271,6 +261,9 @@ fn init_config(args: InitConfigArgs) -> Result<()> {
|
||||
if let Some(agent_token) = args.agent_token {
|
||||
cfg.agent_token = agent_token;
|
||||
}
|
||||
if let Some(days) = args.observation_retention_days {
|
||||
cfg.observation_retention_days = days;
|
||||
}
|
||||
|
||||
if let Some(path) = &args.config {
|
||||
config::save_config(path, &cfg)?;
|
||||
|
||||
@@ -2,6 +2,7 @@ use macaddr::MacAddr;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
use std::net::IpAddr;
|
||||
use std::time::{Duration, Instant};
|
||||
use wakey_core::parse::mac;
|
||||
use wakey_core::{
|
||||
Device, DeviceInventory, DhcpLeaseWithState, InterfaceSummary, InventoryQuery,
|
||||
@@ -17,6 +18,138 @@ impl RequestId {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct TerminalId(String);
|
||||
|
||||
impl TerminalId {
|
||||
pub fn new(value: impl Into<String>) -> Result<Self, String> {
|
||||
let value = value.into();
|
||||
if value.trim().is_empty() {
|
||||
return Err("terminal_id must not be empty".into());
|
||||
}
|
||||
Ok(Self(value))
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for TerminalId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
self.0.fmt(f)
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<String> for TerminalId {
|
||||
type Error = String;
|
||||
|
||||
fn try_from(value: String) -> Result<Self, Self::Error> {
|
||||
Self::new(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for TerminalId {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let raw = String::deserialize(deserializer)?;
|
||||
Self::try_from(raw).map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AgentCapability {
|
||||
Terminal,
|
||||
}
|
||||
|
||||
pub const DEFAULT_TERMINAL_MAX_SESSIONS: usize = 2;
|
||||
pub const DEFAULT_TERMINAL_SESSION_TTL_SECONDS: u64 = 12 * 60 * 60;
|
||||
|
||||
/// Validates a terminal TTL against the platform's monotonic timer range.
|
||||
/// Zero is the explicit unlimited policy.
|
||||
pub fn checked_terminal_session_ttl(
|
||||
session_ttl_seconds: u64,
|
||||
) -> Result<Option<Duration>, &'static str> {
|
||||
if session_ttl_seconds == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
let ttl = Duration::from_secs(session_ttl_seconds);
|
||||
Instant::now()
|
||||
.checked_add(ttl)
|
||||
.ok_or("terminal session TTL is too large for the platform timer")?;
|
||||
Ok(Some(ttl))
|
||||
}
|
||||
|
||||
const fn default_terminal_session_ttl_seconds() -> u64 {
|
||||
DEFAULT_TERMINAL_SESSION_TTL_SECONDS
|
||||
}
|
||||
|
||||
/// Optional parameters attached to advertised agent capabilities.
|
||||
///
|
||||
/// Keep this separate from `AgentCapability`: the capability list remains a
|
||||
/// compact, backward-compatible feature check, while this object can grow as
|
||||
/// individual capabilities gain configurable limits or modes.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AgentCapabilityOptions {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub terminal: Option<TerminalCapabilityOptions>,
|
||||
}
|
||||
|
||||
impl AgentCapabilityOptions {
|
||||
fn is_empty(&self) -> bool {
|
||||
self.terminal.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct TerminalCapabilityOptions {
|
||||
pub max_sessions: usize,
|
||||
#[serde(default = "default_terminal_session_ttl_seconds")]
|
||||
pub session_ttl_seconds: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AgentTerminalSession {
|
||||
pub terminal_id: TerminalId,
|
||||
pub created_at_unix: u64,
|
||||
/// The policy captured when this PTY was created. Zero means unlimited.
|
||||
#[serde(default = "default_terminal_session_ttl_seconds")]
|
||||
pub session_ttl_seconds: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum TerminalControl {
|
||||
Resize { rows: u16, cols: u16 },
|
||||
Snapshot,
|
||||
Ready,
|
||||
Exited { exit_code: Option<i32> },
|
||||
Error { code: String, message: String },
|
||||
Close,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum TerminalAgentHandshake {
|
||||
Auth {
|
||||
agent_id: String,
|
||||
relay_token: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum TerminalOperatorHandshake {
|
||||
Attach {
|
||||
attachment_token: String,
|
||||
operator_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl TryFrom<String> for RequestId {
|
||||
type Error = String;
|
||||
|
||||
@@ -153,6 +286,10 @@ pub enum CommandResult {
|
||||
pub enum ClientMessage {
|
||||
Hello {
|
||||
agent_id: String,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
capabilities: Vec<AgentCapability>,
|
||||
#[serde(default, skip_serializing_if = "AgentCapabilityOptions::is_empty")]
|
||||
capability_options: AgentCapabilityOptions,
|
||||
},
|
||||
Auth {
|
||||
agent_id: String,
|
||||
@@ -173,6 +310,13 @@ pub enum ClientMessage {
|
||||
request_id: RequestId,
|
||||
error: ErrorPayload,
|
||||
},
|
||||
TerminalRejected {
|
||||
terminal_id: TerminalId,
|
||||
error: ErrorPayload,
|
||||
},
|
||||
TerminalSessions {
|
||||
sessions: Vec<AgentTerminalSession>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
@@ -183,6 +327,20 @@ pub enum ServerMessage {
|
||||
command: AgentCommand,
|
||||
},
|
||||
SyncDeviceSnapshot,
|
||||
SyncTerminalSessions,
|
||||
OpenTerminal {
|
||||
terminal_id: TerminalId,
|
||||
relay_token: String,
|
||||
rows: u16,
|
||||
cols: u16,
|
||||
},
|
||||
CloseTerminal {
|
||||
terminal_id: TerminalId,
|
||||
},
|
||||
ResumeTerminal {
|
||||
terminal_id: TerminalId,
|
||||
relay_token: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -209,6 +367,16 @@ mod tests {
|
||||
assert!(err.contains("must not be empty"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_id_deserialization_enforces_validation() {
|
||||
let valid: TerminalId = serde_json::from_str(r#""term-1""#).expect("valid terminal id");
|
||||
assert_eq!(valid.as_str(), "term-1");
|
||||
|
||||
let error = serde_json::from_str::<TerminalId>(r#"" ""#)
|
||||
.expect_err("empty terminal id must fail");
|
||||
assert!(error.to_string().contains("must not be empty"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_result_with_devs_serializes() {
|
||||
let msg = ClientMessage::Result {
|
||||
@@ -232,4 +400,103 @@ mod tests {
|
||||
assert!(json.contains("\"type\":\"device_snapshot\""));
|
||||
assert!(json.contains("\"devices\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_messages_serialize_with_explicit_frame_types() {
|
||||
let message = ServerMessage::OpenTerminal {
|
||||
terminal_id: TerminalId::new("term-1").expect("terminal id"),
|
||||
relay_token: "secret".into(),
|
||||
rows: 30,
|
||||
cols: 120,
|
||||
};
|
||||
let json = serde_json::to_string(&message).expect("serialize open terminal");
|
||||
assert!(json.contains("\"type\":\"open_terminal\""));
|
||||
assert!(json.contains("\"terminal_id\":\"term-1\""));
|
||||
|
||||
let resize = TerminalControl::Resize {
|
||||
rows: 40,
|
||||
cols: 160,
|
||||
};
|
||||
let json = serde_json::to_string(&resize).expect("serialize resize");
|
||||
assert_eq!(json, r#"{"type":"resize","rows":40,"cols":160}"#);
|
||||
let snapshot =
|
||||
serde_json::to_string(&TerminalControl::Snapshot).expect("serialize snapshot");
|
||||
assert_eq!(snapshot, r#"{"type":"snapshot"}"#);
|
||||
let operator = TerminalOperatorHandshake::Attach {
|
||||
attachment_token: "attach-secret".into(),
|
||||
operator_id: "browser-tab".into(),
|
||||
};
|
||||
let json = serde_json::to_string(&operator).expect("serialize operator handshake");
|
||||
assert_eq!(
|
||||
json,
|
||||
r#"{"type":"attach","attachment_token":"attach-secret","operator_id":"browser-tab"}"#
|
||||
);
|
||||
|
||||
let inventory = ClientMessage::TerminalSessions {
|
||||
sessions: vec![AgentTerminalSession {
|
||||
terminal_id: TerminalId::new("term-1").expect("terminal id"),
|
||||
created_at_unix: 42,
|
||||
session_ttl_seconds: 600,
|
||||
}],
|
||||
};
|
||||
let json = serde_json::to_string(&inventory).expect("serialize terminal inventory");
|
||||
assert!(json.contains("\"type\":\"terminal_sessions\""));
|
||||
assert!(json.contains("\"created_at_unix\":42"));
|
||||
|
||||
let resume = ServerMessage::ResumeTerminal {
|
||||
terminal_id: TerminalId::new("term-1").expect("terminal id"),
|
||||
relay_token: "replacement".into(),
|
||||
};
|
||||
let json = serde_json::to_string(&resume).expect("serialize terminal resume");
|
||||
assert!(json.contains("\"type\":\"resume_terminal\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hello_serializes_typed_capability_options() {
|
||||
let message = ClientMessage::Hello {
|
||||
agent_id: "router".into(),
|
||||
capabilities: vec![AgentCapability::Terminal],
|
||||
capability_options: AgentCapabilityOptions {
|
||||
terminal: Some(TerminalCapabilityOptions {
|
||||
max_sessions: 3,
|
||||
session_ttl_seconds: 600,
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
let value = serde_json::to_value(message).expect("serialize hello");
|
||||
assert_eq!(value["capability_options"]["terminal"]["max_sessions"], 3);
|
||||
assert_eq!(
|
||||
value["capability_options"]["terminal"]["session_ttl_seconds"],
|
||||
600
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_ttl_defaults_for_old_messages_and_preserves_explicit_zero() {
|
||||
let old_options: TerminalCapabilityOptions =
|
||||
serde_json::from_value(serde_json::json!({ "max_sessions": 2 }))
|
||||
.expect("deserialize old capability options");
|
||||
assert_eq!(
|
||||
old_options.session_ttl_seconds,
|
||||
DEFAULT_TERMINAL_SESSION_TTL_SECONDS
|
||||
);
|
||||
|
||||
let old_session: AgentTerminalSession = serde_json::from_value(serde_json::json!({
|
||||
"terminal_id": "term-old",
|
||||
"created_at_unix": 42
|
||||
}))
|
||||
.expect("deserialize old terminal inventory");
|
||||
assert_eq!(
|
||||
old_session.session_ttl_seconds,
|
||||
DEFAULT_TERMINAL_SESSION_TTL_SECONDS
|
||||
);
|
||||
|
||||
let unlimited: TerminalCapabilityOptions = serde_json::from_value(serde_json::json!({
|
||||
"max_sessions": 2,
|
||||
"session_ttl_seconds": 0
|
||||
}))
|
||||
.expect("deserialize unlimited capability options");
|
||||
assert_eq!(unlimited.session_ttl_seconds, 0);
|
||||
}
|
||||
}
|
||||
+119
-8
@@ -7,13 +7,20 @@ use tokio_tungstenite::{connect_async, tungstenite::Message};
|
||||
use tracing::{debug, error, info, info_span, warn};
|
||||
|
||||
use crate::config::AgentConfig;
|
||||
use crate::dispatch::dispatch_command;
|
||||
use crate::protocol::{AgentCommand, ClientMessage, ErrorPayload, ServerMessage};
|
||||
use crate::dispatch::{dispatch_command, inventory_for_config};
|
||||
use crate::protocol::{
|
||||
AgentCapability, AgentCapabilityOptions, AgentCommand, ClientMessage, ErrorPayload,
|
||||
ServerMessage, TerminalCapabilityOptions,
|
||||
};
|
||||
use crate::terminal::TerminalManager;
|
||||
|
||||
pub async fn run(config: AgentConfig) -> Result<()> {
|
||||
// Terminal workers belong to the agent process, not a single control socket.
|
||||
// Keeping this manager outside the reconnect loop lets PTYs survive CC loss.
|
||||
let (terminal_manager, mut terminal_events) = TerminalManager::new(&config);
|
||||
let mut backoff = config.reconnect_base_ms.max(100);
|
||||
loop {
|
||||
match run_once(&config).await {
|
||||
match run_once(&config, &terminal_manager, &mut terminal_events).await {
|
||||
Ok(()) => {
|
||||
backoff = config.reconnect_base_ms.max(100);
|
||||
}
|
||||
@@ -26,7 +33,13 @@ pub async fn run(config: AgentConfig) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_once(config: &AgentConfig) -> Result<()> {
|
||||
async fn run_once(
|
||||
config: &AgentConfig,
|
||||
terminal_manager: &TerminalManager,
|
||||
terminal_events: &mut tokio::sync::mpsc::UnboundedReceiver<
|
||||
crate::terminal::TerminalManagerEvent,
|
||||
>,
|
||||
) -> Result<()> {
|
||||
let session_id = format!(
|
||||
"{}-{}",
|
||||
std::process::id(),
|
||||
@@ -71,6 +84,16 @@ async fn run_once(config: &AgentConfig) -> Result<()> {
|
||||
&mut sink,
|
||||
&ClientMessage::Hello {
|
||||
agent_id: config.agent_id.clone(),
|
||||
capabilities: agent_capabilities(config),
|
||||
capability_options: AgentCapabilityOptions {
|
||||
terminal: config
|
||||
.terminal
|
||||
.enabled
|
||||
.then_some(TerminalCapabilityOptions {
|
||||
max_sessions: config.terminal.max_sessions.max(1),
|
||||
session_ttl_seconds: config.terminal.session_ttl_seconds,
|
||||
}),
|
||||
},
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
@@ -82,6 +105,7 @@ async fn run_once(config: &AgentConfig) -> Result<()> {
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
send_terminal_sessions(&mut sink, terminal_manager).await?;
|
||||
info!(agent_id = %config.agent_id, "agent websocket session authenticated");
|
||||
|
||||
let mut heartbeat = interval(Duration::from_secs(30));
|
||||
@@ -91,9 +115,21 @@ async fn run_once(config: &AgentConfig) -> Result<()> {
|
||||
));
|
||||
snapshot_sync.set_missed_tick_behavior(MissedTickBehavior::Skip);
|
||||
snapshot_sync.reset();
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
Some(event) = terminal_events.recv() => {
|
||||
send_json(
|
||||
&mut sink,
|
||||
&ClientMessage::TerminalRejected {
|
||||
terminal_id: event.terminal_id,
|
||||
error: ErrorPayload {
|
||||
code: "terminal_worker_failed".into(),
|
||||
message: event.error,
|
||||
retryable: Some(false),
|
||||
},
|
||||
},
|
||||
).await?;
|
||||
}
|
||||
_ = heartbeat.tick() => {
|
||||
send_json(&mut sink, &ClientMessage::Heartbeat {
|
||||
agent_id: config.agent_id.clone(),
|
||||
@@ -118,7 +154,7 @@ async fn run_once(config: &AgentConfig) -> Result<()> {
|
||||
Message::Text(text) => {
|
||||
match serde_json::from_str::<ServerMessage>(&text) {
|
||||
Ok(message) => {
|
||||
handle_server_message(config, &mut sink, &mut snapshot_sync, message).await?;
|
||||
handle_server_message(config, terminal_manager, &mut sink, &mut snapshot_sync, message).await?;
|
||||
}
|
||||
Err(err) => {
|
||||
// Allow the server to introduce extra frame types without
|
||||
@@ -145,13 +181,34 @@ async fn run_once(config: &AgentConfig) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_terminal_sessions<S>(sink: &mut S, manager: &TerminalManager) -> Result<()>
|
||||
where
|
||||
S: SinkExt<Message> + Unpin,
|
||||
<S as futures_util::Sink<Message>>::Error: std::error::Error + Send + Sync + 'static,
|
||||
{
|
||||
send_json(
|
||||
sink,
|
||||
&ClientMessage::TerminalSessions {
|
||||
sessions: manager.sessions(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn send_device_snapshot_ws<S>(sink: &mut S, config: &AgentConfig) -> Result<usize>
|
||||
where
|
||||
S: SinkExt<Message> + Unpin,
|
||||
<S as futures_util::Sink<Message>>::Error: std::error::Error + Send + Sync + 'static,
|
||||
{
|
||||
let query = wakey_core::InventoryQueryBuilder::new().build();
|
||||
let inventory = wakey::inventory(query)
|
||||
let req = crate::protocol::InventoryRequest {
|
||||
query: None,
|
||||
name: None,
|
||||
ips: Vec::new(),
|
||||
devs: Vec::new(),
|
||||
nuds: Vec::new(),
|
||||
macs: Vec::new(),
|
||||
};
|
||||
let inventory = inventory_for_config(req, config)
|
||||
.await
|
||||
.context("failed to run inventory for device snapshot")?;
|
||||
let count = inventory.devices.len();
|
||||
@@ -174,6 +231,7 @@ pub fn next_backoff_ms(current_ms: u64, max_ms: u64) -> u64 {
|
||||
|
||||
async fn handle_server_message<S>(
|
||||
config: &AgentConfig,
|
||||
terminal_manager: &TerminalManager,
|
||||
sink: &mut S,
|
||||
snapshot_sync: &mut tokio::time::Interval,
|
||||
message: ServerMessage,
|
||||
@@ -216,6 +274,48 @@ where
|
||||
send_device_snapshot_ws(sink, config).await?;
|
||||
snapshot_sync.reset();
|
||||
}
|
||||
ServerMessage::SyncTerminalSessions => {
|
||||
send_terminal_sessions(sink, terminal_manager).await?;
|
||||
}
|
||||
ServerMessage::OpenTerminal {
|
||||
terminal_id,
|
||||
relay_token,
|
||||
rows,
|
||||
cols,
|
||||
} => {
|
||||
info!(terminal_id = %terminal_id, rows, cols, "received terminal open request");
|
||||
if let Err(err) =
|
||||
terminal_manager.open(config, terminal_id.clone(), relay_token, rows, cols)
|
||||
{
|
||||
error!(terminal_id = %terminal_id, error = %err, "terminal open request rejected");
|
||||
send_json(
|
||||
sink,
|
||||
&ClientMessage::TerminalRejected {
|
||||
terminal_id,
|
||||
error: ErrorPayload {
|
||||
code: "terminal_open_rejected".into(),
|
||||
message: err.to_string(),
|
||||
retryable: Some(false),
|
||||
},
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
ServerMessage::CloseTerminal { terminal_id } => {
|
||||
info!(terminal_id = %terminal_id, "received terminal close request");
|
||||
terminal_manager.close(&terminal_id);
|
||||
}
|
||||
ServerMessage::ResumeTerminal {
|
||||
terminal_id,
|
||||
relay_token,
|
||||
} => {
|
||||
info!(terminal_id = %terminal_id, "received terminal relay resume request");
|
||||
if let Err(err) = terminal_manager.resume(&terminal_id, relay_token) {
|
||||
warn!(terminal_id = %terminal_id, error = %err, "terminal relay resume rejected");
|
||||
send_terminal_sessions(sink, terminal_manager).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -294,9 +394,20 @@ fn client_message_kind(message: &ClientMessage) -> &'static str {
|
||||
ClientMessage::DeviceSnapshot { .. } => "device_snapshot",
|
||||
ClientMessage::Result { .. } => "result",
|
||||
ClientMessage::Error { .. } => "error",
|
||||
ClientMessage::TerminalRejected { .. } => "terminal_rejected",
|
||||
ClientMessage::TerminalSessions { .. } => "terminal_sessions",
|
||||
}
|
||||
}
|
||||
|
||||
fn agent_capabilities(config: &AgentConfig) -> Vec<AgentCapability> {
|
||||
#[cfg(unix)]
|
||||
if config.terminal.enabled {
|
||||
return vec![AgentCapability::Terminal];
|
||||
}
|
||||
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
pub fn websocket_url(server_url: &str) -> Result<url::Url> {
|
||||
let base = url::Url::parse(server_url).context("invalid server_url")?;
|
||||
let scheme = match base.scheme() {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "INSERT INTO audit_events\n (event_key, event_id, ts_unix, actor_type, actor_id, agent_id, request_id,\n event_type, outcome, latency_ms, message, metadata_json)\n VALUES (?1, ?2, ?3, 'test', NULL, NULL, NULL, 'test', 'ok', NULL, 'old', '{}')",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 3
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "6ce45cc94cbfdd948aad5fb986f9b5976c79bb63759bc9ecff1a7873e2d70137"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT COUNT(*) FROM alert_transitions WHERE transition_id = ?1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "COUNT(*)",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "b324587b94ee2e98dc8eff5f6735303a2784b74f49068ce8e946cedcd8d12c4e"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT COUNT(*) FROM audit_events WHERE message = ?1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "COUNT(*)",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "bd1666cea8840df04ddcdc8a0c42b9b7771f9bf63c45613c4b6f8b86be479d70"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "DELETE FROM audit_events WHERE ts_unix < ?1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "ca612fee8c80f815b33dbc088cc7e0a7552f1f4e38390959e14ea5cc765bf2c7"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "DELETE FROM alert_transitions WHERE ts_unix < ?1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "ce850a32c8702595bbc3207bf86d53209c48be145562192b3a8f84903d39eac1"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT COUNT(*) FROM audit_events WHERE event_id = ?1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "COUNT(*)",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "dc7056b7421dcc83648cbd360273ebcce1156ce0f7da2d39f054025ab9b66b6b"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "INSERT INTO alert_transitions\n (transition_key, transition_id, ts_unix, alert_id, kind, agent_id,\n from_status, to_status, message, metadata_json)\n VALUES (?1, ?2, ?3, 'alert-old', 'test', NULL, NULL, 'active', 'old', '{}')",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 3
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "e83a43217a3bfaacd2f5e76df1fd05447558646fb2b47963751a7ab53b3a99b6"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "wakey-control-plane"
|
||||
version = "0.3.0"
|
||||
version = "0.3.6"
|
||||
edition = "2024"
|
||||
publish = ["gitea"]
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
CREATE TABLE agent_device_endpoints (
|
||||
agent_id TEXT NOT NULL,
|
||||
device_key TEXT NOT NULL,
|
||||
endpoint_key TEXT NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
mac TEXT,
|
||||
ip TEXT,
|
||||
hostname TEXT,
|
||||
interface TEXT,
|
||||
presence TEXT NOT NULL,
|
||||
first_seen_unix INTEGER NOT NULL,
|
||||
last_seen_unix INTEGER NOT NULL,
|
||||
PRIMARY KEY (agent_id, device_key, endpoint_key),
|
||||
FOREIGN KEY (agent_id, device_key)
|
||||
REFERENCES agent_devices(agent_id, device_key) ON DELETE CASCADE
|
||||
) WITHOUT ROWID;
|
||||
|
||||
CREATE INDEX agent_device_endpoints_mac_idx
|
||||
ON agent_device_endpoints(mac);
|
||||
|
||||
CREATE INDEX agent_device_endpoints_ip_idx
|
||||
ON agent_device_endpoints(ip);
|
||||
@@ -1,4 +1,5 @@
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use axum::Json;
|
||||
@@ -7,12 +8,14 @@ use axum::extract::{Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::IntoResponse;
|
||||
use serde::Deserialize;
|
||||
use tokio::select;
|
||||
use tokio::sync::{RwLock, broadcast};
|
||||
use tokio::time::Duration;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::api::ApiError;
|
||||
use crate::runtime::AppState;
|
||||
use crate::state::{AlertState, AuditEvent, AuditEventFilter};
|
||||
use crate::state::{AlertState, AlertTransition, AuditEvent, AuditEventFilter};
|
||||
|
||||
struct AlertBuildContext<'a> {
|
||||
now_unix: u64,
|
||||
@@ -26,21 +29,14 @@ struct AlertBuildContext<'a> {
|
||||
enroll_rejected_threshold: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ActiveAlertsQuery {
|
||||
pub lookback_seconds: Option<u64>,
|
||||
pub timeout_threshold: Option<u64>,
|
||||
pub auth_rejected_threshold: Option<u64>,
|
||||
pub enroll_rejected_threshold: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AlertHistoryQuery {
|
||||
pub since_unix: Option<u64>,
|
||||
pub limit: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct AlertRuleConfig {
|
||||
pub lookback_seconds: Option<u64>,
|
||||
pub timeout_threshold: Option<u64>,
|
||||
@@ -48,25 +44,255 @@ pub struct AlertRuleConfig {
|
||||
pub enroll_rejected_threshold: Option<u64>,
|
||||
}
|
||||
|
||||
pub async fn active_alerts(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<ActiveAlertsQuery>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let alerts = evaluate_alerts(
|
||||
&state,
|
||||
AlertRuleConfig {
|
||||
lookback_seconds: query.lookback_seconds,
|
||||
timeout_threshold: query.timeout_threshold,
|
||||
auth_rejected_threshold: query.auth_rejected_threshold,
|
||||
enroll_rejected_threshold: query.enroll_rejected_threshold,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
impl Default for AlertRuleConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
lookback_seconds: Some(900),
|
||||
timeout_threshold: Some(3),
|
||||
auth_rejected_threshold: Some(3),
|
||||
enroll_rejected_threshold: Some(5),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(err) = state.store.sync_alert_transitions(&alerts).await {
|
||||
warn!(error = %err, "failed to sync alert transitions");
|
||||
impl AlertRuleConfig {
|
||||
fn validate(&self) -> Result<(), ApiError> {
|
||||
let defaults = Self::default();
|
||||
let overrides = [
|
||||
(
|
||||
"lookback_seconds",
|
||||
self.lookback_seconds,
|
||||
defaults.lookback_seconds,
|
||||
),
|
||||
(
|
||||
"timeout_threshold",
|
||||
self.timeout_threshold,
|
||||
defaults.timeout_threshold,
|
||||
),
|
||||
(
|
||||
"auth_rejected_threshold",
|
||||
self.auth_rejected_threshold,
|
||||
defaults.auth_rejected_threshold,
|
||||
),
|
||||
(
|
||||
"enroll_rejected_threshold",
|
||||
self.enroll_rejected_threshold,
|
||||
defaults.enroll_rejected_threshold,
|
||||
),
|
||||
];
|
||||
if let Some((field, _, _)) = overrides
|
||||
.into_iter()
|
||||
.find(|(_, requested, default)| requested.is_some() && requested != default)
|
||||
{
|
||||
return Err(ApiError::new(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"unsupported_alert_override",
|
||||
format!("custom alert {field} is not supported; omit it or use its default value"),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct AlertsSnapshot {
|
||||
pub ts_unix: u64,
|
||||
pub alerts: Vec<AlertState>,
|
||||
pub recent_transitions: Vec<AlertTransition>,
|
||||
}
|
||||
|
||||
/// Holds the latest alert snapshot computed by the background evaluator and
|
||||
/// broadcasts each new snapshot to connected `alerts/ws` subscribers.
|
||||
#[derive(Clone)]
|
||||
pub struct AlertsHub {
|
||||
tx: broadcast::Sender<String>,
|
||||
status: Arc<RwLock<HubStatus>>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct HubStatus {
|
||||
latest_payload: Option<String>,
|
||||
latest_alerts: Vec<AlertState>,
|
||||
last_success_unix: Option<u64>,
|
||||
failure: Option<String>,
|
||||
last_health_available: Option<bool>,
|
||||
}
|
||||
|
||||
const ALERT_SNAPSHOT_MAX_AGE_SECS: u64 = 15;
|
||||
|
||||
impl Default for AlertsHub {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl AlertsHub {
|
||||
pub fn new() -> Self {
|
||||
let (tx, _) = broadcast::channel(16);
|
||||
Self {
|
||||
tx,
|
||||
status: Arc::new(RwLock::new(HubStatus::default())),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn subscribe(&self) -> broadcast::Receiver<String> {
|
||||
self.tx.subscribe()
|
||||
}
|
||||
|
||||
pub async fn latest_alerts(&self) -> Vec<AlertState> {
|
||||
self.status.read().await.latest_alerts.clone()
|
||||
}
|
||||
|
||||
async fn mark_unavailable(&self, reason: impl Into<String>) {
|
||||
let mut status = self.status.write().await;
|
||||
if status.failure.is_some() {
|
||||
return;
|
||||
}
|
||||
status.failure = Some(reason.into());
|
||||
let reason = status.failure.as_deref().unwrap_or_default().to_owned();
|
||||
status.last_health_available = Some(false);
|
||||
self.broadcast_health(false, Some(reason));
|
||||
}
|
||||
|
||||
async fn unavailable_reason(&self) -> Option<String> {
|
||||
let status = self.status.read().await;
|
||||
evaluator_unavailable_reason(&status, now_unix())
|
||||
}
|
||||
|
||||
async fn resync_payloads(&self) -> (String, Option<String>) {
|
||||
let status = self.status.read().await;
|
||||
(
|
||||
encode_health(evaluator_unavailable_reason(&status, now_unix())),
|
||||
status.latest_payload.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
async fn broadcast_health_if_changed(&self) {
|
||||
let mut status = self.status.write().await;
|
||||
if status.last_success_unix.is_none() || status.failure.is_some() {
|
||||
return;
|
||||
}
|
||||
let reason = evaluator_unavailable_reason(&status, now_unix());
|
||||
let available = reason.is_none();
|
||||
if status.last_health_available == Some(available) {
|
||||
return;
|
||||
}
|
||||
status.last_health_available = Some(available);
|
||||
self.broadcast_health(available, reason);
|
||||
}
|
||||
|
||||
fn broadcast_health(&self, available: bool, reason: Option<String>) {
|
||||
let _ = self.tx.send(encode_health_message(available, reason));
|
||||
}
|
||||
|
||||
pub async fn publish(&self, snapshot: AlertsSnapshot) {
|
||||
let payload = serde_json::json!({
|
||||
"type": "alerts_snapshot",
|
||||
"ts_unix": snapshot.ts_unix,
|
||||
"alerts": snapshot.alerts,
|
||||
"recent_transitions": snapshot.recent_transitions,
|
||||
});
|
||||
let Ok(encoded) = serde_json::to_string(&payload) else {
|
||||
warn!("failed to encode alerts snapshot payload");
|
||||
return;
|
||||
};
|
||||
let mut status = self.status.write().await;
|
||||
let was_unavailable = evaluator_unavailable_reason(&status, now_unix()).is_some();
|
||||
status.latest_alerts = snapshot.alerts;
|
||||
status.latest_payload = Some(encoded.clone());
|
||||
status.last_success_unix = Some(snapshot.ts_unix);
|
||||
status.failure = None;
|
||||
let available = evaluator_unavailable_reason(&status, now_unix()).is_none();
|
||||
if available {
|
||||
status.last_health_available = Some(true);
|
||||
}
|
||||
if was_unavailable && available {
|
||||
self.broadcast_health(true, None);
|
||||
}
|
||||
let _ = self.tx.send(encoded);
|
||||
}
|
||||
}
|
||||
|
||||
fn evaluator_unavailable_reason(status: &HubStatus, now: u64) -> Option<String> {
|
||||
if let Some(failure) = &status.failure {
|
||||
return Some(failure.clone());
|
||||
}
|
||||
match status.last_success_unix {
|
||||
Some(ts) if now.saturating_sub(ts) <= ALERT_SNAPSHOT_MAX_AGE_SECS => None,
|
||||
Some(_) => Some("alert evaluator snapshot is stale".into()),
|
||||
None => Some("alert evaluator has not produced a snapshot yet".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_health(reason: Option<String>) -> String {
|
||||
encode_health_message(reason.is_none(), reason)
|
||||
}
|
||||
|
||||
fn encode_health_message(available: bool, reason: Option<String>) -> String {
|
||||
let mut message = serde_json::json!({
|
||||
"type": "alerts_health",
|
||||
"available": available,
|
||||
});
|
||||
if let Some(reason) = reason {
|
||||
message["reason"] = serde_json::Value::String(reason);
|
||||
}
|
||||
message.to_string()
|
||||
}
|
||||
|
||||
/// Background task that owns alert evaluation: on a fixed ticker it recomputes
|
||||
/// the snapshot, persists transitions, and publishes to subscribers so neither
|
||||
/// the GET endpoint nor individual WS clients evaluate alerts themselves.
|
||||
pub async fn run_alert_evaluator(state: AppState) {
|
||||
let config = AlertRuleConfig::default();
|
||||
let mut tick = tokio::time::interval(Duration::from_secs(5));
|
||||
tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
loop {
|
||||
tick.tick().await;
|
||||
let alerts = match evaluate_alerts(&state, &config).await {
|
||||
Ok(alerts) => alerts,
|
||||
Err(err) => {
|
||||
warn!(code = %err.code, "failed to evaluate alerts in background task");
|
||||
state.alerts.mark_unavailable(err.message.clone()).await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(err) = state.store.sync_alert_transitions(&alerts).await {
|
||||
warn!(error = %err, "failed to sync alert transitions in background task");
|
||||
}
|
||||
|
||||
let recent_transitions = match state.store.list_alert_transitions(None, 20).await {
|
||||
Ok(history) => history,
|
||||
Err(err) => {
|
||||
warn!(error = %err, "failed to load alert transition history for snapshot");
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
|
||||
state
|
||||
.alerts
|
||||
.publish(AlertsSnapshot {
|
||||
ts_unix: now_unix(),
|
||||
alerts,
|
||||
recent_transitions,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn active_alerts(
|
||||
State(state): State<AppState>,
|
||||
Query(config): Query<AlertRuleConfig>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
config.validate()?;
|
||||
if let Some(reason) = state.alerts.unavailable_reason().await {
|
||||
return Err(ApiError::new(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"alerts_unavailable",
|
||||
reason,
|
||||
));
|
||||
}
|
||||
let alerts = state.alerts.latest_alerts().await;
|
||||
Ok((StatusCode::OK, Json(alerts)))
|
||||
}
|
||||
|
||||
@@ -94,64 +320,62 @@ pub async fn alert_history(
|
||||
pub async fn alerts_stream(
|
||||
ws: WebSocketUpgrade,
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<ActiveAlertsQuery>,
|
||||
) -> impl IntoResponse {
|
||||
let config = AlertRuleConfig {
|
||||
lookback_seconds: query.lookback_seconds,
|
||||
timeout_threshold: query.timeout_threshold,
|
||||
auth_rejected_threshold: query.auth_rejected_threshold,
|
||||
enroll_rejected_threshold: query.enroll_rejected_threshold,
|
||||
};
|
||||
ws.on_upgrade(move |socket| alerts_stream_socket(state, socket, config))
|
||||
ws.on_upgrade(move |socket| alerts_stream_socket(state, socket))
|
||||
}
|
||||
|
||||
async fn alerts_stream_socket(state: AppState, mut socket: WebSocket, config: AlertRuleConfig) {
|
||||
let mut tick = tokio::time::interval(Duration::from_secs(5));
|
||||
async fn alerts_stream_socket(state: AppState, mut socket: WebSocket) {
|
||||
let mut rx = state.alerts.subscribe();
|
||||
let mut freshness_tick = tokio::time::interval(Duration::from_secs(1));
|
||||
freshness_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
|
||||
let (health, snapshot) = state.alerts.resync_payloads().await;
|
||||
if socket.send(Message::Text(health.into())).await.is_err() {
|
||||
return;
|
||||
}
|
||||
if let Some(encoded) = snapshot
|
||||
&& socket.send(Message::Text(encoded.into())).await.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
loop {
|
||||
tick.tick().await;
|
||||
let alerts = match evaluate_alerts(&state, config.clone()).await {
|
||||
Ok(alerts) => alerts,
|
||||
Err(err) => {
|
||||
warn!(code = %err.code, "failed to evaluate alerts for stream");
|
||||
continue;
|
||||
select! {
|
||||
incoming = socket.recv() => {
|
||||
match incoming {
|
||||
Some(Ok(Message::Close(_))) | None | Some(Err(_)) => break,
|
||||
Some(Ok(_)) => {}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(err) = state.store.sync_alert_transitions(&alerts).await {
|
||||
warn!(error = %err, "failed to sync alert transitions in stream");
|
||||
}
|
||||
|
||||
let history = match state.store.list_alert_transitions(None, 20).await {
|
||||
Ok(h) => h,
|
||||
Err(err) => {
|
||||
warn!(error = %err, "failed to load alert transition history for stream");
|
||||
Vec::new()
|
||||
update = rx.recv() => {
|
||||
match update {
|
||||
Ok(encoded) => {
|
||||
if socket.send(Message::Text(encoded.into())).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(_)) => {
|
||||
let (health, snapshot) = state.alerts.resync_payloads().await;
|
||||
if socket.send(Message::Text(health.into())).await.is_err() {
|
||||
break;
|
||||
}
|
||||
if let Some(encoded) = snapshot
|
||||
&& socket.send(Message::Text(encoded.into())).await.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => break,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"type": "alerts_snapshot",
|
||||
"ts_unix": now_unix(),
|
||||
"alerts": alerts,
|
||||
"recent_transitions": history,
|
||||
});
|
||||
let encoded = match serde_json::to_string(&payload) {
|
||||
Ok(s) => s,
|
||||
Err(err) => {
|
||||
warn!(error = %err, "failed to encode alerts stream payload");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if socket.send(Message::Text(encoded.into())).await.is_err() {
|
||||
break;
|
||||
_ = freshness_tick.tick() => state.alerts.broadcast_health_if_changed().await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn evaluate_alerts(
|
||||
state: &AppState,
|
||||
config: AlertRuleConfig,
|
||||
config: &AlertRuleConfig,
|
||||
) -> Result<Vec<AlertState>, ApiError> {
|
||||
let lookback_seconds = config.lookback_seconds.unwrap_or(900).clamp(60, 86_400);
|
||||
let timeout_threshold = config.timeout_threshold.unwrap_or(3).max(1);
|
||||
@@ -161,7 +385,14 @@ async fn evaluate_alerts(
|
||||
let now = now_unix();
|
||||
let since_unix = now.saturating_sub(lookback_seconds);
|
||||
|
||||
let enrolled_agents = state.store.list_agents().await;
|
||||
let enrolled_agents = state.store.list_agents().await.map_err(|err| {
|
||||
warn!(error = %err, "failed listing agents for alert evaluation");
|
||||
ApiError::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"alerts_query_failed",
|
||||
err.to_string(),
|
||||
)
|
||||
})?;
|
||||
let connected_agents = state
|
||||
.sessions
|
||||
.read()
|
||||
@@ -359,9 +590,11 @@ fn now_unix() -> u64 {
|
||||
mod tests {
|
||||
use std::collections::HashSet;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
|
||||
use crate::state::AuditEvent;
|
||||
|
||||
use super::{AlertBuildContext, build_alerts};
|
||||
use super::{AlertBuildContext, AlertRuleConfig, AlertsHub, AlertsSnapshot, build_alerts};
|
||||
|
||||
fn event(agent_id: Option<&str>, event_type: &str, outcome: &str, ts_unix: u64) -> AuditEvent {
|
||||
AuditEvent {
|
||||
@@ -450,4 +683,146 @@ mod tests {
|
||||
.any(|a| a.kind == "enroll_reject_spike" && a.agent_id.is_none())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alert_rule_config_accepts_omitted_and_default_values() {
|
||||
AlertRuleConfig::default().validate().unwrap();
|
||||
AlertRuleConfig {
|
||||
lookback_seconds: None,
|
||||
timeout_threshold: None,
|
||||
auth_rejected_threshold: None,
|
||||
enroll_rejected_threshold: None,
|
||||
}
|
||||
.validate()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alert_rule_config_rejects_custom_overrides() {
|
||||
let configs = [
|
||||
AlertRuleConfig {
|
||||
lookback_seconds: Some(300),
|
||||
..Default::default()
|
||||
},
|
||||
AlertRuleConfig {
|
||||
timeout_threshold: Some(4),
|
||||
..Default::default()
|
||||
},
|
||||
AlertRuleConfig {
|
||||
auth_rejected_threshold: Some(4),
|
||||
..Default::default()
|
||||
},
|
||||
AlertRuleConfig {
|
||||
enroll_rejected_threshold: Some(6),
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
|
||||
for config in configs {
|
||||
let error = config.validate().unwrap_err();
|
||||
assert_eq!(error.status, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(error.code, "unsupported_alert_override");
|
||||
assert!(error.message.contains("not supported"));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn alert_hub_reports_initial_failure_and_recovers_on_snapshot() {
|
||||
let hub = AlertsHub::new();
|
||||
assert!(hub.unavailable_reason().await.is_some());
|
||||
let mut updates = hub.subscribe();
|
||||
|
||||
hub.mark_unavailable("store query failed").await;
|
||||
assert_eq!(
|
||||
hub.unavailable_reason().await.as_deref(),
|
||||
Some("store query failed")
|
||||
);
|
||||
let failure: serde_json::Value =
|
||||
serde_json::from_str(&updates.try_recv().unwrap()).unwrap();
|
||||
assert_eq!(failure["type"], "alerts_health");
|
||||
assert_eq!(failure["available"], false);
|
||||
assert_eq!(failure["reason"], "store query failed");
|
||||
|
||||
hub.mark_unavailable("same outage, later attempt").await;
|
||||
assert!(
|
||||
updates.try_recv().is_err(),
|
||||
"failure notifications deduplicate"
|
||||
);
|
||||
|
||||
hub.publish(AlertsSnapshot {
|
||||
ts_unix: super::now_unix(),
|
||||
alerts: Vec::new(),
|
||||
recent_transitions: Vec::new(),
|
||||
})
|
||||
.await;
|
||||
assert_eq!(hub.unavailable_reason().await, None);
|
||||
let recovery: serde_json::Value =
|
||||
serde_json::from_str(&updates.try_recv().unwrap()).unwrap();
|
||||
assert_eq!(recovery["type"], "alerts_health");
|
||||
assert_eq!(recovery["available"], true);
|
||||
assert_eq!(recovery.get("reason"), None);
|
||||
let snapshot: serde_json::Value =
|
||||
serde_json::from_str(&updates.try_recv().unwrap()).unwrap();
|
||||
assert_eq!(snapshot["type"], "alerts_snapshot");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn alert_hub_rejects_stale_snapshots() {
|
||||
let hub = AlertsHub::new();
|
||||
hub.publish(AlertsSnapshot {
|
||||
ts_unix: 0,
|
||||
alerts: Vec::new(),
|
||||
recent_transitions: Vec::new(),
|
||||
})
|
||||
.await;
|
||||
assert_eq!(
|
||||
hub.unavailable_reason().await.as_deref(),
|
||||
Some("alert evaluator snapshot is stale")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn alert_hub_initial_resync_marks_cached_snapshot_stale() {
|
||||
let hub = AlertsHub::new();
|
||||
let (initial_health, initial_snapshot) = hub.resync_payloads().await;
|
||||
let initial_health: serde_json::Value = serde_json::from_str(&initial_health).unwrap();
|
||||
assert_eq!(initial_health["available"], false);
|
||||
assert_eq!(
|
||||
initial_health["reason"],
|
||||
"alert evaluator has not produced a snapshot yet"
|
||||
);
|
||||
assert!(initial_snapshot.is_none());
|
||||
|
||||
hub.publish(AlertsSnapshot {
|
||||
ts_unix: 0,
|
||||
alerts: Vec::new(),
|
||||
recent_transitions: Vec::new(),
|
||||
})
|
||||
.await;
|
||||
let (stale_health, cached_snapshot) = hub.resync_payloads().await;
|
||||
let stale_health: serde_json::Value = serde_json::from_str(&stale_health).unwrap();
|
||||
assert_eq!(stale_health["available"], false);
|
||||
assert_eq!(stale_health["reason"], "alert evaluator snapshot is stale");
|
||||
assert!(cached_snapshot.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn alert_hub_broadcasts_stale_transition_once() {
|
||||
let hub = AlertsHub::new();
|
||||
let mut updates = hub.subscribe();
|
||||
hub.publish(AlertsSnapshot {
|
||||
ts_unix: 0,
|
||||
alerts: Vec::new(),
|
||||
recent_transitions: Vec::new(),
|
||||
})
|
||||
.await;
|
||||
let _snapshot = updates.try_recv().unwrap();
|
||||
|
||||
hub.broadcast_health_if_changed().await;
|
||||
let stale: serde_json::Value = serde_json::from_str(&updates.try_recv().unwrap()).unwrap();
|
||||
assert_eq!(stale["available"], false);
|
||||
assert_eq!(stale["reason"], "alert evaluator snapshot is stale");
|
||||
hub.broadcast_health_if_changed().await;
|
||||
assert!(updates.try_recv().is_err());
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,9 @@ use serde::{Deserialize, Serialize};
|
||||
use std::time::Instant;
|
||||
use tracing::{info, info_span, warn};
|
||||
use uuid::Uuid;
|
||||
use wakey_agent::protocol::{AgentCommand, ErrorPayload, RequestId, ServerMessage};
|
||||
use wakey_agent::protocol::{
|
||||
AgentCapability, AgentCapabilityOptions, AgentCommand, ErrorPayload, RequestId, ServerMessage,
|
||||
};
|
||||
|
||||
use crate::api::ApiError;
|
||||
use crate::runtime::{AgentReply, AppState, SessionEvent};
|
||||
@@ -17,6 +19,8 @@ pub struct AgentStatus {
|
||||
pub agent_id: String,
|
||||
pub connected: bool,
|
||||
pub nickname: Option<String>,
|
||||
pub capabilities: Vec<AgentCapability>,
|
||||
pub capability_options: AgentCapabilityOptions,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -36,13 +40,32 @@ pub struct RelayCommandResponse {
|
||||
}
|
||||
|
||||
pub async fn list_agents(State(state): State<AppState>) -> Result<impl IntoResponse, ApiError> {
|
||||
let enrolled = state.store.list_agents_with_nicknames().await;
|
||||
let enrolled = state
|
||||
.store
|
||||
.list_agents_with_nicknames()
|
||||
.await
|
||||
.map_err(|err| {
|
||||
warn!(error = %err, "failed listing agents");
|
||||
ApiError::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"list_agents_failed",
|
||||
err.to_string(),
|
||||
)
|
||||
})?;
|
||||
let sessions = state.sessions.read().await;
|
||||
|
||||
let agents = enrolled
|
||||
.into_iter()
|
||||
.map(|(agent_id, nickname)| AgentStatus {
|
||||
connected: sessions.contains_key(&agent_id),
|
||||
capabilities: sessions
|
||||
.get(&agent_id)
|
||||
.map(|session| session.capabilities.clone())
|
||||
.unwrap_or_default(),
|
||||
capability_options: sessions
|
||||
.get(&agent_id)
|
||||
.map(|session| session.capability_options.clone())
|
||||
.unwrap_or_default(),
|
||||
agent_id,
|
||||
nickname,
|
||||
})
|
||||
@@ -108,9 +131,9 @@ pub async fn relay_agent_command(
|
||||
.insert(request_id_string.clone(), pending_tx);
|
||||
|
||||
info!("dispatching command to agent");
|
||||
if let Err(err) = state
|
||||
state
|
||||
.store
|
||||
.append_audit_event(AuditEventInput {
|
||||
.audit(AuditEventInput {
|
||||
actor_type: "admin_api".into(),
|
||||
actor_id: None,
|
||||
agent_id: Some(agent_id.to_string()),
|
||||
@@ -121,10 +144,7 @@ pub async fn relay_agent_command(
|
||||
message: "dispatched command to connected agent".into(),
|
||||
metadata: serde_json::json!({ "command": command_kind }),
|
||||
})
|
||||
.await
|
||||
{
|
||||
warn!(error = %err, "failed to append audit event for command dispatch");
|
||||
}
|
||||
.await;
|
||||
|
||||
if let Err(err) = tx.send(SessionEvent::Message(ServerMessage::Command {
|
||||
request_id,
|
||||
@@ -132,9 +152,9 @@ pub async fn relay_agent_command(
|
||||
})) {
|
||||
state.pending.lock().await.remove(&request_id_string);
|
||||
warn!(error = %err, "failed sending command to agent session");
|
||||
if let Err(audit_err) = state
|
||||
state
|
||||
.store
|
||||
.append_audit_event(AuditEventInput {
|
||||
.audit(AuditEventInput {
|
||||
actor_type: "admin_api".into(),
|
||||
actor_id: None,
|
||||
agent_id: Some(agent_id.to_string()),
|
||||
@@ -145,10 +165,7 @@ pub async fn relay_agent_command(
|
||||
message: err.to_string(),
|
||||
metadata: serde_json::json!({ "command": command_kind }),
|
||||
})
|
||||
.await
|
||||
{
|
||||
warn!(error = %audit_err, "failed to append audit event for command send failure");
|
||||
}
|
||||
.await;
|
||||
return Err(ApiError::new(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"agent_send_failed",
|
||||
@@ -165,9 +182,9 @@ pub async fn relay_agent_command(
|
||||
let response = match outcome {
|
||||
Ok(Ok(AgentReply::Result(result))) => {
|
||||
info!("agent command completed");
|
||||
if let Err(err) = state
|
||||
state
|
||||
.store
|
||||
.append_audit_event(AuditEventInput {
|
||||
.audit(AuditEventInput {
|
||||
actor_type: "admin_api".into(),
|
||||
actor_id: None,
|
||||
agent_id: Some(agent_id.to_string()),
|
||||
@@ -178,10 +195,7 @@ pub async fn relay_agent_command(
|
||||
message: "agent command completed".into(),
|
||||
metadata: serde_json::json!({ "command": command_kind }),
|
||||
})
|
||||
.await
|
||||
{
|
||||
warn!(error = %err, "failed to append audit event for command success");
|
||||
}
|
||||
.await;
|
||||
RelayCommandResponse {
|
||||
request_id: request_id_string,
|
||||
status: "ok".into(),
|
||||
@@ -191,9 +205,9 @@ pub async fn relay_agent_command(
|
||||
}
|
||||
Ok(Ok(AgentReply::Error(error))) => {
|
||||
warn!(code = %error.code, "agent command returned error");
|
||||
if let Err(err) = state
|
||||
state
|
||||
.store
|
||||
.append_audit_event(AuditEventInput {
|
||||
.audit(AuditEventInput {
|
||||
actor_type: "admin_api".into(),
|
||||
actor_id: None,
|
||||
agent_id: Some(agent_id.to_string()),
|
||||
@@ -204,10 +218,7 @@ pub async fn relay_agent_command(
|
||||
message: error.message.clone(),
|
||||
metadata: serde_json::json!({ "command": command_kind, "code": error.code }),
|
||||
})
|
||||
.await
|
||||
{
|
||||
warn!(error = %err, "failed to append audit event for command error result");
|
||||
}
|
||||
.await;
|
||||
RelayCommandResponse {
|
||||
request_id: request_id_string,
|
||||
status: "error".into(),
|
||||
@@ -217,9 +228,9 @@ pub async fn relay_agent_command(
|
||||
}
|
||||
Ok(Err(_)) => {
|
||||
warn!("agent response channel dropped");
|
||||
if let Err(err) = state
|
||||
state
|
||||
.store
|
||||
.append_audit_event(AuditEventInput {
|
||||
.audit(AuditEventInput {
|
||||
actor_type: "admin_api".into(),
|
||||
actor_id: None,
|
||||
agent_id: Some(agent_id.to_string()),
|
||||
@@ -230,10 +241,7 @@ pub async fn relay_agent_command(
|
||||
message: "agent response channel dropped".into(),
|
||||
metadata: serde_json::json!({ "command": command_kind }),
|
||||
})
|
||||
.await
|
||||
{
|
||||
warn!(error = %err, "failed to append audit event for dropped response");
|
||||
}
|
||||
.await;
|
||||
return Err(ApiError::new(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"agent_response_dropped",
|
||||
@@ -246,9 +254,9 @@ pub async fn relay_agent_command(
|
||||
timeout_ms = timeout.as_millis() as u64,
|
||||
"agent command timed out"
|
||||
);
|
||||
if let Err(err) = state
|
||||
state
|
||||
.store
|
||||
.append_audit_event(AuditEventInput {
|
||||
.audit(AuditEventInput {
|
||||
actor_type: "admin_api".into(),
|
||||
actor_id: None,
|
||||
agent_id: Some(agent_id.to_string()),
|
||||
@@ -259,10 +267,7 @@ pub async fn relay_agent_command(
|
||||
message: "agent command timed out".into(),
|
||||
metadata: serde_json::json!({ "command": command_kind, "timeout_ms": timeout.as_millis() as u64 }),
|
||||
})
|
||||
.await
|
||||
{
|
||||
warn!(error = %err, "failed to append audit event for timeout");
|
||||
}
|
||||
.await;
|
||||
return Err(ApiError::new(
|
||||
StatusCode::GATEWAY_TIMEOUT,
|
||||
"agent_timeout",
|
||||
|
||||
@@ -5,7 +5,7 @@ mod stats;
|
||||
|
||||
pub use devices::{
|
||||
attach_device_identifier, create_known_device, detach_device_identifier, forget_known_device,
|
||||
list_known_devices, merge_known_device,
|
||||
get_known_device, list_known_devices, merge_known_device,
|
||||
};
|
||||
pub use enroll::{
|
||||
EnrollTokenStatus, IssueEnrollTokenResponse, RevokeAgentResponse, RevokeEnrollTokenResponse,
|
||||
|
||||
@@ -111,6 +111,28 @@ pub async fn list_known_devices(
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_known_device(
|
||||
State(state): State<AppState>,
|
||||
AxumPath(device_id): AxumPath<String>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
match state.store.get_known_device(&device_id).await {
|
||||
Ok(Some(device)) => Ok((StatusCode::OK, Json(known_device_response(device)))),
|
||||
Ok(None) => Err(ApiError::new(
|
||||
StatusCode::NOT_FOUND,
|
||||
"known_device_not_found",
|
||||
"known device not found",
|
||||
)),
|
||||
Err(err) => {
|
||||
warn!(error = %err, "failed to get known device");
|
||||
Err(ApiError::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"get_known_device_failed",
|
||||
err.to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn forget_known_device(
|
||||
State(state): State<AppState>,
|
||||
AxumPath(device_id): AxumPath<String>,
|
||||
|
||||
@@ -74,9 +74,9 @@ pub async fn enroll(
|
||||
match state.store.enroll(&req.enroll_token).await {
|
||||
Ok(issued) => {
|
||||
info!(agent_id = %issued.agent_id, "agent enrollment accepted");
|
||||
if let Err(err) = state
|
||||
state
|
||||
.store
|
||||
.append_audit_event(AuditEventInput {
|
||||
.audit(AuditEventInput {
|
||||
actor_type: "agent".into(),
|
||||
actor_id: Some(issued.agent_id.clone()),
|
||||
agent_id: Some(issued.agent_id.clone()),
|
||||
@@ -87,10 +87,7 @@ pub async fn enroll(
|
||||
message: "agent enrollment accepted".into(),
|
||||
metadata: serde_json::json!({}),
|
||||
})
|
||||
.await
|
||||
{
|
||||
warn!(error = %err, "failed to append audit event for enroll success");
|
||||
}
|
||||
.await;
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(EnrollResponse {
|
||||
@@ -102,9 +99,9 @@ pub async fn enroll(
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(error = %err, "agent enrollment rejected");
|
||||
if let Err(audit_err) = state
|
||||
state
|
||||
.store
|
||||
.append_audit_event(AuditEventInput {
|
||||
.audit(AuditEventInput {
|
||||
actor_type: "agent".into(),
|
||||
actor_id: None,
|
||||
agent_id: None,
|
||||
@@ -115,10 +112,7 @@ pub async fn enroll(
|
||||
message: err.to_string(),
|
||||
metadata: serde_json::json!({}),
|
||||
})
|
||||
.await
|
||||
{
|
||||
warn!(error = %audit_err, "failed to append audit event for enroll rejection");
|
||||
}
|
||||
.await;
|
||||
Err(ApiError::new(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"enrollment_rejected",
|
||||
@@ -145,9 +139,9 @@ pub async fn issue_enroll_token(
|
||||
expires_at_unix = issued.expires_at_unix,
|
||||
"issued enroll token"
|
||||
);
|
||||
if let Err(err) = state
|
||||
state
|
||||
.store
|
||||
.append_audit_event(AuditEventInput {
|
||||
.audit(AuditEventInput {
|
||||
actor_type: "admin_api".into(),
|
||||
actor_id: None,
|
||||
agent_id: None,
|
||||
@@ -161,10 +155,7 @@ pub async fn issue_enroll_token(
|
||||
"expires_at_unix": issued.expires_at_unix,
|
||||
}),
|
||||
})
|
||||
.await
|
||||
{
|
||||
warn!(error = %err, "failed to append audit event for token issuance");
|
||||
}
|
||||
.await;
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(IssueEnrollTokenResponse {
|
||||
@@ -189,9 +180,9 @@ pub async fn list_enroll_tokens(
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
match state.store.list_enroll_tokens().await {
|
||||
Ok(tokens) => {
|
||||
if let Err(err) = state
|
||||
state
|
||||
.store
|
||||
.append_audit_event(AuditEventInput {
|
||||
.audit(AuditEventInput {
|
||||
actor_type: "admin_api".into(),
|
||||
actor_id: None,
|
||||
agent_id: None,
|
||||
@@ -204,10 +195,7 @@ pub async fn list_enroll_tokens(
|
||||
"count": tokens.len(),
|
||||
}),
|
||||
})
|
||||
.await
|
||||
{
|
||||
warn!(error = %err, "failed to append audit event for token listing");
|
||||
}
|
||||
.await;
|
||||
let body = tokens
|
||||
.into_iter()
|
||||
.map(|t| EnrollTokenStatus {
|
||||
@@ -235,9 +223,9 @@ pub async fn revoke_enroll_token(
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
match state.store.revoke_enroll_token(&token).await {
|
||||
Ok(revoked) => {
|
||||
if let Err(err) = state
|
||||
state
|
||||
.store
|
||||
.append_audit_event(AuditEventInput {
|
||||
.audit(AuditEventInput {
|
||||
actor_type: "admin_api".into(),
|
||||
actor_id: None,
|
||||
agent_id: None,
|
||||
@@ -256,10 +244,7 @@ pub async fn revoke_enroll_token(
|
||||
},
|
||||
metadata: serde_json::json!({ "token": token }),
|
||||
})
|
||||
.await
|
||||
{
|
||||
warn!(error = %err, "failed to append audit event for token revoke");
|
||||
}
|
||||
.await;
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(RevokeEnrollTokenResponse { token, revoked }),
|
||||
@@ -289,9 +274,9 @@ pub async fn revoke_agent(
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(err) = state
|
||||
state
|
||||
.store
|
||||
.append_audit_event(AuditEventInput {
|
||||
.audit(AuditEventInput {
|
||||
actor_type: "admin_api".into(),
|
||||
actor_id: None,
|
||||
agent_id: Some(agent_id.clone()),
|
||||
@@ -310,10 +295,7 @@ pub async fn revoke_agent(
|
||||
},
|
||||
metadata: serde_json::json!({ "agent_id": agent_id }),
|
||||
})
|
||||
.await
|
||||
{
|
||||
warn!(error = %err, "failed to append audit event for agent revoke");
|
||||
}
|
||||
.await;
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
@@ -349,9 +331,9 @@ pub async fn set_agent_nickname(
|
||||
.await
|
||||
{
|
||||
Ok(updated) => {
|
||||
if let Err(err) = state
|
||||
state
|
||||
.store
|
||||
.append_audit_event(AuditEventInput {
|
||||
.audit(AuditEventInput {
|
||||
actor_type: "admin_api".into(),
|
||||
actor_id: None,
|
||||
agent_id: Some(agent_id.clone()),
|
||||
@@ -373,10 +355,7 @@ pub async fn set_agent_nickname(
|
||||
"nickname": normalized,
|
||||
}),
|
||||
})
|
||||
.await
|
||||
{
|
||||
warn!(error = %err, "failed to append audit event for nickname set");
|
||||
}
|
||||
.await;
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
|
||||
@@ -2,11 +2,13 @@ use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||
use std::net::IpAddr;
|
||||
|
||||
use macaddr::MacAddr;
|
||||
use wakey_core::Presence;
|
||||
use wakey_core::{EndpointSource, Presence};
|
||||
|
||||
use crate::state::{AgentDeviceWithChildren, DeviceIdentifier, KnownDevice, KnownDeviceSummary};
|
||||
|
||||
use super::types::{FleetDevice, FleetDeviceAgent, FleetWakeRoute, ListFleetDevicesQuery};
|
||||
use super::types::{
|
||||
FleetDevice, FleetDeviceAgent, FleetDeviceEndpoint, FleetWakeRoute, ListFleetDevicesQuery,
|
||||
};
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct FleetBuildContext {
|
||||
@@ -31,6 +33,7 @@ struct FleetAccumulator {
|
||||
hostnames: BTreeSet<String>,
|
||||
sources: BTreeSet<String>,
|
||||
agents: BTreeMap<String, FleetDeviceAgent>,
|
||||
endpoints: Vec<FleetDeviceEndpoint>,
|
||||
first_seen_unix: Option<u64>,
|
||||
last_seen_unix: Option<u64>,
|
||||
presence: Presence,
|
||||
@@ -49,6 +52,7 @@ impl Default for FleetAccumulator {
|
||||
hostnames: BTreeSet::new(),
|
||||
sources: BTreeSet::new(),
|
||||
agents: BTreeMap::new(),
|
||||
endpoints: Vec::new(),
|
||||
first_seen_unix: None,
|
||||
last_seen_unix: None,
|
||||
presence: Presence::Offline,
|
||||
@@ -199,12 +203,25 @@ fn add_agent_device_to_entry(
|
||||
entry.pinned = summary.pinned;
|
||||
entry.known_device = Some(summary);
|
||||
}
|
||||
for mac in &agent_device.macs {
|
||||
entry.macs.insert(*mac);
|
||||
}
|
||||
if !device_offline {
|
||||
for ip in &agent_device.ips {
|
||||
entry.ips.insert(*ip);
|
||||
if agent_device.endpoints.is_empty() {
|
||||
for mac in &agent_device.macs {
|
||||
entry.macs.insert(*mac);
|
||||
}
|
||||
if !device_offline {
|
||||
for ip in &agent_device.ips {
|
||||
entry.ips.insert(*ip);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for endpoint in &agent_device.endpoints {
|
||||
if let Some(mac) = endpoint.key.mac {
|
||||
entry.macs.insert(mac);
|
||||
}
|
||||
if endpoint_ip_is_summary_eligible(endpoint.key.source, endpoint.presence)
|
||||
&& let Some(ip) = endpoint.key.ip
|
||||
{
|
||||
entry.ips.insert(ip);
|
||||
}
|
||||
}
|
||||
}
|
||||
for hostname in &agent_device.hostnames {
|
||||
@@ -213,7 +230,6 @@ fn add_agent_device_to_entry(
|
||||
}
|
||||
entry.hostnames.insert(hostname.clone());
|
||||
}
|
||||
entry.sources.insert("device".to_string());
|
||||
let first_seen = agent_device.device.first_seen();
|
||||
let last_seen = agent_device.device.last_seen();
|
||||
entry.first_seen_unix = Some(
|
||||
@@ -255,35 +271,88 @@ fn add_agent_device_to_entry(
|
||||
last_seen_unix: last_seen,
|
||||
});
|
||||
|
||||
for mac in &agent_device.macs {
|
||||
let ip_for_mac = agent_device.ips.first().copied();
|
||||
let hostname_for_mac = agent_device.hostnames.first().cloned();
|
||||
let rid = route_id(&agent_id, Some(mac), ip_for_mac.as_ref(), "device");
|
||||
let wakeable = status.connected && !device_offline;
|
||||
entry.routes.insert(
|
||||
rid.clone(),
|
||||
if agent_device.endpoints.is_empty() {
|
||||
entry.sources.insert("device".to_string());
|
||||
}
|
||||
|
||||
for endpoint in &agent_device.endpoints {
|
||||
let source = endpoint_source_label(endpoint.key.source).to_string();
|
||||
entry.sources.insert(source.clone());
|
||||
let endpoint_last_seen = endpoint.last_seen_unix.unwrap_or(last_seen);
|
||||
let endpoint_first_seen = endpoint.first_seen_unix.or(Some(first_seen));
|
||||
entry.endpoints.push(FleetDeviceEndpoint {
|
||||
agent_id: agent_id.clone(),
|
||||
nickname: status.nickname.clone(),
|
||||
connected: status.connected,
|
||||
source: source.clone(),
|
||||
mac: endpoint.key.mac,
|
||||
ip: endpoint.key.ip,
|
||||
hostname: endpoint.hostname.clone(),
|
||||
interface: endpoint.interface.clone(),
|
||||
presence: endpoint.presence,
|
||||
first_seen_unix: endpoint_first_seen,
|
||||
last_seen_unix: Some(endpoint_last_seen),
|
||||
});
|
||||
|
||||
let rid = route_id(
|
||||
&agent_id,
|
||||
endpoint.key.mac.as_ref(),
|
||||
endpoint.key.ip.as_ref(),
|
||||
);
|
||||
let wakeable = status.connected && endpoint.key.mac.is_some();
|
||||
insert_route(
|
||||
entry,
|
||||
FleetWakeRoute {
|
||||
route_id: rid,
|
||||
agent_id: agent_id.clone(),
|
||||
nickname: status.nickname.clone(),
|
||||
connected: status.connected,
|
||||
mac: Some(*mac),
|
||||
ip: ip_for_mac,
|
||||
hostname: hostname_for_mac,
|
||||
source: "device".to_string(),
|
||||
last_seen_unix: last_seen,
|
||||
mac: endpoint.key.mac,
|
||||
ip: endpoint.key.ip,
|
||||
hostname: endpoint.hostname.clone(),
|
||||
interface: endpoint.interface.clone(),
|
||||
source,
|
||||
presence: endpoint.presence,
|
||||
last_seen_unix: endpoint_last_seen,
|
||||
wakeable,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if agent_device.macs.is_empty()
|
||||
if agent_device.endpoints.is_empty() && !agent_device.macs.is_empty() {
|
||||
for mac in &agent_device.macs {
|
||||
let ip_for_mac = agent_device.ips.first().copied();
|
||||
let hostname_for_mac = agent_device.hostnames.first().cloned();
|
||||
let rid = route_id(&agent_id, Some(mac), ip_for_mac.as_ref());
|
||||
let wakeable = status.connected;
|
||||
insert_route(
|
||||
entry,
|
||||
FleetWakeRoute {
|
||||
route_id: rid,
|
||||
agent_id: agent_id.clone(),
|
||||
nickname: status.nickname.clone(),
|
||||
connected: status.connected,
|
||||
mac: Some(*mac),
|
||||
ip: ip_for_mac,
|
||||
hostname: hostname_for_mac,
|
||||
interface: None,
|
||||
source: "device".to_string(),
|
||||
presence: device_presence,
|
||||
last_seen_unix: last_seen,
|
||||
wakeable,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if agent_device.endpoints.is_empty()
|
||||
&& agent_device.macs.is_empty()
|
||||
&& let Some(ip) = agent_device.ips.first()
|
||||
{
|
||||
let hostname = agent_device.hostnames.first().cloned();
|
||||
let rid = route_id(&agent_id, None, Some(ip), "device");
|
||||
entry.routes.insert(
|
||||
rid.clone(),
|
||||
let rid = route_id(&agent_id, None, Some(ip));
|
||||
insert_route(
|
||||
entry,
|
||||
FleetWakeRoute {
|
||||
route_id: rid,
|
||||
agent_id: agent_id.clone(),
|
||||
@@ -292,7 +361,9 @@ fn add_agent_device_to_entry(
|
||||
mac: None,
|
||||
ip: Some(*ip),
|
||||
hostname,
|
||||
interface: None,
|
||||
source: "device".to_string(),
|
||||
presence: device_presence,
|
||||
last_seen_unix: last_seen,
|
||||
wakeable: false,
|
||||
},
|
||||
@@ -321,19 +392,66 @@ fn device_known_device(
|
||||
context: &FleetBuildContext,
|
||||
) -> Option<KnownDeviceSummary> {
|
||||
agent_device
|
||||
.macs
|
||||
.first()
|
||||
.endpoints
|
||||
.iter()
|
||||
.filter_map(|endpoint| endpoint.key.mac)
|
||||
.map(|mac| format!("mac:{}", mac.to_string().to_ascii_lowercase()))
|
||||
.and_then(|key| context.identifier_map.get(&key).cloned())
|
||||
.find_map(|key| context.identifier_map.get(&key).cloned())
|
||||
.or_else(|| {
|
||||
agent_device
|
||||
.macs
|
||||
.iter()
|
||||
.map(|mac| format!("mac:{}", mac.to_string().to_ascii_lowercase()))
|
||||
.find_map(|key| context.identifier_map.get(&key).cloned())
|
||||
})
|
||||
.or_else(|| {
|
||||
agent_device
|
||||
.endpoints
|
||||
.iter()
|
||||
.filter_map(|endpoint| endpoint.key.ip)
|
||||
.map(|ip| format!("ip:{ip}"))
|
||||
.find_map(|key| context.identifier_map.get(&key).cloned())
|
||||
})
|
||||
.or_else(|| {
|
||||
agent_device
|
||||
.ips
|
||||
.first()
|
||||
.iter()
|
||||
.map(|ip| format!("ip:{ip}"))
|
||||
.and_then(|key| context.identifier_map.get(&key).cloned())
|
||||
.find_map(|key| context.identifier_map.get(&key).cloned())
|
||||
})
|
||||
}
|
||||
|
||||
fn endpoint_ip_is_summary_eligible(source: EndpointSource, presence: Presence) -> bool {
|
||||
match source {
|
||||
EndpointSource::DhcpLease => true,
|
||||
EndpointSource::Neighbor => presence != Presence::Offline,
|
||||
EndpointSource::HookNeighbor | EndpointSource::HookDhcp => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn insert_route(entry: &mut FleetAccumulator, candidate: FleetWakeRoute) {
|
||||
match entry.routes.entry(candidate.route_id.clone()) {
|
||||
std::collections::btree_map::Entry::Vacant(slot) => {
|
||||
slot.insert(candidate);
|
||||
}
|
||||
std::collections::btree_map::Entry::Occupied(mut slot) => {
|
||||
if compare_route_quality(&candidate, slot.get()).is_gt() {
|
||||
slot.insert(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn compare_route_quality(a: &FleetWakeRoute, b: &FleetWakeRoute) -> std::cmp::Ordering {
|
||||
a.connected
|
||||
.cmp(&b.connected)
|
||||
.then_with(|| a.wakeable.cmp(&b.wakeable))
|
||||
.then_with(|| a.ip.is_some().cmp(&b.ip.is_some()))
|
||||
.then_with(|| a.presence.cmp(&b.presence))
|
||||
.then_with(|| a.last_seen_unix.cmp(&b.last_seen_unix))
|
||||
.then_with(|| source_quality_rank(&a.source).cmp(&source_quality_rank(&b.source)))
|
||||
}
|
||||
|
||||
pub(crate) fn known_device_summary(device: &KnownDevice) -> KnownDeviceSummary {
|
||||
KnownDeviceSummary {
|
||||
device_id: device.device_id.clone(),
|
||||
@@ -345,13 +463,8 @@ pub(crate) fn known_device_summary(device: &KnownDevice) -> KnownDeviceSummary {
|
||||
impl FleetAccumulator {
|
||||
fn into_response(self) -> FleetDevice {
|
||||
let mut route_candidates = self.routes.into_values().collect::<Vec<_>>();
|
||||
route_candidates.sort_by(|a, b| {
|
||||
b.connected
|
||||
.cmp(&a.connected)
|
||||
.then_with(|| b.wakeable.cmp(&a.wakeable))
|
||||
.then_with(|| b.last_seen_unix.cmp(&a.last_seen_unix))
|
||||
.then_with(|| a.agent_id.cmp(&b.agent_id))
|
||||
});
|
||||
route_candidates
|
||||
.sort_by(|a, b| compare_route_quality(b, a).then_with(|| a.agent_id.cmp(&b.agent_id)));
|
||||
let recommended_route = route_candidates
|
||||
.iter()
|
||||
.find(|route| route.wakeable)
|
||||
@@ -373,6 +486,7 @@ impl FleetAccumulator {
|
||||
hostnames: self.hostnames.into_iter().collect(),
|
||||
agents: self.agents.into_values().collect(),
|
||||
sources: self.sources.into_iter().collect(),
|
||||
endpoints: self.endpoints,
|
||||
first_seen_unix: self.first_seen_unix,
|
||||
last_seen_unix: self.last_seen_unix,
|
||||
presence: self.presence,
|
||||
@@ -382,11 +496,29 @@ impl FleetAccumulator {
|
||||
}
|
||||
}
|
||||
|
||||
fn route_id(agent_id: &str, mac: Option<&MacAddr>, ip: Option<&IpAddr>, source: &str) -> String {
|
||||
fn endpoint_source_label(source: EndpointSource) -> &'static str {
|
||||
match source {
|
||||
EndpointSource::Neighbor => "neighbor",
|
||||
EndpointSource::DhcpLease => "dhcp_lease",
|
||||
EndpointSource::HookNeighbor => "hook_neighbor",
|
||||
EndpointSource::HookDhcp => "hook_dhcp",
|
||||
}
|
||||
}
|
||||
|
||||
fn source_quality_rank(source: &str) -> u8 {
|
||||
match source {
|
||||
"neighbor" => 4,
|
||||
"dhcp_lease" => 3,
|
||||
"hook_neighbor" => 2,
|
||||
"hook_dhcp" => 1,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn route_id(agent_id: &str, mac: Option<&MacAddr>, ip: Option<&IpAddr>) -> String {
|
||||
format!(
|
||||
"{}|{}|{}|{}",
|
||||
"{}|{}|{}",
|
||||
agent_id,
|
||||
source,
|
||||
mac.map(|m| m.to_string()).unwrap_or_default(),
|
||||
ip.map(|i| i.to_string()).unwrap_or_default()
|
||||
)
|
||||
|
||||
@@ -4,11 +4,12 @@ use axum::Json;
|
||||
use axum::extract::{Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::IntoResponse;
|
||||
use futures_util::StreamExt;
|
||||
use tracing::warn;
|
||||
use wakey_agent::protocol::{AgentCommand, InventoryRequest, WakeRequest};
|
||||
|
||||
use crate::api::ApiError;
|
||||
use crate::api::commands::relay_agent_command;
|
||||
use crate::api::commands::{RelayCommandResponse, relay_agent_command};
|
||||
use crate::runtime::AppState;
|
||||
|
||||
mod build;
|
||||
@@ -26,6 +27,8 @@ use types::{
|
||||
RefreshFleetDevicesResponse, WakeFleetDeviceRequest, WakeFleetDeviceResponse,
|
||||
};
|
||||
|
||||
const REFRESH_CONCURRENCY: usize = 8;
|
||||
|
||||
pub async fn list_fleet_devices(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<ListFleetDevicesQuery>,
|
||||
@@ -47,98 +50,41 @@ pub async fn refresh_fleet_devices(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<RefreshFleetDevicesRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let mut agent_ids = if req.agent_ids.is_empty() {
|
||||
let agent_ids = if req.agent_ids.is_empty() {
|
||||
let sessions = state.sessions.read().await;
|
||||
sessions.keys().cloned().collect::<Vec<_>>()
|
||||
} else {
|
||||
req.agent_ids.clone()
|
||||
};
|
||||
agent_ids.sort();
|
||||
agent_ids.dedup();
|
||||
let agent_ids = normalize_refresh_agent_ids(agent_ids);
|
||||
|
||||
let mut results = Vec::with_capacity(agent_ids.len());
|
||||
let mut total_accepted = 0usize;
|
||||
for agent_id in agent_ids {
|
||||
let response = relay_agent_command(
|
||||
&state,
|
||||
&agent_id,
|
||||
AgentCommand::Inventory(InventoryRequest {
|
||||
query: None,
|
||||
name: None,
|
||||
ips: Vec::new(),
|
||||
devs: Vec::new(),
|
||||
nuds: Vec::new(),
|
||||
macs: Vec::new(),
|
||||
}),
|
||||
req.timeout_ms,
|
||||
)
|
||||
.await;
|
||||
|
||||
match response {
|
||||
Ok(response) if response.status == "ok" => {
|
||||
let Some(result) = response.result else {
|
||||
results.push(RefreshFleetAgentResult {
|
||||
agent_id,
|
||||
status: "error".into(),
|
||||
accepted: 0,
|
||||
error: Some("inventory command returned no result".into()),
|
||||
});
|
||||
continue;
|
||||
};
|
||||
match serde_json::from_value::<Vec<wakey_core::Device>>(
|
||||
result
|
||||
.get("devices")
|
||||
.cloned()
|
||||
.unwrap_or(serde_json::Value::Array(vec![])),
|
||||
) {
|
||||
Ok(devices) => {
|
||||
match state
|
||||
.store
|
||||
.replace_agent_device_snapshot(&agent_id, &devices)
|
||||
.await
|
||||
{
|
||||
Ok(accepted) => {
|
||||
total_accepted = total_accepted.saturating_add(accepted);
|
||||
results.push(RefreshFleetAgentResult {
|
||||
agent_id,
|
||||
status: "ok".into(),
|
||||
accepted,
|
||||
error: None,
|
||||
});
|
||||
}
|
||||
Err(err) => results.push(RefreshFleetAgentResult {
|
||||
agent_id,
|
||||
status: "error".into(),
|
||||
accepted: 0,
|
||||
error: Some(err.to_string()),
|
||||
}),
|
||||
}
|
||||
}
|
||||
Err(err) => results.push(RefreshFleetAgentResult {
|
||||
agent_id,
|
||||
status: "error".into(),
|
||||
accepted: 0,
|
||||
error: Some(err.to_string()),
|
||||
}),
|
||||
}
|
||||
}
|
||||
Ok(response) => results.push(RefreshFleetAgentResult {
|
||||
agent_id,
|
||||
status: "error".into(),
|
||||
accepted: 0,
|
||||
error: response
|
||||
.error
|
||||
.map(|error| error.message)
|
||||
.or_else(|| Some("inventory command failed".into())),
|
||||
}),
|
||||
Err(err) => results.push(RefreshFleetAgentResult {
|
||||
agent_id,
|
||||
status: "error".into(),
|
||||
accepted: 0,
|
||||
error: Some(format!("{}: {}", err.status, err.message)),
|
||||
}),
|
||||
let results = futures_util::stream::iter(agent_ids.into_iter().map(|agent_id| {
|
||||
let state = state.clone();
|
||||
async move {
|
||||
let response = relay_agent_command(
|
||||
&state,
|
||||
&agent_id,
|
||||
AgentCommand::Inventory(InventoryRequest {
|
||||
query: None,
|
||||
name: None,
|
||||
ips: Vec::new(),
|
||||
devs: Vec::new(),
|
||||
nuds: Vec::new(),
|
||||
macs: Vec::new(),
|
||||
}),
|
||||
req.timeout_ms,
|
||||
)
|
||||
.await;
|
||||
refresh_fleet_agent_result(&state, &agent_id, response).await
|
||||
}
|
||||
}
|
||||
}))
|
||||
.buffer_unordered(REFRESH_CONCURRENCY)
|
||||
.collect::<Vec<_>>()
|
||||
.await;
|
||||
|
||||
let mut results = results;
|
||||
results.sort_by(|a, b| a.agent_id.cmp(&b.agent_id));
|
||||
let total_accepted = results.iter().map(|result| result.accepted).sum::<usize>();
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
@@ -149,6 +95,76 @@ pub async fn refresh_fleet_devices(
|
||||
))
|
||||
}
|
||||
|
||||
fn normalize_refresh_agent_ids(mut agent_ids: Vec<String>) -> Vec<String> {
|
||||
agent_ids.sort();
|
||||
agent_ids.dedup();
|
||||
agent_ids
|
||||
}
|
||||
|
||||
async fn refresh_fleet_agent_result(
|
||||
state: &AppState,
|
||||
agent_id: &str,
|
||||
response: Result<RelayCommandResponse, ApiError>,
|
||||
) -> RefreshFleetAgentResult {
|
||||
match response {
|
||||
Ok(response) if response.status == "ok" => {
|
||||
let Some(result) = response.result else {
|
||||
return RefreshFleetAgentResult {
|
||||
agent_id: agent_id.to_string(),
|
||||
status: "error".into(),
|
||||
accepted: 0,
|
||||
error: Some("inventory command returned no result".into()),
|
||||
};
|
||||
};
|
||||
match serde_json::from_value::<wakey_core::DeviceInventory>(result)
|
||||
.map(|inventory| inventory.devices)
|
||||
{
|
||||
Ok(devices) => {
|
||||
match state
|
||||
.store
|
||||
.replace_agent_device_snapshot(agent_id, &devices)
|
||||
.await
|
||||
{
|
||||
Ok(accepted) => RefreshFleetAgentResult {
|
||||
agent_id: agent_id.to_string(),
|
||||
status: "ok".into(),
|
||||
accepted,
|
||||
error: None,
|
||||
},
|
||||
Err(err) => RefreshFleetAgentResult {
|
||||
agent_id: agent_id.to_string(),
|
||||
status: "error".into(),
|
||||
accepted: 0,
|
||||
error: Some(err.to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
Err(err) => RefreshFleetAgentResult {
|
||||
agent_id: agent_id.to_string(),
|
||||
status: "error".into(),
|
||||
accepted: 0,
|
||||
error: Some(err.to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
Ok(response) => RefreshFleetAgentResult {
|
||||
agent_id: agent_id.to_string(),
|
||||
status: "error".into(),
|
||||
accepted: 0,
|
||||
error: response
|
||||
.error
|
||||
.map(|error| error.message)
|
||||
.or_else(|| Some("inventory command failed".into())),
|
||||
},
|
||||
Err(err) => RefreshFleetAgentResult {
|
||||
agent_id: agent_id.to_string(),
|
||||
status: "error".into(),
|
||||
accepted: 0,
|
||||
error: Some(format!("{}: {}", err.status, err.message)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn wake_fleet_device(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<WakeFleetDeviceRequest>,
|
||||
@@ -246,7 +262,7 @@ async fn load_fleet_devices(
|
||||
let sessions = state.sessions.read().await;
|
||||
sessions.keys().cloned().collect::<BTreeSet<_>>()
|
||||
};
|
||||
let agents = state.store.list_agents_with_nicknames().await;
|
||||
let agents = state.store.list_agents_with_nicknames().await?;
|
||||
let agent_status = agents
|
||||
.into_iter()
|
||||
.map(|(agent_id, nickname)| {
|
||||
@@ -276,6 +292,7 @@ async fn load_fleet_devices(
|
||||
let mut devices = build_fleet_devices(known_devices, agent_devices, &context);
|
||||
filter_fleet_devices(&mut devices, query);
|
||||
let limit = query.limit.unwrap_or(500).clamp(1, 1000);
|
||||
// this is going to be 10 million times harder if i decide to do this lazily
|
||||
devices.truncate(limit);
|
||||
Ok(devices)
|
||||
}
|
||||
@@ -2,15 +2,29 @@ use std::collections::HashMap;
|
||||
use std::net::IpAddr;
|
||||
|
||||
use macaddr::MacAddr;
|
||||
use wakey_core::Presence;
|
||||
use wakey_core::{DeviceEndpoint, EndpointKey, EndpointSource, Presence};
|
||||
|
||||
use super::build::{
|
||||
AgentRuntimeStatus, FleetBuildContext, build_fleet_devices, filter_fleet_devices,
|
||||
known_device_summary,
|
||||
};
|
||||
use super::normalize_refresh_agent_ids;
|
||||
use super::types::ListFleetDevicesQuery;
|
||||
use crate::state::{AgentDeviceRow, AgentDeviceWithChildren, DeviceIdentifier, KnownDevice};
|
||||
|
||||
#[test]
|
||||
fn refresh_agent_selection_keeps_all_agents_beyond_previous_cap() {
|
||||
let ids = (0..140)
|
||||
.map(|index| format!("agent-{index:03}"))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let selected = normalize_refresh_agent_ids(ids);
|
||||
|
||||
assert_eq!(selected.len(), 140);
|
||||
assert_eq!(selected.first().map(String::as_str), Some("agent-000"));
|
||||
assert_eq!(selected.last().map(String::as_str), Some("agent-139"));
|
||||
}
|
||||
|
||||
fn context(connected: &[&str]) -> FleetBuildContext {
|
||||
FleetBuildContext {
|
||||
agent_status: connected
|
||||
@@ -54,6 +68,7 @@ fn agent_device(
|
||||
.into_iter()
|
||||
.collect(),
|
||||
hostnames: vec!["lda".to_string()],
|
||||
endpoints: vec![],
|
||||
facts: vec![],
|
||||
}
|
||||
}
|
||||
@@ -83,6 +98,7 @@ fn offline_agent_device(
|
||||
.into_iter()
|
||||
.collect(),
|
||||
hostnames: vec!["lda".to_string()],
|
||||
endpoints: vec![],
|
||||
facts: vec![],
|
||||
}
|
||||
}
|
||||
@@ -105,10 +121,42 @@ fn offline_ip_only_unknown(
|
||||
macs: vec![],
|
||||
ips: vec![ip.parse().unwrap()],
|
||||
hostnames: vec![],
|
||||
endpoints: vec![],
|
||||
facts: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn endpoint(
|
||||
source: EndpointSource,
|
||||
mac: Option<&str>,
|
||||
ip: Option<&str>,
|
||||
last_seen_unix: u64,
|
||||
) -> DeviceEndpoint {
|
||||
endpoint_with_presence(source, mac, ip, Presence::LikelyOnline, last_seen_unix)
|
||||
}
|
||||
|
||||
fn endpoint_with_presence(
|
||||
source: EndpointSource,
|
||||
mac: Option<&str>,
|
||||
ip: Option<&str>,
|
||||
presence: Presence,
|
||||
last_seen_unix: u64,
|
||||
) -> DeviceEndpoint {
|
||||
DeviceEndpoint {
|
||||
key: EndpointKey::new(
|
||||
source,
|
||||
mac.map(|m| m.parse::<MacAddr>().unwrap()),
|
||||
ip.map(|i| i.parse::<IpAddr>().unwrap()),
|
||||
)
|
||||
.expect("endpoint key"),
|
||||
hostname: Some("lda".into()),
|
||||
interface: Some("br-lan".into()),
|
||||
presence,
|
||||
first_seen_unix: Some(1),
|
||||
last_seen_unix: Some(last_seen_unix),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fleet_grouping_combines_same_mac_across_agents() {
|
||||
let devices = build_fleet_devices(
|
||||
@@ -145,6 +193,146 @@ fn fleet_grouping_combines_same_mac_across_agents() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fleet_routes_use_endpoint_mac_ip_pairs() {
|
||||
let mac = "aa:bb:cc:dd:ee:ff";
|
||||
let mut row = agent_device(
|
||||
"agent-a",
|
||||
"mac:aa:bb:cc:dd:ee:ff",
|
||||
Some(mac),
|
||||
Some("192.168.1.2"),
|
||||
20,
|
||||
);
|
||||
row.endpoints = vec![
|
||||
endpoint(
|
||||
EndpointSource::HookNeighbor,
|
||||
Some(mac),
|
||||
Some("192.168.1.2"),
|
||||
10,
|
||||
),
|
||||
endpoint(EndpointSource::Neighbor, Some(mac), Some("192.168.1.3"), 20),
|
||||
];
|
||||
|
||||
let devices = build_fleet_devices(Vec::new(), vec![row], &context(&["agent-a"]));
|
||||
|
||||
assert_eq!(devices.len(), 1);
|
||||
assert_eq!(devices[0].endpoints.len(), 2);
|
||||
assert_eq!(devices[0].route_candidates.len(), 2);
|
||||
assert_eq!(
|
||||
devices[0]
|
||||
.recommended_route
|
||||
.as_ref()
|
||||
.and_then(|route| route.ip),
|
||||
Some("192.168.1.3".parse().unwrap())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fleet_summary_keeps_current_dhcp_ip_when_neighbor_endpoint_is_offline() {
|
||||
let mac = "aa:bb:cc:dd:ee:ff";
|
||||
let mut row = offline_agent_device(
|
||||
"agent-a",
|
||||
"mac:aa:bb:cc:dd:ee:ff",
|
||||
Some(mac),
|
||||
Some("192.168.1.99"),
|
||||
20,
|
||||
);
|
||||
row.ips = vec![
|
||||
"192.168.1.10".parse().unwrap(),
|
||||
"192.168.1.99".parse().unwrap(),
|
||||
];
|
||||
row.endpoints = vec![
|
||||
endpoint_with_presence(
|
||||
EndpointSource::Neighbor,
|
||||
Some(mac),
|
||||
Some("192.168.1.99"),
|
||||
Presence::Offline,
|
||||
20,
|
||||
),
|
||||
endpoint_with_presence(
|
||||
EndpointSource::DhcpLease,
|
||||
Some(mac),
|
||||
Some("192.168.1.10"),
|
||||
Presence::Unknown,
|
||||
20,
|
||||
),
|
||||
];
|
||||
|
||||
let devices = build_fleet_devices(Vec::new(), vec![row], &context(&["agent-a"]));
|
||||
|
||||
assert_eq!(devices.len(), 1);
|
||||
assert_eq!(
|
||||
devices[0].ips,
|
||||
vec!["192.168.1.10".parse::<IpAddr>().unwrap()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_ip_identifier_matches_endpoint_that_is_not_in_summary_ips() {
|
||||
let known = KnownDevice {
|
||||
device_id: "dev-1".into(),
|
||||
display_name: "lda remembered".into(),
|
||||
pinned: true,
|
||||
created_at_unix: 1,
|
||||
updated_at_unix: 1,
|
||||
notes: None,
|
||||
identifiers: vec![DeviceIdentifier {
|
||||
identifier_key: "ip:192.168.1.99".into(),
|
||||
device_id: "dev-1".into(),
|
||||
kind: "ip".into(),
|
||||
value: "192.168.1.99".into(),
|
||||
created_at_unix: 1,
|
||||
}],
|
||||
};
|
||||
let mut ctx = context(&["agent-a"]);
|
||||
ctx.identifier_map
|
||||
.insert("ip:192.168.1.99".into(), known_device_summary(&known));
|
||||
let mut row = agent_device("agent-a", "ip:192.168.1.99", None, None, 20);
|
||||
row.endpoints = vec![endpoint_with_presence(
|
||||
EndpointSource::HookNeighbor,
|
||||
None,
|
||||
Some("192.168.1.99"),
|
||||
Presence::Offline,
|
||||
20,
|
||||
)];
|
||||
|
||||
let devices = build_fleet_devices(vec![known], vec![row], &ctx);
|
||||
|
||||
assert_eq!(devices.len(), 1);
|
||||
assert_eq!(devices[0].device_key, "known:dev-1");
|
||||
assert_eq!(devices[0].endpoints.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equivalent_endpoint_routes_collapse_to_best_source() {
|
||||
let mac = "aa:bb:cc:dd:ee:ff";
|
||||
let ip = "192.168.1.2";
|
||||
let mut row = agent_device("agent-a", "mac:aa:bb:cc:dd:ee:ff", Some(mac), Some(ip), 20);
|
||||
row.endpoints = vec![
|
||||
endpoint_with_presence(
|
||||
EndpointSource::HookNeighbor,
|
||||
Some(mac),
|
||||
Some(ip),
|
||||
Presence::LikelyOnline,
|
||||
20,
|
||||
),
|
||||
endpoint_with_presence(
|
||||
EndpointSource::Neighbor,
|
||||
Some(mac),
|
||||
Some(ip),
|
||||
Presence::Online,
|
||||
20,
|
||||
),
|
||||
];
|
||||
|
||||
let devices = build_fleet_devices(Vec::new(), vec![row], &context(&["agent-a"]));
|
||||
|
||||
assert_eq!(devices[0].endpoints.len(), 2);
|
||||
assert_eq!(devices[0].route_candidates.len(), 1);
|
||||
assert_eq!(devices[0].route_candidates[0].source, "neighbor");
|
||||
assert_eq!(devices[0].route_candidates[0].presence, Presence::Online);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_device_with_two_macs_absorbs_both_device_groups() {
|
||||
let known = KnownDevice {
|
||||
@@ -242,7 +430,7 @@ fn offline_device_has_offline_presence() {
|
||||
assert_eq!(devices.len(), 1);
|
||||
assert!(devices[0].ips.is_empty());
|
||||
assert_eq!(devices[0].presence, Presence::Offline);
|
||||
assert!(devices[0].recommended_route.is_none());
|
||||
assert!(devices[0].recommended_route.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -68,6 +68,7 @@ pub struct FleetDevice {
|
||||
pub hostnames: Vec<String>,
|
||||
pub agents: Vec<FleetDeviceAgent>,
|
||||
pub sources: Vec<String>,
|
||||
pub endpoints: Vec<FleetDeviceEndpoint>,
|
||||
pub first_seen_unix: Option<u64>,
|
||||
pub last_seen_unix: Option<u64>,
|
||||
pub presence: Presence,
|
||||
@@ -83,6 +84,22 @@ pub struct FleetDeviceAgent {
|
||||
pub last_seen_unix: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FleetDeviceEndpoint {
|
||||
pub agent_id: String,
|
||||
pub nickname: Option<String>,
|
||||
pub connected: bool,
|
||||
pub source: String,
|
||||
#[serde(with = "mac::option_mac")]
|
||||
pub mac: Option<MacAddr>,
|
||||
pub ip: Option<IpAddr>,
|
||||
pub hostname: Option<String>,
|
||||
pub interface: Option<String>,
|
||||
pub presence: Presence,
|
||||
pub first_seen_unix: Option<u64>,
|
||||
pub last_seen_unix: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FleetWakeRoute {
|
||||
pub route_id: String,
|
||||
@@ -93,7 +110,9 @@ pub struct FleetWakeRoute {
|
||||
pub mac: Option<MacAddr>,
|
||||
pub ip: Option<IpAddr>,
|
||||
pub hostname: Option<String>,
|
||||
pub interface: Option<String>,
|
||||
pub source: String,
|
||||
pub presence: Presence,
|
||||
pub last_seen_unix: u64,
|
||||
pub wakeable: bool,
|
||||
}
|
||||
@@ -5,17 +5,22 @@ mod alerts;
|
||||
mod audit;
|
||||
mod commands;
|
||||
mod control;
|
||||
mod terminals;
|
||||
|
||||
pub use alerts::{active_alerts, alert_history, alerts_stream};
|
||||
pub use alerts::{AlertsHub, active_alerts, alert_history, alerts_stream, run_alert_evaluator};
|
||||
pub use audit::list_audit_events;
|
||||
pub use commands::{list_agents, run_command};
|
||||
pub use control::{
|
||||
EnrollTokenStatus, IssueEnrollTokenResponse, RevokeAgentResponse, RevokeEnrollTokenResponse,
|
||||
StateStatsResponse, attach_device_identifier, create_known_device, detach_device_identifier,
|
||||
enroll, forget_known_device, healthz, issue_enroll_token, list_enroll_tokens,
|
||||
enroll, forget_known_device, get_known_device, healthz, issue_enroll_token, list_enroll_tokens,
|
||||
list_fleet_devices, list_known_devices, merge_known_device, refresh_fleet_devices,
|
||||
revoke_agent, revoke_enroll_token, set_agent_nickname, state_stats, wake_fleet_device,
|
||||
};
|
||||
pub use terminals::{
|
||||
agent_terminal_ws, attach_terminal, close_terminal, create_terminal, get_terminal,
|
||||
list_terminals, operator_terminal_ws, terminal_events_ws,
|
||||
};
|
||||
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -0,0 +1,771 @@
|
||||
use axum::Json;
|
||||
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::Response;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
use tokio::sync::broadcast;
|
||||
use tracing::{info, warn};
|
||||
use wakey_agent::protocol::{
|
||||
AgentCapability, DEFAULT_TERMINAL_MAX_SESSIONS, DEFAULT_TERMINAL_SESSION_TTL_SECONDS,
|
||||
ServerMessage, TerminalAgentHandshake, TerminalControl, TerminalId, TerminalOperatorHandshake,
|
||||
};
|
||||
|
||||
use crate::api::ApiError;
|
||||
use crate::runtime::terminals::{
|
||||
TERMINAL_ATTACH_TIMEOUT, TERMINAL_MAX_FRAME_BYTES, TerminalRelayFrame, TerminalSummary,
|
||||
};
|
||||
use crate::runtime::{AppState, SessionEvent};
|
||||
use crate::state::AuditEventInput;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateTerminalRequest {
|
||||
pub agent_id: String,
|
||||
#[serde(default = "default_rows")]
|
||||
pub rows: u16,
|
||||
#[serde(default = "default_cols")]
|
||||
pub cols: u16,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AttachTerminalRequest {
|
||||
/// Stable for one browser tab, allowing CC to distinguish a stale socket
|
||||
/// owned by this tab from a session open in another browser.
|
||||
pub operator_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct TerminalSessionResponse {
|
||||
pub terminal_id: String,
|
||||
pub agent_id: String,
|
||||
pub created_at_unix: u64,
|
||||
pub expires_at_unix: Option<u64>,
|
||||
pub agent_attached: bool,
|
||||
pub operator_attached: bool,
|
||||
pub websocket_url: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub attachment_token: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn create_terminal(
|
||||
State(state): State<AppState>,
|
||||
Json(request): Json<CreateTerminalRequest>,
|
||||
) -> Result<(StatusCode, Json<TerminalSessionResponse>), ApiError> {
|
||||
validate_size(request.rows, request.cols)?;
|
||||
let (agent_tx, max_sessions, session_ttl_seconds) = {
|
||||
let sessions = state.sessions.read().await;
|
||||
let session = sessions.get(&request.agent_id).ok_or_else(|| {
|
||||
ApiError::new(
|
||||
StatusCode::NOT_FOUND,
|
||||
"agent_not_connected",
|
||||
"agent is not connected",
|
||||
)
|
||||
})?;
|
||||
if !session.capabilities.contains(&AgentCapability::Terminal) {
|
||||
return Err(ApiError::new(
|
||||
StatusCode::CONFLICT,
|
||||
"terminal_not_supported",
|
||||
"agent has not advertised terminal capability",
|
||||
));
|
||||
}
|
||||
let max_sessions = session
|
||||
.capability_options
|
||||
.terminal
|
||||
.as_ref()
|
||||
.map(|terminal| terminal.max_sessions)
|
||||
.unwrap_or(DEFAULT_TERMINAL_MAX_SESSIONS)
|
||||
.max(1);
|
||||
let session_ttl_seconds = session
|
||||
.capability_options
|
||||
.terminal
|
||||
.as_ref()
|
||||
.map(|terminal| terminal.session_ttl_seconds)
|
||||
.unwrap_or(DEFAULT_TERMINAL_SESSION_TTL_SECONDS);
|
||||
(session.tx.clone(), max_sessions, session_ttl_seconds)
|
||||
};
|
||||
|
||||
let created = state
|
||||
.terminals
|
||||
.create_with_limits(request.agent_id.clone(), max_sessions, session_ttl_seconds)
|
||||
.await
|
||||
.map_err(registry_error)?;
|
||||
let terminal_id = TerminalId::new(created.terminal_id.clone()).map_err(|message| {
|
||||
ApiError::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"terminal_id_invalid",
|
||||
message,
|
||||
)
|
||||
})?;
|
||||
if agent_tx
|
||||
.send(SessionEvent::Message(ServerMessage::OpenTerminal {
|
||||
terminal_id: terminal_id.clone(),
|
||||
relay_token: created.relay_token,
|
||||
rows: request.rows,
|
||||
cols: request.cols,
|
||||
}))
|
||||
.is_err()
|
||||
{
|
||||
state.terminals.remove(terminal_id.as_str()).await;
|
||||
return Err(ApiError::new(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"agent_send_failed",
|
||||
"failed to send terminal request to agent",
|
||||
));
|
||||
}
|
||||
|
||||
spawn_session_timeout(
|
||||
state.clone(),
|
||||
terminal_id.clone(),
|
||||
request.agent_id.clone(),
|
||||
session_ttl_seconds,
|
||||
);
|
||||
append_terminal_audit(
|
||||
&state,
|
||||
&request.agent_id,
|
||||
terminal_id.as_str(),
|
||||
TerminalAudit {
|
||||
actor_type: "admin_api",
|
||||
event_type: "terminal_request",
|
||||
outcome: "sent",
|
||||
message: "terminal session requested",
|
||||
metadata: serde_json::json!({ "rows": request.rows, "cols": request.cols }),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
info!(terminal_id = %terminal_id, agent_id = %request.agent_id, "terminal session requested");
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(TerminalSessionResponse {
|
||||
websocket_url: operator_ws_path(terminal_id.as_str()),
|
||||
terminal_id: terminal_id.to_string(),
|
||||
agent_id: request.agent_id,
|
||||
created_at_unix: created.created_at_unix,
|
||||
expires_at_unix: created.expires_at_unix,
|
||||
agent_attached: false,
|
||||
operator_attached: false,
|
||||
attachment_token: Some(created.attachment_token),
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn get_terminal(
|
||||
State(state): State<AppState>,
|
||||
Path(terminal_id): Path<String>,
|
||||
) -> Result<Json<TerminalSessionResponse>, ApiError> {
|
||||
let summary = state
|
||||
.terminals
|
||||
.summary(&terminal_id)
|
||||
.await
|
||||
.ok_or_else(|| terminal_not_found(&terminal_id))?;
|
||||
Ok(Json(TerminalSessionResponse {
|
||||
websocket_url: operator_ws_path(&terminal_id),
|
||||
terminal_id,
|
||||
agent_id: summary.agent_id,
|
||||
created_at_unix: summary.created_at_unix,
|
||||
expires_at_unix: summary.expires_at_unix,
|
||||
agent_attached: summary.agent_attached,
|
||||
operator_attached: summary.operator_attached,
|
||||
attachment_token: None,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn list_terminals(State(state): State<AppState>) -> Json<Vec<TerminalSessionResponse>> {
|
||||
Json(
|
||||
state
|
||||
.terminals
|
||||
.summaries()
|
||||
.await
|
||||
.into_iter()
|
||||
.map(terminal_response)
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn attach_terminal(
|
||||
State(state): State<AppState>,
|
||||
Path(terminal_id): Path<String>,
|
||||
Json(request): Json<AttachTerminalRequest>,
|
||||
) -> Result<Json<TerminalSessionResponse>, ApiError> {
|
||||
let attachment_token = state
|
||||
.terminals
|
||||
.issue_attachment_token_for_operator(&terminal_id, &request.operator_id)
|
||||
.await
|
||||
.map_err(registry_error)?;
|
||||
let summary = state
|
||||
.terminals
|
||||
.summary(&terminal_id)
|
||||
.await
|
||||
.ok_or_else(|| terminal_not_found(&terminal_id))?;
|
||||
Ok(Json(TerminalSessionResponse {
|
||||
websocket_url: operator_ws_path(&terminal_id),
|
||||
terminal_id,
|
||||
agent_id: summary.agent_id,
|
||||
created_at_unix: summary.created_at_unix,
|
||||
expires_at_unix: summary.expires_at_unix,
|
||||
agent_attached: summary.agent_attached,
|
||||
operator_attached: summary.operator_attached,
|
||||
attachment_token: Some(attachment_token),
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn close_terminal(
|
||||
State(state): State<AppState>,
|
||||
Path(terminal_id): Path<String>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
if let Some(agent_id) =
|
||||
close_registered_terminal(&state, &terminal_id, TerminalCloseReason::HttpDelete).await
|
||||
{
|
||||
info!(terminal_id, agent_id, "terminal session closed by operator");
|
||||
} else {
|
||||
if !state.terminals.was_closed(&terminal_id).await {
|
||||
return Err(terminal_not_found(&terminal_id));
|
||||
}
|
||||
info!(terminal_id, "terminal session was already closed");
|
||||
}
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
pub async fn agent_terminal_ws(
|
||||
ws: WebSocketUpgrade,
|
||||
State(state): State<AppState>,
|
||||
Path(terminal_id): Path<String>,
|
||||
) -> Response {
|
||||
ws.max_message_size(TERMINAL_MAX_FRAME_BYTES)
|
||||
.on_upgrade(move |socket| handle_agent_terminal_socket(state, terminal_id, socket))
|
||||
}
|
||||
|
||||
pub async fn operator_terminal_ws(
|
||||
ws: WebSocketUpgrade,
|
||||
State(state): State<AppState>,
|
||||
Path(terminal_id): Path<String>,
|
||||
) -> Response {
|
||||
ws.max_message_size(TERMINAL_MAX_FRAME_BYTES)
|
||||
.on_upgrade(move |socket| handle_operator_terminal_socket(state, terminal_id, socket))
|
||||
}
|
||||
|
||||
pub async fn terminal_events_ws(ws: WebSocketUpgrade, State(state): State<AppState>) -> Response {
|
||||
ws.on_upgrade(move |socket| handle_terminal_events_socket(state, socket))
|
||||
}
|
||||
|
||||
async fn handle_terminal_events_socket(state: AppState, socket: WebSocket) {
|
||||
let mut events = state.terminals.subscribe();
|
||||
let (mut write, mut read) = socket.split();
|
||||
if write
|
||||
.send(Message::Text(r#"{"type":"sessions_changed"}"#.into()))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
event = events.recv() => {
|
||||
match event {
|
||||
Ok(()) | Err(broadcast::error::RecvError::Lagged(_)) => {
|
||||
if write.send(Message::Text(r#"{"type":"sessions_changed"}"#.into())).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => break,
|
||||
}
|
||||
}
|
||||
incoming = read.next() => {
|
||||
match incoming {
|
||||
Some(Ok(Message::Ping(payload))) => {
|
||||
if write.send(Message::Pong(payload)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Some(Ok(Message::Pong(_))) => {}
|
||||
Some(Ok(Message::Close(_))) | None | Some(Err(_)) => break,
|
||||
Some(Ok(Message::Text(_))) | Some(Ok(Message::Binary(_))) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_agent_terminal_socket(state: AppState, terminal_id: String, mut socket: WebSocket) {
|
||||
let auth = match receive_text_handshake(&mut socket).await.and_then(|text| {
|
||||
serde_json::from_str::<TerminalAgentHandshake>(&text).map_err(|_| "invalid handshake")
|
||||
}) {
|
||||
Ok(TerminalAgentHandshake::Auth {
|
||||
agent_id,
|
||||
relay_token,
|
||||
}) => (agent_id, relay_token),
|
||||
Err(code) => {
|
||||
close_socket(&mut socket, code).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
let (mut outbound, pending) = match state
|
||||
.terminals
|
||||
.attach_agent(&terminal_id, &auth.0, &auth.1)
|
||||
.await
|
||||
{
|
||||
Ok(outbound) => outbound,
|
||||
Err(code) => {
|
||||
close_socket(&mut socket, code).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
info!(terminal_id, agent_id = %auth.0, "agent terminal socket attached");
|
||||
append_terminal_audit(
|
||||
&state,
|
||||
&auth.0,
|
||||
&terminal_id,
|
||||
TerminalAudit {
|
||||
actor_type: "agent",
|
||||
event_type: "terminal_agent_attach",
|
||||
outcome: "ok",
|
||||
message: "agent terminal transport attached",
|
||||
metadata: serde_json::json!({}),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
let (mut write, mut read) = socket.split();
|
||||
for frame in pending {
|
||||
let closes = matches!(frame, TerminalRelayFrame::Close);
|
||||
if send_relay_frame(&mut write, frame).await.is_err() || closes {
|
||||
state.terminals.remove(&terminal_id).await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
loop {
|
||||
tokio::select! {
|
||||
biased;
|
||||
outbound_frame = outbound.recv() => {
|
||||
let Some(frame) = outbound_frame else { break; };
|
||||
if send_relay_frame(&mut write, frame).await.is_err() { break; }
|
||||
}
|
||||
incoming = read.next() => {
|
||||
let Some(Ok(message)) = incoming else { break; };
|
||||
match agent_relay_frame(message) {
|
||||
Ok(Some(frame)) => {
|
||||
let closes = matches!(frame, TerminalRelayFrame::Close);
|
||||
audit_agent_control_frame(&state, &auth.0, &terminal_id, &frame).await;
|
||||
if state.terminals.relay_from_agent(&terminal_id, frame).await.is_err() { break; }
|
||||
if closes { break; }
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(code) => {
|
||||
warn!(terminal_id, code, "invalid agent terminal frame");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(agent_id) = state.terminals.detach_agent(&terminal_id).await
|
||||
&& let Some(session) = state.sessions.read().await.get(&agent_id)
|
||||
{
|
||||
let _ = session
|
||||
.tx
|
||||
.send(SessionEvent::Message(ServerMessage::SyncTerminalSessions));
|
||||
}
|
||||
info!(terminal_id, "agent terminal relay detached");
|
||||
}
|
||||
|
||||
async fn handle_operator_terminal_socket(
|
||||
state: AppState,
|
||||
terminal_id: String,
|
||||
mut socket: WebSocket,
|
||||
) {
|
||||
let auth = match receive_text_handshake(&mut socket).await.and_then(|text| {
|
||||
serde_json::from_str::<TerminalOperatorHandshake>(&text).map_err(|_| "invalid handshake")
|
||||
}) {
|
||||
Ok(TerminalOperatorHandshake::Attach {
|
||||
attachment_token,
|
||||
operator_id,
|
||||
}) => (attachment_token, operator_id),
|
||||
Err(code) => {
|
||||
close_socket(&mut socket, code).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
let mut outbound = match state
|
||||
.terminals
|
||||
.attach_operator(&terminal_id, &auth.0, &auth.1)
|
||||
.await
|
||||
{
|
||||
Ok(attached) => attached,
|
||||
Err(code) => {
|
||||
close_socket(&mut socket, code).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
info!(terminal_id, "operator terminal socket attached");
|
||||
let snapshot = serde_json::to_string(&TerminalControl::Snapshot)
|
||||
.expect("terminal snapshot control serializes");
|
||||
if let Err(code) = state
|
||||
.terminals
|
||||
.relay_to_agent(&terminal_id, TerminalRelayFrame::Text(snapshot))
|
||||
.await
|
||||
{
|
||||
warn!(terminal_id, code, "failed to request terminal snapshot");
|
||||
}
|
||||
let summary = state.terminals.summary(&terminal_id).await;
|
||||
if let Some(summary) = &summary {
|
||||
append_terminal_audit(
|
||||
&state,
|
||||
&summary.agent_id,
|
||||
&terminal_id,
|
||||
TerminalAudit {
|
||||
actor_type: "admin_api",
|
||||
event_type: "terminal_operator_attach",
|
||||
outcome: "ok",
|
||||
message: "operator terminal transport attached",
|
||||
metadata: serde_json::json!({}),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let (mut write, mut read) = socket.split();
|
||||
if summary.is_some_and(|summary| summary.agent_attached) {
|
||||
let ready = serde_json::to_string(&TerminalControl::Ready)
|
||||
.expect("terminal ready control serializes");
|
||||
if send_relay_frame(&mut write, TerminalRelayFrame::Text(ready))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
state.terminals.detach_operator(&terminal_id, &auth.0).await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
let mut explicit_close = false;
|
||||
loop {
|
||||
tokio::select! {
|
||||
biased;
|
||||
incoming = read.next() => {
|
||||
let Some(Ok(message)) = incoming else { break; };
|
||||
match operator_relay_frame(message) {
|
||||
Ok(Some(frame)) => {
|
||||
explicit_close = matches!(frame, TerminalRelayFrame::Close);
|
||||
if state.terminals.relay_to_agent(&terminal_id, frame).await.is_err() { break; }
|
||||
if explicit_close { break; }
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(code) => {
|
||||
warn!(terminal_id, code, "invalid operator terminal frame");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
outbound_frame = outbound.recv() => {
|
||||
let Some(frame) = outbound_frame else { break; };
|
||||
if send_relay_frame(&mut write, frame).await.is_err() { break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if explicit_close {
|
||||
close_registered_terminal(&state, &terminal_id, TerminalCloseReason::BrowserClose).await;
|
||||
} else {
|
||||
state.terminals.detach_operator(&terminal_id, &auth.0).await;
|
||||
}
|
||||
info!(
|
||||
terminal_id,
|
||||
explicit_close, "operator terminal socket detached"
|
||||
);
|
||||
}
|
||||
|
||||
fn terminal_response(summary: TerminalSummary) -> TerminalSessionResponse {
|
||||
TerminalSessionResponse {
|
||||
websocket_url: operator_ws_path(&summary.terminal_id),
|
||||
terminal_id: summary.terminal_id,
|
||||
agent_id: summary.agent_id,
|
||||
created_at_unix: summary.created_at_unix,
|
||||
expires_at_unix: summary.expires_at_unix,
|
||||
agent_attached: summary.agent_attached,
|
||||
operator_attached: summary.operator_attached,
|
||||
attachment_token: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn agent_relay_frame(message: Message) -> Result<Option<TerminalRelayFrame>, &'static str> {
|
||||
match message {
|
||||
Message::Binary(bytes) => Ok(Some(TerminalRelayFrame::Binary(bytes.to_vec()))),
|
||||
Message::Text(text) => {
|
||||
let control: TerminalControl =
|
||||
serde_json::from_str(&text).map_err(|_| "terminal_control_invalid")?;
|
||||
if !matches!(
|
||||
control,
|
||||
TerminalControl::Ready
|
||||
| TerminalControl::Exited { .. }
|
||||
| TerminalControl::Error { .. }
|
||||
| TerminalControl::Close
|
||||
) {
|
||||
return Err("terminal_control_direction_invalid");
|
||||
}
|
||||
Ok(Some(TerminalRelayFrame::Text(
|
||||
serde_json::to_string(&control).map_err(|_| "terminal_control_invalid")?,
|
||||
)))
|
||||
}
|
||||
Message::Ping(_) | Message::Pong(_) => Ok(None),
|
||||
Message::Close(_) => Ok(Some(TerminalRelayFrame::Close)),
|
||||
}
|
||||
}
|
||||
|
||||
fn operator_relay_frame(message: Message) -> Result<Option<TerminalRelayFrame>, &'static str> {
|
||||
match message {
|
||||
Message::Binary(bytes) => Ok(Some(TerminalRelayFrame::Binary(bytes.to_vec()))),
|
||||
Message::Text(text) => {
|
||||
let control: TerminalControl =
|
||||
serde_json::from_str(&text).map_err(|_| "terminal_control_invalid")?;
|
||||
if !matches!(
|
||||
control,
|
||||
TerminalControl::Resize { .. } | TerminalControl::Close
|
||||
) {
|
||||
return Err("terminal_control_direction_invalid");
|
||||
}
|
||||
if let TerminalControl::Resize { rows, cols } = control {
|
||||
validate_size(rows, cols).map_err(|_| "terminal_size_invalid")?;
|
||||
}
|
||||
Ok(Some(TerminalRelayFrame::Text(
|
||||
serde_json::to_string(&control).map_err(|_| "terminal_control_invalid")?,
|
||||
)))
|
||||
}
|
||||
Message::Ping(_) | Message::Pong(_) => Ok(None),
|
||||
Message::Close(_) => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
async fn receive_text_handshake(socket: &mut WebSocket) -> Result<String, &'static str> {
|
||||
match tokio::time::timeout(TERMINAL_ATTACH_TIMEOUT, socket.recv()).await {
|
||||
Ok(Some(Ok(Message::Text(text)))) => Ok(text.to_string()),
|
||||
Ok(_) => Err("terminal_handshake_required"),
|
||||
Err(_) => Err("terminal_handshake_timeout"),
|
||||
}
|
||||
}
|
||||
|
||||
async fn close_socket(socket: &mut WebSocket, code: &str) {
|
||||
let control = TerminalControl::Error {
|
||||
code: code.into(),
|
||||
message: code.replace('_', " "),
|
||||
};
|
||||
if let Ok(json) = serde_json::to_string(&control) {
|
||||
let _ = socket.send(Message::Text(json.into())).await;
|
||||
}
|
||||
let _ = socket.send(Message::Close(None)).await;
|
||||
}
|
||||
|
||||
async fn send_relay_frame<S>(write: &mut S, frame: TerminalRelayFrame) -> Result<(), axum::Error>
|
||||
where
|
||||
S: futures_util::Sink<Message, Error = axum::Error> + Unpin,
|
||||
{
|
||||
let message = match frame {
|
||||
TerminalRelayFrame::Binary(bytes) => Message::Binary(bytes.into()),
|
||||
TerminalRelayFrame::Text(text) => Message::Text(text.into()),
|
||||
TerminalRelayFrame::Close => Message::Close(None),
|
||||
};
|
||||
write.send(message).await
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum TerminalCloseReason {
|
||||
HttpDelete,
|
||||
BrowserClose,
|
||||
AbsoluteTimeout,
|
||||
}
|
||||
|
||||
impl TerminalCloseReason {
|
||||
fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::HttpDelete => "http_delete",
|
||||
Self::BrowserClose => "browser_close",
|
||||
Self::AbsoluteTimeout => "absolute_timeout",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes and audits a terminal as one logical operation. Only the caller
|
||||
/// that actually removes the session records the close, keeping retries and
|
||||
/// simultaneous timeout/operator closes from producing duplicate events.
|
||||
async fn close_registered_terminal(
|
||||
state: &AppState,
|
||||
terminal_id: &str,
|
||||
reason: TerminalCloseReason,
|
||||
) -> Option<String> {
|
||||
let agent_id = state.terminals.remove(terminal_id).await?;
|
||||
if let Some(session) = state.sessions.read().await.get(&agent_id) {
|
||||
let terminal_id = TerminalId::new(terminal_id.to_string()).ok()?;
|
||||
let _ = session
|
||||
.tx
|
||||
.send(SessionEvent::Message(ServerMessage::CloseTerminal {
|
||||
terminal_id,
|
||||
}));
|
||||
}
|
||||
append_terminal_audit(
|
||||
state,
|
||||
&agent_id,
|
||||
terminal_id,
|
||||
TerminalAudit {
|
||||
actor_type: "admin_api",
|
||||
event_type: "terminal_close",
|
||||
outcome: "ok",
|
||||
message: "terminal session closed",
|
||||
metadata: serde_json::json!({ "reason": reason.as_str() }),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
Some(agent_id)
|
||||
}
|
||||
|
||||
fn spawn_session_timeout(
|
||||
state: AppState,
|
||||
terminal_id: TerminalId,
|
||||
agent_id: String,
|
||||
session_ttl_seconds: u64,
|
||||
) {
|
||||
if session_ttl_seconds == 0 {
|
||||
return;
|
||||
}
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_secs(session_ttl_seconds)).await;
|
||||
if close_registered_terminal(
|
||||
&state,
|
||||
terminal_id.as_str(),
|
||||
TerminalCloseReason::AbsoluteTimeout,
|
||||
)
|
||||
.await
|
||||
.is_some()
|
||||
{
|
||||
info!(terminal_id = %terminal_id, agent_id, "terminal absolute timeout reached");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
struct TerminalAudit<'a> {
|
||||
actor_type: &'a str,
|
||||
event_type: &'a str,
|
||||
outcome: &'a str,
|
||||
message: &'a str,
|
||||
metadata: serde_json::Value,
|
||||
}
|
||||
|
||||
async fn audit_agent_control_frame(
|
||||
state: &AppState,
|
||||
agent_id: &str,
|
||||
terminal_id: &str,
|
||||
frame: &TerminalRelayFrame,
|
||||
) {
|
||||
let TerminalRelayFrame::Text(text) = frame else {
|
||||
return;
|
||||
};
|
||||
let Ok(control) = serde_json::from_str::<TerminalControl>(text) else {
|
||||
return;
|
||||
};
|
||||
let audit = match control {
|
||||
TerminalControl::Ready => TerminalAudit {
|
||||
actor_type: "agent",
|
||||
event_type: "terminal_ready",
|
||||
outcome: "ok",
|
||||
message: "terminal PTY ready",
|
||||
metadata: serde_json::json!({}),
|
||||
},
|
||||
TerminalControl::Exited { exit_code } => TerminalAudit {
|
||||
actor_type: "agent",
|
||||
event_type: "terminal_exit",
|
||||
outcome: "exited",
|
||||
message: "terminal process exited",
|
||||
metadata: serde_json::json!({ "exit_code": exit_code }),
|
||||
},
|
||||
TerminalControl::Error { code, .. } => TerminalAudit {
|
||||
actor_type: "agent",
|
||||
event_type: "terminal_error",
|
||||
outcome: "error",
|
||||
message: "terminal worker reported an error",
|
||||
metadata: serde_json::json!({ "code": code }),
|
||||
},
|
||||
_ => return,
|
||||
};
|
||||
append_terminal_audit(state, agent_id, terminal_id, audit).await;
|
||||
}
|
||||
|
||||
async fn append_terminal_audit(
|
||||
state: &AppState,
|
||||
agent_id: &str,
|
||||
terminal_id: &str,
|
||||
audit: TerminalAudit<'_>,
|
||||
) {
|
||||
state
|
||||
.store
|
||||
.audit(AuditEventInput {
|
||||
actor_type: audit.actor_type.into(),
|
||||
actor_id: None,
|
||||
agent_id: Some(agent_id.to_string()),
|
||||
request_id: Some(terminal_id.to_string()),
|
||||
event_type: audit.event_type.into(),
|
||||
outcome: audit.outcome.into(),
|
||||
latency_ms: None,
|
||||
message: audit.message.into(),
|
||||
metadata: audit.metadata,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
fn validate_size(rows: u16, cols: u16) -> Result<(), ApiError> {
|
||||
if !(1..=300).contains(&rows) || !(1..=500).contains(&cols) {
|
||||
return Err(ApiError::new(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"terminal_size_invalid",
|
||||
"terminal rows must be 1..=300 and columns must be 1..=500",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn registry_error(code: &'static str) -> ApiError {
|
||||
let status = match code {
|
||||
"terminal_not_found" => StatusCode::NOT_FOUND,
|
||||
"terminal_operator_id_invalid" => StatusCode::BAD_REQUEST,
|
||||
"terminal_relay_token_invalid"
|
||||
| "terminal_attachment_token_invalid"
|
||||
| "terminal_operator_mismatch" => StatusCode::UNAUTHORIZED,
|
||||
_ => StatusCode::CONFLICT,
|
||||
};
|
||||
ApiError::new(status, code, code.replace('_', " "))
|
||||
}
|
||||
|
||||
fn terminal_not_found(terminal_id: &str) -> ApiError {
|
||||
ApiError::new(
|
||||
StatusCode::NOT_FOUND,
|
||||
"terminal_not_found",
|
||||
format!("terminal session {terminal_id} was not found"),
|
||||
)
|
||||
}
|
||||
|
||||
fn operator_ws_path(terminal_id: &str) -> String {
|
||||
format!("/api/v1/control/terminals/{terminal_id}/ws")
|
||||
}
|
||||
|
||||
const fn default_rows() -> u16 {
|
||||
24
|
||||
}
|
||||
|
||||
const fn default_cols() -> u16 {
|
||||
80
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn terminal_control_direction_is_enforced() {
|
||||
let resize = serde_json::to_string(&TerminalControl::Resize { rows: 24, cols: 80 })
|
||||
.expect("resize json");
|
||||
assert!(operator_relay_frame(Message::Text(resize.into())).is_ok());
|
||||
|
||||
let ready = serde_json::to_string(&TerminalControl::Ready).expect("ready json");
|
||||
assert!(operator_relay_frame(Message::Text(ready.into())).is_err());
|
||||
|
||||
let snapshot = serde_json::to_string(&TerminalControl::Snapshot).expect("snapshot json");
|
||||
assert!(operator_relay_frame(Message::Text(snapshot.into())).is_err());
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ use anyhow::{Context, Result};
|
||||
|
||||
use crate::cli::{InitConfigArgs, ServeArgs};
|
||||
use crate::config::resolve::{load_file_config, normalize_public_url, resolve_path};
|
||||
use crate::config::types::{WritableConfig, WritableTelemetry};
|
||||
use crate::config::types::{DEFAULT_CONFIG, WritableConfig, WritableTelemetry};
|
||||
|
||||
pub fn write_init_config(args: &InitConfigArgs) -> Result<Option<PathBuf>> {
|
||||
if args.stdout && args.config_file.is_some() {
|
||||
@@ -30,6 +30,7 @@ pub fn write_init_config(args: &InitConfigArgs) -> Result<Option<PathBuf>> {
|
||||
Default::default()
|
||||
};
|
||||
let base_telemetry = base.telemetry.unwrap_or_default();
|
||||
let defaults = &*DEFAULT_CONFIG;
|
||||
|
||||
let bind = match args.bind {
|
||||
Some(bind) => bind,
|
||||
@@ -43,7 +44,8 @@ pub fn write_init_config(args: &InitConfigArgs) -> Result<Option<PathBuf>> {
|
||||
.unwrap_or_else(|| "<defaults>".to_string())
|
||||
)
|
||||
})?,
|
||||
None => "0.0.0.0:8080"
|
||||
None => defaults
|
||||
.bind
|
||||
.parse()
|
||||
.expect("static default bind should parse"),
|
||||
},
|
||||
@@ -52,37 +54,38 @@ pub fn write_init_config(args: &InitConfigArgs) -> Result<Option<PathBuf>> {
|
||||
args.public_url
|
||||
.as_deref()
|
||||
.or(base.public_url.as_deref())
|
||||
.unwrap_or("http://127.0.0.1:8080"),
|
||||
.unwrap_or(&defaults.public_url),
|
||||
);
|
||||
|
||||
let data_dir = args
|
||||
.data_dir
|
||||
.clone()
|
||||
.or(base.data_dir)
|
||||
.unwrap_or_else(|| PathBuf::from(crate::cli::DEFAULT_DATA_DIR));
|
||||
.unwrap_or_else(|| defaults.data_dir.clone());
|
||||
|
||||
let state_file_raw = args
|
||||
.state_file
|
||||
.clone()
|
||||
.or(base.state_file)
|
||||
.unwrap_or_else(|| PathBuf::from("state.sqlite3"));
|
||||
let state_file = resolve_path(&data_dir, state_file_raw);
|
||||
.unwrap_or_else(|| defaults.state_file.clone());
|
||||
let state_file = path_for_rendered_config(&data_dir, state_file_raw);
|
||||
|
||||
let pid_file_raw = args
|
||||
.pid_file
|
||||
.clone()
|
||||
.or(base.pid_file)
|
||||
.unwrap_or_else(|| PathBuf::from("wakey-control-plane.pid"));
|
||||
let pid_file = resolve_path(&data_dir, pid_file_raw);
|
||||
.unwrap_or_else(|| defaults.pid_file.clone());
|
||||
let pid_file = path_for_rendered_config(&data_dir, pid_file_raw);
|
||||
|
||||
let ui_dist_dir = args
|
||||
.ui_dist_dir
|
||||
.clone()
|
||||
.or(base.ui_dist_dir)
|
||||
.unwrap_or_else(|| PathBuf::from("ui/dist"));
|
||||
.unwrap_or_else(|| defaults.ui_dist_dir.clone());
|
||||
|
||||
let bootstrap_enroll_tokens = if args.bootstrap_enroll_tokens.is_empty() {
|
||||
base.bootstrap_enroll_tokens.unwrap_or_default()
|
||||
base.bootstrap_enroll_tokens
|
||||
.unwrap_or_else(|| defaults.bootstrap_enroll_tokens.clone())
|
||||
} else {
|
||||
args.bootstrap_enroll_tokens.clone()
|
||||
};
|
||||
@@ -95,17 +98,17 @@ pub fn write_init_config(args: &InitConfigArgs) -> Result<Option<PathBuf>> {
|
||||
command_timeout_ms: args
|
||||
.command_timeout_ms
|
||||
.or(base.command_timeout_ms)
|
||||
.unwrap_or(30_000)
|
||||
.unwrap_or(defaults.command_timeout_ms)
|
||||
.max(1),
|
||||
enroll_token_ttl_seconds: args
|
||||
.enroll_token_ttl_seconds
|
||||
.or(base.enroll_token_ttl_seconds)
|
||||
.unwrap_or(86_400)
|
||||
.unwrap_or(defaults.enroll_token_ttl_seconds)
|
||||
.max(1),
|
||||
observation_retention_seconds: args
|
||||
.observation_retention_seconds
|
||||
.or(base.observation_retention_seconds)
|
||||
.unwrap_or(2_592_000),
|
||||
.unwrap_or(defaults.observation_retention_seconds),
|
||||
pid_file,
|
||||
ui_dist_dir,
|
||||
bootstrap_enroll_tokens,
|
||||
@@ -113,16 +116,17 @@ pub fn write_init_config(args: &InitConfigArgs) -> Result<Option<PathBuf>> {
|
||||
otlp_endpoint: args
|
||||
.telemetry_otlp_endpoint
|
||||
.clone()
|
||||
.or(base_telemetry.otlp_endpoint),
|
||||
.or(base_telemetry.otlp_endpoint)
|
||||
.or_else(|| defaults.telemetry.otlp_endpoint.clone()),
|
||||
service_name: args
|
||||
.telemetry_service_name
|
||||
.clone()
|
||||
.or(base_telemetry.service_name)
|
||||
.unwrap_or_else(|| "wakey-control-plane".to_string()),
|
||||
.unwrap_or_else(|| defaults.telemetry.service_name.clone()),
|
||||
json_logs: args
|
||||
.telemetry_json_logs
|
||||
.or(base_telemetry.json_logs)
|
||||
.unwrap_or(false),
|
||||
.unwrap_or(defaults.telemetry.json_logs),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -143,6 +147,18 @@ pub fn write_init_config(args: &InitConfigArgs) -> Result<Option<PathBuf>> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Keep relative paths relative when the rendered config's `data_dir` is also
|
||||
/// relative. The daemon resolves relative paths under `data_dir`; embedding
|
||||
/// that directory into another relative path here would make it get joined a
|
||||
/// second time when the generated config is loaded.
|
||||
fn path_for_rendered_config(data_dir: &std::path::Path, candidate: PathBuf) -> PathBuf {
|
||||
if data_dir.is_relative() && candidate.is_relative() {
|
||||
candidate
|
||||
} else {
|
||||
resolve_path(data_dir, candidate)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bootstrap_config_if_missing(args: &ServeArgs) -> Result<bool> {
|
||||
if args.config_file.exists() {
|
||||
return Ok(false);
|
||||
@@ -176,6 +192,7 @@ pub fn bootstrap_config_if_missing(args: &ServeArgs) -> Result<bool> {
|
||||
mod tests {
|
||||
use super::write_init_config;
|
||||
use crate::cli::InitConfigArgs;
|
||||
use crate::config::types::DEFAULT_CONFIG;
|
||||
|
||||
fn temp_test_dir(name: &str) -> std::path::PathBuf {
|
||||
std::env::temp_dir().join(format!(
|
||||
@@ -251,4 +268,98 @@ json_logs = true
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn init_config_renders_shared_default_template() {
|
||||
let dir = temp_test_dir("defaults");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("create temp dir");
|
||||
let out = dir.join("out.toml");
|
||||
|
||||
write_init_config(&InitConfigArgs {
|
||||
config_file: Some(out.clone()),
|
||||
from_config: None,
|
||||
stdout: false,
|
||||
data_dir: None,
|
||||
bind: None,
|
||||
public_url: None,
|
||||
state_file: None,
|
||||
pid_file: None,
|
||||
ui_dist_dir: None,
|
||||
command_timeout_ms: None,
|
||||
enroll_token_ttl_seconds: None,
|
||||
observation_retention_seconds: None,
|
||||
bootstrap_enroll_tokens: Vec::new(),
|
||||
telemetry_otlp_endpoint: None,
|
||||
telemetry_service_name: None,
|
||||
telemetry_json_logs: None,
|
||||
force: false,
|
||||
})
|
||||
.expect("write default config");
|
||||
|
||||
let rendered = std::fs::read_to_string(&out).expect("read default config");
|
||||
let parsed: toml::Value = toml::from_str(&rendered).expect("parse default config");
|
||||
let defaults = &*DEFAULT_CONFIG;
|
||||
assert_eq!(
|
||||
parsed["state_file"].as_str(),
|
||||
defaults.data_dir.join(&defaults.state_file).to_str()
|
||||
);
|
||||
assert_eq!(
|
||||
parsed["command_timeout_ms"].as_integer(),
|
||||
Some(defaults.command_timeout_ms as i64)
|
||||
);
|
||||
assert_eq!(
|
||||
parsed["telemetry"]["service_name"].as_str(),
|
||||
Some(defaults.telemetry.service_name.as_str())
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn init_config_preserves_relative_paths_for_relative_data_dir() {
|
||||
let dir = temp_test_dir("relative-data-dir");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("create temp dir");
|
||||
let base = dir.join("base.toml");
|
||||
let out = dir.join("out.toml");
|
||||
std::fs::write(
|
||||
&base,
|
||||
r#"
|
||||
data_dir = "test-ddir/wakey-control-plane"
|
||||
state_file = "state.sqlite3"
|
||||
pid_file = "wakey-control-plane.pid"
|
||||
"#,
|
||||
)
|
||||
.expect("write base config");
|
||||
|
||||
write_init_config(&InitConfigArgs {
|
||||
config_file: Some(out.clone()),
|
||||
from_config: Some(base),
|
||||
stdout: false,
|
||||
data_dir: None,
|
||||
bind: None,
|
||||
public_url: None,
|
||||
state_file: None,
|
||||
pid_file: None,
|
||||
ui_dist_dir: None,
|
||||
command_timeout_ms: None,
|
||||
enroll_token_ttl_seconds: None,
|
||||
observation_retention_seconds: None,
|
||||
bootstrap_enroll_tokens: Vec::new(),
|
||||
telemetry_otlp_endpoint: None,
|
||||
telemetry_service_name: None,
|
||||
telemetry_json_logs: None,
|
||||
force: false,
|
||||
})
|
||||
.expect("write derived config");
|
||||
|
||||
let parsed: toml::Value =
|
||||
toml::from_str(&std::fs::read_to_string(&out).expect("read derived config"))
|
||||
.expect("parse derived config");
|
||||
assert_eq!(parsed["state_file"].as_str(), Some("state.sqlite3"));
|
||||
assert_eq!(parsed["pid_file"].as_str(), Some("wakey-control-plane.pid"));
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -9,19 +9,20 @@ use crate::cli::{
|
||||
RevokeEnrollTokenArgs, ServeArgs, StateStatsArgs,
|
||||
};
|
||||
use crate::config::types::{
|
||||
DaemonConfig, FileConfig, FileTelemetryConfig, IssueTokenSettings, StateAccessSettings,
|
||||
TelemetryConfig,
|
||||
DEFAULT_CONFIG, DaemonConfig, FileConfig, FileTelemetryConfig, IssueTokenSettings,
|
||||
StateAccessSettings, TelemetryConfig,
|
||||
};
|
||||
|
||||
impl DaemonConfig {
|
||||
pub fn from_serve_args(args: &ServeArgs) -> Result<Self> {
|
||||
let file = load_file_config(&args.config_file)?;
|
||||
let defaults = &*DEFAULT_CONFIG;
|
||||
|
||||
let data_dir = args
|
||||
.data_dir
|
||||
.clone()
|
||||
.or(file.data_dir.clone())
|
||||
.unwrap_or_else(|| PathBuf::from(crate::cli::DEFAULT_DATA_DIR));
|
||||
.unwrap_or_else(|| defaults.data_dir.clone());
|
||||
|
||||
let bind = match args.bind {
|
||||
Some(bind) => bind,
|
||||
@@ -32,7 +33,8 @@ impl DaemonConfig {
|
||||
args.config_file.display()
|
||||
)
|
||||
})?,
|
||||
None => "0.0.0.0:8080"
|
||||
None => defaults
|
||||
.bind
|
||||
.parse()
|
||||
.expect("static default bind should parse"),
|
||||
},
|
||||
@@ -42,34 +44,34 @@ impl DaemonConfig {
|
||||
args.public_url
|
||||
.as_deref()
|
||||
.or(file.public_url.as_deref())
|
||||
.unwrap_or("http://127.0.0.1:8080"),
|
||||
.unwrap_or(&defaults.public_url),
|
||||
);
|
||||
|
||||
let state_file_raw = args
|
||||
.state_file
|
||||
.clone()
|
||||
.or(file.state_file)
|
||||
.unwrap_or_else(|| PathBuf::from("state.sqlite3"));
|
||||
.unwrap_or_else(|| defaults.state_file.clone());
|
||||
let state_file = resolve_path(&data_dir, state_file_raw);
|
||||
|
||||
let command_timeout = Duration::from_millis(
|
||||
args.command_timeout_ms
|
||||
.or(file.command_timeout_ms)
|
||||
.unwrap_or(30_000)
|
||||
.unwrap_or(defaults.command_timeout_ms)
|
||||
.max(1),
|
||||
);
|
||||
|
||||
let enroll_token_ttl = Duration::from_secs(
|
||||
args.enroll_token_ttl_seconds
|
||||
.or(file.enroll_token_ttl_seconds)
|
||||
.unwrap_or(86_400)
|
||||
.unwrap_or(defaults.enroll_token_ttl_seconds)
|
||||
.max(1),
|
||||
);
|
||||
|
||||
let observation_retention = Duration::from_secs(
|
||||
args.observation_retention_seconds
|
||||
.or(file.observation_retention_seconds)
|
||||
.unwrap_or(2_592_000)
|
||||
.unwrap_or(defaults.observation_retention_seconds)
|
||||
.max(1),
|
||||
);
|
||||
|
||||
@@ -77,17 +79,18 @@ impl DaemonConfig {
|
||||
.pid_file
|
||||
.clone()
|
||||
.or(file.pid_file)
|
||||
.unwrap_or_else(|| PathBuf::from("wakey-control-plane.pid"));
|
||||
.unwrap_or_else(|| defaults.pid_file.clone());
|
||||
let pid_file = resolve_path(&data_dir, pid_file_raw);
|
||||
|
||||
let ui_dist_dir = args
|
||||
.ui_dist_dir
|
||||
.clone()
|
||||
.or(file.ui_dist_dir)
|
||||
.unwrap_or_else(|| PathBuf::from("ui/dist"));
|
||||
.unwrap_or_else(|| defaults.ui_dist_dir.clone());
|
||||
|
||||
let bootstrap_enroll_tokens = if args.bootstrap_enroll_tokens.is_empty() {
|
||||
file.bootstrap_enroll_tokens.unwrap_or_default()
|
||||
file.bootstrap_enroll_tokens
|
||||
.unwrap_or_else(|| defaults.bootstrap_enroll_tokens.clone())
|
||||
} else {
|
||||
args.bootstrap_enroll_tokens.clone()
|
||||
};
|
||||
@@ -131,6 +134,7 @@ pub fn resolve_path(data_dir: &Path, candidate: PathBuf) -> PathBuf {
|
||||
|
||||
pub fn resolve_issue_token_settings(args: &IssueEnrollTokenArgs) -> Result<IssueTokenSettings> {
|
||||
let file = load_file_config(&args.config_file)?;
|
||||
let defaults = &*DEFAULT_CONFIG;
|
||||
let state = resolve_state_access(
|
||||
&args.config_file,
|
||||
args.data_dir.clone(),
|
||||
@@ -142,7 +146,7 @@ pub fn resolve_issue_token_settings(args: &IssueEnrollTokenArgs) -> Result<Issue
|
||||
let ttl = Duration::from_secs(
|
||||
args.ttl_seconds
|
||||
.or(file.enroll_token_ttl_seconds)
|
||||
.unwrap_or(86_400)
|
||||
.unwrap_or(defaults.enroll_token_ttl_seconds)
|
||||
.max(1),
|
||||
);
|
||||
|
||||
@@ -254,16 +258,17 @@ fn resolve_state_access(
|
||||
mode: &AdminTargetArgs,
|
||||
) -> Result<StateAccessSettings> {
|
||||
let file = load_file_config(config_file)?;
|
||||
let defaults = &*DEFAULT_CONFIG;
|
||||
|
||||
let data_dir = cli_data_dir
|
||||
.or(file.data_dir)
|
||||
.unwrap_or_else(|| PathBuf::from(crate::cli::DEFAULT_DATA_DIR));
|
||||
.unwrap_or_else(|| defaults.data_dir.clone());
|
||||
|
||||
let state_file = resolve_path(
|
||||
&data_dir,
|
||||
cli_state_file
|
||||
.or(file.state_file)
|
||||
.unwrap_or_else(|| PathBuf::from("state.sqlite3")),
|
||||
.unwrap_or_else(|| defaults.state_file.clone()),
|
||||
);
|
||||
|
||||
let resolved_public_url = cli_public_url
|
||||
@@ -280,7 +285,9 @@ fn resolve_state_access(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{normalize_public_url, resolve_path};
|
||||
use super::{DaemonConfig, normalize_public_url, resolve_path};
|
||||
use crate::cli::ServeArgs;
|
||||
use crate::config::types::DEFAULT_CONFIG;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -303,4 +310,43 @@ mod tests {
|
||||
PathBuf::from("/var/lib/wakey-control-plane/state.sqlite3")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn daemon_config_uses_shared_default_template() {
|
||||
let config_file = std::env::temp_dir().join(format!(
|
||||
"wakey-cc-missing-config-{}.toml",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::remove_file(&config_file);
|
||||
let resolved = DaemonConfig::from_serve_args(&ServeArgs {
|
||||
data_dir: None,
|
||||
bind: None,
|
||||
public_url: None,
|
||||
state_file: None,
|
||||
bootstrap_enroll_tokens: Vec::new(),
|
||||
command_timeout_ms: None,
|
||||
enroll_token_ttl_seconds: None,
|
||||
observation_retention_seconds: None,
|
||||
pid_file: None,
|
||||
ui_dist_dir: None,
|
||||
config_file,
|
||||
bootstrap_config: false,
|
||||
})
|
||||
.expect("resolve default config");
|
||||
let defaults = &*DEFAULT_CONFIG;
|
||||
|
||||
assert_eq!(resolved.data_dir, defaults.data_dir);
|
||||
assert_eq!(
|
||||
resolved.state_file,
|
||||
defaults.data_dir.join(&defaults.state_file)
|
||||
);
|
||||
assert_eq!(
|
||||
resolved.command_timeout,
|
||||
std::time::Duration::from_millis(defaults.command_timeout_ms)
|
||||
);
|
||||
assert_eq!(
|
||||
resolved.telemetry.service_name,
|
||||
defaults.telemetry.service_name
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
use std::net::SocketAddr;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::LazyLock;
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -12,7 +13,6 @@ pub struct DaemonConfig {
|
||||
pub state_file: PathBuf,
|
||||
pub command_timeout: Duration,
|
||||
pub enroll_token_ttl: Duration,
|
||||
#[allow(dead_code)]
|
||||
pub observation_retention: Duration,
|
||||
pub pid_file: PathBuf,
|
||||
pub ui_dist_dir: PathBuf,
|
||||
@@ -29,10 +29,11 @@ pub struct TelemetryConfig {
|
||||
|
||||
impl Default for TelemetryConfig {
|
||||
fn default() -> Self {
|
||||
let defaults = &DEFAULT_CONFIG.telemetry;
|
||||
Self {
|
||||
otlp_endpoint: None,
|
||||
service_name: "wakey-control-plane".to_string(),
|
||||
json_logs: false,
|
||||
otlp_endpoint: defaults.otlp_endpoint.clone(),
|
||||
service_name: defaults.service_name.clone(),
|
||||
json_logs: defaults.json_logs,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -60,7 +61,7 @@ pub(crate) struct FileTelemetryConfig {
|
||||
pub(crate) json_logs: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub(crate) struct WritableConfig {
|
||||
pub(crate) data_dir: PathBuf,
|
||||
pub(crate) bind: String,
|
||||
@@ -76,7 +77,7 @@ pub(crate) struct WritableConfig {
|
||||
pub(crate) telemetry: WritableTelemetry,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub(crate) struct WritableTelemetry {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) otlp_endpoint: Option<String>,
|
||||
@@ -84,6 +85,24 @@ pub(crate) struct WritableTelemetry {
|
||||
pub(crate) json_logs: bool,
|
||||
}
|
||||
|
||||
pub(crate) static DEFAULT_CONFIG: LazyLock<WritableConfig> = LazyLock::new(|| WritableConfig {
|
||||
data_dir: crate::cli::DEFAULT_DATA_DIR.into(),
|
||||
bind: "0.0.0.0:8080".to_string(),
|
||||
public_url: "http://127.0.0.1:8080".to_string(),
|
||||
state_file: "state.sqlite3".into(),
|
||||
command_timeout_ms: 30_000,
|
||||
enroll_token_ttl_seconds: 86_400,
|
||||
observation_retention_seconds: 2_592_000,
|
||||
pid_file: "wakey-control-plane.pid".into(),
|
||||
ui_dist_dir: "ui/dist".into(),
|
||||
bootstrap_enroll_tokens: Vec::new(),
|
||||
telemetry: WritableTelemetry {
|
||||
otlp_endpoint: None,
|
||||
service_name: "wakey-control-plane".to_string(),
|
||||
json_logs: false,
|
||||
},
|
||||
});
|
||||
|
||||
pub struct IssueTokenSettings {
|
||||
pub data_dir: PathBuf,
|
||||
pub state_file: PathBuf,
|
||||
|
||||
@@ -16,7 +16,7 @@ use tower_http::services::ServeFile;
|
||||
use tracing::info;
|
||||
#[cfg(unix)]
|
||||
use tracing::warn;
|
||||
use wakey_agent::protocol::{ErrorPayload, ServerMessage};
|
||||
use wakey_agent::protocol::{AgentCapability, AgentCapabilityOptions, ErrorPayload, ServerMessage};
|
||||
|
||||
use crate::api;
|
||||
use crate::config;
|
||||
@@ -25,6 +25,7 @@ use crate::ws;
|
||||
|
||||
mod admin;
|
||||
mod process;
|
||||
pub mod terminals;
|
||||
pub use admin::revoke_agent;
|
||||
pub use admin::{
|
||||
issue_enroll_token, list_enroll_tokens, migrate_sqlite_state, revoke_enroll_token, state_stats,
|
||||
@@ -41,12 +42,16 @@ pub struct AppState {
|
||||
pub public_url: String,
|
||||
pub command_timeout: Duration,
|
||||
pub enroll_token_ttl: Duration,
|
||||
pub terminals: terminals::TerminalRegistry,
|
||||
pub alerts: api::AlertsHub,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AgentSession {
|
||||
pub connection_id: String,
|
||||
pub tx: mpsc::UnboundedSender<SessionEvent>,
|
||||
pub capabilities: Vec<AgentCapability>,
|
||||
pub capability_options: AgentCapabilityOptions,
|
||||
}
|
||||
#[derive(Clone)]
|
||||
pub enum SessionEvent {
|
||||
@@ -67,11 +72,15 @@ fn public_api_routes(ui_dist_dir: std::path::PathBuf) -> Router<AppState> {
|
||||
.route("/", get(|| async { Redirect::temporary("/ui/") })) // same as caddyfile
|
||||
.nest_service(
|
||||
"/ui/",
|
||||
get_service(ServeDir::new(ui_dist_dir).not_found_service(ServeFile::new(index_file))),
|
||||
get_service(ServeDir::new(ui_dist_dir).fallback(ServeFile::new(index_file))),
|
||||
)
|
||||
.route("/healthz", get(api::healthz))
|
||||
.route("/api/v1/agents/enroll", post(api::enroll))
|
||||
.route("/api/v1/agent/ws", get(ws::agent_ws))
|
||||
.route(
|
||||
"/api/v1/agent/terminals/{terminal_id}/ws",
|
||||
get(api::agent_terminal_ws),
|
||||
)
|
||||
}
|
||||
|
||||
fn control_api_routes() -> Router<AppState> {
|
||||
@@ -104,7 +113,7 @@ fn control_api_routes() -> Router<AppState> {
|
||||
)
|
||||
.route(
|
||||
"/api/v1/control/devices/{device_id}",
|
||||
axum::routing::delete(api::forget_known_device),
|
||||
get(api::get_known_device).delete(api::forget_known_device),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/control/devices/{device_id}/merge",
|
||||
@@ -135,6 +144,26 @@ fn control_api_routes() -> Router<AppState> {
|
||||
"/api/v1/control/agents/{agent_id}/command",
|
||||
post(api::run_command),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/control/terminals",
|
||||
get(api::list_terminals).post(api::create_terminal),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/control/terminals/events/ws",
|
||||
get(api::terminal_events_ws),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/control/terminals/{terminal_id}",
|
||||
get(api::get_terminal).delete(api::close_terminal),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/control/terminals/{terminal_id}/attach",
|
||||
post(api::attach_terminal),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/control/terminals/{terminal_id}/ws",
|
||||
get(api::operator_terminal_ws),
|
||||
)
|
||||
}
|
||||
|
||||
/// Starts the control-plane HTTP and websocket surfaces and manages daemon lifecycle hooks.
|
||||
@@ -157,8 +186,12 @@ pub async fn serve(daemon: config::DaemonConfig) -> Result<()> {
|
||||
public_url: daemon.public_url.clone(),
|
||||
command_timeout: daemon.command_timeout,
|
||||
enroll_token_ttl: daemon.enroll_token_ttl,
|
||||
terminals: terminals::TerminalRegistry::new(),
|
||||
alerts: api::AlertsHub::new(),
|
||||
};
|
||||
|
||||
tokio::spawn(api::run_alert_evaluator(app_state.clone()));
|
||||
|
||||
// Keep route classes explicit so edge policy can map directly:
|
||||
// - public_api_routes: intended internet-facing agent endpoints
|
||||
// - control_api_routes: intended admin-only endpoints behind Access
|
||||
@@ -206,6 +239,18 @@ pub async fn serve(daemon: config::DaemonConfig) -> Result<()> {
|
||||
}
|
||||
Err(err) => warn!(error = %err, "periodic gc failed"),
|
||||
}
|
||||
match app_state.store.gc_retention(daemon.observation_retention).await {
|
||||
Ok(stats) => {
|
||||
if stats.audit_events_removed > 0 || stats.alert_transitions_removed > 0 {
|
||||
info!(
|
||||
audit_events_removed = stats.audit_events_removed,
|
||||
alert_transitions_removed = stats.alert_transitions_removed,
|
||||
"periodic gc pruned rows older than retention window"
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(err) => warn!(error = %err, "periodic retention gc failed"),
|
||||
}
|
||||
}
|
||||
join = &mut server => {
|
||||
let _ = remove_pid_file(&daemon.pid_file);
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -5,6 +5,7 @@ pub use store::Store;
|
||||
#[cfg(test)]
|
||||
pub use types::AgentDeviceRow;
|
||||
pub use types::{
|
||||
AgentDeviceWithChildren, AlertState, AuditEvent, AuditEventFilter, AuditEventInput,
|
||||
DeviceIdentifier, DeviceIdentifierInput, KnownDevice, KnownDeviceInput, KnownDeviceSummary,
|
||||
AgentDeviceWithChildren, AlertState, AlertTransition, AuditEvent, AuditEventFilter,
|
||||
AuditEventInput, DeviceIdentifier, DeviceIdentifierInput, KnownDevice, KnownDeviceInput,
|
||||
KnownDeviceSummary,
|
||||
};
|
||||
@@ -10,7 +10,7 @@ use uuid::Uuid;
|
||||
use crate::state::types::{
|
||||
AlertState, AlertTransition, AuditEvent, AuditEventFilter, AuditEventInput, DeviceIdentifier,
|
||||
DeviceIdentifierInput, EnrollTokenInfo, IssuedAgent, IssuedEnrollToken, KnownDevice,
|
||||
KnownDeviceInput, StateStats,
|
||||
KnownDeviceInput, RetentionStats, StateStats,
|
||||
};
|
||||
|
||||
pub struct Store {
|
||||
@@ -103,6 +103,124 @@ mod tests {
|
||||
assert_eq!(exists, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn enroll_rejects_negative_expiry() {
|
||||
let ts = TestStore::new().await;
|
||||
sqlx::query!(
|
||||
"INSERT INTO enroll_tokens (token, expires_at_unix) VALUES (?1, ?2)",
|
||||
"enr-negative-expiry-test",
|
||||
-1_i64
|
||||
)
|
||||
.execute(&ts.store().pool)
|
||||
.await
|
||||
.expect("insert should succeed");
|
||||
|
||||
let err = ts
|
||||
.store()
|
||||
.enroll("enr-negative-expiry-test")
|
||||
.await
|
||||
.expect_err("negative expiry should be treated as expired");
|
||||
|
||||
assert!(err.to_string().contains("expired"));
|
||||
let exists = sqlx::query_scalar!(
|
||||
"SELECT COUNT(*) FROM enroll_tokens WHERE token = ?1",
|
||||
"enr-negative-expiry-test"
|
||||
)
|
||||
.fetch_one(&ts.store().pool)
|
||||
.await
|
||||
.expect("read should succeed");
|
||||
assert_eq!(exists, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gc_retention_prunes_old_audit_and_alert_history() {
|
||||
let ts = TestStore::new().await;
|
||||
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.expect("clock should be after epoch")
|
||||
.as_secs() as i64;
|
||||
let old_ts = now - 86_400;
|
||||
let old_evt = format!("{:020}:old-evt", old_ts);
|
||||
sqlx::query!(
|
||||
"INSERT INTO audit_events
|
||||
(event_key, event_id, ts_unix, actor_type, actor_id, agent_id, request_id,
|
||||
event_type, outcome, latency_ms, message, metadata_json)
|
||||
VALUES (?1, ?2, ?3, 'test', NULL, NULL, NULL, 'test', 'ok', NULL, 'old', '{}')",
|
||||
old_evt,
|
||||
"old-evt",
|
||||
old_ts
|
||||
)
|
||||
.execute(&ts.store().pool)
|
||||
.await
|
||||
.expect("old audit event should insert");
|
||||
|
||||
let old_atr = format!("{:020}:old-atr", old_ts);
|
||||
sqlx::query!(
|
||||
"INSERT INTO alert_transitions
|
||||
(transition_key, transition_id, ts_unix, alert_id, kind, agent_id,
|
||||
from_status, to_status, message, metadata_json)
|
||||
VALUES (?1, ?2, ?3, 'alert-old', 'test', NULL, NULL, 'active', 'old', '{}')",
|
||||
old_atr,
|
||||
"old-atr",
|
||||
old_ts
|
||||
)
|
||||
.execute(&ts.store().pool)
|
||||
.await
|
||||
.expect("old alert transition should insert");
|
||||
|
||||
ts.store()
|
||||
.append_audit_event(crate::state::AuditEventInput {
|
||||
actor_type: "test".into(),
|
||||
actor_id: None,
|
||||
agent_id: None,
|
||||
request_id: None,
|
||||
event_type: "test".into(),
|
||||
outcome: "ok".into(),
|
||||
latency_ms: None,
|
||||
message: "fresh".into(),
|
||||
metadata: serde_json::json!({}),
|
||||
})
|
||||
.await
|
||||
.expect("fresh audit event should insert");
|
||||
|
||||
let stats = ts
|
||||
.store()
|
||||
.gc_retention(Duration::from_secs(3600))
|
||||
.await
|
||||
.expect("retention gc should succeed");
|
||||
|
||||
assert_eq!(stats.audit_events_removed, 1);
|
||||
assert_eq!(stats.alert_transitions_removed, 1);
|
||||
|
||||
let old_audit_gone = sqlx::query_scalar!(
|
||||
"SELECT COUNT(*) FROM audit_events WHERE event_id = ?1",
|
||||
"old-evt"
|
||||
)
|
||||
.fetch_one(&ts.store().pool)
|
||||
.await
|
||||
.expect("read should succeed");
|
||||
assert_eq!(old_audit_gone, 0);
|
||||
|
||||
let fresh_audit_remains = sqlx::query_scalar!(
|
||||
"SELECT COUNT(*) FROM audit_events WHERE message = ?1",
|
||||
"fresh"
|
||||
)
|
||||
.fetch_one(&ts.store().pool)
|
||||
.await
|
||||
.expect("read should succeed");
|
||||
assert_eq!(fresh_audit_remains, 1);
|
||||
|
||||
let old_transition_gone = sqlx::query_scalar!(
|
||||
"SELECT COUNT(*) FROM alert_transitions WHERE transition_id = ?1",
|
||||
"old-atr"
|
||||
)
|
||||
.fetch_one(&ts.store().pool)
|
||||
.await
|
||||
.expect("read should succeed");
|
||||
assert_eq!(old_transition_gone, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn enroll_rejects_expired_token() {
|
||||
let ts = TestStore::new().await;
|
||||
@@ -209,7 +327,11 @@ mod tests {
|
||||
.expect("nickname set should succeed");
|
||||
assert!(updated);
|
||||
|
||||
let listed = ts.store().list_agents_with_nicknames().await;
|
||||
let listed = ts
|
||||
.store()
|
||||
.list_agents_with_nicknames()
|
||||
.await
|
||||
.expect("list agents with nicknames should succeed");
|
||||
assert!(listed.iter().any(|(id, name)| {
|
||||
id == &issued.agent_id && name.as_deref() == Some("kitchen-router")
|
||||
}));
|
||||
@@ -221,7 +343,11 @@ mod tests {
|
||||
.expect("nickname clear should succeed");
|
||||
assert!(cleared);
|
||||
|
||||
let listed = ts.store().list_agents_with_nicknames().await;
|
||||
let listed = ts
|
||||
.store()
|
||||
.list_agents_with_nicknames()
|
||||
.await
|
||||
.expect("list agents with nicknames should succeed");
|
||||
assert!(
|
||||
listed
|
||||
.iter()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use anyhow::{Context, Result};
|
||||
use wakey_core::{Device, DeviceId};
|
||||
use wakey_core::{Device, DeviceEndpoint, DeviceId, EndpointSource};
|
||||
|
||||
use super::Store;
|
||||
use super::helpers::core::*;
|
||||
@@ -108,6 +108,48 @@ impl Store {
|
||||
.context("failed inserting device ips")?;
|
||||
}
|
||||
|
||||
sqlx::query(
|
||||
"DELETE FROM agent_device_endpoints WHERE agent_id = ?1 AND device_key = ?2",
|
||||
)
|
||||
.bind(agent_id)
|
||||
.bind(&device_key)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.context("failed deleting device endpoints")?;
|
||||
|
||||
let endpoints = endpoints_for_storage(device);
|
||||
if !endpoints.is_empty() {
|
||||
let mut builder = sqlx::QueryBuilder::new(
|
||||
"INSERT INTO agent_device_endpoints \
|
||||
(agent_id, device_key, endpoint_key, source, mac, ip, hostname, interface, presence, first_seen_unix, last_seen_unix) ",
|
||||
);
|
||||
builder.push_values(endpoints, |mut b, endpoint| {
|
||||
let first_seen = endpoint.first_seen_unix.unwrap_or(snapshot_time);
|
||||
let last_seen = endpoint.last_seen_unix.unwrap_or(snapshot_time);
|
||||
b.push_bind(agent_id)
|
||||
.push_bind(device_key.clone())
|
||||
.push_bind(endpoint_storage_key(&endpoint))
|
||||
.push_bind(endpoint_source_to_str(endpoint.key.source))
|
||||
.push_bind(
|
||||
endpoint
|
||||
.key
|
||||
.mac
|
||||
.map(|mac| mac.to_string().to_ascii_lowercase()),
|
||||
)
|
||||
.push_bind(endpoint.key.ip.map(|ip| ip.to_string()))
|
||||
.push_bind(endpoint.hostname.clone())
|
||||
.push_bind(endpoint.interface.clone())
|
||||
.push_bind(presence_to_str(endpoint.presence))
|
||||
.push_bind(i64::try_from(first_seen).unwrap_or(i64::MAX))
|
||||
.push_bind(i64::try_from(last_seen).unwrap_or(i64::MAX));
|
||||
});
|
||||
builder
|
||||
.build()
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.context("failed inserting device endpoints")?;
|
||||
}
|
||||
|
||||
sqlx::query!(
|
||||
"DELETE FROM agent_device_hostnames WHERE agent_id = ?1 AND device_key = ?2",
|
||||
agent_id,
|
||||
@@ -223,6 +265,15 @@ impl Store {
|
||||
.await
|
||||
.context("failed listing agent device hostnames")?;
|
||||
|
||||
let endpoints = sqlx::query_as::<_, AgentDeviceEndpointRow>(
|
||||
r#"SELECT agent_id, device_key, endpoint_key, source, mac, ip,
|
||||
hostname, interface, presence, first_seen_unix, last_seen_unix
|
||||
FROM agent_device_endpoints"#,
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.context("failed listing agent device endpoints")?;
|
||||
|
||||
let facts = sqlx::query_as!(
|
||||
AgentDeviceFactRow,
|
||||
r#"SELECT agent_id as "agent_id!", device_key as "device_key!", fact_json as "fact_json!"
|
||||
@@ -232,7 +283,9 @@ impl Store {
|
||||
.await
|
||||
.context("failed listing agent device facts")?;
|
||||
|
||||
Ok(assemble_device_rows(devices, macs, ips, hostnames, facts))
|
||||
Ok(assemble_device_rows(
|
||||
devices, macs, ips, hostnames, endpoints, facts,
|
||||
))
|
||||
}
|
||||
|
||||
/// List agent device rows for a single agent.
|
||||
@@ -287,6 +340,17 @@ impl Store {
|
||||
.await
|
||||
.context("failed listing agent device hostnames for agent")?;
|
||||
|
||||
let endpoints = sqlx::query_as::<_, AgentDeviceEndpointRow>(
|
||||
r#"SELECT agent_id, device_key, endpoint_key, source, mac, ip,
|
||||
hostname, interface, presence, first_seen_unix, last_seen_unix
|
||||
FROM agent_device_endpoints
|
||||
WHERE agent_id = ?1"#,
|
||||
)
|
||||
.bind(agent_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.context("failed listing agent device endpoints for agent")?;
|
||||
|
||||
let facts = sqlx::query_as!(
|
||||
AgentDeviceFactRow,
|
||||
r#"SELECT agent_id as "agent_id!", device_key as "device_key!", fact_json as "fact_json!"
|
||||
@@ -298,7 +362,9 @@ impl Store {
|
||||
.await
|
||||
.context("failed listing agent device facts for agent")?;
|
||||
|
||||
Ok(assemble_device_rows(devices, macs, ips, hostnames, facts))
|
||||
Ok(assemble_device_rows(
|
||||
devices, macs, ips, hostnames, endpoints, facts,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -307,6 +373,7 @@ fn assemble_device_rows(
|
||||
macs: Vec<AgentDeviceMacRow>,
|
||||
ips: Vec<AgentDeviceIpRow>,
|
||||
hostnames: Vec<AgentDeviceHostnameRow>,
|
||||
endpoints: Vec<AgentDeviceEndpointRow>,
|
||||
facts: Vec<AgentDeviceFactRow>,
|
||||
) -> Vec<AgentDeviceWithChildren> {
|
||||
use std::collections::BTreeMap;
|
||||
@@ -332,6 +399,15 @@ fn assemble_device_rows(
|
||||
.or_default()
|
||||
.push(row.hostname.clone());
|
||||
}
|
||||
let mut endpoint_map: BTreeMap<(&str, &str), Vec<DeviceEndpoint>> = BTreeMap::new();
|
||||
for row in &endpoints {
|
||||
if let Some(endpoint) = row.to_endpoint() {
|
||||
endpoint_map
|
||||
.entry((row.agent_id.as_str(), row.device_key.as_str()))
|
||||
.or_default()
|
||||
.push(endpoint);
|
||||
}
|
||||
}
|
||||
let mut fact_map: BTreeMap<(&str, &str), Vec<String>> = BTreeMap::new();
|
||||
for row in &facts {
|
||||
fact_map
|
||||
@@ -369,11 +445,13 @@ fn assemble_device_rows(
|
||||
})
|
||||
.collect();
|
||||
let hostnames = hostname_map.get(&key).cloned().unwrap_or_default();
|
||||
let endpoints = endpoint_map.get(&key).cloned().unwrap_or_default();
|
||||
let facts = fact_map.get(&key).cloned().unwrap_or_default();
|
||||
AgentDeviceWithChildren {
|
||||
macs,
|
||||
ips,
|
||||
hostnames,
|
||||
endpoints,
|
||||
facts,
|
||||
device,
|
||||
}
|
||||
@@ -397,6 +475,65 @@ fn presence_to_str(presence: wakey_core::Presence) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
fn endpoint_source_to_str(source: EndpointSource) -> &'static str {
|
||||
match source {
|
||||
EndpointSource::Neighbor => "neighbor",
|
||||
EndpointSource::DhcpLease => "dhcp_lease",
|
||||
EndpointSource::HookNeighbor => "hook_neighbor",
|
||||
EndpointSource::HookDhcp => "hook_dhcp",
|
||||
}
|
||||
}
|
||||
|
||||
fn endpoint_storage_key(endpoint: &DeviceEndpoint) -> String {
|
||||
format!(
|
||||
"{}|{}|{}",
|
||||
endpoint_source_to_str(endpoint.key.source),
|
||||
endpoint
|
||||
.key
|
||||
.mac
|
||||
.map(|mac| mac.to_string().to_ascii_lowercase())
|
||||
.unwrap_or_default(),
|
||||
endpoint.key.ip.map(|ip| ip.to_string()).unwrap_or_default()
|
||||
)
|
||||
}
|
||||
|
||||
fn endpoints_for_storage(device: &Device) -> Vec<DeviceEndpoint> {
|
||||
if !device.endpoints.is_empty() {
|
||||
return device.endpoints.clone();
|
||||
}
|
||||
|
||||
let mut endpoints = Vec::new();
|
||||
for neighbor in &device.neighbors {
|
||||
endpoints.push(DeviceEndpoint {
|
||||
key: wakey_core::EndpointKey {
|
||||
source: EndpointSource::Neighbor,
|
||||
mac: neighbor.mac,
|
||||
ip: Some(neighbor.ip),
|
||||
},
|
||||
hostname: None,
|
||||
interface: neighbor.dev.clone(),
|
||||
presence: wakey_core::Presence::from(neighbor.state),
|
||||
first_seen_unix: None,
|
||||
last_seen_unix: None,
|
||||
});
|
||||
}
|
||||
for lease in &device.leases {
|
||||
endpoints.push(DeviceEndpoint {
|
||||
key: wakey_core::EndpointKey {
|
||||
source: EndpointSource::DhcpLease,
|
||||
mac: Some(lease.mac),
|
||||
ip: Some(lease.ip),
|
||||
},
|
||||
hostname: lease.name.clone(),
|
||||
interface: None,
|
||||
presence: wakey_core::Presence::Unknown,
|
||||
first_seen_unix: None,
|
||||
last_seen_unix: None,
|
||||
});
|
||||
}
|
||||
endpoints
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::helpers::test_helpers::TestStore;
|
||||
@@ -412,6 +549,7 @@ mod tests {
|
||||
ips: vec![ip.parse().expect("ip")],
|
||||
macs: vec![mac.parse().expect("mac")],
|
||||
interfaces: vec!["br-lan".to_string()],
|
||||
endpoints: vec![],
|
||||
neighbors: vec![NeighborEntry {
|
||||
ip: ip.parse().expect("ip"),
|
||||
dev: Some("br-lan".to_string()),
|
||||
@@ -457,6 +595,15 @@ mod tests {
|
||||
vec!["192.168.1.10".parse::<std::net::IpAddr>().unwrap()]
|
||||
);
|
||||
assert_eq!(rows[0].hostnames, vec!["first-pc"]);
|
||||
assert_eq!(rows[0].endpoints.len(), 1);
|
||||
assert_eq!(
|
||||
rows[0].endpoints[0].key.source,
|
||||
wakey_core::EndpointSource::Neighbor
|
||||
);
|
||||
assert_eq!(
|
||||
rows[0].endpoints[0].key.ip,
|
||||
Some("192.168.1.10".parse().expect("ip"))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -582,6 +729,7 @@ mod tests {
|
||||
ips: vec![],
|
||||
macs: vec![],
|
||||
interfaces: vec![],
|
||||
endpoints: vec![],
|
||||
neighbors: vec![],
|
||||
leases: vec![],
|
||||
observations: vec![],
|
||||
@@ -612,6 +760,7 @@ mod tests {
|
||||
ips: vec!["192.168.1.10".parse().expect("ip")],
|
||||
macs: vec!["aa:bb:cc:dd:ee:01".parse().expect("mac")],
|
||||
interfaces: vec![],
|
||||
endpoints: vec![],
|
||||
neighbors: vec![],
|
||||
leases: vec![],
|
||||
observations: vec![DeviceObservationFact {
|
||||
|
||||
Loaded 100 of 115 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user