Compare commits

..

4 Commits

Author SHA1 Message Date
jkunz 7fe63541b3 fix: align delegate routing settings UI
Release / build-and-release (push) Successful in 2m44s
2026-05-08 19:32:40 +00:00
jkunz 201602b733 fix: use compiled-safe password hashing
Release / build-and-release (push) Successful in 2m34s
2026-05-08 16:36:58 +00:00
jkunz cc6a81012c fix: restore onebox daemon startup
Release / build-and-release (push) Successful in 2m28s
2026-05-08 16:23:45 +00:00
jkunz fba143d918 fix: update onebox installer credentials output
Release / build-and-release (push) Successful in 2m32s
2026-05-08 16:12:22 +00:00
13 changed files with 153 additions and 109 deletions
+27
View File
@@ -1,5 +1,32 @@
# Changelog # Changelog
## 2026-05-08 - 1.24.7 - fix(web-ui)
align Delegate Routing settings with the Dees catalog control and theme conventions
- replace raw Delegate Routing inputs and save button with `dees-input-text` and `dees-button`
- style the Delegate Routing card with explicit `cssManager.bdTheme(...)` colors
## 2026-05-08 - 1.24.6 - fix(auth)
avoid bcrypt worker crashes in compiled binaries during login and password creation
- replace bcrypt password hashing with a Web Crypto PBKDF2 hash format
- remove legacy password-hash fallbacks; existing deployments need their admin user hash updated
## 2026-05-08 - 1.24.5 - fix(opsserver)
start the OpsServer with typedserver custom routes registered through the UtilityWebsiteServer hook
- fixes daemon startup with the current typedserver lifecycle
- cap SmartProxy readiness waiting at 10 seconds during daemon startup
## 2026-05-08 - 1.24.4 - fix(installer)
avoid documenting a hardcoded initial admin password for fresh installs
- update installer output to point operators to the service logs or `ONEBOX_ADMIN_PASSWORD` for initial credentials
## 2026-05-08 - 1.24.3 - fix(runtime) ## 2026-05-08 - 1.24.3 - fix(runtime)
upgrade runtime dependencies and harden registry/shutdown behavior upgrade runtime dependencies and harden registry/shutdown behavior
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@serve.zone/onebox", "name": "@serve.zone/onebox",
"version": "1.24.3", "version": "1.24.7",
"exports": "./mod.ts", "exports": "./mod.ts",
"tasks": { "tasks": {
"test": "deno test --allow-all test/", "test": "deno test --allow-all test/",
+1 -1
View File
@@ -305,6 +305,6 @@ else
echo " onebox service add myapp --image nginx:latest --domain app.example.com" echo " onebox service add myapp --image nginx:latest --domain app.example.com"
echo "" echo ""
echo " Web UI: http://localhost:3000" echo " Web UI: http://localhost:3000"
echo " Default credentials: admin / admin" echo " Initial admin credentials are written to the service logs unless ONEBOX_ADMIN_PASSWORD is set."
fi fi
echo "" echo ""
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@serve.zone/onebox", "name": "@serve.zone/onebox",
"version": "1.24.3", "version": "1.24.7",
"description": "Self-hosted container platform with automatic SSL and DNS - a mini Heroku for single servers", "description": "Self-hosted container platform with automatic SSL and DNS - a mini Heroku for single servers",
"main": "mod.ts", "main": "mod.ts",
"type": "module", "type": "module",
+7 -12
View File
@@ -5,8 +5,7 @@ import type { IUser as IDatabaseUser } from '../ts/types.ts';
import { AdminHandler } from '../ts/opsserver/handlers/admin.handler.ts'; import { AdminHandler } from '../ts/opsserver/handlers/admin.handler.ts';
import { import {
hashPassword, hashPassword,
isBcryptHash, isPbkdf2Hash,
needsPasswordUpgrade,
verifyPassword, verifyPassword,
} from '../ts/utils/auth.ts'; } from '../ts/utils/auth.ts';
@@ -45,18 +44,14 @@ async function createAdminHandler(users: IDatabaseUser[]): Promise<AdminHandler>
return adminHandler; return adminHandler;
} }
Deno.test('password helpers support bcrypt and legacy password hashes', async () => { Deno.test('password helpers support PBKDF2 password hashes', async () => {
const password = 'correct horse battery staple'; const password = 'correct horse battery staple';
const bcryptHash = await hashPassword(password); const passwordHash = await hashPassword(password);
assert(isBcryptHash(bcryptHash)); assert(isPbkdf2Hash(passwordHash));
assert(await verifyPassword(password, bcryptHash)); assert(await verifyPassword(password, passwordHash));
assert(!(await verifyPassword('wrong password', bcryptHash))); assert(!(await verifyPassword('wrong password', passwordHash)));
assert(!needsPasswordUpgrade(bcryptHash)); assert(!(await verifyPassword(password, btoa(password))));
const legacyHash = btoa(password);
assert(await verifyPassword(password, legacyHash));
assert(needsPasswordUpgrade(legacyHash));
}); });
Deno.test('verified identity is derived from the signed JWT and database, not client fields', async () => { Deno.test('verified identity is derived from the signed JWT and database, not client fields', async () => {
+1 -1
View File
@@ -77,7 +77,7 @@ export class OneboxReverseProxy {
if (status.running) { if (status.running) {
logger.info(`HTTPS already running on port ${this.httpsPort} via SmartProxy`); logger.info(`HTTPS already running on port ${this.httpsPort} via SmartProxy`);
} else { } else {
await this.smartProxy.start(); logger.warn('Skipping HTTPS reverse proxy startup because SmartProxy is not running');
} }
} }
+1 -1
View File
@@ -217,7 +217,7 @@ export class SmartProxyManager {
return network.Id; return network.Id;
} }
private async waitForReady(maxAttempts = 120, intervalMs = 1000): Promise<void> { private async waitForReady(maxAttempts = 10, intervalMs = 1000): Promise<void> {
for (let i = 0; i < maxAttempts; i++) { for (let i = 0; i < maxAttempts; i++) {
try { try {
const response = await fetch(`${this.adminUrl}/ready`); const response = await fetch(`${this.adminUrl}/ready`);
+5 -5
View File
@@ -36,6 +36,7 @@ export class OpsServer {
domain: 'localhost', domain: 'localhost',
feedMetadata: undefined, feedMetadata: undefined,
bundledContent: bundledFiles, bundledContent: bundledFiles,
addCustomRoutes: async (typedserver) => this.registerCustomRoutes(typedserver),
}); });
// Chain typedrouters: server -> opsServer -> individual handlers // Chain typedrouters: server -> opsServer -> individual handlers
@@ -43,7 +44,6 @@ export class OpsServer {
// Set up all handlers // Set up all handlers
await this.setupHandlers(); await this.setupHandlers();
this.registerCustomRoutes();
await this.server.start(port); await this.server.start(port);
logger.success(`OpsServer started on http://localhost:${port}`); logger.success(`OpsServer started on http://localhost:${port}`);
@@ -73,18 +73,18 @@ export class OpsServer {
logger.success('OpsServer TypedRequest handlers initialized'); logger.success('OpsServer TypedRequest handlers initialized');
} }
private registerCustomRoutes(): void { private registerCustomRoutes(typedserver: plugins.typedserver.TypedServer): void {
this.server.typedserver.addRoute( typedserver.addRoute(
'/v2', '/v2',
'ALL', 'ALL',
async (ctx) => this.oneboxRef.registry.handleRequest(ctx.request), async (ctx) => this.oneboxRef.registry.handleRequest(ctx.request),
); );
this.server.typedserver.addRoute( typedserver.addRoute(
'/v2/*', '/v2/*',
'ALL', 'ALL',
async (ctx) => this.oneboxRef.registry.handleRequest(ctx.request), async (ctx) => this.oneboxRef.registry.handleRequest(ctx.request),
); );
this.server.typedserver.addRoute( typedserver.addRoute(
'/backups/:backupId/download', '/backups/:backupId/download',
'GET', 'GET',
async (ctx) => { async (ctx) => {
+1 -6
View File
@@ -2,7 +2,7 @@ import * as plugins from '../../plugins.ts';
import { logger } from '../../logging.ts'; import { logger } from '../../logging.ts';
import type { OpsServer } from '../classes.opsserver.ts'; import type { OpsServer } from '../classes.opsserver.ts';
import * as interfaces from '../../../ts_interfaces/index.ts'; import * as interfaces from '../../../ts_interfaces/index.ts';
import { hashPassword, needsPasswordUpgrade, verifyPassword } from '../../utils/auth.ts'; import { hashPassword, verifyPassword } from '../../utils/auth.ts';
export interface IJwtData { export interface IJwtData {
userId: string; userId: string;
@@ -112,11 +112,6 @@ export class AdminHandler {
throw new plugins.typedrequest.TypedResponseError('Invalid credentials'); throw new plugins.typedrequest.TypedResponseError('Invalid credentials');
} }
if (needsPasswordUpgrade(user.passwordHash)) {
const upgradedHash = await hashPassword(dataArg.password);
this.opsServerRef.oneboxRef.database.updateUserPassword(user.username, upgradedHash);
}
const expiresAt = Date.now() + 24 * 3600 * 1000; const expiresAt = Date.now() + 24 * 3600 * 1000;
const freshUser = this.opsServerRef.oneboxRef.database.getUserByUsername(user.username) || user; const freshUser = this.opsServerRef.oneboxRef.database.getUserByUsername(user.username) || user;
const identity = await this.createIdentityForUser(freshUser, expiresAt); const identity = await this.createIdentityForUser(freshUser, expiresAt);
-4
View File
@@ -55,10 +55,6 @@ export const awsS3 = {
import * as taskbuffer from '@push.rocks/taskbuffer'; import * as taskbuffer from '@push.rocks/taskbuffer';
export { taskbuffer }; export { taskbuffer };
// Crypto utilities (for password hashing, encryption)
import * as bcrypt from 'https://deno.land/x/bcrypt@v0.4.1/mod.ts';
export { bcrypt };
// JWT for authentication // JWT for authentication
import * as jwt from 'https://deno.land/x/djwt@v3.0.2/mod.ts'; import * as jwt from 'https://deno.land/x/djwt@v3.0.2/mod.ts';
export { jwt}; export { jwt};
+78 -12
View File
@@ -1,17 +1,79 @@
import * as plugins from '../plugins.ts'; const pbkdf2HashPattern = /^pbkdf2-sha256\$(\d+)\$([A-Za-z0-9+/=]+)\$([A-Za-z0-9+/=]+)$/;
const pbkdf2Iterations = 210_000;
const pbkdf2KeyLengthBits = 256;
const bcryptHashPattern = /^\$2[abxy]\$\d\d\$/; const bytesToBase64 = (bytesArg: Uint8Array): string => {
let binary = '';
for (const byte of bytesArg) {
binary += String.fromCharCode(byte);
}
return btoa(binary);
};
export function isBcryptHash(passwordHash: string): boolean { const base64ToBytes = (base64Arg: string): Uint8Array => {
return bcryptHashPattern.test(passwordHash); const binary = atob(base64Arg);
} const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
};
export function needsPasswordUpgrade(passwordHash: string): boolean { const timingSafeEqual = (aArg: Uint8Array, bArg: Uint8Array): boolean => {
return !isBcryptHash(passwordHash); if (aArg.length !== bArg.length) {
return false;
}
let diff = 0;
for (let i = 0; i < aArg.length; i++) {
diff |= aArg[i] ^ bArg[i];
}
return diff === 0;
};
const toArrayBuffer = (bytesArg: Uint8Array): ArrayBuffer => {
return bytesArg.buffer.slice(
bytesArg.byteOffset,
bytesArg.byteOffset + bytesArg.byteLength,
) as ArrayBuffer;
};
const derivePasswordHash = async (
passwordArg: string,
saltArg: Uint8Array,
iterationsArg: number,
): Promise<Uint8Array> => {
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(passwordArg),
'PBKDF2',
false,
['deriveBits'],
);
const bits = await crypto.subtle.deriveBits(
{
name: 'PBKDF2',
hash: 'SHA-256',
salt: toArrayBuffer(saltArg),
iterations: iterationsArg,
},
key,
pbkdf2KeyLengthBits,
);
return new Uint8Array(bits);
};
export function isPbkdf2Hash(passwordHash: string): boolean {
return pbkdf2HashPattern.test(passwordHash);
} }
export async function hashPassword(password: string): Promise<string> { export async function hashPassword(password: string): Promise<string> {
return await plugins.bcrypt.hash(password); // Use Web Crypto only so compiled binaries do not depend on external worker files.
const salt = crypto.getRandomValues(new Uint8Array(16));
const hash = await derivePasswordHash(password, salt, pbkdf2Iterations);
return `pbkdf2-sha256$${pbkdf2Iterations}$${bytesToBase64(salt)}$${bytesToBase64(hash)}`;
} }
export async function verifyPassword(password: string, passwordHash: string): Promise<boolean> { export async function verifyPassword(password: string, passwordHash: string): Promise<boolean> {
@@ -19,10 +81,14 @@ export async function verifyPassword(password: string, passwordHash: string): Pr
return false; return false;
} }
if (isBcryptHash(passwordHash)) { const pbkdf2Match = passwordHash.match(pbkdf2HashPattern);
return await plugins.bcrypt.compare(password, passwordHash); if (pbkdf2Match) {
const iterations = Number(pbkdf2Match[1]);
const salt = base64ToBytes(pbkdf2Match[2]);
const expectedHash = base64ToBytes(pbkdf2Match[3]);
const actualHash = await derivePasswordHash(password, salt, iterations);
return timingSafeEqual(actualHash, expectedHash);
} }
// Legacy compatibility for older databases that stored base64-encoded passwords. return false;
return passwordHash === btoa(password);
} }
+1 -1
View File
File diff suppressed because one or more lines are too long
+29 -64
View File
@@ -49,27 +49,29 @@ export class ObViewSettings extends DeesElement {
css` css`
.gateway-card { .gateway-card {
margin-bottom: 24px; margin-bottom: 24px;
border: 1px solid var(--dees-color-border-subtle); border: 1px solid ${cssManager.bdTheme('#e4e4e7', '#27272a')};
border-radius: 12px; border-radius: 12px;
background: var(--dees-color-background, #ffffff); background: ${cssManager.bdTheme('#ffffff', '#09090b')};
overflow: hidden; overflow: hidden;
box-shadow: 0 1px 2px ${cssManager.bdTheme('rgba(0,0,0,0.04)', 'rgba(0,0,0,0.2)')};
} }
.gateway-header { .gateway-header {
padding: 16px 20px; padding: 16px 20px;
border-bottom: 1px solid var(--dees-color-border-subtle); border-bottom: 1px solid ${cssManager.bdTheme('#f4f4f5', '#27272a')};
background: ${cssManager.bdTheme('#fafafa', '#101013')};
} }
.gateway-title { .gateway-title {
font-size: 15px; font-size: 15px;
font-weight: 600; font-weight: 600;
color: var(--dees-color-text-primary); color: ${cssManager.bdTheme('#18181b', '#fafafa')};
} }
.gateway-subtitle { .gateway-subtitle {
margin-top: 4px; margin-top: 4px;
font-size: 13px; font-size: 13px;
color: var(--dees-color-text-muted); color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
} }
.gateway-content { .gateway-content {
@@ -83,34 +85,8 @@ export class ObViewSettings extends DeesElement {
grid-column: 1 / -1; grid-column: 1 / -1;
} }
.field-label { dees-input-text {
display: block;
margin-bottom: 6px;
font-size: 13px;
font-weight: 500;
color: var(--dees-color-text-secondary);
}
input {
width: 100%; width: 100%;
box-sizing: border-box;
padding: 10px 12px;
border: 1px solid var(--dees-color-border-subtle);
border-radius: 8px;
background: transparent;
color: var(--dees-color-text-primary);
font-size: 14px;
}
input:focus {
outline: none;
border-color: #3b82f6;
}
.field-hint {
margin-top: 5px;
font-size: 12px;
color: var(--dees-color-text-muted);
} }
.gateway-footer { .gateway-footer {
@@ -119,21 +95,6 @@ export class ObViewSettings extends DeesElement {
padding: 0 20px 20px; padding: 0 20px 20px;
} }
.save-button {
border: none;
border-radius: 8px;
background: #2563eb;
color: white;
cursor: pointer;
font-size: 13px;
font-weight: 600;
padding: 9px 14px;
}
.save-button:hover {
background: #1d4ed8;
}
@media (max-width: 700px) { @media (max-width: 700px) {
.gateway-content { .gateway-content {
grid-template-columns: 1fr; grid-template-columns: 1fr;
@@ -190,18 +151,23 @@ export class ObViewSettings extends DeesElement {
return html` return html`
<section class="gateway-card"> <section class="gateway-card">
<div class="gateway-header"> <div class="gateway-header">
<div class="gateway-title">External dcrouter Gateway</div> <div class="gateway-title">Delegate Routing</div>
<div class="gateway-subtitle">Delegate public WorkApp routing, DNS, and certificates to a dcrouter edge authority.</div> <div class="gateway-subtitle">Delegate public WorkApp routing, DNS, and certificates to a dcrouter edge authority.</div>
</div> </div>
<div class="gateway-content"> <div class="gateway-content">
${this.renderGatewayInput('dcrouterGatewayUrl', 'Gateway URL', settings?.dcrouterGatewayUrl || '', 'https://edge.example.com', 'Base URL of the dcrouter OpsServer.')} ${this.renderGatewayInput('dcrouterGatewayUrl', 'Gateway URL', settings?.dcrouterGatewayUrl || '', 'Base URL of the dcrouter OpsServer.')}
${this.renderGatewayInput('dcrouterGatewayApiToken', 'API Token', settings?.dcrouterGatewayApiToken || '', 'dcrouter API token', 'Requires workhosters and certificates scopes.', 'password')} ${this.renderGatewayInput('dcrouterGatewayApiToken', 'API Token', settings?.dcrouterGatewayApiToken || '', 'Requires workhosters and certificates scopes.', true)}
${this.renderGatewayInput('dcrouterWorkHosterId', 'WorkHoster ID', settings?.dcrouterWorkHosterId || '', 'optional stable owner ID', 'Leave empty to let Onebox create a stable ID.')} ${this.renderGatewayInput('dcrouterWorkHosterId', 'WorkHoster ID', settings?.dcrouterWorkHosterId || '', 'Leave empty to let Onebox create a stable ID.')}
${this.renderGatewayInput('dcrouterTargetHost', 'Target Host', settings?.dcrouterTargetHost || '', 'public or private host/IP', 'Defaults to the configured server IP when empty.')} ${this.renderGatewayInput('dcrouterTargetHost', 'Target Host', settings?.dcrouterTargetHost || '', 'Defaults to the configured server IP when empty.')}
${this.renderGatewayInput('dcrouterTargetPort', 'Target Port', String(settings?.dcrouterTargetPort || 80), '80', 'Internal HTTP port dcrouter forwards to.', 'number')} ${this.renderGatewayInput('dcrouterTargetPort', 'Target Port', String(settings?.dcrouterTargetPort || 80), 'Internal HTTP port dcrouter forwards to.')}
</div> </div>
<div class="gateway-footer"> <div class="gateway-footer">
<button class="save-button" @click=${() => this.saveExternalGatewaySettings()}>Save Gateway Settings</button> <dees-button
.text=${'Save Gateway Settings'}
.type=${'default'}
.icon=${'lucide:Save'}
@click=${() => this.saveExternalGatewaySettings()}
></dees-button>
</div> </div>
</section> </section>
`; `;
@@ -211,21 +177,20 @@ export class ObViewSettings extends DeesElement {
key: keyof NonNullable<appstate.ISettingsState['settings']>, key: keyof NonNullable<appstate.ISettingsState['settings']>,
label: string, label: string,
value: string, value: string,
placeholder: string,
hint: string, hint: string,
type: 'text' | 'password' | 'number' = 'text', isPassword = false,
): TemplateResult { ): TemplateResult {
return html` return html`
<label class="gateway-field ${key === 'dcrouterGatewayUrl' ? 'full' : ''}"> <div class="gateway-field ${key === 'dcrouterGatewayUrl' ? 'full' : ''}">
<span class="field-label">${label}</span> <dees-input-text
<input .key=${key}
type=${type} .label=${label}
.value=${value} .value=${value}
placeholder=${placeholder} .description=${hint}
.isPasswordBool=${isPassword}
@input=${(event: Event) => this.updateGatewayDraft(key, (event.target as HTMLInputElement).value)} @input=${(event: Event) => this.updateGatewayDraft(key, (event.target as HTMLInputElement).value)}
/> ></dees-input-text>
<span class="field-hint">${hint}</span> </div>
</label>
`; `;
} }