move things around do some BullShit
This commit is contained in:
Generated
+1
@@ -1187,6 +1187,7 @@ dependencies = [
|
|||||||
"futures",
|
"futures",
|
||||||
"macaddr",
|
"macaddr",
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde_html_form",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_with",
|
"serde_with",
|
||||||
"strum",
|
"strum",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ color-eyre = "0.6.5"
|
|||||||
futures = "0.3.31"
|
futures = "0.3.31"
|
||||||
macaddr = { version = "1.0.1", features = ["serde", "serde_std"] }
|
macaddr = { version = "1.0.1", features = ["serde", "serde_std"] }
|
||||||
serde = { version = "1.0.219", features = ["derive"] }
|
serde = { version = "1.0.219", features = ["derive"] }
|
||||||
|
serde_html_form = "0.2.7"
|
||||||
serde_json = "1.0.143"
|
serde_json = "1.0.143"
|
||||||
serde_with = { version = "3.14.0", features = ["json"] }
|
serde_with = { version = "3.14.0", features = ["json"] }
|
||||||
strum = { version = "0.27.2", features = ["derive", "strum_macros"] }
|
strum = { version = "0.27.2", features = ["derive", "strum_macros"] }
|
||||||
|
|||||||
+76
-111
@@ -15,141 +15,106 @@ $ErrorActionPreference = 'Stop'
|
|||||||
try { $PSStyle.OutputRendering = 'Host' } catch {}
|
try { $PSStyle.OutputRendering = 'Host' } catch {}
|
||||||
|
|
||||||
function Invoke-Ext {
|
function Invoke-Ext {
|
||||||
param(
|
param($Exe, $Arguments, $Label)
|
||||||
[Parameter(Mandatory = $true)][string]$Exe,
|
$displayArgs = $Arguments.Clone()
|
||||||
[Parameter(Mandatory = $true)][string[]]$Args,
|
|
||||||
[Parameter(Mandatory = $true)][string]$Label
|
|
||||||
)
|
|
||||||
$displayArgs = @($Args)
|
|
||||||
for ($i = 0; $i -lt $displayArgs.Count; $i++) {
|
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
|
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
|
Write-Host ("[{0}] {1} {2}" -f $Label, $Exe, ($displayArgs -join ' ')) -ForegroundColor Cyan
|
||||||
$out = & $Exe @Args 2>&1
|
|
||||||
$code = $LASTEXITCODE
|
$out = & $Exe @Arguments 2>&1
|
||||||
if ($code -ne 0) {
|
if ($LASTEXITCODE -ne 0) {
|
||||||
Write-Error ("{0} failed ({1}):`n{2}" -f $Label, $code, ($out -join "`n"))
|
Write-Error ("{0} failed ({1}):`n{2}" -f $Label, $LASTEXITCODE, ($out -join "`n"))
|
||||||
throw ("{0} failed ({1})" -f $Label, $code)
|
throw ("{0} failed ({1})" -f $Label, $LASTEXITCODE)
|
||||||
}
|
}
|
||||||
return $out
|
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
|
$repoRoot = Split-Path -Parent $PSScriptRoot
|
||||||
Push-Location $repoRoot
|
Push-Location $repoRoot
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Write-Host "[build] cargo build --release --target $Target" -ForegroundColor Cyan
|
Write-Host "[build] cargo build --release --target $Target" -ForegroundColor Cyan
|
||||||
cargo build --release --target $Target
|
cargo build --release --target $Target
|
||||||
if ($LASTEXITCODE -ne 0) { throw "cargo build failed ($LASTEXITCODE)" }
|
if ($LASTEXITCODE -ne 0) { throw "cargo build failed ($LASTEXITCODE)" }
|
||||||
|
|
||||||
$local = Join-Path $repoRoot ("target/" + $Target + "/release/" + $BinName)
|
$localBin = Join-Path $repoRoot "target/$Target/release/$BinName"
|
||||||
if (-not (Test-Path $local)) { throw "binary not found: $local" }
|
if (-not (Test-Path $localBin)) { throw "binary not found: $localBin" }
|
||||||
|
|
||||||
$remoteTmp = "$RemotePath.tmp"
|
$remoteTmp = "$RemotePath.tmp"
|
||||||
$destTmp = "{0}@{1}:{2}" -f $User, $HostName, $remoteTmp
|
$destTmp = "$User@${HostName}:$remoteTmp"
|
||||||
$localDeploy = Join-Path $repoRoot 'scripts/remote_deploy_wakey.sh'
|
$localDeploy = Join-Path $repoRoot 'scripts/remote_deploy_wakey.sh'
|
||||||
$deployTmp = '/var/tmp/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'
|
$deployPreferred = '/root/.bin/remote_deploy_wakey.sh'
|
||||||
|
|
||||||
if ($Pass) {
|
# Push main binary
|
||||||
$pscp = Get-Command pscp.exe -ErrorAction SilentlyContinue
|
Invoke-Scp -Local $localBin -Dest $destTmp -Pass $Pass -HostKey $HostKey -Quiet:$Quiet
|
||||||
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
|
# Push deploy helper if exists
|
||||||
if (Test-Path $localDeploy) {
|
if (Test-Path $localDeploy) {
|
||||||
$pscpArgsD = @()
|
Invoke-Scp -Local $localDeploy -Dest "$User@${HostName}:$deployTmp" -Pass $Pass -HostKey $HostKey -Quiet:$Quiet
|
||||||
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'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# 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
|
Write-Host "done ✔" -ForegroundColor Green
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
#!/bin/sh /etc/rc.common
|
#!/bin/sh /etc/rc.common
|
||||||
|
START=66
|
||||||
START=66 # Run after the resolvconf swap
|
|
||||||
USE_PROCD=1
|
USE_PROCD=1
|
||||||
|
|
||||||
|
FLAG=/var/wakey_updated.flag
|
||||||
|
|
||||||
start_service() {
|
start_service() {
|
||||||
|
[ -f "$FLAG" ] && return
|
||||||
|
|
||||||
procd_open_instance
|
procd_open_instance
|
||||||
procd_set_param command /etc/ldlda_help/update_wakey.sh
|
procd_set_param command /etc/ldlda_help/update_wakey.sh
|
||||||
procd_set_param respawn 0 0 0
|
procd_set_param respawn 0 0 0
|
||||||
@@ -13,11 +16,9 @@ start_service() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
stop_service() {
|
stop_service() {
|
||||||
: # one-shot
|
: # no-op
|
||||||
}
|
}
|
||||||
|
|
||||||
# Test manually:
|
post_service() {
|
||||||
# chmod +x /etc/init.d/update_wakey
|
touch "$FLAG"
|
||||||
# /etc/init.d/update_wakey start
|
}
|
||||||
# Or enable on boot:
|
|
||||||
# /etc/init.d/update_wakey enable
|
|
||||||
|
|||||||
@@ -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}")
|
||||||
@@ -45,6 +45,8 @@ if ($Publish) {
|
|||||||
$pubArgs = @('publish')
|
$pubArgs = @('publish')
|
||||||
if ($Registry) { $pubArgs += @('--registry', $Registry) }
|
if ($Registry) { $pubArgs += @('--registry', $Registry) }
|
||||||
cargo @pubArgs
|
cargo @pubArgs
|
||||||
|
|
||||||
|
Write-Host ("published. ran cargo {0}" -f ($pubArgs -join " "))
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
Write-Warning ("cargo publish failed: {0}" -f $_)
|
Write-Warning ("cargo publish failed: {0}" -f $_)
|
||||||
@@ -63,4 +65,6 @@ if ($Tag -and $Version) {
|
|||||||
}
|
}
|
||||||
git tag -f "v$Version"
|
git tag -f "v$Version"
|
||||||
git push -f origin "v$Version"
|
git push -f origin "v$Version"
|
||||||
|
|
||||||
|
Write-Host "Pushed tag: $Version."
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -10,14 +10,14 @@
|
|||||||
|
|
||||||
use std::{net::IpAddr, str::FromStr};
|
use std::{net::IpAddr, str::FromStr};
|
||||||
|
|
||||||
use r#impl::ser_opm;
|
use impls::ser_opm;
|
||||||
use macaddr::MacAddr;
|
use macaddr::MacAddr;
|
||||||
use serde_with::skip_serializing_none;
|
use serde_with::skip_serializing_none;
|
||||||
use strum::{Display, EnumString};
|
use strum::{Display, EnumString};
|
||||||
|
|
||||||
use crate::arpparse::error::IPNeighParseError;
|
use crate::arpparse::error::IPNeighParseError;
|
||||||
mod error;
|
mod error;
|
||||||
mod r#impl; // custom (de)serialization impls
|
mod impls; // custom (de)serialization impls
|
||||||
|
|
||||||
/// ip neigh has some cool shit.
|
/// ip neigh has some cool shit.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
//! r#impl AHHHHHH
|
|
||||||
|
|
||||||
use macaddr::MacAddr;
|
|
||||||
use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
|
|
||||||
|
|
||||||
use super::NUDState;
|
|
||||||
|
|
||||||
// Case-insensitive parsing for NUDState via manual Deserialize
|
|
||||||
impl<'de> Deserialize<'de> for NUDState {
|
|
||||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
|
||||||
where
|
|
||||||
D: Deserializer<'de>,
|
|
||||||
{
|
|
||||||
let s: &str = <&str as Deserialize>::deserialize(deserializer)?;
|
|
||||||
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,19 @@
|
|||||||
|
//! r#impl AHHHHHH
|
||||||
|
|
||||||
|
use serde::{Deserialize, Deserializer, de};
|
||||||
|
|
||||||
|
use super::NUDState;
|
||||||
|
|
||||||
|
// Case-insensitive parsing for NUDState via manual Deserialize
|
||||||
|
impl<'de> Deserialize<'de> for NUDState {
|
||||||
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let s: &str = <&str as Deserialize>::deserialize(deserializer)?;
|
||||||
|
s.parse().map_err(de::Error::custom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(unused_imports)]
|
||||||
|
pub use crate::utils::parse::mac::{des_opm, ser_opm};
|
||||||
+12
-3
@@ -1,3 +1,12 @@
|
|||||||
pub const HOME_2: &str = include_str!("../static/home_2.html");
|
// generated with ./scripts/map_static.py
|
||||||
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");
|
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");
|
||||||
|
}
|
||||||
|
|||||||
+34
-31
@@ -1,8 +1,12 @@
|
|||||||
pub mod api;
|
pub mod api;
|
||||||
|
pub mod devs;
|
||||||
|
pub mod dhcp;
|
||||||
|
pub mod status;
|
||||||
|
pub mod wake;
|
||||||
|
|
||||||
pub use crate::route::api::{DeviceQuery, api_router};
|
pub use crate::route::api::{DeviceQuery, api_router};
|
||||||
use crate::{
|
use crate::{
|
||||||
assets,
|
assets::{self},
|
||||||
utils::{ping::_ping_ip, wake::wake},
|
utils::{ping::_ping_ip, wake::wake},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -14,38 +18,9 @@ use axum::{
|
|||||||
};
|
};
|
||||||
use axum_extra::extract::Query;
|
use axum_extra::extract::Query;
|
||||||
|
|
||||||
|
use crate::utils::route::serve_js;
|
||||||
use crate::{MACHINE_NAME, utils::_status_build};
|
use crate::{MACHINE_NAME, utils::_status_build};
|
||||||
|
|
||||||
pub async fn home_2() -> Html<&'static str> {
|
|
||||||
Html(assets::HOME_2)
|
|
||||||
}
|
|
||||||
async fn home_2_css() -> impl IntoResponse {
|
|
||||||
(
|
|
||||||
[
|
|
||||||
(header::CONTENT_TYPE, "text/css; charset=utf-8"),
|
|
||||||
(header::CACHE_CONTROL, "public, max-age=300"),
|
|
||||||
],
|
|
||||||
assets::HOME_2_CSS,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
async fn home_2_js() -> impl IntoResponse {
|
|
||||||
(
|
|
||||||
[
|
|
||||||
(header::CONTENT_TYPE, "application/javascript"),
|
|
||||||
(header::CACHE_CONTROL, "public, max-age=300"),
|
|
||||||
],
|
|
||||||
assets::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
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn wake_handler(
|
pub async fn wake_handler(
|
||||||
Query(DeviceQuery { name, .. }): Query<DeviceQuery>,
|
Query(DeviceQuery { name, .. }): Query<DeviceQuery>,
|
||||||
) -> axum::response::Result<impl IntoResponse> {
|
) -> axum::response::Result<impl IntoResponse> {
|
||||||
@@ -94,3 +69,31 @@ pub async fn _home() -> Html<String> {
|
|||||||
// }
|
// }
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
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)))
|
||||||
|
}
|
||||||
|
|||||||
+19
-216
@@ -1,231 +1,33 @@
|
|||||||
use crate::utils::parse::{boolish_str, de_many, serialize_macs};
|
|
||||||
use std::collections::HashSet;
|
|
||||||
use std::net::IpAddr;
|
|
||||||
|
|
||||||
use axum::{
|
use axum::Json;
|
||||||
Json, Router,
|
use axum::http::StatusCode;
|
||||||
extract::Path,
|
use axum::response::IntoResponse;
|
||||||
http::StatusCode,
|
use axum::routing::post;
|
||||||
response::{IntoResponse, Redirect},
|
use axum::{Router, extract::Path, response::Redirect, routing::get};
|
||||||
routing::get,
|
|
||||||
};
|
|
||||||
use axum_extra::extract::Query;
|
|
||||||
use macaddr::MacAddr;
|
|
||||||
use serde_with::skip_serializing_none;
|
|
||||||
|
|
||||||
use crate::{
|
use crate::utils::route;
|
||||||
arpparse::{self, NUDState},
|
|
||||||
dhcpparse,
|
|
||||||
utils::query::{
|
|
||||||
dev::{self, has_dev},
|
|
||||||
get_macs,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Smart redirect: accept IP, MAC, dev, or NUD state and redirect to /api/status accordingly
|
// 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 {
|
pub async fn status_smart_redirect(
|
||||||
let s = if cfg!(feature = "very-smart-parsing") {
|
Path(q): Path<String>,
|
||||||
crate::utils::parse::extract_host(&q)
|
) -> axum::response::Result<Redirect, impl IntoResponse> {
|
||||||
} else {
|
match serde_html_form::to_string(route::status_smart_redirect(q).await) {
|
||||||
q.trim()
|
Ok(e) => Ok(Redirect::to(&format!("/api/status?{e}"))),
|
||||||
};
|
Err(e) => Err((
|
||||||
// 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 {
|
|
||||||
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 // it will fail also
|
|
||||||
Redirect::to(&format!("/api/status?name={}", urlencoding::encode(s)))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn devs_router() -> Json<Vec<String>> {
|
|
||||||
dev::devs_sorted().into()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Default, Clone, serde::Deserialize)]
|
|
||||||
struct DhcpLeasesQueryRaw {
|
|
||||||
include_state: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
StatusCode::BAD_GATEWAY,
|
||||||
Json(StatusError {
|
Json(StatusError {
|
||||||
name: None,
|
|
||||||
error: e.to_string(),
|
error: e.to_string(),
|
||||||
|
..Default::default()
|
||||||
}),
|
}),
|
||||||
)
|
)),
|
||||||
.into_response(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default, Clone, Hash, serde::Deserialize)]
|
pub use crate::route::devs::*;
|
||||||
pub struct DeviceQuery {
|
pub use crate::route::dhcp::*;
|
||||||
pub name: Option<String>,
|
pub use crate::route::status::*;
|
||||||
// Accept single or many; ignore blanks
|
pub use crate::route::wake::*;
|
||||||
#[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>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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 status_redirect(Path(NamePath { name }): Path<NamePath>) -> Redirect {
|
pub async fn status_redirect(Path(NamePath { name }): Path<NamePath>) -> Redirect {
|
||||||
Redirect::permanent(&format!(
|
Redirect::permanent(&format!(
|
||||||
"/api/status?name={name}",
|
"/api/status?name={name}",
|
||||||
@@ -240,4 +42,5 @@ pub fn api_router() -> Router {
|
|||||||
.route("/dhcp_leases", get(get_dhcp_leases))
|
.route("/dhcp_leases", get(get_dhcp_leases))
|
||||||
.route("/smart/{q}", get(status_smart_redirect))
|
.route("/smart/{q}", get(status_smart_redirect))
|
||||||
.route("/devs", get(devs_router))
|
.route("/devs", get(devs_router))
|
||||||
|
.route("/wake", post(wake_multi))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
use crate::{
|
||||||
|
arpparse::{IpNeighLine, NUDState},
|
||||||
|
utils::parse::{de_many, serialize_macs},
|
||||||
|
};
|
||||||
|
use axum::{Json, http::StatusCode, response::IntoResponse};
|
||||||
|
use axum_extra::extract::Query;
|
||||||
|
use macaddr::MacAddr;
|
||||||
|
use serde::Serialize;
|
||||||
|
use serde_with::skip_serializing_none;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::net::IpAddr;
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Clone, Hash, serde::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, serde::Deserialize)]
|
||||||
|
pub struct NamePath {
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[skip_serializing_none]
|
||||||
|
#[derive(Debug, Default, serde::Serialize)]
|
||||||
|
pub struct Status {
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub table: Vec<IpNeighLine>,
|
||||||
|
pub filters: Filters,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, serde::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, serde::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,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
//! impls are at [`utils::wake::impl`](crate::utils::wake::r#impl) for some reason
|
||||||
|
use std::io;
|
||||||
|
use std::net::IpAddr;
|
||||||
|
|
||||||
|
use crate::utils::parse::mac::{des_opm, ser_opm};
|
||||||
|
use crate::utils::wake::wake_one;
|
||||||
|
use axum::{extract::Json, http::StatusCode, response::IntoResponse};
|
||||||
|
use macaddr::MacAddr;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_with::skip_serializing_none;
|
||||||
|
|
||||||
|
#[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 {
|
||||||
|
pub ip: Option<IpAddr>,
|
||||||
|
#[serde(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 = tokio::net::UdpSocket::bind("0.0.0.0: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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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");
|
|
||||||
+2
-2
@@ -11,13 +11,13 @@ pub mod wake;
|
|||||||
|
|
||||||
use crate::utils::query::_get_macs_2_1;
|
use crate::utils::query::_get_macs_2_1;
|
||||||
|
|
||||||
|
pub mod cmd;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
/// generic so you can do "123.45.67.89:22" or "lda.lan:22" as an input
|
/// generic so you can do "123.45.67.89:22" or "lda.lan:22" as an input
|
||||||
// this is so bad
|
// this is so bad
|
||||||
pub mod ping;
|
pub mod ping;
|
||||||
|
|
||||||
pub mod cmd;
|
|
||||||
pub mod query;
|
pub mod query;
|
||||||
|
pub mod route;
|
||||||
|
|
||||||
// no custom ip deserializer needed when using axum_extra::extract::Query
|
// no custom ip deserializer needed when using axum_extra::extract::Query
|
||||||
// but we add a generic one to ignore blanks and accept OneOrMany
|
// but we add a generic one to ignore blanks and accept OneOrMany
|
||||||
|
|||||||
+49
-14
@@ -58,8 +58,6 @@ pub fn extract_host(input: &str) -> &str {
|
|||||||
s.trim()
|
s.trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
use macaddr::MacAddr;
|
|
||||||
use serde::Serializer;
|
|
||||||
/// key for yes: "1" | "true" | "yes" | "on" | "y"
|
/// key for yes: "1" | "true" | "yes" | "on" | "y"
|
||||||
///
|
///
|
||||||
/// frfr
|
/// frfr
|
||||||
@@ -102,18 +100,6 @@ pub fn boolish_str(s: &str) -> bool {
|
|||||||
&& t.parse::<u64>().map(|n| n != 0).unwrap_or(false))
|
&& 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 {
|
pub mod de_many {
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde::de;
|
use serde::de;
|
||||||
@@ -153,3 +139,52 @@ pub mod de_many {
|
|||||||
Ok(out)
|
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::*;
|
||||||
|
|||||||
+5
-239
@@ -1,240 +1,6 @@
|
|||||||
use crate::arpparse::NUDState;
|
pub mod dev;
|
||||||
use crate::dhcpparse::DhcpLeaseLine;
|
pub mod leases;
|
||||||
use crate::utils::parse::serialize_mac;
|
pub mod macs;
|
||||||
use std::net::IpAddr;
|
|
||||||
|
|
||||||
#[skip_serializing_none]
|
pub use leases::*;
|
||||||
#[derive(Debug, Clone, serde::Serialize)]
|
pub use macs::*;
|
||||||
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},
|
|
||||||
utils::{
|
|
||||||
cmd::exec_command,
|
|
||||||
error::{self, Error, Result},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/// 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?
|
|
||||||
.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 = 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;
|
|
||||||
|
|
||||||
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 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
use crate::arpparse::NUDState;
|
||||||
|
use crate::dhcpparse::DhcpLeaseLine;
|
||||||
|
use crate::utils::parse::serialize_mac;
|
||||||
|
use serde_with::skip_serializing_none;
|
||||||
|
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::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(|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()
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
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<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?
|
||||||
|
.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)
|
||||||
|
.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?.into_iter().collect(),
|
||||||
|
(None, None) => Vec::new(),
|
||||||
|
};
|
||||||
|
let nud_arg = state.map(NUDState::as_ip_neigh_arg);
|
||||||
|
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?;
|
||||||
|
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);
|
||||||
|
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 !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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
|
pub mod impls;
|
||||||
use std::{io, net::IpAddr};
|
use std::{io, net::IpAddr};
|
||||||
|
|
||||||
|
use macaddr::MacAddr;
|
||||||
use tokio::net::UdpSocket;
|
use tokio::net::UdpSocket;
|
||||||
|
|
||||||
use crate::utils::query::get_macs_2_mac;
|
use crate::utils::query::get_macs_2_mac;
|
||||||
@@ -30,3 +32,67 @@ pub async fn wake(machine_name: &str) -> io::Result<u32> {
|
|||||||
}
|
}
|
||||||
Ok(sent_ok)
|
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.0.0.0: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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,336 +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 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}`;
|
|
||||||
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 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);
|
|
||||||
} 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) {
|
|
||||||
// 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);
|
|
||||||
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.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 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 = "";
|
|
||||||
// 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);
|
|
||||||
|
|
||||||
// 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
|
|
||||||
);
|
|
||||||
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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
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();
|
|
||||||
});
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
+2
-2
@@ -4,8 +4,8 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>wakey • home</title>
|
<title>wakey • home</title>
|
||||||
<link rel="stylesheet" href="/home_2.css" />
|
<link rel="stylesheet" href="home_2/styles.css" />
|
||||||
<script type="module" src="/home_2.js" defer></script>
|
<script type="module" src="home_2/main.js" defer></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
export const qs = new URLSearchParams(location.search);
|
||||||
|
|
||||||
|
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) {
|
||||||
|
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();
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
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.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
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);
|
||||||
|
fetchStatus(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
// events
|
||||||
|
elCheck.addEventListener("click", () => {
|
||||||
|
const name = getName(elName);
|
||||||
|
if (!name) return;
|
||||||
|
saveName(name);
|
||||||
|
setLink(name);
|
||||||
|
fetchStatus(name);
|
||||||
|
});
|
||||||
|
elWake.addEventListener("click", () => {
|
||||||
|
const name = getName(elName);
|
||||||
|
if (!name) return;
|
||||||
|
saveName(name);
|
||||||
|
setLink(name);
|
||||||
|
sendWake(name);
|
||||||
|
});
|
||||||
|
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();
|
||||||
|
setLink(initial);
|
||||||
|
fetchStatus(initial);
|
||||||
|
fetchLeases();
|
||||||
|
} else {
|
||||||
|
setPill("warn", "unknown");
|
||||||
|
fetchLeases();
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { elHtml, elLog, setPill, qs } from "./dom.js";
|
||||||
|
import { rankState } from "./utils.js";
|
||||||
|
|
||||||
|
function buildStatusUrl(name) {
|
||||||
|
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);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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");
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td>${
|
||||||
|
row.ip
|
||||||
|
? `<a href="#" class="pick" data-value="${row.ip}" title="filter by ip">${row.ip}</a>`
|
||||||
|
: ""
|
||||||
|
}</td>
|
||||||
|
<td>${
|
||||||
|
row.mac
|
||||||
|
? `<a href="#" class="pick" data-value="${row.mac}" title="filter by mac">${row.mac}</a>`
|
||||||
|
: ""
|
||||||
|
}</td>
|
||||||
|
<td>${
|
||||||
|
row.state
|
||||||
|
? `<a href="#" class="pick" data-value="${row.state}" title="filter by state">${row.state}</a>`
|
||||||
|
: ""
|
||||||
|
}</td>
|
||||||
|
<td>${
|
||||||
|
row.dev
|
||||||
|
? `<a href="#" class="pick" data-value="${row.dev}" title="filter by interface">${row.dev}</a>`
|
||||||
|
: ""
|
||||||
|
}</td>`;
|
||||||
|
tbl.appendChild(tr);
|
||||||
|
}
|
||||||
|
|
||||||
|
elHtml.innerHTML = "";
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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}`;
|
||||||
|
setPill("bad", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await r.json();
|
||||||
|
renderStatus(data);
|
||||||
|
} catch (e) {
|
||||||
|
elLog.textContent = "status error: " + e;
|
||||||
|
setPill("bad", "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { elLog, setPill } from "./dom.js";
|
||||||
|
import { fetchStatus } from "./status.js";
|
||||||
|
|
||||||
|
export async function sendWake(name) {
|
||||||
|
setPill("warn", "waking…");
|
||||||
|
elLog.textContent = "POST /wake?name=" + name;
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/wake?name=${encodeURIComponent(name)}`, {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
const t = await r.text();
|
||||||
|
elLog.textContent = t || "ok";
|
||||||
|
setTimeout(() => fetchStatus(name), 1500);
|
||||||
|
} catch (e) {
|
||||||
|
elLog.textContent = "wake error: " + e;
|
||||||
|
setPill("bad", "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user