Compare commits

...

56 Commits

Author SHA1 Message Date
jkunz 3e68e875ac v2.0.0
Release / build-and-release (push) Successful in 2m38s
2026-05-25 03:12:29 +00:00
jkunz a30260e336 feat(appstore): use shared resolver 2026-05-25 03:10:18 +00:00
jkunz be53f179ab v1.31.0
Release / build-and-release (push) Successful in 2m29s
2026-05-25 01:40:38 +00:00
jkunz db52934f35 feat(appstore): resolve repo manifests and docker digest-tracked images 2026-05-25 01:39:59 +00:00
jkunz d29257dcf7 v1.30.2 2026-05-24 21:23:33 +00:00
jkunz 3b2b806165 fix(smartproxy): clean up legacy reverse proxy naming for SmartProxy 2026-05-24 21:20:46 +00:00
jkunz 070c936a69 v1.30.1
Release / build-and-release (push) Successful in 2m26s
2026-05-24 17:42:02 +00:00
jkunz 3f15cbda80 fix(settings-ui): align settings gateway cards with dees-tile footer actions 2026-05-24 17:41:34 +00:00
jkunz 4b48f0056e v1.30.0
Release / build-and-release (push) Successful in 2m29s
2026-05-24 14:46:51 +00:00
jkunz d91fda084b feat(admin-ui): add configurable Admin UI domain routing 2026-05-24 14:46:35 +00:00
jkunz a86d83f835 v1.29.0
Release / build-and-release (push) Successful in 2m33s
2026-05-24 11:50:10 +00:00
jkunz 05235ec284 feat(update): add Onebox self-upgrade flow 2026-05-24 11:49:43 +00:00
jkunz 4812621376 v1.28.0
Release / build-and-release (push) Successful in 2m38s
2026-05-24 10:19:43 +00:00
jkunz 8b98706d27 chore(release): document catalog update 2026-05-24 10:19:29 +00:00
jkunz e36207347f fix(deps): update serve zone catalog 2026-05-24 10:19:17 +00:00
jkunz 5228eeaa23 feat(appstore): add service volumes and published ports 2026-05-24 10:15:56 +00:00
jkunz e6ebac76b4 v1.27.0
Release / build-and-release (push) Successful in 2m34s
2026-05-21 20:35:07 +00:00
jkunz 27888a9fd1 chore(web): update bundled onebox app 2026-05-21 20:34:36 +00:00
jkunz 3f6b058ce5 feat(web): group onebox sidebar navigation 2026-05-21 20:32:40 +00:00
jkunz ba370cbce8 v1.26.3
Release / build-and-release (push) Successful in 2m29s
2026-05-21 17:06:38 +00:00
jkunz 43c8f261cc fix(web): use dees-table for gateway domains and DNS records views 2026-05-21 17:06:15 +00:00
jkunz 2984c41081 v1.26.2
Release / build-and-release (push) Successful in 2m30s
2026-05-20 14:32:04 +00:00
jkunz d143d73ea9 chore(release): remove duplicate pending heading 2026-05-20 14:31:56 +00:00
jkunz 9f8a6eaa76 chore(release): format pending changelog entry 2026-05-20 14:31:18 +00:00
jkunz 0af8da2c9d chore(release): document proxy reload fix 2026-05-20 14:30:31 +00:00
jkunz fa96d371d6 fix(proxy): reload routes after SmartProxy startup 2026-05-20 14:27:17 +00:00
jkunz 9e4dcc18a2 v1.26.1
Release / build-and-release (push) Successful in 2m31s
2026-05-09 22:36:27 +00:00
jkunz 15574b8629 fix(external-gateway): derive gateway client identity from the dcrouter token and make the settings UI read-only 2026-05-09 22:36:26 +00:00
jkunz b9c90eca3d v1.26.0
Release / build-and-release (push) Successful in 2m36s
2026-05-09 20:04:02 +00:00
jkunz dc37a71802 feat(dcrouter): add managed local dcrouter mode with status controls and gateway integration 2026-05-09 20:04:02 +00:00
jkunz 595e84cdb6 v1.25.0
Release / build-and-release (push) Successful in 2m59s
2026-05-09 11:58:51 +00:00
jkunz 5e04001790 feat(external-gateway): add gateway client domain and DNS record support for dcrouter integration 2026-05-09 11:58:51 +00:00
jkunz 7fe63541b3 fix: align delegate routing settings UI
Release / build-and-release (push) Successful in 2m44s
2026-05-08 19:32:40 +00:00
jkunz 201602b733 fix: use compiled-safe password hashing
Release / build-and-release (push) Successful in 2m34s
2026-05-08 16:36:58 +00:00
jkunz cc6a81012c fix: restore onebox daemon startup
Release / build-and-release (push) Successful in 2m28s
2026-05-08 16:23:45 +00:00
jkunz fba143d918 fix: update onebox installer credentials output
Release / build-and-release (push) Successful in 2m32s
2026-05-08 16:12:22 +00:00
jkunz b0f9d71a18 fix: update onebox runtime dependencies
Release / build-and-release (push) Successful in 2m33s
Bump Onebox to 1.24.3 with current API/runtime dependencies, registry routing fixes, safer initial admin handling, and cleaner shutdown of Docker-backed resources.
2026-05-08 15:39:02 +00:00
jkunz 61f72a4b7a docs: refresh readme and legal info 2026-05-07 20:22:12 +00:00
jkunz c04be7117e feat: expose dcrouter gateway settings 2026-04-29 15:57:10 +00:00
jkunz 7ee740695f feat: add dcrouter external gateway sync 2026-04-29 15:24:25 +00:00
jkunz 1f3705fa25 chore: remove committed dist_serve artifacts 2026-04-29 15:19:28 +00:00
jkunz 90ca53356d fix: restore platform backup data 2026-04-29 14:11:00 +00:00
jkunz 69b528a499 fix: replace stopped platform containers 2026-04-29 07:39:42 +00:00
jkunz 63c6fb4b6a fix: use absolute platform data paths 2026-04-29 07:34:15 +00:00
jkunz 35f83d7c2d fix: isolate platform service data dirs 2026-04-29 02:05:53 +00:00
jkunz c451d71a97 feat: add appstore install CLI 2026-04-29 01:59:09 +00:00
jkunz 2b51178016 fix: clean up registry shutdown 2026-04-29 01:29:53 +00:00
jkunz 5cb6895a14 fix: clean up SmartProxy lifecycle 2026-04-28 21:59:00 +00:00
jkunz c5d9158078 feat: replace onebox ingress with SmartProxy 2026-04-28 21:30:48 +00:00
jkunz 0f5ce708d9 fix: require app template environment values 2026-04-28 15:07:13 +00:00
jkunz 3da7e431c2 refactor: complete opsserver migration 2026-04-28 14:35:26 +00:00
jkunz 49c1830168 feat: resolve app template env placeholders 2026-04-28 14:28:01 +00:00
jkunz 061ce7c3f2 feat: add secret settings manager and migration for legacy settings
- Implemented SecretSettingsManager to handle secret settings with encryption.
- Added functionality to migrate legacy plaintext settings into encrypted storage.
- Introduced methods for setting, getting, and clearing secret settings.
- Created tests for verifying the migration and canonicalization of secret settings.
- Updated app state to handle service updates via socket communication.
- Added interface for push service updates to manage service state changes.
2026-04-19 01:47:06 +00:00
jkunz 618d4d674f Add tests for authentication and security features
- Implement unit tests for password handling in `auth_test.ts`, covering bcrypt and legacy password hashes.
- Create a fake database for user management to facilitate testing of the `AdminHandler`.
- Validate JWT-based identity verification against database records.
- Introduce tests for credential encryption and registry management in `security_test.ts`.
- Ensure registry passwords are securely stored and can be decrypted correctly, including legacy support.
- Add utility functions for password hashing and verification in `auth.ts`.
2026-04-19 01:30:54 +00:00
jkunz 0c9eb0653d v1.24.2
Release / build-and-release (push) Successful in 3m37s
2026-03-24 20:17:30 +00:00
jkunz ed6a35eb86 fix(deps): bump runtime and build tool dependencies 2026-03-24 20:17:30 +00:00
106 changed files with 8920 additions and 42646 deletions
+172
View File
@@ -1,5 +1,177 @@
# Changelog # Changelog
## Pending
## 2026-05-25 - 2.0.0
### Breaking Changes
- switch Onebox App Store resolution to the shared appstore client
- Uses `@serve.zone/appstore` and `@serve.zone/interfaces` for App Store metadata, parsing, and Docker digest resolution
- Renames App Store typed request methods to `getAppStoreTemplates`, `getAppStoreConfig`, `installAppStoreApp`, and `getUpgradeableAppStoreServices`
- Removes local duplicated App Store DTO and resolver code while preserving Onebox install and upgrade behavior
## 2026-05-25 - 1.31.0
### Features
- resolve repo manifests and docker digest-tracked images (appstore)
- Add catalog source, resolved source, channel, runtime, upgrade strategy, and version metadata types for appstore manifests.
- Resolve catalog entries from repo manifests and pin digest-tracked Docker images using registry digests.
- Propagate resolved image digests into app version configs and service creation options.
- Add runtime coverage for repo manifest resolution and digest-tracked latest images.
## 2026-05-24 - 1.30.2
### Fixes
- reduce remaining reverse proxy wording to required legacy SmartProxy cleanup and migration identifiers
- clean up legacy reverse proxy naming for SmartProxy (smartproxy)
- Update legacy reverse proxy service naming and logs used during SmartProxy startup cleanup.
- Clarify migration and documentation wording for the legacy reverse proxy to SmartProxy transition.
- Bump @serve.zone/catalog to ^2.12.6 and add pnpm workspace build dependency settings.
## 2026-05-24 - 1.30.1
### Fixes
- align Onebox settings gateway cards with the dees-tile footer action pattern
- align settings gateway cards with dees-tile footer actions (settings-ui)
- Replaces custom gateway card wrappers with dees-tile header and footer slots.
- Uses tile-styled action buttons for Admin UI and dcrouter settings saves.
## 2026-05-24 - 1.30.0
### Features
- add configurable Onebox Admin UI domain
- expose Admin UI domain in settings
- sync the Admin UI route as a first-class dcrouter gateway route
- keep Admin UI routing separate from app service routes
- add configurable Admin UI domain routing (admin-ui)
- Expose and validate the Admin UI domain in settings
- Sync the Admin UI as a dedicated dcrouter gateway route and SmartProxy route
- Preserve configured and legacy Admin UI routes during stale-route reconciliation
### Fixes
- preserve Onebox Admin UI routes during external gateway stale-route reconciliation
## 2026-05-24 - 1.29.0
### Features
- add Onebox runtime update prompts and admin-triggered self-upgrades
- expose Onebox update status through system status
- reuse the CLI upgrade logic for web-triggered detached upgrades
- show an update banner and guided DeesUpdater flow in the dashboard
## 2026-05-24 - 1.28.0
### Features
- add enterprise-ready App Store runtime support for declared volumes and raw published ports
- validate app template schemas before install, fail invalid port/volume declarations early, and preserve declarations across upgrades and backups
- preflight Docker/host published port conflicts and back up declared service volume data
- show App Store volume mounts and raw host port exposure before deploy
### Fixes
- fix Onebox dashboard system metrics rendering and traffic polling
- update `@serve.zone/catalog` to `^2.12.5`
- preserve an existing managed dcrouter config file instead of rewriting it on container creation
- remove stale external gateway routes during route reconciliation
## 2026-05-21 - 1.27.0
### Features
- group Onebox sidebar navigation into Apps, Network, and Registry sections (web)
- add parent/subview routes for grouped app, network, and registry pages
## 2026-05-21 - 1.26.3
### Fixes
- use `dees-table` for gateway domains and DNS records views (web)
- replace custom row grids with catalog tables, filtering, refresh, and row actions
- use dees-table for gateway domains and DNS records views (web)
- replace custom row layouts with dees-table in gateway domains and DNS records views
- add table filtering, refresh actions, and row/context actions for dcrouter management
## 2026-05-20 - 1.26.2
### Fixes
- reload SmartProxy routes after managed startup (proxy)
- reloads SmartProxy routes immediately after the admin API is ready during startup, avoiding an empty route table when Docker task state lags behind service readiness
## 2026-05-09 - 1.26.1 - fix(external-gateway)
derive gateway client identity from the dcrouter token and make the settings UI read-only
- Resolves external gateway ownership and domain sync to use the gateway client context returned by dcrouter instead of a locally entered client ID.
- Falls back to stored gateway client settings only when token context is unavailable.
- Removes editable Gateway Client ID fields from settings and shows them as diagnostic read-only values for managed and external modes.
- Updates external gateway tests to validate token-derived gateway client IDs and admin-token behavior.
## 2026-05-09 - 1.26.0 - feat(dcrouter)
add managed local dcrouter mode with status controls and gateway integration
- Adds a ManagedDcRouterManager to provision and control a local dcrouter container with default gateway settings.
- Updates gateway sync logic to support managed, external, and disabled dcrouter modes, including managed local route targets.
- Exposes managed dcrouter status, start, stop, and restart operations through OpsServer typed requests.
- Extends settings APIs and the settings UI to configure managed dcrouter ports, image, data directory, and mode selection.
- Adjusts Onebox startup to prepare managed dcrouter settings, shift proxy ports when managed mode is active, and initialize the local gateway before route sync.
## 2026-05-09 - 1.25.0 - feat(external-gateway)
add gateway client domain and DNS record support for dcrouter integration
- switch dcrouter route syncing to gateway-client APIs with fallback to legacy workHoster endpoints
- add admin endpoints and frontend views for browsing gateway domains and DNS records
- introduce dcrouterGatewayClientId settings support while preserving compatibility with the legacy workHoster ID
## 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
- update @design.estate/dees-catalog to ^3.49.0
- update development tooling packages @git.zone/tsbundle, @git.zone/tsdeno, and @git.zone/tswatch
## 2026-03-24 - 1.24.1 - fix(repo) ## 2026-03-24 - 1.24.1 - fix(repo)
migrate smart build config to .smartconfig.json and tidy repository metadata migrate smart build config to .smartconfig.json and tidy repository metadata
+19 -17
View File
@@ -1,6 +1,6 @@
{ {
"name": "@serve.zone/onebox", "name": "@serve.zone/onebox",
"version": "1.24.1", "version": "2.0.0",
"exports": "./mod.ts", "exports": "./mod.ts",
"tasks": { "tasks": {
"test": "deno test --allow-all test/", "test": "deno test --allow-all test/",
@@ -9,25 +9,27 @@
"dev": "pnpm run watch" "dev": "pnpm run watch"
}, },
"imports": { "imports": {
"@std/path": "jsr:@std/path@^1.1.2", "@std/path": "jsr:@std/path@^1.1.4",
"@std/fs": "jsr:@std/fs@^1.0.19", "@std/fs": "jsr:@std/fs@^1.0.23",
"@std/http": "jsr:@std/http@^1.0.21", "@std/http": "jsr:@std/http@^1.1.0",
"@std/assert": "jsr:@std/assert@^1.0.15", "@std/assert": "jsr:@std/assert@^1.0.19",
"@std/encoding": "jsr:@std/encoding@^1.0.10", "@std/encoding": "jsr:@std/encoding@^1.0.10",
"@db/sqlite": "jsr:@db/sqlite@0.12.0", "@db/sqlite": "jsr:@db/sqlite@0.13.0",
"@apiclient.xyz/docker": "npm:@apiclient.xyz/docker@^5.1.1", "@apiclient.xyz/docker": "npm:@apiclient.xyz/docker@^5.1.4",
"@apiclient.xyz/cloudflare": "npm:@apiclient.xyz/cloudflare@6.4.3", "@apiclient.xyz/cloudflare": "npm:@apiclient.xyz/cloudflare@7.1.0",
"@push.rocks/smartacme": "npm:@push.rocks/smartacme@^8.0.0", "@push.rocks/smartacme": "npm:@push.rocks/smartacme@^9.5.0",
"@push.rocks/smartregistry": "npm:@push.rocks/smartregistry@^2.2.0", "@push.rocks/smartregistry": "npm:@push.rocks/smartregistry@^2.9.2",
"@push.rocks/smartstorage": "npm:@push.rocks/smartstorage@^6.3.0", "@push.rocks/smartstorage": "npm:@push.rocks/smartstorage@^6.5.1",
"@push.rocks/taskbuffer": "npm:@push.rocks/taskbuffer@^3.1.0", "@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-interfaces": "npm:@api.global/typedrequest-interfaces@^3.0.19",
"@api.global/typedrequest": "npm:@api.global/typedrequest@^3.2.6", "@api.global/typedrequest": "npm:@api.global/typedrequest@^3.3.1",
"@api.global/typedserver": "npm:@api.global/typedserver@^8.3.1", "@api.global/typedserver": "npm:@api.global/typedserver@^8.4.6",
"@push.rocks/smartguard": "npm:@push.rocks/smartguard@^3.1.0", "@push.rocks/smartguard": "npm:@push.rocks/smartguard@^3.1.0",
"@push.rocks/smartjwt": "npm:@push.rocks/smartjwt@^2.2.1", "@push.rocks/smartjwt": "npm:@push.rocks/smartjwt@^2.2.2",
"@api.global/typedsocket": "npm:@api.global/typedsocket@^4.1.2", "@api.global/typedsocket": "npm:@api.global/typedsocket@^4.1.3",
"@serve.zone/containerarchive": "npm:@serve.zone/containerarchive@^0.1.3" "@serve.zone/containerarchive": "npm:@serve.zone/containerarchive@^0.1.3",
"@serve.zone/interfaces": "npm:@serve.zone/interfaces@^6.0.0",
"@serve.zone/appstore": "npm:@serve.zone/appstore@^0.2.0"
}, },
"compilerOptions": { "compilerOptions": {
"lib": [ "lib": [
-36196
View File
File diff suppressed because one or more lines are too long
-33
View File
@@ -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
View File
@@ -305,6 +305,6 @@ else
echo " onebox service add myapp --image nginx:latest --domain app.example.com" echo " onebox service add myapp --image nginx:latest --domain app.example.com"
echo "" echo ""
echo " Web UI: http://localhost:3000" 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 fi
echo "" echo ""
+13 -14
View File
@@ -1,6 +1,6 @@
{ {
"name": "@serve.zone/onebox", "name": "@serve.zone/onebox",
"version": "1.24.1", "version": "2.0.0",
"description": "Self-hosted container platform with automatic SSL and DNS - a mini Heroku for single servers", "description": "Self-hosted container platform with automatic SSL and DNS - a mini Heroku for single servers",
"main": "mod.ts", "main": "mod.ts",
"type": "module", "type": "module",
@@ -26,7 +26,7 @@
"paas", "paas",
"deployment" "deployment"
], ],
"author": "Lossless GmbH", "author": "Task Venture Capital GmbH",
"license": "MIT", "license": "MIT",
"repository": { "repository": {
"type": "git", "type": "git",
@@ -52,21 +52,20 @@
"x64", "x64",
"arm64" "arm64"
], ],
"packageManager": "pnpm@10.18.1+sha512.77a884a165cbba2d8d1c19e3b4880eee6d2fcabd0d879121e282196b80042351d5eb3ca0935fa599da1dc51265cc68816ad2bddd2a2de5ea9fdf92adbec7cd34", "packageManager": "pnpm@11.1.2",
"dependencies": { "dependencies": {
"@api.global/typedrequest-interfaces": "^3.0.19", "@api.global/typedrequest-interfaces": "^3.0.19",
"@api.global/typedsocket": "^4.1.2", "@api.global/typedsocket": "^4.1.3",
"@design.estate/dees-catalog": "^3.43.3", "@design.estate/dees-catalog": "^3.81.0",
"@design.estate/dees-element": "^2.1.6", "@design.estate/dees-element": "^2.2.4",
"@serve.zone/catalog": "^2.9.0" "@serve.zone/appstore": "^0.2.0",
"@serve.zone/catalog": "^2.12.6",
"@serve.zone/interfaces": "^6.0.0"
}, },
"devDependencies": { "devDependencies": {
"@git.zone/tsbundle": "^2.9.0", "@git.zone/tsbundle": "^2.10.4",
"@git.zone/tsdeno": "^1.2.0", "@git.zone/tsdeno": "^1.3.2",
"@git.zone/tswatch": "^3.2.0" "@git.zone/tswatch": "^3.3.5"
}, },
"private": true, "private": true
"pnpm": {
"overrides": {}
}
} }
+1253 -1164
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -0,0 +1,4 @@
allowBuilds:
esbuild: true
ignoredBuiltDependencies:
- '@design.estate/dees-catalog'
+15 -16
View File
@@ -44,42 +44,42 @@ ts/database/
- All methods delegate to the appropriate repository - All methods delegate to the appropriate repository
- No breaking changes for existing code - 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 legacy core reverse proxy platform service type 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:** **Architecture:**
- Caddy runs as Docker Swarm service (`onebox-caddy`) on the overlay network - SmartProxy runs as Docker Swarm service (`onebox-smartproxy`) on the overlay network
- No binary download required - uses `caddy:2-alpine` Docker image - No host binary download required - uses `code.foss.global/host.today/ht-docker-smartproxy:latest`
- Configuration pushed dynamically via Caddy Admin API (port 2019) - Routes are pushed dynamically via the SmartProxy admin API (host port 2019)
- Automatic HTTPS disabled - certificates managed externally via SmartACME - Automatic HTTPS disabled - certificates managed externally via SmartACME
- Zero-downtime configuration updates - Zero-downtime configuration updates
- Services reached by Docker service name (e.g., `onebox-hello-world:80`) - Services reached by Docker service name (e.g., `onebox-hello-world:80`)
**Key files:** **Key files:**
- `ts/classes/caddy.ts` - CaddyManager class for Docker service and Admin API - `ts/classes/smartproxy.ts` - SmartProxyManager class for Docker service and Admin API
- `ts/classes/reverseproxy.ts` - Delegates to CaddyManager - `ts/classes/reverseproxy.ts` - Delegates to SmartProxyManager
**Certificate workflow:** **Certificate workflow:**
1. `CertRequirementManager` creates requirements for domains 1. `CertRequirementManager` creates requirements for domains
2. Daemon processes requirements via `certmanager.ts` 2. Daemon processes requirements via `certmanager.ts`
3. Certificates stored in database (PEM content) 3. Certificates stored in database (PEM content)
4. `reverseProxy.addCertificate()` passes PEM content to Caddy via `load_pem` (inline in config) 4. `reverseProxy.addCertificate()` passes PEM content to SmartProxy route config
5. Caddy serves TLS with the loaded certificates (no volume mounts needed) 5. SmartProxy serves TLS with the loaded certificates (no volume mounts needed)
**Docker Service Configuration:** **Docker Service Configuration:**
- Service name: `onebox-caddy` - Service name: `onebox-smartproxy`
- Image: `caddy:2-alpine` - Image: `code.foss.global/host.today/ht-docker-smartproxy:latest`
- Network: `onebox-network` (overlay, attachable) - 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:** **Port Mapping:**
@@ -89,5 +89,4 @@ The reverse proxy uses **Caddy** running as a Docker Swarm service for productio
**Log Receiver:** **Log Receiver:**
- Caddy sends access logs to `tcp/172.17.0.1:9999` (Docker bridge gateway) - `ProxyLogReceiver` remains the host-side access-log stream endpoint for proxy log integrations
- `CaddyLogReceiver` on host receives and processes logs
+180 -528
View File
@@ -1,601 +1,253 @@
# @serve.zone/onebox # @serve.zone/onebox
> 🚀 Self-hosted Docker Swarm platform with Caddy reverse proxy, automatic SSL, and real-time WebSocket 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.
**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.
## Issue Reporting and Security ## 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. 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 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.
- **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
## 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 ```text
- 🌐 **Caddy Reverse Proxy** - Production-grade proxy running as Docker service with SNI, HTTP/2, HTTP/3 browser / CLI
- 🔒 **Automatic SSL Certificates** - Let's Encrypt integration with hot-reload and renewal monitoring |
- ☁️ **Cloudflare DNS Integration** - Automatic DNS record creation and zone synchronization v
- 📦 **Built-in Registry** - Private Docker registry with per-service tokens and auto-update OpsServer :3000
- 🔄 **Real-time WebSocket Updates** - Live service status, logs, and system events - 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) ## Installation
- 📝 **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
### Developer Experience Install the released binary:
- 🚀 **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
```bash ```bash
# One-line install (recommended)
curl -sSL https://code.foss.global/serve.zone/onebox/raw/branch/main/install.sh | sudo bash 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 ```bash
# Start the server in development mode
onebox server --ephemeral 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` ```text
- Password: `admin` 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 ```bash
# Install as systemd service onebox service add web --image nginx:latest --domain web.example.com --port 80
sudo onebox daemon install
# Start the daemon
sudo onebox daemon start
# View logs
sudo onebox daemon logs
``` ```
## Architecture 🏗️ For production, install and run the systemd service:
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
```bash ```bash
# Deploy a service sudo onebox systemd enable
onebox service add <name> --image <image> --domain <domain> [--port <port>] [--env KEY=VALUE] sudo onebox systemd start
sudo onebox systemd logs
# 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>
``` ```
### 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 ```bash
# Start server (development) onebox <command> [options]
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
``` ```
### 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 ```bash
# Add external registry credentials onebox config set serverIP 203.0.113.10
onebox registry add --url registry.example.com --username user --password pass onebox config set acmeEmail ops@example.com
onebox config set cloudflareToken cf-token
# List registries onebox config set cloudflareZoneId zone-id
onebox registry list
# Remove registry
onebox registry remove <url>
``` ```
### DNS Management ## App Store
The App Store manager fetches metadata from `serve.zone/appstore` through `@serve.zone/appstore` 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 ```bash
# Add DNS record (requires Cloudflare config) onebox appstore list
onebox dns add <domain> onebox appstore config cloudly
onebox appstore install cloudly --name cloudly --domain cloudly.example.com --env SERVEZONE_ADMINACCOUNT=admin:change-me
# List DNS records
onebox dns list
# Sync from Cloudflare
onebox dns sync
# Remove DNS record
onebox dns remove <domain>
``` ```
### 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 ```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 pnpm run watch
``` pnpm build
### Tasks
```bash
# Development server (auto-restart on changes)
deno task dev
# Run tests
deno task test deno task test
# Watch mode for tests
deno task test:watch deno task test:watch
# Compile binaries for all platforms
deno task compile deno task compile
``` ```
### Project Structure Source map:
``` | Path | Purpose |
onebox/ | --- | --- |
├── ts/ | `mod.ts` | Deno entry point. |
│ ├── classes/ # Core implementations | `ts/cli.ts` | CLI router and command help. |
│ │ ├── onebox.ts # Main coordinator | `ts/classes/onebox.ts` | Main coordinator. |
│ │ ├── reverseproxy.ts # Reverse proxy orchestration | `ts/classes/docker.ts` | Docker client, networks, containers, and Swarm services. |
│ │ ├── caddy.ts # Caddy Docker service management | `ts/classes/reverseproxy.ts` | SmartProxy route and certificate bridge. |
│ │ ├── docker.ts # Docker Swarm API | `ts/classes/platform-services/` | Local platform service providers. |
│ │ ├── httpserver.ts # REST API + WebSocket | `ts/classes/appstore.ts` | Remote App Store catalog and upgrade logic. |
│ │ ├── services.ts # Service orchestration | `ts/classes/backup-manager.ts` | Backup and restore orchestration. |
│ │ ├── certmanager.ts # SSL certificate management | `ts/opsserver/` | Web UI server and TypedRequest handlers. |
│ │ ├── cert-requirement-manager.ts # Certificate requirements | `ts/database/` | SQLite repositories and migrations. |
│ │ ├── ssl.ts # SSL utilities | `ts_web/` | Dashboard source. |
│ │ ├── 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>
```
## License and Legal Information ## 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. **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 ### 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 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.
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.
### Company Information ### Company Information
Task Venture Capital GmbH Task Venture Capital GmbH\
Registered at District court Bremen HRB 35230 HB, Germany 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. 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.
+205
View File
@@ -0,0 +1,205 @@
import { assertEquals, assertThrows } from '@std/assert';
import { AppStoreManager } from '../ts/classes/appstore.ts';
import { OneboxDockerManager } from '../ts/classes/docker.ts';
import type * as servezoneInterfaces from '@serve.zone/interfaces';
import type { IService } from '../ts/types.ts';
type IAppStoreVersionConfig = servezoneInterfaces.appstore.IAppStoreVersionConfig;
const createAppStore = () => new AppStoreManager({} as any);
const baseConfig: IAppStoreVersionConfig = {
image: 'example/app:1.0.0',
port: 3000,
envVars: [
{
key: 'APP_PORT',
value: '3000',
description: 'Application port',
required: true,
},
],
};
const baseService: IService = {
id: 1,
name: 'test-service',
image: 'example/app:1.0.0',
envVars: {},
port: 3000,
status: 'stopped',
createdAt: Date.now(),
updatedAt: Date.now(),
};
Deno.test('appstore normalizes and validates app template runtime fields', () => {
const appStore = createAppStore();
const normalizedVolumes = appStore.normalizeVolumes([
'/data/app',
{ mountPath: '/config', readOnly: true },
]);
assertEquals(normalizedVolumes, [
{ mountPath: '/data/app' },
{ mountPath: '/config', readOnly: true },
]);
appStore.validateAppVersionConfig({
...baseConfig,
volumes: normalizedVolumes,
publishedPorts: [
{ targetPort: 3000, publishedPort: 3000, protocol: 'tcp' },
{ targetPort: 20000, targetPortEnd: 20002, publishedPort: 20000, publishedPortEnd: 20002, protocol: 'udp' },
],
});
});
Deno.test('appstore rejects invalid template ports and volumes', () => {
const appStore = createAppStore();
assertThrows(
() => appStore.validateAppVersionConfig({ ...baseConfig, port: 70000 }),
Error,
'Invalid app config port',
);
assertThrows(
() => appStore.normalizeVolumes([{ mountPath: 'relative/path' }]),
Error,
'mountPath must be an absolute path',
);
assertThrows(
() => appStore.validateAppVersionConfig({
...baseConfig,
publishedPorts: [
{ targetPort: 3000, targetPortEnd: 3002, publishedPort: 3000, publishedPortEnd: 3001, protocol: 'tcp' },
],
}),
Error,
'ranges must have the same size',
);
});
Deno.test('appstore resolves repo manifests and docker digest-tracked latest images', async () => {
const appStoreBaseUrl = 'https://appstore.example.test';
const manifestUrl = 'https://code.example.test/cloudly/servezone.appstore.json';
const digest = 'sha256:1234567890abcdef';
const fakeFetch: typeof fetch = async (input, init) => {
const url = input instanceof Request ? input.url : input.toString();
const method = init?.method || 'GET';
if (url === `${appStoreBaseUrl}/appstore.resolved.json`) {
return new Response('not found', { status: 404 });
}
if (url === `${appStoreBaseUrl}/appstore.json`) {
return Response.json({
schemaVersion: 1,
updatedAt: '2026-05-24T00:00:00Z',
apps: [
{
id: 'cloudly',
name: 'Cloudly',
description: 'Central metadata can stay curated.',
category: 'Dev Tools',
latestVersion: '1.0.0',
source: {
type: 'repoManifest',
url: manifestUrl,
ref: 'main',
},
},
],
});
}
if (url === manifestUrl) {
return Response.json({
schemaVersion: 1,
app: {
id: 'cloudly',
name: 'Cloudly',
description: 'Manifest-owned app metadata.',
category: 'Dev Tools',
maintainer: 'serve.zone',
},
latestVersion: 'latest',
source: {
type: 'dockerImage',
image: 'registry.example.test/serve.zone/cloudly:latest',
tracking: 'digest',
},
runtime: {
image: 'registry.example.test/serve.zone/cloudly:latest',
port: 80,
},
});
}
if (
url === 'https://registry.example.test/v2/serve.zone/cloudly/manifests/latest' &&
method === 'HEAD'
) {
return new Response(null, {
status: 200,
headers: { 'docker-content-digest': digest },
});
}
return new Response(`unexpected ${method} ${url}`, { status: 500 });
};
const appStore = new AppStoreManager({} as any, {
baseUrl: appStoreBaseUrl,
fetch: fakeFetch,
});
const appStoreIndex = await appStore.getAppStore();
assertEquals(appStoreIndex.apps[0].latestVersion, `latest@${digest}`);
assertEquals(appStoreIndex.apps[0].resolvedSource?.manifestHash?.length, 64);
assertEquals(appStoreIndex.apps[0].upgradeStrategy, 'dockerDigest');
const appMeta = await appStore.getAppMeta('cloudly');
assertEquals(appMeta.latestVersion, `latest@${digest}`);
assertEquals(appMeta.versions, [`latest@${digest}`]);
const config = await appStore.getAppVersionConfig('cloudly', appMeta.latestVersion);
assertEquals(config.image, 'registry.example.test/serve.zone/cloudly:latest');
assertEquals(config.appStoreVersion, `latest@${digest}`);
assertEquals(config.resolvedImageDigest, digest);
});
Deno.test('docker service spec validation rejects unsafe volume and port declarations', () => {
const dockerManager = new OneboxDockerManager();
dockerManager.validateServiceSpec({
...baseService,
volumes: [{ mountPath: '/data/app' }],
publishedPorts: [{ targetPort: 3000, publishedPort: 3000, protocol: 'tcp' }],
});
assertThrows(
() => dockerManager.validateServiceSpec({
...baseService,
volumes: [{ mountPath: 'relative/path' }],
}),
Error,
'must be an absolute path',
);
assertThrows(
() => dockerManager.validateServiceSpec({
...baseService,
publishedPorts: [
{ targetPort: 3001, publishedPort: 3000, hostIp: '127.0.0.1', protocol: 'tcp' },
{ targetPort: 3000, publishedPort: 3000, protocol: 'tcp' },
],
}),
Error,
'Duplicate published port',
);
});
+100
View File
@@ -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);
});
+594
View File
@@ -0,0 +1,594 @@
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 services: IService[] = [];
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);
}
getAllServices(): IService[] {
return this.services;
}
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.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, requestData: Record<string, unknown>) => {
if (method === 'getGatewayClientContext') {
return { context: { role: 'gatewayClient', gatewayClient: { type: 'onebox', id: 'onebox-token' } } };
}
assertEquals(method, 'getGatewayClientDomains');
assertEquals(requestData.gatewayClientId, 'onebox-token');
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 gatewayClient 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>) => {
if (method === 'getGatewayClientContext') {
return { context: { role: 'gatewayClient', gatewayClient: { type: 'onebox', id: 'onebox-token' } } };
}
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 === 'syncGatewayClientRoute')!;
const route = syncRequest.requestData.route as any;
const ownership = syncRequest.requestData.ownership as any;
assertEquals(ownership, {
gatewayClientType: 'onebox',
gatewayClientId: 'onebox-token',
appId: '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 syncs Admin UI route to dcrouter gatewayClient API', async () => {
const oneboxRef = makeOneboxRef();
oneboxRef.database.settings.set('adminUiDomain', 'Onebox.Example.com');
oneboxRef.database.settings.set('serverIP', '203.0.113.10');
oneboxRef.database.settings.set('httpPort', '8080');
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>,
) => {
if (method === 'getGatewayClientContext') {
return {
context: { role: 'gatewayClient', gatewayClient: { type: 'onebox', id: 'onebox-token' } },
};
}
requests.push({ method, requestData });
if (method === 'exportCertificate') {
return { success: false };
}
return { success: true, action: 'created', routeId: 'admin-route' };
};
await manager.syncAdminUiRoute();
const syncRequest = requests.find((request) => request.method === 'syncGatewayClientRoute')!;
const route = syncRequest.requestData.route as any;
const ownership = syncRequest.requestData.ownership as any;
assertEquals(ownership, {
gatewayClientType: 'onebox',
gatewayClientId: 'onebox-token',
appId: 'onebox-admin-ui',
hostname: 'onebox.example.com',
});
assertEquals(route.match, { ports: [443], domains: ['onebox.example.com'] });
assertEquals(route.action.targets, [{ host: '203.0.113.10', port: 8080 }]);
assertEquals(syncRequest.requestData.enabled, true);
});
Deno.test('ExternalGatewayManager uses managed dcrouter local target in managed mode', async () => {
const oneboxRef = makeOneboxRef();
(oneboxRef as any).managedDcRouter = {
getMode: () => 'managed',
getGatewayUrl: () => 'http://127.0.0.1:3300',
getAdminToken: async () => 'dcr-managed-token',
ensureGatewayClientId: () => 'onebox-managed',
getRouteTarget: () => ({ host: 'onebox-smartproxy', port: 80 }),
};
const service: IService = {
id: 1,
name: 'hello',
image: 'nginx:latest',
envVars: {},
port: 3000,
domain: 'hello.example.com',
status: 'running',
createdAt: 1,
updatedAt: 1,
};
let syncRequest: Record<string, unknown> | null = null;
const manager = new ExternalGatewayManager(oneboxRef as any);
(manager as any).fireDcRouterRequest = async (method: string, requestData: Record<string, unknown>, config: any) => {
if (method === 'getGatewayClientContext') {
return { context: { role: 'admin' } };
}
if (method === 'exportCertificate') {
return { success: false };
}
assertEquals(config.url, 'http://127.0.0.1:3300');
assertEquals(config.apiToken, 'dcr-managed-token');
syncRequest = requestData;
return { success: true, action: 'created', routeId: 'route-1' };
};
await manager.syncServiceRoute(service);
assert(syncRequest);
const route = (syncRequest as Record<string, unknown>).route as any;
const ownership = (syncRequest as Record<string, unknown>).ownership as any;
assertEquals(ownership.gatewayClientId, 'onebox-managed');
assertEquals(route.action.targets, [{ host: 'onebox-smartproxy', port: 80 }]);
});
Deno.test('ExternalGatewayManager deletes service routes through dcrouter gatewayClient 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>) => {
if (method === 'getGatewayClientContext') {
return { context: { role: 'gatewayClient', gatewayClient: { type: 'onebox', id: 'onebox-token' } } };
}
assertEquals(method, 'syncGatewayClientRoute');
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).gatewayClientId, 'onebox-token');
assertEquals((capturedDeleteRequest.ownership as any).hostname, 'hello.example.com');
});
Deno.test('ExternalGatewayManager removes stale gateway routes during reconciliation', async () => {
const oneboxRef = makeOneboxRef();
oneboxRef.database.settings.set('serverIP', '203.0.113.10');
oneboxRef.database.services.push({
id: 1,
name: 'active',
image: 'nginx:latest',
envVars: {},
port: 3000,
domain: 'active.example.com',
status: 'running',
createdAt: 1,
updatedAt: 1,
});
const deletes: Record<string, unknown>[] = [];
const manager = new ExternalGatewayManager(oneboxRef as any);
(manager as any).fireDcRouterRequest = async (method: string, requestData: Record<string, unknown>) => {
if (method === 'getGatewayClientContext') {
return { context: { role: 'gatewayClient', gatewayClient: { type: 'onebox', id: 'onebox-token' } } };
}
if (method === 'syncGatewayClientRoute') {
if (requestData.delete) {
deletes.push(requestData);
return { success: true, action: 'deleted' };
}
return { success: true, action: 'updated', routeId: 'active-route' };
}
if (method === 'exportCertificate') {
return { success: false };
}
if (method === 'getGatewayClientDnsRecords') {
return {
records: [
{
id: 'active-record',
domainId: 'domain-1',
name: 'active',
type: 'A',
value: '203.0.113.10',
ttl: 300,
source: 'route',
status: 'active',
gatewayClientType: 'onebox',
gatewayClientId: 'onebox-token',
appId: 'active',
hostname: 'active.example.com',
routeId: 'active-route',
},
{
id: 'stale-record',
domainId: 'domain-1',
name: 'stale',
type: 'A',
value: '203.0.113.10',
ttl: 300,
source: 'route',
status: 'active',
gatewayClientType: 'onebox',
gatewayClientId: 'onebox-token',
appId: 'stale',
hostname: 'stale.example.com',
routeId: 'stale-route',
},
],
};
}
throw new Error(`Unexpected method: ${method}`);
};
await manager.syncServiceRoutes();
assertEquals(deletes.length, 1);
assertEquals((deletes[0].ownership as any).hostname, 'stale.example.com');
});
Deno.test('ExternalGatewayManager preserves configured Admin UI route during reconciliation', async () => {
const oneboxRef = makeOneboxRef();
oneboxRef.database.settings.set('adminUiDomain', 'onebox.example.com');
oneboxRef.database.settings.set('serverIP', '203.0.113.10');
oneboxRef.database.services.push({
id: 1,
name: 'active',
image: 'nginx:latest',
envVars: {},
port: 3000,
domain: 'active.example.com',
status: 'running',
createdAt: 1,
updatedAt: 1,
});
const deletes: Record<string, unknown>[] = [];
const manager = new ExternalGatewayManager(oneboxRef as any);
(manager as any).fireDcRouterRequest = async (method: string, requestData: Record<string, unknown>) => {
if (method === 'getGatewayClientContext') {
return { context: { role: 'gatewayClient', gatewayClient: { type: 'onebox', id: 'onebox-token' } } };
}
if (method === 'syncGatewayClientRoute') {
if (requestData.delete) {
deletes.push(requestData);
return { success: true, action: 'deleted' };
}
return { success: true, action: 'updated' };
}
if (method === 'exportCertificate') {
return { success: false };
}
if (method === 'getGatewayClientDnsRecords') {
return {
records: [
{
id: 'admin-record',
domainId: 'domain-1',
name: 'onebox',
type: 'A',
value: '203.0.113.10',
ttl: 300,
source: 'route',
status: 'active',
gatewayClientType: 'onebox',
gatewayClientId: 'onebox-token',
appId: 'onebox-admin-ui',
hostname: 'onebox.example.com',
routeId: 'admin-route',
},
{
id: 'stale-record',
domainId: 'domain-1',
name: 'stale',
type: 'A',
value: '203.0.113.10',
ttl: 300,
source: 'route',
status: 'active',
gatewayClientType: 'onebox',
gatewayClientId: 'onebox-token',
appId: 'stale',
hostname: 'stale.example.com',
routeId: 'stale-route',
},
],
};
}
throw new Error(`Unexpected method: ${method}`);
};
await manager.syncServiceRoutes();
assertEquals(deletes.length, 1);
assertEquals((deletes[0].ownership as any).hostname, 'stale.example.com');
});
Deno.test('ExternalGatewayManager preserves legacy Admin UI route when setting is absent', async () => {
const oneboxRef = makeOneboxRef();
oneboxRef.database.settings.set('serverIP', '203.0.113.10');
const deletes: Record<string, unknown>[] = [];
const manager = new ExternalGatewayManager(oneboxRef as any);
(manager as any).fireDcRouterRequest = async (
method: string,
requestData: Record<string, unknown>,
) => {
if (method === 'getGatewayClientContext') {
return {
context: { role: 'gatewayClient', gatewayClient: { type: 'onebox', id: 'onebox-token' } },
};
}
if (method === 'syncGatewayClientRoute') {
if (requestData.delete) {
deletes.push(requestData);
return { success: true, action: 'deleted' };
}
return { success: true, action: 'updated' };
}
if (method === 'getGatewayClientDnsRecords') {
return {
records: [
{
id: 'legacy-admin-record',
domainId: 'domain-1',
name: 'onebox',
type: 'A',
value: '203.0.113.10',
ttl: 300,
source: 'route',
status: 'active',
gatewayClientType: 'onebox',
gatewayClientId: 'onebox-token',
appId: 'onebox',
hostname: 'onebox.example.com',
routeId: 'legacy-admin-route',
},
{
id: 'stale-record',
domainId: 'domain-1',
name: 'stale',
type: 'A',
value: '203.0.113.10',
ttl: 300,
source: 'route',
status: 'active',
gatewayClientType: 'onebox',
gatewayClientId: 'onebox-token',
appId: 'stale',
hostname: 'stale.example.com',
routeId: 'stale-route',
},
],
};
}
throw new Error(`Unexpected method: ${method}`);
};
await manager.syncServiceRoutes();
assertEquals(deletes.length, 1);
assertEquals((deletes[0].ownership as any).hostname, 'stale.example.com');
});
Deno.test('ExternalGatewayManager deletes old Admin UI route after domain change', async () => {
const oneboxRef = makeOneboxRef();
oneboxRef.database.settings.set('adminUiDomain', 'new.example.com');
oneboxRef.database.settings.set('serverIP', '203.0.113.10');
const deletes: Record<string, unknown>[] = [];
const manager = new ExternalGatewayManager(oneboxRef as any);
(manager as any).fireDcRouterRequest = async (
method: string,
requestData: Record<string, unknown>,
) => {
if (method === 'getGatewayClientContext') {
return {
context: { role: 'gatewayClient', gatewayClient: { type: 'onebox', id: 'onebox-token' } },
};
}
if (method === 'syncGatewayClientRoute') {
if (requestData.delete) {
deletes.push(requestData);
return { success: true, action: 'deleted' };
}
return { success: true, action: 'updated' };
}
if (method === 'exportCertificate') {
return { success: false };
}
if (method === 'getGatewayClientDnsRecords') {
return {
records: [
{
id: 'old-admin-record',
domainId: 'domain-1',
name: 'onebox',
type: 'A',
value: '203.0.113.10',
ttl: 300,
source: 'route',
status: 'active',
gatewayClientType: 'onebox',
gatewayClientId: 'onebox-token',
appId: 'onebox-admin-ui',
hostname: 'old.example.com',
routeId: 'old-admin-route',
},
],
};
}
throw new Error(`Unexpected method: ${method}`);
};
await manager.syncServiceRoutes();
assertEquals(deletes.length, 1);
assertEquals((deletes[0].ownership as any).hostname, 'old.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>) => {
if (method === 'getGatewayClientContext') {
return { context: { role: 'gatewayClient', gatewayClient: { type: 'onebox', id: 'onebox-token' } } };
}
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);
});
+54
View File
@@ -0,0 +1,54 @@
import { assert, assertEquals } from '@std/assert';
import { ManagedDcRouterManager } from '../ts/classes/managed-dcrouter.ts';
class FakeDatabase {
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);
}
async getSecretSetting(key: string): Promise<string | null> {
return this.secretSettings.get(key) ?? null;
}
async setSecretSetting(key: string, value: string): Promise<void> {
this.secretSettings.set(key, value);
}
}
Deno.test('ManagedDcRouterManager persists default managed gateway settings', async () => {
const database = new FakeDatabase();
const manager = new ManagedDcRouterManager({ database } as any);
assertEquals(manager.getMode(), 'managed');
await manager.prepareGatewaySettings();
assertEquals(database.getSetting('dcrouterMode'), 'managed');
assertEquals(manager.getMode(), 'managed');
assertEquals(database.getSetting('dcrouterGatewayUrl'), 'http://127.0.0.1:3300');
assertEquals(database.getSetting('dcrouterTargetHost'), 'onebox-smartproxy');
assertEquals(database.getSetting('dcrouterTargetPort'), '80');
assert(database.getSetting('dcrouterGatewayClientId')?.startsWith('onebox-'));
assert((await database.getSecretSetting('dcrouterManagedAdminApiToken'))?.startsWith('dcr_'));
});
Deno.test('ManagedDcRouterManager keeps existing external gateway default external', async () => {
const database = new FakeDatabase();
database.setSetting('dcrouterGatewayUrl', 'https://edge.example.com');
const manager = new ManagedDcRouterManager({ database } as any);
assertEquals(manager.getMode(), 'external');
await manager.prepareGatewaySettings();
assertEquals(database.getSetting('dcrouterMode'), null);
assertEquals(database.getSetting('dcrouterTargetHost'), null);
});
+50
View File
@@ -0,0 +1,50 @@
import { assertEquals } from '@std/assert';
import { OneboxReverseProxy } from '../ts/classes/reverseproxy.ts';
import type { IService } from '../ts/types.ts';
class FakeDatabase {
public settings = new Map<string, string>();
public services: IService[] = [];
getSetting(key: string): string | null {
return this.settings.get(key) ?? null;
}
getAllServices(): IService[] {
return this.services;
}
getServiceByID(id: number): IService | null {
return this.services.find((service) => service.id === id) ?? null;
}
getAllSSLCertificates(): [] {
return [];
}
}
Deno.test('OneboxReverseProxy loads Admin UI domain as local SmartProxy route', async () => {
const database = new FakeDatabase();
database.settings.set('adminUiDomain', 'onebox.example.com');
database.settings.set('serverIP', '203.0.113.10');
const reverseProxy = new OneboxReverseProxy({ database } as any);
const routes: Array<{ domain: string; upstream: string }> = [];
(reverseProxy as any).smartProxy = {
clear: () => routes.splice(0, routes.length),
addRoute: async (domain: string, upstream: string) => {
routes.push({ domain, upstream });
},
getCertificates: () => [],
};
await reverseProxy.reloadRoutes();
assertEquals(routes, [
{
domain: 'onebox.example.com',
upstream: '203.0.113.10:3000',
},
]);
});
+73
View File
@@ -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:'));
});
+61
View File
@@ -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 -1
View File
@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@serve.zone/onebox', name: '@serve.zone/onebox',
version: '1.24.1', version: '2.0.0',
description: 'Self-hosted container platform with automatic SSL and DNS - a mini Heroku for single servers' description: 'Self-hosted container platform with automatic SSL and DNS - a mini Heroku for single servers'
} }
-210
View File
@@ -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;
}
}
-73
View File
@@ -1,73 +0,0 @@
/**
* App Store type definitions
*/
export interface ICatalog {
schemaVersion: number;
updatedAt: string;
apps: ICatalogApp[];
}
export interface ICatalogApp {
id: string;
name: string;
description: string;
category: string;
iconName?: string;
iconUrl?: string;
latestVersion: string;
tags?: string[];
}
export interface IAppMeta {
id: string;
name: string;
description: string;
category: string;
iconName?: string;
latestVersion: string;
versions: string[];
maintainer?: string;
links?: Record<string, string>;
}
export interface IAppVersionConfig {
image: string;
port: number;
envVars?: Array<{ key: string; value: string; description: string; required?: boolean }>;
volumes?: string[];
platformRequirements?: {
mongodb?: boolean;
s3?: boolean;
clickhouse?: boolean;
redis?: boolean;
mariadb?: boolean;
};
minOneboxVersion?: string;
}
export interface IMigrationContext {
service: {
name: string;
image: string;
envVars: Record<string, string>;
port: number;
};
fromVersion: string;
toVersion: string;
}
export interface IMigrationResult {
success: boolean;
envVars?: Record<string, string>;
image?: string;
warnings: string[];
}
export interface IUpgradeableService {
serviceName: string;
appTemplateId: string;
currentVersion: string;
latestVersion: string;
hasMigration: boolean;
}
+277 -158
View File
@@ -1,193 +1,254 @@
/** /**
* App Store Manager * App Store Manager
* Fetches, caches, and serves app templates from the remote appstore-apptemplates repo. * Fetches, caches, and serves app templates from the remote App Store repo.
* The remote repo is the single source of truth — no fallback catalog.
*/ */
import type { import * as plugins from '../plugins.ts';
ICatalog,
ICatalogApp,
IAppMeta,
IAppVersionConfig,
IMigrationContext,
IMigrationResult,
IUpgradeableService,
} from './appstore-types.ts';
import { logger } from '../logging.ts'; import { logger } from '../logging.ts';
import { getErrorMessage } from '../utils/error.ts'; import { getErrorMessage } from '../utils/error.ts';
import type { Onebox } from './onebox.ts'; import type { Onebox } from './onebox.ts';
import type { IService } from '../types.ts'; import type { IService, IServicePublishedPort, IServiceVolume } from '../types.ts';
import { projectInfo } from '../info.ts';
type IAppStoreIndex = plugins.servezoneInterfaces.appstore.IAppStoreIndex;
type IAppStoreApp = plugins.servezoneInterfaces.appstore.IAppStoreApp;
type IAppStoreAppMeta = plugins.servezoneInterfaces.appstore.IAppStoreAppMeta;
type IAppStoreVersionConfig = plugins.servezoneInterfaces.appstore.IAppStoreVersionConfig;
type IAppStoreInstallOptions = plugins.servezoneInterfaces.appstore.IAppStoreInstallRequest & {
autoDNS?: boolean;
};
type IUpgradeableAppStoreService = plugins.servezoneInterfaces.appstore.IUpgradeableAppStoreService;
export interface IAppStoreManagerOptions {
baseUrl?: string;
fetch?: typeof fetch;
resolveDockerDigests?: boolean;
}
export interface IMigrationContext {
service: {
name: string;
image: string;
envVars: Record<string, string>;
port: number;
};
fromVersion: string;
toVersion: string;
}
export interface IMigrationResult {
success: boolean;
envVars?: Record<string, string>;
image?: string;
imageDigest?: string;
port?: number;
volumes?: IServiceVolume[];
publishedPorts?: IServicePublishedPort[];
warnings: string[];
}
export class AppStoreManager { export class AppStoreManager {
private oneboxRef: Onebox; private appStoreCache: IAppStoreIndex | null = null;
private catalogCache: ICatalog | null = null; private appStoreResolver: plugins.servezoneAppstore.AppStoreResolver;
private lastFetchTime = 0; private lastFetchTime = 0;
private readonly repoBaseUrl = 'https://code.foss.global/serve.zone/appstore-apptemplates/raw/branch/main'; private readonly appStoreBaseUrl: string;
private readonly cacheTtlMs = 5 * 60 * 1000; // 5 minutes private readonly fetchRef: typeof fetch;
private readonly resolveDockerDigests: boolean;
private readonly cacheTtlMs = 5 * 60 * 1000;
constructor(oneboxRef: Onebox) { constructor(
this.oneboxRef = oneboxRef; private oneboxRef: Onebox,
optionsArg: IAppStoreManagerOptions = {},
) {
this.appStoreBaseUrl = optionsArg.baseUrl || 'https://code.foss.global/serve.zone/appstore/raw/branch/main';
this.fetchRef = optionsArg.fetch || fetch;
this.resolveDockerDigests = optionsArg.resolveDockerDigests ?? true;
this.appStoreResolver = this.createAppStoreResolver();
} }
async init(): Promise<void> { public async init(): Promise<void> {
try { try {
await this.getCatalog(); await this.getAppStore();
logger.info(`App Store initialized with ${this.catalogCache?.apps.length || 0} templates`); logger.info(`App Store initialized with ${this.appStoreCache?.apps.length || 0} templates`);
} catch (error) { } catch (error) {
logger.warn(`App Store initialization failed: ${getErrorMessage(error)}`); logger.warn(`App Store initialization failed: ${getErrorMessage(error)}`);
logger.warn('App Store will retry on next request'); logger.warn('App Store will retry on next request');
} }
} }
/** public async getAppStore(): Promise<IAppStoreIndex> {
* Get the catalog (cached, refreshes after TTL)
*/
async getCatalog(): Promise<ICatalog> {
const now = Date.now(); const now = Date.now();
if (this.catalogCache && (now - this.lastFetchTime) < this.cacheTtlMs) { if (this.appStoreCache && (now - this.lastFetchTime) < this.cacheTtlMs) {
return this.catalogCache; return this.appStoreCache;
} }
try { try {
const catalog = await this.fetchJson('catalog.json') as ICatalog; const resolver = this.createAppStoreResolver();
if (catalog && catalog.apps && Array.isArray(catalog.apps)) { const appStore = await resolver.getAppStoreIndex();
this.catalogCache = catalog; this.appStoreResolver = resolver;
this.appStoreCache = appStore;
this.lastFetchTime = now; this.lastFetchTime = now;
return catalog; return appStore;
}
throw new Error('Invalid catalog format');
} catch (error) { } catch (error) {
logger.warn(`Failed to fetch remote catalog: ${getErrorMessage(error)}`); logger.warn(`Failed to fetch remote App Store: ${getErrorMessage(error)}`);
// Return cached if available, otherwise return empty catalog if (this.appStoreCache) {
if (this.catalogCache) { return this.appStoreCache;
return this.catalogCache;
} }
return { schemaVersion: 1, updatedAt: '', apps: [] }; return { schemaVersion: 1, updatedAt: '', apps: [] };
} }
} }
/** public async getApps(): Promise<IAppStoreApp[]> {
* Get the catalog apps list (convenience method for the API) return (await this.getAppStore()).apps;
*/
async getApps(): Promise<ICatalogApp[]> {
const catalog = await this.getCatalog();
return catalog.apps;
} }
/** public async getAppMeta(appIdArg: string): Promise<IAppStoreAppMeta> {
* Fetch app metadata (versions list, etc.)
*/
async getAppMeta(appId: string): Promise<IAppMeta> {
try { try {
return await this.fetchJson(`apps/${appId}/app.json`) as IAppMeta; await this.getAppStore();
return await this.appStoreResolver.getAppMeta(appIdArg);
} catch (error) { } catch (error) {
throw new Error(`Failed to fetch metadata for app '${appId}': ${getErrorMessage(error)}`); throw new Error(`Failed to fetch metadata for app '${appIdArg}': ${getErrorMessage(error)}`);
} }
} }
/** public async getAppVersionConfig(
* Fetch full config for an app version appIdArg: string,
*/ versionArg?: string,
async getAppVersionConfig(appId: string, version: string): Promise<IAppVersionConfig> { ): Promise<IAppStoreVersionConfig> {
try { try {
return await this.fetchJson(`apps/${appId}/versions/${version}/config.json`) as IAppVersionConfig; const version = versionArg || (await this.getAppMeta(appIdArg)).latestVersion;
await this.getAppStore();
return await this.appStoreResolver.getAppVersionConfig(appIdArg, version);
} catch (error) { } catch (error) {
throw new Error(`Failed to fetch config for ${appId}@${version}: ${getErrorMessage(error)}`); throw new Error(`Failed to fetch config for ${appIdArg}@${versionArg || 'latest'}: ${getErrorMessage(error)}`);
} }
} }
/** public async installApp(optionsArg: IAppStoreInstallOptions): Promise<IService> {
* Compare deployed services against catalog to find those with available upgrades this.validateInstallOptions(optionsArg);
*/ const appMeta = await this.getAppMeta(optionsArg.appId);
async getUpgradeableServices(): Promise<IUpgradeableService[]> { const version = optionsArg.version || appMeta.latestVersion;
const catalog = await this.getCatalog(); const config = await this.getAppVersionConfig(optionsArg.appId, version);
const appStoreVersion = config.appStoreVersion || version;
this.assertRuntimeCompatibility(config);
const servicePort = optionsArg.port || config.port;
this.assertValidPort(servicePort, 'install service port');
const volumes = this.normalizeVolumes(config.volumes);
const publishedPorts = optionsArg.publishedPorts || config.publishedPorts || [];
this.validateAppVersionConfig(
{ ...config, port: servicePort, publishedPorts },
`${optionsArg.appId}@${version} install`,
);
const envVars = this.getAppStoreEnvVars(config, optionsArg.envVars || {});
if (this.requiresTemplateValue(envVars, 'SERVICE_DOMAIN') && !optionsArg.domain) {
throw new Error('A domain is required because the app template uses ${SERVICE_DOMAIN}');
}
return await this.oneboxRef.services.deployService({
name: optionsArg.serviceName,
image: config.image,
port: servicePort,
domain: optionsArg.domain,
autoDNS: optionsArg.autoDNS,
envVars,
volumes,
publishedPorts,
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: optionsArg.appId,
appTemplateVersion: appStoreVersion,
imageDigest: config.resolvedImageDigest,
});
}
public async getUpgradeableAppStoreServices(): Promise<IUpgradeableAppStoreService[]> {
const appStore = await this.getAppStore();
const services = this.oneboxRef.database.getAllServices(); const services = this.oneboxRef.database.getAllServices();
const upgradeable: IUpgradeableService[] = []; const upgradeable: IUpgradeableAppStoreService[] = [];
for (const service of services) { for (const service of services) {
if (!service.appTemplateId || !service.appTemplateVersion) continue; if (!service.appTemplateId || !service.appTemplateVersion) continue;
const catalogApp = catalog.apps.find(a => a.id === service.appTemplateId); const appStoreApp = appStore.apps.find((appArg: IAppStoreApp) => appArg.id === service.appTemplateId);
if (!catalogApp) continue; if (!appStoreApp || appStoreApp.latestVersion === service.appTemplateVersion) continue;
if (catalogApp.latestVersion !== service.appTemplateVersion) {
// Check if a migration script exists
const hasMigration = await this.hasMigrationScript(
service.appTemplateId,
service.appTemplateVersion,
catalogApp.latestVersion,
);
upgradeable.push({ upgradeable.push({
serviceName: service.name, serviceName: service.name,
appTemplateId: service.appTemplateId, appTemplateId: service.appTemplateId,
currentVersion: service.appTemplateVersion, currentVersion: service.appTemplateVersion,
latestVersion: catalogApp.latestVersion, latestVersion: appStoreApp.latestVersion,
hasMigration, hasMigration: await this.hasMigrationScript(
service.appTemplateId,
service.appTemplateVersion,
appStoreApp.latestVersion,
),
}); });
} }
}
return upgradeable; return upgradeable;
} }
/** public async hasMigrationScript(
* Check if a migration script exists for a specific version transition appIdArg: string,
*/ fromVersionArg: string,
async hasMigrationScript(appId: string, fromVersion: string, toVersion: string): Promise<boolean> { toVersionArg: string,
): Promise<boolean> {
try { try {
const scriptPath = `apps/${appId}/versions/${toVersion}/migrate-from-${fromVersion}.ts`; await this.fetchText(`apps/${appIdArg}/versions/${toVersionArg}/migrate-from-${fromVersionArg}.ts`);
await this.fetchText(scriptPath);
return true; return true;
} catch { } catch {
return false; return false;
} }
} }
/** public async executeMigration(
* Execute a migration in a sandboxed Deno child process serviceArg: IService,
*/ fromVersionArg: string,
async executeMigration(service: IService, fromVersion: string, toVersion: string): Promise<IMigrationResult> { toVersionArg: string,
const appId = service.appTemplateId; ): Promise<IMigrationResult> {
const appId = serviceArg.appTemplateId;
if (!appId) { if (!appId) {
throw new Error('Service has no appTemplateId'); throw new Error('Service has no appTemplateId');
} }
// Fetch the migration script const scriptPath = `apps/${appId}/versions/${toVersionArg}/migrate-from-${fromVersionArg}.ts`;
const scriptPath = `apps/${appId}/versions/${toVersion}/migrate-from-${fromVersion}.ts`;
let scriptContent: string; let scriptContent: string;
try { try {
scriptContent = await this.fetchText(scriptPath); scriptContent = await this.fetchText(scriptPath);
} catch { } catch {
// No migration script — do a simple config-based upgrade logger.info(`No migration script for ${appId} ${fromVersionArg} -> ${toVersionArg}, using config-only upgrade`);
logger.info(`No migration script for ${appId} ${fromVersion} -> ${toVersion}, using config-only upgrade`); const config = await this.getAppVersionConfig(appId, toVersionArg);
const config = await this.getAppVersionConfig(appId, toVersion);
return { return {
success: true, success: true,
image: config.image, image: config.image,
envVars: undefined, // Keep existing env vars imageDigest: config.resolvedImageDigest,
port: config.port,
volumes: this.normalizeVolumes(config.volumes),
publishedPorts: config.publishedPorts,
envVars: undefined,
warnings: [], warnings: [],
}; };
} }
// Write to temp file
const tempFile = `/tmp/onebox-migration-${crypto.randomUUID()}.ts`; const tempFile = `/tmp/onebox-migration-${crypto.randomUUID()}.ts`;
await Deno.writeTextFile(tempFile, scriptContent); await Deno.writeTextFile(tempFile, scriptContent);
try { try {
// Prepare context
const context: IMigrationContext = { const context: IMigrationContext = {
service: { service: {
name: service.name, name: serviceArg.name,
image: service.image, image: serviceArg.image,
envVars: service.envVars, envVars: serviceArg.envVars,
port: service.port, port: serviceArg.port,
}, },
fromVersion, fromVersion: fromVersionArg,
toVersion, toVersion: toVersionArg,
}; };
// Execute in sandboxed Deno child process
const cmd = new Deno.Command('deno', { const cmd = new Deno.Command('deno', {
args: ['run', '--allow-env', '--allow-net=none', '--allow-read=none', '--allow-write=none', tempFile], args: ['run', '--allow-env', '--allow-net=none', '--allow-read=none', '--allow-write=none', tempFile],
stdin: 'piped', stdin: 'piped',
@@ -196,27 +257,22 @@ export class AppStoreManager {
}); });
const child = cmd.spawn(); const child = cmd.spawn();
// Write context to stdin
const writer = child.stdin.getWriter(); const writer = child.stdin.getWriter();
await writer.write(new TextEncoder().encode(JSON.stringify(context))); await writer.write(new TextEncoder().encode(JSON.stringify(context)));
await writer.close(); await writer.close();
// Read result
const output = await child.output(); const output = await child.output();
const exitCode = output.code;
const stdout = new TextDecoder().decode(output.stdout); const stdout = new TextDecoder().decode(output.stdout);
const stderr = new TextDecoder().decode(output.stderr); const stderr = new TextDecoder().decode(output.stderr);
if (exitCode !== 0) { if (output.code !== 0) {
logger.error(`Migration script failed (exit ${exitCode}): ${stderr.substring(0, 500)}`); logger.error(`Migration script failed (exit ${output.code}): ${stderr.substring(0, 500)}`);
return { return {
success: false, success: false,
warnings: [`Migration script failed: ${stderr.substring(0, 200)}`], warnings: [`Migration script failed: ${stderr.substring(0, 200)}`],
}; };
} }
// Parse result from stdout
try { try {
const result = JSON.parse(stdout) as IMigrationResult; const result = JSON.parse(stdout) as IMigrationResult;
result.success = true; result.success = true;
@@ -229,46 +285,49 @@ export class AppStoreManager {
}; };
} }
} finally { } finally {
// Cleanup temp file
try { try {
await Deno.remove(tempFile); await Deno.remove(tempFile);
} catch { } catch {
// Ignore cleanup errors // Ignore cleanup errors.
} }
} }
} }
/** public async applyUpgrade(
* Apply an upgrade: update image, env vars, recreate container serviceNameArg: string,
*/ migrationResultArg: IMigrationResult,
async applyUpgrade( newVersionArg: string,
serviceName: string,
migrationResult: IMigrationResult,
newVersion: string,
): Promise<IService> { ): Promise<IService> {
const service = this.oneboxRef.database.getServiceByName(serviceName); const service = this.oneboxRef.database.getServiceByName(serviceNameArg);
if (!service) { if (!service) {
throw new Error(`Service not found: ${serviceName}`); throw new Error(`Service not found: ${serviceNameArg}`);
} }
// Stop the existing container
if (service.containerID && service.status === 'running') { if (service.containerID && service.status === 'running') {
await this.oneboxRef.services.stopService(serviceName); await this.oneboxRef.services.stopService(serviceNameArg);
} }
// Update service record
const updates: Partial<IService> = { const updates: Partial<IService> = {
appTemplateVersion: newVersion, appTemplateVersion: newVersionArg,
}; };
if (migrationResult.image) { if (migrationResultArg.image) {
updates.image = migrationResult.image; updates.image = migrationResultArg.image;
} }
if (migrationResultArg.imageDigest !== undefined) {
if (migrationResult.envVars) { updates.imageDigest = migrationResultArg.imageDigest;
// Merge: migration result provides base, user overrides preserved }
const mergedEnvVars = { ...migrationResult.envVars }; if (migrationResultArg.port) {
// Keep any user-set env vars that aren't in the migration result updates.port = migrationResultArg.port;
}
if (migrationResultArg.volumes) {
updates.volumes = migrationResultArg.volumes;
}
if (migrationResultArg.publishedPorts) {
updates.publishedPorts = migrationResultArg.publishedPorts;
}
if (migrationResultArg.envVars) {
const mergedEnvVars = { ...migrationResultArg.envVars };
for (const [key, value] of Object.entries(service.envVars)) { for (const [key, value] of Object.entries(service.envVars)) {
if (!(key in mergedEnvVars)) { if (!(key in mergedEnvVars)) {
mergedEnvVars[key] = value; mergedEnvVars[key] = value;
@@ -279,57 +338,117 @@ export class AppStoreManager {
this.oneboxRef.database.updateService(service.id!, updates); this.oneboxRef.database.updateService(service.id!, updates);
// Pull new image if changed const newImage = migrationResultArg.image || service.image;
const newImage = migrationResult.image || service.image; if (migrationResultArg.image && migrationResultArg.image !== service.image) {
if (migrationResult.image && migrationResult.image !== service.image) {
await this.oneboxRef.docker.pullImage(newImage); await this.oneboxRef.docker.pullImage(newImage);
} }
// Recreate and start container const updatedService = this.oneboxRef.database.getServiceByName(serviceNameArg)!;
const updatedService = this.oneboxRef.database.getServiceByName(serviceName)!;
// Remove old container
if (service.containerID) { if (service.containerID) {
try { try {
await this.oneboxRef.docker.removeContainer(service.containerID, true); await this.oneboxRef.docker.removeContainer(service.containerID, true);
} catch { } catch {
// Container might already be gone // Container might already be gone.
} }
} }
// Create new container
const containerID = await this.oneboxRef.docker.createContainer(updatedService); const containerID = await this.oneboxRef.docker.createContainer(updatedService);
this.oneboxRef.database.updateService(service.id!, { containerID, status: 'starting' }); this.oneboxRef.database.updateService(service.id!, { containerID, status: 'starting' });
// Start container
await this.oneboxRef.docker.startContainer(containerID); await this.oneboxRef.docker.startContainer(containerID);
this.oneboxRef.database.updateService(service.id!, { status: 'running' }); this.oneboxRef.database.updateService(service.id!, { status: 'running' });
logger.success(`Service '${serviceName}' upgraded to template version ${newVersion}`); logger.success(`Service '${serviceNameArg}' upgraded to App Store version ${newVersionArg}`);
return this.oneboxRef.database.getServiceByName(serviceName)!; return this.oneboxRef.database.getServiceByName(serviceNameArg)!;
} }
/** public normalizeVolumes(volumesArg: IAppStoreVersionConfig['volumes'] = []): IServiceVolume[] {
* Fetch JSON from the remote repo return this.appStoreResolver.normalizeVolumes(volumesArg) as IServiceVolume[];
*/
private async fetchJson(path: string): Promise<unknown> {
const url = `${this.repoBaseUrl}/${path}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status} for ${url}`);
}
return response.json();
} }
/** public validateAppVersionConfig(configArg: IAppStoreVersionConfig, labelArg = 'app config'): void {
* Fetch text from the remote repo this.appStoreResolver.validateAppStoreVersionConfig(configArg, labelArg);
*/ }
private async fetchText(path: string): Promise<string> {
const url = `${this.repoBaseUrl}/${path}`; private createAppStoreResolver(): plugins.servezoneAppstore.AppStoreResolver {
const response = await fetch(url); return new plugins.servezoneAppstore.AppStoreResolver({
baseUrl: this.appStoreBaseUrl,
fetch: this.fetchRef,
resolveDockerDigests: this.resolveDockerDigests,
});
}
private async fetchText(pathArg: string): Promise<string> {
const url = `${this.appStoreBaseUrl}/${pathArg}`;
const response = await this.fetchRef(url);
if (!response.ok) { if (!response.ok) {
throw new Error(`HTTP ${response.status} for ${url}`); throw new Error(`HTTP ${response.status} for ${url}`);
} }
return response.text(); return response.text();
} }
private validateInstallOptions(optionsArg: IAppStoreInstallOptions): void {
if (!optionsArg.appId || !/^[a-z0-9][a-z0-9-]*$/.test(optionsArg.appId)) {
throw new Error(`Invalid app id: ${optionsArg.appId}`);
}
if (!optionsArg.serviceName || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,119}$/.test(optionsArg.serviceName)) {
throw new Error(`Invalid service name: ${optionsArg.serviceName}`);
}
if (optionsArg.port !== undefined) {
this.assertValidPort(optionsArg.port, 'install service port');
}
}
private assertValidPort(portArg: number, labelArg: string): void {
if (!Number.isInteger(portArg) || portArg < 1 || portArg > 65535) {
throw new Error(`Invalid ${labelArg}: ${portArg}. Expected an integer port between 1 and 65535.`);
}
}
private getAppStoreEnvVars(
configArg: IAppStoreVersionConfig,
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;
}
Object.assign(envVars, overridesArg);
if (missingRequiredEnvVars.length > 0) {
throw new Error(`Missing required app env var(s): ${missingRequiredEnvVars.join(', ')}`);
}
return envVars;
}
private requiresTemplateValue(envVarsArg: Record<string, string>, templateNameArg: string): boolean {
return Object.values(envVarsArg).some((value) => value.includes(`\${${templateNameArg}}`));
}
private assertRuntimeCompatibility(configArg: IAppStoreVersionConfig): void {
if (!configArg.minOneboxVersion) return;
if (this.compareVersions(projectInfo.version, configArg.minOneboxVersion) < 0) {
throw new Error(
`App requires Onebox >= ${configArg.minOneboxVersion}; current version is ${projectInfo.version}`,
);
}
}
private compareVersions(versionAArg: string, versionBArg: string): number {
const normalize = (versionArg: string) => versionArg.replace(/^v/, '').split('.').map((partArg) => Number(partArg) || 0);
const a = normalize(versionAArg);
const b = normalize(versionBArg);
for (let i = 0; i < Math.max(a.length, b.length); i++) {
const diff = (a[i] || 0) - (b[i] || 0);
if (diff !== 0) return diff > 0 ? 1 : -1;
}
return 0;
}
} }
+257 -92
View File
@@ -43,6 +43,14 @@ const IV_LENGTH = 12;
const SALT_LENGTH = 32; const SALT_LENGTH = 32;
const PBKDF2_ITERATIONS = 100000; const PBKDF2_ITERATIONS = 100000;
interface IS3ConnectionInfo {
endpoint: string;
accessKey: string;
secretKey: string;
bucket: string;
region: string;
}
export class BackupManager { export class BackupManager {
private oneboxRef: Onebox; private oneboxRef: Onebox;
public archive: plugins.ContainerArchive | null = null; public archive: plugins.ContainerArchive | null = null;
@@ -57,7 +65,7 @@ export class BackupManager {
*/ */
async init(): Promise<void> { async init(): Promise<void> {
const repoPath = this.getArchiveRepoPath(); const repoPath = this.getArchiveRepoPath();
const passphrase = this.getBackupPassword() || undefined; const passphrase = await this.getBackupPassword() || undefined;
try { try {
// Try to open existing repo // Try to open existing repo
@@ -177,7 +185,12 @@ export class BackupManager {
await this.exportDockerImage(service.image, `${tempDir}/data/image/image.tar`); await this.exportDockerImage(service.image, `${tempDir}/data/image/image.tar`);
} }
// 4. Build ingest items from temp directory files // 4. Export declared service volume data when the volume opts into backup.
if (service.volumes?.some((volumeArg) => volumeArg.backup !== false)) {
await this.exportServiceVolumes(service, tempDir);
}
// 5. Build ingest items from temp directory files
const items: Array<{ stream: NodeJS.ReadableStream; name: string; type?: string }> = []; const items: Array<{ stream: NodeJS.ReadableStream; name: string; type?: string }> = [];
// Service config // Service config
@@ -198,20 +211,31 @@ export class BackupManager {
for (const resourceType of resourceTypes) { for (const resourceType of resourceTypes) {
const dataDir = `${tempDir}/data/${resourceType}`; const dataDir = `${tempDir}/data/${resourceType}`;
try { try {
for await (const entry of Deno.readDir(dataDir)) { for await (const filePath of this.walkFiles(dataDir)) {
if (entry.isFile) {
items.push({ items.push({
stream: plugins.nodeFs.createReadStream(`${dataDir}/${entry.name}`), stream: plugins.nodeFs.createReadStream(filePath),
name: `data/${resourceType}/${entry.name}`, name: plugins.path.relative(tempDir, filePath).replaceAll('\\', '/'),
type: 'data', type: 'data',
}); });
} }
}
} catch { } catch {
// Directory may not exist if export produced no files // Directory may not exist if export produced no files
} }
} }
const volumeDataDir = `${tempDir}/data/volumes`;
try {
for await (const filePath of this.walkFiles(volumeDataDir)) {
items.push({
stream: plugins.nodeFs.createReadStream(filePath),
name: plugins.path.relative(tempDir, filePath).replaceAll('\\', '/'),
type: 'volume',
});
}
} catch {
// No service volume data was exported.
}
// Docker image // Docker image
if (includeImage && service.image) { if (includeImage && service.image) {
const imagePath = `${tempDir}/data/image/image.tar`; const imagePath = `${tempDir}/data/image/image.tar`;
@@ -227,7 +251,7 @@ export class BackupManager {
} }
} }
// 5. Build snapshot tags // 6. Build snapshot tags
const tags: Record<string, string> = { const tags: Record<string, string> = {
serviceName: service.name, serviceName: service.name,
serviceId: String(service.id), serviceId: String(service.id),
@@ -239,10 +263,10 @@ export class BackupManager {
tags.scheduleId = String(options.scheduleId); tags.scheduleId = String(options.scheduleId);
} }
// 6. Ingest multi-item snapshot into containerarchive // 7. Ingest multi-item snapshot into containerarchive
const snapshot = await this.archive.ingestMulti(items, { tags }); const snapshot = await this.archive.ingestMulti(items, { tags });
// 7. Store backup record in database // 8. Store backup record in database
const backup: IBackup = { const backup: IBackup = {
serviceId: service.id!, serviceId: service.id!,
serviceName: service.name, serviceName: service.name,
@@ -495,7 +519,7 @@ export class BackupManager {
await Deno.remove(tempDir, { recursive: true }); await Deno.remove(tempDir, { recursive: true });
// Encrypt for transport // Encrypt for transport
const password = this.getBackupPassword(); const password = await this.getBackupPassword();
if (password) { if (password) {
const encPath = `${tarPath}.enc`; const encPath = `${tarPath}.enc`;
await this.encryptFile(tarPath, encPath, password); await this.encryptFile(tarPath, encPath, password);
@@ -518,8 +542,8 @@ export class BackupManager {
/** /**
* Get backup password from settings * Get backup password from settings
*/ */
private getBackupPassword(): string | null { private async getBackupPassword(): Promise<string | null> {
return this.oneboxRef.database.getSetting('backup_encryption_password'); return await this.oneboxRef.database.getSecretSetting('backupPassword');
} }
/** /**
@@ -542,7 +566,7 @@ export class BackupManager {
* Restore from a legacy .tar.enc file * Restore from a legacy .tar.enc file
*/ */
private async restoreLegacyBackup(backupPath: string, options: IRestoreOptions): Promise<IRestoreResult> { private async restoreLegacyBackup(backupPath: string, options: IRestoreOptions): Promise<IRestoreResult> {
const backupPassword = this.getBackupPassword(); const backupPassword = await this.getBackupPassword();
if (!backupPassword) { if (!backupPassword) {
throw new Error('Backup password not configured.'); throw new Error('Backup password not configured.');
} }
@@ -669,6 +693,8 @@ export class BackupManager {
registry: serviceConfig.registry, registry: serviceConfig.registry,
port: serviceConfig.port, port: serviceConfig.port,
domain: serviceConfig.domain, domain: serviceConfig.domain,
volumes: serviceConfig.volumes,
publishedPorts: serviceConfig.publishedPorts,
useOneboxRegistry: serviceConfig.useOneboxRegistry, useOneboxRegistry: serviceConfig.useOneboxRegistry,
registryRepository: serviceConfig.registryRepository, registryRepository: serviceConfig.registryRepository,
registryImageTag: serviceConfig.registryImageTag, registryImageTag: serviceConfig.registryImageTag,
@@ -699,6 +725,8 @@ export class BackupManager {
port: serviceConfig.port, port: serviceConfig.port,
domain: options.mode === 'clone' ? undefined : serviceConfig.domain, domain: options.mode === 'clone' ? undefined : serviceConfig.domain,
envVars: serviceConfig.envVars, envVars: serviceConfig.envVars,
volumes: serviceConfig.volumes,
publishedPorts: serviceConfig.publishedPorts,
useOneboxRegistry: serviceConfig.useOneboxRegistry, useOneboxRegistry: serviceConfig.useOneboxRegistry,
registryImageTag: serviceConfig.registryImageTag, registryImageTag: serviceConfig.registryImageTag,
autoUpdateOnPush: serviceConfig.autoUpdateOnPush, autoUpdateOnPush: serviceConfig.autoUpdateOnPush,
@@ -723,6 +751,8 @@ export class BackupManager {
} }
} }
await this.restoreServiceVolumes(service, serviceConfig.volumes || [], tempDir, warnings);
// Cleanup // Cleanup
await Deno.remove(tempDir, { recursive: true }); await Deno.remove(tempDir, { recursive: true });
@@ -785,6 +815,8 @@ export class BackupManager {
image: service.image, image: service.image,
registry: service.registry, registry: service.registry,
envVars: service.envVars, envVars: service.envVars,
volumes: service.volumes,
publishedPorts: service.publishedPorts,
port: service.port, port: service.port,
domain: service.domain, domain: service.domain,
useOneboxRegistry: service.useOneboxRegistry, useOneboxRegistry: service.useOneboxRegistry,
@@ -796,6 +828,62 @@ export class BackupManager {
}; };
} }
private getVolumeBackupName(volumeArg: { mountPath: string }, indexArg: number): string {
const safeMountPath = volumeArg.mountPath
.replace(/^\/+/, '')
.replace(/\/+$/g, '')
.replace(/[^a-zA-Z0-9_.-]+/g, '-') || 'root';
return `${String(indexArg).padStart(3, '0')}-${safeMountPath}`;
}
private async exportServiceVolumes(serviceArg: IService, tempDirArg: string): Promise<void> {
if (!serviceArg.containerID) {
throw new Error(`Cannot export service volumes for ${serviceArg.name}: service has no container ID`);
}
const volumes = (serviceArg.volumes || []).filter((volumeArg) => volumeArg.backup !== false);
for (let i = 0; i < volumes.length; i++) {
const volume = volumes[i];
const backupName = this.getVolumeBackupName(volume, i);
const outputPath = `${tempDirArg}/data/volumes/${backupName}`;
await Deno.mkdir(outputPath, { recursive: true });
await this.copyFromContainer(serviceArg.containerID, `${volume.mountPath}/.`, outputPath);
logger.info(`Exported volume ${volume.mountPath} for service ${serviceArg.name}`);
}
}
private async restoreServiceVolumes(
serviceArg: IService,
volumesArg: NonNullable<IBackupServiceConfig['volumes']>,
tempDirArg: string,
warningsArg: string[],
): Promise<void> {
if (!serviceArg.containerID) {
if (volumesArg.some((volumeArg) => volumeArg.backup !== false)) {
warningsArg.push(`Could not restore service volumes for ${serviceArg.name}: service has no container ID`);
}
return;
}
const volumes = volumesArg.filter((volumeArg) => volumeArg.backup !== false);
for (let i = 0; i < volumes.length; i++) {
const volume = volumes[i];
const backupName = this.getVolumeBackupName(volume, i);
const inputPath = `${tempDirArg}/data/volumes/${backupName}`;
try {
await Deno.stat(inputPath);
} catch {
continue;
}
try {
await this.copyToContainer(`${inputPath}/.`, serviceArg.containerID, volume.mountPath);
logger.info(`Restored volume ${volume.mountPath} for service ${serviceArg.name}`);
} catch (error) {
warningsArg.push(`Volume restore failed for ${volume.mountPath}: ${getErrorMessage(error)}`);
}
}
}
/** /**
* Export MongoDB database * Export MongoDB database
*/ */
@@ -811,7 +899,7 @@ export class BackupManager {
throw new Error('MongoDB service not running'); 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) { if (!connectionUri) {
throw new Error('MongoDB connection URI not found in credentials'); throw new Error('MongoDB connection URI not found in credentials');
} }
@@ -828,19 +916,8 @@ export class BackupManager {
throw new Error(`mongodump failed: ${result.stderr}`); 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 localPath = `${dataDir}/${resource.resourceName}.archive`;
const encoder = new TextEncoder(); await this.copyFromContainer(mongoService.containerId, archivePath, localPath);
await Deno.writeFile(localPath, encoder.encode(copyResult.stdout));
await this.oneboxRef.docker.execInContainer(mongoService.containerId, ['rm', archivePath]); await this.oneboxRef.docker.execInContainer(mongoService.containerId, ['rm', archivePath]);
@@ -860,47 +937,48 @@ export class BackupManager {
const bucketDir = `${dataDir}/${resource.resourceName}`; const bucketDir = `${dataDir}/${resource.resourceName}`;
await Deno.mkdir(bucketDir, { recursive: true }); await Deno.mkdir(bucketDir, { recursive: true });
const endpoint = credentials.endpoint || credentials.S3_ENDPOINT; const s3Info = await this.getReachableS3ConnectionInfo(credentials, resource.platformServiceId);
const accessKey = credentials.accessKey || credentials.S3_ACCESS_KEY; const s3Client = this.createS3Client(s3Info);
const secretKey = credentials.secretKey || credentials.S3_SECRET_KEY; let objectCount = 0;
const bucket = credentials.bucket || credentials.S3_BUCKET; let continuationToken: string | undefined;
if (!endpoint || !accessKey || !secretKey || !bucket) { do {
throw new Error('MinIO credentials incomplete'); const response = await s3Client.send(
} new plugins.awsS3.ListObjectsV2Command({
Bucket: s3Info.bucket,
ContinuationToken: continuationToken,
}),
);
const s3Client = new plugins.smartstorage.SmartStorage({ for (const object of response.Contents || []) {
endpoint, const objectKey = object.Key;
accessKey,
secretKey,
bucket,
});
await s3Client.start();
const objects = await s3Client.listObjects();
for (const obj of objects) {
const objectKey = obj.Key;
if (!objectKey) continue; if (!objectKey) continue;
const objectData = await s3Client.getObject(objectKey); const objectResponse = await s3Client.send(
if (objectData) { new plugins.awsS3.GetObjectCommand({
Bucket: s3Info.bucket,
Key: objectKey,
}),
);
if (!objectResponse.Body) continue;
const objectPath = `${bucketDir}/${objectKey}`; const objectPath = `${bucketDir}/${objectKey}`;
const parentDir = plugins.path.dirname(objectPath); const parentDir = plugins.path.dirname(objectPath);
await Deno.mkdir(parentDir, { recursive: true }); 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( await Deno.writeTextFile(
`${bucketDir}/_metadata.json`, `${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 +1206,36 @@ export class BackupManager {
return imageName; 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 * Restore platform resources for a service
*/ */
@@ -1232,22 +1340,14 @@ export class BackupManager {
} }
const archivePath = `${dataDir}/${backupResourceName}.archive`; const archivePath = `${dataDir}/${backupResourceName}.archive`;
const connectionUri = credentials.connectionUri || credentials.MONGODB_URI; const connectionUri = credentials.connectionUri || credentials.connectionString || credentials.MONGODB_URI;
if (!connectionUri) { if (!connectionUri) {
throw new Error('MongoDB connection URI not found'); throw new Error('MongoDB connection URI not found');
} }
const archiveData = await Deno.readFile(archivePath);
const containerArchivePath = `/tmp/${resource.resourceName}.archive`; const containerArchivePath = `/tmp/${resource.resourceName}.archive`;
await this.copyToContainer(archivePath, mongoService.containerId, containerArchivePath);
const base64Data = btoa(String.fromCharCode(...archiveData));
await this.oneboxRef.docker.execInContainer(mongoService.containerId, [
'bash',
'-c',
`echo '${base64Data}' | base64 -d > ${containerArchivePath}`,
]);
const result = await this.oneboxRef.docker.execInContainer(mongoService.containerId, [ const result = await this.oneboxRef.docker.execInContainer(mongoService.containerId, [
'mongorestore', 'mongorestore',
@@ -1279,39 +1379,25 @@ export class BackupManager {
const bucketDir = `${dataDir}/${backupResourceName}`; const bucketDir = `${dataDir}/${backupResourceName}`;
const endpoint = credentials.endpoint || credentials.S3_ENDPOINT; const s3Info = await this.getReachableS3ConnectionInfo(credentials, resource.platformServiceId);
const accessKey = credentials.accessKey || credentials.S3_ACCESS_KEY; const s3Client = this.createS3Client(s3Info);
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();
let uploadedCount = 0; let uploadedCount = 0;
for await (const entry of Deno.readDir(bucketDir)) { for await (const filePath of this.walkFiles(bucketDir)) {
if (entry.name === '_metadata.json') continue; if (plugins.path.basename(filePath) === '_metadata.json') continue;
const filePath = `${bucketDir}/${entry.name}`;
if (entry.isFile) {
const fileData = await Deno.readFile(filePath); const fileData = await Deno.readFile(filePath);
await s3Client.putObject(entry.name, fileData); const objectKey = plugins.path.relative(bucketDir, filePath).replaceAll('\\', '/');
await s3Client.send(
new plugins.awsS3.PutObjectCommand({
Bucket: s3Info.bucket,
Key: objectKey,
Body: fileData,
}),
);
uploadedCount++; uploadedCount++;
} }
}
await s3Client.stop();
logger.success(`MinIO bucket imported: ${resource.resourceName} (${uploadedCount} objects)`); logger.success(`MinIO bucket imported: ${resource.resourceName} (${uploadedCount} objects)`);
} }
@@ -1585,7 +1671,7 @@ export class BackupManager {
return await crypto.subtle.deriveKey( return await crypto.subtle.deriveKey(
{ {
name: 'PBKDF2', name: 'PBKDF2',
salt, salt: this.toArrayBuffer(salt),
iterations: PBKDF2_ITERATIONS, iterations: PBKDF2_ITERATIONS,
hash: 'SHA-256', hash: 'SHA-256',
}, },
@@ -1600,8 +1686,87 @@ export class BackupManager {
* Compute SHA-256 checksum * Compute SHA-256 checksum
*/ */
private async computeChecksum(data: Uint8Array): Promise<string> { 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); const hashArray = new Uint8Array(hashBuffer);
return 'sha256:' + Array.from(hashArray).map((b) => b.toString(16).padStart(2, '0')).join(''); 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;
}
} }
-592
View File
@@ -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,
};
}
}
+1 -1
View File
@@ -24,7 +24,7 @@ export class CloudflareDomainSync {
*/ */
async init(): Promise<void> { async init(): Promise<void> {
try { try {
const apiKey = this.database.getSetting('cloudflareAPIKey'); const apiKey = await this.database.getSecretSetting('cloudflareToken');
if (!apiKey) { if (!apiKey) {
logger.warn('Cloudflare API key not configured. Domain sync will be limited.'); logger.warn('Cloudflare API key not configured. Domain sync will be limited.');
+2 -2
View File
@@ -27,12 +27,12 @@ export class OneboxDnsManager {
async init(): Promise<void> { async init(): Promise<void> {
try { try {
// Get Cloudflare credentials from settings // Get Cloudflare credentials from settings
const apiKey = this.database.getSetting('cloudflareAPIKey'); const apiKey = await this.database.getSecretSetting('cloudflareToken');
const serverIP = this.database.getSetting('serverIP'); const serverIP = this.database.getSetting('serverIP');
if (!apiKey) { if (!apiKey) {
logger.warn('Cloudflare credentials not configured. DNS management will be disabled.'); 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; return;
} }
+294 -17
View File
@@ -5,14 +5,258 @@
*/ */
import * as plugins from '../plugins.ts'; import * as plugins from '../plugins.ts';
import type { IService, IContainerStats } from '../types.ts'; import type { IService, IContainerStats, IServicePublishedPort } from '../types.ts';
import { logger } from '../logging.ts'; import { logger } from '../logging.ts';
import { getErrorMessage } from '../utils/error.ts'; import { getErrorMessage } from '../utils/error.ts';
type TExpandedPublishedPort = Required<Pick<
IServicePublishedPort,
'targetPort' | 'publishedPort' | 'protocol' | 'hostIp'
>>;
export class OneboxDockerManager { export class OneboxDockerManager {
private dockerClient: InstanceType<typeof plugins.docker.Docker> | null = null; private dockerClient: InstanceType<typeof plugins.docker.Docker> | null = null;
private networkName = 'onebox-network'; private networkName = 'onebox-network';
private getDockerSafeName(valueArg: string, maxLengthArg = 120): string {
const safeName = valueArg
.replace(/[^a-zA-Z0-9_.-]+/g, '-')
.replace(/^[^a-zA-Z0-9]+|[^a-zA-Z0-9]+$/g, '')
.slice(0, maxLengthArg)
.replace(/[^a-zA-Z0-9]+$/g, '');
return safeName || 'data';
}
private getServiceVolumeSource(serviceArg: IService, mountPathArg: string, requestedSourceArg?: string): string {
if (requestedSourceArg) {
return this.getDockerSafeName(requestedSourceArg);
}
const mountName = this.getDockerSafeName(mountPathArg.replace(/^\/+/, '').replace(/\/+$/g, ''), 40);
return this.getDockerSafeName(`onebox-${serviceArg.name}-${mountName}`);
}
private getStandaloneVolumeBinds(serviceArg: IService): string[] {
return (serviceArg.volumes || []).map((volumeArg) => {
const source = this.getServiceVolumeSource(serviceArg, volumeArg.mountPath, volumeArg.source || volumeArg.name);
return `${source}:${volumeArg.mountPath}${volumeArg.readOnly ? ':ro' : ''}`;
});
}
private getSwarmVolumeMounts(serviceArg: IService): Array<Record<string, unknown>> {
return (serviceArg.volumes || []).map((volumeArg) => ({
Type: 'volume',
Source: this.getServiceVolumeSource(serviceArg, volumeArg.mountPath, volumeArg.source || volumeArg.name),
Target: volumeArg.mountPath,
ReadOnly: Boolean(volumeArg.readOnly),
VolumeOptions: {
DriverConfig: {
Name: volumeArg.driver || 'local',
Options: volumeArg.options || {},
},
Labels: {
'managed-by': 'onebox',
'onebox-service': serviceArg.name,
'onebox-mount-path': volumeArg.mountPath,
'onebox-backup': String(volumeArg.backup !== false),
},
},
}));
}
public validateServiceSpec(serviceArg: IService): void {
this.assertValidPort(serviceArg.port, `service port for ${serviceArg.name}`);
for (const volumeArg of serviceArg.volumes || []) {
if (!volumeArg.mountPath || !volumeArg.mountPath.startsWith('/')) {
throw new Error(`Volume mountPath for service ${serviceArg.name} must be an absolute path`);
}
if (volumeArg.mountPath.includes(':')) {
throw new Error(`Volume mountPath for service ${serviceArg.name} must not contain ':'`);
}
if ((volumeArg.source || volumeArg.name)?.includes(':')) {
throw new Error(`Volume source/name for service ${serviceArg.name} must not contain ':'`);
}
}
this.expandPublishedPorts(serviceArg);
}
private assertValidPort(portArg: number, labelArg: string): void {
if (!Number.isInteger(portArg) || portArg < 1 || portArg > 65535) {
throw new Error(`Invalid ${labelArg}: ${portArg}. Expected an integer port between 1 and 65535.`);
}
}
private expandPublishedPorts(serviceArg: IService): TExpandedPublishedPort[] {
const expandedPorts: TExpandedPublishedPort[] = [];
const seenPublishedPorts = new Set<string>();
for (const portArg of serviceArg.publishedPorts || []) {
const protocol = portArg.protocol || 'tcp';
const targetStart = portArg.targetPort;
const targetEnd = portArg.targetPortEnd || targetStart;
const publishedStart = portArg.publishedPort || targetStart;
const publishedEnd = portArg.publishedPortEnd || (publishedStart + (targetEnd - targetStart));
const hostIp = portArg.hostIp || '0.0.0.0';
if (!['tcp', 'udp'].includes(protocol)) {
throw new Error(`Invalid published port protocol for service ${serviceArg.name}: ${protocol}`);
}
this.assertValidPort(targetStart, `published targetPort for service ${serviceArg.name}`);
this.assertValidPort(targetEnd, `published targetPortEnd for service ${serviceArg.name}`);
this.assertValidPort(publishedStart, `published publishedPort for service ${serviceArg.name}`);
this.assertValidPort(publishedEnd, `published publishedPortEnd for service ${serviceArg.name}`);
if (targetEnd < targetStart) {
throw new Error(`Invalid target port range for service ${serviceArg.name}: ${targetStart}-${targetEnd}`);
}
if (publishedEnd < publishedStart) {
throw new Error(`Invalid published port range for service ${serviceArg.name}: ${publishedStart}-${publishedEnd}`);
}
if ((targetEnd - targetStart) !== (publishedEnd - publishedStart)) {
throw new Error(
`Published port range size must match target port range size for service ${serviceArg.name}`,
);
}
if (!this.isValidHostIp(hostIp)) {
throw new Error(`Invalid hostIp for service ${serviceArg.name}: ${hostIp}`);
}
for (let offset = 0; offset <= targetEnd - targetStart; offset++) {
const publishedPort = publishedStart + offset;
const publishedKey = `${hostIp}/${protocol}/${publishedPort}`;
const wildcardKey = `0.0.0.0/${protocol}/${publishedPort}`;
const conflictsWithWildcard = hostIp === '0.0.0.0'
? Array.from(seenPublishedPorts).some((keyArg) => keyArg.endsWith(`/${protocol}/${publishedPort}`))
: seenPublishedPorts.has(wildcardKey);
if (seenPublishedPorts.has(publishedKey) || conflictsWithWildcard) {
throw new Error(`Duplicate published port for service ${serviceArg.name}: ${hostIp}:${publishedPort}/${protocol}`);
}
seenPublishedPorts.add(publishedKey);
expandedPorts.push({
targetPort: targetStart + offset,
publishedPort,
protocol,
hostIp,
});
}
}
return expandedPorts;
}
private isValidHostIp(hostIpArg: string): boolean {
if (['0.0.0.0', '127.0.0.1', '::', '::1', 'localhost'].includes(hostIpArg)) return true;
if (/^(\d{1,3}\.){3}\d{1,3}$/.test(hostIpArg)) {
return hostIpArg.split('.').every((partArg) => Number(partArg) >= 0 && Number(partArg) <= 255);
}
return /^[0-9a-fA-F:]+$/.test(hostIpArg);
}
private async assertPublishedPortsAvailable(serviceArg: IService): Promise<void> {
const publishedPorts = this.expandPublishedPorts(serviceArg);
if (publishedPorts.length === 0) return;
await this.assertPublishedPortsNotUsedByDocker(serviceArg, publishedPorts);
await this.assertPublishedPortsNotUsedByHost(serviceArg, publishedPorts);
}
private async assertPublishedPortsNotUsedByDocker(
serviceArg: IService,
publishedPortsArg: TExpandedPublishedPort[],
): Promise<void> {
const requestedPorts = new Set(
publishedPortsArg.map((portArg) => `${portArg.protocol}/${portArg.publishedPort}`),
);
try {
const containersResponse = await this.dockerClient!.request('GET', '/containers/json?all=true', {});
if (containersResponse.statusCode === 200 && Array.isArray(containersResponse.body)) {
for (const containerArg of containersResponse.body) {
const labels = containerArg.Labels || {};
if (labels['onebox-service'] === serviceArg.name) continue;
for (const portArg of containerArg.Ports || []) {
if (!portArg.PublicPort || !portArg.Type) continue;
if (requestedPorts.has(`${portArg.Type}/${portArg.PublicPort}`)) {
throw new Error(
`Published port ${portArg.PublicPort}/${portArg.Type} is already used by container ${containerArg.Names?.[0] || containerArg.Id}`,
);
}
}
}
}
const servicesResponse = await this.dockerClient!.request('GET', '/services', {});
if (servicesResponse.statusCode === 200 && Array.isArray(servicesResponse.body)) {
for (const service of servicesResponse.body) {
if (service.Spec?.Name === `onebox-${serviceArg.name}`) continue;
for (const portArg of service.Endpoint?.Ports || []) {
if (!portArg.PublishedPort || !portArg.Protocol) continue;
if (requestedPorts.has(`${portArg.Protocol}/${portArg.PublishedPort}`)) {
throw new Error(
`Published port ${portArg.PublishedPort}/${portArg.Protocol} is already used by Docker service ${service.Spec?.Name || service.ID}`,
);
}
}
}
}
} catch (error) {
if (error instanceof Error && error.message.startsWith('Published port ')) throw error;
logger.warn(`Could not complete Docker published-port preflight: ${getErrorMessage(error)}`);
}
}
private async assertPublishedPortsNotUsedByHost(
serviceArg: IService,
publishedPortsArg: TExpandedPublishedPort[],
): Promise<void> {
for (const portArg of publishedPortsArg) {
try {
if (portArg.protocol === 'udp') {
await this.assertUdpPortAvailable(portArg.hostIp, portArg.publishedPort);
} else {
const listener = Deno.listen({ hostname: portArg.hostIp, port: portArg.publishedPort });
listener.close();
}
} catch (error) {
throw new Error(
`Published port ${portArg.hostIp}:${portArg.publishedPort}/${portArg.protocol} for service ${serviceArg.name} is not available: ${getErrorMessage(error)}`,
);
}
}
}
private async assertUdpPortAvailable(hostIpArg: string, portArg: number): Promise<void> {
const dgram = await import('node:dgram');
const socket = dgram.createSocket(hostIpArg.includes(':') ? 'udp6' : 'udp4');
await new Promise<void>((resolve, reject) => {
socket.once('error', reject);
socket.bind(portArg, hostIpArg, () => {
socket.close();
resolve();
});
});
}
private getStandalonePortConfig(serviceArg: IService): {
exposedPorts: Record<string, Record<string, never>>;
portBindings: Record<string, Array<{ HostIp: string; HostPort: string }>>;
} {
const exposedPorts: Record<string, Record<string, never>> = {
[`${serviceArg.port}/tcp`]: {},
};
const portBindings: Record<string, Array<{ HostIp: string; HostPort: string }>> = {
[`${serviceArg.port}/tcp`]: [],
};
for (const publishedPort of this.expandPublishedPorts(serviceArg)) {
const key = `${publishedPort.targetPort}/${publishedPort.protocol}`;
exposedPorts[key] = {};
portBindings[key] = [{ HostIp: publishedPort.hostIp, HostPort: String(publishedPort.publishedPort) }];
}
return { exposedPorts, portBindings };
}
/** /**
* Initialize Docker client and create onebox network * Initialize Docker client and create onebox network
*/ */
@@ -36,6 +280,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 * Ensure onebox network exists
*/ */
@@ -105,6 +366,9 @@ export class OneboxDockerManager {
*/ */
async createContainer(service: IService): Promise<string> { async createContainer(service: IService): Promise<string> {
try { try {
this.validateServiceSpec(service);
await this.assertPublishedPortsAvailable(service);
// Check if Docker is in Swarm mode // Check if Docker is in Swarm mode
let isSwarmMode = false; let isSwarmMode = false;
try { try {
@@ -141,6 +405,8 @@ export class OneboxDockerManager {
env.push(`${key}=${value}`); env.push(`${key}=${value}`);
} }
const portConfig = this.getStandalonePortConfig(service);
// Create container using Docker REST API directly // Create container using Docker REST API directly
const response = await this.dockerClient!.request('POST', `/containers/create?name=onebox-${service.name}`, { const response = await this.dockerClient!.request('POST', `/containers/create?name=onebox-${service.name}`, {
Image: fullImage, Image: fullImage,
@@ -149,18 +415,14 @@ export class OneboxDockerManager {
'managed-by': 'onebox', 'managed-by': 'onebox',
'onebox-service': service.name, 'onebox-service': service.name,
}, },
ExposedPorts: { ExposedPorts: portConfig.exposedPorts,
[`${service.port}/tcp`]: {},
},
HostConfig: { HostConfig: {
NetworkMode: this.networkName, NetworkMode: this.networkName,
RestartPolicy: { RestartPolicy: {
Name: 'unless-stopped', Name: 'unless-stopped',
}, },
PortBindings: { PortBindings: portConfig.portBindings,
// Don't bind to host ports - nginx will proxy Binds: this.getStandaloneVolumeBinds(service),
[`${service.port}/tcp`]: [],
},
}, },
}); });
@@ -190,6 +452,25 @@ export class OneboxDockerManager {
env.push(`${key}=${value}`); env.push(`${key}=${value}`);
} }
const expandedPublishedPorts = this.expandPublishedPorts(service);
const endpointPorts: Array<Record<string, unknown>> = [];
if (!expandedPublishedPorts.some((publishedPort) => publishedPort.protocol === 'tcp' && publishedPort.targetPort === service.port)) {
endpointPorts.push({
Protocol: 'tcp',
TargetPort: service.port,
PublishMode: 'host',
});
}
for (const publishedPort of expandedPublishedPorts) {
endpointPorts.push({
Protocol: publishedPort.protocol,
TargetPort: publishedPort.targetPort,
PublishedPort: publishedPort.publishedPort,
PublishMode: 'host',
});
}
// Create Swarm service using Docker REST API // Create Swarm service using Docker REST API
const response = await this.dockerClient!.request('POST', '/services/create', { const response = await this.dockerClient!.request('POST', '/services/create', {
Name: `onebox-${service.name}`, Name: `onebox-${service.name}`,
@@ -201,6 +482,7 @@ export class OneboxDockerManager {
ContainerSpec: { ContainerSpec: {
Image: fullImage, Image: fullImage,
Env: env, Env: env,
Mounts: this.getSwarmVolumeMounts(service),
Labels: { Labels: {
'managed-by': 'onebox', 'managed-by': 'onebox',
'onebox-service': service.name, 'onebox-service': service.name,
@@ -222,13 +504,7 @@ export class OneboxDockerManager {
}, },
}, },
EndpointSpec: { EndpointSpec: {
Ports: [ Ports: endpointPorts,
{
Protocol: 'tcp',
TargetPort: service.port,
PublishMode: 'host',
},
],
}, },
}); });
@@ -935,8 +1211,9 @@ export class OneboxDockerManager {
logger.info(`Pulling image for platform service: ${options.image}`); logger.info(`Pulling image for platform service: ${options.image}`);
await this.pullImage(options.image); await this.pullImage(options.image);
// Check if container already exists // Check running and stopped containers; stopped platform containers still reserve names.
const existingContainers = await this.dockerClient!.listContainers(); 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) => const existing = existingContainers.find((c: any) =>
c.Names?.some((n: string) => n === `/${options.name}` || n === options.name) c.Names?.some((n: string) => n === `/${options.name}` || n === options.name)
); );
+16 -6
View File
@@ -97,7 +97,11 @@ export class CredentialEncryption {
*/ */
async encrypt(data: Record<string, string>): Promise<string> { async encrypt(data: Record<string, string>): Promise<string> {
if (!this.key) { 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)); const iv = crypto.getRandomValues(new Uint8Array(this.ivLength));
@@ -105,7 +109,7 @@ export class CredentialEncryption {
const ciphertext = await crypto.subtle.encrypt( const ciphertext = await crypto.subtle.encrypt(
{ name: this.algorithm, iv }, { name: this.algorithm, iv },
this.key, key,
encoded encoded
); );
@@ -120,9 +124,15 @@ export class CredentialEncryption {
/** /**
* Decrypt a base64 string back to credentials object * 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) { 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); const combined = this.base64ToBytes(encrypted);
@@ -133,12 +143,12 @@ export class CredentialEncryption {
const decrypted = await crypto.subtle.decrypt( const decrypted = await crypto.subtle.decrypt(
{ name: this.algorithm, iv }, { name: this.algorithm, iv },
this.key, key,
ciphertext ciphertext
); );
const decoded = new TextDecoder().decode(decrypted); const decoded = new TextDecoder().decode(decrypted);
return JSON.parse(decoded); return JSON.parse(decoded) as T;
} }
/** /**
+668
View File
@@ -0,0 +1,668 @@
import * as plugins from '../plugins.ts';
import { logger } from '../logging.ts';
import { getErrorMessage } from '../utils/error.ts';
import { normalizeHostname } from '../utils/domain.ts';
import { OneboxDatabase } from './database.ts';
import type { IDomain, IService } from '../types.ts';
import type { TDcRouterMode } from './managed-dcrouter.ts';
const adminUiRouteName = 'onebox-admin-ui';
type TWorkHosterType = 'onebox';
type TExternalGatewayRoute = Pick<IService, 'id' | 'name' | 'domain' | 'status'> & {
domain: string;
};
interface IExternalGatewayConfig {
url: string;
apiToken: string;
gatewayClientType?: TWorkHosterType;
gatewayClientId?: string;
/** @deprecated Use gatewayClientId. */
workHosterId?: string;
targetHost?: string;
targetPort?: number;
}
interface IGatewayClientContextResponse {
context: {
role: 'admin' | 'gatewayClient' | 'operator';
gatewayClient?: {
type: 'onebox' | 'cloudly' | 'custom';
id: string;
};
};
}
interface IWorkHosterDomain {
id?: string;
name: string;
source?: 'dcrouter' | 'provider';
authoritative?: boolean;
providerId?: string;
serviceCount?: number;
managePath?: string;
manageUrl?: string;
capabilities?: {
canCreateSubdomains: boolean;
canManageDnsRecords: boolean;
canIssueCertificates: boolean;
canHostEmail: boolean;
};
}
interface IGatewayDnsRecord {
id: string;
domainId: string;
domainName?: string;
name: string;
type: string;
value: string;
ttl: number;
source: string;
status: 'active' | 'missing';
gatewayClientType: 'onebox' | 'cloudly' | 'custom';
gatewayClientId: string;
appId: string;
hostname: string;
routeId?: string;
serviceName?: string;
managePath?: string;
manageUrl?: string;
}
interface IWorkAppRouteOwnership {
workHosterType: TWorkHosterType;
workHosterId: string;
workAppId: string;
hostname: string;
}
interface IGatewayClientOwnership {
gatewayClientType?: TWorkHosterType;
gatewayClientId?: string;
appId: 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();
await this.syncServiceRoutes();
}
public async syncServiceRoutes(): Promise<void> {
const adminUiRoute = this.getAdminUiRoute();
const adminUiDomain = adminUiRoute?.domain;
const services = this.database.getAllServices()
.filter((service) =>
service.domain && service.status === 'running' && service.domain !== adminUiDomain
);
const activeHostnames = new Set(services.map((service) => service.domain!));
if (adminUiRoute) {
activeHostnames.add(adminUiRoute.domain);
try {
await this.syncGatewayRoute(adminUiRoute);
} catch (error) {
logger.warn(
`Failed to sync external gateway route for ${adminUiRoute.domain}: ${
getErrorMessage(error)
}`,
);
}
}
for (const service of services) {
try {
await this.syncServiceRoute(service);
} catch (error) {
logger.warn(
`Failed to sync external gateway route for ${service.domain}: ${getErrorMessage(error)}`,
);
}
}
await this.deleteStaleServiceRoutes(activeHostnames);
}
private async deleteStaleServiceRoutes(activeHostnamesArg: Set<string>): Promise<void> {
const records = await this.getGatewayDnsRecords();
const staleRecordsByHostname = new Map<string, IGatewayDnsRecord>();
for (const record of records) {
if (!record.hostname || activeHostnamesArg.has(record.hostname)) continue;
if (this.shouldPreserveUnconfiguredAdminUiRecord(record)) continue;
if (!record.routeId && !record.appId && !record.serviceName) continue;
staleRecordsByHostname.set(record.hostname, record);
}
for (const record of staleRecordsByHostname.values()) {
try {
await this.deleteServiceRoute({
name: record.serviceName || record.appId,
domain: record.hostname,
});
} catch (error) {
logger.warn(
`Failed to delete stale external gateway route for ${record.hostname}: ${
getErrorMessage(error)
}`,
);
}
}
}
public async isConfigured(): Promise<boolean> {
if (this.getMode() === 'disabled') {
return false;
}
const mode = this.getMode();
const url = mode === 'managed'
? this.oneboxRef.managedDcRouter.getGatewayUrl()
: this.normalizeUrl(this.database.getSetting('dcrouterGatewayUrl') || '');
const apiToken = mode === 'managed'
? await this.oneboxRef.managedDcRouter.getAdminToken()
: await this.database.getSecretSetting('dcrouterGatewayApiToken');
return Boolean(url && apiToken);
}
public async syncDomains(): Promise<IDomain[]> {
if (!(await this.isConfigured())) {
return this.database.getDomainsByProvider('dcrouter');
}
const response = { domains: await this.getGatewayDomains() };
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 getGatewayDomains(): Promise<IWorkHosterDomain[]> {
const config = await this.getConfig({ requireTarget: false });
if (!config) return [];
try {
const response = await this.fireDcRouterRequest<{ domains: IWorkHosterDomain[] }>(
'getGatewayClientDomains',
config.gatewayClientId ? { gatewayClientId: config.gatewayClientId } : {},
config,
);
return response.domains.map((domain) => ({
...domain,
manageUrl: this.buildManageUrl(config, domain.managePath),
}));
} catch (error) {
logger.debug(`Falling back to legacy gateway domain API: ${getErrorMessage(error)}`);
const response = await this.fireDcRouterRequest<{ domains: IWorkHosterDomain[] }>(
'getWorkHosterDomains',
{},
config,
);
return response.domains.map((domain) => ({
...domain,
manageUrl: this.buildManageUrl(config, domain.managePath),
}));
}
}
public async getGatewayDnsRecords(): Promise<IGatewayDnsRecord[]> {
const config = await this.getConfig({ requireTarget: false });
if (!config) return [];
try {
const response = await this.fireDcRouterRequest<{ records: IGatewayDnsRecord[] }>(
'getGatewayClientDnsRecords',
config.gatewayClientId ? { gatewayClientId: config.gatewayClientId } : {},
config,
);
return response.records.map((record) => ({
...record,
serviceName: record.serviceName || record.appId,
manageUrl: this.buildManageUrl(config, record.managePath),
}));
} catch (error) {
logger.warn(`Failed to fetch gateway DNS records: ${getErrorMessage(error)}`);
return [];
}
}
public async syncServiceRoute(service: IService): Promise<void> {
if (!service.domain) return;
await this.syncGatewayRoute({
id: service.id,
name: service.name,
domain: service.domain,
status: service.status,
});
}
public async syncAdminUiRoute(): Promise<void> {
const route = this.getAdminUiRoute();
if (!route) return;
await this.syncGatewayRoute(route);
}
public async deleteAdminUiRoute(domain: string): Promise<void> {
const normalizedDomain = normalizeHostname(domain);
if (!normalizedDomain) return;
await this.deleteServiceRoute({
name: adminUiRouteName,
domain: normalizedDomain,
});
}
private async syncGatewayRoute(route: TExternalGatewayRoute): Promise<void> {
if (!route.domain) return;
const config = await this.getConfig({ requireTarget: true });
if (!config) return;
const result = await this.fireDcRouterRequest<IWorkAppRouteSyncResult>(
'syncGatewayClientRoute',
{
ownership: this.buildGatewayClientOwnership(route, route.domain, config),
route: this.buildRoute(route, config),
enabled: route.status === 'running',
},
config,
).catch(async () => {
return await this.fireDcRouterRequest<IWorkAppRouteSyncResult>(
'syncWorkAppRoute',
{
ownership: this.buildOwnership(route, route.domain, config),
route: this.buildRoute(route, config),
enabled: route.status === 'running',
},
config,
);
});
if (!result.success) {
throw new Error(result.message || `dcrouter route sync failed for ${route.domain}`);
}
logger.success(`External gateway route ${result.action || 'synced'} for ${route.domain}`);
await this.importCertificateForDomain(route.domain).catch((error) => {
logger.debug(
`External gateway certificate import skipped for ${route.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>(
'syncGatewayClientRoute',
{
ownership: this.buildGatewayClientOwnership(service, service.domain, config),
delete: true,
},
config,
).catch(async () => {
return 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 mode = this.getMode();
if (mode === 'disabled') {
return null;
}
const url = mode === 'managed'
? this.oneboxRef.managedDcRouter.getGatewayUrl()
: this.normalizeUrl(this.database.getSetting('dcrouterGatewayUrl') || '');
const apiToken = mode === 'managed'
? await this.oneboxRef.managedDcRouter.getAdminToken()
: await this.database.getSecretSetting('dcrouterGatewayApiToken');
if (!url || !apiToken) {
return null;
}
const config: IExternalGatewayConfig = {
url,
apiToken,
};
const contextClient = await this.getGatewayClientFromToken(config);
if (contextClient) {
config.gatewayClientType = contextClient.type;
config.gatewayClientId = contextClient.id;
config.workHosterId = contextClient.id;
} else {
const fallbackGatewayClientId = mode === 'managed'
? this.oneboxRef.managedDcRouter.ensureGatewayClientId()
: this.getStoredGatewayClientId();
if (fallbackGatewayClientId) {
config.gatewayClientType = 'onebox';
config.gatewayClientId = fallbackGatewayClientId;
config.workHosterId = fallbackGatewayClientId;
}
}
if (options.requireTarget !== false) {
if (mode === 'managed') {
const target = this.oneboxRef.managedDcRouter.getRouteTarget();
config.targetHost = target.host;
config.targetPort = target.port;
} else {
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 getMode(): TDcRouterMode {
return this.oneboxRef.managedDcRouter?.getMode?.() || 'external';
}
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 getStoredGatewayClientId(): string {
return this.database.getSetting('dcrouterGatewayClientId') || this.database.getSetting('dcrouterWorkHosterId') || '';
}
private async getGatewayClientFromToken(config: IExternalGatewayConfig): Promise<{ type: TWorkHosterType; id: string } | null> {
try {
const response = await this.fireDcRouterRequest<IGatewayClientContextResponse>(
'getGatewayClientContext',
{},
config,
);
const gatewayClient = response.context.gatewayClient;
if (!gatewayClient) return null;
if (gatewayClient.type !== 'onebox') {
throw new Error(`dcrouter token is bound to unsupported gateway client type: ${gatewayClient.type}`);
}
return { type: gatewayClient.type, id: gatewayClient.id };
} catch (error) {
logger.debug(`dcrouter gateway client context unavailable: ${getErrorMessage(error)}`);
return null;
}
}
private buildOwnership(
service: Pick<IService, 'id' | 'name'>,
hostname: string,
config: IExternalGatewayConfig,
): IWorkAppRouteOwnership {
return {
workHosterType: 'onebox',
workHosterId: config.gatewayClientId || '',
workAppId: service.name || `service-${service.id}`,
hostname,
};
}
private buildGatewayClientOwnership(
service: Pick<IService, 'id' | 'name'>,
hostname: string,
config: IExternalGatewayConfig,
): IGatewayClientOwnership {
const ownership: IGatewayClientOwnership = {
gatewayClientType: config.gatewayClientType || 'onebox',
appId: service.name || `service-${service.id}`,
hostname,
};
if (config.gatewayClientId) {
ownership.gatewayClientId = config.gatewayClientId;
}
return ownership;
}
private getAdminUiRoute(): TExternalGatewayRoute | null {
const domain = normalizeHostname(this.database.getSetting('adminUiDomain') || '');
if (!domain) return null;
return {
id: 0,
name: adminUiRouteName,
domain,
status: 'running',
};
}
private isAdminUiRecord(record: IGatewayDnsRecord): boolean {
const ownerName = record.serviceName || record.appId;
return ownerName === adminUiRouteName || ownerName === 'onebox';
}
private shouldPreserveUnconfiguredAdminUiRecord(record: IGatewayDnsRecord): boolean {
return this.database.getSetting('adminUiDomain') === null && this.isAdminUiRecord(record);
}
private buildRoute(
route: TExternalGatewayRoute,
config: IExternalGatewayConfig,
): IDcRouterRouteConfig {
return {
name: this.routeName(route.domain),
match: {
ports: [443],
domains: [route.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 buildManageUrl(config: IExternalGatewayConfig, managePath?: string): string {
const normalizedPath = managePath?.startsWith('/') ? managePath : managePath ? `/${managePath}` : '';
return `${config.url}${normalizedPath}`;
}
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
+354
View File
@@ -0,0 +1,354 @@
import * as plugins from '../plugins.ts';
import { logger } from '../logging.ts';
import { getErrorMessage } from '../utils/error.ts';
import { OneboxDatabase } from './database.ts';
export type TDcRouterMode = 'managed' | 'external' | 'disabled';
export interface IManagedDcRouterStatus {
mode: TDcRouterMode;
configured: boolean;
running: boolean;
healthy: boolean;
containerId?: string;
image: string;
gatewayUrl: string;
opsPort: number;
httpPort: number;
httpsPort: number;
message?: string;
}
const containerName = 'onebox-dcrouter';
const defaultImage = 'code.foss.global/serve.zone/dcrouter:latest';
const defaultDataDir = './.nogit/dcrouter-data';
const defaultOpsPort = 3300;
const defaultHttpPort = 80;
const defaultHttpsPort = 443;
const internalBaseDir = '/data';
export class ManagedDcRouterManager {
private database: OneboxDatabase;
private dockerClient: InstanceType<typeof plugins.docker.Docker> | null = null;
constructor(private oneboxRef: any) {
this.database = oneboxRef.database;
}
public getMode(): TDcRouterMode {
const storedMode = this.database.getSetting('dcrouterMode');
if (storedMode === 'managed' || storedMode === 'external' || storedMode === 'disabled') {
return storedMode;
}
const hasExternalGateway = Boolean(this.database.getSetting('dcrouterGatewayUrl'));
return hasExternalGateway ? 'external' : 'managed';
}
public getImage(): string {
return this.database.getSetting('dcrouterManagedImage') || defaultImage;
}
public getOpsPort(): number {
return this.parsePort(this.database.getSetting('dcrouterManagedOpsPort'), defaultOpsPort);
}
public getHttpPort(): number {
return this.parsePort(this.database.getSetting('dcrouterManagedHttpPort'), defaultHttpPort);
}
public getHttpsPort(): number {
return this.parsePort(this.database.getSetting('dcrouterManagedHttpsPort'), defaultHttpsPort);
}
public getDataDir(): string {
return this.database.getSetting('dcrouterManagedDataDir') || defaultDataDir;
}
public getGatewayUrl(): string {
return `http://127.0.0.1:${this.getOpsPort()}`;
}
public getRouteTarget(): { host: string; port: number } {
return {
host: 'onebox-smartproxy',
port: 80,
};
}
public ensureGatewayClientId(): string {
let gatewayClientId = this.database.getSetting('dcrouterGatewayClientId')
|| this.database.getSetting('dcrouterWorkHosterId');
if (!gatewayClientId) {
gatewayClientId = `onebox-${crypto.randomUUID()}`;
this.database.setSetting('dcrouterGatewayClientId', gatewayClientId);
}
return gatewayClientId;
}
public async getAdminToken(): Promise<string> {
const existingToken = await this.database.getSecretSetting('dcrouterManagedAdminApiToken');
if (existingToken) {
return existingToken;
}
const token = `dcr_${crypto.randomUUID().replaceAll('-', '')}${crypto.randomUUID().replaceAll('-', '')}`;
await this.database.setSecretSetting('dcrouterManagedAdminApiToken', token);
return token;
}
public async prepareGatewaySettings(): Promise<void> {
if (this.getMode() !== 'managed') {
return;
}
const target = this.getRouteTarget();
this.database.setSetting('dcrouterMode', 'managed');
this.database.setSetting('dcrouterGatewayUrl', this.getGatewayUrl());
this.database.setSetting('dcrouterTargetHost', target.host);
this.database.setSetting('dcrouterTargetPort', String(target.port));
this.ensureGatewayClientId();
await this.getAdminToken();
}
public async init(): Promise<void> {
if (this.getMode() === 'managed') {
await this.start();
return;
}
await this.stop();
}
public async start(options: { recreate?: boolean } = {}): Promise<IManagedDcRouterStatus> {
if (this.getMode() !== 'managed') {
throw new Error('Managed dcrouter mode is not enabled');
}
await this.prepareGatewaySettings();
await this.ensureDockerClient();
if (options.recreate) {
await this.removeExistingContainer();
}
const existingContainer = await this.getExistingContainer();
if (existingContainer) {
if (this.isContainerRunning(existingContainer)) {
await this.waitForReady().catch((error) => {
logger.warn(`Managed dcrouter readiness check failed: ${getErrorMessage(error)}`);
});
return await this.getStatus();
}
await this.startContainer(existingContainer.Id);
await this.waitForReady();
return await this.getStatus();
}
await this.createContainer();
await this.waitForReady();
return await this.getStatus();
}
public async stop(): Promise<IManagedDcRouterStatus> {
await this.ensureDockerClient();
const existingContainer = await this.getExistingContainer();
if (existingContainer && this.isContainerRunning(existingContainer)) {
await this.stopContainer(existingContainer.Id);
}
return await this.getStatus();
}
public async restart(): Promise<IManagedDcRouterStatus> {
return await this.start({ recreate: true });
}
public async getStatus(): Promise<IManagedDcRouterStatus> {
const baseStatus: IManagedDcRouterStatus = {
mode: this.getMode(),
configured: this.getMode() === 'managed',
running: false,
healthy: false,
image: this.getImage(),
gatewayUrl: this.getGatewayUrl(),
opsPort: this.getOpsPort(),
httpPort: this.getHttpPort(),
httpsPort: this.getHttpsPort(),
};
try {
await this.ensureDockerClient();
const existingContainer = await this.getExistingContainer();
if (!existingContainer) {
return baseStatus;
}
const running = this.isContainerRunning(existingContainer);
return {
...baseStatus,
running,
healthy: running ? await this.checkHealthy() : false,
containerId: existingContainer.Id,
};
} catch (error) {
return {
...baseStatus,
message: getErrorMessage(error),
};
}
}
private async ensureDockerClient(): Promise<void> {
if (!this.dockerClient) {
this.dockerClient = new plugins.docker.Docker({
socketPath: 'unix:///var/run/docker.sock',
});
await this.dockerClient.start();
}
}
private parsePort(value: string | null, fallback: number): number {
if (!value) return fallback;
const port = Number(value);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
return fallback;
}
return port;
}
private async getAbsoluteDataDir(): Promise<string> {
const dataDir = plugins.path.resolve(this.getDataDir());
await Deno.mkdir(dataDir, { recursive: true });
return dataDir;
}
private async createContainer(): Promise<void> {
const image = this.getImage();
const token = await this.getAdminToken();
const dataDir = await this.getAbsoluteDataDir();
await this.writeManagedConfig(dataDir);
await this.oneboxRef.docker.pullImage(image);
const response = await this.dockerClient!.request('POST', `/containers/create?name=${containerName}`, {
Image: image,
Env: [
`DCROUTER_BASE_DIR=${internalBaseDir}`,
`DCROUTER_CONFIG_PATH=${internalBaseDir}/managed-config.json`,
`DCROUTER_ADMIN_API_TOKEN=${token}`,
'DCROUTER_ADMIN_API_TOKEN_NAME=Onebox Managed Admin Token',
],
Labels: {
'managed-by': 'onebox',
'onebox-type': 'dcrouter',
},
ExposedPorts: {
'80/tcp': {},
'443/tcp': {},
'3000/tcp': {},
},
HostConfig: {
NetworkMode: 'onebox-network',
RestartPolicy: {
Name: 'unless-stopped',
},
Binds: [`${dataDir}:${internalBaseDir}`],
PortBindings: {
'80/tcp': [{ HostIp: '0.0.0.0', HostPort: String(this.getHttpPort()) }],
'443/tcp': [{ HostIp: '0.0.0.0', HostPort: String(this.getHttpsPort()) }],
'3000/tcp': [{ HostIp: '127.0.0.1', HostPort: String(this.getOpsPort()) }],
},
},
});
if (response.statusCode >= 300) {
throw new Error(`Failed to create managed dcrouter container: HTTP ${response.statusCode} - ${JSON.stringify(response.body)}`);
}
await this.startContainer(response.body.Id);
logger.success(`Managed dcrouter container started: ${response.body.Id}`);
}
private async writeManagedConfig(dataDirArg: string): Promise<void> {
const configPath = plugins.path.join(dataDirArg, 'managed-config.json');
try {
const existingConfig = await Deno.readTextFile(configPath);
JSON.parse(existingConfig);
return;
} catch (error) {
if (!(error instanceof Deno.errors.NotFound)) {
throw new Error(`Managed dcrouter config exists but is not valid JSON: ${getErrorMessage(error)}`);
}
}
const config = {
smartProxyConfig: {
routes: [],
},
};
await Deno.writeTextFile(configPath, JSON.stringify(config, null, 2));
}
private async getExistingContainer(): Promise<any | null> {
const filters = encodeURIComponent(JSON.stringify({ name: [containerName] }));
const response = await this.dockerClient!.request('GET', `/containers/json?all=true&filters=${filters}`, {});
if (response.statusCode >= 300 || !Array.isArray(response.body)) {
return null;
}
return response.body.find((container: any) => {
return container.Names?.some((name: string) => name === `/${containerName}` || name === containerName);
}) ?? null;
}
private isContainerRunning(container: any): boolean {
return container.State === 'running' || Boolean(container.Status?.toLowerCase().startsWith('up '));
}
private async startContainer(containerId: string): Promise<void> {
const response = await this.dockerClient!.request('POST', `/containers/${containerId}/start`, {});
if (response.statusCode >= 300 && response.statusCode !== 304) {
throw new Error(`Failed to start managed dcrouter container: HTTP ${response.statusCode}`);
}
}
private async stopContainer(containerId: string): Promise<void> {
const response = await this.dockerClient!.request('POST', `/containers/${containerId}/stop`, {});
if (response.statusCode >= 300 && response.statusCode !== 304) {
throw new Error(`Failed to stop managed dcrouter container: HTTP ${response.statusCode}`);
}
}
private async removeExistingContainer(): Promise<void> {
const existingContainer = await this.getExistingContainer();
if (!existingContainer) {
return;
}
const response = await this.dockerClient!.request('DELETE', `/containers/${existingContainer.Id}?force=true`, {});
if (response.statusCode >= 300) {
throw new Error(`Failed to remove managed dcrouter container: HTTP ${response.statusCode}`);
}
}
private async checkHealthy(): Promise<boolean> {
try {
const response = await fetch(this.getGatewayUrl());
return response.ok;
} catch {
return false;
}
}
private async waitForReady(maxAttempts = 30, intervalMs = 1000): Promise<void> {
for (let i = 0; i < maxAttempts; i++) {
if (await this.checkHealthy()) {
return;
}
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
throw new Error('Managed dcrouter did not become ready in time');
}
}
+83 -24
View File
@@ -5,7 +5,9 @@
*/ */
import { logger } from '../logging.ts'; import { logger } from '../logging.ts';
import { projectInfo } from '../info.ts';
import { getErrorMessage } from '../utils/error.ts'; import { getErrorMessage } from '../utils/error.ts';
import { hashPassword } from '../utils/auth.ts';
import { OneboxDatabase } from './database.ts'; import { OneboxDatabase } from './database.ts';
import { OneboxDockerManager } from './docker.ts'; import { OneboxDockerManager } from './docker.ts';
import { OneboxServicesManager } from './services.ts'; import { OneboxServicesManager } from './services.ts';
@@ -15,15 +17,17 @@ import { OneboxDnsManager } from './dns.ts';
import { OneboxSslManager } from './ssl.ts'; import { OneboxSslManager } from './ssl.ts';
import { OneboxDaemon } from './daemon.ts'; import { OneboxDaemon } from './daemon.ts';
import { OneboxSystemd } from './systemd.ts'; import { OneboxSystemd } from './systemd.ts';
import { OneboxHttpServer } from './httpserver.ts';
import { CloudflareDomainSync } from './cloudflare-sync.ts'; import { CloudflareDomainSync } from './cloudflare-sync.ts';
import { CertRequirementManager } from './cert-requirement-manager.ts'; import { CertRequirementManager } from './cert-requirement-manager.ts';
import { RegistryManager } from './registry.ts'; import { RegistryManager } from './registry.ts';
import { PlatformServicesManager } from './platform-services/index.ts'; import { PlatformServicesManager } from './platform-services/index.ts';
import { AppStoreManager } from './appstore.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 { BackupManager } from './backup-manager.ts';
import { BackupScheduler } from './backup-scheduler.ts'; import { BackupScheduler } from './backup-scheduler.ts';
import { ExternalGatewayManager } from './external-gateway.ts';
import { ManagedDcRouterManager } from './managed-dcrouter.ts';
import { OneboxUpdateManager } from './update-manager.ts';
import { OpsServer } from '../opsserver/index.ts'; import { OpsServer } from '../opsserver/index.ts';
export class Onebox { export class Onebox {
@@ -36,15 +40,17 @@ export class Onebox {
public ssl: OneboxSslManager; public ssl: OneboxSslManager;
public daemon: OneboxDaemon; public daemon: OneboxDaemon;
public systemd: OneboxSystemd; public systemd: OneboxSystemd;
public httpServer: OneboxHttpServer;
public cloudflareDomainSync: CloudflareDomainSync; public cloudflareDomainSync: CloudflareDomainSync;
public certRequirementManager: CertRequirementManager; public certRequirementManager: CertRequirementManager;
public registry: RegistryManager; public registry: RegistryManager;
public platformServices: PlatformServicesManager; public platformServices: PlatformServicesManager;
public appStore: AppStoreManager; public appStore: AppStoreManager;
public caddyLogReceiver: CaddyLogReceiver; public proxyLogReceiver: ProxyLogReceiver;
public backupManager: BackupManager; public backupManager: BackupManager;
public backupScheduler: BackupScheduler; public backupScheduler: BackupScheduler;
public managedDcRouter: ManagedDcRouterManager;
public externalGateway: ExternalGatewayManager;
public updateManager: OneboxUpdateManager;
public opsServer: OpsServer; public opsServer: OpsServer;
private initialized = false; private initialized = false;
@@ -62,11 +68,10 @@ export class Onebox {
this.ssl = new OneboxSslManager(this); this.ssl = new OneboxSslManager(this);
this.daemon = new OneboxDaemon(this); this.daemon = new OneboxDaemon(this);
this.systemd = new OneboxSystemd(); this.systemd = new OneboxSystemd();
this.httpServer = new OneboxHttpServer(this);
this.registry = new RegistryManager({ this.registry = new RegistryManager({
dataDir: './.nogit/registry-data', dataDir: './.nogit/registry-data',
port: 4000, port: 4000,
baseUrl: 'localhost:5000', baseUrl: 'localhost:3000',
}); });
// Initialize domain management // Initialize domain management
@@ -79,8 +84,8 @@ export class Onebox {
// Initialize App Store manager // Initialize App Store manager
this.appStore = new AppStoreManager(this); this.appStore = new AppStoreManager(this);
// Initialize Caddy log receiver // Initialize reverse proxy log receiver
this.caddyLogReceiver = new CaddyLogReceiver(9999); this.proxyLogReceiver = new ProxyLogReceiver(9999);
// Initialize Backup manager // Initialize Backup manager
this.backupManager = new BackupManager(this); this.backupManager = new BackupManager(this);
@@ -88,6 +93,11 @@ export class Onebox {
// Initialize Backup scheduler // Initialize Backup scheduler
this.backupScheduler = new BackupScheduler(this); this.backupScheduler = new BackupScheduler(this);
// Initialize optional dcrouter gateway integration
this.managedDcRouter = new ManagedDcRouterManager(this);
this.externalGateway = new ExternalGatewayManager(this);
this.updateManager = new OneboxUpdateManager();
// Initialize OpsServer (TypedRequest-based server) // Initialize OpsServer (TypedRequest-based server)
this.opsServer = new OpsServer(this); this.opsServer = new OpsServer(this);
} }
@@ -108,11 +118,25 @@ export class Onebox {
// Initialize Docker // Initialize Docker
await this.docker.init(); await this.docker.init();
// Start Caddy log receiver BEFORE reverse proxy (so Caddy can connect to it)
try { try {
await this.caddyLogReceiver.start(); await this.managedDcRouter.prepareGatewaySettings();
} catch (error) { } catch (error) {
logger.warn(`Failed to start Caddy log receiver: ${getErrorMessage(error)}`); logger.warn(`Managed dcrouter settings preparation failed: ${getErrorMessage(error)}`);
}
if (this.managedDcRouter.getMode() !== 'managed') {
try {
await this.managedDcRouter.stop();
} catch (error) {
logger.warn(`Failed to stop inactive managed dcrouter: ${getErrorMessage(error)}`);
}
}
// Start proxy log receiver before reverse proxy startup.
try {
await this.proxyLogReceiver.start();
} catch (error) {
logger.warn(`Failed to start proxy log receiver: ${getErrorMessage(error)}`);
} }
// Initialize Reverse Proxy // Initialize Reverse Proxy
@@ -125,8 +149,9 @@ export class Onebox {
// Start HTTP reverse proxy (non-critical - don't fail init if ports are busy) // Start HTTP reverse proxy (non-critical - don't fail init if ports are busy)
// Use 8080/8443 in dev mode to avoid permission issues // Use 8080/8443 in dev mode to avoid permission issues
const isDev = Deno.env.get('ONEBOX_DEV') === 'true' || Deno.args.includes('--ephemeral'); const isDev = Deno.env.get('ONEBOX_DEV') === 'true' || Deno.args.includes('--ephemeral');
const httpPort = isDev ? 8080 : 80; const isManagedDcRouter = this.managedDcRouter.getMode() === 'managed';
const httpsPort = isDev ? 8443 : 443; const httpPort = isDev || isManagedDcRouter ? 8080 : 80;
const httpsPort = isDev || isManagedDcRouter ? 8443 : 443;
try { try {
await this.reverseProxy.startHttp(httpPort); await this.reverseProxy.startHttp(httpPort);
@@ -162,6 +187,22 @@ export class Onebox {
logger.warn('Cloudflare domain sync initialization failed - domain sync will be limited'); logger.warn('Cloudflare domain sync initialization failed - domain sync will be limited');
} }
// Initialize managed local dcrouter before syncing delegated routes.
try {
await this.managedDcRouter.init();
} catch (error) {
logger.warn('Managed dcrouter initialization failed - local gateway sync will be disabled');
logger.warn(`Error: ${getErrorMessage(error)}`);
}
// 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) // Initialize Onebox Registry (non-critical)
try { try {
await this.registry.init(); await this.registry.init();
@@ -221,24 +262,31 @@ export class Onebox {
*/ */
private async ensureDefaultUser(): Promise<void> { private async ensureDefaultUser(): Promise<void> {
try { try {
const adminUser = this.database.getUserByUsername('admin'); const adminUsername = Deno.env.get('ONEBOX_ADMIN_USERNAME') || 'admin';
const adminUser = this.database.getUserByUsername(adminUsername);
if (!adminUser) { 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 configuredPassword = Deno.env.get('ONEBOX_ADMIN_PASSWORD');
const passwordHash = btoa('admin'); const initialPassword = configuredPassword || crypto.randomUUID().replaceAll('-', '');
const passwordHash = await hashPassword(initialPassword);
await this.database.createUser({ await this.database.createUser({
username: 'admin', username: adminUsername,
passwordHash, passwordHash,
role: 'admin', role: 'admin',
createdAt: Date.now(), createdAt: Date.now(),
updatedAt: Date.now(), updatedAt: Date.now(),
}); });
logger.warn('Default admin user created with username: admin, password: admin'); if (configuredPassword) {
logger.warn('IMPORTANT: Change the default password immediately!'); 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) { } catch (error) {
logger.error(`Failed to create default user: ${getErrorMessage(error)}`); logger.error(`Failed to create default user: ${getErrorMessage(error)}`);
@@ -261,6 +309,7 @@ export class Onebox {
const proxyStatus = this.reverseProxy.getStatus(); const proxyStatus = this.reverseProxy.getStatus();
const dnsConfigured = this.dns.isConfigured(); const dnsConfigured = this.dns.isConfigured();
const sslConfigured = this.ssl.isConfigured(); const sslConfigured = this.ssl.isConfigured();
const oneboxUpdate = await this.updateManager.getUpdateStatus();
const services = this.services.listServices(); const services = this.services.listServices();
const runningServices = services.filter((s) => s.status === 'running').length; const runningServices = services.filter((s) => s.status === 'running').length;
@@ -271,9 +320,9 @@ export class Onebox {
const providers = this.platformServices.getAllProviders(); const providers = this.platformServices.getAllProviders();
const platformServicesStatus = providers.map((provider) => { const platformServicesStatus = providers.map((provider) => {
const service = platformServices.find((s) => s.type === provider.type); 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'; let status = service?.status || 'not-deployed';
if (provider.type === 'caddy') { if (provider.type === 'smartproxy') {
status = proxyStatus.http.running ? 'running' : 'stopped'; status = proxyStatus.http.running ? 'running' : 'stopped';
} }
// Count resources for this platform service // Count resources for this platform service
@@ -363,6 +412,10 @@ export class Onebox {
} }
return { return {
onebox: {
version: projectInfo.version,
update: oneboxUpdate,
},
docker: { docker: {
running: dockerRunning, running: dockerRunning,
version: dockerRunning ? await this.docker.getDockerVersion() : null, version: dockerRunning ? await this.docker.getDockerVersion() : null,
@@ -435,12 +488,18 @@ export class Onebox {
// Stop reverse proxy if running // Stop reverse proxy if running
await this.reverseProxy.stop(); await this.reverseProxy.stop();
// Stop Caddy log receiver // Stop proxy log receiver
await this.caddyLogReceiver.stop(); await this.proxyLogReceiver.stop();
// Stop built-in registry and backing smartstorage server
await this.registry.stop();
// Close backup archive // Close backup archive
await this.backupManager.close(); await this.backupManager.close();
// Release Docker client resources after all Docker-backed managers stopped.
await this.docker.stop();
// Close database // Close database
this.database.close(); this.database.close();
+2 -2
View File
@@ -14,7 +14,7 @@ import type {
import type { IPlatformServiceProvider } from './providers/base.ts'; import type { IPlatformServiceProvider } from './providers/base.ts';
import { MongoDBProvider } from './providers/mongodb.ts'; import { MongoDBProvider } from './providers/mongodb.ts';
import { MinioProvider } from './providers/minio.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 { ClickHouseProvider } from './providers/clickhouse.ts';
import { MariaDBProvider } from './providers/mariadb.ts'; import { MariaDBProvider } from './providers/mariadb.ts';
import { RedisProvider } from './providers/redis.ts'; import { RedisProvider } from './providers/redis.ts';
@@ -41,7 +41,7 @@ export class PlatformServicesManager {
// Register providers // Register providers
this.registerProvider(new MongoDBProvider(this.oneboxRef)); this.registerProvider(new MongoDBProvider(this.oneboxRef));
this.registerProvider(new MinioProvider(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 ClickHouseProvider(this.oneboxRef));
this.registerProvider(new MariaDBProvider(this.oneboxRef)); this.registerProvider(new MariaDBProvider(this.oneboxRef));
this.registerProvider(new RedisProvider(this.oneboxRef)); this.registerProvider(new RedisProvider(this.oneboxRef));
@@ -103,6 +103,17 @@ export abstract class BasePlatformServiceProvider implements IPlatformServicePro
return `onebox-${this.type}`; 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 * 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 { return {
image: 'clickhouse/clickhouse-server:latest', image: 'clickhouse/clickhouse-server:latest',
port: 8123, // HTTP interface port: 8123, // HTTP interface
volumes: ['/var/lib/onebox/clickhouse:/var/lib/clickhouse'], volumes: [`${this.getPlatformDataDir('clickhouse')}:/var/lib/clickhouse`],
environment: { environment: {
CLICKHOUSE_DB: 'default', CLICKHOUSE_DB: 'default',
// Password will be generated and stored encrypted // Password will be generated and stored encrypted
@@ -53,7 +53,7 @@ export class ClickHouseProvider extends BasePlatformServiceProvider {
async deployContainer(): Promise<string> { async deployContainer(): Promise<string> {
const config = this.getDefaultConfig(); const config = this.getDefaultConfig();
const containerName = this.getContainerName(); const containerName = this.getContainerName();
const dataDir = '/var/lib/onebox/clickhouse'; const dataDir = this.getPlatformDataDir('clickhouse');
logger.info(`Deploying ClickHouse platform service as ${containerName}...`); logger.info(`Deploying ClickHouse platform service as ${containerName}...`);
@@ -76,7 +76,9 @@ export class ClickHouseProvider extends BasePlatformServiceProvider {
if (dataExists && platformService?.adminCredentialsEncrypted) { if (dataExists && platformService?.adminCredentialsEncrypted) {
// Reuse existing credentials from database // Reuse existing credentials from database
logger.info('Reusing existing ClickHouse credentials (data directory already initialized)'); 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 { } else {
// Generate new credentials for fresh deployment // Generate new credentials for fresh deployment
logger.info('Generating new ClickHouse admin credentials'); 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'); 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(); const containerName = this.getContainerName();
// Generate resource names and credentials // 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'); 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}'...`); logger.info(`Deprovisioning ClickHouse database '${resource.resourceName}'...`);
@@ -30,7 +30,7 @@ export class MariaDBProvider extends BasePlatformServiceProvider {
return { return {
image: 'mariadb:11', image: 'mariadb:11',
port: 3306, port: 3306,
volumes: ['/var/lib/onebox/mariadb:/var/lib/mysql'], volumes: [`${this.getPlatformDataDir('mariadb')}:/var/lib/mysql`],
environment: { environment: {
MARIADB_ROOT_PASSWORD: '', MARIADB_ROOT_PASSWORD: '',
// Password will be generated and stored encrypted // Password will be generated and stored encrypted
@@ -52,7 +52,7 @@ export class MariaDBProvider extends BasePlatformServiceProvider {
async deployContainer(): Promise<string> { async deployContainer(): Promise<string> {
const config = this.getDefaultConfig(); const config = this.getDefaultConfig();
const containerName = this.getContainerName(); const containerName = this.getContainerName();
const dataDir = '/var/lib/onebox/mariadb'; const dataDir = this.getPlatformDataDir('mariadb');
logger.info(`Deploying MariaDB platform service as ${containerName}...`); logger.info(`Deploying MariaDB platform service as ${containerName}...`);
@@ -74,7 +74,9 @@ export class MariaDBProvider extends BasePlatformServiceProvider {
if (dataExists && platformService?.adminCredentialsEncrypted) { if (dataExists && platformService?.adminCredentialsEncrypted) {
// Reuse existing credentials from database // Reuse existing credentials from database
logger.info('Reusing existing MariaDB credentials (data directory already initialized)'); 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 { } else {
// Generate new credentials for fresh deployment // Generate new credentials for fresh deployment
logger.info('Generating new MariaDB admin credentials'); logger.info('Generating new MariaDB admin credentials');
@@ -30,7 +30,7 @@ export class MinioProvider extends BasePlatformServiceProvider {
return { return {
image: 'minio/minio:latest', image: 'minio/minio:latest',
port: 9000, port: 9000,
volumes: ['/var/lib/onebox/minio:/data'], volumes: [`${this.getPlatformDataDir('minio')}:/data`],
command: 'server /data --console-address :9001', command: 'server /data --console-address :9001',
environment: { environment: {
MINIO_ROOT_USER: 'admin', MINIO_ROOT_USER: 'admin',
@@ -57,7 +57,7 @@ export class MinioProvider extends BasePlatformServiceProvider {
async deployContainer(): Promise<string> { async deployContainer(): Promise<string> {
const config = this.getDefaultConfig(); const config = this.getDefaultConfig();
const containerName = this.getContainerName(); const containerName = this.getContainerName();
const dataDir = '/var/lib/onebox/minio'; const dataDir = this.getPlatformDataDir('minio');
logger.info(`Deploying MinIO platform service as ${containerName}...`); logger.info(`Deploying MinIO platform service as ${containerName}...`);
@@ -80,7 +80,9 @@ export class MinioProvider extends BasePlatformServiceProvider {
if (dataExists && platformService?.adminCredentialsEncrypted) { if (dataExists && platformService?.adminCredentialsEncrypted) {
// Reuse existing credentials from database // Reuse existing credentials from database
logger.info('Reusing existing MinIO credentials (data directory already initialized)'); 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 { } else {
// Generate new credentials for fresh deployment // Generate new credentials for fresh deployment
logger.info('Generating new MinIO admin credentials'); logger.info('Generating new MinIO admin credentials');
@@ -30,7 +30,7 @@ export class MongoDBProvider extends BasePlatformServiceProvider {
return { return {
image: 'mongo:4.4', image: 'mongo:4.4',
port: 27017, port: 27017,
volumes: ['/var/lib/onebox/mongodb:/data/db'], volumes: [`${this.getPlatformDataDir('mongodb')}:/data/db`],
environment: { environment: {
MONGO_INITDB_ROOT_USERNAME: 'admin', MONGO_INITDB_ROOT_USERNAME: 'admin',
// Password will be generated and stored encrypted // Password will be generated and stored encrypted
@@ -52,7 +52,7 @@ export class MongoDBProvider extends BasePlatformServiceProvider {
async deployContainer(): Promise<string> { async deployContainer(): Promise<string> {
const config = this.getDefaultConfig(); const config = this.getDefaultConfig();
const containerName = this.getContainerName(); const containerName = this.getContainerName();
const dataDir = '/var/lib/onebox/mongodb'; const dataDir = this.getPlatformDataDir('mongodb');
logger.info(`Deploying MongoDB platform service as ${containerName}...`); logger.info(`Deploying MongoDB platform service as ${containerName}...`);
@@ -74,7 +74,9 @@ export class MongoDBProvider extends BasePlatformServiceProvider {
if (dataExists && platformService?.adminCredentialsEncrypted) { if (dataExists && platformService?.adminCredentialsEncrypted) {
// Reuse existing credentials from database // Reuse existing credentials from database
logger.info('Reusing existing MongoDB credentials (data directory already initialized)'); 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 { } else {
// Generate new credentials for fresh deployment // Generate new credentials for fresh deployment
logger.info('Generating new MongoDB admin credentials'); logger.info('Generating new MongoDB admin credentials');
@@ -30,7 +30,7 @@ export class RedisProvider extends BasePlatformServiceProvider {
return { return {
image: 'redis:7-alpine', image: 'redis:7-alpine',
port: 6379, port: 6379,
volumes: ['/var/lib/onebox/redis:/data'], volumes: [`${this.getPlatformDataDir('redis')}:/data`],
environment: {}, environment: {},
}; };
} }
@@ -48,7 +48,7 @@ export class RedisProvider extends BasePlatformServiceProvider {
async deployContainer(): Promise<string> { async deployContainer(): Promise<string> {
const config = this.getDefaultConfig(); const config = this.getDefaultConfig();
const containerName = this.getContainerName(); const containerName = this.getContainerName();
const dataDir = '/var/lib/onebox/redis'; const dataDir = this.getPlatformDataDir('redis');
logger.info(`Deploying Redis platform service as ${containerName}...`); logger.info(`Deploying Redis platform service as ${containerName}...`);
@@ -76,7 +76,9 @@ export class RedisProvider extends BasePlatformServiceProvider {
if (dataExists && platformService?.adminCredentialsEncrypted) { if (dataExists && platformService?.adminCredentialsEncrypted) {
// Reuse existing credentials from database // Reuse existing credentials from database
logger.info('Reusing existing Redis credentials (data directory already initialized)'); 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 { } else {
// Generate new credentials for fresh deployment // Generate new credentials for fresh deployment
logger.info('Generating new Redis admin credentials'); 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. * 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; ts: number;
level?: string; level?: string;
logger?: 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 server: Deno.TcpListener | null = null;
private clients: Map<string, ILogClient> = new Map(); private clients: Map<string, ILogClient> = new Map();
private port: number; private port: number;
private running = false; private running = false;
private connections: Set<Deno.TcpConn> = new Set(); 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 // Adaptive sampling state
private logCountWindow: number[] = []; // timestamps of recent logs private logCountWindow: number[] = []; // timestamps of recent logs
@@ -76,7 +79,7 @@ export class CaddyLogReceiver {
private logCounter = 0; private logCounter = 0;
// Ring buffer for recent logs (for late-joining clients) // Ring buffer for recent logs (for late-joining clients)
private recentLogs: ICaddyAccessLog[] = []; private recentLogs: IProxyAccessLog[] = [];
private maxRecentLogs = 100; private maxRecentLogs = 100;
// Traffic stats aggregation (hourly rolling window) // Traffic stats aggregation (hourly rolling window)
@@ -137,7 +140,7 @@ export class CaddyLogReceiver {
/** /**
* Record a request in traffic stats * Record a request in traffic stats
*/ */
private recordTrafficStats(log: ICaddyAccessLog): void { private recordTrafficStats(log: IProxyAccessLog): void {
const bucket = this.getCurrentStatsBucket(); const bucket = this.getCurrentStatsBucket();
bucket.requestCount++; bucket.requestCount++;
@@ -164,25 +167,25 @@ export class CaddyLogReceiver {
*/ */
async start(): Promise<void> { async start(): Promise<void> {
if (this.running) { if (this.running) {
logger.warn('CaddyLogReceiver is already running'); logger.warn('ProxyLogReceiver is already running');
return; return;
} }
try { try {
this.server = Deno.listen({ port: this.port, transport: 'tcp' }); this.server = Deno.listen({ port: this.port, transport: 'tcp' });
this.running = true; 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 // Start accepting connections in background
this.acceptConnections(); this.acceptTask = this.acceptConnections();
} catch (error) { } catch (error) {
logger.error(`Failed to start CaddyLogReceiver: ${getErrorMessage(error)}`); logger.error(`Failed to start ProxyLogReceiver: ${getErrorMessage(error)}`);
throw error; throw error;
} }
} }
/** /**
* Accept incoming TCP connections from Caddy * Accept incoming TCP connections from the reverse proxy
*/ */
private async acceptConnections(): Promise<void> { private async acceptConnections(): Promise<void> {
if (!this.server) return; if (!this.server) return;
@@ -190,23 +193,26 @@ export class CaddyLogReceiver {
try { try {
for await (const conn of this.server) { for await (const conn of this.server) {
this.connections.add(conn); this.connections.add(conn);
this.handleConnection(conn); const handlerTask = this.handleConnection(conn);
this.connectionHandlers.add(handlerTask);
handlerTask.finally(() => this.connectionHandlers.delete(handlerTask));
} }
} catch (error) { } catch (error) {
if (this.running) { 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> { private async handleConnection(conn: Deno.TcpConn): Promise<void> {
const remoteAddr = conn.remoteAddr as Deno.NetAddr; 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(); const reader = conn.readable.getReader();
this.connectionReaders.set(conn, reader);
const decoder = new TextDecoder(); const decoder = new TextDecoder();
let buffer = ''; let buffer = '';
@@ -217,7 +223,7 @@ export class CaddyLogReceiver {
buffer += decoder.decode(value, { stream: true }); 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'); const lines = buffer.split('\n');
buffer = lines.pop() || ''; // Keep incomplete line in buffer buffer = lines.pop() || ''; // Keep incomplete line in buffer
@@ -229,10 +235,16 @@ export class CaddyLogReceiver {
} }
} catch (error) { } catch (error) {
if (this.running) { if (this.running) {
logger.debug(`CaddyLogReceiver connection closed: ${getErrorMessage(error)}`); logger.debug(`ProxyLogReceiver connection closed: ${getErrorMessage(error)}`);
} }
} finally { } finally {
this.connectionReaders.delete(conn);
this.connections.delete(conn); this.connections.delete(conn);
try {
reader.releaseLock();
} catch {
// Reader may already be released after cancellation during shutdown.
}
try { try {
conn.close(); conn.close();
} catch { } 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 { private processLogLine(line: string): void {
try { 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) // 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' || const isAccessLog = log.logger === 'http.log.access' ||
log.logger === 'access' || log.logger === 'access' ||
(log.request && typeof log.status === 'number'); (log.request && typeof log.status === 'number');
if (!isAccessLog) { if (!isAccessLog) {
logger.debug(`CaddyLogReceiver: Skipping non-access log: ${log.logger || 'unknown'}`); logger.debug(`ProxyLogReceiver: Skipping non-access log: ${log.logger || 'unknown'}`);
return; return;
} }
@@ -268,7 +280,7 @@ export class CaddyLogReceiver {
return; 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 // Add to recent logs buffer
this.recentLogs.push(log); this.recentLogs.push(log);
@@ -277,10 +289,10 @@ export class CaddyLogReceiver {
} }
// Broadcast to WebSocket clients (log how many clients) // 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); this.broadcast(log);
} catch (error) { } 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 * Broadcast a log entry to all connected WebSocket clients
*/ */
private broadcast(log: ICaddyAccessLog): void { private broadcast(log: IProxyAccessLog): void {
const message = JSON.stringify({ const message = JSON.stringify({
type: 'access_log', type: 'access_log',
data: { data: {
@@ -365,7 +377,7 @@ export class CaddyLogReceiver {
/** /**
* Check if a log entry matches a client's filter * 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 // Domain filter
if (filter.domain) { if (filter.domain) {
const logHost = log.request.host.toLowerCase(); const logHost = log.request.host.toLowerCase();
@@ -385,7 +397,7 @@ export class CaddyLogReceiver {
*/ */
addClient(clientId: string, ws: WebSocket, filter: ILogFilter = {}): void { addClient(clientId: string, ws: WebSocket, filter: ILogFilter = {}): void {
this.clients.set(clientId, { id: clientId, ws, filter }); 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 // Send recent logs to new client
for (const log of this.recentLogs) { for (const log of this.recentLogs) {
@@ -422,7 +434,7 @@ export class CaddyLogReceiver {
*/ */
removeClient(clientId: string): void { removeClient(clientId: string): void {
if (this.clients.delete(clientId)) { 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); const client = this.clients.get(clientId);
if (client) { if (client) {
client.filter = filter; 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; 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 // Close all connections
for (const conn of this.connections) { for (const conn of this.connections) {
try { try {
@@ -467,10 +484,19 @@ export class CaddyLogReceiver {
this.server = null; 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 // Clear clients
this.clients.clear(); this.clients.clear();
logger.info('CaddyLogReceiver stopped'); logger.info('ProxyLogReceiver stopped');
} }
/** /**
+17 -8
View File
@@ -9,6 +9,9 @@ import type { IRegistry } from '../types.ts';
import { logger } from '../logging.ts'; import { logger } from '../logging.ts';
import { getErrorMessage } from '../utils/error.ts'; import { getErrorMessage } from '../utils/error.ts';
import { OneboxDatabase } from './database.ts'; import { OneboxDatabase } from './database.ts';
import { credentialEncryption } from './encryption.ts';
const encryptedPasswordPrefix = 'enc:v1:';
export class OneboxRegistriesManager { export class OneboxRegistriesManager {
private oneboxRef: any; // Will be Onebox instance 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) * Encrypt a password (simple base64 for now, should use proper encryption)
*/ */
private encryptPassword(password: string): string { private async encryptPassword(password: string): Promise<string> {
// TODO: Use proper encryption with a secret key const encrypted = await credentialEncryption.encrypt({ password });
// For now, using base64 encoding (NOT SECURE, just for structure) return `${encryptedPasswordPrefix}${encrypted}`;
return plugins.encoding.encodeBase64(new TextEncoder().encode(password));
} }
/** /**
* Decrypt a password * Decrypt a password
*/ */
private decryptPassword(encrypted: string): string { private async decryptPassword(encrypted: string): Promise<string> {
// TODO: Use proper decryption 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)); return new TextDecoder().decode(plugins.encoding.decodeBase64(encrypted));
} }
@@ -48,7 +57,7 @@ export class OneboxRegistriesManager {
} }
// Encrypt password // Encrypt password
const passwordEncrypted = this.encryptPassword(password); const passwordEncrypted = await this.encryptPassword(password);
// Create registry in database // Create registry in database
const registry = await this.database.createRegistry({ const registry = await this.database.createRegistry({
@@ -111,7 +120,7 @@ export class OneboxRegistriesManager {
try { try {
logger.info(`Logging into registry: ${registry.url}`); logger.info(`Logging into registry: ${registry.url}`);
const password = this.decryptPassword(registry.passwordEncrypted); const password = await this.decryptPassword(registry.passwordEncrypted);
// Use docker login command // Use docker login command
const command = [ const command = [
+11 -1
View File
@@ -76,7 +76,7 @@ export class RegistryManager {
}, },
ociTokens: { ociTokens: {
enabled: true, enabled: true,
realm: 'http://localhost:3000/v2/token', realm: `http://${this.baseUrl}/v2/token`,
service: 'onebox-registry', service: 'onebox-registry',
}, },
}, },
@@ -317,6 +317,15 @@ export class RegistryManager {
* Stop the registry and smartstorage server * Stop the registry and smartstorage server
*/ */
async stop(): Promise<void> { 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) { if (this.s3Server) {
try { try {
await this.s3Server.stop(); await this.s3Server.stop();
@@ -324,6 +333,7 @@ export class RegistryManager {
} catch (error) { } catch (error) {
logger.error(`Error stopping smartstorage: ${getErrorMessage(error)}`); logger.error(`Error stopping smartstorage: ${getErrorMessage(error)}`);
} }
this.s3Server = null;
} }
this.isInitialized = false; this.isInitialized = false;
+81 -45
View File
@@ -1,8 +1,8 @@
/** /**
* Reverse Proxy for Onebox * Reverse Proxy for Onebox
* *
* Delegates to Caddy (running as Docker service) for production-grade reverse proxy * Delegates to SmartProxy (running as Docker service) for production-grade reverse proxy
* with native SNI support, HTTP/2, WebSocket proxying, and zero-downtime configuration updates. * 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 * Routes use Docker service names (e.g., onebox-hello-world:80) for container-to-container
* communication within the Docker overlay network. * communication within the Docker overlay network.
@@ -10,21 +10,26 @@
import { logger } from '../logging.ts'; import { logger } from '../logging.ts';
import { getErrorMessage } from '../utils/error.ts'; import { getErrorMessage } from '../utils/error.ts';
import { normalizeHostname } from '../utils/domain.ts';
import { OneboxDatabase } from './database.ts'; import { OneboxDatabase } from './database.ts';
import { CaddyManager } from './caddy.ts'; import { SmartProxyManager } from './smartproxy.ts';
const adminUiRouteName = 'onebox-admin-ui';
const adminUiPort = 3000;
interface IProxyRoute { interface IProxyRoute {
domain: string; domain: string;
targetHost: string; targetHost: string;
targetPort: number; targetPort: number;
serviceId: number; serviceId?: number;
serviceName?: string; serviceName?: string;
routeType: 'service' | 'admin-ui';
} }
export class OneboxReverseProxy { export class OneboxReverseProxy {
private oneboxRef: any; private oneboxRef: any;
private database: OneboxDatabase; private database: OneboxDatabase;
private caddy: CaddyManager; private smartProxy: SmartProxyManager;
private routes: Map<string, IProxyRoute> = new Map(); private routes: Map<string, IProxyRoute> = new Map();
private httpPort = 8080; // Default to dev ports (will be overridden if production) private httpPort = 8080; // Default to dev ports (will be overridden if production)
private httpsPort = 8443; private httpsPort = 8443;
@@ -32,33 +37,32 @@ export class OneboxReverseProxy {
constructor(oneboxRef: any) { constructor(oneboxRef: any) {
this.oneboxRef = oneboxRef; this.oneboxRef = oneboxRef;
this.database = oneboxRef.database; this.database = oneboxRef.database;
this.caddy = new CaddyManager({ this.smartProxy = new SmartProxyManager({
httpPort: this.httpPort, httpPort: this.httpPort,
httpsPort: this.httpsPort, 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> { 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 * 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> { async startHttp(port?: number): Promise<void> {
if (port) { if (port) {
this.httpPort = port; this.httpPort = port;
this.caddy.setPorts(this.httpPort, this.httpsPort); this.smartProxy.setPorts(this.httpPort, this.httpsPort);
} }
try { try {
// Start Caddy (handles both HTTP and HTTPS) await this.smartProxy.start();
await this.caddy.start(); logger.success(`Reverse proxy started on port ${this.httpPort} (SmartProxy Docker service)`);
logger.success(`Reverse proxy started on port ${this.httpPort} (Caddy Docker service)`);
} catch (error) { } catch (error) {
logger.error(`Failed to start reverse proxy: ${getErrorMessage(error)}`); logger.error(`Failed to start reverse proxy: ${getErrorMessage(error)}`);
throw error; throw error;
@@ -66,21 +70,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 * This method exists for interface compatibility
*/ */
async startHttps(port?: number): Promise<void> { async startHttps(port?: number): Promise<void> {
if (port) { if (port) {
this.httpsPort = 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 const status = this.smartProxy.getStatus();
// If already running, just log and optionally reload with new port
const status = this.caddy.getStatus();
if (status.running) { 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 { } else {
await this.caddy.start(); logger.warn('Skipping HTTPS reverse proxy startup because SmartProxy is not running');
} }
} }
@@ -88,13 +90,13 @@ export class OneboxReverseProxy {
* Stop the reverse proxy * Stop the reverse proxy
*/ */
async stop(): Promise<void> { async stop(): Promise<void> {
await this.caddy.stop(); await this.smartProxy.stop();
logger.info('Reverse proxy stopped'); logger.info('Reverse proxy stopped');
} }
/** /**
* Add a route for a service * 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> { async addRoute(serviceId: number, domain: string, targetPort: number): Promise<void> {
try { try {
@@ -105,7 +107,7 @@ export class OneboxReverseProxy {
} }
// Use Docker service name as upstream target // 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 serviceName = `onebox-${service.name}`;
const targetHost = serviceName; const targetHost = serviceName;
@@ -115,13 +117,14 @@ export class OneboxReverseProxy {
targetPort, targetPort,
serviceId, serviceId,
serviceName, serviceName,
routeType: 'service',
}; };
this.routes.set(domain, route); 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}`; const upstream = `${targetHost}:${targetPort}`;
await this.caddy.addRoute(domain, upstream); await this.smartProxy.addRoute(domain, upstream);
logger.success(`Added proxy route: ${domain} -> ${upstream}`); logger.success(`Added proxy route: ${domain} -> ${upstream}`);
} catch (error) { } catch (error) {
@@ -130,15 +133,31 @@ export class OneboxReverseProxy {
} }
} }
async addAdminUiRoute(domain: string): Promise<void> {
const normalizedDomain = normalizeHostname(domain);
if (!normalizedDomain) return;
const targetHost = this.getAdminUiTargetHost();
const route: IProxyRoute = {
domain: normalizedDomain,
targetHost,
targetPort: adminUiPort,
serviceName: adminUiRouteName,
routeType: 'admin-ui',
};
this.routes.set(normalizedDomain, route);
const upstream = `${targetHost}:${adminUiPort}`;
await this.smartProxy.addRoute(normalizedDomain, upstream);
logger.success(`Added Admin UI proxy route: ${normalizedDomain} -> ${upstream}`);
}
/** /**
* Remove a route * Remove a route
*/ */
removeRoute(domain: string): void { async removeRoute(domain: string): Promise<void> {
if (this.routes.delete(domain)) { if (this.routes.delete(domain)) {
// Remove from Caddy (async but we don't wait) await this.smartProxy.removeRoute(domain);
this.caddy.removeRoute(domain).catch((error) => {
logger.error(`Failed to remove Caddy route for ${domain}: ${getErrorMessage(error)}`);
});
logger.success(`Removed proxy route: ${domain}`); logger.success(`Removed proxy route: ${domain}`);
} else { } else {
logger.warn(`Route not found: ${domain}`); logger.warn(`Route not found: ${domain}`);
@@ -159,9 +178,9 @@ export class OneboxReverseProxy {
try { try {
logger.info('Reloading proxy routes...'); logger.info('Reloading proxy routes...');
// Clear local and Caddy routes // Clear local and SmartProxy routes
this.routes.clear(); this.routes.clear();
this.caddy.clear(); this.smartProxy.clear();
const services = this.database.getAllServices(); const services = this.database.getAllServices();
@@ -172,6 +191,11 @@ export class OneboxReverseProxy {
} }
} }
const adminUiDomain = this.getAdminUiDomain();
if (adminUiDomain) {
await this.addAdminUiRoute(adminUiDomain);
}
logger.success(`Loaded ${this.routes.size} proxy routes`); logger.success(`Loaded ${this.routes.size} proxy routes`);
} catch (error) { } catch (error) {
logger.error(`Failed to reload routes: ${getErrorMessage(error)}`); logger.error(`Failed to reload routes: ${getErrorMessage(error)}`);
@@ -179,9 +203,21 @@ export class OneboxReverseProxy {
} }
} }
private getAdminUiDomain(): string {
return normalizeHostname(this.database.getSetting('adminUiDomain') || '');
}
private getAdminUiTargetHost(): string {
const serverIP = this.database.getSetting('serverIP');
if (!serverIP) {
logger.warn('serverIP is not configured; Admin UI proxy route will use host.docker.internal');
}
return serverIP || 'host.docker.internal';
}
/** /**
* Add TLS certificate for a domain * 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> { async addCertificate(domain: string, certPem: string, keyPem: string): Promise<void> {
if (!certPem || !keyPem) { if (!certPem || !keyPem) {
@@ -189,14 +225,14 @@ export class OneboxReverseProxy {
return; return;
} }
await this.caddy.addCertificate(domain, certPem, keyPem); await this.smartProxy.addCertificate(domain, certPem, keyPem);
} }
/** /**
* Remove TLS certificate for a domain * Remove TLS certificate for a domain
*/ */
removeCertificate(domain: string): void { 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)}`); logger.error(`Failed to remove certificate for ${domain}: ${getErrorMessage(error)}`);
}); });
} }
@@ -213,13 +249,13 @@ export class OneboxReverseProxy {
for (const cert of certificates) { for (const cert of certificates) {
// Use fullchainPem for the cert (includes intermediates) and keyPem for the key // Use fullchainPem for the cert (includes intermediates) and keyPem for the key
if (cert.domain && cert.fullchainPem && cert.keyPem) { 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 { } else {
logger.warn(`Skipping certificate for ${cert.domain}: missing PEM content`); 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) { } catch (error) {
logger.error(`Failed to reload certificates: ${getErrorMessage(error)}`); logger.error(`Failed to reload certificates: ${getErrorMessage(error)}`);
throw error; throw error;
@@ -230,19 +266,19 @@ export class OneboxReverseProxy {
* Get status of reverse proxy * Get status of reverse proxy
*/ */
getStatus() { getStatus() {
const caddyStatus = this.caddy.getStatus(); const smartProxyStatus = this.smartProxy.getStatus();
return { return {
http: { http: {
running: caddyStatus.running, running: smartProxyStatus.running,
port: caddyStatus.httpPort, port: smartProxyStatus.httpPort,
}, },
https: { https: {
running: caddyStatus.running, running: smartProxyStatus.running,
port: caddyStatus.httpsPort, port: smartProxyStatus.httpsPort,
certificates: caddyStatus.certificates, certificates: smartProxyStatus.certificates,
}, },
routes: caddyStatus.routes, routes: smartProxyStatus.routes,
backend: 'caddy-docker', backend: 'smartproxy-docker',
}; };
} }
} }
+94 -18
View File
@@ -23,6 +23,35 @@ export class OneboxServicesManager {
this.docker = oneboxRef.docker; 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) * Deploy a new service (full workflow)
*/ */
@@ -66,6 +95,8 @@ export class OneboxServicesManager {
image: options.useOneboxRegistry ? imageToPull : options.image, image: options.useOneboxRegistry ? imageToPull : options.image,
registry: options.registry, registry: options.registry,
envVars: options.envVars || {}, envVars: options.envVars || {},
volumes: options.volumes || [],
publishedPorts: options.publishedPorts || [],
port: options.port, port: options.port,
domain: options.domain, domain: options.domain,
status: 'stopped', status: 'stopped',
@@ -76,6 +107,7 @@ export class OneboxServicesManager {
registryRepository: options.useOneboxRegistry ? options.name : undefined, registryRepository: options.useOneboxRegistry ? options.name : undefined,
registryImageTag: options.registryImageTag || 'latest', registryImageTag: options.registryImageTag || 'latest',
autoUpdateOnPush: options.autoUpdateOnPush, autoUpdateOnPush: options.autoUpdateOnPush,
imageDigest: options.imageDigest,
// Platform requirements // Platform requirements
platformRequirements, platformRequirements,
// App Store template tracking // App Store template tracking
@@ -101,9 +133,15 @@ export class OneboxServicesManager {
// Merge platform env vars with user-specified env vars (user vars take precedence) // Merge platform env vars with user-specified env vars (user vars take precedence)
const mergedEnvVars = { ...platformEnvVars, ...(options.envVars || {}) }; 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 // Update service with merged and resolved env vars.
if (Object.keys(platformEnvVars).length > 0) { if (Object.keys(mergedEnvVars).length > 0) {
this.database.updateService(service.id!, { envVars: mergedEnvVars }); this.database.updateService(service.id!, { envVars: mergedEnvVars });
} }
@@ -193,11 +231,15 @@ export class OneboxServicesManager {
// Note: SSL certificates are now handled automatically by CertRequirementManager // Note: SSL certificates are now handled automatically by CertRequirementManager
// which processes pending requirements created above. No direct obtainCertificate call needed. // 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}`); 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) { } catch (error) {
logger.error(`Failed to deploy service ${options.name}: ${getErrorMessage(error)}`); logger.error(`Failed to deploy service ${options.name}: ${getErrorMessage(error)}`);
throw error; throw error;
@@ -233,15 +275,19 @@ export class OneboxServicesManager {
} catch (routeError) { } catch (routeError) {
logger.warn(`Failed to add proxy route for ${service.domain}: ${getErrorMessage(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}`); logger.success(`Service started: ${name}`);
await this.broadcastServiceUpdate(name, 'started');
} catch (error) { } catch (error) {
logger.error(`Failed to start service ${name}: ${getErrorMessage(error)}`); logger.error(`Failed to start service ${name}: ${getErrorMessage(error)}`);
this.database.updateService( this.database.updateService(
this.database.getServiceByName(name)?.id!, this.database.getServiceByName(name)?.id!,
{ status: 'failed' } { status: 'failed' }
); );
await this.broadcastServiceUpdate(name, 'updated');
throw error; throw error;
} }
} }
@@ -270,10 +316,12 @@ export class OneboxServicesManager {
// Remove reverse proxy route if service has a domain // Remove reverse proxy route if service has a domain
if (service.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}`); logger.success(`Service stopped: ${name}`);
await this.broadcastServiceUpdate(name, 'stopped');
} catch (error) { } catch (error) {
logger.error(`Failed to stop service ${name}: ${getErrorMessage(error)}`); logger.error(`Failed to stop service ${name}: ${getErrorMessage(error)}`);
throw error; throw error;
@@ -301,6 +349,7 @@ export class OneboxServicesManager {
this.database.updateService(service.id!, { status: 'running' }); this.database.updateService(service.id!, { status: 'running' });
logger.success(`Service restarted: ${name}`); logger.success(`Service restarted: ${name}`);
await this.broadcastServiceUpdate(name, 'updated');
} catch (error) { } catch (error) {
logger.error(`Failed to restart service ${name}: ${getErrorMessage(error)}`); logger.error(`Failed to restart service ${name}: ${getErrorMessage(error)}`);
throw error; throw error;
@@ -331,11 +380,13 @@ export class OneboxServicesManager {
// Remove reverse proxy route // Remove reverse proxy route
if (service.domain) { if (service.domain) {
try { try {
this.oneboxRef.reverseProxy.removeRoute(service.domain); await this.oneboxRef.reverseProxy.removeRoute(service.domain);
} catch (error) { } catch (error) {
logger.warn(`Failed to remove reverse proxy route: ${getErrorMessage(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 // Note: We don't remove DNS records or SSL certs automatically
// as they might be used by other services or need manual cleanup // as they might be used by other services or need manual cleanup
} }
@@ -357,6 +408,7 @@ export class OneboxServicesManager {
this.database.deleteService(service.id!); this.database.deleteService(service.id!);
logger.success(`Service removed: ${name}`); logger.success(`Service removed: ${name}`);
await this.oneboxRef.opsServer.broadcastServiceUpdate(name, 'deleted');
} catch (error) { } catch (error) {
logger.error(`Failed to remove service ${name}: ${getErrorMessage(error)}`); logger.error(`Failed to remove service ${name}: ${getErrorMessage(error)}`);
throw error; throw error;
@@ -529,6 +581,8 @@ export class OneboxServicesManager {
port?: number; port?: number;
domain?: string; domain?: string;
envVars?: Record<string, string>; envVars?: Record<string, string>;
volumes?: IService['volumes'];
publishedPorts?: IService['publishedPorts'];
} }
): Promise<IService> { ): Promise<IService> {
try { try {
@@ -567,6 +621,8 @@ export class OneboxServicesManager {
if (updates.port !== undefined) updateData.port = updates.port; if (updates.port !== undefined) updateData.port = updates.port;
if (updates.domain !== undefined) updateData.domain = updates.domain; if (updates.domain !== undefined) updateData.domain = updates.domain;
if (updates.envVars !== undefined) updateData.envVars = updates.envVars; if (updates.envVars !== undefined) updateData.envVars = updates.envVars;
if (updates.volumes !== undefined) updateData.volumes = updates.volumes;
if (updates.publishedPorts !== undefined) updateData.publishedPorts = updates.publishedPorts;
this.database.updateService(service.id!, updateData); this.database.updateService(service.id!, updateData);
@@ -593,10 +649,12 @@ export class OneboxServicesManager {
// Remove old route if it existed // Remove old route if it existed
if (oldDomain) { if (oldDomain) {
try { try {
this.oneboxRef.reverseProxy.removeRoute(oldDomain); await this.oneboxRef.reverseProxy.removeRoute(oldDomain);
} catch (error) { } catch (error) {
logger.warn(`Failed to remove old reverse proxy route: ${getErrorMessage(error)}`); logger.warn(`Failed to remove old reverse proxy route: ${getErrorMessage(error)}`);
} }
await this.deleteExternalGatewayRoute({ ...service, domain: oldDomain });
} }
// Add new route if domain specified // Add new route if domain specified
@@ -625,7 +683,12 @@ export class OneboxServicesManager {
logger.success(`Service ${name} updated (not started)`); 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) { } catch (error) {
logger.error(`Failed to update service ${name}: ${getErrorMessage(error)}`); logger.error(`Failed to update service ${name}: ${getErrorMessage(error)}`);
throw error; throw error;
@@ -659,11 +722,7 @@ export class OneboxServicesManager {
// Only update and broadcast if status changed // Only update and broadcast if status changed
if (service.status !== ourStatus) { if (service.status !== ourStatus) {
this.database.updateService(service.id!, { status: ourStatus }); this.database.updateService(service.id!, { status: ourStatus });
await this.broadcastServiceUpdate(name, 'updated');
// Broadcast status change via WebSocket
if (this.oneboxRef.httpServer) {
this.oneboxRef.httpServer.broadcastServiceStatus(name, ourStatus);
}
} }
} catch (error) { } catch (error) {
logger.debug(`Failed to sync status for service ${name}: ${getErrorMessage(error)}`); logger.debug(`Failed to sync status for service ${name}: ${getErrorMessage(error)}`);
@@ -681,6 +740,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 * Start auto-update monitoring for registry services
* Polls every 30 seconds for digest changes and restarts services if needed * Polls every 30 seconds for digest changes and restarts services if needed
@@ -756,12 +838,6 @@ export class OneboxServicesManager {
// Restart service // Restart service
logger.info(`Auto-restarting service: ${service.name}`); logger.info(`Auto-restarting service: ${service.name}`);
await this.restartService(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) { } else if (!service.imageDigest) {
// First time - just store the digest // First time - just store the digest
this.database.updateService(service.id!, { this.database.updateService(service.id!, {
+514
View File
@@ -0,0 +1,514 @@
/**
* 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_REVERSE_PROXY_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_REVERSE_PROXY_SERVICE_NAME);
if (legacyService) {
logger.info(
`Legacy reverse proxy service ${LEGACY_REVERSE_PROXY_SERVICE_NAME} exists, removing it before SmartProxy startup...`,
);
await this.removeService(LEGACY_REVERSE_PROXY_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)}`);
}
const serviceId = response.body.ID;
logger.info(`SmartProxy service created: ${serviceId}`);
await this.waitForServiceTaskRunning(serviceId);
await this.waitForReady();
this.serviceRunning = true;
await this.reloadConfig({ skipRunningCheck: true });
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');
}
private async waitForServiceTaskRunning(
serviceIdArg: string,
maxAttempts = 30,
intervalMs = 1000,
): Promise<void> {
let lastState = 'unknown';
for (let i = 0; i < maxAttempts; i++) {
const tasksResponse = await this.dockerClient!.request(
'GET',
`/tasks?filters=${encodeURIComponent(JSON.stringify({ service: [serviceIdArg] }))}`,
{},
);
if (tasksResponse.statusCode === 200 && Array.isArray(tasksResponse.body)) {
const tasks = tasksResponse.body;
const runningTask = tasks.find((task: any) => task.Status?.State === 'running');
if (runningTask) {
return;
}
const latestTask = tasks[0];
lastState = latestTask?.Status?.State || lastState;
}
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
throw new Error(`SmartProxy service task did not reach running state (last state: ${lastState})`);
}
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(options: { skipRunningCheck?: boolean } = {}): Promise<void> {
if (!options.skipRunningCheck) {
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
View File
@@ -39,11 +39,11 @@ export class OneboxSslManager {
this.acmeEmail = acmeEmail; this.acmeEmail = acmeEmail;
// Get Cloudflare API key (reuse from DNS manager) // Get Cloudflare API key (reuse from DNS manager)
const cfApiKey = this.database.getSetting('cloudflareAPIKey'); const cfApiKey = await this.database.getSecretSetting('cloudflareToken');
if (!cfApiKey) { if (!cfApiKey) {
logger.warn('Cloudflare API key not configured. SSL certificate management will be limited.'); 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; return;
} }
+214
View File
@@ -0,0 +1,214 @@
import { logger } from '../logging.ts';
import { projectInfo } from '../info.ts';
import { getErrorMessage } from '../utils/error.ts';
import * as interfaces from '../../ts_interfaces/index.ts';
const ONEBOX_REPOSITORY_URL = 'https://code.foss.global/serve.zone/onebox';
const ONEBOX_LATEST_RELEASE_API_URL =
'https://code.foss.global/api/v1/repos/serve.zone/onebox/releases/latest';
const ONEBOX_INSTALL_SCRIPT_URL = `${ONEBOX_REPOSITORY_URL}/raw/branch/main/install.sh`;
const ONEBOX_CHANGELOG_URL = `${ONEBOX_REPOSITORY_URL}/src/branch/main/changelog.md`;
const UPGRADE_LOG_PATH = '/var/log/onebox-upgrade.log';
interface IGiteaReleaseResponse {
tag_name?: unknown;
html_url?: unknown;
}
interface IParsedRelease {
tagName: string;
releaseUrl: string;
}
export class OneboxUpdateManager {
private cachedStatus: interfaces.data.IOneboxUpdateStatus | null = null;
private cachedStatusExpiresAt = 0;
private upgradeStartedAt = 0;
private readonly statusCacheTtlMs = 5 * 60 * 1000;
public async getUpdateStatus(
optionsArg: { force?: boolean } = {},
): Promise<interfaces.data.IOneboxUpdateStatus> {
const now = Date.now();
if (!optionsArg.force && this.cachedStatus && this.cachedStatusExpiresAt > now) {
return this.cachedStatus;
}
const status = await this.fetchUpdateStatus();
this.cachedStatus = status;
this.cachedStatusExpiresAt = now + this.statusCacheTtlMs;
return status;
}
public async startDetachedUpgrade(): Promise<interfaces.data.IOneboxUpgradeStartResult> {
this.assertRoot();
const status = await this.getUpdateStatus({ force: true });
this.assertUpdateCheckSucceeded(status);
const targetVersion = status.latestVersion || status.currentVersion;
if (!status.updateAvailable) {
return {
accepted: false,
currentVersion: status.currentVersion,
targetVersion,
message: 'Onebox is already up to date.',
};
}
if (this.upgradeStartedAt && Date.now() - this.upgradeStartedAt < 10 * 60 * 1000) {
return {
accepted: false,
currentVersion: status.currentVersion,
targetVersion,
message: 'A Onebox upgrade has already been started.',
logPath: UPGRADE_LOG_PATH,
};
}
const command = new Deno.Command('bash', {
args: ['-c', this.createDetachedUpgradeScript()],
stdin: 'null',
stdout: 'null',
stderr: 'null',
detached: true,
});
const child = command.spawn();
child.unref();
this.upgradeStartedAt = Date.now();
logger.info(`Started detached Onebox upgrade process ${child.pid}`);
return {
accepted: true,
currentVersion: status.currentVersion,
targetVersion,
message: 'Onebox upgrade started. The service will restart automatically.',
pid: child.pid,
logPath: UPGRADE_LOG_PATH,
};
}
public async runUpgradeForeground(
statusArg?: interfaces.data.IOneboxUpdateStatus,
): Promise<interfaces.data.IOneboxUpgradeStartResult> {
this.assertRoot();
const status = statusArg || (await this.getUpdateStatus({ force: true }));
this.assertUpdateCheckSucceeded(status);
const targetVersion = status.latestVersion || status.currentVersion;
if (!status.updateAvailable) {
return {
accepted: false,
currentVersion: status.currentVersion,
targetVersion,
message: 'Onebox is already up to date.',
};
}
const installCommand = new Deno.Command('bash', {
args: ['-c', `curl -sSL ${ONEBOX_INSTALL_SCRIPT_URL} | bash`],
stdin: 'inherit',
stdout: 'inherit',
stderr: 'inherit',
});
const installResult = await installCommand.output();
if (!installResult.success) {
throw new Error('Upgrade failed');
}
return {
accepted: true,
currentVersion: status.currentVersion,
targetVersion,
message: `Upgraded to ${targetVersion}`,
};
}
private async fetchUpdateStatus(): Promise<interfaces.data.IOneboxUpdateStatus> {
const currentVersion = this.normalizeVersion(projectInfo.version);
const checkedAt = Date.now();
try {
const release = await this.fetchLatestRelease();
const latestVersion = this.normalizeVersion(release.tagName);
return {
currentVersion,
latestVersion,
updateAvailable: currentVersion !== latestVersion,
checkedAt,
releaseUrl: release.releaseUrl,
changelogUrl: ONEBOX_CHANGELOG_URL,
};
} catch (error) {
return {
currentVersion,
latestVersion: null,
updateAvailable: false,
checkedAt,
releaseUrl: `${ONEBOX_REPOSITORY_URL}/releases`,
changelogUrl: ONEBOX_CHANGELOG_URL,
error: getErrorMessage(error),
};
}
}
private async fetchLatestRelease(): Promise<IParsedRelease> {
const abortController = new AbortController();
const timeoutId = setTimeout(() => abortController.abort(), 5000);
try {
const response = await fetch(ONEBOX_LATEST_RELEASE_API_URL, {
headers: { accept: 'application/json' },
signal: abortController.signal,
});
if (!response.ok) {
throw new Error(`Failed to fetch latest release: HTTP ${response.status}`);
}
const release = await response.json() as IGiteaReleaseResponse;
if (typeof release.tag_name !== 'string' || !release.tag_name) {
throw new Error('Latest release response does not include a tag name');
}
const tagName = release.tag_name;
const releaseUrl = typeof release.html_url === 'string' && release.html_url
? release.html_url
: `${ONEBOX_REPOSITORY_URL}/releases/tag/${this.normalizeVersion(tagName)}`;
return { tagName, releaseUrl };
} finally {
clearTimeout(timeoutId);
}
}
private assertRoot(): void {
if (Deno.uid() !== 0) {
throw new Error('Onebox upgrades must be started as root. Try: sudo onebox upgrade');
}
}
private assertUpdateCheckSucceeded(statusArg: interfaces.data.IOneboxUpdateStatus): void {
if (statusArg.error) {
throw new Error(`Cannot determine latest Onebox release: ${statusArg.error}`);
}
}
private normalizeVersion(versionArg: string): string {
const trimmedVersion = versionArg.trim();
return trimmedVersion.startsWith('v') ? trimmedVersion : `v${trimmedVersion}`;
}
private createDetachedUpgradeScript(): string {
return `
set -e
mkdir -p /var/log
{
echo "==== Onebox upgrade started $(date -Is) ===="
sleep 2
curl -sSL ${ONEBOX_INSTALL_SCRIPT_URL} | bash
echo "==== Onebox upgrade finished $(date -Is) ===="
} >> ${UPGRADE_LOG_PATH} 2>&1
`;
}
}
+212 -55
View File
@@ -8,16 +8,20 @@ import { getErrorMessage } from './utils/error.ts';
import { Onebox } from './classes/onebox.ts'; import { Onebox } from './classes/onebox.ts';
import { OneboxDaemon } from './classes/daemon.ts'; import { OneboxDaemon } from './classes/daemon.ts';
import { OneboxSystemd } from './classes/systemd.ts'; import { OneboxSystemd } from './classes/systemd.ts';
import { OneboxUpdateManager } from './classes/update-manager.ts';
import type * as servezoneInterfaces from '@serve.zone/interfaces';
type IAppStoreVersionConfig = servezoneInterfaces.appstore.IAppStoreVersionConfig;
export async function runCli(): Promise<void> { export async function runCli(): Promise<void> {
const args = Deno.args; 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(); printHelp();
return; 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}`); console.log(`${projectInfo.name} v${projectInfo.version}`);
return; return;
} }
@@ -70,6 +74,11 @@ export async function runCli(): Promise<void> {
await handleSslCommand(onebox, subcommand, args.slice(2)); await handleSslCommand(onebox, subcommand, args.slice(2));
break; break;
case 'appstore':
await handleAppStoreCommand(onebox, subcommand, args.slice(2));
break;
case 'proxy':
case 'nginx': case 'nginx':
await handleNginxCommand(onebox, subcommand, args.slice(2)); await handleNginxCommand(onebox, subcommand, args.slice(2));
break; break;
@@ -104,12 +113,11 @@ async function handleServiceCommand(onebox: Onebox, subcommand: string, args: st
const image = getArg(args, '--image'); const image = getArg(args, '--image');
const domain = getArg(args, '--domain'); const domain = getArg(args, '--domain');
const port = parseInt(getArg(args, '--port') || '80', 10); const port = parseInt(getArg(args, '--port') || '80', 10);
const envArgs = args.filter((a) => a.startsWith('--env=')).map((a) => a.slice(6)); const envVars = parseEnvArgs(args);
const envVars: Record<string, string> = {};
for (const env of envArgs) { requireValue(name, 'service name');
const [key, value] = env.split('='); requireValue(image, '--image');
envVars[key] = value; assertValidPort(port, '--port');
}
await onebox.services.deployService({ name, image, port, domain, envVars }); await onebox.services.deployService({ name, image, port, domain, envVars });
break; break;
@@ -158,6 +166,7 @@ async function handleRegistryCommand(onebox: Onebox, subcommand: string, args: s
const url = getArg(args, '--url'); const url = getArg(args, '--url');
const username = getArg(args, '--username'); const username = getArg(args, '--username');
const password = getArg(args, '--password'); const password = getArg(args, '--password');
requireValue(url, '--url');
await onebox.registries.addRegistry(url, username, password); await onebox.registries.addRegistry(url, username, password);
break; break;
} }
@@ -180,6 +189,68 @@ 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 serviceName = getArg(args, '--name') || appId;
const domain = getArg(args, '--domain');
const portArg = getArg(args, '--port');
const port = portArg ? parseInt(portArg, 10) : undefined;
const autoDNS = getBooleanArg(args, '--auto-dns', true);
requireValue(serviceName, '--name');
if (port !== undefined) {
assertValidPort(port, '--port');
}
const service = await onebox.appStore.installApp({
appId,
version,
serviceName,
domain,
port,
autoDNS,
envVars: parseEnvArgs(args),
});
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 // DNS commands
async function handleDnsCommand(onebox: Onebox, subcommand: string, args: string[]) { async function handleDnsCommand(onebox: Onebox, subcommand: string, args: string[]) {
switch (subcommand) { switch (subcommand) {
@@ -382,7 +453,17 @@ async function handleSystemdCommand(subcommand: string, _args: string[]) {
async function handleConfigCommand(onebox: Onebox, subcommand: string, args: string[]) { async function handleConfigCommand(onebox: Onebox, subcommand: string, args: string[]) {
switch (subcommand) { switch (subcommand) {
case 'show': { case 'show': {
for (const secretKey of onebox.database.getCanonicalSecretSettingKeys()) {
await onebox.database.getSecretSetting(secretKey);
}
const settings = onebox.database.getAllSettings(); const settings = onebox.database.getAllSettings();
for (const secretKey of onebox.database.getCanonicalSecretSettingKeys()) {
if (await onebox.database.hasSecretSetting(secretKey)) {
settings[secretKey] = '********';
}
}
logger.table( logger.table(
['Key', 'Value'], ['Key', 'Value'],
Object.entries(settings).map(([k, v]) => [k, v]) Object.entries(settings).map(([k, v]) => [k, v])
@@ -391,7 +472,11 @@ async function handleConfigCommand(onebox: Onebox, subcommand: string, args: str
} }
case 'set': case 'set':
if (onebox.database.isSecretSettingKey(args[0])) {
await onebox.database.setSecretSetting(args[0], args[1]);
} else {
onebox.database.setSetting(args[0], args[1]); onebox.database.setSetting(args[0], args[1]);
}
logger.success(`Setting ${args[0]} updated`); logger.success(`Setting ${args[0]} updated`);
break; break;
@@ -418,60 +503,29 @@ async function handleUpgradeCommand(): Promise<void> {
logger.info('Checking for updates...'); logger.info('Checking for updates...');
try { try {
// Get current version const updateManager = new OneboxUpdateManager();
const currentVersion = projectInfo.version; const status = await updateManager.getUpdateStatus({ force: true });
if (status.error) {
throw new Error(status.error);
}
// Fetch latest version from Gitea API console.log(` Current version: ${status.currentVersion}`);
const apiUrl = 'https://code.foss.global/api/v1/repos/serve.zone/onebox/releases/latest'; console.log(` Latest version: ${status.latestVersion}`);
const curlCmd = new Deno.Command('curl', {
args: ['-sSL', apiUrl],
stdout: 'piped',
stderr: 'piped',
});
const curlResult = await curlCmd.output();
const response = new TextDecoder().decode(curlResult.stdout);
const release = JSON.parse(response);
const latestVersion = release.tag_name as string; // e.g., "v1.11.0"
// Normalize versions for comparison (ensure both have "v" prefix)
const normalizedCurrent = currentVersion.startsWith('v')
? currentVersion
: `v${currentVersion}`;
const normalizedLatest = latestVersion.startsWith('v')
? latestVersion
: `v${latestVersion}`;
console.log(` Current version: ${normalizedCurrent}`);
console.log(` Latest version: ${normalizedLatest}`);
console.log(''); console.log('');
// Compare normalized versions if (!status.updateAvailable) {
if (normalizedCurrent === normalizedLatest) {
logger.success('Already up to date!'); logger.success('Already up to date!');
return; return;
} }
logger.info(`New version available: ${latestVersion}`); logger.info(`New version available: ${status.latestVersion}`);
logger.info('Downloading and installing...'); logger.info('Downloading and installing...');
console.log(''); console.log('');
// Download and run the install script const upgrade = await updateManager.runUpgradeForeground(status);
const installUrl = 'https://code.foss.global/serve.zone/onebox/raw/branch/main/install.sh';
const installCmd = new Deno.Command('bash', {
args: ['-c', `curl -sSL ${installUrl} | bash`],
stdin: 'inherit',
stdout: 'inherit',
stderr: 'inherit',
});
const installResult = await installCmd.output();
if (!installResult.success) {
logger.error('Upgrade failed');
Deno.exit(1);
}
console.log(''); console.log('');
logger.success(`Upgraded to ${latestVersion}`); logger.success(upgrade.message);
} catch (error) { } catch (error) {
logger.error(`Upgrade failed: ${getErrorMessage(error)}`); logger.error(`Upgrade failed: ${getErrorMessage(error)}`);
Deno.exit(1); Deno.exit(1);
@@ -480,8 +534,106 @@ async function handleUpgradeCommand(): Promise<void> {
// Helpers // Helpers
function getArg(args: string[], flag: string): string { function getArg(args: string[], flag: string): string {
const arg = args.find((a) => a.startsWith(`${flag}=`)); for (let i = 0; i < args.length; i++) {
return arg ? arg.split('=')[1] : ''; 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: IAppStoreVersionConfig,
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 { function printHelp(): void {
@@ -518,9 +670,13 @@ Commands:
ssl list ssl list
ssl force-renew <domain> ssl force-renew <domain>
nginx reload appstore list
nginx test appstore config <app-id> [--version <version>]
nginx status 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 enable Install and enable systemd service
systemd disable Stop, disable, and remove systemd service systemd disable Stop, disable, and remove systemd service
@@ -554,6 +710,7 @@ Production Workflow:
Examples: Examples:
onebox server --ephemeral # Start dev server onebox server --ephemeral # Start dev server
onebox service add myapp --image nginx:latest --domain app.example.com --port 80 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 registry add --url registry.example.com --username user --password pass
onebox systemd enable onebox systemd enable
onebox systemd start onebox systemd start
+36 -1
View File
@@ -26,6 +26,7 @@ import type { TBindValue } from './types.ts';
import { logger } from '../logging.ts'; import { logger } from '../logging.ts';
import { getErrorMessage } from '../utils/error.ts'; import { getErrorMessage } from '../utils/error.ts';
import { MigrationRunner } from './migrations/index.ts'; import { MigrationRunner } from './migrations/index.ts';
import { SecretSettingsManager } from './secret-settings.ts';
// Import repositories // Import repositories
import { import {
@@ -50,6 +51,7 @@ export class OneboxDatabase {
private metricsRepo!: MetricsRepository; private metricsRepo!: MetricsRepository;
private platformRepo!: PlatformRepository; private platformRepo!: PlatformRepository;
private backupRepo!: BackupRepository; private backupRepo!: BackupRepository;
public secretSettings!: SecretSettingsManager;
constructor(dbPath = './.nogit/onebox.db') { constructor(dbPath = './.nogit/onebox.db') {
this.dbPath = dbPath; this.dbPath = dbPath;
@@ -84,6 +86,7 @@ export class OneboxDatabase {
this.metricsRepo = new MetricsRepository(queryFn); this.metricsRepo = new MetricsRepository(queryFn);
this.platformRepo = new PlatformRepository(queryFn); this.platformRepo = new PlatformRepository(queryFn);
this.backupRepo = new BackupRepository(queryFn); this.backupRepo = new BackupRepository(queryFn);
this.secretSettings = new SecretSettingsManager(this.authRepo);
} catch (error) { } catch (error) {
logger.error(`Failed to initialize database: ${getErrorMessage(error)}`); logger.error(`Failed to initialize database: ${getErrorMessage(error)}`);
throw 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 // Version table for migrations
this.query(` this.query(`
CREATE TABLE IF NOT EXISTS migrations ( CREATE TABLE IF NOT EXISTS migrations (
@@ -333,10 +344,34 @@ export class OneboxDatabase {
this.authRepo.setSetting(key, value); this.authRepo.setSetting(key, value);
} }
deleteSetting(key: string): void {
this.authRepo.deleteSetting(key);
}
getAllSettings(): Record<string, string> { getAllSettings(): Record<string, string> {
return this.authRepo.getAllSettings(); 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) ============ // ============ Users CRUD (delegated to repository) ============
async createUser(user: Omit<IUser, 'id'>): Promise<IUser> { async createUser(user: Omit<IUser, 'id'>): Promise<IUser> {
@@ -419,7 +454,7 @@ export class OneboxDatabase {
return this.certificateRepo.getAllDomains(); return this.certificateRepo.getAllDomains();
} }
getDomainsByProvider(provider: 'cloudflare' | 'manual'): IDomain[] { getDomainsByProvider(provider: NonNullable<IDomain['dnsProvider']>): IDomain[] {
return this.certificateRepo.getDomainsByProvider(provider); 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 legacy reverse proxy 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(),
],
);
}
}
@@ -0,0 +1,11 @@
import { BaseMigration } from './base-migration.ts';
import type { TQueryFunction } from '../types.ts';
export class Migration016ServiceVolumes extends BaseMigration {
readonly version = 16;
readonly description = 'Add persistent volume declarations to services';
up(query: TQueryFunction): void {
query(`ALTER TABLE services ADD COLUMN volumes TEXT DEFAULT '[]'`);
}
}
@@ -0,0 +1,11 @@
import { BaseMigration } from './base-migration.ts';
import type { TQueryFunction } from '../types.ts';
export class Migration017ServicePublishedPorts extends BaseMigration {
readonly version = 17;
readonly description = 'Add raw published port declarations to services';
up(query: TQueryFunction): void {
query(`ALTER TABLE services ADD COLUMN published_ports TEXT DEFAULT '[]'`);
}
}
@@ -21,6 +21,9 @@ import { Migration011ScopeColumns } from './migration-011-scope-columns.ts';
import { Migration012GfsRetention } from './migration-012-gfs-retention.ts'; import { Migration012GfsRetention } from './migration-012-gfs-retention.ts';
import { Migration013AppTemplateVersion } from './migration-013-app-template-version.ts'; import { Migration013AppTemplateVersion } from './migration-013-app-template-version.ts';
import { Migration014ContainerArchive } from './migration-014-containerarchive.ts'; import { Migration014ContainerArchive } from './migration-014-containerarchive.ts';
import { Migration015SmartProxyPlatformService } from './migration-015-smartproxy-platform-service.ts';
import { Migration016ServiceVolumes } from './migration-016-service-volumes.ts';
import { Migration017ServicePublishedPorts } from './migration-017-service-published-ports.ts';
import type { BaseMigration } from './base-migration.ts'; import type { BaseMigration } from './base-migration.ts';
export class MigrationRunner { export class MigrationRunner {
@@ -46,6 +49,9 @@ export class MigrationRunner {
new Migration012GfsRetention(), new Migration012GfsRetention(),
new Migration013AppTemplateVersion(), new Migration013AppTemplateVersion(),
new Migration014ContainerArchive(), new Migration014ContainerArchive(),
new Migration015SmartProxyPlatformService(),
new Migration016ServiceVolumes(),
new Migration017ServicePublishedPorts(),
].sort((a, b) => a.version - b.version); ].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> { getAllSettings(): Record<string, string> {
const rows = this.query('SELECT key, value FROM settings'); const rows = this.query('SELECT key, value FROM settings');
const settings: Record<string, string> = {}; const settings: Record<string, string> = {};
@@ -80,4 +84,24 @@ export class AuthRepository extends BaseRepository {
} }
return settings; 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)); 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]); const rows = this.query('SELECT * FROM domains WHERE dns_provider = ? ORDER BY domain ASC', [provider]);
return rows.map((row) => this.rowToDomain(row)); return rows.map((row) => this.rowToDomain(row));
} }
+42 -8
View File
@@ -14,17 +14,19 @@ export class ServiceRepository extends BaseRepository {
const now = Date.now(); const now = Date.now();
this.query( this.query(
`INSERT INTO services ( `INSERT INTO services (
name, image, registry, env_vars, port, domain, container_id, status, name, image, registry, env_vars, volumes, published_ports, port, domain, container_id, status,
created_at, updated_at, created_at, updated_at,
use_onebox_registry, registry_repository, registry_image_tag, use_onebox_registry, registry_repository, registry_image_tag,
auto_update_on_push, image_digest, platform_requirements, auto_update_on_push, image_digest, platform_requirements,
app_template_id, app_template_version app_template_id, app_template_version
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[ [
service.name, service.name,
service.image, service.image,
service.registry || null, service.registry || null,
JSON.stringify(service.envVars), JSON.stringify(service.envVars),
JSON.stringify(service.volumes || []),
JSON.stringify(service.publishedPorts || []),
service.port, service.port,
service.domain || null, service.domain || null,
service.containerID || null, service.containerID || null,
@@ -82,6 +84,14 @@ export class ServiceRepository extends BaseRepository {
fields.push('env_vars = ?'); fields.push('env_vars = ?');
values.push(JSON.stringify(updates.envVars)); values.push(JSON.stringify(updates.envVars));
} }
if (updates.volumes !== undefined) {
fields.push('volumes = ?');
values.push(JSON.stringify(updates.volumes));
}
if (updates.publishedPorts !== undefined) {
fields.push('published_ports = ?');
values.push(JSON.stringify(updates.publishedPorts));
}
if (updates.port !== undefined) { if (updates.port !== undefined) {
fields.push('port = ?'); fields.push('port = ?');
values.push(updates.port); values.push(updates.port);
@@ -169,18 +179,42 @@ export class ServiceRepository extends BaseRepository {
} }
} }
let volumes = [];
const volumesRaw = row.volumes ?? row[20];
if (volumesRaw && volumesRaw !== 'undefined' && volumesRaw !== 'null') {
try {
volumes = JSON.parse(String(volumesRaw));
} catch (e) {
logger.warn(`Failed to parse volumes for service: ${getErrorMessage(e)}`);
volumes = [];
}
}
let publishedPorts = [];
const publishedPortsRaw = row.published_ports;
if (publishedPortsRaw && publishedPortsRaw !== 'undefined' && publishedPortsRaw !== 'null') {
try {
publishedPorts = JSON.parse(String(publishedPortsRaw));
} catch (e) {
logger.warn(`Failed to parse published_ports for service: ${getErrorMessage(e)}`);
publishedPorts = [];
}
}
return { return {
id: Number(row.id || row[0]), id: Number(row.id || row[0]),
name: String(row.name || row[1]), name: String(row.name || row[1]),
image: String(row.image || row[2]), image: String(row.image || row[2]),
registry: (row.registry || row[3]) ? String(row.registry || row[3]) : undefined, registry: (row.registry || row[3]) ? String(row.registry || row[3]) : undefined,
envVars, envVars,
port: Number(row.port || row[5]), volumes,
domain: (row.domain || row[6]) ? String(row.domain || row[6]) : undefined, publishedPorts,
containerID: (row.container_id || row[7]) ? String(row.container_id || row[7]) : undefined, port: Number(row.port ?? row[6] ?? row[5]),
status: String(row.status || row[8]) as IService['status'], domain: (row.domain ?? row[7] ?? row[6]) ? String(row.domain ?? row[7] ?? row[6]) : undefined,
createdAt: Number(row.created_at || row[9]), containerID: (row.container_id ?? row[8] ?? row[7]) ? String(row.container_id ?? row[8] ?? row[7]) : undefined,
updatedAt: Number(row.updated_at || row[10]), status: String(row.status ?? row[9] ?? row[8]) as IService['status'],
createdAt: Number(row.created_at ?? row[10] ?? row[9]),
updatedAt: Number(row.updated_at ?? row[11] ?? row[10]),
useOneboxRegistry: row.use_onebox_registry ? Boolean(row.use_onebox_registry) : undefined, useOneboxRegistry: row.use_onebox_registry ? Boolean(row.use_onebox_registry) : undefined,
registryRepository: row.registry_repository ? String(row.registry_repository) : undefined, registryRepository: row.registry_repository ? String(row.registry_repository) : undefined,
registryImageTag: row.registry_image_tag ? String(row.registry_image_tag) : undefined, registryImageTag: row.registry_image_tag ? String(row.registry_image_tag) : undefined,
+143
View File
@@ -0,0 +1,143 @@
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'],
dcrouterManagedAdminApiToken: [],
} 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);
}
}
}
-2
View File
@@ -13,8 +13,6 @@ export { OneboxDnsManager } from './classes/dns.ts';
export { OneboxSslManager } from './classes/ssl.ts'; export { OneboxSslManager } from './classes/ssl.ts';
export { OneboxDaemon } from './classes/daemon.ts'; export { OneboxDaemon } from './classes/daemon.ts';
export { OneboxSystemd } from './classes/systemd.ts'; export { OneboxSystemd } from './classes/systemd.ts';
export { OneboxHttpServer } from './classes/httpserver.ts';
export { OneboxApiClient } from './classes/apiclient.ts';
// Types // Types
export * from './types.ts'; export * from './types.ts';
+110
View File
@@ -1,6 +1,7 @@
import * as plugins from '../plugins.ts'; import * as plugins from '../plugins.ts';
import { logger } from '../logging.ts'; import { logger } from '../logging.ts';
import type { Onebox } from '../classes/onebox.ts'; import type { Onebox } from '../classes/onebox.ts';
import * as interfaces from '../../ts_interfaces/index.ts';
import * as handlers from './handlers/index.ts'; import * as handlers from './handlers/index.ts';
import { files as bundledFiles } from '../../ts_bundled/bundle.ts'; import { files as bundledFiles } from '../../ts_bundled/bundle.ts';
@@ -22,6 +23,7 @@ export class OpsServer {
public backupsHandler!: handlers.BackupsHandler; public backupsHandler!: handlers.BackupsHandler;
public schedulesHandler!: handlers.SchedulesHandler; public schedulesHandler!: handlers.SchedulesHandler;
public settingsHandler!: handlers.SettingsHandler; public settingsHandler!: handlers.SettingsHandler;
public managedDcRouterHandler!: handlers.ManagedDcRouterHandler;
public logsHandler!: handlers.LogsHandler; public logsHandler!: handlers.LogsHandler;
public workspaceHandler!: handlers.WorkspaceHandler; public workspaceHandler!: handlers.WorkspaceHandler;
public appStoreHandler!: handlers.AppStoreHandler; public appStoreHandler!: handlers.AppStoreHandler;
@@ -35,6 +37,7 @@ export class OpsServer {
domain: 'localhost', domain: 'localhost',
feedMetadata: undefined, feedMetadata: undefined,
bundledContent: bundledFiles, bundledContent: bundledFiles,
addCustomRoutes: async (typedserver) => this.registerCustomRoutes(typedserver),
}); });
// Chain typedrouters: server -> opsServer -> individual handlers // Chain typedrouters: server -> opsServer -> individual handlers
@@ -64,6 +67,7 @@ export class OpsServer {
this.backupsHandler = new handlers.BackupsHandler(this); this.backupsHandler = new handlers.BackupsHandler(this);
this.schedulesHandler = new handlers.SchedulesHandler(this); this.schedulesHandler = new handlers.SchedulesHandler(this);
this.settingsHandler = new handlers.SettingsHandler(this); this.settingsHandler = new handlers.SettingsHandler(this);
this.managedDcRouterHandler = new handlers.ManagedDcRouterHandler(this);
this.logsHandler = new handlers.LogsHandler(this); this.logsHandler = new handlers.LogsHandler(this);
this.workspaceHandler = new handlers.WorkspaceHandler(this); this.workspaceHandler = new handlers.WorkspaceHandler(this);
this.appStoreHandler = new handlers.AppStoreHandler(this); this.appStoreHandler = new handlers.AppStoreHandler(this);
@@ -71,10 +75,116 @@ export class OpsServer {
logger.success('OpsServer TypedRequest handlers initialized'); 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() { public async stop() {
if (this.server) { if (this.server) {
await this.server.stop(); await this.server.stop();
logger.success('OpsServer stopped'); 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,
});
}
} }
+92 -52
View File
@@ -2,9 +2,12 @@ import * as plugins from '../../plugins.ts';
import { logger } from '../../logging.ts'; import { logger } from '../../logging.ts';
import type { OpsServer } from '../classes.opsserver.ts'; import type { OpsServer } from '../classes.opsserver.ts';
import * as interfaces from '../../../ts_interfaces/index.ts'; import * as interfaces from '../../../ts_interfaces/index.ts';
import { hashPassword, verifyPassword } from '../../utils/auth.ts';
export interface IJwtData { export interface IJwtData {
userId: string; userId: string;
username: string;
role: 'admin' | 'user';
status: 'loggedIn' | 'loggedOut'; status: 'loggedIn' | 'loggedOut';
expiresAt: number; expiresAt: number;
} }
@@ -18,12 +21,80 @@ export class AdminHandler {
} }
public async initialize(): Promise<void> { 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.init();
await this.smartjwtInstance.createNewKeyPair();
this.registerHandlers(); 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 { private registerHandlers(): void {
// Login // Login
this.typedrouter.addTypedHandler( this.typedrouter.addTypedHandler(
@@ -36,30 +107,19 @@ export class AdminHandler {
throw new plugins.typedrequest.TypedResponseError('Invalid credentials'); throw new plugins.typedrequest.TypedResponseError('Invalid credentials');
} }
// Verify password (base64 comparison to match existing DB scheme) const passwordMatches = await verifyPassword(dataArg.password, user.passwordHash);
const passwordHash = btoa(dataArg.password); if (!passwordMatches) {
if (passwordHash !== user.passwordHash) {
throw new plugins.typedrequest.TypedResponseError('Invalid credentials'); throw new plugins.typedrequest.TypedResponseError('Invalid credentials');
} }
const expiresAt = Date.now() + 24 * 3600 * 1000; const expiresAt = Date.now() + 24 * 3600 * 1000;
const userId = String(user.id || user.username); const freshUser = this.opsServerRef.oneboxRef.database.getUserByUsername(user.username) || user;
const jwt = await this.smartjwtInstance.createJWT({ const identity = await this.createIdentityForUser(freshUser, expiresAt);
userId,
status: 'loggedIn',
expiresAt,
});
logger.info(`User logged in: ${user.username}`); logger.info(`User logged in: ${user.username}`);
return { return {
identity: { identity,
jwt,
userId,
username: user.username,
expiresAt,
role: user.role,
},
}; };
} catch (error) { } catch (error) {
if (error instanceof plugins.typedrequest.TypedResponseError) throw error; if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
@@ -84,22 +144,11 @@ export class AdminHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_VerifyIdentity>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_VerifyIdentity>(
'verifyIdentity', 'verifyIdentity',
async (dataArg) => { async (dataArg) => {
if (!dataArg.identity?.jwt) {
return { valid: false };
}
try { try {
const jwtData = await this.smartjwtInstance.verifyJWTAndGetData(dataArg.identity.jwt); const identity = await this.getVerifiedIdentity(dataArg.identity);
if (jwtData.expiresAt < Date.now()) return { valid: false };
if (jwtData.status !== 'loggedIn') return { valid: false };
return { return {
valid: true, valid: true,
identity: { identity,
jwt: dataArg.identity.jwt,
userId: jwtData.userId,
username: dataArg.identity.username,
expiresAt: jwtData.expiresAt,
role: dataArg.identity.role,
},
}; };
} catch { } catch {
return { valid: false }; return { valid: false };
@@ -113,18 +162,18 @@ export class AdminHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ChangePassword>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ChangePassword>(
'changePassword', 'changePassword',
async (dataArg) => { async (dataArg) => {
await this.requireValidIdentity(dataArg); const identity = await this.getVerifiedIdentity(dataArg.identity);
const user = this.opsServerRef.oneboxRef.database.getUserByUsername(dataArg.identity.username); const user = this.opsServerRef.oneboxRef.database.getUserByUsername(identity.username);
if (!user) { if (!user) {
throw new plugins.typedrequest.TypedResponseError('User not found'); throw new plugins.typedrequest.TypedResponseError('User not found');
} }
const currentHash = btoa(dataArg.currentPassword); const currentPasswordMatches = await verifyPassword(dataArg.currentPassword, user.passwordHash);
if (currentHash !== user.passwordHash) { if (!currentPasswordMatches) {
throw new plugins.typedrequest.TypedResponseError('Current password is incorrect'); 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); this.opsServerRef.oneboxRef.database.updateUserPassword(user.username, newHash);
logger.info(`Password changed for user: ${user.username}`); 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 // Guard for valid identity
public validIdentityGuard = new plugins.smartguard.Guard<{ public validIdentityGuard = new plugins.smartguard.Guard<{
identity: interfaces.data.IIdentity; identity: interfaces.data.IIdentity;
}>( }>(
async (dataArg) => { async (dataArg) => {
if (!dataArg.identity?.jwt) return false;
try { try {
const jwtData = await this.smartjwtInstance.verifyJWTAndGetData(dataArg.identity.jwt); await this.getVerifiedIdentity(dataArg.identity);
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;
return true; return true;
} catch { } catch {
return false; return false;
@@ -166,9 +203,12 @@ export class AdminHandler {
identity: interfaces.data.IIdentity; identity: interfaces.data.IIdentity;
}>( }>(
async (dataArg) => { async (dataArg) => {
const isValid = await this.validIdentityGuard.exec(dataArg); try {
if (!isValid) return false; const identity = await this.getVerifiedIdentity(dataArg.identity);
return dataArg.identity.role === 'admin'; return identity.role === 'admin';
} catch {
return false;
}
}, },
{ failedHint: 'user is not admin', name: 'adminIdentityGuard' }, { failedHint: 'user is not admin', name: 'adminIdentityGuard' },
); );
+25 -20
View File
@@ -2,7 +2,7 @@ import * as plugins from '../../plugins.ts';
import { logger } from '../../logging.ts'; import { logger } from '../../logging.ts';
import type { OpsServer } from '../classes.opsserver.ts'; import type { OpsServer } from '../classes.opsserver.ts';
import * as interfaces from '../../../ts_interfaces/index.ts'; import * as interfaces from '../../../ts_interfaces/index.ts';
import { requireValidIdentity } from '../helpers/guards.ts'; import { requireAdminIdentity } from '../helpers/guards.ts';
export class AppStoreHandler { export class AppStoreHandler {
public typedrouter = new plugins.typedrequest.TypedRouter(); public typedrouter = new plugins.typedrequest.TypedRouter();
@@ -13,24 +13,22 @@ export class AppStoreHandler {
} }
private registerHandlers(): void { private registerHandlers(): void {
// Get app templates (catalog)
this.typedrouter.addTypedHandler( this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetAppTemplates>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetAppStoreTemplates>(
'getAppTemplates', 'getAppStoreTemplates',
async (dataArg) => { async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg); await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const apps = await this.opsServerRef.oneboxRef.appStore.getApps(); const apps = await this.opsServerRef.oneboxRef.appStore.getApps();
return { apps }; return { apps };
}, },
), ),
); );
// Get app config for a specific version
this.typedrouter.addTypedHandler( this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetAppConfig>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetAppStoreConfig>(
'getAppConfig', 'getAppStoreConfig',
async (dataArg) => { async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg); await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const config = await this.opsServerRef.oneboxRef.appStore.getAppVersionConfig( const config = await this.opsServerRef.oneboxRef.appStore.getAppVersionConfig(
dataArg.appId, dataArg.appId,
dataArg.version, dataArg.version,
@@ -41,24 +39,33 @@ export class AppStoreHandler {
), ),
); );
// Get services with available upgrades
this.typedrouter.addTypedHandler( this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetUpgradeableServices>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_InstallAppStoreApp>(
'getUpgradeableServices', 'installAppStoreApp',
async (dataArg) => { async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg); await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const services = await this.opsServerRef.oneboxRef.appStore.getUpgradeableServices(); const service = await this.opsServerRef.oneboxRef.appStore.installApp(dataArg.install);
return { service };
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetUpgradeableAppStoreServices>(
'getUpgradeableAppStoreServices',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const services = await this.opsServerRef.oneboxRef.appStore.getUpgradeableAppStoreServices();
return { services }; return { services };
}, },
), ),
); );
// Upgrade a service to a new template version
this.typedrouter.addTypedHandler( this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpgradeService>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpgradeAppStoreService>(
'upgradeService', 'upgradeAppStoreService',
async (dataArg) => { async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg); await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const existingService = this.opsServerRef.oneboxRef.database.getServiceByName(dataArg.serviceName); const existingService = this.opsServerRef.oneboxRef.database.getServiceByName(dataArg.serviceName);
if (!existingService) { if (!existingService) {
@@ -73,7 +80,6 @@ export class AppStoreHandler {
logger.info(`Upgrading service '${dataArg.serviceName}' from v${existingService.appTemplateVersion} to v${dataArg.targetVersion}`); logger.info(`Upgrading service '${dataArg.serviceName}' from v${existingService.appTemplateVersion} to v${dataArg.targetVersion}`);
// Execute migration
const migrationResult = await this.opsServerRef.oneboxRef.appStore.executeMigration( const migrationResult = await this.opsServerRef.oneboxRef.appStore.executeMigration(
existingService, existingService,
existingService.appTemplateVersion, existingService.appTemplateVersion,
@@ -86,7 +92,6 @@ export class AppStoreHandler {
); );
} }
// Apply the upgrade
const updatedService = await this.opsServerRef.oneboxRef.appStore.applyUpgrade( const updatedService = await this.opsServerRef.oneboxRef.appStore.applyUpgrade(
dataArg.serviceName, dataArg.serviceName,
migrationResult, migrationResult,
+7 -7
View File
@@ -1,7 +1,7 @@
import * as plugins from '../../plugins.ts'; import * as plugins from '../../plugins.ts';
import type { OpsServer } from '../classes.opsserver.ts'; import type { OpsServer } from '../classes.opsserver.ts';
import * as interfaces from '../../../ts_interfaces/index.ts'; import * as interfaces from '../../../ts_interfaces/index.ts';
import { requireValidIdentity } from '../helpers/guards.ts'; import { requireAdminIdentity } from '../helpers/guards.ts';
export class BackupsHandler { export class BackupsHandler {
public typedrouter = new plugins.typedrequest.TypedRouter(); public typedrouter = new plugins.typedrequest.TypedRouter();
@@ -16,7 +16,7 @@ export class BackupsHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetBackups>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetBackups>(
'getBackups', 'getBackups',
async (dataArg) => { async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg); await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const backups = this.opsServerRef.oneboxRef.backupManager.listBackups(); const backups = this.opsServerRef.oneboxRef.backupManager.listBackups();
return { backups }; return { backups };
}, },
@@ -27,7 +27,7 @@ export class BackupsHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetBackup>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetBackup>(
'getBackup', 'getBackup',
async (dataArg) => { async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg); await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const backup = this.opsServerRef.oneboxRef.database.getBackupById(dataArg.backupId); const backup = this.opsServerRef.oneboxRef.database.getBackupById(dataArg.backupId);
if (!backup) { if (!backup) {
throw new plugins.typedrequest.TypedResponseError('Backup not found'); throw new plugins.typedrequest.TypedResponseError('Backup not found');
@@ -41,7 +41,7 @@ export class BackupsHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteBackup>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteBackup>(
'deleteBackup', 'deleteBackup',
async (dataArg) => { async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg); await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
await this.opsServerRef.oneboxRef.backupManager.deleteBackup(dataArg.backupId); await this.opsServerRef.oneboxRef.backupManager.deleteBackup(dataArg.backupId);
return { ok: true }; return { ok: true };
}, },
@@ -52,7 +52,7 @@ export class BackupsHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RestoreBackup>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RestoreBackup>(
'restoreBackup', 'restoreBackup',
async (dataArg) => { async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg); await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const rawResult = await this.opsServerRef.oneboxRef.backupManager.restoreBackup( const rawResult = await this.opsServerRef.oneboxRef.backupManager.restoreBackup(
dataArg.backupId, dataArg.backupId,
dataArg.options, dataArg.options,
@@ -75,7 +75,7 @@ export class BackupsHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DownloadBackup>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DownloadBackup>(
'downloadBackup', 'downloadBackup',
async (dataArg) => { async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg); await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const backup = this.opsServerRef.oneboxRef.database.getBackupById(dataArg.backupId); const backup = this.opsServerRef.oneboxRef.database.getBackupById(dataArg.backupId);
if (!backup) { if (!backup) {
throw new plugins.typedrequest.TypedResponseError('Backup not found'); 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 // Return a download URL that the client can fetch directly
const filename = backup.filename || `${backup.serviceName}-${backup.createdAt}.tar.enc`; const filename = backup.filename || `${backup.serviceName}-${backup.createdAt}.tar.enc`;
return { return {
downloadUrl: `/api/backups/${dataArg.backupId}/download`, downloadUrl: `/backups/${dataArg.backupId}/download?jwt=${encodeURIComponent(dataArg.identity.jwt)}`,
filename, filename,
}; };
}, },
+16 -5
View File
@@ -1,7 +1,7 @@
import * as plugins from '../../plugins.ts'; import * as plugins from '../../plugins.ts';
import type { OpsServer } from '../classes.opsserver.ts'; import type { OpsServer } from '../classes.opsserver.ts';
import * as interfaces from '../../../ts_interfaces/index.ts'; import * as interfaces from '../../../ts_interfaces/index.ts';
import { requireValidIdentity } from '../helpers/guards.ts'; import { requireAdminIdentity } from '../helpers/guards.ts';
export class DnsHandler { export class DnsHandler {
public typedrouter = new plugins.typedrequest.TypedRouter(); public typedrouter = new plugins.typedrequest.TypedRouter();
@@ -16,7 +16,7 @@ export class DnsHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetDnsRecords>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetDnsRecords>(
'getDnsRecords', 'getDnsRecords',
async (dataArg) => { async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg); await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const records = this.opsServerRef.oneboxRef.dns.listDNSRecords(); const records = this.opsServerRef.oneboxRef.dns.listDNSRecords();
return { records }; return { records };
}, },
@@ -27,7 +27,7 @@ export class DnsHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateDnsRecord>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateDnsRecord>(
'createDnsRecord', 'createDnsRecord',
async (dataArg) => { async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg); await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
await this.opsServerRef.oneboxRef.dns.addDNSRecord(dataArg.domain, dataArg.value); await this.opsServerRef.oneboxRef.dns.addDNSRecord(dataArg.domain, dataArg.value);
const records = this.opsServerRef.oneboxRef.dns.listDNSRecords(); const records = this.opsServerRef.oneboxRef.dns.listDNSRecords();
const record = records.find((r: any) => r.domain === dataArg.domain); 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>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteDnsRecord>(
'deleteDnsRecord', 'deleteDnsRecord',
async (dataArg) => { async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg); await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
await this.opsServerRef.oneboxRef.dns.removeDNSRecord(dataArg.domain); await this.opsServerRef.oneboxRef.dns.removeDNSRecord(dataArg.domain);
return { ok: true }; return { ok: true };
}, },
@@ -51,7 +51,7 @@ export class DnsHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SyncDns>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SyncDns>(
'syncDns', 'syncDns',
async (dataArg) => { async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg); await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
if (!this.opsServerRef.oneboxRef.dns.isConfigured()) { if (!this.opsServerRef.oneboxRef.dns.isConfigured()) {
throw new plugins.typedrequest.TypedResponseError('DNS manager not configured'); throw new plugins.typedrequest.TypedResponseError('DNS manager not configured');
} }
@@ -61,5 +61,16 @@ export class DnsHandler {
}, },
), ),
); );
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetGatewayDnsRecords>(
'getGatewayDnsRecords',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const records = await this.opsServerRef.oneboxRef.externalGateway.getGatewayDnsRecords();
return { records };
},
),
);
} }
} }
+15 -4
View File
@@ -1,7 +1,7 @@
import * as plugins from '../../plugins.ts'; import * as plugins from '../../plugins.ts';
import type { OpsServer } from '../classes.opsserver.ts'; import type { OpsServer } from '../classes.opsserver.ts';
import * as interfaces from '../../../ts_interfaces/index.ts'; import * as interfaces from '../../../ts_interfaces/index.ts';
import { requireValidIdentity } from '../helpers/guards.ts'; import { requireAdminIdentity } from '../helpers/guards.ts';
export class DomainsHandler { export class DomainsHandler {
public typedrouter = new plugins.typedrequest.TypedRouter(); public typedrouter = new plugins.typedrequest.TypedRouter();
@@ -57,7 +57,7 @@ export class DomainsHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetDomains>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetDomains>(
'getDomains', 'getDomains',
async (dataArg) => { async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg); await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const domains = this.buildDomainViews(); const domains = this.buildDomainViews();
return { domains }; return { domains };
}, },
@@ -68,7 +68,7 @@ export class DomainsHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetDomain>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetDomain>(
'getDomain', 'getDomain',
async (dataArg) => { async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg); await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const domain = this.opsServerRef.oneboxRef.database.getDomainByName(dataArg.domainName); const domain = this.opsServerRef.oneboxRef.database.getDomainByName(dataArg.domainName);
if (!domain) { if (!domain) {
throw new plugins.typedrequest.TypedResponseError('Domain not found'); throw new plugins.typedrequest.TypedResponseError('Domain not found');
@@ -87,7 +87,7 @@ export class DomainsHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SyncDomains>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SyncDomains>(
'syncDomains', 'syncDomains',
async (dataArg) => { async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg); await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
if (!this.opsServerRef.oneboxRef.cloudflareDomainSync) { if (!this.opsServerRef.oneboxRef.cloudflareDomainSync) {
throw new plugins.typedrequest.TypedResponseError('Cloudflare domain sync not configured'); throw new plugins.typedrequest.TypedResponseError('Cloudflare domain sync not configured');
} }
@@ -97,5 +97,16 @@ export class DomainsHandler {
}, },
), ),
); );
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetGatewayDomains>(
'getGatewayDomains',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const domains = await this.opsServerRef.oneboxRef.externalGateway.getGatewayDomains();
return { domains };
},
),
);
} }
} }
+1
View File
@@ -10,6 +10,7 @@ export * from './network.handler.ts';
export * from './backups.handler.ts'; export * from './backups.handler.ts';
export * from './schedules.handler.ts'; export * from './schedules.handler.ts';
export * from './settings.handler.ts'; export * from './settings.handler.ts';
export * from './managed-dcrouter.handler.ts';
export * from './logs.handler.ts'; export * from './logs.handler.ts';
export * from './workspace.handler.ts'; export * from './workspace.handler.ts';
export * from './appstore.handler.ts'; export * from './appstore.handler.ts';
+8 -8
View File
@@ -2,7 +2,7 @@ import * as plugins from '../../plugins.ts';
import { logger } from '../../logging.ts'; import { logger } from '../../logging.ts';
import type { OpsServer } from '../classes.opsserver.ts'; import type { OpsServer } from '../classes.opsserver.ts';
import * as interfaces from '../../../ts_interfaces/index.ts'; import * as interfaces from '../../../ts_interfaces/index.ts';
import { requireValidIdentity } from '../helpers/guards.ts'; import { requireAdminIdentity } from '../helpers/guards.ts';
export class LogsHandler { export class LogsHandler {
public typedrouter = new plugins.typedrequest.TypedRouter(); public typedrouter = new plugins.typedrequest.TypedRouter();
@@ -18,7 +18,7 @@ export class LogsHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetServiceLogStream>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetServiceLogStream>(
'getServiceLogStream', 'getServiceLogStream',
async (dataArg) => { async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg); await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const service = this.opsServerRef.oneboxRef.database.getServiceByName(dataArg.serviceName); const service = this.opsServerRef.oneboxRef.database.getServiceByName(dataArg.serviceName);
if (!service) { if (!service) {
@@ -99,7 +99,7 @@ export class LogsHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetPlatformServiceLogStream>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetPlatformServiceLogStream>(
'getPlatformServiceLogStream', 'getPlatformServiceLogStream',
async (dataArg) => { async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg); await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const platformService = this.opsServerRef.oneboxRef.database.getPlatformServiceByType( const platformService = this.opsServerRef.oneboxRef.database.getPlatformServiceByType(
dataArg.serviceType, dataArg.serviceType,
@@ -160,26 +160,26 @@ export class LogsHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetNetworkLogStream>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetNetworkLogStream>(
'getNetworkLogStream', 'getNetworkLogStream',
async (dataArg) => { async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg); await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const virtualStream = new plugins.typedrequest.VirtualStream<Uint8Array>(); const virtualStream = new plugins.typedrequest.VirtualStream<Uint8Array>();
const encoder = new TextEncoder(); const encoder = new TextEncoder();
const clientId = crypto.randomUUID(); 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 = { const mockSocket = {
readyState: 1, // WebSocket.OPEN readyState: 1, // WebSocket.OPEN
send: (data: string) => { send: (data: string) => {
try { try {
virtualStream.sendData(encoder.encode(data)); virtualStream.sendData(encoder.encode(data));
} catch { } catch {
this.opsServerRef.oneboxRef.caddyLogReceiver.removeClient(clientId); this.opsServerRef.oneboxRef.proxyLogReceiver.removeClient(clientId);
} }
}, },
}; };
const filter = dataArg.filter || {}; const filter = dataArg.filter || {};
this.opsServerRef.oneboxRef.caddyLogReceiver.addClient( this.opsServerRef.oneboxRef.proxyLogReceiver.addClient(
clientId, clientId,
mockSocket as any, mockSocket as any,
filter, filter,
@@ -195,7 +195,7 @@ export class LogsHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetEventStream>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetEventStream>(
'getEventStream', 'getEventStream',
async (dataArg) => { async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg); await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const virtualStream = new plugins.typedrequest.VirtualStream<Uint8Array>(); const virtualStream = new plugins.typedrequest.VirtualStream<Uint8Array>();
const encoder = new TextEncoder(); const encoder = new TextEncoder();
@@ -0,0 +1,59 @@
import * as plugins from '../../plugins.ts';
import type { OpsServer } from '../classes.opsserver.ts';
import * as interfaces from '../../../ts_interfaces/index.ts';
import { requireAdminIdentity } from '../helpers/guards.ts';
export class ManagedDcRouterHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
constructor(private opsServerRef: OpsServer) {
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
this.registerHandlers();
}
private registerHandlers(): void {
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetManagedDcRouterStatus>(
'getManagedDcRouterStatus',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const status = await this.opsServerRef.oneboxRef.managedDcRouter.getStatus();
return { status };
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_StartManagedDcRouter>(
'startManagedDcRouter',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const status = await this.opsServerRef.oneboxRef.managedDcRouter.start();
return { status };
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_StopManagedDcRouter>(
'stopManagedDcRouter',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const status = await this.opsServerRef.oneboxRef.managedDcRouter.stop();
return { status };
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RestartManagedDcRouter>(
'restartManagedDcRouter',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const status = await this.opsServerRef.oneboxRef.managedDcRouter.restart();
return { status };
},
),
);
}
}
+7 -7
View File
@@ -1,7 +1,7 @@
import * as plugins from '../../plugins.ts'; import * as plugins from '../../plugins.ts';
import type { OpsServer } from '../classes.opsserver.ts'; import type { OpsServer } from '../classes.opsserver.ts';
import * as interfaces from '../../../ts_interfaces/index.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'; import type { TPlatformServiceType } from '../../types.ts';
export class NetworkHandler { export class NetworkHandler {
@@ -19,7 +19,7 @@ export class NetworkHandler {
redis: 6379, redis: 6379,
postgresql: 5432, postgresql: 5432,
rabbitmq: 5672, rabbitmq: 5672,
caddy: 80, smartproxy: 80,
clickhouse: 8123, clickhouse: 8123,
mariadb: 3306, mariadb: 3306,
}; };
@@ -31,7 +31,7 @@ export class NetworkHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetNetworkTargets>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetNetworkTargets>(
'getNetworkTargets', 'getNetworkTargets',
async (dataArg) => { async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg); await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const targets: interfaces.data.INetworkTarget[] = []; const targets: interfaces.data.INetworkTarget[] = [];
// Services // Services
@@ -83,9 +83,9 @@ export class NetworkHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetNetworkStats>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetNetworkStats>(
'getNetworkStats', 'getNetworkStats',
async (dataArg) => { async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg); await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const proxyStatus = this.opsServerRef.oneboxRef.reverseProxy.getStatus() as any; const proxyStatus = this.opsServerRef.oneboxRef.reverseProxy.getStatus() as any;
const logReceiverStats = this.opsServerRef.oneboxRef.caddyLogReceiver.getStats(); const logReceiverStats = this.opsServerRef.oneboxRef.proxyLogReceiver.getStats();
return { return {
stats: { stats: {
@@ -114,8 +114,8 @@ export class NetworkHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetTrafficStats>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetTrafficStats>(
'getTrafficStats', 'getTrafficStats',
async (dataArg) => { async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg); await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const trafficStats = this.opsServerRef.oneboxRef.caddyLogReceiver.getTrafficStats(60); const trafficStats = this.opsServerRef.oneboxRef.proxyLogReceiver.getTrafficStats(60);
return { stats: trafficStats }; return { stats: trafficStats };
}, },
), ),
+11 -37
View File
@@ -2,7 +2,7 @@ import * as plugins from '../../plugins.ts';
import { logger } from '../../logging.ts'; import { logger } from '../../logging.ts';
import type { OpsServer } from '../classes.opsserver.ts'; import type { OpsServer } from '../classes.opsserver.ts';
import * as interfaces from '../../../ts_interfaces/index.ts'; import * as interfaces from '../../../ts_interfaces/index.ts';
import { requireValidIdentity } from '../helpers/guards.ts'; import { requireAdminIdentity } from '../helpers/guards.ts';
export class PlatformHandler { export class PlatformHandler {
public typedrouter = new plugins.typedrequest.TypedRouter(); public typedrouter = new plugins.typedrequest.TypedRouter();
@@ -91,21 +91,8 @@ export class PlatformHandler {
line: string, line: string,
isError: boolean, isError: boolean,
): void { ): void {
const typedsocket = (this.opsServerRef.server as any)?.typedserver?.typedsocket;
if (!typedsocket) return;
const entry = this.parseLogLine(line, isError); const entry = this.parseLogLine(line, isError);
void this.opsServerRef.pushDashboardEvent('pushPlatformServiceLog', { serviceType, entry });
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(() => {});
} }
private pushServiceLogToClients( private pushServiceLogToClients(
@@ -113,21 +100,8 @@ export class PlatformHandler {
line: string, line: string,
isError: boolean, isError: boolean,
): void { ): void {
const typedsocket = (this.opsServerRef.server as any)?.typedserver?.typedsocket;
if (!typedsocket) return;
const entry = this.parseLogLine(line, isError); const entry = this.parseLogLine(line, isError);
void this.opsServerRef.pushDashboardEvent('pushServiceLog', { serviceName, entry });
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(() => {});
} }
private registerHandlers(): void { private registerHandlers(): void {
@@ -136,7 +110,7 @@ export class PlatformHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetPlatformServices>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetPlatformServices>(
'getPlatformServices', 'getPlatformServices',
async (dataArg) => { async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg); await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const platformServices = this.opsServerRef.oneboxRef.platformServices.getAllPlatformServices(); const platformServices = this.opsServerRef.oneboxRef.platformServices.getAllPlatformServices();
const providers = this.opsServerRef.oneboxRef.platformServices.getAllProviders(); const providers = this.opsServerRef.oneboxRef.platformServices.getAllProviders();
@@ -145,7 +119,7 @@ export class PlatformHandler {
const isCore = 'isCore' in provider && (provider as any).isCore === true; const isCore = 'isCore' in provider && (provider as any).isCore === true;
let status: string = service?.status || 'not-deployed'; let status: string = service?.status || 'not-deployed';
if (provider.type === 'caddy') { if (provider.type === 'smartproxy') {
const proxyStatus = this.opsServerRef.oneboxRef.reverseProxy.getStatus() as any; const proxyStatus = this.opsServerRef.oneboxRef.reverseProxy.getStatus() as any;
status = (proxyStatus.running ?? proxyStatus.http?.running) ? 'running' : 'stopped'; status = (proxyStatus.running ?? proxyStatus.http?.running) ? 'running' : 'stopped';
} }
@@ -172,7 +146,7 @@ export class PlatformHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetPlatformService>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetPlatformService>(
'getPlatformService', 'getPlatformService',
async (dataArg) => { async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg); await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const provider = this.opsServerRef.oneboxRef.platformServices.getProvider(dataArg.serviceType); const provider = this.opsServerRef.oneboxRef.platformServices.getProvider(dataArg.serviceType);
if (!provider) { if (!provider) {
throw new plugins.typedrequest.TypedResponseError(`Unknown platform service type: ${dataArg.serviceType}`); 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; const isCore = 'isCore' in provider && (provider as any).isCore === true;
let rawStatus: string = service?.status || 'not-deployed'; let rawStatus: string = service?.status || 'not-deployed';
if (dataArg.serviceType === 'caddy') { if (dataArg.serviceType === 'smartproxy') {
const proxyStatus = this.opsServerRef.oneboxRef.reverseProxy.getStatus() as any; const proxyStatus = this.opsServerRef.oneboxRef.reverseProxy.getStatus() as any;
rawStatus = (proxyStatus.running ?? proxyStatus.http?.running) ? 'running' : 'stopped'; rawStatus = (proxyStatus.running ?? proxyStatus.http?.running) ? 'running' : 'stopped';
} }
@@ -208,7 +182,7 @@ export class PlatformHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_StartPlatformService>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_StartPlatformService>(
'startPlatformService', 'startPlatformService',
async (dataArg) => { async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg); await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const provider = this.opsServerRef.oneboxRef.platformServices.getProvider(dataArg.serviceType); const provider = this.opsServerRef.oneboxRef.platformServices.getProvider(dataArg.serviceType);
if (!provider) { if (!provider) {
throw new plugins.typedrequest.TypedResponseError(`Unknown platform service type: ${dataArg.serviceType}`); 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>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_StopPlatformService>(
'stopPlatformService', 'stopPlatformService',
async (dataArg) => { async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg); await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const provider = this.opsServerRef.oneboxRef.platformServices.getProvider(dataArg.serviceType); const provider = this.opsServerRef.oneboxRef.platformServices.getProvider(dataArg.serviceType);
if (!provider) { if (!provider) {
throw new plugins.typedrequest.TypedResponseError(`Unknown platform service type: ${dataArg.serviceType}`); 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>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetPlatformServiceStats>(
'getPlatformServiceStats', 'getPlatformServiceStats',
async (dataArg) => { async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg); await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const service = this.opsServerRef.oneboxRef.database.getPlatformServiceByType(dataArg.serviceType); const service = this.opsServerRef.oneboxRef.database.getPlatformServiceByType(dataArg.serviceType);
if (!service || !service.containerId) { if (!service || !service.containerId) {
throw new plugins.typedrequest.TypedResponseError('Platform service has no container'); 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>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetPlatformServiceLogs>(
'getPlatformServiceLogs', 'getPlatformServiceLogs',
async (dataArg) => { async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg); await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const service = this.opsServerRef.oneboxRef.database.getPlatformServiceByType(dataArg.serviceType); const service = this.opsServerRef.oneboxRef.database.getPlatformServiceByType(dataArg.serviceType);
if (!service || !service.containerId) { if (!service || !service.containerId) {
throw new plugins.typedrequest.TypedResponseError('Platform service has no container'); throw new plugins.typedrequest.TypedResponseError('Platform service has no container');
+6 -6
View File
@@ -1,7 +1,7 @@
import * as plugins from '../../plugins.ts'; import * as plugins from '../../plugins.ts';
import type { OpsServer } from '../classes.opsserver.ts'; import type { OpsServer } from '../classes.opsserver.ts';
import * as interfaces from '../../../ts_interfaces/index.ts'; import * as interfaces from '../../../ts_interfaces/index.ts';
import { requireValidIdentity } from '../helpers/guards.ts'; import { requireAdminIdentity } from '../helpers/guards.ts';
export class RegistryHandler { export class RegistryHandler {
public typedrouter = new plugins.typedrequest.TypedRouter(); public typedrouter = new plugins.typedrequest.TypedRouter();
@@ -17,7 +17,7 @@ export class RegistryHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRegistryTags>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRegistryTags>(
'getRegistryTags', 'getRegistryTags',
async (dataArg) => { async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg); await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const tags = await this.opsServerRef.oneboxRef.registry.getImageTags(dataArg.serviceName); const tags = await this.opsServerRef.oneboxRef.registry.getImageTags(dataArg.serviceName);
return { tags }; return { tags };
}, },
@@ -29,7 +29,7 @@ export class RegistryHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRegistryTokens>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRegistryTokens>(
'getRegistryTokens', 'getRegistryTokens',
async (dataArg) => { async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg); await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const rawTokens = this.opsServerRef.oneboxRef.database.getAllRegistryTokens(); const rawTokens = this.opsServerRef.oneboxRef.database.getAllRegistryTokens();
const now = Date.now(); const now = Date.now();
@@ -68,7 +68,7 @@ export class RegistryHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateRegistryToken>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateRegistryToken>(
'createRegistryToken', 'createRegistryToken',
async (dataArg) => { async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg); const identity = await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const config = dataArg.tokenConfig; const config = dataArg.tokenConfig;
// Calculate expiration // Calculate expiration
@@ -95,7 +95,7 @@ export class RegistryHandler {
expiresAt, expiresAt,
createdAt: now, createdAt: now,
lastUsedAt: null, lastUsedAt: null,
createdBy: dataArg.identity.username, createdBy: identity.username,
}); });
let scopeDisplay: string; let scopeDisplay: string;
@@ -133,7 +133,7 @@ export class RegistryHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteRegistryToken>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteRegistryToken>(
'deleteRegistryToken', 'deleteRegistryToken',
async (dataArg) => { async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg); await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const token = this.opsServerRef.oneboxRef.database.getRegistryTokenById(dataArg.tokenId); const token = this.opsServerRef.oneboxRef.database.getRegistryTokenById(dataArg.tokenId);
if (!token) { if (!token) {
throw new plugins.typedrequest.TypedResponseError('Token not found'); throw new plugins.typedrequest.TypedResponseError('Token not found');
+7 -7
View File
@@ -1,7 +1,7 @@
import * as plugins from '../../plugins.ts'; import * as plugins from '../../plugins.ts';
import type { OpsServer } from '../classes.opsserver.ts'; import type { OpsServer } from '../classes.opsserver.ts';
import * as interfaces from '../../../ts_interfaces/index.ts'; import * as interfaces from '../../../ts_interfaces/index.ts';
import { requireValidIdentity } from '../helpers/guards.ts'; import { requireAdminIdentity } from '../helpers/guards.ts';
export class SchedulesHandler { export class SchedulesHandler {
public typedrouter = new plugins.typedrequest.TypedRouter(); public typedrouter = new plugins.typedrequest.TypedRouter();
@@ -16,7 +16,7 @@ export class SchedulesHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetBackupSchedules>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetBackupSchedules>(
'getBackupSchedules', 'getBackupSchedules',
async (dataArg) => { async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg); await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const schedules = this.opsServerRef.oneboxRef.backupScheduler.getAllSchedules(); const schedules = this.opsServerRef.oneboxRef.backupScheduler.getAllSchedules();
return { schedules }; return { schedules };
}, },
@@ -27,7 +27,7 @@ export class SchedulesHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateBackupSchedule>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateBackupSchedule>(
'createBackupSchedule', 'createBackupSchedule',
async (dataArg) => { async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg); await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const schedule = await this.opsServerRef.oneboxRef.backupScheduler.createSchedule( const schedule = await this.opsServerRef.oneboxRef.backupScheduler.createSchedule(
dataArg.scheduleConfig, dataArg.scheduleConfig,
); );
@@ -40,7 +40,7 @@ export class SchedulesHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetBackupSchedule>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetBackupSchedule>(
'getBackupSchedule', 'getBackupSchedule',
async (dataArg) => { async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg); await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const schedule = this.opsServerRef.oneboxRef.backupScheduler.getScheduleById(dataArg.scheduleId); const schedule = this.opsServerRef.oneboxRef.backupScheduler.getScheduleById(dataArg.scheduleId);
if (!schedule) { if (!schedule) {
throw new plugins.typedrequest.TypedResponseError('Schedule not found'); throw new plugins.typedrequest.TypedResponseError('Schedule not found');
@@ -54,7 +54,7 @@ export class SchedulesHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateBackupSchedule>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateBackupSchedule>(
'updateBackupSchedule', 'updateBackupSchedule',
async (dataArg) => { async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg); await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const schedule = await this.opsServerRef.oneboxRef.backupScheduler.updateSchedule( const schedule = await this.opsServerRef.oneboxRef.backupScheduler.updateSchedule(
dataArg.scheduleId, dataArg.scheduleId,
dataArg.updates, dataArg.updates,
@@ -68,7 +68,7 @@ export class SchedulesHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteBackupSchedule>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteBackupSchedule>(
'deleteBackupSchedule', 'deleteBackupSchedule',
async (dataArg) => { async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg); await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
await this.opsServerRef.oneboxRef.backupScheduler.deleteSchedule(dataArg.scheduleId); await this.opsServerRef.oneboxRef.backupScheduler.deleteSchedule(dataArg.scheduleId);
return { ok: true }; return { ok: true };
}, },
@@ -79,7 +79,7 @@ export class SchedulesHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_TriggerBackupSchedule>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_TriggerBackupSchedule>(
'triggerBackupSchedule', 'triggerBackupSchedule',
async (dataArg) => { async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg); await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
await this.opsServerRef.oneboxRef.backupScheduler.triggerBackup(dataArg.scheduleId); await this.opsServerRef.oneboxRef.backupScheduler.triggerBackup(dataArg.scheduleId);
// triggerBackup is void; the backup is created async by the scheduler // triggerBackup is void; the backup is created async by the scheduler
// Return the most recent backup for the schedule // Return the most recent backup for the schedule
+16 -16
View File
@@ -2,7 +2,7 @@ import * as plugins from '../../plugins.ts';
import { logger } from '../../logging.ts'; import { logger } from '../../logging.ts';
import type { OpsServer } from '../classes.opsserver.ts'; import type { OpsServer } from '../classes.opsserver.ts';
import * as interfaces from '../../../ts_interfaces/index.ts'; import * as interfaces from '../../../ts_interfaces/index.ts';
import { requireValidIdentity } from '../helpers/guards.ts'; import { requireAdminIdentity } from '../helpers/guards.ts';
export class ServicesHandler { export class ServicesHandler {
public typedrouter = new plugins.typedrequest.TypedRouter(); public typedrouter = new plugins.typedrequest.TypedRouter();
@@ -18,7 +18,7 @@ export class ServicesHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetServices>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetServices>(
'getServices', 'getServices',
async (dataArg) => { async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg); await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const services = this.opsServerRef.oneboxRef.services.listServices(); const services = this.opsServerRef.oneboxRef.services.listServices();
return { services }; return { services };
}, },
@@ -30,7 +30,7 @@ export class ServicesHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetService>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetService>(
'getService', 'getService',
async (dataArg) => { async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg); await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const service = this.opsServerRef.oneboxRef.services.getService(dataArg.serviceName); const service = this.opsServerRef.oneboxRef.services.getService(dataArg.serviceName);
if (!service) { if (!service) {
throw new plugins.typedrequest.TypedResponseError('Service not found'); throw new plugins.typedrequest.TypedResponseError('Service not found');
@@ -45,7 +45,7 @@ export class ServicesHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateService>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateService>(
'createService', 'createService',
async (dataArg) => { async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg); await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const service = await this.opsServerRef.oneboxRef.services.deployService(dataArg.serviceConfig); const service = await this.opsServerRef.oneboxRef.services.deployService(dataArg.serviceConfig);
return { service }; return { service };
}, },
@@ -57,7 +57,7 @@ export class ServicesHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateService>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateService>(
'updateService', 'updateService',
async (dataArg) => { async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg); await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const service = await this.opsServerRef.oneboxRef.services.updateService( const service = await this.opsServerRef.oneboxRef.services.updateService(
dataArg.serviceName, dataArg.serviceName,
dataArg.updates, dataArg.updates,
@@ -72,7 +72,7 @@ export class ServicesHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteService>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteService>(
'deleteService', 'deleteService',
async (dataArg) => { async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg); await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
await this.opsServerRef.oneboxRef.services.removeService(dataArg.serviceName); await this.opsServerRef.oneboxRef.services.removeService(dataArg.serviceName);
return { ok: true }; return { ok: true };
}, },
@@ -84,7 +84,7 @@ export class ServicesHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_StartService>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_StartService>(
'startService', 'startService',
async (dataArg) => { async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg); await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
await this.opsServerRef.oneboxRef.services.startService(dataArg.serviceName); await this.opsServerRef.oneboxRef.services.startService(dataArg.serviceName);
const service = this.opsServerRef.oneboxRef.services.getService(dataArg.serviceName); const service = this.opsServerRef.oneboxRef.services.getService(dataArg.serviceName);
return { service: service! }; return { service: service! };
@@ -97,7 +97,7 @@ export class ServicesHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_StopService>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_StopService>(
'stopService', 'stopService',
async (dataArg) => { async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg); await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
await this.opsServerRef.oneboxRef.services.stopService(dataArg.serviceName); await this.opsServerRef.oneboxRef.services.stopService(dataArg.serviceName);
const service = this.opsServerRef.oneboxRef.services.getService(dataArg.serviceName); const service = this.opsServerRef.oneboxRef.services.getService(dataArg.serviceName);
return { service: service! }; return { service: service! };
@@ -110,7 +110,7 @@ export class ServicesHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RestartService>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RestartService>(
'restartService', 'restartService',
async (dataArg) => { async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg); await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
await this.opsServerRef.oneboxRef.services.restartService(dataArg.serviceName); await this.opsServerRef.oneboxRef.services.restartService(dataArg.serviceName);
const service = this.opsServerRef.oneboxRef.services.getService(dataArg.serviceName); const service = this.opsServerRef.oneboxRef.services.getService(dataArg.serviceName);
return { service: service! }; return { service: service! };
@@ -123,7 +123,7 @@ export class ServicesHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetServiceLogs>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetServiceLogs>(
'getServiceLogs', 'getServiceLogs',
async (dataArg) => { async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg); await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const logs = await this.opsServerRef.oneboxRef.services.getServiceLogs(dataArg.serviceName); const logs = await this.opsServerRef.oneboxRef.services.getServiceLogs(dataArg.serviceName);
return { logs: String(logs) }; return { logs: String(logs) };
}, },
@@ -135,7 +135,7 @@ export class ServicesHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetServiceStats>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetServiceStats>(
'getServiceStats', 'getServiceStats',
async (dataArg) => { async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg); await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const service = this.opsServerRef.oneboxRef.services.getService(dataArg.serviceName); const service = this.opsServerRef.oneboxRef.services.getService(dataArg.serviceName);
if (!service || !service.containerID) { if (!service || !service.containerID) {
throw new plugins.typedrequest.TypedResponseError('Service has no container'); throw new plugins.typedrequest.TypedResponseError('Service has no container');
@@ -154,7 +154,7 @@ export class ServicesHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetServiceMetrics>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetServiceMetrics>(
'getServiceMetrics', 'getServiceMetrics',
async (dataArg) => { async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg); await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const service = this.opsServerRef.oneboxRef.services.getService(dataArg.serviceName); const service = this.opsServerRef.oneboxRef.services.getService(dataArg.serviceName);
if (!service || !service.id) { if (!service || !service.id) {
throw new plugins.typedrequest.TypedResponseError('Service not found'); throw new plugins.typedrequest.TypedResponseError('Service not found');
@@ -170,7 +170,7 @@ export class ServicesHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetServicePlatformResources>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetServicePlatformResources>(
'getServicePlatformResources', 'getServicePlatformResources',
async (dataArg) => { async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg); await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const rawResources = await this.opsServerRef.oneboxRef.services.getServicePlatformResources( const rawResources = await this.opsServerRef.oneboxRef.services.getServicePlatformResources(
dataArg.serviceName, dataArg.serviceName,
); );
@@ -204,7 +204,7 @@ export class ServicesHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetServiceBackups>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetServiceBackups>(
'getServiceBackups', 'getServiceBackups',
async (dataArg) => { async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg); await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const backups = this.opsServerRef.oneboxRef.backupManager.listBackups(dataArg.serviceName); const backups = this.opsServerRef.oneboxRef.backupManager.listBackups(dataArg.serviceName);
return { backups }; return { backups };
}, },
@@ -216,7 +216,7 @@ export class ServicesHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateServiceBackup>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateServiceBackup>(
'createServiceBackup', 'createServiceBackup',
async (dataArg) => { async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg); await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const result = await this.opsServerRef.oneboxRef.backupManager.createBackup(dataArg.serviceName); const result = await this.opsServerRef.oneboxRef.backupManager.createBackup(dataArg.serviceName);
return { backup: result.backup }; return { backup: result.backup };
}, },
@@ -228,7 +228,7 @@ export class ServicesHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetServiceBackupSchedules>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetServiceBackupSchedules>(
'getServiceBackupSchedules', 'getServiceBackupSchedules',
async (dataArg) => { async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg); await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const service = this.opsServerRef.oneboxRef.services.getService(dataArg.serviceName); const service = this.opsServerRef.oneboxRef.services.getService(dataArg.serviceName);
if (!service) { if (!service) {
throw new plugins.typedrequest.TypedResponseError('Service not found'); throw new plugins.typedrequest.TypedResponseError('Service not found');
+102 -14
View File
@@ -1,7 +1,10 @@
import * as plugins from '../../plugins.ts'; import * as plugins from '../../plugins.ts';
import type { OpsServer } from '../classes.opsserver.ts'; import type { OpsServer } from '../classes.opsserver.ts';
import * as interfaces from '../../../ts_interfaces/index.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';
import { isValidHostname, normalizeHostname } from '../../utils/domain.ts';
export class SettingsHandler { export class SettingsHandler {
public typedrouter = new plugins.typedrequest.TypedRouter(); public typedrouter = new plugins.typedrequest.TypedRouter();
@@ -11,13 +14,29 @@ export class SettingsHandler {
this.registerHandlers(); this.registerHandlers();
} }
private getSettingsObject(): interfaces.data.ISettings { private async getSettingsObject(): Promise<interfaces.data.ISettings> {
const db = this.opsServerRef.oneboxRef.database; 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();
const managedDcRouter = this.opsServerRef.oneboxRef.managedDcRouter;
return { return {
cloudflareToken: settingsMap['cloudflareToken'] || '', cloudflareToken: cloudflareToken || '',
cloudflareZoneId: settingsMap['cloudflareZoneId'] || '', cloudflareZoneId: settingsMap['cloudflareZoneId'] || '',
adminUiDomain: settingsMap['adminUiDomain'] || '',
dcrouterMode: managedDcRouter.getMode(),
dcrouterManagedImage: managedDcRouter.getImage(),
dcrouterManagedOpsPort: managedDcRouter.getOpsPort(),
dcrouterManagedHttpPort: managedDcRouter.getHttpPort(),
dcrouterManagedHttpsPort: managedDcRouter.getHttpsPort(),
dcrouterManagedDataDir: managedDcRouter.getDataDir(),
dcrouterGatewayUrl: settingsMap['dcrouterGatewayUrl'] || '',
dcrouterGatewayApiToken: dcrouterGatewayApiToken || '',
dcrouterGatewayClientId: settingsMap['dcrouterGatewayClientId'] || settingsMap['dcrouterWorkHosterId'] || '',
dcrouterWorkHosterId: settingsMap['dcrouterWorkHosterId'] || settingsMap['dcrouterGatewayClientId'] || '',
dcrouterTargetHost: settingsMap['dcrouterTargetHost'] || '',
dcrouterTargetPort: parseInt(settingsMap['dcrouterTargetPort'] || '0', 10),
autoRenewCerts: settingsMap['autoRenewCerts'] === 'true', autoRenewCerts: settingsMap['autoRenewCerts'] === 'true',
renewalThreshold: parseInt(settingsMap['renewalThreshold'] || '30', 10), renewalThreshold: parseInt(settingsMap['renewalThreshold'] || '30', 10),
acmeEmail: settingsMap['acmeEmail'] || '', acmeEmail: settingsMap['acmeEmail'] || '',
@@ -32,8 +51,8 @@ export class SettingsHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSettings>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSettings>(
'getSettings', 'getSettings',
async (dataArg) => { async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg); await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const settings = this.getSettingsObject(); const settings = await this.getSettingsObject();
return { settings }; return { settings };
}, },
), ),
@@ -43,18 +62,30 @@ export class SettingsHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateSettings>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateSettings>(
'updateSettings', 'updateSettings',
async (dataArg) => { async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg); await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const db = this.opsServerRef.oneboxRef.database; const db = this.opsServerRef.oneboxRef.database;
const updates = dataArg.settings; const updates = dataArg.settings;
const normalizedUpdates = this.normalizeUpdates(updates);
// Store each setting as key-value pair // Store each setting as key-value pair
for (const [key, value] of Object.entries(updates)) { for (const [key, value] of Object.entries(normalizedUpdates)) {
if (value !== undefined) { if (value !== undefined) {
if (db.isSecretSettingKey(key)) {
await db.setSecretSetting(key, String(value));
} else {
db.setSetting(key, String(value)); db.setSetting(key, String(value));
} }
} }
}
const settings = this.getSettingsObject(); if (this.hasRouteSyncSetting(normalizedUpdates)) {
this.refreshGatewayRoutes(normalizedUpdates).catch((error) => {
logger.warn(`dcrouter gateway settings refresh failed: ${getErrorMessage(error)}`);
});
}
const settings = await this.getSettingsObject();
return { settings }; return { settings };
}, },
), ),
@@ -64,8 +95,8 @@ export class SettingsHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SetBackupPassword>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SetBackupPassword>(
'setBackupPassword', 'setBackupPassword',
async (dataArg) => { async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg); await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
this.opsServerRef.oneboxRef.database.setSetting('backupPassword', dataArg.password); await this.opsServerRef.oneboxRef.database.setSecretSetting('backupPassword', dataArg.password);
return { ok: true }; return { ok: true };
}, },
), ),
@@ -75,12 +106,69 @@ export class SettingsHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetBackupPasswordStatus>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetBackupPasswordStatus>(
'getBackupPasswordStatus', 'getBackupPasswordStatus',
async (dataArg) => { async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg); await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const backupPassword = this.opsServerRef.oneboxRef.database.getSetting('backupPassword'); const isConfigured = await this.opsServerRef.oneboxRef.database.hasSecretSetting('backupPassword');
const isConfigured = !!backupPassword;
return { status: { isConfigured } }; return { status: { isConfigured } };
}, },
), ),
); );
} }
private normalizeUpdates(
settings: Partial<interfaces.data.ISettings>,
): Partial<interfaces.data.ISettings> {
const normalizedUpdates = { ...settings };
if (Object.prototype.hasOwnProperty.call(normalizedUpdates, 'adminUiDomain')) {
const normalizedDomain = normalizeHostname(String(normalizedUpdates.adminUiDomain || ''));
if (!isValidHostname(normalizedDomain)) {
throw new plugins.typedrequest.TypedResponseError('Invalid Admin UI domain');
}
normalizedUpdates.adminUiDomain = normalizedDomain;
}
return normalizedUpdates;
}
private hasRouteSyncSetting(settings: Partial<interfaces.data.ISettings>): boolean {
return [
'adminUiDomain',
'dcrouterMode',
'dcrouterManagedImage',
'dcrouterManagedOpsPort',
'dcrouterManagedHttpPort',
'dcrouterManagedHttpsPort',
'dcrouterManagedDataDir',
'dcrouterGatewayUrl',
'dcrouterGatewayApiToken',
'dcrouterGatewayClientId',
'dcrouterWorkHosterId',
'dcrouterTargetHost',
'dcrouterTargetPort',
].some((key) => Object.prototype.hasOwnProperty.call(settings, key));
}
private hasManagedDcRouterRuntimeSetting(settings: Partial<interfaces.data.ISettings>): boolean {
return [
'dcrouterMode',
'dcrouterManagedImage',
'dcrouterManagedOpsPort',
'dcrouterManagedHttpPort',
'dcrouterManagedHttpsPort',
'dcrouterManagedDataDir',
].some((key) => Object.prototype.hasOwnProperty.call(settings, key));
}
private async refreshGatewayRoutes(settings: Partial<interfaces.data.ISettings>): Promise<void> {
const onebox = this.opsServerRef.oneboxRef;
if (this.hasManagedDcRouterRuntimeSetting(settings)) {
if (onebox.managedDcRouter.getMode() === 'managed') {
await onebox.managedDcRouter.restart();
} else {
await onebox.managedDcRouter.stop();
}
}
await onebox.reverseProxy.reloadRoutes();
await onebox.externalGateway.syncDomains();
await onebox.externalGateway.syncServiceRoutes();
}
} }
+5 -5
View File
@@ -1,7 +1,7 @@
import * as plugins from '../../plugins.ts'; import * as plugins from '../../plugins.ts';
import type { OpsServer } from '../classes.opsserver.ts'; import type { OpsServer } from '../classes.opsserver.ts';
import * as interfaces from '../../../ts_interfaces/index.ts'; import * as interfaces from '../../../ts_interfaces/index.ts';
import { requireValidIdentity } from '../helpers/guards.ts'; import { requireAdminIdentity } from '../helpers/guards.ts';
export class SslHandler { export class SslHandler {
public typedrouter = new plugins.typedrequest.TypedRouter(); public typedrouter = new plugins.typedrequest.TypedRouter();
@@ -16,7 +16,7 @@ export class SslHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ObtainCertificate>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ObtainCertificate>(
'obtainCertificate', 'obtainCertificate',
async (dataArg) => { async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg); await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
await this.opsServerRef.oneboxRef.ssl.obtainCertificate(dataArg.domain, false); await this.opsServerRef.oneboxRef.ssl.obtainCertificate(dataArg.domain, false);
const certificate = this.opsServerRef.oneboxRef.ssl.getCertificate(dataArg.domain); const certificate = this.opsServerRef.oneboxRef.ssl.getCertificate(dataArg.domain);
return { certificate: certificate as unknown as interfaces.data.ICertificate }; return { certificate: certificate as unknown as interfaces.data.ICertificate };
@@ -28,7 +28,7 @@ export class SslHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ListCertificates>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ListCertificates>(
'listCertificates', 'listCertificates',
async (dataArg) => { async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg); await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const certificates = this.opsServerRef.oneboxRef.ssl.listCertificates(); const certificates = this.opsServerRef.oneboxRef.ssl.listCertificates();
return { certificates: certificates as unknown as interfaces.data.ICertificate[] }; return { certificates: certificates as unknown as interfaces.data.ICertificate[] };
}, },
@@ -39,7 +39,7 @@ export class SslHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetCertificate>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetCertificate>(
'getCertificate', 'getCertificate',
async (dataArg) => { async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg); await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const certificate = this.opsServerRef.oneboxRef.ssl.getCertificate(dataArg.domain); const certificate = this.opsServerRef.oneboxRef.ssl.getCertificate(dataArg.domain);
if (!certificate) { if (!certificate) {
throw new plugins.typedrequest.TypedResponseError('Certificate not found'); throw new plugins.typedrequest.TypedResponseError('Certificate not found');
@@ -53,7 +53,7 @@ export class SslHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RenewCertificate>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RenewCertificate>(
'renewCertificate', 'renewCertificate',
async (dataArg) => { async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg); await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
await this.opsServerRef.oneboxRef.ssl.renewCertificate(dataArg.domain); await this.opsServerRef.oneboxRef.ssl.renewCertificate(dataArg.domain);
const certificate = this.opsServerRef.oneboxRef.ssl.getCertificate(dataArg.domain); const certificate = this.opsServerRef.oneboxRef.ssl.getCertificate(dataArg.domain);
return { certificate: certificate as unknown as interfaces.data.ICertificate }; return { certificate: certificate as unknown as interfaces.data.ICertificate };
+18 -2
View File
@@ -1,7 +1,8 @@
import * as plugins from '../../plugins.ts'; import * as plugins from '../../plugins.ts';
import type { OpsServer } from '../classes.opsserver.ts'; import type { OpsServer } from '../classes.opsserver.ts';
import * as interfaces from '../../../ts_interfaces/index.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 StatusHandler { export class StatusHandler {
public typedrouter = new plugins.typedrequest.TypedRouter(); public typedrouter = new plugins.typedrequest.TypedRouter();
@@ -16,11 +17,26 @@ export class StatusHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSystemStatus>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSystemStatus>(
'getSystemStatus', 'getSystemStatus',
async (dataArg) => { async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg); await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const status = await this.opsServerRef.oneboxRef.getSystemStatus(); const status = await this.opsServerRef.oneboxRef.getSystemStatus();
return { status: status as unknown as interfaces.data.ISystemStatus }; return { status: status as unknown as interfaces.data.ISystemStatus };
}, },
), ),
); );
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_StartOneboxUpgrade>(
'startOneboxUpgrade',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
try {
const upgrade = await this.opsServerRef.oneboxRef.updateManager.startDetachedUpgrade();
return { upgrade };
} catch (error) {
throw new plugins.typedrequest.TypedResponseError(getErrorMessage(error));
}
},
),
);
} }
} }
+8 -8
View File
@@ -2,7 +2,7 @@ import * as plugins from '../../plugins.ts';
import { logger } from '../../logging.ts'; import { logger } from '../../logging.ts';
import type { OpsServer } from '../classes.opsserver.ts'; import type { OpsServer } from '../classes.opsserver.ts';
import * as interfaces from '../../../ts_interfaces/index.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'; import { getErrorMessage } from '../../utils/error.ts';
export class WorkspaceHandler { export class WorkspaceHandler {
@@ -30,7 +30,7 @@ export class WorkspaceHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_WorkspaceReadFile>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_WorkspaceReadFile>(
'workspaceReadFile', 'workspaceReadFile',
async (dataArg) => { async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg); await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const containerId = await this.resolveContainerId(dataArg.serviceName); const containerId = await this.resolveContainerId(dataArg.serviceName);
const result = await this.opsServerRef.oneboxRef.docker.execInContainer( const result = await this.opsServerRef.oneboxRef.docker.execInContainer(
containerId, containerId,
@@ -49,7 +49,7 @@ export class WorkspaceHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_WorkspaceWriteFile>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_WorkspaceWriteFile>(
'workspaceWriteFile', 'workspaceWriteFile',
async (dataArg) => { async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg); await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const containerId = await this.resolveContainerId(dataArg.serviceName); const containerId = await this.resolveContainerId(dataArg.serviceName);
// Use sh -c with printf to write content (handles special characters) // Use sh -c with printf to write content (handles special characters)
const escaped = dataArg.content.replace(/'/g, "'\\''"); const escaped = dataArg.content.replace(/'/g, "'\\''");
@@ -70,7 +70,7 @@ export class WorkspaceHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_WorkspaceReadDir>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_WorkspaceReadDir>(
'workspaceReadDir', 'workspaceReadDir',
async (dataArg) => { async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg); await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const containerId = await this.resolveContainerId(dataArg.serviceName); const containerId = await this.resolveContainerId(dataArg.serviceName);
// Use ls with -1 -F to get entries with type indicators (/ for dirs) // Use ls with -1 -F to get entries with type indicators (/ for dirs)
const result = await this.opsServerRef.oneboxRef.docker.execInContainer( const result = await this.opsServerRef.oneboxRef.docker.execInContainer(
@@ -103,7 +103,7 @@ export class WorkspaceHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_WorkspaceMkdir>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_WorkspaceMkdir>(
'workspaceMkdir', 'workspaceMkdir',
async (dataArg) => { async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg); await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const containerId = await this.resolveContainerId(dataArg.serviceName); const containerId = await this.resolveContainerId(dataArg.serviceName);
const result = await this.opsServerRef.oneboxRef.docker.execInContainer( const result = await this.opsServerRef.oneboxRef.docker.execInContainer(
containerId, containerId,
@@ -122,7 +122,7 @@ export class WorkspaceHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_WorkspaceRm>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_WorkspaceRm>(
'workspaceRm', 'workspaceRm',
async (dataArg) => { async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg); await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const containerId = await this.resolveContainerId(dataArg.serviceName); const containerId = await this.resolveContainerId(dataArg.serviceName);
const args = dataArg.recursive ? ['rm', '-rf', dataArg.path] : ['rm', '-f', dataArg.path]; const args = dataArg.recursive ? ['rm', '-rf', dataArg.path] : ['rm', '-f', dataArg.path];
const result = await this.opsServerRef.oneboxRef.docker.execInContainer( const result = await this.opsServerRef.oneboxRef.docker.execInContainer(
@@ -142,7 +142,7 @@ export class WorkspaceHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_WorkspaceExists>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_WorkspaceExists>(
'workspaceExists', 'workspaceExists',
async (dataArg) => { async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg); await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const containerId = await this.resolveContainerId(dataArg.serviceName); const containerId = await this.resolveContainerId(dataArg.serviceName);
const result = await this.opsServerRef.oneboxRef.docker.execInContainer( const result = await this.opsServerRef.oneboxRef.docker.execInContainer(
containerId, containerId,
@@ -158,7 +158,7 @@ export class WorkspaceHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_WorkspaceExec>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_WorkspaceExec>(
'workspaceExec', 'workspaceExec',
async (dataArg) => { async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg); await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const containerId = await this.resolveContainerId(dataArg.serviceName); const containerId = await this.resolveContainerId(dataArg.serviceName);
const cmd = dataArg.args const cmd = dataArg.args
? [dataArg.command, ...dataArg.args] ? [dataArg.command, ...dataArg.args]
+4 -16
View File
@@ -5,25 +5,13 @@ import * as interfaces from '../../../ts_interfaces/index.ts';
export async function requireValidIdentity<T extends { identity?: interfaces.data.IIdentity }>( export async function requireValidIdentity<T extends { identity?: interfaces.data.IIdentity }>(
adminHandler: AdminHandler, adminHandler: AdminHandler,
dataArg: T, dataArg: T,
): Promise<void> { ): Promise<interfaces.data.IIdentity> {
if (!dataArg.identity) { return await adminHandler.getVerifiedIdentity(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');
}
} }
export async function requireAdminIdentity<T extends { identity?: interfaces.data.IIdentity }>( export async function requireAdminIdentity<T extends { identity?: interfaces.data.IIdentity }>(
adminHandler: AdminHandler, adminHandler: AdminHandler,
dataArg: T, dataArg: T,
): Promise<void> { ): Promise<interfaces.data.IIdentity> {
if (!dataArg.identity) { return await adminHandler.getVerifiedAdminIdentity(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');
}
} }
+19 -4
View File
@@ -37,14 +37,24 @@ export { smartregistry };
import * as smartstorage from '@push.rocks/smartstorage'; import * as smartstorage from '@push.rocks/smartstorage';
export { 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 // Task scheduling and cron jobs
import * as taskbuffer from '@push.rocks/taskbuffer'; import * as taskbuffer from '@push.rocks/taskbuffer';
export { 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 // JWT for authentication
import * as jwt from 'https://deno.land/x/djwt@v3.0.2/mod.ts'; import * as jwt from 'https://deno.land/x/djwt@v3.0.2/mod.ts';
export { jwt}; export { jwt};
@@ -72,6 +82,11 @@ export { smartguard, smartjwt };
import { ContainerArchive } from '@serve.zone/containerarchive'; import { ContainerArchive } from '@serve.zone/containerarchive';
export { ContainerArchive }; export { ContainerArchive };
// serve.zone App Store contracts and resolver
import * as servezoneInterfaces from '@serve.zone/interfaces';
import * as servezoneAppstore from '@serve.zone/appstore';
export { servezoneInterfaces, servezoneAppstore };
// Node.js compat for streaming // Node.js compat for streaming
import * as nodeFs from 'node:fs'; import * as nodeFs from 'node:fs';
import * as nodeStream from 'node:stream'; import * as nodeStream from 'node:stream';
+50 -6
View File
@@ -9,6 +9,8 @@ export interface IService {
image: string; image: string;
registry?: string; registry?: string;
envVars: Record<string, string>; envVars: Record<string, string>;
volumes?: IServiceVolume[];
publishedPorts?: IServicePublishedPort[];
port: number; port: number;
domain?: string; domain?: string;
containerID?: string; containerID?: string;
@@ -30,6 +32,27 @@ export interface IService {
appTemplateVersion?: string; appTemplateVersion?: string;
} }
export interface IServiceVolume {
name?: string;
source?: string;
mountPath: string;
driver?: string;
readOnly?: boolean;
backup?: boolean;
options?: Record<string, string>;
}
export type TServicePortProtocol = 'tcp' | 'udp';
export interface IServicePublishedPort {
targetPort: number;
targetPortEnd?: number;
publishedPort?: number;
publishedPortEnd?: number;
protocol?: TServicePortProtocol;
hostIp?: string;
}
// Registry types // Registry types
export interface IRegistry { export interface IRegistry {
id?: number; id?: number;
@@ -78,7 +101,7 @@ export interface ITokenCreatedResponse {
} }
// Platform service types // 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 TPlatformResourceType = 'database' | 'bucket' | 'cache' | 'queue';
export type TPlatformServiceStatus = 'stopped' | 'starting' | 'running' | 'stopping' | 'failed'; export type TPlatformServiceStatus = 'stopped' | 'starting' | 'running' | 'stopping' | 'failed';
@@ -148,7 +171,7 @@ export interface INginxConfig {
export interface IDomain { export interface IDomain {
id?: number; id?: number;
domain: string; domain: string;
dnsProvider: 'cloudflare' | 'manual' | null; dnsProvider: 'cloudflare' | 'manual' | 'dcrouter' | null;
cloudflareZoneId?: string; cloudflareZoneId?: string;
isObsolete: boolean; isObsolete: boolean;
defaultWildcard: boolean; defaultWildcard: boolean;
@@ -257,14 +280,30 @@ export interface ISetting {
// Application settings // Application settings
export interface IAppSettings { export interface IAppSettings {
serverIP?: string; serverIP?: string;
cloudflareAPIKey?: string; adminUiDomain?: string;
cloudflareEmail?: string; cloudflareToken?: string;
cloudflareZoneID?: string; cloudflareZoneId?: string;
dcrouterMode?: 'managed' | 'external' | 'disabled';
dcrouterManagedImage?: string;
dcrouterManagedOpsPort?: number;
dcrouterManagedHttpPort?: number;
dcrouterManagedHttpsPort?: number;
dcrouterManagedDataDir?: string;
dcrouterGatewayUrl?: string;
dcrouterGatewayApiToken?: string;
dcrouterGatewayClientId?: string;
/** @deprecated Use dcrouterGatewayClientId. */
dcrouterWorkHosterId?: string;
dcrouterTargetHost?: string;
dcrouterTargetPort?: number;
acmeEmail?: string; acmeEmail?: string;
nginxConfigDir?: string;
dataDir?: string; dataDir?: string;
httpPort?: number; httpPort?: number;
httpsPort?: number;
metricsInterval?: number; metricsInterval?: number;
autoRenewCerts?: boolean;
renewalThreshold?: number;
forceHttps?: boolean;
logRetentionDays?: number; logRetentionDays?: number;
} }
@@ -284,6 +323,8 @@ export interface IServiceDeployOptions {
image: string; image: string;
registry?: string; registry?: string;
envVars?: Record<string, string>; envVars?: Record<string, string>;
volumes?: IServiceVolume[];
publishedPorts?: IServicePublishedPort[];
port: number; port: number;
domain?: string; domain?: string;
autoSSL?: boolean; autoSSL?: boolean;
@@ -292,6 +333,7 @@ export interface IServiceDeployOptions {
useOneboxRegistry?: boolean; useOneboxRegistry?: boolean;
registryImageTag?: string; registryImageTag?: string;
autoUpdateOnPush?: boolean; autoUpdateOnPush?: boolean;
imageDigest?: string;
// Platform service requirements // Platform service requirements
enableMongoDB?: boolean; enableMongoDB?: boolean;
enableS3?: boolean; enableS3?: boolean;
@@ -382,6 +424,8 @@ export interface IBackupServiceConfig {
image: string; image: string;
registry?: string; registry?: string;
envVars: Record<string, string>; envVars: Record<string, string>;
volumes?: IServiceVolume[];
publishedPorts?: IServicePublishedPort[];
port: number; port: number;
domain?: string; domain?: string;
useOneboxRegistry?: boolean; useOneboxRegistry?: boolean;
+94
View File
@@ -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;
}
+17
View File
@@ -0,0 +1,17 @@
export function normalizeHostname(valueArg: string): string {
const trimmedValue = valueArg.trim().toLowerCase();
if (!trimmedValue) return '';
const withoutProtocol = trimmedValue.replace(/^[a-z][a-z0-9+.-]*:\/\//, '');
const withoutPath = withoutProtocol.split('/')[0].split('?')[0].split('#')[0];
return withoutPath.replace(/:\d+$/, '').replace(/\.$/, '');
}
export function isValidHostname(hostnameArg: string): boolean {
if (!hostnameArg) return true;
if (hostnameArg.length > 253) return false;
return hostnameArg.split('.').every((label) => {
if (!label || label.length > 63) return false;
return /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(label);
});
}
+1 -1
View File
File diff suppressed because one or more lines are too long
+38 -1
View File
@@ -5,7 +5,7 @@
export interface IDomain { export interface IDomain {
id?: number; id?: number;
domain: string; domain: string;
dnsProvider: 'cloudflare' | 'manual' | null; dnsProvider: 'cloudflare' | 'manual' | 'dcrouter' | null;
cloudflareZoneId?: string; cloudflareZoneId?: string;
isObsolete: boolean; isObsolete: boolean;
defaultWildcard: boolean; defaultWildcard: boolean;
@@ -57,3 +57,40 @@ export interface IDnsRecord {
createdAt: number; createdAt: number;
updatedAt: number; updatedAt: number;
} }
export interface IGatewayDomain {
id?: string;
name: string;
source?: 'dcrouter' | 'provider';
authoritative?: boolean;
providerId?: string;
serviceCount?: number;
managePath?: string;
manageUrl?: string;
capabilities?: {
canCreateSubdomains: boolean;
canManageDnsRecords: boolean;
canIssueCertificates: boolean;
canHostEmail: boolean;
};
}
export interface IGatewayDnsRecord {
id: string;
domainId: string;
domainName?: string;
name: string;
type: string;
value: string;
ttl: number;
source: string;
status: 'active' | 'missing';
gatewayClientType: 'onebox' | 'cloudly' | 'custom';
gatewayClientId: string;
appId: string;
hostname: string;
routeId?: string;
serviceName?: string;
managePath?: string;
manageUrl?: string;
}
+2 -2
View File
@@ -41,7 +41,7 @@ export interface ITrafficStats {
errorRate: number; errorRate: number;
} }
export interface ICaddyAccessLog { export interface IProxyAccessLog {
ts: number; ts: number;
request: { request: {
remote_ip: string; remote_ip: string;
@@ -59,6 +59,6 @@ export interface INetworkLogMessage {
type: 'connected' | 'access_log' | 'filter_updated'; type: 'connected' | 'access_log' | 'filter_updated';
clientId?: string; clientId?: string;
filter?: { domain?: string; sampleRate?: number }; filter?: { domain?: string; sampleRate?: number };
data?: ICaddyAccessLog; data?: IProxyAccessLog;
timestamp: number; timestamp: number;
} }
+1 -1
View File
@@ -2,7 +2,7 @@
* Platform service data shapes for Onebox * 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 TPlatformServiceStatus = 'not-deployed' | 'stopped' | 'starting' | 'running' | 'stopping' | 'failed';
export type TPlatformResourceType = 'database' | 'bucket' | 'cache' | 'queue'; export type TPlatformResourceType = 'database' | 'bucket' | 'cache' | 'queue';
+27
View File
@@ -12,6 +12,8 @@ export interface IService {
image: string; image: string;
registry?: string; registry?: string;
envVars: Record<string, string>; envVars: Record<string, string>;
volumes?: IServiceVolume[];
publishedPorts?: IServicePublishedPort[];
port: number; port: number;
domain?: string; domain?: string;
containerID?: string; containerID?: string;
@@ -33,12 +35,35 @@ export interface IService {
appTemplateVersion?: string; appTemplateVersion?: string;
} }
export interface IServiceVolume {
name?: string;
source?: string;
mountPath: string;
driver?: string;
readOnly?: boolean;
backup?: boolean;
options?: Record<string, string>;
}
export type TServicePortProtocol = 'tcp' | 'udp';
export interface IServicePublishedPort {
targetPort: number;
targetPortEnd?: number;
publishedPort?: number;
publishedPortEnd?: number;
protocol?: TServicePortProtocol;
hostIp?: string;
}
export interface IServiceCreate { export interface IServiceCreate {
name: string; name: string;
image: string; image: string;
port: number; port: number;
domain?: string; domain?: string;
envVars?: Record<string, string>; envVars?: Record<string, string>;
volumes?: IServiceVolume[];
publishedPorts?: IServicePublishedPort[];
useOneboxRegistry?: boolean; useOneboxRegistry?: boolean;
registryImageTag?: string; registryImageTag?: string;
autoUpdateOnPush?: boolean; autoUpdateOnPush?: boolean;
@@ -57,6 +82,8 @@ export interface IServiceUpdate {
port?: number; port?: number;
domain?: string; domain?: string;
envVars?: Record<string, string>; envVars?: Record<string, string>;
volumes?: IServiceVolume[];
publishedPorts?: IServicePublishedPort[];
} }
export interface IContainerStats { export interface IContainerStats {
+30
View File
@@ -2,9 +2,39 @@
* Settings data shapes for Onebox * Settings data shapes for Onebox
*/ */
export type TDcRouterMode = 'managed' | 'external' | 'disabled';
export interface IManagedDcRouterStatus {
mode: TDcRouterMode;
configured: boolean;
running: boolean;
healthy: boolean;
containerId?: string;
image: string;
gatewayUrl: string;
opsPort: number;
httpPort: number;
httpsPort: number;
message?: string;
}
export interface ISettings { export interface ISettings {
cloudflareToken: string; cloudflareToken: string;
cloudflareZoneId: string; cloudflareZoneId: string;
adminUiDomain: string;
dcrouterMode: TDcRouterMode;
dcrouterManagedImage: string;
dcrouterManagedOpsPort: number;
dcrouterManagedHttpPort: number;
dcrouterManagedHttpsPort: number;
dcrouterManagedDataDir: string;
dcrouterGatewayUrl: string;
dcrouterGatewayApiToken: string;
dcrouterGatewayClientId: string;
/** @deprecated Use dcrouterGatewayClientId. */
dcrouterWorkHosterId: string;
dcrouterTargetHost: string;
dcrouterTargetPort: number;
autoRenewCerts: boolean; autoRenewCerts: boolean;
renewalThreshold: number; renewalThreshold: number;
acmeEmail: string; acmeEmail: string;
+23
View File
@@ -4,7 +4,30 @@
import type { TPlatformServiceType, TPlatformServiceStatus } from './platform.ts'; import type { TPlatformServiceType, TPlatformServiceStatus } from './platform.ts';
export interface IOneboxUpdateStatus {
currentVersion: string;
latestVersion: string | null;
updateAvailable: boolean;
checkedAt: number;
releaseUrl: string;
changelogUrl: string;
error?: string;
}
export interface IOneboxUpgradeStartResult {
accepted: boolean;
currentVersion: string;
targetVersion: string;
message: string;
pid?: number;
logPath?: string;
}
export interface ISystemStatus { export interface ISystemStatus {
onebox: {
version: string;
update: IOneboxUpdateStatus;
};
docker: { docker: {
running: boolean; running: boolean;
version: unknown; version: unknown;
+38 -60
View File
@@ -1,99 +1,77 @@
import type * as servezoneInterfaces from '@serve.zone/interfaces';
import * as plugins from '../plugins.ts'; import * as plugins from '../plugins.ts';
import * as data from '../data/index.ts'; import * as data from '../data/index.ts';
export interface ICatalogApp { export type IAppStoreApp = servezoneInterfaces.appstore.IAppStoreApp;
id: string; export type IAppStoreVersionConfig = servezoneInterfaces.appstore.IAppStoreVersionConfig;
name: string; export type IAppStoreAppMeta = servezoneInterfaces.appstore.IAppStoreAppMeta;
description: string; export type IUpgradeableAppStoreService = servezoneInterfaces.appstore.IUpgradeableAppStoreService;
category: string;
iconName?: string; export interface IAppStoreInstallOptions extends servezoneInterfaces.appstore.IAppStoreInstallRequest {
iconUrl?: string; autoDNS?: boolean;
latestVersion: string;
tags?: string[];
} }
export interface IAppVersionConfig { export interface IReq_GetAppStoreTemplates extends plugins.typedrequestInterfaces.implementsTR<
image: string;
port: number;
envVars?: Array<{ key: string; value: string; description: string; required?: boolean }>;
volumes?: string[];
platformRequirements?: {
mongodb?: boolean;
s3?: boolean;
clickhouse?: boolean;
redis?: boolean;
mariadb?: boolean;
};
minOneboxVersion?: string;
}
export interface IAppMeta {
id: string;
name: string;
description: string;
category: string;
iconName?: string;
latestVersion: string;
versions: string[];
maintainer?: string;
links?: Record<string, string>;
}
export interface IUpgradeableService {
serviceName: string;
appTemplateId: string;
currentVersion: string;
latestVersion: string;
hasMigration: boolean;
}
export interface IReq_GetAppTemplates extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest, plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetAppTemplates IReq_GetAppStoreTemplates
> { > {
method: 'getAppTemplates'; method: 'getAppStoreTemplates';
request: { request: {
identity: data.IIdentity; identity: data.IIdentity;
}; };
response: { response: {
apps: ICatalogApp[]; apps: IAppStoreApp[];
}; };
} }
export interface IReq_GetAppConfig extends plugins.typedrequestInterfaces.implementsTR< export interface IReq_GetAppStoreConfig extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest, plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetAppConfig IReq_GetAppStoreConfig
> { > {
method: 'getAppConfig'; method: 'getAppStoreConfig';
request: { request: {
identity: data.IIdentity; identity: data.IIdentity;
appId: string; appId: string;
version: string; version: string;
}; };
response: { response: {
config: IAppVersionConfig; config: IAppStoreVersionConfig;
appMeta: IAppMeta; appMeta: IAppStoreAppMeta;
}; };
} }
export interface IReq_GetUpgradeableServices extends plugins.typedrequestInterfaces.implementsTR< export interface IReq_InstallAppStoreApp extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest, plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetUpgradeableServices IReq_InstallAppStoreApp
> { > {
method: 'getUpgradeableServices'; method: 'installAppStoreApp';
request: {
identity: data.IIdentity;
install: IAppStoreInstallOptions;
};
response: {
service: data.IService;
};
}
export interface IReq_GetUpgradeableAppStoreServices extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetUpgradeableAppStoreServices
> {
method: 'getUpgradeableAppStoreServices';
request: { request: {
identity: data.IIdentity; identity: data.IIdentity;
}; };
response: { response: {
services: IUpgradeableService[]; services: IUpgradeableAppStoreService[];
}; };
} }
export interface IReq_UpgradeService extends plugins.typedrequestInterfaces.implementsTR< export interface IReq_UpgradeAppStoreService extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest, plugins.typedrequestInterfaces.ITypedRequest,
IReq_UpgradeService IReq_UpgradeAppStoreService
> { > {
method: 'upgradeService'; method: 'upgradeAppStoreService';
request: { request: {
identity: data.IIdentity; identity: data.IIdentity;
serviceName: string; serviceName: string;
+13
View File
@@ -56,3 +56,16 @@ export interface IReq_SyncDns extends plugins.typedrequestInterfaces.implementsT
records: data.IDnsRecord[]; records: data.IDnsRecord[];
}; };
} }
export interface IReq_GetGatewayDnsRecords extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetGatewayDnsRecords
> {
method: 'getGatewayDnsRecords';
request: {
identity: data.IIdentity;
};
response: {
records: data.IGatewayDnsRecord[];
};
}
+13
View File
@@ -40,3 +40,16 @@ export interface IReq_SyncDomains extends plugins.typedrequestInterfaces.impleme
domains: data.IDomainDetail[]; domains: data.IDomainDetail[];
}; };
} }
export interface IReq_GetGatewayDomains extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetGatewayDomains
> {
method: 'getGatewayDomains';
request: {
identity: data.IIdentity;
};
response: {
domains: data.IGatewayDomain[];
};
}
+13
View File
@@ -228,3 +228,16 @@ export interface IReq_PushServiceLog extends plugins.typedrequestInterfaces.impl
}; };
response: {}; 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: {};
}
+52
View File
@@ -54,3 +54,55 @@ export interface IReq_GetBackupPasswordStatus extends plugins.typedrequestInterf
status: data.IBackupPasswordStatus; status: data.IBackupPasswordStatus;
}; };
} }
export interface IReq_GetManagedDcRouterStatus extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetManagedDcRouterStatus
> {
method: 'getManagedDcRouterStatus';
request: {
identity: data.IIdentity;
};
response: {
status: data.IManagedDcRouterStatus;
};
}
export interface IReq_StartManagedDcRouter extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_StartManagedDcRouter
> {
method: 'startManagedDcRouter';
request: {
identity: data.IIdentity;
};
response: {
status: data.IManagedDcRouterStatus;
};
}
export interface IReq_StopManagedDcRouter extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_StopManagedDcRouter
> {
method: 'stopManagedDcRouter';
request: {
identity: data.IIdentity;
};
response: {
status: data.IManagedDcRouterStatus;
};
}
export interface IReq_RestartManagedDcRouter extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_RestartManagedDcRouter
> {
method: 'restartManagedDcRouter';
request: {
identity: data.IIdentity;
};
response: {
status: data.IManagedDcRouterStatus;
};
}
+13
View File
@@ -13,3 +13,16 @@ export interface IReq_GetSystemStatus extends plugins.typedrequestInterfaces.imp
status: data.ISystemStatus; status: data.ISystemStatus;
}; };
} }
export interface IReq_StartOneboxUpgrade extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_StartOneboxUpgrade
> {
method: 'startOneboxUpgrade';
request: {
identity: data.IIdentity;
};
response: {
upgrade: data.IOneboxUpgradeStartResult;
};
}
+1 -1
View File
@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@serve.zone/onebox', name: '@serve.zone/onebox',
version: '1.24.1', version: '2.0.0',
description: 'Self-hosted container platform with automatic SSL and DNS - a mini Heroku for single servers' description: 'Self-hosted container platform with automatic SSL and DNS - a mini Heroku for single servers'
} }
+169 -17
View File
@@ -36,6 +36,8 @@ export interface INetworkState {
trafficStats: interfaces.data.ITrafficStats | null; trafficStats: interfaces.data.ITrafficStats | null;
dnsRecords: interfaces.data.IDnsRecord[]; dnsRecords: interfaces.data.IDnsRecord[];
domains: interfaces.data.IDomainDetail[]; domains: interfaces.data.IDomainDetail[];
gatewayDomains: interfaces.data.IGatewayDomain[];
gatewayDnsRecords: interfaces.data.IGatewayDnsRecord[];
certificates: interfaces.data.ICertificate[]; certificates: interfaces.data.ICertificate[];
} }
@@ -52,15 +54,17 @@ export interface IBackupsState {
export interface ISettingsState { export interface ISettingsState {
settings: interfaces.data.ISettings | null; settings: interfaces.data.ISettings | null;
backupPasswordConfigured: boolean; backupPasswordConfigured: boolean;
managedDcRouterStatus: interfaces.data.IManagedDcRouterStatus | null;
} }
export interface IAppStoreState { export interface IAppStoreState {
apps: interfaces.requests.ICatalogApp[]; apps: interfaces.requests.IAppStoreApp[];
upgradeableServices: interfaces.requests.IUpgradeableService[]; upgradeableServices: interfaces.requests.IUpgradeableAppStoreService[];
} }
export interface IUiState { export interface IUiState {
activeView: string; activeView: string;
activeSubview: string | null;
autoRefresh: boolean; autoRefresh: boolean;
refreshInterval: number; refreshInterval: number;
pendingAppTemplate?: any; pendingAppTemplate?: any;
@@ -110,6 +114,8 @@ export const networkStatePart = await appState.getStatePart<INetworkState>(
trafficStats: null, trafficStats: null,
dnsRecords: [], dnsRecords: [],
domains: [], domains: [],
gatewayDomains: [],
gatewayDnsRecords: [],
certificates: [], certificates: [],
}, },
'soft', 'soft',
@@ -138,6 +144,7 @@ export const settingsStatePart = await appState.getStatePart<ISettingsState>(
{ {
settings: null, settings: null,
backupPasswordConfigured: false, backupPasswordConfigured: false,
managedDcRouterStatus: null,
}, },
'soft', 'soft',
); );
@@ -155,6 +162,7 @@ export const uiStatePart = await appState.getStatePart<IUiState>(
'ui', 'ui',
{ {
activeView: 'dashboard', activeView: 'dashboard',
activeSubview: null,
autoRefresh: true, autoRefresh: true,
refreshInterval: 30000, refreshInterval: 30000,
}, },
@@ -628,6 +636,34 @@ export const fetchDomainsAction = networkStatePart.createAction(async (statePart
} }
}); });
export const fetchGatewayDomainsAction = networkStatePart.createAction(async (statePartArg) => {
const context = getActionContext();
try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetGatewayDomains
>('/typedrequest', 'getGatewayDomains');
const response = await typedRequest.fire({ identity: context.identity! });
return { ...statePartArg.getState(), gatewayDomains: response.domains };
} catch (err) {
console.error('Failed to fetch gateway domains:', err);
return statePartArg.getState();
}
});
export const fetchGatewayDnsRecordsAction = networkStatePart.createAction(async (statePartArg) => {
const context = getActionContext();
try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetGatewayDnsRecords
>('/typedrequest', 'getGatewayDnsRecords');
const response = await typedRequest.fire({ identity: context.identity! });
return { ...statePartArg.getState(), gatewayDnsRecords: response.records };
} catch (err) {
console.error('Failed to fetch gateway DNS records:', err);
return statePartArg.getState();
}
});
export const fetchCertificatesAction = networkStatePart.createAction(async (statePartArg) => { export const fetchCertificatesAction = networkStatePart.createAction(async (statePartArg) => {
const context = getActionContext(); const context = getActionContext();
try { try {
@@ -866,17 +902,21 @@ export const triggerScheduleAction = backupsStatePart.createAction<{ scheduleId:
export const fetchSettingsAction = settingsStatePart.createAction(async (statePartArg) => { export const fetchSettingsAction = settingsStatePart.createAction(async (statePartArg) => {
const context = getActionContext(); const context = getActionContext();
try { try {
const [settingsResp, passwordResp] = await Promise.all([ const [settingsResp, passwordResp, managedDcRouterResp] = await Promise.all([
new plugins.domtools.plugins.typedrequest.TypedRequest< new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetSettings interfaces.requests.IReq_GetSettings
>('/typedrequest', 'getSettings').fire({ identity: context.identity! }), >('/typedrequest', 'getSettings').fire({ identity: context.identity! }),
new plugins.domtools.plugins.typedrequest.TypedRequest< new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetBackupPasswordStatus interfaces.requests.IReq_GetBackupPasswordStatus
>('/typedrequest', 'getBackupPasswordStatus').fire({ identity: context.identity! }), >('/typedrequest', 'getBackupPasswordStatus').fire({ identity: context.identity! }),
new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetManagedDcRouterStatus
>('/typedrequest', 'getManagedDcRouterStatus').fire({ identity: context.identity! }),
]); ]);
return { return {
settings: settingsResp.settings, settings: settingsResp.settings,
backupPasswordConfigured: passwordResp.status.isConfigured, backupPasswordConfigured: passwordResp.status.isConfigured,
managedDcRouterStatus: managedDcRouterResp.status,
}; };
} catch (err) { } catch (err) {
console.error('Failed to fetch settings:', err); console.error('Failed to fetch settings:', err);
@@ -903,6 +943,58 @@ export const updateSettingsAction = settingsStatePart.createAction<{
} }
}); });
export const fetchManagedDcRouterStatusAction = settingsStatePart.createAction(async (statePartArg) => {
const context = getActionContext();
try {
const response = await new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetManagedDcRouterStatus
>('/typedrequest', 'getManagedDcRouterStatus').fire({ identity: context.identity! });
return { ...statePartArg.getState(), managedDcRouterStatus: response.status };
} catch (err) {
console.error('Failed to fetch managed dcrouter status:', err);
return statePartArg.getState();
}
});
export const startManagedDcRouterAction = settingsStatePart.createAction(async (statePartArg) => {
const context = getActionContext();
try {
const response = await new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_StartManagedDcRouter
>('/typedrequest', 'startManagedDcRouter').fire({ identity: context.identity! });
return { ...statePartArg.getState(), managedDcRouterStatus: response.status };
} catch (err) {
console.error('Failed to start managed dcrouter:', err);
return statePartArg.getState();
}
});
export const stopManagedDcRouterAction = settingsStatePart.createAction(async (statePartArg) => {
const context = getActionContext();
try {
const response = await new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_StopManagedDcRouter
>('/typedrequest', 'stopManagedDcRouter').fire({ identity: context.identity! });
return { ...statePartArg.getState(), managedDcRouterStatus: response.status };
} catch (err) {
console.error('Failed to stop managed dcrouter:', err);
return statePartArg.getState();
}
});
export const restartManagedDcRouterAction = settingsStatePart.createAction(async (statePartArg) => {
const context = getActionContext();
try {
const response = await new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_RestartManagedDcRouter
>('/typedrequest', 'restartManagedDcRouter').fire({ identity: context.identity! });
return { ...statePartArg.getState(), managedDcRouterStatus: response.status };
} catch (err) {
console.error('Failed to restart managed dcrouter:', err);
return statePartArg.getState();
}
});
export const setBackupPasswordAction = settingsStatePart.createAction<{ password: string }>( export const setBackupPasswordAction = settingsStatePart.createAction<{ password: string }>(
async (statePartArg, dataArg) => { async (statePartArg, dataArg) => {
const context = getActionContext(); const context = getActionContext();
@@ -926,10 +1018,17 @@ export const setBackupPasswordAction = settingsStatePart.createAction<{ password
// UI Actions // UI Actions
// ============================================================================ // ============================================================================
export const setActiveViewAction = uiStatePart.createAction<{ view: string }>( export const setActiveViewAction = uiStatePart.createAction<{ view: string; subview?: string | null }>(
async (statePartArg, dataArg) => { async (statePartArg, dataArg) => {
const normalizedView = dataArg.view.toLowerCase().replace(/\s+/g, '-'); const normalizedView = dataArg.view.toLowerCase().replace(/\s+/g, '-');
return { ...statePartArg.getState(), activeView: normalizedView }; const normalizedSubview = dataArg.subview
? dataArg.subview.toLowerCase().replace(/\s+/g, '-')
: null;
return {
...statePartArg.getState(),
activeView: normalizedView,
activeSubview: normalizedSubview,
};
}, },
); );
@@ -949,7 +1048,10 @@ const dispatchCombinedRefreshAction = async () => {
if (!loginState.isLoggedIn) return; if (!loginState.isLoggedIn) return;
try { try {
await systemStatePart.dispatchAction(fetchSystemStatusAction, null); await Promise.all([
systemStatePart.dispatchAction(fetchSystemStatusAction, null),
networkStatePart.dispatchAction(fetchTrafficStatsAction, null),
]);
} catch (err) { } catch (err) {
// Silently fail on auto-refresh // Silently fail on auto-refresh
} }
@@ -985,6 +1087,56 @@ startAutoRefresh();
let socketClient: InstanceType<typeof plugins.typedsocket.TypedSocket> | null = null; let socketClient: InstanceType<typeof plugins.typedsocket.TypedSocket> | null = null;
const socketRouter = new plugins.domtools.plugins.typedrequest.TypedRouter(); 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 // Handle server-pushed platform service log entries
socketRouter.addTypedHandler( socketRouter.addTypedHandler(
new plugins.domtools.plugins.typedrequest.TypedHandler<interfaces.requests.IReq_PushPlatformServiceLog>( new plugins.domtools.plugins.typedrequest.TypedHandler<interfaces.requests.IReq_PushPlatformServiceLog>(
@@ -1074,13 +1226,13 @@ async function disconnectSocket() {
// App Store Actions // App Store Actions
// ============================================================================ // ============================================================================
export const fetchAppTemplatesAction = appStoreStatePart.createAction( export const fetchAppStoreTemplatesAction = appStoreStatePart.createAction(
async (statePartArg) => { async (statePartArg) => {
const context = getActionContext(); const context = getActionContext();
try { try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest< const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetAppTemplates interfaces.requests.IReq_GetAppStoreTemplates
>('/typedrequest', 'getAppTemplates'); >('/typedrequest', 'getAppStoreTemplates');
const response = await typedRequest.fire({ identity: context.identity! }); const response = await typedRequest.fire({ identity: context.identity! });
return { ...statePartArg.getState(), apps: response.apps }; return { ...statePartArg.getState(), apps: response.apps };
} catch (err) { } catch (err) {
@@ -1090,13 +1242,13 @@ export const fetchAppTemplatesAction = appStoreStatePart.createAction(
}, },
); );
export const fetchUpgradeableServicesAction = appStoreStatePart.createAction( export const fetchUpgradeableAppStoreServicesAction = appStoreStatePart.createAction(
async (statePartArg) => { async (statePartArg) => {
const context = getActionContext(); const context = getActionContext();
try { try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest< const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetUpgradeableServices interfaces.requests.IReq_GetUpgradeableAppStoreServices
>('/typedrequest', 'getUpgradeableServices'); >('/typedrequest', 'getUpgradeableAppStoreServices');
const response = await typedRequest.fire({ identity: context.identity! }); const response = await typedRequest.fire({ identity: context.identity! });
return { ...statePartArg.getState(), upgradeableServices: response.services }; return { ...statePartArg.getState(), upgradeableServices: response.services };
} catch (err) { } catch (err) {
@@ -1106,15 +1258,15 @@ export const fetchUpgradeableServicesAction = appStoreStatePart.createAction(
}, },
); );
export const upgradeServiceAction = appStoreStatePart.createAction<{ export const upgradeAppStoreServiceAction = appStoreStatePart.createAction<{
serviceName: string; serviceName: string;
targetVersion: string; targetVersion: string;
}>(async (statePartArg, dataArg) => { }>(async (statePartArg, dataArg) => {
const context = getActionContext(); const context = getActionContext();
try { try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest< const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_UpgradeService interfaces.requests.IReq_UpgradeAppStoreService
>('/typedrequest', 'upgradeService'); >('/typedrequest', 'upgradeAppStoreService');
await typedRequest.fire({ await typedRequest.fire({
identity: context.identity!, identity: context.identity!,
serviceName: dataArg.serviceName, serviceName: dataArg.serviceName,
@@ -1122,8 +1274,8 @@ export const upgradeServiceAction = appStoreStatePart.createAction<{
}); });
// Re-fetch upgradeable services and services list // Re-fetch upgradeable services and services list
const upgradeReq = new plugins.domtools.plugins.typedrequest.TypedRequest< const upgradeReq = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetUpgradeableServices interfaces.requests.IReq_GetUpgradeableAppStoreServices
>('/typedrequest', 'getUpgradeableServices'); >('/typedrequest', 'getUpgradeableAppStoreServices');
const upgradeResp = await upgradeReq.fire({ identity: context.identity! }); const upgradeResp = await upgradeReq.fire({ identity: context.identity! });
return { ...statePartArg.getState(), upgradeableServices: upgradeResp.services }; return { ...statePartArg.getState(), upgradeableServices: upgradeResp.services };
} catch (err) { } catch (err) {
+361 -53
View File
@@ -12,12 +12,21 @@ import {
type TemplateResult, type TemplateResult,
} from '@design.estate/dees-element'; } from '@design.estate/dees-element';
import type { ObViewDashboard } from './ob-view-dashboard.js'; interface IUnresolvedView {
import type { ObViewServices } from './ob-view-services.js'; slug?: string;
import type { ObViewNetwork } from './ob-view-network.js'; name: string;
import type { ObViewRegistries } from './ob-view-registries.js'; iconName?: string;
import type { ObViewTokens } from './ob-view-tokens.js'; element?: Promise<any>;
import type { ObViewSettings } from './ob-view-settings.js'; subViews?: IUnresolvedView[];
}
interface IResolvedView {
slug?: string;
name: string;
iconName?: string;
element?: any;
subViews?: IResolvedView[];
}
@customElement('ob-app-shell') @customElement('ob-app-shell')
export class ObAppShell extends DeesElement { export class ObAppShell extends DeesElement {
@@ -27,52 +36,181 @@ export class ObAppShell extends DeesElement {
@state() @state()
accessor uiState: appstate.IUiState = { accessor uiState: appstate.IUiState = {
activeView: 'dashboard', activeView: 'dashboard',
activeSubview: null,
autoRefresh: true, autoRefresh: true,
refreshInterval: 30000, refreshInterval: 30000,
}; };
@state()
accessor systemState: appstate.ISystemState = {
status: null,
};
@state()
accessor globalMessages: plugins.deesCatalog.IGlobalMessage[] = [];
@state() @state()
accessor loginLoading: boolean = false; accessor loginLoading: boolean = false;
@state() @state()
accessor loginError: string = ''; accessor loginError: string = '';
private viewTabs = [ private viewTabs: IUnresolvedView[] = [
{ name: 'Dashboard', iconName: 'lucide:layoutDashboard', element: (async () => (await import('./ob-view-dashboard.js')).ObViewDashboard)() }, {
{ name: 'App Store', iconName: 'lucide:store', element: (async () => (await import('./ob-view-appstore.js')).ObViewAppStore)() }, slug: 'dashboard',
{ name: 'Services', iconName: 'lucide:boxes', element: (async () => (await import('./ob-view-services.js')).ObViewServices)() }, name: 'Dashboard',
{ name: 'Network', iconName: 'lucide:network', element: (async () => (await import('./ob-view-network.js')).ObViewNetwork)() }, iconName: 'lucide:layoutDashboard',
{ name: 'Registries', iconName: 'lucide:package', element: (async () => (await import('./ob-view-registries.js')).ObViewRegistries)() }, element: (async () => (await import('./ob-view-dashboard.js')).ObViewDashboard)(),
{ name: 'Tokens', iconName: 'lucide:key', element: (async () => (await import('./ob-view-tokens.js')).ObViewTokens)() }, },
{ name: 'Settings', iconName: 'lucide:settings', element: (async () => (await import('./ob-view-settings.js')).ObViewSettings)() }, {
slug: 'apps',
name: 'Apps',
iconName: 'lucide:store',
subViews: [
{
slug: 'app-store',
name: 'App Store',
iconName: 'lucide:store',
element: (async () => (await import('./ob-view-appstore.js')).ObViewAppStore)(),
},
{
slug: 'services',
name: 'Services',
iconName: 'lucide:boxes',
element: (async () => (await import('./ob-view-services.js')).ObViewServices)(),
},
],
},
{
slug: 'network',
name: 'Network',
iconName: 'lucide:network',
subViews: [
{
slug: 'proxy',
name: 'Proxy',
iconName: 'lucide:route',
element: (async () => (await import('./ob-view-network.js')).ObViewNetwork)(),
},
{
slug: 'domains',
name: 'Domains',
iconName: 'lucide:globe',
element: (async () => (await import('./ob-view-domains.js')).ObViewDomains)(),
},
{
slug: 'dns-records',
name: 'DNS Records',
iconName: 'lucide:listTree',
element: (async () => (await import('./ob-view-dns-records.js')).ObViewDnsRecords)(),
},
],
},
{
slug: 'registry',
name: 'Registry',
iconName: 'lucide:package',
subViews: [
{
slug: 'registries',
name: 'Registries',
iconName: 'lucide:package',
element: (async () => (await import('./ob-view-registries.js')).ObViewRegistries)(),
},
{
slug: 'tokens',
name: 'Tokens',
iconName: 'lucide:key',
element: (async () => (await import('./ob-view-tokens.js')).ObViewTokens)(),
},
],
},
{
slug: 'settings',
name: 'Settings',
iconName: 'lucide:settings',
element: (async () => (await import('./ob-view-settings.js')).ObViewSettings)(),
},
]; ];
private resolvedViewTabs: Array<{ name: string; iconName?: string; element: any }> = []; private resolvedViewTabs: IResolvedView[] = [];
private suppressedUpdateVersion = '';
private upgradeFlowRunning = false;
constructor() { constructor() {
super(); super();
document.title = 'Onebox'; document.title = 'Onebox';
const loginSubscription = appstate.loginStatePart const loginSubscription = appstate.loginStatePart
.select((stateArg) => stateArg) .select((stateArg: appstate.ILoginState) => stateArg)
.subscribe((loginState) => { .subscribe((loginState: appstate.ILoginState) => {
this.loginState = loginState; this.loginState = loginState;
this.updateGlobalMessages();
if (loginState.isLoggedIn) { if (loginState.isLoggedIn) {
appstate.systemStatePart.dispatchAction(appstate.fetchSystemStatusAction, null); appstate.systemStatePart.dispatchAction(appstate.fetchSystemStatusAction, null);
} }
}); });
this.rxSubscriptions.push(loginSubscription); this.rxSubscriptions.push(loginSubscription);
const systemSubscription = appstate.systemStatePart
.select((stateArg: appstate.ISystemState) => stateArg)
.subscribe((systemState: appstate.ISystemState) => {
this.systemState = systemState;
this.updateGlobalMessages();
});
this.rxSubscriptions.push(systemSubscription);
const uiSubscription = appstate.uiStatePart const uiSubscription = appstate.uiStatePart
.select((stateArg) => stateArg) .select((stateArg: appstate.IUiState) => stateArg)
.subscribe((uiState) => { .subscribe((uiState: appstate.IUiState) => {
this.uiState = uiState; this.uiState = uiState;
this.syncAppdashView(uiState.activeView); this.syncAppdashView(uiState.activeView, uiState.activeSubview);
}); });
this.rxSubscriptions.push(uiSubscription); this.rxSubscriptions.push(uiSubscription);
} }
public static styles = [ private async resolveViewTabs(tabs: IUnresolvedView[]): Promise<IResolvedView[]> {
return Promise.all(
tabs.map(async (tab) => {
const resolvedTab: IResolvedView = {
slug: tab.slug,
name: tab.name,
iconName: tab.iconName,
};
if (tab.element) {
resolvedTab.element = await tab.element;
}
if (tab.subViews) {
resolvedTab.subViews = await this.resolveViewTabs(tab.subViews);
}
return resolvedTab;
}),
);
}
private slugFor(view: IResolvedView): string {
return view.slug ?? view.name.toLowerCase().replace(/\s+/g, '-');
}
private findParent(view: IResolvedView): IResolvedView | undefined {
return this.resolvedViewTabs.find((viewTab) => viewTab.subViews?.includes(view));
}
private findViewBySlug(viewSlug: string, subviewSlug: string | null): IResolvedView | undefined {
const topLevelView = this.resolvedViewTabs.find((view) => this.slugFor(view) === viewSlug);
if (!topLevelView) return undefined;
if (subviewSlug && topLevelView.subViews) {
return topLevelView.subViews.find((subview) => this.slugFor(subview) === subviewSlug) ?? topLevelView;
}
return topLevelView;
}
private get currentViewTab(): IResolvedView | undefined {
if (this.resolvedViewTabs.length === 0) return undefined;
return this.findViewBySlug(this.uiState.activeView, this.uiState.activeSubview) ?? this.resolvedViewTabs[0];
}
public static override styles = [
cssManager.defaultStyles, cssManager.defaultStyles,
css` css`
:host { :host {
@@ -87,16 +225,15 @@ export class ObAppShell extends DeesElement {
`, `,
]; ];
public render(): TemplateResult { public override render(): TemplateResult {
return html` return html`
<div class="maincontainer"> <div class="maincontainer">
<dees-simple-login name="Onebox"> <dees-simple-login name="Onebox">
<dees-simple-appdash <dees-simple-appdash
name="Onebox" name="Onebox"
.viewTabs=${this.resolvedViewTabs} .viewTabs=${this.resolvedViewTabs}
.selectedView=${this.resolvedViewTabs.find( .selectedView=${this.currentViewTab}
(t) => t.name.toLowerCase().replace(/\s+/g, '-') === this.uiState.activeView .globalMessages=${this.globalMessages}
) || this.resolvedViewTabs[0]}
> >
</dees-simple-appdash> </dees-simple-appdash>
</dees-simple-login> </dees-simple-login>
@@ -104,15 +241,8 @@ export class ObAppShell extends DeesElement {
`; `;
} }
public async firstUpdated() { public override async firstUpdated() {
// Resolve async view tab imports this.resolvedViewTabs = await this.resolveViewTabs(this.viewTabs);
this.resolvedViewTabs = await Promise.all(
this.viewTabs.map(async (tab) => ({
name: tab.name,
iconName: tab.iconName,
element: await tab.element,
})),
);
this.requestUpdate(); this.requestUpdate();
await this.updateComplete; await this.updateComplete;
@@ -126,34 +256,44 @@ export class ObAppShell extends DeesElement {
const appDash = this.shadowRoot!.querySelector('dees-simple-appdash') as any; const appDash = this.shadowRoot!.querySelector('dees-simple-appdash') as any;
if (appDash) { if (appDash) {
appDash.addEventListener('view-select', (e: CustomEvent) => { appDash.addEventListener('view-select', (e: CustomEvent) => {
const viewName = e.detail.view.name.toLowerCase().replace(/\s+/g, '-'); const view = e.detail.view as IResolvedView;
appRouter.navigateToView(viewName); const parent = this.findParent(view);
const currentState = appstate.uiStatePart.getState();
if (parent) {
const parentSlug = this.slugFor(parent);
const subviewSlug = this.slugFor(view);
if (currentState.activeView === parentSlug && currentState.activeSubview === subviewSlug) {
return;
}
appRouter.navigateToView(parentSlug, subviewSlug);
} else {
const slug = this.slugFor(view);
if (currentState.activeView === slug && !currentState.activeSubview) {
return;
}
appRouter.navigateToView(slug);
}
}); });
appDash.addEventListener('logout', async () => { appDash.addEventListener('logout', async () => {
await appstate.loginStatePart.dispatchAction(appstate.logoutAction, null); await appstate.loginStatePart.dispatchAction(appstate.logoutAction, null);
}); });
} }
// Load the initial view on the appdash now that tabs are resolved
// Read activeView directly from state (not this.uiState which may be stale)
if (appDash && this.resolvedViewTabs.length > 0) { if (appDash && this.resolvedViewTabs.length > 0) {
const currentActiveView = appstate.uiStatePart.getState().activeView; const currentUiState = appstate.uiStatePart.getState();
const initialView = this.resolvedViewTabs.find( const initialView =
(t) => t.name.toLowerCase().replace(/\s+/g, '-') === currentActiveView, this.findViewBySlug(currentUiState.activeView, currentUiState.activeSubview) ||
) || this.resolvedViewTabs[0]; this.resolvedViewTabs[0];
await appDash.loadView(initialView); await appDash.loadView(initialView);
} }
// Check for stored session (persistent login state)
const loginState = appstate.loginStatePart.getState(); const loginState = appstate.loginStatePart.getState();
if (loginState.identity?.jwt) { if (loginState.identity?.jwt) {
if (loginState.identity.expiresAt > Date.now()) { if (loginState.identity.expiresAt > Date.now()) {
// Switch to dashboard immediately (no flash of login form)
this.loginState = loginState; this.loginState = loginState;
if (simpleLogin) { if (simpleLogin) {
await simpleLogin.switchToSlottedContent(); await simpleLogin.switchToSlottedContent();
} }
// Validate token with server in the background
try { try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest< const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetSystemStatus interfaces.requests.IReq_GetSystemStatus
@@ -161,11 +301,9 @@ export class ObAppShell extends DeesElement {
const response = await typedRequest.fire({ identity: loginState.identity }); const response = await typedRequest.fire({ identity: loginState.identity });
appstate.systemStatePart.setState({ status: response.status }); appstate.systemStatePart.setState({ status: response.status });
} catch (err) { } catch (err) {
// Token rejected by server - switch back to login
console.warn('Stored session invalid, returning to login:', err); console.warn('Stored session invalid, returning to login:', err);
await appstate.loginStatePart.dispatchAction(appstate.logoutAction, null); await appstate.loginStatePart.dispatchAction(appstate.logoutAction, null);
if (simpleLogin) { if (simpleLogin) {
// Force page reload to show login properly
window.location.reload(); window.location.reload();
} }
} }
@@ -206,14 +344,184 @@ export class ObAppShell extends DeesElement {
} }
} }
private syncAppdashView(viewName: string): void { private updateGlobalMessages(): void {
const updateStatus = this.systemState.status?.onebox.update;
if (
!this.loginState.isLoggedIn ||
!updateStatus?.updateAvailable ||
!updateStatus.latestVersion ||
updateStatus.latestVersion === this.suppressedUpdateVersion
) {
this.globalMessages = [];
return;
}
this.globalMessages = [
{
id: `onebox-update-${updateStatus.latestVersion}`,
type: 'info',
icon: 'lucide:download',
message: `Onebox ${updateStatus.latestVersion} is available. Current version: ${updateStatus.currentVersion}.`,
dismissible: false,
actions: [
{
name: 'Update Now',
iconName: 'lucide:download',
action: () => this.startOneboxUpgradeFlow(),
},
{
name: 'Release Notes',
iconName: 'lucide:fileText',
action: () => this.openUpdateUrl(updateStatus.changelogUrl || updateStatus.releaseUrl),
},
{
name: 'Later',
iconName: 'lucide:clock',
action: () => {
this.suppressedUpdateVersion = updateStatus.latestVersion || '';
this.updateGlobalMessages();
},
},
],
},
];
}
private async startOneboxUpgradeFlow(): Promise<void> {
if (this.upgradeFlowRunning) {
return;
}
const identity = appstate.loginStatePart.getState().identity;
const updateStatus = this.systemState.status?.onebox.update;
if (!identity || !updateStatus?.latestVersion) {
return;
}
this.upgradeFlowRunning = true;
const updater = await plugins.deesCatalog.DeesUpdater.createAndShow({
currentVersion: updateStatus.currentVersion,
updatedVersion: updateStatus.latestVersion,
moreInfoUrl: updateStatus.releaseUrl,
changelogUrl: updateStatus.changelogUrl,
successAction: 'reload',
successDelayMs: 30000,
successActionLabel: 'Reloading Onebox UI',
});
try {
updater.updateProgress({
percentage: 10,
indeterminate: true,
statusText: 'Requesting upgrade...',
terminalLines: ['Requesting Onebox upgrade'],
});
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_StartOneboxUpgrade
>('/typedrequest', 'startOneboxUpgrade');
const response = await typedRequest.fire({ identity });
if (!response.upgrade.accepted) {
updater.markUpdateError(response.upgrade.message);
await this.delay(5000);
await updater.destroy();
return;
}
updater.appendProgressLine(response.upgrade.message);
if (response.upgrade.pid) {
updater.appendProgressLine(`Upgrade process PID: ${response.upgrade.pid}`);
}
if (response.upgrade.logPath) {
updater.appendProgressLine(`Upgrade log: ${response.upgrade.logPath}`);
}
updater.updateProgress({
percentage: 45,
indeterminate: true,
statusText: 'Installer started...',
});
await this.waitForOneboxUpgrade(updater, response.upgrade.targetVersion, identity);
await updater.markUpdateReady();
} catch (error) {
updater.markUpdateError(this.getErrorMessage(error));
await this.delay(5000);
await updater.destroy();
} finally {
this.upgradeFlowRunning = false;
}
}
private async waitForOneboxUpgrade(
updaterArg: plugins.deesCatalog.DeesUpdater,
targetVersionArg: string,
identityArg: interfaces.data.IIdentity,
): Promise<void> {
const normalizedTargetVersion = this.normalizeVersion(targetVersionArg);
const timeoutAt = Date.now() + 90000;
let attempt = 0;
updaterArg.appendProgressLine('Waiting for Onebox to restart with the new version');
while (Date.now() < timeoutAt) {
await this.delay(5000);
attempt++;
try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetSystemStatus
>('/typedrequest', 'getSystemStatus');
const response = await typedRequest.fire({ identity: identityArg });
const onlineVersion = this.normalizeVersion(response.status.onebox.version);
updaterArg.appendProgressLine(`Onebox API answered with ${onlineVersion}`);
if (onlineVersion === normalizedTargetVersion) {
updaterArg.updateProgress({
percentage: 100,
indeterminate: false,
statusText: `Onebox ${normalizedTargetVersion} is online.`,
});
return;
}
} catch {
updaterArg.appendProgressLine('Onebox API is restarting...');
}
updaterArg.updateProgress({
percentage: Math.min(95, 45 + attempt * 5),
indeterminate: true,
statusText: `Waiting for Onebox ${normalizedTargetVersion}...`,
});
}
updaterArg.appendProgressLine('Timed out waiting for the version check; reloading the UI anyway');
}
private openUpdateUrl(urlArg: string): void {
window.open(urlArg, '_blank', 'noopener,noreferrer');
}
private async delay(millisecondsArg: number): Promise<void> {
const domtools = await this.domtoolsPromise;
await domtools.convenience.smartdelay.delayFor(millisecondsArg);
}
private getErrorMessage(errorArg: unknown): string {
return errorArg instanceof Error ? errorArg.message : String(errorArg);
}
private normalizeVersion(versionArg: string): string {
const trimmedVersion = versionArg.trim();
return trimmedVersion.startsWith('v') ? trimmedVersion : `v${trimmedVersion}`;
}
private syncAppdashView(viewName: string, subviewName: string | null): void {
const appDash = this.shadowRoot?.querySelector('dees-simple-appdash') as any; const appDash = this.shadowRoot?.querySelector('dees-simple-appdash') as any;
if (!appDash || this.resolvedViewTabs.length === 0) return; if (!appDash || this.resolvedViewTabs.length === 0) return;
// Match kebab-case view name (e.g., 'app-store') to tab name (e.g., 'App Store')
const targetTab = this.resolvedViewTabs.find( const targetTab = this.findViewBySlug(viewName, subviewName);
(t) => t.name.toLowerCase().replace(/\s+/g, '-') === viewName if (!targetTab || appDash.selectedView === targetTab) return;
);
if (!targetTab) return;
appDash.loadView(targetTab); appDash.loadView(targetTab);
} }
} }
+162 -24
View File
@@ -25,13 +25,13 @@ export class ObViewAppStore extends DeesElement {
accessor currentView: 'grid' | 'detail' = 'grid'; accessor currentView: 'grid' | 'detail' = 'grid';
@state() @state()
accessor selectedApp: interfaces.requests.ICatalogApp | null = null; accessor selectedApp: interfaces.requests.IAppStoreApp | null = null;
@state() @state()
accessor selectedAppMeta: interfaces.requests.IAppMeta | null = null; accessor selectedAppMeta: interfaces.requests.IAppStoreAppMeta | null = null;
@state() @state()
accessor selectedAppConfig: interfaces.requests.IAppVersionConfig | null = null; accessor selectedAppConfig: interfaces.requests.IAppStoreVersionConfig | null = null;
@state() @state()
accessor selectedVersion: string = ''; accessor selectedVersion: string = '';
@@ -42,6 +42,9 @@ export class ObViewAppStore extends DeesElement {
@state() @state()
accessor serviceName: string = ''; accessor serviceName: string = '';
@state()
accessor serviceDomain: string = '';
@state() @state()
accessor loading: boolean = false; accessor loading: boolean = false;
@@ -285,6 +288,34 @@ export class ObViewAppStore extends DeesElement {
text-align: center; text-align: center;
color: var(--ci-shade-4, #71717a); color: var(--ci-shade-4, #71717a);
} }
.footprint-list {
display: grid;
gap: 8px;
}
.footprint-item {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
border: 1px solid var(--ci-shade-2, #27272a);
border-radius: 6px;
font-size: 13px;
color: var(--ci-shade-6, #d4d4d8);
}
.footprint-meta {
color: var(--ci-shade-4, #71717a);
font-family: monospace;
}
.exposure-warning {
margin-top: 10px;
color: #fbbf24;
font-size: 12px;
line-height: 1.5;
}
`, `,
]; ];
@@ -300,7 +331,7 @@ export class ObViewAppStore extends DeesElement {
async connectedCallback() { async connectedCallback() {
super.connectedCallback(); super.connectedCallback();
await appstate.appStoreStatePart.dispatchAction(appstate.fetchAppTemplatesAction, null); await appstate.appStoreStatePart.dispatchAction(appstate.fetchAppStoreTemplatesAction, null);
} }
public render(): TemplateResult { public render(): TemplateResult {
@@ -407,6 +438,8 @@ export class ObViewAppStore extends DeesElement {
</div> </div>
` : ''} ` : ''}
${this.renderDeploymentFootprint(config)}
<!-- Version & Image --> <!-- Version & Image -->
<div class="detail-card"> <div class="detail-card">
<div class="section-label">Version</div> <div class="section-label">Version</div>
@@ -474,6 +507,20 @@ export class ObViewAppStore extends DeesElement {
Lowercase letters, numbers, and hyphens only. Lowercase letters, numbers, and hyphens only.
</div> </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>
${this.renderDeployConfirmation(config)}
<div class="actions-row"> <div class="actions-row">
<button class="btn btn-secondary" @click=${() => { this.currentView = 'grid'; }}>Cancel</button> <button class="btn btn-secondary" @click=${() => { this.currentView = 'grid'; }}>Cancel</button>
<button class="btn btn-primary" @click=${() => this.handleDeploy()}> <button class="btn btn-primary" @click=${() => this.handleDeploy()}>
@@ -494,6 +541,73 @@ export class ObViewAppStore extends DeesElement {
`; `;
} }
private renderDeploymentFootprint(config: interfaces.requests.IAppStoreVersionConfig): TemplateResult | '' {
const volumes = this.getConfigVolumes(config);
const publishedPorts = config.publishedPorts || [];
if (volumes.length === 0 && publishedPorts.length === 0) {
return '';
}
return html`
<div class="detail-card">
<div class="section-label">Deployment Footprint</div>
<div class="footprint-list">
${volumes.map((volume) => html`
<div class="footprint-item">
<span>Volume mount</span>
<span class="footprint-meta">
${volume.source || volume.name || 'managed volume'}:${volume.mountPath}${volume.readOnly ? ':ro' : ''}
</span>
</div>
`)}
${publishedPorts.map((port) => html`
<div class="footprint-item">
<span>Published host port</span>
<span class="footprint-meta">${this.formatPublishedPort(port)}</span>
</div>
`)}
</div>
${publishedPorts.length > 0 ? html`
<div class="exposure-warning">
This app publishes raw host ports outside the HTTP proxy. Confirm firewall and network policy before deploying.
</div>
` : ''}
</div>
`;
}
private renderDeployConfirmation(config: interfaces.requests.IAppStoreVersionConfig): TemplateResult | '' {
const volumes = this.getConfigVolumes(config);
const publishedPorts = config.publishedPorts || [];
if (volumes.length === 0 && publishedPorts.length === 0) return '';
return html`
<div class="exposure-warning">
Deploying this app will create ${volumes.length} persistent volume(s)
${publishedPorts.length > 0 ? html`and expose ${publishedPorts.length} host port declaration(s)` : ''}.
</div>
`;
}
private getConfigVolumes(config: interfaces.requests.IAppStoreVersionConfig): interfaces.data.IServiceVolume[] {
return (config.volumes || []).map((volume) => {
if (typeof volume === 'string') {
return { mountPath: volume };
}
return volume;
}).filter((volume) => Boolean(volume.mountPath));
}
private formatPublishedPort(port: interfaces.data.IServicePublishedPort): string {
const protocol = port.protocol || 'tcp';
const target = port.targetPortEnd ? `${port.targetPort}-${port.targetPortEnd}` : String(port.targetPort);
const publishedStart = port.publishedPort || port.targetPort;
const publishedEnd = port.publishedPortEnd || (port.targetPortEnd ? publishedStart + (port.targetPortEnd - port.targetPort) : undefined);
const published = publishedEnd ? `${publishedStart}-${publishedEnd}` : String(publishedStart);
return `${port.hostIp || '0.0.0.0'}:${published}/${protocol} -> ${target}/${protocol}`;
}
private async handleViewDetails(e: CustomEvent) { private async handleViewDetails(e: CustomEvent) {
const app = e.detail?.app; const app = e.detail?.app;
if (!app) return; if (!app) return;
@@ -544,8 +658,8 @@ export class ObViewAppStore extends DeesElement {
if (!identity) return; if (!identity) return;
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest< const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetAppConfig interfaces.requests.IReq_GetAppStoreConfig
>('/typedrequest', 'getAppConfig'); >('/typedrequest', 'getAppStoreConfig');
const response = await typedRequest.fire({ identity, appId, version }); const response = await typedRequest.fire({ identity, appId, version });
@@ -560,6 +674,7 @@ export class ObViewAppStore extends DeesElement {
required: ev.required, required: ev.required,
platformInjected: ev.value?.includes('${') || false, platformInjected: ev.value?.includes('${') || false,
})); }));
this.serviceDomain = '';
} catch (err) { } catch (err) {
console.error('Failed to fetch app config:', err); console.error('Failed to fetch app config:', err);
} }
@@ -571,36 +686,59 @@ export class ObViewAppStore extends DeesElement {
this.editableEnvVars = updated; 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() { private async handleDeploy() {
const app = this.selectedApp; const app = this.selectedApp;
const config = this.selectedAppConfig; const config = this.selectedAppConfig;
if (!app || !config) return; 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> = {}; const envVars: Record<string, string> = {};
for (const ev of this.editableEnvVars) { for (const ev of this.editableEnvVars) {
if (ev.key && ev.value && !ev.platformInjected) { if (ev.key && ev.value) {
envVars[ev.key] = ev.value; envVars[ev.key] = ev.value;
} }
} }
const platformReqs = config.platformRequirements || {};
const serviceConfig: interfaces.data.IServiceCreate = {
name: this.serviceName || app.id,
image: config.image,
port: config.port || 80,
envVars,
enableMongoDB: platformReqs.mongodb || false,
enableS3: platformReqs.s3 || false,
enableClickHouse: platformReqs.clickhouse || false,
enableRedis: platformReqs.redis || false,
enableMariaDB: platformReqs.mariadb || false,
appTemplateId: app.id,
appTemplateVersion: this.selectedVersion,
};
try { try {
await appstate.servicesStatePart.dispatchAction(appstate.createServiceAction, { const identity = appstate.loginStatePart.getState().identity;
config: serviceConfig, if (!identity) return;
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_InstallAppStoreApp
>('/typedrequest', 'installAppStoreApp');
await typedRequest.fire({
identity,
install: {
appId: app.id,
version: this.selectedVersion,
serviceName: this.serviceName || app.id,
domain: this.serviceDomain || undefined,
envVars,
},
}); });
setTimeout(() => { setTimeout(() => {
appRouter.navigateToView('services'); appRouter.navigateToView('services');
+117 -27
View File
@@ -12,6 +12,20 @@ import {
type TemplateResult, type TemplateResult,
} from '@design.estate/dees-element'; } from '@design.estate/dees-element';
const byteUnits = ['B', 'KB', 'MB', 'GB', 'TB'];
function getByteUnitIndex(bytes: number): number {
if (!bytes || bytes === 0) return 0;
return Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), byteUnits.length - 1);
}
function formatBytes(bytes: number, forcedUnitIndex?: number): string {
if ((!bytes || bytes === 0) && forcedUnitIndex === undefined) return '0 B';
const unitIndex = forcedUnitIndex ?? getByteUnitIndex(bytes);
const value = bytes / Math.pow(1024, unitIndex);
return `${value.toFixed(1)} ${byteUnits[unitIndex]}`;
}
@customElement('ob-view-dashboard') @customElement('ob-view-dashboard')
export class ObViewDashboard extends DeesElement { export class ObViewDashboard extends DeesElement {
@state() @state()
@@ -36,6 +50,8 @@ export class ObViewDashboard extends DeesElement {
trafficStats: null, trafficStats: null,
dnsRecords: [], dnsRecords: [],
domains: [], domains: [],
gatewayDomains: [],
gatewayDnsRecords: [],
certificates: [], certificates: [],
}; };
@@ -67,7 +83,42 @@ export class ObViewDashboard extends DeesElement {
public static styles = [ public static styles = [
cssManager.defaultStyles, cssManager.defaultStyles,
shared.viewHostCss, shared.viewHostCss,
css``, css`
.dashboard {
display: flex;
flex-direction: column;
gap: 24px;
}
.section {
display: flex;
flex-direction: column;
}
.section-title {
font-size: 18px;
font-weight: 600;
color: ${cssManager.bdTheme('#18181b', '#fafafa')};
margin: 0 0 12px;
}
.services-grid {
display: grid;
grid-template-columns: 1fr;
gap: 16px;
align-items: stretch;
}
.services-grid > * {
height: 100%;
}
@media (min-width: 768px) {
.services-grid {
grid-template-columns: 1fr 1fr;
}
}
`,
]; ];
async connectedCallback() { async connectedCallback() {
@@ -77,6 +128,7 @@ export class ObViewDashboard extends DeesElement {
appstate.servicesStatePart.dispatchAction(appstate.fetchServicesAction, null), appstate.servicesStatePart.dispatchAction(appstate.fetchServicesAction, null),
appstate.servicesStatePart.dispatchAction(appstate.fetchPlatformServicesAction, null), appstate.servicesStatePart.dispatchAction(appstate.fetchPlatformServicesAction, null),
appstate.networkStatePart.dispatchAction(appstate.fetchNetworkStatsAction, null), appstate.networkStatePart.dispatchAction(appstate.fetchNetworkStatsAction, null),
appstate.networkStatePart.dispatchAction(appstate.fetchTrafficStatsAction, null),
appstate.networkStatePart.dispatchAction(appstate.fetchCertificatesAction, null), appstate.networkStatePart.dispatchAction(appstate.fetchCertificatesAction, null),
]); ]);
} }
@@ -86,10 +138,15 @@ export class ObViewDashboard extends DeesElement {
const services = this.servicesState.services; const services = this.servicesState.services;
const platformServices = this.servicesState.platformServices; const platformServices = this.servicesState.platformServices;
const networkStats = this.networkState.stats; const networkStats = this.networkState.stats;
const trafficStats = this.networkState.trafficStats;
const certificates = this.networkState.certificates; const certificates = this.networkState.certificates;
const statusCounts = trafficStats?.statusCounts || {};
const runningServices = services.filter((s) => s.status === 'running').length; const runningServices = services.filter((s) => s.status === 'running').length;
const stoppedServices = services.filter((s) => s.status === 'stopped').length; const stoppedServices = services.filter((s) => s.status === 'stopped').length;
const memoryUnitIndex = getByteUnitIndex(
status?.docker?.memoryTotal || status?.docker?.memoryUsage || 0,
);
const validCerts = certificates.filter((c) => c.isValid).length; const validCerts = certificates.filter((c) => c.isValid).length;
const expiringCerts = certificates.filter( const expiringCerts = certificates.filter(
@@ -97,22 +154,19 @@ export class ObViewDashboard extends DeesElement {
).length; ).length;
const expiredCerts = certificates.filter((c) => !c.isValid).length; const expiredCerts = certificates.filter((c) => !c.isValid).length;
return html` const dashboardData = {
<ob-sectionheading>Dashboard</ob-sectionheading>
<sz-dashboard-view
.data=${{
cluster: { cluster: {
totalServices: services.length, totalServices: services.length,
running: runningServices, running: runningServices,
stopped: stoppedServices, stopped: stoppedServices,
dockerStatus: status?.docker?.running ? 'running' : 'stopped', dockerStatus: status?.docker?.running ? 'running' as const : 'stopped' as const,
}, },
resourceUsage: { resourceUsage: {
cpu: status?.docker?.cpuUsage || 0, cpu: status?.docker?.cpuUsage || 0,
memoryUsed: status?.docker?.memoryUsage || 0, memoryUsed: formatBytes(status?.docker?.memoryUsage || 0, memoryUnitIndex),
memoryTotal: status?.docker?.memoryTotal || 0, memoryTotal: formatBytes(status?.docker?.memoryTotal || 0, memoryUnitIndex),
networkIn: status?.docker?.networkIn || 0, networkIn: formatBytes(status?.docker?.networkIn || 0),
networkOut: status?.docker?.networkOut || 0, networkOut: formatBytes(status?.docker?.networkOut || 0),
topConsumers: [], topConsumers: [],
}, },
platformServices: platformServices platformServices: platformServices
@@ -123,39 +177,75 @@ export class ObViewDashboard extends DeesElement {
running: ps.status === 'running', running: ps.status === 'running',
})), })),
traffic: { traffic: {
requests: 0, requests: trafficStats?.requestCount || 0,
errors: 0, errors: trafficStats?.errorCount || 0,
errorPercent: 0, errorPercent: trafficStats?.errorRate || 0,
avgResponse: 0, avgResponse: trafficStats?.avgResponseTime || 0,
reqPerMin: 0, reqPerMin: trafficStats?.requestsPerMinute || 0,
status2xx: 0, status2xx: statusCounts['2xx'] || 0,
status3xx: 0, status3xx: statusCounts['3xx'] || 0,
status4xx: 0, status4xx: statusCounts['4xx'] || 0,
status5xx: 0, status5xx: statusCounts['5xx'] || 0,
}, },
proxy: { proxy: {
httpPort: networkStats?.proxy?.httpPort || 80, httpPort: String(networkStats?.proxy?.httpPort || 80),
httpsPort: networkStats?.proxy?.httpsPort || 443, httpsPort: String(networkStats?.proxy?.httpsPort || 443),
httpActive: networkStats?.proxy?.running || false, httpActive: networkStats?.proxy?.running || false,
httpsActive: networkStats?.proxy?.running || false, httpsActive: networkStats?.proxy?.running || false,
routeCount: networkStats?.proxy?.routes || 0, routeCount: String(networkStats?.proxy?.routes || 0),
}, },
certificates: { certificates: {
valid: validCerts, valid: validCerts,
expiring: expiringCerts, expiring: expiringCerts,
expired: expiredCerts, expired: expiredCerts,
}, },
dnsConfigured: true, dnsConfigured: status?.dns?.configured || false,
acmeConfigured: true, acmeConfigured: status?.ssl?.configured || false,
quickActions: [ quickActions: [
{ label: 'Deploy Service', icon: 'lucide:Plus', primary: true }, { label: 'Deploy Service', icon: 'lucide:Plus', primary: true },
{ label: 'Add Domain', icon: 'lucide:Globe' }, { label: 'Add Domain', icon: 'lucide:Globe' },
{ label: 'View Logs', icon: 'lucide:FileText' }, { label: 'View Logs', icon: 'lucide:FileText' },
], ],
}} };
@action-click=${(e: CustomEvent) => this.handleQuickAction(e)}
return html`
<ob-sectionheading>Dashboard</ob-sectionheading>
<div class="dashboard">
<section class="section">
<h2 class="section-title">Cluster Overview</h2>
<sz-status-grid-cluster .stats=${dashboardData.cluster}></sz-status-grid-cluster>
</section>
<section class="section">
<h2 class="section-title">Services & Resources</h2>
<div class="services-grid">
<sz-resource-usage-card .data=${dashboardData.resourceUsage}></sz-resource-usage-card>
<sz-platform-services-card
.services=${dashboardData.platformServices}
@service-click=${(e: CustomEvent) => this.handlePlatformServiceClick(e)} @service-click=${(e: CustomEvent) => this.handlePlatformServiceClick(e)}
></sz-dashboard-view> ></sz-platform-services-card>
</div>
</section>
<section class="section">
<h2 class="section-title">Network & Traffic</h2>
<sz-status-grid-network
.traffic=${dashboardData.traffic}
.proxy=${dashboardData.proxy}
.certificates=${dashboardData.certificates}
></sz-status-grid-network>
</section>
<section class="section">
<h2 class="section-title">Infrastructure</h2>
<sz-status-grid-infra
?dnsConfigured=${dashboardData.dnsConfigured}
?acmeConfigured=${dashboardData.acmeConfigured}
.actions=${dashboardData.quickActions}
@action-click=${(e: CustomEvent) => this.handleQuickAction(e)}
></sz-status-grid-infra>
</section>
</div>
`; `;
} }

Some files were not shown because too many files have changed in this diff Show More