This commit is contained in:
Mateusz Gruszczyński
2026-09-18 10:27:39 +02:00
parent 7d9e6c8189
commit 3ffb02595e
28 changed files with 610 additions and 480 deletions
+28 -29
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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),
)
}
+3 -3
View File
@@ -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);
}
}
+2 -2
View File
@@ -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")
}
+8
View File
@@ -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
View File
@@ -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);
+4 -3
View File
@@ -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
View File
@@ -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;