commit - /dev/null
commit + 91c2fbb0178d372c7de613bcc377cfbeefc26b48
blob - /dev/null
blob + a373fb4b28e59937d6e58caf0f15131289cbcb54 (mode 644)
--- /dev/null
+++ .gitignore
+services.json
blob - /dev/null
blob + 8c19a3a15fbfa7597cee90f0a7f23aca2ca48a06 (mode 755)
--- /dev/null
+++ LICENSE
+MIT License
+
+Copyright (c) 2025 Rohan Verma
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
blob - /dev/null
blob + 74159d899d7ca5e957f1ebb52a09a4d3cc9dd658 (mode 755)
--- /dev/null
+++ README.md
+# navigator ðŸ§
+Navigate between your HomeLab services
+
+## preview
+<img width="1774" height="985" alt="image" src="https://github.com/user-attachments/assets/e58bcbae-1867-4333-b386-e1728629a2ed" />
+<img width="1777" height="993" alt="image" src="https://github.com/user-attachments/assets/d560cdaf-1d22-4dcd-9f86-6127ca7ae369" />
+
+## credits
+- @faustinoaq for porting the JS api to C
+- ryanoasis/nerd-fonts for patching GoMono with nerd for GoMono Nerd Font
+
+## features?
+- super lightweight, in my testing it uses about ~2.2 mb for running the API and the site, the html site is only ~7kb so it fits in one packet
+- dynamically adjusting config file, if you enter a entry in the service.json it'll automatically update the site without restart
+- minimal and clean, meant to be as minimal as possible
+- a status api checker built in that pings each site and all runs in one container
+- dark mode/light mode button
+- the entire container and setup is about ~56kb for everything
+
+## how do i set this up?
+1. git clone this repository
+```
+git clone https://github.com/rohanverma2007/navigator.git
+```
+or clone this if your on arm
+```
+git clone -b arm https://github.com/rohanverma2007/navigator.git
+```
+
+2. cd into the navigator directory `cd navigator`
+
+3. simply run the command `docker compose up -d` (or `podman-compose up -d` if you use podman)
+
+4. should be running on port 11080, a reverse proxy is recommended for this setup
+
+## how do i update?
+
+1. cd into the git cloned directory
+
+2. type inside the dir `git pull`
+
+## reverse proxies?
+I currently use Traefik on my podman containers, heres the labels I have on my navigator container, but you can really use whatever reverse proxy you want (etc. NGINX, Caddy, Apache)
+```
+ labels:
+ - "traefik.enable=true"
+ - "traefik.http.routers.navigate.rule=Host(`navigate.example.com`)"
+ - "traefik.http.routers.navigate.entrypoints=websecure"
+ - "traefik.http.routers.navigate.tls=true"
+ - "traefik.http.routers.navigate.tls.certresolver=lets-encrypt"
+ - "traefik.http.services.navigate.loadbalancer.server.port=11080"
+```
blob - /dev/null
blob + 6d0046034b4ed16096917fa028498301508be9b3 (mode 755)
Binary files /dev/null and assets/dark.png differ
blob - /dev/null
blob + aea1cb78aafbd07bfa51d19b2f8ea4fdd80ccf3d (mode 755)
Binary files /dev/null and assets/light.png differ
blob - /dev/null
blob + bb41e6ada29084ceda07edd90b0e789005e4f5ac (mode 755)
--- /dev/null
+++ compose.yaml
+services:
+ navigator:
+ image: alpine:latest
+ container_name: navigator
+ restart: unless-stopped
+ working_dir: /app
+ command: ./server
+ ports:
+ - "11080:11080"
+ dns:
+ - 1.1.1.1
+ - 8.8.8.8
+ labels:
+ - "traefik.enable=true"
+ - "traefik.http.routers.navigate.rule=Host(`navigate.rsap.sh`)"
+ - "traefik.http.routers.navigate.entrypoints=websecure"
+ - "traefik.http.routers.navigate.tls=true"
+ - "traefik.http.routers.navigate.tls.certresolver=lets-encrypt"
+ - "traefik.http.services.navigate.loadbalancer.server.port=11080"
+ networks:
+ - podman
+ volumes:
+ - ./index.html:/app/index.html:ro
+ - ./services.json:/app/services.json:ro
+ - ./assets/dark.png:/app/dark.png:ro
+ - ./assets/light.png:/app/light.png:ro
+ - ./status-api/server.c:/app/server.c:ro
+ - ./fonts:/app/fonts:ro
+ entrypoint: |
+ sh -c "
+ apk add --no-cache curl gcc musl-dev &&
+ gcc -Os -s -ffunction-sections -fdata-sections -Wl,--gc-sections -o server server.c &&
+ strip --strip-all server &&
+ ls -lh server &&
+ ./server
+ "
+
+networks:
+ podman:
+ external: true
blob - /dev/null
blob + 488376227af092dd09c6070a984de633d4cd3aa5 (mode 755)
Binary files /dev/null and fonts/GoMonoNerdFont-Bold.ttf differ
blob - /dev/null
blob + 16a642c3b4deae303da096948637a9868dd84113 (mode 755)
Binary files /dev/null and fonts/GoMonoNerdFont-Regular.ttf differ
blob - /dev/null
blob + ec7d6813033657ce1c40ddb534f1646343c42624 (mode 755)
--- /dev/null
+++ index.html
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width,initial-scale=1">
+ <title>Navigator</title>
+ <link rel="icon" href="dark.png">
+ <link rel="apple-touch-icon" href="light.png">
+ <style>
+ @font-face {
+ font-family: 'GoMono Nerd Font';
+ src: url('./fonts/GoMonoNerdFont-Regular.ttf') format('truetype');
+ font-weight: normal;
+ font-style: normal;
+ }
+
+ @font-face {
+ font-family: 'GoMono Nerd Font';
+ src: url('./fonts/GoMonoNerdFont-Bold.ttf') format('truetype');
+ font-weight: bold;
+ font-style: normal;
+ }
+
+ * {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+ }
+
+ :root {
+ --bp: #fff;
+ --bs: #f8f8f8;
+ --bh: #f0f0f0;
+ --bc: #fff;
+ --tp: #000;
+ --ts: #666;
+ --b: #e0e0e0;
+ --s: rgba(0, 0, 0, .08);
+ --l: url(light.png);
+ --on: #00c851;
+ --of: #ff3547;
+ }
+
+ [data-theme=dark] {
+ --bp: #0a0a0a;
+ --bs: #1a1a1a;
+ --bh: #252525;
+ --bc: #141414;
+ --tp: #fff;
+ --ts: #999;
+ --b: #2a2a2a;
+ --s: rgba(0, 0, 0, .4);
+ --l: url(dark.png);
+ }
+
+ :root[data-theme="light"] #ti {
+ color: #000000;
+ }
+
+ :root[data-theme="dark"] #ti {
+ color: var(--tp);
+ }
+
+ body {
+ font-family: 'GoMono Nerd Font', monospace;
+ background: var(--bp);
+ color: var(--tp);
+ min-height: 100vh;
+ padding: 60px 40px;
+ transition: background-color .3s, color .3s;
+ }
+
+ .c {
+ max-width: 1400px;
+ margin: 0 auto;
+ }
+
+ .h {
+ margin-bottom: 60px;
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ }
+
+ .lo {
+ width: 40px;
+ height: 40px;
+ background: var(--l) no-repeat center/contain;
+ flex-shrink: 0;
+ transform: translateY(-1px);
+ }
+
+ h1 {
+ font-size: 2rem;
+ font-weight: 400;
+ letter-spacing: -.02em;
+ transform: translateY(-2px);
+ }
+
+ .g {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
+ gap: 20px;
+ }
+
+ .sc {
+ background: var(--bs);
+ border: 1.5px solid var(--b);
+ border-radius: 0px;
+ padding: 24px;
+ text-decoration: none;
+ color: var(--tp);
+ transition: all .2s;
+ position: relative;
+ display: flex;
+ align-items: center;
+ gap: 20px;
+ }
+
+ .sc:hover {
+ background: var(--bh);
+ transform: translateY(-2px);
+ box-shadow: 0 8px 24px var(--s);
+ }
+
+ .si {
+ width: 48px;
+ height: 48px;
+ border-radius: 0px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 24px;
+ flex-shrink: 0;
+ background: var(--bs);
+ border: 0px solid var(--b);
+ overflow: hidden;
+ }
+
+ .si img {
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
+ }
+
+ .sn {
+ flex: 1;
+ min-width: 0;
+ }
+
+ .sn>div:first-child {
+ font-size: 16px;
+ font-weight: 500;
+ margin-bottom: 4px;
+ }
+
+ .sn>div:last-child {
+ font-size: 13px;
+ color: var(--ts);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .d {
+ position: absolute;
+ top: 12px;
+ right: 12px;
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: var(--b);
+ transition: all .3s;
+ }
+
+ .d.on {
+ background: var(--on);
+ box-shadow: 0 0 8px rgba(0, 200, 81, .4);
+ }
+
+ .d.of {
+ background: var(--of);
+ box-shadow: 0 0 8px rgba(255, 53, 71, .4);
+ }
+
+ .e, .l {
+ text-align: center;
+ padding: 100px 20px;
+ color: var(--ts);
+ }
+
+ .e h2 {
+ font-size: 1.5rem;
+ font-weight: 400;
+ margin-bottom: 10px;
+ color: var(--tp);
+ }
+
+ .e p, .l {
+ font-size: 14px;
+ }
+
+ .er, .ce {
+ background: var(--bs);
+ border: 1px solid var(--b);
+ border-radius: 12px;
+ padding: 40px;
+ text-align: center;
+ color: var(--ts);
+ }
+
+ .ce {
+ padding: 20px;
+ margin-top: 20px;
+ text-align: left;
+ }
+
+ .er h2, .ce h3 {
+ font-weight: 400;
+ margin-bottom: 10px;
+ color: var(--tp);
+ }
+
+ .er h2 {
+ font-size: 1.2rem;
+ }
+
+ .ce h3 {
+ font-size: 14px;
+ font-weight: 500;
+ }
+
+ .er pre, .ce pre {
+ background: var(--bc);
+ border: 1px solid var(--b);
+ border-radius: 8px;
+ padding: 12px;
+ margin-top: 20px;
+ font-size: 12px;
+ text-align: left;
+ overflow-x: auto;
+ font-family: 'GoMono Nerd Font', monospace;
+ color: var(--tp);
+ }
+
+ .ce pre {
+ border-radius: 4px;
+ margin-top: 10px;
+ }
+
+ #ti {
+ font-family: 'GoMono Nerd Font', monospace;
+ font-size: 20px;
+ line-height: 1; /* critical */
+ display: block;
+ transform: translateX(-3px);
+ }
+
+ .sb {
+ position: fixed;
+ bottom: 20px;
+ right: 20px;
+ font-size: 14px;
+ color: var(--ts);
+ font-family: 'GoMono Nerd Font', monospace;
+ font-weight: 400;
+ letter-spacing: -.01em;
+ background: var(--bs);
+ padding: 10px 16px;
+ border: 1px solid var(--b);
+ border-radius: 8px;
+ backdrop-filter: blur(8px);
+ }
+
+ .ft {
+ position: fixed;
+ bottom: 20px;
+ left: 50%;
+ transform: translateX(-50%);
+ color: #ffffff;
+ opacity: 0.3;
+ font-size: 12px;
+ }
+
+ .ft span {
+ color: #ffffff;
+ opacity: 0.6;
+ }
+
+ .ft a {
+ color: inherit;
+ text-decoration: none;
+ transition: opacity 0.2s;
+ }
+
+ .ft a:hover {
+ opacity: 2;
+ text-decoration: underline;
+ }
+
+ .tt {
+ position: fixed;
+ top: 20px;
+ right: 20px;
+ width: 44px;
+ height: 44px;
+ border-radius: 12px;
+ background: var(--bs);
+ border: 1px solid var(--b);
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: all .3s;
+ font-size: 20px;
+ font-family: 'GoMono Nerd Font', monospace;
+ }
+
+ .tt:hover {
+ background: var(--bh);
+ transform: scale(1.05);
+ }
+
+ .tt:active {
+ transform: scale(.95);
+ }
+
+ code {
+ background: var(--bh);
+ padding: 2px 6px;
+ border-radius: 3px;
+ }
+
+ @media(max-width: 768px) {
+ body {
+ padding: 40px 20px;
+ }
+
+ .g {
+ grid-template-columns: 1fr;
+ }
+
+ h1 {
+ font-size: 1.5rem;
+ }
+
+ .lo {
+ width: 32px;
+ height: 32px;
+ }
+
+ .sb {
+ font-size: 12px;
+ padding: 8px 12px;
+ bottom: 16px;
+ right: 16px;
+ }
+ }
+
+ @media(prefers-reduced-motion: reduce) {
+ *, ::before, ::after {
+ animation-duration: .01ms!important;
+ animation-iteration-count: 1!important;
+ transition-duration: .01ms!important;
+ }
+ }
+ </style>
+</head>
+<body>
+ <button class="tt" id="t" aria-label="Toggle theme">
+ <span id="ti"></span>
+ </button>
+ <div class="c">
+ <div class="h">
+ <div class="lo"></div>
+ <h1>Navigator</h1>
+ </div>
+ <div id="c">
+ <div class="l">Loading services...</div>
+ </div>
+ </div>
+ <div class="sb">
+ <span id="sc">0</span> services | <span id="oc">0</span> online
+ </div>
+ <div class="ft">
+ Made with <span>ó°‹‘</span> by <a href="https://github.com/rohanverma2007" target="_blank" rel="noopener noreferrer">Rohan Verma</a>
+ </div>
+ <script>
+ (() => {
+ const d = document,
+ q = d.querySelector.bind(d),
+ qa = d.querySelectorAll.bind(d),
+ ls = localStorage,
+ el = d.documentElement;
+ let s = [],
+ ci,
+ lcc = null,
+ sa = null;
+
+ // Theme
+ const pd = matchMedia('(prefers-color-scheme:dark)').matches,
+ st = ls.getItem('t'),
+ th = st || (pd ? 'dark' : 'light');
+ el.setAttribute('data-theme', th);
+ const ut = () => {
+ const ct = el.getAttribute('data-theme');
+ q('#ti').textContent = ct === 'dark' ? '' : '';
+ };
+ ut();
+ q('#t').onclick = () => {
+ const ct = el.getAttribute('data-theme'),
+ nt = ct === 'dark' ? 'light' : 'dark';
+ el.setAttribute('data-theme', nt);
+ ls.setItem('t', nt);
+ ut();
+ };
+
+ // Load config
+ const lc = async () => {
+ try {
+ let r, cl = 0;
+ for (const p of ['./services.json', 'services.json']) {
+ try {
+ r = await fetch(`${p}?t=${Date.now()}`);
+ if (r.ok) {
+ cl = 1;
+ break;
+ }
+ } catch {}
+ }
+ if (!cl && location.protocol === 'file:') {
+ se();
+ return;
+ }
+ if (!cl) throw Error('Could not find services.json');
+ const t = await r.text();
+ if (t !== lcc) {
+ lcc = t;
+ const d = JSON.parse(t);
+ s = d.services || [];
+ sa = d.statusApiUrl || null;
+ rs();
+ cs();
+ }
+ } catch (e) {
+ console.error('Failed to load config:', e);
+ sh(e);
+ }
+ };
+
+ // Render services
+ const rs = () => {
+ const c = q('#c'),
+ sc = q('#sc'),
+ oc = q('#oc');
+ sc.textContent = s.length;
+ if (!s.length) {
+ c.innerHTML = `<div class="e"><h2>No services configured</h2><p>Add services to your services.json file</p><div class="ce"><h3>Example services.json:</h3><pre>{
+ "services": [
+ {
+ "name": "Example Service",
+ "url": "example.rsap.sh",
+ "icon": "🚀"
+ }
+ ]
+}</pre></div></div>`;
+ return;
+ }
+ const g = d.createElement('div');
+ g.className = 'g';
+ s.forEach(sv => {
+ const a = d.createElement('a');
+ let u = sv.url;
+ if (!u.startsWith('http')) u = 'https://' + u;
+ if (sv.port) {
+ const o = new URL(u);
+ o.port = sv.port;
+ u = o.toString();
+ }
+ a.href = u;
+ a.className = 'sc';
+ a.target = '_blank';
+ a.rel = 'noopener noreferrer';
+ let ic = '';
+ if (sv.icon) {
+ if (sv.icon.startsWith('http') || sv.icon.startsWith('/') || sv.icon.includes('.')) {
+ ic = `<img src="${sv.icon}" alt="${sv.name}" loading="lazy" onerror="this.style.display='none';this.parentElement.innerHTML='📦';">`;
+ } else ic = sv.icon;
+ } else ic = '📦';
+ const sc = sv.status === 'online' ? 'on' : sv.status === 'offline' ? 'of' : '';
+ const du = sv.port ? `${sv.url}:${sv.port}` : sv.url;
+ a.innerHTML = `<div class="d ${sc}"></div><div class="si">${ic}</div><div class="sn"><div>${sv.name}</div><div>${du}</div></div>`;
+ g.appendChild(a);
+ });
+ c.innerHTML = '';
+ c.appendChild(g);
+ oc.textContent = s.filter(sv => sv.status === 'online').length;
+ };
+
+ // Error handlers
+ const sh = e => {
+ q('#c').innerHTML = `<div class="er"><h2>Configuration Error</h2><p>Failed to load services.json</p><pre>${e.message}</pre><div class="ce"><h3>Setup Instructions:</h3><p style="margin-bottom:10px;font-size:13px">1. Create a file named <strong>services.json</strong> in the same directory as this HTML file<br>2. Add your services configuration<br>3. Make sure you're serving files through a web server (not file://)</p><h3>Example services.json:</h3><pre>{
+ "services": [
+ {
+ "name": "Jellyfin",
+ "url": "jellyfin.example.com",
+ "icon": "ðŸŽ"
+ }
+ ]
+}</pre></div></div>`;
+ };
+
+ const se = () => {
+ q('#c').innerHTML = `<div class="er"><h2>Local File Access Restricted</h2><p>Cannot load services.json using file:// protocol</p><div class="ce"><h3>How to fix:</h3><p style="margin-bottom:10px;font-size:13px">Option 1: Use a local web server<br><code>python3 -m http.server 8000</code><br><br>Option 2: Use Live Server in VS Code<br><br>Option 3: Deploy to your web server/Traefik</p></div></div>`;
+ };
+
+ // Status check
+ const ck = async i => {
+ const sv = s[i],
+ u = sv.url.startsWith('http') ? sv.url : `https://${sv.url}`;
+ if (sa) {
+ try {
+ const r = await fetch(`${sa}/check?url=${encodeURIComponent(u)}`);
+ const d = await r.json();
+ s[i].status = d.online ? 'online' : 'offline';
+ } catch {
+ s[i].status = 'online';
+ }
+ } else s[i].status = 'online';
+ rs();
+ };
+
+ const cs = () => s.forEach((_, i) => ck(i));
+
+ // Init
+ lc();
+ ci = setInterval(lc, 5000);
+ addEventListener('beforeunload', () => ci && clearInterval(ci));
+ })();
+ </script>
+</body>
+</html>
blob - /dev/null
blob + 792a397431dd426b6392d89c9f4a5b6d007f077f (mode 755)
--- /dev/null
+++ status-api/server.c
+// Ported plain C version from https://github.com/rohanverma2007/navigator/blob/main/status-api/server.js
+// Minimal footprint (~1MB) C port of the navigator tool API server.js shared in r/homelab by u/SmokerrYT
+// @faustinoaq
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+#include <netinet/in.h>
+#include <sys/types.h>
+#include <sys/socket.h>
+#include <fcntl.h>
+#include <sys/stat.h>
+#include <sys/wait.h>
+#include <time.h>
+#include <errno.h>
+
+#define PORT 11080
+#define TIMEOUT 3
+#define CACHE_TTL 20
+#define CACHE_MAX 100
+#define BUF_SIZE 4096
+
+struct CacheEntry {
+ char url[256];
+ int online;
+ int code;
+ int time;
+ time_t ts;
+};
+struct CacheEntry cache[CACHE_MAX];
+int cache_size = 0;
+
+// Find cache entry
+int find_cache(const char *url) {
+ for (int i = 0; i < cache_size; ++i) {
+ if (strcmp(cache[i].url, url) == 0) return i;
+ }
+ return -1;
+}
+
+// Add/update cache
+void set_cache(const char *url, int online, int code, int elapsed) {
+ int idx = find_cache(url);
+ if (idx < 0 && cache_size < CACHE_MAX) idx = cache_size++;
+ if (idx >= 0) {
+ strncpy(cache[idx].url, url, 255);
+ cache[idx].online = online;
+ cache[idx].code = code;
+ cache[idx].time = elapsed;
+ cache[idx].ts = time(NULL);
+ }
+}
+
+// Clean old cache
+void clean_cache() {
+ time_t now = time(NULL);
+ for (int i = 0; i < cache_size;) {
+ if (now - cache[i].ts > CACHE_TTL) {
+ cache[i] = cache[--cache_size];
+ } else {
+ ++i;
+ }
+ }
+}
+
+// URL decode function
+void url_decode(char *dst, const char *src) {
+ char *p = dst;
+ while (*src) {
+ if (*src == '%' && src[1] && src[2]) {
+ char hex[3] = {src[1], src[2], 0};
+ *p++ = (char)strtol(hex, NULL, 16);
+ src += 3;
+ } else if (*src == '+') {
+ *p++ = ' ';
+ src++;
+ } else {
+ *p++ = *src++;
+ }
+ }
+ *p = 0;
+}
+
+// Minimal HTTP check using curl
+void check_http(const char *url, int *online, int *code, int *ms) {
+ char cmd[512], buf[16];
+ char full_url[512];
+
+ // Add https:// if not present
+ if (strstr(url, "http://") == url || strstr(url, "https://") == url) {
+ strcpy(full_url, url);
+ } else {
+ snprintf(full_url, sizeof(full_url), "https://%s", url);
+ }
+
+ snprintf(cmd, sizeof(cmd), "curl -s -o /dev/null -w %%{http_code} --connect-timeout 2 --max-time 3 --insecure '%s'", full_url);
+ clock_t start = clock();
+ FILE *fp = popen(cmd, "r");
+ if (!fp) { *online = 0; *code = 0; *ms = TIMEOUT * 1000; return; }
+ if (fgets(buf, sizeof(buf), fp) == NULL) buf[0] = '\0';
+ pclose(fp);
+ *ms = (int)(1000.0 * (clock() - start) / CLOCKS_PER_SEC);
+ *code = atoi(buf);
+ // Consider auth prompts (401/403) as online since service is responding
+ *online = (*code >= 200 && *code < 500 && *code != 404);
+}
+
+// Get content type based on file extension
+const char* get_content_type(const char *path) {
+ if (strstr(path, ".html")) return "text/html";
+ if (strstr(path, ".json")) return "application/json";
+ if (strstr(path, ".png")) return "image/png";
+ if (strstr(path, ".jpg") || strstr(path, ".jpeg")) return "image/jpeg";
+ return "text/plain";
+}
+
+// Serve static file
+void serve_file(int fd, const char *path) {
+ int f = open(path, O_RDONLY);
+ if (f < 0) {
+ dprintf(fd, "HTTP/1.1 404 Not Found\r\nContent-Type: text/plain\r\n\r\nNot found");
+ return;
+ }
+ struct stat st;
+ fstat(f, &st);
+ const char *content_type = get_content_type(path);
+ dprintf(fd, "HTTP/1.1 200 OK\r\nContent-Type: %s\r\nContent-Length: %ld\r\nCache-Control: public, max-age=86400\r\n\r\n", content_type, st.st_size);
+ char buf[BUF_SIZE];
+ ssize_t n;
+ while ((n = read(f, buf, BUF_SIZE)) > 0) {
+ ssize_t w = write(fd, buf, n);
+ (void)w;
+ }
+ close(f);
+}
+
+// Minimal JSON response
+void send_json(int fd, const char *json) {
+ dprintf(fd, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\r\n%s", json);
+}
+
+// Parse URL param with decoding
+void get_query(const char *req, const char *key, char *out, int outlen) {
+ char *p = strstr(req, key);
+ if (!p) { out[0] = 0; return; }
+ p += strlen(key);
+ char *e = strchr(p, '&');
+ if (!e) e = strchr(p, ' ');
+ int len = e ? e - p : strlen(p);
+ if (len > outlen-1) len = outlen-1;
+
+ char temp[256];
+ strncpy(temp, p, len);
+ temp[len] = 0;
+ url_decode(out, temp);
+}
+
+// Main server loop
+int main() {
+ int server_fd = socket(AF_INET, SOCK_STREAM, 0);
+ if (server_fd < 0) return 1;
+ int opt = 1;
+ setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
+ struct sockaddr_in addr = {0};
+ addr.sin_family = AF_INET;
+ addr.sin_addr.s_addr = INADDR_ANY;
+ addr.sin_port = htons(PORT);
+ if (bind(server_fd, (struct sockaddr*)&addr, sizeof(addr)) < 0) return 1;
+ listen(server_fd, 10);
+ printf("Listening on port %d...\n", PORT);
+ while (1) {
+ int client_fd = accept(server_fd, NULL, NULL);
+ if (client_fd < 0) continue;
+ char req[BUF_SIZE] = {0};
+ ssize_t r = read(client_fd, req, BUF_SIZE-1);
+ (void)r;
+ clean_cache();
+ // API routes
+ if (strstr(req, "GET /api/health")) {
+ char json[128];
+ snprintf(json, sizeof(json), "{\"ok\":1,\"up\":%ld}", time(NULL));
+ send_json(client_fd, json);
+ } else if (strstr(req, "GET /api/check?url=")) {
+ char url[256];
+ get_query(req, "url=", url, sizeof(url));
+ int idx = find_cache(url);
+ int online, code, ms;
+ if (idx >= 0 && time(NULL)-cache[idx].ts < CACHE_TTL) {
+ online = cache[idx].online; code = cache[idx].code; ms = cache[idx].time;
+ } else {
+ check_http(url, &online, &code, &ms);
+ set_cache(url, online, code, ms);
+ }
+ char json[256];
+ snprintf(json, sizeof(json), "{\"url\":\"%s\",\"online\":%s,\"code\":%d,\"time\":%d}", url, online ? "true" : "false", code, ms);
+ send_json(client_fd, json);
+ } else if (strstr(req, "GET /api/cache")) {
+ char json[BUF_SIZE];
+ int n = snprintf(json, sizeof(json), "{\"size\":%d,\"ttl\":%d,\"entries\":[", cache_size, CACHE_TTL);
+ for (int i = 0; i < cache_size; ++i) {
+ n += snprintf(json+n, sizeof(json)-n, "{\"url\":\"%s\",\"online\":%s,\"age\":%ld}%s", cache[i].url, cache[i].online ? "true" : "false", time(NULL)-cache[i].ts, i+1<cache_size?",":"");
+ }
+ snprintf(json+n, sizeof(json)-n, "]}");
+ send_json(client_fd, json);
+ } else if (strstr(req, "DELETE /api/cache")) {
+ cache_size = 0;
+ send_json(client_fd, "{\"cleared\":1}");
+ } else if (strstr(req, "GET / ") || strstr(req, "GET /\r")) {
+ serve_file(client_fd, "index.html");
+ } else if (strstr(req, "GET /services.json")) {
+ serve_file(client_fd, "services.json");
+ } else if (strstr(req, "GET /")) {
+ // Serve static file
+ char path[128] = {0};
+ char *p = strstr(req, "GET /") + 4;
+ char *e = strchr(p, ' ');
+ int len = e ? e-p : 0;
+ if (len > 0 && len < 100) {
+ if (*p == '/') p++; // Skip leading slash
+ strncpy(path, p, len-1);
+ path[len-1] = 0;
+ serve_file(client_fd, path);
+ } else {
+ dprintf(client_fd, "HTTP/1.1 400 Bad Request\r\n\r\n");
+ }
+ } else {
+ dprintf(client_fd, "HTTP/1.1 404 Not Found\r\nContent-Type: text/plain\r\n\r\nNot found");
+ }
+ close(client_fd);
+ }
+ close(server_fd);
+ return 0;
+}