Kieran's opinionated (and probably slightly dumb) nix config
1<!DOCTYPE html>
2<html lang="en">
3
4<head>
5 <meta charset="UTF-8">
6 <meta name="viewport" content="width=device-width, initial-scale=1.0">
7 <title>bore</title>
8 <meta name="description" content="bore - secure tunneling service for exposing local services to the internet">
9 <meta property="og:title" content="bore - tunnel dashboard">
10 <meta property="og:description" content="secure tunneling service powered by frp on bore.dunkirk.sh">
11 <meta property="og:type" content="website">
12 <meta property="og:url" content="https://bore.dunkirk.sh">
13 <meta name="twitter:card" content="summary">
14 <meta name="twitter:title" content="bore - tunnel dashboard">
15 <meta name="twitter:description" content="secure tunneling service powered by frp on bore.dunkirk.sh">
16 <link rel="icon"
17 href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🚇</text></svg>">
18 <style>
19 * {
20 margin: 0;
21 padding: 0;
22 box-sizing: border-box;
23 }
24
25 body {
26 font-family: 'SF Mono', 'Monaco', monospace;
27 background: #0d1117;
28 color: #e6edf3;
29 padding: 2rem;
30 line-height: 1.5;
31 min-height: 100vh;
32 display: flex;
33 flex-direction: column;
34 }
35
36 body.loading .container {
37 opacity: 0;
38 }
39
40 .loading-bar {
41 position: fixed;
42 top: 0;
43 left: 0;
44 width: 100%;
45 height: 3px;
46 background: transparent;
47 z-index: 9999;
48 overflow: hidden;
49 }
50
51 .loading-bar::before {
52 content: '';
53 position: absolute;
54 top: 0;
55 left: 0;
56 width: 100%;
57 height: 100%;
58 background: linear-gradient(90deg, #14b8a6, #fb923c);
59 animation: loading 1.5s ease-in-out infinite;
60 }
61
62 body:not(.loading) .loading-bar {
63 display: none;
64 }
65
66 @keyframes loading {
67 0% {
68 transform: translateX(-100%);
69 }
70 50% {
71 transform: translateX(0%);
72 }
73 100% {
74 transform: translateX(100%);
75 }
76 }
77
78 .container {
79 max-width: 1200px;
80 margin: 0 auto;
81 flex: 1;
82 width: 100%;
83 opacity: 1;
84 transition: opacity 0.3s ease-in-out;
85 }
86
87 header {
88 margin-bottom: 3rem;
89 }
90
91 h1 {
92 font-size: 2.5rem;
93 background: linear-gradient(135deg, #14b8a6, #fb923c);
94 -webkit-background-clip: text;
95 -webkit-text-fill-color: transparent;
96 margin-bottom: 0.5rem;
97 }
98
99 .subtitle {
100 color: #8b949e;
101 font-size: 0.95rem;
102 }
103
104 .stats {
105 display: grid;
106 grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
107 gap: 1rem;
108 margin-bottom: 2rem;
109 }
110
111 .stat-card {
112 background: #161b22;
113 border: 1px solid #30363d;
114 border-radius: 0;
115 padding: 1.5rem;
116 }
117
118 .stat-label {
119 color: #8b949e;
120 font-size: 0.85rem;
121 margin-bottom: 0.5rem;
122 }
123
124 .stat-value {
125 font-size: 2rem;
126 font-weight: 600;
127 color: #14b8a6;
128 }
129
130 .stat-value.orange {
131 color: #fb923c;
132 }
133
134 .section {
135 background: #161b22;
136 border: 1px solid #30363d;
137 border-radius: 0;
138 padding: 1.5rem;
139 margin-bottom: 2rem;
140 }
141
142 h2 {
143 color: #14b8a6;
144 font-size: 1.2rem;
145 margin-bottom: 1.5rem;
146 display: flex;
147 align-items: center;
148 gap: 0.5rem;
149 }
150
151 .tunnel-list {
152 display: flex;
153 flex-direction: column;
154 gap: 1rem;
155 }
156
157 .tunnel {
158 background: #0d1117;
159 border: 1px solid #30363d;
160 border-radius: 0;
161 padding: 1rem;
162 display: grid;
163 grid-template-columns: 1fr auto;
164 gap: 1rem;
165 align-items: center;
166 }
167
168 .tunnel-icon {
169 display: none;
170 }
171
172 .tunnel-info {
173 flex: 1;
174 }
175
176 .tunnel-name {
177 color: #e6edf3;
178 font-weight: 600;
179 margin-bottom: 0.25rem;
180 display: flex;
181 align-items: center;
182 gap: 0.5rem;
183 }
184
185 .tunnel-label {
186 display: inline-block;
187 padding: 0.125rem 0.5rem;
188 background: rgba(251, 146, 60, 0.2);
189 color: #fb923c;
190 border: 1px solid #fb923c;
191 font-size: 0.7rem;
192 font-weight: 500;
193 border-radius: 0;
194 }
195
196 .tunnel-url {
197 color: #8b949e;
198 font-size: 0.85rem;
199 }
200
201 .tunnel-url a {
202 color: #14b8a6;
203 text-decoration: none;
204 }
205
206 .tunnel-url a:hover {
207 text-decoration: underline;
208 }
209
210 .tunnel-status {
211 padding: 0.25rem 0.75rem;
212 border-radius: 0;
213 font-size: 0.8rem;
214 font-weight: 500;
215 }
216
217 .status-online {
218 background: rgba(20, 184, 166, 0.2);
219 color: #14b8a6;
220 border: 1px solid #14b8a6;
221 }
222
223 .empty-state {
224 text-align: center;
225 padding: 3rem 1rem;
226 color: #8b949e;
227 }
228
229 .empty-icon {
230 font-size: 3rem;
231 margin-bottom: 1rem;
232 opacity: 0.5;
233 }
234
235 code {
236 background: #0d1117;
237 padding: 0.2rem 0.5rem;
238 border-radius: 0;
239 color: #fb923c;
240 font-size: 0.9rem;
241 }
242
243 .usage {
244 background: #0d1117;
245 padding: 1rem;
246 border-radius: 0;
247 margin-top: 1rem;
248 }
249
250 .usage pre {
251 color: #8b949e;
252 font-size: 0.9rem;
253 overflow-x: auto;
254 }
255
256 .last-updated {
257 text-align: center;
258 color: #8b949e;
259 font-size: 0.8rem;
260 margin-top: 2rem;
261 padding: 2rem 0;
262 }
263
264 .last-updated a {
265 color: #14b8a6;
266 text-decoration: none;
267 }
268
269 .last-updated a:hover {
270 text-decoration: underline;
271 }
272
273 .offline-tunnels {
274 margin-top: 1.5rem;
275 padding-top: 1.5rem;
276 border-top: 1px solid #30363d;
277 }
278
279 .offline-tunnel {
280 padding: 0.5rem 0;
281 color: #8b949e;
282 font-size: 0.85rem;
283 display: flex;
284 justify-content: space-between;
285 align-items: center;
286 }
287
288 .offline-tunnel-name {
289 opacity: 0.6;
290 }
291
292 .offline-tunnel-stats {
293 font-size: 0.75rem;
294 opacity: 0.5;
295 }
296 </style>
297</head>
298
299<body class="loading">
300 <div class="loading-bar"></div>
301 <main class="container">
302 <header>
303 <h1>🚇 bore</h1>
304 <p class="subtitle">fancy tunnels @ terebithia</p>
305 </header>
306
307 <div class="stats">
308 <div class="stat-card">
309 <div class="stat-label">active tunnels</div>
310 <div class="stat-value" id="activeTunnels">—</div>
311 </div>
312 <div class="stat-card">
313 <div class="stat-label">active connections</div>
314 <div class="stat-value" id="totalConnections">—</div>
315 </div>
316 <div class="stat-card">
317 <div class="stat-label">server status</div>
318 <div class="stat-value orange" id="serverStatus">—</div>
319 </div>
320 <div class="stat-card">
321 <div class="stat-label">total upload</div>
322 <div class="stat-value" id="totalUpload">—</div>
323 </div>
324 <div class="stat-card">
325 <div class="stat-label">total download</div>
326 <div class="stat-value" id="totalDownload">—</div>
327 </div>
328 </div>
329
330 <section class="section">
331 <h2>~boreholes</h2>
332 <div class="tunnel-list" id="tunnelList">
333 <div class="empty-state">
334 <div class="empty-icon">🚇</div>
335 <p>no active tunnels</p>
336 </div>
337 </div>
338 </section>
339 </main>
340
341 <footer class="last-updated">
342 last updated: <span id="lastUpdated">never</span><br>
343 made with ♥︎ by <a href="https://dunkirk.sh" target="_blank">kieran klukas</a>
344 </footer>
345
346 <script>
347 let fetchFailCount = 0;
348 const MAX_FAIL_COUNT = 3;
349 let lastProxiesState = null;
350
351 async function fetchStats() {
352 try {
353 // Fetch server info
354 const serverResponse = await fetch('/api/serverinfo');
355 if (!serverResponse.ok) throw new Error('API unavailable');
356 const serverData = await serverResponse.json();
357
358 // Fetch HTTP proxies (tunnels)
359 const proxiesResponse = await fetch('/api/proxy/http');
360 const proxiesData = await proxiesResponse.json();
361
362 // Reset fail count on success
363 fetchFailCount = 0;
364
365 // Update stats
366 document.getElementById('activeTunnels').textContent = serverData.clientCounts || 0;
367 document.getElementById('serverStatus').textContent = 'online';
368 document.getElementById('totalConnections').textContent = serverData.curConns || 0;
369 document.getElementById('totalUpload').textContent = formatBytes(serverData.totalTrafficOut || 0);
370 document.getElementById('totalDownload').textContent = formatBytes(serverData.totalTrafficIn || 0);
371
372 // Update page title
373 const tunnelCount = serverData.clientCounts || 0;
374 const totalTraffic = formatBytes((serverData.totalTrafficIn || 0) + (serverData.totalTrafficOut || 0));
375 document.title = tunnelCount > 0
376 ? `bore - ${tunnelCount} active • ${totalTraffic}`
377 : 'bore';
378
379 // Check if tunnel list structure changed
380 const proxies = proxiesData.proxies || [];
381 const currentState = JSON.stringify(proxies.map(p => ({ name: p.name, status: p.status })));
382
383 if (currentState !== lastProxiesState) {
384 // Structure changed, rebuild DOM
385 lastProxiesState = currentState;
386 renderTunnelList(proxies);
387 } else {
388 // Structure unchanged, just update data
389 updateTunnelData(proxies);
390 }
391
392 document.getElementById('lastUpdated').textContent = new Date().toLocaleTimeString();
393
394 // Remove loading class after first successful fetch
395 document.body.classList.remove('loading');
396 } catch (error) {
397 fetchFailCount++;
398 document.getElementById('serverStatus').textContent = 'offline';
399 console.error('Failed to fetch stats:', error);
400
401 // Reload page if failed multiple times (server might have updated)
402 if (fetchFailCount >= MAX_FAIL_COUNT) {
403 console.log('Multiple fetch failures detected, reloading page...');
404 window.location.reload();
405 }
406 }
407 }
408
409 function renderTunnelList(proxies) {
410 const tunnelList = document.getElementById('tunnelList');
411 const onlineTunnels = proxies.filter(p => p.status === 'online');
412 const offlineTunnels = proxies.filter(p => p.status !== 'online');
413
414 if (onlineTunnels.length === 0 && offlineTunnels.length === 0) {
415 tunnelList.innerHTML = `
416 <div class="empty-state">
417 <div class="empty-icon">🚇</div>
418 <p>no active tunnels</p>
419 </div>
420 `;
421 } else {
422 let html = '';
423
424 // Render online tunnels
425 if (onlineTunnels.length > 0) {
426 html += onlineTunnels.map(proxy => {
427 const subdomain = proxy.conf?.subdomain || 'unknown';
428 const url = `https://${subdomain}.bore.dunkirk.sh`;
429
430 // Parse label from proxy name (format: subdomain[label])
431 const labelMatch = proxy.name.match(/\[([^\]]+)\]$/);
432 const label = labelMatch ? labelMatch[1] : null;
433 const displayName = label ? proxy.name.replace(/\[[^\]]+\]$/, '') : proxy.name;
434
435 return `
436 <div class="tunnel" data-tunnel="${proxy.name}">
437 <div class="tunnel-info">
438 <div class="tunnel-name">
439 ${displayName || 'unnamed'}
440 ${label ? `<span class="tunnel-label">${label}</span>` : ''}
441 </div>
442 <div class="tunnel-url">
443 <a href="${url}" target="_blank">${url}</a>
444 </div>
445 <div style="color: #8b949e; font-size: 0.75rem; margin-top: 0.25rem;">
446 started: <span data-start-time="${proxy.lastStartTime || ''}"></span> • traffic in: <span data-traffic-in="${proxy.name}">0 B</span> • out: <span data-traffic-out="${proxy.name}">0 B</span>
447 </div>
448 </div>
449 <div class="tunnel-status status-online">online</div>
450 </div>
451 `;
452 }).join('');
453 }
454
455 // Render offline tunnels
456 if (offlineTunnels.length > 0) {
457 html += '<div class="offline-tunnels">';
458 html += '<div style="color: #8b949e; font-size: 0.85rem; margin-bottom: 0.75rem;">recently disconnected</div>';
459 html += offlineTunnels.map(proxy => {
460 // Parse label from proxy name (format: subdomain[label])
461 const labelMatch = proxy.name.match(/\[([^\]]+)\]$/);
462 const label = labelMatch ? labelMatch[1] : null;
463 const displayName = label ? proxy.name.replace(/\[[^\]]+\]$/, '') : proxy.name;
464
465 if (!proxy.conf) {
466 return `
467 <div class="offline-tunnel" data-tunnel="${proxy.name}">
468 <span class="offline-tunnel-name">${displayName || 'unnamed'}${label ? ` [${label}]` : ''}</span>
469 <span class="offline-tunnel-stats">in: <span data-traffic-in="${proxy.name}">0 B</span> • out: <span data-traffic-out="${proxy.name}">0 B</span></span>
470 </div>
471 `;
472 }
473
474 const subdomain = proxy.conf.subdomain || 'unknown';
475 const url = `https://${subdomain}.bore.dunkirk.sh`;
476 return `
477 <div class="offline-tunnel" data-tunnel="${proxy.name}">
478 <span class="offline-tunnel-name">${displayName || 'unnamed'}${label ? ` [${label}]` : ''} → ${url}</span>
479 <span class="offline-tunnel-stats">in: <span data-traffic-in="${proxy.name}">0 B</span> • out: <span data-traffic-out="${proxy.name}">0 B</span></span>
480 </div>
481 `;
482 }).join('');
483 html += '</div>';
484 }
485
486 tunnelList.innerHTML = html;
487
488 // Update all relative times
489 updateRelativeTimes();
490
491
492 }
493
494 // Update data
495 updateTunnelData(proxies);
496 }
497
498 function updateTunnelData(proxies) {
499 proxies.forEach(proxy => {
500 const trafficInEl = document.querySelector(`[data-traffic-in="${proxy.name}"]`);
501 const trafficOutEl = document.querySelector(`[data-traffic-out="${proxy.name}"]`);
502
503 if (trafficInEl) trafficInEl.textContent = formatBytes(proxy.todayTrafficIn || 0);
504 if (trafficOutEl) trafficOutEl.textContent = formatBytes(proxy.todayTrafficOut || 0);
505
506
507 });
508
509 // Update relative times
510 updateRelativeTimes();
511 }
512
513 function formatBytes(bytes) {
514 if (bytes === 0) return '0 B';
515 const k = 1024;
516 const sizes = ['B', 'KB', 'MB', 'GB'];
517 const i = Math.floor(Math.log(bytes) / Math.log(k));
518 return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
519 }
520
521 function formatTime(timeStr) {
522 // Input format: "12-08 20:15:20" (MM-DD HH:MM:SS)
523 const [datePart, timePart] = timeStr.split(' ');
524 const [month, day] = datePart.split('-');
525 const [hour, minute, second] = timePart.split(':');
526
527 const now = new Date();
528 const inputDate = new Date(now.getFullYear(), parseInt(month) - 1, parseInt(day), parseInt(hour), parseInt(minute), parseInt(second));
529
530 const diffInSeconds = (now.getTime() - inputDate.getTime()) / 1000;
531 const diffInMinutes = Math.round(diffInSeconds / 60);
532 const diffInHours = Math.round(diffInMinutes / 60);
533
534 if (diffInSeconds < 60) {
535 return 'just now';
536 } else if (diffInHours < 1) {
537 return diffInMinutes === 1 ? '1 minute ago' : `${diffInMinutes} minutes ago`;
538 } else if (now.toDateString() === inputDate.toDateString()) {
539 return 'today';
540 } else if (
541 new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1).toDateString() ===
542 inputDate.toDateString()
543 ) {
544 return 'yesterday';
545 } else {
546 return inputDate.toLocaleTimeString([], {
547 month: 'numeric',
548 day: 'numeric',
549 hour: 'numeric',
550 minute: 'numeric',
551 });
552 }
553 }
554
555 function updateRelativeTimes() {
556 document.querySelectorAll('[data-start-time]').forEach(element => {
557 const timeStr = element.getAttribute('data-start-time');
558 element.textContent = formatTime(timeStr);
559 });
560 }
561
562 // Fetch immediately and then every 5 seconds
563 fetchStats();
564 setInterval(fetchStats, 5000);
565
566 // Update relative times every 10 seconds
567 setInterval(updateRelativeTimes, 10000);
568 </script>
569</body>
570
571</html>