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 font-size: 0.7rem;
189 font-weight: 500;
190 border-radius: 0;
191 margin-left: 0.25rem;
192 border: 1px solid;
193 }
194
195 .tunnel-url {
196 color: #8b949e;
197 font-size: 0.85rem;
198 }
199
200 .tunnel-url a {
201 color: #14b8a6;
202 text-decoration: none;
203 }
204
205 .tunnel-url a:hover {
206 text-decoration: underline;
207 }
208
209 .tunnel-status {
210 padding: 0.25rem 0.75rem;
211 border-radius: 0;
212 font-size: 0.8rem;
213 font-weight: 500;
214 }
215
216 .status-online {
217 background: rgba(20, 184, 166, 0.2);
218 color: #14b8a6;
219 border: 1px solid #14b8a6;
220 }
221
222 .empty-state {
223 text-align: center;
224 padding: 3rem 1rem;
225 color: #8b949e;
226 }
227
228 .empty-icon {
229 font-size: 3rem;
230 margin-bottom: 1rem;
231 opacity: 0.5;
232 }
233
234 code {
235 background: #0d1117;
236 padding: 0.2rem 0.5rem;
237 border-radius: 0;
238 color: #fb923c;
239 font-size: 0.9rem;
240 }
241
242 .usage {
243 background: #0d1117;
244 padding: 1rem;
245 border-radius: 0;
246 margin-top: 1rem;
247 }
248
249 .usage pre {
250 color: #8b949e;
251 font-size: 0.9rem;
252 overflow-x: auto;
253 }
254
255 .last-updated {
256 text-align: center;
257 color: #8b949e;
258 font-size: 0.8rem;
259 margin-top: 2rem;
260 padding: 2rem 0;
261 }
262
263 .last-updated a {
264 color: #14b8a6;
265 text-decoration: none;
266 }
267
268 .last-updated a:hover {
269 text-decoration: underline;
270 }
271
272 .offline-tunnels {
273 margin-top: 1.5rem;
274 padding-top: 1.5rem;
275 border-top: 1px solid #30363d;
276 }
277
278 .offline-tunnel {
279 padding: 0.5rem 0;
280 color: #8b949e;
281 font-size: 0.85rem;
282 display: flex;
283 justify-content: space-between;
284 align-items: center;
285 }
286
287 .offline-tunnel-name {
288 opacity: 0.6;
289 }
290
291 .offline-tunnel-stats {
292 font-size: 0.75rem;
293 opacity: 0.5;
294 }
295 </style>
296</head>
297
298<body class="loading">
299 <div class="loading-bar"></div>
300 <main class="container">
301 <header>
302 <h1>🚇 bore</h1>
303 <p class="subtitle">fancy tunnels @ terebithia</p>
304 </header>
305
306 <div class="stats">
307 <div class="stat-card">
308 <div class="stat-label">active tunnels</div>
309 <div class="stat-value" id="activeTunnels">—</div>
310 </div>
311 <div class="stat-card">
312 <div class="stat-label">active connections</div>
313 <div class="stat-value" id="totalConnections">—</div>
314 </div>
315 <div class="stat-card">
316 <div class="stat-label">server status</div>
317 <div class="stat-value orange" id="serverStatus">—</div>
318 </div>
319 <div class="stat-card">
320 <div class="stat-label">total upload</div>
321 <div class="stat-value" id="totalUpload">—</div>
322 </div>
323 <div class="stat-card">
324 <div class="stat-label">total download</div>
325 <div class="stat-value" id="totalDownload">—</div>
326 </div>
327 </div>
328
329 <section class="section">
330 <h2>~boreholes</h2>
331 <div class="tunnel-list" id="tunnelList">
332 <div class="empty-state">
333 <div class="empty-icon">🚇</div>
334 <p>no active tunnels</p>
335 </div>
336 </div>
337 </section>
338 </main>
339
340 <footer class="last-updated">
341 last updated: <span id="lastUpdated">never</span><br>
342 made with ♥︎ by <a href="https://dunkirk.sh" target="_blank">kieran klukas</a>
343 </footer>
344
345 <script>
346 let fetchFailCount = 0;
347 const MAX_FAIL_COUNT = 3;
348 let lastProxiesState = null;
349
350 // Predefined color palette for labels
351 const labelColors = [
352 { color: '#a78bfa', bg: 'rgba(167, 139, 250, 0.2)' }, // purple
353 { color: '#f472b6', bg: 'rgba(244, 114, 182, 0.2)' }, // pink
354 { color: '#facc15', bg: 'rgba(250, 204, 21, 0.2)' }, // yellow
355 { color: '#60a5fa', bg: 'rgba(96, 165, 250, 0.2)' }, // blue
356 { color: '#f87171', bg: 'rgba(248, 113, 113, 0.2)' }, // red
357 { color: '#38bdf8', bg: 'rgba(56, 189, 248, 0.2)' }, // sky
358 { color: '#c084fc', bg: 'rgba(192, 132, 252, 0.2)' }, // violet
359 { color: '#fb7185', bg: 'rgba(251, 113, 133, 0.2)' }, // rose
360 ];
361
362 // Hash string to index
363 function stringToColorIndex(str) {
364 let hash = 0;
365 for (let i = 0; i < str.length; i++) {
366 hash = str.charCodeAt(i) + ((hash << 5) - hash);
367 }
368 return Math.abs(hash) % labelColors.length;
369 }
370
371 // Get label color and styles
372 function getLabelStyle(label) {
373 const trimmedLabel = label.trim();
374 if (trimmedLabel === 'prod') {
375 return {
376 color: '#22c55e',
377 bgColor: 'rgba(34, 197, 94, 0.2)',
378 borderColor: '#22c55e'
379 };
380 }
381
382 if (trimmedLabel === 'dev') {
383 return {
384 color: '#fb923c',
385 bgColor: 'rgba(251, 146, 60, 0.2)',
386 borderColor: '#fb923c'
387 };
388 }
389
390 const colorIndex = stringToColorIndex(trimmedLabel);
391 const colorScheme = labelColors[colorIndex];
392 return {
393 color: colorScheme.color,
394 bgColor: colorScheme.bg,
395 borderColor: colorScheme.color
396 };
397 }
398
399 async function fetchStats() {
400 try {
401 // Fetch server info
402 const serverResponse = await fetch('/api/serverinfo');
403 if (!serverResponse.ok) throw new Error('API unavailable');
404 const serverData = await serverResponse.json();
405
406 // Fetch HTTP proxies (tunnels)
407 const proxiesResponse = await fetch('/api/proxy/http');
408 const proxiesData = await proxiesResponse.json();
409
410 // Reset fail count on success
411 fetchFailCount = 0;
412
413 // Update stats
414 document.getElementById('activeTunnels').textContent = serverData.clientCounts || 0;
415 document.getElementById('serverStatus').textContent = 'online';
416 document.getElementById('totalConnections').textContent = serverData.curConns || 0;
417 document.getElementById('totalUpload').textContent = formatBytes(serverData.totalTrafficOut || 0);
418 document.getElementById('totalDownload').textContent = formatBytes(serverData.totalTrafficIn || 0);
419
420 // Update page title
421 const tunnelCount = serverData.clientCounts || 0;
422 const totalTraffic = formatBytes((serverData.totalTrafficIn || 0) + (serverData.totalTrafficOut || 0));
423 document.title = tunnelCount > 0
424 ? `bore - ${tunnelCount} active • ${totalTraffic}`
425 : 'bore';
426
427 // Check if tunnel list structure changed
428 const proxies = proxiesData.proxies || [];
429 const currentState = JSON.stringify(proxies.map(p => ({ name: p.name, status: p.status })));
430
431 if (currentState !== lastProxiesState) {
432 // Structure changed, rebuild DOM
433 lastProxiesState = currentState;
434 renderTunnelList(proxies);
435 } else {
436 // Structure unchanged, just update data
437 updateTunnelData(proxies);
438 }
439
440 document.getElementById('lastUpdated').textContent = new Date().toLocaleTimeString();
441
442 // Remove loading class after first successful fetch
443 document.body.classList.remove('loading');
444 } catch (error) {
445 fetchFailCount++;
446 document.getElementById('serverStatus').textContent = 'offline';
447 console.error('Failed to fetch stats:', error);
448
449 // Reload page if failed multiple times (server might have updated)
450 if (fetchFailCount >= MAX_FAIL_COUNT) {
451 console.log('Multiple fetch failures detected, reloading page...');
452 window.location.reload();
453 }
454 }
455 }
456
457 function renderTunnelList(proxies) {
458 const tunnelList = document.getElementById('tunnelList');
459 const onlineTunnels = proxies.filter(p => p.status === 'online');
460 const offlineTunnels = proxies.filter(p => p.status !== 'online');
461
462 if (onlineTunnels.length === 0 && offlineTunnels.length === 0) {
463 tunnelList.innerHTML = `
464 <div class="empty-state">
465 <div class="empty-icon">🚇</div>
466 <p>no active tunnels</p>
467 </div>
468 `;
469 } else {
470 let html = '';
471
472 // Render online tunnels
473 if (onlineTunnels.length > 0) {
474 html += onlineTunnels.map(proxy => {
475 const subdomain = proxy.conf?.subdomain || 'unknown';
476 const url = `https://${subdomain}.bore.dunkirk.sh`;
477
478 // Parse labels from proxy name (format: subdomain[label1,label2])
479 const labelMatch = proxy.name.match(/\[([^\]]+)\]$/);
480 const labels = labelMatch ? labelMatch[1].split(',') : [];
481 const displayName = labels.length > 0 ? proxy.name.replace(/\[[^\]]+\]$/, '') : proxy.name;
482
483 const labelHtml = labels.map(label => {
484 const trimmedLabel = label.trim();
485 const style = getLabelStyle(trimmedLabel);
486 return `<span class="tunnel-label" style="color: ${style.color}; background: ${style.bgColor}; border-color: ${style.borderColor};">${trimmedLabel}</span>`;
487 }).join('');
488
489 return `
490 <div class="tunnel" data-tunnel="${proxy.name}">
491 <div class="tunnel-info">
492 <div class="tunnel-name">
493 ${displayName || 'unnamed'}
494 ${labelHtml}
495 </div>
496 <div class="tunnel-url">
497 <a href="${url}" target="_blank">${url}</a>
498 </div>
499 <div style="color: #8b949e; font-size: 0.75rem; margin-top: 0.25rem;">
500 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>
501 </div>
502 </div>
503 <div class="tunnel-status status-online">online</div>
504 </div>
505 `;
506 }).join('');
507 }
508
509 // Render offline tunnels
510 if (offlineTunnels.length > 0) {
511 html += '<div class="offline-tunnels">';
512 html += '<div style="color: #8b949e; font-size: 0.85rem; margin-bottom: 0.75rem;">recently disconnected</div>';
513 html += offlineTunnels.map(proxy => {
514 // Parse labels from proxy name (format: subdomain[label1,label2])
515 const labelMatch = proxy.name.match(/\[([^\]]+)\]$/);
516 const labels = labelMatch ? labelMatch[1].split(',').map(l => l.trim()) : [];
517 const displayName = labels.length > 0 ? proxy.name.replace(/\[[^\]]+\]$/, '') : proxy.name;
518 const labelStr = labels.length > 0 ? ` [${labels.join(', ')}]` : '';
519
520 if (!proxy.conf) {
521 return `
522 <div class="offline-tunnel" data-tunnel="${proxy.name}">
523 <span class="offline-tunnel-name">${displayName || 'unnamed'}${labelStr}</span>
524 <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>
525 </div>
526 `;
527 }
528
529 const subdomain = proxy.conf.subdomain || 'unknown';
530 const url = `https://${subdomain}.bore.dunkirk.sh`;
531 return `
532 <div class="offline-tunnel" data-tunnel="${proxy.name}">
533 <span class="offline-tunnel-name">${displayName || 'unnamed'}${labelStr} → ${url}</span>
534 <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>
535 </div>
536 `;
537 }).join('');
538 html += '</div>';
539 }
540
541 tunnelList.innerHTML = html;
542
543 // Update all relative times
544 updateRelativeTimes();
545
546
547 }
548
549 // Update data
550 updateTunnelData(proxies);
551 }
552
553 function updateTunnelData(proxies) {
554 proxies.forEach(proxy => {
555 const trafficInEl = document.querySelector(`[data-traffic-in="${proxy.name}"]`);
556 const trafficOutEl = document.querySelector(`[data-traffic-out="${proxy.name}"]`);
557
558 if (trafficInEl) trafficInEl.textContent = formatBytes(proxy.todayTrafficIn || 0);
559 if (trafficOutEl) trafficOutEl.textContent = formatBytes(proxy.todayTrafficOut || 0);
560
561
562 });
563
564 // Update relative times
565 updateRelativeTimes();
566 }
567
568 function formatBytes(bytes) {
569 if (bytes === 0) return '0 B';
570 const k = 1024;
571 const sizes = ['B', 'KB', 'MB', 'GB'];
572 const i = Math.floor(Math.log(bytes) / Math.log(k));
573 return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
574 }
575
576 function formatTime(timeStr) {
577 // Input format: "12-08 20:15:20" (MM-DD HH:MM:SS)
578 const [datePart, timePart] = timeStr.split(' ');
579 const [month, day] = datePart.split('-');
580 const [hour, minute, second] = timePart.split(':');
581
582 const now = new Date();
583 const inputDate = new Date(now.getFullYear(), parseInt(month) - 1, parseInt(day), parseInt(hour), parseInt(minute), parseInt(second));
584
585 const diffInSeconds = (now.getTime() - inputDate.getTime()) / 1000;
586 const diffInMinutes = Math.round(diffInSeconds / 60);
587 const diffInHours = Math.round(diffInMinutes / 60);
588
589 if (diffInSeconds < 60) {
590 return 'just now';
591 } else if (diffInHours < 1) {
592 return diffInMinutes === 1 ? '1 minute ago' : `${diffInMinutes} minutes ago`;
593 } else if (now.toDateString() === inputDate.toDateString()) {
594 return 'today';
595 } else if (
596 new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1).toDateString() ===
597 inputDate.toDateString()
598 ) {
599 return 'yesterday';
600 } else {
601 return inputDate.toLocaleTimeString([], {
602 month: 'numeric',
603 day: 'numeric',
604 hour: 'numeric',
605 minute: 'numeric',
606 });
607 }
608 }
609
610 function updateRelativeTimes() {
611 document.querySelectorAll('[data-start-time]').forEach(element => {
612 const timeStr = element.getAttribute('data-start-time');
613 element.textContent = formatTime(timeStr);
614 });
615 }
616
617 // Fetch immediately and then every 5 seconds
618 fetchStats();
619 setInterval(fetchStats, 5000);
620
621 // Update relative times every 10 seconds
622 setInterval(updateRelativeTimes, 10000);
623 </script>
624</body>
625
626</html>