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
38 changes: 38 additions & 0 deletions api/src/docker-api.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
const { fetchUnchecked } = require('./proxy');

/* Socket proxy address -> engine API version. */
const prefixes = new Map();
const MAX_PREFIXES = 8;

/* The engine sets Api-Version on every reply, a rejected version included. A
proxy's own refusal carries none. */
/** @param {string} base @param {object} opts @returns {Promise<string>} */
async function engineVersion(base, opts) {
const r = await fetchUnchecked(`${base}/v1.24/version`, { ...opts, withHeaders: true });
const v = String(r.headers?.['api-version'] || r.data?.ApiVersion || '');
return /^1\.\d{1,3}$/.test(v) ? v : '';
}

/** @param {string} socketUrl @param {string} path starts with "/"
@param {{ maxBytes?: number, timeout?: number }} [opts] */
async function dockerGet(socketUrl, path, opts = {}) {
const base = socketUrl.replace(/\/+$/, '');
const known = prefixes.get(base);
if (known) {
const r = await fetchUnchecked(`${base}/v${known}${path}`, opts);
if (r.status >= 400) prefixes.delete(base);
return r;
}
const r = await fetchUnchecked(`${base}${path}`, opts);
if (r.status !== 401 && r.status !== 403) return r;
const version = await engineVersion(base, opts);
if (!version) return r;
const retried = await fetchUnchecked(`${base}/v${version}${path}`, opts);
if (retried.status < 400) {
if (prefixes.size >= MAX_PREFIXES) prefixes.clear();
prefixes.set(base, version);
}
return retried;
}

module.exports = { dockerGet };
26 changes: 26 additions & 0 deletions api/src/docker-labels.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/* Labels can carry widget API keys. Only these fields leave the server. */
const FIELDS = Object.freeze(['name', 'group', 'href', 'icon', 'description', 'siteMonitor', 'ping', 'weight']);

/** One entry per container that carries a `homepage.` label, in list order.
@param {unknown} list the body of GET /containers/json
@returns {Array<{ container: string, labels: Record<string, string>, widget: boolean }>} */
function homepageLabels(list) {
const out = [];
for (const c of Array.isArray(list) ? list : []) {
const all = c && typeof c.Labels === 'object' && c.Labels ? c.Labels : {};
const keys = Object.keys(all).filter(k => k.startsWith('homepage.'));
if (!keys.length) continue;
const name = Array.isArray(c.Names) && typeof c.Names[0] === 'string' ? c.Names[0].replace(/^\//, '') : '';
/** @type {Record<string, string>} */
const labels = {};
for (const f of FIELDS) {
const v = all[`homepage.${f}`];
if (typeof v === 'string') labels[f] = v;
}
const widget = keys.some(k => /^homepage\.widgets?[.[]/.test(k));
out.push({ container: name, labels, widget });
}
return out;
}

module.exports = { homepageLabels };
41 changes: 37 additions & 4 deletions api/src/proxy.js
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,10 @@ const isPrivateAddress = addr => inAddressSet(addr, _blockedV4, BLOCKED_IPV6_RE)

/** Loopback, unspecified or link-local. @param {string} addr @returns {boolean} */
const isSelfAddress = addr => inAddressSet(addr, _selfV4, SELF_IPV6_RE);

const _linkLocalV4 = toRanges(BLOCKED_IPV4.filter(([base]) => base === '169.254.0.0'));
/** @param {string} addr @returns {boolean} */
const isLinkLocal = addr => inAddressSet(addr, _linkLocalV4, /^fe[89ab][0-9a-f]:/i);
/* Never sniff this from the body alone. A plain-text line like "Version 1.2"
matches the metric grammar exactly. */
function looksLikeMetrics(ct, body) {
Expand Down Expand Up @@ -582,7 +586,7 @@ const PING_TIMED_OUT = Object.freeze({
code: 'timeout.no-answer',
});

function pingUrl(raw, ms = PING_MS, skipTls, pinIp) {
function pingUrl(raw, ms = PING_MS, skipTls, pinIp, lookup) {
if (IS_DEMO) {
return Promise.resolve({
ok: false,
Expand Down Expand Up @@ -611,7 +615,7 @@ function pingUrl(raw, ms = PING_MS, skipTls, pinIp) {
port,
path: u.pathname || '/',
timeout: ms,
lookup: resolver.lookupWithin(ms),
lookup: lookup || resolver.lookupWithin(ms),
rejectUnauthorized: !skip,
};
if (pin) {
Expand Down Expand Up @@ -709,8 +713,37 @@ function jsonRpcUnchecked(url, opts, fn) {
return jsonRpc(rewriteUrl(url), opts, fn);
}

function pingUnchecked(url, ms, skipTls) {
return pingUrl(rewriteUrl(url), ms, skipTls);
const PING_LINK_LOCAL = Object.freeze({
ok: false,
status: 0,
error: 'Blocked: link-local address.',
kind: 'blocked',
code: 'blocked.link-local',
});

/* Keep the link-local refusal. 169.254.169.254 is the cloud metadata service. */
async function pingUnchecked(url, ms, skipTls) {
if (IS_DEMO) return pingUrl(url, ms, skipTls);
const target = rewriteUrl(url);
let host;
try {
host = bareHost(new URL(target).hostname);
} catch {
return pingUrl(target, ms, skipTls);
}
if (net.isIP(host)) return isLinkLocal(host) ? { ...PING_LINK_LOCAL } : pingUrl(target, ms, skipTls);
const budget = ms || PING_MS;
const started = Date.now();
let found;
try {
found = await resolver.resolveAll(host, budget);
} catch (e) {
if (e instanceof Error && e.message === 'Timed out') return { ...PING_TIMED_OUT };
found = e instanceof Error ? e : new Error(String(e));
}
if (Array.isArray(found) && found.some(a => isLinkLocal(a.address))) return { ...PING_LINK_LOCAL };
/* Connect only to the addresses checked above. A second lookup is never checked. */
return pingUrl(target, Math.max(1, budget - (Date.now() - started)), skipTls, null, resolver.lookupFrom(found));
}

async function pingChecked(url, ms, skipTls) {
Expand Down
27 changes: 17 additions & 10 deletions api/src/resolve.js
Original file line number Diff line number Diff line change
Expand Up @@ -92,22 +92,29 @@ async function resolveHost(host, ms) {
return (await resolveAll(host, ms))[0];
}

/** A `lookup` option for http.request that answers from `found` and never asks DNS.
@param {Address[] | Error} found */
function lookupFrom(found) {
return (host, options, callback) => {
const cb = typeof options === 'function' ? options : callback;
const o = typeof options === 'object' && options ? options : {};
if (found instanceof Error) return cb(found);
const wanted = o.family === 4 || o.family === 6 ? found.filter(a => a.family === o.family) : found;
if (!wanted.length) return cb(failed('ENOTFOUND', host));
if (o.all) return cb(null, wanted);
cb(null, wanted[0].address, wanted[0].family);
};
}

/** A `lookup` option for http.request, bounded by `ms`.
@param {number} ms */
function lookupWithin(ms) {
return (host, options, callback) => {
const cb = typeof options === 'function' ? options : callback;
const o = typeof options === 'object' && options ? options : {};
resolveAll(host, ms).then(
list => {
const wanted = o.family === 4 || o.family === 6 ? list.filter(a => a.family === o.family) : list;
if (!wanted.length) return cb(failed('ENOTFOUND', host));
if (o.all) return cb(null, wanted);
cb(null, wanted[0].address, wanted[0].family);
},
e => cb(e),
list => lookupFrom(list)(host, options, callback),
e => lookupFrom(e)(host, options, callback),
);
};
}

module.exports = { resolveAll, resolveHost, lookupWithin, fromHosts };
module.exports = { resolveAll, resolveHost, lookupWithin, lookupFrom, fromHosts };
3 changes: 2 additions & 1 deletion api/src/routes/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ const { fail, KIND } = require('../api-error');
/* Shared with the browser rather than copied. The Dockerfile places this file
where the same relative path resolves inside the image. */
const { firstUnsafeLink } = require('../../../ui/js/link-url.js');
const { DOCK_MAX } = require('../../../ui/js/limits.js');
const { DOCK_MAX, clampName } = require('../../../ui/js/limits.js');
const { scrubAllSecrets, preserveAllSecrets } = require('../config-secrets');
const { rewriteChanged } = require('../secret-scope');
const { firstMalformedRow } = require('../badge-headers');
Expand Down Expand Up @@ -137,6 +137,7 @@ on('POST', '/api/config', async (req, res) => {
});
}
}
for (const item of data.items) if (typeof item.label === 'string') item.label = clampName(item.label);
if (data.items.filter(i => i.type === 'app' && i.dock).length > DOCK_MAX)
return json(res, 400, {
error: `at most ${DOCK_MAX} apps can be shown in the dock`,
Expand Down
53 changes: 50 additions & 3 deletions api/src/routes/health.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ const { KIND } = require('../api-error');
const LIMITS = require('../poll-limits');
const { loadConfig } = require('../config');
const {
fetchUnchecked,
pingUnchecked,
urlPolicyError,
pingErrorText,
Expand All @@ -20,10 +19,13 @@ const { IS_DEMO } = require('../demo');
const demoData = require('../demo-data');
const log = require('../log');
const proxmox = require('../proxmox');
const { homepageLabels } = require('../docker-labels');
const { dockerGet } = require('../docker-api');
const { RETYPE_MESSAGE } = require('../secret-scope');
const { errorBody } = require('../api-error');
const SOCKET_PROXY_URL_DEFAULT = process.env.SOCKET_PROXY_URL || '';
const CONTAINERS_KEEP_MS = 2 * 60 * 1000;
const LABELS_MAX_BYTES = 16 * 1024 * 1024;

/** @param {any} item */
function guestOf(item) {
Expand All @@ -39,7 +41,7 @@ function socketProxyUrl(cfg) {

async function fetchContainerHealth(socketUrl) {
try {
const r = await fetchUnchecked(`${socketUrl}/containers/json?all=true`);
const r = await dockerGet(socketUrl, '/containers/json?all=true');
if (!Array.isArray(r.data)) return Object.create(null);
/* Null prototype. Keys are container names, so on an ordinary object a
container called "constructor" matches an inherited value and one that
Expand Down Expand Up @@ -109,7 +111,7 @@ async function probeSocketProxy(url) {
const policy = urlPolicyError(u);
if (policy) return { ok: false, fatal: true, error: policy, code: 'invalid.url' };
try {
const r = await fetchUnchecked(`${url.replace(/\/+$/, '')}/version`, { timeout: PING_MS });
const r = await dockerGet(url, '/version', { timeout: PING_MS });
if (r.status === 401 || r.status === 403)
return { ok: false, fatal: true, error: 'The socket proxy refused the request.', code: 'upstream.refused' };
if (r.status >= 400)
Expand Down Expand Up @@ -150,6 +152,51 @@ on('POST', '/api/docker/test', async (req, res) => {
}
});

on('GET', '/api/docker/labels', async (req, res) => {
const limited = rateLimit(getIp(req), 'docker-labels', 10, 60_000);
if (limited) return json(res, 429, { error: limited, kind: KIND.BLOCKED, code: 'blocked.rate-limit' });
if (IS_DEMO)
return json(res, 403, {
error: 'Outbound requests are disabled in demo mode',
kind: KIND.BLOCKED,
code: 'blocked.demo',
});
const socketUrl = socketProxyUrl(loadConfig());
if (!socketUrl)
return json(res, 400, { error: 'No socket proxy is set.', kind: KIND.INVALID, code: 'invalid.socket-missing' });
try {
const r = await dockerGet(socketUrl, '/containers/json?all=true', { maxBytes: LABELS_MAX_BYTES });
if (r.status === 401 || r.status === 403)
return json(res, 502, {
error: 'The socket proxy refused the request.',
kind: KIND.UPSTREAM,
code: 'upstream.refused',
});
if (r.status >= 400)
return json(res, 502, {
error: `The socket proxy answered with HTTP ${r.status}.`,
kind: KIND.UPSTREAM,
code: 'upstream.status',
detail: { status: r.status },
});
if (!Array.isArray(r.data))
return json(res, 502, {
error: 'Something is listening there, but it is not a Docker socket proxy.',
kind: KIND.UPSTREAM,
code: 'upstream.not-docker',
});
json(res, 200, { containers: homepageLabels(r.data) });
} catch (e) {
if (e.responseTooLarge)
return json(res, 502, {
error: 'The container list is too large to read.',
kind: KIND.UPSTREAM,
code: 'upstream.too-many-containers',
});
json(res, 502, errorBody(e));
}
});

const MISSING = Object.freeze({
error: 'Enter the Proxmox address, token ID and secret.',
kind: KIND.INVALID,
Expand Down
44 changes: 44 additions & 0 deletions api/test/config-name-limit.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
const path = require('node:path');

const { tmpDir } = require('../test-support/tmp');
process.env.CONFIG_PATH = path.join(tmpDir('namelimit'), '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');
const { loadConfig, saveConfig } = require('../src/config');
const { NAME_MAX } = require('../../ui/js/limits.js');

let server, base;

before(async () => {
saveConfig({ items: [], settings: {} });
server = http.createServer(dispatch);
await new Promise(r => server.listen(0, '127.0.0.1', r));
base = `http://127.0.0.1:${server.address().port}`;
});
after(() => server.close());

test('a saved name is cut to the name limit', async () => {
const long = 'N'.repeat(2000);
const r = await fetch(`${base}/api/config`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Origin: base },
body: JSON.stringify({
items: [
{ id: 'a', type: 'app', label: long, href: 'http://a.lan' },
{ id: 'f', type: 'folder', label: long, children: ['a'] },
{ id: 'b', type: 'app', label: 'Short', href: 'http://b.lan' },
],
settings: {},
}),
});
assert.equal(r.status, 200);
const labels = loadConfig()
.items.filter(i => ['a', 'f', 'b'].includes(i.id))
.map(i => i.label);
assert.deepEqual(labels, ['N'.repeat(NAME_MAX), 'N'.repeat(NAME_MAX), 'Short']);
});
Loading
Loading