This commit is contained in:
2025-11-18 00:03:24 +00:00
parent 246a6073e0
commit 8f538ab9c0
50 changed files with 12836 additions and 531 deletions

111
.claude/CLAUDE.md Normal file
View File

@@ -0,0 +1,111 @@
# Onebox Development Notes
## ⚠️ CRITICAL DEVELOPMENT RULES ⚠️
### NEVER GUESS - ALWAYS READ THE ACTUAL CODE
**FUCKING ALWAYS look at the dependency actual code. Don't start fucking guessing stuff.**
When working with any dependency:
1. **READ the actual source code** in `node_modules/` or check the package documentation
2. **CHECK the exact API** - don't assume based on similar libraries
3. **VERIFY method names, return types, and property structures** before using them
4. **TEST with the actual implementation** - APIs change between versions
Common mistakes to avoid:
- ❌ Assuming API structure based on similar libraries
- ❌ Guessing method names or property paths
- ❌ Using outdated documentation without checking current version
- ✅ Read the actual TypeScript definitions in node_modules
- ✅ Check the package's README and changelog
- ✅ Test the actual behavior before implementing
## Architecture Changes
### Reverse Proxy Implementation
- **Replaced Nginx** with native Deno reverse proxy (`ts/classes/reverseproxy.ts`)
- Features:
- HTTP/HTTPS dual servers (ports 80/443)
- TLS/SSL certificate management with hot-reload
- WebSocket bidirectional proxying
- Dynamic routing from database
- SNI (Server Name Indication) support
### Code Organization
- Removed "onebox." prefix from all TypeScript files
- Organized into subfolders:
- `ts/classes/` - All class implementations
- `ts/` - Root level utilities (logging, types, plugins, cli, info)
## Debugging Tips
### Backend Logs
Use the background bash task to check server logs:
```bash
# Check for specific patterns (e.g., Login attempts)
BashOutput tool with filter: "Login|error|Error"
# Check all recent output
BashOutput tool without filter
```
The dev server runs with `--watch` so it auto-restarts on file changes.
### Frontend Testing
Use Playwright for UI testing:
```typescript
// Navigate to app
mcp__playwright__browser_navigate({ url: "http://localhost:3000" })
// Fill login form
mcp__playwright__browser_fill_form({
fields: [
{ name: "Username", type: "textbox", ref: "...", value: "admin" },
{ name: "Password", type: "textbox", ref: "...", value: "admin" }
]
})
// Click button
mcp__playwright__browser_click({ element: "Sign in button", ref: "..." })
// Check console errors
// Playwright automatically shows console messages in results
```
### Common Issues
#### Login Issue (Fixed)
**Problem**: `admin/admin` credentials returned "Invalid credentials"
**Root Cause**: `rowToUser()` function in database.ts was accessing rows as arrays `row[2]` instead of objects `row.password_hash`. The @db/sqlite library returns rows as objects with snake_case column names.
**Fix**: Updated `rowToUser()` to support both access patterns:
```typescript
private rowToUser(row: any): IUser {
return {
passwordHash: String(row.password_hash || row[2]),
// ... other fields
};
}
```
**Location**: `ts/classes/database.ts:506-515`
## Default Credentials
- Username: `admin`
- Password: `admin`
- ⚠️ Change immediately after first login!
## Development Server
```bash
# Main server (port 3000)
deno task dev
# Check server status
curl http://localhost:3000/api/status
```
## API Endpoints
- `POST /api/auth/login` - Login (returns JWT-like token)
- `GET /api/status` - System status (requires auth)
- `GET /api/services` - List services (requires auth)
- See `ts/classes/httpserver.ts` for full API

View File

@@ -2,23 +2,24 @@
"name": "@serve.zone/onebox",
"version": "1.0.0",
"exports": "./mod.ts",
"nodeModulesDir": "auto",
"tasks": {
"test": "deno test --allow-all test/",
"test:watch": "deno test --allow-all --watch test/",
"compile": "bash scripts/compile-all.sh",
"dev": "deno run --allow-all --watch mod.ts"
"dev": "deno run --allow-all --unstable-ffi --watch mod.ts server --ephemeral --monitor"
},
"imports": {
"@std/path": "jsr:@std/path@^1.0.0",
"@std/fs": "jsr:@std/fs@^1.0.0",
"@std/http": "jsr:@std/http@^1.0.0",
"@std/assert": "jsr:@std/assert@^1.0.0",
"@std/encoding": "jsr:@std/encoding@^1.0.0",
"@db/sqlite": "jsr:@db/sqlite@^0.11.0",
"@push.rocks/smartdaemon": "npm:@push.rocks/smartdaemon@^2.0.0",
"@apiclient.xyz/docker": "npm:@apiclient.xyz/docker@^2.0.0",
"@apiclient.xyz/cloudflare": "npm:@apiclient.xyz/cloudflare@^2.0.0",
"@push.rocks/smartacme": "npm:@push.rocks/smartacme@^2.0.0"
"@std/path": "jsr:@std/path@^1.1.2",
"@std/fs": "jsr:@std/fs@^1.0.19",
"@std/http": "jsr:@std/http@^1.0.21",
"@std/assert": "jsr:@std/assert@^1.0.15",
"@std/encoding": "jsr:@std/encoding@^1.0.10",
"@db/sqlite": "jsr:@db/sqlite@0.12.0",
"@push.rocks/smartdaemon": "npm:@push.rocks/smartdaemon@^2.1.0",
"@apiclient.xyz/docker": "npm:@apiclient.xyz/docker@1.3.6",
"@apiclient.xyz/cloudflare": "npm:@apiclient.xyz/cloudflare@6.4.3",
"@push.rocks/smartacme": "npm:@push.rocks/smartacme@^8.0.0"
},
"compilerOptions": {
"lib": ["deno.window", "deno.ns"],

View File

@@ -48,5 +48,7 @@
"cpu": [
"x64",
"arm64"
]
],
"packageManager": "pnpm@10.18.1+sha512.77a884a165cbba2d8d1c19e3b4880eee6d2fcabd0d879121e282196b80042351d5eb3ca0935fa599da1dc51265cc68816ad2bddd2a2de5ea9fdf92adbec7cd34",
"dependencies": {}
}

1242
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

209
ts/classes/apiclient.ts Normal file
View File

@@ -0,0 +1,209 @@
/**
* API Client for communicating with Onebox daemon
*
* Provides methods for CLI commands to interact with running daemon via HTTP API
*/
import type {
IService,
IRegistry,
IDnsRecord,
ISslCertificate,
IServiceDeployOptions,
} from '../types.ts';
export class OneboxApiClient {
private baseUrl: string;
private token?: string;
constructor(port = 3000) {
this.baseUrl = `http://localhost:${port}`;
}
/**
* Check if daemon is reachable
*/
async isReachable(): Promise<boolean> {
try {
const response = await fetch(`${this.baseUrl}/api/status`, {
signal: AbortSignal.timeout(5000), // 5 second timeout
});
return response.ok;
} catch {
return false;
}
}
// ============ Service Operations ============
async deployService(config: IServiceDeployOptions): Promise<IService> {
return await this.request<IService>('POST', '/api/services', config);
}
async removeService(name: string): Promise<void> {
await this.request('DELETE', `/api/services/${name}`);
}
async startService(name: string): Promise<void> {
await this.request('POST', `/api/services/${name}/start`);
}
async stopService(name: string): Promise<void> {
await this.request('POST', `/api/services/${name}/stop`);
}
async restartService(name: string): Promise<void> {
await this.request('POST', `/api/services/${name}/restart`);
}
async listServices(): Promise<IService[]> {
return await this.request<IService[]>('GET', '/api/services');
}
async getServiceLogs(name: string, limit = 1000): Promise<string[]> {
const result = await this.request<{ logs: string[] }>(
'GET',
`/api/services/${name}/logs?limit=${limit}`
);
return result.logs;
}
// ============ Registry Operations ============
async addRegistry(url: string, username: string, password: string): Promise<void> {
await this.request('POST', '/api/registries', { url, username, password });
}
async removeRegistry(url: string): Promise<void> {
await this.request('DELETE', `/api/registries/${encodeURIComponent(url)}`);
}
async listRegistries(): Promise<IRegistry[]> {
return await this.request<IRegistry[]>('GET', '/api/registries');
}
// ============ DNS Operations ============
async addDnsRecord(domain: string): Promise<void> {
await this.request('POST', '/api/dns', { domain });
}
async removeDnsRecord(domain: string): Promise<void> {
await this.request('DELETE', `/api/dns/${domain}`);
}
async listDnsRecords(): Promise<IDnsRecord[]> {
return await this.request<IDnsRecord[]>('GET', '/api/dns');
}
async syncDns(): Promise<void> {
await this.request('POST', '/api/dns/sync');
}
// ============ SSL Operations ============
async renewCertificate(domain?: string): Promise<void> {
const path = domain ? `/api/ssl/renew/${domain}` : '/api/ssl/renew';
await this.request('POST', path);
}
async listCertificates(): Promise<ISslCertificate[]> {
return await this.request<ISslCertificate[]>('GET', '/api/ssl');
}
async forceRenewCertificate(domain: string): Promise<void> {
await this.request('POST', `/api/ssl/renew/${domain}?force=true`);
}
// ============ Nginx Operations ============
async reloadNginx(): Promise<void> {
await this.request('POST', '/api/nginx/reload');
}
async testNginx(): Promise<{ success: boolean; output: string }> {
return await this.request('POST', '/api/nginx/test');
}
async getNginxStatus(): Promise<{ status: string }> {
return await this.request('GET', '/api/nginx/status');
}
// ============ Config Operations ============
async getSettings(): Promise<Record<string, string>> {
return await this.request<Record<string, string>>('GET', '/api/config');
}
async setSetting(key: string, value: string): Promise<void> {
await this.request('POST', '/api/config', { key, value });
}
// ============ System Operations ============
async getStatus(): Promise<{
services: { total: number; running: number; stopped: number };
uptime: number;
}> {
return await this.request('GET', '/api/status');
}
// ============ Helper Methods ============
/**
* Make HTTP request to daemon
*/
private async request<T = unknown>(
method: string,
path: string,
body?: unknown
): Promise<T> {
const url = `${this.baseUrl}${path}`;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (this.token) {
headers['Authorization'] = `Bearer ${this.token}`;
}
const options: RequestInit = {
method,
headers,
signal: AbortSignal.timeout(30000), // 30 second timeout
};
if (body) {
options.body = JSON.stringify(body);
}
try {
const response = await fetch(url, options);
if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: response.statusText }));
throw new Error(errorData.message || `HTTP ${response.status}: ${response.statusText}`);
}
// For DELETE and some POST requests, there might be no content
if (response.status === 204 || response.headers.get('content-length') === '0') {
return undefined as T;
}
return await response.json();
} catch (error) {
if (error.name === 'TimeoutError') {
throw new Error('Request timed out. Daemon might be unresponsive.');
}
throw error;
}
}
/**
* Set authentication token
*/
setToken(token: string): void {
this.token = token;
}
}

View File

@@ -4,26 +4,40 @@
* Handles background monitoring, metrics collection, and automatic tasks
*/
import * as plugins from './onebox.plugins.ts';
import { logger } from './onebox.logging.ts';
import { projectInfo } from './onebox.info.ts';
import type { Onebox } from './onebox.classes.onebox.ts';
import * as plugins from '../plugins.ts';
import { logger } from '../logging.ts';
import { projectInfo } from '../info.ts';
import type { Onebox } from './onebox.ts';
// PID file constants
const PID_FILE_PATH = '/var/run/onebox/onebox.pid';
const PID_DIR = '/var/run/onebox';
const FALLBACK_PID_DIR = `${Deno.env.get('HOME')}/.onebox`;
const FALLBACK_PID_FILE = `${FALLBACK_PID_DIR}/onebox.pid`;
export class OneboxDaemon {
private oneboxRef: Onebox;
private smartdaemon: plugins.smartdaemon.SmartDaemon;
private smartdaemon: plugins.smartdaemon.SmartDaemon | null = null;
private running = false;
private monitoringInterval: number | null = null;
private metricsInterval = 60000; // 1 minute
private pidFilePath: string = PID_FILE_PATH;
constructor(oneboxRef: Onebox) {
this.oneboxRef = oneboxRef;
this.smartdaemon = new plugins.smartdaemon.SmartDaemon();
}
// Get metrics interval from settings
const customInterval = this.oneboxRef.database.getSetting('metricsInterval');
if (customInterval) {
this.metricsInterval = parseInt(customInterval, 10);
/**
* Load settings from database (call after database init)
*/
private loadSettings(): void {
try {
const customInterval = this.oneboxRef.database.getSetting('metricsInterval');
if (customInterval) {
this.metricsInterval = parseInt(customInterval, 10);
}
} catch {
// Database not initialized yet - use defaults
}
}
@@ -34,6 +48,11 @@ export class OneboxDaemon {
try {
logger.info('Installing Onebox daemon service...');
// Initialize smartdaemon if needed
if (!this.smartdaemon) {
this.smartdaemon = new plugins.smartdaemon.SmartDaemon();
}
// Get installation directory
const execPath = Deno.execPath();
@@ -63,6 +82,11 @@ export class OneboxDaemon {
try {
logger.info('Uninstalling Onebox daemon service...');
// Initialize smartdaemon if needed
if (!this.smartdaemon) {
this.smartdaemon = new plugins.smartdaemon.SmartDaemon();
}
const service = await this.smartdaemon.getService('onebox');
if (service) {
@@ -92,6 +116,12 @@ export class OneboxDaemon {
this.running = true;
// Load settings from database
this.loadSettings();
// Write PID file
await this.writePidFile();
// Start monitoring loop
this.startMonitoring();
@@ -130,6 +160,9 @@ export class OneboxDaemon {
// Stop HTTP server
await this.oneboxRef.httpServer.stop();
// Remove PID file
await this.removePidFile();
logger.success('Onebox daemon stopped');
} catch (error) {
logger.error(`Failed to stop daemon: ${error.message}`);
@@ -140,7 +173,7 @@ export class OneboxDaemon {
/**
* Start monitoring loop
*/
private startMonitoring(): void {
public startMonitoring(): void {
logger.info('Starting monitoring loop...');
this.monitoringInterval = setInterval(async () => {
@@ -154,7 +187,7 @@ export class OneboxDaemon {
/**
* Stop monitoring loop
*/
private stopMonitoring(): void {
public stopMonitoring(): void {
if (this.monitoringInterval !== null) {
clearInterval(this.monitoringInterval);
this.monitoringInterval = null;
@@ -262,11 +295,111 @@ export class OneboxDaemon {
return this.running;
}
/**
* Write PID file
*/
private async writePidFile(): Promise<void> {
try {
// Try primary location first
try {
await Deno.mkdir(PID_DIR, { recursive: true });
await Deno.writeTextFile(PID_FILE_PATH, Deno.pid.toString());
this.pidFilePath = PID_FILE_PATH;
logger.debug(`PID file written: ${PID_FILE_PATH}`);
return;
} catch (error) {
// Permission denied - try fallback location
logger.debug(`Cannot write to ${PID_DIR}, using fallback location`);
}
// Fallback to user directory
await Deno.mkdir(FALLBACK_PID_DIR, { recursive: true });
await Deno.writeTextFile(FALLBACK_PID_FILE, Deno.pid.toString());
this.pidFilePath = FALLBACK_PID_FILE;
logger.debug(`PID file written: ${FALLBACK_PID_FILE}`);
} catch (error) {
logger.warn(`Failed to write PID file: ${error.message}`);
// Non-fatal - daemon can still run
}
}
/**
* Remove PID file
*/
private async removePidFile(): Promise<void> {
try {
await Deno.remove(this.pidFilePath);
logger.debug(`PID file removed: ${this.pidFilePath}`);
} catch (error) {
// Ignore errors - file might not exist
logger.debug(`Could not remove PID file: ${error.message}`);
}
}
/**
* Check if daemon is running
*/
static async isDaemonRunning(): Promise<boolean> {
const pid = await OneboxDaemon.getDaemonPid();
if (!pid) {
return false;
}
try {
// Check if process exists
await Deno.stat(`/proc/${pid}`);
return true;
} catch {
// Process doesn't exist - clean up stale PID file
logger.debug(`Cleaning up stale PID file`);
try {
await Deno.remove(PID_FILE_PATH);
} catch {
try {
await Deno.remove(FALLBACK_PID_FILE);
} catch {
// Ignore
}
}
return false;
}
}
/**
* Get daemon PID
*/
static async getDaemonPid(): Promise<number | null> {
try {
// Try primary location
try {
const pidText = await Deno.readTextFile(PID_FILE_PATH);
return parseInt(pidText.trim(), 10);
} catch {
// Try fallback location
const pidText = await Deno.readTextFile(FALLBACK_PID_FILE);
return parseInt(pidText.trim(), 10);
}
} catch {
return null;
}
}
/**
* Ensure no daemon is running
*/
static async ensureNoDaemon(): Promise<void> {
const running = await OneboxDaemon.isDaemonRunning();
if (running) {
throw new Error('Daemon is already running. Please stop it first with: onebox daemon stop');
}
}
/**
* Get service status from systemd
*/
async getServiceStatus(): Promise<string> {
try {
// Don't need smartdaemon to check status, just use systemctl directly
const command = new Deno.Command('systemctl', {
args: ['status', 'smartdaemon_onebox'],
stdout: 'piped',

View File

@@ -2,7 +2,7 @@
* Database layer for Onebox using SQLite
*/
import * as plugins from './onebox.plugins.ts';
import * as plugins from '../plugins.ts';
import type {
IService,
IRegistry,
@@ -13,14 +13,14 @@ import type {
ILogEntry,
IUser,
ISetting,
} from './onebox.types.ts';
import { logger } from './onebox.logging.ts';
} from '../types.ts';
import { logger } from '../logging.ts';
export class OneboxDatabase {
private db: plugins.sqlite.DB | null = null;
private dbPath: string;
constructor(dbPath = '/var/lib/onebox/onebox.db') {
constructor(dbPath = './.nogit/onebox.db') {
this.dbPath = dbPath;
}
@@ -55,7 +55,7 @@ export class OneboxDatabase {
if (!this.db) throw new Error('Database not initialized');
// Services table
this.db.query(`
this.query(`
CREATE TABLE IF NOT EXISTS services (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
@@ -72,7 +72,7 @@ export class OneboxDatabase {
`);
// Registries table
this.db.query(`
this.query(`
CREATE TABLE IF NOT EXISTS registries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url TEXT NOT NULL UNIQUE,
@@ -83,7 +83,7 @@ export class OneboxDatabase {
`);
// Nginx configs table
this.db.query(`
this.query(`
CREATE TABLE IF NOT EXISTS nginx_configs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
service_id INTEGER NOT NULL,
@@ -98,7 +98,7 @@ export class OneboxDatabase {
`);
// SSL certificates table
this.db.query(`
this.query(`
CREATE TABLE IF NOT EXISTS ssl_certificates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
domain TEXT NOT NULL UNIQUE,
@@ -113,7 +113,7 @@ export class OneboxDatabase {
`);
// DNS records table
this.db.query(`
this.query(`
CREATE TABLE IF NOT EXISTS dns_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
domain TEXT NOT NULL UNIQUE,
@@ -126,7 +126,7 @@ export class OneboxDatabase {
`);
// Metrics table
this.db.query(`
this.query(`
CREATE TABLE IF NOT EXISTS metrics (
id INTEGER PRIMARY KEY AUTOINCREMENT,
service_id INTEGER NOT NULL,
@@ -141,13 +141,13 @@ export class OneboxDatabase {
`);
// Create index for metrics queries
this.db.query(`
this.query(`
CREATE INDEX IF NOT EXISTS idx_metrics_service_timestamp
ON metrics(service_id, timestamp DESC)
`);
// Logs table
this.db.query(`
this.query(`
CREATE TABLE IF NOT EXISTS logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
service_id INTEGER NOT NULL,
@@ -160,13 +160,13 @@ export class OneboxDatabase {
`);
// Create index for logs queries
this.db.query(`
this.query(`
CREATE INDEX IF NOT EXISTS idx_logs_service_timestamp
ON logs(service_id, timestamp DESC)
`);
// Users table
this.db.query(`
this.query(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
@@ -178,7 +178,7 @@ export class OneboxDatabase {
`);
// Settings table
this.db.query(`
this.query(`
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
@@ -187,7 +187,7 @@ export class OneboxDatabase {
`);
// Version table for migrations
this.db.query(`
this.query(`
CREATE TABLE IF NOT EXISTS migrations (
version INTEGER PRIMARY KEY,
applied_at INTEGER NOT NULL
@@ -220,7 +220,7 @@ export class OneboxDatabase {
if (!this.db) throw new Error('Database not initialized');
try {
const result = this.db.query('SELECT MAX(version) as version FROM migrations');
const result = this.query('SELECT MAX(version) as version FROM migrations');
return result.length > 0 && result[0][0] !== null ? Number(result[0][0]) : 0;
} catch {
return 0;
@@ -233,7 +233,7 @@ export class OneboxDatabase {
private setMigrationVersion(version: number): void {
if (!this.db) throw new Error('Database not initialized');
this.db.query('INSERT INTO migrations (version, applied_at) VALUES (?, ?)', [
this.query('INSERT INTO migrations (version, applied_at) VALUES (?, ?)', [
version,
Date.now(),
]);
@@ -255,8 +255,27 @@ export class OneboxDatabase {
* Execute a raw query
*/
query<T = unknown[]>(sql: string, params: unknown[] = []): T[] {
if (!this.db) throw new Error('Database not initialized');
return this.db.query(sql, params) as T[];
if (!this.db) {
const error = new Error('Database not initialized');
console.error('Database access before initialization!');
console.error('Stack trace:', error.stack);
throw error;
}
// For queries without parameters, use exec for better performance
if (params.length === 0 && !sql.trim().toUpperCase().startsWith('SELECT')) {
this.db.exec(sql);
return [] as T[];
}
// For SELECT queries or statements with parameters, use prepare().all()
const stmt = this.db.prepare(sql);
try {
const result = stmt.all(...params);
return result as T[];
} finally {
stmt.finalize();
}
}
// ============ Services CRUD ============
@@ -265,7 +284,7 @@ export class OneboxDatabase {
if (!this.db) throw new Error('Database not initialized');
const now = Date.now();
this.db.query(
this.query(
`INSERT INTO services (name, image, registry, env_vars, port, domain, container_id, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
@@ -288,21 +307,21 @@ export class OneboxDatabase {
getServiceByName(name: string): IService | null {
if (!this.db) throw new Error('Database not initialized');
const rows = this.db.query('SELECT * FROM services WHERE name = ?', [name]);
const rows = this.query('SELECT * FROM services WHERE name = ?', [name]);
return rows.length > 0 ? this.rowToService(rows[0]) : null;
}
getServiceByID(id: number): IService | null {
if (!this.db) throw new Error('Database not initialized');
const rows = this.db.query('SELECT * FROM services WHERE id = ?', [id]);
const rows = this.query('SELECT * FROM services WHERE id = ?', [id]);
return rows.length > 0 ? this.rowToService(rows[0]) : null;
}
getAllServices(): IService[] {
if (!this.db) throw new Error('Database not initialized');
const rows = this.db.query('SELECT * FROM services ORDER BY created_at DESC');
const rows = this.query('SELECT * FROM services ORDER BY created_at DESC');
return rows.map((row) => this.rowToService(row));
}
@@ -345,12 +364,12 @@ export class OneboxDatabase {
values.push(Date.now());
values.push(id);
this.db.query(`UPDATE services SET ${fields.join(', ')} WHERE id = ?`, values);
this.query(`UPDATE services SET ${fields.join(', ')} WHERE id = ?`, values);
}
deleteService(id: number): void {
if (!this.db) throw new Error('Database not initialized');
this.db.query('DELETE FROM services WHERE id = ?', [id]);
this.query('DELETE FROM services WHERE id = ?', [id]);
}
private rowToService(row: unknown[]): IService {
@@ -375,7 +394,7 @@ export class OneboxDatabase {
if (!this.db) throw new Error('Database not initialized');
const now = Date.now();
this.db.query(
this.query(
'INSERT INTO registries (url, username, password_encrypted, created_at) VALUES (?, ?, ?, ?)',
[registry.url, registry.username, registry.passwordEncrypted, now]
);
@@ -386,20 +405,20 @@ export class OneboxDatabase {
getRegistryByURL(url: string): IRegistry | null {
if (!this.db) throw new Error('Database not initialized');
const rows = this.db.query('SELECT * FROM registries WHERE url = ?', [url]);
const rows = this.query('SELECT * FROM registries WHERE url = ?', [url]);
return rows.length > 0 ? this.rowToRegistry(rows[0]) : null;
}
getAllRegistries(): IRegistry[] {
if (!this.db) throw new Error('Database not initialized');
const rows = this.db.query('SELECT * FROM registries ORDER BY created_at DESC');
const rows = this.query('SELECT * FROM registries ORDER BY created_at DESC');
return rows.map((row) => this.rowToRegistry(row));
}
deleteRegistry(url: string): void {
if (!this.db) throw new Error('Database not initialized');
this.db.query('DELETE FROM registries WHERE url = ?', [url]);
this.query('DELETE FROM registries WHERE url = ?', [url]);
}
private rowToRegistry(row: unknown[]): IRegistry {
@@ -417,7 +436,7 @@ export class OneboxDatabase {
getSetting(key: string): string | null {
if (!this.db) throw new Error('Database not initialized');
const rows = this.db.query('SELECT value FROM settings WHERE key = ?', [key]);
const rows = this.query('SELECT value FROM settings WHERE key = ?', [key]);
return rows.length > 0 ? String(rows[0][0]) : null;
}
@@ -425,7 +444,7 @@ export class OneboxDatabase {
if (!this.db) throw new Error('Database not initialized');
const now = Date.now();
this.db.query(
this.query(
'INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES (?, ?, ?)',
[key, value, now]
);
@@ -434,10 +453,13 @@ export class OneboxDatabase {
getAllSettings(): Record<string, string> {
if (!this.db) throw new Error('Database not initialized');
const rows = this.db.query('SELECT key, value FROM settings');
const rows = this.query('SELECT key, value FROM settings');
const settings: Record<string, string> = {};
for (const row of rows) {
settings[String(row[0])] = String(row[1]);
// @db/sqlite returns rows as objects with column names as keys
const key = (row as any).key || row[0];
const value = (row as any).value || row[1];
settings[String(key)] = String(value);
}
return settings;
}
@@ -448,7 +470,7 @@ export class OneboxDatabase {
if (!this.db) throw new Error('Database not initialized');
const now = Date.now();
this.db.query(
this.query(
'INSERT INTO users (username, password_hash, role, created_at, updated_at) VALUES (?, ?, ?, ?, ?)',
[user.username, user.passwordHash, user.role, now, now]
);
@@ -459,20 +481,20 @@ export class OneboxDatabase {
getUserByUsername(username: string): IUser | null {
if (!this.db) throw new Error('Database not initialized');
const rows = this.db.query('SELECT * FROM users WHERE username = ?', [username]);
const rows = this.query('SELECT * FROM users WHERE username = ?', [username]);
return rows.length > 0 ? this.rowToUser(rows[0]) : null;
}
getAllUsers(): IUser[] {
if (!this.db) throw new Error('Database not initialized');
const rows = this.db.query('SELECT * FROM users ORDER BY created_at DESC');
const rows = this.query('SELECT * FROM users ORDER BY created_at DESC');
return rows.map((row) => this.rowToUser(row));
}
updateUserPassword(username: string, passwordHash: string): void {
if (!this.db) throw new Error('Database not initialized');
this.db.query('UPDATE users SET password_hash = ?, updated_at = ? WHERE username = ?', [
this.query('UPDATE users SET password_hash = ?, updated_at = ? WHERE username = ?', [
passwordHash,
Date.now(),
username,
@@ -481,17 +503,17 @@ export class OneboxDatabase {
deleteUser(username: string): void {
if (!this.db) throw new Error('Database not initialized');
this.db.query('DELETE FROM users WHERE username = ?', [username]);
this.query('DELETE FROM users WHERE username = ?', [username]);
}
private rowToUser(row: unknown[]): IUser {
private rowToUser(row: any): IUser {
return {
id: Number(row[0]),
username: String(row[1]),
passwordHash: String(row[2]),
role: String(row[3]) as IUser['role'],
createdAt: Number(row[4]),
updatedAt: Number(row[5]),
id: Number(row.id || row[0]),
username: String(row.username || row[1]),
passwordHash: String(row.password_hash || row[2]),
role: String(row.role || row[3]) as IUser['role'],
createdAt: Number(row.created_at || row[4]),
updatedAt: Number(row.updated_at || row[5]),
};
}
@@ -500,7 +522,7 @@ export class OneboxDatabase {
addMetric(metric: Omit<IMetric, 'id'>): void {
if (!this.db) throw new Error('Database not initialized');
this.db.query(
this.query(
`INSERT INTO metrics (service_id, timestamp, cpu_percent, memory_used, memory_limit, network_rx_bytes, network_tx_bytes)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[
@@ -518,7 +540,7 @@ export class OneboxDatabase {
getMetrics(serviceId: number, limit = 100): IMetric[] {
if (!this.db) throw new Error('Database not initialized');
const rows = this.db.query(
const rows = this.query(
'SELECT * FROM metrics WHERE service_id = ? ORDER BY timestamp DESC LIMIT ?',
[serviceId, limit]
);
@@ -543,7 +565,7 @@ export class OneboxDatabase {
addLog(log: Omit<ILogEntry, 'id'>): void {
if (!this.db) throw new Error('Database not initialized');
this.db.query(
this.query(
'INSERT INTO logs (service_id, timestamp, message, level, source) VALUES (?, ?, ?, ?, ?)',
[log.serviceId, log.timestamp, log.message, log.level, log.source]
);
@@ -552,7 +574,7 @@ export class OneboxDatabase {
getLogs(serviceId: number, limit = 1000): ILogEntry[] {
if (!this.db) throw new Error('Database not initialized');
const rows = this.db.query(
const rows = this.query(
'SELECT * FROM logs WHERE service_id = ? ORDER BY timestamp DESC LIMIT ?',
[serviceId, limit]
);
@@ -576,7 +598,7 @@ export class OneboxDatabase {
if (!this.db) throw new Error('Database not initialized');
const now = Date.now();
this.db.query(
this.query(
`INSERT INTO ssl_certificates (domain, cert_path, key_path, full_chain_path, expiry_date, issuer, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[
@@ -597,14 +619,14 @@ export class OneboxDatabase {
getSSLCertificate(domain: string): ISslCertificate | null {
if (!this.db) throw new Error('Database not initialized');
const rows = this.db.query('SELECT * FROM ssl_certificates WHERE domain = ?', [domain]);
const rows = this.query('SELECT * FROM ssl_certificates WHERE domain = ?', [domain]);
return rows.length > 0 ? this.rowToSSLCert(rows[0]) : null;
}
getAllSSLCertificates(): ISslCertificate[] {
if (!this.db) throw new Error('Database not initialized');
const rows = this.db.query('SELECT * FROM ssl_certificates ORDER BY expiry_date ASC');
const rows = this.query('SELECT * FROM ssl_certificates ORDER BY expiry_date ASC');
return rows.map((row) => this.rowToSSLCert(row));
}
@@ -635,12 +657,12 @@ export class OneboxDatabase {
values.push(Date.now());
values.push(domain);
this.db.query(`UPDATE ssl_certificates SET ${fields.join(', ')} WHERE domain = ?`, values);
this.query(`UPDATE ssl_certificates SET ${fields.join(', ')} WHERE domain = ?`, values);
}
deleteSSLCertificate(domain: string): void {
if (!this.db) throw new Error('Database not initialized');
this.db.query('DELETE FROM ssl_certificates WHERE domain = ?', [domain]);
this.query('DELETE FROM ssl_certificates WHERE domain = ?', [domain]);
}
private rowToSSLCert(row: unknown[]): ISslCertificate {

View File

@@ -4,14 +4,14 @@
* Manages DNS records via Cloudflare API
*/
import * as plugins from './onebox.plugins.ts';
import { logger } from './onebox.logging.ts';
import { OneboxDatabase } from './onebox.classes.database.ts';
import * as plugins from '../plugins.ts';
import { logger } from '../logging.ts';
import { OneboxDatabase } from './database.ts';
export class OneboxDnsManager {
private oneboxRef: any;
private database: OneboxDatabase;
private cloudflareClient: plugins.cloudflare.Cloudflare | null = null;
private cloudflareClient: plugins.cloudflare.CloudflareAccount | null = null;
private zoneID: string | null = null;
private serverIP: string | null = null;
@@ -28,27 +28,58 @@ export class OneboxDnsManager {
// Get Cloudflare credentials from settings
const apiKey = this.database.getSetting('cloudflareAPIKey');
const email = this.database.getSetting('cloudflareEmail');
const zoneID = this.database.getSetting('cloudflareZoneID');
const serverIP = this.database.getSetting('serverIP');
if (!apiKey || !email || !zoneID) {
if (!apiKey || !email) {
logger.warn('Cloudflare credentials not configured. DNS management will be disabled.');
logger.info('Configure with: onebox config set cloudflareAPIKey <key>');
logger.info('Configure with: onebox config set cloudflareEmail <email>');
return;
}
this.zoneID = zoneID;
this.serverIP = serverIP;
// Initialize Cloudflare client
this.cloudflareClient = new plugins.cloudflare.Cloudflare({
apiKey,
email,
});
// The CloudflareAccount class expects just the API key/token
this.cloudflareClient = new plugins.cloudflare.CloudflareAccount(apiKey);
// Try to get zone ID from settings, or auto-detect
let zoneID = this.database.getSetting('cloudflareZoneID');
if (!zoneID) {
// Auto-detect zones
logger.info('No zone ID configured, fetching available zones...');
const zones = await this.cloudflareClient.convenience.listZones();
if (zones.length === 0) {
logger.warn('No Cloudflare zones found for this account');
return;
} else if (zones.length === 1) {
// Auto-select the only zone
zoneID = zones[0].id;
this.database.setSetting('cloudflareZoneID', zoneID);
logger.success(`Auto-selected zone: ${zones[0].name} (${zoneID})`);
} else {
// Multiple zones found - log them for user to choose
logger.info(`Found ${zones.length} Cloudflare zones:`);
for (const zone of zones) {
logger.info(` - ${zone.name} (ID: ${zone.id})`);
}
logger.warn('Multiple zones found. Please set one with: onebox config set cloudflareZoneID <id>');
return;
}
}
this.zoneID = zoneID;
logger.info('DNS manager initialized with Cloudflare');
} catch (error) {
logger.error(`Failed to initialize DNS manager: ${error.message}`);
if (error.message && error.message.includes('Authorization header')) {
logger.error('The provided API key appears to be invalid.');
logger.error('Make sure you are using a Cloudflare API TOKEN (not the global API key).');
logger.info('Create an API Token at: https://dash.cloudflare.com/profile/api-tokens');
logger.info('The token needs "Zone:Read" and "DNS:Edit" permissions.');
}
throw error;
}
}

View File

@@ -4,9 +4,9 @@
* Handles all Docker operations: containers, images, networks, volumes
*/
import * as plugins from './onebox.plugins.ts';
import type { IService, IContainerStats } from './onebox.types.ts';
import { logger } from './onebox.logging.ts';
import * as plugins from '../plugins.ts';
import type { IService, IContainerStats } from '../types.ts';
import { logger } from '../logging.ts';
export class OneboxDockerManager {
private dockerClient: plugins.docker.Docker | null = null;
@@ -22,6 +22,9 @@ export class OneboxDockerManager {
socketPath: '/var/run/docker.sock',
});
// Start the Docker client
await this.dockerClient.start();
logger.info('Docker client initialized');
// Ensure onebox network exists
@@ -37,8 +40,8 @@ export class OneboxDockerManager {
*/
private async ensureNetwork(): Promise<void> {
try {
const networks = await this.dockerClient!.listNetworks();
const existingNetwork = networks.find((n: any) => n.Name === this.networkName);
const networks = await this.dockerClient!.getNetworks();
const existingNetwork = networks.find((n: any) => n.name === this.networkName);
if (!existingNetwork) {
logger.info(`Creating Docker network: ${this.networkName}`);
@@ -370,14 +373,11 @@ export class OneboxDockerManager {
*/
async listContainers(): Promise<any[]> {
try {
const containers = await this.dockerClient!.listContainers({
all: true,
filters: {
label: ['managed-by=onebox'],
},
});
return containers;
const containers = await this.dockerClient!.getContainers();
// Filter for onebox-managed containers
return containers.filter((c: any) =>
c.labels && c.labels['managed-by'] === 'onebox'
);
} catch (error) {
logger.error(`Failed to list containers: ${error.message}`);
return [];

View File

@@ -4,10 +4,10 @@
* Serves REST API and Angular UI
*/
import * as plugins from './onebox.plugins.ts';
import { logger } from './onebox.logging.ts';
import type { Onebox } from './onebox.classes.onebox.ts';
import type { IApiResponse } from './onebox.types.ts';
import * as plugins from '../plugins.ts';
import { logger } from '../logging.ts';
import type { Onebox } from './onebox.ts';
import type { IApiResponse } from '../types.ts';
export class OneboxHttpServer {
private oneboxRef: Onebox;
@@ -75,16 +75,93 @@ export class OneboxHttpServer {
return await this.handleApiRequest(req, path);
}
// Serve Angular UI (TODO: implement static file serving)
return new Response('Onebox API - UI coming soon', {
headers: { 'Content-Type': 'text/plain' },
});
// Serve Angular UI
return await this.serveStaticFile(path);
} catch (error) {
logger.error(`Request error: ${error.message}`);
return this.jsonResponse({ success: false, error: error.message }, 500);
}
}
/**
* Serve static files from ui/dist
*/
private async serveStaticFile(path: string): Promise<Response> {
try {
// Default to index.html for root and non-file paths
let filePath = path === '/' ? '/index.html' : path;
// For Angular routing - serve index.html for non-asset paths
if (!filePath.includes('.') && filePath !== '/index.html') {
filePath = '/index.html';
}
const fullPath = `./ui/dist${filePath}`;
// Read file
const file = await Deno.readFile(fullPath);
// Determine content type
const contentType = this.getContentType(filePath);
return new Response(file, {
headers: {
'Content-Type': contentType,
'Cache-Control': filePath === '/index.html' ? 'no-cache' : 'public, max-age=3600',
},
});
} catch (error) {
// File not found - serve index.html for Angular routing
if (error instanceof Deno.errors.NotFound) {
try {
const indexFile = await Deno.readFile('./ui/dist/index.html');
return new Response(indexFile, {
headers: {
'Content-Type': 'text/html',
'Cache-Control': 'no-cache',
},
});
} catch {
return new Response('UI not built. Run: cd ui && npm run build', {
status: 404,
headers: { 'Content-Type': 'text/plain' },
});
}
}
return new Response('File not found', {
status: 404,
headers: { 'Content-Type': 'text/plain' },
});
}
}
/**
* Get content type for file
*/
private getContentType(path: string): string {
const ext = path.split('.').pop()?.toLowerCase();
const mimeTypes: Record<string, string> = {
'html': 'text/html',
'css': 'text/css',
'js': 'application/javascript',
'json': 'application/json',
'png': 'image/png',
'jpg': 'image/jpeg',
'jpeg': 'image/jpeg',
'gif': 'image/gif',
'svg': 'image/svg+xml',
'ico': 'image/x-icon',
'woff': 'font/woff',
'woff2': 'font/woff2',
'ttf': 'font/ttf',
'eot': 'application/vnd.ms-fontobject',
};
return mimeTypes[ext || ''] || 'application/octet-stream';
}
/**
* Handle API requests
*/
@@ -101,8 +178,14 @@ export class OneboxHttpServer {
}
// Route to appropriate handler
if (path === '/api/status' && method === 'GET') {
if (path === '/api/auth/login' && method === 'POST') {
return await this.handleLoginRequest(req);
} else if (path === '/api/status' && method === 'GET') {
return await this.handleStatusRequest();
} else if (path === '/api/settings' && method === 'GET') {
return await this.handleGetSettingsRequest();
} else if (path === '/api/settings' && method === 'PUT') {
return await this.handleUpdateSettingsRequest(req);
} else if (path === '/api/services' && method === 'GET') {
return await this.handleListServicesRequest();
} else if (path === '/api/services' && method === 'POST') {
@@ -132,6 +215,58 @@ export class OneboxHttpServer {
// API Handlers
private async handleLoginRequest(req: Request): Promise<Response> {
try {
const body = await req.json();
const { username, password } = body;
logger.info(`Login attempt for user: ${username}`);
if (!username || !password) {
return this.jsonResponse(
{ success: false, error: 'Username and password required' },
400
);
}
// Get user from database
const user = this.oneboxRef.database.getUserByUsername(username);
if (!user) {
logger.info(`User not found: ${username}`);
return this.jsonResponse({ success: false, error: 'Invalid credentials' }, 401);
}
logger.info(`User found: ${username}, checking password...`);
// Verify password (simple base64 comparison for now)
const passwordHash = btoa(password);
logger.info(`Password hash: ${passwordHash}, stored hash: ${user.passwordHash}`);
if (passwordHash !== user.passwordHash) {
logger.info(`Password mismatch for user: ${username}`);
return this.jsonResponse({ success: false, error: 'Invalid credentials' }, 401);
}
// Generate simple token (in production, use proper JWT)
const token = btoa(`${user.username}:${Date.now()}`);
return this.jsonResponse({
success: true,
data: {
token,
user: {
username: user.username,
role: user.role,
},
},
});
} catch (error) {
logger.error(`Login error: ${error.message}`);
return this.jsonResponse({ success: false, error: 'Login failed' }, 500);
}
}
private async handleStatusRequest(): Promise<Response> {
const status = await this.oneboxRef.getSystemStatus();
return this.jsonResponse({ success: true, data: status });
@@ -181,6 +316,40 @@ export class OneboxHttpServer {
return this.jsonResponse({ success: true, data: logs });
}
private async handleGetSettingsRequest(): Promise<Response> {
const settings = this.oneboxRef.database.getAllSettings();
return this.jsonResponse({ success: true, data: settings });
}
private async handleUpdateSettingsRequest(req: Request): Promise<Response> {
try {
const body = await req.json();
if (!body || typeof body !== 'object') {
return this.jsonResponse(
{ success: false, error: 'Invalid request body' },
400
);
}
// Update each setting
for (const [key, value] of Object.entries(body)) {
if (typeof value === 'string') {
this.oneboxRef.database.setSetting(key, value);
logger.info(`Setting updated: ${key}`);
}
}
return this.jsonResponse({
success: true,
message: 'Settings updated successfully'
});
} catch (error) {
logger.error(`Failed to update settings: ${error.message}`);
return this.jsonResponse({ success: false, error: 'Failed to update settings' }, 500);
}
}
/**
* Helper to create JSON response
*/

View File

@@ -4,23 +4,23 @@
* Coordinates all components and provides the main API
*/
import { logger } from './onebox.logging.ts';
import { OneboxDatabase } from './onebox.classes.database.ts';
import { OneboxDockerManager } from './onebox.classes.docker.ts';
import { OneboxServicesManager } from './onebox.classes.services.ts';
import { OneboxRegistriesManager } from './onebox.classes.registries.ts';
import { OneboxNginxManager } from './onebox.classes.nginx.ts';
import { OneboxDnsManager } from './onebox.classes.dns.ts';
import { OneboxSslManager } from './onebox.classes.ssl.ts';
import { OneboxDaemon } from './onebox.classes.daemon.ts';
import { OneboxHttpServer } from './onebox.classes.httpserver.ts';
import { logger } from '../logging.ts';
import { OneboxDatabase } from './database.ts';
import { OneboxDockerManager } from './docker.ts';
import { OneboxServicesManager } from './services.ts';
import { OneboxRegistriesManager } from './registries.ts';
import { OneboxReverseProxy } from './reverseproxy.ts';
import { OneboxDnsManager } from './dns.ts';
import { OneboxSslManager } from './ssl.ts';
import { OneboxDaemon } from './daemon.ts';
import { OneboxHttpServer } from './httpserver.ts';
export class Onebox {
public database: OneboxDatabase;
public docker: OneboxDockerManager;
public services: OneboxServicesManager;
public registries: OneboxRegistriesManager;
public nginx: OneboxNginxManager;
public reverseProxy: OneboxReverseProxy;
public dns: OneboxDnsManager;
public ssl: OneboxSslManager;
public daemon: OneboxDaemon;
@@ -36,7 +36,7 @@ export class Onebox {
this.docker = new OneboxDockerManager();
this.services = new OneboxServicesManager(this);
this.registries = new OneboxRegistriesManager(this);
this.nginx = new OneboxNginxManager(this);
this.reverseProxy = new OneboxReverseProxy(this);
this.dns = new OneboxDnsManager(this);
this.ssl = new OneboxSslManager(this);
this.daemon = new OneboxDaemon(this);
@@ -59,8 +59,8 @@ export class Onebox {
// Initialize Docker
await this.docker.init();
// Initialize Nginx
await this.nginx.init();
// Initialize Reverse Proxy
await this.reverseProxy.init();
// Initialize DNS (non-critical)
try {
@@ -97,15 +97,12 @@ export class Onebox {
if (!adminUser) {
logger.info('Creating default admin user...');
// Hash default password 'admin'
const passwordHash = await Deno.readTextFile('/dev/urandom').then((data) =>
// Simple hash for now - should use bcrypt
btoa('admin')
);
// Simple base64 encoding for now - should use bcrypt in production
const passwordHash = btoa('admin');
await this.database.createUser({
username: 'admin',
passwordHash: btoa('admin'), // Simple encoding for now
passwordHash,
role: 'admin',
createdAt: Date.now(),
updatedAt: Date.now(),
@@ -132,7 +129,7 @@ export class Onebox {
async getSystemStatus() {
try {
const dockerRunning = await this.docker.isDockerRunning();
const nginxStatus = await this.nginx.getStatus();
const proxyStatus = this.reverseProxy.getStatus();
const dnsConfigured = this.dns.isConfigured();
const sslConfigured = this.ssl.isConfigured();
@@ -145,10 +142,7 @@ export class Onebox {
running: dockerRunning,
version: dockerRunning ? await this.docker.getDockerVersion() : null,
},
nginx: {
status: nginxStatus,
installed: await this.nginx.isInstalled(),
},
reverseProxy: proxyStatus,
dns: {
configured: dnsConfigured,
},
@@ -209,6 +203,9 @@ export class Onebox {
// Stop HTTP server if running
await this.httpServer.stop();
// Stop reverse proxy if running
await this.reverseProxy.stop();
// Close database
this.database.close();

View File

@@ -4,10 +4,10 @@
* Manages Docker registry credentials and authentication
*/
import * as plugins from './onebox.plugins.ts';
import type { IRegistry } from './onebox.types.ts';
import { logger } from './onebox.logging.ts';
import { OneboxDatabase } from './onebox.classes.database.ts';
import * as plugins from '../plugins.ts';
import type { IRegistry } from '../types.ts';
import { logger } from '../logging.ts';
import { OneboxDatabase } from './database.ts';
export class OneboxRegistriesManager {
private oneboxRef: any; // Will be Onebox instance

495
ts/classes/reverseproxy.ts Normal file
View File

@@ -0,0 +1,495 @@
/**
* Reverse Proxy for Onebox
*
* Native Deno HTTP/HTTPS reverse proxy with WebSocket support
*/
import { logger } from '../logging.ts';
import { OneboxDatabase } from './database.ts';
interface IProxyRoute {
domain: string;
targetHost: string;
targetPort: number;
serviceId: number;
containerID?: string;
}
interface ITlsConfig {
domain: string;
certPath: string;
keyPath: string;
}
export class OneboxReverseProxy {
private oneboxRef: any;
private database: OneboxDatabase;
private routes: Map<string, IProxyRoute> = new Map();
private httpServer: Deno.HttpServer | null = null;
private httpsServer: Deno.HttpServer | null = null;
private httpPort = 80;
private httpsPort = 443;
private tlsConfigs: Map<string, ITlsConfig> = new Map();
constructor(oneboxRef: any) {
this.oneboxRef = oneboxRef;
this.database = oneboxRef.database;
}
/**
* Initialize reverse proxy
*/
async init(): Promise<void> {
try {
logger.info('Reverse proxy initialized');
} catch (error) {
logger.error(`Failed to initialize reverse proxy: ${error.message}`);
throw error;
}
}
/**
* Start the HTTP reverse proxy server
*/
async startHttp(port?: number): Promise<void> {
if (this.httpServer) {
logger.warn('HTTP reverse proxy already running');
return;
}
if (port) {
this.httpPort = port;
}
try {
logger.info(`Starting HTTP reverse proxy on port ${this.httpPort}...`);
this.httpServer = Deno.serve(
{
port: this.httpPort,
hostname: '0.0.0.0',
onListen: ({ hostname, port }) => {
logger.success(`HTTP reverse proxy listening on http://${hostname}:${port}`);
},
},
(req) => this.handleRequest(req, false)
);
logger.success(`HTTP reverse proxy started on port ${this.httpPort}`);
} catch (error) {
logger.error(`Failed to start HTTP reverse proxy: ${error.message}`);
throw error;
}
}
/**
* Start the HTTPS reverse proxy server
*/
async startHttps(port?: number): Promise<void> {
if (this.httpsServer) {
logger.warn('HTTPS reverse proxy already running');
return;
}
if (port) {
this.httpsPort = port;
}
try {
// Check if we have any TLS configs
if (this.tlsConfigs.size === 0) {
logger.info('No TLS certificates configured, skipping HTTPS server');
return;
}
logger.info(`Starting HTTPS reverse proxy on port ${this.httpsPort}...`);
// Get the first certificate as default
const defaultConfig = Array.from(this.tlsConfigs.values())[0];
this.httpsServer = Deno.serve(
{
port: this.httpsPort,
hostname: '0.0.0.0',
cert: await Deno.readTextFile(defaultConfig.certPath),
key: await Deno.readTextFile(defaultConfig.keyPath),
onListen: ({ hostname, port }) => {
logger.success(`HTTPS reverse proxy listening on https://${hostname}:${port}`);
},
},
(req) => this.handleRequest(req, true)
);
logger.success(`HTTPS reverse proxy started on port ${this.httpsPort}`);
} catch (error) {
logger.error(`Failed to start HTTPS reverse proxy: ${error.message}`);
// Don't throw - HTTPS is optional
logger.warn('Continuing without HTTPS support');
}
}
/**
* Stop all reverse proxy servers
*/
async stop(): Promise<void> {
const promises: Promise<void>[] = [];
if (this.httpServer) {
promises.push(this.httpServer.shutdown());
this.httpServer = null;
logger.info('HTTP reverse proxy stopped');
}
if (this.httpsServer) {
promises.push(this.httpsServer.shutdown());
this.httpsServer = null;
logger.info('HTTPS reverse proxy stopped');
}
await Promise.all(promises);
}
/**
* Handle incoming HTTP/HTTPS request
*/
private async handleRequest(req: Request, isHttps: boolean): Promise<Response> {
const url = new URL(req.url);
const host = req.headers.get('host')?.split(':')[0] || '';
logger.debug(`Proxy request: ${req.method} ${host}${url.pathname}`);
// Find matching route
const route = this.routes.get(host);
if (!route) {
logger.debug(`No route found for host: ${host}`);
return new Response('Service not found', {
status: 404,
headers: { 'Content-Type': 'text/plain' },
});
}
// Check if this is a WebSocket upgrade request
const upgrade = req.headers.get('upgrade')?.toLowerCase();
if (upgrade === 'websocket') {
return await this.handleWebSocketUpgrade(req, route, isHttps);
}
try {
// Build target URL
const targetUrl = `http://${route.targetHost}:${route.targetPort}${url.pathname}${url.search}`;
logger.debug(`Proxying to: ${targetUrl}`);
// Forward request to target
const targetReq = new Request(targetUrl, {
method: req.method,
headers: this.forwardHeaders(req.headers, host, isHttps),
body: req.body,
});
const response = await fetch(targetReq);
// Forward response back to client
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers: this.filterResponseHeaders(response.headers),
});
} catch (error) {
logger.error(`Proxy error for ${host}: ${error.message}`);
return new Response('Bad Gateway', {
status: 502,
headers: { 'Content-Type': 'text/plain' },
});
}
}
/**
* Handle WebSocket upgrade and proxy connection
*/
private async handleWebSocketUpgrade(
req: Request,
route: IProxyRoute,
isHttps: boolean
): Promise<Response> {
try {
const url = new URL(req.url);
const targetUrl = `ws://${route.targetHost}:${route.targetPort}${url.pathname}${url.search}`;
logger.info(`WebSocket upgrade: ${url.host} -> ${targetUrl}`);
// Upgrade the client connection
const { socket: clientSocket, response } = Deno.upgradeWebSocket(req);
// Connect to backend WebSocket
const backendSocket = new WebSocket(targetUrl);
// Proxy messages from client to backend
clientSocket.onmessage = (e) => {
if (backendSocket.readyState === WebSocket.OPEN) {
backendSocket.send(e.data);
}
};
// Proxy messages from backend to client
backendSocket.onmessage = (e) => {
if (clientSocket.readyState === WebSocket.OPEN) {
clientSocket.send(e.data);
}
};
// Handle client close
clientSocket.onclose = () => {
logger.debug(`Client WebSocket closed for ${url.host}`);
backendSocket.close();
};
// Handle backend close
backendSocket.onclose = () => {
logger.debug(`Backend WebSocket closed for ${targetUrl}`);
clientSocket.close();
};
// Handle errors
clientSocket.onerror = (e) => {
logger.error(`Client WebSocket error: ${e}`);
backendSocket.close();
};
backendSocket.onerror = (e) => {
logger.error(`Backend WebSocket error: ${e}`);
clientSocket.close();
};
return response;
} catch (error) {
logger.error(`WebSocket upgrade error: ${error.message}`);
return new Response('WebSocket Upgrade Failed', {
status: 500,
headers: { 'Content-Type': 'text/plain' },
});
}
}
/**
* Forward request headers to target, filtering out problematic ones
*/
private forwardHeaders(headers: Headers, originalHost: string, isHttps: boolean): Headers {
const forwarded = new Headers();
// Copy most headers
for (const [key, value] of headers.entries()) {
// Skip headers that should not be forwarded
if (
key.toLowerCase() === 'host' ||
key.toLowerCase() === 'connection' ||
key.toLowerCase() === 'keep-alive' ||
key.toLowerCase() === 'proxy-authenticate' ||
key.toLowerCase() === 'proxy-authorization' ||
key.toLowerCase() === 'te' ||
key.toLowerCase() === 'trailers' ||
key.toLowerCase() === 'transfer-encoding' ||
key.toLowerCase() === 'upgrade'
) {
continue;
}
forwarded.set(key, value);
}
// Add X-Forwarded headers
forwarded.set('X-Forwarded-For', headers.get('x-forwarded-for') || 'unknown');
forwarded.set('X-Forwarded-Host', originalHost);
forwarded.set('X-Forwarded-Proto', isHttps ? 'https' : 'http');
return forwarded;
}
/**
* Filter response headers
*/
private filterResponseHeaders(headers: Headers): Headers {
const filtered = new Headers();
for (const [key, value] of headers.entries()) {
// Skip problematic headers
if (
key.toLowerCase() === 'connection' ||
key.toLowerCase() === 'keep-alive' ||
key.toLowerCase() === 'transfer-encoding'
) {
continue;
}
filtered.set(key, value);
}
return filtered;
}
/**
* Add a route for a service
*/
async addRoute(serviceId: number, domain: string, targetPort: number): Promise<void> {
try {
// Get container IP from Docker
const service = this.database.getServiceByID(serviceId);
if (!service || !service.containerID) {
throw new Error(`Service not found or has no container: ${serviceId}`);
}
// For Docker, we can use the container name or get its IP
// For now, use localhost since containers expose ports
const targetHost = 'localhost'; // TODO: Get actual container IP from Docker network
const route: IProxyRoute = {
domain,
targetHost,
targetPort,
serviceId,
};
this.routes.set(domain, route);
logger.success(`Added proxy route: ${domain} -> ${targetHost}:${targetPort}`);
} catch (error) {
logger.error(`Failed to add route for ${domain}: ${error.message}`);
throw error;
}
}
/**
* Remove a route
*/
removeRoute(domain: string): void {
if (this.routes.delete(domain)) {
logger.success(`Removed proxy route: ${domain}`);
} else {
logger.warn(`Route not found: ${domain}`);
}
}
/**
* Get all routes
*/
getRoutes(): IProxyRoute[] {
return Array.from(this.routes.values());
}
/**
* Reload routes from database
*/
async reloadRoutes(): Promise<void> {
try {
logger.info('Reloading proxy routes...');
this.routes.clear();
const services = this.database.getAllServices();
for (const service of services) {
if (service.domain && service.status === 'running' && service.containerID) {
await this.addRoute(service.id!, service.domain, service.port);
}
}
logger.success(`Loaded ${this.routes.size} proxy routes`);
} catch (error) {
logger.error(`Failed to reload routes: ${error.message}`);
throw error;
}
}
/**
* Add TLS certificate for a domain
*/
async addCertificate(domain: string, certPath: string, keyPath: string): Promise<void> {
try {
// Verify certificate files exist
await Deno.stat(certPath);
await Deno.stat(keyPath);
this.tlsConfigs.set(domain, {
domain,
certPath,
keyPath,
});
logger.success(`Added TLS certificate for ${domain}`);
// If HTTPS server is already running, we need to restart it
// TODO: Implement hot reload for certificates
if (this.httpsServer) {
logger.warn('HTTPS server restart required for new certificate to take effect');
}
} catch (error) {
logger.error(`Failed to add certificate for ${domain}: ${error.message}`);
throw error;
}
}
/**
* Remove TLS certificate for a domain
*/
removeCertificate(domain: string): void {
if (this.tlsConfigs.delete(domain)) {
logger.success(`Removed TLS certificate for ${domain}`);
} else {
logger.warn(`Certificate not found for domain: ${domain}`);
}
}
/**
* Reload TLS certificates from SSL manager
*/
async reloadCertificates(): Promise<void> {
try {
logger.info('Reloading TLS certificates...');
this.tlsConfigs.clear();
const certificates = this.database.getAllSSLCertificates();
for (const cert of certificates) {
if (cert.domain && cert.certPath && cert.keyPath) {
try {
await this.addCertificate(cert.domain, cert.fullChainPath, cert.keyPath);
} catch (error) {
logger.warn(`Failed to load certificate for ${cert.domain}: ${error.message}`);
}
}
}
logger.success(`Loaded ${this.tlsConfigs.size} TLS certificates`);
// Restart HTTPS server if it was running
if (this.httpsServer) {
logger.info('Restarting HTTPS server with new certificates...');
await this.httpsServer.shutdown();
this.httpsServer = null;
await this.startHttps();
}
} catch (error) {
logger.error(`Failed to reload certificates: ${error.message}`);
throw error;
}
}
/**
* Get status of reverse proxy
*/
getStatus() {
return {
http: {
running: this.httpServer !== null,
port: this.httpPort,
},
https: {
running: this.httpsServer !== null,
port: this.httpsPort,
certificates: this.tlsConfigs.size,
},
routes: this.routes.size,
};
}
}

View File

@@ -4,10 +4,10 @@
* Orchestrates service deployment: Docker + Nginx + DNS + SSL
*/
import type { IService, IServiceDeployOptions } from './onebox.types.ts';
import { logger } from './onebox.logging.ts';
import { OneboxDatabase } from './onebox.classes.database.ts';
import { OneboxDockerManager } from './onebox.classes.docker.ts';
import type { IService, IServiceDeployOptions } from '../types.ts';
import { logger } from '../logging.ts';
import { OneboxDatabase } from './database.ts';
import { OneboxDockerManager } from './docker.ts';
export class OneboxServicesManager {
private oneboxRef: any; // Will be Onebox instance
@@ -77,19 +77,18 @@ export class OneboxServicesManager {
}
}
// Configure nginx
// Configure reverse proxy
try {
await this.oneboxRef.nginx.createConfig(service.id!, options.domain, options.port);
await this.oneboxRef.nginx.reload();
await this.oneboxRef.reverseProxy.addRoute(service.id!, options.domain, options.port);
} catch (error) {
logger.warn(`Failed to configure Nginx for ${options.domain}: ${error.message}`);
logger.warn(`Failed to configure reverse proxy for ${options.domain}: ${error.message}`);
}
// Configure SSL (if autoSSL is enabled)
if (options.autoSSL !== false) {
try {
await this.oneboxRef.ssl.obtainCertificate(options.domain);
await this.oneboxRef.nginx.reload();
await this.oneboxRef.reverseProxy.reloadCertificates();
} catch (error) {
logger.warn(`Failed to obtain SSL certificate for ${options.domain}: ${error.message}`);
}
@@ -215,13 +214,12 @@ export class OneboxServicesManager {
}
}
// Remove nginx config
// Remove reverse proxy route
if (service.domain) {
try {
await this.oneboxRef.nginx.removeConfig(service.id!);
await this.oneboxRef.nginx.reload();
this.oneboxRef.reverseProxy.removeRoute(service.domain);
} catch (error) {
logger.warn(`Failed to remove Nginx config: ${error.message}`);
logger.warn(`Failed to remove reverse proxy route: ${error.message}`);
}
// Note: We don't remove DNS records or SSL certs automatically

View File

@@ -4,9 +4,9 @@
* Manages SSL certificates via Let's Encrypt (using smartacme)
*/
import * as plugins from './onebox.plugins.ts';
import { logger } from './onebox.logging.ts';
import { OneboxDatabase } from './onebox.classes.database.ts';
import * as plugins from '../plugins.ts';
import { logger } from '../logging.ts';
import { OneboxDatabase } from './database.ts';
export class OneboxSslManager {
private oneboxRef: any;
@@ -106,8 +106,8 @@ export class OneboxSslManager {
});
}
// Enable SSL in nginx config
await this.oneboxRef.nginx.enableSSL(domain);
// Reload certificates in reverse proxy
await this.oneboxRef.reverseProxy.reloadCertificates();
logger.success(`SSL certificate obtained for ${domain}`);
} catch (error) {
@@ -182,8 +182,8 @@ export class OneboxSslManager {
logger.success(`Certificate renewed for ${domain}`);
// Reload nginx
await this.oneboxRef.nginx.reload();
// Reload certificates in reverse proxy
await this.oneboxRef.reverseProxy.reloadCertificates();
} catch (error) {
logger.error(`Failed to renew certificate for ${domain}: ${error.message}`);
throw error;
@@ -256,8 +256,8 @@ export class OneboxSslManager {
logger.success('All certificates renewed');
// Reload nginx
await this.oneboxRef.nginx.reload();
// Reload certificates in reverse proxy
await this.oneboxRef.reverseProxy.reloadCertificates();
} catch (error) {
logger.error(`Failed to renew all certificates: ${error.message}`);
throw error;

View File

@@ -2,9 +2,10 @@
* CLI Router for Onebox
*/
import { logger } from './onebox.logging.ts';
import { projectInfo } from './onebox.info.ts';
import { Onebox } from './onebox.classes.onebox.ts';
import { logger } from './logging.ts';
import { projectInfo } from './info.ts';
import { Onebox } from './classes/onebox.ts';
import { OneboxDaemon } from './classes/daemon.ts';
export async function runCli(): Promise<void> {
const args = Deno.args;
@@ -23,6 +24,15 @@ export async function runCli(): Promise<void> {
const subcommand = args[1];
try {
// Server command has special handling (doesn't shut down)
if (command === 'server') {
const onebox = new Onebox();
await onebox.init();
await handleServerCommand(onebox, args.slice(1));
// Server command runs forever (or until Ctrl+C), so this never returns
return;
}
// Initialize Onebox
const onebox = new Onebox();
await onebox.init();
@@ -239,6 +249,62 @@ async function handleNginxCommand(onebox: Onebox, subcommand: string, _args: str
}
}
// Server command
async function handleServerCommand(onebox: Onebox, args: string[]) {
const ephemeral = args.includes('--ephemeral');
const port = parseInt(getArg(args, '--port') || '3000', 10);
const monitor = args.includes('--monitor') || ephemeral; // ephemeral includes monitoring
if (ephemeral) {
// Ensure no daemon is running
try {
await OneboxDaemon.ensureNoDaemon();
} catch (error) {
logger.error('Cannot start in ephemeral mode: Daemon is already running');
logger.info('Stop the daemon first: onebox daemon stop');
logger.info('Or run without --ephemeral to use the existing daemon');
Deno.exit(1);
}
}
logger.info('Starting Onebox server...');
// Start HTTP server
await onebox.httpServer.start(port);
// Start monitoring if requested
if (monitor) {
logger.info('Starting monitoring loop...');
onebox.daemon.startMonitoring();
}
logger.success(`Onebox server running on http://localhost:${port}`);
if (ephemeral) {
logger.info('Running in ephemeral mode - Press Ctrl+C to stop');
} else {
logger.info('Press Ctrl+C to stop');
}
// Setup signal handlers
const shutdown = async () => {
logger.info('Shutting down...');
if (monitor) {
onebox.daemon.stopMonitoring();
}
await onebox.httpServer.stop();
await onebox.shutdown();
Deno.exit(0);
};
Deno.addSignalListener('SIGINT', shutdown);
Deno.addSignalListener('SIGTERM', shutdown);
// Keep alive
while (true) {
await new Promise(resolve => setTimeout(resolve, 1000));
}
}
// Daemon commands
async function handleDaemonCommand(onebox: Onebox, subcommand: string, _args: string[]) {
switch (subcommand) {
@@ -316,6 +382,12 @@ Onebox v${projectInfo.version} - Self-hosted container platform
Usage: onebox <command> [options]
Commands:
server [--ephemeral] [--port <port>] [--monitor]
Start HTTP server
--ephemeral: Run in foreground (development mode, checks no daemon running)
--port: HTTP server port (default: 3000)
--monitor: Enable monitoring loop (included with --ephemeral)
service add <name> --image <image> [--domain <domain>] [--port <port>] [--env KEY=VALUE]
service remove <name>
service start <name>
@@ -357,7 +429,17 @@ Options:
--version, -v Show version
--debug Enable debug logging
Development Workflow:
deno task dev # Start ephemeral server with monitoring
onebox service add ... # In another terminal
Production Workflow:
onebox daemon install # Install systemd service
onebox daemon start # Start daemon
onebox service add ... # CLI uses daemon
Examples:
onebox server --ephemeral # Start dev server
onebox service add myapp --image nginx:latest --domain app.example.com --port 80
onebox registry add --url registry.example.com --username user --password pass
onebox daemon install

View File

@@ -2,23 +2,24 @@
* Main exports and CLI entry point for Onebox
*/
export { Onebox } from './onebox.classes.onebox.ts';
export { runCli } from './onebox.cli.ts';
export { OneboxDatabase } from './onebox.classes.database.ts';
export { OneboxDockerManager } from './onebox.classes.docker.ts';
export { OneboxServicesManager } from './onebox.classes.services.ts';
export { OneboxRegistriesManager } from './onebox.classes.registries.ts';
export { OneboxNginxManager } from './onebox.classes.nginx.ts';
export { OneboxDnsManager } from './onebox.classes.dns.ts';
export { OneboxSslManager } from './onebox.classes.ssl.ts';
export { OneboxDaemon } from './onebox.classes.daemon.ts';
export { OneboxHttpServer } from './onebox.classes.httpserver.ts';
export { Onebox } from './classes/onebox.ts';
export { runCli } from './cli.ts';
export { OneboxDatabase } from './classes/database.ts';
export { OneboxDockerManager } from './classes/docker.ts';
export { OneboxServicesManager } from './classes/services.ts';
export { OneboxRegistriesManager } from './classes/registries.ts';
export { OneboxReverseProxy } from './classes/reverseproxy.ts';
export { OneboxDnsManager } from './classes/dns.ts';
export { OneboxSslManager } from './classes/ssl.ts';
export { OneboxDaemon } from './classes/daemon.ts';
export { OneboxHttpServer } from './classes/httpserver.ts';
export { OneboxApiClient } from './classes/apiclient.ts';
// Types
export * from './onebox.types.ts';
export * from './types.ts';
// Logging
export { logger } from './onebox.logging.ts';
export { logger } from './logging.ts';
// Version info
export { projectInfo } from './onebox.info.ts';
export { projectInfo } from './info.ts';

View File

@@ -1,345 +0,0 @@
/**
* Nginx Manager for Onebox
*
* Manages Nginx reverse proxy configurations for services
*/
import * as plugins from './onebox.plugins.ts';
import { logger } from './onebox.logging.ts';
import { OneboxDatabase } from './onebox.classes.database.ts';
export class OneboxNginxManager {
private oneboxRef: any;
private database: OneboxDatabase;
private configDir = '/etc/nginx/sites-available';
private enabledDir = '/etc/nginx/sites-enabled';
constructor(oneboxRef: any) {
this.oneboxRef = oneboxRef;
this.database = oneboxRef.database;
// Allow custom nginx config directory
const customDir = this.database.getSetting('nginxConfigDir');
if (customDir) {
this.configDir = customDir;
}
}
/**
* Initialize nginx manager
*/
async init(): Promise<void> {
try {
// Ensure directories exist
await Deno.mkdir(this.configDir, { recursive: true });
await Deno.mkdir(this.enabledDir, { recursive: true });
logger.info('Nginx manager initialized');
} catch (error) {
logger.error(`Failed to initialize Nginx manager: ${error.message}`);
throw error;
}
}
/**
* Create nginx config for a service
*/
async createConfig(serviceId: number, domain: string, port: number): Promise<void> {
try {
logger.info(`Creating Nginx config for ${domain}`);
const service = this.database.getServiceByID(serviceId);
if (!service) {
throw new Error(`Service not found: ${serviceId}`);
}
// Get container IP (or use container name for DNS resolution within Docker network)
const containerName = `onebox-${service.name}`;
// Generate config
const config = this.generateConfig(domain, containerName, port);
// Write config file
const configPath = `${this.configDir}/onebox-${service.name}.conf`;
await Deno.writeTextFile(configPath, config);
// Create symlink in sites-enabled
const enabledPath = `${this.enabledDir}/onebox-${service.name}.conf`;
try {
await Deno.remove(enabledPath);
} catch {
// Ignore if doesn't exist
}
await Deno.symlink(configPath, enabledPath);
logger.success(`Nginx config created: ${domain}`);
} catch (error) {
logger.error(`Failed to create Nginx config: ${error.message}`);
throw error;
}
}
/**
* Generate nginx configuration
*/
private generateConfig(domain: string, upstream: string, port: number): string {
return `# Onebox-managed configuration for ${domain}
# Generated at ${new Date().toISOString()}
upstream onebox_${domain.replace(/\./g, '_')} {
server ${upstream}:${port};
}
# HTTP server (redirect to HTTPS or serve directly)
server {
listen 80;
listen [::]:80;
server_name ${domain};
# ACME challenge for Let's Encrypt
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
# Redirect to HTTPS (will be enabled after SSL is configured)
# location / {
# return 301 https://$server_name$request_uri;
# }
# Proxy to container (remove after SSL is configured)
location / {
proxy_pass http://onebox_${domain.replace(/\./g, '_')};
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket support
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# Timeouts
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
}
# HTTPS server (uncomment after SSL is configured)
# server {
# listen 443 ssl http2;
# listen [::]:443 ssl http2;
# server_name ${domain};
#
# ssl_certificate /etc/letsencrypt/live/${domain}/fullchain.pem;
# ssl_certificate_key /etc/letsencrypt/live/${domain}/privkey.pem;
#
# # SSL configuration
# ssl_protocols TLSv1.2 TLSv1.3;
# ssl_ciphers HIGH:!aNULL:!MD5;
# ssl_prefer_server_ciphers on;
#
# location / {
# proxy_pass http://onebox_${domain.replace(/\./g, '_')};
# proxy_set_header Host $host;
# proxy_set_header X-Real-IP $remote_addr;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# proxy_set_header X-Forwarded-Proto $scheme;
#
# # WebSocket support
# proxy_http_version 1.1;
# proxy_set_header Upgrade $http_upgrade;
# proxy_set_header Connection "upgrade";
#
# # Timeouts
# proxy_connect_timeout 60s;
# proxy_send_timeout 60s;
# proxy_read_timeout 60s;
# }
# }
`;
}
/**
* Update nginx config to enable SSL
*/
async enableSSL(domain: string): Promise<void> {
try {
logger.info(`Enabling SSL for ${domain}`);
// Find service by domain
const services = this.database.getAllServices();
const service = services.find((s) => s.domain === domain);
if (!service) {
throw new Error(`Service not found for domain: ${domain}`);
}
const configPath = `${this.configDir}/onebox-${service.name}.conf`;
let config = await Deno.readTextFile(configPath);
// Enable HTTPS redirect and HTTPS server block
config = config.replace('# location / {\n # return 301', 'location / {\n return 301');
config = config.replace('# }', '}');
config = config.replace(/# server \{[\s\S]*?# \}/m, (match) =>
match.replace(/# /g, '')
);
// Comment out HTTP proxy location
config = config.replace(
/# Proxy to container \(remove after SSL is configured\)[\s\S]*?location \/ \{[\s\S]*?\n \}/,
(match) => `# ${match.replace(/\n/g, '\n # ')}`
);
await Deno.writeTextFile(configPath, config);
logger.success(`SSL enabled for ${domain}`);
} catch (error) {
logger.error(`Failed to enable SSL for ${domain}: ${error.message}`);
throw error;
}
}
/**
* Remove nginx config for a service
*/
async removeConfig(serviceId: number): Promise<void> {
try {
const service = this.database.getServiceByID(serviceId);
if (!service) {
throw new Error(`Service not found: ${serviceId}`);
}
logger.info(`Removing Nginx config for ${service.name}`);
// Remove symlink
const enabledPath = `${this.enabledDir}/onebox-${service.name}.conf`;
try {
await Deno.remove(enabledPath);
} catch {
// Ignore if doesn't exist
}
// Remove config file
const configPath = `${this.configDir}/onebox-${service.name}.conf`;
try {
await Deno.remove(configPath);
} catch {
// Ignore if doesn't exist
}
logger.success(`Nginx config removed for ${service.name}`);
} catch (error) {
logger.error(`Failed to remove Nginx config: ${error.message}`);
throw error;
}
}
/**
* Test nginx configuration
*/
async test(): Promise<boolean> {
try {
const command = new Deno.Command('nginx', {
args: ['-t'],
stdout: 'piped',
stderr: 'piped',
});
const { code, stderr } = await command.output();
if (code !== 0) {
const errorMsg = new TextDecoder().decode(stderr);
logger.error(`Nginx config test failed: ${errorMsg}`);
return false;
}
logger.success('Nginx configuration is valid');
return true;
} catch (error) {
logger.error(`Failed to test Nginx config: ${error.message}`);
return false;
}
}
/**
* Reload nginx
*/
async reload(): Promise<void> {
try {
// Test config first
const isValid = await this.test();
if (!isValid) {
throw new Error('Nginx configuration is invalid');
}
logger.info('Reloading Nginx...');
const command = new Deno.Command('systemctl', {
args: ['reload', 'nginx'],
stdout: 'piped',
stderr: 'piped',
});
const { code, stderr } = await command.output();
if (code !== 0) {
const errorMsg = new TextDecoder().decode(stderr);
throw new Error(`Nginx reload failed: ${errorMsg}`);
}
logger.success('Nginx reloaded successfully');
} catch (error) {
logger.error(`Failed to reload Nginx: ${error.message}`);
throw error;
}
}
/**
* Get nginx status
*/
async getStatus(): Promise<string> {
try {
const command = new Deno.Command('systemctl', {
args: ['status', 'nginx'],
stdout: 'piped',
stderr: 'piped',
});
const { code, stdout } = await command.output();
const output = new TextDecoder().decode(stdout);
if (code === 0 || output.includes('active (running)')) {
return 'running';
} else if (output.includes('inactive') || output.includes('dead')) {
return 'stopped';
} else if (output.includes('failed')) {
return 'failed';
} else {
return 'unknown';
}
} catch (error) {
logger.error(`Failed to get Nginx status: ${error.message}`);
return 'unknown';
}
}
/**
* Check if nginx is installed
*/
async isInstalled(): Promise<boolean> {
try {
const command = new Deno.Command('which', {
args: ['nginx'],
stdout: 'piped',
stderr: 'piped',
});
const { code } = await command.output();
return code === 0;
} catch {
return false;
}
}
}

View File

@@ -14,16 +14,16 @@ import * as encoding from '@std/encoding';
export { path, fs, http, encoding };
// Database
import * as sqlite from '@db/sqlite';
export { sqlite };
import { Database } from '@db/sqlite';
export const sqlite = { DB: Database };
// Systemd Daemon Integration
import * as smartdaemon from '@push.rocks/smartdaemon';
export { smartdaemon };
// Docker API Client
import * as docker from '@apiclient.xyz/docker';
export { docker };
import { DockerHost } from '@apiclient.xyz/docker';
export const docker = { Docker: DockerHost };
// Cloudflare DNS Management
import * as cloudflare from '@apiclient.xyz/cloudflare';
@@ -38,8 +38,8 @@ import * as bcrypt from 'https://deno.land/x/bcrypt@v0.4.1/mod.ts';
export { bcrypt };
// JWT for authentication
import { create as createJwt, verify as verifyJwt, decode as decodeJwt } from 'https://deno.land/x/djwt@v3.0.2/mod.ts';
export { createJwt, verifyJwt, decodeJwt };
import * as jwt from 'https://deno.land/x/djwt@v3.0.2/mod.ts';
export { jwt};
// Crypto key management
import { crypto } from 'https://deno.land/std@0.208.0/crypto/mod.ts';

14
ui/.gitignore vendored Normal file
View File

@@ -0,0 +1,14 @@
# Dependencies
node_modules/
# Build outputs
dist/
.angular/
# IDE
.vscode/
.idea/
# Misc
.DS_Store
*.log

77
ui/angular.json Normal file
View File

@@ -0,0 +1,77 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"onebox-ui": {
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"style": "css",
"standalone": true
}
},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:browser",
"options": {
"outputPath": "dist",
"index": "src/index.html",
"main": "src/main.ts",
"polyfills": ["zone.js"],
"tsConfig": "tsconfig.app.json",
"assets": [
"src/favicon.ico",
"src/assets"
],
"styles": [
"src/styles.css"
],
"scripts": []
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kb",
"maximumError": "1mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "2kb",
"maximumError": "4kb"
}
],
"outputHashing": "all"
},
"development": {
"buildOptimizer": false,
"optimization": false,
"vendorChunk": true,
"extractLicenses": false,
"sourceMap": true,
"namedChunks": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"configurations": {
"production": {
"buildTarget": "onebox-ui:build:production"
},
"development": {
"buildTarget": "onebox-ui:build:development"
}
},
"defaultConfiguration": "development"
}
}
}
}
}

37
ui/package.json Normal file
View File

@@ -0,0 +1,37 @@
{
"name": "onebox-ui",
"version": "1.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve --proxy-config proxy.conf.json",
"build": "ng build --configuration production",
"watch": "ng build --watch --configuration development",
"test": "ng test"
},
"private": true,
"dependencies": {
"@angular/animations": "^18.0.0",
"@angular/common": "^18.0.0",
"@angular/compiler": "^18.0.0",
"@angular/core": "^18.0.0",
"@angular/forms": "^18.0.0",
"@angular/platform-browser": "^18.0.0",
"@angular/platform-browser-dynamic": "^18.0.0",
"@angular/router": "^18.0.0",
"chart.js": "^4.4.0",
"ng2-charts": "^6.0.0",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"zone.js": "~0.14.3"
},
"devDependencies": {
"@angular-devkit/build-angular": "^18.0.0",
"@angular/cli": "^18.0.0",
"@angular/compiler-cli": "^18.0.0",
"@types/node": "^20.11.0",
"autoprefixer": "^10.4.19",
"postcss": "^8.4.38",
"tailwindcss": "^3.4.3",
"typescript": "~5.4.0"
}
}

8220
ui/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

7
ui/proxy.conf.json Normal file
View File

@@ -0,0 +1,7 @@
{
"/api": {
"target": "http://localhost:3000",
"secure": false,
"changeOrigin": true
}
}

View File

@@ -0,0 +1,10 @@
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
@Component({
selector: 'app-root',
standalone: true,
imports: [RouterOutlet],
template: `<router-outlet></router-outlet>`,
})
export class AppComponent {}

71
ui/src/app/app.routes.ts Normal file
View File

@@ -0,0 +1,71 @@
import { Routes } from '@angular/router';
import { authGuard } from './core/guards/auth.guard';
export const routes: Routes = [
{
path: 'login',
loadComponent: () =>
import('./features/login/login.component').then((m) => m.LoginComponent),
},
{
path: '',
canActivate: [authGuard],
loadComponent: () =>
import('./shared/components/layout.component').then((m) => m.LayoutComponent),
children: [
{
path: '',
redirectTo: 'dashboard',
pathMatch: 'full',
},
{
path: 'dashboard',
loadComponent: () =>
import('./features/dashboard/dashboard.component').then((m) => m.DashboardComponent),
},
{
path: 'services',
loadComponent: () =>
import('./features/services/services-list.component').then(
(m) => m.ServicesListComponent
),
},
{
path: 'services/new',
loadComponent: () =>
import('./features/services/service-create.component').then(
(m) => m.ServiceCreateComponent
),
},
{
path: 'services/:name',
loadComponent: () =>
import('./features/services/service-detail.component').then(
(m) => m.ServiceDetailComponent
),
},
{
path: 'registries',
loadComponent: () =>
import('./features/registries/registries.component').then(
(m) => m.RegistriesComponent
),
},
{
path: 'dns',
loadComponent: () =>
import('./features/dns/dns.component').then((m) => m.DnsComponent),
},
{
path: 'ssl',
loadComponent: () =>
import('./features/ssl/ssl.component').then((m) => m.SslComponent),
},
{
path: 'settings',
loadComponent: () =>
import('./features/settings/settings.component').then((m) => m.SettingsComponent),
},
],
},
];

View File

@@ -0,0 +1,15 @@
import { inject } from '@angular/core';
import { Router, CanActivateFn } from '@angular/router';
import { AuthService } from '../services/auth.service';
export const authGuard: CanActivateFn = () => {
const authService = inject(AuthService);
const router = inject(Router);
if (authService.isAuthenticated()) {
return true;
}
router.navigate(['/login']);
return false;
};

View File

@@ -0,0 +1,18 @@
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { AuthService } from '../services/auth.service';
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const authService = inject(AuthService);
const token = authService.getToken();
if (token && !req.url.includes('/api/auth/login')) {
req = req.clone({
setHeaders: {
Authorization: `Bearer ${token}`,
},
});
}
return next(req);
};

View File

@@ -0,0 +1,144 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
export interface ApiResponse<T = any> {
success: boolean;
data?: T;
error?: string;
message?: string;
}
export interface Service {
id: number;
name: string;
image: string;
registry?: string;
envVars: Record<string, string>;
port: number;
domain?: string;
containerID?: string;
status: 'stopped' | 'starting' | 'running' | 'stopping' | 'failed';
createdAt: number;
updatedAt: number;
}
export interface Registry {
id: number;
url: string;
username: string;
createdAt: number;
}
export interface SystemStatus {
docker: {
running: boolean;
version: any;
};
nginx: {
status: string;
installed: boolean;
};
dns: {
configured: boolean;
};
ssl: {
configured: boolean;
certbotInstalled: boolean;
};
services: {
total: number;
running: number;
stopped: number;
};
}
@Injectable({
providedIn: 'root',
})
export class ApiService {
private http = inject(HttpClient);
private baseUrl = '/api';
// System
getStatus(): Observable<ApiResponse<SystemStatus>> {
return this.http.get<ApiResponse<SystemStatus>>(`${this.baseUrl}/status`);
}
// Services
getServices(): Observable<ApiResponse<Service[]>> {
return this.http.get<ApiResponse<Service[]>>(`${this.baseUrl}/services`);
}
getService(name: string): Observable<ApiResponse<Service>> {
return this.http.get<ApiResponse<Service>>(`${this.baseUrl}/services/${name}`);
}
createService(data: any): Observable<ApiResponse<Service>> {
return this.http.post<ApiResponse<Service>>(`${this.baseUrl}/services`, data);
}
deleteService(name: string): Observable<ApiResponse> {
return this.http.delete<ApiResponse>(`${this.baseUrl}/services/${name}`);
}
startService(name: string): Observable<ApiResponse> {
return this.http.post<ApiResponse>(`${this.baseUrl}/services/${name}/start`, {});
}
stopService(name: string): Observable<ApiResponse> {
return this.http.post<ApiResponse>(`${this.baseUrl}/services/${name}/stop`, {});
}
restartService(name: string): Observable<ApiResponse> {
return this.http.post<ApiResponse>(`${this.baseUrl}/services/${name}/restart`, {});
}
getServiceLogs(name: string): Observable<ApiResponse<string>> {
return this.http.get<ApiResponse<string>>(`${this.baseUrl}/services/${name}/logs`);
}
// Registries
getRegistries(): Observable<ApiResponse<Registry[]>> {
return this.http.get<ApiResponse<Registry[]>>(`${this.baseUrl}/registries`);
}
createRegistry(data: any): Observable<ApiResponse<Registry>> {
return this.http.post<ApiResponse<Registry>>(`${this.baseUrl}/registries`, data);
}
deleteRegistry(url: string): Observable<ApiResponse> {
return this.http.delete<ApiResponse>(`${this.baseUrl}/registries/${encodeURIComponent(url)}`);
}
// DNS
getDnsRecords(): Observable<ApiResponse<any[]>> {
return this.http.get<ApiResponse<any[]>>(`${this.baseUrl}/dns`);
}
createDnsRecord(data: any): Observable<ApiResponse> {
return this.http.post<ApiResponse>(`${this.baseUrl}/dns`, data);
}
deleteDnsRecord(domain: string): Observable<ApiResponse> {
return this.http.delete<ApiResponse>(`${this.baseUrl}/dns/${domain}`);
}
// SSL
getSslCertificates(): Observable<ApiResponse<any[]>> {
return this.http.get<ApiResponse<any[]>>(`${this.baseUrl}/ssl`);
}
renewSslCertificate(domain: string): Observable<ApiResponse> {
return this.http.post<ApiResponse>(`${this.baseUrl}/ssl/${domain}/renew`, {});
}
// Settings
getSettings(): Observable<ApiResponse<Record<string, string>>> {
return this.http.get<ApiResponse<Record<string, string>>>(`${this.baseUrl}/settings`);
}
updateSetting(key: string, value: string): Observable<ApiResponse> {
return this.http.post<ApiResponse>(`${this.baseUrl}/settings`, { key, value });
}
}

View File

@@ -0,0 +1,69 @@
import { Injectable, inject, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Router } from '@angular/router';
import { Observable, tap } from 'rxjs';
export interface LoginRequest {
username: string;
password: string;
}
export interface LoginResponse {
success: boolean;
data?: {
token: string;
user: {
username: string;
role: string;
};
};
error?: string;
}
@Injectable({
providedIn: 'root',
})
export class AuthService {
private http = inject(HttpClient);
private router = inject(Router);
isAuthenticated = signal(false);
currentUser = signal<{ username: string; role: string } | null>(null);
constructor() {
// Check if already authenticated
const token = this.getToken();
if (token) {
this.isAuthenticated.set(true);
// TODO: Decode JWT to get user info
this.currentUser.set({ username: 'admin', role: 'admin' });
}
}
login(credentials: LoginRequest): Observable<LoginResponse> {
return this.http.post<LoginResponse>('/api/auth/login', credentials).pipe(
tap((response) => {
if (response.success && response.data) {
this.setToken(response.data.token);
this.currentUser.set(response.data.user);
this.isAuthenticated.set(true);
}
})
);
}
logout(): void {
localStorage.removeItem('onebox_token');
this.isAuthenticated.set(false);
this.currentUser.set(null);
this.router.navigate(['/login']);
}
getToken(): string | null {
return localStorage.getItem('onebox_token');
}
private setToken(token: string): void {
localStorage.setItem('onebox_token', token);
}
}

View File

@@ -0,0 +1,192 @@
import { Component, OnInit, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import { ApiService, SystemStatus } from '../../core/services/api.service';
@Component({
selector: 'app-dashboard',
standalone: true,
imports: [CommonModule, RouterLink],
template: `
<div class="px-4 sm:px-0">
<h1 class="text-3xl font-bold text-gray-900 mb-8">Dashboard</h1>
@if (loading()) {
<div class="text-center py-12">
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
</div>
} @else if (status()) {
<!-- Stats Grid -->
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4 mb-8">
<!-- Total Services -->
<div class="card">
<div class="flex items-center">
<div class="flex-shrink-0 bg-primary-500 rounded-md p-3">
<svg class="h-6 w-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Total Services</dt>
<dd class="text-3xl font-semibold text-gray-900">{{ status()!.services.total }}</dd>
</dl>
</div>
</div>
</div>
<!-- Running Services -->
<div class="card">
<div class="flex items-center">
<div class="flex-shrink-0 bg-green-500 rounded-md p-3">
<svg class="h-6 w-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Running</dt>
<dd class="text-3xl font-semibold text-gray-900">{{ status()!.services.running }}</dd>
</dl>
</div>
</div>
</div>
<!-- Stopped Services -->
<div class="card">
<div class="flex items-center">
<div class="flex-shrink-0 bg-gray-500 rounded-md p-3">
<svg class="h-6 w-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Stopped</dt>
<dd class="text-3xl font-semibold text-gray-900">{{ status()!.services.stopped }}</dd>
</dl>
</div>
</div>
</div>
<!-- Docker Status -->
<div class="card">
<div class="flex items-center">
<div class="flex-shrink-0 rounded-md p-3" [ngClass]="status()!.docker.running ? 'bg-green-500' : 'bg-red-500'">
<svg class="h-6 w-6 text-white" fill="currentColor" viewBox="0 0 24 24">
<path d="M13.983 11.078h2.119a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.119a.185.185 0 00-.185.185v1.888c0 .102.083.185.185.185m-2.954-5.43h2.118a.186.186 0 00.186-.186V3.574a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m0 2.716h2.118a.187.187 0 00.186-.186V6.29a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.887c0 .102.082.185.185.186m-2.93 0h2.12a.186.186 0 00.184-.186V6.29a.185.185 0 00-.185-.185H8.1a.185.185 0 00-.185.185v1.887c0 .102.083.185.185.186m-2.964 0h2.119a.186.186 0 00.185-.186V6.29a.185.185 0 00-.185-.185H5.136a.186.186 0 00-.186.185v1.887c0 .102.084.185.186.186m5.893 2.715h2.118a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m-2.93 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.083.185.185.185m-2.964 0h2.119a.185.185 0 00.185-.185V9.006a.185.185 0 00-.184-.186h-2.12a.186.186 0 00-.186.186v1.887c0 .102.084.185.186.185m-2.92 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.082.185.185.185M23.763 9.89c-.065-.051-.672-.51-1.954-.51-.338 0-.676.03-1.01.09-.458-1.314-1.605-2.16-2.898-2.16h-.048c-.328 0-.654.06-.969.18-.618-2.066-2.215-3.073-4.752-3.073-2.538 0-4.135 1.007-4.753 3.073-.315-.12-.64-.18-.969-.18h-.048c-1.293 0-2.44.846-2.898 2.16a8.39 8.39 0 00-1.01-.09c-1.282 0-1.889.459-1.954.51L0 10.2l.08.31s.935 3.605 4.059 4.794v.003c.563.215 1.156.322 1.756.322.71 0 1.423-.129 2.112-.385a8.804 8.804 0 002.208.275c.877 0 1.692-.165 2.411-.49a4.71 4.71 0 001.617.28c.606 0 1.201-.11 1.773-.328.572.219 1.167.327 1.772.327.71 0 1.423-.129 2.112-.385.79.251 1.57.376 2.315.376.606 0 1.2-.107 1.766-.322v-.003c3.124-1.189 4.059-4.794 4.059-4.794l.08-.31-.237-.31z"/>
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Docker</dt>
<dd class="text-lg font-semibold text-gray-900">
{{ status()!.docker.running ? 'Running' : 'Stopped' }}
</dd>
</dl>
</div>
</div>
</div>
</div>
<!-- System Status -->
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
<!-- Docker -->
<div class="card">
<h3 class="text-lg font-medium text-gray-900 mb-4">Docker</h3>
<div class="space-y-2">
<div class="flex justify-between">
<span class="text-sm text-gray-600">Status</span>
<span [ngClass]="status()!.docker.running ? 'badge-success' : 'badge-danger'" class="badge">
{{ status()!.docker.running ? 'Running' : 'Stopped' }}
</span>
</div>
@if (status()!.docker.version) {
<div class="flex justify-between">
<span class="text-sm text-gray-600">Version</span>
<span class="text-sm text-gray-900">{{ status()!.docker.version.Version }}</span>
</div>
}
</div>
</div>
<!-- Nginx -->
<div class="card">
<h3 class="text-lg font-medium text-gray-900 mb-4">Nginx</h3>
<div class="space-y-2">
<div class="flex justify-between">
<span class="text-sm text-gray-600">Status</span>
<span [ngClass]="status()!.nginx.status === 'running' ? 'badge-success' : 'badge-danger'" class="badge">
{{ status()!.nginx.status }}
</span>
</div>
<div class="flex justify-between">
<span class="text-sm text-gray-600">Installed</span>
<span [ngClass]="status()!.nginx.installed ? 'badge-success' : 'badge-danger'" class="badge">
{{ status()!.nginx.installed ? 'Yes' : 'No' }}
</span>
</div>
</div>
</div>
<!-- DNS & SSL -->
<div class="card">
<h3 class="text-lg font-medium text-gray-900 mb-4">DNS & SSL</h3>
<div class="space-y-2">
<div class="flex justify-between">
<span class="text-sm text-gray-600">DNS Configured</span>
<span [ngClass]="status()!.dns.configured ? 'badge-success' : 'badge-warning'" class="badge">
{{ status()!.dns.configured ? 'Yes' : 'No' }}
</span>
</div>
<div class="flex justify-between">
<span class="text-sm text-gray-600">SSL Configured</span>
<span [ngClass]="status()!.ssl.configured ? 'badge-success' : 'badge-warning'" class="badge">
{{ status()!.ssl.configured ? 'Yes' : 'No' }}
</span>
</div>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="mt-8">
<h3 class="text-lg font-medium text-gray-900 mb-4">Quick Actions</h3>
<div class="flex space-x-4">
<a routerLink="/services/new" class="btn btn-primary">
Deploy New Service
</a>
<a routerLink="/services" class="btn btn-secondary">
View All Services
</a>
</div>
</div>
}
</div>
`,
})
export class DashboardComponent implements OnInit {
private apiService = inject(ApiService);
status = signal<SystemStatus | null>(null);
loading = signal(true);
ngOnInit(): void {
this.loadStatus();
}
loadStatus(): void {
this.loading.set(true);
this.apiService.getStatus().subscribe({
next: (response) => {
if (response.success && response.data) {
this.status.set(response.data);
}
this.loading.set(false);
},
error: () => {
this.loading.set(false);
},
});
}
}

View File

@@ -0,0 +1,73 @@
import { Component, OnInit, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ApiService } from '../../core/services/api.service';
@Component({
selector: 'app-dns',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<div class="px-4 sm:px-0">
<h1 class="text-3xl font-bold text-gray-900 mb-8">DNS Records</h1>
@if (records().length > 0) {
<div class="card overflow-hidden p-0">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Domain</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Type</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Value</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
@for (record of records(); track record.domain) {
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ record.domain }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ record.type }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ record.value }}</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm">
<button (click)="deleteRecord(record)" class="text-red-600 hover:text-red-900">Delete</button>
</td>
</tr>
}
</tbody>
</table>
</div>
} @else {
<div class="card text-center py-12">
<p class="text-gray-500">No DNS records configured</p>
<p class="text-sm text-gray-400 mt-2">DNS records are created automatically when deploying services with domains</p>
</div>
}
</div>
`,
})
export class DnsComponent implements OnInit {
private apiService = inject(ApiService);
records = signal<any[]>([]);
ngOnInit(): void {
this.loadRecords();
}
loadRecords(): void {
this.apiService.getDnsRecords().subscribe({
next: (response) => {
if (response.success && response.data) {
this.records.set(response.data);
}
},
});
}
deleteRecord(record: any): void {
if (confirm(`Delete DNS record for ${record.domain}?`)) {
this.apiService.deleteDnsRecord(record.domain).subscribe({
next: () => this.loadRecords(),
});
}
}
}

View File

@@ -0,0 +1,103 @@
import { Component, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { AuthService } from '../../core/services/auth.service';
@Component({
selector: 'app-login',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<div class="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-md w-full space-y-8">
<div>
<h2 class="mt-6 text-center text-3xl font-extrabold text-gray-900">
Onebox
</h2>
<p class="mt-2 text-center text-sm text-gray-600">
Sign in to your account
</p>
</div>
<form class="mt-8 space-y-6" (ngSubmit)="onSubmit()">
<div class="rounded-md shadow-sm -space-y-px">
<div>
<label for="username" class="sr-only">Username</label>
<input
id="username"
name="username"
type="text"
[(ngModel)]="username"
required
class="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm"
placeholder="Username"
/>
</div>
<div>
<label for="password" class="sr-only">Password</label>
<input
id="password"
name="password"
type="password"
[(ngModel)]="password"
required
class="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm"
placeholder="Password"
/>
</div>
</div>
@if (error) {
<div class="rounded-md bg-red-50 p-4">
<p class="text-sm text-red-800">{{ error }}</p>
</div>
}
<div>
<button
type="submit"
[disabled]="loading"
class="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50"
>
{{ loading ? 'Signing in...' : 'Sign in' }}
</button>
</div>
<div class="text-sm text-gray-600 text-center">
<p>Default credentials: admin / admin</p>
<p class="text-xs text-gray-500 mt-1">Please change after first login</p>
</div>
</form>
</div>
</div>
`,
})
export class LoginComponent {
private authService = inject(AuthService);
private router = inject(Router);
username = '';
password = '';
loading = false;
error = '';
onSubmit(): void {
this.error = '';
this.loading = true;
this.authService.login({ username: this.username, password: this.password }).subscribe({
next: (response) => {
this.loading = false;
if (response.success) {
this.router.navigate(['/dashboard']);
} else {
this.error = response.error || 'Login failed';
}
},
error: (err) => {
this.loading = false;
this.error = err.error?.error || 'An error occurred during login';
},
});
}
}

View File

@@ -0,0 +1,103 @@
import { Component, OnInit, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ApiService, Registry } from '../../core/services/api.service';
@Component({
selector: 'app-registries',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<div class="px-4 sm:px-0">
<h1 class="text-3xl font-bold text-gray-900 mb-8">Docker Registries</h1>
<!-- Add Registry Form -->
<div class="card mb-8 max-w-2xl">
<h2 class="text-lg font-medium text-gray-900 mb-4">Add Registry</h2>
<form (ngSubmit)="addRegistry()" class="space-y-4">
<div>
<label for="url" class="label">Registry URL</label>
<input type="text" id="url" [(ngModel)]="newRegistry.url" name="url" required placeholder="registry.example.com" class="input" />
</div>
<div>
<label for="username" class="label">Username</label>
<input type="text" id="username" [(ngModel)]="newRegistry.username" name="username" required class="input" />
</div>
<div>
<label for="password" class="label">Password</label>
<input type="password" id="password" [(ngModel)]="newRegistry.password" name="password" required class="input" />
</div>
<button type="submit" class="btn btn-primary">Add Registry</button>
</form>
</div>
<!-- Registries List -->
@if (registries().length > 0) {
<div class="card overflow-hidden p-0">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">URL</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Username</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Created</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
@for (registry of registries(); track registry.id) {
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ registry.url }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ registry.username }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ formatDate(registry.createdAt) }}</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm">
<button (click)="deleteRegistry(registry)" class="text-red-600 hover:text-red-900">Delete</button>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
`,
})
export class RegistriesComponent implements OnInit {
private apiService = inject(ApiService);
registries = signal<Registry[]>([]);
newRegistry = { url: '', username: '', password: '' };
ngOnInit(): void {
this.loadRegistries();
}
loadRegistries(): void {
this.apiService.getRegistries().subscribe({
next: (response) => {
if (response.success && response.data) {
this.registries.set(response.data);
}
},
});
}
addRegistry(): void {
this.apiService.createRegistry(this.newRegistry).subscribe({
next: () => {
this.newRegistry = { url: '', username: '', password: '' };
this.loadRegistries();
},
});
}
deleteRegistry(registry: Registry): void {
if (confirm(`Delete registry ${registry.url}?`)) {
this.apiService.deleteRegistry(registry.url).subscribe({
next: () => this.loadRegistries(),
});
}
}
formatDate(timestamp: number): string {
return new Date(timestamp).toLocaleDateString();
}
}

View File

@@ -0,0 +1,221 @@
import { Component, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { ApiService } from '../../core/services/api.service';
interface EnvVar {
key: string;
value: string;
}
@Component({
selector: 'app-service-create',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<div class="px-4 sm:px-0">
<h1 class="text-3xl font-bold text-gray-900 mb-8">Deploy New Service</h1>
<div class="card max-w-3xl">
<form (ngSubmit)="onSubmit()">
<!-- Name -->
<div class="mb-6">
<label for="name" class="label">Service Name *</label>
<input
type="text"
id="name"
[(ngModel)]="name"
name="name"
required
placeholder="myapp"
class="input"
/>
<p class="mt-1 text-sm text-gray-500">Lowercase letters, numbers, and hyphens only</p>
</div>
<!-- Image -->
<div class="mb-6">
<label for="image" class="label">Docker Image *</label>
<input
type="text"
id="image"
[(ngModel)]="image"
name="image"
required
placeholder="nginx:latest"
class="input"
/>
<p class="mt-1 text-sm text-gray-500">Format: image:tag or registry/image:tag</p>
</div>
<!-- Port -->
<div class="mb-6">
<label for="port" class="label">Container Port *</label>
<input
type="number"
id="port"
[(ngModel)]="port"
name="port"
required
placeholder="80"
class="input"
/>
<p class="mt-1 text-sm text-gray-500">Port that your application listens on</p>
</div>
<!-- Domain -->
<div class="mb-6">
<label for="domain" class="label">Domain (Optional)</label>
<input
type="text"
id="domain"
[(ngModel)]="domain"
name="domain"
placeholder="app.example.com"
class="input"
/>
<p class="mt-1 text-sm text-gray-500">Leave empty to skip automatic DNS & SSL</p>
</div>
<!-- Environment Variables -->
<div class="mb-6">
<label class="label">Environment Variables</label>
@for (env of envVars(); track $index) {
<div class="flex gap-2 mb-2">
<input
type="text"
[(ngModel)]="env.key"
[name]="'envKey' + $index"
placeholder="KEY"
class="input flex-1"
/>
<input
type="text"
[(ngModel)]="env.value"
[name]="'envValue' + $index"
placeholder="value"
class="input flex-1"
/>
<button type="button" (click)="removeEnvVar($index)" class="btn btn-danger">
Remove
</button>
</div>
}
<button type="button" (click)="addEnvVar()" class="btn btn-secondary mt-2">
Add Environment Variable
</button>
</div>
<!-- Options -->
<div class="mb-6">
<div class="flex items-center mb-2">
<input
type="checkbox"
id="autoDNS"
[(ngModel)]="autoDNS"
name="autoDNS"
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
/>
<label for="autoDNS" class="ml-2 block text-sm text-gray-900">
Configure DNS automatically
</label>
</div>
<div class="flex items-center">
<input
type="checkbox"
id="autoSSL"
[(ngModel)]="autoSSL"
name="autoSSL"
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
/>
<label for="autoSSL" class="ml-2 block text-sm text-gray-900">
Obtain SSL certificate automatically
</label>
</div>
</div>
@if (error()) {
<div class="rounded-md bg-red-50 p-4 mb-6">
<p class="text-sm text-red-800">{{ error() }}</p>
</div>
}
<!-- Actions -->
<div class="flex justify-end space-x-4">
<button type="button" (click)="cancel()" class="btn btn-secondary">
Cancel
</button>
<button type="submit" [disabled]="loading()" class="btn btn-primary">
{{ loading() ? 'Deploying...' : 'Deploy Service' }}
</button>
</div>
</form>
</div>
</div>
`,
})
export class ServiceCreateComponent {
private apiService = inject(ApiService);
private router = inject(Router);
name = '';
image = '';
port = 80;
domain = '';
autoDNS = true;
autoSSL = true;
envVars = signal<EnvVar[]>([]);
loading = signal(false);
error = signal('');
addEnvVar(): void {
this.envVars.update((vars) => [...vars, { key: '', value: '' }]);
}
removeEnvVar(index: number): void {
this.envVars.update((vars) => vars.filter((_, i) => i !== index));
}
onSubmit(): void {
this.error.set('');
this.loading.set(true);
// Convert env vars to object
const envVarsObj: Record<string, string> = {};
for (const env of this.envVars()) {
if (env.key && env.value) {
envVarsObj[env.key] = env.value;
}
}
const data = {
name: this.name,
image: this.image,
port: this.port,
domain: this.domain || undefined,
envVars: envVarsObj,
autoDNS: this.autoDNS,
autoSSL: this.autoSSL,
};
this.apiService.createService(data).subscribe({
next: (response) => {
this.loading.set(false);
if (response.success) {
this.router.navigate(['/services']);
} else {
this.error.set(response.error || 'Failed to deploy service');
}
},
error: (err) => {
this.loading.set(false);
this.error.set(err.error?.error || 'An error occurred');
},
});
}
cancel(): void {
this.router.navigate(['/services']);
}
}

View File

@@ -0,0 +1,209 @@
import { Component, OnInit, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router';
import { ApiService, Service } from '../../core/services/api.service';
@Component({
selector: 'app-service-detail',
standalone: true,
imports: [CommonModule],
template: `
<div class="px-4 sm:px-0">
@if (loading()) {
<div class="text-center py-12">
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
</div>
} @else if (service()) {
<div class="mb-8">
<div class="flex items-center justify-between">
<h1 class="text-3xl font-bold text-gray-900">{{ service()!.name }}</h1>
<span [ngClass]="{
'badge-success': service()!.status === 'running',
'badge-danger': service()!.status === 'stopped' || service()!.status === 'failed',
'badge-warning': service()!.status === 'starting' || service()!.status === 'stopping'
}" class="badge text-lg">
{{ service()!.status }}
</span>
</div>
</div>
<!-- Details Card -->
<div class="card mb-6">
<h2 class="text-lg font-medium text-gray-900 mb-4">Service Details</h2>
<dl class="grid grid-cols-1 gap-x-4 gap-y-4 sm:grid-cols-2">
<div>
<dt class="text-sm font-medium text-gray-500">Image</dt>
<dd class="mt-1 text-sm text-gray-900">{{ service()!.image }}</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Port</dt>
<dd class="mt-1 text-sm text-gray-900">{{ service()!.port }}</dd>
</div>
@if (service()!.domain) {
<div>
<dt class="text-sm font-medium text-gray-500">Domain</dt>
<dd class="mt-1 text-sm text-gray-900">
<a [href]="'https://' + service()!.domain" target="_blank" class="text-primary-600 hover:text-primary-900">
{{ service()!.domain }}
</a>
</dd>
</div>
}
@if (service()!.containerID) {
<div>
<dt class="text-sm font-medium text-gray-500">Container ID</dt>
<dd class="mt-1 text-sm text-gray-900 font-mono">{{ service()!.containerID?.substring(0, 12) }}</dd>
</div>
}
<div>
<dt class="text-sm font-medium text-gray-500">Created</dt>
<dd class="mt-1 text-sm text-gray-900">{{ formatDate(service()!.createdAt) }}</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Updated</dt>
<dd class="mt-1 text-sm text-gray-900">{{ formatDate(service()!.updatedAt) }}</dd>
</div>
</dl>
<!-- Environment Variables -->
@if (Object.keys(service()!.envVars).length > 0) {
<div class="mt-6">
<h3 class="text-sm font-medium text-gray-500 mb-2">Environment Variables</h3>
<div class="bg-gray-50 rounded-md p-4">
@for (entry of Object.entries(service()!.envVars); track entry[0]) {
<div class="flex justify-between py-1">
<span class="text-sm font-mono text-gray-700">{{ entry[0] }}</span>
<span class="text-sm font-mono text-gray-900">{{ entry[1] }}</span>
</div>
}
</div>
</div>
}
</div>
<!-- Actions -->
<div class="card mb-6">
<h2 class="text-lg font-medium text-gray-900 mb-4">Actions</h2>
<div class="flex space-x-4">
@if (service()!.status === 'stopped') {
<button (click)="startService()" class="btn btn-success">Start</button>
}
@if (service()!.status === 'running') {
<button (click)="stopService()" class="btn btn-secondary">Stop</button>
<button (click)="restartService()" class="btn btn-primary">Restart</button>
}
<button (click)="deleteService()" class="btn btn-danger">Delete</button>
</div>
</div>
<!-- Logs -->
<div class="card">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-medium text-gray-900">Logs</h2>
<button (click)="refreshLogs()" class="btn btn-secondary text-sm">Refresh</button>
</div>
@if (loadingLogs()) {
<div class="text-center py-8">
<div class="inline-block animate-spin rounded-full h-6 w-6 border-b-2 border-primary-600"></div>
</div>
} @else {
<div class="bg-gray-900 rounded-md p-4 overflow-x-auto">
<pre class="text-xs text-gray-100 font-mono">{{ logs() || 'No logs available' }}</pre>
</div>
}
</div>
}
</div>
`,
})
export class ServiceDetailComponent implements OnInit {
private apiService = inject(ApiService);
private route = inject(ActivatedRoute);
private router = inject(Router);
service = signal<Service | null>(null);
logs = signal('');
loading = signal(true);
loadingLogs = signal(false);
Object = Object;
ngOnInit(): void {
const name = this.route.snapshot.paramMap.get('name')!;
this.loadService(name);
this.loadLogs(name);
}
loadService(name: string): void {
this.loading.set(true);
this.apiService.getService(name).subscribe({
next: (response) => {
if (response.success && response.data) {
this.service.set(response.data);
}
this.loading.set(false);
},
error: () => {
this.loading.set(false);
this.router.navigate(['/services']);
},
});
}
loadLogs(name: string): void {
this.loadingLogs.set(true);
this.apiService.getServiceLogs(name).subscribe({
next: (response) => {
if (response.success && response.data) {
this.logs.set(response.data);
}
this.loadingLogs.set(false);
},
error: () => {
this.loadingLogs.set(false);
},
});
}
refreshLogs(): void {
this.loadLogs(this.service()!.name);
}
startService(): void {
this.apiService.startService(this.service()!.name).subscribe({
next: () => {
this.loadService(this.service()!.name);
},
});
}
stopService(): void {
this.apiService.stopService(this.service()!.name).subscribe({
next: () => {
this.loadService(this.service()!.name);
},
});
}
restartService(): void {
this.apiService.restartService(this.service()!.name).subscribe({
next: () => {
this.loadService(this.service()!.name);
},
});
}
deleteService(): void {
if (confirm(`Are you sure you want to delete ${this.service()!.name}?`)) {
this.apiService.deleteService(this.service()!.name).subscribe({
next: () => {
this.router.navigate(['/services']);
},
});
}
}
formatDate(timestamp: number): string {
return new Date(timestamp).toLocaleString();
}
}

View File

@@ -0,0 +1,150 @@
import { Component, OnInit, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import { ApiService, Service } from '../../core/services/api.service';
@Component({
selector: 'app-services-list',
standalone: true,
imports: [CommonModule, RouterLink],
template: `
<div class="px-4 sm:px-0">
<div class="sm:flex sm:items-center sm:justify-between mb-8">
<h1 class="text-3xl font-bold text-gray-900">Services</h1>
<div class="mt-4 sm:mt-0">
<a routerLink="/services/new" class="btn btn-primary">
Deploy New Service
</a>
</div>
</div>
@if (loading()) {
<div class="text-center py-12">
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
</div>
} @else if (services().length === 0) {
<div class="card text-center py-12">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
<h3 class="mt-2 text-sm font-medium text-gray-900">No services</h3>
<p class="mt-1 text-sm text-gray-500">Get started by deploying a new service.</p>
<div class="mt-6">
<a routerLink="/services/new" class="btn btn-primary">
Deploy Service
</a>
</div>
</div>
} @else {
<div class="card overflow-hidden p-0">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Image</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Domain</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
<th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
@for (service of services(); track service.id) {
<tr class="hover:bg-gray-50">
<td class="px-6 py-4 whitespace-nowrap">
<a [routerLink]="['/services', service.name]" class="text-sm font-medium text-primary-600 hover:text-primary-900">
{{ service.name }}
</a>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{{ service.image }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{{ service.domain || '-' }}
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span [ngClass]="{
'badge-success': service.status === 'running',
'badge-danger': service.status === 'stopped' || service.status === 'failed',
'badge-warning': service.status === 'starting' || service.status === 'stopping'
}" class="badge">
{{ service.status }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2">
@if (service.status === 'stopped') {
<button (click)="startService(service)" class="text-green-600 hover:text-green-900">Start</button>
}
@if (service.status === 'running') {
<button (click)="stopService(service)" class="text-yellow-600 hover:text-yellow-900">Stop</button>
<button (click)="restartService(service)" class="text-blue-600 hover:text-blue-900">Restart</button>
}
<button (click)="deleteService(service)" class="text-red-600 hover:text-red-900">Delete</button>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
`,
})
export class ServicesListComponent implements OnInit {
private apiService = inject(ApiService);
services = signal<Service[]>([]);
loading = signal(true);
ngOnInit(): void {
this.loadServices();
}
loadServices(): void {
this.loading.set(true);
this.apiService.getServices().subscribe({
next: (response) => {
if (response.success && response.data) {
this.services.set(response.data);
}
this.loading.set(false);
},
error: () => {
this.loading.set(false);
},
});
}
startService(service: Service): void {
this.apiService.startService(service.name).subscribe({
next: () => {
this.loadServices();
},
});
}
stopService(service: Service): void {
this.apiService.stopService(service.name).subscribe({
next: () => {
this.loadServices();
},
});
}
restartService(service: Service): void {
this.apiService.restartService(service.name).subscribe({
next: () => {
this.loadServices();
},
});
}
deleteService(service: Service): void {
if (confirm(`Are you sure you want to delete ${service.name}?`)) {
this.apiService.deleteService(service.name).subscribe({
next: () => {
this.loadServices();
},
});
}
}
}

View File

@@ -0,0 +1,96 @@
import { Component, OnInit, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ApiService } from '../../core/services/api.service';
@Component({
selector: 'app-settings',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<div class="px-4 sm:px-0">
<h1 class="text-3xl font-bold text-gray-900 mb-8">Settings</h1>
<div class="space-y-6">
<!-- Cloudflare Settings -->
<div class="card">
<h2 class="text-lg font-medium text-gray-900 mb-4">Cloudflare DNS</h2>
<div class="space-y-4">
<div>
<label class="label">API Key</label>
<input type="password" [(ngModel)]="settings.cloudflareAPIKey" class="input" />
</div>
<div>
<label class="label">Email</label>
<input type="email" [(ngModel)]="settings.cloudflareEmail" class="input" />
</div>
<div>
<label class="label">Zone ID</label>
<input type="text" [(ngModel)]="settings.cloudflareZoneID" class="input" />
</div>
</div>
</div>
<!-- Server Settings -->
<div class="card">
<h2 class="text-lg font-medium text-gray-900 mb-4">Server</h2>
<div class="space-y-4">
<div>
<label class="label">Server IP</label>
<input type="text" [(ngModel)]="settings.serverIP" class="input" placeholder="1.2.3.4" />
</div>
<div>
<label class="label">HTTP Port</label>
<input type="number" [(ngModel)]="settings.httpPort" class="input" placeholder="3000" />
</div>
</div>
</div>
<!-- SSL Settings -->
<div class="card">
<h2 class="text-lg font-medium text-gray-900 mb-4">SSL / ACME</h2>
<div>
<label class="label">ACME Email</label>
<input type="email" [(ngModel)]="settings.acmeEmail" class="input" placeholder="admin@example.com" />
</div>
</div>
<!-- Save Button -->
<div class="flex justify-end">
<button (click)="saveSettings()" class="btn btn-primary">
Save Settings
</button>
</div>
</div>
</div>
`,
})
export class SettingsComponent implements OnInit {
private apiService = inject(ApiService);
settings: any = {};
ngOnInit(): void {
this.loadSettings();
}
loadSettings(): void {
this.apiService.getSettings().subscribe({
next: (response) => {
if (response.success && response.data) {
this.settings = response.data;
}
},
});
}
saveSettings(): void {
// Save each setting individually
const promises = Object.entries(this.settings).map(([key, value]) =>
this.apiService.updateSetting(key, value as string).toPromise()
);
Promise.all(promises).then(() => {
alert('Settings saved successfully');
});
}
}

View File

@@ -0,0 +1,86 @@
import { Component, OnInit, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ApiService } from '../../core/services/api.service';
@Component({
selector: 'app-ssl',
standalone: true,
imports: [CommonModule],
template: `
<div class="px-4 sm:px-0">
<h1 class="text-3xl font-bold text-gray-900 mb-8">SSL Certificates</h1>
@if (certificates().length > 0) {
<div class="card overflow-hidden p-0">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Domain</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Issuer</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Expiry</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
@for (cert of certificates(); track cert.domain) {
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ cert.domain }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ cert.issuer }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
<span [ngClass]="isExpiringSoon(cert.expiryDate) ? 'text-red-600' : 'text-gray-500'">
{{ formatDate(cert.expiryDate) }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm">
<button (click)="renewCertificate(cert)" class="text-primary-600 hover:text-primary-900">Renew</button>
</td>
</tr>
}
</tbody>
</table>
</div>
} @else {
<div class="card text-center py-12">
<p class="text-gray-500">No SSL certificates</p>
<p class="text-sm text-gray-400 mt-2">Certificates are obtained automatically when deploying services with domains</p>
</div>
}
</div>
`,
})
export class SslComponent implements OnInit {
private apiService = inject(ApiService);
certificates = signal<any[]>([]);
ngOnInit(): void {
this.loadCertificates();
}
loadCertificates(): void {
this.apiService.getSslCertificates().subscribe({
next: (response) => {
if (response.success && response.data) {
this.certificates.set(response.data);
}
},
});
}
renewCertificate(cert: any): void {
this.apiService.renewSslCertificate(cert.domain).subscribe({
next: () => {
alert('Certificate renewal initiated');
this.loadCertificates();
},
});
}
formatDate(timestamp: number): string {
return new Date(timestamp).toLocaleDateString();
}
isExpiringSoon(timestamp: number): boolean {
const thirtyDays = 30 * 24 * 60 * 60 * 1000;
return timestamp - Date.now() < thirtyDays;
}
}

View File

@@ -0,0 +1,89 @@
import { Component, inject } from '@angular/core';
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import { CommonModule } from '@angular/common';
import { AuthService } from '../../core/services/auth.service';
@Component({
selector: 'app-layout',
standalone: true,
imports: [CommonModule, RouterOutlet, RouterLink, RouterLinkActive],
template: `
<div class="min-h-screen bg-gray-50">
<!-- Navigation -->
<nav class="bg-white shadow-sm">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex">
<div class="flex-shrink-0 flex items-center">
<span class="text-2xl font-bold text-primary-600">Onebox</span>
</div>
<div class="hidden sm:ml-6 sm:flex sm:space-x-8">
<a
routerLink="/dashboard"
routerLinkActive="border-primary-500 text-gray-900"
[routerLinkActiveOptions]="{ exact: true }"
class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"
>
Dashboard
</a>
<a
routerLink="/services"
routerLinkActive="border-primary-500 text-gray-900"
class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"
>
Services
</a>
<a
routerLink="/registries"
routerLinkActive="border-primary-500 text-gray-900"
class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"
>
Registries
</a>
<a
routerLink="/dns"
routerLinkActive="border-primary-500 text-gray-900"
class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"
>
DNS
</a>
<a
routerLink="/ssl"
routerLinkActive="border-primary-500 text-gray-900"
class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"
>
SSL
</a>
<a
routerLink="/settings"
routerLinkActive="border-primary-500 text-gray-900"
class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"
>
Settings
</a>
</div>
</div>
<div class="flex items-center">
<span class="text-sm text-gray-700 mr-4">{{ authService.currentUser()?.username }}</span>
<button (click)="logout()" class="btn btn-secondary text-sm">
Logout
</button>
</div>
</div>
</div>
</nav>
<!-- Main Content -->
<main class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
<router-outlet></router-outlet>
</main>
</div>
`,
})
export class LayoutComponent {
authService = inject(AuthService);
logout(): void {
this.authService.logout();
}
}

1
ui/src/favicon.ico Normal file
View File

@@ -0,0 +1 @@
<!-- Empty favicon placeholder -->

13
ui/src/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Onebox - Container Platform</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
</head>
<body class="bg-gray-50">
<app-root></app-root>
</body>
</html>

13
ui/src/main.ts Normal file
View File

@@ -0,0 +1,13 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { provideRouter } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { AppComponent } from './app/app.component';
import { routes } from './app/app.routes';
import { authInterceptor } from './app/core/interceptors/auth.interceptor';
bootstrapApplication(AppComponent, {
providers: [
provideRouter(routes),
provideHttpClient(withInterceptors([authInterceptor])),
],
}).catch((err) => console.error(err));

57
ui/src/styles.css Normal file
View File

@@ -0,0 +1,57 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer components {
.btn {
@apply px-4 py-2 rounded-lg font-medium transition-colors duration-200;
}
.btn-primary {
@apply bg-primary-600 text-white hover:bg-primary-700;
}
.btn-secondary {
@apply bg-gray-200 text-gray-800 hover:bg-gray-300;
}
.btn-danger {
@apply bg-red-600 text-white hover:bg-red-700;
}
.btn-success {
@apply bg-green-600 text-white hover:bg-green-700;
}
.card {
@apply bg-white rounded-lg shadow-md p-6;
}
.input {
@apply w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500;
}
.label {
@apply block text-sm font-medium text-gray-700 mb-1;
}
.badge {
@apply px-2 py-1 text-xs font-semibold rounded-full;
}
.badge-success {
@apply bg-green-100 text-green-800;
}
.badge-warning {
@apply bg-yellow-100 text-yellow-800;
}
.badge-danger {
@apply bg-red-100 text-red-800;
}
.badge-info {
@apply bg-blue-100 text-blue-800;
}
}

25
ui/tailwind.config.js Normal file
View File

@@ -0,0 +1,25 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./src/**/*.{html,ts}",
],
theme: {
extend: {
colors: {
primary: {
50: '#f0f9ff',
100: '#e0f2fe',
200: '#bae6fd',
300: '#7dd3fc',
400: '#38bdf8',
500: '#0ea5e9',
600: '#0284c7',
700: '#0369a1',
800: '#075985',
900: '#0c4a6e',
},
},
},
},
plugins: [],
}

13
ui/tsconfig.app.json Normal file
View File

@@ -0,0 +1,13 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
"types": []
},
"files": [
"src/main.ts"
],
"include": [
"src/**/*.d.ts"
]
}

31
ui/tsconfig.json Normal file
View File

@@ -0,0 +1,31 @@
{
"compileOnSave": false,
"compilerOptions": {
"outDir": "./dist/out-tsc",
"forceConsistentCasingInFileNames": true,
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true,
"sourceMap": true,
"declaration": false,
"experimentalDecorators": true,
"moduleResolution": "node",
"importHelpers": true,
"target": "ES2022",
"module": "ES2022",
"useDefineForClassFields": false,
"lib": [
"ES2022",
"dom"
]
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
}
}