mirror of
https://codeberg.org/icewind/tasproxy.git
synced 2026-06-03 10:14:09 +02:00
initial implementation
This commit is contained in:
parent
cd839b8475
commit
4a11d00024
8 changed files with 2520 additions and 2 deletions
2
.env
Normal file
2
.env
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
MQTT_HOSTNAME=astoria
|
||||
PORT=3030
|
||||
2208
Cargo.lock
generated
Normal file
2208
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
12
Cargo.toml
12
Cargo.toml
|
|
@ -7,3 +7,15 @@ edition = "2018"
|
|||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
rumqttc = "0.2.0"
|
||||
tokio = "0.2"
|
||||
dashmap = "3.11"
|
||||
json = "0.12.4"
|
||||
warp = "0.2.5"
|
||||
dotenv = "0.15.0"
|
||||
ctrlc = { version = "3.1.7", features = ["termination"] }
|
||||
color-eyre = "0.5.7"
|
||||
async-stream = "0.3.0"
|
||||
pin-utils = "0.1.0"
|
||||
hostname = "^0.3"
|
||||
warp-reverse-proxy = "0.2.0"
|
||||
29
src/config.rs
Normal file
29
src/config.rs
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
use color_eyre::{eyre::WrapErr, Result};
|
||||
use std::str::FromStr;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Config {
|
||||
pub mqtt_host: String,
|
||||
pub mqtt_port: u16,
|
||||
pub host_port: u16,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn from_env() -> Result<Self> {
|
||||
let mqtt_host = dotenv::var("MQTT_HOSTNAME").wrap_err("MQTT_HOSTNAME not set")?;
|
||||
let mqtt_port = dotenv::var("MQTT_PORT")
|
||||
.ok()
|
||||
.and_then(|port| u16::from_str(&port).ok())
|
||||
.unwrap_or(1883);
|
||||
let host_port = dotenv::var("PORT")
|
||||
.ok()
|
||||
.and_then(|port| u16::from_str(&port).ok())
|
||||
.unwrap_or(80);
|
||||
|
||||
Ok(Config {
|
||||
mqtt_host,
|
||||
mqtt_port,
|
||||
host_port,
|
||||
})
|
||||
}
|
||||
}
|
||||
43
src/devices.rs
Normal file
43
src/devices.rs
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
use json::JsonValue;
|
||||
use std::net::IpAddr;
|
||||
use std::str::FromStr;
|
||||
|
||||
#[derive(Debug, Eq, PartialEq, Clone, Hash)]
|
||||
pub struct Device {
|
||||
pub topic: String,
|
||||
pub hostname: String,
|
||||
}
|
||||
|
||||
impl Device {
|
||||
pub fn get_topic(&self, prefix: &str, command: &str) -> String {
|
||||
format!("{}/{}/{}/{}", prefix, self.topic, self.hostname, command)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct DeviceState {
|
||||
name: String,
|
||||
pub ip: Option<IpAddr>,
|
||||
}
|
||||
|
||||
impl DeviceState {
|
||||
pub fn update(&mut self, json: JsonValue) {
|
||||
if json["DeviceName"].is_string() && !json["DeviceName"].is_empty() {
|
||||
self.name = json["DeviceName"].to_string();
|
||||
}
|
||||
if !json["IPAddress1"].is_empty() {
|
||||
let result = json["IPAddress1"].to_string();
|
||||
if let Some(Ok(ip)) = result
|
||||
.split(' ')
|
||||
.map(|part| part.trim_start_matches('(').trim_end_matches(')'))
|
||||
.rev()
|
||||
.map(IpAddr::from_str)
|
||||
.next()
|
||||
{
|
||||
self.ip = Some(ip);
|
||||
} else {
|
||||
eprintln!("malformed ipaddress result: {}", result);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
142
src/main.rs
142
src/main.rs
|
|
@ -1,3 +1,141 @@
|
|||
fn main() {
|
||||
println!("Hello, world!");
|
||||
use crate::config::Config;
|
||||
use crate::devices::DeviceState;
|
||||
use crate::mqtt::mqtt_stream;
|
||||
use crate::topic::Topic;
|
||||
use color_eyre::{eyre::WrapErr, Report, Result};
|
||||
use dashmap::DashMap;
|
||||
use pin_utils::pin_mut;
|
||||
use rumqttc::{MqttOptions, QoS};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::stream::StreamExt;
|
||||
use warp::hyper::http::uri::Authority;
|
||||
use warp::Filter;
|
||||
use warp_reverse_proxy::{extract_request_data_filter, proxy_to_and_forward_response};
|
||||
|
||||
mod config;
|
||||
mod devices;
|
||||
mod mqtt;
|
||||
mod topic;
|
||||
|
||||
type DeviceStates = Arc<DashMap<String, DeviceState>>;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let config = Config::from_env()?;
|
||||
let host_port = config.host_port;
|
||||
|
||||
let device_states = DeviceStates::default();
|
||||
|
||||
ctrlc::set_handler(move || {
|
||||
std::process::exit(0);
|
||||
})
|
||||
.expect("Error setting Ctrl-C handler");
|
||||
|
||||
let states = device_states.clone();
|
||||
let mqtt_host = config.mqtt_host;
|
||||
let mqtt_port = config.mqtt_port;
|
||||
tokio::task::spawn(async move {
|
||||
loop {
|
||||
if let Err(e) = mqtt_client(&mqtt_host, mqtt_port, states.clone()).await {
|
||||
eprintln!("lost mqtt collection: {:#}", e);
|
||||
}
|
||||
eprintln!("reconnecting after 1s");
|
||||
tokio::time::delay_for(Duration::from_secs(1)).await;
|
||||
}
|
||||
});
|
||||
|
||||
let state = warp::any().map(move || device_states.clone());
|
||||
|
||||
let proxy = warp::any()
|
||||
.and(warp::filters::host::optional())
|
||||
.and(state)
|
||||
.and_then(
|
||||
move |host: Option<Authority>, states: DeviceStates| async move {
|
||||
let host = match host {
|
||||
Some(host) => host,
|
||||
None => return Err(warp::reject::not_found()),
|
||||
};
|
||||
let requested_device = host.as_str().split('.').next().unwrap();
|
||||
if let Some(state) = states.get(requested_device) {
|
||||
if let Some(ip) = state.ip {
|
||||
Ok((format!("http://{}", ip), String::new()))
|
||||
} else {
|
||||
Err(warp::reject::not_found())
|
||||
}
|
||||
} else {
|
||||
Err(warp::reject::not_found())
|
||||
}
|
||||
},
|
||||
)
|
||||
.untuple_one()
|
||||
.and(extract_request_data_filter())
|
||||
.and_then(proxy_to_and_forward_response);
|
||||
|
||||
warp::serve(proxy).run(([0, 0, 0, 0], host_port)).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn mqtt_client(host: &str, port: u16, device_states: DeviceStates) -> Result<()> {
|
||||
let hostname = hostname::get()?
|
||||
.into_string()
|
||||
.map_err(|_| Report::msg("invalid hostname"))?;
|
||||
let mut mqtt_options = MqttOptions::new(format!("taspromto-{}", hostname), host, port);
|
||||
mqtt_options.set_keep_alive(5);
|
||||
|
||||
let (client, stream) = mqtt_stream(mqtt_options)
|
||||
.await
|
||||
.wrap_err("Failed to setup mqtt listener")?;
|
||||
|
||||
pin_mut!(stream);
|
||||
|
||||
while let Some(message) = stream.next().await {
|
||||
let message = message?;
|
||||
println!(
|
||||
"{} {}",
|
||||
message.topic,
|
||||
std::str::from_utf8(message.payload.as_ref()).unwrap_or_default()
|
||||
);
|
||||
let topic = Topic::from(message.topic.as_str());
|
||||
|
||||
match topic {
|
||||
Topic::LWT(device) => {
|
||||
// on discovery, ask the device for it's ip and name
|
||||
let send_client = client.clone();
|
||||
tokio::task::spawn(async move {
|
||||
if let Err(e) = send_client
|
||||
.publish(
|
||||
device.get_topic("cmnd", "IPADDRESS"),
|
||||
QoS::AtMostOnce,
|
||||
false,
|
||||
"",
|
||||
)
|
||||
.await
|
||||
{
|
||||
eprintln!("Failed to ask for power state: {:#}", e);
|
||||
}
|
||||
if let Err(e) = send_client
|
||||
.publish(
|
||||
device.get_topic("cmnd", "DeviceName"),
|
||||
QoS::AtMostOnce,
|
||||
false,
|
||||
"",
|
||||
)
|
||||
.await
|
||||
{
|
||||
eprintln!("Failed to ask for device name: {:#}", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
Topic::Result(device) => {
|
||||
let payload = std::str::from_utf8(message.payload.as_ref()).unwrap_or_default();
|
||||
if let Ok(json) = json::parse(payload) {
|
||||
let mut device_state = device_states.entry(device.hostname).or_default();
|
||||
device_state.update(json);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
29
src/mqtt.rs
Normal file
29
src/mqtt.rs
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
use async_stream::try_stream;
|
||||
use color_eyre::Result;
|
||||
use rumqttc::{AsyncClient, Event, EventLoop, MqttOptions, Packet, Publish, QoS};
|
||||
use tokio::stream::{Stream, StreamExt};
|
||||
|
||||
pub async fn mqtt_stream(
|
||||
mqtt_options: MqttOptions,
|
||||
) -> Result<(AsyncClient, impl Stream<Item = Result<Publish>>)> {
|
||||
let (client, event_loop) = AsyncClient::new(mqtt_options, 10);
|
||||
client.subscribe("tele/+/+/LWT", QoS::AtMostOnce).await?;
|
||||
client.subscribe("stat/+/+/RESULT", QoS::AtMostOnce).await?;
|
||||
|
||||
let stream = event_loop_to_stream(event_loop).filter_map(|event| match event {
|
||||
Ok(Event::Incoming(Packet::Publish(message))) => Some(Ok(message)),
|
||||
Ok(_) => None,
|
||||
Err(e) => Some(Err(e)),
|
||||
});
|
||||
|
||||
Ok((client, stream))
|
||||
}
|
||||
|
||||
fn event_loop_to_stream(mut event_loop: EventLoop) -> impl Stream<Item = Result<Event>> {
|
||||
try_stream! {
|
||||
loop {
|
||||
let event = event_loop.poll().await?;
|
||||
yield event;
|
||||
}
|
||||
}
|
||||
}
|
||||
57
src/topic.rs
Normal file
57
src/topic.rs
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
use crate::devices::Device;
|
||||
|
||||
#[derive(Debug, Eq, PartialEq)]
|
||||
pub enum Topic {
|
||||
LWT(Device),
|
||||
State(Device),
|
||||
Sensor(Device),
|
||||
Result(Device),
|
||||
Other(String),
|
||||
}
|
||||
|
||||
impl From<&str> for Topic {
|
||||
fn from(raw: &str) -> Self {
|
||||
let mut parts = raw.split('/');
|
||||
if let (Some(prefix), Some(topic), Some(hostname), Some(cmd)) =
|
||||
(parts.next(), parts.next(), parts.next(), parts.next())
|
||||
{
|
||||
let device = Device {
|
||||
topic: topic.to_string(),
|
||||
hostname: hostname.to_string(),
|
||||
};
|
||||
match (prefix, cmd) {
|
||||
("tele", "LWT") => Topic::LWT(device),
|
||||
("tele", "STATE") => Topic::State(device),
|
||||
("tele", "SENSOR") => Topic::Sensor(device),
|
||||
("stat", "RESULT") => Topic::Result(device),
|
||||
_ => Topic::Other(raw.to_string()),
|
||||
}
|
||||
} else {
|
||||
Topic::Other(raw.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_topic() {
|
||||
let device = Device {
|
||||
hostname: "hostname".to_string(),
|
||||
topic: "foo".to_string(),
|
||||
};
|
||||
assert_eq!(
|
||||
Topic::LWT(device.clone()),
|
||||
Topic::from("tele/foo/hostname/LWT")
|
||||
);
|
||||
assert_eq!(
|
||||
Topic::State(device.clone()),
|
||||
Topic::from("tele/foo/hostname/STATE")
|
||||
);
|
||||
assert_eq!(
|
||||
Topic::Sensor(device.clone()),
|
||||
Topic::from("tele/foo/hostname/SENSOR")
|
||||
);
|
||||
assert_eq!(
|
||||
Topic::Result(device.clone()),
|
||||
Topic::from("stat/foo/hostname/RESULT")
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue