This commit is contained in:
Dominic Grimm 2024-11-22 12:38:00 +01:00
parent 76b43a0386
commit 220a446fb6
21 changed files with 677 additions and 111 deletions

View file

@ -11,11 +11,16 @@ chrono-tz = { version = "0.10.0", features = ["serde"] }
clap = { version = "4.5.20", features = ["derive"] }
cron = { version = "0.13.0", features = ["serde"] }
env_logger = "0.11.5"
envconfig = "0.11.0"
futures = "0.3.31"
futures-util = "0.3.31"
jsonwebtoken = "9.3.0"
kdash_protocol = { path = "../kdash_protocol" }
reqwest = { version = "0.12.9", features = ["stream"] }
rumqttc = "0.24.0"
serde = { version = "1.0.215", features = ["derive"] }
serde_json = "1.0.132"
serde_with = { version = "3.11.0", features = ["chrono_0_4"] }
tokio = { version = "1.41.1", features = ["full"] }
url = { version = "2.5.3", features = ["serde"] }
uuid = { version = "1.11.0", features = ["serde"] }

View file

@ -0,0 +1,22 @@
use envconfig::Envconfig;
#[derive(Envconfig, Clone)]
pub struct Config {
#[envconfig(from = "KDASH_MQTT_SERVER")]
pub kdash_mqtt_server: String,
#[envconfig(from = "KDASH_MQTT_PORT")]
pub kdash_mqtt_port: u16,
#[envconfig(from = "KDASH_MQTT_USERNAME")]
pub kdash_mqtt_username: String,
#[envconfig(from = "KDASH_MQTT_PASSWORD")]
pub kdash_mqtt_password: String,
#[envconfig(from = "KDASH_MQTT_TOPIC")]
pub kdash_mqtt_topic: String,
#[envconfig(from = "KDASH_MQTT_DISCOVERY_PREFIX")]
pub kdash_mqtt_discovery_prefix: Option<String>,
}

View file

@ -1,31 +1,24 @@
use actix_web::{dev::PeerAddr, get, http, web, Error, HttpRequest, HttpResponse};
use std::collections::HashMap;
use crate::{AppState, Claims};
use actix_web::{dev::PeerAddr, get, post, web, Error, HttpResponse};
use crate::{
AppState, Claims, MqttClient, MqttDeviceDiscovery, MqttDeviceDiscoveryComponent,
MqttDeviceDiscoveryDevice, MqttDeviceDiscoveryOrigin, MqttState, VERSION,
};
#[get("/config")]
pub async fn get_config(req: HttpRequest, data: web::Data<AppState>) -> HttpResponse {
let token = match req
.headers()
.get(http::header::AUTHORIZATION)
.and_then(|h| h.to_str().ok())
.filter(|s| s.starts_with("Bearer "))
.map(|s| &s[7..])
{
Some(s) => s,
pub async fn get_config(data: web::Data<AppState>, claims: Option<Claims>) -> HttpResponse {
let claims = match claims {
Some(x) => x,
None => return HttpResponse::Unauthorized().finish(),
};
let decoded =
match jsonwebtoken::decode::<Claims>(token, &data.jwt_decoding_key, &data.jwt_validation) {
Ok(x) => x,
Err(_) => return HttpResponse::BadRequest().finish(),
};
if !data.device_ids.contains(&decoded.claims.sub) {
if !data.device_ids.contains(&claims.sub) {
return HttpResponse::Unauthorized().finish();
}
let buf = match data.devices_api_json.get(&decoded.claims.sub) {
let buf = match data.devices_api_json.get(&claims.sub) {
Some(x) => x,
None => return HttpResponse::InternalServerError().finish(),
};
@ -35,35 +28,153 @@ pub async fn get_config(req: HttpRequest, data: web::Data<AppState>) -> HttpResp
.body(buf.clone())
}
#[post("/config")]
pub async fn post_config(
claims: Option<Claims>,
data: web::Data<AppState>,
mqtt_client: web::Data<MqttClient>,
device_state: web::Json<kdash_protocol::DeviceStatePost>,
) -> HttpResponse {
let claims = match claims {
Some(x) => x,
None => return HttpResponse::Unauthorized().finish(),
};
if !data.device_ids.contains(&claims.sub) {
return HttpResponse::Unauthorized().finish();
}
let (config, config_buf) = match (
data.devices_api.get(&claims.sub),
data.devices_api_json.get(&claims.sub),
) {
(Some(config), Some(config_buf)) => (config, config_buf),
_ => return HttpResponse::InternalServerError().finish(),
};
let device_id = claims.sub.to_string();
let state_topic = format!("{}/device/{}/state", mqtt_client.topic, device_id);
let state_payload = match serde_json::to_vec(&MqttState {
battery_charging: device_state.battery_charging,
battery_level: device_state.battery_level,
battery_current: device_state.battery_current,
battery_voltage: device_state.battery_voltage,
}) {
Ok(x) => x,
Err(_) => return HttpResponse::InternalServerError().finish(),
};
if mqtt_client
.client
.publish(
&state_topic,
rumqttc::v5::mqttbytes::QoS::AtLeastOnce,
false,
state_payload,
)
.await
.is_err()
{
return HttpResponse::InternalServerError().finish();
}
if let Some(discovery_topic) = &mqtt_client.discovery_topic {
let unique_id = format!("kdash_{}", device_id);
let unique_id_battery_level = format!("{}_battery_level", unique_id);
let unique_id_battery_current = format!("{}_battery_current", unique_id);
let unique_id_battery_voltage = format!("{}_battery_voltage", unique_id);
let mut components = HashMap::new();
components.insert(
"battery_level".to_string(),
MqttDeviceDiscoveryComponent {
platform: "sensor",
device_class: "battery",
unit_of_measurement: Some("%"),
value_template: "{{ value_json.battery_level }}",
unique_id: &unique_id_battery_level,
},
);
components.insert(
"battery_current".to_string(),
MqttDeviceDiscoveryComponent {
platform: "sensor",
device_class: "current",
unit_of_measurement: Some("mA"),
value_template: "{{ value_json.battery_current }}",
unique_id: &unique_id_battery_current,
},
);
components.insert(
"battery_voltage".to_string(),
MqttDeviceDiscoveryComponent {
platform: "sensor",
device_class: "voltage",
unit_of_measurement: Some("mV"),
value_template: "{{ value_json.battery_voltage }}",
unique_id: &unique_id_battery_voltage,
},
);
let discovery = MqttDeviceDiscovery {
device: MqttDeviceDiscoveryDevice {
identifiers: &unique_id,
name: config.name.to_owned(),
},
origin: MqttDeviceDiscoveryOrigin {
name: "kdash",
software_version: VERSION,
url: "https://git.dergrimm.net/dergrimm/kdash",
},
components,
state_topic: &state_topic,
qos: 2,
};
let discovery_payload = match serde_json::to_vec(&discovery) {
Ok(x) => x,
Err(_) => return HttpResponse::InternalServerError().finish(),
};
if mqtt_client
.client
.publish(
format!("{}/device/kdash/{}/config", discovery_topic, device_id),
rumqttc::v5::mqttbytes::QoS::AtLeastOnce,
false,
discovery_payload,
)
.await
.is_err()
{
return HttpResponse::InternalServerError().finish();
}
}
HttpResponse::Ok()
.content_type("application/json")
.body(config_buf.clone())
}
#[get("/image")]
pub async fn get_image(
req: HttpRequest,
claims: Option<Claims>,
peer_addr: Option<PeerAddr>,
data: web::Data<AppState>,
client: web::Data<reqwest::Client>,
) -> Result<HttpResponse, Error> {
let token = match req
.headers()
.get(http::header::AUTHORIZATION)
.and_then(|h| h.to_str().ok())
.filter(|s| s.starts_with("Bearer "))
.map(|s| &s[7..])
{
Some(s) => s,
let claims = match claims {
Some(x) => x,
None => return Ok(HttpResponse::Unauthorized().finish()),
};
let decoded =
match jsonwebtoken::decode::<Claims>(token, &data.jwt_decoding_key, &data.jwt_validation) {
Ok(x) => x,
Err(_) => return Ok(HttpResponse::BadRequest().finish()),
};
if !data.device_ids.contains(&decoded.claims.sub) {
if !data.device_ids.contains(&claims.sub) {
return Ok(HttpResponse::Unauthorized().finish());
}
let config = match data.devices.get(&decoded.claims.sub) {
let config = match data.devices.get(&claims.sub) {
Some(x) => x,
None => return Ok(HttpResponse::InternalServerError().finish()),
};

View file

@ -1,14 +1,24 @@
use actix_web::dev::{forward_ready, Service, ServiceRequest, ServiceResponse, Transform};
use actix_web::{http, FromRequest, HttpMessage};
use anyhow::Result;
use futures::future::{err, ok, LocalBoxFuture};
use jsonwebtoken::{DecodingKey, Validation};
use kdash_protocol::Percent;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::fs;
use std::future::{ready, Ready};
use std::marker::PhantomData;
use std::path::Path;
use std::sync::Arc;
use uuid::Uuid;
pub mod config;
pub mod device;
pub mod handlers;
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
#[derive(Debug, Deserialize, Clone)]
pub struct Config {
pub devices: HashMap<Uuid, device::Config>,
@ -35,3 +45,177 @@ pub struct Claims {
pub exp: i64,
pub sub: Uuid,
}
impl FromRequest for Claims {
type Error = actix_web::Error;
type Future = futures::future::Ready<Result<Self, Self::Error>>;
fn from_request(
req: &actix_web::HttpRequest,
_payload: &mut actix_web::dev::Payload,
) -> Self::Future {
match req.extensions().get::<Claims>() {
Some(claims) => ok(claims.clone()),
None => err(actix_web::error::ErrorUnauthorized("Unauthorized")),
}
}
}
pub struct Authority {
pub decoding_key: DecodingKey,
pub validation: Validation,
}
pub struct JwtAuth<Claims> {
pub auth: Arc<Authority>,
claims_marker: PhantomData<Claims>,
}
impl<Claims> JwtAuth<Claims> {
pub fn new(auth: Arc<Authority>) -> Self {
Self {
auth,
claims_marker: PhantomData,
}
}
}
impl<S, B, Claims> Transform<S, ServiceRequest> for JwtAuth<Claims>
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = actix_web::Error>,
S::Future: 'static,
B: 'static,
{
type Response = ServiceResponse<B>;
type Error = actix_web::Error;
type InitError = ();
type Transform = JwtAuthMiddleware<S, Claims>;
type Future = Ready<Result<Self::Transform, Self::InitError>>;
fn new_transform(&self, service: S) -> Self::Future {
ready(Ok(JwtAuthMiddleware {
service,
auth: Arc::clone(&self.auth),
claims_marker: PhantomData,
}))
}
}
pub struct JwtAuthMiddleware<S, Claims> {
service: S,
auth: Arc<Authority>,
claims_marker: PhantomData<Claims>,
}
impl<S, B, C> Service<ServiceRequest> for JwtAuthMiddleware<S, C>
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = actix_web::Error>,
S::Future: 'static,
B: 'static,
{
type Response = ServiceResponse<B>;
type Error = actix_web::Error;
type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
forward_ready!(service);
fn call(&self, req: ServiceRequest) -> Self::Future {
let decoded = req
.headers()
.get(http::header::AUTHORIZATION)
.and_then(|h| h.to_str().ok())
.filter(|s| s.starts_with("Bearer "))
.map(|s| &s[7..])
.and_then(|token| {
jsonwebtoken::decode::<Claims>(
token,
&self.auth.decoding_key,
&self.auth.validation,
)
.ok()
});
if let Some(data) = decoded {
req.extensions_mut().insert(data.claims);
dbg!(req.extensions());
}
Box::pin(self.service.call(req))
}
}
pub struct MqttClient {
pub client: rumqttc::v5::AsyncClient,
pub topic: String,
pub discovery_topic: Option<String>,
}
#[derive(Serialize)]
pub struct MqttState {
pub battery_charging: bool,
pub battery_level: Percent,
pub battery_current: i16,
pub battery_voltage: i16,
}
#[derive(Serialize)]
pub struct MqttDeviceDiscovery<'a> {
#[serde(rename = "dev")]
pub device: MqttDeviceDiscoveryDevice<'a>,
#[serde(rename = "o")]
pub origin: MqttDeviceDiscoveryOrigin<'a>,
#[serde(rename = "cmps")]
pub components: HashMap<String, MqttDeviceDiscoveryComponent<'a>>,
pub state_topic: &'a str,
pub qos: u8,
}
#[derive(Serialize)]
pub struct MqttDeviceDiscoveryDevice<'a> {
#[serde(rename = "ids")]
pub identifiers: &'a str,
pub name: String,
// #[serde(rename = "mf")]
// pub manufacturer: &'a str,
// #[serde(rename = "mdl")]
// pub default_manufacturer: &'a str,
// #[serde(rename = "sw")]
// pub sw_version: &'a str,
// #[serde(rename = "sn")]
// pub serial_number: &'a str,
// #[serde(rename = "hw")]
// pub hw_version: &'a str,
}
#[derive(Serialize)]
pub struct MqttDeviceDiscoveryOrigin<'a> {
pub name: &'a str,
#[serde(rename = "sw")]
pub software_version: &'a str,
pub url: &'a str,
}
#[derive(Serialize)]
pub struct MqttDeviceDiscoveryComponent<'a> {
#[serde(rename = "p")]
pub platform: &'a str,
pub device_class: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub unit_of_measurement: Option<&'a str>,
pub value_template: &'a str,
pub unique_id: &'a str,
}

View file

@ -2,11 +2,14 @@ use actix_web::{middleware::Logger, web, App, HttpServer};
use anyhow::Result;
use chrono::prelude::*;
use clap::{Parser, Subcommand};
use envconfig::Envconfig;
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation};
use std::{
collections::{HashMap, HashSet},
fs,
path::PathBuf,
sync::Arc,
time::Duration,
};
use uuid::Uuid;
@ -40,6 +43,8 @@ async fn main() -> Result<()> {
let cli = Cli::parse();
let env_config = kdash_server::config::Config::init_from_env()?;
let config = kdash_server::read_config(&cli.config)?;
let sk = fs::read(cli.private_key)?;
@ -61,6 +66,25 @@ async fn main() -> Result<()> {
println!("{}", token);
}
Commands::Start => {
let mut mqtt_options = rumqttc::v5::MqttOptions::new(
"kdash-server",
env_config.kdash_mqtt_server,
env_config.kdash_mqtt_port,
);
mqtt_options.set_credentials(
env_config.kdash_mqtt_username,
env_config.kdash_mqtt_password,
);
mqtt_options.set_keep_alive(Duration::from_secs(10));
let (mqtt_client, mut eventloop) = rumqttc::v5::AsyncClient::new(mqtt_options, 20);
tokio::spawn(async move {
loop {
eventloop.poll().await.unwrap();
}
});
HttpServer::new(move || {
let devices = config.devices.to_owned();
let device_ids: HashSet<Uuid> = config.devices.keys().cloned().collect();
@ -88,8 +112,20 @@ async fn main() -> Result<()> {
jwt_validation: Validation::new(ALGORITHM),
}))
.app_data(web::Data::new(reqwest_client))
.app_data(web::Data::new(kdash_server::MqttClient {
client: mqtt_client.to_owned(),
topic: env_config.kdash_mqtt_topic.to_owned(),
discovery_topic: env_config.kdash_mqtt_discovery_prefix.to_owned(),
}))
.wrap(Logger::default())
.wrap(kdash_server::JwtAuth::<kdash_server::Claims>::new(
Arc::new(kdash_server::Authority {
decoding_key: decoding_key.to_owned(),
validation: Validation::new(ALGORITHM),
}),
))
.service(kdash_server::handlers::get_config)
.service(kdash_server::handlers::post_config)
.service(kdash_server::handlers::get_image)
})
.bind(("0.0.0.0", 8080))?