Skip to main content

postrust_proxy/admin/
ui.rs

1//! HTML admin UI for proxy management.
2
3use crate::ProxyState;
4use axum::{extract::State, response::Html, routing::get, Router};
5use std::sync::Arc;
6
7/// Escape text for an HTML element body or a quoted attribute value.
8///
9/// Every value interpolated into these pages goes through this. Route and
10/// upstream names, hosts, paths and backend addresses all come from the API --
11/// and so from whoever can reach it -- and a name like
12/// `<script>...</script>` would otherwise run in the browser of the operator
13/// viewing the dashboard, with the operator's access.
14fn esc(text: &str) -> String {
15    let mut out = String::with_capacity(text.len());
16    for c in text.chars() {
17        match c {
18            '&' => out.push_str("&amp;"),
19            '<' => out.push_str("&lt;"),
20            '>' => out.push_str("&gt;"),
21            '"' => out.push_str("&quot;"),
22            '\'' => out.push_str("&#39;"),
23            c => out.push(c),
24        }
25    }
26    out
27}
28
29/// Create the admin UI router.
30pub fn admin_ui_router() -> Router<Arc<ProxyState>> {
31    Router::new()
32        .route("/", get(proxy_dashboard))
33        .route("/routes", get(routes_page))
34        .route("/upstreams", get(upstreams_page))
35        .route("/health", get(health_page))
36        .route("/certificates", get(certificates_page))
37}
38
39async fn proxy_dashboard(State(state): State<Arc<ProxyState>>) -> Html<String> {
40    let config = state.config.read().await;
41
42    let backends_count: usize = config.upstreams.iter().map(|u| u.backends.len()).sum();
43
44    let html = format!(
45        r#"<!DOCTYPE html>
46<html>
47<head>
48    <title>Proxy Dashboard - Postrust</title>
49    <style>
50        body {{ font-family: system-ui, sans-serif; margin: 0; padding: 20px; background: #f5f5f5; }}
51        .container {{ max-width: 1200px; margin: 0 auto; }}
52        h1 {{ color: #333; }}
53        .stats {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 20px; margin: 20px 0; }}
54        .stat-card {{ background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }}
55        .stat-card h3 {{ margin: 0 0 10px 0; color: #666; font-size: 14px; }}
56        .stat-card .value {{ font-size: 36px; font-weight: bold; color: #333; }}
57        .nav {{ margin: 20px 0; }}
58        .nav a {{ display: inline-block; padding: 10px 20px; background: #007bff; color: white; text-decoration: none; border-radius: 4px; margin-right: 10px; }}
59        .nav a:hover {{ background: #0056b3; }}
60    </style>
61</head>
62<body>
63    <div class="container">
64        <h1>Proxy Dashboard</h1>
65
66        <div class="nav">
67            <a href="/admin/proxy/routes">Routes</a>
68            <a href="/admin/proxy/upstreams">Upstreams</a>
69            <a href="/admin/proxy/health">Health</a>
70            <a href="/admin/proxy/certificates">Certificates</a>
71        </div>
72
73        <div class="stats">
74            <div class="stat-card">
75                <h3>Routes</h3>
76                <div class="value">{}</div>
77            </div>
78            <div class="stat-card">
79                <h3>Upstreams</h3>
80                <div class="value">{}</div>
81            </div>
82            <div class="stat-card">
83                <h3>Backends</h3>
84                <div class="value">{}</div>
85            </div>
86        </div>
87    </div>
88</body>
89</html>"#,
90        config.routes.len(),
91        config.upstreams.len(),
92        backends_count,
93    );
94
95    Html(html)
96}
97
98async fn routes_page(State(state): State<Arc<ProxyState>>) -> Html<String> {
99    let config = state.config.read().await;
100
101    let routes_html: String = config
102        .routes
103        .iter()
104        .map(|r| {
105            format!(
106                r#"<tr>
107                <td>{}</td>
108                <td>{}</td>
109                <td>{}</td>
110                <td>{}</td>
111                <td>{}</td>
112            </tr>"#,
113                r.id.map(|id| id.to_string()).unwrap_or_default(),
114                esc(&r.name),
115                esc(r.match_.host.as_deref().unwrap_or("*")),
116                esc(r.match_.path.as_deref().unwrap_or("/")),
117                r.priority,
118            )
119        })
120        .collect();
121
122    let html = format!(
123        r#"<!DOCTYPE html>
124<html>
125<head>
126    <title>Routes - Postrust Proxy</title>
127    <style>
128        body {{ font-family: system-ui, sans-serif; margin: 0; padding: 20px; background: #f5f5f5; }}
129        .container {{ max-width: 1200px; margin: 0 auto; }}
130        h1 {{ color: #333; }}
131        table {{ width: 100%; border-collapse: collapse; background: white; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }}
132        th, td {{ padding: 12px 15px; text-align: left; border-bottom: 1px solid #eee; }}
133        th {{ background: #f8f9fa; font-weight: 600; }}
134        .nav {{ margin: 20px 0; }}
135        .nav a {{ display: inline-block; padding: 10px 20px; background: #6c757d; color: white; text-decoration: none; border-radius: 4px; margin-right: 10px; }}
136        .nav a:hover {{ background: #5a6268; }}
137    </style>
138</head>
139<body>
140    <div class="container">
141        <h1>Routes</h1>
142
143        <div class="nav">
144            <a href="/admin/proxy/">Back to Dashboard</a>
145        </div>
146
147        <table>
148            <thead>
149                <tr>
150                    <th>ID</th>
151                    <th>Name</th>
152                    <th>Host</th>
153                    <th>Path Prefix</th>
154                    <th>Priority</th>
155                </tr>
156            </thead>
157            <tbody>
158                {}
159            </tbody>
160        </table>
161    </div>
162</body>
163</html>"#,
164        routes_html
165    );
166
167    Html(html)
168}
169
170async fn upstreams_page(State(state): State<Arc<ProxyState>>) -> Html<String> {
171    let config = state.config.read().await;
172
173    let upstreams_html: String = config
174        .upstreams
175        .iter()
176        .map(|u| {
177            format!(
178                r#"<tr>
179                <td>{}</td>
180                <td>{}</td>
181                <td>{}</td>
182                <td>{}</td>
183            </tr>"#,
184                u.id.map(|id| id.to_string()).unwrap_or_default(),
185                esc(&u.name),
186                esc(&format!("{:?}", u.lb_strategy)),
187                u.backends.len(),
188            )
189        })
190        .collect();
191
192    let html = format!(
193        r#"<!DOCTYPE html>
194<html>
195<head>
196    <title>Upstreams - Postrust Proxy</title>
197    <style>
198        body {{ font-family: system-ui, sans-serif; margin: 0; padding: 20px; background: #f5f5f5; }}
199        .container {{ max-width: 1200px; margin: 0 auto; }}
200        h1 {{ color: #333; }}
201        table {{ width: 100%; border-collapse: collapse; background: white; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }}
202        th, td {{ padding: 12px 15px; text-align: left; border-bottom: 1px solid #eee; }}
203        th {{ background: #f8f9fa; font-weight: 600; }}
204        .nav {{ margin: 20px 0; }}
205        .nav a {{ display: inline-block; padding: 10px 20px; background: #6c757d; color: white; text-decoration: none; border-radius: 4px; margin-right: 10px; }}
206        .nav a:hover {{ background: #5a6268; }}
207    </style>
208</head>
209<body>
210    <div class="container">
211        <h1>Upstreams</h1>
212
213        <div class="nav">
214            <a href="/admin/proxy/">Back to Dashboard</a>
215        </div>
216
217        <table>
218            <thead>
219                <tr>
220                    <th>ID</th>
221                    <th>Name</th>
222                    <th>Load Balance</th>
223                    <th>Backends</th>
224                </tr>
225            </thead>
226            <tbody>
227                {}
228            </tbody>
229        </table>
230    </div>
231</body>
232</html>"#,
233        upstreams_html
234    );
235
236    Html(html)
237}
238
239async fn health_page(State(state): State<Arc<ProxyState>>) -> Html<String> {
240    let config = state.config.read().await;
241
242    let mut health_html = String::new();
243    for upstream in &config.upstreams {
244        for backend in &upstream.backends {
245            let (status, status_class) = if let Some(id) = backend.id {
246                if state.health_checker.is_healthy(id) {
247                    ("Healthy", "healthy")
248                } else {
249                    ("Unhealthy", "unhealthy")
250                }
251            } else {
252                ("Unknown", "unknown")
253            };
254
255            health_html.push_str(&format!(
256                r#"<tr>
257                    <td>{}</td>
258                    <td>{}</td>
259                    <td>{}</td>
260                    <td class="{}">{}</td>
261                </tr>"#,
262                esc(&upstream.name),
263                esc(&backend.address),
264                esc(&backend.scheme),
265                status_class,
266                status,
267            ));
268        }
269    }
270
271    let html = format!(
272        r#"<!DOCTYPE html>
273<html>
274<head>
275    <title>Health Status - Postrust Proxy</title>
276    <style>
277        body {{ font-family: system-ui, sans-serif; margin: 0; padding: 20px; background: #f5f5f5; }}
278        .container {{ max-width: 1200px; margin: 0 auto; }}
279        h1 {{ color: #333; }}
280        table {{ width: 100%; border-collapse: collapse; background: white; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }}
281        th, td {{ padding: 12px 15px; text-align: left; border-bottom: 1px solid #eee; }}
282        th {{ background: #f8f9fa; font-weight: 600; }}
283        .healthy {{ color: #28a745; font-weight: bold; }}
284        .unhealthy {{ color: #dc3545; font-weight: bold; }}
285        .unknown {{ color: #6c757d; }}
286        .nav {{ margin: 20px 0; }}
287        .nav a {{ display: inline-block; padding: 10px 20px; background: #6c757d; color: white; text-decoration: none; border-radius: 4px; margin-right: 10px; }}
288        .nav a:hover {{ background: #5a6268; }}
289    </style>
290</head>
291<body>
292    <div class="container">
293        <h1>Backend Health Status</h1>
294
295        <div class="nav">
296            <a href="/admin/proxy/">Back to Dashboard</a>
297        </div>
298
299        <table>
300            <thead>
301                <tr>
302                    <th>Upstream</th>
303                    <th>Backend Address</th>
304                    <th>Scheme</th>
305                    <th>Status</th>
306                </tr>
307            </thead>
308            <tbody>
309                {}
310            </tbody>
311        </table>
312    </div>
313</body>
314</html>"#,
315        health_html
316    );
317
318    Html(html)
319}
320
321async fn certificates_page(State(_state): State<Arc<ProxyState>>) -> Html<String> {
322    // TODO: Integrate with CertificateStore
323    let html = r#"<!DOCTYPE html>
324<html>
325<head>
326    <title>Certificates - Postrust Proxy</title>
327    <style>
328        body { font-family: system-ui, sans-serif; margin: 0; padding: 20px; background: #f5f5f5; }
329        .container { max-width: 1200px; margin: 0 auto; }
330        h1 { color: #333; }
331        .placeholder { background: white; padding: 40px; text-align: center; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); color: #666; }
332        .nav { margin: 20px 0; }
333        .nav a { display: inline-block; padding: 10px 20px; background: #6c757d; color: white; text-decoration: none; border-radius: 4px; margin-right: 10px; }
334        .nav a:hover { background: #5a6268; }
335    </style>
336</head>
337<body>
338    <div class="container">
339        <h1>SSL/TLS Certificates</h1>
340
341        <div class="nav">
342            <a href="/admin/proxy/">Back to Dashboard</a>
343        </div>
344
345        <div class="placeholder">
346            <p>Certificate management will be available here.</p>
347            <p>Certificates are automatically managed via Let's Encrypt ACME.</p>
348        </div>
349    </div>
350</body>
351</html>"#;
352
353    Html(html.to_string())
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359
360    #[test]
361    fn markup_is_escaped() {
362        assert_eq!(
363            esc(r#"<script>alert("x")</script> & 'y'"#),
364            "&lt;script&gt;alert(&quot;x&quot;)&lt;/script&gt; &amp; &#39;y&#39;"
365        );
366        assert_eq!(esc("plain-name_1"), "plain-name_1");
367    }
368}