v0.15.4
This commit is contained in:
+28
-29
@@ -34,7 +34,6 @@ use axum::{
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||||
use chrono::{Duration as ChronoDuration, NaiveTime, Utc};
|
||||
use futures_util::StreamExt;
|
||||
use rand::{rngs::OsRng, RngCore};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use sha2::{Digest, Sha256};
|
||||
@@ -91,31 +90,31 @@ pub fn router(state: AppState) -> Router {
|
||||
.route("/api/discovery", post(discover))
|
||||
.route("/api/devices", get(list_devices).post(add_device))
|
||||
.route(
|
||||
"/api/devices/:id",
|
||||
"/api/devices/{id}",
|
||||
get(get_device).patch(patch_device).delete(delete_device),
|
||||
)
|
||||
.route("/api/devices/:id/bind", post(bind_device))
|
||||
.route("/api/devices/:id/poll", post(poll_device))
|
||||
.route("/api/devices/:id/probe", post(probe_device))
|
||||
.route("/api/devices/:id/command", post(command_device))
|
||||
.route("/api/devices/{id}/bind", post(bind_device))
|
||||
.route("/api/devices/{id}/poll", post(poll_device))
|
||||
.route("/api/devices/{id}/probe", post(probe_device))
|
||||
.route("/api/devices/{id}/command", post(command_device))
|
||||
.route(
|
||||
"/api/device-groups",
|
||||
get(list_device_groups).post(create_device_group),
|
||||
)
|
||||
.route(
|
||||
"/api/device-groups/:id",
|
||||
"/api/device-groups/{id}",
|
||||
get(get_device_group)
|
||||
.put(update_device_group)
|
||||
.delete(delete_device_group),
|
||||
)
|
||||
.route("/api/zones", get(list_zones).post(create_zone))
|
||||
.route(
|
||||
"/api/zones/:id",
|
||||
"/api/zones/{id}",
|
||||
get(get_zone).put(update_zone).delete(delete_zone),
|
||||
)
|
||||
.route("/api/zones/:id/control", post(update_zone_control))
|
||||
.route("/api/zones/{id}/control", post(update_zone_control))
|
||||
.route(
|
||||
"/api/zones/:id/compressor-queue/cancel",
|
||||
"/api/zones/{id}/compressor-queue/cancel",
|
||||
post(cancel_zone_compressor_queue),
|
||||
)
|
||||
.route(
|
||||
@@ -123,21 +122,21 @@ pub fn router(state: AppState) -> Router {
|
||||
post(cancel_all_compressor_queues),
|
||||
)
|
||||
.route(
|
||||
"/api/zones/:id/schedule-template",
|
||||
"/api/zones/{id}/schedule-template",
|
||||
post(apply_schedule_template),
|
||||
)
|
||||
.route("/api/groups", get(list_groups).post(create_group))
|
||||
.route(
|
||||
"/api/groups/:id",
|
||||
"/api/groups/{id}",
|
||||
get(get_group).put(update_group).delete(delete_group),
|
||||
)
|
||||
.route("/api/groups/:id/control", post(update_group_control))
|
||||
.route("/api/groups/{id}/control", post(update_group_control))
|
||||
.route("/api/house/control", post(update_house_control))
|
||||
.route("/api/house/power", post(update_house_power))
|
||||
.route("/api/house/preset", post(update_house_preset))
|
||||
.route("/api/schedules", get(list_schedules).post(create_schedule))
|
||||
.route(
|
||||
"/api/schedules/:id",
|
||||
"/api/schedules/{id}",
|
||||
get(get_schedule)
|
||||
.put(update_schedule)
|
||||
.delete(delete_schedule),
|
||||
@@ -147,7 +146,7 @@ pub fn router(state: AppState) -> Router {
|
||||
get(list_automations).post(create_automation),
|
||||
)
|
||||
.route(
|
||||
"/api/automations/:id",
|
||||
"/api/automations/{id}",
|
||||
get(get_automation)
|
||||
.put(update_automation)
|
||||
.delete(delete_automation),
|
||||
@@ -155,10 +154,10 @@ pub fn router(state: AppState) -> Router {
|
||||
.route("/api/flows", get(list_flows).post(create_flow))
|
||||
.route("/api/flows/import", post(import_flow))
|
||||
.route("/api/flows/simulate", post(simulate_flow))
|
||||
.route("/api/flows/:id/export", get(export_flow))
|
||||
.route("/api/flows/:id/logs", get(flow_logs))
|
||||
.route("/api/flows/{id}/export", get(export_flow))
|
||||
.route("/api/flows/{id}/logs", get(flow_logs))
|
||||
.route(
|
||||
"/api/flows/:id",
|
||||
"/api/flows/{id}",
|
||||
get(get_flow).put(update_flow).delete(delete_flow),
|
||||
)
|
||||
.route("/api/readings", get(readings))
|
||||
@@ -215,7 +214,7 @@ pub fn router(state: AppState) -> Router {
|
||||
get(list_access_tokens).post(create_access_token),
|
||||
)
|
||||
.route(
|
||||
"/api/access-tokens/:id",
|
||||
"/api/access-tokens/{id}",
|
||||
axum::routing::delete(delete_access_token),
|
||||
)
|
||||
.route("/api/integrations/gree-cloud/test", post(test_gree_cloud))
|
||||
@@ -224,7 +223,7 @@ pub fn router(state: AppState) -> Router {
|
||||
get(discover_gree_cloud_devices),
|
||||
)
|
||||
.route(
|
||||
"/api/integrations/gree-cloud/devices/:cloud_id/add",
|
||||
"/api/integrations/gree-cloud/devices/{cloud_id}/add",
|
||||
post(add_gree_cloud_device),
|
||||
)
|
||||
.route(
|
||||
@@ -236,7 +235,7 @@ pub fn router(state: AppState) -> Router {
|
||||
post(reconnect_gree_cloud),
|
||||
)
|
||||
.route(
|
||||
"/api/devices/:id/cloud-diagnostics",
|
||||
"/api/devices/{id}/cloud-diagnostics",
|
||||
get(cloud_device_diagnostics),
|
||||
)
|
||||
.route(
|
||||
@@ -271,7 +270,7 @@ pub fn router(state: AppState) -> Router {
|
||||
get(list_devices),
|
||||
)
|
||||
.route(
|
||||
"/api/integrations/home-assistant/devices/:id/command",
|
||||
"/api/integrations/home-assistant/devices/{id}/command",
|
||||
post(command_home_assistant_device),
|
||||
)
|
||||
.route(
|
||||
@@ -283,7 +282,7 @@ pub fn router(state: AppState) -> Router {
|
||||
get(list_home_assistant_groups),
|
||||
)
|
||||
.route(
|
||||
"/api/integrations/home-assistant/groups/:id/control",
|
||||
"/api/integrations/home-assistant/groups/{id}/control",
|
||||
post(update_home_assistant_group_control),
|
||||
)
|
||||
.route(
|
||||
@@ -299,7 +298,7 @@ pub fn router(state: AppState) -> Router {
|
||||
post(update_house_power),
|
||||
)
|
||||
.route(
|
||||
"/api/integrations/home-assistant/zones/:id/control",
|
||||
"/api/integrations/home-assistant/zones/{id}/control",
|
||||
post(update_home_assistant_zone_control),
|
||||
)
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
@@ -309,8 +308,8 @@ pub fn router(state: AppState) -> Router {
|
||||
|
||||
let mut app = Router::new()
|
||||
.route("/api/health", get(health))
|
||||
.route("/api/public/charts/custom/:token", get(public_custom_chart))
|
||||
.route("/charts/custom/:token", get(custom_chart_page))
|
||||
.route("/api/public/charts/custom/{token}", get(public_custom_chart))
|
||||
.route("/charts/custom/{token}", get(custom_chart_page))
|
||||
.route("/custom-chart.js", get(custom_chart_js))
|
||||
.route("/ws", get(websocket))
|
||||
.route("/", get(index))
|
||||
@@ -322,11 +321,11 @@ pub fn router(state: AppState) -> Router {
|
||||
.route("/manifest.webmanifest", get(manifest))
|
||||
.route("/sw.js", get(service_worker))
|
||||
.route("/favicon.svg", get(favicon))
|
||||
.route("/flows/:id", get(index))
|
||||
.route("/flows/{id}", get(index))
|
||||
.route("/lang/index.json", get(language_index))
|
||||
.route("/lang/:file", get(language_file))
|
||||
.route("/lang/{file}", get(language_file))
|
||||
.route("/presets/index.json", get(preset_index))
|
||||
.route("/presets/:file", get(preset_file))
|
||||
.route("/presets/{file}", get(preset_file))
|
||||
.merge(openapi::swagger_ui(&state.config.base_path))
|
||||
.merge(protected)
|
||||
.merge(home_assistant_api);
|
||||
|
||||
+1
-2
@@ -99,7 +99,6 @@ fn hash_token(token: &str) -> String {
|
||||
|
||||
fn generate_access_token() -> String {
|
||||
let mut bytes = [0u8; 32];
|
||||
let mut rng = OsRng;
|
||||
rng.fill_bytes(&mut bytes);
|
||||
rand::fill(&mut bytes);
|
||||
format!("gree_controller_{}", URL_SAFE_NO_PAD.encode(bytes))
|
||||
}
|
||||
|
||||
+1
-2
@@ -550,8 +550,7 @@ fn validate_public_chart_spec(series: &[String]) -> Result<(), AppError> {
|
||||
|
||||
fn generate_public_chart_token() -> String {
|
||||
let mut bytes = [0u8; 32];
|
||||
let mut rng = OsRng;
|
||||
rng.fill_bytes(&mut bytes);
|
||||
rand::fill(&mut bytes);
|
||||
format!("chart_{}", URL_SAFE_NO_PAD.encode(bytes))
|
||||
}
|
||||
|
||||
|
||||
+2
-4
@@ -12,10 +12,8 @@ pub(super) fn swagger_ui(base_path: &str) -> SwaggerUi {
|
||||
.external_url_unchecked("/api-docs/openapi.json", document(base_path))
|
||||
.config(
|
||||
SwaggerConfig::new([docs_url])
|
||||
.filter(true)
|
||||
.try_it_out_enabled(true)
|
||||
.display_request_duration(true)
|
||||
.persist_authorization(true),
|
||||
.doc_expansion("none")
|
||||
.default_models_expand_depth(-1),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ async fn send_ws_bootstrap(state: &AppState, socket: &mut WebSocket) -> Result<O
|
||||
),
|
||||
};
|
||||
socket
|
||||
.send(Message::Text(message.to_string()))
|
||||
.send(Message::Text(message.to_string().into()))
|
||||
.await
|
||||
.map_err(|_| ())?;
|
||||
Ok(revision)
|
||||
@@ -81,7 +81,7 @@ async fn websocket_loop(state: AppState, mut socket: WebSocket) {
|
||||
match event {
|
||||
Ok(event) => {
|
||||
if let Ok(text) = serde_json::to_string(&event) {
|
||||
if socket.send(Message::Text(text)).await.is_err() { break; }
|
||||
if socket.send(Message::Text(text.into())).await.is_err() { break; }
|
||||
}
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => {
|
||||
@@ -105,7 +105,7 @@ async fn websocket_loop(state: AppState, mut socket: WebSocket) {
|
||||
continue;
|
||||
}
|
||||
let text = control_plan_ws_message(snapshot.as_ref()).to_string();
|
||||
if socket.send(Message::Text(text)).await.is_err() { break; }
|
||||
if socket.send(Message::Text(text.into())).await.is_err() { break; }
|
||||
last_control_plan_revision = Some(snapshot.revision);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,8 +94,8 @@ fn request_client(
|
||||
reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(10))
|
||||
.user_agent(concat!("gree-controller/", env!("CARGO_PKG_VERSION")))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.danger_accept_invalid_hostnames(true)
|
||||
.tls_danger_accept_invalid_certs(true)
|
||||
.tls_danger_accept_invalid_hostnames(true)
|
||||
.build()
|
||||
.context("cannot build Home Assistant HTTPS client")
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
install_rustls_crypto_provider();
|
||||
let config = Config::load()?;
|
||||
init_tracing();
|
||||
|
||||
@@ -191,6 +192,13 @@ async fn main() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn install_rustls_crypto_provider() {
|
||||
// reqwest is built with `rustls-no-provider` while tokio-rustls enables only
|
||||
// `ring`. Install it once at process startup so every rustls consumer uses
|
||||
// the same provider and rustls never has to guess between ring and AWS-LC.
|
||||
let _ = tokio_rustls::rustls::crypto::ring::default_provider().install_default();
|
||||
}
|
||||
|
||||
fn init_tracing() {
|
||||
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "info,tower_http=info".into());
|
||||
|
||||
+16
-11
@@ -1,10 +1,10 @@
|
||||
use aes::{
|
||||
cipher::{generic_array::GenericArray, BlockDecrypt, BlockEncrypt, KeyInit},
|
||||
Aes128,
|
||||
cipher::{BlockCipherDecrypt, BlockCipherEncrypt, KeyInit},
|
||||
Aes128, Block,
|
||||
};
|
||||
use aes_gcm::{
|
||||
aead::{AeadInPlace, KeyInit as AeadKeyInit},
|
||||
Aes128Gcm, Nonce,
|
||||
aead::{consts::U12, AeadInOut, KeyInit as AeadKeyInit},
|
||||
Aes128Gcm, Nonce, Tag,
|
||||
};
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use base64::{
|
||||
@@ -62,7 +62,8 @@ pub fn encrypt_v1(key: &str, plaintext: &[u8]) -> Result<String> {
|
||||
data.extend_from_slice(plaintext);
|
||||
data.extend(std::iter::repeat(pad as u8).take(pad));
|
||||
for block in data.chunks_exact_mut(16) {
|
||||
cipher.encrypt_block(GenericArray::from_mut_slice(block));
|
||||
let block: &mut Block = block.try_into().expect("AES block is 16 bytes");
|
||||
cipher.encrypt_block(block);
|
||||
}
|
||||
Ok(STANDARD.encode(data))
|
||||
}
|
||||
@@ -77,7 +78,8 @@ pub fn decrypt_v1(key: &str, ciphertext_b64: &str) -> Result<Vec<u8>> {
|
||||
bail!("invalid AES-ECB ciphertext length")
|
||||
}
|
||||
for block in data.chunks_exact_mut(16) {
|
||||
cipher.decrypt_block(GenericArray::from_mut_slice(block));
|
||||
let block: &mut Block = block.try_into().expect("AES block is 16 bytes");
|
||||
cipher.decrypt_block(block);
|
||||
}
|
||||
let pad = *data.last().ok_or_else(|| anyhow!("empty plaintext"))? as usize;
|
||||
let valid_padding = pad > 0
|
||||
@@ -106,10 +108,10 @@ pub fn encrypt_v2(key: &str, plaintext: &[u8]) -> Result<V2Encrypted> {
|
||||
let key = normalize_key(key)?;
|
||||
let cipher = <Aes128Gcm as AeadKeyInit>::new_from_slice(&key)
|
||||
.map_err(|_| anyhow!("invalid AES-GCM key"))?;
|
||||
let nonce = Nonce::from_slice(&GCM_NONCE);
|
||||
let nonce: Nonce<U12> = GCM_NONCE.into();
|
||||
let mut buffer = plaintext.to_vec();
|
||||
let tag = cipher
|
||||
.encrypt_in_place_detached(nonce, GCM_AAD, &mut buffer)
|
||||
.encrypt_inout_detached(&nonce, GCM_AAD, (&mut buffer[..]).into())
|
||||
.map_err(|_| anyhow!("AES-GCM encryption failed"))?;
|
||||
Ok(V2Encrypted {
|
||||
ciphertext: STANDARD.encode(buffer),
|
||||
@@ -130,10 +132,13 @@ pub fn decrypt_v2(key: &str, ciphertext_b64: &str, tag_b64: &str) -> Result<Vec<
|
||||
let mut data = GREE_BASE64_DECODE
|
||||
.decode(ciphertext_b64)
|
||||
.context("invalid GCM ciphertext")?;
|
||||
let nonce = Nonce::from_slice(&GCM_NONCE);
|
||||
let tag = GenericArray::from_slice(&tag_bytes);
|
||||
let nonce: Nonce<U12> = GCM_NONCE.into();
|
||||
let tag: &Tag = tag_bytes
|
||||
.as_slice()
|
||||
.try_into()
|
||||
.map_err(|_| anyhow!("invalid GCM tag length"))?;
|
||||
cipher
|
||||
.decrypt_in_place_detached(nonce, GCM_AAD, &mut data, tag)
|
||||
.decrypt_inout_detached(&nonce, GCM_AAD, (&mut data[..]).into(), tag)
|
||||
.map_err(|_| anyhow!("AES-GCM authentication failed"))?;
|
||||
// A few modules append 0xff filler bytes to decrypted JSON.
|
||||
data.retain(|byte| *byte != 0xff);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::crypto::decrypt_v1;
|
||||
use aes::{
|
||||
cipher::{generic_array::GenericArray, BlockEncrypt, KeyInit},
|
||||
Aes128,
|
||||
cipher::{BlockCipherEncrypt, KeyInit},
|
||||
Aes128, Block,
|
||||
};
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use base64::{engine::general_purpose::STANDARD, Engine as _};
|
||||
@@ -387,7 +387,8 @@ fn encrypt_cloud_api(plaintext: &[u8]) -> Result<String> {
|
||||
data.extend_from_slice(plaintext);
|
||||
data.extend(std::iter::repeat(pad as u8).take(pad));
|
||||
for block in data.chunks_exact_mut(16) {
|
||||
cipher.encrypt_block(GenericArray::from_mut_slice(block));
|
||||
let block: &mut Block = block.try_into().expect("AES block is 16 bytes");
|
||||
cipher.encrypt_block(block);
|
||||
}
|
||||
Ok(STANDARD.encode(data))
|
||||
}
|
||||
|
||||
+12
-9
@@ -14,7 +14,6 @@ use crate::{
|
||||
};
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use chrono::{DateTime, Utc};
|
||||
use rand::Rng;
|
||||
use serde::Serialize;
|
||||
use serde_json::{json, Value};
|
||||
use std::{
|
||||
@@ -1072,8 +1071,7 @@ impl GreeCloudProvider {
|
||||
} else {
|
||||
(encrypt_v1(key, &plaintext)?, None)
|
||||
};
|
||||
let cid = rand::thread_rng()
|
||||
.gen_range(1_000_000_000_u64..=9_999_999_999_u64)
|
||||
let cid = rand::random_range(1_000_000_000_u64..=9_999_999_999_u64)
|
||||
.to_string();
|
||||
let request_kind = inner_payload
|
||||
.get("t")
|
||||
@@ -2047,7 +2045,7 @@ pub async fn cloud_reconnect_loop(
|
||||
}
|
||||
}
|
||||
let base = (1_u64 << attempt.min(6)).min(60);
|
||||
let jitter = rand::thread_rng().gen_range(0..=base.min(10));
|
||||
let jitter = rand::random_range(0..=base.min(10));
|
||||
sleep(Duration::from_secs(base + jitter)).await;
|
||||
}
|
||||
}
|
||||
@@ -2056,6 +2054,11 @@ pub async fn cloud_reconnect_loop(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn test_http_client() -> reqwest::Client {
|
||||
crate::install_rustls_crypto_provider();
|
||||
reqwest::Client::new()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_parser_rejects_malformed_column_count() {
|
||||
assert!(properties_from_payload(&json!({"t":"dat","cols":["Pow"],"dat":[]})).is_err());
|
||||
@@ -2151,7 +2154,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn mqtt_payload_is_ignored_after_all_cloud_devices_are_removed() {
|
||||
let provider = GreeCloudProvider::new(reqwest::Client::new());
|
||||
let provider = GreeCloudProvider::new(test_http_client());
|
||||
let result = provider
|
||||
.handle_raw_message("status/stale-parent/device", b"not-json-anymore")
|
||||
.await;
|
||||
@@ -2160,7 +2163,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn mqtt_payload_for_unknown_parent_still_errors_when_devices_are_registered() {
|
||||
let provider = GreeCloudProvider::new(reqwest::Client::new());
|
||||
let provider = GreeCloudProvider::new(test_http_client());
|
||||
provider.inner.registered.write().await.insert(
|
||||
"AABBCCDDEEFF".into(),
|
||||
RegisteredDevice {
|
||||
@@ -2190,7 +2193,7 @@ mod tests {
|
||||
None,
|
||||
Arc::new(AtomicBool::new(false)),
|
||||
);
|
||||
let providers = ProviderDispatcher::new(local, reqwest::Client::new());
|
||||
let providers = ProviderDispatcher::new(local, test_http_client());
|
||||
let mut device = Device::simulated_default();
|
||||
device.connection_type = ConnectionType::Local;
|
||||
assert_eq!(providers.provider_name(&device), "local");
|
||||
@@ -2200,7 +2203,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn cloud_provider_rejects_local_device_before_network_io() {
|
||||
let provider = GreeCloudProvider::new(reqwest::Client::new());
|
||||
let provider = GreeCloudProvider::new(test_http_client());
|
||||
let device = Device::simulated_default();
|
||||
let error = provider.register_device(&device).await.unwrap_err();
|
||||
assert!(error.to_string().contains("Local device"));
|
||||
@@ -2242,7 +2245,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn cloud_command_order_matches_reference() {
|
||||
let provider = GreeCloudProvider::new(reqwest::Client::new());
|
||||
let provider = GreeCloudProvider::new(test_http_client());
|
||||
let mut device = Device::simulated_default();
|
||||
device.id = "cloud-test".into();
|
||||
device.connection_type = ConnectionType::GreeCloud;
|
||||
|
||||
Reference in New Issue
Block a user