feat(frontend): show intercepted request & response

This commit is contained in:
2025-12-08 17:35:12 +08:00
parent 2a6a3a739c
commit 51a482de7f
11 changed files with 202 additions and 88 deletions

View File

@@ -36,6 +36,9 @@
sqlite sqlite
gemini-cli gemini-cli
biome biome
sqlx-cli
typos
lazysql
]; ];
buildInputs = [ ]; buildInputs = [ ];
LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath (nativeBuildInputs ++ buildInputs); LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath (nativeBuildInputs ++ buildInputs);

View File

@@ -3,7 +3,7 @@ import RulesList from "./components/RulesList";
import CommandQueue from "./components/CommandQueue"; import CommandQueue from "./components/CommandQueue";
import Login from "./components/Login"; import Login from "./components/Login";
import ChangePassword from "./components/ChangePassword"; import ChangePassword from "./components/ChangePassword";
import RequestLog from "./components/RequestLog"; import RequestLogs from "./components/RequestLogs";
import { authStore } from "./api/auth"; import { authStore } from "./api/auth";
const App: Component = () => { const App: Component = () => {
@@ -80,7 +80,7 @@ const App: Component = () => {
: "border-transparent text-gray-500 hover:text-gray-700" : "border-transparent text-gray-500 hover:text-gray-700"
}`} }`}
> >
Request Log Request Logs
</button> </button>
</div> </div>
</div> </div>
@@ -89,7 +89,7 @@ const App: Component = () => {
<main class="max-w-7xl mx-auto px-4 py-6"> <main class="max-w-7xl mx-auto px-4 py-6">
{activeTab() === "rules" && <RulesList />} {activeTab() === "rules" && <RulesList />}
{activeTab() === "commands" && <CommandQueue />} {activeTab() === "commands" && <CommandQueue />}
{activeTab() === "logs" && <RequestLog />} {activeTab() === "logs" && <RequestLogs />}
</main> </main>
<Show when={showChangePassword()}> <Show when={showChangePassword()}>

View File

@@ -83,6 +83,38 @@ const RequestDetails: Component<RequestDetailsProps> = (props) => {
<TreeView name="body" data={props.log.response_body} isRoot={true} /> <TreeView name="body" data={props.log.response_body} isRoot={true} />
</div> </div>
</div> </div>
<Show when={props.log.intercepted_request}>
<div>
<h4 class="font-semibold mb-2 flex items-center">
Intercepted Request
<Show when={props.log.request_interception_action}>
<span class="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
{props.log.request_interception_action}
</span>
</Show>
</h4>
<div class="bg-gray-50 p-3 rounded overflow-x-auto">
<TreeView name="body" data={props.log.intercepted_request} isRoot={true} />
</div>
</div>
</Show>
<Show when={props.log.intercepted_response}>
<div>
<h4 class="font-semibold mb-2 flex items-center">
Intercepted Response
<Show when={props.log.response_interception_action}>
<span class="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
{props.log.response_interception_action}
</span>
</Show>
</h4>
<div class="bg-gray-50 p-3 rounded overflow-x-auto">
<TreeView name="body" data={props.log.intercepted_response} isRoot={true} />
</div>
</div>
</Show>
</div> </div>
</CardContent> </CardContent>

View File

@@ -7,7 +7,7 @@ import { Input } from "./ui/Input";
import { Select } from "./ui/Select"; import { Select } from "./ui/Select";
import RequestDetails from "./RequestDetails"; import RequestDetails from "./RequestDetails";
const RequestLog: Component = () => { const RequestLogs: Component = () => {
const [search, setSearch] = createSignal(""); const [search, setSearch] = createSignal("");
const [inputValue, setInputValue] = createSignal(""); const [inputValue, setInputValue] = createSignal("");
const [method, setMethod] = createSignal(""); const [method, setMethod] = createSignal("");
@@ -51,7 +51,7 @@ const RequestLog: Component = () => {
<div class="space-y-4"> <div class="space-y-4">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h2 class="text-2xl font-semibold"> <h2 class="text-2xl font-semibold">
Request Log Request Logs
<Show when={logs() && !logs.loading && !allLogs.loading}> <Show when={logs() && !logs.loading && !allLogs.loading}>
<span class="text-gray-500 text-base ml-2"> <span class="text-gray-500 text-base ml-2">
({logs()?.length} of {allLogs()?.length} logs) ({logs()?.length} of {allLogs()?.length} logs)
@@ -138,4 +138,4 @@ const RequestLog: Component = () => {
); );
}; };
export default RequestLog; export default RequestLogs;

View File

@@ -1,13 +1,15 @@
export interface InterceptionRule { export interface InterceptionRule {
id: number; id: number;
method_name: string; method_name: string;
action: "passthrough" | "modify" | "replace"; action: InterceptionAction;
custom_response: any | null; custom_response: any | null;
is_enabled: boolean; is_enabled: boolean;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
export type InterceptionAction = "passthrough" | "modify" | "replace";
export interface Command { export interface Command {
id: number; id: number;
command: any; command: any;
@@ -50,7 +52,9 @@ export interface RequestLog {
method: string; method: string;
request_body: object; request_body: object;
response_body: object; response_body: object;
request_interception_action: string; intercepted_request: object;
response_interception_action: string; intercepted_response: object;
request_interception_action?: InterceptionAction;
response_interception_action?: InterceptionAction;
created_at: string; created_at: string;
} }

View File

@@ -0,0 +1,4 @@
ALTER TABLE request_logs
ADD intercepted_request TEXT;
ALTER TABLE request_logs
ADD intercepted_response TEXT;

View File

@@ -4,6 +4,8 @@ use chrono::{DateTime, TimeZone, Utc};
use serde::{Deserialize, Serialize, Serializer}; use serde::{Deserialize, Serialize, Serializer};
use serde_json::Value; use serde_json::Value;
use crate::db::models::InterceptionAction;
// Authentication models // Authentication models
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct LoginRequest { pub struct LoginRequest {
@@ -48,7 +50,7 @@ pub struct UpdateCommandRequest {
pub struct RuleResponse { pub struct RuleResponse {
pub id: i64, pub id: i64,
pub method_name: String, pub method_name: String,
pub action: String, pub action: InterceptionAction,
pub custom_response: Option<String>, pub custom_response: Option<String>,
pub is_enabled: bool, pub is_enabled: bool,
#[serde(serialize_with = "serialize_dt")] #[serde(serialize_with = "serialize_dt")]

View File

@@ -33,14 +33,14 @@ impl Cryptor {
} }
pub fn encrypt(&self, plaintext: String) -> String { pub fn encrypt(&self, plaintext: String) -> String {
// Allocate buffer with extra space for padding (AES block size is 16 bytes) // Prepare buffer with extra space for padding (AES block size is 16 bytes)
let plaintext_bytes = plaintext.as_bytes(); let len = plaintext.len();
let mut buffer = vec![0u8; 16 * (plaintext_bytes.len() / 16 + 1)]; let mut buffer = plaintext.into_bytes();
buffer[..plaintext_bytes.len()].copy_from_slice(plaintext_bytes); buffer.extend(std::iter::repeat_n(0, 16 * (len / 16 + 1)));
let ciphertext = self let ciphertext = self
.encryptor .encryptor
.clone() .clone()
.encrypt_padded_mut::<Pkcs7>(&mut buffer, plaintext_bytes.len()) .encrypt_padded_mut::<Pkcs7>(&mut buffer, len)
.expect("enough space for encrypting is allocated"); .expect("enough space for encrypting is allocated");
STANDARD.encode(ciphertext) STANDARD.encode(ciphertext)

View File

@@ -4,11 +4,22 @@ use chrono::{DateTime, TimeZone, Utc};
use serde::{Deserialize, Serialize, Serializer}; use serde::{Deserialize, Serialize, Serializer};
use serde_json::Value; 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)] #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct InterceptionRule { pub struct InterceptionRule {
pub id: i64, pub id: i64,
pub method_name: String, pub method_name: String,
pub action: String, pub action: InterceptionAction,
pub custom_response: Option<String>, pub custom_response: Option<String>,
pub is_enabled: bool, pub is_enabled: bool,
#[serde(serialize_with = "serialize_dt")] #[serde(serialize_with = "serialize_dt")]
@@ -45,8 +56,10 @@ pub struct RequestLog {
pub method: String, pub method: String,
pub request_body: Value, pub request_body: Value,
pub response_body: Value, pub response_body: Value,
pub request_interception_action: String, pub intercepted_request: Option<Value>,
pub response_interception_action: String, pub intercepted_response: Option<Value>,
pub request_interception_action: Option<InterceptionAction>,
pub response_interception_action: Option<InterceptionAction>,
#[serde(serialize_with = "serialize_dt")] #[serde(serialize_with = "serialize_dt")]
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
} }

View File

@@ -1,5 +1,4 @@
use crate::db::models::RequestLog; use crate::db::models::{InterceptionAction, RequestLog};
use serde_json::Value;
use sqlx::{QueryBuilder, SqlitePool}; use sqlx::{QueryBuilder, SqlitePool};
pub async fn list( pub async fn list(
@@ -37,23 +36,29 @@ pub async fn list(
pub async fn create( pub async fn create(
pool: &SqlitePool, pool: &SqlitePool,
method: String, method: String,
request_body: Value, request_body: String,
response_body: Value, response_body: String,
request_interception_action: String, intercepted_request: Option<String>,
response_interception_action: String, intercepted_response: Option<String>,
request_interception_action: Option<InterceptionAction>,
response_interception_action: Option<InterceptionAction>,
) -> Result<i64, sqlx::Error> { ) -> Result<i64, sqlx::Error> {
let result = sqlx::query( let result = sqlx::query(
"INSERT INTO request_logs ( "INSERT INTO request_logs (
method, method,
request_body, request_body,
response_body, response_body,
intercepted_request,
intercepted_response,
request_interception_action, request_interception_action,
response_interception_action response_interception_action
) VALUES (?, ?, ?, ?, ?)", ) VALUES (?, ?, ?, ?, ?, ?, ?)",
) )
.bind(method) .bind(method)
.bind(request_body) .bind(request_body)
.bind(response_body) .bind(response_body)
.bind(intercepted_request)
.bind(intercepted_response)
.bind(request_interception_action) .bind(request_interception_action)
.bind(response_interception_action) .bind(response_interception_action)
.execute(pool) .execute(pool)

View File

@@ -6,12 +6,23 @@ use axum::{
middleware::Next, middleware::Next,
response::{IntoResponse, Response}, response::{IntoResponse, Response},
}; };
use bytes::Bytes;
use http_body_util::BodyExt; use http_body_util::BodyExt;
use serde_json::Value; use serde_json::Value;
use tracing::{debug, warn}; 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. /// Main middleware to intercept, decrypt, modify, and log requests and responses.
pub async fn middleware( pub async fn middleware(
@@ -20,8 +31,15 @@ pub async fn middleware(
next: Next, next: Next,
) -> impl IntoResponse { ) -> impl IntoResponse {
let (parts, body) = req.into_parts(); let (parts, body) = req.into_parts();
let body_bytes = match body.collect().await { let body_bytes;
Ok(body) => body.to_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) => { Err(e) => {
warn!("Failed to read request body: {}", e); warn!("Failed to read request body: {}", e);
return ( return (
@@ -33,16 +51,27 @@ pub async fn middleware(
}; };
// Process request: decrypt, deserialize, modify, re-encrypt // 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 // 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; let res = next.run(req).await;
// Process response: decrypt, deserialize, modify, re-encrypt // Process response: decrypt, deserialize, modify, re-encrypt
let (resp_parts, body) = res.into_parts(); let (resp_parts, body) = res.into_parts();
let body_bytes = match body.collect().await { let body_bytes;
Ok(b) => b.to_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) => { Err(e) => {
warn!("Failed to read response body: {}", e); warn!("Failed to read response body: {}", e);
return ( return (
@@ -53,25 +82,24 @@ pub async fn middleware(
} }
}; };
let (final_response_body, decrypted_response) = let processed_resp = process_response(body, &method, &ctx).await;
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 (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 // Write log to database
if let Err(e) = db::repositories::logs::create( if let Err(e) = db::repositories::logs::create(
&ctx.db, &ctx.db,
method, method,
decrypted_request, processed_req.original,
decrypted_response, processed_resp.original,
"".into(), req_body,
"".into(), resp_body,
req_action,
resp_action,
) )
.await .await
{ {
@@ -84,16 +112,14 @@ pub async fn middleware(
*response_builder.headers_mut().unwrap() = resp_parts.headers; *response_builder.headers_mut().unwrap() = resp_parts.headers;
} }
response_builder response_builder
.body(axum::body::Body::from(final_response_body)) .body(axum::body::Body::from(processed_resp.encrypted))
.unwrap() .unwrap()
} }
/// Processes the incoming request body. /// Processes the incoming request body.
/// Returns the re-encrypted body for the next handler, the decrypted JSON for logging, and the method name. /// 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<AppContext>) -> (String, Value, String) { async fn process_request(body: &str, ctx: &Arc<AppContext>) -> (Processed, String) {
let body_str = String::from_utf8(body_bytes.to_vec()).unwrap_or_default(); let mut plain_request: Value = serde_json::from_str(body).unwrap_or_else(|err| {
let mut plain_request: Value = serde_json::from_str(&body_str).unwrap_or_else(|err| {
warn!( warn!(
"Failed to deserialize request body: {}. Using fallback string value.", "Failed to deserialize request body: {}. Using fallback string value.",
err err
@@ -101,42 +127,54 @@ async fn process_request(body_bytes: Bytes, ctx: &Arc<AppContext>) -> (String, V
Value::String("Could not deserialize request".into()) Value::String("Could not deserialize request".into())
}); });
decrypt_params(&mut plain_request, ctx); decrypt_params(&mut plain_request, ctx);
let original = serde_json::to_string(&plain_request).expect("deserialization succeeded");
let method = plain_request let method = plain_request
.get("method") .get("method")
.and_then(Value::as_str) .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(); .to_string();
if let Err(e) = modify_request(&mut plain_request, &method, ctx).await { let action = match modify_request(&mut plain_request, &method, ctx).await {
warn!("Failed to modify request: {}", e); 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(); let mut encrypted_request = plain_request.clone();
encrypt_params(&mut crypted_request, ctx); encrypt_params(&mut encrypted_request, ctx);
( (
serde_json::to_string(&crypted_request).expect("deserialization succeeded"), Processed {
plain_request, original,
final_,
encrypted: serde_json::to_string(&encrypted_request)
.expect("deserialization succeeded"),
},
method, method,
) )
} }
/// Processes the outgoing response body. /// Processes the outgoing response body.
/// Returns the final encrypted body for the client and the decrypted JSON for logging. /// Returns the final encrypted body for the client and the decrypted JSON for logging.
async fn process_response( async fn process_response(body: String, method: &str, ctx: &Arc<AppContext>) -> Processed {
body_bytes: Bytes, let decrypted_body = ctx.cryptor.decrypt(body.clone()).unwrap_or_else(|err| {
method: &str,
ctx: &Arc<AppContext>,
) -> (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| {
warn!( warn!(
"Failed to decrypt response body: {}. Assuming it's not encrypted.", "Failed to decrypt response body: {}. Assuming it's not encrypted.",
err err
); );
body_str body
}); });
let mut response_value: Value = serde_json::from_str(&decrypted_body).unwrap_or_else(|err| { 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()) 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 { let modified_body_str = serde_json::to_string(&response_value).expect("serialization succeeded");
warn!("Failed to modify response: {}", e); 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. /// Placeholder for request modification logic.
@@ -164,9 +209,9 @@ async fn modify_request(
_request_json: &mut Value, _request_json: &mut Value,
_method: &str, _method: &str,
_ctx: &Arc<AppContext>, _ctx: &Arc<AppContext>,
) -> anyhow::Result<()> { ) -> anyhow::Result<Option<InterceptionAction>> {
// TODO: Implement request modification logic based on rules or other criteria. // TODO: Implement request modification logic based on rules or other criteria.
Ok(()) Ok(None)
} }
/// Applies modification rules to the response. /// Applies modification rules to the response.
@@ -174,10 +219,9 @@ async fn modify_response(
response_json: &mut Value, response_json: &mut Value,
method: &str, method: &str,
ctx: &Arc<AppContext>, ctx: &Arc<AppContext>,
original_decrypted: &str, ) -> anyhow::Result<Option<InterceptionAction>> {
) -> anyhow::Result<()> {
// Check for generic method interception (e.g., replace response from DB) // 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); debug!("Intercepting response for method: {}", method);
*response_json = serde_json::from_str(&intercepted).unwrap_or_else(|e| { *response_json = serde_json::from_str(&intercepted).unwrap_or_else(|e| {
warn!( warn!(
@@ -186,10 +230,11 @@ async fn modify_response(
); );
Value::String(intercepted) Value::String(intercepted)
}); });
return Ok(()); return Ok(Some(action));
} }
// Special handling for getcommand // Special handling for getcommand
// TODO: Return interception rule
if method == "com.linspirer.device.getcommand" if method == "com.linspirer.device.getcommand"
&& let Err(e) = handle_getcommand_response(response_json, ctx).await && 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. /// Handles the 'getcommand' response by injecting verified commands.
@@ -257,15 +302,21 @@ async fn handle_getcommand_response(
/// Checks for and applies response interception rules. /// Checks for and applies response interception rules.
async fn intercept_response( async fn intercept_response(
method: &str, method: &str,
_orignal_response: &str,
ctx: &Arc<AppContext>, ctx: &Arc<AppContext>,
) -> anyhow::Result<Option<String>> { ) -> anyhow::Result<Option<(String, InterceptionAction)>> {
// Check if there's an interception rule for this method // Check if there's an interception rule for this method
let rule = crate::db::repositories::rules::find_by_method(&ctx.db, method).await?; let rule = crate::db::repositories::rules::find_by_method(&ctx.db, method).await?;
match rule { match rule {
Some(r) if r.action == "replace" => Ok(r.custom_response.as_ref().cloned()), Some(InterceptionRule {
Some(r) if r.action == "modify" => { action: InterceptionAction::Replace,
custom_response,
..
}) => Ok(custom_response.map(|resp| (resp, InterceptionAction::Replace))),
Some(InterceptionRule {
action: InterceptionAction::Modify,
..
}) => {
// TODO: Apply modifications // TODO: Apply modifications
Ok(None) Ok(None)
} }