feat: web frontend; middleware; serde (WIP?)
This commit is contained in:
@@ -2,16 +2,10 @@ root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
insert_final_newline = true
|
||||
|
||||
[*.{rs,toml}]
|
||||
indent_size = 4
|
||||
insert_final_newline = false
|
||||
|
||||
[*.nix]
|
||||
indent_size = 2
|
||||
|
||||
[Makefile]
|
||||
[*.{rs,toml,sql}]
|
||||
indent_size = 4
|
||||
|
||||
31
.env.example
31
.env.example
@@ -1,7 +1,26 @@
|
||||
# Please replace these with your actual key and IV
|
||||
LINSPIRER_KEY="0123456789abcdef"
|
||||
LINSPIRER_IV="0123456789abcdef"
|
||||
# Example .env file for Linspirer MITM Server
|
||||
# Copy this to .env and fill in your values
|
||||
|
||||
# Optional: Set the listening host and port
|
||||
# LINSPIRER_HOST="0.0.0.0"
|
||||
# LINSPIRER_PORT="8080"
|
||||
# Required: AES-128 encryption key (16 characters)
|
||||
LINSPIRER_KEY=your16charkey!!
|
||||
|
||||
# Required: AES-128 initialization vector (16 characters)
|
||||
LINSPIRER_IV=your16charivec!!
|
||||
|
||||
# Required: JWT secret for generating tokens
|
||||
LINSPIRER_JWT_SECRET=a-very-long-secret-string
|
||||
|
||||
# Optional: Target server URL (default: https://cloud.linspirer.com:883)
|
||||
# LINSPIRER_TARGET_URL=https://cloud.linspirer.com:883
|
||||
|
||||
# Optional: Server host (default: 0.0.0.0)
|
||||
# LINSPIRER_HOST=0.0.0.0
|
||||
|
||||
# Optional: Server port (default: 8080)
|
||||
# LINSPIRER_PORT=8080
|
||||
|
||||
# Optional: Database path (default: sqlite://./data/linspirer.db)
|
||||
# LINSPIRER_DB_PATH=sqlite://./data/linspirer.db
|
||||
|
||||
# Optional: Rust log level
|
||||
# RUST_LOG=info
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -4,3 +4,5 @@
|
||||
/target
|
||||
|
||||
/*.log
|
||||
|
||||
data
|
||||
|
||||
1184
Cargo.lock
generated
1184
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
12
Cargo.toml
12
Cargo.toml
@@ -6,9 +6,10 @@ edition = "2024"
|
||||
[dependencies]
|
||||
axum = "0.8"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tower-http = { version = "0.6", features = ["compression-full"] }
|
||||
tower-http = { version = "0.6", features = ["compression-full", "fs"] }
|
||||
hyper = { version = "1", features = ["full"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_with = { version = "3.16", features = ["hashbrown_0_16", "json"] }
|
||||
serde_json = "1"
|
||||
base64 = "0.22"
|
||||
aes = "0.8"
|
||||
@@ -21,4 +22,11 @@ thiserror = "2"
|
||||
http-body-util = "0.1.1"
|
||||
chrono = { version = "0.4", features = ["clock"] }
|
||||
reqwest = { version = "0.12", features = ["json", "rustls-tls", "gzip"], default-features = false }
|
||||
hashbrown = { version = "0.16", features = ["serde"] }
|
||||
hashbrown = { version = "0.16", features = ["serde"] }
|
||||
concat-idents = "1.1"
|
||||
indoc = "2.0"
|
||||
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"] }
|
||||
rust-embed = "8.0"
|
||||
mime_guess = "2.0"
|
||||
jsonwebtoken = "9"
|
||||
bcrypt = "0.17"
|
||||
|
||||
@@ -24,7 +24,8 @@
|
||||
gcc
|
||||
openssl
|
||||
pkg-config
|
||||
(fenix.packages.${system}.stable.withComponents [
|
||||
sqlite
|
||||
(fenix.packages.${system}.complete.withComponents [
|
||||
"cargo"
|
||||
"clippy"
|
||||
"rust-src"
|
||||
|
||||
7
frontend/.gitignore
vendored
Normal file
7
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
# Ignore node_modules and build artifacts
|
||||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
|
||||
# Keep the placeholder index.html for development
|
||||
!dist/index.html
|
||||
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>My Linspirer Admin</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
2780
frontend/package-lock.json
generated
Normal file
2780
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
frontend/package.json
Normal file
23
frontend/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "mylinspirer-admin",
|
||||
"version": "0.1.0",
|
||||
"description": "Admin frontend for My Linspirer MITM Server",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"solid-js": "^1.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"autoprefixer": "^10.4.0",
|
||||
"postcss": "^8.4.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"terser": "^5.44.1",
|
||||
"typescript": "^5.6.0",
|
||||
"vite": "^5.4.0",
|
||||
"vite-plugin-solid": "^2.10.2"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
96
frontend/src/App.tsx
Normal file
96
frontend/src/App.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { Component, createSignal, onMount, Show } from 'solid-js';
|
||||
import RulesList from './components/RulesList';
|
||||
import CommandQueue from './components/CommandQueue';
|
||||
import Login from './components/Login';
|
||||
import ChangePassword from './components/ChangePassword';
|
||||
import { authStore } from './api/auth';
|
||||
|
||||
const App: Component = () => {
|
||||
const [activeTab, setActiveTab] = createSignal<'rules' | 'commands'>('rules');
|
||||
const [isAuthenticated, setIsAuthenticated] = createSignal(false);
|
||||
const [showChangePassword, setShowChangePassword] = createSignal(false);
|
||||
|
||||
onMount(() => {
|
||||
// Check if user is already authenticated
|
||||
setIsAuthenticated(authStore.isAuthenticated());
|
||||
});
|
||||
|
||||
const handleLoginSuccess = (token: string) => {
|
||||
authStore.setToken(token);
|
||||
setIsAuthenticated(true);
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
authStore.clearToken();
|
||||
setIsAuthenticated(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Show when={isAuthenticated()} fallback={<Login onLoginSuccess={handleLoginSuccess} />}>
|
||||
<div class="min-h-screen bg-gray-100">
|
||||
<header class="bg-white shadow">
|
||||
<div class="max-w-7xl mx-auto px-4 py-6 flex justify-between items-center">
|
||||
<h1 class="text-3xl font-bold text-gray-900">
|
||||
My Linspirer Control Panel
|
||||
</h1>
|
||||
<div class="flex space-x-3">
|
||||
<button
|
||||
onClick={() => setShowChangePassword(true)}
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
>
|
||||
Change Password
|
||||
</button>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav class="bg-white border-b">
|
||||
<div class="max-w-7xl mx-auto px-4">
|
||||
<div class="flex space-x-8">
|
||||
<button
|
||||
onClick={() => setActiveTab('rules')}
|
||||
class={`py-4 px-1 border-b-2 font-medium text-sm ${
|
||||
activeTab() === 'rules'
|
||||
? 'border-indigo-500 text-indigo-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
Interception Rules
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('commands')}
|
||||
class={`py-4 px-1 border-b-2 font-medium text-sm ${
|
||||
activeTab() === 'commands'
|
||||
? 'border-indigo-500 text-indigo-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
Command Queue
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="max-w-7xl mx-auto px-4 py-6">
|
||||
{activeTab() === 'rules' && <RulesList />}
|
||||
{activeTab() === 'commands' && <CommandQueue />}
|
||||
</main>
|
||||
|
||||
<Show when={showChangePassword()}>
|
||||
<ChangePassword
|
||||
onClose={() => setShowChangePassword(false)}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
19
frontend/src/api/auth.ts
Normal file
19
frontend/src/api/auth.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
const TOKEN_KEY = 'admin_token';
|
||||
|
||||
export const authStore = {
|
||||
getToken(): string | null {
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
},
|
||||
|
||||
setToken(token: string): void {
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
},
|
||||
|
||||
clearToken(): void {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
},
|
||||
|
||||
isAuthenticated(): boolean {
|
||||
return !!this.getToken();
|
||||
},
|
||||
};
|
||||
105
frontend/src/api/client.ts
Normal file
105
frontend/src/api/client.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import type { InterceptionRule, Command, CreateRuleRequest, UpdateRuleRequest, UpdateCommandRequest } from '../types';
|
||||
import { authStore } from './auth';
|
||||
|
||||
const API_BASE = '/admin/api';
|
||||
|
||||
async function request<T>(
|
||||
endpoint: string,
|
||||
options?: RequestInit
|
||||
): Promise<T> {
|
||||
const token = authStore.getToken();
|
||||
|
||||
const response = await fetch(`${API_BASE}${endpoint}`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { 'Authorization': `Bearer ${token}` } : {}),
|
||||
...options?.headers,
|
||||
},
|
||||
...options,
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
// Unauthorized - clear token and trigger re-authentication
|
||||
authStore.clearToken();
|
||||
window.location.reload();
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error(error.error || `API Error: ${response.statusText}`);
|
||||
}
|
||||
|
||||
if (response.status === 204 || response.status === 200) {
|
||||
// Check if response has content
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (!contentType || !contentType.includes('application/json')) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
// Check if there's actually content to parse
|
||||
const text = await response.text();
|
||||
if (!text || text.trim() === '') {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
return JSON.parse(text);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Rules API
|
||||
export const rulesApi = {
|
||||
list: () =>
|
||||
request<InterceptionRule[]>('/rules'),
|
||||
|
||||
create: (rule: CreateRuleRequest) =>
|
||||
request<InterceptionRule>('/rules', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(rule),
|
||||
}),
|
||||
|
||||
update: (id: number, rule: UpdateRuleRequest) =>
|
||||
request<InterceptionRule>(`/rules/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(rule),
|
||||
}),
|
||||
|
||||
delete: (id: number) =>
|
||||
request<void>(`/rules/${id}`, { method: 'DELETE' }),
|
||||
};
|
||||
|
||||
// Commands API
|
||||
export const commandsApi = {
|
||||
list: (status?: string) =>
|
||||
request<Command[]>(`/commands${status ? `?status=${status}` : ''}`),
|
||||
|
||||
updateStatus: (id: number, req: UpdateCommandRequest) =>
|
||||
request<Command>(`/commands/${id}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(req),
|
||||
}),
|
||||
};
|
||||
|
||||
// Config API
|
||||
export const configApi = {
|
||||
get: () => request<Record<string, string>>('/config'),
|
||||
update: (config: Record<string, string>) =>
|
||||
request<void>('/config', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(config),
|
||||
}),
|
||||
};
|
||||
|
||||
// Auth API
|
||||
export const authApi = {
|
||||
changePassword: (oldPassword: string, newPassword: string) =>
|
||||
request<void>('/password', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
old_password: oldPassword,
|
||||
new_password: newPassword,
|
||||
}),
|
||||
}),
|
||||
};
|
||||
143
frontend/src/components/ChangePassword.tsx
Normal file
143
frontend/src/components/ChangePassword.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import { Component, createSignal } from 'solid-js';
|
||||
import { authApi } from '../api/client';
|
||||
import { authStore } from '../api/auth';
|
||||
|
||||
interface ChangePasswordProps {
|
||||
onClose: () => void;
|
||||
onLogout: () => void;
|
||||
}
|
||||
|
||||
const ChangePassword: Component<ChangePasswordProps> = (props) => {
|
||||
const [oldPassword, setOldPassword] = createSignal('');
|
||||
const [newPassword, setNewPassword] = createSignal('');
|
||||
const [confirmPassword, setConfirmPassword] = createSignal('');
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
const [success, setSuccess] = createSignal(false);
|
||||
const [loading, setLoading] = createSignal(false);
|
||||
|
||||
const handleSubmit = async (e: Event) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setSuccess(false);
|
||||
|
||||
// Validation
|
||||
if (!oldPassword() || !newPassword() || !confirmPassword()) {
|
||||
setError('Please fill in all fields');
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword() !== confirmPassword()) {
|
||||
setError('New passwords do not match');
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword().length < 6) {
|
||||
setError('New password must be at least 6 characters');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await authApi.changePassword(oldPassword(), newPassword());
|
||||
setSuccess(true);
|
||||
setOldPassword('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
|
||||
// Close modal and logout after 2 seconds
|
||||
setTimeout(() => {
|
||||
props.onClose();
|
||||
authStore.clearToken();
|
||||
props.onLogout();
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to change password');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 flex items-center justify-center z-50">
|
||||
<div class="bg-white rounded-lg shadow-xl max-w-md w-full mx-4">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<h3 class="text-lg font-medium text-gray-900">Change Password</h3>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} class="px-6 py-4 space-y-4">
|
||||
{error() && (
|
||||
<div class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
|
||||
{error()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success() && (
|
||||
<div class="bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded">
|
||||
Password changed successfully! You will be logged out...
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Old Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={oldPassword()}
|
||||
onInput={(e) => setOldPassword(e.currentTarget.value)}
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
disabled={loading()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||
New Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword()}
|
||||
onInput={(e) => setNewPassword(e.currentTarget.value)}
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
disabled={loading()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Confirm New Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword()}
|
||||
onInput={(e) => setConfirmPassword(e.currentTarget.value)}
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
disabled={loading()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.onClose}
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
disabled={loading()}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={loading()}
|
||||
>
|
||||
{loading() ? 'Changing...' : 'Change Password'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChangePassword;
|
||||
83
frontend/src/components/CommandQueue.tsx
Normal file
83
frontend/src/components/CommandQueue.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { Component, createResource, For, Show } from 'solid-js';
|
||||
import { commandsApi } from '../api/client';
|
||||
|
||||
const CommandQueue: Component = () => {
|
||||
const [commands, { refetch }] = createResource(commandsApi.list);
|
||||
|
||||
const updateCommandStatus = async (id: number, status: string) => {
|
||||
try {
|
||||
await commandsApi.updateStatus(id, { status });
|
||||
refetch();
|
||||
} catch (err) {
|
||||
console.error('Failed to update command:', err);
|
||||
alert(`Error: ${err}`);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleString();
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="space-y-4">
|
||||
<h2 class="text-2xl font-semibold">Command Queue</h2>
|
||||
|
||||
<div class="bg-white shadow rounded-lg overflow-hidden">
|
||||
<Show when={!commands.loading} fallback={<div class="p-4">Loading...</div>}>
|
||||
<For each={commands()} fallback={
|
||||
<div class="p-8 text-center text-gray-500">
|
||||
No commands in queue
|
||||
</div>
|
||||
}>
|
||||
{(cmd) => (
|
||||
<div class="border-b p-4 hover:bg-gray-50">
|
||||
<div class="flex justify-between items-start">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class={`px-2 py-1 rounded-full text-xs font-semibold ${
|
||||
cmd.status === 'verified' ? 'bg-green-100 text-green-800' :
|
||||
cmd.status === 'rejected' ? 'bg-red-100 text-red-800' :
|
||||
'bg-yellow-100 text-yellow-800'
|
||||
}`}>
|
||||
{cmd.status}
|
||||
</span>
|
||||
<span class="text-sm text-gray-500">
|
||||
{formatDate(cmd.received_at)}
|
||||
</span>
|
||||
</div>
|
||||
<pre class="text-sm bg-gray-100 p-3 rounded overflow-x-auto">
|
||||
{JSON.stringify(cmd.command, null, 2)}
|
||||
</pre>
|
||||
<Show when={cmd.notes}>
|
||||
<div class="mt-2 text-sm text-gray-600">
|
||||
<strong>Notes:</strong> {cmd.notes}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={cmd.status === 'unverified'}>
|
||||
<div class="ml-4 flex flex-col gap-2">
|
||||
<button
|
||||
onClick={() => updateCommandStatus(cmd.id, 'verified')}
|
||||
class="px-3 py-1 bg-green-600 text-white rounded text-sm hover:bg-green-700"
|
||||
>
|
||||
Verify
|
||||
</button>
|
||||
<button
|
||||
onClick={() => updateCommandStatus(cmd.id, 'rejected')}
|
||||
class="px-3 py-1 bg-red-600 text-white rounded text-sm hover:bg-red-700"
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CommandQueue;
|
||||
87
frontend/src/components/Login.tsx
Normal file
87
frontend/src/components/Login.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { Component, createSignal } from 'solid-js';
|
||||
|
||||
interface LoginProps {
|
||||
onLoginSuccess: (token: string) => void;
|
||||
}
|
||||
|
||||
const Login: Component<LoginProps> = (props) => {
|
||||
const [password, setPassword] = createSignal('');
|
||||
const [error, setError] = createSignal('');
|
||||
const [isLoading, setIsLoading] = createSignal(false);
|
||||
|
||||
const handleSubmit = async (e: Event) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch('/admin/api/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ password: password() }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ error: 'Login failed' }));
|
||||
throw new Error(errorData.error || 'Invalid password');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
props.onLoginSuccess(data.token);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Login failed');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="min-h-screen bg-gray-100 flex items-center justify-center">
|
||||
<div class="bg-white p-8 rounded-lg shadow-md w-96">
|
||||
<h1 class="text-2xl font-bold text-gray-900 mb-6 text-center">
|
||||
My Linspirer Admin Login
|
||||
</h1>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div class="mb-4">
|
||||
<label class="block text-gray-700 text-sm font-medium mb-2" for="password">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password()}
|
||||
onInput={(e) => setPassword(e.currentTarget.value)}
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
placeholder="Enter admin password"
|
||||
disabled={isLoading()}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error() && (
|
||||
<div class="mb-4 p-3 bg-red-50 border border-red-200 rounded-md">
|
||||
<p class="text-sm text-red-600">{error()}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading()}
|
||||
class="w-full bg-indigo-600 text-white py-2 px-4 rounded-md hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 disabled:bg-gray-400"
|
||||
>
|
||||
{isLoading() ? 'Logging in...' : 'Login'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-4 text-center text-sm text-gray-500">
|
||||
<p>Default password: admin123</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
266
frontend/src/components/RulesList.tsx
Normal file
266
frontend/src/components/RulesList.tsx
Normal file
@@ -0,0 +1,266 @@
|
||||
import { Component, createSignal, createResource, For, Show } from 'solid-js';
|
||||
import { rulesApi } from '../api/client';
|
||||
import type { InterceptionRule } from '../types';
|
||||
|
||||
const RulesList: Component = () => {
|
||||
const [rules, { refetch }] = createResource(rulesApi.list);
|
||||
const [showEditor, setShowEditor] = createSignal(false);
|
||||
const [editingId, setEditingId] = createSignal<number | null>(null);
|
||||
const [editingMethod, setEditingMethod] = createSignal('');
|
||||
const [editingAction, setEditingAction] = createSignal<'passthrough' | 'modify' | 'replace'>('passthrough');
|
||||
const [editingResponse, setEditingResponse] = createSignal('');
|
||||
|
||||
const toggleRule = async (rule: InterceptionRule) => {
|
||||
try {
|
||||
await rulesApi.update(rule.id, {
|
||||
is_enabled: !rule.is_enabled,
|
||||
});
|
||||
refetch();
|
||||
} catch (err) {
|
||||
console.error('Failed to toggle rule:', err);
|
||||
alert(`Error: ${err}`);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteRule = async (id: number) => {
|
||||
if (!confirm('Are you sure you want to delete this rule?')) return;
|
||||
|
||||
try {
|
||||
await rulesApi.delete(id);
|
||||
refetch();
|
||||
} catch (err) {
|
||||
console.error('Failed to delete rule:', err);
|
||||
alert(`Error: ${err}`);
|
||||
}
|
||||
};
|
||||
|
||||
const createNewRule = async () => {
|
||||
if (!editingMethod()) {
|
||||
alert('Please enter a method name');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await rulesApi.create({
|
||||
method_name: editingMethod(),
|
||||
action: editingAction(),
|
||||
custom_response: editingAction() === 'replace' ? editingResponse() : undefined,
|
||||
});
|
||||
setShowEditor(false);
|
||||
setEditingMethod('');
|
||||
setEditingResponse('');
|
||||
refetch();
|
||||
} catch (err) {
|
||||
console.error('Failed to create rule:', err);
|
||||
alert(`Error: ${err}`);
|
||||
}
|
||||
};
|
||||
|
||||
const startEdit = (rule: InterceptionRule) => {
|
||||
setEditingId(rule.id);
|
||||
setEditingMethod(rule.method_name);
|
||||
setEditingAction(rule.action);
|
||||
setEditingResponse(rule.custom_response || '');
|
||||
setShowEditor(true);
|
||||
};
|
||||
|
||||
const cancelEdit = () => {
|
||||
setEditingId(null);
|
||||
setEditingMethod('');
|
||||
setEditingAction('passthrough');
|
||||
setEditingResponse('');
|
||||
setShowEditor(false);
|
||||
};
|
||||
|
||||
const saveEdit = async () => {
|
||||
const id = editingId();
|
||||
if (id === null) {
|
||||
await createNewRule();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!editingMethod()) {
|
||||
alert('Please enter a method name');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await rulesApi.update(id, {
|
||||
method_name: editingMethod(),
|
||||
action: editingAction(),
|
||||
custom_response: editingAction() === 'replace' ? editingResponse() : undefined,
|
||||
});
|
||||
cancelEdit();
|
||||
refetch();
|
||||
} catch (err) {
|
||||
console.error('Failed to update rule:', err);
|
||||
alert(`Error: ${err}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="space-y-4">
|
||||
<div class="flex justify-between items-center">
|
||||
<h2 class="text-2xl font-semibold">Interception Rules</h2>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (showEditor()) {
|
||||
cancelEdit();
|
||||
} else {
|
||||
setShowEditor(true);
|
||||
}
|
||||
}}
|
||||
class="bg-indigo-600 text-white px-4 py-2 rounded-md hover:bg-indigo-700"
|
||||
>
|
||||
{showEditor() ? 'Cancel' : '+ New Rule'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Show when={showEditor()}>
|
||||
<div class="bg-white shadow rounded-lg p-6 mb-4">
|
||||
<h3 class="text-lg font-semibold mb-4">
|
||||
{editingId() !== null ? 'Edit Rule' : 'Create New Rule'}
|
||||
</h3>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Method Name
|
||||
</label>
|
||||
<Show
|
||||
when={editingId() === null}
|
||||
fallback={
|
||||
<input
|
||||
type="text"
|
||||
value={editingMethod()}
|
||||
onInput={(e) => setEditingMethod(e.currentTarget.value)}
|
||||
class="w-full border border-gray-300 rounded-md px-3 py-2"
|
||||
placeholder="com.linspirer.method.name"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<select
|
||||
value={editingMethod()}
|
||||
onChange={(e) => setEditingMethod(e.currentTarget.value)}
|
||||
class="w-full border border-gray-300 rounded-md px-3 py-2"
|
||||
>
|
||||
<option value="">-- Select Method --</option>
|
||||
<option value="com.linspirer.tactics.gettactics">Get Tactics</option>
|
||||
<option value="com.linspirer.device.getcommand">Get Command</option>
|
||||
</select>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Action
|
||||
</label>
|
||||
<select
|
||||
value={editingAction()}
|
||||
onChange={(e) => setEditingAction(e.currentTarget.value as any)}
|
||||
class="w-full border border-gray-300 rounded-md px-3 py-2"
|
||||
>
|
||||
<option value="passthrough">Passthrough (Forward to server)</option>
|
||||
<option value="replace">Replace (Custom response)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<Show when={editingAction() === 'replace'}>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Custom Response (JSON)
|
||||
</label>
|
||||
<textarea
|
||||
value={editingResponse()}
|
||||
onInput={(e) => setEditingResponse(e.currentTarget.value)}
|
||||
rows={10}
|
||||
class="w-full border border-gray-300 rounded-md px-3 py-2 font-mono text-sm"
|
||||
placeholder='{"code": 0, "type": "object", "data": {...}}'
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<button
|
||||
onClick={saveEdit}
|
||||
class="px-4 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700"
|
||||
>
|
||||
{editingId() !== null ? 'Update Rule' : 'Create Rule'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="bg-white shadow rounded-lg overflow-hidden">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Method Name
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Action
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Status
|
||||
</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<Show when={!rules.loading} fallback={
|
||||
<tr><td colspan="4" class="text-center py-4">Loading...</td></tr>
|
||||
}>
|
||||
<For each={rules()}>
|
||||
{(rule) => (
|
||||
<tr>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
{rule.method_name}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<span class={`px-2 py-1 rounded-full text-xs ${
|
||||
rule.action === 'replace' ? 'bg-purple-100 text-purple-800' :
|
||||
rule.action === 'modify' ? 'bg-blue-100 text-blue-800' :
|
||||
'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{rule.action}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<button
|
||||
onClick={() => toggleRule(rule)}
|
||||
class={`px-3 py-1 rounded-md text-sm font-medium ${
|
||||
rule.is_enabled
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-gray-100 text-gray-800'
|
||||
}`}
|
||||
>
|
||||
{rule.is_enabled ? '✓ Enabled' : '✗ Disabled'}
|
||||
</button>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<button
|
||||
onClick={() => startEdit(rule)}
|
||||
class="text-indigo-600 hover:text-indigo-900 mr-4"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deleteRule(rule.id)}
|
||||
class="text-red-600 hover:text-red-900"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RulesList;
|
||||
10
frontend/src/index.tsx
Normal file
10
frontend/src/index.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
/* @refresh reload */
|
||||
import { render } from 'solid-js/web';
|
||||
import App from './App';
|
||||
import './styles/index.css';
|
||||
|
||||
const root = document.getElementById('root');
|
||||
|
||||
if (!root) throw new Error('Root element not found');
|
||||
|
||||
render(() => <App />, root);
|
||||
21
frontend/src/styles/index.css
Normal file
21
frontend/src/styles/index.css
Normal file
@@ -0,0 +1,21 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
@apply bg-gray-100;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
@apply bg-gray-400 rounded;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
@apply bg-gray-500;
|
||||
}
|
||||
36
frontend/src/types/index.ts
Normal file
36
frontend/src/types/index.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
export interface InterceptionRule {
|
||||
id: number;
|
||||
method_name: string;
|
||||
action: 'passthrough' | 'modify' | 'replace';
|
||||
custom_response: any | null;
|
||||
is_enabled: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface Command {
|
||||
id: number;
|
||||
command: any;
|
||||
status: 'unverified' | 'verified' | 'rejected';
|
||||
received_at: string;
|
||||
processed_at?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface CreateRuleRequest {
|
||||
method_name: string;
|
||||
action: string;
|
||||
custom_response?: string;
|
||||
}
|
||||
|
||||
export interface UpdateRuleRequest {
|
||||
method_name?: string;
|
||||
action?: string;
|
||||
custom_response?: string;
|
||||
is_enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateCommandRequest {
|
||||
status: string;
|
||||
notes?: string;
|
||||
}
|
||||
11
frontend/tailwind.config.js
Normal file
11
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,11 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
21
frontend/tsconfig.json
Normal file
21
frontend/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"jsx": "preserve",
|
||||
"jsxImportSource": "solid-js",
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
21
frontend/vite.config.ts
Normal file
21
frontend/vite.config.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import solid from 'vite-plugin-solid';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [solid()],
|
||||
base: '/admin/',
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
assetsDir: 'assets',
|
||||
sourcemap: false,
|
||||
minify: 'terser',
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/admin/api': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
62
migrations/20251130125600_init.sql
Normal file
62
migrations/20251130125600_init.sql
Normal file
@@ -0,0 +1,62 @@
|
||||
-- Initial schema for My Linspirer MITM server
|
||||
|
||||
-- Global configuration
|
||||
CREATE TABLE IF NOT EXISTS config (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
description TEXT,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Interception rules for JSON-RPC methods
|
||||
CREATE TABLE IF NOT EXISTS interception_rules (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
method_name TEXT NOT NULL UNIQUE,
|
||||
action TEXT NOT NULL CHECK(action IN ('passthrough', 'modify', 'replace')),
|
||||
custom_response TEXT,
|
||||
is_enabled BOOLEAN DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Template responses (for building custom_response)
|
||||
CREATE TABLE IF NOT EXISTS response_templates (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
method_name TEXT NOT NULL,
|
||||
response_json TEXT NOT NULL,
|
||||
description TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Command queue persistence
|
||||
CREATE TABLE IF NOT EXISTS commands (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
command_json TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK(status IN ('unverified', 'verified', 'rejected')),
|
||||
received_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
processed_at DATETIME,
|
||||
notes TEXT
|
||||
);
|
||||
|
||||
-- Request/Response logs (optional, can be large)
|
||||
CREATE TABLE IF NOT EXISTS request_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
method TEXT,
|
||||
path TEXT,
|
||||
request_body TEXT,
|
||||
response_body TEXT,
|
||||
status_code INTEGER,
|
||||
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Indexes for performance
|
||||
CREATE INDEX IF NOT EXISTS idx_commands_status ON commands(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_logs_timestamp ON request_logs(timestamp);
|
||||
CREATE INDEX IF NOT EXISTS idx_logs_method ON request_logs(method);
|
||||
|
||||
-- Insert default config values
|
||||
INSERT OR IGNORE INTO config (key, value, description) VALUES
|
||||
('target_url', 'https://cloud.linspirer.com:883', 'Target server URL for proxying'),
|
||||
('logging_enabled', 'true', 'Enable request/response logging'),
|
||||
('log_retention_days', '7', 'Number of days to keep request logs');
|
||||
3
migrations/20251130140000_add_admin_auth.sql
Normal file
3
migrations/20251130140000_add_admin_auth.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
-- Add admin authentication
|
||||
INSERT OR IGNORE INTO config (key, value, description) VALUES
|
||||
('admin_password_hash', '$2a$12$foWbCgnovjZxmfE1fbut9uJlMCWrx.KrGUbmKlDH727B4JB7YUZEe', 'Admin password hash (default: admin123)');
|
||||
47
src/admin/auth_middleware.rs
Normal file
47
src/admin/auth_middleware.rs
Normal file
@@ -0,0 +1,47 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
extract::{Request, State},
|
||||
http::StatusCode,
|
||||
middleware::Next,
|
||||
response::Response,
|
||||
};
|
||||
|
||||
use crate::{auth, state::AppState};
|
||||
|
||||
pub async fn auth_middleware(
|
||||
State(state): State<Arc<AppState>>,
|
||||
request: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, StatusCode> {
|
||||
// Skip authentication for login endpoint
|
||||
if request.uri().path().ends_with("/login") {
|
||||
return Ok(next.run(request).await);
|
||||
}
|
||||
|
||||
// Skip authentication for static files (non-API routes)
|
||||
if !request.uri().path().starts_with("/api/") {
|
||||
return Ok(next.run(request).await);
|
||||
}
|
||||
|
||||
// Get Authorization header
|
||||
let auth_header = request
|
||||
.headers()
|
||||
.get("Authorization")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.ok_or(StatusCode::UNAUTHORIZED)?;
|
||||
|
||||
// Check if it's a Bearer token
|
||||
if !auth_header.starts_with("Bearer ") {
|
||||
return Err(StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
let token = &auth_header[7..]; // Skip "Bearer "
|
||||
|
||||
// Validate token
|
||||
auth::validate_token(state.jwt_secret.as_bytes(), token)
|
||||
.map_err(|_| StatusCode::UNAUTHORIZED)?;
|
||||
|
||||
// Token is valid, proceed
|
||||
Ok(next.run(request).await)
|
||||
}
|
||||
366
src/admin/handlers.rs
Normal file
366
src/admin/handlers.rs
Normal file
@@ -0,0 +1,366 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
};
|
||||
use tracing::error;
|
||||
|
||||
use crate::{AppState, auth, crypto, db};
|
||||
|
||||
use super::models::*;
|
||||
|
||||
// Authentication handlers
|
||||
pub async fn login(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(req): Json<LoginRequest>,
|
||||
) -> Result<Json<LoginResponse>, (StatusCode, Json<ApiError>)> {
|
||||
// Get stored password hash from config
|
||||
let password_hash = match db::repositories::config::get(&state.db, "admin_password_hash").await
|
||||
{
|
||||
Ok(Some(hash)) => hash,
|
||||
Ok(None) => {
|
||||
error!("Admin password hash not found in config");
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ApiError::new("Authentication not configured")),
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to get password hash: {}", e);
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ApiError::new("Authentication error")),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// Verify password
|
||||
match auth::verify_password(&req.password, &password_hash) {
|
||||
Ok(true) => {
|
||||
// Generate JWT token
|
||||
match auth::generate_token(state.jwt_secret.as_bytes(), "admin") {
|
||||
Ok(token) => Ok(Json(LoginResponse { token })),
|
||||
Err(e) => {
|
||||
error!("Failed to generate token: {}", e);
|
||||
Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ApiError::new("Failed to generate token")),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(false) => Err((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(ApiError::new("Invalid password")),
|
||||
)),
|
||||
Err(e) => {
|
||||
error!("Password verification error: {}", e);
|
||||
Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ApiError::new("Authentication error")),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn change_password(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(req): Json<super::models::ChangePasswordRequest>,
|
||||
) -> Result<StatusCode, (StatusCode, Json<ApiError>)> {
|
||||
// Get current password hash from config
|
||||
let current_hash = match db::repositories::config::get(&state.db, "admin_password_hash").await {
|
||||
Ok(Some(hash)) => hash,
|
||||
Ok(None) => {
|
||||
error!("Admin password hash not found in config");
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ApiError::new("Authentication not configured")),
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to get password hash: {}", e);
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ApiError::new("Failed to get current password")),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// Verify old password
|
||||
match auth::verify_password(&req.old_password, ¤t_hash) {
|
||||
Ok(true) => {
|
||||
// Hash new password
|
||||
let new_hash = match auth::hash_password(&req.new_password) {
|
||||
Ok(hash) => hash,
|
||||
Err(e) => {
|
||||
error!("Failed to hash new password: {}", e);
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ApiError::new("Failed to hash new password")),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// Update password hash in config
|
||||
if let Err(e) = db::repositories::config::set(
|
||||
&state.db,
|
||||
"admin_password_hash",
|
||||
&new_hash,
|
||||
Some("Hashed admin password"),
|
||||
)
|
||||
.await
|
||||
{
|
||||
error!("Failed to update password hash: {}", e);
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ApiError::new("Failed to update password")),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
Ok(false) => Err((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(ApiError::new("Invalid old password")),
|
||||
)),
|
||||
Err(e) => {
|
||||
error!("Password verification error: {}", e);
|
||||
Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ApiError::new("Password verification error")),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rules handlers
|
||||
pub async fn list_rules(
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> Result<Json<Vec<RuleResponse>>, (StatusCode, Json<ApiError>)> {
|
||||
match db::repositories::rules::list_all(&state.db).await {
|
||||
Ok(rules) => Ok(Json(rules.into_iter().map(Into::into).collect())),
|
||||
Err(e) => {
|
||||
error!("Failed to list rules: {}", e);
|
||||
Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ApiError::new("Failed to fetch rules")),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_rule(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(req): Json<CreateRuleRequest>,
|
||||
) -> Result<Json<RuleResponse>, (StatusCode, Json<ApiError>)> {
|
||||
// Validate action
|
||||
if !matches!(req.action.as_str(), "passthrough" | "modify" | "replace") {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(ApiError::new(
|
||||
"Invalid action. Must be 'passthrough', 'modify', or 'replace'",
|
||||
)),
|
||||
));
|
||||
}
|
||||
|
||||
// If action is replace, validate and encrypt custom_response
|
||||
let custom_response = if req.action == "replace" {
|
||||
match req.custom_response {
|
||||
Some(resp) => Some(resp),
|
||||
None => {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(ApiError::new(
|
||||
"custom_response is required when action is 'replace'",
|
||||
)),
|
||||
));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
match db::repositories::rules::create(
|
||||
&state.db,
|
||||
&req.method_name,
|
||||
&req.action,
|
||||
custom_response.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(id) => match db::repositories::rules::find_by_id(&state.db, id).await {
|
||||
Ok(Some(rule)) => Ok(Json(rule.into())),
|
||||
_ => Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ApiError::new("Failed to fetch created rule")),
|
||||
)),
|
||||
},
|
||||
Err(e) => {
|
||||
error!("Failed to create rule: {}", e);
|
||||
Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ApiError::new(format!("Failed to create rule: {}", e))),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update_rule(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<i64>,
|
||||
Json(req): Json<UpdateRuleRequest>,
|
||||
) -> Result<Json<RuleResponse>, (StatusCode, Json<ApiError>)> {
|
||||
// Validate action if provided
|
||||
if let Some(ref action) = req.action
|
||||
&& !matches!(action.as_str(), "passthrough" | "modify" | "replace")
|
||||
{
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(ApiError::new("Invalid action")),
|
||||
));
|
||||
}
|
||||
|
||||
// Encrypt custom_response if provided
|
||||
let custom_response = if let Some(resp) = req.custom_response {
|
||||
match crypto::encrypt(&resp, &state.key, &state.iv) {
|
||||
Ok(encrypted) => Some(encrypted),
|
||||
Err(e) => {
|
||||
error!("Failed to encrypt custom response: {}", e);
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(ApiError::new("Failed to encrypt custom response")),
|
||||
));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
match db::repositories::rules::update(
|
||||
&state.db,
|
||||
id,
|
||||
req.method_name.as_deref(),
|
||||
req.action.as_deref(),
|
||||
custom_response.as_deref(),
|
||||
req.is_enabled,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => match db::repositories::rules::find_by_id(&state.db, id).await {
|
||||
Ok(Some(rule)) => Ok(Json(rule.into())),
|
||||
_ => Err((StatusCode::NOT_FOUND, Json(ApiError::new("Rule not found")))),
|
||||
},
|
||||
Err(e) => {
|
||||
error!("Failed to update rule: {}", e);
|
||||
Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ApiError::new("Failed to update rule")),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn delete_rule(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<i64>,
|
||||
) -> Result<StatusCode, (StatusCode, Json<ApiError>)> {
|
||||
match db::repositories::rules::delete(&state.db, id).await {
|
||||
Ok(_) => Ok(StatusCode::NO_CONTENT),
|
||||
Err(e) => {
|
||||
error!("Failed to delete rule: {}", e);
|
||||
Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ApiError::new("Failed to delete rule")),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Commands handlers
|
||||
pub async fn list_commands(
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> Result<Json<Vec<CommandResponse>>, (StatusCode, Json<ApiError>)> {
|
||||
match db::repositories::commands::list_all(&state.db).await {
|
||||
Ok(commands) => Ok(Json(commands.into_iter().map(Into::into).collect())),
|
||||
Err(e) => {
|
||||
error!("Failed to list commands: {}", e);
|
||||
Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ApiError::new("Failed to fetch commands")),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn verify_command(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<i64>,
|
||||
Json(req): Json<UpdateCommandRequest>,
|
||||
) -> Result<Json<CommandResponse>, (StatusCode, Json<ApiError>)> {
|
||||
match db::repositories::commands::update_status(
|
||||
&state.db,
|
||||
id,
|
||||
&req.status,
|
||||
req.notes.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => match db::repositories::commands::find_by_id(&state.db, id).await {
|
||||
Ok(Some(cmd)) => {
|
||||
// Also update in-memory queue if status is verified
|
||||
if req.status == "verified"
|
||||
&& let Ok(cmd_value) = serde_json::from_str(&cmd.command_json)
|
||||
{
|
||||
state.commands.verified.write().await.push(cmd_value);
|
||||
}
|
||||
Ok(Json(cmd.into()))
|
||||
}
|
||||
_ => Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(ApiError::new("Command not found")),
|
||||
)),
|
||||
},
|
||||
Err(e) => {
|
||||
error!("Failed to update command: {}", e);
|
||||
Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ApiError::new("Failed to update command")),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Config handlers
|
||||
pub async fn get_config(
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> Result<Json<std::collections::HashMap<String, String>>, (StatusCode, Json<ApiError>)> {
|
||||
match db::repositories::config::get_all(&state.db).await {
|
||||
Ok(config) => Ok(Json(config)),
|
||||
Err(e) => {
|
||||
error!("Failed to get config: {}", e);
|
||||
Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ApiError::new("Failed to fetch configuration")),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update_config(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(config): Json<std::collections::HashMap<String, String>>,
|
||||
) -> Result<StatusCode, (StatusCode, Json<ApiError>)> {
|
||||
for (key, value) in config {
|
||||
if let Err(e) = db::repositories::config::set(&state.db, &key, &value, None).await {
|
||||
error!("Failed to update config {}: {}", key, e);
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ApiError::new(format!("Failed to update config: {}", e))),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
5
src/admin/mod.rs
Normal file
5
src/admin/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
pub mod auth_middleware;
|
||||
pub mod handlers;
|
||||
pub mod models;
|
||||
pub mod routes;
|
||||
pub mod static_files;
|
||||
101
src/admin/models.rs
Normal file
101
src/admin/models.rs
Normal file
@@ -0,0 +1,101 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
// Authentication models
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct LoginRequest {
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct LoginResponse {
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ChangePasswordRequest {
|
||||
pub old_password: String,
|
||||
pub new_password: String,
|
||||
}
|
||||
|
||||
// Request models
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateRuleRequest {
|
||||
pub method_name: String,
|
||||
pub action: String,
|
||||
pub custom_response: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateRuleRequest {
|
||||
pub method_name: Option<String>,
|
||||
pub action: Option<String>,
|
||||
pub custom_response: Option<String>,
|
||||
pub is_enabled: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateCommandRequest {
|
||||
pub status: String,
|
||||
pub notes: Option<String>,
|
||||
}
|
||||
|
||||
// Response models
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct RuleResponse {
|
||||
pub id: i64,
|
||||
pub method_name: String,
|
||||
pub action: String,
|
||||
pub custom_response: Option<String>,
|
||||
pub is_enabled: bool,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
impl From<crate::db::models::InterceptionRule> for RuleResponse {
|
||||
fn from(rule: crate::db::models::InterceptionRule) -> Self {
|
||||
Self {
|
||||
id: rule.id,
|
||||
method_name: rule.method_name,
|
||||
action: rule.action,
|
||||
custom_response: rule.custom_response,
|
||||
is_enabled: rule.is_enabled,
|
||||
created_at: rule.created_at,
|
||||
updated_at: rule.updated_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CommandResponse {
|
||||
pub id: i64,
|
||||
pub command: Value,
|
||||
pub status: String,
|
||||
pub received_at: String,
|
||||
pub processed_at: Option<String>,
|
||||
pub notes: Option<String>,
|
||||
}
|
||||
|
||||
impl From<crate::db::models::Command> for CommandResponse {
|
||||
fn from(cmd: crate::db::models::Command) -> Self {
|
||||
Self {
|
||||
id: cmd.id,
|
||||
command: serde_json::from_str(&cmd.command_json).unwrap_or(Value::Null),
|
||||
status: cmd.status,
|
||||
received_at: cmd.received_at,
|
||||
processed_at: cmd.processed_at,
|
||||
notes: cmd.notes,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ApiError {
|
||||
pub error: String,
|
||||
}
|
||||
|
||||
impl ApiError {
|
||||
pub fn new(msg: impl Into<String>) -> Self {
|
||||
Self { error: msg.into() }
|
||||
}
|
||||
}
|
||||
38
src/admin/routes.rs
Normal file
38
src/admin/routes.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
routing::{delete, get, post, put},
|
||||
};
|
||||
|
||||
use crate::AppState;
|
||||
|
||||
use super::{auth_middleware, handlers, static_files};
|
||||
|
||||
pub fn admin_routes(state: Arc<AppState>) -> Router<Arc<AppState>> {
|
||||
// Public routes (no authentication required)
|
||||
let public_routes = Router::new().route("/api/login", post(handlers::login));
|
||||
|
||||
// Protected API routes (require authentication)
|
||||
let protected_routes = Router::new()
|
||||
.route("/api/password", put(handlers::change_password))
|
||||
.route("/api/rules", get(handlers::list_rules))
|
||||
.route("/api/rules", post(handlers::create_rule))
|
||||
.route("/api/rules/{:id}", put(handlers::update_rule))
|
||||
.route("/api/rules/{:id}", delete(handlers::delete_rule))
|
||||
.route("/api/commands", get(handlers::list_commands))
|
||||
.route("/api/commands/{:id}", post(handlers::verify_command))
|
||||
.route("/api/config", get(handlers::get_config))
|
||||
.route("/api/config", put(handlers::update_config))
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
state,
|
||||
auth_middleware::auth_middleware,
|
||||
));
|
||||
|
||||
// Combine routes
|
||||
Router::new()
|
||||
.merge(public_routes)
|
||||
.merge(protected_routes)
|
||||
// Static files (frontend)
|
||||
.fallback(static_files::serve_static)
|
||||
}
|
||||
49
src/admin/static_files.rs
Normal file
49
src/admin/static_files.rs
Normal file
@@ -0,0 +1,49 @@
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{StatusCode, Uri, header},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use rust_embed::RustEmbed;
|
||||
use tracing::info;
|
||||
|
||||
#[derive(RustEmbed)]
|
||||
#[folder = "frontend/dist"]
|
||||
pub struct Assets;
|
||||
|
||||
pub async fn serve_static(uri: Uri) -> impl IntoResponse {
|
||||
let mut path = uri.path().trim_start_matches("/admin/").to_string();
|
||||
|
||||
// Default to index.html for root or directories
|
||||
if path.is_empty() || path.ends_with('/') {
|
||||
path = "index.html".to_string();
|
||||
}
|
||||
|
||||
info!("{path}");
|
||||
|
||||
match Assets::get(path.trim_start_matches('/')) {
|
||||
Some(content) => {
|
||||
let mime = mime_guess::from_path(&path).first_or_octet_stream();
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, mime.as_ref())
|
||||
.body(Body::from(content.data))
|
||||
.unwrap()
|
||||
}
|
||||
None => {
|
||||
// For SPA routing, serve index.html for non-asset paths
|
||||
if !path.contains('.')
|
||||
&& let Some(index) = Assets::get("index.html")
|
||||
{
|
||||
return Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, "text/html")
|
||||
.body(Body::from(index.data))
|
||||
.unwrap();
|
||||
}
|
||||
Response::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.body(Body::from("404 Not Found"))
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
}
|
||||
56
src/auth.rs
Normal file
56
src/auth.rs
Normal file
@@ -0,0 +1,56 @@
|
||||
use anyhow::{Result, anyhow};
|
||||
use bcrypt::{DEFAULT_COST, hash, verify};
|
||||
use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation, decode, encode};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
const TOKEN_EXPIRATION_HOURS: u64 = 24;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Claims {
|
||||
pub sub: String,
|
||||
pub exp: usize,
|
||||
}
|
||||
|
||||
/// Hash a password using bcrypt
|
||||
pub fn hash_password(password: &str) -> Result<String> {
|
||||
hash(password, DEFAULT_COST).map_err(|e| anyhow!("Failed to hash password: {}", e))
|
||||
}
|
||||
|
||||
/// Verify a password against a hash
|
||||
pub fn verify_password(password: &str, hash: &str) -> Result<bool> {
|
||||
verify(password, hash).map_err(|e| anyhow!("Failed to verify password: {}", e))
|
||||
}
|
||||
|
||||
/// Generate a JWT token
|
||||
pub fn generate_token(jwt_secret: &[u8], username: &str) -> Result<String> {
|
||||
let expiration = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_err(|e| anyhow!("Time error: {}", e))?
|
||||
.as_secs()
|
||||
+ (TOKEN_EXPIRATION_HOURS * 3600);
|
||||
|
||||
let claims = Claims {
|
||||
sub: username.to_owned(),
|
||||
exp: expiration as usize,
|
||||
};
|
||||
|
||||
encode(
|
||||
&Header::default(),
|
||||
&claims,
|
||||
&EncodingKey::from_secret(jwt_secret),
|
||||
)
|
||||
.map_err(|e| anyhow!("Failed to generate token: {}", e))
|
||||
}
|
||||
|
||||
/// Validate a JWT token and return the claims
|
||||
pub fn validate_token(jwt_secret: &[u8], token: &str) -> Result<Claims> {
|
||||
let token_data = decode::<Claims>(
|
||||
token,
|
||||
&DecodingKey::from_secret(jwt_secret),
|
||||
&Validation::default(),
|
||||
)
|
||||
.map_err(|e| anyhow!("Invalid token: {}", e))?;
|
||||
|
||||
Ok(token_data.claims)
|
||||
}
|
||||
@@ -63,9 +63,13 @@ pub fn encrypt(plaintext: &str, key: &str, iv: &str) -> Result<String, CryptoErr
|
||||
let encryptor = Aes128CbcEnc::new_from_slices(key_bytes, iv_bytes).map_err(CryptoError::Aes)?;
|
||||
|
||||
// 2. Encrypt the plaintext with PKCS7 padding
|
||||
let mut buffer = plaintext.as_bytes().to_vec();
|
||||
// Allocate buffer with extra space for padding (AES block size is 16 bytes)
|
||||
let plaintext_bytes = plaintext.as_bytes();
|
||||
let mut buffer = vec![0u8; plaintext_bytes.len() + 16];
|
||||
buffer[..plaintext_bytes.len()].copy_from_slice(plaintext_bytes);
|
||||
|
||||
let ciphertext = encryptor
|
||||
.encrypt_padded_mut::<Pkcs7>(&mut buffer, plaintext.len())
|
||||
.encrypt_padded_mut::<Pkcs7>(&mut buffer, plaintext_bytes.len())
|
||||
.map_err(CryptoError::Pad)?;
|
||||
|
||||
// 3. Base64 encode the ciphertext
|
||||
|
||||
26
src/db/mod.rs
Normal file
26
src/db/mod.rs
Normal file
@@ -0,0 +1,26 @@
|
||||
pub mod models;
|
||||
pub mod repositories;
|
||||
|
||||
use sqlx::sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions};
|
||||
use std::str::FromStr;
|
||||
use tracing::info;
|
||||
|
||||
pub async fn init_db(database_url: &str) -> anyhow::Result<SqlitePool> {
|
||||
info!("Initializing database at: {}", database_url);
|
||||
|
||||
// Parse connection options
|
||||
let options = SqliteConnectOptions::from_str(database_url)?.create_if_missing(true);
|
||||
|
||||
// Create connection pool
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(5)
|
||||
.connect_with(options)
|
||||
.await?;
|
||||
|
||||
// Run migrations
|
||||
sqlx::migrate!("./migrations").run(&pool).await?;
|
||||
|
||||
info!("Database initialized successfully");
|
||||
|
||||
Ok(pool)
|
||||
}
|
||||
51
src/db/models.rs
Normal file
51
src/db/models.rs
Normal file
@@ -0,0 +1,51 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct InterceptionRule {
|
||||
pub id: i64,
|
||||
pub method_name: String,
|
||||
pub action: String,
|
||||
pub custom_response: Option<String>,
|
||||
pub is_enabled: bool,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct Command {
|
||||
pub id: i64,
|
||||
pub command_json: String,
|
||||
pub status: String,
|
||||
pub received_at: String,
|
||||
pub processed_at: Option<String>,
|
||||
pub notes: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct Config {
|
||||
pub key: String,
|
||||
pub value: String,
|
||||
pub description: Option<String>,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct ResponseTemplate {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub method_name: String,
|
||||
pub response_json: String,
|
||||
pub description: Option<String>,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct RequestLog {
|
||||
pub id: i64,
|
||||
pub method: Option<String>,
|
||||
pub path: String,
|
||||
pub request_body: String,
|
||||
pub response_body: String,
|
||||
pub status_code: i32,
|
||||
pub timestamp: String,
|
||||
}
|
||||
78
src/db/repositories/commands.rs
Normal file
78
src/db/repositories/commands.rs
Normal file
@@ -0,0 +1,78 @@
|
||||
use crate::db::models::Command;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
pub async fn list_all(pool: &SqlitePool) -> anyhow::Result<Vec<Command>> {
|
||||
let commands = sqlx::query_as::<_, Command>("SELECT * FROM commands ORDER BY received_at DESC")
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
Ok(commands)
|
||||
}
|
||||
|
||||
pub async fn list_by_status(pool: &SqlitePool, status: &str) -> anyhow::Result<Vec<Command>> {
|
||||
let commands = sqlx::query_as::<_, Command>(
|
||||
"SELECT * FROM commands WHERE status = ? ORDER BY received_at DESC",
|
||||
)
|
||||
.bind(status)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
Ok(commands)
|
||||
}
|
||||
|
||||
pub async fn find_by_id(pool: &SqlitePool, id: i64) -> anyhow::Result<Option<Command>> {
|
||||
let command = sqlx::query_as::<_, Command>("SELECT * FROM commands WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
Ok(command)
|
||||
}
|
||||
|
||||
pub async fn insert(pool: &SqlitePool, command_json: &str, status: &str) -> anyhow::Result<i64> {
|
||||
let result = sqlx::query("INSERT INTO commands (command_json, status) VALUES (?, ?)")
|
||||
.bind(command_json)
|
||||
.bind(status)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(result.last_insert_rowid())
|
||||
}
|
||||
|
||||
pub async fn update_status(
|
||||
pool: &SqlitePool,
|
||||
id: i64,
|
||||
status: &str,
|
||||
notes: Option<&str>,
|
||||
) -> anyhow::Result<()> {
|
||||
sqlx::query(
|
||||
"UPDATE commands SET status = ?, processed_at = CURRENT_TIMESTAMP, notes = ? WHERE id = ?",
|
||||
)
|
||||
.bind(status)
|
||||
.bind(notes)
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_old(pool: &SqlitePool, days: i64) -> anyhow::Result<u64> {
|
||||
let result = sqlx::query(
|
||||
"DELETE FROM commands WHERE status IN ('verified', 'rejected')
|
||||
AND processed_at < datetime('now', '-' || ? || ' days')",
|
||||
)
|
||||
.bind(days)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
|
||||
pub async fn clear_verified(pool: &SqlitePool) -> anyhow::Result<()> {
|
||||
sqlx::query("DELETE FROM commands WHERE status = 'verified'")
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
51
src/db/repositories/config.rs
Normal file
51
src/db/repositories/config.rs
Normal file
@@ -0,0 +1,51 @@
|
||||
use crate::db::models::Config;
|
||||
use sqlx::SqlitePool;
|
||||
use std::collections::HashMap;
|
||||
|
||||
pub async fn get_all(pool: &SqlitePool) -> anyhow::Result<HashMap<String, String>> {
|
||||
let configs = sqlx::query_as::<_, Config>("SELECT * FROM config")
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
let map: HashMap<String, String> = configs.into_iter().map(|c| (c.key, c.value)).collect();
|
||||
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
pub async fn get(pool: &SqlitePool, key: &str) -> anyhow::Result<Option<String>> {
|
||||
let config = sqlx::query_as::<_, Config>("SELECT * FROM config WHERE key = ?")
|
||||
.bind(key)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
Ok(config.map(|c| c.value))
|
||||
}
|
||||
|
||||
pub async fn set(
|
||||
pool: &SqlitePool,
|
||||
key: &str,
|
||||
value: &str,
|
||||
description: Option<&str>,
|
||||
) -> anyhow::Result<()> {
|
||||
sqlx::query(
|
||||
"INSERT INTO config (key, value, description) VALUES (?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = ?, updated_at = CURRENT_TIMESTAMP",
|
||||
)
|
||||
.bind(key)
|
||||
.bind(value)
|
||||
.bind(description)
|
||||
.bind(value)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete(pool: &SqlitePool, key: &str) -> anyhow::Result<()> {
|
||||
sqlx::query("DELETE FROM config WHERE key = ?")
|
||||
.bind(key)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
3
src/db/repositories/mod.rs
Normal file
3
src/db/repositories/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod commands;
|
||||
pub mod config;
|
||||
pub mod rules;
|
||||
105
src/db/repositories/rules.rs
Normal file
105
src/db/repositories/rules.rs
Normal file
@@ -0,0 +1,105 @@
|
||||
use crate::db::models::InterceptionRule;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
pub async fn list_all(pool: &SqlitePool) -> anyhow::Result<Vec<InterceptionRule>> {
|
||||
let rules = sqlx::query_as::<_, InterceptionRule>(
|
||||
"SELECT * FROM interception_rules ORDER BY created_at DESC",
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
Ok(rules)
|
||||
}
|
||||
|
||||
pub async fn find_by_id(pool: &SqlitePool, id: i64) -> anyhow::Result<Option<InterceptionRule>> {
|
||||
let rule =
|
||||
sqlx::query_as::<_, InterceptionRule>("SELECT * FROM interception_rules WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
Ok(rule)
|
||||
}
|
||||
|
||||
pub async fn find_by_method(
|
||||
pool: &SqlitePool,
|
||||
method: &str,
|
||||
) -> anyhow::Result<Option<InterceptionRule>> {
|
||||
let rule = sqlx::query_as::<_, InterceptionRule>(
|
||||
"SELECT * FROM interception_rules WHERE method_name = ? AND is_enabled = 1",
|
||||
)
|
||||
.bind(method)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
Ok(rule)
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
pool: &SqlitePool,
|
||||
method_name: &str,
|
||||
action: &str,
|
||||
custom_response: Option<&str>,
|
||||
) -> anyhow::Result<i64> {
|
||||
let result = sqlx::query(
|
||||
"INSERT INTO interception_rules (method_name, action, custom_response, is_enabled)
|
||||
VALUES (?, ?, ?, 1)",
|
||||
)
|
||||
.bind(method_name)
|
||||
.bind(action)
|
||||
.bind(custom_response)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(result.last_insert_rowid())
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
pool: &SqlitePool,
|
||||
id: i64,
|
||||
method_name: Option<&str>,
|
||||
action: Option<&str>,
|
||||
custom_response: Option<&str>,
|
||||
is_enabled: Option<bool>,
|
||||
) -> anyhow::Result<()> {
|
||||
let mut query = String::from("UPDATE interception_rules SET updated_at = CURRENT_TIMESTAMP");
|
||||
let mut params: Vec<String> = Vec::new();
|
||||
|
||||
if let Some(m) = method_name {
|
||||
query.push_str(", method_name = ?");
|
||||
params.push(m.to_string());
|
||||
}
|
||||
if let Some(a) = action {
|
||||
query.push_str(", action = ?");
|
||||
params.push(a.to_string());
|
||||
}
|
||||
if custom_response.is_some() {
|
||||
query.push_str(", custom_response = ?");
|
||||
params.push(custom_response.unwrap_or("").to_string());
|
||||
}
|
||||
if let Some(e) = is_enabled {
|
||||
query.push_str(", is_enabled = ?");
|
||||
params.push(if e { "1" } else { "0" }.to_string());
|
||||
}
|
||||
|
||||
query.push_str(" WHERE id = ?");
|
||||
|
||||
let mut q = sqlx::query(&query);
|
||||
for param in params {
|
||||
q = q.bind(param);
|
||||
}
|
||||
q = q.bind(id);
|
||||
|
||||
q.execute(pool).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete(pool: &SqlitePool, id: i64) -> anyhow::Result<()> {
|
||||
sqlx::query("DELETE FROM interception_rules WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
445
src/jsonrpc.rs
445
src/jsonrpc.rs
@@ -1,19 +1,438 @@
|
||||
use hashbrown::HashMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct Request {
|
||||
pub method: String,
|
||||
pub params: Option<serde_json::Map<String, Value>>,
|
||||
pub id: Value,
|
||||
pub jsonrpc: Option<String>
|
||||
mod tactics;
|
||||
|
||||
use tactics::Tactics;
|
||||
|
||||
macro_rules! data_wrapper {
|
||||
($name:ident, $ty:literal) => {
|
||||
::concat_idents::concat_idents! {
|
||||
struct_name = $name, Wrapper,
|
||||
|
||||
{
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
#[serde(tag = "type", rename = $ty)]
|
||||
pub struct struct_name {
|
||||
pub data: $name,
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
macro_rules! declare_request_enum {
|
||||
(
|
||||
$(#[$enum_meta:meta])*
|
||||
$vis:vis enum $enum_name:ident {
|
||||
$(
|
||||
$key:literal => $variant:ident($ty:ty)
|
||||
),*
|
||||
$(,)?
|
||||
|
||||
_ => $generic_variant:ident($generic_ty:ident)
|
||||
}
|
||||
) => {
|
||||
$(#[$enum_meta])*
|
||||
#[serde(tag = "method", content = "params")]
|
||||
$vis enum $enum_name {
|
||||
$(
|
||||
#[serde(rename = $key)]
|
||||
$variant($ty),
|
||||
)*
|
||||
|
||||
#[serde(untagged)]
|
||||
$generic_variant($generic_ty),
|
||||
}
|
||||
|
||||
impl<'de> serde::Deserialize<'de> for $enum_name {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
#[derive(serde::Deserialize)]
|
||||
struct __RawRequest {
|
||||
method: String,
|
||||
params: serde_json::Map<String, Value>,
|
||||
}
|
||||
|
||||
let raw = __RawRequest::deserialize(deserializer)?;
|
||||
|
||||
match raw.method.as_str() {
|
||||
$(
|
||||
$key => {
|
||||
let params = serde_json::from_value(Value::Object(raw.params))
|
||||
.map_err(serde::de::Error::custom)?;
|
||||
Ok($enum_name::$variant(params))
|
||||
}
|
||||
)*
|
||||
_ => {
|
||||
Ok($enum_name::$generic_variant($generic_ty {
|
||||
method: raw.method,
|
||||
params: raw.params,
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
declare_request_enum! {
|
||||
#[derive(Serialize, Debug)]
|
||||
pub enum RequestContent {
|
||||
"com.linspirer.tactics.gettactics" => GetTactics(GetTacticsParams),
|
||||
_ => Generic(GenericRequestContent)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct Request {
|
||||
#[serde(rename = "!version")]
|
||||
pub version: i32,
|
||||
pub client_version: String,
|
||||
pub id: i32,
|
||||
pub jsonrpc: String,
|
||||
#[serde(flatten)]
|
||||
pub content: RequestContent,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct BaseParams {
|
||||
email: String,
|
||||
model: String,
|
||||
swdid: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct GetTacticsParams {
|
||||
#[serde(flatten)]
|
||||
base: BaseParams,
|
||||
launcher_version: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct GenericRequestContent {
|
||||
pub method: String,
|
||||
#[serde(default)]
|
||||
pub params: serde_json::Map<String, Value>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct Response {
|
||||
pub result: Option<Value>,
|
||||
pub params: Option<HashMap<String, Value>>,
|
||||
pub id: Value,
|
||||
pub jsonrpc: Option<String>
|
||||
}
|
||||
pub code: i32,
|
||||
#[serde(flatten)]
|
||||
pub data: ResponseData,
|
||||
}
|
||||
|
||||
data_wrapper!(Tactics, "object");
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
#[serde(untagged)]
|
||||
pub enum ResponseData {
|
||||
Tactics(Box<TacticsWrapper>),
|
||||
Generic(GenericResponseContent),
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct GenericResponseContent {
|
||||
pub r#type: String,
|
||||
#[serde(default)]
|
||||
pub data: Option<Value>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use indoc::indoc;
|
||||
use serde_json::Map;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn deserialize_gettactics_response() {
|
||||
const RESP: &str = indoc!(
|
||||
r#"{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"app_status": true,
|
||||
"app_tactics": {
|
||||
"applist": [
|
||||
{
|
||||
"canuninstall": false,
|
||||
"created_at": "2025-09-04 08:04:08",
|
||||
"devicetype": "HITV102C",
|
||||
"exception_white_url": 1,
|
||||
"grant_to": 788955,
|
||||
"grant_type": 5,
|
||||
"groupid": 1,
|
||||
"hide_icon_status": 0,
|
||||
"id": 156727,
|
||||
"is_trust": true,
|
||||
"isforce": true,
|
||||
"isnew": false,
|
||||
"name": "青鹿作业5",
|
||||
"packagename": "com.qljy.smarthomework.student",
|
||||
"sha1": "FD:73:65:ED:4F:B9:32:DE:4D:8D:26:BF:B7:61:E4:CF:82:47:24:AB",
|
||||
"sort_weight": 0,
|
||||
"status": 1,
|
||||
"target_sdk_version": 30,
|
||||
"updated_at": "2025-11-19 17:23:59",
|
||||
"versioncode": 1019,
|
||||
"versionname": "5.5.6"
|
||||
}
|
||||
]
|
||||
},
|
||||
"device_setting": {
|
||||
"alarm_clock_status": 0,
|
||||
"allow_change_password_status": 0,
|
||||
"calendar_status": 0,
|
||||
"camera_status": 0,
|
||||
"data_flow_status": 1,
|
||||
"disable_reinstall_system_status": 0,
|
||||
"enable_client_admin_status": 1,
|
||||
"enable_gesture_pwd_status": 0,
|
||||
"enable_gps_status": 1,
|
||||
"enable_screenshots_status": 1,
|
||||
"enable_system_upgrade_status": 1,
|
||||
"enable_wifi_advanced_status": 0,
|
||||
"gallery_status": 0,
|
||||
"hide_accelerate_status": 0,
|
||||
"hide_cleanup_status": 0,
|
||||
"keep_alive_package": null,
|
||||
"launch_app": {
|
||||
"launch_mode": 1,
|
||||
"launch_package": "cn.com.ava.ebook5"
|
||||
},
|
||||
"logout_status": 1,
|
||||
"only_install_store_app_status": 1,
|
||||
"otg_set": {
|
||||
"pv_list": [],
|
||||
"status": 0
|
||||
},
|
||||
"protected_eyes_status": {
|
||||
"distance_status": 0,
|
||||
"sensitive_status": 0,
|
||||
"sitting_position_status": 0
|
||||
},
|
||||
"remind_duration": {
|
||||
"duration": 0,
|
||||
"remind_status": 0
|
||||
},
|
||||
"rotate_setting_status": 1,
|
||||
"school_class_display_status": 0,
|
||||
"sdcard_and_otg": 0,
|
||||
"show_privacy_statement_status": 1,
|
||||
"simcard": 0
|
||||
},
|
||||
"device_status": true,
|
||||
"device_tactics": {
|
||||
"deviceManage": {
|
||||
"command_bluetooth": true,
|
||||
"command_camera": true,
|
||||
"command_connect_usb": false,
|
||||
"command_data_flow": true,
|
||||
"command_force_open_wifi": false,
|
||||
"command_gps": true,
|
||||
"command_otg": false,
|
||||
"command_phone_msg": false,
|
||||
"command_recording": true,
|
||||
"command_sd_card": false,
|
||||
"command_wifi_advanced": false,
|
||||
"command_wifi_switch": true
|
||||
}
|
||||
},
|
||||
"enable_amap_status": 1,
|
||||
"free_control": 0,
|
||||
"id": 116425,
|
||||
"illegal_status": false,
|
||||
"illegal_tactics": {
|
||||
"already_root": {
|
||||
"eliminate_data": false,
|
||||
"enable": false,
|
||||
"lock_workspace": false,
|
||||
"notify_admin": false
|
||||
},
|
||||
"change_simcard": {
|
||||
"eliminate_data": false,
|
||||
"enable": false,
|
||||
"lock_workspace": false,
|
||||
"notify_admin": false
|
||||
},
|
||||
"prohibited_app": {
|
||||
"eliminate_data": false,
|
||||
"enable": false,
|
||||
"lock_workspace": false,
|
||||
"notify_admin": false
|
||||
},
|
||||
"usb_to_pc": {
|
||||
"eliminate_data": false,
|
||||
"enable": false,
|
||||
"lock_workspace": false,
|
||||
"notify_admin": false
|
||||
}
|
||||
},
|
||||
"interest_applist": [],
|
||||
"name": "whoami",
|
||||
"release_control": 0,
|
||||
"updated_at": "2025-09-04 08:05:18",
|
||||
"usergroup": 1219237,
|
||||
"wifi_status": false,
|
||||
"wifi_tactics": [],
|
||||
"wifi_tactics_2": [],
|
||||
"workspace_status": false,
|
||||
"workspace_tactics": {
|
||||
"worktime": {}
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}"#
|
||||
);
|
||||
let resp: Response = serde_json::from_str(RESP).unwrap();
|
||||
println!("{resp:#?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialize_gettactics_response() {
|
||||
let tactics = Tactics {
|
||||
app_status: true,
|
||||
app_tactics: tactics::AppTactics {
|
||||
applist: Vec::new(),
|
||||
},
|
||||
device_setting: tactics::DeviceSetting {
|
||||
alarm_clock_status: true,
|
||||
allow_change_password_status: true,
|
||||
calendar_status: true,
|
||||
camera_status: true,
|
||||
data_flow_status: true,
|
||||
disable_reinstall_system_status: true,
|
||||
enable_client_admin_status: true,
|
||||
enable_gesture_pwd_status: true,
|
||||
enable_gps_status: true,
|
||||
enable_screenshots_status: true,
|
||||
enable_system_upgrade_status: true,
|
||||
enable_wifi_advanced_status: true,
|
||||
gallery_status: true,
|
||||
hide_accelerate_status: false,
|
||||
hide_cleanup_status: false,
|
||||
keep_alive_package: Value::Null,
|
||||
launch_app: tactics::LaunchApp {
|
||||
launch_mode: 1,
|
||||
launch_package: "cn.com.ava.ebook5".to_string(),
|
||||
},
|
||||
logout_status: true,
|
||||
only_install_store_app_status: true,
|
||||
otg_set: tactics::OtgSet {
|
||||
pv_list: vec![],
|
||||
status: false,
|
||||
},
|
||||
protected_eyes_status: tactics::ProtectedEyesStatus {
|
||||
distance_status: false,
|
||||
sensitive_status: false,
|
||||
sitting_position_status: false,
|
||||
},
|
||||
remind_duration: tactics::RemindDuration {
|
||||
duration: 0,
|
||||
remind_status: false,
|
||||
},
|
||||
rotate_setting_status: true,
|
||||
school_class_display_status: false,
|
||||
sdcard_and_otg: false,
|
||||
show_privacy_statement_status: true,
|
||||
simcard: false,
|
||||
},
|
||||
device_status: true,
|
||||
device_tactics: tactics::DeviceTactics {
|
||||
device_manage: tactics::DeviceManage {
|
||||
command_bluetooth: true,
|
||||
command_data_flow: true,
|
||||
command_gps: true,
|
||||
command_otg: true,
|
||||
command_camera: true,
|
||||
command_sd_card: true,
|
||||
command_phone_msg: true,
|
||||
command_recording: true,
|
||||
command_connect_usb: true,
|
||||
command_wifi_switch: true,
|
||||
command_wifi_advanced: true,
|
||||
command_force_open_wifi: true,
|
||||
},
|
||||
},
|
||||
enable_amap_status: true,
|
||||
free_control: false,
|
||||
id: 116425,
|
||||
illegal_status: false,
|
||||
illegal_tactics: tactics::IllegalTactics::default(),
|
||||
interest_applist: vec![],
|
||||
name: "whoami".to_string(),
|
||||
release_control: false,
|
||||
updated_at: "idunno".to_string(),
|
||||
user_group: 1219237,
|
||||
wifi_status: false,
|
||||
wifi_tactics: vec![],
|
||||
wifi_tactics_2: vec![],
|
||||
workspace_status: false,
|
||||
workspace_tactics: tactics::WorkspaceTactics {
|
||||
worktime: Map::new(),
|
||||
},
|
||||
};
|
||||
println!("{}", serde_json::to_string_pretty(&tactics).unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_gettactics_request() {
|
||||
const REQ: &str = indoc!(
|
||||
r#"{
|
||||
"!version": 6,
|
||||
"client_version": "sxqinglu_product_5.04.105.1",
|
||||
"id": 1,
|
||||
"jsonrpc": "2.0",
|
||||
"method": "com.linspirer.tactics.gettactics",
|
||||
"params": {
|
||||
"email": "idunno",
|
||||
"launcher_version": "sxqinglu_product_5.04.105.1",
|
||||
"model": "HITV102C",
|
||||
"swdid": "idunno"
|
||||
}
|
||||
}"#
|
||||
);
|
||||
let req: Request = serde_json::from_str(REQ).unwrap();
|
||||
println!("{:#?}", req);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialize_gettactics_request() {
|
||||
let req = Request {
|
||||
version: 6,
|
||||
client_version: "sxqinglu_product_5.04.105.1".to_string(),
|
||||
id: 1,
|
||||
jsonrpc: "2.0".to_string(),
|
||||
content: RequestContent::GetTactics(GetTacticsParams {
|
||||
base: BaseParams {
|
||||
email: "idunno".to_string(),
|
||||
model: "HITV102C".to_string(),
|
||||
swdid: "idunno".to_string(),
|
||||
},
|
||||
launcher_version: "sxqinglu_product_5.04.105.1".to_string(),
|
||||
}),
|
||||
};
|
||||
// base params goes first
|
||||
const REQ: &str = indoc!(
|
||||
r#"{
|
||||
"!version": 6,
|
||||
"client_version": "sxqinglu_product_5.04.105.1",
|
||||
"id": 1,
|
||||
"jsonrpc": "2.0",
|
||||
"method": "com.linspirer.tactics.gettactics",
|
||||
"params": {
|
||||
"email": "idunno",
|
||||
"model": "HITV102C",
|
||||
"swdid": "idunno",
|
||||
"launcher_version": "sxqinglu_product_5.04.105.1"
|
||||
}
|
||||
}"#
|
||||
);
|
||||
let req = serde_json::to_string_pretty(&req).unwrap();
|
||||
println!("{req}");
|
||||
assert_eq!(req, REQ);
|
||||
}
|
||||
}
|
||||
|
||||
206
src/jsonrpc/tactics.rs
Normal file
206
src/jsonrpc/tactics.rs
Normal file
@@ -0,0 +1,206 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
use serde_with::{BoolFromInt, serde_as};
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Deserialize, Serialize, Debug)]
|
||||
pub struct Tactics {
|
||||
pub app_status: bool,
|
||||
pub app_tactics: AppTactics,
|
||||
pub device_setting: DeviceSetting,
|
||||
pub device_status: bool,
|
||||
pub device_tactics: DeviceTactics,
|
||||
#[serde_as(as = "BoolFromInt")]
|
||||
pub enable_amap_status: bool,
|
||||
#[serde_as(as = "BoolFromInt")]
|
||||
pub free_control: bool,
|
||||
pub id: u32,
|
||||
pub illegal_status: bool,
|
||||
pub illegal_tactics: IllegalTactics,
|
||||
pub interest_applist: Vec<Value>,
|
||||
pub name: String,
|
||||
#[serde_as(as = "BoolFromInt")]
|
||||
pub release_control: bool,
|
||||
// TODO: std::time::Instant
|
||||
pub updated_at: String,
|
||||
#[serde(rename = "usergroup")]
|
||||
pub user_group: u32,
|
||||
pub wifi_status: bool,
|
||||
pub wifi_tactics: Vec<Value>,
|
||||
pub wifi_tactics_2: Vec<Value>,
|
||||
pub workspace_status: bool,
|
||||
pub workspace_tactics: WorkspaceTactics,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug)]
|
||||
pub struct AppTactics {
|
||||
pub applist: Vec<App>,
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Deserialize, Serialize, Debug)]
|
||||
pub struct App {
|
||||
#[serde(rename = "canuninstall")]
|
||||
can_uninstall: bool,
|
||||
// TODO: std::time::Instant
|
||||
created_at: String,
|
||||
#[serde(rename = "devicetype")]
|
||||
device_type: String,
|
||||
#[serde_as(as = "BoolFromInt")]
|
||||
exception_white_url: bool,
|
||||
grant_to: u32,
|
||||
grant_type: u32,
|
||||
#[serde(rename = "groupid")]
|
||||
group_id: u32,
|
||||
#[serde_as(as = "BoolFromInt")]
|
||||
hide_icon_status: bool,
|
||||
id: u32,
|
||||
is_trust: bool,
|
||||
#[serde(rename = "isforce")]
|
||||
is_force: bool,
|
||||
#[serde(rename = "isnew")]
|
||||
is_new: bool,
|
||||
name: String,
|
||||
#[serde(rename = "packagename")]
|
||||
package_name: String,
|
||||
sha1: String,
|
||||
sort_weight: i32,
|
||||
status: i32,
|
||||
target_sdk_version: i32,
|
||||
// TODO: std::time::Instant
|
||||
updated_at: String,
|
||||
#[serde(rename = "versioncode")]
|
||||
version_code: i32,
|
||||
#[serde(rename = "versionname")]
|
||||
version_name: String,
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Deserialize, Serialize, Debug)]
|
||||
pub struct DeviceSetting {
|
||||
#[serde_as(as = "BoolFromInt")]
|
||||
pub alarm_clock_status: bool,
|
||||
#[serde_as(as = "BoolFromInt")]
|
||||
pub allow_change_password_status: bool,
|
||||
#[serde_as(as = "BoolFromInt")]
|
||||
pub calendar_status: bool,
|
||||
#[serde_as(as = "BoolFromInt")]
|
||||
pub camera_status: bool,
|
||||
#[serde_as(as = "BoolFromInt")]
|
||||
pub data_flow_status: bool,
|
||||
#[serde_as(as = "BoolFromInt")]
|
||||
pub disable_reinstall_system_status: bool,
|
||||
#[serde_as(as = "BoolFromInt")]
|
||||
pub enable_client_admin_status: bool,
|
||||
#[serde_as(as = "BoolFromInt")]
|
||||
pub enable_gesture_pwd_status: bool,
|
||||
#[serde_as(as = "BoolFromInt")]
|
||||
pub enable_gps_status: bool,
|
||||
#[serde_as(as = "BoolFromInt")]
|
||||
pub enable_screenshots_status: bool,
|
||||
#[serde_as(as = "BoolFromInt")]
|
||||
pub enable_system_upgrade_status: bool,
|
||||
#[serde_as(as = "BoolFromInt")]
|
||||
pub enable_wifi_advanced_status: bool,
|
||||
#[serde_as(as = "BoolFromInt")]
|
||||
pub gallery_status: bool,
|
||||
#[serde_as(as = "BoolFromInt")]
|
||||
pub hide_accelerate_status: bool,
|
||||
#[serde_as(as = "BoolFromInt")]
|
||||
pub hide_cleanup_status: bool,
|
||||
pub keep_alive_package: Value,
|
||||
pub launch_app: LaunchApp,
|
||||
#[serde_as(as = "BoolFromInt")]
|
||||
pub logout_status: bool,
|
||||
#[serde_as(as = "BoolFromInt")]
|
||||
pub only_install_store_app_status: bool,
|
||||
pub otg_set: OtgSet,
|
||||
pub protected_eyes_status: ProtectedEyesStatus,
|
||||
pub remind_duration: RemindDuration,
|
||||
#[serde_as(as = "BoolFromInt")]
|
||||
pub rotate_setting_status: bool,
|
||||
#[serde_as(as = "BoolFromInt")]
|
||||
pub school_class_display_status: bool,
|
||||
#[serde_as(as = "BoolFromInt")]
|
||||
pub sdcard_and_otg: bool,
|
||||
#[serde_as(as = "BoolFromInt")]
|
||||
pub show_privacy_statement_status: bool,
|
||||
#[serde_as(as = "BoolFromInt")]
|
||||
pub simcard: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug)]
|
||||
pub struct LaunchApp {
|
||||
pub launch_mode: u32,
|
||||
pub launch_package: String,
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Deserialize, Serialize, Debug)]
|
||||
pub struct OtgSet {
|
||||
pub pv_list: Vec<Value>,
|
||||
#[serde_as(as = "BoolFromInt")]
|
||||
pub status: bool,
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Deserialize, Serialize, Debug)]
|
||||
pub struct ProtectedEyesStatus {
|
||||
#[serde_as(as = "BoolFromInt")]
|
||||
pub distance_status: bool,
|
||||
#[serde_as(as = "BoolFromInt")]
|
||||
pub sensitive_status: bool,
|
||||
#[serde_as(as = "BoolFromInt")]
|
||||
pub sitting_position_status: bool,
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Deserialize, Serialize, Debug)]
|
||||
pub struct RemindDuration {
|
||||
pub duration: u32,
|
||||
#[serde_as(as = "BoolFromInt")]
|
||||
pub remind_status: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug)]
|
||||
pub struct DeviceTactics {
|
||||
#[serde(rename = "deviceManage")]
|
||||
pub device_manage: DeviceManage,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug)]
|
||||
pub struct DeviceManage {
|
||||
pub command_bluetooth: bool,
|
||||
pub command_camera: bool,
|
||||
pub command_connect_usb: bool,
|
||||
pub command_data_flow: bool,
|
||||
pub command_force_open_wifi: bool,
|
||||
pub command_gps: bool,
|
||||
pub command_otg: bool,
|
||||
pub command_phone_msg: bool,
|
||||
pub command_recording: bool,
|
||||
pub command_sd_card: bool,
|
||||
pub command_wifi_advanced: bool,
|
||||
pub command_wifi_switch: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, Default)]
|
||||
pub struct IllegalTactics {
|
||||
pub already_root: IllegalTactic,
|
||||
pub change_simcard: IllegalTactic,
|
||||
pub prohibited_app: IllegalTactic,
|
||||
pub usb_to_pc: IllegalTactic,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, Default)]
|
||||
pub struct IllegalTactic {
|
||||
pub eliminate_data: bool,
|
||||
pub enable: bool,
|
||||
pub lock_workspace: bool,
|
||||
pub notify_admin: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug)]
|
||||
pub struct WorkspaceTactics {
|
||||
pub worktime: Map<String, Value>,
|
||||
}
|
||||
44
src/main.rs
44
src/main.rs
@@ -1,32 +1,28 @@
|
||||
use std::{net::SocketAddr, sync::Arc};
|
||||
|
||||
use anyhow::Context;
|
||||
use axum::Router;
|
||||
use axum::handler::Handler;
|
||||
use dotenvy::dotenv;
|
||||
use serde_json::Value;
|
||||
use tokio::sync::RwLock;
|
||||
use tower_http::compression::CompressionLayer;
|
||||
use tracing::{error, info, level_filters::LevelFilter};
|
||||
use tracing_subscriber::{EnvFilter, fmt, prelude::*};
|
||||
|
||||
mod admin;
|
||||
mod auth;
|
||||
mod crypto;
|
||||
mod db;
|
||||
mod jsonrpc;
|
||||
mod middleware;
|
||||
mod proxy;
|
||||
mod state;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub client: reqwest::Client,
|
||||
pub target_url: reqwest::Url,
|
||||
pub key: String,
|
||||
pub iv: String,
|
||||
pub command_queue: Arc<RwLock<Vec<Value>>>,
|
||||
pub saved_tactics: Arc<RwLock<Option<Value>>>,
|
||||
}
|
||||
use state::AppState;
|
||||
|
||||
const DEFAULT_TARGET_URL: &str = "https://cloud.linspirer.com:883";
|
||||
const DEFAULT_HOST: &str = "0.0.0.0";
|
||||
const DEFAULT_PORT: &str = "8080";
|
||||
const DEFAULT_DB_PATH: &str = "sqlite://./data/linspirer.db";
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
@@ -50,6 +46,9 @@ async fn main() -> anyhow::Result<()> {
|
||||
let target_url_str = target_url_str.as_deref().unwrap_or(DEFAULT_TARGET_URL);
|
||||
let target_url = reqwest::Url::parse(target_url_str)?;
|
||||
|
||||
let db_path_str = std::env::var("LINSPIRER_DB_PATH");
|
||||
let db_path = db_path_str.as_deref().unwrap_or(DEFAULT_DB_PATH);
|
||||
|
||||
let host_str = std::env::var("LINSPIRER_HOST");
|
||||
let host = host_str.as_deref().unwrap_or(DEFAULT_HOST);
|
||||
let port_str = std::env::var("LINSPIRER_PORT");
|
||||
@@ -58,6 +57,11 @@ async fn main() -> anyhow::Result<()> {
|
||||
let addr: SocketAddr = addr_str
|
||||
.parse()
|
||||
.context(format!("Invalid address format: {}", addr_str))?;
|
||||
let jwt_secret = std::env::var("LINSPIRER_JWT_SECRET")
|
||||
.map_err(|_| anyhow::anyhow!("LINSPIRER_JWT_SECRET not set"))?;
|
||||
|
||||
// Initialize database
|
||||
let db = db::init_db(db_path).await?;
|
||||
|
||||
// Create a reqwest client that ignores SSL certificate verification
|
||||
let client = reqwest::Client::builder()
|
||||
@@ -71,16 +75,20 @@ async fn main() -> anyhow::Result<()> {
|
||||
target_url,
|
||||
key,
|
||||
iv,
|
||||
command_queue: Arc::new(RwLock::new(Vec::new())),
|
||||
saved_tactics: Arc::new(RwLock::new(None)),
|
||||
jwt_secret,
|
||||
db,
|
||||
commands: Default::default(),
|
||||
});
|
||||
|
||||
let log_middleware =
|
||||
axum::middleware::from_fn_with_state(state.clone(), middleware::log_middleware);
|
||||
|
||||
// Build our application
|
||||
let app = proxy::proxy_handler
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
middleware::log_middleware,
|
||||
))
|
||||
let app = Router::new()
|
||||
// Admin routes
|
||||
.nest("/admin", admin::routes::admin_routes(state.clone()))
|
||||
// Proxy all other routes (fallback)
|
||||
.fallback(proxy::proxy_handler.layer(log_middleware))
|
||||
.layer(CompressionLayer::new().gzip(true))
|
||||
.with_state(state);
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ use axum::{
|
||||
};
|
||||
use http_body_util::BodyExt;
|
||||
use serde_json::Value;
|
||||
use tracing::{info, warn};
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use crate::{AppState, crypto};
|
||||
|
||||
@@ -77,24 +77,35 @@ pub async fn log_middleware(
|
||||
};
|
||||
let resp_body_text = String::from_utf8(body_bytes.clone().to_vec()).unwrap_or_default();
|
||||
|
||||
let response_body_to_log = if Some("com.linspirer.device.getcommand") == method.as_deref() {
|
||||
match handle_getcommand_response(&resp_body_text, &state).await {
|
||||
Ok(new_body) => ResponseBody::Modified(new_body),
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"Failed to handle getcommand response: {}. Responding with empty command list.",
|
||||
e
|
||||
);
|
||||
let mut empty_response =
|
||||
serde_json::from_str::<Value>(&resp_body_text).unwrap_or(Value::Null);
|
||||
if let Some(obj) = empty_response.as_object_mut() {
|
||||
obj.insert("result".to_string(), Value::Array(vec![]));
|
||||
// Check for generic method interception first
|
||||
let response_body_to_log = if let Some(method_str) = &method {
|
||||
if let Ok(Some(intercepted)) =
|
||||
maybe_intercept_response(method_str, &resp_body_text, &state).await
|
||||
{
|
||||
info!("Intercepting response for method: {}", method_str);
|
||||
ResponseBody::Original(intercepted)
|
||||
} else if Some("com.linspirer.device.getcommand") == method.as_deref() {
|
||||
// Special handling for getcommand
|
||||
match handle_getcommand_response(&resp_body_text, &state).await {
|
||||
Ok(new_body) => ResponseBody::Modified(new_body),
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"Failed to handle getcommand response: {}. Responding with empty command list.",
|
||||
e
|
||||
);
|
||||
let mut empty_response =
|
||||
serde_json::from_str::<Value>(&resp_body_text).unwrap_or(Value::Null);
|
||||
if let Some(obj) = empty_response.as_object_mut() {
|
||||
obj.insert("result".to_string(), Value::Array(vec![]));
|
||||
}
|
||||
ResponseBody::Modified(empty_response)
|
||||
}
|
||||
ResponseBody::Modified(empty_response)
|
||||
}
|
||||
} else {
|
||||
ResponseBody::Original(resp_body_text.clone())
|
||||
}
|
||||
} else {
|
||||
ResponseBody::Original(resp_body_text)
|
||||
ResponseBody::Original(resp_body_text.clone())
|
||||
};
|
||||
|
||||
let (decrypted_response_for_log, final_response_body) = match response_body_to_log {
|
||||
@@ -111,7 +122,7 @@ pub async fn log_middleware(
|
||||
}
|
||||
};
|
||||
|
||||
info!(
|
||||
debug!(
|
||||
"{}\nRequest:\n{}\nResponse:\n{}\n{}",
|
||||
path,
|
||||
serde_json::to_string_pretty(&decrypted_request_log).unwrap_or_default(),
|
||||
@@ -149,23 +160,61 @@ fn process_and_log_request(body: &str, key: &str, iv: &str) -> anyhow::Result<Va
|
||||
Ok(request_data)
|
||||
}
|
||||
|
||||
async fn handle_getcommand_response(body_text: &str, state: &Arc<AppState>) -> anyhow::Result<Value> {
|
||||
async fn handle_getcommand_response(
|
||||
body_text: &str,
|
||||
state: &Arc<AppState>,
|
||||
) -> anyhow::Result<Value> {
|
||||
let decrypted = crypto::decrypt(body_text, &state.key, &state.iv)?;
|
||||
let mut response_json: Value = serde_json::from_str(&decrypted)?;
|
||||
|
||||
if let Some(result) = response_json.get("result")
|
||||
&& let Some(commands) = result.as_array()
|
||||
if let Some(result) = response_json.get_mut("result")
|
||||
&& let Some(commands) = result.as_array_mut()
|
||||
&& !commands.is_empty()
|
||||
{
|
||||
let mut queue = state.command_queue.write().await;
|
||||
for cmd in commands {
|
||||
queue.push(cmd.clone());
|
||||
// Persist commands to database
|
||||
for cmd in commands.iter() {
|
||||
let cmd_json = serde_json::to_string(cmd)?;
|
||||
if let Err(e) =
|
||||
crate::db::repositories::commands::insert(&state.db, &cmd_json, "unverified").await
|
||||
{
|
||||
warn!("Failed to persist command to database: {}", e);
|
||||
}
|
||||
info!("Added command to the queue: {:?}", cmd);
|
||||
}
|
||||
|
||||
// Also add to in-memory queue for backwards compatibility
|
||||
let mut queue = state.commands.unverified.write().await;
|
||||
queue.extend(commands.drain(..));
|
||||
}
|
||||
|
||||
if let Some(obj) = response_json.as_object_mut() {
|
||||
obj.insert("result".to_string(), Value::Array(vec![]));
|
||||
// Get verified commands from database
|
||||
let verified_cmds =
|
||||
match crate::db::repositories::commands::list_by_status(&state.db, "verified").await {
|
||||
Ok(cmds) => cmds,
|
||||
Err(e) => {
|
||||
warn!("Failed to fetch verified commands from database: {}", e);
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
|
||||
// Convert to JSON values
|
||||
let verified_values: Vec<Value> = verified_cmds
|
||||
.iter()
|
||||
.filter_map(|c| serde_json::from_str(&c.command_json).ok())
|
||||
.collect();
|
||||
|
||||
// Also include in-memory verified commands
|
||||
let mem_verified = std::mem::take(&mut *state.commands.verified.write().await);
|
||||
let mut all_verified = verified_values;
|
||||
all_verified.extend(mem_verified);
|
||||
|
||||
obj.insert("result".to_string(), Value::Array(all_verified.clone()));
|
||||
|
||||
// Clear verified commands from database after sending
|
||||
if let Err(e) = crate::db::repositories::commands::clear_verified(&state.db).await {
|
||||
warn!("Failed to clear verified commands from database: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(response_json)
|
||||
@@ -175,4 +224,29 @@ fn decrypt_and_format(body_text: &str, key: &str, iv: &str) -> anyhow::Result<St
|
||||
let decrypted = crypto::decrypt(body_text, key, iv)?;
|
||||
let formatted: Value = serde_json::from_str(&decrypted)?;
|
||||
Ok(serde_json::to_string_pretty(&formatted)?)
|
||||
}
|
||||
}
|
||||
|
||||
async fn maybe_intercept_response(
|
||||
method: &str,
|
||||
_original_response: &str,
|
||||
state: &Arc<AppState>,
|
||||
) -> anyhow::Result<Option<String>> {
|
||||
// Check if there's an interception rule for this method
|
||||
let rule = crate::db::repositories::rules::find_by_method(&state.db, method).await?;
|
||||
|
||||
match rule {
|
||||
Some(r) if r.action == "replace" => {
|
||||
if let Some(custom_response) = &r.custom_response {
|
||||
Ok(crypto::encrypt(custom_response, &state.key, &state.iv).map(Some)?)
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
Some(r) if r.action == "modify" => {
|
||||
// Future: Apply transformations
|
||||
// For now, just pass through
|
||||
Ok(None)
|
||||
}
|
||||
_ => Ok(None), // Passthrough
|
||||
}
|
||||
}
|
||||
|
||||
33
src/state.rs
Normal file
33
src/state.rs
Normal file
@@ -0,0 +1,33 @@
|
||||
use serde_json::Value;
|
||||
use sqlx::SqlitePool;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
pub struct AppState {
|
||||
pub client: reqwest::Client,
|
||||
pub target_url: reqwest::Url,
|
||||
pub key: String,
|
||||
pub iv: String,
|
||||
pub jwt_secret: String,
|
||||
pub db: SqlitePool,
|
||||
pub commands: Commands,
|
||||
}
|
||||
|
||||
pub struct Commands {
|
||||
pub unverified: RwLock<Vec<Value>>,
|
||||
pub verified: RwLock<Vec<Value>>,
|
||||
}
|
||||
|
||||
impl Commands {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
unverified: RwLock::default(),
|
||||
verified: RwLock::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Commands {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user