more bullshit IN!
This commit is contained in:
@@ -21,3 +21,7 @@ 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
|
||||
@@ -37,6 +37,9 @@ WAKEY_HOST=git.ldlda.com WAKEY_OWNER=lda WAKEY_REPO=wakey sh /etc/ldlda_help/upd
|
||||
## Scripts overview
|
||||
|
||||
- `act_runner.ps1` — Start/seed the local Gitea runner. Use `-Attach` to see logs, `-ForceConfigure` to register non-interactively.
|
||||
- `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.
|
||||
- `cross_build.ps1` — Simple wrapper for `cargo build` per target (default: armv7-unknown-linux-musleabihf).
|
||||
- `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).
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
[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(
|
||||
[Parameter(Mandatory = $true)][string]$Exe,
|
||||
[Parameter(Mandatory = $true)][string[]]$Args,
|
||||
[Parameter(Mandatory = $true)][string]$Label
|
||||
)
|
||||
$displayArgs = @($Args)
|
||||
for ($i = 0; $i -lt $displayArgs.Count; $i++) {
|
||||
if ($displayArgs[$i] -eq '-pw' -and ($i + 1) -lt $displayArgs.Count) { $displayArgs[$i + 1] = '****' } # lowkey leaked my password
|
||||
}
|
||||
Write-Host ("[{0}] {1} {2}" -f $Label, $Exe, ($displayArgs -join ' ')) -ForegroundColor Cyan
|
||||
$out = & $Exe @Args 2>&1
|
||||
$code = $LASTEXITCODE
|
||||
if ($code -ne 0) {
|
||||
Write-Error ("{0} failed ({1}):`n{2}" -f $Label, $code, ($out -join "`n"))
|
||||
throw ("{0} failed ({1})" -f $Label, $code)
|
||||
}
|
||||
return $out
|
||||
}
|
||||
|
||||
$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)" }
|
||||
|
||||
$local = Join-Path $repoRoot ("target/" + $Target + "/release/" + $BinName)
|
||||
if (-not (Test-Path $local)) { throw "binary not found: $local" }
|
||||
|
||||
$remoteTmp = "$RemotePath.tmp"
|
||||
$destTmp = "{0}@{1}:{2}" -f $User, $HostName, $remoteTmp
|
||||
$localDeploy = Join-Path $repoRoot 'scripts/remote_deploy_wakey.sh'
|
||||
$deployTmp = '/var/tmp/remote_deploy_wakey.sh'
|
||||
$destDeployTmp = "{0}@{1}:{2}" -f $User, $HostName, $deployTmp
|
||||
$deployPreferred = '/root/.bin/remote_deploy_wakey.sh'
|
||||
|
||||
if ($Pass) {
|
||||
$pscp = Get-Command pscp.exe -ErrorAction SilentlyContinue
|
||||
if ($pscp) {
|
||||
$pscpArgs = @()
|
||||
if ($Quiet) { $pscpArgs += '-q' }
|
||||
if ($HostKey) { $pscpArgs += @('-batch', '-scp', '-hostkey', $HostKey, '-pw', $Pass, $local, $destTmp) }
|
||||
else { $pscpArgs += @('-scp', '-pw', $Pass, $local, $destTmp) }
|
||||
Invoke-Ext -Exe $pscp.Path -Args $pscpArgs -Label 'push'
|
||||
|
||||
$plink = Get-Command plink.exe -ErrorAction SilentlyContinue
|
||||
if (Test-Path $localDeploy) {
|
||||
$pscpArgsD = @()
|
||||
if ($Quiet) { $pscpArgsD += '-q' }
|
||||
if ($HostKey) { $pscpArgsD += @('-batch', '-scp', '-hostkey', $HostKey, '-pw', $Pass, $localDeploy, $destDeployTmp) }
|
||||
else { $pscpArgsD += @('-scp', '-pw', $Pass, $localDeploy, $destDeployTmp) }
|
||||
Invoke-Ext -Exe $pscp.Path -Args $pscpArgsD -Label 'push-deploy'
|
||||
}
|
||||
$remoteCmdCore = @'
|
||||
DEPLOY=$DEPLOY_PREFERRED
|
||||
if [ ! -x "$DEPLOY" ] && [ -f $DEPLOY_TMP ]; then
|
||||
sed -i "s/\r$//" $DEPLOY_TMP 2>/dev/null || true
|
||||
chmod +x $DEPLOY_TMP; DEPLOY=$DEPLOY_TMP
|
||||
fi
|
||||
sh "$DEPLOY" $REMOTE_TMP $REMOTE_PATH $DO_RESTART
|
||||
'@
|
||||
$remoteCmdCore = $remoteCmdCore.Replace('$DEPLOY_PREFERRED', $deployPreferred).Replace('$DEPLOY_TMP', $deployTmp).Replace('$REMOTE_TMP', $remoteTmp).Replace('$REMOTE_PATH', $RemotePath)
|
||||
$remoteCmdCore = if ($Restart) { $remoteCmdCore.Replace('$DO_RESTART', '1') } else { $remoteCmdCore.Replace('$DO_RESTART', '0') }
|
||||
$remoteCmdCore = ($remoteCmdCore -replace "`r", "")
|
||||
$remoteCmd = "sh -lc '$remoteCmdCore'"
|
||||
if ($Quiet) { $remoteCmd = "$remoteCmd >/dev/null 2>&1" }
|
||||
if ($plink) {
|
||||
$plinkArgs = @('-batch', '-ssh', '-pw', $Pass, "$User@$HostName", $remoteCmd)
|
||||
Invoke-Ext -Exe $plink.Path -Args $plinkArgs -Label 'ssh'
|
||||
}
|
||||
else {
|
||||
$sshArgs = @()
|
||||
if ($Quiet) { $sshArgs += '-q' }
|
||||
$sshArgs += @("$User@$HostName", $remoteCmd)
|
||||
Invoke-Ext -Exe 'ssh' -Args $sshArgs -Label 'ssh'
|
||||
}
|
||||
}
|
||||
else {
|
||||
Write-Warning "pscp.exe not found. Falling back to scp (you may be prompted for a password)."
|
||||
$scpArgs = @('-O')
|
||||
if ($Quiet) { $scpArgs += '-q' }
|
||||
$scpArgs += @($local, $destTmp)
|
||||
Invoke-Ext -Exe 'scp' -Args $scpArgs -Label 'push'
|
||||
if (Test-Path $localDeploy) {
|
||||
$scpArgsD = @('-O')
|
||||
if ($Quiet) { $scpArgsD += '-q' }
|
||||
$scpArgsD += @($localDeploy, $destDeployTmp)
|
||||
Invoke-Ext -Exe 'scp' -Args $scpArgsD -Label 'push-deploy'
|
||||
}
|
||||
$remoteCmdCore = @'
|
||||
DEPLOY=$DEPLOY_PREFERRED
|
||||
if [ ! -x "$DEPLOY" ] && [ -f $DEPLOY_TMP ]; then
|
||||
sed -i "s/\r$//" $DEPLOY_TMP 2>/dev/null || true
|
||||
chmod +x $DEPLOY_TMP; DEPLOY=$DEPLOY_TMP
|
||||
fi
|
||||
sh "$DEPLOY" $REMOTE_TMP $REMOTE_PATH $DO_RESTART
|
||||
'@
|
||||
$remoteCmdCore = $remoteCmdCore.Replace('$DEPLOY_PREFERRED', $deployPreferred).Replace('$DEPLOY_TMP', $deployTmp).Replace('$REMOTE_TMP', $remoteTmp).Replace('$REMOTE_PATH', $RemotePath)
|
||||
$remoteCmdCore = if ($Restart) { $remoteCmdCore.Replace('$DO_RESTART', '1') } else { $remoteCmdCore.Replace('$DO_RESTART', '0') }
|
||||
$remoteCmdCore = ($remoteCmdCore -replace "`r", "")
|
||||
$remoteCmd = "sh -lc '$remoteCmdCore'"
|
||||
if ($Quiet) { $remoteCmd = "$remoteCmd >/dev/null 2>&1" }
|
||||
$sshArgs = @()
|
||||
if ($Quiet) { $sshArgs += '-q' }
|
||||
$sshArgs += @("$User@$HostName", $remoteCmd)
|
||||
Invoke-Ext -Exe 'ssh' -Args $sshArgs -Label 'ssh'
|
||||
}
|
||||
}
|
||||
else {
|
||||
$scpArgs = @('-O')
|
||||
if ($Quiet) { $scpArgs += '-q' }
|
||||
$scpArgs += @($local, $destTmp)
|
||||
Invoke-Ext -Exe 'scp' -Args $scpArgs -Label 'push'
|
||||
if (Test-Path $localDeploy) {
|
||||
$scpArgsD = @('-O')
|
||||
if ($Quiet) { $scpArgsD += '-q' }
|
||||
$scpArgsD += @($localDeploy, $destDeployTmp)
|
||||
Invoke-Ext -Exe 'scp' -Args $scpArgsD -Label 'push-deploy'
|
||||
}
|
||||
$remoteCmdCore = @'
|
||||
DEPLOY=$DEPLOY_PREFERRED
|
||||
if [ ! -x "$DEPLOY" ] && [ -f $DEPLOY_TMP ]; then sed -i "s/\r$//" $DEPLOY_TMP 2>/dev/null || true; chmod +x $DEPLOY_TMP; DEPLOY=$DEPLOY_TMP; fi
|
||||
sh "$DEPLOY" $REMOTE_TMP $REMOTE_PATH $DO_RESTART
|
||||
'@
|
||||
$remoteCmdCore = $remoteCmdCore.Replace('$DEPLOY_PREFERRED', $deployPreferred).Replace('$DEPLOY_TMP', $deployTmp).Replace('$REMOTE_TMP', $remoteTmp).Replace('$REMOTE_PATH', $RemotePath)
|
||||
$remoteCmdCore = if ($Restart) { $remoteCmdCore.Replace('$DO_RESTART', '1') } else { $remoteCmdCore.Replace('$DO_RESTART', '0') }
|
||||
$remoteCmdCore = ($remoteCmdCore -replace "`r", "")
|
||||
$remoteCmd = "sh -lc '$remoteCmdCore'"
|
||||
if ($Quiet) { $remoteCmd = "$remoteCmd >/dev/null 2>&1" }
|
||||
$sshArgs = @()
|
||||
if ($Quiet) { $sshArgs += '-q' }
|
||||
$sshArgs += @("$User@$HostName", $remoteCmd)
|
||||
Invoke-Ext -Exe 'ssh' -Args $sshArgs -Label 'ssh'
|
||||
}
|
||||
|
||||
Write-Host "done ✔" -ForegroundColor Green
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
@@ -15,3 +15,9 @@ start_service() {
|
||||
stop_service() {
|
||||
: # one-shot
|
||||
}
|
||||
|
||||
# Test manually:
|
||||
# chmod +x /etc/init.d/update_wakey
|
||||
# /etc/init.d/update_wakey start
|
||||
# Or enable on boot:
|
||||
# /etc/init.d/update_wakey enable
|
||||
|
||||
@@ -15,7 +15,7 @@ fi
|
||||
kill -TERM "$pids" 2>/dev/null || true
|
||||
|
||||
# Optional: hard kill if still alive after a short grace
|
||||
sleep 1 # uhhh sleep is stupid
|
||||
usleep 250000 # uhhh sleep is stupid
|
||||
remain=""
|
||||
for p in $pids; do
|
||||
kill -0 "$p" 2>/dev/null && remain="$remain $p"
|
||||
@@ -16,7 +16,9 @@
|
||||
set -eu
|
||||
|
||||
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; }
|
||||
@@ -90,12 +92,32 @@ 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"; 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; 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
|
||||
chmod +x /etc/init.d/kill_wakey.sh || true
|
||||
/etc/init.d/wakey enable || true
|
||||
/etc/init.d/wakey restart || /etc/init.d/wakey start || true
|
||||
fi
|
||||
|
||||
@@ -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,13 +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
|
||||
|
||||
if (-not $NoBin) {
|
||||
if (Test-Path $binSrc) {
|
||||
Copy-Item $binSrc (Join-Path $rootDir "wakey") -Force
|
||||
Copy-Item (Join-Path $root 'scripts/kill.sh') (Join-Path $rootDir "kill_wakey.sh") -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
|
||||
@@ -43,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,5 +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)" # }
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
#!/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
|
||||
+1
-17
@@ -10,8 +10,8 @@
|
||||
|
||||
use std::{net::IpAddr, str::FromStr};
|
||||
|
||||
use r#impl::ser_opm;
|
||||
use macaddr::MacAddr;
|
||||
use serde::{Deserialize, Serialize, Serializer, de};
|
||||
use serde_with::skip_serializing_none;
|
||||
use strum::{Display, EnumString};
|
||||
|
||||
@@ -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)]
|
||||
|
||||
+20
-1
@@ -1,4 +1,7 @@
|
||||
use serde::{Deserialize, Deserializer, de};
|
||||
//! r#impl AHHHHHH
|
||||
|
||||
use macaddr::MacAddr;
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
|
||||
|
||||
use super::NUDState;
|
||||
|
||||
@@ -12,3 +15,19 @@ impl<'de> Deserialize<'de> for NUDState {
|
||||
s.parse().map_err(de::Error::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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
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");
|
||||
@@ -1,3 +1,45 @@
|
||||
/// 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 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};
|
||||
|
||||
+1
-1
@@ -4,9 +4,9 @@ use axum::{
|
||||
};
|
||||
use tokio::net::TcpListener;
|
||||
mod arpparse;
|
||||
pub mod assets;
|
||||
mod dhcpparse;
|
||||
mod route;
|
||||
pub mod r#static;
|
||||
mod utils;
|
||||
use std::io;
|
||||
|
||||
|
||||
+4
-4
@@ -2,7 +2,7 @@ pub mod api;
|
||||
|
||||
pub use crate::route::api::{DeviceQuery, api_router};
|
||||
use crate::{
|
||||
r#static as st,
|
||||
assets,
|
||||
utils::{ping::_ping_ip, wake::wake},
|
||||
};
|
||||
|
||||
@@ -17,7 +17,7 @@ use axum_extra::extract::Query;
|
||||
use crate::{MACHINE_NAME, utils::_status_build};
|
||||
|
||||
pub async fn home_2() -> Html<&'static str> {
|
||||
Html(st::HOME_2)
|
||||
Html(assets::HOME_2)
|
||||
}
|
||||
async fn home_2_css() -> impl IntoResponse {
|
||||
(
|
||||
@@ -25,7 +25,7 @@ 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,
|
||||
assets::HOME_2_CSS,
|
||||
)
|
||||
}
|
||||
async fn home_2_js() -> impl IntoResponse {
|
||||
@@ -34,7 +34,7 @@ async fn home_2_js() -> impl IntoResponse {
|
||||
(header::CONTENT_TYPE, "application/javascript"),
|
||||
(header::CACHE_CONTROL, "public, max-age=300"),
|
||||
],
|
||||
st::HOME_2_JS,
|
||||
assets::HOME_2_JS,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+49
-15
@@ -1,4 +1,4 @@
|
||||
use crate::utils::de_many;
|
||||
use crate::utils::parse::{boolish_str, de_many, serialize_macs};
|
||||
use std::collections::HashSet;
|
||||
use std::net::IpAddr;
|
||||
|
||||
@@ -11,7 +11,6 @@ use axum::{
|
||||
};
|
||||
use axum_extra::extract::Query;
|
||||
use macaddr::MacAddr;
|
||||
use serde::ser::Serializer;
|
||||
use serde_with::skip_serializing_none;
|
||||
|
||||
use crate::{
|
||||
@@ -25,9 +24,18 @@ use crate::{
|
||||
|
||||
// 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();
|
||||
let s = if cfg!(feature = "very-smart-parsing") {
|
||||
crate::utils::parse::extract_host(&q)
|
||||
} else {
|
||||
q.trim()
|
||||
};
|
||||
// 1) IP
|
||||
if let Ok(ip) = s.parse::<IpAddr>() {
|
||||
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 {
|
||||
return Redirect::to(&format!("/api/status?ip={ip}"));
|
||||
}
|
||||
// 2) MAC
|
||||
@@ -46,7 +54,7 @@ pub async fn status_smart_redirect(Path(q): Path<String>) -> Redirect {
|
||||
if tokio::net::lookup_host((s, 0)).await.is_ok() {
|
||||
return Redirect::to(&format!("/api/status?name={}", urlencoding::encode(s)));
|
||||
}
|
||||
// Default: name last
|
||||
// Default: name last // it will fail also
|
||||
Redirect::to(&format!("/api/status?name={}", urlencoding::encode(s)))
|
||||
}
|
||||
|
||||
@@ -54,9 +62,42 @@ pub async fn devs_router() -> Json<Vec<String>> {
|
||||
dev::devs_sorted().into()
|
||||
}
|
||||
|
||||
async fn get_dhcp_leases() -> impl IntoResponse {
|
||||
match dhcpparse::read_dhcp_leases().await {
|
||||
Ok(leases) => (StatusCode::OK, Json(leases)).into_response(),
|
||||
#[derive(Debug, Default, Clone, serde::Deserialize)]
|
||||
struct DhcpLeasesQueryRaw {
|
||||
include_state: Option<String>,
|
||||
}
|
||||
|
||||
// #[skip_serializing_none]
|
||||
// #[derive(Debug, Clone, serde::Serialize)]
|
||||
// struct DhcpLeaseOut {
|
||||
// expires_epoch: u64,
|
||||
// ip: IpAddr,
|
||||
// #[serde(serialize_with = "serialize_mac")]
|
||||
// mac: macaddr::MacAddr,
|
||||
// // #[serde(skip_serializing_if = "Option::is_none")]
|
||||
// name: Option<String>,
|
||||
// // extras
|
||||
// // #[serde(skip_serializing_if = "Option::is_none")]
|
||||
// nud_state: Option<NUDState>,
|
||||
// // #[serde(skip_serializing_if = "Option::is_none")]
|
||||
// rank: Option<u8>,
|
||||
// }
|
||||
|
||||
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 {
|
||||
@@ -110,13 +151,6 @@ pub struct Filters {
|
||||
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 {
|
||||
|
||||
+1
-52
@@ -8,71 +8,20 @@
|
||||
/// 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 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;
|
||||
|
||||
// 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;
|
||||
|
||||
#[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(crate) mod parse;
|
||||
|
||||
pub async fn _status_build(machine_name: &str) -> String {
|
||||
let formatted_macs = match _get_macs_2_1(machine_name).await {
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
/// 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()
|
||||
}
|
||||
|
||||
use macaddr::MacAddr;
|
||||
use serde::Serializer;
|
||||
/// 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 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)
|
||||
}
|
||||
|
||||
pub fn serialize_mac<S: serde::Serializer>(m: &macaddr::MacAddr, s: S) -> Result<S::Ok, S::Error> {
|
||||
s.serialize_str(&m.to_string())
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
+15
-1
@@ -1,12 +1,14 @@
|
||||
// this ENTIRE file is redundant... or?
|
||||
|
||||
use std::time::Duration;
|
||||
use std::{net::IpAddr, time::Duration};
|
||||
|
||||
use tokio::{
|
||||
net::{TcpStream, ToSocketAddrs},
|
||||
time::timeout,
|
||||
};
|
||||
|
||||
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
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
+66
-9
@@ -1,17 +1,76 @@
|
||||
use std::{collections::HashSet, net::IpAddr};
|
||||
use crate::arpparse::NUDState;
|
||||
use crate::dhcpparse::DhcpLeaseLine;
|
||||
use crate::utils::parse::serialize_mac;
|
||||
use std::net::IpAddr;
|
||||
|
||||
#[skip_serializing_none]
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct DhcpLeaseOut {
|
||||
pub expires_epoch: u64,
|
||||
pub ip: IpAddr,
|
||||
#[serde(serialize_with = "serialize_mac")]
|
||||
pub mac: macaddr::MacAddr,
|
||||
pub name: Option<String>,
|
||||
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::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(|l| DhcpLeaseOut {
|
||||
expires_epoch: l.expires_epoch,
|
||||
ip: l.ip,
|
||||
mac: l.mac,
|
||||
name: l.name,
|
||||
nud_state: map.get(&l.ip).map(|(s, _)| *s),
|
||||
rank: map.get(&l.ip).map(|(_, r)| *r),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
use std::collections::HashSet;
|
||||
|
||||
use macaddr::MacAddr;
|
||||
use serde_with::skip_serializing_none;
|
||||
// use tokio::io;
|
||||
|
||||
use crate::{
|
||||
arpparse::{self, IpNeighLine, NUDState},
|
||||
arpparse::{self, IpNeighLine},
|
||||
utils::{
|
||||
cmd::exec_command,
|
||||
error::{self, Error, Result},
|
||||
get_ips,
|
||||
},
|
||||
};
|
||||
|
||||
/// 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 async fn _get_macs_2_1(machine_name: &str) -> Result<HashSet<(IpAddr, MacAddr, NUDState)>> {
|
||||
Ok(get_macs_1(machine_name)
|
||||
.await?
|
||||
@@ -92,7 +151,7 @@ pub async fn get_macs(
|
||||
|
||||
// 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()));
|
||||
// let nud_arg = 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()];
|
||||
@@ -141,7 +200,7 @@ pub async fn get_macs(
|
||||
}
|
||||
|
||||
pub mod dev {
|
||||
use std::{collections::HashSet, sync::LazyLock};
|
||||
use std::collections::HashSet;
|
||||
|
||||
pub fn get_dev() -> HashSet<String> {
|
||||
// Prefer /sys/class/net, fallback to /proc/net/dev; filter out loopback
|
||||
@@ -169,15 +228,13 @@ pub mod dev {
|
||||
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();
|
||||
let mut v: Vec<String> = get_dev().into_iter().collect();
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
|
||||
pub fn has_dev(name: &str) -> bool {
|
||||
DEVS.contains(name)
|
||||
get_dev().contains(name)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,39 @@ 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);
|
||||
}
|
||||
|
||||
+158
-4
@@ -5,8 +5,21 @@ const elCheck = $("check");
|
||||
const elWake = $("wake");
|
||||
const elLog = $("log");
|
||||
const elHtml = $("html");
|
||||
const elLeases = $("leases_html");
|
||||
const pill = $("status-pill");
|
||||
const link = $("permalink");
|
||||
const elPreview = $("preview");
|
||||
|
||||
// One-time delegated click handler for all pickable links
|
||||
function handlePickClick(e) {
|
||||
const a = e.target && /** @type {HTMLElement} */ (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 setPill(kind, text) {
|
||||
pill.className = `pill ${kind}`;
|
||||
@@ -22,6 +35,46 @@ function setLink(name) {
|
||||
function getName() {
|
||||
return (elName.value || "").trim();
|
||||
}
|
||||
function extractHostLikeBackend(input) {
|
||||
let s = String(input || "").trim();
|
||||
if (!s) return "";
|
||||
// strip scheme or network-path reference
|
||||
const schemeIdx = s.indexOf("://");
|
||||
if (schemeIdx >= 0) s = s.slice(schemeIdx + 3);
|
||||
else if (s.startsWith("//")) s = s.slice(2);
|
||||
// strip userinfo
|
||||
const at = s.lastIndexOf("@");
|
||||
if (at >= 0) s = s.slice(at + 1);
|
||||
// bracketed IPv6
|
||||
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();
|
||||
}
|
||||
function updatePreview() {
|
||||
if (!elPreview) return;
|
||||
const raw = getName();
|
||||
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);
|
||||
fetchStatus(v);
|
||||
}
|
||||
function saveName(name) {
|
||||
try {
|
||||
localStorage.setItem("wakey:name", name);
|
||||
@@ -51,9 +104,15 @@ function rankState(s) {
|
||||
}
|
||||
|
||||
function buildStatusUrl(name) {
|
||||
// If no extra filters, use smart redirect for name/ip/mac/dev/nud detection
|
||||
const hasExtraFilters = ["ip", "dev", "nud", "mac"].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);
|
||||
// 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);
|
||||
@@ -64,12 +123,35 @@ function buildStatusUrl(name) {
|
||||
/** @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.className = "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>`;
|
||||
const ip = row.ip || "";
|
||||
const mac = row.mac || "";
|
||||
const dev = row.dev || "";
|
||||
const state = row.state || "";
|
||||
tr.innerHTML = `
|
||||
<td>${
|
||||
ip
|
||||
? `<a href="#" class="pick" data-value="${ip}" title="filter by ip">${ip}</a>`
|
||||
: ""
|
||||
}</td>
|
||||
<td>${
|
||||
mac
|
||||
? `<a href="#" class="pick" data-value="${mac}" title="filter by mac">${mac}</a>`
|
||||
: ""
|
||||
}</td>
|
||||
<td>${
|
||||
state
|
||||
? `<a href="#" class="pick" data-value="${state}" title="filter by state">${state}</a>`
|
||||
: ""
|
||||
}</td>
|
||||
<td>${
|
||||
dev
|
||||
? `<a href="#" class="pick" data-value="${dev}" title="filter by interface">${dev}</a>`
|
||||
: ""
|
||||
}</td>`;
|
||||
tbl.appendChild(tr);
|
||||
}
|
||||
elHtml.innerHTML = "";
|
||||
@@ -93,6 +175,8 @@ function renderStatus(data) {
|
||||
}
|
||||
elHtml.appendChild(tbl);
|
||||
|
||||
// click-to-filter handled by a single, persistent delegated listener (set once at load)
|
||||
|
||||
if ((data.table || []).length > 0) {
|
||||
const best = data.table.reduce((a, b) =>
|
||||
rankState(b.state) > rankState(a.state) ? b : a
|
||||
@@ -134,6 +218,72 @@ async function fetchStatus(name) {
|
||||
}
|
||||
}
|
||||
|
||||
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.innerHTML = `<tr><th></th><th>IP</th><th>MAC</th><th>Name</th><th>Expires</th></tr>`;
|
||||
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";
|
||||
} 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>
|
||||
<td>${
|
||||
ip
|
||||
? `<a href="#" class="pick" data-value="${ip}" title="filter by ip">${ip}</a>`
|
||||
: ""
|
||||
}</td>
|
||||
<td>${
|
||||
mac
|
||||
? `<a href="#" class="pick" data-value="${mac}" title="filter by mac">${mac}</a>`
|
||||
: ""
|
||||
}</td>
|
||||
<td>${
|
||||
name
|
||||
? `<a href="#" class="pick" data-value="${name}" title="filter by name">${name}</a>`
|
||||
: ""
|
||||
}</td>
|
||||
<td><span class="tiny">${whenText}</span></td>`;
|
||||
tbl.appendChild(tr);
|
||||
}
|
||||
elLeases.innerHTML = "";
|
||||
elLeases.appendChild(tbl);
|
||||
// click-to-filter handled by a single, persistent delegated listener (set once at load)
|
||||
}
|
||||
|
||||
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 {}
|
||||
}
|
||||
|
||||
async function sendWake(name) {
|
||||
setPill("warn", "waking…");
|
||||
elLog.textContent = "POST /wake?name=" + name;
|
||||
@@ -169,14 +319,18 @@ elWake.addEventListener("click", () => {
|
||||
elName.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") elCheck.click();
|
||||
});
|
||||
elName.addEventListener("input", updatePreview);
|
||||
|
||||
// init
|
||||
const initial = loadName();
|
||||
if (initial) {
|
||||
elName.value = initial;
|
||||
updatePreview();
|
||||
setLink(initial); // when the permalink doing YOUR job
|
||||
// auto-check on load in this A/B page
|
||||
fetchStatus(initial);
|
||||
fetchLeases();
|
||||
} else {
|
||||
setPill("warn", "unknown");
|
||||
fetchLeases();
|
||||
}
|
||||
|
||||
+15
-7
@@ -3,14 +3,14 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>wakey • home_2</title>
|
||||
<title>wakey • home</title>
|
||||
<link rel="stylesheet" href="/home_2.css" />
|
||||
<script type="module" src="/home_2.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>
|
||||
|
||||
Reference in New Issue
Block a user