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.
This commit is contained in:
50
.gitignore
vendored
Normal file
50
.gitignore
vendored
Normal file
@@ -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
|
||||||
70
bin/onebox-wrapper.js
Normal file
70
bin/onebox-wrapper.js
Normal file
@@ -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);
|
||||||
|
});
|
||||||
29
changelog.md
Normal file
29
changelog.md
Normal file
@@ -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
|
||||||
44
deno.json
Normal file
44
deno.json
Normal file
@@ -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"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
dist/binaries/.gitkeep
vendored
Normal file
1
dist/binaries/.gitkeep
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Keep this directory in git
|
||||||
192
install.sh
Executable file
192
install.sh
Executable file
@@ -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 <key>"
|
||||||
|
echo " onebox config set cloudflareEmail <email>"
|
||||||
|
echo " onebox config set cloudflareZoneID <zone-id>"
|
||||||
|
echo " onebox config set serverIP <your-server-ip>"
|
||||||
|
echo ""
|
||||||
|
echo "2. Configure ACME email:"
|
||||||
|
echo " onebox config set acmeEmail <your@email.com>"
|
||||||
|
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
|
||||||
21
license
Normal file
21
license
Normal file
@@ -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.
|
||||||
21
mod.ts
Normal file
21
mod.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
52
package.json
Normal file
52
package.json
Normal file
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
253
readme.hints.md
Normal file
253
readme.hints.md
Normal file
@@ -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/<domain>/`
|
||||||
|
- 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.<name>.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
|
||||||
209
readme.md
Normal file
209
readme.md
Normal file
@@ -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 <name> --image <image> --domain <domain> [--env KEY=VALUE]
|
||||||
|
onebox service remove <name>
|
||||||
|
onebox service start <name>
|
||||||
|
onebox service stop <name>
|
||||||
|
onebox service restart <name>
|
||||||
|
onebox service list
|
||||||
|
onebox service logs <name> [--follow]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Registry Management
|
||||||
|
|
||||||
|
```bash
|
||||||
|
onebox registry add --url <url> --username <user> --password <pass>
|
||||||
|
onebox registry remove <url>
|
||||||
|
onebox registry list
|
||||||
|
```
|
||||||
|
|
||||||
|
### DNS Management
|
||||||
|
|
||||||
|
```bash
|
||||||
|
onebox dns add <domain> --ip <ip>
|
||||||
|
onebox dns remove <domain>
|
||||||
|
onebox dns list
|
||||||
|
onebox dns sync
|
||||||
|
```
|
||||||
|
|
||||||
|
### SSL Management
|
||||||
|
|
||||||
|
```bash
|
||||||
|
onebox ssl renew [domain]
|
||||||
|
onebox ssl list
|
||||||
|
onebox ssl force-renew <domain>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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 <username> --password <password> [--role admin|user]
|
||||||
|
onebox user remove <username>
|
||||||
|
onebox user list
|
||||||
|
onebox user passwd <username>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
```bash
|
||||||
|
onebox config show
|
||||||
|
onebox config set <key> <value>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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)
|
||||||
56
scripts/compile-all.sh
Executable file
56
scripts/compile-all.sh
Executable file
@@ -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"
|
||||||
105
scripts/install-binary.js
Normal file
105
scripts/install-binary.js
Normal file
@@ -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);
|
||||||
|
});
|
||||||
24
ts/index.ts
Normal file
24
ts/index.ts
Normal file
@@ -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';
|
||||||
292
ts/onebox.classes.daemon.ts
Normal file
292
ts/onebox.classes.daemon.ts
Normal file
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
// 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<string> {
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
659
ts/onebox.classes.database.ts
Normal file
659
ts/onebox.classes.database.ts
Normal file
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<T = unknown[]>(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<IService, 'id'>): Promise<IService> {
|
||||||
|
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<IService>): 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<IRegistry, 'id'>): Promise<IRegistry> {
|
||||||
|
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<string, string> {
|
||||||
|
if (!this.db) throw new Error('Database not initialized');
|
||||||
|
|
||||||
|
const rows = this.db.query('SELECT key, value FROM settings');
|
||||||
|
const settings: Record<string, string> = {};
|
||||||
|
for (const row of rows) {
|
||||||
|
settings[String(row[0])] = String(row[1]);
|
||||||
|
}
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Users CRUD ============
|
||||||
|
|
||||||
|
async createUser(user: Omit<IUser, 'id'>): Promise<IUser> {
|
||||||
|
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<IMetric, 'id'>): 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<ILogEntry, 'id'>): 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<ISslCertificate, 'id'>): Promise<ISslCertificate> {
|
||||||
|
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<ISslCertificate>): 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]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
270
ts/onebox.classes.dns.ts
Normal file
270
ts/onebox.classes.dns.ts
Normal file
@@ -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<void> {
|
||||||
|
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 <key>');
|
||||||
|
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<void> {
|
||||||
|
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 <ip>');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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<void> {
|
||||||
|
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<any> {
|
||||||
|
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<void> {
|
||||||
|
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<boolean> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
489
ts/onebox.classes.docker.ts
Normal file
489
ts/onebox.classes.docker.ts
Normal file
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<string> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<string> {
|
||||||
|
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<IContainerStats | null> {
|
||||||
|
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<void> {
|
||||||
|
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<any[]> {
|
||||||
|
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<boolean> {
|
||||||
|
try {
|
||||||
|
await this.dockerClient!.ping();
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Docker version info
|
||||||
|
*/
|
||||||
|
async getDockerVersion(): Promise<any> {
|
||||||
|
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<void> {
|
||||||
|
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<string | null> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
193
ts/onebox.classes.httpserver.ts
Normal file
193
ts/onebox.classes.httpserver.ts
Normal file
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<Response> {
|
||||||
|
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<Response> {
|
||||||
|
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<Response> {
|
||||||
|
const status = await this.oneboxRef.getSystemStatus();
|
||||||
|
return this.jsonResponse({ success: true, data: status });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleListServicesRequest(): Promise<Response> {
|
||||||
|
const services = this.oneboxRef.services.listServices();
|
||||||
|
return this.jsonResponse({ success: true, data: services });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleDeployServiceRequest(req: Request): Promise<Response> {
|
||||||
|
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<Response> {
|
||||||
|
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<Response> {
|
||||||
|
await this.oneboxRef.services.removeService(name);
|
||||||
|
return this.jsonResponse({ success: true, message: 'Service removed' });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleStartServiceRequest(name: string): Promise<Response> {
|
||||||
|
await this.oneboxRef.services.startService(name);
|
||||||
|
return this.jsonResponse({ success: true, message: 'Service started' });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleStopServiceRequest(name: string): Promise<Response> {
|
||||||
|
await this.oneboxRef.services.stopService(name);
|
||||||
|
return this.jsonResponse({ success: true, message: 'Service stopped' });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleRestartServiceRequest(name: string): Promise<Response> {
|
||||||
|
await this.oneboxRef.services.restartService(name);
|
||||||
|
return this.jsonResponse({ success: true, message: 'Service restarted' });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleGetLogsRequest(name: string): Promise<Response> {
|
||||||
|
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' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
345
ts/onebox.classes.nginx.ts
Normal file
345
ts/onebox.classes.nginx.ts
Normal file
@@ -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<void> {
|
||||||
|
try {
|
||||||
|
// Ensure directories exist
|
||||||
|
await Deno.mkdir(this.configDir, { recursive: true });
|
||||||
|
await Deno.mkdir(this.enabledDir, { recursive: true });
|
||||||
|
|
||||||
|
logger.info('Nginx manager initialized');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to initialize Nginx manager: ${error.message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create nginx config for a service
|
||||||
|
*/
|
||||||
|
async createConfig(serviceId: number, domain: string, port: number): Promise<void> {
|
||||||
|
try {
|
||||||
|
logger.info(`Creating Nginx config for ${domain}`);
|
||||||
|
|
||||||
|
const service = this.database.getServiceByID(serviceId);
|
||||||
|
if (!service) {
|
||||||
|
throw new Error(`Service not found: ${serviceId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get container IP (or use container name for DNS resolution within Docker network)
|
||||||
|
const containerName = `onebox-${service.name}`;
|
||||||
|
|
||||||
|
// Generate config
|
||||||
|
const config = this.generateConfig(domain, containerName, port);
|
||||||
|
|
||||||
|
// Write config file
|
||||||
|
const configPath = `${this.configDir}/onebox-${service.name}.conf`;
|
||||||
|
await Deno.writeTextFile(configPath, config);
|
||||||
|
|
||||||
|
// Create symlink in sites-enabled
|
||||||
|
const enabledPath = `${this.enabledDir}/onebox-${service.name}.conf`;
|
||||||
|
try {
|
||||||
|
await Deno.remove(enabledPath);
|
||||||
|
} catch {
|
||||||
|
// Ignore if doesn't exist
|
||||||
|
}
|
||||||
|
await Deno.symlink(configPath, enabledPath);
|
||||||
|
|
||||||
|
logger.success(`Nginx config created: ${domain}`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to create Nginx config: ${error.message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate nginx configuration
|
||||||
|
*/
|
||||||
|
private generateConfig(domain: string, upstream: string, port: number): string {
|
||||||
|
return `# Onebox-managed configuration for ${domain}
|
||||||
|
# Generated at ${new Date().toISOString()}
|
||||||
|
|
||||||
|
upstream onebox_${domain.replace(/\./g, '_')} {
|
||||||
|
server ${upstream}:${port};
|
||||||
|
}
|
||||||
|
|
||||||
|
# HTTP server (redirect to HTTPS or serve directly)
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
server_name ${domain};
|
||||||
|
|
||||||
|
# ACME challenge for Let's Encrypt
|
||||||
|
location /.well-known/acme-challenge/ {
|
||||||
|
root /var/www/certbot;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Redirect to HTTPS (will be enabled after SSL is configured)
|
||||||
|
# location / {
|
||||||
|
# return 301 https://$server_name$request_uri;
|
||||||
|
# }
|
||||||
|
|
||||||
|
# Proxy to container (remove after SSL is configured)
|
||||||
|
location / {
|
||||||
|
proxy_pass http://onebox_${domain.replace(/\./g, '_')};
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
# WebSocket support
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
|
||||||
|
# Timeouts
|
||||||
|
proxy_connect_timeout 60s;
|
||||||
|
proxy_send_timeout 60s;
|
||||||
|
proxy_read_timeout 60s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# HTTPS server (uncomment after SSL is configured)
|
||||||
|
# server {
|
||||||
|
# listen 443 ssl http2;
|
||||||
|
# listen [::]:443 ssl http2;
|
||||||
|
# server_name ${domain};
|
||||||
|
#
|
||||||
|
# ssl_certificate /etc/letsencrypt/live/${domain}/fullchain.pem;
|
||||||
|
# ssl_certificate_key /etc/letsencrypt/live/${domain}/privkey.pem;
|
||||||
|
#
|
||||||
|
# # SSL configuration
|
||||||
|
# ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
# ssl_ciphers HIGH:!aNULL:!MD5;
|
||||||
|
# ssl_prefer_server_ciphers on;
|
||||||
|
#
|
||||||
|
# location / {
|
||||||
|
# proxy_pass http://onebox_${domain.replace(/\./g, '_')};
|
||||||
|
# proxy_set_header Host $host;
|
||||||
|
# proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
# proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
#
|
||||||
|
# # WebSocket support
|
||||||
|
# proxy_http_version 1.1;
|
||||||
|
# proxy_set_header Upgrade $http_upgrade;
|
||||||
|
# proxy_set_header Connection "upgrade";
|
||||||
|
#
|
||||||
|
# # Timeouts
|
||||||
|
# proxy_connect_timeout 60s;
|
||||||
|
# proxy_send_timeout 60s;
|
||||||
|
# proxy_read_timeout 60s;
|
||||||
|
# }
|
||||||
|
# }
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update nginx config to enable SSL
|
||||||
|
*/
|
||||||
|
async enableSSL(domain: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
logger.info(`Enabling SSL for ${domain}`);
|
||||||
|
|
||||||
|
// Find service by domain
|
||||||
|
const services = this.database.getAllServices();
|
||||||
|
const service = services.find((s) => s.domain === domain);
|
||||||
|
|
||||||
|
if (!service) {
|
||||||
|
throw new Error(`Service not found for domain: ${domain}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const configPath = `${this.configDir}/onebox-${service.name}.conf`;
|
||||||
|
let config = await Deno.readTextFile(configPath);
|
||||||
|
|
||||||
|
// Enable HTTPS redirect and HTTPS server block
|
||||||
|
config = config.replace('# location / {\n # return 301', 'location / {\n return 301');
|
||||||
|
config = config.replace('# }', '}');
|
||||||
|
config = config.replace(/# server \{[\s\S]*?# \}/m, (match) =>
|
||||||
|
match.replace(/# /g, '')
|
||||||
|
);
|
||||||
|
|
||||||
|
// Comment out HTTP proxy location
|
||||||
|
config = config.replace(
|
||||||
|
/# Proxy to container \(remove after SSL is configured\)[\s\S]*?location \/ \{[\s\S]*?\n \}/,
|
||||||
|
(match) => `# ${match.replace(/\n/g, '\n # ')}`
|
||||||
|
);
|
||||||
|
|
||||||
|
await Deno.writeTextFile(configPath, config);
|
||||||
|
|
||||||
|
logger.success(`SSL enabled for ${domain}`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to enable SSL for ${domain}: ${error.message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove nginx config for a service
|
||||||
|
*/
|
||||||
|
async removeConfig(serviceId: number): Promise<void> {
|
||||||
|
try {
|
||||||
|
const service = this.database.getServiceByID(serviceId);
|
||||||
|
if (!service) {
|
||||||
|
throw new Error(`Service not found: ${serviceId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Removing Nginx config for ${service.name}`);
|
||||||
|
|
||||||
|
// Remove symlink
|
||||||
|
const enabledPath = `${this.enabledDir}/onebox-${service.name}.conf`;
|
||||||
|
try {
|
||||||
|
await Deno.remove(enabledPath);
|
||||||
|
} catch {
|
||||||
|
// Ignore if doesn't exist
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove config file
|
||||||
|
const configPath = `${this.configDir}/onebox-${service.name}.conf`;
|
||||||
|
try {
|
||||||
|
await Deno.remove(configPath);
|
||||||
|
} catch {
|
||||||
|
// Ignore if doesn't exist
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.success(`Nginx config removed for ${service.name}`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to remove Nginx config: ${error.message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test nginx configuration
|
||||||
|
*/
|
||||||
|
async test(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const command = new Deno.Command('nginx', {
|
||||||
|
args: ['-t'],
|
||||||
|
stdout: 'piped',
|
||||||
|
stderr: 'piped',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { code, stderr } = await command.output();
|
||||||
|
|
||||||
|
if (code !== 0) {
|
||||||
|
const errorMsg = new TextDecoder().decode(stderr);
|
||||||
|
logger.error(`Nginx config test failed: ${errorMsg}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.success('Nginx configuration is valid');
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to test Nginx config: ${error.message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reload nginx
|
||||||
|
*/
|
||||||
|
async reload(): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Test config first
|
||||||
|
const isValid = await this.test();
|
||||||
|
if (!isValid) {
|
||||||
|
throw new Error('Nginx configuration is invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Reloading Nginx...');
|
||||||
|
|
||||||
|
const command = new Deno.Command('systemctl', {
|
||||||
|
args: ['reload', 'nginx'],
|
||||||
|
stdout: 'piped',
|
||||||
|
stderr: 'piped',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { code, stderr } = await command.output();
|
||||||
|
|
||||||
|
if (code !== 0) {
|
||||||
|
const errorMsg = new TextDecoder().decode(stderr);
|
||||||
|
throw new Error(`Nginx reload failed: ${errorMsg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.success('Nginx reloaded successfully');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to reload Nginx: ${error.message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get nginx status
|
||||||
|
*/
|
||||||
|
async getStatus(): Promise<string> {
|
||||||
|
try {
|
||||||
|
const command = new Deno.Command('systemctl', {
|
||||||
|
args: ['status', 'nginx'],
|
||||||
|
stdout: 'piped',
|
||||||
|
stderr: 'piped',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { code, stdout } = await command.output();
|
||||||
|
const output = new TextDecoder().decode(stdout);
|
||||||
|
|
||||||
|
if (code === 0 || output.includes('active (running)')) {
|
||||||
|
return 'running';
|
||||||
|
} else if (output.includes('inactive') || output.includes('dead')) {
|
||||||
|
return 'stopped';
|
||||||
|
} else if (output.includes('failed')) {
|
||||||
|
return 'failed';
|
||||||
|
} else {
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to get Nginx status: ${error.message}`);
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if nginx is installed
|
||||||
|
*/
|
||||||
|
async isInstalled(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const command = new Deno.Command('which', {
|
||||||
|
args: ['nginx'],
|
||||||
|
stdout: 'piped',
|
||||||
|
stderr: 'piped',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { code } = await command.output();
|
||||||
|
return code === 0;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
220
ts/onebox.classes.onebox.ts
Normal file
220
ts/onebox.classes.onebox.ts
Normal file
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
await this.daemon.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop daemon mode
|
||||||
|
*/
|
||||||
|
async stopDaemon(): Promise<void> {
|
||||||
|
await this.daemon.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start HTTP server
|
||||||
|
*/
|
||||||
|
async startHttpServer(port?: number): Promise<void> {
|
||||||
|
await this.httpServer.start(port);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop HTTP server
|
||||||
|
*/
|
||||||
|
async stopHttpServer(): Promise<void> {
|
||||||
|
await this.httpServer.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shutdown Onebox gracefully
|
||||||
|
*/
|
||||||
|
async shutdown(): Promise<void> {
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
195
ts/onebox.classes.registries.ts
Normal file
195
ts/onebox.classes.registries.ts
Normal file
@@ -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<IRegistry> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<boolean> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
407
ts/onebox.classes.services.ts
Normal file
407
ts/onebox.classes.services.ts
Normal file
@@ -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<IService> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<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 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<void> {
|
||||||
|
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<string> {
|
||||||
|
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<string, string>): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
const services = this.listServices();
|
||||||
|
|
||||||
|
for (const service of services) {
|
||||||
|
await this.syncServiceStatus(service.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
317
ts/onebox.classes.ssl.ts
Normal file
317
ts/onebox.classes.ssl.ts
Normal file
@@ -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<void> {
|
||||||
|
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 <email>');
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<boolean> {
|
||||||
|
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<Date | null> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
366
ts/onebox.cli.ts
Normal file
366
ts/onebox.cli.ts
Normal file
@@ -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<void> {
|
||||||
|
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<string, string> = {};
|
||||||
|
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 <command> [options]
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
service add <name> --image <image> [--domain <domain>] [--port <port>] [--env KEY=VALUE]
|
||||||
|
service remove <name>
|
||||||
|
service start <name>
|
||||||
|
service stop <name>
|
||||||
|
service restart <name>
|
||||||
|
service list
|
||||||
|
service logs <name>
|
||||||
|
|
||||||
|
registry add --url <url> --username <user> --password <pass>
|
||||||
|
registry remove --url <url>
|
||||||
|
registry list
|
||||||
|
|
||||||
|
dns add <domain>
|
||||||
|
dns remove <domain>
|
||||||
|
dns list
|
||||||
|
dns sync
|
||||||
|
|
||||||
|
ssl renew [domain]
|
||||||
|
ssl list
|
||||||
|
ssl force-renew <domain>
|
||||||
|
|
||||||
|
nginx reload
|
||||||
|
nginx test
|
||||||
|
nginx status
|
||||||
|
|
||||||
|
daemon install
|
||||||
|
daemon start
|
||||||
|
daemon stop
|
||||||
|
daemon logs
|
||||||
|
daemon status
|
||||||
|
|
||||||
|
config show
|
||||||
|
config set <key> <value>
|
||||||
|
|
||||||
|
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
|
||||||
|
`);
|
||||||
|
}
|
||||||
12
ts/onebox.info.ts
Normal file
12
ts/onebox.info.ts
Normal file
@@ -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',
|
||||||
|
};
|
||||||
124
ts/onebox.logging.ts
Normal file
124
ts/onebox.logging.ts
Normal file
@@ -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();
|
||||||
46
ts/onebox.plugins.ts
Normal file
46
ts/onebox.plugins.ts
Normal file
@@ -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 };
|
||||||
165
ts/onebox.types.ts
Normal file
165
ts/onebox.types.ts
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
/**
|
||||||
|
* Type definitions for Onebox
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Service types
|
||||||
|
export interface IService {
|
||||||
|
id?: number;
|
||||||
|
name: string;
|
||||||
|
image: string;
|
||||||
|
registry?: string;
|
||||||
|
envVars: Record<string, string>;
|
||||||
|
port: number;
|
||||||
|
domain?: string;
|
||||||
|
containerID?: string;
|
||||||
|
status: 'stopped' | 'starting' | 'running' | 'stopping' | 'failed';
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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<string, string>;
|
||||||
|
port: number;
|
||||||
|
domain?: string;
|
||||||
|
autoSSL?: boolean;
|
||||||
|
autoDNS?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTP API request/response types
|
||||||
|
export interface IApiResponse<T = unknown> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user