moving things around
This commit is contained in:
Generated
+7
@@ -1127,6 +1127,12 @@ version = "1.0.18"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512"
|
checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "urlencoding"
|
||||||
|
version = "2.1.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "valuable"
|
name = "valuable"
|
||||||
version = "0.1.1"
|
version = "0.1.1"
|
||||||
@@ -1147,6 +1153,7 @@ dependencies = [
|
|||||||
"strum",
|
"strum",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"urlencoding",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ serde_with = { version = "3.14.0", features = ["json"] }
|
|||||||
strum = { version = "0.27.2", features = ["derive", "strum_macros"] }
|
strum = { version = "0.27.2", features = ["derive", "strum_macros"] }
|
||||||
thiserror = "2.0.16"
|
thiserror = "2.0.16"
|
||||||
tokio = { version = "1.47.1", features = ["fs", "process", "rt-multi-thread"] }
|
tokio = { version = "1.47.1", features = ["fs", "process", "rt-multi-thread"] }
|
||||||
|
urlencoding = "2.1.3"
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
opt-level = "z"
|
opt-level = "z"
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ use thiserror::Error;
|
|||||||
|
|
||||||
#[derive(Debug, Display, Error)]
|
#[derive(Debug, Display, Error)]
|
||||||
pub enum IPNeighParseError {
|
pub enum IPNeighParseError {
|
||||||
IpWhere,
|
IpWhere, // i never seen a ip neigh where the first thing aint an ip
|
||||||
IpParseError(AddrParseError),
|
IpParseError(AddrParseError),
|
||||||
DevWhere,
|
// DevWhere,
|
||||||
MacParseError(macaddr::ParseError),
|
MacParseError(macaddr::ParseError),
|
||||||
StateWhere,
|
StateWhere, // i never seen a ip neigh without the big FAILED at the end
|
||||||
StateParseError(strum::ParseError),
|
StateParseError(strum::ParseError),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+25
-34
@@ -1,16 +1,27 @@
|
|||||||
use axum::{Router, response::Html, routing::get};
|
use axum::{
|
||||||
|
Router,
|
||||||
|
extract::Query,
|
||||||
|
http::StatusCode,
|
||||||
|
response::{Html, IntoResponse},
|
||||||
|
routing::{get, post},
|
||||||
|
};
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
mod arpparse;
|
mod arpparse;
|
||||||
mod route;
|
mod route;
|
||||||
mod utils;
|
mod utils;
|
||||||
use route::*;
|
use crate::{
|
||||||
use utils::*;
|
route::DeviceQuery,
|
||||||
|
utils::{ping::ping_ip, query::get_macs_2_1, wake::wake},
|
||||||
|
};
|
||||||
|
|
||||||
const MACHINE_NAME: &str = "lda.lan";
|
const MACHINE_NAME: &str = "lda.lan";
|
||||||
|
|
||||||
async fn home() -> Html<String> {
|
async fn home() -> Html<String> {
|
||||||
Html(format!(
|
Html(format!(
|
||||||
r#"
|
r#"
|
||||||
<html>
|
<html>
|
||||||
<body>
|
<body>
|
||||||
|
<p><a href="/home_2">Alternate UI</a></p>
|
||||||
<p>the machine is {}! <a href="/status">Status</a></p>
|
<p>the machine is {}! <a href="/status">Status</a></p>
|
||||||
<form method="POST" action="/wake">
|
<form method="POST" action="/wake">
|
||||||
<button type="submit">Wake LDA</button>
|
<button type="submit">Wake LDA</button>
|
||||||
@@ -31,34 +42,15 @@ async fn home() -> Html<String> {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn wake_handler() -> axum::response::Result<&'static str> {
|
async fn wake_handler(
|
||||||
match wake(MACHINE_NAME).await {
|
Query(DeviceQuery { name }): Query<DeviceQuery>,
|
||||||
Ok(x) if x > 0 => Ok("Packet sent!"),
|
) -> axum::response::Result<impl IntoResponse> {
|
||||||
_ => Err("Wake failed".into()),
|
match wake(name.as_deref().unwrap_or(MACHINE_NAME)).await {
|
||||||
|
Ok(x) if x > 0 => Ok((StatusCode::ACCEPTED, format!("{x} packets sent!"))),
|
||||||
|
_ => Err((StatusCode::GATEWAY_TIMEOUT, "Wake failed").into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn status() -> Html<String> {
|
|
||||||
// let formatted_ips = match get_ips(MACHINE_NAME).await {
|
|
||||||
// Ok(ips) => {
|
|
||||||
// let string: String = ips
|
|
||||||
// .iter()
|
|
||||||
// .map(|ip| format!("<tr><td>{ip}</td></tr>"))
|
|
||||||
// .collect();
|
|
||||||
// format!(
|
|
||||||
// r#"<p>the ips of {m} are:</p>
|
|
||||||
// <table>
|
|
||||||
// <tr><th>IP</th></tr>
|
|
||||||
// {string}
|
|
||||||
// </table>"#,
|
|
||||||
// m = MACHINE_NAME
|
|
||||||
// )
|
|
||||||
// }
|
|
||||||
// Err(e) => format!("<p>error getting ips: {e}</p>"),
|
|
||||||
// };
|
|
||||||
Html(status_build(MACHINE_NAME).await)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn status_build(machine_name: &str) -> String {
|
pub async fn status_build(machine_name: &str) -> String {
|
||||||
let formatted_macs = match get_macs_2_1(machine_name).await {
|
let formatted_macs = match get_macs_2_1(machine_name).await {
|
||||||
Ok(table) => {
|
Ok(table) => {
|
||||||
@@ -101,16 +93,15 @@ pub async fn status_build(machine_name: &str) -> String {
|
|||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> color_eyre::Result<()> {
|
async fn main() -> color_eyre::Result<()> {
|
||||||
|
use crate::route::{api_status, get_status_2, home_2_route};
|
||||||
|
|
||||||
color_eyre::install()?;
|
color_eyre::install()?;
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.route("/", get(home))
|
.route("/", get(home))
|
||||||
.route("/home_2", get(home_2))
|
.merge(home_2_route())
|
||||||
.route("/wake", axum::routing::post(wake_handler))
|
.route("/wake", post(wake_handler))
|
||||||
// .route("/wake", axum::routing::post(wake_handler))
|
|
||||||
// .route("/status", get(status))
|
|
||||||
.route("/status", get(get_status_2))
|
.route("/status", get(get_status_2))
|
||||||
.route("/api/status/{name}", get(get_status_json))
|
.merge(api_status());
|
||||||
;
|
|
||||||
|
|
||||||
let port = TcpListener::bind("0.0.0.0:12012").await?;
|
let port = TcpListener::bind("0.0.0.0:12012").await?;
|
||||||
axum::serve(port, app.into_make_service()).await?;
|
axum::serve(port, app.into_make_service()).await?;
|
||||||
|
|||||||
+62
-12
@@ -1,19 +1,46 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
Json,
|
Json, Router,
|
||||||
extract::{Path, Query},
|
extract::{Path, Query},
|
||||||
http::StatusCode,
|
http::{StatusCode, header},
|
||||||
response::{Html, IntoResponse},
|
response::{Html, IntoResponse, Redirect},
|
||||||
|
routing::get,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{MACHINE_NAME, arpparse, status_build, utils::get_macs_1};
|
use crate::{MACHINE_NAME, arpparse, status_build, utils::query::get_macs_1};
|
||||||
|
|
||||||
pub async fn home_2() -> Html<&'static str> {
|
async fn home_2() -> Html<&'static str> {
|
||||||
Html(include_str!("../static/home_2"))
|
Html(include_str!("../static/home_2"))
|
||||||
}
|
}
|
||||||
|
async fn home_2_css() -> impl IntoResponse {
|
||||||
|
(
|
||||||
|
[
|
||||||
|
(header::CONTENT_TYPE, "text/css; charset=utf-8"),
|
||||||
|
(header::CACHE_CONTROL, "public, max-age=300"),
|
||||||
|
],
|
||||||
|
include_str!("../static/assets/home_2.css"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
async fn home_2_js() -> impl IntoResponse {
|
||||||
|
(
|
||||||
|
[
|
||||||
|
(header::CONTENT_TYPE, "application/javascript"),
|
||||||
|
(header::CACHE_CONTROL, "public, max-age=300"),
|
||||||
|
],
|
||||||
|
include_str!("../static/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
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default, Clone, Hash, serde::Deserialize)]
|
#[derive(Debug, Default, Clone, Hash, serde::Deserialize)]
|
||||||
pub struct DeviceQuery {
|
pub struct DeviceQuery {
|
||||||
name: Option<String>,
|
pub name: Option<String>,
|
||||||
// ip: Option<IpAddr>,
|
// ip: Option<IpAddr>,
|
||||||
// #[serde(deserialize_with = "des_opm")]
|
// #[serde(deserialize_with = "des_opm")]
|
||||||
// mac: Option<MacAddr>,
|
// mac: Option<MacAddr>,
|
||||||
@@ -35,13 +62,24 @@ pub struct StatusError {
|
|||||||
}
|
}
|
||||||
// pub struct statuserror? table? and error? on status? what is the strat here
|
// pub struct statuserror? table? and error? on status? what is the strat here
|
||||||
|
|
||||||
pub async fn get_status_json(p: Option<Path<NamePath>>) -> impl IntoResponse {
|
pub async fn get_status_json(
|
||||||
let name = match p {
|
// p: Option<Path<NamePath>>,
|
||||||
Some(Path(NamePath { name })) => name,
|
Query(DeviceQuery { name }): Query<DeviceQuery>,
|
||||||
_ => MACHINE_NAME.to_owned(),
|
) -> impl IntoResponse {
|
||||||
};
|
let name = /* p
|
||||||
|
.map(|Path(n)| n.name)
|
||||||
|
.or */(name)
|
||||||
|
.unwrap_or_else(|| MACHINE_NAME.to_owned());
|
||||||
match get_macs_1(&name).await {
|
match get_macs_1(&name).await {
|
||||||
Ok(table) => (StatusCode::OK, Json(Status { name, table })).into_response(),
|
Ok(table) => {
|
||||||
|
// let canonical = format!("/api/status?name={name}");
|
||||||
|
(
|
||||||
|
StatusCode::OK,
|
||||||
|
// [(header::LINK, format!("<{canonical}>; rel=\"canonical\""))],
|
||||||
|
Json(Status { name, table }),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
Err(error) => (
|
Err(error) => (
|
||||||
StatusCode::BAD_GATEWAY,
|
StatusCode::BAD_GATEWAY,
|
||||||
Json(StatusError {
|
Json(StatusError {
|
||||||
@@ -52,6 +90,18 @@ pub async fn get_status_json(p: Option<Path<NamePath>>) -> impl IntoResponse {
|
|||||||
.into_response(), // holy clutch. Couldve been disasterous
|
.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}",
|
||||||
|
name = urlencoding::encode(&name) // just for
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn api_status() -> Router {
|
||||||
|
Router::new()
|
||||||
|
.route("/api/status/{name}", get(status_redirect)) // api/status should be like the entire ip neigh br lan like idk like
|
||||||
|
.route("/api/status", get(get_status_json))
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn get_status_2(q: Query<DeviceQuery>) -> Html<String> {
|
pub async fn get_status_2(q: Query<DeviceQuery>) -> Html<String> {
|
||||||
let name = match q {
|
let name = match q {
|
||||||
|
|||||||
+6
-110
@@ -7,53 +7,15 @@ pub const LDA_MACS: [[u8; 6]; 2] = [
|
|||||||
|
|
||||||
/// is it time to lookup host lda.lan for this...
|
/// 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 static LDA_MACS_2: LazyLock<[MacAddr; 2]> = LazyLock::new(|| LDA_MACS.map(MacAddr::from));
|
||||||
pub async fn wake(machine_name: &str) -> io::Result<u32> {
|
pub mod wake;
|
||||||
let suh = UdpSocket::bind("0.0.0.0:0").await?;
|
use std::{net::IpAddr, sync::LazyLock};
|
||||||
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)
|
|
||||||
}
|
|
||||||
use std::{collections::HashSet, net::IpAddr, sync::LazyLock, time::Duration};
|
|
||||||
|
|
||||||
use macaddr::MacAddr;
|
use macaddr::MacAddr;
|
||||||
use tokio::{
|
use tokio::io;
|
||||||
io,
|
|
||||||
net::{TcpStream, ToSocketAddrs, UdpSocket},
|
|
||||||
time::timeout,
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::arpparse::{self, IpNeighLine, NUDState};
|
|
||||||
|
|
||||||
/// generic so you can do "123.45.67.89:22" or "lda.lan:22" as an input
|
/// generic so you can do "123.45.67.89:22" or "lda.lan:22" as an input
|
||||||
// this is so bad
|
// this is so bad
|
||||||
pub async fn ping_ip<T: ToSocketAddrs>(addr: T) -> bool {
|
pub mod ping;
|
||||||
timeout(Duration::from_secs(1), TcpStream::connect(addr))
|
|
||||||
.await
|
|
||||||
.is_ok()
|
|
||||||
}
|
|
||||||
pub async fn _ping_ip_2<T: ToSocketAddrs>(_addr: T) -> bool {
|
|
||||||
todo!("use icmp")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// this is because i like [`IpAddr`] more than [`SocketAddr`](std::net::SocketAddr)
|
/// this is because i like [`IpAddr`] more than [`SocketAddr`](std::net::SocketAddr)
|
||||||
pub async fn get_ips(machine_name: &str) -> io::Result<Vec<IpAddr>> {
|
pub async fn get_ips(machine_name: &str) -> io::Result<Vec<IpAddr>> {
|
||||||
@@ -63,71 +25,5 @@ pub async fn get_ips(machine_name: &str) -> io::Result<Vec<IpAddr>> {
|
|||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_macs_1(machine_name: &str) -> io::Result<Vec<arpparse::IpNeighLine>> {
|
pub mod cmd;
|
||||||
let ips = get_ips(machine_name).await?;
|
pub mod query;
|
||||||
let futures = ips.iter().map(|ip| {
|
|
||||||
let ip = ip.to_canonical();
|
|
||||||
async move {
|
|
||||||
let o = exec_command(
|
|
||||||
"ip",
|
|
||||||
["neigh", "show", "to", &ip.to_string(), "dev", "br-lan"],
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
if !o.status.success() {
|
|
||||||
return Err(io::Error::other(format!(
|
|
||||||
"`ip neigh` failed for {ip} (status: {st}): {err}",
|
|
||||||
st = o.status,
|
|
||||||
err = String::from_utf8_lossy(&o.stderr),
|
|
||||||
)));
|
|
||||||
};
|
|
||||||
Ok(String::from_utf8_lossy(&o.stdout)
|
|
||||||
.lines()
|
|
||||||
.map(arpparse::parse_ip_neigh_line)
|
|
||||||
.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 */
|
|
||||||
.collect())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn exec_command<S: AsRef<std::ffi::OsStr>>(
|
|
||||||
cmd: S,
|
|
||||||
args: impl IntoIterator<Item = S>,
|
|
||||||
) -> io::Result<std::process::Output> {
|
|
||||||
let mut u = tokio::process::Command::new(cmd);
|
|
||||||
u.args(args);
|
|
||||||
u.output().await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_macs_2_1(machine_name: &str) -> io::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) -> io::Result<HashSet<MacAddr>> {
|
|
||||||
Ok(get_macs_1(machine_name)
|
|
||||||
.await?
|
|
||||||
.into_iter()
|
|
||||||
.filter_map(
|
|
||||||
|IpNeighLine {
|
|
||||||
ip: _,
|
|
||||||
dev: _,
|
|
||||||
mac,
|
|
||||||
state: _,
|
|
||||||
}| mac,
|
|
||||||
)
|
|
||||||
.collect())
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
use std::io;
|
||||||
|
|
||||||
|
pub(crate) async fn exec_command<S: AsRef<std::ffi::OsStr>>(
|
||||||
|
cmd: S,
|
||||||
|
args: impl IntoIterator<Item = S>,
|
||||||
|
) -> io::Result<std::process::Output> {
|
||||||
|
let mut u = tokio::process::Command::new(cmd);
|
||||||
|
u.args(args);
|
||||||
|
u.output().await
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use tokio::{net::{TcpStream, ToSocketAddrs}, time::timeout};
|
||||||
|
|
||||||
|
pub async fn ping_ip<T: ToSocketAddrs>(addr: T) -> bool {
|
||||||
|
timeout(Duration::from_secs(1), TcpStream::connect(addr))
|
||||||
|
.await
|
||||||
|
.is_ok()
|
||||||
|
}
|
||||||
|
pub async fn _ping_ip_2<T: ToSocketAddrs>(_addr: T) -> bool {
|
||||||
|
todo!("use icmp")
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
use std::{collections::HashSet, net::IpAddr};
|
||||||
|
|
||||||
|
use macaddr::MacAddr;
|
||||||
|
use tokio::io;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
arpparse::{self, IpNeighLine, NUDState},
|
||||||
|
utils::{cmd::exec_command, get_ips},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub async fn get_macs_2_1(machine_name: &str) -> io::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) -> io::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) -> io::Result<Vec<arpparse::IpNeighLine>> {
|
||||||
|
let ips = get_ips(machine_name).await?;
|
||||||
|
let futures = ips.iter().map(|ip| {
|
||||||
|
let ip = ip.to_canonical();
|
||||||
|
async move {
|
||||||
|
let o = exec_command(
|
||||||
|
"ip",
|
||||||
|
["neigh", "show", "to", &ip.to_string(), "dev", "br-lan"],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
if !o.status.success() {
|
||||||
|
return Err(io::Error::other(format!(
|
||||||
|
"`ip neigh` failed for {ip} (status: {st}): {err}",
|
||||||
|
st = o.status,
|
||||||
|
err = String::from_utf8_lossy(&o.stderr),
|
||||||
|
)));
|
||||||
|
};
|
||||||
|
Ok(String::from_utf8_lossy(&o.stdout)
|
||||||
|
.lines()
|
||||||
|
.map(arpparse::parse_ip_neigh_line)
|
||||||
|
.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 */
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
use std::{io, net::IpAddr};
|
||||||
|
|
||||||
|
use tokio::net::UdpSocket;
|
||||||
|
|
||||||
|
use crate::utils::{query::get_macs_2_mac, LDA_MACS_2};
|
||||||
|
|
||||||
|
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,113 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: light dark;
|
||||||
|
--bg: #0b0b0b;
|
||||||
|
--fg: #e6e6e6;
|
||||||
|
--muted: #888;
|
||||||
|
--ok: #17a34a;
|
||||||
|
--warn: #d97706;
|
||||||
|
--bad: #dc2626;
|
||||||
|
--btn: #2563eb;
|
||||||
|
--card: #111827;
|
||||||
|
}
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-family: system-ui, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
display: flex;
|
||||||
|
min-height: 100dvh;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
.wrap {
|
||||||
|
width: min(900px, 95vw);
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 18px;
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.muted {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
input[type="text"] {
|
||||||
|
flex: 1 1 240px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid #2a2a2a;
|
||||||
|
background: #0f0f0f;
|
||||||
|
color: var(--fg);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid #2a2a2a;
|
||||||
|
background: var(--btn);
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
button.secondary {
|
||||||
|
background: #1f2937;
|
||||||
|
}
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.pill {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
border: 1px solid #2a2a2a;
|
||||||
|
}
|
||||||
|
.ok {
|
||||||
|
background: #052e1a;
|
||||||
|
border-color: #064e3b;
|
||||||
|
color: #86efac;
|
||||||
|
}
|
||||||
|
.warn {
|
||||||
|
background: #2b1800;
|
||||||
|
border-color: #7c2d12;
|
||||||
|
color: #fbbf24;
|
||||||
|
}
|
||||||
|
.bad {
|
||||||
|
background: #330b0b;
|
||||||
|
border-color: #7f1d1d;
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
border: 1px solid #2a2a2a;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--card);
|
||||||
|
}
|
||||||
|
#out {
|
||||||
|
min-height: 100px;
|
||||||
|
}
|
||||||
|
pre {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-wrap: break-word;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
a {
|
||||||
|
color: #93c5fd;
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
const qs = new URLSearchParams(location.search);
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
const elName = $("name");
|
||||||
|
const elCheck = $("check");
|
||||||
|
const elWake = $("wake");
|
||||||
|
const elLog = $("log");
|
||||||
|
const elHtml = $("html");
|
||||||
|
const pill = $("status-pill");
|
||||||
|
const link = $("permalink");
|
||||||
|
|
||||||
|
function setPill(kind, text) {
|
||||||
|
pill.className = `pill ${kind}`;
|
||||||
|
pill.textContent = text;
|
||||||
|
}
|
||||||
|
function setLink(name) {
|
||||||
|
const url = new URL(location.href);
|
||||||
|
if (name) url.searchParams.set("name", name);
|
||||||
|
else url.searchParams.delete("name");
|
||||||
|
history.replaceState(null, "", url);
|
||||||
|
link.href = url.toString();
|
||||||
|
}
|
||||||
|
function getName() {
|
||||||
|
return (elName.value || "").trim();
|
||||||
|
}
|
||||||
|
function saveName(name) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem("wakey:name", name);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
function loadName() {
|
||||||
|
return qs.get("name") || localStorage.getItem("wakey:name") || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function rankState(s) {
|
||||||
|
return (
|
||||||
|
{
|
||||||
|
Permanent: 5,
|
||||||
|
Reachable: 5,
|
||||||
|
Stale: 4,
|
||||||
|
Delay: 3,
|
||||||
|
Probe: 3,
|
||||||
|
Incomplete: 3,
|
||||||
|
Noarp: 2,
|
||||||
|
None: 1,
|
||||||
|
Failed: 0,
|
||||||
|
}[s] ?? 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStatus(data) {
|
||||||
|
// data: { name: string, table: Array<{ ip, dev, mac, state }>} | { name, error }
|
||||||
|
const tbl = document.createElement("table");
|
||||||
|
tbl.innerHTML = `<tr><th>IP</th><th>MAC</th><th>State</th><th>IF</th></tr>`;
|
||||||
|
for (const row of data.table || []) {
|
||||||
|
const tr = document.createElement("tr");
|
||||||
|
const mac = row.mac ?? "";
|
||||||
|
const dev = row.dev ?? "";
|
||||||
|
tr.innerHTML = `<td>${row.ip}</td><td>${mac}</td><td>${row.state}</td><td>${dev}</td>`;
|
||||||
|
tbl.appendChild(tr);
|
||||||
|
}
|
||||||
|
elHtml.innerHTML = "";
|
||||||
|
elHtml.appendChild(tbl);
|
||||||
|
|
||||||
|
if ((data.table || []).length > 0) {
|
||||||
|
const best = data.table.reduce((a, b) =>
|
||||||
|
rankState(b.state) > rankState(a.state) ? b : a
|
||||||
|
);
|
||||||
|
const r = rankState(best.state);
|
||||||
|
if (r >= 5) setPill("ok", "online");
|
||||||
|
else if (r >= 2) setPill("warn", "maybe");
|
||||||
|
else setPill("bad", "offline");
|
||||||
|
} else {
|
||||||
|
setPill("warn", "unknown");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchStatus(name) {
|
||||||
|
setPill("warn", "checking…");
|
||||||
|
elLog.textContent = "GET /api/status?name=" + name;
|
||||||
|
elHtml.innerHTML = "";
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/status?name=${encodeURIComponent(name)}`);
|
||||||
|
if (!r.ok) {
|
||||||
|
const err = await r.json().catch(() => ({}));
|
||||||
|
elLog.textContent = `status error: ${err.error || r.status}`;
|
||||||
|
setPill("bad", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await r.json();
|
||||||
|
renderStatus(data);
|
||||||
|
} catch (e) {
|
||||||
|
elLog.textContent = "status error: " + e;
|
||||||
|
setPill("bad", "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendWake(name) {
|
||||||
|
setPill("warn", "waking…");
|
||||||
|
elLog.textContent = "POST /wake?name=" + name;
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/wake?name=${encodeURIComponent(name)}`, {
|
||||||
|
method: "POST",
|
||||||
|
// headers: { "X-Target-Name": name },
|
||||||
|
});
|
||||||
|
const t = await r.text();
|
||||||
|
elLog.textContent = t || "ok";
|
||||||
|
// Optional: recheck after small delay
|
||||||
|
setTimeout(() => fetchStatus(name), 600);
|
||||||
|
} catch (e) {
|
||||||
|
elLog.textContent = "wake error: " + e;
|
||||||
|
setPill("bad", "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
elCheck.addEventListener("click", () => {
|
||||||
|
const name = getName();
|
||||||
|
if (!name) return;
|
||||||
|
saveName(name);
|
||||||
|
setLink(name);
|
||||||
|
fetchStatus(name);
|
||||||
|
});
|
||||||
|
elWake.addEventListener("click", () => {
|
||||||
|
const name = getName();
|
||||||
|
if (!name) return;
|
||||||
|
saveName(name);
|
||||||
|
setLink(name);
|
||||||
|
sendWake(name);
|
||||||
|
});
|
||||||
|
elName.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Enter") elCheck.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
// init
|
||||||
|
const initial = loadName();
|
||||||
|
if (initial) {
|
||||||
|
elName.value = initial;
|
||||||
|
// setLink(initial); // when the permalink doing YOUR job
|
||||||
|
// auto-check on load in this A/B page
|
||||||
|
fetchStatus(initial);
|
||||||
|
} else {
|
||||||
|
setPill("warn", "unknown");
|
||||||
|
}
|
||||||
+4
-222
@@ -4,126 +4,13 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>wakey • home_2</title>
|
<title>wakey • home_2</title>
|
||||||
<style>
|
<link rel="stylesheet" href="/home_2.css" />
|
||||||
:root {
|
<script type="module" src="/home_2.js" defer></script>
|
||||||
color-scheme: light dark;
|
|
||||||
--bg: #0b0b0b;
|
|
||||||
--fg: #e6e6e6;
|
|
||||||
--muted: #888;
|
|
||||||
--ok: #17a34a;
|
|
||||||
--warn: #d97706;
|
|
||||||
--bad: #dc2626;
|
|
||||||
--btn: #2563eb;
|
|
||||||
--card: #111827;
|
|
||||||
}
|
|
||||||
html,
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
font-family: system-ui, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
|
|
||||||
}
|
|
||||||
body {
|
|
||||||
display: flex;
|
|
||||||
min-height: 100dvh;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: var(--bg);
|
|
||||||
color: var(--fg);
|
|
||||||
}
|
|
||||||
.wrap {
|
|
||||||
width: min(900px, 95vw);
|
|
||||||
display: grid;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
h1 {
|
|
||||||
font-size: 18px;
|
|
||||||
margin: 0;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.muted {
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
.row {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
input[type="text"] {
|
|
||||||
flex: 1 1 240px;
|
|
||||||
padding: 10px 12px;
|
|
||||||
border-radius: 10px;
|
|
||||||
border: 1px solid #2a2a2a;
|
|
||||||
background: #0f0f0f;
|
|
||||||
color: var(--fg);
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
button {
|
|
||||||
padding: 10px 14px;
|
|
||||||
border-radius: 10px;
|
|
||||||
border: 1px solid #2a2a2a;
|
|
||||||
background: var(--btn);
|
|
||||||
color: white;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
button.secondary {
|
|
||||||
background: #1f2937;
|
|
||||||
}
|
|
||||||
button:disabled {
|
|
||||||
opacity: 0.6;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
.pill {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 4px 8px;
|
|
||||||
border-radius: 999px;
|
|
||||||
font-size: 12px;
|
|
||||||
border: 1px solid #2a2a2a;
|
|
||||||
}
|
|
||||||
.ok {
|
|
||||||
background: #052e1a;
|
|
||||||
border-color: #064e3b;
|
|
||||||
color: #86efac;
|
|
||||||
}
|
|
||||||
.warn {
|
|
||||||
background: #2b1800;
|
|
||||||
border-color: #7c2d12;
|
|
||||||
color: #fbbf24;
|
|
||||||
}
|
|
||||||
.bad {
|
|
||||||
background: #330b0b;
|
|
||||||
border-color: #7f1d1d;
|
|
||||||
color: #fca5a5;
|
|
||||||
}
|
|
||||||
.card {
|
|
||||||
border: 1px solid #2a2a2a;
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 12px;
|
|
||||||
background: var(--card);
|
|
||||||
}
|
|
||||||
#out {
|
|
||||||
min-height: 100px;
|
|
||||||
}
|
|
||||||
pre {
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-wrap: break-word;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
a {
|
|
||||||
color: #93c5fd;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<header>
|
<header>
|
||||||
<h1>home_2<!-- <span class="muted">A/B test page</span> --></h1>
|
<h1>home_2 <span class="muted">A/B test page</span></h1>
|
||||||
<span id="status-pill" class="pill warn">unknown</span>
|
<span id="status-pill" class="pill warn">unknown</span>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -139,7 +26,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="row muted" style="gap: 16px">
|
<div class="row muted" style="gap: 16px">
|
||||||
<span
|
<span
|
||||||
>Uses query (?name=...) on the age old /status.<!-- and header (X-Target-Name) so either extractor
|
>Uses query (?name=...) to view its status.<!-- and header (X-Target-Name) so either extractor
|
||||||
path works. --></span
|
path works. --></span
|
||||||
>
|
>
|
||||||
<a id="permalink" href="#">permalink</a>
|
<a id="permalink" href="#">permalink</a>
|
||||||
@@ -150,110 +37,5 @@
|
|||||||
<div id="html"></div>
|
<div id="html"></div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
|
||||||
const qs = new URLSearchParams(location.search);
|
|
||||||
const $ = (id) => document.getElementById(id);
|
|
||||||
const elName = $("name");
|
|
||||||
const elCheck = $("check");
|
|
||||||
const elWake = $("wake");
|
|
||||||
const elLog = $("log");
|
|
||||||
const elHtml = $("html");
|
|
||||||
const pill = $("status-pill");
|
|
||||||
const link = $("permalink");
|
|
||||||
|
|
||||||
function setPill(kind, text) {
|
|
||||||
pill.className = `pill ${kind}`;
|
|
||||||
pill.textContent = text;
|
|
||||||
}
|
|
||||||
function setLink(name) {
|
|
||||||
const url = new URL(location.href);
|
|
||||||
if (name) url.searchParams.set("name", name);
|
|
||||||
else url.searchParams.delete("name");
|
|
||||||
history.replaceState(null, "", url);
|
|
||||||
link.href = url.toString();
|
|
||||||
}
|
|
||||||
function getName() {
|
|
||||||
return (elName.value || "").trim();
|
|
||||||
}
|
|
||||||
function saveName(name) {
|
|
||||||
try {
|
|
||||||
localStorage.setItem("wakey:name", name);
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
function loadName() {
|
|
||||||
return qs.get("name") || localStorage.getItem("wakey:name") || "";
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchStatus(name) {
|
|
||||||
setPill("warn", "checking…");
|
|
||||||
elLog.textContent = "GET /status?name=" + name;
|
|
||||||
elHtml.innerHTML = "";
|
|
||||||
try {
|
|
||||||
const r = await fetch(`/status?name=${encodeURIComponent(name)}`, {
|
|
||||||
// headers: { "X-Target-Name": name },
|
|
||||||
});
|
|
||||||
const t = await r.text();
|
|
||||||
elHtml.innerHTML = t;
|
|
||||||
// Heuristic: look for words in returned HTML // das. lets look for better
|
|
||||||
const low = t.toLowerCase();
|
|
||||||
if (low.includes("online")) setPill("ok", "online");
|
|
||||||
else if (low.includes("maybe")) setPill("warn", "maybe");
|
|
||||||
else if (low.includes("offline") || low.includes("failed"))
|
|
||||||
setPill("bad", "offline");
|
|
||||||
else setPill("warn", "unknown");
|
|
||||||
} catch (e) {
|
|
||||||
elLog.textContent = "status error: " + e;
|
|
||||||
setPill("bad", "error");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sendWake(name) {
|
|
||||||
setPill("warn", "waking…");
|
|
||||||
elLog.textContent = "POST /wake?name=" + name;
|
|
||||||
try {
|
|
||||||
const r = await fetch(`/wake?name=${encodeURIComponent(name)}`, {
|
|
||||||
method: "POST",
|
|
||||||
// headers: { "X-Target-Name": name },
|
|
||||||
});
|
|
||||||
const t = await r.text();
|
|
||||||
elLog.textContent = t || "ok";
|
|
||||||
// Optional: recheck after small delay
|
|
||||||
setTimeout(() => fetchStatus(name), 600);
|
|
||||||
} catch (e) {
|
|
||||||
elLog.textContent = "wake error: " + e;
|
|
||||||
setPill("bad", "error");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
elCheck.addEventListener("click", () => {
|
|
||||||
const name = getName();
|
|
||||||
if (!name) return;
|
|
||||||
saveName(name);
|
|
||||||
setLink(name);
|
|
||||||
fetchStatus(name);
|
|
||||||
});
|
|
||||||
elWake.addEventListener("click", () => {
|
|
||||||
const name = getName();
|
|
||||||
if (!name) return;
|
|
||||||
saveName(name);
|
|
||||||
setLink(name);
|
|
||||||
sendWake(name);
|
|
||||||
});
|
|
||||||
elName.addEventListener("keydown", (e) => {
|
|
||||||
if (e.key === "Enter") elCheck.click();
|
|
||||||
});
|
|
||||||
|
|
||||||
// init
|
|
||||||
const initial = loadName();
|
|
||||||
if (initial) {
|
|
||||||
elName.value = initial;
|
|
||||||
setLink(initial);
|
|
||||||
// auto-check on load in this A/B page
|
|
||||||
fetchStatus(initial);
|
|
||||||
} else {
|
|
||||||
setPill("warn", "unknown");
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user