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