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
## 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)
upgrade runtime dependencies and harden registry/shutdown behavior
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@serve.zone/onebox",
"version": "1.24.3",
"version": "1.24.7",
"exports": "./mod.ts",
"tasks": {
"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 ""
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
echo ""
+1 -1
View File
@@ -1,6 +1,6 @@
{
"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",
"main": "mod.ts",
"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 {
hashPassword,
isBcryptHash,
needsPasswordUpgrade,
isPbkdf2Hash,
verifyPassword,
} from '../ts/utils/auth.ts';
@@ -45,18 +44,14 @@ async function createAdminHandler(users: IDatabaseUser[]): Promise<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 bcryptHash = await hashPassword(password);
const passwordHash = await hashPassword(password);
assert(isBcryptHash(bcryptHash));
assert(await verifyPassword(password, bcryptHash));
assert(!(await verifyPassword('wrong password', bcryptHash)));
assert(!needsPasswordUpgrade(bcryptHash));
const legacyHash = btoa(password);
assert(await verifyPassword(password, legacyHash));
assert(needsPasswordUpgrade(legacyHash));
assert(isPbkdf2Hash(passwordHash));
assert(await verifyPassword(password, passwordHash));
assert(!(await verifyPassword('wrong password', passwordHash)));
assert(!(await verifyPassword(password, btoa(password))));
});
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) {
logger.info(`HTTPS already running on port ${this.httpsPort} via SmartProxy`);
} 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;
}
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++) {
try {
const response = await fetch(`${this.adminUrl}/ready`);
+5 -5
View File
@@ -36,6 +36,7 @@ export class OpsServer {
domain: 'localhost',
feedMetadata: undefined,
bundledContent: bundledFiles,
addCustomRoutes: async (typedserver) => this.registerCustomRoutes(typedserver),
});
// Chain typedrouters: server -> opsServer -> individual handlers
@@ -43,7 +44,6 @@ export class OpsServer {
// Set up all handlers
await this.setupHandlers();
this.registerCustomRoutes();
await this.server.start(port);
logger.success(`OpsServer started on http://localhost:${port}`);
@@ -73,18 +73,18 @@ export class OpsServer {
logger.success('OpsServer TypedRequest handlers initialized');
}
private registerCustomRoutes(): void {
this.server.typedserver.addRoute(
private registerCustomRoutes(typedserver: plugins.typedserver.TypedServer): void {
typedserver.addRoute(
'/v2',
'ALL',
async (ctx) => this.oneboxRef.registry.handleRequest(ctx.request),
);
this.server.typedserver.addRoute(
typedserver.addRoute(
'/v2/*',
'ALL',
async (ctx) => this.oneboxRef.registry.handleRequest(ctx.request),
);
this.server.typedserver.addRoute(
typedserver.addRoute(
'/backups/:backupId/download',
'GET',
async (ctx) => {
+1 -6
View File
@@ -2,7 +2,7 @@ import * as plugins from '../../plugins.ts';
import { logger } from '../../logging.ts';
import type { OpsServer } from '../classes.opsserver.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 {
userId: string;
@@ -112,11 +112,6 @@ export class AdminHandler {
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 freshUser = this.opsServerRef.oneboxRef.database.getUserByUsername(user.username) || user;
const identity = await this.createIdentityForUser(freshUser, expiresAt);
-4
View File
@@ -55,10 +55,6 @@ export const awsS3 = {
import * as taskbuffer from '@push.rocks/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
import * as jwt from 'https://deno.land/x/djwt@v3.0.2/mod.ts';
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 {
return bcryptHashPattern.test(passwordHash);
}
const base64ToBytes = (base64Arg: string): Uint8Array => {
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 {
return !isBcryptHash(passwordHash);
const timingSafeEqual = (aArg: Uint8Array, bArg: Uint8Array): boolean => {
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> {
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> {
@@ -19,10 +81,14 @@ export async function verifyPassword(password: string, passwordHash: string): Pr
return false;
}
if (isBcryptHash(passwordHash)) {
return await plugins.bcrypt.compare(password, passwordHash);
const pbkdf2Match = passwordHash.match(pbkdf2HashPattern);
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 passwordHash === btoa(password);
return false;
}
+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`
.gateway-card {
margin-bottom: 24px;
border: 1px solid var(--dees-color-border-subtle);
border: 1px solid ${cssManager.bdTheme('#e4e4e7', '#27272a')};
border-radius: 12px;
background: var(--dees-color-background, #ffffff);
background: ${cssManager.bdTheme('#ffffff', '#09090b')};
overflow: hidden;
box-shadow: 0 1px 2px ${cssManager.bdTheme('rgba(0,0,0,0.04)', 'rgba(0,0,0,0.2)')};
}
.gateway-header {
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 {
font-size: 15px;
font-weight: 600;
color: var(--dees-color-text-primary);
color: ${cssManager.bdTheme('#18181b', '#fafafa')};
}
.gateway-subtitle {
margin-top: 4px;
font-size: 13px;
color: var(--dees-color-text-muted);
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
}
.gateway-content {
@@ -83,34 +85,8 @@ export class ObViewSettings extends DeesElement {
grid-column: 1 / -1;
}
.field-label {
display: block;
margin-bottom: 6px;
font-size: 13px;
font-weight: 500;
color: var(--dees-color-text-secondary);
}
input {
dees-input-text {
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 {
@@ -119,21 +95,6 @@ export class ObViewSettings extends DeesElement {
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) {
.gateway-content {
grid-template-columns: 1fr;
@@ -190,18 +151,23 @@ export class ObViewSettings extends DeesElement {
return html`
<section class="gateway-card">
<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>
<div class="gateway-content">
${this.renderGatewayInput('dcrouterGatewayUrl', 'Gateway URL', settings?.dcrouterGatewayUrl || '', 'https://edge.example.com', 'Base URL of the dcrouter OpsServer.')}
${this.renderGatewayInput('dcrouterGatewayApiToken', 'API Token', settings?.dcrouterGatewayApiToken || '', 'dcrouter API token', 'Requires workhosters and certificates scopes.', 'password')}
${this.renderGatewayInput('dcrouterWorkHosterId', 'WorkHoster ID', settings?.dcrouterWorkHosterId || '', 'optional stable owner ID', '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('dcrouterTargetPort', 'Target Port', String(settings?.dcrouterTargetPort || 80), '80', 'Internal HTTP port dcrouter forwards to.', 'number')}
${this.renderGatewayInput('dcrouterGatewayUrl', 'Gateway URL', settings?.dcrouterGatewayUrl || '', 'Base URL of the dcrouter OpsServer.')}
${this.renderGatewayInput('dcrouterGatewayApiToken', 'API Token', settings?.dcrouterGatewayApiToken || '', 'Requires workhosters and certificates scopes.', true)}
${this.renderGatewayInput('dcrouterWorkHosterId', 'WorkHoster ID', settings?.dcrouterWorkHosterId || '', 'Leave empty to let Onebox create a stable ID.')}
${this.renderGatewayInput('dcrouterTargetHost', 'Target Host', settings?.dcrouterTargetHost || '', 'Defaults to the configured server IP when empty.')}
${this.renderGatewayInput('dcrouterTargetPort', 'Target Port', String(settings?.dcrouterTargetPort || 80), 'Internal HTTP port dcrouter forwards to.')}
</div>
<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>
</section>
`;
@@ -211,21 +177,20 @@ export class ObViewSettings extends DeesElement {
key: keyof NonNullable<appstate.ISettingsState['settings']>,
label: string,
value: string,
placeholder: string,
hint: string,
type: 'text' | 'password' | 'number' = 'text',
isPassword = false,
): TemplateResult {
return html`
<label class="gateway-field ${key === 'dcrouterGatewayUrl' ? 'full' : ''}">
<span class="field-label">${label}</span>
<input
type=${type}
<div class="gateway-field ${key === 'dcrouterGatewayUrl' ? 'full' : ''}">
<dees-input-text
.key=${key}
.label=${label}
.value=${value}
placeholder=${placeholder}
.description=${hint}
.isPasswordBool=${isPassword}
@input=${(event: Event) => this.updateGatewayDraft(key, (event.target as HTMLInputElement).value)}
/>
<span class="field-hint">${hint}</span>
</label>
></dees-input-text>
</div>
`;
}