Compare commits
5
Commits
No files matched your search
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "UPDATE device_identifiers SET device_id = ?1 WHERE device_id = ?2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "7247cc488921de2313a602697590502771a231bde7d03cd9ccac55a70971da45"
|
||||
}
|
||||
Generated
+5
-5
@@ -3701,7 +3701,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "wakey"
|
||||
version = "0.2.2"
|
||||
version = "0.2.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -3722,7 +3722,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wakey-agent"
|
||||
version = "0.1.2"
|
||||
version = "0.2.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
@@ -3746,7 +3746,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wakey-control-plane"
|
||||
version = "0.1.2"
|
||||
version = "0.2.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -3774,7 +3774,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wakey-core"
|
||||
version = "0.2.2"
|
||||
version = "0.2.3"
|
||||
dependencies = [
|
||||
"macaddr",
|
||||
"serde",
|
||||
@@ -3785,7 +3785,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wakey-linux"
|
||||
version = "0.2.2"
|
||||
version = "0.2.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"futures",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "wakey"
|
||||
version = "0.2.2"
|
||||
version = "0.2.3"
|
||||
edition = "2024"
|
||||
publish = ["gitea"]
|
||||
|
||||
|
||||
Regular → Executable
File mode changed.
Regular → Executable
File mode changed.
@@ -104,8 +104,10 @@ main() {
|
||||
# Ensure execute bits on staged files we know should be executable
|
||||
for f in \
|
||||
"$STAGING/etc/init.d/"* \
|
||||
"$STAGING/etc/hotplug.d/"*/* \
|
||||
"$STAGING/etc/ldlda_help/"*.sh \
|
||||
"$STAGING/root/.bin/wakey" \
|
||||
"$STAGING/root/.bin/wakey-agent" \
|
||||
"$STAGING/root/.bin/kill_wakey.sh" \
|
||||
"$STAGING/root/.bin/remote_deploy_wakey.sh"; do
|
||||
[ -e "$f" ] && chmod +x "$f" 2>/dev/null || true
|
||||
@@ -114,11 +116,14 @@ main() {
|
||||
# Normalize line endings for shell scripts (avoid CRLF issues on OpenWrt)
|
||||
for f in \
|
||||
"$STAGING/etc/init.d/"* \
|
||||
"$STAGING/etc/hotplug.d/"*/* \
|
||||
"$STAGING/etc/ldlda_help/"*.sh \
|
||||
"$STAGING/root/.bin/"*.sh; do
|
||||
[ -f "$f" ] && sed -i 's/\r$//' "$f" 2>/dev/null || true
|
||||
done
|
||||
|
||||
chown -R root:root "$STAGING" 2>/dev/null || true
|
||||
|
||||
# Copy staged tree into /
|
||||
tar -C "$STAGING" -cf - . | tar -C / -xpf - || fail "install copy failed"
|
||||
|
||||
|
||||
@@ -82,6 +82,9 @@ if (Test-Path $hotplugSrc) {
|
||||
$content = Get-Content -Raw -LiteralPath $_.FullName
|
||||
$content = $content -replace "`r`n", "`n"
|
||||
Set-Content -NoNewline -LiteralPath $dest -Value $content -Encoding UTF8
|
||||
if (Get-Command chmod -ErrorAction SilentlyContinue) {
|
||||
chmod +x $dest
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +64,40 @@ sudo_cmd() {
|
||||
fail 'root privileges are required for install/restart steps; set SUDO or run as root'
|
||||
}
|
||||
|
||||
priv() {
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
$SUDO_BIN "$@"
|
||||
else
|
||||
"$@"
|
||||
fi
|
||||
}
|
||||
|
||||
restore_label() {
|
||||
path=$1
|
||||
if command -v restorecon >/dev/null 2>&1; then
|
||||
priv restorecon "$path" || true
|
||||
fi
|
||||
}
|
||||
|
||||
install_executable_force() {
|
||||
src=$1
|
||||
dst=$2
|
||||
dir=$(dirname "$dst")
|
||||
tmp="$dst.new.$$"
|
||||
old="$dst.old.$$"
|
||||
|
||||
priv mkdir -p "$dir"
|
||||
priv rm -f "$tmp"
|
||||
priv cp -f "$src" "$tmp"
|
||||
priv chmod 0755 "$tmp"
|
||||
if [ -e "$dst" ]; then
|
||||
priv mv -f "$dst" "$old" || true
|
||||
fi
|
||||
priv mv -f "$tmp" "$dst"
|
||||
priv rm -f "$old"
|
||||
restore_label "$dst"
|
||||
}
|
||||
|
||||
main() {
|
||||
REPO_URL="${WAKEY_CC_REPO_URL:-https://git.ldlda.com/lda/wakey.git}"
|
||||
REF="${WAKEY_CC_REF:-main}"
|
||||
@@ -124,26 +158,20 @@ main() {
|
||||
[ -f "$STAGING/bin/wakey-control-plane" ] || fail 'bundle missing bin/wakey-control-plane'
|
||||
[ -f "$STAGING/ui/dist/index.html" ] || fail 'bundle missing ui/dist/index.html'
|
||||
|
||||
if [ "$(id -u)" -ne 0 ] && [ -n "$SUDO_BIN" ]; then
|
||||
$SUDO_BIN mkdir -p "$ROOT"
|
||||
$SUDO_BIN cp -a "$STAGING/." "$ROOT/"
|
||||
else
|
||||
mkdir -p "$ROOT"
|
||||
cp -a "$STAGING/." "$ROOT/"
|
||||
fi
|
||||
priv mkdir -p "$ROOT"
|
||||
install_executable_force \
|
||||
"$STAGING/bin/wakey-control-plane" \
|
||||
"$ROOT/bin/wakey-control-plane"
|
||||
priv cp -a "$STAGING/ui" "$ROOT/"
|
||||
priv cp -a "$STAGING/scripts" "$ROOT/"
|
||||
priv cp -a "$STAGING/deploy" "$ROOT/"
|
||||
|
||||
if [ -z "${WAKEY_CC_NO_RESTART:-}" ] && command -v systemctl >/dev/null 2>&1; then
|
||||
if systemctl list-unit-files "$SERVICE" >/dev/null 2>&1; then
|
||||
log "restarting $SERVICE"
|
||||
if [ "$(id -u)" -ne 0 ] && [ -n "$SUDO_BIN" ]; then
|
||||
$SUDO_BIN systemctl daemon-reload
|
||||
$SUDO_BIN systemctl restart "$SERVICE"
|
||||
$SUDO_BIN systemctl --no-pager --full status "$SERVICE" | sed -n '1,16p'
|
||||
else
|
||||
systemctl daemon-reload
|
||||
systemctl restart "$SERVICE"
|
||||
systemctl --no-pager --full status "$SERVICE" | sed -n '1,16p'
|
||||
fi
|
||||
priv systemctl daemon-reload
|
||||
priv systemctl restart "$SERVICE"
|
||||
priv systemctl --no-pager --full status "$SERVICE" | sed -n '1,16p'
|
||||
else
|
||||
log "service $SERVICE not installed; skipped restart"
|
||||
fi
|
||||
|
||||
+134
@@ -122,6 +122,63 @@ export type AgentDeviceObservationEvent = {
|
||||
known_device: KnownDeviceSummary | null;
|
||||
};
|
||||
|
||||
export type FleetDeviceAgent = {
|
||||
agent_id: string;
|
||||
nickname?: string | null;
|
||||
connected: boolean;
|
||||
last_seen_unix: number;
|
||||
};
|
||||
|
||||
export type FleetWakeRoute = {
|
||||
route_id: string;
|
||||
agent_id: string;
|
||||
nickname?: string | null;
|
||||
connected: boolean;
|
||||
mac: string | null;
|
||||
ip: string | null;
|
||||
hostname: string | null;
|
||||
source: string;
|
||||
last_seen_unix: number;
|
||||
wakeable: boolean;
|
||||
};
|
||||
|
||||
export type FleetDevice = {
|
||||
device_key: string;
|
||||
display_name: string;
|
||||
known_device: KnownDeviceSummary | null;
|
||||
pinned: boolean;
|
||||
ips: string[];
|
||||
macs: string[];
|
||||
hostnames: string[];
|
||||
agents: FleetDeviceAgent[];
|
||||
sources: string[];
|
||||
first_seen_unix: number | null;
|
||||
last_seen_unix: number | null;
|
||||
presence: string;
|
||||
route_candidates: FleetWakeRoute[];
|
||||
recommended_route: FleetWakeRoute | null;
|
||||
};
|
||||
|
||||
export type RefreshFleetDevicesResponse = {
|
||||
total_accepted: number;
|
||||
agents: {
|
||||
agent_id: string;
|
||||
status: string;
|
||||
accepted: number;
|
||||
error: string | null;
|
||||
}[];
|
||||
};
|
||||
|
||||
export type WakeFleetDeviceResponse = {
|
||||
route: FleetWakeRoute;
|
||||
command: {
|
||||
request_id: string;
|
||||
status: string;
|
||||
result?: unknown;
|
||||
error?: { code: string; message: string; retryable?: boolean | null };
|
||||
};
|
||||
};
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
...init,
|
||||
@@ -208,6 +265,57 @@ export function fetchKnownDevices(): Promise<KnownDevice[]> {
|
||||
return request<KnownDevice[]>("/api/v1/control/devices");
|
||||
}
|
||||
|
||||
export function fetchFleetDevices(opts?: {
|
||||
query?: string;
|
||||
presence?: string;
|
||||
known?: string;
|
||||
agentId?: string;
|
||||
limit?: number;
|
||||
}): Promise<FleetDevice[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (opts?.query) params.set("query", opts.query);
|
||||
if (opts?.presence && opts.presence !== "all") {
|
||||
params.set("presence", opts.presence);
|
||||
}
|
||||
if (opts?.known && opts.known !== "all") params.set("known", opts.known);
|
||||
if (opts?.agentId) params.set("agent_id", opts.agentId);
|
||||
params.set("limit", String(opts?.limit ?? 500));
|
||||
return request<FleetDevice[]>(
|
||||
`/api/v1/control/fleet/devices?${params.toString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function refreshFleetDevices(input?: {
|
||||
agentIds?: string[];
|
||||
timeoutMs?: number;
|
||||
}): Promise<RefreshFleetDevicesResponse> {
|
||||
return request<RefreshFleetDevicesResponse>(
|
||||
"/api/v1/control/fleet/devices/refresh",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
agent_ids: input?.agentIds ?? [],
|
||||
timeout_ms: input?.timeoutMs ?? null,
|
||||
}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function wakeFleetDevice(input: {
|
||||
deviceKey: string;
|
||||
routeId?: string | null;
|
||||
timeoutMs?: number;
|
||||
}): Promise<WakeFleetDeviceResponse> {
|
||||
return request<WakeFleetDeviceResponse>("/api/v1/control/fleet/wake", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
device_key: input.deviceKey,
|
||||
route_id: input.routeId ?? null,
|
||||
timeout_ms: input.timeoutMs ?? null,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchObservations(opts?: {
|
||||
agentId?: string;
|
||||
limit?: number;
|
||||
@@ -270,6 +378,32 @@ export function attachObservationIdentifier(
|
||||
);
|
||||
}
|
||||
|
||||
export function attachDeviceIdentifier(
|
||||
deviceId: string,
|
||||
identifier: { kind: string; value: string },
|
||||
): Promise<KnownDevice> {
|
||||
return request<KnownDevice>(
|
||||
`/api/v1/control/devices/${encodeURIComponent(deviceId)}/identifiers`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(identifier),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function mergeKnownDevice(
|
||||
targetDeviceId: string,
|
||||
sourceDeviceId: string,
|
||||
): Promise<KnownDevice> {
|
||||
return request<KnownDevice>(
|
||||
`/api/v1/control/devices/${encodeURIComponent(targetDeviceId)}/merge`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ source_device_id: sourceDeviceId }),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function runCommand(
|
||||
agentId: string,
|
||||
kind: CommandKind,
|
||||
|
||||
+33
-11
@@ -1,18 +1,27 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { NavLink, Outlet } from "react-router-dom";
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
import { ChevronDown, Moon, Sun } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
const navItems = [
|
||||
["/", "Devices"],
|
||||
["/observations", "Observed"],
|
||||
["/commands", "Wake Tools"],
|
||||
["/dashboard", "Fleet Health"],
|
||||
] as const;
|
||||
|
||||
const adminItems = [
|
||||
["/agents", "Agents"],
|
||||
["/audit", "Audit"],
|
||||
["/alerts", "Alerts"],
|
||||
["/tokens", "Tokens"],
|
||||
["/alerts", "Alerts"],
|
||||
["/observations", "Raw Observations"],
|
||||
["/commands", "Command Runner"],
|
||||
["/audit", "Audit"],
|
||||
] as const;
|
||||
|
||||
type Theme = "light" | "dark";
|
||||
@@ -41,11 +50,9 @@ export function AppLayout() {
|
||||
<div className="mx-auto max-w-7xl p-4">
|
||||
<header className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
Wakey Operator UI
|
||||
</h1>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Wakey</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Find devices fast and wake them reliably
|
||||
Fleet devices, presence, and wake controls
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
@@ -70,7 +77,7 @@ export function AppLayout() {
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<nav className="mb-3 flex flex-wrap gap-2">
|
||||
<nav className="mb-3 flex flex-wrap items-center gap-2">
|
||||
{navItems.map(([to, label]) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
@@ -78,7 +85,7 @@ export function AppLayout() {
|
||||
end={to === "/"}
|
||||
className={({ isActive }) =>
|
||||
[
|
||||
"rounded-full border px-3 py-1.5 text-sm transition-colors",
|
||||
"rounded-md border px-3 py-1.5 text-sm transition-colors",
|
||||
isActive
|
||||
? "border-primary/60 bg-primary/10 text-foreground"
|
||||
: "border-border bg-card text-muted-foreground hover:text-foreground",
|
||||
@@ -88,6 +95,21 @@ export function AppLayout() {
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="inline-flex items-center gap-1 rounded-md border border-border bg-card px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground">
|
||||
Admin / Debug
|
||||
<ChevronDown className="size-4" aria-hidden />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-48">
|
||||
{adminItems.map(([to, label]) => (
|
||||
<DropdownMenuItem key={to}>
|
||||
<NavLink className="w-full" to={to}>
|
||||
{label}
|
||||
</NavLink>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</nav>
|
||||
|
||||
<main>
|
||||
|
||||
+750
-447
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "wakey-agent"
|
||||
version = "0.1.2"
|
||||
version = "0.2.3"
|
||||
edition = "2024"
|
||||
publish = ["gitea"]
|
||||
|
||||
|
||||
@@ -124,12 +124,21 @@ async fn main() -> Result<()> {
|
||||
fn observe(args: cli::ObserveArgs) -> Result<()> {
|
||||
let mut cmd = std::process::Command::new(resolve_wakey_binary());
|
||||
cmd.arg("observe");
|
||||
let config_path = args.config.clone();
|
||||
if let Ok(config) = config::load_config(&args.config) {
|
||||
config::apply_local_path_env_to_command(&mut cmd, &config);
|
||||
}
|
||||
|
||||
match args.command {
|
||||
ObserveCommand::Dhcp(args) => {
|
||||
::tracing::debug!(
|
||||
action = %args.action,
|
||||
mac = %args.mac,
|
||||
ip = ?args.ip,
|
||||
hostname = ?args.hostname,
|
||||
config = %config_path.display(),
|
||||
"forwarding dhcp observation to wakey"
|
||||
);
|
||||
cmd.arg("dhcp")
|
||||
.arg("--action")
|
||||
.arg(args.action)
|
||||
@@ -143,6 +152,13 @@ fn observe(args: cli::ObserveArgs) -> Result<()> {
|
||||
}
|
||||
}
|
||||
ObserveCommand::Neigh(args) => {
|
||||
::tracing::debug!(
|
||||
action = %args.action,
|
||||
mac = ?args.mac,
|
||||
ip = ?args.ip,
|
||||
config = %config_path.display(),
|
||||
"forwarding neighbor observation to wakey"
|
||||
);
|
||||
cmd.arg("neigh").arg("--action").arg(args.action);
|
||||
if let Some(mac) = args.mac {
|
||||
cmd.arg("--mac").arg(mac);
|
||||
@@ -154,6 +170,7 @@ fn observe(args: cli::ObserveArgs) -> Result<()> {
|
||||
}
|
||||
|
||||
let status = cmd.status()?;
|
||||
::tracing::debug!(%status, "wakey observe exited");
|
||||
if !status.success() {
|
||||
anyhow::bail!("wakey observe exited with {status}");
|
||||
}
|
||||
|
||||
@@ -130,6 +130,17 @@ pub struct WakeRequest {
|
||||
pub ip: Option<IpAddr>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AgentObservation {
|
||||
pub kind: String,
|
||||
pub action: String,
|
||||
pub mac: Option<String>,
|
||||
pub ip: Option<IpAddr>,
|
||||
pub hostname: Option<String>,
|
||||
pub first_seen_unix: u64,
|
||||
pub last_seen_unix: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum AgentCommand {
|
||||
@@ -161,6 +172,10 @@ pub enum ClientMessage {
|
||||
Heartbeat {
|
||||
agent_id: String,
|
||||
},
|
||||
Observations {
|
||||
agent_id: String,
|
||||
observations: Vec<AgentObservation>,
|
||||
},
|
||||
Result {
|
||||
request_id: RequestId,
|
||||
result: CommandResult,
|
||||
@@ -178,6 +193,7 @@ pub enum ServerMessage {
|
||||
request_id: RequestId,
|
||||
command: AgentCommand,
|
||||
},
|
||||
SyncObservations,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -215,4 +231,24 @@ mod tests {
|
||||
assert!(json.contains("\"type\":\"result\""));
|
||||
assert!(json.contains("\"kind\":\"devs\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn observations_message_serializes() {
|
||||
let msg = ClientMessage::Observations {
|
||||
agent_id: "agent-a".into(),
|
||||
observations: vec![AgentObservation {
|
||||
kind: "dhcp".into(),
|
||||
action: "update".into(),
|
||||
mac: Some("aa:bb:cc:dd:ee:ff".into()),
|
||||
ip: Some("192.168.1.10".parse().expect("ip")),
|
||||
hostname: Some("lda".into()),
|
||||
first_seen_unix: 10,
|
||||
last_seen_unix: 20,
|
||||
}],
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&msg).expect("serialize");
|
||||
assert!(json.contains("\"type\":\"observations\""));
|
||||
assert!(json.contains("\"kind\":\"dhcp\""));
|
||||
}
|
||||
}
|
||||
+55
-32
@@ -9,7 +9,7 @@ use tracing::{debug, error, info, info_span, warn};
|
||||
|
||||
use crate::config::AgentConfig;
|
||||
use crate::dispatch::dispatch_command;
|
||||
use crate::protocol::{AgentCommand, ClientMessage, ErrorPayload, ServerMessage};
|
||||
use crate::protocol::{AgentCommand, AgentObservation, ClientMessage, ErrorPayload, ServerMessage};
|
||||
|
||||
pub async fn run(config: AgentConfig) -> Result<()> {
|
||||
let mut backoff = config.reconnect_base_ms.max(100);
|
||||
@@ -85,13 +85,13 @@ async fn run_once(config: &AgentConfig) -> Result<()> {
|
||||
.await?;
|
||||
info!(agent_id = %config.agent_id, "agent websocket session authenticated");
|
||||
|
||||
let http_client = reqwest::Client::new();
|
||||
let mut heartbeat = interval(Duration::from_secs(30));
|
||||
heartbeat.set_missed_tick_behavior(MissedTickBehavior::Skip);
|
||||
let mut observation_sync = interval(Duration::from_secs(
|
||||
config.observation_sync_interval_seconds.max(1),
|
||||
));
|
||||
observation_sync.set_missed_tick_behavior(MissedTickBehavior::Skip);
|
||||
observation_sync.reset();
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
@@ -102,7 +102,7 @@ async fn run_once(config: &AgentConfig) -> Result<()> {
|
||||
debug!(agent_id = %config.agent_id, "heartbeat sent");
|
||||
}
|
||||
_ = observation_sync.tick() => {
|
||||
if let Err(err) = send_agent_observations(&http_client, config).await {
|
||||
if let Err(err) = send_agent_observations_ws(&mut sink, config).await {
|
||||
warn!(agent_id = %config.agent_id, error = %err, "failed to sync local observations");
|
||||
}
|
||||
}
|
||||
@@ -119,7 +119,7 @@ async fn run_once(config: &AgentConfig) -> Result<()> {
|
||||
Message::Text(text) => {
|
||||
match serde_json::from_str::<ServerMessage>(&text) {
|
||||
Ok(message) => {
|
||||
handle_server_message(config, &mut sink, message).await?;
|
||||
handle_server_message(config, &mut sink, &mut observation_sync, message).await?;
|
||||
}
|
||||
Err(err) => {
|
||||
// Allow the server to introduce extra frame types without
|
||||
@@ -150,18 +150,7 @@ async fn run_once(config: &AgentConfig) -> Result<()> {
|
||||
struct UploadAgentObservationsRequest {
|
||||
agent_id: String,
|
||||
agent_token: String,
|
||||
observations: Vec<AgentObservationRequest>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct AgentObservationRequest {
|
||||
kind: String,
|
||||
action: String,
|
||||
mac: Option<String>,
|
||||
ip: Option<IpAddr>,
|
||||
hostname: Option<String>,
|
||||
first_seen_unix: u64,
|
||||
last_seen_unix: u64,
|
||||
observations: Vec<AgentObservation>,
|
||||
}
|
||||
|
||||
pub async fn sync_observations_once(config: &AgentConfig) -> Result<usize> {
|
||||
@@ -170,10 +159,7 @@ pub async fn sync_observations_once(config: &AgentConfig) -> Result<usize> {
|
||||
}
|
||||
|
||||
async fn send_agent_observations(client: &reqwest::Client, config: &AgentConfig) -> Result<usize> {
|
||||
let observations =
|
||||
wakey::wakey_linux::dhcp::list_local_observations_from_path(&config.observation_store_path)
|
||||
.await
|
||||
.context("failed to read local observations")?;
|
||||
let observations = load_agent_observations(config).await?;
|
||||
if observations.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
@@ -182,18 +168,7 @@ async fn send_agent_observations(client: &reqwest::Client, config: &AgentConfig)
|
||||
let payload = UploadAgentObservationsRequest {
|
||||
agent_id: config.agent_id.clone(),
|
||||
agent_token: config.agent_token.clone(),
|
||||
observations: observations
|
||||
.into_iter()
|
||||
.map(|observation| AgentObservationRequest {
|
||||
kind: observation.kind,
|
||||
action: observation.action,
|
||||
mac: observation.mac,
|
||||
ip: observation.ip,
|
||||
hostname: observation.hostname,
|
||||
first_seen_unix: observation.first_seen_unix,
|
||||
last_seen_unix: observation.last_seen_unix,
|
||||
})
|
||||
.collect(),
|
||||
observations,
|
||||
};
|
||||
|
||||
let response = client
|
||||
@@ -218,6 +193,47 @@ async fn send_agent_observations(client: &reqwest::Client, config: &AgentConfig)
|
||||
Ok(payload.observations.len())
|
||||
}
|
||||
|
||||
async fn send_agent_observations_ws<S>(sink: &mut S, config: &AgentConfig) -> Result<usize>
|
||||
where
|
||||
S: SinkExt<Message> + Unpin,
|
||||
<S as futures_util::Sink<Message>>::Error: std::error::Error + Send + Sync + 'static,
|
||||
{
|
||||
let observations = load_agent_observations(config).await?;
|
||||
if observations.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
let count = observations.len();
|
||||
send_json(
|
||||
sink,
|
||||
&ClientMessage::Observations {
|
||||
agent_id: config.agent_id.clone(),
|
||||
observations,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
debug!(agent_id = %config.agent_id, observations = count, "sent observations over websocket");
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
async fn load_agent_observations(config: &AgentConfig) -> Result<Vec<AgentObservation>> {
|
||||
let observations =
|
||||
wakey::wakey_linux::dhcp::list_local_observations_from_path(&config.observation_store_path)
|
||||
.await
|
||||
.context("failed to read local observations")?;
|
||||
Ok(observations
|
||||
.into_iter()
|
||||
.map(|observation| AgentObservation {
|
||||
kind: observation.kind,
|
||||
action: observation.action,
|
||||
mac: observation.mac,
|
||||
ip: observation.ip,
|
||||
hostname: observation.hostname,
|
||||
first_seen_unix: observation.first_seen_unix,
|
||||
last_seen_unix: observation.last_seen_unix,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub fn next_backoff_ms(current_ms: u64, max_ms: u64) -> u64 {
|
||||
let cap = max_ms.max(current_ms);
|
||||
current_ms.saturating_mul(2).min(cap)
|
||||
@@ -226,6 +242,7 @@ pub fn next_backoff_ms(current_ms: u64, max_ms: u64) -> u64 {
|
||||
async fn handle_server_message<S>(
|
||||
config: &AgentConfig,
|
||||
sink: &mut S,
|
||||
observation_sync: &mut tokio::time::Interval,
|
||||
message: ServerMessage,
|
||||
) -> Result<()>
|
||||
where
|
||||
@@ -261,6 +278,11 @@ where
|
||||
}
|
||||
}
|
||||
}
|
||||
ServerMessage::SyncObservations => {
|
||||
info!("received observation sync request from control-plane");
|
||||
send_agent_observations_ws(sink, config).await?;
|
||||
observation_sync.reset();
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -336,6 +358,7 @@ fn client_message_kind(message: &ClientMessage) -> &'static str {
|
||||
ClientMessage::Hello { .. } => "hello",
|
||||
ClientMessage::Auth { .. } => "auth",
|
||||
ClientMessage::Heartbeat { .. } => "heartbeat",
|
||||
ClientMessage::Observations { .. } => "observations",
|
||||
ClientMessage::Result { .. } => "result",
|
||||
ClientMessage::Error { .. } => "error",
|
||||
}
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "UPDATE device_identifiers SET device_id = ?1 WHERE device_id = ?2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "7247cc488921de2313a602697590502771a231bde7d03cd9ccac55a70971da45"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "wakey-control-plane"
|
||||
version = "0.1.2"
|
||||
version = "0.2.3"
|
||||
edition = "2024"
|
||||
publish = ["gitea"]
|
||||
|
||||
|
||||
@@ -58,14 +58,26 @@ pub async fn run_command(
|
||||
AxumPath(agent_id): AxumPath<String>,
|
||||
Json(req): Json<RelayCommandRequest>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
|
||||
match relay_agent_command(&state, &agent_id, req.command, req.timeout_ms).await {
|
||||
Ok(response) => Ok((StatusCode::OK, Json(response))),
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn relay_agent_command(
|
||||
state: &AppState,
|
||||
agent_id: &str,
|
||||
command: AgentCommand,
|
||||
timeout_ms: Option<u64>,
|
||||
) -> Result<RelayCommandResponse, (StatusCode, Json<serde_json::Value>)> {
|
||||
let request_id_string = format!("req-{}", Uuid::new_v4());
|
||||
let command = command_kind(&req.command);
|
||||
let command_kind = command_kind(&command);
|
||||
let started = Instant::now();
|
||||
let span = info_span!(
|
||||
"relay_command",
|
||||
agent_id = %agent_id,
|
||||
request_id = %request_id_string,
|
||||
command = %command,
|
||||
command = %command_kind,
|
||||
);
|
||||
let _span_guard = span.enter();
|
||||
|
||||
@@ -79,7 +91,7 @@ pub async fn run_command(
|
||||
|
||||
let tx = {
|
||||
let sessions = state.sessions.read().await;
|
||||
sessions.get(&agent_id).map(|session| session.tx.clone())
|
||||
sessions.get(agent_id).map(|session| session.tx.clone())
|
||||
}
|
||||
.ok_or_else(|| {
|
||||
warn!("command rejected: agent not connected");
|
||||
@@ -103,13 +115,13 @@ pub async fn run_command(
|
||||
.append_audit_event(AuditEventInput {
|
||||
actor_type: "admin_api".into(),
|
||||
actor_id: None,
|
||||
agent_id: Some(agent_id.clone()),
|
||||
agent_id: Some(agent_id.to_string()),
|
||||
request_id: Some(request_id_string.clone()),
|
||||
event_type: "command_dispatch".into(),
|
||||
outcome: "sent".into(),
|
||||
latency_ms: None,
|
||||
message: "dispatched command to connected agent".into(),
|
||||
metadata: serde_json::json!({ "command": command }),
|
||||
metadata: serde_json::json!({ "command": command_kind }),
|
||||
})
|
||||
.await
|
||||
{
|
||||
@@ -118,7 +130,7 @@ pub async fn run_command(
|
||||
|
||||
if let Err(err) = tx.send(SessionEvent::Message(ServerMessage::Command {
|
||||
request_id,
|
||||
command: req.command,
|
||||
command,
|
||||
})) {
|
||||
state.pending.lock().await.remove(&request_id_string);
|
||||
warn!(error = %err, "failed sending command to agent session");
|
||||
@@ -127,13 +139,13 @@ pub async fn run_command(
|
||||
.append_audit_event(AuditEventInput {
|
||||
actor_type: "admin_api".into(),
|
||||
actor_id: None,
|
||||
agent_id: Some(agent_id.clone()),
|
||||
agent_id: Some(agent_id.to_string()),
|
||||
request_id: Some(request_id_string.clone()),
|
||||
event_type: "command_dispatch".into(),
|
||||
outcome: "send_failed".into(),
|
||||
latency_ms: Some(started.elapsed().as_millis() as u64),
|
||||
message: err.to_string(),
|
||||
metadata: serde_json::json!({ "command": command }),
|
||||
metadata: serde_json::json!({ "command": command_kind }),
|
||||
})
|
||||
.await
|
||||
{
|
||||
@@ -147,7 +159,7 @@ pub async fn run_command(
|
||||
}
|
||||
|
||||
let timeout = std::time::Duration::from_millis(
|
||||
req.timeout_ms
|
||||
timeout_ms
|
||||
.unwrap_or(state.command_timeout.as_millis() as u64)
|
||||
.max(1),
|
||||
);
|
||||
@@ -160,13 +172,13 @@ pub async fn run_command(
|
||||
.append_audit_event(AuditEventInput {
|
||||
actor_type: "admin_api".into(),
|
||||
actor_id: None,
|
||||
agent_id: Some(agent_id.clone()),
|
||||
agent_id: Some(agent_id.to_string()),
|
||||
request_id: Some(request_id_string.clone()),
|
||||
event_type: "command_result".into(),
|
||||
outcome: "ok".into(),
|
||||
latency_ms: Some(started.elapsed().as_millis() as u64),
|
||||
message: "agent command completed".into(),
|
||||
metadata: serde_json::json!({ "command": command }),
|
||||
metadata: serde_json::json!({ "command": command_kind }),
|
||||
})
|
||||
.await
|
||||
{
|
||||
@@ -186,13 +198,13 @@ pub async fn run_command(
|
||||
.append_audit_event(AuditEventInput {
|
||||
actor_type: "admin_api".into(),
|
||||
actor_id: None,
|
||||
agent_id: Some(agent_id.clone()),
|
||||
agent_id: Some(agent_id.to_string()),
|
||||
request_id: Some(request_id_string.clone()),
|
||||
event_type: "command_result".into(),
|
||||
outcome: "error".into(),
|
||||
latency_ms: Some(started.elapsed().as_millis() as u64),
|
||||
message: error.message.clone(),
|
||||
metadata: serde_json::json!({ "command": command, "code": error.code }),
|
||||
metadata: serde_json::json!({ "command": command_kind, "code": error.code }),
|
||||
})
|
||||
.await
|
||||
{
|
||||
@@ -212,13 +224,13 @@ pub async fn run_command(
|
||||
.append_audit_event(AuditEventInput {
|
||||
actor_type: "admin_api".into(),
|
||||
actor_id: None,
|
||||
agent_id: Some(agent_id.clone()),
|
||||
agent_id: Some(agent_id.to_string()),
|
||||
request_id: Some(request_id_string.clone()),
|
||||
event_type: "command_result".into(),
|
||||
outcome: "response_dropped".into(),
|
||||
latency_ms: Some(started.elapsed().as_millis() as u64),
|
||||
message: "agent response channel dropped".into(),
|
||||
metadata: serde_json::json!({ "command": command }),
|
||||
metadata: serde_json::json!({ "command": command_kind }),
|
||||
})
|
||||
.await
|
||||
{
|
||||
@@ -241,13 +253,13 @@ pub async fn run_command(
|
||||
.append_audit_event(AuditEventInput {
|
||||
actor_type: "admin_api".into(),
|
||||
actor_id: None,
|
||||
agent_id: Some(agent_id.clone()),
|
||||
agent_id: Some(agent_id.to_string()),
|
||||
request_id: Some(request_id_string.clone()),
|
||||
event_type: "command_result".into(),
|
||||
outcome: "timeout".into(),
|
||||
latency_ms: Some(started.elapsed().as_millis() as u64),
|
||||
message: "agent command timed out".into(),
|
||||
metadata: serde_json::json!({ "command": command, "timeout_ms": timeout.as_millis() as u64 }),
|
||||
metadata: serde_json::json!({ "command": command_kind, "timeout_ms": timeout.as_millis() as u64 }),
|
||||
})
|
||||
.await
|
||||
{
|
||||
@@ -261,7 +273,7 @@ pub async fn run_command(
|
||||
}
|
||||
};
|
||||
|
||||
Ok((StatusCode::OK, Json(response)))
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn command_kind(command: &AgentCommand) -> &'static str {
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
mod devices;
|
||||
mod enroll;
|
||||
mod fleet;
|
||||
mod observations;
|
||||
mod stats;
|
||||
|
||||
pub use devices::{
|
||||
attach_device_identifier, attach_observation_identifier, create_known_device,
|
||||
forget_known_device, list_known_devices,
|
||||
forget_known_device, list_known_devices, merge_known_device,
|
||||
};
|
||||
pub use enroll::{
|
||||
EnrollTokenStatus, IssueEnrollTokenResponse, RevokeAgentResponse, RevokeEnrollTokenResponse,
|
||||
enroll, healthz, issue_enroll_token, list_enroll_tokens, revoke_agent, revoke_enroll_token,
|
||||
set_agent_nickname,
|
||||
};
|
||||
pub use fleet::{list_fleet_devices, refresh_fleet_devices, wake_fleet_device};
|
||||
pub use observations::{
|
||||
list_agent_observation_history, list_agent_observations, upload_agent_observations,
|
||||
list_agent_observation_history, list_agent_observations, request_agent_observation_sync,
|
||||
upload_agent_observations,
|
||||
};
|
||||
pub use stats::{StateStatsResponse, state_stats};
|
||||
@@ -56,6 +56,11 @@ pub struct ForgetKnownDeviceResponse {
|
||||
pub forgotten: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct MergeKnownDeviceRequest {
|
||||
pub source_device_id: String,
|
||||
}
|
||||
|
||||
pub async fn create_known_device(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<CreateKnownDeviceRequest>,
|
||||
@@ -193,6 +198,33 @@ pub async fn attach_observation_identifier(
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn merge_known_device(
|
||||
State(state): State<AppState>,
|
||||
AxumPath(device_id): AxumPath<String>,
|
||||
Json(req): Json<MergeKnownDeviceRequest>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
|
||||
match state
|
||||
.store
|
||||
.merge_known_devices(&device_id, &req.source_device_id)
|
||||
.await
|
||||
{
|
||||
Ok(Some(device)) => Ok((StatusCode::OK, Json(known_device_response(device)))),
|
||||
Ok(None) => Err(json_error(
|
||||
StatusCode::NOT_FOUND,
|
||||
"known_device_not_found",
|
||||
"target or source known device not found",
|
||||
)),
|
||||
Err(err) => {
|
||||
warn!(error = %err, "failed to merge known devices");
|
||||
Err(json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"merge_known_device_failed",
|
||||
&err.to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn known_device_response(device: crate::state::KnownDevice) -> KnownDeviceResponse {
|
||||
KnownDeviceResponse {
|
||||
device_id: device.device_id,
|
||||
|
||||
@@ -0,0 +1,953 @@
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||
use std::net::IpAddr;
|
||||
|
||||
use axum::Json;
|
||||
use axum::extract::{Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::IntoResponse;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::warn;
|
||||
use wakey_agent::protocol::{AgentCommand, InventoryRequest, WakeRequest};
|
||||
|
||||
use crate::api::commands::{RelayCommandResponse, relay_agent_command};
|
||||
use crate::api::json_error;
|
||||
use crate::runtime::AppState;
|
||||
use crate::state::{
|
||||
AgentDeviceObservation, AgentDeviceObservationInput, DeviceIdentifier, KnownDevice,
|
||||
KnownDeviceSummary,
|
||||
};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListFleetDevicesQuery {
|
||||
pub query: Option<String>,
|
||||
pub presence: Option<String>,
|
||||
pub known: Option<String>,
|
||||
pub agent_id: Option<String>,
|
||||
pub limit: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RefreshFleetDevicesRequest {
|
||||
#[serde(default)]
|
||||
pub agent_ids: Vec<String>,
|
||||
pub timeout_ms: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct RefreshFleetDevicesResponse {
|
||||
pub total_accepted: usize,
|
||||
pub agents: Vec<RefreshFleetAgentResult>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct RefreshFleetAgentResult {
|
||||
pub agent_id: String,
|
||||
pub status: String,
|
||||
pub accepted: usize,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct WakeFleetDeviceRequest {
|
||||
pub device_key: String,
|
||||
pub route_id: Option<String>,
|
||||
pub timeout_ms: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct WakeFleetDeviceResponse {
|
||||
pub route: FleetWakeRoute,
|
||||
pub command: RelayCommandResponse,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FleetDevice {
|
||||
pub device_key: String,
|
||||
pub display_name: String,
|
||||
pub known_device: Option<KnownDeviceSummary>,
|
||||
pub pinned: bool,
|
||||
pub ips: Vec<String>,
|
||||
pub macs: Vec<String>,
|
||||
pub hostnames: Vec<String>,
|
||||
pub agents: Vec<FleetDeviceAgent>,
|
||||
pub sources: Vec<String>,
|
||||
pub first_seen_unix: Option<u64>,
|
||||
pub last_seen_unix: Option<u64>,
|
||||
pub presence: String,
|
||||
pub route_candidates: Vec<FleetWakeRoute>,
|
||||
pub recommended_route: Option<FleetWakeRoute>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FleetDeviceAgent {
|
||||
pub agent_id: String,
|
||||
pub nickname: Option<String>,
|
||||
pub connected: bool,
|
||||
pub last_seen_unix: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FleetWakeRoute {
|
||||
pub route_id: String,
|
||||
pub agent_id: String,
|
||||
pub nickname: Option<String>,
|
||||
pub connected: bool,
|
||||
pub mac: Option<String>,
|
||||
pub ip: Option<String>,
|
||||
pub hostname: Option<String>,
|
||||
pub source: String,
|
||||
pub last_seen_unix: u64,
|
||||
pub wakeable: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct FleetBuildContext {
|
||||
agent_status: HashMap<String, AgentRuntimeStatus>,
|
||||
identifier_map: HashMap<String, KnownDeviceSummary>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct AgentRuntimeStatus {
|
||||
nickname: Option<String>,
|
||||
connected: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct FleetAccumulator {
|
||||
device_key: String,
|
||||
display_name: Option<String>,
|
||||
known_device: Option<KnownDeviceSummary>,
|
||||
pinned: bool,
|
||||
ips: BTreeSet<String>,
|
||||
macs: BTreeSet<String>,
|
||||
hostnames: BTreeSet<String>,
|
||||
sources: BTreeSet<String>,
|
||||
agents: BTreeMap<String, FleetDeviceAgent>,
|
||||
first_seen_unix: Option<u64>,
|
||||
last_seen_unix: Option<u64>,
|
||||
presence_rank: u8,
|
||||
routes: BTreeMap<String, FleetWakeRoute>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct InventoryEnvelope {
|
||||
kind: String,
|
||||
devices: Vec<InventoryDevice>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct InventoryDevice {
|
||||
#[serde(default)]
|
||||
names: Vec<String>,
|
||||
#[serde(default)]
|
||||
ips: Vec<IpAddr>,
|
||||
#[serde(default)]
|
||||
macs: Vec<String>,
|
||||
#[serde(default)]
|
||||
presence: String,
|
||||
}
|
||||
|
||||
pub async fn list_fleet_devices(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<ListFleetDevicesQuery>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
|
||||
match load_fleet_devices(&state, &query).await {
|
||||
Ok(devices) => Ok((StatusCode::OK, Json(devices))),
|
||||
Err(err) => {
|
||||
warn!(error = %err, "failed to list fleet devices");
|
||||
Err(json_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"list_fleet_devices_failed",
|
||||
&err.to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn refresh_fleet_devices(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<RefreshFleetDevicesRequest>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
|
||||
let mut agent_ids = if req.agent_ids.is_empty() {
|
||||
let sessions = state.sessions.read().await;
|
||||
sessions.keys().cloned().collect::<Vec<_>>()
|
||||
} else {
|
||||
req.agent_ids.clone()
|
||||
};
|
||||
agent_ids.sort();
|
||||
agent_ids.dedup();
|
||||
|
||||
let mut results = Vec::with_capacity(agent_ids.len());
|
||||
let mut total_accepted = 0usize;
|
||||
for agent_id in agent_ids {
|
||||
let response = relay_agent_command(
|
||||
&state,
|
||||
&agent_id,
|
||||
AgentCommand::Inventory(InventoryRequest {
|
||||
query: None,
|
||||
name: None,
|
||||
ips: Vec::new(),
|
||||
devs: Vec::new(),
|
||||
nuds: Vec::new(),
|
||||
macs: Vec::new(),
|
||||
}),
|
||||
req.timeout_ms,
|
||||
)
|
||||
.await;
|
||||
|
||||
match response {
|
||||
Ok(response) if response.status == "ok" => {
|
||||
let Some(result) = response.result else {
|
||||
results.push(RefreshFleetAgentResult {
|
||||
agent_id,
|
||||
status: "error".into(),
|
||||
accepted: 0,
|
||||
error: Some("inventory command returned no result".into()),
|
||||
});
|
||||
continue;
|
||||
};
|
||||
match inventory_result_to_observations(result) {
|
||||
Ok(observations) => match state
|
||||
.store
|
||||
.upsert_agent_observations(&agent_id, observations)
|
||||
.await
|
||||
{
|
||||
Ok(accepted) => {
|
||||
total_accepted = total_accepted.saturating_add(accepted);
|
||||
results.push(RefreshFleetAgentResult {
|
||||
agent_id,
|
||||
status: "ok".into(),
|
||||
accepted,
|
||||
error: None,
|
||||
});
|
||||
}
|
||||
Err(err) => results.push(RefreshFleetAgentResult {
|
||||
agent_id,
|
||||
status: "error".into(),
|
||||
accepted: 0,
|
||||
error: Some(err.to_string()),
|
||||
}),
|
||||
},
|
||||
Err(err) => results.push(RefreshFleetAgentResult {
|
||||
agent_id,
|
||||
status: "error".into(),
|
||||
accepted: 0,
|
||||
error: Some(err.to_string()),
|
||||
}),
|
||||
}
|
||||
}
|
||||
Ok(response) => results.push(RefreshFleetAgentResult {
|
||||
agent_id,
|
||||
status: "error".into(),
|
||||
accepted: 0,
|
||||
error: response
|
||||
.error
|
||||
.map(|error| error.message)
|
||||
.or_else(|| Some("inventory command failed".into())),
|
||||
}),
|
||||
Err((status, body)) => results.push(RefreshFleetAgentResult {
|
||||
agent_id,
|
||||
status: "error".into(),
|
||||
accepted: 0,
|
||||
error: Some(format!("{status}: {}", body.0)),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(RefreshFleetDevicesResponse {
|
||||
total_accepted,
|
||||
agents: results,
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn wake_fleet_device(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<WakeFleetDeviceRequest>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
|
||||
let query = ListFleetDevicesQuery {
|
||||
query: None,
|
||||
presence: None,
|
||||
known: None,
|
||||
agent_id: None,
|
||||
limit: Some(1000),
|
||||
};
|
||||
let devices = load_fleet_devices(&state, &query).await.map_err(|err| {
|
||||
warn!(error = %err, "failed loading fleet devices for wake");
|
||||
json_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"load_fleet_devices_failed",
|
||||
&err.to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let device = devices
|
||||
.into_iter()
|
||||
.find(|device| device.device_key == req.device_key)
|
||||
.ok_or_else(|| {
|
||||
json_error(
|
||||
StatusCode::NOT_FOUND,
|
||||
"fleet_device_not_found",
|
||||
"fleet device not found",
|
||||
)
|
||||
})?;
|
||||
|
||||
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(|| {
|
||||
json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"wake_route_unavailable",
|
||||
"no wakeable connected MAC-backed route is available",
|
||||
)
|
||||
})?;
|
||||
|
||||
let Some(mac) = route.mac.as_deref() else {
|
||||
return Err(json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"wake_route_unavailable",
|
||||
"selected route does not include a MAC address",
|
||||
));
|
||||
};
|
||||
if !route.connected {
|
||||
return Err(json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"wake_route_unavailable",
|
||||
"selected route agent is not connected",
|
||||
));
|
||||
}
|
||||
|
||||
let mac = mac.parse().map_err(|err| {
|
||||
json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid_wake_route",
|
||||
&format!("invalid route MAC: {err}"),
|
||||
)
|
||||
})?;
|
||||
let ip = route
|
||||
.ip
|
||||
.as_deref()
|
||||
.map(str::parse)
|
||||
.transpose()
|
||||
.map_err(|err| {
|
||||
json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid_wake_route",
|
||||
&format!("invalid route IP: {err}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
let command = relay_agent_command(
|
||||
&state,
|
||||
&route.agent_id,
|
||||
AgentCommand::Wake(WakeRequest {
|
||||
query: None,
|
||||
mac: Some(mac),
|
||||
ip,
|
||||
}),
|
||||
req.timeout_ms,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(WakeFleetDeviceResponse { route, command }),
|
||||
))
|
||||
}
|
||||
|
||||
async fn load_fleet_devices(
|
||||
state: &AppState,
|
||||
query: &ListFleetDevicesQuery,
|
||||
) -> anyhow::Result<Vec<FleetDevice>> {
|
||||
let known_devices = state.store.list_known_devices().await?;
|
||||
let observations = state
|
||||
.store
|
||||
.list_agent_observations(None, query.limit.unwrap_or(1000).max(1))
|
||||
.await?;
|
||||
let connected = {
|
||||
let sessions = state.sessions.read().await;
|
||||
sessions.keys().cloned().collect::<BTreeSet<_>>()
|
||||
};
|
||||
let agents = state.store.list_agents_with_nicknames().await;
|
||||
let agent_status = agents
|
||||
.into_iter()
|
||||
.map(|(agent_id, nickname)| {
|
||||
let connected = connected.contains(&agent_id);
|
||||
(
|
||||
agent_id,
|
||||
AgentRuntimeStatus {
|
||||
nickname,
|
||||
connected,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect::<HashMap<_, _>>();
|
||||
|
||||
let mut identifier_map = HashMap::new();
|
||||
for device in &known_devices {
|
||||
let summary = known_device_summary(device);
|
||||
for identifier in &device.identifiers {
|
||||
identifier_map.insert(identifier.identifier_key.clone(), summary.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let context = FleetBuildContext {
|
||||
agent_status,
|
||||
identifier_map,
|
||||
};
|
||||
let mut devices = build_fleet_devices(known_devices, observations, &context);
|
||||
filter_fleet_devices(&mut devices, query);
|
||||
let limit = query.limit.unwrap_or(500).clamp(1, 1000);
|
||||
devices.truncate(limit);
|
||||
Ok(devices)
|
||||
}
|
||||
|
||||
fn build_fleet_devices(
|
||||
known_devices: Vec<KnownDevice>,
|
||||
observations: Vec<AgentDeviceObservation>,
|
||||
context: &FleetBuildContext,
|
||||
) -> Vec<FleetDevice> {
|
||||
let mut by_key = BTreeMap::<String, FleetAccumulator>::new();
|
||||
|
||||
for device in known_devices {
|
||||
let key = format!("known:{}", device.device_id);
|
||||
let entry = by_key
|
||||
.entry(key.clone())
|
||||
.or_insert_with(|| FleetAccumulator {
|
||||
device_key: key,
|
||||
display_name: Some(device.display_name.clone()),
|
||||
known_device: Some(known_device_summary(&device)),
|
||||
pinned: device.pinned,
|
||||
presence_rank: 1,
|
||||
..Default::default()
|
||||
});
|
||||
for identifier in device.identifiers {
|
||||
add_identifier_to_entry(entry, &identifier);
|
||||
}
|
||||
}
|
||||
|
||||
for observation in observations {
|
||||
let key = observation_group_key(&observation, context);
|
||||
let entry = by_key
|
||||
.entry(key.clone())
|
||||
.or_insert_with(|| FleetAccumulator {
|
||||
device_key: key,
|
||||
..Default::default()
|
||||
});
|
||||
add_observation_to_entry(entry, observation, context);
|
||||
}
|
||||
|
||||
let mut devices = by_key
|
||||
.into_values()
|
||||
.map(FleetAccumulator::into_response)
|
||||
.collect::<Vec<_>>();
|
||||
devices.sort_by(|a, b| {
|
||||
b.pinned
|
||||
.cmp(&a.pinned)
|
||||
.then_with(|| b.known_device.is_some().cmp(&a.known_device.is_some()))
|
||||
.then_with(|| presence_rank(&b.presence).cmp(&presence_rank(&a.presence)))
|
||||
.then_with(|| b.last_seen_unix.cmp(&a.last_seen_unix))
|
||||
.then_with(|| a.display_name.cmp(&b.display_name))
|
||||
});
|
||||
devices
|
||||
}
|
||||
|
||||
fn filter_fleet_devices(devices: &mut Vec<FleetDevice>, query: &ListFleetDevicesQuery) {
|
||||
let search = normalize_filter(query.query.as_deref());
|
||||
let presence = normalize_filter(query.presence.as_deref());
|
||||
let known = normalize_filter(query.known.as_deref());
|
||||
let agent_id = normalize_filter(query.agent_id.as_deref());
|
||||
|
||||
devices.retain(|device| {
|
||||
if let Some(presence) = presence.as_deref()
|
||||
&& presence != "all"
|
||||
&& device.presence != presence
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if let Some(known) = known.as_deref() {
|
||||
match known {
|
||||
"known" if device.known_device.is_none() => return false,
|
||||
"unknown" if device.known_device.is_some() => return false,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(agent_id) = agent_id.as_deref()
|
||||
&& !device
|
||||
.agents
|
||||
.iter()
|
||||
.any(|agent| agent.agent_id.to_ascii_lowercase().contains(agent_id))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if let Some(search) = search.as_deref() {
|
||||
let mut haystack = vec![
|
||||
device.device_key.as_str(),
|
||||
device.display_name.as_str(),
|
||||
device.presence.as_str(),
|
||||
];
|
||||
haystack.extend(device.ips.iter().map(String::as_str));
|
||||
haystack.extend(device.macs.iter().map(String::as_str));
|
||||
haystack.extend(device.hostnames.iter().map(String::as_str));
|
||||
haystack.extend(device.sources.iter().map(String::as_str));
|
||||
haystack.extend(device.agents.iter().map(|agent| agent.agent_id.as_str()));
|
||||
if !haystack
|
||||
.into_iter()
|
||||
.any(|value| value.to_ascii_lowercase().contains(search))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
});
|
||||
}
|
||||
|
||||
fn observation_group_key(
|
||||
observation: &AgentDeviceObservation,
|
||||
context: &FleetBuildContext,
|
||||
) -> String {
|
||||
if let Some(summary) = observation_known_device(observation, context) {
|
||||
return format!("known:{}", summary.device_id);
|
||||
}
|
||||
if let Some(mac) = observation.mac.as_deref() {
|
||||
return format!("mac:{mac}");
|
||||
}
|
||||
if let Some(ip) = observation.ip.as_deref() {
|
||||
return format!("ip:{ip}");
|
||||
}
|
||||
observation.observation_key.clone()
|
||||
}
|
||||
|
||||
fn add_observation_to_entry(
|
||||
entry: &mut FleetAccumulator,
|
||||
observation: AgentDeviceObservation,
|
||||
context: &FleetBuildContext,
|
||||
) {
|
||||
if let Some(summary) = observation_known_device(&observation, context)
|
||||
&& entry.known_device.is_none()
|
||||
{
|
||||
entry.display_name = Some(summary.display_name.clone());
|
||||
entry.pinned = summary.pinned;
|
||||
entry.known_device = Some(summary);
|
||||
}
|
||||
if let Some(mac) = observation.mac.as_deref() {
|
||||
entry.macs.insert(mac.to_string());
|
||||
}
|
||||
if let Some(ip) = observation.ip.as_deref() {
|
||||
entry.ips.insert(ip.to_string());
|
||||
}
|
||||
if let Some(hostname) = observation.hostname.as_deref() {
|
||||
if entry.display_name.is_none() {
|
||||
entry.display_name = Some(hostname.to_string());
|
||||
}
|
||||
entry.hostnames.insert(hostname.to_string());
|
||||
}
|
||||
entry.sources.insert(observation.kind.clone());
|
||||
entry.first_seen_unix = Some(
|
||||
entry
|
||||
.first_seen_unix
|
||||
.map(|current| current.min(observation.first_seen_unix))
|
||||
.unwrap_or(observation.first_seen_unix),
|
||||
);
|
||||
entry.last_seen_unix = Some(
|
||||
entry
|
||||
.last_seen_unix
|
||||
.map(|current| current.max(observation.last_seen_unix))
|
||||
.unwrap_or(observation.last_seen_unix),
|
||||
);
|
||||
entry.presence_rank = entry
|
||||
.presence_rank
|
||||
.max(observation_presence_rank(&observation));
|
||||
|
||||
let status = context
|
||||
.agent_status
|
||||
.get(&observation.agent_id)
|
||||
.cloned()
|
||||
.unwrap_or(AgentRuntimeStatus {
|
||||
nickname: None,
|
||||
connected: false,
|
||||
});
|
||||
entry
|
||||
.agents
|
||||
.entry(observation.agent_id.clone())
|
||||
.and_modify(|agent| {
|
||||
agent.last_seen_unix = agent.last_seen_unix.max(observation.last_seen_unix);
|
||||
agent.connected = status.connected;
|
||||
agent.nickname = status.nickname.clone();
|
||||
})
|
||||
.or_insert(FleetDeviceAgent {
|
||||
agent_id: observation.agent_id.clone(),
|
||||
nickname: status.nickname.clone(),
|
||||
connected: status.connected,
|
||||
last_seen_unix: observation.last_seen_unix,
|
||||
});
|
||||
|
||||
let route_id = route_id(
|
||||
&observation.agent_id,
|
||||
observation.mac.as_deref(),
|
||||
observation.ip.as_deref(),
|
||||
&observation.kind,
|
||||
);
|
||||
let wakeable = status.connected && observation.mac.is_some();
|
||||
entry.routes.insert(
|
||||
route_id.clone(),
|
||||
FleetWakeRoute {
|
||||
route_id: route_id.clone(),
|
||||
agent_id: observation.agent_id,
|
||||
nickname: status.nickname,
|
||||
connected: status.connected,
|
||||
mac: observation.mac,
|
||||
ip: observation.ip,
|
||||
hostname: observation.hostname,
|
||||
source: observation.kind,
|
||||
last_seen_unix: observation.last_seen_unix,
|
||||
wakeable,
|
||||
},
|
||||
);
|
||||
if let Some(route) = entry.routes.get_mut(&route_id) {
|
||||
route.wakeable = route.connected && route.mac.is_some();
|
||||
}
|
||||
}
|
||||
|
||||
fn add_identifier_to_entry(entry: &mut FleetAccumulator, identifier: &DeviceIdentifier) {
|
||||
match identifier.kind.as_str() {
|
||||
"mac" => {
|
||||
entry.macs.insert(identifier.value.clone());
|
||||
}
|
||||
"ip" => {
|
||||
entry.ips.insert(identifier.value.clone());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn observation_known_device(
|
||||
observation: &AgentDeviceObservation,
|
||||
context: &FleetBuildContext,
|
||||
) -> Option<KnownDeviceSummary> {
|
||||
observation
|
||||
.mac
|
||||
.as_deref()
|
||||
.and_then(|mac| context.identifier_map.get(&format!("mac:{mac}")).cloned())
|
||||
.or_else(|| {
|
||||
observation
|
||||
.ip
|
||||
.as_deref()
|
||||
.and_then(|ip| context.identifier_map.get(&format!("ip:{ip}")).cloned())
|
||||
})
|
||||
}
|
||||
|
||||
fn known_device_summary(device: &KnownDevice) -> KnownDeviceSummary {
|
||||
KnownDeviceSummary {
|
||||
device_id: device.device_id.clone(),
|
||||
display_name: device.display_name.clone(),
|
||||
pinned: device.pinned,
|
||||
}
|
||||
}
|
||||
|
||||
impl FleetAccumulator {
|
||||
fn into_response(self) -> FleetDevice {
|
||||
let mut route_candidates = self.routes.into_values().collect::<Vec<_>>();
|
||||
route_candidates.sort_by(|a, b| {
|
||||
b.connected
|
||||
.cmp(&a.connected)
|
||||
.then_with(|| b.wakeable.cmp(&a.wakeable))
|
||||
.then_with(|| b.last_seen_unix.cmp(&a.last_seen_unix))
|
||||
.then_with(|| a.agent_id.cmp(&b.agent_id))
|
||||
});
|
||||
let recommended_route = route_candidates
|
||||
.iter()
|
||||
.find(|route| route.wakeable)
|
||||
.cloned();
|
||||
let display_name = self
|
||||
.display_name
|
||||
.or_else(|| self.hostnames.iter().next().cloned())
|
||||
.or_else(|| self.macs.iter().next().cloned())
|
||||
.or_else(|| self.ips.iter().next().cloned())
|
||||
.unwrap_or_else(|| "(unknown device)".to_string());
|
||||
|
||||
FleetDevice {
|
||||
device_key: self.device_key,
|
||||
display_name,
|
||||
known_device: self.known_device,
|
||||
pinned: self.pinned,
|
||||
ips: self.ips.into_iter().collect(),
|
||||
macs: self.macs.into_iter().collect(),
|
||||
hostnames: self.hostnames.into_iter().collect(),
|
||||
agents: self.agents.into_values().collect(),
|
||||
sources: self.sources.into_iter().collect(),
|
||||
first_seen_unix: self.first_seen_unix,
|
||||
last_seen_unix: self.last_seen_unix,
|
||||
presence: rank_presence(self.presence_rank).to_string(),
|
||||
route_candidates,
|
||||
recommended_route,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn inventory_result_to_observations(
|
||||
result: serde_json::Value,
|
||||
) -> anyhow::Result<Vec<AgentDeviceObservationInput>> {
|
||||
let envelope: InventoryEnvelope = serde_json::from_value(result)?;
|
||||
if envelope.kind != "inventory" {
|
||||
anyhow::bail!("expected inventory result, got {}", envelope.kind);
|
||||
}
|
||||
|
||||
let now = now_unix();
|
||||
let mut out = Vec::new();
|
||||
for device in envelope.devices {
|
||||
let hostname = device
|
||||
.names
|
||||
.iter()
|
||||
.find(|name| !name.trim().is_empty())
|
||||
.cloned();
|
||||
let action = if device.presence == "offline" {
|
||||
"remove"
|
||||
} else {
|
||||
"update"
|
||||
};
|
||||
if !device.macs.is_empty() {
|
||||
for mac in device.macs {
|
||||
out.push(AgentDeviceObservationInput {
|
||||
kind: "inventory".into(),
|
||||
action: action.into(),
|
||||
mac: Some(mac),
|
||||
ip: device.ips.first().map(ToString::to_string),
|
||||
hostname: hostname.clone(),
|
||||
first_seen_unix: now,
|
||||
last_seen_unix: now,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
for ip in &device.ips {
|
||||
out.push(AgentDeviceObservationInput {
|
||||
kind: "inventory".into(),
|
||||
action: action.into(),
|
||||
mac: None,
|
||||
ip: Some(ip.to_string()),
|
||||
hostname: hostname.clone(),
|
||||
first_seen_unix: now,
|
||||
last_seen_unix: now,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn observation_presence_rank(observation: &AgentDeviceObservation) -> u8 {
|
||||
match observation.last_action.as_str() {
|
||||
"remove" => 0,
|
||||
"add" | "old" | "update" => 2,
|
||||
_ => 1,
|
||||
}
|
||||
}
|
||||
|
||||
fn rank_presence(rank: u8) -> &'static str {
|
||||
match rank {
|
||||
3 => "online",
|
||||
2 => "likely_online",
|
||||
0 => "offline",
|
||||
_ => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
fn presence_rank(presence: &str) -> u8 {
|
||||
match presence {
|
||||
"online" => 3,
|
||||
"likely_online" => 2,
|
||||
"offline" => 0,
|
||||
_ => 1,
|
||||
}
|
||||
}
|
||||
|
||||
fn route_id(agent_id: &str, mac: Option<&str>, ip: Option<&str>, source: &str) -> String {
|
||||
format!(
|
||||
"{}|{}|{}|{}",
|
||||
agent_id,
|
||||
source,
|
||||
mac.unwrap_or(""),
|
||||
ip.unwrap_or("")
|
||||
)
|
||||
}
|
||||
|
||||
fn normalize_filter(value: Option<&str>) -> Option<String> {
|
||||
value
|
||||
.map(|value| value.trim().to_ascii_lowercase())
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn now_unix() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|duration| duration.as_secs())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn context(connected: &[&str]) -> FleetBuildContext {
|
||||
FleetBuildContext {
|
||||
agent_status: connected
|
||||
.iter()
|
||||
.map(|agent| {
|
||||
(
|
||||
(*agent).to_string(),
|
||||
AgentRuntimeStatus {
|
||||
nickname: None,
|
||||
connected: true,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
identifier_map: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn observation(
|
||||
agent_id: &str,
|
||||
mac: Option<&str>,
|
||||
ip: Option<&str>,
|
||||
last_seen_unix: u64,
|
||||
) -> AgentDeviceObservation {
|
||||
AgentDeviceObservation {
|
||||
observation_key: format!(
|
||||
"agent:{agent_id}:dhcp:{}",
|
||||
mac.map(|mac| format!("mac:{mac}"))
|
||||
.or_else(|| ip.map(|ip| format!("ip:{ip}")))
|
||||
.unwrap_or_default()
|
||||
),
|
||||
agent_id: agent_id.into(),
|
||||
kind: "dhcp".into(),
|
||||
mac: mac.map(str::to_string),
|
||||
ip: ip.map(str::to_string),
|
||||
hostname: Some("lda".into()),
|
||||
first_seen_unix: 1,
|
||||
last_seen_unix,
|
||||
last_action: "update".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fleet_grouping_combines_same_mac_across_agents() {
|
||||
let devices = build_fleet_devices(
|
||||
Vec::new(),
|
||||
vec![
|
||||
observation(
|
||||
"agent-a",
|
||||
Some("aa:bb:cc:dd:ee:ff"),
|
||||
Some("192.168.1.2"),
|
||||
10,
|
||||
),
|
||||
observation(
|
||||
"agent-b",
|
||||
Some("aa:bb:cc:dd:ee:ff"),
|
||||
Some("192.168.2.2"),
|
||||
20,
|
||||
),
|
||||
],
|
||||
&context(&["agent-a", "agent-b"]),
|
||||
);
|
||||
|
||||
assert_eq!(devices.len(), 1);
|
||||
assert_eq!(devices[0].macs, vec!["aa:bb:cc:dd:ee:ff"]);
|
||||
assert_eq!(devices[0].agents.len(), 2);
|
||||
assert_eq!(
|
||||
devices[0]
|
||||
.recommended_route
|
||||
.as_ref()
|
||||
.map(|route| route.agent_id.as_str()),
|
||||
Some("agent-b")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_device_with_two_macs_absorbs_both_observation_groups() {
|
||||
let known = KnownDevice {
|
||||
device_id: "dev-1".into(),
|
||||
display_name: "lda".into(),
|
||||
pinned: true,
|
||||
created_at_unix: 1,
|
||||
updated_at_unix: 1,
|
||||
notes: None,
|
||||
identifiers: vec![
|
||||
DeviceIdentifier {
|
||||
identifier_key: "mac:aa:bb:cc:dd:ee:01".into(),
|
||||
device_id: "dev-1".into(),
|
||||
kind: "mac".into(),
|
||||
value: "aa:bb:cc:dd:ee:01".into(),
|
||||
created_at_unix: 1,
|
||||
},
|
||||
DeviceIdentifier {
|
||||
identifier_key: "mac:aa:bb:cc:dd:ee:02".into(),
|
||||
device_id: "dev-1".into(),
|
||||
kind: "mac".into(),
|
||||
value: "aa:bb:cc:dd:ee:02".into(),
|
||||
created_at_unix: 1,
|
||||
},
|
||||
],
|
||||
};
|
||||
let mut ctx = context(&["agent-a"]);
|
||||
for identifier in &known.identifiers {
|
||||
ctx.identifier_map.insert(
|
||||
identifier.identifier_key.clone(),
|
||||
known_device_summary(&known),
|
||||
);
|
||||
}
|
||||
|
||||
let devices = build_fleet_devices(
|
||||
vec![known],
|
||||
vec![
|
||||
observation("agent-a", Some("aa:bb:cc:dd:ee:01"), None, 10),
|
||||
observation("agent-a", Some("aa:bb:cc:dd:ee:02"), None, 20),
|
||||
],
|
||||
&ctx,
|
||||
);
|
||||
|
||||
assert_eq!(devices.len(), 1);
|
||||
assert_eq!(devices[0].device_key, "known:dev-1");
|
||||
assert_eq!(devices[0].macs.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ip_only_unknown_is_visible_but_not_wakeable() {
|
||||
let devices = build_fleet_devices(
|
||||
Vec::new(),
|
||||
vec![observation("agent-a", None, Some("192.168.1.2"), 10)],
|
||||
&context(&["agent-a"]),
|
||||
);
|
||||
|
||||
assert_eq!(devices.len(), 1);
|
||||
assert_eq!(devices[0].ips, vec!["192.168.1.2"]);
|
||||
assert!(devices[0].recommended_route.is_none());
|
||||
assert!(!devices[0].route_candidates[0].wakeable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inventory_result_maps_to_stored_observations() {
|
||||
let observations = inventory_result_to_observations(serde_json::json!({
|
||||
"kind": "inventory",
|
||||
"devices": [{
|
||||
"names": ["lda"],
|
||||
"ips": ["192.168.1.2"],
|
||||
"macs": ["aa:bb:cc:dd:ee:ff"],
|
||||
"presence": "likely_online"
|
||||
}]
|
||||
}))
|
||||
.expect("inventory should map");
|
||||
|
||||
assert_eq!(observations.len(), 1);
|
||||
assert_eq!(observations[0].kind, "inventory");
|
||||
assert_eq!(observations[0].action, "update");
|
||||
assert_eq!(observations[0].mac.as_deref(), Some("aa:bb:cc:dd:ee:ff"));
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
use axum::Json;
|
||||
use axum::extract::{Query, State};
|
||||
use axum::extract::{Path as AxumPath, Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::IntoResponse;
|
||||
use serde::Deserialize;
|
||||
use tracing::warn;
|
||||
use wakey_agent::protocol::ServerMessage;
|
||||
|
||||
use crate::api::json_error;
|
||||
use crate::runtime::AppState;
|
||||
use crate::runtime::{AppState, SessionEvent};
|
||||
use crate::state::{
|
||||
AgentDeviceObservationEvent, AgentDeviceObservationInput, AgentDeviceObservationView,
|
||||
};
|
||||
@@ -35,6 +36,12 @@ pub struct UploadAgentObservationsResponse {
|
||||
pub accepted: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub struct RequestAgentObservationSyncResponse {
|
||||
pub agent_id: String,
|
||||
pub requested: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListObservationsQuery {
|
||||
pub agent_id: Option<String>,
|
||||
@@ -130,6 +137,43 @@ pub async fn list_agent_observations(
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn request_agent_observation_sync(
|
||||
State(state): State<AppState>,
|
||||
AxumPath(agent_id): AxumPath<String>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
|
||||
let tx = {
|
||||
let sessions = state.sessions.read().await;
|
||||
sessions.get(&agent_id).map(|session| session.tx.clone())
|
||||
};
|
||||
let Some(tx) = tx else {
|
||||
return Ok((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(RequestAgentObservationSyncResponse {
|
||||
agent_id,
|
||||
requested: false,
|
||||
}),
|
||||
));
|
||||
};
|
||||
|
||||
match tx.send(SessionEvent::Message(ServerMessage::SyncObservations)) {
|
||||
Ok(()) => Ok((
|
||||
StatusCode::OK,
|
||||
Json(RequestAgentObservationSyncResponse {
|
||||
agent_id,
|
||||
requested: true,
|
||||
}),
|
||||
)),
|
||||
Err(err) => {
|
||||
warn!(error = %err, "failed to request agent observation sync");
|
||||
Err(json_error(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"agent_observation_sync_request_failed",
|
||||
&err.to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn list_agent_observation_history(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<ListObservationHistoryQuery>,
|
||||
|
||||
@@ -14,8 +14,9 @@ pub use control::{
|
||||
StateStatsResponse, attach_device_identifier, attach_observation_identifier,
|
||||
create_known_device, enroll, forget_known_device, healthz, issue_enroll_token,
|
||||
list_agent_observation_history, list_agent_observations, list_enroll_tokens,
|
||||
list_known_devices, revoke_agent, revoke_enroll_token, set_agent_nickname, state_stats,
|
||||
upload_agent_observations,
|
||||
list_fleet_devices, list_known_devices, merge_known_device, refresh_fleet_devices,
|
||||
request_agent_observation_sync, revoke_agent, revoke_enroll_token, set_agent_nickname,
|
||||
state_stats, upload_agent_observations, wake_fleet_device,
|
||||
};
|
||||
|
||||
pub fn json_error(
|
||||
|
||||
@@ -90,6 +90,9 @@ pub struct InitConfigArgs {
|
||||
#[arg(long = "config-file", alias = "config")]
|
||||
pub config_file: Option<PathBuf>,
|
||||
|
||||
#[arg(long)]
|
||||
pub from_config: Option<PathBuf>,
|
||||
|
||||
#[arg(long, conflicts_with = "config_file")]
|
||||
pub stdout: bool,
|
||||
|
||||
@@ -126,8 +129,8 @@ pub struct InitConfigArgs {
|
||||
#[arg(long)]
|
||||
pub telemetry_service_name: Option<String>,
|
||||
|
||||
#[arg(long)]
|
||||
pub telemetry_json_logs: bool,
|
||||
#[arg(long, num_args = 0..=1, default_missing_value = "true")]
|
||||
pub telemetry_json_logs: Option<bool>,
|
||||
|
||||
#[arg(long)]
|
||||
pub force: bool,
|
||||
@@ -238,12 +241,13 @@ pub struct StateStatsArgs {
|
||||
pub target: AdminTargetArgs,
|
||||
}
|
||||
|
||||
// not the greatest thing ive used, this and the migration code gets deleted shortly anyways
|
||||
#[derive(Args)]
|
||||
pub struct ImportSledStateArgs {
|
||||
#[arg(long)]
|
||||
#[arg(long, visible_alias = "from")]
|
||||
pub from_sled_state: PathBuf,
|
||||
|
||||
#[arg(long)]
|
||||
#[arg(long, visible_alias = "to")]
|
||||
pub to_state_file: PathBuf,
|
||||
|
||||
#[arg(long)]
|
||||
|
||||
@@ -3,7 +3,7 @@ use std::path::PathBuf;
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
use crate::cli::{InitConfigArgs, ServeArgs};
|
||||
use crate::config::resolve::{normalize_public_url, resolve_path};
|
||||
use crate::config::resolve::{load_file_config, normalize_public_url, resolve_path};
|
||||
use crate::config::types::{WritableConfig, WritableTelemetry};
|
||||
|
||||
pub fn write_init_config(args: &InitConfigArgs) -> Result<Option<PathBuf>> {
|
||||
@@ -21,56 +21,104 @@ pub fn write_init_config(args: &InitConfigArgs) -> Result<Option<PathBuf>> {
|
||||
);
|
||||
}
|
||||
|
||||
let bind = args.bind.unwrap_or_else(|| {
|
||||
"0.0.0.0:8080"
|
||||
let base = if let Some(from_config) = &args.from_config {
|
||||
if !from_config.exists() {
|
||||
anyhow::bail!("base config {} does not exist", from_config.display());
|
||||
}
|
||||
load_file_config(from_config)?
|
||||
} else {
|
||||
Default::default()
|
||||
};
|
||||
let base_telemetry = base.telemetry.unwrap_or_default();
|
||||
|
||||
let bind = match args.bind {
|
||||
Some(bind) => bind,
|
||||
None => match base.bind {
|
||||
Some(ref bind) => bind.parse().with_context(|| {
|
||||
format!(
|
||||
"invalid bind address `{bind}` in base config {}",
|
||||
args.from_config
|
||||
.as_ref()
|
||||
.map(|path| path.display().to_string())
|
||||
.unwrap_or_else(|| "<defaults>".to_string())
|
||||
)
|
||||
})?,
|
||||
None => "0.0.0.0:8080"
|
||||
.parse()
|
||||
.expect("static default bind should parse")
|
||||
});
|
||||
.expect("static default bind should parse"),
|
||||
},
|
||||
};
|
||||
let public_url = normalize_public_url(
|
||||
args.public_url
|
||||
.as_deref()
|
||||
.or(base.public_url.as_deref())
|
||||
.unwrap_or("http://127.0.0.1:8080"),
|
||||
);
|
||||
|
||||
let data_dir = args
|
||||
.data_dir
|
||||
.clone()
|
||||
.or(base.data_dir)
|
||||
.unwrap_or_else(|| PathBuf::from(crate::cli::DEFAULT_DATA_DIR));
|
||||
|
||||
let state_file_raw = args
|
||||
.state_file
|
||||
.clone()
|
||||
.or(base.state_file)
|
||||
.unwrap_or_else(|| PathBuf::from("state.sqlite3"));
|
||||
let state_file = resolve_path(&data_dir, state_file_raw);
|
||||
|
||||
let pid_file_raw = args
|
||||
.pid_file
|
||||
.clone()
|
||||
.or(base.pid_file)
|
||||
.unwrap_or_else(|| PathBuf::from("wakey-control-plane.pid"));
|
||||
let pid_file = resolve_path(&data_dir, pid_file_raw);
|
||||
|
||||
let ui_dist_dir = args
|
||||
.ui_dist_dir
|
||||
.clone()
|
||||
.or(base.ui_dist_dir)
|
||||
.unwrap_or_else(|| PathBuf::from("ui/dist"));
|
||||
|
||||
let bootstrap_enroll_tokens = if args.bootstrap_enroll_tokens.is_empty() {
|
||||
base.bootstrap_enroll_tokens.unwrap_or_default()
|
||||
} else {
|
||||
args.bootstrap_enroll_tokens.clone()
|
||||
};
|
||||
|
||||
let body = WritableConfig {
|
||||
data_dir,
|
||||
bind: bind.to_string(),
|
||||
public_url,
|
||||
state_file,
|
||||
command_timeout_ms: args.command_timeout_ms.unwrap_or(30_000).max(1),
|
||||
enroll_token_ttl_seconds: args.enroll_token_ttl_seconds.unwrap_or(86_400).max(1),
|
||||
command_timeout_ms: args
|
||||
.command_timeout_ms
|
||||
.or(base.command_timeout_ms)
|
||||
.unwrap_or(30_000)
|
||||
.max(1),
|
||||
enroll_token_ttl_seconds: args
|
||||
.enroll_token_ttl_seconds
|
||||
.or(base.enroll_token_ttl_seconds)
|
||||
.unwrap_or(86_400)
|
||||
.max(1),
|
||||
pid_file,
|
||||
ui_dist_dir,
|
||||
bootstrap_enroll_tokens: args.bootstrap_enroll_tokens.clone(),
|
||||
bootstrap_enroll_tokens,
|
||||
telemetry: WritableTelemetry {
|
||||
otlp_endpoint: args.telemetry_otlp_endpoint.clone(),
|
||||
otlp_endpoint: args
|
||||
.telemetry_otlp_endpoint
|
||||
.clone()
|
||||
.or(base_telemetry.otlp_endpoint),
|
||||
service_name: args
|
||||
.telemetry_service_name
|
||||
.clone()
|
||||
.or(base_telemetry.service_name)
|
||||
.unwrap_or_else(|| "wakey-control-plane".to_string()),
|
||||
json_logs: args.telemetry_json_logs,
|
||||
json_logs: args
|
||||
.telemetry_json_logs
|
||||
.or(base_telemetry.json_logs)
|
||||
.unwrap_or(false),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -108,12 +156,93 @@ pub fn bootstrap_config_if_missing(args: &ServeArgs) -> Result<bool> {
|
||||
command_timeout_ms: args.command_timeout_ms,
|
||||
enroll_token_ttl_seconds: args.enroll_token_ttl_seconds,
|
||||
bootstrap_enroll_tokens: args.bootstrap_enroll_tokens.clone(),
|
||||
from_config: None,
|
||||
telemetry_otlp_endpoint: None,
|
||||
telemetry_service_name: None,
|
||||
telemetry_json_logs: false,
|
||||
telemetry_json_logs: None,
|
||||
force: false,
|
||||
};
|
||||
|
||||
write_init_config(&init)?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::write_init_config;
|
||||
use crate::cli::InitConfigArgs;
|
||||
|
||||
fn temp_test_dir(name: &str) -> std::path::PathBuf {
|
||||
std::env::temp_dir().join(format!(
|
||||
"wakey-cc-init-config-{name}-{}",
|
||||
std::process::id()
|
||||
))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn init_config_from_config_preserves_base_and_applies_overrides() {
|
||||
let dir = temp_test_dir("from-config");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("create temp dir");
|
||||
let base = dir.join("base.toml");
|
||||
let out = dir.join("out.toml");
|
||||
std::fs::write(
|
||||
&base,
|
||||
r#"
|
||||
data_dir = "/tmp/wakey-base"
|
||||
bind = "127.0.0.1:9090"
|
||||
public_url = "https://old.example/"
|
||||
state_file = "old.sqlite3"
|
||||
command_timeout_ms = 1234
|
||||
enroll_token_ttl_seconds = 5678
|
||||
pid_file = "old.pid"
|
||||
ui_dist_dir = "/opt/wakey/ui"
|
||||
bootstrap_enroll_tokens = ["old-token"]
|
||||
|
||||
[telemetry]
|
||||
otlp_endpoint = "http://otel.example"
|
||||
service_name = "old-service"
|
||||
json_logs = true
|
||||
"#,
|
||||
)
|
||||
.expect("write base config");
|
||||
|
||||
write_init_config(&InitConfigArgs {
|
||||
config_file: Some(out.clone()),
|
||||
from_config: Some(base),
|
||||
stdout: false,
|
||||
data_dir: None,
|
||||
bind: None,
|
||||
public_url: Some("https://new.example/".to_string()),
|
||||
state_file: None,
|
||||
pid_file: None,
|
||||
ui_dist_dir: None,
|
||||
command_timeout_ms: Some(999),
|
||||
enroll_token_ttl_seconds: None,
|
||||
bootstrap_enroll_tokens: Vec::new(),
|
||||
telemetry_otlp_endpoint: None,
|
||||
telemetry_service_name: None,
|
||||
telemetry_json_logs: None,
|
||||
force: false,
|
||||
})
|
||||
.expect("write derived config");
|
||||
|
||||
let rendered = std::fs::read_to_string(&out).expect("read 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["bind"].as_str(), Some("127.0.0.1:9090"));
|
||||
assert_eq!(parsed["command_timeout_ms"].as_integer(), Some(999));
|
||||
assert_eq!(parsed["enroll_token_ttl_seconds"].as_integer(), Some(5678));
|
||||
assert_eq!(
|
||||
parsed["state_file"].as_str(),
|
||||
Some("/tmp/wakey-base/old.sqlite3")
|
||||
);
|
||||
assert_eq!(parsed["telemetry"]["json_logs"].as_bool(), Some(true));
|
||||
assert_eq!(
|
||||
parsed["telemetry"]["service_name"].as_str(),
|
||||
Some("old-service")
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -93,6 +93,15 @@ fn control_api_routes() -> Router<AppState> {
|
||||
axum::routing::delete(api::revoke_enroll_token),
|
||||
)
|
||||
.route("/api/v1/control/state-stats", get(api::state_stats))
|
||||
.route(
|
||||
"/api/v1/control/fleet/devices",
|
||||
get(api::list_fleet_devices),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/control/fleet/devices/refresh",
|
||||
post(api::refresh_fleet_devices),
|
||||
)
|
||||
.route("/api/v1/control/fleet/wake", post(api::wake_fleet_device))
|
||||
.route(
|
||||
"/api/v1/control/observations",
|
||||
get(api::list_agent_observations),
|
||||
@@ -101,6 +110,10 @@ fn control_api_routes() -> Router<AppState> {
|
||||
"/api/v1/control/observations/history",
|
||||
get(api::list_agent_observation_history),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/control/agents/{agent_id}/observations/sync",
|
||||
post(api::request_agent_observation_sync),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/control/devices",
|
||||
get(api::list_known_devices).post(api::create_known_device),
|
||||
@@ -109,6 +122,10 @@ fn control_api_routes() -> Router<AppState> {
|
||||
"/api/v1/control/devices/{device_id}",
|
||||
axum::routing::delete(api::forget_known_device),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/control/devices/{device_id}/merge",
|
||||
post(api::merge_known_device),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/control/devices/{device_id}/identifiers",
|
||||
post(api::attach_device_identifier),
|
||||
|
||||
@@ -3,7 +3,7 @@ mod types;
|
||||
|
||||
pub use store::Store;
|
||||
pub use types::{
|
||||
AgentDeviceObservationEvent, AgentDeviceObservationInput, AgentDeviceObservationView,
|
||||
AlertState, AuditEvent, AuditEventFilter, AuditEventInput, DeviceIdentifierInput, KnownDevice,
|
||||
KnownDeviceInput,
|
||||
AgentDeviceObservation, AgentDeviceObservationEvent, AgentDeviceObservationInput,
|
||||
AgentDeviceObservationView, AlertState, AuditEvent, AuditEventFilter, AuditEventInput,
|
||||
DeviceIdentifier, DeviceIdentifierInput, KnownDevice, KnownDeviceInput, KnownDeviceSummary,
|
||||
};
|
||||
@@ -337,6 +337,59 @@ mod tests {
|
||||
cleanup_dir(&dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn merge_known_devices_moves_identifiers_and_deletes_source() {
|
||||
let (store, dir) = make_store().await;
|
||||
let target = store
|
||||
.create_known_device(KnownDeviceInput {
|
||||
display_name: "lda".into(),
|
||||
pinned: true,
|
||||
notes: None,
|
||||
identifiers: vec![DeviceIdentifierInput {
|
||||
kind: "mac".into(),
|
||||
value: "aa:bb:cc:dd:ee:01".into(),
|
||||
}],
|
||||
})
|
||||
.await
|
||||
.expect("target should create");
|
||||
let source = store
|
||||
.create_known_device(KnownDeviceInput {
|
||||
display_name: "lda duplicate".into(),
|
||||
pinned: false,
|
||||
notes: None,
|
||||
identifiers: vec![DeviceIdentifierInput {
|
||||
kind: "mac".into(),
|
||||
value: "aa:bb:cc:dd:ee:02".into(),
|
||||
}],
|
||||
})
|
||||
.await
|
||||
.expect("source should create");
|
||||
|
||||
let merged = store
|
||||
.merge_known_devices(&target.device_id, &source.device_id)
|
||||
.await
|
||||
.expect("merge should succeed")
|
||||
.expect("target should remain");
|
||||
|
||||
assert_eq!(merged.device_id, target.device_id);
|
||||
assert_eq!(merged.identifiers.len(), 2);
|
||||
assert!(
|
||||
merged
|
||||
.identifiers
|
||||
.iter()
|
||||
.any(|identifier| identifier.value == "aa:bb:cc:dd:ee:02")
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.get_known_device(&source.device_id)
|
||||
.await
|
||||
.expect("source lookup should work")
|
||||
.is_none()
|
||||
);
|
||||
|
||||
cleanup_dir(&dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn agent_observations_upsert_current_state_and_events() {
|
||||
let (store, dir) = make_store().await;
|
||||
|
||||
@@ -73,6 +73,75 @@ impl Store {
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
pub async fn merge_known_devices(
|
||||
&self,
|
||||
target_device_id: &str,
|
||||
source_device_id: &str,
|
||||
) -> Result<Option<KnownDevice>> {
|
||||
if target_device_id == source_device_id {
|
||||
return self.get_known_device(target_device_id).await;
|
||||
}
|
||||
|
||||
let now = now_unix();
|
||||
let now_i64 = i64::try_from(now).context("known device timestamp overflow")?;
|
||||
let mut tx = self
|
||||
.pool
|
||||
.begin()
|
||||
.await
|
||||
.context("failed starting known device merge transaction")?;
|
||||
|
||||
let target_exists = sqlx::query_scalar!(
|
||||
r#"SELECT COUNT(*) as "count!: i64" FROM known_devices WHERE device_id = ?1"#,
|
||||
target_device_id
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.context("failed checking target known device existence")?;
|
||||
if target_exists == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let source_exists = sqlx::query_scalar!(
|
||||
r#"SELECT COUNT(*) as "count!: i64" FROM known_devices WHERE device_id = ?1"#,
|
||||
source_device_id
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.context("failed checking source known device existence")?;
|
||||
if source_exists == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE device_identifiers SET device_id = ?1 WHERE device_id = ?2",
|
||||
target_device_id,
|
||||
source_device_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.context("failed moving source identifiers to target device")?;
|
||||
sqlx::query!(
|
||||
"DELETE FROM known_devices WHERE device_id = ?1",
|
||||
source_device_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.context("failed deleting merged source known device")?;
|
||||
sqlx::query!(
|
||||
"UPDATE known_devices SET updated_at_unix = ?1 WHERE device_id = ?2",
|
||||
now_i64,
|
||||
target_device_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.context("failed updating merged target known device timestamp")?;
|
||||
|
||||
tx.commit()
|
||||
.await
|
||||
.context("failed committing known device merge transaction")?;
|
||||
self.get_known_device(target_device_id).await
|
||||
}
|
||||
|
||||
pub async fn attach_device_identifier(
|
||||
&self,
|
||||
device_id: &str,
|
||||
|
||||
@@ -8,7 +8,7 @@ use std::time::Instant;
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::{debug, info, info_span, warn};
|
||||
use uuid::Uuid;
|
||||
use wakey_agent::protocol::{ErrorPayload, RequestId};
|
||||
use wakey_agent::protocol::{AgentObservation, ErrorPayload, RequestId, ServerMessage};
|
||||
|
||||
use crate::runtime::{AgentReply, AgentSession, AppState, SessionEvent};
|
||||
use crate::state::AuditEventInput;
|
||||
@@ -26,6 +26,10 @@ enum IncomingClientMessage {
|
||||
Heartbeat {
|
||||
agent_id: String,
|
||||
},
|
||||
Observations {
|
||||
agent_id: String,
|
||||
observations: Vec<AgentObservation>,
|
||||
},
|
||||
Result {
|
||||
request_id: RequestId,
|
||||
result: serde_json::Value,
|
||||
@@ -238,6 +242,7 @@ async fn process_agent_text(
|
||||
{
|
||||
warn!(error = %err, "failed to append audit event for auth success");
|
||||
}
|
||||
let _ = tx.send(SessionEvent::Message(ServerMessage::SyncObservations));
|
||||
}
|
||||
IncomingClientMessage::Heartbeat { agent_id } => {
|
||||
if authed_agent_id.as_deref() != Some(agent_id.as_str()) {
|
||||
@@ -246,6 +251,40 @@ async fn process_agent_text(
|
||||
ensure_current_session(state, &agent_id, connection_id).await?;
|
||||
debug!(agent_id = %agent_id, "heartbeat received");
|
||||
}
|
||||
IncomingClientMessage::Observations {
|
||||
agent_id,
|
||||
observations,
|
||||
} => {
|
||||
if authed_agent_id.as_deref() != Some(agent_id.as_str()) {
|
||||
anyhow::bail!("observations for unauthenticated or mismatched agent");
|
||||
}
|
||||
ensure_current_session(state, &agent_id, connection_id).await?;
|
||||
let inputs = observations
|
||||
.into_iter()
|
||||
.map(|observation| crate::state::AgentDeviceObservationInput {
|
||||
kind: observation.kind,
|
||||
action: observation.action,
|
||||
mac: observation.mac,
|
||||
ip: observation.ip.map(|ip| ip.to_string()),
|
||||
hostname: observation.hostname,
|
||||
first_seen_unix: observation.first_seen_unix,
|
||||
last_seen_unix: observation.last_seen_unix,
|
||||
})
|
||||
.collect();
|
||||
match state
|
||||
.store
|
||||
.upsert_agent_observations(&agent_id, inputs)
|
||||
.await
|
||||
{
|
||||
Ok(accepted) => {
|
||||
debug!(agent_id = %agent_id, accepted, "agent websocket observations accepted");
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(agent_id = %agent_id, error = %err, "failed to store websocket observations");
|
||||
anyhow::bail!("failed to store observations: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
IncomingClientMessage::Result { request_id, result } => {
|
||||
let agent_id = authed_agent_id
|
||||
.as_deref()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "wakey-core"
|
||||
version = "0.2.2"
|
||||
version = "0.2.3"
|
||||
edition = "2024"
|
||||
publish = ["gitea"]
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "wakey-linux"
|
||||
version = "0.2.2"
|
||||
version = "0.2.3"
|
||||
edition = "2024"
|
||||
publish = ["gitea"]
|
||||
|
||||
|
||||
Reference in new issue
Block a user