mirror of
https://github.com/UNITRONIX/BetterDesk.git
synced 2026-10-26 06:19:53 +00:00
b6d968a355
Map source-build vs GHCR updates, panel vs Client API ports, and initiator_token_rejected diagnostics for reverse-proxy Docker installs (Discussion #452). Refs #452 Thanks: INSOLVE (Honorary); Marco Jakobs (@jacotec); MyNameisStitch (@MyNameisStitch); Redspin (@playerumpknow); muopc (@muopc)
257 lines
11 KiB
JavaScript
257 lines
11 KiB
JavaScript
'use strict';
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
const repoRoot = path.resolve(__dirname, '..', '..');
|
|
const dockerfiles = ['Dockerfile', 'Dockerfile.console', 'Dockerfile.server'];
|
|
|
|
function copySources(dockerfile) {
|
|
return fs.readFileSync(path.join(repoRoot, dockerfile), 'utf8')
|
|
.split(/\r?\n/)
|
|
.filter(line => /^\s*COPY\s/.test(line) && !line.includes('--from='))
|
|
.flatMap(line => {
|
|
const tokens = line.trim().split(/\s+/).slice(1);
|
|
return tokens.slice(0, -1);
|
|
});
|
|
}
|
|
|
|
function sourceExists(source) {
|
|
if (!/[?*[\]{}]/.test(source)) {
|
|
return fs.existsSync(path.join(repoRoot, source));
|
|
}
|
|
|
|
const wildcardIndex = source.search(/[?*[\]{}]/);
|
|
const directory = path.dirname(source.slice(0, wildcardIndex));
|
|
const prefix = path.basename(source.slice(0, wildcardIndex));
|
|
const absoluteDirectory = path.join(repoRoot, directory);
|
|
|
|
return fs.existsSync(absoluteDirectory)
|
|
&& fs.readdirSync(absoluteDirectory).some(entry => entry.startsWith(prefix));
|
|
}
|
|
|
|
describe('Docker build context contract', () => {
|
|
test.each(dockerfiles)('%s only copies sources present in the repository', dockerfile => {
|
|
const missing = copySources(dockerfile).filter(source => !sourceExists(source));
|
|
|
|
expect(missing).toEqual([]);
|
|
});
|
|
|
|
test.each(dockerfiles)('%s does not require withdrawn agent sources', dockerfile => {
|
|
const contents = fs.readFileSync(path.join(repoRoot, dockerfile), 'utf8');
|
|
|
|
expect(contents).not.toMatch(/betterdesk-(?:support-)?agent|agent-source/);
|
|
});
|
|
});
|
|
|
|
describe('Docker admin bootstrap contract', () => {
|
|
const composeFiles = [
|
|
'docker-compose.yml',
|
|
'docker-compose.single.yml',
|
|
'docker-compose.quick.yml',
|
|
'docker-compose.quick.single.yml',
|
|
'docker-compose.quick.macvlan.yml',
|
|
'docker-compose.quick.single.macvlan.yml',
|
|
];
|
|
|
|
test.each(composeFiles)('%s forwards all admin password aliases', composeFile => {
|
|
const contents = fs.readFileSync(path.join(repoRoot, composeFile), 'utf8');
|
|
|
|
expect(contents).toContain('ADMIN_PASSWORD=${ADMIN_PASSWORD:-}');
|
|
expect(contents).toContain('INIT_ADMIN_PASS=${ADMIN_PASSWORD:-}');
|
|
expect(contents).toContain('DEFAULT_ADMIN_PASSWORD=${ADMIN_PASSWORD:-}');
|
|
});
|
|
|
|
test('split Compose variants use the shared primary SQLite path', () => {
|
|
for (const composeFile of ['docker-compose.quick.yml', 'docker-compose.quick.macvlan.yml']) {
|
|
const contents = fs.readFileSync(path.join(repoRoot, composeFile), 'utf8');
|
|
|
|
expect(contents).toContain('DB_URL=/opt/rustdesk/db_v2.sqlite3');
|
|
expect(contents).toContain('DB_PATH=/opt/rustdesk/db_v2.sqlite3');
|
|
}
|
|
});
|
|
|
|
test('split server entrypoint normalizes the Go database path', () => {
|
|
const contents = fs.readFileSync(
|
|
path.join(repoRoot, 'docker', 'server-entrypoint.sh'),
|
|
'utf8'
|
|
);
|
|
|
|
expect(contents).toContain('export DB_URL="${DB_PATH:-${RUSTDESK_PATH:-$DATA_DIR}/db_v2.sqlite3}"');
|
|
expect(contents).toContain('export DB_URL="${DATABASE_URL:-${DB_PATH:-${RUSTDESK_PATH:-$DATA_DIR}/db_v2.sqlite3}}"');
|
|
});
|
|
|
|
test('hardened Docker checks app-owned paths as betterdesk', () => {
|
|
const bootstrap = fs.readFileSync(
|
|
path.join(repoRoot, 'docker', 'bootstrap-admin-credentials.sh'),
|
|
'utf8'
|
|
);
|
|
const entrypoint = fs.readFileSync(
|
|
path.join(repoRoot, 'docker', 'entrypoint.sh'),
|
|
'utf8'
|
|
);
|
|
const guard = fs.readFileSync(
|
|
path.join(repoRoot, 'docker', 'guard-sqlite-auth-split.sh'),
|
|
'utf8'
|
|
);
|
|
|
|
expect(bootstrap).toContain('file_exists_as_betterdesk()');
|
|
expect(bootstrap).toContain('path_exists_as_betterdesk()');
|
|
expect(bootstrap).toContain('file_exists_as_betterdesk "$_file"');
|
|
expect(bootstrap).toContain('file_exists_as_betterdesk "$_primary_db"');
|
|
expect(bootstrap).toContain('path_exists_as_betterdesk "$CREDS_FILE"');
|
|
expect(entrypoint).toContain('file_exists_as_betterdesk "$API_KEY_FILE"');
|
|
expect(entrypoint).toContain('file_exists_as_betterdesk "$ENROLLMENT_SENTINEL"');
|
|
// Guard is self-contained for unit tests, but still prefers the
|
|
// betterdesk-owned helper when bootstrap has already defined it.
|
|
expect(guard).toContain('_guard_file_exists()');
|
|
expect(guard).toContain('file_exists_as_betterdesk "$1"');
|
|
expect(guard).toContain('_guard_run_as_betterdesk test -f "$1"');
|
|
expect(guard).toContain('_guard_file_exists "$_auth_db"');
|
|
expect(guard).toContain('_guard_file_exists "$_primary_db"');
|
|
});
|
|
|
|
test('Docker entrypoints source path helpers before using them', () => {
|
|
for (const entrypoint of [
|
|
'docker/server-entrypoint.sh',
|
|
'docker/console-entrypoint.sh',
|
|
'docker/entrypoint.sh',
|
|
]) {
|
|
const contents = fs.readFileSync(path.join(repoRoot, entrypoint), 'utf8');
|
|
const bootstrapSource = contents.indexOf('. /docker/bootstrap-admin-credentials.sh');
|
|
const firstHelperUse = contents.indexOf('file_exists_as_betterdesk ');
|
|
|
|
expect(bootstrapSource).toBeGreaterThanOrEqual(0);
|
|
expect(firstHelperUse === -1 || firstHelperUse > bootstrapSource).toBe(true);
|
|
}
|
|
});
|
|
|
|
test('Docker entrypoints source bootstrap before the SQLite guard', () => {
|
|
for (const entrypoint of ['docker/console-entrypoint.sh', 'docker/entrypoint.sh']) {
|
|
const contents = fs.readFileSync(path.join(repoRoot, entrypoint), 'utf8');
|
|
const bootstrapSource = contents.indexOf('. /docker/bootstrap-admin-credentials.sh');
|
|
const guardSource = contents.indexOf('. /docker/guard-sqlite-auth-split.sh');
|
|
|
|
expect(bootstrapSource).toBeGreaterThanOrEqual(0);
|
|
expect(guardSource).toBeGreaterThan(bootstrapSource);
|
|
}
|
|
});
|
|
|
|
test('AIO supervisord admin variables are always exported by bootstrap', () => {
|
|
const bootstrap = fs.readFileSync(
|
|
path.join(repoRoot, 'docker', 'bootstrap-admin-credentials.sh'),
|
|
'utf8'
|
|
);
|
|
const supervisor = fs.readFileSync(
|
|
path.join(repoRoot, 'docker', 'supervisord.conf'),
|
|
'utf8'
|
|
);
|
|
|
|
expect(bootstrap).toContain('ensure_admin_exports()');
|
|
expect(bootstrap).toContain('export INIT_ADMIN_USER=');
|
|
expect(bootstrap).toContain('export INIT_ADMIN_PASS=');
|
|
expect(bootstrap).toContain('export DEFAULT_ADMIN_USERNAME=');
|
|
expect(bootstrap).toContain('export DEFAULT_ADMIN_PASSWORD=');
|
|
expect(supervisor).toContain('%(ENV_DEFAULT_ADMIN_USERNAME)s');
|
|
expect(supervisor).toContain('%(ENV_DEFAULT_ADMIN_PASSWORD)s');
|
|
expect(supervisor).toContain('%(ENV_INIT_ADMIN_USER)s');
|
|
expect(supervisor).toContain('%(ENV_INIT_ADMIN_PASS)s');
|
|
});
|
|
|
|
test.each([
|
|
'docker-compose.yml',
|
|
'docker-compose.single.yml',
|
|
'docker-compose.quick.yml',
|
|
'docker-compose.quick.single.yml',
|
|
'docker-compose.quick.macvlan.yml',
|
|
'docker-compose.quick.single.macvlan.yml',
|
|
])('%s exposes optional TRUST_PROXY configuration', composeFile => {
|
|
const contents = fs.readFileSync(path.join(repoRoot, composeFile), 'utf8');
|
|
|
|
expect(contents).toContain('TRUST_PROXY=${TRUST_PROXY:-}');
|
|
});
|
|
});
|
|
|
|
describe('Docker compose layout contract (Discussion #452)', () => {
|
|
const sourceBuildCompose = ['docker-compose.yml', 'docker-compose.single.yml'];
|
|
const ghcrQuickCompose = [
|
|
'docker-compose.quick.yml',
|
|
'docker-compose.quick.single.yml',
|
|
'docker-compose.quick.macvlan.yml',
|
|
'docker-compose.quick.single.macvlan.yml',
|
|
];
|
|
|
|
test.each(sourceBuildCompose)('%s is source-build (local tags, pull never)', composeFile => {
|
|
const contents = fs.readFileSync(path.join(repoRoot, composeFile), 'utf8');
|
|
|
|
expect(contents).toMatch(/pull_policy:\s*never/);
|
|
expect(contents).toMatch(/image:\s*betterdesk(?:-server|-console)?:local/);
|
|
expect(contents).toMatch(/BUILD FROM SOURCE|source build|builds images locally|pull_policy:\s*never/i);
|
|
expect(contents).toMatch(/docker compose up -d --build|up -d --build/);
|
|
// Must not tell operators that bare `docker compose pull` refreshes *:local
|
|
expect(contents).toMatch(/pull.*does NOT|pull_policy:\s*never|does nothing/i);
|
|
});
|
|
|
|
test.each(ghcrQuickCompose)('%s is prebuilt GHCR (pull refreshes images)', composeFile => {
|
|
const contents = fs.readFileSync(path.join(repoRoot, composeFile), 'utf8');
|
|
// Ignore comments — headers may mention source-build pull_policy: never for contrast.
|
|
const yamlBody = contents
|
|
.split(/\r?\n/)
|
|
.filter(line => !/^\s*#/.test(line))
|
|
.join('\n');
|
|
|
|
expect(contents).toMatch(/ghcr\.io\/unitronix\/betterdesk/);
|
|
expect(contents).toMatch(/docker compose pull/);
|
|
expect(yamlBody).not.toMatch(/pull_policy:\s*never/);
|
|
expect(yamlBody).not.toMatch(/image:\s*betterdesk(?:-server|-console)?:local/);
|
|
});
|
|
|
|
test('split source-build compose documents Client API vs panel ports', () => {
|
|
const contents = fs.readFileSync(path.join(repoRoot, 'docker-compose.yml'), 'utf8');
|
|
|
|
expect(contents).toContain('21114:21114');
|
|
expect(contents).toContain('21118:21118');
|
|
expect(contents).toContain('21119:21119');
|
|
expect(contents).toContain('5000:5000');
|
|
expect(contents).toContain('21121:21121');
|
|
expect(contents).toMatch(/:21114/);
|
|
expect(contents).toMatch(/Client API|Go Client API|Go HTTP API/);
|
|
expect(contents).toMatch(/web panel|Web Console|admin panel/i);
|
|
expect(contents).toMatch(/21118|WebSocket Signal/);
|
|
expect(contents).toMatch(/21119|WebSocket Relay/);
|
|
});
|
|
|
|
test('split GHCR compose documents preferred Client API on 21114', () => {
|
|
const contents = fs.readFileSync(path.join(repoRoot, 'docker-compose.quick.yml'), 'utf8');
|
|
|
|
expect(contents).toContain('21114:21114');
|
|
expect(contents).toContain('21118:21118');
|
|
expect(contents).toContain('21119:21119');
|
|
expect(contents).toMatch(/:21114/);
|
|
expect(contents).toMatch(/do NOT point RustDesk API Server|not the console port/i);
|
|
expect(contents).toContain('DB_URL=/opt/rustdesk/db_v2.sqlite3');
|
|
expect(contents).toContain('DB_PATH=/opt/rustdesk/db_v2.sqlite3');
|
|
expect(contents).toContain('PANEL_SIGNAL_PROXY_CIDRS');
|
|
});
|
|
|
|
test('single-container quick compose exposes Client API on 21121', () => {
|
|
const contents = fs.readFileSync(
|
|
path.join(repoRoot, 'docker-compose.quick.single.yml'),
|
|
'utf8'
|
|
);
|
|
|
|
expect(contents).toContain('21121:21121');
|
|
expect(contents).toContain('5000:5000');
|
|
expect(contents).toContain('21118:21118');
|
|
expect(contents).toContain('21119:21119');
|
|
expect(contents).toMatch(/HTTP API|Client API|RustDesk client/);
|
|
});
|
|
|
|
test('production defaults discourage open legacy outbound on source-build split', () => {
|
|
const contents = fs.readFileSync(path.join(repoRoot, 'docker-compose.yml'), 'utf8');
|
|
|
|
expect(contents).toContain('ENCRYPTED_ONLY=1');
|
|
expect(contents).toMatch(/ALLOW_LEGACY_OUTBOUND=\$\{ALLOW_LEGACY_OUTBOUND:-N\}/);
|
|
});
|
|
});
|