Add health proxy service and Nginx configuration for health checks

This commit is contained in:
MayaChat
2025-11-24 12:26:37 -05:00
parent aa1a38b6fb
commit af2658bfce
4 changed files with 145 additions and 10 deletions

34
backend/health-proxy.py Normal file
View File

@@ -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)

View File

@@ -12,6 +12,23 @@ services:
- ./logos:/usr/share/nginx/html/logos:ro - ./logos:/usr/share/nginx/html/logos:ro
- ./js:/usr/share/nginx/html/js:ro - ./js:/usr/share/nginx/html/js:ro
- ./README.md:/usr/share/nginx/html/README.md: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 restart: unless-stopped
logging: logging:
driver: json-file driver: json-file

View File

@@ -82,39 +82,64 @@
// Function to create a service card // Function to create a service card
function createServiceCard(s, host, allServices, tailscaleIP) { function createServiceCard(s, host, allServices, tailscaleIP) {
const name = s.getAttribute('name') || s.getAttribute('id') || 'unknown'; 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 port = s.getAttribute('port') || '';
const logo = s.getAttribute('logo') || ''; const logo = s.getAttribute('logo') || '';
const hostAttr = s.getAttribute('host'); // optional public hostname or full URL const hostAttr = s.getAttribute('host'); // optional public hostname or full URL
const manualStatus = s.getAttribute('status'); // optional: 'online', 'offline', 'maintenance' const manualStatus = s.getAttribute('status'); // optional: 'online', 'offline', 'maintenance'
const checkHealth = s.getAttribute('check-health') !== 'false'; // default true, set to false to disable 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 href = '';
let healthCheckUrl = ''; // Separate URL for health checks 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){ if(hostAttr){
// Service has a public hostname or URL // 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)){ 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; href = hostAttr;
healthCheckUrl = hostAttr;
} else { } else {
const hasPortInHost = /:\d+$/.test(hostAttr); const hasPortInHost = /:\d+$/.test(hostAttr);
if(hasPortInHost){ if(hasPortInHost){
// host:port
href = `${proto}://${hostAttr}`; href = `${proto}://${hostAttr}`;
healthCheckUrl = `${proto}://${hostAttr}`; parsedHost = hostAttr.split(':')[0];
parsedPort = hostAttr.split(':')[1];
} else { } else {
href = `https://${hostAttr}`; 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 { } else {
// Local service - use Tailscale IP if configured, otherwise current hostname // Local service - use Tailscale IP if configured, otherwise current hostname
const targetHost = tailscaleIP || host; const targetHost = tailscaleIP || host;
let portPart = ''; let portPart = '';
if(port && !((proto==='http'&&port==='80')||(proto==='https'&&port==='443'))){ portPart = ':'+port; } if(port && !((proto==='http'&&port==='80')||(proto==='https'&&port==='443'))){ portPart = ':'+port; }
href = `${proto}://${targetHost}${portPart}`; // Keep the link protocol as the defined proto (so clicking uses the intended protocol),
healthCheckUrl = `${proto}://${targetHost}${portPart}`; // 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'); const a = document.createElement('a');
@@ -136,23 +161,26 @@
a.appendChild(img); a.appendChild(img);
a.appendChild(span); 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'); const statusDot = document.createElement('span');
statusDot.className = 'status-dot status-checking'; statusDot.className = 'status-dot status-checking';
statusDot.title = 'Checking status...'; statusDot.title = 'Checking status...';
let currentStatus = 'checking'; 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'){ if(manualStatus === 'maintenance'){
statusDot.className = 'status-dot status-maintenance'; statusDot.className = 'status-dot status-maintenance';
statusDot.title = 'Status: maintenance'; statusDot.title = 'Status: maintenance';
currentStatus = 'maintenance'; currentStatus = 'maintenance';
a.appendChild(statusDot); a.appendChild(statusDot);
} else if(checkHealth && healthCheckUrl){ } else if(checkHealth && healthCheckUrl){
// Show checking indicator initially // Show checking indicator initially
a.appendChild(statusDot); a.appendChild(statusDot);
// Perform health check using healthCheckUrl (Tailscale IP for local services) // 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(){ (async function performHealthCheck(){
try { try {
const controller = new AbortController(); const controller = new AbortController();
@@ -173,6 +201,22 @@
statusDot.title = 'Status: online'; statusDot.title = 'Status: online';
} catch(err) { } catch(err) {
// Connection failed or timed out // 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'; currentStatus = 'offline';
statusDot.className = 'status-dot status-offline'; statusDot.className = 'status-dot status-offline';
statusDot.title = 'Status: offline'; statusDot.title = 'Status: offline';

40
nginx.conf Normal file
View File

@@ -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;
}
}