Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7fe63541b3 | |||
| 201602b733 | |||
| cc6a81012c | |||
| fba143d918 | |||
| b0f9d71a18 | |||
| 61f72a4b7a | |||
| c04be7117e | |||
| 7ee740695f | |||
| 1f3705fa25 | |||
| 90ca53356d | |||
| 69b528a499 | |||
| 63c6fb4b6a | |||
| 35f83d7c2d | |||
| c451d71a97 | |||
| 2b51178016 | |||
| 5cb6895a14 | |||
| c5d9158078 | |||
| 0f5ce708d9 | |||
| 3da7e431c2 | |||
| 49c1830168 | |||
| 061ce7c3f2 | |||
| 618d4d674f |
@@ -1,5 +1,40 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-05-08 - 1.24.7 - fix(web-ui)
|
||||
|
||||
align Delegate Routing settings with the Dees catalog control and theme conventions
|
||||
|
||||
- replace raw Delegate Routing inputs and save button with `dees-input-text` and `dees-button`
|
||||
- style the Delegate Routing card with explicit `cssManager.bdTheme(...)` colors
|
||||
|
||||
## 2026-05-08 - 1.24.6 - fix(auth)
|
||||
|
||||
avoid bcrypt worker crashes in compiled binaries during login and password creation
|
||||
|
||||
- replace bcrypt password hashing with a Web Crypto PBKDF2 hash format
|
||||
- remove legacy password-hash fallbacks; existing deployments need their admin user hash updated
|
||||
|
||||
## 2026-05-08 - 1.24.5 - fix(opsserver)
|
||||
|
||||
start the OpsServer with typedserver custom routes registered through the UtilityWebsiteServer hook
|
||||
|
||||
- fixes daemon startup with the current typedserver lifecycle
|
||||
- cap SmartProxy readiness waiting at 10 seconds during daemon startup
|
||||
|
||||
## 2026-05-08 - 1.24.4 - fix(installer)
|
||||
|
||||
avoid documenting a hardcoded initial admin password for fresh installs
|
||||
|
||||
- update installer output to point operators to the service logs or `ONEBOX_ADMIN_PASSWORD` for initial credentials
|
||||
|
||||
## 2026-05-08 - 1.24.3 - fix(runtime)
|
||||
|
||||
upgrade runtime dependencies and harden registry/shutdown behavior
|
||||
|
||||
- update Deno, API, Docker, Cloudflare, SmartACME, SmartRegistry, SmartStorage, TaskBuffer, catalog, and build-tool dependencies
|
||||
- expose the embedded OCI registry through OpsServer `/v2` routes with the configured token realm
|
||||
- avoid creating a hardcoded default admin password and close Docker/log receiver resources during shutdown
|
||||
|
||||
## 2026-03-24 - 1.24.2 - fix(deps)
|
||||
bump runtime and build tool dependencies
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@serve.zone/onebox",
|
||||
"version": "1.24.2",
|
||||
"version": "1.24.7",
|
||||
"exports": "./mod.ts",
|
||||
"tasks": {
|
||||
"test": "deno test --allow-all test/",
|
||||
@@ -9,24 +9,24 @@
|
||||
"dev": "pnpm run watch"
|
||||
},
|
||||
"imports": {
|
||||
"@std/path": "jsr:@std/path@^1.1.2",
|
||||
"@std/fs": "jsr:@std/fs@^1.0.19",
|
||||
"@std/http": "jsr:@std/http@^1.0.21",
|
||||
"@std/assert": "jsr:@std/assert@^1.0.15",
|
||||
"@std/path": "jsr:@std/path@^1.1.4",
|
||||
"@std/fs": "jsr:@std/fs@^1.0.23",
|
||||
"@std/http": "jsr:@std/http@^1.1.0",
|
||||
"@std/assert": "jsr:@std/assert@^1.0.19",
|
||||
"@std/encoding": "jsr:@std/encoding@^1.0.10",
|
||||
"@db/sqlite": "jsr:@db/sqlite@0.12.0",
|
||||
"@apiclient.xyz/docker": "npm:@apiclient.xyz/docker@^5.1.1",
|
||||
"@apiclient.xyz/cloudflare": "npm:@apiclient.xyz/cloudflare@6.4.3",
|
||||
"@push.rocks/smartacme": "npm:@push.rocks/smartacme@^8.0.0",
|
||||
"@push.rocks/smartregistry": "npm:@push.rocks/smartregistry@^2.2.0",
|
||||
"@push.rocks/smartstorage": "npm:@push.rocks/smartstorage@^6.3.0",
|
||||
"@push.rocks/taskbuffer": "npm:@push.rocks/taskbuffer@^3.1.0",
|
||||
"@db/sqlite": "jsr:@db/sqlite@0.13.0",
|
||||
"@apiclient.xyz/docker": "npm:@apiclient.xyz/docker@^5.1.4",
|
||||
"@apiclient.xyz/cloudflare": "npm:@apiclient.xyz/cloudflare@7.1.0",
|
||||
"@push.rocks/smartacme": "npm:@push.rocks/smartacme@^9.5.0",
|
||||
"@push.rocks/smartregistry": "npm:@push.rocks/smartregistry@^2.9.2",
|
||||
"@push.rocks/smartstorage": "npm:@push.rocks/smartstorage@^6.5.1",
|
||||
"@push.rocks/taskbuffer": "npm:@push.rocks/taskbuffer@^8.0.2",
|
||||
"@api.global/typedrequest-interfaces": "npm:@api.global/typedrequest-interfaces@^3.0.19",
|
||||
"@api.global/typedrequest": "npm:@api.global/typedrequest@^3.2.6",
|
||||
"@api.global/typedserver": "npm:@api.global/typedserver@^8.3.1",
|
||||
"@api.global/typedrequest": "npm:@api.global/typedrequest@^3.3.1",
|
||||
"@api.global/typedserver": "npm:@api.global/typedserver@^8.4.6",
|
||||
"@push.rocks/smartguard": "npm:@push.rocks/smartguard@^3.1.0",
|
||||
"@push.rocks/smartjwt": "npm:@push.rocks/smartjwt@^2.2.1",
|
||||
"@api.global/typedsocket": "npm:@api.global/typedsocket@^4.1.2",
|
||||
"@push.rocks/smartjwt": "npm:@push.rocks/smartjwt@^2.2.2",
|
||||
"@api.global/typedsocket": "npm:@api.global/typedsocket@^4.1.3",
|
||||
"@serve.zone/containerarchive": "npm:@serve.zone/containerarchive@^0.1.3"
|
||||
},
|
||||
"compilerOptions": {
|
||||
|
||||
-36196
File diff suppressed because one or more lines are too long
@@ -1,33 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta
|
||||
name="viewport"
|
||||
content="user-scalable=0, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width, height=device-height"
|
||||
/>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<title>Onebox</title>
|
||||
<link rel="preconnect" href="https://assetbroker.lossless.one/" crossorigin>
|
||||
<link rel="stylesheet" href="https://assetbroker.lossless.one/fonts/fonts.css">
|
||||
<style>
|
||||
html {
|
||||
-ms-text-size-adjust: 100%;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
body {
|
||||
position: relative;
|
||||
background: #000;
|
||||
margin: 0px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
<p style="color: #fff; text-align: center; margin-top: 100px;">
|
||||
JavaScript is required to run the Onebox dashboard.
|
||||
</p>
|
||||
</noscript>
|
||||
</body>
|
||||
<script defer type="module" src="/bundle.js"></script>
|
||||
</html>
|
||||
+1
-1
@@ -305,6 +305,6 @@ else
|
||||
echo " onebox service add myapp --image nginx:latest --domain app.example.com"
|
||||
echo ""
|
||||
echo " Web UI: http://localhost:3000"
|
||||
echo " Default credentials: admin / admin"
|
||||
echo " Initial admin credentials are written to the service logs unless ONEBOX_ADMIN_PASSWORD is set."
|
||||
fi
|
||||
echo ""
|
||||
|
||||
+8
-8
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@serve.zone/onebox",
|
||||
"version": "1.24.2",
|
||||
"version": "1.24.7",
|
||||
"description": "Self-hosted container platform with automatic SSL and DNS - a mini Heroku for single servers",
|
||||
"main": "mod.ts",
|
||||
"type": "module",
|
||||
@@ -26,7 +26,7 @@
|
||||
"paas",
|
||||
"deployment"
|
||||
],
|
||||
"author": "Lossless GmbH",
|
||||
"author": "Task Venture Capital GmbH",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -55,15 +55,15 @@
|
||||
"packageManager": "pnpm@10.18.1+sha512.77a884a165cbba2d8d1c19e3b4880eee6d2fcabd0d879121e282196b80042351d5eb3ca0935fa599da1dc51265cc68816ad2bddd2a2de5ea9fdf92adbec7cd34",
|
||||
"dependencies": {
|
||||
"@api.global/typedrequest-interfaces": "^3.0.19",
|
||||
"@api.global/typedsocket": "^4.1.2",
|
||||
"@design.estate/dees-catalog": "^3.49.0",
|
||||
"@design.estate/dees-element": "^2.1.6",
|
||||
"@serve.zone/catalog": "^2.9.0"
|
||||
"@api.global/typedsocket": "^4.1.3",
|
||||
"@design.estate/dees-catalog": "^3.81.0",
|
||||
"@design.estate/dees-element": "^2.2.4",
|
||||
"@serve.zone/catalog": "^2.12.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@git.zone/tsbundle": "^2.10.0",
|
||||
"@git.zone/tsbundle": "^2.10.1",
|
||||
"@git.zone/tsdeno": "^1.3.1",
|
||||
"@git.zone/tswatch": "^3.3.2"
|
||||
"@git.zone/tswatch": "^3.3.3"
|
||||
},
|
||||
"private": true,
|
||||
"pnpm": {
|
||||
|
||||
Generated
+832
-976
File diff suppressed because it is too large
Load Diff
+15
-16
@@ -44,42 +44,42 @@ ts/database/
|
||||
- All methods delegate to the appropriate repository
|
||||
- No breaking changes for existing code
|
||||
|
||||
## Current Migration Version: 8
|
||||
## Current Migration Version: 15
|
||||
|
||||
Migration 8 converted certificate storage from file paths to PEM content.
|
||||
Migration 15 renames the core reverse proxy platform service from `caddy` to `smartproxy`.
|
||||
|
||||
## Reverse Proxy (November 2025 - Caddy Docker Service)
|
||||
## Reverse Proxy (April 2026 - SmartProxy Docker Service)
|
||||
|
||||
The reverse proxy uses **Caddy** running as a Docker Swarm service for production-grade reverse proxying with native SNI support, HTTP/2, HTTP/3, and WebSocket handling.
|
||||
The reverse proxy uses **SmartProxy** running as a Docker Swarm service for production-grade reverse proxying with TLS termination and WebSocket handling.
|
||||
|
||||
**Architecture:**
|
||||
|
||||
- Caddy runs as Docker Swarm service (`onebox-caddy`) on the overlay network
|
||||
- No binary download required - uses `caddy:2-alpine` Docker image
|
||||
- Configuration pushed dynamically via Caddy Admin API (port 2019)
|
||||
- SmartProxy runs as Docker Swarm service (`onebox-smartproxy`) on the overlay network
|
||||
- No host binary download required - uses `code.foss.global/host.today/ht-docker-smartproxy:latest`
|
||||
- Routes are pushed dynamically via the SmartProxy admin API (host port 2019)
|
||||
- Automatic HTTPS disabled - certificates managed externally via SmartACME
|
||||
- Zero-downtime configuration updates
|
||||
- Services reached by Docker service name (e.g., `onebox-hello-world:80`)
|
||||
|
||||
**Key files:**
|
||||
|
||||
- `ts/classes/caddy.ts` - CaddyManager class for Docker service and Admin API
|
||||
- `ts/classes/reverseproxy.ts` - Delegates to CaddyManager
|
||||
- `ts/classes/smartproxy.ts` - SmartProxyManager class for Docker service and Admin API
|
||||
- `ts/classes/reverseproxy.ts` - Delegates to SmartProxyManager
|
||||
|
||||
**Certificate workflow:**
|
||||
|
||||
1. `CertRequirementManager` creates requirements for domains
|
||||
2. Daemon processes requirements via `certmanager.ts`
|
||||
3. Certificates stored in database (PEM content)
|
||||
4. `reverseProxy.addCertificate()` passes PEM content to Caddy via `load_pem` (inline in config)
|
||||
5. Caddy serves TLS with the loaded certificates (no volume mounts needed)
|
||||
4. `reverseProxy.addCertificate()` passes PEM content to SmartProxy route config
|
||||
5. SmartProxy serves TLS with the loaded certificates (no volume mounts needed)
|
||||
|
||||
**Docker Service Configuration:**
|
||||
|
||||
- Service name: `onebox-caddy`
|
||||
- Image: `caddy:2-alpine`
|
||||
- Service name: `onebox-smartproxy`
|
||||
- Image: `code.foss.global/host.today/ht-docker-smartproxy:latest`
|
||||
- Network: `onebox-network` (overlay, attachable)
|
||||
- Startup: Writes initial config with `admin.listen: 0.0.0.0:2019` for host access
|
||||
- Startup: SmartProxy daemon admin API listens on container port 3000, published on host port 2019
|
||||
|
||||
**Port Mapping:**
|
||||
|
||||
@@ -89,5 +89,4 @@ The reverse proxy uses **Caddy** running as a Docker Swarm service for productio
|
||||
|
||||
**Log Receiver:**
|
||||
|
||||
- Caddy sends access logs to `tcp/172.17.0.1:9999` (Docker bridge gateway)
|
||||
- `CaddyLogReceiver` on host receives and processes logs
|
||||
- `ProxyLogReceiver` remains the host-side access-log stream endpoint for proxy log integrations
|
||||
|
||||
@@ -1,601 +1,253 @@
|
||||
# @serve.zone/onebox
|
||||
|
||||
> 🚀 Self-hosted Docker Swarm platform with Caddy reverse proxy, automatic SSL, and real-time WebSocket updates
|
||||
|
||||
**Onebox** transforms any Linux server into a powerful container hosting platform. Deploy Docker Swarm services with automatic HTTPS, DNS configuration, and Caddy reverse proxy running as a Docker service - all managed through a beautiful Angular web interface with real-time updates.
|
||||
Onebox is a self-hosted application platform for a single server. It combines Docker, SmartProxy routing, a typed web control plane, app templates, platform services, and containerarchive-powered backups into one Deno-distributed binary.
|
||||
|
||||
## Issue Reporting and Security
|
||||
|
||||
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
|
||||
|
||||
## What Makes Onebox Different? 🎯
|
||||
## What Onebox Does
|
||||
|
||||
- **Caddy Reverse Proxy in Docker** - Production-grade HTTP/HTTPS proxy running as a Swarm service with native service discovery, HTTP/2, HTTP/3, and bidirectional WebSocket proxying
|
||||
- **Docker Swarm First** - All workloads (including the reverse proxy!) run as Swarm services on the overlay network for seamless service-to-service communication
|
||||
- **Real-time Everything** - WebSocket-powered live updates for service status, logs, and metrics across all connected clients
|
||||
- **Single Executable** - Compiles to a standalone binary - just run it, no dependencies
|
||||
- **Private Registry Included** - Built-in Docker registry with token-based auth and auto-deploy on push
|
||||
- **Zero Config SSL** - Automatic Let's Encrypt certificates with inline `load_pem` (no volume mounts needed)
|
||||
- **Cloudflare Integration** - Automatic DNS record management and zone synchronization
|
||||
- **Modern Stack** - Deno runtime + SQLite database + Angular 19 UI
|
||||
Onebox turns a Linux host into a small PaaS that can run your own containers and curated app templates without a separate control plane. It is designed for the "one good server" use case: one machine, one local Docker runtime, one web dashboard, one operational surface.
|
||||
|
||||
## Features ✨
|
||||
- Deploys Docker workloads from external images or Onebox App Store templates.
|
||||
- Uses the local Docker socket and creates the `onebox-network` network automatically.
|
||||
- Runs workloads as Docker Swarm services when Swarm is active, otherwise as standalone containers.
|
||||
- Starts a SmartProxy-backed reverse proxy for HTTP/S routing and WebSocket traffic.
|
||||
- Serves the web UI and TypedRequest/TypedSocket API through `OpsServer` on port `3000` by default.
|
||||
- Stores platform state in SQLite.
|
||||
- Can provision app dependencies through local platform providers: MongoDB, MinIO/S3, ClickHouse, MariaDB, and Redis.
|
||||
- Tracks domains, Cloudflare DNS records, ACME certificates, service logs, metrics, backup schedules, and app template metadata.
|
||||
- Can sync routes and import certificates from an external `dcrouter` gateway when configured.
|
||||
|
||||
### Core Platform
|
||||
## Architecture
|
||||
|
||||
- 🐳 **Docker Swarm Management** - Deploy, scale, and orchestrate services with Swarm mode
|
||||
- 🌐 **Caddy Reverse Proxy** - Production-grade proxy running as Docker service with SNI, HTTP/2, HTTP/3
|
||||
- 🔒 **Automatic SSL Certificates** - Let's Encrypt integration with hot-reload and renewal monitoring
|
||||
- ☁️ **Cloudflare DNS Integration** - Automatic DNS record creation and zone synchronization
|
||||
- 📦 **Built-in Registry** - Private Docker registry with per-service tokens and auto-update
|
||||
- 🔄 **Real-time WebSocket Updates** - Live service status, logs, and system events
|
||||
```text
|
||||
browser / CLI
|
||||
|
|
||||
v
|
||||
OpsServer :3000
|
||||
- bundled web UI
|
||||
- TypedRequest handlers
|
||||
- TypedSocket dashboard events
|
||||
|
|
||||
v
|
||||
Onebox coordinator
|
||||
- SQLite repositories
|
||||
- Docker manager
|
||||
- SmartProxy route manager
|
||||
- DNS and SSL managers
|
||||
- platform service providers
|
||||
- app store manager
|
||||
- backup manager and scheduler
|
||||
|
|
||||
v
|
||||
Docker host
|
||||
- onebox-network
|
||||
- SmartProxy
|
||||
- user services
|
||||
- optional platform services
|
||||
```
|
||||
|
||||
### Monitoring & Management
|
||||
`Onebox` is the central class. It initializes the database, Docker, SmartProxy, DNS, SSL, platform services, App Store, backup subsystem, optional external gateway integration, and the web/API server.
|
||||
|
||||
- 📊 **Metrics Collection** - Historical CPU, memory, and network stats (every 60s)
|
||||
- 📝 **Centralized Logging** - Container logs with streaming and retention policies
|
||||
- 🎨 **Angular Web UI** - Modern, responsive interface with real-time updates
|
||||
- 👥 **Multi-user Support** - Role-based access control (admin/user)
|
||||
- 💾 **SQLite Database** - Embedded, zero-configuration storage
|
||||
## Installation
|
||||
|
||||
### Developer Experience
|
||||
|
||||
- 🚀 **Auto-update on Push** - Push to registry and services update automatically
|
||||
- 🔐 **Private Registry Support** - Use Docker Hub, Gitea, or custom registries
|
||||
- 🔄 **Systemd Integration** - Run as a daemon with auto-restart
|
||||
- 🎛️ **Full CLI & API** - Manage everything from terminal or HTTP API
|
||||
|
||||
## Quick Start 🏁
|
||||
|
||||
### Installation
|
||||
Install the released binary:
|
||||
|
||||
```bash
|
||||
# One-line install (recommended)
|
||||
curl -sSL https://code.foss.global/serve.zone/onebox/raw/branch/main/install.sh | sudo bash
|
||||
|
||||
# Install a specific version
|
||||
curl -sSL https://code.foss.global/serve.zone/onebox/raw/branch/main/install.sh | sudo bash -s -- --version v1.11.0
|
||||
|
||||
# Or install from npm
|
||||
pnpm install -g @serve.zone/onebox
|
||||
```
|
||||
|
||||
### First Run
|
||||
For published wrapper builds, install with pnpm:
|
||||
|
||||
```bash
|
||||
pnpm add --global @serve.zone/onebox
|
||||
```
|
||||
|
||||
This repository currently marks the package as private; use the install script or a released wrapper package when available.
|
||||
|
||||
The package wrapper downloads the platform-specific binary during postinstall. Current release assets are named for Linux, macOS, and Windows on x64/ARM64 where available.
|
||||
|
||||
## Quick Start
|
||||
|
||||
Run a foreground development instance:
|
||||
|
||||
```bash
|
||||
# Start the server in development mode
|
||||
onebox server --ephemeral
|
||||
|
||||
# In another terminal, deploy your first service
|
||||
onebox service add myapp \
|
||||
--image nginx:latest \
|
||||
--domain app.example.com \
|
||||
--port 80
|
||||
```
|
||||
|
||||
### Access the Web UI
|
||||
Open the dashboard:
|
||||
|
||||
Open `http://localhost:3000` in your browser.
|
||||
```text
|
||||
http://localhost:3000
|
||||
```
|
||||
|
||||
**Default credentials:**
|
||||
Default bootstrap credentials are created when no admin user exists:
|
||||
|
||||
- Username: `admin`
|
||||
- Password: `admin`
|
||||
```text
|
||||
username: admin
|
||||
password: admin
|
||||
```
|
||||
|
||||
⚠️ **Change the default password immediately after first login!**
|
||||
Change the default password immediately after first login.
|
||||
|
||||
### Production Setup
|
||||
Deploy a simple service:
|
||||
|
||||
```bash
|
||||
# Install as systemd service
|
||||
sudo onebox daemon install
|
||||
|
||||
# Start the daemon
|
||||
sudo onebox daemon start
|
||||
|
||||
# View logs
|
||||
sudo onebox daemon logs
|
||||
onebox service add web --image nginx:latest --domain web.example.com --port 80
|
||||
```
|
||||
|
||||
## Architecture 🏗️
|
||||
|
||||
Onebox is built with modern technologies for performance and developer experience:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Angular 19 Web UI │
|
||||
│ (Real-time WebSocket Updates) │
|
||||
└─────────────────┬───────────────────────────────┘
|
||||
│ HTTP/WS
|
||||
┌─────────────────▼───────────────────────────────┐
|
||||
│ Deno HTTP Server (Port 3000) │
|
||||
│ REST API + WebSocket Broadcast │
|
||||
└─────────────────┬───────────────────────────────┘
|
||||
│
|
||||
┌─────────────────▼───────────────────────────────┐
|
||||
│ Docker Swarm │
|
||||
│ ┌──────────────────────────────┐ │
|
||||
│ │ onebox-network (overlay) │ │
|
||||
│ ├──────────────────────────────┤ │
|
||||
│ │ onebox-caddy (Caddy proxy) │ │
|
||||
│ │ HTTP (80) + HTTPS (443) │ │
|
||||
│ │ Admin API → config updates │ │
|
||||
│ ├──────────────────────────────┤ │
|
||||
│ │ Your Services │ │
|
||||
│ │ (reachable by service name) │ │
|
||||
│ └──────────────────────────────┘ │
|
||||
└─────┬───────────────────────────────────────────┘
|
||||
│
|
||||
├──► SSL Certificate Manager (Let's Encrypt)
|
||||
├──► Cloudflare DNS Manager
|
||||
├──► Built-in Docker Registry
|
||||
└──► SQLite Database
|
||||
```
|
||||
|
||||
### Core Components
|
||||
|
||||
| Component | Description |
|
||||
| ----------------------- | -------------------------------------------------------------------- |
|
||||
| **Deno Runtime** | Modern TypeScript with built-in security |
|
||||
| **Caddy Reverse Proxy** | Docker Swarm service with HTTP/2, HTTP/3, SNI, and WebSocket support |
|
||||
| **Docker Swarm** | Container orchestration (all workloads run as services) |
|
||||
| **SQLite Database** | Configuration, metrics, and user data |
|
||||
| **WebSocket Server** | Real-time bidirectional communication |
|
||||
| **Let's Encrypt** | Automatic SSL certificate management |
|
||||
| **Cloudflare API** | DNS record automation |
|
||||
|
||||
## CLI Reference 📖
|
||||
|
||||
### Service Management
|
||||
For production, install and run the systemd service:
|
||||
|
||||
```bash
|
||||
# Deploy a service
|
||||
onebox service add <name> --image <image> --domain <domain> [--port <port>] [--env KEY=VALUE]
|
||||
|
||||
# Deploy with Onebox Registry (auto-update on push)
|
||||
onebox service add myapp --use-onebox-registry --domain myapp.example.com
|
||||
|
||||
# List services
|
||||
onebox service list
|
||||
|
||||
# Control services
|
||||
onebox service start <name>
|
||||
onebox service stop <name>
|
||||
onebox service restart <name>
|
||||
|
||||
# Remove service
|
||||
onebox service remove <name>
|
||||
|
||||
# View logs
|
||||
onebox service logs <name>
|
||||
sudo onebox systemd enable
|
||||
sudo onebox systemd start
|
||||
sudo onebox systemd logs
|
||||
```
|
||||
|
||||
### Server Management
|
||||
The systemd unit runs `onebox systemd start-daemon` with `/var/lib/onebox` as its working directory. From source or foreground runs, the default SQLite path is `./.nogit/onebox.db` relative to the current working directory.
|
||||
|
||||
## CLI Reference
|
||||
|
||||
```bash
|
||||
# Start server (development)
|
||||
onebox server --ephemeral # Runs in foreground with monitoring
|
||||
|
||||
# Start server (production)
|
||||
onebox daemon install # Install systemd service
|
||||
onebox daemon start # Start daemon
|
||||
onebox daemon stop # Stop daemon
|
||||
onebox daemon logs # View logs
|
||||
onebox <command> [options]
|
||||
```
|
||||
|
||||
### Registry Management
|
||||
Core commands:
|
||||
|
||||
| Command | Purpose |
|
||||
| --- | --- |
|
||||
| `server [--ephemeral] [--port <port>] [--monitor]` | Start the web/API server in the foreground. |
|
||||
| `service add <name> --image <image> [--domain <domain>] [--port <port>] [--env KEY=VALUE]` | Deploy a workload. |
|
||||
| `service list` | List known services. |
|
||||
| `service start <name>` | Start a stopped service. |
|
||||
| `service stop <name>` | Stop a running service. |
|
||||
| `service restart <name>` | Restart a service. |
|
||||
| `service remove <name>` | Remove a service and its route. |
|
||||
| `service logs <name>` | Print Docker logs for a service. |
|
||||
| `appstore list` | List remote app templates. |
|
||||
| `appstore config <app-id> [--version <version>]` | Print app metadata and version config. |
|
||||
| `appstore install <app-id> --name <name> [--domain <domain>] [--version <version>] [--env KEY=VALUE]` | Install an app template. |
|
||||
| `registry add --url <url> --username <user> --password <pass>` | Store external registry credentials. |
|
||||
| `registry remove --url <url>` | Remove registry credentials. |
|
||||
| `registry list` | List configured registries. |
|
||||
| `dns add <domain>` | Add a DNS record through the configured DNS manager. |
|
||||
| `dns sync` | Sync Cloudflare domains into Onebox. |
|
||||
| `ssl renew [domain]` | Renew one certificate or expiring certificates. |
|
||||
| `ssl list` | List stored certificates. |
|
||||
| `ssl force-renew <domain>` | Force certificate renewal for a domain. |
|
||||
| `proxy reload` | Reload routes and certificates into SmartProxy. |
|
||||
| `proxy test` | Check reverse proxy state. |
|
||||
| `proxy status` | Print route/certificate counts and ports. |
|
||||
| `systemd enable` | Install and enable the systemd unit. |
|
||||
| `systemd disable` | Stop, disable, and remove the systemd unit. |
|
||||
| `systemd start` | Start Onebox through systemd. |
|
||||
| `systemd stop` | Stop Onebox through systemd. |
|
||||
| `systemd status` | Show service status. |
|
||||
| `systemd logs` | Follow `journalctl` logs. |
|
||||
| `config show` | Show stored settings with secret values masked. |
|
||||
| `config set <key> <value>` | Store a setting or supported secret setting. |
|
||||
| `status` | Print JSON system status. |
|
||||
| `upgrade` | Install the latest released binary. Requires root. |
|
||||
|
||||
The legacy `nginx` command name is still accepted as an alias for `proxy`, but SmartProxy is the active proxy backend.
|
||||
|
||||
## Configuration Notes
|
||||
|
||||
Useful settings include:
|
||||
|
||||
| Setting | Purpose |
|
||||
| --- | --- |
|
||||
| `serverIP` | IP address used for DNS records. |
|
||||
| `cloudflareToken` | Cloudflare API token. `cloudflareAPIKey` is accepted as a legacy alias. |
|
||||
| `cloudflareZoneId` | Cloudflare zone identifier. |
|
||||
| `acmeEmail` | ACME account email for certificate issuance. |
|
||||
| `httpPort` | OpsServer/web UI port. Defaults to `3000`. |
|
||||
| `metricsInterval` | Metrics collection interval in milliseconds. |
|
||||
| `backupPassword` | Secret passphrase for encrypted backup repositories. |
|
||||
| `dcrouterGatewayUrl` | Optional external dcrouter API endpoint. |
|
||||
| `dcrouterGatewayApiToken` | Optional external dcrouter API token. |
|
||||
| `dcrouterWorkHosterId` | Optional work hoster identity used for route ownership. |
|
||||
| `dcrouterTargetHost` | Optional target host advertised to dcrouter. |
|
||||
| `dcrouterTargetPort` | Optional target port advertised to dcrouter. |
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
# Add external registry credentials
|
||||
onebox registry add --url registry.example.com --username user --password pass
|
||||
|
||||
# List registries
|
||||
onebox registry list
|
||||
|
||||
# Remove registry
|
||||
onebox registry remove <url>
|
||||
onebox config set serverIP 203.0.113.10
|
||||
onebox config set acmeEmail ops@example.com
|
||||
onebox config set cloudflareToken cf-token
|
||||
onebox config set cloudflareZoneId zone-id
|
||||
```
|
||||
|
||||
### DNS Management
|
||||
## App Store
|
||||
|
||||
The App Store manager fetches catalog data from `serve.zone/appstore-apptemplates` and caches it briefly. Templates can declare platform requirements, so installing an app can automatically provision MongoDB, S3-compatible storage, ClickHouse, Redis, or MariaDB resources and inject the resulting credentials as environment variables.
|
||||
|
||||
```bash
|
||||
# Add DNS record (requires Cloudflare config)
|
||||
onebox dns add <domain>
|
||||
|
||||
# List DNS records
|
||||
onebox dns list
|
||||
|
||||
# Sync from Cloudflare
|
||||
onebox dns sync
|
||||
|
||||
# Remove DNS record
|
||||
onebox dns remove <domain>
|
||||
onebox appstore list
|
||||
onebox appstore config cloudly
|
||||
onebox appstore install cloudly --name cloudly --domain cloudly.example.com --env SERVEZONE_ADMINACCOUNT=admin:change-me
|
||||
```
|
||||
|
||||
### SSL Management
|
||||
## Backups
|
||||
|
||||
Backups are built around `@serve.zone/containerarchive`. Onebox exports service configuration, platform resource metadata, supported platform data, and optionally Docker images into a content-addressed archive repository. The code also keeps compatibility paths for older `.tar.enc` backup flows.
|
||||
|
||||
Backup and schedule operations are primarily exposed through the OpsServer/web UI handlers.
|
||||
|
||||
## Development
|
||||
|
||||
Requirements:
|
||||
|
||||
- Deno for the application runtime.
|
||||
- pnpm for package scripts.
|
||||
- Docker for any runtime path that initializes Onebox fully.
|
||||
|
||||
Common tasks:
|
||||
|
||||
```bash
|
||||
# Renew expiring certificates
|
||||
onebox ssl renew
|
||||
|
||||
# Force renew specific domain
|
||||
onebox ssl force-renew <domain>
|
||||
|
||||
# List certificates
|
||||
onebox ssl list
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
```bash
|
||||
# Show all settings
|
||||
onebox config show
|
||||
|
||||
# Set configuration value
|
||||
onebox config set <key> <value>
|
||||
|
||||
# Example: Configure Cloudflare
|
||||
onebox config set cloudflareAPIKey your-api-key
|
||||
onebox config set cloudflareEmail your@email.com
|
||||
onebox config set cloudflareZoneID your-zone-id
|
||||
```
|
||||
|
||||
### System Status
|
||||
|
||||
```bash
|
||||
# Get full system status
|
||||
onebox status
|
||||
```
|
||||
|
||||
### Upgrade
|
||||
|
||||
```bash
|
||||
# Upgrade to the latest version (requires root)
|
||||
sudo onebox upgrade
|
||||
```
|
||||
|
||||
## Configuration 🔧
|
||||
|
||||
### System Requirements
|
||||
|
||||
- **Linux** (x64 or ARM64)
|
||||
- **Docker** installed and running
|
||||
- **Docker Swarm** initialized (`docker swarm init`)
|
||||
- **Root/sudo access** for ports 80/443
|
||||
- **(Optional) Cloudflare account** for DNS automation
|
||||
|
||||
### Data Locations
|
||||
|
||||
| Data | Location |
|
||||
| -------------------- | ------------------------------ |
|
||||
| **Database** | `./onebox.db` (or custom path) |
|
||||
| **SSL Certificates** | Managed by CertManager |
|
||||
| **Registry Data** | `./.nogit/registry-data` |
|
||||
|
||||
### Environment Variables
|
||||
|
||||
```bash
|
||||
# Database location
|
||||
ONEBOX_DB_PATH=/path/to/onebox.db
|
||||
|
||||
# HTTP server port (default: 3000)
|
||||
ONEBOX_HTTP_PORT=3000
|
||||
|
||||
# Enable debug logging
|
||||
ONEBOX_DEBUG=true
|
||||
```
|
||||
|
||||
## Development 💻
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
# Clone repository
|
||||
git clone https://code.foss.global/serve.zone/onebox
|
||||
cd onebox
|
||||
|
||||
# Start development server (auto-restart on changes)
|
||||
pnpm run watch
|
||||
```
|
||||
|
||||
### Tasks
|
||||
|
||||
```bash
|
||||
# Development server (auto-restart on changes)
|
||||
deno task dev
|
||||
|
||||
# Run tests
|
||||
pnpm build
|
||||
deno task test
|
||||
|
||||
# Watch mode for tests
|
||||
deno task test:watch
|
||||
|
||||
# Compile binaries for all platforms
|
||||
deno task compile
|
||||
```
|
||||
|
||||
### Project Structure
|
||||
Source map:
|
||||
|
||||
```
|
||||
onebox/
|
||||
├── ts/
|
||||
│ ├── classes/ # Core implementations
|
||||
│ │ ├── onebox.ts # Main coordinator
|
||||
│ │ ├── reverseproxy.ts # Reverse proxy orchestration
|
||||
│ │ ├── caddy.ts # Caddy Docker service management
|
||||
│ │ ├── docker.ts # Docker Swarm API
|
||||
│ │ ├── httpserver.ts # REST API + WebSocket
|
||||
│ │ ├── services.ts # Service orchestration
|
||||
│ │ ├── certmanager.ts # SSL certificate management
|
||||
│ │ ├── cert-requirement-manager.ts # Certificate requirements
|
||||
│ │ ├── ssl.ts # SSL utilities
|
||||
│ │ ├── registry.ts # Built-in Docker registry
|
||||
│ │ ├── registries.ts # External registry management
|
||||
│ │ ├── dns.ts # DNS record management
|
||||
│ │ ├── cloudflare-sync.ts # Cloudflare zone sync
|
||||
│ │ ├── daemon.ts # Systemd daemon management
|
||||
│ │ └── apiclient.ts # API client utilities
|
||||
│ ├── database/ # Database layer (repository pattern)
|
||||
│ │ ├── index.ts # Main OneboxDatabase class
|
||||
│ │ ├── base.repository.ts # Base repository class
|
||||
│ │ └── repositories/ # Domain-specific repositories
|
||||
│ │ ├── service.repository.ts
|
||||
│ │ ├── certificate.repository.ts
|
||||
│ │ ├── auth.repository.ts
|
||||
│ │ ├── metrics.repository.ts
|
||||
│ │ └── ...
|
||||
│ ├── cli.ts # CLI router
|
||||
│ ├── types.ts # TypeScript interfaces
|
||||
│ ├── logging.ts # Logging utilities
|
||||
│ └── plugins.ts # Dependency imports
|
||||
├── ui/ # Angular 19 web interface
|
||||
├── test/ # Test files
|
||||
├── mod.ts # Main entry point
|
||||
└── deno.json # Deno configuration
|
||||
```
|
||||
|
||||
### API Endpoints
|
||||
|
||||
The HTTP server exposes a comprehensive REST API:
|
||||
|
||||
#### Authentication
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| ------ | ----------------- | ----------------------------------- |
|
||||
| `POST` | `/api/auth/login` | User authentication (returns token) |
|
||||
|
||||
#### Services
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| -------- | --------------------------------- | ------------------------- |
|
||||
| `GET` | `/api/services` | List all services |
|
||||
| `POST` | `/api/services` | Create/deploy service |
|
||||
| `GET` | `/api/services/:name` | Get service details |
|
||||
| `PUT` | `/api/services/:name` | Update service |
|
||||
| `DELETE` | `/api/services/:name` | Delete service |
|
||||
| `POST` | `/api/services/:name/start` | Start service |
|
||||
| `POST` | `/api/services/:name/stop` | Stop service |
|
||||
| `POST` | `/api/services/:name/restart` | Restart service |
|
||||
| `GET` | `/api/services/:name/logs` | Get service logs |
|
||||
| `WS` | `/api/services/:name/logs/stream` | Stream logs via WebSocket |
|
||||
|
||||
#### SSL Certificates
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| ------ | ------------------------ | ----------------------- |
|
||||
| `GET` | `/api/ssl/list` | List all certificates |
|
||||
| `GET` | `/api/ssl/:domain` | Get certificate details |
|
||||
| `POST` | `/api/ssl/obtain` | Request new certificate |
|
||||
| `POST` | `/api/ssl/:domain/renew` | Force renew certificate |
|
||||
|
||||
#### Domains
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| ------ | ---------------------- | ---------------------------- |
|
||||
| `GET` | `/api/domains` | List all domains |
|
||||
| `GET` | `/api/domains/:domain` | Get domain details |
|
||||
| `POST` | `/api/domains/sync` | Sync domains from Cloudflare |
|
||||
|
||||
#### DNS Records
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| -------- | ------------------ | ------------------------ |
|
||||
| `GET` | `/api/dns` | List DNS records |
|
||||
| `POST` | `/api/dns` | Create DNS record |
|
||||
| `DELETE` | `/api/dns/:domain` | Delete DNS record |
|
||||
| `POST` | `/api/dns/sync` | Sync DNS from Cloudflare |
|
||||
|
||||
#### Registry
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| -------- | ----------------------------- | ----------------------------- |
|
||||
| `GET` | `/api/registry/tags/:service` | Get registry tags for service |
|
||||
| `GET` | `/api/registry/tokens` | List registry tokens |
|
||||
| `POST` | `/api/registry/tokens` | Create registry token |
|
||||
| `DELETE` | `/api/registry/tokens/:id` | Delete registry token |
|
||||
|
||||
#### System
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| ------ | --------------- | ------------------------------- |
|
||||
| `GET` | `/api/status` | System status |
|
||||
| `GET` | `/api/settings` | Get settings |
|
||||
| `PUT` | `/api/settings` | Update settings |
|
||||
| `WS` | `/api/ws` | WebSocket for real-time updates |
|
||||
|
||||
### WebSocket Messages
|
||||
|
||||
Real-time updates are broadcast via WebSocket:
|
||||
|
||||
```typescript
|
||||
// Service lifecycle updates
|
||||
{
|
||||
type: 'service_update',
|
||||
action: 'created' | 'updated' | 'deleted' | 'started' | 'stopped',
|
||||
service: { id, name, status, ... }
|
||||
}
|
||||
|
||||
// Service status changes
|
||||
{
|
||||
type: 'service_status',
|
||||
service: { id, name, status, ... }
|
||||
}
|
||||
|
||||
// System status updates
|
||||
{
|
||||
type: 'system_status',
|
||||
status: { docker, reverseProxy, services, ... }
|
||||
}
|
||||
```
|
||||
|
||||
## Advanced Usage 🚀
|
||||
|
||||
### Using the Built-in Registry
|
||||
|
||||
```bash
|
||||
# Deploy a service with Onebox Registry
|
||||
onebox service add myapp \
|
||||
--use-onebox-registry \
|
||||
--domain myapp.example.com \
|
||||
--auto-update-on-push
|
||||
|
||||
# Get the registry token for pushing images
|
||||
# (Token is automatically created and stored in database)
|
||||
|
||||
# Push your image
|
||||
docker tag myimage:latest localhost:4000/myapp:latest
|
||||
docker push localhost:4000/myapp:latest
|
||||
|
||||
# Service automatically updates! 🎉
|
||||
```
|
||||
|
||||
### Registry Token Management
|
||||
|
||||
```bash
|
||||
# Create a CI/CD token via API
|
||||
curl -X POST http://localhost:3000/api/registry/tokens \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name": "github-actions", "type": "ci", "scope": ["myapp"], "expiresIn": "90d"}'
|
||||
|
||||
# Use token for docker login
|
||||
docker login localhost:4000 -u ci -p <token>
|
||||
```
|
||||
|
||||
### Cloudflare DNS Integration
|
||||
|
||||
```bash
|
||||
# Configure Cloudflare (one-time setup)
|
||||
onebox config set cloudflareAPIKey your-api-key
|
||||
onebox config set cloudflareEmail your@email.com
|
||||
onebox config set cloudflareZoneID your-zone-id
|
||||
|
||||
# Deploy with automatic DNS
|
||||
onebox service add myapp \
|
||||
--image nginx:latest \
|
||||
--domain myapp.example.com
|
||||
|
||||
# DNS record is automatically created!
|
||||
|
||||
# Sync all domains from Cloudflare
|
||||
onebox dns sync
|
||||
```
|
||||
|
||||
### SSL Certificate Management
|
||||
|
||||
SSL certificates are automatically obtained and renewed:
|
||||
|
||||
- ✅ Certificates are requested when a service with a domain is deployed
|
||||
- ✅ Renewal happens automatically 30 days before expiry
|
||||
- ✅ Certificates are hot-reloaded without downtime
|
||||
- ✅ Force renewal: `onebox ssl force-renew <domain>`
|
||||
|
||||
### Monitoring and Metrics
|
||||
|
||||
Metrics are collected every 60 seconds (configurable):
|
||||
|
||||
```bash
|
||||
# Set metrics interval (milliseconds)
|
||||
onebox config set metricsInterval 30000
|
||||
|
||||
# View in web UI or query database directly
|
||||
sqlite3 onebox.db "SELECT * FROM metrics WHERE service_id = 1 ORDER BY timestamp DESC LIMIT 10"
|
||||
```
|
||||
|
||||
## Troubleshooting 🔧
|
||||
|
||||
### Docker Swarm Not Initialized
|
||||
|
||||
```bash
|
||||
# Initialize Docker Swarm
|
||||
docker swarm init
|
||||
|
||||
# Verify swarm mode
|
||||
docker info | grep "Swarm: active"
|
||||
```
|
||||
|
||||
### Port Already in Use
|
||||
|
||||
```bash
|
||||
# Check what's using port 80/443
|
||||
sudo lsof -i :80
|
||||
sudo lsof -i :443
|
||||
|
||||
# Kill the process or change Onebox ports
|
||||
onebox config set httpPort 8080
|
||||
```
|
||||
|
||||
### SSL Certificate Issues
|
||||
|
||||
```bash
|
||||
# Check certificate status
|
||||
onebox ssl list
|
||||
|
||||
# Verify DNS is pointing to your server
|
||||
dig +short yourdomain.com
|
||||
|
||||
# Force certificate renewal
|
||||
onebox ssl force-renew yourdomain.com
|
||||
```
|
||||
|
||||
### WebSocket Connection Issues
|
||||
|
||||
- ✅ Ensure firewall allows WebSocket connections
|
||||
- ✅ Check browser console for connection errors
|
||||
- ✅ Verify `/api/ws` endpoint is accessible
|
||||
|
||||
### Service Not Starting
|
||||
|
||||
```bash
|
||||
# Check Docker logs
|
||||
docker service logs <service-name>
|
||||
|
||||
# Check Onebox logs
|
||||
onebox daemon logs
|
||||
|
||||
# Verify image exists
|
||||
docker images | grep <image-name>
|
||||
```
|
||||
| Path | Purpose |
|
||||
| --- | --- |
|
||||
| `mod.ts` | Deno entry point. |
|
||||
| `ts/cli.ts` | CLI router and command help. |
|
||||
| `ts/classes/onebox.ts` | Main coordinator. |
|
||||
| `ts/classes/docker.ts` | Docker client, networks, containers, and Swarm services. |
|
||||
| `ts/classes/reverseproxy.ts` | SmartProxy route and certificate bridge. |
|
||||
| `ts/classes/platform-services/` | Local platform service providers. |
|
||||
| `ts/classes/appstore.ts` | Remote App Store catalog and upgrade logic. |
|
||||
| `ts/classes/backup-manager.ts` | Backup and restore orchestration. |
|
||||
| `ts/opsserver/` | Web UI server and TypedRequest handlers. |
|
||||
| `ts/database/` | SQLite repositories and migrations. |
|
||||
| `ts_web/` | Dashboard source. |
|
||||
|
||||
## License and Legal Information
|
||||
|
||||
This repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository.
|
||||
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [license](./license) file.
|
||||
|
||||
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
||||
|
||||
### Trademarks
|
||||
|
||||
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH and are not included within the scope of the MIT license granted herein. Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines, and any usage must be approved in writing by Task Venture Capital GmbH.
|
||||
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
|
||||
|
||||
### Issue Reporting and Security
|
||||
|
||||
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
|
||||
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
|
||||
|
||||
### Company Information
|
||||
|
||||
Task Venture Capital GmbH
|
||||
Registered at District court Bremen HRB 35230 HB, Germany
|
||||
Task Venture Capital GmbH\
|
||||
Registered at District Court Bremen HRB 35230 HB, Germany
|
||||
|
||||
For any legal inquiries or if you require further information, please contact us via email at hello@task.vc.
|
||||
For any legal inquiries or further information, please contact us via email at hello@task.vc.
|
||||
|
||||
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { assert, assertEquals, fail } from '@std/assert';
|
||||
|
||||
import * as plugins from '../ts/plugins.ts';
|
||||
import type { IUser as IDatabaseUser } from '../ts/types.ts';
|
||||
import { AdminHandler } from '../ts/opsserver/handlers/admin.handler.ts';
|
||||
import {
|
||||
hashPassword,
|
||||
isPbkdf2Hash,
|
||||
verifyPassword,
|
||||
} from '../ts/utils/auth.ts';
|
||||
|
||||
class FakeDatabase {
|
||||
constructor(private users: Map<string, IDatabaseUser>) {}
|
||||
|
||||
getUserByUsername(username: string): IDatabaseUser | null {
|
||||
return this.users.get(username) ?? null;
|
||||
}
|
||||
|
||||
updateUserPassword(username: string, passwordHash: string): void {
|
||||
const user = this.users.get(username);
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.users.set(username, {
|
||||
...user,
|
||||
passwordHash,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function createAdminHandler(users: IDatabaseUser[]): Promise<AdminHandler> {
|
||||
const userMap = new Map(users.map((user) => [user.username, user]));
|
||||
const fakeOpsServer = {
|
||||
typedrouter: new plugins.typedrequest.TypedRouter(),
|
||||
oneboxRef: {
|
||||
database: new FakeDatabase(userMap),
|
||||
},
|
||||
};
|
||||
|
||||
const adminHandler = new AdminHandler(fakeOpsServer as any);
|
||||
await adminHandler.initialize();
|
||||
return adminHandler;
|
||||
}
|
||||
|
||||
Deno.test('password helpers support PBKDF2 password hashes', async () => {
|
||||
const password = 'correct horse battery staple';
|
||||
const passwordHash = await hashPassword(password);
|
||||
|
||||
assert(isPbkdf2Hash(passwordHash));
|
||||
assert(await verifyPassword(password, passwordHash));
|
||||
assert(!(await verifyPassword('wrong password', passwordHash)));
|
||||
assert(!(await verifyPassword(password, btoa(password))));
|
||||
});
|
||||
|
||||
Deno.test('verified identity is derived from the signed JWT and database, not client fields', async () => {
|
||||
const adminHandler = await createAdminHandler([
|
||||
{
|
||||
id: 1,
|
||||
username: 'alice',
|
||||
passwordHash: await hashPassword('password123'),
|
||||
role: 'user',
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
]);
|
||||
|
||||
const expiresAt = Date.now() + 60_000;
|
||||
const jwt = await adminHandler.smartjwtInstance.createJWT({
|
||||
userId: '1',
|
||||
username: 'alice',
|
||||
role: 'user',
|
||||
status: 'loggedIn',
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
const verifiedIdentity = await adminHandler.getVerifiedIdentity({
|
||||
jwt,
|
||||
userId: '999',
|
||||
username: 'mallory',
|
||||
role: 'admin',
|
||||
expiresAt: 0,
|
||||
});
|
||||
|
||||
assertEquals(verifiedIdentity.userId, '1');
|
||||
assertEquals(verifiedIdentity.username, 'alice');
|
||||
assertEquals(verifiedIdentity.role, 'user');
|
||||
assertEquals(verifiedIdentity.expiresAt, expiresAt);
|
||||
|
||||
let rejected = false;
|
||||
try {
|
||||
await adminHandler.getVerifiedAdminIdentity(verifiedIdentity);
|
||||
fail('Expected admin-only identity verification to reject non-admin users');
|
||||
} catch {
|
||||
rejected = true;
|
||||
}
|
||||
|
||||
assert(rejected);
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
import { assert, assertEquals } from '@std/assert';
|
||||
|
||||
import { ExternalGatewayManager } from '../ts/classes/external-gateway.ts';
|
||||
import type { IDomain, IService, ISslCertificate } from '../ts/types.ts';
|
||||
|
||||
class FakeDatabase {
|
||||
public settings = new Map<string, string>();
|
||||
public secretSettings = new Map<string, string>();
|
||||
public domains: IDomain[] = [];
|
||||
public certificates = new Map<string, ISslCertificate>();
|
||||
private nextDomainId = 1;
|
||||
|
||||
getSetting(key: string): string | null {
|
||||
return this.settings.get(key) ?? null;
|
||||
}
|
||||
|
||||
setSetting(key: string, value: string): void {
|
||||
this.settings.set(key, value);
|
||||
}
|
||||
|
||||
async getSecretSetting(key: string): Promise<string | null> {
|
||||
return this.secretSettings.get(key) ?? null;
|
||||
}
|
||||
|
||||
getDomainByName(domain: string): IDomain | null {
|
||||
return this.domains.find((entry) => entry.domain === domain) ?? null;
|
||||
}
|
||||
|
||||
createDomain(domain: Omit<IDomain, 'id'>): IDomain {
|
||||
const createdDomain = { ...domain, id: this.nextDomainId++ };
|
||||
this.domains.push(createdDomain);
|
||||
return createdDomain;
|
||||
}
|
||||
|
||||
updateDomain(id: number, updates: Partial<IDomain>): void {
|
||||
const index = this.domains.findIndex((entry) => entry.id === id);
|
||||
if (index === -1) return;
|
||||
this.domains[index] = { ...this.domains[index], ...updates };
|
||||
}
|
||||
|
||||
getDomainsByProvider(provider: NonNullable<IDomain['dnsProvider']>): IDomain[] {
|
||||
return this.domains.filter((entry) => entry.dnsProvider === provider);
|
||||
}
|
||||
|
||||
getSSLCertificate(domain: string): ISslCertificate | null {
|
||||
return this.certificates.get(domain) ?? null;
|
||||
}
|
||||
|
||||
updateSSLCertificate(domain: string, updates: Partial<ISslCertificate>): void {
|
||||
const existing = this.certificates.get(domain);
|
||||
if (!existing) return;
|
||||
this.certificates.set(domain, { ...existing, ...updates });
|
||||
}
|
||||
|
||||
async createSSLCertificate(cert: Omit<ISslCertificate, 'id'>): Promise<ISslCertificate> {
|
||||
const storedCert = { ...cert, id: this.certificates.size + 1 };
|
||||
this.certificates.set(cert.domain, storedCert);
|
||||
return storedCert;
|
||||
}
|
||||
}
|
||||
|
||||
const makeOneboxRef = () => {
|
||||
const database = new FakeDatabase();
|
||||
database.settings.set('dcrouterGatewayUrl', 'https://edge.example.com');
|
||||
database.settings.set('dcrouterWorkHosterId', 'onebox-1');
|
||||
database.secretSettings.set('dcrouterGatewayApiToken', 'dcr-token');
|
||||
|
||||
let reloadCount = 0;
|
||||
return {
|
||||
database,
|
||||
reverseProxy: {
|
||||
reloadCertificates: async () => {
|
||||
reloadCount++;
|
||||
},
|
||||
get reloadCount() {
|
||||
return reloadCount;
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
Deno.test('ExternalGatewayManager syncs dcrouter domains into Onebox domains', async () => {
|
||||
const oneboxRef = makeOneboxRef();
|
||||
oneboxRef.database.domains.push({
|
||||
id: 99,
|
||||
domain: 'old.example.com',
|
||||
dnsProvider: 'dcrouter',
|
||||
isObsolete: false,
|
||||
defaultWildcard: true,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
});
|
||||
|
||||
const manager = new ExternalGatewayManager(oneboxRef as any);
|
||||
(manager as any).fireDcRouterRequest = async (method: string) => {
|
||||
assertEquals(method, 'getWorkHosterDomains');
|
||||
return {
|
||||
domains: [
|
||||
{
|
||||
name: 'example.com',
|
||||
capabilities: {
|
||||
canCreateSubdomains: true,
|
||||
canManageDnsRecords: true,
|
||||
canIssueCertificates: true,
|
||||
canHostEmail: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
const domains = await manager.syncDomains();
|
||||
|
||||
assertEquals(domains.length, 2);
|
||||
assertEquals(oneboxRef.database.getDomainByName('example.com')?.dnsProvider, 'dcrouter');
|
||||
assertEquals(oneboxRef.database.getDomainByName('example.com')?.defaultWildcard, true);
|
||||
assertEquals(oneboxRef.database.getDomainByName('old.example.com')?.isObsolete, true);
|
||||
});
|
||||
|
||||
Deno.test('ExternalGatewayManager syncs service routes to dcrouter WorkHoster API', async () => {
|
||||
const oneboxRef = makeOneboxRef();
|
||||
oneboxRef.database.settings.set('serverIP', '203.0.113.10');
|
||||
oneboxRef.database.settings.set('httpPort', '8080');
|
||||
|
||||
const service: IService = {
|
||||
id: 1,
|
||||
name: 'hello',
|
||||
image: 'nginx:latest',
|
||||
envVars: {},
|
||||
port: 3000,
|
||||
domain: 'hello.example.com',
|
||||
status: 'running',
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
};
|
||||
|
||||
const requests: Array<{ method: string; requestData: Record<string, unknown> }> = [];
|
||||
const manager = new ExternalGatewayManager(oneboxRef as any);
|
||||
(manager as any).fireDcRouterRequest = async (method: string, requestData: Record<string, unknown>) => {
|
||||
requests.push({ method, requestData });
|
||||
if (method === 'exportCertificate') {
|
||||
return { success: false };
|
||||
}
|
||||
return { success: true, action: 'created', routeId: 'route-1' };
|
||||
};
|
||||
|
||||
await manager.syncServiceRoute(service);
|
||||
|
||||
const syncRequest = requests.find((request) => request.method === 'syncWorkAppRoute')!;
|
||||
const route = syncRequest.requestData.route as any;
|
||||
const ownership = syncRequest.requestData.ownership as any;
|
||||
|
||||
assertEquals(ownership, {
|
||||
workHosterType: 'onebox',
|
||||
workHosterId: 'onebox-1',
|
||||
workAppId: 'hello',
|
||||
hostname: 'hello.example.com',
|
||||
});
|
||||
assertEquals(route.match, { ports: [443], domains: ['hello.example.com'] });
|
||||
assertEquals(route.action.targets, [{ host: '203.0.113.10', port: 8080 }]);
|
||||
assertEquals(route.action.tls, { mode: 'terminate', certificate: 'auto' });
|
||||
assertEquals(syncRequest.requestData.enabled, true);
|
||||
});
|
||||
|
||||
Deno.test('ExternalGatewayManager deletes service routes through dcrouter WorkHoster API', async () => {
|
||||
const oneboxRef = makeOneboxRef();
|
||||
const manager = new ExternalGatewayManager(oneboxRef as any);
|
||||
let deleteRequest: Record<string, unknown> | null = null;
|
||||
|
||||
(manager as any).fireDcRouterRequest = async (method: string, requestData: Record<string, unknown>) => {
|
||||
assertEquals(method, 'syncWorkAppRoute');
|
||||
deleteRequest = requestData;
|
||||
return { success: true, action: 'deleted', routeId: 'route-1' };
|
||||
};
|
||||
|
||||
await manager.deleteServiceRoute({
|
||||
id: 1,
|
||||
name: 'hello',
|
||||
domain: 'hello.example.com',
|
||||
});
|
||||
|
||||
assert(deleteRequest);
|
||||
const capturedDeleteRequest = deleteRequest as Record<string, unknown>;
|
||||
assertEquals(capturedDeleteRequest.delete, true);
|
||||
assertEquals((capturedDeleteRequest.ownership as any).hostname, 'hello.example.com');
|
||||
});
|
||||
|
||||
Deno.test('ExternalGatewayManager imports exported dcrouter certificates into Onebox', async () => {
|
||||
const oneboxRef = makeOneboxRef();
|
||||
const manager = new ExternalGatewayManager(oneboxRef as any);
|
||||
(manager as any).fireDcRouterRequest = async (method: string, requestData: Record<string, unknown>) => {
|
||||
assertEquals(method, 'exportCertificate');
|
||||
assertEquals(requestData.domain, 'hello.example.com');
|
||||
return {
|
||||
success: true,
|
||||
cert: {
|
||||
id: 'cert-1',
|
||||
domainName: 'hello.example.com',
|
||||
created: 1,
|
||||
validUntil: 2,
|
||||
privateKey: '-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----',
|
||||
publicKey: '-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----',
|
||||
csr: '',
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const imported = await manager.importCertificateForDomain('hello.example.com');
|
||||
|
||||
assert(imported);
|
||||
assertEquals(oneboxRef.database.getSSLCertificate('hello.example.com')?.issuer, 'dcrouter');
|
||||
assertEquals(oneboxRef.reverseProxy.reloadCount, 1);
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { assert, assertEquals } from '@std/assert';
|
||||
|
||||
import { SecretSettingsManager } from '../ts/database/secret-settings.ts';
|
||||
|
||||
class FakeAuthRepository {
|
||||
public settings = new Map<string, string>();
|
||||
public secretSettings = new Map<string, string>();
|
||||
|
||||
getSetting(key: string): string | null {
|
||||
return this.settings.get(key) ?? null;
|
||||
}
|
||||
|
||||
setSetting(key: string, value: string): void {
|
||||
this.settings.set(key, value);
|
||||
}
|
||||
|
||||
deleteSetting(key: string): void {
|
||||
this.settings.delete(key);
|
||||
}
|
||||
|
||||
getSecretSetting(key: string): string | null {
|
||||
return this.secretSettings.get(key) ?? null;
|
||||
}
|
||||
|
||||
setSecretSetting(key: string, value: string): void {
|
||||
this.secretSettings.set(key, value);
|
||||
}
|
||||
|
||||
deleteSecretSetting(key: string): void {
|
||||
this.secretSettings.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
Deno.test('secret settings migrate legacy plaintext aliases into encrypted storage', async () => {
|
||||
const authRepo = new FakeAuthRepository();
|
||||
authRepo.setSetting('cloudflareAPIKey', 'cf-secret-token');
|
||||
|
||||
const secretSettings = new SecretSettingsManager(authRepo as any);
|
||||
const token = await secretSettings.get('cloudflareToken');
|
||||
|
||||
assertEquals(token, 'cf-secret-token');
|
||||
assertEquals(authRepo.getSetting('cloudflareAPIKey'), null);
|
||||
assertEquals(authRepo.getSetting('cloudflareToken'), null);
|
||||
|
||||
const storedSecret = authRepo.getSecretSetting('cloudflareToken');
|
||||
assert(storedSecret?.startsWith('enc:v1:'));
|
||||
});
|
||||
|
||||
Deno.test('secret settings canonicalize aliases and clear old secret entries', async () => {
|
||||
const authRepo = new FakeAuthRepository();
|
||||
const secretSettings = new SecretSettingsManager(authRepo as any);
|
||||
|
||||
await secretSettings.set('backup_encryption_password', 'backup-passphrase');
|
||||
|
||||
assertEquals(await secretSettings.get('backupPassword'), 'backup-passphrase');
|
||||
assert(authRepo.getSecretSetting('backupPassword')?.startsWith('enc:v1:'));
|
||||
assertEquals(authRepo.getSecretSetting('backup_encryption_password'), null);
|
||||
|
||||
secretSettings.clear('backupPassword');
|
||||
assertEquals(await secretSettings.get('backupPassword'), null);
|
||||
});
|
||||
|
||||
Deno.test('secret settings treat dcrouter gateway token as encrypted secret', async () => {
|
||||
const authRepo = new FakeAuthRepository();
|
||||
authRepo.setSetting('externalGatewayApiToken', 'dcr-secret-token');
|
||||
|
||||
const secretSettings = new SecretSettingsManager(authRepo as any);
|
||||
const token = await secretSettings.get('dcrouterGatewayApiToken');
|
||||
|
||||
assertEquals(token, 'dcr-secret-token');
|
||||
assertEquals(authRepo.getSetting('externalGatewayApiToken'), null);
|
||||
assert(authRepo.getSecretSetting('dcrouterGatewayApiToken')?.startsWith('enc:v1:'));
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { assert, assertEquals } from '@std/assert';
|
||||
|
||||
import type { IRegistry } from '../ts/types.ts';
|
||||
import { credentialEncryption } from '../ts/classes/encryption.ts';
|
||||
import { OneboxRegistriesManager } from '../ts/classes/registries.ts';
|
||||
|
||||
class FakeRegistryDatabase {
|
||||
private registries = new Map<string, IRegistry>();
|
||||
|
||||
getRegistryByURL(url: string): IRegistry | null {
|
||||
return this.registries.get(url) ?? null;
|
||||
}
|
||||
|
||||
async createRegistry(registry: Omit<IRegistry, 'id'>): Promise<IRegistry> {
|
||||
const savedRegistry: IRegistry = {
|
||||
id: this.registries.size + 1,
|
||||
...registry,
|
||||
};
|
||||
this.registries.set(savedRegistry.url, savedRegistry);
|
||||
return savedRegistry;
|
||||
}
|
||||
|
||||
deleteRegistry(url: string): void {
|
||||
this.registries.delete(url);
|
||||
}
|
||||
|
||||
getAllRegistries(): IRegistry[] {
|
||||
return Array.from(this.registries.values());
|
||||
}
|
||||
}
|
||||
|
||||
Deno.test('credential encryption lazily initializes and roundtrips payloads', async () => {
|
||||
const encrypted = await credentialEncryption.encrypt({ password: 'super-secret' });
|
||||
const decrypted = await credentialEncryption.decrypt<{ password: string }>(encrypted);
|
||||
|
||||
assert(encrypted.length > 0);
|
||||
assertEquals(decrypted.password, 'super-secret');
|
||||
});
|
||||
|
||||
Deno.test('registry passwords use encrypted storage with legacy decode fallback', async () => {
|
||||
const fakeDatabase = new FakeRegistryDatabase();
|
||||
const registriesManager = new OneboxRegistriesManager({ database: fakeDatabase } as any);
|
||||
|
||||
(registriesManager as any).loginToRegistry = async () => {};
|
||||
|
||||
const registry = await registriesManager.addRegistry(
|
||||
'registry.example.com',
|
||||
'ci-user',
|
||||
'correct horse battery staple',
|
||||
);
|
||||
|
||||
assert(registry.passwordEncrypted.startsWith('enc:v1:'));
|
||||
assertEquals(
|
||||
await (registriesManager as any).decryptPassword(registry.passwordEncrypted),
|
||||
'correct horse battery staple',
|
||||
);
|
||||
assertEquals(
|
||||
await (registriesManager as any).decryptPassword(btoa('legacy-password')),
|
||||
'legacy-password',
|
||||
);
|
||||
});
|
||||
@@ -1,210 +0,0 @@
|
||||
/**
|
||||
* API Client for communicating with Onebox daemon
|
||||
*
|
||||
* Provides methods for CLI commands to interact with running daemon via HTTP API
|
||||
*/
|
||||
|
||||
import type {
|
||||
IService,
|
||||
IRegistry,
|
||||
IDnsRecord,
|
||||
ISslCertificate,
|
||||
IServiceDeployOptions,
|
||||
} from '../types.ts';
|
||||
import { getErrorMessage } from '../utils/error.ts';
|
||||
|
||||
export class OneboxApiClient {
|
||||
private baseUrl: string;
|
||||
private token?: string;
|
||||
|
||||
constructor(port = 3000) {
|
||||
this.baseUrl = `http://localhost:${port}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if daemon is reachable
|
||||
*/
|
||||
async isReachable(): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/api/status`, {
|
||||
signal: AbortSignal.timeout(5000), // 5 second timeout
|
||||
});
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ============ Service Operations ============
|
||||
|
||||
async deployService(config: IServiceDeployOptions): Promise<IService> {
|
||||
return await this.request<IService>('POST', '/api/services', config);
|
||||
}
|
||||
|
||||
async removeService(name: string): Promise<void> {
|
||||
await this.request('DELETE', `/api/services/${name}`);
|
||||
}
|
||||
|
||||
async startService(name: string): Promise<void> {
|
||||
await this.request('POST', `/api/services/${name}/start`);
|
||||
}
|
||||
|
||||
async stopService(name: string): Promise<void> {
|
||||
await this.request('POST', `/api/services/${name}/stop`);
|
||||
}
|
||||
|
||||
async restartService(name: string): Promise<void> {
|
||||
await this.request('POST', `/api/services/${name}/restart`);
|
||||
}
|
||||
|
||||
async listServices(): Promise<IService[]> {
|
||||
return await this.request<IService[]>('GET', '/api/services');
|
||||
}
|
||||
|
||||
async getServiceLogs(name: string, limit = 1000): Promise<string[]> {
|
||||
const result = await this.request<{ logs: string[] }>(
|
||||
'GET',
|
||||
`/api/services/${name}/logs?limit=${limit}`
|
||||
);
|
||||
return result.logs;
|
||||
}
|
||||
|
||||
// ============ Registry Operations ============
|
||||
|
||||
async addRegistry(url: string, username: string, password: string): Promise<void> {
|
||||
await this.request('POST', '/api/registries', { url, username, password });
|
||||
}
|
||||
|
||||
async removeRegistry(url: string): Promise<void> {
|
||||
await this.request('DELETE', `/api/registries/${encodeURIComponent(url)}`);
|
||||
}
|
||||
|
||||
async listRegistries(): Promise<IRegistry[]> {
|
||||
return await this.request<IRegistry[]>('GET', '/api/registries');
|
||||
}
|
||||
|
||||
// ============ DNS Operations ============
|
||||
|
||||
async addDnsRecord(domain: string): Promise<void> {
|
||||
await this.request('POST', '/api/dns', { domain });
|
||||
}
|
||||
|
||||
async removeDnsRecord(domain: string): Promise<void> {
|
||||
await this.request('DELETE', `/api/dns/${domain}`);
|
||||
}
|
||||
|
||||
async listDnsRecords(): Promise<IDnsRecord[]> {
|
||||
return await this.request<IDnsRecord[]>('GET', '/api/dns');
|
||||
}
|
||||
|
||||
async syncDns(): Promise<void> {
|
||||
await this.request('POST', '/api/dns/sync');
|
||||
}
|
||||
|
||||
// ============ SSL Operations ============
|
||||
|
||||
async renewCertificate(domain?: string): Promise<void> {
|
||||
const path = domain ? `/api/ssl/renew/${domain}` : '/api/ssl/renew';
|
||||
await this.request('POST', path);
|
||||
}
|
||||
|
||||
async listCertificates(): Promise<ISslCertificate[]> {
|
||||
return await this.request<ISslCertificate[]>('GET', '/api/ssl');
|
||||
}
|
||||
|
||||
async forceRenewCertificate(domain: string): Promise<void> {
|
||||
await this.request('POST', `/api/ssl/renew/${domain}?force=true`);
|
||||
}
|
||||
|
||||
// ============ Nginx Operations ============
|
||||
|
||||
async reloadNginx(): Promise<void> {
|
||||
await this.request('POST', '/api/nginx/reload');
|
||||
}
|
||||
|
||||
async testNginx(): Promise<{ success: boolean; output: string }> {
|
||||
return await this.request('POST', '/api/nginx/test');
|
||||
}
|
||||
|
||||
async getNginxStatus(): Promise<{ status: string }> {
|
||||
return await this.request('GET', '/api/nginx/status');
|
||||
}
|
||||
|
||||
// ============ Config Operations ============
|
||||
|
||||
async getSettings(): Promise<Record<string, string>> {
|
||||
return await this.request<Record<string, string>>('GET', '/api/config');
|
||||
}
|
||||
|
||||
async setSetting(key: string, value: string): Promise<void> {
|
||||
await this.request('POST', '/api/config', { key, value });
|
||||
}
|
||||
|
||||
// ============ System Operations ============
|
||||
|
||||
async getStatus(): Promise<{
|
||||
services: { total: number; running: number; stopped: number };
|
||||
uptime: number;
|
||||
}> {
|
||||
return await this.request('GET', '/api/status');
|
||||
}
|
||||
|
||||
// ============ Helper Methods ============
|
||||
|
||||
/**
|
||||
* Make HTTP request to daemon
|
||||
*/
|
||||
private async request<T = unknown>(
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown
|
||||
): Promise<T> {
|
||||
const url = `${this.baseUrl}${path}`;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
if (this.token) {
|
||||
headers['Authorization'] = `Bearer ${this.token}`;
|
||||
}
|
||||
|
||||
const options: RequestInit = {
|
||||
method,
|
||||
headers,
|
||||
signal: AbortSignal.timeout(30000), // 30 second timeout
|
||||
};
|
||||
|
||||
if (body) {
|
||||
options.body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, options);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ message: response.statusText }));
|
||||
throw new Error(errorData.message || `HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
// For DELETE and some POST requests, there might be no content
|
||||
if (response.status === 204 || response.headers.get('content-length') === '0') {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'TimeoutError') {
|
||||
throw new Error('Request timed out. Daemon might be unresponsive.');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set authentication token
|
||||
*/
|
||||
setToken(token: string): void {
|
||||
this.token = token;
|
||||
}
|
||||
}
|
||||
+175
-92
@@ -43,6 +43,14 @@ const IV_LENGTH = 12;
|
||||
const SALT_LENGTH = 32;
|
||||
const PBKDF2_ITERATIONS = 100000;
|
||||
|
||||
interface IS3ConnectionInfo {
|
||||
endpoint: string;
|
||||
accessKey: string;
|
||||
secretKey: string;
|
||||
bucket: string;
|
||||
region: string;
|
||||
}
|
||||
|
||||
export class BackupManager {
|
||||
private oneboxRef: Onebox;
|
||||
public archive: plugins.ContainerArchive | null = null;
|
||||
@@ -57,7 +65,7 @@ export class BackupManager {
|
||||
*/
|
||||
async init(): Promise<void> {
|
||||
const repoPath = this.getArchiveRepoPath();
|
||||
const passphrase = this.getBackupPassword() || undefined;
|
||||
const passphrase = await this.getBackupPassword() || undefined;
|
||||
|
||||
try {
|
||||
// Try to open existing repo
|
||||
@@ -198,14 +206,12 @@ export class BackupManager {
|
||||
for (const resourceType of resourceTypes) {
|
||||
const dataDir = `${tempDir}/data/${resourceType}`;
|
||||
try {
|
||||
for await (const entry of Deno.readDir(dataDir)) {
|
||||
if (entry.isFile) {
|
||||
items.push({
|
||||
stream: plugins.nodeFs.createReadStream(`${dataDir}/${entry.name}`),
|
||||
name: `data/${resourceType}/${entry.name}`,
|
||||
type: 'data',
|
||||
});
|
||||
}
|
||||
for await (const filePath of this.walkFiles(dataDir)) {
|
||||
items.push({
|
||||
stream: plugins.nodeFs.createReadStream(filePath),
|
||||
name: plugins.path.relative(tempDir, filePath).replaceAll('\\', '/'),
|
||||
type: 'data',
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Directory may not exist if export produced no files
|
||||
@@ -495,7 +501,7 @@ export class BackupManager {
|
||||
await Deno.remove(tempDir, { recursive: true });
|
||||
|
||||
// Encrypt for transport
|
||||
const password = this.getBackupPassword();
|
||||
const password = await this.getBackupPassword();
|
||||
if (password) {
|
||||
const encPath = `${tarPath}.enc`;
|
||||
await this.encryptFile(tarPath, encPath, password);
|
||||
@@ -518,8 +524,8 @@ export class BackupManager {
|
||||
/**
|
||||
* Get backup password from settings
|
||||
*/
|
||||
private getBackupPassword(): string | null {
|
||||
return this.oneboxRef.database.getSetting('backup_encryption_password');
|
||||
private async getBackupPassword(): Promise<string | null> {
|
||||
return await this.oneboxRef.database.getSecretSetting('backupPassword');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -542,7 +548,7 @@ export class BackupManager {
|
||||
* Restore from a legacy .tar.enc file
|
||||
*/
|
||||
private async restoreLegacyBackup(backupPath: string, options: IRestoreOptions): Promise<IRestoreResult> {
|
||||
const backupPassword = this.getBackupPassword();
|
||||
const backupPassword = await this.getBackupPassword();
|
||||
if (!backupPassword) {
|
||||
throw new Error('Backup password not configured.');
|
||||
}
|
||||
@@ -811,7 +817,7 @@ export class BackupManager {
|
||||
throw new Error('MongoDB service not running');
|
||||
}
|
||||
|
||||
const connectionUri = credentials.connectionUri || credentials.MONGODB_URI;
|
||||
const connectionUri = credentials.connectionUri || credentials.connectionString || credentials.MONGODB_URI;
|
||||
if (!connectionUri) {
|
||||
throw new Error('MongoDB connection URI not found in credentials');
|
||||
}
|
||||
@@ -828,19 +834,8 @@ export class BackupManager {
|
||||
throw new Error(`mongodump failed: ${result.stderr}`);
|
||||
}
|
||||
|
||||
const container = await this.oneboxRef.docker.getContainerById(mongoService.containerId);
|
||||
if (!container) {
|
||||
throw new Error('MongoDB container not found');
|
||||
}
|
||||
|
||||
const copyResult = await this.oneboxRef.docker.execInContainer(mongoService.containerId, [
|
||||
'cat',
|
||||
archivePath,
|
||||
]);
|
||||
|
||||
const localPath = `${dataDir}/${resource.resourceName}.archive`;
|
||||
const encoder = new TextEncoder();
|
||||
await Deno.writeFile(localPath, encoder.encode(copyResult.stdout));
|
||||
await this.copyFromContainer(mongoService.containerId, archivePath, localPath);
|
||||
|
||||
await this.oneboxRef.docker.execInContainer(mongoService.containerId, ['rm', archivePath]);
|
||||
|
||||
@@ -860,47 +855,48 @@ export class BackupManager {
|
||||
const bucketDir = `${dataDir}/${resource.resourceName}`;
|
||||
await Deno.mkdir(bucketDir, { recursive: true });
|
||||
|
||||
const endpoint = credentials.endpoint || credentials.S3_ENDPOINT;
|
||||
const accessKey = credentials.accessKey || credentials.S3_ACCESS_KEY;
|
||||
const secretKey = credentials.secretKey || credentials.S3_SECRET_KEY;
|
||||
const bucket = credentials.bucket || credentials.S3_BUCKET;
|
||||
const s3Info = await this.getReachableS3ConnectionInfo(credentials, resource.platformServiceId);
|
||||
const s3Client = this.createS3Client(s3Info);
|
||||
let objectCount = 0;
|
||||
let continuationToken: string | undefined;
|
||||
|
||||
if (!endpoint || !accessKey || !secretKey || !bucket) {
|
||||
throw new Error('MinIO credentials incomplete');
|
||||
}
|
||||
do {
|
||||
const response = await s3Client.send(
|
||||
new plugins.awsS3.ListObjectsV2Command({
|
||||
Bucket: s3Info.bucket,
|
||||
ContinuationToken: continuationToken,
|
||||
}),
|
||||
);
|
||||
|
||||
const s3Client = new plugins.smartstorage.SmartStorage({
|
||||
endpoint,
|
||||
accessKey,
|
||||
secretKey,
|
||||
bucket,
|
||||
});
|
||||
for (const object of response.Contents || []) {
|
||||
const objectKey = object.Key;
|
||||
if (!objectKey) continue;
|
||||
|
||||
await s3Client.start();
|
||||
const objectResponse = await s3Client.send(
|
||||
new plugins.awsS3.GetObjectCommand({
|
||||
Bucket: s3Info.bucket,
|
||||
Key: objectKey,
|
||||
}),
|
||||
);
|
||||
|
||||
const objects = await s3Client.listObjects();
|
||||
if (!objectResponse.Body) continue;
|
||||
|
||||
for (const obj of objects) {
|
||||
const objectKey = obj.Key;
|
||||
if (!objectKey) continue;
|
||||
|
||||
const objectData = await s3Client.getObject(objectKey);
|
||||
if (objectData) {
|
||||
const objectPath = `${bucketDir}/${objectKey}`;
|
||||
const parentDir = plugins.path.dirname(objectPath);
|
||||
await Deno.mkdir(parentDir, { recursive: true });
|
||||
await Deno.writeFile(objectPath, objectData);
|
||||
await Deno.writeFile(objectPath, await objectResponse.Body.transformToByteArray());
|
||||
objectCount++;
|
||||
}
|
||||
}
|
||||
|
||||
await s3Client.stop();
|
||||
continuationToken = response.IsTruncated ? response.NextContinuationToken : undefined;
|
||||
} while (continuationToken);
|
||||
|
||||
await Deno.writeTextFile(
|
||||
`${bucketDir}/_metadata.json`,
|
||||
JSON.stringify({ bucket, objectCount: objects.length }, null, 2)
|
||||
JSON.stringify({ bucket: s3Info.bucket, objectCount }, null, 2)
|
||||
);
|
||||
|
||||
logger.success(`MinIO bucket exported: ${resource.resourceName} (${objects.length} objects)`);
|
||||
logger.success(`MinIO bucket exported: ${resource.resourceName} (${objectCount} objects)`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1128,6 +1124,36 @@ export class BackupManager {
|
||||
return imageName;
|
||||
}
|
||||
|
||||
private async copyFromContainer(
|
||||
containerId: string,
|
||||
containerPath: string,
|
||||
outputPath: string,
|
||||
): Promise<void> {
|
||||
await this.runDockerCp([`${containerId}:${containerPath}`, outputPath], 'docker cp from container failed');
|
||||
}
|
||||
|
||||
private async copyToContainer(
|
||||
inputPath: string,
|
||||
containerId: string,
|
||||
containerPath: string,
|
||||
): Promise<void> {
|
||||
await this.runDockerCp([inputPath, `${containerId}:${containerPath}`], 'docker cp to container failed');
|
||||
}
|
||||
|
||||
private async runDockerCp(args: string[], errorMessage: string): Promise<void> {
|
||||
const command = new Deno.Command('docker', {
|
||||
args: ['cp', ...args],
|
||||
stdout: 'piped',
|
||||
stderr: 'piped',
|
||||
});
|
||||
const result = await command.output();
|
||||
|
||||
if (!result.success) {
|
||||
const stderr = new TextDecoder().decode(result.stderr).trim();
|
||||
throw new Error(`${errorMessage}: ${stderr}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore platform resources for a service
|
||||
*/
|
||||
@@ -1232,22 +1258,14 @@ export class BackupManager {
|
||||
}
|
||||
|
||||
const archivePath = `${dataDir}/${backupResourceName}.archive`;
|
||||
const connectionUri = credentials.connectionUri || credentials.MONGODB_URI;
|
||||
const connectionUri = credentials.connectionUri || credentials.connectionString || credentials.MONGODB_URI;
|
||||
|
||||
if (!connectionUri) {
|
||||
throw new Error('MongoDB connection URI not found');
|
||||
}
|
||||
|
||||
const archiveData = await Deno.readFile(archivePath);
|
||||
const containerArchivePath = `/tmp/${resource.resourceName}.archive`;
|
||||
|
||||
const base64Data = btoa(String.fromCharCode(...archiveData));
|
||||
|
||||
await this.oneboxRef.docker.execInContainer(mongoService.containerId, [
|
||||
'bash',
|
||||
'-c',
|
||||
`echo '${base64Data}' | base64 -d > ${containerArchivePath}`,
|
||||
]);
|
||||
await this.copyToContainer(archivePath, mongoService.containerId, containerArchivePath);
|
||||
|
||||
const result = await this.oneboxRef.docker.execInContainer(mongoService.containerId, [
|
||||
'mongorestore',
|
||||
@@ -1279,40 +1297,26 @@ export class BackupManager {
|
||||
|
||||
const bucketDir = `${dataDir}/${backupResourceName}`;
|
||||
|
||||
const endpoint = credentials.endpoint || credentials.S3_ENDPOINT;
|
||||
const accessKey = credentials.accessKey || credentials.S3_ACCESS_KEY;
|
||||
const secretKey = credentials.secretKey || credentials.S3_SECRET_KEY;
|
||||
const bucket = credentials.bucket || credentials.S3_BUCKET;
|
||||
|
||||
if (!endpoint || !accessKey || !secretKey || !bucket) {
|
||||
throw new Error('MinIO credentials incomplete');
|
||||
}
|
||||
|
||||
const s3Client = new plugins.smartstorage.SmartStorage({
|
||||
endpoint,
|
||||
accessKey,
|
||||
secretKey,
|
||||
bucket,
|
||||
});
|
||||
|
||||
await s3Client.start();
|
||||
const s3Info = await this.getReachableS3ConnectionInfo(credentials, resource.platformServiceId);
|
||||
const s3Client = this.createS3Client(s3Info);
|
||||
|
||||
let uploadedCount = 0;
|
||||
|
||||
for await (const entry of Deno.readDir(bucketDir)) {
|
||||
if (entry.name === '_metadata.json') continue;
|
||||
for await (const filePath of this.walkFiles(bucketDir)) {
|
||||
if (plugins.path.basename(filePath) === '_metadata.json') continue;
|
||||
|
||||
const filePath = `${bucketDir}/${entry.name}`;
|
||||
|
||||
if (entry.isFile) {
|
||||
const fileData = await Deno.readFile(filePath);
|
||||
await s3Client.putObject(entry.name, fileData);
|
||||
uploadedCount++;
|
||||
}
|
||||
const fileData = await Deno.readFile(filePath);
|
||||
const objectKey = plugins.path.relative(bucketDir, filePath).replaceAll('\\', '/');
|
||||
await s3Client.send(
|
||||
new plugins.awsS3.PutObjectCommand({
|
||||
Bucket: s3Info.bucket,
|
||||
Key: objectKey,
|
||||
Body: fileData,
|
||||
}),
|
||||
);
|
||||
uploadedCount++;
|
||||
}
|
||||
|
||||
await s3Client.stop();
|
||||
|
||||
logger.success(`MinIO bucket imported: ${resource.resourceName} (${uploadedCount} objects)`);
|
||||
}
|
||||
|
||||
@@ -1585,7 +1589,7 @@ export class BackupManager {
|
||||
return await crypto.subtle.deriveKey(
|
||||
{
|
||||
name: 'PBKDF2',
|
||||
salt,
|
||||
salt: this.toArrayBuffer(salt),
|
||||
iterations: PBKDF2_ITERATIONS,
|
||||
hash: 'SHA-256',
|
||||
},
|
||||
@@ -1600,8 +1604,87 @@ export class BackupManager {
|
||||
* Compute SHA-256 checksum
|
||||
*/
|
||||
private async computeChecksum(data: Uint8Array): Promise<string> {
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', this.toArrayBuffer(data));
|
||||
const hashArray = new Uint8Array(hashBuffer);
|
||||
return 'sha256:' + Array.from(hashArray).map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
private getS3ConnectionInfo(credentials: Record<string, string>): IS3ConnectionInfo {
|
||||
const endpoint = credentials.endpoint || credentials.S3_ENDPOINT;
|
||||
const accessKey = credentials.accessKey || credentials.S3_ACCESS_KEY;
|
||||
const secretKey = credentials.secretKey || credentials.S3_SECRET_KEY;
|
||||
const bucket = credentials.bucket || credentials.S3_BUCKET;
|
||||
|
||||
if (!endpoint || !accessKey || !secretKey || !bucket) {
|
||||
throw new Error('MinIO credentials incomplete');
|
||||
}
|
||||
|
||||
return {
|
||||
endpoint,
|
||||
accessKey,
|
||||
secretKey,
|
||||
bucket,
|
||||
region: credentials.region || credentials.AWS_REGION || 'us-east-1',
|
||||
};
|
||||
}
|
||||
|
||||
private async getReachableS3ConnectionInfo(
|
||||
credentials: Record<string, string>,
|
||||
platformServiceId: number,
|
||||
): Promise<IS3ConnectionInfo> {
|
||||
const s3Info = this.getS3ConnectionInfo(credentials);
|
||||
|
||||
let endpointUrl: URL;
|
||||
try {
|
||||
endpointUrl = new URL(s3Info.endpoint);
|
||||
} catch {
|
||||
return s3Info;
|
||||
}
|
||||
|
||||
if (endpointUrl.hostname !== 'onebox-minio') {
|
||||
return s3Info;
|
||||
}
|
||||
|
||||
const platformService = this.oneboxRef.database.getPlatformServiceById(platformServiceId);
|
||||
const hostPort = platformService?.containerId
|
||||
? await this.oneboxRef.docker.getContainerHostPort(platformService.containerId, 9000)
|
||||
: null;
|
||||
if (!hostPort) {
|
||||
return s3Info;
|
||||
}
|
||||
|
||||
endpointUrl.hostname = '127.0.0.1';
|
||||
endpointUrl.port = String(hostPort);
|
||||
return {
|
||||
...s3Info,
|
||||
endpoint: endpointUrl.toString().replace(/\/$/, ''),
|
||||
};
|
||||
}
|
||||
|
||||
private createS3Client(s3Info: IS3ConnectionInfo) {
|
||||
return new plugins.awsS3.S3Client({
|
||||
endpoint: s3Info.endpoint,
|
||||
region: s3Info.region,
|
||||
forcePathStyle: true,
|
||||
credentials: {
|
||||
accessKeyId: s3Info.accessKey,
|
||||
secretAccessKey: s3Info.secretKey,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async *walkFiles(directory: string): AsyncGenerator<string> {
|
||||
for await (const entry of Deno.readDir(directory)) {
|
||||
const entryPath = plugins.path.join(directory, entry.name);
|
||||
if (entry.isDirectory) {
|
||||
yield* this.walkFiles(entryPath);
|
||||
} else if (entry.isFile) {
|
||||
yield entryPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private toArrayBuffer(data: Uint8Array): ArrayBuffer {
|
||||
return data.slice().buffer as ArrayBuffer;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,592 +0,0 @@
|
||||
/**
|
||||
* Caddy Manager for Onebox
|
||||
*
|
||||
* Manages Caddy as a Docker Swarm service instead of a host binary.
|
||||
* This allows Caddy to access services on the Docker overlay network.
|
||||
*/
|
||||
|
||||
import * as plugins from '../plugins.ts';
|
||||
import { logger } from '../logging.ts';
|
||||
import { getErrorMessage } from '../utils/error.ts';
|
||||
|
||||
const CADDY_SERVICE_NAME = 'onebox-caddy';
|
||||
const CADDY_IMAGE = 'caddy:2-alpine';
|
||||
const DOCKER_GATEWAY_IP = '172.17.0.1'; // Docker bridge gateway for container-to-host communication
|
||||
|
||||
export interface ICaddyRoute {
|
||||
domain: string;
|
||||
upstream: string; // e.g., "onebox-hello-world:80"
|
||||
}
|
||||
|
||||
export interface ICaddyCertificate {
|
||||
domain: string;
|
||||
certPem: string;
|
||||
keyPem: string;
|
||||
}
|
||||
|
||||
interface ICaddyLoggingConfig {
|
||||
logs: {
|
||||
[name: string]: {
|
||||
writer: {
|
||||
output: string;
|
||||
address?: string;
|
||||
dial_timeout?: string;
|
||||
soft_start?: boolean;
|
||||
};
|
||||
encoder?: { format: string };
|
||||
level?: string;
|
||||
include?: string[];
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface ICaddyConfig {
|
||||
admin: {
|
||||
listen: string;
|
||||
};
|
||||
logging?: ICaddyLoggingConfig;
|
||||
apps: {
|
||||
http: {
|
||||
servers: {
|
||||
[key: string]: {
|
||||
listen: string[];
|
||||
routes: ICaddyRouteConfig[];
|
||||
automatic_https?: {
|
||||
disable?: boolean;
|
||||
disable_redirects?: boolean;
|
||||
};
|
||||
logs?: {
|
||||
default_logger_name: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
tls?: {
|
||||
automation?: {
|
||||
policies: Array<{ issuers: never[] }>;
|
||||
};
|
||||
certificates?: {
|
||||
load_pem?: Array<{
|
||||
certificate: string;
|
||||
key: string;
|
||||
tags?: string[];
|
||||
}>;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface ICaddyRouteConfig {
|
||||
match: Array<{ host: string[] }>;
|
||||
handle: Array<{
|
||||
handler: string;
|
||||
upstreams?: Array<{ dial: string }>;
|
||||
routes?: ICaddyRouteConfig[];
|
||||
}>;
|
||||
terminal?: boolean;
|
||||
}
|
||||
|
||||
export class CaddyManager {
|
||||
private dockerClient: InstanceType<typeof plugins.docker.Docker> | null = null;
|
||||
private certsDir: string;
|
||||
private adminUrl: string;
|
||||
private httpPort: number;
|
||||
private httpsPort: number;
|
||||
private logReceiverPort: number;
|
||||
private loggingEnabled: boolean;
|
||||
private routes: Map<string, ICaddyRoute> = new Map();
|
||||
private certificates: Map<string, ICaddyCertificate> = new Map();
|
||||
private networkName = 'onebox-network';
|
||||
private serviceRunning = false;
|
||||
|
||||
constructor(options?: {
|
||||
certsDir?: string;
|
||||
adminPort?: number;
|
||||
httpPort?: number;
|
||||
httpsPort?: number;
|
||||
logReceiverPort?: number;
|
||||
loggingEnabled?: boolean;
|
||||
}) {
|
||||
this.certsDir = options?.certsDir || './.nogit/certs';
|
||||
this.adminUrl = `http://localhost:${options?.adminPort || 2019}`;
|
||||
this.httpPort = options?.httpPort || 8080;
|
||||
this.httpsPort = options?.httpsPort || 8443;
|
||||
this.logReceiverPort = options?.logReceiverPort || 9999;
|
||||
this.loggingEnabled = options?.loggingEnabled ?? true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize Docker client for Caddy service management
|
||||
*/
|
||||
private async ensureDockerClient(): Promise<void> {
|
||||
if (!this.dockerClient) {
|
||||
this.dockerClient = new plugins.docker.Docker({
|
||||
socketPath: 'unix:///var/run/docker.sock',
|
||||
});
|
||||
await this.dockerClient.start();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update listening ports (must call reloadConfig after if running)
|
||||
*/
|
||||
setPorts(httpPort: number, httpsPort: number): void {
|
||||
this.httpPort = httpPort;
|
||||
this.httpsPort = httpsPort;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start Caddy as a Docker Swarm service
|
||||
*/
|
||||
async start(): Promise<void> {
|
||||
if (this.serviceRunning) {
|
||||
logger.warn('Caddy service is already running');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.ensureDockerClient();
|
||||
|
||||
// Create certs directory for backup/persistence
|
||||
await Deno.mkdir(this.certsDir, { recursive: true });
|
||||
|
||||
logger.info('Starting Caddy Docker service...');
|
||||
|
||||
// Check if service already exists
|
||||
const existingService = await this.getExistingService();
|
||||
if (existingService) {
|
||||
logger.info('Caddy service exists, removing old service...');
|
||||
await this.removeService();
|
||||
// Wait for service to be removed
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
}
|
||||
|
||||
// Get network ID
|
||||
const networkId = await this.getNetworkId();
|
||||
|
||||
// Create Caddy Docker service
|
||||
const response = await this.dockerClient!.request('POST', '/services/create', {
|
||||
Name: CADDY_SERVICE_NAME,
|
||||
Labels: {
|
||||
'managed-by': 'onebox',
|
||||
'onebox-type': 'caddy',
|
||||
},
|
||||
TaskTemplate: {
|
||||
ContainerSpec: {
|
||||
Image: CADDY_IMAGE,
|
||||
// Start Caddy with admin listening on all interfaces so we can reach it from host
|
||||
// Write minimal config to /tmp and start Caddy with that config
|
||||
Command: ['sh', '-c', 'printf \'{"admin":{"listen":"0.0.0.0:2019"}}\' > /tmp/caddy.json && caddy run --config /tmp/caddy.json'],
|
||||
},
|
||||
Networks: [
|
||||
{
|
||||
Target: networkId,
|
||||
},
|
||||
],
|
||||
RestartPolicy: {
|
||||
Condition: 'any',
|
||||
MaxAttempts: 0,
|
||||
},
|
||||
},
|
||||
Mode: {
|
||||
Replicated: {
|
||||
Replicas: 1,
|
||||
},
|
||||
},
|
||||
EndpointSpec: {
|
||||
Ports: [
|
||||
{
|
||||
Protocol: 'tcp',
|
||||
TargetPort: 80,
|
||||
PublishedPort: this.httpPort,
|
||||
PublishMode: 'host',
|
||||
},
|
||||
{
|
||||
Protocol: 'tcp',
|
||||
TargetPort: 443,
|
||||
PublishedPort: this.httpsPort,
|
||||
PublishMode: 'host',
|
||||
},
|
||||
{
|
||||
Protocol: 'tcp',
|
||||
TargetPort: 2019,
|
||||
PublishedPort: 2019,
|
||||
PublishMode: 'host',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
if (response.statusCode >= 300) {
|
||||
throw new Error(`Failed to create Caddy service: HTTP ${response.statusCode} - ${JSON.stringify(response.body)}`);
|
||||
}
|
||||
|
||||
logger.info(`Caddy service created: ${response.body.ID}`);
|
||||
|
||||
// Wait for Admin API to be ready
|
||||
await this.waitForReady();
|
||||
|
||||
this.serviceRunning = true;
|
||||
|
||||
// Now configure via Admin API with current routes and certificates
|
||||
await this.reloadConfig();
|
||||
|
||||
logger.success(`Caddy started (HTTP: ${this.httpPort}, HTTPS: ${this.httpsPort}, Admin: ${this.adminUrl})`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to start Caddy: ${getErrorMessage(error)}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get existing Caddy service if any
|
||||
*/
|
||||
private async getExistingService(): Promise<any | null> {
|
||||
try {
|
||||
const response = await this.dockerClient!.request('GET', `/services/${CADDY_SERVICE_NAME}`, {});
|
||||
if (response.statusCode === 200) {
|
||||
return response.body;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the Caddy service
|
||||
*/
|
||||
private async removeService(): Promise<void> {
|
||||
try {
|
||||
await this.dockerClient!.request('DELETE', `/services/${CADDY_SERVICE_NAME}`, {});
|
||||
} catch {
|
||||
// Service may not exist
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get network ID by name
|
||||
*/
|
||||
private async getNetworkId(): Promise<string> {
|
||||
const networks = await this.dockerClient!.listNetworks();
|
||||
const network = networks.find((n: any) => n.Name === this.networkName);
|
||||
if (!network) {
|
||||
throw new Error(`Network not found: ${this.networkName}`);
|
||||
}
|
||||
return network.Id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for Caddy Admin API to be ready
|
||||
*/
|
||||
private async waitForReady(maxAttempts = 60, intervalMs = 500): Promise<void> {
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
try {
|
||||
const response = await fetch(`${this.adminUrl}/config/`);
|
||||
if (response.ok) {
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Not ready yet
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
||||
}
|
||||
throw new Error('Caddy service failed to start within timeout');
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop Caddy Docker service
|
||||
*/
|
||||
async stop(): Promise<void> {
|
||||
if (!this.serviceRunning && !(await this.getExistingService())) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.ensureDockerClient();
|
||||
|
||||
logger.info('Stopping Caddy service...');
|
||||
|
||||
await this.removeService();
|
||||
|
||||
this.serviceRunning = false;
|
||||
logger.info('Caddy service stopped');
|
||||
} catch (error) {
|
||||
logger.error(`Failed to stop Caddy: ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Caddy Admin API is healthy
|
||||
*/
|
||||
async isHealthy(): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${this.adminUrl}/config/`);
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Caddy service is running
|
||||
*/
|
||||
async isRunning(): Promise<boolean> {
|
||||
try {
|
||||
await this.ensureDockerClient();
|
||||
const service = await this.getExistingService();
|
||||
if (!service) return false;
|
||||
|
||||
// Check if service has running tasks
|
||||
const tasksResponse = await this.dockerClient!.request(
|
||||
'GET',
|
||||
`/tasks?filters=${encodeURIComponent(JSON.stringify({ service: [CADDY_SERVICE_NAME] }))}`,
|
||||
{}
|
||||
);
|
||||
|
||||
if (tasksResponse.statusCode !== 200) return false;
|
||||
|
||||
const tasks = tasksResponse.body;
|
||||
return tasks.some((task: any) => task.Status?.State === 'running');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Caddy JSON configuration from current routes and certificates
|
||||
*/
|
||||
private buildConfig(): ICaddyConfig {
|
||||
const routes: ICaddyRouteConfig[] = [];
|
||||
|
||||
// Add routes
|
||||
for (const [domain, route] of this.routes) {
|
||||
routes.push({
|
||||
match: [{ host: [domain] }],
|
||||
handle: [
|
||||
{
|
||||
handler: 'reverse_proxy',
|
||||
upstreams: [{ dial: route.upstream }],
|
||||
},
|
||||
],
|
||||
terminal: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Build certificate load_pem entries (inline PEM content)
|
||||
const loadPem: Array<{ certificate: string; key: string; tags?: string[] }> = [];
|
||||
for (const [domain, cert] of this.certificates) {
|
||||
loadPem.push({
|
||||
certificate: cert.certPem,
|
||||
key: cert.keyPem,
|
||||
tags: [domain],
|
||||
});
|
||||
}
|
||||
|
||||
const config: ICaddyConfig = {
|
||||
admin: {
|
||||
listen: '0.0.0.0:2019', // Listen on all interfaces inside container
|
||||
},
|
||||
apps: {
|
||||
http: {
|
||||
servers: {
|
||||
main: {
|
||||
listen: [':80', ':443'],
|
||||
routes,
|
||||
// Disable automatic HTTPS to prevent Caddy from trying to obtain certs
|
||||
automatic_https: {
|
||||
disable: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Add access logging configuration if enabled
|
||||
if (this.loggingEnabled) {
|
||||
config.logging = {
|
||||
logs: {
|
||||
access: {
|
||||
writer: {
|
||||
output: 'net',
|
||||
// Use Docker bridge gateway IP to reach log receiver on host
|
||||
address: `tcp/${DOCKER_GATEWAY_IP}:${this.logReceiverPort}`,
|
||||
dial_timeout: '5s',
|
||||
soft_start: true, // Continue even if log receiver is down
|
||||
},
|
||||
encoder: { format: 'json' },
|
||||
level: 'INFO',
|
||||
include: ['http.log.access'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Associate server with access logger
|
||||
config.apps.http.servers.main.logs = {
|
||||
default_logger_name: 'access',
|
||||
};
|
||||
}
|
||||
|
||||
// Add TLS config if we have certificates
|
||||
if (loadPem.length > 0) {
|
||||
config.apps.tls = {
|
||||
automation: {
|
||||
// Disable automatic HTTPS - we manage certs ourselves
|
||||
policies: [{ issuers: [] }],
|
||||
},
|
||||
certificates: {
|
||||
load_pem: loadPem,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reload Caddy configuration via Admin API
|
||||
*/
|
||||
async reloadConfig(): Promise<void> {
|
||||
const isRunning = await this.isRunning();
|
||||
if (!isRunning) {
|
||||
logger.warn('Caddy not running, cannot reload config');
|
||||
return;
|
||||
}
|
||||
|
||||
const config = this.buildConfig();
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.adminUrl}/load`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(config),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`Failed to reload Caddy config: ${response.status} ${text}`);
|
||||
}
|
||||
|
||||
logger.debug('Caddy configuration reloaded');
|
||||
} catch (error) {
|
||||
logger.error(`Failed to reload Caddy config: ${getErrorMessage(error)}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add or update a route
|
||||
*/
|
||||
async addRoute(domain: string, upstream: string): Promise<void> {
|
||||
this.routes.set(domain, { domain, upstream });
|
||||
|
||||
if (await this.isRunning()) {
|
||||
await this.reloadConfig();
|
||||
}
|
||||
|
||||
logger.success(`Added Caddy route: ${domain} -> ${upstream}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a route
|
||||
*/
|
||||
async removeRoute(domain: string): Promise<void> {
|
||||
if (this.routes.delete(domain)) {
|
||||
if (await this.isRunning()) {
|
||||
await this.reloadConfig();
|
||||
}
|
||||
logger.success(`Removed Caddy route: ${domain}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add or update a TLS certificate
|
||||
* Stores PEM content in memory for Admin API, also writes to disk for backup
|
||||
*/
|
||||
async addCertificate(domain: string, certPem: string, keyPem: string): Promise<void> {
|
||||
// Store PEM content in memory for buildConfig()
|
||||
this.certificates.set(domain, {
|
||||
domain,
|
||||
certPem,
|
||||
keyPem,
|
||||
});
|
||||
|
||||
// Also write to disk for backup/persistence
|
||||
try {
|
||||
await Deno.mkdir(this.certsDir, { recursive: true });
|
||||
await Deno.writeTextFile(`${this.certsDir}/${domain}.crt`, certPem);
|
||||
await Deno.writeTextFile(`${this.certsDir}/${domain}.key`, keyPem);
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to write certificate backup for ${domain}: ${getErrorMessage(error)}`);
|
||||
}
|
||||
|
||||
if (await this.isRunning()) {
|
||||
await this.reloadConfig();
|
||||
}
|
||||
|
||||
logger.success(`Added TLS certificate for ${domain}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a TLS certificate
|
||||
*/
|
||||
async removeCertificate(domain: string): Promise<void> {
|
||||
if (this.certificates.delete(domain)) {
|
||||
// Remove backup files
|
||||
try {
|
||||
await Deno.remove(`${this.certsDir}/${domain}.crt`);
|
||||
await Deno.remove(`${this.certsDir}/${domain}.key`);
|
||||
} catch {
|
||||
// Files may not exist
|
||||
}
|
||||
|
||||
if (await this.isRunning()) {
|
||||
await this.reloadConfig();
|
||||
}
|
||||
|
||||
logger.success(`Removed TLS certificate for ${domain}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all current routes
|
||||
*/
|
||||
getRoutes(): ICaddyRoute[] {
|
||||
return Array.from(this.routes.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all current certificates
|
||||
*/
|
||||
getCertificates(): ICaddyCertificate[] {
|
||||
return Array.from(this.certificates.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all routes and certificates (useful for reload from database)
|
||||
*/
|
||||
clear(): void {
|
||||
this.routes.clear();
|
||||
this.certificates.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status
|
||||
*/
|
||||
getStatus(): {
|
||||
running: boolean;
|
||||
httpPort: number;
|
||||
httpsPort: number;
|
||||
routes: number;
|
||||
certificates: number;
|
||||
} {
|
||||
return {
|
||||
running: this.serviceRunning,
|
||||
httpPort: this.httpPort,
|
||||
httpsPort: this.httpsPort,
|
||||
routes: this.routes.size,
|
||||
certificates: this.certificates.size,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,7 @@ export class CloudflareDomainSync {
|
||||
*/
|
||||
async init(): Promise<void> {
|
||||
try {
|
||||
const apiKey = this.database.getSetting('cloudflareAPIKey');
|
||||
const apiKey = await this.database.getSecretSetting('cloudflareToken');
|
||||
|
||||
if (!apiKey) {
|
||||
logger.warn('Cloudflare API key not configured. Domain sync will be limited.');
|
||||
|
||||
+2
-2
@@ -27,12 +27,12 @@ export class OneboxDnsManager {
|
||||
async init(): Promise<void> {
|
||||
try {
|
||||
// Get Cloudflare credentials from settings
|
||||
const apiKey = this.database.getSetting('cloudflareAPIKey');
|
||||
const apiKey = await this.database.getSecretSetting('cloudflareToken');
|
||||
const serverIP = this.database.getSetting('serverIP');
|
||||
|
||||
if (!apiKey) {
|
||||
logger.warn('Cloudflare credentials not configured. DNS management will be disabled.');
|
||||
logger.info('Configure with: onebox config set cloudflareAPIKey <key>');
|
||||
logger.info('Configure with: onebox config set cloudflareToken <key>');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
+20
-2
@@ -36,6 +36,23 @@ export class OneboxDockerManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Release resources held by the Docker API client.
|
||||
*/
|
||||
async stop(): Promise<void> {
|
||||
if (!this.dockerClient) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.dockerClient.stop();
|
||||
} catch (error) {
|
||||
logger.error(`Failed to stop Docker client: ${getErrorMessage(error)}`);
|
||||
} finally {
|
||||
this.dockerClient = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure onebox network exists
|
||||
*/
|
||||
@@ -935,8 +952,9 @@ export class OneboxDockerManager {
|
||||
logger.info(`Pulling image for platform service: ${options.image}`);
|
||||
await this.pullImage(options.image);
|
||||
|
||||
// Check if container already exists
|
||||
const existingContainers = await this.dockerClient!.listContainers();
|
||||
// Check running and stopped containers; stopped platform containers still reserve names.
|
||||
const existingContainersResponse = await this.dockerClient!.request('GET', '/containers/json?all=true', {});
|
||||
const existingContainers = Array.isArray(existingContainersResponse.body) ? existingContainersResponse.body : [];
|
||||
const existing = existingContainers.find((c: any) =>
|
||||
c.Names?.some((n: string) => n === `/${options.name}` || n === options.name)
|
||||
);
|
||||
|
||||
@@ -97,7 +97,11 @@ export class CredentialEncryption {
|
||||
*/
|
||||
async encrypt(data: Record<string, string>): Promise<string> {
|
||||
if (!this.key) {
|
||||
throw new Error('Encryption not initialized. Call init() first.');
|
||||
await this.init();
|
||||
}
|
||||
const key = this.key;
|
||||
if (!key) {
|
||||
throw new Error('Encryption key initialization failed.');
|
||||
}
|
||||
|
||||
const iv = crypto.getRandomValues(new Uint8Array(this.ivLength));
|
||||
@@ -105,7 +109,7 @@ export class CredentialEncryption {
|
||||
|
||||
const ciphertext = await crypto.subtle.encrypt(
|
||||
{ name: this.algorithm, iv },
|
||||
this.key,
|
||||
key,
|
||||
encoded
|
||||
);
|
||||
|
||||
@@ -120,9 +124,15 @@ export class CredentialEncryption {
|
||||
/**
|
||||
* Decrypt a base64 string back to credentials object
|
||||
*/
|
||||
async decrypt(encrypted: string): Promise<Record<string, string>> {
|
||||
async decrypt<T extends Record<string, string> = Record<string, string>>(
|
||||
encrypted: string,
|
||||
): Promise<T> {
|
||||
if (!this.key) {
|
||||
throw new Error('Encryption not initialized. Call init() first.');
|
||||
await this.init();
|
||||
}
|
||||
const key = this.key;
|
||||
if (!key) {
|
||||
throw new Error('Encryption key initialization failed.');
|
||||
}
|
||||
|
||||
const combined = this.base64ToBytes(encrypted);
|
||||
@@ -133,12 +143,12 @@ export class CredentialEncryption {
|
||||
|
||||
const decrypted = await crypto.subtle.decrypt(
|
||||
{ name: this.algorithm, iv },
|
||||
this.key,
|
||||
key,
|
||||
ciphertext
|
||||
);
|
||||
|
||||
const decoded = new TextDecoder().decode(decrypted);
|
||||
return JSON.parse(decoded);
|
||||
return JSON.parse(decoded) as T;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
import * as plugins from '../plugins.ts';
|
||||
import { logger } from '../logging.ts';
|
||||
import { getErrorMessage } from '../utils/error.ts';
|
||||
import { OneboxDatabase } from './database.ts';
|
||||
import type { IDomain, IService } from '../types.ts';
|
||||
|
||||
type TWorkHosterType = 'onebox';
|
||||
|
||||
interface IExternalGatewayConfig {
|
||||
url: string;
|
||||
apiToken: string;
|
||||
workHosterId: string;
|
||||
targetHost?: string;
|
||||
targetPort?: number;
|
||||
}
|
||||
|
||||
interface IWorkHosterDomain {
|
||||
name: string;
|
||||
capabilities?: {
|
||||
canCreateSubdomains: boolean;
|
||||
canManageDnsRecords: boolean;
|
||||
canIssueCertificates: boolean;
|
||||
canHostEmail: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
interface IWorkAppRouteOwnership {
|
||||
workHosterType: TWorkHosterType;
|
||||
workHosterId: string;
|
||||
workAppId: string;
|
||||
hostname: string;
|
||||
}
|
||||
|
||||
interface IWorkAppRouteSyncResult {
|
||||
success: boolean;
|
||||
action?: 'created' | 'updated' | 'deleted' | 'unchanged';
|
||||
routeId?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
interface IDcRouterCertificateExport {
|
||||
success: boolean;
|
||||
cert?: {
|
||||
id: string;
|
||||
domainName: string;
|
||||
created: number;
|
||||
validUntil: number;
|
||||
privateKey: string;
|
||||
publicKey: string;
|
||||
csr: string;
|
||||
};
|
||||
message?: string;
|
||||
}
|
||||
|
||||
interface IDcRouterRouteConfig {
|
||||
name: string;
|
||||
match: {
|
||||
ports: number[];
|
||||
domains: string[];
|
||||
};
|
||||
action: {
|
||||
type: 'forward';
|
||||
targets: Array<{ host: string; port: number }>;
|
||||
tls: {
|
||||
mode: 'terminate';
|
||||
certificate: 'auto';
|
||||
};
|
||||
websocket: {
|
||||
enabled: boolean;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export class ExternalGatewayManager {
|
||||
private database: OneboxDatabase;
|
||||
|
||||
constructor(private oneboxRef: any) {
|
||||
this.database = oneboxRef.database;
|
||||
}
|
||||
|
||||
public async init(): Promise<void> {
|
||||
if (!(await this.isConfigured())) {
|
||||
logger.info('External dcrouter gateway not configured');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.syncDomains();
|
||||
}
|
||||
|
||||
public async isConfigured(): Promise<boolean> {
|
||||
const config = await this.getConfig({ requireTarget: false });
|
||||
return Boolean(config);
|
||||
}
|
||||
|
||||
public async syncDomains(): Promise<IDomain[]> {
|
||||
const config = await this.requireConfig({ requireTarget: false });
|
||||
const response = await this.fireDcRouterRequest<{ domains: IWorkHosterDomain[] }>(
|
||||
'getWorkHosterDomains',
|
||||
{},
|
||||
config,
|
||||
);
|
||||
|
||||
const activeDomainNames = new Set<string>();
|
||||
const now = Date.now();
|
||||
|
||||
for (const gatewayDomain of response.domains) {
|
||||
const domainName = gatewayDomain.name.trim().toLowerCase();
|
||||
if (!domainName) continue;
|
||||
|
||||
activeDomainNames.add(domainName);
|
||||
const existingDomain = this.database.getDomainByName(domainName);
|
||||
const defaultWildcard = gatewayDomain.capabilities?.canIssueCertificates !== false;
|
||||
|
||||
if (existingDomain) {
|
||||
this.database.updateDomain(existingDomain.id!, {
|
||||
dnsProvider: 'dcrouter',
|
||||
isObsolete: false,
|
||||
defaultWildcard,
|
||||
updatedAt: now,
|
||||
});
|
||||
} else {
|
||||
this.database.createDomain({
|
||||
domain: domainName,
|
||||
dnsProvider: 'dcrouter',
|
||||
isObsolete: false,
|
||||
defaultWildcard,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const domain of this.database.getDomainsByProvider('dcrouter')) {
|
||||
if (!activeDomainNames.has(domain.domain)) {
|
||||
this.database.updateDomain(domain.id!, {
|
||||
isObsolete: true,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
logger.success(`Synced ${activeDomainNames.size} domain(s) from external dcrouter gateway`);
|
||||
return this.database.getDomainsByProvider('dcrouter');
|
||||
}
|
||||
|
||||
public async syncServiceRoute(service: IService): Promise<void> {
|
||||
if (!service.domain) return;
|
||||
|
||||
const config = await this.getConfig({ requireTarget: true });
|
||||
if (!config) return;
|
||||
|
||||
const result = await this.fireDcRouterRequest<IWorkAppRouteSyncResult>(
|
||||
'syncWorkAppRoute',
|
||||
{
|
||||
ownership: this.buildOwnership(service, service.domain, config),
|
||||
route: this.buildRoute(service, config),
|
||||
enabled: service.status === 'running',
|
||||
},
|
||||
config,
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.message || `dcrouter route sync failed for ${service.domain}`);
|
||||
}
|
||||
|
||||
logger.success(`External gateway route ${result.action || 'synced'} for ${service.domain}`);
|
||||
await this.importCertificateForDomain(service.domain).catch((error) => {
|
||||
logger.debug(`External gateway certificate import skipped for ${service.domain}: ${getErrorMessage(error)}`);
|
||||
});
|
||||
}
|
||||
|
||||
public async deleteServiceRoute(service: Pick<IService, 'id' | 'name' | 'domain'>): Promise<void> {
|
||||
if (!service.domain) return;
|
||||
|
||||
const config = await this.getConfig({ requireTarget: false });
|
||||
if (!config) return;
|
||||
|
||||
const result = await this.fireDcRouterRequest<IWorkAppRouteSyncResult>(
|
||||
'syncWorkAppRoute',
|
||||
{
|
||||
ownership: this.buildOwnership(service, service.domain, config),
|
||||
delete: true,
|
||||
},
|
||||
config,
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.message || `dcrouter route delete failed for ${service.domain}`);
|
||||
}
|
||||
|
||||
logger.info(`External gateway route ${result.action || 'deleted'} for ${service.domain}`);
|
||||
}
|
||||
|
||||
public async importCertificateForDomain(domain: string): Promise<boolean> {
|
||||
const config = await this.getConfig({ requireTarget: false });
|
||||
if (!config) return false;
|
||||
|
||||
const result = await this.fireDcRouterRequest<IDcRouterCertificateExport>(
|
||||
'exportCertificate',
|
||||
{ domain },
|
||||
config,
|
||||
);
|
||||
|
||||
if (!result.success || !result.cert) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const existingCertificate = this.database.getSSLCertificate(domain);
|
||||
if (existingCertificate) {
|
||||
this.database.updateSSLCertificate(domain, {
|
||||
certPem: result.cert.publicKey,
|
||||
keyPem: result.cert.privateKey,
|
||||
fullchainPem: result.cert.publicKey,
|
||||
expiryDate: result.cert.validUntil,
|
||||
updatedAt: now,
|
||||
});
|
||||
} else {
|
||||
await this.database.createSSLCertificate({
|
||||
domain,
|
||||
certPem: result.cert.publicKey,
|
||||
keyPem: result.cert.privateKey,
|
||||
fullchainPem: result.cert.publicKey,
|
||||
expiryDate: result.cert.validUntil,
|
||||
issuer: 'dcrouter',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
await this.oneboxRef.reverseProxy.reloadCertificates();
|
||||
logger.success(`Imported external gateway certificate for ${domain}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async getConfig(options: { requireTarget?: boolean } = {}): Promise<IExternalGatewayConfig | null> {
|
||||
const url = this.normalizeUrl(this.database.getSetting('dcrouterGatewayUrl') || '');
|
||||
const apiToken = await this.database.getSecretSetting('dcrouterGatewayApiToken');
|
||||
if (!url || !apiToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const config: IExternalGatewayConfig = {
|
||||
url,
|
||||
apiToken,
|
||||
workHosterId: this.ensureWorkHosterId(),
|
||||
};
|
||||
|
||||
if (options.requireTarget !== false) {
|
||||
config.targetHost = this.database.getSetting('dcrouterTargetHost')
|
||||
|| this.database.getSetting('serverIP')
|
||||
|| undefined;
|
||||
const targetPort = this.parsePort(
|
||||
this.database.getSetting('dcrouterTargetPort')
|
||||
|| this.database.getSetting('httpPort')
|
||||
|| '80',
|
||||
);
|
||||
config.targetPort = targetPort;
|
||||
|
||||
if (!config.targetHost) {
|
||||
throw new Error('dcrouterTargetHost or serverIP must be configured for external gateway route sync');
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
private async requireConfig(options: { requireTarget?: boolean } = {}): Promise<IExternalGatewayConfig> {
|
||||
const config = await this.getConfig(options);
|
||||
if (!config) {
|
||||
throw new Error('External dcrouter gateway is not configured');
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
private normalizeUrl(url: string): string {
|
||||
const trimmedUrl = url.trim().replace(/\/+$/, '');
|
||||
if (!trimmedUrl) return '';
|
||||
if (/^https?:\/\//.test(trimmedUrl)) return trimmedUrl;
|
||||
return `https://${trimmedUrl}`;
|
||||
}
|
||||
|
||||
private parsePort(portValue: string): number {
|
||||
const port = Number(portValue);
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
throw new Error(`Invalid dcrouter target port: ${portValue}`);
|
||||
}
|
||||
return port;
|
||||
}
|
||||
|
||||
private ensureWorkHosterId(): string {
|
||||
let workHosterId = this.database.getSetting('dcrouterWorkHosterId');
|
||||
if (!workHosterId) {
|
||||
workHosterId = crypto.randomUUID();
|
||||
this.database.setSetting('dcrouterWorkHosterId', workHosterId);
|
||||
}
|
||||
return workHosterId;
|
||||
}
|
||||
|
||||
private buildOwnership(
|
||||
service: Pick<IService, 'id' | 'name'>,
|
||||
hostname: string,
|
||||
config: IExternalGatewayConfig,
|
||||
): IWorkAppRouteOwnership {
|
||||
return {
|
||||
workHosterType: 'onebox',
|
||||
workHosterId: config.workHosterId,
|
||||
workAppId: service.name || `service-${service.id}`,
|
||||
hostname,
|
||||
};
|
||||
}
|
||||
|
||||
private buildRoute(service: IService, config: IExternalGatewayConfig): IDcRouterRouteConfig {
|
||||
return {
|
||||
name: this.routeName(service.domain!),
|
||||
match: {
|
||||
ports: [443],
|
||||
domains: [service.domain!],
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: config.targetHost!, port: config.targetPort! }],
|
||||
tls: {
|
||||
mode: 'terminate',
|
||||
certificate: 'auto',
|
||||
},
|
||||
websocket: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private routeName(domain: string): string {
|
||||
return `onebox-${domain.replace(/[^a-zA-Z0-9]+/g, '-').replace(/^-|-$/g, '')}`;
|
||||
}
|
||||
|
||||
private async fireDcRouterRequest<TResponse>(
|
||||
method: string,
|
||||
requestData: Record<string, unknown>,
|
||||
config: IExternalGatewayConfig,
|
||||
): Promise<TResponse> {
|
||||
const typedRequest = new plugins.typedrequest.TypedRequest<any>(
|
||||
`${config.url}/typedrequest`,
|
||||
method,
|
||||
);
|
||||
return await typedRequest.fire({
|
||||
...requestData,
|
||||
apiToken: config.apiToken,
|
||||
}) as TResponse;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+46
-22
@@ -6,6 +6,7 @@
|
||||
|
||||
import { logger } from '../logging.ts';
|
||||
import { getErrorMessage } from '../utils/error.ts';
|
||||
import { hashPassword } from '../utils/auth.ts';
|
||||
import { OneboxDatabase } from './database.ts';
|
||||
import { OneboxDockerManager } from './docker.ts';
|
||||
import { OneboxServicesManager } from './services.ts';
|
||||
@@ -15,15 +16,15 @@ import { OneboxDnsManager } from './dns.ts';
|
||||
import { OneboxSslManager } from './ssl.ts';
|
||||
import { OneboxDaemon } from './daemon.ts';
|
||||
import { OneboxSystemd } from './systemd.ts';
|
||||
import { OneboxHttpServer } from './httpserver.ts';
|
||||
import { CloudflareDomainSync } from './cloudflare-sync.ts';
|
||||
import { CertRequirementManager } from './cert-requirement-manager.ts';
|
||||
import { RegistryManager } from './registry.ts';
|
||||
import { PlatformServicesManager } from './platform-services/index.ts';
|
||||
import { AppStoreManager } from './appstore.ts';
|
||||
import { CaddyLogReceiver } from './caddy-log-receiver.ts';
|
||||
import { ProxyLogReceiver } from './proxy-log-receiver.ts';
|
||||
import { BackupManager } from './backup-manager.ts';
|
||||
import { BackupScheduler } from './backup-scheduler.ts';
|
||||
import { ExternalGatewayManager } from './external-gateway.ts';
|
||||
import { OpsServer } from '../opsserver/index.ts';
|
||||
|
||||
export class Onebox {
|
||||
@@ -36,15 +37,15 @@ export class Onebox {
|
||||
public ssl: OneboxSslManager;
|
||||
public daemon: OneboxDaemon;
|
||||
public systemd: OneboxSystemd;
|
||||
public httpServer: OneboxHttpServer;
|
||||
public cloudflareDomainSync: CloudflareDomainSync;
|
||||
public certRequirementManager: CertRequirementManager;
|
||||
public registry: RegistryManager;
|
||||
public platformServices: PlatformServicesManager;
|
||||
public appStore: AppStoreManager;
|
||||
public caddyLogReceiver: CaddyLogReceiver;
|
||||
public proxyLogReceiver: ProxyLogReceiver;
|
||||
public backupManager: BackupManager;
|
||||
public backupScheduler: BackupScheduler;
|
||||
public externalGateway: ExternalGatewayManager;
|
||||
public opsServer: OpsServer;
|
||||
|
||||
private initialized = false;
|
||||
@@ -62,11 +63,10 @@ export class Onebox {
|
||||
this.ssl = new OneboxSslManager(this);
|
||||
this.daemon = new OneboxDaemon(this);
|
||||
this.systemd = new OneboxSystemd();
|
||||
this.httpServer = new OneboxHttpServer(this);
|
||||
this.registry = new RegistryManager({
|
||||
dataDir: './.nogit/registry-data',
|
||||
port: 4000,
|
||||
baseUrl: 'localhost:5000',
|
||||
baseUrl: 'localhost:3000',
|
||||
});
|
||||
|
||||
// Initialize domain management
|
||||
@@ -79,8 +79,8 @@ export class Onebox {
|
||||
// Initialize App Store manager
|
||||
this.appStore = new AppStoreManager(this);
|
||||
|
||||
// Initialize Caddy log receiver
|
||||
this.caddyLogReceiver = new CaddyLogReceiver(9999);
|
||||
// Initialize reverse proxy log receiver
|
||||
this.proxyLogReceiver = new ProxyLogReceiver(9999);
|
||||
|
||||
// Initialize Backup manager
|
||||
this.backupManager = new BackupManager(this);
|
||||
@@ -88,6 +88,9 @@ export class Onebox {
|
||||
// Initialize Backup scheduler
|
||||
this.backupScheduler = new BackupScheduler(this);
|
||||
|
||||
// Initialize optional dcrouter edge gateway integration
|
||||
this.externalGateway = new ExternalGatewayManager(this);
|
||||
|
||||
// Initialize OpsServer (TypedRequest-based server)
|
||||
this.opsServer = new OpsServer(this);
|
||||
}
|
||||
@@ -108,11 +111,11 @@ export class Onebox {
|
||||
// Initialize Docker
|
||||
await this.docker.init();
|
||||
|
||||
// Start Caddy log receiver BEFORE reverse proxy (so Caddy can connect to it)
|
||||
// Start proxy log receiver before reverse proxy startup.
|
||||
try {
|
||||
await this.caddyLogReceiver.start();
|
||||
await this.proxyLogReceiver.start();
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to start Caddy log receiver: ${getErrorMessage(error)}`);
|
||||
logger.warn(`Failed to start proxy log receiver: ${getErrorMessage(error)}`);
|
||||
}
|
||||
|
||||
// Initialize Reverse Proxy
|
||||
@@ -162,6 +165,14 @@ export class Onebox {
|
||||
logger.warn('Cloudflare domain sync initialization failed - domain sync will be limited');
|
||||
}
|
||||
|
||||
// Initialize external dcrouter gateway (non-critical)
|
||||
try {
|
||||
await this.externalGateway.init();
|
||||
} catch (error) {
|
||||
logger.warn('External dcrouter gateway initialization failed - edge sync will be disabled');
|
||||
logger.warn(`Error: ${getErrorMessage(error)}`);
|
||||
}
|
||||
|
||||
// Initialize Onebox Registry (non-critical)
|
||||
try {
|
||||
await this.registry.init();
|
||||
@@ -221,24 +232,31 @@ export class Onebox {
|
||||
*/
|
||||
private async ensureDefaultUser(): Promise<void> {
|
||||
try {
|
||||
const adminUser = this.database.getUserByUsername('admin');
|
||||
const adminUsername = Deno.env.get('ONEBOX_ADMIN_USERNAME') || 'admin';
|
||||
const adminUser = this.database.getUserByUsername(adminUsername);
|
||||
|
||||
if (!adminUser) {
|
||||
logger.info('Creating default admin user...');
|
||||
logger.info(`Creating initial admin user ${adminUsername}...`);
|
||||
|
||||
// Simple base64 encoding for now - should use bcrypt in production
|
||||
const passwordHash = btoa('admin');
|
||||
const configuredPassword = Deno.env.get('ONEBOX_ADMIN_PASSWORD');
|
||||
const initialPassword = configuredPassword || crypto.randomUUID().replaceAll('-', '');
|
||||
const passwordHash = await hashPassword(initialPassword);
|
||||
|
||||
await this.database.createUser({
|
||||
username: 'admin',
|
||||
username: adminUsername,
|
||||
passwordHash,
|
||||
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!');
|
||||
if (configuredPassword) {
|
||||
logger.warn(`Initial admin user created from ONEBOX_ADMIN_PASSWORD: ${adminUsername}`);
|
||||
} else {
|
||||
logger.warn(`Initial admin user created: ${adminUsername}`);
|
||||
logger.warn(`Generated one-time admin password: ${initialPassword}`);
|
||||
}
|
||||
logger.warn('Change the initial admin password immediately.');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to create default user: ${getErrorMessage(error)}`);
|
||||
@@ -271,9 +289,9 @@ export class Onebox {
|
||||
const providers = this.platformServices.getAllProviders();
|
||||
const platformServicesStatus = providers.map((provider) => {
|
||||
const service = platformServices.find((s) => s.type === provider.type);
|
||||
// For Caddy, check actual runtime status since it starts without a DB record
|
||||
// For SmartProxy, check actual runtime status since it starts without a DB record
|
||||
let status = service?.status || 'not-deployed';
|
||||
if (provider.type === 'caddy') {
|
||||
if (provider.type === 'smartproxy') {
|
||||
status = proxyStatus.http.running ? 'running' : 'stopped';
|
||||
}
|
||||
// Count resources for this platform service
|
||||
@@ -435,12 +453,18 @@ export class Onebox {
|
||||
// Stop reverse proxy if running
|
||||
await this.reverseProxy.stop();
|
||||
|
||||
// Stop Caddy log receiver
|
||||
await this.caddyLogReceiver.stop();
|
||||
// Stop proxy log receiver
|
||||
await this.proxyLogReceiver.stop();
|
||||
|
||||
// Stop built-in registry and backing smartstorage server
|
||||
await this.registry.stop();
|
||||
|
||||
// Close backup archive
|
||||
await this.backupManager.close();
|
||||
|
||||
// Release Docker client resources after all Docker-backed managers stopped.
|
||||
await this.docker.stop();
|
||||
|
||||
// Close database
|
||||
this.database.close();
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import type {
|
||||
import type { IPlatformServiceProvider } from './providers/base.ts';
|
||||
import { MongoDBProvider } from './providers/mongodb.ts';
|
||||
import { MinioProvider } from './providers/minio.ts';
|
||||
import { CaddyProvider } from './providers/caddy.ts';
|
||||
import { SmartProxyProvider } from './providers/smartproxy.ts';
|
||||
import { ClickHouseProvider } from './providers/clickhouse.ts';
|
||||
import { MariaDBProvider } from './providers/mariadb.ts';
|
||||
import { RedisProvider } from './providers/redis.ts';
|
||||
@@ -41,7 +41,7 @@ export class PlatformServicesManager {
|
||||
// Register providers
|
||||
this.registerProvider(new MongoDBProvider(this.oneboxRef));
|
||||
this.registerProvider(new MinioProvider(this.oneboxRef));
|
||||
this.registerProvider(new CaddyProvider(this.oneboxRef));
|
||||
this.registerProvider(new SmartProxyProvider(this.oneboxRef));
|
||||
this.registerProvider(new ClickHouseProvider(this.oneboxRef));
|
||||
this.registerProvider(new MariaDBProvider(this.oneboxRef));
|
||||
this.registerProvider(new RedisProvider(this.oneboxRef));
|
||||
|
||||
@@ -103,6 +103,17 @@ export abstract class BasePlatformServiceProvider implements IPlatformServicePro
|
||||
return `onebox-${this.type}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the host data directory for a platform service.
|
||||
*/
|
||||
protected getPlatformDataDir(serviceDirectoryArg: string): string {
|
||||
const configuredDataDir = this.oneboxRef.database.getSetting('dataDir');
|
||||
const baseDataDir = configuredDataDir ||
|
||||
(Deno.env.get('ONEBOX_DEV') === 'true' ? './.nogit/platform-data' : '/var/lib/onebox');
|
||||
const absoluteBaseDataDir = baseDataDir.startsWith('/') ? baseDataDir : `${Deno.cwd()}/${baseDataDir}`;
|
||||
return `${absoluteBaseDataDir.replace(/\/+$/, '')}/${serviceDirectoryArg}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a resource name from a user service name
|
||||
*/
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
/**
|
||||
* Caddy Platform Service Provider
|
||||
*
|
||||
* Caddy is a core infrastructure service that provides reverse proxy functionality.
|
||||
* Unlike other platform services:
|
||||
* - It doesn't provision resources for user services
|
||||
* - It's started automatically by Onebox and cannot be stopped by users
|
||||
* - It delegates to the existing CaddyManager for actual operations
|
||||
*/
|
||||
|
||||
import { BasePlatformServiceProvider } from './base.ts';
|
||||
import type {
|
||||
IService,
|
||||
IPlatformResource,
|
||||
IPlatformServiceConfig,
|
||||
IProvisionedResource,
|
||||
IEnvVarMapping,
|
||||
TPlatformServiceType,
|
||||
TPlatformResourceType,
|
||||
} from '../../../types.ts';
|
||||
import { logger } from '../../../logging.ts';
|
||||
import type { Onebox } from '../../onebox.ts';
|
||||
|
||||
export class CaddyProvider extends BasePlatformServiceProvider {
|
||||
readonly type: TPlatformServiceType = 'caddy';
|
||||
readonly displayName = 'Caddy Reverse Proxy';
|
||||
readonly resourceTypes: TPlatformResourceType[] = []; // Caddy doesn't provision resources
|
||||
readonly isCore = true; // Core infrastructure - cannot be stopped by users
|
||||
|
||||
constructor(oneboxRef: Onebox) {
|
||||
super(oneboxRef);
|
||||
}
|
||||
|
||||
getDefaultConfig(): IPlatformServiceConfig {
|
||||
return {
|
||||
image: 'caddy:2-alpine',
|
||||
port: 80,
|
||||
volumes: [],
|
||||
environment: {},
|
||||
};
|
||||
}
|
||||
|
||||
getEnvVarMappings(): IEnvVarMapping[] {
|
||||
// Caddy doesn't inject any env vars into user services
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Deploy Caddy container - delegates to CaddyManager via reverseProxy
|
||||
*/
|
||||
async deployContainer(): Promise<string> {
|
||||
logger.info('Starting Caddy via reverse proxy manager...');
|
||||
|
||||
// Get the reverse proxy which manages Caddy
|
||||
const reverseProxy = this.oneboxRef.reverseProxy;
|
||||
|
||||
// Start reverse proxy (which starts Caddy)
|
||||
await reverseProxy.startHttp();
|
||||
|
||||
// Get Caddy status to find container ID
|
||||
const status = reverseProxy.getStatus();
|
||||
|
||||
// Update platform service record
|
||||
const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type);
|
||||
if (platformService) {
|
||||
this.oneboxRef.database.updatePlatformService(platformService.id!, {
|
||||
status: 'running',
|
||||
containerId: 'onebox-caddy', // Service name for Swarm services
|
||||
});
|
||||
}
|
||||
|
||||
logger.success('Caddy platform service started');
|
||||
return 'onebox-caddy';
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop Caddy container - NOT ALLOWED for core infrastructure
|
||||
*/
|
||||
async stopContainer(_containerId: string): Promise<void> {
|
||||
throw new Error('Caddy is a core infrastructure service and cannot be stopped');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Caddy is healthy via the reverse proxy
|
||||
*/
|
||||
async healthCheck(): Promise<boolean> {
|
||||
try {
|
||||
const reverseProxy = this.oneboxRef.reverseProxy;
|
||||
const status = reverseProxy.getStatus();
|
||||
return status.http.running;
|
||||
} catch (error) {
|
||||
logger.debug(`Caddy health check failed: ${error}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Caddy doesn't provision resources for user services
|
||||
*/
|
||||
async provisionResource(_userService: IService): Promise<IProvisionedResource> {
|
||||
throw new Error('Caddy does not provision resources for user services');
|
||||
}
|
||||
|
||||
/**
|
||||
* Caddy doesn't deprovision resources
|
||||
*/
|
||||
async deprovisionResource(_resource: IPlatformResource, _credentials: Record<string, string>): Promise<void> {
|
||||
throw new Error('Caddy does not manage resources for user services');
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,7 @@ export class ClickHouseProvider extends BasePlatformServiceProvider {
|
||||
return {
|
||||
image: 'clickhouse/clickhouse-server:latest',
|
||||
port: 8123, // HTTP interface
|
||||
volumes: ['/var/lib/onebox/clickhouse:/var/lib/clickhouse'],
|
||||
volumes: [`${this.getPlatformDataDir('clickhouse')}:/var/lib/clickhouse`],
|
||||
environment: {
|
||||
CLICKHOUSE_DB: 'default',
|
||||
// Password will be generated and stored encrypted
|
||||
@@ -53,7 +53,7 @@ export class ClickHouseProvider extends BasePlatformServiceProvider {
|
||||
async deployContainer(): Promise<string> {
|
||||
const config = this.getDefaultConfig();
|
||||
const containerName = this.getContainerName();
|
||||
const dataDir = '/var/lib/onebox/clickhouse';
|
||||
const dataDir = this.getPlatformDataDir('clickhouse');
|
||||
|
||||
logger.info(`Deploying ClickHouse platform service as ${containerName}...`);
|
||||
|
||||
@@ -76,7 +76,9 @@ export class ClickHouseProvider extends BasePlatformServiceProvider {
|
||||
if (dataExists && platformService?.adminCredentialsEncrypted) {
|
||||
// Reuse existing credentials from database
|
||||
logger.info('Reusing existing ClickHouse credentials (data directory already initialized)');
|
||||
adminCredentials = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
|
||||
adminCredentials = await credentialEncryption.decrypt<{ username: string; password: string }>(
|
||||
platformService.adminCredentialsEncrypted,
|
||||
);
|
||||
} else {
|
||||
// Generate new credentials for fresh deployment
|
||||
logger.info('Generating new ClickHouse admin credentials');
|
||||
@@ -191,7 +193,9 @@ export class ClickHouseProvider extends BasePlatformServiceProvider {
|
||||
throw new Error('ClickHouse platform service not found or not configured');
|
||||
}
|
||||
|
||||
const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
|
||||
const adminCreds = await credentialEncryption.decrypt<{ username: string; password: string }>(
|
||||
platformService.adminCredentialsEncrypted,
|
||||
);
|
||||
const containerName = this.getContainerName();
|
||||
|
||||
// Generate resource names and credentials
|
||||
@@ -247,7 +251,9 @@ export class ClickHouseProvider extends BasePlatformServiceProvider {
|
||||
throw new Error('ClickHouse platform service not found or not configured');
|
||||
}
|
||||
|
||||
const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
|
||||
const adminCreds = await credentialEncryption.decrypt<{ username: string; password: string }>(
|
||||
platformService.adminCredentialsEncrypted,
|
||||
);
|
||||
|
||||
logger.info(`Deprovisioning ClickHouse database '${resource.resourceName}'...`);
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ export class MariaDBProvider extends BasePlatformServiceProvider {
|
||||
return {
|
||||
image: 'mariadb:11',
|
||||
port: 3306,
|
||||
volumes: ['/var/lib/onebox/mariadb:/var/lib/mysql'],
|
||||
volumes: [`${this.getPlatformDataDir('mariadb')}:/var/lib/mysql`],
|
||||
environment: {
|
||||
MARIADB_ROOT_PASSWORD: '',
|
||||
// Password will be generated and stored encrypted
|
||||
@@ -52,7 +52,7 @@ export class MariaDBProvider extends BasePlatformServiceProvider {
|
||||
async deployContainer(): Promise<string> {
|
||||
const config = this.getDefaultConfig();
|
||||
const containerName = this.getContainerName();
|
||||
const dataDir = '/var/lib/onebox/mariadb';
|
||||
const dataDir = this.getPlatformDataDir('mariadb');
|
||||
|
||||
logger.info(`Deploying MariaDB platform service as ${containerName}...`);
|
||||
|
||||
@@ -74,7 +74,9 @@ export class MariaDBProvider extends BasePlatformServiceProvider {
|
||||
if (dataExists && platformService?.adminCredentialsEncrypted) {
|
||||
// Reuse existing credentials from database
|
||||
logger.info('Reusing existing MariaDB credentials (data directory already initialized)');
|
||||
adminCredentials = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
|
||||
adminCredentials = await credentialEncryption.decrypt<{ username: string; password: string }>(
|
||||
platformService.adminCredentialsEncrypted,
|
||||
);
|
||||
} else {
|
||||
// Generate new credentials for fresh deployment
|
||||
logger.info('Generating new MariaDB admin credentials');
|
||||
|
||||
@@ -30,7 +30,7 @@ export class MinioProvider extends BasePlatformServiceProvider {
|
||||
return {
|
||||
image: 'minio/minio:latest',
|
||||
port: 9000,
|
||||
volumes: ['/var/lib/onebox/minio:/data'],
|
||||
volumes: [`${this.getPlatformDataDir('minio')}:/data`],
|
||||
command: 'server /data --console-address :9001',
|
||||
environment: {
|
||||
MINIO_ROOT_USER: 'admin',
|
||||
@@ -57,7 +57,7 @@ export class MinioProvider extends BasePlatformServiceProvider {
|
||||
async deployContainer(): Promise<string> {
|
||||
const config = this.getDefaultConfig();
|
||||
const containerName = this.getContainerName();
|
||||
const dataDir = '/var/lib/onebox/minio';
|
||||
const dataDir = this.getPlatformDataDir('minio');
|
||||
|
||||
logger.info(`Deploying MinIO platform service as ${containerName}...`);
|
||||
|
||||
@@ -80,7 +80,9 @@ export class MinioProvider extends BasePlatformServiceProvider {
|
||||
if (dataExists && platformService?.adminCredentialsEncrypted) {
|
||||
// Reuse existing credentials from database
|
||||
logger.info('Reusing existing MinIO credentials (data directory already initialized)');
|
||||
adminCredentials = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
|
||||
adminCredentials = await credentialEncryption.decrypt<{ username: string; password: string }>(
|
||||
platformService.adminCredentialsEncrypted,
|
||||
);
|
||||
} else {
|
||||
// Generate new credentials for fresh deployment
|
||||
logger.info('Generating new MinIO admin credentials');
|
||||
|
||||
@@ -30,7 +30,7 @@ export class MongoDBProvider extends BasePlatformServiceProvider {
|
||||
return {
|
||||
image: 'mongo:4.4',
|
||||
port: 27017,
|
||||
volumes: ['/var/lib/onebox/mongodb:/data/db'],
|
||||
volumes: [`${this.getPlatformDataDir('mongodb')}:/data/db`],
|
||||
environment: {
|
||||
MONGO_INITDB_ROOT_USERNAME: 'admin',
|
||||
// Password will be generated and stored encrypted
|
||||
@@ -52,7 +52,7 @@ export class MongoDBProvider extends BasePlatformServiceProvider {
|
||||
async deployContainer(): Promise<string> {
|
||||
const config = this.getDefaultConfig();
|
||||
const containerName = this.getContainerName();
|
||||
const dataDir = '/var/lib/onebox/mongodb';
|
||||
const dataDir = this.getPlatformDataDir('mongodb');
|
||||
|
||||
logger.info(`Deploying MongoDB platform service as ${containerName}...`);
|
||||
|
||||
@@ -74,7 +74,9 @@ export class MongoDBProvider extends BasePlatformServiceProvider {
|
||||
if (dataExists && platformService?.adminCredentialsEncrypted) {
|
||||
// Reuse existing credentials from database
|
||||
logger.info('Reusing existing MongoDB credentials (data directory already initialized)');
|
||||
adminCredentials = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
|
||||
adminCredentials = await credentialEncryption.decrypt<{ username: string; password: string }>(
|
||||
platformService.adminCredentialsEncrypted,
|
||||
);
|
||||
} else {
|
||||
// Generate new credentials for fresh deployment
|
||||
logger.info('Generating new MongoDB admin credentials');
|
||||
|
||||
@@ -30,7 +30,7 @@ export class RedisProvider extends BasePlatformServiceProvider {
|
||||
return {
|
||||
image: 'redis:7-alpine',
|
||||
port: 6379,
|
||||
volumes: ['/var/lib/onebox/redis:/data'],
|
||||
volumes: [`${this.getPlatformDataDir('redis')}:/data`],
|
||||
environment: {},
|
||||
};
|
||||
}
|
||||
@@ -48,7 +48,7 @@ export class RedisProvider extends BasePlatformServiceProvider {
|
||||
async deployContainer(): Promise<string> {
|
||||
const config = this.getDefaultConfig();
|
||||
const containerName = this.getContainerName();
|
||||
const dataDir = '/var/lib/onebox/redis';
|
||||
const dataDir = this.getPlatformDataDir('redis');
|
||||
|
||||
logger.info(`Deploying Redis platform service as ${containerName}...`);
|
||||
|
||||
@@ -76,7 +76,9 @@ export class RedisProvider extends BasePlatformServiceProvider {
|
||||
if (dataExists && platformService?.adminCredentialsEncrypted) {
|
||||
// Reuse existing credentials from database
|
||||
logger.info('Reusing existing Redis credentials (data directory already initialized)');
|
||||
adminCredentials = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
|
||||
adminCredentials = await credentialEncryption.decrypt<{ username: string; password: string }>(
|
||||
platformService.adminCredentialsEncrypted,
|
||||
);
|
||||
} else {
|
||||
// Generate new credentials for fresh deployment
|
||||
logger.info('Generating new Redis admin credentials');
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* SmartProxy Platform Service Provider
|
||||
*
|
||||
* SmartProxy is a core infrastructure service that provides reverse proxy functionality.
|
||||
* Unlike other platform services:
|
||||
* - It doesn't provision resources for user services
|
||||
* - It's started automatically by Onebox and cannot be stopped by users
|
||||
* - It delegates to the existing reverse proxy manager for actual operations
|
||||
*/
|
||||
|
||||
import { BasePlatformServiceProvider } from './base.ts';
|
||||
import type {
|
||||
IService,
|
||||
IPlatformResource,
|
||||
IPlatformServiceConfig,
|
||||
IProvisionedResource,
|
||||
IEnvVarMapping,
|
||||
TPlatformServiceType,
|
||||
TPlatformResourceType,
|
||||
} from '../../../types.ts';
|
||||
import { logger } from '../../../logging.ts';
|
||||
import type { Onebox } from '../../onebox.ts';
|
||||
|
||||
export class SmartProxyProvider extends BasePlatformServiceProvider {
|
||||
readonly type: TPlatformServiceType = 'smartproxy';
|
||||
readonly displayName = 'SmartProxy Reverse Proxy';
|
||||
readonly resourceTypes: TPlatformResourceType[] = [];
|
||||
readonly isCore = true;
|
||||
|
||||
constructor(oneboxRef: Onebox) {
|
||||
super(oneboxRef);
|
||||
}
|
||||
|
||||
getDefaultConfig(): IPlatformServiceConfig {
|
||||
return {
|
||||
image: 'code.foss.global/host.today/ht-docker-smartproxy:latest',
|
||||
port: 80,
|
||||
volumes: [],
|
||||
environment: {},
|
||||
};
|
||||
}
|
||||
|
||||
getEnvVarMappings(): IEnvVarMapping[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
async deployContainer(): Promise<string> {
|
||||
logger.info('Starting SmartProxy via reverse proxy manager...');
|
||||
|
||||
const reverseProxy = this.oneboxRef.reverseProxy;
|
||||
await reverseProxy.startHttp();
|
||||
|
||||
const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type);
|
||||
if (platformService) {
|
||||
this.oneboxRef.database.updatePlatformService(platformService.id!, {
|
||||
status: 'running',
|
||||
containerId: 'onebox-smartproxy',
|
||||
});
|
||||
}
|
||||
|
||||
logger.success('SmartProxy platform service started');
|
||||
return 'onebox-smartproxy';
|
||||
}
|
||||
|
||||
async stopContainer(_containerId: string): Promise<void> {
|
||||
throw new Error('SmartProxy is a core infrastructure service and cannot be stopped');
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<boolean> {
|
||||
try {
|
||||
const reverseProxy = this.oneboxRef.reverseProxy;
|
||||
const status = reverseProxy.getStatus();
|
||||
return status.http.running;
|
||||
} catch (error) {
|
||||
logger.debug(`SmartProxy health check failed: ${error}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async provisionResource(_userService: IService): Promise<IProvisionedResource> {
|
||||
throw new Error('SmartProxy does not provision resources for user services');
|
||||
}
|
||||
|
||||
async deprovisionResource(_resource: IPlatformResource, _credentials: Record<string, string>): Promise<void> {
|
||||
throw new Error('SmartProxy does not manage resources for user services');
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Caddy Log Receiver for Onebox
|
||||
* Proxy Log Receiver for Onebox
|
||||
*
|
||||
* TCP server that receives access logs from Caddy and broadcasts them to WebSocket clients.
|
||||
* TCP server that receives reverse proxy access logs and broadcasts them to WebSocket clients.
|
||||
* Supports per-client filtering by domain and adaptive sampling at high volume.
|
||||
*/
|
||||
|
||||
@@ -18,9 +18,9 @@ export interface ILogFilter {
|
||||
}
|
||||
|
||||
/**
|
||||
* Caddy access log entry structure (from Caddy JSON format)
|
||||
* Reverse proxy access log entry structure.
|
||||
*/
|
||||
export interface ICaddyAccessLog {
|
||||
export interface IProxyAccessLog {
|
||||
ts: number;
|
||||
level?: string;
|
||||
logger?: string;
|
||||
@@ -60,14 +60,17 @@ interface ILogClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* CaddyLogReceiver - TCP server for Caddy access logs
|
||||
* ProxyLogReceiver - TCP server for reverse proxy access logs
|
||||
*/
|
||||
export class CaddyLogReceiver {
|
||||
export class ProxyLogReceiver {
|
||||
private server: Deno.TcpListener | null = null;
|
||||
private clients: Map<string, ILogClient> = new Map();
|
||||
private port: number;
|
||||
private running = false;
|
||||
private connections: Set<Deno.TcpConn> = new Set();
|
||||
private connectionReaders: Map<Deno.TcpConn, ReadableStreamDefaultReader<Uint8Array>> = new Map();
|
||||
private connectionHandlers: Set<Promise<void>> = new Set();
|
||||
private acceptTask: Promise<void> | null = null;
|
||||
|
||||
// Adaptive sampling state
|
||||
private logCountWindow: number[] = []; // timestamps of recent logs
|
||||
@@ -76,7 +79,7 @@ export class CaddyLogReceiver {
|
||||
private logCounter = 0;
|
||||
|
||||
// Ring buffer for recent logs (for late-joining clients)
|
||||
private recentLogs: ICaddyAccessLog[] = [];
|
||||
private recentLogs: IProxyAccessLog[] = [];
|
||||
private maxRecentLogs = 100;
|
||||
|
||||
// Traffic stats aggregation (hourly rolling window)
|
||||
@@ -137,7 +140,7 @@ export class CaddyLogReceiver {
|
||||
/**
|
||||
* Record a request in traffic stats
|
||||
*/
|
||||
private recordTrafficStats(log: ICaddyAccessLog): void {
|
||||
private recordTrafficStats(log: IProxyAccessLog): void {
|
||||
const bucket = this.getCurrentStatsBucket();
|
||||
|
||||
bucket.requestCount++;
|
||||
@@ -164,25 +167,25 @@ export class CaddyLogReceiver {
|
||||
*/
|
||||
async start(): Promise<void> {
|
||||
if (this.running) {
|
||||
logger.warn('CaddyLogReceiver is already running');
|
||||
logger.warn('ProxyLogReceiver is already running');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.server = Deno.listen({ port: this.port, transport: 'tcp' });
|
||||
this.running = true;
|
||||
logger.success(`CaddyLogReceiver started on TCP port ${this.port}`);
|
||||
logger.success(`ProxyLogReceiver started on TCP port ${this.port}`);
|
||||
|
||||
// Start accepting connections in background
|
||||
this.acceptConnections();
|
||||
this.acceptTask = this.acceptConnections();
|
||||
} catch (error) {
|
||||
logger.error(`Failed to start CaddyLogReceiver: ${getErrorMessage(error)}`);
|
||||
logger.error(`Failed to start ProxyLogReceiver: ${getErrorMessage(error)}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept incoming TCP connections from Caddy
|
||||
* Accept incoming TCP connections from the reverse proxy
|
||||
*/
|
||||
private async acceptConnections(): Promise<void> {
|
||||
if (!this.server) return;
|
||||
@@ -190,23 +193,26 @@ export class CaddyLogReceiver {
|
||||
try {
|
||||
for await (const conn of this.server) {
|
||||
this.connections.add(conn);
|
||||
this.handleConnection(conn);
|
||||
const handlerTask = this.handleConnection(conn);
|
||||
this.connectionHandlers.add(handlerTask);
|
||||
handlerTask.finally(() => this.connectionHandlers.delete(handlerTask));
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.running) {
|
||||
logger.error(`CaddyLogReceiver accept error: ${getErrorMessage(error)}`);
|
||||
logger.error(`ProxyLogReceiver accept error: ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a single TCP connection from Caddy
|
||||
* Handle a single TCP connection from the reverse proxy
|
||||
*/
|
||||
private async handleConnection(conn: Deno.TcpConn): Promise<void> {
|
||||
const remoteAddr = conn.remoteAddr as Deno.NetAddr;
|
||||
logger.debug(`CaddyLogReceiver: Connection from ${remoteAddr.hostname}:${remoteAddr.port}`);
|
||||
logger.debug(`ProxyLogReceiver: Connection from ${remoteAddr.hostname}:${remoteAddr.port}`);
|
||||
|
||||
const reader = conn.readable.getReader();
|
||||
this.connectionReaders.set(conn, reader);
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
@@ -217,7 +223,7 @@ export class CaddyLogReceiver {
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
// Process complete lines (Caddy sends newline-delimited JSON)
|
||||
// Process complete newline-delimited JSON log lines.
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || ''; // Keep incomplete line in buffer
|
||||
|
||||
@@ -229,10 +235,16 @@ export class CaddyLogReceiver {
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.running) {
|
||||
logger.debug(`CaddyLogReceiver connection closed: ${getErrorMessage(error)}`);
|
||||
logger.debug(`ProxyLogReceiver connection closed: ${getErrorMessage(error)}`);
|
||||
}
|
||||
} finally {
|
||||
this.connectionReaders.delete(conn);
|
||||
this.connections.delete(conn);
|
||||
try {
|
||||
reader.releaseLock();
|
||||
} catch {
|
||||
// Reader may already be released after cancellation during shutdown.
|
||||
}
|
||||
try {
|
||||
conn.close();
|
||||
} catch {
|
||||
@@ -242,18 +254,18 @@ export class CaddyLogReceiver {
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single log line from Caddy
|
||||
* Process a single access log line
|
||||
*/
|
||||
private processLogLine(line: string): void {
|
||||
try {
|
||||
const log = JSON.parse(line) as ICaddyAccessLog;
|
||||
const log = JSON.parse(line) as IProxyAccessLog;
|
||||
|
||||
// Only process access logs (check for http.log.access or just access, or any log with request/status)
|
||||
const isAccessLog = log.logger === 'http.log.access' ||
|
||||
log.logger === 'access' ||
|
||||
(log.request && typeof log.status === 'number');
|
||||
if (!isAccessLog) {
|
||||
logger.debug(`CaddyLogReceiver: Skipping non-access log: ${log.logger || 'unknown'}`);
|
||||
logger.debug(`ProxyLogReceiver: Skipping non-access log: ${log.logger || 'unknown'}`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -268,7 +280,7 @@ export class CaddyLogReceiver {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug(`CaddyLogReceiver: Access log received - ${log.request?.method} ${log.request?.host}${log.request?.uri} (status: ${log.status})`);
|
||||
logger.debug(`ProxyLogReceiver: Access log received - ${log.request?.method} ${log.request?.host}${log.request?.uri} (status: ${log.status})`);
|
||||
|
||||
// Add to recent logs buffer
|
||||
this.recentLogs.push(log);
|
||||
@@ -277,10 +289,10 @@ export class CaddyLogReceiver {
|
||||
}
|
||||
|
||||
// Broadcast to WebSocket clients (log how many clients)
|
||||
logger.debug(`CaddyLogReceiver: Broadcasting to ${this.clients.size} clients`);
|
||||
logger.debug(`ProxyLogReceiver: Broadcasting to ${this.clients.size} clients`);
|
||||
this.broadcast(log);
|
||||
} catch (error) {
|
||||
logger.debug(`Failed to parse Caddy log line: ${getErrorMessage(error)}`);
|
||||
logger.debug(`Failed to parse proxy log line: ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -317,7 +329,7 @@ export class CaddyLogReceiver {
|
||||
/**
|
||||
* Broadcast a log entry to all connected WebSocket clients
|
||||
*/
|
||||
private broadcast(log: ICaddyAccessLog): void {
|
||||
private broadcast(log: IProxyAccessLog): void {
|
||||
const message = JSON.stringify({
|
||||
type: 'access_log',
|
||||
data: {
|
||||
@@ -365,7 +377,7 @@ export class CaddyLogReceiver {
|
||||
/**
|
||||
* Check if a log entry matches a client's filter
|
||||
*/
|
||||
private matchesFilter(log: ICaddyAccessLog, filter: ILogFilter): boolean {
|
||||
private matchesFilter(log: IProxyAccessLog, filter: ILogFilter): boolean {
|
||||
// Domain filter
|
||||
if (filter.domain) {
|
||||
const logHost = log.request.host.toLowerCase();
|
||||
@@ -385,7 +397,7 @@ export class CaddyLogReceiver {
|
||||
*/
|
||||
addClient(clientId: string, ws: WebSocket, filter: ILogFilter = {}): void {
|
||||
this.clients.set(clientId, { id: clientId, ws, filter });
|
||||
logger.debug(`CaddyLogReceiver: Added client ${clientId} (${this.clients.size} total)`);
|
||||
logger.debug(`ProxyLogReceiver: Added client ${clientId} (${this.clients.size} total)`);
|
||||
|
||||
// Send recent logs to new client
|
||||
for (const log of this.recentLogs) {
|
||||
@@ -422,7 +434,7 @@ export class CaddyLogReceiver {
|
||||
*/
|
||||
removeClient(clientId: string): void {
|
||||
if (this.clients.delete(clientId)) {
|
||||
logger.debug(`CaddyLogReceiver: Removed client ${clientId} (${this.clients.size} remaining)`);
|
||||
logger.debug(`ProxyLogReceiver: Removed client ${clientId} (${this.clients.size} remaining)`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -433,7 +445,7 @@ export class CaddyLogReceiver {
|
||||
const client = this.clients.get(clientId);
|
||||
if (client) {
|
||||
client.filter = filter;
|
||||
logger.debug(`CaddyLogReceiver: Updated filter for client ${clientId}`);
|
||||
logger.debug(`ProxyLogReceiver: Updated filter for client ${clientId}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -447,6 +459,11 @@ export class CaddyLogReceiver {
|
||||
|
||||
this.running = false;
|
||||
|
||||
// Cancel pending reads before closing sockets so background handlers can finish.
|
||||
await Promise.allSettled(
|
||||
Array.from(this.connectionReaders.values()).map((reader) => reader.cancel()),
|
||||
);
|
||||
|
||||
// Close all connections
|
||||
for (const conn of this.connections) {
|
||||
try {
|
||||
@@ -467,10 +484,19 @@ export class CaddyLogReceiver {
|
||||
this.server = null;
|
||||
}
|
||||
|
||||
if (this.acceptTask) {
|
||||
await this.acceptTask.catch(() => {});
|
||||
this.acceptTask = null;
|
||||
}
|
||||
|
||||
await Promise.allSettled(this.connectionHandlers);
|
||||
this.connectionHandlers.clear();
|
||||
this.connectionReaders.clear();
|
||||
|
||||
// Clear clients
|
||||
this.clients.clear();
|
||||
|
||||
logger.info('CaddyLogReceiver stopped');
|
||||
logger.info('ProxyLogReceiver stopped');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -9,6 +9,9 @@ import type { IRegistry } from '../types.ts';
|
||||
import { logger } from '../logging.ts';
|
||||
import { getErrorMessage } from '../utils/error.ts';
|
||||
import { OneboxDatabase } from './database.ts';
|
||||
import { credentialEncryption } from './encryption.ts';
|
||||
|
||||
const encryptedPasswordPrefix = 'enc:v1:';
|
||||
|
||||
export class OneboxRegistriesManager {
|
||||
private oneboxRef: any; // Will be Onebox instance
|
||||
@@ -22,17 +25,23 @@ export class OneboxRegistriesManager {
|
||||
/**
|
||||
* 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));
|
||||
private async encryptPassword(password: string): Promise<string> {
|
||||
const encrypted = await credentialEncryption.encrypt({ password });
|
||||
return `${encryptedPasswordPrefix}${encrypted}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a password
|
||||
*/
|
||||
private decryptPassword(encrypted: string): string {
|
||||
// TODO: Use proper decryption
|
||||
private async decryptPassword(encrypted: string): Promise<string> {
|
||||
if (encrypted.startsWith(encryptedPasswordPrefix)) {
|
||||
const decrypted = await credentialEncryption.decrypt<{ password: string }>(
|
||||
encrypted.slice(encryptedPasswordPrefix.length),
|
||||
);
|
||||
return decrypted.password;
|
||||
}
|
||||
|
||||
// Legacy compatibility for older databases that stored base64-encoded passwords.
|
||||
return new TextDecoder().decode(plugins.encoding.decodeBase64(encrypted));
|
||||
}
|
||||
|
||||
@@ -48,7 +57,7 @@ export class OneboxRegistriesManager {
|
||||
}
|
||||
|
||||
// Encrypt password
|
||||
const passwordEncrypted = this.encryptPassword(password);
|
||||
const passwordEncrypted = await this.encryptPassword(password);
|
||||
|
||||
// Create registry in database
|
||||
const registry = await this.database.createRegistry({
|
||||
@@ -111,7 +120,7 @@ export class OneboxRegistriesManager {
|
||||
try {
|
||||
logger.info(`Logging into registry: ${registry.url}`);
|
||||
|
||||
const password = this.decryptPassword(registry.passwordEncrypted);
|
||||
const password = await this.decryptPassword(registry.passwordEncrypted);
|
||||
|
||||
// Use docker login command
|
||||
const command = [
|
||||
|
||||
+11
-1
@@ -76,7 +76,7 @@ export class RegistryManager {
|
||||
},
|
||||
ociTokens: {
|
||||
enabled: true,
|
||||
realm: 'http://localhost:3000/v2/token',
|
||||
realm: `http://${this.baseUrl}/v2/token`,
|
||||
service: 'onebox-registry',
|
||||
},
|
||||
},
|
||||
@@ -317,6 +317,15 @@ export class RegistryManager {
|
||||
* Stop the registry and smartstorage server
|
||||
*/
|
||||
async stop(): Promise<void> {
|
||||
if (this.registry) {
|
||||
try {
|
||||
this.registry.destroy?.();
|
||||
} catch (error) {
|
||||
logger.error(`Error destroying smartregistry: ${getErrorMessage(error)}`);
|
||||
}
|
||||
this.registry = null;
|
||||
}
|
||||
|
||||
if (this.s3Server) {
|
||||
try {
|
||||
await this.s3Server.stop();
|
||||
@@ -324,6 +333,7 @@ export class RegistryManager {
|
||||
} catch (error) {
|
||||
logger.error(`Error stopping smartstorage: ${getErrorMessage(error)}`);
|
||||
}
|
||||
this.s3Server = null;
|
||||
}
|
||||
|
||||
this.isInitialized = false;
|
||||
|
||||
+38
-44
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* Reverse Proxy for Onebox
|
||||
*
|
||||
* Delegates to Caddy (running as Docker service) for production-grade reverse proxy
|
||||
* with native SNI support, HTTP/2, WebSocket proxying, and zero-downtime configuration updates.
|
||||
* Delegates to SmartProxy (running as Docker service) for production-grade reverse proxy
|
||||
* with TLS termination, WebSocket proxying, and zero-downtime configuration updates.
|
||||
*
|
||||
* Routes use Docker service names (e.g., onebox-hello-world:80) for container-to-container
|
||||
* communication within the Docker overlay network.
|
||||
@@ -11,7 +11,7 @@
|
||||
import { logger } from '../logging.ts';
|
||||
import { getErrorMessage } from '../utils/error.ts';
|
||||
import { OneboxDatabase } from './database.ts';
|
||||
import { CaddyManager } from './caddy.ts';
|
||||
import { SmartProxyManager } from './smartproxy.ts';
|
||||
|
||||
interface IProxyRoute {
|
||||
domain: string;
|
||||
@@ -24,7 +24,7 @@ interface IProxyRoute {
|
||||
export class OneboxReverseProxy {
|
||||
private oneboxRef: any;
|
||||
private database: OneboxDatabase;
|
||||
private caddy: CaddyManager;
|
||||
private smartProxy: SmartProxyManager;
|
||||
private routes: Map<string, IProxyRoute> = new Map();
|
||||
private httpPort = 8080; // Default to dev ports (will be overridden if production)
|
||||
private httpsPort = 8443;
|
||||
@@ -32,33 +32,32 @@ export class OneboxReverseProxy {
|
||||
constructor(oneboxRef: any) {
|
||||
this.oneboxRef = oneboxRef;
|
||||
this.database = oneboxRef.database;
|
||||
this.caddy = new CaddyManager({
|
||||
this.smartProxy = new SmartProxyManager({
|
||||
httpPort: this.httpPort,
|
||||
httpsPort: this.httpsPort,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize reverse proxy - Caddy runs as Docker service, no setup needed
|
||||
* Initialize reverse proxy - SmartProxy runs as Docker service, no setup needed
|
||||
*/
|
||||
async init(): Promise<void> {
|
||||
logger.info('Reverse proxy initialized (Caddy Docker service)');
|
||||
logger.info('Reverse proxy initialized (SmartProxy Docker service)');
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the HTTP/HTTPS reverse proxy server
|
||||
* Caddy handles both HTTP and HTTPS on the configured ports
|
||||
* SmartProxy handles both HTTP and HTTPS on the configured ports
|
||||
*/
|
||||
async startHttp(port?: number): Promise<void> {
|
||||
if (port) {
|
||||
this.httpPort = port;
|
||||
this.caddy.setPorts(this.httpPort, this.httpsPort);
|
||||
this.smartProxy.setPorts(this.httpPort, this.httpsPort);
|
||||
}
|
||||
|
||||
try {
|
||||
// Start Caddy (handles both HTTP and HTTPS)
|
||||
await this.caddy.start();
|
||||
logger.success(`Reverse proxy started on port ${this.httpPort} (Caddy Docker service)`);
|
||||
await this.smartProxy.start();
|
||||
logger.success(`Reverse proxy started on port ${this.httpPort} (SmartProxy Docker service)`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to start reverse proxy: ${getErrorMessage(error)}`);
|
||||
throw error;
|
||||
@@ -66,21 +65,19 @@ export class OneboxReverseProxy {
|
||||
}
|
||||
|
||||
/**
|
||||
* Start HTTPS - Caddy already handles HTTPS when started
|
||||
* Start HTTPS - SmartProxy already handles HTTPS when started
|
||||
* This method exists for interface compatibility
|
||||
*/
|
||||
async startHttps(port?: number): Promise<void> {
|
||||
if (port) {
|
||||
this.httpsPort = port;
|
||||
this.caddy.setPorts(this.httpPort, this.httpsPort);
|
||||
this.smartProxy.setPorts(this.httpPort, this.httpsPort);
|
||||
}
|
||||
// Caddy handles both HTTP and HTTPS together
|
||||
// If already running, just log and optionally reload with new port
|
||||
const status = this.caddy.getStatus();
|
||||
const status = this.smartProxy.getStatus();
|
||||
if (status.running) {
|
||||
logger.info(`HTTPS already running on port ${this.httpsPort} via Caddy`);
|
||||
logger.info(`HTTPS already running on port ${this.httpsPort} via SmartProxy`);
|
||||
} else {
|
||||
await this.caddy.start();
|
||||
logger.warn('Skipping HTTPS reverse proxy startup because SmartProxy is not running');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,13 +85,13 @@ export class OneboxReverseProxy {
|
||||
* Stop the reverse proxy
|
||||
*/
|
||||
async stop(): Promise<void> {
|
||||
await this.caddy.stop();
|
||||
await this.smartProxy.stop();
|
||||
logger.info('Reverse proxy stopped');
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a route for a service
|
||||
* Uses Docker service name for upstream (Caddy runs in same Docker network)
|
||||
* Uses Docker service name for upstream (SmartProxy runs in same Docker network)
|
||||
*/
|
||||
async addRoute(serviceId: number, domain: string, targetPort: number): Promise<void> {
|
||||
try {
|
||||
@@ -105,7 +102,7 @@ export class OneboxReverseProxy {
|
||||
}
|
||||
|
||||
// Use Docker service name as upstream target
|
||||
// Caddy runs on the same Docker network, so it can resolve service names directly
|
||||
// SmartProxy runs on the same Docker network, so it can resolve service names directly
|
||||
const serviceName = `onebox-${service.name}`;
|
||||
const targetHost = serviceName;
|
||||
|
||||
@@ -119,9 +116,9 @@ export class OneboxReverseProxy {
|
||||
|
||||
this.routes.set(domain, route);
|
||||
|
||||
// Add route to Caddy using Docker service name
|
||||
// Add route to SmartProxy using Docker service name
|
||||
const upstream = `${targetHost}:${targetPort}`;
|
||||
await this.caddy.addRoute(domain, upstream);
|
||||
await this.smartProxy.addRoute(domain, upstream);
|
||||
|
||||
logger.success(`Added proxy route: ${domain} -> ${upstream}`);
|
||||
} catch (error) {
|
||||
@@ -133,12 +130,9 @@ export class OneboxReverseProxy {
|
||||
/**
|
||||
* Remove a route
|
||||
*/
|
||||
removeRoute(domain: string): void {
|
||||
async removeRoute(domain: string): Promise<void> {
|
||||
if (this.routes.delete(domain)) {
|
||||
// Remove from Caddy (async but we don't wait)
|
||||
this.caddy.removeRoute(domain).catch((error) => {
|
||||
logger.error(`Failed to remove Caddy route for ${domain}: ${getErrorMessage(error)}`);
|
||||
});
|
||||
await this.smartProxy.removeRoute(domain);
|
||||
logger.success(`Removed proxy route: ${domain}`);
|
||||
} else {
|
||||
logger.warn(`Route not found: ${domain}`);
|
||||
@@ -159,9 +153,9 @@ export class OneboxReverseProxy {
|
||||
try {
|
||||
logger.info('Reloading proxy routes...');
|
||||
|
||||
// Clear local and Caddy routes
|
||||
// Clear local and SmartProxy routes
|
||||
this.routes.clear();
|
||||
this.caddy.clear();
|
||||
this.smartProxy.clear();
|
||||
|
||||
const services = this.database.getAllServices();
|
||||
|
||||
@@ -181,7 +175,7 @@ export class OneboxReverseProxy {
|
||||
|
||||
/**
|
||||
* Add TLS certificate for a domain
|
||||
* Sends PEM content to Caddy via Admin API
|
||||
* Sends PEM content to SmartProxy via Admin API
|
||||
*/
|
||||
async addCertificate(domain: string, certPem: string, keyPem: string): Promise<void> {
|
||||
if (!certPem || !keyPem) {
|
||||
@@ -189,14 +183,14 @@ export class OneboxReverseProxy {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.caddy.addCertificate(domain, certPem, keyPem);
|
||||
await this.smartProxy.addCertificate(domain, certPem, keyPem);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove TLS certificate for a domain
|
||||
*/
|
||||
removeCertificate(domain: string): void {
|
||||
this.caddy.removeCertificate(domain).catch((error) => {
|
||||
this.smartProxy.removeCertificate(domain).catch((error) => {
|
||||
logger.error(`Failed to remove certificate for ${domain}: ${getErrorMessage(error)}`);
|
||||
});
|
||||
}
|
||||
@@ -213,13 +207,13 @@ export class OneboxReverseProxy {
|
||||
for (const cert of certificates) {
|
||||
// Use fullchainPem for the cert (includes intermediates) and keyPem for the key
|
||||
if (cert.domain && cert.fullchainPem && cert.keyPem) {
|
||||
await this.caddy.addCertificate(cert.domain, cert.fullchainPem, cert.keyPem);
|
||||
await this.smartProxy.addCertificate(cert.domain, cert.fullchainPem, cert.keyPem);
|
||||
} else {
|
||||
logger.warn(`Skipping certificate for ${cert.domain}: missing PEM content`);
|
||||
}
|
||||
}
|
||||
|
||||
logger.success(`Loaded ${this.caddy.getCertificates().length} TLS certificates`);
|
||||
logger.success(`Loaded ${this.smartProxy.getCertificates().length} TLS certificates`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to reload certificates: ${getErrorMessage(error)}`);
|
||||
throw error;
|
||||
@@ -230,19 +224,19 @@ export class OneboxReverseProxy {
|
||||
* Get status of reverse proxy
|
||||
*/
|
||||
getStatus() {
|
||||
const caddyStatus = this.caddy.getStatus();
|
||||
const smartProxyStatus = this.smartProxy.getStatus();
|
||||
return {
|
||||
http: {
|
||||
running: caddyStatus.running,
|
||||
port: caddyStatus.httpPort,
|
||||
running: smartProxyStatus.running,
|
||||
port: smartProxyStatus.httpPort,
|
||||
},
|
||||
https: {
|
||||
running: caddyStatus.running,
|
||||
port: caddyStatus.httpsPort,
|
||||
certificates: caddyStatus.certificates,
|
||||
running: smartProxyStatus.running,
|
||||
port: smartProxyStatus.httpsPort,
|
||||
certificates: smartProxyStatus.certificates,
|
||||
},
|
||||
routes: caddyStatus.routes,
|
||||
backend: 'caddy-docker',
|
||||
routes: smartProxyStatus.routes,
|
||||
backend: 'smartproxy-docker',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+87
-18
@@ -23,6 +23,35 @@ export class OneboxServicesManager {
|
||||
this.docker = oneboxRef.docker;
|
||||
}
|
||||
|
||||
private async broadcastServiceUpdate(
|
||||
serviceName: string,
|
||||
action: 'created' | 'updated' | 'deleted' | 'started' | 'stopped',
|
||||
): Promise<void> {
|
||||
await this.oneboxRef.opsServer.broadcastServiceUpdate(
|
||||
serviceName,
|
||||
action,
|
||||
this.database.getServiceByName(serviceName),
|
||||
);
|
||||
}
|
||||
|
||||
private async syncExternalGatewayRoute(service: IService): Promise<void> {
|
||||
if (!this.oneboxRef.externalGateway) return;
|
||||
try {
|
||||
await this.oneboxRef.externalGateway.syncServiceRoute(service);
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to sync external gateway route for ${service.domain}: ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async deleteExternalGatewayRoute(service: Pick<IService, 'id' | 'name' | 'domain'>): Promise<void> {
|
||||
if (!this.oneboxRef.externalGateway) return;
|
||||
try {
|
||||
await this.oneboxRef.externalGateway.deleteServiceRoute(service);
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to delete external gateway route for ${service.domain}: ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deploy a new service (full workflow)
|
||||
*/
|
||||
@@ -101,9 +130,15 @@ export class OneboxServicesManager {
|
||||
|
||||
// Merge platform env vars with user-specified env vars (user vars take precedence)
|
||||
const mergedEnvVars = { ...platformEnvVars, ...(options.envVars || {}) };
|
||||
this.resolveEnvVarTemplates(mergedEnvVars, {
|
||||
...platformEnvVars,
|
||||
SERVICE_NAME: options.name,
|
||||
SERVICE_DOMAIN: options.domain || '',
|
||||
SERVICE_PORT: String(options.port),
|
||||
});
|
||||
|
||||
// Update service with merged env vars
|
||||
if (Object.keys(platformEnvVars).length > 0) {
|
||||
// Update service with merged and resolved env vars.
|
||||
if (Object.keys(mergedEnvVars).length > 0) {
|
||||
this.database.updateService(service.id!, { envVars: mergedEnvVars });
|
||||
}
|
||||
|
||||
@@ -193,11 +228,15 @@ export class OneboxServicesManager {
|
||||
|
||||
// Note: SSL certificates are now handled automatically by CertRequirementManager
|
||||
// which processes pending requirements created above. No direct obtainCertificate call needed.
|
||||
|
||||
await this.syncExternalGatewayRoute(this.database.getServiceByName(options.name)!);
|
||||
}
|
||||
|
||||
logger.success(`Service deployed successfully: ${options.name}`);
|
||||
|
||||
return this.database.getServiceByName(options.name)!;
|
||||
const deployedService = this.database.getServiceByName(options.name)!;
|
||||
await this.broadcastServiceUpdate(options.name, 'created');
|
||||
return deployedService;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to deploy service ${options.name}: ${getErrorMessage(error)}`);
|
||||
throw error;
|
||||
@@ -233,15 +272,19 @@ export class OneboxServicesManager {
|
||||
} catch (routeError) {
|
||||
logger.warn(`Failed to add proxy route for ${service.domain}: ${getErrorMessage(routeError)}`);
|
||||
}
|
||||
|
||||
await this.syncExternalGatewayRoute(this.database.getServiceByName(name)!);
|
||||
}
|
||||
|
||||
logger.success(`Service started: ${name}`);
|
||||
await this.broadcastServiceUpdate(name, 'started');
|
||||
} catch (error) {
|
||||
logger.error(`Failed to start service ${name}: ${getErrorMessage(error)}`);
|
||||
this.database.updateService(
|
||||
this.database.getServiceByName(name)?.id!,
|
||||
{ status: 'failed' }
|
||||
);
|
||||
await this.broadcastServiceUpdate(name, 'updated');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -270,10 +313,12 @@ export class OneboxServicesManager {
|
||||
|
||||
// Remove reverse proxy route if service has a domain
|
||||
if (service.domain) {
|
||||
this.oneboxRef.reverseProxy.removeRoute(service.domain);
|
||||
await this.oneboxRef.reverseProxy.removeRoute(service.domain);
|
||||
await this.deleteExternalGatewayRoute(service);
|
||||
}
|
||||
|
||||
logger.success(`Service stopped: ${name}`);
|
||||
await this.broadcastServiceUpdate(name, 'stopped');
|
||||
} catch (error) {
|
||||
logger.error(`Failed to stop service ${name}: ${getErrorMessage(error)}`);
|
||||
throw error;
|
||||
@@ -301,6 +346,7 @@ export class OneboxServicesManager {
|
||||
this.database.updateService(service.id!, { status: 'running' });
|
||||
|
||||
logger.success(`Service restarted: ${name}`);
|
||||
await this.broadcastServiceUpdate(name, 'updated');
|
||||
} catch (error) {
|
||||
logger.error(`Failed to restart service ${name}: ${getErrorMessage(error)}`);
|
||||
throw error;
|
||||
@@ -331,11 +377,13 @@ export class OneboxServicesManager {
|
||||
// Remove reverse proxy route
|
||||
if (service.domain) {
|
||||
try {
|
||||
this.oneboxRef.reverseProxy.removeRoute(service.domain);
|
||||
await this.oneboxRef.reverseProxy.removeRoute(service.domain);
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to remove reverse proxy route: ${getErrorMessage(error)}`);
|
||||
}
|
||||
|
||||
await this.deleteExternalGatewayRoute(service);
|
||||
|
||||
// Note: We don't remove DNS records or SSL certs automatically
|
||||
// as they might be used by other services or need manual cleanup
|
||||
}
|
||||
@@ -357,6 +405,7 @@ export class OneboxServicesManager {
|
||||
this.database.deleteService(service.id!);
|
||||
|
||||
logger.success(`Service removed: ${name}`);
|
||||
await this.oneboxRef.opsServer.broadcastServiceUpdate(name, 'deleted');
|
||||
} catch (error) {
|
||||
logger.error(`Failed to remove service ${name}: ${getErrorMessage(error)}`);
|
||||
throw error;
|
||||
@@ -593,10 +642,12 @@ export class OneboxServicesManager {
|
||||
// Remove old route if it existed
|
||||
if (oldDomain) {
|
||||
try {
|
||||
this.oneboxRef.reverseProxy.removeRoute(oldDomain);
|
||||
await this.oneboxRef.reverseProxy.removeRoute(oldDomain);
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to remove old reverse proxy route: ${getErrorMessage(error)}`);
|
||||
}
|
||||
|
||||
await this.deleteExternalGatewayRoute({ ...service, domain: oldDomain });
|
||||
}
|
||||
|
||||
// Add new route if domain specified
|
||||
@@ -625,7 +676,12 @@ export class OneboxServicesManager {
|
||||
logger.success(`Service ${name} updated (not started)`);
|
||||
}
|
||||
|
||||
return this.database.getServiceByName(name)!;
|
||||
const refreshedService = this.database.getServiceByName(name)!;
|
||||
if (refreshedService.domain && refreshedService.status === 'running') {
|
||||
await this.syncExternalGatewayRoute(refreshedService);
|
||||
}
|
||||
await this.broadcastServiceUpdate(name, 'updated');
|
||||
return refreshedService;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to update service ${name}: ${getErrorMessage(error)}`);
|
||||
throw error;
|
||||
@@ -659,11 +715,7 @@ export class OneboxServicesManager {
|
||||
// Only update and broadcast if status changed
|
||||
if (service.status !== ourStatus) {
|
||||
this.database.updateService(service.id!, { status: ourStatus });
|
||||
|
||||
// Broadcast status change via WebSocket
|
||||
if (this.oneboxRef.httpServer) {
|
||||
this.oneboxRef.httpServer.broadcastServiceStatus(name, ourStatus);
|
||||
}
|
||||
await this.broadcastServiceUpdate(name, 'updated');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug(`Failed to sync status for service ${name}: ${getErrorMessage(error)}`);
|
||||
@@ -681,6 +733,29 @@ export class OneboxServicesManager {
|
||||
}
|
||||
}
|
||||
|
||||
private resolveEnvVarTemplates(
|
||||
envVarsArg: Record<string, string>,
|
||||
valuesArg: Record<string, string>,
|
||||
): void {
|
||||
for (const [key, value] of Object.entries(envVarsArg)) {
|
||||
const missingValues = new Set<string>();
|
||||
const resolvedValue = value.replace(/\$\{([A-Z0-9_]+)\}/g, (match, placeholderName) => {
|
||||
const replacement = valuesArg[placeholderName];
|
||||
if (replacement === undefined || replacement === '') {
|
||||
missingValues.add(placeholderName);
|
||||
return match;
|
||||
}
|
||||
return replacement;
|
||||
});
|
||||
if (missingValues.size > 0) {
|
||||
throw new Error(
|
||||
`Missing template value(s) for ${key}: ${Array.from(missingValues).join(', ')}`,
|
||||
);
|
||||
}
|
||||
envVarsArg[key] = resolvedValue;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start auto-update monitoring for registry services
|
||||
* Polls every 30 seconds for digest changes and restarts services if needed
|
||||
@@ -756,12 +831,6 @@ export class OneboxServicesManager {
|
||||
// Restart service
|
||||
logger.info(`Auto-restarting service: ${service.name}`);
|
||||
await this.restartService(service.name);
|
||||
|
||||
// Broadcast update via WebSocket
|
||||
this.oneboxRef.httpServer.broadcastServiceUpdate({
|
||||
action: 'updated',
|
||||
service: this.database.getServiceByName(service.name)!,
|
||||
});
|
||||
} else if (!service.imageDigest) {
|
||||
// First time - just store the digest
|
||||
this.database.updateService(service.id!, {
|
||||
|
||||
@@ -0,0 +1,477 @@
|
||||
/**
|
||||
* SmartProxy Manager for Onebox
|
||||
*
|
||||
* Manages SmartProxy as a Docker Swarm service so it can route to services on
|
||||
* the Onebox overlay network.
|
||||
*/
|
||||
|
||||
import * as plugins from '../plugins.ts';
|
||||
import { logger } from '../logging.ts';
|
||||
import { getErrorMessage } from '../utils/error.ts';
|
||||
|
||||
const SMARTPROXY_SERVICE_NAME = 'onebox-smartproxy';
|
||||
const LEGACY_CADDY_SERVICE_NAME = 'onebox-caddy';
|
||||
const SMARTPROXY_IMAGE = 'code.foss.global/host.today/ht-docker-smartproxy:latest';
|
||||
const SMARTPROXY_ADMIN_CONTAINER_PORT = 3000;
|
||||
const SMARTPROXY_HTTP_CONTAINER_PORT = 80;
|
||||
const SMARTPROXY_HTTPS_CONTAINER_PORT = 443;
|
||||
|
||||
export interface ISmartProxyRoute {
|
||||
domain: string;
|
||||
upstream: string;
|
||||
}
|
||||
|
||||
export interface ISmartProxyCertificate {
|
||||
domain: string;
|
||||
certPem: string;
|
||||
keyPem: string;
|
||||
}
|
||||
|
||||
interface ISmartProxyRouteConfig {
|
||||
name: string;
|
||||
match: {
|
||||
ports: number;
|
||||
domains: string;
|
||||
protocol?: 'http' | 'tcp' | 'udp' | 'quic' | 'http3';
|
||||
};
|
||||
action: {
|
||||
type: 'forward';
|
||||
targets: Array<{ host: string; port: number }>;
|
||||
tls?: {
|
||||
mode: 'terminate';
|
||||
certificate: {
|
||||
key: string;
|
||||
cert: string;
|
||||
};
|
||||
};
|
||||
websocket?: {
|
||||
enabled: boolean;
|
||||
};
|
||||
};
|
||||
priority?: number;
|
||||
}
|
||||
|
||||
export class SmartProxyManager {
|
||||
private dockerClient: InstanceType<typeof plugins.docker.Docker> | null = null;
|
||||
private certsDir: string;
|
||||
private adminUrl: string;
|
||||
private adminPort: number;
|
||||
private httpPort: number;
|
||||
private httpsPort: number;
|
||||
private routes: Map<string, ISmartProxyRoute> = new Map();
|
||||
private certificates: Map<string, ISmartProxyCertificate> = new Map();
|
||||
private networkName = 'onebox-network';
|
||||
private serviceRunning = false;
|
||||
|
||||
constructor(options?: {
|
||||
certsDir?: string;
|
||||
adminPort?: number;
|
||||
httpPort?: number;
|
||||
httpsPort?: number;
|
||||
}) {
|
||||
this.certsDir = options?.certsDir || './.nogit/certs';
|
||||
this.adminPort = options?.adminPort || 2019;
|
||||
this.adminUrl = `http://localhost:${this.adminPort}`;
|
||||
this.httpPort = options?.httpPort || 8080;
|
||||
this.httpsPort = options?.httpsPort || 8443;
|
||||
}
|
||||
|
||||
private async ensureDockerClient(): Promise<void> {
|
||||
if (!this.dockerClient) {
|
||||
this.dockerClient = new plugins.docker.Docker({
|
||||
socketPath: 'unix:///var/run/docker.sock',
|
||||
});
|
||||
await this.dockerClient.start();
|
||||
}
|
||||
}
|
||||
|
||||
setPorts(httpPort: number, httpsPort: number): void {
|
||||
this.httpPort = httpPort;
|
||||
this.httpsPort = httpsPort;
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.serviceRunning) {
|
||||
logger.warn('SmartProxy service is already running');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.ensureDockerClient();
|
||||
await Deno.mkdir(this.certsDir, { recursive: true });
|
||||
|
||||
logger.info('Starting SmartProxy Docker service...');
|
||||
|
||||
const legacyService = await this.getExistingService(LEGACY_CADDY_SERVICE_NAME);
|
||||
if (legacyService) {
|
||||
logger.info('Legacy Caddy service exists, removing it before SmartProxy startup...');
|
||||
await this.removeService(LEGACY_CADDY_SERVICE_NAME);
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
}
|
||||
|
||||
const existingService = await this.getExistingService();
|
||||
if (existingService) {
|
||||
logger.info('SmartProxy service exists, removing old service...');
|
||||
await this.removeService();
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
}
|
||||
|
||||
const networkId = await this.getNetworkId();
|
||||
|
||||
const response = await this.dockerClient!.request('POST', '/services/create', {
|
||||
Name: SMARTPROXY_SERVICE_NAME,
|
||||
Labels: {
|
||||
'managed-by': 'onebox',
|
||||
'onebox-type': 'smartproxy',
|
||||
},
|
||||
TaskTemplate: {
|
||||
ContainerSpec: {
|
||||
Image: SMARTPROXY_IMAGE,
|
||||
Env: [
|
||||
'SMARTPROXY_ADMIN_HOST=0.0.0.0',
|
||||
`SMARTPROXY_ADMIN_PORT=${SMARTPROXY_ADMIN_CONTAINER_PORT}`,
|
||||
],
|
||||
},
|
||||
Networks: [
|
||||
{
|
||||
Target: networkId,
|
||||
},
|
||||
],
|
||||
RestartPolicy: {
|
||||
Condition: 'any',
|
||||
MaxAttempts: 0,
|
||||
},
|
||||
},
|
||||
Mode: {
|
||||
Replicated: {
|
||||
Replicas: 1,
|
||||
},
|
||||
},
|
||||
EndpointSpec: {
|
||||
Ports: [
|
||||
{
|
||||
Protocol: 'tcp',
|
||||
TargetPort: SMARTPROXY_HTTP_CONTAINER_PORT,
|
||||
PublishedPort: this.httpPort,
|
||||
PublishMode: 'host',
|
||||
},
|
||||
{
|
||||
Protocol: 'tcp',
|
||||
TargetPort: SMARTPROXY_HTTPS_CONTAINER_PORT,
|
||||
PublishedPort: this.httpsPort,
|
||||
PublishMode: 'host',
|
||||
},
|
||||
{
|
||||
Protocol: 'tcp',
|
||||
TargetPort: SMARTPROXY_ADMIN_CONTAINER_PORT,
|
||||
PublishedPort: this.adminPort,
|
||||
PublishMode: 'host',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
if (response.statusCode >= 300) {
|
||||
throw new Error(`Failed to create SmartProxy service: HTTP ${response.statusCode} - ${JSON.stringify(response.body)}`);
|
||||
}
|
||||
|
||||
logger.info(`SmartProxy service created: ${response.body.ID}`);
|
||||
|
||||
await this.waitForReady();
|
||||
this.serviceRunning = true;
|
||||
await this.reloadConfig();
|
||||
|
||||
logger.success(`SmartProxy started (HTTP: ${this.httpPort}, HTTPS: ${this.httpsPort}, Admin: ${this.adminUrl})`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to start SmartProxy: ${getErrorMessage(error)}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async getExistingService(serviceNameArg = SMARTPROXY_SERVICE_NAME): Promise<any | null> {
|
||||
try {
|
||||
const response = await this.dockerClient!.request('GET', `/services/${serviceNameArg}`, {});
|
||||
if (response.statusCode === 200) {
|
||||
return response.body;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async removeService(serviceNameArg = SMARTPROXY_SERVICE_NAME): Promise<void> {
|
||||
try {
|
||||
await this.dockerClient!.request('DELETE', `/services/${serviceNameArg}`, {});
|
||||
} catch {
|
||||
// Service may not exist.
|
||||
}
|
||||
}
|
||||
|
||||
private async getNetworkId(): Promise<string> {
|
||||
const networks = await this.dockerClient!.listNetworks();
|
||||
const network = networks.find((n: any) => n.Name === this.networkName);
|
||||
if (!network) {
|
||||
throw new Error(`Network not found: ${this.networkName}`);
|
||||
}
|
||||
return network.Id;
|
||||
}
|
||||
|
||||
private async waitForReady(maxAttempts = 10, intervalMs = 1000): Promise<void> {
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
try {
|
||||
const response = await fetch(`${this.adminUrl}/ready`);
|
||||
if (response.ok) {
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Not ready yet.
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
||||
}
|
||||
throw new Error('SmartProxy service failed to start within timeout');
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
try {
|
||||
await this.ensureDockerClient();
|
||||
|
||||
if (!this.serviceRunning && !(await this.getExistingService())) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info('Stopping SmartProxy service...');
|
||||
await this.removeService();
|
||||
|
||||
this.serviceRunning = false;
|
||||
logger.info('SmartProxy service stopped');
|
||||
} catch (error) {
|
||||
logger.error(`Failed to stop SmartProxy: ${getErrorMessage(error)}`);
|
||||
} finally {
|
||||
if (this.dockerClient) {
|
||||
try {
|
||||
await this.dockerClient.stop();
|
||||
} catch (error) {
|
||||
logger.error(`Failed to stop SmartProxy Docker client: ${getErrorMessage(error)}`);
|
||||
} finally {
|
||||
this.dockerClient = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async isHealthy(): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${this.adminUrl}/health`);
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async isRunning(): Promise<boolean> {
|
||||
try {
|
||||
await this.ensureDockerClient();
|
||||
const service = await this.getExistingService();
|
||||
if (!service) return false;
|
||||
|
||||
const tasksResponse = await this.dockerClient!.request(
|
||||
'GET',
|
||||
`/tasks?filters=${encodeURIComponent(JSON.stringify({ service: [SMARTPROXY_SERVICE_NAME] }))}`,
|
||||
{},
|
||||
);
|
||||
|
||||
if (tasksResponse.statusCode !== 200) return false;
|
||||
|
||||
const tasks = tasksResponse.body;
|
||||
return tasks.some((task: any) => task.Status?.State === 'running');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private routeName(prefixArg: string, domainArg: string): string {
|
||||
return `${prefixArg}-${domainArg.replace(/[^a-zA-Z0-9]+/g, '-').replace(/^-|-$/g, '')}`;
|
||||
}
|
||||
|
||||
private parseUpstream(upstreamArg: string): { host: string; port: number } {
|
||||
const separatorIndex = upstreamArg.lastIndexOf(':');
|
||||
if (separatorIndex <= 0 || separatorIndex === upstreamArg.length - 1) {
|
||||
throw new Error(`Invalid upstream target: ${upstreamArg}`);
|
||||
}
|
||||
|
||||
const host = upstreamArg.slice(0, separatorIndex);
|
||||
const port = Number(upstreamArg.slice(separatorIndex + 1));
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
throw new Error(`Invalid upstream port in target: ${upstreamArg}`);
|
||||
}
|
||||
|
||||
return { host, port };
|
||||
}
|
||||
|
||||
private buildRoutes(): ISmartProxyRouteConfig[] {
|
||||
const routeConfigs: ISmartProxyRouteConfig[] = [];
|
||||
|
||||
for (const [domain, route] of this.routes) {
|
||||
const target = this.parseUpstream(route.upstream);
|
||||
const baseAction = {
|
||||
type: 'forward' as const,
|
||||
targets: [target],
|
||||
websocket: {
|
||||
enabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
routeConfigs.push({
|
||||
name: this.routeName('http', domain),
|
||||
match: {
|
||||
ports: SMARTPROXY_HTTP_CONTAINER_PORT,
|
||||
domains: domain,
|
||||
protocol: 'http',
|
||||
},
|
||||
action: baseAction,
|
||||
priority: 10,
|
||||
});
|
||||
|
||||
const certificate = this.certificates.get(domain);
|
||||
if (certificate) {
|
||||
routeConfigs.push({
|
||||
name: this.routeName('https', domain),
|
||||
match: {
|
||||
ports: SMARTPROXY_HTTPS_CONTAINER_PORT,
|
||||
domains: domain,
|
||||
protocol: 'http',
|
||||
},
|
||||
action: {
|
||||
...baseAction,
|
||||
tls: {
|
||||
mode: 'terminate',
|
||||
certificate: {
|
||||
key: certificate.keyPem,
|
||||
cert: certificate.certPem,
|
||||
},
|
||||
},
|
||||
},
|
||||
priority: 20,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return routeConfigs;
|
||||
}
|
||||
|
||||
async reloadConfig(): Promise<void> {
|
||||
const isRunning = await this.isRunning();
|
||||
if (!isRunning) {
|
||||
logger.warn('SmartProxy not running, cannot reload config');
|
||||
return;
|
||||
}
|
||||
|
||||
const routes = this.buildRoutes();
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.adminUrl}/routes`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ routes }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`Failed to reload SmartProxy routes: ${response.status} ${text}`);
|
||||
}
|
||||
|
||||
logger.debug('SmartProxy routes reloaded');
|
||||
} catch (error) {
|
||||
logger.error(`Failed to reload SmartProxy routes: ${getErrorMessage(error)}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async addRoute(domain: string, upstream: string): Promise<void> {
|
||||
this.routes.set(domain, { domain, upstream });
|
||||
|
||||
if (await this.isRunning()) {
|
||||
await this.reloadConfig();
|
||||
}
|
||||
|
||||
logger.success(`Added SmartProxy route: ${domain} -> ${upstream}`);
|
||||
}
|
||||
|
||||
async removeRoute(domain: string): Promise<void> {
|
||||
if (this.routes.delete(domain)) {
|
||||
if (await this.isRunning()) {
|
||||
await this.reloadConfig();
|
||||
}
|
||||
logger.success(`Removed SmartProxy route: ${domain}`);
|
||||
}
|
||||
}
|
||||
|
||||
async addCertificate(domain: string, certPem: string, keyPem: string): Promise<void> {
|
||||
this.certificates.set(domain, {
|
||||
domain,
|
||||
certPem,
|
||||
keyPem,
|
||||
});
|
||||
|
||||
try {
|
||||
await Deno.mkdir(this.certsDir, { recursive: true });
|
||||
await Deno.writeTextFile(`${this.certsDir}/${domain}.crt`, certPem);
|
||||
await Deno.writeTextFile(`${this.certsDir}/${domain}.key`, keyPem);
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to write certificate backup for ${domain}: ${getErrorMessage(error)}`);
|
||||
}
|
||||
|
||||
if (await this.isRunning()) {
|
||||
await this.reloadConfig();
|
||||
}
|
||||
|
||||
logger.success(`Added TLS certificate for ${domain}`);
|
||||
}
|
||||
|
||||
async removeCertificate(domain: string): Promise<void> {
|
||||
if (this.certificates.delete(domain)) {
|
||||
try {
|
||||
await Deno.remove(`${this.certsDir}/${domain}.crt`);
|
||||
await Deno.remove(`${this.certsDir}/${domain}.key`);
|
||||
} catch {
|
||||
// Files may not exist.
|
||||
}
|
||||
|
||||
if (await this.isRunning()) {
|
||||
await this.reloadConfig();
|
||||
}
|
||||
|
||||
logger.success(`Removed TLS certificate for ${domain}`);
|
||||
}
|
||||
}
|
||||
|
||||
getRoutes(): ISmartProxyRoute[] {
|
||||
return Array.from(this.routes.values());
|
||||
}
|
||||
|
||||
getCertificates(): ISmartProxyCertificate[] {
|
||||
return Array.from(this.certificates.values());
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.routes.clear();
|
||||
this.certificates.clear();
|
||||
}
|
||||
|
||||
getStatus(): {
|
||||
running: boolean;
|
||||
httpPort: number;
|
||||
httpsPort: number;
|
||||
routes: number;
|
||||
certificates: number;
|
||||
} {
|
||||
return {
|
||||
running: this.serviceRunning,
|
||||
httpPort: this.httpPort,
|
||||
httpsPort: this.httpsPort,
|
||||
routes: this.routes.size,
|
||||
certificates: this.certificates.size,
|
||||
};
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -39,11 +39,11 @@ export class OneboxSslManager {
|
||||
this.acmeEmail = acmeEmail;
|
||||
|
||||
// Get Cloudflare API key (reuse from DNS manager)
|
||||
const cfApiKey = this.database.getSetting('cloudflareAPIKey');
|
||||
const cfApiKey = await this.database.getSecretSetting('cloudflareToken');
|
||||
|
||||
if (!cfApiKey) {
|
||||
logger.warn('Cloudflare API key not configured. SSL certificate management will be limited.');
|
||||
logger.info('Configure with: onebox config set cloudflareAPIKey <key>');
|
||||
logger.info('Configure with: onebox config set cloudflareToken <key>');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,16 +8,17 @@ import { getErrorMessage } from './utils/error.ts';
|
||||
import { Onebox } from './classes/onebox.ts';
|
||||
import { OneboxDaemon } from './classes/daemon.ts';
|
||||
import { OneboxSystemd } from './classes/systemd.ts';
|
||||
import type { IAppVersionConfig } from './classes/appstore-types.ts';
|
||||
|
||||
export async function runCli(): Promise<void> {
|
||||
const args = Deno.args;
|
||||
|
||||
if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
|
||||
if (args.length === 0 || (args.length === 1 && (args[0] === '--help' || args[0] === '-h'))) {
|
||||
printHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.includes('--version') || args.includes('-v')) {
|
||||
if (args.length === 1 && (args[0] === '--version' || args[0] === '-v')) {
|
||||
console.log(`${projectInfo.name} v${projectInfo.version}`);
|
||||
return;
|
||||
}
|
||||
@@ -70,6 +71,11 @@ export async function runCli(): Promise<void> {
|
||||
await handleSslCommand(onebox, subcommand, args.slice(2));
|
||||
break;
|
||||
|
||||
case 'appstore':
|
||||
await handleAppStoreCommand(onebox, subcommand, args.slice(2));
|
||||
break;
|
||||
|
||||
case 'proxy':
|
||||
case 'nginx':
|
||||
await handleNginxCommand(onebox, subcommand, args.slice(2));
|
||||
break;
|
||||
@@ -104,12 +110,11 @@ async function handleServiceCommand(onebox: Onebox, subcommand: string, args: st
|
||||
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;
|
||||
}
|
||||
const envVars = parseEnvArgs(args);
|
||||
|
||||
requireValue(name, 'service name');
|
||||
requireValue(image, '--image');
|
||||
assertValidPort(port, '--port');
|
||||
|
||||
await onebox.services.deployService({ name, image, port, domain, envVars });
|
||||
break;
|
||||
@@ -158,6 +163,7 @@ async function handleRegistryCommand(onebox: Onebox, subcommand: string, args: s
|
||||
const url = getArg(args, '--url');
|
||||
const username = getArg(args, '--username');
|
||||
const password = getArg(args, '--password');
|
||||
requireValue(url, '--url');
|
||||
await onebox.registries.addRegistry(url, username, password);
|
||||
break;
|
||||
}
|
||||
@@ -180,6 +186,76 @@ async function handleRegistryCommand(onebox: Onebox, subcommand: string, args: s
|
||||
}
|
||||
}
|
||||
|
||||
// App Store commands
|
||||
async function handleAppStoreCommand(onebox: Onebox, subcommand: string, args: string[]) {
|
||||
switch (subcommand) {
|
||||
case 'list': {
|
||||
const apps = await onebox.appStore.getApps();
|
||||
logger.table(
|
||||
['ID', 'Name', 'Category', 'Latest'],
|
||||
apps.map((app) => [app.id, app.name, app.category, app.latestVersion])
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'config': {
|
||||
const appId = args[0];
|
||||
requireValue(appId, 'app id');
|
||||
const appMeta = await onebox.appStore.getAppMeta(appId);
|
||||
const version = getArg(args, '--version') || appMeta.latestVersion;
|
||||
const config = await onebox.appStore.getAppVersionConfig(appId, version);
|
||||
console.log(JSON.stringify({ appMeta, version, config }, null, 2));
|
||||
break;
|
||||
}
|
||||
|
||||
case 'install': {
|
||||
const appId = args[0];
|
||||
requireValue(appId, 'app id');
|
||||
|
||||
const appMeta = await onebox.appStore.getAppMeta(appId);
|
||||
const version = getArg(args, '--version') || appMeta.latestVersion;
|
||||
const config = await onebox.appStore.getAppVersionConfig(appId, version);
|
||||
const serviceName = getArg(args, '--name') || appId;
|
||||
const domain = getArg(args, '--domain');
|
||||
const port = parseInt(getArg(args, '--port') || String(config.port), 10);
|
||||
const envVars = getAppStoreEnvVars(config, parseEnvArgs(args));
|
||||
const autoDNS = getBooleanArg(args, '--auto-dns', true);
|
||||
|
||||
requireValue(serviceName, '--name');
|
||||
assertValidPort(port, '--port');
|
||||
if (requiresTemplateValue(envVars, 'SERVICE_DOMAIN')) {
|
||||
requireValue(domain, '--domain');
|
||||
}
|
||||
|
||||
const service = await onebox.services.deployService({
|
||||
name: serviceName,
|
||||
image: config.image,
|
||||
port,
|
||||
domain,
|
||||
autoDNS,
|
||||
envVars,
|
||||
enableMongoDB: Boolean(config.platformRequirements?.mongodb),
|
||||
enableS3: Boolean(config.platformRequirements?.s3),
|
||||
enableClickHouse: Boolean(config.platformRequirements?.clickhouse),
|
||||
enableRedis: Boolean(config.platformRequirements?.redis),
|
||||
enableMariaDB: Boolean(config.platformRequirements?.mariadb),
|
||||
appTemplateId: appId,
|
||||
appTemplateVersion: version,
|
||||
});
|
||||
|
||||
logger.success(`Installed ${appMeta.name} ${version} as ${service.name}`);
|
||||
if (service.domain) {
|
||||
logger.info(`Route: https://${service.domain}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
logger.error(`Unknown appstore subcommand: ${subcommand}`);
|
||||
logger.info('Available: list, config, install');
|
||||
}
|
||||
}
|
||||
|
||||
// DNS commands
|
||||
async function handleDnsCommand(onebox: Onebox, subcommand: string, args: string[]) {
|
||||
switch (subcommand) {
|
||||
@@ -382,7 +458,17 @@ async function handleSystemdCommand(subcommand: string, _args: string[]) {
|
||||
async function handleConfigCommand(onebox: Onebox, subcommand: string, args: string[]) {
|
||||
switch (subcommand) {
|
||||
case 'show': {
|
||||
for (const secretKey of onebox.database.getCanonicalSecretSettingKeys()) {
|
||||
await onebox.database.getSecretSetting(secretKey);
|
||||
}
|
||||
|
||||
const settings = onebox.database.getAllSettings();
|
||||
for (const secretKey of onebox.database.getCanonicalSecretSettingKeys()) {
|
||||
if (await onebox.database.hasSecretSetting(secretKey)) {
|
||||
settings[secretKey] = '********';
|
||||
}
|
||||
}
|
||||
|
||||
logger.table(
|
||||
['Key', 'Value'],
|
||||
Object.entries(settings).map(([k, v]) => [k, v])
|
||||
@@ -391,7 +477,11 @@ async function handleConfigCommand(onebox: Onebox, subcommand: string, args: str
|
||||
}
|
||||
|
||||
case 'set':
|
||||
onebox.database.setSetting(args[0], args[1]);
|
||||
if (onebox.database.isSecretSettingKey(args[0])) {
|
||||
await onebox.database.setSecretSetting(args[0], args[1]);
|
||||
} else {
|
||||
onebox.database.setSetting(args[0], args[1]);
|
||||
}
|
||||
logger.success(`Setting ${args[0]} updated`);
|
||||
break;
|
||||
|
||||
@@ -480,8 +570,106 @@ async function handleUpgradeCommand(): Promise<void> {
|
||||
|
||||
// Helpers
|
||||
function getArg(args: string[], flag: string): string {
|
||||
const arg = args.find((a) => a.startsWith(`${flag}=`));
|
||||
return arg ? arg.split('=')[1] : '';
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
if (arg.startsWith(`${flag}=`)) {
|
||||
return arg.slice(flag.length + 1);
|
||||
}
|
||||
if (arg === flag) {
|
||||
const value = args[i + 1];
|
||||
return value && !value.startsWith('--') ? value : '';
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function getRepeatedArgs(args: string[], flag: string): string[] {
|
||||
const values: string[] = [];
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
if (arg.startsWith(`${flag}=`)) {
|
||||
values.push(arg.slice(flag.length + 1));
|
||||
continue;
|
||||
}
|
||||
if (arg === flag) {
|
||||
const value = args[i + 1];
|
||||
if (value && !value.startsWith('--')) {
|
||||
values.push(value);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function getBooleanArg(args: string[], flag: string, defaultValue: boolean): boolean {
|
||||
if (args.includes(`--no-${flag.slice(2)}`)) {
|
||||
return false;
|
||||
}
|
||||
const value = getArg(args, flag);
|
||||
if (!value) {
|
||||
return args.includes(flag) ? true : defaultValue;
|
||||
}
|
||||
return !['0', 'false', 'no', 'off'].includes(value.toLowerCase());
|
||||
}
|
||||
|
||||
function parseEnvArgs(args: string[]): Record<string, string> {
|
||||
const envVars: Record<string, string> = {};
|
||||
for (const envArg of getRepeatedArgs(args, '--env')) {
|
||||
const separatorIndex = envArg.indexOf('=');
|
||||
if (separatorIndex === -1) {
|
||||
throw new Error(`Invalid --env value '${envArg}'. Expected KEY=VALUE.`);
|
||||
}
|
||||
const key = envArg.slice(0, separatorIndex);
|
||||
const value = envArg.slice(separatorIndex + 1);
|
||||
requireValue(key, '--env key');
|
||||
envVars[key] = value;
|
||||
}
|
||||
return envVars;
|
||||
}
|
||||
|
||||
function getAppStoreEnvVars(
|
||||
configArg: IAppVersionConfig,
|
||||
overridesArg: Record<string, string>,
|
||||
): Record<string, string> {
|
||||
const envVars: Record<string, string> = {};
|
||||
const missingRequiredEnvVars: string[] = [];
|
||||
|
||||
for (const envVar of configArg.envVars || []) {
|
||||
const value = overridesArg[envVar.key] ?? envVar.value ?? '';
|
||||
if (envVar.required && !value) {
|
||||
missingRequiredEnvVars.push(envVar.key);
|
||||
}
|
||||
envVars[envVar.key] = value;
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(overridesArg)) {
|
||||
envVars[key] = value;
|
||||
}
|
||||
|
||||
if (missingRequiredEnvVars.length > 0) {
|
||||
throw new Error(
|
||||
`Missing required app env var(s): ${missingRequiredEnvVars.join(', ')}. Use --env KEY=VALUE.`
|
||||
);
|
||||
}
|
||||
|
||||
return envVars;
|
||||
}
|
||||
|
||||
function requiresTemplateValue(envVarsArg: Record<string, string>, templateNameArg: string): boolean {
|
||||
return Object.values(envVarsArg).some((value) => value.includes(`\${${templateNameArg}}`));
|
||||
}
|
||||
|
||||
function requireValue(valueArg: string | undefined, labelArg: string): asserts valueArg is string {
|
||||
if (!valueArg) {
|
||||
throw new Error(`Missing required ${labelArg}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertValidPort(portArg: number, labelArg: string): void {
|
||||
if (!Number.isInteger(portArg) || portArg <= 0 || portArg > 65535) {
|
||||
throw new Error(`Invalid ${labelArg}: ${portArg}`);
|
||||
}
|
||||
}
|
||||
|
||||
function printHelp(): void {
|
||||
@@ -518,9 +706,13 @@ Commands:
|
||||
ssl list
|
||||
ssl force-renew <domain>
|
||||
|
||||
nginx reload
|
||||
nginx test
|
||||
nginx status
|
||||
appstore list
|
||||
appstore config <app-id> [--version <version>]
|
||||
appstore install <app-id> --name <name> [--domain <domain>] [--version <version>] [--env KEY=VALUE]
|
||||
|
||||
proxy reload # nginx alias is still supported
|
||||
proxy test
|
||||
proxy status
|
||||
|
||||
systemd enable Install and enable systemd service
|
||||
systemd disable Stop, disable, and remove systemd service
|
||||
@@ -554,6 +746,7 @@ Production Workflow:
|
||||
Examples:
|
||||
onebox server --ephemeral # Start dev server
|
||||
onebox service add myapp --image nginx:latest --domain app.example.com --port 80
|
||||
onebox appstore install cloudly --name cloudly --domain cloudly.example.com --env SERVEZONE_ADMINACCOUNT=admin:password
|
||||
onebox registry add --url registry.example.com --username user --password pass
|
||||
onebox systemd enable
|
||||
onebox systemd start
|
||||
|
||||
+36
-1
@@ -26,6 +26,7 @@ import type { TBindValue } from './types.ts';
|
||||
import { logger } from '../logging.ts';
|
||||
import { getErrorMessage } from '../utils/error.ts';
|
||||
import { MigrationRunner } from './migrations/index.ts';
|
||||
import { SecretSettingsManager } from './secret-settings.ts';
|
||||
|
||||
// Import repositories
|
||||
import {
|
||||
@@ -50,6 +51,7 @@ export class OneboxDatabase {
|
||||
private metricsRepo!: MetricsRepository;
|
||||
private platformRepo!: PlatformRepository;
|
||||
private backupRepo!: BackupRepository;
|
||||
public secretSettings!: SecretSettingsManager;
|
||||
|
||||
constructor(dbPath = './.nogit/onebox.db') {
|
||||
this.dbPath = dbPath;
|
||||
@@ -84,6 +86,7 @@ export class OneboxDatabase {
|
||||
this.metricsRepo = new MetricsRepository(queryFn);
|
||||
this.platformRepo = new PlatformRepository(queryFn);
|
||||
this.backupRepo = new BackupRepository(queryFn);
|
||||
this.secretSettings = new SecretSettingsManager(this.authRepo);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to initialize database: ${getErrorMessage(error)}`);
|
||||
throw error;
|
||||
@@ -229,6 +232,14 @@ export class OneboxDatabase {
|
||||
)
|
||||
`);
|
||||
|
||||
this.query(`
|
||||
CREATE TABLE IF NOT EXISTS secret_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
)
|
||||
`);
|
||||
|
||||
// Version table for migrations
|
||||
this.query(`
|
||||
CREATE TABLE IF NOT EXISTS migrations (
|
||||
@@ -333,10 +344,34 @@ export class OneboxDatabase {
|
||||
this.authRepo.setSetting(key, value);
|
||||
}
|
||||
|
||||
deleteSetting(key: string): void {
|
||||
this.authRepo.deleteSetting(key);
|
||||
}
|
||||
|
||||
getAllSettings(): Record<string, string> {
|
||||
return this.authRepo.getAllSettings();
|
||||
}
|
||||
|
||||
async getSecretSetting(key: string): Promise<string | null> {
|
||||
return await this.secretSettings.get(key);
|
||||
}
|
||||
|
||||
async setSecretSetting(key: string, value: string | null): Promise<void> {
|
||||
await this.secretSettings.set(key, value);
|
||||
}
|
||||
|
||||
async hasSecretSetting(key: string): Promise<boolean> {
|
||||
return await this.secretSettings.has(key);
|
||||
}
|
||||
|
||||
isSecretSettingKey(key: string): boolean {
|
||||
return this.secretSettings.isSecretKey(key);
|
||||
}
|
||||
|
||||
getCanonicalSecretSettingKeys(): string[] {
|
||||
return this.secretSettings.getCanonicalKeys();
|
||||
}
|
||||
|
||||
// ============ Users CRUD (delegated to repository) ============
|
||||
|
||||
async createUser(user: Omit<IUser, 'id'>): Promise<IUser> {
|
||||
@@ -419,7 +454,7 @@ export class OneboxDatabase {
|
||||
return this.certificateRepo.getAllDomains();
|
||||
}
|
||||
|
||||
getDomainsByProvider(provider: 'cloudflare' | 'manual'): IDomain[] {
|
||||
getDomainsByProvider(provider: NonNullable<IDomain['dnsProvider']>): IDomain[] {
|
||||
return this.certificateRepo.getDomainsByProvider(provider);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { BaseMigration } from './base-migration.ts';
|
||||
import type { TQueryFunction } from '../types.ts';
|
||||
|
||||
export class Migration015SmartProxyPlatformService extends BaseMigration {
|
||||
readonly version = 15;
|
||||
readonly description = 'Rename Caddy platform service to SmartProxy';
|
||||
|
||||
up(query: TQueryFunction): void {
|
||||
query(
|
||||
`UPDATE platform_services
|
||||
SET name = 'onebox-smartproxy',
|
||||
type = 'smartproxy',
|
||||
container_id = CASE
|
||||
WHEN container_id = 'onebox-caddy' THEN 'onebox-smartproxy'
|
||||
ELSE container_id
|
||||
END,
|
||||
config = ?,
|
||||
updated_at = ?
|
||||
WHERE type = 'caddy'`,
|
||||
[
|
||||
JSON.stringify({
|
||||
image: 'code.foss.global/host.today/ht-docker-smartproxy:latest',
|
||||
port: 80,
|
||||
volumes: [],
|
||||
environment: {},
|
||||
}),
|
||||
Date.now(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import { Migration011ScopeColumns } from './migration-011-scope-columns.ts';
|
||||
import { Migration012GfsRetention } from './migration-012-gfs-retention.ts';
|
||||
import { Migration013AppTemplateVersion } from './migration-013-app-template-version.ts';
|
||||
import { Migration014ContainerArchive } from './migration-014-containerarchive.ts';
|
||||
import { Migration015SmartProxyPlatformService } from './migration-015-smartproxy-platform-service.ts';
|
||||
import type { BaseMigration } from './base-migration.ts';
|
||||
|
||||
export class MigrationRunner {
|
||||
@@ -46,6 +47,7 @@ export class MigrationRunner {
|
||||
new Migration012GfsRetention(),
|
||||
new Migration013AppTemplateVersion(),
|
||||
new Migration014ContainerArchive(),
|
||||
new Migration015SmartProxyPlatformService(),
|
||||
].sort((a, b) => a.version - b.version);
|
||||
}
|
||||
|
||||
|
||||
@@ -70,6 +70,10 @@ export class AuthRepository extends BaseRepository {
|
||||
);
|
||||
}
|
||||
|
||||
deleteSetting(key: string): void {
|
||||
this.query('DELETE FROM settings WHERE key = ?', [key]);
|
||||
}
|
||||
|
||||
getAllSettings(): Record<string, string> {
|
||||
const rows = this.query('SELECT key, value FROM settings');
|
||||
const settings: Record<string, string> = {};
|
||||
@@ -80,4 +84,24 @@ export class AuthRepository extends BaseRepository {
|
||||
}
|
||||
return settings;
|
||||
}
|
||||
|
||||
getSecretSetting(key: string): string | null {
|
||||
const rows = this.query('SELECT value FROM secret_settings WHERE key = ?', [key]);
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
const value = (rows[0] as any).value || rows[0][0];
|
||||
return value ? String(value) : null;
|
||||
}
|
||||
|
||||
setSecretSetting(key: string, value: string): void {
|
||||
const now = Date.now();
|
||||
this.query(
|
||||
'INSERT OR REPLACE INTO secret_settings (key, value, updated_at) VALUES (?, ?, ?)',
|
||||
[key, value, now],
|
||||
);
|
||||
}
|
||||
|
||||
deleteSecretSetting(key: string): void {
|
||||
this.query('DELETE FROM secret_settings WHERE key = ?', [key]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ export class CertificateRepository extends BaseRepository {
|
||||
return rows.map((row) => this.rowToDomain(row));
|
||||
}
|
||||
|
||||
getDomainsByProvider(provider: 'cloudflare' | 'manual'): IDomain[] {
|
||||
getDomainsByProvider(provider: NonNullable<IDomain['dnsProvider']>): IDomain[] {
|
||||
const rows = this.query('SELECT * FROM domains WHERE dns_provider = ? ORDER BY domain ASC', [provider]);
|
||||
return rows.map((row) => this.rowToDomain(row));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { credentialEncryption } from '../classes/encryption.ts';
|
||||
import type { AuthRepository } from './repositories/auth.repository.ts';
|
||||
|
||||
const encryptedSecretPrefix = 'enc:v1:';
|
||||
|
||||
const secretSettingAliases = {
|
||||
backupPassword: ['backup_encryption_password'],
|
||||
cloudflareToken: ['cloudflareAPIKey'],
|
||||
dcrouterGatewayApiToken: ['externalGatewayApiToken'],
|
||||
} as const;
|
||||
|
||||
type TCanonicalSecretSettingKey = keyof typeof secretSettingAliases;
|
||||
|
||||
export class SecretSettingsManager {
|
||||
constructor(private authRepo: AuthRepository) {}
|
||||
|
||||
public isSecretKey(key: string): boolean {
|
||||
return this.resolveCanonicalKey(key) !== null;
|
||||
}
|
||||
|
||||
public getCanonicalKeys(): TCanonicalSecretSettingKey[] {
|
||||
return Object.keys(secretSettingAliases) as TCanonicalSecretSettingKey[];
|
||||
}
|
||||
|
||||
public async get(key: string): Promise<string | null> {
|
||||
const canonicalKey = this.resolveCanonicalKey(key);
|
||||
if (!canonicalKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const candidateKey of this.getCandidateKeys(canonicalKey)) {
|
||||
const secretValue = this.authRepo.getSecretSetting(candidateKey);
|
||||
if (secretValue !== null) {
|
||||
const decryptedValue = await this.decodeStoredValue(secretValue);
|
||||
await this.normalizeStoredSecret(canonicalKey, candidateKey, secretValue, decryptedValue);
|
||||
return decryptedValue;
|
||||
}
|
||||
|
||||
const legacyValue = this.authRepo.getSetting(candidateKey);
|
||||
if (legacyValue !== null) {
|
||||
await this.set(canonicalKey, legacyValue);
|
||||
if (candidateKey !== canonicalKey) {
|
||||
this.authRepo.deleteSetting(candidateKey);
|
||||
}
|
||||
this.authRepo.deleteSetting(canonicalKey);
|
||||
return legacyValue;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public async set(key: string, value: string | null): Promise<void> {
|
||||
const canonicalKey = this.resolveCanonicalKey(key);
|
||||
if (!canonicalKey) {
|
||||
throw new Error(`Unsupported secret setting key: ${key}`);
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
this.clear(canonicalKey);
|
||||
return;
|
||||
}
|
||||
|
||||
const encryptedValue = await credentialEncryption.encrypt({ value });
|
||||
this.authRepo.setSecretSetting(canonicalKey, `${encryptedSecretPrefix}${encryptedValue}`);
|
||||
|
||||
for (const aliasKey of secretSettingAliases[canonicalKey]) {
|
||||
this.authRepo.deleteSecretSetting(aliasKey);
|
||||
this.authRepo.deleteSetting(aliasKey);
|
||||
}
|
||||
|
||||
this.authRepo.deleteSetting(canonicalKey);
|
||||
}
|
||||
|
||||
public async has(key: string): Promise<boolean> {
|
||||
return (await this.get(key)) !== null;
|
||||
}
|
||||
|
||||
public clear(key: string): void {
|
||||
const canonicalKey = this.resolveCanonicalKey(key);
|
||||
if (!canonicalKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.authRepo.deleteSecretSetting(canonicalKey);
|
||||
this.authRepo.deleteSetting(canonicalKey);
|
||||
|
||||
for (const aliasKey of secretSettingAliases[canonicalKey]) {
|
||||
this.authRepo.deleteSecretSetting(aliasKey);
|
||||
this.authRepo.deleteSetting(aliasKey);
|
||||
}
|
||||
}
|
||||
|
||||
private resolveCanonicalKey(key: string): TCanonicalSecretSettingKey | null {
|
||||
if (key in secretSettingAliases) {
|
||||
return key as TCanonicalSecretSettingKey;
|
||||
}
|
||||
|
||||
for (const [canonicalKey, aliases] of Object.entries(secretSettingAliases)) {
|
||||
if ((aliases as readonly string[]).includes(key)) {
|
||||
return canonicalKey as TCanonicalSecretSettingKey;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private getCandidateKeys(canonicalKey: TCanonicalSecretSettingKey): string[] {
|
||||
return [canonicalKey, ...secretSettingAliases[canonicalKey]];
|
||||
}
|
||||
|
||||
private async decodeStoredValue(value: string): Promise<string> {
|
||||
if (value.startsWith(encryptedSecretPrefix)) {
|
||||
const decrypted = await credentialEncryption.decrypt<{ value: string }>(
|
||||
value.slice(encryptedSecretPrefix.length),
|
||||
);
|
||||
return decrypted.value;
|
||||
}
|
||||
|
||||
// Compatibility for any earlier secret_settings rows stored without encryption.
|
||||
return value;
|
||||
}
|
||||
|
||||
private async normalizeStoredSecret(
|
||||
canonicalKey: TCanonicalSecretSettingKey,
|
||||
sourceKey: string,
|
||||
storedValue: string,
|
||||
decryptedValue: string,
|
||||
): Promise<void> {
|
||||
if (sourceKey !== canonicalKey || !storedValue.startsWith(encryptedSecretPrefix)) {
|
||||
await this.set(canonicalKey, decryptedValue);
|
||||
if (sourceKey !== canonicalKey) {
|
||||
this.authRepo.deleteSecretSetting(sourceKey);
|
||||
}
|
||||
}
|
||||
|
||||
this.authRepo.deleteSetting(canonicalKey);
|
||||
for (const aliasKey of secretSettingAliases[canonicalKey]) {
|
||||
this.authRepo.deleteSetting(aliasKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,8 +13,6 @@ export { OneboxDnsManager } from './classes/dns.ts';
|
||||
export { OneboxSslManager } from './classes/ssl.ts';
|
||||
export { OneboxDaemon } from './classes/daemon.ts';
|
||||
export { OneboxSystemd } from './classes/systemd.ts';
|
||||
export { OneboxHttpServer } from './classes/httpserver.ts';
|
||||
export { OneboxApiClient } from './classes/apiclient.ts';
|
||||
|
||||
// Types
|
||||
export * from './types.ts';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as plugins from '../plugins.ts';
|
||||
import { logger } from '../logging.ts';
|
||||
import type { Onebox } from '../classes/onebox.ts';
|
||||
import * as interfaces from '../../ts_interfaces/index.ts';
|
||||
import * as handlers from './handlers/index.ts';
|
||||
import { files as bundledFiles } from '../../ts_bundled/bundle.ts';
|
||||
|
||||
@@ -35,6 +36,7 @@ export class OpsServer {
|
||||
domain: 'localhost',
|
||||
feedMetadata: undefined,
|
||||
bundledContent: bundledFiles,
|
||||
addCustomRoutes: async (typedserver) => this.registerCustomRoutes(typedserver),
|
||||
});
|
||||
|
||||
// Chain typedrouters: server -> opsServer -> individual handlers
|
||||
@@ -71,10 +73,116 @@ export class OpsServer {
|
||||
logger.success('OpsServer TypedRequest handlers initialized');
|
||||
}
|
||||
|
||||
private registerCustomRoutes(typedserver: plugins.typedserver.TypedServer): void {
|
||||
typedserver.addRoute(
|
||||
'/v2',
|
||||
'ALL',
|
||||
async (ctx) => this.oneboxRef.registry.handleRequest(ctx.request),
|
||||
);
|
||||
typedserver.addRoute(
|
||||
'/v2/*',
|
||||
'ALL',
|
||||
async (ctx) => this.oneboxRef.registry.handleRequest(ctx.request),
|
||||
);
|
||||
typedserver.addRoute(
|
||||
'/backups/:backupId/download',
|
||||
'GET',
|
||||
async (ctx) => {
|
||||
const jwt = ctx.query.jwt;
|
||||
if (!jwt) {
|
||||
return new Response('Missing JWT', { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
await this.adminHandler.getVerifiedAdminIdentity({
|
||||
jwt,
|
||||
userId: '',
|
||||
username: '',
|
||||
expiresAt: 0,
|
||||
role: 'user',
|
||||
});
|
||||
} catch {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
const backupId = Number(ctx.params.backupId);
|
||||
if (!Number.isInteger(backupId) || backupId < 1) {
|
||||
return new Response('Invalid backup id', { status: 400 });
|
||||
}
|
||||
|
||||
const backup = this.oneboxRef.database.getBackupById(backupId);
|
||||
if (!backup) {
|
||||
return new Response('Backup not found', { status: 404 });
|
||||
}
|
||||
|
||||
const filename = this.sanitizeDownloadFilename(
|
||||
backup.filename || `${backup.serviceName}-${backup.createdAt}.tar.enc`,
|
||||
);
|
||||
|
||||
let filePath = this.oneboxRef.backupManager.getBackupFilePath(backupId);
|
||||
let shouldCleanup = false;
|
||||
|
||||
if (!filePath) {
|
||||
filePath = await this.oneboxRef.backupManager.getBackupExportPath(backupId);
|
||||
shouldCleanup = !!filePath;
|
||||
}
|
||||
|
||||
if (!filePath) {
|
||||
return new Response('Backup export unavailable', { status: 404 });
|
||||
}
|
||||
|
||||
try {
|
||||
const fileData = await Deno.readFile(filePath);
|
||||
return new Response(fileData, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-type': 'application/octet-stream',
|
||||
'content-disposition': `attachment; filename="${filename}"`,
|
||||
'content-length': String(fileData.byteLength),
|
||||
'cache-control': 'no-store',
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
if (shouldCleanup) {
|
||||
await Deno.remove(filePath).catch(() => {});
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private sanitizeDownloadFilename(filename: string): string {
|
||||
return filename.replace(/["\\\r\n]/g, '_');
|
||||
}
|
||||
|
||||
public async stop() {
|
||||
if (this.server) {
|
||||
await this.server.stop();
|
||||
logger.success('OpsServer stopped');
|
||||
}
|
||||
}
|
||||
|
||||
public async pushDashboardEvent(method: string, payload: unknown): Promise<void> {
|
||||
const typedsocket = (this.server as any)?.typedserver?.typedsocket;
|
||||
if (!typedsocket) {
|
||||
return;
|
||||
}
|
||||
|
||||
const connections = await typedsocket.findAllTargetConnectionsByTag('role', 'ops_dashboard');
|
||||
await Promise.allSettled(
|
||||
connections.map((connection: any) => typedsocket.createTypedRequest(method, connection).fire(payload)),
|
||||
);
|
||||
}
|
||||
|
||||
public async broadcastServiceUpdate(
|
||||
serviceName: string,
|
||||
action: interfaces.requests.IReq_PushServiceUpdate['request']['action'],
|
||||
service?: interfaces.data.IService | null,
|
||||
): Promise<void> {
|
||||
await this.pushDashboardEvent('pushServiceUpdate', {
|
||||
action,
|
||||
serviceName,
|
||||
service: service || undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,12 @@ import * as plugins from '../../plugins.ts';
|
||||
import { logger } from '../../logging.ts';
|
||||
import type { OpsServer } from '../classes.opsserver.ts';
|
||||
import * as interfaces from '../../../ts_interfaces/index.ts';
|
||||
import { hashPassword, verifyPassword } from '../../utils/auth.ts';
|
||||
|
||||
export interface IJwtData {
|
||||
userId: string;
|
||||
username: string;
|
||||
role: 'admin' | 'user';
|
||||
status: 'loggedIn' | 'loggedOut';
|
||||
expiresAt: number;
|
||||
}
|
||||
@@ -18,12 +21,80 @@ export class AdminHandler {
|
||||
}
|
||||
|
||||
public async initialize(): Promise<void> {
|
||||
this.smartjwtInstance = new plugins.smartjwt.SmartJwt();
|
||||
this.smartjwtInstance = new plugins.smartjwt.SmartJwt<IJwtData>();
|
||||
await this.smartjwtInstance.init();
|
||||
await this.smartjwtInstance.createNewKeyPair();
|
||||
this.registerHandlers();
|
||||
}
|
||||
|
||||
private async createIdentityForUser(
|
||||
user: interfaces.data.IUser & { id?: number },
|
||||
expiresAt: number,
|
||||
): Promise<interfaces.data.IIdentity> {
|
||||
const userId = String(user.id || user.username);
|
||||
const jwt = await this.smartjwtInstance.createJWT({
|
||||
userId,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
status: 'loggedIn',
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
return {
|
||||
jwt,
|
||||
userId,
|
||||
username: user.username,
|
||||
expiresAt,
|
||||
role: user.role,
|
||||
};
|
||||
}
|
||||
|
||||
public async getVerifiedIdentity(
|
||||
identityArg: interfaces.data.IIdentity | null | undefined,
|
||||
): Promise<interfaces.data.IIdentity> {
|
||||
if (!identityArg?.jwt) {
|
||||
throw new plugins.typedrequest.TypedResponseError('No identity provided');
|
||||
}
|
||||
|
||||
let jwtData: IJwtData;
|
||||
try {
|
||||
jwtData = await this.smartjwtInstance.verifyJWTAndGetData(identityArg.jwt);
|
||||
} catch {
|
||||
throw new plugins.typedrequest.TypedResponseError('Valid identity required');
|
||||
}
|
||||
|
||||
if (jwtData.expiresAt < Date.now() || jwtData.status !== 'loggedIn') {
|
||||
throw new plugins.typedrequest.TypedResponseError('Valid identity required');
|
||||
}
|
||||
|
||||
const user = this.opsServerRef.oneboxRef.database.getUserByUsername(jwtData.username);
|
||||
if (!user) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Valid identity required');
|
||||
}
|
||||
|
||||
const userId = String(user.id || user.username);
|
||||
if (jwtData.userId !== userId) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Valid identity required');
|
||||
}
|
||||
|
||||
return {
|
||||
jwt: identityArg.jwt,
|
||||
userId,
|
||||
username: user.username,
|
||||
expiresAt: jwtData.expiresAt,
|
||||
role: user.role,
|
||||
};
|
||||
}
|
||||
|
||||
public async getVerifiedAdminIdentity(
|
||||
identityArg: interfaces.data.IIdentity | null | undefined,
|
||||
): Promise<interfaces.data.IIdentity> {
|
||||
const identity = await this.getVerifiedIdentity(identityArg);
|
||||
if (identity.role !== 'admin') {
|
||||
throw new plugins.typedrequest.TypedResponseError('Admin access required');
|
||||
}
|
||||
return identity;
|
||||
}
|
||||
|
||||
private registerHandlers(): void {
|
||||
// Login
|
||||
this.typedrouter.addTypedHandler(
|
||||
@@ -36,30 +107,19 @@ export class AdminHandler {
|
||||
throw new plugins.typedrequest.TypedResponseError('Invalid credentials');
|
||||
}
|
||||
|
||||
// Verify password (base64 comparison to match existing DB scheme)
|
||||
const passwordHash = btoa(dataArg.password);
|
||||
if (passwordHash !== user.passwordHash) {
|
||||
const passwordMatches = await verifyPassword(dataArg.password, user.passwordHash);
|
||||
if (!passwordMatches) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Invalid credentials');
|
||||
}
|
||||
|
||||
const expiresAt = Date.now() + 24 * 3600 * 1000;
|
||||
const userId = String(user.id || user.username);
|
||||
const jwt = await this.smartjwtInstance.createJWT({
|
||||
userId,
|
||||
status: 'loggedIn',
|
||||
expiresAt,
|
||||
});
|
||||
const freshUser = this.opsServerRef.oneboxRef.database.getUserByUsername(user.username) || user;
|
||||
const identity = await this.createIdentityForUser(freshUser, expiresAt);
|
||||
|
||||
logger.info(`User logged in: ${user.username}`);
|
||||
|
||||
return {
|
||||
identity: {
|
||||
jwt,
|
||||
userId,
|
||||
username: user.username,
|
||||
expiresAt,
|
||||
role: user.role,
|
||||
},
|
||||
identity,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||
@@ -84,22 +144,11 @@ export class AdminHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_VerifyIdentity>(
|
||||
'verifyIdentity',
|
||||
async (dataArg) => {
|
||||
if (!dataArg.identity?.jwt) {
|
||||
return { valid: false };
|
||||
}
|
||||
try {
|
||||
const jwtData = await this.smartjwtInstance.verifyJWTAndGetData(dataArg.identity.jwt);
|
||||
if (jwtData.expiresAt < Date.now()) return { valid: false };
|
||||
if (jwtData.status !== 'loggedIn') return { valid: false };
|
||||
const identity = await this.getVerifiedIdentity(dataArg.identity);
|
||||
return {
|
||||
valid: true,
|
||||
identity: {
|
||||
jwt: dataArg.identity.jwt,
|
||||
userId: jwtData.userId,
|
||||
username: dataArg.identity.username,
|
||||
expiresAt: jwtData.expiresAt,
|
||||
role: dataArg.identity.role,
|
||||
},
|
||||
identity,
|
||||
};
|
||||
} catch {
|
||||
return { valid: false };
|
||||
@@ -110,21 +159,21 @@ export class AdminHandler {
|
||||
|
||||
// Change Password
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ChangePassword>(
|
||||
'changePassword',
|
||||
async (dataArg) => {
|
||||
await this.requireValidIdentity(dataArg);
|
||||
const user = this.opsServerRef.oneboxRef.database.getUserByUsername(dataArg.identity.username);
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ChangePassword>(
|
||||
'changePassword',
|
||||
async (dataArg) => {
|
||||
const identity = await this.getVerifiedIdentity(dataArg.identity);
|
||||
const user = this.opsServerRef.oneboxRef.database.getUserByUsername(identity.username);
|
||||
if (!user) {
|
||||
throw new plugins.typedrequest.TypedResponseError('User not found');
|
||||
}
|
||||
|
||||
const currentHash = btoa(dataArg.currentPassword);
|
||||
if (currentHash !== user.passwordHash) {
|
||||
const currentPasswordMatches = await verifyPassword(dataArg.currentPassword, user.passwordHash);
|
||||
if (!currentPasswordMatches) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Current password is incorrect');
|
||||
}
|
||||
|
||||
const newHash = btoa(dataArg.newPassword);
|
||||
const newHash = await hashPassword(dataArg.newPassword);
|
||||
this.opsServerRef.oneboxRef.database.updateUserPassword(user.username, newHash);
|
||||
logger.info(`Password changed for user: ${user.username}`);
|
||||
|
||||
@@ -134,25 +183,13 @@ export class AdminHandler {
|
||||
);
|
||||
}
|
||||
|
||||
private async requireValidIdentity(dataArg: { identity: interfaces.data.IIdentity }): Promise<void> {
|
||||
const passed = await this.validIdentityGuard.exec({ identity: dataArg.identity });
|
||||
if (!passed) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Valid identity required');
|
||||
}
|
||||
}
|
||||
|
||||
// Guard for valid identity
|
||||
public validIdentityGuard = new plugins.smartguard.Guard<{
|
||||
identity: interfaces.data.IIdentity;
|
||||
}>(
|
||||
async (dataArg) => {
|
||||
if (!dataArg.identity?.jwt) return false;
|
||||
try {
|
||||
const jwtData = await this.smartjwtInstance.verifyJWTAndGetData(dataArg.identity.jwt);
|
||||
if (jwtData.expiresAt < Date.now()) return false;
|
||||
if (jwtData.status !== 'loggedIn') return false;
|
||||
if (dataArg.identity.expiresAt !== jwtData.expiresAt) return false;
|
||||
if (dataArg.identity.userId !== jwtData.userId) return false;
|
||||
await this.getVerifiedIdentity(dataArg.identity);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
@@ -166,9 +203,12 @@ export class AdminHandler {
|
||||
identity: interfaces.data.IIdentity;
|
||||
}>(
|
||||
async (dataArg) => {
|
||||
const isValid = await this.validIdentityGuard.exec(dataArg);
|
||||
if (!isValid) return false;
|
||||
return dataArg.identity.role === 'admin';
|
||||
try {
|
||||
const identity = await this.getVerifiedIdentity(dataArg.identity);
|
||||
return identity.role === 'admin';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{ failedHint: 'user is not admin', name: 'adminIdentityGuard' },
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@ import * as plugins from '../../plugins.ts';
|
||||
import { logger } from '../../logging.ts';
|
||||
import type { OpsServer } from '../classes.opsserver.ts';
|
||||
import * as interfaces from '../../../ts_interfaces/index.ts';
|
||||
import { requireValidIdentity } from '../helpers/guards.ts';
|
||||
import { requireAdminIdentity } from '../helpers/guards.ts';
|
||||
|
||||
export class AppStoreHandler {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
@@ -18,7 +18,7 @@ export class AppStoreHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetAppTemplates>(
|
||||
'getAppTemplates',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const apps = await this.opsServerRef.oneboxRef.appStore.getApps();
|
||||
return { apps };
|
||||
},
|
||||
@@ -30,7 +30,7 @@ export class AppStoreHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetAppConfig>(
|
||||
'getAppConfig',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const config = await this.opsServerRef.oneboxRef.appStore.getAppVersionConfig(
|
||||
dataArg.appId,
|
||||
dataArg.version,
|
||||
@@ -46,7 +46,7 @@ export class AppStoreHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetUpgradeableServices>(
|
||||
'getUpgradeableServices',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const services = await this.opsServerRef.oneboxRef.appStore.getUpgradeableServices();
|
||||
return { services };
|
||||
},
|
||||
@@ -58,7 +58,7 @@ export class AppStoreHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpgradeService>(
|
||||
'upgradeService',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
|
||||
const existingService = this.opsServerRef.oneboxRef.database.getServiceByName(dataArg.serviceName);
|
||||
if (!existingService) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as plugins from '../../plugins.ts';
|
||||
import type { OpsServer } from '../classes.opsserver.ts';
|
||||
import * as interfaces from '../../../ts_interfaces/index.ts';
|
||||
import { requireValidIdentity } from '../helpers/guards.ts';
|
||||
import { requireAdminIdentity } from '../helpers/guards.ts';
|
||||
|
||||
export class BackupsHandler {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
@@ -16,7 +16,7 @@ export class BackupsHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetBackups>(
|
||||
'getBackups',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const backups = this.opsServerRef.oneboxRef.backupManager.listBackups();
|
||||
return { backups };
|
||||
},
|
||||
@@ -27,7 +27,7 @@ export class BackupsHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetBackup>(
|
||||
'getBackup',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const backup = this.opsServerRef.oneboxRef.database.getBackupById(dataArg.backupId);
|
||||
if (!backup) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Backup not found');
|
||||
@@ -41,7 +41,7 @@ export class BackupsHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteBackup>(
|
||||
'deleteBackup',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
await this.opsServerRef.oneboxRef.backupManager.deleteBackup(dataArg.backupId);
|
||||
return { ok: true };
|
||||
},
|
||||
@@ -52,7 +52,7 @@ export class BackupsHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RestoreBackup>(
|
||||
'restoreBackup',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const rawResult = await this.opsServerRef.oneboxRef.backupManager.restoreBackup(
|
||||
dataArg.backupId,
|
||||
dataArg.options,
|
||||
@@ -75,7 +75,7 @@ export class BackupsHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DownloadBackup>(
|
||||
'downloadBackup',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const backup = this.opsServerRef.oneboxRef.database.getBackupById(dataArg.backupId);
|
||||
if (!backup) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Backup not found');
|
||||
@@ -83,7 +83,7 @@ export class BackupsHandler {
|
||||
// Return a download URL that the client can fetch directly
|
||||
const filename = backup.filename || `${backup.serviceName}-${backup.createdAt}.tar.enc`;
|
||||
return {
|
||||
downloadUrl: `/api/backups/${dataArg.backupId}/download`,
|
||||
downloadUrl: `/backups/${dataArg.backupId}/download?jwt=${encodeURIComponent(dataArg.identity.jwt)}`,
|
||||
filename,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as plugins from '../../plugins.ts';
|
||||
import type { OpsServer } from '../classes.opsserver.ts';
|
||||
import * as interfaces from '../../../ts_interfaces/index.ts';
|
||||
import { requireValidIdentity } from '../helpers/guards.ts';
|
||||
import { requireAdminIdentity } from '../helpers/guards.ts';
|
||||
|
||||
export class DnsHandler {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
@@ -16,7 +16,7 @@ export class DnsHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetDnsRecords>(
|
||||
'getDnsRecords',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const records = this.opsServerRef.oneboxRef.dns.listDNSRecords();
|
||||
return { records };
|
||||
},
|
||||
@@ -27,7 +27,7 @@ export class DnsHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateDnsRecord>(
|
||||
'createDnsRecord',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
await this.opsServerRef.oneboxRef.dns.addDNSRecord(dataArg.domain, dataArg.value);
|
||||
const records = this.opsServerRef.oneboxRef.dns.listDNSRecords();
|
||||
const record = records.find((r: any) => r.domain === dataArg.domain);
|
||||
@@ -40,7 +40,7 @@ export class DnsHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteDnsRecord>(
|
||||
'deleteDnsRecord',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
await this.opsServerRef.oneboxRef.dns.removeDNSRecord(dataArg.domain);
|
||||
return { ok: true };
|
||||
},
|
||||
@@ -51,7 +51,7 @@ export class DnsHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SyncDns>(
|
||||
'syncDns',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
if (!this.opsServerRef.oneboxRef.dns.isConfigured()) {
|
||||
throw new plugins.typedrequest.TypedResponseError('DNS manager not configured');
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as plugins from '../../plugins.ts';
|
||||
import type { OpsServer } from '../classes.opsserver.ts';
|
||||
import * as interfaces from '../../../ts_interfaces/index.ts';
|
||||
import { requireValidIdentity } from '../helpers/guards.ts';
|
||||
import { requireAdminIdentity } from '../helpers/guards.ts';
|
||||
|
||||
export class DomainsHandler {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
@@ -57,7 +57,7 @@ export class DomainsHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetDomains>(
|
||||
'getDomains',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const domains = this.buildDomainViews();
|
||||
return { domains };
|
||||
},
|
||||
@@ -68,7 +68,7 @@ export class DomainsHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetDomain>(
|
||||
'getDomain',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const domain = this.opsServerRef.oneboxRef.database.getDomainByName(dataArg.domainName);
|
||||
if (!domain) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Domain not found');
|
||||
@@ -87,7 +87,7 @@ export class DomainsHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SyncDomains>(
|
||||
'syncDomains',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
if (!this.opsServerRef.oneboxRef.cloudflareDomainSync) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Cloudflare domain sync not configured');
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import * as plugins from '../../plugins.ts';
|
||||
import { logger } from '../../logging.ts';
|
||||
import type { OpsServer } from '../classes.opsserver.ts';
|
||||
import * as interfaces from '../../../ts_interfaces/index.ts';
|
||||
import { requireValidIdentity } from '../helpers/guards.ts';
|
||||
import { requireAdminIdentity } from '../helpers/guards.ts';
|
||||
|
||||
export class LogsHandler {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
@@ -18,7 +18,7 @@ export class LogsHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetServiceLogStream>(
|
||||
'getServiceLogStream',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
|
||||
const service = this.opsServerRef.oneboxRef.database.getServiceByName(dataArg.serviceName);
|
||||
if (!service) {
|
||||
@@ -99,7 +99,7 @@ export class LogsHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetPlatformServiceLogStream>(
|
||||
'getPlatformServiceLogStream',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
|
||||
const platformService = this.opsServerRef.oneboxRef.database.getPlatformServiceByType(
|
||||
dataArg.serviceType,
|
||||
@@ -160,26 +160,26 @@ export class LogsHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetNetworkLogStream>(
|
||||
'getNetworkLogStream',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
|
||||
const virtualStream = new plugins.typedrequest.VirtualStream<Uint8Array>();
|
||||
const encoder = new TextEncoder();
|
||||
const clientId = crypto.randomUUID();
|
||||
|
||||
// Create a mock WebSocket-like object for the CaddyLogReceiver
|
||||
// Create a mock WebSocket-like object for the proxy log receiver.
|
||||
const mockSocket = {
|
||||
readyState: 1, // WebSocket.OPEN
|
||||
send: (data: string) => {
|
||||
try {
|
||||
virtualStream.sendData(encoder.encode(data));
|
||||
} catch {
|
||||
this.opsServerRef.oneboxRef.caddyLogReceiver.removeClient(clientId);
|
||||
this.opsServerRef.oneboxRef.proxyLogReceiver.removeClient(clientId);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const filter = dataArg.filter || {};
|
||||
this.opsServerRef.oneboxRef.caddyLogReceiver.addClient(
|
||||
this.opsServerRef.oneboxRef.proxyLogReceiver.addClient(
|
||||
clientId,
|
||||
mockSocket as any,
|
||||
filter,
|
||||
@@ -195,7 +195,7 @@ export class LogsHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetEventStream>(
|
||||
'getEventStream',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
|
||||
const virtualStream = new plugins.typedrequest.VirtualStream<Uint8Array>();
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as plugins from '../../plugins.ts';
|
||||
import type { OpsServer } from '../classes.opsserver.ts';
|
||||
import * as interfaces from '../../../ts_interfaces/index.ts';
|
||||
import { requireValidIdentity } from '../helpers/guards.ts';
|
||||
import { requireAdminIdentity } from '../helpers/guards.ts';
|
||||
import type { TPlatformServiceType } from '../../types.ts';
|
||||
|
||||
export class NetworkHandler {
|
||||
@@ -19,7 +19,7 @@ export class NetworkHandler {
|
||||
redis: 6379,
|
||||
postgresql: 5432,
|
||||
rabbitmq: 5672,
|
||||
caddy: 80,
|
||||
smartproxy: 80,
|
||||
clickhouse: 8123,
|
||||
mariadb: 3306,
|
||||
};
|
||||
@@ -31,7 +31,7 @@ export class NetworkHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetNetworkTargets>(
|
||||
'getNetworkTargets',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const targets: interfaces.data.INetworkTarget[] = [];
|
||||
|
||||
// Services
|
||||
@@ -83,9 +83,9 @@ export class NetworkHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetNetworkStats>(
|
||||
'getNetworkStats',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const proxyStatus = this.opsServerRef.oneboxRef.reverseProxy.getStatus() as any;
|
||||
const logReceiverStats = this.opsServerRef.oneboxRef.caddyLogReceiver.getStats();
|
||||
const logReceiverStats = this.opsServerRef.oneboxRef.proxyLogReceiver.getStats();
|
||||
|
||||
return {
|
||||
stats: {
|
||||
@@ -114,8 +114,8 @@ export class NetworkHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetTrafficStats>(
|
||||
'getTrafficStats',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const trafficStats = this.opsServerRef.oneboxRef.caddyLogReceiver.getTrafficStats(60);
|
||||
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const trafficStats = this.opsServerRef.oneboxRef.proxyLogReceiver.getTrafficStats(60);
|
||||
return { stats: trafficStats };
|
||||
},
|
||||
),
|
||||
|
||||
@@ -2,7 +2,7 @@ import * as plugins from '../../plugins.ts';
|
||||
import { logger } from '../../logging.ts';
|
||||
import type { OpsServer } from '../classes.opsserver.ts';
|
||||
import * as interfaces from '../../../ts_interfaces/index.ts';
|
||||
import { requireValidIdentity } from '../helpers/guards.ts';
|
||||
import { requireAdminIdentity } from '../helpers/guards.ts';
|
||||
|
||||
export class PlatformHandler {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
@@ -91,21 +91,8 @@ export class PlatformHandler {
|
||||
line: string,
|
||||
isError: boolean,
|
||||
): void {
|
||||
const typedsocket = (this.opsServerRef.server as any)?.typedserver?.typedsocket;
|
||||
if (!typedsocket) return;
|
||||
|
||||
const entry = this.parseLogLine(line, isError);
|
||||
|
||||
typedsocket.findAllTargetConnectionsByTag('role', 'ops_dashboard')
|
||||
.then((connections: any[]) => {
|
||||
for (const conn of connections) {
|
||||
typedsocket.createTypedRequest<interfaces.requests.IReq_PushPlatformServiceLog>(
|
||||
'pushPlatformServiceLog',
|
||||
conn,
|
||||
).fire({ serviceType, entry }).catch(() => {});
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
void this.opsServerRef.pushDashboardEvent('pushPlatformServiceLog', { serviceType, entry });
|
||||
}
|
||||
|
||||
private pushServiceLogToClients(
|
||||
@@ -113,21 +100,8 @@ export class PlatformHandler {
|
||||
line: string,
|
||||
isError: boolean,
|
||||
): void {
|
||||
const typedsocket = (this.opsServerRef.server as any)?.typedserver?.typedsocket;
|
||||
if (!typedsocket) return;
|
||||
|
||||
const entry = this.parseLogLine(line, isError);
|
||||
|
||||
typedsocket.findAllTargetConnectionsByTag('role', 'ops_dashboard')
|
||||
.then((connections: any[]) => {
|
||||
for (const conn of connections) {
|
||||
typedsocket.createTypedRequest<interfaces.requests.IReq_PushServiceLog>(
|
||||
'pushServiceLog',
|
||||
conn,
|
||||
).fire({ serviceName, entry }).catch(() => {});
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
void this.opsServerRef.pushDashboardEvent('pushServiceLog', { serviceName, entry });
|
||||
}
|
||||
|
||||
private registerHandlers(): void {
|
||||
@@ -136,7 +110,7 @@ export class PlatformHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetPlatformServices>(
|
||||
'getPlatformServices',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const platformServices = this.opsServerRef.oneboxRef.platformServices.getAllPlatformServices();
|
||||
const providers = this.opsServerRef.oneboxRef.platformServices.getAllProviders();
|
||||
|
||||
@@ -145,7 +119,7 @@ export class PlatformHandler {
|
||||
const isCore = 'isCore' in provider && (provider as any).isCore === true;
|
||||
|
||||
let status: string = service?.status || 'not-deployed';
|
||||
if (provider.type === 'caddy') {
|
||||
if (provider.type === 'smartproxy') {
|
||||
const proxyStatus = this.opsServerRef.oneboxRef.reverseProxy.getStatus() as any;
|
||||
status = (proxyStatus.running ?? proxyStatus.http?.running) ? 'running' : 'stopped';
|
||||
}
|
||||
@@ -172,7 +146,7 @@ export class PlatformHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetPlatformService>(
|
||||
'getPlatformService',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const provider = this.opsServerRef.oneboxRef.platformServices.getProvider(dataArg.serviceType);
|
||||
if (!provider) {
|
||||
throw new plugins.typedrequest.TypedResponseError(`Unknown platform service type: ${dataArg.serviceType}`);
|
||||
@@ -182,7 +156,7 @@ export class PlatformHandler {
|
||||
const isCore = 'isCore' in provider && (provider as any).isCore === true;
|
||||
|
||||
let rawStatus: string = service?.status || 'not-deployed';
|
||||
if (dataArg.serviceType === 'caddy') {
|
||||
if (dataArg.serviceType === 'smartproxy') {
|
||||
const proxyStatus = this.opsServerRef.oneboxRef.reverseProxy.getStatus() as any;
|
||||
rawStatus = (proxyStatus.running ?? proxyStatus.http?.running) ? 'running' : 'stopped';
|
||||
}
|
||||
@@ -208,7 +182,7 @@ export class PlatformHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_StartPlatformService>(
|
||||
'startPlatformService',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const provider = this.opsServerRef.oneboxRef.platformServices.getProvider(dataArg.serviceType);
|
||||
if (!provider) {
|
||||
throw new plugins.typedrequest.TypedResponseError(`Unknown platform service type: ${dataArg.serviceType}`);
|
||||
@@ -235,7 +209,7 @@ export class PlatformHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_StopPlatformService>(
|
||||
'stopPlatformService',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const provider = this.opsServerRef.oneboxRef.platformServices.getProvider(dataArg.serviceType);
|
||||
if (!provider) {
|
||||
throw new plugins.typedrequest.TypedResponseError(`Unknown platform service type: ${dataArg.serviceType}`);
|
||||
@@ -268,7 +242,7 @@ export class PlatformHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetPlatformServiceStats>(
|
||||
'getPlatformServiceStats',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const service = this.opsServerRef.oneboxRef.database.getPlatformServiceByType(dataArg.serviceType);
|
||||
if (!service || !service.containerId) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Platform service has no container');
|
||||
@@ -289,7 +263,7 @@ export class PlatformHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetPlatformServiceLogs>(
|
||||
'getPlatformServiceLogs',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const service = this.opsServerRef.oneboxRef.database.getPlatformServiceByType(dataArg.serviceType);
|
||||
if (!service || !service.containerId) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Platform service has no container');
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as plugins from '../../plugins.ts';
|
||||
import type { OpsServer } from '../classes.opsserver.ts';
|
||||
import * as interfaces from '../../../ts_interfaces/index.ts';
|
||||
import { requireValidIdentity } from '../helpers/guards.ts';
|
||||
import { requireAdminIdentity } from '../helpers/guards.ts';
|
||||
|
||||
export class RegistryHandler {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
@@ -17,7 +17,7 @@ export class RegistryHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRegistryTags>(
|
||||
'getRegistryTags',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const tags = await this.opsServerRef.oneboxRef.registry.getImageTags(dataArg.serviceName);
|
||||
return { tags };
|
||||
},
|
||||
@@ -29,7 +29,7 @@ export class RegistryHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRegistryTokens>(
|
||||
'getRegistryTokens',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const rawTokens = this.opsServerRef.oneboxRef.database.getAllRegistryTokens();
|
||||
const now = Date.now();
|
||||
|
||||
@@ -68,7 +68,7 @@ export class RegistryHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateRegistryToken>(
|
||||
'createRegistryToken',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const identity = await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const config = dataArg.tokenConfig;
|
||||
|
||||
// Calculate expiration
|
||||
@@ -95,7 +95,7 @@ export class RegistryHandler {
|
||||
expiresAt,
|
||||
createdAt: now,
|
||||
lastUsedAt: null,
|
||||
createdBy: dataArg.identity.username,
|
||||
createdBy: identity.username,
|
||||
});
|
||||
|
||||
let scopeDisplay: string;
|
||||
@@ -133,7 +133,7 @@ export class RegistryHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteRegistryToken>(
|
||||
'deleteRegistryToken',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const token = this.opsServerRef.oneboxRef.database.getRegistryTokenById(dataArg.tokenId);
|
||||
if (!token) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Token not found');
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as plugins from '../../plugins.ts';
|
||||
import type { OpsServer } from '../classes.opsserver.ts';
|
||||
import * as interfaces from '../../../ts_interfaces/index.ts';
|
||||
import { requireValidIdentity } from '../helpers/guards.ts';
|
||||
import { requireAdminIdentity } from '../helpers/guards.ts';
|
||||
|
||||
export class SchedulesHandler {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
@@ -16,7 +16,7 @@ export class SchedulesHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetBackupSchedules>(
|
||||
'getBackupSchedules',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const schedules = this.opsServerRef.oneboxRef.backupScheduler.getAllSchedules();
|
||||
return { schedules };
|
||||
},
|
||||
@@ -27,7 +27,7 @@ export class SchedulesHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateBackupSchedule>(
|
||||
'createBackupSchedule',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const schedule = await this.opsServerRef.oneboxRef.backupScheduler.createSchedule(
|
||||
dataArg.scheduleConfig,
|
||||
);
|
||||
@@ -40,7 +40,7 @@ export class SchedulesHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetBackupSchedule>(
|
||||
'getBackupSchedule',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const schedule = this.opsServerRef.oneboxRef.backupScheduler.getScheduleById(dataArg.scheduleId);
|
||||
if (!schedule) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Schedule not found');
|
||||
@@ -54,7 +54,7 @@ export class SchedulesHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateBackupSchedule>(
|
||||
'updateBackupSchedule',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const schedule = await this.opsServerRef.oneboxRef.backupScheduler.updateSchedule(
|
||||
dataArg.scheduleId,
|
||||
dataArg.updates,
|
||||
@@ -68,7 +68,7 @@ export class SchedulesHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteBackupSchedule>(
|
||||
'deleteBackupSchedule',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
await this.opsServerRef.oneboxRef.backupScheduler.deleteSchedule(dataArg.scheduleId);
|
||||
return { ok: true };
|
||||
},
|
||||
@@ -79,7 +79,7 @@ export class SchedulesHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_TriggerBackupSchedule>(
|
||||
'triggerBackupSchedule',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
await this.opsServerRef.oneboxRef.backupScheduler.triggerBackup(dataArg.scheduleId);
|
||||
// triggerBackup is void; the backup is created async by the scheduler
|
||||
// Return the most recent backup for the schedule
|
||||
|
||||
@@ -2,7 +2,7 @@ import * as plugins from '../../plugins.ts';
|
||||
import { logger } from '../../logging.ts';
|
||||
import type { OpsServer } from '../classes.opsserver.ts';
|
||||
import * as interfaces from '../../../ts_interfaces/index.ts';
|
||||
import { requireValidIdentity } from '../helpers/guards.ts';
|
||||
import { requireAdminIdentity } from '../helpers/guards.ts';
|
||||
|
||||
export class ServicesHandler {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
@@ -18,7 +18,7 @@ export class ServicesHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetServices>(
|
||||
'getServices',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const services = this.opsServerRef.oneboxRef.services.listServices();
|
||||
return { services };
|
||||
},
|
||||
@@ -30,7 +30,7 @@ export class ServicesHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetService>(
|
||||
'getService',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const service = this.opsServerRef.oneboxRef.services.getService(dataArg.serviceName);
|
||||
if (!service) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Service not found');
|
||||
@@ -45,7 +45,7 @@ export class ServicesHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateService>(
|
||||
'createService',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const service = await this.opsServerRef.oneboxRef.services.deployService(dataArg.serviceConfig);
|
||||
return { service };
|
||||
},
|
||||
@@ -57,7 +57,7 @@ export class ServicesHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateService>(
|
||||
'updateService',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const service = await this.opsServerRef.oneboxRef.services.updateService(
|
||||
dataArg.serviceName,
|
||||
dataArg.updates,
|
||||
@@ -72,7 +72,7 @@ export class ServicesHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteService>(
|
||||
'deleteService',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
await this.opsServerRef.oneboxRef.services.removeService(dataArg.serviceName);
|
||||
return { ok: true };
|
||||
},
|
||||
@@ -84,7 +84,7 @@ export class ServicesHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_StartService>(
|
||||
'startService',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
await this.opsServerRef.oneboxRef.services.startService(dataArg.serviceName);
|
||||
const service = this.opsServerRef.oneboxRef.services.getService(dataArg.serviceName);
|
||||
return { service: service! };
|
||||
@@ -97,7 +97,7 @@ export class ServicesHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_StopService>(
|
||||
'stopService',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
await this.opsServerRef.oneboxRef.services.stopService(dataArg.serviceName);
|
||||
const service = this.opsServerRef.oneboxRef.services.getService(dataArg.serviceName);
|
||||
return { service: service! };
|
||||
@@ -110,7 +110,7 @@ export class ServicesHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RestartService>(
|
||||
'restartService',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
await this.opsServerRef.oneboxRef.services.restartService(dataArg.serviceName);
|
||||
const service = this.opsServerRef.oneboxRef.services.getService(dataArg.serviceName);
|
||||
return { service: service! };
|
||||
@@ -123,7 +123,7 @@ export class ServicesHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetServiceLogs>(
|
||||
'getServiceLogs',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const logs = await this.opsServerRef.oneboxRef.services.getServiceLogs(dataArg.serviceName);
|
||||
return { logs: String(logs) };
|
||||
},
|
||||
@@ -135,7 +135,7 @@ export class ServicesHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetServiceStats>(
|
||||
'getServiceStats',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const service = this.opsServerRef.oneboxRef.services.getService(dataArg.serviceName);
|
||||
if (!service || !service.containerID) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Service has no container');
|
||||
@@ -154,7 +154,7 @@ export class ServicesHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetServiceMetrics>(
|
||||
'getServiceMetrics',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const service = this.opsServerRef.oneboxRef.services.getService(dataArg.serviceName);
|
||||
if (!service || !service.id) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Service not found');
|
||||
@@ -170,7 +170,7 @@ export class ServicesHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetServicePlatformResources>(
|
||||
'getServicePlatformResources',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const rawResources = await this.opsServerRef.oneboxRef.services.getServicePlatformResources(
|
||||
dataArg.serviceName,
|
||||
);
|
||||
@@ -204,7 +204,7 @@ export class ServicesHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetServiceBackups>(
|
||||
'getServiceBackups',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const backups = this.opsServerRef.oneboxRef.backupManager.listBackups(dataArg.serviceName);
|
||||
return { backups };
|
||||
},
|
||||
@@ -216,7 +216,7 @@ export class ServicesHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateServiceBackup>(
|
||||
'createServiceBackup',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const result = await this.opsServerRef.oneboxRef.backupManager.createBackup(dataArg.serviceName);
|
||||
return { backup: result.backup };
|
||||
},
|
||||
@@ -228,7 +228,7 @@ export class ServicesHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetServiceBackupSchedules>(
|
||||
'getServiceBackupSchedules',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const service = this.opsServerRef.oneboxRef.services.getService(dataArg.serviceName);
|
||||
if (!service) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Service not found');
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import * as plugins from '../../plugins.ts';
|
||||
import type { OpsServer } from '../classes.opsserver.ts';
|
||||
import * as interfaces from '../../../ts_interfaces/index.ts';
|
||||
import { requireValidIdentity } from '../helpers/guards.ts';
|
||||
import { requireAdminIdentity } from '../helpers/guards.ts';
|
||||
import { logger } from '../../logging.ts';
|
||||
import { getErrorMessage } from '../../utils/error.ts';
|
||||
|
||||
export class SettingsHandler {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
@@ -11,13 +13,20 @@ export class SettingsHandler {
|
||||
this.registerHandlers();
|
||||
}
|
||||
|
||||
private getSettingsObject(): interfaces.data.ISettings {
|
||||
private async getSettingsObject(): Promise<interfaces.data.ISettings> {
|
||||
const db = this.opsServerRef.oneboxRef.database;
|
||||
const settingsMap = db.getAllSettings(); // Returns Record<string, string>
|
||||
const cloudflareToken = await db.getSecretSetting('cloudflareToken');
|
||||
const dcrouterGatewayApiToken = await db.getSecretSetting('dcrouterGatewayApiToken');
|
||||
const settingsMap = db.getAllSettings();
|
||||
|
||||
return {
|
||||
cloudflareToken: settingsMap['cloudflareToken'] || '',
|
||||
cloudflareToken: cloudflareToken || '',
|
||||
cloudflareZoneId: settingsMap['cloudflareZoneId'] || '',
|
||||
dcrouterGatewayUrl: settingsMap['dcrouterGatewayUrl'] || '',
|
||||
dcrouterGatewayApiToken: dcrouterGatewayApiToken || '',
|
||||
dcrouterWorkHosterId: settingsMap['dcrouterWorkHosterId'] || '',
|
||||
dcrouterTargetHost: settingsMap['dcrouterTargetHost'] || '',
|
||||
dcrouterTargetPort: parseInt(settingsMap['dcrouterTargetPort'] || '0', 10),
|
||||
autoRenewCerts: settingsMap['autoRenewCerts'] === 'true',
|
||||
renewalThreshold: parseInt(settingsMap['renewalThreshold'] || '30', 10),
|
||||
acmeEmail: settingsMap['acmeEmail'] || '',
|
||||
@@ -32,8 +41,8 @@ export class SettingsHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSettings>(
|
||||
'getSettings',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const settings = this.getSettingsObject();
|
||||
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const settings = await this.getSettingsObject();
|
||||
return { settings };
|
||||
},
|
||||
),
|
||||
@@ -43,18 +52,28 @@ export class SettingsHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateSettings>(
|
||||
'updateSettings',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const db = this.opsServerRef.oneboxRef.database;
|
||||
const updates = dataArg.settings;
|
||||
|
||||
// Store each setting as key-value pair
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
if (value !== undefined) {
|
||||
db.setSetting(key, String(value));
|
||||
if (db.isSecretSettingKey(key)) {
|
||||
await db.setSecretSetting(key, String(value));
|
||||
} else {
|
||||
db.setSetting(key, String(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const settings = this.getSettingsObject();
|
||||
if (this.hasExternalGatewaySetting(updates)) {
|
||||
this.refreshExternalGateway().catch((error) => {
|
||||
logger.warn(`External gateway settings refresh failed: ${getErrorMessage(error)}`);
|
||||
});
|
||||
}
|
||||
|
||||
const settings = await this.getSettingsObject();
|
||||
return { settings };
|
||||
},
|
||||
),
|
||||
@@ -64,8 +83,8 @@ export class SettingsHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SetBackupPassword>(
|
||||
'setBackupPassword',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
this.opsServerRef.oneboxRef.database.setSetting('backupPassword', dataArg.password);
|
||||
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
await this.opsServerRef.oneboxRef.database.setSecretSetting('backupPassword', dataArg.password);
|
||||
return { ok: true };
|
||||
},
|
||||
),
|
||||
@@ -75,12 +94,35 @@ export class SettingsHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetBackupPasswordStatus>(
|
||||
'getBackupPasswordStatus',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const backupPassword = this.opsServerRef.oneboxRef.database.getSetting('backupPassword');
|
||||
const isConfigured = !!backupPassword;
|
||||
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const isConfigured = await this.opsServerRef.oneboxRef.database.hasSecretSetting('backupPassword');
|
||||
return { status: { isConfigured } };
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private hasExternalGatewaySetting(settings: Partial<interfaces.data.ISettings>): boolean {
|
||||
return [
|
||||
'dcrouterGatewayUrl',
|
||||
'dcrouterGatewayApiToken',
|
||||
'dcrouterWorkHosterId',
|
||||
'dcrouterTargetHost',
|
||||
'dcrouterTargetPort',
|
||||
].some((key) => Object.prototype.hasOwnProperty.call(settings, key));
|
||||
}
|
||||
|
||||
private async refreshExternalGateway(): Promise<void> {
|
||||
const onebox = this.opsServerRef.oneboxRef;
|
||||
await onebox.externalGateway.syncDomains();
|
||||
|
||||
const services = onebox.database.getAllServices().filter((service) => service.domain);
|
||||
await Promise.all(services.map(async (service) => {
|
||||
try {
|
||||
await onebox.externalGateway.syncServiceRoute(service);
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to sync external gateway route for ${service.domain}: ${getErrorMessage(error)}`);
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as plugins from '../../plugins.ts';
|
||||
import type { OpsServer } from '../classes.opsserver.ts';
|
||||
import * as interfaces from '../../../ts_interfaces/index.ts';
|
||||
import { requireValidIdentity } from '../helpers/guards.ts';
|
||||
import { requireAdminIdentity } from '../helpers/guards.ts';
|
||||
|
||||
export class SslHandler {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
@@ -16,7 +16,7 @@ export class SslHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ObtainCertificate>(
|
||||
'obtainCertificate',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
await this.opsServerRef.oneboxRef.ssl.obtainCertificate(dataArg.domain, false);
|
||||
const certificate = this.opsServerRef.oneboxRef.ssl.getCertificate(dataArg.domain);
|
||||
return { certificate: certificate as unknown as interfaces.data.ICertificate };
|
||||
@@ -28,7 +28,7 @@ export class SslHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ListCertificates>(
|
||||
'listCertificates',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const certificates = this.opsServerRef.oneboxRef.ssl.listCertificates();
|
||||
return { certificates: certificates as unknown as interfaces.data.ICertificate[] };
|
||||
},
|
||||
@@ -39,7 +39,7 @@ export class SslHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetCertificate>(
|
||||
'getCertificate',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const certificate = this.opsServerRef.oneboxRef.ssl.getCertificate(dataArg.domain);
|
||||
if (!certificate) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Certificate not found');
|
||||
@@ -53,7 +53,7 @@ export class SslHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RenewCertificate>(
|
||||
'renewCertificate',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
await this.opsServerRef.oneboxRef.ssl.renewCertificate(dataArg.domain);
|
||||
const certificate = this.opsServerRef.oneboxRef.ssl.getCertificate(dataArg.domain);
|
||||
return { certificate: certificate as unknown as interfaces.data.ICertificate };
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as plugins from '../../plugins.ts';
|
||||
import type { OpsServer } from '../classes.opsserver.ts';
|
||||
import * as interfaces from '../../../ts_interfaces/index.ts';
|
||||
import { requireValidIdentity } from '../helpers/guards.ts';
|
||||
import { requireAdminIdentity } from '../helpers/guards.ts';
|
||||
|
||||
export class StatusHandler {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
@@ -16,7 +16,7 @@ export class StatusHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSystemStatus>(
|
||||
'getSystemStatus',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const status = await this.opsServerRef.oneboxRef.getSystemStatus();
|
||||
return { status: status as unknown as interfaces.data.ISystemStatus };
|
||||
},
|
||||
|
||||
@@ -2,7 +2,7 @@ import * as plugins from '../../plugins.ts';
|
||||
import { logger } from '../../logging.ts';
|
||||
import type { OpsServer } from '../classes.opsserver.ts';
|
||||
import * as interfaces from '../../../ts_interfaces/index.ts';
|
||||
import { requireValidIdentity } from '../helpers/guards.ts';
|
||||
import { requireAdminIdentity } from '../helpers/guards.ts';
|
||||
import { getErrorMessage } from '../../utils/error.ts';
|
||||
|
||||
export class WorkspaceHandler {
|
||||
@@ -30,7 +30,7 @@ export class WorkspaceHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_WorkspaceReadFile>(
|
||||
'workspaceReadFile',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const containerId = await this.resolveContainerId(dataArg.serviceName);
|
||||
const result = await this.opsServerRef.oneboxRef.docker.execInContainer(
|
||||
containerId,
|
||||
@@ -49,7 +49,7 @@ export class WorkspaceHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_WorkspaceWriteFile>(
|
||||
'workspaceWriteFile',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const containerId = await this.resolveContainerId(dataArg.serviceName);
|
||||
// Use sh -c with printf to write content (handles special characters)
|
||||
const escaped = dataArg.content.replace(/'/g, "'\\''");
|
||||
@@ -70,7 +70,7 @@ export class WorkspaceHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_WorkspaceReadDir>(
|
||||
'workspaceReadDir',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const containerId = await this.resolveContainerId(dataArg.serviceName);
|
||||
// Use ls with -1 -F to get entries with type indicators (/ for dirs)
|
||||
const result = await this.opsServerRef.oneboxRef.docker.execInContainer(
|
||||
@@ -103,7 +103,7 @@ export class WorkspaceHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_WorkspaceMkdir>(
|
||||
'workspaceMkdir',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const containerId = await this.resolveContainerId(dataArg.serviceName);
|
||||
const result = await this.opsServerRef.oneboxRef.docker.execInContainer(
|
||||
containerId,
|
||||
@@ -122,7 +122,7 @@ export class WorkspaceHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_WorkspaceRm>(
|
||||
'workspaceRm',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const containerId = await this.resolveContainerId(dataArg.serviceName);
|
||||
const args = dataArg.recursive ? ['rm', '-rf', dataArg.path] : ['rm', '-f', dataArg.path];
|
||||
const result = await this.opsServerRef.oneboxRef.docker.execInContainer(
|
||||
@@ -142,7 +142,7 @@ export class WorkspaceHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_WorkspaceExists>(
|
||||
'workspaceExists',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const containerId = await this.resolveContainerId(dataArg.serviceName);
|
||||
const result = await this.opsServerRef.oneboxRef.docker.execInContainer(
|
||||
containerId,
|
||||
@@ -158,7 +158,7 @@ export class WorkspaceHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_WorkspaceExec>(
|
||||
'workspaceExec',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const containerId = await this.resolveContainerId(dataArg.serviceName);
|
||||
const cmd = dataArg.args
|
||||
? [dataArg.command, ...dataArg.args]
|
||||
|
||||
@@ -5,25 +5,13 @@ import * as interfaces from '../../../ts_interfaces/index.ts';
|
||||
export async function requireValidIdentity<T extends { identity?: interfaces.data.IIdentity }>(
|
||||
adminHandler: AdminHandler,
|
||||
dataArg: T,
|
||||
): Promise<void> {
|
||||
if (!dataArg.identity) {
|
||||
throw new plugins.typedrequest.TypedResponseError('No identity provided');
|
||||
}
|
||||
const passed = await adminHandler.validIdentityGuard.exec({ identity: dataArg.identity });
|
||||
if (!passed) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Valid identity required');
|
||||
}
|
||||
): Promise<interfaces.data.IIdentity> {
|
||||
return await adminHandler.getVerifiedIdentity(dataArg.identity);
|
||||
}
|
||||
|
||||
export async function requireAdminIdentity<T extends { identity?: interfaces.data.IIdentity }>(
|
||||
adminHandler: AdminHandler,
|
||||
dataArg: T,
|
||||
): Promise<void> {
|
||||
if (!dataArg.identity) {
|
||||
throw new plugins.typedrequest.TypedResponseError('No identity provided');
|
||||
}
|
||||
const passed = await adminHandler.adminIdentityGuard.exec({ identity: dataArg.identity });
|
||||
if (!passed) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Admin access required');
|
||||
}
|
||||
): Promise<interfaces.data.IIdentity> {
|
||||
return await adminHandler.getVerifiedAdminIdentity(dataArg.identity);
|
||||
}
|
||||
|
||||
+14
-4
@@ -37,14 +37,24 @@ export { smartregistry };
|
||||
import * as smartstorage from '@push.rocks/smartstorage';
|
||||
export { smartstorage };
|
||||
|
||||
// AWS S3 client for S3-compatible object operations
|
||||
import {
|
||||
S3Client,
|
||||
ListObjectsV2Command,
|
||||
GetObjectCommand,
|
||||
PutObjectCommand,
|
||||
} from 'npm:@aws-sdk/client-s3@3.1009.0';
|
||||
export const awsS3 = {
|
||||
S3Client,
|
||||
ListObjectsV2Command,
|
||||
GetObjectCommand,
|
||||
PutObjectCommand,
|
||||
};
|
||||
|
||||
// Task scheduling and cron jobs
|
||||
import * as taskbuffer from '@push.rocks/taskbuffer';
|
||||
export { taskbuffer };
|
||||
|
||||
// Crypto utilities (for password hashing, encryption)
|
||||
import * as bcrypt from 'https://deno.land/x/bcrypt@v0.4.1/mod.ts';
|
||||
export { bcrypt };
|
||||
|
||||
// JWT for authentication
|
||||
import * as jwt from 'https://deno.land/x/djwt@v3.0.2/mod.ts';
|
||||
export { jwt};
|
||||
|
||||
+13
-6
@@ -78,7 +78,7 @@ export interface ITokenCreatedResponse {
|
||||
}
|
||||
|
||||
// Platform service types
|
||||
export type TPlatformServiceType = 'mongodb' | 'minio' | 'redis' | 'postgresql' | 'rabbitmq' | 'caddy' | 'clickhouse' | 'mariadb';
|
||||
export type TPlatformServiceType = 'mongodb' | 'minio' | 'redis' | 'postgresql' | 'rabbitmq' | 'smartproxy' | 'clickhouse' | 'mariadb';
|
||||
export type TPlatformResourceType = 'database' | 'bucket' | 'cache' | 'queue';
|
||||
export type TPlatformServiceStatus = 'stopped' | 'starting' | 'running' | 'stopping' | 'failed';
|
||||
|
||||
@@ -148,7 +148,7 @@ export interface INginxConfig {
|
||||
export interface IDomain {
|
||||
id?: number;
|
||||
domain: string;
|
||||
dnsProvider: 'cloudflare' | 'manual' | null;
|
||||
dnsProvider: 'cloudflare' | 'manual' | 'dcrouter' | null;
|
||||
cloudflareZoneId?: string;
|
||||
isObsolete: boolean;
|
||||
defaultWildcard: boolean;
|
||||
@@ -257,14 +257,21 @@ export interface ISetting {
|
||||
// Application settings
|
||||
export interface IAppSettings {
|
||||
serverIP?: string;
|
||||
cloudflareAPIKey?: string;
|
||||
cloudflareEmail?: string;
|
||||
cloudflareZoneID?: string;
|
||||
cloudflareToken?: string;
|
||||
cloudflareZoneId?: string;
|
||||
dcrouterGatewayUrl?: string;
|
||||
dcrouterGatewayApiToken?: string;
|
||||
dcrouterWorkHosterId?: string;
|
||||
dcrouterTargetHost?: string;
|
||||
dcrouterTargetPort?: number;
|
||||
acmeEmail?: string;
|
||||
nginxConfigDir?: string;
|
||||
dataDir?: string;
|
||||
httpPort?: number;
|
||||
httpsPort?: number;
|
||||
metricsInterval?: number;
|
||||
autoRenewCerts?: boolean;
|
||||
renewalThreshold?: number;
|
||||
forceHttps?: boolean;
|
||||
logRetentionDays?: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
const pbkdf2HashPattern = /^pbkdf2-sha256\$(\d+)\$([A-Za-z0-9+/=]+)\$([A-Za-z0-9+/=]+)$/;
|
||||
const pbkdf2Iterations = 210_000;
|
||||
const pbkdf2KeyLengthBits = 256;
|
||||
|
||||
const bytesToBase64 = (bytesArg: Uint8Array): string => {
|
||||
let binary = '';
|
||||
for (const byte of bytesArg) {
|
||||
binary += String.fromCharCode(byte);
|
||||
}
|
||||
return btoa(binary);
|
||||
};
|
||||
|
||||
const base64ToBytes = (base64Arg: string): Uint8Array => {
|
||||
const binary = atob(base64Arg);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
};
|
||||
|
||||
const timingSafeEqual = (aArg: Uint8Array, bArg: Uint8Array): boolean => {
|
||||
if (aArg.length !== bArg.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let diff = 0;
|
||||
for (let i = 0; i < aArg.length; i++) {
|
||||
diff |= aArg[i] ^ bArg[i];
|
||||
}
|
||||
return diff === 0;
|
||||
};
|
||||
|
||||
const toArrayBuffer = (bytesArg: Uint8Array): ArrayBuffer => {
|
||||
return bytesArg.buffer.slice(
|
||||
bytesArg.byteOffset,
|
||||
bytesArg.byteOffset + bytesArg.byteLength,
|
||||
) as ArrayBuffer;
|
||||
};
|
||||
|
||||
const derivePasswordHash = async (
|
||||
passwordArg: string,
|
||||
saltArg: Uint8Array,
|
||||
iterationsArg: number,
|
||||
): Promise<Uint8Array> => {
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
new TextEncoder().encode(passwordArg),
|
||||
'PBKDF2',
|
||||
false,
|
||||
['deriveBits'],
|
||||
);
|
||||
|
||||
const bits = await crypto.subtle.deriveBits(
|
||||
{
|
||||
name: 'PBKDF2',
|
||||
hash: 'SHA-256',
|
||||
salt: toArrayBuffer(saltArg),
|
||||
iterations: iterationsArg,
|
||||
},
|
||||
key,
|
||||
pbkdf2KeyLengthBits,
|
||||
);
|
||||
|
||||
return new Uint8Array(bits);
|
||||
};
|
||||
|
||||
export function isPbkdf2Hash(passwordHash: string): boolean {
|
||||
return pbkdf2HashPattern.test(passwordHash);
|
||||
}
|
||||
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
// Use Web Crypto only so compiled binaries do not depend on external worker files.
|
||||
const salt = crypto.getRandomValues(new Uint8Array(16));
|
||||
const hash = await derivePasswordHash(password, salt, pbkdf2Iterations);
|
||||
return `pbkdf2-sha256$${pbkdf2Iterations}$${bytesToBase64(salt)}$${bytesToBase64(hash)}`;
|
||||
}
|
||||
|
||||
export async function verifyPassword(password: string, passwordHash: string): Promise<boolean> {
|
||||
if (!passwordHash) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const pbkdf2Match = passwordHash.match(pbkdf2HashPattern);
|
||||
if (pbkdf2Match) {
|
||||
const iterations = Number(pbkdf2Match[1]);
|
||||
const salt = base64ToBytes(pbkdf2Match[2]);
|
||||
const expectedHash = base64ToBytes(pbkdf2Match[3]);
|
||||
const actualHash = await derivePasswordHash(password, salt, iterations);
|
||||
return timingSafeEqual(actualHash, expectedHash);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -5,7 +5,7 @@
|
||||
export interface IDomain {
|
||||
id?: number;
|
||||
domain: string;
|
||||
dnsProvider: 'cloudflare' | 'manual' | null;
|
||||
dnsProvider: 'cloudflare' | 'manual' | 'dcrouter' | null;
|
||||
cloudflareZoneId?: string;
|
||||
isObsolete: boolean;
|
||||
defaultWildcard: boolean;
|
||||
|
||||
@@ -41,7 +41,7 @@ export interface ITrafficStats {
|
||||
errorRate: number;
|
||||
}
|
||||
|
||||
export interface ICaddyAccessLog {
|
||||
export interface IProxyAccessLog {
|
||||
ts: number;
|
||||
request: {
|
||||
remote_ip: string;
|
||||
@@ -59,6 +59,6 @@ export interface INetworkLogMessage {
|
||||
type: 'connected' | 'access_log' | 'filter_updated';
|
||||
clientId?: string;
|
||||
filter?: { domain?: string; sampleRate?: number };
|
||||
data?: ICaddyAccessLog;
|
||||
data?: IProxyAccessLog;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Platform service data shapes for Onebox
|
||||
*/
|
||||
|
||||
export type TPlatformServiceType = 'mongodb' | 'minio' | 'redis' | 'postgresql' | 'rabbitmq' | 'caddy' | 'clickhouse' | 'mariadb';
|
||||
export type TPlatformServiceType = 'mongodb' | 'minio' | 'redis' | 'postgresql' | 'rabbitmq' | 'smartproxy' | 'clickhouse' | 'mariadb';
|
||||
export type TPlatformServiceStatus = 'not-deployed' | 'stopped' | 'starting' | 'running' | 'stopping' | 'failed';
|
||||
export type TPlatformResourceType = 'database' | 'bucket' | 'cache' | 'queue';
|
||||
|
||||
|
||||
@@ -5,6 +5,11 @@
|
||||
export interface ISettings {
|
||||
cloudflareToken: string;
|
||||
cloudflareZoneId: string;
|
||||
dcrouterGatewayUrl: string;
|
||||
dcrouterGatewayApiToken: string;
|
||||
dcrouterWorkHosterId: string;
|
||||
dcrouterTargetHost: string;
|
||||
dcrouterTargetPort: number;
|
||||
autoRenewCerts: boolean;
|
||||
renewalThreshold: number;
|
||||
acmeEmail: string;
|
||||
|
||||
@@ -228,3 +228,16 @@ export interface IReq_PushServiceLog extends plugins.typedrequestInterfaces.impl
|
||||
};
|
||||
response: {};
|
||||
}
|
||||
|
||||
export interface IReq_PushServiceUpdate extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_PushServiceUpdate
|
||||
> {
|
||||
method: 'pushServiceUpdate';
|
||||
request: {
|
||||
action: 'created' | 'updated' | 'deleted' | 'started' | 'stopped';
|
||||
serviceName: string;
|
||||
service?: data.IService;
|
||||
};
|
||||
response: {};
|
||||
}
|
||||
|
||||
@@ -985,6 +985,56 @@ startAutoRefresh();
|
||||
let socketClient: InstanceType<typeof plugins.typedsocket.TypedSocket> | null = null;
|
||||
const socketRouter = new plugins.domtools.plugins.typedrequest.TypedRouter();
|
||||
|
||||
const upsertService = (
|
||||
services: interfaces.data.IService[],
|
||||
service: interfaces.data.IService,
|
||||
): interfaces.data.IService[] => {
|
||||
const existingIndex = services.findIndex((item) => item.name === service.name);
|
||||
if (existingIndex === -1) {
|
||||
return [...services, service];
|
||||
}
|
||||
|
||||
const updatedServices = [...services];
|
||||
updatedServices[existingIndex] = service;
|
||||
return updatedServices;
|
||||
};
|
||||
|
||||
socketRouter.addTypedHandler(
|
||||
new plugins.domtools.plugins.typedrequest.TypedHandler<interfaces.requests.IReq_PushServiceUpdate>(
|
||||
'pushServiceUpdate',
|
||||
async (dataArg) => {
|
||||
const state = servicesStatePart.getState();
|
||||
let services = state.services;
|
||||
let currentService = state.currentService;
|
||||
let currentServiceLogs = state.currentServiceLogs;
|
||||
let currentServiceStats = state.currentServiceStats;
|
||||
|
||||
if (dataArg.action === 'deleted') {
|
||||
services = services.filter((service) => service.name !== dataArg.serviceName);
|
||||
if (currentService?.name === dataArg.serviceName) {
|
||||
currentService = null;
|
||||
currentServiceLogs = [];
|
||||
currentServiceStats = null;
|
||||
}
|
||||
} else if (dataArg.service) {
|
||||
services = upsertService(services, dataArg.service);
|
||||
if (currentService?.name === dataArg.service.name) {
|
||||
currentService = dataArg.service;
|
||||
}
|
||||
}
|
||||
|
||||
servicesStatePart.setState({
|
||||
...state,
|
||||
services,
|
||||
currentService,
|
||||
currentServiceLogs,
|
||||
currentServiceStats,
|
||||
});
|
||||
return {};
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Handle server-pushed platform service log entries
|
||||
socketRouter.addTypedHandler(
|
||||
new plugins.domtools.plugins.typedrequest.TypedHandler<interfaces.requests.IReq_PushPlatformServiceLog>(
|
||||
|
||||
@@ -42,6 +42,9 @@ export class ObViewAppStore extends DeesElement {
|
||||
@state()
|
||||
accessor serviceName: string = '';
|
||||
|
||||
@state()
|
||||
accessor serviceDomain: string = '';
|
||||
|
||||
@state()
|
||||
accessor loading: boolean = false;
|
||||
|
||||
@@ -474,6 +477,18 @@ export class ObViewAppStore extends DeesElement {
|
||||
Lowercase letters, numbers, and hyphens only.
|
||||
</div>
|
||||
|
||||
<div class="section-label" style="margin-top: 18px;">Domain</div>
|
||||
<input
|
||||
class="name-input"
|
||||
type="text"
|
||||
.value=${this.serviceDomain}
|
||||
placeholder="e.g. cloudly.example.com"
|
||||
@input=${(e: Event) => this.handleServiceDomainChange((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
<div style="font-size: 12px; color: var(--ci-shade-4, #71717a); margin-top: 6px;">
|
||||
Onebox routes this domain to the deployed app. Required when the app uses SERVICE_DOMAIN.
|
||||
</div>
|
||||
|
||||
<div class="actions-row">
|
||||
<button class="btn btn-secondary" @click=${() => { this.currentView = 'grid'; }}>Cancel</button>
|
||||
<button class="btn btn-primary" @click=${() => this.handleDeploy()}>
|
||||
@@ -560,6 +575,7 @@ export class ObViewAppStore extends DeesElement {
|
||||
required: ev.required,
|
||||
platformInjected: ev.value?.includes('${') || false,
|
||||
}));
|
||||
this.serviceDomain = '';
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch app config:', err);
|
||||
}
|
||||
@@ -571,14 +587,40 @@ export class ObViewAppStore extends DeesElement {
|
||||
this.editableEnvVars = updated;
|
||||
}
|
||||
|
||||
private handleServiceDomainChange(valueArg: string) {
|
||||
this.serviceDomain = this.normalizeDomain(valueArg);
|
||||
}
|
||||
|
||||
private normalizeDomain(valueArg: string) {
|
||||
return valueArg.trim().replace(/^https?:\/\//, '').replace(/\/$/, '');
|
||||
}
|
||||
|
||||
private async handleDeploy() {
|
||||
const app = this.selectedApp;
|
||||
const config = this.selectedAppConfig;
|
||||
if (!app || !config) return;
|
||||
const missingRequiredEnvVars = this.editableEnvVars.filter((envVarArg) => {
|
||||
return envVarArg.required && !envVarArg.platformInjected && !envVarArg.value.trim();
|
||||
});
|
||||
if (missingRequiredEnvVars.length > 0) {
|
||||
console.error(
|
||||
`Missing required environment variables: ${missingRequiredEnvVars
|
||||
.map((envVarArg) => envVarArg.key)
|
||||
.join(', ')}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const needsServiceDomain = (config.envVars || []).some((envVarArg) => {
|
||||
return envVarArg.value?.includes('${SERVICE_DOMAIN}');
|
||||
});
|
||||
if (needsServiceDomain && !this.serviceDomain) {
|
||||
console.error('A domain is required for this app.');
|
||||
return;
|
||||
}
|
||||
|
||||
const envVars: Record<string, string> = {};
|
||||
for (const ev of this.editableEnvVars) {
|
||||
if (ev.key && ev.value && !ev.platformInjected) {
|
||||
if (ev.key && ev.value) {
|
||||
envVars[ev.key] = ev.value;
|
||||
}
|
||||
}
|
||||
@@ -588,6 +630,7 @@ export class ObViewAppStore extends DeesElement {
|
||||
name: this.serviceName || app.id,
|
||||
image: config.image,
|
||||
port: config.port || 80,
|
||||
domain: this.serviceDomain || undefined,
|
||||
envVars,
|
||||
enableMongoDB: platformReqs.mongodb || false,
|
||||
enableS3: platformReqs.s3 || false,
|
||||
|
||||
@@ -609,7 +609,7 @@ export class ObViewServices extends DeesElement {
|
||||
mongodb: { host: 'onebox-mongodb', port: 27017, version: '4.4', config: { engine: 'WiredTiger', authEnabled: true } },
|
||||
minio: { host: 'onebox-minio', port: 9000, version: 'latest', config: { consolePort: 9001, region: 'us-east-1' } },
|
||||
clickhouse: { host: 'onebox-clickhouse', port: 8123, version: 'latest', config: { nativePort: 9000, httpPort: 8123 } },
|
||||
caddy: { host: 'onebox-caddy', port: 80, version: '2-alpine', config: { httpsPort: 443, adminApi: 2019 } },
|
||||
smartproxy: { host: 'onebox-smartproxy', port: 80, version: 'latest', config: { httpsPort: 443, adminApi: 2019 } },
|
||||
mariadb: { host: 'onebox-mariadb', port: 3306, version: '11', config: { engine: 'InnoDB', authEnabled: true } },
|
||||
redis: { host: 'onebox-redis', port: 6379, version: '7-alpine', config: { appendonly: true, maxDatabases: 16 } },
|
||||
};
|
||||
|
||||
@@ -46,7 +46,61 @@ export class ObViewSettings extends DeesElement {
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
shared.viewHostCss,
|
||||
css``,
|
||||
css`
|
||||
.gateway-card {
|
||||
margin-bottom: 24px;
|
||||
border: 1px solid ${cssManager.bdTheme('#e4e4e7', '#27272a')};
|
||||
border-radius: 12px;
|
||||
background: ${cssManager.bdTheme('#ffffff', '#09090b')};
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 2px ${cssManager.bdTheme('rgba(0,0,0,0.04)', 'rgba(0,0,0,0.2)')};
|
||||
}
|
||||
|
||||
.gateway-header {
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid ${cssManager.bdTheme('#f4f4f5', '#27272a')};
|
||||
background: ${cssManager.bdTheme('#fafafa', '#101013')};
|
||||
}
|
||||
|
||||
.gateway-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('#18181b', '#fafafa')};
|
||||
}
|
||||
|
||||
.gateway-subtitle {
|
||||
margin-top: 4px;
|
||||
font-size: 13px;
|
||||
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
|
||||
}
|
||||
|
||||
.gateway-content {
|
||||
padding: 20px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.gateway-field.full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
dees-input-text {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.gateway-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 0 20px 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.gateway-content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
async connectedCallback() {
|
||||
@@ -57,6 +111,7 @@ export class ObViewSettings extends DeesElement {
|
||||
public render(): TemplateResult {
|
||||
return html`
|
||||
<ob-sectionheading>Settings</ob-sectionheading>
|
||||
${this.renderExternalGatewaySettings()}
|
||||
<sz-settings-view
|
||||
.settings=${this.settingsState.settings || {
|
||||
darkMode: true,
|
||||
@@ -90,4 +145,82 @@ export class ObViewSettings extends DeesElement {
|
||||
></sz-settings-view>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderExternalGatewaySettings(): TemplateResult {
|
||||
const settings = this.settingsState.settings;
|
||||
return html`
|
||||
<section class="gateway-card">
|
||||
<div class="gateway-header">
|
||||
<div class="gateway-title">Delegate Routing</div>
|
||||
<div class="gateway-subtitle">Delegate public WorkApp routing, DNS, and certificates to a dcrouter edge authority.</div>
|
||||
</div>
|
||||
<div class="gateway-content">
|
||||
${this.renderGatewayInput('dcrouterGatewayUrl', 'Gateway URL', settings?.dcrouterGatewayUrl || '', 'Base URL of the dcrouter OpsServer.')}
|
||||
${this.renderGatewayInput('dcrouterGatewayApiToken', 'API Token', settings?.dcrouterGatewayApiToken || '', 'Requires workhosters and certificates scopes.', true)}
|
||||
${this.renderGatewayInput('dcrouterWorkHosterId', 'WorkHoster ID', settings?.dcrouterWorkHosterId || '', 'Leave empty to let Onebox create a stable ID.')}
|
||||
${this.renderGatewayInput('dcrouterTargetHost', 'Target Host', settings?.dcrouterTargetHost || '', 'Defaults to the configured server IP when empty.')}
|
||||
${this.renderGatewayInput('dcrouterTargetPort', 'Target Port', String(settings?.dcrouterTargetPort || 80), 'Internal HTTP port dcrouter forwards to.')}
|
||||
</div>
|
||||
<div class="gateway-footer">
|
||||
<dees-button
|
||||
.text=${'Save Gateway Settings'}
|
||||
.type=${'default'}
|
||||
.icon=${'lucide:Save'}
|
||||
@click=${() => this.saveExternalGatewaySettings()}
|
||||
></dees-button>
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderGatewayInput(
|
||||
key: keyof NonNullable<appstate.ISettingsState['settings']>,
|
||||
label: string,
|
||||
value: string,
|
||||
hint: string,
|
||||
isPassword = false,
|
||||
): TemplateResult {
|
||||
return html`
|
||||
<div class="gateway-field ${key === 'dcrouterGatewayUrl' ? 'full' : ''}">
|
||||
<dees-input-text
|
||||
.key=${key}
|
||||
.label=${label}
|
||||
.value=${value}
|
||||
.description=${hint}
|
||||
.isPasswordBool=${isPassword}
|
||||
@input=${(event: Event) => this.updateGatewayDraft(key, (event.target as HTMLInputElement).value)}
|
||||
></dees-input-text>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private updateGatewayDraft(
|
||||
key: keyof NonNullable<appstate.ISettingsState['settings']>,
|
||||
value: string,
|
||||
): void {
|
||||
const currentSettings = this.settingsState.settings || {} as NonNullable<appstate.ISettingsState['settings']>;
|
||||
const nextValue = key === 'dcrouterTargetPort' ? Number(value) || 0 : value;
|
||||
this.settingsState = {
|
||||
...this.settingsState,
|
||||
settings: {
|
||||
...currentSettings,
|
||||
[key]: nextValue,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async saveExternalGatewaySettings(): Promise<void> {
|
||||
const settings = this.settingsState.settings;
|
||||
if (!settings) return;
|
||||
|
||||
await appstate.settingsStatePart.dispatchAction(appstate.updateSettingsAction, {
|
||||
settings: {
|
||||
dcrouterGatewayUrl: settings.dcrouterGatewayUrl || '',
|
||||
dcrouterGatewayApiToken: settings.dcrouterGatewayApiToken || '',
|
||||
dcrouterWorkHosterId: settings.dcrouterWorkHosterId || '',
|
||||
dcrouterTargetHost: settings.dcrouterTargetHost || '',
|
||||
dcrouterTargetPort: Number(settings.dcrouterTargetPort) || 80,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user