Compare commits
3
Commits
No files matched your search
@@ -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
File diff suppressed because it is too large.
Load diff
+4
-2
@@ -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"
|
||||
|
||||
@@ -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
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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" }
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
|
||||
|
||||
@@ -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" }
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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\""));
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user