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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
2 changes: 2 additions & 0 deletions api/src/proxy.js
Original file line number Diff line number Diff line change
Expand Up @@ -507,6 +507,8 @@ module.exports = {
pingUnchecked,
SsrfBlockedError,
statusDesc,
pingErrorText,
errCode,
urlPolicyError,
ALLOWED_PROTOCOLS,
looksLikeMetrics,
Expand Down
59 changes: 57 additions & 2 deletions api/src/routes/health.js
Original file line number Diff line number Diff line change
@@ -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');
Expand Down Expand Up @@ -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) => {
Expand Down
126 changes: 126 additions & 0 deletions api/test/docker-proxy-test.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
17 changes: 17 additions & 0 deletions docs/support.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion ui/i18n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
5 changes: 4 additions & 1 deletion ui/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
5 changes: 4 additions & 1 deletion ui/i18n/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
5 changes: 4 additions & 1 deletion ui/i18n/fa.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}» حذف شود؟ برنامه‌های داخل آن حذف نخواهند شد.",
Expand Down
5 changes: 4 additions & 1 deletion ui/i18n/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
5 changes: 4 additions & 1 deletion ui/i18n/zh-Hans.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}”?其中的应用不会被删除。",
Expand Down
7 changes: 6 additions & 1 deletion ui/js/admin-app-form.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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];
Expand Down
Loading
Loading