feat: web frontend; middleware; serde (WIP?)

This commit is contained in:
2025-11-30 09:41:37 +08:00
parent be35040e26
commit 531ac029af
45 changed files with 6806 additions and 82 deletions

View File

@@ -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

View File

@@ -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
View File

@@ -4,3 +4,5 @@
/target
/*.log
data

1184
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -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"
@@ -22,3 +23,10 @@ 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"] }
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"

View File

@@ -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
View 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
View 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

File diff suppressed because it is too large Load Diff

23
frontend/package.json Normal file
View 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"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

96
frontend/src/App.tsx Normal file
View 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
View 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
View 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,
}),
}),
};

View 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;

View 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;

View 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;

View 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
View 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);

View 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;
}

View 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;
}

View 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
View 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
View 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,
}
}
}
});

View 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');

View 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)');

View 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
View 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, &current_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
View 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
View 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
View 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
View 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
View 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)
}

View File

@@ -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
View 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
View 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,
}

View 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(())
}

View 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(())
}

View File

@@ -0,0 +1,3 @@
pub mod commands;
pub mod config;
pub mod rules;

View 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(())
}

View File

@@ -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)]
pub struct Response {
pub result: Option<Value>,
pub params: Option<HashMap<String, Value>>,
pub id: Value,
pub jsonrpc: Option<String>
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 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
View 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>,
}

View File

@@ -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);

View File

@@ -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)
@@ -176,3 +225,28 @@ fn decrypt_and_format(body_text: &str, key: &str, iv: &str) -> anyhow::Result<St
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
View 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()
}
}