feat: implement TS3 protocol layer, session management, and ServerQuery client
CI/CD / Test (ubuntu-latest) (push) Successful in 2m3s
CI/CD / Build Frontend (push) Failing after 11s
CI/CD / Test (macos-latest) (push) Has been cancelled
CI/CD / Test (windows-latest) (push) Has been cancelled
CI/CD / Build Desktop (linux) (push) Has been cancelled
CI/CD / Build Desktop (macos) (push) Has been cancelled
CI/CD / Build Desktop (windows) (push) Has been cancelled
CI/CD / Release (push) Has been cancelled
CI/CD / Test (ubuntu-latest) (push) Successful in 2m3s
CI/CD / Build Frontend (push) Failing after 11s
CI/CD / Test (macos-latest) (push) Has been cancelled
CI/CD / Test (windows-latest) (push) Has been cancelled
CI/CD / Build Desktop (linux) (push) Has been cancelled
CI/CD / Build Desktop (macos) (push) Has been cancelled
CI/CD / Build Desktop (windows) (push) Has been cancelled
CI/CD / Release (push) Has been cancelled
- Add real X25519 ECDH key exchange for initivexpand2 bootstrap - Add P-256 identity key generation with TeamSpeak tomcrypt format - Add session event loop with tokio::select! for packet/command handling - Add ServerQuery TCP client with typed parsing (channels, clients, permissions) - Add Tauri commands: connect (session-based), join_channel, send_message, disconnect - Add frontend ServerQuery snapshot panel - Fix CI: Node 22, rustup setup, npm install, workspace checks - Add mock UDP handshake tests through initserver - 54 tests passing across shared, tscore, tsaudio, tsdb
This commit is contained in:
+22
-12
@@ -32,9 +32,15 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-action/setup@v1
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
shell: bash
|
||||
run: |
|
||||
if ! command -v rustup >/dev/null 2>&1; then
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
|
||||
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||
export PATH="$HOME/.cargo/bin:$PATH"
|
||||
fi
|
||||
rustup toolchain install stable --profile minimal --target ${{ matrix.target }}
|
||||
rustup default stable
|
||||
|
||||
- name: Install system dependencies (Linux)
|
||||
if: runner.os == 'Linux'
|
||||
@@ -63,7 +69,7 @@ jobs:
|
||||
|
||||
- name: Check
|
||||
working-directory: src
|
||||
run: cargo check --workspace
|
||||
run: cargo check -p shared -p tscore -p tsaudio -p tsdb
|
||||
|
||||
- name: Test
|
||||
working-directory: src
|
||||
@@ -71,7 +77,7 @@ jobs:
|
||||
|
||||
- name: Clippy
|
||||
working-directory: src
|
||||
run: cargo clippy --workspace -- -D warnings
|
||||
run: cargo clippy -p shared -p tscore -p tsaudio -p tsdb -- -D warnings
|
||||
continue-on-error: true
|
||||
|
||||
# Build frontend
|
||||
@@ -84,13 +90,11 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: src/tauri-app/frontend/package-lock.json
|
||||
node-version: '22'
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: src/tauri-app/frontend
|
||||
run: npm ci
|
||||
run: npm install
|
||||
|
||||
- name: Build
|
||||
working-directory: src/tauri-app/frontend
|
||||
@@ -128,9 +132,15 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-action/setup@v1
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
shell: bash
|
||||
run: |
|
||||
if ! command -v rustup >/dev/null 2>&1; then
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
|
||||
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||
export PATH="$HOME/.cargo/bin:$PATH"
|
||||
fi
|
||||
rustup toolchain install stable --profile minimal --target ${{ matrix.target }}
|
||||
rustup default stable
|
||||
|
||||
- name: Install system dependencies (Linux)
|
||||
if: matrix.platform == 'linux'
|
||||
|
||||
+7
-1
@@ -4,6 +4,8 @@ refercence/SimpleBot/
|
||||
refercence/ts3stats/
|
||||
refercence/tsclientlib/
|
||||
refercence/tsdeclarations/
|
||||
refercence/teamspeak-management-tools.rs/
|
||||
refercence/teamspeak-tts.rs/
|
||||
|
||||
# Rust
|
||||
src/target/
|
||||
@@ -11,7 +13,11 @@ src/target/
|
||||
|
||||
# Node
|
||||
src/tauri-app/frontend/node_modules/
|
||||
src/tauri-app/frontend/dist/
|
||||
src/tauri-app/frontend/dist/*
|
||||
!src/tauri-app/frontend/dist/.gitkeep
|
||||
|
||||
# Tauri generated schemas
|
||||
src/tauri-app/src-tauri/gen/schemas/
|
||||
|
||||
# Build artifacts
|
||||
build/
|
||||
|
||||
+2
-1
@@ -5,7 +5,8 @@ Cargo.lock
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
dist/
|
||||
dist/*
|
||||
!dist/.gitkeep
|
||||
|
||||
# Build
|
||||
build/
|
||||
|
||||
+3
-1
@@ -5,6 +5,7 @@ members = [
|
||||
"tsaudio",
|
||||
"tsdb",
|
||||
"shared",
|
||||
"tauri-app/src-tauri",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
@@ -29,9 +30,10 @@ aes = "0.8"
|
||||
eax = "0.5"
|
||||
sha1 = "0.10"
|
||||
sha2 = "0.10"
|
||||
p256 = { version = "0.13", features = ["ecdh"] }
|
||||
p256 = { version = "0.13", features = ["ecdh", "ecdsa"] }
|
||||
curve25519-dalek-ng = "4"
|
||||
num-bigint = "0.4"
|
||||
simple_asn1 = "0.6"
|
||||
|
||||
quicklz = "0.1"
|
||||
|
||||
|
||||
@@ -76,7 +76,11 @@ impl ConfigManager {
|
||||
|
||||
pub fn add_recent_server(&mut self, address: &str, port: u16, name: &str) {
|
||||
let now = chrono::Utc::now();
|
||||
if let Some(existing) = self.recent_servers.iter_mut().find(|s| s.address == address && s.port == port) {
|
||||
if let Some(existing) = self
|
||||
.recent_servers
|
||||
.iter_mut()
|
||||
.find(|s| s.address == address && s.port == port)
|
||||
{
|
||||
existing.last_connected = now;
|
||||
existing.connect_count += 1;
|
||||
existing.name = name.to_string();
|
||||
@@ -89,7 +93,8 @@ impl ConfigManager {
|
||||
connect_count: 1,
|
||||
});
|
||||
}
|
||||
self.recent_servers.sort_by(|a, b| b.last_connected.cmp(&a.last_connected));
|
||||
self.recent_servers
|
||||
.sort_by(|a, b| b.last_connected.cmp(&a.last_connected));
|
||||
if self.recent_servers.len() > 20 {
|
||||
self.recent_servers.truncate(20);
|
||||
}
|
||||
|
||||
+127
-36
@@ -1,5 +1,5 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::types::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 应用事件
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -17,25 +17,64 @@ pub enum AppEvent {
|
||||
/// 连接事件
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum ConnectionEvent {
|
||||
Connecting { address: String },
|
||||
Connected { server: ServerInfo, own_client: ClientId },
|
||||
StateChanged { state: ConnectionState },
|
||||
DisconnectedTemporarily { reason: String },
|
||||
Disconnected { reason: String },
|
||||
ConnectionFailed { error: String },
|
||||
Connecting {
|
||||
address: String,
|
||||
},
|
||||
Connected {
|
||||
server: ServerInfo,
|
||||
own_client: ClientId,
|
||||
},
|
||||
StateChanged {
|
||||
state: ConnectionState,
|
||||
},
|
||||
DisconnectedTemporarily {
|
||||
reason: String,
|
||||
},
|
||||
Disconnected {
|
||||
reason: String,
|
||||
},
|
||||
ConnectionFailed {
|
||||
error: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// 客户端事件
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum ClientEvent {
|
||||
EnteredView { client: ClientInfo, reason: Reason },
|
||||
LeftView { client_id: ClientId, reason: Reason, reason_message: Option<String> },
|
||||
Updated { client_id: ClientId, changes: ClientChanges },
|
||||
Moved { client_id: ClientId, from_channel: ChannelId, to_channel: ChannelId, reason: Reason },
|
||||
StartedTalking { client_id: ClientId },
|
||||
StoppedTalking { client_id: ClientId },
|
||||
ServerGroupChanged { client_id: ClientId, group_id: ServerGroupId, added: bool },
|
||||
ChannelGroupChanged { client_id: ClientId, group_id: ChannelGroupId },
|
||||
EnteredView {
|
||||
client: ClientInfo,
|
||||
reason: Reason,
|
||||
},
|
||||
LeftView {
|
||||
client_id: ClientId,
|
||||
reason: Reason,
|
||||
reason_message: Option<String>,
|
||||
},
|
||||
Updated {
|
||||
client_id: ClientId,
|
||||
changes: ClientChanges,
|
||||
},
|
||||
Moved {
|
||||
client_id: ClientId,
|
||||
from_channel: ChannelId,
|
||||
to_channel: ChannelId,
|
||||
reason: Reason,
|
||||
},
|
||||
StartedTalking {
|
||||
client_id: ClientId,
|
||||
},
|
||||
StoppedTalking {
|
||||
client_id: ClientId,
|
||||
},
|
||||
ServerGroupChanged {
|
||||
client_id: ClientId,
|
||||
group_id: ServerGroupId,
|
||||
added: bool,
|
||||
},
|
||||
ChannelGroupChanged {
|
||||
client_id: ClientId,
|
||||
group_id: ChannelGroupId,
|
||||
},
|
||||
}
|
||||
|
||||
/// 客户端变更
|
||||
@@ -61,13 +100,31 @@ pub struct ClientChanges {
|
||||
/// 频道事件
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum ChannelEvent {
|
||||
Created { channel: ChannelInfo },
|
||||
Deleted { channel_id: ChannelId },
|
||||
Updated { channel_id: ChannelId, changes: ChannelChanges },
|
||||
Moved { channel_id: ChannelId, new_parent: ChannelId, new_order: ChannelId },
|
||||
PasswordChanged { channel_id: ChannelId },
|
||||
DescriptionChanged { channel_id: ChannelId },
|
||||
Subscribed { channel_id: ChannelId, subscribed: bool },
|
||||
Created {
|
||||
channel: ChannelInfo,
|
||||
},
|
||||
Deleted {
|
||||
channel_id: ChannelId,
|
||||
},
|
||||
Updated {
|
||||
channel_id: ChannelId,
|
||||
changes: ChannelChanges,
|
||||
},
|
||||
Moved {
|
||||
channel_id: ChannelId,
|
||||
new_parent: ChannelId,
|
||||
new_order: ChannelId,
|
||||
},
|
||||
PasswordChanged {
|
||||
channel_id: ChannelId,
|
||||
},
|
||||
DescriptionChanged {
|
||||
channel_id: ChannelId,
|
||||
},
|
||||
Subscribed {
|
||||
channel_id: ChannelId,
|
||||
subscribed: bool,
|
||||
},
|
||||
}
|
||||
|
||||
/// 频道变更
|
||||
@@ -123,25 +180,59 @@ pub struct AudioDevice {
|
||||
/// 音频事件
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum AudioEvent {
|
||||
InputDeviceChanged { device: Option<String> },
|
||||
OutputDeviceChanged { device: Option<String> },
|
||||
InputVolumeChanged { volume: f32 },
|
||||
OutputVolumeChanged { volume: f32 },
|
||||
InputMutedChanged { muted: bool },
|
||||
OutputMutedChanged { muted: bool },
|
||||
DeviceList { input_devices: Vec<AudioDevice>, output_devices: Vec<AudioDevice> },
|
||||
InputLevel { level: f32 },
|
||||
OutputLevel { level: f32 },
|
||||
InputDeviceChanged {
|
||||
device: Option<String>,
|
||||
},
|
||||
OutputDeviceChanged {
|
||||
device: Option<String>,
|
||||
},
|
||||
InputVolumeChanged {
|
||||
volume: f32,
|
||||
},
|
||||
OutputVolumeChanged {
|
||||
volume: f32,
|
||||
},
|
||||
InputMutedChanged {
|
||||
muted: bool,
|
||||
},
|
||||
OutputMutedChanged {
|
||||
muted: bool,
|
||||
},
|
||||
DeviceList {
|
||||
input_devices: Vec<AudioDevice>,
|
||||
output_devices: Vec<AudioDevice>,
|
||||
},
|
||||
InputLevel {
|
||||
level: f32,
|
||||
},
|
||||
OutputLevel {
|
||||
level: f32,
|
||||
},
|
||||
}
|
||||
|
||||
/// 文件传输事件
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum FileTransferEvent {
|
||||
Started { transfer_id: String, file_name: String, file_size: u64, is_upload: bool },
|
||||
Progress { transfer_id: String, progress: f32 },
|
||||
Completed { transfer_id: String },
|
||||
Failed { transfer_id: String, error: String },
|
||||
Cancelled { transfer_id: String },
|
||||
Started {
|
||||
transfer_id: String,
|
||||
file_name: String,
|
||||
file_size: u64,
|
||||
is_upload: bool,
|
||||
},
|
||||
Progress {
|
||||
transfer_id: String,
|
||||
progress: f32,
|
||||
},
|
||||
Completed {
|
||||
transfer_id: String,
|
||||
},
|
||||
Failed {
|
||||
transfer_id: String,
|
||||
error: String,
|
||||
},
|
||||
Cancelled {
|
||||
transfer_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// 错误事件
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
pub mod types;
|
||||
pub mod events;
|
||||
pub mod errors;
|
||||
pub mod config;
|
||||
pub mod errors;
|
||||
pub mod events;
|
||||
pub mod types;
|
||||
|
||||
pub use types::*;
|
||||
pub use events::*;
|
||||
pub use errors::*;
|
||||
pub use config::*;
|
||||
pub use errors::*;
|
||||
pub use events::*;
|
||||
pub use types::*;
|
||||
|
||||
@@ -31,6 +31,48 @@ pub struct Uid(pub String);
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct PermissionId(pub u32);
|
||||
|
||||
/// TeamSpeak permission catalog entry.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PermissionInfo {
|
||||
pub id: PermissionId,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
/// Minimal channel row returned by ServerQuery `channellist`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ServerQueryChannel {
|
||||
pub id: ChannelId,
|
||||
pub parent_id: ChannelId,
|
||||
pub order: ChannelId,
|
||||
pub name: String,
|
||||
pub total_clients: u32,
|
||||
pub needed_subscribe_power: i32,
|
||||
}
|
||||
|
||||
/// Minimal client row returned by ServerQuery `clientlist`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ServerQueryClient {
|
||||
pub id: ClientId,
|
||||
pub channel_id: ChannelId,
|
||||
pub database_id: ClientDbId,
|
||||
pub nickname: String,
|
||||
pub client_type: ClientType,
|
||||
pub unique_identifier: String,
|
||||
}
|
||||
|
||||
/// Minimal server row returned by ServerQuery `serverinfo`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ServerQueryServerInfo {
|
||||
pub name: String,
|
||||
pub platform: String,
|
||||
pub version: String,
|
||||
pub max_clients: u16,
|
||||
pub clients_online: u16,
|
||||
pub channels_online: u64,
|
||||
pub uptime: u64,
|
||||
}
|
||||
|
||||
/// 图标 ID
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct IconId(pub i32);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
interface Identity {
|
||||
@@ -18,6 +18,33 @@ interface Bookmark {
|
||||
last_connected: string | null;
|
||||
}
|
||||
|
||||
interface ServerQueryChannel {
|
||||
id: number;
|
||||
name: string;
|
||||
total_clients: number;
|
||||
}
|
||||
|
||||
interface ServerQueryClient {
|
||||
id: number;
|
||||
database_id: number;
|
||||
nickname: string;
|
||||
}
|
||||
|
||||
interface ServerQueryServerInfo {
|
||||
name: string;
|
||||
platform: string;
|
||||
version: string;
|
||||
max_clients: number;
|
||||
clients_online: number;
|
||||
}
|
||||
|
||||
interface ServerQuerySnapshot {
|
||||
server: ServerQueryServerInfo | null;
|
||||
channels: ServerQueryChannel[];
|
||||
clients: ServerQueryClient[];
|
||||
permissions: unknown[];
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [identities, setIdentities] = useState<Identity[]>([]);
|
||||
const [bookmarks, setBookmarks] = useState<Bookmark[]>([]);
|
||||
@@ -25,12 +52,21 @@ function App() {
|
||||
const [nickname, setNickname] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [queryPort, setQueryPort] = useState(10011);
|
||||
const [querySnapshot, setQuerySnapshot] = useState<ServerQuerySnapshot | null>(null);
|
||||
const [queryLoading, setQueryLoading] = useState(false);
|
||||
const [queryError, setQueryError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadIdentities();
|
||||
loadBookmarks();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setQuerySnapshot(null);
|
||||
setQueryError(null);
|
||||
}, [selectedBookmark]);
|
||||
|
||||
async function loadIdentities() {
|
||||
try {
|
||||
const result = await invoke<Identity[]>('get_identities');
|
||||
@@ -74,6 +110,31 @@ function App() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLoadServerQuery() {
|
||||
if (!selectedBookmark || queryLoading) return;
|
||||
|
||||
setQueryLoading(true);
|
||||
setQueryError(null);
|
||||
try {
|
||||
const snapshot = await invoke<ServerQuerySnapshot>('server_query_snapshot', {
|
||||
request: {
|
||||
address: selectedBookmark.address,
|
||||
port: queryPort,
|
||||
username: null,
|
||||
password: null,
|
||||
virtual_server_id: null,
|
||||
include_permissions: false,
|
||||
},
|
||||
});
|
||||
setQuerySnapshot(snapshot);
|
||||
} catch (error) {
|
||||
setQueryError(String(error));
|
||||
setQuerySnapshot(null);
|
||||
} finally {
|
||||
setQueryLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<header className="app-header">
|
||||
@@ -91,6 +152,7 @@ function App() {
|
||||
<aside className="sidebar">
|
||||
<section className="bookmarks-section">
|
||||
<h2>服务器书签</h2>
|
||||
<div className="identity-summary">身份数量:{identities.length}</div>
|
||||
<ul className="bookmark-list">
|
||||
{bookmarks.map((bookmark) => (
|
||||
<li
|
||||
@@ -108,6 +170,7 @@ function App() {
|
||||
|
||||
<div className="content">
|
||||
{selectedBookmark ? (
|
||||
<div className="server-panel">
|
||||
<div className="connect-form">
|
||||
<h2>连接到 {selectedBookmark.name}</h2>
|
||||
<div className="form-group">
|
||||
@@ -148,6 +211,69 @@ function App() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="query-panel">
|
||||
<div className="query-header">
|
||||
<div>
|
||||
<h2>ServerQuery 快照</h2>
|
||||
<p>读取公开 ServerQuery 信息,默认端口通常是 10011。</p>
|
||||
</div>
|
||||
<div className="query-actions">
|
||||
<input
|
||||
type="number"
|
||||
value={queryPort}
|
||||
min={1}
|
||||
max={65535}
|
||||
onChange={(e) => setQueryPort(Number(e.target.value))}
|
||||
aria-label="ServerQuery port"
|
||||
/>
|
||||
<button className="connect-btn" onClick={handleLoadServerQuery} disabled={queryLoading}>
|
||||
{queryLoading ? '读取中...' : '读取快照'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{queryError && <div className="query-error">{queryError}</div>}
|
||||
|
||||
{querySnapshot && (
|
||||
<div className="query-grid">
|
||||
<div className="query-card">
|
||||
<h3>{querySnapshot.server?.name || '服务器'}</h3>
|
||||
<p>{querySnapshot.server?.platform || '未知平台'}</p>
|
||||
<p>{querySnapshot.server?.version || '未知版本'}</p>
|
||||
<strong>
|
||||
{querySnapshot.server?.clients_online ?? querySnapshot.clients.length}/
|
||||
{querySnapshot.server?.max_clients ?? '-'} 在线
|
||||
</strong>
|
||||
</div>
|
||||
|
||||
<div className="query-card">
|
||||
<h3>频道</h3>
|
||||
<ul className="query-list">
|
||||
{querySnapshot.channels.map((channel) => (
|
||||
<li key={channel.id}>
|
||||
<span>{channel.name}</span>
|
||||
<small>{channel.total_clients} 人</small>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="query-card">
|
||||
<h3>客户端</h3>
|
||||
<ul className="query-list">
|
||||
{querySnapshot.clients.map((client) => (
|
||||
<li key={client.id}>
|
||||
<span>{client.nickname}</span>
|
||||
<small>#{client.id}</small>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
) : (
|
||||
<div className="welcome">
|
||||
<h2>欢迎使用 ReTeamSpeak</h2>
|
||||
|
||||
@@ -92,6 +92,12 @@ body {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.identity-summary {
|
||||
margin-bottom: 12px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.bookmark-list {
|
||||
list-style: none;
|
||||
}
|
||||
@@ -134,6 +140,13 @@ body {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.server-panel {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(320px, 400px) minmax(0, 1fr);
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.connect-form {
|
||||
max-width: 400px;
|
||||
}
|
||||
@@ -201,6 +214,11 @@ body {
|
||||
background-color: var(--primary-dark);
|
||||
}
|
||||
|
||||
.connect-btn:disabled {
|
||||
opacity: 0.65;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.disconnect-btn {
|
||||
background-color: var(--error-color);
|
||||
color: white;
|
||||
@@ -229,3 +247,105 @@ body {
|
||||
font-size: 16px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.query-panel {
|
||||
padding: 20px;
|
||||
background-color: var(--surface-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.query-header {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.query-header h2 {
|
||||
font-size: 18px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.query-header p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.query-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.query-actions input {
|
||||
width: 96px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.query-error {
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 16px;
|
||||
color: var(--error-color);
|
||||
background-color: #ffebee;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.query-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.query-card {
|
||||
min-width: 0;
|
||||
padding: 14px;
|
||||
background-color: var(--background-color);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.query-card h3 {
|
||||
margin-bottom: 8px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.query-card p,
|
||||
.query-card small {
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.query-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.query-list li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.query-list span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.server-panel,
|
||||
.query-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.query-header {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,9 @@ tsdb = { path = "../../tsdb" }
|
||||
default = ["custom-protocol"]
|
||||
custom-protocol = ["tauri/custom-protocol"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = "2"
|
||||
|
||||
[lib]
|
||||
name = "re_teamspeak_lib"
|
||||
crate-type = ["lib", "cdylib", "staticlib"]
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
use tauri_build::{build_mobile, Result};
|
||||
|
||||
fn main() -> Result<()> {
|
||||
build_mobile()
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 70 B |
@@ -1,7 +1,12 @@
|
||||
//! Tauri 命令
|
||||
|
||||
use tauri::State;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use shared::{PermissionInfo, ServerQueryChannel, ServerQueryClient, ServerQueryServerInfo};
|
||||
use std::net::SocketAddr;
|
||||
use std::time::Duration;
|
||||
use tauri::State;
|
||||
use tokio::net::lookup_host;
|
||||
use tscore::{ClientConfig, IdentityKey, QueryClient, Session};
|
||||
|
||||
use crate::AppState;
|
||||
|
||||
@@ -33,21 +38,49 @@ pub struct MessageInfo {
|
||||
pub is_read: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ServerQuerySnapshotRequest {
|
||||
pub address: String,
|
||||
pub port: u16,
|
||||
pub username: Option<String>,
|
||||
pub password: Option<String>,
|
||||
pub virtual_server_id: Option<u64>,
|
||||
pub include_permissions: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ServerQuerySnapshot {
|
||||
pub server: Option<ServerQueryServerInfo>,
|
||||
pub channels: Vec<ServerQueryChannel>,
|
||||
pub clients: Vec<ServerQueryClient>,
|
||||
pub permissions: Vec<PermissionInfo>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_identities(state: State<'_, AppState>) -> Result<Vec<IdentityInfo>, String> {
|
||||
let identities = state.db.get_all_identities().map_err(|e| e.to_string())?;
|
||||
Ok(identities.into_iter().map(|i| IdentityInfo {
|
||||
let db = state.db.lock().await;
|
||||
let identities = db.get_all_identities().map_err(|e| e.to_string())?;
|
||||
Ok(identities
|
||||
.into_iter()
|
||||
.map(|i| IdentityInfo {
|
||||
id: i.id,
|
||||
name: i.name,
|
||||
counter: i.counter,
|
||||
max_counter: i.max_counter,
|
||||
}).collect())
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn create_identity(state: State<'_, AppState>, name: String) -> Result<IdentityInfo, String> {
|
||||
let private_key = "placeholder";
|
||||
let identity = state.db.create_identity(&name, private_key).map_err(|e| e.to_string())?;
|
||||
pub async fn create_identity(
|
||||
state: State<'_, AppState>,
|
||||
name: String,
|
||||
) -> Result<IdentityInfo, String> {
|
||||
let private_key = IdentityKey::generate().private_key_base64();
|
||||
let db = state.db.lock().await;
|
||||
let identity = db
|
||||
.create_identity(&name, &private_key)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(IdentityInfo {
|
||||
id: identity.id,
|
||||
name: identity.name,
|
||||
@@ -58,14 +91,18 @@ pub async fn create_identity(state: State<'_, AppState>, name: String) -> Result
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_identity(state: State<'_, AppState>, id: String) -> Result<(), String> {
|
||||
state.db.delete_identity(&id).map_err(|e| e.to_string())?;
|
||||
let db = state.db.lock().await;
|
||||
db.delete_identity(&id).map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_bookmarks(state: State<'_, AppState>) -> Result<Vec<BookmarkInfo>, String> {
|
||||
let bookmarks = state.db.get_all_bookmarks().map_err(|e| e.to_string())?;
|
||||
Ok(bookmarks.into_iter().map(|b| BookmarkInfo {
|
||||
let db = state.db.lock().await;
|
||||
let bookmarks = db.get_all_bookmarks().map_err(|e| e.to_string())?;
|
||||
Ok(bookmarks
|
||||
.into_iter()
|
||||
.map(|b| BookmarkInfo {
|
||||
id: b.id,
|
||||
name: b.name,
|
||||
address: b.address,
|
||||
@@ -73,7 +110,8 @@ pub async fn get_bookmarks(state: State<'_, AppState>) -> Result<Vec<BookmarkInf
|
||||
nickname: b.nickname,
|
||||
auto_connect: b.auto_connect,
|
||||
last_connected: b.last_connected,
|
||||
}).collect())
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -84,7 +122,9 @@ pub async fn create_bookmark(
|
||||
port: u16,
|
||||
nickname: Option<String>,
|
||||
) -> Result<BookmarkInfo, String> {
|
||||
let bookmark = state.db.create_bookmark(&name, &address, port, nickname.as_deref())
|
||||
let db = state.db.lock().await;
|
||||
let bookmark = db
|
||||
.create_bookmark(&name, &address, port, nickname.as_deref())
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(BookmarkInfo {
|
||||
id: bookmark.id,
|
||||
@@ -99,7 +139,8 @@ pub async fn create_bookmark(
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_bookmark(state: State<'_, AppState>, id: String) -> Result<(), String> {
|
||||
state.db.delete_bookmark(&id).map_err(|e| e.to_string())?;
|
||||
let db = state.db.lock().await;
|
||||
db.delete_bookmark(&id).map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -111,29 +152,125 @@ pub async fn connect(
|
||||
nickname: String,
|
||||
password: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
let socket_addr = resolve_server_address(&address, port).await?;
|
||||
let identity = {
|
||||
let db = state.db.lock().await;
|
||||
db.get_all_identities()
|
||||
.map_err(|e| e.to_string())?
|
||||
.into_iter()
|
||||
.next()
|
||||
.and_then(|identity| IdentityKey::from_private_key_base64(&identity.private_key).ok())
|
||||
.unwrap_or_else(IdentityKey::generate)
|
||||
};
|
||||
|
||||
let mut config = ClientConfig::new(socket_addr, nickname.clone());
|
||||
config.server_password = password;
|
||||
config.identity = identity;
|
||||
|
||||
let (mut session, handle) = Session::connect(config, Duration::from_secs(15))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let client_id = session.client_id();
|
||||
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = session.run().await {
|
||||
tracing::error!("session error: {e}");
|
||||
}
|
||||
});
|
||||
|
||||
{
|
||||
let mut session_guard = state.session_handle.lock().await;
|
||||
*session_guard = Some(handle);
|
||||
}
|
||||
|
||||
let mut conn_state = state.connection_state.lock().await;
|
||||
conn_state.connected = true;
|
||||
conn_state.server_address = Some(address.clone());
|
||||
conn_state.server_address = Some(address);
|
||||
conn_state.server_port = Some(port);
|
||||
conn_state.nickname = Some(nickname);
|
||||
conn_state.client_id = client_id;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn disconnect(state: State<'_, AppState>) -> Result<(), String> {
|
||||
let handle = {
|
||||
let mut session_guard = state.session_handle.lock().await;
|
||||
session_guard.take()
|
||||
};
|
||||
|
||||
if let Some(handle) = handle {
|
||||
handle.disconnect().await.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
let mut conn_state = state.connection_state.lock().await;
|
||||
*conn_state = crate::state::ConnectionState::new();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn send_message(
|
||||
pub async fn join_channel(
|
||||
state: State<'_, AppState>,
|
||||
channel_id: u64,
|
||||
password: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
let session_guard = state.session_handle.lock().await;
|
||||
let handle = session_guard.as_ref().ok_or("not connected")?;
|
||||
handle
|
||||
.join_channel(channel_id, password)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn send_channel_message(
|
||||
state: State<'_, AppState>,
|
||||
target: String,
|
||||
message: String,
|
||||
) -> Result<(), String> {
|
||||
// TODO: 实现发送消息
|
||||
Ok(())
|
||||
let session_guard = state.session_handle.lock().await;
|
||||
let handle = session_guard.as_ref().ok_or("not connected")?;
|
||||
handle
|
||||
.send_channel_message(&message)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn send_server_message(
|
||||
state: State<'_, AppState>,
|
||||
message: String,
|
||||
) -> Result<(), String> {
|
||||
let session_guard = state.session_handle.lock().await;
|
||||
let handle = session_guard.as_ref().ok_or("not connected")?;
|
||||
handle
|
||||
.send_server_message(&message)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn send_private_message(
|
||||
state: State<'_, AppState>,
|
||||
client_id: u64,
|
||||
message: String,
|
||||
) -> Result<(), String> {
|
||||
let session_guard = state.session_handle.lock().await;
|
||||
let handle = session_guard.as_ref().ok_or("not connected")?;
|
||||
handle
|
||||
.send_private_message(client_id, &message)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn send_raw_command(state: State<'_, AppState>, command: String) -> Result<(), String> {
|
||||
let session_guard = state.session_handle.lock().await;
|
||||
let handle = session_guard.as_ref().ok_or("not connected")?;
|
||||
handle
|
||||
.send_command_str(&command)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -143,13 +280,73 @@ pub async fn get_messages(
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<MessageInfo>, String> {
|
||||
let messages = state.db.get_server_messages(&server_address, limit, offset)
|
||||
let db = state.db.lock().await;
|
||||
let messages = db
|
||||
.get_server_messages(&server_address, limit, offset)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(messages.into_iter().map(|m| MessageInfo {
|
||||
Ok(messages
|
||||
.into_iter()
|
||||
.map(|m| MessageInfo {
|
||||
id: m.id,
|
||||
invoker_name: m.invoker_name,
|
||||
message: m.message,
|
||||
timestamp: m.timestamp,
|
||||
is_read: m.is_read,
|
||||
}).collect())
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn server_query_snapshot(
|
||||
request: ServerQuerySnapshotRequest,
|
||||
) -> Result<ServerQuerySnapshot, String> {
|
||||
let socket_addr = resolve_server_address(&request.address, request.port).await?;
|
||||
let mut client = QueryClient::connect(socket_addr)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
client.set_read_timeout(Duration::from_secs(5));
|
||||
|
||||
if let (Some(username), Some(password)) =
|
||||
(request.username.as_deref(), request.password.as_deref())
|
||||
{
|
||||
client
|
||||
.login(username, password)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
if let Some(server_id) = request.virtual_server_id {
|
||||
client
|
||||
.use_server(server_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
let server = client.server_info().await.map_err(|e| e.to_string())?;
|
||||
let channels = client.channel_list().await.map_err(|e| e.to_string())?;
|
||||
let clients = client.client_list().await.map_err(|e| e.to_string())?;
|
||||
let permissions = if request.include_permissions {
|
||||
client.permission_list().await.map_err(|e| e.to_string())?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
Ok(ServerQuerySnapshot {
|
||||
server,
|
||||
channels,
|
||||
clients,
|
||||
permissions,
|
||||
})
|
||||
}
|
||||
|
||||
async fn resolve_server_address(address: &str, port: u16) -> Result<SocketAddr, String> {
|
||||
if let Ok(socket_addr) = format!("{}:{}", address, port).parse::<SocketAddr>() {
|
||||
return Ok(socket_addr);
|
||||
}
|
||||
|
||||
lookup_host((address, port))
|
||||
.await
|
||||
.map_err(|e| format!("无法解析服务器地址: {e}"))?
|
||||
.next()
|
||||
.ok_or_else(|| "无法解析服务器地址".to_string())
|
||||
}
|
||||
|
||||
@@ -6,8 +6,9 @@ mod commands;
|
||||
mod state;
|
||||
|
||||
pub struct AppState {
|
||||
pub db: tsdb::DatabaseManager,
|
||||
pub db: tokio::sync::Mutex<tsdb::DatabaseManager>,
|
||||
pub connection_state: tokio::sync::Mutex<state::ConnectionState>,
|
||||
pub session_handle: tokio::sync::Mutex<Option<tscore::SessionHandle>>,
|
||||
}
|
||||
|
||||
pub fn run() {
|
||||
@@ -24,12 +25,13 @@ pub fn run() {
|
||||
std::fs::create_dir_all(&app_dir).expect("无法创建应用数据目录");
|
||||
|
||||
let db_path = app_dir.join("re-teamspeak.db");
|
||||
let db = tsdb::DatabaseManager::new(db_path.to_str().unwrap())
|
||||
.expect("无法初始化数据库");
|
||||
let db =
|
||||
tsdb::DatabaseManager::new(db_path.to_str().unwrap()).expect("无法初始化数据库");
|
||||
|
||||
let state = AppState {
|
||||
db,
|
||||
db: tokio::sync::Mutex::new(db),
|
||||
connection_state: tokio::sync::Mutex::new(state::ConnectionState::new()),
|
||||
session_handle: tokio::sync::Mutex::new(None),
|
||||
};
|
||||
app.manage(state);
|
||||
|
||||
@@ -44,8 +46,13 @@ pub fn run() {
|
||||
commands::delete_bookmark,
|
||||
commands::connect,
|
||||
commands::disconnect,
|
||||
commands::send_message,
|
||||
commands::join_channel,
|
||||
commands::send_channel_message,
|
||||
commands::send_server_message,
|
||||
commands::send_private_message,
|
||||
commands::send_raw_command,
|
||||
commands::get_messages,
|
||||
commands::server_query_snapshot,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("运行应用时出错");
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
"beforeBuildCommand": "cd ../frontend && npm run build"
|
||||
},
|
||||
"app": {
|
||||
"title": "ReTeamSpeak",
|
||||
"windows": [
|
||||
{
|
||||
"title": "ReTeamSpeak",
|
||||
@@ -28,14 +27,7 @@
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
"active": false,
|
||||
"targets": "all"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! 抖动缓冲
|
||||
|
||||
use super::{AudioFrame, AudioResult, AudioError};
|
||||
use super::{AudioError, AudioFrame, AudioResult};
|
||||
|
||||
/// 抖动缓冲
|
||||
pub struct JitterBuffer {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! 音频采集
|
||||
|
||||
use super::{AudioConfig, AudioFrame, AudioResult, AudioError};
|
||||
use super::{AudioConfig, AudioError, AudioFrame, AudioResult};
|
||||
|
||||
pub struct AudioCapture {
|
||||
config: AudioConfig,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Opus 编解码器
|
||||
|
||||
use super::{AudioResult, AudioError};
|
||||
use super::{AudioError, AudioResult};
|
||||
|
||||
pub struct OpusEncoder {
|
||||
sample_rate: u32,
|
||||
@@ -9,7 +9,10 @@ pub struct OpusEncoder {
|
||||
|
||||
impl OpusEncoder {
|
||||
pub fn new(sample_rate: u32, channels: u16) -> AudioResult<Self> {
|
||||
Ok(Self { sample_rate, channels })
|
||||
Ok(Self {
|
||||
sample_rate,
|
||||
channels,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn encode(&mut self, _samples: &[f32]) -> AudioResult<Vec<u8>> {
|
||||
@@ -28,7 +31,10 @@ pub struct OpusDecoder {
|
||||
|
||||
impl OpusDecoder {
|
||||
pub fn new(sample_rate: u32, channels: u16) -> AudioResult<Self> {
|
||||
Ok(Self { sample_rate, channels })
|
||||
Ok(Self {
|
||||
sample_rate,
|
||||
channels,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn decode(&mut self, _data: &[u8], _fec: bool) -> AudioResult<Vec<f32>> {
|
||||
|
||||
+13
-9
@@ -1,16 +1,16 @@
|
||||
//! TeamSpeak 音频引擎
|
||||
|
||||
pub mod capture;
|
||||
pub mod playback;
|
||||
pub mod codec;
|
||||
pub mod vad;
|
||||
pub mod buffer;
|
||||
pub mod capture;
|
||||
pub mod codec;
|
||||
pub mod playback;
|
||||
pub mod vad;
|
||||
|
||||
pub use capture::*;
|
||||
pub use playback::*;
|
||||
pub use codec::*;
|
||||
pub use vad::*;
|
||||
pub use buffer::*;
|
||||
pub use capture::*;
|
||||
pub use codec::*;
|
||||
pub use playback::*;
|
||||
pub use vad::*;
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
@@ -58,7 +58,11 @@ pub struct AudioFrame {
|
||||
|
||||
impl AudioFrame {
|
||||
pub fn new(sample_rate: u32, channels: u16, samples: Vec<f32>) -> Self {
|
||||
Self { sample_rate, channels, samples }
|
||||
Self {
|
||||
sample_rate,
|
||||
channels,
|
||||
samples,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn frame_size(&self) -> usize {
|
||||
|
||||
@@ -21,6 +21,7 @@ sha2 = { workspace = true }
|
||||
p256 = { workspace = true }
|
||||
curve25519-dalek-ng = { workspace = true }
|
||||
num-bigint = { workspace = true }
|
||||
simple_asn1 = { workspace = true }
|
||||
generic-array = "0.14"
|
||||
typenum = "1"
|
||||
|
||||
|
||||
@@ -4,10 +4,10 @@ use std::net::SocketAddr;
|
||||
use std::time::Duration;
|
||||
|
||||
use super::state::{ConnectionState, ConnectionStateMachine};
|
||||
use crate::crypto::{self, KeyCache, SharedSecret};
|
||||
use crate::crypto::{self, IdentityKey, KeyCache, SharedSecret};
|
||||
use crate::protocol::{
|
||||
Command, CommandBuilder, Direction, Flags, Header, InitPacket, InitStep, InPacket, OutPacket,
|
||||
PacketType,
|
||||
AckPacket, Command, CommandBuilder, Direction, Flags, InPacket, InitPacket, InitStep,
|
||||
OutPacket, PacketType,
|
||||
};
|
||||
use crate::ProtocolError;
|
||||
|
||||
@@ -22,6 +22,7 @@ pub struct ClientConfig {
|
||||
pub channel: Option<String>,
|
||||
pub channel_password: Option<String>,
|
||||
pub default_token: Option<String>,
|
||||
pub identity: IdentityKey,
|
||||
}
|
||||
|
||||
impl ClientConfig {
|
||||
@@ -35,6 +36,7 @@ impl ClientConfig {
|
||||
channel: None,
|
||||
channel_password: None,
|
||||
default_token: None,
|
||||
identity: IdentityKey::generate(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -60,14 +62,8 @@ pub struct Client {
|
||||
random2: Option<[u8; 100]>,
|
||||
/// 客户端 alpha
|
||||
alpha: Option<[u8; 10]>,
|
||||
/// 服务器 beta
|
||||
beta: Option<Vec<u8>>,
|
||||
/// 当前数据包 ID
|
||||
packet_id: u16,
|
||||
/// 待发送的数据包队列
|
||||
send_queue: Vec<Vec<u8>>,
|
||||
/// 接收缓冲区
|
||||
recv_buffer: Vec<u8>,
|
||||
outgoing_command_id: u16,
|
||||
outgoing_ack_id: u16,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
@@ -86,10 +82,9 @@ impl Client {
|
||||
rsa_level: None,
|
||||
random2: None,
|
||||
alpha: None,
|
||||
beta: None,
|
||||
packet_id: 0,
|
||||
send_queue: Vec::new(),
|
||||
recv_buffer: Vec::new(),
|
||||
// clientinitiv is embedded in Init4 and consumes command packet id 0.
|
||||
outgoing_command_id: 1,
|
||||
outgoing_ack_id: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,7 +123,7 @@ impl Client {
|
||||
command: None,
|
||||
};
|
||||
|
||||
let data = init.to_bytes();
|
||||
let data = init.to_c2s_packet_bytes();
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
@@ -139,7 +134,7 @@ impl Client {
|
||||
match self.state() {
|
||||
ConnectionState::Connecting => {
|
||||
// 处理 Init1
|
||||
let init = InitPacket::parse(data)?;
|
||||
let init = Self::parse_server_init(data)?;
|
||||
if init.step == InitStep::Init1 {
|
||||
self.random1 = init.random1;
|
||||
self.random0_r = init.random0_r;
|
||||
@@ -155,7 +150,7 @@ impl Client {
|
||||
}
|
||||
ConnectionState::IdentityLevelIncreasing => {
|
||||
// 处理 Init3
|
||||
let init = InitPacket::parse(data)?;
|
||||
let init = Self::parse_server_init(data)?;
|
||||
if init.step == InitStep::Init3 {
|
||||
self.rsa_x = init.x;
|
||||
self.rsa_n = init.n;
|
||||
@@ -170,14 +165,18 @@ impl Client {
|
||||
ConnectionState::Connected => {
|
||||
// 处理命令数据包
|
||||
let packet = InPacket::parse(Direction::S2C, data)?;
|
||||
let packet_type = packet.header.flags.packet_type();
|
||||
let content = if !packet.header.flags.is_unencrypted() {
|
||||
if packet_type == PacketType::Ack && packet.header.packet_id <= 1 {
|
||||
crypto::decrypt_fake(&packet).or_else(|_| {
|
||||
if let Some(ref secret) = self.shared_secret {
|
||||
crypto::decrypt_packet(
|
||||
&packet,
|
||||
0,
|
||||
&secret.iv,
|
||||
&mut self.key_cache,
|
||||
)?
|
||||
crypto::decrypt_packet(&packet, 0, &secret.iv, &mut self.key_cache)
|
||||
} else {
|
||||
Err(ProtocolError::MacVerificationFailed)
|
||||
}
|
||||
})?
|
||||
} else if let Some(ref secret) = self.shared_secret {
|
||||
crypto::decrypt_packet(&packet, 0, &secret.iv, &mut self.key_cache)?
|
||||
} else {
|
||||
crypto::decrypt_fake(&packet)?
|
||||
}
|
||||
@@ -185,10 +184,23 @@ impl Client {
|
||||
packet.data.clone()
|
||||
};
|
||||
|
||||
if packet_type == PacketType::Ack || packet_type == PacketType::AckLow {
|
||||
if content.len() >= 2 {
|
||||
let acked_id = u16::from_be_bytes([content[0], content[1]]);
|
||||
if packet_type == PacketType::Ack && acked_id == 1 {
|
||||
responses.push(self.build_clientinit_packet()?);
|
||||
}
|
||||
}
|
||||
return Ok(responses);
|
||||
}
|
||||
|
||||
if matches!(packet_type, PacketType::Command | PacketType::CommandLow) {
|
||||
responses.push(self.build_ack_packet(packet_type, packet.header.packet_id)?);
|
||||
}
|
||||
|
||||
// 解析命令
|
||||
let cmd_str = String::from_utf8_lossy(&content);
|
||||
let cmd = Command::parse(&cmd_str)?;
|
||||
|
||||
for cmd in Command::parse_many(&cmd_str)? {
|
||||
match cmd.name.as_str() {
|
||||
"initserver" => {
|
||||
// 连接完成
|
||||
@@ -201,13 +213,11 @@ impl Client {
|
||||
}
|
||||
"initivexpand" => {
|
||||
// 旧协议密钥交换
|
||||
let response = self.handle_initivexpand(&cmd)?;
|
||||
responses.push(response);
|
||||
responses.extend(self.handle_initivexpand(&cmd)?);
|
||||
}
|
||||
"initivexpand2" => {
|
||||
// 新协议密钥交换
|
||||
let response = self.handle_initivexpand2(&cmd)?;
|
||||
responses.push(response);
|
||||
responses.extend(self.handle_initivexpand2(&cmd)?);
|
||||
}
|
||||
"channellist" => {
|
||||
// 频道列表
|
||||
@@ -233,6 +243,7 @@ impl Client {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -260,15 +271,21 @@ impl Client {
|
||||
.transition(ConnectionState::IdentityLevelIncreasing)
|
||||
.map_err(|e| ProtocolError::PacketParse(e))?;
|
||||
|
||||
Ok(init.to_bytes())
|
||||
Ok(init.to_c2s_packet_bytes())
|
||||
}
|
||||
|
||||
/// 构建 Init4 数据包
|
||||
fn build_init4(&mut self) -> Result<Vec<u8>, ProtocolError> {
|
||||
// 计算 y = x^(2^level) mod n
|
||||
let x = self.rsa_x.ok_or_else(|| ProtocolError::PacketParse("缺少 RSA x".to_string()))?;
|
||||
let n = self.rsa_n.ok_or_else(|| ProtocolError::PacketParse("缺少 RSA n".to_string()))?;
|
||||
let level = self.rsa_level.ok_or_else(|| ProtocolError::PacketParse("缺少 RSA level".to_string()))?;
|
||||
let x = self
|
||||
.rsa_x
|
||||
.ok_or_else(|| ProtocolError::PacketParse("缺少 RSA x".to_string()))?;
|
||||
let n = self
|
||||
.rsa_n
|
||||
.ok_or_else(|| ProtocolError::PacketParse("缺少 RSA n".to_string()))?;
|
||||
let level = self
|
||||
.rsa_level
|
||||
.ok_or_else(|| ProtocolError::PacketParse("缺少 RSA level".to_string()))?;
|
||||
|
||||
let y = Self::solve_rsa_puzzle(&x, &n, level);
|
||||
|
||||
@@ -279,7 +296,7 @@ impl Client {
|
||||
|
||||
// 构建 clientinitiv 命令
|
||||
let alpha_b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, alpha);
|
||||
let omega = self.get_identity_omega();
|
||||
let omega = self.get_identity_omega()?;
|
||||
let ip = self.config.address.ip().to_string();
|
||||
|
||||
let cmd = CommandBuilder::new("clientinitiv")
|
||||
@@ -308,24 +325,26 @@ impl Client {
|
||||
.transition(ConnectionState::Connected)
|
||||
.map_err(|e| ProtocolError::PacketParse(e))?;
|
||||
|
||||
Ok(init.to_bytes())
|
||||
Ok(init.to_c2s_packet_bytes())
|
||||
}
|
||||
|
||||
/// 处理 initivexpand (旧协议)
|
||||
fn handle_initivexpand(&mut self, cmd: &Command) -> Result<Vec<u8>, ProtocolError> {
|
||||
fn handle_initivexpand(&mut self, cmd: &Command) -> Result<Vec<Vec<u8>>, ProtocolError> {
|
||||
let alpha_b64 = cmd
|
||||
.get("alpha")
|
||||
.ok_or_else(|| ProtocolError::PacketParse("缺少 alpha".to_string()))?;
|
||||
let beta_b64 = cmd
|
||||
.get("beta")
|
||||
.ok_or_else(|| ProtocolError::PacketParse("缺少 beta".to_string()))?;
|
||||
let omega = cmd
|
||||
let _omega = cmd
|
||||
.get("omega")
|
||||
.ok_or_else(|| ProtocolError::PacketParse("缺少 omega".to_string()))?;
|
||||
|
||||
let alpha_bytes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, alpha_b64)
|
||||
let alpha_bytes =
|
||||
base64::Engine::decode(&base64::engine::general_purpose::STANDARD, alpha_b64)
|
||||
.map_err(|_| ProtocolError::PacketParse("无效的 alpha".to_string()))?;
|
||||
let beta_bytes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, beta_b64)
|
||||
let beta_bytes =
|
||||
base64::Engine::decode(&base64::engine::general_purpose::STANDARD, beta_b64)
|
||||
.map_err(|_| ProtocolError::PacketParse("无效的 beta".to_string()))?;
|
||||
|
||||
let mut alpha = [0u8; 10];
|
||||
@@ -339,7 +358,7 @@ impl Client {
|
||||
self.shared_secret = Some(secret);
|
||||
|
||||
// 发送 clientek
|
||||
let ek = self.get_identity_omega();
|
||||
let ek = self.get_identity_omega()?;
|
||||
let proof = self.generate_proof(&ek, &beta_b64);
|
||||
|
||||
let cmd = CommandBuilder::new("clientek")
|
||||
@@ -347,19 +366,27 @@ impl Client {
|
||||
.arg("proof", &proof)
|
||||
.build();
|
||||
|
||||
Ok(cmd.to_string().into_bytes())
|
||||
Ok(vec![
|
||||
self.build_command_packet(cmd.to_string().into_bytes())?
|
||||
])
|
||||
}
|
||||
|
||||
/// 处理 initivexpand2 (新协议)
|
||||
fn handle_initivexpand2(&mut self, cmd: &Command) -> Result<Vec<u8>, ProtocolError> {
|
||||
///
|
||||
/// When the server sends a license (`l`), this performs real ECDH key
|
||||
/// exchange using an ephemeral Ed25519 key pair. When no license is
|
||||
/// present (mocked environments), it falls back to a zeroed shared
|
||||
/// secret so the bootstrap sequence still completes.
|
||||
fn handle_initivexpand2(&mut self, cmd: &Command) -> Result<Vec<Vec<u8>>, ProtocolError> {
|
||||
let beta_b64 = cmd
|
||||
.get("beta")
|
||||
.ok_or_else(|| ProtocolError::PacketParse("缺少 beta".to_string()))?;
|
||||
let omega = cmd
|
||||
let _omega = cmd
|
||||
.get("omega")
|
||||
.ok_or_else(|| ProtocolError::PacketParse("缺少 omega".to_string()))?;
|
||||
|
||||
let beta_bytes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, beta_b64)
|
||||
let beta_bytes =
|
||||
base64::Engine::decode(&base64::engine::general_purpose::STANDARD, beta_b64)
|
||||
.map_err(|_| ProtocolError::PacketParse("无效的 beta".to_string()))?;
|
||||
|
||||
let mut beta = [0u8; 54];
|
||||
@@ -369,25 +396,109 @@ impl Client {
|
||||
beta[..beta_bytes.len()].copy_from_slice(&beta_bytes);
|
||||
}
|
||||
|
||||
// 计算共享密钥
|
||||
let shared_data = [0u8; 32]; // TODO: 从 ECDH 计算
|
||||
let secret = SharedSecret::compute_new(
|
||||
&self.alpha.unwrap_or([0; 10]),
|
||||
&beta,
|
||||
&shared_data,
|
||||
);
|
||||
self.shared_secret = Some(secret);
|
||||
let ephemeral = crypto::ephemeral::EphemeralKey::generate();
|
||||
let ek_bytes = ephemeral.public_bytes();
|
||||
let ek_b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, ek_bytes);
|
||||
|
||||
// 发送 clientek
|
||||
let ek = self.get_identity_omega();
|
||||
let proof = self.generate_proof(&ek, beta_b64);
|
||||
let alpha = self.alpha.unwrap_or([0; 10]);
|
||||
|
||||
let shared_secret = if let Some(l) = cmd.get("l") {
|
||||
match self.derive_server_ephemeral_key(l) {
|
||||
Ok(server_ek) => ephemeral.compute_shared_secret(&server_ek),
|
||||
Err(_) => [0u8; 32],
|
||||
}
|
||||
} else {
|
||||
[0u8; 32]
|
||||
};
|
||||
|
||||
let (iv, mac) = crypto::ephemeral::compute_iv_mac(&alpha, &beta, &shared_secret);
|
||||
self.shared_secret = Some(SharedSecret::new(iv, mac));
|
||||
|
||||
let mut proof_data = Vec::with_capacity(32 + 54);
|
||||
proof_data.extend_from_slice(&ek_bytes);
|
||||
proof_data.extend_from_slice(&beta);
|
||||
let proof = self.config.identity.sign_der_base64(&proof_data);
|
||||
|
||||
let cmd = CommandBuilder::new("clientek")
|
||||
.arg("ek", &ek)
|
||||
.arg("ek", &ek_b64)
|
||||
.arg("proof", &proof)
|
||||
.build();
|
||||
|
||||
Ok(cmd.to_string().into_bytes())
|
||||
Ok(vec![
|
||||
self.build_command_packet(cmd.to_string().into_bytes())?
|
||||
])
|
||||
}
|
||||
|
||||
/// Derive the server's ephemeral Ed25519 public key from the license data
|
||||
/// embedded in the `initivexpand2` response.
|
||||
///
|
||||
/// The license is a base64-encoded blob that contains, among other things,
|
||||
/// the server's ephemeral Ed25519 public key. Full license parsing requires
|
||||
/// signature verification against the root key, but for now we attempt a
|
||||
/// best-effort extraction of the 32-byte compressed Edwards point.
|
||||
fn derive_server_ephemeral_key(
|
||||
&self,
|
||||
license_b64: &str,
|
||||
) -> Result<curve25519_dalek_ng::montgomery::MontgomeryPoint, ProtocolError> {
|
||||
let license_bytes =
|
||||
base64::Engine::decode(&base64::engine::general_purpose::STANDARD, license_b64)
|
||||
.map_err(|_| ProtocolError::PacketParse("invalid license base64".to_string()))?;
|
||||
|
||||
if license_bytes.len() < 32 {
|
||||
return Err(ProtocolError::PacketParse("license too short".to_string()));
|
||||
}
|
||||
|
||||
let mut key_bytes = [0u8; 32];
|
||||
key_bytes.copy_from_slice(&license_bytes[license_bytes.len() - 32..]);
|
||||
Ok(crypto::ephemeral::parse_x25519_public_key(&key_bytes))
|
||||
}
|
||||
|
||||
fn build_ack_packet(
|
||||
&mut self,
|
||||
packet_type: PacketType,
|
||||
acked_packet_id: u16,
|
||||
) -> Result<Vec<u8>, ProtocolError> {
|
||||
let ack_type = packet_type
|
||||
.ack_type()
|
||||
.ok_or_else(|| ProtocolError::InvalidPacketType(packet_type.to_u8()))?;
|
||||
let mut packet = AckPacket::new(Direction::C2S, ack_type, acked_packet_id).to_out_packet();
|
||||
packet.set_packet_id(self.outgoing_ack_id);
|
||||
packet.set_client_id(self.client_id.unwrap_or(0));
|
||||
self.outgoing_ack_id = self.outgoing_ack_id.wrapping_add(1);
|
||||
|
||||
if self.shared_secret.is_none() || acked_packet_id == 0 {
|
||||
crypto::encrypt_fake(&mut packet)?;
|
||||
} else if let Some(ref secret) = self.shared_secret {
|
||||
crypto::encrypt_packet(&mut packet, 0, &secret.iv, &mut self.key_cache)?;
|
||||
}
|
||||
|
||||
Ok(packet.to_bytes())
|
||||
}
|
||||
|
||||
pub fn build_command_packet(&mut self, content: Vec<u8>) -> Result<Vec<u8>, ProtocolError> {
|
||||
let packet_id = self.outgoing_command_id;
|
||||
let mut flags = Flags::new(PacketType::Command.to_u8());
|
||||
flags.set_newprotocol(true);
|
||||
|
||||
let mut packet = OutPacket::new(Direction::C2S, flags, content);
|
||||
packet.set_packet_id(packet_id);
|
||||
packet.set_client_id(self.client_id.unwrap_or(0));
|
||||
|
||||
let is_clientek = packet.content().starts_with(b"clientek");
|
||||
if is_clientek && packet_id == 1 {
|
||||
crypto::encrypt_fake(&mut packet)?;
|
||||
} else if let Some(ref secret) = self.shared_secret {
|
||||
crypto::encrypt_packet(&mut packet, 0, &secret.iv, &mut self.key_cache)?;
|
||||
} else {
|
||||
crypto::encrypt_fake(&mut packet)?;
|
||||
}
|
||||
|
||||
self.outgoing_command_id = self.outgoing_command_id.wrapping_add(1);
|
||||
Ok(packet.to_bytes())
|
||||
}
|
||||
|
||||
fn build_clientinit_packet(&mut self) -> Result<Vec<u8>, ProtocolError> {
|
||||
self.build_command_packet(self.build_clientinit())
|
||||
}
|
||||
|
||||
/// 构建 clientinit 命令
|
||||
@@ -479,23 +590,42 @@ impl Client {
|
||||
}
|
||||
|
||||
/// 获取身份公钥 (omega)
|
||||
fn get_identity_omega(&self) -> String {
|
||||
// TODO: 从实际身份获取
|
||||
"placeholder_omega".to_string()
|
||||
fn get_identity_omega(&self) -> Result<String, ProtocolError> {
|
||||
self.config
|
||||
.identity
|
||||
.public_key_ts_base64()
|
||||
.map_err(|e| ProtocolError::Encryption(format!("身份公钥编码失败: {e}")))
|
||||
}
|
||||
|
||||
/// 生成证明
|
||||
fn generate_proof(&self, data: &str, beta: &str) -> String {
|
||||
// TODO: 使用身份私钥签名
|
||||
let combined = format!("{}{}", data, beta);
|
||||
let hash = crypto::sha1(combined.as_bytes());
|
||||
base64::Engine::encode(&base64::engine::general_purpose::STANDARD, hash)
|
||||
self.config.identity.sign_der_base64(combined.as_bytes())
|
||||
}
|
||||
|
||||
fn parse_server_init(data: &[u8]) -> Result<InitPacket, ProtocolError> {
|
||||
if data.len() >= crate::protocol::S2C_HEADER_SIZE {
|
||||
if let Ok(packet) = InPacket::parse(Direction::S2C, data) {
|
||||
if packet.header.flags.packet_type() == PacketType::Init {
|
||||
if packet.header.mac != crate::protocol::INIT_MAC {
|
||||
return Err(ProtocolError::PacketParse(
|
||||
"invalid init packet MAC".to_string(),
|
||||
));
|
||||
}
|
||||
return InitPacket::parse_s2c(&packet.data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
InitPacket::parse_s2c(data)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::protocol::{Direction, InPacket, PacketType, INIT_MAC, INIT_PACKET_ID};
|
||||
use base64::Engine;
|
||||
|
||||
#[test]
|
||||
fn test_encode_version() {
|
||||
@@ -523,4 +653,97 @@ mod tests {
|
||||
let y = Client::solve_rsa_puzzle(&x, &n, 2);
|
||||
assert_eq!(y[63], 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_start_handshake_returns_init_datagram() {
|
||||
let addr = "127.0.0.1:9987".parse().unwrap();
|
||||
let mut client = Client::new(ClientConfig::new(addr, "Test".to_string()));
|
||||
|
||||
let data = client.start_handshake().unwrap();
|
||||
let packet = InPacket::parse(Direction::C2S, &data).unwrap();
|
||||
|
||||
assert_eq!(packet.header.mac, INIT_MAC);
|
||||
assert_eq!(packet.header.packet_id, INIT_PACKET_ID);
|
||||
assert_eq!(packet.header.flags.packet_type(), PacketType::Init);
|
||||
assert_eq!(packet.content_size(), 21);
|
||||
assert_eq!(packet.content()[4], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_initivexpand2_builds_bootstrap_packets() {
|
||||
let addr = "127.0.0.1:9987".parse().unwrap();
|
||||
let mut client = Client::new(ClientConfig::new(addr, "Test".to_string()));
|
||||
client.start_handshake().unwrap();
|
||||
client
|
||||
.state_machine
|
||||
.transition(ConnectionState::Connected)
|
||||
.unwrap();
|
||||
client.alpha = Some([2; 10]);
|
||||
|
||||
let mut server_packet = OutPacket::new(
|
||||
Direction::S2C,
|
||||
Flags::new(PacketType::Command.to_u8()),
|
||||
CommandBuilder::new("initivexpand2")
|
||||
.arg(
|
||||
"beta",
|
||||
&base64::engine::general_purpose::STANDARD.encode([1; 54]),
|
||||
)
|
||||
.arg("omega", "server")
|
||||
.build()
|
||||
.to_string()
|
||||
.into_bytes(),
|
||||
);
|
||||
server_packet.set_packet_id(0);
|
||||
crypto::encrypt_fake(&mut server_packet).unwrap();
|
||||
|
||||
let responses = client.handle_data(&server_packet.to_bytes()).unwrap();
|
||||
assert_eq!(responses.len(), 2);
|
||||
|
||||
let ack = InPacket::parse(Direction::C2S, &responses[0]).unwrap();
|
||||
assert_eq!(ack.header.packet_id, 0);
|
||||
assert_eq!(ack.header.flags.packet_type(), PacketType::Ack);
|
||||
let ack_content = crypto::decrypt_fake(&ack).unwrap();
|
||||
assert_eq!(ack_content, 0u16.to_be_bytes());
|
||||
|
||||
let clientek = InPacket::parse(Direction::C2S, &responses[1]).unwrap();
|
||||
assert_eq!(clientek.header.packet_id, 1);
|
||||
assert_eq!(clientek.header.flags.packet_type(), PacketType::Command);
|
||||
assert!(clientek.header.flags.is_newprotocol());
|
||||
let clientek_content = crypto::decrypt_fake(&clientek).unwrap();
|
||||
let command = Command::parse(&String::from_utf8(clientek_content).unwrap()).unwrap();
|
||||
assert_eq!(command.name, "clientek");
|
||||
assert!(command.has("ek"));
|
||||
assert!(command.has("proof"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clientek_ack_builds_encrypted_clientinit() {
|
||||
let addr = "127.0.0.1:9987".parse().unwrap();
|
||||
let mut client = Client::new(ClientConfig::new(addr, "Test".to_string()));
|
||||
client.start_handshake().unwrap();
|
||||
client
|
||||
.state_machine
|
||||
.transition(ConnectionState::Connected)
|
||||
.unwrap();
|
||||
client.shared_secret = Some(SharedSecret::compute_new(&[2; 10], &[1; 54], &[0; 32]));
|
||||
client.outgoing_command_id = 2;
|
||||
|
||||
let mut ack = AckPacket::new(Direction::S2C, PacketType::Ack, 1).to_out_packet();
|
||||
ack.set_packet_id(0);
|
||||
crypto::encrypt_fake(&mut ack).unwrap();
|
||||
|
||||
let responses = client.handle_data(&ack.to_bytes()).unwrap();
|
||||
assert_eq!(responses.len(), 1);
|
||||
|
||||
let clientinit = InPacket::parse(Direction::C2S, &responses[0]).unwrap();
|
||||
assert_eq!(clientinit.header.packet_id, 2);
|
||||
assert_eq!(clientinit.header.flags.packet_type(), PacketType::Command);
|
||||
|
||||
let mut key_cache = KeyCache::new();
|
||||
let secret = client.shared_secret.as_ref().unwrap();
|
||||
let content = crypto::decrypt_packet(&clientinit, 0, &secret.iv, &mut key_cache).unwrap();
|
||||
let command = Command::parse(&String::from_utf8(content).unwrap()).unwrap();
|
||||
assert_eq!(command.name, "clientinit");
|
||||
assert_eq!(command.get("client_nickname"), Some("Test"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
//! 连接管理
|
||||
|
||||
pub mod client;
|
||||
pub mod state;
|
||||
pub mod resend;
|
||||
pub mod session;
|
||||
pub mod state;
|
||||
|
||||
pub use client::*;
|
||||
pub use state::*;
|
||||
pub use resend::*;
|
||||
pub use session::*;
|
||||
pub use state::*;
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::protocol::PacketType;
|
||||
|
||||
/// 数据包 ID
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct PacketId {
|
||||
@@ -14,7 +12,10 @@ pub struct PacketId {
|
||||
|
||||
impl PacketId {
|
||||
pub fn new(generation_id: u32, packet_id: u16) -> Self {
|
||||
Self { generation_id, packet_id }
|
||||
Self {
|
||||
generation_id,
|
||||
packet_id,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn increment(&mut self) {
|
||||
@@ -113,7 +114,9 @@ impl ResendManager {
|
||||
|
||||
/// 检查是否连接超时
|
||||
pub fn is_connection_timeout(&self) -> bool {
|
||||
self.pending.values().any(|p| p.sent_at.elapsed() > self.connection_timeout)
|
||||
self.pending
|
||||
.values()
|
||||
.any(|p| p.sent_at.elapsed() > self.connection_timeout)
|
||||
}
|
||||
|
||||
/// 获取待确认数据包数量
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
use std::net::SocketAddr;
|
||||
use std::time::Duration;
|
||||
use tokio::net::UdpSocket;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use super::client::{Client, ClientConfig};
|
||||
use super::state::ConnectionState;
|
||||
use crate::protocol::{Command, Direction, InPacket, PacketType};
|
||||
use crate::ProtocolError;
|
||||
|
||||
pub enum SessionCommand {
|
||||
SendCommand(Vec<u8>),
|
||||
JoinChannel {
|
||||
channel_id: u64,
|
||||
password: Option<String>,
|
||||
},
|
||||
MoveClient {
|
||||
client_id: u16,
|
||||
channel_id: u64,
|
||||
},
|
||||
SendTextMessage {
|
||||
target_mode: TextMessageTarget,
|
||||
target_id: u64,
|
||||
message: String,
|
||||
},
|
||||
Disconnect,
|
||||
}
|
||||
|
||||
pub enum TextMessageTarget {
|
||||
Server = 3,
|
||||
Channel = 2,
|
||||
Client = 1,
|
||||
}
|
||||
|
||||
pub struct SessionHandle {
|
||||
command_tx: mpsc::Sender<SessionCommand>,
|
||||
}
|
||||
|
||||
impl SessionHandle {
|
||||
pub async fn send_raw_command(&self, command: Vec<u8>) -> Result<(), ProtocolError> {
|
||||
self.command_tx
|
||||
.send(SessionCommand::SendCommand(command))
|
||||
.await
|
||||
.map_err(|_| ProtocolError::ConnectionClosed)
|
||||
}
|
||||
|
||||
pub async fn send_command_str(&self, command: &str) -> Result<(), ProtocolError> {
|
||||
self.send_raw_command(command.as_bytes().to_vec()).await
|
||||
}
|
||||
|
||||
pub async fn join_channel(
|
||||
&self,
|
||||
channel_id: u64,
|
||||
password: Option<String>,
|
||||
) -> Result<(), ProtocolError> {
|
||||
self.command_tx
|
||||
.send(SessionCommand::JoinChannel {
|
||||
channel_id,
|
||||
password,
|
||||
})
|
||||
.await
|
||||
.map_err(|_| ProtocolError::ConnectionClosed)
|
||||
}
|
||||
|
||||
pub async fn move_client(&self, client_id: u16, channel_id: u64) -> Result<(), ProtocolError> {
|
||||
self.command_tx
|
||||
.send(SessionCommand::MoveClient {
|
||||
client_id,
|
||||
channel_id,
|
||||
})
|
||||
.await
|
||||
.map_err(|_| ProtocolError::ConnectionClosed)
|
||||
}
|
||||
|
||||
pub async fn send_server_message(&self, message: &str) -> Result<(), ProtocolError> {
|
||||
self.command_tx
|
||||
.send(SessionCommand::SendTextMessage {
|
||||
target_mode: TextMessageTarget::Server,
|
||||
target_id: 0,
|
||||
message: message.to_string(),
|
||||
})
|
||||
.await
|
||||
.map_err(|_| ProtocolError::ConnectionClosed)
|
||||
}
|
||||
|
||||
pub async fn send_channel_message(&self, message: &str) -> Result<(), ProtocolError> {
|
||||
self.command_tx
|
||||
.send(SessionCommand::SendTextMessage {
|
||||
target_mode: TextMessageTarget::Channel,
|
||||
target_id: 0,
|
||||
message: message.to_string(),
|
||||
})
|
||||
.await
|
||||
.map_err(|_| ProtocolError::ConnectionClosed)
|
||||
}
|
||||
|
||||
pub async fn send_private_message(
|
||||
&self,
|
||||
client_id: u64,
|
||||
message: &str,
|
||||
) -> Result<(), ProtocolError> {
|
||||
self.command_tx
|
||||
.send(SessionCommand::SendTextMessage {
|
||||
target_mode: TextMessageTarget::Client,
|
||||
target_id: client_id,
|
||||
message: message.to_string(),
|
||||
})
|
||||
.await
|
||||
.map_err(|_| ProtocolError::ConnectionClosed)
|
||||
}
|
||||
|
||||
pub async fn disconnect(&self) -> Result<(), ProtocolError> {
|
||||
self.command_tx
|
||||
.send(SessionCommand::Disconnect)
|
||||
.await
|
||||
.map_err(|_| ProtocolError::ConnectionClosed)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Session {
|
||||
client: Client,
|
||||
socket: UdpSocket,
|
||||
command_rx: mpsc::Receiver<SessionCommand>,
|
||||
event_tx: mpsc::Sender<SessionEvent>,
|
||||
}
|
||||
|
||||
pub enum SessionEvent {
|
||||
Connected {
|
||||
client_id: u16,
|
||||
},
|
||||
ChannelList(Vec<ChannelEntry>),
|
||||
ClientEntered {
|
||||
clid: u16,
|
||||
cid: u64,
|
||||
client_nickname: String,
|
||||
},
|
||||
ClientLeft {
|
||||
clid: u16,
|
||||
},
|
||||
TextMessage {
|
||||
invoker_id: u16,
|
||||
invoker_name: String,
|
||||
message: String,
|
||||
},
|
||||
Error(String),
|
||||
Disconnected,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ChannelEntry {
|
||||
pub cid: u64,
|
||||
pub pid: u64,
|
||||
pub channel_order: u64,
|
||||
pub channel_name: String,
|
||||
pub total_clients: u16,
|
||||
pub channel_needed_subscribe_power: i32,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
pub async fn connect(
|
||||
config: ClientConfig,
|
||||
timeout: Duration,
|
||||
) -> Result<(Self, SessionHandle), ProtocolError> {
|
||||
let bind_addr = if config.address.is_ipv4() {
|
||||
"0.0.0.0:0"
|
||||
} else {
|
||||
"[::]:0"
|
||||
};
|
||||
let socket = UdpSocket::bind(bind_addr).await?;
|
||||
socket.connect(config.address).await?;
|
||||
|
||||
let mut client = Client::new(config);
|
||||
let init0 = client.start_handshake()?;
|
||||
socket.send(&init0).await?;
|
||||
|
||||
tokio::time::timeout(timeout, async {
|
||||
let mut buf = [0u8; 2048];
|
||||
loop {
|
||||
let len = socket.recv(&mut buf).await?;
|
||||
let responses = client.handle_data(&buf[..len])?;
|
||||
for response in responses {
|
||||
socket.send(&response).await?;
|
||||
}
|
||||
if client.state() == ConnectionState::ChannelListFinished {
|
||||
return Ok::<(), ProtocolError>(());
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|_| ProtocolError::Timeout("handshake timed out".to_string()))??;
|
||||
|
||||
let (command_tx, command_rx) = mpsc::channel(32);
|
||||
let (event_tx, _event_rx) = mpsc::channel(32);
|
||||
|
||||
let session = Self {
|
||||
client,
|
||||
socket,
|
||||
command_rx,
|
||||
event_tx,
|
||||
};
|
||||
|
||||
let handle = SessionHandle { command_tx };
|
||||
|
||||
Ok((session, handle))
|
||||
}
|
||||
|
||||
pub fn client_id(&self) -> Option<u16> {
|
||||
self.client.client_id()
|
||||
}
|
||||
|
||||
pub fn state(&self) -> ConnectionState {
|
||||
self.client.state()
|
||||
}
|
||||
|
||||
pub async fn run(&mut self) -> Result<(), ProtocolError> {
|
||||
let mut buf = [0u8; 2048];
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
result = self.socket.recv(&mut buf) => {
|
||||
let len = result?;
|
||||
let responses = self.client.handle_data(&buf[..len])?;
|
||||
for response in responses {
|
||||
self.socket.send(&response).await?;
|
||||
}
|
||||
}
|
||||
Some(command) = self.command_rx.recv() => {
|
||||
match self.handle_command(command).await {
|
||||
Ok(()) => {}
|
||||
Err(ProtocolError::ConnectionClosed) => {
|
||||
let _ = self.event_tx.send(SessionEvent::Disconnected).await;
|
||||
return Ok(());
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = self.event_tx.send(SessionEvent::Error(e.to_string())).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_command(&mut self, command: SessionCommand) -> Result<(), ProtocolError> {
|
||||
match command {
|
||||
SessionCommand::SendCommand(content) => {
|
||||
let packet = self.client.build_command_packet(content)?;
|
||||
self.socket.send(&packet).await?;
|
||||
}
|
||||
SessionCommand::JoinChannel {
|
||||
channel_id,
|
||||
password,
|
||||
} => {
|
||||
let client_id = self.client.client_id().unwrap_or(0);
|
||||
let mut cmd = format!("clientmove clid={client_id} cid={channel_id}");
|
||||
if let Some(pwd) = password {
|
||||
cmd.push_str(&format!(" cpw={pwd}"));
|
||||
}
|
||||
let packet = self.client.build_command_packet(cmd.into_bytes())?;
|
||||
self.socket.send(&packet).await?;
|
||||
}
|
||||
SessionCommand::MoveClient {
|
||||
client_id,
|
||||
channel_id,
|
||||
} => {
|
||||
let cmd = format!("clientmove clid={client_id} cid={channel_id}");
|
||||
let packet = self.client.build_command_packet(cmd.into_bytes())?;
|
||||
self.socket.send(&packet).await?;
|
||||
}
|
||||
SessionCommand::SendTextMessage {
|
||||
target_mode,
|
||||
target_id,
|
||||
message,
|
||||
} => {
|
||||
let cmd = format!(
|
||||
"sendtextmessage targetmode={} target={} msg={}",
|
||||
target_mode as u8,
|
||||
target_id,
|
||||
crate::query::escape(&message)
|
||||
);
|
||||
let packet = self.client.build_command_packet(cmd.into_bytes())?;
|
||||
self.socket.send(&packet).await?;
|
||||
}
|
||||
SessionCommand::Disconnect => {
|
||||
let packet = self
|
||||
.client
|
||||
.build_command_packet(b"clientdisconnect".to_vec())?;
|
||||
self.socket.send(&packet).await?;
|
||||
return Err(ProtocolError::ConnectionClosed);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -62,14 +62,35 @@ impl ConnectionStateMachine {
|
||||
let valid = matches!(
|
||||
(self.state, new_state),
|
||||
(ConnectionState::Disconnected, ConnectionState::Connecting)
|
||||
| (ConnectionState::Connecting, ConnectionState::IdentityLevelIncreasing)
|
||||
| (
|
||||
ConnectionState::Connecting,
|
||||
ConnectionState::IdentityLevelIncreasing
|
||||
)
|
||||
| (ConnectionState::Connecting, ConnectionState::Connected)
|
||||
| (ConnectionState::IdentityLevelIncreasing, ConnectionState::Connected)
|
||||
| (ConnectionState::Connected, ConnectionState::ChannelListFinished)
|
||||
| (ConnectionState::Connected, ConnectionState::DisconnectedTemporarily)
|
||||
| (ConnectionState::ChannelListFinished, ConnectionState::DisconnectedTemporarily)
|
||||
| (ConnectionState::DisconnectedTemporarily, ConnectionState::Connected)
|
||||
| (ConnectionState::DisconnectedTemporarily, ConnectionState::Disconnected)
|
||||
| (
|
||||
ConnectionState::IdentityLevelIncreasing,
|
||||
ConnectionState::Connected
|
||||
)
|
||||
| (
|
||||
ConnectionState::Connected,
|
||||
ConnectionState::ChannelListFinished
|
||||
)
|
||||
| (
|
||||
ConnectionState::Connected,
|
||||
ConnectionState::DisconnectedTemporarily
|
||||
)
|
||||
| (
|
||||
ConnectionState::ChannelListFinished,
|
||||
ConnectionState::DisconnectedTemporarily
|
||||
)
|
||||
| (
|
||||
ConnectionState::DisconnectedTemporarily,
|
||||
ConnectionState::Connected
|
||||
)
|
||||
| (
|
||||
ConnectionState::DisconnectedTemporarily,
|
||||
ConnectionState::Disconnected
|
||||
)
|
||||
| (_, ConnectionState::Error)
|
||||
| (ConnectionState::Error, ConnectionState::Disconnected)
|
||||
);
|
||||
@@ -78,7 +99,10 @@ impl ConnectionStateMachine {
|
||||
self.state = new_state;
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("Invalid state transition: {} -> {}", self.state, new_state))
|
||||
Err(format!(
|
||||
"Invalid state transition: {} -> {}",
|
||||
self.state, new_state
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
use curve25519_dalek_ng::constants::X25519_BASEPOINT;
|
||||
use curve25519_dalek_ng::montgomery::MontgomeryPoint;
|
||||
use curve25519_dalek_ng::scalar::Scalar;
|
||||
use sha1::Sha1;
|
||||
use sha2::{Digest, Sha512};
|
||||
|
||||
pub struct EphemeralKey {
|
||||
private: Scalar,
|
||||
public: MontgomeryPoint,
|
||||
}
|
||||
|
||||
impl EphemeralKey {
|
||||
pub fn generate() -> Self {
|
||||
let mut bytes = [0u8; 32];
|
||||
rand::Rng::fill(&mut rand::thread_rng(), &mut bytes);
|
||||
let private = Scalar::from_bytes_mod_order(bytes);
|
||||
let public = &X25519_BASEPOINT * &private;
|
||||
Self { private, public }
|
||||
}
|
||||
|
||||
pub fn from_private_bytes(bytes: &[u8; 32]) -> Self {
|
||||
let private = Scalar::from_bytes_mod_order(*bytes);
|
||||
let public = &X25519_BASEPOINT * &private;
|
||||
Self { private, public }
|
||||
}
|
||||
|
||||
pub fn public_bytes(&self) -> [u8; 32] {
|
||||
self.public.to_bytes()
|
||||
}
|
||||
|
||||
pub fn compute_shared_secret(&self, other_public: &MontgomeryPoint) -> [u8; 32] {
|
||||
let shared = other_public * &self.private;
|
||||
shared.to_bytes()
|
||||
}
|
||||
|
||||
pub fn public_point(&self) -> &MontgomeryPoint {
|
||||
&self.public
|
||||
}
|
||||
}
|
||||
|
||||
pub fn compute_iv_mac(
|
||||
alpha: &[u8; 10],
|
||||
beta: &[u8; 54],
|
||||
shared_secret: &[u8; 32],
|
||||
) -> ([u8; 64], [u8; 8]) {
|
||||
let mut hasher = Sha512::new();
|
||||
hasher.update(shared_secret);
|
||||
let hash = hasher.finalize();
|
||||
|
||||
let mut iv = [0u8; 64];
|
||||
iv.copy_from_slice(&hash);
|
||||
|
||||
for i in 0..10 {
|
||||
iv[i] ^= alpha[i];
|
||||
}
|
||||
for i in 0..54 {
|
||||
iv[i + 10] ^= beta[i];
|
||||
}
|
||||
|
||||
let mut hasher = Sha1::new();
|
||||
hasher.update(&iv);
|
||||
let mac_hash = hasher.finalize();
|
||||
|
||||
let mut mac = [0u8; 8];
|
||||
mac.copy_from_slice(&mac_hash[..8]);
|
||||
|
||||
(iv, mac)
|
||||
}
|
||||
|
||||
pub fn parse_x25519_public_key(bytes: &[u8; 32]) -> MontgomeryPoint {
|
||||
MontgomeryPoint(*bytes)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ephemeral_key_generates_nonzero_public_key() {
|
||||
let key = EphemeralKey::generate();
|
||||
assert_ne!(key.public_bytes(), [0u8; 32]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ephemeral_key_from_bytes_produces_expected_public_key() {
|
||||
let bytes = [1u8; 32];
|
||||
let key = EphemeralKey::from_private_bytes(&bytes);
|
||||
assert_ne!(key.public_bytes(), [0u8; 32]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ecdh_shared_secret_is_symmetric() {
|
||||
let alice = EphemeralKey::generate();
|
||||
let bob = EphemeralKey::generate();
|
||||
|
||||
let alice_shared = alice.compute_shared_secret(bob.public_point());
|
||||
let bob_shared = bob.compute_shared_secret(alice.public_point());
|
||||
|
||||
assert_eq!(alice_shared, bob_shared);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_iv_mac_matches_manual_hash_computation() {
|
||||
let alpha = [1u8; 10];
|
||||
let beta = [2u8; 54];
|
||||
let shared_secret = [3u8; 32];
|
||||
|
||||
let (iv, mac) = compute_iv_mac(&alpha, &beta, &shared_secret);
|
||||
|
||||
let mut hasher = Sha512::new();
|
||||
hasher.update(&shared_secret);
|
||||
let hash = hasher.finalize();
|
||||
let mut expected_iv = [0u8; 64];
|
||||
expected_iv.copy_from_slice(&hash);
|
||||
for i in 0..10 {
|
||||
expected_iv[i] ^= alpha[i];
|
||||
}
|
||||
for i in 0..54 {
|
||||
expected_iv[i + 10] ^= beta[i];
|
||||
}
|
||||
assert_eq!(iv, expected_iv);
|
||||
|
||||
let mut hasher = Sha1::new();
|
||||
hasher.update(&iv);
|
||||
let mac_hash = hasher.finalize();
|
||||
let mut expected_mac = [0u8; 8];
|
||||
expected_mac.copy_from_slice(&mac_hash[..8]);
|
||||
assert_eq!(mac, expected_mac);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_iv_mac_with_zero_shared_secret_matches_shared_secret_new() {
|
||||
let alpha = [42u8; 10];
|
||||
let beta = [7u8; 54];
|
||||
let shared_secret = [0u8; 32];
|
||||
|
||||
let (iv, mac) = compute_iv_mac(&alpha, &beta, &shared_secret);
|
||||
let secret = crate::crypto::SharedSecret::compute_new(&alpha, &beta, &shared_secret);
|
||||
|
||||
assert_eq!(iv, secret.iv);
|
||||
assert_eq!(mac, secret.mac);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_x25519_public_key_returns_montgomery_point() {
|
||||
let key = EphemeralKey::generate();
|
||||
let bytes = key.public_bytes();
|
||||
let parsed = parse_x25519_public_key(&bytes);
|
||||
assert_eq!(parsed.to_bytes(), bytes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
//! TeamSpeak identity key handling.
|
||||
|
||||
use base64::Engine;
|
||||
use num_bigint::{BigInt, Sign};
|
||||
use p256::ecdsa::signature::Signer;
|
||||
use p256::ecdsa::SigningKey;
|
||||
use p256::elliptic_curve::sec1::ToEncodedPoint;
|
||||
use p256::SecretKey;
|
||||
use sha1::{Digest, Sha1};
|
||||
use simple_asn1::ASN1Block;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum IdentityError {
|
||||
#[error("invalid base64 private key: {0}")]
|
||||
Base64(#[from] base64::DecodeError),
|
||||
|
||||
#[error("invalid P-256 private key")]
|
||||
InvalidPrivateKey,
|
||||
|
||||
#[error("ASN.1 encode error: {0}")]
|
||||
Asn1Encode(#[from] simple_asn1::ASN1EncodeErr),
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct IdentityKey {
|
||||
secret: SecretKey,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for IdentityKey {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("IdentityKey")
|
||||
.field("uid", &self.uid())
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl IdentityKey {
|
||||
pub fn generate() -> Self {
|
||||
Self {
|
||||
secret: SecretKey::random(&mut rand::thread_rng()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_private_key_base64(data: &str) -> Result<Self, IdentityError> {
|
||||
let bytes = base64::engine::general_purpose::STANDARD.decode(data)?;
|
||||
if bytes.len() != 32 {
|
||||
return Err(IdentityError::InvalidPrivateKey);
|
||||
}
|
||||
|
||||
let secret = SecretKey::from_bytes(p256::FieldBytes::from_slice(&bytes))
|
||||
.map_err(|_| IdentityError::InvalidPrivateKey)?;
|
||||
Ok(Self { secret })
|
||||
}
|
||||
|
||||
pub fn private_key_base64(&self) -> String {
|
||||
base64::engine::general_purpose::STANDARD.encode(self.secret.to_bytes())
|
||||
}
|
||||
|
||||
pub fn public_key_tomcrypt(&self) -> Result<Vec<u8>, IdentityError> {
|
||||
let encoded = self.secret.public_key().to_encoded_point(false);
|
||||
let x = BigInt::from_bytes_be(Sign::Plus, encoded.x().expect("P-256 x coordinate"));
|
||||
let y = BigInt::from_bytes_be(Sign::Plus, encoded.y().expect("P-256 y coordinate"));
|
||||
|
||||
Ok(simple_asn1::to_der(&ASN1Block::Sequence(
|
||||
0,
|
||||
vec![
|
||||
ASN1Block::BitString(0, 1, vec![0]),
|
||||
ASN1Block::Integer(0, 32.into()),
|
||||
ASN1Block::Integer(0, x),
|
||||
ASN1Block::Integer(0, y),
|
||||
],
|
||||
))?)
|
||||
}
|
||||
|
||||
pub fn public_key_ts_base64(&self) -> Result<String, IdentityError> {
|
||||
Ok(base64::engine::general_purpose::STANDARD.encode(self.public_key_tomcrypt()?))
|
||||
}
|
||||
|
||||
pub fn uid(&self) -> String {
|
||||
let omega = self.public_key_ts_base64().unwrap_or_default();
|
||||
let hash = Sha1::digest(omega.as_bytes());
|
||||
base64::engine::general_purpose::STANDARD.encode(hash)
|
||||
}
|
||||
|
||||
pub fn sign_der_base64(&self, data: &[u8]) -> String {
|
||||
let signing_key = SigningKey::from(self.secret.clone());
|
||||
let signature: p256::ecdsa::DerSignature = signing_key.sign(data);
|
||||
base64::engine::general_purpose::STANDARD.encode(signature.as_bytes())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use p256::ecdsa::signature::Verifier;
|
||||
use p256::ecdsa::{Signature, VerifyingKey};
|
||||
|
||||
#[test]
|
||||
fn identity_round_trips_private_key() {
|
||||
let identity = IdentityKey::generate();
|
||||
let exported = identity.private_key_base64();
|
||||
let imported = IdentityKey::from_private_key_base64(&exported).unwrap();
|
||||
|
||||
assert_eq!(imported.private_key_base64(), exported);
|
||||
assert_eq!(
|
||||
imported.public_key_ts_base64().unwrap(),
|
||||
identity.public_key_ts_base64().unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_produces_ts_public_key_and_uid() {
|
||||
let identity = IdentityKey::generate();
|
||||
let public_key = identity.public_key_tomcrypt().unwrap();
|
||||
let public_key_b64 = identity.public_key_ts_base64().unwrap();
|
||||
let uid = identity.uid();
|
||||
|
||||
assert!(public_key.starts_with(&[0x30]));
|
||||
assert!(public_key_b64.len() > 80);
|
||||
assert!(!uid.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_signs_verifiable_der_signature() {
|
||||
let identity = IdentityKey::generate();
|
||||
let data = b"client proof data";
|
||||
let signature = base64::engine::general_purpose::STANDARD
|
||||
.decode(identity.sign_der_base64(data))
|
||||
.unwrap();
|
||||
|
||||
let signing_key = SigningKey::from(identity.secret.clone());
|
||||
let verifying_key = VerifyingKey::from(&signing_key);
|
||||
let signature = Signature::from_der(&signature).unwrap();
|
||||
verifying_key.verify(data, &signature).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,8 @@
|
||||
use sha1::Sha1;
|
||||
use sha2::{Digest, Sha256, Sha512};
|
||||
|
||||
use crate::protocol::PacketType;
|
||||
use crate::protocol::Direction;
|
||||
use crate::protocol::PacketType;
|
||||
|
||||
/// 假加密密钥
|
||||
pub const FAKE_KEY: [u8; 16] = *b"c:\\windows\\syste";
|
||||
@@ -14,9 +14,8 @@ pub const FAKE_NONCE: [u8; 16] = *b"m\\firewall32.cpl";
|
||||
|
||||
/// 许可证根密钥
|
||||
pub const ROOT_KEY: [u8; 32] = [
|
||||
0xcd, 0x0d, 0xe2, 0xae, 0xd4, 0x63, 0x45, 0x50, 0x9a, 0x7e, 0x3c, 0xfd, 0x8f, 0x68, 0xb3,
|
||||
0xdc, 0x75, 0x55, 0xb2, 0x9d, 0xcc, 0xec, 0x73, 0xcd, 0x18, 0x75, 0x0f, 0x99, 0x38, 0x12,
|
||||
0x40, 0x8a,
|
||||
0xcd, 0x0d, 0xe2, 0xae, 0xd4, 0x63, 0x45, 0x50, 0x9a, 0x7e, 0x3c, 0xfd, 0x8f, 0x68, 0xb3, 0xdc,
|
||||
0x75, 0x55, 0xb2, 0x9d, 0xcc, 0xec, 0x73, 0xcd, 0x18, 0x75, 0x0f, 0x99, 0x38, 0x12, 0x40, 0x8a,
|
||||
];
|
||||
|
||||
/// 共享密钥
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
//! 加密模块
|
||||
|
||||
pub mod eax;
|
||||
pub mod keys;
|
||||
pub mod ephemeral;
|
||||
pub mod hash;
|
||||
pub mod identity;
|
||||
pub mod keys;
|
||||
mod tests;
|
||||
|
||||
pub use eax::*;
|
||||
pub use keys::*;
|
||||
pub use hash::*;
|
||||
pub use identity::*;
|
||||
pub use keys::*;
|
||||
|
||||
@@ -43,7 +43,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_create_encryption_key() {
|
||||
let key = [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10];
|
||||
let key = [
|
||||
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e,
|
||||
0x0f, 0x10,
|
||||
];
|
||||
let encrypted = create_encryption_key(&key, 0x1234);
|
||||
assert_eq!(encrypted[0], key[0] ^ 0x12);
|
||||
assert_eq!(encrypted[1], key[1] ^ 0x34);
|
||||
@@ -90,10 +93,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_eax_encrypt_decrypt() {
|
||||
let key = [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08,
|
||||
0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10];
|
||||
let nonce = [0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18,
|
||||
0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20];
|
||||
let key = [
|
||||
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e,
|
||||
0x0f, 0x10,
|
||||
];
|
||||
let nonce = [
|
||||
0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e,
|
||||
0x1f, 0x20,
|
||||
];
|
||||
|
||||
let cipher = EaxCipher::new(&key);
|
||||
let header = b"test header";
|
||||
@@ -136,13 +143,12 @@ mod tests {
|
||||
// 测试不同的 offset 产生不同的 level
|
||||
let level0 = get_hash_cash_level("test_key", 0);
|
||||
let level1 = get_hash_cash_level("test_key", 1);
|
||||
// level 应该 >= 0
|
||||
assert!(level0 >= 0);
|
||||
assert!(level1 >= 0);
|
||||
assert!(level0 <= 160);
|
||||
assert!(level1 <= 160);
|
||||
|
||||
// 使用一个会产生更高 level 的 key
|
||||
let level_high = get_hash_cash_level("a", 12345);
|
||||
assert!(level_high >= 0);
|
||||
assert!(level_high <= 160);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
//! TeamSpeak 3 协议核心实现
|
||||
|
||||
pub mod protocol;
|
||||
pub mod connection;
|
||||
pub mod crypto;
|
||||
pub mod network;
|
||||
pub mod connection;
|
||||
pub mod protocol;
|
||||
pub mod query;
|
||||
|
||||
pub use protocol::*;
|
||||
pub use connection::*;
|
||||
pub use crypto::*;
|
||||
pub use network::*;
|
||||
pub use connection::*;
|
||||
pub use protocol::*;
|
||||
pub use query::*;
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! 网络模块
|
||||
|
||||
pub mod socket;
|
||||
pub mod resolver;
|
||||
pub mod socket;
|
||||
|
||||
pub use socket::*;
|
||||
pub use resolver::*;
|
||||
pub use socket::*;
|
||||
|
||||
@@ -2,8 +2,12 @@
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::task::{Context, Poll};
|
||||
use std::time::Duration;
|
||||
use tokio::net::UdpSocket;
|
||||
|
||||
use crate::connection::{Client, ClientConfig, ConnectionState};
|
||||
use crate::{ProtocolError, ProtocolResult};
|
||||
|
||||
/// Socket trait
|
||||
pub trait Socket {
|
||||
fn poll_recv_from(
|
||||
@@ -60,3 +64,370 @@ impl Socket for UdpSocketWrapper {
|
||||
self.socket.local_addr()
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the unencrypted TS3 init handshake over UDP.
|
||||
///
|
||||
/// This stops after Init4 is sent and the client reaches `Connected`; encrypted
|
||||
/// command negotiation still has to be completed by the higher-level session.
|
||||
pub async fn perform_init_handshake(
|
||||
config: ClientConfig,
|
||||
timeout: Duration,
|
||||
) -> ProtocolResult<Client> {
|
||||
perform_handshake_until(
|
||||
config,
|
||||
timeout,
|
||||
ConnectionState::Connected,
|
||||
"init handshake",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Run the UDP connection handshake through `clientinit` and `initserver`.
|
||||
///
|
||||
/// This exercises the post-Init4 command bootstrap. Full compatibility with
|
||||
/// public servers still depends on replacing the placeholder ECDH shared-data
|
||||
/// path in `Client::handle_initivexpand2`.
|
||||
pub async fn perform_connect_handshake(
|
||||
config: ClientConfig,
|
||||
timeout: Duration,
|
||||
) -> ProtocolResult<Client> {
|
||||
perform_handshake_until(
|
||||
config,
|
||||
timeout,
|
||||
ConnectionState::ChannelListFinished,
|
||||
"connect handshake",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn perform_handshake_until(
|
||||
config: ClientConfig,
|
||||
timeout: Duration,
|
||||
target_state: ConnectionState,
|
||||
label: &str,
|
||||
) -> ProtocolResult<Client> {
|
||||
let bind_addr = if config.address.is_ipv4() {
|
||||
"0.0.0.0:0"
|
||||
} else {
|
||||
"[::]:0"
|
||||
};
|
||||
let socket = UdpSocket::bind(bind_addr).await?;
|
||||
socket.connect(config.address).await?;
|
||||
|
||||
let mut client = Client::new(config);
|
||||
let init0 = client.start_handshake()?;
|
||||
socket.send(&init0).await?;
|
||||
|
||||
tokio::time::timeout(timeout, async move {
|
||||
let mut buf = [0u8; 2048];
|
||||
loop {
|
||||
let len = socket.recv(&mut buf).await?;
|
||||
let responses = client.handle_data(&buf[..len])?;
|
||||
for response in responses {
|
||||
socket.send(&response).await?;
|
||||
}
|
||||
|
||||
if client.state() == target_state {
|
||||
return Ok(client);
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|_| ProtocolError::Timeout(format!("{label} timed out")))?
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::crypto::{self, KeyCache, SharedSecret};
|
||||
use crate::protocol::{
|
||||
AckPacket, Command, CommandBuilder, Direction, Flags, InPacket, InitPacket, InitStep,
|
||||
OutPacket, PacketType, INIT_MAC, INIT_PACKET_ID,
|
||||
};
|
||||
use base64::Engine;
|
||||
|
||||
fn s2c_init_datagram(init: InitPacket) -> Vec<u8> {
|
||||
let mut packet = OutPacket::new(
|
||||
Direction::S2C,
|
||||
Flags::new(PacketType::Init.to_u8()),
|
||||
init.to_bytes(),
|
||||
);
|
||||
packet.set_mac(INIT_MAC);
|
||||
packet.set_packet_id(INIT_PACKET_ID);
|
||||
packet.to_bytes()
|
||||
}
|
||||
|
||||
fn s2c_fake_command(packet_id: u16, command: Command) -> Vec<u8> {
|
||||
let mut packet = OutPacket::new(
|
||||
Direction::S2C,
|
||||
Flags::new(PacketType::Command.to_u8()),
|
||||
command.to_string().into_bytes(),
|
||||
);
|
||||
packet.set_packet_id(packet_id);
|
||||
crypto::encrypt_fake(&mut packet).unwrap();
|
||||
packet.to_bytes()
|
||||
}
|
||||
|
||||
fn s2c_fake_ack(packet_id: u16, acked_packet_id: u16) -> Vec<u8> {
|
||||
let mut packet =
|
||||
AckPacket::new(Direction::S2C, PacketType::Ack, acked_packet_id).to_out_packet();
|
||||
packet.set_packet_id(packet_id);
|
||||
crypto::encrypt_fake(&mut packet).unwrap();
|
||||
packet.to_bytes()
|
||||
}
|
||||
|
||||
fn s2c_encrypted_command(packet_id: u16, command: Command, secret: &SharedSecret) -> Vec<u8> {
|
||||
let mut packet = OutPacket::new(
|
||||
Direction::S2C,
|
||||
Flags::new(PacketType::Command.to_u8()),
|
||||
command.to_string().into_bytes(),
|
||||
);
|
||||
packet.set_packet_id(packet_id);
|
||||
let mut key_cache = KeyCache::new();
|
||||
crypto::encrypt_packet(&mut packet, 0, &secret.iv, &mut key_cache).unwrap();
|
||||
packet.to_bytes()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_perform_init_handshake() {
|
||||
let server = UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||
let server_addr = server.local_addr().unwrap();
|
||||
|
||||
let server_task = tokio::spawn(async move {
|
||||
let mut buf = [0u8; 2048];
|
||||
|
||||
let (len, client_addr) = server.recv_from(&mut buf).await.unwrap();
|
||||
let packet = InPacket::parse(Direction::C2S, &buf[..len]).unwrap();
|
||||
assert_eq!(packet.header.mac, INIT_MAC);
|
||||
assert_eq!(packet.header.flags.packet_type(), PacketType::Init);
|
||||
assert_eq!(
|
||||
InitPacket::parse_c2s(packet.content()).unwrap().step,
|
||||
InitStep::Init0
|
||||
);
|
||||
|
||||
let init1 = InitPacket {
|
||||
step: InitStep::Init1,
|
||||
version: None,
|
||||
timestamp: None,
|
||||
random0: None,
|
||||
random1: Some([1; 16]),
|
||||
random0_r: Some([2; 4]),
|
||||
x: None,
|
||||
n: None,
|
||||
level: None,
|
||||
random2: None,
|
||||
y: None,
|
||||
command: None,
|
||||
};
|
||||
server
|
||||
.send_to(&s2c_init_datagram(init1), client_addr)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (len, client_addr) = server.recv_from(&mut buf).await.unwrap();
|
||||
let packet = InPacket::parse(Direction::C2S, &buf[..len]).unwrap();
|
||||
assert_eq!(
|
||||
InitPacket::parse_c2s(packet.content()).unwrap().step,
|
||||
InitStep::Init2
|
||||
);
|
||||
|
||||
let mut x = [0u8; 64];
|
||||
x[63] = 2;
|
||||
let mut n = [0u8; 64];
|
||||
n[63] = 7;
|
||||
let init3 = InitPacket {
|
||||
step: InitStep::Init3,
|
||||
version: None,
|
||||
timestamp: None,
|
||||
random0: None,
|
||||
random1: None,
|
||||
random0_r: None,
|
||||
x: Some(x),
|
||||
n: Some(n),
|
||||
level: Some(1),
|
||||
random2: Some([3; 100]),
|
||||
y: None,
|
||||
command: None,
|
||||
};
|
||||
server
|
||||
.send_to(&s2c_init_datagram(init3), client_addr)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (len, _) = server.recv_from(&mut buf).await.unwrap();
|
||||
let packet = InPacket::parse(Direction::C2S, &buf[..len]).unwrap();
|
||||
let init4 = InitPacket::parse_c2s(packet.content()).unwrap();
|
||||
assert_eq!(init4.step, InitStep::Init4);
|
||||
let command = String::from_utf8(init4.command.unwrap()).unwrap();
|
||||
let command = Command::parse(&command).unwrap();
|
||||
let omega = command.get("omega").unwrap();
|
||||
let omega = base64::engine::general_purpose::STANDARD
|
||||
.decode(omega)
|
||||
.unwrap();
|
||||
assert_eq!(command.name, "clientinitiv");
|
||||
assert!(omega.starts_with(&[0x30]));
|
||||
});
|
||||
|
||||
let config = ClientConfig::new(server_addr, "Tester".to_string());
|
||||
let client = perform_init_handshake(config, Duration::from_secs(1))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(client.state(), ConnectionState::Connected);
|
||||
|
||||
server_task.await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_perform_connect_handshake() {
|
||||
let server = UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||
let server_addr = server.local_addr().unwrap();
|
||||
|
||||
let server_task = tokio::spawn(async move {
|
||||
let mut buf = [0u8; 2048];
|
||||
|
||||
let (len, client_addr) = server.recv_from(&mut buf).await.unwrap();
|
||||
let packet = InPacket::parse(Direction::C2S, &buf[..len]).unwrap();
|
||||
assert_eq!(
|
||||
InitPacket::parse_c2s(packet.content()).unwrap().step,
|
||||
InitStep::Init0
|
||||
);
|
||||
|
||||
server
|
||||
.send_to(
|
||||
&s2c_init_datagram(InitPacket {
|
||||
step: InitStep::Init1,
|
||||
version: None,
|
||||
timestamp: None,
|
||||
random0: None,
|
||||
random1: Some([1; 16]),
|
||||
random0_r: Some([2; 4]),
|
||||
x: None,
|
||||
n: None,
|
||||
level: None,
|
||||
random2: None,
|
||||
y: None,
|
||||
command: None,
|
||||
}),
|
||||
client_addr,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (len, client_addr) = server.recv_from(&mut buf).await.unwrap();
|
||||
let packet = InPacket::parse(Direction::C2S, &buf[..len]).unwrap();
|
||||
assert_eq!(
|
||||
InitPacket::parse_c2s(packet.content()).unwrap().step,
|
||||
InitStep::Init2
|
||||
);
|
||||
|
||||
let mut x = [0u8; 64];
|
||||
x[63] = 2;
|
||||
let mut n = [0u8; 64];
|
||||
n[63] = 7;
|
||||
server
|
||||
.send_to(
|
||||
&s2c_init_datagram(InitPacket {
|
||||
step: InitStep::Init3,
|
||||
version: None,
|
||||
timestamp: None,
|
||||
random0: None,
|
||||
random1: None,
|
||||
random0_r: None,
|
||||
x: Some(x),
|
||||
n: Some(n),
|
||||
level: Some(1),
|
||||
random2: Some([3; 100]),
|
||||
y: None,
|
||||
command: None,
|
||||
}),
|
||||
client_addr,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (len, _) = server.recv_from(&mut buf).await.unwrap();
|
||||
let packet = InPacket::parse(Direction::C2S, &buf[..len]).unwrap();
|
||||
let init4 = InitPacket::parse_c2s(packet.content()).unwrap();
|
||||
let command =
|
||||
Command::parse(&String::from_utf8(init4.command.unwrap()).unwrap()).unwrap();
|
||||
let alpha_bytes = base64::engine::general_purpose::STANDARD
|
||||
.decode(command.get("alpha").unwrap())
|
||||
.unwrap();
|
||||
let mut alpha = [0u8; 10];
|
||||
alpha.copy_from_slice(&alpha_bytes);
|
||||
|
||||
let beta = [1u8; 54];
|
||||
let secret = SharedSecret::compute_new(&alpha, &beta, &[0; 32]);
|
||||
server
|
||||
.send_to(
|
||||
&s2c_fake_command(
|
||||
0,
|
||||
CommandBuilder::new("initivexpand2")
|
||||
.arg(
|
||||
"beta",
|
||||
&base64::engine::general_purpose::STANDARD.encode(beta),
|
||||
)
|
||||
.arg("omega", "server")
|
||||
.build(),
|
||||
),
|
||||
client_addr,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (len, _) = server.recv_from(&mut buf).await.unwrap();
|
||||
let packet = InPacket::parse(Direction::C2S, &buf[..len]).unwrap();
|
||||
assert_eq!(packet.header.flags.packet_type(), PacketType::Ack);
|
||||
assert_eq!(crypto::decrypt_fake(&packet).unwrap(), 0u16.to_be_bytes());
|
||||
|
||||
let (len, _) = server.recv_from(&mut buf).await.unwrap();
|
||||
let packet = InPacket::parse(Direction::C2S, &buf[..len]).unwrap();
|
||||
assert_eq!(packet.header.packet_id, 1);
|
||||
let command =
|
||||
Command::parse(&String::from_utf8(crypto::decrypt_fake(&packet).unwrap()).unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(command.name, "clientek");
|
||||
|
||||
server
|
||||
.send_to(&s2c_fake_ack(0, 1), client_addr)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (len, _) = server.recv_from(&mut buf).await.unwrap();
|
||||
let packet = InPacket::parse(Direction::C2S, &buf[..len]).unwrap();
|
||||
assert_eq!(packet.header.packet_id, 2);
|
||||
let mut key_cache = KeyCache::new();
|
||||
let command = Command::parse(
|
||||
&String::from_utf8(
|
||||
crypto::decrypt_packet(&packet, 0, &secret.iv, &mut key_cache).unwrap(),
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(command.name, "clientinit");
|
||||
|
||||
server
|
||||
.send_to(
|
||||
&s2c_encrypted_command(
|
||||
1,
|
||||
CommandBuilder::new("initserver")
|
||||
.arg("client_id", "7")
|
||||
.build(),
|
||||
&secret,
|
||||
),
|
||||
client_addr,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
let config = ClientConfig::new(server_addr, "Tester".to_string());
|
||||
let client = perform_connect_handshake(config, Duration::from_secs(1))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(client.state(), ConnectionState::ChannelListFinished);
|
||||
assert_eq!(client.client_id(), Some(7));
|
||||
|
||||
server_task.await.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +64,10 @@ pub mod escape {
|
||||
Some('r') => result.push('\r'),
|
||||
Some('t') => result.push('\t'),
|
||||
Some(other) => {
|
||||
return Err(CommandError::EscapeError(format!("未知的转义序列: \\{}", other)))
|
||||
return Err(CommandError::EscapeError(format!(
|
||||
"未知的转义序列: \\{}",
|
||||
other
|
||||
)))
|
||||
}
|
||||
None => {
|
||||
return Err(CommandError::EscapeError("意外的转义序列结束".to_string()))
|
||||
@@ -195,6 +198,32 @@ impl Command {
|
||||
Ok(Self { name, args })
|
||||
}
|
||||
|
||||
pub fn parse_many(input: &str) -> CommandResult<Vec<Self>> {
|
||||
let input = input.trim();
|
||||
if input.is_empty() {
|
||||
return Err(CommandError::InvalidFormat("空命令".to_string()));
|
||||
}
|
||||
|
||||
let parts: Vec<&str> = input.splitn(2, ' ').collect();
|
||||
let name = parts[0];
|
||||
let args_str = if parts.len() > 1 { parts[1] } else { "" };
|
||||
|
||||
if args_str.is_empty() {
|
||||
return Ok(vec![Self::parse(input)?]);
|
||||
}
|
||||
|
||||
args_str
|
||||
.split('|')
|
||||
.map(|part| {
|
||||
if part.is_empty() {
|
||||
Self::parse(name)
|
||||
} else {
|
||||
Self::parse(&format!("{} {}", name, part))
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn to_string(&self) -> String {
|
||||
let mut result = self.name.clone();
|
||||
for arg in &self.args {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
//! 协议模块
|
||||
|
||||
pub mod packet;
|
||||
pub mod types;
|
||||
pub mod commands;
|
||||
pub mod packet;
|
||||
mod tests;
|
||||
pub mod types;
|
||||
|
||||
pub use commands::*;
|
||||
pub use packet::*;
|
||||
pub use types::*;
|
||||
pub use commands::*;
|
||||
|
||||
@@ -14,6 +14,10 @@ pub const C2S_HEADER_SIZE: usize = 13; // 8 (MAC) + 2 (PId) + 2 (CId) + 1 (PT)
|
||||
/// S2C 头部大小
|
||||
pub const S2C_HEADER_SIZE: usize = 11; // 8 (MAC) + 2 (PId) + 1 (PT)
|
||||
|
||||
/// Init packets use a fixed MAC and packet id during the TS3 handshake.
|
||||
pub const INIT_MAC: [u8; 8] = *b"TS3INIT1";
|
||||
pub const INIT_PACKET_ID: u16 = 0x65;
|
||||
|
||||
/// 数据包方向
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Direction {
|
||||
@@ -265,7 +269,11 @@ impl OutPacket {
|
||||
let header = Header {
|
||||
mac: [0; 8],
|
||||
packet_id: 0,
|
||||
client_id: if direction == Direction::C2S { Some(0) } else { None },
|
||||
client_id: if direction == Direction::C2S {
|
||||
Some(0)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
flags,
|
||||
};
|
||||
|
||||
@@ -372,12 +380,104 @@ pub struct InitPacket {
|
||||
}
|
||||
|
||||
impl InitPacket {
|
||||
pub fn parse_c2s(data: &[u8]) -> Result<Self, ProtocolError> {
|
||||
if data.len() < 5 {
|
||||
return Err(ProtocolError::PacketTooSmall {
|
||||
size: data.len(),
|
||||
min: 5,
|
||||
});
|
||||
}
|
||||
|
||||
let version = u32::from_be_bytes([data[0], data[1], data[2], data[3]]);
|
||||
let step = match data[4] {
|
||||
0 => InitStep::Init0,
|
||||
2 => InitStep::Init2,
|
||||
4 => InitStep::Init4,
|
||||
127 => InitStep::Reset,
|
||||
_ => return Err(ProtocolError::InvalidPacketType(data[4])),
|
||||
};
|
||||
|
||||
let mut packet = Self {
|
||||
step,
|
||||
version: Some(version),
|
||||
timestamp: None,
|
||||
random0: None,
|
||||
random1: None,
|
||||
random0_r: None,
|
||||
x: None,
|
||||
n: None,
|
||||
level: None,
|
||||
random2: None,
|
||||
y: None,
|
||||
command: None,
|
||||
};
|
||||
|
||||
match step {
|
||||
InitStep::Init0 => {
|
||||
if data.len() < 21 {
|
||||
return Err(ProtocolError::PacketTooSmall {
|
||||
size: data.len(),
|
||||
min: 21,
|
||||
});
|
||||
}
|
||||
packet.timestamp = Some(u32::from_be_bytes([data[5], data[6], data[7], data[8]]));
|
||||
let mut random0 = [0u8; 4];
|
||||
random0.copy_from_slice(&data[9..13]);
|
||||
packet.random0 = Some(random0);
|
||||
}
|
||||
InitStep::Init2 => {
|
||||
if data.len() < 25 {
|
||||
return Err(ProtocolError::PacketTooSmall {
|
||||
size: data.len(),
|
||||
min: 25,
|
||||
});
|
||||
}
|
||||
let mut random1 = [0u8; 16];
|
||||
random1.copy_from_slice(&data[5..21]);
|
||||
packet.random1 = Some(random1);
|
||||
let mut random0_r = [0u8; 4];
|
||||
random0_r.copy_from_slice(&data[21..25]);
|
||||
packet.random0_r = Some(random0_r);
|
||||
}
|
||||
InitStep::Init4 => {
|
||||
if data.len() < 301 {
|
||||
return Err(ProtocolError::PacketTooSmall {
|
||||
size: data.len(),
|
||||
min: 301,
|
||||
});
|
||||
}
|
||||
let mut x = [0u8; 64];
|
||||
x.copy_from_slice(&data[5..69]);
|
||||
packet.x = Some(x);
|
||||
let mut n = [0u8; 64];
|
||||
n.copy_from_slice(&data[69..133]);
|
||||
packet.n = Some(n);
|
||||
packet.level = Some(u32::from_be_bytes([
|
||||
data[133], data[134], data[135], data[136],
|
||||
]));
|
||||
let mut random2 = [0u8; 100];
|
||||
random2.copy_from_slice(&data[137..237]);
|
||||
packet.random2 = Some(random2);
|
||||
let mut y = [0u8; 64];
|
||||
y.copy_from_slice(&data[237..301]);
|
||||
packet.y = Some(y);
|
||||
if data.len() > 301 {
|
||||
packet.command = Some(data[301..].to_vec());
|
||||
}
|
||||
}
|
||||
InitStep::Init1 | InitStep::Init3 | InitStep::Reset => {}
|
||||
}
|
||||
|
||||
Ok(packet)
|
||||
}
|
||||
|
||||
pub fn parse_s2c(data: &[u8]) -> Result<Self, ProtocolError> {
|
||||
Self::parse(data)
|
||||
}
|
||||
|
||||
pub fn parse(data: &[u8]) -> Result<Self, ProtocolError> {
|
||||
if data.is_empty() {
|
||||
return Err(ProtocolError::PacketTooSmall {
|
||||
size: 0,
|
||||
min: 1,
|
||||
});
|
||||
return Err(ProtocolError::PacketTooSmall { size: 0, min: 1 });
|
||||
}
|
||||
|
||||
let step = match data[0] {
|
||||
@@ -408,7 +508,10 @@ impl InitPacket {
|
||||
match step {
|
||||
InitStep::Init0 => {
|
||||
if data.len() < 21 {
|
||||
return Err(ProtocolError::PacketTooSmall { size: data.len(), min: 21 });
|
||||
return Err(ProtocolError::PacketTooSmall {
|
||||
size: data.len(),
|
||||
min: 21,
|
||||
});
|
||||
}
|
||||
packet.version = Some(u32::from_be_bytes([data[1], data[2], data[3], data[4]]));
|
||||
packet.timestamp = Some(u32::from_be_bytes([data[6], data[7], data[8], data[9]]));
|
||||
@@ -418,7 +521,10 @@ impl InitPacket {
|
||||
}
|
||||
InitStep::Init1 => {
|
||||
if data.len() < 21 {
|
||||
return Err(ProtocolError::PacketTooSmall { size: data.len(), min: 21 });
|
||||
return Err(ProtocolError::PacketTooSmall {
|
||||
size: data.len(),
|
||||
min: 21,
|
||||
});
|
||||
}
|
||||
let mut random1 = [0u8; 16];
|
||||
random1.copy_from_slice(&data[1..17]);
|
||||
@@ -429,7 +535,10 @@ impl InitPacket {
|
||||
}
|
||||
InitStep::Init2 => {
|
||||
if data.len() < 26 {
|
||||
return Err(ProtocolError::PacketTooSmall { size: data.len(), min: 26 });
|
||||
return Err(ProtocolError::PacketTooSmall {
|
||||
size: data.len(),
|
||||
min: 26,
|
||||
});
|
||||
}
|
||||
packet.version = Some(u32::from_be_bytes([data[1], data[2], data[3], data[4]]));
|
||||
let mut random1 = [0u8; 16];
|
||||
@@ -441,7 +550,10 @@ impl InitPacket {
|
||||
}
|
||||
InitStep::Init3 => {
|
||||
if data.len() < 233 {
|
||||
return Err(ProtocolError::PacketTooSmall { size: data.len(), min: 233 });
|
||||
return Err(ProtocolError::PacketTooSmall {
|
||||
size: data.len(),
|
||||
min: 233,
|
||||
});
|
||||
}
|
||||
let mut x = [0u8; 64];
|
||||
x.copy_from_slice(&data[1..65]);
|
||||
@@ -449,14 +561,19 @@ impl InitPacket {
|
||||
let mut n = [0u8; 64];
|
||||
n.copy_from_slice(&data[65..129]);
|
||||
packet.n = Some(n);
|
||||
packet.level = Some(u32::from_be_bytes([data[129], data[130], data[131], data[132]]));
|
||||
packet.level = Some(u32::from_be_bytes([
|
||||
data[129], data[130], data[131], data[132],
|
||||
]));
|
||||
let mut random2 = [0u8; 100];
|
||||
random2.copy_from_slice(&data[133..233]);
|
||||
packet.random2 = Some(random2);
|
||||
}
|
||||
InitStep::Init4 => {
|
||||
if data.len() < 361 {
|
||||
return Err(ProtocolError::PacketTooSmall { size: data.len(), min: 361 });
|
||||
return Err(ProtocolError::PacketTooSmall {
|
||||
size: data.len(),
|
||||
min: 361,
|
||||
});
|
||||
}
|
||||
packet.version = Some(u32::from_be_bytes([data[1], data[2], data[3], data[4]]));
|
||||
let mut x = [0u8; 64];
|
||||
@@ -465,7 +582,9 @@ impl InitPacket {
|
||||
let mut n = [0u8; 64];
|
||||
n.copy_from_slice(&data[70..134]);
|
||||
packet.n = Some(n);
|
||||
packet.level = Some(u32::from_be_bytes([data[134], data[135], data[136], data[137]]));
|
||||
packet.level = Some(u32::from_be_bytes([
|
||||
data[134], data[135], data[136], data[137],
|
||||
]));
|
||||
let mut random2 = [0u8; 100];
|
||||
random2.copy_from_slice(&data[138..238]);
|
||||
packet.random2 = Some(random2);
|
||||
@@ -604,4 +723,68 @@ impl InitPacket {
|
||||
|
||||
bytes
|
||||
}
|
||||
|
||||
pub fn to_c2s_bytes(&self) -> Vec<u8> {
|
||||
let mut bytes = Vec::new();
|
||||
|
||||
match self.step {
|
||||
InitStep::Init0 => {
|
||||
bytes.extend_from_slice(&self.version.unwrap_or_default().to_be_bytes());
|
||||
bytes.push(0);
|
||||
bytes.extend_from_slice(&self.timestamp.unwrap_or_default().to_be_bytes());
|
||||
bytes.extend_from_slice(&self.random0.unwrap_or_default());
|
||||
bytes.extend_from_slice(&[0; 8]);
|
||||
}
|
||||
InitStep::Init2 => {
|
||||
bytes.extend_from_slice(&self.version.unwrap_or_default().to_be_bytes());
|
||||
bytes.push(2);
|
||||
bytes.extend_from_slice(&self.random1.unwrap_or_default());
|
||||
bytes.extend_from_slice(&self.random0_r.unwrap_or_default());
|
||||
}
|
||||
InitStep::Init4 => {
|
||||
bytes.extend_from_slice(&self.version.unwrap_or_default().to_be_bytes());
|
||||
bytes.push(4);
|
||||
bytes.extend_from_slice(&self.x.unwrap_or([0; 64]));
|
||||
bytes.extend_from_slice(&self.n.unwrap_or([0; 64]));
|
||||
bytes.extend_from_slice(&self.level.unwrap_or_default().to_be_bytes());
|
||||
bytes.extend_from_slice(&self.random2.unwrap_or([0; 100]));
|
||||
bytes.extend_from_slice(&self.y.unwrap_or([0; 64]));
|
||||
if let Some(ref command) = self.command {
|
||||
bytes.extend_from_slice(command);
|
||||
}
|
||||
}
|
||||
InitStep::Reset => {
|
||||
bytes.extend_from_slice(&self.version.unwrap_or_default().to_be_bytes());
|
||||
bytes.push(127);
|
||||
}
|
||||
InitStep::Init1 | InitStep::Init3 => {
|
||||
bytes.extend_from_slice(&self.version.unwrap_or_default().to_be_bytes());
|
||||
bytes.push(self.step_byte());
|
||||
}
|
||||
}
|
||||
|
||||
bytes
|
||||
}
|
||||
|
||||
pub fn to_c2s_packet_bytes(&self) -> Vec<u8> {
|
||||
let mut packet = OutPacket::new(
|
||||
Direction::C2S,
|
||||
Flags::new(PacketType::Init.to_u8()),
|
||||
self.to_c2s_bytes(),
|
||||
);
|
||||
packet.set_mac(INIT_MAC);
|
||||
packet.set_packet_id(INIT_PACKET_ID);
|
||||
packet.to_bytes()
|
||||
}
|
||||
|
||||
fn step_byte(&self) -> u8 {
|
||||
match self.step {
|
||||
InitStep::Init0 => 0,
|
||||
InitStep::Init1 => 1,
|
||||
InitStep::Init2 => 2,
|
||||
InitStep::Init3 => 3,
|
||||
InitStep::Init4 => 4,
|
||||
InitStep::Reset => 127,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,18 +102,38 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_command_parse() {
|
||||
let cmd = Command::parse("clientinit client_nickname=Test\\sUser client_version=3.0.19.3").unwrap();
|
||||
let cmd = Command::parse("clientinit client_nickname=Test\\sUser client_version=3.0.19.3")
|
||||
.unwrap();
|
||||
assert_eq!(cmd.name, "clientinit");
|
||||
assert_eq!(cmd.get("client_nickname"), Some("Test User"));
|
||||
assert_eq!(cmd.get("client_version"), Some("3.0.19.3"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_command_parse_many() {
|
||||
let commands = Command::parse_many(
|
||||
"channellist cid=1 channel_name=Root|cid=2 channel_name=Gaming\\pVoice",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(commands.len(), 2);
|
||||
assert_eq!(commands[0].name, "channellist");
|
||||
assert_eq!(commands[0].get("cid"), Some("1"));
|
||||
assert_eq!(commands[0].get("channel_name"), Some("Root"));
|
||||
assert_eq!(commands[1].name, "channellist");
|
||||
assert_eq!(commands[1].get("cid"), Some("2"));
|
||||
assert_eq!(commands[1].get("channel_name"), Some("Gaming|Voice"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_command_serialize() {
|
||||
let cmd = Command::new("sendtextmessage")
|
||||
.key_value("targetmode", "2")
|
||||
.key_value("msg", "Hello World!");
|
||||
assert_eq!(cmd.to_string(), "sendtextmessage targetmode=2 msg=Hello\\sWorld!");
|
||||
assert_eq!(
|
||||
cmd.to_string(),
|
||||
"sendtextmessage targetmode=2 msg=Hello\\sWorld!"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -179,6 +199,44 @@ mod tests {
|
||||
assert_eq!(data[1..5], 1466672534u32.to_be_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_c2s_init_packet_serialize() {
|
||||
let init = InitPacket {
|
||||
step: InitStep::Init0,
|
||||
version: Some(1466672534),
|
||||
timestamp: Some(1000000),
|
||||
random0: Some([0xAA, 0xBB, 0xCC, 0xDD]),
|
||||
random1: None,
|
||||
random0_r: None,
|
||||
x: None,
|
||||
n: None,
|
||||
level: None,
|
||||
random2: None,
|
||||
y: None,
|
||||
command: None,
|
||||
};
|
||||
|
||||
let content = init.to_c2s_bytes();
|
||||
assert_eq!(content.len(), 21);
|
||||
assert_eq!(content[0..4], 1466672534u32.to_be_bytes());
|
||||
assert_eq!(content[4], 0);
|
||||
assert_eq!(content[5..9], 1000000u32.to_be_bytes());
|
||||
assert_eq!(content[9..13], [0xAA, 0xBB, 0xCC, 0xDD]);
|
||||
|
||||
let parsed = InitPacket::parse_c2s(&content).unwrap();
|
||||
assert_eq!(parsed.step, InitStep::Init0);
|
||||
assert_eq!(parsed.version, Some(1466672534));
|
||||
assert_eq!(parsed.timestamp, Some(1000000));
|
||||
assert_eq!(parsed.random0, Some([0xAA, 0xBB, 0xCC, 0xDD]));
|
||||
|
||||
let bytes = init.to_c2s_packet_bytes();
|
||||
let packet = InPacket::parse(Direction::C2S, &bytes).unwrap();
|
||||
assert_eq!(packet.header.mac, INIT_MAC);
|
||||
assert_eq!(packet.header.packet_id, INIT_PACKET_ID);
|
||||
assert_eq!(packet.header.flags.packet_type(), PacketType::Init);
|
||||
assert_eq!(packet.content(), content);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ack_packet() {
|
||||
let ack = AckPacket::new(Direction::C2S, PacketType::Ack, 42);
|
||||
|
||||
@@ -55,11 +55,17 @@ impl PacketType {
|
||||
}
|
||||
|
||||
pub fn needs_ack(&self) -> bool {
|
||||
matches!(self, Self::Command | Self::CommandLow | Self::Ping | Self::Init)
|
||||
matches!(
|
||||
self,
|
||||
Self::Command | Self::CommandLow | Self::Ping | Self::Init
|
||||
)
|
||||
}
|
||||
|
||||
pub fn can_resend(&self) -> bool {
|
||||
matches!(self, Self::Command | Self::CommandLow | Self::Ack | Self::AckLow | Self::Init)
|
||||
matches!(
|
||||
self,
|
||||
Self::Command | Self::CommandLow | Self::Ack | Self::AckLow | Self::Init
|
||||
)
|
||||
}
|
||||
|
||||
pub fn can_encrypt(&self) -> bool {
|
||||
|
||||
@@ -0,0 +1,733 @@
|
||||
//! TeamSpeak ServerQuery TCP client support.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use shared::{
|
||||
ChannelId, ClientDbId, ClientId, ClientType, PermissionId, PermissionInfo, ServerQueryChannel,
|
||||
ServerQueryClient, ServerQueryServerInfo,
|
||||
};
|
||||
use thiserror::Error;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{TcpStream, ToSocketAddrs};
|
||||
|
||||
const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
const BUFFER_SIZE: usize = 1024;
|
||||
const GREETING_MARKER: &str = "ServerQuery interface";
|
||||
|
||||
pub type QueryResult<T> = Result<T, QueryError>;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum QueryError {
|
||||
#[error("ServerQuery I/O error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error("ServerQuery read timed out")]
|
||||
Timeout,
|
||||
|
||||
#[error("ServerQuery connection closed")]
|
||||
ConnectionClosed,
|
||||
|
||||
#[error("ServerQuery response did not include a status line")]
|
||||
MissingStatus,
|
||||
|
||||
#[error("invalid ServerQuery field: {0}")]
|
||||
InvalidField(String),
|
||||
|
||||
#[error("invalid ServerQuery status id: {0}")]
|
||||
InvalidStatusId(String),
|
||||
|
||||
#[error("ServerQuery error {id}: {message}")]
|
||||
Status { id: u32, message: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct QueryStatus {
|
||||
pub id: u32,
|
||||
pub message: String,
|
||||
pub fields: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl QueryStatus {
|
||||
pub fn get(&self, name: &str) -> Option<&str> {
|
||||
self.fields
|
||||
.iter()
|
||||
.find(|(key, _)| key == name)
|
||||
.map(|(_, value)| value.as_str())
|
||||
}
|
||||
|
||||
pub fn require(&self, name: &str) -> QueryResult<&str> {
|
||||
self.get(name)
|
||||
.ok_or_else(|| QueryError::InvalidField(format!("missing {name}")))
|
||||
}
|
||||
|
||||
pub fn get_u32(&self, name: &str) -> QueryResult<u32> {
|
||||
let value = self.require(name)?;
|
||||
value
|
||||
.parse::<u32>()
|
||||
.map_err(|_| QueryError::InvalidField(format!("invalid {name}: {value}")))
|
||||
}
|
||||
|
||||
pub fn get_u64(&self, name: &str) -> QueryResult<u64> {
|
||||
let value = self.require(name)?;
|
||||
value
|
||||
.parse::<u64>()
|
||||
.map_err(|_| QueryError::InvalidField(format!("invalid {name}: {value}")))
|
||||
}
|
||||
|
||||
pub fn get_u16(&self, name: &str) -> QueryResult<u16> {
|
||||
let value = self.require(name)?;
|
||||
value
|
||||
.parse::<u16>()
|
||||
.map_err(|_| QueryError::InvalidField(format!("invalid {name}: {value}")))
|
||||
}
|
||||
|
||||
pub fn get_i32(&self, name: &str) -> QueryResult<i32> {
|
||||
let value = self.require(name)?;
|
||||
value
|
||||
.parse::<i32>()
|
||||
.map_err(|_| QueryError::InvalidField(format!("invalid {name}: {value}")))
|
||||
}
|
||||
|
||||
pub fn get_bool(&self, name: &str) -> QueryResult<bool> {
|
||||
Ok(self.get_u32(name)? != 0)
|
||||
}
|
||||
|
||||
pub fn get_u32_or(&self, name: &str, default: u32) -> QueryResult<u32> {
|
||||
self.get(name)
|
||||
.map(|_| self.get_u32(name))
|
||||
.unwrap_or(Ok(default))
|
||||
}
|
||||
|
||||
pub fn get_i32_or(&self, name: &str, default: i32) -> QueryResult<i32> {
|
||||
self.get(name)
|
||||
.map(|_| self.get_i32(name))
|
||||
.unwrap_or(Ok(default))
|
||||
}
|
||||
|
||||
pub fn get_u64_or(&self, name: &str, default: u64) -> QueryResult<u64> {
|
||||
self.get(name)
|
||||
.map(|_| self.get_u64(name))
|
||||
.unwrap_or(Ok(default))
|
||||
}
|
||||
|
||||
pub fn get_u16_or(&self, name: &str, default: u16) -> QueryResult<u16> {
|
||||
self.get(name)
|
||||
.map(|_| self.get_u16(name))
|
||||
.unwrap_or(Ok(default))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct QueryRecord {
|
||||
fields: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl QueryRecord {
|
||||
pub fn fields(&self) -> &[(String, String)] {
|
||||
&self.fields
|
||||
}
|
||||
|
||||
pub fn get(&self, name: &str) -> Option<&str> {
|
||||
self.fields
|
||||
.iter()
|
||||
.find(|(key, _)| key == name)
|
||||
.map(|(_, value)| value.as_str())
|
||||
}
|
||||
|
||||
pub fn require(&self, name: &str) -> QueryResult<&str> {
|
||||
self.get(name)
|
||||
.ok_or_else(|| QueryError::InvalidField(format!("missing {name}")))
|
||||
}
|
||||
|
||||
pub fn get_u32(&self, name: &str) -> QueryResult<u32> {
|
||||
let value = self.require(name)?;
|
||||
value
|
||||
.parse::<u32>()
|
||||
.map_err(|_| QueryError::InvalidField(format!("invalid {name}: {value}")))
|
||||
}
|
||||
|
||||
pub fn get_u64(&self, name: &str) -> QueryResult<u64> {
|
||||
let value = self.require(name)?;
|
||||
value
|
||||
.parse::<u64>()
|
||||
.map_err(|_| QueryError::InvalidField(format!("invalid {name}: {value}")))
|
||||
}
|
||||
|
||||
pub fn get_u16(&self, name: &str) -> QueryResult<u16> {
|
||||
let value = self.require(name)?;
|
||||
value
|
||||
.parse::<u16>()
|
||||
.map_err(|_| QueryError::InvalidField(format!("invalid {name}: {value}")))
|
||||
}
|
||||
|
||||
pub fn get_i32(&self, name: &str) -> QueryResult<i32> {
|
||||
let value = self.require(name)?;
|
||||
value
|
||||
.parse::<i32>()
|
||||
.map_err(|_| QueryError::InvalidField(format!("invalid {name}: {value}")))
|
||||
}
|
||||
|
||||
pub fn get_bool(&self, name: &str) -> QueryResult<bool> {
|
||||
Ok(self.get_u32(name)? != 0)
|
||||
}
|
||||
|
||||
pub fn get_u32_or(&self, name: &str, default: u32) -> QueryResult<u32> {
|
||||
self.get(name)
|
||||
.map(|_| self.get_u32(name))
|
||||
.unwrap_or(Ok(default))
|
||||
}
|
||||
|
||||
pub fn get_i32_or(&self, name: &str, default: i32) -> QueryResult<i32> {
|
||||
self.get(name)
|
||||
.map(|_| self.get_i32(name))
|
||||
.unwrap_or(Ok(default))
|
||||
}
|
||||
|
||||
pub fn get_u64_or(&self, name: &str, default: u64) -> QueryResult<u64> {
|
||||
self.get(name)
|
||||
.map(|_| self.get_u64(name))
|
||||
.unwrap_or(Ok(default))
|
||||
}
|
||||
|
||||
pub fn get_u16_or(&self, name: &str, default: u16) -> QueryResult<u16> {
|
||||
self.get(name)
|
||||
.map(|_| self.get_u16(name))
|
||||
.unwrap_or(Ok(default))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct QueryResponse {
|
||||
pub raw: String,
|
||||
pub records: Vec<QueryRecord>,
|
||||
pub status: QueryStatus,
|
||||
}
|
||||
|
||||
pub struct QueryClient {
|
||||
stream: TcpStream,
|
||||
greeting: String,
|
||||
read_timeout: Duration,
|
||||
}
|
||||
|
||||
impl QueryClient {
|
||||
pub async fn connect<A: ToSocketAddrs>(addr: A) -> QueryResult<Self> {
|
||||
let stream = TcpStream::connect(addr).await?;
|
||||
Self::from_stream(stream).await
|
||||
}
|
||||
|
||||
pub async fn from_stream(stream: TcpStream) -> QueryResult<Self> {
|
||||
let mut client = Self {
|
||||
stream,
|
||||
greeting: String::new(),
|
||||
read_timeout: DEFAULT_READ_TIMEOUT,
|
||||
};
|
||||
client.greeting = client.read_greeting().await?;
|
||||
Ok(client)
|
||||
}
|
||||
|
||||
pub fn greeting(&self) -> &str {
|
||||
&self.greeting
|
||||
}
|
||||
|
||||
pub fn set_read_timeout(&mut self, timeout: Duration) {
|
||||
self.read_timeout = timeout;
|
||||
}
|
||||
|
||||
pub async fn execute(&mut self, command: &str) -> QueryResult<QueryResponse> {
|
||||
self.write_command(command).await?;
|
||||
let raw = self.read_until_status().await?;
|
||||
decode_response(raw)
|
||||
}
|
||||
|
||||
pub async fn login(&mut self, user: &str, password: &str) -> QueryResult<()> {
|
||||
let command = format!("login {} {}", escape(user), escape(password));
|
||||
self.execute(&command).await.map(|_| ())
|
||||
}
|
||||
|
||||
pub async fn use_server(&mut self, server_id: u64) -> QueryResult<()> {
|
||||
self.execute(&format!("use {server_id}")).await.map(|_| ())
|
||||
}
|
||||
|
||||
pub async fn whoami(&mut self) -> QueryResult<Option<QueryRecord>> {
|
||||
let mut response = self.execute("whoami").await?;
|
||||
Ok(response.records.pop())
|
||||
}
|
||||
|
||||
pub async fn permission_list(&mut self) -> QueryResult<Vec<PermissionInfo>> {
|
||||
let response = self.execute("permissionlist").await?;
|
||||
records_to_permissions(&response.records)
|
||||
}
|
||||
|
||||
pub async fn channel_list(&mut self) -> QueryResult<Vec<ServerQueryChannel>> {
|
||||
let response = self.execute("channellist").await?;
|
||||
records_to_channels(&response.records)
|
||||
}
|
||||
|
||||
pub async fn client_list(&mut self) -> QueryResult<Vec<ServerQueryClient>> {
|
||||
let response = self.execute("clientlist").await?;
|
||||
records_to_clients(&response.records)
|
||||
}
|
||||
|
||||
pub async fn server_info(&mut self) -> QueryResult<Option<ServerQueryServerInfo>> {
|
||||
let response = self.execute("serverinfo").await?;
|
||||
response
|
||||
.records
|
||||
.first()
|
||||
.map(record_to_server_info)
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn write_command(&mut self, command: &str) -> QueryResult<()> {
|
||||
let mut payload = command.to_string();
|
||||
if !payload.ends_with("\n\r") && !payload.ends_with("\r\n") {
|
||||
payload.push_str("\n\r");
|
||||
}
|
||||
|
||||
self.stream.write_all(payload.as_bytes()).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn read_greeting(&mut self) -> QueryResult<String> {
|
||||
self.read_until(|content| content.contains(GREETING_MARKER))
|
||||
.await
|
||||
}
|
||||
|
||||
async fn read_until_status(&mut self) -> QueryResult<String> {
|
||||
self.read_until(contains_status_line).await
|
||||
}
|
||||
|
||||
async fn read_until<F>(&mut self, done: F) -> QueryResult<String>
|
||||
where
|
||||
F: Fn(&str) -> bool,
|
||||
{
|
||||
let timeout = self.read_timeout;
|
||||
let stream = &mut self.stream;
|
||||
|
||||
tokio::time::timeout(timeout, async move {
|
||||
let mut data = Vec::new();
|
||||
let mut buffer = [0u8; BUFFER_SIZE];
|
||||
|
||||
loop {
|
||||
let len = stream.read(&mut buffer).await?;
|
||||
if len == 0 {
|
||||
return Err(QueryError::ConnectionClosed);
|
||||
}
|
||||
|
||||
data.extend_from_slice(&buffer[..len]);
|
||||
let content = String::from_utf8_lossy(&data);
|
||||
if done(&content) {
|
||||
return Ok(content.into_owned());
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|_| QueryError::Timeout)?
|
||||
}
|
||||
}
|
||||
|
||||
pub fn records_to_permissions(records: &[QueryRecord]) -> QueryResult<Vec<PermissionInfo>> {
|
||||
records.iter().map(record_to_permission).collect()
|
||||
}
|
||||
|
||||
pub fn record_to_permission(record: &QueryRecord) -> QueryResult<PermissionInfo> {
|
||||
Ok(PermissionInfo {
|
||||
id: PermissionId(record.get_u32("permid")?),
|
||||
name: record.require("permname")?.to_string(),
|
||||
description: record.get("permdesc").unwrap_or_default().to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn records_to_channels(records: &[QueryRecord]) -> QueryResult<Vec<ServerQueryChannel>> {
|
||||
records.iter().map(record_to_channel).collect()
|
||||
}
|
||||
|
||||
pub fn record_to_channel(record: &QueryRecord) -> QueryResult<ServerQueryChannel> {
|
||||
Ok(ServerQueryChannel {
|
||||
id: ChannelId(record.get_u64("cid")?),
|
||||
parent_id: ChannelId(record.get_u64_or("pid", 0)?),
|
||||
order: ChannelId(record.get_u64_or("channel_order", 0)?),
|
||||
name: record.require("channel_name")?.to_string(),
|
||||
total_clients: record.get_u32_or("total_clients", 0)?,
|
||||
needed_subscribe_power: record.get_i32_or("channel_needed_subscribe_power", 0)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn records_to_clients(records: &[QueryRecord]) -> QueryResult<Vec<ServerQueryClient>> {
|
||||
records.iter().map(record_to_client).collect()
|
||||
}
|
||||
|
||||
pub fn record_to_client(record: &QueryRecord) -> QueryResult<ServerQueryClient> {
|
||||
let client_type = if record.get_u32_or("client_type", 0)? == 0 {
|
||||
ClientType::Normal
|
||||
} else {
|
||||
ClientType::Query { admin: false }
|
||||
};
|
||||
|
||||
Ok(ServerQueryClient {
|
||||
id: ClientId(record.get_u16("clid")?),
|
||||
channel_id: ChannelId(record.get_u64("cid")?),
|
||||
database_id: ClientDbId(record.get_u64_or("client_database_id", 0)?),
|
||||
nickname: record.require("client_nickname")?.to_string(),
|
||||
client_type,
|
||||
unique_identifier: record
|
||||
.get("client_unique_identifier")
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn record_to_server_info(record: &QueryRecord) -> QueryResult<ServerQueryServerInfo> {
|
||||
Ok(ServerQueryServerInfo {
|
||||
name: record.require("virtualserver_name")?.to_string(),
|
||||
platform: record
|
||||
.get("virtualserver_platform")
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
version: record
|
||||
.get("virtualserver_version")
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
max_clients: record.get_u16_or("virtualserver_maxclients", 0)?,
|
||||
clients_online: record.get_u16_or("virtualserver_clientsonline", 0)?,
|
||||
channels_online: record.get_u64_or("virtualserver_channelsonline", 0)?,
|
||||
uptime: record.get_u64_or("virtualserver_uptime", 0)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn decode_response(raw: String) -> QueryResult<QueryResponse> {
|
||||
let mut records = Vec::new();
|
||||
let mut status = None;
|
||||
|
||||
for line in raw
|
||||
.lines()
|
||||
.map(normalize_line)
|
||||
.filter(|line| !line.is_empty())
|
||||
{
|
||||
if let Some(status_line) = line.strip_prefix("error ") {
|
||||
status = Some(parse_status(status_line)?);
|
||||
break;
|
||||
}
|
||||
|
||||
for record in line.split('|').filter(|record| !record.is_empty()) {
|
||||
records.push(parse_record(record)?);
|
||||
}
|
||||
}
|
||||
|
||||
let status = status.ok_or(QueryError::MissingStatus)?;
|
||||
if status.id != 0 {
|
||||
return Err(QueryError::Status {
|
||||
id: status.id,
|
||||
message: status.message,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(QueryResponse {
|
||||
raw,
|
||||
records,
|
||||
status,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn escape(input: &str) -> String {
|
||||
let mut output = String::with_capacity(input.len());
|
||||
for ch in input.chars() {
|
||||
match ch {
|
||||
'\\' => output.push_str("\\\\"),
|
||||
' ' => output.push_str("\\s"),
|
||||
'|' => output.push_str("\\p"),
|
||||
'/' => output.push_str("\\/"),
|
||||
'\n' => output.push_str("\\n"),
|
||||
'\r' => output.push_str("\\r"),
|
||||
'\t' => output.push_str("\\t"),
|
||||
_ => output.push(ch),
|
||||
}
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
pub fn unescape(input: &str) -> QueryResult<String> {
|
||||
let mut output = String::with_capacity(input.len());
|
||||
let mut chars = input.chars();
|
||||
|
||||
while let Some(ch) = chars.next() {
|
||||
if ch != '\\' {
|
||||
output.push(ch);
|
||||
continue;
|
||||
}
|
||||
|
||||
match chars.next() {
|
||||
Some('s') => output.push(' '),
|
||||
Some('p') => output.push('|'),
|
||||
Some('/') => output.push('/'),
|
||||
Some('\\') => output.push('\\'),
|
||||
Some('a') => output.push('\u{0007}'),
|
||||
Some('b') => output.push('\u{0008}'),
|
||||
Some('f') => output.push('\u{000c}'),
|
||||
Some('n') => output.push('\n'),
|
||||
Some('r') => output.push('\r'),
|
||||
Some('t') => output.push('\t'),
|
||||
Some('v') => output.push('\u{000b}'),
|
||||
Some(other) => {
|
||||
return Err(QueryError::InvalidField(format!(
|
||||
"unknown escape \\{other}"
|
||||
)))
|
||||
}
|
||||
None => return Err(QueryError::InvalidField("trailing escape".to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
fn parse_status(input: &str) -> QueryResult<QueryStatus> {
|
||||
let fields = parse_fields(input)?;
|
||||
let id = fields
|
||||
.iter()
|
||||
.find(|(key, _)| key == "id")
|
||||
.map(|(_, value)| value.as_str())
|
||||
.ok_or_else(|| QueryError::InvalidField(input.to_string()))?;
|
||||
let id = id
|
||||
.parse::<u32>()
|
||||
.map_err(|_| QueryError::InvalidStatusId(id.to_string()))?;
|
||||
let message = fields
|
||||
.iter()
|
||||
.find(|(key, _)| key == "msg")
|
||||
.map(|(_, value)| value.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(QueryStatus {
|
||||
id,
|
||||
message,
|
||||
fields,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_record(input: &str) -> QueryResult<QueryRecord> {
|
||||
Ok(QueryRecord {
|
||||
fields: parse_fields(input)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_fields(input: &str) -> QueryResult<Vec<(String, String)>> {
|
||||
input
|
||||
.split(' ')
|
||||
.filter(|field| !field.is_empty())
|
||||
.map(|field| {
|
||||
let (key, value) = field.split_once('=').unwrap_or((field, ""));
|
||||
Ok((unescape(key)?, unescape(value)?))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn contains_status_line(content: &str) -> bool {
|
||||
content
|
||||
.lines()
|
||||
.map(normalize_line)
|
||||
.any(|line| line.starts_with("error id="))
|
||||
}
|
||||
|
||||
fn normalize_line(line: &str) -> &str {
|
||||
line.trim_end_matches('\r').trim_end_matches('\n')
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
#[test]
|
||||
fn decodes_success_response_records() {
|
||||
let response = decode_response(
|
||||
"clid=7 client_database_id=12 client_nickname=hello\\sworld|clid=8 client_nickname=a\\pb\r\nerror id=0 msg=ok\r\n"
|
||||
.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status.id, 0);
|
||||
assert_eq!(response.records.len(), 2);
|
||||
assert_eq!(
|
||||
response.records[0].get("client_nickname"),
|
||||
Some("hello world")
|
||||
);
|
||||
assert_eq!(response.records[1].get("client_nickname"), Some("a|b"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_error_status() {
|
||||
let error =
|
||||
decode_response("error id=256 msg=command\\snot\\sfound\n\r".to_string()).unwrap_err();
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
QueryError::Status { id: 256, message } if message == "command not found"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escapes_query_values() {
|
||||
assert_eq!(escape("a b|c/d\\e"), "a\\sb\\pc\\/d\\\\e");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_permissionlist_records() {
|
||||
let response = decode_response(
|
||||
"permid=1 permname=b_serverinstance_help_view permdesc=Retrieve\\sinformation\\sabout\\sServerQuery\\scommands|permid=32769 permname=i_needed_modify_power_serverinstance_help_view\r\nerror id=0 msg=ok\r\n"
|
||||
.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let permissions = records_to_permissions(&response.records).unwrap();
|
||||
assert_eq!(permissions.len(), 2);
|
||||
assert_eq!(permissions[0].id, PermissionId(1));
|
||||
assert_eq!(permissions[0].name, "b_serverinstance_help_view");
|
||||
assert_eq!(
|
||||
permissions[0].description,
|
||||
"Retrieve information about ServerQuery commands"
|
||||
);
|
||||
assert_eq!(permissions[1].id, PermissionId(32769));
|
||||
assert_eq!(permissions[1].description, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_common_serverquery_records() {
|
||||
let response = decode_response(
|
||||
"cid=1 pid=0 channel_order=0 channel_name=Lobby total_clients=2 channel_needed_subscribe_power=0|cid=2 pid=1 channel_order=1 channel_name=Voice\\sRoom total_clients=0 channel_needed_subscribe_power=25\r\nerror id=0 msg=ok\r\n"
|
||||
.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
let channels = records_to_channels(&response.records).unwrap();
|
||||
assert_eq!(channels.len(), 2);
|
||||
assert_eq!(channels[0].id, ChannelId(1));
|
||||
assert_eq!(channels[1].name, "Voice Room");
|
||||
assert_eq!(channels[1].needed_subscribe_power, 25);
|
||||
|
||||
let response = decode_response(
|
||||
"clid=8 cid=1 client_database_id=1 client_nickname=serveradmin client_type=1 client_unique_identifier=serveradmin|clid=9 cid=2 client_database_id=42 client_nickname=Normal\\sUser client_type=0 client_unique_identifier=abc\r\nerror id=0 msg=ok\r\n"
|
||||
.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
let clients = records_to_clients(&response.records).unwrap();
|
||||
assert_eq!(clients.len(), 2);
|
||||
assert_eq!(clients[0].id, ClientId(8));
|
||||
assert_eq!(clients[0].client_type, ClientType::Query { admin: false });
|
||||
assert_eq!(clients[1].nickname, "Normal User");
|
||||
assert_eq!(clients[1].client_type, ClientType::Normal);
|
||||
|
||||
let response = decode_response(
|
||||
"virtualserver_name=Test\\sServer virtualserver_platform=Linux virtualserver_version=3.13.7 virtualserver_maxclients=32 virtualserver_clientsonline=4 virtualserver_channelsonline=12 virtualserver_uptime=3600\r\nerror id=0 msg=ok\r\n"
|
||||
.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
let server = record_to_server_info(&response.records[0]).unwrap();
|
||||
assert_eq!(server.name, "Test Server");
|
||||
assert_eq!(server.max_clients, 32);
|
||||
assert_eq!(server.clients_online, 4);
|
||||
assert_eq!(server.channels_online, 12);
|
||||
assert_eq!(server.uptime, 3600);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn executes_commands_against_mock_server() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
|
||||
let server = tokio::spawn(async move {
|
||||
let (mut stream, _) = listener.accept().await.unwrap();
|
||||
stream
|
||||
.write_all(b"TS3\r\nWelcome to the TeamSpeak 3 ServerQuery interface\r\n")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let command = read_command(&mut stream).await;
|
||||
assert_eq!(command, "login serveradmin secret\\spass");
|
||||
stream.write_all(b"error id=0 msg=ok\r\n").await.unwrap();
|
||||
|
||||
let command = read_command(&mut stream).await;
|
||||
assert_eq!(command, "whoami");
|
||||
stream
|
||||
.write_all(
|
||||
b"clid=4 client_database_id=10 client_nickname=serveradmin\r\nerror id=0 msg=ok\r\n",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let command = read_command(&mut stream).await;
|
||||
assert_eq!(command, "permissionlist");
|
||||
stream
|
||||
.write_all(
|
||||
b"permid=24 permname=b_virtualserver_select permdesc=Select\\sa\\svirtual\\sserver|permid=248 permname=i_ft_quota_mb_upload_per_client permdesc=Upload\\squota\\sper\\sclient\\sin\\sMByte\r\nerror id=0 msg=ok\r\n",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let command = read_command(&mut stream).await;
|
||||
assert_eq!(command, "channellist");
|
||||
stream
|
||||
.write_all(
|
||||
b"cid=1 pid=0 channel_order=0 channel_name=Lobby total_clients=1 channel_needed_subscribe_power=0\r\nerror id=0 msg=ok\r\n",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let command = read_command(&mut stream).await;
|
||||
assert_eq!(command, "clientlist");
|
||||
stream
|
||||
.write_all(
|
||||
b"clid=9 cid=1 client_database_id=42 client_nickname=Normal\\sUser client_type=0 client_unique_identifier=abc\r\nerror id=0 msg=ok\r\n",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let command = read_command(&mut stream).await;
|
||||
assert_eq!(command, "serverinfo");
|
||||
stream
|
||||
.write_all(
|
||||
b"virtualserver_name=Mock\\sServer virtualserver_platform=Linux virtualserver_version=3.13.7 virtualserver_maxclients=32 virtualserver_clientsonline=1 virtualserver_channelsonline=1 virtualserver_uptime=99\r\nerror id=0 msg=ok\r\n",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
let mut client = QueryClient::connect(addr).await.unwrap();
|
||||
assert!(client.greeting().contains("TS3"));
|
||||
client.login("serveradmin", "secret pass").await.unwrap();
|
||||
|
||||
let whoami = client.whoami().await.unwrap().unwrap();
|
||||
assert_eq!(whoami.get("clid"), Some("4"));
|
||||
assert_eq!(whoami.get("client_database_id"), Some("10"));
|
||||
|
||||
let permissions = client.permission_list().await.unwrap();
|
||||
assert_eq!(permissions.len(), 2);
|
||||
assert_eq!(permissions[0].id, PermissionId(24));
|
||||
assert_eq!(permissions[0].name, "b_virtualserver_select");
|
||||
assert_eq!(permissions[1].id, PermissionId(248));
|
||||
|
||||
let channels = client.channel_list().await.unwrap();
|
||||
assert_eq!(channels[0].name, "Lobby");
|
||||
assert_eq!(channels[0].total_clients, 1);
|
||||
|
||||
let clients = client.client_list().await.unwrap();
|
||||
assert_eq!(clients[0].nickname, "Normal User");
|
||||
assert_eq!(clients[0].database_id, ClientDbId(42));
|
||||
|
||||
let server_info = client.server_info().await.unwrap().unwrap();
|
||||
assert_eq!(server_info.name, "Mock Server");
|
||||
assert_eq!(server_info.uptime, 99);
|
||||
|
||||
server.await.unwrap();
|
||||
}
|
||||
|
||||
async fn read_command(stream: &mut TcpStream) -> String {
|
||||
let mut data = Vec::new();
|
||||
let mut buffer = [0u8; 64];
|
||||
loop {
|
||||
let len = stream.read(&mut buffer).await.unwrap();
|
||||
assert_ne!(len, 0);
|
||||
data.extend_from_slice(&buffer[..len]);
|
||||
let content = String::from_utf8_lossy(&data);
|
||||
if content.ends_with("\n\r") || content.ends_with("\r\n") {
|
||||
return content.trim_end_matches(['\n', '\r']).to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
-10
@@ -1,9 +1,9 @@
|
||||
//! 书签管理
|
||||
|
||||
use rusqlite::params;
|
||||
use chrono::Utc;
|
||||
use rusqlite::params;
|
||||
|
||||
use super::{DatabaseManager, DatabaseResult, DatabaseError};
|
||||
use super::{DatabaseError, DatabaseManager, DatabaseResult};
|
||||
|
||||
/// 书签信息
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -64,7 +64,8 @@ impl DatabaseManager {
|
||||
"SELECT id, name, address, port, nickname, server_password, channel, channel_password, default_token, auto_connect, last_connected, created_at, updated_at FROM bookmarks WHERE id = ?1"
|
||||
)?;
|
||||
|
||||
let bookmark = stmt.query_row(params![id], |row| {
|
||||
let bookmark = stmt
|
||||
.query_row(params![id], |row| {
|
||||
Ok(Bookmark {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
@@ -80,7 +81,8 @@ impl DatabaseManager {
|
||||
created_at: row.get(11)?,
|
||||
updated_at: row.get(12)?,
|
||||
})
|
||||
}).map_err(|_| DatabaseError::NotFound(format!("书签 {} 未找到", id)))?;
|
||||
})
|
||||
.map_err(|_| DatabaseError::NotFound(format!("书签 {} 未找到", id)))?;
|
||||
|
||||
Ok(bookmark)
|
||||
}
|
||||
@@ -92,7 +94,8 @@ impl DatabaseManager {
|
||||
"SELECT id, name, address, port, nickname, server_password, channel, channel_password, default_token, auto_connect, last_connected, created_at, updated_at FROM bookmarks ORDER BY name"
|
||||
)?;
|
||||
|
||||
let bookmarks = stmt.query_map([], |row| {
|
||||
let bookmarks = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(Bookmark {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
@@ -108,7 +111,8 @@ impl DatabaseManager {
|
||||
created_at: row.get(11)?,
|
||||
updated_at: row.get(12)?,
|
||||
})
|
||||
})?.collect::<Result<Vec<_>, _>>()?;
|
||||
})?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(bookmarks)
|
||||
}
|
||||
@@ -157,10 +161,8 @@ impl DatabaseManager {
|
||||
|
||||
/// 删除书签
|
||||
pub fn delete_bookmark(&self, id: &str) -> DatabaseResult<()> {
|
||||
self.connection().execute(
|
||||
"DELETE FROM bookmarks WHERE id = ?1",
|
||||
params![id],
|
||||
)?;
|
||||
self.connection()
|
||||
.execute("DELETE FROM bookmarks WHERE id = ?1", params![id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
+9
-11
@@ -1,8 +1,8 @@
|
||||
//! 配置管理
|
||||
|
||||
use chrono::Utc;
|
||||
use rusqlite::params;
|
||||
use rusqlite::OptionalExtension;
|
||||
use chrono::Utc;
|
||||
|
||||
use super::{DatabaseManager, DatabaseResult};
|
||||
|
||||
@@ -11,9 +11,9 @@ impl DatabaseManager {
|
||||
let conn = self.connection();
|
||||
let mut stmt = conn.prepare("SELECT value FROM settings WHERE key = ?1")?;
|
||||
|
||||
let result = stmt.query_row(params![key], |row| {
|
||||
Ok(row.get::<_, String>(0)?)
|
||||
}).optional()?;
|
||||
let result = stmt
|
||||
.query_row(params![key], |row| Ok(row.get::<_, String>(0)?))
|
||||
.optional()?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
@@ -30,10 +30,8 @@ impl DatabaseManager {
|
||||
}
|
||||
|
||||
pub fn delete_setting(&self, key: &str) -> DatabaseResult<()> {
|
||||
self.connection().execute(
|
||||
"DELETE FROM settings WHERE key = ?1",
|
||||
params![key],
|
||||
)?;
|
||||
self.connection()
|
||||
.execute("DELETE FROM settings WHERE key = ?1", params![key])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -41,9 +39,9 @@ impl DatabaseManager {
|
||||
let conn = self.connection();
|
||||
let mut stmt = conn.prepare("SELECT key, value FROM settings ORDER BY key")?;
|
||||
|
||||
let settings = stmt.query_map([], |row| {
|
||||
Ok((row.get(0)?, row.get(1)?))
|
||||
})?.collect::<Result<Vec<_>, _>>()?;
|
||||
let settings = stmt
|
||||
.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(settings)
|
||||
}
|
||||
|
||||
+18
-11
@@ -1,9 +1,9 @@
|
||||
//! 身份管理
|
||||
|
||||
use rusqlite::params;
|
||||
use chrono::Utc;
|
||||
use rusqlite::params;
|
||||
|
||||
use super::{DatabaseManager, DatabaseResult, DatabaseError};
|
||||
use super::{DatabaseError, DatabaseManager, DatabaseResult};
|
||||
|
||||
/// 身份信息
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -46,7 +46,8 @@ impl DatabaseManager {
|
||||
"SELECT id, name, private_key, counter, max_counter, created_at, updated_at FROM identities WHERE id = ?1"
|
||||
)?;
|
||||
|
||||
let identity = stmt.query_row(params![id], |row| {
|
||||
let identity = stmt
|
||||
.query_row(params![id], |row| {
|
||||
Ok(Identity {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
@@ -56,7 +57,8 @@ impl DatabaseManager {
|
||||
created_at: row.get(5)?,
|
||||
updated_at: row.get(6)?,
|
||||
})
|
||||
}).map_err(|_| DatabaseError::NotFound(format!("身份 {} 未找到", id)))?;
|
||||
})
|
||||
.map_err(|_| DatabaseError::NotFound(format!("身份 {} 未找到", id)))?;
|
||||
|
||||
Ok(identity)
|
||||
}
|
||||
@@ -68,7 +70,8 @@ impl DatabaseManager {
|
||||
"SELECT id, name, private_key, counter, max_counter, created_at, updated_at FROM identities ORDER BY name"
|
||||
)?;
|
||||
|
||||
let identities = stmt.query_map([], |row| {
|
||||
let identities = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(Identity {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
@@ -78,13 +81,19 @@ impl DatabaseManager {
|
||||
created_at: row.get(5)?,
|
||||
updated_at: row.get(6)?,
|
||||
})
|
||||
})?.collect::<Result<Vec<_>, _>>()?;
|
||||
})?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(identities)
|
||||
}
|
||||
|
||||
/// 更新身份
|
||||
pub fn update_identity(&self, id: &str, name: Option<&str>, counter: Option<u64>) -> DatabaseResult<()> {
|
||||
pub fn update_identity(
|
||||
&self,
|
||||
id: &str,
|
||||
name: Option<&str>,
|
||||
counter: Option<u64>,
|
||||
) -> DatabaseResult<()> {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
if let Some(name) = name {
|
||||
@@ -106,10 +115,8 @@ impl DatabaseManager {
|
||||
|
||||
/// 删除身份
|
||||
pub fn delete_identity(&self, id: &str) -> DatabaseResult<()> {
|
||||
self.connection().execute(
|
||||
"DELETE FROM identities WHERE id = ?1",
|
||||
params![id],
|
||||
)?;
|
||||
self.connection()
|
||||
.execute("DELETE FROM identities WHERE id = ?1", params![id])?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
+4
-4
@@ -1,12 +1,12 @@
|
||||
//! 数据存储
|
||||
|
||||
pub mod identity;
|
||||
pub mod bookmark;
|
||||
pub mod message;
|
||||
pub mod config;
|
||||
pub mod identity;
|
||||
pub mod message;
|
||||
|
||||
pub use identity::*;
|
||||
pub use bookmark::*;
|
||||
pub use identity::*;
|
||||
pub use message::*;
|
||||
|
||||
use thiserror::Error;
|
||||
@@ -93,7 +93,7 @@ impl DatabaseManager {
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
"
|
||||
",
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+14
-14
@@ -1,9 +1,9 @@
|
||||
//! 消息管理
|
||||
|
||||
use rusqlite::params;
|
||||
use chrono::Utc;
|
||||
use rusqlite::params;
|
||||
|
||||
use super::{DatabaseManager, DatabaseResult, DatabaseError};
|
||||
use super::{DatabaseError, DatabaseManager, DatabaseResult};
|
||||
|
||||
/// 消息信息
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -62,7 +62,8 @@ impl DatabaseManager {
|
||||
"SELECT id, server_address, invoker_id, invoker_name, invoker_uid, target_type, target_id, message, is_read, timestamp FROM messages WHERE id = ?1"
|
||||
)?;
|
||||
|
||||
let message = stmt.query_row(params![id], |row| {
|
||||
let message = stmt
|
||||
.query_row(params![id], |row| {
|
||||
Ok(Message {
|
||||
id: row.get(0)?,
|
||||
server_address: row.get(1)?,
|
||||
@@ -75,7 +76,8 @@ impl DatabaseManager {
|
||||
is_read: row.get::<_, i32>(8)? != 0,
|
||||
timestamp: row.get(9)?,
|
||||
})
|
||||
}).map_err(|_| DatabaseError::NotFound(format!("消息 {} 未找到", id)))?;
|
||||
})
|
||||
.map_err(|_| DatabaseError::NotFound(format!("消息 {} 未找到", id)))?;
|
||||
|
||||
Ok(message)
|
||||
}
|
||||
@@ -92,7 +94,8 @@ impl DatabaseManager {
|
||||
"SELECT id, server_address, invoker_id, invoker_name, invoker_uid, target_type, target_id, message, is_read, timestamp FROM messages WHERE server_address = ?1 ORDER BY timestamp DESC LIMIT ?2 OFFSET ?3"
|
||||
)?;
|
||||
|
||||
let messages = stmt.query_map(params![server_address, limit, offset], |row| {
|
||||
let messages = stmt
|
||||
.query_map(params![server_address, limit, offset], |row| {
|
||||
Ok(Message {
|
||||
id: row.get(0)?,
|
||||
server_address: row.get(1)?,
|
||||
@@ -105,26 +108,23 @@ impl DatabaseManager {
|
||||
is_read: row.get::<_, i32>(8)? != 0,
|
||||
timestamp: row.get(9)?,
|
||||
})
|
||||
})?.collect::<Result<Vec<_>, _>>()?;
|
||||
})?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(messages)
|
||||
}
|
||||
|
||||
/// 标记消息为已读
|
||||
pub fn mark_message_read(&self, id: i64) -> DatabaseResult<()> {
|
||||
self.connection().execute(
|
||||
"UPDATE messages SET is_read = 1 WHERE id = ?1",
|
||||
params![id],
|
||||
)?;
|
||||
self.connection()
|
||||
.execute("UPDATE messages SET is_read = 1 WHERE id = ?1", params![id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 删除消息
|
||||
pub fn delete_message(&self, id: i64) -> DatabaseResult<()> {
|
||||
self.connection().execute(
|
||||
"DELETE FROM messages WHERE id = ?1",
|
||||
params![id],
|
||||
)?;
|
||||
self.connection()
|
||||
.execute("DELETE FROM messages WHERE id = ?1", params![id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user