Compare commits
26
Commits
v0.1.1
...
1307cec52b
No files matched your search
File renamed without changes.
@@ -1,5 +1,8 @@
|
||||
you can use block code as so:
|
||||
you should use block code to refence code, like so:
|
||||
```rust
|
||||
hello_world!(println);
|
||||
// indented lines
|
||||
```
|
||||
in the chat.
|
||||
in the chat. Else it fucks up formatting.
|
||||
|
||||
We work towards clean, maintainable design. im not the best at this thing.
|
||||
Vendored
+2
-1
@@ -1,3 +1,4 @@
|
||||
{
|
||||
"rust-analyzer.cargo.target": "armv7-unknown-linux-musleabihf"
|
||||
"rust-analyzer.cargo.target": "armv7-unknown-linux-musleabihf",
|
||||
"rust-analyzer.diagnostics.disabled": ["unlinked-file"]
|
||||
}
|
||||
Generated
+2
-1
@@ -1179,7 +1179,7 @@ checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
||||
|
||||
[[package]]
|
||||
name = "wakey"
|
||||
version = "0.1.1"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"axum-extra",
|
||||
@@ -1187,6 +1187,7 @@ dependencies = [
|
||||
"futures",
|
||||
"macaddr",
|
||||
"serde",
|
||||
"serde_html_form",
|
||||
"serde_json",
|
||||
"serde_with",
|
||||
"strum",
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "wakey"
|
||||
version = "0.1.1"
|
||||
version = "0.1.5"
|
||||
edition = "2024"
|
||||
publish = ["gitea"]
|
||||
|
||||
@@ -11,6 +11,7 @@ color-eyre = "0.6.5"
|
||||
futures = "0.3.31"
|
||||
macaddr = { version = "1.0.1", features = ["serde", "serde_std"] }
|
||||
serde = { version = "1.0.219", features = ["derive"] }
|
||||
serde_html_form = "0.2.7"
|
||||
serde_json = "1.0.143"
|
||||
serde_with = { version = "3.14.0", features = ["json"] }
|
||||
strum = { version = "0.27.2", features = ["derive", "strum_macros"] }
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# taking back my code AHHHH
|
||||
|
||||
this is going to be lengthy. ci is spotless but idk how it works cuz powershell. this is good but i cant afford VPN on the router; we will need to do some bullshit.
|
||||
|
||||
## overview
|
||||
|
||||
we need a:
|
||||
|
||||
- small ahh bin in the router.
|
||||
- forwards a shell and some functions.
|
||||
- may be shell calling fns maybe some unsafe c. i cant unsafe c.
|
||||
- big ahh bin in the vps.
|
||||
- connect to the small ahh bin thru the wide ass internet.
|
||||
- forwards some APIs.
|
||||
- static site
|
||||
- now need no hosts; apis can be called anywhere anyway.
|
||||
- do offer these in the big ahh bins tho lets not miss out some shit.
|
||||
- automation
|
||||
- gitea
|
||||
- on the vps or the uh idk.
|
||||
- ABSOLUTELY can auto build main:latest every day / other day.
|
||||
- and releases.
|
||||
- router
|
||||
- a script to pull the tar. from actions artifacts or from releases. (lua or shell yo)
|
||||
- a script for the bin and the init.d scripts for these two.
|
||||
- vps
|
||||
- a systemd service (ima use ai for ts ngl)
|
||||
- an auto updater. maybe we overkill and dnf. maybe go easy and git clone + cargo install. maybe we pull from release or nightly. idk
|
||||
|
||||
i think idk
|
||||
|
||||
## router
|
||||
|
||||
### current
|
||||
|
||||
everything.
|
||||
|
||||
we have some functions that call ip neigh a bajillion times.
|
||||
we have some other functions that read the same file/folder in /sys or sum shit.
|
||||
we have a function that broadcast to a set ahh address.
|
||||
|
||||
we have some routes; and some data structures acting as schema for them ahh. we dont do posts with json but gets with query strings for some ahh reasons.
|
||||
|
||||
these are carryover from the early days.
|
||||
|
||||
also from the early days are the js.
|
||||
|
||||
static was 100% ai with 0 intervention.
|
||||
|
||||
we have a search bar of sort that hooks through a smart endpoint that searches for everything.
|
||||
|
||||
we have some bullshit. and two buttons to check and wake. that pings `/api/smart/{query}` and `/wake?name=query`. why.
|
||||
we have the result of the wake and or status and the errors in the first text box.
|
||||
|
||||
the list of cliets with names connected to the router the second box.
|
||||
thats cool.
|
||||
|
||||
### changes?
|
||||
|
||||
idk changes.
|
||||
+25
-21
@@ -6,34 +6,34 @@ This folder has small helpers for build, CI, and router install. Keep it simple;
|
||||
|
||||
1. Start your Gitea runner on this Windows PC (if not already running):
|
||||
|
||||
```powershell
|
||||
./scripts/act_runner.ps1
|
||||
```
|
||||
```powershell
|
||||
./scripts/act_runner.ps1
|
||||
```
|
||||
|
||||
If first-time, it prints the exact register command. Run it once, check labels include `windows:host,self-hosted`, then rerun the script.
|
||||
If first-time, it prints the exact register command. Run it once, check labels include `windows:host,self-hosted`, then rerun the script.
|
||||
|
||||
2. Publish, tag, and push (recommended):
|
||||
|
||||
```powershell
|
||||
./scripts/publish.ps1 -Tag
|
||||
```
|
||||
```powershell
|
||||
./scripts/publish.ps1 -Tag
|
||||
```
|
||||
|
||||
This will bump the version (if you specify one), build, ensure the runner is started, tag, and push. To also publish to the registry, add `-Publish`.
|
||||
This will bump the version (if you specify one), build, ensure the runner is started, tag, and push. To also publish to the registry, add `-Publish`.
|
||||
|
||||
3. Install on the router
|
||||
|
||||
Use the release asset URL from your Gitea Release page:
|
||||
Use the updater on the router to fetch the latest automatically:
|
||||
|
||||
```sh
|
||||
wget -O- https://<gitea>/<owner>/<repo>/releases/download/v0.1.0/wakey-rootfs-v0.1.0-armv7-unknown-linux-musleabihf.tgz | tar -xz -C /
|
||||
chmod +x /etc/init.d/wakey && /etc/init.d/wakey enable && /etc/init.d/wakey restart
|
||||
```
|
||||
```sh
|
||||
WAKEY_HOST=git.ldlda.com WAKEY_OWNER=lda WAKEY_REPO=wakey sh /etc/ldlda_help/update_wakey.sh
|
||||
```
|
||||
|
||||
Alternatively, use the updater on the router to fetch the latest automatically:
|
||||
Alternatively, use the release asset URL from your Gitea Release page:
|
||||
|
||||
```sh
|
||||
WAKEY_HOST=git.ldlda.com WAKEY_OWNER=lda WAKEY_REPO=wakey sh /etc/ldlda_help/update_wakey.sh
|
||||
```
|
||||
```sh
|
||||
wget -O- https://<gitea>/<owner>/<repo>/releases/download/v0.1.0/wakey-rootfs-v0.1.0-armv7-unknown-linux-musleabihf.tgz | tar -xz -C /
|
||||
chmod +x /etc/init.d/wakey && /etc/init.d/wakey enable && /etc/init.d/wakey restart
|
||||
```
|
||||
|
||||
## Scripts overview
|
||||
|
||||
@@ -41,9 +41,7 @@ This folder has small helpers for build, CI, and router install. Keep it simple;
|
||||
- `dev_push.ps1` — Fast dev loop: build + upload to router. Usage:
|
||||
- `./scripts/dev_push.ps1 -Pass <password> [-HostName <ip>] [-RemotePath </root/.bin/wakey>] [-Restart] [-Quiet]`
|
||||
- Uploads to `<RemotePath>.tmp` then atomically moves into place; `-Restart` restarts the service; `-Quiet` silences MOTD.
|
||||
- `cross_build.ps1` — Simple wrapper for `cargo build` per target (default: armv7-unknown-linux-musleabihf).
|
||||
- `package_rootfs.ps1` — Produces `dist/wakey-rootfs-<version>-<target>.tgz` with `/root/.bin/wakey` and `/etc/init.d/*`.
|
||||
- `package.ps1` — Dev bundle with binaries + init scripts (not a rootfs layout).
|
||||
- `publish.ps1` — Optional version bump + build + tag (and `cargo publish` only if you pass `-Publish`).
|
||||
|
||||
## CI (Gitea)
|
||||
@@ -56,8 +54,8 @@ This folder has small helpers for build, CI, and router install. Keep it simple;
|
||||
## Local build/install (manual path)
|
||||
|
||||
```powershell
|
||||
# Build (requires cross installed)
|
||||
./scripts/cross_build.ps1 -Release -Targets armv7-unknown-linux-musleabihf
|
||||
# Build
|
||||
cargo build --release --target armv7-unknown-linux-musleabihf
|
||||
|
||||
# Package rootfs
|
||||
./scripts/package_rootfs.ps1 -Version v0.1.0 -Target armv7-unknown-linux-musleabihf
|
||||
@@ -72,4 +70,10 @@ tar -xz -f /tmp/wakey.tgz -C /
|
||||
chmod +x /etc/init.d/wakey && /etc/init.d/wakey enable && /etc/init.d/wakey restart
|
||||
```
|
||||
|
||||
Or you can use the script:
|
||||
|
||||
```powershell
|
||||
./scripts/dev_push.ps1 -Pass <password>
|
||||
```
|
||||
|
||||
That’s it. Keep the flow: start runner → tag push → install.
|
||||
+11
-2
@@ -38,7 +38,10 @@ if (-not (Test-Path $configFile)) {
|
||||
}
|
||||
if ($ForceConfigure) {
|
||||
Write-Host "Config not found. Initializing non-interactively (ForceConfigure)..."
|
||||
$configParent = Split-Path -Parent $configFile
|
||||
Push-Location $configParent
|
||||
& $RunnerPath register --no-interactive --config $configFile --instance $ServerUrl --token $Token --labels $Labels
|
||||
Pop-Location
|
||||
}
|
||||
else {
|
||||
Write-Host "Config not found. Run this manually once (note labels embed host executor on Windows):"
|
||||
@@ -49,9 +52,15 @@ if (-not (Test-Path $configFile)) {
|
||||
|
||||
if ($Attach) {
|
||||
Write-Host "Starting act_runner (attached)... Ctrl+C to stop"
|
||||
$configParent = Split-Path -Parent $configFile
|
||||
Push-Location $configParent
|
||||
& $RunnerPath daemon --config $configFile
|
||||
Pop-Location
|
||||
}
|
||||
else {
|
||||
Start-Process -FilePath $RunnerPath -ArgumentList @("daemon", "--config", $configFile) -WindowStyle Hidden
|
||||
Write-Host "act_runner started."
|
||||
$configParent = Split-Path -Parent $configFile
|
||||
Push-Location $configParent
|
||||
$proc = Start-Process -FilePath $RunnerPath -ArgumentList @("daemon", "--config", $configFile) -WindowStyle Hidden -PassThru
|
||||
Pop-Location
|
||||
Write-Host "act_runner started (PID=$($proc.Id))."
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
# Build release binaries using cross for Linux targets.
|
||||
# Requires: cross installed (cargo install cross)
|
||||
param(
|
||||
[string[]]$Targets = @("armv7-unknown-linux-musleabihf"),
|
||||
[switch]$Release
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$mode = if ($Release) { "--release" } else { "" }
|
||||
|
||||
foreach ($t in $Targets) {
|
||||
Write-Host "Building for $t..."
|
||||
cross build $mode --target $t
|
||||
}
|
||||
|
||||
Write-Host "Done."
|
||||
+77
-112
@@ -15,141 +15,106 @@ $ErrorActionPreference = 'Stop'
|
||||
try { $PSStyle.OutputRendering = 'Host' } catch {}
|
||||
|
||||
function Invoke-Ext {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Exe,
|
||||
[Parameter(Mandatory = $true)][string[]]$Args,
|
||||
[Parameter(Mandatory = $true)][string]$Label
|
||||
)
|
||||
$displayArgs = @($Args)
|
||||
param($Exe, $Arguments, $Label)
|
||||
$displayArgs = $Arguments.Clone()
|
||||
for ($i = 0; $i -lt $displayArgs.Count; $i++) {
|
||||
if ($displayArgs[$i] -eq '-pw' -and ($i + 1) -lt $displayArgs.Count) { $displayArgs[$i + 1] = '****' } # 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
|
||||
$out = & $Exe @Args 2>&1
|
||||
$code = $LASTEXITCODE
|
||||
if ($code -ne 0) {
|
||||
Write-Error ("{0} failed ({1}):`n{2}" -f $Label, $code, ($out -join "`n"))
|
||||
throw ("{0} failed ({1})" -f $Label, $code)
|
||||
|
||||
$out = & $Exe @Arguments 2>&1
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error ("{0} failed ({1}):`n{2}" -f $Label, $LASTEXITCODE, ($out -join "`n"))
|
||||
throw ("{0} failed ({1})" -f $Label, $LASTEXITCODE)
|
||||
}
|
||||
return $out
|
||||
}
|
||||
|
||||
function Get-DeployScript {
|
||||
param($DeployPreferred, $DeployTmp, $RemoteTmp, $RemotePath, $RestartFlag)
|
||||
return @"
|
||||
DEPLOY=$DeployPreferred;
|
||||
if [ ! -x "`$DEPLOY" ] && [ -f $DeployTmp ]; then
|
||||
sed -i "s/\r$//" $DeployTmp 2>/dev/null || true
|
||||
chmod +x $DeployTmp
|
||||
DEPLOY=$DeployTmp
|
||||
fi
|
||||
sh "`$DEPLOY" $RemoteTmp $RemotePath $RestartFlag
|
||||
"@ # -replace "`r", "" -replace "`r`n", "`n"
|
||||
|
||||
}
|
||||
|
||||
function Invoke-Scp {
|
||||
param($Local, $Dest, $Pass, $HostKey, [switch]$Quiet)
|
||||
if ($pscp = Get-Command pscp.exe -ErrorAction SilentlyContinue) {
|
||||
$arguments = @('-scp')
|
||||
if ($Quiet) { $arguments += '-q' }
|
||||
if ($HostKey) { $arguments += @('-batch', '-hostkey', $HostKey) }
|
||||
if ($Pass) { $arguments += @('-pw', $Pass) }
|
||||
$arguments += @($Local, $Dest)
|
||||
Invoke-Ext -Exe $pscp.Path -Arguments $arguments -Label 'scp'
|
||||
}
|
||||
else {
|
||||
$arguments = @('-O')
|
||||
if ($Quiet) { $arguments += '-q' }
|
||||
$arguments += @($Local, $Dest)
|
||||
Invoke-Ext -Exe 'scp' -Arguments $arguments -Label 'scp'
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-Ssh {
|
||||
param($Cmd, $User, $Remote, $Pass, [switch]$Quiet)
|
||||
$Cmd = $Cmd -replace "`r`n", "`n" -replace "`r", ""
|
||||
if ($plink = Get-Command plink.exe -ErrorAction SilentlyContinue) {
|
||||
$arguments = @('-batch', '-ssh')
|
||||
if ($Pass) { $arguments += @('-pw', $Pass) }
|
||||
$arguments += "$User@$Remote", $Cmd
|
||||
Invoke-Ext -Exe $plink.Path -Arguments $arguments -Label 'ssh'
|
||||
}
|
||||
else {
|
||||
$arguments = @()
|
||||
if ($Quiet) { $arguments += '-q' }
|
||||
$arguments += "$User@$Remote", $Cmd
|
||||
Invoke-Ext -Exe 'ssh' -Arguments $arguments -Label 'ssh'
|
||||
}
|
||||
}
|
||||
|
||||
#---- Main Flow ----
|
||||
$repoRoot = Split-Path -Parent $PSScriptRoot
|
||||
Push-Location $repoRoot
|
||||
|
||||
try {
|
||||
Write-Host "[build] cargo build --release --target $Target" -ForegroundColor Cyan
|
||||
cargo build --release --target $Target
|
||||
if ($LASTEXITCODE -ne 0) { throw "cargo build failed ($LASTEXITCODE)" }
|
||||
|
||||
$local = Join-Path $repoRoot ("target/" + $Target + "/release/" + $BinName)
|
||||
if (-not (Test-Path $local)) { throw "binary not found: $local" }
|
||||
$localBin = Join-Path $repoRoot "target/$Target/release/$BinName"
|
||||
if (-not (Test-Path $localBin)) { throw "binary not found: $localBin" }
|
||||
|
||||
$remoteTmp = "$RemotePath.tmp"
|
||||
$destTmp = "{0}@{1}:{2}" -f $User, $HostName, $remoteTmp
|
||||
$destTmp = "$User@${HostName}:$remoteTmp"
|
||||
$localDeploy = Join-Path $repoRoot 'scripts/remote_deploy_wakey.sh'
|
||||
$deployTmp = '/var/tmp/remote_deploy_wakey.sh'
|
||||
$destDeployTmp = "{0}@{1}:{2}" -f $User, $HostName, $deployTmp
|
||||
$deployPreferred = '/root/.bin/remote_deploy_wakey.sh'
|
||||
|
||||
if ($Pass) {
|
||||
$pscp = Get-Command pscp.exe -ErrorAction SilentlyContinue
|
||||
if ($pscp) {
|
||||
$pscpArgs = @()
|
||||
if ($Quiet) { $pscpArgs += '-q' }
|
||||
if ($HostKey) { $pscpArgs += @('-batch', '-scp', '-hostkey', $HostKey, '-pw', $Pass, $local, $destTmp) }
|
||||
else { $pscpArgs += @('-scp', '-pw', $Pass, $local, $destTmp) }
|
||||
Invoke-Ext -Exe $pscp.Path -Args $pscpArgs -Label 'push'
|
||||
# Push main binary
|
||||
Invoke-Scp -Local $localBin -Dest $destTmp -Pass $Pass -HostKey $HostKey -Quiet:$Quiet
|
||||
|
||||
$plink = Get-Command plink.exe -ErrorAction SilentlyContinue
|
||||
if (Test-Path $localDeploy) {
|
||||
$pscpArgsD = @()
|
||||
if ($Quiet) { $pscpArgsD += '-q' }
|
||||
if ($HostKey) { $pscpArgsD += @('-batch', '-scp', '-hostkey', $HostKey, '-pw', $Pass, $localDeploy, $destDeployTmp) }
|
||||
else { $pscpArgsD += @('-scp', '-pw', $Pass, $localDeploy, $destDeployTmp) }
|
||||
Invoke-Ext -Exe $pscp.Path -Args $pscpArgsD -Label 'push-deploy'
|
||||
}
|
||||
$remoteCmdCore = @'
|
||||
DEPLOY=$DEPLOY_PREFERRED
|
||||
if [ ! -x "$DEPLOY" ] && [ -f $DEPLOY_TMP ]; then
|
||||
sed -i "s/\r$//" $DEPLOY_TMP 2>/dev/null || true
|
||||
chmod +x $DEPLOY_TMP; DEPLOY=$DEPLOY_TMP
|
||||
fi
|
||||
sh "$DEPLOY" $REMOTE_TMP $REMOTE_PATH $DO_RESTART
|
||||
'@
|
||||
$remoteCmdCore = $remoteCmdCore.Replace('$DEPLOY_PREFERRED', $deployPreferred).Replace('$DEPLOY_TMP', $deployTmp).Replace('$REMOTE_TMP', $remoteTmp).Replace('$REMOTE_PATH', $RemotePath)
|
||||
$remoteCmdCore = if ($Restart) { $remoteCmdCore.Replace('$DO_RESTART', '1') } else { $remoteCmdCore.Replace('$DO_RESTART', '0') }
|
||||
$remoteCmdCore = ($remoteCmdCore -replace "`r", "")
|
||||
$remoteCmd = "sh -lc '$remoteCmdCore'"
|
||||
if ($Quiet) { $remoteCmd = "$remoteCmd >/dev/null 2>&1" }
|
||||
if ($plink) {
|
||||
$plinkArgs = @('-batch', '-ssh', '-pw', $Pass, "$User@$HostName", $remoteCmd)
|
||||
Invoke-Ext -Exe $plink.Path -Args $plinkArgs -Label 'ssh'
|
||||
}
|
||||
else {
|
||||
$sshArgs = @()
|
||||
if ($Quiet) { $sshArgs += '-q' }
|
||||
$sshArgs += @("$User@$HostName", $remoteCmd)
|
||||
Invoke-Ext -Exe 'ssh' -Args $sshArgs -Label 'ssh'
|
||||
}
|
||||
}
|
||||
else {
|
||||
Write-Warning "pscp.exe not found. Falling back to scp (you may be prompted for a password)."
|
||||
$scpArgs = @('-O')
|
||||
if ($Quiet) { $scpArgs += '-q' }
|
||||
$scpArgs += @($local, $destTmp)
|
||||
Invoke-Ext -Exe 'scp' -Args $scpArgs -Label 'push'
|
||||
if (Test-Path $localDeploy) {
|
||||
$scpArgsD = @('-O')
|
||||
if ($Quiet) { $scpArgsD += '-q' }
|
||||
$scpArgsD += @($localDeploy, $destDeployTmp)
|
||||
Invoke-Ext -Exe 'scp' -Args $scpArgsD -Label 'push-deploy'
|
||||
}
|
||||
$remoteCmdCore = @'
|
||||
DEPLOY=$DEPLOY_PREFERRED
|
||||
if [ ! -x "$DEPLOY" ] && [ -f $DEPLOY_TMP ]; then
|
||||
sed -i "s/\r$//" $DEPLOY_TMP 2>/dev/null || true
|
||||
chmod +x $DEPLOY_TMP; DEPLOY=$DEPLOY_TMP
|
||||
fi
|
||||
sh "$DEPLOY" $REMOTE_TMP $REMOTE_PATH $DO_RESTART
|
||||
'@
|
||||
$remoteCmdCore = $remoteCmdCore.Replace('$DEPLOY_PREFERRED', $deployPreferred).Replace('$DEPLOY_TMP', $deployTmp).Replace('$REMOTE_TMP', $remoteTmp).Replace('$REMOTE_PATH', $RemotePath)
|
||||
$remoteCmdCore = if ($Restart) { $remoteCmdCore.Replace('$DO_RESTART', '1') } else { $remoteCmdCore.Replace('$DO_RESTART', '0') }
|
||||
$remoteCmdCore = ($remoteCmdCore -replace "`r", "")
|
||||
$remoteCmd = "sh -lc '$remoteCmdCore'"
|
||||
if ($Quiet) { $remoteCmd = "$remoteCmd >/dev/null 2>&1" }
|
||||
$sshArgs = @()
|
||||
if ($Quiet) { $sshArgs += '-q' }
|
||||
$sshArgs += @("$User@$HostName", $remoteCmd)
|
||||
Invoke-Ext -Exe 'ssh' -Args $sshArgs -Label 'ssh'
|
||||
}
|
||||
}
|
||||
else {
|
||||
$scpArgs = @('-O')
|
||||
if ($Quiet) { $scpArgs += '-q' }
|
||||
$scpArgs += @($local, $destTmp)
|
||||
Invoke-Ext -Exe 'scp' -Args $scpArgs -Label 'push'
|
||||
if (Test-Path $localDeploy) {
|
||||
$scpArgsD = @('-O')
|
||||
if ($Quiet) { $scpArgsD += '-q' }
|
||||
$scpArgsD += @($localDeploy, $destDeployTmp)
|
||||
Invoke-Ext -Exe 'scp' -Args $scpArgsD -Label 'push-deploy'
|
||||
}
|
||||
$remoteCmdCore = @'
|
||||
DEPLOY=$DEPLOY_PREFERRED
|
||||
if [ ! -x "$DEPLOY" ] && [ -f $DEPLOY_TMP ]; then sed -i "s/\r$//" $DEPLOY_TMP 2>/dev/null || true; chmod +x $DEPLOY_TMP; DEPLOY=$DEPLOY_TMP; fi
|
||||
sh "$DEPLOY" $REMOTE_TMP $REMOTE_PATH $DO_RESTART
|
||||
'@
|
||||
$remoteCmdCore = $remoteCmdCore.Replace('$DEPLOY_PREFERRED', $deployPreferred).Replace('$DEPLOY_TMP', $deployTmp).Replace('$REMOTE_TMP', $remoteTmp).Replace('$REMOTE_PATH', $RemotePath)
|
||||
$remoteCmdCore = if ($Restart) { $remoteCmdCore.Replace('$DO_RESTART', '1') } else { $remoteCmdCore.Replace('$DO_RESTART', '0') }
|
||||
$remoteCmdCore = ($remoteCmdCore -replace "`r", "")
|
||||
$remoteCmd = "sh -lc '$remoteCmdCore'"
|
||||
if ($Quiet) { $remoteCmd = "$remoteCmd >/dev/null 2>&1" }
|
||||
$sshArgs = @()
|
||||
if ($Quiet) { $sshArgs += '-q' }
|
||||
$sshArgs += @("$User@$HostName", $remoteCmd)
|
||||
Invoke-Ext -Exe 'ssh' -Args $sshArgs -Label 'ssh'
|
||||
# Push deploy helper if exists
|
||||
if (Test-Path $localDeploy) {
|
||||
Invoke-Scp -Local $localDeploy -Dest "$User@${HostName}:$deployTmp" -Pass $Pass -HostKey $HostKey -Quiet:$Quiet
|
||||
}
|
||||
|
||||
# Build and run remote deploy command
|
||||
$restartFlag = $(if ($Restart) { '1' } else { '0' })
|
||||
$script = Get-DeployScript $deployPreferred $deployTmp $remoteTmp $RemotePath $restartFlag
|
||||
$remoteCmd = "sh -lc '$($script -replace "`r",'')'"
|
||||
if ($Quiet) { $remoteCmd += " >/dev/null 2>&1" }
|
||||
|
||||
Invoke-Ssh -Cmd $remoteCmd -User $User -Remote $HostName -Pass $Pass -Quiet:$Quiet
|
||||
|
||||
Write-Host "done ✔" -ForegroundColor Green
|
||||
}
|
||||
finally {
|
||||
|
||||
@@ -7,18 +7,25 @@
|
||||
# Default-Stop:
|
||||
# Short-Description: Pre-flight Tailscale update
|
||||
|
||||
START=50 # Run before Tailscale (typically START~80)
|
||||
USE_PROCD=1
|
||||
START=50 # Run before Tailscale (typically START~80)
|
||||
|
||||
start_service() {
|
||||
procd_open_instance
|
||||
procd_set_param command /etc/ldlda_help/update_tailscale.sh
|
||||
procd_set_param respawn 0 0 0
|
||||
procd_set_param stdout 1
|
||||
procd_set_param stderr 1
|
||||
procd_close_instance
|
||||
}
|
||||
start() {
|
||||
LOGFILE=/var/log/update_tailscale.log
|
||||
MAX_RETRIES=10
|
||||
RETRY_COUNT=0
|
||||
|
||||
stop_service() {
|
||||
: # one-shot
|
||||
# shellcheck disable=SC2016
|
||||
sh -c '
|
||||
while [ "$RETRY_COUNT" -lt "$MAX_RETRIES" ]; do
|
||||
if fn; then
|
||||
echo "[update_tailscale] Success at $(date)" >>"$LOGFILE"
|
||||
exit 0
|
||||
fi
|
||||
RETRY_COUNT=$((RETRY_COUNT + 1))
|
||||
echo "[update_tailscale] Retry $RETRY_COUNT at $(date)" >>"$LOGFILE"
|
||||
sleep 5
|
||||
done
|
||||
echo "[update_tailscale] Too many retries. Exiting at $(date)" >>"$LOGFILE"
|
||||
exit 1
|
||||
' >>"$LOGFILE" 2>&1 &
|
||||
}
|
||||
@@ -1,23 +1,23 @@
|
||||
#!/bin/sh /etc/rc.common
|
||||
START=66
|
||||
|
||||
START=66 # Run after the resolvconf swap
|
||||
USE_PROCD=1
|
||||
start() {
|
||||
LOGFILE=/var/log/update_wakey.log
|
||||
MAX_RETRIES=10
|
||||
RETRY_COUNT=0
|
||||
|
||||
start_service() {
|
||||
procd_open_instance
|
||||
procd_set_param command /etc/ldlda_help/update_wakey.sh
|
||||
procd_set_param respawn 0 0 0
|
||||
procd_set_param stdout 1
|
||||
procd_set_param stderr 1
|
||||
procd_close_instance
|
||||
# shellcheck disable=SC2016
|
||||
sh -c '
|
||||
while [ "$RETRY_COUNT" -lt "$MAX_RETRIES" ]; do
|
||||
if /etc/ldlda_help/update_wakey.sh; then
|
||||
echo "[update_wakey] Success at $(date)" >>"$LOGFILE"
|
||||
exit 0
|
||||
fi
|
||||
RETRY_COUNT=$((RETRY_COUNT + 1))
|
||||
echo "[update_wakey] Retry $RETRY_COUNT at $(date)" >>"$LOGFILE"
|
||||
sleep 5
|
||||
done
|
||||
echo "[update_wakey] Too many retries. Exiting at $(date)" >>"$LOGFILE"
|
||||
exit 1
|
||||
' >>"$LOGFILE" 2>&1 &
|
||||
}
|
||||
|
||||
stop_service() {
|
||||
: # one-shot
|
||||
}
|
||||
|
||||
# Test manually:
|
||||
# chmod +x /etc/init.d/update_wakey
|
||||
# /etc/init.d/update_wakey start
|
||||
# Or enable on boot:
|
||||
# /etc/init.d/update_wakey enable
|
||||
@@ -8,6 +8,11 @@ elif [ -f /rom/etc/resolv.conf ]; then
|
||||
cp -af /rom/etc/resolv.conf /etc/resolv.conf
|
||||
fi
|
||||
|
||||
if [ -x "$(which tailscale)" ]; then
|
||||
tailscale update
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 1. Fetch the latest version number from Tailscale's site
|
||||
LATEST=$(curl -s https://pkgs.tailscale.com/stable/ | grep -Eo 'tailscale_[0-9\.]+_arm.tgz' | head -n1)
|
||||
|
||||
@@ -20,7 +25,6 @@ URL="https://pkgs.tailscale.com/stable/${LATEST}"
|
||||
|
||||
cd /var/tmp
|
||||
|
||||
|
||||
if [ -f "$LATEST" ]; then
|
||||
echo "file exists"
|
||||
rm "$LATEST"
|
||||
|
||||
@@ -15,13 +15,18 @@
|
||||
|
||||
set -eu
|
||||
|
||||
# reboot == new file.
|
||||
FLAG=/var/wakey_updated.flag
|
||||
ARCH=${WAKEY_ARCH:-armv7-unknown-linux-musleabihf}
|
||||
TMPDIR="/var/tmp"
|
||||
TMPFILE="$TMPDIR/wakey-rootfs.$$.$ARCH.tgz"
|
||||
STAGING="$TMPDIR/wakey-rootfs.$$.$ARCH"
|
||||
|
||||
log() { echo "[update_wakey] $*"; }
|
||||
fail() { echo "[update_wakey] ERROR: $*" >&2; exit 1; }
|
||||
fail() {
|
||||
echo "[update_wakey] ERROR: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
http_get() {
|
||||
# http_get <url>
|
||||
@@ -65,13 +70,14 @@ latest_asset_url() {
|
||||
API="https://$HOST/api/v1/repos/$OWNER/$REPO/releases/latest"
|
||||
RESP=$(http_get "$API") || return 1
|
||||
# Extract the first browser_download_url ending with our ARCH .tgz (BusyBox-friendly)
|
||||
echo "$RESP" \
|
||||
| grep -o '"browser_download_url"[[:space:]]*:[[:space:]]*"[^"]*'"$ARCH"'\.tgz"' \
|
||||
| head -n1 \
|
||||
| cut -d '"' -f 4
|
||||
echo "$RESP" |
|
||||
grep -o '"browser_download_url"[[:space:]]*:[[:space:]]*"[^"]*'"$ARCH"'\.tgz"' |
|
||||
head -n1 |
|
||||
cut -d '"' -f 4
|
||||
}
|
||||
|
||||
main() {
|
||||
[ -f "$FLAG" ] && return
|
||||
URL="${WAKEY_TGZ_URL:-}"
|
||||
if [ -z "$URL" ]; then
|
||||
HOST=${WAKEY_HOST:-git.ldlda.com}
|
||||
@@ -108,7 +114,8 @@ main() {
|
||||
# Normalize line endings for shell scripts (avoid CRLF issues on OpenWrt)
|
||||
for f in \
|
||||
"$STAGING/etc/init.d/"* \
|
||||
"$STAGING/etc/ldlda_help/"*.sh; do
|
||||
"$STAGING/etc/ldlda_help/"*.sh \
|
||||
"$STAGING/root/.bin/"*.sh; do
|
||||
[ -f "$f" ] && sed -i 's/\r$//' "$f" 2>/dev/null || true
|
||||
done
|
||||
|
||||
@@ -122,6 +129,7 @@ main() {
|
||||
/etc/init.d/wakey enable || true
|
||||
/etc/init.d/wakey restart || /etc/init.d/wakey start || true
|
||||
fi
|
||||
touch "$FLAG"
|
||||
log "done"
|
||||
}
|
||||
|
||||
|
||||
@@ -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}")
|
||||
@@ -1,53 +0,0 @@
|
||||
# Package a release tarball with binaries and init scripts.
|
||||
# Usage: ./scripts/package.ps1 -Version 0.1.0 -OutDir dist
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Version,
|
||||
[string]$OutDir = "dist",
|
||||
[string[]]$Targets = @("armv7-unknown-linux-musleabihf")
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$root = Split-Path -Parent $MyInvocation.MyCommand.Path | Split-Path -Parent
|
||||
$dist = Join-Path $root $OutDir
|
||||
New-Item -ItemType Directory -Force -Path $dist | Out-Null
|
||||
|
||||
$pkgName = "wakey-$Version"
|
||||
$pkgDir = Join-Path $dist $pkgName
|
||||
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue $pkgDir
|
||||
New-Item -ItemType Directory -Force -Path $pkgDir | Out-Null
|
||||
|
||||
# Copy LICENSE/README if present
|
||||
if (Test-Path "$root/README.md") { Copy-Item "$root/README.md" $pkgDir }
|
||||
if (Test-Path "$root/LICENSE") { Copy-Item "$root/LICENSE" $pkgDir }
|
||||
|
||||
# Binaries per target
|
||||
foreach ($t in $Targets) {
|
||||
$binName = if ($t -like "*-windows-*") { "wakey.exe" } else { "wakey" }
|
||||
$src = Join-Path $root "target/$t/release/$binName"
|
||||
if (-not (Test-Path $src)) { throw "Missing binary: $src" }
|
||||
$tDir = Join-Path $pkgDir "bin/$t"
|
||||
New-Item -ItemType Directory -Force -Path $tDir | Out-Null
|
||||
Copy-Item $src $tDir
|
||||
}
|
||||
|
||||
# Init scripts
|
||||
$initDir = Join-Path $pkgDir "init"
|
||||
New-Item -ItemType Directory -Force -Path $initDir | Out-Null
|
||||
Copy-Item "$root/scripts/init" $initDir -Recurse
|
||||
|
||||
# Systemd service (for Linux hosts)
|
||||
Copy-Item "$root/scripts/systemd" $initDir -Recurse -ErrorAction SilentlyContinue
|
||||
|
||||
# Tarball
|
||||
Push-Location $dist
|
||||
if (Get-Command tar -ErrorAction SilentlyContinue) {
|
||||
tar -czf "$pkgName.tgz" "$pkgName"
|
||||
}
|
||||
else {
|
||||
# Fallback to zip on Windows
|
||||
Compress-Archive -Path "$pkgName" -DestinationPath "$pkgName.zip" -Force
|
||||
}
|
||||
Pop-Location
|
||||
|
||||
Write-Host "Packaged: $dist/$pkgName.(tgz|zip)"
|
||||
+7
-2
@@ -31,10 +31,11 @@ if (-not $Version) {
|
||||
if ($Version) {
|
||||
$pattern = '(?m)^version\s*=\s*"[^"]+"'
|
||||
$replacement = ('version = "{0}"' -f $Version)
|
||||
(Get-Content Cargo.toml -Raw) -replace $pattern, $replacement | Set-Content Cargo.toml -Encoding UTF8
|
||||
(Get-Content Cargo.toml -Raw) -replace $pattern, $replacement | Set-Content Cargo.toml -Encoding UTF8 -NoNewline
|
||||
}
|
||||
|
||||
# Ensure build ok
|
||||
cargo fmt
|
||||
cargo build --release
|
||||
|
||||
# Publish crate (only if -Publish and registry auth is available)
|
||||
@@ -44,6 +45,8 @@ if ($Publish) {
|
||||
$pubArgs = @('publish')
|
||||
if ($Registry) { $pubArgs += @('--registry', $Registry) }
|
||||
cargo @pubArgs
|
||||
|
||||
Write-Host ("published. ran cargo {0}" -f ($pubArgs -join " "))
|
||||
}
|
||||
catch {
|
||||
Write-Warning ("cargo publish failed: {0}" -f $_)
|
||||
@@ -52,7 +55,7 @@ if ($Publish) {
|
||||
|
||||
if ($Tag -and $Version) {
|
||||
# If you git push a tag, this will trigger the act_runner and the release workflow on your self-hosted runner (see .gitea/workflows/release.yml)
|
||||
$actRunnerScript = Join-Path (Split-Path -Parent $PSScriptRoot) 'act_runner.ps1'
|
||||
$actRunnerScript = Join-Path $PSScriptRoot 'act_runner.ps1'
|
||||
if (Test-Path $actRunnerScript) {
|
||||
Write-Host "Ensuring act_runner is running..."
|
||||
& $actRunnerScript
|
||||
@@ -62,4 +65,6 @@ if ($Tag -and $Version) {
|
||||
}
|
||||
git tag -f "v$Version"
|
||||
git push -f origin "v$Version"
|
||||
|
||||
Write-Host "Pushed tag: $Version."
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
# One-shot: build, package, and print artifact location (no cross required).
|
||||
# Usage: ./scripts/release.ps1 -Version 0.1.0 -Targets armv7-unknown-linux-musleabihf
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Version,
|
||||
[string[]]$Targets = @("armv7-unknown-linux-musleabihf")
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
foreach ($t in $Targets) {
|
||||
rustup target add $t
|
||||
cargo build --release --target $t
|
||||
}
|
||||
./scripts/package.ps1 -Version $Version -Targets $Targets
|
||||
@@ -14,7 +14,10 @@ if [ -z "$BIN_TMP" ] || [ -z "$DEST" ]; then
|
||||
exit 2
|
||||
fi
|
||||
|
||||
[ -f "$BIN_TMP" ] || { echo "tmp binary not found: $BIN_TMP" >&2; exit 1; }
|
||||
[ -f "$BIN_TMP" ] || {
|
||||
echo "tmp binary not found: $BIN_TMP" >&2
|
||||
exit 1
|
||||
}
|
||||
chmod +x "$BIN_TMP" || true
|
||||
|
||||
if [ "$RESTART" = "1" ]; then
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
[Unit]
|
||||
Description=Wakey WOL+ARP service
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/local/bin/wakey
|
||||
Restart=on-failure
|
||||
RestartSec=2
|
||||
User=wakey
|
||||
Group=wakey
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -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};
|
||||
@@ -10,14 +10,14 @@
|
||||
|
||||
use std::{net::IpAddr, str::FromStr};
|
||||
|
||||
use r#impl::ser_opm;
|
||||
use impls::ser_opm;
|
||||
use macaddr::MacAddr;
|
||||
use serde_with::skip_serializing_none;
|
||||
use strum::{Display, EnumString};
|
||||
|
||||
use crate::arpparse::error::IPNeighParseError;
|
||||
mod error;
|
||||
mod r#impl; // custom (de)serialization impls
|
||||
mod impls; // custom (de)serialization impls
|
||||
|
||||
/// ip neigh has some cool shit.
|
||||
///
|
||||
+12
-3
@@ -1,3 +1,12 @@
|
||||
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");
|
||||
// generated with ./scripts/map_static.py
|
||||
|
||||
pub const HOME_2_HTML: &str = include_str!("../static/home_2.html");
|
||||
pub mod home_2 {
|
||||
pub const DOM_JS: &str = include_str!("../static/home_2/dom.js");
|
||||
pub const LEASES_JS: &str = include_str!("../static/home_2/leases.js");
|
||||
pub const MAIN_JS: &str = include_str!("../static/home_2/main.js");
|
||||
pub const STATUS_JS: &str = include_str!("../static/home_2/status.js");
|
||||
pub const STYLES_CSS: &str = include_str!("../static/home_2/styles.css");
|
||||
pub const UTILS_JS: &str = include_str!("../static/home_2/utils.js");
|
||||
pub const WAKE_JS: &str = include_str!("../static/home_2/wake.js");
|
||||
}
|
||||
+3
-1
@@ -2,7 +2,8 @@
|
||||
const MAC_NAME_CACHE: &str = "/tmp/wakey_mac_names.json";
|
||||
|
||||
/// Load MAC->name cache from disk
|
||||
async fn load_mac_name_cache() -> io::Result<std::collections::BTreeMap<String, String>> {
|
||||
pub(crate) async fn load_mac_name_cache() -> io::Result<std::collections::BTreeMap<String, String>>
|
||||
{
|
||||
match tokio::fs::read_to_string(MAC_NAME_CACHE).await {
|
||||
Ok(s) => serde_json::from_str(&s).map_err(io::Error::other),
|
||||
Err(e) if e.kind() == ErrorKind::NotFound => Ok(Default::default()),
|
||||
@@ -26,6 +27,7 @@ pub async fn read_dhcp_leases_with_names() -> io::Result<Vec<DhcpLeaseLine>> {
|
||||
for mut l in leases {
|
||||
let mac_s = l.mac.to_string();
|
||||
if let Some(ref name) = l.name {
|
||||
// if no name in cache file or it changed
|
||||
if cache.get(&mac_s).map(|v| v != name).unwrap_or(true) {
|
||||
cache.insert(mac_s, name.clone());
|
||||
changed = true;
|
||||
|
||||
+12
-8
@@ -1,7 +1,14 @@
|
||||
use axum::{
|
||||
Router,
|
||||
routing::{get, post},
|
||||
};
|
||||
//! braindead version v0.1.x
|
||||
//!
|
||||
//! # whats next
|
||||
//!
|
||||
//! for version 2 i hope to have:
|
||||
//!
|
||||
//! 1. idk reworked frontend;
|
||||
//! 2. incorporate ip -j;
|
||||
//! 3. small 1-5 second caching;
|
||||
|
||||
use axum::{Router, routing::get};
|
||||
use tokio::net::TcpListener;
|
||||
mod arpparse;
|
||||
pub mod assets;
|
||||
@@ -10,18 +17,15 @@ mod route;
|
||||
mod utils;
|
||||
use std::io;
|
||||
|
||||
const MACHINE_NAME: &str = "lda.lan";
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[tokio::main]
|
||||
async fn entry() -> io::Result<()> {
|
||||
use crate::route::{api_router, home_2, home_2_route, wake_handler};
|
||||
use crate::route::{api_router, home_2, home_2_route};
|
||||
|
||||
let app = Router::new()
|
||||
// .route("/home", get(home))
|
||||
.route("/", get(home_2))
|
||||
.merge(home_2_route())
|
||||
.route("/wake", post(wake_handler))
|
||||
// .route("/status", get(get_status_2))
|
||||
.nest("/api", api_router());
|
||||
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
pub mod api;
|
||||
|
||||
pub use crate::route::api::{DeviceQuery, api_router};
|
||||
use crate::{
|
||||
assets,
|
||||
utils::{ping::_ping_ip, wake::wake},
|
||||
};
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
http::{StatusCode, header},
|
||||
response::{Html, IntoResponse},
|
||||
routing::get,
|
||||
};
|
||||
use axum_extra::extract::Query;
|
||||
|
||||
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(
|
||||
Query(DeviceQuery { name, .. }): Query<DeviceQuery>,
|
||||
) -> axum::response::Result<impl IntoResponse> {
|
||||
match wake(name.as_deref().unwrap_or(MACHINE_NAME)).await {
|
||||
Ok(0) => Err((StatusCode::NOT_FOUND, "No packets sent!").into()),
|
||||
Ok(x) => Ok((
|
||||
StatusCode::ACCEPTED,
|
||||
format!("{x} packet{s} sent!", s = if x > 1 { "s" } else { "" }),
|
||||
)),
|
||||
_ => Err((StatusCode::GATEWAY_TIMEOUT, "Wake failed").into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn _get_status_2(q: Query<DeviceQuery>) -> Html<String> {
|
||||
let name = match q {
|
||||
Query(DeviceQuery {
|
||||
name: Some(name), ..
|
||||
}) => name,
|
||||
_ => MACHINE_NAME.to_string(),
|
||||
};
|
||||
Html(_status_build(&name).await)
|
||||
}
|
||||
|
||||
pub async fn _home() -> Html<String> {
|
||||
Html(format!(
|
||||
r#"
|
||||
<html>
|
||||
<body>
|
||||
<p><a href="/home_2">Alternate UI</a></p>
|
||||
<p>the machine is {}! <a href="/status">Status</a></p>
|
||||
<form method="POST" action="/wake">
|
||||
<button type="submit">Wake LDA</button>
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
"#,
|
||||
if _ping_ip((MACHINE_NAME, 22)).await {
|
||||
"on"
|
||||
} else {
|
||||
"off"
|
||||
} // match get_ips(MACHINE_NAME).await {
|
||||
// Ok(ips) => {
|
||||
// // let addrs: Vec<SocketAddr> = ips.into_iter().map(|ip|(ip, 22).into()).collect();
|
||||
// }
|
||||
// Err(_) => "off",
|
||||
// }
|
||||
))
|
||||
}
|
||||
+60
-225
@@ -1,231 +1,63 @@
|
||||
use crate::utils::parse::{boolish_str, de_many, serialize_macs};
|
||||
use std::collections::HashSet;
|
||||
use std::net::IpAddr;
|
||||
use crate::route::error::ApiError;
|
||||
use crate::utils::query_parser::{QueryType, parse_query};
|
||||
use axum::Json;
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::IntoResponse;
|
||||
use axum::{extract::Path, response::Redirect};
|
||||
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::Path,
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Redirect},
|
||||
routing::get,
|
||||
};
|
||||
use axum_extra::extract::Query;
|
||||
use macaddr::MacAddr;
|
||||
use serde_with::skip_serializing_none;
|
||||
|
||||
use crate::{
|
||||
arpparse::{self, NUDState},
|
||||
dhcpparse,
|
||||
utils::query::{
|
||||
dev::{self, has_dev},
|
||||
get_macs,
|
||||
},
|
||||
};
|
||||
use crate::route::status::{DeviceQuery, Filters, NamePath};
|
||||
use crate::utils::query::get_ips;
|
||||
|
||||
// Smart redirect: accept IP, MAC, dev, or NUD state and redirect to /api/status accordingly
|
||||
pub async fn status_smart_redirect(Path(q): Path<String>) -> Redirect {
|
||||
let s = if cfg!(feature = "very-smart-parsing") {
|
||||
crate::utils::parse::extract_host(&q)
|
||||
} else {
|
||||
q.trim()
|
||||
pub async fn status_smart_redirect(
|
||||
Path(q): Path<String>,
|
||||
) -> axum::response::Result<Redirect, impl IntoResponse> {
|
||||
// no less bullshit
|
||||
let query = match parse_query(q) {
|
||||
QueryType::Ip(ip_addr) => DeviceQuery {
|
||||
filter: Filters {
|
||||
ips: vec![ip_addr],
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
QueryType::Mac(mac_addr) => DeviceQuery {
|
||||
filter: Filters {
|
||||
macs: vec![mac_addr],
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
QueryType::Dev(s) => DeviceQuery {
|
||||
filter: Filters {
|
||||
devs: vec![s],
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
QueryType::Nud(nudstate) => DeviceQuery {
|
||||
filter: Filters {
|
||||
nuds: vec![nudstate],
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
QueryType::Unknown(n) => DeviceQuery {
|
||||
name: Some(n),
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
// 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) => (
|
||||
match serde_html_form::to_string(query) {
|
||||
Ok(e) => Ok(Redirect::to(&format!("/api/status?{e}"))),
|
||||
Err(e) => Err((
|
||||
StatusCode::BAD_GATEWAY,
|
||||
Json(StatusError {
|
||||
name: None,
|
||||
Json(ApiError {
|
||||
error: e.to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Hash, serde::Deserialize)]
|
||||
pub struct DeviceQuery {
|
||||
pub name: Option<String>,
|
||||
// Accept single or many; ignore blanks
|
||||
#[serde(default, deserialize_with = "de_many::vec_from_strs")]
|
||||
ip: Vec<IpAddr>,
|
||||
#[serde(default, deserialize_with = "de_many::vec_from_strs")]
|
||||
mac: Vec<MacAddr>,
|
||||
/// optional interface filter (e.g., br-lan)
|
||||
#[serde(default, deserialize_with = "de_many::vec_from_strs")]
|
||||
pub dev: Vec<String>,
|
||||
/// optional NUD state filter; accepts any case (e.g., reachable, REACHABLE)
|
||||
#[serde(default, deserialize_with = "de_many::vec_from_strs")]
|
||||
pub nud: Vec<NUDState>,
|
||||
}
|
||||
#[derive(Debug, Default, Clone, Hash, serde::Deserialize)]
|
||||
pub struct NamePath {
|
||||
name: String,
|
||||
}
|
||||
#[skip_serializing_none]
|
||||
#[derive(Debug, Default, serde::Serialize)]
|
||||
pub struct Status {
|
||||
name: Option<String>,
|
||||
table: Vec<arpparse::IpNeighLine>,
|
||||
filters: Filters,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, serde::Serialize)]
|
||||
pub struct Filters {
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
ip: Vec<IpAddr>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
dev: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
nud: Vec<NUDState>,
|
||||
#[serde(
|
||||
skip_serializing_if = "Vec::is_empty",
|
||||
serialize_with = "serialize_macs"
|
||||
)]
|
||||
mac: Vec<MacAddr>,
|
||||
}
|
||||
|
||||
#[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 {
|
||||
Redirect::permanent(&format!(
|
||||
"/api/status?name={name}",
|
||||
@@ -233,11 +65,14 @@ pub async fn status_redirect(Path(NamePath { name }): Path<NamePath>) -> Redirec
|
||||
))
|
||||
}
|
||||
|
||||
pub fn api_router() -> Router {
|
||||
Router::new()
|
||||
.route("/status/{name}", get(status_redirect))
|
||||
.route("/status", get(get_status_json))
|
||||
.route("/dhcp_leases", get(get_dhcp_leases))
|
||||
.route("/smart/{q}", get(status_smart_redirect))
|
||||
.route("/devs", get(devs_router))
|
||||
pub async fn ip(Path(name): Path<String>) -> impl IntoResponse {
|
||||
get_ips(&name).await.map_or_else(
|
||||
|e| {
|
||||
ApiError {
|
||||
error: e.to_string(),
|
||||
}
|
||||
.into_response()
|
||||
},
|
||||
|ips| Json(ips.collect::<Vec<_>>()).into_response(),
|
||||
)
|
||||
}
|
||||
@@ -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,37 @@
|
||||
use crate::{
|
||||
dhcpparse::read_dhcp_leases_with_names,
|
||||
route::error::ApiError,
|
||||
utils::{parse::boolish_str, query::enrich_leases_with_nud_state},
|
||||
};
|
||||
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 read_dhcp_leases_with_names().await {
|
||||
Ok(leases_with_names) => {
|
||||
if !include_state {
|
||||
return (StatusCode::OK, Json(leases_with_names)).into_response();
|
||||
}
|
||||
let out = enrich_leases_with_nud_state(leases_with_names).await;
|
||||
(StatusCode::OK, Json(out)).into_response()
|
||||
}
|
||||
Err(e) => (
|
||||
StatusCode::BAD_GATEWAY,
|
||||
Json(ApiError {
|
||||
error: e.to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
use axum::{
|
||||
Json,
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ApiError {
|
||||
pub error: String,
|
||||
}
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, Json(self)).into_response()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
pub mod api;
|
||||
pub mod devs;
|
||||
pub mod dhcp;
|
||||
pub mod error;
|
||||
pub mod status;
|
||||
pub mod wake;
|
||||
|
||||
use crate::assets;
|
||||
use crate::route::api::ip;
|
||||
use crate::route::api::status_redirect;
|
||||
use crate::route::api::status_smart_redirect;
|
||||
use crate::route::devs::devs_router;
|
||||
use crate::route::dhcp::get_dhcp_leases;
|
||||
use crate::route::status::get_status_json;
|
||||
use crate::route::wake::wake_multi;
|
||||
|
||||
use axum::routing::post;
|
||||
use axum::{Router, http::header, response::Html, routing::get};
|
||||
|
||||
use crate::utils::route::serve_js;
|
||||
|
||||
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)))
|
||||
}
|
||||
|
||||
pub fn api_router() -> Router {
|
||||
Router::new()
|
||||
.route("/status/{name}", get(status_redirect))
|
||||
.route("/status", get(get_status_json))
|
||||
.route("/dhcp_leases", get(get_dhcp_leases))
|
||||
.route("/smart/{q}", get(status_smart_redirect))
|
||||
.route("/devs", get(devs_router))
|
||||
.route("/wake", post(wake_multi))
|
||||
.route("/ips/{name}", get(ip))
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
use crate::route::error::ApiError;
|
||||
use axum::{Json, http::StatusCode, response::IntoResponse};
|
||||
use axum_extra::extract::Query;
|
||||
use macaddr::MacAddr;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_with::skip_serializing_none;
|
||||
use std::net::IpAddr;
|
||||
|
||||
use crate::arpparse::NUDState;
|
||||
use crate::utils::parse::de_many;
|
||||
use crate::utils::parse::serialize_macs;
|
||||
use crate::utils::query::get_macs;
|
||||
|
||||
#[derive(Debug, Default, Clone, Hash, Deserialize, Serialize)]
|
||||
pub struct DeviceQuery {
|
||||
pub name: Option<String>,
|
||||
#[serde(flatten)]
|
||||
pub filter: Filters,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Hash, Deserialize)]
|
||||
pub struct NamePath {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[skip_serializing_none]
|
||||
#[derive(Debug, Default, Serialize)]
|
||||
pub struct Status<T> {
|
||||
pub name: Option<String>,
|
||||
pub table: Vec<T>,
|
||||
pub filters: Filters,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Hash, Serialize, Deserialize)]
|
||||
pub struct Filters {
|
||||
#[serde(default, deserialize_with = "de_many::vec_from_strs")]
|
||||
pub ips: Vec<IpAddr>,
|
||||
#[serde(default, deserialize_with = "de_many::vec_from_strs")]
|
||||
pub devs: Vec<String>,
|
||||
#[serde(default, deserialize_with = "de_many::vec_from_strs")]
|
||||
pub nuds: Vec<NUDState>,
|
||||
#[serde(
|
||||
default,
|
||||
deserialize_with = "de_many::vec_from_strs",
|
||||
serialize_with = "serialize_macs"
|
||||
)]
|
||||
pub macs: Vec<MacAddr>,
|
||||
}
|
||||
|
||||
#[deprecated = "use ApiError"]
|
||||
#[skip_serializing_none]
|
||||
#[derive(Debug, Serialize, Default)]
|
||||
pub struct StatusError {
|
||||
pub name: Option<String>,
|
||||
pub error: String,
|
||||
}
|
||||
|
||||
pub async fn get_status_json(
|
||||
Query(DeviceQuery {
|
||||
name,
|
||||
filter:
|
||||
Filters {
|
||||
ips,
|
||||
devs,
|
||||
nuds,
|
||||
macs,
|
||||
},
|
||||
..
|
||||
}): Query<DeviceQuery>,
|
||||
) -> impl IntoResponse {
|
||||
match get_macs(
|
||||
&name.iter().collect::<Vec<_>>(),
|
||||
&ips,
|
||||
&devs.iter().collect::<Vec<_>>(),
|
||||
&nuds,
|
||||
&macs,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(table) => {
|
||||
let filters = Filters {
|
||||
ips,
|
||||
devs,
|
||||
nuds,
|
||||
macs,
|
||||
};
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(Status {
|
||||
name,
|
||||
table,
|
||||
filters,
|
||||
}),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
Err(error) => (
|
||||
StatusCode::BAD_GATEWAY,
|
||||
Json(ApiError {
|
||||
error: error.to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
// Status endpoints
|
||||
@@ -0,0 +1,112 @@
|
||||
//! impls are at [`utils::wake::impl`](crate::utils::wake::r#impl) for some reason
|
||||
use std::io;
|
||||
use std::net::IpAddr;
|
||||
|
||||
/* use crate::arpparse::IpNeighLine;
|
||||
use crate::route::api::Status; */
|
||||
use crate::utils::parse::mac::{des_opm, ser_opm};
|
||||
use crate::utils::wake::wake_one;
|
||||
use axum::{extract::Json, http::StatusCode, response::IntoResponse};
|
||||
use futures::TryFutureExt;
|
||||
use macaddr::MacAddr;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_with::skip_serializing_none;
|
||||
use tokio::net::UdpSocket;
|
||||
|
||||
#[skip_serializing_none]
|
||||
#[derive(Debug, Default, Serialize, Clone)]
|
||||
pub struct WakeResult {
|
||||
pub success: bool,
|
||||
pub result: Option<Vec<WakeTargetResult>>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
#[skip_serializing_none]
|
||||
#[derive(Debug, Serialize, Clone, Copy)]
|
||||
pub struct WakeTargetResult {
|
||||
#[serde(flatten)]
|
||||
pub target: WakeTarget,
|
||||
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, Serialize, Deserialize, Clone, Copy)]
|
||||
pub struct WakeTarget {
|
||||
#[serde(default)]
|
||||
pub ip: Option<IpAddr>,
|
||||
#[serde(default, serialize_with = "ser_opm", deserialize_with = "des_opm")]
|
||||
pub mac: Option<MacAddr>,
|
||||
}
|
||||
|
||||
pub async fn wake_multi(Json(req): Json<Vec<WakeTarget>>) -> impl IntoResponse {
|
||||
match wake_multi_split(req).await {
|
||||
Ok(results) => (
|
||||
StatusCode::OK,
|
||||
Json(WakeResult {
|
||||
success: true,
|
||||
result: Some(results),
|
||||
..Default::default()
|
||||
}),
|
||||
),
|
||||
Err(error) => {
|
||||
let error = Some(format!("Error: {}", error));
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(WakeResult {
|
||||
success: false,
|
||||
error,
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// this is so bad
|
||||
pub async fn wake_multi_split(
|
||||
targets: impl IntoIterator<Item = WakeTarget>,
|
||||
) -> io::Result<Vec<WakeTargetResult>> {
|
||||
let sock = UdpSocket::bind("[::]:0")
|
||||
.or_else(|_| UdpSocket::bind(":0"))
|
||||
.await?;
|
||||
sock.set_broadcast(true)?;
|
||||
|
||||
let iter = targets.into_iter().map(async |c| {
|
||||
if c.is_incomplete() {
|
||||
c.to_incomplete()
|
||||
} else {
|
||||
let t = c.try_into().expect("complete struct failed to try_into");
|
||||
wake_one(&sock, t).await.into()
|
||||
}
|
||||
});
|
||||
Ok(futures::future::join_all(iter).await)
|
||||
}
|
||||
/* #[derive(Debug, Serialize)]
|
||||
pub struct WakeStatusLine {
|
||||
#[serde(flatten)]
|
||||
pub status: IpNeighLine, // most powerful find of the century
|
||||
pub wake_status: WakeTargetStatus,
|
||||
}
|
||||
pub type WakeStatus = Status<WakeStatusLine>; */
|
||||
// /// return status BUT plus a indicator of i sent a wake.
|
||||
// pub async fn wake_status(
|
||||
// Query(DeviceQuery {
|
||||
// name,
|
||||
// ip,
|
||||
// dev,
|
||||
// nud,
|
||||
// mac,
|
||||
// ..
|
||||
// }): Query<DeviceQuery>,
|
||||
// ) -> impl IntoResponse {
|
||||
// }
|
||||
@@ -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");
|
||||
@@ -1,64 +0,0 @@
|
||||
// pub const LDA_MACS: [[u8; 6]; 2] = [
|
||||
// // ether
|
||||
// [0x04, 0x7c, 0x16, 0x79, 0x6d, 0xee],
|
||||
// // wifi
|
||||
// [0xbc, 0x09, 0x1b, 0xec, 0x65, 0xd0],
|
||||
// ];
|
||||
|
||||
/// is it time to lookup host lda.lan for this...
|
||||
// pub static LDA_MACS_2: LazyLock<[MacAddr; 2]> = LazyLock::new(|| LDA_MACS.map(MacAddr::from));
|
||||
pub mod wake;
|
||||
|
||||
use crate::utils::query::_get_macs_2_1;
|
||||
|
||||
pub mod error;
|
||||
/// generic so you can do "123.45.67.89:22" or "lda.lan:22" as an input
|
||||
// this is so bad
|
||||
pub mod ping;
|
||||
|
||||
pub mod cmd;
|
||||
pub mod query;
|
||||
|
||||
// no custom ip deserializer needed when using axum_extra::extract::Query
|
||||
// but we add a generic one to ignore blanks and accept OneOrMany
|
||||
pub(crate) mod parse;
|
||||
|
||||
pub async fn _status_build(machine_name: &str) -> String {
|
||||
let formatted_macs = match _get_macs_2_1(machine_name).await {
|
||||
Ok(table) => {
|
||||
let the: String = table
|
||||
.iter()
|
||||
.map(|(ip, mac, state)| {
|
||||
let mac_str = // if let Some(mac) = mac {
|
||||
mac.to_string()
|
||||
// } else {
|
||||
// "None".into()
|
||||
// }
|
||||
;
|
||||
format!(
|
||||
"<tr><td>{ip}</td><td>{mac_str}</td><td>{state}</td></tr>",
|
||||
state = state._dumber_state()
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
format!(
|
||||
r#"<p>info of {machine_name}:</p>
|
||||
<table>
|
||||
<tr><th>IP</th><th>MAC</th><th>State</th></tr>
|
||||
{the}
|
||||
</table>"#
|
||||
)
|
||||
}
|
||||
Err(e) => format!("<p>errors getting table for {machine_name}: {e}</p>"),
|
||||
};
|
||||
|
||||
format!(
|
||||
r#"
|
||||
<html>
|
||||
<body>
|
||||
{formatted_macs}
|
||||
</body>
|
||||
</html>
|
||||
"#,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// pub const LDA_MACS: [[u8; 6]; 2] = [
|
||||
// // ether
|
||||
// [0x04, 0x7c, 0x16, 0x79, 0x6d, 0xee],
|
||||
// // wifi
|
||||
// [0xbc, 0x09, 0x1b, 0xec, 0x65, 0xd0],
|
||||
// ];
|
||||
|
||||
/// is it time to lookup host lda.lan for this...
|
||||
// pub static LDA_MACS_2: LazyLock<[MacAddr; 2]> = LazyLock::new(|| LDA_MACS.map(MacAddr::from));
|
||||
pub mod wake;
|
||||
|
||||
pub mod cmd;
|
||||
pub mod error;
|
||||
/// generic so you can do "123.45.67.89:22" or "lda.lan:22" as an input
|
||||
// this is so bad
|
||||
pub mod ping;
|
||||
pub mod query;
|
||||
pub mod query_parser;
|
||||
pub mod route;
|
||||
|
||||
// no custom ip deserializer needed when using axum_extra::extract::Query
|
||||
// but we add a generic one to ignore blanks and accept OneOrMany
|
||||
pub(crate) mod parse;
|
||||
+49
-14
@@ -58,8 +58,6 @@ pub fn extract_host(input: &str) -> &str {
|
||||
s.trim()
|
||||
}
|
||||
|
||||
use macaddr::MacAddr;
|
||||
use serde::Serializer;
|
||||
/// key for yes: "1" | "true" | "yes" | "on" | "y"
|
||||
///
|
||||
/// frfr
|
||||
@@ -102,18 +100,6 @@ pub fn boolish_str(s: &str) -> bool {
|
||||
&& t.parse::<u64>().map(|n| n != 0).unwrap_or(false))
|
||||
}
|
||||
|
||||
pub fn serialize_macs<S>(macs: &[MacAddr], serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
let strings: Vec<String> = macs.iter().map(|m| m.to_string()).collect();
|
||||
serde::Serialize::serialize(&strings, serializer)
|
||||
}
|
||||
|
||||
pub fn serialize_mac<S: serde::Serializer>(m: &macaddr::MacAddr, s: S) -> Result<S::Ok, S::Error> {
|
||||
s.serialize_str(&m.to_string())
|
||||
}
|
||||
|
||||
pub mod de_many {
|
||||
use serde::Deserialize;
|
||||
use serde::de;
|
||||
@@ -153,3 +139,52 @@ pub mod de_many {
|
||||
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::*;
|
||||
+2
-2
@@ -7,7 +7,7 @@ use tokio::{
|
||||
time::timeout,
|
||||
};
|
||||
|
||||
use crate::{arpparse::NUDState, utils::query::get_macs};
|
||||
use crate::{arpparse::NUDState, utils::query::get_mac};
|
||||
|
||||
pub async fn _ping_ip<T: ToSocketAddrs>(addr: T) -> bool {
|
||||
timeout(Duration::from_secs(1), TcpStream::connect(addr))
|
||||
@@ -19,7 +19,7 @@ pub async fn _ping_ip_2<T: ToSocketAddrs>(_addr: T) -> bool {
|
||||
}
|
||||
|
||||
pub async fn _ping_ip_3<T: Into<IpAddr>>(addr: T) -> u8 {
|
||||
match get_macs(None, Some(&[addr.into()]), None, None).await {
|
||||
match get_mac(Some(addr.into()), None, None).await {
|
||||
Err(_) => 0,
|
||||
Ok(l) => l
|
||||
.into_iter()
|
||||
|
||||
@@ -1,240 +0,0 @@
|
||||
use crate::arpparse::NUDState;
|
||||
use crate::dhcpparse::DhcpLeaseLine;
|
||||
use crate::utils::parse::serialize_mac;
|
||||
use std::net::IpAddr;
|
||||
|
||||
#[skip_serializing_none]
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct DhcpLeaseOut {
|
||||
pub expires_epoch: u64,
|
||||
pub ip: IpAddr,
|
||||
#[serde(serialize_with = "serialize_mac")]
|
||||
pub mac: macaddr::MacAddr,
|
||||
pub name: Option<String>,
|
||||
pub nud_state: Option<NUDState>,
|
||||
pub rank: Option<u8>,
|
||||
}
|
||||
|
||||
/// Enrich DHCP leases with NUD state and rank using get_macs
|
||||
pub async fn enrich_leases_with_nud_state(leases: Vec<DhcpLeaseLine>) -> Vec<DhcpLeaseOut> {
|
||||
use crate::utils::query::get_macs;
|
||||
let ips: Vec<IpAddr> = leases.iter().map(|l| l.ip).collect();
|
||||
let mut map: std::collections::HashMap<IpAddr, (NUDState, u8)> =
|
||||
std::collections::HashMap::new();
|
||||
if let Ok(rows) = get_macs(None, Some(&ips), None, None).await {
|
||||
for row in rows {
|
||||
let state = row.state;
|
||||
let r = state.rank();
|
||||
map.entry(row.ip)
|
||||
.and_modify(|e| {
|
||||
if r > e.1 {
|
||||
*e = (state, r)
|
||||
}
|
||||
})
|
||||
.or_insert((state, r));
|
||||
}
|
||||
}
|
||||
leases
|
||||
.into_iter()
|
||||
.map(|l| DhcpLeaseOut {
|
||||
expires_epoch: l.expires_epoch,
|
||||
ip: l.ip,
|
||||
mac: l.mac,
|
||||
name: l.name,
|
||||
nud_state: map.get(&l.ip).map(|(s, _)| *s),
|
||||
rank: map.get(&l.ip).map(|(_, r)| *r),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
use std::collections::HashSet;
|
||||
|
||||
use macaddr::MacAddr;
|
||||
use serde_with::skip_serializing_none;
|
||||
// use tokio::io;
|
||||
|
||||
use crate::{
|
||||
arpparse::{self, IpNeighLine},
|
||||
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,45 @@
|
||||
use std::{collections::HashSet, fs};
|
||||
|
||||
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 e.file_type()
|
||||
.map(|ft| {
|
||||
if ft.is_symlink() {
|
||||
// true
|
||||
fs::metadata(e.path()).map(|m| m.is_dir()).unwrap_or(false)
|
||||
} else {
|
||||
ft.is_dir()
|
||||
}
|
||||
})
|
||||
.unwrap_or(false)
|
||||
&& 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,45 @@
|
||||
use crate::arpparse::NUDState;
|
||||
use crate::dhcpparse::DhcpLeaseLine;
|
||||
use crate::utils::query::get_macs;
|
||||
use serde_with::skip_serializing_none;
|
||||
use std::net::IpAddr;
|
||||
|
||||
#[skip_serializing_none]
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct DhcpLeaseOut {
|
||||
#[serde(flatten)]
|
||||
pub lease_line: DhcpLeaseLine,
|
||||
pub nud_state: Option<NUDState>,
|
||||
pub rank: Option<u8>,
|
||||
}
|
||||
|
||||
/// Enrich DHCP leases with NUD state and rank using get_macs
|
||||
pub async fn enrich_leases_with_nud_state(leases: Vec<DhcpLeaseLine>) -> Vec<DhcpLeaseOut> {
|
||||
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(&[] as &[&str], &ips, &[] as &[&str], &[], &[]).await {
|
||||
for row in rows {
|
||||
let state = row.state;
|
||||
let r = state.rank();
|
||||
map.entry(row.ip)
|
||||
.and_modify(|e| {
|
||||
if r > e.1 {
|
||||
*e = (state, r)
|
||||
}
|
||||
})
|
||||
.or_insert((state, r));
|
||||
}
|
||||
}
|
||||
leases
|
||||
.into_iter()
|
||||
.map(|lease_line| {
|
||||
let (nud_state, rank) = map.get(&lease_line.ip).copied().unzip();
|
||||
DhcpLeaseOut {
|
||||
lease_line,
|
||||
nud_state,
|
||||
rank,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
use macaddr::MacAddr;
|
||||
|
||||
use crate::arpparse::{self, IpNeighLine, NUDState};
|
||||
use crate::utils::{
|
||||
cmd::exec_command,
|
||||
error::{self, Result},
|
||||
};
|
||||
use std::collections::HashSet;
|
||||
use std::net::IpAddr;
|
||||
|
||||
pub async fn get_ips(machine_name: &str) -> Result<impl Iterator<Item = IpAddr>> {
|
||||
Ok(tokio::net::lookup_host((machine_name, 0))
|
||||
.await
|
||||
.map_err(|e| error::Error::DnsResolve {
|
||||
name: machine_name.to_string(),
|
||||
source: e,
|
||||
})?
|
||||
.map(|c| c.ip()))
|
||||
}
|
||||
|
||||
// #[deprecated(
|
||||
// since = "0.1.5",
|
||||
// note = "just call once everything with get mac
|
||||
// and then filter it bro WHY DO YOU EVEN DO TS"
|
||||
// )]
|
||||
// good now
|
||||
//
|
||||
// Current logic: When filtering by exactly 1 dev/mac, exclude entries missing that field.
|
||||
// This is because missing dev/mac usually means the entry is incomplete/transient.
|
||||
//
|
||||
// when there is only one MACs (getmac got some), the result will not have them fields.
|
||||
// so there are three cases:
|
||||
//
|
||||
// 1. dont got nothing: take all of them (macset.is_empty())
|
||||
// 2. exactly one: pre-filtered by ip, everything matches,
|
||||
// devset.len() != 1 returns false, but then it works????
|
||||
// OH THIS fuckass code i added it in the get_mac
|
||||
// 3. devset.len() > 1. if none then absolutely not match,
|
||||
// if some then check with the set; thats normal
|
||||
pub async fn get_macs(
|
||||
machine_names: &[impl AsRef<str>],
|
||||
ips: &[IpAddr],
|
||||
devs: &[impl AsRef<str>],
|
||||
state: &[NUDState],
|
||||
macs: &[MacAddr],
|
||||
) -> Result<Vec<IpNeighLine>> {
|
||||
let mut ip_set: HashSet<IpAddr> = ips.iter().map(|ip| ip.to_canonical()).collect();
|
||||
let ip_m: HashSet<IpAddr> = futures::future::try_join_all(
|
||||
machine_names
|
||||
.iter()
|
||||
.map(|c| async { get_ips(c.as_ref()).await }),
|
||||
)
|
||||
.await?
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect();
|
||||
let ip_all = if ip_set.is_empty() && ip_m.is_empty() {
|
||||
None
|
||||
} else if ip_set.is_empty() {
|
||||
Some(ip_m)
|
||||
} else if ip_m.is_empty() {
|
||||
Some(ip_set)
|
||||
} else {
|
||||
Some({
|
||||
ip_set.retain(|c| ip_m.contains(c)); // inline AHHH
|
||||
ip_set
|
||||
})
|
||||
};
|
||||
|
||||
let opt_dev = if devs.len() > 1 {
|
||||
None
|
||||
} else {
|
||||
devs.iter().next().map(AsRef::as_ref)
|
||||
};
|
||||
let opt_state = if state.len() > 1 {
|
||||
None
|
||||
} else {
|
||||
state.iter().next().copied()
|
||||
};
|
||||
|
||||
let run_one = |to_ip: Option<IpAddr>| get_mac(to_ip, opt_dev, opt_state);
|
||||
|
||||
let mut ip_filtered = if let Some(something) = ip_all {
|
||||
if something.len() == 1 {
|
||||
run_one(something.into_iter().next()).await?
|
||||
} else {
|
||||
run_one(None)
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter(|c| something.contains(&c.ip))
|
||||
.collect()
|
||||
}
|
||||
} else {
|
||||
run_one(None).await?
|
||||
};
|
||||
|
||||
// Apply additional filters if any were provided
|
||||
if !devs.is_empty() || !macs.is_empty() || !state.is_empty() {
|
||||
let devset: HashSet<_> = devs.iter().map(AsRef::as_ref).collect();
|
||||
let nudset: HashSet<_> = state.iter().collect();
|
||||
let macset: HashSet<_> = macs.iter().collect();
|
||||
|
||||
ip_filtered.retain(|entry| {
|
||||
// Dev filter: if we're filtering by dev, entry must have a dev AND it must be in the set
|
||||
let dev_ok =
|
||||
devset.is_empty() || entry.dev.as_deref().is_some_and(|d| devset.contains(d));
|
||||
|
||||
// NUD filter: always present, simple check
|
||||
let nud_ok = nudset.is_empty() || nudset.contains(&entry.state);
|
||||
|
||||
// MAC filter: if we're filtering by MAC, entry must have a MAC AND it must be in the set
|
||||
let mac_ok = macset.is_empty() || entry.mac.is_some_and(|m| macset.contains(&m));
|
||||
|
||||
dev_ok && nud_ok && mac_ok
|
||||
})
|
||||
};
|
||||
Ok(ip_filtered)
|
||||
}
|
||||
|
||||
/// the atomic get_macs. handle ONE thing only.
|
||||
pub async fn get_mac(
|
||||
ip: Option<IpAddr>,
|
||||
dev: Option<&str>,
|
||||
state: Option<NUDState>,
|
||||
) -> Result<Vec<IpNeighLine>> {
|
||||
let mut args: Vec<String> = vec!["neigh".into(), "show".into()];
|
||||
if let Some(ip) = ip {
|
||||
args.push("to".into());
|
||||
args.push(ip.to_string());
|
||||
}
|
||||
if let Some(d) = dev {
|
||||
args.push("dev".into());
|
||||
args.push(d.to_string());
|
||||
}
|
||||
if let Some(nud) = state {
|
||||
args.push("nud".into());
|
||||
args.push(nud.as_ip_neigh_arg().into());
|
||||
}
|
||||
let cmd = "ip";
|
||||
let out = exec_command(cmd, args.iter().map(String::as_str).collect::<Vec<_>>()).await?;
|
||||
if !out.status.success() {
|
||||
return Err(error::Error::CommandFailed {
|
||||
cmd,
|
||||
args,
|
||||
status: out.status.code(),
|
||||
stderr: String::from_utf8_lossy(&out.stderr).into(),
|
||||
});
|
||||
}
|
||||
let lines = String::from_utf8_lossy(&out.stdout);
|
||||
let parsed = lines.lines().flat_map(arpparse::parse_ip_neigh_line);
|
||||
let rows: Vec<IpNeighLine> = if let Some(d) = dev {
|
||||
parsed.map(IpNeighLine::with_dev(d)).collect()
|
||||
} else {
|
||||
parsed.collect()
|
||||
};
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/*
|
||||
/// get macs where you just run ip neigh then rust handles the filtering (faster than get mac)
|
||||
pub async fn get_macs_rust(
|
||||
machine_names: Option<&str>,
|
||||
ips: Option<&[IpAddr]>,
|
||||
devs: Option<&[&str]>,
|
||||
states: Option<&[NUDState]>,
|
||||
) -> Result<Vec<IpNeighLine>> {
|
||||
let mut ip_map: HashSet<IpAddr> = HashSet::new();
|
||||
let mut machine_map = HashSet::new();
|
||||
if let Some(ips) = ips {
|
||||
ip_map.extend(ips);
|
||||
}
|
||||
if let Some(m) = machine_names {
|
||||
machine_map.extend(get_ips(m).await.into_iter().flatten());
|
||||
}
|
||||
|
||||
let real = match (ip_map.is_empty(), machine_map.is_empty()) {
|
||||
(true, true) => HashSet::new(),
|
||||
(true, false) => machine_map,
|
||||
(false, true) => ip_map,
|
||||
(false, false) => ip_map.intersection(&machine_map).copied().collect(),
|
||||
};
|
||||
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
pub async fn _get_machines(m: &[&str]) -> Vec<IpAddr> {
|
||||
let futs = m.iter().map(|c| get_ips(c));
|
||||
futures::future::join_all(futs)
|
||||
.await
|
||||
.into_iter()
|
||||
.flat_map(|f| f.into_iter().flatten())
|
||||
.collect()
|
||||
} */
|
||||
@@ -0,0 +1,6 @@
|
||||
pub mod dev;
|
||||
pub mod leases;
|
||||
pub mod macs;
|
||||
|
||||
pub use leases::*;
|
||||
pub use macs::*;
|
||||
@@ -0,0 +1,44 @@
|
||||
use std::net::IpAddr;
|
||||
|
||||
use macaddr::MacAddr;
|
||||
|
||||
use crate::{arpparse::NUDState, utils::query::dev::has_dev};
|
||||
|
||||
pub enum QueryType {
|
||||
Ip(IpAddr),
|
||||
Mac(MacAddr),
|
||||
Dev(String),
|
||||
Nud(NUDState),
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
pub fn parse_query(q: String) -> QueryType {
|
||||
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 {
|
||||
return QueryType::Ip(ip);
|
||||
}
|
||||
// 2) MAC
|
||||
if let Ok(mac) = s.parse::<MacAddr>() {
|
||||
return QueryType::Mac(mac);
|
||||
}
|
||||
// 3) NUD state (reachable, stale, ...)
|
||||
if let Ok(state) = s.parse::<NUDState>() {
|
||||
return QueryType::Nud(state);
|
||||
}
|
||||
// 4) Known device? prefer dev first
|
||||
if has_dev(s) {
|
||||
return QueryType::Dev(s.to_string());
|
||||
}
|
||||
// Default: name last // it will fail also
|
||||
QueryType::Unknown(s.to_string())
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
use axum::{http::header, response::IntoResponse};
|
||||
|
||||
pub async fn serve_js(content: &'static str) -> impl IntoResponse {
|
||||
(
|
||||
[
|
||||
(header::CONTENT_TYPE, "application/javascript"),
|
||||
(header::CACHE_CONTROL, "public, max-age=300"),
|
||||
],
|
||||
content,
|
||||
)
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
use std::{io, net::IpAddr};
|
||||
|
||||
use tokio::net::UdpSocket;
|
||||
|
||||
use crate::utils::query::get_macs_2_mac;
|
||||
|
||||
pub async fn wake(machine_name: &str) -> io::Result<u32> {
|
||||
let suh = UdpSocket::bind("0.0.0.0:0").await?;
|
||||
suh.set_broadcast(true)?;
|
||||
let /* mut */ macs = get_macs_2_mac(machine_name).await.unwrap_or_default();
|
||||
// macs.extend(*LDA_MACS_2);
|
||||
let mut sent_ok = 0;
|
||||
for mac in macs {
|
||||
let mb = mac.as_bytes();
|
||||
|
||||
let mut pac = [0; 6 + 6 * 16]; // 6x FF + 16x mac6
|
||||
pac[..6].fill(0xff);
|
||||
for i in 1..=16 {
|
||||
pac[i * 6..(i + 1) * 6].copy_from_slice(mb);
|
||||
}
|
||||
|
||||
match suh
|
||||
.send_to(&pac, (IpAddr::from([192, 168, 100, 255]), 9))
|
||||
.await
|
||||
{
|
||||
Ok(n) if n == pac.len() => sent_ok += 1,
|
||||
Ok(n) => eprintln!("partial send ({n}/{})", pac.len()),
|
||||
Err(e) => eprintln!("send error: {e}"),
|
||||
}
|
||||
}
|
||||
Ok(sent_ok)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
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<WakeTarget> for RouteWakeTarget {
|
||||
fn from(WakeTarget { ip, mac }: WakeTarget) -> Self {
|
||||
Self {
|
||||
ip: Some(ip),
|
||||
mac: Some(mac),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<WakeTargetResult> for RouteWakeResult {
|
||||
fn from(WakeTargetResult { target, status }: WakeTargetResult) -> Self {
|
||||
Self {
|
||||
target: target.into(),
|
||||
status: status.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RouteWakeTarget {
|
||||
pub fn to_incomplete(self) -> RouteWakeResult {
|
||||
RouteWakeResult {
|
||||
target: self,
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
pub mod impls;
|
||||
use std::{io, net::IpAddr};
|
||||
|
||||
use futures::TryFutureExt;
|
||||
use macaddr::MacAddr;
|
||||
use tokio::net::UdpSocket;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Hash)]
|
||||
pub struct WakeTarget {
|
||||
pub ip: IpAddr,
|
||||
pub mac: MacAddr,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, Hash)]
|
||||
pub struct WakeTargetResult {
|
||||
pub target: WakeTarget,
|
||||
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, WakeStatus::Success)
|
||||
}
|
||||
fn bad(self) -> WakeTargetResult {
|
||||
WakeTargetResult::new(self, WakeStatus::WrongSize)
|
||||
}
|
||||
fn errored(self) -> WakeTargetResult {
|
||||
WakeTargetResult::new(self, WakeStatus::NonexistentAddress)
|
||||
}
|
||||
}
|
||||
impl WakeTargetResult {
|
||||
fn new(target: WakeTarget, status: WakeStatus) -> Self {
|
||||
Self { target, status }
|
||||
}
|
||||
}
|
||||
|
||||
// its time. we have the ip; the macs. we dont need to send to the uh the broadcast anymore???
|
||||
pub async fn _wake_multi(
|
||||
targets: impl IntoIterator<Item = WakeTarget>,
|
||||
) -> io::Result<Vec<WakeTargetResult>> {
|
||||
let sock = UdpSocket::bind("[::]:0")
|
||||
.or_else(|_| UdpSocket::bind(":0"))
|
||||
.await?;
|
||||
sock.set_broadcast(true)?;
|
||||
let fs = targets.into_iter().map(|t| wake_one(&sock, t));
|
||||
Ok(futures::future::join_all(fs).await)
|
||||
}
|
||||
|
||||
pub async fn wake_one(sock: &UdpSocket, t: WakeTarget) -> WakeTargetResult {
|
||||
let mac = t.mac;
|
||||
let mb = mac.as_bytes();
|
||||
let mut pac = [0; 6 + 6 * 16];
|
||||
pac[..6].fill(0xff);
|
||||
for i in 1..=16 {
|
||||
pac[i * 6..(i + 1) * 6].copy_from_slice(mb);
|
||||
}
|
||||
let ip = t.ip;
|
||||
let port = 9;
|
||||
match sock.send_to(&pac, (ip, port)).await {
|
||||
Ok(n) if n == pac.len() => t.good(),
|
||||
Ok(_) => t.bad(),
|
||||
Err(_) => t.errored(),
|
||||
}
|
||||
}
|
||||
|
||||
// pub async fn wake_query();
|
||||
@@ -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();
|
||||
}
|
||||
+3
-3
@@ -4,8 +4,8 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>wakey • home</title>
|
||||
<link rel="stylesheet" href="/home_2.css" />
|
||||
<script type="module" src="/home_2.js" defer></script>
|
||||
<link rel="stylesheet" href="home_2/styles.css" />
|
||||
<script type="module" src="home_2/main.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
@@ -27,7 +27,7 @@
|
||||
<div class="row muted" style="gap: 16px">
|
||||
<span
|
||||
>Uses query
|
||||
<span title="available keys: name, ip, mac, dev, nud"
|
||||
<span title="available keys: name, ips, macs, devs, nuds"
|
||||
>(?name=...)</span
|
||||
>
|
||||
on this page to view the status.<!-- and header (X-Target-Name) so either extractor
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { filter_array } from "./status.js";
|
||||
|
||||
export const qs = new URLSearchParams(location.search);
|
||||
|
||||
// $ is normally queryselector are we fr
|
||||
export const $ = (id) => document.getElementById(id);
|
||||
export const elName = $("name");
|
||||
export const elCheck = $("check");
|
||||
export const elWake = $("wake");
|
||||
export const elLog = $("log");
|
||||
export const elHtml = $("html");
|
||||
export const elLeases = $("leases_html");
|
||||
export const pill = $("status-pill");
|
||||
export const link = $("permalink");
|
||||
export const elPreview = $("preview");
|
||||
|
||||
export function setPill(kind, text) {
|
||||
pill.className = `pill ${kind}`;
|
||||
pill.textContent = text;
|
||||
}
|
||||
|
||||
export function setLink(name, clear) {
|
||||
const url = new URL(location.href);
|
||||
if (clear) url.searchParams.forEach((_, k) => url.searchParams.delete(k)); // FUCK
|
||||
/* const hasExtraFilters = filter_array.some(
|
||||
(k) => url.searchParams.getAll(k).length
|
||||
);
|
||||
if (hasExtraFilters) {
|
||||
// jus returns whatever; they are specifying further
|
||||
} else */ {
|
||||
if (name) url.searchParams.set("name", name);
|
||||
else url.searchParams.delete("name");
|
||||
}
|
||||
history.replaceState(null, "", url);
|
||||
link.href = url.toString();
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { elLeases } from "./dom.js";
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {{
|
||||
* expires_epoch: Number
|
||||
* rank?: Number
|
||||
* ip: String
|
||||
* mac: String
|
||||
* nud_state?: String
|
||||
* name?: String
|
||||
* }[]} leases
|
||||
* @returns
|
||||
*/
|
||||
export function renderLeases(leases) {
|
||||
if (!elLeases) return;
|
||||
if (!Array.isArray(leases) || leases.length === 0) {
|
||||
elLeases.textContent = "no leases";
|
||||
return;
|
||||
}
|
||||
const tbl = document.createElement("table");
|
||||
tbl.className = "table";
|
||||
|
||||
(
|
||||
tbl.tHead || tbl.createTHead()
|
||||
).innerHTML = `<tr><th></th><th>IP</th><th>MAC</th><th>Name</th><th>Expires</th></tr>`;
|
||||
|
||||
const tbd = tbl.tBodies.item(0) || tbl.createTBody();
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
for (const l of leases) {
|
||||
const tr = document.createElement("tr");
|
||||
const exp = Number(l.expires_epoch || 0);
|
||||
const expired = exp > 0 && exp <= nowSec;
|
||||
const when = exp > 0 ? new Date(exp * 1000) : null;
|
||||
const whenText = when ? when.toLocaleString() : "";
|
||||
let dotClass = "dot ok";
|
||||
if (expired) {
|
||||
dotClass = "dot bad";
|
||||
} /* rank from NUDState */ else if (typeof l?.rank === "number") {
|
||||
if (l.rank >= 5) dotClass = "dot ok";
|
||||
else if (l.rank >= 2) dotClass = "dot warn";
|
||||
else dotClass = "dot bad";
|
||||
}
|
||||
const title = expired
|
||||
? "expired"
|
||||
: l?.nud_state
|
||||
? String(l.nud_state).toLowerCase()
|
||||
: "unknown";
|
||||
const ip = l.ip || "";
|
||||
const mac = l.mac || "";
|
||||
const name = l.name || "";
|
||||
tr.innerHTML = `
|
||||
<td><span class="${dotClass}" title="${title}"></span></td>
|
||||
${Object.entries({ ip, mac, name })
|
||||
.map(
|
||||
([name, value]) =>
|
||||
`<td>${
|
||||
value &&
|
||||
`<a href="#" class="pick" data-value="${value}" title="filter by ${name}">${value}</a>`
|
||||
}</td>`
|
||||
)
|
||||
.join("\n")}
|
||||
<td><span class="tiny">${whenText}</span></td>`;
|
||||
tbd.appendChild(tr);
|
||||
}
|
||||
elLeases.innerHTML = "";
|
||||
elLeases.appendChild(tbl);
|
||||
}
|
||||
|
||||
export async function fetchLeases() {
|
||||
try {
|
||||
const r = await fetch("/api/dhcp_leases?include_state=1");
|
||||
if (!r.ok) return;
|
||||
const leases = await r.json();
|
||||
renderLeases(leases);
|
||||
} catch {}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import {
|
||||
elName,
|
||||
elCheck,
|
||||
elWake,
|
||||
elHtml,
|
||||
elLeases,
|
||||
elPreview,
|
||||
setLink,
|
||||
setPill,
|
||||
qs,
|
||||
} from "./dom.js";
|
||||
import {
|
||||
getName,
|
||||
saveName,
|
||||
loadName,
|
||||
extractHostLikeBackend,
|
||||
} from "./utils.js";
|
||||
import { fetchStatus } from "./status.js";
|
||||
import { fetchLeases } from "./leases.js";
|
||||
import { sendWake } from "./wake.js";
|
||||
|
||||
// delegated click handler
|
||||
function handlePickClick(e) {
|
||||
const a = e.target && e.target.closest("a.pick");
|
||||
if (!a) return;
|
||||
e.preventDefault();
|
||||
const v = a.getAttribute("data-value");
|
||||
pickTarget(v);
|
||||
}
|
||||
if (elHtml) elHtml.addEventListener("click", handlePickClick);
|
||||
if (elLeases) elLeases.addEventListener("click", handlePickClick);
|
||||
|
||||
function updatePreview() {
|
||||
const raw = getName(elName);
|
||||
const host = extractHostLikeBackend(raw);
|
||||
elPreview.textContent = host && host !== raw ? `→ ${host}` : "";
|
||||
}
|
||||
|
||||
function pickTarget(value) {
|
||||
const v = String(value || "").trim();
|
||||
if (!v) return;
|
||||
elName.value = v;
|
||||
updatePreview();
|
||||
saveName(v);
|
||||
setLink(v, true);
|
||||
fetchStatus(v);
|
||||
fetchLeases();
|
||||
}
|
||||
|
||||
// events
|
||||
elCheck.addEventListener("click", () => {
|
||||
const name = getName(elName);
|
||||
if (!name) return;
|
||||
saveName(name);
|
||||
setLink(name);
|
||||
fetchStatus(name);
|
||||
fetchLeases();
|
||||
});
|
||||
elWake.addEventListener("click", () => {
|
||||
const name = getName(elName);
|
||||
if (!name) return;
|
||||
saveName(name);
|
||||
setLink(name);
|
||||
sendWake(name);
|
||||
fetchLeases();
|
||||
});
|
||||
elName.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") elCheck.click();
|
||||
});
|
||||
elName.addEventListener("input", updatePreview);
|
||||
|
||||
// init
|
||||
const initial = loadName(qs);
|
||||
if (initial) {
|
||||
elName.value = initial;
|
||||
updatePreview();
|
||||
if (qs.has("name")) {
|
||||
setLink(initial);
|
||||
fetchStatus(initial);
|
||||
} else {
|
||||
fetchStatus();
|
||||
}
|
||||
fetchLeases();
|
||||
} else {
|
||||
setPill("warn", "unknown");
|
||||
updatePreview();
|
||||
fetchStatus();
|
||||
fetchLeases();
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import { elHtml, elLog, setPill, qs, pill } from "./dom.js";
|
||||
import { rankState } from "./utils.js";
|
||||
import { merge_wake_data, translate_wake_message } from "./wake.js";
|
||||
|
||||
// IpNeighLine
|
||||
const status_map = {
|
||||
ip: "ip",
|
||||
mac: "mac",
|
||||
state: "state",
|
||||
dev: "interface",
|
||||
};
|
||||
export const status_array = Object.keys(status_map);
|
||||
// Filters
|
||||
export const filter_array = ["ips", "devs", "nuds", "macs"];
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {String} name
|
||||
* @returns {URL}
|
||||
*/
|
||||
function buildStatusUrl(name) {
|
||||
const hasExtraFilters = filter_array.some((k) => qs.getAll(k).length);
|
||||
if (name && !hasExtraFilters) {
|
||||
return new URL(`/api/smart/${encodeURIComponent(name)}`, location.origin);
|
||||
}
|
||||
const u = new URL("/api/status", location.origin);
|
||||
if (name) u.searchParams.set("name", name);
|
||||
for (const k of filter_array) {
|
||||
const vals = qs.getAll(k);
|
||||
for (const v of vals) u.searchParams.append(k, v);
|
||||
}
|
||||
return u;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {{
|
||||
* has_wake?: true
|
||||
* table: {
|
||||
* wake_status?: Boolean
|
||||
* ips: String
|
||||
* dev: String
|
||||
* mac: String
|
||||
* state: String
|
||||
* }[]
|
||||
* filters: {
|
||||
* ips?: String[]
|
||||
* devs?: String[]
|
||||
* nuds?: String[]
|
||||
* macs?: String[]
|
||||
* }
|
||||
* }} data
|
||||
*/
|
||||
export function renderStatus(data) {
|
||||
const tbl = document.createElement("table");
|
||||
tbl.className = "table";
|
||||
tbl.innerHTML = `<thead><tr>${
|
||||
data.has_wake ? "<th>Wake status</th>" : ""
|
||||
}<th>IP</th><th>MAC</th><th>State</th><th>IF</th></tr></thead>`;
|
||||
|
||||
// sum hax
|
||||
const tbd = tbl.tBodies.item(0) || tbl.createTBody();
|
||||
for (const row of data.table) {
|
||||
if (data.has_wake && !row.wake_status)
|
||||
throw TypeError("specified has wake but no wake stats");
|
||||
const tr = document.createElement("tr");
|
||||
tr.innerHTML = `${
|
||||
row.wake_status
|
||||
? `<td><span class="dot ${
|
||||
row.wake_status == "succeed" ? "ok" : "bad"
|
||||
}" title="${translate_wake_message(row.wake_status)}"></span></td>`
|
||||
: ""
|
||||
}${Object.entries(status_map)
|
||||
.map(([field, description]) => {
|
||||
const value = row[field];
|
||||
return `<td>${
|
||||
value
|
||||
? `<a href="#" class="pick" data-value="${value}" title="filter by ${description}">${value}</a>`
|
||||
: ""
|
||||
}</td>`;
|
||||
})
|
||||
.join("")}`;
|
||||
tbd.appendChild(tr);
|
||||
}
|
||||
|
||||
elHtml.innerHTML = "";
|
||||
if (data.filters) {
|
||||
const parts = [];
|
||||
filter_array.forEach((field) => {
|
||||
if (Array.isArray(data.filters[field]) && data.filters[field].length)
|
||||
parts.push(`${field}=[${data.filters[field].join(", ")}]`);
|
||||
});
|
||||
|
||||
if (parts.length) {
|
||||
const info = document.createElement("div");
|
||||
info.className = "filters";
|
||||
info.textContent = `Filters: ${parts.join("; ")}`;
|
||||
elHtml.appendChild(info);
|
||||
}
|
||||
}
|
||||
elHtml.appendChild(tbl);
|
||||
|
||||
if ((data.table || []).length > 0) {
|
||||
const best = data.table.reduce((a, b) =>
|
||||
rankState(b.state) > rankState(a.state) ? b : a
|
||||
);
|
||||
const r = rankState(best.state);
|
||||
if (r >= 5) setPill("ok", "online");
|
||||
else if (r >= 2) setPill("warn", "maybe");
|
||||
else setPill("bad", "offline");
|
||||
if (data.filters.nuds?.length > 0) pill.textContent += " (filtered)";
|
||||
} else {
|
||||
setPill("warn", "unknown");
|
||||
}
|
||||
}
|
||||
/**
|
||||
* @param {String} name
|
||||
* @param {Boolean} render
|
||||
* @param {{
|
||||
* ip: String,
|
||||
* mac: String,
|
||||
* status:
|
||||
* "incomplete" | "succeed" | "nonexistent_address" | "wrong_size"
|
||||
* }} data_wake
|
||||
* @returns {{
|
||||
* has_wake: false
|
||||
* table: {
|
||||
* ip: String
|
||||
* dev: String
|
||||
* mac: String
|
||||
* state: String
|
||||
* }[]
|
||||
* filters: {
|
||||
* ips?: String[]
|
||||
* devs?: String[]
|
||||
* nuds?: String[]
|
||||
* macs?: String[]
|
||||
* }
|
||||
* } | {
|
||||
* has_wake: true
|
||||
* table: {
|
||||
* wake_status: Boolean
|
||||
* ips: String
|
||||
* dev: String
|
||||
* mac: String
|
||||
* state: String
|
||||
* }[]
|
||||
* filters: {
|
||||
* ips?: String[]
|
||||
* devs?: String[]
|
||||
* nuds?: String[]
|
||||
* macs?: String[]
|
||||
* }
|
||||
* }}
|
||||
*/
|
||||
export async function fetchStatus(name, render = true, data_wake) {
|
||||
setPill("warn", "checking…");
|
||||
const u = buildStatusUrl(name);
|
||||
elLog.textContent = "GET " + u.pathname + u.search;
|
||||
try {
|
||||
const r = await fetch(u);
|
||||
if (!r.ok) {
|
||||
let msg = String(r.status);
|
||||
try {
|
||||
/** @type {{error: string}} */
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* this one does not have data wake
|
||||
* @type {{
|
||||
* table: {
|
||||
* ip: String
|
||||
* dev: String
|
||||
* mac: String
|
||||
* state: String
|
||||
* wake_status?: String
|
||||
* }[]
|
||||
* filters: {
|
||||
* ips?: String[]
|
||||
* devs?: String[]
|
||||
* nuds?: String[]
|
||||
* macs?: String[]
|
||||
* }
|
||||
* has_wake?: true
|
||||
* }}
|
||||
*/
|
||||
const data = await r.json();
|
||||
|
||||
if (data_wake) {
|
||||
data.table = merge_wake_data(data.table, data_wake);
|
||||
data.has_wake = true;
|
||||
}
|
||||
if (render) renderStatus(data);
|
||||
return data;
|
||||
} catch (e) {
|
||||
elLog.textContent = "status error: " + e;
|
||||
setPill("bad", "error");
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,13 @@ body {
|
||||
padding: 0;
|
||||
font-family: system-ui, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
wrap {
|
||||
margin-inline: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
min-height: 100dvh;
|
||||
@@ -0,0 +1,55 @@
|
||||
export function getName(elName) {
|
||||
return (elName.value || "").trim();
|
||||
}
|
||||
|
||||
export function saveName(name) {
|
||||
try {
|
||||
localStorage.setItem("wakey:name", name);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export function loadName(qs) {
|
||||
return qs.get("name") || localStorage.getItem("wakey:name") || "";
|
||||
}
|
||||
|
||||
export function extractHostLikeBackend(input) {
|
||||
let s = String(input || "").trim();
|
||||
if (!s) return "";
|
||||
const schemeIdx = s.indexOf("://");
|
||||
if (schemeIdx >= 0) s = s.slice(schemeIdx + 3);
|
||||
else if (s.startsWith("//")) s = s.slice(2);
|
||||
const at = s.lastIndexOf("@");
|
||||
if (at >= 0) s = s.slice(at + 1);
|
||||
if (s.startsWith("[")) {
|
||||
const end = s.indexOf("]");
|
||||
if (end > 1) s = s.slice(1, end);
|
||||
} else {
|
||||
const slash = s.indexOf("/");
|
||||
if (slash >= 0) s = s.slice(0, slash);
|
||||
const colon = s.lastIndexOf(":");
|
||||
if (colon > 0 && (s.match(/:/g) || []).length === 1) {
|
||||
const port = s.slice(colon + 1);
|
||||
if (/^\d+$/.test(port)) s = s.slice(0, colon);
|
||||
}
|
||||
}
|
||||
return s.trim();
|
||||
}
|
||||
|
||||
export function rankState(s) {
|
||||
const key = String(s || "")
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
return (
|
||||
{
|
||||
PERMANENT: 5,
|
||||
REACHABLE: 5,
|
||||
STALE: 4,
|
||||
DELAY: 3,
|
||||
PROBE: 3,
|
||||
INCOMPLETE: 3,
|
||||
NOARP: 2,
|
||||
NONE: 1,
|
||||
FAILED: 0,
|
||||
}[key] ?? 0
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { elHtml, elLog, setPill } from "./dom.js";
|
||||
import { fetchStatus, renderStatus } from "./status.js";
|
||||
|
||||
/**
|
||||
* @param {String} name just plain name
|
||||
*/
|
||||
export async function sendWake(name) {
|
||||
const data = await fetchStatus(name, false);
|
||||
if (!data) return; // can not proceed; theres nothing.
|
||||
const wake_targets = data.table.map(({ ip, mac }) => {
|
||||
return { ip, mac };
|
||||
});
|
||||
setPill("warn", "waking…");
|
||||
elLog.textContent = "POST /api/wake";
|
||||
try {
|
||||
const r = await fetch(`/api/wake`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(wake_targets),
|
||||
});
|
||||
if (!r.ok) {
|
||||
let msg = String(r.status);
|
||||
try {
|
||||
const err = await r.clone().json();
|
||||
msg = err.error || JSON.stringify(err);
|
||||
} catch {
|
||||
msg = await r.text();
|
||||
}
|
||||
elLog.textContent = `wake error: ${msg}`;
|
||||
setPill("bad", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const j = await r.json();
|
||||
|
||||
if (!j.success) {
|
||||
elLog.textContent = `wake error: ${j.error}`;
|
||||
setPill("bad", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const result = j.result;
|
||||
data.table = merge_wake_data(data.table, result);
|
||||
data.has_wake = true;
|
||||
|
||||
renderStatus(data);
|
||||
|
||||
setTimeout(() => fetchStatus(name, undefined, result), 2000); // long ass timeout
|
||||
} catch (e) {
|
||||
elLog.textContent = "wake error: " + e;
|
||||
setPill("bad", "error");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {{
|
||||
* ip: String
|
||||
* dev: String
|
||||
* mac: String
|
||||
* state: String
|
||||
* }[]} table
|
||||
* @param {{
|
||||
* ip: String,
|
||||
* mac: String,
|
||||
* status: "incomplete" | "succeed" | "nonexistent_address" | "wrong_size"
|
||||
* }[]} wake
|
||||
* @returns {{
|
||||
* ip: String
|
||||
* dev: String
|
||||
* mac: String
|
||||
* state: String
|
||||
* }[] | {
|
||||
* ip: String
|
||||
* dev: String
|
||||
* mac: String
|
||||
* state: String
|
||||
* wake_status: boolean
|
||||
* }[]}
|
||||
*/
|
||||
export function merge_wake_data(table, wake) {
|
||||
if (!wake) return table;
|
||||
if (wake.length != table.length)
|
||||
throw TypeError(
|
||||
"wake status table and status table not of the same length"
|
||||
);
|
||||
const return_array = [];
|
||||
let linear_failed = false;
|
||||
for (const [index, entry] of table.entries()) {
|
||||
if (wake[index].ip != entry.ip || wake[index].mac != entry.mac) {
|
||||
linear_failed = true;
|
||||
break;
|
||||
} // use alternative method
|
||||
|
||||
return_array.push({ wake_status: wake[index].status, ...entry });
|
||||
}
|
||||
|
||||
// never happening AHH
|
||||
if (linear_failed) {
|
||||
return_array = [];
|
||||
const wake_map = new Map();
|
||||
wake.forEach(({ ip, mac, status }) => {
|
||||
wake_map.set(JSON.stringify({ ip, mac }), status);
|
||||
});
|
||||
return_array = table.map((entry) => {
|
||||
const { ip, mac } = entry;
|
||||
return {
|
||||
wake_status: wake_map.get(JSON.stringify({ ip, mac })),
|
||||
...entry,
|
||||
};
|
||||
});
|
||||
}
|
||||
//
|
||||
return return_array;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {"incomplete" | "succeed" | "nonexistent_address" | "wrong_size" | any} wake_msg
|
||||
* @returns {string}
|
||||
*/
|
||||
export function translate_wake_message(wake_msg) {
|
||||
switch (wake_msg) {
|
||||
case "incomplete":
|
||||
return "Incomplete address (both ip and MAC required)"
|
||||
case "succeed":
|
||||
return "Wake request sent successfully"
|
||||
case "nonexistent_address":
|
||||
return "Errored pinging this address (nonexistent address)"
|
||||
case "wrong_size":
|
||||
return "Wake request malformed"
|
||||
default:
|
||||
return "Unknown"
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user