diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cb74cea..bf17db0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,7 +37,8 @@ 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. + is refused with the reason and what to change about it, 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. diff --git a/api/src/routes/health.js b/api/src/routes/health.js index 36c97c23..cbf90151 100644 --- a/api/src/routes/health.js +++ b/api/src/routes/health.js @@ -3,7 +3,16 @@ const { rateLimit } = require('../auth'); const { KIND } = require('../api-error'); const LIMITS = require('../poll-limits'); const { loadConfig } = require('../config'); -const { fetchUnchecked, pingUnchecked, urlPolicyError, pingErrorText, errCode } = require('../proxy'); +const { + fetchUnchecked, + pingUnchecked, + urlPolicyError, + pingErrorText, + errCode, + bareHost, + isDockerServiceName, + isPrivateAddress, +} = require('../proxy'); const { PING_MS } = require('../timeouts'); const { IS_DEMO } = require('../demo'); const demoData = require('../demo-data'); @@ -56,6 +65,27 @@ const WRONG_ADDRESS_CODES = new Set([ 'EPROTO', ]); +/* The two address shapes fail for opposite reasons, and neither error text says + so on its own. A service name is resolved by Docker's own DNS, which answers + only for containers sharing a network. An IP reaches only what the proxy + published: a proxy bound to the host's loopback, as the usual compose does, + is unreachable from inside any container, at any address. + + The hint names which of the two applies. The UI translates it; the codes are + the contract between them. */ +const HINT = Object.freeze({ SHARED_NETWORK: 'shared-network', PUBLISH_PORT: 'publish-port' }); + +/* A packet to a port bound on another namespace's loopback is dropped rather + than refused, so this case arrives as a timeout. Left non-fatal it saved with + a warning, which is the exact silence this probe exists to end. A literal + address is also never the one that is still starting up under a name. */ +const isLiteralAddress = host => isPrivateAddress(host) || /^\d{1,3}(\.\d{1,3}){3}$/.test(host) || host.includes(':'); + +function addressHint(host) { + if (isDockerServiceName(host)) return HINT.SHARED_NETWORK; + return HINT.PUBLISH_PORT; +} + /* 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. */ @@ -80,7 +110,10 @@ async function probeSocketProxy(url) { 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) }; + const host = bareHost(u.hostname); + const code = errCode(e) ?? ''; + const fatal = WRONG_ADDRESS_CODES.has(code) || (!code && isLiteralAddress(host)); + return { ok: false, fatal, error: pingErrorText(e), hint: addressHint(host) }; } } diff --git a/api/test/docker-proxy-test.test.js b/api/test/docker-proxy-test.test.js index 3d2c01be..644edf93 100644 --- a/api/test/docker-proxy-test.test.js +++ b/api/test/docker-proxy-test.test.js @@ -20,7 +20,7 @@ const http = require('node:http'); require('../src/routes'); const { dispatch } = require('../src/router'); -let server, base, proxy, proxyBase, dead, deadBase; +let server, base, proxy, proxyBase, dead, deadBase, hang, hangPort; 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}`))); @@ -42,12 +42,18 @@ before(async () => { deadBase = await listen(dead); await close(dead); + /* Accepts the connection and never answers, which is how a dropped packet + looks to the prober: no error code, just the deadline. */ + hang = http.createServer(() => {}); + hangPort = new URL(await listen(hang)).port; + server = http.createServer(dispatch); base = await listen(server); }); after(async () => { await close(server); await close(proxy); + await close(hang); }); function probe(url, { origin = true } = {}) { @@ -114,6 +120,31 @@ test('a scheme that cannot be requested is refused before any connection', async assert.equal(r.body.fatal, true); }); +/* The two address shapes fail for opposite reasons and neither error text says + which, so the hint carries the shape and the UI turns it into advice. */ +test('a service name that fails is told to share a network', async () => { + const r = await probe('http://socket-proxy-nonexistent:2375'); + assert.equal(r.body.ok, false); + assert.equal(r.body.hint, 'shared-network'); +}); + +test('an address that fails is told to publish the port', async () => { + const r = await probe(deadBase); + assert.equal(r.body.ok, false); + assert.equal(r.body.hint, 'publish-port'); +}); + +/* The case this whole endpoint came from. A proxy published on the host's + loopback drops packets from inside a container rather than refusing them, so + it arrives as a timeout. Treated as merely slow, it saved with a warning and + left every app red with no explanation. */ +test('a literal address that never answers is refused, not merely warned about', async () => { + const r = await probe(`http://127.0.0.1:${hangPort}`); + assert.equal(r.body.ok, false); + assert.equal(r.body.fatal, true, 'a timeout on a literal address is not a proxy still starting'); + assert.equal(r.body.hint, 'publish-port'); +}); + test('a missing address is rejected', async () => { const r = await probe(''); assert.equal(r.status, 400); diff --git a/docs/support.md b/docs/support.md index ac9c6945..1ab702ee 100644 --- a/docs/support.md +++ b/docs/support.md @@ -111,11 +111,58 @@ Settings refuses an address that is plainly wrong when you save it, and names th 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`. +Two addresses that look interchangeable are not, and they fail for opposite +reasons. + +**A service name**, such as `http://socket-proxy:2375`, is resolved by Docker's +own DNS, which answers only for containers that share a network. Add the proxy's +network to the Stackyard service and the name resolves: + +```yaml +services: + stackyard: + networks: + - socket_proxy_network + +networks: + socket_proxy_network: + external: true + name: socket_proxy_network +``` + +A service on `network_mode: bridge` is on Docker's default bridge, which has no +name resolution at all, so no service name can work from there. + +**An IP address** reaches only what the proxy published. The usual socket proxy +compose publishes on the host's loopback: + +```yaml + ports: + - "127.0.0.1:2375:2375" +``` + +That port exists on the host and nowhere else. A container's own `127.0.0.1` is +not the host's, and the host's LAN address does not carry the port either, so no +IP reaches the proxy from inside a container. Because packets to it are dropped +rather than refused, this appears as a timeout rather than a connection error. + +To use an IP, republish the proxy's port on an interface containers can reach. +The Docker bridge address, usually `172.17.0.1`, reaches containers on that host +without exposing anything to the network: + +```yaml + ports: + - "172.17.0.1:2375:2375" +``` + +Publishing on `0.0.0.0` also works and is worth avoiding. Even with `POST=0` and +`EXEC=0`, it offers an unauthenticated read of every container, image, network +and log on that host to anyone who can reach the port. + +Reaching a proxy on a **different host** needs that port published on the host's +LAN address, with the exposure above. Running a socket proxy on each host and +pointing each Stackyard at its own is usually better, since container names on +one host mean nothing on another. ### A widget says "Not configured" or shows an error instead of data diff --git a/ui/admin/index.html b/ui/admin/index.html index a3e3c58e..2d89d8b2 100644 --- a/ui/admin/index.html +++ b/ui/admin/index.html @@ -93,7 +93,8 @@
Socket URLhttp://socket-proxy:2375

Errors shows warnings and errors only. Security events are always logged.

- + +
Security
diff --git a/ui/i18n/de.json b/ui/i18n/de.json index d1d1981e..a9d604f4 100644 --- a/ui/i18n/de.json +++ b/ui/i18n/de.json @@ -38,7 +38,7 @@ "hideHealthy": "Badge für gesunden Status ausblenden", "socketUrl": "Socket-URL", "loggingTip": "\"Fehler\" zeigt nur Warnungen und Fehler an. Sicherheitsereignisse werden immer protokolliert.", - "socketTip": "Verwende die URL eines Proxy Socket-Containers für einen Zugriff mit minimalen Rechten.", + "socketTip": "Verwenden Sie die URL eines Socket-Proxy-Containers, niemals den Docker-Socket selbst. Ein Name wie http://socket-proxy:2375 funktioniert nur, wenn dieser Container und der Proxy dasselbe Docker-Netzwerk teilen; eine IP-Adresse nur, wenn der Proxy seinen Port über das Loopback des Hosts hinaus veröffentlicht.", "security": "Sicherheit", "signOutEverywhereTip": "Meldet jeden Browser und jedes Gerät ab, auch dieses. Nützlich, wenn eine Sitzung offen geblieben oder kompromittiert sein könnte. Das Passwort bleibt unverändert.", "signOutEverywhereBtn": "Alle abmelden", @@ -178,7 +178,9 @@ "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}" + "socketUrlUnverified": "Gespeichert, aber der Socket-Proxy hat nicht geantwortet: {reason}", + "socketHintSharedNetwork": "Diesen Namen löst Docker nur für Container in einem gemeinsamen Netzwerk auf. Legen Sie beide in dasselbe Netzwerk oder verwenden Sie eine Adresse, die der Proxy veröffentlicht.", + "socketHintPublishPort": "Unter dieser Adresse hat nichts geantwortet. Ein auf dem Loopback des Hosts veröffentlichter Socket-Proxy ist aus einem Container nicht erreichbar; veröffentlichen Sie ihn auf der Docker-Bridge-Adresse oder legen Sie beide Container in ein gemeinsames Netzwerk und verwenden Sie den Namen des Proxys." }, "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 d37300e6..c3143485 100644 --- a/ui/i18n/en.json +++ b/ui/i18n/en.json @@ -38,7 +38,7 @@ "hideHealthy": "Hide Healthy Badge", "socketUrl": "Socket URL", "loggingTip": "Errors shows warnings and errors only. Security events are always logged.", - "socketTip": "Use the URL of a socket proxy container for least-privilege access.", + "socketTip": "Use the URL of a socket proxy container, never the Docker socket itself. A name such as http://socket-proxy:2375 works only when this container and the proxy share a Docker network; an IP address works only when the proxy publishes its port beyond the host’s own loopback.", "security": "Security", "signOutEverywhereTip": "Signs out every browser and device, including this one. Use it if you think a session may have been left open or compromised. Your password does not change.", "signOutEverywhereBtn": "Sign out all", @@ -178,7 +178,9 @@ "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}" + "socketUrlUnverified": "Saved, but the socket proxy did not answer: {reason}", + "socketHintSharedNetwork": "That name is resolved by Docker, which answers only for containers on a shared network. Put both on the same network, or use an address the proxy publishes.", + "socketHintPublishPort": "Nothing at that address answered. A socket proxy published on the host’s loopback cannot be reached from inside a container; publish it on the Docker bridge address, or put both containers on a shared network and use the proxy’s name." }, "confirm": { "deleteFolder": "Delete folder \"{name}\"? Apps inside will not be deleted.", diff --git a/ui/i18n/es.json b/ui/i18n/es.json index 7d4a997f..1addd4cb 100644 --- a/ui/i18n/es.json +++ b/ui/i18n/es.json @@ -38,7 +38,7 @@ "hideHealthy": "Ocultar insignia de estado saludable", "socketUrl": "URL del socket", "loggingTip": "\"Errores\" muestra solo advertencias y errores. Los eventos de seguridad siempre se registran.", - "socketTip": "Usa la URL de un contenedor Proxy Socket para un acceso con mínimo privilegio.", + "socketTip": "Usa la URL de un contenedor socket proxy, nunca el socket de Docker. Un nombre como http://socket-proxy:2375 solo funciona si este contenedor y el proxy comparten una red de Docker; una dirección IP solo funciona si el proxy publica su puerto más allá del bucle local del host.", "security": "Seguridad", "signOutEverywhereTip": "Cierra la sesión en todos los navegadores y dispositivos, incluido este. Útil si crees que una sesión quedó abierta o comprometida. Tu contraseña no cambia.", "signOutEverywhereBtn": "Cerrar todas", @@ -178,7 +178,9 @@ "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}" + "socketUrlUnverified": "Guardado, pero el proxy de socket no respondió: {reason}", + "socketHintSharedNetwork": "Ese nombre lo resuelve Docker, que solo responde para contenedores en una red compartida. Ponlos en la misma red o usa una dirección que el proxy publique.", + "socketHintPublishPort": "Nada respondió en esa dirección. Un socket proxy publicado en el bucle local del host no es accesible desde un contenedor; publícalo en la dirección del puente de Docker, o pon ambos contenedores en una red compartida y usa el nombre del proxy." }, "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 3e3cea70..31cb5386 100644 --- a/ui/i18n/fa.json +++ b/ui/i18n/fa.json @@ -38,7 +38,7 @@ "hideHealthy": "پنهان کردن وضعیت سلامت", "socketUrl": "آدرس سوکت", "loggingTip": "حالت خطاها فقط هشدارها و خطاها را نمایش می‌دهد. رویدادهای امنیتی همیشه ثبت می‌شوند.", - "socketTip": "برای دسترسی با کمترین امتیاز، از آدرس یک کانتینر Proxy Socket استفاده کنید.", + "socketTip": "از نشانی یک کانتینر پراکسی سوکت استفاده کنید، نه خود سوکت داکر. نامی مانند http://socket-proxy:2375 تنها زمانی کار می‌کند که این کانتینر و پراکسی در یک شبکه داکر باشند؛ نشانی آی‌پی تنها زمانی کار می‌کند که پراکسی درگاه خود را فراتر از لوپ‌بک میزبان منتشر کند.", "security": "امنیت", "signOutEverywhereTip": "همه مرورگرها و دستگاه‌ها، از جمله همین دستگاه، خارج می‌شوند. اگر گمان می‌کنید نشستی باز مانده یا لو رفته است از آن استفاده کنید. گذرواژه شما تغییر نمی‌کند.", "signOutEverywhereBtn": "خروج از همه", @@ -178,7 +178,9 @@ "secretsWithheld": "اعتبارنامه را دوباره وارد کنید برای: {items}. نگه داشته نشد، چون درخواستِ مربوط به آن تغییر کرده است.", "socketUrlMissing": "نشانی پراکسی سوکت را وارد کنید یا پایش داکر را خاموش کنید.", "socketUrlBad": "پراکسی سوکت ذخیره نشد: {reason}", - "socketUrlUnverified": "ذخیره شد، اما پراکسی سوکت پاسخ نداد: {reason}" + "socketUrlUnverified": "ذخیره شد، اما پراکسی سوکت پاسخ نداد: {reason}", + "socketHintSharedNetwork": "این نام را داکر تنها برای کانتینرهایی که در یک شبکه مشترک هستند پاسخ می‌دهد. هر دو را در یک شبکه بگذارید یا از نشانی‌ای استفاده کنید که پراکسی منتشر می‌کند.", + "socketHintPublishPort": "هیچ پاسخی از آن نشانی نیامد. پراکسی سوکتی که روی لوپ‌بک میزبان منتشر شده از درون کانتینر در دسترس نیست؛ آن را روی نشانی پل داکر منتشر کنید یا هر دو کانتینر را در یک شبکه مشترک بگذارید و از نام پراکسی استفاده کنید." }, "confirm": { "deleteFolder": "پوشه «{name}» حذف شود؟ برنامه‌های داخل آن حذف نخواهند شد.", diff --git a/ui/i18n/fr.json b/ui/i18n/fr.json index 0c1f535e..b8d77418 100644 --- a/ui/i18n/fr.json +++ b/ui/i18n/fr.json @@ -38,7 +38,7 @@ "hideHealthy": "Masquer le badge d'état sain", "socketUrl": "URL du socket", "loggingTip": "\"Erreurs\" n'affiche que les avertissements et les erreurs. Les événements de sécurité sont toujours enregistrés.", - "socketTip": "Utilisez l'URL d'un conteneur Proxy Socket pour un accès à privilèges minimaux.", + "socketTip": "Utilisez l’URL d’un conteneur socket proxy, jamais le socket Docker lui-même. Un nom tel que http://socket-proxy:2375 ne fonctionne que si ce conteneur et le proxy partagent un réseau Docker ; une adresse IP ne fonctionne que si le proxy publie son port au-delà de la boucle locale de l’hôte.", "security": "Sécurité", "signOutEverywhereTip": "Déconnecte tous les navigateurs et appareils, y compris celui-ci. Utile si une session a pu rester ouverte ou être compromise. Votre mot de passe ne change pas.", "signOutEverywhereBtn": "Tout déconnecter", @@ -178,7 +178,9 @@ "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}" + "socketUrlUnverified": "Enregistré, mais le proxy de socket n’a pas répondu : {reason}", + "socketHintSharedNetwork": "Ce nom est résolu par Docker, qui ne répond que pour des conteneurs partageant un réseau. Placez les deux sur le même réseau, ou utilisez une adresse publiée par le proxy.", + "socketHintPublishPort": "Rien n’a répondu à cette adresse. Un socket proxy publié sur la boucle locale de l’hôte est inaccessible depuis un conteneur ; publiez-le sur l’adresse du pont Docker, ou placez les deux conteneurs sur un réseau partagé et utilisez le nom du proxy." }, "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 63cbfa22..59b92c8e 100644 --- a/ui/i18n/zh-Hans.json +++ b/ui/i18n/zh-Hans.json @@ -38,7 +38,7 @@ "hideHealthy": "隐藏健康徽章", "socketUrl": "Socket 地址", "loggingTip": "“错误”级别仅显示警告和错误。安全事件始终会被记录。", - "socketTip": "为实现最小权限访问,请使用 Proxy Socket 容器的地址。", + "socketTip": "请使用 套接字代理容器的地址,而不是 Docker 套接字本身。像 http://socket-proxy:2375 这样的名称只有在本容器与代理位于同一个 Docker 网络时才有效;IP 地址只有在代理将端口发布到主机回环之外时才有效。", "security": "安全", "signOutEverywhereTip": "退出所有浏览器和设备,包括当前设备。如果怀疑某个会话未关闭或已泄露,可以使用此功能。密码不会更改。", "signOutEverywhereBtn": "全部退出", @@ -178,7 +178,9 @@ "secretsWithheld": "请重新输入以下项的凭据:{items}。凭据未被保留,因为它所属的请求已更改。", "socketUrlMissing": "请输入套接字代理地址,或关闭 Docker 监控。", "socketUrlBad": "未保存套接字代理:{reason}", - "socketUrlUnverified": "已保存,但套接字代理没有响应:{reason}" + "socketUrlUnverified": "已保存,但套接字代理没有响应:{reason}", + "socketHintSharedNetwork": "该名称由 Docker 解析,且只对处于同一网络的容器生效。请将两者放入同一网络,或改用代理已发布的地址。", + "socketHintPublishPort": "该地址没有任何响应。发布在主机回环上的套接字代理无法从容器内访问;请将其发布到 Docker 网桥地址,或把两个容器放入同一网络并使用代理名称。" }, "confirm": { "deleteFolder": "删除文件夹“{name}”?其中的应用不会被删除。", diff --git a/ui/js/admin-settings.js b/ui/js/admin-settings.js index 202b2f07..150b9ef1 100644 --- a/ui/js/admin-settings.js +++ b/ui/js/admin-settings.js @@ -9,6 +9,14 @@ import { el, inp, q, qa, setUserText } from '/js/utils.js?v=84d58686'; it. */ let _passwordSet = false; +/* Hint codes from the socket proxy probe, mapped to what the user can do about + each. The server picks the code from the address shape; the wording lives + here so it is translated. */ +const SOCKET_HINTS = Object.freeze({ + 'shared-network': 'toast.socketHintSharedNetwork', + 'publish-port': 'toast.socketHintPublishPort', +}); + /* Both controls only mean anything while auth is on; the revoke one also needs a password to exist, since that is what makes a session possible. @@ -171,6 +179,8 @@ export function loadSettings(c) { if (socketRow) socketRow.classList.toggle('d-none', !v); const socketHint = el('socket-hint'); if (socketHint) socketHint.style.display = v ? '' : 'none'; + const socketHintLink = el('socket-hint-link'); + if (socketHintLink) socketHintLink.style.display = v ? '' : 'none'; }; applyDocker(dockerEnEl.checked); dockerEnEl.addEventListener('change', () => applyDocker(dockerEnEl.checked)); @@ -323,14 +333,19 @@ async function saveServer() { toast(t('toast.saveFailed', { err: e.message }), 'err'); return; } + /* The error says what happened; the hint says which of the two address + shapes it was and what makes that shape work. Neither is much use alone: + "connection refused" for an IP is exactly what a proxy published on the + host's loopback looks like from inside a container. */ + const hint = SOCKET_HINTS[probe.hint] ? ` ${t(SOCKET_HINTS[probe.hint])}` : ''; if (!probe.ok && probe.fatal) { - toast(t('toast.socketUrlBad', { reason: probe.error }), 'err'); + toast(t('toast.socketUrlBad', { reason: probe.error }) + hint, '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 }); + if (!probe.ok) socketWarning = t('toast.socketUrlUnverified', { reason: probe.error }) + hint; } /* Switching protection off deletes the stored password, so this is the moment