Add health proxy service and Nginx configuration for health checks
This commit is contained in:
34
backend/health-proxy.py
Normal file
34
backend/health-proxy.py
Normal 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)
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
40
nginx.conf
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user