commit 91c2fbb0178d372c7de613bcc377cfbeefc26b48 from: rohanverma2007 date: Mon Apr 20 20:40:41 2026 UTC mirror: https://github.com/rohanverma2007/navigator commit - /dev/null commit + 91c2fbb0178d372c7de613bcc377cfbeefc26b48 blob - /dev/null blob + a373fb4b28e59937d6e58caf0f15131289cbcb54 (mode 644) --- /dev/null +++ .gitignore @@ -0,0 +1 @@ +services.json blob - /dev/null blob + 8c19a3a15fbfa7597cee90f0a7f23aca2ca48a06 (mode 755) --- /dev/null +++ LICENSE @@ -0,0 +1,21 @@ +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 @@ -0,0 +1,52 @@ +# navigator 🧭 +Navigate between your HomeLab services + +## preview +image +image + +## 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 @@ -0,0 +1,40 @@ +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 @@ -0,0 +1,542 @@ + + + + + + Navigator + + + + + + +
+
+
+

Navigator

+
+
+
Loading services...
+
+
+
+ 0 services | 0 online +
+
+ Made with ó°‹‘ by Rohan Verma +
+ + + blob - /dev/null blob + 792a397431dd426b6392d89c9f4a5b6d007f077f (mode 755) --- /dev/null +++ status-api/server.c @@ -0,0 +1,234 @@ +// 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 +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#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 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; +}