diff --git a/backend/health-proxy.py b/backend/health-proxy.py new file mode 100644 index 0000000..0827e42 --- /dev/null +++ b/backend/health-proxy.py @@ -0,0 +1,34 @@ +from flask import Flask, request, jsonify, abort +import requests +import os + +app = Flask(__name__) + +# Optional token for access control (not production secure) +HEALTH_TOKEN = os.environ.get('HEALTH_TOKEN') + +@app.route('/healthcheck') +def healthcheck(): + token = request.args.get('token') or request.headers.get('X-Health-Token') + if HEALTH_TOKEN and token != HEALTH_TOKEN: + return jsonify({'error': 'Unauthorized'}), 401 + + host = request.args.get('host') + port = request.args.get('port') + proto = request.args.get('proto', 'http') + if not host or not port: + return jsonify({'error': 'missing parameters, expected host and port'}), 400 + + url = f"{proto}://{host}:{port}" + + try: + # Use HEAD to do a lightweight check + r = requests.head(url, timeout=4, allow_redirects=True, verify=False) + # Any 2xx/3xx is considered OK + ok = 200 <= r.status_code < 400 + return jsonify({'ok': ok, 'status_code': r.status_code}), (200 if ok else 502) + except requests.RequestException as e: + return jsonify({'ok': False, 'error': str(e)}), 502 + +if __name__ == '__main__': + app.run(host='0.0.0.0', port=8081) diff --git a/docker-compose.yml b/docker-compose.yml index 9dc3253..bade4ad 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,6 +12,23 @@ services: - ./logos:/usr/share/nginx/html/logos:ro - ./js:/usr/share/nginx/html/js:ro - ./README.md:/usr/share/nginx/html/README.md:ro + - ./nginx.conf:/etc/nginx/nginx.conf:ro + restart: unless-stopped + logging: + driver: json-file + options: + max-size: "5m" + max-file: "2" + + health-proxy: + image: python:3.10-slim + container_name: services-homepage-health-proxy + working_dir: /app + volumes: + - ./backend/health-proxy.py:/app/health-proxy.py:ro + environment: + - HEALTH_TOKEN= + command: ["sh", "-c", "pip install flask requests && python health-proxy.py"] restart: unless-stopped logging: driver: json-file diff --git a/js/services-loader.js b/js/services-loader.js index cf0c5f4..0440f22 100644 --- a/js/services-loader.js +++ b/js/services-loader.js @@ -82,39 +82,64 @@ // Function to create a service card function createServiceCard(s, host, allServices, tailscaleIP) { const name = s.getAttribute('name') || s.getAttribute('id') || 'unknown'; - const proto = s.getAttribute('proto') || 'http'; + const proto = s.getAttribute('proto') || 'http'; const port = s.getAttribute('port') || ''; const logo = s.getAttribute('logo') || ''; const hostAttr = s.getAttribute('host'); // optional public hostname or full URL const manualStatus = s.getAttribute('status'); // optional: 'online', 'offline', 'maintenance' const checkHealth = s.getAttribute('check-health') !== 'false'; // default true, set to false to disable - // Build href: prefer explicit host attribute when present. + // Build href: prefer explicit host attribute when present. let href = ''; let healthCheckUrl = ''; // Separate URL for health checks + const pageIsSecure = window.location.protocol === 'https:'; + const desiredProto = pageIsSecure ? 'https' : proto; // use https for health checks when page is secure to avoid mixed content if(hostAttr){ // Service has a public hostname or URL + // Parse hostAttr to extract hostname and optional port + let parsedHost = hostAttr; + let parsedPort = port; + let parsedProto = proto; if(/^https?:\/\//i.test(hostAttr)){ + // remove protocol + const u = new URL(hostAttr); + parsedHost = u.hostname; + parsedPort = u.port || parsedPort; + parsedProto = u.protocol.replace(':','') || parsedProto; href = hostAttr; - healthCheckUrl = hostAttr; } else { const hasPortInHost = /:\d+$/.test(hostAttr); if(hasPortInHost){ + // host:port href = `${proto}://${hostAttr}`; - healthCheckUrl = `${proto}://${hostAttr}`; + parsedHost = hostAttr.split(':')[0]; + parsedPort = hostAttr.split(':')[1]; } else { href = `https://${hostAttr}`; - healthCheckUrl = `https://${hostAttr}`; + parsedHost = hostAttr; } } + // Always proxy health checks via same-origin endpoint to avoid mixed-content + const encodedHost = encodeURIComponent(parsedHost); + const hcPort = parsedPort || (pageIsSecure ? '443' : (parsedProto === 'https' ? '443' : '80')); + healthCheckUrl = `/healthcheck?host=${encodedHost}&port=${encodeURIComponent(hcPort)}&proto=${encodeURIComponent(parsedProto)}`; } else { // Local service - use Tailscale IP if configured, otherwise current hostname const targetHost = tailscaleIP || host; let portPart = ''; if(port && !((proto==='http'&&port==='80')||(proto==='https'&&port==='443'))){ portPart = ':'+port; } - href = `${proto}://${targetHost}${portPart}`; - healthCheckUrl = `${proto}://${targetHost}${portPart}`; + // Keep the link protocol as the defined proto (so clicking uses the intended protocol), + // but use the server-side health proxy for health checks to avoid mixed content blocking. + href = `${proto}://${targetHost}${portPart}`; + // Use our same-origin proxy path which nginx will forward to the health-proxy service. + const encodedHost = encodeURIComponent(targetHost); + const hcPort = port || (desiredProto === 'https' ? '443' : '80'); + healthCheckUrl = `/healthcheck?host=${encodedHost}&port=${encodeURIComponent(hcPort)}&proto=${encodeURIComponent(desiredProto)}`; + // Warn when site is secure but service link is HTTP and target is a private IP + if(pageIsSecure && proto === 'http' && targetHost === tailscaleIP){ + console.warn(`Service ${name} will use HTTP at ${href}, but the page is loaded over HTTPS — mixed-content checks will prevent programmatic health checks to the service. Consider enabling TLS or providing a public hostname.`); + } } const a = document.createElement('a'); @@ -136,23 +161,26 @@ a.appendChild(img); a.appendChild(span); - // Add status indicator - will be updated by health check + // Add status indicator - will be updated by health check const statusDot = document.createElement('span'); statusDot.className = 'status-dot status-checking'; statusDot.title = 'Checking status...'; let currentStatus = 'checking'; - // If maintenance mode is set manually, use that and skip health check + // If maintenance mode is set manually, use that and skip health check if(manualStatus === 'maintenance'){ statusDot.className = 'status-dot status-maintenance'; statusDot.title = 'Status: maintenance'; currentStatus = 'maintenance'; a.appendChild(statusDot); - } else if(checkHealth && healthCheckUrl){ + } else if(checkHealth && healthCheckUrl){ // Show checking indicator initially a.appendChild(statusDot); // Perform health check using healthCheckUrl (Tailscale IP for local services) + if(healthCheckUrl !== href){ + console.log(`Service ${name}: using healthCheckUrl (${healthCheckUrl}) while link is ${href}`); + } (async function performHealthCheck(){ try { const controller = new AbortController(); @@ -173,6 +201,22 @@ statusDot.title = 'Status: online'; } catch(err) { // Connection failed or timed out + // If the page is not secure, try fallback to http if we used https for health check + if(!pageIsSecure && healthCheckUrl.startsWith('https:')){ + try { + const fallback = healthCheckUrl.replace(/^https:/i, 'http:'); + const controller2 = new AbortController(); + const timeoutId2 = setTimeout(() => controller2.abort(), 5000); + await fetch(fallback, { method: 'HEAD', mode: 'no-cors', cache: 'no-cache', signal: controller2.signal }); + clearTimeout(timeoutId2); + currentStatus = 'online'; + statusDot.className = 'status-dot status-online'; + statusDot.title = 'Status: online'; + return; + } catch(fErr){ + // continue to mark offline + } + } currentStatus = 'offline'; statusDot.className = 'status-dot status-offline'; statusDot.title = 'Status: offline'; diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..04bdfd3 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,40 @@ +user nginx; +worker_processes 1; +error_log /var/log/nginx/error.log warn; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + sendfile on; + keepalive_timeout 65; + + server { + listen 80; + server_name localhost; + + # Health proxy endpoint - forwards to internal python service + location /healthcheck { + proxy_pass http://health-proxy:8081$request_uri; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_connect_timeout 3s; + proxy_read_timeout 5s; + } + + # Serve static files + location / { + root /usr/share/nginx/html; + try_files $uri $uri/ =404; + } + + error_page 404 /404.html; + } +}