diff --git a/flake.nix b/flake.nix index 0072096..c5c3f1b 100644 --- a/flake.nix +++ b/flake.nix @@ -36,6 +36,9 @@ sqlite gemini-cli biome + sqlx-cli + typos + lazysql ]; buildInputs = [ ]; LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath (nativeBuildInputs ++ buildInputs); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e941b74..87d1c98 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,7 +3,7 @@ import RulesList from "./components/RulesList"; import CommandQueue from "./components/CommandQueue"; import Login from "./components/Login"; import ChangePassword from "./components/ChangePassword"; -import RequestLog from "./components/RequestLog"; +import RequestLogs from "./components/RequestLogs"; import { authStore } from "./api/auth"; const App: Component = () => { @@ -80,7 +80,7 @@ const App: Component = () => { : "border-transparent text-gray-500 hover:text-gray-700" }`} > - Request Log + Request Logs @@ -89,7 +89,7 @@ const App: Component = () => {
{activeTab() === "rules" && } {activeTab() === "commands" && } - {activeTab() === "logs" && } + {activeTab() === "logs" && }
diff --git a/frontend/src/components/RequestDetails.tsx b/frontend/src/components/RequestDetails.tsx index 208ad29..8ef601e 100644 --- a/frontend/src/components/RequestDetails.tsx +++ b/frontend/src/components/RequestDetails.tsx @@ -83,6 +83,38 @@ const RequestDetails: Component = (props) => { + + + +
+

+ Intercepted Request + + + {props.log.request_interception_action} + + +

+
+ +
+
+
+ +
+

+ Intercepted Response + + + {props.log.response_interception_action} + + +

+
+ +
+
+
diff --git a/frontend/src/components/RequestLog.tsx b/frontend/src/components/RequestLogs.tsx similarity index 98% rename from frontend/src/components/RequestLog.tsx rename to frontend/src/components/RequestLogs.tsx index f1c21a4..9f34746 100644 --- a/frontend/src/components/RequestLog.tsx +++ b/frontend/src/components/RequestLogs.tsx @@ -7,7 +7,7 @@ import { Input } from "./ui/Input"; import { Select } from "./ui/Select"; import RequestDetails from "./RequestDetails"; -const RequestLog: Component = () => { +const RequestLogs: Component = () => { const [search, setSearch] = createSignal(""); const [inputValue, setInputValue] = createSignal(""); const [method, setMethod] = createSignal(""); @@ -51,7 +51,7 @@ const RequestLog: Component = () => {

- Request Log + Request Logs ({logs()?.length} of {allLogs()?.length} logs) @@ -138,4 +138,4 @@ const RequestLog: Component = () => { ); }; -export default RequestLog; +export default RequestLogs; diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 2c67bee..92ecd78 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -1,13 +1,15 @@ export interface InterceptionRule { id: number; method_name: string; - action: "passthrough" | "modify" | "replace"; + action: InterceptionAction; custom_response: any | null; is_enabled: boolean; created_at: string; updated_at: string; } +export type InterceptionAction = "passthrough" | "modify" | "replace"; + export interface Command { id: number; command: any; @@ -50,7 +52,9 @@ export interface RequestLog { method: string; request_body: object; response_body: object; - request_interception_action: string; - response_interception_action: string; + intercepted_request: object; + intercepted_response: object; + request_interception_action?: InterceptionAction; + response_interception_action?: InterceptionAction; created_at: string; } diff --git a/migrations/20251209101840_add_request_logs_intercepted.sql b/migrations/20251209101840_add_request_logs_intercepted.sql new file mode 100644 index 0000000..18a140b --- /dev/null +++ b/migrations/20251209101840_add_request_logs_intercepted.sql @@ -0,0 +1,4 @@ +ALTER TABLE request_logs +ADD intercepted_request TEXT; +ALTER TABLE request_logs +ADD intercepted_response TEXT; diff --git a/src/admin/models.rs b/src/admin/models.rs index 92c27f1..c99b038 100644 --- a/src/admin/models.rs +++ b/src/admin/models.rs @@ -4,6 +4,8 @@ use chrono::{DateTime, TimeZone, Utc}; use serde::{Deserialize, Serialize, Serializer}; use serde_json::Value; +use crate::db::models::InterceptionAction; + // Authentication models #[derive(Debug, Deserialize)] pub struct LoginRequest { @@ -48,7 +50,7 @@ pub struct UpdateCommandRequest { pub struct RuleResponse { pub id: i64, pub method_name: String, - pub action: String, + pub action: InterceptionAction, pub custom_response: Option, pub is_enabled: bool, #[serde(serialize_with = "serialize_dt")] diff --git a/src/crypto.rs b/src/crypto.rs index babf8e3..76b8342 100644 --- a/src/crypto.rs +++ b/src/crypto.rs @@ -33,14 +33,14 @@ impl Cryptor { } pub fn encrypt(&self, plaintext: String) -> String { - // Allocate buffer with extra space for padding (AES block size is 16 bytes) - let plaintext_bytes = plaintext.as_bytes(); - let mut buffer = vec![0u8; 16 * (plaintext_bytes.len() / 16 + 1)]; - buffer[..plaintext_bytes.len()].copy_from_slice(plaintext_bytes); + // Prepare buffer with extra space for padding (AES block size is 16 bytes) + let len = plaintext.len(); + let mut buffer = plaintext.into_bytes(); + buffer.extend(std::iter::repeat_n(0, 16 * (len / 16 + 1))); let ciphertext = self .encryptor .clone() - .encrypt_padded_mut::(&mut buffer, plaintext_bytes.len()) + .encrypt_padded_mut::(&mut buffer, len) .expect("enough space for encrypting is allocated"); STANDARD.encode(ciphertext) diff --git a/src/db/models.rs b/src/db/models.rs index 1cc277d..fc94d9d 100644 --- a/src/db/models.rs +++ b/src/db/models.rs @@ -4,11 +4,22 @@ use chrono::{DateTime, TimeZone, Utc}; use serde::{Deserialize, Serialize, Serializer}; use serde_json::Value; +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, sqlx::Type)] +#[serde(rename_all = "snake_case")] +#[sqlx(rename_all = "snake_case")] +pub enum InterceptionAction { + Passthrough, + Modify, + Replace, + #[serde(other)] + Unknown, +} + #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] pub struct InterceptionRule { pub id: i64, pub method_name: String, - pub action: String, + pub action: InterceptionAction, pub custom_response: Option, pub is_enabled: bool, #[serde(serialize_with = "serialize_dt")] @@ -45,8 +56,10 @@ pub struct RequestLog { pub method: String, pub request_body: Value, pub response_body: Value, - pub request_interception_action: String, - pub response_interception_action: String, + pub intercepted_request: Option, + pub intercepted_response: Option, + pub request_interception_action: Option, + pub response_interception_action: Option, #[serde(serialize_with = "serialize_dt")] pub created_at: DateTime, } diff --git a/src/db/repositories/logs.rs b/src/db/repositories/logs.rs index f30075b..28b2fe9 100644 --- a/src/db/repositories/logs.rs +++ b/src/db/repositories/logs.rs @@ -1,5 +1,4 @@ -use crate::db::models::RequestLog; -use serde_json::Value; +use crate::db::models::{InterceptionAction, RequestLog}; use sqlx::{QueryBuilder, SqlitePool}; pub async fn list( @@ -37,23 +36,29 @@ pub async fn list( pub async fn create( pool: &SqlitePool, method: String, - request_body: Value, - response_body: Value, - request_interception_action: String, - response_interception_action: String, + request_body: String, + response_body: String, + intercepted_request: Option, + intercepted_response: Option, + request_interception_action: Option, + response_interception_action: Option, ) -> Result { let result = sqlx::query( "INSERT INTO request_logs ( method, request_body, response_body, + intercepted_request, + intercepted_response, request_interception_action, response_interception_action - ) VALUES (?, ?, ?, ?, ?)", + ) VALUES (?, ?, ?, ?, ?, ?, ?)", ) .bind(method) .bind(request_body) .bind(response_body) + .bind(intercepted_request) + .bind(intercepted_response) .bind(request_interception_action) .bind(response_interception_action) .execute(pool) diff --git a/src/middleware.rs b/src/middleware.rs index 50d1bdd..89e7e2b 100644 --- a/src/middleware.rs +++ b/src/middleware.rs @@ -6,12 +6,23 @@ use axum::{ middleware::Next, response::{IntoResponse, Response}, }; -use bytes::Bytes; use http_body_util::BodyExt; use serde_json::Value; use tracing::{debug, warn}; -use crate::{AppContext, db}; +use crate::{ + AppContext, + db::{ + self, + models::{InterceptionAction, InterceptionRule}, + }, +}; + +struct Processed { + original: String, + final_: Option<(String, InterceptionAction)>, + encrypted: String, +} /// Main middleware to intercept, decrypt, modify, and log requests and responses. pub async fn middleware( @@ -20,8 +31,15 @@ pub async fn middleware( next: Next, ) -> impl IntoResponse { let (parts, body) = req.into_parts(); - let body_bytes = match body.collect().await { - Ok(body) => body.to_bytes(), + let body_bytes; + let body = match body.collect().await { + Ok(body) => { + body_bytes = body.to_bytes(); + str::from_utf8(&body_bytes).unwrap_or_else(|e| { + warn!("Received request with invalid UTF-8: {e}. Replacing with an empty string"); + "" + }) + } Err(e) => { warn!("Failed to read request body: {}", e); return ( @@ -33,16 +51,27 @@ pub async fn middleware( }; // Process request: decrypt, deserialize, modify, re-encrypt - let (processed_req_body, decrypted_request, method) = process_request(body_bytes, &ctx).await; + let (processed_req, method) = process_request(body, &ctx).await; // Pass modified request to the next handler - let req = Request::from_parts(parts, axum::body::Body::from(processed_req_body)); + let req = Request::from_parts(parts, axum::body::Body::from(processed_req.encrypted)); let res = next.run(req).await; // Process response: decrypt, deserialize, modify, re-encrypt let (resp_parts, body) = res.into_parts(); - let body_bytes = match body.collect().await { - Ok(b) => b.to_bytes(), + let body_bytes; + let body = match body.collect().await { + Ok(b) => { + body_bytes = b.to_bytes(); + str::from_utf8(&body_bytes) + .unwrap_or_else(|e| { + warn!( + "Received response with invalid UTF-8: {e}. Replacing with an empty string" + ); + "" + }) + .to_string() + } Err(e) => { warn!("Failed to read response body: {}", e); return ( @@ -53,25 +82,24 @@ pub async fn middleware( } }; - let (final_response_body, decrypted_response) = - process_response(body_bytes, &method, &ctx).await; - - // Log the decrypted request and response - debug!( - "\nRequest:\n{}\nResponse:\n{}\n{}", - serde_json::to_string_pretty(&decrypted_request).unwrap_or_default(), - serde_json::to_string_pretty(&decrypted_response).unwrap_or_default(), - "-".repeat(80), - ); + let processed_resp = process_response(body, &method, &ctx).await; + let (req_body, req_action) = processed_req + .final_ + .map_or((None, None), |(a, b)| (Some(a), Some(b))); + let (resp_body, resp_action) = processed_resp + .final_ + .map_or((None, None), |(a, b)| (Some(a), Some(b))); // Write log to database if let Err(e) = db::repositories::logs::create( &ctx.db, method, - decrypted_request, - decrypted_response, - "".into(), - "".into(), + processed_req.original, + processed_resp.original, + req_body, + resp_body, + req_action, + resp_action, ) .await { @@ -84,16 +112,14 @@ pub async fn middleware( *response_builder.headers_mut().unwrap() = resp_parts.headers; } response_builder - .body(axum::body::Body::from(final_response_body)) + .body(axum::body::Body::from(processed_resp.encrypted)) .unwrap() } /// Processes the incoming request body. /// Returns the re-encrypted body for the next handler, the decrypted JSON for logging, and the method name. -async fn process_request(body_bytes: Bytes, ctx: &Arc) -> (String, Value, String) { - let body_str = String::from_utf8(body_bytes.to_vec()).unwrap_or_default(); - - let mut plain_request: Value = serde_json::from_str(&body_str).unwrap_or_else(|err| { +async fn process_request(body: &str, ctx: &Arc) -> (Processed, String) { + let mut plain_request: Value = serde_json::from_str(body).unwrap_or_else(|err| { warn!( "Failed to deserialize request body: {}. Using fallback string value.", err @@ -101,42 +127,54 @@ async fn process_request(body_bytes: Bytes, ctx: &Arc) -> (String, V Value::String("Could not deserialize request".into()) }); decrypt_params(&mut plain_request, ctx); + let original = serde_json::to_string(&plain_request).expect("deserialization succeeded"); let method = plain_request .get("method") .and_then(Value::as_str) - .unwrap_or_default() + .unwrap_or_else(|| { + warn!("No JSON-RPC method found in request body, fallback to an empty string"); + "" + }) .to_string(); - if let Err(e) = modify_request(&mut plain_request, &method, ctx).await { - warn!("Failed to modify request: {}", e); - } + let action = match modify_request(&mut plain_request, &method, ctx).await { + Ok(action) => action, + Err(e) => { + warn!("Failed to modify request: {}", e); + None + } + }; + let final_ = action.map(|action| { + ( + serde_json::to_string(&plain_request).expect("deserialization succeeded"), + action, + ) + }); - let mut crypted_request = plain_request.clone(); - encrypt_params(&mut crypted_request, ctx); + let mut encrypted_request = plain_request.clone(); + encrypt_params(&mut encrypted_request, ctx); ( - serde_json::to_string(&crypted_request).expect("deserialization succeeded"), - plain_request, + Processed { + original, + final_, + encrypted: serde_json::to_string(&encrypted_request) + .expect("deserialization succeeded"), + }, method, ) } /// Processes the outgoing response body. /// Returns the final encrypted body for the client and the decrypted JSON for logging. -async fn process_response( - body_bytes: Bytes, - method: &str, - ctx: &Arc, -) -> (String, Value) { - let body_str = String::from_utf8(body_bytes.to_vec()).unwrap_or_default(); - - let decrypted_body = ctx.cryptor.decrypt(body_str.clone()).unwrap_or_else(|err| { +async fn process_response(body: String, method: &str, ctx: &Arc) -> Processed { + let decrypted_body = ctx.cryptor.decrypt(body.clone()).unwrap_or_else(|err| { warn!( "Failed to decrypt response body: {}. Assuming it's not encrypted.", err ); - body_str + body }); let mut response_value: Value = serde_json::from_str(&decrypted_body).unwrap_or_else(|err| { @@ -147,16 +185,23 @@ async fn process_response( Value::String(decrypted_body.clone()) }); - let decrypted = response_value.clone(); + let action = match modify_response(&mut response_value, method, ctx).await { + Ok(action) => action, + Err(e) => { + warn!("Failed to modify response: {}", e); + None + } + }; - if let Err(e) = modify_response(&mut response_value, method, ctx, &decrypted_body).await { - warn!("Failed to modify response: {}", e); + let modified_body_str = serde_json::to_string(&response_value).expect("serialization succeeded"); + let encrypted = ctx.cryptor.encrypt(modified_body_str.clone()); + let final_ = action.map(|action| (modified_body_str.clone(), action)); + + Processed { + original: decrypted_body, + final_, + encrypted, } - - let modified_body_str = serde_json::to_string(&response_value).unwrap_or_default(); - let encrypted = ctx.cryptor.encrypt(modified_body_str); - - (encrypted, decrypted) } /// Placeholder for request modification logic. @@ -164,9 +209,9 @@ async fn modify_request( _request_json: &mut Value, _method: &str, _ctx: &Arc, -) -> anyhow::Result<()> { +) -> anyhow::Result> { // TODO: Implement request modification logic based on rules or other criteria. - Ok(()) + Ok(None) } /// Applies modification rules to the response. @@ -174,10 +219,9 @@ async fn modify_response( response_json: &mut Value, method: &str, ctx: &Arc, - original_decrypted: &str, -) -> anyhow::Result<()> { +) -> anyhow::Result> { // Check for generic method interception (e.g., replace response from DB) - if let Some(intercepted) = intercept_response(method, original_decrypted, ctx).await? { + if let Some((intercepted, action)) = intercept_response(method, ctx).await? { debug!("Intercepting response for method: {}", method); *response_json = serde_json::from_str(&intercepted).unwrap_or_else(|e| { warn!( @@ -186,10 +230,11 @@ async fn modify_response( ); Value::String(intercepted) }); - return Ok(()); + return Ok(Some(action)); } // Special handling for getcommand + // TODO: Return interception rule if method == "com.linspirer.device.getcommand" && let Err(e) = handle_getcommand_response(response_json, ctx).await { @@ -202,7 +247,7 @@ async fn modify_response( } } - Ok(()) + Ok(None) } /// Handles the 'getcommand' response by injecting verified commands. @@ -257,15 +302,21 @@ async fn handle_getcommand_response( /// Checks for and applies response interception rules. async fn intercept_response( method: &str, - _orignal_response: &str, ctx: &Arc, -) -> anyhow::Result> { +) -> anyhow::Result> { // Check if there's an interception rule for this method let rule = crate::db::repositories::rules::find_by_method(&ctx.db, method).await?; match rule { - Some(r) if r.action == "replace" => Ok(r.custom_response.as_ref().cloned()), - Some(r) if r.action == "modify" => { + Some(InterceptionRule { + action: InterceptionAction::Replace, + custom_response, + .. + }) => Ok(custom_response.map(|resp| (resp, InterceptionAction::Replace))), + Some(InterceptionRule { + action: InterceptionAction::Modify, + .. + }) => { // TODO: Apply modifications Ok(None) }