Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
37 changes: 35 additions & 2 deletions api/src/routes/health.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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. */
Expand All @@ -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) };
}
}

Expand Down
33 changes: 32 additions & 1 deletion api/test/docker-proxy-test.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}`)));
Expand All @@ -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 } = {}) {
Expand Down Expand Up @@ -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);
Expand Down
57 changes: 52 additions & 5 deletions docs/support.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion ui/admin/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,8 @@
<div class="row ie-row d-none" id="ie-socket"><span class="rl" data-i18n="general.socketUrl">Socket URL</span><span class="rv is-ph" id="ie-socket-v">http://socket-proxy:2375</span><button class="pe" type="button" aria-label="Edit socket URL"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M11 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-5"/><path d="M18.4 2.6a1.85 1.85 0 0 1 2.6 2.6l-9.1 9.1-3.4 1 1-3.4z"/></svg></button></div>
</div>
<p class="grp-tip" data-i18n="general.loggingTip">Errors shows warnings and errors only. Security events are always logged.</p>
<p class="grp-tip" id="socket-hint" style="display:none" data-i18n-html="general.socketTip">Use the URL of a <strong>socket proxy</strong> container for least-privilege access.</p>
<p class="grp-tip" id="socket-hint" style="display:none" data-i18n-html="general.socketTip">Use the URL of a <strong>socket proxy</strong> container, never the Docker socket itself. A name such as <code>http://socket-proxy:2375</code> 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.</p>
<p class="grp-tip" id="socket-hint-link" style="display:none"><a href="https://github.com/SandObserver/stackyard/blob/main/docs/support.md#every-app-with-a-container-shows-as-unhealthy" target="_blank" rel="noopener" data-i18n="about.support">Support</a></p>

<div class="grp-hdr" data-i18n="general.security">Security</div>
<div class="grp">
Expand Down
6 changes: 4 additions & 2 deletions ui/i18n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 <strong>Proxy Socket</strong>-Containers für einen Zugriff mit minimalen Rechten.",
"socketTip": "Verwenden Sie die URL eines <strong>Socket-Proxy</strong>-Containers, niemals den Docker-Socket selbst. Ein Name wie <code>http://socket-proxy:2375</code> 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",
Expand Down Expand Up @@ -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.",
Expand Down
6 changes: 4 additions & 2 deletions ui/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 <strong>socket proxy</strong> container for least-privilege access.",
"socketTip": "Use the URL of a <strong>socket proxy</strong> container, never the Docker socket itself. A name such as <code>http://socket-proxy:2375</code> 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",
Expand Down Expand Up @@ -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.",
Expand Down
6 changes: 4 additions & 2 deletions ui/i18n/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 <strong>Proxy Socket</strong> para un acceso con mínimo privilegio.",
"socketTip": "Usa la URL de un contenedor <strong>socket proxy</strong>, nunca el socket de Docker. Un nombre como <code>http://socket-proxy:2375</code> 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",
Expand Down Expand Up @@ -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.",
Expand Down
6 changes: 4 additions & 2 deletions ui/i18n/fa.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
"hideHealthy": "پنهان کردن وضعیت سلامت",
"socketUrl": "آدرس سوکت",
"loggingTip": "حالت خطاها فقط هشدارها و خطاها را نمایش می‌دهد. رویدادهای امنیتی همیشه ثبت می‌شوند.",
"socketTip": "برای دسترسی با کمترین امتیاز، از آدرس یک کانتینر <strong>Proxy Socket</strong> استفاده کنید.",
"socketTip": "از نشانی یک کانتینر <strong>پراکسی سوکت</strong> استفاده کنید، نه خود سوکت داکر. نامی مانند <code>http://socket-proxy:2375</code> تنها زمانی کار می‌کند که این کانتینر و پراکسی در یک شبکه داکر باشند؛ نشانی آی‌پی تنها زمانی کار می‌کند که پراکسی درگاه خود را فراتر از لوپ‌بک میزبان منتشر کند.",
"security": "امنیت",
"signOutEverywhereTip": "همه مرورگرها و دستگاه‌ها، از جمله همین دستگاه، خارج می‌شوند. اگر گمان می‌کنید نشستی باز مانده یا لو رفته است از آن استفاده کنید. گذرواژه شما تغییر نمی‌کند.",
"signOutEverywhereBtn": "خروج از همه",
Expand Down Expand Up @@ -178,7 +178,9 @@
"secretsWithheld": "اعتبارنامه را دوباره وارد کنید برای: {items}. نگه داشته نشد، چون درخواستِ مربوط به آن تغییر کرده است.",
"socketUrlMissing": "نشانی پراکسی سوکت را وارد کنید یا پایش داکر را خاموش کنید.",
"socketUrlBad": "پراکسی سوکت ذخیره نشد: {reason}",
"socketUrlUnverified": "ذخیره شد، اما پراکسی سوکت پاسخ نداد: {reason}"
"socketUrlUnverified": "ذخیره شد، اما پراکسی سوکت پاسخ نداد: {reason}",
"socketHintSharedNetwork": "این نام را داکر تنها برای کانتینرهایی که در یک شبکه مشترک هستند پاسخ می‌دهد. هر دو را در یک شبکه بگذارید یا از نشانی‌ای استفاده کنید که پراکسی منتشر می‌کند.",
"socketHintPublishPort": "هیچ پاسخی از آن نشانی نیامد. پراکسی سوکتی که روی لوپ‌بک میزبان منتشر شده از درون کانتینر در دسترس نیست؛ آن را روی نشانی پل داکر منتشر کنید یا هر دو کانتینر را در یک شبکه مشترک بگذارید و از نام پراکسی استفاده کنید."
},
"confirm": {
"deleteFolder": "پوشه «{name}» حذف شود؟ برنامه‌های داخل آن حذف نخواهند شد.",
Expand Down
Loading
Loading