diff --git a/README.md b/README.md index 394983c..800c7e7 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,7 @@ automatically. Non-secret settings go directly in `docker-compose.yml`: | `OPENWEBUI_TOKEN` | Open WebUI API key — enables the prompt line + model list (**set in `.env`**) | | `GITEA_TOKEN` | Gitea API token — private repo stats (public repos work without it) | | `GITLAB_TOKEN` | GitLab API token — private project stats (public projects work without it) | +| `GITLAB_PROXY` | Forward proxy (HTTP CONNECT) for all GitLab requests — git stats **and** the online badge (**set in `.env`**). Change the URL when the proxy stack moves; unset = direct connection. With the proxy on the docker host use `http://host.docker.internal:8118` (the container resolves `host.docker.internal` via `extra_hosts`) | `GITEA_URL` / `GITLAB_URL` / `NETBIRD_URL` / `OPENWEBUI_URL` | Override the default service URLs | | `GPU_STATS_URL` | GPU stats upstream (default: the `gpu-stats` compose service) | | `OLLAMA_URL` | Ollama instance for loaded-model stats (default: `http://100.103.83.12:11435`) | diff --git a/api/proxy.js b/api/proxy.js new file mode 100644 index 0000000..480ea5a --- /dev/null +++ b/api/proxy.js @@ -0,0 +1,94 @@ +// --------------------------------------------------------------------------- +// Outbound proxy support for the container backend scripts. +// +// Node's core http/https modules ignore proxy env vars, so requests that +// should go through a forward proxy are tunneled manually via HTTP CONNECT. +// Currently used for GitLab access (see GITLAB_PROXY below). +// +// Configure in .env / docker-compose.yml (no rebuild needed, container +// restart is enough): +// GITLAB_PROXY=http://127.0.0.1:8118 (updater run on the host) +// GITLAB_PROXY=http://host.docker.internal:8118 (proxy on the docker host) +// GITLAB_PROXY=http://10.0.0.5:8118 (proxy moved elsewhere) +// Empty/unset disables proxying (direct connection). +// --------------------------------------------------------------------------- + +const http = require("http"); +const tls = require("tls"); +const { URL } = require("url"); + +const GITLAB_PROXY = process.env.GITLAB_PROXY || ""; +const GITLAB_HOST = (() => { + try { + return new URL(process.env.GITLAB_URL || "https://gitlab.ixsol.wien").hostname; + } catch { + return "gitlab.ixsol.wien"; + } +})(); + +// Returns the proxy URL to use for a given target URL, or null for direct. +function proxyFor(url) { + if (!GITLAB_PROXY) return null; + try { + if (new URL(url).hostname === GITLAB_HOST) return GITLAB_PROXY; + } catch { + // unparseable URL — no proxy + } + return null; +} + +// Opens a CONNECT tunnel through the proxy and resolves to a socket usable +// as the connection for a request to targetUrl: a TLS socket for https +// targets, the raw tunnel socket otherwise. +function connectThroughProxy(proxyUrl, targetUrl, timeoutMs, tlsOptions = {}) { + return new Promise((resolve, reject) => { + const target = new URL(targetUrl); + const proxy = new URL(proxyUrl); + const port = target.port || (target.protocol === "https:" ? "443" : "80"); + const req = http.request({ + host: proxy.hostname, + port: proxy.port || 80, + method: "CONNECT", + path: `${target.hostname}:${port}`, + headers: { Host: `${target.hostname}:${port}` }, + timeout: timeoutMs || 10000, + }); + req.on("connect", (res, socket) => { + if (res.statusCode !== 200) { + socket.destroy(); + reject(new Error(`Proxy CONNECT to ${target.hostname} failed: HTTP ${res.statusCode}`)); + return; + } + resolve( + target.protocol === "https:" + ? tls.connect({ socket, servername: target.hostname, ...tlsOptions }) + : socket + ); + }); + req.on("timeout", () => { + req.destroy(); + reject(new Error(`Proxy CONNECT to ${target.hostname} timed out`)); + }); + req.on("error", reject); + req.end(); + }); +} + +// Request options for client.request() that route the request through the +// proxy: https via the CONNECT tunnel (caller passes the socket returned by +// connectThroughProxy), plain http via an absolute-form request URI. +function proxiedRequestOptions(proxyUrl, targetUrl, socket) { + const u = new URL(targetUrl); + if (u.protocol === "https:") { + return { agent: false, createConnection: () => socket }; + } + const p = new URL(proxyUrl); + return { + host: p.hostname, + port: p.port || 80, + path: u.href, + headers: { Host: u.host }, + }; +} + +module.exports = { proxyFor, connectThroughProxy, proxiedRequestOptions }; \ No newline at end of file diff --git a/api/update-data.js b/api/update-data.js index 7fc091d..e671f0c 100644 --- a/api/update-data.js +++ b/api/update-data.js @@ -13,6 +13,7 @@ const https = require("https"); const fs = require("fs"); const path = require("path"); const { URL } = require("url"); +const { proxyFor, connectThroughProxy, proxiedRequestOptions } = require("./proxy"); const OUT_FILE = process.env.OUT_FILE || "/usr/share/nginx/html/data.json"; const TZ = process.env.TZ || "UTC"; @@ -41,18 +42,33 @@ const GITLAB_TOKEN = process.env.GITLAB_TOKEN || ""; const OPENWEBUI_URL = process.env.OPENWEBUI_URL || "http://100.103.83.12:3000"; const OPENWEBUI_TOKEN = process.env.OPENWEBUI_TOKEN || ""; -function request(url, options = {}) { +// Central outbound request helper: honors the outbound proxy (GITLAB_PROXY) +// for the configured host — see api/proxy.js. +async function request(url, options = {}) { + const u = new URL(url); + const client = u.protocol === "https:" ? https : http; + const requestOptions = { + method: options.method || "GET", + headers: options.headers || {}, + timeout: options.timeout || 15000, + }; + + const proxy = proxyFor(url); + if (proxy) { + // https is tunneled through the proxy via CONNECT; plain http uses an + // absolute-form request URI addressed to the proxy. + let socket = null; + if (u.protocol === "https:") { + socket = await connectThroughProxy(proxy, url, requestOptions.timeout); + } + Object.assign(requestOptions, proxiedRequestOptions(proxy, url, socket)); + } + return new Promise((resolve, reject) => { const start = Date.now(); - const u = new URL(url); - const client = u.protocol === "https:" ? https : http; const req = client.request( u, - { - method: options.method || "GET", - headers: options.headers || {}, - timeout: options.timeout || 15000, - }, + requestOptions, (res) => { let body = ""; res.setEncoding("utf8"); diff --git a/api/update-health.js b/api/update-health.js index 06a7385..41d4987 100644 --- a/api/update-health.js +++ b/api/update-health.js @@ -15,6 +15,7 @@ const https = require("https"); const fs = require("fs"); const path = require("path"); const { URL } = require("url"); +const { proxyFor, connectThroughProxy, proxiedRequestOptions } = require("./proxy"); const OUT_FILE = process.env.OUT_FILE || "/usr/share/nginx/html/health.json"; const TIMEOUT = parseInt(process.env.HEALTH_TIMEOUT || "5000", 10); @@ -51,40 +52,56 @@ function checkUrl(url) { resolve(result); }; - let req; - try { - const u = new URL(url); - const client = u.protocol === "https:" ? https : http; - req = client.request( - u, - { + (async () => { + let req; + try { + const u = new URL(url); + const client = u.protocol === "https:" ? https : http; + const options = { method: "GET", - agent: u.protocol === "https:" ? httpsAgent : httpAgent, timeout: TIMEOUT, - }, - (res) => { - const code = res.statusCode; - res.destroy(); // response headers are all we need - done({ - status: code >= 500 ? "error" : "online", - code, - latencyMs: Date.now() - started, - error: null, - }); - } - ); - } catch (e) { - return done({ status: "offline", code: null, latencyMs: null, error: e.message }); - } + }; - req.on("timeout", () => { - req.destroy(); - done({ status: "offline", code: null, latencyMs: null, error: "Timeout" }); - }); - req.on("error", (e) => { - done({ status: "offline", code: null, latencyMs: null, error: e.message }); - }); - req.end(); + // Honor the outbound proxy (GITLAB_PROXY) so the GitLab badge checks + // the same path the git stats actually take (see api/proxy.js). + const proxy = proxyFor(url); + if (proxy) { + let socket = null; + if (u.protocol === "https:") { + socket = await connectThroughProxy(proxy, url, TIMEOUT, { rejectUnauthorized: false }); + } + Object.assign(options, proxiedRequestOptions(proxy, url, socket)); + } else { + options.agent = u.protocol === "https:" ? httpsAgent : httpAgent; + } + + req = client.request( + u, + options, + (res) => { + const code = res.statusCode; + res.destroy(); // response headers are all we need + done({ + status: code >= 500 ? "error" : "online", + code, + latencyMs: Date.now() - started, + error: null, + }); + } + ); + } catch (e) { + return done({ status: "offline", code: null, latencyMs: null, error: e.message }); + } + + req.on("timeout", () => { + req.destroy(); + done({ status: "offline", code: null, latencyMs: null, error: "Timeout" }); + }); + req.on("error", (e) => { + done({ status: "offline", code: null, latencyMs: null, error: e.message }); + }); + req.end(); + })(); }); } diff --git a/docker-compose.yml b/docker-compose.yml index ac3a1ee..98939a7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,6 +22,11 @@ services: # Uncomment and fill in to enable private git stats. # - GITLAB_TOKEN= # - NETBIRD_TOKEN= + # Forward proxy for GitLab API access + health checks (api/proxy.js). + # Set the URL in .env — change it there when the proxy stack moves + # (unset = direct connection). With the proxy on the docker host: + # GITLAB_PROXY=http://host.docker.internal:8118 + - GITLAB_PROXY=${GITLAB_PROXY:-} # GPU stats upstream; defaults to the gpu-stats service below. # To run gpu_stats.py on the host instead, use: # http://host.docker.internal:9101/stats @@ -29,6 +34,10 @@ services: # - GPU_STATS_URL=http://gpu-stats:9101/stats # Ollama instance for the loaded-model stats in the GPU widget. # - OLLAMA_URL=http://100.103.83.12:11435 + extra_hosts: + # Alias for the docker host, so container services can reach host + # processes (e.g. the proxy behind GITLAB_PROXY) by a stable name. + - "host.docker.internal:host-gateway" volumes: # Persists the Outlook OAuth refresh token across container updates. - outlook-tokens:/opt/dashboard/tokens