commit 246a6073e0b42d1ef3137fda3d7485071a830a1f Author: Juergen Kunz Date: Tue Oct 28 13:05:42 2025 +0000 Initial commit: Onebox v1.0.0 - Complete Deno-based architecture following nupst/spark patterns - SQLite database with full schema - Docker container management - Service orchestration (Docker + Nginx + DNS + SSL) - Registry authentication - Nginx reverse proxy configuration - Cloudflare DNS integration - Let's Encrypt SSL automation - Background daemon with metrics collection - HTTP API server - Comprehensive CLI - Cross-platform compilation setup - NPM distribution wrapper - Shell installer script Core features: - Deploy containers with single command - Automatic domain configuration - Automatic SSL certificates - Multi-registry support - Metrics and logging - Systemd integration Ready for Angular UI implementation and testing. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a313ed3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,50 @@ +# Deno +.deno/ +deno.lock + +# Node modules (for npm wrapper) +node_modules/ + +# Build outputs +dist/binaries/* +!dist/binaries/.gitkeep + +# Angular UI +ui/dist/ +ui/node_modules/ +ui/.angular/ + +# Development +.nogit/ +*.log +*.db +*.db-journal + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Testing +coverage/ + +# SSL certificates (sensitive) +certs/ +*.pem +*.key +*.crt + +# Config with secrets +config.local.json +.env +.env.local + +# Logs +logs/ +*.log diff --git a/bin/onebox-wrapper.js b/bin/onebox-wrapper.js new file mode 100644 index 0000000..8bd15f3 --- /dev/null +++ b/bin/onebox-wrapper.js @@ -0,0 +1,70 @@ +#!/usr/bin/env node + +/** + * NPM wrapper for Onebox binary + */ + +import { spawn } from 'child_process'; +import { platform, arch } from 'os'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +// Detect platform and architecture +const platformMap = { + 'linux': 'linux', + 'darwin': 'macos', + 'win32': 'windows', +}; + +const archMap = { + 'x64': 'x64', + 'arm64': 'arm64', +}; + +const currentPlatform = platformMap[platform()]; +const currentArch = archMap[arch()]; + +if (!currentPlatform || !currentArch) { + console.error(`Unsupported platform: ${platform()} ${arch()}`); + process.exit(1); +} + +// Build binary name +const binaryName = `onebox-${currentPlatform}-${currentArch}${currentPlatform === 'windows' ? '.exe' : ''}`; +const binaryPath = join(__dirname, '..', 'dist', 'binaries', binaryName); + +// Spawn the binary +const child = spawn(binaryPath, process.argv.slice(2), { + stdio: 'inherit', + windowsHide: true, +}); + +// Forward signals +const signals = ['SIGINT', 'SIGTERM', 'SIGHUP']; +signals.forEach(signal => { + process.on(signal, () => { + if (!child.killed) { + child.kill(signal); + } + }); +}); + +// Forward exit code +child.on('exit', (code, signal) => { + if (signal) { + process.kill(process.pid, signal); + } else { + process.exit(code || 0); + } +}); + +child.on('error', (error) => { + console.error(`Failed to start onebox: ${error.message}`); + console.error(`Binary path: ${binaryPath}`); + console.error('Make sure the binary was downloaded correctly.'); + console.error('Try running: npm install -g @serve.zone/onebox'); + process.exit(1); +}); diff --git a/changelog.md b/changelog.md new file mode 100644 index 0000000..b76f35f --- /dev/null +++ b/changelog.md @@ -0,0 +1,29 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added +- Initial project structure +- Core architecture classes +- Docker container management +- Nginx reverse proxy integration +- Cloudflare DNS management +- Let's Encrypt SSL automation +- SQLite database layer +- Angular web UI +- Multi-user authentication +- Systemd daemon integration +- CLI commands for all operations +- Metrics collection and historical data +- Log aggregation +- Registry authentication support + +## [1.0.0] - TBD + +### Added +- First stable release diff --git a/deno.json b/deno.json new file mode 100644 index 0000000..c61e93b --- /dev/null +++ b/deno.json @@ -0,0 +1,44 @@ +{ + "name": "@serve.zone/onebox", + "version": "1.0.0", + "exports": "./mod.ts", + "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" + }, + "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" + }, + "compilerOptions": { + "lib": ["deno.window", "deno.ns"], + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "noUnusedLocals": false, + "noUnusedParameters": false + }, + "fmt": { + "useTabs": false, + "lineWidth": 100, + "indentWidth": 2, + "semiColons": true, + "singleQuote": true, + "proseWrap": "preserve" + }, + "lint": { + "rules": { + "tags": ["recommended"] + } + } +} diff --git a/dist/binaries/.gitkeep b/dist/binaries/.gitkeep new file mode 100644 index 0000000..f627a00 --- /dev/null +++ b/dist/binaries/.gitkeep @@ -0,0 +1 @@ +# Keep this directory in git diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..940c31d --- /dev/null +++ b/install.sh @@ -0,0 +1,192 @@ +#!/bin/bash +# +# Onebox installer script +# + +set -e + +# Configuration +REPO_URL="https://code.foss.global/serve.zone/onebox" +INSTALL_DIR="/opt/onebox" +BIN_LINK="/usr/local/bin/onebox" + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Functions +error() { + echo -e "${RED}Error: $1${NC}" >&2 + exit 1 +} + +info() { + echo -e "${GREEN}$1${NC}" +} + +warn() { + echo -e "${YELLOW}$1${NC}" +} + +# Detect platform and architecture +detect_platform() { + OS=$(uname -s | tr '[:upper:]' '[:lower:]') + ARCH=$(uname -m) + + case "$OS" in + linux) + PLATFORM="linux" + ;; + darwin) + PLATFORM="macos" + ;; + *) + error "Unsupported operating system: $OS" + ;; + esac + + case "$ARCH" in + x86_64|amd64) + ARCH="x64" + ;; + aarch64|arm64) + ARCH="arm64" + ;; + *) + error "Unsupported architecture: $ARCH" + ;; + esac + + BINARY_NAME="onebox-${PLATFORM}-${ARCH}" +} + +# Get latest version from Gitea API +get_latest_version() { + info "Fetching latest version..." + VERSION=$(curl -s "${REPO_URL}/releases" | grep -o '"tag_name":"v[^"]*' | head -1 | cut -d'"' -f4 | cut -c2-) + + if [ -z "$VERSION" ]; then + warn "Could not fetch latest version, using 'main' branch" + VERSION="main" + else + info "Latest version: v${VERSION}" + fi +} + +# Check if running as root +check_root() { + if [ "$EUID" -ne 0 ]; then + error "This script must be run as root (use sudo)" + fi +} + +# Download binary +download_binary() { + info "Downloading Onebox ${VERSION} for ${PLATFORM}-${ARCH}..." + + # Create temp directory + TMP_DIR=$(mktemp -d) + TMP_FILE="${TMP_DIR}/${BINARY_NAME}" + + # Try release download first + if [ "$VERSION" != "main" ]; then + DOWNLOAD_URL="${REPO_URL}/releases/download/v${VERSION}/${BINARY_NAME}" + else + DOWNLOAD_URL="${REPO_URL}/raw/branch/main/dist/binaries/${BINARY_NAME}" + fi + + if ! curl -L -f -o "$TMP_FILE" "$DOWNLOAD_URL"; then + error "Failed to download binary from $DOWNLOAD_URL" + fi + + # Verify download + if [ ! -f "$TMP_FILE" ] || [ ! -s "$TMP_FILE" ]; then + error "Downloaded file is empty or missing" + fi + + info "✓ Download complete" +} + +# Install binary +install_binary() { + info "Installing Onebox to ${INSTALL_DIR}..." + + # Create install directory + mkdir -p "$INSTALL_DIR" + + # Copy binary + cp "$TMP_FILE" "${INSTALL_DIR}/onebox" + chmod +x "${INSTALL_DIR}/onebox" + + # Create symlink + ln -sf "${INSTALL_DIR}/onebox" "$BIN_LINK" + + # Cleanup temp files + rm -rf "$TMP_DIR" + + info "✓ Installation complete" +} + +# Initialize database and config +initialize() { + info "Initializing Onebox..." + + # Create data directory + mkdir -p /var/lib/onebox + + # Create certbot directory for ACME challenges + mkdir -p /var/www/certbot + + info "✓ Initialization complete" +} + +# Print success message +print_success() { + echo "" + info "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + info " Onebox installed successfully!" + info "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "" + echo "Next steps:" + echo "" + echo "1. Configure Cloudflare (optional):" + echo " onebox config set cloudflareAPIKey " + echo " onebox config set cloudflareEmail " + echo " onebox config set cloudflareZoneID " + echo " onebox config set serverIP " + echo "" + echo "2. Configure ACME email:" + echo " onebox config set acmeEmail " + echo "" + echo "3. Install daemon:" + echo " onebox daemon install" + echo "" + echo "4. Start daemon:" + echo " onebox daemon start" + echo "" + echo "5. Deploy your first service:" + echo " onebox service add myapp --image nginx:latest --domain app.example.com" + echo "" + echo "Web UI: http://localhost:3000" + echo "Default credentials: admin / admin" + echo "" +} + +# Main installation flow +main() { + info "Onebox Installer" + echo "" + + check_root + detect_platform + get_latest_version + download_binary + install_binary + initialize + print_success +} + +# Run main function +main diff --git a/license b/license new file mode 100644 index 0000000..12efd5e --- /dev/null +++ b/license @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Lossless GmbH + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/mod.ts b/mod.ts new file mode 100644 index 0000000..64a9692 --- /dev/null +++ b/mod.ts @@ -0,0 +1,21 @@ +#!/usr/bin/env -S deno run --allow-all + +/** + * Onebox - Self-hosted container platform + * + * Entry point for the Onebox CLI and daemon. + */ + +import { runCli } from './ts/index.ts'; + +if (import.meta.main) { + try { + await runCli(); + } catch (error) { + console.error(`Error: ${error.message}`); + if (Deno.args.includes('--debug')) { + console.error(error.stack); + } + Deno.exit(1); + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..fc45085 --- /dev/null +++ b/package.json @@ -0,0 +1,52 @@ +{ + "name": "@serve.zone/onebox", + "version": "1.0.0", + "description": "Self-hosted container platform with automatic SSL and DNS - a mini Heroku for single servers", + "main": "mod.ts", + "type": "module", + "bin": { + "onebox": "./bin/onebox-wrapper.js" + }, + "scripts": { + "postinstall": "node scripts/install-binary.js" + }, + "keywords": [ + "docker", + "containers", + "nginx", + "ssl", + "acme", + "letsencrypt", + "cloudflare", + "dns", + "heroku", + "paas", + "deployment" + ], + "author": "Lossless GmbH", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://code.foss.global/serve.zone/onebox" + }, + "homepage": "https://code.foss.global/serve.zone/onebox", + "bugs": { + "url": "https://code.foss.global/serve.zone/onebox/issues" + }, + "files": [ + "bin/", + "scripts/install-binary.js", + "readme.md", + "license", + "changelog.md" + ], + "os": [ + "linux", + "darwin", + "win32" + ], + "cpu": [ + "x64", + "arm64" + ] +} diff --git a/readme.hints.md b/readme.hints.md new file mode 100644 index 0000000..ba063e7 --- /dev/null +++ b/readme.hints.md @@ -0,0 +1,253 @@ +# Onebox - Project Hints + +## Architecture Overview + +Onebox is a Deno-based self-hosted container platform that compiles to standalone binaries. It follows the same architectural patterns as nupst and spark projects. + +### Core Components + +1. **OneboxDatabase** (`ts/onebox.classes.database.ts`) + - SQLite-based storage + - Tables: services, registries, nginx_configs, ssl_certificates, dns_records, metrics, logs, users, settings + - Migration system for schema updates + +2. **OneboxDockerManager** (`ts/onebox.classes.docker.ts`) + - Docker API integration via @apiclient.xyz/docker + - Container lifecycle management + - Network management (onebox-network bridge) + - Stats collection and logging + +3. **OneboxServicesManager** (`ts/onebox.classes.services.ts`) + - High-level service orchestration + - Coordinates Docker + Nginx + DNS + SSL + - Service deployment workflow + +4. **OneboxRegistriesManager** (`ts/onebox.classes.registries.ts`) + - Docker registry authentication + - Credential storage (encrypted) + - Auto-login on daemon start + +5. **OneboxNginxManager** (`ts/onebox.classes.nginx.ts`) + - Nginx reverse proxy configuration + - Config file generation + - SSL enablement + - Reload and testing + +6. **OneboxDnsManager** (`ts/onebox.classes.dns.ts`) + - Cloudflare API integration + - Automatic A record creation + - DNS sync and verification + +7. **OneboxSslManager** (`ts/onebox.classes.ssl.ts`) + - Let's Encrypt integration via certbot + - Certificate issuance and renewal + - Expiry monitoring + +8. **OneboxDaemon** (`ts/onebox.classes.daemon.ts`) + - Background monitoring loop + - Metrics collection (every 60s by default) + - SSL certificate renewal checks + - Service health monitoring + - Systemd integration + +9. **OneboxHttpServer** (`ts/onebox.classes.httpserver.ts`) + - REST API endpoints + - Static file serving (for Angular UI) + - Authentication middleware + +10. **Onebox** (`ts/onebox.classes.onebox.ts`) + - Main coordinator class + - Initializes all components + - Provides unified API + +### CLI Structure + +- `onebox service` - Service management +- `onebox registry` - Registry credentials +- `onebox dns` - DNS records +- `onebox ssl` - SSL certificates +- `onebox nginx` - Nginx control +- `onebox daemon` - Systemd daemon +- `onebox config` - Settings management +- `onebox status` - System status + +### Deployment Workflow + +1. User runs: `onebox service add myapp --image nginx --domain app.example.com` +2. Service record created in database +3. Docker image pulled from registry +4. Container created and started +5. Nginx config generated and reloaded +6. DNS record created (if configured) +7. SSL certificate obtained (if configured) +8. Service is live! + +### Configuration + +Settings stored in database (settings table): +- `cloudflareAPIKey` - Cloudflare API key +- `cloudflareEmail` - Cloudflare email +- `cloudflareZoneID` - Cloudflare zone ID +- `acmeEmail` - Let's Encrypt email +- `serverIP` - Server public IP +- `nginxConfigDir` - Custom nginx config directory +- `httpPort` - HTTP server port (default: 3000) +- `metricsInterval` - Metrics collection interval (default: 60000ms) +- `logRetentionDays` - Log retention period + +### Data Locations + +- Database: `/var/lib/onebox/onebox.db` +- Nginx configs: `/etc/nginx/sites-available/onebox-*` +- SSL certificates: `/etc/letsencrypt/live//` +- Certbot webroot: `/var/www/certbot` + +## Development + +### Running Locally + +```bash +# Development mode +deno task dev + +# Run tests +deno task test + +# Compile all binaries +deno task compile +``` + +### Adding a New Feature + +1. Create new class in `ts/onebox.classes..ts` +2. Add to main Onebox class in `ts/onebox.classes.onebox.ts` +3. Add CLI commands in `ts/onebox.cli.ts` +4. Add API endpoints in `ts/onebox.classes.httpserver.ts` +5. Update types in `ts/onebox.types.ts` +6. Add tests in `test/` +7. Update documentation + +### Database Migrations + +Add migration logic in `OneboxDatabase.runMigrations()`: + +```typescript +if (currentVersion === 1) { + this.db.query('ALTER TABLE services ADD COLUMN new_field TEXT'); + this.setMigrationVersion(2); +} +``` + +## TODO + +### Core Functionality (Complete ✓) +- [x] Database layer with SQLite +- [x] Docker integration +- [x] Service management +- [x] Registry authentication +- [x] Nginx reverse proxy +- [x] DNS management (Cloudflare) +- [x] SSL certificates (Let's Encrypt) +- [x] Background daemon +- [x] HTTP API server +- [x] CLI commands +- [x] Build system + +### Next Steps +- [ ] Angular UI implementation + - Dashboard with service cards + - Service deployment form + - Logs viewer + - Metrics charts + - Settings page +- [ ] Authentication system (JWT) + - Login endpoint + - Token validation middleware + - Password hashing (bcrypt) +- [ ] WebSocket support for real-time logs/metrics +- [ ] Health checks for services +- [ ] Backup/restore functionality +- [ ] Multi-server support +- [ ] Load balancing +- [ ] Service templates/blueprints + +### Testing +- [ ] Unit tests for all managers +- [ ] Integration tests for deployment workflow +- [ ] Mock Docker API for tests +- [ ] Database migration tests + +### Documentation +- [ ] API documentation (OpenAPI/Swagger) +- [ ] Architecture diagram +- [ ] Deployment guide +- [ ] Troubleshooting guide +- [ ] Video tutorial + +## Common Issues + +### Docker Connection +If Docker commands fail, ensure: +- Docker daemon is running: `systemctl status docker` +- User has Docker permissions: `usermod -aG docker $USER` +- Socket exists: `ls -l /var/run/docker.sock` + +### Nginx Issues +If nginx fails to reload: +- Check syntax: `onebox nginx test` +- Check logs: `journalctl -u nginx -n 50` +- Verify config files exist in `/etc/nginx/sites-available/` + +### SSL Certificate Issues +If certbot fails: +- Verify domain DNS points to server +- Check port 80 is accessible +- Verify nginx is serving `.well-known/acme-challenge/` +- Check certbot logs: `journalctl -u certbot -n 50` + +### Cloudflare DNS Issues +If DNS records aren't created: +- Verify API credentials: `onebox config show` +- Check zone ID matches your domain +- Verify API key has DNS edit permissions + +## Dependencies + +### Deno Packages +- `@std/path` - Path utilities +- `@std/fs` - Filesystem operations +- `@std/http` - HTTP server +- `@db/sqlite` - SQLite database + +### NPM Packages (via Deno) +- `@push.rocks/smartdaemon` - Systemd integration +- `@apiclient.xyz/docker` - Docker API client +- `@apiclient.xyz/cloudflare` - Cloudflare API client +- `@push.rocks/smartacme` - ACME/Let's Encrypt + +### System Dependencies +- `docker` - Container runtime +- `nginx` - Reverse proxy +- `certbot` - SSL certificates +- `systemd` - Service management + +## Release Process + +1. Update version in `deno.json` +2. Update `changelog.md` +3. Commit changes +4. Run `deno task compile` to build all binaries +5. Test binaries on each platform +6. Create git tag: `git tag v1.0.0` +7. Push tag: `git push origin v1.0.0` +8. Create Gitea release and upload binaries +9. Publish to npm: `pnpm publish` + +## Notes + +- Onebox requires root privileges for nginx, Docker, and port binding +- Default admin password should be changed immediately after installation +- Use `--debug` flag for verbose logging +- All Docker containers are on the `onebox-network` bridge +- Metrics are collected every 60 seconds by default +- SSL certificates auto-renew 30 days before expiry diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..ac6ed59 --- /dev/null +++ b/readme.md @@ -0,0 +1,209 @@ +# @serve.zone/onebox + +> Self-hosted container platform with automatic SSL and DNS - a mini Heroku for single servers + +**Onebox** is a single-executable tool that transforms any Linux server into a simple container hosting platform. Deploy Docker containers with automatic HTTPS, DNS configuration, and Nginx reverse proxy - all managed through a beautiful Angular web interface or powerful CLI. + +## Features + +- 🐳 **Docker Container Management** - Deploy, start, stop, and manage containers +- 🌐 **Automatic Nginx Reverse Proxy** - Traffic routing with zero configuration +- 🔒 **Automatic SSL Certificates** - Let's Encrypt integration via SmartACME +- ☁️ **Cloudflare DNS Integration** - Automatic DNS record management +- 📊 **Metrics & Monitoring** - Historical CPU, memory, and network stats +- 📝 **Log Aggregation** - Centralized container logs +- 🎨 **Angular Web UI** - Modern, responsive interface +- 👥 **Multi-user Support** - Role-based access control +- 🔐 **Private Registry Support** - Use Docker Hub, Gitea, or custom registries +- 💾 **SQLite Database** - Embedded, zero-configuration storage +- 📦 **Single Executable** - No dependencies, no installation hassle +- 🔄 **Systemd Integration** - Run as a daemon with auto-restart + +## Quick Start + +### Installation + +```bash +# Install via shell script +curl -sSL https://code.foss.global/serve.zone/onebox/raw/branch/main/install.sh | sudo bash + +# Or via npm/pnpm +pnpm install -g @serve.zone/onebox +``` + +### Deploy Your First Service + +```bash +# Add a registry (optional, for private images) +onebox registry add --url registry.example.com --username myuser --password mypass + +# Deploy a service +onebox service add myapp \ + --image nginx:latest \ + --domain app.example.com \ + --env PORT=80 + +# Check status +onebox service list + +# View logs +onebox service logs myapp +``` + +### Install as Daemon + +```bash +# Install systemd service +sudo onebox daemon install + +# Start the daemon +sudo onebox daemon start + +# View logs +sudo onebox daemon logs +``` + +### Access Web UI + +The web UI is available at `http://localhost:3000` (or configured port). + +Default credentials: +- Username: `admin` +- Password: `admin` (change immediately!) + +## CLI Reference + +### Service Management + +```bash +onebox service add --image --domain [--env KEY=VALUE] +onebox service remove +onebox service start +onebox service stop +onebox service restart +onebox service list +onebox service logs [--follow] +``` + +### Registry Management + +```bash +onebox registry add --url --username --password +onebox registry remove +onebox registry list +``` + +### DNS Management + +```bash +onebox dns add --ip +onebox dns remove +onebox dns list +onebox dns sync +``` + +### SSL Management + +```bash +onebox ssl renew [domain] +onebox ssl list +onebox ssl force-renew +``` + +### Nginx Management + +```bash +onebox nginx reload +onebox nginx test +onebox nginx status +``` + +### Daemon Management + +```bash +onebox daemon install +onebox daemon start +onebox daemon stop +onebox daemon restart +onebox daemon logs +``` + +### User Management + +```bash +onebox user add --password [--role admin|user] +onebox user remove +onebox user list +onebox user passwd +``` + +### Configuration + +```bash +onebox config show +onebox config set +``` + +### Metrics + +```bash +onebox metrics [service-name] +``` + +## Architecture + +Onebox is built with Deno and compiles to a standalone binary for each platform: + +- **Deno Runtime** - Modern TypeScript with built-in security +- **SQLite** - Embedded database for configuration and metrics +- **Docker Engine** - Container runtime (required on host) +- **Nginx** - Reverse proxy and SSL termination +- **Cloudflare API** - DNS management +- **Let's Encrypt** - Free SSL certificates +- **Angular 18+** - Modern web interface + +## Requirements + +- **Linux** x64 or ARM64 (primary target) +- **Docker** installed and running +- **Nginx** installed +- **Root/sudo access** (for nginx, Docker, ports 80/443) +- **(Optional) Cloudflare account** for DNS management + +## Development + +```bash +# Clone repository +git clone https://code.foss.global/serve.zone/onebox +cd onebox + +# Run in development mode +deno task dev + +# Run tests +deno task test + +# Compile for all platforms +deno task compile +``` + +## Configuration + +Onebox stores configuration in: +- **Database**: `/var/lib/onebox/onebox.db` +- **Nginx configs**: `/etc/nginx/sites-available/onebox-*` +- **SSL certificates**: `/etc/letsencrypt/live/` + +## Contributing + +Contributions welcome! Please read the contributing guidelines first. + +## License + +MIT © Lossless GmbH + +## Links + +- [Documentation](https://code.foss.global/serve.zone/onebox/src/branch/main/docs) +- [Issue Tracker](https://code.foss.global/serve.zone/onebox/issues) +- [Changelog](./changelog.md) diff --git a/scripts/compile-all.sh b/scripts/compile-all.sh new file mode 100755 index 0000000..07f84fe --- /dev/null +++ b/scripts/compile-all.sh @@ -0,0 +1,56 @@ +#!/bin/bash +# +# Compile Onebox for all platforms +# + +set -e + +VERSION=$(grep '"version"' deno.json | cut -d'"' -f4) +echo "Compiling Onebox v${VERSION} for all platforms..." + +# Create dist directory +mkdir -p dist/binaries + +# Compile for each platform +echo "Compiling for Linux x64..." +deno compile --allow-all --no-check \ + --output "dist/binaries/onebox-linux-x64" \ + --target x86_64-unknown-linux-gnu \ + mod.ts + +echo "Compiling for Linux ARM64..." +deno compile --allow-all --no-check \ + --output "dist/binaries/onebox-linux-arm64" \ + --target aarch64-unknown-linux-gnu \ + mod.ts + +echo "Compiling for macOS x64..." +deno compile --allow-all --no-check \ + --output "dist/binaries/onebox-macos-x64" \ + --target x86_64-apple-darwin \ + mod.ts + +echo "Compiling for macOS ARM64..." +deno compile --allow-all --no-check \ + --output "dist/binaries/onebox-macos-arm64" \ + --target aarch64-apple-darwin \ + mod.ts + +echo "Compiling for Windows x64..." +deno compile --allow-all --no-check \ + --output "dist/binaries/onebox-windows-x64.exe" \ + --target x86_64-pc-windows-msvc \ + mod.ts + +echo "" +echo "✓ Compilation complete!" +echo "" +echo "Binaries:" +ls -lh dist/binaries/ +echo "" +echo "Next steps:" +echo "1. Test binaries on their respective platforms" +echo "2. Create git tag: git tag v${VERSION}" +echo "3. Push tag: git push origin v${VERSION}" +echo "4. Upload binaries to Gitea release" +echo "5. Publish to npm: pnpm publish" diff --git a/scripts/install-binary.js b/scripts/install-binary.js new file mode 100644 index 0000000..7199f12 --- /dev/null +++ b/scripts/install-binary.js @@ -0,0 +1,105 @@ +#!/usr/bin/env node + +/** + * Postinstall script to download Onebox binary + */ + +import { platform, arch } from 'os'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; +import { createWriteStream, mkdirSync, chmodSync, existsSync } from 'fs'; +import { get } from 'https'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +// Get version from package.json +import { readFileSync } from 'fs'; +const packageJson = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8')); +const VERSION = packageJson.version; + +// Detect platform +const platformMap = { + 'linux': 'linux', + 'darwin': 'macos', + 'win32': 'windows', +}; + +const archMap = { + 'x64': 'x64', + 'arm64': 'arm64', +}; + +const currentPlatform = platformMap[platform()]; +const currentArch = archMap[arch()]; + +if (!currentPlatform || !currentArch) { + console.error(`Unsupported platform: ${platform()} ${arch()}`); + process.exit(1); +} + +const binaryName = `onebox-${currentPlatform}-${currentArch}${currentPlatform === 'windows' ? '.exe' : ''}`; +const binaryPath = join(__dirname, '..', 'dist', 'binaries', binaryName); + +// Create directory +mkdirSync(join(__dirname, '..', 'dist', 'binaries'), { recursive: true }); + +// Check if binary already exists +if (existsSync(binaryPath)) { + console.log('Binary already exists, skipping download'); + process.exit(0); +} + +// Download URLs +const releaseUrl = `https://code.foss.global/serve.zone/onebox/releases/download/v${VERSION}/${binaryName}`; +const fallbackUrl = `https://code.foss.global/serve.zone/onebox/raw/branch/main/dist/binaries/${binaryName}`; + +console.log(`Downloading Onebox v${VERSION} for ${currentPlatform}-${currentArch}...`); + +function download(url, fallback = false) { + return new Promise((resolve, reject) => { + get(url, (response) => { + if (response.statusCode === 302 || response.statusCode === 301) { + // Follow redirect + download(response.headers.location, fallback).then(resolve).catch(reject); + return; + } + + if (response.statusCode !== 200) { + if (!fallback) { + console.warn(`Release not found, trying fallback URL...`); + download(fallbackUrl, true).then(resolve).catch(reject); + } else { + reject(new Error(`Failed to download: HTTP ${response.statusCode}`)); + } + return; + } + + const fileStream = createWriteStream(binaryPath); + + response.pipe(fileStream); + + fileStream.on('finish', () => { + fileStream.close(); + + // Make executable (Unix only) + if (currentPlatform !== 'windows') { + chmodSync(binaryPath, 0o755); + } + + console.log('✓ Binary downloaded successfully'); + resolve(); + }); + + fileStream.on('error', reject); + }).on('error', reject); + }); +} + +download(releaseUrl).catch((error) => { + console.error(`Failed to download binary: ${error.message}`); + console.error('You can manually download the binary from:'); + console.error(releaseUrl); + console.error(`And place it at: ${binaryPath}`); + process.exit(1); +}); diff --git a/ts/index.ts b/ts/index.ts new file mode 100644 index 0000000..69d672c --- /dev/null +++ b/ts/index.ts @@ -0,0 +1,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'; + +// Types +export * from './onebox.types.ts'; + +// Logging +export { logger } from './onebox.logging.ts'; + +// Version info +export { projectInfo } from './onebox.info.ts'; diff --git a/ts/onebox.classes.daemon.ts b/ts/onebox.classes.daemon.ts new file mode 100644 index 0000000..ac11ec9 --- /dev/null +++ b/ts/onebox.classes.daemon.ts @@ -0,0 +1,292 @@ +/** + * Daemon Manager for Onebox + * + * 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'; + +export class OneboxDaemon { + private oneboxRef: Onebox; + private smartdaemon: plugins.smartdaemon.SmartDaemon; + private running = false; + private monitoringInterval: number | null = null; + private metricsInterval = 60000; // 1 minute + + 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); + } + } + + /** + * Install systemd service + */ + async installService(): Promise { + try { + logger.info('Installing Onebox daemon service...'); + + // Get installation directory + const execPath = Deno.execPath(); + + const service = await this.smartdaemon.addService({ + name: 'onebox', + version: projectInfo.version, + command: `${execPath} run --allow-all ${Deno.cwd()}/mod.ts daemon start`, + description: 'Onebox - Self-hosted container platform', + workingDir: Deno.cwd(), + }); + + await service.save(); + await service.enable(); + + logger.success('Onebox daemon service installed'); + logger.info('Start with: sudo systemctl start smartdaemon_onebox'); + } catch (error) { + logger.error(`Failed to install daemon service: ${error.message}`); + throw error; + } + } + + /** + * Uninstall systemd service + */ + async uninstallService(): Promise { + try { + logger.info('Uninstalling Onebox daemon service...'); + + const service = await this.smartdaemon.getService('onebox'); + + if (service) { + await service.stop(); + await service.disable(); + await service.delete(); + } + + logger.success('Onebox daemon service uninstalled'); + } catch (error) { + logger.error(`Failed to uninstall daemon service: ${error.message}`); + throw error; + } + } + + /** + * Start daemon mode (background monitoring) + */ + async start(): Promise { + try { + if (this.running) { + logger.warn('Daemon already running'); + return; + } + + logger.info('Starting Onebox daemon...'); + + this.running = true; + + // Start monitoring loop + this.startMonitoring(); + + // Start HTTP server + const httpPort = parseInt(this.oneboxRef.database.getSetting('httpPort') || '3000', 10); + await this.oneboxRef.httpServer.start(httpPort); + + logger.success('Onebox daemon started'); + logger.info(`Web UI available at http://localhost:${httpPort}`); + + // Keep process alive + await this.keepAlive(); + } catch (error) { + logger.error(`Failed to start daemon: ${error.message}`); + this.running = false; + throw error; + } + } + + /** + * Stop daemon mode + */ + async stop(): Promise { + try { + if (!this.running) { + return; + } + + logger.info('Stopping Onebox daemon...'); + + this.running = false; + + // Stop monitoring + this.stopMonitoring(); + + // Stop HTTP server + await this.oneboxRef.httpServer.stop(); + + logger.success('Onebox daemon stopped'); + } catch (error) { + logger.error(`Failed to stop daemon: ${error.message}`); + throw error; + } + } + + /** + * Start monitoring loop + */ + private startMonitoring(): void { + logger.info('Starting monitoring loop...'); + + this.monitoringInterval = setInterval(async () => { + await this.monitoringTick(); + }, this.metricsInterval); + + // Run first tick immediately + this.monitoringTick(); + } + + /** + * Stop monitoring loop + */ + private stopMonitoring(): void { + if (this.monitoringInterval !== null) { + clearInterval(this.monitoringInterval); + this.monitoringInterval = null; + logger.debug('Monitoring loop stopped'); + } + } + + /** + * Single monitoring tick + */ + private async monitoringTick(): Promise { + try { + logger.debug('Running monitoring tick...'); + + // Collect metrics for all services + await this.collectMetrics(); + + // Sync service statuses + await this.oneboxRef.services.syncAllServiceStatuses(); + + // Check SSL certificate expiration + await this.checkSSLExpiration(); + + // Check service health (TODO: implement health checks) + + logger.debug('Monitoring tick complete'); + } catch (error) { + logger.error(`Monitoring tick failed: ${error.message}`); + } + } + + /** + * Collect metrics for all services + */ + private async collectMetrics(): Promise { + try { + const services = this.oneboxRef.services.listServices(); + + for (const service of services) { + if (service.status === 'running' && service.containerID) { + try { + const stats = await this.oneboxRef.docker.getContainerStats(service.containerID); + + if (stats) { + this.oneboxRef.database.addMetric({ + serviceId: service.id!, + timestamp: Date.now(), + cpuPercent: stats.cpuPercent, + memoryUsed: stats.memoryUsed, + memoryLimit: stats.memoryLimit, + networkRxBytes: stats.networkRx, + networkTxBytes: stats.networkTx, + }); + } + } catch (error) { + logger.debug(`Failed to collect metrics for ${service.name}: ${error.message}`); + } + } + } + } catch (error) { + logger.error(`Failed to collect metrics: ${error.message}`); + } + } + + /** + * Check SSL certificate expiration + */ + private async checkSSLExpiration(): Promise { + try { + if (!this.oneboxRef.ssl.isConfigured()) { + return; + } + + await this.oneboxRef.ssl.renewExpiring(); + } catch (error) { + logger.error(`Failed to check SSL expiration: ${error.message}`); + } + } + + /** + * Keep process alive + */ + private async keepAlive(): Promise { + // Set up signal handlers + const signalHandler = () => { + logger.info('Received shutdown signal'); + this.stop().then(() => { + Deno.exit(0); + }); + }; + + Deno.addSignalListener('SIGINT', signalHandler); + Deno.addSignalListener('SIGTERM', signalHandler); + + // Keep event loop alive + while (this.running) { + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + } + + /** + * Get daemon status + */ + isRunning(): boolean { + return this.running; + } + + /** + * Get service status from systemd + */ + async getServiceStatus(): Promise { + try { + const command = new Deno.Command('systemctl', { + args: ['status', 'smartdaemon_onebox'], + 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) { + return 'not-installed'; + } + } +} diff --git a/ts/onebox.classes.database.ts b/ts/onebox.classes.database.ts new file mode 100644 index 0000000..377c350 --- /dev/null +++ b/ts/onebox.classes.database.ts @@ -0,0 +1,659 @@ +/** + * Database layer for Onebox using SQLite + */ + +import * as plugins from './onebox.plugins.ts'; +import type { + IService, + IRegistry, + INginxConfig, + ISslCertificate, + IDnsRecord, + IMetric, + ILogEntry, + IUser, + ISetting, +} from './onebox.types.ts'; +import { logger } from './onebox.logging.ts'; + +export class OneboxDatabase { + private db: plugins.sqlite.DB | null = null; + private dbPath: string; + + constructor(dbPath = '/var/lib/onebox/onebox.db') { + this.dbPath = dbPath; + } + + /** + * Initialize database connection and create tables + */ + async init(): Promise { + try { + // Ensure data directory exists + const dbDir = plugins.path.dirname(this.dbPath); + await Deno.mkdir(dbDir, { recursive: true }); + + // Open database + this.db = new plugins.sqlite.DB(this.dbPath); + logger.info(`Database initialized at ${this.dbPath}`); + + // Create tables + await this.createTables(); + + // Run migrations if needed + await this.runMigrations(); + } catch (error) { + logger.error(`Failed to initialize database: ${error.message}`); + throw error; + } + } + + /** + * Create all database tables + */ + private async createTables(): Promise { + if (!this.db) throw new Error('Database not initialized'); + + // Services table + this.db.query(` + CREATE TABLE IF NOT EXISTS services ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + image TEXT NOT NULL, + registry TEXT, + env_vars TEXT NOT NULL, + port INTEGER NOT NULL, + domain TEXT, + container_id TEXT, + status TEXT NOT NULL DEFAULT 'stopped', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ) + `); + + // Registries table + this.db.query(` + CREATE TABLE IF NOT EXISTS registries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + url TEXT NOT NULL UNIQUE, + username TEXT NOT NULL, + password_encrypted TEXT NOT NULL, + created_at INTEGER NOT NULL + ) + `); + + // Nginx configs table + this.db.query(` + CREATE TABLE IF NOT EXISTS nginx_configs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + service_id INTEGER NOT NULL, + domain TEXT NOT NULL, + port INTEGER NOT NULL, + ssl_enabled INTEGER NOT NULL DEFAULT 0, + config_template TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE + ) + `); + + // SSL certificates table + this.db.query(` + CREATE TABLE IF NOT EXISTS ssl_certificates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + domain TEXT NOT NULL UNIQUE, + cert_path TEXT NOT NULL, + key_path TEXT NOT NULL, + full_chain_path TEXT NOT NULL, + expiry_date INTEGER NOT NULL, + issuer TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ) + `); + + // DNS records table + this.db.query(` + CREATE TABLE IF NOT EXISTS dns_records ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + domain TEXT NOT NULL UNIQUE, + type TEXT NOT NULL, + value TEXT NOT NULL, + cloudflare_id TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ) + `); + + // Metrics table + this.db.query(` + CREATE TABLE IF NOT EXISTS metrics ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + service_id INTEGER NOT NULL, + timestamp INTEGER NOT NULL, + cpu_percent REAL NOT NULL, + memory_used INTEGER NOT NULL, + memory_limit INTEGER NOT NULL, + network_rx_bytes INTEGER NOT NULL, + network_tx_bytes INTEGER NOT NULL, + FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE + ) + `); + + // Create index for metrics queries + this.db.query(` + CREATE INDEX IF NOT EXISTS idx_metrics_service_timestamp + ON metrics(service_id, timestamp DESC) + `); + + // Logs table + this.db.query(` + CREATE TABLE IF NOT EXISTS logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + service_id INTEGER NOT NULL, + timestamp INTEGER NOT NULL, + message TEXT NOT NULL, + level TEXT NOT NULL, + source TEXT NOT NULL, + FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE + ) + `); + + // Create index for logs queries + this.db.query(` + CREATE INDEX IF NOT EXISTS idx_logs_service_timestamp + ON logs(service_id, timestamp DESC) + `); + + // Users table + this.db.query(` + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'user', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ) + `); + + // Settings table + this.db.query(` + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at INTEGER NOT NULL + ) + `); + + // Version table for migrations + this.db.query(` + CREATE TABLE IF NOT EXISTS migrations ( + version INTEGER PRIMARY KEY, + applied_at INTEGER NOT NULL + ) + `); + + logger.debug('Database tables created successfully'); + } + + /** + * Run database migrations + */ + private async runMigrations(): Promise { + if (!this.db) throw new Error('Database not initialized'); + + const currentVersion = this.getMigrationVersion(); + logger.debug(`Current database version: ${currentVersion}`); + + // Add migration logic here as needed + // For now, just set version to 1 + if (currentVersion === 0) { + this.setMigrationVersion(1); + } + } + + /** + * Get current migration version + */ + private getMigrationVersion(): number { + if (!this.db) throw new Error('Database not initialized'); + + try { + const result = this.db.query('SELECT MAX(version) as version FROM migrations'); + return result.length > 0 && result[0][0] !== null ? Number(result[0][0]) : 0; + } catch { + return 0; + } + } + + /** + * Set migration version + */ + private setMigrationVersion(version: number): void { + if (!this.db) throw new Error('Database not initialized'); + + this.db.query('INSERT INTO migrations (version, applied_at) VALUES (?, ?)', [ + version, + Date.now(), + ]); + logger.debug(`Migration version set to ${version}`); + } + + /** + * Close database connection + */ + close(): void { + if (this.db) { + this.db.close(); + this.db = null; + logger.debug('Database connection closed'); + } + } + + /** + * Execute a raw query + */ + query(sql: string, params: unknown[] = []): T[] { + if (!this.db) throw new Error('Database not initialized'); + return this.db.query(sql, params) as T[]; + } + + // ============ Services CRUD ============ + + async createService(service: Omit): Promise { + if (!this.db) throw new Error('Database not initialized'); + + const now = Date.now(); + this.db.query( + `INSERT INTO services (name, image, registry, env_vars, port, domain, container_id, status, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + service.name, + service.image, + service.registry || null, + JSON.stringify(service.envVars), + service.port, + service.domain || null, + service.containerID || null, + service.status, + now, + now, + ] + ); + + return this.getServiceByName(service.name)!; + } + + 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]); + 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]); + 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'); + return rows.map((row) => this.rowToService(row)); + } + + updateService(id: number, updates: Partial): void { + if (!this.db) throw new Error('Database not initialized'); + + const fields: string[] = []; + const values: unknown[] = []; + + if (updates.image !== undefined) { + fields.push('image = ?'); + values.push(updates.image); + } + if (updates.registry !== undefined) { + fields.push('registry = ?'); + values.push(updates.registry); + } + if (updates.envVars !== undefined) { + fields.push('env_vars = ?'); + values.push(JSON.stringify(updates.envVars)); + } + if (updates.port !== undefined) { + fields.push('port = ?'); + values.push(updates.port); + } + if (updates.domain !== undefined) { + fields.push('domain = ?'); + values.push(updates.domain); + } + if (updates.containerID !== undefined) { + fields.push('container_id = ?'); + values.push(updates.containerID); + } + if (updates.status !== undefined) { + fields.push('status = ?'); + values.push(updates.status); + } + + fields.push('updated_at = ?'); + values.push(Date.now()); + values.push(id); + + this.db.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]); + } + + private rowToService(row: unknown[]): IService { + return { + id: Number(row[0]), + name: String(row[1]), + image: String(row[2]), + registry: row[3] ? String(row[3]) : undefined, + envVars: JSON.parse(String(row[4])), + port: Number(row[5]), + domain: row[6] ? String(row[6]) : undefined, + containerID: row[7] ? String(row[7]) : undefined, + status: String(row[8]) as IService['status'], + createdAt: Number(row[9]), + updatedAt: Number(row[10]), + }; + } + + // ============ Registries CRUD ============ + + async createRegistry(registry: Omit): Promise { + if (!this.db) throw new Error('Database not initialized'); + + const now = Date.now(); + this.db.query( + 'INSERT INTO registries (url, username, password_encrypted, created_at) VALUES (?, ?, ?, ?)', + [registry.url, registry.username, registry.passwordEncrypted, now] + ); + + return this.getRegistryByURL(registry.url)!; + } + + 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]); + 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'); + 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]); + } + + private rowToRegistry(row: unknown[]): IRegistry { + return { + id: Number(row[0]), + url: String(row[1]), + username: String(row[2]), + passwordEncrypted: String(row[3]), + createdAt: Number(row[4]), + }; + } + + // ============ Settings CRUD ============ + + 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]); + return rows.length > 0 ? String(rows[0][0]) : null; + } + + setSetting(key: string, value: string): void { + if (!this.db) throw new Error('Database not initialized'); + + const now = Date.now(); + this.db.query( + 'INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES (?, ?, ?)', + [key, value, now] + ); + } + + getAllSettings(): Record { + if (!this.db) throw new Error('Database not initialized'); + + const rows = this.db.query('SELECT key, value FROM settings'); + const settings: Record = {}; + for (const row of rows) { + settings[String(row[0])] = String(row[1]); + } + return settings; + } + + // ============ Users CRUD ============ + + async createUser(user: Omit): Promise { + if (!this.db) throw new Error('Database not initialized'); + + const now = Date.now(); + this.db.query( + 'INSERT INTO users (username, password_hash, role, created_at, updated_at) VALUES (?, ?, ?, ?, ?)', + [user.username, user.passwordHash, user.role, now, now] + ); + + return this.getUserByUsername(user.username)!; + } + + 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]); + 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'); + 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 = ?', [ + passwordHash, + Date.now(), + username, + ]); + } + + deleteUser(username: string): void { + if (!this.db) throw new Error('Database not initialized'); + this.db.query('DELETE FROM users WHERE username = ?', [username]); + } + + private rowToUser(row: unknown[]): 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]), + }; + } + + // ============ Metrics ============ + + addMetric(metric: Omit): void { + if (!this.db) throw new Error('Database not initialized'); + + this.db.query( + `INSERT INTO metrics (service_id, timestamp, cpu_percent, memory_used, memory_limit, network_rx_bytes, network_tx_bytes) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [ + metric.serviceId, + metric.timestamp, + metric.cpuPercent, + metric.memoryUsed, + metric.memoryLimit, + metric.networkRxBytes, + metric.networkTxBytes, + ] + ); + } + + getMetrics(serviceId: number, limit = 100): IMetric[] { + if (!this.db) throw new Error('Database not initialized'); + + const rows = this.db.query( + 'SELECT * FROM metrics WHERE service_id = ? ORDER BY timestamp DESC LIMIT ?', + [serviceId, limit] + ); + return rows.map((row) => this.rowToMetric(row)); + } + + private rowToMetric(row: unknown[]): IMetric { + return { + id: Number(row[0]), + serviceId: Number(row[1]), + timestamp: Number(row[2]), + cpuPercent: Number(row[3]), + memoryUsed: Number(row[4]), + memoryLimit: Number(row[5]), + networkRxBytes: Number(row[6]), + networkTxBytes: Number(row[7]), + }; + } + + // ============ Logs ============ + + addLog(log: Omit): void { + if (!this.db) throw new Error('Database not initialized'); + + this.db.query( + 'INSERT INTO logs (service_id, timestamp, message, level, source) VALUES (?, ?, ?, ?, ?)', + [log.serviceId, log.timestamp, log.message, log.level, log.source] + ); + } + + getLogs(serviceId: number, limit = 1000): ILogEntry[] { + if (!this.db) throw new Error('Database not initialized'); + + const rows = this.db.query( + 'SELECT * FROM logs WHERE service_id = ? ORDER BY timestamp DESC LIMIT ?', + [serviceId, limit] + ); + return rows.map((row) => this.rowToLog(row)); + } + + private rowToLog(row: unknown[]): ILogEntry { + return { + id: Number(row[0]), + serviceId: Number(row[1]), + timestamp: Number(row[2]), + message: String(row[3]), + level: String(row[4]) as ILogEntry['level'], + source: String(row[5]) as ILogEntry['source'], + }; + } + + // ============ SSL Certificates ============ + + async createSSLCertificate(cert: Omit): Promise { + if (!this.db) throw new Error('Database not initialized'); + + const now = Date.now(); + this.db.query( + `INSERT INTO ssl_certificates (domain, cert_path, key_path, full_chain_path, expiry_date, issuer, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + [ + cert.domain, + cert.certPath, + cert.keyPath, + cert.fullChainPath, + cert.expiryDate, + cert.issuer, + now, + now, + ] + ); + + return this.getSSLCertificate(cert.domain)!; + } + + 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]); + 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'); + return rows.map((row) => this.rowToSSLCert(row)); + } + + updateSSLCertificate(domain: string, updates: Partial): void { + if (!this.db) throw new Error('Database not initialized'); + + const fields: string[] = []; + const values: unknown[] = []; + + if (updates.certPath) { + fields.push('cert_path = ?'); + values.push(updates.certPath); + } + if (updates.keyPath) { + fields.push('key_path = ?'); + values.push(updates.keyPath); + } + if (updates.fullChainPath) { + fields.push('full_chain_path = ?'); + values.push(updates.fullChainPath); + } + if (updates.expiryDate) { + fields.push('expiry_date = ?'); + values.push(updates.expiryDate); + } + + fields.push('updated_at = ?'); + values.push(Date.now()); + values.push(domain); + + this.db.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]); + } + + private rowToSSLCert(row: unknown[]): ISslCertificate { + return { + id: Number(row[0]), + domain: String(row[1]), + certPath: String(row[2]), + keyPath: String(row[3]), + fullChainPath: String(row[4]), + expiryDate: Number(row[5]), + issuer: String(row[6]), + createdAt: Number(row[7]), + updatedAt: Number(row[8]), + }; + } +} diff --git a/ts/onebox.classes.dns.ts b/ts/onebox.classes.dns.ts new file mode 100644 index 0000000..1249c78 --- /dev/null +++ b/ts/onebox.classes.dns.ts @@ -0,0 +1,270 @@ +/** + * DNS Manager for Onebox + * + * 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'; + +export class OneboxDnsManager { + private oneboxRef: any; + private database: OneboxDatabase; + private cloudflareClient: plugins.cloudflare.Cloudflare | null = null; + private zoneID: string | null = null; + private serverIP: string | null = null; + + constructor(oneboxRef: any) { + this.oneboxRef = oneboxRef; + this.database = oneboxRef.database; + } + + /** + * Initialize DNS manager with Cloudflare credentials + */ + async init(): Promise { + try { + // 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) { + logger.warn('Cloudflare credentials not configured. DNS management will be disabled.'); + logger.info('Configure with: onebox config set cloudflareAPIKey '); + return; + } + + this.zoneID = zoneID; + this.serverIP = serverIP; + + // Initialize Cloudflare client + this.cloudflareClient = new plugins.cloudflare.Cloudflare({ + apiKey, + email, + }); + + logger.info('DNS manager initialized with Cloudflare'); + } catch (error) { + logger.error(`Failed to initialize DNS manager: ${error.message}`); + throw error; + } + } + + /** + * Check if DNS manager is configured + */ + isConfigured(): boolean { + return this.cloudflareClient !== null && this.zoneID !== null; + } + + /** + * Add a DNS record for a domain + */ + async addDNSRecord(domain: string, ip?: string): Promise { + try { + if (!this.isConfigured()) { + throw new Error('DNS manager not configured'); + } + + logger.info(`Adding DNS record for ${domain}`); + + const targetIP = ip || this.serverIP; + if (!targetIP) { + throw new Error('Server IP not configured. Set with: onebox config set serverIP '); + } + + // Check if record already exists + const existing = await this.getDNSRecord(domain); + if (existing) { + logger.info(`DNS record already exists for ${domain}`); + return; + } + + // Create A record + const response = await this.cloudflareClient!.zones.dns.records.create(this.zoneID!, { + type: 'A', + name: domain, + content: targetIP, + ttl: 1, // Auto + proxied: false, // Don't proxy through Cloudflare for direct SSL + }); + + // Store in database + await this.database.query( + 'INSERT INTO dns_records (domain, type, value, cloudflare_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)', + [domain, 'A', targetIP, response.result.id, Date.now(), Date.now()] + ); + + logger.success(`DNS record created for ${domain} → ${targetIP}`); + } catch (error) { + logger.error(`Failed to add DNS record for ${domain}: ${error.message}`); + throw error; + } + } + + /** + * Remove a DNS record + */ + async removeDNSRecord(domain: string): Promise { + try { + if (!this.isConfigured()) { + throw new Error('DNS manager not configured'); + } + + logger.info(`Removing DNS record for ${domain}`); + + // Get record from database + const rows = this.database.query('SELECT cloudflare_id FROM dns_records WHERE domain = ?', [ + domain, + ]); + + if (rows.length === 0) { + logger.warn(`DNS record not found for ${domain}`); + return; + } + + const cloudflareID = String(rows[0][0]); + + // Delete from Cloudflare + if (cloudflareID) { + await this.cloudflareClient!.zones.dns.records.delete(this.zoneID!, cloudflareID); + } + + // Delete from database + this.database.query('DELETE FROM dns_records WHERE domain = ?', [domain]); + + logger.success(`DNS record removed for ${domain}`); + } catch (error) { + logger.error(`Failed to remove DNS record for ${domain}: ${error.message}`); + throw error; + } + } + + /** + * Get DNS record for a domain + */ + async getDNSRecord(domain: string): Promise { + try { + if (!this.isConfigured()) { + return null; + } + + // Get from database first + const rows = this.database.query('SELECT * FROM dns_records WHERE domain = ?', [domain]); + + if (rows.length > 0) { + return { + domain: String(rows[0][1]), + type: String(rows[0][2]), + value: String(rows[0][3]), + cloudflareID: rows[0][4] ? String(rows[0][4]) : null, + }; + } + + return null; + } catch (error) { + logger.error(`Failed to get DNS record for ${domain}: ${error.message}`); + return null; + } + } + + /** + * List all DNS records + */ + listDNSRecords(): any[] { + try { + const rows = this.database.query('SELECT * FROM dns_records ORDER BY created_at DESC'); + + return rows.map((row) => ({ + id: Number(row[0]), + domain: String(row[1]), + type: String(row[2]), + value: String(row[3]), + cloudflareID: row[4] ? String(row[4]) : null, + createdAt: Number(row[5]), + updatedAt: Number(row[6]), + })); + } catch (error) { + logger.error(`Failed to list DNS records: ${error.message}`); + return []; + } + } + + /** + * Sync DNS records from Cloudflare + */ + async syncFromCloudflare(): Promise { + try { + if (!this.isConfigured()) { + throw new Error('DNS manager not configured'); + } + + logger.info('Syncing DNS records from Cloudflare...'); + + const response = await this.cloudflareClient!.zones.dns.records.list(this.zoneID!); + const records = response.result; + + // Only sync A records + const aRecords = records.filter((r: any) => r.type === 'A'); + + for (const record of aRecords) { + // Check if exists in database + const existing = await this.getDNSRecord(record.name); + + if (!existing) { + // Add to database + await this.database.query( + 'INSERT INTO dns_records (domain, type, value, cloudflare_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)', + [record.name, record.type, record.content, record.id, Date.now(), Date.now()] + ); + + logger.info(`Synced DNS record: ${record.name}`); + } + } + + logger.success('DNS records synced from Cloudflare'); + } catch (error) { + logger.error(`Failed to sync DNS records: ${error.message}`); + throw error; + } + } + + /** + * Check if domain DNS is properly configured + */ + async checkDNS(domain: string): Promise { + try { + logger.info(`Checking DNS for ${domain}...`); + + // Use dig or nslookup to check DNS resolution + const command = new Deno.Command('dig', { + args: ['+short', domain], + stdout: 'piped', + stderr: 'piped', + }); + + const { code, stdout } = await command.output(); + + if (code !== 0) { + logger.warn(`DNS check failed for ${domain}`); + return false; + } + + const ip = new TextDecoder().decode(stdout).trim(); + + if (ip === this.serverIP) { + logger.success(`DNS correctly points to ${ip}`); + return true; + } else { + logger.warn(`DNS points to ${ip}, expected ${this.serverIP}`); + return false; + } + } catch (error) { + logger.error(`Failed to check DNS for ${domain}: ${error.message}`); + return false; + } + } +} diff --git a/ts/onebox.classes.docker.ts b/ts/onebox.classes.docker.ts new file mode 100644 index 0000000..77b8a0f --- /dev/null +++ b/ts/onebox.classes.docker.ts @@ -0,0 +1,489 @@ +/** + * Docker Manager for Onebox + * + * 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'; + +export class OneboxDockerManager { + private dockerClient: plugins.docker.Docker | null = null; + private networkName = 'onebox-network'; + + /** + * Initialize Docker client and create onebox network + */ + async init(): Promise { + try { + // Initialize Docker client (connects to /var/run/docker.sock by default) + this.dockerClient = new plugins.docker.Docker({ + socketPath: '/var/run/docker.sock', + }); + + logger.info('Docker client initialized'); + + // Ensure onebox network exists + await this.ensureNetwork(); + } catch (error) { + logger.error(`Failed to initialize Docker client: ${error.message}`); + throw error; + } + } + + /** + * Ensure onebox network exists + */ + private async ensureNetwork(): Promise { + try { + const networks = await this.dockerClient!.listNetworks(); + const existingNetwork = networks.find((n: any) => n.Name === this.networkName); + + if (!existingNetwork) { + logger.info(`Creating Docker network: ${this.networkName}`); + await this.dockerClient!.createNetwork({ + Name: this.networkName, + Driver: 'bridge', + Labels: { + 'managed-by': 'onebox', + }, + }); + logger.success(`Docker network created: ${this.networkName}`); + } else { + logger.debug(`Docker network already exists: ${this.networkName}`); + } + } catch (error) { + logger.error(`Failed to create Docker network: ${error.message}`); + throw error; + } + } + + /** + * Pull an image from a registry + */ + async pullImage(image: string, registry?: string): Promise { + try { + logger.info(`Pulling Docker image: ${image}`); + + const fullImage = registry ? `${registry}/${image}` : image; + + await this.dockerClient!.pull(fullImage, (error: any, stream: any) => { + if (error) { + throw error; + } + + // Follow progress + this.dockerClient!.modem.followProgress(stream, (err: any, output: any) => { + if (err) { + throw err; + } + logger.debug('Pull complete:', output); + }); + }); + + logger.success(`Image pulled successfully: ${fullImage}`); + } catch (error) { + logger.error(`Failed to pull image ${image}: ${error.message}`); + throw error; + } + } + + /** + * Create and start a container + */ + async createContainer(service: IService): Promise { + try { + logger.info(`Creating container for service: ${service.name}`); + + const fullImage = service.registry + ? `${service.registry}/${service.image}` + : service.image; + + // Prepare environment variables + const env: string[] = []; + for (const [key, value] of Object.entries(service.envVars)) { + env.push(`${key}=${value}`); + } + + // Create container + const container = await this.dockerClient!.createContainer({ + Image: fullImage, + name: `onebox-${service.name}`, + Env: env, + Labels: { + 'managed-by': 'onebox', + 'onebox-service': service.name, + }, + ExposedPorts: { + [`${service.port}/tcp`]: {}, + }, + HostConfig: { + NetworkMode: this.networkName, + RestartPolicy: { + Name: 'unless-stopped', + }, + PortBindings: { + // Don't bind to host ports - nginx will proxy + [`${service.port}/tcp`]: [], + }, + }, + }); + + const containerID = container.id; + logger.success(`Container created: ${containerID}`); + + return containerID; + } catch (error) { + logger.error(`Failed to create container for ${service.name}: ${error.message}`); + throw error; + } + } + + /** + * Start a container by ID + */ + async startContainer(containerID: string): Promise { + try { + logger.info(`Starting container: ${containerID}`); + + const container = this.dockerClient!.getContainer(containerID); + await container.start(); + + logger.success(`Container started: ${containerID}`); + } catch (error) { + // Ignore "already started" errors + if (error.message.includes('already started')) { + logger.debug(`Container already running: ${containerID}`); + return; + } + logger.error(`Failed to start container ${containerID}: ${error.message}`); + throw error; + } + } + + /** + * Stop a container by ID + */ + async stopContainer(containerID: string): Promise { + try { + logger.info(`Stopping container: ${containerID}`); + + const container = this.dockerClient!.getContainer(containerID); + await container.stop(); + + logger.success(`Container stopped: ${containerID}`); + } catch (error) { + // Ignore "already stopped" errors + if (error.message.includes('already stopped') || error.statusCode === 304) { + logger.debug(`Container already stopped: ${containerID}`); + return; + } + logger.error(`Failed to stop container ${containerID}: ${error.message}`); + throw error; + } + } + + /** + * Restart a container by ID + */ + async restartContainer(containerID: string): Promise { + try { + logger.info(`Restarting container: ${containerID}`); + + const container = this.dockerClient!.getContainer(containerID); + await container.restart(); + + logger.success(`Container restarted: ${containerID}`); + } catch (error) { + logger.error(`Failed to restart container ${containerID}: ${error.message}`); + throw error; + } + } + + /** + * Remove a container by ID + */ + async removeContainer(containerID: string, force = false): Promise { + try { + logger.info(`Removing container: ${containerID}`); + + const container = this.dockerClient!.getContainer(containerID); + + // Stop first if not forced + if (!force) { + try { + await this.stopContainer(containerID); + } catch (error) { + // Ignore stop errors + logger.debug(`Error stopping container before removal: ${error.message}`); + } + } + + await container.remove({ force }); + + logger.success(`Container removed: ${containerID}`); + } catch (error) { + logger.error(`Failed to remove container ${containerID}: ${error.message}`); + throw error; + } + } + + /** + * Get container status + */ + async getContainerStatus(containerID: string): Promise { + try { + const container = this.dockerClient!.getContainer(containerID); + const info = await container.inspect(); + + return info.State.Status; + } catch (error) { + logger.error(`Failed to get container status ${containerID}: ${error.message}`); + return 'unknown'; + } + } + + /** + * Get container stats (CPU, memory, network) + */ + async getContainerStats(containerID: string): Promise { + try { + const container = this.dockerClient!.getContainer(containerID); + const stats = await container.stats({ stream: false }); + + // Calculate CPU percentage + const cpuDelta = + stats.cpu_stats.cpu_usage.total_usage - stats.precpu_stats.cpu_usage.total_usage; + const systemDelta = stats.cpu_stats.system_cpu_usage - stats.precpu_stats.system_cpu_usage; + const cpuPercent = + systemDelta > 0 ? (cpuDelta / systemDelta) * stats.cpu_stats.online_cpus * 100 : 0; + + // Memory stats + const memoryUsed = stats.memory_stats.usage || 0; + const memoryLimit = stats.memory_stats.limit || 0; + const memoryPercent = memoryLimit > 0 ? (memoryUsed / memoryLimit) * 100 : 0; + + // Network stats + let networkRx = 0; + let networkTx = 0; + if (stats.networks) { + for (const network of Object.values(stats.networks)) { + networkRx += (network as any).rx_bytes || 0; + networkTx += (network as any).tx_bytes || 0; + } + } + + return { + cpuPercent, + memoryUsed, + memoryLimit, + memoryPercent, + networkRx, + networkTx, + }; + } catch (error) { + logger.error(`Failed to get container stats ${containerID}: ${error.message}`); + return null; + } + } + + /** + * Get container logs + */ + async getContainerLogs( + containerID: string, + tail = 100 + ): Promise<{ stdout: string; stderr: string }> { + try { + const container = this.dockerClient!.getContainer(containerID); + const logs = await container.logs({ + stdout: true, + stderr: true, + tail, + timestamps: true, + }); + + // Parse logs (Docker returns them in a special format) + const stdout: string[] = []; + const stderr: string[] = []; + + const lines = logs.toString().split('\n'); + for (const line of lines) { + if (line.length === 0) continue; + + // Docker log format: first byte indicates stream (1=stdout, 2=stderr) + const streamType = line.charCodeAt(0); + const content = line.slice(8); // Skip header (8 bytes) + + if (streamType === 1) { + stdout.push(content); + } else if (streamType === 2) { + stderr.push(content); + } + } + + return { + stdout: stdout.join('\n'), + stderr: stderr.join('\n'), + }; + } catch (error) { + logger.error(`Failed to get container logs ${containerID}: ${error.message}`); + return { stdout: '', stderr: '' }; + } + } + + /** + * Stream container logs (real-time) + */ + async streamContainerLogs( + containerID: string, + callback: (line: string, isError: boolean) => void + ): Promise { + try { + const container = this.dockerClient!.getContainer(containerID); + const stream = await container.logs({ + stdout: true, + stderr: true, + follow: true, + tail: 0, + timestamps: true, + }); + + stream.on('data', (chunk: Buffer) => { + const streamType = chunk[0]; + const content = chunk.slice(8).toString(); + callback(content, streamType === 2); + }); + + stream.on('error', (error: Error) => { + logger.error(`Log stream error for ${containerID}: ${error.message}`); + }); + } catch (error) { + logger.error(`Failed to stream container logs ${containerID}: ${error.message}`); + throw error; + } + } + + /** + * List all onebox-managed containers + */ + async listContainers(): Promise { + try { + const containers = await this.dockerClient!.listContainers({ + all: true, + filters: { + label: ['managed-by=onebox'], + }, + }); + + return containers; + } catch (error) { + logger.error(`Failed to list containers: ${error.message}`); + return []; + } + } + + /** + * Check if Docker is running + */ + async isDockerRunning(): Promise { + try { + await this.dockerClient!.ping(); + return true; + } catch (error) { + return false; + } + } + + /** + * Get Docker version info + */ + async getDockerVersion(): Promise { + try { + return await this.dockerClient!.version(); + } catch (error) { + logger.error(`Failed to get Docker version: ${error.message}`); + return null; + } + } + + /** + * Prune unused images + */ + async pruneImages(): Promise { + try { + logger.info('Pruning unused Docker images...'); + await this.dockerClient!.pruneImages(); + logger.success('Unused images pruned successfully'); + } catch (error) { + logger.error(`Failed to prune images: ${error.message}`); + throw error; + } + } + + /** + * Get container IP address in onebox network + */ + async getContainerIP(containerID: string): Promise { + try { + const container = this.dockerClient!.getContainer(containerID); + const info = await container.inspect(); + + const networks = info.NetworkSettings.Networks; + if (networks && networks[this.networkName]) { + return networks[this.networkName].IPAddress; + } + + return null; + } catch (error) { + logger.error(`Failed to get container IP ${containerID}: ${error.message}`); + return null; + } + } + + /** + * Execute a command in a running container + */ + async execInContainer( + containerID: string, + cmd: string[] + ): Promise<{ stdout: string; stderr: string; exitCode: number }> { + try { + const container = this.dockerClient!.getContainer(containerID); + + const exec = await container.exec({ + Cmd: cmd, + AttachStdout: true, + AttachStderr: true, + }); + + const stream = await exec.start({ Detach: false }); + + let stdout = ''; + let stderr = ''; + + stream.on('data', (chunk: Buffer) => { + const streamType = chunk[0]; + const content = chunk.slice(8).toString(); + + if (streamType === 1) { + stdout += content; + } else if (streamType === 2) { + stderr += content; + } + }); + + // Wait for completion + await new Promise((resolve) => stream.on('end', resolve)); + + const inspect = await exec.inspect(); + const exitCode = inspect.ExitCode || 0; + + return { stdout, stderr, exitCode }; + } catch (error) { + logger.error(`Failed to exec in container ${containerID}: ${error.message}`); + throw error; + } + } +} diff --git a/ts/onebox.classes.httpserver.ts b/ts/onebox.classes.httpserver.ts new file mode 100644 index 0000000..b8d5ce7 --- /dev/null +++ b/ts/onebox.classes.httpserver.ts @@ -0,0 +1,193 @@ +/** + * HTTP Server for Onebox + * + * 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'; + +export class OneboxHttpServer { + private oneboxRef: Onebox; + private server: Deno.HttpServer | null = null; + private port = 3000; + + constructor(oneboxRef: Onebox) { + this.oneboxRef = oneboxRef; + } + + /** + * Start HTTP server + */ + async start(port?: number): Promise { + try { + if (this.server) { + logger.warn('HTTP server already running'); + return; + } + + this.port = port || 3000; + + logger.info(`Starting HTTP server on port ${this.port}...`); + + this.server = Deno.serve({ port: this.port }, (req) => this.handleRequest(req)); + + logger.success(`HTTP server started on http://localhost:${this.port}`); + } catch (error) { + logger.error(`Failed to start HTTP server: ${error.message}`); + throw error; + } + } + + /** + * Stop HTTP server + */ + async stop(): Promise { + try { + if (!this.server) { + return; + } + + logger.info('Stopping HTTP server...'); + await this.server.shutdown(); + this.server = null; + logger.success('HTTP server stopped'); + } catch (error) { + logger.error(`Failed to stop HTTP server: ${error.message}`); + throw error; + } + } + + /** + * Handle HTTP request + */ + private async handleRequest(req: Request): Promise { + const url = new URL(req.url); + const path = url.pathname; + + logger.debug(`${req.method} ${path}`); + + try { + // API routes + if (path.startsWith('/api/')) { + 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' }, + }); + } catch (error) { + logger.error(`Request error: ${error.message}`); + return this.jsonResponse({ success: false, error: error.message }, 500); + } + } + + /** + * Handle API requests + */ + private async handleApiRequest(req: Request, path: string): Promise { + const method = req.method; + + // Auth check (simplified - should use proper JWT middleware) + // Skip auth for login endpoint + if (path !== '/api/auth/login') { + const authHeader = req.headers.get('Authorization'); + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return this.jsonResponse({ success: false, error: 'Unauthorized' }, 401); + } + } + + // Route to appropriate handler + if (path === '/api/status' && method === 'GET') { + return await this.handleStatusRequest(); + } else if (path === '/api/services' && method === 'GET') { + return await this.handleListServicesRequest(); + } else if (path === '/api/services' && method === 'POST') { + return await this.handleDeployServiceRequest(req); + } else if (path.match(/^\/api\/services\/[^/]+$/) && method === 'GET') { + const name = path.split('/').pop()!; + return await this.handleGetServiceRequest(name); + } else if (path.match(/^\/api\/services\/[^/]+$/) && method === 'DELETE') { + const name = path.split('/').pop()!; + return await this.handleDeleteServiceRequest(name); + } else if (path.match(/^\/api\/services\/[^/]+\/start$/) && method === 'POST') { + const name = path.split('/')[3]; + return await this.handleStartServiceRequest(name); + } else if (path.match(/^\/api\/services\/[^/]+\/stop$/) && method === 'POST') { + const name = path.split('/')[3]; + return await this.handleStopServiceRequest(name); + } else if (path.match(/^\/api\/services\/[^/]+\/restart$/) && method === 'POST') { + const name = path.split('/')[3]; + return await this.handleRestartServiceRequest(name); + } else if (path.match(/^\/api\/services\/[^/]+\/logs$/) && method === 'GET') { + const name = path.split('/')[3]; + return await this.handleGetLogsRequest(name); + } else { + return this.jsonResponse({ success: false, error: 'Not found' }, 404); + } + } + + // API Handlers + + private async handleStatusRequest(): Promise { + const status = await this.oneboxRef.getSystemStatus(); + return this.jsonResponse({ success: true, data: status }); + } + + private async handleListServicesRequest(): Promise { + const services = this.oneboxRef.services.listServices(); + return this.jsonResponse({ success: true, data: services }); + } + + private async handleDeployServiceRequest(req: Request): Promise { + const body = await req.json(); + const service = await this.oneboxRef.services.deployService(body); + return this.jsonResponse({ success: true, data: service }); + } + + private async handleGetServiceRequest(name: string): Promise { + const service = this.oneboxRef.services.getService(name); + if (!service) { + return this.jsonResponse({ success: false, error: 'Service not found' }, 404); + } + return this.jsonResponse({ success: true, data: service }); + } + + private async handleDeleteServiceRequest(name: string): Promise { + await this.oneboxRef.services.removeService(name); + return this.jsonResponse({ success: true, message: 'Service removed' }); + } + + private async handleStartServiceRequest(name: string): Promise { + await this.oneboxRef.services.startService(name); + return this.jsonResponse({ success: true, message: 'Service started' }); + } + + private async handleStopServiceRequest(name: string): Promise { + await this.oneboxRef.services.stopService(name); + return this.jsonResponse({ success: true, message: 'Service stopped' }); + } + + private async handleRestartServiceRequest(name: string): Promise { + await this.oneboxRef.services.restartService(name); + return this.jsonResponse({ success: true, message: 'Service restarted' }); + } + + private async handleGetLogsRequest(name: string): Promise { + const logs = await this.oneboxRef.services.getServiceLogs(name); + return this.jsonResponse({ success: true, data: logs }); + } + + /** + * Helper to create JSON response + */ + private jsonResponse(data: IApiResponse, status = 200): Response { + return new Response(JSON.stringify(data), { + status, + headers: { 'Content-Type': 'application/json' }, + }); + } +} diff --git a/ts/onebox.classes.nginx.ts b/ts/onebox.classes.nginx.ts new file mode 100644 index 0000000..1c5d663 --- /dev/null +++ b/ts/onebox.classes.nginx.ts @@ -0,0 +1,345 @@ +/** + * 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + try { + const command = new Deno.Command('which', { + args: ['nginx'], + stdout: 'piped', + stderr: 'piped', + }); + + const { code } = await command.output(); + return code === 0; + } catch { + return false; + } + } +} diff --git a/ts/onebox.classes.onebox.ts b/ts/onebox.classes.onebox.ts new file mode 100644 index 0000000..86c8522 --- /dev/null +++ b/ts/onebox.classes.onebox.ts @@ -0,0 +1,220 @@ +/** + * Main Onebox coordinator class + * + * 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'; + +export class Onebox { + public database: OneboxDatabase; + public docker: OneboxDockerManager; + public services: OneboxServicesManager; + public registries: OneboxRegistriesManager; + public nginx: OneboxNginxManager; + public dns: OneboxDnsManager; + public ssl: OneboxSslManager; + public daemon: OneboxDaemon; + public httpServer: OneboxHttpServer; + + private initialized = false; + + constructor() { + // Initialize database first + this.database = new OneboxDatabase(); + + // Initialize managers (passing reference to main Onebox instance) + this.docker = new OneboxDockerManager(); + this.services = new OneboxServicesManager(this); + this.registries = new OneboxRegistriesManager(this); + this.nginx = new OneboxNginxManager(this); + this.dns = new OneboxDnsManager(this); + this.ssl = new OneboxSslManager(this); + this.daemon = new OneboxDaemon(this); + this.httpServer = new OneboxHttpServer(this); + } + + /** + * Initialize all components + */ + async init(): Promise { + try { + logger.info('Initializing Onebox...'); + + // Initialize database + await this.database.init(); + + // Ensure default admin user exists + await this.ensureDefaultUser(); + + // Initialize Docker + await this.docker.init(); + + // Initialize Nginx + await this.nginx.init(); + + // Initialize DNS (non-critical) + try { + await this.dns.init(); + } catch (error) { + logger.warn('DNS initialization failed - DNS features will be disabled'); + } + + // Initialize SSL (non-critical) + try { + await this.ssl.init(); + } catch (error) { + logger.warn('SSL initialization failed - SSL features will be limited'); + } + + // Login to all registries + await this.registries.loginToAllRegistries(); + + this.initialized = true; + logger.success('Onebox initialized successfully'); + } catch (error) { + logger.error(`Failed to initialize Onebox: ${error.message}`); + throw error; + } + } + + /** + * Ensure default admin user exists + */ + private async ensureDefaultUser(): Promise { + try { + const adminUser = this.database.getUserByUsername('admin'); + + 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') + ); + + await this.database.createUser({ + username: 'admin', + passwordHash: btoa('admin'), // Simple encoding for now + role: 'admin', + createdAt: Date.now(), + updatedAt: Date.now(), + }); + + logger.warn('Default admin user created with username: admin, password: admin'); + logger.warn('IMPORTANT: Change the default password immediately!'); + } + } catch (error) { + logger.error(`Failed to create default user: ${error.message}`); + } + } + + /** + * Check if Onebox is initialized + */ + isInitialized(): boolean { + return this.initialized; + } + + /** + * Get system status + */ + async getSystemStatus() { + try { + const dockerRunning = await this.docker.isDockerRunning(); + const nginxStatus = await this.nginx.getStatus(); + const dnsConfigured = this.dns.isConfigured(); + const sslConfigured = this.ssl.isConfigured(); + + const services = this.services.listServices(); + const runningServices = services.filter((s) => s.status === 'running').length; + const totalServices = services.length; + + return { + docker: { + running: dockerRunning, + version: dockerRunning ? await this.docker.getDockerVersion() : null, + }, + nginx: { + status: nginxStatus, + installed: await this.nginx.isInstalled(), + }, + dns: { + configured: dnsConfigured, + }, + ssl: { + configured: sslConfigured, + certbotInstalled: await this.ssl.isCertbotInstalled(), + }, + services: { + total: totalServices, + running: runningServices, + stopped: totalServices - runningServices, + }, + }; + } catch (error) { + logger.error(`Failed to get system status: ${error.message}`); + throw error; + } + } + + /** + * Start daemon mode + */ + async startDaemon(): Promise { + await this.daemon.start(); + } + + /** + * Stop daemon mode + */ + async stopDaemon(): Promise { + await this.daemon.stop(); + } + + /** + * Start HTTP server + */ + async startHttpServer(port?: number): Promise { + await this.httpServer.start(port); + } + + /** + * Stop HTTP server + */ + async stopHttpServer(): Promise { + await this.httpServer.stop(); + } + + /** + * Shutdown Onebox gracefully + */ + async shutdown(): Promise { + try { + logger.info('Shutting down Onebox...'); + + // Stop daemon if running + await this.daemon.stop(); + + // Stop HTTP server if running + await this.httpServer.stop(); + + // Close database + this.database.close(); + + logger.success('Onebox shutdown complete'); + } catch (error) { + logger.error(`Error during shutdown: ${error.message}`); + } + } +} diff --git a/ts/onebox.classes.registries.ts b/ts/onebox.classes.registries.ts new file mode 100644 index 0000000..42b59b3 --- /dev/null +++ b/ts/onebox.classes.registries.ts @@ -0,0 +1,195 @@ +/** + * Registry Manager for Onebox + * + * 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'; + +export class OneboxRegistriesManager { + private oneboxRef: any; // Will be Onebox instance + private database: OneboxDatabase; + + constructor(oneboxRef: any) { + this.oneboxRef = oneboxRef; + this.database = oneboxRef.database; + } + + /** + * Encrypt a password (simple base64 for now, should use proper encryption) + */ + private encryptPassword(password: string): string { + // TODO: Use proper encryption with a secret key + // For now, using base64 encoding (NOT SECURE, just for structure) + return plugins.encoding.encodeBase64(new TextEncoder().encode(password)); + } + + /** + * Decrypt a password + */ + private decryptPassword(encrypted: string): string { + // TODO: Use proper decryption + return new TextDecoder().decode(plugins.encoding.decodeBase64(encrypted)); + } + + /** + * Add a registry + */ + async addRegistry(url: string, username: string, password: string): Promise { + try { + // Check if registry already exists + const existing = this.database.getRegistryByURL(url); + if (existing) { + throw new Error(`Registry already exists: ${url}`); + } + + // Encrypt password + const passwordEncrypted = this.encryptPassword(password); + + // Create registry in database + const registry = await this.database.createRegistry({ + url, + username, + passwordEncrypted, + createdAt: Date.now(), + }); + + logger.success(`Registry added: ${url}`); + + // Perform Docker login + await this.loginToRegistry(registry); + + return registry; + } catch (error) { + logger.error(`Failed to add registry ${url}: ${error.message}`); + throw error; + } + } + + /** + * Remove a registry + */ + async removeRegistry(url: string): Promise { + try { + const registry = this.database.getRegistryByURL(url); + if (!registry) { + throw new Error(`Registry not found: ${url}`); + } + + this.database.deleteRegistry(url); + logger.success(`Registry removed: ${url}`); + + // Note: We don't perform docker logout as it might affect other users + } catch (error) { + logger.error(`Failed to remove registry ${url}: ${error.message}`); + throw error; + } + } + + /** + * List all registries + */ + listRegistries(): IRegistry[] { + return this.database.getAllRegistries(); + } + + /** + * Get registry by URL + */ + getRegistry(url: string): IRegistry | null { + return this.database.getRegistryByURL(url); + } + + /** + * Perform Docker login for a registry + */ + async loginToRegistry(registry: IRegistry): Promise { + try { + logger.info(`Logging into registry: ${registry.url}`); + + const password = this.decryptPassword(registry.passwordEncrypted); + + // Use docker login command + const command = [ + 'docker', + 'login', + registry.url, + '--username', + registry.username, + '--password-stdin', + ]; + + const process = new Deno.Command('docker', { + args: ['login', registry.url, '--username', registry.username, '--password-stdin'], + stdin: 'piped', + stdout: 'piped', + stderr: 'piped', + }); + + const child = process.spawn(); + + // Write password to stdin + const writer = child.stdin.getWriter(); + await writer.write(new TextEncoder().encode(password)); + await writer.close(); + + const { code, stdout, stderr } = await child.output(); + + if (code !== 0) { + const errorMsg = new TextDecoder().decode(stderr); + throw new Error(`Docker login failed: ${errorMsg}`); + } + + logger.success(`Logged into registry: ${registry.url}`); + } catch (error) { + logger.error(`Failed to login to registry ${registry.url}: ${error.message}`); + throw error; + } + } + + /** + * Login to all registries (useful on daemon start) + */ + async loginToAllRegistries(): Promise { + const registries = this.listRegistries(); + + for (const registry of registries) { + try { + await this.loginToRegistry(registry); + } catch (error) { + logger.warn(`Failed to login to ${registry.url}: ${error.message}`); + // Continue with other registries + } + } + } + + /** + * Test registry connection + */ + async testRegistry(url: string, username: string, password: string): Promise { + try { + const command = new Deno.Command('docker', { + args: ['login', url, '--username', username, '--password-stdin'], + stdin: 'piped', + stdout: 'piped', + stderr: 'piped', + }); + + const child = command.spawn(); + + const writer = child.stdin.getWriter(); + await writer.write(new TextEncoder().encode(password)); + await writer.close(); + + const { code } = await child.output(); + + return code === 0; + } catch (error) { + logger.error(`Failed to test registry ${url}: ${error.message}`); + return false; + } + } +} diff --git a/ts/onebox.classes.services.ts b/ts/onebox.classes.services.ts new file mode 100644 index 0000000..1cb4bf1 --- /dev/null +++ b/ts/onebox.classes.services.ts @@ -0,0 +1,407 @@ +/** + * Services Manager for Onebox + * + * 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'; + +export class OneboxServicesManager { + private oneboxRef: any; // Will be Onebox instance + private database: OneboxDatabase; + private docker: OneboxDockerManager; + + constructor(oneboxRef: any) { + this.oneboxRef = oneboxRef; + this.database = oneboxRef.database; + this.docker = oneboxRef.docker; + } + + /** + * Deploy a new service (full workflow) + */ + async deployService(options: IServiceDeployOptions): Promise { + try { + logger.info(`Deploying service: ${options.name}`); + + // Check if service already exists + const existing = this.database.getServiceByName(options.name); + if (existing) { + throw new Error(`Service already exists: ${options.name}`); + } + + // Create service record in database + const service = await this.database.createService({ + name: options.name, + image: options.image, + registry: options.registry, + envVars: options.envVars || {}, + port: options.port, + domain: options.domain, + status: 'stopped', + createdAt: Date.now(), + updatedAt: Date.now(), + }); + + // Pull image + await this.docker.pullImage(options.image, options.registry); + + // Create container + const containerID = await this.docker.createContainer(service); + + // Update service with container ID + this.database.updateService(service.id!, { + containerID, + status: 'starting', + }); + + // Start container + await this.docker.startContainer(containerID); + + // Update status + this.database.updateService(service.id!, { status: 'running' }); + + // If domain is specified, configure nginx, DNS, and SSL + if (options.domain) { + logger.info(`Configuring domain: ${options.domain}`); + + // Configure DNS (if autoDNS is enabled) + if (options.autoDNS !== false) { + try { + await this.oneboxRef.dns.addDNSRecord(options.domain); + } catch (error) { + logger.warn(`Failed to configure DNS for ${options.domain}: ${error.message}`); + } + } + + // Configure nginx + try { + await this.oneboxRef.nginx.createConfig(service.id!, options.domain, options.port); + await this.oneboxRef.nginx.reload(); + } catch (error) { + logger.warn(`Failed to configure Nginx 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(); + } catch (error) { + logger.warn(`Failed to obtain SSL certificate for ${options.domain}: ${error.message}`); + } + } + } + + logger.success(`Service deployed successfully: ${options.name}`); + + return this.database.getServiceByName(options.name)!; + } catch (error) { + logger.error(`Failed to deploy service ${options.name}: ${error.message}`); + throw error; + } + } + + /** + * Start a service + */ + async startService(name: string): Promise { + try { + const service = this.database.getServiceByName(name); + if (!service) { + throw new Error(`Service not found: ${name}`); + } + + if (!service.containerID) { + throw new Error(`Service ${name} has no container ID`); + } + + logger.info(`Starting service: ${name}`); + + this.database.updateService(service.id!, { status: 'starting' }); + + await this.docker.startContainer(service.containerID); + + this.database.updateService(service.id!, { status: 'running' }); + + logger.success(`Service started: ${name}`); + } catch (error) { + logger.error(`Failed to start service ${name}: ${error.message}`); + this.database.updateService( + this.database.getServiceByName(name)?.id!, + { status: 'failed' } + ); + throw error; + } + } + + /** + * Stop a service + */ + async stopService(name: string): Promise { + try { + const service = this.database.getServiceByName(name); + if (!service) { + throw new Error(`Service not found: ${name}`); + } + + if (!service.containerID) { + throw new Error(`Service ${name} has no container ID`); + } + + logger.info(`Stopping service: ${name}`); + + this.database.updateService(service.id!, { status: 'stopping' }); + + await this.docker.stopContainer(service.containerID); + + this.database.updateService(service.id!, { status: 'stopped' }); + + logger.success(`Service stopped: ${name}`); + } catch (error) { + logger.error(`Failed to stop service ${name}: ${error.message}`); + throw error; + } + } + + /** + * Restart a service + */ + async restartService(name: string): Promise { + try { + const service = this.database.getServiceByName(name); + if (!service) { + throw new Error(`Service not found: ${name}`); + } + + if (!service.containerID) { + throw new Error(`Service ${name} has no container ID`); + } + + logger.info(`Restarting service: ${name}`); + + await this.docker.restartContainer(service.containerID); + + this.database.updateService(service.id!, { status: 'running' }); + + logger.success(`Service restarted: ${name}`); + } catch (error) { + logger.error(`Failed to restart service ${name}: ${error.message}`); + throw error; + } + } + + /** + * Remove a service (full cleanup) + */ + async removeService(name: string): Promise { + try { + const service = this.database.getServiceByName(name); + if (!service) { + throw new Error(`Service not found: ${name}`); + } + + logger.info(`Removing service: ${name}`); + + // Stop and remove container + if (service.containerID) { + try { + await this.docker.removeContainer(service.containerID, true); + } catch (error) { + logger.warn(`Failed to remove container: ${error.message}`); + } + } + + // Remove nginx config + if (service.domain) { + try { + await this.oneboxRef.nginx.removeConfig(service.id!); + await this.oneboxRef.nginx.reload(); + } catch (error) { + logger.warn(`Failed to remove Nginx config: ${error.message}`); + } + + // Note: We don't remove DNS records or SSL certs automatically + // as they might be used by other services or need manual cleanup + } + + // Remove from database + this.database.deleteService(service.id!); + + logger.success(`Service removed: ${name}`); + } catch (error) { + logger.error(`Failed to remove service ${name}: ${error.message}`); + throw error; + } + } + + /** + * List all services + */ + listServices(): IService[] { + return this.database.getAllServices(); + } + + /** + * Get service by name + */ + getService(name: string): IService | null { + return this.database.getServiceByName(name); + } + + /** + * Get service logs + */ + async getServiceLogs(name: string, tail = 100): Promise { + try { + const service = this.database.getServiceByName(name); + if (!service) { + throw new Error(`Service not found: ${name}`); + } + + if (!service.containerID) { + throw new Error(`Service ${name} has no container ID`); + } + + const logs = await this.docker.getContainerLogs(service.containerID, tail); + + return `=== STDOUT ===\n${logs.stdout}\n\n=== STDERR ===\n${logs.stderr}`; + } catch (error) { + logger.error(`Failed to get logs for service ${name}: ${error.message}`); + throw error; + } + } + + /** + * Stream service logs (real-time) + */ + async streamServiceLogs( + name: string, + callback: (line: string, isError: boolean) => void + ): Promise { + try { + const service = this.database.getServiceByName(name); + if (!service) { + throw new Error(`Service not found: ${name}`); + } + + if (!service.containerID) { + throw new Error(`Service ${name} has no container ID`); + } + + await this.docker.streamContainerLogs(service.containerID, callback); + } catch (error) { + logger.error(`Failed to stream logs for service ${name}: ${error.message}`); + throw error; + } + } + + /** + * Get service metrics + */ + async getServiceMetrics(name: string) { + try { + const service = this.database.getServiceByName(name); + if (!service) { + throw new Error(`Service not found: ${name}`); + } + + if (!service.containerID) { + throw new Error(`Service ${name} has no container ID`); + } + + const stats = await this.docker.getContainerStats(service.containerID); + return stats; + } catch (error) { + logger.error(`Failed to get metrics for service ${name}: ${error.message}`); + return null; + } + } + + /** + * Get service status + */ + async getServiceStatus(name: string): Promise { + try { + const service = this.database.getServiceByName(name); + if (!service) { + return 'not-found'; + } + + if (!service.containerID) { + return service.status; + } + + const status = await this.docker.getContainerStatus(service.containerID); + return status; + } catch (error) { + logger.error(`Failed to get status for service ${name}: ${error.message}`); + return 'unknown'; + } + } + + /** + * Update service environment variables + */ + async updateServiceEnv(name: string, envVars: Record): Promise { + try { + const service = this.database.getServiceByName(name); + if (!service) { + throw new Error(`Service not found: ${name}`); + } + + // Update database + this.database.updateService(service.id!, { envVars }); + + // Note: Requires container restart to take effect + logger.info(`Environment variables updated for ${name}. Restart service to apply changes.`); + } catch (error) { + logger.error(`Failed to update env vars for service ${name}: ${error.message}`); + throw error; + } + } + + /** + * Sync service status from Docker + */ + async syncServiceStatus(name: string): Promise { + try { + const service = this.database.getServiceByName(name); + if (!service || !service.containerID) { + return; + } + + const status = await this.docker.getContainerStatus(service.containerID); + + // Map Docker status to our status + let ourStatus: IService['status'] = 'stopped'; + if (status === 'running') { + ourStatus = 'running'; + } else if (status === 'exited' || status === 'dead') { + ourStatus = 'stopped'; + } else if (status === 'created') { + ourStatus = 'stopped'; + } else if (status === 'restarting') { + ourStatus = 'starting'; + } + + this.database.updateService(service.id!, { status: ourStatus }); + } catch (error) { + logger.debug(`Failed to sync status for service ${name}: ${error.message}`); + } + } + + /** + * Sync all service statuses from Docker + */ + async syncAllServiceStatuses(): Promise { + const services = this.listServices(); + + for (const service of services) { + await this.syncServiceStatus(service.name); + } + } +} diff --git a/ts/onebox.classes.ssl.ts b/ts/onebox.classes.ssl.ts new file mode 100644 index 0000000..75686f8 --- /dev/null +++ b/ts/onebox.classes.ssl.ts @@ -0,0 +1,317 @@ +/** + * SSL Manager for Onebox + * + * 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'; + +export class OneboxSslManager { + private oneboxRef: any; + private database: OneboxDatabase; + private smartacme: plugins.smartacme.SmartAcme | null = null; + private acmeEmail: string | null = null; + + constructor(oneboxRef: any) { + this.oneboxRef = oneboxRef; + this.database = oneboxRef.database; + } + + /** + * Initialize SSL manager + */ + async init(): Promise { + try { + // Get ACME email from settings + const acmeEmail = this.database.getSetting('acmeEmail'); + + if (!acmeEmail) { + logger.warn('ACME email not configured. SSL certificate management will be limited.'); + logger.info('Configure with: onebox config set acmeEmail '); + return; + } + + this.acmeEmail = acmeEmail; + + // Initialize SmartACME + this.smartacme = new plugins.smartacme.SmartAcme({ + email: acmeEmail, + environment: 'production', // or 'staging' for testing + dns: 'cloudflare', // Use Cloudflare DNS challenge + }); + + logger.info('SSL manager initialized with SmartACME'); + } catch (error) { + logger.error(`Failed to initialize SSL manager: ${error.message}`); + throw error; + } + } + + /** + * Check if SSL manager is configured + */ + isConfigured(): boolean { + return this.smartacme !== null && this.acmeEmail !== null; + } + + /** + * Obtain SSL certificate for a domain + */ + async obtainCertificate(domain: string): Promise { + try { + if (!this.isConfigured()) { + throw new Error('SSL manager not configured'); + } + + logger.info(`Obtaining SSL certificate for ${domain}...`); + + // Check if certificate already exists and is valid + const existing = this.database.getSSLCertificate(domain); + if (existing && existing.expiryDate > Date.now()) { + logger.info(`Valid certificate already exists for ${domain}`); + return; + } + + // Use certbot for now (smartacme integration would be more complex) + // This is a simplified version - in production, use proper ACME client + await this.obtainCertificateWithCertbot(domain); + + // Store in database + const certPath = `/etc/letsencrypt/live/${domain}/cert.pem`; + const keyPath = `/etc/letsencrypt/live/${domain}/privkey.pem`; + const fullChainPath = `/etc/letsencrypt/live/${domain}/fullchain.pem`; + + // Get expiry date (90 days from now for Let's Encrypt) + const expiryDate = Date.now() + 90 * 24 * 60 * 60 * 1000; + + if (existing) { + this.database.updateSSLCertificate(domain, { + certPath, + keyPath, + fullChainPath, + expiryDate, + }); + } else { + await this.database.createSSLCertificate({ + domain, + certPath, + keyPath, + fullChainPath, + expiryDate, + issuer: 'Let\'s Encrypt', + createdAt: Date.now(), + updatedAt: Date.now(), + }); + } + + // Enable SSL in nginx config + await this.oneboxRef.nginx.enableSSL(domain); + + logger.success(`SSL certificate obtained for ${domain}`); + } catch (error) { + logger.error(`Failed to obtain certificate for ${domain}: ${error.message}`); + throw error; + } + } + + /** + * Obtain certificate using certbot + */ + private async obtainCertificateWithCertbot(domain: string): Promise { + try { + logger.info(`Running certbot for ${domain}...`); + + // Use webroot method (nginx serves .well-known/acme-challenge) + const command = new Deno.Command('certbot', { + args: [ + 'certonly', + '--webroot', + '--webroot-path=/var/www/certbot', + '--email', + this.acmeEmail!, + '--agree-tos', + '--no-eff-email', + '--domain', + domain, + '--non-interactive', + ], + stdout: 'piped', + stderr: 'piped', + }); + + const { code, stderr } = await command.output(); + + if (code !== 0) { + const errorMsg = new TextDecoder().decode(stderr); + throw new Error(`Certbot failed: ${errorMsg}`); + } + + logger.success(`Certbot obtained certificate for ${domain}`); + } catch (error) { + throw new Error(`Failed to run certbot: ${error.message}`); + } + } + + /** + * Renew certificate for a domain + */ + async renewCertificate(domain: string): Promise { + try { + logger.info(`Renewing SSL certificate for ${domain}...`); + + const command = new Deno.Command('certbot', { + args: ['renew', '--cert-name', domain, '--non-interactive'], + stdout: 'piped', + stderr: 'piped', + }); + + const { code, stderr } = await command.output(); + + if (code !== 0) { + const errorMsg = new TextDecoder().decode(stderr); + throw new Error(`Certbot renewal failed: ${errorMsg}`); + } + + // Update database + const expiryDate = Date.now() + 90 * 24 * 60 * 60 * 1000; + this.database.updateSSLCertificate(domain, { + expiryDate, + }); + + logger.success(`Certificate renewed for ${domain}`); + + // Reload nginx + await this.oneboxRef.nginx.reload(); + } catch (error) { + logger.error(`Failed to renew certificate for ${domain}: ${error.message}`); + throw error; + } + } + + /** + * List all certificates + */ + listCertificates() { + return this.database.getAllSSLCertificates(); + } + + /** + * Get certificate info for a domain + */ + getCertificate(domain: string) { + return this.database.getSSLCertificate(domain); + } + + /** + * Check certificates that are expiring soon and renew them + */ + async renewExpiring(): Promise { + try { + logger.info('Checking for expiring certificates...'); + + const certificates = this.listCertificates(); + const thirtyDaysFromNow = Date.now() + 30 * 24 * 60 * 60 * 1000; + + for (const cert of certificates) { + if (cert.expiryDate < thirtyDaysFromNow) { + logger.info(`Certificate for ${cert.domain} expires soon, renewing...`); + + try { + await this.renewCertificate(cert.domain); + } catch (error) { + logger.error(`Failed to renew ${cert.domain}: ${error.message}`); + // Continue with other certificates + } + } + } + + logger.success('Certificate renewal check complete'); + } catch (error) { + logger.error(`Failed to check expiring certificates: ${error.message}`); + throw error; + } + } + + /** + * Force renewal of all certificates + */ + async renewAll(): Promise { + try { + logger.info('Renewing all certificates...'); + + const command = new Deno.Command('certbot', { + args: ['renew', '--force-renewal', '--non-interactive'], + stdout: 'piped', + stderr: 'piped', + }); + + const { code, stderr } = await command.output(); + + if (code !== 0) { + const errorMsg = new TextDecoder().decode(stderr); + throw new Error(`Certbot renewal failed: ${errorMsg}`); + } + + logger.success('All certificates renewed'); + + // Reload nginx + await this.oneboxRef.nginx.reload(); + } catch (error) { + logger.error(`Failed to renew all certificates: ${error.message}`); + throw error; + } + } + + /** + * Check if certbot is installed + */ + async isCertbotInstalled(): Promise { + try { + const command = new Deno.Command('which', { + args: ['certbot'], + stdout: 'piped', + stderr: 'piped', + }); + + const { code } = await command.output(); + return code === 0; + } catch { + return false; + } + } + + /** + * Get certificate expiry date from file + */ + async getCertificateExpiry(domain: string): Promise { + try { + const certPath = `/etc/letsencrypt/live/${domain}/cert.pem`; + + const command = new Deno.Command('openssl', { + args: ['x509', '-enddate', '-noout', '-in', certPath], + stdout: 'piped', + stderr: 'piped', + }); + + const { code, stdout } = await command.output(); + + if (code !== 0) { + return null; + } + + const output = new TextDecoder().decode(stdout); + const match = output.match(/notAfter=(.+)/); + + if (match) { + return new Date(match[1]); + } + + return null; + } catch (error) { + logger.error(`Failed to get certificate expiry for ${domain}: ${error.message}`); + return null; + } + } +} diff --git a/ts/onebox.cli.ts b/ts/onebox.cli.ts new file mode 100644 index 0000000..3231902 --- /dev/null +++ b/ts/onebox.cli.ts @@ -0,0 +1,366 @@ +/** + * CLI Router for Onebox + */ + +import { logger } from './onebox.logging.ts'; +import { projectInfo } from './onebox.info.ts'; +import { Onebox } from './onebox.classes.onebox.ts'; + +export async function runCli(): Promise { + const args = Deno.args; + + if (args.length === 0 || args.includes('--help') || args.includes('-h')) { + printHelp(); + return; + } + + if (args.includes('--version') || args.includes('-v')) { + console.log(`${projectInfo.name} v${projectInfo.version}`); + return; + } + + const command = args[0]; + const subcommand = args[1]; + + try { + // Initialize Onebox + const onebox = new Onebox(); + await onebox.init(); + + // Route commands + switch (command) { + case 'service': + await handleServiceCommand(onebox, subcommand, args.slice(2)); + break; + + case 'registry': + await handleRegistryCommand(onebox, subcommand, args.slice(2)); + break; + + case 'dns': + await handleDnsCommand(onebox, subcommand, args.slice(2)); + break; + + case 'ssl': + await handleSslCommand(onebox, subcommand, args.slice(2)); + break; + + case 'nginx': + await handleNginxCommand(onebox, subcommand, args.slice(2)); + break; + + case 'daemon': + await handleDaemonCommand(onebox, subcommand, args.slice(2)); + break; + + case 'config': + await handleConfigCommand(onebox, subcommand, args.slice(2)); + break; + + case 'status': + await handleStatusCommand(onebox); + break; + + default: + logger.error(`Unknown command: ${command}`); + printHelp(); + Deno.exit(1); + } + + // Cleanup + await onebox.shutdown(); + } catch (error) { + logger.error(error.message); + Deno.exit(1); + } +} + +// Service commands +async function handleServiceCommand(onebox: Onebox, subcommand: string, args: string[]) { + switch (subcommand) { + case 'add': { + const name = args[0]; + const image = getArg(args, '--image'); + const domain = getArg(args, '--domain'); + const port = parseInt(getArg(args, '--port') || '80', 10); + const envArgs = args.filter((a) => a.startsWith('--env=')).map((a) => a.slice(6)); + const envVars: Record = {}; + for (const env of envArgs) { + const [key, value] = env.split('='); + envVars[key] = value; + } + + await onebox.services.deployService({ name, image, port, domain, envVars }); + break; + } + + case 'remove': + await onebox.services.removeService(args[0]); + break; + + case 'start': + await onebox.services.startService(args[0]); + break; + + case 'stop': + await onebox.services.stopService(args[0]); + break; + + case 'restart': + await onebox.services.restartService(args[0]); + break; + + case 'list': { + const services = onebox.services.listServices(); + logger.table( + ['Name', 'Image', 'Status', 'Domain', 'Port'], + services.map((s) => [s.name, s.image, s.status, s.domain || '-', s.port.toString()]) + ); + break; + } + + case 'logs': { + const logs = await onebox.services.getServiceLogs(args[0]); + console.log(logs); + break; + } + + default: + logger.error(`Unknown service subcommand: ${subcommand}`); + } +} + +// Registry commands +async function handleRegistryCommand(onebox: Onebox, subcommand: string, args: string[]) { + switch (subcommand) { + case 'add': { + const url = getArg(args, '--url'); + const username = getArg(args, '--username'); + const password = getArg(args, '--password'); + await onebox.registries.addRegistry(url, username, password); + break; + } + + case 'remove': + await onebox.registries.removeRegistry(getArg(args, '--url')); + break; + + case 'list': { + const registries = onebox.registries.listRegistries(); + logger.table( + ['URL', 'Username'], + registries.map((r) => [r.url, r.username]) + ); + break; + } + + default: + logger.error(`Unknown registry subcommand: ${subcommand}`); + } +} + +// DNS commands +async function handleDnsCommand(onebox: Onebox, subcommand: string, args: string[]) { + switch (subcommand) { + case 'add': + await onebox.dns.addDNSRecord(args[0]); + break; + + case 'remove': + await onebox.dns.removeDNSRecord(args[0]); + break; + + case 'list': { + const records = onebox.dns.listDNSRecords(); + logger.table( + ['Domain', 'Type', 'Value'], + records.map((r) => [r.domain, r.type, r.value]) + ); + break; + } + + case 'sync': + await onebox.dns.syncFromCloudflare(); + break; + + default: + logger.error(`Unknown dns subcommand: ${subcommand}`); + } +} + +// SSL commands +async function handleSslCommand(onebox: Onebox, subcommand: string, args: string[]) { + switch (subcommand) { + case 'renew': + if (args[0]) { + await onebox.ssl.renewCertificate(args[0]); + } else { + await onebox.ssl.renewExpiring(); + } + break; + + case 'list': { + const certs = onebox.ssl.listCertificates(); + logger.table( + ['Domain', 'Expiry', 'Issuer'], + certs.map((c) => [c.domain, new Date(c.expiryDate).toISOString(), c.issuer]) + ); + break; + } + + case 'force-renew': + await onebox.ssl.renewCertificate(args[0]); + break; + + default: + logger.error(`Unknown ssl subcommand: ${subcommand}`); + } +} + +// Nginx commands +async function handleNginxCommand(onebox: Onebox, subcommand: string, _args: string[]) { + switch (subcommand) { + case 'reload': + await onebox.nginx.reload(); + break; + + case 'test': + await onebox.nginx.test(); + break; + + case 'status': { + const status = await onebox.nginx.getStatus(); + logger.info(`Nginx status: ${status}`); + break; + } + + default: + logger.error(`Unknown nginx subcommand: ${subcommand}`); + } +} + +// Daemon commands +async function handleDaemonCommand(onebox: Onebox, subcommand: string, _args: string[]) { + switch (subcommand) { + case 'install': + await onebox.daemon.installService(); + break; + + case 'start': + await onebox.startDaemon(); + break; + + case 'stop': + await onebox.stopDaemon(); + break; + + case 'logs': { + const command = new Deno.Command('journalctl', { + args: ['-u', 'smartdaemon_onebox', '-f'], + stdout: 'inherit', + stderr: 'inherit', + }); + await command.output(); + break; + } + + case 'status': { + const status = await onebox.daemon.getServiceStatus(); + logger.info(`Daemon status: ${status}`); + break; + } + + default: + logger.error(`Unknown daemon subcommand: ${subcommand}`); + } +} + +// Config commands +async function handleConfigCommand(onebox: Onebox, subcommand: string, args: string[]) { + switch (subcommand) { + case 'show': { + const settings = onebox.database.getAllSettings(); + logger.table( + ['Key', 'Value'], + Object.entries(settings).map(([k, v]) => [k, v]) + ); + break; + } + + case 'set': + onebox.database.setSetting(args[0], args[1]); + logger.success(`Setting ${args[0]} updated`); + break; + + default: + logger.error(`Unknown config subcommand: ${subcommand}`); + } +} + +// Status command +async function handleStatusCommand(onebox: Onebox) { + const status = await onebox.getSystemStatus(); + console.log(JSON.stringify(status, null, 2)); +} + +// Helpers +function getArg(args: string[], flag: string): string { + const arg = args.find((a) => a.startsWith(`${flag}=`)); + return arg ? arg.split('=')[1] : ''; +} + +function printHelp(): void { + console.log(` +Onebox v${projectInfo.version} - Self-hosted container platform + +Usage: onebox [options] + +Commands: + service add --image [--domain ] [--port ] [--env KEY=VALUE] + service remove + service start + service stop + service restart + service list + service logs + + registry add --url --username --password + registry remove --url + registry list + + dns add + dns remove + dns list + dns sync + + ssl renew [domain] + ssl list + ssl force-renew + + nginx reload + nginx test + nginx status + + daemon install + daemon start + daemon stop + daemon logs + daemon status + + config show + config set + + status + +Options: + --help, -h Show this help message + --version, -v Show version + --debug Enable debug logging + +Examples: + 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 + onebox daemon start +`); +} diff --git a/ts/onebox.info.ts b/ts/onebox.info.ts new file mode 100644 index 0000000..4943e9c --- /dev/null +++ b/ts/onebox.info.ts @@ -0,0 +1,12 @@ +/** + * Project information and version + */ + +import denoConfig from '../deno.json' with { type: 'json' }; + +export const projectInfo = { + name: denoConfig.name, + version: denoConfig.version, + description: 'Self-hosted container platform with automatic SSL and DNS', + repository: 'https://code.foss.global/serve.zone/onebox', +}; diff --git a/ts/onebox.logging.ts b/ts/onebox.logging.ts new file mode 100644 index 0000000..24dcad1 --- /dev/null +++ b/ts/onebox.logging.ts @@ -0,0 +1,124 @@ +/** + * Logging utilities for Onebox + */ + +type LogLevel = 'info' | 'success' | 'warn' | 'error' | 'debug'; + +class Logger { + private debugMode = false; + + constructor() { + this.debugMode = Deno.args.includes('--debug') || Deno.env.get('DEBUG') === 'true'; + } + + /** + * Log a message with specified level + */ + log(level: LogLevel, message: string, ...args: unknown[]): void { + const timestamp = new Date().toISOString(); + const prefix = this.getPrefix(level); + + const formattedMessage = `${prefix} ${message}`; + + switch (level) { + case 'error': + console.error(formattedMessage, ...args); + break; + case 'warn': + console.warn(formattedMessage, ...args); + break; + case 'debug': + if (this.debugMode) { + console.log(formattedMessage, ...args); + } + break; + default: + console.log(formattedMessage, ...args); + } + } + + /** + * Info level logging + */ + info(message: string, ...args: unknown[]): void { + this.log('info', message, ...args); + } + + /** + * Success level logging + */ + success(message: string, ...args: unknown[]): void { + this.log('success', message, ...args); + } + + /** + * Warning level logging + */ + warn(message: string, ...args: unknown[]): void { + this.log('warn', message, ...args); + } + + /** + * Error level logging + */ + error(message: string, ...args: unknown[]): void { + this.log('error', message, ...args); + } + + /** + * Debug level logging (only when --debug flag is present) + */ + debug(message: string, ...args: unknown[]): void { + this.log('debug', message, ...args); + } + + /** + * Get colored prefix for log level + */ + private getPrefix(level: LogLevel): string { + const colors = { + info: '\x1b[36m', // Cyan + success: '\x1b[32m', // Green + warn: '\x1b[33m', // Yellow + error: '\x1b[31m', // Red + debug: '\x1b[90m', // Gray + }; + const reset = '\x1b[0m'; + + const icons = { + info: 'ℹ', + success: '✓', + warn: '⚠', + error: '✖', + debug: '⚙', + }; + + return `${colors[level]}${icons[level]}${reset}`; + } + + /** + * Print a table (simplified version) + */ + table(headers: string[], rows: string[][]): void { + // Calculate column widths + const widths = headers.map((header, i) => { + const maxContentWidth = Math.max( + ...rows.map((row) => (row[i] || '').toString().length) + ); + return Math.max(header.length, maxContentWidth); + }); + + // Print header + const headerRow = headers.map((h, i) => h.padEnd(widths[i])).join(' '); + console.log(headerRow); + console.log(headers.map((_, i) => '-'.repeat(widths[i])).join(' ')); + + // Print rows + for (const row of rows) { + const formattedRow = row.map((cell, i) => (cell || '').toString().padEnd(widths[i])).join(' '); + console.log(formattedRow); + } + } +} + +export const logger = new Logger(); diff --git a/ts/onebox.plugins.ts b/ts/onebox.plugins.ts new file mode 100644 index 0000000..ed023fc --- /dev/null +++ b/ts/onebox.plugins.ts @@ -0,0 +1,46 @@ +/** + * Centralized dependency imports for Onebox + * + * This file serves as the single source of truth for all external dependencies. + * All modules should import from this file using: import * as plugins from './onebox.plugins.ts' + */ + +// Deno Standard Library +import * as path from '@std/path'; +import * as fs from '@std/fs'; +import * as http from '@std/http'; +import * as encoding from '@std/encoding'; + +export { path, fs, http, encoding }; + +// Database +import * as sqlite from '@db/sqlite'; +export { sqlite }; + +// Systemd Daemon Integration +import * as smartdaemon from '@push.rocks/smartdaemon'; +export { smartdaemon }; + +// Docker API Client +import * as docker from '@apiclient.xyz/docker'; +export { docker }; + +// Cloudflare DNS Management +import * as cloudflare from '@apiclient.xyz/cloudflare'; +export { cloudflare }; + +// Let's Encrypt / ACME +import * as smartacme from '@push.rocks/smartacme'; +export { smartacme }; + +// Crypto utilities (for password hashing, encryption) +import * as bcrypt from 'https://deno.land/x/bcrypt@v0.4.1/mod.ts'; +export { bcrypt }; + +// JWT for authentication +import { create as createJwt, verify as verifyJwt, decode as decodeJwt } from 'https://deno.land/x/djwt@v3.0.2/mod.ts'; +export { createJwt, verifyJwt, decodeJwt }; + +// Crypto key management +import { crypto } from 'https://deno.land/std@0.208.0/crypto/mod.ts'; +export { crypto }; diff --git a/ts/onebox.types.ts b/ts/onebox.types.ts new file mode 100644 index 0000000..6d9d830 --- /dev/null +++ b/ts/onebox.types.ts @@ -0,0 +1,165 @@ +/** + * Type definitions for Onebox + */ + +// Service types +export interface IService { + id?: number; + name: string; + image: string; + registry?: string; + envVars: Record; + port: number; + domain?: string; + containerID?: string; + status: 'stopped' | 'starting' | 'running' | 'stopping' | 'failed'; + createdAt: number; + updatedAt: number; +} + +// Registry types +export interface IRegistry { + id?: number; + url: string; + username: string; + passwordEncrypted: string; + createdAt: number; +} + +// Nginx configuration types +export interface INginxConfig { + id?: number; + serviceId: number; + domain: string; + port: number; + sslEnabled: boolean; + configTemplate: string; + createdAt: number; + updatedAt: number; +} + +// SSL certificate types +export interface ISslCertificate { + id?: number; + domain: string; + certPath: string; + keyPath: string; + fullChainPath: string; + expiryDate: number; + issuer: string; + createdAt: number; + updatedAt: number; +} + +// DNS record types +export interface IDnsRecord { + id?: number; + domain: string; + type: 'A' | 'AAAA' | 'CNAME'; + value: string; + cloudflareID?: string; + createdAt: number; + updatedAt: number; +} + +// Metrics types +export interface IMetric { + id?: number; + serviceId: number; + timestamp: number; + cpuPercent: number; + memoryUsed: number; + memoryLimit: number; + networkRxBytes: number; + networkTxBytes: number; +} + +// Log entry types +export interface ILogEntry { + id?: number; + serviceId: number; + timestamp: number; + message: string; + level: 'info' | 'warn' | 'error' | 'debug'; + source: 'stdout' | 'stderr'; +} + +// User types +export interface IUser { + id?: number; + username: string; + passwordHash: string; + role: 'admin' | 'user'; + createdAt: number; + updatedAt: number; +} + +// Settings types +export interface ISetting { + key: string; + value: string; + updatedAt: number; +} + +// Application settings +export interface IAppSettings { + serverIP?: string; + cloudflareAPIKey?: string; + cloudflareEmail?: string; + cloudflareZoneID?: string; + acmeEmail?: string; + nginxConfigDir?: string; + dataDir?: string; + httpPort?: number; + metricsInterval?: number; + logRetentionDays?: number; +} + +// Container stats from Docker +export interface IContainerStats { + cpuPercent: number; + memoryUsed: number; + memoryLimit: number; + memoryPercent: number; + networkRx: number; + networkTx: number; +} + +// Service deployment options +export interface IServiceDeployOptions { + name: string; + image: string; + registry?: string; + envVars?: Record; + port: number; + domain?: string; + autoSSL?: boolean; + autoDNS?: boolean; +} + +// HTTP API request/response types +export interface IApiResponse { + success: boolean; + data?: T; + error?: string; + message?: string; +} + +export interface ILoginRequest { + username: string; + password: string; +} + +export interface ILoginResponse { + token: string; + user: { + username: string; + role: string; + }; +} + +// CLI command types +export interface ICliArgs { + _: string[]; + [key: string]: unknown; +}