3 Commits
32 changed files with 2895 additions and 643 deletions

No files matched your search

+3 -3
View File
@@ -9,7 +9,7 @@ on:
jobs:
build:
runs-on: [self-hosted, windows]
runs-on: [self-hosted, linux, fedora, wsl, release]
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -28,12 +28,12 @@ jobs:
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
key: linux-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Build (armv7-musl)
shell: pwsh
run: |
cargo build --release --target armv7-unknown-linux-musleabihf
cargo build --release --target armv7-unknown-linux-musleabihf -p wakey -p wakey-agent
- name: Package rootfs tarball
shell: pwsh
Generated
+1161 -172
View File
File diff suppressed because it is too large. Load diff
+4 -2
View File
@@ -24,7 +24,9 @@ tokio = { version = "1", features = [
"io-util",
"macros",
] }
tower-http = { version = "0", features = ["fs"] }
tower-http = { version = "0", features = ["fs", "trace"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
urlencoding = "2"
wakey-core = { path = "wakey-core" }
wakey-linux = { path = "wakey-linux" }
@@ -39,7 +41,7 @@ very-smart-parsing = [
] # this is the a-bit-redundant parse thing that copilot made
[workspace]
members = ["ipjs", "wakey-core", "wakey-linux"]
members = ["ipjs", "wakey-agent", "wakey-core", "wakey-linux"]
[dependencies.lda-ipjs]
path = "ipjs"
+23
View File
@@ -64,6 +64,29 @@ The long-term direction is:
- keep HTTP as an adapter, not the architecture
- eventually move toward an agent + control-plane model
## Future direction
The likely next large step is splitting the current temporary HTTP/web hosting
role away from the main `wakey` binary.
The intended shape is roughly:
- `wakey`
- stable service layer and operator CLI
- `wakey-agent`
- router-side daemon exposing a network API over the service layer
- control-center app
- remote UI or control plane that talks to one or more agents
That future agent layer will likely need:
- explicit registration/authentication
- a stable remote API
- a small deployment/bootstrap story on the router
The current CLI and compatibility HTTP adapter are being kept small on purpose
so that split can happen later without moving the real product logic again.
## CLI
`wakey` is usable as a local/operator CLI.
+15 -7
View File
@@ -4,13 +4,13 @@ This folder has small helpers for build, CI, and router install. Keep it simple;
## Quick start (recommended)
1. Start your Gitea runner on this Windows PC (if not already running):
1. Start your Fedora/WSL Gitea runner (recommended release path):
```powershell
./scripts/act_runner.ps1
./scripts/act_runner_wsl.ps1 -Action start -Attach
```
If first-time, it prints the exact register command. Run it once, check labels include `windows:host,self-hosted`, then rerun the script.
If first-time, use the runbook in [`WSL_RUNNER.md`](./WSL_RUNNER.md) to update and register the Fedora runner first.
2. Publish, tag, and push (recommended):
@@ -18,7 +18,7 @@ This folder has small helpers for build, CI, and router install. Keep it simple;
./scripts/publish.ps1 -Tag
```
This will bump the version (if you specify one), build, ensure the runner is started, tag, and push. To also publish to the registry, add `-Publish`.
This will bump the version (if you specify one), build locally, tag, push, and start the Fedora/WSL runner in `--once` mode by default. To also publish to the registry, add `-Publish`.
3. Install on the router
@@ -38,6 +38,8 @@ This folder has small helpers for build, CI, and router install. Keep it simple;
## Scripts overview
- `act_runner.ps1` — Start/seed the local Gitea runner. Use `-Attach` to see logs, `-ForceConfigure` to register non-interactively.
- `act_runner_wsl.ps1` — Windows PowerShell wrapper that controls the Fedora/WSL runner.
- `act_runner_fedora.sh` — Linux/Fedora helper used inside WSL for update/register/start/stop/status.
- `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]`
- Uploads to `<RemotePath>.tmp` then atomically moves into place; `-Restart` restarts the service; `-Quiet` silences MOTD.
@@ -92,10 +94,16 @@ Implementation notes:
## CI (Gitea)
- Defined in `.gitea/workflows/release.yml`.
- Runs on your `self-hosted, windows` runner.
- Steps: cross build → package rootfs → upload artifact (v3) → publish a release and attach tgz.
- Release job now targets your Fedora/WSL runner labels: `self-hosted`, `linux`, `fedora`, `wsl`, `release`.
- Steps: Linux-native build of `wakey` + `wakey-agent` → package rootfs → upload artifact (v3) → publish a release and attach tgz.
- Requires `secrets.GITEA_TOKEN` in the repo to publish the release.
## Runner migration
- Fedora/WSL is now the intended release-builder path.
- The Windows runner can stay registered in parallel during migration and fallback.
- The detailed operator runbook is in [`WSL_RUNNER.md`](./WSL_RUNNER.md).
## Local build/install (manual path)
```powershell
@@ -121,4 +129,4 @@ Or you can use the script:
./scripts/dev_push.ps1 -Pass <password>
```
That’s it. Keep the flow: start runner → tag push → install.
That’s it. Keep the flow: start Fedora/WSL runner → tag push → install.
+131
View File
@@ -0,0 +1,131 @@
# Fedora / WSL Gitea Runner
This repo now expects Linux/router release builds to run on a Fedora 43 runner
inside WSL, not on the Windows host runner.
The goal is simple:
- build `wakey` and `wakey-agent` in a Linux-native environment
- stop fighting Windows-host ARM/musl/TLS toolchain issues
- keep PowerShell on Windows as the operator front door
## Runner layout
Recommended WSL location:
```text
/mnt/c/Users/Admin/Documents/realshit/wakey/ar_data/wsl_runner
```
This matches the default used by `act_runner_wsl.ps1` and
`act_runner_fedora.sh`, so WSL runner config is isolated at
`ar_data/wsl_runner/config.yaml` in this repo.
Recommended labels:
```text
self-hosted,linux,fedora,wsl,release:host
```
The Windows runner can stay registered during migration as fallback.
## Fedora setup
Install the basics in Fedora 43:
```bash
sudo dnf install -y git curl tar gzip findutils python3
sudo dnf install -y powershell
```
Install Rust and the router target:
```bash
rustup target add armv7-unknown-linux-musleabihf
```
Install the native cross toolchain pieces you need for Linux/ARM router builds.
The exact package names can vary over time, so confirm the current Fedora names
for:
- ARM Linux GCC
- musl development/toolchain support
- OpenSSL/ring/TLS build prerequisites if needed by your dependency graph
## Runner lifecycle
From Windows PowerShell, using the WSL wrapper:
```powershell
./scripts/act_runner_wsl.ps1 -Action update
./scripts/act_runner_wsl.ps1 -Action register -ServerUrl https://git.ldlda.com/ -Token <runner-token>
./scripts/act_runner_wsl.ps1 -Action start -Attach
./scripts/act_runner_wsl.ps1 -Action start -Once
./scripts/act_runner_wsl.ps1 -Action status
./scripts/act_runner_wsl.ps1 -Action stop
```
Path control (binary and config are independent):
```powershell
./scripts/act_runner_wsl.ps1 -Action status `
-RunnerBin /home/lda/gitea-runner/act_runner `
-ConfigPath /mnt/c/Users/Admin/Documents/realshit/wakey/ar_data/wsl_runner/config.yaml
```
If your distro name is not `FedoraLinux-43`, set it explicitly:
```powershell
./scripts/act_runner_wsl.ps1 -Distro FedoraLinux-43 -Action status
```
Or set:
```powershell
$env:WAKEY_WSL_DISTRO = "FedoraLinux-43"
```
Inside Fedora directly, you can also use:
```bash
./scripts/act_runner_fedora.sh update
./scripts/act_runner_fedora.sh register --server-url https://git.ldlda.com/ --token <runner-token>
./scripts/act_runner_fedora.sh start --attach
./scripts/act_runner_fedora.sh start --once
./scripts/act_runner_fedora.sh status
./scripts/act_runner_fedora.sh stop
```
## Release flow
The release workflow is expected to run on the Fedora/WSL runner labels:
```text
self-hosted + linux + fedora + wsl + release
```
It builds:
- `wakey`
- `wakey-agent`
and packages both into the rootfs tarball.
From Windows, the intended operator flow remains:
```powershell
./scripts/publish.ps1 -Tag
```
That script should trigger the WSL runner in `--once` mode by default.
## Rollback
If the Fedora runner path is broken:
1. stop using the WSL runner labels in the workflow
2. temporarily point the workflow back to the Windows runner labels
3. use the existing Windows runner as fallback while fixing the Fedora path
Do not delete the Windows runner until the Fedora path has already produced at
least one successful full release and one successful router install.
+4 -4
View File
@@ -1,12 +1,12 @@
# Starts the Gitea act_runner if not already running.
# Usage: ./scripts/act_runner.ps1 -Config "C:/Users/Admin/Documents/gitea/runner.yaml" -RunnerPath "C:/Users/Admin/Documents/gitea/act_runner.exe" -ServerUrl "https://git.ldlda.com/" -Token "<reg token>" -Labels "self-hosted,windows"
# Starts the Windows-host Gitea act_runner if not already running.
# Usage: ./scripts/act_runner.ps1 -Config "C:/Users/Admin/Documents/gitea/runner.yaml" -RunnerPath "C:/Users/Admin/Documents/gitea/act_runner.exe" -ServerUrl "https://git.ldlda.com/" -Token "<reg token>" -Labels "self-hosted,windows:host"
param(
[string]$RunnerPath = "$HOME/Documents/gitea/act_runner.exe",
# Config is a FILE path (e.g., runner.yaml). Parent folder will be created if missing.
[string]$Config = (Join-Path (Split-Path -Parent $PSScriptRoot) 'ar_data/config.yaml'),
[string]$ServerUrl,
[string]$Token,
[string]$Labels = "windows:host,self-hosted",
[string]$Labels = "self-hosted,windows:host",
# Opt-in to non-interactive configure; by default we print the command for you to run manually.
[switch]$ForceConfigure,
# When -Attach, run in the foreground to see logs (good for first-time troubleshooting)
@@ -46,7 +46,7 @@ if (-not (Test-Path $configFile)) {
Pop-Location
}
else {
Write-Host "Config not found. Run this manually once (note labels embed host executor on Windows):"
Write-Host "Config not found. Run this manually once (Windows-host runner labels):"
Write-Host "`t$RunnerPath register --no-interactive --config `"$configFile`" --instance `"$ServerUrl`" --token `"$Token`" --labels `"$Labels`""
exit 2
}
+190
View File
@@ -0,0 +1,190 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
RUNNER_BIN="${RUNNER_BIN:-/home/lda/gitea-runner/act_runner}"
CONFIG_PATH="${CONFIG_PATH:-$REPO_ROOT/ar_data/wsl_runner/config.yaml}"
RUNNER_HOME="${RUNNER_HOME:-$(dirname "$CONFIG_PATH")}"
SERVER_URL="${SERVER_URL:-}"
TOKEN="${TOKEN:-}"
LABELS="${LABELS:-self-hosted,linux,fedora,wsl,release:host}"
usage() {
cat <<'EOF'
Usage:
act_runner_fedora.sh update
act_runner_fedora.sh register --server-url URL --token TOKEN [--labels LABELS]
act_runner_fedora.sh start [--attach] [--once]
act_runner_fedora.sh stop
act_runner_fedora.sh status
Environment overrides:
RUNNER_HOME, RUNNER_BIN, CONFIG_PATH, SERVER_URL, TOKEN, LABELS
Path overrides:
--runner-home DIR
--runner-bin PATH
--config-path PATH
EOF
}
ensure_dirs() {
mkdir -p "$RUNNER_HOME" "$(dirname "$RUNNER_BIN")"
}
update_runner() {
ensure_dirs
local api="https://gitea.com/api/v1/repos/gitea/act_runner/releases/latest"
local release
release="$(curl -fsSL "$api")"
local asset_url
asset_url="$(
python3 - "$(printf '%s' "$release")" <<'PY'
import json, sys
data = json.loads(sys.argv[1])
for asset in data["assets"]:
if asset["name"].endswith("linux-amd64"):
print(asset["browser_download_url"])
break
else:
raise SystemExit("no linux-amd64 act_runner asset found")
PY
)"
local tmp="$RUNNER_HOME/act_runner.tmp"
curl -fsSL "$asset_url" -o "$tmp"
chmod +x "$tmp"
mv -f "$tmp" "$RUNNER_BIN"
echo "Updated act_runner -> $RUNNER_BIN"
}
register_runner() {
ensure_dirs
if [[ -z "$SERVER_URL" || -z "$TOKEN" ]]; then
echo "register requires SERVER_URL and TOKEN (or --server-url/--token)" >&2
exit 2
fi
"$RUNNER_BIN" register \
--no-interactive \
--config "$CONFIG_PATH" \
--instance "$SERVER_URL" \
--token "$TOKEN" \
--labels "$LABELS"
}
start_runner() {
local mode="${1:-daemon}"
ensure_dirs
if [[ ! -f "$CONFIG_PATH" ]]; then
echo "Runner config not found at $CONFIG_PATH" >&2
exit 2
fi
case "$mode" in
attach)
exec "$RUNNER_BIN" daemon --config "$CONFIG_PATH"
;;
once)
exec "$RUNNER_BIN" daemon --config "$CONFIG_PATH" --once
;;
daemon)
nohup "$RUNNER_BIN" daemon --config "$CONFIG_PATH" >/tmp/act_runner.log 2>&1 &
echo "Started act_runner in background"
;;
*)
echo "Unknown start mode: $mode" >&2
exit 2
;;
esac
}
stop_runner() {
pkill -f "$RUNNER_BIN" || true
}
status_runner() {
if pgrep -f "$RUNNER_BIN" >/dev/null; then
echo "act_runner is running"
else
echo "act_runner is not running"
fi
}
ACTION="${1:-}"
shift || true
while [[ $# -gt 0 ]]; do
case "$1" in
--server-url)
SERVER_URL="$2"
shift 2
;;
--token)
TOKEN="$2"
shift 2
;;
--labels)
LABELS="$2"
shift 2
;;
--runner-home)
RUNNER_HOME="$2"
if [[ "${RUNNER_BIN:-}" == "/home/lda/gitea-runner/act_runner" ]]; then
RUNNER_BIN="$RUNNER_HOME/act_runner"
fi
if [[ "${CONFIG_PATH:-}" == "$REPO_ROOT/ar_data/wsl_runner/config.yaml" ]]; then
CONFIG_PATH="$RUNNER_HOME/config.yaml"
fi
shift 2
;;
--runner-bin)
RUNNER_BIN="$2"
shift 2
;;
--config-path)
CONFIG_PATH="$2"
RUNNER_HOME="$(dirname "$CONFIG_PATH")"
shift 2
;;
--attach)
START_MODE="attach"
shift
;;
--once)
START_MODE="once"
shift
;;
*)
echo "Unknown argument: $1" >&2
usage
exit 2
;;
esac
done
case "$ACTION" in
update)
update_runner
;;
register)
register_runner
;;
start)
start_runner "${START_MODE:-daemon}"
;;
stop)
stop_runner
;;
status)
status_runner
;;
*)
usage
exit 2
;;
esac
+60
View File
@@ -0,0 +1,60 @@
param(
[ValidateSet("update", "register", "start", "stop", "status")]
[string]$Action = "start",
[string]$Distro = $(if ($env:WAKEY_WSL_DISTRO) { $env:WAKEY_WSL_DISTRO } else { "FedoraLinux-43" }),
[string]$RunnerHome,
[string]$RunnerBin = "/home/lda/gitea-runner/act_runner",
[string]$ConfigPath,
[string]$ServerUrl,
[string]$Token,
[string]$Labels = "self-hosted,linux,fedora,wsl,release:host",
[switch]$Attach,
[switch]$Once
)
$ErrorActionPreference = "Stop"
. "$PSScriptRoot/lib.ps1"
$scriptWin = Join-Path $PSScriptRoot "act_runner_fedora.sh"
if (-not (Test-Path $scriptWin)) {
throw "Missing Fedora runner helper: $scriptWin"
}
$scriptLinux = (& wsl.exe -d $Distro -e wslpath -a "$scriptWin").Trim()
if (-not $scriptLinux) {
throw "Failed to resolve WSL path for $scriptWin"
}
if (-not $RunnerHome) {
$repoRootWin = Split-Path -Parent $PSScriptRoot
$defaultRunnerHomeWin = Join-Path $repoRootWin "ar_data/wsl_runner"
$RunnerHome = (& wsl.exe -d $Distro -e wslpath -a "$defaultRunnerHomeWin").Trim()
if (-not $RunnerHome) {
throw "Failed to resolve WSL runner home for $defaultRunnerHomeWin"
}
}
if (-not $ConfigPath) {
$repoRootWin = Split-Path -Parent $PSScriptRoot
$defaultConfigWin = Join-Path $repoRootWin "ar_data/wsl_runner/config.yaml"
$ConfigPath = (& wsl.exe -d $Distro -e wslpath -a "$defaultConfigWin").Trim()
if (-not $ConfigPath) {
throw "Failed to resolve WSL config path for $defaultConfigWin"
}
}
$parts = @("bash", (Quote-ShArg $scriptLinux), (Quote-ShArg $Action), "--runner-home", (Quote-ShArg $RunnerHome))
$parts += @("--runner-bin", (Quote-ShArg $RunnerBin), "--config-path", (Quote-ShArg $ConfigPath))
if ($ServerUrl) { $parts += @("--server-url", (Quote-ShArg $ServerUrl)) }
if ($Token) { $parts += @("--token", (Quote-ShArg $Token)) }
if ($Labels) { $parts += @("--labels", (Quote-ShArg $Labels)) }
if ($Attach) { $parts += "--attach" }
if ($Once) { $parts += "--once" }
$cmd = $parts -join " "
Write-Host $cmd
& wsl.exe -d $Distro -e bash -lc $cmd
if ($LASTEXITCODE -ne 0) {
throw "WSL runner action failed ($LASTEXITCODE)"
}
+17 -5
View File
@@ -1,12 +1,16 @@
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidDefaultValueSwitchParameter', "",
Justification = 'Default true is intentional for fast dev loop')]
param(
[ValidateSet("cargo", "cross")]
[string]$Cargo = "cargo",
[string]$Pass,
[string]$HostName = "192.168.100.1",
[string]$User = "root",
[string]$RemotePath = "/root/.bin/wakey",
[string]$AgentRemotePath = "/root/.bin/wakey-agent",
[string]$Target = "armv7-unknown-linux-musleabihf",
[string]$BinName = "wakey",
[string]$AgentBinName = "wakey-agent",
[string]$HostKey,
[switch]$Restart = $true,
[switch]$Quiet = $true
@@ -37,21 +41,26 @@ $repoRoot = Split-Path -Parent $PSScriptRoot
Push-Location $repoRoot
try {
Write-Host "[build] cargo build --release --target $Target" -ForegroundColor Cyan
cargo build --release --target $Target
if ($LASTEXITCODE -ne 0) { throw "cargo build failed ($LASTEXITCODE)" }
Write-Host "[build] $Cargo build --release --target $Target -p wakey -p wakey-agent" -ForegroundColor Cyan
. "$Cargo" build --release --target $Target -p wakey -p wakey-agent
if ($LASTEXITCODE -ne 0) { throw "$Cargo build failed ($LASTEXITCODE)" }
$localBin = Join-Path $repoRoot "target/$Target/release/$BinName"
$localAgentBin = Join-Path $repoRoot "target/$Target/release/$AgentBinName"
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"
$localDeploy = Join-Path $repoRoot 'scripts/remote_deploy_wakey.sh'
$deployTmp = '/var/tmp/remote_deploy_wakey.sh'
$deployPreferred = '/root/.bin/remote_deploy_wakey.sh'
# Push main binary
# Push binaries
Invoke-Scp -Local $localBin -Dest $destTmp -Pass $Pass -HostKey $HostKey -Quiet:$Quiet
Invoke-Scp -Local $localAgentBin -Dest $agentDestTmp -Pass $Pass -HostKey $HostKey -Quiet:$Quiet
# Push static assets
$localStatic = Join-Path $repoRoot "static"
@@ -74,7 +83,10 @@ try {
# Build and run remote deploy command
$restartFlag = $(if ($Restart) { '1' } else { '0' })
$script = Get-DeployScript $deployPreferred $deployTmp $remoteTmp $RemotePath $restartFlag
$script = @"
$(Get-DeployScript $deployPreferred $deployTmp $remoteTmp $RemotePath 0)
$(Get-DeployScript $deployPreferred $deployTmp $agentRemoteTmp $AgentRemotePath $restartFlag)
"@
$remoteCmd = "sh -lc '$($script -replace "`r",'')'"
if ($Quiet) { $remoteCmd += " >/dev/null 2>&1" }
+2
View File
@@ -5,10 +5,12 @@ START=99
USE_PROCD=1
NAME=wakey
# BIN=/root/.bin/wakey-agent
BIN=/root/.bin/wakey
start_service() {
procd_open_instance
# procd_set_param command "$BIN" serve
procd_set_param command "$BIN" http --host :: --port 12012
procd_set_param respawn 5 1 0
procd_set_param stdout 1
+9
View File
@@ -1,6 +1,7 @@
# Build a rootfs tarball suitable for `wget -O- ... | tar -xz -C /` on OpenWrt.
# It lays out files as absolute-root paths inside the archive:
# root/.bin/wakey
# root/.bin/wakey-agent
# etc/init.d/wakey
# Usage: ./scripts/package_rootfs.ps1 -Version 0.1.0 -Target armv7-unknown-linux-musleabihf -OutDir dist
param(
@@ -17,7 +18,9 @@ $dist = Join-Path $root $OutDir
New-Item -ItemType Directory -Force -Path $dist | Out-Null
$binName = if ($Target -like "*-windows-*") { "wakey.exe" } else { "wakey" }
$agentBinName = if ($Target -like "*-windows-*") { "wakey-agent.exe" } else { "wakey-agent" }
$binSrc = Join-Path $root "target/$Target/release/$binName"
$agentBinSrc = Join-Path $root "target/$Target/release/$agentBinName"
$staging = Join-Path $dist ("rootfs-" + [System.Guid]::NewGuid().ToString("N"))
New-Item -ItemType Directory -Force -Path $staging | Out-Null
@@ -35,6 +38,12 @@ if (-not $NoBin) {
else {
throw "Missing binary: $binSrc (build it first or pass -NoBin)"
}
if (Test-Path $agentBinSrc) {
Copy-Item $agentBinSrc (Join-Path $rootDir "wakey-agent") -Force
}
else {
throw "Missing binary: $agentBinSrc (build it first or pass -NoBin)"
}
}
# Normalize kill script line endings and copy
$killSrc = Join-Path $root 'scripts/kill_wakey.sh'
+28 -9
View File
@@ -10,7 +10,10 @@ param(
[string]$Version,
[switch]$Tag,
[switch]$Publish,
[string]$Registry
[string]$Registry,
[ValidateSet('wsl', 'windows', 'none')]
[string]$RunnerMode = 'wsl',
[string]$WslDistro = $(if ($env:WAKEY_WSL_DISTRO) { $env:WAKEY_WSL_DISTRO } else { 'FedoraLinux' })
)
$ErrorActionPreference = 'Stop'
@@ -60,13 +63,29 @@ if ($Tag -and $Version) {
git push -f origin "v$Version"
Write-Host "Pushed tag: v$Version"
# Run act_runner in --once mode to process the release job
$actRunnerScript = Join-Path $PSScriptRoot 'act_runner.ps1'
if (Test-Path $actRunnerScript) {
Write-Host "Starting act_runner (once mode) to process release job..."
& $actRunnerScript -Once
}
else {
Write-Warning "act_runner.ps1 not found at $actRunnerScript. Skipping runner."
switch ($RunnerMode) {
'wsl' {
$runnerScript = Join-Path $PSScriptRoot 'act_runner_wsl.ps1'
if (Test-Path $runnerScript) {
Write-Host "Starting Fedora/WSL act_runner (once mode) to process release job..."
& $runnerScript -Distro $WslDistro -Action start -Once
}
else {
Write-Warning "act_runner_wsl.ps1 not found at $runnerScript. Skipping runner."
}
}
'windows' {
$runnerScript = Join-Path $PSScriptRoot 'act_runner.ps1'
if (Test-Path $runnerScript) {
Write-Host "Starting Windows act_runner (once mode) to process release job..."
& $runnerScript -Once
}
else {
Write-Warning "act_runner.ps1 not found at $runnerScript. Skipping runner."
}
}
'none' {
Write-Host "RunnerMode=none; not starting a local runner helper."
}
}
}
+365
View File
@@ -0,0 +1,365 @@
//! CLI argument parsing, dispatch, rendering, and tracing defaults.
pub mod table;
use std::net::{IpAddr, SocketAddr};
use anyhow::Result;
use clap::{ArgAction, Args, Parser, Subcommand};
use tracing::{debug, info};
use tracing_subscriber::{EnvFilter, fmt, layer::SubscriberExt, util::SubscriberInitExt};
use wakey_core::{DeviceFilters, DeviceQuery, InterfaceSummary, WakeResult};
#[derive(Parser)]
#[command(name = "wakey")]
#[command(version, about = "CLI and temporary HTTP adapter for Wakey")]
#[command(
long_about = "Wakey can run as a local/operator CLI or serve the legacy HTTP/static interface during the migration to a service-first architecture."
)]
pub struct Cli {
/// Increase log verbosity. Use `-v` for debug and `-vv` for trace.
#[arg(short = 'v', long = "verbose", action = ArgAction::Count, global = true)]
pub verbose: u8,
#[command(subcommand)]
pub command: Command,
}
#[derive(Subcommand)]
pub enum Command {
/// Serve the temporary legacy HTTP/static app.
Http(HttpArgs),
/// Show device status rows from neighbor/device data.
Status(StatusArgs),
/// Show DHCP leases, optionally enriched with current neighbor state.
Leases(LeasesArgs),
/// Send Wake-on-LAN packets from a query or explicit MAC/IP pair.
Wake(WakeArgs),
/// Show condensed network interface summaries.
Devs(DevsArgs),
}
#[derive(Args)]
pub struct HttpArgs {
/// Host address to bind the HTTP server to.
#[arg(long, default_value = "::")]
pub host: IpAddr,
/// TCP port to bind the HTTP server to.
#[arg(long, default_value_t = 12012)]
pub port: u16,
}
#[derive(Args)]
pub struct LeasesArgs {
/// Include best-known current neighbor state for each lease IP.
#[arg(long)]
pub include_state: bool,
/// Print machine-readable JSON instead of a table.
#[arg(long)]
pub json: bool,
}
#[derive(Args)]
#[command(after_long_help = "Examples:
wakey wake bedroom-pc
wakey wake --mac aa:bb:cc:dd:ee:ff
wakey wake --mac aa:bb:cc:dd:ee:ff --ip 192.168.1.255
Rules:
- query mode and explicit --mac/--ip mode are mutually exclusive
- --ip requires --mac
- --mac without --ip fans out to interface broadcast targets")]
pub struct WakeArgs {
/// Free-form device query, for example a hostname, IP, MAC, interface, or NUD state.
pub query: Option<String>,
/// Explicit MAC address for manual wake mode.
#[arg(long)]
pub mac: Option<macaddr::MacAddr>,
/// Explicit destination IP or broadcast address for manual wake mode.
#[arg(long)]
pub ip: Option<IpAddr>,
/// Print machine-readable JSON instead of a table.
#[arg(long)]
pub json: bool,
}
#[derive(Args)]
#[command(after_long_help = "Examples:
wakey status bedroom-pc
wakey status --mac aa:bb:cc:dd:ee:ff
wakey status --dev br-lan --nud reachable
If only the positional query is provided, it is treated as free-form input and resolved through the smart selector path.")]
pub struct StatusArgs {
/// Free-form device query.
pub query: Option<String>,
/// Explicit name/text filter.
#[arg(long)]
pub name: Option<String>,
/// Explicit IP filters.
#[arg(long = "ip")]
pub ips: Vec<std::net::IpAddr>,
/// Explicit interface-name filters.
#[arg(long = "dev")]
pub devs: Vec<String>,
/// Explicit neighbor-state filters.
#[arg(long = "nud")]
pub nuds: Vec<wakey_core::NeighborState>,
/// Explicit MAC-address filters.
#[arg(long = "mac")]
pub macs: Vec<macaddr::MacAddr>,
/// Print machine-readable JSON instead of a table.
#[arg(long)]
pub json: bool,
}
#[derive(Args)]
#[command(after_long_help = "Examples:
wakey devs
wakey devs br-lan
wakey devs --up
wakey devs --json")]
pub struct DevsArgs {
/// Optional interface name to show.
pub dev: Option<String>,
/// Show only interfaces whose operstate is `up`.
#[arg(long)]
pub up: bool,
/// Print machine-readable JSON instead of a table.
#[arg(long)]
pub json: bool,
}
pub fn init_tracing(verbose: u8) {
let filter = EnvFilter::try_from_default_env()
.or_else(|_| EnvFilter::try_new(default_filter_for_verbosity(verbose)))
.expect("static tracing filter should parse");
tracing_subscriber::registry()
.with(filter)
.with(fmt::layer())
.init();
}
pub fn default_filter_for_verbosity(verbose: u8) -> &'static str {
match verbose {
0 => "wakey=info,tower_http=info",
1 => "wakey=debug,tower_http=debug",
_ => "wakey=trace,tower_http=trace",
}
}
pub async fn run(cli: Cli) -> Result<()> {
init_tracing(cli.verbose);
match cli.command {
Command::Http(args) => {
let addr = SocketAddr::new(args.host, args.port);
info!(%addr, "dispatching http command");
wakey::serve_http_from_current_exe(addr).await?;
}
Command::Status(args) => {
let as_json = args.json;
let query = status_args_to_query(args);
debug!(?query, json = as_json, "dispatching status command");
let status = if query.name.is_some()
&& query.filter.ips.is_empty()
&& query.filter.devs.is_empty()
&& query.filter.nuds.is_empty()
&& query.filter.macs.is_empty()
{
wakey::get_status_for_input(query.name.clone().unwrap_or_default()).await?
} else {
wakey::get_status(query).await?
};
if as_json {
println!("{}", serde_json::to_string_pretty(&status)?);
} else {
if let Some(name) = &status.name {
println!("name: {name}");
}
println!("{}", table::render_status_table(&status));
}
}
Command::Leases(args) => {
debug!(
include_state = args.include_state,
json = args.json,
"dispatching leases command"
);
let leases = wakey::get_leases(wakey_core::LeaseQuery {
include_state: args.include_state,
})
.await?;
if args.json {
println!("{}", serde_json::to_string_pretty(&leases)?);
} else {
println!("{}", table::render_leases_table(&leases));
}
}
Command::Wake(args) => {
let as_json = args.json;
debug!(
has_query = args.query.is_some(),
has_mac = args.mac.is_some(),
has_ip = args.ip.is_some(),
json = as_json,
"dispatching wake command"
);
let result = run_wake(args).await?;
if as_json {
println!("{}", serde_json::to_string_pretty(&result)?);
} else {
println!("{}", table::render_wake_table(&result));
}
}
Command::Devs(args) => {
debug!(dev = ?args.dev, up = args.up, json = args.json, "dispatching devs command");
let devs = if let Some(name) = &args.dev {
wakey::get_interface_summary(name)
.await?
.into_iter()
.collect()
} else {
wakey::get_interface_summaries().await?
};
let devs = filter_interface_summaries(devs, &args);
if args.json {
println!("{}", serde_json::to_string_pretty(&devs)?);
} else {
println!("{}", table::render_devs_table(&devs));
}
}
}
Ok(())
}
fn status_args_to_query(args: StatusArgs) -> DeviceQuery {
if let Some(query) = args.query.as_ref()
&& args.name.is_none()
&& args.ips.is_empty()
&& args.devs.is_empty()
&& args.nuds.is_empty()
&& args.macs.is_empty()
{
return DeviceQuery {
name: Some(query.clone()),
..Default::default()
};
}
DeviceQuery {
name: args.name.or(args.query),
filter: DeviceFilters {
ips: args.ips,
devs: args.devs,
nuds: args.nuds,
macs: args.macs,
},
}
}
fn validate_wake_args(args: &WakeArgs) -> Result<()> {
let has_query = args.query.is_some();
let has_mac = args.mac.is_some();
let has_ip = args.ip.is_some();
if has_ip && !has_mac {
anyhow::bail!("`wakey wake --ip` needs `--mac`");
}
if has_query && (has_mac || has_ip) {
anyhow::bail!("query mode and explicit `--mac/--ip` mode are mutually exclusive");
}
if !has_query && !has_mac {
anyhow::bail!("provide either a query or `--mac`");
}
Ok(())
}
async fn run_wake(args: WakeArgs) -> Result<WakeResult> {
validate_wake_args(&args)?;
match (args.query, args.mac, args.ip) {
(Some(query), None, None) => wakey::wake_from_query(query).await,
(None, Some(mac), ip) => wakey::wake_explicit(mac, ip).await,
_ => unreachable!("wake args validated before dispatch"),
}
}
fn filter_interface_summaries(
mut devs: Vec<InterfaceSummary>,
args: &DevsArgs,
) -> Vec<InterfaceSummary> {
if args.up {
devs.retain(|dev| dev.operstate == "up");
}
if let Some(name) = &args.dev {
devs.retain(|dev| &dev.ifname == name);
}
devs
}
#[cfg(test)]
mod tests {
use super::{WakeArgs, default_filter_for_verbosity};
#[test]
fn wake_rejects_ip_without_mac() {
let err = super::validate_wake_args(&WakeArgs {
query: None,
mac: None,
ip: Some("192.168.1.10".parse().expect("ip")),
json: false,
})
.expect_err("ip-only wake should be rejected");
assert!(err.to_string().contains("--ip"));
}
#[test]
fn wake_rejects_mixed_query_and_explicit_mode() {
let err = super::validate_wake_args(&WakeArgs {
query: Some("pc".into()),
mac: Some("aa:bb:cc:dd:ee:ff".parse().expect("mac")),
ip: None,
json: false,
})
.expect_err("mixed wake mode should be rejected");
assert!(err.to_string().contains("mutually exclusive"));
}
#[test]
fn wake_accepts_query_mode() {
super::validate_wake_args(&WakeArgs {
query: Some("pc".into()),
mac: None,
ip: None,
json: false,
})
.expect("query mode should be accepted");
}
#[test]
fn wake_accepts_manual_mac_mode() {
super::validate_wake_args(&WakeArgs {
query: None,
mac: Some("aa:bb:cc:dd:ee:ff".parse().expect("mac")),
ip: None,
json: false,
})
.expect("manual mac mode should be accepted");
}
#[test]
fn verbosity_maps_to_expected_default_filters() {
assert_eq!(default_filter_for_verbosity(0), "wakey=info,tower_http=info");
assert_eq!(default_filter_for_verbosity(1), "wakey=debug,tower_http=debug");
assert_eq!(default_filter_for_verbosity(2), "wakey=trace,tower_http=trace");
assert_eq!(default_filter_for_verbosity(9), "wakey=trace,tower_http=trace");
}
}
+112
View File
@@ -0,0 +1,112 @@
use chrono::{DateTime, Local};
use comfy_table::{Cell, ContentArrangement, Table, presets::UTF8_FULL};
use wakey_core::{DhcpLeaseWithState, InterfaceSummary, Status, WakeResult};
pub fn render_status_table(status: &Status<wakey_core::NeighborEntry>) -> Table {
let mut table = base_table();
table.set_header(vec!["IP", "MAC", "Dev", "State"]);
for row in &status.table {
table.add_row(vec![
Cell::new(row.ip.to_string()),
Cell::new(row.mac.map(|v| v.to_string()).unwrap_or_default()),
Cell::new(row.dev.clone().unwrap_or_default()),
Cell::new(row.state.to_string()),
]);
}
table
}
pub fn render_leases_table(leases: &[DhcpLeaseWithState]) -> Table {
let mut table = base_table();
table.set_header(vec!["Expires", "IP", "MAC", "Name", "State"]);
for lease in leases {
table.add_row(vec![
Cell::new(format_epoch(lease.lease_line.expires_epoch)),
Cell::new(lease.lease_line.ip.to_string()),
Cell::new(lease.lease_line.mac.to_string()),
Cell::new(lease.lease_line.name.clone().unwrap_or_default()),
Cell::new(
lease
.nud_state
.map(|v| v.to_string())
.unwrap_or_default(),
),
]);
}
table
}
pub fn render_wake_table(result: &WakeResult) -> Table {
let mut table = base_table();
table.set_header(vec!["IP", "MAC", "Status"]);
for row in &result.result {
table.add_row(vec![
Cell::new(row.target.ip.map(|v| v.to_string()).unwrap_or_default()),
Cell::new(row.target.mac.map(|v| v.to_string()).unwrap_or_default()),
Cell::new(format!("{:?}", row.status)),
]);
}
table
}
pub fn render_devs_table(devs: &[InterfaceSummary]) -> Table {
let mut table = base_table();
table.set_header(vec!["Ifname", "State", "MAC", "Family", "CIDR", "Broadcast", "Scope/Label"]);
for dev in devs {
if dev.addrs.is_empty() {
table.add_row(vec![
Cell::new(&dev.ifname),
Cell::new(&dev.operstate),
Cell::new(dev.mac.map(|v| v.to_string()).unwrap_or_default()),
Cell::new(""),
Cell::new(""),
Cell::new(""),
Cell::new(""),
]);
continue;
}
for (idx, addr) in dev.addrs.iter().enumerate() {
let lead = idx == 0;
table.add_row(vec![
Cell::new(if lead { dev.ifname.as_str() } else { "" }),
Cell::new(if lead { dev.operstate.as_str() } else { "" }),
Cell::new(if lead {
dev.mac.map(|v| v.to_string()).unwrap_or_default()
} else {
String::new()
}),
Cell::new(addr.family.clone().unwrap_or_default()),
Cell::new(addr.cidr.clone().unwrap_or_default()),
Cell::new(addr.broadcast.map(|v| v.to_string()).unwrap_or_default()),
Cell::new(match (&addr.scope, &addr.label) {
(Some(scope), Some(label)) => format!("{scope} / {label}"),
(Some(scope), None) => scope.clone(),
(None, Some(label)) => label.clone(),
(None, None) => String::new(),
}),
]);
}
}
table
}
fn base_table() -> Table {
let mut table = Table::new();
table
.load_preset(UTF8_FULL)
.set_content_arrangement(ContentArrangement::Dynamic);
table
}
fn format_epoch(epoch: u64) -> String {
DateTime::from_timestamp(epoch as i64, 0)
.map(|dt| {
dt.with_timezone(&Local)
.format("%Y-%m-%d %H:%M:%S")
.to_string()
})
.unwrap_or_else(|| epoch.to_string())
}
-123
View File
@@ -1,123 +0,0 @@
use chrono::{DateTime, Local, Utc};
use comfy_table::{Cell, ContentArrangement, Table, presets};
use wakey_core::{DhcpLeaseWithState, InterfaceSummary, WakeResult};
fn base_table() -> Table {
let mut table = Table::new();
table
.load_preset(presets::UTF8_FULL_CONDENSED)
.set_content_arrangement(ContentArrangement::Dynamic);
table
}
pub fn render_status_table(status: &wakey::StatusResponse) -> Table {
let mut table = base_table();
table.set_header(["IP", "MAC", "State", "IF"]);
for row in &status.table {
table.add_row([
Cell::new(row.ip),
Cell::new(row.mac.map(|m| m.to_string()).unwrap_or_default()),
Cell::new(format!("{:?}", row.state).to_lowercase()),
Cell::new(row.dev.clone().unwrap_or_default()),
]);
}
table
}
pub fn render_leases_table(leases: &[DhcpLeaseWithState]) -> Table {
let mut table = base_table();
table.set_header(["IP", "MAC", "Name", "Expires", "NUD"]);
for lease in leases {
let expires = format_epoch_local(lease.lease_line.expires_epoch);
table.add_row([
Cell::new(lease.lease_line.ip),
Cell::new(lease.lease_line.mac),
Cell::new(lease.lease_line.name.clone().unwrap_or_default()),
Cell::new(expires),
Cell::new(
lease
.nud_state
.map(|s| format!("{:?}", s).to_lowercase())
.unwrap_or_default(),
),
]);
}
table
}
pub fn render_wake_table(result: &WakeResult) -> Table {
let mut table = base_table();
table.set_header(["IP", "MAC", "Status"]);
for row in &result.result {
table.add_row([
Cell::new(row.target.ip.map(|ip| ip.to_string()).unwrap_or_default()),
Cell::new(row.target.mac.map(|m| m.to_string()).unwrap_or_default()),
Cell::new(format!("{:?}", row.status).to_lowercase()),
]);
}
table
}
pub fn render_devs_table(devs: &[InterfaceSummary]) -> Table {
let mut table = base_table();
table.set_header([
"Interface",
"State",
"MAC",
"Family",
"Address",
"Broadcast",
"Scope/Label",
]);
for dev in devs {
if dev.addrs.is_empty() {
table.add_row([
Cell::new(&dev.ifname),
Cell::new(&dev.operstate),
Cell::new(dev.mac.map(|m| m.to_string()).unwrap_or_default()),
Cell::new(""),
Cell::new(""),
Cell::new(""),
Cell::new(""),
]);
continue;
}
for (idx, addr) in dev.addrs.iter().enumerate() {
let scope_label = match (&addr.scope, &addr.label) {
(Some(scope), Some(label)) => format!("{scope} ({label})"),
(Some(scope), None) => scope.clone(),
(None, Some(label)) => label.clone(),
(None, None) => String::new(),
};
let lead = idx == 0;
table.add_row([
Cell::new(if lead { dev.ifname.as_str() } else { "" }),
Cell::new(if lead { dev.operstate.as_str() } else { "" }),
Cell::new(if lead {
dev.mac.map(|m| m.to_string()).unwrap_or_default()
} else {
String::new()
}),
Cell::new(addr.family.clone().unwrap_or_default()),
Cell::new(addr.cidr.clone().unwrap_or_default()),
Cell::new(addr.broadcast.map(|ip| ip.to_string()).unwrap_or_default()),
Cell::new(scope_label),
]);
}
}
table
}
fn format_epoch_local(epoch: u64) -> String {
match DateTime::<Utc>::from_timestamp(epoch as i64, 0) {
Some(dt) => dt
.with_timezone(&Local)
.format("%Y-%m-%d %H:%M:%S")
.to_string(),
None => epoch.to_string(),
}
}
+8
View File
@@ -1,3 +1,9 @@
//! Compatibility types and mappers for the legacy HTTP/static client.
//!
//! These types intentionally preserve old JSON shapes expected by `/static`
//! while the core and service layers evolve underneath them. They do not define
//! the long-term domain model of the project.
use serde::Serialize;
use wakey_core::parse::mac;
use wakey_core::{
@@ -49,6 +55,8 @@ pub struct LegacyWakeResultRow {
}
/// Map legacy-style status rows into the old response shape.
///
/// This helper exists for compatibility with the original frontend contract.
pub fn legacy_status_from_domain(status: Status<NeighborEntry>) -> LegacyStatusResponse {
LegacyStatusResponse {
name: status.name,
+18 -2
View File
@@ -1,3 +1,9 @@
//! Temporary HTTP adapter for the legacy web/static surface.
//!
//! This module exists to keep the old `/api` routes and `/static` frontend
//! working while the project is migrated toward a service-first architecture.
//! New product logic should live in [`crate::service`], not here.
pub mod compat;
pub mod route;
@@ -5,12 +11,17 @@ use std::{io, net::SocketAddr};
use axum::Router;
use tokio::net::TcpListener;
use tower_http::services::ServeDir;
use tower_http::{services::ServeDir, trace::TraceLayer};
use tracing::info;
/// Build the temporary HTTP app that serves the legacy API and static frontend.
///
/// This is a compatibility surface. It should stay thin and delegate actual
/// product behavior to the service layer.
pub fn http_app(static_root: std::path::PathBuf) -> Router {
Router::new()
.nest("/api", route::api_router())
.layer(TraceLayer::new_for_http())
.fallback_service(axum::routing::get_service(
ServeDir::new(static_root)
.append_index_html_on_directories(true)
@@ -22,12 +33,17 @@ pub fn http_app(static_root: std::path::PathBuf) -> Router {
}
/// Serve the temporary HTTP app on the provided socket address.
///
/// This is intended for transition and compatibility, not as the long-term
/// architecture boundary of the project.
pub async fn serve_http(addr: SocketAddr, static_root: std::path::PathBuf) -> io::Result<()> {
info!(%addr, static_root = %static_root.display(), "starting legacy http adapter");
let listener = TcpListener::bind(addr).await?;
axum::serve(listener, http_app(static_root).into_make_service()).await
}
/// Serve the HTTP app using the `static/` directory next to the current executable.
/// Serve the HTTP app using the `static/` directory next to the current
/// executable.
pub async fn serve_http_from_current_exe(addr: SocketAddr) -> io::Result<()> {
let exe = std::env::current_exe()?;
let root = exe
+6
View File
@@ -1,2 +1,8 @@
//! Transitional compatibility wrappers preserved during the migration.
//!
//! Items in this module exist so the codebase can keep working while older
//! parsing paths and adapter surfaces are being retired or replaced. New logic
//! should prefer the service layer and the dedicated crate boundaries instead.
pub mod arpparse;
pub mod dhcpparse;
+3 -308
View File
@@ -1,193 +1,6 @@
mod cli_table;
mod cli;
use std::net::{IpAddr, SocketAddr};
use clap::{Args, Parser, Subcommand};
use wakey_core::{DeviceFilters, DeviceQuery, InterfaceSummary, WakeResult};
#[derive(Parser)]
#[command(name = "wakey")]
#[command(version, about = "CLI and temporary HTTP adapter for Wakey")]
#[command(
long_about = "Wakey can run as a local/operator CLI or serve the legacy HTTP/static interface during the migration to a service-first architecture."
)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
/// Serve the temporary legacy HTTP/static app.
Http(HttpArgs),
/// Show device status rows from neighbor/device data.
Status(StatusArgs),
/// Show DHCP leases, optionally enriched with current neighbor state.
Leases(LeasesArgs),
/// Send Wake-on-LAN packets from a query or explicit MAC/IP pair.
Wake(WakeArgs),
/// Show condensed network interface summaries.
Devs(DevsArgs),
}
#[derive(Args)]
struct HttpArgs {
/// Host address to bind the HTTP server to.
#[arg(long, default_value = "::")]
host: IpAddr,
/// TCP port to bind the HTTP server to.
#[arg(long, default_value_t = 12012)]
port: u16,
}
#[derive(Args)]
struct LeasesArgs {
/// Include best-known current neighbor state for each lease IP.
#[arg(long)]
include_state: bool,
/// Print machine-readable JSON instead of a table.
#[arg(long)]
json: bool,
}
#[derive(Args)]
#[command(after_long_help = "Examples:
wakey wake bedroom-pc
wakey wake --mac aa:bb:cc:dd:ee:ff
wakey wake --mac aa:bb:cc:dd:ee:ff --ip 192.168.1.255
Rules:
- query mode and explicit --mac/--ip mode are mutually exclusive
- --ip requires --mac
- --mac without --ip fans out to interface broadcast targets")]
struct WakeArgs {
/// Free-form device query, for example a hostname, IP, MAC, interface, or NUD state.
query: Option<String>,
/// Explicit MAC address for manual wake mode.
#[arg(long)]
mac: Option<macaddr::MacAddr>,
/// Explicit destination IP or broadcast address for manual wake mode.
#[arg(long)]
ip: Option<IpAddr>,
/// Print machine-readable JSON instead of a table.
#[arg(long)]
json: bool,
}
#[derive(Args)]
#[command(after_long_help = "Examples:
wakey status bedroom-pc
wakey status --mac aa:bb:cc:dd:ee:ff
wakey status --dev br-lan --nud reachable
If only the positional query is provided, it is treated as free-form input and resolved through the smart selector path.")]
struct StatusArgs {
/// Free-form device query.
query: Option<String>,
/// Explicit name/text filter.
#[arg(long)]
name: Option<String>,
/// Explicit IP filters.
#[arg(long = "ip")]
ips: Vec<std::net::IpAddr>,
/// Explicit interface-name filters.
#[arg(long = "dev")]
devs: Vec<String>,
/// Explicit neighbor-state filters.
#[arg(long = "nud")]
nuds: Vec<wakey_core::NeighborState>,
/// Explicit MAC-address filters.
#[arg(long = "mac")]
macs: Vec<macaddr::MacAddr>,
/// Print machine-readable JSON instead of a table.
#[arg(long)]
json: bool,
}
#[derive(Args)]
#[command(after_long_help = "Examples:
wakey devs
wakey devs br-lan
wakey devs --up
wakey devs --json")]
struct DevsArgs {
/// Optional interface name to show.
dev: Option<String>,
/// Show only interfaces whose operstate is `up`.
#[arg(long)]
up: bool,
/// Print machine-readable JSON instead of a table.
#[arg(long)]
json: bool,
}
fn status_args_to_query(args: StatusArgs) -> wakey_core::DeviceQuery {
if let Some(query) = args.query.as_ref()
&& args.name.is_none()
&& args.ips.is_empty()
&& args.devs.is_empty()
&& args.nuds.is_empty()
&& args.macs.is_empty()
{
return DeviceQuery {
name: Some(query.clone()),
..Default::default()
};
}
DeviceQuery {
name: args.name.or(args.query),
filter: DeviceFilters {
ips: args.ips,
devs: args.devs,
nuds: args.nuds,
macs: args.macs,
},
}
}
fn validate_wake_args(args: &WakeArgs) -> anyhow::Result<()> {
let has_query = args.query.is_some();
let has_mac = args.mac.is_some();
let has_ip = args.ip.is_some();
if has_ip && !has_mac {
anyhow::bail!("`wakey wake --ip` needs `--mac`");
}
if has_query && (has_mac || has_ip) {
anyhow::bail!("query mode and explicit `--mac/--ip` mode are mutually exclusive");
}
if !has_query && !has_mac {
anyhow::bail!("provide either a query or `--mac`");
}
Ok(())
}
async fn run_wake(args: WakeArgs) -> anyhow::Result<WakeResult> {
validate_wake_args(&args)?;
match (args.query, args.mac, args.ip) {
(Some(query), None, None) => wakey::wake_from_query(query).await,
(None, Some(mac), ip) => wakey::wake_explicit(mac, ip).await,
_ => unreachable!("wake args validated before dispatch"),
}
}
fn filter_interface_summaries(
mut devs: Vec<InterfaceSummary>,
args: &DevsArgs,
) -> Vec<InterfaceSummary> {
if args.up {
devs.retain(|dev| dev.operstate == "up");
}
if let Some(name) = &args.dev {
devs.retain(|dev| &dev.ifname == name);
}
devs
}
use clap::Parser;
#[cfg(not(target_os = "linux"))]
fn main() -> anyhow::Result<()> {
@@ -199,123 +12,5 @@ fn main() -> anyhow::Result<()> {
#[cfg(target_os = "linux")]
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
match cli.command {
Command::Http(args) => {
let addr = SocketAddr::new(args.host, args.port);
wakey::serve_http_from_current_exe(addr).await?;
}
Command::Status(args) => {
let as_json = args.json;
let query = status_args_to_query(args);
let status = if query.name.is_some()
&& query.filter.ips.is_empty()
&& query.filter.devs.is_empty()
&& query.filter.nuds.is_empty()
&& query.filter.macs.is_empty()
{
wakey::get_status_for_input(query.name.clone().unwrap_or_default()).await?
} else {
wakey::get_status(query).await?
};
if as_json {
println!("{}", serde_json::to_string_pretty(&status)?);
} else {
if let Some(name) = &status.name {
println!("name: {name}");
}
println!("{}", cli_table::render_status_table(&status));
}
}
Command::Leases(args) => {
let leases = wakey::get_leases(wakey_core::LeaseQuery {
include_state: args.include_state,
})
.await?;
if args.json {
println!("{}", serde_json::to_string_pretty(&leases)?);
} else {
println!("{}", cli_table::render_leases_table(&leases));
}
}
Command::Wake(args) => {
let as_json = args.json;
let result = run_wake(args).await?;
if as_json {
println!("{}", serde_json::to_string_pretty(&result)?);
} else {
println!("{}", cli_table::render_wake_table(&result));
}
}
Command::Devs(args) => {
let devs = if let Some(name) = &args.dev {
wakey::get_interface_summary(name)
.await?
.into_iter()
.collect()
} else {
wakey::get_interface_summaries().await?
};
let devs = filter_interface_summaries(devs, &args);
if args.json {
println!("{}", serde_json::to_string_pretty(&devs)?);
} else {
println!("{}", cli_table::render_devs_table(&devs));
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::WakeArgs;
#[test]
fn wake_rejects_ip_without_mac() {
let err = super::validate_wake_args(&WakeArgs {
query: None,
mac: None,
ip: Some("192.168.1.10".parse().expect("ip")),
json: false,
})
.expect_err("ip-only wake should be rejected");
assert!(err.to_string().contains("--ip"));
}
#[test]
fn wake_rejects_mixed_query_and_explicit_mode() {
let err = super::validate_wake_args(&WakeArgs {
query: Some("pc".into()),
mac: Some("aa:bb:cc:dd:ee:ff".parse().expect("mac")),
ip: None,
json: false,
})
.expect_err("mixed wake mode should be rejected");
assert!(err.to_string().contains("mutually exclusive"));
}
#[test]
fn wake_accepts_query_mode() {
super::validate_wake_args(&WakeArgs {
query: Some("pc".into()),
mac: None,
ip: None,
json: false,
})
.expect("query mode should be accepted");
}
#[test]
fn wake_accepts_manual_mac_mode() {
super::validate_wake_args(&WakeArgs {
query: None,
mac: Some("aa:bb:cc:dd:ee:ff".parse().expect("mac")),
ip: None,
json: false,
})
.expect("manual mac mode should be accepted");
}
cli::run(cli::Cli::parse()).await
}
+10 -3
View File
@@ -1,4 +1,5 @@
use anyhow::Result;
use tracing::{debug, instrument};
use wakey_core::InterfaceSummary;
/// Return interface names only.
@@ -9,16 +10,22 @@ pub async fn list_interfaces() -> Result<Vec<String>> {
}
/// Return condensed interface summaries useful for CLI and wake routing.
#[instrument(skip_all)]
pub async fn get_interface_summaries() -> Result<Vec<InterfaceSummary>> {
wakey_linux::devices::list_interface_summaries().await
let summaries = wakey_linux::devices::list_interface_summaries().await?;
debug!(count = summaries.len(), "loaded interface summaries");
Ok(summaries)
}
/// Return one named interface summary when present.
#[instrument(skip_all, fields(ifname = name))]
pub async fn get_interface_summary(name: &str) -> Result<Option<InterfaceSummary>> {
Ok(get_interface_summaries()
let summary = get_interface_summaries()
.await?
.into_iter()
.find(|iface| iface.ifname == name))
.find(|iface| iface.ifname == name);
debug!(found = summary.is_some(), "resolved interface summary");
Ok(summary)
}
/// Resolve a hostname through the local resolver and collect all returned IPs.
+5 -1
View File
@@ -1,4 +1,5 @@
use anyhow::Result;
use tracing::{debug, instrument};
use wakey_core::{Device, DeviceQuery, NeighborEntry, Presence, Status};
use crate::service::inventory::inventory;
@@ -11,13 +12,15 @@ pub type StatusResponse = Status<NeighborEntry>;
///
/// This keeps the old status response shape alive while the underlying model is
/// increasingly device-centered.
#[instrument(skip_all, fields(name = ?query.name))]
pub async fn get_status(query: DeviceQuery) -> Result<StatusResponse> {
let inventory = inventory(query.clone()).await?;
let table = inventory
let table: Vec<NeighborEntry> = inventory
.devices
.iter()
.flat_map(device_to_status_rows)
.collect();
debug!(rows = table.len(), devices = inventory.devices.len(), "built status response");
Ok(Status {
name: query.name,
table,
@@ -26,6 +29,7 @@ pub async fn get_status(query: DeviceQuery) -> Result<StatusResponse> {
}
/// Convenience wrapper around [`get_status`] for free-form user input.
#[instrument(skip_all)]
pub async fn get_status_for_input(input: impl Into<String>) -> Result<StatusResponse> {
let query = resolve_query(input).await?;
get_status(query).await
+16 -3
View File
@@ -1,20 +1,24 @@
use anyhow::{Context, Result};
use macaddr::MacAddr;
use std::net::IpAddr;
use tracing::{debug, instrument};
use wakey_core::{InterfaceSummary, WakeResult, WakeTarget};
use crate::service::interfaces::get_interface_summaries;
use crate::service::inventory::resolve_devices;
/// Send Wake-on-LAN packets for already-concrete wake targets.
#[instrument(skip_all, fields(targets = targets.len()))]
pub async fn wake_targets(targets: Vec<WakeTarget>) -> Result<WakeResult> {
let result = wakey_linux::wake::wake_many(targets)
.await
.context("failed to send wake packets")?;
debug!(results = result.len(), "wake packets sent");
Ok(WakeResult { result })
}
/// Resolve free-form input into wake targets and send the packets.
#[instrument(skip_all)]
pub async fn wake_from_query(input: impl Into<String>) -> Result<WakeResult> {
let targets = resolve_wake_targets(input).await?;
wake_targets(targets).await
@@ -23,12 +27,14 @@ pub async fn wake_from_query(input: impl Into<String>) -> Result<WakeResult> {
/// Build broadcast wake targets for every broadcast-capable interface.
///
/// This is used by explicit manual wake mode when only a MAC address is supplied.
#[instrument(skip_all)]
pub async fn broadcast_wake_targets(mac: MacAddr) -> Result<Vec<WakeTarget>> {
let interfaces = get_interface_summaries().await?;
broadcast_wake_targets_from_interfaces(&interfaces, mac)
}
/// Wake a device explicitly by MAC, optionally targeting a specific IP/broadcast.
#[instrument(skip_all, fields(has_ip = ip.is_some()))]
pub async fn wake_explicit(mac: MacAddr, ip: Option<IpAddr>) -> Result<WakeResult> {
let targets = match ip {
Some(ip) => explicit_wake_targets_for_ip(mac, ip),
@@ -41,9 +47,10 @@ pub async fn wake_explicit(mac: MacAddr, ip: Option<IpAddr>) -> Result<WakeResul
///
/// The current resolution strategy fans out one wake target per resolved device IP,
/// using the first known MAC address for that device.
#[instrument(skip_all)]
pub async fn resolve_wake_targets(input: impl Into<String>) -> Result<Vec<WakeTarget>> {
let devices = resolve_devices(input).await?;
Ok(devices
let targets: Vec<WakeTarget> = devices
.into_iter()
.flat_map(|device| {
let mac = device.macs.first().copied();
@@ -52,7 +59,9 @@ pub async fn resolve_wake_targets(input: impl Into<String>) -> Result<Vec<WakeTa
.into_iter()
.map(move |ip| WakeTarget { ip: Some(ip), mac })
})
.collect())
.collect();
debug!(targets = targets.len(), "resolved wake targets");
Ok(targets)
}
fn explicit_wake_targets_for_ip(mac: MacAddr, ip: IpAddr) -> Vec<WakeTarget> {
@@ -80,6 +89,7 @@ fn broadcast_wake_targets_from_interfaces(
anyhow::bail!("no broadcast-capable interfaces found");
}
debug!(targets = targets.len(), interfaces = interfaces.len(), "built broadcast wake targets");
Ok(targets)
}
@@ -122,7 +132,10 @@ mod tests {
let err = broadcast_wake_targets_from_interfaces(&interfaces, mac)
.expect_err("should error without broadcast-capable interfaces");
assert!(err.to_string().contains("no broadcast-capable interfaces found"));
assert!(
err.to_string()
.contains("no broadcast-capable interfaces found")
);
}
#[test]
+3 -1
View File
@@ -72,7 +72,9 @@ async fn broadcast_wake_targets_real_router_resolve_from_interfaces() -> anyhow:
"all broadcast targets should preserve the requested MAC"
);
assert!(
targets.iter().all(|target| matches!(target.ip, Some(IpAddr::V4(_)))),
targets
.iter()
.all(|target| matches!(target.ip, Some(IpAddr::V4(_)))),
"broadcast targets should be IPv4 broadcast destinations"
);
+22
View File
@@ -0,0 +1,22 @@
[package]
name = "wakey-agent"
version = "0.1.0"
edition = "2024"
[dependencies]
anyhow = "1"
clap = { version = "4", features = ["derive"] }
futures-util = "0.3"
http = "1"
macaddr = { version = "1", features = ["serde", "serde_std"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["fs", "macros", "rt-multi-thread", "time", "signal", "sync"] }
tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] }
toml = "0.8"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
url = "2"
wakey = { path = ".." }
wakey-core = { path = "../wakey-core" }
+75
View File
@@ -0,0 +1,75 @@
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;
pub const DEFAULT_CONFIG_PATH: &str = "/etc/wakey-agent/config.toml";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct AgentConfig {
pub server_url: String,
pub agent_id: String,
pub agent_token: String,
#[serde(default = "default_reconnect_base_ms")]
pub reconnect_base_ms: u64,
#[serde(default = "default_reconnect_max_ms")]
pub reconnect_max_ms: u64,
}
const fn default_reconnect_base_ms() -> u64 {
1_000
}
const fn default_reconnect_max_ms() -> u64 {
30_000
}
pub fn load_config(path: &Path) -> Result<AgentConfig> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("failed to read agent config {}", path.display()))?;
toml::from_str(&content)
.with_context(|| format!("failed to parse agent config {}", path.display()))
}
pub fn save_config(path: &Path, config: &AgentConfig) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("failed to create config dir {}", parent.display()))?;
}
let content = toml::to_string_pretty(config).context("failed to serialize agent config")?;
let tmp = path.with_extension("toml.tmp");
std::fs::write(&tmp, content)
.with_context(|| format!("failed to write temp config {}", tmp.display()))?;
std::fs::rename(&tmp, path).with_context(|| {
format!(
"failed to move temp config {} into {}",
tmp.display(),
path.display()
)
})?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn config_roundtrip() {
let dir = std::env::temp_dir().join(format!("wakey-agent-config-{}", std::process::id()));
let path = dir.join("config.toml");
let config = AgentConfig {
server_url: "https://example.com".into(),
agent_id: "agent-1".into(),
agent_token: "secret".into(),
reconnect_base_ms: 123,
reconnect_max_ms: 456,
};
save_config(&path, &config).expect("save");
let loaded = load_config(&path).expect("load");
assert_eq!(loaded, config);
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_dir_all(&dir);
}
}
+114
View File
@@ -0,0 +1,114 @@
use anyhow::Result;
use tracing::{debug, instrument};
use crate::protocol::{
AgentCommand, CommandResult, DevsRequest, InventoryRequest, LeasesRequest, StatusRequest,
WakeRequest,
};
#[instrument(skip_all)]
pub async fn dispatch_command(command: AgentCommand) -> Result<CommandResult> {
match command {
AgentCommand::Status(req) => dispatch_status(req).await,
AgentCommand::Leases(req) => dispatch_leases(req).await,
AgentCommand::Devs(req) => dispatch_devs(req).await,
AgentCommand::Inventory(req) => dispatch_inventory(req).await,
AgentCommand::Wake(req) => dispatch_wake(req).await,
}
}
async fn dispatch_status(req: StatusRequest) -> Result<CommandResult> {
let query = req.into_device_query();
let status = if query.name.is_some()
&& query.filter.ips.is_empty()
&& query.filter.devs.is_empty()
&& query.filter.nuds.is_empty()
&& query.filter.macs.is_empty()
{
wakey::get_status_for_input(query.name.clone().unwrap_or_default()).await?
} else {
wakey::get_status(query).await?
};
debug!(rows = status.table.len(), "dispatched status command");
Ok(CommandResult::Status(status))
}
async fn dispatch_leases(req: LeasesRequest) -> Result<CommandResult> {
let leases = wakey::get_leases(wakey_core::LeaseQuery {
include_state: req.include_state,
})
.await?;
Ok(CommandResult::Leases(leases))
}
async fn dispatch_devs(req: DevsRequest) -> Result<CommandResult> {
let mut devs = if let Some(name) = &req.dev {
wakey::get_interface_summary(name).await?.into_iter().collect()
} else {
wakey::get_interface_summaries().await?
};
if req.up_only {
devs.retain(|dev| dev.operstate == "up");
}
Ok(CommandResult::Devs(devs))
}
async fn dispatch_inventory(req: InventoryRequest) -> Result<CommandResult> {
let inventory = wakey::inventory(req.into_device_query()).await?;
Ok(CommandResult::Inventory(inventory))
}
async fn dispatch_wake(req: WakeRequest) -> Result<CommandResult> {
validate_wake_request(&req)?;
let result = match (req.query, req.mac, req.ip) {
(Some(query), None, None) => wakey::wake_from_query(query).await?,
(None, Some(mac), ip) => wakey::wake_explicit(mac, ip).await?,
_ => unreachable!("wake request validated before dispatch"),
};
Ok(CommandResult::Wake(result))
}
pub fn validate_wake_request(req: &WakeRequest) -> Result<()> {
let has_query = req.query.is_some();
let has_mac = req.mac.is_some();
let has_ip = req.ip.is_some();
if has_ip && !has_mac {
anyhow::bail!("wake request `ip` requires `mac`");
}
if has_query && (has_mac || has_ip) {
anyhow::bail!("wake query mode and explicit mode are mutually exclusive");
}
if !has_query && !has_mac {
anyhow::bail!("wake request requires either `query` or `mac`");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::protocol::WakeRequest;
#[test]
fn wake_rejects_ip_without_mac() {
let err = validate_wake_request(&WakeRequest {
query: None,
mac: None,
ip: Some("192.168.1.1".parse().expect("ip")),
})
.expect_err("should reject");
assert!(err.to_string().contains("requires `mac`"));
}
#[test]
fn wake_rejects_mixed_mode() {
let err = validate_wake_request(&WakeRequest {
query: Some("pc".into()),
mac: Some("aa:bb:cc:dd:ee:ff".parse().expect("mac")),
ip: None,
})
.expect_err("should reject");
assert!(err.to_string().contains("mutually exclusive"));
}
}
+70
View File
@@ -0,0 +1,70 @@
use anyhow::{Context, Result};
use reqwest::StatusCode;
use serde::{Deserialize, Serialize};
use std::path::Path;
use tracing::info;
use crate::config::{AgentConfig, save_config};
#[derive(Debug, Serialize)]
struct EnrollRequest<'a> {
enroll_token: &'a str,
}
#[derive(Debug, Deserialize)]
struct EnrollResponse {
agent_id: String,
agent_token: String,
server_url: Option<String>,
}
pub async fn enroll(server_url: &str, enroll_token: &str, config_path: &Path) -> Result<AgentConfig> {
let server_url = normalize_server_url(server_url);
let endpoint = format!("{server_url}/api/v1/agents/enroll");
let client = reqwest::Client::new();
let response = client
.post(endpoint)
.json(&EnrollRequest { enroll_token })
.send()
.await
.context("failed to call enrollment endpoint")?;
if response.status() != StatusCode::OK {
let status = response.status();
let body = response
.text()
.await
.unwrap_or_else(|_| "<unable to read enrollment error body>".into());
anyhow::bail!("enrollment failed with {status}: {body}");
}
let payload: EnrollResponse = response
.json()
.await
.context("failed to decode enrollment response")?;
let config = AgentConfig {
server_url: payload.server_url.unwrap_or(server_url),
agent_id: payload.agent_id,
agent_token: payload.agent_token,
reconnect_base_ms: 1_000,
reconnect_max_ms: 30_000,
};
save_config(config_path, &config)?;
info!(config_path = %config_path.display(), "wrote agent config");
Ok(config)
}
pub fn normalize_server_url(server_url: &str) -> String {
server_url.trim_end_matches('/').to_string()
}
#[cfg(test)]
mod tests {
use super::normalize_server_url;
#[test]
fn normalize_server_url_trims_slash() {
assert_eq!(normalize_server_url("https://example.com/"), "https://example.com");
}
}
+72
View File
@@ -0,0 +1,72 @@
mod config;
mod dispatch;
mod enroll;
mod protocol;
mod session;
mod tracing;
use std::path::PathBuf;
use clap::{ArgAction, Args, Parser, Subcommand};
use ::tracing::info;
#[derive(Parser)]
#[command(name = "wakey-agent")]
#[command(version, about = "Outbound control-plane agent for Wakey")]
struct Cli {
/// Increase log verbosity. Use `-v` for debug and `-vv` for trace.
#[arg(short = 'v', long = "verbose", action = ArgAction::Count, global = true)]
verbose: u8,
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
/// Start the agent daemon and maintain the outbound control-plane session.
Serve(ServeArgs),
/// Enroll this router with a control plane and write agent config.
Enroll(EnrollArgs),
}
#[derive(Args)]
struct ServeArgs {
/// Path to the agent config file.
#[arg(long, default_value = config::DEFAULT_CONFIG_PATH)]
config: PathBuf,
}
#[derive(Args)]
struct EnrollArgs {
/// Base HTTPS URL of the control plane.
#[arg(long)]
server_url: String,
/// One-time or short-lived enroll token provided by the control plane.
#[arg(long)]
enroll_token: String,
/// Path to the agent config file to write.
#[arg(long, default_value = config::DEFAULT_CONFIG_PATH)]
config: PathBuf,
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
tracing::init(cli.verbose);
match cli.command {
Command::Serve(args) => {
let config = config::load_config(&args.config)?;
info!(config_path = %args.config.display(), agent_id = %config.agent_id, "starting wakey-agent");
session::run(config).await?;
}
Command::Enroll(args) => {
let config = enroll::enroll(&args.server_url, &args.enroll_token, &args.config).await?;
println!("agent_id={}", config.agent_id);
println!("config={}", args.config.display());
}
}
Ok(())
}
+173
View File
@@ -0,0 +1,173 @@
use macaddr::MacAddr;
use serde::{Deserialize, Serialize};
use std::net::IpAddr;
use wakey_core::{
DeviceFilters, DeviceInventory, DeviceQuery, DhcpLeaseWithState, InterfaceSummary, NeighborEntry,
Status, WakeResult,
};
use wakey_core::parse::mac;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StatusRequest {
pub query: Option<String>,
pub name: Option<String>,
#[serde(default)]
pub ips: Vec<IpAddr>,
#[serde(default)]
pub devs: Vec<String>,
#[serde(default)]
pub nuds: Vec<wakey_core::NeighborState>,
#[serde(default)]
#[serde(with = "mac::vec_mac")]
pub macs: Vec<MacAddr>,
}
impl StatusRequest {
pub fn into_device_query(self) -> DeviceQuery {
if let Some(query) = self.query.as_ref()
&& self.name.is_none()
&& self.ips.is_empty()
&& self.devs.is_empty()
&& self.nuds.is_empty()
&& self.macs.is_empty()
{
return DeviceQuery {
name: Some(query.clone()),
..Default::default()
};
}
DeviceQuery {
name: self.name.or(self.query),
filter: DeviceFilters {
ips: self.ips,
devs: self.devs,
nuds: self.nuds,
macs: self.macs,
},
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LeasesRequest {
#[serde(default)]
pub include_state: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DevsRequest {
pub dev: Option<String>,
#[serde(default)]
pub up_only: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InventoryRequest {
pub query: Option<String>,
pub name: Option<String>,
#[serde(default)]
pub ips: Vec<IpAddr>,
#[serde(default)]
pub devs: Vec<String>,
#[serde(default)]
pub nuds: Vec<wakey_core::NeighborState>,
#[serde(default)]
#[serde(with = "mac::vec_mac")]
pub macs: Vec<MacAddr>,
}
impl InventoryRequest {
pub fn into_device_query(self) -> DeviceQuery {
StatusRequest {
query: self.query,
name: self.name,
ips: self.ips,
devs: self.devs,
nuds: self.nuds,
macs: self.macs,
}
.into_device_query()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WakeRequest {
pub query: Option<String>,
#[serde(default)]
#[serde(with = "mac::option_mac")]
pub mac: Option<MacAddr>,
#[serde(default)]
pub ip: Option<IpAddr>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum AgentCommand {
Status(StatusRequest),
Leases(LeasesRequest),
Devs(DevsRequest),
Inventory(InventoryRequest),
Wake(WakeRequest),
}
#[derive(Debug, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum CommandResult {
Status(Status<NeighborEntry>),
Leases(Vec<DhcpLeaseWithState>),
Devs(Vec<InterfaceSummary>),
Inventory(DeviceInventory),
Wake(WakeResult),
}
#[derive(Debug, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ClientMessage {
Hello {
agent_id: String,
},
Auth {
agent_id: String,
agent_token: String,
},
Heartbeat {
agent_id: String,
},
Result {
request_id: String,
result: CommandResult,
},
Error {
request_id: String,
error: String,
},
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ServerMessage {
Command {
request_id: String,
command: AgentCommand,
},
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn command_serialization_is_stable() {
let msg = ServerMessage::Command {
request_id: "req-1".into(),
command: AgentCommand::Leases(LeasesRequest {
include_state: true,
}),
};
let json = serde_json::to_string(&msg).expect("serialize");
assert!(json.contains("\"request_id\":\"req-1\""));
assert!(json.contains("\"kind\":\"leases\""));
}
}
+156
View File
@@ -0,0 +1,156 @@
use anyhow::{Context, Result};
use futures_util::{SinkExt, StreamExt};
use tokio::time::{Duration, MissedTickBehavior, interval, sleep};
use tokio_tungstenite::{connect_async, tungstenite::Message};
use tracing::{debug, error, info, warn};
use crate::config::AgentConfig;
use crate::dispatch::dispatch_command;
use crate::protocol::{ClientMessage, ServerMessage};
pub async fn run(config: AgentConfig) -> Result<()> {
let mut backoff = config.reconnect_base_ms.max(100);
loop {
match run_once(&config).await {
Ok(()) => {
backoff = config.reconnect_base_ms.max(100);
}
Err(err) => {
warn!(error = %err, backoff_ms = backoff, "agent session ended; reconnecting");
sleep(Duration::from_millis(backoff)).await;
backoff = (backoff.saturating_mul(2)).min(config.reconnect_max_ms.max(backoff));
}
}
}
}
async fn run_once(config: &AgentConfig) -> Result<()> {
let ws_url = websocket_url(&config.server_url)?;
info!(%ws_url, agent_id = %config.agent_id, "connecting agent websocket");
let (stream, _) = connect_async(ws_url.as_str())
.await
.context("failed to connect websocket")?;
let (mut sink, mut source) = stream.split();
send_json(&mut sink, &ClientMessage::Hello {
agent_id: config.agent_id.clone(),
})
.await?;
send_json(
&mut sink,
&ClientMessage::Auth {
agent_id: config.agent_id.clone(),
agent_token: config.agent_token.clone(),
},
)
.await?;
let mut heartbeat = interval(Duration::from_secs(30));
heartbeat.set_missed_tick_behavior(MissedTickBehavior::Skip);
loop {
tokio::select! {
_ = heartbeat.tick() => {
send_json(&mut sink, &ClientMessage::Heartbeat {
agent_id: config.agent_id.clone(),
}).await?;
}
maybe_msg = source.next() => {
let msg = match maybe_msg {
Some(msg) => msg.context("websocket frame failed")?,
None => anyhow::bail!("websocket closed by server"),
};
match msg {
Message::Text(text) => {
let message: ServerMessage = serde_json::from_str(&text)
.context("failed to decode server message")?;
handle_server_message(&mut sink, message).await?;
}
Message::Ping(payload) => {
sink.send(Message::Pong(payload)).await.context("failed to send pong")?;
}
Message::Pong(_) => {}
Message::Close(frame) => {
anyhow::bail!("websocket closed: {:?}", frame);
}
Message::Binary(_) => {
debug!("ignoring unexpected binary websocket frame");
}
Message::Frame(_) => {}
}
}
}
}
}
async fn handle_server_message<S>(sink: &mut S, message: ServerMessage) -> Result<()>
where
S: SinkExt<Message> + Unpin,
<S as futures_util::Sink<Message>>::Error: std::error::Error + Send + Sync + 'static,
{
match message {
ServerMessage::Command {
request_id,
command,
} => match dispatch_command(command).await {
Ok(result) => {
send_json(sink, &ClientMessage::Result { request_id, result }).await?;
}
Err(err) => {
error!(request_id, error = %err, "command dispatch failed");
send_json(
sink,
&ClientMessage::Error {
request_id,
error: err.to_string(),
},
)
.await?;
}
},
}
Ok(())
}
async fn send_json<S>(sink: &mut S, message: &ClientMessage) -> Result<()>
where
S: SinkExt<Message> + Unpin,
<S as futures_util::Sink<Message>>::Error: std::error::Error + Send + Sync + 'static,
{
let payload = serde_json::to_string(message).context("failed to serialize websocket message")?;
sink.send(Message::Text(payload))
.await
.context("failed to send websocket message")?;
Ok(())
}
pub fn websocket_url(server_url: &str) -> Result<url::Url> {
let base = url::Url::parse(server_url).context("invalid server_url")?;
let scheme = match base.scheme() {
"http" => "ws",
"https" => "wss",
"ws" => "ws",
"wss" => "wss",
other => anyhow::bail!("unsupported server_url scheme `{other}`"),
};
let mut url = base;
url.set_scheme(scheme)
.map_err(|_| anyhow::anyhow!("failed to convert server_url scheme"))?;
url.set_path("/api/v1/agent/ws");
url.set_query(None);
url.set_fragment(None);
Ok(url)
}
#[cfg(test)]
mod tests {
use super::websocket_url;
#[test]
fn websocket_url_uses_expected_path() {
let url = websocket_url("https://example.com/control").expect("url");
assert_eq!(url.as_str(), "wss://example.com/api/v1/agent/ws");
}
}
+20
View File
@@ -0,0 +1,20 @@
use tracing_subscriber::{EnvFilter, fmt, layer::SubscriberExt, util::SubscriberInitExt};
pub fn init(verbose: u8) {
let filter = EnvFilter::try_from_default_env()
.or_else(|_| EnvFilter::try_new(default_filter(verbose)))
.expect("static tracing filter should parse");
tracing_subscriber::registry()
.with(filter)
.with(fmt::layer())
.init();
}
fn default_filter(verbose: u8) -> &'static str {
match verbose {
0 => "wakey_agent=info,wakey=info",
1 => "wakey_agent=debug,wakey=debug",
_ => "wakey_agent=trace,wakey=debug",
}
}