diff --git a/CHANGELOG.md b/CHANGELOG.md index 940e0575..3cb74cea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- The Docker socket proxy address is checked when it is saved, and a wrong one + is refused with the reason instead of being stored. + +- The icon preview in the app editor keeps its inset and no longer reverts to + the initial letter while an icon name is being typed. + - Exporting the config now reports a failure instead of saving an error response as `stackyard-config.json`. diff --git a/api/src/proxy.js b/api/src/proxy.js index 8cf5a8ed..e21cc3e8 100644 --- a/api/src/proxy.js +++ b/api/src/proxy.js @@ -507,6 +507,8 @@ module.exports = { pingUnchecked, SsrfBlockedError, statusDesc, + pingErrorText, + errCode, urlPolicyError, ALLOWED_PROTOCOLS, looksLikeMetrics, diff --git a/api/src/routes/health.js b/api/src/routes/health.js index 1dfde100..36c97c23 100644 --- a/api/src/routes/health.js +++ b/api/src/routes/health.js @@ -1,9 +1,9 @@ -const { on, json, getIp } = require('../router'); +const { on, json, getIp, readBody, checkOrigin } = require('../router'); const { rateLimit } = require('../auth'); const { KIND } = require('../api-error'); const LIMITS = require('../poll-limits'); const { loadConfig } = require('../config'); -const { fetchUnchecked, pingUnchecked } = require('../proxy'); +const { fetchUnchecked, pingUnchecked, urlPolicyError, pingErrorText, errCode } = require('../proxy'); const { PING_MS } = require('../timeouts'); const { IS_DEMO } = require('../demo'); const demoData = require('../demo-data'); @@ -43,6 +43,61 @@ async function fetchContainerHealth() { } } +/* These codes mean the address itself is wrong: nothing is listening, or the + name resolves nowhere. A timeout is not among them, because a proxy that is + still starting produces one, and refusing that save would lock the field on + an address that is about to work. */ +const WRONG_ADDRESS_CODES = new Set([ + 'ECONNREFUSED', + 'ENOTFOUND', + 'EAI_AGAIN', + 'EHOSTUNREACH', + 'ENETUNREACH', + 'EPROTO', +]); + +/* A Docker API answers /version with an ApiVersion. Anything else on that port + is some other service, which would otherwise be stored as a working address + and report every container as down. */ +async function probeSocketProxy(url) { + /* Outbound requests are disabled in the demo, so every address would fail the + probe and no Docker setting could be saved there at all. */ + if (IS_DEMO) return { ok: true }; + let u; + try { + u = new URL(url); + } catch { + return { ok: false, fatal: true, error: 'That is not a valid URL.' }; + } + const policy = urlPolicyError(u); + if (policy) return { ok: false, fatal: true, error: policy }; + try { + const r = await fetchUnchecked(`${url.replace(/\/+$/, '')}/version`, { timeout: PING_MS }); + if (r.status === 401 || r.status === 403) + return { ok: false, fatal: true, error: 'The socket proxy refused the request.' }; + if (r.status >= 400) return { ok: false, fatal: true, error: `The address answered with HTTP ${r.status}.` }; + if (!r.data || typeof r.data !== 'object' || !r.data.ApiVersion) + return { ok: false, fatal: true, error: 'Something is listening there, but it is not a Docker socket proxy.' }; + return { ok: true, version: String(r.data.ApiVersion) }; + } catch (e) { + return { ok: false, fatal: WRONG_ADDRESS_CODES.has(errCode(e) ?? ''), error: pingErrorText(e) }; + } +} + +on('POST', '/api/docker/test', async (req, res) => { + if (!checkOrigin(req, res)) return; + const limited = rateLimit(getIp(req), 'docker-test', 20, 60_000); + if (limited) return json(res, 429, { ok: false, error: limited, kind: KIND.BLOCKED }); + try { + const { url } = JSON.parse(await readBody(req)); + if (!url || typeof url !== 'string') + return json(res, 400, { ok: false, error: 'url required', kind: KIND.INVALID }); + json(res, 200, await probeSocketProxy(url.trim())); + } catch { + json(res, 400, { ok: false, error: 'url required', kind: KIND.INVALID }); + } +}); + on('GET', '/health', (_, res) => json(res, 200, { ok: true })); on('GET', '/api/health', async (req, res) => { diff --git a/api/test/docker-proxy-test.test.js b/api/test/docker-proxy-test.test.js new file mode 100644 index 00000000..3d2c01be --- /dev/null +++ b/api/test/docker-proxy-test.test.js @@ -0,0 +1,126 @@ +/* The Docker socket proxy address was stored exactly as typed, with nothing + ever contacting it. A wrong address then looked configured while every app + backed by a container reported down at once, because a container that cannot + be found is treated as not running, and the only trace of the real cause was + a line in the container log. + + /api/docker/test answers whether the address is usable, and separates an + address that is wrong from one that is merely not answering yet. Only the + first refuses the save; a proxy still starting must not lock the field. */ + +const path = require('node:path'); + +const { tmpDir } = require('../test-support/tmp'); +process.env.CONFIG_PATH = path.join(tmpDir('docker-test'), 'apps.json'); + +const { test, before, after } = require('node:test'); +const assert = require('node:assert/strict'); +const http = require('node:http'); + +require('../src/routes'); +const { dispatch } = require('../src/router'); + +let server, base, proxy, proxyBase, dead, deadBase; +let reply = { status: 200, body: JSON.stringify({ ApiVersion: '1.43' }) }; + +const listen = s => new Promise(r => s.listen(0, '127.0.0.1', () => r(`http://127.0.0.1:${s.address().port}`))); +const close = s => + new Promise(r => { + s.closeAllConnections?.(); + s.close(r); + }); + +before(async () => { + proxy = http.createServer((_, res) => { + res.writeHead(reply.status, { 'Content-Type': 'application/json' }); + res.end(reply.body); + }); + proxyBase = await listen(proxy); + + /* Bound only to learn a port nothing is listening on, then closed. */ + dead = http.createServer(() => {}); + deadBase = await listen(dead); + await close(dead); + + server = http.createServer(dispatch); + base = await listen(server); +}); +after(async () => { + await close(server); + await close(proxy); +}); + +function probe(url, { origin = true } = {}) { + const u = new URL(base + '/api/docker/test'); + const body = JSON.stringify({ url }); + const headers = { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }; + if (origin) headers.Origin = base; + return new Promise((resolve, reject) => { + const req = http.request({ hostname: u.hostname, port: u.port, path: u.pathname, method: 'POST', headers }, res => { + let b = ''; + res.on('data', c => { + b += c; + }); + res.on('end', () => resolve({ status: res.statusCode, body: JSON.parse(b) })); + }); + req.on('error', reject); + req.end(body); + }); +} + +test('a Docker socket proxy is accepted', async () => { + reply = { status: 200, body: JSON.stringify({ ApiVersion: '1.43' }) }; + const r = await probe(proxyBase); + assert.equal(r.body.ok, true); + assert.equal(r.body.version, '1.43'); +}); + +test('a trailing slash on the address is not fatal', async () => { + reply = { status: 200, body: JSON.stringify({ ApiVersion: '1.43' }) }; + assert.equal((await probe(`${proxyBase}/`)).body.ok, true); +}); + +/* The case that produced the whole report: something answers, so a plain + reachability check would pass, but it is not a Docker API. */ +test('another service on that port is refused', async () => { + reply = { status: 200, body: JSON.stringify({ hello: 'world' }) }; + const r = await probe(proxyBase); + assert.equal(r.body.ok, false); + assert.equal(r.body.fatal, true); +}); + +test('a proxy that refuses the request is refused', async () => { + reply = { status: 403, body: '{}' }; + const r = await probe(proxyBase); + assert.equal(r.body.ok, false); + assert.equal(r.body.fatal, true); +}); + +test('an address with nothing listening is refused', async () => { + const r = await probe(deadBase); + assert.equal(r.body.ok, false); + assert.equal(r.body.fatal, true, 'a refused connection means the address is wrong'); +}); + +test('a name that resolves nowhere is refused', async () => { + const r = await probe('http://no-such-host.invalid:2375'); + assert.equal(r.body.ok, false); + assert.equal(r.body.fatal, true); +}); + +test('a scheme that cannot be requested is refused before any connection', async () => { + const r = await probe('tcp://socket-proxy:2375'); + assert.equal(r.body.ok, false); + assert.equal(r.body.fatal, true); +}); + +test('a missing address is rejected', async () => { + const r = await probe(''); + assert.equal(r.status, 400); + assert.equal(r.body.ok, false); +}); + +test('the probe needs an origin, like every other request that acts', async () => { + const r = await probe(proxyBase, { origin: false }); + assert.equal(r.status, 403); +}); diff --git a/docker-compose.yml b/docker-compose.yml index c32884ea..48160d9f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,7 +23,7 @@ services: pids_limit: 256 ports: - "8700:80" - # Allows the container to reach services on the host by IP (e.g. http://10.0.0.100:8989). + # Allows the container to reach services on the host by IP (e.g. http://192.168.1.100:8989). # On Linux, containers cannot reach the host's LAN IP without this. # host-gateway is a Docker built-in that resolves to the host machine's IP. extra_hosts: diff --git a/docs/support.md b/docs/support.md index f188d513..ac9c6945 100644 --- a/docs/support.md +++ b/docs/support.md @@ -100,6 +100,23 @@ Most homelab services live on private IPs, so set `ALLOW_PRIVATE_IPS=true`. Docker service names (hostnames with no dot, such as `adguard`) are trusted and are not blocked, so linking to containers on the same network works without it. +### Every app with a container shows as unhealthy + +The container list comes from a Docker socket proxy, which is a separate +container you run yourself. Stackyard only stores its address. When that address +cannot be reached, no container is found, and a container that cannot be found +counts as not running, so every app checked by container name turns red at once. + +Settings refuses an address that is plainly wrong when you save it, and names the +reason. An address that is accepted but stops working later shows as +`container health fetch failed` in `docker logs`. + +Two addresses that look interchangeable are not. A service name such as +`http://socket-proxy:2375` only resolves for containers on the same Docker +network, so it works from a Stackyard deployed beside the proxy and from nowhere +else. Reaching the proxy from another host needs its port published on that host +and the address given as an IP, such as `http://192.168.1.100:2375`. + ### A widget says "Not configured" or shows an error instead of data - Confirm the server URL and credentials in the Settings app. Secret fields show diff --git a/ui/i18n/de.json b/ui/i18n/de.json index e290e79c..d1d1981e 100644 --- a/ui/i18n/de.json +++ b/ui/i18n/de.json @@ -175,7 +175,10 @@ "importForeignNothing": "Aus diesen Dateien gibt es nichts zu importieren.", "importYamlUnsupported": "{file}: {reason} (Zeile {line})", "importUnknownFormat": "{file} ist keine gethomepage- oder Dashy-Konfiguration.", - "secretsWithheld": "Zugangsdaten erneut eingeben für: {items}. Sie wurden nicht übernommen, weil sich die zugehörige Anfrage geändert hat." + "secretsWithheld": "Zugangsdaten erneut eingeben für: {items}. Sie wurden nicht übernommen, weil sich die zugehörige Anfrage geändert hat.", + "socketUrlMissing": "Adresse des Socket-Proxys eingeben oder Docker-Überwachung ausschalten.", + "socketUrlBad": "Socket-Proxy nicht gespeichert: {reason}", + "socketUrlUnverified": "Gespeichert, aber der Socket-Proxy hat nicht geantwortet: {reason}" }, "confirm": { "deleteFolder": "Ordner „{name}“ löschen? Enthaltene Apps werden nicht gelöscht.", diff --git a/ui/i18n/en.json b/ui/i18n/en.json index 6db0ee84..d37300e6 100644 --- a/ui/i18n/en.json +++ b/ui/i18n/en.json @@ -175,7 +175,10 @@ "importForeignNothing": "Nothing to import from those files.", "importYamlUnsupported": "{file}: {reason} (line {line})", "importUnknownFormat": "{file} is not a gethomepage or Dashy config.", - "secretsWithheld": "Enter the credential again for: {items}. It was not kept, because the request it belongs to changed." + "secretsWithheld": "Enter the credential again for: {items}. It was not kept, because the request it belongs to changed.", + "socketUrlMissing": "Enter the socket proxy address, or turn Docker monitoring off.", + "socketUrlBad": "Socket proxy not saved: {reason}", + "socketUrlUnverified": "Saved, but the socket proxy did not answer: {reason}" }, "confirm": { "deleteFolder": "Delete folder \"{name}\"? Apps inside will not be deleted.", diff --git a/ui/i18n/es.json b/ui/i18n/es.json index c87c3073..7d4a997f 100644 --- a/ui/i18n/es.json +++ b/ui/i18n/es.json @@ -175,7 +175,10 @@ "importForeignNothing": "No hay nada que importar en esos archivos.", "importYamlUnsupported": "{file}: {reason} (línea {line})", "importUnknownFormat": "{file} no es una configuración de gethomepage ni de Dashy.", - "secretsWithheld": "Vuelve a introducir la credencial de: {items}. No se conservó porque cambió la petición a la que pertenece." + "secretsWithheld": "Vuelve a introducir la credencial de: {items}. No se conservó porque cambió la petición a la que pertenece.", + "socketUrlMissing": "Introduce la dirección del proxy de socket o desactiva la supervisión de Docker.", + "socketUrlBad": "Proxy de socket no guardado: {reason}", + "socketUrlUnverified": "Guardado, pero el proxy de socket no respondió: {reason}" }, "confirm": { "deleteFolder": "¿Eliminar la carpeta \"{name}\"? Las aplicaciones que contiene no se eliminarán.", diff --git a/ui/i18n/fa.json b/ui/i18n/fa.json index e2a3ea0f..3e3cea70 100644 --- a/ui/i18n/fa.json +++ b/ui/i18n/fa.json @@ -175,7 +175,10 @@ "importForeignNothing": "در این فایل‌ها چیزی برای درون‌ریزی نیست.", "importYamlUnsupported": "{file}: {reason} (خط {line})", "importUnknownFormat": "{file} پیکربندی gethomepage یا Dashy نیست.", - "secretsWithheld": "اعتبارنامه را دوباره وارد کنید برای: {items}. نگه داشته نشد، چون درخواستِ مربوط به آن تغییر کرده است." + "secretsWithheld": "اعتبارنامه را دوباره وارد کنید برای: {items}. نگه داشته نشد، چون درخواستِ مربوط به آن تغییر کرده است.", + "socketUrlMissing": "نشانی پراکسی سوکت را وارد کنید یا پایش داکر را خاموش کنید.", + "socketUrlBad": "پراکسی سوکت ذخیره نشد: {reason}", + "socketUrlUnverified": "ذخیره شد، اما پراکسی سوکت پاسخ نداد: {reason}" }, "confirm": { "deleteFolder": "پوشه «{name}» حذف شود؟ برنامه‌های داخل آن حذف نخواهند شد.", diff --git a/ui/i18n/fr.json b/ui/i18n/fr.json index 30f6ad7b..0c1f535e 100644 --- a/ui/i18n/fr.json +++ b/ui/i18n/fr.json @@ -175,7 +175,10 @@ "importForeignNothing": "Rien à importer dans ces fichiers.", "importYamlUnsupported": "{file} : {reason} (ligne {line})", "importUnknownFormat": "{file} n’est pas une configuration gethomepage ou Dashy.", - "secretsWithheld": "Saisissez à nouveau les identifiants pour : {items}. Ils n’ont pas été conservés, car la requête associée a changé." + "secretsWithheld": "Saisissez à nouveau les identifiants pour : {items}. Ils n’ont pas été conservés, car la requête associée a changé.", + "socketUrlMissing": "Saisissez l’adresse du proxy de socket ou désactivez la surveillance Docker.", + "socketUrlBad": "Proxy de socket non enregistré : {reason}", + "socketUrlUnverified": "Enregistré, mais le proxy de socket n’a pas répondu : {reason}" }, "confirm": { "deleteFolder": "Supprimer le dossier « {name} » ? Les applications qu'il contient ne seront pas supprimées.", diff --git a/ui/i18n/zh-Hans.json b/ui/i18n/zh-Hans.json index 8368de4f..63cbfa22 100644 --- a/ui/i18n/zh-Hans.json +++ b/ui/i18n/zh-Hans.json @@ -175,7 +175,10 @@ "importForeignNothing": "这些文件中没有可导入的内容。", "importYamlUnsupported": "{file}:{reason}(第 {line} 行)", "importUnknownFormat": "{file} 不是 gethomepage 或 Dashy 配置。", - "secretsWithheld": "请重新输入以下项的凭据:{items}。凭据未被保留,因为它所属的请求已更改。" + "secretsWithheld": "请重新输入以下项的凭据:{items}。凭据未被保留,因为它所属的请求已更改。", + "socketUrlMissing": "请输入套接字代理地址,或关闭 Docker 监控。", + "socketUrlBad": "未保存套接字代理:{reason}", + "socketUrlUnverified": "已保存,但套接字代理没有响应:{reason}" }, "confirm": { "deleteFolder": "删除文件夹“{name}”?其中的应用不会被删除。", diff --git a/ui/js/admin-app-form.js b/ui/js/admin-app-form.js index f2f36e0f..df2e2fa1 100644 --- a/ui/js/admin-app-form.js +++ b/ui/js/admin-app-form.js @@ -426,9 +426,14 @@ function setInitialGlyph(p) { s.textContent = (l[0] || '?').toUpperCase(); p.replaceChildren(s); } +/* Typing runs this per keystroke, so several attempts are in flight at once and + the half-typed ones fail last. Without the token, a stale failure replaced the + preview the finished name had already loaded. */ +let prevRun = 0; function updPrev() { const p = el('ipv'); if (!p) return; + const run = ++prevRun; p.style.background = rc(state.scol); if (!state.siurl) { setInitialGlyph(p); @@ -441,9 +446,9 @@ function updPrev() { } let step = 0; const img = document.createElement('img'); - img.style.cssText = 'width:30px;height:30px;object-fit:contain;'; img.alt = ''; img.onerror = () => { + if (run !== prevRun) return; step++; if (step < fallbacks.length) { img.src = fallbacks[step]; diff --git a/ui/js/admin-settings.js b/ui/js/admin-settings.js index 051134d3..202b2f07 100644 --- a/ui/js/admin-settings.js +++ b/ui/js/admin-settings.js @@ -289,6 +289,7 @@ async function saveWallpaper() { async function saveServer() { const pw = inp('sec-pw')?.value || ''; const enabled = inp('sec-en')?.checked || false; + let socketWarning = ''; /* Every rule that can refuse this save is asked before the first request. Checked afterwards, as they were, a refusal had already saved the title, @@ -306,6 +307,32 @@ async function saveServer() { return; } + /* Asked here, with the other refusals, so a wrong address never reaches the + config. Stored unchecked it looked configured while every app backed by a + container reported down, with the reason only in the container log. */ + if (inp('srv-docker-en')?.checked) { + const url = inp('srv-socket')?.value?.trim() || ''; + if (!url) { + toast(t('toast.socketUrlMissing'), 'err'); + return; + } + let probe; + try { + probe = await ap('/api/docker/test', { url }); + } catch (e) { + toast(t('toast.saveFailed', { err: e.message }), 'err'); + return; + } + if (!probe.ok && probe.fatal) { + toast(t('toast.socketUrlBad', { reason: probe.error }), 'err'); + return; + } + /* Not answering yet is not the same as wrong: a proxy still starting would + otherwise block a save that is correct. Reported once the save lands, so + it is not immediately replaced by the success toast. */ + if (!probe.ok) socketWarning = t('toast.socketUrlUnverified', { reason: probe.error }); + } + /* Switching protection off deletes the stored password, so this is the moment it really goes. Asked before anything is written, so answering no means the save never started rather than abandoning one that is half done. */ @@ -360,7 +387,7 @@ async function saveServer() { pwEl.value = ''; } } - toast(t('toast.saved')); + toast(socketWarning || t('toast.saved'), socketWarning ? 'err' : 'ok'); if (langChanged) { location.reload(); return; diff --git a/ui/test/icon-preview.test.mjs b/ui/test/icon-preview.test.mjs new file mode 100644 index 00000000..0260bc1c --- /dev/null +++ b/ui/test/icon-preview.test.mjs @@ -0,0 +1,37 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/* Two faults in the app editor's icon preview, both invisible until an icon is + actually chosen, because the markup rendered when the form opens is correct + and only the redraw was wrong. + + The tile is 34px and the app list's tile is 48px. The redraw carried the + list's 30px image size as an inline style, which in the smaller tile filled + it edge to edge and lost the inset every other icon on the dashboard has. + + The redraw also runs per keystroke, so several images load at once and the + half-typed names fail last. Each failed attempt reset the tile to the letter + placeholder, including attempts belonging to a name already typed past, so a + name like "adguard home" never appeared until the value had been saved and + redrawn once on its own. */ + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const read = p => fs.readFileSync(path.join(root, p), 'utf8'); +const form = read('js/admin-app-form.js'); +const css = read('css/admin.css'); + +test('the preview image is sized by the stylesheet, not by a fixed pixel value', () => { + const updPrev = form.slice(form.indexOf('function updPrev()')); + assert.doesNotMatch(updPrev, /width:\s*\d+px/, 'the redraw pins a pixel width, so the tile has no inset'); + assert.match(css, /\.icon-prev img\{width:62%;height:62%/, 'the proportional rule is gone'); +}); + +test('a stale attempt cannot overwrite the preview of a newer one', () => { + const updPrev = form.slice(form.indexOf('function updPrev()')); + const onerror = updPrev.slice(updPrev.indexOf('img.onerror')); + assert.match(onerror, /if \(run !== prevRun\) return;/, 'a late failure is not checked against the current attempt'); + assert.match(updPrev, /const run = \+\+prevRun;/, 'attempts are not numbered'); +});