Compare commits
34
Commits
v0.3.6
..
03c1f14dba
No files matched your search
+131
-48
@@ -8,20 +8,64 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: read
|
||||||
|
|
||||||
|
env:
|
||||||
|
SQLX_OFFLINE: "true"
|
||||||
|
RELEASE_VERSION: ${{ github.ref_name }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
router:
|
||||||
runs-on: ubuntu-latest
|
name: Router (ARMv7 musl)
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- uses: actions/checkout@v4
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
with:
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
|
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
|
- name: Cache cargo
|
||||||
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
targets: x86_64-unknown-linux-gnu,aarch64-unknown-linux-gnu
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
|
target
|
||||||
|
key: release-router-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
restore-keys: release-router-${{ runner.os }}-${{ runner.arch }}-
|
||||||
|
|
||||||
|
- name: Cache cross executable
|
||||||
|
id: cross
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/.cargo/bin/cross
|
||||||
|
key: cross-${{ runner.os }}-${{ runner.arch }}-0.2.5
|
||||||
|
|
||||||
|
- name: Install cross
|
||||||
|
if: steps.cross.outputs.cache-hit != 'true'
|
||||||
|
run: cargo install cross --version 0.2.5 --locked
|
||||||
|
|
||||||
|
- name: Build router binaries
|
||||||
|
run: cross build --release --locked --target armv7-unknown-linux-musleabihf -p wakey -p wakey-agent
|
||||||
|
|
||||||
|
- name: Package rootfs tarball
|
||||||
|
shell: pwsh
|
||||||
|
run: ./scripts/package_rootfs.ps1 -Version $env:RELEASE_VERSION -Target armv7-unknown-linux-musleabihf
|
||||||
|
|
||||||
|
- name: Upload rootfs
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: wakey-rootfs
|
||||||
|
path: dist/wakey-rootfs-*armv7-unknown-linux-musleabihf.tgz
|
||||||
|
if-no-files-found: error
|
||||||
|
compression-level: 0
|
||||||
|
|
||||||
|
ui:
|
||||||
|
name: UI
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
with:
|
with:
|
||||||
@@ -33,8 +77,36 @@ jobs:
|
|||||||
cache: pnpm
|
cache: pnpm
|
||||||
cache-dependency-path: ui/pnpm-lock.yaml
|
cache-dependency-path: ui/pnpm-lock.yaml
|
||||||
|
|
||||||
- name: Install cross
|
- name: Build UI dist
|
||||||
run: cargo install cross --locked
|
working-directory: ui
|
||||||
|
run: |
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
pnpm build
|
||||||
|
|
||||||
|
- name: Upload UI
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: wakey-ui
|
||||||
|
path: ui/dist/
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
cc:
|
||||||
|
name: CC (${{ matrix.target }})
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- target: x86_64-unknown-linux-gnu
|
||||||
|
runner: ubuntu-24.04
|
||||||
|
- target: aarch64-unknown-linux-gnu
|
||||||
|
runner: ubuntu-24.04-arm
|
||||||
|
runs-on: ${{ matrix.runner }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
- name: Cache cargo
|
- name: Cache cargo
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
@@ -43,57 +115,67 @@ jobs:
|
|||||||
~/.cargo/registry
|
~/.cargo/registry
|
||||||
~/.cargo/git
|
~/.cargo/git
|
||||||
target
|
target
|
||||||
key: linux-cargo-${{ hashFiles('**/Cargo.lock') }}
|
key: release-cc-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
restore-keys: release-cc-${{ matrix.target }}-
|
||||||
|
|
||||||
- name: Build (armv7-musl with cross)
|
# Native GNU builds use the runner's glibc. Gitea remains the separate
|
||||||
run: |
|
# older-glibc release path for deployment hosts that need it.
|
||||||
cross build --release --target armv7-unknown-linux-musleabihf -p wakey -p wakey-agent
|
- name: Build control-plane natively
|
||||||
|
run: cargo build --release --locked --target ${{ matrix.target }} -p wakey-control-plane
|
||||||
|
|
||||||
- name: Build UI dist
|
- name: Upload control-plane binary
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
cd ui
|
|
||||||
pnpm install --frozen-lockfile
|
|
||||||
pnpm build
|
|
||||||
|
|
||||||
- name: Build control-plane (linux-gnu)
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# x86_64 builds natively; aarch64 needs cross
|
|
||||||
echo "Building control-plane x86_64-unknown-linux-gnu (native)"
|
|
||||||
cargo build --release --target x86_64-unknown-linux-gnu -p wakey-control-plane
|
|
||||||
|
|
||||||
echo "Building control-plane aarch64-unknown-linux-gnu (cross)"
|
|
||||||
cross build --release --target aarch64-unknown-linux-gnu -p wakey-control-plane
|
|
||||||
|
|
||||||
- name: Package rootfs tarball
|
|
||||||
shell: pwsh
|
|
||||||
run: |
|
|
||||||
./scripts/package_rootfs.ps1 -Version "${{ github.ref_name }}" -Target armv7-unknown-linux-musleabihf
|
|
||||||
|
|
||||||
- name: Package control-plane bundles
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
chmod +x scripts/update_wakey_cc.sh scripts/package_wakey_cc_bundle.sh
|
|
||||||
for target in x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu; do
|
|
||||||
./scripts/package_wakey_cc_bundle.sh --version "${{ github.ref_name }}" --target "$target"
|
|
||||||
done
|
|
||||||
|
|
||||||
- name: Upload artifact
|
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: wakey-cc-bin-${{ matrix.target }}
|
||||||
|
path: target/${{ matrix.target }}/release/wakey-control-plane
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
release:
|
||||||
|
name: Package and publish
|
||||||
|
needs: [router, ui, cc]
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Download rootfs
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: wakey-rootfs
|
name: wakey-rootfs
|
||||||
path: dist/wakey-rootfs-*armv7-unknown-linux-musleabihf.tgz
|
path: dist
|
||||||
|
|
||||||
|
- name: Download UI
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: wakey-ui
|
||||||
|
path: ui/dist
|
||||||
|
|
||||||
|
- name: Download x86-64 CC
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: wakey-cc-bin-x86_64-unknown-linux-gnu
|
||||||
|
path: target/x86_64-unknown-linux-gnu/release
|
||||||
|
|
||||||
|
- name: Download ARM64 CC
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: wakey-cc-bin-aarch64-unknown-linux-gnu
|
||||||
|
path: target/aarch64-unknown-linux-gnu/release
|
||||||
|
|
||||||
|
- name: Package control-plane bundles
|
||||||
|
run: |
|
||||||
|
for target in x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu; do
|
||||||
|
sh ./scripts/package_wakey_cc_bundle.sh --version "$RELEASE_VERSION" --target "$target"
|
||||||
|
done
|
||||||
|
|
||||||
- name: Upload control-plane bundles
|
- name: Upload control-plane bundles
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: wakey-cc-bundles
|
name: wakey-cc-bundles
|
||||||
path: dist/wakey-cc-*unknown-linux-gnu.tgz
|
path: dist/wakey-cc-*unknown-linux-gnu.tgz
|
||||||
|
if-no-files-found: error
|
||||||
|
compression-level: 0
|
||||||
|
|
||||||
- name: Publish Release to GitHub
|
- name: Publish Release to GitHub
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
@@ -101,6 +183,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
tag_name: ${{ github.ref_name }}
|
tag_name: ${{ github.ref_name }}
|
||||||
|
fail_on_unmatched_files: true
|
||||||
files: |
|
files: |
|
||||||
dist/wakey-rootfs-*armv7-unknown-linux-musleabihf.tgz
|
dist/wakey-rootfs-*armv7-unknown-linux-musleabihf.tgz
|
||||||
dist/wakey-cc-*unknown-linux-gnu.tgz
|
dist/wakey-cc-*unknown-linux-gnu.tgz
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "UPDATE known_devices SET display_name = ?1, updated_at_unix = ?2 WHERE device_id = ?3",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 3
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "897b794a7f94af42b545d74a5b859850fc9935f40385061b029fab5892a2aded"
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "DELETE FROM agent_devices WHERE last_seen_unix < ?1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "cc4b885c31f965013ccaa6f8ae33f6c8d68898654fc0873682c8212339088306"
|
||||||
|
}
|
||||||
Generated
+884
-959
File diff suppressed because it is too large.
Load diff
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "wakey"
|
name = "wakey"
|
||||||
version = "0.3.6"
|
version = "0.3.7"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
publish = ["gitea"]
|
publish = ["gitea"]
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,11 @@
|
|||||||
#
|
#
|
||||||
# Security model:
|
# Security model:
|
||||||
# - Public endpoints for agents: health, enrollment, control WS, and scoped terminal relay 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)
|
# - Private admin surface: root, /ui/* and /api/v1/control/* (verified Access JWT)
|
||||||
|
# Configure CC's [access] section and loopback bind before enabling this template.
|
||||||
|
# Never expose /internal/auth/verify through this virtual host.
|
||||||
#
|
#
|
||||||
# Replace cp.example.com with your public control-plane domain.
|
# Replace wakey.ldlda.com and the loopback upstream port for your deployment.
|
||||||
|
|
||||||
wakey.ldlda.com {
|
wakey.ldlda.com {
|
||||||
encode zstd gzip
|
encode zstd gzip
|
||||||
@@ -15,15 +17,14 @@ wakey.ldlda.com {
|
|||||||
reverse_proxy 127.0.0.1:6767
|
reverse_proxy 127.0.0.1:6767
|
||||||
}
|
}
|
||||||
|
|
||||||
# Admin surface requires Cloudflare Access headers.
|
# Applies to HTTP requests and operator WebSocket upgrades, not WS frames.
|
||||||
@admin path /ui* /api/v1/control/*
|
@admin path / /ui* /api/v1/control/*
|
||||||
@cf_access header_regexp CFJWT Cf-Access-Jwt-Assertion .+
|
|
||||||
|
|
||||||
handle @admin {
|
handle @admin {
|
||||||
handle @cf_access {
|
forward_auth 127.0.0.1:6767 {
|
||||||
reverse_proxy 127.0.0.1:6767
|
uri /internal/auth/verify
|
||||||
}
|
}
|
||||||
respond "forbidden" 403
|
reverse_proxy 127.0.0.1:6767
|
||||||
}
|
}
|
||||||
|
|
||||||
# Deny unknown paths by default.
|
# Deny unknown paths by default.
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ ExecStart=/opt/wakey/bin/wakey-control-plane serve --config-file /etc/wakey-cont
|
|||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=2
|
RestartSec=2
|
||||||
Environment=RUST_LOG=info
|
Environment=RUST_LOG=info
|
||||||
|
EnvironmentFile=-/etc/wakey-control-plane/environment
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
@@ -75,8 +75,23 @@ Current DHCP leases are summary-eligible and may support wake if they include a
|
|||||||
|
|
||||||
`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.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.
|
||||||
|
|
||||||
|
Fleet display labels prefer the saved device name, then a hostname, then a MAC,
|
||||||
|
then an IP. Generated address labels use `Device: <address>` without presence
|
||||||
|
prefixes; the badge carries presence. An inactive IP is only a naming fallback
|
||||||
|
when no name or MAC exists, never evidence of a current address or a wake route.
|
||||||
|
Offline neighbor evidence does not establish that a DHCP address has expired or
|
||||||
|
been reassigned. If a concrete source observes that endpoint again, its eligible
|
||||||
|
IP returns to the summary; manually remembered identifiers are not removed by this.
|
||||||
|
|
||||||
`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.
|
`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 control plane derives stored snapshot presence and fleet presence from the
|
||||||
|
maximum endpoint signal whenever endpoints exist. A cached summary must not
|
||||||
|
override endpoint evidence; endpoint-less legacy rows fall back to their stored
|
||||||
|
summary. Presence describes the last observation, not guaranteed current
|
||||||
|
reachability. Agent disconnection does not rewrite endpoint presence or expire
|
||||||
|
the retained snapshot.
|
||||||
|
|
||||||
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.
|
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 and Wake Route Ordering
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ The agent keeps its terminal manager outside the control-WebSocket reconnect loo
|
|||||||
|
|
||||||
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.
|
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.
|
The agent configuration sets `terminal.session_ttl` (for example, `"12h"`) for newly created PTYs. The default is 12 hours; `"0s"` disables automatic expiry. Legacy `session_ttl_seconds` is accepted with a deprecation warning. The wire protocol still uses integer seconds, so fractional-second TTLs are rejected. 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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# Configuration Durations
|
||||||
|
|
||||||
|
Agent and control-plane TOML duration settings use strings with explicit units.
|
||||||
|
Runtime code receives `std::time::Duration`; API and WebSocket numeric fields
|
||||||
|
such as `timeout_ms`, `session_ttl_seconds`, and `latency_ms` are unchanged.
|
||||||
|
|
||||||
|
## Agent
|
||||||
|
|
||||||
|
```toml
|
||||||
|
reconnect_base = "1s"
|
||||||
|
reconnect_max = "30s"
|
||||||
|
observation_sync_interval = "60s"
|
||||||
|
observation_retention = "7d"
|
||||||
|
|
||||||
|
[terminal]
|
||||||
|
session_ttl = "12h"
|
||||||
|
```
|
||||||
|
|
||||||
|
`observation_retention` prunes the agent's local hotplug memory during inventory
|
||||||
|
collection. It does not delete the control plane's last snapshot for an agent.
|
||||||
|
`"0d"` disables this local pruning. `terminal.session_ttl = "0s"` disables PTY
|
||||||
|
expiry. Reconnect and sync timers must be positive and fit the platform timer.
|
||||||
|
Terminal TTL and hook retention must resolve to whole seconds because their
|
||||||
|
protocol/storage timestamps use seconds. Reconnect and sync intervals can use
|
||||||
|
subsecond values.
|
||||||
|
Reconnect scheduling retains its existing 100ms minimum backoff.
|
||||||
|
|
||||||
|
## Control Plane
|
||||||
|
|
||||||
|
```toml
|
||||||
|
command_timeout = "30s"
|
||||||
|
enroll_token_ttl = "1d"
|
||||||
|
history_retention = "30d"
|
||||||
|
device_retention = "60d"
|
||||||
|
```
|
||||||
|
|
||||||
|
`command_timeout`, `enroll_token_ttl`, and `history_retention` must be positive.
|
||||||
|
Enrollment TTL and history retention must resolve to whole seconds. Command
|
||||||
|
timeout can use subsecond values.
|
||||||
|
`history_retention` deletes old audit events and alert transitions, not device
|
||||||
|
snapshots or saved identities.
|
||||||
|
|
||||||
|
`device_retention` expires agent-collected device rows and their child endpoints,
|
||||||
|
addresses, names and facts. The default is `"60d"`; `"0d"` disables expiry.
|
||||||
|
It must resolve to whole seconds. Age is measured from the last CC-received
|
||||||
|
snapshot that included the device, not endpoint timestamps or online presence.
|
||||||
|
An offline device included in new snapshots therefore stays retained. Saved
|
||||||
|
devices and their manually attached identifiers are never deleted by this cleanup.
|
||||||
|
Complete snapshots still remove devices omitted by that agent immediately;
|
||||||
|
retention handles old rows left behind when an agent stops syncing.
|
||||||
|
Expired rows are removed by the Unix daemon's existing maintenance loop every
|
||||||
|
five minutes, including an initial tick at startup. Restart CC to apply changes
|
||||||
|
to this setting; the state-only reload does not reload configuration.
|
||||||
|
|
||||||
|
## Legacy Files
|
||||||
|
|
||||||
|
| Old Key | New Key | Numeric Legacy Unit |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Agent `reconnect_base_ms` | `reconnect_base` | milliseconds |
|
||||||
|
| Agent `reconnect_max_ms` | `reconnect_max` | milliseconds |
|
||||||
|
| Agent `observation_sync_interval_seconds` | `observation_sync_interval` | seconds |
|
||||||
|
| Agent `observation_retention_days` | `observation_retention` | days |
|
||||||
|
| Agent `terminal.session_ttl_seconds` | `terminal.session_ttl` | seconds |
|
||||||
|
| CC `command_timeout_ms` | `command_timeout` | milliseconds |
|
||||||
|
| CC `enroll_token_ttl_seconds` | `enroll_token_ttl` | seconds |
|
||||||
|
| CC `observation_retention_seconds` | `history_retention` | seconds |
|
||||||
|
|
||||||
|
Old keys accept non-negative integers in their original units, or explicit
|
||||||
|
duration strings, and produce a warning on stderr when loaded. New keys require
|
||||||
|
strings; bare numbers are rejected. Supplying both keys for the same setting is
|
||||||
|
an error, even when their values agree. Negative, malformed, and overflowing
|
||||||
|
values are rejected; zero active timers are rejected rather than silently
|
||||||
|
clamped. No secrets are included in deprecation warnings.
|
||||||
|
|
||||||
|
`init-config --from-config ...` reads legacy files and writes only canonical
|
||||||
|
keys and formatted duration strings. Existing numeric CLI flags are unchanged
|
||||||
|
in this slice, and their overrides also serialize in the new format.
|
||||||
|
|
||||||
|
For example, legacy `reconnect_base_ms = 1500` is read as 1500 milliseconds
|
||||||
|
and written back as `reconnect_base = "1s 500ms"`. New-key
|
||||||
|
`reconnect_base = 1500` is rejected: no implicit numeric unit is assigned.
|
||||||
|
Serialization always writes duration strings, regardless of the accepted input
|
||||||
|
format, using `humantime-serde`.
|
||||||
|
|
||||||
|
The parser is [humantime](https://docs.rs/humantime/latest/humantime/fn.parse_duration.html).
|
||||||
|
Examples include `"14d"`, `"168h"`, `"250ms"`, and `"1h 30m"`. These are elapsed
|
||||||
|
durations, not calendar scheduling rules; use days/hours for retention rather
|
||||||
|
than assuming calendar-month semantics.
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Fleet View Cache
|
||||||
|
|
||||||
|
CC retains one complete, grouped fleet view in memory. List requests still apply
|
||||||
|
their own search, visibility, presence, agent filters and result limit. Neither
|
||||||
|
the database nor the response format changes.
|
||||||
|
|
||||||
|
## Inputs And Invalidation
|
||||||
|
|
||||||
|
The cache key combines the store's fleet revision and connected-agent IDs.
|
||||||
|
Successful snapshot writes, saved-device create/rename/forget/merge, identifier
|
||||||
|
edits, agent enrollment/revocation/nickname edits, and device-retention cleanup
|
||||||
|
advance the revision. Token-only, audit and alert writes do not invalidate it.
|
||||||
|
Connection changes are detected from the live session map on each read.
|
||||||
|
|
||||||
|
The next reader rebuilds a changed view. Concurrent readers wait for that build
|
||||||
|
and reuse it. The helper checks the key again after building: if inputs changed
|
||||||
|
while database reads/grouping were in flight, it discards the result and retries.
|
||||||
|
Build errors are returned, not cached; cancelling a reader releases the lock.
|
||||||
|
The previous view is not served as a fallback after a rebuild error.
|
||||||
|
After three invalidated builds, the reader stops and releases the lock. Fleet
|
||||||
|
list/wake requests return HTTP 503 with code `fleet_busy`; clients can retry
|
||||||
|
later. No stale view or command is used as a fallback. This bounds the number
|
||||||
|
of rebuilds per reader, not database execution time or total concurrent requests.
|
||||||
|
|
||||||
|
Wake searches the complete cached view by device key, without list visibility
|
||||||
|
filters or the list API's 1,000-row limit. It borrows the view and clones only the
|
||||||
|
selected route. Command dispatch still checks the current session;
|
||||||
|
a cached route cannot grant access to a disconnected agent.
|
||||||
|
|
||||||
|
`GET /api/v1/control/fleet/devices/{device_key}` returns one exact row from the
|
||||||
|
complete view, or `404 fleet_device_not_found`. Details do not inherit list
|
||||||
|
filters or limits. After Remember/Attach/Merge, the UI follows the saved ID
|
||||||
|
returned by the mutation, not matching MAC/IP heuristics. It also refreshes the
|
||||||
|
full saved identity independently of list results; Forget still closes details.
|
||||||
|
|
||||||
|
## External Writes
|
||||||
|
|
||||||
|
The revision belongs to the running CC process. If another process directly
|
||||||
|
modifies its SQLite database, run the existing CC reload command (or send
|
||||||
|
SIGHUP) to invalidate the view, or restart CC. The cache does not watch files
|
||||||
|
or poll SQLite for external changes. Normal HTTP/WS store writes invalidate
|
||||||
|
automatically, and there is no time-based cache expiry.
|
||||||
|
|
||||||
|
## Shared Helper And Tracing
|
||||||
|
|
||||||
|
`runtime::view_cache::ViewCache` owns synchronization and stale-build rejection.
|
||||||
|
Fleet code supplies the key and builder. It is intentionally CC-local; alerts
|
||||||
|
keep their existing timer-driven evaluator rather than being forced into this
|
||||||
|
lazy cache.
|
||||||
|
|
||||||
|
`fleet_load` spans remain on requests. A cache miss adds `fleet_rebuild`, with
|
||||||
|
database loads and `fleet_group_devices` nested below it. On a cache hit, list
|
||||||
|
requests clone/filter the view; wake requests look up their device in the borrowed
|
||||||
|
view. Neither path repeats the database reads or grouping.
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
# Multi-CC Identity And Configuration
|
||||||
|
|
||||||
|
Status: proposal, 2026-10-12. This is not an implemented config format.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
One agent process can serve production and test CCs simultaneously. Collect
|
||||||
|
locally once, forward to independent connections, and show human names alongside
|
||||||
|
IDs. A failed CC must not block another or kill local terminal processes.
|
||||||
|
|
||||||
|
## Identity And Names
|
||||||
|
|
||||||
|
- **CC ID:** stable identity of a logical control plane, independent of URL,
|
||||||
|
hostname, display name and WebSocket connection. Renaming or moving it must
|
||||||
|
not require agents to enroll again.
|
||||||
|
- **CC name:** configured on CC and advertised to authenticated agents. Start
|
||||||
|
with config-based rename plus reload/restart; no rename API required initially.
|
||||||
|
- **Agent instance ID/name:** local daemon identity and reported human name,
|
||||||
|
shared across its connections. Not the enrollment identity of every CC.
|
||||||
|
- **Enrollment:** each CC issues its own `agent_id` and token. Never send one
|
||||||
|
CC's credentials to another. Existing per-CC agent nicknames override the
|
||||||
|
reported name in that CC's UI; names never become routing/authentication keys.
|
||||||
|
- **Connection alias:** local config key such as `cloud` or `test`, useful for
|
||||||
|
CLI selection and pre-auth logs. It is not the remote CC ID or authoritative
|
||||||
|
remote name.
|
||||||
|
|
||||||
|
CC ID is generated once and retained in SQLite metadata when not configured;
|
||||||
|
an explicit config ID must agree with an existing stored identity, not silently
|
||||||
|
replace it. New init-config output can include a generated ID. Agent instance
|
||||||
|
ID belongs in its local config. Copying either deployment for an independent
|
||||||
|
test instance requires a distinct identity; do not mint a new ID each startup.
|
||||||
|
|
||||||
|
## Proposed Config Shape
|
||||||
|
|
||||||
|
CC keeps existing operational settings and adds:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[identity]
|
||||||
|
id = "cc-<uuid>"
|
||||||
|
name = "Cloud"
|
||||||
|
```
|
||||||
|
|
||||||
|
Agent separates local identity/settings from per-CC enrollment:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[identity]
|
||||||
|
id = "node-<uuid>"
|
||||||
|
name = "Router"
|
||||||
|
|
||||||
|
[local]
|
||||||
|
pid_file = "/var/run/wakey-agent.pid"
|
||||||
|
dhcp_leases_path = "/tmp/dhcp.leases"
|
||||||
|
mac_name_cache_path = "/tmp/wakey_mac_names.json"
|
||||||
|
observation_store_path = "/tmp/wakey_observations.json"
|
||||||
|
|
||||||
|
[sync]
|
||||||
|
interval = "60s"
|
||||||
|
hook_retention = "7d"
|
||||||
|
|
||||||
|
[reconnect]
|
||||||
|
base = "1s"
|
||||||
|
max = "30s"
|
||||||
|
|
||||||
|
[control_planes.cloud]
|
||||||
|
server_url = "https://wakey.example.com"
|
||||||
|
agent_id = "agent-<cloud-issued-id>"
|
||||||
|
agent_token = "<cloud-token>"
|
||||||
|
# expected_cc_id = "cc-<uuid>"
|
||||||
|
|
||||||
|
[control_planes.test]
|
||||||
|
server_url = "http://127.0.0.1:8080"
|
||||||
|
agent_id = "agent-<test-issued-id>"
|
||||||
|
agent_token = "<test-token>"
|
||||||
|
```
|
||||||
|
|
||||||
|
Existing `[terminal]` and `[telemetry]` remain daemon-wide. Reconnect defaults
|
||||||
|
apply independently to each connection; avoid per-CC copies of every setting.
|
||||||
|
The field names above are proposed, not a reason to rename unrelated CC settings.
|
||||||
|
|
||||||
|
Keep accepting the legacy single-CC root fields as one `default` connection,
|
||||||
|
with a deprecation warning. Reject mixed legacy/new connection declarations
|
||||||
|
rather than guessing. Init-config emits the new shape; `--from-config` preserves
|
||||||
|
all connections and their credentials. Enrollment targets a connection alias:
|
||||||
|
one connection can be implicit, multiple connections require explicit selection.
|
||||||
|
|
||||||
|
## Connection And Forwarding Rules
|
||||||
|
|
||||||
|
- Add negotiated, post-auth CC identity/name metadata. Current protocol has
|
||||||
|
Hello/Auth but no explicit successful-auth response; sending credentials is
|
||||||
|
not proof of authentication. Old clients must not receive unsupported enum
|
||||||
|
variants unconditionally. Keep legacy single-CC compatibility explicit.
|
||||||
|
- Forward the agent's configured name during hello; CC sends its own name/ID
|
||||||
|
after auth. Show `name (id)` or a short ID in operator/debug views. Retain the
|
||||||
|
local alias and last reported name for disconnected status, marking it offline.
|
||||||
|
- Each CC gets its own worker, retry state, bounded outbound queue and request
|
||||||
|
replies. Reject duplicate authenticated connections to the same CC ID; changed
|
||||||
|
expected IDs require an explicit operator decision, not silent rebinding.
|
||||||
|
- CC ID is identification, not a substitute for TLS/server authentication.
|
||||||
|
- Share local inventory/hook storage and periodic collection. Fan out complete
|
||||||
|
snapshots under each CC's enrollment ID; a slow connection cannot hold the
|
||||||
|
collector hostage. This does not turn snapshots into a durable event journal.
|
||||||
|
- Requests/replies and their trace contexts stay scoped to their connection.
|
||||||
|
Never broadcast command results or terminal bytes to every CC.
|
||||||
|
- Saved devices, nicknames, permissions and history remain independent on each
|
||||||
|
CC. No CC-to-CC synchronization or automatic identity promotion.
|
||||||
|
|
||||||
|
## Terminal And Telemetry Scope
|
||||||
|
|
||||||
|
The agent owns PTYs and one daemon-wide session registry. Session limits apply
|
||||||
|
globally, not once per connection. Initially, sessions belong to their creating
|
||||||
|
CC: another CC cannot attach, resume or close them. Attachment ownership is
|
||||||
|
enforced on the agent, not just inferred from one CC's registry. Disconnecting
|
||||||
|
one CC preserves its PTYs until existing TTL/explicit-close/agent-exit policy acts.
|
||||||
|
Reconnecting the same CC ID can reconcile its own sessions; removing a configured
|
||||||
|
CC must have an explicit terminal cleanup policy before implementation ships.
|
||||||
|
|
||||||
|
Use local agent instance ID and CC ID for `service.instance.id`, addressing the
|
||||||
|
current unspecified CC instance. Add configured display names as resource
|
||||||
|
metadata, not a replacement for stable IDs. Grafana presentation/label mapping
|
||||||
|
needs validation; do not promise names will automatically become `instance`.
|
||||||
|
CPU/RSS/host metrics describe one local process and must not be duplicated per CC.
|
||||||
|
Document whether snapshot counters count collections or per-CC sends before fan-out.
|
||||||
|
|
||||||
|
## Implementation Order
|
||||||
|
|
||||||
|
1. Stable CC/local-agent identities, names, telemetry metadata and config adapters.
|
||||||
|
2. Negotiated authenticated metadata exchange; keep the one-CC path working.
|
||||||
|
3. Shared local managers plus isolated connection workers and snapshot fan-out.
|
||||||
|
4. Global terminal ownership checks and multi-CC operator/debug status.
|
||||||
|
|
||||||
|
Acceptance tests must cover config/enrollment isolation, names changing without
|
||||||
|
IDs changing, one CC failing while another proceeds, shared collection without
|
||||||
|
cross-CC replies, duplicate CC identity rejection, and cross-CC terminal access
|
||||||
|
rejection. Use two test CCs and one agent before deploying this on the router.
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
# OpenWrt UCI for Wakey
|
||||||
|
|
||||||
|
Wakey's OpenWrt init script reads `/etc/config/wakey` for the log filter and
|
||||||
|
OTLP authorization header, then passes them to the agent through procd.
|
||||||
|
The agent's application settings remain in `/etc/wakey-agent/config.toml`.
|
||||||
|
The previous `/etc/wakey-agent/environment` file is no longer read.
|
||||||
|
|
||||||
|
## The three names
|
||||||
|
|
||||||
|
```sh
|
||||||
|
uci set wakey.main.otlp_authorization='Basic BASE64_OF_USERNAME_COLON_PASSWORD'
|
||||||
|
```
|
||||||
|
|
||||||
|
| Part | Meaning |
|
||||||
|
| --- | --- |
|
||||||
|
| `wakey` | Package: `/etc/config/wakey` |
|
||||||
|
| `main` | Named section inside that package |
|
||||||
|
| `otlp_authorization` | Option inside the section |
|
||||||
|
|
||||||
|
Quotes keep the complete `Basic ...` value together despite its space.
|
||||||
|
The credential is the Base64 encoding of `username:password`.
|
||||||
|
It is not the htpasswd hash, and Base64 is not encryption.
|
||||||
|
|
||||||
|
## Create and save
|
||||||
|
|
||||||
|
Run as root on the router. `touch` does not truncate an existing file:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
touch /etc/config/wakey
|
||||||
|
chown root:root /etc/config/wakey
|
||||||
|
chmod 600 /etc/config/wakey
|
||||||
|
|
||||||
|
uci set wakey.main=service
|
||||||
|
uci set wakey.main.log_filter='info'
|
||||||
|
uci set wakey.main.otlp_authorization='Basic BASE64_OF_USERNAME_COLON_PASSWORD'
|
||||||
|
uci commit wakey
|
||||||
|
chmod 600 /etc/config/wakey
|
||||||
|
```
|
||||||
|
|
||||||
|
`wakey.main=service` creates a named section of type `service`. The type is our
|
||||||
|
chosen label, not a built-in Wakey feature. Existing options are not cleared.
|
||||||
|
`set` stages changes; `commit wakey` writes that package to disk. Committing does
|
||||||
|
not restart a service.
|
||||||
|
|
||||||
|
The resulting file looks like:
|
||||||
|
|
||||||
|
```text
|
||||||
|
config service 'main'
|
||||||
|
option log_filter 'info'
|
||||||
|
option otlp_authorization 'Basic BASE64_OF_USERNAME_COLON_PASSWORD'
|
||||||
|
```
|
||||||
|
|
||||||
|
The example above uses a placeholder. Avoid typing real credentials into a
|
||||||
|
command that your shell will save in history. In an interactive SSH terminal, you can
|
||||||
|
read the Basic header without echoing it and send it to UCI over stdin:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
(
|
||||||
|
set -e
|
||||||
|
terminal_state=$(stty -g </dev/tty)
|
||||||
|
# Restore the original terminal settings even on failure or interruption.
|
||||||
|
trap 'stty "$terminal_state" </dev/tty' 0
|
||||||
|
trap 'exit 1' HUP INT TERM
|
||||||
|
printf 'Complete Basic authorization header: ' >/dev/tty
|
||||||
|
stty -echo </dev/tty
|
||||||
|
IFS= read -r authorization </dev/tty
|
||||||
|
stty "$terminal_state" </dev/tty
|
||||||
|
printf '\n' >/dev/tty
|
||||||
|
printf "set wakey.main.otlp_authorization='%s'\ncommit wakey\n" "$authorization" | uci batch
|
||||||
|
chmod 600 /etc/config/wakey
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Use a normal `Basic <base64>` header here, without quotes/newlines inside the value.
|
||||||
|
The shell's builtin `printf` avoids passing the secret in `uci`'s command arguments.
|
||||||
|
UCI is not a secret vault: committed files, staged changes, router backups, and
|
||||||
|
root access can expose these credentials. Do not paste their output into logs/chat.
|
||||||
|
|
||||||
|
## Inspect, change, delete
|
||||||
|
|
||||||
|
Inspect the non-secret option:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
uci get wakey.main.log_filter
|
||||||
|
```
|
||||||
|
|
||||||
|
`uci show wakey`, `uci export wakey`, `uci changes wakey`, and
|
||||||
|
`uci get wakey.main.otlp_authorization` **print the credential**. Use them only
|
||||||
|
when you deliberately want to see it.
|
||||||
|
|
||||||
|
Change an option:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
uci set wakey.main.log_filter='wakey_agent=debug,wakey=debug,opentelemetry=warn'
|
||||||
|
uci commit wakey
|
||||||
|
```
|
||||||
|
|
||||||
|
Delete just the authorization option:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
uci -q delete wakey.main.otlp_authorization
|
||||||
|
uci commit wakey
|
||||||
|
```
|
||||||
|
|
||||||
|
If TOML still requires `authorization_env`, removing the variable makes startup
|
||||||
|
fail. Remove that TOML field too only when the receiver no longer requires auth.
|
||||||
|
|
||||||
|
Discard all uncommitted changes to this package:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
uci revert wakey
|
||||||
|
```
|
||||||
|
|
||||||
|
## How the service reads it
|
||||||
|
|
||||||
|
`config_load` and `config_get` are shell helpers from `/lib/functions.sh`, available
|
||||||
|
in the normal `/etc/rc.common` init-script environment. They are not standalone
|
||||||
|
commands to run directly in your SSH shell.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
config_load wakey
|
||||||
|
config_get authorization main otlp_authorization
|
||||||
|
```
|
||||||
|
|
||||||
|
`config_get` arguments are **destination variable, section, option, optional default**.
|
||||||
|
The line above puts the option into the shell variable `authorization`. It does
|
||||||
|
not export it to the agent automatically; procd must receive it explicitly.
|
||||||
|
|
||||||
|
The checked-in [init script](../scripts/init/openwrt/wakey) uses these helpers.
|
||||||
|
Its configuration handling is:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
start_service() {
|
||||||
|
local authorization='' log_filter='info'
|
||||||
|
if [ -f /etc/config/wakey ]; then
|
||||||
|
config_load wakey || return 1
|
||||||
|
config_get authorization main otlp_authorization
|
||||||
|
config_get log_filter main log_filter 'info'
|
||||||
|
fi
|
||||||
|
|
||||||
|
procd_open_instance
|
||||||
|
procd_set_param command "$BIN" serve --config "$CONFIG"
|
||||||
|
procd_set_param file "$CONFIG"
|
||||||
|
[ ! -f /etc/config/wakey ] || procd_append_param file /etc/config/wakey
|
||||||
|
procd_set_param env "RUST_LOG=$log_filter"
|
||||||
|
[ -z "$authorization" ] || procd_append_param env "WAKEY_OTLP_AUTHORIZATION=$authorization"
|
||||||
|
procd_set_param respawn 5 1 0
|
||||||
|
procd_set_param stdout 1
|
||||||
|
procd_set_param stderr 1
|
||||||
|
procd_close_instance
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep these fields in the agent's TOML:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[telemetry]
|
||||||
|
otlp_endpoint = "http://10.5.0.1:4317"
|
||||||
|
authorization_env = "WAKEY_OTLP_AUTHORIZATION"
|
||||||
|
```
|
||||||
|
|
||||||
|
UCI stores the header; procd passes the environment variable; Rust reads it using
|
||||||
|
the name in TOML. The application does not parse UCI itself.
|
||||||
|
|
||||||
|
After installing the updated init script and committing settings:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
/etc/init.d/wakey restart
|
||||||
|
```
|
||||||
|
|
||||||
|
Use **restart**, not the existing HUP-based reload: a running process cannot receive
|
||||||
|
changed environment variables that way. UCI is only for the router; the CC's
|
||||||
|
systemd environment file remains unchanged. See [telemetry.md](telemetry.md).
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [OpenWrt UCI CLI source and command usage](https://github.com/openwrt/uci/blob/master/cli.c)
|
||||||
|
- [OpenWrt config_load/config_get helpers](https://github.com/openwrt/openwrt/blob/main/package/base-files/files/lib/functions.sh)
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
# Origin Authentication With Caddy
|
||||||
|
|
||||||
|
Cloudflare Access protects the operator surface at the edge. An origin reachable
|
||||||
|
directly must also verify Access tokens: a nonempty assertion header is not
|
||||||
|
authentication. A read-only probe through `mails.ldlda.com`, preserving Wakey's
|
||||||
|
TLS hostname and HTTP host, returned 403 without a header and 200 with a forged
|
||||||
|
header on the same origin IP. Do not retain the old header-presence matcher.
|
||||||
|
|
||||||
|
## Configure CC
|
||||||
|
|
||||||
|
Add this to the deployed CC configuration, using your protected application's
|
||||||
|
settings, not the bypass application's audience:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
bind = "127.0.0.1:23501"
|
||||||
|
|
||||||
|
[access]
|
||||||
|
issuer = "https://ldlda.cloudflareaccess.com"
|
||||||
|
audience = "0c8ca0bce40a48ddc5bc065cea8390eaddd75a84458f147bbe249fcc57c3d781"
|
||||||
|
# Optional; defaults to the issuer's /cdn-cgi/access/certs endpoint.
|
||||||
|
# jwks_url = "https://ldlda.cloudflareaccess.com/cdn-cgi/access/certs"
|
||||||
|
```
|
||||||
|
|
||||||
|
Issuer and key URLs must use HTTPS without user credentials, query or fragment.
|
||||||
|
The issuer is an origin without a trailing slash; it must exactly match the JWT
|
||||||
|
issuer. The audience is not a secret. URLs come only from trusted configuration,
|
||||||
|
never from an incoming token. `init-config --from-config` preserves `[access]`.
|
||||||
|
|
||||||
|
CC requires a loopback bind when `[access]` is configured. With no `[access]`,
|
||||||
|
the verifier returns 401; existing local development routes remain unchanged.
|
||||||
|
Changes to Access configuration require a daemon restart, not a state reload.
|
||||||
|
|
||||||
|
## Configure Caddy
|
||||||
|
|
||||||
|
Use [the Caddy template](../deploy/control-plane.Caddyfile), replacing the domain
|
||||||
|
and every upstream port with your deployment values. Its protected handler calls
|
||||||
|
CC's `GET /internal/auth/verify` through `forward_auth`, then proxies the original
|
||||||
|
request only on success. No additional service or custom Caddy module is needed.
|
||||||
|
|
||||||
|
Protect `/`, `/ui*` and `/api/v1/control/*`. This includes operator WebSocket
|
||||||
|
handshakes; established WebSocket frames are not individually authenticated and
|
||||||
|
token expiry does not automatically disconnect an existing WebSocket.
|
||||||
|
|
||||||
|
Keep `/healthz`, `/api/v1/agents/enroll`, `/api/v1/agent/ws` and
|
||||||
|
`/api/v1/agent/terminals/*` outside Access verification. They retain Wakey's own
|
||||||
|
agent/scoped-relay authentication. Cloudflare's bypass application should cover
|
||||||
|
these paths; terminal child paths must be included. If using HTTP ACME challenges,
|
||||||
|
ensure `/.well-known/acme-challenge/*` is exempt at Cloudflare; Caddy handles its
|
||||||
|
own active challenges, not CC.
|
||||||
|
|
||||||
|
Do not proxy `/internal/auth/verify` publicly and do not put `forward_auth` in
|
||||||
|
front of the verifier itself. Do not replace verification with a static header
|
||||||
|
that Caddy adds to every request. Caddy owns enforcement: direct access to the
|
||||||
|
CC port would bypass it, so loopback binding and host access controls matter.
|
||||||
|
|
||||||
|
## Verification And Cache
|
||||||
|
|
||||||
|
The verifier accepts only RS256, verifies signatures, and requires matching
|
||||||
|
issuer/audience plus expiry. A future `nbf` also rejects the token. No clock-skew
|
||||||
|
leeway is applied; keep the host clock synchronized. Duplicate assertion headers
|
||||||
|
and assertions over 16 KiB are rejected. No tokens, claims or upstream bodies
|
||||||
|
are logged.
|
||||||
|
|
||||||
|
Responses have no identity payload:
|
||||||
|
|
||||||
|
| Status | Meaning |
|
||||||
|
| --- | --- |
|
||||||
|
| 204 | Valid protected-application token |
|
||||||
|
| 401 | Missing/invalid token, unknown key after successful key retrieval, or unconfigured verifier |
|
||||||
|
| 503 | Required signing keys cannot be obtained safely |
|
||||||
|
|
||||||
|
CC reuses one HTTP client and stores parsed keys with their HTTP cache policy.
|
||||||
|
`http-cache-semantics` determines freshness from the issuer's `Cache-Control`,
|
||||||
|
`Age`, `Date` and `Expires`, rather than a built-in key TTL. For example,
|
||||||
|
`public, max-age=14400, must-revalidate, no-transform` permits four hours of
|
||||||
|
freshness minus the response's existing age; `must-revalidate` forbids stale
|
||||||
|
reuse, not every fresh reuse. No heuristic lifetime is invented when freshness
|
||||||
|
is unspecified. A `no-cache` response requires revalidation before reuse;
|
||||||
|
`no-store` retains neither keys nor policy after serving the fetching request.
|
||||||
|
|
||||||
|
Fresh matching keys verify locally. Stale policies produce conditional requests
|
||||||
|
using ETag or Last-Modified when available. A matching 304 renews the policy and
|
||||||
|
reuses parsed keys without downloading/parsing a body; mismatched or unsolicited
|
||||||
|
304 responses fail closed. An unknown key ID forces conditional revalidation
|
||||||
|
even when the stored response remains fresh, to discover rotation.
|
||||||
|
|
||||||
|
Fetches are single-flight and limited to once per minute, including failures.
|
||||||
|
Rotation immediately after a fetch may therefore take up to a minute to become
|
||||||
|
available. If the issuer requires revalidation on every use (or allows no
|
||||||
|
storage), subsequent callers inside that cooldown receive 503 rather than reuse
|
||||||
|
keys contrary to the HTTP policy. Successful 200 refresh replaces the entire
|
||||||
|
set, including removing all keys for a valid empty set. Failed refresh does not
|
||||||
|
extend freshness: matching keys may be used only while the stored policy still
|
||||||
|
permits reuse. There is no indefinitely stale fallback, including on outages.
|
||||||
|
|
||||||
|
Fetches have a five-second timeout, no redirects, a 256 KiB body limit and at
|
||||||
|
most 64 keys. These limits are internal safeguards, not new config knobs.
|
||||||
|
|
||||||
|
## Rollout
|
||||||
|
|
||||||
|
1. Install the new CC binary, add `[access]`, and restart CC. Check the loopback
|
||||||
|
verifier returns 401 for an absent or forged assertion.
|
||||||
|
2. Update Caddy from the template and validate its configuration before reload.
|
||||||
|
The repo template uses port 6767; this deployment uses 23501.
|
||||||
|
3. Confirm a real Access login can use the UI/control APIs and operator terminal
|
||||||
|
WebSocket. Confirm agent connections and enrollment still work.
|
||||||
|
4. Repeat a direct-origin request with Wakey's Host/SNI. Both absent and forged
|
||||||
|
assertions must return 401, not the UI. Verify the internal endpoint itself
|
||||||
|
returns 404 through the public virtual host.
|
||||||
|
|
||||||
|
Do not roll back to header-only acceptance on a verification failure. A UI
|
||||||
|
build or a CC restart alone does not update the deployed Caddy policy.
|
||||||
|
|
||||||
|
References: [Cloudflare token validation](https://developers.cloudflare.com/cloudflare-one/access-controls/applications/http-apps/authorization-cookie/validating-json/)
|
||||||
|
and [Caddy forward_auth](https://caddyserver.com/docs/caddyfile/directives/forward_auth).
|
||||||
|
HTTP policy is provided by [http-cache-semantics](https://docs.rs/http-cache-semantics/latest/http_cache_semantics/struct.CachePolicy.html);
|
||||||
|
only the single-resource storage, bounded fetch and anti-hammering policy are
|
||||||
|
application-owned. No general-purpose disk or Moka response cache is needed.
|
||||||
+137
@@ -0,0 +1,137 @@
|
|||||||
|
# Wakey Roadmap
|
||||||
|
|
||||||
|
Last updated: 2026-10-12. This records agreed work and follow-ups, not a release
|
||||||
|
promise. Checked items are implemented; live validation is called out separately.
|
||||||
|
|
||||||
|
## Completed Observability
|
||||||
|
|
||||||
|
- [x] Shared optional OTLP setup for traces, logs, and metrics in wakey-core.
|
||||||
|
- [x] Basic/Bearer authorization supplied through a named environment variable.
|
||||||
|
- [x] OpenWrt UCI configuration and systemd environment-file support, with setup guides.
|
||||||
|
- [x] Cross-process command trace context; verified live on a CC-to-agent wake.
|
||||||
|
- [x] Inventory source, merge, device-storage, and fleet-operation spans.
|
||||||
|
- [x] Agent connection phase spans and retry events, without entered guards across awaits.
|
||||||
|
- [x] Terminal snapshot generation, transport attachment, resume, and backpressure instrumentation.
|
||||||
|
- [x] Separate hostname-resolution and neighbor-table spans; a live slow wake spent about four seconds in resolution.
|
||||||
|
- [x] Metrics recipes for rates, duration averages/percentiles, terminal activity, and snapshots.
|
||||||
|
- [x] Authenticated live logs, metrics, and traces confirmed through Grafana's read-only API.
|
||||||
|
|
||||||
|
See [telemetry setup](telemetry.md), [metrics recipes](telemetry-recipes.md), and
|
||||||
|
[OpenWrt UCI](openwrt-uci.md). Terminal instrumentation has automated coverage;
|
||||||
|
live browser recovery and shutdown behavior are not established by those checks.
|
||||||
|
|
||||||
|
## Device Management And Presentation
|
||||||
|
|
||||||
|
- [x] Endpoint-derived presence on snapshot writes and fleet reads; endpoint-less legacy rows retain their summary. CLI ordering matches the core signal order.
|
||||||
|
|
||||||
|
- [x] Make **forget remembered device** reachable in the frontend, with confirmation. The existing backend removes the known identity and its identifiers, not agent observations. An observed device may reappear as unknown.
|
||||||
|
- [x] Add remembered-device rename support, with backend validation and frontend editing (`PATCH /api/v1/control/devices/{device_id}`). Pinned state and identifiers are preserved.
|
||||||
|
- [ ] Check and complete editing of remembered-device notes.
|
||||||
|
- [x] Replace generic unnamed-row labels with useful fallbacks. Prefer saved name, hostname, then `Device: <mac>`, then `Device: <ip>`; retain a generic label only when neither address exists. Presence stays in the badge, not the name. Inactive IPs can label IP-only rows without becoming active IPs or wake routes. Unknown-only fleet groups remain unknown.
|
||||||
|
- [ ] Review frontend visibility defaults. Keep an explicit all-rows option; do not hide evidence just to fix poor labels. The user deliberately enabled all-row querying.
|
||||||
|
- [x] Keep device details current after identity mutations change the grouping key or list filters exclude the result. Exact fleet details and full saved-identity reads follow the mutation's explicit ID; no shared-IP matching or stale-control fallback. Remember/Attach/Rename verified with mocked browser flows on desktop, Remember/Rename on mobile; backend tests cover filtering, old-key rejection and lookup beyond the list cap. Forget deliberately closes; detaching stays on the saved identity instead of guessing among split observations.
|
||||||
|
|
||||||
|
Identifier detachment already exists in the current frontend source; it is **not**
|
||||||
|
the requested whole-device removal. No automatic identity promotion or deletion
|
||||||
|
of remembered identifiers based on observation expiry.
|
||||||
|
|
||||||
|
## Telemetry Follow-Ups
|
||||||
|
|
||||||
|
- [x] Human-readable agent/CC config durations with unit-aware legacy loading, stderr deprecation warnings, and canonical init-config output. See [configuration durations](config-durations.md).
|
||||||
|
- [x] CC device retention by last snapshot inclusion (`device_retention = "60d"`, zero disables), independent of audit history and preserving saved identities.
|
||||||
|
- [x] Lazy grouped fleet-view cache with store/connection invalidation and stale-build rejection. See [fleet cache](fleet-cache.md).
|
||||||
|
|
||||||
|
- [x] Separate console, OTLP log, and trace filters using per-layer filtering. Explicit signal config overrides `RUST_LOG`, then CLI verbosity supplies defaults. Unconfigured defaults remain info-level; exporter-failure logs stay local. Live validation pending.
|
||||||
|
- [x] Upgrade the matched OpenTelemetry stack to 0.33 with exporter-managed backoff/jitter retries. Controlled collector tests cover all-signal recovery, bounded retries, and no retry on authentication rejection; live outage validation remains pending.
|
||||||
|
- [ ] Record hostname-resolution success/failure without exporting hostnames or resolved addresses. Current spans show timing but do not explicitly classify the result.
|
||||||
|
- [ ] Investigate the slow resolver with a reproducible device-side check. Timing isolated the phase, not the cause; do not treat instrumentation as a fix.
|
||||||
|
- [x] Propagate optional W3C trace context for periodic/requested WS snapshots so agent collection and CC persistence can be inspected together. Wire compatibility and context linkage tested; live Grafana inspection confirmed CC storage is a child of agent collection in the same trace. Storage can start after collection ends: this is an asynchronous handoff, not a persistence acknowledgement.
|
||||||
|
- [x] Add current-state gauges: retained device/endpoint rows per agent and unfiltered grouped fleet identities split saved/unsaved. Sample independently every 30 seconds with bounded revision checks; replace labels and suppress failed samples. Collector and state lifecycle tests included; dashboard recipes ready.
|
||||||
|
- [ ] Decide whether snapshots need persistence acknowledgements. The existing sent-device counter does not prove CC storage; any confirmed-storage metric needs an authoritative measurement point.
|
||||||
|
- [x] Break down snapshot persistence timing into pool acquisition, transaction acquisition, row replacement, and commit child spans. Shared write acquisition retains SQLx ownership and `BEGIN IMMEDIATE`; snapshot row work stays in the caller's transaction. Existing rollback and cache-refresh tests cover behavior. Live trace screenshot confirmed all phases: about 83 microseconds acquiring a connection, 154 microseconds beginning the transaction, 12.8 ms replacing rows, and 18.94 ms committing. This sample does not justify changing SQLite durability or prove a persistent bottleneck. See [telemetry span semantics](telemetry.md#signals).
|
||||||
|
|
||||||
|
The existing snapshot counter is cumulative collection volume, not unique devices
|
||||||
|
or current fleet size. Metric labels should remain bounded; no per-device labels.
|
||||||
|
|
||||||
|
## Process And Host Visibility
|
||||||
|
|
||||||
|
- [x] Add Linux process age/RSS and host uptime gauges for agent and CC through opt-in OTLP, using the existing collection interval. Core owns OS-independent instruments; wakey-linux supplies the optional procfs reader. Missing data omits observations; no added labels, config fields, health routes or polling tasks. Collector coverage verifies units and values; router/live dashboard validation pending.
|
||||||
|
- [x] Add cumulative process CPU time (counter) and current open-FD count (gauge) through the same Linux reader and OS-independent instruments. Exclude child processes, omit failed samples, and document one-core CPU-rate interpretation and sampling FD overhead. Extend collector/reader coverage rather than adding timing-based tests; live validation pending.
|
||||||
|
- [ ] Add start-time/version metadata if needed. Keep version as resource metadata rather than repeating it on every sample.
|
||||||
|
- [x] Add total/available host memory and 1/5/15-minute load gauges through the optional Linux reader, with byte conversion owned by procfs and fixed load-period labels. Omit unavailable MemAvailable instead of substituting free memory; document kernel views versus container limits. Collector/reader coverage included; live validation pending.
|
||||||
|
- [ ] Complete a small host summary with hostname, OS/kernel and CPU count. Sources must tolerate missing Linux files; not every agent host is a router.
|
||||||
|
- [ ] Display CC and agent process/host information in Fleet Health and agent details: version/name, process/host uptime, RSS, CPU time/usage, open FDs, available/total host memory and load where supported. OTLP export is not the UI's data source; the UI must work without a collector.
|
||||||
|
- [ ] Add typed agent status snapshots over the existing authenticated WebSocket, on a modest timer and when requested. Reuse Linux collection rather than introducing separate parsers; CC retains each agent's latest sample with received time and connection status. Show stale/disconnected or unavailable values explicitly, never as zero/current. Keep routine samples in memory initially, not an unbounded DB history.
|
||||||
|
- [ ] Expose the CC's own sampled status and retained agent summaries through protected control APIs. Keep `/healthz` unchanged and do not expose environment variables, credentials or command lines. CPU percentages require successive cumulative CPU/time samples; do not label raw CPU seconds as utilization. No expensive host reads on every UI request.
|
||||||
|
- [ ] Keep `/healthz` as cheap liveness; consider a separate bounded `/readyz` database-access check for CC. Collector outages and disconnected fleet agents must not make a functioning CC unready.
|
||||||
|
- [ ] Add application resource gauges where useful: DB pool usage, connected agents, pending commands, and active terminal sessions. Check existing instruments first to avoid duplicates; distinguish pool pressure from host/process pressure.
|
||||||
|
|
||||||
|
## Device Diagnostics
|
||||||
|
|
||||||
|
- [ ] Add agent-side ICMP ping through the existing command relay, with an explicit IP and selected agent/network route. Bound probe count, total duration, output and concurrent work; no shell interpolation. Account for missing tools and ICMP permissions, and support IPv4/IPv6 where available.
|
||||||
|
- [ ] Return probe replies, loss and round-trip times; offer Ping alongside Wake in device details. MAC-only devices cannot be pinged without an IP. A reply means that IP responded from that agent's network, not proof of saved identity; no reply must not automatically mark a device offline or remove an identifier.
|
||||||
|
|
||||||
|
## Optional Agent Sources And Capabilities
|
||||||
|
|
||||||
|
These are exploration items, not a commitment to a router-only model or a new
|
||||||
|
message bus. Sparse WSL inventory is legitimate; missing evidence is not failure.
|
||||||
|
|
||||||
|
- [ ] Discover and advertise usable observation sources separately from supported commands and configured permissions. Extend the existing hello/capability mechanism where appropriate; CC/UI must tolerate missing sources and older agents. Do not infer router status from Linux alone.
|
||||||
|
- [ ] Investigate read-only Wi-Fi station data on the actual router: signal in **dBm**, association/interface, rates and traffic counters where available. Evaluate driver-supported `iw`/netlink or local `ubus`/hostapd interfaces before choosing a dependency. Keep measurements scoped to agent/interface/station with source and capture time; they are not permanent identity or globally valid presence. UI may show a signal badge only when supported and sufficiently fresh.
|
||||||
|
- [ ] Investigate interface lifecycle hooks for WAN/link changes, keeping scripts minimal and logic in the binary. Interface up/down does not prove Internet reachability; any active connectivity probe needs an explicit target and bounded policy. Existing DHCP/neighbor hooks remain independent evidence, not a way to enrich or overwrite other sources.
|
||||||
|
- [ ] Keep source support, operator authorization and local agent policy distinct. Capability advertisement is not permission: CC checks authorization and the agent enforces configured limits. New diagnostic APIs should be typed, protected operations, not arbitrary shell, UCI or bus calls exposed to the browser.
|
||||||
|
|
||||||
|
OpenWrt [hotplug](https://openwrt.org/docs/guide-user/base-system/hotplug) provides
|
||||||
|
event hooks; [ubus](https://openwrt.org/docs/techref/ubus) is local IPC/RPC, and
|
||||||
|
[UCI](https://openwrt.org/docs/guide-developer/ubus/uci) manages configuration.
|
||||||
|
None is a Kafka-style retained observation history. Verify what the vendor
|
||||||
|
firmware actually exposes before making any of them an agent requirement.
|
||||||
|
|
||||||
|
## Origin Authentication
|
||||||
|
|
||||||
|
- [x] Replace the nonempty assertion matcher in the Caddy template with `forward_auth`. The initial `lb.ldlda.com` probe reached Cloudflare; the later `mails.ldlda.com` probe reached an origin and confirmed 403 without an assertion versus 200 with a forged assertion on the same IP. This is a reproduced bypass, not just a theoretical concern; production rollout remains required.
|
||||||
|
- [x] Add CC's `/internal/auth/verify` endpoint with configured issuer/audience/key URL, RS256 signature and required claim validation, bounded cached key retrieval, single-flight refresh and outage cooldown. Return 204/401/503 without exposing tokens or identity data. Configured verification requires loopback binding, and `init-config --from-config` preserves it. See [origin authentication](origin-auth.md).
|
||||||
|
- [x] Cover forged/expired/wrong-audience/missing-claim tokens, key rotation/removal (including an empty set), duplicate assertions, unavailable/malformed/oversized key responses, cooldown and concurrent fetches. Router coverage verifies the oracle is separate from public/control handlers; actual Caddy enforcement is deployment-owned and still requires validation.
|
||||||
|
- [x] Use `http-cache-semantics` for issuer-supplied HTTP freshness instead of a hardcoded signing-key TTL. Preserve bounded streaming reads, force revalidation on unknown key IDs, and handle ETag/Last-Modified 304 responses without reparsing a body. Tests cover fresh reuse, Age, no-cache/no-store, unspecified freshness, conditional rotation and invalid 304 rejection; no sleeps required.
|
||||||
|
- [ ] Deploy the new CC verifier and Caddy policy for root/UI/control HTTP and operator WS handshakes. Keep public agent paths outside Access checks and the verifier unexposed publicly. Validate Caddy configuration, real Access login, agent connectivity, protected WebSocket rejection, and forged-header direct-origin rejection. Authentication failures must never fall back to header-only acceptance.
|
||||||
|
|
||||||
|
## Multi-CC Planning
|
||||||
|
|
||||||
|
- [ ] Consolidate stable CC/local-agent IDs, forwarded display names, per-CC enrollments, and agent config restructuring. See [multi-CC proposal](multi-cc-plan.md). Identity/name exchange and terminal ownership must be defined before adding connection workers; no CC-to-CC state synchronization is planned.
|
||||||
|
|
||||||
|
## Validation And Reliability
|
||||||
|
|
||||||
|
- [x] Bound fleet-cache rebuilds to three attempts under sustained mutations; return retryable HTTP 503 `fleet_busy` rather than silently serving stale wake routes. Deterministic limit/recovery tests included.
|
||||||
|
- [x] Separate fleet wake selection from dispatch; wake lookup uses the complete cached view rather than the presentation list's 1,000-row cap. Automated coverage checks explicit-route rejection and lookup beyond the cap; live checks confirm missing-device and invalid-route errors without dispatching wakes.
|
||||||
|
- [x] Share device read assembly within a single SQLite read transaction. Query-plan inspection on the test database with Python SQLite confirmed all six per-agent reads use primary-key searches; full-fleet reads scan intentionally, with covering indexes for MAC/IP projections and no temporary sort. No additional indexes justified by this check.
|
||||||
|
- [x] Separate fleet refresh selection, relay, decoding/storage, and aggregation. Preserve concurrency of eight and per-agent failures; tests cover disconnected agents, sorted partial results, invalid payload preservation, and valid empty snapshot replacement.
|
||||||
|
- [x] Add a refresh integration regression: a simulated agent answers through the production command relay; its inventory reaches SQLite, invalidates a previously populated fleet cache, and appears in the next fleet read. A disconnected agent does not cancel success; duplicate selection sends only one command. This covers the CC application path, not a real agent process or WebSocket transport.
|
||||||
|
- [ ] Reproduce/check Ctrl-C cleanup on the test device: process groups, PTYs, relay tasks, and telemetry shutdown. Leftovers were suspected, not confirmed.
|
||||||
|
- [ ] Exercise collector unavailability and recovery with running agent/CC instances. Verify normal operations continue and telemetry queues remain bounded; outage losses are expected under the current best-effort policy.
|
||||||
|
- [ ] Validate terminal recovery instrumentation live: generation timing/size, transport attach/resume, and backpressure episodes ending in drain or disconnect.
|
||||||
|
- [ ] Validate the revised UCI credential prompt and service environment on the live router.
|
||||||
|
|
||||||
|
## Watch, Not Yet Fix
|
||||||
|
|
||||||
|
- Fleet listing time is mostly device-store reads in observed traces. Measure
|
||||||
|
sustained latency and row counts before changing queries or indexes.
|
||||||
|
- Snapshot write durations vary. Existing store spans include pool/lock waits,
|
||||||
|
transaction work, and result assembly where applicable; they are not pure SQL timings.
|
||||||
|
|
||||||
|
## Deferred
|
||||||
|
|
||||||
|
- Pinned-state editing: currently not needed for the small remembered fleet.
|
||||||
|
- Browser-render acknowledgement: attach acceptance and snapshot generation do
|
||||||
|
not prove xterm finished restoring. This would require additional protocol work.
|
||||||
|
- Retaining terminal images across recovery: text-screen snapshots do not provide image replay.
|
||||||
|
- CPU/heap profiling: use targeted profiling when a measured issue calls for it;
|
||||||
|
OTLP traces are not CPU profiles or flame graphs.
|
||||||
|
- Grafana dashboard construction is the user's exercise; keep improving the recipe book rather than taking over dashboard editing.
|
||||||
|
|
||||||
|
## Release Notes
|
||||||
|
|
||||||
|
- No version bump is made by this roadmap. Additive optional config fields alone
|
||||||
|
do not require 0.4.0; reconsider if compatibility or defaults change materially.
|
||||||
|
- Recheck live telemetry after deployment. A Grafana volume reset can remove
|
||||||
|
history and invalidate service-account tokens or data-source UIDs.
|
||||||
|
- Never commit Grafana tokens, UCI credentials, or router backups.
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
# Wakey Metrics Recipes
|
||||||
|
|
||||||
|
Use Grafana Explore with your Prometheus-compatible data source and the Code
|
||||||
|
query editor. These recipes assume standard OTLP-to-Prometheus name conversion.
|
||||||
|
Discover your actual series first:
|
||||||
|
|
||||||
|
```promql
|
||||||
|
count by (__name__, job) ({__name__=~"wakey[._].*"})
|
||||||
|
```
|
||||||
|
|
||||||
|
With Alloy's Prometheus exporter, service names normally become `job`, and agent
|
||||||
|
instance IDs become `instance`. Inspect the returned labels before adding filters.
|
||||||
|
For example, append `{job="wakey-agent"}` to a metric selector only if that is
|
||||||
|
the job value you actually see. Metrics export every 30 seconds; use a time range
|
||||||
|
that includes recent activity and allow at least two exports for rate queries.
|
||||||
|
|
||||||
|
## Metric Types
|
||||||
|
|
||||||
|
| Type | What it records | How to read it |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Counter | Cumulative events or bytes since process startup | `rate` for per-second throughput; `increase` for a window's estimated total |
|
||||||
|
| UpDownCounter | Adds and subtracts from a current quantity | Read the current value directly; do not use `rate` for active sessions |
|
||||||
|
| Gauge | Measures a current value, such as stored device rows | Read directly; do not use `rate` or `increase` |
|
||||||
|
| Histogram | Distribution of individual measurements | `_sum / _count` for an average; `_bucket` for estimated percentiles |
|
||||||
|
|
||||||
|
Counter resets on restart are handled by `rate` and `increase`. Apply those
|
||||||
|
functions **before** summing across instances so individual resets remain visible.
|
||||||
|
|
||||||
|
## Process And Host
|
||||||
|
|
||||||
|
Discover the new gauges first (these are Linux-only):
|
||||||
|
|
||||||
|
```promql
|
||||||
|
count by (__name__, job, instance) ({__name__=~"wakey_(process|host)_.*"})
|
||||||
|
```
|
||||||
|
|
||||||
|
Process age and host uptime, in seconds:
|
||||||
|
|
||||||
|
```promql
|
||||||
|
wakey_process_uptime_seconds
|
||||||
|
```
|
||||||
|
|
||||||
|
```promql
|
||||||
|
wakey_host_uptime_seconds
|
||||||
|
```
|
||||||
|
|
||||||
|
Set Grafana's unit to a duration. Compare the two: process age resetting while
|
||||||
|
host uptime continues suggests a daemon restart; both resetting suggests a host
|
||||||
|
restart or a changed namespace/view. Missing samples are not a zero uptime or
|
||||||
|
proof of a restart. These are gauges: do not use `rate` or `increase` on them.
|
||||||
|
|
||||||
|
Resident process memory, in bytes:
|
||||||
|
|
||||||
|
```promql
|
||||||
|
wakey_process_memory_resident_bytes
|
||||||
|
```
|
||||||
|
|
||||||
|
Set the unit to **bytes (IEC)**. Keep `job` and `instance` in the legend to compare
|
||||||
|
agent and CC separately. RSS includes shared/file-backed pages and is not heap
|
||||||
|
allocation alone. A missing series can mean disabled telemetry, an unsupported
|
||||||
|
platform, unavailable procfs, or export trouble. No new health endpoint or host
|
||||||
|
inventory is provided here; do not sum host uptime from multiple daemons.
|
||||||
|
|
||||||
|
CPU consumption as a percentage of **one CPU core**:
|
||||||
|
|
||||||
|
```promql
|
||||||
|
100 * rate(wakey_process_cpu_time_seconds_total[5m])
|
||||||
|
```
|
||||||
|
|
||||||
|
Set the unit to **percent (0-100)**. A multithreaded process can exceed 100%;
|
||||||
|
this is not normalized by the host's CPU count. The counter includes daemon
|
||||||
|
user/system CPU, not its child shells or commands. Allow at least two exports;
|
||||||
|
`rate` handles counter resets on process restart.
|
||||||
|
|
||||||
|
Current open descriptors:
|
||||||
|
|
||||||
|
```promql
|
||||||
|
wakey_process_open_fds
|
||||||
|
```
|
||||||
|
|
||||||
|
Read directly as a count, not a rate. The sampling read can contribute temporary
|
||||||
|
descriptors. Persistent growth can warrant investigation, but sockets, terminal
|
||||||
|
sessions and pool connections legitimately change the count too.
|
||||||
|
|
||||||
|
Host usable/available RAM, in bytes:
|
||||||
|
|
||||||
|
```promql
|
||||||
|
wakey_host_memory_total_bytes
|
||||||
|
```
|
||||||
|
|
||||||
|
```promql
|
||||||
|
wakey_host_memory_available_bytes
|
||||||
|
```
|
||||||
|
|
||||||
|
Set units to **bytes (IEC)**. Estimated percentage not available for new work:
|
||||||
|
|
||||||
|
```promql
|
||||||
|
100 * (1 - wakey_host_memory_available_bytes / wakey_host_memory_total_bytes)
|
||||||
|
```
|
||||||
|
|
||||||
|
Available includes reclaimable memory and is not merely free RAM. Missing
|
||||||
|
`MemAvailable` leaves a gap rather than substituting `MemFree`. Do not sum host
|
||||||
|
RAM from multiple daemon instances on the same host, or interpret these kernel
|
||||||
|
values as a container's cgroup limit.
|
||||||
|
|
||||||
|
Load averages by period:
|
||||||
|
|
||||||
|
```promql
|
||||||
|
wakey_host_load
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `{{job}} / {{instance}} / {{period}}` as the legend. Fixed periods are `1m`,
|
||||||
|
`5m`, and `15m`. Read directly as a load count, not a percentage or a rate:
|
||||||
|
Linux includes runnable and uninterruptible tasks; a high load can include I/O
|
||||||
|
waiting rather than CPU work. These values are not normalized by CPU count.
|
||||||
|
|
||||||
|
## Operations
|
||||||
|
|
||||||
|
Completed attempts per second, including errors and timeouts:
|
||||||
|
|
||||||
|
```promql
|
||||||
|
sum by (job, operation, outcome) (rate(wakey_operations_total[5m]))
|
||||||
|
```
|
||||||
|
|
||||||
|
Estimated attempts over the last hour:
|
||||||
|
|
||||||
|
```promql
|
||||||
|
sum by (job, operation, outcome) (increase(wakey_operations_total[1h]))
|
||||||
|
```
|
||||||
|
|
||||||
|
Average duration over the last five minutes:
|
||||||
|
|
||||||
|
```promql
|
||||||
|
sum by (job, operation) (rate(wakey_operation_duration_seconds_sum[5m]))
|
||||||
|
/
|
||||||
|
sum by (job, operation) (rate(wakey_operation_duration_seconds_count[5m]))
|
||||||
|
```
|
||||||
|
|
||||||
|
Set Grafana's unit to **seconds**. This is a weighted average, not an average of
|
||||||
|
instance averages. Both successful and unsuccessful attempts are included;
|
||||||
|
add `outcome="ok"` to both selectors for successful attempts only.
|
||||||
|
|
||||||
|
Estimated p95 duration:
|
||||||
|
|
||||||
|
```promql
|
||||||
|
histogram_quantile(
|
||||||
|
0.95,
|
||||||
|
sum by (le, job, operation) (
|
||||||
|
rate(wakey_operation_duration_seconds_bucket[5m])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
This needs classic histogram bucket series. Keep `le` in the grouping: it is
|
||||||
|
the bucket boundary, in seconds. p95 is an estimate from those boundaries,
|
||||||
|
not an exact sort of recorded durations. Few operations make percentiles noisy.
|
||||||
|
|
||||||
|
Operation-duration buckets span 100 microseconds to 60 seconds, with an overflow
|
||||||
|
bucket above that. Older binaries used SDK defaults whose first positive boundary
|
||||||
|
was 5 seconds: fast operations could therefore report an estimated p95 of 4.75
|
||||||
|
seconds. Updating both CC and agents fixes future measurements, not historical
|
||||||
|
data. Wait until the query's window contains only new-version measurements after
|
||||||
|
rollout; mixing old and new bucket layouts makes the estimate unreliable.
|
||||||
|
With roughly one snapshot per minute, a five-minute percentile is based on only
|
||||||
|
a handful of observations and can still look stepped. Use the mean above alongside it.
|
||||||
|
|
||||||
|
CC relay duration includes waiting for the agent; agent execution duration
|
||||||
|
measures its dispatch separately. Do not sum both and call that user latency.
|
||||||
|
|
||||||
|
## Terminals
|
||||||
|
|
||||||
|
Active PTY workers by agent, including detached sessions:
|
||||||
|
|
||||||
|
```promql
|
||||||
|
sum by (job, instance) (wakey_terminal_active)
|
||||||
|
```
|
||||||
|
|
||||||
|
Read this directly as a count. It is not a count of browser tabs or attachments.
|
||||||
|
|
||||||
|
PTY output bytes per second:
|
||||||
|
|
||||||
|
```promql
|
||||||
|
sum by (job, instance) (rate(wakey_terminal_output_bytes_total[5m]))
|
||||||
|
```
|
||||||
|
|
||||||
|
Input bytes per second:
|
||||||
|
|
||||||
|
```promql
|
||||||
|
sum by (job, instance) (rate(wakey_terminal_input_bytes_total[5m]))
|
||||||
|
```
|
||||||
|
|
||||||
|
Set the unit to **bytes/sec**. Output is measured at the PTY read, even while
|
||||||
|
detached; it is not WebSocket wire traffic or confirmed browser delivery.
|
||||||
|
|
||||||
|
Relay queue-full encounters over the last fifteen minutes:
|
||||||
|
|
||||||
|
```promql
|
||||||
|
sum by (job, instance) (increase(wakey_terminal_backpressure_total[15m]))
|
||||||
|
```
|
||||||
|
|
||||||
|
This counts encounters, not their duration and not dropped chunks. A burst of
|
||||||
|
image output can raise it while output still arrives intact through backpressure.
|
||||||
|
|
||||||
|
Average duration of completed backpressure episodes, in seconds:
|
||||||
|
|
||||||
|
```promql
|
||||||
|
sum by (job, instance, outcome) (
|
||||||
|
rate(wakey_operation_duration_seconds_sum{operation="terminal_backpressure_wait"}[5m])
|
||||||
|
)
|
||||||
|
/
|
||||||
|
sum by (job, instance, outcome) (
|
||||||
|
rate(wakey_operation_duration_seconds_count{operation="terminal_backpressure_wait"}[5m])
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
An episode starts when the output channel is full and ends when the pending
|
||||||
|
backlog drains or the relay disconnects. It is not per-frame network latency;
|
||||||
|
unfinished episodes do not appear until they end. Keep the outcome grouping so
|
||||||
|
disconnected episodes are not confused with successfully drained ones.
|
||||||
|
|
||||||
|
## Snapshots
|
||||||
|
|
||||||
|
Device entries in successfully sent snapshots over the last hour:
|
||||||
|
|
||||||
|
```promql
|
||||||
|
sum by (job, instance) (increase(wakey_snapshot_devices_total[1h]))
|
||||||
|
```
|
||||||
|
|
||||||
|
Repeated snapshots count the same devices again. This is collection volume,
|
||||||
|
**not unique devices, current fleet size, or confirmed CC persistence**.
|
||||||
|
|
||||||
|
## Current Devices And Endpoints
|
||||||
|
|
||||||
|
These gauges are emitted by CC. Counts are sampled at startup and every 30 seconds,
|
||||||
|
independently of UI requests, and exported on the normal metrics schedule. Allow
|
||||||
|
up to two intervals for a change to appear. The snapshot counter above cannot
|
||||||
|
substitute for these gauges.
|
||||||
|
|
||||||
|
A **device** is a grouped device identity. An **endpoint** is one observed address
|
||||||
|
entry with an IP, optional MAC/interface, source, and presence. One PC can have
|
||||||
|
multiple IPv4/IPv6 endpoints, and different sources or agents can observe the
|
||||||
|
same address. An endpoint here is not an HTTP API route.
|
||||||
|
|
||||||
|
| Panel | Scope | Suggested visualization |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Stored devices by agent | Device rows currently retained in CC for each agent; not globally unique identities | Bar gauge, one value per agent |
|
||||||
|
| Stored endpoints by agent | Endpoint rows currently retained in CC for each agent, including inactive entries; not unique IPs | Bar gauge, one value per agent |
|
||||||
|
| Fleet devices | Grouped fleet identities, split into saved and unsaved | Stat, one value per category |
|
||||||
|
|
||||||
|
Stored device rows by agent:
|
||||||
|
|
||||||
|
```promql
|
||||||
|
sum by (job, agent_id) (wakey_stored_devices{job="wakey-control-plane"})
|
||||||
|
```
|
||||||
|
|
||||||
|
Stored endpoint rows by agent:
|
||||||
|
|
||||||
|
```promql
|
||||||
|
sum by (job, agent_id) (wakey_stored_endpoints{job="wakey-control-plane"})
|
||||||
|
```
|
||||||
|
|
||||||
|
Grouped fleet devices by manual identity status:
|
||||||
|
|
||||||
|
```promql
|
||||||
|
sum by (job, identity) (wakey_fleet_devices{job="wakey-control-plane"})
|
||||||
|
```
|
||||||
|
|
||||||
|
Adjust the `job` selector to your actual service label. These queries assume one
|
||||||
|
CC owns the fleet; do not sum replicas of the same database as distinct devices.
|
||||||
|
Enrolled agents with no stored rows report zero. After revocation, an agent can
|
||||||
|
remain represented while its device rows are retained. When neither enrollment
|
||||||
|
nor stored rows exist, the sampler stops reporting that agent label.
|
||||||
|
|
||||||
|
Use Grafana's **short** unit with zero decimal places. Read the gauges directly;
|
||||||
|
do not apply `rate` or `increase`. Counts must reflect unfiltered CC state, not
|
||||||
|
the last UI search or visibility filter. An agent going offline does not by
|
||||||
|
itself delete its stored rows, so these counts are not online-device counts.
|
||||||
|
|
||||||
|
Do not sum per-agent device counts and label that value "unique fleet devices":
|
||||||
|
the same device can be observed by more than one agent. Likewise, an endpoint
|
||||||
|
count is not a count of distinct IP addresses. Saved/unsaved describes manual
|
||||||
|
identity management, not online/offline/unknown presence.
|
||||||
|
|
||||||
|
Failed refreshes suppress observations until sampling recovers; they do not report
|
||||||
|
zero or keep exporting an old sample. Prometheus may still show its last value
|
||||||
|
until that series becomes stale. Export callbacks only read the in-memory sample;
|
||||||
|
they never wait on database work. Sampling is disabled when OTLP is disabled.
|
||||||
|
|
||||||
|
## Empty or Strange Results
|
||||||
|
|
||||||
|
- No series: generate the relevant activity, wait for export, and check the metrics pipeline.
|
||||||
|
- Different names: use the discovery query; collector translation can change suffixes and labels.
|
||||||
|
- `NaN` average: no operations occurred in the window, so the denominator is zero. Show a gap, not a fake zero latency.
|
||||||
|
- Empty `rate`: it needs multiple samples. Try a longer window and check the time range.
|
||||||
|
- Fractional `increase`: Prometheus extrapolates to the window boundaries; event counts can therefore appear fractional.
|
||||||
|
- Stale active count: the last exported value is not an immediate liveness check after a crash or network loss.
|
||||||
|
- No `instance` label: CC does not currently set a service instance ID; agent telemetry does.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [Wakey telemetry setup and limitations](telemetry.md)
|
||||||
|
- [Prometheus rate, increase, and histogram_quantile](https://prometheus.io/docs/prometheus/latest/querying/functions/)
|
||||||
|
- [Alloy OTLP-to-Prometheus conversion](https://grafana.com/docs/alloy/latest/reference/components/otelcol/otelcol.exporter.prometheus/)
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
# Telemetry
|
||||||
|
|
||||||
|
CC and the agent daemon share the optional `wakey-core/telemetry` module.
|
||||||
|
Local logs remain enabled. Configure an OTLP **gRPC** receiver to additionally
|
||||||
|
export traces, structured logs, and metrics; HTTP/4318 is not supported in this slice.
|
||||||
|
|
||||||
|
CC configuration:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[telemetry]
|
||||||
|
otlp_endpoint = "http://127.0.0.1:4317"
|
||||||
|
authorization_env = "WAKEY_OTLP_AUTHORIZATION"
|
||||||
|
service_name = "wakey-control-plane"
|
||||||
|
json_logs = true
|
||||||
|
```
|
||||||
|
|
||||||
|
Agent configuration (replace the example with the collector's WireGuard address):
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[telemetry]
|
||||||
|
otlp_endpoint = "http://10.5.0.1:4317"
|
||||||
|
authorization_env = "WAKEY_OTLP_AUTHORIZATION"
|
||||||
|
service_name = "wakey-agent"
|
||||||
|
json_logs = false
|
||||||
|
```
|
||||||
|
|
||||||
|
Omit `otlp_endpoint` to disable exporting. Omit `authorization_env` for a receiver
|
||||||
|
that requires no authentication. Existing configs need no changes.
|
||||||
|
An empty agent service name uses `wakey-agent`; its enrolled agent ID becomes
|
||||||
|
the resource's `service.instance.id`, not a metric label. Telemetry config changes
|
||||||
|
require a daemon restart, not SIGHUP. Short-lived enroll/reload/observe commands
|
||||||
|
only log locally and do not initialize exporters.
|
||||||
|
|
||||||
|
## Receiver and filtering
|
||||||
|
|
||||||
|
The collector must enable OTLP receivers in **traces, logs, and metrics** pipelines.
|
||||||
|
Loopback is sufficient for CC on the same host/network namespace. The router
|
||||||
|
needs a collector listener reachable over WireGuard; do not expose an unauthenticated
|
||||||
|
receiver on the public Internet. This initial setup assumes plaintext inside that
|
||||||
|
tunnel. `json_logs` changes local output,
|
||||||
|
not whether OTLP logs are exported.
|
||||||
|
|
||||||
|
## Credentials and services
|
||||||
|
|
||||||
|
The OpenWrt agent uses `/etc/config/wakey`; see the
|
||||||
|
[UCI setup guide](openwrt-uci.md) for commands and permissions.
|
||||||
|
The CC uses a systemd environment file as described below.
|
||||||
|
|
||||||
|
`authorization_env` names an environment variable containing the complete header:
|
||||||
|
`Basic <base64(username:password)>` or `Bearer <token>`. With exporting enabled,
|
||||||
|
missing/empty/invalid values fail startup; credentials are never written to TOML
|
||||||
|
or included in error messages. Metadata is marked sensitive for Debug output.
|
||||||
|
Base64 is not encryption: use the WireGuard/SSH tunnel or another protected transport.
|
||||||
|
|
||||||
|
The systemd CC reads the optional `/etc/wakey-control-plane/environment` file.
|
||||||
|
|
||||||
|
Use this portable literal format, **without quotes or `export`**:
|
||||||
|
|
||||||
|
```text
|
||||||
|
# Replace this placeholder with the actual encoded credential locally.
|
||||||
|
WAKEY_OTLP_AUTHORIZATION=Basic BASE64_OF_USERNAME_COLON_PASSWORD
|
||||||
|
RUST_LOG=info
|
||||||
|
```
|
||||||
|
|
||||||
|
Protect the file with owner `root:root` and mode `0600`. systemd's manager reads it
|
||||||
|
before switching to the service user. Use the simple format above, with LF endings
|
||||||
|
and no inline comments or multiline values.
|
||||||
|
An absent file is allowed; configuring auth without providing its variable is not.
|
||||||
|
|
||||||
|
After changing the file, **restart** the service. On systemd, run `daemon-reload`
|
||||||
|
once after installing the updated unit. OpenWrt's HUP reload does not reload the
|
||||||
|
process environment. For a foreground WSL test, export the variable in that shell
|
||||||
|
and use `http://127.0.0.1:4317` with the SSH tunnel; do not put credentials in CLI args.
|
||||||
|
|
||||||
|
Console logs, OTLP logs, and traces can have independent filters:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[telemetry]
|
||||||
|
console_filter = "info"
|
||||||
|
otlp_log_filter = "debug"
|
||||||
|
trace_filter = "info"
|
||||||
|
```
|
||||||
|
|
||||||
|
For each signal, an explicit filter wins over `RUST_LOG`, which wins over CLI
|
||||||
|
verbosity (`-v`). With no filters, environment override, or verbosity flags, the
|
||||||
|
application defaults remain info-level with SDK warnings. Metrics are unaffected.
|
||||||
|
Explicit filters must be valid and nonempty; changing them requires a restart.
|
||||||
|
Without explicit filters, `RUST_LOG=warn` suppresses informational spans and logs.
|
||||||
|
Use `wakey_agent=info,wakey=info,wakey_core=info,opentelemetry=warn` for the agent and
|
||||||
|
`wakey_control_plane=info,wakey_core=info,opentelemetry=warn` for CC. Exporter warnings
|
||||||
|
remain local to prevent export-failure feedback loops. Both OTLP logs and traces
|
||||||
|
exclude OpenTelemetry SDK targets and the `h2`, `hyper`, `hyper_util`, `tonic`
|
||||||
|
and `tower` crate targets, even under broad debug filters. These transport
|
||||||
|
crates also serve application traffic: their diagnostics stay console-only,
|
||||||
|
while Wakey's command/storage/relay spans still export. Console filters remain
|
||||||
|
independent. Increasing verbosity exports application debug events too; inspect
|
||||||
|
them before enabling it broadly.
|
||||||
|
|
||||||
|
## Signals
|
||||||
|
|
||||||
|
- `relay_command` on CC propagates W3C context in an optional command field.
|
||||||
|
`execute_command` on the agent adopts it; older messages/agents remain compatible.
|
||||||
|
- `collect_device_snapshot` and `store_device_snapshot` measure collection and
|
||||||
|
persistence as linked spans across the agent WebSocket. The snapshot carries
|
||||||
|
optional W3C context from collection; CC adopts it after authentication checks.
|
||||||
|
Periodic and requested syncs use the same path. Old messages and empty/invalid
|
||||||
|
context remain valid but do not create a cross-process link. Both processes
|
||||||
|
need tracing enabled and suitable filters/sampling to see both sides.
|
||||||
|
- Terminal create/attach/close/resume spans describe lifecycle operations, not
|
||||||
|
output chunks. Trace-linked OTLP logs use the SDK's tracing-context bridge.
|
||||||
|
- Inventory source spans separate neighbor, DHCP lease, and hook-store reads
|
||||||
|
from merging. `db_*` spans cover snapshot replacement, fleet reads, and manual
|
||||||
|
identity changes, including transaction waits and result assembly where applicable.
|
||||||
|
Snapshot replacement retains `db_replace_agent_snapshot` as its total duration,
|
||||||
|
with four sequential child spans:
|
||||||
|
- `db_pool_acquire`: obtaining a pooled connection, including any connection
|
||||||
|
setup/checks; it is not exclusively queue wait.
|
||||||
|
- `db_write_transaction_begin`: executing `BEGIN IMMEDIATE`, including SQLite
|
||||||
|
writer-lock wait and worker scheduling, not a direct lock-wait measurement.
|
||||||
|
- `db_snapshot_write_rows`: existing-key lookup, normalization, parent/child
|
||||||
|
writes, and deletion of devices missing from the complete snapshot.
|
||||||
|
- `db_snapshot_commit`: transaction commit, including its SQLite/worker work;
|
||||||
|
this does not isolate filesystem flush time.
|
||||||
|
Pool and transaction-begin spans come from the shared write helper, so they
|
||||||
|
also appear under other write operations. No per-row spans or SQL values are
|
||||||
|
recorded. A failed write rolls back through SQLx and has no commit span;
|
||||||
|
fleet cache invalidation still happens only after a successful commit.
|
||||||
|
- Fleet spans cover list/load/grouping, refresh fan-out with per-agent outcomes,
|
||||||
|
and wake dispatch. Counts distinguish grouped, filtered, and returned rows;
|
||||||
|
wake events identify the chosen agent and whether the route was explicit,
|
||||||
|
without recording device identifiers or addresses.
|
||||||
|
- Connectivity uses short `agent_dns`, `agent_connect`, `agent_handshake_send`,
|
||||||
|
and CC `agent_auth_verify` spans, rather than connection-long spans. Connect
|
||||||
|
attempts also contribute to operation metrics. Lifecycle events report retries
|
||||||
|
with backoff and CC disconnect duration. Sending credentials is not an auth ACK;
|
||||||
|
CC's authentication event is the authoritative result. DNS diagnostics are a
|
||||||
|
separate lookup, not a measurement of the resolver inside WebSocket connect.
|
||||||
|
- Terminal recovery uses `terminal_snapshot_generate` (encoded byte count),
|
||||||
|
transport-attachment spans, and `terminal_snapshot_request`. These measure
|
||||||
|
local work, not browser restoration or end-to-end image replay. Resume operation
|
||||||
|
success means credentials were queued for the worker. `terminal_backpressure_wait`
|
||||||
|
operation durations cover one full-queue backlog episode until it drains (`ok`)
|
||||||
|
or disconnects (`disconnected`); cancellation can retain the default `error` outcome.
|
||||||
|
|
||||||
|
Instrument operation boundaries rather than individual rows or terminal chunks.
|
||||||
|
Use `skip_all`, explicitly allow count fields, and instrument async futures rather
|
||||||
|
than holding entered span guards across awaits. Do not record query arguments,
|
||||||
|
SQL parameters, device payloads, or credentials. Span names are fixed across requests.
|
||||||
|
|
||||||
|
Metrics are exported every 30 seconds and at orderly shutdown:
|
||||||
|
|
||||||
|
The table uses OpenTelemetry instrument names. Prometheus conversion typically
|
||||||
|
replaces dots with underscores and adds type/unit suffixes; histograms produce
|
||||||
|
multiple series. See the [metrics recipe book](telemetry-recipes.md) for discovery
|
||||||
|
queries, rates, averages, percentiles, and Grafana units.
|
||||||
|
|
||||||
|
| Metric | Meaning |
|
||||||
|
| --- | --- |
|
||||||
|
| `wakey.operations` | Completed command/snapshot attempts, labeled by operation and outcome |
|
||||||
|
| `wakey.operation.duration` | Corresponding duration histogram in seconds |
|
||||||
|
| `wakey.snapshot.devices` | Cumulative devices in snapshots successfully sent by the agent |
|
||||||
|
| `wakey.stored.devices` | CC gauge of retained device rows, labeled by `agent_id` |
|
||||||
|
| `wakey.stored.endpoints` | CC gauge of retained endpoint rows, including inactive entries, labeled by `agent_id` |
|
||||||
|
| `wakey.fleet.devices` | CC gauge of unfiltered grouped identities, labeled by `identity` (`saved` or `unsaved`) |
|
||||||
|
| `wakey.process.uptime` | Linux gauge: seconds since this process's kernel-reported start, including suspend |
|
||||||
|
| `wakey.process.memory.resident` | Linux gauge: approximate resident memory in bytes, from VmRSS |
|
||||||
|
| `wakey.process.cpu.time` | Linux counter: cumulative user + system CPU seconds for all process threads, excluding children |
|
||||||
|
| `wakey.process.open_fds` | Linux gauge: current count of open process file descriptors |
|
||||||
|
| `wakey.host.uptime` | Linux gauge: uptime in seconds reported by the process's procfs view, including suspend |
|
||||||
|
| `wakey.host.memory.total` | Linux gauge: total usable RAM in bytes from the visible meminfo |
|
||||||
|
| `wakey.host.memory.available` | Linux gauge: estimated available RAM in bytes; omitted if MemAvailable is unavailable |
|
||||||
|
| `wakey.host.load` | Linux gauge: load average, with fixed `period` labels `1m`, `5m`, `15m` |
|
||||||
|
| `wakey.terminal.active` | Agent PTY workers currently alive |
|
||||||
|
| `wakey.terminal.input.bytes` | Input bytes received by the terminal relay |
|
||||||
|
| `wakey.terminal.output.bytes` | Output bytes read from the PTY, including while detached |
|
||||||
|
| `wakey.terminal.backpressure` | Relay queue-full encounters, not dropped output |
|
||||||
|
|
||||||
|
Metric labels do not include request/session IDs, MACs, or IPs. New spans use
|
||||||
|
`skip_all` and explicitly select safe fields; terminal bytes and credentials are
|
||||||
|
not attached. Profiles, host CPU-utilization metrics, connected-agent gauges, and export-loss
|
||||||
|
dashboards are not part of this initial slice.
|
||||||
|
|
||||||
|
Process and host gauges are registered automatically for Linux agent/CC daemons
|
||||||
|
when OTLP is enabled. `wakey-core` owns the OS-independent instruments and
|
||||||
|
`ProcessMetricsSource` interface; `wakey-linux` supplies the procfs reader via
|
||||||
|
its optional `telemetry` feature. Each daemon attaches that reader to its
|
||||||
|
telemetry guard, so core has no Linux dependency or filesystem reads.
|
||||||
|
Their small procfs reads run during the existing 30-second
|
||||||
|
metric collection and final shutdown collection; no independent poller or task
|
||||||
|
is started. Non-Linux builds omit these gauges. Missing/unreadable procfs data
|
||||||
|
omits the affected observation instead of reporting zero or failing the daemon.
|
||||||
|
No hostname, PID, command line or environment values are exported by this slice.
|
||||||
|
Service/instance resource attributes identify each daemon; two daemons on the
|
||||||
|
same host can report the same host uptime, so do not sum it across processes.
|
||||||
|
CPU time is not elapsed time or CPU percentage: use its rate for consumption.
|
||||||
|
Open-FD sampling can temporarily open descriptors counted by the measurement.
|
||||||
|
Process metrics describe the daemon itself, not its child shells/PTY programs
|
||||||
|
or the entire host. CPU/FD collection is also supplied by `wakey-linux`;
|
||||||
|
unsupported platform readers may omit these optional measurements.
|
||||||
|
Host memory/load come from the same optional Linux reader. `MemAvailable`
|
||||||
|
estimates memory reusable without swapping and is not `MemFree`; no fallback
|
||||||
|
is invented on older kernels. Load averages include runnable and uninterruptible
|
||||||
|
tasks, so they are not CPU percentages. These are the visible kernel/procfs
|
||||||
|
figures, not cgroup memory limits or container-specific load measurements;
|
||||||
|
multiple daemons may export duplicate views of the same host.
|
||||||
|
|
||||||
|
RSS includes anonymous, file-backed and shared resident pages, not just heap,
|
||||||
|
and is an approximate kernel measurement. Process age uses procfs start ticks
|
||||||
|
and uptime, not wall-clock subtraction or telemetry initialization time.
|
||||||
|
Container/time namespaces can change the visible host clock; this is the
|
||||||
|
process's Linux view, not an assertion about a physical machine's boot time.
|
||||||
|
The Linux parser is [procfs](https://docs.rs/procfs/0.18.0/procfs/), with optional
|
||||||
|
default features disabled. Public `/healthz` remains unchanged.
|
||||||
|
|
||||||
|
Current-state fleet gauges sample independently every 30 seconds, with bounded
|
||||||
|
revision retries to avoid mixed-state counts. Enrolled empty agents report zero;
|
||||||
|
removed labels disappear when no enrollment or retained rows remain. Failed samples
|
||||||
|
are omitted, not converted to zeros. No per-device labels are emitted. See the
|
||||||
|
recipe book for scopes, queries, and the distinction between devices and endpoints.
|
||||||
|
|
||||||
|
## Failure policy
|
||||||
|
|
||||||
|
Trace and log queues are capped at 256 records each, with export batches of 64
|
||||||
|
and a 3-second request timeout. Export happens off the application path. The
|
||||||
|
OTLP exporters use their recommended retry policy: up to three retries after
|
||||||
|
the first attempt, exponential backoff and jitter, within the exporter timeout
|
||||||
|
budget. Temporary transport/receiver failures can recover; authentication
|
||||||
|
rejection and partial-success responses are not retried. Requests already in
|
||||||
|
flight can exceed the retry budget, so it is not a hard wall-clock deadline.
|
||||||
|
The application does not wrap exports in another retry loop. On an
|
||||||
|
outage, telemetry can be dropped rather than accumulating unbounded router RAM;
|
||||||
|
terminal output follows its existing lossless backpressure policy independently.
|
||||||
|
|
||||||
|
Automated receiver tests reject the first export of each signal and confirm
|
||||||
|
recovery, check the retry-attempt limit on a persistent outage, and confirm that
|
||||||
|
bad credentials cause only one attempt. These tests do not establish durable
|
||||||
|
delivery during a long outage; bounded queues can still drop telemetry.
|
||||||
|
The bound is a record count, not an absolute byte limit on arbitrary log messages.
|
||||||
|
Providers flush on orderly daemon exit (Ctrl-C/SIGTERM), on a blocking worker while
|
||||||
|
Tokio remains alive. Only completed spans are exported; this is not graceful draining
|
||||||
|
of every in-flight request. SIGKILL/power loss cannot flush. This is not a persistent
|
||||||
|
telemetry spool.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo test -p wakey-core --features telemetry --test telemetry
|
||||||
|
```
|
||||||
|
|
||||||
|
The test starts a temporary gRPC collector and verifies all three signals,
|
||||||
|
command parent/child IDs, log correlation, and shutdown delivery without sleeps.
|
||||||
@@ -53,6 +53,9 @@ Use netlink only when:
|
|||||||
|
|
||||||
Today that mainly applies to `neighbor::nl`.
|
Today that mainly applies to `neighbor::nl`.
|
||||||
|
|
||||||
|
`wakey-linux` explicitly uses netlink for neighbor and address inventory on its
|
||||||
|
Unix-gated path, without automatic JSON fallback.
|
||||||
|
|
||||||
## Relationship to wakey
|
## Relationship to wakey
|
||||||
|
|
||||||
`wakey` is the product.
|
`wakey` is the product.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
//! rtnetlink-based neighbor table query. One syscall, filter in userspace.
|
//! rtnetlink-based neighbor query: dump links and neighbors, then filter in userspace.
|
||||||
#![cfg(unix)]
|
#![cfg(unix)]
|
||||||
use std::{
|
use std::{
|
||||||
collections::{HashMap, HashSet},
|
collections::{HashMap, HashSet},
|
||||||
@@ -75,7 +75,7 @@ pub async fn get_with_handle(
|
|||||||
NeighbourAddress::Inet6(v6) => ip = Some(IpAddr::from(v6)),
|
NeighbourAddress::Inet6(v6) => ip = Some(IpAddr::from(v6)),
|
||||||
_ => continue 'row,
|
_ => continue 'row,
|
||||||
},
|
},
|
||||||
NeighbourAttribute::LinkLocalAddress(bytes) => {
|
NeighbourAttribute::LinkLayerAddress(bytes) => {
|
||||||
mac = match bytes.len() {
|
mac = match bytes.len() {
|
||||||
6 => bytes.first_chunk::<6>().map(|&b| MacAddr::from(b)),
|
6 => bytes.first_chunk::<6>().map(|&b| MacAddr::from(b)),
|
||||||
8 => bytes.first_chunk::<8>().map(|&b| MacAddr::from(b)),
|
8 => bytes.first_chunk::<8>().map(|&b| MacAddr::from(b)),
|
||||||
|
|||||||
Regular → Executable
+16
-1
@@ -14,7 +14,22 @@ pscp.exe -l root -scp -pw $PASSWD -r 192.168.100.1:/etc/ldlda_help $PSScriptRoot
|
|||||||
pscp.exe -l root -scp -pw $PASSWD 192.168.100.1:/etc/rc.local $PSScriptRoot
|
pscp.exe -l root -scp -pw $PASSWD 192.168.100.1:/etc/rc.local $PSScriptRoot
|
||||||
pscp.exe -l root -scp -pw $PASSWD -r 192.168.100.1:/etc/wakey-agent $PSScriptRoot
|
pscp.exe -l root -scp -pw $PASSWD -r 192.168.100.1:/etc/wakey-agent $PSScriptRoot
|
||||||
|
|
||||||
Remove-Item -Recurse (Join-Path $PSScriptRoot "root")
|
$uciDir = Join-Path $PSScriptRoot 'uci-config'
|
||||||
|
New-Item -ItemType Directory -Force -Path $uciDir | Out-Null
|
||||||
|
# This ignored backup contains credentials; do not add it to version control.
|
||||||
|
pscp.exe -l root -scp -pw $PASSWD 192.168.100.1:/etc/config/wakey (Join-Path $uciDir 'wakey')
|
||||||
|
|
||||||
|
$rootBackup = Join-Path $PSScriptRoot 'root'
|
||||||
|
if (Test-Path -LiteralPath $rootBackup) {
|
||||||
|
# Router backups include hidden/read-only files. Fail once if deletion is
|
||||||
|
# genuinely blocked rather than merging a fresh copy into a partial backup.
|
||||||
|
try {
|
||||||
|
Remove-Item -LiteralPath $rootBackup -Recurse -Force -ErrorAction Stop
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
throw "Cannot remove previous router backup '$rootBackup': $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
}
|
||||||
pscp.exe -l root -scp -pw $PASSWD -r 192.168.100.1:/root $PSScriptRoot
|
pscp.exe -l root -scp -pw $PASSWD -r 192.168.100.1:/root $PSScriptRoot
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ This folder has small helpers for build, CI, and router install. Keep it simple;
|
|||||||
- `dev_push.ps1` — Fast dev loop: build + upload to router. Usage:
|
- `dev_push.ps1` — Fast dev loop: build + upload to router. Usage:
|
||||||
- `./scripts/dev_push.ps1 -Pass <password> [-HostName <ip>] [-RemotePath </root/.bin/wakey>] [-Restart] [-Quiet]`
|
- `./scripts/dev_push.ps1 -Pass <password> [-HostName <ip>] [-RemotePath </root/.bin/wakey>] [-Restart] [-Quiet]`
|
||||||
- Uploads to `<RemotePath>.tmp` then atomically moves into place; `-Restart` restarts the service; `-Quiet` silences MOTD.
|
- Uploads to `<RemotePath>.tmp` then atomically moves into place; `-Restart` restarts the service; `-Quiet` silences MOTD.
|
||||||
|
- Defaults to both binaries plus init/hooks, with restart enabled. Use `-Restart:$false` to leave the service running, or `-Quiet:$false` for deployment output. Config files are untouched.
|
||||||
|
- Binary-only agent update: `./scripts/dev_push.ps1 -HostName router -BinariesOnly -Package wakey-agent -Quiet:$false`
|
||||||
|
- Use `-BinariesOnly` without `-Package` for both binaries, or `-Package wakey` for only the CLI. Cargo still builds the selected package's dependencies.
|
||||||
|
- The checked-in `remote_deploy_wakey.sh` is streamed over SSH to `sh -s -- --batch ...` in all modes, with the deployment plan passed as quoted arguments. No remote helper is uploaded or required. Its original single-binary invocation remains supported. The shared `Invoke-Ssh -Cmd 'sh -s' -Stdin $script` supports stdin scripts for other callers.
|
||||||
- `package_rootfs.ps1` — Produces `dist/wakey-rootfs-<version>-<target>.tgz` with `/root/.bin/wakey` and `/etc/init.d/*`.
|
- `package_rootfs.ps1` — Produces `dist/wakey-rootfs-<version>-<target>.tgz` with `/root/.bin/wakey` and `/etc/init.d/*`.
|
||||||
- `update_wakey_cc.sh` — Linux VPS updater for control-plane bundles; extracts into the selected directory (default: current directory) and restarts `wakey-cc.service`.
|
- `update_wakey_cc.sh` — Linux VPS updater for control-plane bundles; extracts into the selected directory (default: current directory) and restarts `wakey-cc.service`.
|
||||||
- `update_wakey_cc_from_repo.sh` — Linux VPS source updater; clones repo to temp dir, builds UI + control-plane locally, packages bundle tgz, installs into selected directory, and restarts `wakey-cc.service`.
|
- `update_wakey_cc_from_repo.sh` — Linux VPS source updater; clones repo to temp dir, builds UI + control-plane locally, packages bundle tgz, installs into selected directory, and restarts `wakey-cc.service`.
|
||||||
@@ -144,6 +148,14 @@ Implementation notes:
|
|||||||
|
|
||||||
## CI (GitHub)
|
## CI (GitHub)
|
||||||
|
|
||||||
|
- `.github/workflows/release.yml` builds router binaries, UI, x86-64 CC and
|
||||||
|
ARM64 CC in parallel. CC uses matching Ubuntu 24.04 runners and native Cargo;
|
||||||
|
Cross is used only for the ARMv7-musl router binaries. A final job downloads
|
||||||
|
their artifacts, packages CC bundles and publishes after every build succeeds.
|
||||||
|
- GitHub's native GNU binaries inherit its runner's glibc requirements. Use the
|
||||||
|
Gitea bundles for older deployment hosts, checking that its build actually
|
||||||
|
used Zig's configured baseline. Gitea retains one job for `--once` runners.
|
||||||
|
|
||||||
- Defined in `.github/workflows/ci.yml` on pushes/PRs to `main` or `master`:
|
- Defined in `.github/workflows/ci.yml` on pushes/PRs to `main` or `master`:
|
||||||
- **`rust` job:** `cargo fmt --check`, `clippy -D warnings`, `cargo test --workspace` on `ubuntu-latest`.
|
- **`rust` job:** `cargo fmt --check`, `clippy -D warnings`, `cargo test --workspace` on `ubuntu-latest`.
|
||||||
- **`ui` job:** under `ui/`, `pnpm install --frozen-lockfile`, then `pnpm run typecheck`, `format:check`, and `build` (mirrors what you need before packaging the control-plane bundle). GitHub sets `CI=true` for the runner; pnpm uses lockfile `ui/pnpm-lock.yaml` for cache.
|
- **`ui` job:** under `ui/`, `pnpm install --frozen-lockfile`, then `pnpm run typecheck`, `format:check`, and `build` (mirrors what you need before packaging the control-plane bundle). GitHub sets `CI=true` for the runner; pnpm uses lockfile `ui/pnpm-lock.yaml` for cache.
|
||||||
|
|||||||
@@ -67,6 +67,12 @@ From Windows PowerShell, using the WSL wrapper:
|
|||||||
|
|
||||||
Path control (binary and config are independent):
|
Path control (binary and config are independent):
|
||||||
|
|
||||||
|
When called from PowerShell inside WSL, absolute POSIX paths pass through
|
||||||
|
unchanged. Windows paths are converted using the selected distro's `wslpath`.
|
||||||
|
Relative runner home, binary and config paths resolve from the current PowerShell
|
||||||
|
directory. Conversion also supports paths that have not been created yet.
|
||||||
|
The helper, runner home and config paths must be accessible in that distro.
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
./scripts/act_runner_wsl.ps1 -Action status `
|
./scripts/act_runner_wsl.ps1 -Action status `
|
||||||
-RunnerBin /home/lda/gitea-runner/act_runner `
|
-RunnerBin /home/lda/gitea-runner/act_runner `
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
param(
|
param(
|
||||||
[ValidateSet("update", "register", "start", "stop", "status")]
|
[ValidateSet("update", "register", "start", "stop", "status")]
|
||||||
[string]$Action = "start",
|
[string]$Action = "start",
|
||||||
[string]$Distro = $(if ($env:WAKEY_WSL_DISTRO) { $env:WAKEY_WSL_DISTRO } else { "FedoraLinux-43" }),
|
[string]$Distro = $( $env:WAKEY_WSL_DISTRO ?? "FedoraLinux-43" ),
|
||||||
[string]$RunnerHome,
|
[string]$RunnerHome,
|
||||||
[string]$RunnerBin = "/home/lda/gitea-runner/act_runner",
|
[string]$RunnerBin = "/home/lda/gitea-runner/act_runner",
|
||||||
[string]$ConfigPath,
|
[string]$ConfigPath,
|
||||||
@@ -22,7 +22,7 @@ if (-not (Test-Path $scriptWin)) {
|
|||||||
throw "Missing Fedora runner helper: $scriptWin"
|
throw "Missing Fedora runner helper: $scriptWin"
|
||||||
}
|
}
|
||||||
|
|
||||||
$scriptLinux = (& wsl.exe -d $Distro -e wslpath -a "$scriptWin").Trim()
|
$scriptLinux = ConvertTo-WslPath -WindowsPath $scriptWin -Distro $Distro
|
||||||
if (-not $scriptLinux) {
|
if (-not $scriptLinux) {
|
||||||
throw "Failed to resolve WSL path for $scriptWin"
|
throw "Failed to resolve WSL path for $scriptWin"
|
||||||
}
|
}
|
||||||
@@ -30,21 +30,19 @@ if (-not $scriptLinux) {
|
|||||||
if (-not $RunnerHome) {
|
if (-not $RunnerHome) {
|
||||||
$repoRootWin = Split-Path -Parent $PSScriptRoot
|
$repoRootWin = Split-Path -Parent $PSScriptRoot
|
||||||
$defaultRunnerHomeWin = Join-Path $repoRootWin "ar_data/wsl_runner"
|
$defaultRunnerHomeWin = Join-Path $repoRootWin "ar_data/wsl_runner"
|
||||||
$RunnerHome = (& wsl.exe -d $Distro -e wslpath -a "$defaultRunnerHomeWin").Trim()
|
$RunnerHome = $defaultRunnerHomeWin
|
||||||
if (-not $RunnerHome) {
|
|
||||||
throw "Failed to resolve WSL runner home for $defaultRunnerHomeWin"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (-not $ConfigPath) {
|
if (-not $ConfigPath) {
|
||||||
$repoRootWin = Split-Path -Parent $PSScriptRoot
|
$repoRootWin = Split-Path -Parent $PSScriptRoot
|
||||||
$defaultConfigWin = Join-Path $repoRootWin "ar_data/wsl_runner/config.yaml"
|
$defaultConfigWin = Join-Path $repoRootWin "ar_data/wsl_runner/config.yaml"
|
||||||
$ConfigPath = (& wsl.exe -d $Distro -e wslpath -a "$defaultConfigWin").Trim()
|
$ConfigPath = $defaultConfigWin
|
||||||
if (-not $ConfigPath) {
|
|
||||||
throw "Failed to resolve WSL config path for $defaultConfigWin"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$RunnerHome = ConvertTo-WslPath -Path $RunnerHome -Distro $Distro
|
||||||
|
$RunnerBin = ConvertTo-WslPath -Path $RunnerBin -Distro $Distro
|
||||||
|
$ConfigPath = ConvertTo-WslPath -Path $ConfigPath -Distro $Distro
|
||||||
|
|
||||||
$parts = @("bash", (Quote-ShArg $scriptLinux), (Quote-ShArg $Action), "--runner-home", (Quote-ShArg $RunnerHome))
|
$parts = @("bash", (Quote-ShArg $scriptLinux), (Quote-ShArg $Action), "--runner-home", (Quote-ShArg $RunnerHome))
|
||||||
$parts += @("--runner-bin", (Quote-ShArg $RunnerBin), "--config-path", (Quote-ShArg $ConfigPath))
|
$parts += @("--runner-bin", (Quote-ShArg $RunnerBin), "--config-path", (Quote-ShArg $ConfigPath))
|
||||||
if ($ServerUrl) { $parts += @("--server-url", (Quote-ShArg $ServerUrl)) }
|
if ($ServerUrl) { $parts += @("--server-url", (Quote-ShArg $ServerUrl)) }
|
||||||
|
|||||||
+80
-95
@@ -4,6 +4,9 @@
|
|||||||
param(
|
param(
|
||||||
[ValidateSet("cargo", "cross")]
|
[ValidateSet("cargo", "cross")]
|
||||||
[string]$Cargo = "cargo",
|
[string]$Cargo = "cargo",
|
||||||
|
[ValidateSet("wakey", "wakey-agent")]
|
||||||
|
[ValidateNotNullOrEmpty()]
|
||||||
|
[string[]]$Package = @("wakey", "wakey-agent"),
|
||||||
[string]$Pass,
|
[string]$Pass,
|
||||||
[string]$HostName = "192.168.100.1",
|
[string]$HostName = "192.168.100.1",
|
||||||
[int]$Port = 22,
|
[int]$Port = 22,
|
||||||
@@ -17,6 +20,7 @@ param(
|
|||||||
[string]$HostKey,
|
[string]$HostKey,
|
||||||
[switch]$ForcePassword,
|
[switch]$ForcePassword,
|
||||||
[switch]$SkipInitScript,
|
[switch]$SkipInitScript,
|
||||||
|
[switch]$BinariesOnly,
|
||||||
[switch]$Restart = $true,
|
[switch]$Restart = $true,
|
||||||
[switch]$Quiet = $true
|
[switch]$Quiet = $true
|
||||||
)
|
)
|
||||||
@@ -26,109 +30,90 @@ try { $PSStyle.OutputRendering = 'Host' } catch {}
|
|||||||
|
|
||||||
. "$PSScriptRoot/lib.ps1"
|
. "$PSScriptRoot/lib.ps1"
|
||||||
|
|
||||||
$Pass = Get-DefaultPassword $Pass
|
function Build-Packages {
|
||||||
|
param([string[]]$Packages)
|
||||||
|
$buildArgs = @('build', '--release', '--target', $Target)
|
||||||
|
foreach ($pkg in $Packages) { $buildArgs += @('-p', $pkg) }
|
||||||
|
Write-Host "[build] $Cargo $($buildArgs -join ' ')" -ForegroundColor Cyan
|
||||||
|
& $Cargo @buildArgs
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "$Cargo build failed ($LASTEXITCODE)" }
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
function New-DeploymentPlan {
|
||||||
|
param([string[]]$Packages)
|
||||||
|
# Keep CLI first so replacing both binaries restarts the service only once.
|
||||||
|
$binaries = @(
|
||||||
|
@{ Package = 'wakey'; Name = $BinName; Destination = $RemotePath }
|
||||||
|
@{ Package = 'wakey-agent'; Name = $AgentBinName; Destination = $AgentRemotePath }
|
||||||
|
) | Where-Object { $_.Package -in $Packages } | ForEach-Object {
|
||||||
|
[pscustomobject]@{
|
||||||
|
Local = Join-Path $repoRoot "target/$Target/release/$($_.Name)"
|
||||||
|
Temp = "$($_.Destination).tmp"
|
||||||
|
Destination = $_.Destination
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$support = @(
|
||||||
|
if (-not $BinariesOnly) {
|
||||||
|
if (-not $SkipInitScript) {
|
||||||
|
@{ Source = 'init/openwrt/wakey'; Temp = '/var/tmp/wakey.init.tmp'; Destination = $RemoteInitPath }
|
||||||
|
}
|
||||||
|
@{ Source = 'hotplug/openwrt/dhcp/95-wakey'; Temp = '/var/tmp/wakey.hotplug.dhcp.tmp'; Destination = '/etc/hotplug.d/dhcp/95-wakey' }
|
||||||
|
@{ Source = 'hotplug/openwrt/neigh/95-wakey'; Temp = '/var/tmp/wakey.hotplug.neigh.tmp'; Destination = '/etc/hotplug.d/neigh/95-wakey' }
|
||||||
|
}
|
||||||
|
) | ForEach-Object {
|
||||||
|
$local = Join-Path $PSScriptRoot $_.Source
|
||||||
|
if (Test-Path -LiteralPath $local) {
|
||||||
|
[pscustomobject]@{ Local = $local; Temp = $_.Temp; Destination = $_.Destination }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach ($binary in $binaries) {
|
||||||
|
if (-not (Test-Path -LiteralPath $binary.Local)) { throw "binary not found: $($binary.Local)" }
|
||||||
|
}
|
||||||
|
[pscustomobject]@{ Binaries = @($binaries); Support = @($support) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-RemoteDeployment {
|
||||||
|
param($Plan)
|
||||||
|
$deployArgs = @('--batch')
|
||||||
|
for ($i = 0; $i -lt $Plan.Binaries.Count; $i++) {
|
||||||
|
$binary = $Plan.Binaries[$i]
|
||||||
|
$restartFlag = if ($Restart -and $i -eq $Plan.Binaries.Count - 1) { '1' } else { '0' }
|
||||||
|
$deployArgs += @('--binary', $binary.Temp, $binary.Destination, $restartFlag)
|
||||||
|
}
|
||||||
|
foreach ($file in $Plan.Support) {
|
||||||
|
$deployArgs += @('--install', $file.Temp, $file.Destination)
|
||||||
|
}
|
||||||
|
# Only data needs shell quoting; the checked-in shell program travels as stdin.
|
||||||
|
$command = 'sh -s -- ' + (($deployArgs | ForEach-Object { Quote-ShArg $_ }) -join ' ')
|
||||||
|
if ($Quiet) { $command += ' >/dev/null 2>&1' }
|
||||||
|
[pscustomobject]@{
|
||||||
|
Script = Get-Content -Raw -LiteralPath "$PSScriptRoot/remote_deploy_wakey.sh"
|
||||||
|
Command = $command
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Send-DeploymentFiles {
|
||||||
|
param($Plan)
|
||||||
|
foreach ($file in @($Plan.Binaries) + @($Plan.Support)) {
|
||||||
|
Invoke-Scp -Local $file.Local -Dest "$User@${HostName}:$($file.Temp)" -Pass $Pass -HostKey $HostKey -Port $Port -Quiet:$Quiet -ForcePassword:$ForcePassword
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$Pass = Get-DefaultPassword $Pass
|
||||||
# Backward compatibility: allow HostName in the form host:port.
|
# Backward compatibility: allow HostName in the form host:port.
|
||||||
$hostPort = Split-HostPort -HostName $HostName -DefaultPort $Port
|
$hostPort = Split-HostPort -HostName $HostName -DefaultPort $Port
|
||||||
$HostName = $hostPort.Host
|
$HostName = $hostPort.Host
|
||||||
$Port = $hostPort.Port
|
$Port = $hostPort.Port
|
||||||
|
|
||||||
function Get-DeployScript {
|
|
||||||
param($DeployPreferred, $DeployTmp, $RemoteTmp, $RemotePath, $RestartFlag)
|
|
||||||
return @"
|
|
||||||
DEPLOY=$DeployPreferred;
|
|
||||||
if [ ! -x "`$DEPLOY" ] && [ -f $DeployTmp ]; then
|
|
||||||
sed -i "s/\r$//" $DeployTmp 2>/dev/null || true
|
|
||||||
chmod +x $DeployTmp
|
|
||||||
DEPLOY=$DeployTmp
|
|
||||||
fi
|
|
||||||
sh "`$DEPLOY" $RemoteTmp $RemotePath $RestartFlag
|
|
||||||
"@
|
|
||||||
}
|
|
||||||
|
|
||||||
#---- Main Flow ----
|
|
||||||
$repoRoot = Split-Path -Parent $PSScriptRoot
|
$repoRoot = Split-Path -Parent $PSScriptRoot
|
||||||
Push-Location $repoRoot
|
Push-Location $repoRoot
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Write-Host "[build] $Cargo build --release --target $Target -p wakey -p wakey-agent" -ForegroundColor Cyan
|
Build-Packages ($Package | Select-Object -Unique)
|
||||||
. "$Cargo" build --release --target $Target -p wakey -p wakey-agent
|
$plan = New-DeploymentPlan $Package
|
||||||
if ($LASTEXITCODE -ne 0) { throw "$Cargo build failed ($LASTEXITCODE)" }
|
# Prepare before uploading so missing local helpers fail without remote changes.
|
||||||
|
$deployment = Get-RemoteDeployment $plan
|
||||||
$localBin = Join-Path $repoRoot "target/$Target/release/$BinName"
|
Send-DeploymentFiles $plan
|
||||||
$localAgentBin = Join-Path $repoRoot "target/$Target/release/$AgentBinName"
|
Invoke-Ssh -Cmd $deployment.Command -Stdin $deployment.Script -User $User -Remote $HostName -Pass $Pass -Port $Port -Quiet:$Quiet -ForcePassword:$ForcePassword
|
||||||
if (-not (Test-Path $localBin)) { throw "binary not found: $localBin" }
|
|
||||||
if (-not (Test-Path $localAgentBin)) { throw "binary not found: $localAgentBin" }
|
|
||||||
|
|
||||||
$remoteTmp = "$RemotePath.tmp"
|
|
||||||
$agentRemoteTmp = "$AgentRemotePath.tmp"
|
|
||||||
$destTmp = "$User@${HostName}:$remoteTmp"
|
|
||||||
$agentDestTmp = "$User@${HostName}:$agentRemoteTmp"
|
|
||||||
$initTmp = '/var/tmp/wakey.init.tmp'
|
|
||||||
$initDestTmp = "$User@${HostName}:$initTmp"
|
|
||||||
$localDeploy = Join-Path $repoRoot 'scripts/remote_deploy_wakey.sh'
|
|
||||||
$localInit = Join-Path $repoRoot 'scripts/init/openwrt/wakey'
|
|
||||||
$localDhcpHotplug = Join-Path $repoRoot 'scripts/hotplug/openwrt/dhcp/95-wakey'
|
|
||||||
$localNeighHotplug = Join-Path $repoRoot 'scripts/hotplug/openwrt/neigh/95-wakey'
|
|
||||||
$deployTmp = '/var/tmp/remote_deploy_wakey.sh'
|
|
||||||
$deployPreferred = '/root/.bin/remote_deploy_wakey.sh'
|
|
||||||
$dhcpHotplugTmp = '/var/tmp/wakey.hotplug.dhcp.tmp'
|
|
||||||
$neighHotplugTmp = '/var/tmp/wakey.hotplug.neigh.tmp'
|
|
||||||
|
|
||||||
# Push binaries
|
|
||||||
Invoke-Scp -Local $localBin -Dest $destTmp -Pass $Pass -HostKey $HostKey -Port $Port -Quiet:$Quiet -ForcePassword:$ForcePassword
|
|
||||||
Invoke-Scp -Local $localAgentBin -Dest $agentDestTmp -Pass $Pass -HostKey $HostKey -Port $Port -Quiet:$Quiet -ForcePassword:$ForcePassword
|
|
||||||
|
|
||||||
# Push OpenWrt init script unless explicitly skipped
|
|
||||||
if (-not $SkipInitScript -and (Test-Path $localInit)) {
|
|
||||||
Invoke-Scp -Local $localInit -Dest $initDestTmp -Pass $Pass -HostKey $HostKey -Port $Port -Quiet:$Quiet -ForcePassword:$ForcePassword
|
|
||||||
}
|
|
||||||
if (Test-Path $localDhcpHotplug) {
|
|
||||||
Invoke-Scp -Local $localDhcpHotplug -Dest "$User@${HostName}:$dhcpHotplugTmp" -Pass $Pass -HostKey $HostKey -Port $Port -Quiet:$Quiet -ForcePassword:$ForcePassword
|
|
||||||
}
|
|
||||||
if (Test-Path $localNeighHotplug) {
|
|
||||||
Invoke-Scp -Local $localNeighHotplug -Dest "$User@${HostName}:$neighHotplugTmp" -Pass $Pass -HostKey $HostKey -Port $Port -Quiet:$Quiet -ForcePassword:$ForcePassword
|
|
||||||
}
|
|
||||||
|
|
||||||
# Push deploy helper if exists
|
|
||||||
if (Test-Path $localDeploy) {
|
|
||||||
Invoke-Scp -Local $localDeploy -Dest "$User@${HostName}:$deployTmp" -Pass $Pass -HostKey $HostKey -Port $Port -Quiet:$Quiet -ForcePassword:$ForcePassword
|
|
||||||
}
|
|
||||||
|
|
||||||
# Build and run remote deploy command
|
|
||||||
$restartFlag = $(if ($Restart) { '1' } else { '0' })
|
|
||||||
$script = @"
|
|
||||||
$(Get-DeployScript $deployPreferred $deployTmp $remoteTmp $RemotePath 0)
|
|
||||||
$(Get-DeployScript $deployPreferred $deployTmp $agentRemoteTmp $AgentRemotePath $restartFlag)
|
|
||||||
$(if (-not $SkipInitScript) {
|
|
||||||
@"
|
|
||||||
if [ -f $initTmp ]; then
|
|
||||||
sed -i "s/\r$//" $initTmp 2>/dev/null || true
|
|
||||||
if command -v install >/dev/null 2>&1; then
|
|
||||||
install -m 0755 $initTmp $RemoteInitPath
|
|
||||||
else
|
|
||||||
cp -f $initTmp $RemoteInitPath
|
|
||||||
chmod 0755 $RemoteInitPath
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
"@
|
|
||||||
})
|
|
||||||
if [ -f $dhcpHotplugTmp ]; then
|
|
||||||
sed -i "s/\r$//" $dhcpHotplugTmp 2>/dev/null || true
|
|
||||||
mkdir -p /etc/hotplug.d/dhcp
|
|
||||||
cp -f $dhcpHotplugTmp /etc/hotplug.d/dhcp/95-wakey
|
|
||||||
fi
|
|
||||||
if [ -f $neighHotplugTmp ]; then
|
|
||||||
sed -i "s/\r$//" $neighHotplugTmp 2>/dev/null || true
|
|
||||||
mkdir -p /etc/hotplug.d/neigh
|
|
||||||
cp -f $neighHotplugTmp /etc/hotplug.d/neigh/95-wakey
|
|
||||||
fi
|
|
||||||
"@
|
|
||||||
$remoteCmd = "sh -lc '$($script -replace "`r",'')'"
|
|
||||||
if ($Quiet) { $remoteCmd += " >/dev/null 2>&1" }
|
|
||||||
|
|
||||||
Invoke-Ssh -Cmd $remoteCmd -User $User -Remote $HostName -Pass $Pass -Port $Port -Quiet:$Quiet -ForcePassword:$ForcePassword
|
|
||||||
|
|
||||||
Write-Host "done ✔" -ForegroundColor Green
|
Write-Host "done ✔" -ForegroundColor Green
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
|
|||||||
@@ -7,12 +7,26 @@ USE_PROCD=1
|
|||||||
NAME=wakey
|
NAME=wakey
|
||||||
BIN=/root/.bin/wakey-agent
|
BIN=/root/.bin/wakey-agent
|
||||||
CONFIG=/etc/wakey-agent/config.toml
|
CONFIG=/etc/wakey-agent/config.toml
|
||||||
|
UCI_CONFIG=/etc/config/wakey
|
||||||
|
|
||||||
start_service() {
|
start_service() {
|
||||||
|
local authorization='' log_filter='info'
|
||||||
|
if [ -f "$UCI_CONFIG" ]; then
|
||||||
|
config_load wakey || return 1
|
||||||
|
config_get authorization main otlp_authorization
|
||||||
|
config_get log_filter main log_filter 'info'
|
||||||
|
fi
|
||||||
procd_open_instance
|
procd_open_instance
|
||||||
procd_set_param command "$BIN" serve --config "$CONFIG"
|
procd_set_param command "$BIN" serve --config "$CONFIG"
|
||||||
procd_set_param file "$CONFIG"
|
procd_set_param file "$CONFIG"
|
||||||
# procd_set_param env RUST_LOG=wakey_agent=debug,wakey=debug
|
if [ -f "$UCI_CONFIG" ]; then
|
||||||
|
procd_append_param file "$UCI_CONFIG"
|
||||||
|
fi
|
||||||
|
procd_set_param env "RUST_LOG=$log_filter"
|
||||||
|
# Pass the complete header as one argument; never log its value.
|
||||||
|
if [ -n "$authorization" ]; then
|
||||||
|
procd_append_param env "WAKEY_OTLP_AUTHORIZATION=$authorization"
|
||||||
|
fi
|
||||||
procd_set_param respawn 5 1 0
|
procd_set_param respawn 5 1 0
|
||||||
procd_set_param stdout 1
|
procd_set_param stdout 1
|
||||||
procd_set_param stderr 1
|
procd_set_param stderr 1
|
||||||
|
|||||||
+54
-6
@@ -27,8 +27,15 @@ function Test-IsWsl {
|
|||||||
return $false
|
return $false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# With -Distro, resolve local relative paths from the PowerShell working
|
||||||
|
# directory and convert Windows paths with the distro's mount configuration.
|
||||||
|
# Without it, keep the existing mapping used by SCP callers, including remote destinations.
|
||||||
function ConvertTo-WslPath {
|
function ConvertTo-WslPath {
|
||||||
param([string]$WindowsPath)
|
param(
|
||||||
|
[Alias('Path')]
|
||||||
|
[string]$WindowsPath,
|
||||||
|
[string]$Distro
|
||||||
|
)
|
||||||
|
|
||||||
if ([string]::IsNullOrWhiteSpace($WindowsPath)) {
|
if ([string]::IsNullOrWhiteSpace($WindowsPath)) {
|
||||||
return $WindowsPath
|
return $WindowsPath
|
||||||
@@ -39,6 +46,36 @@ function ConvertTo-WslPath {
|
|||||||
return $WindowsPath
|
return $WindowsPath
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# wslpath expects a Windows input here. Use the selected distro's mount
|
||||||
|
# configuration when requested, while keeping existing POSIX paths intact.
|
||||||
|
if ($Distro) {
|
||||||
|
# Absolute Windows paths can be supplied even from Linux PowerShell.
|
||||||
|
# Relative inputs use the filesystem provider without requiring a file
|
||||||
|
# to exist, which is important for a new runner home/config.
|
||||||
|
if ($WindowsPath -notmatch '^[A-Za-z]:[\\/]|^\\\\') {
|
||||||
|
$provider = $null
|
||||||
|
$drive = $null
|
||||||
|
$WindowsPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath(
|
||||||
|
$WindowsPath, [ref]$provider, [ref]$drive)
|
||||||
|
if ($provider.Name -ne 'FileSystem') {
|
||||||
|
throw "WSL paths must use the filesystem provider"
|
||||||
|
}
|
||||||
|
if ($WindowsPath.StartsWith('/')) {
|
||||||
|
return $WindowsPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$output = & wsl.exe -d $Distro -e wslpath -a "$WindowsPath"
|
||||||
|
$exitCode = $LASTEXITCODE
|
||||||
|
if ($exitCode -ne 0) {
|
||||||
|
throw "wslpath failed for '$WindowsPath' in '$Distro' (exit $exitCode)"
|
||||||
|
}
|
||||||
|
$converted = ($output -join "`n").Trim()
|
||||||
|
if (-not $converted) {
|
||||||
|
throw "wslpath returned no path for '$WindowsPath' in '$Distro'"
|
||||||
|
}
|
||||||
|
return $converted
|
||||||
|
}
|
||||||
|
|
||||||
# Handle Windows path (e.g., C:\path\to\file -> /mnt/c/path/to/file)
|
# Handle Windows path (e.g., C:\path\to\file -> /mnt/c/path/to/file)
|
||||||
if ($WindowsPath -match '^([A-Z]):(.*)$') {
|
if ($WindowsPath -match '^([A-Z]):(.*)$') {
|
||||||
$drive = $matches[1].ToLower()
|
$drive = $matches[1].ToLower()
|
||||||
@@ -154,7 +191,7 @@ function Get-SshpassCommand {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Invoke-Ext {
|
function Invoke-Ext {
|
||||||
param($Exe, $Arguments, $Label)
|
param($Exe, $Arguments, $Label, [string]$Stdin)
|
||||||
$displayArgs = $Arguments.Clone()
|
$displayArgs = $Arguments.Clone()
|
||||||
for ($i = 0; $i -lt $displayArgs.Count; $i++) {
|
for ($i = 0; $i -lt $displayArgs.Count; $i++) {
|
||||||
if ($displayArgs[$i] -eq '-pw' -and ($i + 1) -lt $displayArgs.Count) {
|
if ($displayArgs[$i] -eq '-pw' -and ($i + 1) -lt $displayArgs.Count) {
|
||||||
@@ -166,7 +203,12 @@ function Invoke-Ext {
|
|||||||
}
|
}
|
||||||
Write-Host ("[{0}] {1} {2}" -f $Label, $Exe, ($displayArgs -join ' ')) -ForegroundColor Cyan
|
Write-Host ("[{0}] {1} {2}" -f $Label, $Exe, ($displayArgs -join ' ')) -ForegroundColor Cyan
|
||||||
|
|
||||||
|
# Keep stdin optional so interactive callers retain their existing behavior.
|
||||||
|
if ($PSBoundParameters.ContainsKey('Stdin')) {
|
||||||
|
$out = $Stdin | & $Exe @Arguments 2>&1
|
||||||
|
} else {
|
||||||
$out = & $Exe @Arguments 2>&1
|
$out = & $Exe @Arguments 2>&1
|
||||||
|
}
|
||||||
if ($LASTEXITCODE -ne 0) {
|
if ($LASTEXITCODE -ne 0) {
|
||||||
if ($Exe -like '*sshpass*' -and $LASTEXITCODE -eq 6) {
|
if ($Exe -like '*sshpass*' -and $LASTEXITCODE -eq 6) {
|
||||||
Write-Error ("{0} failed: sshpass could not confirm host key automatically (code 6). Use ssh once manually, or enable StrictHostKeyChecking=accept-new." -f $Label)
|
Write-Error ("{0} failed: sshpass could not confirm host key automatically (code 6). Use ssh once manually, or enable StrictHostKeyChecking=accept-new." -f $Label)
|
||||||
@@ -247,8 +289,14 @@ function Invoke-Scp {
|
|||||||
|
|
||||||
|
|
||||||
function Invoke-Ssh {
|
function Invoke-Ssh {
|
||||||
param($Cmd, $User, $Remote, $Pass, [int]$Port = 22, [switch]$Quiet, [switch]$ForcePassword)
|
# Supply Stdin for scripts, e.g. -Cmd 'sh -s' -Stdin $script. This avoids
|
||||||
|
# embedding a whole script in a remote shell command's quoting layer.
|
||||||
|
param($Cmd, $User, $Remote, $Pass, [int]$Port = 22, [switch]$Quiet, [switch]$ForcePassword, [string]$Stdin)
|
||||||
$Cmd = Normalize-LineEndings $Cmd
|
$Cmd = Normalize-LineEndings $Cmd
|
||||||
|
$inputArgs = @{}
|
||||||
|
if ($PSBoundParameters.ContainsKey('Stdin')) {
|
||||||
|
$inputArgs.Stdin = Normalize-LineEndings $Stdin
|
||||||
|
}
|
||||||
|
|
||||||
$isWin = [bool]$IsWindows
|
$isWin = [bool]$IsWindows
|
||||||
$isWsl = Test-IsWsl
|
$isWsl = Test-IsWsl
|
||||||
@@ -260,7 +308,7 @@ function Invoke-Ssh {
|
|||||||
if ($Pass) { $arguments += @('-pw', $Pass) }
|
if ($Pass) { $arguments += @('-pw', $Pass) }
|
||||||
$dest = if ($User) { "$User@$Remote" } else { $Remote }
|
$dest = if ($User) { "$User@$Remote" } else { $Remote }
|
||||||
$arguments += $dest, $Cmd
|
$arguments += $dest, $Cmd
|
||||||
Invoke-Ext -Exe $plink.Path -Arguments $arguments -Label 'ssh'
|
Invoke-Ext -Exe $plink.Path -Arguments $arguments -Label 'ssh' @inputArgs
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
$arguments = @()
|
$arguments = @()
|
||||||
@@ -282,7 +330,7 @@ function Invoke-Ssh {
|
|||||||
$sshpassExe = Get-SshpassCommand
|
$sshpassExe = Get-SshpassCommand
|
||||||
if ($sshpassExe) {
|
if ($sshpassExe) {
|
||||||
$wrapped = @('-p', $Pass, 'ssh') + $arguments
|
$wrapped = @('-p', $Pass, 'ssh') + $arguments
|
||||||
Invoke-Ext -Exe $sshpassExe -Arguments $wrapped -Label 'ssh'
|
Invoke-Ext -Exe $sshpassExe -Arguments $wrapped -Label 'ssh' @inputArgs
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (-not $script:WarnedNoSshpass) {
|
if (-not $script:WarnedNoSshpass) {
|
||||||
@@ -301,7 +349,7 @@ function Invoke-Ssh {
|
|||||||
throw 'ForcePassword requires sshpass'
|
throw 'ForcePassword requires sshpass'
|
||||||
}
|
}
|
||||||
|
|
||||||
Invoke-Ext -Exe 'ssh' -Arguments $arguments -Label 'ssh'
|
Invoke-Ext -Exe 'ssh' -Arguments $arguments -Label 'ssh' @inputArgs
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,8 +1,14 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# Usage: remote_deploy_wakey.sh <bin_tmp> <dest_path> [restart_flag]
|
# Usage: remote_deploy_wakey.sh <bin_tmp> <dest_path> [restart_flag]
|
||||||
# restart_flag: 1 (default) to stop/start, 0 to only replace file.
|
# restart_flag: 1 (default) to stop/start, 0 to only replace file.
|
||||||
|
# Or stream over SSH: sh -s -- --batch [--binary <tmp> <dest> <restart> |
|
||||||
|
# --install <tmp> <dest>]...
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
|
# A subshell keeps per-binary variables and exit statements local, including
|
||||||
|
# when several replacements are requested by one dev_push invocation.
|
||||||
|
deploy_binary() (
|
||||||
BIN_TMP="$1"
|
BIN_TMP="$1"
|
||||||
DEST="$2"
|
DEST="$2"
|
||||||
RESTART="${3:-1}"
|
RESTART="${3:-1}"
|
||||||
@@ -39,3 +45,36 @@ if [ "$RESTART" = "1" ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
exit 0
|
exit 0
|
||||||
|
)
|
||||||
|
|
||||||
|
install_support_file() {
|
||||||
|
sed -i 's/\r$//' "$1" 2>/dev/null || true
|
||||||
|
mkdir -p "$(dirname "$2")"
|
||||||
|
cp -f "$1" "$2"
|
||||||
|
chmod 0755 "$2"
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ "${1:-}" != '--batch' ]; then
|
||||||
|
deploy_binary "$@"
|
||||||
|
exit
|
||||||
|
fi
|
||||||
|
|
||||||
|
shift
|
||||||
|
while [ "$#" -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--binary)
|
||||||
|
[ "$#" -ge 4 ] || { echo '--binary requires tmp, destination, restart' >&2; exit 2; }
|
||||||
|
deploy_binary "$2" "$3" "$4"
|
||||||
|
shift 4
|
||||||
|
;;
|
||||||
|
--install)
|
||||||
|
[ "$#" -ge 3 ] || { echo '--install requires tmp and destination' >&2; exit 2; }
|
||||||
|
install_support_file "$2" "$3"
|
||||||
|
shift 3
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "unknown deployment operation: $1" >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
#!/usr/bin/env pwsh
|
||||||
|
# Run with pwsh -NoProfile -File scripts/tests/dev_push.ps1. No router is contacted.
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$repo = Split-Path -Parent (Split-Path -Parent $PSScriptRoot)
|
||||||
|
$fixture = Join-Path ([IO.Path]::GetTempPath()) "wakey-dev-push-$([guid]::NewGuid())"
|
||||||
|
|
||||||
|
function Assert($condition, $message) {
|
||||||
|
if (-not $condition) { throw $message }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
New-Item -ItemType Directory -Path "$fixture/scripts" -Force | Out-Null
|
||||||
|
Copy-Item "$repo/scripts/dev_push.ps1" "$fixture/scripts/dev_push.ps1"
|
||||||
|
Copy-Item "$repo/scripts/lib.ps1" "$fixture/scripts/actual-lib.ps1"
|
||||||
|
foreach ($file in @('remote_deploy_wakey.sh', 'init/openwrt/wakey', 'hotplug/openwrt/dhcp/95-wakey', 'hotplug/openwrt/neigh/95-wakey')) {
|
||||||
|
$dest = "$fixture/scripts/$file"
|
||||||
|
New-Item -ItemType Directory -Path (Split-Path -Parent $dest) -Force | Out-Null
|
||||||
|
Copy-Item "$repo/scripts/$file" $dest
|
||||||
|
}
|
||||||
|
# Override only external operations; keep path resolution and quoting real.
|
||||||
|
@'
|
||||||
|
. "$PSScriptRoot/actual-lib.ps1"
|
||||||
|
function cargo {
|
||||||
|
$global:DeployTest.Build = @($args)
|
||||||
|
$global:LASTEXITCODE = $global:DeployTest.BuildExit
|
||||||
|
}
|
||||||
|
function Invoke-Scp {
|
||||||
|
param($Local, $Dest, $Pass, $HostKey, $Port, [switch]$Quiet, [switch]$ForcePassword)
|
||||||
|
$global:DeployTest.Uploads.Add([pscustomobject]@{ Local = $Local; Dest = $Dest })
|
||||||
|
if ($global:DeployTest.Execute) {
|
||||||
|
Copy-Item -LiteralPath $Local -Destination ($Dest -split ':', 2)[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function Invoke-Ssh {
|
||||||
|
param($Cmd, $User, $Remote, $Pass, $Port, [switch]$Quiet, [switch]$ForcePassword, $Stdin)
|
||||||
|
$global:DeployTest.Command = $Cmd
|
||||||
|
$global:DeployTest.Body = $Stdin
|
||||||
|
if (-not $Cmd.StartsWith('sh -s -- ')) { throw 'Deployment must use SSH stdin with sh -s' }
|
||||||
|
# Parse exactly what would reach the remote shell, without executing it.
|
||||||
|
$Stdin | & sh -n
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw 'Generated deployment shell is invalid' }
|
||||||
|
if ($global:DeployTest.Execute) {
|
||||||
|
$Stdin | & sh -c $Cmd
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw 'Local fixture deployment failed' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'@ | Set-Content "$fixture/scripts/lib.ps1"
|
||||||
|
|
||||||
|
function Run-Deploy($options = @{}, $binaries = @('wakey', 'wakey-agent'), $buildExit = 0, $execute = $false) {
|
||||||
|
$global:DeployTest = @{
|
||||||
|
Build = @(); BuildExit = $buildExit; Execute = $execute
|
||||||
|
Uploads = [System.Collections.Generic.List[object]]::new()
|
||||||
|
Command = $null
|
||||||
|
}
|
||||||
|
$output = "$fixture/target/test/release"
|
||||||
|
if (Test-Path $output) { Remove-Item -Recurse -Force $output }
|
||||||
|
New-Item -ItemType Directory -Path $output -Force | Out-Null
|
||||||
|
foreach ($binary in $binaries) { New-Item -ItemType File -Path "$output/$binary" | Out-Null }
|
||||||
|
& "$fixture/scripts/dev_push.ps1" -Pass test-only -HostName router -Target test -Quiet:$false @options
|
||||||
|
}
|
||||||
|
|
||||||
|
Run-Deploy
|
||||||
|
Assert ($global:DeployTest.Uploads.Count -eq 5) 'Default deploy must upload both binaries and three support files'
|
||||||
|
Assert (($global:DeployTest.Build -join ' ') -eq 'build --release --target test -p wakey -p wakey-agent') 'Default build changed'
|
||||||
|
Assert ($global:DeployTest.Command.Contains("'/root/.bin/wakey.tmp' '/root/.bin/wakey' '0'")) 'CLI must not restart before agent replacement'
|
||||||
|
Assert ($global:DeployTest.Command.Contains("'/root/.bin/wakey-agent.tmp' '/root/.bin/wakey-agent' '1'")) 'Agent must restart by default'
|
||||||
|
Assert ($global:DeployTest.Body -ceq (Get-Content -Raw "$repo/scripts/remote_deploy_wakey.sh")) 'Deployment must stream the checked-in file unchanged'
|
||||||
|
|
||||||
|
Run-Deploy @{ BinariesOnly = $true }
|
||||||
|
Assert ($global:DeployTest.Uploads.Count -eq 2) 'Binary-only deploy uploaded support files'
|
||||||
|
Assert (-not $global:DeployTest.Command.Contains('--install')) 'Binary-only deploy contains support installation'
|
||||||
|
|
||||||
|
foreach ($package in @('wakey', 'wakey-agent')) {
|
||||||
|
Run-Deploy @{ BinariesOnly = $true; Package = @($package, $package); Restart = $false } @($package)
|
||||||
|
Assert ($global:DeployTest.Uploads.Count -eq 1) 'Single-package deploy uploaded another file'
|
||||||
|
Assert (($global:DeployTest.Build -join ' ') -eq "build --release --target test -p $package") 'Package selection or deduplication failed'
|
||||||
|
Assert ($global:DeployTest.Command.Contains("'/root/.bin/$package.tmp' '/root/.bin/$package' '0'")) 'Restart override lost'
|
||||||
|
}
|
||||||
|
|
||||||
|
Run-Deploy @{ SkipInitScript = $true }
|
||||||
|
Assert ($global:DeployTest.Uploads.Count -eq 4) 'SkipInitScript must still upload hooks'
|
||||||
|
|
||||||
|
Run-Deploy @{ BinariesOnly = $true; Package = @('wakey-agent'); AgentRemotePath = "/tmp/owner's bin/agent" } @('wakey-agent')
|
||||||
|
Assert ($global:DeployTest.Command.Contains('owner')) 'Custom path lost'
|
||||||
|
|
||||||
|
# Execute only binary replacement inside the temporary fixture, with restart
|
||||||
|
# disabled. This also checks that the helper's exit doesn't skip binary two.
|
||||||
|
$cliPath = "$fixture/installed cli"
|
||||||
|
$agentPath = "$fixture/owner's agent"
|
||||||
|
Run-Deploy @{ BinariesOnly = $true; Restart = $false; RemotePath = $cliPath; AgentRemotePath = $agentPath } @('wakey', 'wakey-agent') 0 $true
|
||||||
|
Assert ((Test-Path -LiteralPath $cliPath) -and (Test-Path -LiteralPath $agentPath)) 'Streamed helper did not install both binaries'
|
||||||
|
Assert (-not (Test-Path -LiteralPath "$agentPath.tmp")) 'Temporary upload was not moved'
|
||||||
|
|
||||||
|
$supportTemp = "$fixture/support.tmp"
|
||||||
|
$supportDest = "$fixture/hook dir/owner's hook"
|
||||||
|
[IO.File]::WriteAllText($supportTemp, "#!/bin/sh`r`necho hook`r`n")
|
||||||
|
& sh "$fixture/scripts/remote_deploy_wakey.sh" --batch --install $supportTemp $supportDest
|
||||||
|
Assert ($LASTEXITCODE -eq 0) 'Support installation failed'
|
||||||
|
Assert (-not (Get-Content -Raw -LiteralPath $supportDest).Contains("`r")) 'Support CRLF was not normalized'
|
||||||
|
& sh -c 'test -x "$1"' sh $supportDest
|
||||||
|
Assert ($LASTEXITCODE -eq 0) 'Installed support file is not executable'
|
||||||
|
|
||||||
|
New-Item -ItemType File -Path "$fixture/legacy.tmp" | Out-Null
|
||||||
|
& sh "$fixture/scripts/remote_deploy_wakey.sh" "$fixture/legacy.tmp" "$fixture/legacy" 0
|
||||||
|
Assert ($LASTEXITCODE -eq 0 -and (Test-Path "$fixture/legacy")) 'Original helper invocation broke'
|
||||||
|
|
||||||
|
$failed = $false
|
||||||
|
try { Run-Deploy @{} @() 1 } catch { $failed = $_.Exception.Message -like '*build failed*' }
|
||||||
|
Assert $failed 'Build failure did not abort deployment'
|
||||||
|
Assert ($global:DeployTest.Uploads.Count -eq 0 -and $null -eq $global:DeployTest.Command) 'Failed build attempted deployment'
|
||||||
|
Write-Host 'dev_push tests passed'
|
||||||
|
} finally {
|
||||||
|
Remove-Variable DeployTest -Scope Global -ErrorAction SilentlyContinue
|
||||||
|
Remove-Item -Recurse -Force $fixture
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
#!/usr/bin/env pwsh
|
||||||
|
# Run with pwsh -NoProfile -File scripts/tests/ssh_stdin.ps1 on Linux/WSL.
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
. "$PSScriptRoot/../lib.ps1"
|
||||||
|
|
||||||
|
# Exercise the real native-command pipeline without contacting a server.
|
||||||
|
$body = "printf '%s\n' `"owner's directory`"`nprintf '%s\n' 'second line'"
|
||||||
|
$output = @(Invoke-Ext -Exe sh -Arguments @('-s') -Label test -Stdin $body)
|
||||||
|
if (($output -join '|') -cne "owner's directory|second line") { throw 'stdin script changed in transit' }
|
||||||
|
$output = @(Invoke-Ext -Exe sh -Arguments @('-c', 'printf command-only') -Label test)
|
||||||
|
if (($output -join '') -cne 'command-only') { throw 'Command-only execution changed' }
|
||||||
|
$failed = $false
|
||||||
|
try { Invoke-Ext -Exe sh -Arguments @('-s') -Label test -Stdin 'exit 7' } catch { $failed = $true }
|
||||||
|
if (-not $failed) { throw 'stdin execution swallowed a failure' }
|
||||||
|
|
||||||
|
# Test SSH argument and stdin forwarding independently of native SSH availability.
|
||||||
|
function Test-IsWsl { return $true }
|
||||||
|
function Get-SshpassCommand { return $script:Sshpass }
|
||||||
|
function Invoke-Ext {
|
||||||
|
param($Exe, $Arguments, $Label, [string]$Stdin)
|
||||||
|
$script:Call = @{ Exe = $Exe; Arguments = $Arguments; Stdin = $Stdin; HasStdin = $PSBoundParameters.ContainsKey('Stdin') }
|
||||||
|
}
|
||||||
|
$script:Sshpass = $null
|
||||||
|
Invoke-Ssh -Cmd 'sh -s' -User root -Remote test -Stdin "printf ok`r`n"
|
||||||
|
if ($script:Call.Exe -ne 'ssh' -or $script:Call.Stdin.Contains("`r") -or -not $script:Call.HasStdin) {
|
||||||
|
throw 'SSH stdin was not normalized and forwarded'
|
||||||
|
}
|
||||||
|
if (($script:Call.Arguments[-2..-1] -join '|') -ne 'root@test|sh -s') { throw 'SSH command changed' }
|
||||||
|
Invoke-Ssh -Cmd 'true' -User root -Remote test
|
||||||
|
if ($script:Call.HasStdin) { throw 'Command-only SSH unexpectedly redirects stdin' }
|
||||||
|
$script:Sshpass = '/fake/sshpass'
|
||||||
|
Invoke-Ssh -Cmd 'sh -s' -User root -Remote test -Pass test-only -Stdin $body
|
||||||
|
if ($script:Call.Exe -ne '/fake/sshpass' -or $script:Call.Stdin -cne $body) { throw 'sshpass lost stdin' }
|
||||||
|
Write-Host 'SSH stdin tests passed'
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Host-side contract test; OpenWrt's UCI and procd helpers are stubbed.
|
||||||
|
set -eu
|
||||||
|
ROOT="$(CDPATH='' cd -- "$(dirname -- "$0")/../.." && pwd)"
|
||||||
|
TMP="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$TMP"' EXIT HUP INT TERM
|
||||||
|
|
||||||
|
config_load() { [ "$1" = wakey ]; loaded=1; return "$load_status"; }
|
||||||
|
config_get() {
|
||||||
|
[ "$2" = main ]
|
||||||
|
case "$3" in
|
||||||
|
otlp_authorization) authorization=$test_authorization ;;
|
||||||
|
log_filter) log_filter=${test_log_filter:-$4} ;;
|
||||||
|
*) return 1 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
procd_open_instance() { opened=1; }
|
||||||
|
procd_close_instance() { :; }
|
||||||
|
procd_set_param() {
|
||||||
|
if [ "$1" = env ]; then
|
||||||
|
[ "$#" -eq 2 ]
|
||||||
|
received_filter=$2
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
procd_append_param() {
|
||||||
|
case "$1" in
|
||||||
|
env) [ "$#" -eq 2 ]; received_auth=$2 ;;
|
||||||
|
file) tracked_file=$2 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
. "$ROOT/scripts/init/openwrt/wakey"
|
||||||
|
UCI_CONFIG="$TMP/wakey"
|
||||||
|
touch "$UCI_CONFIG"
|
||||||
|
load_status=0
|
||||||
|
loaded=0
|
||||||
|
opened=0
|
||||||
|
test_authorization='Basic dGVzdDp0ZXN0=='
|
||||||
|
test_log_filter='wakey_agent=debug,opentelemetry=warn'
|
||||||
|
start_service
|
||||||
|
[ "$loaded" -eq 1 ]
|
||||||
|
[ "$opened" -eq 1 ]
|
||||||
|
[ "$received_auth" = "WAKEY_OTLP_AUTHORIZATION=$test_authorization" ]
|
||||||
|
[ "$received_filter" = "RUST_LOG=$test_log_filter" ]
|
||||||
|
[ "$tracked_file" = "$UCI_CONFIG" ]
|
||||||
|
|
||||||
|
# Missing options retain the default filter and do not export empty credentials.
|
||||||
|
test_authorization=''
|
||||||
|
test_log_filter=''
|
||||||
|
received_auth=''
|
||||||
|
start_service
|
||||||
|
[ "$received_auth" = '' ]
|
||||||
|
[ "$received_filter" = 'RUST_LOG=info' ]
|
||||||
|
|
||||||
|
load_status=1
|
||||||
|
opened=0
|
||||||
|
if start_service; then
|
||||||
|
echo 'failed UCI load was accepted' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
[ "$opened" -eq 0 ]
|
||||||
|
|
||||||
|
UCI_CONFIG="$TMP/absent"
|
||||||
|
loaded=0
|
||||||
|
tracked_file=''
|
||||||
|
start_service
|
||||||
|
[ "$loaded" -eq 0 ]
|
||||||
|
[ "$tracked_file" = '' ]
|
||||||
|
[ "$received_filter" = 'RUST_LOG=info' ]
|
||||||
|
echo 'PASS: OpenWrt UCI environment forwarding and optional configuration'
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
#!/usr/bin/env pwsh
|
||||||
|
# Run with pwsh -NoProfile -File scripts/tests/wsl_paths.ps1.
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
. "$PSScriptRoot/../lib.ps1"
|
||||||
|
|
||||||
|
# Intercept WSL so these tests never start or register a real runner.
|
||||||
|
$global:WakeyWslTest = @{
|
||||||
|
Calls = [System.Collections.Generic.List[object]]::new()
|
||||||
|
ExitCode = 0
|
||||||
|
# Fake conversion response, chosen to exercise a path containing spaces.
|
||||||
|
Output = '/mnt/c/runner home/config.yaml'
|
||||||
|
}
|
||||||
|
function global:wsl.exe {
|
||||||
|
$global:WakeyWslTest.Calls.Add(@($args))
|
||||||
|
$global:LASTEXITCODE = $global:WakeyWslTest.ExitCode
|
||||||
|
if ($args[3] -eq 'wslpath') { $global:WakeyWslTest.Output }
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($path in @('/home/lda/repo/scripts/helper.sh', '/mnt/c/runner home/config.yaml')) {
|
||||||
|
$result = ConvertTo-WslPath -WindowsPath $path -Distro TestDistro
|
||||||
|
if ($result -cne $path) { throw "POSIX path changed: $result" }
|
||||||
|
}
|
||||||
|
if ($global:WakeyWslTest.Calls.Count -ne 0) { throw 'POSIX paths invoked wslpath' }
|
||||||
|
|
||||||
|
foreach ($path in @('C:\runner home\config.yaml', 'C:/runner home/config.yaml', '\\server\share\runner home\config.yaml')) {
|
||||||
|
$global:WakeyWslTest.Calls.Clear()
|
||||||
|
$result = ConvertTo-WslPath -WindowsPath $path -Distro TestDistro
|
||||||
|
if ($result -cne '/mnt/c/runner home/config.yaml') { throw 'Mock Windows conversion failed' }
|
||||||
|
$call = $global:WakeyWslTest.Calls[0]
|
||||||
|
if (($call[0..4] -join ' ') -cne '-d TestDistro -e wslpath -a' -or $call[5] -cne $path) {
|
||||||
|
throw 'Distro or Windows path was not passed intact'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ((ConvertTo-WslPath 'D:\runner\config.yaml') -cne '/mnt/d/runner/config.yaml') {
|
||||||
|
throw 'Existing drive conversion changed'
|
||||||
|
}
|
||||||
|
foreach ($path in @('root@router:/root/.bin/', 'relative/file')) {
|
||||||
|
if ((ConvertTo-WslPath $path) -cne $path) { throw 'Existing SCP path handling changed' }
|
||||||
|
}
|
||||||
|
|
||||||
|
# Resolve missing relative paths from the PS location, including dot segments.
|
||||||
|
$temp = Join-Path ([System.IO.Path]::GetTempPath()) "wakey-wsl-paths-$([guid]::NewGuid())"
|
||||||
|
New-Item -ItemType Directory -Path $temp | Out-Null
|
||||||
|
Push-Location $temp
|
||||||
|
try {
|
||||||
|
foreach ($relative in @('new folder/config.yaml', '.\new folder\config.yaml', 'nested/../new folder/config.yaml')) {
|
||||||
|
$expected = Join-Path $temp 'new folder/config.yaml'
|
||||||
|
$global:WakeyWslTest.Calls.Clear()
|
||||||
|
$result = ConvertTo-WslPath -Path $relative -Distro TestDistro
|
||||||
|
if ($IsWindows) {
|
||||||
|
if ($global:WakeyWslTest.Calls[0][5] -cne $expected) { throw 'Relative Windows path was not resolved before conversion' }
|
||||||
|
} elseif ($result -cne $expected -or $global:WakeyWslTest.Calls.Count -ne 0) {
|
||||||
|
throw "Relative POSIX path was not resolved: $result"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
Pop-Location
|
||||||
|
Remove-Item -Recurse -Force $temp
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($case in @(
|
||||||
|
@{ ExitCode = 1; Output = '' },
|
||||||
|
@{ ExitCode = 1; Output = '/mnt/c/bad' },
|
||||||
|
@{ ExitCode = 0; Output = '' }
|
||||||
|
)) {
|
||||||
|
$global:WakeyWslTest.ExitCode = $case.ExitCode
|
||||||
|
$global:WakeyWslTest.Output = $case.Output
|
||||||
|
$caught = $false
|
||||||
|
try { ConvertTo-WslPath -WindowsPath 'C:\bad' -Distro TestDistro | Out-Null }
|
||||||
|
catch { $caught = $_.Exception.Message -like 'wslpath *' }
|
||||||
|
if (-not $caught) { throw 'Failed/empty wslpath result was accepted' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $IsWindows) {
|
||||||
|
$global:WakeyWslTest.Calls.Clear()
|
||||||
|
$global:WakeyWslTest.ExitCode = 0
|
||||||
|
& "$PSScriptRoot/../act_runner_wsl.ps1" -Action status -Distro TestDistro
|
||||||
|
if ($global:WakeyWslTest.Calls.Count -ne 1 -or $global:WakeyWslTest.Calls[0][3] -ne 'bash') {
|
||||||
|
throw 'POSIX wrapper paths should invoke only the runner command'
|
||||||
|
}
|
||||||
|
$command = $global:WakeyWslTest.Calls[0][5]
|
||||||
|
if ($command -notlike '*act_runner_fedora.sh*' -or $command -notlike '*ar_data/wsl_runner/config.yaml*') {
|
||||||
|
throw 'Wrapper did not resolve default paths'
|
||||||
|
}
|
||||||
|
$global:WakeyWslTest.Calls.Clear()
|
||||||
|
$global:WakeyWslTest.Output = '/mnt/c/runner home/config.yaml'
|
||||||
|
& "$PSScriptRoot/../act_runner_wsl.ps1" -Action status -Distro TestDistro `
|
||||||
|
-RunnerHome 'new runner home' -RunnerBin 'bin/act_runner' -ConfigPath 'C:\runner home\config.yaml'
|
||||||
|
if ($global:WakeyWslTest.Calls.Count -ne 2 -or $global:WakeyWslTest.Calls[0][3] -ne 'wslpath') {
|
||||||
|
throw 'Explicit Windows config should be converted once before running bash'
|
||||||
|
}
|
||||||
|
$command = $global:WakeyWslTest.Calls[1][5]
|
||||||
|
foreach ($path in @((Join-Path (Get-Location).Path 'new runner home'), (Join-Path (Get-Location).Path 'bin/act_runner'), '/mnt/c/runner home/config.yaml')) {
|
||||||
|
if (-not $command.Contains((Quote-ShArg $path))) { throw "Explicit path missing from runner command: $path" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Write-Host 'WSL path tests passed'
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use tracing::{debug, warn};
|
use tracing::{Instrument, debug, warn};
|
||||||
use wakey_core::{
|
use wakey_core::{
|
||||||
Device, DeviceInventory, DeviceObservationFact, DhcpLease, DhcpLeaseWithState, InventoryQuery,
|
Device, DeviceInventory, DeviceObservationFact, DhcpLease, DhcpLeaseWithState, InventoryQuery,
|
||||||
NeighborEntry, Presence, Query,
|
NeighborEntry, Presence, Query,
|
||||||
@@ -19,10 +19,28 @@ pub async fn resolve_devices(input: impl Into<String>) -> Result<Vec<Device>> {
|
|||||||
/// This is the current center of gravity for the service layer. Higher-level
|
/// This is the current center of gravity for the service layer. Higher-level
|
||||||
/// status and wake flows should prefer deriving from inventory rather than
|
/// status and wake flows should prefer deriving from inventory rather than
|
||||||
/// directly from raw Linux source rows.
|
/// directly from raw Linux source rows.
|
||||||
|
#[tracing::instrument(name = "inventory", skip_all, fields(devices = tracing::field::Empty))]
|
||||||
pub async fn inventory(query: InventoryQuery) -> Result<DeviceInventory> {
|
pub async fn inventory(query: InventoryQuery) -> Result<DeviceInventory> {
|
||||||
let neighbors = wakey_linux::devices::query_neighbors(&query).await?;
|
// Instrument futures, not entered guards: each source remains correctly
|
||||||
let leases = get_leases().await?;
|
// parented when Tokio moves the task between workers during an await.
|
||||||
let observations = match wakey_linux::observations::list_local_observations().await {
|
let neighbors = async {
|
||||||
|
let rows = wakey_linux::devices::query_neighbors(&query).await?;
|
||||||
|
debug!(rows = rows.len(), "neighbor source collected");
|
||||||
|
Ok::<_, anyhow::Error>(rows)
|
||||||
|
}
|
||||||
|
.instrument(tracing::info_span!("inventory_neighbors"))
|
||||||
|
.await?;
|
||||||
|
let leases = async {
|
||||||
|
let rows = get_leases().await?;
|
||||||
|
debug!(rows = rows.len(), "DHCP source collected");
|
||||||
|
Ok::<_, anyhow::Error>(rows)
|
||||||
|
}
|
||||||
|
.instrument(tracing::info_span!("inventory_dhcp_leases"))
|
||||||
|
.await?;
|
||||||
|
let observations = match wakey_linux::observations::list_local_observations()
|
||||||
|
.instrument(tracing::info_span!("inventory_hook_store"))
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(observations) => observations
|
Ok(observations) => observations
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_map(local_observation_to_fact)
|
.filter_map(local_observation_to_fact)
|
||||||
@@ -39,6 +57,7 @@ pub async fn inventory(query: InventoryQuery) -> Result<DeviceInventory> {
|
|||||||
"building device inventory"
|
"building device inventory"
|
||||||
);
|
);
|
||||||
let devices = merge_devices_with_observations(neighbors, leases, observations, &query);
|
let devices = merge_devices_with_observations(neighbors, leases, observations, &query);
|
||||||
|
tracing::Span::current().record("devices", devices.len());
|
||||||
debug!(devices = devices.len(), "merged device inventory");
|
debug!(devices = devices.len(), "merged device inventory");
|
||||||
Ok(DeviceInventory { devices })
|
Ok(DeviceInventory { devices })
|
||||||
}
|
}
|
||||||
@@ -57,6 +76,7 @@ pub fn merge_devices(
|
|||||||
|
|
||||||
/// Merge raw neighbor entries, DHCP leases, and hook observations into device
|
/// Merge raw neighbor entries, DHCP leases, and hook observations into device
|
||||||
/// aggregates.
|
/// aggregates.
|
||||||
|
#[tracing::instrument(name = "inventory_merge", skip_all, fields(neighbors = neighbors.len(), leases = leases.len(), observations = observations.len()))]
|
||||||
pub fn merge_devices_with_observations(
|
pub fn merge_devices_with_observations(
|
||||||
neighbors: Vec<NeighborEntry>,
|
neighbors: Vec<NeighborEntry>,
|
||||||
leases: Vec<DhcpLeaseWithState>,
|
leases: Vec<DhcpLeaseWithState>,
|
||||||
@@ -169,11 +189,13 @@ pub fn local_observation_to_fact(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const fn presence_rank(presence: Presence) -> u8 {
|
const fn presence_rank(presence: Presence) -> u8 {
|
||||||
|
// Match the core signal order: Unknown has no verdict, while Offline
|
||||||
|
// carries concrete evidence and belongs above it in the inventory list.
|
||||||
match presence {
|
match presence {
|
||||||
Presence::Online => 3,
|
Presence::Online => 3,
|
||||||
Presence::LikelyOnline => 2,
|
Presence::LikelyOnline => 2,
|
||||||
Presence::Unknown => 1,
|
Presence::Offline => 1,
|
||||||
Presence::Offline => 0,
|
Presence::Unknown => 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,6 +226,23 @@ mod tests {
|
|||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn inventory_sorts_offline_before_unknown() {
|
||||||
|
let neighbors = [NeighborState::Incomplete, NeighborState::Failed]
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, state)| NeighborEntry {
|
||||||
|
ip: IpAddr::V4(Ipv4Addr::new(192, 168, 1, index as u8 + 10)),
|
||||||
|
dev: None,
|
||||||
|
mac: None,
|
||||||
|
state,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let devices = merge_devices(neighbors, Vec::new(), &InventoryQueryBuilder::new().build());
|
||||||
|
assert_eq!(devices[0].presence, Presence::Offline);
|
||||||
|
assert_eq!(devices[1].presence, Presence::Unknown);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn merge_devices_applies_and_across_categories() {
|
fn merge_devices_applies_and_across_categories() {
|
||||||
let query = InventoryQueryBuilder::new()
|
let query = InventoryQueryBuilder::new()
|
||||||
|
|||||||
@@ -339,6 +339,12 @@ export function fetchKnownDevices(): Promise<KnownDevice[]> {
|
|||||||
return request<KnownDevice[]>("/api/v1/control/devices");
|
return request<KnownDevice[]>("/api/v1/control/devices");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function fetchKnownDevice(deviceId: string): Promise<KnownDevice> {
|
||||||
|
return request<KnownDevice>(
|
||||||
|
`/api/v1/control/devices/${encodeURIComponent(deviceId)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function fetchFleetDevices(opts?: {
|
export function fetchFleetDevices(opts?: {
|
||||||
query?: string;
|
query?: string;
|
||||||
presence?: string;
|
presence?: string;
|
||||||
@@ -361,6 +367,12 @@ export function fetchFleetDevices(opts?: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function fetchFleetDevice(deviceKey: string): Promise<FleetDevice> {
|
||||||
|
return request<FleetDevice>(
|
||||||
|
`/api/v1/control/fleet/devices/${encodeURIComponent(deviceKey)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function refreshFleetDevices(input?: {
|
export function refreshFleetDevices(input?: {
|
||||||
agentIds?: string[];
|
agentIds?: string[];
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
@@ -409,6 +421,27 @@ export function createKnownDevice(input: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function renameKnownDevice(
|
||||||
|
deviceId: string,
|
||||||
|
displayName: string,
|
||||||
|
): Promise<KnownDevice> {
|
||||||
|
return request<KnownDevice>(
|
||||||
|
`/api/v1/control/devices/${encodeURIComponent(deviceId)}`,
|
||||||
|
{
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({ display_name: displayName }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function forgetKnownDevice(
|
||||||
|
deviceId: string,
|
||||||
|
): Promise<{ device_id: string; forgotten: boolean }> {
|
||||||
|
return request(`/api/v1/control/devices/${encodeURIComponent(deviceId)}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function attachDeviceIdentifier(
|
export function attachDeviceIdentifier(
|
||||||
deviceId: string,
|
deviceId: string,
|
||||||
identifier: { kind: string; value: string },
|
identifier: { kind: string; value: string },
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import { toast } from "sonner";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
fetchFleetDevices,
|
fetchFleetDevices,
|
||||||
|
fetchFleetDevice,
|
||||||
fetchKnownDevices,
|
fetchKnownDevices,
|
||||||
|
fetchKnownDevice,
|
||||||
refreshFleetDevices,
|
refreshFleetDevices,
|
||||||
wakeFleetDevice,
|
wakeFleetDevice,
|
||||||
type Agent,
|
type Agent,
|
||||||
@@ -70,6 +72,15 @@ export function DevicesPage({ agents, onAfterWake, onRefresh }: Props) {
|
|||||||
const [wakeBusyKey, setWakeBusyKey] = useState("");
|
const [wakeBusyKey, setWakeBusyKey] = useState("");
|
||||||
const [details, setDetails] = useState<FleetDevice | null>(null);
|
const [details, setDetails] = useState<FleetDevice | null>(null);
|
||||||
const loadSeqRef = useRef(0);
|
const loadSeqRef = useRef(0);
|
||||||
|
const detailsKeyRef = useRef<string | null>(null);
|
||||||
|
const detailsSeqRef = useRef(0);
|
||||||
|
|
||||||
|
function selectDetails(device: FleetDevice | null) {
|
||||||
|
// Closing or selecting another row invalidates in-flight detail refreshes.
|
||||||
|
detailsSeqRef.current += 1;
|
||||||
|
detailsKeyRef.current = device?.device_key ?? null;
|
||||||
|
setDetails(device);
|
||||||
|
}
|
||||||
|
|
||||||
async function loadFleet() {
|
async function loadFleet() {
|
||||||
// Latest request wins: filter changes and debounced searches overlap, and
|
// Latest request wins: filter changes and debounced searches overlap, and
|
||||||
@@ -99,6 +110,51 @@ export function DevicesPage({ agents, onAfterWake, onRefresh }: Props) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function refreshView(
|
||||||
|
originKey: string | null = detailsKeyRef.current,
|
||||||
|
knownDeviceId?: string,
|
||||||
|
) {
|
||||||
|
if (!originKey || originKey !== detailsKeyRef.current) {
|
||||||
|
await loadFleet();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Mutations supply an explicit identity; never infer it from a shared IP.
|
||||||
|
// Details use their own read, so list filters and limits cannot close them.
|
||||||
|
const key = knownDeviceId ? `known:${knownDeviceId}` : originKey;
|
||||||
|
detailsKeyRef.current = key;
|
||||||
|
const seq = ++detailsSeqRef.current;
|
||||||
|
try {
|
||||||
|
const [device] = await Promise.all([fetchFleetDevice(key), loadFleet()]);
|
||||||
|
if (seq !== detailsSeqRef.current || detailsKeyRef.current !== key)
|
||||||
|
return;
|
||||||
|
const saved = device.known_device
|
||||||
|
? await fetchKnownDevice(device.known_device.device_id)
|
||||||
|
: null;
|
||||||
|
if (seq === detailsSeqRef.current && detailsKeyRef.current === key) {
|
||||||
|
// The dialog's full identity must stay current even if the list refresh
|
||||||
|
// failed. Dropdown options may come from the list, not mutation controls.
|
||||||
|
if (saved) {
|
||||||
|
setKnownDevices((current) =>
|
||||||
|
current.some((item) => item.device_id === saved.device_id)
|
||||||
|
? current.map((item) =>
|
||||||
|
item.device_id === saved.device_id ? saved : item,
|
||||||
|
)
|
||||||
|
: [...current, saved],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setDetails(device);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (seq !== detailsSeqRef.current || detailsKeyRef.current !== key)
|
||||||
|
return;
|
||||||
|
// A missing/failed identity read must not leave stale mutation controls.
|
||||||
|
selectDetails(null);
|
||||||
|
toast.error("Failed to refresh device details", {
|
||||||
|
description: String(err),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function refreshAllConnected() {
|
async function refreshAllConnected() {
|
||||||
setRefreshing(true);
|
setRefreshing(true);
|
||||||
try {
|
try {
|
||||||
@@ -108,7 +164,7 @@ export function DevicesPage({ agents, onAfterWake, onRefresh }: Props) {
|
|||||||
? `Refreshed ${result.total_accepted} devices; ${failed.length} agent(s) failed`
|
? `Refreshed ${result.total_accepted} devices; ${failed.length} agent(s) failed`
|
||||||
: `Refreshed ${result.total_accepted} devices from ${result.agents.length} connected agents`;
|
: `Refreshed ${result.total_accepted} devices from ${result.agents.length} connected agents`;
|
||||||
toast.success(msg);
|
toast.success(msg);
|
||||||
await loadFleet();
|
await refreshView();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error("Fleet refresh failed", { description: String(err) });
|
toast.error("Fleet refresh failed", { description: String(err) });
|
||||||
} finally {
|
} finally {
|
||||||
@@ -127,7 +183,7 @@ export function DevicesPage({ agents, onAfterWake, onRefresh }: Props) {
|
|||||||
const msg = `Wake sent via ${agentLabel(result.route.agent_id, result.route.nickname)} (${result.route.mac ?? "no mac"})`;
|
const msg = `Wake sent via ${agentLabel(result.route.agent_id, result.route.nickname)} (${result.route.mac ?? "no mac"})`;
|
||||||
toast.success(msg);
|
toast.success(msg);
|
||||||
if (onAfterWake) await onAfterWake();
|
if (onAfterWake) await onAfterWake();
|
||||||
await loadFleet();
|
await refreshView();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error("Wake failed", { description: String(err) });
|
toast.error("Wake failed", { description: String(err) });
|
||||||
} finally {
|
} finally {
|
||||||
@@ -263,6 +319,7 @@ export function DevicesPage({ agents, onAfterWake, onRefresh }: Props) {
|
|||||||
label="Identity"
|
label="Identity"
|
||||||
value={known}
|
value={known}
|
||||||
values={knownFilters}
|
values={knownFilters}
|
||||||
|
labels={{ known: "saved", unknown: "unsaved" }}
|
||||||
onChange={(value) => setKnown(value as KnownFilter)}
|
onChange={(value) => setKnown(value as KnownFilter)}
|
||||||
/>
|
/>
|
||||||
<label
|
<label
|
||||||
@@ -338,7 +395,7 @@ export function DevicesPage({ agents, onAfterWake, onRefresh }: Props) {
|
|||||||
device={device}
|
device={device}
|
||||||
busy={wakeBusyKey === device.device_key}
|
busy={wakeBusyKey === device.device_key}
|
||||||
onWake={() => void wakeDevice(device)}
|
onWake={() => void wakeDevice(device)}
|
||||||
onDetails={() => setDetails(device)}
|
onDetails={() => selectDetails(device)}
|
||||||
onCopy={(label, value) => void copyValue(label, value)}
|
onCopy={(label, value) => void copyValue(label, value)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -362,13 +419,15 @@ export function DevicesPage({ agents, onAfterWake, onRefresh }: Props) {
|
|||||||
open={Boolean(details)}
|
open={Boolean(details)}
|
||||||
busy={wakeBusyKey === details?.device_key}
|
busy={wakeBusyKey === details?.device_key}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open) setDetails(null);
|
if (!open) selectDetails(null);
|
||||||
}}
|
}}
|
||||||
onWake={(routeId) => {
|
onWake={(routeId) => {
|
||||||
if (details) void wakeDevice(details, routeId);
|
if (details) void wakeDevice(details, routeId);
|
||||||
}}
|
}}
|
||||||
onCopy={(label, value) => void copyValue(label, value)}
|
onCopy={(label, value) => void copyValue(label, value)}
|
||||||
onChanged={() => void loadFleet()}
|
onChanged={(knownDeviceId) =>
|
||||||
|
refreshView(details?.device_key ?? null, knownDeviceId)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
@@ -404,9 +463,9 @@ function FleetDeviceRow({
|
|||||||
<div className="truncate font-medium">{device.display_name}</div>
|
<div className="truncate font-medium">{device.display_name}</div>
|
||||||
<div className="mt-1 flex flex-wrap gap-1">
|
<div className="mt-1 flex flex-wrap gap-1">
|
||||||
{device.known_device ? (
|
{device.known_device ? (
|
||||||
<Badge variant="secondary">known</Badge>
|
<Badge variant="secondary">saved</Badge>
|
||||||
) : (
|
) : (
|
||||||
<Badge variant="outline">unknown</Badge>
|
<Badge variant="outline">unsaved</Badge>
|
||||||
)}
|
)}
|
||||||
{device.pinned && <Badge variant="outline">pinned</Badge>}
|
{device.pinned && <Badge variant="outline">pinned</Badge>}
|
||||||
{device.sources.map((source) => (
|
{device.sources.map((source) => (
|
||||||
|
|||||||
@@ -27,11 +27,13 @@ export function FilterSelect({
|
|||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
values,
|
values,
|
||||||
|
labels,
|
||||||
onChange,
|
onChange,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string;
|
||||||
values: readonly string[];
|
values: readonly string[];
|
||||||
|
labels?: Readonly<Record<string, string>>;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@@ -39,12 +41,12 @@ export function FilterSelect({
|
|||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
<Select value={value} onValueChange={(next) => next && onChange(next)}>
|
<Select value={value} onValueChange={(next) => next && onChange(next)}>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<span>{value.replace("_", " ")}</span>
|
<span>{labels?.[value] ?? value.replace("_", " ")}</span>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent alignItemWithTrigger={false}>
|
<SelectContent alignItemWithTrigger={false}>
|
||||||
{values.map((item) => (
|
{values.map((item) => (
|
||||||
<SelectItem key={item} value={item}>
|
<SelectItem key={item} value={item}>
|
||||||
{item.replace("_", " ")}
|
{labels?.[item] ?? item.replace("_", " ")}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Copy, Minus, Plus, Zap } from "lucide-react";
|
import { Copy, Minus, Plus, Zap, Pencil, Trash2, Check, X } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -7,6 +7,8 @@ import {
|
|||||||
createKnownDevice,
|
createKnownDevice,
|
||||||
detachDeviceIdentifier,
|
detachDeviceIdentifier,
|
||||||
mergeKnownDevice,
|
mergeKnownDevice,
|
||||||
|
renameKnownDevice,
|
||||||
|
forgetKnownDevice,
|
||||||
type FleetDevice,
|
type FleetDevice,
|
||||||
type FleetDeviceEndpoint,
|
type FleetDeviceEndpoint,
|
||||||
type KnownDevice,
|
type KnownDevice,
|
||||||
@@ -46,7 +48,7 @@ type Props = {
|
|||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
onWake: (routeId?: string | null) => void;
|
onWake: (routeId?: string | null) => void;
|
||||||
onCopy: (label: string, value: string) => void;
|
onCopy: (label: string, value: string) => void;
|
||||||
onChanged: () => void;
|
onChanged: (knownDeviceId?: string) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function FleetDeviceDetailsDialog({
|
export function FleetDeviceDetailsDialog({
|
||||||
@@ -68,6 +70,8 @@ export function FleetDeviceDetailsDialog({
|
|||||||
const [addValue, setAddValue] = useState("");
|
const [addValue, setAddValue] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [actionBusy, setActionBusy] = useState(false);
|
const [actionBusy, setActionBusy] = useState(false);
|
||||||
|
const [editingName, setEditingName] = useState(false);
|
||||||
|
const [forgetConfirm, setForgetConfirm] = useState(false);
|
||||||
|
|
||||||
const rowIdentifiers = device ? identifiersFor(device) : [];
|
const rowIdentifiers = device ? identifiersFor(device) : [];
|
||||||
|
|
||||||
@@ -95,18 +99,20 @@ export function FleetDeviceDetailsDialog({
|
|||||||
(kd) => kd.device_id !== currentDevice.known_device?.device_id,
|
(kd) => kd.device_id !== currentDevice.known_device?.device_id,
|
||||||
);
|
);
|
||||||
|
|
||||||
async function wrap(fn: () => Promise<void | { changed?: boolean }>) {
|
async function wrap(
|
||||||
|
fn: () => Promise<void | { changed?: boolean; knownDeviceId?: string }>,
|
||||||
|
) {
|
||||||
setError("");
|
setError("");
|
||||||
setActionBusy(true);
|
setActionBusy(true);
|
||||||
try {
|
try {
|
||||||
const res = await fn();
|
const res = await fn();
|
||||||
if (!res || res.changed !== false) {
|
if (!res || res.changed !== false) {
|
||||||
onChanged();
|
await onChanged(res?.knownDeviceId);
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(String(err));
|
setError(String(err));
|
||||||
if (err && (err.changed || err.partial)) {
|
if (err && (err.changed || err.partial)) {
|
||||||
onChanged();
|
await onChanged(err.knownDeviceId);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setActionBusy(false);
|
setActionBusy(false);
|
||||||
@@ -121,6 +127,7 @@ export function FleetDeviceDetailsDialog({
|
|||||||
identifiers: rowIdentifiers,
|
identifiers: rowIdentifiers,
|
||||||
});
|
});
|
||||||
toast.success(`Remembered as "${result.display_name}"`);
|
toast.success(`Remembered as "${result.display_name}"`);
|
||||||
|
return { knownDeviceId: result.device_id };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,7 +136,7 @@ export function FleetDeviceDetailsDialog({
|
|||||||
const target = knownDevices.find((kd) => kd.device_id === targetDeviceId);
|
const target = knownDevices.find((kd) => kd.device_id === targetDeviceId);
|
||||||
await wrap(async () => {
|
await wrap(async () => {
|
||||||
const ids = fullKnown ? unattachedIdentifiers : rowIdentifiers;
|
const ids = fullKnown ? unattachedIdentifiers : rowIdentifiers;
|
||||||
if (ids.length === 0) return;
|
if (ids.length === 0) return { changed: false };
|
||||||
|
|
||||||
const results = await Promise.allSettled(
|
const results = await Promise.allSettled(
|
||||||
ids.map((id) => attachDeviceIdentifier(targetDeviceId, id)),
|
ids.map((id) => attachDeviceIdentifier(targetDeviceId, id)),
|
||||||
@@ -140,14 +147,17 @@ export function FleetDeviceDetailsDialog({
|
|||||||
|
|
||||||
if (errors.length) {
|
if (errors.length) {
|
||||||
const err = errors[0].reason as Error;
|
const err = errors[0].reason as Error;
|
||||||
if (changed) (err as any).changed = true;
|
if (changed) {
|
||||||
|
(err as any).changed = true;
|
||||||
|
(err as any).knownDeviceId = targetDeviceId;
|
||||||
|
}
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.success(
|
toast.success(
|
||||||
`Attached ${ids.length} identifier(s) to "${target?.display_name ?? targetDeviceId}"`,
|
`Attached ${ids.length} identifier(s) to "${target?.display_name ?? targetDeviceId}"`,
|
||||||
);
|
);
|
||||||
return { changed };
|
return { changed, knownDeviceId: targetDeviceId };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,12 +165,12 @@ export function FleetDeviceDetailsDialog({
|
|||||||
if (!targetDeviceId || !currentDevice.known_device) return;
|
if (!targetDeviceId || !currentDevice.known_device) return;
|
||||||
const target = knownDevices.find((kd) => kd.device_id === targetDeviceId);
|
const target = knownDevices.find((kd) => kd.device_id === targetDeviceId);
|
||||||
await wrap(async () => {
|
await wrap(async () => {
|
||||||
await mergeKnownDevice(
|
const merged = await mergeKnownDevice(
|
||||||
targetDeviceId,
|
targetDeviceId,
|
||||||
currentDevice.known_device!.device_id,
|
currentDevice.known_device!.device_id,
|
||||||
);
|
);
|
||||||
toast.success(`Merged into "${target?.display_name ?? targetDeviceId}"`);
|
toast.success(`Merged into "${target?.display_name ?? targetDeviceId}"`);
|
||||||
onOpenChange(false);
|
return { knownDeviceId: merged.device_id };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,6 +194,24 @@ export function FleetDeviceDetailsDialog({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleRename() {
|
||||||
|
if (!fullKnown || !displayName.trim()) return;
|
||||||
|
await wrap(async () => {
|
||||||
|
await renameKnownDevice(fullKnown.device_id, displayName.trim());
|
||||||
|
setEditingName(false);
|
||||||
|
toast.success("Device renamed");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleForget() {
|
||||||
|
if (!fullKnown) return;
|
||||||
|
await wrap(async () => {
|
||||||
|
await forgetKnownDevice(fullKnown.device_id);
|
||||||
|
toast.success("Remembered device forgotten");
|
||||||
|
onOpenChange(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const selectedRoute =
|
const selectedRoute =
|
||||||
device.route_candidates.find((r) => r.route_id === routeId) ??
|
device.route_candidates.find((r) => r.route_id === routeId) ??
|
||||||
device.recommended_route;
|
device.recommended_route;
|
||||||
@@ -275,7 +303,7 @@ export function FleetDeviceDetailsDialog({
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => onWake(selectedRoute?.route_id ?? null)}
|
onClick={() => onWake(selectedRoute?.route_id ?? null)}
|
||||||
disabled={busy || !selectedRoute?.wakeable}
|
disabled={actionBusy || busy || !selectedRoute?.wakeable}
|
||||||
>
|
>
|
||||||
<Zap className="size-4" aria-hidden />
|
<Zap className="size-4" aria-hidden />
|
||||||
{busy ? "Waking" : "Wake"}
|
{busy ? "Waking" : "Wake"}
|
||||||
@@ -309,9 +337,36 @@ export function FleetDeviceDetailsDialog({
|
|||||||
{fullKnown && (
|
{fullKnown && (
|
||||||
<div className="grid gap-3">
|
<div className="grid gap-3">
|
||||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||||
<Badge variant="secondary">known</Badge>
|
<Badge variant="secondary">saved</Badge>
|
||||||
{fullKnown.pinned && <Badge variant="outline">pinned</Badge>}
|
{fullKnown.pinned && <Badge variant="outline">pinned</Badge>}
|
||||||
<span className="font-medium">{fullKnown.display_name}</span>
|
<span className="font-medium">{fullKnown.display_name}</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
title="Rename device"
|
||||||
|
aria-label="Rename device"
|
||||||
|
disabled={actionBusy}
|
||||||
|
onClick={() => {
|
||||||
|
setDisplayName(fullKnown.display_name);
|
||||||
|
setEditingName(true);
|
||||||
|
setForgetConfirm(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pencil className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
title="Forget remembered device"
|
||||||
|
aria-label="Forget remembered device"
|
||||||
|
disabled={actionBusy}
|
||||||
|
onClick={() => {
|
||||||
|
setForgetConfirm(true);
|
||||||
|
setEditingName(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3.5" />
|
||||||
|
</Button>
|
||||||
{fullKnown.notes && (
|
{fullKnown.notes && (
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
: {fullKnown.notes}
|
: {fullKnown.notes}
|
||||||
@@ -319,6 +374,76 @@ export function FleetDeviceDetailsDialog({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{editingName && (
|
||||||
|
<form
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
void handleRename();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
aria-label="Device name"
|
||||||
|
autoFocus
|
||||||
|
value={displayName}
|
||||||
|
disabled={actionBusy}
|
||||||
|
onChange={(event) => setDisplayName(event.target.value)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="icon-sm"
|
||||||
|
title="Save name"
|
||||||
|
aria-label="Save name"
|
||||||
|
disabled={actionBusy || !displayName.trim()}
|
||||||
|
>
|
||||||
|
<Check className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
title="Cancel rename"
|
||||||
|
aria-label="Cancel rename"
|
||||||
|
disabled={actionBusy}
|
||||||
|
onClick={() => setEditingName(false)}
|
||||||
|
>
|
||||||
|
<X className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
{forgetConfirm && (
|
||||||
|
<div
|
||||||
|
className="grid gap-2"
|
||||||
|
role="group"
|
||||||
|
aria-label="Confirm forget device"
|
||||||
|
>
|
||||||
|
<p className="text-sm">
|
||||||
|
Forget “{fullKnown.display_name}” and its remembered
|
||||||
|
identifiers? Agent observations remain; it may reappear as
|
||||||
|
an unsaved device.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
disabled={actionBusy}
|
||||||
|
onClick={() => void handleForget()}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
Forget device
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={actionBusy}
|
||||||
|
onClick={() => setForgetConfirm(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Current identifiers with remove buttons */}
|
{/* Current identifiers with remove buttons */}
|
||||||
<div className="rounded-md border bg-muted/30 p-3">
|
<div className="rounded-md border bg-muted/30 p-3">
|
||||||
<div className="mb-2 text-xs font-medium uppercase text-muted-foreground">
|
<div className="mb-2 text-xs font-medium uppercase text-muted-foreground">
|
||||||
@@ -434,7 +559,7 @@ export function FleetDeviceDetailsDialog({
|
|||||||
<Separator />
|
<Separator />
|
||||||
<div className="grid gap-1.5">
|
<div className="grid gap-1.5">
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Merge this known device into another:
|
Merge this saved device into another:
|
||||||
</p>
|
</p>
|
||||||
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-2">
|
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-2">
|
||||||
<Select
|
<Select
|
||||||
@@ -479,7 +604,7 @@ export function FleetDeviceDetailsDialog({
|
|||||||
{/* Remember as new known device */}
|
{/* Remember as new known device */}
|
||||||
<div className="grid gap-1.5">
|
<div className="grid gap-1.5">
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Remember as a new known device:
|
Remember as a new saved device:
|
||||||
</p>
|
</p>
|
||||||
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-2">
|
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-2">
|
||||||
<Input
|
<Input
|
||||||
@@ -505,7 +630,7 @@ export function FleetDeviceDetailsDialog({
|
|||||||
{/* Attach to existing known device */}
|
{/* Attach to existing known device */}
|
||||||
<div className="grid gap-1.5">
|
<div className="grid gap-1.5">
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Or attach to an existing known device:
|
Or attach to an existing saved device:
|
||||||
</p>
|
</p>
|
||||||
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-2">
|
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-2">
|
||||||
<Select
|
<Select
|
||||||
@@ -518,7 +643,7 @@ export function FleetDeviceDetailsDialog({
|
|||||||
? knownDevices.find(
|
? knownDevices.find(
|
||||||
(kd) => kd.device_id === targetDeviceId,
|
(kd) => kd.device_id === targetDeviceId,
|
||||||
)?.display_name
|
)?.display_name
|
||||||
: "Select known device"}
|
: "Select saved device"}
|
||||||
</span>
|
</span>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent
|
<SelectContent
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "wakey-agent"
|
name = "wakey-agent"
|
||||||
version = "0.3.6"
|
version = "0.3.7"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
publish = ["gitea"]
|
publish = ["gitea"]
|
||||||
|
|
||||||
@@ -9,6 +9,7 @@ anyhow = "1"
|
|||||||
clap = { version = "4", features = ["derive"] }
|
clap = { version = "4", features = ["derive"] }
|
||||||
futures-util = "0.3"
|
futures-util = "0.3"
|
||||||
http = "1"
|
http = "1"
|
||||||
|
humantime-serde = "1"
|
||||||
macaddr = { version = "1", features = ["serde", "serde_std"] }
|
macaddr = { version = "1", features = ["serde", "serde_std"] }
|
||||||
nix = { version = "0.31", default-features = false, features = [
|
nix = { version = "0.31", default-features = false, features = [
|
||||||
"signal",
|
"signal",
|
||||||
@@ -32,13 +33,10 @@ tokio = { version = "1", features = [
|
|||||||
tokio-tungstenite = { version = "0.29", features = ["rustls-tls-webpki-roots"] }
|
tokio-tungstenite = { version = "0.29", features = ["rustls-tls-webpki-roots"] }
|
||||||
toml = "1.1"
|
toml = "1.1"
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = [
|
|
||||||
"env-filter",
|
|
||||||
"fmt",
|
|
||||||
"time",
|
|
||||||
] }
|
|
||||||
time = { version = "0.3", features = ["formatting", "local-offset"] }
|
|
||||||
url = "2"
|
url = "2"
|
||||||
vt100 = { package = "wakey-vt100", path = "../vendor/vt100", registry = "gitea", version = "=0.16.2-wakey.2" }
|
vt100 = { package = "wakey-vt100", path = "../vendor/vt100", registry = "gitea", version = "=0.16.2-wakey.2" }
|
||||||
wakey = { path = "..", registry = "gitea", version = "0" }
|
wakey = { path = "..", registry = "gitea", version = "0" }
|
||||||
wakey-core = { path = "../wakey-core", registry = "gitea", version = "0" }
|
wakey-core = { path = "../wakey-core", registry = "gitea", version = "0", features = ["telemetry"] }
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
|
wakey-linux = { path = "../wakey-linux", registry = "gitea", version = "0", features = ["telemetry"] }
|
||||||
+234
-51
@@ -3,6 +3,8 @@ use serde::{Deserialize, Serialize};
|
|||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::LazyLock;
|
use std::sync::LazyLock;
|
||||||
|
use std::time::Duration;
|
||||||
|
use wakey_core::config_duration::{self, LegacyDuration};
|
||||||
|
|
||||||
use crate::protocol::{
|
use crate::protocol::{
|
||||||
DEFAULT_TERMINAL_MAX_SESSIONS, DEFAULT_TERMINAL_SESSION_TTL_SECONDS,
|
DEFAULT_TERMINAL_MAX_SESSIONS, DEFAULT_TERMINAL_SESSION_TTL_SECONDS,
|
||||||
@@ -19,19 +21,25 @@ const DEFAULT_MAC_NAME_CACHE_PATH: &str = "/tmp/wakey_mac_names.json";
|
|||||||
const DEFAULT_OBSERVATION_STORE_PATH: &str = "/tmp/wakey_observations.json";
|
const DEFAULT_OBSERVATION_STORE_PATH: &str = "/tmp/wakey_observations.json";
|
||||||
pub const DEFAULT_OBSERVATION_RETENTION_DAYS: u64 = 7;
|
pub const DEFAULT_OBSERVATION_RETENTION_DAYS: u64 = 7;
|
||||||
|
|
||||||
|
// Derive canonical serde functions once; the trait adapter below translates
|
||||||
|
// legacy keys without maintaining another copy of every config field.
|
||||||
#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(remote = "Self")]
|
||||||
pub struct AgentConfig {
|
pub struct AgentConfig {
|
||||||
pub server_url: String,
|
pub server_url: String,
|
||||||
pub agent_id: String,
|
pub agent_id: String,
|
||||||
pub agent_token: String,
|
pub agent_token: String,
|
||||||
#[serde(default = "default_reconnect_base_ms")]
|
#[serde(default = "default_reconnect_base", with = "humantime_serde")]
|
||||||
pub reconnect_base_ms: u64,
|
pub reconnect_base: Duration,
|
||||||
#[serde(default = "default_reconnect_max_ms")]
|
#[serde(default = "default_reconnect_max", with = "humantime_serde")]
|
||||||
pub reconnect_max_ms: u64,
|
pub reconnect_max: Duration,
|
||||||
#[serde(default = "default_observation_sync_interval_seconds")]
|
#[serde(
|
||||||
pub observation_sync_interval_seconds: u64,
|
default = "default_observation_sync_interval",
|
||||||
#[serde(default = "default_observation_retention_days")]
|
with = "humantime_serde"
|
||||||
pub observation_retention_days: u64,
|
)]
|
||||||
|
pub observation_sync_interval: Duration,
|
||||||
|
#[serde(default = "default_observation_retention", with = "humantime_serde")]
|
||||||
|
pub observation_retention: Duration,
|
||||||
#[serde(default = "default_pid_file")]
|
#[serde(default = "default_pid_file")]
|
||||||
pub pid_file: PathBuf,
|
pub pid_file: PathBuf,
|
||||||
#[serde(default = "default_dhcp_leases_path")]
|
#[serde(default = "default_dhcp_leases_path")]
|
||||||
@@ -42,10 +50,12 @@ pub struct AgentConfig {
|
|||||||
pub observation_store_path: PathBuf,
|
pub observation_store_path: PathBuf,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub terminal: TerminalConfig,
|
pub terminal: TerminalConfig,
|
||||||
|
#[serde(default)]
|
||||||
|
pub telemetry: wakey_core::telemetry::TelemetryConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(remote = "Self", deny_unknown_fields)]
|
||||||
pub struct TerminalConfig {
|
pub struct TerminalConfig {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
@@ -58,8 +68,8 @@ pub struct TerminalConfig {
|
|||||||
#[serde(default = "default_terminal_max_sessions")]
|
#[serde(default = "default_terminal_max_sessions")]
|
||||||
pub max_sessions: usize,
|
pub max_sessions: usize,
|
||||||
/// Maximum lifetime of a PTY process. Zero disables automatic expiry.
|
/// Maximum lifetime of a PTY process. Zero disables automatic expiry.
|
||||||
#[serde(default = "default_terminal_session_ttl_seconds")]
|
#[serde(default = "default_terminal_session_ttl", with = "humantime_serde")]
|
||||||
pub session_ttl_seconds: u64,
|
pub session_ttl: Duration,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for TerminalConfig {
|
impl Default for TerminalConfig {
|
||||||
@@ -70,7 +80,7 @@ impl Default for TerminalConfig {
|
|||||||
args: Vec::new(),
|
args: Vec::new(),
|
||||||
current_dir: None,
|
current_dir: None,
|
||||||
max_sessions: default_terminal_max_sessions(),
|
max_sessions: default_terminal_max_sessions(),
|
||||||
session_ttl_seconds: default_terminal_session_ttl_seconds(),
|
session_ttl: default_terminal_session_ttl(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -79,15 +89,16 @@ pub static DEFAULT_CONFIG: LazyLock<AgentConfig> = LazyLock::new(|| AgentConfig
|
|||||||
server_url: "https://wakey.ldlda.com".to_string(),
|
server_url: "https://wakey.ldlda.com".to_string(),
|
||||||
agent_id: "REPLACE_ME_AGENT_ID".to_string(),
|
agent_id: "REPLACE_ME_AGENT_ID".to_string(),
|
||||||
agent_token: "REPLACE_ME_AGENT_TOKEN".to_string(),
|
agent_token: "REPLACE_ME_AGENT_TOKEN".to_string(),
|
||||||
reconnect_base_ms: default_reconnect_base_ms(),
|
reconnect_base: default_reconnect_base(),
|
||||||
reconnect_max_ms: default_reconnect_max_ms(),
|
reconnect_max: default_reconnect_max(),
|
||||||
observation_sync_interval_seconds: default_observation_sync_interval_seconds(),
|
observation_sync_interval: default_observation_sync_interval(),
|
||||||
observation_retention_days: default_observation_retention_days(),
|
observation_retention: default_observation_retention(),
|
||||||
pid_file: default_pid_file(),
|
pid_file: default_pid_file(),
|
||||||
dhcp_leases_path: default_dhcp_leases_path(),
|
dhcp_leases_path: default_dhcp_leases_path(),
|
||||||
mac_name_cache_path: default_mac_name_cache_path(),
|
mac_name_cache_path: default_mac_name_cache_path(),
|
||||||
observation_store_path: default_observation_store_path(),
|
observation_store_path: default_observation_store_path(),
|
||||||
terminal: TerminalConfig::default(),
|
terminal: TerminalConfig::default(),
|
||||||
|
telemetry: wakey_core::telemetry::TelemetryConfig::default(),
|
||||||
});
|
});
|
||||||
|
|
||||||
impl fmt::Debug for AgentConfig {
|
impl fmt::Debug for AgentConfig {
|
||||||
@@ -96,39 +107,34 @@ impl fmt::Debug for AgentConfig {
|
|||||||
.field("server_url", &self.server_url)
|
.field("server_url", &self.server_url)
|
||||||
.field("agent_id", &self.agent_id)
|
.field("agent_id", &self.agent_id)
|
||||||
.field("agent_token", &"<redacted>")
|
.field("agent_token", &"<redacted>")
|
||||||
.field("reconnect_base_ms", &self.reconnect_base_ms)
|
.field("reconnect_base", &self.reconnect_base)
|
||||||
.field("reconnect_max_ms", &self.reconnect_max_ms)
|
.field("reconnect_max", &self.reconnect_max)
|
||||||
.field(
|
.field("observation_sync_interval", &self.observation_sync_interval)
|
||||||
"observation_sync_interval_seconds",
|
.field("observation_retention", &self.observation_retention)
|
||||||
&self.observation_sync_interval_seconds,
|
|
||||||
)
|
|
||||||
.field(
|
|
||||||
"observation_retention_days",
|
|
||||||
&self.observation_retention_days,
|
|
||||||
)
|
|
||||||
.field("pid_file", &self.pid_file)
|
.field("pid_file", &self.pid_file)
|
||||||
.field("dhcp_leases_path", &self.dhcp_leases_path)
|
.field("dhcp_leases_path", &self.dhcp_leases_path)
|
||||||
.field("mac_name_cache_path", &self.mac_name_cache_path)
|
.field("mac_name_cache_path", &self.mac_name_cache_path)
|
||||||
.field("observation_store_path", &self.observation_store_path)
|
.field("observation_store_path", &self.observation_store_path)
|
||||||
.field("terminal", &self.terminal)
|
.field("terminal", &self.terminal)
|
||||||
|
.field("telemetry", &self.telemetry)
|
||||||
.finish()
|
.finish()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const fn default_reconnect_base_ms() -> u64 {
|
const fn default_reconnect_base() -> Duration {
|
||||||
1_000
|
Duration::from_secs(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
const fn default_reconnect_max_ms() -> u64 {
|
const fn default_reconnect_max() -> Duration {
|
||||||
30_000
|
Duration::from_secs(30)
|
||||||
}
|
}
|
||||||
|
|
||||||
const fn default_observation_sync_interval_seconds() -> u64 {
|
const fn default_observation_sync_interval() -> Duration {
|
||||||
60
|
Duration::from_secs(60)
|
||||||
}
|
}
|
||||||
|
|
||||||
const fn default_observation_retention_days() -> u64 {
|
const fn default_observation_retention() -> Duration {
|
||||||
DEFAULT_OBSERVATION_RETENTION_DAYS
|
Duration::from_secs(DEFAULT_OBSERVATION_RETENTION_DAYS * 86_400)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_pid_file() -> PathBuf {
|
fn default_pid_file() -> PathBuf {
|
||||||
@@ -155,17 +161,43 @@ const fn default_terminal_max_sessions() -> usize {
|
|||||||
DEFAULT_TERMINAL_MAX_SESSIONS
|
DEFAULT_TERMINAL_MAX_SESSIONS
|
||||||
}
|
}
|
||||||
|
|
||||||
const fn default_terminal_session_ttl_seconds() -> u64 {
|
const fn default_terminal_session_ttl() -> Duration {
|
||||||
DEFAULT_TERMINAL_SESSION_TTL_SECONDS
|
Duration::from_secs(DEFAULT_TERMINAL_SESSION_TTL_SECONDS)
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TerminalConfig {
|
impl TerminalConfig {
|
||||||
pub(crate) fn session_ttl(&self) -> Result<Option<std::time::Duration>> {
|
pub(crate) fn session_ttl(&self) -> Result<Option<std::time::Duration>> {
|
||||||
checked_terminal_session_ttl(self.session_ttl_seconds).map_err(anyhow::Error::msg)
|
anyhow::ensure!(
|
||||||
|
self.session_ttl.subsec_nanos() == 0,
|
||||||
|
"terminal.session_ttl must use whole seconds (the terminal protocol uses seconds)"
|
||||||
|
);
|
||||||
|
checked_terminal_session_ttl(self.session_ttl.as_secs()).map_err(anyhow::Error::msg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AgentConfig {
|
impl AgentConfig {
|
||||||
|
/// Validate scheduling bounds and second-based storage/protocol boundaries.
|
||||||
|
/// Zero is allowed for hook retention and terminal TTL, not active timers.
|
||||||
|
fn validate(&self) -> Result<()> {
|
||||||
|
for (name, duration) in [
|
||||||
|
("reconnect_base", self.reconnect_base),
|
||||||
|
("reconnect_max", self.reconnect_max),
|
||||||
|
("observation_sync_interval", self.observation_sync_interval),
|
||||||
|
] {
|
||||||
|
anyhow::ensure!(!duration.is_zero(), "{name} must be positive");
|
||||||
|
anyhow::ensure!(
|
||||||
|
std::time::Instant::now().checked_add(duration).is_some(),
|
||||||
|
"{name} exceeds the platform timer range"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
anyhow::ensure!(
|
||||||
|
self.observation_retention.subsec_nanos() == 0,
|
||||||
|
"observation_retention must use whole seconds (hook timestamps use seconds)"
|
||||||
|
);
|
||||||
|
self.terminal.session_ttl()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn local_path_envs(&self) -> Vec<(&'static str, &Path)> {
|
pub fn local_path_envs(&self) -> Vec<(&'static str, &Path)> {
|
||||||
vec![
|
vec![
|
||||||
(WAKEY_DHCP_LEASES_ENV, self.dhcp_leases_path.as_path()),
|
(WAKEY_DHCP_LEASES_ENV, self.dhcp_leases_path.as_path()),
|
||||||
@@ -187,13 +219,99 @@ pub fn apply_local_path_env_to_command(cmd: &mut std::process::Command, config:
|
|||||||
pub fn load_config(path: &Path) -> Result<AgentConfig> {
|
pub fn load_config(path: &Path) -> Result<AgentConfig> {
|
||||||
let content = std::fs::read_to_string(path)
|
let content = std::fs::read_to_string(path)
|
||||||
.with_context(|| format!("failed to read agent config {}", path.display()))?;
|
.with_context(|| format!("failed to read agent config {}", path.display()))?;
|
||||||
let config: AgentConfig = toml::from_str(&content)
|
let raw: toml::Value = toml::from_str(&content)
|
||||||
.with_context(|| format!("failed to parse agent config {}", path.display()))?;
|
.with_context(|| format!("failed to parse agent config {}", path.display()))?;
|
||||||
config.terminal.session_ttl()?;
|
let mut value = serde_json::to_value(raw)?;
|
||||||
|
let mut deprecated = config_duration::normalize(&mut value, AGENT_DURATION_KEYS)
|
||||||
|
.map_err(anyhow::Error::msg)?
|
||||||
|
.into_iter()
|
||||||
|
.map(|key| ("", key))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
if let Some(terminal) = value.get_mut("terminal") {
|
||||||
|
deprecated.extend(
|
||||||
|
config_duration::normalize(terminal, TERMINAL_DURATION_KEYS)
|
||||||
|
.map_err(anyhow::Error::msg)?
|
||||||
|
.into_iter()
|
||||||
|
.map(|key| ("terminal.", key)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let config: AgentConfig = serde_json::from_value(value)
|
||||||
|
.with_context(|| format!("failed to parse agent config {}", path.display()))?;
|
||||||
|
config.validate()?;
|
||||||
|
for (prefix, key) in deprecated {
|
||||||
|
// Initial config loading precedes tracing setup; stderr also keeps
|
||||||
|
// init-config stdout valid TOML and never prints config secrets.
|
||||||
|
eprintln!(
|
||||||
|
"warning: {}: `{prefix}{}` is deprecated; use `{prefix}{}` with a duration string",
|
||||||
|
path.display(),
|
||||||
|
key.old,
|
||||||
|
key.new
|
||||||
|
);
|
||||||
|
}
|
||||||
Ok(config)
|
Ok(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const AGENT_DURATION_KEYS: &[LegacyDuration] = &[
|
||||||
|
LegacyDuration {
|
||||||
|
old: "reconnect_base_ms",
|
||||||
|
new: "reconnect_base",
|
||||||
|
unit: Duration::from_millis(1),
|
||||||
|
},
|
||||||
|
LegacyDuration {
|
||||||
|
old: "reconnect_max_ms",
|
||||||
|
new: "reconnect_max",
|
||||||
|
unit: Duration::from_millis(1),
|
||||||
|
},
|
||||||
|
LegacyDuration {
|
||||||
|
old: "observation_sync_interval_seconds",
|
||||||
|
new: "observation_sync_interval",
|
||||||
|
unit: Duration::from_secs(1),
|
||||||
|
},
|
||||||
|
LegacyDuration {
|
||||||
|
old: "observation_retention_days",
|
||||||
|
new: "observation_retention",
|
||||||
|
unit: Duration::from_secs(86_400),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const TERMINAL_DURATION_KEYS: &[LegacyDuration] = &[LegacyDuration {
|
||||||
|
old: "session_ttl_seconds",
|
||||||
|
new: "session_ttl",
|
||||||
|
unit: Duration::from_secs(1),
|
||||||
|
}];
|
||||||
|
|
||||||
|
impl Serialize for AgentConfig {
|
||||||
|
fn serialize<S: serde::Serializer>(
|
||||||
|
&self,
|
||||||
|
serializer: S,
|
||||||
|
) -> std::result::Result<S::Ok, S::Error> {
|
||||||
|
Self::serialize(self, serializer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl<'de> Deserialize<'de> for AgentConfig {
|
||||||
|
fn deserialize<D: serde::Deserializer<'de>>(
|
||||||
|
deserializer: D,
|
||||||
|
) -> std::result::Result<Self, D::Error> {
|
||||||
|
config_duration::deserialize_legacy(deserializer, AGENT_DURATION_KEYS, Self::deserialize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl Serialize for TerminalConfig {
|
||||||
|
fn serialize<S: serde::Serializer>(
|
||||||
|
&self,
|
||||||
|
serializer: S,
|
||||||
|
) -> std::result::Result<S::Ok, S::Error> {
|
||||||
|
Self::serialize(self, serializer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl<'de> Deserialize<'de> for TerminalConfig {
|
||||||
|
fn deserialize<D: serde::Deserializer<'de>>(
|
||||||
|
deserializer: D,
|
||||||
|
) -> std::result::Result<Self, D::Error> {
|
||||||
|
config_duration::deserialize_legacy(deserializer, TERMINAL_DURATION_KEYS, Self::deserialize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn save_config(path: &Path, config: &AgentConfig) -> Result<()> {
|
pub fn save_config(path: &Path, config: &AgentConfig) -> Result<()> {
|
||||||
|
config.validate()?;
|
||||||
let content = toml::to_string_pretty(config).context("failed to serialize agent config")?;
|
let content = toml::to_string_pretty(config).context("failed to serialize agent config")?;
|
||||||
write_config_atomically(path, &content)
|
write_config_atomically(path, &content)
|
||||||
}
|
}
|
||||||
@@ -258,6 +376,62 @@ fn write_config_atomically(path: &Path, content: &str) -> Result<()> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
const HEADER: &str =
|
||||||
|
"server_url = 'https://example.com'\nagent_id = 'agent-1'\nagent_token = 'secret'\n";
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_duration_keys_convert_and_serialize_canonically() {
|
||||||
|
let legacy: AgentConfig = toml::from_str(&format!("{HEADER}\nreconnect_base_ms = 123\nreconnect_max_ms = 456\nobservation_sync_interval_seconds = 7\nobservation_retention_days = 3\n[terminal]\nsession_ttl_seconds = 600\n")).unwrap();
|
||||||
|
let canonical: AgentConfig = toml::from_str(&format!("{HEADER}\nreconnect_base = '123ms'\nreconnect_max = '456ms'\nobservation_sync_interval = '7s'\nobservation_retention = '3d'\n[terminal]\nsession_ttl = '10m'\n")).unwrap();
|
||||||
|
assert_eq!(legacy, canonical);
|
||||||
|
legacy.validate().unwrap();
|
||||||
|
let output = toml::to_string(&legacy).unwrap();
|
||||||
|
for key in AGENT_DURATION_KEYS {
|
||||||
|
assert!(!output.contains(key.old));
|
||||||
|
assert!(output.contains(key.new));
|
||||||
|
}
|
||||||
|
assert!(!output.contains("session_ttl_seconds"));
|
||||||
|
assert_eq!(toml::from_str::<AgentConfig>(&output).unwrap(), canonical);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn config_rejects_ambiguous_units_and_invalid_timer_values() {
|
||||||
|
for fields in [
|
||||||
|
"reconnect_base = '1s'\nreconnect_base_ms = 1000",
|
||||||
|
"observation_retention = '7d'\nobservation_retention_days = 7",
|
||||||
|
"reconnect_base = 1000",
|
||||||
|
"observation_retention_days = -1",
|
||||||
|
"observation_retention_days = 9223372036854775807",
|
||||||
|
"reconnect_base = 'nonsense'",
|
||||||
|
"[terminal]\nsession_ttl = '1h'\nsession_ttl_seconds = 3600",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
toml::from_str::<AgentConfig>(&format!("{HEADER}{fields}")).is_err(),
|
||||||
|
"{fields}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for fields in [
|
||||||
|
"observation_sync_interval = '0s'",
|
||||||
|
"reconnect_base = '0s'",
|
||||||
|
"[terminal]\nsession_ttl = '500ms'",
|
||||||
|
] {
|
||||||
|
let config: AgentConfig = toml::from_str(&format!("{HEADER}{fields}")).unwrap();
|
||||||
|
assert!(config.validate().is_err(), "{fields}");
|
||||||
|
}
|
||||||
|
let config: AgentConfig = toml::from_str(&format!(
|
||||||
|
"{HEADER}observation_retention = '0d'\n[terminal]\nsession_ttl = '0s'"
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
config.validate().unwrap();
|
||||||
|
assert!(config.observation_retention.is_zero());
|
||||||
|
assert_eq!(config.terminal.session_ttl().unwrap(), None);
|
||||||
|
let oversized = AgentConfig {
|
||||||
|
reconnect_max: Duration::from_secs(u64::MAX),
|
||||||
|
..config
|
||||||
|
};
|
||||||
|
assert!(oversized.validate().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn config_roundtrip() {
|
fn config_roundtrip() {
|
||||||
let dir = std::env::temp_dir().join(format!("wakey-agent-config-{}", std::process::id()));
|
let dir = std::env::temp_dir().join(format!("wakey-agent-config-{}", std::process::id()));
|
||||||
@@ -266,21 +440,30 @@ mod tests {
|
|||||||
server_url: "https://example.com".into(),
|
server_url: "https://example.com".into(),
|
||||||
agent_id: "agent-1".into(),
|
agent_id: "agent-1".into(),
|
||||||
agent_token: "secret".into(),
|
agent_token: "secret".into(),
|
||||||
reconnect_base_ms: 123,
|
reconnect_base: Duration::from_millis(123),
|
||||||
reconnect_max_ms: 456,
|
reconnect_max: Duration::from_millis(456),
|
||||||
observation_sync_interval_seconds: 7,
|
observation_sync_interval: Duration::from_secs(7),
|
||||||
observation_retention_days: 3,
|
observation_retention: Duration::from_secs(3 * 86_400),
|
||||||
pid_file: "/tmp/test-wakey-agent.pid".into(),
|
pid_file: "/tmp/test-wakey-agent.pid".into(),
|
||||||
dhcp_leases_path: "/tmp/test-dhcp.leases".into(),
|
dhcp_leases_path: "/tmp/test-dhcp.leases".into(),
|
||||||
mac_name_cache_path: "/tmp/test-names.json".into(),
|
mac_name_cache_path: "/tmp/test-names.json".into(),
|
||||||
observation_store_path: "/tmp/test-observations.json".into(),
|
observation_store_path: "/tmp/test-observations.json".into(),
|
||||||
|
telemetry: wakey_core::telemetry::TelemetryConfig {
|
||||||
|
otlp_endpoint: Some("http://10.0.0.1:4317".into()),
|
||||||
|
authorization_env: Some("WAKEY_OTLP_AUTHORIZATION".into()),
|
||||||
|
service_name: "router-agent".into(),
|
||||||
|
json_logs: true,
|
||||||
|
console_filter: Some("info".into()),
|
||||||
|
otlp_log_filter: Some("debug".into()),
|
||||||
|
trace_filter: Some("info".into()),
|
||||||
|
},
|
||||||
terminal: TerminalConfig {
|
terminal: TerminalConfig {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
shell: "/bin/sh".into(),
|
shell: "/bin/sh".into(),
|
||||||
args: vec!["-l".into()],
|
args: vec!["-l".into()],
|
||||||
current_dir: Some("/tmp".into()),
|
current_dir: Some("/tmp".into()),
|
||||||
max_sessions: 2,
|
max_sessions: 2,
|
||||||
session_ttl_seconds: 600,
|
session_ttl: Duration::from_secs(600),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -310,8 +493,8 @@ agent_token = "secret"
|
|||||||
.expect("config should parse");
|
.expect("config should parse");
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
config.observation_retention_days,
|
config.observation_retention,
|
||||||
DEFAULT_OBSERVATION_RETENTION_DAYS
|
DEFAULT_CONFIG.observation_retention
|
||||||
);
|
);
|
||||||
assert_eq!(config.terminal, TerminalConfig::default());
|
assert_eq!(config.terminal, TerminalConfig::default());
|
||||||
}
|
}
|
||||||
@@ -335,8 +518,8 @@ max_sessions = 2
|
|||||||
assert!(config.terminal.args.is_empty());
|
assert!(config.terminal.args.is_empty());
|
||||||
assert!(config.terminal.current_dir.is_none());
|
assert!(config.terminal.current_dir.is_none());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
config.terminal.session_ttl_seconds,
|
config.terminal.session_ttl,
|
||||||
DEFAULT_TERMINAL_SESSION_TTL_SECONDS
|
TerminalConfig::default().session_ttl
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -361,18 +544,18 @@ max_session = 67
|
|||||||
#[test]
|
#[test]
|
||||||
fn terminal_ttl_zero_is_unlimited_and_positive_is_exact() {
|
fn terminal_ttl_zero_is_unlimited_and_positive_is_exact() {
|
||||||
let mut terminal = TerminalConfig {
|
let mut terminal = TerminalConfig {
|
||||||
session_ttl_seconds: 0,
|
session_ttl: Duration::ZERO,
|
||||||
..TerminalConfig::default()
|
..TerminalConfig::default()
|
||||||
};
|
};
|
||||||
assert_eq!(terminal.session_ttl().expect("unlimited TTL"), None);
|
assert_eq!(terminal.session_ttl().expect("unlimited TTL"), None);
|
||||||
|
|
||||||
terminal.session_ttl_seconds = 67;
|
terminal.session_ttl = Duration::from_secs(67);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
terminal.session_ttl().expect("finite TTL"),
|
terminal.session_ttl().expect("finite TTL"),
|
||||||
Some(std::time::Duration::from_secs(67))
|
Some(std::time::Duration::from_secs(67))
|
||||||
);
|
);
|
||||||
|
|
||||||
terminal.session_ttl_seconds = u64::MAX;
|
terminal.session_ttl = Duration::from_secs(u64::MAX);
|
||||||
assert!(terminal.session_ttl().is_err());
|
assert!(terminal.session_ttl().is_err());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use tracing::{debug, info, instrument, warn};
|
use tracing::{Instrument, debug, info, instrument, warn};
|
||||||
|
|
||||||
use crate::config::AgentConfig;
|
use crate::config::AgentConfig;
|
||||||
use crate::protocol::{
|
use crate::protocol::{
|
||||||
@@ -69,22 +69,31 @@ async fn dispatch_inventory(req: InventoryRequest, config: &AgentConfig) -> Resu
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[instrument(name = "agent_inventory", skip_all, fields(neighbors = tracing::field::Empty, leases = tracing::field::Empty, observations = tracing::field::Empty, devices = tracing::field::Empty))]
|
||||||
pub async fn inventory_for_config(
|
pub async fn inventory_for_config(
|
||||||
req: InventoryRequest,
|
req: InventoryRequest,
|
||||||
config: &AgentConfig,
|
config: &AgentConfig,
|
||||||
) -> Result<wakey_core::DeviceInventory> {
|
) -> Result<wakey_core::DeviceInventory> {
|
||||||
let query = req.into_inventory_query();
|
let query = req.into_inventory_query();
|
||||||
let neighbors = wakey::wakey_linux::devices::query_neighbors(&query).await?;
|
// This configured path serves both commands and periodic snapshots; keep
|
||||||
|
// phase spans here so neither caller bypasses source timing.
|
||||||
|
let neighbors = wakey::wakey_linux::devices::query_neighbors(&query)
|
||||||
|
.instrument(tracing::info_span!("inventory_neighbors"))
|
||||||
|
.await?;
|
||||||
|
tracing::Span::current().record("neighbors", neighbors.len());
|
||||||
let leases = wakey::wakey_linux::dhcp::read_dhcp_leases_with_names_from_paths(
|
let leases = wakey::wakey_linux::dhcp::read_dhcp_leases_with_names_from_paths(
|
||||||
&config.dhcp_leases_path,
|
&config.dhcp_leases_path,
|
||||||
&config.observation_store_path,
|
&config.observation_store_path,
|
||||||
&config.mac_name_cache_path,
|
&config.mac_name_cache_path,
|
||||||
)
|
)
|
||||||
|
.instrument(tracing::info_span!("inventory_dhcp_leases"))
|
||||||
.await?;
|
.await?;
|
||||||
match wakey::wakey_linux::observations::prune_stale_observations_from_path(
|
tracing::Span::current().record("leases", leases.len());
|
||||||
|
match wakey::wakey_linux::observations::prune_expired(
|
||||||
&config.observation_store_path,
|
&config.observation_store_path,
|
||||||
config.observation_retention_days,
|
config.observation_retention,
|
||||||
)
|
)
|
||||||
|
.instrument(tracing::info_span!("inventory_observation_prune"))
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(removed) if removed > 0 => {
|
Ok(removed) if removed > 0 => {
|
||||||
@@ -98,6 +107,7 @@ pub async fn inventory_for_config(
|
|||||||
let observations = match wakey::wakey_linux::observations::list_local_observations_from_path(
|
let observations = match wakey::wakey_linux::observations::list_local_observations_from_path(
|
||||||
&config.observation_store_path,
|
&config.observation_store_path,
|
||||||
)
|
)
|
||||||
|
.instrument(tracing::info_span!("inventory_hook_store"))
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(observations) => observations
|
Ok(observations) => observations
|
||||||
@@ -109,6 +119,7 @@ pub async fn inventory_for_config(
|
|||||||
Vec::new()
|
Vec::new()
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
tracing::Span::current().record("observations", observations.len());
|
||||||
debug!(
|
debug!(
|
||||||
neighbors = neighbors.len(),
|
neighbors = neighbors.len(),
|
||||||
leases = leases.len(),
|
leases = leases.len(),
|
||||||
@@ -123,6 +134,7 @@ pub async fn inventory_for_config(
|
|||||||
&query,
|
&query,
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
tracing::Span::current().record("devices", inventory.devices.len());
|
||||||
debug!(
|
debug!(
|
||||||
rows = inventory.devices.len(),
|
rows = inventory.devices.len(),
|
||||||
"dispatched inventory command"
|
"dispatched inventory command"
|
||||||
|
|||||||
@@ -143,21 +143,28 @@ mod tests {
|
|||||||
server_url: "https://old.example.com".into(),
|
server_url: "https://old.example.com".into(),
|
||||||
agent_id: "old-agent".into(),
|
agent_id: "old-agent".into(),
|
||||||
agent_token: "old-token".into(),
|
agent_token: "old-token".into(),
|
||||||
reconnect_base_ms: 2_000,
|
reconnect_base: std::time::Duration::from_secs(2),
|
||||||
reconnect_max_ms: 60_000,
|
reconnect_max: std::time::Duration::from_secs(60),
|
||||||
observation_sync_interval_seconds: 30,
|
observation_sync_interval: std::time::Duration::from_secs(30),
|
||||||
observation_retention_days: 11,
|
observation_retention: std::time::Duration::from_secs(11 * 86_400),
|
||||||
pid_file: "/tmp/custom-wakey-agent.pid".into(),
|
pid_file: "/tmp/custom-wakey-agent.pid".into(),
|
||||||
dhcp_leases_path: "/tmp/custom-dhcp.leases".into(),
|
dhcp_leases_path: "/tmp/custom-dhcp.leases".into(),
|
||||||
mac_name_cache_path: "/tmp/custom-names.json".into(),
|
mac_name_cache_path: "/tmp/custom-names.json".into(),
|
||||||
observation_store_path: "/tmp/custom-observations.json".into(),
|
observation_store_path: "/tmp/custom-observations.json".into(),
|
||||||
|
telemetry: wakey_core::telemetry::TelemetryConfig {
|
||||||
|
otlp_endpoint: Some("http://10.0.0.1:4317".into()),
|
||||||
|
authorization_env: Some("WAKEY_OTLP_AUTHORIZATION".into()),
|
||||||
|
service_name: "router-agent".into(),
|
||||||
|
json_logs: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
terminal: crate::config::TerminalConfig {
|
terminal: crate::config::TerminalConfig {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
shell: "/bin/ash".into(),
|
shell: "/bin/ash".into(),
|
||||||
args: vec!["-l".into()],
|
args: vec!["-l".into()],
|
||||||
current_dir: Some("/root".into()),
|
current_dir: Some("/root".into()),
|
||||||
max_sessions: 2,
|
max_sessions: 2,
|
||||||
session_ttl_seconds: 600,
|
session_ttl: std::time::Duration::from_secs(600),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -169,7 +176,10 @@ mod tests {
|
|||||||
assert_eq!(config.agent_id, "agent-123");
|
assert_eq!(config.agent_id, "agent-123");
|
||||||
assert_eq!(config.agent_token, "token-xyz");
|
assert_eq!(config.agent_token, "token-xyz");
|
||||||
assert_eq!(config.server_url, "https://control.example.com");
|
assert_eq!(config.server_url, "https://control.example.com");
|
||||||
assert_eq!(config.observation_retention_days, 11);
|
assert_eq!(
|
||||||
|
config.observation_retention,
|
||||||
|
base_config.observation_retention
|
||||||
|
);
|
||||||
assert_eq!(config.pid_file, base_config.pid_file);
|
assert_eq!(config.pid_file, base_config.pid_file);
|
||||||
assert_eq!(config.dhcp_leases_path, base_config.dhcp_leases_path);
|
assert_eq!(config.dhcp_leases_path, base_config.dhcp_leases_path);
|
||||||
assert_eq!(config.terminal, base_config.terminal);
|
assert_eq!(config.terminal, base_config.terminal);
|
||||||
|
|||||||
+33
-2
@@ -15,9 +15,37 @@ use wakey_agent::protocol;
|
|||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<()> {
|
async fn main() -> Result<()> {
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
tracing::init(cli.verbose);
|
|
||||||
let global_config = cli.config.as_deref();
|
let global_config = cli.config.as_deref();
|
||||||
|
|
||||||
|
// Only the long-lived daemon exports: hotplug/enroll invocations stay cheap.
|
||||||
|
let daemon_config = if let Command::Serve(args) = &cli.command {
|
||||||
|
let path = global_config.unwrap_or(&args.config);
|
||||||
|
// Let serve report its enrollment/setup instructions for a missing file.
|
||||||
|
if path.exists() {
|
||||||
|
Some(config::load_config(path)?)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let local_only = wakey_core::telemetry::TelemetryConfig::default();
|
||||||
|
let telemetry = tracing::init(
|
||||||
|
cli.verbose,
|
||||||
|
daemon_config
|
||||||
|
.as_ref()
|
||||||
|
.map(|c| &c.telemetry)
|
||||||
|
.unwrap_or(&local_only),
|
||||||
|
daemon_config.as_ref().map(|c| c.agent_id.as_str()),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let result = run(cli).await;
|
||||||
|
tokio::task::spawn_blocking(move || telemetry.shutdown()).await?;
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run(cli: Cli) -> Result<()> {
|
||||||
|
let global_config = cli.config.as_deref();
|
||||||
match cli.command {
|
match cli.command {
|
||||||
Command::Serve(mut args) => {
|
Command::Serve(mut args) => {
|
||||||
if let Some(config) = global_config {
|
if let Some(config) = global_config {
|
||||||
@@ -262,7 +290,10 @@ fn init_config(args: InitConfigArgs) -> Result<()> {
|
|||||||
cfg.agent_token = agent_token;
|
cfg.agent_token = agent_token;
|
||||||
}
|
}
|
||||||
if let Some(days) = args.observation_retention_days {
|
if let Some(days) = args.observation_retention_days {
|
||||||
cfg.observation_retention_days = days;
|
cfg.observation_retention = std::time::Duration::from_secs(
|
||||||
|
days.checked_mul(86_400)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("observation retention is too large"))?,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(path) = &args.config {
|
if let Some(path) = &args.config {
|
||||||
|
|||||||
@@ -301,6 +301,9 @@ pub enum ClientMessage {
|
|||||||
DeviceSnapshot {
|
DeviceSnapshot {
|
||||||
agent_id: String,
|
agent_id: String,
|
||||||
devices: Vec<Device>,
|
devices: Vec<Device>,
|
||||||
|
/// W3C trace context for the collection that produced this snapshot.
|
||||||
|
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
|
||||||
|
trace_context: std::collections::BTreeMap<String, String>,
|
||||||
},
|
},
|
||||||
Result {
|
Result {
|
||||||
request_id: RequestId,
|
request_id: RequestId,
|
||||||
@@ -325,6 +328,8 @@ pub enum ServerMessage {
|
|||||||
Command {
|
Command {
|
||||||
request_id: RequestId,
|
request_id: RequestId,
|
||||||
command: AgentCommand,
|
command: AgentCommand,
|
||||||
|
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
|
||||||
|
trace_context: std::collections::BTreeMap<String, String>,
|
||||||
},
|
},
|
||||||
SyncDeviceSnapshot,
|
SyncDeviceSnapshot,
|
||||||
SyncTerminalSessions,
|
SyncTerminalSessions,
|
||||||
@@ -354,6 +359,7 @@ mod tests {
|
|||||||
command: AgentCommand::Leases(LeasesRequest {
|
command: AgentCommand::Leases(LeasesRequest {
|
||||||
include_state: true,
|
include_state: true,
|
||||||
}),
|
}),
|
||||||
|
trace_context: Default::default(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let json = serde_json::to_string(&msg).expect("serialize");
|
let json = serde_json::to_string(&msg).expect("serialize");
|
||||||
@@ -367,6 +373,36 @@ mod tests {
|
|||||||
assert!(err.contains("must not be empty"));
|
assert!(err.contains("must not be empty"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn old_commands_without_trace_context_remain_valid() {
|
||||||
|
let message: ServerMessage = serde_json::from_str(r#"{"type":"command","request_id":"req-old","command":{"kind":"leases","include_state":false}}"#).expect("old command");
|
||||||
|
let ServerMessage::Command { trace_context, .. } = message else {
|
||||||
|
panic!("command expected")
|
||||||
|
};
|
||||||
|
assert!(trace_context.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn command_trace_context_roundtrips() {
|
||||||
|
let message = ServerMessage::Command {
|
||||||
|
request_id: RequestId::try_from("req-traced".to_string()).unwrap(),
|
||||||
|
command: AgentCommand::Leases(LeasesRequest {
|
||||||
|
include_state: false,
|
||||||
|
}),
|
||||||
|
trace_context: [(
|
||||||
|
"traceparent".into(),
|
||||||
|
"00-0123456789abcdef0123456789abcdef-0123456789abcdef-01".into(),
|
||||||
|
)]
|
||||||
|
.into(),
|
||||||
|
};
|
||||||
|
let ServerMessage::Command { trace_context, .. } =
|
||||||
|
serde_json::from_str(&serde_json::to_string(&message).unwrap()).unwrap()
|
||||||
|
else {
|
||||||
|
panic!("command expected")
|
||||||
|
};
|
||||||
|
assert!(trace_context.contains_key("traceparent"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn terminal_id_deserialization_enforces_validation() {
|
fn terminal_id_deserialization_enforces_validation() {
|
||||||
let valid: TerminalId = serde_json::from_str(r#""term-1""#).expect("valid terminal id");
|
let valid: TerminalId = serde_json::from_str(r#""term-1""#).expect("valid terminal id");
|
||||||
@@ -394,11 +430,13 @@ mod tests {
|
|||||||
let msg = ClientMessage::DeviceSnapshot {
|
let msg = ClientMessage::DeviceSnapshot {
|
||||||
agent_id: "agent-a".into(),
|
agent_id: "agent-a".into(),
|
||||||
devices: vec![],
|
devices: vec![],
|
||||||
|
trace_context: Default::default(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let json = serde_json::to_string(&msg).expect("serialize");
|
let json = serde_json::to_string(&msg).expect("serialize");
|
||||||
assert!(json.contains("\"type\":\"device_snapshot\""));
|
assert!(json.contains("\"type\":\"device_snapshot\""));
|
||||||
assert!(json.contains("\"devices\""));
|
assert!(json.contains("\"devices\""));
|
||||||
|
assert!(!json.contains("trace_context"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ pub async fn serve(args: ServeArgs) -> Result<()> {
|
|||||||
{
|
{
|
||||||
use tokio::signal::unix::{SignalKind, signal};
|
use tokio::signal::unix::{SignalKind, signal};
|
||||||
let mut hup = signal(SignalKind::hangup()).context("failed to install SIGHUP handler")?;
|
let mut hup = signal(SignalKind::hangup()).context("failed to install SIGHUP handler")?;
|
||||||
|
let mut terminate =
|
||||||
|
signal(SignalKind::terminate()).context("failed to install SIGTERM handler")?;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
@@ -37,9 +39,17 @@ pub async fn serve(args: ServeArgs) -> Result<()> {
|
|||||||
worker.abort();
|
worker.abort();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
_ = terminate.recv() => {
|
||||||
|
info!("SIGTERM received; shutting down wakey-agent");
|
||||||
|
worker.abort();
|
||||||
|
break;
|
||||||
|
}
|
||||||
_ = hup.recv() => {
|
_ = hup.recv() => {
|
||||||
match config::load_config(&args.config) {
|
match config::load_config(&args.config) {
|
||||||
Ok(new_cfg) => {
|
Ok(new_cfg) => {
|
||||||
|
if new_cfg.telemetry != cfg.telemetry {
|
||||||
|
warn!("telemetry configuration changed; restart the daemon to apply it");
|
||||||
|
}
|
||||||
cfg = new_cfg;
|
cfg = new_cfg;
|
||||||
info!(config_path = %args.config.display(), agent_id = %cfg.agent_id, "reload requested; restarting session with updated config");
|
info!(config_path = %args.config.display(), agent_id = %cfg.agent_id, "reload requested; restarting session with updated config");
|
||||||
worker.abort();
|
worker.abort();
|
||||||
@@ -67,6 +77,8 @@ pub async fn serve(args: ServeArgs) -> Result<()> {
|
|||||||
worker.abort();
|
worker.abort();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Await the cancelled session task before flushing its telemetry.
|
||||||
|
let _ = worker.await;
|
||||||
let _ = remove_pid_file(&pid_file);
|
let _ = remove_pid_file(&pid_file);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
+65
-33
@@ -4,7 +4,7 @@ use std::net::IpAddr;
|
|||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
use tokio::time::{Duration, MissedTickBehavior, interval, sleep};
|
use tokio::time::{Duration, MissedTickBehavior, interval, sleep};
|
||||||
use tokio_tungstenite::{connect_async, tungstenite::Message};
|
use tokio_tungstenite::{connect_async, tungstenite::Message};
|
||||||
use tracing::{debug, error, info, info_span, warn};
|
use tracing::{Instrument, debug, error, info, info_span, warn};
|
||||||
|
|
||||||
use crate::config::AgentConfig;
|
use crate::config::AgentConfig;
|
||||||
use crate::dispatch::{dispatch_command, inventory_for_config};
|
use crate::dispatch::{dispatch_command, inventory_for_config};
|
||||||
@@ -18,16 +18,16 @@ pub async fn run(config: AgentConfig) -> Result<()> {
|
|||||||
// Terminal workers belong to the agent process, not a single control socket.
|
// Terminal workers belong to the agent process, not a single control socket.
|
||||||
// Keeping this manager outside the reconnect loop lets PTYs survive CC loss.
|
// Keeping this manager outside the reconnect loop lets PTYs survive CC loss.
|
||||||
let (terminal_manager, mut terminal_events) = TerminalManager::new(&config);
|
let (terminal_manager, mut terminal_events) = TerminalManager::new(&config);
|
||||||
let mut backoff = config.reconnect_base_ms.max(100);
|
let mut backoff = config.reconnect_base.max(Duration::from_millis(100));
|
||||||
loop {
|
loop {
|
||||||
match run_once(&config, &terminal_manager, &mut terminal_events).await {
|
match run_once(&config, &terminal_manager, &mut terminal_events).await {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
backoff = config.reconnect_base_ms.max(100);
|
backoff = config.reconnect_base.max(Duration::from_millis(100));
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
warn!(error = %err, backoff_ms = backoff, "agent session ended; reconnecting");
|
warn!(agent_id = %config.agent_id, error = %err, backoff_ms = backoff.as_millis() as u64, "agent session ended; reconnecting");
|
||||||
sleep(Duration::from_millis(backoff)).await;
|
sleep(backoff).await;
|
||||||
backoff = next_backoff_ms(backoff, config.reconnect_max_ms);
|
backoff = next_backoff(backoff, config.reconnect_max);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -40,20 +40,14 @@ async fn run_once(
|
|||||||
crate::terminal::TerminalManagerEvent,
|
crate::terminal::TerminalManagerEvent,
|
||||||
>,
|
>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let session_id = format!(
|
// Connection-long spans export too late and entered guards are unsafe across
|
||||||
"{}-{}",
|
// awaits. Short phase spans export while a healthy socket is still running.
|
||||||
std::process::id(),
|
|
||||||
std::time::SystemTime::now()
|
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
|
||||||
.map(|d| d.as_millis())
|
|
||||||
.unwrap_or_default()
|
|
||||||
);
|
|
||||||
let span = info_span!("agent_session", session_id = %session_id, agent_id = %config.agent_id);
|
|
||||||
let _span_guard = span.enter(); // the ENTIRETY of below is agent_session.
|
|
||||||
|
|
||||||
let ws_url = websocket_url(&config.server_url)?;
|
let ws_url = websocket_url(&config.server_url)?;
|
||||||
info!(%ws_url, agent_id = %config.agent_id, "connecting agent websocket");
|
info!(%ws_url, agent_id = %config.agent_id, "connecting agent websocket");
|
||||||
if let Some((dns_resolve_ms, resolved_addrs)) = dns_resolution_diagnostics(&ws_url).await {
|
if let Some((dns_resolve_ms, resolved_addrs)) = dns_resolution_diagnostics(&ws_url)
|
||||||
|
.instrument(info_span!("agent_dns", agent_id = %config.agent_id))
|
||||||
|
.await
|
||||||
|
{
|
||||||
info!(
|
info!(
|
||||||
host = %ws_url.host_str().unwrap_or(""),
|
host = %ws_url.host_str().unwrap_or(""),
|
||||||
dns_resolve_ms,
|
dns_resolve_ms,
|
||||||
@@ -70,7 +64,15 @@ async fn run_once(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let connect_started = Instant::now();
|
let connect_started = Instant::now();
|
||||||
let (stream, _) = connect_async(ws_url.as_str())
|
let (stream, _) = async {
|
||||||
|
let mut operation = wakey_core::telemetry::Operation::new("agent_connect");
|
||||||
|
let result = connect_async(ws_url.as_str()).await;
|
||||||
|
if result.is_ok() {
|
||||||
|
operation.outcome("ok");
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
.instrument(info_span!("agent_connect", agent_id = %config.agent_id))
|
||||||
.await
|
.await
|
||||||
.context("failed to connect websocket")?;
|
.context("failed to connect websocket")?;
|
||||||
let ws_connect_ms = connect_started.elapsed().as_millis() as u64;
|
let ws_connect_ms = connect_started.elapsed().as_millis() as u64;
|
||||||
@@ -80,6 +82,7 @@ async fn run_once(
|
|||||||
}
|
}
|
||||||
let (mut sink, mut source) = stream.split();
|
let (mut sink, mut source) = stream.split();
|
||||||
|
|
||||||
|
async {
|
||||||
send_json(
|
send_json(
|
||||||
&mut sink,
|
&mut sink,
|
||||||
&ClientMessage::Hello {
|
&ClientMessage::Hello {
|
||||||
@@ -91,7 +94,7 @@ async fn run_once(
|
|||||||
.enabled
|
.enabled
|
||||||
.then_some(TerminalCapabilityOptions {
|
.then_some(TerminalCapabilityOptions {
|
||||||
max_sessions: config.terminal.max_sessions.max(1),
|
max_sessions: config.terminal.max_sessions.max(1),
|
||||||
session_ttl_seconds: config.terminal.session_ttl_seconds,
|
session_ttl_seconds: config.terminal.session_ttl.as_secs(),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -106,13 +109,15 @@ async fn run_once(
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
send_terminal_sessions(&mut sink, terminal_manager).await?;
|
send_terminal_sessions(&mut sink, terminal_manager).await?;
|
||||||
info!(agent_id = %config.agent_id, "agent websocket session authenticated");
|
Ok::<_, anyhow::Error>(())
|
||||||
|
}
|
||||||
|
.instrument(info_span!("agent_handshake_send", agent_id = %config.agent_id))
|
||||||
|
.await?;
|
||||||
|
info!(agent_id = %config.agent_id, "agent hello and credentials sent; awaiting server traffic");
|
||||||
|
|
||||||
let mut heartbeat = interval(Duration::from_secs(30));
|
let mut heartbeat = interval(Duration::from_secs(30));
|
||||||
heartbeat.set_missed_tick_behavior(MissedTickBehavior::Skip);
|
heartbeat.set_missed_tick_behavior(MissedTickBehavior::Skip);
|
||||||
let mut snapshot_sync = interval(Duration::from_secs(
|
let mut snapshot_sync = interval(config.observation_sync_interval);
|
||||||
config.observation_sync_interval_seconds.max(1),
|
|
||||||
));
|
|
||||||
snapshot_sync.set_missed_tick_behavior(MissedTickBehavior::Skip);
|
snapshot_sync.set_missed_tick_behavior(MissedTickBehavior::Skip);
|
||||||
snapshot_sync.reset();
|
snapshot_sync.reset();
|
||||||
loop {
|
loop {
|
||||||
@@ -195,11 +200,13 @@ where
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(name = "collect_device_snapshot", skip_all)]
|
||||||
async fn send_device_snapshot_ws<S>(sink: &mut S, config: &AgentConfig) -> Result<usize>
|
async fn send_device_snapshot_ws<S>(sink: &mut S, config: &AgentConfig) -> Result<usize>
|
||||||
where
|
where
|
||||||
S: SinkExt<Message> + Unpin,
|
S: SinkExt<Message> + Unpin,
|
||||||
<S as futures_util::Sink<Message>>::Error: std::error::Error + Send + Sync + 'static,
|
<S as futures_util::Sink<Message>>::Error: std::error::Error + Send + Sync + 'static,
|
||||||
{
|
{
|
||||||
|
let mut operation = wakey_core::telemetry::Operation::new("snapshot");
|
||||||
let req = crate::protocol::InventoryRequest {
|
let req = crate::protocol::InventoryRequest {
|
||||||
query: None,
|
query: None,
|
||||||
name: None,
|
name: None,
|
||||||
@@ -217,16 +224,22 @@ where
|
|||||||
&ClientMessage::DeviceSnapshot {
|
&ClientMessage::DeviceSnapshot {
|
||||||
agent_id: config.agent_id.clone(),
|
agent_id: config.agent_id.clone(),
|
||||||
devices: inventory.devices,
|
devices: inventory.devices,
|
||||||
|
trace_context: wakey_core::telemetry::current_trace_context(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
debug!(agent_id = %config.agent_id, devices = count, "sent device snapshot over websocket");
|
debug!(agent_id = %config.agent_id, devices = count, "sent device snapshot over websocket");
|
||||||
|
operation.outcome("ok");
|
||||||
|
wakey_core::telemetry::count(
|
||||||
|
wakey_core::telemetry::Counter::SnapshotDevices,
|
||||||
|
count as u64,
|
||||||
|
);
|
||||||
Ok(count)
|
Ok(count)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn next_backoff_ms(current_ms: u64, max_ms: u64) -> u64 {
|
pub fn next_backoff(current: Duration, max: Duration) -> Duration {
|
||||||
let cap = max_ms.max(current_ms);
|
let cap = max.max(current);
|
||||||
current_ms.saturating_mul(2).min(cap)
|
current.saturating_mul(2).min(cap)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_server_message<S>(
|
async fn handle_server_message<S>(
|
||||||
@@ -244,11 +257,16 @@ where
|
|||||||
ServerMessage::Command {
|
ServerMessage::Command {
|
||||||
request_id,
|
request_id,
|
||||||
command,
|
command,
|
||||||
|
trace_context,
|
||||||
} => {
|
} => {
|
||||||
let kind = command_kind(&command);
|
let kind = command_kind(&command);
|
||||||
|
let span = info_span!("execute_command", command = kind, request_id = %request_id);
|
||||||
|
wakey_core::telemetry::set_remote_parent(&span, &trace_context);
|
||||||
|
let mut operation = wakey_core::telemetry::Operation::new(kind);
|
||||||
info!(request_id = %request_id, command = %kind, "received command from control-plane");
|
info!(request_id = %request_id, command = %kind, "received command from control-plane");
|
||||||
match dispatch_command(command, config).await {
|
match tracing::Instrument::instrument(dispatch_command(command, config), span).await {
|
||||||
Ok(result) => {
|
Ok(result) => {
|
||||||
|
operation.outcome("ok");
|
||||||
info!(request_id = %request_id, command = %kind, "command execution completed");
|
info!(request_id = %request_id, command = %kind, "command execution completed");
|
||||||
send_command_result(sink, request_id, kind, result).await?;
|
send_command_result(sink, request_id, kind, result).await?;
|
||||||
}
|
}
|
||||||
@@ -455,7 +473,7 @@ mod tests {
|
|||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use tokio_tungstenite::tungstenite::Message;
|
use tokio_tungstenite::tungstenite::Message;
|
||||||
|
|
||||||
use super::{next_backoff_ms, send_command_result, websocket_url};
|
use super::{next_backoff, send_command_result, websocket_url};
|
||||||
use crate::protocol::{CommandResult, RequestId};
|
use crate::protocol::{CommandResult, RequestId};
|
||||||
|
|
||||||
struct RecordingSink {
|
struct RecordingSink {
|
||||||
@@ -514,14 +532,28 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn backoff_doubles_until_cap() {
|
fn backoff_doubles_until_cap() {
|
||||||
assert_eq!(next_backoff_ms(1_000, 30_000), 2_000);
|
use std::time::Duration;
|
||||||
assert_eq!(next_backoff_ms(16_000, 30_000), 30_000);
|
assert_eq!(
|
||||||
assert_eq!(next_backoff_ms(30_000, 30_000), 30_000);
|
next_backoff(Duration::from_secs(1), Duration::from_secs(30)),
|
||||||
|
Duration::from_secs(2)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
next_backoff(Duration::from_secs(16), Duration::from_secs(30)),
|
||||||
|
Duration::from_secs(30)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
next_backoff(Duration::from_secs(30), Duration::from_secs(30)),
|
||||||
|
Duration::from_secs(30)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn backoff_never_shrinks_when_max_is_lower() {
|
fn backoff_never_shrinks_when_max_is_lower() {
|
||||||
assert_eq!(next_backoff_ms(8_000, 1_000), 8_000);
|
use std::time::Duration;
|
||||||
|
assert_eq!(
|
||||||
|
next_backoff(Duration::from_secs(8), Duration::from_secs(1)),
|
||||||
|
Duration::from_secs(8)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ struct RelayOutputBuffer {
|
|||||||
sender: Option<mpsc::Sender<Message>>,
|
sender: Option<mpsc::Sender<Message>>,
|
||||||
pending: VecDeque<Message>,
|
pending: VecDeque<Message>,
|
||||||
snapshot_pending: bool,
|
snapshot_pending: bool,
|
||||||
|
backpressure_wait: Option<wakey_core::telemetry::Operation>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RelayOutputBuffer {
|
impl RelayOutputBuffer {
|
||||||
@@ -44,6 +45,9 @@ impl RelayOutputBuffer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn disconnect(&mut self) {
|
fn disconnect(&mut self) {
|
||||||
|
if let Some(mut wait) = self.backpressure_wait.take() {
|
||||||
|
wait.outcome("disconnected");
|
||||||
|
}
|
||||||
self.sender = None;
|
self.sender = None;
|
||||||
self.pending.clear();
|
self.pending.clear();
|
||||||
self.snapshot_pending = false;
|
self.snapshot_pending = false;
|
||||||
@@ -80,6 +84,14 @@ impl RelayOutputBuffer {
|
|||||||
match sender.try_send(frame) {
|
match sender.try_send(frame) {
|
||||||
Ok(()) => {}
|
Ok(()) => {}
|
||||||
Err(mpsc::error::TrySendError::Full(frame)) => {
|
Err(mpsc::error::TrySendError::Full(frame)) => {
|
||||||
|
// One measurement per backlog episode, not per select poll.
|
||||||
|
self.backpressure_wait = Some(wakey_core::telemetry::Operation::new(
|
||||||
|
"terminal_backpressure_wait",
|
||||||
|
));
|
||||||
|
wakey_core::telemetry::count(
|
||||||
|
wakey_core::telemetry::Counter::TerminalBackpressure,
|
||||||
|
1,
|
||||||
|
);
|
||||||
self.pending.push_back(frame);
|
self.pending.push_back(frame);
|
||||||
}
|
}
|
||||||
Err(mpsc::error::TrySendError::Closed(_)) => self.disconnect(),
|
Err(mpsc::error::TrySendError::Closed(_)) => self.disconnect(),
|
||||||
@@ -107,6 +119,9 @@ impl RelayOutputBuffer {
|
|||||||
permit.send(frame);
|
permit.send(frame);
|
||||||
if self.pending.is_empty() {
|
if self.pending.is_empty() {
|
||||||
self.snapshot_pending = false;
|
self.snapshot_pending = false;
|
||||||
|
if let Some(mut wait) = self.backpressure_wait.take() {
|
||||||
|
wait.outcome("ok");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,10 +145,14 @@ impl TerminalState {
|
|||||||
self.parser.screen_mut().set_size(rows, cols);
|
self.parser.screen_mut().set_size(rows, cols);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(name = "terminal_snapshot_generate", skip_all, fields(bytes = tracing::field::Empty))]
|
||||||
fn snapshot(&self) -> Vec<u8> {
|
fn snapshot(&self) -> Vec<u8> {
|
||||||
self.parser
|
let snapshot = self
|
||||||
|
.parser
|
||||||
.screen()
|
.screen()
|
||||||
.snapshot_formatted(TERMINAL_SCROLLBACK_ROWS)
|
.snapshot_formatted(TERMINAL_SCROLLBACK_ROWS);
|
||||||
|
tracing::Span::current().record("bytes", snapshot.len());
|
||||||
|
snapshot
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,6 +188,7 @@ impl TerminalManager {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(name = "terminal_open", skip_all, fields(terminal_id = %terminal_id))]
|
||||||
pub fn open(
|
pub fn open(
|
||||||
&self,
|
&self,
|
||||||
config: &AgentConfig,
|
config: &AgentConfig,
|
||||||
@@ -204,7 +224,7 @@ impl TerminalManager {
|
|||||||
cancel: Some(cancel_tx),
|
cancel: Some(cancel_tx),
|
||||||
relay_credentials: relay_tx.clone(),
|
relay_credentials: relay_tx.clone(),
|
||||||
created_at_unix,
|
created_at_unix,
|
||||||
session_ttl_seconds: config.terminal.session_ttl_seconds,
|
session_ttl_seconds: config.terminal.session_ttl.as_secs(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -214,6 +234,7 @@ impl TerminalManager {
|
|||||||
let active = Arc::downgrade(&self.active);
|
let active = Arc::downgrade(&self.active);
|
||||||
let events = self.events.clone();
|
let events = self.events.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
|
let _active_metric = wakey_core::telemetry::ActiveTerminal::new();
|
||||||
let result = run_terminal(
|
let result = run_terminal(
|
||||||
&config,
|
&config,
|
||||||
&terminal_id,
|
&terminal_id,
|
||||||
@@ -238,6 +259,7 @@ impl TerminalManager {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(name = "terminal_close", skip_all, fields(terminal_id = %terminal_id))]
|
||||||
pub fn close(&self, terminal_id: &TerminalId) -> bool {
|
pub fn close(&self, terminal_id: &TerminalId) -> bool {
|
||||||
let mut active = self.active.lock().expect("terminal manager poisoned");
|
let mut active = self.active.lock().expect("terminal manager poisoned");
|
||||||
let Some(terminal) = active.get_mut(terminal_id.as_str()) else {
|
let Some(terminal) = active.get_mut(terminal_id.as_str()) else {
|
||||||
@@ -253,7 +275,9 @@ impl TerminalManager {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(name = "terminal_resume", skip_all, fields(terminal_id = %terminal_id))]
|
||||||
pub fn resume(&self, terminal_id: &TerminalId, relay_token: String) -> Result<()> {
|
pub fn resume(&self, terminal_id: &TerminalId, relay_token: String) -> Result<()> {
|
||||||
|
let mut operation = wakey_core::telemetry::Operation::new("terminal_resume");
|
||||||
let active = self.active.lock().expect("terminal manager poisoned");
|
let active = self.active.lock().expect("terminal manager poisoned");
|
||||||
let session = active
|
let session = active
|
||||||
.get(terminal_id.as_str())
|
.get(terminal_id.as_str())
|
||||||
@@ -261,6 +285,8 @@ impl TerminalManager {
|
|||||||
if session.relay_credentials.send(relay_token).is_err() {
|
if session.relay_credentials.send(relay_token).is_err() {
|
||||||
anyhow::bail!("terminal session {terminal_id} has stopped");
|
anyhow::bail!("terminal session {terminal_id} has stopped");
|
||||||
}
|
}
|
||||||
|
operation.outcome("ok");
|
||||||
|
info!("terminal resume credentials queued for worker");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -453,6 +479,7 @@ async fn run_terminal(
|
|||||||
match read {
|
match read {
|
||||||
Ok(0) => break,
|
Ok(0) => break,
|
||||||
Ok(count) => {
|
Ok(count) => {
|
||||||
|
wakey_core::telemetry::count(wakey_core::telemetry::Counter::TerminalOutputBytes, count as u64);
|
||||||
terminal_state.process(&output[..count]);
|
terminal_state.process(&output[..count]);
|
||||||
relay_output.queue_live(Message::Binary(output[..count].to_vec().into()));
|
relay_output.queue_live(Message::Binary(output[..count].to_vec().into()));
|
||||||
}
|
}
|
||||||
@@ -575,6 +602,7 @@ where
|
|||||||
let Some(message) = incoming else { break; };
|
let Some(message) = incoming else { break; };
|
||||||
match message.context("terminal relay websocket receive failed")? {
|
match message.context("terminal relay websocket receive failed")? {
|
||||||
Message::Binary(bytes) => {
|
Message::Binary(bytes) => {
|
||||||
|
wakey_core::telemetry::count(wakey_core::telemetry::Counter::TerminalInputBytes, bytes.len() as u64);
|
||||||
pending_input = Some(RelayInput::Binary {
|
pending_input = Some(RelayInput::Binary {
|
||||||
generation,
|
generation,
|
||||||
bytes: bytes.to_vec(),
|
bytes: bytes.to_vec(),
|
||||||
@@ -1180,6 +1208,7 @@ mod tests {
|
|||||||
|
|
||||||
assert!(relay.is_connected());
|
assert!(relay.is_connected());
|
||||||
assert!(relay.has_pending());
|
assert!(relay.has_pending());
|
||||||
|
assert!(relay.backpressure_wait.is_some());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
rx.recv().await.expect("original queued frame").into_data(),
|
rx.recv().await.expect("original queued frame").into_data(),
|
||||||
vec![1]
|
vec![1]
|
||||||
@@ -1193,6 +1222,20 @@ mod tests {
|
|||||||
vec![2]
|
vec![2]
|
||||||
);
|
);
|
||||||
assert!(!relay.has_pending());
|
assert!(!relay.has_pending());
|
||||||
|
assert!(relay.backpressure_wait.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn disconnect_finishes_backpressure_episode() {
|
||||||
|
let (tx, _rx) = mpsc::channel(1);
|
||||||
|
let mut relay = RelayOutputBuffer::default();
|
||||||
|
relay.connect(tx);
|
||||||
|
relay.queue_live(Message::Binary(vec![1].into()));
|
||||||
|
relay.queue_live(Message::Binary(vec![2].into()));
|
||||||
|
assert!(relay.backpressure_wait.is_some());
|
||||||
|
relay.disconnect();
|
||||||
|
assert!(relay.backpressure_wait.is_none());
|
||||||
|
assert!(!relay.has_pending());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
+19
-25
@@ -1,28 +1,22 @@
|
|||||||
use time::UtcOffset;
|
use anyhow::Result;
|
||||||
use time::format_description::well_known::Rfc3339;
|
use wakey_core::telemetry::{TelemetryConfig, TelemetryGuard};
|
||||||
use tracing_subscriber::fmt::time::OffsetTime;
|
|
||||||
use tracing_subscriber::{EnvFilter, fmt, layer::SubscriberExt, util::SubscriberInitExt};
|
|
||||||
|
|
||||||
pub fn init(verbose: u8) {
|
pub fn init(
|
||||||
let filter = EnvFilter::try_from_default_env()
|
verbose: u8,
|
||||||
.or_else(|_| EnvFilter::try_new(default_filter(verbose)))
|
config: &TelemetryConfig,
|
||||||
.expect("static tracing filter should parse");
|
agent_id: Option<&str>,
|
||||||
|
) -> Result<TelemetryGuard> {
|
||||||
tracing_subscriber::registry()
|
let guard = wakey_core::telemetry::init(
|
||||||
.with(filter)
|
config,
|
||||||
.with(fmt::layer().with_timer(local_offset_timer()))
|
"wakey-agent",
|
||||||
.init();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_filter(verbose: u8) -> &'static str {
|
|
||||||
match verbose {
|
match verbose {
|
||||||
0 => "wakey_agent=info,wakey=info",
|
0 => "wakey_agent=info,wakey=info,wakey_core=info,opentelemetry=warn",
|
||||||
1 => "wakey_agent=debug,wakey=debug",
|
1 => "wakey_agent=debug,wakey=debug,wakey_core=info,opentelemetry=warn",
|
||||||
_ => "wakey_agent=trace,wakey=debug",
|
_ => "wakey_agent=trace,wakey=debug,wakey_core=info,opentelemetry=warn",
|
||||||
}
|
},
|
||||||
}
|
agent_id,
|
||||||
|
)?;
|
||||||
fn local_offset_timer() -> OffsetTime<Rfc3339> {
|
#[cfg(target_os = "linux")]
|
||||||
let offset = UtcOffset::current_local_offset().unwrap_or(UtcOffset::UTC);
|
let guard = guard.with_process_metrics(wakey_linux::telemetry::ProcfsProcessMetrics);
|
||||||
OffsetTime::new(offset, Rfc3339)
|
Ok(guard)
|
||||||
}
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "UPDATE known_devices SET display_name = ?1, updated_at_unix = ?2 WHERE device_id = ?3",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 3
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "897b794a7f94af42b545d74a5b859850fc9935f40385061b029fab5892a2aded"
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "DELETE FROM agent_devices WHERE last_seen_unix < ?1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "cc4b885c31f965013ccaa6f8ae33f6c8d68898654fc0873682c8212339088306"
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "wakey-control-plane"
|
name = "wakey-control-plane"
|
||||||
version = "0.3.6"
|
version = "0.3.7"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
publish = ["gitea"]
|
publish = ["gitea"]
|
||||||
|
|
||||||
@@ -9,6 +9,9 @@ anyhow = { workspace = true }
|
|||||||
axum = { version = "0.8", features = ["ws", "json"] }
|
axum = { version = "0.8", features = ["ws", "json"] }
|
||||||
clap = { version = "4", features = ["derive"] }
|
clap = { version = "4", features = ["derive"] }
|
||||||
futures-util = "0.3"
|
futures-util = "0.3"
|
||||||
|
humantime-serde = "1"
|
||||||
|
http-cache-semantics = { version = "3", default-features = false, features = ["reqwest"] }
|
||||||
|
jsonwebtoken = { version = "10.3", default-features = false, features = ["aws_lc_rs"] }
|
||||||
macaddr = { workspace = true }
|
macaddr = { workspace = true }
|
||||||
nix = { version = "0.31", default-features = false, features = [
|
nix = { version = "0.31", default-features = false, features = [
|
||||||
"signal",
|
"signal",
|
||||||
@@ -20,6 +23,7 @@ reqwest = { version = "0.13", default-features = false, features = [
|
|||||||
] }
|
] }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
|
thiserror = "2"
|
||||||
|
|
||||||
sqlx = { version = "0.8", features = [
|
sqlx = { version = "0.8", features = [
|
||||||
"runtime-tokio",
|
"runtime-tokio",
|
||||||
@@ -33,17 +37,13 @@ toml = "1.1"
|
|||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
tower-http = { version = "0.6.8", features = ["fs"] }
|
tower-http = { version = "0.6.8", features = ["fs"] }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
tracing-opentelemetry = "0.32"
|
|
||||||
tracing-subscriber = { version = "0.3", features = [
|
|
||||||
"env-filter",
|
|
||||||
"fmt",
|
|
||||||
"json",
|
|
||||||
"time",
|
|
||||||
] }
|
|
||||||
time = { version = "0.3", features = ["formatting", "local-offset"] }
|
|
||||||
opentelemetry = "0.31"
|
|
||||||
opentelemetry-otlp = { version = "0.31", features = ["grpc-tonic", "trace"] }
|
|
||||||
opentelemetry_sdk = { version = "0.31", features = ["rt-tokio"] }
|
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
wakey-agent = { path = "../wakey-agent", registry = "gitea", version = "0" }
|
wakey-agent = { path = "../wakey-agent", registry = "gitea", version = "0" }
|
||||||
wakey-core = { path = "../wakey-core", registry = "gitea", version = "0" }
|
wakey-core = { path = "../wakey-core", registry = "gitea", version = "0", features = ["telemetry"] }
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
|
wakey-linux = { path = "../wakey-linux", registry = "gitea", version = "0", features = ["telemetry"] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tower = { version = "0.5", features = ["util"] }
|
||||||
|
jsonwebtoken = { version = "10.3", default-features = false, features = ["use_pem", "aws_lc_rs"] }
|
||||||
@@ -4,7 +4,7 @@ use axum::http::StatusCode;
|
|||||||
use axum::response::IntoResponse;
|
use axum::response::IntoResponse;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
use tracing::{info, info_span, warn};
|
use tracing::{info, warn};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use wakey_agent::protocol::{
|
use wakey_agent::protocol::{
|
||||||
AgentCapability, AgentCapabilityOptions, AgentCommand, ErrorPayload, RequestId, ServerMessage,
|
AgentCapability, AgentCapabilityOptions, AgentCommand, ErrorPayload, RequestId, ServerMessage,
|
||||||
@@ -85,6 +85,7 @@ pub async fn run_command(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(name = "relay_command", skip_all, fields(agent_id = %agent_id, request_id = tracing::field::Empty, command = tracing::field::Empty))]
|
||||||
pub async fn relay_agent_command(
|
pub async fn relay_agent_command(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
agent_id: &str,
|
agent_id: &str,
|
||||||
@@ -93,14 +94,11 @@ pub async fn relay_agent_command(
|
|||||||
) -> Result<RelayCommandResponse, ApiError> {
|
) -> Result<RelayCommandResponse, ApiError> {
|
||||||
let request_id_string = format!("req-{}", Uuid::new_v4());
|
let request_id_string = format!("req-{}", Uuid::new_v4());
|
||||||
let command_kind = command_kind(&command);
|
let command_kind = command_kind(&command);
|
||||||
|
tracing::Span::current()
|
||||||
|
.record("request_id", &request_id_string)
|
||||||
|
.record("command", command_kind);
|
||||||
let started = Instant::now();
|
let started = Instant::now();
|
||||||
let span = info_span!(
|
let mut operation = wakey_core::telemetry::Operation::new(command_kind);
|
||||||
"relay_command",
|
|
||||||
agent_id = %agent_id,
|
|
||||||
request_id = %request_id_string,
|
|
||||||
command = %command_kind,
|
|
||||||
);
|
|
||||||
let _span_guard = span.enter();
|
|
||||||
|
|
||||||
let request_id = RequestId::try_from(request_id_string.clone()).map_err(|err| {
|
let request_id = RequestId::try_from(request_id_string.clone()).map_err(|err| {
|
||||||
ApiError::new(
|
ApiError::new(
|
||||||
@@ -149,6 +147,7 @@ pub async fn relay_agent_command(
|
|||||||
if let Err(err) = tx.send(SessionEvent::Message(ServerMessage::Command {
|
if let Err(err) = tx.send(SessionEvent::Message(ServerMessage::Command {
|
||||||
request_id,
|
request_id,
|
||||||
command,
|
command,
|
||||||
|
trace_context: wakey_core::telemetry::current_trace_context(),
|
||||||
})) {
|
})) {
|
||||||
state.pending.lock().await.remove(&request_id_string);
|
state.pending.lock().await.remove(&request_id_string);
|
||||||
warn!(error = %err, "failed sending command to agent session");
|
warn!(error = %err, "failed sending command to agent session");
|
||||||
@@ -173,11 +172,9 @@ pub async fn relay_agent_command(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let timeout = std::time::Duration::from_millis(
|
let timeout = timeout_ms
|
||||||
timeout_ms
|
.map(|value| std::time::Duration::from_millis(value.max(1)))
|
||||||
.unwrap_or(state.command_timeout.as_millis() as u64)
|
.unwrap_or(state.command_timeout);
|
||||||
.max(1),
|
|
||||||
);
|
|
||||||
let outcome = tokio::time::timeout(timeout, pending_rx).await;
|
let outcome = tokio::time::timeout(timeout, pending_rx).await;
|
||||||
let response = match outcome {
|
let response = match outcome {
|
||||||
Ok(Ok(AgentReply::Result(result))) => {
|
Ok(Ok(AgentReply::Result(result))) => {
|
||||||
@@ -249,6 +246,7 @@ pub async fn relay_agent_command(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
|
operation.outcome("timeout");
|
||||||
state.pending.lock().await.remove(&request_id_string);
|
state.pending.lock().await.remove(&request_id_string);
|
||||||
warn!(
|
warn!(
|
||||||
timeout_ms = timeout.as_millis() as u64,
|
timeout_ms = timeout.as_millis() as u64,
|
||||||
@@ -276,6 +274,11 @@ pub async fn relay_agent_command(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
operation.outcome(if response.status == "ok" {
|
||||||
|
"ok"
|
||||||
|
} else {
|
||||||
|
"error"
|
||||||
|
});
|
||||||
Ok(response)
|
Ok(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,12 +5,14 @@ mod stats;
|
|||||||
|
|
||||||
pub use devices::{
|
pub use devices::{
|
||||||
attach_device_identifier, create_known_device, detach_device_identifier, forget_known_device,
|
attach_device_identifier, create_known_device, detach_device_identifier, forget_known_device,
|
||||||
get_known_device, list_known_devices, merge_known_device,
|
get_known_device, list_known_devices, merge_known_device, rename_known_device,
|
||||||
};
|
};
|
||||||
pub use enroll::{
|
pub use enroll::{
|
||||||
EnrollTokenStatus, IssueEnrollTokenResponse, RevokeAgentResponse, RevokeEnrollTokenResponse,
|
EnrollTokenStatus, IssueEnrollTokenResponse, RevokeAgentResponse, RevokeEnrollTokenResponse,
|
||||||
enroll, healthz, issue_enroll_token, list_enroll_tokens, revoke_agent, revoke_enroll_token,
|
enroll, healthz, issue_enroll_token, list_enroll_tokens, revoke_agent, revoke_enroll_token,
|
||||||
set_agent_nickname,
|
set_agent_nickname,
|
||||||
};
|
};
|
||||||
pub use fleet::{list_fleet_devices, refresh_fleet_devices, wake_fleet_device};
|
pub(crate) use fleet::FleetCache;
|
||||||
|
pub(crate) use fleet::sample_fleet_counts;
|
||||||
|
pub use fleet::{get_fleet_device, list_fleet_devices, refresh_fleet_devices, wake_fleet_device};
|
||||||
pub use stats::{StateStatsResponse, state_stats};
|
pub use stats::{StateStatsResponse, state_stats};
|
||||||
@@ -25,6 +25,47 @@ pub struct DeviceIdentifierRequest {
|
|||||||
pub value: String,
|
pub value: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct RenameKnownDeviceRequest {
|
||||||
|
pub display_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn rename_known_device(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AxumPath(device_id): AxumPath<String>,
|
||||||
|
Json(req): Json<RenameKnownDeviceRequest>,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
// The store validates too, but checking here distinguishes bad input from
|
||||||
|
// storage failures without interpreting anyhow error messages.
|
||||||
|
if req.display_name.trim().is_empty() {
|
||||||
|
return Err(ApiError::new(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"rename_known_device_failed",
|
||||||
|
"display_name must not be empty",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
match state
|
||||||
|
.store
|
||||||
|
.rename_known_device(&device_id, &req.display_name)
|
||||||
|
.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 rename known device");
|
||||||
|
Err(ApiError::new(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"rename_known_device_failed",
|
||||||
|
err.to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct KnownDeviceResponse {
|
pub struct KnownDeviceResponse {
|
||||||
pub device_id: String,
|
pub device_id: String,
|
||||||
@@ -262,3 +303,112 @@ fn known_device_response(device: crate::state::KnownDevice) -> KnownDeviceRespon
|
|||||||
.collect(),
|
.collect(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::sync::{Mutex, RwLock};
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rename_distinguishes_invalid_names_missing_devices_and_storage_failures() {
|
||||||
|
let dir = std::env::temp_dir().join(format!("wakey-rename-api-{}", uuid::Uuid::new_v4()));
|
||||||
|
let store = Arc::new(
|
||||||
|
crate::state::Store::load_or_init(
|
||||||
|
&dir.join("state.sqlite3"),
|
||||||
|
vec![],
|
||||||
|
Duration::from_secs(60),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
let state = AppState {
|
||||||
|
store: store.clone(),
|
||||||
|
sessions: Arc::new(RwLock::new(HashMap::new())),
|
||||||
|
pending: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
public_url: "http://localhost".into(),
|
||||||
|
command_timeout: Duration::from_secs(30),
|
||||||
|
enroll_token_ttl: Duration::from_secs(60),
|
||||||
|
terminals: crate::runtime::terminals::TerminalRegistry::new(),
|
||||||
|
alerts: crate::api::AlertsHub::new(),
|
||||||
|
fleet: Arc::new(crate::api::FleetCache::default()),
|
||||||
|
};
|
||||||
|
let device = store
|
||||||
|
.create_known_device(KnownDeviceInput {
|
||||||
|
display_name: "before".into(),
|
||||||
|
pinned: false,
|
||||||
|
notes: None,
|
||||||
|
identifiers: vec![],
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let rename = |id: String, name: &str| {
|
||||||
|
rename_known_device(
|
||||||
|
State(state.clone()),
|
||||||
|
AxumPath(id),
|
||||||
|
Json(RenameKnownDeviceRequest {
|
||||||
|
display_name: name.into(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let response = rename(device.device_id.clone(), " after ")
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.into_response();
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
assert_eq!(
|
||||||
|
store
|
||||||
|
.get_known_device(&device.device_id)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
.display_name,
|
||||||
|
"after"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
rename("missing".into(), "valid")
|
||||||
|
.await
|
||||||
|
.err()
|
||||||
|
.unwrap()
|
||||||
|
.status,
|
||||||
|
StatusCode::NOT_FOUND
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
rename(device.device_id.clone(), " \t\n ")
|
||||||
|
.await
|
||||||
|
.err()
|
||||||
|
.unwrap()
|
||||||
|
.status,
|
||||||
|
StatusCode::BAD_REQUEST
|
||||||
|
);
|
||||||
|
// A deterministic SQLite failure, not a clock-sensitive lock timeout.
|
||||||
|
let mut tx = store.begin_write().await.unwrap();
|
||||||
|
sqlx::query("CREATE TRIGGER reject_rename BEFORE UPDATE ON known_devices BEGIN SELECT RAISE(ABORT, 'test rename failure'); END")
|
||||||
|
.execute(&mut *tx).await.unwrap();
|
||||||
|
tx.commit().await.unwrap();
|
||||||
|
let failure = rename(device.device_id.clone(), "valid")
|
||||||
|
.await
|
||||||
|
.err()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(failure.status, StatusCode::INTERNAL_SERVER_ERROR);
|
||||||
|
assert_eq!(failure.code, "rename_known_device_failed");
|
||||||
|
assert_eq!(
|
||||||
|
store
|
||||||
|
.get_known_device(&device.device_id)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
.display_name,
|
||||||
|
"after"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
rename(device.device_id, " ").await.err().unwrap().status,
|
||||||
|
StatusCode::BAD_REQUEST
|
||||||
|
);
|
||||||
|
drop(state);
|
||||||
|
drop(store);
|
||||||
|
std::fs::remove_dir_all(dir).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,10 +2,15 @@ use std::collections::{BTreeMap, BTreeSet, HashMap};
|
|||||||
use std::net::IpAddr;
|
use std::net::IpAddr;
|
||||||
|
|
||||||
use macaddr::MacAddr;
|
use macaddr::MacAddr;
|
||||||
use wakey_core::{EndpointSource, Presence};
|
use wakey_core::Presence;
|
||||||
|
|
||||||
use crate::state::{AgentDeviceWithChildren, DeviceIdentifier, KnownDevice, KnownDeviceSummary};
|
use crate::state::{AgentDeviceWithChildren, DeviceIdentifier, KnownDevice, KnownDeviceSummary};
|
||||||
|
|
||||||
|
mod addresses;
|
||||||
|
mod routes;
|
||||||
|
use addresses::add_address_evidence;
|
||||||
|
use routes::{add_endpoint_routes, add_legacy_routes, compare_route_quality};
|
||||||
|
|
||||||
use super::types::{
|
use super::types::{
|
||||||
FleetDevice, FleetDeviceAgent, FleetDeviceEndpoint, FleetWakeRoute, ListFleetDevicesQuery,
|
FleetDevice, FleetDeviceAgent, FleetDeviceEndpoint, FleetWakeRoute, ListFleetDevicesQuery,
|
||||||
};
|
};
|
||||||
@@ -26,6 +31,9 @@ pub(crate) struct AgentRuntimeStatus {
|
|||||||
struct FleetAccumulator {
|
struct FleetAccumulator {
|
||||||
device_key: String,
|
device_key: String,
|
||||||
display_name: Option<String>,
|
display_name: Option<String>,
|
||||||
|
/// Naming-only evidence, used when no name, MAC, or summary IP is available.
|
||||||
|
/// Must not contribute to summary IPs or wake routes.
|
||||||
|
fallback_ip: Option<IpAddr>,
|
||||||
known_device: Option<KnownDeviceSummary>,
|
known_device: Option<KnownDeviceSummary>,
|
||||||
pinned: bool,
|
pinned: bool,
|
||||||
ips: BTreeSet<IpAddr>,
|
ips: BTreeSet<IpAddr>,
|
||||||
@@ -45,6 +53,7 @@ impl Default for FleetAccumulator {
|
|||||||
Self {
|
Self {
|
||||||
device_key: String::new(),
|
device_key: String::new(),
|
||||||
display_name: None,
|
display_name: None,
|
||||||
|
fallback_ip: None,
|
||||||
known_device: None,
|
known_device: None,
|
||||||
pinned: false,
|
pinned: false,
|
||||||
ips: BTreeSet::new(),
|
ips: BTreeSet::new(),
|
||||||
@@ -55,12 +64,14 @@ impl Default for FleetAccumulator {
|
|||||||
endpoints: Vec::new(),
|
endpoints: Vec::new(),
|
||||||
first_seen_unix: None,
|
first_seen_unix: None,
|
||||||
last_seen_unix: None,
|
last_seen_unix: None,
|
||||||
presence: Presence::Offline,
|
// Start at the lowest signal so unknown-only evidence stays unknown.
|
||||||
|
presence: Presence::Unknown,
|
||||||
routes: BTreeMap::new(),
|
routes: BTreeMap::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(name = "fleet_group_devices", skip_all, fields(known = known_devices.len(), observed = agent_devices.len()))]
|
||||||
pub(crate) fn build_fleet_devices(
|
pub(crate) fn build_fleet_devices(
|
||||||
known_devices: Vec<KnownDevice>,
|
known_devices: Vec<KnownDevice>,
|
||||||
agent_devices: Vec<AgentDeviceWithChildren>,
|
agent_devices: Vec<AgentDeviceWithChildren>,
|
||||||
@@ -193,8 +204,22 @@ fn add_agent_device_to_entry(
|
|||||||
agent_device: AgentDeviceWithChildren,
|
agent_device: AgentDeviceWithChildren,
|
||||||
context: &FleetBuildContext,
|
context: &FleetBuildContext,
|
||||||
) {
|
) {
|
||||||
let device_offline = agent_device.device.presence() == Presence::Offline;
|
add_address_evidence(entry, &agent_device);
|
||||||
if let Some(summary) = device_known_device(&agent_device, context)
|
add_names_and_identity(entry, &agent_device, context);
|
||||||
|
let status = add_agent_summary(entry, &agent_device, context);
|
||||||
|
if agent_device.endpoints.is_empty() {
|
||||||
|
add_legacy_routes(entry, &agent_device, &status);
|
||||||
|
} else {
|
||||||
|
add_endpoint_routes(entry, &agent_device, &status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_names_and_identity(
|
||||||
|
entry: &mut FleetAccumulator,
|
||||||
|
agent_device: &AgentDeviceWithChildren,
|
||||||
|
context: &FleetBuildContext,
|
||||||
|
) {
|
||||||
|
if let Some(summary) = device_known_device(agent_device, context)
|
||||||
&& entry.known_device.is_none()
|
&& entry.known_device.is_none()
|
||||||
{
|
{
|
||||||
if let Some(ref name) = agent_device.device.display_name {
|
if let Some(ref name) = agent_device.device.display_name {
|
||||||
@@ -203,33 +228,19 @@ fn add_agent_device_to_entry(
|
|||||||
entry.pinned = summary.pinned;
|
entry.pinned = summary.pinned;
|
||||||
entry.known_device = Some(summary);
|
entry.known_device = Some(summary);
|
||||||
}
|
}
|
||||||
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 {
|
for hostname in &agent_device.hostnames {
|
||||||
if entry.display_name.is_none() {
|
if entry.display_name.is_none() {
|
||||||
entry.display_name = Some(hostname.clone());
|
entry.display_name = Some(hostname.clone());
|
||||||
}
|
}
|
||||||
entry.hostnames.insert(hostname.clone());
|
entry.hostnames.insert(hostname.clone());
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_agent_summary(
|
||||||
|
entry: &mut FleetAccumulator,
|
||||||
|
agent_device: &AgentDeviceWithChildren,
|
||||||
|
context: &FleetBuildContext,
|
||||||
|
) -> AgentRuntimeStatus {
|
||||||
let first_seen = agent_device.device.first_seen();
|
let first_seen = agent_device.device.first_seen();
|
||||||
let last_seen = agent_device.device.last_seen();
|
let last_seen = agent_device.device.last_seen();
|
||||||
entry.first_seen_unix = Some(
|
entry.first_seen_unix = Some(
|
||||||
@@ -244,8 +255,7 @@ fn add_agent_device_to_entry(
|
|||||||
.map(|current| current.max(last_seen))
|
.map(|current| current.max(last_seen))
|
||||||
.unwrap_or(last_seen),
|
.unwrap_or(last_seen),
|
||||||
);
|
);
|
||||||
let device_presence = agent_device.device.presence();
|
entry.presence = std::cmp::max(entry.presence, agent_device.presence());
|
||||||
entry.presence = std::cmp::max(entry.presence, device_presence);
|
|
||||||
|
|
||||||
let agent_id = agent_device.device.agent_id.clone();
|
let agent_id = agent_device.device.agent_id.clone();
|
||||||
let status = context
|
let status = context
|
||||||
@@ -270,105 +280,7 @@ fn add_agent_device_to_entry(
|
|||||||
connected: status.connected,
|
connected: status.connected,
|
||||||
last_seen_unix: last_seen,
|
last_seen_unix: last_seen,
|
||||||
});
|
});
|
||||||
|
status
|
||||||
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: 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.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));
|
|
||||||
insert_route(
|
|
||||||
entry,
|
|
||||||
FleetWakeRoute {
|
|
||||||
route_id: rid,
|
|
||||||
agent_id: agent_id.clone(),
|
|
||||||
nickname: status.nickname.clone(),
|
|
||||||
connected: status.connected,
|
|
||||||
mac: None,
|
|
||||||
ip: Some(*ip),
|
|
||||||
hostname,
|
|
||||||
interface: None,
|
|
||||||
source: "device".to_string(),
|
|
||||||
presence: device_presence,
|
|
||||||
last_seen_unix: last_seen,
|
|
||||||
wakeable: false,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn add_identifier_to_entry(entry: &mut FleetAccumulator, identifier: &DeviceIdentifier) {
|
fn add_identifier_to_entry(entry: &mut FleetAccumulator, identifier: &DeviceIdentifier) {
|
||||||
@@ -421,37 +333,6 @@ fn device_known_device(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
pub(crate) fn known_device_summary(device: &KnownDevice) -> KnownDeviceSummary {
|
||||||
KnownDeviceSummary {
|
KnownDeviceSummary {
|
||||||
device_id: device.device_id.clone(),
|
device_id: device.device_id.clone(),
|
||||||
@@ -462,6 +343,7 @@ pub(crate) fn known_device_summary(device: &KnownDevice) -> KnownDeviceSummary {
|
|||||||
|
|
||||||
impl FleetAccumulator {
|
impl FleetAccumulator {
|
||||||
fn into_response(self) -> FleetDevice {
|
fn into_response(self) -> FleetDevice {
|
||||||
|
let display_name = self.display_label();
|
||||||
let mut route_candidates = self.routes.into_values().collect::<Vec<_>>();
|
let mut route_candidates = self.routes.into_values().collect::<Vec<_>>();
|
||||||
route_candidates
|
route_candidates
|
||||||
.sort_by(|a, b| compare_route_quality(b, a).then_with(|| a.agent_id.cmp(&b.agent_id)));
|
.sort_by(|a, b| compare_route_quality(b, a).then_with(|| a.agent_id.cmp(&b.agent_id)));
|
||||||
@@ -469,13 +351,6 @@ impl FleetAccumulator {
|
|||||||
.iter()
|
.iter()
|
||||||
.find(|route| route.wakeable)
|
.find(|route| route.wakeable)
|
||||||
.cloned();
|
.cloned();
|
||||||
let display_name = self
|
|
||||||
.display_name
|
|
||||||
.or_else(|| self.hostnames.iter().next().cloned())
|
|
||||||
.or_else(|| self.macs.iter().next().map(|mac| mac.to_string()))
|
|
||||||
.or_else(|| self.ips.iter().next().map(|ip| ip.to_string()))
|
|
||||||
.unwrap_or_else(|| "(unknown device)".to_string());
|
|
||||||
|
|
||||||
FleetDevice {
|
FleetDevice {
|
||||||
device_key: self.device_key,
|
device_key: self.device_key,
|
||||||
display_name,
|
display_name,
|
||||||
@@ -496,34 +371,6 @@ impl FleetAccumulator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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,
|
|
||||||
mac.map(|m| m.to_string()).unwrap_or_default(),
|
|
||||||
ip.map(|i| i.to_string()).unwrap_or_default()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn normalize_filter(value: Option<&str>) -> Option<String> {
|
fn normalize_filter(value: Option<&str>) -> Option<String> {
|
||||||
value
|
value
|
||||||
.map(|value| value.trim().to_ascii_lowercase())
|
.map(|value| value.trim().to_ascii_lowercase())
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
//! Current-address summaries and naming-only evidence have different eligibility.
|
||||||
|
|
||||||
|
use super::FleetAccumulator;
|
||||||
|
use crate::state::AgentDeviceWithChildren;
|
||||||
|
use wakey_core::{EndpointSource, Presence};
|
||||||
|
|
||||||
|
/// Keep naming evidence distinct from addresses eligible for the summary.
|
||||||
|
pub(super) fn add_address_evidence(
|
||||||
|
entry: &mut FleetAccumulator,
|
||||||
|
agent_device: &AgentDeviceWithChildren,
|
||||||
|
) {
|
||||||
|
// Retain address evidence for naming only, without promoting stale IPs into
|
||||||
|
// the active summary or wake-route candidates.
|
||||||
|
let fallback_ip = agent_device
|
||||||
|
.ips
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.chain(
|
||||||
|
agent_device
|
||||||
|
.endpoints
|
||||||
|
.iter()
|
||||||
|
.filter_map(|endpoint| endpoint.key.ip),
|
||||||
|
)
|
||||||
|
.min();
|
||||||
|
entry.fallback_ip = entry.fallback_ip.into_iter().chain(fallback_ip).min();
|
||||||
|
if agent_device.endpoints.is_empty() {
|
||||||
|
for mac in &agent_device.macs {
|
||||||
|
entry.macs.insert(*mac);
|
||||||
|
}
|
||||||
|
if agent_device.presence() != Presence::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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FleetAccumulator {
|
||||||
|
/// Names and MACs outrank address evidence; presence belongs in the badge.
|
||||||
|
/// Summary IPs outrank naming-only fallback IPs, which may be inactive.
|
||||||
|
pub(super) fn display_label(&self) -> String {
|
||||||
|
self.display_name
|
||||||
|
.clone()
|
||||||
|
.or_else(|| self.hostnames.iter().next().cloned())
|
||||||
|
.or_else(|| {
|
||||||
|
self.macs
|
||||||
|
.iter()
|
||||||
|
.next()
|
||||||
|
.map(|mac| mac.to_string())
|
||||||
|
.or_else(|| {
|
||||||
|
self.ips
|
||||||
|
.iter()
|
||||||
|
.next()
|
||||||
|
.copied()
|
||||||
|
.or(self.fallback_ip)
|
||||||
|
.map(|ip| ip.to_string())
|
||||||
|
})
|
||||||
|
.map(|address| format!("Device: {address}"))
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| "(unknown device)".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
//! Build and rank agent-specific wake routes without promoting IPs into summaries.
|
||||||
|
|
||||||
|
use super::{AgentRuntimeStatus, FleetAccumulator, FleetDeviceEndpoint, FleetWakeRoute};
|
||||||
|
use crate::state::AgentDeviceWithChildren;
|
||||||
|
use macaddr::MacAddr;
|
||||||
|
use std::net::IpAddr;
|
||||||
|
use wakey_core::EndpointSource;
|
||||||
|
|
||||||
|
/// Endpoint evidence is preserved even when its IP is excluded from the summary.
|
||||||
|
pub(super) fn add_endpoint_routes(
|
||||||
|
entry: &mut FleetAccumulator,
|
||||||
|
agent_device: &AgentDeviceWithChildren,
|
||||||
|
status: &AgentRuntimeStatus,
|
||||||
|
) {
|
||||||
|
let agent_id = &agent_device.device.agent_id;
|
||||||
|
let first_seen = agent_device.device.first_seen();
|
||||||
|
let last_seen = agent_device.device.last_seen();
|
||||||
|
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: 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,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compatibility for stored rows without authoritative endpoint evidence.
|
||||||
|
pub(super) fn add_legacy_routes(
|
||||||
|
entry: &mut FleetAccumulator,
|
||||||
|
agent_device: &AgentDeviceWithChildren,
|
||||||
|
status: &AgentRuntimeStatus,
|
||||||
|
) {
|
||||||
|
entry.sources.insert("device".to_string());
|
||||||
|
let agent_id = &agent_device.device.agent_id;
|
||||||
|
let device_presence = agent_device.presence();
|
||||||
|
let last_seen = agent_device.device.last_seen();
|
||||||
|
if !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.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));
|
||||||
|
insert_route(
|
||||||
|
entry,
|
||||||
|
FleetWakeRoute {
|
||||||
|
route_id: rid,
|
||||||
|
agent_id: agent_id.clone(),
|
||||||
|
nickname: status.nickname.clone(),
|
||||||
|
connected: status.connected,
|
||||||
|
mac: None,
|
||||||
|
ip: Some(*ip),
|
||||||
|
hostname,
|
||||||
|
interface: None,
|
||||||
|
source: "device".to_string(),
|
||||||
|
presence: device_presence,
|
||||||
|
last_seen_unix: last_seen,
|
||||||
|
wakeable: 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) 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)))
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
mac.map(|m| m.to_string()).unwrap_or_default(),
|
||||||
|
ip.map(|i| i.to_string()).unwrap_or_default()
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,19 +1,20 @@
|
|||||||
use std::collections::{BTreeSet, HashMap};
|
use std::collections::{BTreeSet, HashMap};
|
||||||
|
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
use axum::extract::{Query, State};
|
use axum::extract::{Path, Query, State};
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use axum::response::IntoResponse;
|
use axum::response::IntoResponse;
|
||||||
use futures_util::StreamExt;
|
|
||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
use wakey_agent::protocol::{AgentCommand, InventoryRequest, WakeRequest};
|
|
||||||
|
|
||||||
use crate::api::ApiError;
|
use crate::api::ApiError;
|
||||||
use crate::api::commands::{RelayCommandResponse, relay_agent_command};
|
|
||||||
use crate::runtime::AppState;
|
use crate::runtime::AppState;
|
||||||
|
|
||||||
mod build;
|
mod build;
|
||||||
|
mod refresh;
|
||||||
mod types;
|
mod types;
|
||||||
|
mod wake;
|
||||||
|
pub use refresh::refresh_fleet_devices;
|
||||||
|
pub use wake::wake_fleet_device;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests;
|
mod tests;
|
||||||
@@ -22,13 +23,75 @@ use build::{
|
|||||||
AgentRuntimeStatus, FleetBuildContext, build_fleet_devices, filter_fleet_devices,
|
AgentRuntimeStatus, FleetBuildContext, build_fleet_devices, filter_fleet_devices,
|
||||||
known_device_summary,
|
known_device_summary,
|
||||||
};
|
};
|
||||||
use types::{
|
use types::{FleetDevice, ListFleetDevicesQuery};
|
||||||
FleetDevice, ListFleetDevicesQuery, RefreshFleetAgentResult, RefreshFleetDevicesRequest,
|
|
||||||
RefreshFleetDevicesResponse, WakeFleetDeviceRequest, WakeFleetDeviceResponse,
|
|
||||||
};
|
|
||||||
|
|
||||||
const REFRESH_CONCURRENCY: usize = 8;
|
pub(crate) type FleetCache =
|
||||||
|
crate::runtime::view_cache::ViewCache<(u64, BTreeSet<String>), Vec<FleetDevice>>;
|
||||||
|
|
||||||
|
async fn fleet_view(state: &AppState) -> anyhow::Result<std::sync::Arc<Vec<FleetDevice>>> {
|
||||||
|
state
|
||||||
|
.fleet
|
||||||
|
.get_or_try_init(
|
||||||
|
|| async {
|
||||||
|
let connected = state
|
||||||
|
.sessions
|
||||||
|
.read()
|
||||||
|
.await
|
||||||
|
.keys()
|
||||||
|
.cloned()
|
||||||
|
.collect::<BTreeSet<_>>();
|
||||||
|
(state.store.fleet_revision(), connected)
|
||||||
|
},
|
||||||
|
|| build_fleet_view(state),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Count stored rows separately from grouped identities. Reject mixed revisions;
|
||||||
|
/// filtered UI results must never become the source of current-state metrics.
|
||||||
|
pub(crate) async fn sample_fleet_counts(
|
||||||
|
state: &AppState,
|
||||||
|
) -> anyhow::Result<wakey_core::telemetry::FleetCounts> {
|
||||||
|
for _ in 0..3 {
|
||||||
|
let revision = state.store.fleet_revision();
|
||||||
|
let mut counts = wakey_core::telemetry::FleetCounts::default();
|
||||||
|
for (agent, _) in state.store.list_agents_with_nicknames().await? {
|
||||||
|
counts.agents.entry(agent).or_default();
|
||||||
|
}
|
||||||
|
for row in state.store.list_agent_device_rows().await? {
|
||||||
|
let agent = counts.agents.entry(row.device.agent_id).or_default();
|
||||||
|
agent.devices += 1;
|
||||||
|
agent.endpoints += row.endpoints.len() as u64;
|
||||||
|
}
|
||||||
|
let fleet = fleet_view(state).await?;
|
||||||
|
counts.saved = fleet
|
||||||
|
.iter()
|
||||||
|
.filter(|device| device.known_device.is_some())
|
||||||
|
.count() as u64;
|
||||||
|
counts.unsaved = fleet.len() as u64 - counts.saved;
|
||||||
|
if state.store.fleet_revision() == revision {
|
||||||
|
return Ok(counts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(crate::runtime::view_cache::RebuildLimit.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fleet_error(err: anyhow::Error, fallback_code: &str) -> ApiError {
|
||||||
|
if err.is::<crate::runtime::view_cache::RebuildLimit>() {
|
||||||
|
return ApiError::new(
|
||||||
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
"fleet_busy",
|
||||||
|
err.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ApiError::new(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
fallback_code,
|
||||||
|
err.to_string(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(name = "fleet_list", skip_all)]
|
||||||
pub async fn list_fleet_devices(
|
pub async fn list_fleet_devices(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Query(query): Query<ListFleetDevicesQuery>,
|
Query(query): Query<ListFleetDevicesQuery>,
|
||||||
@@ -37,158 +100,25 @@ pub async fn list_fleet_devices(
|
|||||||
Ok(devices) => Ok((StatusCode::OK, Json(devices))),
|
Ok(devices) => Ok((StatusCode::OK, Json(devices))),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
warn!(error = %err, "failed to list fleet devices");
|
warn!(error = %err, "failed to list fleet devices");
|
||||||
Err(ApiError::new(
|
Err(fleet_error(err, "list_fleet_devices_failed"))
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
"list_fleet_devices_failed",
|
|
||||||
err.to_string(),
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn refresh_fleet_devices(
|
/// Details are an exact identity lookup, not a filtered/paginated list lookup.
|
||||||
|
/// In particular, remembering an unsaved row must remain readable under its new key.
|
||||||
|
#[tracing::instrument(name = "fleet_get", skip_all)]
|
||||||
|
pub async fn get_fleet_device(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Json(req): Json<RefreshFleetDevicesRequest>,
|
Path(device_key): Path<String>,
|
||||||
) -> Result<impl IntoResponse, ApiError> {
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
let agent_ids = if req.agent_ids.is_empty() {
|
let devices = fleet_view(&state)
|
||||||
let sessions = state.sessions.read().await;
|
|
||||||
sessions.keys().cloned().collect::<Vec<_>>()
|
|
||||||
} else {
|
|
||||||
req.agent_ids.clone()
|
|
||||||
};
|
|
||||||
let agent_ids = normalize_refresh_agent_ids(agent_ids);
|
|
||||||
|
|
||||||
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,
|
|
||||||
Json(RefreshFleetDevicesResponse {
|
|
||||||
total_accepted,
|
|
||||||
agents: results,
|
|
||||||
}),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
.await
|
||||||
{
|
.map_err(|err| fleet_error(err, "get_fleet_device_failed"))?;
|
||||||
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>,
|
|
||||||
) -> Result<impl IntoResponse, ApiError> {
|
|
||||||
let query = ListFleetDevicesQuery {
|
|
||||||
query: None,
|
|
||||||
presence: None,
|
|
||||||
known: None,
|
|
||||||
agent_id: None,
|
|
||||||
visibility: Some("all".into()),
|
|
||||||
limit: Some(1000),
|
|
||||||
};
|
|
||||||
let devices = load_fleet_devices(&state, &query).await.map_err(|err| {
|
|
||||||
warn!(error = %err, "failed loading fleet devices for wake");
|
|
||||||
ApiError::new(
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
"load_fleet_devices_failed",
|
|
||||||
err.to_string(),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let device = devices
|
let device = devices
|
||||||
.into_iter()
|
.iter()
|
||||||
.find(|device| device.device_key == req.device_key)
|
.find(|device| device.device_key == device_key)
|
||||||
|
.cloned()
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
ApiError::new(
|
ApiError::new(
|
||||||
StatusCode::NOT_FOUND,
|
StatusCode::NOT_FOUND,
|
||||||
@@ -196,66 +126,29 @@ pub async fn wake_fleet_device(
|
|||||||
"fleet device not found",
|
"fleet device not found",
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
Ok(Json(device))
|
||||||
let route = match req.route_id.as_deref() {
|
|
||||||
Some(route_id) => device
|
|
||||||
.route_candidates
|
|
||||||
.into_iter()
|
|
||||||
.find(|route| route.route_id == route_id),
|
|
||||||
None => device.recommended_route,
|
|
||||||
}
|
|
||||||
.ok_or_else(|| {
|
|
||||||
ApiError::new(
|
|
||||||
StatusCode::BAD_REQUEST,
|
|
||||||
"wake_route_unavailable",
|
|
||||||
"no wakeable connected MAC-backed route is available",
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let Some(mac) = route.mac else {
|
|
||||||
return Err(ApiError::new(
|
|
||||||
StatusCode::BAD_REQUEST,
|
|
||||||
"wake_route_unavailable",
|
|
||||||
"selected route does not include a MAC address",
|
|
||||||
));
|
|
||||||
};
|
|
||||||
if !route.connected {
|
|
||||||
return Err(ApiError::new(
|
|
||||||
StatusCode::BAD_REQUEST,
|
|
||||||
"wake_route_unavailable",
|
|
||||||
"selected route agent is not connected",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if !route.wakeable {
|
|
||||||
return Err(ApiError::new(
|
|
||||||
StatusCode::BAD_REQUEST,
|
|
||||||
"wake_route_unavailable",
|
|
||||||
"selected route is not wakeable",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let command = relay_agent_command(
|
|
||||||
&state,
|
|
||||||
&route.agent_id,
|
|
||||||
AgentCommand::Wake(WakeRequest {
|
|
||||||
query: None,
|
|
||||||
mac: Some(mac),
|
|
||||||
ip: route.ip,
|
|
||||||
}),
|
|
||||||
req.timeout_ms,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok((
|
|
||||||
StatusCode::OK,
|
|
||||||
Json(WakeFleetDeviceResponse { route, command }),
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(name = "fleet_load", skip_all, fields(grouped = tracing::field::Empty, filtered = tracing::field::Empty, returned = tracing::field::Empty))]
|
||||||
async fn load_fleet_devices(
|
async fn load_fleet_devices(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
query: &ListFleetDevicesQuery,
|
query: &ListFleetDevicesQuery,
|
||||||
) -> anyhow::Result<Vec<FleetDevice>> {
|
) -> anyhow::Result<Vec<FleetDevice>> {
|
||||||
|
let cached = fleet_view(state).await?;
|
||||||
|
// Cache the complete view. Query-specific visibility, search and limits
|
||||||
|
// must never affect what another client (or a wake request) can retrieve.
|
||||||
|
let mut devices = cached.as_ref().clone();
|
||||||
|
tracing::Span::current().record("grouped", devices.len());
|
||||||
|
filter_fleet_devices(&mut devices, query);
|
||||||
|
tracing::Span::current().record("filtered", devices.len());
|
||||||
|
let limit = query.limit.unwrap_or(500).clamp(1, 1000);
|
||||||
|
devices.truncate(limit);
|
||||||
|
tracing::Span::current().record("returned", devices.len());
|
||||||
|
Ok(devices)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(name = "fleet_rebuild", skip_all)]
|
||||||
|
async fn build_fleet_view(state: &AppState) -> anyhow::Result<Vec<FleetDevice>> {
|
||||||
let known_devices = state.store.list_known_devices().await?;
|
let known_devices = state.store.list_known_devices().await?;
|
||||||
let agent_devices = state.store.list_agent_device_rows().await?;
|
let agent_devices = state.store.list_agent_device_rows().await?;
|
||||||
let connected = {
|
let connected = {
|
||||||
@@ -289,10 +182,5 @@ async fn load_fleet_devices(
|
|||||||
agent_status,
|
agent_status,
|
||||||
identifier_map,
|
identifier_map,
|
||||||
};
|
};
|
||||||
let mut devices = build_fleet_devices(known_devices, agent_devices, &context);
|
Ok(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)
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,432 @@
|
|||||||
|
//! Refresh agents independently: one failed relay or snapshot does not cancel others.
|
||||||
|
|
||||||
|
use axum::{Json, extract::State, http::StatusCode, response::IntoResponse};
|
||||||
|
use futures_util::StreamExt;
|
||||||
|
use tracing::{info, warn};
|
||||||
|
use wakey_agent::protocol::{AgentCommand, InventoryRequest};
|
||||||
|
|
||||||
|
use super::types::{
|
||||||
|
RefreshFleetAgentResult, RefreshFleetDevicesRequest, RefreshFleetDevicesResponse,
|
||||||
|
};
|
||||||
|
use crate::api::{
|
||||||
|
ApiError,
|
||||||
|
commands::{RelayCommandResponse, relay_agent_command},
|
||||||
|
};
|
||||||
|
use crate::runtime::AppState;
|
||||||
|
use crate::state::Store;
|
||||||
|
|
||||||
|
const REFRESH_CONCURRENCY: usize = 8;
|
||||||
|
|
||||||
|
#[tracing::instrument(name = "fleet_refresh", skip_all, fields(agents = tracing::field::Empty, succeeded = tracing::field::Empty, failed = tracing::field::Empty, accepted = tracing::field::Empty))]
|
||||||
|
pub async fn refresh_fleet_devices(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(req): Json<RefreshFleetDevicesRequest>,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
let agent_ids = select_agents(&state, req.agent_ids).await;
|
||||||
|
tracing::Span::current().record("agents", agent_ids.len());
|
||||||
|
let results = futures_util::stream::iter(
|
||||||
|
agent_ids
|
||||||
|
.into_iter()
|
||||||
|
.map(|agent_id| refresh_agent(&state, agent_id, req.timeout_ms)),
|
||||||
|
)
|
||||||
|
.buffer_unordered(REFRESH_CONCURRENCY)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.await;
|
||||||
|
let response = summarize(results);
|
||||||
|
let succeeded = response
|
||||||
|
.agents
|
||||||
|
.iter()
|
||||||
|
.filter(|result| result.status == "ok")
|
||||||
|
.count();
|
||||||
|
let span = tracing::Span::current();
|
||||||
|
span.record("succeeded", succeeded);
|
||||||
|
span.record("failed", response.agents.len() - succeeded);
|
||||||
|
span.record("accepted", response.total_accepted);
|
||||||
|
info!("fleet refresh completed");
|
||||||
|
Ok((StatusCode::OK, Json(response)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Default to a point-in-time connected set. Explicit IDs remain eligible so
|
||||||
|
/// the relay can report disconnected agents as per-agent errors, not omit them.
|
||||||
|
async fn select_agents(state: &AppState, requested: Vec<String>) -> Vec<String> {
|
||||||
|
let ids = if requested.is_empty() {
|
||||||
|
state.sessions.read().await.keys().cloned().collect()
|
||||||
|
} else {
|
||||||
|
requested
|
||||||
|
};
|
||||||
|
normalize_refresh_agent_ids(ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn normalize_refresh_agent_ids(mut agent_ids: Vec<String>) -> Vec<String> {
|
||||||
|
agent_ids.sort();
|
||||||
|
agent_ids.dedup();
|
||||||
|
agent_ids
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(name = "fleet_refresh_agent", skip_all, fields(agent_id = %agent_id, outcome = tracing::field::Empty, accepted = tracing::field::Empty))]
|
||||||
|
async fn refresh_agent(
|
||||||
|
state: &AppState,
|
||||||
|
agent_id: String,
|
||||||
|
timeout_ms: Option<u64>,
|
||||||
|
) -> RefreshFleetAgentResult {
|
||||||
|
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(),
|
||||||
|
}),
|
||||||
|
timeout_ms,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let result = store_inventory(&state.store, &agent_id, response).await;
|
||||||
|
tracing::Span::current().record("outcome", result.status.as_str());
|
||||||
|
tracing::Span::current().record("accepted", result.accepted);
|
||||||
|
if result.status != "ok" {
|
||||||
|
// The response retains diagnostic detail; do not export payloads.
|
||||||
|
warn!("fleet agent refresh failed");
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate the relay result before replacing anything. An empty *valid*
|
||||||
|
/// inventory is still a complete snapshot and intentionally clears old rows.
|
||||||
|
fn decode_inventory(
|
||||||
|
response: Result<RelayCommandResponse, ApiError>,
|
||||||
|
) -> Result<wakey_core::DeviceInventory, String> {
|
||||||
|
let response = response.map_err(|err| format!("{}: {}", err.status, err.message))?;
|
||||||
|
if response.status != "ok" {
|
||||||
|
return Err(response
|
||||||
|
.error
|
||||||
|
.map(|error| error.message)
|
||||||
|
.unwrap_or_else(|| "inventory command failed".into()));
|
||||||
|
}
|
||||||
|
let result = response
|
||||||
|
.result
|
||||||
|
.ok_or_else(|| "inventory command returned no result".to_string())?;
|
||||||
|
serde_json::from_value(result).map_err(|err| err.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn store_inventory(
|
||||||
|
store: &Store,
|
||||||
|
agent_id: &str,
|
||||||
|
response: Result<RelayCommandResponse, ApiError>,
|
||||||
|
) -> RefreshFleetAgentResult {
|
||||||
|
let accepted = async {
|
||||||
|
let inventory = decode_inventory(response)?;
|
||||||
|
store
|
||||||
|
.replace_agent_device_snapshot(agent_id, &inventory.devices)
|
||||||
|
.await
|
||||||
|
.map_err(|err| err.to_string())
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
match accepted {
|
||||||
|
Ok(accepted) => RefreshFleetAgentResult {
|
||||||
|
agent_id: agent_id.into(),
|
||||||
|
status: "ok".into(),
|
||||||
|
accepted,
|
||||||
|
error: None,
|
||||||
|
},
|
||||||
|
Err(error) => RefreshFleetAgentResult {
|
||||||
|
agent_id: agent_id.into(),
|
||||||
|
status: "error".into(),
|
||||||
|
accepted: 0,
|
||||||
|
error: Some(error),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Concurrent completion order is deliberately not part of the response contract.
|
||||||
|
fn summarize(mut agents: Vec<RefreshFleetAgentResult>) -> RefreshFleetDevicesResponse {
|
||||||
|
agents.sort_by(|a, b| a.agent_id.cmp(&b.agent_id));
|
||||||
|
RefreshFleetDevicesResponse {
|
||||||
|
total_accepted: agents.iter().map(|result| result.accepted).sum(),
|
||||||
|
agents,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
fn response(result: Option<serde_json::Value>) -> RelayCommandResponse {
|
||||||
|
RelayCommandResponse {
|
||||||
|
request_id: "refresh-test".into(),
|
||||||
|
status: "ok".into(),
|
||||||
|
result,
|
||||||
|
error: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn refresh_updates_cached_fleet_despite_a_disconnected_agent() {
|
||||||
|
use crate::runtime::{AgentReply, AgentSession, SessionEvent};
|
||||||
|
use std::{collections::HashMap, sync::Arc, time::Duration};
|
||||||
|
use tokio::sync::{Mutex, RwLock, mpsc};
|
||||||
|
use wakey_agent::protocol::{CommandResult, ServerMessage};
|
||||||
|
|
||||||
|
let dir =
|
||||||
|
std::env::temp_dir().join(format!("wakey-refresh-handler-{}", uuid::Uuid::new_v4()));
|
||||||
|
let state = AppState {
|
||||||
|
store: Arc::new(
|
||||||
|
Store::load_or_init(&dir.join("state.sqlite3"), vec![], Duration::from_secs(60))
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
),
|
||||||
|
sessions: Arc::new(RwLock::new(HashMap::new())),
|
||||||
|
pending: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
public_url: "http://localhost".into(),
|
||||||
|
command_timeout: Duration::from_secs(30),
|
||||||
|
enroll_token_ttl: Duration::from_secs(60),
|
||||||
|
terminals: crate::runtime::terminals::TerminalRegistry::new(),
|
||||||
|
alerts: crate::api::AlertsHub::new(),
|
||||||
|
fleet: Arc::new(super::super::FleetCache::default()),
|
||||||
|
};
|
||||||
|
let token = state
|
||||||
|
.store
|
||||||
|
.issue_enroll_token(Duration::from_secs(60))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let agent_id = state
|
||||||
|
.store
|
||||||
|
.enroll(&token.enroll_token)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.agent_id;
|
||||||
|
let device = |ip: &str| {
|
||||||
|
wakey_core::Device::from_parts(
|
||||||
|
vec![wakey_core::NeighborEntry {
|
||||||
|
ip: ip.parse().unwrap(),
|
||||||
|
mac: Some("aa:bb:cc:dd:ee:01".parse().unwrap()),
|
||||||
|
dev: Some("br-lan".into()),
|
||||||
|
state: wakey_core::NeighborState::Reachable,
|
||||||
|
}],
|
||||||
|
vec![],
|
||||||
|
)
|
||||||
|
};
|
||||||
|
state
|
||||||
|
.store
|
||||||
|
.replace_agent_device_snapshot(&agent_id, &[device("192.168.1.10")])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||||
|
state.sessions.write().await.insert(
|
||||||
|
agent_id.clone(),
|
||||||
|
AgentSession {
|
||||||
|
connection_id: "test".into(),
|
||||||
|
tx,
|
||||||
|
capabilities: vec![],
|
||||||
|
capability_options: Default::default(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
select_agents(&state, vec![]).await.as_slice(),
|
||||||
|
std::slice::from_ref(&agent_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Prime the real cache with old data before refresh touches the store.
|
||||||
|
let query = super::super::types::ListFleetDevicesQuery {
|
||||||
|
visibility: Some("all".into()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let before = super::super::load_fleet_devices(&state, &query)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(before.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
before[0].ips,
|
||||||
|
["192.168.1.10".parse::<std::net::IpAddr>().unwrap()]
|
||||||
|
);
|
||||||
|
|
||||||
|
let refresh = refresh_fleet_devices(
|
||||||
|
State(state.clone()),
|
||||||
|
Json(RefreshFleetDevicesRequest {
|
||||||
|
agent_ids: vec!["disconnected".into(), agent_id.clone(), agent_id.clone()],
|
||||||
|
timeout_ms: None,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// Answer through the production relay's pending-request channel. No
|
||||||
|
// sleep or polling: receiving the command proves its waiter exists.
|
||||||
|
let reply = async {
|
||||||
|
let SessionEvent::Message(ServerMessage::Command {
|
||||||
|
request_id,
|
||||||
|
command,
|
||||||
|
..
|
||||||
|
}) = rx.recv().await.unwrap()
|
||||||
|
else {
|
||||||
|
panic!("expected inventory command");
|
||||||
|
};
|
||||||
|
let AgentCommand::Inventory(request) = command else {
|
||||||
|
panic!("expected inventory command");
|
||||||
|
};
|
||||||
|
assert!(request.query.is_none() && request.name.is_none());
|
||||||
|
assert!(
|
||||||
|
request.ips.is_empty()
|
||||||
|
&& request.macs.is_empty()
|
||||||
|
&& request.devs.is_empty()
|
||||||
|
&& request.nuds.is_empty()
|
||||||
|
);
|
||||||
|
let result =
|
||||||
|
serde_json::to_value(CommandResult::Inventory(wakey_core::DeviceInventory {
|
||||||
|
devices: vec![device("192.168.1.20")],
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
state
|
||||||
|
.pending
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.remove(request_id.as_str())
|
||||||
|
.unwrap()
|
||||||
|
.send(AgentReply::Result(result))
|
||||||
|
.unwrap_or_else(|_| panic!("relay waiter dropped"));
|
||||||
|
};
|
||||||
|
let (response, ()) = tokio::join!(refresh, reply);
|
||||||
|
let response = response.unwrap().into_response();
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let body = axum::body::to_bytes(response.into_body(), 4096)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let result: RefreshFleetDevicesResponse = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert_eq!(result.total_accepted, 1);
|
||||||
|
assert_eq!(result.agents.len(), 2);
|
||||||
|
assert!(
|
||||||
|
result
|
||||||
|
.agents
|
||||||
|
.windows(2)
|
||||||
|
.all(|pair| pair[0].agent_id < pair[1].agent_id)
|
||||||
|
);
|
||||||
|
let success = result
|
||||||
|
.agents
|
||||||
|
.iter()
|
||||||
|
.find(|row| row.agent_id == agent_id)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(success.status, "ok");
|
||||||
|
assert_eq!(success.accepted, 1);
|
||||||
|
assert!(success.error.is_none());
|
||||||
|
let failed = result
|
||||||
|
.agents
|
||||||
|
.iter()
|
||||||
|
.find(|row| row.agent_id == "disconnected")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(failed.status, "error");
|
||||||
|
assert_eq!(failed.accepted, 0);
|
||||||
|
assert_eq!(
|
||||||
|
failed.error.as_deref(),
|
||||||
|
Some("404 Not Found: agent is not connected")
|
||||||
|
);
|
||||||
|
assert!(state.pending.lock().await.is_empty());
|
||||||
|
assert!(matches!(
|
||||||
|
rx.try_recv(),
|
||||||
|
Err(mpsc::error::TryRecvError::Empty)
|
||||||
|
));
|
||||||
|
|
||||||
|
let after = super::super::load_fleet_devices(&state, &query)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(after.len(), 1);
|
||||||
|
assert_eq!(after[0].device_key, before[0].device_key);
|
||||||
|
assert_eq!(
|
||||||
|
after[0].ips,
|
||||||
|
["192.168.1.20".parse::<std::net::IpAddr>().unwrap()]
|
||||||
|
);
|
||||||
|
let stored = state
|
||||||
|
.store
|
||||||
|
.list_agent_device_rows_for_agent(&agent_id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(stored.len(), 1);
|
||||||
|
assert_eq!(stored[0].endpoints.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
stored[0].endpoints[0].key.ip,
|
||||||
|
Some("192.168.1.20".parse().unwrap())
|
||||||
|
);
|
||||||
|
drop(state);
|
||||||
|
std::fs::remove_dir_all(dir).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn refresh_stores_snapshots_but_invalid_results_leave_them_untouched() {
|
||||||
|
let dir = std::env::temp_dir().join(format!("wakey-refresh-{}", uuid::Uuid::new_v4()));
|
||||||
|
let store = Store::load_or_init(
|
||||||
|
&dir.join("state.sqlite3"),
|
||||||
|
vec![],
|
||||||
|
std::time::Duration::from_secs(60),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let device = wakey_core::Device::from_parts(
|
||||||
|
vec![wakey_core::NeighborEntry {
|
||||||
|
ip: "192.168.1.10".parse().unwrap(),
|
||||||
|
mac: Some("aa:bb:cc:dd:ee:01".parse().unwrap()),
|
||||||
|
dev: Some("br-lan".into()),
|
||||||
|
state: wakey_core::NeighborState::Reachable,
|
||||||
|
}],
|
||||||
|
vec![],
|
||||||
|
);
|
||||||
|
let inventory = serde_json::to_value(wakey_core::DeviceInventory {
|
||||||
|
devices: vec![device],
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let stored = store_inventory(&store, "agent", Ok(response(Some(inventory)))).await;
|
||||||
|
assert_eq!(stored.status, "ok");
|
||||||
|
assert_eq!(stored.accepted, 1);
|
||||||
|
let revision = store.fleet_revision();
|
||||||
|
let mut command_failed = response(None);
|
||||||
|
command_failed.status = "error".into();
|
||||||
|
command_failed.error = Some(wakey_agent::protocol::ErrorPayload {
|
||||||
|
code: "inventory_failed".into(),
|
||||||
|
message: "source unavailable".into(),
|
||||||
|
retryable: None,
|
||||||
|
});
|
||||||
|
for failed in [
|
||||||
|
Ok(response(None)),
|
||||||
|
Ok(response(Some(json!({"devices": "invalid"})))),
|
||||||
|
Ok(command_failed),
|
||||||
|
Err(ApiError::new(
|
||||||
|
StatusCode::BAD_GATEWAY,
|
||||||
|
"agent_send_failed",
|
||||||
|
"send failed",
|
||||||
|
)),
|
||||||
|
] {
|
||||||
|
let invalid = store_inventory(&store, "agent", failed).await;
|
||||||
|
assert_eq!(invalid.status, "error");
|
||||||
|
assert_eq!(invalid.accepted, 0);
|
||||||
|
assert!(invalid.error.is_some());
|
||||||
|
assert_eq!(store.fleet_revision(), revision);
|
||||||
|
let rows = store
|
||||||
|
.list_agent_device_rows_for_agent("agent")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(rows.len(), 1);
|
||||||
|
assert_eq!(rows[0].endpoints.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
rows[0].endpoints[0].key.ip,
|
||||||
|
Some("192.168.1.10".parse().unwrap())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let rows = store
|
||||||
|
.list_agent_device_rows_for_agent("agent")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(rows.len(), 1);
|
||||||
|
assert_eq!(rows[0].endpoints.len(), 1);
|
||||||
|
let empty =
|
||||||
|
store_inventory(&store, "agent", Ok(response(Some(json!({"devices": []}))))).await;
|
||||||
|
assert_eq!(empty.status, "ok");
|
||||||
|
assert_eq!(empty.accepted, 0);
|
||||||
|
assert!(
|
||||||
|
store
|
||||||
|
.list_agent_device_rows_for_agent("agent")
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
drop(store);
|
||||||
|
std::fs::remove_dir_all(dir).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,10 +8,320 @@ use super::build::{
|
|||||||
AgentRuntimeStatus, FleetBuildContext, build_fleet_devices, filter_fleet_devices,
|
AgentRuntimeStatus, FleetBuildContext, build_fleet_devices, filter_fleet_devices,
|
||||||
known_device_summary,
|
known_device_summary,
|
||||||
};
|
};
|
||||||
use super::normalize_refresh_agent_ids;
|
use super::refresh::normalize_refresh_agent_ids;
|
||||||
use super::types::ListFleetDevicesQuery;
|
use super::types::ListFleetDevicesQuery;
|
||||||
use crate::state::{AgentDeviceRow, AgentDeviceWithChildren, DeviceIdentifier, KnownDevice};
|
use crate::state::{AgentDeviceRow, AgentDeviceWithChildren, DeviceIdentifier, KnownDevice};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cache_rebuild_limit_is_retryable_but_db_failures_keep_their_error_code() {
|
||||||
|
let busy = super::fleet_error(
|
||||||
|
crate::runtime::view_cache::RebuildLimit.into(),
|
||||||
|
"list_fleet_devices_failed",
|
||||||
|
);
|
||||||
|
assert_eq!(busy.status, axum::http::StatusCode::SERVICE_UNAVAILABLE);
|
||||||
|
assert_eq!(busy.code, "fleet_busy");
|
||||||
|
let failed = super::fleet_error(
|
||||||
|
anyhow::anyhow!("database failed"),
|
||||||
|
"list_fleet_devices_failed",
|
||||||
|
);
|
||||||
|
assert_eq!(failed.status, axum::http::StatusCode::INTERNAL_SERVER_ERROR);
|
||||||
|
assert_eq!(failed.code, "list_fleet_devices_failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn fleet_cache_tracks_store_edits_and_connections_without_caching_filters() {
|
||||||
|
use crate::runtime::{AgentSession, AppState};
|
||||||
|
use crate::state::{DeviceIdentifierInput, KnownDeviceInput, Store};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::sync::{Mutex, RwLock, mpsc};
|
||||||
|
|
||||||
|
let dir = std::env::temp_dir().join(format!("wakey-fleet-cache-{}", uuid::Uuid::new_v4()));
|
||||||
|
let state = AppState {
|
||||||
|
store: Arc::new(
|
||||||
|
Store::load_or_init(&dir.join("state.sqlite3"), vec![], Duration::from_secs(60))
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
),
|
||||||
|
sessions: Arc::new(RwLock::new(HashMap::new())),
|
||||||
|
pending: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
public_url: "http://localhost".into(),
|
||||||
|
command_timeout: Duration::from_secs(30),
|
||||||
|
enroll_token_ttl: Duration::from_secs(60),
|
||||||
|
terminals: crate::runtime::terminals::TerminalRegistry::new(),
|
||||||
|
alerts: crate::api::AlertsHub::new(),
|
||||||
|
fleet: Arc::new(super::FleetCache::default()),
|
||||||
|
};
|
||||||
|
let store = &state.store;
|
||||||
|
assert_eq!(
|
||||||
|
super::sample_fleet_counts(&state).await.unwrap(),
|
||||||
|
Default::default()
|
||||||
|
);
|
||||||
|
let token = store
|
||||||
|
.issue_enroll_token(Duration::from_secs(60))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let agent = store.enroll(&token.enroll_token).await.unwrap();
|
||||||
|
let empty = super::sample_fleet_counts(&state).await.unwrap();
|
||||||
|
assert_eq!(empty.agents[&agent.agent_id].devices, 0);
|
||||||
|
assert_eq!(empty.agents[&agent.agent_id].endpoints, 0);
|
||||||
|
let observed = wakey_core::Device::from_parts(
|
||||||
|
vec![wakey_core::NeighborEntry {
|
||||||
|
ip: "192.168.1.10".parse().unwrap(),
|
||||||
|
mac: Some("aa:bb:cc:dd:ee:01".parse().unwrap()),
|
||||||
|
dev: Some("br-lan".into()),
|
||||||
|
state: wakey_core::NeighborState::Reachable,
|
||||||
|
}],
|
||||||
|
vec![],
|
||||||
|
);
|
||||||
|
store
|
||||||
|
.replace_agent_device_snapshot(&agent.agent_id, &[observed])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let initial = super::fleet_view(&state).await.unwrap();
|
||||||
|
assert_eq!(initial.len(), 1);
|
||||||
|
let counts = super::sample_fleet_counts(&state).await.unwrap();
|
||||||
|
assert_eq!(counts.agents[&agent.agent_id].devices, 1);
|
||||||
|
assert_eq!(counts.agents[&agent.agent_id].endpoints, 1);
|
||||||
|
assert_eq!((counts.saved, counts.unsaved), (0, 1));
|
||||||
|
assert!(Arc::ptr_eq(
|
||||||
|
&initial,
|
||||||
|
&super::fleet_view(&state).await.unwrap()
|
||||||
|
));
|
||||||
|
assert!(initial[0].recommended_route.is_none());
|
||||||
|
|
||||||
|
let (tx, _rx) = mpsc::unbounded_channel();
|
||||||
|
state.sessions.write().await.insert(
|
||||||
|
agent.agent_id.clone(),
|
||||||
|
AgentSession {
|
||||||
|
connection_id: "test".into(),
|
||||||
|
tx,
|
||||||
|
capabilities: vec![],
|
||||||
|
capability_options: Default::default(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let connected = super::fleet_view(&state).await.unwrap();
|
||||||
|
assert!(connected[0].recommended_route.is_some());
|
||||||
|
store
|
||||||
|
.set_agent_nickname(&agent.agent_id, Some("router"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
super::fleet_view(&state).await.unwrap()[0].agents[0]
|
||||||
|
.nickname
|
||||||
|
.as_deref(),
|
||||||
|
Some("router")
|
||||||
|
);
|
||||||
|
|
||||||
|
let saved = store
|
||||||
|
.create_known_device(KnownDeviceInput {
|
||||||
|
display_name: "my pc".into(),
|
||||||
|
pinned: true,
|
||||||
|
notes: None,
|
||||||
|
identifiers: vec![DeviceIdentifierInput {
|
||||||
|
kind: "mac".into(),
|
||||||
|
value: "aa:bb:cc:dd:ee:01".into(),
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
super::fleet_view(&state).await.unwrap()[0].display_name,
|
||||||
|
"my pc"
|
||||||
|
);
|
||||||
|
store
|
||||||
|
.rename_known_device(&saved.device_id, "renamed pc")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let before_error = super::fleet_view(&state).await.unwrap();
|
||||||
|
assert!(
|
||||||
|
store
|
||||||
|
.rename_known_device(&saved.device_id, " ")
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(Arc::ptr_eq(
|
||||||
|
&before_error,
|
||||||
|
&super::fleet_view(&state).await.unwrap()
|
||||||
|
));
|
||||||
|
assert_eq!(
|
||||||
|
super::fleet_view(&state).await.unwrap()[0].display_name,
|
||||||
|
"renamed pc"
|
||||||
|
);
|
||||||
|
let query = ListFleetDevicesQuery {
|
||||||
|
query: Some("does-not-exist".into()),
|
||||||
|
visibility: Some("all".into()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
super::load_fleet_devices(&state, &query)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
// The filtered list excludes this row, but exact details still follow the
|
||||||
|
// authoritative saved key after remembering and renaming the observation.
|
||||||
|
{
|
||||||
|
use axum::{
|
||||||
|
extract::{Path, State},
|
||||||
|
response::IntoResponse,
|
||||||
|
};
|
||||||
|
let response = super::get_fleet_device(
|
||||||
|
State(state.clone()),
|
||||||
|
Path(format!("known:{}", saved.device_id)),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.into_response();
|
||||||
|
assert_eq!(response.status(), axum::http::StatusCode::OK);
|
||||||
|
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let details: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert_eq!(details["display_name"], "renamed pc");
|
||||||
|
assert_eq!(details["known_device"]["device_id"], saved.device_id);
|
||||||
|
let missing =
|
||||||
|
super::get_fleet_device(State(state.clone()), Path(initial[0].device_key.clone()))
|
||||||
|
.await
|
||||||
|
.err()
|
||||||
|
.expect("old unsaved grouping key no longer exists");
|
||||||
|
assert_eq!(missing.status, axum::http::StatusCode::NOT_FOUND);
|
||||||
|
assert_eq!(missing.code, "fleet_device_not_found");
|
||||||
|
}
|
||||||
|
let counts = super::sample_fleet_counts(&state).await.unwrap();
|
||||||
|
assert_eq!((counts.saved, counts.unsaved), (1, 0));
|
||||||
|
let all = ListFleetDevicesQuery {
|
||||||
|
visibility: Some("all".into()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
super::load_fleet_devices(&state, &all).await.unwrap().len(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
|
||||||
|
store
|
||||||
|
.detach_device_identifier(&saved.device_id, &saved.identifiers[0].identifier_key)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(super::fleet_view(&state).await.unwrap().len(), 2);
|
||||||
|
let counts = super::sample_fleet_counts(&state).await.unwrap();
|
||||||
|
assert_eq!((counts.saved, counts.unsaved), (1, 1));
|
||||||
|
store
|
||||||
|
.attach_device_identifier(
|
||||||
|
&saved.device_id,
|
||||||
|
DeviceIdentifierInput {
|
||||||
|
kind: "mac".into(),
|
||||||
|
value: "aa:bb:cc:dd:ee:01".into(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(super::fleet_view(&state).await.unwrap().len(), 1);
|
||||||
|
let other = store
|
||||||
|
.create_known_device(KnownDeviceInput {
|
||||||
|
display_name: "other".into(),
|
||||||
|
pinned: false,
|
||||||
|
notes: None,
|
||||||
|
identifiers: vec![],
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(super::fleet_view(&state).await.unwrap().len(), 2);
|
||||||
|
let limited = ListFleetDevicesQuery {
|
||||||
|
visibility: Some("all".into()),
|
||||||
|
limit: Some(1),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
super::load_fleet_devices(&state, &limited)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.len(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
let counts = super::sample_fleet_counts(&state).await.unwrap();
|
||||||
|
assert_eq!((counts.saved, counts.unsaved), (2, 0));
|
||||||
|
assert_eq!(
|
||||||
|
super::load_fleet_devices(&state, &all).await.unwrap().len(),
|
||||||
|
2
|
||||||
|
);
|
||||||
|
store
|
||||||
|
.merge_known_devices(&saved.device_id, &other.device_id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(super::fleet_view(&state).await.unwrap().len(), 1);
|
||||||
|
store.forget_known_device(&saved.device_id).await.unwrap();
|
||||||
|
assert!(
|
||||||
|
super::fleet_view(&state).await.unwrap()[0]
|
||||||
|
.known_device
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
|
||||||
|
state.sessions.write().await.clear();
|
||||||
|
let counts = super::sample_fleet_counts(&state).await.unwrap();
|
||||||
|
assert_eq!(counts.agents[&agent.agent_id].devices, 1);
|
||||||
|
assert_eq!((counts.saved, counts.unsaved), (0, 1));
|
||||||
|
assert!(
|
||||||
|
super::fleet_view(&state).await.unwrap()[0]
|
||||||
|
.recommended_route
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
let before_reload = super::fleet_view(&state).await.unwrap();
|
||||||
|
store.reload_from_disk().await.unwrap();
|
||||||
|
assert!(!Arc::ptr_eq(
|
||||||
|
&before_reload,
|
||||||
|
&super::fleet_view(&state).await.unwrap()
|
||||||
|
));
|
||||||
|
store.revoke_agent(&agent.agent_id).await.unwrap();
|
||||||
|
assert!(
|
||||||
|
super::fleet_view(&state).await.unwrap()[0].agents[0]
|
||||||
|
.nickname
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
store
|
||||||
|
.replace_agent_device_snapshot(&agent.agent_id, &[])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(super::fleet_view(&state).await.unwrap().is_empty());
|
||||||
|
assert_eq!(
|
||||||
|
super::sample_fleet_counts(&state).await.unwrap(),
|
||||||
|
Default::default()
|
||||||
|
);
|
||||||
|
// Identical devices on two agents count twice as stored rows, once as a fleet identity.
|
||||||
|
// Offline endpoints are retained too; these metrics are not reachability counters.
|
||||||
|
let offline = wakey_core::Device::from_parts(
|
||||||
|
vec![wakey_core::NeighborEntry {
|
||||||
|
ip: "192.168.1.10".parse().unwrap(),
|
||||||
|
mac: Some("aa:bb:cc:dd:ee:01".parse().unwrap()),
|
||||||
|
dev: Some("br-lan".into()),
|
||||||
|
state: wakey_core::NeighborState::Failed,
|
||||||
|
}],
|
||||||
|
vec![],
|
||||||
|
);
|
||||||
|
for id in ["agent-a", "agent-b"] {
|
||||||
|
store
|
||||||
|
.replace_agent_device_snapshot(id, std::slice::from_ref(&offline))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
let counts = super::sample_fleet_counts(&state).await.unwrap();
|
||||||
|
assert_eq!(counts.agents.len(), 2);
|
||||||
|
for count in counts.agents.values() {
|
||||||
|
assert_eq!(count.devices, 1);
|
||||||
|
assert_eq!(count.endpoints, 1);
|
||||||
|
}
|
||||||
|
assert_eq!((counts.saved, counts.unsaved), (0, 1));
|
||||||
|
store
|
||||||
|
.replace_agent_device_snapshot("agent-a", &[])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let counts = super::sample_fleet_counts(&state).await.unwrap();
|
||||||
|
assert!(!counts.agents.contains_key("agent-a"));
|
||||||
|
assert_eq!(counts.agents["agent-b"].devices, 1);
|
||||||
|
drop(state);
|
||||||
|
std::fs::remove_dir_all(dir).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn refresh_agent_selection_keeps_all_agents_beyond_previous_cap() {
|
fn refresh_agent_selection_keeps_all_agents_beyond_previous_cap() {
|
||||||
let ids = (0..140)
|
let ids = (0..140)
|
||||||
@@ -447,11 +757,153 @@ fn unknown_ip_only_offline_device_is_hidden_by_default() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
assert_eq!(devices.len(), 1);
|
assert_eq!(devices.len(), 1);
|
||||||
assert_eq!(devices[0].display_name, "(unknown device)");
|
assert_eq!(devices[0].display_name, "Device: 192.168.1.2");
|
||||||
filter_fleet_devices(&mut devices, &ListFleetDevicesQuery::default());
|
filter_fleet_devices(&mut devices, &ListFleetDevicesQuery::default());
|
||||||
assert!(devices.is_empty());
|
assert!(devices.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn endpoint_presence_overrides_conflicting_cached_summary() {
|
||||||
|
for connected in [false, true] {
|
||||||
|
for (cached, evidence) in [
|
||||||
|
(Presence::Unknown, Presence::Offline),
|
||||||
|
(Presence::Online, Presence::Unknown),
|
||||||
|
(Presence::Offline, Presence::Online),
|
||||||
|
] {
|
||||||
|
let mut row = offline_ip_only_unknown("agent-a", "ip:192.168.1.2", "192.168.1.2", 20);
|
||||||
|
row.device.presence = cached.as_str().into();
|
||||||
|
let mut endpoint = endpoint_with_presence(
|
||||||
|
EndpointSource::Neighbor,
|
||||||
|
None,
|
||||||
|
Some("192.168.1.2"),
|
||||||
|
evidence,
|
||||||
|
20,
|
||||||
|
);
|
||||||
|
endpoint.hostname = None;
|
||||||
|
row.endpoints = vec![endpoint];
|
||||||
|
let ctx = if connected {
|
||||||
|
context(&["agent-a"])
|
||||||
|
} else {
|
||||||
|
context(&[])
|
||||||
|
};
|
||||||
|
let devices = build_fleet_devices(Vec::new(), vec![row], &ctx);
|
||||||
|
assert_eq!(devices[0].presence, evidence);
|
||||||
|
assert_eq!(devices[0].endpoints[0].presence, evidence);
|
||||||
|
assert_eq!(devices[0].agents[0].connected, connected);
|
||||||
|
if evidence == Presence::Offline {
|
||||||
|
assert_eq!(devices[0].display_name, "Device: 192.168.1.2");
|
||||||
|
assert!(devices[0].ips.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unnamed_endpoint_labels_preserve_presence_and_inactive_ips() {
|
||||||
|
for (presence, mac, ip, expected) in [
|
||||||
|
(
|
||||||
|
Presence::Offline,
|
||||||
|
None,
|
||||||
|
Some("192.168.1.2"),
|
||||||
|
"Device: 192.168.1.2",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Presence::Unknown,
|
||||||
|
None,
|
||||||
|
Some("192.168.1.2"),
|
||||||
|
"Device: 192.168.1.2",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Presence::Offline,
|
||||||
|
Some("aa:bb:cc:dd:ee:01"),
|
||||||
|
None,
|
||||||
|
"Device: AA:BB:CC:DD:EE:01",
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let mut row = offline_ip_only_unknown("agent-a", "test", "192.168.1.2", 20);
|
||||||
|
row.ips.clear();
|
||||||
|
row.device.presence = presence.as_str().into();
|
||||||
|
let mut evidence = endpoint_with_presence(EndpointSource::Neighbor, mac, ip, presence, 20);
|
||||||
|
evidence.hostname = None;
|
||||||
|
row.endpoints = vec![evidence];
|
||||||
|
let devices = build_fleet_devices(Vec::new(), vec![row], &context(&["agent-a"]));
|
||||||
|
assert_eq!(devices[0].display_name, expected);
|
||||||
|
assert_eq!(devices[0].presence, presence);
|
||||||
|
if presence == Presence::Offline {
|
||||||
|
assert!(devices[0].ips.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unnamed_mac_device_does_not_use_inactive_ip_or_presence_as_its_name() {
|
||||||
|
for presence in [Presence::Offline, Presence::Unknown, Presence::Online] {
|
||||||
|
let mut row =
|
||||||
|
offline_ip_only_unknown("agent-a", "mac:22:07:53:BF:FC:12", "192.168.100.125", 20);
|
||||||
|
row.macs = vec!["22:07:53:bf:fc:12".parse().unwrap()];
|
||||||
|
row.hostnames.clear();
|
||||||
|
row.endpoints = [EndpointSource::Neighbor, EndpointSource::HookNeighbor]
|
||||||
|
.into_iter()
|
||||||
|
.map(|source| {
|
||||||
|
let mut endpoint = endpoint_with_presence(
|
||||||
|
source,
|
||||||
|
Some("22:07:53:bf:fc:12"),
|
||||||
|
Some("192.168.100.125"),
|
||||||
|
presence,
|
||||||
|
20,
|
||||||
|
);
|
||||||
|
endpoint.hostname = None;
|
||||||
|
endpoint
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let devices = build_fleet_devices(Vec::new(), vec![row], &context(&["agent-a"]));
|
||||||
|
assert_eq!(devices[0].display_name, "Device: 22:07:53:BF:FC:12");
|
||||||
|
assert_eq!(devices[0].presence, presence);
|
||||||
|
assert_eq!(devices[0].endpoints.len(), 2);
|
||||||
|
if presence == Presence::Offline {
|
||||||
|
assert!(devices[0].ips.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ip_only_label_prefers_summary_ip_and_uses_inactive_evidence_only_as_fallback() {
|
||||||
|
for has_current_ip in [true, false] {
|
||||||
|
let mut row = offline_ip_only_unknown("agent-a", "test", "192.168.1.2", 20);
|
||||||
|
let mut inactive = endpoint_with_presence(
|
||||||
|
EndpointSource::Neighbor,
|
||||||
|
None,
|
||||||
|
Some("192.168.1.2"),
|
||||||
|
Presence::Offline,
|
||||||
|
20,
|
||||||
|
);
|
||||||
|
inactive.hostname = None;
|
||||||
|
row.endpoints = vec![inactive];
|
||||||
|
if has_current_ip {
|
||||||
|
let mut current = endpoint_with_presence(
|
||||||
|
EndpointSource::Neighbor,
|
||||||
|
None,
|
||||||
|
Some("192.168.1.10"),
|
||||||
|
Presence::Online,
|
||||||
|
20,
|
||||||
|
);
|
||||||
|
current.hostname = None;
|
||||||
|
row.endpoints.push(current);
|
||||||
|
}
|
||||||
|
let devices = build_fleet_devices(Vec::new(), vec![row], &context(&["agent-a"]));
|
||||||
|
if has_current_ip {
|
||||||
|
assert_eq!(devices[0].display_name, "Device: 192.168.1.10");
|
||||||
|
assert_eq!(
|
||||||
|
devices[0].ips,
|
||||||
|
vec!["192.168.1.10".parse::<IpAddr>().unwrap()]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
assert_eq!(devices[0].display_name, "Device: 192.168.1.2");
|
||||||
|
assert!(devices[0].ips.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn visibility_all_keeps_unknown_ip_only_offline_device() {
|
fn visibility_all_keeps_unknown_ip_only_offline_device() {
|
||||||
let mut devices = build_fleet_devices(
|
let mut devices = build_fleet_devices(
|
||||||
@@ -473,7 +925,7 @@ fn visibility_all_keeps_unknown_ip_only_offline_device() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
assert_eq!(devices.len(), 1);
|
assert_eq!(devices.len(), 1);
|
||||||
assert_eq!(devices[0].display_name, "(unknown device)");
|
assert_eq!(devices[0].display_name, "Device: 192.168.1.2");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -0,0 +1,410 @@
|
|||||||
|
use axum::{Json, extract::State, http::StatusCode, response::IntoResponse};
|
||||||
|
use macaddr::MacAddr;
|
||||||
|
use tracing::{Instrument, info, warn};
|
||||||
|
use wakey_agent::protocol::{AgentCommand, WakeRequest};
|
||||||
|
|
||||||
|
use super::types::{FleetDevice, FleetWakeRoute, WakeFleetDeviceRequest, WakeFleetDeviceResponse};
|
||||||
|
use super::{fleet_error, fleet_view};
|
||||||
|
use crate::api::{ApiError, commands::relay_agent_command};
|
||||||
|
use crate::runtime::AppState;
|
||||||
|
|
||||||
|
#[tracing::instrument(name = "fleet_wake", skip_all, fields(explicit_route = req.route_id.is_some(), outcome = tracing::field::Empty))]
|
||||||
|
pub async fn wake_fleet_device(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(req): Json<WakeFleetDeviceRequest>,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
// Early database/relay errors retain an outcome even without a command result.
|
||||||
|
tracing::Span::current().record("outcome", "error");
|
||||||
|
let selected = load_selected_route(&state, &req.device_key, req.route_id.as_deref()).await?;
|
||||||
|
info!(agent_id = %selected.route.agent_id, has_ip = selected.route.ip.is_some(),
|
||||||
|
explicit_route = req.route_id.is_some(), "fleet wake route selected");
|
||||||
|
let response = dispatch_wake(&state, selected, req.timeout_ms).await?;
|
||||||
|
tracing::Span::current().record("outcome", response.command.status.as_str());
|
||||||
|
Ok((StatusCode::OK, Json(response)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List visibility and pagination are presentation policy, not wake eligibility.
|
||||||
|
/// Borrow the complete cached view and clone only the selected route.
|
||||||
|
async fn load_selected_route(
|
||||||
|
state: &AppState,
|
||||||
|
device_key: &str,
|
||||||
|
route_id: Option<&str>,
|
||||||
|
) -> Result<SelectedRoute, ApiError> {
|
||||||
|
let span = tracing::info_span!("fleet_load", grouped = tracing::field::Empty);
|
||||||
|
let devices = fleet_view(state)
|
||||||
|
.instrument(span.clone())
|
||||||
|
.await
|
||||||
|
.map_err(|err| {
|
||||||
|
warn!(error = %err, "failed loading fleet devices for wake");
|
||||||
|
fleet_error(err, "load_fleet_devices_failed")
|
||||||
|
})?;
|
||||||
|
span.record("grouped", devices.len());
|
||||||
|
select_route(&devices, device_key, route_id).map_err(|error| {
|
||||||
|
tracing::Span::current().record("outcome", error.outcome());
|
||||||
|
error.into_api_error()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Selection does not rank routes: the fleet builder already chose the recommendation.
|
||||||
|
/// An explicit route never falls back, including when it is disconnected or IP-only.
|
||||||
|
fn select_route(
|
||||||
|
devices: &[FleetDevice],
|
||||||
|
device_key: &str,
|
||||||
|
route_id: Option<&str>,
|
||||||
|
) -> Result<SelectedRoute, RouteError> {
|
||||||
|
let device = devices
|
||||||
|
.iter()
|
||||||
|
.find(|device| device.device_key == device_key)
|
||||||
|
.ok_or(RouteError::DeviceNotFound)?;
|
||||||
|
let route = match route_id {
|
||||||
|
Some(id) => device
|
||||||
|
.route_candidates
|
||||||
|
.iter()
|
||||||
|
.find(|route| route.route_id == id),
|
||||||
|
None => device.recommended_route.as_ref(),
|
||||||
|
}
|
||||||
|
.ok_or(RouteError::Unavailable)?;
|
||||||
|
let mac = route.mac.ok_or(RouteError::MissingMac)?;
|
||||||
|
if !route.connected {
|
||||||
|
return Err(RouteError::Disconnected);
|
||||||
|
}
|
||||||
|
if !route.wakeable {
|
||||||
|
return Err(RouteError::NotWakeable);
|
||||||
|
}
|
||||||
|
Ok(SelectedRoute {
|
||||||
|
route: route.clone(),
|
||||||
|
mac,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Only the selector constructs this; dispatch cannot accidentally use an IP-only route.
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct SelectedRoute {
|
||||||
|
route: FleetWakeRoute,
|
||||||
|
mac: MacAddr,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SelectedRoute {
|
||||||
|
fn command(&self) -> AgentCommand {
|
||||||
|
AgentCommand::Wake(WakeRequest {
|
||||||
|
query: None,
|
||||||
|
mac: Some(self.mac),
|
||||||
|
ip: self.route.ip,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn dispatch_wake(
|
||||||
|
state: &AppState,
|
||||||
|
selected: SelectedRoute,
|
||||||
|
timeout_ms: Option<u64>,
|
||||||
|
) -> Result<WakeFleetDeviceResponse, ApiError> {
|
||||||
|
// The relay checks the current agent connection again; cached connectivity
|
||||||
|
// is only selection evidence, not a guarantee that the agent is still there.
|
||||||
|
let command = relay_agent_command(
|
||||||
|
state,
|
||||||
|
&selected.route.agent_id,
|
||||||
|
selected.command(),
|
||||||
|
timeout_ms,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(WakeFleetDeviceResponse {
|
||||||
|
route: selected.route,
|
||||||
|
command,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
enum RouteError {
|
||||||
|
DeviceNotFound,
|
||||||
|
Unavailable,
|
||||||
|
MissingMac,
|
||||||
|
Disconnected,
|
||||||
|
NotWakeable,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RouteError {
|
||||||
|
fn outcome(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::DeviceNotFound => "device_not_found",
|
||||||
|
Self::Unavailable => "route_unavailable",
|
||||||
|
Self::MissingMac => "route_without_mac",
|
||||||
|
Self::Disconnected => "route_disconnected",
|
||||||
|
Self::NotWakeable => "route_not_wakeable",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn into_api_error(self) -> ApiError {
|
||||||
|
let message = match self {
|
||||||
|
Self::DeviceNotFound => {
|
||||||
|
return ApiError::new(
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
"fleet_device_not_found",
|
||||||
|
"fleet device not found",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Self::Unavailable => "no wakeable connected MAC-backed route is available",
|
||||||
|
Self::MissingMac => "selected route does not include a MAC address",
|
||||||
|
Self::Disconnected => "selected route agent is not connected",
|
||||||
|
Self::NotWakeable => "selected route is not wakeable",
|
||||||
|
};
|
||||||
|
ApiError::new(StatusCode::BAD_REQUEST, "wake_route_unavailable", message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::super::{load_fleet_devices, types::ListFleetDevicesQuery};
|
||||||
|
use super::*;
|
||||||
|
use wakey_core::Presence;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn wake_lookup_can_select_a_device_beyond_the_list_limit() {
|
||||||
|
use std::collections::{BTreeSet, HashMap};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::sync::{Mutex, RwLock};
|
||||||
|
|
||||||
|
let dir = std::env::temp_dir().join(format!("wakey-wake-limit-{}", uuid::Uuid::new_v4()));
|
||||||
|
let state = AppState {
|
||||||
|
store: Arc::new(
|
||||||
|
crate::state::Store::load_or_init(
|
||||||
|
&dir.join("state.sqlite3"),
|
||||||
|
vec![],
|
||||||
|
Duration::from_secs(60),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
),
|
||||||
|
sessions: Arc::new(RwLock::new(HashMap::new())),
|
||||||
|
pending: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
public_url: "http://localhost".into(),
|
||||||
|
command_timeout: Duration::from_secs(30),
|
||||||
|
enroll_token_ttl: Duration::from_secs(60),
|
||||||
|
terminals: crate::runtime::terminals::TerminalRegistry::new(),
|
||||||
|
alerts: crate::api::AlertsHub::new(),
|
||||||
|
fleet: Arc::new(super::super::FleetCache::default()),
|
||||||
|
};
|
||||||
|
// Seed the complete cached view, as the builder would. Exercise both
|
||||||
|
// production read paths without thousands of unrelated SQL inserts.
|
||||||
|
state
|
||||||
|
.fleet
|
||||||
|
.get_or_try_init(
|
||||||
|
|| async { (state.store.fleet_revision(), BTreeSet::new()) },
|
||||||
|
|| async {
|
||||||
|
Ok((0..1001)
|
||||||
|
.map(|i| {
|
||||||
|
let mut row = device(vec![route("valid")], Some(route("valid")));
|
||||||
|
row.device_key = format!("pc-{i}");
|
||||||
|
row
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let listed = load_fleet_devices(
|
||||||
|
&state,
|
||||||
|
&ListFleetDevicesQuery {
|
||||||
|
visibility: Some("all".into()),
|
||||||
|
limit: Some(1000),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(listed.len(), 1000);
|
||||||
|
assert!(listed.iter().all(|row| row.device_key != "pc-1000"));
|
||||||
|
{
|
||||||
|
use axum::extract::Path;
|
||||||
|
let details =
|
||||||
|
super::super::get_fleet_device(State(state.clone()), Path("pc-1000".into()))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.into_response();
|
||||||
|
assert_eq!(details.status(), StatusCode::OK);
|
||||||
|
let body = axum::body::to_bytes(details.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let row: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert_eq!(row["device_key"], "pc-1000");
|
||||||
|
}
|
||||||
|
for route_id in [None, Some("valid")] {
|
||||||
|
let selected = load_selected_route(&state, "pc-1000", route_id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(selected.route.route_id, "valid");
|
||||||
|
}
|
||||||
|
drop(state);
|
||||||
|
std::fs::remove_dir_all(dir).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn route(id: &str) -> FleetWakeRoute {
|
||||||
|
FleetWakeRoute {
|
||||||
|
route_id: id.into(),
|
||||||
|
agent_id: "agent-a".into(),
|
||||||
|
nickname: None,
|
||||||
|
connected: true,
|
||||||
|
mac: Some("aa:bb:cc:dd:ee:01".parse().unwrap()),
|
||||||
|
ip: Some("192.168.1.10".parse().unwrap()),
|
||||||
|
hostname: None,
|
||||||
|
interface: None,
|
||||||
|
source: "neighbor".into(),
|
||||||
|
presence: Presence::Offline,
|
||||||
|
last_seen_unix: 1,
|
||||||
|
wakeable: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn device(candidates: Vec<FleetWakeRoute>, recommended: Option<FleetWakeRoute>) -> FleetDevice {
|
||||||
|
FleetDevice {
|
||||||
|
device_key: "pc".into(),
|
||||||
|
display_name: "pc".into(),
|
||||||
|
known_device: None,
|
||||||
|
pinned: false,
|
||||||
|
ips: vec![],
|
||||||
|
macs: vec![],
|
||||||
|
hostnames: vec![],
|
||||||
|
agents: vec![],
|
||||||
|
sources: vec![],
|
||||||
|
endpoints: vec![],
|
||||||
|
first_seen_unix: None,
|
||||||
|
last_seen_unix: None,
|
||||||
|
presence: Presence::Offline,
|
||||||
|
route_candidates: candidates,
|
||||||
|
recommended_route: recommended,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn selection_uses_recommendation_or_exact_override_and_preserves_address_pair() {
|
||||||
|
let first = route("first");
|
||||||
|
let mut recommended = route("recommended");
|
||||||
|
recommended.agent_id = "agent-b".into();
|
||||||
|
recommended.ip = Some("192.168.2.20".parse().unwrap());
|
||||||
|
let devices = [device(vec![first, recommended.clone()], Some(recommended))];
|
||||||
|
for (override_id, route_id, agent, ip) in [
|
||||||
|
(None, "recommended", "agent-b", "192.168.2.20"),
|
||||||
|
(Some("first"), "first", "agent-a", "192.168.1.10"),
|
||||||
|
] {
|
||||||
|
let selected = select_route(&devices, "pc", override_id).unwrap();
|
||||||
|
assert_eq!(selected.route.route_id, route_id);
|
||||||
|
assert_eq!(selected.route.agent_id, agent);
|
||||||
|
let AgentCommand::Wake(command) = selected.command() else {
|
||||||
|
panic!("not a wake command")
|
||||||
|
};
|
||||||
|
assert!(command.query.is_none());
|
||||||
|
assert_eq!(command.mac, selected.route.mac);
|
||||||
|
assert_eq!(command.ip, Some(ip.parse().unwrap()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_device_and_missing_routes_keep_distinct_errors() {
|
||||||
|
let valid = route("valid");
|
||||||
|
let devices = [device(vec![valid.clone()], Some(valid))];
|
||||||
|
assert_eq!(
|
||||||
|
select_route(&devices, "missing", None).unwrap_err(),
|
||||||
|
RouteError::DeviceNotFound
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
select_route(&devices, "pc", Some("missing")).unwrap_err(),
|
||||||
|
RouteError::Unavailable
|
||||||
|
);
|
||||||
|
let devices = [device(vec![route("valid")], None)];
|
||||||
|
assert_eq!(
|
||||||
|
select_route(&devices, "pc", None).unwrap_err(),
|
||||||
|
RouteError::Unavailable
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_explicit_routes_do_not_fall_back_and_validation_order_is_preserved() {
|
||||||
|
for (mac, connected, wakeable, expected) in [
|
||||||
|
(None, false, false, RouteError::MissingMac),
|
||||||
|
(
|
||||||
|
Some("aa:bb:cc:dd:ee:01"),
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
RouteError::Disconnected,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Some("aa:bb:cc:dd:ee:01"),
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
RouteError::NotWakeable,
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let mut invalid = route("invalid");
|
||||||
|
invalid.mac = mac.map(|value| value.parse().unwrap());
|
||||||
|
invalid.connected = connected;
|
||||||
|
invalid.wakeable = wakeable;
|
||||||
|
let devices = [device(vec![invalid, route("valid")], Some(route("valid")))];
|
||||||
|
assert_eq!(
|
||||||
|
select_route(&devices, "pc", Some("invalid")).unwrap_err(),
|
||||||
|
expected
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mac_only_offline_route_is_still_wakeable() {
|
||||||
|
let mut mac_only = route("mac-only");
|
||||||
|
mac_only.ip = None;
|
||||||
|
let devices = [device(vec![mac_only.clone()], Some(mac_only))];
|
||||||
|
let selected = select_route(&devices, "pc", None).unwrap();
|
||||||
|
let AgentCommand::Wake(command) = selected.command() else {
|
||||||
|
panic!("not a wake command")
|
||||||
|
};
|
||||||
|
assert!(command.mac.is_some());
|
||||||
|
assert!(command.ip.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn route_errors_preserve_http_codes_messages_and_trace_outcomes() {
|
||||||
|
for (error, status, code, message, outcome) in [
|
||||||
|
(
|
||||||
|
RouteError::DeviceNotFound,
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
"fleet_device_not_found",
|
||||||
|
"fleet device not found",
|
||||||
|
"device_not_found",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
RouteError::Unavailable,
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"wake_route_unavailable",
|
||||||
|
"no wakeable connected MAC-backed route is available",
|
||||||
|
"route_unavailable",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
RouteError::MissingMac,
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"wake_route_unavailable",
|
||||||
|
"selected route does not include a MAC address",
|
||||||
|
"route_without_mac",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
RouteError::Disconnected,
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"wake_route_unavailable",
|
||||||
|
"selected route agent is not connected",
|
||||||
|
"route_disconnected",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
RouteError::NotWakeable,
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"wake_route_unavailable",
|
||||||
|
"selected route is not wakeable",
|
||||||
|
"route_not_wakeable",
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
assert_eq!(error.outcome(), outcome);
|
||||||
|
let response = error.into_api_error();
|
||||||
|
assert_eq!(response.status, status);
|
||||||
|
assert_eq!(response.code, code);
|
||||||
|
assert_eq!(response.message, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ mod audit;
|
|||||||
mod commands;
|
mod commands;
|
||||||
mod control;
|
mod control;
|
||||||
mod terminals;
|
mod terminals;
|
||||||
|
pub(crate) use control::FleetCache;
|
||||||
|
pub(crate) use control::sample_fleet_counts;
|
||||||
|
|
||||||
pub use alerts::{AlertsHub, active_alerts, alert_history, alerts_stream, run_alert_evaluator};
|
pub use alerts::{AlertsHub, active_alerts, alert_history, alerts_stream, run_alert_evaluator};
|
||||||
pub use audit::list_audit_events;
|
pub use audit::list_audit_events;
|
||||||
@@ -13,9 +15,10 @@ pub use commands::{list_agents, run_command};
|
|||||||
pub use control::{
|
pub use control::{
|
||||||
EnrollTokenStatus, IssueEnrollTokenResponse, RevokeAgentResponse, RevokeEnrollTokenResponse,
|
EnrollTokenStatus, IssueEnrollTokenResponse, RevokeAgentResponse, RevokeEnrollTokenResponse,
|
||||||
StateStatsResponse, attach_device_identifier, create_known_device, detach_device_identifier,
|
StateStatsResponse, attach_device_identifier, create_known_device, detach_device_identifier,
|
||||||
enroll, forget_known_device, get_known_device, healthz, issue_enroll_token, list_enroll_tokens,
|
enroll, forget_known_device, get_fleet_device, get_known_device, healthz, issue_enroll_token,
|
||||||
list_fleet_devices, list_known_devices, merge_known_device, refresh_fleet_devices,
|
list_enroll_tokens, list_fleet_devices, list_known_devices, merge_known_device,
|
||||||
revoke_agent, revoke_enroll_token, set_agent_nickname, state_stats, wake_fleet_device,
|
refresh_fleet_devices, rename_known_device, revoke_agent, revoke_enroll_token,
|
||||||
|
set_agent_nickname, state_stats, wake_fleet_device,
|
||||||
};
|
};
|
||||||
pub use terminals::{
|
pub use terminals::{
|
||||||
agent_terminal_ws, attach_terminal, close_terminal, create_terminal, get_terminal,
|
agent_terminal_ws, attach_terminal, close_terminal, create_terminal, get_terminal,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use futures_util::{SinkExt, StreamExt};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tokio::sync::broadcast;
|
use tokio::sync::broadcast;
|
||||||
use tracing::{info, warn};
|
use tracing::{Instrument, info, warn};
|
||||||
use wakey_agent::protocol::{
|
use wakey_agent::protocol::{
|
||||||
AgentCapability, DEFAULT_TERMINAL_MAX_SESSIONS, DEFAULT_TERMINAL_SESSION_TTL_SECONDS,
|
AgentCapability, DEFAULT_TERMINAL_MAX_SESSIONS, DEFAULT_TERMINAL_SESSION_TTL_SECONDS,
|
||||||
ServerMessage, TerminalAgentHandshake, TerminalControl, TerminalId, TerminalOperatorHandshake,
|
ServerMessage, TerminalAgentHandshake, TerminalControl, TerminalId, TerminalOperatorHandshake,
|
||||||
@@ -49,6 +49,7 @@ pub struct TerminalSessionResponse {
|
|||||||
pub attachment_token: Option<String>,
|
pub attachment_token: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(name = "terminal_create", skip_all)]
|
||||||
pub async fn create_terminal(
|
pub async fn create_terminal(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Json(request): Json<CreateTerminalRequest>,
|
Json(request): Json<CreateTerminalRequest>,
|
||||||
@@ -183,6 +184,7 @@ pub async fn list_terminals(State(state): State<AppState>) -> Json<Vec<TerminalS
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(name = "terminal_attach", skip_all)]
|
||||||
pub async fn attach_terminal(
|
pub async fn attach_terminal(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(terminal_id): Path<String>,
|
Path(terminal_id): Path<String>,
|
||||||
@@ -210,6 +212,7 @@ pub async fn attach_terminal(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(name = "terminal_close", skip_all)]
|
||||||
pub async fn close_terminal(
|
pub async fn close_terminal(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(terminal_id): Path<String>,
|
Path(terminal_id): Path<String>,
|
||||||
@@ -304,10 +307,17 @@ async fn handle_agent_terminal_socket(state: AppState, terminal_id: String, mut
|
|||||||
let (mut outbound, pending) = match state
|
let (mut outbound, pending) = match state
|
||||||
.terminals
|
.terminals
|
||||||
.attach_agent(&terminal_id, &auth.0, &auth.1)
|
.attach_agent(&terminal_id, &auth.0, &auth.1)
|
||||||
|
.instrument(
|
||||||
|
tracing::info_span!("terminal_agent_transport_attach", terminal_id = %terminal_id),
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(outbound) => outbound,
|
Ok(outbound) => outbound,
|
||||||
Err(code) => {
|
Err(code) => {
|
||||||
|
warn!(
|
||||||
|
terminal_id,
|
||||||
|
code, "agent terminal transport attachment rejected"
|
||||||
|
);
|
||||||
close_socket(&mut socket, code).await;
|
close_socket(&mut socket, code).await;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -390,10 +400,17 @@ async fn handle_operator_terminal_socket(
|
|||||||
let mut outbound = match state
|
let mut outbound = match state
|
||||||
.terminals
|
.terminals
|
||||||
.attach_operator(&terminal_id, &auth.0, &auth.1)
|
.attach_operator(&terminal_id, &auth.0, &auth.1)
|
||||||
|
.instrument(
|
||||||
|
tracing::info_span!("terminal_operator_transport_attach", terminal_id = %terminal_id),
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(attached) => attached,
|
Ok(attached) => attached,
|
||||||
Err(code) => {
|
Err(code) => {
|
||||||
|
warn!(
|
||||||
|
terminal_id,
|
||||||
|
code, "operator terminal transport attachment rejected"
|
||||||
|
);
|
||||||
close_socket(&mut socket, code).await;
|
close_socket(&mut socket, code).await;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -404,6 +421,7 @@ async fn handle_operator_terminal_socket(
|
|||||||
if let Err(code) = state
|
if let Err(code) = state
|
||||||
.terminals
|
.terminals
|
||||||
.relay_to_agent(&terminal_id, TerminalRelayFrame::Text(snapshot))
|
.relay_to_agent(&terminal_id, TerminalRelayFrame::Text(snapshot))
|
||||||
|
.instrument(tracing::info_span!("terminal_snapshot_request", terminal_id = %terminal_id))
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
warn!(terminal_id, code, "failed to request terminal snapshot");
|
warn!(terminal_id, code, "failed to request terminal snapshot");
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ pub struct ServeArgs {
|
|||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
pub enroll_token_ttl_seconds: Option<u64>,
|
pub enroll_token_ttl_seconds: Option<u64>,
|
||||||
|
|
||||||
|
/// Audit/alert history retention in seconds (legacy CLI name; not device retention).
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
pub observation_retention_seconds: Option<u64>,
|
pub observation_retention_seconds: Option<u64>,
|
||||||
|
|
||||||
@@ -129,6 +130,7 @@ pub struct InitConfigArgs {
|
|||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
pub enroll_token_ttl_seconds: Option<u64>,
|
pub enroll_token_ttl_seconds: Option<u64>,
|
||||||
|
|
||||||
|
/// Audit/alert history retention in seconds (legacy CLI name; not device retention).
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
pub observation_retention_seconds: Option<u64>,
|
pub observation_retention_seconds: Option<u64>,
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use anyhow::{Context, Result};
|
|||||||
|
|
||||||
use crate::cli::{InitConfigArgs, ServeArgs};
|
use crate::cli::{InitConfigArgs, ServeArgs};
|
||||||
use crate::config::resolve::{load_file_config, normalize_public_url, resolve_path};
|
use crate::config::resolve::{load_file_config, normalize_public_url, resolve_path};
|
||||||
use crate::config::types::{DEFAULT_CONFIG, WritableConfig, WritableTelemetry};
|
use crate::config::types::{DEFAULT_CONFIG, FileConfig, FileTelemetryConfig};
|
||||||
|
|
||||||
pub fn write_init_config(args: &InitConfigArgs) -> Result<Option<PathBuf>> {
|
pub fn write_init_config(args: &InitConfigArgs) -> Result<Option<PathBuf>> {
|
||||||
if args.stdout && args.config_file.is_some() {
|
if args.stdout && args.config_file.is_some() {
|
||||||
@@ -44,10 +44,7 @@ pub fn write_init_config(args: &InitConfigArgs) -> Result<Option<PathBuf>> {
|
|||||||
.unwrap_or_else(|| "<defaults>".to_string())
|
.unwrap_or_else(|| "<defaults>".to_string())
|
||||||
)
|
)
|
||||||
})?,
|
})?,
|
||||||
None => defaults
|
None => defaults.bind,
|
||||||
.bind
|
|
||||||
.parse()
|
|
||||||
.expect("static default bind should parse"),
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
let public_url = normalize_public_url(
|
let public_url = normalize_public_url(
|
||||||
@@ -90,44 +87,71 @@ pub fn write_init_config(args: &InitConfigArgs) -> Result<Option<PathBuf>> {
|
|||||||
args.bootstrap_enroll_tokens.clone()
|
args.bootstrap_enroll_tokens.clone()
|
||||||
};
|
};
|
||||||
|
|
||||||
let body = WritableConfig {
|
let command_timeout = args
|
||||||
data_dir,
|
|
||||||
bind: bind.to_string(),
|
|
||||||
public_url,
|
|
||||||
state_file,
|
|
||||||
command_timeout_ms: args
|
|
||||||
.command_timeout_ms
|
.command_timeout_ms
|
||||||
.or(base.command_timeout_ms)
|
.map(std::time::Duration::from_millis)
|
||||||
.unwrap_or(defaults.command_timeout_ms)
|
.or(base.command_timeout)
|
||||||
.max(1),
|
.unwrap_or(defaults.command_timeout);
|
||||||
enroll_token_ttl_seconds: args
|
let enroll_token_ttl = args
|
||||||
.enroll_token_ttl_seconds
|
.enroll_token_ttl_seconds
|
||||||
.or(base.enroll_token_ttl_seconds)
|
.map(std::time::Duration::from_secs)
|
||||||
.unwrap_or(defaults.enroll_token_ttl_seconds)
|
.or(base.enroll_token_ttl)
|
||||||
.max(1),
|
.unwrap_or(defaults.enroll_token_ttl);
|
||||||
observation_retention_seconds: args
|
let history_retention = args
|
||||||
.observation_retention_seconds
|
.observation_retention_seconds
|
||||||
.or(base.observation_retention_seconds)
|
.map(std::time::Duration::from_secs)
|
||||||
.unwrap_or(defaults.observation_retention_seconds),
|
.or(base.history_retention)
|
||||||
pid_file,
|
.unwrap_or(defaults.history_retention);
|
||||||
ui_dist_dir,
|
super::resolve::validate_durations(command_timeout, enroll_token_ttl, history_retention)?;
|
||||||
bootstrap_enroll_tokens,
|
let device_retention = base.device_retention.unwrap_or(defaults.device_retention);
|
||||||
telemetry: WritableTelemetry {
|
super::resolve::validate_device_retention(device_retention)?;
|
||||||
|
|
||||||
|
let body = FileConfig {
|
||||||
|
access: base.access,
|
||||||
|
data_dir: Some(data_dir),
|
||||||
|
bind: Some(bind.to_string()),
|
||||||
|
public_url: Some(public_url),
|
||||||
|
state_file: Some(state_file),
|
||||||
|
command_timeout: Some(command_timeout),
|
||||||
|
enroll_token_ttl: Some(enroll_token_ttl),
|
||||||
|
history_retention: Some(history_retention),
|
||||||
|
device_retention: Some(device_retention),
|
||||||
|
pid_file: Some(pid_file),
|
||||||
|
ui_dist_dir: Some(ui_dist_dir),
|
||||||
|
// Preserve the previous generated template's omission of an empty list.
|
||||||
|
// Explicit empty lists still round-trip through FileConfig unchanged.
|
||||||
|
bootstrap_enroll_tokens: (!bootstrap_enroll_tokens.is_empty())
|
||||||
|
.then_some(bootstrap_enroll_tokens),
|
||||||
|
telemetry: Some(FileTelemetryConfig {
|
||||||
|
console_filter: base_telemetry
|
||||||
|
.console_filter
|
||||||
|
.or_else(|| defaults.telemetry.console_filter.clone()),
|
||||||
|
otlp_log_filter: base_telemetry
|
||||||
|
.otlp_log_filter
|
||||||
|
.or_else(|| defaults.telemetry.otlp_log_filter.clone()),
|
||||||
|
trace_filter: base_telemetry
|
||||||
|
.trace_filter
|
||||||
|
.or_else(|| defaults.telemetry.trace_filter.clone()),
|
||||||
|
authorization_env: base_telemetry
|
||||||
|
.authorization_env
|
||||||
|
.or_else(|| defaults.telemetry.authorization_env.clone()),
|
||||||
otlp_endpoint: args
|
otlp_endpoint: args
|
||||||
.telemetry_otlp_endpoint
|
.telemetry_otlp_endpoint
|
||||||
.clone()
|
.clone()
|
||||||
.or(base_telemetry.otlp_endpoint)
|
.or(base_telemetry.otlp_endpoint)
|
||||||
.or_else(|| defaults.telemetry.otlp_endpoint.clone()),
|
.or_else(|| defaults.telemetry.otlp_endpoint.clone()),
|
||||||
service_name: args
|
service_name: Some(
|
||||||
.telemetry_service_name
|
args.telemetry_service_name
|
||||||
.clone()
|
.clone()
|
||||||
.or(base_telemetry.service_name)
|
.or(base_telemetry.service_name)
|
||||||
.unwrap_or_else(|| defaults.telemetry.service_name.clone()),
|
.unwrap_or_else(|| defaults.telemetry.service_name.clone()),
|
||||||
json_logs: args
|
),
|
||||||
.telemetry_json_logs
|
json_logs: Some(
|
||||||
|
args.telemetry_json_logs
|
||||||
.or(base_telemetry.json_logs)
|
.or(base_telemetry.json_logs)
|
||||||
.unwrap_or(defaults.telemetry.json_logs),
|
.unwrap_or(defaults.telemetry.json_logs),
|
||||||
},
|
),
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
let rendered = toml::to_string_pretty(&body).context("failed to render config template")?;
|
let rendered = toml::to_string_pretty(&body).context("failed to render config template")?;
|
||||||
@@ -217,12 +241,22 @@ public_url = "https://old.example/"
|
|||||||
state_file = "old.sqlite3"
|
state_file = "old.sqlite3"
|
||||||
command_timeout_ms = 1234
|
command_timeout_ms = 1234
|
||||||
enroll_token_ttl_seconds = 5678
|
enroll_token_ttl_seconds = 5678
|
||||||
|
device_retention = "0d"
|
||||||
pid_file = "old.pid"
|
pid_file = "old.pid"
|
||||||
ui_dist_dir = "/opt/wakey/ui"
|
ui_dist_dir = "/opt/wakey/ui"
|
||||||
bootstrap_enroll_tokens = ["old-token"]
|
bootstrap_enroll_tokens = ["old-token"]
|
||||||
|
|
||||||
|
[access]
|
||||||
|
issuer = "https://access.example"
|
||||||
|
audience = "protected-app"
|
||||||
|
jwks_url = "https://keys.example/custom"
|
||||||
|
|
||||||
[telemetry]
|
[telemetry]
|
||||||
otlp_endpoint = "http://otel.example"
|
otlp_endpoint = "http://otel.example"
|
||||||
|
authorization_env = "WAKEY_OTLP_AUTHORIZATION"
|
||||||
|
console_filter = "info"
|
||||||
|
otlp_log_filter = "debug"
|
||||||
|
trace_filter = "info"
|
||||||
service_name = "old-service"
|
service_name = "old-service"
|
||||||
json_logs = true
|
json_logs = true
|
||||||
"#,
|
"#,
|
||||||
@@ -254,13 +288,41 @@ json_logs = true
|
|||||||
let parsed: toml::Value = toml::from_str(&rendered).expect("parse derived config");
|
let parsed: toml::Value = toml::from_str(&rendered).expect("parse derived config");
|
||||||
assert_eq!(parsed["public_url"].as_str(), Some("https://new.example"));
|
assert_eq!(parsed["public_url"].as_str(), Some("https://new.example"));
|
||||||
assert_eq!(parsed["bind"].as_str(), Some("127.0.0.1:9090"));
|
assert_eq!(parsed["bind"].as_str(), Some("127.0.0.1:9090"));
|
||||||
assert_eq!(parsed["command_timeout_ms"].as_integer(), Some(999));
|
let converted: super::FileConfig = toml::from_str(&rendered).unwrap();
|
||||||
assert_eq!(parsed["enroll_token_ttl_seconds"].as_integer(), Some(5678));
|
assert_eq!(
|
||||||
|
converted.command_timeout,
|
||||||
|
Some(std::time::Duration::from_millis(999))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
converted.enroll_token_ttl,
|
||||||
|
Some(std::time::Duration::from_secs(5678))
|
||||||
|
);
|
||||||
|
assert!(parsed.get("command_timeout_ms").is_none());
|
||||||
|
assert_eq!(converted.device_retention, Some(std::time::Duration::ZERO));
|
||||||
|
assert_eq!(
|
||||||
|
converted.access,
|
||||||
|
Some(crate::config::AccessConfig {
|
||||||
|
issuer: "https://access.example".into(),
|
||||||
|
audience: "protected-app".into(),
|
||||||
|
jwks_url: Some("https://keys.example/custom".into()),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert!(parsed.get("enroll_token_ttl_seconds").is_none());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parsed["state_file"].as_str(),
|
parsed["state_file"].as_str(),
|
||||||
Some("/tmp/wakey-base/old.sqlite3")
|
Some("/tmp/wakey-base/old.sqlite3")
|
||||||
);
|
);
|
||||||
assert_eq!(parsed["telemetry"]["json_logs"].as_bool(), Some(true));
|
assert_eq!(parsed["telemetry"]["json_logs"].as_bool(), Some(true));
|
||||||
|
assert_eq!(parsed["telemetry"]["console_filter"].as_str(), Some("info"));
|
||||||
|
assert_eq!(
|
||||||
|
parsed["telemetry"]["otlp_log_filter"].as_str(),
|
||||||
|
Some("debug")
|
||||||
|
);
|
||||||
|
assert_eq!(parsed["telemetry"]["trace_filter"].as_str(), Some("info"));
|
||||||
|
assert_eq!(
|
||||||
|
parsed["telemetry"]["authorization_env"].as_str(),
|
||||||
|
Some("WAKEY_OTLP_AUTHORIZATION")
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parsed["telemetry"]["service_name"].as_str(),
|
parsed["telemetry"]["service_name"].as_str(),
|
||||||
Some("old-service")
|
Some("old-service")
|
||||||
@@ -304,10 +366,9 @@ json_logs = true
|
|||||||
parsed["state_file"].as_str(),
|
parsed["state_file"].as_str(),
|
||||||
defaults.data_dir.join(&defaults.state_file).to_str()
|
defaults.data_dir.join(&defaults.state_file).to_str()
|
||||||
);
|
);
|
||||||
assert_eq!(
|
let converted: super::FileConfig = toml::from_str(&rendered).unwrap();
|
||||||
parsed["command_timeout_ms"].as_integer(),
|
assert_eq!(converted.command_timeout, Some(defaults.command_timeout));
|
||||||
Some(defaults.command_timeout_ms as i64)
|
assert_eq!(converted.device_retention, Some(defaults.device_retention));
|
||||||
);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parsed["telemetry"]["service_name"].as_str(),
|
parsed["telemetry"]["service_name"].as_str(),
|
||||||
Some(defaults.telemetry.service_name.as_str())
|
Some(defaults.telemetry.service_name.as_str())
|
||||||
|
|||||||
@@ -8,4 +8,4 @@ pub use resolve::{
|
|||||||
resolve_revoke_agent_settings, resolve_revoke_enroll_token_settings,
|
resolve_revoke_agent_settings, resolve_revoke_enroll_token_settings,
|
||||||
resolve_state_stats_settings,
|
resolve_state_stats_settings,
|
||||||
};
|
};
|
||||||
pub use types::{DaemonConfig, TelemetryConfig};
|
pub use types::{AccessConfig, DaemonConfig, TelemetryConfig};
|
||||||
@@ -33,10 +33,7 @@ impl DaemonConfig {
|
|||||||
args.config_file.display()
|
args.config_file.display()
|
||||||
)
|
)
|
||||||
})?,
|
})?,
|
||||||
None => defaults
|
None => defaults.bind,
|
||||||
.bind
|
|
||||||
.parse()
|
|
||||||
.expect("static default bind should parse"),
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -54,26 +51,24 @@ impl DaemonConfig {
|
|||||||
.unwrap_or_else(|| defaults.state_file.clone());
|
.unwrap_or_else(|| defaults.state_file.clone());
|
||||||
let state_file = resolve_path(&data_dir, state_file_raw);
|
let state_file = resolve_path(&data_dir, state_file_raw);
|
||||||
|
|
||||||
let command_timeout = Duration::from_millis(
|
let command_timeout = args
|
||||||
args.command_timeout_ms
|
.command_timeout_ms
|
||||||
.or(file.command_timeout_ms)
|
.map(Duration::from_millis)
|
||||||
.unwrap_or(defaults.command_timeout_ms)
|
.or(file.command_timeout)
|
||||||
.max(1),
|
.unwrap_or(defaults.command_timeout);
|
||||||
);
|
let enroll_token_ttl = args
|
||||||
|
.enroll_token_ttl_seconds
|
||||||
let enroll_token_ttl = Duration::from_secs(
|
.map(Duration::from_secs)
|
||||||
args.enroll_token_ttl_seconds
|
.or(file.enroll_token_ttl)
|
||||||
.or(file.enroll_token_ttl_seconds)
|
.unwrap_or(defaults.enroll_token_ttl);
|
||||||
.unwrap_or(defaults.enroll_token_ttl_seconds)
|
let history_retention = args
|
||||||
.max(1),
|
.observation_retention_seconds
|
||||||
);
|
.map(Duration::from_secs)
|
||||||
|
.or(file.history_retention)
|
||||||
let observation_retention = Duration::from_secs(
|
.unwrap_or(defaults.history_retention);
|
||||||
args.observation_retention_seconds
|
validate_durations(command_timeout, enroll_token_ttl, history_retention)?;
|
||||||
.or(file.observation_retention_seconds)
|
let device_retention = file.device_retention.unwrap_or(defaults.device_retention);
|
||||||
.unwrap_or(defaults.observation_retention_seconds)
|
validate_device_retention(device_retention)?;
|
||||||
.max(1),
|
|
||||||
);
|
|
||||||
|
|
||||||
let pid_file_raw = args
|
let pid_file_raw = args
|
||||||
.pid_file
|
.pid_file
|
||||||
@@ -104,11 +99,13 @@ impl DaemonConfig {
|
|||||||
state_file,
|
state_file,
|
||||||
command_timeout,
|
command_timeout,
|
||||||
enroll_token_ttl,
|
enroll_token_ttl,
|
||||||
observation_retention,
|
history_retention,
|
||||||
|
device_retention,
|
||||||
pid_file,
|
pid_file,
|
||||||
ui_dist_dir,
|
ui_dist_dir,
|
||||||
bootstrap_enroll_tokens,
|
bootstrap_enroll_tokens,
|
||||||
telemetry,
|
telemetry,
|
||||||
|
access: file.access,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -143,12 +140,12 @@ pub fn resolve_issue_token_settings(args: &IssueEnrollTokenArgs) -> Result<Issue
|
|||||||
&args.target,
|
&args.target,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
let ttl = Duration::from_secs(
|
let ttl = args
|
||||||
args.ttl_seconds
|
.ttl_seconds
|
||||||
.or(file.enroll_token_ttl_seconds)
|
.map(Duration::from_secs)
|
||||||
.unwrap_or(defaults.enroll_token_ttl_seconds)
|
.or(file.enroll_token_ttl)
|
||||||
.max(1),
|
.unwrap_or(defaults.enroll_token_ttl);
|
||||||
);
|
validate_durations(defaults.command_timeout, ttl, defaults.history_retention)?;
|
||||||
|
|
||||||
Ok(IssueTokenSettings {
|
Ok(IssueTokenSettings {
|
||||||
data_dir: state.data_dir,
|
data_dir: state.data_dir,
|
||||||
@@ -209,8 +206,66 @@ pub(crate) fn load_file_config(path: &Path) -> Result<FileConfig> {
|
|||||||
|
|
||||||
let raw = std::fs::read_to_string(path)
|
let raw = std::fs::read_to_string(path)
|
||||||
.with_context(|| format!("failed to read config file {}", path.display()))?;
|
.with_context(|| format!("failed to read config file {}", path.display()))?;
|
||||||
toml::from_str::<FileConfig>(&raw)
|
let raw: toml::Value = toml::from_str(&raw)
|
||||||
.with_context(|| format!("failed to parse config file {}", path.display()))
|
.with_context(|| format!("failed to parse config file {}", path.display()))?;
|
||||||
|
let mut value = serde_json::to_value(raw)?;
|
||||||
|
let deprecated =
|
||||||
|
wakey_core::config_duration::normalize(&mut value, super::types::DURATION_KEYS)
|
||||||
|
.map_err(anyhow::Error::msg)?;
|
||||||
|
let config = serde_json::from_value(value)
|
||||||
|
.with_context(|| format!("failed to parse config file {}", path.display()))?;
|
||||||
|
for key in deprecated {
|
||||||
|
// Daemon config is loaded before telemetry initialization. Keep these
|
||||||
|
// warnings on stderr, including when init-config emits TOML on stdout.
|
||||||
|
eprintln!(
|
||||||
|
"warning: {}: `{}` is deprecated; use `{}` with a duration string",
|
||||||
|
path.display(),
|
||||||
|
key.old,
|
||||||
|
key.new
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Device expiry uses whole-second snapshot timestamps, not an active timer.
|
||||||
|
/// Unlike history retention, zero is a supported opt-out.
|
||||||
|
pub(crate) fn validate_device_retention(retention: Duration) -> Result<()> {
|
||||||
|
anyhow::ensure!(
|
||||||
|
retention.subsec_nanos() == 0,
|
||||||
|
"device_retention must use whole seconds"
|
||||||
|
);
|
||||||
|
anyhow::ensure!(
|
||||||
|
i64::try_from(retention.as_secs()).is_ok(),
|
||||||
|
"device_retention is too large"
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn validate_durations(
|
||||||
|
command_timeout: Duration,
|
||||||
|
enroll_token_ttl: Duration,
|
||||||
|
history_retention: Duration,
|
||||||
|
) -> Result<()> {
|
||||||
|
for (name, value) in [
|
||||||
|
("command_timeout", command_timeout),
|
||||||
|
("enroll_token_ttl", enroll_token_ttl),
|
||||||
|
("history_retention", history_retention),
|
||||||
|
] {
|
||||||
|
anyhow::ensure!(!value.is_zero(), "{name} must be positive");
|
||||||
|
anyhow::ensure!(
|
||||||
|
std::time::Instant::now().checked_add(value).is_some(),
|
||||||
|
"{name} exceeds the platform timer range"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
anyhow::ensure!(
|
||||||
|
enroll_token_ttl.subsec_nanos() == 0,
|
||||||
|
"enroll_token_ttl must use whole seconds"
|
||||||
|
);
|
||||||
|
anyhow::ensure!(
|
||||||
|
history_retention.subsec_nanos() == 0,
|
||||||
|
"history_retention must use whole seconds"
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_admin_public_url(
|
fn resolve_admin_public_url(
|
||||||
@@ -229,8 +284,13 @@ fn resolve_admin_public_url(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_telemetry(file: Option<FileTelemetryConfig>) -> TelemetryConfig {
|
fn resolve_telemetry(file: Option<FileTelemetryConfig>) -> TelemetryConfig {
|
||||||
let mut out = TelemetryConfig::default();
|
// Service-specific defaults live in the CC template, not the shared type.
|
||||||
|
let mut out = DEFAULT_CONFIG.telemetry.clone();
|
||||||
if let Some(file) = file {
|
if let Some(file) = file {
|
||||||
|
out.authorization_env = file.authorization_env;
|
||||||
|
out.console_filter = file.console_filter;
|
||||||
|
out.otlp_log_filter = file.otlp_log_filter;
|
||||||
|
out.trace_filter = file.trace_filter;
|
||||||
if let Some(endpoint) = file.otlp_endpoint {
|
if let Some(endpoint) = file.otlp_endpoint {
|
||||||
let trimmed = endpoint.trim().to_string();
|
let trimmed = endpoint.trim().to_string();
|
||||||
if !trimmed.is_empty() {
|
if !trimmed.is_empty() {
|
||||||
@@ -285,11 +345,12 @@ fn resolve_state_access(
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{DaemonConfig, normalize_public_url, resolve_path};
|
use super::{DaemonConfig, normalize_public_url, resolve_path, validate_device_retention};
|
||||||
use crate::cli::ServeArgs;
|
use crate::cli::ServeArgs;
|
||||||
use crate::config::types::DEFAULT_CONFIG;
|
use crate::config::types::DEFAULT_CONFIG;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn normalize_public_url_trims_trailing_slash() {
|
fn normalize_public_url_trims_trailing_slash() {
|
||||||
@@ -340,13 +401,57 @@ mod tests {
|
|||||||
resolved.state_file,
|
resolved.state_file,
|
||||||
defaults.data_dir.join(&defaults.state_file)
|
defaults.data_dir.join(&defaults.state_file)
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(resolved.command_timeout, defaults.command_timeout);
|
||||||
resolved.command_timeout,
|
assert_eq!(resolved.device_retention, defaults.device_retention);
|
||||||
std::time::Duration::from_millis(defaults.command_timeout_ms)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
resolved.telemetry.service_name,
|
resolved.telemetry.service_name,
|
||||||
defaults.telemetry.service_name
|
defaults.telemetry.service_name
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn device_retention_accepts_disabled_and_whole_seconds_only() {
|
||||||
|
assert!(validate_device_retention(Duration::ZERO).is_ok());
|
||||||
|
assert!(validate_device_retention(DEFAULT_CONFIG.device_retention).is_ok());
|
||||||
|
assert!(validate_device_retention(Duration::from_millis(1_500)).is_err());
|
||||||
|
assert!(validate_device_retention(Duration::from_secs(u64::MAX)).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_duration_policies_are_rejected_without_clamping() {
|
||||||
|
use std::time::Duration;
|
||||||
|
let defaults = &*DEFAULT_CONFIG;
|
||||||
|
assert!(
|
||||||
|
super::validate_durations(
|
||||||
|
Duration::ZERO,
|
||||||
|
defaults.enroll_token_ttl,
|
||||||
|
defaults.history_retention
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
super::validate_durations(
|
||||||
|
defaults.command_timeout,
|
||||||
|
Duration::from_millis(500),
|
||||||
|
defaults.history_retention
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
super::validate_durations(
|
||||||
|
defaults.command_timeout,
|
||||||
|
defaults.enroll_token_ttl,
|
||||||
|
Duration::ZERO
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
super::validate_durations(
|
||||||
|
Duration::from_secs(u64::MAX),
|
||||||
|
defaults.enroll_token_ttl,
|
||||||
|
defaults.history_retention
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,8 @@ use std::sync::LazyLock;
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use wakey_core::config_duration::{self, LegacyDuration};
|
||||||
|
pub use wakey_core::telemetry::TelemetryConfig;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct DaemonConfig {
|
pub struct DaemonConfig {
|
||||||
@@ -13,91 +15,113 @@ pub struct DaemonConfig {
|
|||||||
pub state_file: PathBuf,
|
pub state_file: PathBuf,
|
||||||
pub command_timeout: Duration,
|
pub command_timeout: Duration,
|
||||||
pub enroll_token_ttl: Duration,
|
pub enroll_token_ttl: Duration,
|
||||||
pub observation_retention: Duration,
|
pub history_retention: Duration,
|
||||||
|
/// Maximum age since an agent snapshot included a device; zero disables expiry.
|
||||||
|
pub device_retention: Duration,
|
||||||
pub pid_file: PathBuf,
|
pub pid_file: PathBuf,
|
||||||
pub ui_dist_dir: PathBuf,
|
pub ui_dist_dir: PathBuf,
|
||||||
pub bootstrap_enroll_tokens: Vec<String>,
|
pub bootstrap_enroll_tokens: Vec<String>,
|
||||||
pub telemetry: TelemetryConfig,
|
pub telemetry: TelemetryConfig,
|
||||||
|
pub access: Option<AccessConfig>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
/// Trusted origin-verifier settings; no URL is taken from a JWT header or claim.
|
||||||
pub struct TelemetryConfig {
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub otlp_endpoint: Option<String>,
|
#[serde(deny_unknown_fields)]
|
||||||
pub service_name: String,
|
pub struct AccessConfig {
|
||||||
pub json_logs: bool,
|
pub issuer: String,
|
||||||
|
pub audience: String,
|
||||||
|
/// Omission uses the Cloudflare Access certs path beneath the issuer.
|
||||||
|
pub jwks_url: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for TelemetryConfig {
|
/// TOML DTO for both loading partial files and generating complete files.
|
||||||
fn default() -> Self {
|
/// Options preserve omission for precedence; runtime conversion stays separate.
|
||||||
let defaults = &DEFAULT_CONFIG.telemetry;
|
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
|
||||||
Self {
|
#[serde(remote = "Self")]
|
||||||
otlp_endpoint: defaults.otlp_endpoint.clone(),
|
|
||||||
service_name: defaults.service_name.clone(),
|
|
||||||
json_logs: defaults.json_logs,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Default)]
|
|
||||||
pub(crate) struct FileConfig {
|
pub(crate) struct FileConfig {
|
||||||
pub(crate) data_dir: Option<PathBuf>,
|
pub(crate) data_dir: Option<PathBuf>,
|
||||||
pub(crate) bind: Option<String>,
|
pub(crate) bind: Option<String>,
|
||||||
pub(crate) public_url: Option<String>,
|
pub(crate) public_url: Option<String>,
|
||||||
pub(crate) state_file: Option<PathBuf>,
|
pub(crate) state_file: Option<PathBuf>,
|
||||||
pub(crate) command_timeout_ms: Option<u64>,
|
#[serde(default, with = "humantime_serde::option")]
|
||||||
pub(crate) enroll_token_ttl_seconds: Option<u64>,
|
pub(crate) command_timeout: Option<Duration>,
|
||||||
pub(crate) observation_retention_seconds: Option<u64>,
|
#[serde(default, with = "humantime_serde::option")]
|
||||||
|
pub(crate) enroll_token_ttl: Option<Duration>,
|
||||||
|
#[serde(default, with = "humantime_serde::option")]
|
||||||
|
pub(crate) history_retention: Option<Duration>,
|
||||||
|
#[serde(default, with = "humantime_serde::option")]
|
||||||
|
pub(crate) device_retention: Option<Duration>,
|
||||||
pub(crate) pid_file: Option<PathBuf>,
|
pub(crate) pid_file: Option<PathBuf>,
|
||||||
pub(crate) ui_dist_dir: Option<PathBuf>,
|
pub(crate) ui_dist_dir: Option<PathBuf>,
|
||||||
#[serde(alias = "enroll_tokens")]
|
#[serde(alias = "enroll_tokens")]
|
||||||
pub(crate) bootstrap_enroll_tokens: Option<Vec<String>>,
|
pub(crate) bootstrap_enroll_tokens: Option<Vec<String>>,
|
||||||
pub(crate) telemetry: Option<FileTelemetryConfig>,
|
pub(crate) telemetry: Option<FileTelemetryConfig>,
|
||||||
|
pub(crate) access: Option<AccessConfig>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Default)]
|
pub(crate) const DURATION_KEYS: &[LegacyDuration] = &[
|
||||||
|
LegacyDuration {
|
||||||
|
old: "command_timeout_ms",
|
||||||
|
new: "command_timeout",
|
||||||
|
unit: Duration::from_millis(1),
|
||||||
|
},
|
||||||
|
LegacyDuration {
|
||||||
|
old: "enroll_token_ttl_seconds",
|
||||||
|
new: "enroll_token_ttl",
|
||||||
|
unit: Duration::from_secs(1),
|
||||||
|
},
|
||||||
|
LegacyDuration {
|
||||||
|
old: "observation_retention_seconds",
|
||||||
|
new: "history_retention",
|
||||||
|
unit: Duration::from_secs(1),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
impl Serialize for FileConfig {
|
||||||
|
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||||
|
Self::serialize(self, serializer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl<'de> Deserialize<'de> for FileConfig {
|
||||||
|
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||||
|
config_duration::deserialize_legacy(deserializer, DURATION_KEYS, Self::deserialize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
|
||||||
pub(crate) struct FileTelemetryConfig {
|
pub(crate) struct FileTelemetryConfig {
|
||||||
|
// Missing fields must remain distinguishable from explicit values when
|
||||||
|
// init-config merges an existing file with CLI overrides and defaults.
|
||||||
|
pub(crate) console_filter: Option<String>,
|
||||||
|
pub(crate) otlp_log_filter: Option<String>,
|
||||||
|
pub(crate) trace_filter: Option<String>,
|
||||||
pub(crate) otlp_endpoint: Option<String>,
|
pub(crate) otlp_endpoint: Option<String>,
|
||||||
|
pub(crate) authorization_env: Option<String>,
|
||||||
pub(crate) service_name: Option<String>,
|
pub(crate) service_name: Option<String>,
|
||||||
pub(crate) json_logs: Option<bool>,
|
pub(crate) json_logs: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
// Relative template paths are resolved under data_dir by the runtime loader.
|
||||||
pub(crate) struct WritableConfig {
|
pub(crate) static DEFAULT_CONFIG: LazyLock<DaemonConfig> = LazyLock::new(|| DaemonConfig {
|
||||||
pub(crate) data_dir: PathBuf,
|
|
||||||
pub(crate) bind: String,
|
|
||||||
pub(crate) public_url: String,
|
|
||||||
pub(crate) state_file: PathBuf,
|
|
||||||
pub(crate) command_timeout_ms: u64,
|
|
||||||
pub(crate) enroll_token_ttl_seconds: u64,
|
|
||||||
pub(crate) observation_retention_seconds: u64,
|
|
||||||
pub(crate) pid_file: PathBuf,
|
|
||||||
pub(crate) ui_dist_dir: PathBuf,
|
|
||||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
|
||||||
pub(crate) bootstrap_enroll_tokens: Vec<String>,
|
|
||||||
pub(crate) telemetry: WritableTelemetry,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
|
||||||
pub(crate) struct WritableTelemetry {
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub(crate) otlp_endpoint: Option<String>,
|
|
||||||
pub(crate) service_name: String,
|
|
||||||
pub(crate) json_logs: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) static DEFAULT_CONFIG: LazyLock<WritableConfig> = LazyLock::new(|| WritableConfig {
|
|
||||||
data_dir: crate::cli::DEFAULT_DATA_DIR.into(),
|
data_dir: crate::cli::DEFAULT_DATA_DIR.into(),
|
||||||
bind: "0.0.0.0:8080".to_string(),
|
bind: SocketAddr::from(([0, 0, 0, 0], 8080)),
|
||||||
public_url: "http://127.0.0.1:8080".to_string(),
|
public_url: "http://127.0.0.1:8080".to_string(),
|
||||||
state_file: "state.sqlite3".into(),
|
state_file: "state.sqlite3".into(),
|
||||||
command_timeout_ms: 30_000,
|
command_timeout: Duration::from_millis(30_000),
|
||||||
enroll_token_ttl_seconds: 86_400,
|
enroll_token_ttl: Duration::from_secs(86_400),
|
||||||
observation_retention_seconds: 2_592_000,
|
history_retention: Duration::from_secs(2_592_000),
|
||||||
|
device_retention: Duration::from_secs(60 * 86_400),
|
||||||
pid_file: "wakey-control-plane.pid".into(),
|
pid_file: "wakey-control-plane.pid".into(),
|
||||||
ui_dist_dir: "ui/dist".into(),
|
ui_dist_dir: "ui/dist".into(),
|
||||||
bootstrap_enroll_tokens: Vec::new(),
|
bootstrap_enroll_tokens: Vec::new(),
|
||||||
telemetry: WritableTelemetry {
|
access: None,
|
||||||
|
telemetry: TelemetryConfig {
|
||||||
|
console_filter: None,
|
||||||
|
otlp_log_filter: None,
|
||||||
|
trace_filter: None,
|
||||||
otlp_endpoint: None,
|
otlp_endpoint: None,
|
||||||
|
authorization_env: None,
|
||||||
service_name: "wakey-control-plane".to_string(),
|
service_name: "wakey-control-plane".to_string(),
|
||||||
json_logs: false,
|
json_logs: false,
|
||||||
},
|
},
|
||||||
@@ -115,3 +139,85 @@ pub struct StateAccessSettings {
|
|||||||
pub state_file: PathBuf,
|
pub state_file: PathBuf,
|
||||||
pub public_url: Option<String>,
|
pub public_url: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{FileConfig, FileTelemetryConfig};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn file_dto_round_trip_preserves_omission_and_explicit_values() {
|
||||||
|
let omitted: FileConfig = toml::from_str("").unwrap();
|
||||||
|
assert_eq!(omitted, FileConfig::default());
|
||||||
|
assert_eq!(toml::to_string(&omitted).unwrap(), "");
|
||||||
|
|
||||||
|
let explicit = FileConfig {
|
||||||
|
bootstrap_enroll_tokens: Some(Vec::new()),
|
||||||
|
history_retention: Some(std::time::Duration::ZERO),
|
||||||
|
telemetry: Some(FileTelemetryConfig {
|
||||||
|
json_logs: Some(false),
|
||||||
|
console_filter: Some("info".into()),
|
||||||
|
otlp_log_filter: Some("debug".into()),
|
||||||
|
trace_filter: Some("info".into()),
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let rendered = toml::to_string_pretty(&explicit).unwrap();
|
||||||
|
let restored: FileConfig = toml::from_str(&rendered).unwrap();
|
||||||
|
assert_eq!(restored, explicit);
|
||||||
|
assert_ne!(restored, omitted);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_token_alias_serializes_with_canonical_name() {
|
||||||
|
let file: FileConfig = toml::from_str("enroll_tokens = []").unwrap();
|
||||||
|
assert_eq!(file.bootstrap_enroll_tokens, Some(Vec::new()));
|
||||||
|
let rendered = toml::to_string(&file).unwrap();
|
||||||
|
assert!(rendered.contains("bootstrap_enroll_tokens = []"));
|
||||||
|
assert!(!rendered.starts_with("enroll_tokens"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_durations_match_new_keys_and_emit_only_strings() {
|
||||||
|
let legacy: FileConfig = toml::from_str("command_timeout_ms = 1234\nenroll_token_ttl_seconds = 3600\nobservation_retention_seconds = 2592000").unwrap();
|
||||||
|
let canonical: FileConfig = toml::from_str(
|
||||||
|
"command_timeout = '1234ms'\nenroll_token_ttl = '1h'\nhistory_retention = '30d'",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(legacy, canonical);
|
||||||
|
let output = toml::to_string(&legacy).unwrap();
|
||||||
|
for key in super::DURATION_KEYS {
|
||||||
|
assert!(!output.contains(key.old));
|
||||||
|
assert!(output.contains(key.new));
|
||||||
|
}
|
||||||
|
assert_eq!(toml::from_str::<FileConfig>(&output).unwrap(), canonical);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn file_durations_reject_duplicates_and_bare_numbers() {
|
||||||
|
for key in super::DURATION_KEYS {
|
||||||
|
let both = format!("{} = '1s'\n{} = 1", key.new, key.old);
|
||||||
|
assert!(toml::from_str::<FileConfig>(&both).is_err());
|
||||||
|
let number = format!("{} = 1", key.new);
|
||||||
|
assert!(toml::from_str::<FileConfig>(&number).is_err());
|
||||||
|
let negative = format!("{} = -1", key.old);
|
||||||
|
assert!(toml::from_str::<FileConfig>(&negative).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn device_retention_round_trips_and_requires_units() {
|
||||||
|
for text in ["60d", "0d"] {
|
||||||
|
let file: FileConfig = toml::from_str(&format!("device_retention = '{text}'")).unwrap();
|
||||||
|
let output = toml::to_string(&file).unwrap();
|
||||||
|
assert_eq!(toml::from_str::<FileConfig>(&output).unwrap(), file);
|
||||||
|
}
|
||||||
|
for input in [
|
||||||
|
"device_retention = 60",
|
||||||
|
"device_retention = 1.5",
|
||||||
|
"device_retention = '-1d'",
|
||||||
|
] {
|
||||||
|
assert!(toml::from_str::<FileConfig>(input).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,8 +28,10 @@ async fn main() -> Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let daemon = config::DaemonConfig::from_serve_args(&args)?;
|
let daemon = config::DaemonConfig::from_serve_args(&args)?;
|
||||||
tracing::init(cli.verbose, &daemon.telemetry)?;
|
let telemetry = tracing::init(cli.verbose, &daemon.telemetry)?;
|
||||||
runtime::serve(daemon).await
|
let result = runtime::serve(daemon).await;
|
||||||
|
tokio::task::spawn_blocking(move || telemetry.shutdown()).await?;
|
||||||
|
result
|
||||||
}
|
}
|
||||||
Command::InitConfig(args) => {
|
Command::InitConfig(args) => {
|
||||||
if let Some(path) = config::write_init_config(&args)? {
|
if let Some(path) = config::write_init_config(&args)? {
|
||||||
|
|||||||
@@ -0,0 +1,337 @@
|
|||||||
|
//! Origin authentication oracle for Caddy, not middleware on the control API.
|
||||||
|
//! Enforcement belongs to the proxy; the daemon must remain loopback-only.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration, SystemTime};
|
||||||
|
|
||||||
|
use anyhow::{Context, Result, ensure};
|
||||||
|
use axum::{
|
||||||
|
Extension, Router,
|
||||||
|
http::{HeaderMap, StatusCode},
|
||||||
|
routing::get,
|
||||||
|
};
|
||||||
|
use http_cache_semantics::{AfterResponse, BeforeRequest, CacheOptions, CachePolicy};
|
||||||
|
use jsonwebtoken::jwk::{AlgorithmParameters, JwkSet, KeyAlgorithm, KeyOperations, PublicKeyUse};
|
||||||
|
use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode, decode_header};
|
||||||
|
use reqwest::{Client, Url};
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
use tokio::time::Instant;
|
||||||
|
|
||||||
|
use super::AppState;
|
||||||
|
use crate::config::AccessConfig;
|
||||||
|
|
||||||
|
const REFRESH_COOLDOWN: Duration = Duration::from_secs(60);
|
||||||
|
const FETCH_TIMEOUT: Duration = Duration::from_secs(5);
|
||||||
|
const MAX_JWKS_BYTES: usize = 256 * 1024;
|
||||||
|
const MAX_TOKEN_BYTES: usize = 16 * 1024;
|
||||||
|
const MAX_KEYS: usize = 64;
|
||||||
|
|
||||||
|
// This is a private, single-resource cache. Do not invent a freshness lifetime
|
||||||
|
// when the issuer supplies none, or override its no-cache/no-store directives.
|
||||||
|
const CACHE_OPTIONS: CacheOptions = CacheOptions {
|
||||||
|
shared: false,
|
||||||
|
cache_heuristic: 0.0,
|
||||||
|
immutable_min_time_to_live: Duration::ZERO,
|
||||||
|
ignore_cargo_cult: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Absent configuration deliberately leaves the oracle denying every request.
|
||||||
|
pub(super) fn routes(config: Option<&AccessConfig>, bind: SocketAddr) -> Result<Router<AppState>> {
|
||||||
|
let verifier = config
|
||||||
|
.map(|config| {
|
||||||
|
ensure!(
|
||||||
|
bind.ip().is_loopback(),
|
||||||
|
"Access verification requires a loopback bind; Caddy owns enforcement"
|
||||||
|
);
|
||||||
|
Verifier::new(config).map(Arc::new)
|
||||||
|
})
|
||||||
|
.transpose()?;
|
||||||
|
Ok(verifier_routes(verifier))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verifier_routes<S: Clone + Send + Sync + 'static>(verifier: Option<Arc<Verifier>>) -> Router<S> {
|
||||||
|
Router::new()
|
||||||
|
.route("/internal/auth/verify", get(verify))
|
||||||
|
.layer(Extension(verifier))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn verify(
|
||||||
|
Extension(verifier): Extension<Option<Arc<Verifier>>>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> StatusCode {
|
||||||
|
let Some(verifier) = verifier else {
|
||||||
|
return StatusCode::UNAUTHORIZED;
|
||||||
|
};
|
||||||
|
let mut values = headers.get_all("cf-access-jwt-assertion").iter();
|
||||||
|
let Some(token) = values.next().and_then(|value| value.to_str().ok()) else {
|
||||||
|
return StatusCode::UNAUTHORIZED;
|
||||||
|
};
|
||||||
|
if values.next().is_some() {
|
||||||
|
return StatusCode::UNAUTHORIZED;
|
||||||
|
}
|
||||||
|
match verifier.validate(token).await {
|
||||||
|
Ok(()) => StatusCode::NO_CONTENT,
|
||||||
|
Err(VerifyError::Invalid) => StatusCode::UNAUTHORIZED,
|
||||||
|
Err(VerifyError::Unavailable) => StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Verifier {
|
||||||
|
client: Client,
|
||||||
|
jwks_url: Url,
|
||||||
|
validation: Validation,
|
||||||
|
cache: Mutex<KeyCache>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct KeyCache {
|
||||||
|
keys: HashMap<String, DecodingKey>,
|
||||||
|
policy: Option<CachePolicy>,
|
||||||
|
attempted_at: Option<Instant>,
|
||||||
|
last_fetch_failed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
|
||||||
|
enum VerifyError {
|
||||||
|
#[error("invalid Access assertion")]
|
||||||
|
Invalid,
|
||||||
|
#[error("Access signing keys unavailable")]
|
||||||
|
Unavailable,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Verifier {
|
||||||
|
fn new(config: &AccessConfig) -> Result<Self> {
|
||||||
|
let issuer = trusted_url(&config.issuer)?;
|
||||||
|
ensure!(
|
||||||
|
issuer.path() == "/" && !config.issuer.ends_with('/'),
|
||||||
|
"Access issuer must be an HTTPS origin without a trailing slash"
|
||||||
|
);
|
||||||
|
ensure!(
|
||||||
|
!config.audience.trim().is_empty(),
|
||||||
|
"Access audience must not be empty"
|
||||||
|
);
|
||||||
|
let jwks_url = match &config.jwks_url {
|
||||||
|
Some(url) => trusted_url(url)?,
|
||||||
|
None => issuer.join("/cdn-cgi/access/certs")?,
|
||||||
|
};
|
||||||
|
let mut validation = Validation::new(Algorithm::RS256);
|
||||||
|
validation.set_issuer(&[&config.issuer]);
|
||||||
|
validation.set_audience(&[&config.audience]);
|
||||||
|
validation.set_required_spec_claims(&["exp", "iss", "aud"]);
|
||||||
|
validation.validate_nbf = true;
|
||||||
|
validation.leeway = 0;
|
||||||
|
Ok(Self {
|
||||||
|
client: Client::builder()
|
||||||
|
.timeout(FETCH_TIMEOUT)
|
||||||
|
.redirect(reqwest::redirect::Policy::none())
|
||||||
|
.build()?,
|
||||||
|
jwks_url,
|
||||||
|
validation,
|
||||||
|
cache: Mutex::new(KeyCache::default()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn validate(&self, token: &str) -> Result<(), VerifyError> {
|
||||||
|
if token.len() > MAX_TOKEN_BYTES {
|
||||||
|
return Err(VerifyError::Invalid);
|
||||||
|
}
|
||||||
|
// Only kid selects a cached key. Token-supplied URLs never drive I/O.
|
||||||
|
let header = decode_header(token).map_err(|_| VerifyError::Invalid)?;
|
||||||
|
if header.alg != Algorithm::RS256 {
|
||||||
|
return Err(VerifyError::Invalid);
|
||||||
|
}
|
||||||
|
let kid = header
|
||||||
|
.kid
|
||||||
|
.filter(|kid| !kid.is_empty() && kid.len() <= 256)
|
||||||
|
.ok_or(VerifyError::Invalid)?;
|
||||||
|
let key = self.key(&kid).await?;
|
||||||
|
decode::<serde_json::Value>(token, &key, &self.validation)
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(|_| VerifyError::Invalid)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn key(&self, kid: &str) -> Result<DecodingKey, VerifyError> {
|
||||||
|
// Single-flight refresh with a bounded HTTP fetch. The cooldown is
|
||||||
|
// global, not an attacker-controlled per-kid cache, including outages.
|
||||||
|
let mut cache = self.cache.lock().await;
|
||||||
|
let mut request = self
|
||||||
|
.client
|
||||||
|
.get(self.jwks_url.clone())
|
||||||
|
.build()
|
||||||
|
.map_err(|_| VerifyError::Unavailable)?;
|
||||||
|
let now = Instant::now();
|
||||||
|
let fresh = cache.policy.as_ref().is_some_and(|policy| {
|
||||||
|
matches!(
|
||||||
|
policy.before_request(&request, SystemTime::now()),
|
||||||
|
BeforeRequest::Fresh(_)
|
||||||
|
)
|
||||||
|
});
|
||||||
|
if fresh && let Some(key) = cache.keys.get(kid) {
|
||||||
|
return Ok(key.clone());
|
||||||
|
}
|
||||||
|
if cache
|
||||||
|
.attempted_at
|
||||||
|
.is_none_or(|at| now.duration_since(at) >= REFRESH_COOLDOWN)
|
||||||
|
{
|
||||||
|
cache.attempted_at = Some(now);
|
||||||
|
// A missing kid must bypass a still-fresh response cache to notice
|
||||||
|
// rotation. The HTTP library adds ETag/Last-Modified validators.
|
||||||
|
request.headers_mut().insert(
|
||||||
|
reqwest::header::CACHE_CONTROL,
|
||||||
|
reqwest::header::HeaderValue::from_static("no-cache"),
|
||||||
|
);
|
||||||
|
if let Some(policy) = &cache.policy
|
||||||
|
&& let BeforeRequest::Stale {
|
||||||
|
request: conditional,
|
||||||
|
..
|
||||||
|
} = policy.before_request(&request, SystemTime::now())
|
||||||
|
{
|
||||||
|
*request.headers_mut() = conditional.headers;
|
||||||
|
}
|
||||||
|
match self.fetch_keys(&request, &cache).await {
|
||||||
|
Ok((keys, policy)) => {
|
||||||
|
let selected = keys.get(kid).cloned().ok_or(VerifyError::Invalid);
|
||||||
|
if policy.is_storable() {
|
||||||
|
cache.keys = keys;
|
||||||
|
cache.policy = Some(policy);
|
||||||
|
} else {
|
||||||
|
// no-store permits this response's use for this request,
|
||||||
|
// but neither keys nor HTTP metadata may be retained.
|
||||||
|
cache.keys.clear();
|
||||||
|
cache.policy = None;
|
||||||
|
}
|
||||||
|
cache.last_fetch_failed = false;
|
||||||
|
return selected;
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
// Never include upstream bodies, tokens or claims in logs.
|
||||||
|
tracing::warn!(
|
||||||
|
"Access signing-key refresh failed; verification fails closed when no fresh matching key is available"
|
||||||
|
);
|
||||||
|
cache.last_fetch_failed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !fresh || cache.last_fetch_failed {
|
||||||
|
Err(VerifyError::Unavailable)
|
||||||
|
} else {
|
||||||
|
Err(VerifyError::Invalid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_keys(
|
||||||
|
&self,
|
||||||
|
request: &reqwest::Request,
|
||||||
|
cached: &KeyCache,
|
||||||
|
) -> Result<(HashMap<String, DecodingKey>, CachePolicy)> {
|
||||||
|
let mut response = self
|
||||||
|
.client
|
||||||
|
.execute(
|
||||||
|
request
|
||||||
|
.try_clone()
|
||||||
|
.context("JWKS request cannot be cloned")?,
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
.error_for_status()?;
|
||||||
|
let received_at = SystemTime::now();
|
||||||
|
if response.status() == StatusCode::NOT_MODIFIED {
|
||||||
|
ensure!(
|
||||||
|
request
|
||||||
|
.headers()
|
||||||
|
.contains_key(reqwest::header::IF_NONE_MATCH)
|
||||||
|
|| request
|
||||||
|
.headers()
|
||||||
|
.contains_key(reqwest::header::IF_MODIFIED_SINCE),
|
||||||
|
"unsolicited JWKS 304"
|
||||||
|
);
|
||||||
|
let policy = cached
|
||||||
|
.policy
|
||||||
|
.as_ref()
|
||||||
|
.context("JWKS 304 without stored keys")?;
|
||||||
|
return match policy.after_response(request, &response, received_at) {
|
||||||
|
AfterResponse::NotModified(policy, _) => Ok((cached.keys.clone(), policy)),
|
||||||
|
AfterResponse::Modified(_, _) => {
|
||||||
|
anyhow::bail!("JWKS 304 did not match stored validators")
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
ensure!(
|
||||||
|
response.status() == StatusCode::OK,
|
||||||
|
"JWKS must return 200 or a matching 304"
|
||||||
|
);
|
||||||
|
let policy = CachePolicy::new_options(request, &response, received_at, CACHE_OPTIONS);
|
||||||
|
ensure!(
|
||||||
|
response
|
||||||
|
.content_length()
|
||||||
|
.is_none_or(|len| len <= MAX_JWKS_BYTES as u64),
|
||||||
|
"JWKS too large"
|
||||||
|
);
|
||||||
|
let mut body = Vec::new();
|
||||||
|
while let Some(chunk) = response.chunk().await? {
|
||||||
|
ensure!(chunk.len() <= MAX_JWKS_BYTES - body.len(), "JWKS too large");
|
||||||
|
body.extend_from_slice(&chunk);
|
||||||
|
}
|
||||||
|
Ok((parse_keys(&body)?, policy))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn trusted_url(value: &str) -> Result<Url> {
|
||||||
|
let url = Url::parse(value).context("invalid Access URL")?;
|
||||||
|
ensure!(
|
||||||
|
url.scheme() == "https"
|
||||||
|
&& url.host_str().is_some()
|
||||||
|
&& url.username().is_empty()
|
||||||
|
&& url.password().is_none()
|
||||||
|
&& url.query().is_none()
|
||||||
|
&& url.fragment().is_none(),
|
||||||
|
"Access URLs require HTTPS without credentials, query or fragment"
|
||||||
|
);
|
||||||
|
Ok(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_keys(body: &[u8]) -> Result<HashMap<String, DecodingKey>> {
|
||||||
|
let jwks: JwkSet = serde_json::from_slice(body)?;
|
||||||
|
ensure!(jwks.keys.len() <= MAX_KEYS, "too many signing keys");
|
||||||
|
let mut keys = HashMap::new();
|
||||||
|
for jwk in jwks.keys {
|
||||||
|
if !matches!(jwk.algorithm, AlgorithmParameters::RSA(_))
|
||||||
|
|| jwk
|
||||||
|
.common
|
||||||
|
.key_algorithm
|
||||||
|
.is_some_and(|alg| alg != KeyAlgorithm::RS256)
|
||||||
|
|| jwk
|
||||||
|
.common
|
||||||
|
.public_key_use
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|usage| *usage != PublicKeyUse::Signature)
|
||||||
|
|| jwk
|
||||||
|
.common
|
||||||
|
.key_operations
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|ops| !ops.contains(&KeyOperations::Verify))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(kid) = jwk
|
||||||
|
.common
|
||||||
|
.key_id
|
||||||
|
.as_ref()
|
||||||
|
.filter(|kid| !kid.is_empty() && kid.len() <= 256)
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let key = DecodingKey::from_jwk(&jwk)?;
|
||||||
|
ensure!(
|
||||||
|
keys.insert(kid.clone(), key).is_none(),
|
||||||
|
"duplicate signing key ID"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// A valid empty set revokes every cached key. Do not treat it as a fetch
|
||||||
|
// failure and preserve keys that the issuer has deliberately removed.
|
||||||
|
Ok(keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
-----BEGIN PRIVATE KEY-----
|
||||||
|
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCpN0wYAVcYDhNr
|
||||||
|
MNgF22jtk0uiZZQQl4K+uM1S3ivY3sQDjZj3y6icwR5MhYHOtCW2+EY/2IctpEcd
|
||||||
|
/3li5OOna2RKDdTpshw/mE3Q4NYZyk8JCAoIY+J9yzQsBdTjKbSUu3tMFxQb89ap
|
||||||
|
baAooZ5TpnZ3ZVZUN1SQjYq5my8sZAkO9JOOMHnPx/5Ba5Z4IT9DX8hzlgG+e1F3
|
||||||
|
lW3XpjTt3xgGp3ZWNUU6hA2dnwHrkvmy91U1ojC0Bow2ySL0hbgHnFwFNpsx5wFs
|
||||||
|
Mum4/LI+ZA9yaWKdbPKXx7W0g+x0ihqyq/K00z/KE4zIIENS+hNv/Ko7nMB0S/s2
|
||||||
|
gkdmgiEjAgMBAAECggEAE0olR6lDczpg4VEYlxZWGvBgaLCj/9OIXn3KPijrHlZK
|
||||||
|
nvhnGHxOtMvY9zJ9mSwfuStf4AsADJETtao3/VuH+L+nwj9bEZGHDILWqrnZU5L5
|
||||||
|
49bW70eU+++2+1EQ3ce/zk1O9rNnd0ZFJrGYq85nXN7MK2Z8awOXBWwf2nJZHs9Q
|
||||||
|
QYM+LFiLDqkT+sTGIwV8mNwVN+S4tKEyxPWuFmGU649nQL6LAy/NJKtH+6C7feEa
|
||||||
|
pdIoUZ6TT9QvR2kVC5XTaDeLGf1uwgzFBkB9hZoFMBzh8Bx1k0p9WkA1ViJkSzXc
|
||||||
|
T38deIES5RUid0/GVxOKZkuYk7jnoLWMxK8T5SzeWQKBgQDg3VOyPy0ykD+YtgbL
|
||||||
|
4DWrBW/kregMfximpESEL7yP+XAKv6vYox4oGDncoX7g48C6K6c1kSdlGb0We1nA
|
||||||
|
md+TraIQjDWv9Zyfr7FeiD8739xJGz8T7Y0TU7sdd1EYz5fUkURLqzkX9Tpy4+Ia
|
||||||
|
mXSUH4jfyjMWVghhe9E2rJmUawKBgQDApWvHFIp2nWDvErE/xPQnMCTxc3ndGbfi
|
||||||
|
1CszYlQcm+C57KhMprWUbDSXrGzqlqn75npdIsXXbY1SORb44antwHzofJyhE9aA
|
||||||
|
RGPDKtym9d8/+zy7DtYfw7IlIDZtXFpgCQPtKPXVZ0v3gE0/kYfeRcoyyQnsT8jd
|
||||||
|
MqiX0zMUKQKBgEf+tQhx78+rnaEQTrbB74z1LbEi+RPfMY5RM9q6yf2y2GuomKXA
|
||||||
|
/CvMLeJ1FoF8ij1RMrUU528YQZFMJx1iZtIKmsP/kETpN59tCjYBG3XZI9eIULbZ
|
||||||
|
rPyOJ2+0vxNZi/w0EpDDnoEMNA2K5t6rWUb/nbxBKgAxxXcoxIBj3LxHAoGAJYFP
|
||||||
|
DVATcOpMz96MwYR/oA2Ac71ukTwLj86MppFTHMrAIpJPZbTjDwVN6+V2wukYKOGD
|
||||||
|
GxTE/Z7nb4WL0mJkqdPavYxdFgL9tLUb0whKMvsVPqiT9zr5Nu/rg8zUq8/aDG5U
|
||||||
|
5FwgGC7nHEVPRLv5fq2Jov9+vhH+YgoVxpEolZkCgYAgZ+PdXcm1XkwioHlBCfOH
|
||||||
|
ilXAaubbb1SZtrodabplqut8ox9oq9RL/Fa549IPIB5e+SSAU3S/al21/0+bxs8C
|
||||||
|
QyoWII8ayb7vgZ46TBuaaZVT4Oxd83GQgc4Az91m7UZIgxhbS/3os1tqXJqQKujt
|
||||||
|
Wy7tHJh9RC/z2+kFSog3tA==
|
||||||
|
-----END PRIVATE KEY-----
|
||||||
@@ -0,0 +1,590 @@
|
|||||||
|
use super::*;
|
||||||
|
use axum::body::Body;
|
||||||
|
use axum::http::{HeaderValue, Request};
|
||||||
|
use jsonwebtoken::jwk::Jwk;
|
||||||
|
use jsonwebtoken::{EncodingKey, Header, encode};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
use tower::ServiceExt;
|
||||||
|
|
||||||
|
// Public test-only private key, never used as a deployment credential.
|
||||||
|
const TEST_KEY: &[u8] = include_bytes!("test-key.pem");
|
||||||
|
const ISSUER: &str = "https://access.example";
|
||||||
|
const AUDIENCE: &str = "wakey-test";
|
||||||
|
|
||||||
|
fn config() -> AccessConfig {
|
||||||
|
AccessConfig {
|
||||||
|
issuer: ISSUER.into(),
|
||||||
|
audience: AUDIENCE.into(),
|
||||||
|
jwks_url: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encoding_key() -> EncodingKey {
|
||||||
|
EncodingKey::from_rsa_pem(TEST_KEY).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn jwks(kid: &str) -> Value {
|
||||||
|
let mut jwk = Jwk::from_encoding_key(&encoding_key(), Algorithm::RS256).unwrap();
|
||||||
|
jwk.common.key_id = Some(kid.into());
|
||||||
|
json!({"keys": [jwk]})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn claims() -> Value {
|
||||||
|
// Fixed dates make boundary tests independent of scheduling delays.
|
||||||
|
json!({"iss": ISSUER, "aud": [AUDIENCE], "exp": 4102444800u64})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn token(kid: &str, claims: &Value) -> String {
|
||||||
|
let mut header = Header::new(Algorithm::RS256);
|
||||||
|
header.kid = Some(kid.into());
|
||||||
|
encode(&header, claims, &encoding_key()).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn cached() -> Arc<Verifier> {
|
||||||
|
let verifier = Arc::new(Verifier::new(&config()).unwrap());
|
||||||
|
let mut cache = verifier.cache.lock().await;
|
||||||
|
cache.keys = parse_keys(&serde_json::to_vec(&jwks("one")).unwrap()).unwrap();
|
||||||
|
cache.policy = Some(test_policy(
|
||||||
|
&verifier,
|
||||||
|
"public, max-age=14400, must-revalidate",
|
||||||
|
None,
|
||||||
|
));
|
||||||
|
cache.attempted_at = Some(Instant::now());
|
||||||
|
drop(cache);
|
||||||
|
verifier
|
||||||
|
}
|
||||||
|
|
||||||
|
fn test_policy(verifier: &Verifier, cache_control: &str, etag: Option<&str>) -> CachePolicy {
|
||||||
|
let request = verifier
|
||||||
|
.client
|
||||||
|
.get(verifier.jwks_url.clone())
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
let mut response = axum::http::Response::builder().header("cache-control", cache_control);
|
||||||
|
if let Some(etag) = etag {
|
||||||
|
response = response.header("etag", etag);
|
||||||
|
}
|
||||||
|
CachePolicy::new_options(
|
||||||
|
&request,
|
||||||
|
&response.body(()).unwrap(),
|
||||||
|
SystemTime::now(),
|
||||||
|
CACHE_OPTIONS,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn status(app: Router, assertion: Option<&str>) -> StatusCode {
|
||||||
|
let mut request = Request::builder().uri("/internal/auth/verify");
|
||||||
|
if let Some(assertion) = assertion {
|
||||||
|
request = request.header("cf-access-jwt-assertion", assertion);
|
||||||
|
}
|
||||||
|
app.oneshot(request.body(Body::empty()).unwrap())
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.status()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn oracle_accepts_only_valid_application_tokens() {
|
||||||
|
let verifier = cached().await;
|
||||||
|
let app = verifier_routes(Some(verifier));
|
||||||
|
assert_eq!(
|
||||||
|
status(app.clone(), Some(&token("one", &claims()))).await,
|
||||||
|
StatusCode::NO_CONTENT
|
||||||
|
);
|
||||||
|
assert_eq!(status(app.clone(), None).await, StatusCode::UNAUTHORIZED);
|
||||||
|
assert_eq!(
|
||||||
|
status(app.clone(), Some("wakey-invalid-test")).await,
|
||||||
|
StatusCode::UNAUTHORIZED
|
||||||
|
);
|
||||||
|
for (field, value) in [
|
||||||
|
("iss", json!("https://other.example")),
|
||||||
|
("aud", json!(["bypass-app"])),
|
||||||
|
("exp", json!(1)),
|
||||||
|
("nbf", json!(4102444800u64)),
|
||||||
|
] {
|
||||||
|
let mut invalid = claims();
|
||||||
|
invalid[field] = value;
|
||||||
|
assert_eq!(
|
||||||
|
status(app.clone(), Some(&token("one", &invalid))).await,
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
"{field}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for field in ["iss", "aud", "exp"] {
|
||||||
|
let mut invalid = claims();
|
||||||
|
invalid.as_object_mut().unwrap().remove(field);
|
||||||
|
assert_eq!(
|
||||||
|
status(app.clone(), Some(&token("one", &invalid))).await,
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
"missing {field}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let mut forged = token("one", &claims()).into_bytes();
|
||||||
|
let signature_start = forged.iter().rposition(|byte| *byte == b'.').unwrap() + 1;
|
||||||
|
forged[signature_start] = if forged[signature_start] == b'A' {
|
||||||
|
b'B'
|
||||||
|
} else {
|
||||||
|
b'A'
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
status(app.clone(), Some(std::str::from_utf8(&forged).unwrap())).await,
|
||||||
|
StatusCode::UNAUTHORIZED
|
||||||
|
);
|
||||||
|
let mut header = Header::new(Algorithm::HS256);
|
||||||
|
header.kid = Some("one".into());
|
||||||
|
let hmac = encode(
|
||||||
|
&header,
|
||||||
|
&claims(),
|
||||||
|
&EncodingKey::from_secret(b"not-an-rsa-key"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
status(app.clone(), Some(&hmac)).await,
|
||||||
|
StatusCode::UNAUTHORIZED
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
status(app.clone(), Some(&token("unknown", &claims()))).await,
|
||||||
|
StatusCode::UNAUTHORIZED
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
status(app.clone(), Some(&"a".repeat(MAX_TOKEN_BYTES + 1))).await,
|
||||||
|
StatusCode::UNAUTHORIZED
|
||||||
|
);
|
||||||
|
let mut request = Request::builder()
|
||||||
|
.uri("/internal/auth/verify")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
let valid = token("one", &claims());
|
||||||
|
request.headers_mut().append(
|
||||||
|
"cf-access-jwt-assertion",
|
||||||
|
HeaderValue::from_str(&valid).unwrap(),
|
||||||
|
);
|
||||||
|
request
|
||||||
|
.headers_mut()
|
||||||
|
.append("cf-access-jwt-assertion", HeaderValue::from_static("fake"));
|
||||||
|
assert_eq!(
|
||||||
|
app.oneshot(request).await.unwrap().status(),
|
||||||
|
StatusCode::UNAUTHORIZED
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
status(verifier_routes(None), Some(&valid)).await,
|
||||||
|
StatusCode::UNAUTHORIZED
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn trusted_configuration_rejects_unsafe_urls_and_non_loopback_bind() {
|
||||||
|
assert_eq!(
|
||||||
|
Verifier::new(&config()).unwrap().jwks_url.as_str(),
|
||||||
|
"https://access.example/cdn-cgi/access/certs"
|
||||||
|
);
|
||||||
|
let mut explicit = config();
|
||||||
|
explicit.jwks_url = Some("https://keys.example/custom".into());
|
||||||
|
assert_eq!(
|
||||||
|
Verifier::new(&explicit).unwrap().jwks_url.as_str(),
|
||||||
|
"https://keys.example/custom"
|
||||||
|
);
|
||||||
|
for url in [
|
||||||
|
"http://access.example",
|
||||||
|
"https://user:[email protected]",
|
||||||
|
"https://access.example?q=1",
|
||||||
|
"https://access.example/#fragment",
|
||||||
|
"not a url",
|
||||||
|
] {
|
||||||
|
assert!(trusted_url(url).is_err(), "{url}");
|
||||||
|
}
|
||||||
|
let mut invalid = config();
|
||||||
|
invalid.audience.clear();
|
||||||
|
assert!(Verifier::new(&invalid).is_err());
|
||||||
|
invalid = config();
|
||||||
|
invalid.issuer.push_str("/wrong-path");
|
||||||
|
assert!(Verifier::new(&invalid).is_err());
|
||||||
|
assert!(routes(Some(&config()), "0.0.0.0:8080".parse().unwrap()).is_err());
|
||||||
|
assert!(routes(Some(&config()), "[::1]:8080".parse().unwrap()).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn signing_key_set_rejects_duplicates_and_unusable_keys() {
|
||||||
|
let key = jwks("one")["keys"][0].clone();
|
||||||
|
assert!(parse_keys(br#"{"keys": []}"#).unwrap().is_empty());
|
||||||
|
for set in [
|
||||||
|
json!({"keys": [key.clone(), key.clone()]}),
|
||||||
|
json!({"keys": vec![key.clone(); MAX_KEYS + 1]}),
|
||||||
|
] {
|
||||||
|
assert!(parse_keys(&serde_json::to_vec(&set).unwrap()).is_err());
|
||||||
|
}
|
||||||
|
for (field, value) in [
|
||||||
|
("use", json!("enc")),
|
||||||
|
("alg", json!("RS512")),
|
||||||
|
("key_ops", json!(["encrypt"])),
|
||||||
|
] {
|
||||||
|
let mut invalid = key.clone();
|
||||||
|
invalid[field] = value;
|
||||||
|
assert!(
|
||||||
|
parse_keys(&serde_json::to_vec(&json!({"keys": [invalid]})).unwrap())
|
||||||
|
.unwrap()
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn key_fetch_is_single_flight_rotates_and_fails_closed() {
|
||||||
|
let hits = Arc::new(AtomicUsize::new(0));
|
||||||
|
let reply = Arc::new(RwLock::new((
|
||||||
|
StatusCode::OK,
|
||||||
|
serde_json::to_string(&jwks("one")).unwrap(),
|
||||||
|
)));
|
||||||
|
let app = Router::new().route(
|
||||||
|
"/keys",
|
||||||
|
get({
|
||||||
|
let hits = hits.clone();
|
||||||
|
let reply = reply.clone();
|
||||||
|
move || {
|
||||||
|
let hits = hits.clone();
|
||||||
|
let reply = reply.clone();
|
||||||
|
async move {
|
||||||
|
hits.fetch_add(1, Ordering::Relaxed);
|
||||||
|
let (status, body) = reply.read().await.clone();
|
||||||
|
(
|
||||||
|
status,
|
||||||
|
[("cache-control", "public, max-age=14400, must-revalidate")],
|
||||||
|
body,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let address = listener.local_addr().unwrap();
|
||||||
|
let server = tokio::spawn(async move {
|
||||||
|
axum::serve(listener, app).await.unwrap();
|
||||||
|
});
|
||||||
|
let mut verifier = Verifier::new(&config()).unwrap();
|
||||||
|
// HTTP is allowed only in this in-process fixture, never in loaded config.
|
||||||
|
verifier.jwks_url = Url::parse(&format!("http://{address}/keys")).unwrap();
|
||||||
|
let verifier = Arc::new(verifier);
|
||||||
|
let valid = token("one", &claims());
|
||||||
|
let (first, second) = tokio::join!(verifier.validate(&valid), verifier.validate(&valid));
|
||||||
|
assert!(first.is_ok() && second.is_ok());
|
||||||
|
assert_eq!(hits.load(Ordering::Relaxed), 1);
|
||||||
|
for kid in ["random-1", "random-2"] {
|
||||||
|
assert_eq!(
|
||||||
|
verifier.validate(&token(kid, &claims())).await,
|
||||||
|
Err(VerifyError::Invalid)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(hits.load(Ordering::Relaxed), 1);
|
||||||
|
*reply.write().await = (StatusCode::OK, serde_json::to_string(&jwks("two")).unwrap());
|
||||||
|
verifier.cache.lock().await.attempted_at = Some(Instant::now() - REFRESH_COOLDOWN);
|
||||||
|
assert!(verifier.validate(&token("two", &claims())).await.is_ok());
|
||||||
|
assert_eq!(verifier.validate(&valid).await, Err(VerifyError::Invalid));
|
||||||
|
assert_eq!(hits.load(Ordering::Relaxed), 2);
|
||||||
|
// A failed refresh cannot authorize unknown keys or expired cached keys.
|
||||||
|
*reply.write().await = (StatusCode::SERVICE_UNAVAILABLE, "unavailable".into());
|
||||||
|
verifier.cache.lock().await.attempted_at = Some(Instant::now() - REFRESH_COOLDOWN);
|
||||||
|
assert_eq!(
|
||||||
|
verifier.validate(&valid).await,
|
||||||
|
Err(VerifyError::Unavailable)
|
||||||
|
);
|
||||||
|
assert!(verifier.validate(&token("two", &claims())).await.is_ok());
|
||||||
|
verifier.cache.lock().await.policy =
|
||||||
|
Some(test_policy(&verifier, "max-age=0, must-revalidate", None));
|
||||||
|
assert_eq!(
|
||||||
|
verifier.validate(&token("two", &claims())).await,
|
||||||
|
Err(VerifyError::Unavailable)
|
||||||
|
);
|
||||||
|
assert_eq!(hits.load(Ordering::Relaxed), 3);
|
||||||
|
// Oversized and malformed responses also fail closed, with bounded retries.
|
||||||
|
for body in ["x".repeat(MAX_JWKS_BYTES + 1), "not json".into()] {
|
||||||
|
*reply.write().await = (StatusCode::OK, body);
|
||||||
|
verifier.cache.lock().await.attempted_at = Some(Instant::now() - REFRESH_COOLDOWN);
|
||||||
|
assert_eq!(
|
||||||
|
verifier.validate(&valid).await,
|
||||||
|
Err(VerifyError::Unavailable)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
status(verifier_routes(Some(verifier.clone())), Some(&valid)).await,
|
||||||
|
StatusCode::SERVICE_UNAVAILABLE
|
||||||
|
);
|
||||||
|
// A successful empty set must revoke previously cached keys immediately.
|
||||||
|
*reply.write().await = (StatusCode::OK, r#"{"keys": []}"#.into());
|
||||||
|
verifier.cache.lock().await.attempted_at = Some(Instant::now() - REFRESH_COOLDOWN);
|
||||||
|
assert_eq!(
|
||||||
|
verifier.validate(&token("two", &claims())).await,
|
||||||
|
Err(VerifyError::Invalid)
|
||||||
|
);
|
||||||
|
assert!(verifier.cache.lock().await.keys.is_empty());
|
||||||
|
server.abort();
|
||||||
|
let _ = server.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
struct KeyOrigin {
|
||||||
|
verifier: Arc<Verifier>,
|
||||||
|
reply: Arc<RwLock<(StatusCode, HeaderMap, String)>>,
|
||||||
|
requests: Arc<Mutex<Vec<HeaderMap>>>,
|
||||||
|
server: tokio::task::JoinHandle<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl KeyOrigin {
|
||||||
|
async fn new(headers: &[(&str, &str)]) -> Self {
|
||||||
|
let reply = Arc::new(RwLock::new((
|
||||||
|
StatusCode::OK,
|
||||||
|
HeaderMap::new(),
|
||||||
|
String::new(),
|
||||||
|
)));
|
||||||
|
let requests = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let app = Router::new().route(
|
||||||
|
"/keys",
|
||||||
|
get({
|
||||||
|
let reply = reply.clone();
|
||||||
|
let requests = requests.clone();
|
||||||
|
move |headers: HeaderMap| {
|
||||||
|
let reply = reply.clone();
|
||||||
|
let requests = requests.clone();
|
||||||
|
async move {
|
||||||
|
requests.lock().await.push(headers);
|
||||||
|
reply.read().await.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let address = listener.local_addr().unwrap();
|
||||||
|
let server = tokio::spawn(async move {
|
||||||
|
axum::serve(listener, app).await.unwrap();
|
||||||
|
});
|
||||||
|
let mut verifier = Verifier::new(&config()).unwrap();
|
||||||
|
verifier.jwks_url = Url::parse(&format!("http://{address}/keys")).unwrap();
|
||||||
|
let origin = Self {
|
||||||
|
verifier: Arc::new(verifier),
|
||||||
|
reply,
|
||||||
|
requests,
|
||||||
|
server,
|
||||||
|
};
|
||||||
|
origin
|
||||||
|
.respond(
|
||||||
|
StatusCode::OK,
|
||||||
|
serde_json::to_string(&jwks("one")).unwrap(),
|
||||||
|
headers,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
origin
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn respond(&self, status: StatusCode, body: String, headers: &[(&str, &str)]) {
|
||||||
|
let mut response = axum::http::Response::builder().status(status);
|
||||||
|
for (name, value) in headers {
|
||||||
|
response = response.header(*name, *value);
|
||||||
|
}
|
||||||
|
let (parts, body) = response.body(body).unwrap().into_parts();
|
||||||
|
*self.reply.write().await = (status, parts.headers, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn allow_refresh(&self) {
|
||||||
|
self.verifier.cache.lock().await.attempted_at = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn stop(self) {
|
||||||
|
self.server.abort();
|
||||||
|
let _ = self.server.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn http_cache_reuses_fresh_keys_revalidates_etags_and_forces_rotation() {
|
||||||
|
let origin = KeyOrigin::new(&[
|
||||||
|
(
|
||||||
|
"cache-control",
|
||||||
|
"public, max-age=14400, must-revalidate, no-transform",
|
||||||
|
),
|
||||||
|
("etag", "\"v1\""),
|
||||||
|
])
|
||||||
|
.await;
|
||||||
|
let one = token("one", &claims());
|
||||||
|
for _ in 0..2 {
|
||||||
|
assert!(origin.verifier.validate(&one).await.is_ok());
|
||||||
|
}
|
||||||
|
assert_eq!(origin.requests.lock().await.len(), 1);
|
||||||
|
// Expire the policy directly; no wall-clock sleeps or delayed retries.
|
||||||
|
origin.verifier.cache.lock().await.policy = Some(test_policy(
|
||||||
|
&origin.verifier,
|
||||||
|
"max-age=0, must-revalidate",
|
||||||
|
Some("\"v1\""),
|
||||||
|
));
|
||||||
|
origin.allow_refresh().await;
|
||||||
|
origin
|
||||||
|
.respond(
|
||||||
|
StatusCode::NOT_MODIFIED,
|
||||||
|
String::new(),
|
||||||
|
&[
|
||||||
|
("etag", "\"v1\""),
|
||||||
|
("cache-control", "public, max-age=14400, must-revalidate"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
for _ in 0..2 {
|
||||||
|
assert!(origin.verifier.validate(&one).await.is_ok());
|
||||||
|
}
|
||||||
|
assert_eq!(origin.requests.lock().await.len(), 2);
|
||||||
|
assert_eq!(origin.requests.lock().await[1]["if-none-match"], "\"v1\"");
|
||||||
|
// An unknown kid forces revalidation even though the HTTP entry is fresh.
|
||||||
|
origin.allow_refresh().await;
|
||||||
|
origin
|
||||||
|
.respond(
|
||||||
|
StatusCode::OK,
|
||||||
|
serde_json::to_string(&jwks("two")).unwrap(),
|
||||||
|
&[
|
||||||
|
("etag", "\"v2\""),
|
||||||
|
("cache-control", "public, max-age=14400, must-revalidate"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(
|
||||||
|
origin
|
||||||
|
.verifier
|
||||||
|
.validate(&token("two", &claims()))
|
||||||
|
.await
|
||||||
|
.is_ok()
|
||||||
|
);
|
||||||
|
assert_eq!(origin.requests.lock().await.len(), 3);
|
||||||
|
assert_eq!(origin.requests.lock().await[2]["cache-control"], "no-cache");
|
||||||
|
assert_eq!(origin.requests.lock().await[2]["if-none-match"], "\"v1\"");
|
||||||
|
assert_eq!(
|
||||||
|
origin.verifier.validate(&one).await,
|
||||||
|
Err(VerifyError::Invalid)
|
||||||
|
);
|
||||||
|
// A mismatched 304 must not renew trust in the stored keys.
|
||||||
|
origin.verifier.cache.lock().await.policy = Some(test_policy(
|
||||||
|
&origin.verifier,
|
||||||
|
"max-age=0, must-revalidate",
|
||||||
|
Some("\"v2\""),
|
||||||
|
));
|
||||||
|
origin.allow_refresh().await;
|
||||||
|
origin
|
||||||
|
.respond(
|
||||||
|
StatusCode::NOT_MODIFIED,
|
||||||
|
String::new(),
|
||||||
|
&[("etag", "\"wrong\""), ("cache-control", "max-age=14400")],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
origin.verifier.validate(&token("two", &claims())).await,
|
||||||
|
Err(VerifyError::Unavailable)
|
||||||
|
);
|
||||||
|
origin.stop().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn http_cache_honors_age_no_cache_and_no_store() {
|
||||||
|
for (cache_control, age, stores) in [
|
||||||
|
("no-store", "0", false),
|
||||||
|
("no-cache", "0", true),
|
||||||
|
("public, max-age=14400, must-revalidate", "14401", true),
|
||||||
|
("", "0", true),
|
||||||
|
] {
|
||||||
|
let origin = KeyOrigin::new(&[("cache-control", cache_control), ("age", age)]).await;
|
||||||
|
let one = token("one", &claims());
|
||||||
|
assert!(origin.verifier.validate(&one).await.is_ok());
|
||||||
|
assert_eq!(origin.verifier.cache.lock().await.policy.is_some(), stores);
|
||||||
|
if !stores {
|
||||||
|
assert!(origin.verifier.cache.lock().await.keys.is_empty());
|
||||||
|
}
|
||||||
|
// A successful origin response can serve the requesting caller, but
|
||||||
|
// an HTTP-stale/non-storable response cannot authorize later requests.
|
||||||
|
assert_eq!(
|
||||||
|
origin.verifier.validate(&one).await,
|
||||||
|
Err(VerifyError::Unavailable)
|
||||||
|
);
|
||||||
|
assert_eq!(origin.requests.lock().await.len(), 1);
|
||||||
|
origin.allow_refresh().await;
|
||||||
|
assert!(origin.verifier.validate(&one).await.is_ok());
|
||||||
|
assert_eq!(origin.requests.lock().await.len(), 2);
|
||||||
|
origin.stop().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn http_cache_revalidates_last_modified_and_rejects_unsolicited_304() {
|
||||||
|
let modified = "Wed, 01 Jan 2025 00:00:00 GMT";
|
||||||
|
let origin = KeyOrigin::new(&[
|
||||||
|
("cache-control", "max-age=0, must-revalidate"),
|
||||||
|
("last-modified", modified),
|
||||||
|
])
|
||||||
|
.await;
|
||||||
|
let one = token("one", &claims());
|
||||||
|
assert!(origin.verifier.validate(&one).await.is_ok());
|
||||||
|
origin.allow_refresh().await;
|
||||||
|
origin
|
||||||
|
.respond(
|
||||||
|
StatusCode::NOT_MODIFIED,
|
||||||
|
String::new(),
|
||||||
|
&[
|
||||||
|
("last-modified", modified),
|
||||||
|
("cache-control", "max-age=14400"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(origin.verifier.validate(&one).await.is_ok());
|
||||||
|
assert_eq!(
|
||||||
|
origin.requests.lock().await[1]["if-modified-since"],
|
||||||
|
modified
|
||||||
|
);
|
||||||
|
origin.stop().await;
|
||||||
|
|
||||||
|
let origin = KeyOrigin::new(&[]).await;
|
||||||
|
origin
|
||||||
|
.respond(StatusCode::NOT_MODIFIED, String::new(), &[])
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
origin.verifier.validate(&one).await,
|
||||||
|
Err(VerifyError::Unavailable)
|
||||||
|
);
|
||||||
|
assert!(origin.verifier.cache.lock().await.policy.is_none());
|
||||||
|
origin.stop().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn verifier_routes_do_not_gate_agent_or_control_handlers() {
|
||||||
|
let dir = std::env::temp_dir().join(format!("wakey-access-routes-{}", uuid::Uuid::new_v4()));
|
||||||
|
let store = crate::state::Store::load_or_init(
|
||||||
|
&dir.join("state.sqlite3"),
|
||||||
|
vec![],
|
||||||
|
Duration::from_secs(60),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let state = AppState {
|
||||||
|
store: Arc::new(store),
|
||||||
|
sessions: Arc::new(RwLock::new(HashMap::new())),
|
||||||
|
pending: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
public_url: "http://localhost".into(),
|
||||||
|
command_timeout: Duration::from_secs(30),
|
||||||
|
enroll_token_ttl: Duration::from_secs(60),
|
||||||
|
terminals: super::super::terminals::TerminalRegistry::new(),
|
||||||
|
alerts: crate::api::AlertsHub::new(),
|
||||||
|
fleet: Arc::new(crate::api::FleetCache::default()),
|
||||||
|
};
|
||||||
|
let app = Router::new()
|
||||||
|
.merge(routes(Some(&config()), "127.0.0.1:8080".parse().unwrap()).unwrap())
|
||||||
|
.merge(super::super::public_api_routes(dir.clone()))
|
||||||
|
.merge(super::super::control_api_routes())
|
||||||
|
.with_state(state);
|
||||||
|
for (path, expected) in [
|
||||||
|
("/healthz", StatusCode::OK),
|
||||||
|
("/api/v1/control/agents", StatusCode::OK),
|
||||||
|
("/internal/auth/verify", StatusCode::UNAUTHORIZED),
|
||||||
|
] {
|
||||||
|
let response = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(response.status(), expected, "{path}");
|
||||||
|
}
|
||||||
|
drop(app);
|
||||||
|
std::fs::remove_dir_all(dir).unwrap();
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use wakey_core::telemetry::FleetMetrics;
|
||||||
|
|
||||||
|
use super::AppState;
|
||||||
|
|
||||||
|
/// Tie polling to the daemon lifetime, including early server errors.
|
||||||
|
pub(super) struct FleetMetricsTask(tokio::task::JoinHandle<()>);
|
||||||
|
|
||||||
|
impl FleetMetricsTask {
|
||||||
|
pub(super) fn start(state: AppState) -> Option<Self> {
|
||||||
|
let metrics = FleetMetrics::new()?;
|
||||||
|
Some(Self(tokio::spawn(async move {
|
||||||
|
let mut tick = tokio::time::interval(Duration::from_secs(30));
|
||||||
|
tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||||
|
loop {
|
||||||
|
tick.tick().await;
|
||||||
|
match crate::api::sample_fleet_counts(&state).await {
|
||||||
|
Ok(sample) => metrics.replace(Some(sample)),
|
||||||
|
Err(error) => {
|
||||||
|
metrics.replace(None);
|
||||||
|
tracing::warn!(%error, "failed to refresh fleet metrics");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for FleetMetricsTask {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.0.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,9 +23,12 @@ use crate::config;
|
|||||||
use crate::state;
|
use crate::state;
|
||||||
use crate::ws;
|
use crate::ws;
|
||||||
|
|
||||||
|
mod access;
|
||||||
mod admin;
|
mod admin;
|
||||||
|
mod fleet_metrics;
|
||||||
mod process;
|
mod process;
|
||||||
pub mod terminals;
|
pub mod terminals;
|
||||||
|
pub(crate) mod view_cache;
|
||||||
pub use admin::revoke_agent;
|
pub use admin::revoke_agent;
|
||||||
pub use admin::{
|
pub use admin::{
|
||||||
issue_enroll_token, list_enroll_tokens, migrate_sqlite_state, revoke_enroll_token, state_stats,
|
issue_enroll_token, list_enroll_tokens, migrate_sqlite_state, revoke_enroll_token, state_stats,
|
||||||
@@ -44,6 +47,7 @@ pub struct AppState {
|
|||||||
pub enroll_token_ttl: Duration,
|
pub enroll_token_ttl: Duration,
|
||||||
pub terminals: terminals::TerminalRegistry,
|
pub terminals: terminals::TerminalRegistry,
|
||||||
pub alerts: api::AlertsHub,
|
pub alerts: api::AlertsHub,
|
||||||
|
pub(crate) fleet: Arc<api::FleetCache>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -106,6 +110,10 @@ fn control_api_routes() -> Router<AppState> {
|
|||||||
"/api/v1/control/fleet/devices/refresh",
|
"/api/v1/control/fleet/devices/refresh",
|
||||||
post(api::refresh_fleet_devices),
|
post(api::refresh_fleet_devices),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/v1/control/fleet/devices/{device_key}",
|
||||||
|
get(api::get_fleet_device),
|
||||||
|
)
|
||||||
.route("/api/v1/control/fleet/wake", post(api::wake_fleet_device))
|
.route("/api/v1/control/fleet/wake", post(api::wake_fleet_device))
|
||||||
.route(
|
.route(
|
||||||
"/api/v1/control/devices",
|
"/api/v1/control/devices",
|
||||||
@@ -113,7 +121,9 @@ fn control_api_routes() -> Router<AppState> {
|
|||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/v1/control/devices/{device_id}",
|
"/api/v1/control/devices/{device_id}",
|
||||||
get(api::get_known_device).delete(api::forget_known_device),
|
get(api::get_known_device)
|
||||||
|
.patch(api::rename_known_device)
|
||||||
|
.delete(api::forget_known_device),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/v1/control/devices/{device_id}/merge",
|
"/api/v1/control/devices/{device_id}/merge",
|
||||||
@@ -168,6 +178,9 @@ fn control_api_routes() -> Router<AppState> {
|
|||||||
|
|
||||||
/// Starts the control-plane HTTP and websocket surfaces and manages daemon lifecycle hooks.
|
/// Starts the control-plane HTTP and websocket surfaces and manages daemon lifecycle hooks.
|
||||||
pub async fn serve(daemon: config::DaemonConfig) -> Result<()> {
|
pub async fn serve(daemon: config::DaemonConfig) -> Result<()> {
|
||||||
|
// The verifier delegates enforcement to Caddy. Do not expose the other
|
||||||
|
// handlers directly when that boundary is configured.
|
||||||
|
let access_routes = access::routes(daemon.access.as_ref(), daemon.bind)?;
|
||||||
write_pid_file(&daemon.pid_file)?;
|
write_pid_file(&daemon.pid_file)?;
|
||||||
info!(pid_file = %daemon.pid_file.display(), "wrote control-plane pid file");
|
info!(pid_file = %daemon.pid_file.display(), "wrote control-plane pid file");
|
||||||
|
|
||||||
@@ -188,14 +201,17 @@ pub async fn serve(daemon: config::DaemonConfig) -> Result<()> {
|
|||||||
enroll_token_ttl: daemon.enroll_token_ttl,
|
enroll_token_ttl: daemon.enroll_token_ttl,
|
||||||
terminals: terminals::TerminalRegistry::new(),
|
terminals: terminals::TerminalRegistry::new(),
|
||||||
alerts: api::AlertsHub::new(),
|
alerts: api::AlertsHub::new(),
|
||||||
|
fleet: Arc::new(api::FleetCache::default()),
|
||||||
};
|
};
|
||||||
|
|
||||||
tokio::spawn(api::run_alert_evaluator(app_state.clone()));
|
tokio::spawn(api::run_alert_evaluator(app_state.clone()));
|
||||||
|
let _fleet_metrics = fleet_metrics::FleetMetricsTask::start(app_state.clone());
|
||||||
|
|
||||||
// Keep route classes explicit so edge policy can map directly:
|
// Keep route classes explicit so edge policy can map directly:
|
||||||
// - public_api_routes: intended internet-facing agent endpoints
|
// - public_api_routes: intended internet-facing agent endpoints
|
||||||
// - control_api_routes: intended admin-only endpoints behind Access
|
// - control_api_routes: intended admin-only endpoints behind Access
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
|
.merge(access_routes)
|
||||||
.merge(public_api_routes(daemon.ui_dist_dir.clone()))
|
.merge(public_api_routes(daemon.ui_dist_dir.clone()))
|
||||||
.merge(control_api_routes())
|
.merge(control_api_routes())
|
||||||
.with_state(app_state.clone());
|
.with_state(app_state.clone());
|
||||||
@@ -213,6 +229,8 @@ pub async fn serve(daemon: config::DaemonConfig) -> Result<()> {
|
|||||||
use tokio::signal::unix::{SignalKind, signal};
|
use tokio::signal::unix::{SignalKind, signal};
|
||||||
let mut server = server;
|
let mut server = server;
|
||||||
let mut hup = signal(SignalKind::hangup()).context("failed to install SIGHUP handler")?;
|
let mut hup = signal(SignalKind::hangup()).context("failed to install SIGHUP handler")?;
|
||||||
|
let mut terminate =
|
||||||
|
signal(SignalKind::terminate()).context("failed to install SIGTERM handler")?;
|
||||||
let mut gc_tick = tokio::time::interval(Duration::from_secs(300));
|
let mut gc_tick = tokio::time::interval(Duration::from_secs(300));
|
||||||
gc_tick.set_missed_tick_behavior(MissedTickBehavior::Skip);
|
gc_tick.set_missed_tick_behavior(MissedTickBehavior::Skip);
|
||||||
|
|
||||||
@@ -224,6 +242,11 @@ pub async fn serve(daemon: config::DaemonConfig) -> Result<()> {
|
|||||||
server.abort();
|
server.abort();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
_ = terminate.recv() => {
|
||||||
|
info!("SIGTERM received; shutting down control-plane");
|
||||||
|
server.abort();
|
||||||
|
break;
|
||||||
|
}
|
||||||
_ = hup.recv() => {
|
_ = hup.recv() => {
|
||||||
match app_state.store.reload_from_disk().await {
|
match app_state.store.reload_from_disk().await {
|
||||||
Ok(()) => info!("reloaded state from disk"),
|
Ok(()) => info!("reloaded state from disk"),
|
||||||
@@ -239,7 +262,7 @@ pub async fn serve(daemon: config::DaemonConfig) -> Result<()> {
|
|||||||
}
|
}
|
||||||
Err(err) => warn!(error = %err, "periodic gc failed"),
|
Err(err) => warn!(error = %err, "periodic gc failed"),
|
||||||
}
|
}
|
||||||
match app_state.store.gc_retention(daemon.observation_retention).await {
|
match app_state.store.gc_retention(daemon.history_retention).await {
|
||||||
Ok(stats) => {
|
Ok(stats) => {
|
||||||
if stats.audit_events_removed > 0 || stats.alert_transitions_removed > 0 {
|
if stats.audit_events_removed > 0 || stats.alert_transitions_removed > 0 {
|
||||||
info!(
|
info!(
|
||||||
@@ -251,6 +274,11 @@ pub async fn serve(daemon: config::DaemonConfig) -> Result<()> {
|
|||||||
}
|
}
|
||||||
Err(err) => warn!(error = %err, "periodic retention gc failed"),
|
Err(err) => warn!(error = %err, "periodic retention gc failed"),
|
||||||
}
|
}
|
||||||
|
match app_state.store.prune_devices(daemon.device_retention).await {
|
||||||
|
Ok(removed) if removed > 0 => info!(removed, "periodic gc pruned expired agent devices"),
|
||||||
|
Ok(_) => {},
|
||||||
|
Err(err) => warn!(error = %err, "periodic device retention gc failed"),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
join = &mut server => {
|
join = &mut server => {
|
||||||
let _ = remove_pid_file(&daemon.pid_file);
|
let _ = remove_pid_file(&daemon.pid_file);
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
//! Lazy derived views keyed by their inputs, with one rebuild at a time.
|
||||||
|
|
||||||
|
use std::future::Future;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
const MAX_REBUILDS: usize = 3;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub(crate) struct RebuildLimit;
|
||||||
|
|
||||||
|
impl std::fmt::Display for RebuildLimit {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.write_str("view inputs kept changing during rebuilding; retry the request")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for RebuildLimit {}
|
||||||
|
|
||||||
|
pub(crate) struct ViewCache<K, V> {
|
||||||
|
entry: Mutex<Option<(K, Arc<V>)>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<K, V> Default for ViewCache<K, V> {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
entry: Mutex::new(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<K: Eq, V> ViewCache<K, V> {
|
||||||
|
/// Reuse a matching view or rebuild it. `key` must reflect every input to
|
||||||
|
/// `build`; rechecking it prevents an in-flight old build being published.
|
||||||
|
/// Errors are not cached, and cancellation releases the rebuild lock.
|
||||||
|
/// Three invalidated builds return `RebuildLimit` rather than starving readers.
|
||||||
|
pub(crate) async fn get_or_try_init<KF, BF>(
|
||||||
|
&self,
|
||||||
|
key: impl Fn() -> KF,
|
||||||
|
build: impl Fn() -> BF,
|
||||||
|
) -> anyhow::Result<Arc<V>>
|
||||||
|
where
|
||||||
|
KF: Future<Output = K>,
|
||||||
|
BF: Future<Output = anyhow::Result<V>>,
|
||||||
|
{
|
||||||
|
// Keep publication and rebuilding under one lock. Waiting readers then
|
||||||
|
// reuse the completed view rather than launching duplicate DB work.
|
||||||
|
let mut entry = self.entry.lock().await;
|
||||||
|
for _ in 0..MAX_REBUILDS {
|
||||||
|
let before = key().await;
|
||||||
|
if let Some((stored_key, value)) = &*entry
|
||||||
|
&& *stored_key == before
|
||||||
|
{
|
||||||
|
return Ok(Arc::clone(value));
|
||||||
|
}
|
||||||
|
let value = build().await?;
|
||||||
|
if before == key().await {
|
||||||
|
let value = Arc::new(value);
|
||||||
|
*entry = Some((before, Arc::clone(&value)));
|
||||||
|
return Ok(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(RebuildLimit.into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use tokio::sync::Notify;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn concurrent_readers_share_a_build_and_changed_keys_rebuild() {
|
||||||
|
let cache = ViewCache::default();
|
||||||
|
let revision = AtomicU64::new(0);
|
||||||
|
let builds = AtomicU64::new(0);
|
||||||
|
let key = || async { revision.load(Ordering::SeqCst) };
|
||||||
|
let build = || async {
|
||||||
|
builds.fetch_add(1, Ordering::SeqCst);
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
Ok::<_, anyhow::Error>(42)
|
||||||
|
};
|
||||||
|
let (a, b) = tokio::join!(
|
||||||
|
cache.get_or_try_init(key, build),
|
||||||
|
cache.get_or_try_init(key, build)
|
||||||
|
);
|
||||||
|
assert!(Arc::ptr_eq(&a.unwrap(), &b.unwrap()));
|
||||||
|
assert_eq!(builds.load(Ordering::SeqCst), 1);
|
||||||
|
revision.fetch_add(1, Ordering::SeqCst);
|
||||||
|
cache.get_or_try_init(key, build).await.unwrap();
|
||||||
|
assert_eq!(builds.load(Ordering::SeqCst), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn invalidation_during_a_build_discards_the_old_result() {
|
||||||
|
let cache = ViewCache::default();
|
||||||
|
let revision = AtomicU64::new(0);
|
||||||
|
let builds = AtomicU64::new(0);
|
||||||
|
let started = Notify::new();
|
||||||
|
let release = Notify::new();
|
||||||
|
let reader = cache.get_or_try_init(
|
||||||
|
|| async { revision.load(Ordering::SeqCst) },
|
||||||
|
|| async {
|
||||||
|
let captured = revision.load(Ordering::SeqCst);
|
||||||
|
if builds.fetch_add(1, Ordering::SeqCst) == 0 {
|
||||||
|
started.notify_one();
|
||||||
|
release.notified().await;
|
||||||
|
}
|
||||||
|
Ok::<_, anyhow::Error>(captured)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let writer = async {
|
||||||
|
started.notified().await;
|
||||||
|
revision.store(1, Ordering::SeqCst);
|
||||||
|
release.notify_one();
|
||||||
|
};
|
||||||
|
let (result, ()) = tokio::join!(reader, writer);
|
||||||
|
assert_eq!(*result.unwrap(), 1);
|
||||||
|
assert_eq!(builds.load(Ordering::SeqCst), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn failed_builds_are_retried() {
|
||||||
|
let cache = ViewCache::default();
|
||||||
|
assert!(
|
||||||
|
cache
|
||||||
|
.get_or_try_init(
|
||||||
|
|| async { 0 },
|
||||||
|
|| async { Err::<u8, _>(anyhow::anyhow!("db failed")) }
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
*cache
|
||||||
|
.get_or_try_init(|| async { 0 }, || async { Ok::<_, anyhow::Error>(7) })
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
7
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn cancelling_a_build_does_not_lock_out_readers() {
|
||||||
|
let cache = Arc::new(ViewCache::default());
|
||||||
|
let started = Arc::new(Notify::new());
|
||||||
|
let task = tokio::spawn({
|
||||||
|
let cache = Arc::clone(&cache);
|
||||||
|
let started = Arc::clone(&started);
|
||||||
|
async move {
|
||||||
|
cache
|
||||||
|
.get_or_try_init(
|
||||||
|
|| async { 0 },
|
||||||
|
|| async {
|
||||||
|
started.notify_one();
|
||||||
|
std::future::pending::<anyhow::Result<u8>>().await
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
});
|
||||||
|
started.notified().await;
|
||||||
|
task.abort();
|
||||||
|
assert!(task.await.unwrap_err().is_cancelled());
|
||||||
|
assert_eq!(
|
||||||
|
*cache
|
||||||
|
.get_or_try_init(|| async { 0 }, || async { Ok::<_, anyhow::Error>(7) })
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
7
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn sustained_changes_stop_at_the_limit_and_a_later_read_recovers() {
|
||||||
|
let cache = ViewCache::default();
|
||||||
|
let revision = AtomicU64::new(0);
|
||||||
|
let key = || async { revision.load(Ordering::SeqCst) };
|
||||||
|
cache
|
||||||
|
.get_or_try_init(key, || async { Ok(0_u8) })
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
revision.store(1, Ordering::SeqCst);
|
||||||
|
let builds = AtomicU64::new(0);
|
||||||
|
let error = cache
|
||||||
|
.get_or_try_init(key, || async {
|
||||||
|
builds.fetch_add(1, Ordering::SeqCst);
|
||||||
|
revision.fetch_add(1, Ordering::SeqCst);
|
||||||
|
Ok(1)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(error.is::<RebuildLimit>());
|
||||||
|
assert_eq!(builds.load(Ordering::SeqCst), MAX_REBUILDS as u64);
|
||||||
|
let recovered = cache
|
||||||
|
.get_or_try_init(key, || async { Ok(2) })
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
*recovered, 2,
|
||||||
|
"must not fall back to the previous cached value"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
|||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous};
|
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous};
|
||||||
use sqlx::{Row, Sqlite, SqlitePool, Transaction};
|
use sqlx::{Row, Sqlite, SqlitePool, Transaction};
|
||||||
use tracing::{info, warn};
|
use tracing::{Instrument, info, warn};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::state::types::{
|
use crate::state::types::{
|
||||||
@@ -16,14 +16,34 @@ use crate::state::types::{
|
|||||||
pub struct Store {
|
pub struct Store {
|
||||||
db_path: PathBuf,
|
db_path: PathBuf,
|
||||||
pool: SqlitePool,
|
pool: SqlitePool,
|
||||||
|
fleet_revision: std::sync::atomic::AtomicU64,
|
||||||
}
|
}
|
||||||
impl Store {
|
impl Store {
|
||||||
|
pub(crate) fn fleet_revision(&self) -> u64 {
|
||||||
|
self.fleet_revision
|
||||||
|
.load(std::sync::atomic::Ordering::Acquire)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Call after a successful fleet-relevant write, never before its commit.
|
||||||
|
fn touch_fleet(&self) {
|
||||||
|
self.fleet_revision
|
||||||
|
.fetch_add(1, std::sync::atomic::Ordering::Release);
|
||||||
|
}
|
||||||
|
|
||||||
/// Begins a new transaction with an IMMEDIATE write lock.
|
/// Begins a new transaction with an IMMEDIATE write lock.
|
||||||
///
|
///
|
||||||
/// Use this for all transactions that perform writes to avoid SQLite upgrade deadlocks.
|
/// Use this for all transactions that perform writes to avoid SQLite upgrade deadlocks.
|
||||||
|
/// Separate spans distinguish pool wait/setup from acquiring SQLite's writer
|
||||||
|
/// transaction. Keep SQLx ownership and rollback-on-drop behavior intact.
|
||||||
pub async fn begin_write(&self) -> Result<Transaction<'static, Sqlite>> {
|
pub async fn begin_write(&self) -> Result<Transaction<'static, Sqlite>> {
|
||||||
self.pool
|
let connection = self
|
||||||
.begin_with("BEGIN IMMEDIATE")
|
.pool
|
||||||
|
.acquire()
|
||||||
|
.instrument(tracing::info_span!("db_pool_acquire"))
|
||||||
|
.await
|
||||||
|
.context("failed to begin write transaction")?;
|
||||||
|
Transaction::begin(connection, Some("BEGIN IMMEDIATE".into()))
|
||||||
|
.instrument(tracing::info_span!("db_write_transaction_begin"))
|
||||||
.await
|
.await
|
||||||
.context("failed to begin write transaction")
|
.context("failed to begin write transaction")
|
||||||
}
|
}
|
||||||
@@ -355,6 +375,79 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rename_preserves_identity_and_forget_removes_identifiers_only() {
|
||||||
|
let ts = TestStore::new().await;
|
||||||
|
let store = ts.store();
|
||||||
|
let device = store
|
||||||
|
.create_known_device(KnownDeviceInput {
|
||||||
|
display_name: "lda".into(),
|
||||||
|
pinned: true,
|
||||||
|
notes: Some("keep notes".into()),
|
||||||
|
identifiers: vec![DeviceIdentifierInput {
|
||||||
|
kind: "mac".into(),
|
||||||
|
value: "aa:bb:cc:dd:ee:01".into(),
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
store
|
||||||
|
.rename_known_device(&device.device_id, " ")
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
let renamed = store
|
||||||
|
.rename_known_device(&device.device_id, " Windows PC ")
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(renamed.display_name, "Windows PC");
|
||||||
|
assert_eq!(renamed.device_id, device.device_id);
|
||||||
|
assert_eq!(renamed.identifiers.len(), 1);
|
||||||
|
assert_eq!(renamed.notes, device.notes);
|
||||||
|
assert!(renamed.pinned);
|
||||||
|
assert!(
|
||||||
|
store
|
||||||
|
.rename_known_device("missing", "name")
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
let observed = wakey_core::Device::from_parts(
|
||||||
|
vec![wakey_core::NeighborEntry {
|
||||||
|
mac: Some("aa:bb:cc:dd:ee:01".parse().unwrap()),
|
||||||
|
ip: "192.168.1.2".parse().unwrap(),
|
||||||
|
dev: None,
|
||||||
|
state: wakey_core::NeighborState::Reachable,
|
||||||
|
}],
|
||||||
|
Vec::new(),
|
||||||
|
);
|
||||||
|
store
|
||||||
|
.replace_agent_device_snapshot("agent-a", &[observed])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(store.forget_known_device(&device.device_id).await.unwrap());
|
||||||
|
assert!(
|
||||||
|
store
|
||||||
|
.get_known_device(&device.device_id)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
store
|
||||||
|
.lookup_known_device_by_identifier(DeviceIdentifierInput {
|
||||||
|
kind: "mac".into(),
|
||||||
|
value: "aa:bb:cc:dd:ee:01".into(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
assert_eq!(store.list_agent_device_rows().await.unwrap().len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn known_device_can_hold_multiple_manual_mac_identifiers() {
|
async fn known_device_can_hold_multiple_manual_mac_identifiers() {
|
||||||
let ts = TestStore::new().await;
|
let ts = TestStore::new().await;
|
||||||
|
|||||||
@@ -1,15 +1,52 @@
|
|||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
|
use tracing::Instrument;
|
||||||
use wakey_core::{Device, DeviceEndpoint, DeviceId, EndpointSource};
|
use wakey_core::{Device, DeviceEndpoint, DeviceId, EndpointSource};
|
||||||
|
|
||||||
use super::Store;
|
use super::Store;
|
||||||
use super::helpers::core::*;
|
use super::helpers::core::*;
|
||||||
use crate::state::types::*;
|
use crate::state::types::*;
|
||||||
|
|
||||||
|
mod read;
|
||||||
|
mod snapshot;
|
||||||
|
|
||||||
impl Store {
|
impl Store {
|
||||||
|
/// Expire agent-reported devices, preserving all operator-saved identities.
|
||||||
|
/// `last_seen_unix` on the parent is CC snapshot inclusion time, not proof
|
||||||
|
/// of online presence. Cascades remove child addresses, endpoints and facts.
|
||||||
|
#[tracing::instrument(name = "db_prune_devices", skip_all)]
|
||||||
|
pub async fn prune_devices(&self, retention: std::time::Duration) -> Result<u64> {
|
||||||
|
self.prune_devices_at(retention, now_unix()).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn prune_devices_at(&self, retention: std::time::Duration, now: u64) -> Result<u64> {
|
||||||
|
if retention.is_zero() {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
let cutoff = i64::try_from(now.saturating_sub(retention.as_secs()))
|
||||||
|
.context("device retention cutoff overflow")?;
|
||||||
|
let mut tx = self.begin_write().await?;
|
||||||
|
let removed = sqlx::query!(
|
||||||
|
"DELETE FROM agent_devices WHERE last_seen_unix < ?1",
|
||||||
|
cutoff
|
||||||
|
)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await
|
||||||
|
.context("failed pruning expired agent devices")?
|
||||||
|
.rows_affected();
|
||||||
|
tx.commit()
|
||||||
|
.await
|
||||||
|
.context("failed committing device retention cleanup")?;
|
||||||
|
if removed > 0 {
|
||||||
|
self.touch_fleet();
|
||||||
|
}
|
||||||
|
Ok(removed)
|
||||||
|
}
|
||||||
|
|
||||||
/// Replace the complete device snapshot for an agent.
|
/// Replace the complete device snapshot for an agent.
|
||||||
///
|
///
|
||||||
/// This is the only write path for agent device state. Every sync source
|
/// This is the only write path for agent device state. Every sync source
|
||||||
/// (WebSocket snapshot, fleet refresh, HTTP upload) must go through here.
|
/// (WebSocket snapshot, fleet refresh, HTTP upload) must go through here.
|
||||||
|
#[tracing::instrument(name = "db_replace_agent_snapshot", skip_all, fields(devices = devices.len(), accepted = tracing::field::Empty))]
|
||||||
pub async fn replace_agent_device_snapshot(
|
pub async fn replace_agent_device_snapshot(
|
||||||
&self,
|
&self,
|
||||||
agent_id: &str,
|
agent_id: &str,
|
||||||
@@ -17,448 +54,35 @@ impl Store {
|
|||||||
) -> Result<usize> {
|
) -> Result<usize> {
|
||||||
let mut tx = self.begin_write().await?;
|
let mut tx = self.begin_write().await?;
|
||||||
|
|
||||||
let mut incoming_keys = std::collections::HashSet::with_capacity(devices.len());
|
let accepted = snapshot::replace(&mut tx, agent_id, devices).await?;
|
||||||
let snapshot_time = now_unix();
|
|
||||||
let snapshot_time_i64 = i64::try_from(snapshot_time).context("snapshot time overflow")?;
|
|
||||||
|
|
||||||
let existing_keys: Vec<String> = sqlx::query_scalar!(
|
|
||||||
"SELECT device_key FROM agent_devices WHERE agent_id = ?1",
|
|
||||||
agent_id
|
|
||||||
)
|
|
||||||
.fetch_all(&mut *tx)
|
|
||||||
.await
|
|
||||||
.context("failed fetching existing keys")?;
|
|
||||||
|
|
||||||
for device in devices {
|
|
||||||
let Some(device_id) = &device.id else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let device_key = device_key_from_id(device_id);
|
|
||||||
incoming_keys.insert(device_key.clone());
|
|
||||||
|
|
||||||
let presence = presence_to_str(device.presence);
|
|
||||||
let display_name: Option<String> = None;
|
|
||||||
|
|
||||||
sqlx::query!(
|
|
||||||
"INSERT INTO agent_devices (agent_id, device_key, presence, display_name, first_seen_unix, last_seen_unix)
|
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
|
|
||||||
ON CONFLICT (agent_id, device_key) DO UPDATE SET
|
|
||||||
presence = excluded.presence,
|
|
||||||
display_name = excluded.display_name,
|
|
||||||
last_seen_unix = excluded.last_seen_unix",
|
|
||||||
agent_id,
|
|
||||||
device_key,
|
|
||||||
presence,
|
|
||||||
display_name,
|
|
||||||
snapshot_time_i64,
|
|
||||||
snapshot_time_i64
|
|
||||||
)
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await
|
|
||||||
.context("failed upserting agent device")?;
|
|
||||||
|
|
||||||
// Replace child rows.
|
|
||||||
sqlx::query!(
|
|
||||||
"DELETE FROM agent_device_macs WHERE agent_id = ?1 AND device_key = ?2",
|
|
||||||
agent_id,
|
|
||||||
device_key
|
|
||||||
)
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await
|
|
||||||
.context("failed deleting device macs")?;
|
|
||||||
|
|
||||||
if !device.macs.is_empty() {
|
|
||||||
let mut builder = sqlx::QueryBuilder::new(
|
|
||||||
"INSERT INTO agent_device_macs (agent_id, device_key, mac) ",
|
|
||||||
);
|
|
||||||
builder.push_values(&device.macs, |mut b, mac| {
|
|
||||||
b.push_bind(agent_id)
|
|
||||||
.push_bind(device_key.clone())
|
|
||||||
.push_bind(mac.to_string().to_ascii_lowercase());
|
|
||||||
});
|
|
||||||
builder
|
|
||||||
.build()
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await
|
|
||||||
.context("failed inserting device macs")?;
|
|
||||||
}
|
|
||||||
|
|
||||||
sqlx::query!(
|
|
||||||
"DELETE FROM agent_device_ips WHERE agent_id = ?1 AND device_key = ?2",
|
|
||||||
agent_id,
|
|
||||||
device_key
|
|
||||||
)
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await
|
|
||||||
.context("failed deleting device ips")?;
|
|
||||||
|
|
||||||
if !device.ips.is_empty() {
|
|
||||||
let mut builder = sqlx::QueryBuilder::new(
|
|
||||||
"INSERT INTO agent_device_ips (agent_id, device_key, ip) ",
|
|
||||||
);
|
|
||||||
builder.push_values(&device.ips, |mut b, ip| {
|
|
||||||
b.push_bind(agent_id)
|
|
||||||
.push_bind(device_key.clone())
|
|
||||||
.push_bind(ip.to_string());
|
|
||||||
});
|
|
||||||
builder
|
|
||||||
.build()
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await
|
|
||||||
.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,
|
|
||||||
device_key
|
|
||||||
)
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await
|
|
||||||
.context("failed deleting device hostnames")?;
|
|
||||||
|
|
||||||
if !device.names.is_empty() {
|
|
||||||
let mut builder = sqlx::QueryBuilder::new(
|
|
||||||
"INSERT INTO agent_device_hostnames (agent_id, device_key, hostname) ",
|
|
||||||
);
|
|
||||||
builder.push_values(&device.names, |mut b, hostname| {
|
|
||||||
b.push_bind(agent_id)
|
|
||||||
.push_bind(device_key.clone())
|
|
||||||
.push_bind(hostname);
|
|
||||||
});
|
|
||||||
builder
|
|
||||||
.build()
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await
|
|
||||||
.context("failed inserting device hostnames")?;
|
|
||||||
}
|
|
||||||
|
|
||||||
sqlx::query!(
|
|
||||||
"DELETE FROM agent_device_facts WHERE agent_id = ?1 AND device_key = ?2",
|
|
||||||
agent_id,
|
|
||||||
device_key
|
|
||||||
)
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await
|
|
||||||
.context("failed deleting device facts")?;
|
|
||||||
|
|
||||||
if !device.observations.is_empty() {
|
|
||||||
let mut facts_json = Vec::with_capacity(device.observations.len());
|
|
||||||
for obs in &device.observations {
|
|
||||||
facts_json.push(serde_json::to_string(obs).context("failed serializing fact")?);
|
|
||||||
}
|
|
||||||
let mut builder = sqlx::QueryBuilder::new(
|
|
||||||
"INSERT INTO agent_device_facts (agent_id, device_key, fact_json) ",
|
|
||||||
);
|
|
||||||
builder.push_values(facts_json, |mut b, fact| {
|
|
||||||
b.push_bind(agent_id)
|
|
||||||
.push_bind(device_key.clone())
|
|
||||||
.push_bind(fact);
|
|
||||||
});
|
|
||||||
builder
|
|
||||||
.build()
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await
|
|
||||||
.context("failed inserting device facts")?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for old_key in existing_keys {
|
|
||||||
if !incoming_keys.contains(&old_key) {
|
|
||||||
sqlx::query!(
|
|
||||||
"DELETE FROM agent_devices WHERE agent_id = ?1 AND device_key = ?2",
|
|
||||||
agent_id,
|
|
||||||
old_key
|
|
||||||
)
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await
|
|
||||||
.context("failed pruning old agent device")?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tx.commit()
|
tx.commit()
|
||||||
|
.instrument(tracing::info_span!("db_snapshot_commit"))
|
||||||
.await
|
.await
|
||||||
.context("failed committing device snapshot")?;
|
.context("failed committing device snapshot")?;
|
||||||
|
self.touch_fleet();
|
||||||
|
|
||||||
Ok(incoming_keys.len())
|
tracing::Span::current().record("accepted", accepted);
|
||||||
|
Ok(accepted)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List all agent device rows with their child MAC/IP/hostname/fact rows.
|
/// List all agent devices and their child rows from one consistent read view.
|
||||||
|
#[tracing::instrument(name = "db_list_fleet_devices", skip_all)]
|
||||||
pub async fn list_agent_device_rows(&self) -> Result<Vec<AgentDeviceWithChildren>> {
|
pub async fn list_agent_device_rows(&self) -> Result<Vec<AgentDeviceWithChildren>> {
|
||||||
let devices = sqlx::query_as!(
|
read::load(&self.pool, None).await
|
||||||
AgentDeviceRow,
|
|
||||||
r#"SELECT agent_id, device_key, presence, display_name,
|
|
||||||
first_seen_unix, last_seen_unix
|
|
||||||
FROM agent_devices
|
|
||||||
ORDER BY agent_id, device_key"#,
|
|
||||||
)
|
|
||||||
.fetch_all(&self.pool)
|
|
||||||
.await
|
|
||||||
.context("failed listing agent devices")?;
|
|
||||||
|
|
||||||
let macs = sqlx::query_as!(
|
|
||||||
AgentDeviceMacRow,
|
|
||||||
r#"SELECT agent_id as "agent_id!", device_key as "device_key!", mac as "mac!"
|
|
||||||
FROM agent_device_macs"#,
|
|
||||||
)
|
|
||||||
.fetch_all(&self.pool)
|
|
||||||
.await
|
|
||||||
.context("failed listing agent device macs")?;
|
|
||||||
|
|
||||||
let ips = sqlx::query_as!(
|
|
||||||
AgentDeviceIpRow,
|
|
||||||
r#"SELECT agent_id as "agent_id!", device_key as "device_key!", ip as "ip!"
|
|
||||||
FROM agent_device_ips"#,
|
|
||||||
)
|
|
||||||
.fetch_all(&self.pool)
|
|
||||||
.await
|
|
||||||
.context("failed listing agent device ips")?;
|
|
||||||
|
|
||||||
let hostnames = sqlx::query_as!(
|
|
||||||
AgentDeviceHostnameRow,
|
|
||||||
r#"SELECT agent_id as "agent_id!", device_key as "device_key!", hostname as "hostname!"
|
|
||||||
FROM agent_device_hostnames"#,
|
|
||||||
)
|
|
||||||
.fetch_all(&self.pool)
|
|
||||||
.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!"
|
|
||||||
FROM agent_device_facts"#,
|
|
||||||
)
|
|
||||||
.fetch_all(&self.pool)
|
|
||||||
.await
|
|
||||||
.context("failed listing agent device facts")?;
|
|
||||||
|
|
||||||
Ok(assemble_device_rows(
|
|
||||||
devices, macs, ips, hostnames, endpoints, facts,
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List agent device rows for a single agent.
|
/// List one agent's devices using the same assembly path as fleet reads.
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
|
#[tracing::instrument(name = "db_list_agent_devices", skip_all)]
|
||||||
pub async fn list_agent_device_rows_for_agent(
|
pub async fn list_agent_device_rows_for_agent(
|
||||||
&self,
|
&self,
|
||||||
agent_id: &str,
|
agent_id: &str,
|
||||||
) -> Result<Vec<AgentDeviceWithChildren>> {
|
) -> Result<Vec<AgentDeviceWithChildren>> {
|
||||||
let devices = sqlx::query_as!(
|
read::load(&self.pool, Some(agent_id)).await
|
||||||
AgentDeviceRow,
|
|
||||||
r#"SELECT agent_id, device_key, presence, display_name,
|
|
||||||
first_seen_unix, last_seen_unix
|
|
||||||
FROM agent_devices
|
|
||||||
WHERE agent_id = ?1
|
|
||||||
ORDER BY device_key"#,
|
|
||||||
agent_id,
|
|
||||||
)
|
|
||||||
.fetch_all(&self.pool)
|
|
||||||
.await
|
|
||||||
.context("failed listing agent devices for agent")?;
|
|
||||||
|
|
||||||
let macs = sqlx::query_as!(
|
|
||||||
AgentDeviceMacRow,
|
|
||||||
r#"SELECT agent_id as "agent_id!", device_key as "device_key!", mac as "mac!"
|
|
||||||
FROM agent_device_macs
|
|
||||||
WHERE agent_id = ?1"#,
|
|
||||||
agent_id,
|
|
||||||
)
|
|
||||||
.fetch_all(&self.pool)
|
|
||||||
.await
|
|
||||||
.context("failed listing agent device macs for agent")?;
|
|
||||||
|
|
||||||
let ips = sqlx::query_as!(
|
|
||||||
AgentDeviceIpRow,
|
|
||||||
r#"SELECT agent_id as "agent_id!", device_key as "device_key!", ip as "ip!"
|
|
||||||
FROM agent_device_ips
|
|
||||||
WHERE agent_id = ?1"#,
|
|
||||||
agent_id,
|
|
||||||
)
|
|
||||||
.fetch_all(&self.pool)
|
|
||||||
.await
|
|
||||||
.context("failed listing agent device ips for agent")?;
|
|
||||||
|
|
||||||
let hostnames = sqlx::query_as!(
|
|
||||||
AgentDeviceHostnameRow,
|
|
||||||
r#"SELECT agent_id as "agent_id!", device_key as "device_key!", hostname as "hostname!"
|
|
||||||
FROM agent_device_hostnames
|
|
||||||
WHERE agent_id = ?1"#,
|
|
||||||
agent_id,
|
|
||||||
)
|
|
||||||
.fetch_all(&self.pool)
|
|
||||||
.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!"
|
|
||||||
FROM agent_device_facts
|
|
||||||
WHERE agent_id = ?1"#,
|
|
||||||
agent_id,
|
|
||||||
)
|
|
||||||
.fetch_all(&self.pool)
|
|
||||||
.await
|
|
||||||
.context("failed listing agent device facts for agent")?;
|
|
||||||
|
|
||||||
Ok(assemble_device_rows(
|
|
||||||
devices, macs, ips, hostnames, endpoints, facts,
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn assemble_device_rows(
|
|
||||||
devices: Vec<AgentDeviceRow>,
|
|
||||||
macs: Vec<AgentDeviceMacRow>,
|
|
||||||
ips: Vec<AgentDeviceIpRow>,
|
|
||||||
hostnames: Vec<AgentDeviceHostnameRow>,
|
|
||||||
endpoints: Vec<AgentDeviceEndpointRow>,
|
|
||||||
facts: Vec<AgentDeviceFactRow>,
|
|
||||||
) -> Vec<AgentDeviceWithChildren> {
|
|
||||||
use std::collections::BTreeMap;
|
|
||||||
|
|
||||||
let mut mac_map: BTreeMap<(&str, &str), Vec<&AgentDeviceMacRow>> = BTreeMap::new();
|
|
||||||
for row in &macs {
|
|
||||||
mac_map
|
|
||||||
.entry((row.agent_id.as_str(), row.device_key.as_str()))
|
|
||||||
.or_default()
|
|
||||||
.push(row);
|
|
||||||
}
|
|
||||||
let mut ip_map: BTreeMap<(&str, &str), Vec<&AgentDeviceIpRow>> = BTreeMap::new();
|
|
||||||
for row in &ips {
|
|
||||||
ip_map
|
|
||||||
.entry((row.agent_id.as_str(), row.device_key.as_str()))
|
|
||||||
.or_default()
|
|
||||||
.push(row);
|
|
||||||
}
|
|
||||||
let mut hostname_map: BTreeMap<(&str, &str), Vec<String>> = BTreeMap::new();
|
|
||||||
for row in &hostnames {
|
|
||||||
hostname_map
|
|
||||||
.entry((row.agent_id.as_str(), row.device_key.as_str()))
|
|
||||||
.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
|
|
||||||
.entry((row.agent_id.as_str(), row.device_key.as_str()))
|
|
||||||
.or_default()
|
|
||||||
.push(row.fact_json.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
devices
|
|
||||||
.into_iter()
|
|
||||||
.map(|device| {
|
|
||||||
let key = (device.agent_id.as_str(), device.device_key.as_str());
|
|
||||||
let macs: Vec<macaddr::MacAddr> = mac_map
|
|
||||||
.get(&key)
|
|
||||||
.into_iter()
|
|
||||||
.flatten()
|
|
||||||
.filter_map(|row| match macaddr::MacAddr::try_from(*row) {
|
|
||||||
Ok(mac) => Some(mac),
|
|
||||||
Err(e) => {
|
|
||||||
::tracing::warn!(error = %e, agent_id = %device.agent_id, device_key = %device.device_key, raw_mac = %row.mac, "failed to parse mac from agent_device_macs row");
|
|
||||||
None
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
let ips: Vec<std::net::IpAddr> = ip_map
|
|
||||||
.get(&key)
|
|
||||||
.into_iter()
|
|
||||||
.flatten()
|
|
||||||
.filter_map(|row| match std::net::IpAddr::try_from(*row) {
|
|
||||||
Ok(ip) => Some(ip),
|
|
||||||
Err(e) => {
|
|
||||||
::tracing::warn!(error = %e, agent_id = %device.agent_id, device_key = %device.device_key, raw_ip = %row.ip, "failed to parse ip from agent_device_ips row");
|
|
||||||
None
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.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,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn device_key_from_id(device_id: &DeviceId) -> String {
|
pub fn device_key_from_id(device_id: &DeviceId) -> String {
|
||||||
match device_id {
|
match device_id {
|
||||||
DeviceId::Mac(mac) => format!("mac:{}", mac.to_string().to_ascii_lowercase()),
|
DeviceId::Mac(mac) => format!("mac:{}", mac.to_string().to_ascii_lowercase()),
|
||||||
@@ -562,6 +186,241 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn device_retention_uses_snapshot_age_and_preserves_saved_identity() {
|
||||||
|
use crate::state::types::{DeviceIdentifierInput, KnownDeviceInput};
|
||||||
|
|
||||||
|
let ts = TestStore::new().await;
|
||||||
|
let store = ts.store();
|
||||||
|
let mut device = sample_device("aa:bb:cc:dd:ee:01", "192.168.1.10", "pc");
|
||||||
|
device.neighbors[0].state = NeighborState::Failed;
|
||||||
|
device.observations.push(DeviceObservationFact {
|
||||||
|
kind: "neigh".into(),
|
||||||
|
action: "remove".into(),
|
||||||
|
mac: device.macs.first().copied(),
|
||||||
|
ip: device.ips.first().copied(),
|
||||||
|
hostname: None,
|
||||||
|
first_seen_unix: Some(1),
|
||||||
|
last_seen_unix: Some(1),
|
||||||
|
});
|
||||||
|
let saved = store
|
||||||
|
.create_known_device(KnownDeviceInput {
|
||||||
|
display_name: "saved pc".into(),
|
||||||
|
pinned: true,
|
||||||
|
notes: None,
|
||||||
|
identifiers: vec![DeviceIdentifierInput {
|
||||||
|
kind: "mac".into(),
|
||||||
|
value: "aa:bb:cc:dd:ee:01".into(),
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Explicit timestamps exercise the exact boundary and future rows
|
||||||
|
// without sleeping or relying on the test machine's wall clock.
|
||||||
|
for (agent, seen) in [("expired", 99_i64), ("boundary", 100), ("fresh", 101)] {
|
||||||
|
store
|
||||||
|
.replace_agent_device_snapshot(agent, &[device.clone()])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
sqlx::query("UPDATE agent_devices SET last_seen_unix = ?1 WHERE agent_id = ?2")
|
||||||
|
.bind(seen)
|
||||||
|
.bind(agent)
|
||||||
|
.execute(&store.pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
sqlx::query("UPDATE agent_device_endpoints SET last_seen_unix = 1 WHERE agent_id = ?1")
|
||||||
|
.bind(agent)
|
||||||
|
.execute(&store.pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
let before_cleanup = store.fleet_revision();
|
||||||
|
assert_eq!(
|
||||||
|
store.prune_devices_at(Duration::ZERO, 1_000).await.unwrap(),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
assert_eq!(store.fleet_revision(), before_cleanup);
|
||||||
|
assert_eq!(
|
||||||
|
store
|
||||||
|
.prune_devices_at(Duration::from_secs(900), 1_000)
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
let after_cleanup = store.fleet_revision();
|
||||||
|
assert_ne!(after_cleanup, before_cleanup);
|
||||||
|
assert_eq!(
|
||||||
|
store
|
||||||
|
.prune_devices_at(Duration::from_secs(900), 1_000)
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
assert_eq!(store.fleet_revision(), after_cleanup);
|
||||||
|
|
||||||
|
for table in [
|
||||||
|
"agent_devices",
|
||||||
|
"agent_device_macs",
|
||||||
|
"agent_device_ips",
|
||||||
|
"agent_device_hostnames",
|
||||||
|
"agent_device_endpoints",
|
||||||
|
"agent_device_facts",
|
||||||
|
] {
|
||||||
|
let count: i64 = sqlx::query_scalar(&format!(
|
||||||
|
"SELECT COUNT(*) FROM {table} WHERE agent_id = 'expired'"
|
||||||
|
))
|
||||||
|
.fetch_one(&store.pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(count, 0, "expired rows remain in {table}");
|
||||||
|
}
|
||||||
|
for agent in ["boundary", "fresh"] {
|
||||||
|
let rows = store.list_agent_device_rows_for_agent(agent).await.unwrap();
|
||||||
|
assert_eq!(rows.len(), 1);
|
||||||
|
assert_eq!(rows[0].presence(), Presence::Offline);
|
||||||
|
}
|
||||||
|
let retained = store
|
||||||
|
.get_known_device(&saved.device_id)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(retained.identifiers.len(), 1);
|
||||||
|
assert!(retained.pinned);
|
||||||
|
// Retention longer than the current epoch must not wrap the cutoff.
|
||||||
|
assert_eq!(
|
||||||
|
store
|
||||||
|
.prune_devices_at(Duration::from_secs(2_000), 1_000)
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_new_snapshot_renews_device_retention_even_when_offline() {
|
||||||
|
let ts = TestStore::new().await;
|
||||||
|
let store = ts.store();
|
||||||
|
let mut device = sample_device("aa:bb:cc:dd:ee:01", "192.168.1.10", "pc");
|
||||||
|
device.neighbors[0].state = NeighborState::Failed;
|
||||||
|
store
|
||||||
|
.replace_agent_device_snapshot("agent-a", &[device.clone()])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
sqlx::query("UPDATE agent_devices SET last_seen_unix = 1")
|
||||||
|
.execute(&store.pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
store
|
||||||
|
.replace_agent_device_snapshot("agent-a", &[device])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let rows = store
|
||||||
|
.list_agent_device_rows_for_agent("agent-a")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let included_at = u64::try_from(rows[0].device.last_seen_unix).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
store
|
||||||
|
.prune_devices_at(Duration::from_secs(60), included_at + 60)
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
store
|
||||||
|
.prune_devices_at(Duration::from_secs(60), included_at + 61)
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn snapshot_presence_is_derived_from_stored_endpoints() {
|
||||||
|
let ts = TestStore::new().await;
|
||||||
|
let mut device = sample_device("aa:bb:cc:dd:ee:01", "192.168.1.10", "pc");
|
||||||
|
// Compatibility snapshots can lack endpoints but carry raw neighbors.
|
||||||
|
device.presence = Presence::Unknown;
|
||||||
|
device.neighbors[0].state = NeighborState::Failed;
|
||||||
|
ts.store()
|
||||||
|
.replace_agent_device_snapshot("agent-a", &[device.clone()])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let rows = ts
|
||||||
|
.store()
|
||||||
|
.list_agent_device_rows_for_agent("agent-a")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(rows[0].device.presence, "offline");
|
||||||
|
assert_eq!(rows[0].endpoints[0].presence, Presence::Offline);
|
||||||
|
|
||||||
|
device.presence = Presence::Online;
|
||||||
|
device.endpoints = rows[0].endpoints.clone();
|
||||||
|
device.endpoints[0].presence = Presence::Unknown;
|
||||||
|
ts.store()
|
||||||
|
.replace_agent_device_snapshot("agent-a", &[device])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let rows = ts
|
||||||
|
.store()
|
||||||
|
.list_agent_device_rows_for_agent("agent-a")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(rows[0].device.presence, "unknown");
|
||||||
|
assert_eq!(rows[0].endpoints[0].presence, Presence::Unknown);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn child_write_failure_rolls_back_the_whole_snapshot_without_invalidating_fleet() {
|
||||||
|
let ts = TestStore::new().await;
|
||||||
|
let store = ts.store();
|
||||||
|
let original = sample_device("aa:bb:cc:dd:ee:01", "192.168.1.10", "original");
|
||||||
|
store
|
||||||
|
.replace_agent_device_snapshot("agent-a", &[original])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let before = store
|
||||||
|
.list_agent_device_rows_for_agent("agent-a")
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.remove(0);
|
||||||
|
let revision = store.fleet_revision();
|
||||||
|
|
||||||
|
let mut updated = sample_device("aa:bb:cc:dd:ee:01", "192.168.1.20", "updated");
|
||||||
|
updated.neighbors[0].state = NeighborState::Failed;
|
||||||
|
let mut invalid = sample_device("aa:bb:cc:dd:ee:02", "192.168.1.30", "duplicate");
|
||||||
|
// The second device fails late, after the first device and several child
|
||||||
|
// tables were written. Every helper must share the outer transaction.
|
||||||
|
invalid.names.push("duplicate".into());
|
||||||
|
let error = store
|
||||||
|
.replace_agent_device_snapshot("agent-a", &[updated, invalid])
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(
|
||||||
|
error
|
||||||
|
.to_string()
|
||||||
|
.contains("failed inserting device hostnames")
|
||||||
|
);
|
||||||
|
assert_eq!(store.fleet_revision(), revision);
|
||||||
|
|
||||||
|
let mut rows = store
|
||||||
|
.list_agent_device_rows_for_agent("agent-a")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(rows.len(), 1, "the failed new device must not survive");
|
||||||
|
let after = rows.remove(0);
|
||||||
|
assert_eq!(after.device.device_key, before.device.device_key);
|
||||||
|
assert_eq!(after.device.presence, before.device.presence);
|
||||||
|
assert_eq!(after.device.first_seen_unix, before.device.first_seen_unix);
|
||||||
|
assert_eq!(after.device.last_seen_unix, before.device.last_seen_unix);
|
||||||
|
assert_eq!(after.macs, before.macs);
|
||||||
|
assert_eq!(after.ips, before.ips);
|
||||||
|
assert_eq!(after.hostnames, before.hostnames);
|
||||||
|
assert_eq!(after.endpoints, before.endpoints);
|
||||||
|
assert_eq!(after.facts, before.facts);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn snapshot_inserts_devices_and_children() {
|
async fn snapshot_inserts_devices_and_children() {
|
||||||
let ts = TestStore::new().await;
|
let ts = TestStore::new().await;
|
||||||
|
|||||||
@@ -0,0 +1,270 @@
|
|||||||
|
//! Fleet and single-agent reads share one sequence and a single SQLite read view.
|
||||||
|
|
||||||
|
use crate::state::types::*;
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use sqlx::{SqliteConnection, SqlitePool};
|
||||||
|
|
||||||
|
mod assembly;
|
||||||
|
use assembly::DeviceRows;
|
||||||
|
|
||||||
|
/// A read transaction prevents child rows from belonging to a newer snapshot
|
||||||
|
/// than their parents. Keep scoped SQL explicit so agent reads use indexed filters.
|
||||||
|
pub(super) async fn load(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
agent_id: Option<&str>,
|
||||||
|
) -> Result<Vec<AgentDeviceWithChildren>> {
|
||||||
|
let mut tx = pool
|
||||||
|
.begin()
|
||||||
|
.await
|
||||||
|
.context("failed starting device read transaction")?;
|
||||||
|
let rows = DeviceRows {
|
||||||
|
devices: load_devices(&mut tx, agent_id).await?,
|
||||||
|
macs: load_macs(&mut tx, agent_id).await?,
|
||||||
|
ips: load_ips(&mut tx, agent_id).await?,
|
||||||
|
hostnames: load_hostnames(&mut tx, agent_id).await?,
|
||||||
|
endpoints: load_endpoints(&mut tx, agent_id).await?,
|
||||||
|
facts: load_facts(&mut tx, agent_id).await?,
|
||||||
|
};
|
||||||
|
tx.commit()
|
||||||
|
.await
|
||||||
|
.context("failed completing device read transaction")?;
|
||||||
|
Ok(rows.into_devices())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_devices(
|
||||||
|
connection: &mut SqliteConnection,
|
||||||
|
agent_id: Option<&str>,
|
||||||
|
) -> Result<Vec<AgentDeviceRow>> {
|
||||||
|
match agent_id {
|
||||||
|
Some(agent_id) => sqlx::query_as!(
|
||||||
|
AgentDeviceRow,
|
||||||
|
r#"SELECT agent_id, device_key, presence, display_name,
|
||||||
|
first_seen_unix, last_seen_unix
|
||||||
|
FROM agent_devices
|
||||||
|
WHERE agent_id = ?1
|
||||||
|
ORDER BY device_key"#,
|
||||||
|
agent_id,
|
||||||
|
)
|
||||||
|
.fetch_all(&mut *connection)
|
||||||
|
.await
|
||||||
|
.context("failed listing agent devices for agent"),
|
||||||
|
None => sqlx::query_as!(
|
||||||
|
AgentDeviceRow,
|
||||||
|
r#"SELECT agent_id, device_key, presence, display_name,
|
||||||
|
first_seen_unix, last_seen_unix
|
||||||
|
FROM agent_devices
|
||||||
|
ORDER BY agent_id, device_key"#,
|
||||||
|
)
|
||||||
|
.fetch_all(&mut *connection)
|
||||||
|
.await
|
||||||
|
.context("failed listing agent devices"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_macs(
|
||||||
|
connection: &mut SqliteConnection,
|
||||||
|
agent_id: Option<&str>,
|
||||||
|
) -> Result<Vec<AgentDeviceMacRow>> {
|
||||||
|
match agent_id {
|
||||||
|
Some(agent_id) => sqlx::query_as!(
|
||||||
|
AgentDeviceMacRow,
|
||||||
|
r#"SELECT agent_id as "agent_id!", device_key as "device_key!", mac as "mac!"
|
||||||
|
FROM agent_device_macs
|
||||||
|
WHERE agent_id = ?1"#,
|
||||||
|
agent_id,
|
||||||
|
)
|
||||||
|
.fetch_all(&mut *connection)
|
||||||
|
.await
|
||||||
|
.context("failed listing agent device macs for agent"),
|
||||||
|
None => sqlx::query_as!(
|
||||||
|
AgentDeviceMacRow,
|
||||||
|
r#"SELECT agent_id as "agent_id!", device_key as "device_key!", mac as "mac!"
|
||||||
|
FROM agent_device_macs"#,
|
||||||
|
)
|
||||||
|
.fetch_all(&mut *connection)
|
||||||
|
.await
|
||||||
|
.context("failed listing agent device macs"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_ips(
|
||||||
|
connection: &mut SqliteConnection,
|
||||||
|
agent_id: Option<&str>,
|
||||||
|
) -> Result<Vec<AgentDeviceIpRow>> {
|
||||||
|
match agent_id {
|
||||||
|
Some(agent_id) => sqlx::query_as!(
|
||||||
|
AgentDeviceIpRow,
|
||||||
|
r#"SELECT agent_id as "agent_id!", device_key as "device_key!", ip as "ip!"
|
||||||
|
FROM agent_device_ips
|
||||||
|
WHERE agent_id = ?1"#,
|
||||||
|
agent_id,
|
||||||
|
)
|
||||||
|
.fetch_all(&mut *connection)
|
||||||
|
.await
|
||||||
|
.context("failed listing agent device ips for agent"),
|
||||||
|
None => sqlx::query_as!(
|
||||||
|
AgentDeviceIpRow,
|
||||||
|
r#"SELECT agent_id as "agent_id!", device_key as "device_key!", ip as "ip!"
|
||||||
|
FROM agent_device_ips"#,
|
||||||
|
)
|
||||||
|
.fetch_all(&mut *connection)
|
||||||
|
.await
|
||||||
|
.context("failed listing agent device ips"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_hostnames(
|
||||||
|
connection: &mut SqliteConnection,
|
||||||
|
agent_id: Option<&str>,
|
||||||
|
) -> Result<Vec<AgentDeviceHostnameRow>> {
|
||||||
|
match agent_id {
|
||||||
|
Some(agent_id) => sqlx::query_as!(
|
||||||
|
AgentDeviceHostnameRow,
|
||||||
|
r#"SELECT agent_id as "agent_id!", device_key as "device_key!", hostname as "hostname!"
|
||||||
|
FROM agent_device_hostnames
|
||||||
|
WHERE agent_id = ?1"#,
|
||||||
|
agent_id,
|
||||||
|
)
|
||||||
|
.fetch_all(&mut *connection)
|
||||||
|
.await
|
||||||
|
.context("failed listing agent device hostnames for agent"),
|
||||||
|
None => sqlx::query_as!(
|
||||||
|
AgentDeviceHostnameRow,
|
||||||
|
r#"SELECT agent_id as "agent_id!", device_key as "device_key!", hostname as "hostname!"
|
||||||
|
FROM agent_device_hostnames"#,
|
||||||
|
)
|
||||||
|
.fetch_all(&mut *connection)
|
||||||
|
.await
|
||||||
|
.context("failed listing agent device hostnames"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_endpoints(
|
||||||
|
connection: &mut SqliteConnection,
|
||||||
|
agent_id: Option<&str>,
|
||||||
|
) -> Result<Vec<AgentDeviceEndpointRow>> {
|
||||||
|
match agent_id {
|
||||||
|
Some(agent_id) => 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(&mut *connection)
|
||||||
|
.await
|
||||||
|
.context("failed listing agent device endpoints for agent"),
|
||||||
|
None => 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(&mut *connection)
|
||||||
|
.await
|
||||||
|
.context("failed listing agent device endpoints"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_facts(
|
||||||
|
connection: &mut SqliteConnection,
|
||||||
|
agent_id: Option<&str>,
|
||||||
|
) -> Result<Vec<AgentDeviceFactRow>> {
|
||||||
|
match agent_id {
|
||||||
|
Some(agent_id) => {
|
||||||
|
sqlx::query_as!(
|
||||||
|
AgentDeviceFactRow,
|
||||||
|
r#"SELECT agent_id as "agent_id!", device_key as "device_key!", fact_json as "fact_json!"
|
||||||
|
FROM agent_device_facts
|
||||||
|
WHERE agent_id = ?1"#,
|
||||||
|
agent_id,
|
||||||
|
)
|
||||||
|
.fetch_all(&mut *connection)
|
||||||
|
.await
|
||||||
|
.context("failed listing agent device facts for agent")
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
sqlx::query_as!(
|
||||||
|
AgentDeviceFactRow,
|
||||||
|
r#"SELECT agent_id as "agent_id!", device_key as "device_key!", fact_json as "fact_json!"
|
||||||
|
FROM agent_device_facts"#,
|
||||||
|
)
|
||||||
|
.fetch_all(&mut *connection)
|
||||||
|
.await
|
||||||
|
.context("failed listing agent device facts")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::state::store::helpers::test_helpers::TestStore;
|
||||||
|
use wakey_core::{Device, NeighborEntry, NeighborState};
|
||||||
|
|
||||||
|
fn device(ip: &str) -> Device {
|
||||||
|
Device::from_parts(
|
||||||
|
vec![NeighborEntry {
|
||||||
|
ip: ip.parse().unwrap(),
|
||||||
|
mac: Some("aa:bb:cc:dd:ee:01".parse().unwrap()),
|
||||||
|
dev: Some("br-lan".into()),
|
||||||
|
state: NeighborState::Reachable,
|
||||||
|
}],
|
||||||
|
vec![],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn scoped_reads_match_fleet_rows_without_leaking_other_agents() {
|
||||||
|
let ts = TestStore::new().await;
|
||||||
|
let store = ts.store();
|
||||||
|
for (agent, ip) in [("a", "192.168.1.10"), ("b", "192.168.1.20")] {
|
||||||
|
store
|
||||||
|
.replace_agent_device_snapshot(agent, &[device(ip)])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
let fleet = store.list_agent_device_rows().await.unwrap();
|
||||||
|
assert_eq!(fleet.len(), 2);
|
||||||
|
for agent in ["a", "b", "missing"] {
|
||||||
|
let scoped = store.list_agent_device_rows_for_agent(agent).await.unwrap();
|
||||||
|
let expected = fleet
|
||||||
|
.iter()
|
||||||
|
.filter(|row| row.device.agent_id == agent)
|
||||||
|
.cloned()
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
// Compare every parent/child field, including timestamps and endpoint metadata.
|
||||||
|
assert_eq!(format!("{scoped:?}"), format!("{expected:?}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn child_reads_share_the_parent_snapshot_during_a_concurrent_write() {
|
||||||
|
let ts = TestStore::new().await;
|
||||||
|
let store = ts.store();
|
||||||
|
store
|
||||||
|
.replace_agent_device_snapshot("a", &[device("192.168.1.10")])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let mut tx = store.pool.begin().await.unwrap();
|
||||||
|
let parents = load_devices(&mut tx, Some("a")).await.unwrap();
|
||||||
|
assert_eq!(parents.len(), 1);
|
||||||
|
|
||||||
|
// In WAL mode the writer can commit while the reader still sees the
|
||||||
|
// original snapshot. No sleeps or timing assumptions are needed.
|
||||||
|
store
|
||||||
|
.replace_agent_device_snapshot("a", &[device("192.168.1.20")])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let ips = load_ips(&mut tx, Some("a")).await.unwrap();
|
||||||
|
let endpoints = load_endpoints(&mut tx, Some("a")).await.unwrap();
|
||||||
|
assert_eq!(ips[0].ip, "192.168.1.10");
|
||||||
|
assert_eq!(endpoints[0].ip.as_deref(), Some("192.168.1.10"));
|
||||||
|
tx.commit().await.unwrap();
|
||||||
|
|
||||||
|
let current = store.list_agent_device_rows_for_agent("a").await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
current[0].ips,
|
||||||
|
["192.168.1.20".parse::<std::net::IpAddr>().unwrap()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
//! Attach decoded children to their parent using the full agent/device identity.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use crate::state::types::*;
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub(super) struct DeviceRows {
|
||||||
|
pub(super) devices: Vec<AgentDeviceRow>,
|
||||||
|
pub(super) macs: Vec<AgentDeviceMacRow>,
|
||||||
|
pub(super) ips: Vec<AgentDeviceIpRow>,
|
||||||
|
pub(super) hostnames: Vec<AgentDeviceHostnameRow>,
|
||||||
|
pub(super) endpoints: Vec<AgentDeviceEndpointRow>,
|
||||||
|
pub(super) facts: Vec<AgentDeviceFactRow>,
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeviceIndex = BTreeMap<(String, String), AgentDeviceWithChildren>;
|
||||||
|
|
||||||
|
impl DeviceRows {
|
||||||
|
pub(super) fn into_devices(self) -> Vec<AgentDeviceWithChildren> {
|
||||||
|
let mut index = self
|
||||||
|
.devices
|
||||||
|
.into_iter()
|
||||||
|
.map(|device| {
|
||||||
|
let key = (device.agent_id.clone(), device.device_key.clone());
|
||||||
|
(
|
||||||
|
key,
|
||||||
|
AgentDeviceWithChildren {
|
||||||
|
device,
|
||||||
|
macs: Vec::new(),
|
||||||
|
ips: Vec::new(),
|
||||||
|
hostnames: Vec::new(),
|
||||||
|
endpoints: Vec::new(),
|
||||||
|
facts: Vec::new(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect::<DeviceIndex>();
|
||||||
|
add_macs(&mut index, self.macs);
|
||||||
|
add_ips(&mut index, self.ips);
|
||||||
|
add_hostnames(&mut index, self.hostnames);
|
||||||
|
add_endpoints(&mut index, self.endpoints);
|
||||||
|
add_facts(&mut index, self.facts);
|
||||||
|
// Matches the SQL order: agent_id/device_key for fleet reads, and
|
||||||
|
// device_key within one agent. Child row order is left unchanged.
|
||||||
|
index.into_values().collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_macs(index: &mut DeviceIndex, rows: Vec<AgentDeviceMacRow>) {
|
||||||
|
for row in rows {
|
||||||
|
let mac = match macaddr::MacAddr::try_from(&row) {
|
||||||
|
Ok(mac) => mac,
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!(%error, agent_id = %row.agent_id, device_key = %row.device_key,
|
||||||
|
raw_mac = %row.mac, "failed to parse mac from agent_device_macs row");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Some(device) = index.get_mut(&(row.agent_id, row.device_key)) {
|
||||||
|
device.macs.push(mac);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_ips(index: &mut DeviceIndex, rows: Vec<AgentDeviceIpRow>) {
|
||||||
|
for row in rows {
|
||||||
|
let ip = match std::net::IpAddr::try_from(&row) {
|
||||||
|
Ok(ip) => ip,
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!(%error, agent_id = %row.agent_id, device_key = %row.device_key,
|
||||||
|
raw_ip = %row.ip, "failed to parse ip from agent_device_ips row");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Some(device) = index.get_mut(&(row.agent_id, row.device_key)) {
|
||||||
|
device.ips.push(ip);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_hostnames(index: &mut DeviceIndex, rows: Vec<AgentDeviceHostnameRow>) {
|
||||||
|
for row in rows {
|
||||||
|
if let Some(device) = index.get_mut(&(row.agent_id, row.device_key)) {
|
||||||
|
device.hostnames.push(row.hostname);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_endpoints(index: &mut DeviceIndex, rows: Vec<AgentDeviceEndpointRow>) {
|
||||||
|
for row in rows {
|
||||||
|
if let Some(endpoint) = row.to_endpoint()
|
||||||
|
&& let Some(device) = index.get_mut(&(row.agent_id, row.device_key))
|
||||||
|
{
|
||||||
|
device.endpoints.push(endpoint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_facts(index: &mut DeviceIndex, rows: Vec<AgentDeviceFactRow>) {
|
||||||
|
for row in rows {
|
||||||
|
if let Some(device) = index.get_mut(&(row.agent_id, row.device_key)) {
|
||||||
|
device.facts.push(row.fact_json);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn parent(agent: &str, key: &str) -> AgentDeviceRow {
|
||||||
|
AgentDeviceRow {
|
||||||
|
agent_id: agent.into(),
|
||||||
|
device_key: key.into(),
|
||||||
|
presence: "unknown".into(),
|
||||||
|
display_name: None,
|
||||||
|
first_seen_unix: 1,
|
||||||
|
last_seen_unix: 2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn assembly_keeps_agent_boundaries_order_and_empty_parents() {
|
||||||
|
let rows = DeviceRows {
|
||||||
|
devices: vec![
|
||||||
|
parent("b", "shared"),
|
||||||
|
parent("a", "shared"),
|
||||||
|
parent("a", "empty"),
|
||||||
|
],
|
||||||
|
hostnames: vec![
|
||||||
|
AgentDeviceHostnameRow {
|
||||||
|
agent_id: "b".into(),
|
||||||
|
device_key: "shared".into(),
|
||||||
|
hostname: "second".into(),
|
||||||
|
},
|
||||||
|
AgentDeviceHostnameRow {
|
||||||
|
agent_id: "a".into(),
|
||||||
|
device_key: "shared".into(),
|
||||||
|
hostname: "first".into(),
|
||||||
|
},
|
||||||
|
AgentDeviceHostnameRow {
|
||||||
|
agent_id: "missing".into(),
|
||||||
|
device_key: "shared".into(),
|
||||||
|
hostname: "orphan".into(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
facts: vec![AgentDeviceFactRow {
|
||||||
|
agent_id: "a".into(),
|
||||||
|
device_key: "shared".into(),
|
||||||
|
fact_json: "{\"action\":\"remove\"}".into(),
|
||||||
|
}],
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
.into_devices();
|
||||||
|
assert_eq!(rows.len(), 3);
|
||||||
|
assert_eq!(rows[0].device.device_key, "empty");
|
||||||
|
assert!(rows[0].hostnames.is_empty());
|
||||||
|
assert_eq!(rows[1].hostnames, ["first"]);
|
||||||
|
assert_eq!(rows[1].facts, ["{\"action\":\"remove\"}"]);
|
||||||
|
assert_eq!(rows[2].hostnames, ["second"]);
|
||||||
|
assert!(rows[2].facts.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn malformed_children_do_not_hide_valid_children_or_the_parent() {
|
||||||
|
let rows = DeviceRows {
|
||||||
|
devices: vec![parent("a", "pc")],
|
||||||
|
macs: ["not-a-mac", "aa:bb:cc:dd:ee:01"]
|
||||||
|
.into_iter()
|
||||||
|
.map(|mac| AgentDeviceMacRow {
|
||||||
|
agent_id: "a".into(),
|
||||||
|
device_key: "pc".into(),
|
||||||
|
mac: mac.into(),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
ips: ["not-an-ip", "192.168.1.10"]
|
||||||
|
.into_iter()
|
||||||
|
.map(|ip| AgentDeviceIpRow {
|
||||||
|
agent_id: "a".into(),
|
||||||
|
device_key: "pc".into(),
|
||||||
|
ip: ip.into(),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
.into_devices();
|
||||||
|
assert_eq!(rows.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
rows[0].macs,
|
||||||
|
["aa:bb:cc:dd:ee:01".parse::<macaddr::MacAddr>().unwrap()]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
rows[0].ips,
|
||||||
|
["192.168.1.10".parse::<std::net::IpAddr>().unwrap()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
//! Replaces snapshot rows using the caller's transaction. No helper commits.
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use sqlx::SqliteConnection;
|
||||||
|
use wakey_core::{Device, DeviceEndpoint};
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
endpoint_presence, endpoint_source_to_str, endpoint_storage_key, endpoints_for_storage,
|
||||||
|
presence_to_str,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Includes key lookup, normalization, parent/child writes and missing-device
|
||||||
|
/// deletion. This measures replacement work, not pure SQL execution time.
|
||||||
|
#[tracing::instrument(name = "db_snapshot_write_rows", skip_all, fields(devices = devices.len()))]
|
||||||
|
pub(super) async fn replace(
|
||||||
|
connection: &mut SqliteConnection,
|
||||||
|
agent_id: &str,
|
||||||
|
devices: &[Device],
|
||||||
|
) -> Result<usize> {
|
||||||
|
let mut incoming_keys = std::collections::HashSet::with_capacity(devices.len());
|
||||||
|
let snapshot_time = super::now_unix();
|
||||||
|
|
||||||
|
let existing_keys: Vec<String> = sqlx::query_scalar!(
|
||||||
|
"SELECT device_key FROM agent_devices WHERE agent_id = ?1",
|
||||||
|
agent_id
|
||||||
|
)
|
||||||
|
.fetch_all(&mut *connection)
|
||||||
|
.await
|
||||||
|
.context("failed fetching existing keys")?;
|
||||||
|
|
||||||
|
for device in devices {
|
||||||
|
let Some(device_id) = &device.id else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let device_key = super::device_key_from_id(device_id);
|
||||||
|
incoming_keys.insert(device_key.clone());
|
||||||
|
|
||||||
|
write_device(connection, agent_id, &device_key, device, snapshot_time).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
for old_key in existing_keys {
|
||||||
|
if !incoming_keys.contains(&old_key) {
|
||||||
|
sqlx::query!(
|
||||||
|
"DELETE FROM agent_devices WHERE agent_id = ?1 AND device_key = ?2",
|
||||||
|
agent_id,
|
||||||
|
old_key
|
||||||
|
)
|
||||||
|
.execute(&mut *connection)
|
||||||
|
.await
|
||||||
|
.context("failed pruning old agent device")?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(incoming_keys.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn write_device(
|
||||||
|
connection: &mut SqliteConnection,
|
||||||
|
agent_id: &str,
|
||||||
|
device_key: &str,
|
||||||
|
device: &Device,
|
||||||
|
snapshot_time: u64,
|
||||||
|
) -> Result<()> {
|
||||||
|
let endpoints = endpoints_for_storage(device);
|
||||||
|
let presence = presence_to_str(endpoint_presence(&endpoints, device.presence));
|
||||||
|
upsert_parent(connection, agent_id, device_key, presence, snapshot_time).await?;
|
||||||
|
replace_macs(connection, agent_id, device_key, device).await?;
|
||||||
|
replace_ips(connection, agent_id, device_key, device).await?;
|
||||||
|
replace_endpoints(connection, agent_id, device_key, &endpoints, snapshot_time).await?;
|
||||||
|
replace_hostnames(connection, agent_id, device_key, device).await?;
|
||||||
|
replace_facts(connection, agent_id, device_key, device).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates snapshot inclusion time, while preserving the parent's first-seen time.
|
||||||
|
async fn upsert_parent(
|
||||||
|
connection: &mut SqliteConnection,
|
||||||
|
agent_id: &str,
|
||||||
|
device_key: &str,
|
||||||
|
presence: &str,
|
||||||
|
snapshot_time: u64,
|
||||||
|
) -> Result<()> {
|
||||||
|
let snapshot_time_i64 = i64::try_from(snapshot_time).context("snapshot time overflow")?;
|
||||||
|
let display_name: Option<String> = None;
|
||||||
|
sqlx::query!(
|
||||||
|
"INSERT INTO agent_devices (agent_id, device_key, presence, display_name, first_seen_unix, last_seen_unix)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
|
||||||
|
ON CONFLICT (agent_id, device_key) DO UPDATE SET
|
||||||
|
presence = excluded.presence,
|
||||||
|
display_name = excluded.display_name,
|
||||||
|
last_seen_unix = excluded.last_seen_unix",
|
||||||
|
agent_id,
|
||||||
|
device_key,
|
||||||
|
presence,
|
||||||
|
display_name,
|
||||||
|
snapshot_time_i64,
|
||||||
|
snapshot_time_i64
|
||||||
|
)
|
||||||
|
.execute(&mut *connection)
|
||||||
|
.await
|
||||||
|
.context("failed upserting agent device")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn replace_macs(
|
||||||
|
connection: &mut SqliteConnection,
|
||||||
|
agent_id: &str,
|
||||||
|
device_key: &str,
|
||||||
|
device: &Device,
|
||||||
|
) -> Result<()> {
|
||||||
|
sqlx::query!(
|
||||||
|
"DELETE FROM agent_device_macs WHERE agent_id = ?1 AND device_key = ?2",
|
||||||
|
agent_id,
|
||||||
|
device_key
|
||||||
|
)
|
||||||
|
.execute(&mut *connection)
|
||||||
|
.await
|
||||||
|
.context("failed deleting device macs")?;
|
||||||
|
|
||||||
|
if !device.macs.is_empty() {
|
||||||
|
let mut builder =
|
||||||
|
sqlx::QueryBuilder::new("INSERT INTO agent_device_macs (agent_id, device_key, mac) ");
|
||||||
|
builder.push_values(&device.macs, |mut b, mac| {
|
||||||
|
b.push_bind(agent_id)
|
||||||
|
.push_bind(device_key)
|
||||||
|
.push_bind(mac.to_string().to_ascii_lowercase());
|
||||||
|
});
|
||||||
|
builder
|
||||||
|
.build()
|
||||||
|
.execute(&mut *connection)
|
||||||
|
.await
|
||||||
|
.context("failed inserting device macs")?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn replace_ips(
|
||||||
|
connection: &mut SqliteConnection,
|
||||||
|
agent_id: &str,
|
||||||
|
device_key: &str,
|
||||||
|
device: &Device,
|
||||||
|
) -> Result<()> {
|
||||||
|
sqlx::query!(
|
||||||
|
"DELETE FROM agent_device_ips WHERE agent_id = ?1 AND device_key = ?2",
|
||||||
|
agent_id,
|
||||||
|
device_key
|
||||||
|
)
|
||||||
|
.execute(&mut *connection)
|
||||||
|
.await
|
||||||
|
.context("failed deleting device ips")?;
|
||||||
|
|
||||||
|
if !device.ips.is_empty() {
|
||||||
|
let mut builder =
|
||||||
|
sqlx::QueryBuilder::new("INSERT INTO agent_device_ips (agent_id, device_key, ip) ");
|
||||||
|
builder.push_values(&device.ips, |mut b, ip| {
|
||||||
|
b.push_bind(agent_id)
|
||||||
|
.push_bind(device_key)
|
||||||
|
.push_bind(ip.to_string());
|
||||||
|
});
|
||||||
|
builder
|
||||||
|
.build()
|
||||||
|
.execute(&mut *connection)
|
||||||
|
.await
|
||||||
|
.context("failed inserting device ips")?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn replace_endpoints(
|
||||||
|
connection: &mut SqliteConnection,
|
||||||
|
agent_id: &str,
|
||||||
|
device_key: &str,
|
||||||
|
endpoints: &[DeviceEndpoint],
|
||||||
|
snapshot_time: u64,
|
||||||
|
) -> Result<()> {
|
||||||
|
sqlx::query("DELETE FROM agent_device_endpoints WHERE agent_id = ?1 AND device_key = ?2")
|
||||||
|
.bind(agent_id)
|
||||||
|
.bind(device_key)
|
||||||
|
.execute(&mut *connection)
|
||||||
|
.await
|
||||||
|
.context("failed deleting device endpoints")?;
|
||||||
|
|
||||||
|
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)
|
||||||
|
.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 *connection)
|
||||||
|
.await
|
||||||
|
.context("failed inserting device endpoints")?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn replace_hostnames(
|
||||||
|
connection: &mut SqliteConnection,
|
||||||
|
agent_id: &str,
|
||||||
|
device_key: &str,
|
||||||
|
device: &Device,
|
||||||
|
) -> Result<()> {
|
||||||
|
sqlx::query!(
|
||||||
|
"DELETE FROM agent_device_hostnames WHERE agent_id = ?1 AND device_key = ?2",
|
||||||
|
agent_id,
|
||||||
|
device_key
|
||||||
|
)
|
||||||
|
.execute(&mut *connection)
|
||||||
|
.await
|
||||||
|
.context("failed deleting device hostnames")?;
|
||||||
|
|
||||||
|
if !device.names.is_empty() {
|
||||||
|
let mut builder = sqlx::QueryBuilder::new(
|
||||||
|
"INSERT INTO agent_device_hostnames (agent_id, device_key, hostname) ",
|
||||||
|
);
|
||||||
|
builder.push_values(&device.names, |mut b, hostname| {
|
||||||
|
b.push_bind(agent_id)
|
||||||
|
.push_bind(device_key)
|
||||||
|
.push_bind(hostname);
|
||||||
|
});
|
||||||
|
builder
|
||||||
|
.build()
|
||||||
|
.execute(&mut *connection)
|
||||||
|
.await
|
||||||
|
.context("failed inserting device hostnames")?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn replace_facts(
|
||||||
|
connection: &mut SqliteConnection,
|
||||||
|
agent_id: &str,
|
||||||
|
device_key: &str,
|
||||||
|
device: &Device,
|
||||||
|
) -> Result<()> {
|
||||||
|
sqlx::query!(
|
||||||
|
"DELETE FROM agent_device_facts WHERE agent_id = ?1 AND device_key = ?2",
|
||||||
|
agent_id,
|
||||||
|
device_key
|
||||||
|
)
|
||||||
|
.execute(&mut *connection)
|
||||||
|
.await
|
||||||
|
.context("failed deleting device facts")?;
|
||||||
|
|
||||||
|
if !device.observations.is_empty() {
|
||||||
|
let mut facts_json = Vec::with_capacity(device.observations.len());
|
||||||
|
for obs in &device.observations {
|
||||||
|
facts_json.push(serde_json::to_string(obs).context("failed serializing fact")?);
|
||||||
|
}
|
||||||
|
let mut builder = sqlx::QueryBuilder::new(
|
||||||
|
"INSERT INTO agent_device_facts (agent_id, device_key, fact_json) ",
|
||||||
|
);
|
||||||
|
builder.push_values(facts_json, |mut b, fact| {
|
||||||
|
b.push_bind(agent_id).push_bind(device_key).push_bind(fact);
|
||||||
|
});
|
||||||
|
builder
|
||||||
|
.build()
|
||||||
|
.execute(&mut *connection)
|
||||||
|
.await
|
||||||
|
.context("failed inserting device facts")?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -25,7 +25,11 @@ impl Store {
|
|||||||
.await
|
.await
|
||||||
.with_context(|| format!("failed to migrate state db {}", db_path.display()))?;
|
.with_context(|| format!("failed to migrate state db {}", db_path.display()))?;
|
||||||
|
|
||||||
let store = Self { db_path, pool };
|
let store = Self {
|
||||||
|
db_path,
|
||||||
|
pool,
|
||||||
|
fleet_revision: Default::default(),
|
||||||
|
};
|
||||||
|
|
||||||
store.ensure_schema_version().await?;
|
store.ensure_schema_version().await?;
|
||||||
store
|
store
|
||||||
@@ -87,7 +91,8 @@ impl Store {
|
|||||||
.fetch_one(&self.pool)
|
.fetch_one(&self.pool)
|
||||||
.await
|
.await
|
||||||
.context("failed validating SQLite state connection")?;
|
.context("failed validating SQLite state connection")?;
|
||||||
info!(path = %self.db_path.display(), "reload requested; SQLite backend does not require in-memory reload");
|
self.touch_fleet();
|
||||||
|
info!(path = %self.db_path.display(), "reload requested; invalidated derived fleet views");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
impl Store {
|
impl Store {
|
||||||
|
#[tracing::instrument(name = "db_create_known_device", skip_all, fields(identifiers = input.identifiers.len()))]
|
||||||
pub async fn create_known_device(&self, input: KnownDeviceInput) -> Result<KnownDevice> {
|
pub async fn create_known_device(&self, input: KnownDeviceInput) -> Result<KnownDevice> {
|
||||||
let display_name = normalize_required_text(&input.display_name, "display_name")?;
|
let display_name = normalize_required_text(&input.display_name, "display_name")?;
|
||||||
let notes = normalize_optional_text(input.notes.as_deref());
|
let notes = normalize_optional_text(input.notes.as_deref());
|
||||||
@@ -36,11 +37,13 @@ impl Store {
|
|||||||
tx.commit()
|
tx.commit()
|
||||||
.await
|
.await
|
||||||
.context("failed committing known device transaction")?;
|
.context("failed committing known device transaction")?;
|
||||||
|
self.touch_fleet();
|
||||||
self.get_known_device(&device_id)
|
self.get_known_device(&device_id)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| anyhow::anyhow!("created known device disappeared"))
|
.ok_or_else(|| anyhow::anyhow!("created known device disappeared"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(name = "db_list_known_devices", skip_all)]
|
||||||
pub async fn list_known_devices(&self) -> Result<Vec<KnownDevice>> {
|
pub async fn list_known_devices(&self) -> Result<Vec<KnownDevice>> {
|
||||||
let rows = list_known_device_rows(&self.pool).await?;
|
let rows = list_known_device_rows(&self.pool).await?;
|
||||||
|
|
||||||
@@ -52,6 +55,7 @@ impl Store {
|
|||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(name = "db_get_known_device", skip_all)]
|
||||||
pub async fn get_known_device(&self, device_id: &str) -> Result<Option<KnownDevice>> {
|
pub async fn get_known_device(&self, device_id: &str) -> Result<Option<KnownDevice>> {
|
||||||
let row = get_known_device_row(&self.pool, device_id).await?;
|
let row = get_known_device_row(&self.pool, device_id).await?;
|
||||||
|
|
||||||
@@ -61,14 +65,43 @@ impl Store {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(name = "db_rename_known_device", skip_all)]
|
||||||
|
pub async fn rename_known_device(
|
||||||
|
&self,
|
||||||
|
device_id: &str,
|
||||||
|
display_name: &str,
|
||||||
|
) -> Result<Option<KnownDevice>> {
|
||||||
|
let display_name = normalize_required_text(display_name, "display_name")?;
|
||||||
|
let now = i64::try_from(now_unix()).context("known device timestamp overflow")?;
|
||||||
|
let result = sqlx::query!(
|
||||||
|
"UPDATE known_devices SET display_name = ?1, updated_at_unix = ?2 WHERE device_id = ?3",
|
||||||
|
display_name,
|
||||||
|
now,
|
||||||
|
device_id
|
||||||
|
)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.context("failed renaming known device")?;
|
||||||
|
if result.rows_affected() == 0 {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
self.touch_fleet();
|
||||||
|
self.get_known_device(device_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(name = "db_forget_known_device", skip_all)]
|
||||||
pub async fn forget_known_device(&self, device_id: &str) -> Result<bool> {
|
pub async fn forget_known_device(&self, device_id: &str) -> Result<bool> {
|
||||||
let result = sqlx::query!("DELETE FROM known_devices WHERE device_id = ?1", device_id)
|
let result = sqlx::query!("DELETE FROM known_devices WHERE device_id = ?1", device_id)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
.context("failed deleting known device")?;
|
.context("failed deleting known device")?;
|
||||||
|
if result.rows_affected() > 0 {
|
||||||
|
self.touch_fleet();
|
||||||
|
}
|
||||||
Ok(result.rows_affected() > 0)
|
Ok(result.rows_affected() > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(name = "db_merge_known_devices", skip_all)]
|
||||||
pub async fn merge_known_devices(
|
pub async fn merge_known_devices(
|
||||||
&self,
|
&self,
|
||||||
target_device_id: &str,
|
target_device_id: &str,
|
||||||
@@ -131,9 +164,11 @@ impl Store {
|
|||||||
tx.commit()
|
tx.commit()
|
||||||
.await
|
.await
|
||||||
.context("failed committing known device merge transaction")?;
|
.context("failed committing known device merge transaction")?;
|
||||||
|
self.touch_fleet();
|
||||||
self.get_known_device(target_device_id).await
|
self.get_known_device(target_device_id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(name = "db_attach_device_identifier", skip_all)]
|
||||||
pub async fn attach_device_identifier(
|
pub async fn attach_device_identifier(
|
||||||
&self,
|
&self,
|
||||||
device_id: &str,
|
device_id: &str,
|
||||||
@@ -166,9 +201,11 @@ impl Store {
|
|||||||
tx.commit()
|
tx.commit()
|
||||||
.await
|
.await
|
||||||
.context("failed committing device identifier transaction")?;
|
.context("failed committing device identifier transaction")?;
|
||||||
|
self.touch_fleet();
|
||||||
self.get_known_device(device_id).await
|
self.get_known_device(device_id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(name = "db_detach_device_identifier", skip_all)]
|
||||||
pub async fn detach_device_identifier(
|
pub async fn detach_device_identifier(
|
||||||
&self,
|
&self,
|
||||||
device_id: &str,
|
device_id: &str,
|
||||||
@@ -207,6 +244,7 @@ impl Store {
|
|||||||
tx.commit()
|
tx.commit()
|
||||||
.await
|
.await
|
||||||
.context("failed committing device identifier detach transaction")?;
|
.context("failed committing device identifier detach transaction")?;
|
||||||
|
self.touch_fleet();
|
||||||
self.get_known_device(device_id).await
|
self.get_known_device(device_id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ impl Store {
|
|||||||
tx.commit()
|
tx.commit()
|
||||||
.await
|
.await
|
||||||
.context("failed committing state db after enroll")?;
|
.context("failed committing state db after enroll")?;
|
||||||
|
self.touch_fleet();
|
||||||
info!(agent_id = %agent_id, "issued persistent agent credentials");
|
info!(agent_id = %agent_id, "issued persistent agent credentials");
|
||||||
|
|
||||||
Ok(IssuedAgent {
|
Ok(IssuedAgent {
|
||||||
@@ -113,6 +114,9 @@ impl Store {
|
|||||||
tx.commit()
|
tx.commit()
|
||||||
.await
|
.await
|
||||||
.context("failed committing db after agent revoke")?;
|
.context("failed committing db after agent revoke")?;
|
||||||
|
if result.rows_affected() > 0 {
|
||||||
|
self.touch_fleet();
|
||||||
|
}
|
||||||
Ok(result.rows_affected() > 0)
|
Ok(result.rows_affected() > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,6 +150,7 @@ impl Store {
|
|||||||
.context("failed clearing agent nickname")?;
|
.context("failed clearing agent nickname")?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self.touch_fleet();
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -218,6 +218,24 @@ pub struct AgentDeviceWithChildren {
|
|||||||
pub facts: Vec<String>,
|
pub facts: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl AgentDeviceWithChildren {
|
||||||
|
/// Last-observed presence, not a claim of current reachability or freshness.
|
||||||
|
pub fn presence(&self) -> Presence {
|
||||||
|
endpoint_presence(&self.endpoints, self.device.presence())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Endpoints are authoritative when present; endpoint-less legacy rows retain
|
||||||
|
/// their summary until a new snapshot replaces them. Never merge the cached
|
||||||
|
/// summary into endpoint evidence, as it may contain a contradictory signal.
|
||||||
|
pub(crate) fn endpoint_presence(endpoints: &[DeviceEndpoint], fallback: Presence) -> Presence {
|
||||||
|
endpoints
|
||||||
|
.iter()
|
||||||
|
.map(|endpoint| endpoint.presence)
|
||||||
|
.max()
|
||||||
|
.unwrap_or(fallback)
|
||||||
|
}
|
||||||
|
|
||||||
fn endpoint_source_from_str(raw: &str) -> Option<EndpointSource> {
|
fn endpoint_source_from_str(raw: &str) -> Option<EndpointSource> {
|
||||||
match raw {
|
match raw {
|
||||||
"neighbor" => Some(EndpointSource::Neighbor),
|
"neighbor" => Some(EndpointSource::Neighbor),
|
||||||
|
|||||||
@@ -1,99 +1,19 @@
|
|||||||
use anyhow::{Context, Result};
|
|
||||||
use opentelemetry::global;
|
|
||||||
use opentelemetry::trace::TracerProvider as _;
|
|
||||||
use opentelemetry_otlp::WithExportConfig;
|
|
||||||
use opentelemetry_sdk::Resource;
|
|
||||||
use opentelemetry_sdk::trace::{SdkTracerProvider, Tracer};
|
|
||||||
use time::UtcOffset;
|
|
||||||
use time::format_description::well_known::Rfc3339;
|
|
||||||
use tracing_subscriber::fmt::time::OffsetTime;
|
|
||||||
use tracing_subscriber::{EnvFilter, fmt, layer::SubscriberExt, util::SubscriberInitExt};
|
|
||||||
|
|
||||||
use crate::config::TelemetryConfig;
|
use crate::config::TelemetryConfig;
|
||||||
|
use anyhow::Result;
|
||||||
|
pub use wakey_core::telemetry::TelemetryGuard;
|
||||||
|
|
||||||
pub fn init(verbose: u8, telemetry: &TelemetryConfig) -> Result<()> {
|
pub fn init(verbose: u8, telemetry: &TelemetryConfig) -> Result<TelemetryGuard> {
|
||||||
let filter = EnvFilter::try_from_default_env()
|
let guard = wakey_core::telemetry::init(
|
||||||
.or_else(|_| EnvFilter::try_new(default_filter(verbose)))
|
telemetry,
|
||||||
.expect("static tracing filter should parse");
|
"wakey-control-plane",
|
||||||
|
|
||||||
let otel = build_otel_layer(telemetry)?;
|
|
||||||
|
|
||||||
if telemetry.json_logs {
|
|
||||||
if let Some(otel_layer) = otel {
|
|
||||||
tracing_subscriber::registry()
|
|
||||||
.with(otel_layer)
|
|
||||||
.with(filter)
|
|
||||||
.with(fmt::layer().json().with_timer(local_offset_timer()))
|
|
||||||
.init();
|
|
||||||
tracing::info!(endpoint = ?telemetry.otlp_endpoint, json_logs = telemetry.json_logs, "tracing initialized with otlp exporter");
|
|
||||||
} else {
|
|
||||||
tracing_subscriber::registry()
|
|
||||||
.with(filter)
|
|
||||||
.with(fmt::layer().json().with_timer(local_offset_timer()))
|
|
||||||
.init();
|
|
||||||
tracing::info!(
|
|
||||||
json_logs = telemetry.json_logs,
|
|
||||||
"tracing initialized without otlp exporter"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else if let Some(otel_layer) = otel {
|
|
||||||
tracing_subscriber::registry()
|
|
||||||
.with(otel_layer)
|
|
||||||
.with(filter)
|
|
||||||
.with(fmt::layer().with_timer(local_offset_timer()))
|
|
||||||
.init();
|
|
||||||
tracing::info!(endpoint = ?telemetry.otlp_endpoint, json_logs = telemetry.json_logs, "tracing initialized with otlp exporter");
|
|
||||||
} else {
|
|
||||||
tracing_subscriber::registry()
|
|
||||||
.with(filter)
|
|
||||||
.with(fmt::layer().with_timer(local_offset_timer()))
|
|
||||||
.init();
|
|
||||||
tracing::info!(
|
|
||||||
json_logs = telemetry.json_logs,
|
|
||||||
"tracing initialized without otlp exporter"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_otel_layer(
|
|
||||||
telemetry: &TelemetryConfig,
|
|
||||||
) -> Result<Option<tracing_opentelemetry::OpenTelemetryLayer<tracing_subscriber::Registry, Tracer>>>
|
|
||||||
{
|
|
||||||
let Some(endpoint) = telemetry.otlp_endpoint.as_deref() else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
|
|
||||||
let exporter = opentelemetry_otlp::SpanExporter::builder()
|
|
||||||
.with_tonic()
|
|
||||||
.with_endpoint(endpoint)
|
|
||||||
.build()
|
|
||||||
.context("failed to build OTLP span exporter")?;
|
|
||||||
|
|
||||||
let provider = SdkTracerProvider::builder()
|
|
||||||
.with_batch_exporter(exporter)
|
|
||||||
.with_resource(
|
|
||||||
Resource::builder_empty()
|
|
||||||
.with_service_name(telemetry.service_name.clone())
|
|
||||||
.build(),
|
|
||||||
)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
let tracer = provider.tracer(telemetry.service_name.clone());
|
|
||||||
global::set_tracer_provider(provider);
|
|
||||||
Ok(Some(tracing_opentelemetry::layer().with_tracer(tracer)))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_filter(verbose: u8) -> &'static str {
|
|
||||||
match verbose {
|
match verbose {
|
||||||
0 => "wakey_control_plane=info",
|
0 => "wakey_control_plane=info,wakey_core=info,opentelemetry=warn",
|
||||||
1 => "wakey_control_plane=debug",
|
1 => "wakey_control_plane=debug,wakey_core=info,opentelemetry=warn",
|
||||||
_ => "wakey_control_plane=trace",
|
_ => "wakey_control_plane=trace,wakey_core=info,opentelemetry=warn",
|
||||||
}
|
},
|
||||||
}
|
None,
|
||||||
|
)?;
|
||||||
fn local_offset_timer() -> OffsetTime<Rfc3339> {
|
#[cfg(target_os = "linux")]
|
||||||
let offset = UtcOffset::current_local_offset().unwrap_or(UtcOffset::UTC);
|
let guard = guard.with_process_metrics(wakey_linux::telemetry::ProcfsProcessMetrics);
|
||||||
OffsetTime::new(offset, Rfc3339)
|
Ok(guard)
|
||||||
}
|
}
|
||||||
@@ -6,7 +6,7 @@ use futures_util::{SinkExt, StreamExt};
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use tracing::{debug, info, info_span, warn};
|
use tracing::{Instrument, debug, info, info_span, warn};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use wakey_agent::protocol::{
|
use wakey_agent::protocol::{
|
||||||
AgentCapability, AgentCapabilityOptions, AgentTerminalSession, DEFAULT_TERMINAL_MAX_SESSIONS,
|
AgentCapability, AgentCapabilityOptions, AgentTerminalSession, DEFAULT_TERMINAL_MAX_SESSIONS,
|
||||||
@@ -38,6 +38,8 @@ enum IncomingClientMessage {
|
|||||||
DeviceSnapshot {
|
DeviceSnapshot {
|
||||||
agent_id: String,
|
agent_id: String,
|
||||||
devices: Vec<Device>,
|
devices: Vec<Device>,
|
||||||
|
#[serde(default)]
|
||||||
|
trace_context: std::collections::BTreeMap<String, String>,
|
||||||
},
|
},
|
||||||
Result {
|
Result {
|
||||||
request_id: RequestId,
|
request_id: RequestId,
|
||||||
@@ -71,9 +73,9 @@ pub async fn agent_ws(ws: WebSocketUpgrade, State(state): State<AppState>) -> im
|
|||||||
|
|
||||||
async fn handle_agent_socket(state: AppState, socket: WebSocket) {
|
async fn handle_agent_socket(state: AppState, socket: WebSocket) {
|
||||||
let connection_id = Uuid::new_v4().to_string();
|
let connection_id = Uuid::new_v4().to_string();
|
||||||
let span = info_span!("agent_ws_connection", connection_id = %connection_id);
|
// Lifecycle events are available immediately; do not hold an entered span
|
||||||
let _span_guard = span.enter();
|
// across socket awaits or wait for disconnect to export the connection.
|
||||||
info!("agent websocket upgraded");
|
info!(%connection_id, "agent websocket upgraded");
|
||||||
let connected_at = Instant::now();
|
let connected_at = Instant::now();
|
||||||
|
|
||||||
let (mut write, mut read) = socket.split();
|
let (mut write, mut read) = socket.split();
|
||||||
@@ -111,11 +113,11 @@ async fn handle_agent_socket(state: AppState, socket: WebSocket) {
|
|||||||
let msg = match frame {
|
let msg = match frame {
|
||||||
Some(Ok(msg)) => msg,
|
Some(Ok(msg)) => msg,
|
||||||
Some(Err(err)) => {
|
Some(Err(err)) => {
|
||||||
warn!(error = %err, "agent websocket receive error");
|
warn!(%connection_id, error = %err, "agent websocket receive error");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
info!("agent websocket stream ended by peer");
|
info!(%connection_id, "agent websocket stream ended by peer");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -132,14 +134,14 @@ async fn handle_agent_socket(state: AppState, socket: WebSocket) {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
warn!(error = %err, "closing agent websocket due to protocol/auth error");
|
warn!(%connection_id, error = %err, "closing agent websocket due to protocol/auth error");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Message::Ping(_) => {}
|
Message::Ping(_) => {}
|
||||||
Message::Pong(_) => {}
|
Message::Pong(_) => {}
|
||||||
Message::Close(frame) => {
|
Message::Close(frame) => {
|
||||||
info!(close = ?frame, "agent websocket close frame received");
|
info!(%connection_id, close = ?frame, "agent websocket close frame received");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
Message::Binary(_) => {
|
Message::Binary(_) => {
|
||||||
@@ -149,7 +151,7 @@ async fn handle_agent_socket(state: AppState, socket: WebSocket) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Some(agent_id) = connection.authed_agent_id {
|
if let Some(agent_id) = connection.authed_agent_id {
|
||||||
info!(agent_id = %agent_id, "agent disconnected");
|
info!(%connection_id, agent_id = %agent_id, connected_ms = now_duration_ms(connected_at.elapsed()), "agent disconnected");
|
||||||
let mut sessions = state.sessions.write().await;
|
let mut sessions = state.sessions.write().await;
|
||||||
let should_remove = sessions
|
let should_remove = sessions
|
||||||
.get(&agent_id)
|
.get(&agent_id)
|
||||||
@@ -245,6 +247,7 @@ async fn process_agent_text(
|
|||||||
if !state
|
if !state
|
||||||
.store
|
.store
|
||||||
.verify_agent_token(&agent_id, &agent_token)
|
.verify_agent_token(&agent_id, &agent_token)
|
||||||
|
.instrument(info_span!("agent_auth_verify", agent_id = %agent_id, %connection_id))
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
warn!(agent_id = %agent_id, "agent auth rejected");
|
warn!(agent_id = %agent_id, "agent auth rejected");
|
||||||
@@ -304,17 +307,30 @@ async fn process_agent_text(
|
|||||||
ensure_current_session(state, &agent_id, connection_id).await?;
|
ensure_current_session(state, &agent_id, connection_id).await?;
|
||||||
debug!(agent_id = %agent_id, "heartbeat received");
|
debug!(agent_id = %agent_id, "heartbeat received");
|
||||||
}
|
}
|
||||||
IncomingClientMessage::DeviceSnapshot { agent_id, devices } => {
|
IncomingClientMessage::DeviceSnapshot {
|
||||||
|
agent_id,
|
||||||
|
devices,
|
||||||
|
trace_context,
|
||||||
|
} => {
|
||||||
if connection.authed_agent_id.as_deref() != Some(agent_id.as_str()) {
|
if connection.authed_agent_id.as_deref() != Some(agent_id.as_str()) {
|
||||||
anyhow::bail!("device_snapshot for unauthenticated or mismatched agent");
|
anyhow::bail!("device_snapshot for unauthenticated or mismatched agent");
|
||||||
}
|
}
|
||||||
ensure_current_session(state, &agent_id, connection_id).await?;
|
ensure_current_session(state, &agent_id, connection_id).await?;
|
||||||
match state
|
let mut operation = wakey_core::telemetry::Operation::new("snapshot_store");
|
||||||
|
let span = tracing::info_span!("store_device_snapshot", agent_id = %agent_id, devices = devices.len());
|
||||||
|
// Continue collection's trace rather than parenting persistence to
|
||||||
|
// the long-lived agent connection. Empty/invalid context is ignored.
|
||||||
|
wakey_core::telemetry::set_remote_parent(&span, &trace_context);
|
||||||
|
match tracing::Instrument::instrument(
|
||||||
|
state
|
||||||
.store
|
.store
|
||||||
.replace_agent_device_snapshot(&agent_id, &devices)
|
.replace_agent_device_snapshot(&agent_id, &devices),
|
||||||
|
span,
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(accepted) => {
|
Ok(accepted) => {
|
||||||
|
operation.outcome("ok");
|
||||||
debug!(agent_id = %agent_id, accepted, "agent websocket device snapshot accepted");
|
debug!(agent_id = %agent_id, accepted, "agent websocket device snapshot accepted");
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
@@ -450,6 +466,42 @@ mod tests {
|
|||||||
AgentConnectionState, IncomingClientMessage, is_current_session, validate_auth_identity,
|
AgentConnectionState, IncomingClientMessage, is_current_session, validate_auth_identity,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn device_snapshot_trace_context_is_optional_and_cross_crate_compatible() {
|
||||||
|
let legacy: IncomingClientMessage =
|
||||||
|
serde_json::from_str(r#"{"type":"device_snapshot","agent_id":"agent-a","devices":[]}"#)
|
||||||
|
.unwrap();
|
||||||
|
let IncomingClientMessage::DeviceSnapshot { trace_context, .. } = legacy else {
|
||||||
|
panic!("expected snapshot");
|
||||||
|
};
|
||||||
|
assert!(trace_context.is_empty());
|
||||||
|
|
||||||
|
let expected = [(
|
||||||
|
"traceparent".to_string(),
|
||||||
|
"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01".to_string(),
|
||||||
|
)]
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
|
let message = wakey_agent::protocol::ClientMessage::DeviceSnapshot {
|
||||||
|
agent_id: "agent-a".into(),
|
||||||
|
devices: vec![],
|
||||||
|
trace_context: expected,
|
||||||
|
};
|
||||||
|
let IncomingClientMessage::DeviceSnapshot {
|
||||||
|
agent_id,
|
||||||
|
trace_context,
|
||||||
|
..
|
||||||
|
} = serde_json::from_str(&serde_json::to_string(&message).unwrap()).unwrap()
|
||||||
|
else {
|
||||||
|
panic!("expected snapshot");
|
||||||
|
};
|
||||||
|
assert_eq!(agent_id, "agent-a");
|
||||||
|
assert_eq!(
|
||||||
|
trace_context["traceparent"],
|
||||||
|
"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn current_session_check_rejects_stale_connection_ids() {
|
fn current_session_check_rejects_stale_connection_ids() {
|
||||||
let (tx, _rx) = mpsc::unbounded_channel();
|
let (tx, _rx) = mpsc::unbounded_channel();
|
||||||
|
|||||||
+20
-1
@@ -1,15 +1,34 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "wakey-core"
|
name = "wakey-core"
|
||||||
version = "0.3.6"
|
version = "0.3.7"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
publish = ["gitea"]
|
publish = ["gitea"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
anyhow = { workspace = true, optional = true }
|
||||||
|
opentelemetry = { version = "0.33", optional = true }
|
||||||
|
opentelemetry-otlp = { version = "0.33", default-features = false, features = ["grpc-tonic", "trace", "logs", "metrics"], optional = true }
|
||||||
|
opentelemetry_sdk = { version = "0.33", optional = true }
|
||||||
|
opentelemetry-appender-tracing = { version = "0.33", optional = true }
|
||||||
|
tracing = { workspace = true, optional = true }
|
||||||
|
tracing-opentelemetry = { version = "0.34", optional = true }
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "json", "time"], optional = true }
|
||||||
|
time = { version = "0.3", features = ["formatting", "local-offset"], optional = true }
|
||||||
|
tonic = { version = "0.14", default-features = false, optional = true }
|
||||||
macaddr = { workspace = true }
|
macaddr = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
humantime = "2"
|
||||||
serde_with = { version = "3", features = ["json"] }
|
serde_with = { version = "3", features = ["json"] }
|
||||||
strum = { version = "0", features = ["derive", "strum_macros"] }
|
strum = { version = "0", features = ["derive", "strum_macros"] }
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
telemetry = ["dep:anyhow", "dep:opentelemetry", "dep:opentelemetry-otlp", "dep:opentelemetry_sdk", "dep:opentelemetry-appender-tracing", "dep:tracing", "dep:tracing-opentelemetry", "dep:tracing-subscriber", "dep:time", "dep:tonic"]
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "sync", "time"] }
|
||||||
|
tokio-stream = { version = "0.1", features = ["net"] }
|
||||||
|
tonic = "0.14"
|
||||||
|
opentelemetry-proto = { version = "0.33", default-features = false, features = ["gen-tonic", "trace", "logs", "metrics"] }
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
//! Human-readable config durations and the unit-aware legacy key adapter.
|
||||||
|
//! Protocol numbers and metric units deliberately do not use this adapter.
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Deserializer, de::Error};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub struct LegacyDuration {
|
||||||
|
pub old: &'static str,
|
||||||
|
pub new: &'static str,
|
||||||
|
pub unit: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Normalize one config table before normal serde decoding. The returned keys
|
||||||
|
/// let loaders warn only after validation, even before tracing is initialized.
|
||||||
|
pub fn normalize(
|
||||||
|
value: &mut Value,
|
||||||
|
keys: &[LegacyDuration],
|
||||||
|
) -> Result<Vec<LegacyDuration>, String> {
|
||||||
|
let table = value.as_object_mut().ok_or("expected a config table")?;
|
||||||
|
let mut deprecated = Vec::new();
|
||||||
|
for key in keys {
|
||||||
|
if table.contains_key(key.old) && table.contains_key(key.new) {
|
||||||
|
return Err(format!(
|
||||||
|
"configure only one of `{}` and `{}`",
|
||||||
|
key.old, key.new
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Some(old) = table.remove(key.old) {
|
||||||
|
let duration = if let Some(number) = old.as_u64() {
|
||||||
|
// u64 multiplication must not wrap for large legacy day values.
|
||||||
|
let nanos = key
|
||||||
|
.unit
|
||||||
|
.as_nanos()
|
||||||
|
.checked_mul(u128::from(number))
|
||||||
|
.ok_or_else(|| format!("`{}` is too large", key.old))?;
|
||||||
|
let seconds = u64::try_from(nanos / 1_000_000_000)
|
||||||
|
.map_err(|_| format!("`{}` is too large", key.old))?;
|
||||||
|
Duration::new(seconds, (nanos % 1_000_000_000) as u32)
|
||||||
|
} else if let Some(text) = old.as_str() {
|
||||||
|
humantime::parse_duration(text).map_err(|err| format!("`{}`: {err}", key.old))?
|
||||||
|
} else {
|
||||||
|
return Err(format!(
|
||||||
|
"`{}` requires a non-negative integer or duration string",
|
||||||
|
key.old
|
||||||
|
));
|
||||||
|
};
|
||||||
|
table.insert(
|
||||||
|
key.new.into(),
|
||||||
|
Value::String(humantime::format_duration(duration).to_string()),
|
||||||
|
);
|
||||||
|
deprecated.push(*key);
|
||||||
|
}
|
||||||
|
if let Some(value) = table.get(key.new) {
|
||||||
|
let text = value
|
||||||
|
.as_str()
|
||||||
|
.ok_or_else(|| format!("`{}` requires a duration string with units", key.new))?;
|
||||||
|
humantime::parse_duration(text).map_err(|err| format!("`{}`: {err}", key.new))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(deprecated)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode through the canonical serde definition after translating old keys.
|
||||||
|
/// The callback is a serde-generated associated function (`remote = "Self"`),
|
||||||
|
/// avoiding a second copy of every unrelated config field in a legacy DTO.
|
||||||
|
pub fn deserialize_legacy<'de, D, T>(
|
||||||
|
deserializer: D,
|
||||||
|
keys: &[LegacyDuration],
|
||||||
|
decode: impl FnOnce(Value) -> Result<T, serde_json::Error>,
|
||||||
|
) -> Result<T, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let mut value = Value::deserialize(deserializer)?;
|
||||||
|
normalize(&mut value, keys).map_err(D::Error::custom)?;
|
||||||
|
decode(value).map_err(D::Error::custom)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
const KEYS: &[LegacyDuration] = &[LegacyDuration {
|
||||||
|
old: "interval_ms",
|
||||||
|
new: "interval",
|
||||||
|
unit: Duration::from_millis(1),
|
||||||
|
}];
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_units_are_exact_and_conflicts_are_rejected() {
|
||||||
|
let mut value = serde_json::json!({"interval_ms": 1234});
|
||||||
|
assert_eq!(normalize(&mut value, KEYS).unwrap().len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
humantime::parse_duration(value["interval"].as_str().unwrap()).unwrap(),
|
||||||
|
Duration::from_millis(1234)
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
normalize(
|
||||||
|
&mut serde_json::json!({"interval_ms": 1, "interval": "1s"}),
|
||||||
|
KEYS
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
for invalid in [
|
||||||
|
serde_json::json!({"interval": 5}),
|
||||||
|
serde_json::json!({"interval_ms": -1}),
|
||||||
|
serde_json::json!({"interval": "potato"}),
|
||||||
|
] {
|
||||||
|
assert!(normalize(&mut invalid.clone(), KEYS).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
//! Shared domain types and parsing for DHCP, neighbors, interfaces, devices, and wake flows.
|
//! Shared domain types and parsing for DHCP, neighbors, interfaces, devices, and wake flows.
|
||||||
|
|
||||||
|
pub mod config_duration;
|
||||||
pub mod model;
|
pub mod model;
|
||||||
pub mod parse;
|
pub mod parse;
|
||||||
|
|
||||||
|
#[cfg(feature = "telemetry")]
|
||||||
|
pub mod telemetry;
|
||||||
|
|
||||||
pub use model::*;
|
pub use model::*;
|
||||||
@@ -0,0 +1,578 @@
|
|||||||
|
//! Shared, opt-in OTLP setup. Local logging remains available without a collector.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use opentelemetry::{
|
||||||
|
KeyValue, global,
|
||||||
|
trace::{TraceContextExt as _, TracerProvider as _},
|
||||||
|
};
|
||||||
|
use opentelemetry_otlp::WithExportConfig;
|
||||||
|
use opentelemetry_otlp::WithTonicConfig;
|
||||||
|
use opentelemetry_sdk::{
|
||||||
|
Resource, logs::SdkLoggerProvider, metrics::SdkMeterProvider, trace::SdkTracerProvider,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tracing_opentelemetry::OpenTelemetrySpanExt;
|
||||||
|
use tracing_subscriber::{EnvFilter, Layer, fmt, layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
|
|
||||||
|
/// A single gRPC receiver accepts all three signals. No endpoint means local-only.
|
||||||
|
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct TelemetryConfig {
|
||||||
|
pub otlp_endpoint: Option<String>,
|
||||||
|
/// Name of the environment variable holding the complete Authorization header.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub authorization_env: Option<String>,
|
||||||
|
pub service_name: String,
|
||||||
|
pub json_logs: bool,
|
||||||
|
/// Explicit per-signal filters override RUST_LOG and CLI verbosity.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub console_filter: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub otlp_log_filter: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub trace_filter: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Owns providers until all instrumented work has stopped; flush off the async workers.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct TelemetryGuard {
|
||||||
|
traces: Option<SdkTracerProvider>,
|
||||||
|
logs: Option<SdkLoggerProvider>,
|
||||||
|
metrics: Option<SdkMeterProvider>,
|
||||||
|
process: Option<process::ProcessMetrics>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TelemetryGuard {
|
||||||
|
/// Attach platform measurements only when OTLP metrics are enabled. Core
|
||||||
|
/// owns instruments; the supplied reader owns all OS-specific collection.
|
||||||
|
pub fn with_process_metrics(mut self, source: impl ProcessMetricsSource) -> Self {
|
||||||
|
if let Some(metrics) = &self.metrics {
|
||||||
|
use opentelemetry::metrics::MeterProvider as _;
|
||||||
|
self.process = Some(process::ProcessMetrics::new(
|
||||||
|
&metrics.meter("wakey"),
|
||||||
|
source,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn shutdown(self) {
|
||||||
|
// Export failures must not turn an otherwise successful daemon exit into a failure.
|
||||||
|
if let Some(provider) = self.metrics {
|
||||||
|
let _ = provider.shutdown();
|
||||||
|
}
|
||||||
|
if let Some(provider) = self.logs {
|
||||||
|
let _ = provider.shutdown();
|
||||||
|
}
|
||||||
|
if let Some(provider) = self.traces {
|
||||||
|
let _ = provider.shutdown();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn init(
|
||||||
|
config: &TelemetryConfig,
|
||||||
|
service: &str,
|
||||||
|
default_filter: &str,
|
||||||
|
instance: Option<&str>,
|
||||||
|
) -> Result<TelemetryGuard> {
|
||||||
|
let rust_log = std::env::var("RUST_LOG").ok();
|
||||||
|
let console_filter = signal_filter(
|
||||||
|
config.console_filter.as_deref(),
|
||||||
|
rust_log.as_deref(),
|
||||||
|
default_filter,
|
||||||
|
)?;
|
||||||
|
let service = if config.service_name.is_empty() {
|
||||||
|
service
|
||||||
|
} else {
|
||||||
|
&config.service_name
|
||||||
|
};
|
||||||
|
let mut guard = TelemetryGuard::default();
|
||||||
|
if let Some(endpoint) = config
|
||||||
|
.otlp_endpoint
|
||||||
|
.as_deref()
|
||||||
|
.filter(|s| !s.trim().is_empty())
|
||||||
|
{
|
||||||
|
// Filters belong to individual layers: a quiet console must not globally
|
||||||
|
// disable debug events needed by the OTLP log exporter.
|
||||||
|
let trace_filter = signal_filter(
|
||||||
|
config.trace_filter.as_deref(),
|
||||||
|
rust_log.as_deref(),
|
||||||
|
default_filter,
|
||||||
|
)?;
|
||||||
|
let log_filter = signal_filter(
|
||||||
|
config.otlp_log_filter.as_deref(),
|
||||||
|
rust_log.as_deref(),
|
||||||
|
default_filter,
|
||||||
|
)?;
|
||||||
|
let metadata = authorization_metadata(config.authorization_env.as_deref(), |name| {
|
||||||
|
std::env::var(name).ok()
|
||||||
|
})?;
|
||||||
|
let mut resource = Resource::builder_empty().with_service_name(service.to_owned());
|
||||||
|
if let Some(instance) = instance {
|
||||||
|
resource =
|
||||||
|
resource.with_attribute(KeyValue::new("service.instance.id", instance.to_owned()));
|
||||||
|
}
|
||||||
|
let resource = resource.build();
|
||||||
|
// Small fixed queues are intentional: a disconnected collector cannot grow router RAM.
|
||||||
|
// Exporters own transient retries/backoff; authentication failures are
|
||||||
|
// not retried. The timeout remains their budget, not a durable outbox.
|
||||||
|
let trace_exporter = opentelemetry_otlp::SpanExporter::builder()
|
||||||
|
.with_tonic()
|
||||||
|
.with_metadata(metadata.clone())
|
||||||
|
.with_endpoint(endpoint)
|
||||||
|
.with_timeout(Duration::from_secs(3))
|
||||||
|
.with_retry_policy(opentelemetry_otlp::RetryPolicy::recommended())
|
||||||
|
.build()
|
||||||
|
.context("OTLP trace exporter")?;
|
||||||
|
let trace_processor = opentelemetry_sdk::trace::BatchSpanProcessor::builder(trace_exporter)
|
||||||
|
.with_batch_config(
|
||||||
|
opentelemetry_sdk::trace::BatchConfigBuilder::default()
|
||||||
|
.with_max_queue_size(256)
|
||||||
|
.with_max_export_batch_size(64)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.build();
|
||||||
|
let traces = SdkTracerProvider::builder()
|
||||||
|
.with_span_processor(trace_processor)
|
||||||
|
.with_resource(resource.clone())
|
||||||
|
.build();
|
||||||
|
let log_exporter = opentelemetry_otlp::LogExporter::builder()
|
||||||
|
.with_tonic()
|
||||||
|
.with_metadata(metadata.clone())
|
||||||
|
.with_endpoint(endpoint)
|
||||||
|
.with_timeout(Duration::from_secs(3))
|
||||||
|
.with_retry_policy(opentelemetry_otlp::RetryPolicy::recommended())
|
||||||
|
.build()
|
||||||
|
.context("OTLP log exporter")?;
|
||||||
|
let log_processor = opentelemetry_sdk::logs::BatchLogProcessor::builder(log_exporter)
|
||||||
|
.with_batch_config(
|
||||||
|
opentelemetry_sdk::logs::BatchConfigBuilder::default()
|
||||||
|
.with_max_queue_size(256)
|
||||||
|
.with_max_export_batch_size(64)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.build();
|
||||||
|
let logs = SdkLoggerProvider::builder()
|
||||||
|
.with_log_processor(log_processor)
|
||||||
|
.with_resource(resource.clone())
|
||||||
|
.build();
|
||||||
|
let metric_exporter = opentelemetry_otlp::MetricExporter::builder()
|
||||||
|
.with_tonic()
|
||||||
|
.with_metadata(metadata)
|
||||||
|
.with_endpoint(endpoint)
|
||||||
|
.with_timeout(Duration::from_secs(3))
|
||||||
|
.with_retry_policy(opentelemetry_otlp::RetryPolicy::recommended())
|
||||||
|
.build()
|
||||||
|
.context("OTLP metric exporter")?;
|
||||||
|
let reader = opentelemetry_sdk::metrics::PeriodicReader::builder(metric_exporter)
|
||||||
|
.with_interval(Duration::from_secs(30))
|
||||||
|
.build();
|
||||||
|
let metrics = SdkMeterProvider::builder()
|
||||||
|
.with_reader(reader)
|
||||||
|
.with_resource(resource)
|
||||||
|
.build();
|
||||||
|
global::set_tracer_provider(traces.clone());
|
||||||
|
global::set_meter_provider(metrics.clone());
|
||||||
|
let _ = METRICS.set(Instruments::new());
|
||||||
|
let otel = tracing_opentelemetry::layer()
|
||||||
|
.with_tracer(traces.tracer(service.to_owned()))
|
||||||
|
.with_filter(trace_filter)
|
||||||
|
.with_filter(tracing_subscriber::filter::filter_fn(not_exporter_internal));
|
||||||
|
// Apply the same exclusion to both OTLP layers. Console filtering stays
|
||||||
|
// independent so exporter and transport diagnostics remain available.
|
||||||
|
let logs_layer =
|
||||||
|
opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge::new(&logs)
|
||||||
|
.with_filter(log_filter)
|
||||||
|
.with_filter(tracing_subscriber::filter::filter_fn(not_exporter_internal));
|
||||||
|
let subscriber = tracing_subscriber::registry().with(otel).with(logs_layer);
|
||||||
|
if config.json_logs {
|
||||||
|
subscriber
|
||||||
|
.with(
|
||||||
|
fmt::layer()
|
||||||
|
.json()
|
||||||
|
.with_timer(local_timer())
|
||||||
|
.with_filter(console_filter),
|
||||||
|
)
|
||||||
|
.try_init()?;
|
||||||
|
} else {
|
||||||
|
subscriber
|
||||||
|
.with(
|
||||||
|
fmt::layer()
|
||||||
|
.with_timer(local_timer())
|
||||||
|
.with_filter(console_filter),
|
||||||
|
)
|
||||||
|
.try_init()?;
|
||||||
|
}
|
||||||
|
guard = TelemetryGuard {
|
||||||
|
traces: Some(traces),
|
||||||
|
logs: Some(logs),
|
||||||
|
metrics: Some(metrics),
|
||||||
|
process: None,
|
||||||
|
};
|
||||||
|
} else if config.json_logs {
|
||||||
|
tracing_subscriber::registry()
|
||||||
|
.with(
|
||||||
|
fmt::layer()
|
||||||
|
.json()
|
||||||
|
.with_timer(local_timer())
|
||||||
|
.with_filter(console_filter),
|
||||||
|
)
|
||||||
|
.try_init()?;
|
||||||
|
} else {
|
||||||
|
tracing_subscriber::registry()
|
||||||
|
.with(
|
||||||
|
fmt::layer()
|
||||||
|
.with_timer(local_timer())
|
||||||
|
.with_filter(console_filter),
|
||||||
|
)
|
||||||
|
.try_init()?;
|
||||||
|
}
|
||||||
|
Ok(guard)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Exporting the exporter's own diagnostics can create a perpetual feedback
|
||||||
|
/// loop. Transport targets are shared with application traffic, so exclude
|
||||||
|
/// them from OTLP altogether while retaining console diagnostics.
|
||||||
|
fn not_exporter_internal(metadata: &tracing::Metadata<'_>) -> bool {
|
||||||
|
let target = metadata.target();
|
||||||
|
let root = target.split("::").next().unwrap_or(target);
|
||||||
|
!(root == "opentelemetry"
|
||||||
|
|| root.starts_with("opentelemetry_")
|
||||||
|
|| matches!(root, "h2" | "hyper" | "hyper_util" | "tonic" | "tower"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn signal_filter(
|
||||||
|
explicit: Option<&str>,
|
||||||
|
rust_log: Option<&str>,
|
||||||
|
default_filter: &str,
|
||||||
|
) -> Result<EnvFilter> {
|
||||||
|
if let Some(value) = explicit {
|
||||||
|
anyhow::ensure!(
|
||||||
|
!value.trim().is_empty(),
|
||||||
|
"telemetry filter must not be empty"
|
||||||
|
);
|
||||||
|
return EnvFilter::try_new(value).context("invalid telemetry filter");
|
||||||
|
}
|
||||||
|
// Preserve the existing fallback for an absent or invalid RUST_LOG.
|
||||||
|
rust_log
|
||||||
|
.and_then(|value| EnvFilter::try_new(value).ok())
|
||||||
|
.map(Ok)
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
EnvFilter::try_new(default_filter).context("invalid default telemetry filter")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn authorization_metadata(
|
||||||
|
name: Option<&str>,
|
||||||
|
lookup: impl FnOnce(&str) -> Option<String>,
|
||||||
|
) -> Result<tonic::metadata::MetadataMap> {
|
||||||
|
let mut metadata = tonic::metadata::MetadataMap::new();
|
||||||
|
let Some(name) = name else {
|
||||||
|
return Ok(metadata);
|
||||||
|
};
|
||||||
|
anyhow::ensure!(
|
||||||
|
!name.is_empty()
|
||||||
|
&& name.bytes().all(|c| c.is_ascii_alphanumeric() || c == b'_')
|
||||||
|
&& !name.as_bytes()[0].is_ascii_digit(),
|
||||||
|
"telemetry authorization_env must be a valid environment variable name"
|
||||||
|
);
|
||||||
|
let raw = lookup(name).with_context(|| {
|
||||||
|
format!("telemetry authorization environment variable {name} is missing or not UTF-8")
|
||||||
|
})?;
|
||||||
|
let header = raw.trim();
|
||||||
|
anyhow::ensure!(
|
||||||
|
!header.is_empty(),
|
||||||
|
"telemetry authorization environment variable {name} is empty"
|
||||||
|
);
|
||||||
|
let valid_scheme = header.split_once(' ').is_some_and(|(scheme, credentials)| {
|
||||||
|
(scheme.eq_ignore_ascii_case("basic") || scheme.eq_ignore_ascii_case("bearer"))
|
||||||
|
&& !credentials.trim().is_empty()
|
||||||
|
});
|
||||||
|
anyhow::ensure!(
|
||||||
|
valid_scheme && !raw.contains(['\r', '\n']),
|
||||||
|
"telemetry authorization environment variable {name} must contain a single Basic or Bearer header"
|
||||||
|
);
|
||||||
|
// Never include the supplied value/parser error in diagnostics. Sensitive
|
||||||
|
// metadata also hides it if a dependency Debug-prints an outgoing request.
|
||||||
|
let mut value = tonic::metadata::MetadataValue::try_from(header).map_err(|_| {
|
||||||
|
anyhow::anyhow!("invalid authorization metadata in environment variable {name}")
|
||||||
|
})?;
|
||||||
|
value.set_sensitive(true);
|
||||||
|
metadata.insert("authorization", value);
|
||||||
|
Ok(metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn local_timer() -> fmt::time::OffsetTime<time::format_description::well_known::Rfc3339> {
|
||||||
|
fmt::time::OffsetTime::new(
|
||||||
|
time::UtcOffset::current_local_offset().unwrap_or(time::UtcOffset::UTC),
|
||||||
|
time::format_description::well_known::Rfc3339,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct instruments once, after the real provider exists. Disabled telemetry
|
||||||
|
// therefore does no per-PTY-read allocations or instrument registration.
|
||||||
|
static METRICS: OnceLock<Instruments> = OnceLock::new();
|
||||||
|
mod fleet;
|
||||||
|
mod process;
|
||||||
|
pub use fleet::{AgentDeviceCounts, FleetCounts, FleetMetrics};
|
||||||
|
pub use process::{HostLoad, HostMemory, ProcessMetricsSource};
|
||||||
|
struct Instruments {
|
||||||
|
operations: opentelemetry::metrics::Counter<u64>,
|
||||||
|
duration: opentelemetry::metrics::Histogram<f64>,
|
||||||
|
snapshot_devices: opentelemetry::metrics::Counter<u64>,
|
||||||
|
terminal_active: opentelemetry::metrics::UpDownCounter<i64>,
|
||||||
|
terminal_input: opentelemetry::metrics::Counter<u64>,
|
||||||
|
terminal_output: opentelemetry::metrics::Counter<u64>,
|
||||||
|
terminal_backpressure: opentelemetry::metrics::Counter<u64>,
|
||||||
|
}
|
||||||
|
impl Instruments {
|
||||||
|
fn new() -> Self {
|
||||||
|
let meter = global::meter("wakey");
|
||||||
|
Self {
|
||||||
|
operations: meter.u64_counter("wakey.operations").build(),
|
||||||
|
duration: meter
|
||||||
|
.f64_histogram("wakey.operation.duration")
|
||||||
|
.with_unit("s")
|
||||||
|
// Measurements are seconds; SDK defaults start at 5, which
|
||||||
|
// collapses millisecond operations into one unusably wide bucket.
|
||||||
|
.with_boundaries(vec![
|
||||||
|
0.0001, 0.0005, 0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0,
|
||||||
|
10.0, 30.0, 60.0,
|
||||||
|
])
|
||||||
|
.build(),
|
||||||
|
snapshot_devices: meter.u64_counter("wakey.snapshot.devices").build(),
|
||||||
|
terminal_active: meter.i64_up_down_counter("wakey.terminal.active").build(),
|
||||||
|
terminal_input: meter
|
||||||
|
.u64_counter("wakey.terminal.input.bytes")
|
||||||
|
.with_unit("By")
|
||||||
|
.build(),
|
||||||
|
terminal_output: meter
|
||||||
|
.u64_counter("wakey.terminal.output.bytes")
|
||||||
|
.with_unit("By")
|
||||||
|
.build(),
|
||||||
|
terminal_backpressure: meter.u64_counter("wakey.terminal.backpressure").build(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// W3C context only, never arbitrary application fields or credentials.
|
||||||
|
pub fn current_trace_context() -> BTreeMap<String, String> {
|
||||||
|
use opentelemetry::propagation::TextMapPropagator;
|
||||||
|
let mut carrier = std::collections::HashMap::new();
|
||||||
|
opentelemetry_sdk::propagation::TraceContextPropagator::new()
|
||||||
|
.inject_context(&tracing::Span::current().context(), &mut carrier);
|
||||||
|
carrier.into_iter().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_remote_parent(span: &tracing::Span, carrier: &BTreeMap<String, String>) {
|
||||||
|
use opentelemetry::propagation::TextMapPropagator;
|
||||||
|
let carrier: std::collections::HashMap<String, String> = carrier
|
||||||
|
.iter()
|
||||||
|
.filter(|(key, value)| {
|
||||||
|
matches!(key.as_str(), "traceparent" | "tracestate") && value.len() <= 512
|
||||||
|
})
|
||||||
|
.map(|(k, v)| (k.clone(), v.clone()))
|
||||||
|
.collect();
|
||||||
|
let context = opentelemetry_sdk::propagation::TraceContextPropagator::new().extract(&carrier);
|
||||||
|
if context.has_active_span() {
|
||||||
|
let _ = span.set_parent(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records completion on every return path, without high-cardinality identifiers.
|
||||||
|
pub struct Operation {
|
||||||
|
started: Instant,
|
||||||
|
name: &'static str,
|
||||||
|
outcome: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Operation {
|
||||||
|
pub fn new(name: &'static str) -> Self {
|
||||||
|
Self {
|
||||||
|
started: Instant::now(),
|
||||||
|
name,
|
||||||
|
outcome: "error",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn outcome(&mut self, outcome: &'static str) {
|
||||||
|
self.outcome = outcome;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Operation {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let Some(metrics) = METRICS.get() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let labels = [
|
||||||
|
KeyValue::new("operation", self.name),
|
||||||
|
KeyValue::new("outcome", self.outcome),
|
||||||
|
];
|
||||||
|
metrics.operations.add(1, &labels);
|
||||||
|
metrics
|
||||||
|
.duration
|
||||||
|
.record(self.started.elapsed().as_secs_f64(), &labels);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum Counter {
|
||||||
|
SnapshotDevices,
|
||||||
|
TerminalInputBytes,
|
||||||
|
TerminalOutputBytes,
|
||||||
|
TerminalBackpressure,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn count(counter: Counter, value: u64) {
|
||||||
|
let Some(metrics) = METRICS.get() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let counter = match counter {
|
||||||
|
Counter::SnapshotDevices => &metrics.snapshot_devices,
|
||||||
|
Counter::TerminalInputBytes => &metrics.terminal_input,
|
||||||
|
Counter::TerminalOutputBytes => &metrics.terminal_output,
|
||||||
|
Counter::TerminalBackpressure => &metrics.terminal_backpressure,
|
||||||
|
};
|
||||||
|
counter.add(value, &[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An active session contributes until its worker finishes or is cancelled.
|
||||||
|
pub struct ActiveTerminal;
|
||||||
|
impl ActiveTerminal {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
if let Some(metrics) = METRICS.get() {
|
||||||
|
metrics.terminal_active.add(1, &[]);
|
||||||
|
}
|
||||||
|
Self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl Default for ActiveTerminal {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl Drop for ActiveTerminal {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if let Some(metrics) = METRICS.get() {
|
||||||
|
metrics.terminal_active.add(-1, &[]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod auth_tests {
|
||||||
|
#[test]
|
||||||
|
fn explicit_signal_filters_override_environment_and_validate() {
|
||||||
|
assert_eq!(
|
||||||
|
super::signal_filter(Some("debug"), Some("warn"), "info")
|
||||||
|
.unwrap()
|
||||||
|
.to_string(),
|
||||||
|
"debug"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
super::signal_filter(None, Some("warn"), "info")
|
||||||
|
.unwrap()
|
||||||
|
.to_string(),
|
||||||
|
"warn"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
super::signal_filter(None, None, "info")
|
||||||
|
.unwrap()
|
||||||
|
.to_string(),
|
||||||
|
"info"
|
||||||
|
);
|
||||||
|
assert!(super::signal_filter(Some(""), None, "info").is_err());
|
||||||
|
assert!(super::signal_filter(Some("["), None, "info").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn trace_context_connects_collection_to_remote_persistence() {
|
||||||
|
let provider = SdkTracerProvider::builder().build();
|
||||||
|
let subscriber = tracing_subscriber::registry()
|
||||||
|
.with(tracing_opentelemetry::layer().with_tracer(provider.tracer("snapshot-test")));
|
||||||
|
// Scoped subscriber and synchronous entered guards keep this isolated
|
||||||
|
// from other tests without installing a global exporter or collector.
|
||||||
|
tracing::subscriber::with_default(subscriber, || {
|
||||||
|
let collect = tracing::info_span!("collect_device_snapshot");
|
||||||
|
let carrier = {
|
||||||
|
let _entered = collect.enter();
|
||||||
|
current_trace_context()
|
||||||
|
};
|
||||||
|
let collection = carrier["traceparent"].split('-').collect::<Vec<_>>();
|
||||||
|
let store = tracing::info_span!("store_device_snapshot");
|
||||||
|
set_remote_parent(&store, &carrier);
|
||||||
|
let persisted = {
|
||||||
|
let _entered = store.enter();
|
||||||
|
current_trace_context()
|
||||||
|
};
|
||||||
|
let persistence = persisted["traceparent"].split('-').collect::<Vec<_>>();
|
||||||
|
assert_eq!(collection[1], persistence[1], "trace IDs must match");
|
||||||
|
assert_ne!(
|
||||||
|
collection[2], persistence[2],
|
||||||
|
"spans must have distinct IDs"
|
||||||
|
);
|
||||||
|
|
||||||
|
let unrelated = tracing::info_span!("unrelated");
|
||||||
|
set_remote_parent(&unrelated, &BTreeMap::new());
|
||||||
|
set_remote_parent(
|
||||||
|
&unrelated,
|
||||||
|
&[("traceparent".into(), "invalid".into())]
|
||||||
|
.into_iter()
|
||||||
|
.collect(),
|
||||||
|
);
|
||||||
|
let _entered = unrelated.enter();
|
||||||
|
let local = current_trace_context();
|
||||||
|
assert_ne!(
|
||||||
|
collection[1],
|
||||||
|
local["traceparent"].split('-').nth(1).unwrap()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn authorization_supports_both_schemes_and_redacts_debug() {
|
||||||
|
for header in ["Basic dGVzdDp0ZXN0", "Bearer test-token"] {
|
||||||
|
let metadata =
|
||||||
|
authorization_metadata(Some("OTLP_AUTH"), |_| Some(header.into())).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
metadata.get("authorization").unwrap().to_str().unwrap(),
|
||||||
|
header
|
||||||
|
);
|
||||||
|
assert!(!format!("{metadata:?}").contains(header));
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
authorization_metadata(None, |_| panic!("no credential lookup"))
|
||||||
|
.unwrap()
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_empty_and_invalid_auth_fail_without_echoing_secrets() {
|
||||||
|
for value in [
|
||||||
|
None,
|
||||||
|
Some(""),
|
||||||
|
Some(" "),
|
||||||
|
Some("Basic "),
|
||||||
|
Some("Digest private-secret"),
|
||||||
|
Some("Basic private-secret\r\n"),
|
||||||
|
Some("Bearer private-secret\u{7f}"),
|
||||||
|
] {
|
||||||
|
let error = authorization_metadata(Some("OTLP_AUTH"), |_| value.map(str::to_owned))
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(!format!("{error:#}").contains("private-secret"));
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
authorization_metadata(Some("BAD=NAME"), |_| panic!(
|
||||||
|
"invalid name must not be looked up"
|
||||||
|
))
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::sync::{Arc, RwLock};
|
||||||
|
|
||||||
|
use opentelemetry::{KeyValue, metrics::ObservableGauge};
|
||||||
|
|
||||||
|
/// Retained CC rows, not unique addresses or online-device counts.
|
||||||
|
#[derive(Debug, Default, PartialEq, Eq)]
|
||||||
|
pub struct AgentDeviceCounts {
|
||||||
|
pub devices: u64,
|
||||||
|
pub endpoints: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One unfiltered, revision-checked CC sample. Agent IDs are the only identity labels.
|
||||||
|
#[derive(Debug, Default, PartialEq, Eq)]
|
||||||
|
pub struct FleetCounts {
|
||||||
|
pub agents: BTreeMap<String, AgentDeviceCounts>,
|
||||||
|
pub saved: u64,
|
||||||
|
pub unsaved: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Callbacks only read memory; async DB work stays outside the export thread.
|
||||||
|
/// Replacing the entire sample removes vanished labels instead of retaining their series.
|
||||||
|
pub struct FleetMetrics {
|
||||||
|
sample: Arc<RwLock<Option<FleetCounts>>>,
|
||||||
|
_devices: ObservableGauge<u64>,
|
||||||
|
_endpoints: ObservableGauge<u64>,
|
||||||
|
_fleet: ObservableGauge<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FleetMetrics {
|
||||||
|
/// Avoid polling the DB at all when OTLP metrics are disabled.
|
||||||
|
pub fn new() -> Option<Self> {
|
||||||
|
super::METRICS.get()?;
|
||||||
|
let meter = opentelemetry::global::meter("wakey");
|
||||||
|
let sample = Arc::new(RwLock::new(None::<FleetCounts>));
|
||||||
|
let devices = sample.clone();
|
||||||
|
let endpoints = sample.clone();
|
||||||
|
let fleet = sample.clone();
|
||||||
|
Some(Self {
|
||||||
|
sample,
|
||||||
|
_devices: meter
|
||||||
|
.u64_observable_gauge("wakey.stored.devices")
|
||||||
|
.with_callback(move |observer| {
|
||||||
|
if let Ok(sample) = devices.read()
|
||||||
|
&& let Some(sample) = sample.as_ref()
|
||||||
|
{
|
||||||
|
for (agent, counts) in &sample.agents {
|
||||||
|
observer.observe(
|
||||||
|
counts.devices,
|
||||||
|
&[KeyValue::new("agent_id", agent.clone())],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.build(),
|
||||||
|
_endpoints: meter
|
||||||
|
.u64_observable_gauge("wakey.stored.endpoints")
|
||||||
|
.with_callback(move |observer| {
|
||||||
|
if let Ok(sample) = endpoints.read()
|
||||||
|
&& let Some(sample) = sample.as_ref()
|
||||||
|
{
|
||||||
|
for (agent, counts) in &sample.agents {
|
||||||
|
observer.observe(
|
||||||
|
counts.endpoints,
|
||||||
|
&[KeyValue::new("agent_id", agent.clone())],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.build(),
|
||||||
|
_fleet: meter
|
||||||
|
.u64_observable_gauge("wakey.fleet.devices")
|
||||||
|
.with_callback(move |observer| {
|
||||||
|
if let Ok(sample) = fleet.read()
|
||||||
|
&& let Some(sample) = sample.as_ref()
|
||||||
|
{
|
||||||
|
observer.observe(sample.saved, &[KeyValue::new("identity", "saved")]);
|
||||||
|
observer.observe(sample.unsaved, &[KeyValue::new("identity", "unsaved")]);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.build(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// None suppresses observations after a failed refresh, rather than reporting stale counts.
|
||||||
|
pub fn replace(&self, sample: Option<FleetCounts>) {
|
||||||
|
if let Ok(mut current) = self.sample.write() {
|
||||||
|
*current = sample;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
//! OS-independent process/host instruments. Callers supply a platform reader.
|
||||||
|
//! Failed reads omit a sample: zero must not masquerade as a restart or freed RAM.
|
||||||
|
|
||||||
|
use opentelemetry::KeyValue;
|
||||||
|
use opentelemetry::metrics::{Meter, ObservableCounter, ObservableGauge};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
/// Host-visible memory in bytes; available is an estimate, not merely free RAM.
|
||||||
|
pub struct HostMemory {
|
||||||
|
pub total_bytes: u64,
|
||||||
|
pub available_bytes: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Host load averages, not CPU utilization percentages.
|
||||||
|
pub struct HostLoad {
|
||||||
|
pub one_minute: f64,
|
||||||
|
pub five_minutes: f64,
|
||||||
|
pub fifteen_minutes: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Values use the documented seconds/bytes/count units, or None when unavailable. Readers run
|
||||||
|
/// on the metric collection thread: keep work small and local, never perform
|
||||||
|
/// network requests or emit exporter-directed logs from these callbacks.
|
||||||
|
pub trait ProcessMetricsSource: Send + Sync + 'static {
|
||||||
|
fn process_uptime_seconds(&self) -> Option<f64>;
|
||||||
|
fn resident_memory_bytes(&self) -> Option<u64>;
|
||||||
|
fn host_uptime_seconds(&self) -> Option<f64>;
|
||||||
|
/// Cumulative user + system CPU seconds for the process, excluding children.
|
||||||
|
/// Optional additions default to unavailable for existing platform readers.
|
||||||
|
fn cpu_time_seconds(&self) -> Option<f64> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
fn open_file_descriptors(&self) -> Option<u64> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
fn host_memory(&self) -> Option<HostMemory> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
fn host_load(&self) -> Option<HostLoad> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) struct ProcessMetrics {
|
||||||
|
_uptime: ObservableGauge<f64>,
|
||||||
|
_resident: ObservableGauge<u64>,
|
||||||
|
_host_uptime: ObservableGauge<f64>,
|
||||||
|
_cpu_time: ObservableCounter<f64>,
|
||||||
|
_open_fds: ObservableGauge<u64>,
|
||||||
|
_host_total_memory: ObservableGauge<u64>,
|
||||||
|
_host_available_memory: ObservableGauge<u64>,
|
||||||
|
_host_load: ObservableGauge<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProcessMetrics {
|
||||||
|
pub(super) fn new(meter: &Meter, source: impl ProcessMetricsSource) -> Self {
|
||||||
|
let source = Arc::new(source);
|
||||||
|
let uptime = source.clone();
|
||||||
|
let resident = source.clone();
|
||||||
|
let host = source.clone();
|
||||||
|
let cpu = source.clone();
|
||||||
|
let fds = source.clone();
|
||||||
|
let total_memory = source.clone();
|
||||||
|
let available_memory = source.clone();
|
||||||
|
Self {
|
||||||
|
_uptime: meter
|
||||||
|
.f64_observable_gauge("wakey.process.uptime")
|
||||||
|
.with_unit("s")
|
||||||
|
.with_description("Process age reported by the platform reader")
|
||||||
|
.with_callback(move |observer| {
|
||||||
|
if let Some(seconds) = uptime.process_uptime_seconds()
|
||||||
|
&& seconds.is_finite()
|
||||||
|
&& seconds >= 0.0
|
||||||
|
{
|
||||||
|
observer.observe(seconds, &[]);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.build(),
|
||||||
|
_resident: meter
|
||||||
|
.u64_observable_gauge("wakey.process.memory.resident")
|
||||||
|
.with_unit("By")
|
||||||
|
.with_description("Resident process memory reported by the platform reader")
|
||||||
|
.with_callback(move |observer| {
|
||||||
|
if let Some(bytes) = resident.resident_memory_bytes() {
|
||||||
|
observer.observe(bytes, &[]);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.build(),
|
||||||
|
_host_uptime: meter
|
||||||
|
.f64_observable_gauge("wakey.host.uptime")
|
||||||
|
.with_unit("s")
|
||||||
|
.with_description("Host uptime reported by the platform reader")
|
||||||
|
.with_callback(move |observer| {
|
||||||
|
if let Some(seconds) = host.host_uptime_seconds()
|
||||||
|
&& seconds.is_finite()
|
||||||
|
&& seconds >= 0.0
|
||||||
|
{
|
||||||
|
observer.observe(seconds, &[]);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.build(),
|
||||||
|
_cpu_time: meter
|
||||||
|
.f64_observable_counter("wakey.process.cpu.time")
|
||||||
|
.with_unit("s")
|
||||||
|
.with_description("Cumulative user and system process CPU time, excluding children")
|
||||||
|
.with_callback(move |observer| {
|
||||||
|
if let Some(seconds) = cpu.cpu_time_seconds()
|
||||||
|
&& seconds.is_finite()
|
||||||
|
&& seconds >= 0.0
|
||||||
|
{
|
||||||
|
observer.observe(seconds, &[]);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.build(),
|
||||||
|
_open_fds: meter
|
||||||
|
.u64_observable_gauge("wakey.process.open_fds")
|
||||||
|
.with_description(
|
||||||
|
"Current open file-descriptor count reported by the platform reader",
|
||||||
|
)
|
||||||
|
.with_callback(move |observer| {
|
||||||
|
if let Some(count) = fds.open_file_descriptors() {
|
||||||
|
observer.observe(count, &[]);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.build(),
|
||||||
|
_host_total_memory: meter
|
||||||
|
.u64_observable_gauge("wakey.host.memory.total")
|
||||||
|
.with_unit("By")
|
||||||
|
.with_description("Total usable RAM reported by the platform reader")
|
||||||
|
.with_callback(move |observer| {
|
||||||
|
if let Some(memory) = total_memory.host_memory() {
|
||||||
|
observer.observe(memory.total_bytes, &[]);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.build(),
|
||||||
|
_host_available_memory: meter
|
||||||
|
.u64_observable_gauge("wakey.host.memory.available")
|
||||||
|
.with_unit("By")
|
||||||
|
.with_description("Estimated RAM available for applications without swapping")
|
||||||
|
.with_callback(move |observer| {
|
||||||
|
if let Some(memory) = available_memory.host_memory()
|
||||||
|
&& let Some(bytes) = memory.available_bytes
|
||||||
|
{
|
||||||
|
observer.observe(bytes, &[]);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.build(),
|
||||||
|
_host_load: meter
|
||||||
|
.f64_observable_gauge("wakey.host.load")
|
||||||
|
.with_description("Host load average over the labeled period, not CPU percent")
|
||||||
|
.with_callback(move |observer| {
|
||||||
|
if let Some(load) = source.host_load() {
|
||||||
|
for (period, value) in [
|
||||||
|
("1m", load.one_minute),
|
||||||
|
("5m", load.five_minutes),
|
||||||
|
("15m", load.fifteen_minutes),
|
||||||
|
] {
|
||||||
|
if value.is_finite() && value >= 0.0 {
|
||||||
|
observer.observe(value, &[KeyValue::new("period", period)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.build(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,573 @@
|
|||||||
|
#![cfg(feature = "telemetry")]
|
||||||
|
|
||||||
|
use opentelemetry_proto::tonic::collector::{
|
||||||
|
logs::v1 as logs, metrics::v1 as metrics, trace::v1 as traces,
|
||||||
|
};
|
||||||
|
use opentelemetry_proto::tonic::metrics::v1::{metric, number_data_point};
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use tonic::{Request, Response, Status};
|
||||||
|
use wakey_core::telemetry::{self, TelemetryConfig};
|
||||||
|
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
struct Collector {
|
||||||
|
traces: Arc<Mutex<Vec<traces::ExportTraceServiceRequest>>>,
|
||||||
|
logs: Arc<Mutex<Vec<logs::ExportLogsServiceRequest>>>,
|
||||||
|
metrics: Arc<Mutex<Vec<metrics::ExportMetricsServiceRequest>>>,
|
||||||
|
fail_until: usize,
|
||||||
|
attempts: [Arc<AtomicUsize>; 3],
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_auth<T>(request: &Request<T>) -> Result<(), Status> {
|
||||||
|
if request
|
||||||
|
.metadata()
|
||||||
|
.get("authorization")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
!= Some("Basic dGVzdDp0ZXN0")
|
||||||
|
{
|
||||||
|
return Err(Status::unauthenticated(
|
||||||
|
"test receiver requires authorization",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Use a real receiver and counted RPCs, not sleep/elapsed-time expectations.
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn exporter_retries_are_bounded_and_bad_credentials_are_not_retried() {
|
||||||
|
use opentelemetry_otlp::{RetryPolicy, WithExportConfig, WithTonicConfig};
|
||||||
|
use opentelemetry_sdk::trace::SpanExporter as _;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let endpoint = format!("http://{}", listener.local_addr().unwrap());
|
||||||
|
let collector = Collector {
|
||||||
|
fail_until: usize::MAX,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let (stop_tx, stop_rx) = tokio::sync::oneshot::channel();
|
||||||
|
let server = tokio::spawn(
|
||||||
|
tonic::transport::Server::builder()
|
||||||
|
.add_service(traces::trace_service_server::TraceServiceServer::new(
|
||||||
|
collector.clone(),
|
||||||
|
))
|
||||||
|
.serve_with_incoming_shutdown(
|
||||||
|
tokio_stream::wrappers::TcpListenerStream::new(listener),
|
||||||
|
async {
|
||||||
|
let _ = stop_rx.await;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
let mut metadata = tonic::metadata::MetadataMap::new();
|
||||||
|
metadata.insert("authorization", "Basic dGVzdDp0ZXN0".parse().unwrap());
|
||||||
|
let policy = RetryPolicy::recommended()
|
||||||
|
.with_max_retries(2)
|
||||||
|
.with_initial_delay(Duration::from_millis(1))
|
||||||
|
.with_max_delay(Duration::from_millis(4))
|
||||||
|
.with_max_jitter(Duration::ZERO);
|
||||||
|
let exporter = opentelemetry_otlp::SpanExporter::builder()
|
||||||
|
.with_tonic()
|
||||||
|
.with_endpoint(&endpoint)
|
||||||
|
.with_metadata(metadata)
|
||||||
|
.with_timeout(Duration::from_secs(3))
|
||||||
|
.with_retry_policy(policy.clone())
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
assert!(exporter.export(vec![]).await.is_err());
|
||||||
|
assert_eq!(collector.attempts[0].load(Ordering::SeqCst), 3);
|
||||||
|
|
||||||
|
let unauthorized = opentelemetry_otlp::SpanExporter::builder()
|
||||||
|
.with_tonic()
|
||||||
|
.with_endpoint(endpoint)
|
||||||
|
.with_timeout(Duration::from_secs(3))
|
||||||
|
.with_retry_policy(policy)
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
assert!(unauthorized.export(vec![]).await.is_err());
|
||||||
|
assert_eq!(
|
||||||
|
collector.attempts[0].load(Ordering::SeqCst),
|
||||||
|
4,
|
||||||
|
"authentication failure must add only one attempt"
|
||||||
|
);
|
||||||
|
let _ = stop_tx.send(());
|
||||||
|
server.await.unwrap().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tonic::async_trait]
|
||||||
|
impl traces::trace_service_server::TraceService for Collector {
|
||||||
|
async fn export(
|
||||||
|
&self,
|
||||||
|
request: Request<traces::ExportTraceServiceRequest>,
|
||||||
|
) -> Result<Response<traces::ExportTraceServiceResponse>, Status> {
|
||||||
|
let attempt = self.attempts[0].fetch_add(1, Ordering::SeqCst);
|
||||||
|
check_auth(&request)?;
|
||||||
|
if attempt < self.fail_until {
|
||||||
|
return Err(Status::unavailable("temporary receiver outage"));
|
||||||
|
}
|
||||||
|
self.traces.lock().unwrap().push(request.into_inner());
|
||||||
|
Ok(Response::new(Default::default()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[tonic::async_trait]
|
||||||
|
impl logs::logs_service_server::LogsService for Collector {
|
||||||
|
async fn export(
|
||||||
|
&self,
|
||||||
|
request: Request<logs::ExportLogsServiceRequest>,
|
||||||
|
) -> Result<Response<logs::ExportLogsServiceResponse>, Status> {
|
||||||
|
let attempt = self.attempts[1].fetch_add(1, Ordering::SeqCst);
|
||||||
|
check_auth(&request)?;
|
||||||
|
if attempt < self.fail_until {
|
||||||
|
return Err(Status::unavailable("temporary receiver outage"));
|
||||||
|
}
|
||||||
|
self.logs.lock().unwrap().push(request.into_inner());
|
||||||
|
Ok(Response::new(Default::default()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[tonic::async_trait]
|
||||||
|
impl metrics::metrics_service_server::MetricsService for Collector {
|
||||||
|
async fn export(
|
||||||
|
&self,
|
||||||
|
request: Request<metrics::ExportMetricsServiceRequest>,
|
||||||
|
) -> Result<Response<metrics::ExportMetricsServiceResponse>, Status> {
|
||||||
|
let attempt = self.attempts[2].fetch_add(1, Ordering::SeqCst);
|
||||||
|
check_auth(&request)?;
|
||||||
|
if attempt < self.fail_until {
|
||||||
|
return Err(Status::unavailable("temporary receiver outage"));
|
||||||
|
}
|
||||||
|
self.metrics.lock().unwrap().push(request.into_inner());
|
||||||
|
Ok(Response::new(Default::default()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shutdown is the synchronization point: no sleeps or export-interval polling.
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn exports_all_signals_and_preserves_remote_command_parent() {
|
||||||
|
// Exercise the real global subscriber without mutating this process's environment
|
||||||
|
// or inheriting a developer's RUST_LOG=warn/off setting.
|
||||||
|
if std::env::var_os("WAKEY_OTLP_TEST_CHILD").is_none() {
|
||||||
|
for retry in ["0", "1"] {
|
||||||
|
let output = std::process::Command::new(std::env::current_exe().unwrap())
|
||||||
|
.args([
|
||||||
|
"--exact",
|
||||||
|
"exports_all_signals_and_preserves_remote_command_parent",
|
||||||
|
"--nocapture",
|
||||||
|
])
|
||||||
|
.env("WAKEY_OTLP_TEST_CHILD", "1")
|
||||||
|
.env("WAKEY_OTLP_TEST_RETRY", retry)
|
||||||
|
.env("RUST_LOG", "info")
|
||||||
|
.env("WAKEY_OTLP_TEST_AUTH", "Basic dGVzdDp0ZXN0")
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
output.status.success(),
|
||||||
|
"{}",
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
for console in [&output.stdout, &output.stderr] {
|
||||||
|
assert!(
|
||||||
|
!String::from_utf8_lossy(console)
|
||||||
|
.contains("debug log exported without console output")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let endpoint = format!("http://{}", listener.local_addr().unwrap());
|
||||||
|
let retry = std::env::var("WAKEY_OTLP_TEST_RETRY").as_deref() == Ok("1");
|
||||||
|
let collector = Collector {
|
||||||
|
fail_until: usize::from(retry),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let (stop_tx, stop_rx) = tokio::sync::oneshot::channel();
|
||||||
|
let server = tonic::transport::Server::builder()
|
||||||
|
.add_service(traces::trace_service_server::TraceServiceServer::new(
|
||||||
|
collector.clone(),
|
||||||
|
))
|
||||||
|
.add_service(logs::logs_service_server::LogsServiceServer::new(
|
||||||
|
collector.clone(),
|
||||||
|
))
|
||||||
|
.add_service(metrics::metrics_service_server::MetricsServiceServer::new(
|
||||||
|
collector.clone(),
|
||||||
|
))
|
||||||
|
.serve_with_incoming_shutdown(
|
||||||
|
tokio_stream::wrappers::TcpListenerStream::new(listener),
|
||||||
|
async {
|
||||||
|
let _ = stop_rx.await;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let server = tokio::spawn(server);
|
||||||
|
// The exporter contract is OS-independent; Linux procfs collection is
|
||||||
|
// tested in wakey-linux rather than imported into core.
|
||||||
|
struct ProcessSample;
|
||||||
|
impl telemetry::ProcessMetricsSource for ProcessSample {
|
||||||
|
fn process_uptime_seconds(&self) -> Option<f64> {
|
||||||
|
Some(120.5)
|
||||||
|
}
|
||||||
|
fn resident_memory_bytes(&self) -> Option<u64> {
|
||||||
|
Some(32 * 1024 * 1024)
|
||||||
|
}
|
||||||
|
fn host_uptime_seconds(&self) -> Option<f64> {
|
||||||
|
Some(10_000.0)
|
||||||
|
}
|
||||||
|
fn cpu_time_seconds(&self) -> Option<f64> {
|
||||||
|
Some(1.25)
|
||||||
|
}
|
||||||
|
fn open_file_descriptors(&self) -> Option<u64> {
|
||||||
|
Some(12)
|
||||||
|
}
|
||||||
|
fn host_memory(&self) -> Option<telemetry::HostMemory> {
|
||||||
|
Some(telemetry::HostMemory {
|
||||||
|
total_bytes: 2 * 1024 * 1024 * 1024,
|
||||||
|
available_bytes: Some(512 * 1024 * 1024),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fn host_load(&self) -> Option<telemetry::HostLoad> {
|
||||||
|
Some(telemetry::HostLoad {
|
||||||
|
one_minute: 0.5,
|
||||||
|
five_minutes: 1.0,
|
||||||
|
fifteen_minutes: 1.5,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let guard = telemetry::init(
|
||||||
|
&TelemetryConfig {
|
||||||
|
otlp_endpoint: Some(endpoint),
|
||||||
|
authorization_env: Some("WAKEY_OTLP_TEST_AUTH".into()),
|
||||||
|
service_name: "wakey-test".into(),
|
||||||
|
json_logs: false,
|
||||||
|
console_filter: Some("info".into()),
|
||||||
|
otlp_log_filter: Some("debug".into()),
|
||||||
|
trace_filter: Some("info,opentelemetry_sdk=debug,h2=debug,hyper=debug,hyper_util=debug,tonic=debug,tower=debug".into()),
|
||||||
|
},
|
||||||
|
"unused",
|
||||||
|
"info",
|
||||||
|
Some("test-agent"),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.with_process_metrics(ProcessSample);
|
||||||
|
|
||||||
|
let fleet_metrics = telemetry::FleetMetrics::new().unwrap();
|
||||||
|
fleet_metrics.replace(Some(telemetry::FleetCounts {
|
||||||
|
agents: [(
|
||||||
|
"removed-agent".into(),
|
||||||
|
telemetry::AgentDeviceCounts {
|
||||||
|
devices: 9,
|
||||||
|
endpoints: 20,
|
||||||
|
},
|
||||||
|
)]
|
||||||
|
.into(),
|
||||||
|
saved: 9,
|
||||||
|
unsaved: 0,
|
||||||
|
}));
|
||||||
|
fleet_metrics.replace(None);
|
||||||
|
fleet_metrics.replace(Some(telemetry::FleetCounts {
|
||||||
|
agents: [
|
||||||
|
(
|
||||||
|
"router".into(),
|
||||||
|
telemetry::AgentDeviceCounts {
|
||||||
|
devices: 2,
|
||||||
|
endpoints: 7,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
("empty-agent".into(), Default::default()),
|
||||||
|
]
|
||||||
|
.into(),
|
||||||
|
saved: 1,
|
||||||
|
unsaved: 1,
|
||||||
|
}));
|
||||||
|
|
||||||
|
let parent = tracing::info_span!("relay_command");
|
||||||
|
let context = parent.in_scope(telemetry::current_trace_context);
|
||||||
|
assert!(context.contains_key("traceparent"));
|
||||||
|
let child = tracing::info_span!("execute_command");
|
||||||
|
telemetry::set_remote_parent(&child, &context);
|
||||||
|
child.in_scope(|| {
|
||||||
|
tracing::info!(command = "inventory", "command complete");
|
||||||
|
let mut operation = telemetry::Operation::new("inventory");
|
||||||
|
operation.outcome("ok");
|
||||||
|
telemetry::count(telemetry::Counter::SnapshotDevices, 3);
|
||||||
|
let _active = telemetry::ActiveTerminal::new();
|
||||||
|
});
|
||||||
|
drop(child);
|
||||||
|
drop(parent);
|
||||||
|
tracing::debug!("debug log exported without console output");
|
||||||
|
let hidden = tracing::debug_span!("debug_span_not_exported");
|
||||||
|
drop(hidden);
|
||||||
|
tracing::warn!(target: "opentelemetry_sdk", "synthetic exporter failure remains local");
|
||||||
|
macro_rules! transport_activity {
|
||||||
|
($target:literal) => {{
|
||||||
|
let span = tracing::debug_span!(target: $target, "exporter_transport_span");
|
||||||
|
span.in_scope(|| tracing::debug!(target: $target, "synthetic exporter transport event"));
|
||||||
|
}};
|
||||||
|
}
|
||||||
|
transport_activity!("opentelemetry_sdk");
|
||||||
|
transport_activity!("h2::proto");
|
||||||
|
transport_activity!("hyper::client");
|
||||||
|
transport_activity!("hyper_util::client");
|
||||||
|
transport_activity!("tonic::transport");
|
||||||
|
transport_activity!("tower::buffer");
|
||||||
|
// A similarly named application target is not transport infrastructure.
|
||||||
|
tracing::info!(target: "tower_device", "application target remains exported");
|
||||||
|
let invalid_parent = tracing::info_span!("invalid_remote_parent");
|
||||||
|
telemetry::set_remote_parent(
|
||||||
|
&invalid_parent,
|
||||||
|
&[("traceparent".into(), "malformed".into())].into(),
|
||||||
|
);
|
||||||
|
drop(invalid_parent);
|
||||||
|
tokio::task::spawn_blocking(move || guard.shutdown())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let _ = stop_tx.send(());
|
||||||
|
server.await.unwrap().unwrap();
|
||||||
|
|
||||||
|
for attempts in &collector.attempts {
|
||||||
|
assert!(
|
||||||
|
attempts.load(Ordering::SeqCst) > usize::from(retry),
|
||||||
|
"each signal must recover from its first failed export"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let traces = collector.traces.lock().unwrap();
|
||||||
|
let spans: Vec<_> = traces
|
||||||
|
.iter()
|
||||||
|
.flat_map(|r| &r.resource_spans)
|
||||||
|
.flat_map(|r| &r.scope_spans)
|
||||||
|
.flat_map(|s| &s.spans)
|
||||||
|
.collect();
|
||||||
|
let parent = spans.iter().find(|s| s.name == "relay_command").unwrap();
|
||||||
|
let child = spans.iter().find(|s| s.name == "execute_command").unwrap();
|
||||||
|
assert_eq!(parent.trace_id, child.trace_id);
|
||||||
|
assert_eq!(parent.span_id, child.parent_span_id);
|
||||||
|
let invalid_parent = spans
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.name == "invalid_remote_parent")
|
||||||
|
.unwrap();
|
||||||
|
assert!(invalid_parent.parent_span_id.is_empty());
|
||||||
|
assert_ne!(parent.trace_id, invalid_parent.trace_id);
|
||||||
|
let logs = collector.logs.lock().unwrap();
|
||||||
|
assert!(
|
||||||
|
!spans
|
||||||
|
.iter()
|
||||||
|
.any(|span| span.name == "exporter_transport_span"),
|
||||||
|
"exporter transport spans must not feed back into OTLP"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!spans
|
||||||
|
.iter()
|
||||||
|
.any(|span| span.name == "debug_span_not_exported")
|
||||||
|
);
|
||||||
|
let records: Vec<_> = logs
|
||||||
|
.iter()
|
||||||
|
.flat_map(|r| &r.resource_logs)
|
||||||
|
.flat_map(|r| &r.scope_logs)
|
||||||
|
.flat_map(|s| &s.log_records)
|
||||||
|
.collect();
|
||||||
|
assert!(
|
||||||
|
!records
|
||||||
|
.iter()
|
||||||
|
.any(|r| format!("{:?}", r.body).contains("synthetic exporter transport event")),
|
||||||
|
"exporter transport events must not feed back into OTLP"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
records
|
||||||
|
.iter()
|
||||||
|
.any(|r| format!("{:?}", r.body).contains("application target remains exported"))
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
records
|
||||||
|
.iter()
|
||||||
|
.any(|r| r.trace_id == child.trace_id && r.span_id == child.span_id)
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!records
|
||||||
|
.iter()
|
||||||
|
.any(|r| format!("{:?}", r.body).contains("synthetic exporter failure"))
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
records
|
||||||
|
.iter()
|
||||||
|
.any(|r| format!("{:?}", r.body).contains("debug log exported without console output"))
|
||||||
|
);
|
||||||
|
let metrics = collector.metrics.lock().unwrap();
|
||||||
|
let names: Vec<_> = metrics
|
||||||
|
.iter()
|
||||||
|
.flat_map(|r| &r.resource_metrics)
|
||||||
|
.flat_map(|r| &r.scope_metrics)
|
||||||
|
.flat_map(|s| &s.metrics)
|
||||||
|
.map(|m| m.name.as_str())
|
||||||
|
.collect();
|
||||||
|
for name in [
|
||||||
|
"wakey.operations",
|
||||||
|
"wakey.operation.duration",
|
||||||
|
"wakey.snapshot.devices",
|
||||||
|
"wakey.terminal.active",
|
||||||
|
"wakey.stored.devices",
|
||||||
|
"wakey.stored.endpoints",
|
||||||
|
"wakey.fleet.devices",
|
||||||
|
] {
|
||||||
|
assert!(names.contains(&name), "missing {name}");
|
||||||
|
}
|
||||||
|
let latest = metrics.last().unwrap();
|
||||||
|
{
|
||||||
|
let duration = latest
|
||||||
|
.resource_metrics
|
||||||
|
.iter()
|
||||||
|
.flat_map(|r| &r.scope_metrics)
|
||||||
|
.flat_map(|s| &s.metrics)
|
||||||
|
.find(|m| m.name == "wakey.operation.duration")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(duration.unit, "s");
|
||||||
|
let Some(metric::Data::Histogram(histogram)) = &duration.data else {
|
||||||
|
panic!("operation duration must export a histogram");
|
||||||
|
};
|
||||||
|
let point = histogram.data_points.first().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
point.explicit_bounds,
|
||||||
|
vec![
|
||||||
|
0.0001, 0.0005, 0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0,
|
||||||
|
10.0, 30.0, 60.0,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
// One inventory operation is recorded above. Derive its expected bucket
|
||||||
|
// from the exported sum, avoiding elapsed-time thresholds or sleeps.
|
||||||
|
assert_eq!(point.count, 1);
|
||||||
|
let sample = point.sum.unwrap();
|
||||||
|
let bucket = point
|
||||||
|
.explicit_bounds
|
||||||
|
.partition_point(|bound| *bound < sample);
|
||||||
|
let mut expected = vec![0; point.explicit_bounds.len() + 1];
|
||||||
|
expected[bucket] = 1;
|
||||||
|
assert_eq!(point.bucket_counts, expected);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
for (name, unit, expected) in [
|
||||||
|
(
|
||||||
|
"wakey.process.uptime",
|
||||||
|
"s",
|
||||||
|
number_data_point::Value::AsDouble(120.5),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"wakey.process.memory.resident",
|
||||||
|
"By",
|
||||||
|
number_data_point::Value::AsInt(32 * 1024 * 1024),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"wakey.host.uptime",
|
||||||
|
"s",
|
||||||
|
number_data_point::Value::AsDouble(10_000.0),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"wakey.process.open_fds",
|
||||||
|
"",
|
||||||
|
number_data_point::Value::AsInt(12),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"wakey.host.memory.total",
|
||||||
|
"By",
|
||||||
|
number_data_point::Value::AsInt(2 * 1024 * 1024 * 1024),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"wakey.host.memory.available",
|
||||||
|
"By",
|
||||||
|
number_data_point::Value::AsInt(512 * 1024 * 1024),
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let metric = latest
|
||||||
|
.resource_metrics
|
||||||
|
.iter()
|
||||||
|
.flat_map(|resource| &resource.scope_metrics)
|
||||||
|
.flat_map(|scope| &scope.metrics)
|
||||||
|
.find(|metric| metric.name == name)
|
||||||
|
.unwrap_or_else(|| panic!("missing {name}"));
|
||||||
|
assert_eq!(metric.unit, unit);
|
||||||
|
let Some(metric::Data::Gauge(gauge)) = &metric.data else {
|
||||||
|
panic!("{name} must be a gauge");
|
||||||
|
};
|
||||||
|
assert_eq!(gauge.data_points.len(), 1);
|
||||||
|
let point = &gauge.data_points[0];
|
||||||
|
assert!(
|
||||||
|
point.attributes.is_empty(),
|
||||||
|
"process metrics need no PID/host/address labels"
|
||||||
|
);
|
||||||
|
assert_eq!(point.value, Some(expected));
|
||||||
|
}
|
||||||
|
let cpu = latest
|
||||||
|
.resource_metrics
|
||||||
|
.iter()
|
||||||
|
.flat_map(|resource| &resource.scope_metrics)
|
||||||
|
.flat_map(|scope| &scope.metrics)
|
||||||
|
.find(|metric| metric.name == "wakey.process.cpu.time")
|
||||||
|
.expect("CPU time exported");
|
||||||
|
assert_eq!(cpu.unit, "s");
|
||||||
|
let Some(metric::Data::Sum(sum)) = &cpu.data else {
|
||||||
|
panic!("CPU time must be a counter, not a gauge");
|
||||||
|
};
|
||||||
|
assert!(sum.is_monotonic);
|
||||||
|
assert_eq!(sum.data_points.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
sum.data_points[0].value,
|
||||||
|
Some(number_data_point::Value::AsDouble(1.25))
|
||||||
|
);
|
||||||
|
assert!(sum.data_points[0].attributes.is_empty());
|
||||||
|
let load = latest
|
||||||
|
.resource_metrics
|
||||||
|
.iter()
|
||||||
|
.flat_map(|resource| &resource.scope_metrics)
|
||||||
|
.flat_map(|scope| &scope.metrics)
|
||||||
|
.find(|metric| metric.name == "wakey.host.load")
|
||||||
|
.expect("host load exported");
|
||||||
|
let Some(metric::Data::Gauge(gauge)) = &load.data else {
|
||||||
|
panic!("host load must be a gauge");
|
||||||
|
};
|
||||||
|
assert_eq!(gauge.data_points.len(), 3);
|
||||||
|
for (period, expected) in [("1m", 0.5), ("5m", 1.0), ("15m", 1.5)] {
|
||||||
|
let point = gauge.data_points.iter().find(|point| point.attributes.iter().any(|attribute| {
|
||||||
|
attribute.key == "period" && attribute.value.as_ref().and_then(|value| value.value.as_ref())
|
||||||
|
== Some(&opentelemetry_proto::tonic::common::v1::any_value::Value::StringValue(period.into()))
|
||||||
|
})).expect("load period exported");
|
||||||
|
assert_eq!(point.attributes.len(), 1, "only fixed period labels");
|
||||||
|
assert_eq!(
|
||||||
|
point.value,
|
||||||
|
Some(number_data_point::Value::AsDouble(expected))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (name, label, expected) in [
|
||||||
|
(
|
||||||
|
"wakey.stored.devices",
|
||||||
|
"agent_id",
|
||||||
|
vec![("router", 2), ("empty-agent", 0)],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"wakey.stored.endpoints",
|
||||||
|
"agent_id",
|
||||||
|
vec![("router", 7), ("empty-agent", 0)],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"wakey.fleet.devices",
|
||||||
|
"identity",
|
||||||
|
vec![("saved", 1), ("unsaved", 1)],
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let metric = latest
|
||||||
|
.resource_metrics
|
||||||
|
.iter()
|
||||||
|
.flat_map(|r| &r.scope_metrics)
|
||||||
|
.flat_map(|s| &s.metrics)
|
||||||
|
.find(|m| m.name == name)
|
||||||
|
.unwrap();
|
||||||
|
let Some(metric::Data::Gauge(gauge)) = &metric.data else {
|
||||||
|
panic!("{name} must be a gauge");
|
||||||
|
};
|
||||||
|
assert_eq!(gauge.data_points.len(), expected.len());
|
||||||
|
for (value, count) in expected {
|
||||||
|
let point = gauge.data_points.iter().find(|p| p.attributes.iter().any(|a| {
|
||||||
|
a.key == label && a.value.as_ref().and_then(|v| v.value.as_ref())
|
||||||
|
== Some(&opentelemetry_proto::tonic::common::v1::any_value::Value::StringValue(value.into()))
|
||||||
|
})).unwrap();
|
||||||
|
assert_eq!(point.value, Some(number_data_point::Value::AsInt(count)));
|
||||||
|
assert_eq!(point.attributes.len(), 1, "no device/address labels");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "wakey-linux"
|
name = "wakey-linux"
|
||||||
version = "0.3.6"
|
version = "0.3.7"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
publish = ["gitea"]
|
publish = ["gitea"]
|
||||||
|
|
||||||
@@ -11,11 +11,18 @@ macaddr = { workspace = true }
|
|||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
tokio = { workspace = true, features = ["net", "sync"] }
|
tokio = { workspace = true, features = ["net", "sync"] }
|
||||||
|
tracing = "0.1"
|
||||||
wakey-core = { path = "../wakey-core", registry = "gitea", version = "0"}
|
wakey-core = { path = "../wakey-core", registry = "gitea", version = "0"}
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
serial_test = "3"
|
serial_test = "3"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
telemetry = ["wakey-core/telemetry", "dep:procfs"]
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
|
procfs = { version = "0.18", default-features = false, optional = true }
|
||||||
|
|
||||||
[dependencies.lda-ipjs]
|
[dependencies.lda-ipjs]
|
||||||
path = "../ipjs"
|
path = "../ipjs"
|
||||||
registry = "gitea"
|
registry = "gitea"
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ use futures::future::try_join_all;
|
|||||||
use lda_ipjs::subcommands::neighbor;
|
use lda_ipjs::subcommands::neighbor;
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::net::IpAddr;
|
use std::net::IpAddr;
|
||||||
|
use tracing::Instrument;
|
||||||
use wakey_core::{InventoryQuery, NeighborEntry, NeighborState, Query};
|
use wakey_core::{InventoryQuery, NeighborEntry, NeighborState, Query};
|
||||||
|
|
||||||
/// Resolve a hostname through the local resolver and return all reported IPs.
|
/// Resolve a hostname through the local resolver and return all reported IPs.
|
||||||
|
#[tracing::instrument(name = "neighbor_hostname_resolve", skip_all)]
|
||||||
pub async fn get_ips(machine_name: &str) -> Result<impl Iterator<Item = IpAddr>> {
|
pub async fn get_ips(machine_name: &str) -> Result<impl Iterator<Item = IpAddr>> {
|
||||||
Ok(tokio::net::lookup_host((machine_name, 0))
|
Ok(tokio::net::lookup_host((machine_name, 0))
|
||||||
.await
|
.await
|
||||||
@@ -54,6 +56,10 @@ pub async fn get_neighbors(
|
|||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
{
|
{
|
||||||
let results = neighbor::nl::get(&ip_filter, &dev_strs, &nud_filter, macs)
|
let results = neighbor::nl::get(&ip_filter, &dev_strs, &nud_filter, macs)
|
||||||
|
.instrument(tracing::info_span!(
|
||||||
|
"neighbor_table_read",
|
||||||
|
backend = "netlink"
|
||||||
|
))
|
||||||
.await
|
.await
|
||||||
.context("rtnetlink failed")?
|
.context("rtnetlink failed")?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -68,6 +74,7 @@ pub async fn get_neighbors(
|
|||||||
if ip_filter.is_empty() {
|
if ip_filter.is_empty() {
|
||||||
let dev = dev_strs.first().copied();
|
let dev = dev_strs.first().copied();
|
||||||
results = neighbor::get_with_backend(neighbor::Backend::Json, None, dev, &nud_filter)
|
results = neighbor::get_with_backend(neighbor::Backend::Json, None, dev, &nud_filter)
|
||||||
|
.instrument(tracing::info_span!("neighbor_table_read", backend = "json"))
|
||||||
.await
|
.await
|
||||||
.context("ip -j neigh failed")?
|
.context("ip -j neigh failed")?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -82,6 +89,7 @@ pub async fn get_neighbors(
|
|||||||
dev,
|
dev,
|
||||||
&nud_filter,
|
&nud_filter,
|
||||||
)
|
)
|
||||||
|
.instrument(tracing::info_span!("neighbor_table_read", backend = "json"))
|
||||||
.await
|
.await
|
||||||
.with_context(|| format!("ip -j neigh failed for {ip}"))?
|
.with_context(|| format!("ip -j neigh failed for {ip}"))?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
pub mod devices;
|
pub mod devices;
|
||||||
pub mod dhcp;
|
pub mod dhcp;
|
||||||
pub mod observations;
|
pub mod observations;
|
||||||
|
#[cfg(all(target_os = "linux", feature = "telemetry"))]
|
||||||
|
pub mod telemetry;
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
pub mod terminal;
|
pub mod terminal;
|
||||||
pub mod wake;
|
pub mod wake;
|
||||||
|
|||||||
@@ -165,17 +165,27 @@ pub async fn prune_stale_observations_from_path(
|
|||||||
path: impl AsRef<Path>,
|
path: impl AsRef<Path>,
|
||||||
retention_days: u64,
|
retention_days: u64,
|
||||||
) -> io::Result<usize> {
|
) -> io::Result<usize> {
|
||||||
if retention_days == 0 {
|
prune_expired(
|
||||||
return Ok(0);
|
path,
|
||||||
}
|
std::time::Duration::from_secs(retention_days.saturating_mul(24 * 60 * 60)),
|
||||||
let max_age_seconds = retention_days.saturating_mul(24 * 60 * 60);
|
)
|
||||||
prune_observations_older_than_from_path(path, now_unix().saturating_sub(max_age_seconds)).await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn prune_observations_older_than_from_path(
|
/// Remove hook observations older than `retention` from the given store.
|
||||||
|
/// Zero disables expiry; age is measured from each row's last-seen timestamp.
|
||||||
|
pub async fn prune_expired(
|
||||||
path: impl AsRef<Path>,
|
path: impl AsRef<Path>,
|
||||||
cutoff_unix: u64,
|
retention: std::time::Duration,
|
||||||
) -> io::Result<usize> {
|
) -> io::Result<usize> {
|
||||||
|
if retention.is_zero() {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
let max_age_seconds = retention.as_secs();
|
||||||
|
prune_before(path, now_unix().saturating_sub(max_age_seconds)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn prune_before(path: impl AsRef<Path>, cutoff_unix: u64) -> io::Result<usize> {
|
||||||
let path = path.as_ref();
|
let path = path.as_ref();
|
||||||
let mut store = load_observation_store_from_path(path).await?;
|
let mut store = load_observation_store_from_path(path).await?;
|
||||||
let before = store.dhcp_clients.len() + store.neighbors.len();
|
let before = store.dhcp_clients.len() + store.neighbors.len();
|
||||||
@@ -648,7 +658,7 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.expect("fixture should write");
|
.expect("fixture should write");
|
||||||
|
|
||||||
let removed = prune_observations_older_than_from_path(&observation_path, 15)
|
let removed = prune_before(&observation_path, 15)
|
||||||
.await
|
.await
|
||||||
.expect("prune should succeed");
|
.expect("prune should succeed");
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
//! Linux procfs reader for core's OS-independent process/host instruments.
|
||||||
|
use procfs::Current;
|
||||||
|
use wakey_core::telemetry::{HostLoad, HostMemory, ProcessMetricsSource};
|
||||||
|
|
||||||
|
/// Reads only this process and the visible host clock; never scans other PIDs.
|
||||||
|
/// Constructing the reader does no I/O, so disabled OTLP remains local-only.
|
||||||
|
pub struct ProcfsProcessMetrics;
|
||||||
|
|
||||||
|
impl ProcessMetricsSource for ProcfsProcessMetrics {
|
||||||
|
fn process_uptime_seconds(&self) -> Option<f64> {
|
||||||
|
process_uptime()
|
||||||
|
}
|
||||||
|
fn resident_memory_bytes(&self) -> Option<u64> {
|
||||||
|
resident_bytes()
|
||||||
|
}
|
||||||
|
fn host_uptime_seconds(&self) -> Option<f64> {
|
||||||
|
host_uptime()
|
||||||
|
}
|
||||||
|
fn cpu_time_seconds(&self) -> Option<f64> {
|
||||||
|
let stat = procfs::process::Process::myself().ok()?.stat().ok()?;
|
||||||
|
let ticks = procfs::ticks_per_second();
|
||||||
|
if ticks == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// utime/stime account for all process threads, not child processes.
|
||||||
|
Some(stat.utime.checked_add(stat.stime)? as f64 / ticks as f64)
|
||||||
|
}
|
||||||
|
fn open_file_descriptors(&self) -> Option<u64> {
|
||||||
|
let process = procfs::process::Process::myself().ok()?;
|
||||||
|
// procfs uses the kernel's count when available, otherwise enumerates
|
||||||
|
// directory entries without reading every FD's target. Sampling itself
|
||||||
|
// temporarily opens procfs descriptors, which can contribute to this count.
|
||||||
|
u64::try_from(process.fd_count().ok()?).ok()
|
||||||
|
}
|
||||||
|
fn host_memory(&self) -> Option<HostMemory> {
|
||||||
|
let memory = procfs::Meminfo::current().ok()?;
|
||||||
|
// Unlike Status::vmrss, Meminfo has already converted its fields to bytes.
|
||||||
|
// Do not substitute MemFree when an older kernel omits MemAvailable.
|
||||||
|
Some(HostMemory {
|
||||||
|
total_bytes: memory.mem_total,
|
||||||
|
available_bytes: memory.mem_available,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fn host_load(&self) -> Option<HostLoad> {
|
||||||
|
let load = procfs::LoadAverage::current().ok()?;
|
||||||
|
Some(HostLoad {
|
||||||
|
one_minute: f64::from(load.one),
|
||||||
|
five_minutes: f64::from(load.five),
|
||||||
|
fifteen_minutes: f64::from(load.fifteen),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn host_uptime() -> Option<f64> {
|
||||||
|
let seconds = procfs::Uptime::current().ok()?.uptime;
|
||||||
|
(seconds.is_finite() && seconds >= 0.0).then_some(seconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn process_uptime() -> Option<f64> {
|
||||||
|
let stat = procfs::process::Process::myself().ok()?.stat().ok()?;
|
||||||
|
let ticks = procfs::ticks_per_second();
|
||||||
|
if ticks == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// Both values use the kernel's boot-time clock, not wall time or telemetry
|
||||||
|
// initialization time. This also avoids parsing process names in stat ourselves.
|
||||||
|
let age = host_uptime()? - stat.starttime as f64 / ticks as f64;
|
||||||
|
(age.is_finite() && age >= 0.0).then_some(age)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resident_bytes() -> Option<u64> {
|
||||||
|
let status = procfs::process::Process::myself().ok()?.status().ok()?;
|
||||||
|
// procfs documents VmRSS in KiB even though the Linux file spells it "kB".
|
||||||
|
// It includes anonymous, file-backed and shared resident pages, not heap alone.
|
||||||
|
status.vmrss?.checked_mul(1024)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn current_process_and_visible_host_can_be_sampled() {
|
||||||
|
let source = ProcfsProcessMetrics;
|
||||||
|
let age = source
|
||||||
|
.process_uptime_seconds()
|
||||||
|
.expect("process start/uptime available");
|
||||||
|
let host = source.host_uptime_seconds().expect("host uptime available");
|
||||||
|
assert!(age.is_finite() && age >= 0.0);
|
||||||
|
assert!(host.is_finite() && host >= age);
|
||||||
|
assert!(source.resident_memory_bytes().expect("VmRSS available") > 0);
|
||||||
|
let cpu = source
|
||||||
|
.cpu_time_seconds()
|
||||||
|
.expect("process CPU ticks available");
|
||||||
|
assert!(cpu.is_finite() && cpu >= 0.0);
|
||||||
|
assert!(source.open_file_descriptors().expect("FD count available") > 0);
|
||||||
|
let memory = source.host_memory().expect("host memory available");
|
||||||
|
assert!(memory.total_bytes > 0);
|
||||||
|
if let Some(available) = memory.available_bytes {
|
||||||
|
assert!(available <= memory.total_bytes);
|
||||||
|
}
|
||||||
|
let load = source.host_load().expect("host load available");
|
||||||
|
for average in [load.one_minute, load.five_minutes, load.fifteen_minutes] {
|
||||||
|
assert!(average.is_finite() && average >= 0.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user