trident_client/
server.rs

1use crate::error::Error;
2use axum::extract::Path;
3use axum::extract::State;
4use axum::http::header;
5use axum::http::StatusCode;
6use axum::response::Html;
7use axum::response::Response;
8use axum::routing::get;
9use axum::Router;
10use notify::Config;
11use notify::RecommendedWatcher;
12use notify::RecursiveMode;
13use notify::Watcher;
14use serde::Serialize;
15use std::collections::HashMap;
16use std::path::PathBuf;
17use std::sync::Arc;
18use std::sync::Mutex;
19use std::time::SystemTime;
20use tokio::fs;
21use tower_http::cors::CorsLayer;
22use tower_http::services::ServeDir;
23
24#[derive(Debug, Clone, Serialize)]
25pub struct FileInfo {
26    pub name: String,
27    pub path: String,
28    pub modified: SystemTime,
29    pub size: u64,
30    pub file_type: FileType,
31}
32
33#[derive(Debug, Clone, Serialize)]
34pub enum FileType {
35    Dashboard,
36    Log,
37}
38
39#[derive(Clone)]
40struct AppState {
41    files: Arc<Mutex<HashMap<String, FileInfo>>>,
42    base_path: PathBuf,
43}
44
45impl AppState {
46    fn new(base_path: PathBuf) -> Self {
47        Self {
48            files: Arc::new(Mutex::new(HashMap::new())),
49            base_path,
50        }
51    }
52
53    fn update_files(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
54        let mut files = self.files.lock().unwrap();
55        files.clear();
56
57        if !self.base_path.exists() {
58            return Ok(());
59        }
60
61        for entry in std::fs::read_dir(&self.base_path)? {
62            let entry = entry?;
63            let path = entry.path();
64
65            if path.is_file() {
66                if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
67                    if let Some(file_type) = determine_file_type(&path) {
68                        let metadata = entry.metadata()?;
69
70                        let info = FileInfo {
71                            name: name.to_string(),
72                            path: path.to_string_lossy().to_string(),
73                            modified: metadata.modified()?,
74                            size: metadata.len(),
75                            file_type,
76                        };
77                        files.insert(name.to_string(), info);
78                    }
79                }
80            }
81        }
82
83        Ok(())
84    }
85
86    fn get_files(&self) -> Vec<FileInfo> {
87        let files = self.files.lock().unwrap();
88        let mut sorted: Vec<_> = files.values().cloned().collect();
89        sorted.sort_by(|a, b| b.modified.cmp(&a.modified));
90        sorted
91    }
92}
93
94fn determine_file_type(path: &std::path::Path) -> Option<FileType> {
95    if let Some(extension) = path.extension().and_then(|e| e.to_str()) {
96        match extension.to_lowercase().as_str() {
97            "html" | "htm" => Some(FileType::Dashboard),
98            "log" => Some(FileType::Log),
99            _ => None,
100        }
101    } else {
102        None
103    }
104}
105
106async fn file_list(State(state): State<AppState>) -> Result<Html<String>, StatusCode> {
107    let _ = state.update_files();
108    let files = state.get_files();
109
110    let html = generate_file_list_html(files);
111    Ok(Html(html))
112}
113
114async fn serve_file(
115    State(state): State<AppState>,
116    Path(filename): Path<String>,
117) -> Result<Response, StatusCode> {
118    let file_path = state.base_path.join(&filename);
119
120    if !file_path.exists() || !file_path.is_file() {
121        return Err(StatusCode::NOT_FOUND);
122    }
123
124    if let Some(file_type) = determine_file_type(&file_path) {
125        match fs::read_to_string(&file_path).await {
126            Ok(content) => {
127                let (content_type, formatted_content) = match file_type {
128                    FileType::Dashboard => ("text/html; charset=utf-8", content),
129                    FileType::Log => (
130                        "text/html; charset=utf-8",
131                        format_log_as_html(&content, &filename),
132                    ),
133                };
134
135                let response = Response::builder()
136                    .header(header::CONTENT_TYPE, content_type)
137                    .body(formatted_content.into())
138                    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
139                Ok(response)
140            }
141            Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
142        }
143    } else {
144        Err(StatusCode::FORBIDDEN)
145    }
146}
147
148fn generate_file_list_html(files: Vec<FileInfo>) -> String {
149    // Load the template
150    let template = include_str!("server/dashboard_list_template.html");
151
152    // Separate files by type
153    let mut dashboards: Vec<&FileInfo> = files
154        .iter()
155        .filter(|f| matches!(f.file_type, FileType::Dashboard))
156        .collect();
157    let mut logs: Vec<&FileInfo> = files
158        .iter()
159        .filter(|f| matches!(f.file_type, FileType::Log))
160        .collect();
161
162    // Sort each type by modified time
163    dashboards.sort_by(|a, b| b.modified.cmp(&a.modified));
164    logs.sort_by(|a, b| b.modified.cmp(&a.modified));
165
166    let sections = if files.is_empty() {
167        r#"
168        <div class="no-dashboards">
169            <div class="no-dashboards-icon">📊</div>
170            <h3>No Files Available</h3>
171            <p>No fuzzing files found in the monitored directory.</p>
172            <p style="font-size: 0.9em; margin-top: 8px;">Start fuzzing to see results here.</p>
173        </div>
174        "#
175        .to_string()
176    } else {
177        let mut sections = String::new();
178
179        // Dashboard section
180        if !dashboards.is_empty() {
181            sections.push_str(&format!(
182                r#"
183                <div class="section-header">
184                    <h2>📊 Fuzzing Dashboards <span class="count">({} files)</span></h2>
185                </div>
186                <div class="dashboards-grid">
187                "#,
188                dashboards.len()
189            ));
190
191            for dashboard in dashboards {
192                let time_str = format_time(dashboard.modified);
193                sections.push_str(&format!(
194                    r#"
195                    <div class="file-item dashboard-type">
196                        <div class="file-header">
197                            <h3 class="file-title">📊 {}</h3>
198                            <div class="file-meta">
199                                <span class="file-time">{}</span>
200                            </div>
201                        </div>
202                        <div class="file-actions">
203                            <a href="/file/{}" class="btn btn-primary">
204                                View Dashboard
205                            </a>
206                        </div>
207                    </div>
208                    "#,
209                    dashboard.name, time_str, dashboard.name
210                ));
211            }
212
213            sections.push_str("</div>");
214        }
215
216        // Debug logs section
217        if !logs.is_empty() {
218            sections.push_str(&format!(
219                r#"
220                <div class="section-header">
221                    <h2>📋 Debug Logs <span class="count">({} files)</span></h2>
222                </div>
223                <div class="dashboards-grid">
224                "#,
225                logs.len()
226            ));
227
228            for log in logs {
229                let time_str = format_time(log.modified);
230                sections.push_str(&format!(
231                    r#"
232                    <div class="file-item log-type">
233                        <div class="file-header">
234                            <h3 class="file-title">📋 {}</h3>
235                            <div class="file-meta">
236                                <span class="file-time">{}</span>
237                            </div>
238                        </div>
239                        <div class="file-actions">
240                            <a href="/file/{}" class="btn btn-secondary">
241                                View Log
242                            </a>
243                        </div>
244                    </div>
245                    "#,
246                    log.name, time_str, log.name
247                ));
248            }
249
250            sections.push_str("</div>");
251        }
252
253        sections
254    };
255
256    // Replace template variables
257    template.replace("{{DASHBOARD_ITEMS}}", &sections)
258}
259
260fn format_time(time: SystemTime) -> String {
261    use std::time::UNIX_EPOCH;
262
263    match time.duration_since(UNIX_EPOCH) {
264        Ok(duration) => {
265            let secs = duration.as_secs();
266            let now_secs = SystemTime::now()
267                .duration_since(UNIX_EPOCH)
268                .unwrap_or_default()
269                .as_secs();
270
271            let diff = now_secs.saturating_sub(secs);
272
273            if diff < 60 {
274                "Just now".to_string()
275            } else if diff < 3600 {
276                format!("{} min ago", diff / 60)
277            } else if diff < 86400 {
278                format!("{} hr ago", diff / 3600)
279            } else {
280                format!("{} days ago", diff / 86400)
281            }
282        }
283        Err(_) => "Unknown".to_string(),
284    }
285}
286
287pub struct DashboardServer {
288    directory: PathBuf,
289    host: String,
290    port: u16,
291}
292
293impl DashboardServer {
294    pub fn new(directory: impl Into<PathBuf>, host: String, port: u16) -> Self {
295        Self {
296            directory: directory.into(),
297            host,
298            port,
299        }
300    }
301
302    pub async fn start(&self) -> Result<(), Error> {
303        let base_path = self.directory.clone();
304
305        // Create directory if it doesn't exist
306        if !base_path.exists() {
307            tokio::fs::create_dir_all(&base_path).await?;
308        }
309
310        println!("🚀 Starting Trident Dashboard Server");
311        println!("📁 Serving dashboards from: {}", base_path.display());
312        println!("🌐 Server running at: http://{}:{}", self.host, self.port);
313        println!("📊 Dashboard list: http://{}:{}/", self.host, self.port);
314        println!("🔄 Web page auto-refreshes every 3 seconds");
315        println!();
316
317        let state = AppState::new(base_path.clone());
318
319        // Initial scan for files
320        let _ = state.update_files();
321
322        // Set up file watcher for real-time updates
323        let watch_state = state.clone();
324        let watch_path = base_path.clone();
325        tokio::spawn(async move {
326            let (tx, mut rx) = tokio::sync::mpsc::channel(100);
327
328            let mut watcher = RecommendedWatcher::new(
329                move |res| {
330                    let _ = tx.blocking_send(res);
331                },
332                Config::default(),
333            )
334            .unwrap();
335
336            let _ = watcher.watch(&watch_path, RecursiveMode::NonRecursive);
337
338            while let Some(_event) = rx.recv().await {
339                let _ = watch_state.update_files();
340            }
341        });
342
343        // Build the router
344        let app = Router::new()
345            .route("/", get(file_list))
346            .route("/file/:filename", get(serve_file))
347            .route("/dashboard/:filename", get(serve_file)) // Backward compatibility
348            .nest_service("/static", ServeDir::new(&base_path))
349            .layer(CorsLayer::permissive())
350            .with_state(state);
351
352        // Start the server
353        let addr = format!("{}:{}", self.host, self.port);
354        let listener = tokio::net::TcpListener::bind(&addr).await?;
355
356        println!("✅ Server started successfully!");
357        println!("Press Ctrl+C to stop the server");
358        println!();
359
360        axum::serve(listener, app).await?;
361
362        Ok(())
363    }
364}
365
366fn format_log_as_html(content: &str, filename: &str) -> String {
367    let highlighted_content = content
368        .lines()
369        .map(|line| {
370            let escaped = html_escape(line);
371            if line.contains("ERROR") {
372                format!("<div class=\"log-error\">{}</div>", escaped)
373            } else if line.contains("DEBUG") {
374                format!("<div class=\"log-debug\">{}</div>", escaped)
375            } else if line.contains("Program") && line.contains("invoke") {
376                format!("<div class=\"log-invoke\">{}</div>", escaped)
377            } else if line.contains("Program") && line.contains("success") {
378                format!("<div class=\"log-success\">{}</div>", escaped)
379            } else if line.contains("Program") && line.contains("failed") {
380                format!("<div class=\"log-failed\">{}</div>", escaped)
381            } else if line.contains("PANICKED") {
382                format!("<div class=\"log-panic\">{}</div>", escaped)
383            } else {
384                format!("<div class=\"log-line\">{}</div>", escaped)
385            }
386        })
387        .collect::<Vec<_>>()
388        .join("");
389
390    format!(
391        r#"<!DOCTYPE html>
392<html lang="en">
393<head>
394    <meta charset="UTF-8">
395    <meta name="viewport" content="width=device-width, initial-scale=1.0">
396    <title>{} - Trident Debug Log</title>
397    <style>
398        body {{
399            font-family: 'Courier New', monospace;
400            margin: 0;
401            padding: 20px;
402            background: #0f172a;
403            color: #e2e8f0;
404            font-size: 14px;
405            line-height: 1.4;
406        }}
407        .header {{
408            background: #1e293b;
409            padding: 20px;
410            border-radius: 8px;
411            margin-bottom: 20px;
412            border-left: 4px solid #3b82f6;
413        }}
414        .content {{
415            background: #1e293b;
416            padding: 20px;
417            border-radius: 8px;
418            max-height: 80vh;
419            overflow-y: auto;
420            border: 1px solid #334155;
421        }}
422        .log-line {{ color: #cbd5e1; }}
423        .log-error {{ color: #f87171; font-weight: bold; }}
424        .log-debug {{ color: #94a3b8; }}
425        .log-invoke {{ color: #22c55e; }}
426        .log-success {{ color: #22c55e; font-weight: bold; }}
427        .log-failed {{ color: #f87171; font-weight: bold; }}
428        .log-panic {{ color: #fbbf24; font-weight: bold; background: rgba(251, 191, 36, 0.1); padding: 2px 4px; border-radius: 4px; }}
429        .nav {{ margin-bottom: 20px; }}
430        .nav a {{ color: #38bdf8; text-decoration: none; }}
431        .nav a:hover {{ text-decoration: underline; }}
432    </style>
433</head>
434<body>
435    <div class="nav">
436        <a href="/">← Back to File List</a>
437    </div>
438    <div class="header">
439        <h1>📋 Debug Log: {}</h1>
440        <p>Trident SVM execution log with syntax highlighting</p>
441    </div>
442    <div class="content">
443        {}
444    </div>
445</body>
446</html>"#,
447        filename, filename, highlighted_content
448    )
449}
450
451fn html_escape(text: &str) -> String {
452    text.replace('&', "&amp;")
453        .replace('<', "&lt;")
454        .replace('>', "&gt;")
455        .replace('"', "&quot;")
456        .replace('\'', "&#x27;")
457}