18 Commits
49 changed files with 2216 additions and 946 deletions

No files matched your search

Generated
+2 -1
View File
@@ -1179,7 +1179,7 @@ checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]]
name = "wakey"
version = "0.1.0"
version = "0.1.3"
dependencies = [
"axum",
"axum-extra",
@@ -1187,6 +1187,7 @@ dependencies = [
"futures",
"macaddr",
"serde",
"serde_html_form",
"serde_json",
"serde_with",
"strum",
+7 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "wakey"
version = "0.1.0"
version = "0.1.3"
edition = "2024"
publish = ["gitea"]
@@ -11,13 +11,18 @@ color-eyre = "0.6.5"
futures = "0.3.31"
macaddr = { version = "1.0.1", features = ["serde", "serde_std"] }
serde = { version = "1.0.219", features = ["derive"] }
serde_html_form = "0.2.7"
serde_json = "1.0.143"
serde_with = { version = "3.14.0", features = ["json"] }
strum = { version = "0.27.2", features = ["derive", "strum_macros"] }
thiserror = "2.0.16"
tokio = { version = "1.47.1", features = ["fs", "process", "rt-multi-thread"] }
tokio = { version = "1.47.1", features = ["fs", "process", "rt-multi-thread", "io-util"] }
urlencoding = "2.1.3"
[profile.release]
opt-level = "z"
strip = true
[features]
default = ["very-smart-parsing"]
very-smart-parsing = [] # this is the a-bit-redundant parse thing that copilot made
+32 -24
View File
@@ -4,42 +4,44 @@ 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
1. Start your Gitea runner on this Windows PC (if not already running):
```powershell
./scripts/act_runner.ps1
```
```powershell
./scripts/act_runner.ps1
```
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, it prints the exact register command. Run it once, check labels include `windows:host,self-hosted`, then rerun the script.
1. Tag and push to trigger CI
2. Publish, tag, and push (recommended):
```powershell
git tag v0.1.0
git push origin v0.1.0
```
```powershell
./scripts/publish.ps1 -Tag
```
1. Install on the router
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`.
Use the release asset URL from your Gitea Release page:
3. Install on the router
```sh
wget -O- https://<gitea>/<owner>/<repo>/releases/download/v0.1.0/wakey-rootfs-v0.1.0-armv7-unknown-linux-musleabihf.tgz | tar -xz -C /
chmod +x /etc/init.d/wakey && /etc/init.d/wakey enable && /etc/init.d/wakey restart
```
Use the updater on the router to fetch the latest automatically:
Alternatively, use the updater on the router to fetch the latest automatically:
```sh
WAKEY_HOST=git.ldlda.com WAKEY_OWNER=lda WAKEY_REPO=wakey sh /etc/ldlda_help/update_wakey.sh
```
```sh
WAKEY_HOST=git.ldlda.com WAKEY_OWNER=lda WAKEY_REPO=wakey sh /etc/ldlda_help/update_wakey.sh
```
Alternatively, use the release asset URL from your Gitea Release page:
```sh
wget -O- https://<gitea>/<owner>/<repo>/releases/download/v0.1.0/wakey-rootfs-v0.1.0-armv7-unknown-linux-musleabihf.tgz | tar -xz -C /
chmod +x /etc/init.d/wakey && /etc/init.d/wakey enable && /etc/init.d/wakey restart
```
## Scripts overview
- `act_runner.ps1` — Start/seed the local Gitea runner. Use `-Attach` to see logs, `-ForceConfigure` to register non-interactively.
- `cross_build.ps1` — Simple wrapper for `cargo build` per target (default: armv7-unknown-linux-musleabihf).
- `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.
- `package_rootfs.ps1` — Produces `dist/wakey-rootfs-<version>-<target>.tgz` with `/root/.bin/wakey` and `/etc/init.d/*`.
- `package.ps1` — Dev bundle with binaries + init scripts (not a rootfs layout).
- `publish.ps1` — Optional version bump + build + tag (and `cargo publish` only if you pass `-Publish`).
## CI (Gitea)
@@ -52,8 +54,8 @@ WAKEY_HOST=git.ldlda.com WAKEY_OWNER=lda WAKEY_REPO=wakey sh /etc/ldlda_help/upd
## Local build/install (manual path)
```powershell
# Build (requires cross installed)
./scripts/cross_build.ps1 -Release -Targets armv7-unknown-linux-musleabihf
# Build
cargo build --release --target armv7-unknown-linux-musleabihf
# Package rootfs
./scripts/package_rootfs.ps1 -Version v0.1.0 -Target armv7-unknown-linux-musleabihf
@@ -68,4 +70,10 @@ tar -xz -f /tmp/wakey.tgz -C /
chmod +x /etc/init.d/wakey && /etc/init.d/wakey enable && /etc/init.d/wakey restart
```
Or you can use the script:
```powershell
./scripts/dev_push.ps1 -Pass <password>
```
That’s it. Keep the flow: start runner → tag push → install.
+11 -2
View File
@@ -38,7 +38,10 @@ if (-not (Test-Path $configFile)) {
}
if ($ForceConfigure) {
Write-Host "Config not found. Initializing non-interactively (ForceConfigure)..."
$configParent = Split-Path -Parent $configFile
Push-Location $configParent
& $RunnerPath register --no-interactive --config $configFile --instance $ServerUrl --token $Token --labels $Labels
Pop-Location
}
else {
Write-Host "Config not found. Run this manually once (note labels embed host executor on Windows):"
@@ -49,9 +52,15 @@ if (-not (Test-Path $configFile)) {
if ($Attach) {
Write-Host "Starting act_runner (attached)... Ctrl+C to stop"
$configParent = Split-Path -Parent $configFile
Push-Location $configParent
& $RunnerPath daemon --config $configFile
Pop-Location
}
else {
Start-Process -FilePath $RunnerPath -ArgumentList @("daemon", "--config", $configFile) -WindowStyle Hidden
Write-Host "act_runner started."
$configParent = Split-Path -Parent $configFile
Push-Location $configParent
$proc = Start-Process -FilePath $RunnerPath -ArgumentList @("daemon", "--config", $configFile) -WindowStyle Hidden -PassThru
Pop-Location
Write-Host "act_runner started (PID=$($proc.Id))."
}
-17
View File
@@ -1,17 +0,0 @@
# Build release binaries using cross for Linux targets.
# Requires: cross installed (cargo install cross)
param(
[string[]]$Targets = @("armv7-unknown-linux-musleabihf"),
[switch]$Release
)
$ErrorActionPreference = 'Stop'
$mode = if ($Release) { "--release" } else { "" }
foreach ($t in $Targets) {
Write-Host "Building for $t..."
cross build $mode --target $t
}
Write-Host "Done."
+122
View File
@@ -0,0 +1,122 @@
[System.Diagnostics.CodeAnalysis.SuppressMessage('PSAvoidDefaultValueSwitchParameter', 'Default true is intentional for fast dev loop')]
param(
[string]$Pass,
[string]$HostName = "192.168.100.1",
[string]$User = "root",
[string]$RemotePath = "/root/.bin/wakey",
[string]$Target = "armv7-unknown-linux-musleabihf",
[string]$BinName = "wakey",
[string]$HostKey,
[switch]$Restart = $true,
[switch]$Quiet = $true
)
$ErrorActionPreference = 'Stop'
try { $PSStyle.OutputRendering = 'Host' } catch {}
function Invoke-Ext {
param($Exe, $Arguments, $Label)
$displayArgs = $Arguments.Clone()
for ($i = 0; $i -lt $displayArgs.Count; $i++) {
if ($displayArgs[$i] -eq '-pw' -and ($i + 1) -lt $displayArgs.Count) {
$displayArgs[$i + 1] = '****'
}
}
Write-Host ("[{0}] {1} {2}" -f $Label, $Exe, ($displayArgs -join ' ')) -ForegroundColor Cyan
$out = & $Exe @Arguments 2>&1
if ($LASTEXITCODE -ne 0) {
Write-Error ("{0} failed ({1}):`n{2}" -f $Label, $LASTEXITCODE, ($out -join "`n"))
throw ("{0} failed ({1})" -f $Label, $LASTEXITCODE)
}
return $out
}
function Get-DeployScript {
param($DeployPreferred, $DeployTmp, $RemoteTmp, $RemotePath, $RestartFlag)
return @"
DEPLOY=$DeployPreferred;
if [ ! -x "`$DEPLOY" ] && [ -f $DeployTmp ]; then
sed -i "s/\r$//" $DeployTmp 2>/dev/null || true
chmod +x $DeployTmp
DEPLOY=$DeployTmp
fi
sh "`$DEPLOY" $RemoteTmp $RemotePath $RestartFlag
"@ # -replace "`r", "" -replace "`r`n", "`n"
}
function Invoke-Scp {
param($Local, $Dest, $Pass, $HostKey, [switch]$Quiet)
if ($pscp = Get-Command pscp.exe -ErrorAction SilentlyContinue) {
$arguments = @('-scp')
if ($Quiet) { $arguments += '-q' }
if ($HostKey) { $arguments += @('-batch', '-hostkey', $HostKey) }
if ($Pass) { $arguments += @('-pw', $Pass) }
$arguments += @($Local, $Dest)
Invoke-Ext -Exe $pscp.Path -Arguments $arguments -Label 'scp'
}
else {
$arguments = @('-O')
if ($Quiet) { $arguments += '-q' }
$arguments += @($Local, $Dest)
Invoke-Ext -Exe 'scp' -Arguments $arguments -Label 'scp'
}
}
function Invoke-Ssh {
param($Cmd, $User, $Remote, $Pass, [switch]$Quiet)
$Cmd = $Cmd -replace "`r`n", "`n" -replace "`r", ""
if ($plink = Get-Command plink.exe -ErrorAction SilentlyContinue) {
$arguments = @('-batch', '-ssh')
if ($Pass) { $arguments += @('-pw', $Pass) }
$arguments += "$User@$Remote", $Cmd
Invoke-Ext -Exe $plink.Path -Arguments $arguments -Label 'ssh'
}
else {
$arguments = @()
if ($Quiet) { $arguments += '-q' }
$arguments += "$User@$Remote", $Cmd
Invoke-Ext -Exe 'ssh' -Arguments $arguments -Label 'ssh'
}
}
#---- Main Flow ----
$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)" }
$localBin = Join-Path $repoRoot "target/$Target/release/$BinName"
if (-not (Test-Path $localBin)) { throw "binary not found: $localBin" }
$remoteTmp = "$RemotePath.tmp"
$destTmp = "$User@${HostName}:$remoteTmp"
$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
Invoke-Scp -Local $localBin -Dest $destTmp -Pass $Pass -HostKey $HostKey -Quiet:$Quiet
# Push deploy helper if exists
if (Test-Path $localDeploy) {
Invoke-Scp -Local $localDeploy -Dest "$User@${HostName}:$deployTmp" -Pass $Pass -HostKey $HostKey -Quiet:$Quiet
}
# Build and run remote deploy command
$restartFlag = $(if ($Restart) { '1' } else { '0' })
$script = Get-DeployScript $deployPreferred $deployTmp $remoteTmp $RemotePath $restartFlag
$remoteCmd = "sh -lc '$($script -replace "`r",'')'"
if ($Quiet) { $remoteCmd += " >/dev/null 2>&1" }
Invoke-Ssh -Cmd $remoteCmd -User $User -Remote $HostName -Pass $Pass -Quiet:$Quiet
Write-Host "done ✔" -ForegroundColor Green
}
finally {
Pop-Location
}
+18 -11
View File
@@ -8,17 +8,24 @@
# Short-Description: Pre-flight Tailscale update
START=50 # Run before Tailscale (typically START~80)
USE_PROCD=1
start_service() {
procd_open_instance
procd_set_param command /etc/ldlda_help/update_tailscale.sh
procd_set_param respawn 0 0 0
procd_set_param stdout 1
procd_set_param stderr 1
procd_close_instance
}
start() {
LOGFILE=/var/log/update_tailscale.log
MAX_RETRIES=10
RETRY_COUNT=0
stop_service() {
: # one-shot
# shellcheck disable=SC2016
nohup sh -c '
while [ "$RETRY_COUNT" -lt "$MAX_RETRIES" ]; do
if fn; then
echo "[update_tailscale] Success at $(date)" >>"$LOGFILE"
exit 0
fi
RETRY_COUNT=$((RETRY_COUNT + 1))
echo "[update_tailscale] Retry $RETRY_COUNT at $(date)" >>"$LOGFILE"
sleep 5
done
echo "[update_tailscale] Too many retries. Exiting at $(date)" >>"$LOGFILE"
exit 1
' >>"$LOGFILE" 2>&1 &
}
+19 -13
View File
@@ -1,17 +1,23 @@
#!/bin/sh /etc/rc.common
START=66
START=66 # Run after the resolvconf swap
USE_PROCD=1
start() {
LOGFILE=/var/log/update_wakey.log
MAX_RETRIES=10
RETRY_COUNT=0
start_service() {
procd_open_instance
procd_set_param command /etc/ldlda_help/update_wakey.sh
procd_set_param respawn 0 0 0
procd_set_param stdout 1
procd_set_param stderr 1
procd_close_instance
}
stop_service() {
: # one-shot
# shellcheck disable=SC2016
nohup sh -c '
while [ "$RETRY_COUNT" -lt "$MAX_RETRIES" ]; do
if /etc/ldlda_help/update_wakey.sh; then
echo "[update_wakey] Success at $(date)" >>"$LOGFILE"
exit 0
fi
RETRY_COUNT=$((RETRY_COUNT + 1))
echo "[update_wakey] Retry $RETRY_COUNT at $(date)" >>"$LOGFILE"
sleep 5
done
echo "[update_wakey] Too many retries. Exiting at $(date)" >>"$LOGFILE"
exit 1
' >>"$LOGFILE" 2>&1 &
}
+3 -3
View File
@@ -12,14 +12,14 @@ fi
[ -n "$pids" ] || exit 0
# Send TERM first
kill -TERM $pids 2>/dev/null || true
kill -TERM "$pids" 2>/dev/null || true
# Optional: hard kill if still alive after a short grace
sleep 0.2
usleep 250000 # uhhh sleep is stupid
remain=""
for p in $pids; do
kill -0 "$p" 2>/dev/null && remain="$remain $p"
done
[ -z "$remain" ] || kill -KILL $remain 2>/dev/null || true
[ -z "$remain" ] || kill -KILL "$remain" 2>/dev/null || true
exit 0
+12 -3
View File
@@ -2,7 +2,16 @@
set -e
# 0th step from lda is to move the pre assed resolv.conf to the real resolv.conf
[ -f /tmp/resolv.conf ] && cp -af /tmp/resolv.conf /etc/resolv.conf || ([ -f /rom/etc/resolv.conf ] && cp -af /rom/etc/resolv.conf /etc/resolv.conf)
if [ -f /tmp/resolv.conf ]; then
cp -af /tmp/resolv.conf /etc/resolv.conf
elif [ -f /rom/etc/resolv.conf ]; then
cp -af /rom/etc/resolv.conf /etc/resolv.conf
fi
if [ -x "$(which tailscale)" ]; then
tailscale update
exit 0
fi
# 1. Fetch the latest version number from Tailscale's site
LATEST=$(curl -s https://pkgs.tailscale.com/stable/ | grep -Eo 'tailscale_[0-9\.]+_arm.tgz' | head -n1)
@@ -16,10 +25,10 @@ URL="https://pkgs.tailscale.com/stable/${LATEST}"
cd /var/tmp
[ -f "$LATEST" ] && {
if [ -f "$LATEST" ]; then
echo "file exists"
rm "$LATEST"
}
fi
echo "Downloading $LATEST..."
wget -q "$URL"
+40 -9
View File
@@ -15,11 +15,18 @@
set -eu
# reboot == new file.
FLAG=/var/wakey_updated.flag
ARCH=${WAKEY_ARCH:-armv7-unknown-linux-musleabihf}
TMPFILE="/tmp/wakey-rootfs.$$.$ARCH.tgz"
TMPDIR="/var/tmp"
TMPFILE="$TMPDIR/wakey-rootfs.$$.$ARCH.tgz"
STAGING="$TMPDIR/wakey-rootfs.$$.$ARCH"
log() { echo "[update_wakey] $*"; }
fail() { echo "[update_wakey] ERROR: $*" >&2; exit 1; }
fail() {
echo "[update_wakey] ERROR: $*" >&2
exit 1
}
http_get() {
# http_get <url>
@@ -45,7 +52,6 @@ fetch() {
if command -v uclient-fetch >/dev/null 2>&1; then
uclient-fetch -O "$2" "$1" || return 1
elif command -v wget >/dev/null 2>&1; then
# shellcheck disable=SC2086
wget ${WAKEY_INSECURE:+--no-check-certificate} -O "$2" "$1" || return 1
elif command -v curl >/dev/null 2>&1; then
if [ -n "${WAKEY_INSECURE:-}" ]; then
@@ -64,13 +70,14 @@ latest_asset_url() {
API="https://$HOST/api/v1/repos/$OWNER/$REPO/releases/latest"
RESP=$(http_get "$API") || return 1
# Extract the first browser_download_url ending with our ARCH .tgz (BusyBox-friendly)
echo "$RESP" \
| grep -o '"browser_download_url"[[:space:]]*:[[:space:]]*"[^"]*'"$ARCH"'\.tgz"' \
| head -n1 \
| cut -d '"' -f 4
echo "$RESP" |
grep -o '"browser_download_url"[[:space:]]*:[[:space:]]*"[^"]*'"$ARCH"'\.tgz"' |
head -n1 |
cut -d '"' -f 4
}
main() {
[ -f "$FLAG" ] && return
URL="${WAKEY_TGZ_URL:-}"
if [ -z "$URL" ]; then
HOST=${WAKEY_HOST:-git.ldlda.com}
@@ -91,14 +98,38 @@ main() {
fi
log "installing"
tar -xz -f "$TMPFILE" -C / || fail "extract failed"
mkdir -p "$TMPDIR" "$STAGING"
tar -xz -f "$TMPFILE" -C "$STAGING" || fail "extract failed"
# Ensure execute bits on staged files we know should be executable
for f in \
"$STAGING/etc/init.d/"* \
"$STAGING/etc/ldlda_help/"*.sh \
"$STAGING/root/.bin/wakey" \
"$STAGING/root/.bin/kill_wakey.sh" \
"$STAGING/root/.bin/remote_deploy_wakey.sh"; do
[ -e "$f" ] && chmod +x "$f" 2>/dev/null || true
done
# Normalize line endings for shell scripts (avoid CRLF issues on OpenWrt)
for f in \
"$STAGING/etc/init.d/"* \
"$STAGING/etc/ldlda_help/"*.sh \
"$STAGING/root/.bin/"*.sh; do
[ -f "$f" ] && sed -i 's/\r$//' "$f" 2>/dev/null || true
done
# Copy staged tree into /
tar -C "$STAGING" -cf - . | tar -C / -xpf - || fail "install copy failed"
rm -f "$TMPFILE"
rm -rf "$STAGING"
if [ -f /etc/init.d/wakey ]; then
chmod +x /etc/init.d/wakey || true
/etc/init.d/wakey enable || true
/etc/init.d/wakey restart || /etc/init.d/wakey start || true
fi
touch "$FLAG"
log "done"
}
+65
View File
@@ -0,0 +1,65 @@
"thanks chatgpt"
from pathlib import Path
root = Path(__file__).parent.parent
static = root / "static" # change "assets" to your folder
out_rs = root / "src" / "assets.rs"
def sanitize(name: str) -> str:
# Valid Rust identifiers: letters, digits, underscores; no starting digit
out = []
for c in name:
if c.isalnum() or c == "_":
out.append(c)
else:
out.append("_")
s = "".join(out)
if s and s[0].isdigit():
s = "_" + s
return s
def indent(text: str, n: int) -> str:
pad = " " * n
return "\n".join(pad + line if line.strip() else line for line in text.splitlines())
class RsAssetFile:
def __init__(self, path: Path):
self.path = path
def __str__(self):
const_name = sanitize(self.path.name).upper()
return (
f"pub const {const_name}: &str = "
f'include_str!("{self.path.relative_to(out_rs.parent, walk_up=True).as_posix()}");'
)
# specify walk_up to have .. in yo path
class RsAssetModule:
def __init__(self, folder: Path, body_only: bool = False):
self.folder = folder
self.full = not body_only
def __str__(self):
files = [str(RsAssetFile(s)) for s in self.folder.iterdir() if s.is_file()]
subs = [str(RsAssetModule(s)) for s in self.folder.iterdir() if s.is_dir()]
body = "\n".join(files + subs)
return (
f"pub mod {sanitize(self.folder.name).lower()} {{" * self.full
+ f"\n{indent(body, 4*self.full)}\n"
+ self.full * "}"
)
# generate
rs_code = "// generated with ./scripts/map_static.py\n" + str(
RsAssetModule(static, True)
)
out_rs.write_text(rs_code, encoding="utf-8")
print(f"written to {out_rs}")
-53
View File
@@ -1,53 +0,0 @@
# Package a release tarball with binaries and init scripts.
# Usage: ./scripts/package.ps1 -Version 0.1.0 -OutDir dist
param(
[Parameter(Mandatory = $true)][string]$Version,
[string]$OutDir = "dist",
[string[]]$Targets = @("armv7-unknown-linux-musleabihf")
)
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $MyInvocation.MyCommand.Path | Split-Path -Parent
$dist = Join-Path $root $OutDir
New-Item -ItemType Directory -Force -Path $dist | Out-Null
$pkgName = "wakey-$Version"
$pkgDir = Join-Path $dist $pkgName
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue $pkgDir
New-Item -ItemType Directory -Force -Path $pkgDir | Out-Null
# Copy LICENSE/README if present
if (Test-Path "$root/README.md") { Copy-Item "$root/README.md" $pkgDir }
if (Test-Path "$root/LICENSE") { Copy-Item "$root/LICENSE" $pkgDir }
# Binaries per target
foreach ($t in $Targets) {
$binName = if ($t -like "*-windows-*") { "wakey.exe" } else { "wakey" }
$src = Join-Path $root "target/$t/release/$binName"
if (-not (Test-Path $src)) { throw "Missing binary: $src" }
$tDir = Join-Path $pkgDir "bin/$t"
New-Item -ItemType Directory -Force -Path $tDir | Out-Null
Copy-Item $src $tDir
}
# Init scripts
$initDir = Join-Path $pkgDir "init"
New-Item -ItemType Directory -Force -Path $initDir | Out-Null
Copy-Item "$root/scripts/init" $initDir -Recurse
# Systemd service (for Linux hosts)
Copy-Item "$root/scripts/systemd" $initDir -Recurse -ErrorAction SilentlyContinue
# Tarball
Push-Location $dist
if (Get-Command tar -ErrorAction SilentlyContinue) {
tar -czf "$pkgName.tgz" "$pkgName"
}
else {
# Fallback to zip on Windows
Compress-Archive -Path "$pkgName" -DestinationPath "$pkgName.zip" -Force
}
Pop-Location
Write-Host "Packaged: $dist/$pkgName.(tgz|zip)"
+38 -5
View File
@@ -6,7 +6,8 @@
param(
[Parameter(Mandatory = $true)][string]$Version,
[Parameter(Mandatory = $true)][string]$Target,
[string]$OutDir = "dist"
[string]$OutDir = "dist",
[switch]$NoBin
)
$ErrorActionPreference = 'Stop'
@@ -17,7 +18,6 @@ New-Item -ItemType Directory -Force -Path $dist | Out-Null
$binName = if ($Target -like "*-windows-*") { "wakey.exe" } else { "wakey" }
$binSrc = Join-Path $root "target/$Target/release/$binName"
if (-not (Test-Path $binSrc)) { throw "Missing binary: $binSrc (build it first)" }
$staging = Join-Path $dist ("rootfs-" + [System.Guid]::NewGuid().ToString("N"))
New-Item -ItemType Directory -Force -Path $staging | Out-Null
@@ -28,12 +28,37 @@ $etcDir = Join-Path $staging "etc/init.d"
New-Item -ItemType Directory -Force -Path $rootDir | Out-Null
New-Item -ItemType Directory -Force -Path $etcDir | Out-Null
Copy-Item $binSrc (Join-Path $rootDir "wakey") -Force
if (-not $NoBin) {
if (Test-Path $binSrc) {
Copy-Item $binSrc (Join-Path $rootDir "wakey") -Force
}
else {
throw "Missing binary: $binSrc (build it first or pass -NoBin)"
}
}
# Normalize kill script line endings and copy
$killSrc = Join-Path $root 'scripts/kill_wakey.sh'
if (Test-Path $killSrc) {
$killContent = Get-Content -Raw -LiteralPath $killSrc
$killContent = $killContent -replace "`r`n", "`n"
Set-Content -NoNewline -LiteralPath (Join-Path $rootDir "kill_wakey.sh") -Value $killContent -Encoding UTF8
}
# Normalize remote deploy script and copy (optional helper)
$deploySrc = Join-Path $root 'scripts/remote_deploy_wakey.sh'
if (Test-Path $deploySrc) {
$deployContent = Get-Content -Raw -LiteralPath $deploySrc
$deployContent = $deployContent -replace "`r`n", "`n"
Set-Content -NoNewline -LiteralPath (Join-Path $rootDir "remote_deploy_wakey.sh") -Value $deployContent -Encoding UTF8
}
# Copy all OpenWrt init scripts present in repo
Get-ChildItem (Join-Path $root 'scripts/init/openwrt') -File | ForEach-Object {
$dest = Join-Path $etcDir $_.Name
Copy-Item $_.FullName $dest -Force
$content = Get-Content -Raw -LiteralPath $_.FullName
# normalize to LF line endings
$content = $content -replace "`r`n", "`n"
Set-Content -NoNewline -LiteralPath $dest -Value $content -Encoding UTF8
}
# Copy helper scripts intended for /etc/ldlda_help
@@ -42,7 +67,10 @@ if (Test-Path $helpSrc) {
$etcHelpDir = Join-Path $staging 'etc/ldlda_help'
New-Item -ItemType Directory -Force -Path $etcHelpDir | Out-Null
Get-ChildItem $helpSrc -File | ForEach-Object {
Copy-Item $_.FullName (Join-Path $etcHelpDir $_.Name) -Force
$d = Join-Path $etcHelpDir $_.Name
$c = Get-Content -Raw -LiteralPath $_.FullName
$c = $c -replace "`r`n", "`n"
Set-Content -NoNewline -LiteralPath $d -Value $c -Encoding UTF8
}
}
@@ -64,4 +92,9 @@ Remove-Item -Recurse -Force $staging
Write-Host "Rootfs package: " (Join-Path $dist $pkgName)
Write-Host "On router: wget -O- <URL/$pkgName> | tar -xz -C /"
Write-Host "Then: chmod +x /etc/init.d/wakey && /etc/init.d/wakey enable && /etc/init.d/wakey start"
if ($NoBin) {
Write-Host "Note: -NoBin used, binary not included. Only scripts/configs were packaged." -ForegroundColor Yellow
}
Write-Host "Helper scripts: /etc/ldlda_help/* (e.g., update_wakey.sh, update_tailscale.sh). Mark executable if needed: chmod +x /etc/ldlda_help/*.sh"
Write-Host "Kill helper: /root/.bin/kill_wakey.sh (make it executable: chmod +x /root/.bin/kill_wakey.sh)"
<# if (Test-Path $deploySrc) { #> Write-Host "Deploy helper: /root/.bin/remote_deploy_wakey.sh (chmod +x /root/.bin/remote_deploy_wakey.sh)" # }
+29 -3
View File
@@ -9,20 +9,33 @@
param(
[string]$Version,
[switch]$Tag,
[switch]$Publish
[switch]$ForceTag,
[switch]$Publish,
[string]$Registry
)
$ErrorActionPreference = 'Stop'
# If no version provided, deduce from Cargo.toml
if (-not $Version) {
$cargoToml = Get-Content Cargo.toml -Raw
if ($cargoToml -match 'version\s*=\s*"([^"]+)"') {
$Version = $matches[1]
Write-Host "Deduced version from Cargo.toml: $Version"
}
else {
Write-Error "Could not deduce version from Cargo.toml. Please specify -Version."
exit 1
}
}
if ($Version) {
$pattern = '(?m)^version\s*=\s*"[^"]+"'
$replacement = ('version = "{0}"' -f $Version)
(Get-Content Cargo.toml -Raw) -replace $pattern, $replacement | Set-Content Cargo.toml -Encoding UTF8
(Get-Content Cargo.toml -Raw) -replace $pattern, $replacement | Set-Content Cargo.toml -Encoding UTF8 -NoNewline
}
# Ensure build ok
cargo fmt
cargo build --release
# Publish crate (only if -Publish and registry auth is available)
@@ -32,6 +45,8 @@ if ($Publish) {
$pubArgs = @('publish')
if ($Registry) { $pubArgs += @('--registry', $Registry) }
cargo @pubArgs
Write-Host ("published. ran cargo {0}" -f ($pubArgs -join " "))
}
catch {
Write-Warning ("cargo publish failed: {0}" -f $_)
@@ -39,6 +54,17 @@ if ($Publish) {
}
if ($Tag -and $Version) {
# If you git push a tag, this will trigger the act_runner and the release workflow on your self-hosted runner (see .gitea/workflows/release.yml)
$actRunnerScript = Join-Path $PSScriptRoot 'act_runner.ps1'
if (Test-Path $actRunnerScript) {
Write-Host "Ensuring act_runner is running..."
& $actRunnerScript
}
else {
Write-Warning "act_runner.ps1 not found at $actRunnerScript. Skipping runner start."
}
git tag -f "v$Version"
git push -f origin "v$Version"
Write-Host "Pushed tag: $Version."
}
-14
View File
@@ -1,14 +0,0 @@
# One-shot: build, package, and print artifact location (no cross required).
# Usage: ./scripts/release.ps1 -Version 0.1.0 -Targets armv7-unknown-linux-musleabihf
param(
[Parameter(Mandatory = $true)][string]$Version,
[string[]]$Targets = @("armv7-unknown-linux-musleabihf")
)
$ErrorActionPreference = 'Stop'
foreach ($t in $Targets) {
rustup target add $t
cargo build --release --target $t
}
./scripts/package.ps1 -Version $Version -Targets $Targets
+41
View File
@@ -0,0 +1,41 @@
#!/bin/sh
# Usage: remote_deploy_wakey.sh <bin_tmp> <dest_path> [restart_flag]
# restart_flag: 1 (default) to stop/start, 0 to only replace file.
set -e
BIN_TMP="$1"
DEST="$2"
RESTART="${3:-1}"
INIT="/etc/init.d/wakey"
KILL="/root/.bin/kill_wakey.sh"
if [ -z "$BIN_TMP" ] || [ -z "$DEST" ]; then
echo "usage: $0 <bin_tmp> <dest_path> [restart_flag]" >&2
exit 2
fi
[ -f "$BIN_TMP" ] || {
echo "tmp binary not found: $BIN_TMP" >&2
exit 1
}
chmod +x "$BIN_TMP" || true
if [ "$RESTART" = "1" ]; then
if [ -x "$INIT" ]; then
"$INIT" stop || true
elif [ -x "$KILL" ]; then
sh "$KILL" || true
fi
fi
mv -f "$BIN_TMP" "$DEST"
if [ "$RESTART" = "1" ]; then
if [ -x "$INIT" ]; then
"$INIT" start || "$INIT" restart || true
else
nohup "$DEST" >/dev/null 2>&1 &
fi
fi
exit 0
-15
View File
@@ -1,15 +0,0 @@
[Unit]
Description=Wakey WOL+ARP service
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/local/bin/wakey
Restart=on-failure
RestartSec=2
User=wakey
Group=wakey
[Install]
WantedBy=multi-user.target
+3 -19
View File
@@ -10,14 +10,14 @@
use std::{net::IpAddr, str::FromStr};
use impls::ser_opm;
use macaddr::MacAddr;
use serde::{Deserialize, Serialize, Serializer, de};
use serde_with::skip_serializing_none;
use strum::{Display, EnumString};
use crate::arpparse::error::IPNeighParseError;
mod error;
mod r#impl; // custom (de)serialization impls
mod impls; // custom (de)serialization impls
/// ip neigh has some cool shit.
///
@@ -39,22 +39,6 @@ pub struct IpNeighLine {
pub state: NUDState,
}
/// serialize an [`Option<MacAddr>`]
pub fn ser_opm<S: Serializer>(bro: &Option<MacAddr>, ser: S) -> Result<S::Ok, S::Error> {
Option::<String>::serialize(&bro.as_ref().map(ToString::to_string), ser)
}
/// deserialize an [`Option<MacAddr>`]
pub fn _des_opm<'de, D>(des: D) -> Result<Option<MacAddr>, D::Error>
where
D: serde::Deserializer<'de>,
{
Option::<&str>::deserialize(des)?
.map(str::parse)
.transpose()
.map_err(de::Error::custom)
}
// NUDState custom Deserialize now lives in arpparse/impl.rs; use serde_with OneOrMany for Vec
#[derive(Debug, PartialEq, Eq, EnumString, Display, Clone, Copy, Hash, serde::Serialize)]
@@ -114,7 +98,7 @@ impl NUDState {
}
}
/// dumb UI label
pub fn dumber_state(&self) -> &'static str {
pub fn _dumber_state(&self) -> &'static str {
match self {
NUDState::Permanent | NUDState::Reachable => "online",
NUDState::Stale => "maybe online",
@@ -1,3 +1,5 @@
//! r#impl AHHHHHH
use serde::{Deserialize, Deserializer, de};
use super::NUDState;
@@ -12,3 +14,6 @@ impl<'de> Deserialize<'de> for NUDState {
s.parse().map_err(de::Error::custom)
}
}
#[allow(unused_imports)]
pub use crate::utils::parse::mac::{des_opm, ser_opm};
+12
View File
@@ -0,0 +1,12 @@
// generated with ./scripts/map_static.py
pub const HOME_2_HTML: &str = include_str!("../static/home_2.html");
pub mod home_2 {
pub const DOM_JS: &str = include_str!("../static/home_2/dom.js");
pub const LEASES_JS: &str = include_str!("../static/home_2/leases.js");
pub const MAIN_JS: &str = include_str!("../static/home_2/main.js");
pub const STATUS_JS: &str = include_str!("../static/home_2/status.js");
pub const STYLES_CSS: &str = include_str!("../static/home_2/styles.css");
pub const UTILS_JS: &str = include_str!("../static/home_2/utils.js");
pub const WAKE_JS: &str = include_str!("../static/home_2/wake.js");
}
+87
View File
@@ -0,0 +1,87 @@
/// MAC->name cache location (ephemeral)
const MAC_NAME_CACHE: &str = "/tmp/wakey_mac_names.json";
/// Load MAC->name cache from disk
async fn load_mac_name_cache() -> io::Result<std::collections::BTreeMap<String, String>> {
match tokio::fs::read_to_string(MAC_NAME_CACHE).await {
Ok(s) => serde_json::from_str(&s).map_err(io::Error::other),
Err(e) if e.kind() == ErrorKind::NotFound => Ok(Default::default()),
Err(e) => Err(e),
}
}
/// Save MAC->name cache to disk
async fn save_mac_name_cache(map: &std::collections::BTreeMap<String, String>) -> io::Result<()> {
let s = serde_json::to_string(map).map_err(io::Error::other)?;
let _ = tokio::fs::write(MAC_NAME_CACHE, s).await;
Ok(())
}
/// Read all leases, filling names from MAC->name cache if missing
pub async fn read_dhcp_leases_with_names() -> io::Result<Vec<DhcpLeaseLine>> {
let leases = read_dhcp_leases().await?;
let mut cache = load_mac_name_cache().await.unwrap_or_default();
let mut changed = false;
let mut leases_with_names = Vec::with_capacity(leases.len());
for mut l in leases {
let mac_s = l.mac.to_string();
if let Some(ref name) = l.name {
// if no name in cache file or it changed
if cache.get(&mac_s).map(|v| v != name).unwrap_or(true) {
cache.insert(mac_s, name.clone());
changed = true;
}
} else if let Some(prev) = cache.get(&mac_s) {
l.name = Some(prev.clone());
}
leases_with_names.push(l);
}
if changed {
let _ = save_mac_name_cache(&cache).await;
}
Ok(leases_with_names)
}
use macaddr::MacAddr;
use serde::Serializer;
use std::io::{self, ErrorKind};
use std::net::IpAddr;
/// A single line from /tmp/dhcp.leases
#[derive(Debug, Clone, serde::Serialize)]
pub struct DhcpLeaseLine {
/// Epoch seconds when the lease expires
pub expires_epoch: u64,
pub ip: IpAddr,
#[serde(serialize_with = "ser_mac")]
pub mac: MacAddr,
pub name: Option<String>,
}
fn ser_mac<S: Serializer>(m: &MacAddr, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&m.to_string())
}
/// Parse one line of /tmp/dhcp.leases
pub fn parse_dhcp_lease_line(line: &str) -> Option<DhcpLeaseLine> {
let mut c = line.split_whitespace();
let expires_epoch: u64 = c.next()?.parse().ok()?;
let mac = c.next()?.parse().ok()?;
let ip = c.next()?.parse().ok()?;
let name = c.next().filter(|c| *c != "*").map(str::to_string);
// ignore any remaining columns (e.g., client-id)
Some(DhcpLeaseLine {
expires_epoch,
ip,
mac,
name,
})
}
/// Read all leases from /tmp/dhcp.leases (simple and fast)
pub async fn read_dhcp_leases() -> io::Result<Vec<DhcpLeaseLine>> {
match tokio::fs::read_to_string("/tmp/dhcp.leases").await {
Ok(file) => Ok(file.lines().filter_map(parse_dhcp_lease_line).collect()),
Err(e) if e.kind() == ErrorKind::NotFound => Ok(Vec::new()),
Err(e) => Err(e),
}
}
+6 -93
View File
@@ -1,115 +1,29 @@
use axum::{
Router,
extract::Query,
http::StatusCode,
response::{Html, IntoResponse},
routing::{get, post},
};
use tokio::net::TcpListener;
mod arpparse;
pub mod assets;
mod dhcpparse;
mod route;
mod r#static;
mod utils;
use crate::{
route::DeviceQuery,
utils::{ping::ping_ip, query::get_macs_2_1, wake::wake},
};
use r#static as st;
use std::io;
const MACHINE_NAME: &str = "lda.lan";
async fn home() -> Html<String> {
Html(format!(
r#"
<html>
<body>
<p><a href="/home_2">Alternate UI</a></p>
<p>the machine is {}! <a href="/status">Status</a></p>
<form method="POST" action="/wake">
<button type="submit">Wake LDA</button>
</form>
</body>
</html>
"#,
if ping_ip((MACHINE_NAME, 22)).await {
"on"
} else {
"off"
} // match get_ips(MACHINE_NAME).await {
// Ok(ips) => {
// // let addrs: Vec<SocketAddr> = ips.into_iter().map(|ip|(ip, 22).into()).collect();
// }
// Err(_) => "off",
// }
))
}
async fn wake_handler(
Query(DeviceQuery { name, .. }): Query<DeviceQuery>,
) -> axum::response::Result<impl IntoResponse> {
match wake(name.as_deref().unwrap_or(MACHINE_NAME)).await {
Ok(0) => Err((StatusCode::NOT_FOUND, "No packets sent!").into()),
Ok(x) => Ok((
StatusCode::ACCEPTED,
format!("{x} packet{s} sent!", s = if x > 1 { "s" } else { "" }),
)),
_ => Err((StatusCode::GATEWAY_TIMEOUT, "Wake failed").into()),
}
}
pub async fn status_build(machine_name: &str) -> String {
let formatted_macs = match get_macs_2_1(machine_name).await {
Ok(table) => {
let the: String = table
.iter()
.map(|(ip, mac, state)| {
let mac_str = // if let Some(mac) = mac {
mac.to_string()
// } else {
// "None".into()
// }
;
format!(
"<tr><td>{ip}</td><td>{mac_str}</td><td>{state}</td></tr>",
state = state.dumber_state()
)
})
.collect();
format!(
r#"<p>info of {machine_name}:</p>
<table>
<tr><th>IP</th><th>MAC</th><th>State</th></tr>
{the}
</table>"#
)
}
Err(e) => format!("<p>errors getting table for {machine_name}: {e}</p>"),
};
format!(
r#"
<html>
<body>
{formatted_macs}
</body>
</html>
"#,
)
}
#[cfg(target_os = "linux")]
#[tokio::main]
async fn entry() -> io::Result<()> {
use crate::route::{api_status, devs_router, get_status_2, home_2, home_2_route};
use crate::route::{api_router, home_2, home_2_route, wake_handler};
let app = Router::new()
.route("/home", get(home))
// .route("/home", get(home))
.route("/", get(home_2))
.merge(home_2_route())
.route("/wake", post(wake_handler))
.route("/status", get(get_status_2))
.merge(api_status())
.route("/api/devs", get(devs_router));
// .route("/status", get(get_status_2))
.nest("/api", api_router());
let port = TcpListener::bind("0.0.0.0:12012").await?;
axum::serve(port, app.into_make_service()).await?;
@@ -119,7 +33,6 @@ async fn entry() -> io::Result<()> {
#[cfg(not(target_os = "linux"))]
fn main() -> color_eyre::Result<()> {
color_eyre::install()?;
use std::net::ToSocketAddrs;
// use crate::arpparse::NUDState;
// println!("{}", NUDState::Reachable.to_string().to_lowercase());
println!("{:?}", "svuhuvshdv:331".to_socket_addrs());
+78 -231
View File
@@ -1,252 +1,99 @@
use std::collections::HashSet;
use std::net::IpAddr;
pub mod api;
pub mod devs;
pub mod dhcp;
pub mod status;
pub mod wake;
pub use crate::route::api::{DeviceQuery, api_router};
use crate::{
assets::{self},
utils::{ping::_ping_ip, wake::wake},
};
use axum::{
Json, Router,
extract::Path,
Router,
http::{StatusCode, header},
response::{Html, IntoResponse, Redirect},
response::{Html, IntoResponse},
routing::get,
};
use axum_extra::extract::Query;
use macaddr::MacAddr;
use serde::ser::Serializer;
use serde_with::skip_serializing_none;
use crate::{
MACHINE_NAME,
arpparse::{self, NUDState},
st, status_build,
utils::{
de_many,
query::{
dev::{self, has_dev},
get_macs,
},
},
};
use crate::utils::route::serve_js;
use crate::{MACHINE_NAME, utils::_status_build};
pub async fn home_2() -> Html<&'static str> {
Html(st::HOME_2)
}
async fn home_2_css() -> impl IntoResponse {
(
[
(header::CONTENT_TYPE, "text/css; charset=utf-8"),
(header::CACHE_CONTROL, "public, max-age=300"),
],
st::HOME_2_CSS,
)
}
async fn home_2_js() -> impl IntoResponse {
(
[
(header::CONTENT_TYPE, "application/javascript"),
(header::CACHE_CONTROL, "public, max-age=300"),
],
st::HOME_2_JS,
)
}
/// all the pages related to home_2
pub fn home_2_route() -> Router {
Router::new()
.route("/home_2", get(home_2))
.route("/home_2.css", get(home_2_css))
.route("/home_2.js", get(home_2_js)) //js
}
#[derive(Debug, Default, Clone, Hash, serde::Deserialize)]
pub struct DeviceQuery {
pub name: Option<String>,
// Accept single or many; ignore blanks
#[serde(default, deserialize_with = "de_many::vec_from_strs")]
ip: Vec<IpAddr>,
#[serde(default, deserialize_with = "de_many::vec_from_strs")]
mac: Vec<MacAddr>,
/// optional interface filter (e.g., br-lan)
#[serde(default, deserialize_with = "de_many::vec_from_strs")]
pub dev: Vec<String>,
/// optional NUD state filter; accepts any case (e.g., reachable, REACHABLE)
#[serde(default, deserialize_with = "de_many::vec_from_strs")]
pub nud: Vec<NUDState>,
}
#[derive(Debug, Default, Clone, Hash, serde::Deserialize)]
pub struct NamePath {
name: String,
}
#[skip_serializing_none]
#[derive(Debug, Default, serde::Serialize)]
pub struct Status {
name: Option<String>,
table: Vec<arpparse::IpNeighLine>,
filters: Filters,
}
#[derive(Debug, Default, serde::Serialize)]
pub struct Filters {
#[serde(skip_serializing_if = "Vec::is_empty")]
ip: Vec<IpAddr>,
#[serde(skip_serializing_if = "Vec::is_empty")]
dev: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
nud: Vec<NUDState>,
#[serde(
skip_serializing_if = "Vec::is_empty",
serialize_with = "serialize_macs"
)]
mac: Vec<MacAddr>,
}
fn serialize_macs<S>(macs: &[MacAddr], serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let strings: Vec<String> = macs.iter().map(|m| m.to_string()).collect();
serde::Serialize::serialize(&strings, serializer)
}
#[skip_serializing_none]
#[derive(Debug, serde::Serialize)]
pub struct StatusError {
name: Option<String>,
error: String,
}
pub async fn get_status_json(
// p: Option<Path<NamePath>>,
Query(DeviceQuery {
name,
ip,
dev,
nud,
mac,
..
}): Query<DeviceQuery>,
) -> impl IntoResponse {
fn to_opts<T: Clone>(slice: &[T]) -> Vec<Option<T>> {
if slice.is_empty() {
vec![None]
} else {
slice.iter().cloned().map(Some).collect()
}
}
/* let name = /* p
.map(|Path(n)| n.name)
.or */(name)
// .unwrap_or_else(|| MACHINE_NAME.to_owned())
; */
// ip/dev/nud already parsed; assemble options
let ips_opt = if ip.is_empty() {
None
} else {
Some(ip.clone())
};
let dev_opts: Vec<Option<String>> = to_opts(&dev);
let nud_opts: Vec<Option<NUDState>> = to_opts(&nud);
let filters = Filters {
ip,
dev,
nud,
mac: mac.clone(),
};
// Run combinations of dev/nud and merge results
let mut tasks = Vec::new();
for d in &dev_opts {
for n in &nud_opts {
tasks.push(get_macs(
name.as_deref(),
ips_opt.as_deref(),
d.as_deref(),
*n,
));
}
}
match futures::future::try_join_all(tasks)
.await
.map(|v| v.into_iter().flatten().collect::<Vec<_>>())
{
Ok(mut table) => {
// Optional MAC post-filtering if provided
if !mac.is_empty() {
let wanted: HashSet<MacAddr> = mac.into_iter().collect();
table.retain(|row| row.mac.map(|m| wanted.contains(&m)).unwrap_or(false));
}
// let canonical = format!("/api/status?name={name}");
(
StatusCode::OK,
// [(header::LINK, format!("<{canonical}>; rel=\"canonical\""))],
Json(Status {
name,
table,
filters,
}),
)
.into_response()
}
Err(error) => (
StatusCode::BAD_GATEWAY,
Json(StatusError {
name,
error: error.to_string(),
}),
)
.into_response(), // holy clutch. Couldve been disasterous
pub async fn wake_handler(
Query(DeviceQuery { name, .. }): Query<DeviceQuery>,
) -> axum::response::Result<impl IntoResponse> {
match wake(name.as_deref().unwrap_or(MACHINE_NAME)).await {
Ok(0) => Err((StatusCode::NOT_FOUND, "No packets sent!").into()),
Ok(x) => Ok((
StatusCode::ACCEPTED,
format!("{x} packet{s} sent!", s = if x > 1 { "s" } else { "" }),
)),
_ => Err((StatusCode::GATEWAY_TIMEOUT, "Wake failed").into()),
}
}
pub async fn status_redirect(Path(NamePath { name }): Path<NamePath>) -> Redirect {
Redirect::permanent(&format!(
"/api/status?name={name}",
name = urlencoding::encode(&name) // just for
))
}
pub fn api_status() -> Router {
Router::new()
.route("/api/status/{name}", get(status_redirect)) // api/status should be like the entire ip neigh br lan like idk like
.route("/api/status", get(get_status_json))
.route("/api/smart/{q}", get(status_smart_redirect))
}
pub async fn get_status_2(q: Query<DeviceQuery>) -> Html<String> {
pub async fn _get_status_2(q: Query<DeviceQuery>) -> Html<String> {
let name = match q {
Query(DeviceQuery {
name: Some(name), ..
}) => name,
_ => MACHINE_NAME.to_string(),
};
Html(status_build(&name).await)
Html(_status_build(&name).await)
}
// Smart redirect: accept IP, MAC, dev, or NUD state and redirect to /api/status accordingly
pub async fn status_smart_redirect(Path(q): Path<String>) -> Redirect {
let s = q.trim();
// 1) IP
if let Ok(ip) = s.parse::<IpAddr>() {
return Redirect::to(&format!("/api/status?ip={ip}"));
}
// 2) MAC
if let Ok(mac) = s.parse::<MacAddr>() {
return Redirect::to(&format!("/api/status?mac={mac}"));
}
// 3) NUD state (reachable, stale, ...)
if let Ok(state) = s.parse::<NUDState>() {
return Redirect::to(&format!("/api/status?nud={state}"));
}
// 4) Known device? prefer dev first
if has_dev(s) {
return Redirect::to(&format!("/api/status?dev={}", urlencoding::encode(s)));
}
// 5) Try DNS: if it resolves, treat as name
if tokio::net::lookup_host((s, 0)).await.is_ok() {
return Redirect::to(&format!("/api/status?name={}", urlencoding::encode(s)));
}
// Default: name last
Redirect::to(&format!("/api/status?name={}", urlencoding::encode(s)))
pub async fn _home() -> Html<String> {
Html(format!(
r#"
<html>
<body>
<p><a href="/home_2">Alternate UI</a></p>
<p>the machine is {}! <a href="/status">Status</a></p>
<form method="POST" action="/wake">
<button type="submit">Wake LDA</button>
</form>
</body>
</html>
"#,
if _ping_ip((MACHINE_NAME, 22)).await {
"on"
} else {
"off"
} // match get_ips(MACHINE_NAME).await {
// Ok(ips) => {
// // let addrs: Vec<SocketAddr> = ips.into_iter().map(|ip|(ip, 22).into()).collect();
// }
// Err(_) => "off",
// }
))
}
pub async fn devs_router() -> Json<Vec<String>> {
dev::devs_sorted().into()
pub async fn home_2() -> Html<&'static str> {
Html(assets::HOME_2_HTML)
}
pub fn home_2_route() -> Router {
use assets::*;
Router::new()
.route("/home_2", get(|| async { Html(HOME_2_HTML) }))
.route("/home_2/", get(|| async { Html(HOME_2_HTML) }))
.route("/home_2.html", get(|| async { Html(HOME_2_HTML) }))
.route(
"/home_2/styles.css",
get(|| async {
(
[
(header::CONTENT_TYPE, "text/css; charset=utf-8"),
(header::CACHE_CONTROL, "public, max-age=300"),
],
home_2::STYLES_CSS,
)
}),
)
.route("/home_2/main.js", get(|| serve_js(home_2::MAIN_JS)))
.route("/home_2/leases.js", get(|| serve_js(home_2::LEASES_JS)))
.route("/home_2/status.js", get(|| serve_js(home_2::STATUS_JS)))
.route("/home_2/utils.js", get(|| serve_js(home_2::UTILS_JS)))
.route("/home_2/wake.js", get(|| serve_js(home_2::WAKE_JS)))
.route("/home_2/dom.js", get(|| serve_js(home_2::DOM_JS)))
}
+45
View File
@@ -0,0 +1,45 @@
use axum::Json;
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::routing::post;
use axum::{Router, extract::Path, response::Redirect, routing::get};
use crate::utils::route;
// Smart redirect: accept IP, MAC, dev, or NUD state and redirect to /api/status accordingly
pub async fn status_smart_redirect(
Path(q): Path<String>,
) -> axum::response::Result<Redirect, impl IntoResponse> {
match serde_html_form::to_string(route::status_smart_redirect(q).await) {
Ok(e) => Ok(Redirect::to(&format!("/api/status?{e}"))),
Err(e) => Err((
StatusCode::BAD_GATEWAY,
Json(StatusError {
error: e.to_string(),
..Default::default()
}),
)),
}
}
pub use crate::route::devs::*;
pub use crate::route::dhcp::*;
pub use crate::route::status::*;
pub use crate::route::wake::*;
pub async fn status_redirect(Path(NamePath { name }): Path<NamePath>) -> Redirect {
Redirect::permanent(&format!(
"/api/status?name={name}",
name = urlencoding::encode(&name) // just for
))
}
pub fn api_router() -> Router {
Router::new()
.route("/status/{name}", get(status_redirect))
.route("/status", get(get_status_json))
.route("/dhcp_leases", get(get_dhcp_leases))
.route("/smart/{q}", get(status_smart_redirect))
.route("/devs", get(devs_router))
.route("/wake", post(wake_multi))
}
+7
View File
@@ -0,0 +1,7 @@
use crate::utils::query::dev;
use axum::Json;
pub async fn devs_router() -> Json<Vec<String>> {
dev::devs_sorted().into()
}
// Device listing endpoints
+34
View File
@@ -0,0 +1,34 @@
use crate::{dhcpparse, route::api::StatusError, utils::parse::boolish_str};
use axum::{Json, extract::Query, http::StatusCode, response::IntoResponse};
// DHCP lease endpoints
#[derive(Debug, Default, Clone, serde::Deserialize)]
pub struct DhcpLeasesQueryRaw {
include_state: Option<String>,
}
pub async fn get_dhcp_leases(Query(raw): Query<DhcpLeasesQueryRaw>) -> impl IntoResponse {
let include_state = raw
.include_state
.as_deref()
.map(boolish_str)
.unwrap_or(false);
match dhcpparse::read_dhcp_leases_with_names().await {
Ok(leases_with_names) => {
if !include_state {
return (StatusCode::OK, Json(leases_with_names)).into_response();
}
let out = crate::utils::query::enrich_leases_with_nud_state(leases_with_names).await;
(StatusCode::OK, Json(out)).into_response()
}
Err(e) => (
StatusCode::BAD_GATEWAY,
Json(StatusError {
error: e.to_string(),
..Default::default()
}),
)
.into_response(),
}
}
+136
View File
@@ -0,0 +1,136 @@
use crate::{
arpparse::NUDState,
utils::parse::{de_many, serialize_macs},
};
use axum::{Json, http::StatusCode, response::IntoResponse};
use axum_extra::extract::Query;
use macaddr::MacAddr;
use serde::{Deserialize, Serialize};
use serde_with::skip_serializing_none;
use std::collections::HashSet;
use std::net::IpAddr;
#[derive(Debug, Default, Clone, Hash, Deserialize, Serialize)]
pub struct DeviceQuery {
pub name: Option<String>,
#[serde(default, deserialize_with = "de_many::vec_from_strs")]
pub ip: Vec<IpAddr>,
#[serde(
default,
deserialize_with = "de_many::vec_from_strs",
serialize_with = "serialize_macs"
)]
pub mac: Vec<MacAddr>,
#[serde(default, deserialize_with = "de_many::vec_from_strs")]
pub dev: Vec<String>,
#[serde(default, deserialize_with = "de_many::vec_from_strs")]
pub nud: Vec<NUDState>,
}
#[derive(Debug, Default, Clone, Hash, Deserialize)]
pub struct NamePath {
pub name: String,
}
#[skip_serializing_none]
#[derive(Debug, Default, Serialize)]
pub struct Status<T> {
pub name: Option<String>,
pub table: Vec<T>,
pub filters: Filters,
}
#[derive(Debug, Default, Serialize)]
pub struct Filters {
#[serde(skip_serializing_if = "Vec::is_empty")]
pub ip: Vec<IpAddr>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub dev: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub nud: Vec<NUDState>,
#[serde(
skip_serializing_if = "Vec::is_empty",
serialize_with = "serialize_macs"
)]
pub mac: Vec<MacAddr>,
}
#[skip_serializing_none]
#[derive(Debug, Serialize, Default)]
pub struct StatusError {
pub name: Option<String>,
pub error: String,
}
pub async fn get_status_json(
Query(DeviceQuery {
name,
ip,
dev,
nud,
mac,
..
}): Query<DeviceQuery>,
) -> impl IntoResponse {
fn to_opts<T: Clone>(slice: &[T]) -> Vec<Option<T>> {
if slice.is_empty() {
vec![None]
} else {
slice.iter().cloned().map(Some).collect()
}
}
let ips_opt = if ip.is_empty() {
None
} else {
Some(ip.clone())
};
let dev_opts: Vec<Option<String>> = to_opts(&dev);
let nud_opts: Vec<Option<NUDState>> = to_opts(&nud);
let filters = Filters {
ip,
dev,
nud,
mac: mac.clone(),
};
let mut tasks = Vec::new();
for d in &dev_opts {
for n in &nud_opts {
tasks.push(crate::utils::query::get_macs(
name.as_deref(),
ips_opt.as_deref(),
d.as_deref(),
*n,
));
}
}
// why try join all?
match futures::future::try_join_all(tasks)
.await
.map(|v| v.into_iter().flatten().collect::<Vec<_>>())
{
Ok(mut table) => {
if !mac.is_empty() {
let wanted: HashSet<MacAddr> = mac.into_iter().collect();
table.retain(|row| row.mac.map(|m| wanted.contains(&m)).unwrap_or(false));
}
(
StatusCode::OK,
Json(Status {
name,
table,
filters,
}),
)
.into_response()
}
Err(error) => (
StatusCode::BAD_GATEWAY,
Json(StatusError {
name,
error: error.to_string(),
}),
)
.into_response(),
}
}
// Status endpoints
+119
View File
@@ -0,0 +1,119 @@
//! impls are at [`utils::wake::impl`](crate::utils::wake::r#impl) for some reason
use std::io;
use std::net::IpAddr;
/* use crate::arpparse::IpNeighLine;
use crate::route::api::Status; */
use crate::utils::parse::mac::{des_opm, ser_opm};
use crate::utils::wake::wake_one;
use axum::{extract::Json, http::StatusCode, response::IntoResponse};
use futures::TryFutureExt;
use macaddr::MacAddr;
use serde::{Deserialize, Serialize};
use serde_with::skip_serializing_none;
use tokio::net::UdpSocket;
#[skip_serializing_none]
#[derive(Debug, Default, Serialize, Clone)]
pub struct WakeResult {
pub success: bool,
pub result: Option<Vec<WakeTargetResult>>,
pub error: Option<String>,
}
#[skip_serializing_none]
#[derive(Debug, Serialize, Clone, Copy)]
pub struct WakeTargetResult {
pub ip: Option<IpAddr>,
#[serde(serialize_with = "ser_opm")]
pub mac: Option<MacAddr>,
pub status: WakeTargetStatus,
}
#[derive(Debug, Serialize, Clone, Copy, Hash)]
#[serde(rename_all = "snake_case")]
pub enum WakeTargetStatus {
Succeed,
/// not a real address...
NonexistentAddress,
WrongSize,
/// input is not enough
Incomplete,
}
#[skip_serializing_none]
#[derive(Debug, Deserialize, Clone, Copy)]
pub struct WakeTarget {
#[serde(default)]
pub ip: Option<IpAddr>,
#[serde(default, deserialize_with = "des_opm")]
pub mac: Option<MacAddr>,
}
pub async fn wake_multi(Json(req): Json<Vec<WakeTarget>>) -> impl IntoResponse {
match wake_multi_split(req).await {
Ok(results) => (
StatusCode::OK,
Json(WakeResult {
success: true,
result: Some(results),
..Default::default()
}),
),
Err(error) => {
let error = Some(format!("Error: {}", error));
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(WakeResult {
success: false,
error,
..Default::default()
}),
)
}
}
}
/// this is so bad
pub async fn wake_multi_split(
targets: impl IntoIterator<Item = WakeTarget>,
) -> io::Result<Vec<WakeTargetResult>> {
let sock = UdpSocket::bind("[::]:0")
.or_else(|_| UdpSocket::bind(":0"))
.await?;
sock.set_broadcast(true)?;
Ok(
futures::future::join_all(targets.into_iter().map(async |c| {
if c.is_incomplete() {
c.to_incomplete()
} else {
wake_one(
&sock,
c.try_into().expect("complete struct failed to try_into"),
)
.await
.into()
}
}))
.await,
)
}
/* #[derive(Debug, Serialize)]
pub struct WakeStatusLine {
#[serde(flatten)]
pub status: IpNeighLine, // most powerful find of the century
pub wake_status: WakeTargetStatus,
}
pub type WakeStatus = Status<WakeStatusLine>; */
// /// return status BUT plus a indicator of i sent a wake.
// pub async fn wake_status(
// Query(DeviceQuery {
// name,
// ip,
// dev,
// nud,
// mac,
// ..
// }): Query<DeviceQuery>,
// ) -> impl IntoResponse {
// }
-3
View File
@@ -1,3 +0,0 @@
pub const HOME_2: &str = include_str!("../static/home_2.html");
pub const HOME_2_CSS: &str = include_str!("../static/assets/home_2.css");
pub const HOME_2_JS: &str = include_str!("../static/assets/home_2.js");
+41 -50
View File
@@ -8,66 +8,57 @@
/// is it time to lookup host lda.lan for this...
// pub static LDA_MACS_2: LazyLock<[MacAddr; 2]> = LazyLock::new(|| LDA_MACS.map(MacAddr::from));
pub mod wake;
use std::net::IpAddr;
use crate::utils::query::_get_macs_2_1;
pub mod cmd;
pub mod error;
/// generic so you can do "123.45.67.89:22" or "lda.lan:22" as an input
// this is so bad
pub mod ping;
/// this is because i like [`IpAddr`] more than [`SocketAddr`](std::net::SocketAddr)
pub async fn get_ips(machine_name: &str) -> error::Result<Vec<IpAddr>> {
let it = tokio::net::lookup_host((machine_name, 0))
.await
.map_err(|e| error::Error::DnsResolve {
name: machine_name.to_string(),
source: e,
})?;
Ok(it.map(|c| c.ip()).collect())
}
pub mod cmd;
pub mod query;
pub mod route;
// no custom ip deserializer needed when using axum_extra::extract::Query
// but we add a generic one to ignore blanks and accept OneOrMany
pub mod de_many {
use serde::Deserialize;
use serde::de;
pub(crate) mod parse;
#[derive(Deserialize)]
#[serde(untagged)]
enum OneOrMany<T> {
One(T),
Many(Vec<T>),
pub async fn _status_build(machine_name: &str) -> String {
let formatted_macs = match _get_macs_2_1(machine_name).await {
Ok(table) => {
let the: String = table
.iter()
.map(|(ip, mac, state)| {
let mac_str = // if let Some(mac) = mac {
mac.to_string()
// } else {
// "None".into()
// }
;
format!(
"<tr><td>{ip}</td><td>{mac_str}</td><td>{state}</td></tr>",
state = state._dumber_state()
)
})
.collect();
format!(
r#"<p>info of {machine_name}:</p>
<table>
<tr><th>IP</th><th>MAC</th><th>State</th></tr>
{the}
</table>"#
)
}
Err(e) => format!("<p>errors getting table for {machine_name}: {e}</p>"),
};
pub fn vec_from_strs<'de, D, T>(des: D) -> Result<Vec<T>, D::Error>
where
D: serde::Deserializer<'de>,
T: std::str::FromStr,
T::Err: std::fmt::Display,
{
let raw: OneOrMany<String> = OneOrMany::<String>::deserialize(des)?;
let mut out = Vec::new();
match raw {
OneOrMany::One(s) => {
let t = s.trim();
if !t.is_empty() {
out.push(t.parse().map_err(de::Error::custom)?);
}
}
OneOrMany::Many(vs) => {
for s in vs {
let t = s.trim();
if t.is_empty() {
continue;
}
out.push(t.parse().map_err(de::Error::custom)?);
}
}
}
Ok(out)
}
format!(
r#"
<html>
<body>
{formatted_macs}
</body>
</html>
"#,
)
}
+190
View File
@@ -0,0 +1,190 @@
/// Parses Chrome-style numeric IPv4 forms: hex (0x...), decimal, octal.
pub fn parse_numeric_ipv4(s: &str) -> Option<std::net::IpAddr> {
let s = s.trim();
// hex
if let Some(hex) = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X"))
&& hex.chars().all(|c| c.is_ascii_hexdigit())
&& let Ok(n) = u32::from_str_radix(hex, 16)
{
return Some(std::net::IpAddr::V4(std::net::Ipv4Addr::from(n)));
}
// decimal
if s.chars().all(|c| c.is_ascii_digit())
&& let Ok(n) = s.parse::<u32>()
{
return Some(std::net::IpAddr::V4(std::net::Ipv4Addr::from(n)));
}
// octal (leading 0, all octal digits)
if s.len() > 1
&& s.as_bytes()[0] == b'0'
&& s.chars().all(|c| matches!(c, '0'..='7'))
&& let Ok(n) = u32::from_str_radix(s, 8)
{
return Some(std::net::IpAddr::V4(std::net::Ipv4Addr::from(n)));
}
None
}
/// Extracts the host portion from a URL-like string, for smart input parsing.
pub fn extract_host(input: &str) -> &str {
let mut s = input.trim();
// Strip scheme (e.g., http://, https://, ssh://) or network-path reference (//host)
if let Some(idx) = s.find("://") {
s = &s[idx + 3..];
} else if let Some(rest) = s.strip_prefix("//") {
s = rest;
}
// Strip potential userinfo (user@host)
if let Some((_, host)) = s.rsplit_once('@') {
s = host;
}
// If bracketed IPv6 like [::1]:8080/path -> extract inside brackets
if let Some(host) = s.strip_prefix('[') {
if let Some(end) = host.find(']') {
s = &host[..end];
}
} else {
// Trim path suffix if any
if let Some(pos) = s.find('/') {
s = &s[..pos];
}
// Drop trailing :port if present and numeric, but only if there's exactly one ':'
if let Some((host, port)) = s.rsplit_once(':')
&& s.matches(':').count() == 1
&& port.chars().all(|c| c.is_ascii_digit())
{
s = host;
}
}
s.trim()
}
/// key for yes: "1" | "true" | "yes" | "on" | "y"
///
/// frfr
pub fn _de_boolish<'de, D>(des: D) -> Result<bool, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::Deserialize;
#[derive(Deserialize)]
#[serde(untagged)]
enum Boolish {
B(bool),
I(u8),
S(String),
}
Ok(match Boolish::deserialize(des)? {
Boolish::B(b) => b,
Boolish::I(i) => i != 0,
Boolish::S(s) => {
let t = s.trim().to_ascii_lowercase();
if t.is_empty() {
true // presence implies true
} else {
matches!(t.as_str(), "1" | "true" | "yes" | "on" | "y")
}
}
})
}
/// Parse a tolerant boolean value from a string.
/// Accepts: "1", "true", "yes", "on", "y" as true; "0", "false", "no", "off", "n" as false.
/// Empty string means true (presence-only query flag).
pub fn boolish_str(s: &str) -> bool {
let t = s.trim().to_ascii_lowercase();
if t.is_empty() {
return true;
}
matches!(t.as_str(), "1" | "true" | "yes" | "on" | "y")
|| (!matches!(t.as_str(), "0" | "false" | "no" | "off" | "n")
&& t.parse::<u64>().map(|n| n != 0).unwrap_or(false))
}
pub mod de_many {
use serde::Deserialize;
use serde::de;
#[derive(Deserialize)]
#[serde(untagged)]
enum OneOrMany<T> {
One(T),
Many(Vec<T>),
}
pub fn vec_from_strs<'de, D, T>(des: D) -> Result<Vec<T>, D::Error>
where
D: serde::Deserializer<'de>,
T: std::str::FromStr,
T::Err: std::fmt::Display,
{
let raw: OneOrMany<String> = OneOrMany::<String>::deserialize(des)?;
let mut out = Vec::new();
match raw {
OneOrMany::One(s) => {
let t = s.trim();
if !t.is_empty() {
out.push(t.parse().map_err(de::Error::custom)?);
}
}
OneOrMany::Many(vs) => {
for s in vs {
let t = s.trim();
if t.is_empty() {
continue;
}
out.push(t.parse().map_err(de::Error::custom)?);
}
}
}
Ok(out)
}
}
pub mod mac {
use macaddr::MacAddr;
use serde::{self, Deserialize, Deserializer, de::Error as DeError};
use serde::{Serialize, Serializer, de};
pub fn serialize_macs<S>(macs: &[MacAddr], serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let strings: Vec<String> = macs.iter().map(|m| m.to_string()).collect();
serde::Serialize::serialize(&strings, serializer)
}
/// Serialize a MacAddr as a string
pub fn _serialize_mac<S>(mac: &MacAddr, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&mac.to_string())
}
/// Deserialize a MacAddr from a string
pub fn _deserialize_mac<'de, D>(deserializer: D) -> Result<MacAddr, D::Error>
where
D: Deserializer<'de>,
{
let s = <String as serde::Deserialize>::deserialize(deserializer)?;
s.parse::<MacAddr>().map_err(DeError::custom)
}
/// serialize an [`Option<MacAddr>`]
pub fn ser_opm<S: Serializer>(bro: &Option<MacAddr>, ser: S) -> Result<S::Ok, S::Error> {
Option::<String>::serialize(&bro.as_ref().map(ToString::to_string), ser)
}
/// deserialize an [`Option<MacAddr>`]
pub fn des_opm<'de, D>(des: D) -> Result<Option<MacAddr>, D::Error>
where
D: serde::Deserializer<'de>,
{
Option::<&str>::deserialize(des)?
.map(str::parse)
.transpose()
.map_err(de::Error::custom)
}
}
pub use mac::*;
+16 -2
View File
@@ -1,13 +1,15 @@
// this ENTIRE file is redundant... or?
use std::time::Duration;
use std::{net::IpAddr, time::Duration};
use tokio::{
net::{TcpStream, ToSocketAddrs},
time::timeout,
};
pub async fn ping_ip<T: ToSocketAddrs>(addr: T) -> bool {
use crate::{arpparse::NUDState, utils::query::get_macs};
pub async fn _ping_ip<T: ToSocketAddrs>(addr: T) -> bool {
timeout(Duration::from_secs(1), TcpStream::connect(addr))
.await
.is_ok()
@@ -15,3 +17,15 @@ pub async fn ping_ip<T: ToSocketAddrs>(addr: T) -> bool {
pub async fn _ping_ip_2<T: ToSocketAddrs>(_addr: T) -> bool {
todo!("use icmp")
}
pub async fn _ping_ip_3<T: Into<IpAddr>>(addr: T) -> u8 {
match get_macs(None, Some(&[addr.into()]), None, None).await {
Err(_) => 0,
Ok(l) => l
.into_iter()
.map(|e| e.state)
.max()
.map(NUDState::rank)
.unwrap_or_default(),
}
}
+5 -182
View File
@@ -1,183 +1,6 @@
use std::{collections::HashSet, net::IpAddr};
pub mod dev;
pub mod leases;
pub mod macs;
use macaddr::MacAddr;
// use tokio::io;
use crate::{
arpparse::{self, IpNeighLine, NUDState},
utils::{
cmd::exec_command,
error::{self, Error, Result},
get_ips,
},
};
pub async fn get_macs_2_1(machine_name: &str) -> Result<HashSet<(IpAddr, MacAddr, NUDState)>> {
Ok(get_macs_1(machine_name)
.await?
.into_iter()
.filter_map(
|IpNeighLine {
ip,
dev: _,
mac,
state,
}| mac.map(|mac| (ip, mac, state)),
)
.collect())
}
pub async fn get_macs_2_mac(machine_name: &str) -> Result<HashSet<MacAddr>> {
Ok(get_macs_1(machine_name)
.await?
.into_iter()
.filter_map(
|IpNeighLine {
ip: _,
dev: _,
mac,
state: _,
}| mac,
)
.collect())
}
pub async fn get_macs_1(machine_name: &str) -> Result<Vec<arpparse::IpNeighLine>> {
let dev = "br-lan";
let ips = get_ips(machine_name).await?;
let futures = ips.iter().map(|ip| {
let ip = ip.to_canonical();
async move {
let cmd = "ip";
let args = ["neigh", "show", "to", &ip.to_string(), "dev", dev];
let o = exec_command(cmd, args).await?;
if !o.status.success() {
return Err(Error::CommandFailed {
cmd,
args: args.iter().map(ToString::to_string).collect(),
status: o.status.code(),
stderr: String::from_utf8_lossy(&o.stderr).into(),
});
};
Ok(String::from_utf8_lossy(&o.stdout)
.lines()
.flat_map(arpparse::parse_ip_neigh_line)
// .map(IpNeighLine::with_dev(dev)) // this could be after flatmap up there
.collect::<Vec<_>>())
}
});
let res = futures::future::try_join_all(futures).await?; // async move block errs.
Ok(res
.into_iter()
.flatten() /* resolve double vec */
// .flatten() /* drop parse errors (flat_map cleared) */
.collect())
}
pub async fn get_macs(
machine_name: Option<&str>,
ips: Option<&[IpAddr]>,
dev: Option<&str>,
state: Option<NUDState>,
) -> Result<Vec<IpNeighLine>> {
// Collect IPs early (before any await) to avoid holding generics across await points
let ip_list: Option<Vec<IpAddr>> =
ips.map(|slice| slice.iter().copied().map(|ip| ip.to_canonical()).collect());
// Resolve by machine name if no IPs provided but we have a name
let ip_list = match (ip_list, machine_name) {
(Some(list), _) => list,
(None, Some(name)) => get_ips(name).await?.into_iter().collect(),
(None, None) => Vec::new(),
};
// Helper to convert NUDState to the string expected by `ip neigh`
let nud_arg = state.map(NUDState::as_ip_neigh_arg);
// let nud_arg = Rc::new(state.map(|s| s.to_string().to_lowercase()));
// Build a closure to run one `ip neigh` invocation and parse results
let run_one = |to_ip: Option<IpAddr>| async move {
let mut args: Vec<String> = vec!["neigh".into(), "show".into()];
if let Some(ip) = to_ip {
args.push("to".into());
args.push(ip.to_string());
}
if let Some(d) = dev {
args.push("dev".into());
args.push(d.to_string());
}
if let Some(nud) = nud_arg {
args.push("nud".into());
args.push(nud.to_string());
}
let o = exec_command("ip", args.iter().map(String::as_str).collect::<Vec<_>>()).await?; // hope to rustc that it knows how to unfuck ts
if !o.status.success() {
return Err(Error::CommandFailed {
cmd: "ip",
args,
status: o.status.code(),
stderr: String::from_utf8_lossy(&o.stderr).into(),
});
}
let lines = String::from_utf8_lossy(&o.stdout);
// Parse lines and, if a specific dev filter was used, stamp that dev onto rows
let parsed = lines.lines().flat_map(arpparse::parse_ip_neigh_line);
let rows: Vec<IpNeighLine> = if let Some(d) = dev {
parsed.map(IpNeighLine::with_dev(d)).collect()
} else {
parsed.collect()
};
Ok::<Vec<IpNeighLine>, error::Error>(rows)
};
// If we have specific IPs, query each; otherwise query the whole table once
if !ip_list.is_empty() {
let futures = ip_list.into_iter().map(|ip| run_one(Some(ip)));
let res = futures::future::try_join_all(futures).await?;
Ok(res.into_iter().flatten().collect())
} else {
run_one(None).await
}
}
pub mod dev {
use std::{collections::HashSet, sync::LazyLock};
pub fn get_dev() -> HashSet<String> {
// Prefer /sys/class/net, fallback to /proc/net/dev; filter out loopback
let mut devs: HashSet<String> = HashSet::new();
if let Ok(rd) = std::fs::read_dir("/sys/class/net") {
for e in rd.flatten() {
if let Ok(name) = e.file_name().into_string()
&& name != "lo"
&& !name.is_empty()
{
devs.insert(name);
}
}
} else if let Ok(txt) = std::fs::read_to_string("/proc/net/dev") {
for line in txt.lines().skip(2) {
// skip headers
if let Some((name, _rest)) = line.split_once(':') {
let n = name.trim().to_string();
if n != "lo" && !n.is_empty() {
devs.insert(n);
}
}
}
}
devs
}
pub static DEVS: LazyLock<HashSet<String>> = LazyLock::new(get_dev);
pub fn devs_sorted() -> Vec<String> {
let mut v: Vec<String> = DEVS.iter().cloned().collect();
v.sort();
v
}
pub fn has_dev(name: &str) -> bool {
DEVS.contains(name)
}
}
pub use leases::*;
pub use macs::*;
+35
View File
@@ -0,0 +1,35 @@
use std::collections::HashSet;
pub fn get_dev() -> HashSet<String> {
let mut devs: HashSet<String> = HashSet::new();
if let Ok(rd) = std::fs::read_dir("/sys/class/net") {
for e in rd.flatten() {
if let Ok(name) = e.file_name().into_string()
&& name != "lo"
&& !name.is_empty()
{
devs.insert(name);
}
}
} else if let Ok(txt) = std::fs::read_to_string("/proc/net/dev") {
for line in txt.lines().skip(2) {
if let Some((name, _rest)) = line.split_once(':') {
let n = name.trim().to_string();
if n != "lo" && !n.is_empty() {
devs.insert(n);
}
}
}
}
devs
}
pub fn devs_sorted() -> Vec<String> {
let mut v: Vec<String> = get_dev().into_iter().collect();
v.sort();
v
}
pub fn has_dev(name: &str) -> bool {
get_dev().contains(name)
}
+45
View File
@@ -0,0 +1,45 @@
use crate::arpparse::NUDState;
use crate::dhcpparse::DhcpLeaseLine;
use serde_with::skip_serializing_none;
use std::net::IpAddr;
#[skip_serializing_none]
#[derive(Debug, Clone, serde::Serialize)]
pub struct DhcpLeaseOut {
#[serde(flatten)]
pub lease_line: DhcpLeaseLine,
pub nud_state: Option<NUDState>,
pub rank: Option<u8>,
}
/// Enrich DHCP leases with NUD state and rank using get_macs
pub async fn enrich_leases_with_nud_state(leases: Vec<DhcpLeaseLine>) -> Vec<DhcpLeaseOut> {
use crate::utils::query::macs::get_macs;
let ips: Vec<IpAddr> = leases.iter().map(|l| l.ip).collect();
let mut map: std::collections::HashMap<IpAddr, (NUDState, u8)> =
std::collections::HashMap::new();
if let Ok(rows) = get_macs(None, Some(&ips), None, None).await {
for row in rows {
let state = row.state;
let r = state.rank();
map.entry(row.ip)
.and_modify(|e| {
if r > e.1 {
*e = (state, r)
}
})
.or_insert((state, r));
}
}
leases
.into_iter()
.map(|lease_line| {
let (nud_state, rank) = map.get(&lease_line.ip).copied().unzip();
DhcpLeaseOut {
lease_line,
nud_state,
rank,
}
})
.collect()
}
+171
View File
@@ -0,0 +1,171 @@
use crate::arpparse::{self, IpNeighLine, NUDState};
use crate::utils::{
cmd::exec_command,
error::{self, Error, Result},
};
use macaddr::MacAddr;
use std::collections::HashSet;
use std::net::IpAddr;
pub async fn get_ips(machine_name: &str) -> error::Result<impl Iterator<Item = IpAddr>> {
Ok(tokio::net::lookup_host((machine_name, 0))
.await
.map_err(|e| error::Error::DnsResolve {
name: machine_name.to_string(),
source: e,
})?
.map(|c| c.ip()))
}
pub async fn _get_macs_2_1(machine_name: &str) -> Result<HashSet<(IpAddr, MacAddr, NUDState)>> {
Ok(get_macs_1(machine_name)
.await?
.into_iter()
.filter_map(
|IpNeighLine {
ip,
dev: _,
mac,
state,
}| mac.map(|mac| (ip, mac, state)),
)
.collect())
}
pub async fn get_macs_2_mac(machine_name: &str) -> Result<HashSet<MacAddr>> {
Ok(get_macs_1(machine_name)
.await?
.into_iter()
.filter_map(
|IpNeighLine {
ip: _,
dev: _,
mac,
state: _,
}| mac,
)
.collect())
}
pub async fn get_macs_1(machine_name: &str) -> Result<Vec<arpparse::IpNeighLine>> {
let dev = "br-lan";
let ips = get_ips(machine_name).await?;
let futures = ips.map(|ip| {
let ip = ip.to_canonical();
async move {
let cmd = "ip";
let args = ["neigh", "show", "to", &ip.to_string(), "dev", dev];
let o = exec_command(cmd, args).await?;
if !o.status.success() {
return Err(Error::CommandFailed {
cmd,
args: args.iter().map(ToString::to_string).collect(),
status: o.status.code(),
stderr: String::from_utf8_lossy(&o.stderr).into(),
});
};
Ok(String::from_utf8_lossy(&o.stdout)
.lines()
.flat_map(arpparse::parse_ip_neigh_line)
.collect::<Vec<_>>())
}
});
let res = futures::future::try_join_all(futures).await?;
Ok(res.into_iter().flatten().collect())
}
pub async fn get_macs(
machine_name: Option<&str>,
ips: Option<&[IpAddr]>,
dev: Option<&str>,
state: Option<NUDState>,
) -> Result<Vec<IpNeighLine>> {
let ip_list: Option<Vec<IpAddr>> =
ips.map(|slice| slice.iter().copied().map(|ip| ip.to_canonical()).collect());
let ip_list = match (ip_list, machine_name) {
(Some(list), _) => list,
(None, Some(name)) => get_ips(name).await?.collect(),
(None, None) => Vec::new(),
};
let run_one = |to_ip: Option<IpAddr>| get_mac(to_ip, dev, state);
if !ip_list.is_empty() {
let futures = ip_list.into_iter().map(|ip| run_one(Some(ip)));
let res = futures::future::try_join_all(futures).await?;
Ok(res.into_iter().flatten().collect())
} else {
run_one(None).await
}
}
/// the atomic get_macs. handle ONE thing only.
pub async fn get_mac(
ip: Option<IpAddr>,
dev: Option<&str>,
state: Option<NUDState>,
) -> error::Result<Vec<IpNeighLine>> {
let mut args: Vec<String> = vec!["neigh".into(), "show".into()];
if let Some(ip) = ip {
args.push("to".into());
args.push(ip.to_string());
}
if let Some(d) = dev {
args.push("dev".into());
args.push(d.to_string());
}
if let Some(nud) = state {
args.push("nud".into());
args.push(nud.as_ip_neigh_arg().into());
}
let cmd = "ip";
let out = exec_command(cmd, args.iter().map(String::as_str).collect::<Vec<_>>()).await?;
if !out.status.success() {
return Err(error::Error::CommandFailed {
cmd,
args,
status: out.status.code(),
stderr: String::from_utf8_lossy(&out.stderr).into(),
});
}
let lines = String::from_utf8_lossy(&out.stdout);
let parsed = lines.lines().flat_map(arpparse::parse_ip_neigh_line);
let rows: Vec<IpNeighLine> = if let Some(d) = dev {
parsed.map(IpNeighLine::with_dev(d)).collect()
} else {
parsed.collect()
};
Ok(rows)
}
/* pub async fn _get_macs_good(
machine_names: Option<&str>,
ips: Option<&[IpAddr]>,
devs: Option<&[&str]>,
states: Option<&[NUDState]>,
) -> Result<Vec<IpNeighLine>> {
let mut ip_map: HashSet<IpAddr> = HashSet::new();
let mut machine_map = HashSet::new();
if let Some(ips) = ips {
ip_map.extend(ips);
}
if let Some(m) = machine_names {
machine_map.extend(get_ips(m).await.into_iter().flatten());
}
let real = match (ip_map.is_empty(), machine_map.is_empty()) {
(true, true) => HashSet::new(),
(true, false) => machine_map,
(false, true) => ip_map,
(false, false) => ip_map.intersection(&machine_map).copied().collect(),
};
Ok(vec![])
}
pub async fn _get_machines(m: &[&str]) -> Vec<IpAddr> {
let futs = m.iter().map(|c| get_ips(c));
futures::future::join_all(futs)
.await
.into_iter()
.flat_map(|f| f.into_iter().flatten())
.collect()
} */
+72
View File
@@ -0,0 +1,72 @@
use std::net::IpAddr;
use axum::{http::header, response::IntoResponse};
use macaddr::MacAddr;
use crate::{arpparse::NUDState, route::DeviceQuery, utils::query::dev::has_dev};
pub async fn serve_js(content: &'static str) -> impl IntoResponse {
(
[
(header::CONTENT_TYPE, "application/javascript"),
(header::CACHE_CONTROL, "public, max-age=300"),
],
content,
)
}
pub async fn status_smart_redirect(q: String) -> DeviceQuery {
let s = if cfg!(feature = "very-smart-parsing") {
crate::utils::parse::extract_host(&q)
} else {
q.trim()
};
// 1) IP
let ip = if cfg!(feature = "very-smart-parsing") {
crate::utils::parse::parse_numeric_ipv4(s).or_else(|| s.parse::<IpAddr>().ok())
} else {
s.parse::<IpAddr>().ok()
};
if let Some(ip) = ip {
let ip = vec![ip];
return DeviceQuery {
ip,
..Default::default()
};
}
// 2) MAC
if let Ok(mac) = s.parse::<MacAddr>() {
let mac = vec![mac];
return DeviceQuery {
mac,
..Default::default()
};
}
// 3) NUD state (reachable, stale, ...)
if let Ok(state) = s.parse::<NUDState>() {
let nud = vec![state];
return DeviceQuery {
nud,
..Default::default()
};
}
// 4) Known device? prefer dev first
if has_dev(s) {
return DeviceQuery {
dev: vec![s.to_string()],
..Default::default()
};
}
// 5) Try DNS: if it resolves, treat as name
if tokio::net::lookup_host((s, 0)).await.is_ok() {
return DeviceQuery {
name: Some(s.to_string()),
..Default::default()
};
}
// Default: name last // it will fail also
DeviceQuery {
name: Some(s.to_string()),
..Default::default()
}
}
+71
View File
@@ -1,5 +1,8 @@
pub mod impls;
use std::{io, net::IpAddr};
use futures::TryFutureExt;
use macaddr::MacAddr;
use tokio::net::UdpSocket;
use crate::utils::query::get_macs_2_mac;
@@ -30,3 +33,71 @@ pub async fn wake(machine_name: &str) -> io::Result<u32> {
}
Ok(sent_ok)
}
#[derive(Debug, Clone, Copy, Hash)]
pub struct WakeTarget {
pub ip: IpAddr,
pub mac: MacAddr,
}
#[derive(Debug, Clone, Copy, Hash)]
pub struct WakeTargetResult {
pub ip: IpAddr,
pub mac: MacAddr,
pub status: WakeStatus,
}
#[derive(Debug, Clone, Copy, Hash)]
pub enum WakeStatus {
Success,
NonexistentAddress,
WrongSize,
}
impl WakeTarget {
fn _new(ip: IpAddr, mac: MacAddr) -> Self {
Self { ip, mac }
}
fn good(self) -> WakeTargetResult {
WakeTargetResult::new(self.ip, self.mac, WakeStatus::Success)
}
fn bad(self) -> WakeTargetResult {
WakeTargetResult::new(self.ip, self.mac, WakeStatus::WrongSize)
}
fn errored(self) -> WakeTargetResult {
WakeTargetResult::new(self.ip, self.mac, WakeStatus::NonexistentAddress)
}
}
impl WakeTargetResult {
fn new(ip: IpAddr, mac: MacAddr, status: WakeStatus) -> Self {
Self { ip, mac, status }
}
}
// its time. we have the ip; the macs. we dont need to send to the uh the broadcast anymore???
pub async fn _wake_multi(
targets: impl IntoIterator<Item = WakeTarget>,
) -> io::Result<Vec<WakeTargetResult>> {
let sock = UdpSocket::bind("[::]:0")
.or_else(|_| UdpSocket::bind(":0"))
.await?;
sock.set_broadcast(true)?;
let fs = targets.into_iter().map(|t| wake_one(&sock, t));
Ok(futures::future::join_all(fs).await)
}
pub async fn wake_one(sock: &UdpSocket, t: WakeTarget) -> WakeTargetResult {
let mac = t.mac;
let mb = mac.as_bytes();
let mut pac = [0; 6 + 6 * 16];
pac[..6].fill(0xff);
for i in 1..=16 {
pac[i * 6..(i + 1) * 6].copy_from_slice(mb);
}
let ip = t.ip;
let port = 9;
match sock.send_to(&pac, (ip, port)).await {
Ok(n) if n == pac.len() => t.good(),
Ok(_) => t.bad(),
Err(_) => t.errored(),
}
}
// pub async fn wake_query();
+62
View File
@@ -0,0 +1,62 @@
use super::{WakeStatus, WakeTarget, WakeTargetResult};
use crate::route::wake::{
WakeTarget as RouteWakeTarget, WakeTargetResult as RouteWakeResult,
WakeTargetStatus as RouteWakeStatus,
};
#[derive(Debug, Clone, Copy)]
pub struct Incomplete;
impl TryFrom<RouteWakeTarget> for WakeTarget {
type Error = Incomplete;
fn try_from(value: RouteWakeTarget) -> Result<Self, Self::Error> {
if let RouteWakeTarget {
ip: Some(ip),
mac: Some(mac),
} = value
{
Ok(Self { ip, mac })
} else {
Err(Incomplete)
}
}
}
impl From<WakeTargetResult> for RouteWakeResult {
fn from(WakeTargetResult { ip, mac, status }: WakeTargetResult) -> Self {
Self {
ip: Some(ip),
mac: Some(mac),
status: status.into(),
}
}
}
impl RouteWakeTarget {
pub fn to_incomplete(self) -> RouteWakeResult {
RouteWakeResult {
ip: self.ip,
mac: self.mac,
status: RouteWakeStatus::Incomplete,
}
}
pub fn is_incomplete(&self) -> bool {
!matches!(
self,
Self {
ip: Some(_),
mac: Some(_)
}
)
}
}
impl From<WakeStatus> for RouteWakeStatus {
fn from(value: WakeStatus) -> Self {
match value {
WakeStatus::NonexistentAddress => Self::NonexistentAddress,
WakeStatus::Success => Self::Succeed,
WakeStatus::WrongSize => Self::WrongSize,
}
}
}
-182
View File
@@ -1,182 +0,0 @@
const qs = new URLSearchParams(location.search);
const $ = (id) => document.getElementById(id);
const elName = $("name");
const elCheck = $("check");
const elWake = $("wake");
const elLog = $("log");
const elHtml = $("html");
const pill = $("status-pill");
const link = $("permalink");
function setPill(kind, text) {
pill.className = `pill ${kind}`;
pill.textContent = text;
}
function setLink(name) {
const url = new URL(location.href);
if (name) url.searchParams.set("name", name);
else url.searchParams.delete("name");
history.replaceState(null, "", url);
link.href = url.toString();
}
function getName() {
return (elName.value || "").trim();
}
function saveName(name) {
try {
localStorage.setItem("wakey:name", name);
} catch {}
}
function loadName() {
return qs.get("name") || localStorage.getItem("wakey:name") || "";
}
/** @param {String} s NUD state */
function rankState(s) {
const key = String(s || "")
.trim()
.toUpperCase();
return (
{
PERMANENT: 5,
REACHABLE: 5,
STALE: 4,
DELAY: 3,
PROBE: 3,
INCOMPLETE: 3,
NOARP: 2,
NONE: 1,
FAILED: 0,
}[key] ?? 0
);
}
function buildStatusUrl(name) {
const u = new URL("/api/status", location.origin);
if (name) u.searchParams.set("name", name);
// forward multi-value filters from current page URL
for (const k of ["ip", "dev", "nud", "mac"]) {
const vals = qs.getAll(k);
for (const v of vals) u.searchParams.append(k, v);
}
return u;
}
/** @param {{ name?: String, table: Array<{ ip, dev, mac, state }>, filters: {ip, dev, nud, mac}} | { name?: String, error }} data from /api/status */
function renderStatus(data) {
const tbl = document.createElement("table");
tbl.innerHTML = `<tr><th>IP</th><th>MAC</th><th>State</th><th>IF</th></tr>`;
for (const row of data.table || []) {
const tr = document.createElement("tr");
const mac = row.mac ?? "";
const dev = row.dev ?? "";
tr.innerHTML = `<td>${row.ip}</td><td>${mac}</td><td>${row.state}</td><td>${dev}</td>`;
tbl.appendChild(tr);
}
elHtml.innerHTML = "";
// Optional: show applied filters
if (data.filters) {
const parts = [];
if (Array.isArray(data.filters.ip) && data.filters.ip.length)
parts.push(`ip=[${data.filters.ip.join(", ")}]`);
if (Array.isArray(data.filters.dev) && data.filters.dev.length)
parts.push(`dev=[${data.filters.dev.join(", ")}]`);
if (Array.isArray(data.filters.nud) && data.filters.nud.length)
parts.push(`nud=[${data.filters.nud.join(", ")}]`);
if (Array.isArray(data.filters.mac) && data.filters.mac.length)
parts.push(`mac=[${data.filters.mac.join(", ")}]`);
if (parts.length) {
const info = document.createElement("div");
info.className = "filters";
info.textContent = `Filters: ${parts.join("; ")}`;
elHtml.appendChild(info);
}
}
elHtml.appendChild(tbl);
if ((data.table || []).length > 0) {
const best = data.table.reduce((a, b) =>
rankState(b.state) > rankState(a.state) ? b : a
);
const r = rankState(best.state);
if (r >= 5) setPill("ok", "online");
else if (r >= 2) setPill("warn", "maybe");
else setPill("bad", "offline");
} else {
setPill("warn", "unknown");
}
}
async function fetchStatus(name) {
setPill("warn", "checking…");
const u = buildStatusUrl(name);
elLog.textContent = "GET " + u.pathname + u.search;
elHtml.innerHTML = "";
try {
const r = await fetch(u);
if (!r.ok) {
let msg = String(r.status);
try {
const err = await r.clone().json();
msg = err.error || JSON.stringify(err);
} catch {
msg = await r.text();
}
elLog.textContent = `status error: ${msg}`;
// elHtml.textContent = msg;
setPill("bad", "error");
return;
}
const data = await r.json();
renderStatus(data);
} catch (e) {
elLog.textContent = "status error: " + e;
setPill("bad", "error");
}
}
async function sendWake(name) {
setPill("warn", "waking…");
elLog.textContent = "POST /wake?name=" + name;
try {
const r = await fetch(`/wake?name=${encodeURIComponent(name)}`, {
method: "POST",
// headers: { "X-Target-Name": name },
});
const t = await r.text();
elLog.textContent = t || "ok";
// Optional: recheck after medium delay
setTimeout(() => fetchStatus(name), 1500);
} catch (e) {
elLog.textContent = "wake error: " + e;
setPill("bad", "error");
}
}
elCheck.addEventListener("click", () => {
const name = getName();
if (!name) return;
saveName(name);
setLink(name);
fetchStatus(name);
});
elWake.addEventListener("click", () => {
const name = getName();
if (!name) return;
saveName(name);
setLink(name);
sendWake(name);
});
elName.addEventListener("keydown", (e) => {
if (e.key === "Enter") elCheck.click();
});
// init
const initial = loadName();
if (initial) {
elName.value = initial;
setLink(initial); // when the permalink doing YOUR job
// auto-check on load in this A/B page
fetchStatus(initial);
} else {
setPill("warn", "unknown");
}
+17 -9
View File
@@ -3,14 +3,14 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>wakey • home_2</title>
<link rel="stylesheet" href="/home_2.css" />
<script type="module" src="/home_2.js" defer></script>
<title>wakey • home</title>
<link rel="stylesheet" href="home_2/styles.css" />
<script type="module" src="home_2/main.js" defer></script>
</head>
<body>
<div class="wrap">
<header>
<h1>home_2 <span class="muted">A/B test page</span></h1>
<h1>home <span class="muted">ping your device with a WoL</span></h1>
<span id="status-pill" class="pill warn">unknown</span>
</header>
@@ -18,7 +18,7 @@
<input
id="name"
type="text"
placeholder="target name e.g. lda.lan"
placeholder="target (name, ip, mac...)"
spellcheck="false"
/>
<button id="check" class="secondary">Check</button>
@@ -26,17 +26,25 @@
</div>
<div class="row muted" style="gap: 16px">
<span
>Uses query <span title="available keys: name, ip, dev, nud">(?name=...)</span> on this page to view the
status.<!-- and header (X-Target-Name) so either extractor
path works. --></span
>Uses query
<span title="available keys: name, ip, mac, dev, nud"
>(?name=...)</span
>
<a id="permalink" href="#">permalink</a>
on this page to view the status.<!-- and header (X-Target-Name) so either extractor
path works. --></span
><span id="preview" class="tiny"></span
><a id="permalink" href="#">permalink</a>
</div>
<section id="out" class="card">
<pre id="log">ready.</pre>
<div id="html"></div>
</section>
<section id="leases" class="card">
<h3 style="margin-top: 0">DHCP leases</h3>
<div id="leases_html"></div>
</section>
</div>
</body>
</html>
+36
View File
@@ -0,0 +1,36 @@
import { filter_array } from "./status.js";
export const qs = new URLSearchParams(location.search);
// $ is normally queryselector are we fr
export const $ = (id) => document.getElementById(id);
export const elName = $("name");
export const elCheck = $("check");
export const elWake = $("wake");
export const elLog = $("log");
export const elHtml = $("html");
export const elLeases = $("leases_html");
export const pill = $("status-pill");
export const link = $("permalink");
export const elPreview = $("preview");
export function setPill(kind, text) {
pill.className = `pill ${kind}`;
pill.textContent = text;
}
export function setLink(name, clear) {
const url = new URL(location.href);
if (clear) url.searchParams.forEach((_, k) => url.searchParams.delete(k)); // FUCK
/* const hasExtraFilters = filter_array.some(
(k) => url.searchParams.getAll(k).length
);
if (hasExtraFilters) {
// jus returns whatever; they are specifying further
} else */ {
if (name) url.searchParams.set("name", name);
else url.searchParams.delete("name");
}
history.replaceState(null, "", url);
link.href = url.toString();
}
+65
View File
@@ -0,0 +1,65 @@
import { elLeases } from "./dom.js";
export function renderLeases(leases) {
if (!elLeases) return;
if (!Array.isArray(leases) || leases.length === 0) {
elLeases.textContent = "no leases";
return;
}
const tbl = document.createElement("table");
tbl.className = "table";
(
tbl.tHead || tbl.createTHead()
).innerHTML = `<tr><th></th><th>IP</th><th>MAC</th><th>Name</th><th>Expires</th></tr>`;
const tbd = tbl.tBodies.item(0) || tbl.createTBody();
const nowSec = Math.floor(Date.now() / 1000);
for (const l of leases) {
const tr = document.createElement("tr");
const exp = Number(l.expires_epoch || 0);
const expired = exp > 0 && exp <= nowSec;
const when = exp > 0 ? new Date(exp * 1000) : null;
const whenText = when ? when.toLocaleString() : "";
let dotClass = "dot ok";
if (expired) {
dotClass = "dot bad";
} /* rank from NUDState */ else if (typeof l?.rank === "number") {
if (l.rank >= 5) dotClass = "dot ok";
else if (l.rank >= 2) dotClass = "dot warn";
else dotClass = "dot bad";
}
const title = expired
? "expired"
: l?.nud_state
? String(l.nud_state).toLowerCase()
: "unknown";
const ip = l.ip || "";
const mac = l.mac || "";
const name = l.name || "";
tr.innerHTML = `
<td><span class="${dotClass}" title="${title}"></span></td>
${Object.entries({ ip, mac, name })
.map(
([name, value]) =>
`<td>${
value &&
`<a href="#" class="pick" data-value="${value}" title="filter by ${name}">${value}</a>`
}</td>`
)
.join("\n")}
<td><span class="tiny">${whenText}</span></td>`;
tbd.appendChild(tr);
}
elLeases.innerHTML = "";
elLeases.appendChild(tbl);
}
export async function fetchLeases() {
try {
const r = await fetch("/api/dhcp_leases?include_state=1");
if (!r.ok) return;
const leases = await r.json();
renderLeases(leases);
} catch {}
}
+89
View File
@@ -0,0 +1,89 @@
import {
elName,
elCheck,
elWake,
elHtml,
elLeases,
elPreview,
setLink,
setPill,
qs,
} from "./dom.js";
import {
getName,
saveName,
loadName,
extractHostLikeBackend,
} from "./utils.js";
import { fetchStatus } from "./status.js";
import { fetchLeases } from "./leases.js";
import { sendWake } from "./wake.js";
// delegated click handler
function handlePickClick(e) {
const a = e.target && e.target.closest("a.pick");
if (!a) return;
e.preventDefault();
const v = a.getAttribute("data-value");
pickTarget(v);
}
if (elHtml) elHtml.addEventListener("click", handlePickClick);
if (elLeases) elLeases.addEventListener("click", handlePickClick);
function updatePreview() {
const raw = getName(elName);
const host = extractHostLikeBackend(raw);
elPreview.textContent = host && host !== raw ? `→ ${host}` : "";
}
function pickTarget(value) {
const v = String(value || "").trim();
if (!v) return;
elName.value = v;
updatePreview();
saveName(v);
setLink(v, true);
fetchStatus(v);
fetchLeases();
}
// events
elCheck.addEventListener("click", () => {
const name = getName(elName);
if (!name) return;
saveName(name);
setLink(name);
fetchStatus(name);
fetchLeases();
});
elWake.addEventListener("click", () => {
const name = getName(elName);
if (!name) return;
saveName(name);
setLink(name);
sendWake(name);
fetchLeases();
});
elName.addEventListener("keydown", (e) => {
if (e.key === "Enter") elCheck.click();
});
elName.addEventListener("input", updatePreview);
// init
const initial = loadName(qs);
if (initial) {
elName.value = initial;
updatePreview();
if (qs.has("name")) {
setLink(initial);
fetchStatus(initial);
} else {
fetchStatus();
}
fetchLeases();
} else {
setPill("warn", "unknown");
updatePreview();
fetchStatus();
fetchLeases();
}
+121
View File
@@ -0,0 +1,121 @@
import { elHtml, elLog, setPill, qs, pill } from "./dom.js";
import { rankState } from "./utils.js";
import { merge_wake_data, translate_wake_message } from "./wake.js";
const status_map = {
ip: "ip",
mac: "mac",
state: "state",
dev: "interface",
};
export const status_array = Object.keys(status_map);
export const filter_array = ["ip", "dev", "nud", "mac"];
function buildStatusUrl(name) {
const hasExtraFilters = filter_array.some((k) => qs.getAll(k).length);
if (name && !hasExtraFilters) {
return new URL(`/api/smart/${encodeURIComponent(name)}`, location.origin);
}
const u = new URL("/api/status", location.origin);
if (name) u.searchParams.set("name", name);
for (const k of filter_array) {
const vals = qs.getAll(k);
for (const v of vals) u.searchParams.append(k, v);
}
return u;
}
export function renderStatus(data) {
const tbl = document.createElement("table");
tbl.className = "table";
tbl.innerHTML = `<thead><tr>${
data.has_wake ? "<th>Wake status</th>" : ""
}<th>IP</th><th>MAC</th><th>State</th><th>IF</th></tr></thead>`;
// sum hax
const tbd = tbl.tBodies.item(0) || tbl.createTBody();
for (const row of data.table) {
if (data.has_wake && !row.wake_status)
throw TypeError("specified has wake but no wake stats");
const tr = document.createElement("tr");
tr.innerHTML = `${
row.wake_status
? `<td><span class="dot ${
row.wake_status == "succeed" ? "ok" : "bad"
}" title="${translate_wake_message(row.wake_status)}"></span></td>`
: ""
}${Object.entries(status_map)
.map(([field, description]) => {
const value = row[field];
return `<td>${
value
? `<a href="#" class="pick" data-value="${value}" title="filter by ${description}">${value}</a>`
: ""
}</td>`;
})
.join("")}`;
tbd.appendChild(tr);
}
elHtml.innerHTML = "";
if (data.filters) {
const parts = [];
filter_array.forEach((field) => {
if (Array.isArray(data.filters[field]) && data.filters[field].length)
parts.push(`${field}=[${data.filters[field].join(", ")}]`);
});
if (parts.length) {
const info = document.createElement("div");
info.className = "filters";
info.textContent = `Filters: ${parts.join("; ")}`;
elHtml.appendChild(info);
}
}
elHtml.appendChild(tbl);
if ((data.table || []).length > 0) {
const best = data.table.reduce((a, b) =>
rankState(b.state) > rankState(a.state) ? b : a
);
const r = rankState(best.state);
if (r >= 5) setPill("ok", "online");
else if (r >= 2) setPill("warn", "maybe");
else setPill("bad", "offline");
if (data.filters.nud?.length > 0) pill.textContent += " (filtered)";
} else {
setPill("warn", "unknown");
}
}
export async function fetchStatus(name, render = true, data_wake) {
setPill("warn", "checking…");
const u = buildStatusUrl(name);
elLog.textContent = "GET " + u.pathname + u.search;
try {
const r = await fetch(u);
if (!r.ok) {
let msg = String(r.status);
try {
const err = await r.clone().json();
msg = err.error || JSON.stringify(err);
} catch {
msg = await r.text();
}
elLog.textContent = `status error: ${msg}`;
setPill("bad", "error");
return;
}
const data = await r.json();
if (data_wake) {
data.table = merge_wake_data(data.table, data_wake);
data.has_wake = true;
}
if (render) renderStatus(data);
return data;
} catch (e) {
elLog.textContent = "status error: " + e;
setPill("bad", "error");
}
}
@@ -111,6 +111,13 @@ pre {
a {
color: #93c5fd;
}
.table a.pick {
color: #93c5fd;
text-decoration: none;
}
.table a.pick:hover {
text-decoration: underline;
}
#html {
overflow-y: auto;
@@ -126,3 +133,43 @@ a {
color: var(--muted);
font-size: 12px;
}
/* simple table styling */
.table {
width: 100%;
border-collapse: collapse;
}
.table th,
.table td {
padding: 6px 8px;
border-bottom: 1px solid #2a2a2a;
font-size: 12px;
}
.table th {
text-align: left;
color: var(--muted);
font-weight: 600;
}
.tiny {
font-size: 11px;
}
.dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--muted);
}
.dot.ok {
background: var(--ok);
}
.dot.bad {
background: var(--bad);
}
.dot.warn {
background: var(--warn);
}
#preview:empty {
display: none;
}
+55
View File
@@ -0,0 +1,55 @@
export function getName(elName) {
return (elName.value || "").trim();
}
export function saveName(name) {
try {
localStorage.setItem("wakey:name", name);
} catch {}
}
export function loadName(qs) {
return qs.get("name") || localStorage.getItem("wakey:name") || "";
}
export function extractHostLikeBackend(input) {
let s = String(input || "").trim();
if (!s) return "";
const schemeIdx = s.indexOf("://");
if (schemeIdx >= 0) s = s.slice(schemeIdx + 3);
else if (s.startsWith("//")) s = s.slice(2);
const at = s.lastIndexOf("@");
if (at >= 0) s = s.slice(at + 1);
if (s.startsWith("[")) {
const end = s.indexOf("]");
if (end > 1) s = s.slice(1, end);
} else {
const slash = s.indexOf("/");
if (slash >= 0) s = s.slice(0, slash);
const colon = s.lastIndexOf(":");
if (colon > 0 && (s.match(/:/g) || []).length === 1) {
const port = s.slice(colon + 1);
if (/^\d+$/.test(port)) s = s.slice(0, colon);
}
}
return s.trim();
}
export function rankState(s) {
const key = String(s || "")
.trim()
.toUpperCase();
return (
{
PERMANENT: 5,
REACHABLE: 5,
STALE: 4,
DELAY: 3,
PROBE: 3,
INCOMPLETE: 3,
NOARP: 2,
NONE: 1,
FAILED: 0,
}[key] ?? 0
);
}
+107
View File
@@ -0,0 +1,107 @@
import { elHtml, elLog, setPill } from "./dom.js";
import { fetchStatus, renderStatus } from "./status.js";
export async function sendWake(name) {
const data = await fetchStatus(name, false);
if (!data) return; // can not proceed; theres nothing.
const wake_targets = data.table.map(({ ip, mac }) => {
return { ip, mac };
});
setPill("warn", "waking…");
elLog.textContent = "POST /api/wake";
try {
const r = await fetch(`/api/wake`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(wake_targets),
});
if (!r.ok) {
let msg = String(r.status);
try {
const err = await r.clone().json();
msg = err.error || JSON.stringify(err);
} catch {
msg = await r.text();
}
elLog.textContent = `wake error: ${msg}`;
setPill("bad", "error");
return;
}
const j = await r.json();
if (!j.success) {
elLog.textContent = `wake error: ${j.error}`;
setPill("bad", "error");
return;
}
const result = j.result;
data.table = merge_wake_data(data.table, result);
data.has_wake = true;
renderStatus(data);
setTimeout(() => fetchStatus(name, undefined, result), 2000); // long ass timeout
} catch (e) {
elLog.textContent = "wake error: " + e;
setPill("bad", "error");
}
}
/**
*
* @param {Array} table
* @param {Array} wake
*/
export function merge_wake_data(table, wake) {
if (!wake) return table;
if (wake.length != table.length)
throw TypeError(
"wake status table and status table not of the same length"
);
const return_array = [];
let linear_failed = false;
for (const [index, entry] of table.entries()) {
if (wake[index].ip != entry.ip || wake[index].mac != entry.mac) {
linear_failed = true;
break;
} // use alternative method
return_array.push({ wake_status: wake[index].status, ...entry });
}
// never happening AHH
if (linear_failed) {
return_array = [];
const wake_map = new Map();
wake.forEach(({ ip, mac, status }) => {
wake_map.set(JSON.stringify({ ip, mac }), status);
});
return_array = table.map((entry) => {
const { ip, mac } = entry;
return {
wake_status: wake_map.get(JSON.stringify({ ip, mac })),
...entry,
};
});
}
//
return return_array;
}
export function translate_wake_message(wake_msg) {
switch (wake_msg) {
case "incomplete":
return "Incomplete address (both ip and MAC required)"
case "succeed":
return "Wake request sent successfully"
case "nonexistent_address":
return "Errored pinging this address (nonexistent address)"
case "wrong_size":
return "Wake request malformed"
default:
return "Unknown"
}
}