Compare commits

...

185 Commits

Author SHA1 Message Date
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
jkunz 242677404b v1.24.1
Release / build-and-release (push) Failing after 24s
2026-03-24 20:08:25 +00:00
jkunz 8c6159c596 fix(repo): migrate smart build config to .smartconfig.json and tidy repository metadata 2026-03-24 20:08:25 +00:00
jkunz c210507951 v1.24.0
Release / build-and-release (push) Successful in 3m6s
2026-03-24 19:54:56 +00:00
jkunz 0799efadae feat(backup): add containerarchive-backed backup storage, restore, download, and pruning support 2026-03-24 19:54:56 +00:00
jkunz 22a7e76645 v1.23.0
Release / build-and-release (push) Successful in 3m24s
2026-03-21 19:36:25 +00:00
jkunz 22f34e7de5 feat(appstore): add remote app store templates with service upgrades and Redis/MariaDB platform support 2026-03-21 19:36:25 +00:00
jkunz c0f9f979c7 v1.22.2
Release / build-and-release (push) Successful in 2m59s
2026-03-18 09:48:12 +00:00
jkunz 6e5743c837 fix(web-ui): stabilize app store service creation flow and add Ghost sqlite defaults 2026-03-18 09:48:12 +00:00
jkunz 829f7e47f1 v1.22.1
Publish to npm / npm-publish (push) Failing after 8s
CI / Type Check & Lint (push) Failing after 29s
CI / Build Test (Current Platform) (push) Successful in 1m2s
CI / Build All Platforms (push) Successful in 2m19s
Release / build-and-release (push) Successful in 3m45s
2026-03-18 02:44:14 +00:00
jkunz a36af5c3d5 fix(repo): no changes to commit 2026-03-18 02:44:14 +00:00
jkunz 3c99ee5f83 v1.22.0
Publish to npm / npm-publish (push) Failing after 7s
CI / Type Check & Lint (push) Failing after 29s
CI / Build Test (Current Platform) (push) Successful in 1m1s
CI / Build All Platforms (push) Successful in 2m11s
Release / build-and-release (push) Successful in 3m56s
2026-03-18 02:37:39 +00:00
jkunz 2faa416895 feat(web-appstore): add an App Store view for quick service deployment from curated templates 2026-03-18 02:37:39 +00:00
jkunz acbf448c6f v1.21.0
Publish to npm / npm-publish (push) Failing after 8s
CI / Type Check & Lint (push) Failing after 27s
CI / Build Test (Current Platform) (push) Successful in 1m2s
CI / Build All Platforms (push) Successful in 2m13s
Release / build-and-release (push) Successful in 3m34s
2026-03-18 02:22:45 +00:00
jkunz 5c48ae4156 feat(opsserver): add container workspace API and backend execution environment for services 2026-03-18 02:22:45 +00:00
jkunz 3108408133 v1.20.0
Publish to npm / npm-publish (push) Failing after 7s
CI / Type Check & Lint (push) Failing after 39s
CI / Build Test (Current Platform) (push) Successful in 1m7s
CI / Build All Platforms (push) Successful in 2m52s
Release / build-and-release (push) Successful in 6m2s
2026-03-17 23:39:25 +00:00
jkunz 6defdb4431 feat(ops-dashboard): stream user service logs to the ops dashboard and resolve service containers for Docker log streaming 2026-03-17 23:39:24 +00:00
jkunz f63be883ce v1.19.12
Publish to npm / npm-publish (push) Failing after 10s
CI / Build All Platforms (push) Failing after 13m15s
CI / Build Test (Current Platform) (push) Failing after 13m17s
CI / Type Check & Lint (push) Failing after 13m19s
Release / build-and-release (push) Successful in 6m6s
2026-03-17 12:52:34 +00:00
jkunz 87844bbb8e fix(repo): no changes to commit 2026-03-17 12:52:34 +00:00
jkunz 02b7cda2be v1.19.11
Publish to npm / npm-publish (push) Failing after 9s
CI / Type Check & Lint (push) Failing after 28s
CI / Build Test (Current Platform) (push) Successful in 1m0s
CI / Build All Platforms (push) Successful in 1m57s
Release / build-and-release (push) Successful in 5m51s
2026-03-17 10:40:40 +00:00
jkunz 3b8f95e8e1 fix(repo): no changes to commit 2026-03-17 10:40:40 +00:00
jkunz ee774e3f41 v1.19.10
Publish to npm / npm-publish (push) Failing after 10s
CI / Type Check & Lint (push) Failing after 38s
CI / Build Test (Current Platform) (push) Successful in 1m6s
CI / Build All Platforms (push) Successful in 2m17s
Release / build-and-release (push) Successful in 7m46s
2026-03-17 02:09:50 +00:00
jkunz 6d93dfa459 fix(repo): no changes to commit 2026-03-17 02:09:50 +00:00
jkunz ac394cfafc v1.19.9
Publish to npm / npm-publish (push) Failing after 8s
CI / Type Check & Lint (push) Failing after 29s
CI / Build Test (Current Platform) (push) Successful in 1m2s
CI / Build All Platforms (push) Successful in 2m0s
Release / build-and-release (push) Successful in 2m51s
2026-03-17 01:53:38 +00:00
jkunz 97e9f232fa fix(repo): no changes to commit 2026-03-17 01:53:38 +00:00
jkunz 3dcb6a38e5 v1.19.8
CI / Build All Platforms (push) Failing after 9s
Publish to npm / npm-publish (push) Failing after 8s
CI / Type Check & Lint (push) Failing after 28s
CI / Build Test (Current Platform) (push) Successful in 59s
Release / build-and-release (push) Failing after 1m24s
2026-03-17 01:37:18 +00:00
jkunz ca33970e9a fix(repo): no changes to commit 2026-03-17 01:37:18 +00:00
jkunz cd34b98a25 v1.19.7
Publish to npm / npm-publish (push) Failing after 8s
CI / Type Check & Lint (push) Failing after 33s
CI / Build Test (Current Platform) (push) Successful in 1m7s
CI / Build All Platforms (push) Successful in 1m52s
Release / build-and-release (push) Successful in 3m50s
2026-03-17 01:03:14 +00:00
jkunz a089e5bedb fix(repo): no changes to commit 2026-03-17 01:03:14 +00:00
jkunz 9786ff62f0 v1.19.6
Publish to npm / npm-publish (push) Failing after 9s
CI / Type Check & Lint (push) Failing after 31s
CI / Build Test (Current Platform) (push) Successful in 1m6s
CI / Build All Platforms (push) Successful in 2m15s
Release / build-and-release (push) Failing after 3m30s
2026-03-17 00:45:56 +00:00
jkunz 4a5abc4a0a fix(repository): no changes to commit 2026-03-17 00:45:55 +00:00
jkunz 893a532758 v1.19.5
Publish to npm / npm-publish (push) Failing after 10s
Release / build-and-release (push) Failing after 3m34s
2026-03-17 00:45:37 +00:00
jkunz 7ea286c0a9 fix(repo): no changes to commit 2026-03-17 00:45:37 +00:00
jkunz f94f47e313 v1.19.4
Publish to npm / npm-publish (push) Failing after 8s
CI / Type Check & Lint (push) Failing after 32s
CI / Build Test (Current Platform) (push) Successful in 1m7s
CI / Build All Platforms (push) Successful in 2m8s
Release / build-and-release (push) Successful in 3m38s
2026-03-17 00:01:08 +00:00
jkunz b1a46f8757 fix(repository): no changes to commit 2026-03-17 00:01:08 +00:00
jkunz 56c71226e5 v1.19.3
CI / Build All Platforms (push) Failing after 3s
Publish to npm / npm-publish (push) Failing after 8s
CI / Type Check & Lint (push) Failing after 36s
CI / Build Test (Current Platform) (push) Successful in 1m3s
Release / build-and-release (push) Successful in 2m48s
2026-03-16 20:06:11 +00:00
jkunz f53109a01e fix(repo): no changes to commit 2026-03-16 20:06:11 +00:00
jkunz bcb2473cc5 v1.19.2
Publish to npm / npm-publish (push) Failing after 10s
CI / Type Check & Lint (push) Failing after 34s
CI / Build Test (Current Platform) (push) Successful in 1m10s
CI / Build All Platforms (push) Successful in 2m34s
Release / build-and-release (push) Failing after 4m2s
2026-03-16 20:00:10 +00:00
jkunz 689dcf295b fix(docs): remove outdated UI screenshot assets from project documentation 2026-03-16 20:00:10 +00:00
jkunz c1e14e9fc7 v1.19.1
CI / Type Check & Lint (push) Failing after 30s
CI / Build Test (Current Platform) (push) Successful in 1m3s
CI / Build All Platforms (push) Successful in 2m5s
2026-03-16 19:55:27 +00:00
jkunz d5fd57e2c3 fix(dashboard): add updated dashboard screenshots for refresh and resource usage states 2026-03-16 19:55:27 +00:00
jkunz 079e6a64a9 fix(dashboard): add aggregated resource usage stats to the dashboard
Publish to npm / npm-publish (push) Failing after 4s
CI / Type Check & Lint (push) Failing after 29s
CI / Build Test (Current Platform) (push) Successful in 1m1s
Release / build-and-release (push) Failing after 1m43s
CI / Build All Platforms (push) Successful in 2m8s
- Aggregate CPU, memory, and network stats across all running containers
- Extend ISystemStatus.docker with resource usage fields
- Fix getContainerStats Swarm service ID fallback
- Wire dashboard resource usage card to real backend data
2026-03-16 16:47:05 +00:00
jkunz a04cf053db v1.19.0
Publish to npm / npm-publish (push) Failing after 11s
CI / Type Check & Lint (push) Failing after 30s
CI / Build Test (Current Platform) (push) Successful in 58s
CI / Build All Platforms (push) Successful in 2m21s
Release / build-and-release (push) Successful in 4m11s
2026-03-16 16:19:39 +00:00
jkunz ec0e377ccb feat(opsserver,web): add real-time platform service log streaming to the dashboard 2026-03-16 16:19:39 +00:00
jkunz 3b3d0433cb fix(platform-services): fix detail view navigation and log display
CI / Build All Platforms (push) Failing after 4s
Publish to npm / npm-publish (push) Failing after 8s
CI / Type Check & Lint (push) Failing after 29s
CI / Build Test (Current Platform) (push) Successful in 1m6s
Release / build-and-release (push) Successful in 3m21s
- Add back button for returning to services list
- Fix DOM lifecycle when switching between platform services
- Fix timestamp format for dees-chart-log compatibility
- Clear previous stats/logs state before fetching new data
2026-03-16 14:48:46 +00:00
jkunz 5f876449ca v1.18.4
CI / Build All Platforms (push) Failing after 6s
Publish to npm / npm-publish (push) Failing after 8s
CI / Type Check & Lint (push) Failing after 24s
CI / Build Test (Current Platform) (push) Successful in 54s
Release / build-and-release (push) Successful in 2m19s
2026-03-16 14:35:45 +00:00
jkunz 8e781c7f9d fix(repo): no changes to commit 2026-03-16 14:35:45 +00:00
jkunz a3eefbe92c v1.18.3
Publish to npm / npm-publish (push) Failing after 9s
CI / Type Check & Lint (push) Failing after 29s
CI / Build Test (Current Platform) (push) Successful in 1m1s
CI / Build All Platforms (push) Successful in 2m3s
Release / build-and-release (push) Successful in 3m2s
2026-03-16 14:22:37 +00:00
jkunz 41679427c6 fix(deps): bump @serve.zone/catalog to ^2.6.1 2026-03-16 14:22:37 +00:00
jkunz c420a30341 v1.18.2
Publish to npm / npm-publish (push) Failing after 9s
CI / Type Check & Lint (push) Failing after 28s
CI / Build Test (Current Platform) (push) Successful in 59s
CI / Build All Platforms (push) Successful in 2m0s
Release / build-and-release (push) Successful in 3m41s
2026-03-16 14:14:55 +00:00
jkunz fe109f0953 fix(repo): no changes to commit 2026-03-16 14:14:55 +00:00
jkunz 012dce63b1 v1.18.1
Publish to npm / npm-publish (push) Failing after 10s
Release / build-and-release (push) Successful in 4m0s
2026-03-16 14:14:34 +00:00
jkunz 54780482c7 fix(repo): no changes to commit 2026-03-16 14:14:34 +00:00
jkunz 7ab0fb3c1f v1.18.0
Publish to npm / npm-publish (push) Failing after 9s
CI / Type Check & Lint (push) Failing after 27s
CI / Build Test (Current Platform) (push) Successful in 58s
CI / Build All Platforms (push) Successful in 1m52s
Release / build-and-release (push) Successful in 2m58s
2026-03-16 13:51:43 +00:00
jkunz 713fda2a86 feat(platform-services): add platform service log retrieval and display in the services UI 2026-03-16 13:51:43 +00:00
jkunz ec32c19300 v1.17.4
CI / Type Check & Lint (push) Failing after 30s
Publish to npm / npm-publish (push) Failing after 24s
CI / Build Test (Current Platform) (push) Successful in 1m1s
CI / Build All Platforms (push) Successful in 2m12s
Release / build-and-release (push) Successful in 4m0s
2026-03-16 13:26:56 +00:00
jkunz 7d1d91157c fix(docs): add hello world running screenshot for documentation 2026-03-16 13:26:56 +00:00
jkunz b69c96c240 v1.17.3
CI / Build Test (Current Platform) (push) Failing after 6s
Publish to npm / npm-publish (push) Failing after 8s
CI / Type Check & Lint (push) Failing after 32s
CI / Build All Platforms (push) Successful in 2m4s
Release / build-and-release (push) Successful in 3m13s
2026-03-16 13:05:47 +00:00
jkunz 9ee8851d03 fix(mongodb): downgrade the MongoDB service image to 4.4 and use the legacy mongo shell for container operations 2026-03-16 13:05:47 +00:00
jkunz 7f6031f31a v1.17.2
CI / Type Check & Lint (push) Failing after 29s
Publish to npm / npm-publish (push) Failing after 25s
CI / Build Test (Current Platform) (push) Successful in 1m4s
CI / Build All Platforms (push) Successful in 2m5s
Release / build-and-release (push) Successful in 4m12s
2026-03-16 12:45:44 +00:00
jkunz 6f1b8469e0 fix(platform-services): provision ClickHouse, MinIO, and MongoDB resources via docker exec instead of host port access 2026-03-16 12:45:44 +00:00
jkunz cd06c74cc3 v1.17.1
Publish to npm / npm-publish (push) Failing after 10s
CI / Type Check & Lint (push) Failing after 41s
CI / Build Test (Current Platform) (push) Successful in 1m12s
Release / build-and-release (push) Failing after 1m54s
CI / Build All Platforms (push) Successful in 2m17s
2026-03-16 12:40:39 +00:00
jkunz d3acc720ca fix(repo): no changes to commit 2026-03-16 12:40:39 +00:00
jkunz 1b6de75097 v1.17.0
Publish to npm / npm-publish (push) Failing after 10s
CI / Type Check & Lint (push) Failing after 29s
CI / Build Test (Current Platform) (push) Successful in 56s
Release / build-and-release (push) Failing after 37s
CI / Build All Platforms (push) Successful in 2m7s
2026-03-16 12:36:02 +00:00
jkunz 497f8f59a7 feat(web/services): add deploy service action to the services view 2026-03-16 12:36:02 +00:00
jkunz 0c7d65e4ad v1.16.0
Publish to npm / npm-publish (push) Failing after 10s
CI / Type Check & Lint (push) Failing after 28s
CI / Build Test (Current Platform) (push) Successful in 52s
CI / Build All Platforms (push) Successful in 1m50s
Release / build-and-release (push) Successful in 2m49s
2026-03-16 11:45:56 +00:00
jkunz 3f2cd074ce feat(services): add platform service navigation and stats in the services UI 2026-03-16 11:45:56 +00:00
jkunz 59ed7233bd v1.15.3
CI / Build All Platforms (push) Failing after 3s
Publish to npm / npm-publish (push) Failing after 8s
CI / Type Check & Lint (push) Failing after 25s
CI / Build Test (Current Platform) (push) Successful in 54s
Release / build-and-release (push) Successful in 2m35s
2026-03-16 11:07:00 +00:00
jkunz 01e3ba16c4 fix(install): refresh systemd service configuration before restarting previously running installations 2026-03-16 11:07:00 +00:00
jkunz f5c1d5fcda v1.15.2
Publish to npm / npm-publish (push) Failing after 8s
CI / Type Check & Lint (push) Failing after 32s
CI / Build Test (Current Platform) (push) Successful in 1m7s
CI / Build All Platforms (push) Successful in 1m58s
Release / build-and-release (push) Successful in 3m9s
2026-03-16 10:58:08 +00:00
jkunz 45b0971f2f fix(systemd): set HOME and DENO_DIR for the systemd service environment 2026-03-16 10:58:08 +00:00
jkunz 178f440d7e v1.15.1
CI / Type Check & Lint (push) Failing after 30s
Publish to npm / npm-publish (push) Failing after 29s
CI / Build Test (Current Platform) (push) Successful in 1m0s
CI / Build All Platforms (push) Successful in 2m8s
Release / build-and-release (push) Successful in 2m53s
2026-03-16 10:23:05 +00:00
jkunz 7fff15a90c fix(systemd): move Docker installation and swarm initialization to systemd enable flow 2026-03-16 10:23:05 +00:00
jkunz 69e23f667e v1.15.0
CI / Build All Platforms (push) Failing after 7s
Publish to npm / npm-publish (push) Failing after 8s
CI / Type Check & Lint (push) Failing after 26s
CI / Build Test (Current Platform) (push) Successful in 58s
Release / build-and-release (push) Successful in 2m58s
2026-03-16 10:02:59 +00:00
jkunz a2bf4df7c2 feat(systemd): replace smartdaemon-based service management with native systemd commands 2026-03-16 10:02:59 +00:00
jkunz 9e0a0b5a89 v1.14.10
Publish to npm / npm-publish (push) Failing after 9s
CI / Type Check & Lint (push) Failing after 32s
CI / Build Test (Current Platform) (push) Successful in 59s
CI / Build All Platforms (push) Successful in 2m1s
Release / build-and-release (push) Successful in 2m42s
2026-03-16 08:40:48 +00:00
jkunz 3a227bd838 fix(services): stop auto-update monitoring during shutdown 2026-03-16 08:40:48 +00:00
jkunz f5a7fccfc2 v1.14.9
CI / Type Check & Lint (push) Failing after 25s
Publish to npm / npm-publish (push) Failing after 32s
CI / Build Test (Current Platform) (push) Successful in 57s
CI / Build All Platforms (push) Successful in 2m20s
Release / build-and-release (push) Successful in 3m50s
2026-03-16 08:25:32 +00:00
jkunz a30d2029a5 fix(repo): no changes to commit 2026-03-16 08:25:32 +00:00
jkunz 88727dd47d v1.14.8
Publish to npm / npm-publish (push) Failing after 9s
CI / Type Check & Lint (push) Failing after 45s
CI / Build Test (Current Platform) (push) Successful in 1m19s
CI / Build All Platforms (push) Successful in 2m46s
Release / build-and-release (push) Successful in 5m20s
2026-03-16 03:06:23 +00:00
jkunz 9a5ed2220e fix(repo): no changes to commit 2026-03-16 03:06:23 +00:00
jkunz decd39e7c4 v1.14.7
CI / Build All Platforms (push) Has been cancelled
CI / Type Check & Lint (push) Has been cancelled
CI / Build Test (Current Platform) (push) Has been cancelled
Publish to npm / npm-publish (push) Failing after 8s
Release / build-and-release (push) Successful in 5m34s
2026-03-16 03:06:17 +00:00
jkunz ad2e228208 fix(repo): no changes to commit 2026-03-16 03:06:17 +00:00
jkunz cf06019d79 v1.14.6
CI / Build Test (Current Platform) (push) Has been cancelled
CI / Type Check & Lint (push) Has been cancelled
CI / Build All Platforms (push) Has been cancelled
Publish to npm / npm-publish (push) Failing after 6s
Release / build-and-release (push) Successful in 5m30s
2026-03-16 03:06:08 +00:00
jkunz cf44b0047d fix(project): no changes to commit 2026-03-16 03:06:08 +00:00
jkunz 260b5364e6 v1.14.5
CI / Build All Platforms (push) Failing after 6s
Publish to npm / npm-publish (push) Failing after 8s
CI / Type Check & Lint (push) Failing after 24s
CI / Build Test (Current Platform) (push) Successful in 51s
Release / build-and-release (push) Successful in 3m30s
2026-03-16 03:04:57 +00:00
jkunz 51c1962042 fix(onebox): move Docker auto-install and swarm initialization into Onebox startup flow 2026-03-16 03:04:57 +00:00
jkunz d3b78054ad v1.14.4
CI / Build Test (Current Platform) (push) Failing after 5s
Publish to npm / npm-publish (push) Failing after 7s
CI / Type Check & Lint (push) Failing after 30s
CI / Build All Platforms (push) Successful in 1m57s
Release / build-and-release (push) Successful in 3m15s
2026-03-16 02:37:59 +00:00
jkunz d2ae35f0ce fix(repo): no changes to commit 2026-03-16 02:37:59 +00:00
jkunz a605477663 v1.14.3
Publish to npm / npm-publish (push) Failing after 7s
CI / Type Check & Lint (push) Failing after 29s
CI / Build Test (Current Platform) (push) Successful in 52s
CI / Build All Platforms (push) Successful in 1m54s
Release / build-and-release (push) Successful in 3m14s
2026-03-16 02:17:20 +00:00
jkunz ba98086548 fix(repo): no changes to commit 2026-03-16 02:17:20 +00:00
jkunz 0b3c22556b v1.14.2
CI / Build All Platforms (push) Failing after 6s
Publish to npm / npm-publish (push) Failing after 7s
CI / Type Check & Lint (push) Failing after 23s
CI / Build Test (Current Platform) (push) Successful in 51s
Release / build-and-release (push) Has been cancelled
2026-03-16 02:11:41 +00:00
jkunz 069e6e6c8f fix(repo): no changes to commit 2026-03-16 02:11:41 +00:00
jkunz 10598520d8 v1.14.1
Publish to npm / npm-publish (push) Failing after 9s
CI / Type Check & Lint (push) Failing after 28s
CI / Build Test (Current Platform) (push) Successful in 57s
CI / Build All Platforms (push) Successful in 2m6s
Release / build-and-release (push) Successful in 3m32s
2026-03-16 01:33:07 +00:00
jkunz 075b7946b1 fix(repo): no changes to commit 2026-03-16 01:33:07 +00:00
jkunz f47fca3304 v1.14.0
Publish to npm / npm-publish (push) Failing after 7s
CI / Type Check & Lint (push) Failing after 31s
CI / Build Test (Current Platform) (push) Successful in 1m2s
CI / Build All Platforms (push) Successful in 2m5s
Release / build-and-release (push) Successful in 4m57s
2026-03-16 01:19:58 +00:00
jkunz 575e010a6b feat(daemon): auto-install Docker and initialize Swarm during daemon service setup 2026-03-16 01:19:58 +00:00
jkunz 60a5dc4663 v1.13.17
CI / Type Check & Lint (push) Failing after 39s
Publish to npm / npm-publish (push) Failing after 54s
CI / Build Test (Current Platform) (push) Successful in 1m49s
CI / Build All Platforms (push) Successful in 3m18s
Release / build-and-release (push) Successful in 4m38s
2026-03-16 01:10:23 +00:00
jkunz 36d80b1e27 fix(ci): remove forced container image pulling from Gitea workflow jobs 2026-03-16 01:10:23 +00:00
jkunz 465cf0ee72 v1.13.16
CI / Type Check & Lint (push) Failing after 0s
CI / Build Test (Current Platform) (push) Failing after 0s
CI / Build All Platforms (push) Failing after 0s
Publish to npm / npm-publish (push) Failing after 0s
Release / build-and-release (push) Failing after 2m33s
2026-03-16 00:59:27 +00:00
jkunz bd5cd5c0cb fix(ci): refresh workflow container images on every run and bump @apiclient.xyz/docker to ^5.1.1 2026-03-16 00:59:27 +00:00
jkunz b622565e34 v1.13.15
CI / Build Test (Current Platform) (push) Failing after 5s
CI / Type Check & Lint (push) Failing after 30s
Publish to npm / npm-publish (push) Failing after 35s
CI / Build All Platforms (push) Successful in 2m25s
Release / build-and-release (push) Successful in 3m43s
2026-03-15 21:13:56 +00:00
jkunz 56376121ab fix(repo): no changes to commit 2026-03-15 21:13:56 +00:00
jkunz e3359d1235 v1.13.14
CI / Type Check & Lint (push) Failing after 2s
CI / Build Test (Current Platform) (push) Failing after 2s
CI / Build All Platforms (push) Failing after 2s
Publish to npm / npm-publish (push) Failing after 2s
Release / build-and-release (push) Successful in 5m55s
2026-03-15 20:56:22 +00:00
jkunz f1eeec6922 fix(repo): no changes to commit 2026-03-15 20:56:22 +00:00
jkunz 69362bb529 v1.13.13
CI / Build All Platforms (push) Failing after 6s
Publish to npm / npm-publish (push) Failing after 8s
CI / Type Check & Lint (push) Failing after 32s
CI / Build Test (Current Platform) (push) Failing after 50s
Release / build-and-release (push) Failing after 4s
2026-03-15 20:49:49 +00:00
jkunz 857fcc50ba fix(repo): no changes to commit 2026-03-15 20:49:49 +00:00
jkunz 5d0df006eb v1.13.12
Publish to npm / npm-publish (push) Failing after 9s
CI / Type Check & Lint (push) Failing after 30s
CI / Build Test (Current Platform) (push) Failing after 59s
Release / build-and-release (push) Failing after 1m10s
CI / Build All Platforms (push) Successful in 1m49s
2026-03-15 19:27:13 +00:00
jkunz e6256502ce fix(ci): run pnpm install with --ignore-scripts in CI and release workflows 2026-03-15 19:27:13 +00:00
jkunz d5dc141171 v1.13.11
Publish to npm / npm-publish (push) Failing after 9s
CI / Build Test (Current Platform) (push) Failing after 22s
CI / Build All Platforms (push) Failing after 20s
CI / Type Check & Lint (push) Failing after 30s
Release / build-and-release (push) Failing after 15s
2026-03-15 19:11:46 +00:00
jkunz 2538f5ae2c fix(project): no changes to commit 2026-03-15 19:11:46 +00:00
jkunz 4613193dcc v1.13.10
Publish to npm / npm-publish (push) Failing after 12s
CI / Type Check & Lint (push) Failing after 40s
CI / Build All Platforms (push) Failing after 1m28s
CI / Build Test (Current Platform) (push) Failing after 1m31s
Release / build-and-release (push) Failing after 1m21s
2026-03-15 18:39:16 +00:00
jkunz 848b3afe54 fix(deps): bump @git.zone/tsdeno to ^1.2.0 2026-03-15 18:39:16 +00:00
jkunz dd86bae942 v1.13.9
Publish to npm / npm-publish (push) Failing after 8s
CI / Build Test (Current Platform) (push) Failing after 14s
CI / Build All Platforms (push) Failing after 18s
Release / build-and-release (push) Failing after 19s
CI / Type Check & Lint (push) Failing after 30s
2026-03-15 18:32:32 +00:00
jkunz 4691c61544 fix(repo): no changes to commit 2026-03-15 18:32:32 +00:00
jkunz dfb2d3b340 v1.13.8
Publish to npm / npm-publish (push) Failing after 9s
CI / Build Test (Current Platform) (push) Failing after 15s
CI / Build All Platforms (push) Failing after 16s
CI / Type Check & Lint (push) Failing after 38s
Release / build-and-release (push) Failing after 31s
2026-03-15 18:12:48 +00:00
jkunz 6a19ab05e3 fix(repo): no changes to commit 2026-03-15 18:12:48 +00:00
jkunz 7b718da7a2 v1.13.7
CI / Build All Platforms (push) Failing after 13m21s
CI / Build Test (Current Platform) (push) Failing after 13m23s
CI / Type Check & Lint (push) Failing after 13m25s
Release / build-and-release (push) Failing after 15s
Publish to npm / npm-publish (push) Failing after 6s
2026-03-15 16:38:24 +00:00
jkunz ebaf545418 fix(repo): no changes to commit 2026-03-15 16:38:24 +00:00
jkunz 2cdfdaed55 v1.13.6
CI / Build All Platforms (push) Has been cancelled
CI / Build Test (Current Platform) (push) Has been cancelled
Publish to npm / npm-publish (push) Has been cancelled
Release / build-and-release (push) Has been cancelled
CI / Type Check & Lint (push) Failing after 53m53s
2026-03-15 15:49:42 +00:00
jkunz 2216804652 fix(ci): correct workflow container image registry path 2026-03-15 15:49:42 +00:00
jkunz 1b177037f5 v1.13.5
CI / Type Check & Lint (push) Failing after 1s
CI / Build Test (Current Platform) (push) Failing after 1s
CI / Build All Platforms (push) Failing after 1s
Publish to npm / npm-publish (push) Failing after 1s
Release / build-and-release (push) Failing after 1s
2026-03-15 15:47:21 +00:00
jkunz 9d6590927c fix(workflows): switch Gitea workflow containers from ht-docker-dbase to ht-docker-node 2026-03-15 15:47:21 +00:00
jkunz eaf401200c v1.13.4
CI / Type Check & Lint (push) Failing after 2s
CI / Build Test (Current Platform) (push) Failing after 1s
CI / Build All Platforms (push) Failing after 1s
Publish to npm / npm-publish (push) Failing after 1s
Release / build-and-release (push) Failing after 1s
2026-03-15 15:44:54 +00:00
jkunz e97a4d53ae fix(ci): run workflows in the shared build container and enable corepack for pnpm installs 2026-03-15 15:44:54 +00:00
jkunz ca2b3b25a5 v1.13.3
Publish to npm / npm-publish (push) Failing after 7s
CI / Build Test (Current Platform) (push) Failing after 12s
CI / Build All Platforms (push) Failing after 13s
Release / build-and-release (push) Failing after 13s
CI / Type Check & Lint (push) Failing after 24s
2026-03-15 15:41:37 +00:00
jkunz 19703de50d fix(build): replace custom Deno compile scripts with tsdeno-based binary builds in CI and release workflows 2026-03-15 15:41:37 +00:00
jkunz bcab4f274e v1.13.2
CI / Type Check & Lint (push) Failing after 9s
CI / Build Test (Current Platform) (push) Failing after 9s
Release / build-and-release (push) Failing after 9s
Publish to npm / npm-publish (push) Failing after 1m54s
CI / Build All Platforms (push) Successful in 3m37s
2026-03-15 13:37:03 +00:00
jkunz 64e947735f fix(scripts): install production dependencies before compiling binaries and exclude local node_modules from builds 2026-03-15 13:37:03 +00:00
jkunz 1e05c08002 v1.13.1
CI / Type Check & Lint (push) Failing after 8s
CI / Build Test (Current Platform) (push) Failing after 8s
CI / Build All Platforms (push) Failing after 9s
Publish to npm / npm-publish (push) Failing after 9s
Release / build-and-release (push) Failing after 9s
2026-03-15 13:31:26 +00:00
jkunz 167df321f9 fix(deno): remove nodeModulesDir from Deno configuration 2026-03-15 13:31:25 +00:00
jkunz 49998c4c32 add migration
CI / Type Check & Lint (push) Failing after 36s
CI / Build Test (Current Platform) (push) Failing after 1m8s
CI / Build All Platforms (push) Successful in 8m29s
2026-03-15 12:45:13 +00:00
jkunz 8045ec38df v1.13.0
CI / Build Test (Current Platform) (push) Failing after 1m0s
CI / Type Check & Lint (push) Failing after 1m10s
Release / build-and-release (push) Failing after 4m38s
Publish to npm / npm-publish (push) Failing after 5m34s
CI / Build All Platforms (push) Successful in 10m5s
2026-03-15 12:24:48 +00:00
jkunz 793fb18b43 feat(install): improve installer with version selection, service restart handling, and upgrade documentation 2026-03-15 12:24:48 +00:00
jkunz 09534fd899 v1.12.1
CI / Type Check & Lint (push) Failing after 45s
CI / Build Test (Current Platform) (push) Failing after 1m24s
CI / Build All Platforms (push) Failing after 3m27s
Publish to npm / npm-publish (push) Failing after 3m21s
Release / build-and-release (push) Failing after 4m45s
2026-03-15 12:07:15 +00:00
jkunz 5f3783a5e9 fix(package.json): update package metadata 2026-03-15 12:07:15 +00:00
jkunz 92555c5a5e v1.12.0
Publish to npm / npm-publish (push) Failing after 4m33s
Release / build-and-release (push) Failing after 4m49s
2026-03-15 12:06:55 +00:00
jkunz ddc7fa4bee feat(cli,release): add self-upgrade command and automate CI, release, and npm publishing workflows 2026-03-15 12:06:55 +00:00
jkunz eceb5d99c8 v1.11.0 2026-03-03 11:57:41 +00:00
jkunz 0631b7731f feat(services): map backend service data to UI components, add stats & logs parsing, fetch service stats, and fix logs request param 2026-03-03 11:57:41 +00:00
jkunz 4c485cdc0a v1.10.3 2026-03-02 07:26:38 +00:00
jkunz 0f0da0f2ef fix(bin): make bin/onebox-wrapper.js executable 2026-03-02 07:26:38 +00:00
jkunz 88367f70eb v1.10.2 2026-03-02 07:18:54 +00:00
jkunz bfcfef79da fix(build): update build/watch configuration, switch to esbuild bundler and tswatch, and bump catalog and tooling dependencies 2026-03-02 07:18:54 +00:00
jkunz d95270613b v1.10.1 2026-02-24 20:31:08 +00:00
jkunz 14f6746833 fix(package.json): update package metadata 2026-02-24 20:31:07 +00:00
jkunz fe8ca00337 v1.10.0 2026-02-24 18:15:44 +00:00
jkunz ba05cc84fe feat(opsserver): introduce OpsServer (TypedRequest API) and new lightweight web UI; replace legacy Angular UI and add typed interfaces 2026-02-24 18:15:44 +00:00
jkunz 84c47cd7f5 v1.9.2 2025-12-03 22:10:56 +00:00
jkunz 9365f20f6d fix(ui): Add VS Code configs for the UI workspace and normalize dark theme CSS variables 2025-12-03 22:10:56 +00:00
jkunz bc2ed4b03a v1.9.1 2025-11-27 22:35:35 +00:00
jkunz e4dd4cce0a fix(ui): Correct import success toast and add VS Code launch/tasks recommendations for the UI 2025-11-27 22:35:35 +00:00
jkunz 34c90e21db v1.9.0 2025-11-27 22:29:36 +00:00
jkunz ea7bb1395f feat(backups): Add backup import API and improve backup download/import flow in UI 2025-11-27 22:29:36 +00:00
jkunz c529dfe34d v1.8.0 2025-11-27 21:42:07 +00:00
jkunz 6ba7e655e3 feat(backup): Add backup scheduling system with GFS retention, API and UI integration 2025-11-27 21:42:07 +00:00
jkunz c5d239ab28 v1.7.0 2025-11-27 13:48:11 +00:00
jkunz 5cd7e7c252 feat(backup): Add backup system: BackupManager, DB schema, API endpoints and UI support
Introduce a complete service backup/restore subsystem with encrypted archives, database records and REST endpoints. Implements BackupManager with export/import for service config, platform resources (MongoDB, MinIO, ClickHouse), and Docker images; adds BackupRepository and migrations for backups table and include_image_in_backup; integrates backup flows into the HTTP API and the UI client; exposes backup password management and restore modes (restore/import/clone). Wire BackupManager into Onebox initialization.
2025-11-27 13:48:11 +00:00
jkunz e7ade45097 v1.6.0 2025-11-27 09:50:06 +00:00
jkunz 7b159a3486 feat(ui.dashboard): Add Resource Usage card to dashboard and make dashboard cards full-height; add VSCode launch/tasks/config 2025-11-27 09:50:06 +00:00
218 changed files with 22603 additions and 23606 deletions
-140
View File
@@ -1,140 +0,0 @@
# Onebox Development Notes
## ⚠️ CRITICAL DEVELOPMENT RULES ⚠️
### NEVER GUESS - ALWAYS READ THE ACTUAL CODE
**FUCKING ALWAYS look at the dependency actual code. Don't start fucking guessing stuff.**
run "pnpm run watch" when starting to do stuff, so the UI gets recompiled and the server automatically restarts on file changes.
When working with any dependency:
1. **READ the actual source code** in `node_modules/` or check the package documentation
2. **CHECK the exact API** - don't assume based on similar libraries
3. **VERIFY method names, return types, and property structures** before using them
4. **TEST with the actual implementation** - APIs change between versions
Common mistakes to avoid:
- ❌ Assuming API structure based on similar libraries
- ❌ Guessing method names or property paths
- ❌ Using outdated documentation without checking current version
- ✅ Read the actual TypeScript definitions in node_modules
- ✅ Check the package's README and changelog
- ✅ Test the actual behavior before implementing
## Architecture Changes
### Reverse Proxy Implementation
- **Replaced Nginx** with native Deno reverse proxy (`ts/classes/reverseproxy.ts`)
- Features:
- HTTP/HTTPS dual servers (ports 80/443)
- TLS/SSL certificate management with hot-reload
- WebSocket bidirectional proxying
- Dynamic routing from database
- SNI (Server Name Indication) support
### Code Organization
- Removed "onebox." prefix from all TypeScript files
- Organized into subfolders:
- `ts/classes/` - All class implementations
- `ts/` - Root level utilities (logging, types, plugins, cli, info)
### WebSocket Real-time Communication
- **Backend**: WebSocket endpoint at `/api/ws` (`ts/classes/httpserver.ts:96-174`)
- Connection management with client Set tracking
- Broadcast methods: `broadcast()`, `broadcastServiceUpdate()`, `broadcastServiceStatus()`
- Integrated with service lifecycle (start/stop/restart actions)
- Status monitoring loop broadcasts changes automatically
- **Frontend**: Angular WebSocket service (`ui/src/app/core/services/websocket.service.ts`)
- Auto-connects on app initialization
- Exponential backoff reconnection (max 5 attempts)
- RxJS Observable-based message streaming
- Components subscribe to real-time updates
- **Message Types**:
- `connected` - Initial connection confirmation
- `service_update` - Service lifecycle changes (action: created/updated/deleted/started/stopped)
- `service_status` - Real-time status changes from monitoring loop
- `system_status` - System-wide updates
- **Testing**: Use `.nogit/test-ws-updates.ts` to monitor WebSocket messages
### Docker Configuration
- **System Docker**: Uses root Docker at `/var/run/docker.sock` (NOT rootless)
- **Swarm Mode**: Enabled for service orchestration
- **API Access**: Interact with Docker via direct API calls to the socket
- ❌ DO NOT switch Docker CLI contexts
- ✅ Use curl/HTTP requests to `/var/run/docker.sock`
- **Network**: Overlay network `onebox-network` with `Attachable: true`
- **Services vs Containers**: All workloads run as Swarm services (not standalone containers)
## Debugging Tips
### Backend Logs
Use the background bash task to check server logs:
```bash
# Check for specific patterns (e.g., Login attempts)
BashOutput tool with filter: "Login|error|Error"
# Check all recent output
BashOutput tool without filter
```
The dev server runs with `--watch` so it auto-restarts on file changes.
### Frontend Testing
Use Playwright for UI testing:
```typescript
// Navigate to app
mcp__playwright__browser_navigate({ url: "http://localhost:3000" })
// Fill login form
mcp__playwright__browser_fill_form({
fields: [
{ name: "Username", type: "textbox", ref: "...", value: "admin" },
{ name: "Password", type: "textbox", ref: "...", value: "admin" }
]
})
// Click button
mcp__playwright__browser_click({ element: "Sign in button", ref: "..." })
// Check console errors
// Playwright automatically shows console messages in results
```
### Common Issues
#### Login Issue (Fixed)
**Problem**: `admin/admin` credentials returned "Invalid credentials"
**Root Cause**: `rowToUser()` function in database.ts was accessing rows as arrays `row[2]` instead of objects `row.password_hash`. The @db/sqlite library returns rows as objects with snake_case column names.
**Fix**: Updated `rowToUser()` to support both access patterns:
```typescript
private rowToUser(row: any): IUser {
return {
passwordHash: String(row.password_hash || row[2]),
// ... other fields
};
}
```
**Location**: `ts/classes/database.ts:506-515`
## Default Credentials
- Username: `admin`
- Password: `admin`
- ⚠️ Change immediately after first login!
## Development Server
```bash
# Main server (port 3000)
deno task dev
# Check server status
curl http://localhost:3000/api/status
```
## API Endpoints
- `POST /api/auth/login` - Login (returns JWT-like token)
- `GET /api/status` - System status (requires auth)
- `GET /api/services` - List services (requires auth)
- See `ts/classes/httpserver.ts` for full API
+37
View File
@@ -0,0 +1,37 @@
## Onebox {{VERSION}}
Pre-compiled binaries for multiple platforms.
### Installation
#### Option 1: Via npm (recommended)
```bash
npm install -g @serve.zone/onebox
```
#### Option 2: Via installer script
```bash
curl -sSL https://code.foss.global/serve.zone/onebox/raw/branch/main/install.sh | sudo bash
```
#### Option 3: Direct binary download
Download the appropriate binary for your platform from the assets below and make it executable.
### Supported Platforms
- Linux x86_64 (x64)
- Linux ARM64 (aarch64)
- macOS x86_64 (Intel)
- macOS ARM64 (Apple Silicon)
- Windows x86_64
### Checksums
SHA256 checksums are provided in `SHA256SUMS.txt` for binary verification.
### npm Package
The npm package includes automatic binary detection and installation for your platform.
+211
View File
@@ -0,0 +1,211 @@
name: Release
on:
push:
tags:
- 'v*'
jobs:
build-and-release:
runs-on: ubuntu-latest
container:
image: code.foss.global/host.today/ht-docker-node:latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Deno
uses: denoland/setup-deno@v1
with:
deno-version: v2.x
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Enable corepack
run: corepack enable
- name: Install dependencies
run: pnpm install --ignore-scripts
- name: Get version from tag
id: version
run: |
VERSION=${GITHUB_REF#refs/tags/}
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "version_number=${VERSION#v}" >> $GITHUB_OUTPUT
echo "Building version: $VERSION"
- name: Verify deno.json version matches tag
run: |
DENO_VERSION=$(grep -o '"version": "[^"]*"' deno.json | cut -d'"' -f4)
TAG_VERSION="${{ steps.version.outputs.version_number }}"
echo "deno.json version: $DENO_VERSION"
echo "Tag version: $TAG_VERSION"
if [ "$DENO_VERSION" != "$TAG_VERSION" ]; then
echo "ERROR: Version mismatch!"
echo "deno.json has version $DENO_VERSION but tag is $TAG_VERSION"
exit 1
fi
- name: Compile binaries for all platforms
run: mkdir -p dist/binaries && npx tsdeno compile
- name: Generate SHA256 checksums
run: |
cd dist/binaries
sha256sum * > SHA256SUMS.txt
cat SHA256SUMS.txt
cd ../..
- name: Extract changelog for this version
id: changelog
run: |
VERSION="${{ steps.version.outputs.version }}"
# Check if CHANGELOG.md exists
if [ ! -f CHANGELOG.md ] && [ ! -f changelog.md ]; then
echo "No changelog found, using default release notes"
cat > /tmp/release_notes.md << EOF
## Onebox $VERSION
Pre-compiled binaries for multiple platforms.
### Installation
Use the installation script:
\`\`\`bash
curl -sSL https://code.foss.global/serve.zone/onebox/raw/branch/main/install.sh | sudo bash
\`\`\`
Or download the binary for your platform and make it executable.
### Supported Platforms
- Linux x86_64 (x64)
- Linux ARM64 (aarch64)
- macOS x86_64 (Intel)
- macOS ARM64 (Apple Silicon)
- Windows x86_64
### Checksums
SHA256 checksums are provided in SHA256SUMS.txt
EOF
else
CHANGELOG_FILE=$([ -f CHANGELOG.md ] && echo "CHANGELOG.md" || echo "changelog.md")
awk "/## \[$VERSION\]/,/## \[/" "$CHANGELOG_FILE" | sed '$d' > /tmp/release_notes.md || cat > /tmp/release_notes.md << EOF
## Onebox $VERSION
See changelog.md for full details.
### Installation
Use the installation script:
\`\`\`bash
curl -sSL https://code.foss.global/serve.zone/onebox/raw/branch/main/install.sh | sudo bash
\`\`\`
EOF
fi
echo "Release notes:"
cat /tmp/release_notes.md
- name: Delete existing release if it exists
run: |
VERSION="${{ steps.version.outputs.version }}"
echo "Checking for existing release $VERSION..."
# Try to get existing release by tag
EXISTING_RELEASE_ID=$(curl -s \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
"https://code.foss.global/api/v1/repos/serve.zone/onebox/releases/tags/$VERSION" \
| jq -r '.id // empty')
if [ -n "$EXISTING_RELEASE_ID" ]; then
echo "Found existing release (ID: $EXISTING_RELEASE_ID), deleting..."
curl -X DELETE -s \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
"https://code.foss.global/api/v1/repos/serve.zone/onebox/releases/$EXISTING_RELEASE_ID"
echo "Existing release deleted"
sleep 2
else
echo "No existing release found, proceeding with creation"
fi
- name: Create Gitea Release
run: |
VERSION="${{ steps.version.outputs.version }}"
RELEASE_NOTES=$(cat /tmp/release_notes.md)
# Create the release
echo "Creating release for $VERSION..."
RELEASE_ID=$(curl -X POST -s \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
-H "Content-Type: application/json" \
"https://code.foss.global/api/v1/repos/serve.zone/onebox/releases" \
-d "{
\"tag_name\": \"$VERSION\",
\"name\": \"Onebox $VERSION\",
\"body\": $(jq -Rs . /tmp/release_notes.md),
\"draft\": false,
\"prerelease\": false
}" | jq -r '.id')
echo "Release created with ID: $RELEASE_ID"
# Upload binaries as release assets
for binary in dist/binaries/*; do
filename=$(basename "$binary")
echo "Uploading $filename..."
curl -X POST -s \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
-H "Content-Type: application/octet-stream" \
--data-binary "@$binary" \
"https://code.foss.global/api/v1/repos/serve.zone/onebox/releases/$RELEASE_ID/assets?name=$filename"
done
echo "All assets uploaded successfully"
- name: Clean up old releases
run: |
echo "Cleaning up old releases (keeping only last 3)..."
# Fetch all releases sorted by creation date
RELEASES=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
"https://code.foss.global/api/v1/repos/serve.zone/onebox/releases" | \
jq -r 'sort_by(.created_at) | reverse | .[3:] | .[].id')
# Delete old releases
if [ -n "$RELEASES" ]; then
echo "Found releases to delete:"
for release_id in $RELEASES; do
echo " Deleting release ID: $release_id"
curl -X DELETE -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
"https://code.foss.global/api/v1/repos/serve.zone/onebox/releases/$release_id"
done
echo "Old releases deleted successfully"
else
echo "No old releases to delete (less than 4 releases total)"
fi
echo ""
- name: Release Summary
run: |
echo "================================================"
echo " Release ${{ steps.version.outputs.version }} Complete!"
echo "================================================"
echo ""
echo "Binaries published:"
ls -lh dist/binaries/
echo ""
echo "Release URL:"
echo "https://code.foss.global/serve.zone/onebox/releases/tag/${{ steps.version.outputs.version }}"
echo ""
echo "Installation command:"
echo "curl -sSL https://code.foss.global/serve.zone/onebox/raw/branch/main/install.sh | sudo bash"
echo ""
+27
View File
@@ -1,3 +1,30 @@
.nogit/
# artifacts
coverage/
public/
# installs
node_modules/
# caches
.yarn/
.cache/
.rpt2_cache
# builds
dist/
dist_*/
# rust
rust/target/
dist_rust/
# AI
.claude/
.serena/
#------# custom
# Deno # Deno
.deno/ .deno/
deno.lock deno.lock
+79
View File
@@ -0,0 +1,79 @@
{
"@git.zone/tsbundle": {
"bundles": [
{
"from": "./ts_web/index.ts",
"to": "./ts_bundled/bundle.ts",
"outputMode": "base64ts",
"bundler": "esbuild",
"production": true,
"includeFiles": [
{
"from": "./html/index.html",
"to": "index.html"
}
]
}
]
},
"@git.zone/tsdeno": {
"compileTargets": [
{
"name": "onebox-linux-x64",
"entryPoint": "mod.ts",
"outDir": "dist/binaries",
"target": "x86_64-unknown-linux-gnu",
"permissions": ["--allow-all"],
"noCheck": true
},
{
"name": "onebox-linux-arm64",
"entryPoint": "mod.ts",
"outDir": "dist/binaries",
"target": "aarch64-unknown-linux-gnu",
"permissions": ["--allow-all"],
"noCheck": true
}
]
},
"@git.zone/tswatch": {
"bundles": [
{
"from": "./ts_web/index.ts",
"to": "./ts_bundled/bundle.ts",
"outputMode": "base64ts",
"bundler": "esbuild",
"production": true,
"watchPatterns": ["./ts_web/**/*", "./html/**/*"],
"includeFiles": [
{
"from": "./html/index.html",
"to": "index.html"
}
]
}
],
"watchers": [
{
"name": "backend",
"watch": ["./ts/**/*", "./ts_interfaces/**/*", "./ts_bundled/**/*"],
"command": "deno run --allow-all --unstable-ffi mod.ts server --ephemeral --monitor",
"restart": true,
"debounce": 500,
"runOnStart": true
}
]
},
"@git.zone/cli": {
"projectType": "denoSaaS",
"module": {
"githost": "code.foss.global",
"gitscope": "serve.zone",
"gitrepo": "onebox",
"description": "Self-hosted container platform with automatic SSL and DNS - a mini Heroku for single servers",
"npmPackagename": "@serve.zone/onebox",
"license": "MIT"
}
},
"@ship.zone/szci": {}
}
+591 -3
View File
@@ -1,6 +1,587 @@
# Changelog # Changelog
## 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)
migrate smart build config to .smartconfig.json and tidy repository metadata
- Rename npmextra.json to .smartconfig.json and extend it with CLI project metadata for the repository.
- Mark the package as private and add an empty pnpm overrides block in package.json.
- Expand .gitignore to cover common build artifacts, caches, install directories, and local tooling folders.
- Reformat changelog and README files for cleaner spacing and Markdown table alignment without changing documented behavior.
## 2026-03-24 - 1.24.0 - feat(backup)
add containerarchive-backed backup storage, restore, download, and pruning support
- add database support for archive snapshot IDs and stored size tracking for backups
- initialize and close the backup archive during onebox lifecycle startup and shutdown
- allow backup download and restore flows to work with archive snapshots as well as legacy file-based backups
- schedule daily archive pruning based on the most generous configured retention policy
- replace smarts3 with smartstorage for registry-backed S3-compatible storage
## 2026-03-21 - 1.23.0 - feat(appstore)
add remote app store templates with service upgrades and Redis/MariaDB platform support
- introduces an App Store manager, API handlers, shared request types, and web UI flow for browsing remote templates and deploying services from template metadata
- tracks app template id and version on services, adds upgrade discovery and migration-based service upgrades, and includes a database migration for template version columns
- adds Redis and MariaDB platform service providers with provisioning plus backup and restore support, and exposes their requirements through service creation and app template config
## 2026-03-18 - 1.22.2 - fix(web-ui)
stabilize app store service creation flow and add Ghost sqlite defaults
- Defers App Store navigation to the services view to avoid destroying the current view during the deploy event handler.
- Processes pending app templates after services view updates so the create flow opens reliably.
- Adds default Ghost environment variables for sqlite3 and the database file path in the App Store template.
- Removes obsolete Gitea CI and npm publish workflow definitions.
## 2026-03-18 - 1.22.1 - fix(repo)
no changes to commit
## 2026-03-18 - 1.22.0 - feat(web-appstore)
add an App Store view for quick service deployment from curated templates
- adds a new App Store tab to the web UI with curated Docker app templates
- passes selected app templates through UI state into the services view for quick deployment
- supports quick deploy creation with prefilled image, port, environment variables, and optional platform service flags
- updates @serve.zone/catalog to ^2.8.0 to support the new app store view
## 2026-03-18 - 1.21.0 - feat(opsserver)
add container workspace API and backend execution environment for services
- introduces typed workspace handlers for reading, writing, listing, creating, removing, and executing commands inside service containers
- adds frontend backend-execution environment integration so the service view can open a workspace against a selected service
- extends Docker exec lookup to resolve Swarm service container IDs when a direct container ID is unavailable
## 2026-03-17 - 1.20.0 - feat(ops-dashboard)
stream user service logs to the ops dashboard and resolve service containers for Docker log streaming
- add typed socket support for pushing live user service log entries to the web app
- extend platform log streaming to include running user services with separate dashboard handlers
- fall back from direct container lookup to service-to-container resolution when streaming Docker logs
- update log parsing to preserve timestamps and infer log levels for service log entries
- bump @serve.zone/catalog to ^2.7.0
## 2026-03-17 - 1.19.12 - fix(repo)
no changes to commit
## 2026-03-17 - 1.19.11 - fix(repo)
no changes to commit
## 2026-03-17 - 1.19.10 - fix(repo)
no changes to commit
## 2026-03-17 - 1.19.9 - fix(repo)
no changes to commit
## 2026-03-17 - 1.19.8 - fix(repo)
no changes to commit
## 2026-03-17 - 1.19.7 - fix(repo)
no changes to commit
## 2026-03-17 - 1.19.6 - fix(repository)
no changes to commit
## 2026-03-17 - 1.19.5 - fix(repo)
no changes to commit
## 2026-03-17 - 1.19.4 - fix(repository)
no changes to commit
## 2026-03-16 - 1.19.3 - fix(repo)
no changes to commit
## 2026-03-16 - 1.19.2 - fix(docs)
remove outdated UI screenshot assets from project documentation
- Deletes multiple PNG screenshots that documented previous dashboard, service form, and hello-world states.
- Reduces repository clutter by removing obsolete image assets no longer needed in docs.
## 2026-03-16 - 1.19.1 - fix(dashboard)
add updated dashboard screenshots for refresh and resource usage states
- Adds new dashboard screenshots covering post-refresh, resource usage, and populated data views.
- Updates visual assets to document current dashboard behavior and UI states.
## 2026-03-16 - 1.19.1 - fix(dashboard)
add aggregated resource usage stats to the dashboard
- Aggregate CPU, memory, and network stats across all running user and platform service containers in getSystemStatus
- Extend ISystemStatus.docker interface with cpuUsage, memoryUsage, memoryTotal, networkIn, networkOut fields
- Fix getContainerStats to properly handle Swarm service IDs by catching exceptions and falling back to label-based container lookup
- Wire dashboard resource usage card to display real aggregated data from the backend
## 2026-03-16 - 1.19.0 - feat(opsserver,web)
add real-time platform service log streaming to the dashboard
- stream running platform service container logs from the ops server to connected dashboard clients via TypedSocket
- parse Docker log timestamps and levels for both pushed and fetched platform service log entries
- enhance the platform service detail view with mapped statuses and predefined host, port, version, and config metadata
- add the typedsocket dependency and update the catalog package for dashboard support
## 2026-03-16 - 1.18.5 - fix(platform-services)
fix platform service detail view navigation and log display
- Add back button to platform service detail view for returning to services list
- Fix DOM lifecycle when switching between platform services (destroy and recreate dees-chart-log)
- Fix timestamp format for log entries to use ISO 8601 for dees-chart-log compatibility
- Clear previous stats/logs state before fetching new platform service data
## 2026-03-16 - 1.18.4 - fix(repo)
no changes to commit
## 2026-03-16 - 1.18.3 - fix(deps)
bump @serve.zone/catalog to ^2.6.1
- Updates the @serve.zone/catalog runtime dependency from ^2.6.0 to ^2.6.1.
## 2026-03-16 - 1.18.2 - fix(repo)
no changes to commit
## 2026-03-16 - 1.18.1 - fix(repo)
no changes to commit
## 2026-03-16 - 1.18.0 - feat(platform-services)
add platform service log retrieval and display in the services UI
- add typed request support in the ops server to fetch Docker logs for platform service containers
- store fetched platform service logs in web app state and load them when opening platform service details
- render platform service logs in the services detail view and add sidebar icons for main navigation tabs
## 2026-03-16 - 1.17.4 - fix(docs)
add hello world running screenshot for documentation
- Adds a new PNG asset showing the application in a running hello world state.
- Supports project documentation or README usage without changing runtime behavior.
## 2026-03-16 - 1.17.3 - fix(mongodb)
downgrade the MongoDB service image to 4.4 and use the legacy mongo shell for container operations
- changes the default MongoDB container image from mongo:7 to mongo:4.4
- replaces mongosh with mongo for health checks, provisioning, and deprovisioning inside the container
## 2026-03-16 - 1.17.2 - fix(platform-services)
provision ClickHouse, MinIO, and MongoDB resources via docker exec instead of host port access
- switch ClickHouse provisioning and teardown to in-container client commands to avoid host port mapping issues
- replace MinIO host-side S3 API calls with in-container mc commands for bucket creation and removal
- run MongoDB provisioning and deprovisioning through mongosh inside the container and improve docker exec failure reporting
## 2026-03-16 - 1.17.1 - fix(repo)
no changes to commit
## 2026-03-16 - 1.17.0 - feat(web/services)
add deploy service action to the services view
- Adds a prominent "Deploy Service" button to the services page header.
- Routes users into the create service view directly from the services listing.
- Includes a new service creation form screenshot asset for the updated interface.
## 2026-03-16 - 1.16.0 - feat(services)
add platform service navigation and stats in the services UI
- add platform service stats state and fetch action
- show platform services in the services list and open a platform detail view
- enable dashboard clicks to jump directly to the selected platform service
- refresh platform service stats after start and restart actions
- bump @serve.zone/catalog to ^2.6.0 for the new platform service UI components
## 2026-03-16 - 1.15.3 - fix(install)
refresh systemd service configuration before restarting previously running installations
- Re-enable the systemd service during updates so unit file changes are applied before restart
- Add a log message indicating the service configuration is being refreshed
## 2026-03-16 - 1.15.2 - fix(systemd)
set HOME and DENO_DIR for the systemd service environment
- Adds HOME=/root to the generated onebox systemd unit
- Adds DENO_DIR=/root/.cache/deno so Deno cache paths are available when running as a service
## 2026-03-16 - 1.15.1 - fix(systemd)
move Docker installation and swarm initialization to systemd enable flow
- Ensures Docker is installed before writing and enabling the systemd unit that depends on docker.service.
- Removes Docker auto-installation from Onebox initialization so setup happens in the service management path.
## 2026-03-16 - 1.15.0 - feat(systemd)
replace smartdaemon-based service management with native systemd commands
- adds a dedicated OneboxSystemd manager for enabling, disabling, starting, stopping, checking status, and following logs
- introduces a new `onebox systemd` CLI command set and updates install and help output to use it
- removes the smartdaemon dependency and related service management code
## 2026-03-16 - 1.14.10 - fix(services)
stop auto-update monitoring during shutdown
- Track the auto-update polling interval in the services manager
- Clear the auto-update interval when Onebox shuts down to prevent background checks after shutdown
## 2026-03-16 - 1.14.9 - fix(repo)
no changes to commit
## 2026-03-16 - 1.14.8 - fix(repo)
no changes to commit
## 2026-03-16 - 1.14.7 - fix(repo)
no changes to commit
## 2026-03-16 - 1.14.6 - fix(project)
no changes to commit
## 2026-03-16 - 1.14.5 - fix(onebox)
move Docker auto-install and swarm initialization into Onebox startup flow
- removes Docker setup from daemon service installation
- ensures Docker is installed before Docker initialization during Onebox startup
- preserves automatic Docker Swarm initialization on fresh servers
## 2026-03-16 - 1.14.4 - fix(repo)
no changes to commit
## 2026-03-16 - 1.14.3 - fix(repo)
no changes to commit
## 2026-03-16 - 1.14.2 - fix(repo)
no changes to commit
## 2026-03-16 - 1.14.1 - fix(repo)
no changes to commit
## 2026-03-16 - 1.14.0 - feat(daemon)
auto-install Docker and initialize Swarm during daemon service setup
- Adds a Docker availability check before installing the Onebox daemon service
- Installs Docker automatically when it is missing using the standard installation script
- Attempts to initialize Docker Swarm after installation and handles already-initialized environments gracefully
## 2026-03-16 - 1.13.17 - fix(ci)
remove forced container image pulling from Gitea workflow jobs
- Drops the `--pull always` container option from CI, npm publish, and release workflows.
- Keeps workflow container images unchanged while avoiding forced pulls on every job run.
## 2026-03-16 - 1.13.16 - fix(ci)
refresh workflow container images on every run and bump @apiclient.xyz/docker to ^5.1.1
- add --pull always to CI, release, and npm publish workflow containers to avoid stale images
- update @apiclient.xyz/docker from ^5.1.0 to ^5.1.1 in deno.json
## 2026-03-15 - 1.13.15 - fix(repo)
no changes to commit
## 2026-03-15 - 1.13.14 - fix(repo)
no changes to commit
## 2026-03-15 - 1.13.13 - fix(repo)
no changes to commit
## 2026-03-15 - 1.13.12 - fix(ci)
run pnpm install with --ignore-scripts in CI and release workflows
- Update CI workflow dependency installation steps to skip lifecycle scripts during builds.
- Apply the same install change to the release workflow for consistent automation behavior.
## 2026-03-15 - 1.13.11 - fix(project)
no changes to commit
## 2026-03-15 - 1.13.10 - fix(deps)
bump @git.zone/tsdeno to ^1.2.0
- Updates the tsdeno development dependency from ^1.1.1 to ^1.2.0.
## 2026-03-15 - 1.13.9 - fix(repo)
no changes to commit
## 2026-03-15 - 1.13.8 - fix(repo)
no changes to commit
## 2026-03-15 - 1.13.7 - fix(repo)
no changes to commit
## 2026-03-15 - 1.13.6 - fix(ci)
correct workflow container image registry path
- Update Gitea CI, release, and npm publish workflows to use the corrected ht-docker-node image path
- Align all workflow container references from hosttoday to host.today to prevent pipeline image resolution issues
## 2026-03-15 - 1.13.5 - fix(workflows)
switch Gitea workflow containers from ht-docker-dbase to ht-docker-node
- Updates the CI, release, and npm publish workflows to use the Node-focused container image consistently.
- Aligns workflow runtime images with the project's Node and Deno build and publish steps.
## 2026-03-15 - 1.13.4 - fix(ci)
run workflows in the shared build container and enable corepack for pnpm installs
- adds the ht-docker-dbase container image to CI, release, and npm publish workflows
- enables corepack before pnpm install in build and release jobs to ensure package manager availability
## 2026-03-15 - 1.13.3 - fix(build)
replace custom Deno compile scripts with tsdeno-based binary builds in CI and release workflows
- adds @git.zone/tsdeno as a dev dependency and configures compile targets in npmextra.json
- updates CI and release workflows to install Node.js dependencies before running tsdeno compile
- removes the legacy scripts/compile-all.sh script and points the compile task to tsdeno compile
## 2026-03-15 - 1.13.2 - fix(scripts)
install production dependencies before compiling binaries and exclude local node_modules from builds
- Adds a dependency installation step using the application entrypoint before cross-platform compilation
- Updates all deno compile targets to use --node-modules-dir=none to avoid bundling local node_modules
## 2026-03-15 - 1.13.1 - fix(deno)
remove nodeModulesDir from Deno configuration
- Drops the explicit nodeModulesDir setting from deno.json.
- Keeps the package version unchanged at 1.13.0 while simplifying runtime configuration.
## 2026-03-15 - 1.13.0 - feat(install)
improve installer with version selection, service restart handling, and upgrade documentation
- Adds installer command-line options for help, specific version selection, and custom install directory.
- Fetches the latest release from the Gitea API when no version is provided and installs the matching platform binary.
- Preserves Onebox data directories, stops and restarts the systemd service during updates, and refreshes installation instructions in the README including upgrade usage.
## 2026-03-15 - 1.12.1 - fix(package.json)
update package metadata
- Single metadata-only file changed (+1, -1)
- No source code or runtime behavior modified; safe patch release
## 2026-03-15 - 1.12.0 - feat(cli,release)
add self-upgrade command and automate CI, release, and npm publishing workflows
- adds a new `onebox upgrade` CLI command that checks the latest release and reinstalls the current binary via the installer script
- introduces Gitea CI workflows for type checks, build verification, multi-platform binary compilation, release creation, and npm publishing
- adds a reusable release template describing installation options, supported platforms, and checksum availability
## 2026-03-03 - 1.11.0 - feat(services)
map backend service data to UI components, add stats & logs parsing, fetch service stats, and fix logs request param
- Fix: rename service logs request property from 'lines' to 'tail' when calling typedRequest
- Add data transformation helpers: formatBytes, parseImageString, mapStatus, toServiceDetail, toServiceStats, parseLogs
- Transform service list and detail props to match @serve.zone/catalog component interfaces (map status, image, repo/tag, timestamps, registry)
- Dispatch fetchServiceStatsAction on service click and surface transformed stats with default values to avoid nulls
- Parse and normalize logs into timestamp/message pairs for the detail view
## 2026-03-02 - 1.10.3 - fix(bin)
make bin/onebox-wrapper.js executable
- Metadata-only change: file mode updated for bin/onebox-wrapper.js to include the executable bit
- No source or behavior changes to the code
## 2026-03-02 - 1.10.2 - fix(build)
update build/watch configuration, switch to esbuild bundler and tswatch, and bump catalog and tooling dependencies
- Switch watch script to 'tswatch' (replaced previous concurrently command invoking deno + tswatch).
- npmextra.json: set bundler to 'esbuild', enable production mode, include html/index.html in the bundle, and extend watchPatterns to include ./html/\*_/_.
- Backend watcher: expanded watch globs and changed command to include --unstable-ffi and runtime flags (--ephemeral --monitor); restart and debounce kept.
- Bump runtime deps: @design.estate/dees-catalog -> ^3.43.3, @serve.zone/catalog -> ^2.5.0.
- Bump devDependencies: @git.zone/tsbundle -> ^2.9.0, @git.zone/tswatch -> ^3.2.0.
## 2026-02-24 - 1.10.1 - fix(package.json)
update package metadata
- Single metadata-only file changed (+1 -1)
- No source code or runtime behavior modified; safe patch release
- Current package version is 1.10.0; recommend patch bump to 1.10.1
## 2026-02-24 - 1.10.0 - feat(opsserver)
introduce OpsServer (TypedRequest API) and new lightweight web UI; replace legacy Angular UI and add typed interfaces
- Add OpsServer (ts/opsserver) with TypedRequest handlers for admin, services, platform, dns, domains, registry, network, backups, schedules, settings and logs.
- Integrate typedrequest/typedserver and smartjwt/smartguard plugins (ts/plugins.ts) and add comprehensive ts_interfaces for requests and data shapes.
- Replace legacy HTTP server usage with OpsServer throughout daemon, Onebox class and CLI (ts/classes/daemon.ts, ts/classes/onebox.ts, ts/cli.ts).
- Implement log streaming via VirtualStream and support for downloading/restoring backups and registry token management within handlers.
- Introduce new web UI built with dees-element web components under ts_web (ob-app-shell and views) and bundle/watch tooling (npmextra.json, tsbundle/tswatch integration).
- Update package.json: add build/watch scripts, tsbundle/tswatch dev deps and new runtime dependencies for typedrequest and catalog components.
- Remove large Angular-based ui application and related services/components in ui/ (major cleanup of Angular code and assets).
- Note: This adds many new endpoints and internal API changes (TypedRequest-based); consumers of the old UI/HTTP endpoints should migrate to the new OpsServer TypedRequest API and web components.
## 2025-12-03 - 1.9.2 - fix(ui)
Add VS Code configs for the UI workspace and normalize dark theme CSS variables
- Add VS Code workspace files under ui/.vscode:
- - extensions.json: recommend the Angular language support extension
- - launch.json: Chrome launch configurations for 'ng serve' and 'ng test' (preLaunchTask hooks)
- - tasks.json: npm 'start' and 'test' tasks with a background TypeScript problem matcher to improve dev workflow
- Update ui/src/styles.css dark theme variables to use neutral black/gray HSL values for background, foreground, cards, popovers, accents, borders, inputs and ring to improve contrast and consistency
## 2025-11-27 - 1.9.1 - fix(ui)
Correct import success toast and add VS Code launch/tasks recommendations for the UI
- Fix backup import success toast in backups-tab.component to reference response.data.service.name (previously response.data.serviceName), preventing incorrect service name display.
- Add VS Code workspace settings for the UI: extensions recommendation, launch configurations for 'ng serve' and 'ng test', and npm tasks for start/test to simplify local development and debugging.
## 2025-11-27 - 1.9.0 - feat(backups)
Add backup import API and improve backup download/import flow in UI
- Backend: add /api/backups/import endpoint to accept multipart file uploads or JSON with a URL and import backups (saves temp file, validates .tar.enc, calls backupManager.restoreBackup in import mode).
- Backend: server-side import handler downloads remote backup URLs, stores temporary file, invokes restore/import logic and cleans up temp files.
- Frontend: add downloadBackup, importBackupFromFile and importBackupFromUrl methods to ApiService; trigger browser download using Blob and object URL with Authorization header.
- Frontend: replace raw download link in service detail UI with a Download button that calls downloadBackup and shows success/error toasts.
- Dev: add VS Code launch, tasks and recommended extensions for the ui workspace to simplify local development.
## 2025-11-27 - 1.8.0 - feat(backup)
Add backup scheduling system with GFS retention, API and UI integration
- Introduce backup scheduling subsystem (BackupScheduler) and integrate it into Onebox lifecycle (init & shutdown)
- Extend BackupManager.createBackup to accept schedule metadata (scheduleId) so scheduled runs are tracked
- Add GFS-style retention policy support (IRetentionPolicy + RETENTION_PRESETS) and expose per-tier retention in types
- Database migrations and repository changes: create backups and backup_schedules tables, add schedule_id, per-tier retention columns, and scope (all/pattern/service) support (migrations up to version 12)
- HTTP API: add backup schedule endpoints (GET/POST/PUT/DELETE /api/backup-schedules), trigger endpoint (/api/backup-schedules/:id/trigger), and service-scoped schedule endpoints
- UI: add API client methods for backup schedules and register a Backups tab in Services UI to surface schedules/backups
- Add task scheduling dependency (@push.rocks/taskbuffer) and export it via plugins.ts; update deno.json accordingly
- Type and repository updates across codebase to support schedule-aware backups, schedule CRUD, and retention enforcement
## 2025-11-27 - 1.7.0 - feat(backup)
Add backup system: BackupManager, DB schema, API endpoints and UI support
Introduce a complete service backup/restore subsystem with encrypted archives, database records and REST endpoints. Implements BackupManager with export/import for service config, platform resources (MongoDB, MinIO, ClickHouse), and Docker images; adds BackupRepository and migrations for backups table and include_image_in_backup; integrates backup flows into the HTTP API and the UI client; exposes backup password management and restore modes (restore/import/clone). Wire BackupManager into Onebox initialization.
- Add BackupManager implementing create/restore/export/import/encrypt/decrypt workflows (service config, platform resource dumps, Docker image export/import) and support for restore modes: restore, import, clone.
- Add BackupRepository and database migrations: create backups table and add include_image_in_backup column to services; database API methods for create/get/list/delete backups.
- Add HTTP API endpoints for backup management: list/create/get/download/delete backups, restore backups (/api/backups/restore) and backup password endpoints (/api/settings/backup-password).
- Update UI ApiService and types: add IBackup, IRestoreOptions, IRestoreResult, IBackupPasswordStatus and corresponding ApiService methods (getBackups, createBackup, getBackup, deleteBackup, getBackupDownloadUrl, restoreBackup, setBackupPassword, checkBackupPassword).
- Expose includeImageInBackup flag on service model and persist it in ServiceRepository (defaults to true for existing rows); service update flow supports toggling this option.
- Integrate BackupManager into Onebox core (initialized in Onebox constructor) and wire HTTP handlers to use the new manager; add DB repository export/import glue so backups are stored and referenced by ID.
## 2025-11-27 - 1.6.0 - feat(ui.dashboard)
Add Resource Usage card to dashboard and make dashboard cards full-height; add VSCode launch/tasks/config
- Introduce ResourceUsageCardComponent and include it as a full-width row in the dashboard layout.
- Make several dashboard card components (Certificates, Traffic, Platform Services) full-height by adding host classes and applying h-full to ui-card elements for consistent card sizing.
- Reflow dashboard rows (insert Resource Usage as a dedicated row and update row numbering) to improve visual layout.
- Add VSCode workspace configuration: recommended Angular extension, launch configurations for ng serve/ng test, and npm tasks to run/start the UI in development.
## 2025-11-27 - 1.5.0 - feat(network) ## 2025-11-27 - 1.5.0 - feat(network)
Add traffic stats endpoint and dashboard UI; enhance platform services and certificate health reporting Add traffic stats endpoint and dashboard UI; enhance platform services and certificate health reporting
- Add /api/network/traffic-stats GET endpoint to the HTTP API with an optional minutes query parameter (validated, 1-60). - Add /api/network/traffic-stats GET endpoint to the HTTP API with an optional minutes query parameter (validated, 1-60).
@@ -12,26 +593,29 @@ Add traffic stats endpoint and dashboard UI; enhance platform services and certi
- Add VSCode workspace launch/tasks recommendations for the UI development environment. - Add VSCode workspace launch/tasks recommendations for the UI development environment.
## 2025-11-26 - 1.4.0 - feat(platform-services) ## 2025-11-26 - 1.4.0 - feat(platform-services)
Add ClickHouse platform service support and improve related healthchecks and tooling Add ClickHouse platform service support and improve related healthchecks and tooling
- Add ClickHouse as a first-class platform service: register provider, provision/cleanup support and env var injection - Add ClickHouse as a first-class platform service: register provider, provision/cleanup support and env var injection
- Expose ClickHouse endpoints in the HTTP API routing (list/get/start/stop/stats) and map default port (8123) - Expose ClickHouse endpoints in the HTTP API routing (list/get/start/stop/stats) and map default port (8123)
- Enable services to request ClickHouse as a platform requirement (enableClickHouse / platformRequirements) during deploy/provision flows - Enable services to request ClickHouse as a platform requirement (enableClickHouse / platformRequirements) during deploy/provision flows
- Fix ClickHouse container health check to use absolute wget path (/usr/bin/wget) for more reliable in-container checks - Fix ClickHouse container health check to use absolute wget path (/usr/bin/wget) for more reliable in-container checks
- Add VS Code workspace launch/tasks/extensions configs for the UI (ui/.vscode/*) to improve local dev experience - Add VS Code workspace launch/tasks/extensions configs for the UI (ui/.vscode/\*) to improve local dev experience
## 2025-11-26 - 1.3.0 - feat(platform-services) ## 2025-11-26 - 1.3.0 - feat(platform-services)
Add ClickHouse platform service support (provider, types, provisioning, UI and port mappings) Add ClickHouse platform service support (provider, types, provisioning, UI and port mappings)
- Introduce ClickHouse as a first-class platform service: added ClickHouseProvider and registered it in PlatformServicesManager - Introduce ClickHouse as a first-class platform service: added ClickHouseProvider and registered it in PlatformServicesManager
- Support provisioning ClickHouse resources for user services and storing encrypted credentials in platform_resources - Support provisioning ClickHouse resources for user services and storing encrypted credentials in platform_resources
- Add ClickHouse to core types (TPlatformServiceType, IPlatformRequirements, IServiceDeployOptions) and service DB handling so services can request ClickHouse - Add ClickHouse to core types (TPlatformServiceType, IPlatformRequirements, IServiceDeployOptions) and service DB handling so services can request ClickHouse
- Inject ClickHouse-related environment variables into deployed services (CLICKHOUSE_* mappings) when provisioning resources - Inject ClickHouse-related environment variables into deployed services (CLICKHOUSE\_\* mappings) when provisioning resources
- Expose ClickHouse default port (8123) in platform port mappings / network targets - Expose ClickHouse default port (8123) in platform port mappings / network targets
- UI: add checkbox and description for enabling ClickHouse during service creation; form now submits enableClickHouse - UI: add checkbox and description for enabling ClickHouse during service creation; form now submits enableClickHouse
- Add VS Code recommendations and launch/tasks for the UI development workflow - Add VS Code recommendations and launch/tasks for the UI development workflow
## 2025-11-26 - 1.2.1 - fix(platform-services/minio) ## 2025-11-26 - 1.2.1 - fix(platform-services/minio)
Improve MinIO provider: reuse existing data and credentials, use host-bound port for provisioning, and safer provisioning/deprovisioning Improve MinIO provider: reuse existing data and credentials, use host-bound port for provisioning, and safer provisioning/deprovisioning
- MinIO provider now detects existing data directory and will reuse stored admin credentials when available instead of regenerating them. - MinIO provider now detects existing data directory and will reuse stored admin credentials when available instead of regenerating them.
@@ -42,15 +626,17 @@ Improve MinIO provider: reuse existing data and credentials, use host-bound port
- Added VSCode workspace files (extensions, launch, tasks) for the ui project to improve developer experience. - Added VSCode workspace files (extensions, launch, tasks) for the ui project to improve developer experience.
## 2025-11-26 - 1.2.0 - feat(ui) ## 2025-11-26 - 1.2.0 - feat(ui)
Sync UI tab state with URL and update routes/links Sync UI tab state with URL and update routes/links
- Add VSCode workspace recommendations, launch and tasks configs for the UI (ui/.vscode/*) - Add VSCode workspace recommendations, launch and tasks configs for the UI (ui/.vscode/\*)
- Update Angular routes to support tab URL segments and default redirects for services, network and registries - Update Angular routes to support tab URL segments and default redirects for services, network and registries
- Change service detail route to use explicit 'detail/:name' path and update links accordingly - Change service detail route to use explicit 'detail/:name' path and update links accordingly
- Make ServicesList, Registries and Network components read tab from route params and navigate on tab changes; add ngOnDestroy to unsubscribe - Make ServicesList, Registries and Network components read tab from route params and navigate on tab changes; add ngOnDestroy to unsubscribe
- Update Domain detail template link to point to the new services detail route - Update Domain detail template link to point to the new services detail route
## 2025-11-26 - 1.1.0 - feat(platform-services) ## 2025-11-26 - 1.1.0 - feat(platform-services)
Add platform service log streaming, improve health checks and provisioning robustness Add platform service log streaming, improve health checks and provisioning robustness
- Add WebSocket log streaming support for platform services (backend + UI) to stream MinIO/MongoDB/Caddy logs in real time - Add WebSocket log streaming support for platform services (backend + UI) to stream MinIO/MongoDB/Caddy logs in real time
@@ -70,6 +656,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Added ### Added
- Initial project structure - Initial project structure
- Core architecture classes - Core architecture classes
- Docker container management - Docker container management
@@ -88,4 +675,5 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [1.0.0] - TBD ## [1.0.0] - TBD
### Added ### Added
- First stable release - First stable release
+20 -14
View File
@@ -1,27 +1,33 @@
{ {
"name": "@serve.zone/onebox", "name": "@serve.zone/onebox",
"version": "1.5.0", "version": "1.25.0",
"exports": "./mod.ts", "exports": "./mod.ts",
"nodeModulesDir": "auto",
"tasks": { "tasks": {
"test": "deno test --allow-all test/", "test": "deno test --allow-all test/",
"test:watch": "deno test --allow-all --watch test/", "test:watch": "deno test --allow-all --watch test/",
"compile": "bash scripts/compile-all.sh", "compile": "tsdeno compile",
"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",
"@push.rocks/smartdaemon": "npm:@push.rocks/smartdaemon@^2.1.0", "@apiclient.xyz/docker": "npm:@apiclient.xyz/docker@^5.1.4",
"@apiclient.xyz/docker": "npm:@apiclient.xyz/docker@^5.1.0", "@apiclient.xyz/cloudflare": "npm:@apiclient.xyz/cloudflare@7.1.0",
"@apiclient.xyz/cloudflare": "npm:@apiclient.xyz/cloudflare@6.4.3", "@push.rocks/smartacme": "npm:@push.rocks/smartacme@^9.5.0",
"@push.rocks/smartacme": "npm:@push.rocks/smartacme@^8.0.0", "@push.rocks/smartregistry": "npm:@push.rocks/smartregistry@^2.9.2",
"@push.rocks/smartregistry": "npm:@push.rocks/smartregistry@^2.2.0", "@push.rocks/smartstorage": "npm:@push.rocks/smartstorage@^6.5.1",
"@push.rocks/smarts3": "npm:@push.rocks/smarts3@^5.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": "npm:@api.global/typedrequest@^3.3.1",
"@api.global/typedserver": "npm:@api.global/typedserver@^8.4.6",
"@push.rocks/smartguard": "npm:@push.rocks/smartguard@^3.1.0",
"@push.rocks/smartjwt": "npm:@push.rocks/smartjwt@^2.2.2",
"@api.global/typedsocket": "npm:@api.global/typedsocket@^4.1.3",
"@serve.zone/containerarchive": "npm:@serve.zone/containerarchive@^0.1.3"
}, },
"compilerOptions": { "compilerOptions": {
"lib": [ "lib": [
+33
View File
@@ -0,0 +1,33 @@
<!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>
+255 -137
View File
@@ -1,155 +1,290 @@
#!/bin/bash #!/bin/bash
# Onebox Installer Script
# Downloads and installs pre-compiled Onebox binary from Gitea releases
# #
# Onebox installer script # Usage:
# Direct piped installation (recommended):
# curl -sSL https://code.foss.global/serve.zone/onebox/raw/branch/main/install.sh | sudo bash
# #
# With version specification:
# curl -sSL https://code.foss.global/serve.zone/onebox/raw/branch/main/install.sh | sudo bash -s -- --version v1.11.0
#
# Options:
# -h, --help Show this help message
# --version VERSION Install specific version (e.g., v1.11.0)
# --install-dir DIR Installation directory (default: /opt/onebox)
set -e set -e
# Configuration # Default values
REPO_URL="https://code.foss.global/serve.zone/onebox" SHOW_HELP=0
SPECIFIED_VERSION=""
INSTALL_DIR="/opt/onebox" INSTALL_DIR="/opt/onebox"
BIN_LINK="/usr/local/bin/onebox" GITEA_BASE_URL="https://code.foss.global"
GITEA_REPO="serve.zone/onebox"
SERVICE_NAME="onebox"
# Colors # Parse command line arguments
RED='\033[0;31m' while [[ $# -gt 0 ]]; do
GREEN='\033[0;32m' case $1 in
YELLOW='\033[1;33m' -h|--help)
NC='\033[0m' # No Color SHOW_HELP=1
shift
# Functions ;;
error() { --version)
echo -e "${RED}Error: $1${NC}" >&2 SPECIFIED_VERSION="$2"
shift 2
;;
--install-dir)
INSTALL_DIR="$2"
shift 2
;;
*)
echo "Unknown option: $1"
echo "Use -h or --help for usage information"
exit 1 exit 1
}
info() {
echo -e "${GREEN}$1${NC}"
}
warn() {
echo -e "${YELLOW}$1${NC}"
}
# Detect platform and architecture
detect_platform() {
OS=$(uname -s | tr '[:upper:]' '[:lower:]')
ARCH=$(uname -m)
case "$OS" in
linux)
PLATFORM="linux"
;;
darwin)
PLATFORM="macos"
;;
*)
error "Unsupported operating system: $OS"
;; ;;
esac esac
done
case "$ARCH" in if [ $SHOW_HELP -eq 1 ]; then
x86_64|amd64) echo "Onebox Installer Script"
ARCH="x64" echo "Downloads and installs pre-compiled Onebox binary"
;; echo ""
aarch64|arm64) echo "Usage: $0 [options]"
ARCH="arm64" echo ""
;; echo "Options:"
*) echo " -h, --help Show this help message"
error "Unsupported architecture: $ARCH" echo " --version VERSION Install specific version (e.g., v1.11.0)"
;; echo " --install-dir DIR Installation directory (default: /opt/onebox)"
esac echo ""
echo "Examples:"
BINARY_NAME="onebox-${PLATFORM}-${ARCH}" echo " # Install latest version"
} echo " curl -sSL https://code.foss.global/serve.zone/onebox/raw/branch/main/install.sh | sudo bash"
echo ""
# Get latest version from Gitea API echo " # Install specific version"
get_latest_version() { echo " curl -sSL https://code.foss.global/serve.zone/onebox/raw/branch/main/install.sh | sudo bash -s -- --version v1.11.0"
info "Fetching latest version..." exit 0
VERSION=$(curl -s "${REPO_URL}/releases" | grep -o '"tag_name":"v[^"]*' | head -1 | cut -d'"' -f4 | cut -c2-)
if [ -z "$VERSION" ]; then
warn "Could not fetch latest version, using 'main' branch"
VERSION="main"
else
info "Latest version: v${VERSION}"
fi fi
}
# Check if running as root # Check if running as root
check_root() {
if [ "$EUID" -ne 0 ]; then if [ "$EUID" -ne 0 ]; then
error "This script must be run as root (use sudo)" echo "Please run as root (sudo bash install.sh or pipe to sudo bash)"
exit 1
fi fi
}
# Download binary # Helper function to detect OS and architecture
download_binary() { detect_platform() {
info "Downloading Onebox ${VERSION} for ${PLATFORM}-${ARCH}..." local os=$(uname -s)
local arch=$(uname -m)
# Create temp directory # Map OS
TMP_DIR=$(mktemp -d) case "$os" in
TMP_FILE="${TMP_DIR}/${BINARY_NAME}" Linux)
os_name="linux"
;;
Darwin)
os_name="macos"
;;
MINGW*|MSYS*|CYGWIN*)
os_name="windows"
;;
*)
echo "Error: Unsupported operating system: $os"
echo "Supported: Linux, macOS, Windows"
exit 1
;;
esac
# Try release download first # Map architecture
if [ "$VERSION" != "main" ]; then case "$arch" in
DOWNLOAD_URL="${REPO_URL}/releases/download/v${VERSION}/${BINARY_NAME}" x86_64|amd64)
arch_name="x64"
;;
aarch64|arm64)
arch_name="arm64"
;;
*)
echo "Error: Unsupported architecture: $arch"
echo "Supported: x86_64/amd64 (x64), aarch64/arm64 (arm64)"
exit 1
;;
esac
# Construct binary name
if [ "$os_name" = "windows" ]; then
echo "onebox-${os_name}-${arch_name}.exe"
else else
DOWNLOAD_URL="${REPO_URL}/raw/branch/main/dist/binaries/${BINARY_NAME}" echo "onebox-${os_name}-${arch_name}"
fi fi
if ! curl -L -f -o "$TMP_FILE" "$DOWNLOAD_URL"; then
error "Failed to download binary from $DOWNLOAD_URL"
fi
# Verify download
if [ ! -f "$TMP_FILE" ] || [ ! -s "$TMP_FILE" ]; then
error "Downloaded file is empty or missing"
fi
info "✓ Download complete"
} }
# Install binary # Get latest release version from Gitea API
install_binary() { get_latest_version() {
info "Installing Onebox to ${INSTALL_DIR}..." echo "Fetching latest release version from Gitea..." >&2
# Create install directory local api_url="${GITEA_BASE_URL}/api/v1/repos/${GITEA_REPO}/releases/latest"
local response=$(curl -sSL "$api_url" 2>/dev/null)
if [ $? -ne 0 ] || [ -z "$response" ]; then
echo "Error: Failed to fetch latest release information from Gitea API" >&2
echo "URL: $api_url" >&2
exit 1
fi
# Extract tag_name from JSON response
local version=$(echo "$response" | grep -o '"tag_name":"[^"]*"' | cut -d'"' -f4)
if [ -z "$version" ]; then
echo "Error: Could not determine latest version from API response" >&2
exit 1
fi
echo "$version"
}
# Main installation process
echo "================================================"
echo " Onebox Installation Script"
echo "================================================"
echo ""
# Detect platform
BINARY_NAME=$(detect_platform)
echo "Detected platform: $BINARY_NAME"
echo ""
# Determine version to install
if [ -n "$SPECIFIED_VERSION" ]; then
VERSION="$SPECIFIED_VERSION"
echo "Installing specified version: $VERSION"
else
VERSION=$(get_latest_version)
echo "Installing latest version: $VERSION"
fi
echo ""
# Construct download URL
DOWNLOAD_URL="${GITEA_BASE_URL}/${GITEA_REPO}/releases/download/${VERSION}/${BINARY_NAME}"
echo "Download URL: $DOWNLOAD_URL"
echo ""
# Check if service is running and stop it
SERVICE_WAS_RUNNING=0
if systemctl is-enabled --quiet "$SERVICE_NAME" 2>/dev/null || systemctl is-active --quiet "$SERVICE_NAME" 2>/dev/null; then
SERVICE_WAS_RUNNING=1
if systemctl is-active --quiet "$SERVICE_NAME" 2>/dev/null; then
echo "Stopping Onebox service..."
systemctl stop "$SERVICE_NAME"
fi
fi
# Clean installation directory - ensure only binary exists
if [ -d "$INSTALL_DIR" ]; then
echo "Cleaning installation directory: $INSTALL_DIR"
rm -rf "$INSTALL_DIR"
fi
# Create fresh installation directory
echo "Creating installation directory: $INSTALL_DIR"
mkdir -p "$INSTALL_DIR" mkdir -p "$INSTALL_DIR"
# Copy binary # Download binary
cp "$TMP_FILE" "${INSTALL_DIR}/onebox" echo "Downloading Onebox binary..."
chmod +x "${INSTALL_DIR}/onebox" TEMP_FILE="$INSTALL_DIR/onebox.download"
curl -sSL "$DOWNLOAD_URL" -o "$TEMP_FILE"
# Create symlink if [ $? -ne 0 ]; then
ln -sf "${INSTALL_DIR}/onebox" "$BIN_LINK" echo "Error: Failed to download binary from $DOWNLOAD_URL"
echo ""
echo "Please check:"
echo " 1. Your internet connection"
echo " 2. The specified version exists: ${GITEA_BASE_URL}/${GITEA_REPO}/releases"
echo " 3. The platform binary is available for this release"
rm -f "$TEMP_FILE"
exit 1
fi
# Cleanup temp files # Check if download was successful (file exists and not empty)
rm -rf "$TMP_DIR" if [ ! -s "$TEMP_FILE" ]; then
echo "Error: Downloaded file is empty or does not exist"
rm -f "$TEMP_FILE"
exit 1
fi
info "✓ Installation complete" # Move to final location
} BINARY_PATH="$INSTALL_DIR/onebox"
mv "$TEMP_FILE" "$BINARY_PATH"
# Initialize database and config if [ $? -ne 0 ] || [ ! -f "$BINARY_PATH" ]; then
initialize() { echo "Error: Failed to move binary to $BINARY_PATH"
info "Initializing Onebox..." rm -f "$TEMP_FILE" 2>/dev/null
exit 1
fi
# Create data directory # Make executable
chmod +x "$BINARY_PATH"
if [ $? -ne 0 ]; then
echo "Error: Failed to make binary executable"
exit 1
fi
echo "Binary installed successfully to: $BINARY_PATH"
echo ""
# Check if /usr/local/bin is in PATH
if [[ ":$PATH:" == *":/usr/local/bin:"* ]]; then
BIN_DIR="/usr/local/bin"
else
BIN_DIR="/usr/bin"
fi
# Create symlink for global access
ln -sf "$BINARY_PATH" "$BIN_DIR/onebox"
echo "Symlink created: $BIN_DIR/onebox -> $BINARY_PATH"
echo ""
# Create data directories
mkdir -p /var/lib/onebox mkdir -p /var/lib/onebox
# Create certbot directory for ACME challenges
mkdir -p /var/www/certbot mkdir -p /var/www/certbot
info "✓ Initialization complete" # Re-enable and restart service if it was previously running (refreshes unit file)
} if [ $SERVICE_WAS_RUNNING -eq 1 ]; then
echo "Refreshing systemd service..."
onebox systemd enable
echo "Restarting Onebox service..."
systemctl restart "$SERVICE_NAME"
echo "Service restarted successfully."
echo ""
fi
# Print success message echo "================================================"
print_success() { echo " Onebox Installation Complete!"
echo "================================================"
echo "" echo ""
info "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "Installation details:"
info " Onebox installed successfully!" echo " Binary location: $BINARY_PATH"
info "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo " Symlink location: $BIN_DIR/onebox"
echo " Version: $VERSION"
echo "" echo ""
echo "Next steps:"
# Check if database exists (indicates existing installation)
if [ -f "/var/lib/onebox/onebox.db" ]; then
echo "Data directory: /var/lib/onebox (preserved)"
echo ""
echo "Your existing data has been preserved."
if [ $SERVICE_WAS_RUNNING -eq 1 ]; then
echo "The service has been restarted with your current settings."
else
echo "Start the service with: onebox systemd start"
fi
else
echo "Get started:"
echo ""
echo " onebox --version"
echo " onebox --help"
echo "" echo ""
echo " 1. Configure Cloudflare (optional):" echo " 1. Configure Cloudflare (optional):"
echo " onebox config set cloudflareAPIKey <key>" echo " onebox config set cloudflareAPIKey <key>"
@@ -160,33 +295,16 @@ print_success() {
echo " 2. Configure ACME email:" echo " 2. Configure ACME email:"
echo " onebox config set acmeEmail <your@email.com>" echo " onebox config set acmeEmail <your@email.com>"
echo "" echo ""
echo "3. Install daemon:" echo " 3. Enable systemd service:"
echo " onebox daemon install" echo " onebox systemd enable"
echo "" echo ""
echo "4. Start daemon:" echo " 4. Start service:"
echo " onebox daemon start" echo " onebox systemd start"
echo "" echo ""
echo " 5. Deploy your first service:" echo " 5. Deploy your first service:"
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
echo "" echo ""
}
# Main installation flow
main() {
info "Onebox Installer"
echo ""
check_root
detect_platform
get_latest_version
download_binary
install_binary
initialize
print_success
}
# Run main function
main
+19 -5
View File
@@ -1,6 +1,6 @@
{ {
"name": "@serve.zone/onebox", "name": "@serve.zone/onebox",
"version": "1.5.0", "version": "1.25.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",
@@ -9,7 +9,9 @@
}, },
"scripts": { "scripts": {
"postinstall": "node scripts/install-binary.js", "postinstall": "node scripts/install-binary.js",
"watch": "concurrently --kill-others --names \"BACKEND,UI\" --prefix-colors \"cyan,magenta\" \"deno run --allow-all --unstable-ffi --watch mod.ts server --ephemeral --monitor\" \"cd ui && pnpm run watch\"" "watch": "tswatch",
"build": "tsbundle",
"bundle": "tsbundle"
}, },
"keywords": [ "keywords": [
"docker", "docker",
@@ -24,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",
@@ -51,8 +53,20 @@
"arm64" "arm64"
], ],
"packageManager": "pnpm@10.18.1+sha512.77a884a165cbba2d8d1c19e3b4880eee6d2fcabd0d879121e282196b80042351d5eb3ca0935fa599da1dc51265cc68816ad2bddd2a2de5ea9fdf92adbec7cd34", "packageManager": "pnpm@10.18.1+sha512.77a884a165cbba2d8d1c19e3b4880eee6d2fcabd0d879121e282196b80042351d5eb3ca0935fa599da1dc51265cc68816ad2bddd2a2de5ea9fdf92adbec7cd34",
"dependencies": {}, "dependencies": {
"@api.global/typedrequest-interfaces": "^3.0.19",
"@api.global/typedsocket": "^4.1.3",
"@design.estate/dees-catalog": "^3.81.0",
"@design.estate/dees-element": "^2.2.4",
"@serve.zone/catalog": "^2.12.4"
},
"devDependencies": { "devDependencies": {
"concurrently": "^9.1.2" "@git.zone/tsbundle": "^2.10.1",
"@git.zone/tsdeno": "^1.3.1",
"@git.zone/tswatch": "^3.3.3"
},
"private": true,
"pnpm": {
"overrides": {}
} }
} }
+4963 -81
View File
File diff suppressed because it is too large Load Diff
+25 -16
View File
@@ -3,6 +3,7 @@
## SSL Certificate Storage (November 2025) ## SSL Certificate Storage (November 2025)
SSL certificates are now stored directly in the SQLite database as PEM content instead of file paths: SSL certificates are now stored directly in the SQLite database as PEM content instead of file paths:
- `ISslCertificate` and `ICertificate` interfaces use `certPem`, `keyPem`, `fullchainPem` properties - `ISslCertificate` and `ICertificate` interfaces use `certPem`, `keyPem`, `fullchainPem` properties
- Database migration 8 converted the `certificates` table schema - Database migration 8 converted the `certificates` table schema
- No filesystem storage for certificates - everything in DB - No filesystem storage for certificates - everything in DB
@@ -16,6 +17,7 @@ SSL certificates are now stored directly in the SQLite database as PEM content i
The database layer has been refactored into a repository pattern: The database layer has been refactored into a repository pattern:
**Directory Structure:** **Directory Structure:**
``` ```
ts/database/ ts/database/
├── index.ts # Main OneboxDatabase class (composes repositories, handles migrations) ├── index.ts # Main OneboxDatabase class (composes repositories, handles migrations)
@@ -32,52 +34,59 @@ ts/database/
``` ```
**Import paths:** **Import paths:**
- Main: `import { OneboxDatabase } from './database/index.ts'` - Main: `import { OneboxDatabase } from './database/index.ts'`
- Legacy (deprecated): `import { OneboxDatabase } from './classes/database.ts'` (re-exports from new location) - Legacy (deprecated): `import { OneboxDatabase } from './classes/database.ts'` (re-exports from new location)
**API Compatibility:** **API Compatibility:**
- The `OneboxDatabase` class maintains the same public API - The `OneboxDatabase` class maintains the same public API
- 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 core reverse proxy platform service from `caddy` to `smartproxy`.
## Reverse Proxy (November 2025 - Caddy Docker Service) ## Reverse Proxy (April 2026 - SmartProxy Docker Service)
The reverse proxy uses **Caddy** running as a Docker Swarm service for production-grade reverse proxying with native SNI support, HTTP/2, HTTP/3, and WebSocket handling. The reverse proxy uses **SmartProxy** running as a Docker Swarm service for production-grade reverse proxying with TLS termination and WebSocket handling.
**Architecture:** **Architecture:**
- Caddy runs as Docker Swarm service (`onebox-caddy`) on the overlay network
- No binary download required - uses `caddy:2-alpine` Docker image - SmartProxy runs as Docker Swarm service (`onebox-smartproxy`) on the overlay network
- Configuration pushed dynamically via Caddy Admin API (port 2019) - No host binary download required - uses `code.foss.global/host.today/ht-docker-smartproxy:latest`
- Routes are pushed dynamically via the SmartProxy admin API (host port 2019)
- Automatic HTTPS disabled - certificates managed externally via SmartACME - 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/reverseproxy.ts` - Delegates to CaddyManager - `ts/classes/smartproxy.ts` - SmartProxyManager class for Docker service and Admin API
- `ts/classes/reverseproxy.ts` - Delegates to SmartProxyManager
**Certificate workflow:** **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`
- Image: `caddy:2-alpine` - Service name: `onebox-smartproxy`
- 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:**
- Dev mode: HTTP on 8080, HTTPS on 8443, Admin on 2019 - Dev mode: HTTP on 8080, HTTPS on 8443, Admin on 2019
- Production: HTTP on 80, HTTPS on 443, Admin on 2019 - Production: HTTP on 80, HTTPS on 443, Admin on 2019
- All ports use `PublishMode: 'host'` for direct binding - All ports use `PublishMode: 'host'` for direct binding
**Log Receiver:** **Log Receiver:**
- Caddy sends access logs to `tcp/172.17.0.1:9999` (Docker bridge gateway)
- `CaddyLogReceiver` on host receives and processes logs - `ProxyLogReceiver` remains the host-side access-log stream endpoint for proxy log integrations
+187 -516
View File
@@ -1,582 +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
- 🌐 **Caddy Reverse Proxy** - Production-grade proxy running as Docker service with SNI, HTTP/2, HTTP/3
- 🔒 **Automatic SSL Certificates** - Let's Encrypt integration with hot-reload and renewal monitoring
- ☁️ **Cloudflare DNS Integration** - Automatic DNS record creation and zone synchronization
- 📦 **Built-in Registry** - Private Docker registry with per-service tokens and auto-update
- 🔄 **Real-time WebSocket Updates** - Live service status, logs, and system events
### Monitoring & Management ```text
- 📊 **Metrics Collection** - Historical CPU, memory, and network stats (every 60s) browser / CLI
- 📝 **Centralized Logging** - Container logs with streaming and retention policies |
- 🎨 **Angular Web UI** - Modern, responsive interface with real-time updates v
- 👥 **Multi-user Support** - Role-based access control (admin/user) OpsServer :3000
- 💾 **SQLite Database** - Embedded, zero-configuration storage - bundled web UI
- TypedRequest handlers
### Developer Experience - TypedSocket dashboard events
- 🚀 **Auto-update on Push** - Push to registry and services update automatically |
- 🔐 **Private Registry Support** - Use Docker Hub, Gitea, or custom registries v
- 🔄 **Systemd Integration** - Run as a daemon with auto-restart Onebox coordinator
- 🎛️ **Full CLI & API** - Manage everything from terminal or HTTP API - SQLite repositories
- Docker manager
## Quick Start 🏁 - SmartProxy route manager
- DNS and SSL managers
### Installation - platform service providers
- app store manager
```bash - backup manager and scheduler
# Download the latest release for your platform |
curl -sSL https://code.foss.global/serve.zone/onebox/releases/latest/download/onebox-linux-x64 -o onebox v
chmod +x onebox Docker host
sudo mv onebox /usr/local/bin/ - onebox-network
- SmartProxy
# Or install from npm - user services
pnpm install -g @serve.zone/onebox - optional platform services
``` ```
### First Run `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.
## Installation
Install the released binary:
```bash
curl -sSL https://code.foss.global/serve.zone/onebox/raw/branch/main/install.sh | sudo bash
```
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`
- Password: `admin`
⚠️ **Change the default password immediately after first login!** ```text
username: admin
password: admin
```
### Production Setup Change the default password immediately after first login.
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 catalog data from `serve.zone/appstore-apptemplates` and caches it briefly. Templates can declare platform requirements, so installing an app can automatically provision MongoDB, S3-compatible storage, ClickHouse, Redis, or MariaDB resources and inject the resulting credentials as environment variables.
```bash ```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
```
## 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.
-56
View File
@@ -1,56 +0,0 @@
#!/bin/bash
#
# Compile Onebox for all platforms
#
set -e
VERSION=$(grep '"version"' deno.json | cut -d'"' -f4)
echo "Compiling Onebox v${VERSION} for all platforms..."
# Create dist directory
mkdir -p dist/binaries
# Compile for each platform
echo "Compiling for Linux x64..."
deno compile --allow-all --no-check \
--output "dist/binaries/onebox-linux-x64" \
--target x86_64-unknown-linux-gnu \
mod.ts
echo "Compiling for Linux ARM64..."
deno compile --allow-all --no-check \
--output "dist/binaries/onebox-linux-arm64" \
--target aarch64-unknown-linux-gnu \
mod.ts
echo "Compiling for macOS x64..."
deno compile --allow-all --no-check \
--output "dist/binaries/onebox-macos-x64" \
--target x86_64-apple-darwin \
mod.ts
echo "Compiling for macOS ARM64..."
deno compile --allow-all --no-check \
--output "dist/binaries/onebox-macos-arm64" \
--target aarch64-apple-darwin \
mod.ts
echo "Compiling for Windows x64..."
deno compile --allow-all --no-check \
--output "dist/binaries/onebox-windows-x64.exe" \
--target x86_64-pc-windows-msvc \
mod.ts
echo ""
echo "✓ Compilation complete!"
echo ""
echo "Binaries:"
ls -lh dist/binaries/
echo ""
echo "Next steps:"
echo "1. Test binaries on their respective platforms"
echo "2. Create git tag: git tag v${VERSION}"
echo "3. Push tag: git push origin v${VERSION}"
echo "4. Upload binaries to Gitea release"
echo "5. Publish to npm: pnpm publish"
+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);
});
+216
View File
@@ -0,0 +1,216 @@
import { assert, assertEquals } from '@std/assert';
import { ExternalGatewayManager } from '../ts/classes/external-gateway.ts';
import type { IDomain, IService, ISslCertificate } from '../ts/types.ts';
class FakeDatabase {
public settings = new Map<string, string>();
public secretSettings = new Map<string, string>();
public domains: IDomain[] = [];
public certificates = new Map<string, ISslCertificate>();
private nextDomainId = 1;
getSetting(key: string): string | null {
return this.settings.get(key) ?? null;
}
setSetting(key: string, value: string): void {
this.settings.set(key, value);
}
async getSecretSetting(key: string): Promise<string | null> {
return this.secretSettings.get(key) ?? null;
}
getDomainByName(domain: string): IDomain | null {
return this.domains.find((entry) => entry.domain === domain) ?? null;
}
createDomain(domain: Omit<IDomain, 'id'>): IDomain {
const createdDomain = { ...domain, id: this.nextDomainId++ };
this.domains.push(createdDomain);
return createdDomain;
}
updateDomain(id: number, updates: Partial<IDomain>): void {
const index = this.domains.findIndex((entry) => entry.id === id);
if (index === -1) return;
this.domains[index] = { ...this.domains[index], ...updates };
}
getDomainsByProvider(provider: NonNullable<IDomain['dnsProvider']>): IDomain[] {
return this.domains.filter((entry) => entry.dnsProvider === provider);
}
getSSLCertificate(domain: string): ISslCertificate | null {
return this.certificates.get(domain) ?? null;
}
updateSSLCertificate(domain: string, updates: Partial<ISslCertificate>): void {
const existing = this.certificates.get(domain);
if (!existing) return;
this.certificates.set(domain, { ...existing, ...updates });
}
async createSSLCertificate(cert: Omit<ISslCertificate, 'id'>): Promise<ISslCertificate> {
const storedCert = { ...cert, id: this.certificates.size + 1 };
this.certificates.set(cert.domain, storedCert);
return storedCert;
}
}
const makeOneboxRef = () => {
const database = new FakeDatabase();
database.settings.set('dcrouterGatewayUrl', 'https://edge.example.com');
database.settings.set('dcrouterGatewayClientId', 'onebox-1');
database.settings.set('dcrouterWorkHosterId', 'onebox-1');
database.secretSettings.set('dcrouterGatewayApiToken', 'dcr-token');
let reloadCount = 0;
return {
database,
reverseProxy: {
reloadCertificates: async () => {
reloadCount++;
},
get reloadCount() {
return reloadCount;
},
},
};
};
Deno.test('ExternalGatewayManager syncs dcrouter domains into Onebox domains', async () => {
const oneboxRef = makeOneboxRef();
oneboxRef.database.domains.push({
id: 99,
domain: 'old.example.com',
dnsProvider: 'dcrouter',
isObsolete: false,
defaultWildcard: true,
createdAt: 1,
updatedAt: 1,
});
const manager = new ExternalGatewayManager(oneboxRef as any);
(manager as any).fireDcRouterRequest = async (method: string, requestData: Record<string, unknown>) => {
assertEquals(method, 'getGatewayClientDomains');
assertEquals(requestData.gatewayClientId, 'onebox-1');
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>) => {
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-1',
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 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>) => {
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-1');
assertEquals((capturedDeleteRequest.ownership as any).hostname, 'hello.example.com');
});
Deno.test('ExternalGatewayManager imports exported dcrouter certificates into Onebox', async () => {
const oneboxRef = makeOneboxRef();
const manager = new ExternalGatewayManager(oneboxRef as any);
(manager as any).fireDcRouterRequest = async (method: string, requestData: Record<string, unknown>) => {
assertEquals(method, 'exportCertificate');
assertEquals(requestData.domain, 'hello.example.com');
return {
success: true,
cert: {
id: 'cert-1',
domainName: 'hello.example.com',
created: 1,
validUntil: 2,
privateKey: '-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----',
publicKey: '-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----',
csr: '',
},
};
};
const imported = await manager.importCertificateForDomain('hello.example.com');
assert(imported);
assertEquals(oneboxRef.database.getSSLCertificate('hello.example.com')?.issuer, 'dcrouter');
assertEquals(oneboxRef.reverseProxy.reloadCount, 1);
});
+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.5.0', version: '1.25.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
@@ -0,0 +1,73 @@
/**
* 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;
}
+335
View File
@@ -0,0 +1,335 @@
/**
* App Store Manager
* Fetches, caches, and serves app templates from the remote appstore-apptemplates repo.
* The remote repo is the single source of truth — no fallback catalog.
*/
import type {
ICatalog,
ICatalogApp,
IAppMeta,
IAppVersionConfig,
IMigrationContext,
IMigrationResult,
IUpgradeableService,
} from './appstore-types.ts';
import { logger } from '../logging.ts';
import { getErrorMessage } from '../utils/error.ts';
import type { Onebox } from './onebox.ts';
import type { IService } from '../types.ts';
export class AppStoreManager {
private oneboxRef: Onebox;
private catalogCache: ICatalog | null = null;
private lastFetchTime = 0;
private readonly repoBaseUrl = 'https://code.foss.global/serve.zone/appstore-apptemplates/raw/branch/main';
private readonly cacheTtlMs = 5 * 60 * 1000; // 5 minutes
constructor(oneboxRef: Onebox) {
this.oneboxRef = oneboxRef;
}
async init(): Promise<void> {
try {
await this.getCatalog();
logger.info(`App Store initialized with ${this.catalogCache?.apps.length || 0} templates`);
} catch (error) {
logger.warn(`App Store initialization failed: ${getErrorMessage(error)}`);
logger.warn('App Store will retry on next request');
}
}
/**
* Get the catalog (cached, refreshes after TTL)
*/
async getCatalog(): Promise<ICatalog> {
const now = Date.now();
if (this.catalogCache && (now - this.lastFetchTime) < this.cacheTtlMs) {
return this.catalogCache;
}
try {
const catalog = await this.fetchJson('catalog.json') as ICatalog;
if (catalog && catalog.apps && Array.isArray(catalog.apps)) {
this.catalogCache = catalog;
this.lastFetchTime = now;
return catalog;
}
throw new Error('Invalid catalog format');
} catch (error) {
logger.warn(`Failed to fetch remote catalog: ${getErrorMessage(error)}`);
// Return cached if available, otherwise return empty catalog
if (this.catalogCache) {
return this.catalogCache;
}
return { schemaVersion: 1, updatedAt: '', apps: [] };
}
}
/**
* Get the catalog apps list (convenience method for the API)
*/
async getApps(): Promise<ICatalogApp[]> {
const catalog = await this.getCatalog();
return catalog.apps;
}
/**
* Fetch app metadata (versions list, etc.)
*/
async getAppMeta(appId: string): Promise<IAppMeta> {
try {
return await this.fetchJson(`apps/${appId}/app.json`) as IAppMeta;
} catch (error) {
throw new Error(`Failed to fetch metadata for app '${appId}': ${getErrorMessage(error)}`);
}
}
/**
* Fetch full config for an app version
*/
async getAppVersionConfig(appId: string, version: string): Promise<IAppVersionConfig> {
try {
return await this.fetchJson(`apps/${appId}/versions/${version}/config.json`) as IAppVersionConfig;
} catch (error) {
throw new Error(`Failed to fetch config for ${appId}@${version}: ${getErrorMessage(error)}`);
}
}
/**
* Compare deployed services against catalog to find those with available upgrades
*/
async getUpgradeableServices(): Promise<IUpgradeableService[]> {
const catalog = await this.getCatalog();
const services = this.oneboxRef.database.getAllServices();
const upgradeable: IUpgradeableService[] = [];
for (const service of services) {
if (!service.appTemplateId || !service.appTemplateVersion) continue;
const catalogApp = catalog.apps.find(a => a.id === service.appTemplateId);
if (!catalogApp) 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({
serviceName: service.name,
appTemplateId: service.appTemplateId,
currentVersion: service.appTemplateVersion,
latestVersion: catalogApp.latestVersion,
hasMigration,
});
}
}
return upgradeable;
}
/**
* Check if a migration script exists for a specific version transition
*/
async hasMigrationScript(appId: string, fromVersion: string, toVersion: string): Promise<boolean> {
try {
const scriptPath = `apps/${appId}/versions/${toVersion}/migrate-from-${fromVersion}.ts`;
await this.fetchText(scriptPath);
return true;
} catch {
return false;
}
}
/**
* Execute a migration in a sandboxed Deno child process
*/
async executeMigration(service: IService, fromVersion: string, toVersion: string): Promise<IMigrationResult> {
const appId = service.appTemplateId;
if (!appId) {
throw new Error('Service has no appTemplateId');
}
// Fetch the migration script
const scriptPath = `apps/${appId}/versions/${toVersion}/migrate-from-${fromVersion}.ts`;
let scriptContent: string;
try {
scriptContent = await this.fetchText(scriptPath);
} catch {
// No migration script — do a simple config-based upgrade
logger.info(`No migration script for ${appId} ${fromVersion} -> ${toVersion}, using config-only upgrade`);
const config = await this.getAppVersionConfig(appId, toVersion);
return {
success: true,
image: config.image,
envVars: undefined, // Keep existing env vars
warnings: [],
};
}
// Write to temp file
const tempFile = `/tmp/onebox-migration-${crypto.randomUUID()}.ts`;
await Deno.writeTextFile(tempFile, scriptContent);
try {
// Prepare context
const context: IMigrationContext = {
service: {
name: service.name,
image: service.image,
envVars: service.envVars,
port: service.port,
},
fromVersion,
toVersion,
};
// Execute in sandboxed Deno child process
const cmd = new Deno.Command('deno', {
args: ['run', '--allow-env', '--allow-net=none', '--allow-read=none', '--allow-write=none', tempFile],
stdin: 'piped',
stdout: 'piped',
stderr: 'piped',
});
const child = cmd.spawn();
// Write context to stdin
const writer = child.stdin.getWriter();
await writer.write(new TextEncoder().encode(JSON.stringify(context)));
await writer.close();
// Read result
const output = await child.output();
const exitCode = output.code;
const stdout = new TextDecoder().decode(output.stdout);
const stderr = new TextDecoder().decode(output.stderr);
if (exitCode !== 0) {
logger.error(`Migration script failed (exit ${exitCode}): ${stderr.substring(0, 500)}`);
return {
success: false,
warnings: [`Migration script failed: ${stderr.substring(0, 200)}`],
};
}
// Parse result from stdout
try {
const result = JSON.parse(stdout) as IMigrationResult;
result.success = true;
return result;
} catch {
logger.error(`Failed to parse migration output: ${stdout.substring(0, 200)}`);
return {
success: false,
warnings: ['Migration script produced invalid output'],
};
}
} finally {
// Cleanup temp file
try {
await Deno.remove(tempFile);
} catch {
// Ignore cleanup errors
}
}
}
/**
* Apply an upgrade: update image, env vars, recreate container
*/
async applyUpgrade(
serviceName: string,
migrationResult: IMigrationResult,
newVersion: string,
): Promise<IService> {
const service = this.oneboxRef.database.getServiceByName(serviceName);
if (!service) {
throw new Error(`Service not found: ${serviceName}`);
}
// Stop the existing container
if (service.containerID && service.status === 'running') {
await this.oneboxRef.services.stopService(serviceName);
}
// Update service record
const updates: Partial<IService> = {
appTemplateVersion: newVersion,
};
if (migrationResult.image) {
updates.image = migrationResult.image;
}
if (migrationResult.envVars) {
// Merge: migration result provides base, user overrides preserved
const mergedEnvVars = { ...migrationResult.envVars };
// Keep any user-set env vars that aren't in the migration result
for (const [key, value] of Object.entries(service.envVars)) {
if (!(key in mergedEnvVars)) {
mergedEnvVars[key] = value;
}
}
updates.envVars = mergedEnvVars;
}
this.oneboxRef.database.updateService(service.id!, updates);
// Pull new image if changed
const newImage = migrationResult.image || service.image;
if (migrationResult.image && migrationResult.image !== service.image) {
await this.oneboxRef.docker.pullImage(newImage);
}
// Recreate and start container
const updatedService = this.oneboxRef.database.getServiceByName(serviceName)!;
// Remove old container
if (service.containerID) {
try {
await this.oneboxRef.docker.removeContainer(service.containerID, true);
} catch {
// Container might already be gone
}
}
// Create new container
const containerID = await this.oneboxRef.docker.createContainer(updatedService);
this.oneboxRef.database.updateService(service.id!, { containerID, status: 'starting' });
// Start container
await this.oneboxRef.docker.startContainer(containerID);
this.oneboxRef.database.updateService(service.id!, { status: 'running' });
logger.success(`Service '${serviceName}' upgraded to template version ${newVersion}`);
return this.oneboxRef.database.getServiceByName(serviceName)!;
}
/**
* Fetch JSON from the remote repo
*/
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();
}
/**
* Fetch text from the remote repo
*/
private async fetchText(path: string): Promise<string> {
const url = `${this.repoBaseUrl}/${path}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status} for ${url}`);
}
return response.text();
}
}
File diff suppressed because it is too large Load Diff
+705
View File
@@ -0,0 +1,705 @@
/**
* Backup Scheduler for Onebox
*
* Uses @push.rocks/taskbuffer for cron-based scheduled backups
* with GFS (Grandfather-Father-Son) time-window based retention scheme.
*/
import * as plugins from '../plugins.ts';
import type {
IBackupSchedule,
IBackupScheduleCreate,
IBackupScheduleUpdate,
IService,
IRetentionPolicy,
} from '../types.ts';
import { RETENTION_PRESETS } from '../types.ts';
import { logger } from '../logging.ts';
import { getErrorMessage } from '../utils/error.ts';
import type { Onebox } from './onebox.ts';
export class BackupScheduler {
private oneboxRef: Onebox;
private taskManager!: plugins.taskbuffer.TaskManager;
private scheduledTasks: Map<number, plugins.taskbuffer.Task> = new Map();
private initialized = false;
constructor(oneboxRef: Onebox) {
this.oneboxRef = oneboxRef;
// TaskManager is created in init() to avoid log spam before ready
}
/**
* Initialize the scheduler and load enabled schedules
*/
async init(): Promise<void> {
if (this.initialized) {
logger.warn('BackupScheduler already initialized');
return;
}
try {
// Create TaskManager here (not in constructor) to avoid "no cronjobs" log spam
this.taskManager = new plugins.taskbuffer.TaskManager();
// Add heartbeat task immediately to prevent "no cronjobs specified" log spam
// This runs hourly and does nothing, but keeps taskbuffer happy
const heartbeatTask = new plugins.taskbuffer.Task({
name: 'backup-scheduler-heartbeat',
taskFunction: async () => {
// No-op heartbeat task
},
});
this.taskManager.addAndScheduleTask(heartbeatTask, '0 * * * *'); // Hourly
// Load all enabled schedules from database
const schedules = this.oneboxRef.database.getEnabledBackupSchedules();
for (const schedule of schedules) {
await this.registerTask(schedule);
}
// Add periodic archive prune task (runs daily at 3 AM)
const pruneTask = new plugins.taskbuffer.Task({
name: 'backup-archive-prune',
taskFunction: async () => {
await this.pruneArchive();
},
});
this.taskManager.addAndScheduleTask(pruneTask, '0 3 * * *');
// Start the task manager (activates cron scheduling)
await this.taskManager.start();
this.initialized = true;
logger.info(`Backup scheduler started with ${schedules.length} enabled schedule(s)`);
} catch (error) {
logger.error(`Failed to initialize backup scheduler: ${getErrorMessage(error)}`);
throw error;
}
}
/**
* Stop the scheduler
*/
async stop(): Promise<void> {
if (!this.initialized || !this.taskManager) return;
try {
await this.taskManager.stop();
this.scheduledTasks.clear();
this.initialized = false;
logger.info('Backup scheduler stopped');
} catch (error) {
logger.error(`Failed to stop backup scheduler: ${getErrorMessage(error)}`);
}
}
/**
* Create a new backup schedule
*/
async createSchedule(request: IBackupScheduleCreate): Promise<IBackupSchedule> {
// Validate based on scope type
let serviceId: number | undefined;
let serviceName: string | undefined;
switch (request.scopeType) {
case 'service':
// Validate service exists
if (!request.serviceName) {
throw new Error('serviceName is required for service-specific schedules');
}
const service = this.oneboxRef.database.getServiceByName(request.serviceName);
if (!service) {
throw new Error(`Service not found: ${request.serviceName}`);
}
serviceId = service.id!;
serviceName = service.name;
break;
case 'pattern':
// Validate pattern is provided
if (!request.scopePattern) {
throw new Error('scopePattern is required for pattern-based schedules');
}
// Validate pattern matches at least one service
const matchingServices = this.getServicesMatchingPattern(request.scopePattern);
if (matchingServices.length === 0) {
logger.warn(`Pattern "${request.scopePattern}" currently matches no services`);
}
break;
case 'all':
// No validation needed for global schedules
break;
default:
throw new Error(`Invalid scope type: ${request.scopeType}`);
}
// Use provided cron expression
const cronExpression = request.cronExpression;
// Calculate next run time
const nextRunAt = this.calculateNextRun(cronExpression);
// Create schedule in database
const schedule = this.oneboxRef.database.createBackupSchedule({
scopeType: request.scopeType,
scopePattern: request.scopePattern,
serviceId,
serviceName,
cronExpression,
retention: request.retention,
enabled: request.enabled !== false,
lastRunAt: null,
nextRunAt,
lastStatus: null,
lastError: null,
createdAt: Date.now(),
updatedAt: Date.now(),
});
// Register task if enabled
if (schedule.enabled) {
await this.registerTask(schedule);
}
const scopeDesc = this.getScopeDescription(schedule);
const retentionDesc = this.getRetentionDescription(schedule.retention);
logger.info(`Backup schedule created: ${schedule.id} for ${scopeDesc} (${retentionDesc})`);
return schedule;
}
/**
* Update an existing backup schedule
*/
async updateSchedule(scheduleId: number, updates: IBackupScheduleUpdate): Promise<IBackupSchedule> {
const schedule = this.oneboxRef.database.getBackupScheduleById(scheduleId);
if (!schedule) {
throw new Error(`Backup schedule not found: ${scheduleId}`);
}
// Deschedule existing task if present
await this.descheduleTask(scheduleId);
// Update database
this.oneboxRef.database.updateBackupSchedule(scheduleId, updates);
// Get updated schedule
const updatedSchedule = this.oneboxRef.database.getBackupScheduleById(scheduleId)!;
// Calculate new next run time if cron changed
if (updates.cronExpression) {
const nextRunAt = this.calculateNextRun(updatedSchedule.cronExpression);
this.oneboxRef.database.updateBackupSchedule(scheduleId, { nextRunAt });
}
// Re-register task if enabled
if (updatedSchedule.enabled) {
await this.registerTask(updatedSchedule);
}
logger.info(`Backup schedule updated: ${scheduleId}`);
return this.oneboxRef.database.getBackupScheduleById(scheduleId)!;
}
/**
* Delete a backup schedule
*/
async deleteSchedule(scheduleId: number): Promise<void> {
const schedule = this.oneboxRef.database.getBackupScheduleById(scheduleId);
if (!schedule) {
throw new Error(`Backup schedule not found: ${scheduleId}`);
}
// Deschedule task
await this.descheduleTask(scheduleId);
// Delete from database
this.oneboxRef.database.deleteBackupSchedule(scheduleId);
logger.info(`Backup schedule deleted: ${scheduleId}`);
}
/**
* Trigger immediate backup for a schedule
*/
async triggerBackup(scheduleId: number): Promise<void> {
const schedule = this.oneboxRef.database.getBackupScheduleById(scheduleId);
if (!schedule) {
throw new Error(`Backup schedule not found: ${scheduleId}`);
}
logger.info(`Manually triggering backup for schedule ${scheduleId}`);
await this.executeBackup(schedule);
}
/**
* Get all schedules
*/
getAllSchedules(): IBackupSchedule[] {
return this.oneboxRef.database.getAllBackupSchedules();
}
/**
* Get schedule by ID
*/
getScheduleById(id: number): IBackupSchedule | null {
return this.oneboxRef.database.getBackupScheduleById(id);
}
/**
* Get schedules for a service
*/
getSchedulesForService(serviceName: string): IBackupSchedule[] {
const service = this.oneboxRef.database.getServiceByName(serviceName);
if (!service) {
return [];
}
return this.oneboxRef.database.getBackupSchedulesByService(service.id!);
}
/**
* Get retention presets
*/
getRetentionPresets(): typeof RETENTION_PRESETS {
return RETENTION_PRESETS;
}
// ========== Private Methods ==========
/**
* Register a task for a schedule
*/
private async registerTask(schedule: IBackupSchedule): Promise<void> {
const taskName = `backup-${schedule.id}`;
const task = new plugins.taskbuffer.Task({
name: taskName,
taskFunction: async () => {
await this.executeBackup(schedule);
},
});
// Add and schedule the task
this.taskManager.addAndScheduleTask(task, schedule.cronExpression);
this.scheduledTasks.set(schedule.id!, task);
// Update next run time in database
this.updateNextRunTime(schedule.id!);
logger.debug(`Registered backup task: ${taskName} with cron: ${schedule.cronExpression}`);
}
/**
* Deschedule a task
*/
private async descheduleTask(scheduleId: number): Promise<void> {
const task = this.scheduledTasks.get(scheduleId);
if (task) {
await this.taskManager.descheduleTask(task);
this.scheduledTasks.delete(scheduleId);
logger.debug(`Descheduled backup task for schedule ${scheduleId}`);
}
}
/**
* Execute a backup for a schedule
*/
private async executeBackup(schedule: IBackupSchedule): Promise<void> {
const scopeDesc = this.getScopeDescription(schedule);
const servicesToBackup = this.getServicesForSchedule(schedule);
if (servicesToBackup.length === 0) {
logger.warn(`No services to backup for schedule ${schedule.id} (${scopeDesc})`);
this.oneboxRef.database.updateBackupSchedule(schedule.id!, {
lastRunAt: Date.now(),
lastStatus: 'success',
lastError: 'No matching services found',
});
this.updateNextRunTime(schedule.id!);
return;
}
const retentionDesc = this.getRetentionDescription(schedule.retention);
logger.info(`Executing scheduled backup for ${scopeDesc}: ${servicesToBackup.length} service(s) (${retentionDesc})`);
let successCount = 0;
let failCount = 0;
const errors: string[] = [];
for (const service of servicesToBackup) {
try {
// Create backup with schedule ID
await this.oneboxRef.backupManager.createBackup(service.name, {
scheduleId: schedule.id,
});
// Apply time-window based retention policy for this service
await this.applyRetention(schedule, service.id!);
successCount++;
logger.success(`Scheduled backup completed for ${service.name}`);
} catch (error) {
const errorMessage = getErrorMessage(error);
logger.error(`Scheduled backup failed for ${service.name}: ${errorMessage}`);
errors.push(`${service.name}: ${errorMessage}`);
failCount++;
}
}
// Update schedule status
const lastStatus = failCount === 0 ? 'success' : 'failed';
const lastError = errors.length > 0 ? errors.join('; ') : null;
this.oneboxRef.database.updateBackupSchedule(schedule.id!, {
lastRunAt: Date.now(),
lastStatus,
lastError,
});
if (failCount === 0) {
logger.success(`Scheduled backup completed for ${scopeDesc}: ${successCount} service(s)`);
} else {
logger.warn(`Scheduled backup partially failed for ${scopeDesc}: ${successCount} succeeded, ${failCount} failed`);
}
// Update next run time
this.updateNextRunTime(schedule.id!);
}
/**
* Apply time-window based retention policy
* Works correctly regardless of backup frequency (cron schedule)
*/
private async applyRetention(schedule: IBackupSchedule, serviceId: number): Promise<void> {
// Get all backups for this schedule and service
const allBackups = this.oneboxRef.database.getBackupsByService(serviceId);
const backups = allBackups.filter(b => b.scheduleId === schedule.id);
if (backups.length === 0) {
return;
}
const { hourly, daily, weekly, monthly } = schedule.retention;
const now = Date.now();
const toKeep = new Set<number>();
// Hourly: Keep up to N most recent backups from last 24 hours
if (hourly > 0) {
const recentBackups = backups
.filter(b => now - b.createdAt < 24 * 60 * 60 * 1000)
.sort((a, b) => b.createdAt - a.createdAt)
.slice(0, hourly);
recentBackups.forEach(b => toKeep.add(b.id!));
}
// Daily: Keep oldest backup per day for last N days
if (daily > 0) {
for (let i = 0; i < daily; i++) {
const dayStart = this.getStartOfDay(now, i);
const dayEnd = dayStart + 24 * 60 * 60 * 1000;
const dayBackups = backups.filter(b =>
b.createdAt >= dayStart && b.createdAt < dayEnd
);
if (dayBackups.length > 0) {
// Keep oldest from this day (most representative)
const oldest = dayBackups.sort((a, b) => a.createdAt - b.createdAt)[0];
toKeep.add(oldest.id!);
}
}
}
// Weekly: Keep oldest backup per week for last N weeks
if (weekly > 0) {
for (let i = 0; i < weekly; i++) {
const weekStart = this.getStartOfWeek(now, i);
const weekEnd = weekStart + 7 * 24 * 60 * 60 * 1000;
const weekBackups = backups.filter(b =>
b.createdAt >= weekStart && b.createdAt < weekEnd
);
if (weekBackups.length > 0) {
const oldest = weekBackups.sort((a, b) => a.createdAt - b.createdAt)[0];
toKeep.add(oldest.id!);
}
}
}
// Monthly: Keep oldest backup per month for last N months
if (monthly > 0) {
for (let i = 0; i < monthly; i++) {
const { start, end } = this.getMonthRange(now, i);
const monthBackups = backups.filter(b =>
b.createdAt >= start && b.createdAt < end
);
if (monthBackups.length > 0) {
const oldest = monthBackups.sort((a, b) => a.createdAt - b.createdAt)[0];
toKeep.add(oldest.id!);
}
}
}
// Delete anything not in toKeep
for (const backup of backups) {
if (!toKeep.has(backup.id!)) {
try {
await this.oneboxRef.backupManager.deleteBackup(backup.id!);
const backupRef = backup.snapshotId || backup.filename;
logger.info(`Deleted backup ${backupRef} (retention policy)`);
} catch (error) {
const backupRef = backup.snapshotId || backup.filename;
logger.warn(`Failed to delete old backup ${backupRef}: ${getErrorMessage(error)}`);
}
}
}
}
/**
* Get start of day (midnight) for N days ago
*/
private getStartOfDay(now: number, daysAgo: number): number {
const date = new Date(now);
date.setDate(date.getDate() - daysAgo);
date.setHours(0, 0, 0, 0);
return date.getTime();
}
/**
* Get start of week (Sunday midnight) for N weeks ago
*/
private getStartOfWeek(now: number, weeksAgo: number): number {
const date = new Date(now);
date.setDate(date.getDate() - (weeksAgo * 7) - date.getDay());
date.setHours(0, 0, 0, 0);
return date.getTime();
}
/**
* Get month range for N months ago
*/
private getMonthRange(now: number, monthsAgo: number): { start: number; end: number } {
const date = new Date(now);
date.setMonth(date.getMonth() - monthsAgo);
date.setDate(1);
date.setHours(0, 0, 0, 0);
const start = date.getTime();
date.setMonth(date.getMonth() + 1);
const end = date.getTime();
return { start, end };
}
/**
* Update next run time for a schedule
*/
private updateNextRunTime(scheduleId: number): void {
const schedule = this.oneboxRef.database.getBackupScheduleById(scheduleId);
if (!schedule) return;
const nextRunAt = this.calculateNextRun(schedule.cronExpression);
this.oneboxRef.database.updateBackupSchedule(scheduleId, { nextRunAt });
}
/**
* Calculate next run time from cron expression
*/
private calculateNextRun(cronExpression: string): number {
try {
// Get next scheduled runs from task manager
const scheduledTasks = this.taskManager.getScheduledTasks();
// Find our task and get its next run
for (const taskInfo of scheduledTasks) {
if (taskInfo.schedule === cronExpression && taskInfo.nextRun) {
return taskInfo.nextRun.getTime();
}
}
// Fallback: parse cron and calculate next occurrence
// Simple implementation for common patterns
const now = new Date();
const parts = cronExpression.split(' ');
if (parts.length === 5) {
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts;
// For daily schedules (e.g., "0 2 * * *")
if (dayOfMonth === '*' && month === '*' && dayOfWeek === '*') {
const nextRun = new Date(now);
nextRun.setHours(parseInt(hour), parseInt(minute), 0, 0);
if (nextRun <= now) {
nextRun.setDate(nextRun.getDate() + 1);
}
return nextRun.getTime();
}
// For weekly schedules (e.g., "0 2 * * 0")
if (dayOfMonth === '*' && month === '*' && dayOfWeek !== '*') {
const targetDay = parseInt(dayOfWeek);
const nextRun = new Date(now);
nextRun.setHours(parseInt(hour), parseInt(minute), 0, 0);
const currentDay = now.getDay();
let daysUntilTarget = (targetDay - currentDay + 7) % 7;
if (daysUntilTarget === 0 && nextRun <= now) {
daysUntilTarget = 7;
}
nextRun.setDate(nextRun.getDate() + daysUntilTarget);
return nextRun.getTime();
}
// For monthly schedules (e.g., "0 2 1 * *")
if (dayOfMonth !== '*' && month === '*' && dayOfWeek === '*') {
const targetDay = parseInt(dayOfMonth);
const nextRun = new Date(now);
nextRun.setDate(targetDay);
nextRun.setHours(parseInt(hour), parseInt(minute), 0, 0);
if (nextRun <= now) {
nextRun.setMonth(nextRun.getMonth() + 1);
}
return nextRun.getTime();
}
// For yearly schedules (e.g., "0 2 1 1 *")
if (dayOfMonth !== '*' && month !== '*' && dayOfWeek === '*') {
const targetMonth = parseInt(month) - 1; // JavaScript months are 0-indexed
const targetDay = parseInt(dayOfMonth);
const nextRun = new Date(now);
nextRun.setMonth(targetMonth, targetDay);
nextRun.setHours(parseInt(hour), parseInt(minute), 0, 0);
if (nextRun <= now) {
nextRun.setFullYear(nextRun.getFullYear() + 1);
}
return nextRun.getTime();
}
}
// Default: next day at 2 AM
const fallback = new Date(now);
fallback.setDate(fallback.getDate() + 1);
fallback.setHours(2, 0, 0, 0);
return fallback.getTime();
} catch {
// On any error, return tomorrow at 2 AM
const fallback = new Date();
fallback.setDate(fallback.getDate() + 1);
fallback.setHours(2, 0, 0, 0);
return fallback.getTime();
}
}
/**
* Get services that match a schedule based on its scope type
*/
private getServicesForSchedule(schedule: IBackupSchedule): IService[] {
const allServices = this.oneboxRef.database.getAllServices();
switch (schedule.scopeType) {
case 'all':
return allServices;
case 'pattern':
if (!schedule.scopePattern) return [];
return this.getServicesMatchingPattern(schedule.scopePattern);
case 'service':
if (!schedule.serviceId) return [];
const service = allServices.find(s => s.id === schedule.serviceId);
return service ? [service] : [];
default:
return [];
}
}
/**
* Get services that match a glob pattern
*/
private getServicesMatchingPattern(pattern: string): IService[] {
const allServices = this.oneboxRef.database.getAllServices();
return allServices.filter(s => this.matchesGlobPattern(s.name, pattern));
}
/**
* Simple glob pattern matching (supports * and ?)
*/
private matchesGlobPattern(text: string, pattern: string): boolean {
// Convert glob pattern to regex
// Escape special regex characters except * and ?
const regexPattern = pattern
.replace(/[.+^${}()|[\]\\]/g, '\\$&') // Escape special chars
.replace(/\*/g, '.*') // * matches any characters
.replace(/\?/g, '.'); // ? matches single character
const regex = new RegExp(`^${regexPattern}$`, 'i');
return regex.test(text);
}
/**
* Get human-readable description of a schedule's scope
*/
private getScopeDescription(schedule: IBackupSchedule): string {
switch (schedule.scopeType) {
case 'all':
return 'all services';
case 'pattern':
return `pattern "${schedule.scopePattern}"`;
case 'service':
return `service "${schedule.serviceName}"`;
default:
return 'unknown scope';
}
}
/**
* Get human-readable description of retention policy
*/
private getRetentionDescription(retention: IRetentionPolicy): string {
return `H:${retention.hourly} D:${retention.daily} W:${retention.weekly} M:${retention.monthly}`;
}
/**
* Prune the containerarchive repository to reclaim storage.
* Uses the most generous retention policy across all schedules.
*/
private async pruneArchive(): Promise<void> {
const archive = this.oneboxRef.backupManager.archive;
if (!archive) return;
try {
// Compute the most generous retention across all schedules
const schedules = this.oneboxRef.database.getAllBackupSchedules();
// Default minimums if no schedules exist
let maxDays = 7;
let maxWeeks = 4;
let maxMonths = 12;
for (const schedule of schedules) {
if (schedule.retention.daily > maxDays) maxDays = schedule.retention.daily;
if (schedule.retention.weekly > maxWeeks) maxWeeks = schedule.retention.weekly;
if (schedule.retention.monthly > maxMonths) maxMonths = schedule.retention.monthly;
}
const result = await archive.prune(
{
keepDays: maxDays,
keepWeeks: maxWeeks,
keepMonths: maxMonths,
},
false, // not dry run
);
if (result.removedSnapshots > 0 || result.freedBytes > 0) {
const freedMB = Math.round(result.freedBytes / (1024 * 1024) * 10) / 10;
logger.info(
`Archive prune: removed ${result.removedSnapshots} snapshot(s), ` +
`${result.removedPacks} pack(s), freed ${freedMB} MB`
);
}
} catch (error) {
logger.warn(`Archive prune failed: ${getErrorMessage(error)}`);
}
}
}
-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.');
+9 -122
View File
@@ -4,9 +4,7 @@
* Handles background monitoring, metrics collection, and automatic tasks * Handles background monitoring, metrics collection, and automatic tasks
*/ */
import * as plugins from '../plugins.ts';
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 type { Onebox } from './onebox.ts'; import type { Onebox } from './onebox.ts';
@@ -18,7 +16,6 @@ const FALLBACK_PID_FILE = `${FALLBACK_PID_DIR}/onebox.pid`;
export class OneboxDaemon { export class OneboxDaemon {
private oneboxRef: Onebox; private oneboxRef: Onebox;
private smartdaemon: plugins.smartdaemon.SmartDaemon | null = null;
private running = false; private running = false;
private monitoringInterval: number | null = null; private monitoringInterval: number | null = null;
private statsInterval: number | null = null; private statsInterval: number | null = null;
@@ -46,68 +43,6 @@ export class OneboxDaemon {
} }
} }
/**
* Install systemd service
*/
async installService(): Promise<void> {
try {
logger.info('Installing Onebox daemon service...');
// Initialize smartdaemon if needed
if (!this.smartdaemon) {
this.smartdaemon = new plugins.smartdaemon.SmartDaemon();
}
// Get installation directory
const execPath = Deno.execPath();
const service = await this.smartdaemon.addService({
name: 'onebox',
version: projectInfo.version,
command: `${execPath} run --allow-all ${Deno.cwd()}/mod.ts daemon start`,
description: 'Onebox - Self-hosted container platform',
workingDir: Deno.cwd(),
});
await service.save();
await service.enable();
logger.success('Onebox daemon service installed');
logger.info('Start with: sudo systemctl start smartdaemon_onebox');
} catch (error) {
logger.error(`Failed to install daemon service: ${getErrorMessage(error)}`);
throw error;
}
}
/**
* Uninstall systemd service
*/
async uninstallService(): Promise<void> {
try {
logger.info('Uninstalling Onebox daemon service...');
// Initialize smartdaemon if needed
if (!this.smartdaemon) {
this.smartdaemon = new plugins.smartdaemon.SmartDaemon();
}
const services = await this.smartdaemon.systemdManager.getServices();
const service = services.find(s => s.name === 'onebox');
if (service) {
await service.stop();
await service.disable();
await service.delete();
}
logger.success('Onebox daemon service uninstalled');
} catch (error) {
logger.error(`Failed to uninstall daemon service: ${getErrorMessage(error)}`);
throw error;
}
}
/** /**
* Start daemon mode (background monitoring) * Start daemon mode (background monitoring)
*/ */
@@ -131,9 +66,9 @@ export class OneboxDaemon {
// Start monitoring loop // Start monitoring loop
this.startMonitoring(); this.startMonitoring();
// Start HTTP server // Start OpsServer (serves new UI + TypedRequest API)
const httpPort = parseInt(this.oneboxRef.database.getSetting('httpPort') || '3000', 10); const httpPort = parseInt(this.oneboxRef.database.getSetting('httpPort') || '3000', 10);
await this.oneboxRef.httpServer.start(httpPort); await this.oneboxRef.opsServer.start(httpPort);
logger.success('Onebox daemon started'); logger.success('Onebox daemon started');
logger.info(`Web UI available at http://localhost:${httpPort}`); logger.info(`Web UI available at http://localhost:${httpPort}`);
@@ -163,8 +98,8 @@ export class OneboxDaemon {
// Stop monitoring // Stop monitoring
this.stopMonitoring(); this.stopMonitoring();
// Stop HTTP server // Stop OpsServer
await this.oneboxRef.httpServer.stop(); await this.oneboxRef.opsServer.stop();
// Remove PID file // Remove PID file
await this.removePidFile(); await this.removePidFile();
@@ -280,31 +215,12 @@ export class OneboxDaemon {
} }
/** /**
* Broadcast stats to WebSocket clients (real-time updates) * Broadcast stats (placeholder for future WebSocket integration via OpsServer)
*/ */
private async broadcastStats(): Promise<void> { private async broadcastStats(): Promise<void> {
try { // Stats broadcasting via WebSocket is not yet implemented in OpsServer.
const services = this.oneboxRef.services.listServices(); // Metrics are still collected and stored in the DB by collectMetrics().
const runningServices = services.filter(s => s.status === 'running' && s.containerID); // The new UI fetches stats via TypedRequests on demand.
logger.info(`Broadcasting stats for ${runningServices.length} running services`);
for (const service of runningServices) {
try {
const stats = await this.oneboxRef.docker.getContainerStats(service.containerID!);
if (stats) {
logger.info(`Broadcasting stats for ${service.name}: CPU=${stats.cpuPercent.toFixed(1)}%, Mem=${Math.round(stats.memoryUsed / 1024 / 1024)}MB`);
this.oneboxRef.httpServer.broadcastStatsUpdate(service.name, stats);
} else {
logger.warn(`No stats returned for ${service.name} (containerID: ${service.containerID})`);
}
} catch (error) {
logger.warn(`Stats collection failed for ${service.name}: ${getErrorMessage(error)}`);
}
}
} catch (error) {
logger.error(`Broadcast stats error: ${getErrorMessage(error)}`);
}
} }
/** /**
@@ -501,36 +417,7 @@ export class OneboxDaemon {
static async ensureNoDaemon(): Promise<void> { static async ensureNoDaemon(): Promise<void> {
const running = await OneboxDaemon.isDaemonRunning(); const running = await OneboxDaemon.isDaemonRunning();
if (running) { if (running) {
throw new Error('Daemon is already running. Please stop it first with: onebox daemon stop'); throw new Error('Daemon is already running. Please stop it first with: onebox systemd stop');
}
}
/**
* Get service status from systemd
*/
async getServiceStatus(): Promise<string> {
try {
// Don't need smartdaemon to check status, just use systemctl directly
const command = new Deno.Command('systemctl', {
args: ['status', 'smartdaemon_onebox'],
stdout: 'piped',
stderr: 'piped',
});
const { code, stdout } = await command.output();
const output = new TextDecoder().decode(stdout);
if (code === 0 || output.includes('active (running)')) {
return 'running';
} else if (output.includes('inactive') || output.includes('dead')) {
return 'stopped';
} else if (output.includes('failed')) {
return 'failed';
} else {
return 'unknown';
}
} catch (error) {
return 'not-installed';
} }
} }
} }
+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;
} }
+66 -8
View File
@@ -36,6 +36,23 @@ export class OneboxDockerManager {
} }
} }
/**
* Release resources held by the Docker API client.
*/
async stop(): Promise<void> {
if (!this.dockerClient) {
return;
}
try {
await this.dockerClient.stop();
} catch (error) {
logger.error(`Failed to stop Docker client: ${getErrorMessage(error)}`);
} finally {
this.dockerClient = null;
}
}
/** /**
* Ensure onebox network exists * Ensure onebox network exists
*/ */
@@ -596,18 +613,26 @@ export class OneboxDockerManager {
async getContainerStats(containerID: string): Promise<IContainerStats | null> { async getContainerStats(containerID: string): Promise<IContainerStats | null> {
try { try {
// Try to get container directly first // Try to get container directly first
let container = await this.dockerClient!.getContainerById(containerID); let container: any = null;
try {
container = await this.dockerClient!.getContainerById(containerID);
} catch {
// Container not found by ID — might be a Swarm service ID
}
// If not found, it might be a service ID - try to get the actual container ID // If not found, it might be a service ID - try to get the actual container ID
if (!container) { if (!container) {
const serviceContainerId = await this.getContainerIdForService(containerID); const serviceContainerId = await this.getContainerIdForService(containerID);
if (serviceContainerId) { if (serviceContainerId) {
try {
container = await this.dockerClient!.getContainerById(serviceContainerId); container = await this.dockerClient!.getContainerById(serviceContainerId);
} catch {
// Service container also not found
}
} }
} }
if (!container) { if (!container) {
// Container/service not found
return null; return null;
} }
@@ -849,7 +874,23 @@ export class OneboxDockerManager {
cmd: string[] cmd: string[]
): Promise<{ stdout: string; stderr: string; exitCode: number }> { ): Promise<{ stdout: string; stderr: string; exitCode: number }> {
try { try {
const container = await this.dockerClient!.getContainerById(containerID); let container: any = null;
try {
container = await this.dockerClient!.getContainerById(containerID);
} catch {
// Not a direct container ID — try Swarm service lookup
}
if (!container) {
const serviceContainerId = await this.getContainerIdForService(containerID);
if (serviceContainerId) {
try {
container = await this.dockerClient!.getContainerById(serviceContainerId);
} catch {
// Service container also not found
}
}
}
if (!container) { if (!container) {
throw new Error(`Container not found: ${containerID}`); throw new Error(`Container not found: ${containerID}`);
@@ -881,12 +922,12 @@ export class OneboxDockerManager {
]); ]);
const execInfo = await inspect(); const execInfo = await inspect();
const exitCode = execInfo.ExitCode || 0; const exitCode = execInfo.ExitCode ?? -1;
return { stdout, stderr, exitCode }; return { stdout, stderr, exitCode };
} catch (error) { } catch (error) {
logger.error(`Failed to exec in container ${containerID}: ${getErrorMessage(error)}`); logger.error(`Failed to exec in container ${containerID}: ${getErrorMessage(error)}`);
throw error; return { stdout: '', stderr: getErrorMessage(error), exitCode: -1 };
} }
} }
@@ -911,8 +952,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)
); );
@@ -1011,7 +1053,23 @@ export class OneboxDockerManager {
callback: (line: string, isError: boolean) => void callback: (line: string, isError: boolean) => void
): Promise<void> { ): Promise<void> {
try { try {
const container = await this.dockerClient!.getContainerById(containerID); let container: any = null;
try {
container = await this.dockerClient!.getContainerById(containerID);
} catch {
// Not a direct container ID — try Swarm service lookup
}
if (!container) {
const serviceContainerId = await this.getContainerIdForService(containerID);
if (serviceContainerId) {
try {
container = await this.dockerClient!.getContainerById(serviceContainerId);
} catch {
// Service container also not found
}
}
}
if (!container) { if (!container) {
throw new Error(`Container not found: ${containerID}`); throw new Error(`Container not found: ${containerID}`);
+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;
} }
/** /**
+474
View File
@@ -0,0 +1,474 @@
import * as plugins from '../plugins.ts';
import { logger } from '../logging.ts';
import { getErrorMessage } from '../utils/error.ts';
import { OneboxDatabase } from './database.ts';
import type { IDomain, IService } from '../types.ts';
type TWorkHosterType = 'onebox';
interface IExternalGatewayConfig {
url: string;
apiToken: string;
gatewayClientId: string;
/** @deprecated Use gatewayClientId. */
workHosterId: string;
targetHost?: string;
targetPort?: number;
}
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();
}
public async isConfigured(): Promise<boolean> {
const config = await this.getConfig({ requireTarget: false });
return Boolean(config);
}
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',
{ 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',
{ 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;
const config = await this.getConfig({ requireTarget: true });
if (!config) return;
const result = await this.fireDcRouterRequest<IWorkAppRouteSyncResult>(
'syncGatewayClientRoute',
{
ownership: this.buildGatewayClientOwnership(service, service.domain, config),
route: this.buildRoute(service, config),
enabled: service.status === 'running',
},
config,
).catch(async () => {
return await this.fireDcRouterRequest<IWorkAppRouteSyncResult>(
'syncWorkAppRoute',
{
ownership: this.buildOwnership(service, service.domain!, config),
route: this.buildRoute(service, config),
enabled: service.status === 'running',
},
config,
);
});
if (!result.success) {
throw new Error(result.message || `dcrouter route sync failed for ${service.domain}`);
}
logger.success(`External gateway route ${result.action || 'synced'} for ${service.domain}`);
await this.importCertificateForDomain(service.domain).catch((error) => {
logger.debug(`External gateway certificate import skipped for ${service.domain}: ${getErrorMessage(error)}`);
});
}
public async deleteServiceRoute(service: Pick<IService, 'id' | 'name' | 'domain'>): Promise<void> {
if (!service.domain) return;
const config = await this.getConfig({ requireTarget: false });
if (!config) return;
const result = await this.fireDcRouterRequest<IWorkAppRouteSyncResult>(
'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 url = this.normalizeUrl(this.database.getSetting('dcrouterGatewayUrl') || '');
const apiToken = await this.database.getSecretSetting('dcrouterGatewayApiToken');
if (!url || !apiToken) {
return null;
}
const gatewayClientId = this.ensureGatewayClientId();
const config: IExternalGatewayConfig = {
url,
apiToken,
gatewayClientId,
workHosterId: gatewayClientId,
};
if (options.requireTarget !== false) {
config.targetHost = this.database.getSetting('dcrouterTargetHost')
|| this.database.getSetting('serverIP')
|| undefined;
const targetPort = this.parsePort(
this.database.getSetting('dcrouterTargetPort')
|| this.database.getSetting('httpPort')
|| '80',
);
config.targetPort = targetPort;
if (!config.targetHost) {
throw new Error('dcrouterTargetHost or serverIP must be configured for external gateway route sync');
}
}
return config;
}
private async requireConfig(options: { requireTarget?: boolean } = {}): Promise<IExternalGatewayConfig> {
const config = await this.getConfig(options);
if (!config) {
throw new Error('External dcrouter gateway is not configured');
}
return config;
}
private normalizeUrl(url: string): string {
const trimmedUrl = url.trim().replace(/\/+$/, '');
if (!trimmedUrl) return '';
if (/^https?:\/\//.test(trimmedUrl)) return trimmedUrl;
return `https://${trimmedUrl}`;
}
private parsePort(portValue: string): number {
const port = Number(portValue);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error(`Invalid dcrouter target port: ${portValue}`);
}
return port;
}
private ensureGatewayClientId(): string {
let gatewayClientId = this.database.getSetting('dcrouterGatewayClientId') || this.database.getSetting('dcrouterWorkHosterId');
if (!gatewayClientId) {
gatewayClientId = crypto.randomUUID();
this.database.setSetting('dcrouterGatewayClientId', gatewayClientId);
}
return gatewayClientId;
}
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 {
return {
gatewayClientType: 'onebox',
gatewayClientId: config.gatewayClientId,
appId: service.name || `service-${service.id}`,
hostname,
};
}
private buildRoute(service: IService, config: IExternalGatewayConfig): IDcRouterRouteConfig {
return {
name: this.routeName(service.domain!),
match: {
ports: [443],
domains: [service.domain!],
},
action: {
type: 'forward',
targets: [{ host: config.targetHost!, port: config.targetPort! }],
tls: {
mode: 'terminate',
certificate: 'auto',
},
websocket: {
enabled: true,
},
},
};
}
private routeName(domain: string): string {
return `onebox-${domain.replace(/[^a-zA-Z0-9]+/g, '-').replace(/^-|-$/g, '')}`;
}
private 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
+163 -42
View File
@@ -6,6 +6,7 @@
import { logger } from '../logging.ts'; import { logger } from '../logging.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';
@@ -14,12 +15,17 @@ import { OneboxReverseProxy } from './reverseproxy.ts';
import { OneboxDnsManager } from './dns.ts'; 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 { OneboxHttpServer } from './httpserver.ts'; import { OneboxSystemd } from './systemd.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 { CaddyLogReceiver } from './caddy-log-receiver.ts'; import { AppStoreManager } from './appstore.ts';
import { ProxyLogReceiver } from './proxy-log-receiver.ts';
import { BackupManager } from './backup-manager.ts';
import { BackupScheduler } from './backup-scheduler.ts';
import { ExternalGatewayManager } from './external-gateway.ts';
import { OpsServer } from '../opsserver/index.ts';
export class Onebox { export class Onebox {
public database: OneboxDatabase; public database: OneboxDatabase;
@@ -30,12 +36,17 @@ export class Onebox {
public dns: OneboxDnsManager; public dns: OneboxDnsManager;
public ssl: OneboxSslManager; public ssl: OneboxSslManager;
public daemon: OneboxDaemon; public daemon: OneboxDaemon;
public httpServer: OneboxHttpServer; public systemd: OneboxSystemd;
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 caddyLogReceiver: CaddyLogReceiver; public appStore: AppStoreManager;
public proxyLogReceiver: ProxyLogReceiver;
public backupManager: BackupManager;
public backupScheduler: BackupScheduler;
public externalGateway: ExternalGatewayManager;
public opsServer: OpsServer;
private initialized = false; private initialized = false;
@@ -51,11 +62,11 @@ export class Onebox {
this.dns = new OneboxDnsManager(this); this.dns = new OneboxDnsManager(this);
this.ssl = new OneboxSslManager(this); this.ssl = new OneboxSslManager(this);
this.daemon = new OneboxDaemon(this); this.daemon = new OneboxDaemon(this);
this.httpServer = new OneboxHttpServer(this); this.systemd = new OneboxSystemd();
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
@@ -65,8 +76,23 @@ export class Onebox {
// Initialize platform services manager // Initialize platform services manager
this.platformServices = new PlatformServicesManager(this); this.platformServices = new PlatformServicesManager(this);
// Initialize Caddy log receiver // Initialize App Store manager
this.caddyLogReceiver = new CaddyLogReceiver(9999); this.appStore = new AppStoreManager(this);
// Initialize reverse proxy log receiver
this.proxyLogReceiver = new ProxyLogReceiver(9999);
// Initialize Backup manager
this.backupManager = new BackupManager(this);
// Initialize Backup scheduler
this.backupScheduler = new BackupScheduler(this);
// Initialize optional dcrouter edge gateway integration
this.externalGateway = new ExternalGatewayManager(this);
// Initialize OpsServer (TypedRequest-based server)
this.opsServer = new OpsServer(this);
} }
/** /**
@@ -85,11 +111,11 @@ 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) // Start proxy log receiver before reverse proxy startup.
try { try {
await this.caddyLogReceiver.start(); await this.proxyLogReceiver.start();
} catch (error) { } catch (error) {
logger.warn(`Failed to start Caddy log receiver: ${getErrorMessage(error)}`); logger.warn(`Failed to start proxy log receiver: ${getErrorMessage(error)}`);
} }
// Initialize Reverse Proxy // Initialize Reverse Proxy
@@ -139,6 +165,14 @@ 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 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();
@@ -155,12 +189,36 @@ export class Onebox {
logger.warn(`Error: ${getErrorMessage(error)}`); logger.warn(`Error: ${getErrorMessage(error)}`);
} }
// Initialize App Store (non-critical)
try {
await this.appStore.init();
} catch (error) {
logger.warn('App Store initialization failed - app templates will be unavailable until reconnected');
logger.warn(`Error: ${getErrorMessage(error)}`);
}
// Login to all registries // Login to all registries
await this.registries.loginToAllRegistries(); await this.registries.loginToAllRegistries();
// Start auto-update monitoring for registry services // Start auto-update monitoring for registry services
this.services.startAutoUpdateMonitoring(); this.services.startAutoUpdateMonitoring();
// Initialize BackupManager (containerarchive repository, non-critical)
try {
await this.backupManager.init();
} catch (error) {
logger.warn('BackupManager initialization failed - backups will be limited');
logger.warn(`Error: ${getErrorMessage(error)}`);
}
// Initialize Backup Scheduler (non-critical)
try {
await this.backupScheduler.init();
} catch (error) {
logger.warn('Backup scheduler initialization failed - scheduled backups will be disabled');
logger.warn(`Error: ${getErrorMessage(error)}`);
}
this.initialized = true; this.initialized = true;
logger.success('Onebox initialized successfully'); logger.success('Onebox initialized successfully');
} catch (error) { } catch (error) {
@@ -174,24 +232,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)}`);
@@ -224,9 +289,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
@@ -265,10 +330,65 @@ export class Onebox {
// Sort expiring domains by days remaining (ascending) // Sort expiring domains by days remaining (ascending)
expiringDomains.sort((a, b) => a.daysRemaining - b.daysRemaining); expiringDomains.sort((a, b) => a.daysRemaining - b.daysRemaining);
// Aggregate resource usage across all running service containers
let totalCpu = 0;
let totalMemoryUsed = 0;
let totalMemoryLimit = 0;
let totalNetworkIn = 0;
let totalNetworkOut = 0;
if (dockerRunning) {
const allServices = this.services.listServices();
const runningUserServices = allServices.filter((s) => s.status === 'running' && s.containerID);
logger.debug(`Resource stats: ${runningUserServices.length} running user services`);
const statsPromises = runningUserServices
.map((s) => {
logger.debug(`Fetching stats for user service: ${s.name} (${s.containerID})`);
return this.docker.getContainerStats(s.containerID!).catch((err) => {
logger.debug(`Stats failed for ${s.name}: ${(err as Error).message}`);
return null;
});
});
// Also get stats for platform service containers
const allPlatformServices = this.platformServices.getAllPlatformServices();
const runningPlatformServices = allPlatformServices.filter((s) => s.status === 'running' && s.containerId);
logger.debug(`Resource stats: ${runningPlatformServices.length} running platform services`);
const platformStatsPromises = runningPlatformServices
.map((s) => {
logger.debug(`Fetching stats for platform service: ${s.type} (${s.containerId})`);
return this.docker.getContainerStats(s.containerId!).catch((err) => {
logger.debug(`Stats failed for ${s.type}: ${(err as Error).message}`);
return null;
});
});
const allStats = await Promise.all([...statsPromises, ...platformStatsPromises]);
let successCount = 0;
for (const stats of allStats) {
if (stats) {
successCount++;
totalCpu += stats.cpuPercent;
totalMemoryUsed += stats.memoryUsed;
totalMemoryLimit = Math.max(totalMemoryLimit, stats.memoryLimit);
totalNetworkIn += stats.networkRx;
totalNetworkOut += stats.networkTx;
}
}
logger.debug(`Resource stats: ${successCount}/${allStats.length} containers returned stats. CPU: ${totalCpu}, Mem: ${totalMemoryUsed}`);
}
return { return {
docker: { docker: {
running: dockerRunning, running: dockerRunning,
version: dockerRunning ? await this.docker.getDockerVersion() : null, version: dockerRunning ? await this.docker.getDockerVersion() : null,
cpuUsage: Math.round(totalCpu * 10) / 10,
memoryUsage: totalMemoryUsed,
memoryTotal: totalMemoryLimit,
networkIn: totalNetworkIn,
networkOut: totalNetworkOut,
}, },
reverseProxy: proxyStatus, reverseProxy: proxyStatus,
dns: { dns: {
@@ -298,31 +418,17 @@ export class Onebox {
} }
/** /**
* Start daemon mode * Start OpsServer (TypedRequest-based, serves new UI)
*/
async startDaemon(): Promise<void> {
await this.daemon.start();
}
/**
* Stop daemon mode
*/
async stopDaemon(): Promise<void> {
await this.daemon.stop();
}
/**
* Start HTTP server
*/ */
async startHttpServer(port?: number): Promise<void> { async startHttpServer(port?: number): Promise<void> {
await this.httpServer.start(port); await this.opsServer.start(port || 3000);
} }
/** /**
* Stop HTTP server * Stop OpsServer
*/ */
async stopHttpServer(): Promise<void> { async stopHttpServer(): Promise<void> {
await this.httpServer.stop(); await this.opsServer.stop();
} }
/** /**
@@ -332,17 +438,32 @@ export class Onebox {
try { try {
logger.info('Shutting down Onebox...'); logger.info('Shutting down Onebox...');
// Stop auto-update monitoring
this.services.stopAutoUpdateMonitoring();
// Stop backup scheduler
await this.backupScheduler.stop();
// Stop daemon if running // Stop daemon if running
await this.daemon.stop(); await this.daemon.stop();
// Stop HTTP server if running // Stop OpsServer if running
await this.httpServer.stop(); await this.opsServer.stop();
// 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
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();
+3
View File
@@ -8,3 +8,6 @@ export type { IPlatformServiceProvider } from './providers/base.ts';
export { BasePlatformServiceProvider } from './providers/base.ts'; export { BasePlatformServiceProvider } from './providers/base.ts';
export { MongoDBProvider } from './providers/mongodb.ts'; export { MongoDBProvider } from './providers/mongodb.ts';
export { MinioProvider } from './providers/minio.ts'; export { MinioProvider } from './providers/minio.ts';
export { ClickHouseProvider } from './providers/clickhouse.ts';
export { MariaDBProvider } from './providers/mariadb.ts';
export { RedisProvider } from './providers/redis.ts';
+60 -2
View File
@@ -14,8 +14,10 @@ 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 { RedisProvider } from './providers/redis.ts';
import { logger } from '../../logging.ts'; import { logger } from '../../logging.ts';
import { getErrorMessage } from '../../utils/error.ts'; import { getErrorMessage } from '../../utils/error.ts';
import { credentialEncryption } from '../encryption.ts'; import { credentialEncryption } from '../encryption.ts';
@@ -39,8 +41,10 @@ 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 RedisProvider(this.oneboxRef));
logger.info(`Platform services manager initialized with ${this.providers.size} providers`); logger.info(`Platform services manager initialized with ${this.providers.size} providers`);
} }
@@ -304,6 +308,60 @@ export class PlatformServicesManager {
logger.success(`ClickHouse provisioned for service '${service.name}'`); logger.success(`ClickHouse provisioned for service '${service.name}'`);
} }
// Provision Redis if requested
if (requirements.redis) {
logger.info(`Provisioning Redis for service '${service.name}'...`);
// Ensure Redis is running
const redisService = await this.ensureRunning('redis');
const provider = this.providers.get('redis')!;
// Provision cache resource
const result = await provider.provisionResource(service);
// Store resource record
const encryptedCreds = await credentialEncryption.encrypt(result.credentials);
this.oneboxRef.database.createPlatformResource({
platformServiceId: redisService.id!,
serviceId: service.id!,
resourceType: result.type,
resourceName: result.name,
credentialsEncrypted: encryptedCreds,
createdAt: Date.now(),
});
// Merge env vars
Object.assign(allEnvVars, result.envVars);
logger.success(`Redis provisioned for service '${service.name}'`);
}
// Provision MariaDB if requested
if (requirements.mariadb) {
logger.info(`Provisioning MariaDB for service '${service.name}'...`);
// Ensure MariaDB is running
const mariadbService = await this.ensureRunning('mariadb');
const provider = this.providers.get('mariadb')!;
// Provision database
const result = await provider.provisionResource(service);
// Store resource record
const encryptedCreds = await credentialEncryption.encrypt(result.credentials);
this.oneboxRef.database.createPlatformResource({
platformServiceId: mariadbService.id!,
serviceId: service.id!,
resourceType: result.type,
resourceName: result.name,
credentialsEncrypted: encryptedCreds,
createdAt: Date.now(),
});
// Merge env vars
Object.assign(allEnvVars, result.envVars);
logger.success(`MariaDB provisioned for service '${service.name}'`);
}
return allEnvVars; return allEnvVars;
} }
@@ -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,15 +193,11 @@ 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();
// Get container host port for connection from host (overlay network IPs not accessible from host)
const hostPort = await this.oneboxRef.docker.getContainerHostPort(platformService.containerId, 8123);
if (!hostPort) {
throw new Error('Could not get ClickHouse container host port');
}
// Generate resource names and credentials // Generate resource names and credentials
const dbName = this.generateResourceName(userService.name); const dbName = this.generateResourceName(userService.name);
const username = this.generateResourceName(userService.name); const username = this.generateResourceName(userService.name);
@@ -207,35 +205,16 @@ export class ClickHouseProvider extends BasePlatformServiceProvider {
logger.info(`Provisioning ClickHouse database '${dbName}' for service '${userService.name}'...`); logger.info(`Provisioning ClickHouse database '${dbName}' for service '${userService.name}'...`);
// Connect to ClickHouse via localhost and the mapped host port // Use docker exec to provision inside the container (avoids host port mapping issues)
const baseUrl = `http://127.0.0.1:${hostPort}`; const queries = [
`CREATE DATABASE IF NOT EXISTS ${dbName}`,
`CREATE USER IF NOT EXISTS ${username} IDENTIFIED BY '${password}'`,
`GRANT ALL ON ${dbName}.* TO ${username}`,
];
// Create database for (const query of queries) {
await this.executeQuery( await this.execClickHouseQuery(platformService.containerId, adminCreds, query);
baseUrl, }
adminCreds.username,
adminCreds.password,
`CREATE DATABASE IF NOT EXISTS ${dbName}`
);
logger.info(`Created ClickHouse database '${dbName}'`);
// Create user with access to this database
await this.executeQuery(
baseUrl,
adminCreds.username,
adminCreds.password,
`CREATE USER IF NOT EXISTS ${username} IDENTIFIED BY '${password}'`
);
logger.info(`Created ClickHouse user '${username}'`);
// Grant permissions on the database
await this.executeQuery(
baseUrl,
adminCreds.username,
adminCreds.password,
`GRANT ALL ON ${dbName}.* TO ${username}`
);
logger.info(`Granted permissions to user '${username}' on database '${dbName}'`);
logger.success(`ClickHouse database '${dbName}' provisioned with user '${username}'`); logger.success(`ClickHouse database '${dbName}' provisioned with user '${username}'`);
@@ -272,39 +251,15 @@ 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,
// Get container host port for connection from host (overlay network IPs not accessible from host) );
const hostPort = await this.oneboxRef.docker.getContainerHostPort(platformService.containerId, 8123);
if (!hostPort) {
throw new Error('Could not get ClickHouse container host port');
}
logger.info(`Deprovisioning ClickHouse database '${resource.resourceName}'...`); logger.info(`Deprovisioning ClickHouse database '${resource.resourceName}'...`);
const baseUrl = `http://127.0.0.1:${hostPort}`;
try { try {
// Drop the user await this.execClickHouseQuery(platformService.containerId, adminCreds, `DROP USER IF EXISTS ${credentials.username}`);
try { await this.execClickHouseQuery(platformService.containerId, adminCreds, `DROP DATABASE IF EXISTS ${resource.resourceName}`);
await this.executeQuery(
baseUrl,
adminCreds.username,
adminCreds.password,
`DROP USER IF EXISTS ${credentials.username}`
);
logger.info(`Dropped ClickHouse user '${credentials.username}'`);
} catch (e) {
logger.warn(`Could not drop ClickHouse user: ${getErrorMessage(e)}`);
}
// Drop the database
await this.executeQuery(
baseUrl,
adminCreds.username,
adminCreds.password,
`DROP DATABASE IF EXISTS ${resource.resourceName}`
);
logger.success(`ClickHouse database '${resource.resourceName}' dropped`); logger.success(`ClickHouse database '${resource.resourceName}' dropped`);
} catch (e) { } catch (e) {
logger.error(`Failed to deprovision ClickHouse database: ${getErrorMessage(e)}`); logger.error(`Failed to deprovision ClickHouse database: ${getErrorMessage(e)}`);
@@ -313,26 +268,27 @@ export class ClickHouseProvider extends BasePlatformServiceProvider {
} }
/** /**
* Execute a ClickHouse SQL query via HTTP interface * Execute a ClickHouse SQL query via docker exec inside the container
*/ */
private async executeQuery( private async execClickHouseQuery(
baseUrl: string, containerId: string,
username: string, adminCreds: { username: string; password: string },
password: string,
query: string query: string
): Promise<string> { ): Promise<string> {
const url = `${baseUrl}/?user=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}`; const result = await this.oneboxRef.docker.execInContainer(
containerId,
[
'clickhouse-client',
'--user', adminCreds.username,
'--password', adminCreds.password,
'--query', query,
]
);
const response = await fetch(url, { if (result.exitCode !== 0) {
method: 'POST', throw new Error(`ClickHouse query failed (exit ${result.exitCode}): ${result.stderr.substring(0, 200)}`);
body: query,
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`ClickHouse query failed: ${errorText}`);
} }
return await response.text(); return result.stdout;
} }
} }
@@ -0,0 +1,281 @@
/**
* MariaDB Platform Service Provider
*/
import { BasePlatformServiceProvider } from './base.ts';
import type {
IService,
IPlatformResource,
IPlatformServiceConfig,
IProvisionedResource,
IEnvVarMapping,
TPlatformServiceType,
TPlatformResourceType,
} from '../../../types.ts';
import { logger } from '../../../logging.ts';
import { getErrorMessage } from '../../../utils/error.ts';
import { credentialEncryption } from '../../encryption.ts';
import type { Onebox } from '../../onebox.ts';
export class MariaDBProvider extends BasePlatformServiceProvider {
readonly type: TPlatformServiceType = 'mariadb';
readonly displayName = 'MariaDB';
readonly resourceTypes: TPlatformResourceType[] = ['database'];
constructor(oneboxRef: Onebox) {
super(oneboxRef);
}
getDefaultConfig(): IPlatformServiceConfig {
return {
image: 'mariadb:11',
port: 3306,
volumes: [`${this.getPlatformDataDir('mariadb')}:/var/lib/mysql`],
environment: {
MARIADB_ROOT_PASSWORD: '',
// Password will be generated and stored encrypted
},
};
}
getEnvVarMappings(): IEnvVarMapping[] {
return [
{ envVar: 'MARIADB_HOST', credentialPath: 'host' },
{ envVar: 'MARIADB_PORT', credentialPath: 'port' },
{ envVar: 'MARIADB_DATABASE', credentialPath: 'database' },
{ envVar: 'MARIADB_USER', credentialPath: 'username' },
{ envVar: 'MARIADB_PASSWORD', credentialPath: 'password' },
{ envVar: 'MARIADB_URI', credentialPath: 'connectionString' },
];
}
async deployContainer(): Promise<string> {
const config = this.getDefaultConfig();
const containerName = this.getContainerName();
const dataDir = this.getPlatformDataDir('mariadb');
logger.info(`Deploying MariaDB platform service as ${containerName}...`);
// Check if we have existing data and stored credentials
const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type);
let adminCredentials: { username: string; password: string };
let dataExists = false;
// Check if data directory has existing MariaDB data
try {
const stat = await Deno.stat(`${dataDir}/ibdata1`);
dataExists = stat.isFile;
logger.info(`MariaDB data directory exists with ibdata1 file`);
} catch {
// ibdata1 file doesn't exist, this is a fresh install
dataExists = false;
}
if (dataExists && platformService?.adminCredentialsEncrypted) {
// Reuse existing credentials from database
logger.info('Reusing existing MariaDB credentials (data directory already initialized)');
adminCredentials = await credentialEncryption.decrypt<{ username: string; password: string }>(
platformService.adminCredentialsEncrypted,
);
} else {
// Generate new credentials for fresh deployment
logger.info('Generating new MariaDB admin credentials');
adminCredentials = {
username: 'root',
password: credentialEncryption.generatePassword(32),
};
// If data exists but we don't have credentials, we need to wipe the data
if (dataExists) {
logger.warn('MariaDB data exists but no credentials in database - wiping data directory');
try {
await Deno.remove(dataDir, { recursive: true });
} catch (e) {
logger.error(`Failed to wipe MariaDB data directory: ${getErrorMessage(e)}`);
throw new Error('Cannot deploy MariaDB: data directory exists without credentials');
}
}
}
// Ensure data directory exists
try {
await Deno.mkdir(dataDir, { recursive: true });
} catch (e) {
// Directory might already exist
if (!(e instanceof Deno.errors.AlreadyExists)) {
logger.warn(`Could not create MariaDB data directory: ${getErrorMessage(e)}`);
}
}
// Create container using Docker API
const envVars = [
`MARIADB_ROOT_PASSWORD=${adminCredentials.password}`,
];
// Use Docker to create the container
const containerId = await this.oneboxRef.docker.createPlatformContainer({
name: containerName,
image: config.image,
port: config.port,
env: envVars,
volumes: config.volumes,
network: this.getNetworkName(),
});
// Store encrypted admin credentials (only update if new or changed)
const encryptedCreds = await credentialEncryption.encrypt(adminCredentials);
if (platformService) {
this.oneboxRef.database.updatePlatformService(platformService.id!, {
containerId,
adminCredentialsEncrypted: encryptedCreds,
status: 'starting',
});
}
logger.success(`MariaDB container created: ${containerId}`);
return containerId;
}
async stopContainer(containerId: string): Promise<void> {
logger.info(`Stopping MariaDB container ${containerId}...`);
await this.oneboxRef.docker.stopContainer(containerId);
logger.success('MariaDB container stopped');
}
async healthCheck(): Promise<boolean> {
try {
logger.info('MariaDB health check: starting...');
const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type);
if (!platformService) {
logger.info('MariaDB health check: platform service not found in database');
return false;
}
if (!platformService.adminCredentialsEncrypted) {
logger.info('MariaDB health check: no admin credentials stored');
return false;
}
if (!platformService.containerId) {
logger.info('MariaDB health check: no container ID in database record');
return false;
}
logger.info(`MariaDB health check: using container ID ${platformService.containerId.substring(0, 12)}...`);
const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
// Use docker exec to run health check inside the container
const result = await this.oneboxRef.docker.execInContainer(
platformService.containerId,
['mariadb-admin', 'ping', '-u', 'root', `-p${adminCreds.password}`]
);
if (result.exitCode === 0) {
logger.info('MariaDB health check: success');
return true;
} else {
logger.info(`MariaDB health check failed: exit code ${result.exitCode}, stderr: ${result.stderr.substring(0, 200)}`);
return false;
}
} catch (error) {
logger.info(`MariaDB health check exception: ${getErrorMessage(error)}`);
return false;
}
}
async provisionResource(userService: IService): Promise<IProvisionedResource> {
const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type);
if (!platformService || !platformService.adminCredentialsEncrypted || !platformService.containerId) {
throw new Error('MariaDB platform service not found or not configured');
}
const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
const containerName = this.getContainerName();
// Generate resource names and credentials
const dbName = this.generateResourceName(userService.name);
const username = this.generateResourceName(userService.name);
const password = credentialEncryption.generatePassword(32);
logger.info(`Provisioning MariaDB database '${dbName}' for service '${userService.name}'...`);
// Create database and user via mariadb inside the container
const sql = [
`CREATE DATABASE IF NOT EXISTS \`${dbName}\`;`,
`CREATE USER IF NOT EXISTS '${username}'@'%' IDENTIFIED BY '${password.replace(/'/g, "\\'")}';`,
`GRANT ALL PRIVILEGES ON \`${dbName}\`.* TO '${username}'@'%';`,
`FLUSH PRIVILEGES;`,
].join(' ');
const result = await this.oneboxRef.docker.execInContainer(
platformService.containerId,
[
'mariadb',
'-u', 'root',
`-p${adminCreds.password}`,
'-e', sql,
]
);
if (result.exitCode !== 0) {
throw new Error(`Failed to provision MariaDB database: exit code ${result.exitCode}, output: ${result.stdout.substring(0, 200)} ${result.stderr.substring(0, 200)}`);
}
logger.success(`MariaDB database '${dbName}' provisioned with user '${username}'`);
// Build the credentials and env vars
const credentials: Record<string, string> = {
host: containerName,
port: '3306',
database: dbName,
username,
password,
connectionString: `mysql://${username}:${password}@${containerName}:3306/${dbName}`,
};
// Map credentials to env vars
const envVars: Record<string, string> = {};
for (const mapping of this.getEnvVarMappings()) {
if (credentials[mapping.credentialPath]) {
envVars[mapping.envVar] = credentials[mapping.credentialPath];
}
}
return {
type: 'database',
name: dbName,
credentials,
envVars,
};
}
async deprovisionResource(resource: IPlatformResource, credentials: Record<string, string>): Promise<void> {
const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type);
if (!platformService || !platformService.adminCredentialsEncrypted || !platformService.containerId) {
throw new Error('MariaDB platform service not found or not configured');
}
const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
logger.info(`Deprovisioning MariaDB database '${resource.resourceName}'...`);
const sql = [
`DROP USER IF EXISTS '${credentials.username}'@'%';`,
`DROP DATABASE IF EXISTS \`${resource.resourceName}\`;`,
].join(' ');
const result = await this.oneboxRef.docker.execInContainer(
platformService.containerId,
[
'mariadb',
'-u', 'root',
`-p${adminCreds.password}`,
'-e', sql,
]
);
if (result.exitCode !== 0) {
logger.warn(`MariaDB deprovision returned exit code ${result.exitCode}: ${result.stderr.substring(0, 200)}`);
}
logger.success(`MariaDB database '${resource.resourceName}' dropped`);
}
}
+43 -117
View File
@@ -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');
@@ -196,84 +198,28 @@ export class MinioProvider extends BasePlatformServiceProvider {
const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted); const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
const containerName = this.getContainerName(); const containerName = this.getContainerName();
// Get container host port for connection from host (overlay network IPs not accessible from host) // Generate bucket name
const hostPort = await this.oneboxRef.docker.getContainerHostPort(platformService.containerId, 9000);
if (!hostPort) {
throw new Error('Could not get MinIO container host port');
}
// Generate bucket name and credentials
const bucketName = this.generateBucketName(userService.name); const bucketName = this.generateBucketName(userService.name);
const accessKey = credentialEncryption.generateAccessKey(20);
const secretKey = credentialEncryption.generateSecretKey(40);
logger.info(`Provisioning MinIO bucket '${bucketName}' for service '${userService.name}'...`); logger.info(`Provisioning MinIO bucket '${bucketName}' for service '${userService.name}'...`);
// Connect to MinIO via localhost and the mapped host port (for provisioning from host) // Use docker exec with mc (MinIO Client) inside the container
const provisioningEndpoint = `http://127.0.0.1:${hostPort}`; // First configure mc alias for local server
await this.execMc(platformService.containerId, [
// Import AWS S3 client 'alias', 'set', 'local', 'http://localhost:9000',
const { S3Client, CreateBucketCommand, PutBucketPolicyCommand } = await import('npm:@aws-sdk/client-s3@3'); adminCreds.username, adminCreds.password,
]);
// Create S3 client with admin credentials - connect via host port
const s3Client = new S3Client({
endpoint: provisioningEndpoint,
region: 'us-east-1',
credentials: {
accessKeyId: adminCreds.username,
secretAccessKey: adminCreds.password,
},
forcePathStyle: true,
});
// Create the bucket // Create the bucket
try { const mbResult = await this.execMc(platformService.containerId, [
await s3Client.send(new CreateBucketCommand({ 'mb', '--ignore-existing', `local/${bucketName}`,
Bucket: bucketName, ]);
}));
logger.info(`Created MinIO bucket '${bucketName}'`); logger.info(`Created MinIO bucket '${bucketName}'`);
} catch (e: any) {
if (e.name !== 'BucketAlreadyOwnedByYou' && e.name !== 'BucketAlreadyExists') {
throw e;
}
logger.warn(`Bucket '${bucketName}' already exists`);
}
// Create service account/access key using MinIO Admin API // Set bucket policy to allow public read/write (services on the same network use root creds)
// MinIO Admin API requires mc client or direct API calls await this.execMc(platformService.containerId, [
// For simplicity, we'll use root credentials and bucket policy isolation 'anonymous', 'set', 'none', `local/${bucketName}`,
// In production, you'd use MinIO's Admin API to create service accounts ]);
// Set bucket policy to allow access only with this bucket's credentials
const bucketPolicy = {
Version: '2012-10-17',
Statement: [
{
Effect: 'Allow',
Principal: { AWS: ['*'] },
Action: ['s3:GetObject', 's3:PutObject', 's3:DeleteObject', 's3:ListBucket'],
Resource: [
`arn:aws:s3:::${bucketName}`,
`arn:aws:s3:::${bucketName}/*`,
],
},
],
};
try {
await s3Client.send(new PutBucketPolicyCommand({
Bucket: bucketName,
Policy: JSON.stringify(bucketPolicy),
}));
logger.info(`Set bucket policy for '${bucketName}'`);
} catch (e) {
logger.warn(`Could not set bucket policy: ${getErrorMessage(e)}`);
}
// Note: For proper per-service credentials, MinIO Admin API should be used
// For now, we're providing the bucket with root access
// TODO: Implement MinIO service account creation
logger.warn('Using root credentials for MinIO access. Consider implementing service accounts for production.');
// Use container name for the endpoint in credentials (user services run in same network) // Use container name for the endpoint in credentials (user services run in same network)
const serviceEndpoint = `http://${containerName}:9000`; const serviceEndpoint = `http://${containerName}:9000`;
@@ -281,7 +227,7 @@ export class MinioProvider extends BasePlatformServiceProvider {
const credentials: Record<string, string> = { const credentials: Record<string, string> = {
endpoint: serviceEndpoint, endpoint: serviceEndpoint,
bucket: bucketName, bucket: bucketName,
accessKey: adminCreds.username, // Using root for now accessKey: adminCreds.username,
secretKey: adminCreds.password, secretKey: adminCreds.password,
region: 'us-east-1', region: 'us-east-1',
}; };
@@ -312,57 +258,37 @@ export class MinioProvider extends BasePlatformServiceProvider {
const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted); const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
// Get container host port for connection from host (overlay network IPs not accessible from host)
const hostPort = await this.oneboxRef.docker.getContainerHostPort(platformService.containerId, 9000);
if (!hostPort) {
throw new Error('Could not get MinIO container host port');
}
logger.info(`Deprovisioning MinIO bucket '${resource.resourceName}'...`); logger.info(`Deprovisioning MinIO bucket '${resource.resourceName}'...`);
const { S3Client, DeleteBucketCommand, ListObjectsV2Command, DeleteObjectsCommand } = await import('npm:@aws-sdk/client-s3@3'); // Configure mc alias
await this.execMc(platformService.containerId, [
const s3Client = new S3Client({ 'alias', 'set', 'local', 'http://localhost:9000',
endpoint: `http://127.0.0.1:${hostPort}`, adminCreds.username, adminCreds.password,
region: 'us-east-1', ]);
credentials: {
accessKeyId: adminCreds.username,
secretAccessKey: adminCreds.password,
},
forcePathStyle: true,
});
try { try {
// First, delete all objects in the bucket // Remove all objects and the bucket
let continuationToken: string | undefined; await this.execMc(platformService.containerId, [
do { 'rb', '--force', `local/${resource.resourceName}`,
const listResponse = await s3Client.send(new ListObjectsV2Command({ ]);
Bucket: resource.resourceName,
ContinuationToken: continuationToken,
}));
if (listResponse.Contents && listResponse.Contents.length > 0) {
await s3Client.send(new DeleteObjectsCommand({
Bucket: resource.resourceName,
Delete: {
Objects: listResponse.Contents.map(obj => ({ Key: obj.Key! })),
},
}));
logger.info(`Deleted ${listResponse.Contents.length} objects from bucket`);
}
continuationToken = listResponse.IsTruncated ? listResponse.NextContinuationToken : undefined;
} while (continuationToken);
// Now delete the bucket
await s3Client.send(new DeleteBucketCommand({
Bucket: resource.resourceName,
}));
logger.success(`MinIO bucket '${resource.resourceName}' deleted`); logger.success(`MinIO bucket '${resource.resourceName}' deleted`);
} catch (e) { } catch (e) {
logger.error(`Failed to delete MinIO bucket: ${getErrorMessage(e)}`); logger.error(`Failed to delete MinIO bucket: ${getErrorMessage(e)}`);
throw e; throw e;
} }
} }
/**
* Execute mc (MinIO Client) command inside the container
*/
private async execMc(
containerId: string,
args: string[],
): Promise<{ stdout: string; stderr: string }> {
const result = await this.oneboxRef.docker.execInContainer(containerId, ['mc', ...args]);
if (result.exitCode !== 0) {
throw new Error(`mc command failed (exit ${result.exitCode}): ${result.stderr.substring(0, 200)}`);
}
return result;
}
} }
@@ -28,9 +28,9 @@ export class MongoDBProvider extends BasePlatformServiceProvider {
getDefaultConfig(): IPlatformServiceConfig { getDefaultConfig(): IPlatformServiceConfig {
return { return {
image: 'mongo:7', 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');
@@ -165,7 +167,7 @@ export class MongoDBProvider extends BasePlatformServiceProvider {
// This avoids network issues with overlay networks // This avoids network issues with overlay networks
const result = await this.oneboxRef.docker.execInContainer( const result = await this.oneboxRef.docker.execInContainer(
platformService.containerId, platformService.containerId,
['mongosh', '--eval', 'db.adminCommand("ping")', '--username', adminCreds.username, '--password', adminCreds.password, '--authenticationDatabase', 'admin', '--quiet'] ['mongo', '--eval', 'db.adminCommand("ping")', '--username', adminCreds.username, '--password', adminCreds.password, '--authenticationDatabase', 'admin', '--quiet']
); );
if (result.exitCode === 0) { if (result.exitCode === 0) {
@@ -190,12 +192,6 @@ export class MongoDBProvider extends BasePlatformServiceProvider {
const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted); const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
const containerName = this.getContainerName(); const containerName = this.getContainerName();
// Get container host port for connection from host (overlay network IPs not accessible from host)
const hostPort = await this.oneboxRef.docker.getContainerHostPort(platformService.containerId, 27017);
if (!hostPort) {
throw new Error('Could not get MongoDB container host port');
}
// Generate resource names and credentials // Generate resource names and credentials
const dbName = this.generateResourceName(userService.name); const dbName = this.generateResourceName(userService.name);
const username = this.generateResourceName(userService.name); const username = this.generateResourceName(userService.name);
@@ -203,31 +199,39 @@ export class MongoDBProvider extends BasePlatformServiceProvider {
logger.info(`Provisioning MongoDB database '${dbName}' for service '${userService.name}'...`); logger.info(`Provisioning MongoDB database '${dbName}' for service '${userService.name}'...`);
// Connect to MongoDB via localhost and the mapped host port // Use docker exec to provision inside the container (avoids host port mapping issues)
const { MongoClient } = await import('npm:mongodb@6'); const escapedPassword = password.replace(/'/g, "'\\''");
const adminUri = `mongodb://${adminCreds.username}:${adminCreds.password}@127.0.0.1:${hostPort}/?authSource=admin`; const escapedAdminPassword = adminCreds.password.replace(/'/g, "'\\''");
const client = new MongoClient(adminUri); // Create database and user via mongo inside the container
await client.connect(); const mongoScript = `
db = db.getSiblingDB('${dbName}');
try { db.createCollection('_onebox_init');
// Create the database by switching to it (MongoDB creates on first write) db.createUser({
const db = client.db(dbName); user: '${username}',
pwd: '${escapedPassword}',
// Create a collection to ensure the database exists roles: [{ role: 'readWrite', db: '${dbName}' }]
await db.createCollection('_onebox_init');
// Create user with readWrite access to this database
await db.command({
createUser: username,
pwd: password,
roles: [{ role: 'readWrite', db: dbName }],
}); });
print('PROVISION_SUCCESS');
`;
const result = await this.oneboxRef.docker.execInContainer(
platformService.containerId,
[
'mongo',
'--username', adminCreds.username,
'--password', escapedAdminPassword,
'--authenticationDatabase', 'admin',
'--quiet',
'--eval', mongoScript,
]
);
if (result.exitCode !== 0 || !result.stdout.includes('PROVISION_SUCCESS')) {
throw new Error(`Failed to provision MongoDB database: exit code ${result.exitCode}, output: ${result.stdout.substring(0, 200)} ${result.stderr.substring(0, 200)}`);
}
logger.success(`MongoDB database '${dbName}' provisioned with user '${username}'`); logger.success(`MongoDB database '${dbName}' provisioned with user '${username}'`);
} finally {
await client.close();
}
// Build the credentials and env vars // Build the credentials and env vars
const credentials: Record<string, string> = { const credentials: Record<string, string> = {
@@ -262,37 +266,33 @@ export class MongoDBProvider extends BasePlatformServiceProvider {
} }
const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted); const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
const escapedAdminPassword = adminCreds.password.replace(/'/g, "'\\''");
// Get container host port for connection from host (overlay network IPs not accessible from host)
const hostPort = await this.oneboxRef.docker.getContainerHostPort(platformService.containerId, 27017);
if (!hostPort) {
throw new Error('Could not get MongoDB container host port');
}
logger.info(`Deprovisioning MongoDB database '${resource.resourceName}'...`); logger.info(`Deprovisioning MongoDB database '${resource.resourceName}'...`);
const { MongoClient } = await import('npm:mongodb@6'); const mongoScript = `
const adminUri = `mongodb://${adminCreds.username}:${adminCreds.password}@127.0.0.1:${hostPort}/?authSource=admin`; db = db.getSiblingDB('${resource.resourceName}');
try { db.dropUser('${credentials.username}'); } catch(e) { print('User drop failed: ' + e); }
db.dropDatabase();
print('DEPROVISION_SUCCESS');
`;
const client = new MongoClient(adminUri); const result = await this.oneboxRef.docker.execInContainer(
await client.connect(); platformService.containerId,
[
'mongo',
'--username', adminCreds.username,
'--password', escapedAdminPassword,
'--authenticationDatabase', 'admin',
'--quiet',
'--eval', mongoScript,
]
);
try { if (result.exitCode !== 0) {
const db = client.db(resource.resourceName); logger.warn(`MongoDB deprovision returned exit code ${result.exitCode}: ${result.stderr.substring(0, 200)}`);
// Drop the user
try {
await db.command({ dropUser: credentials.username });
logger.info(`Dropped MongoDB user '${credentials.username}'`);
} catch (e) {
logger.warn(`Could not drop MongoDB user: ${getErrorMessage(e)}`);
} }
// Drop the database
await db.dropDatabase();
logger.success(`MongoDB database '${resource.resourceName}' dropped`); logger.success(`MongoDB database '${resource.resourceName}' dropped`);
} finally {
await client.close();
}
} }
} }
@@ -0,0 +1,285 @@
/**
* Redis Platform Service Provider
*/
import { BasePlatformServiceProvider } from './base.ts';
import type {
IService,
IPlatformResource,
IPlatformServiceConfig,
IProvisionedResource,
IEnvVarMapping,
TPlatformServiceType,
TPlatformResourceType,
} from '../../../types.ts';
import { logger } from '../../../logging.ts';
import { getErrorMessage } from '../../../utils/error.ts';
import { credentialEncryption } from '../../encryption.ts';
import type { Onebox } from '../../onebox.ts';
export class RedisProvider extends BasePlatformServiceProvider {
readonly type: TPlatformServiceType = 'redis';
readonly displayName = 'Redis';
readonly resourceTypes: TPlatformResourceType[] = ['cache'];
constructor(oneboxRef: Onebox) {
super(oneboxRef);
}
getDefaultConfig(): IPlatformServiceConfig {
return {
image: 'redis:7-alpine',
port: 6379,
volumes: [`${this.getPlatformDataDir('redis')}:/data`],
environment: {},
};
}
getEnvVarMappings(): IEnvVarMapping[] {
return [
{ envVar: 'REDIS_HOST', credentialPath: 'host' },
{ envVar: 'REDIS_PORT', credentialPath: 'port' },
{ envVar: 'REDIS_PASSWORD', credentialPath: 'password' },
{ envVar: 'REDIS_DB', credentialPath: 'db' },
{ envVar: 'REDIS_URL', credentialPath: 'connectionString' },
];
}
async deployContainer(): Promise<string> {
const config = this.getDefaultConfig();
const containerName = this.getContainerName();
const dataDir = this.getPlatformDataDir('redis');
logger.info(`Deploying Redis platform service as ${containerName}...`);
// Check if we have existing data and stored credentials
const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type);
let adminCredentials: { username: string; password: string };
let dataExists = false;
// Check if data directory has existing Redis data
try {
const stat = await Deno.stat(`${dataDir}/dump.rdb`);
dataExists = stat.isFile;
logger.info(`Redis data directory exists with dump.rdb file`);
} catch {
// Also check for appendonly file
try {
const stat = await Deno.stat(`${dataDir}/appendonly.aof`);
dataExists = stat.isFile;
logger.info(`Redis data directory exists with appendonly.aof file`);
} catch {
dataExists = false;
}
}
if (dataExists && platformService?.adminCredentialsEncrypted) {
// Reuse existing credentials from database
logger.info('Reusing existing Redis credentials (data directory already initialized)');
adminCredentials = await credentialEncryption.decrypt<{ username: string; password: string }>(
platformService.adminCredentialsEncrypted,
);
} else {
// Generate new credentials for fresh deployment
logger.info('Generating new Redis admin credentials');
adminCredentials = {
username: 'default',
password: credentialEncryption.generatePassword(32),
};
// If data exists but we don't have credentials, we need to wipe the data
if (dataExists) {
logger.warn('Redis data exists but no credentials in database - wiping data directory');
try {
await Deno.remove(dataDir, { recursive: true });
} catch (e) {
logger.error(`Failed to wipe Redis data directory: ${getErrorMessage(e)}`);
throw new Error('Cannot deploy Redis: data directory exists without credentials');
}
}
}
// Ensure data directory exists
try {
await Deno.mkdir(dataDir, { recursive: true });
} catch (e) {
// Directory might already exist
if (!(e instanceof Deno.errors.AlreadyExists)) {
logger.warn(`Could not create Redis data directory: ${getErrorMessage(e)}`);
}
}
// Redis uses command args for password, not env vars
const containerId = await this.oneboxRef.docker.createPlatformContainer({
name: containerName,
image: config.image,
port: config.port,
env: [],
volumes: config.volumes,
network: this.getNetworkName(),
command: ['redis-server', '--requirepass', adminCredentials.password, '--appendonly', 'yes'],
});
// Store encrypted admin credentials (only update if new or changed)
const encryptedCreds = await credentialEncryption.encrypt(adminCredentials);
if (platformService) {
this.oneboxRef.database.updatePlatformService(platformService.id!, {
containerId,
adminCredentialsEncrypted: encryptedCreds,
status: 'starting',
});
}
logger.success(`Redis container created: ${containerId}`);
return containerId;
}
async stopContainer(containerId: string): Promise<void> {
logger.info(`Stopping Redis container ${containerId}...`);
await this.oneboxRef.docker.stopContainer(containerId);
logger.success('Redis container stopped');
}
async healthCheck(): Promise<boolean> {
try {
logger.info('Redis health check: starting...');
const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type);
if (!platformService) {
logger.info('Redis health check: platform service not found in database');
return false;
}
if (!platformService.adminCredentialsEncrypted) {
logger.info('Redis health check: no admin credentials stored');
return false;
}
if (!platformService.containerId) {
logger.info('Redis health check: no container ID in database record');
return false;
}
logger.info(`Redis health check: using container ID ${platformService.containerId.substring(0, 12)}...`);
const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
// Use docker exec to run health check inside the container
const result = await this.oneboxRef.docker.execInContainer(
platformService.containerId,
['redis-cli', '-a', adminCreds.password, 'ping']
);
if (result.exitCode === 0 && result.stdout.includes('PONG')) {
logger.info('Redis health check: success');
return true;
} else {
logger.info(`Redis health check failed: exit code ${result.exitCode}, stdout: ${result.stdout.substring(0, 200)}`);
return false;
}
} catch (error) {
logger.info(`Redis health check exception: ${getErrorMessage(error)}`);
return false;
}
}
async provisionResource(userService: IService): Promise<IProvisionedResource> {
const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type);
if (!platformService || !platformService.adminCredentialsEncrypted) {
throw new Error('Redis platform service not found or not configured');
}
const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
const containerName = this.getContainerName();
// Determine the next available DB index (1-15, reserving 0 for admin)
const existingResources = this.oneboxRef.database.getPlatformResourcesByPlatformService(platformService.id!);
const usedIndexes = new Set<number>();
for (const resource of existingResources) {
try {
const creds = await credentialEncryption.decrypt(resource.credentialsEncrypted);
if (creds.db) {
usedIndexes.add(parseInt(creds.db, 10));
}
} catch {
// Skip resources with corrupt credentials
}
}
let dbIndex = -1;
for (let i = 1; i <= 15; i++) {
if (!usedIndexes.has(i)) {
dbIndex = i;
break;
}
}
if (dbIndex === -1) {
throw new Error('No available Redis database indexes (max 15 services per Redis instance)');
}
const resourceName = this.generateResourceName(userService.name);
logger.info(`Provisioning Redis database index ${dbIndex} for service '${userService.name}'...`);
// No server-side creation needed - Redis DB indexes exist implicitly
// Just verify connectivity
if (platformService.containerId) {
const result = await this.oneboxRef.docker.execInContainer(
platformService.containerId,
['redis-cli', '-a', adminCreds.password, '-n', String(dbIndex), 'ping']
);
if (result.exitCode !== 0 || !result.stdout.includes('PONG')) {
throw new Error(`Failed to verify Redis database ${dbIndex}: exit code ${result.exitCode}`);
}
}
logger.success(`Redis database index ${dbIndex} provisioned for service '${userService.name}'`);
// Build the credentials and env vars
const credentials: Record<string, string> = {
host: containerName,
port: '6379',
password: adminCreds.password,
db: String(dbIndex),
connectionString: `redis://:${adminCreds.password}@${containerName}:6379/${dbIndex}`,
};
// Map credentials to env vars
const envVars: Record<string, string> = {};
for (const mapping of this.getEnvVarMappings()) {
if (credentials[mapping.credentialPath]) {
envVars[mapping.envVar] = credentials[mapping.credentialPath];
}
}
return {
type: 'cache',
name: resourceName,
credentials,
envVars,
};
}
async deprovisionResource(resource: IPlatformResource, credentials: Record<string, string>): Promise<void> {
const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type);
if (!platformService || !platformService.adminCredentialsEncrypted || !platformService.containerId) {
throw new Error('Redis platform service not found or not configured');
}
const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
const dbIndex = credentials.db || '0';
logger.info(`Deprovisioning Redis database index ${dbIndex} for resource '${resource.resourceName}'...`);
// Flush the specific database
const result = await this.oneboxRef.docker.execInContainer(
platformService.containerId,
['redis-cli', '-a', adminCreds.password, '-n', dbIndex, 'FLUSHDB']
);
if (result.exitCode !== 0) {
logger.warn(`Redis deprovision returned exit code ${result.exitCode}: ${result.stderr.substring(0, 200)}`);
}
logger.success(`Redis database index ${dbIndex} flushed for resource '${resource.resourceName}'`);
}
}
@@ -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 = [
+22 -12
View File
@@ -2,7 +2,7 @@
* Onebox Registry Manager * Onebox Registry Manager
* *
* Manages the local Docker registry using: * Manages the local Docker registry using:
* - @push.rocks/smarts3 (S3-compatible server with filesystem storage) * - @push.rocks/smartstorage (S3-compatible server with filesystem storage)
* - @push.rocks/smartregistry (OCI-compliant Docker registry) * - @push.rocks/smartregistry (OCI-compliant Docker registry)
*/ */
@@ -27,7 +27,7 @@ export class RegistryManager {
} }
/** /**
* Initialize the registry (start smarts3 and smartregistry) * Initialize the registry (start smartstorage and smartregistry)
*/ */
async init(): Promise<void> { async init(): Promise<void> {
if (this.isInitialized) { if (this.isInitialized) {
@@ -39,10 +39,10 @@ export class RegistryManager {
const dataDir = this.options.dataDir || './.nogit/registry-data'; const dataDir = this.options.dataDir || './.nogit/registry-data';
const port = this.options.port || 4000; const port = this.options.port || 4000;
logger.info(`Starting smarts3 server on port ${port}...`); logger.info(`Starting smartstorage server on port ${port}...`);
// 1. Start smarts3 server (S3-compatible storage with filesystem backend) // 1. Start smartstorage server (S3-compatible storage with filesystem backend)
this.s3Server = await plugins.smarts3.Smarts3.createAndStart({ this.s3Server = await plugins.smartstorage.SmartStorage.createAndStart({
server: { server: {
port: port, port: port,
address: '0.0.0.0', address: '0.0.0.0',
@@ -53,16 +53,16 @@ export class RegistryManager {
}, },
}); });
logger.success(`smarts3 server started on port ${port}`); logger.success(`smartstorage server started on port ${port}`);
// 2. Configure smartregistry to use smarts3 // 2. Configure smartregistry to use smartstorage
logger.info('Initializing smartregistry...'); logger.info('Initializing smartregistry...');
this.registry = new plugins.smartregistry.SmartRegistry({ this.registry = new plugins.smartregistry.SmartRegistry({
storage: { storage: {
endpoint: 'localhost', endpoint: 'localhost',
port: port, port: port,
accessKey: 'onebox', // smarts3 doesn't validate credentials accessKey: 'onebox', // smartstorage doesn't validate credentials
accessSecret: 'onebox', accessSecret: 'onebox',
useSsl: false, useSsl: false,
region: 'us-east-1', region: 'us-east-1',
@@ -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',
}, },
}, },
@@ -314,16 +314,26 @@ export class RegistryManager {
} }
/** /**
* Stop the registry and smarts3 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();
logger.info('smarts3 server stopped'); logger.info('smartstorage server stopped');
} catch (error) { } catch (error) {
logger.error(`Error stopping smarts3: ${getErrorMessage(error)}`); logger.error(`Error stopping smartstorage: ${getErrorMessage(error)}`);
} }
this.s3Server = null;
} }
this.isInitialized = false; this.isInitialized = false;
+38 -44
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.
@@ -11,7 +11,7 @@
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 { CaddyManager } from './caddy.ts'; import { SmartProxyManager } from './smartproxy.ts';
interface IProxyRoute { interface IProxyRoute {
domain: string; domain: string;
@@ -24,7 +24,7 @@ interface IProxyRoute {
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 +32,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 +65,19 @@ export class OneboxReverseProxy {
} }
/** /**
* Start HTTPS - Caddy already handles HTTPS when started * Start HTTPS - SmartProxy already handles HTTPS when started
* This method exists for interface compatibility * 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 +85,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 +102,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;
@@ -119,9 +116,9 @@ export class OneboxReverseProxy {
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) {
@@ -133,12 +130,9 @@ export class OneboxReverseProxy {
/** /**
* 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 +153,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();
@@ -181,7 +175,7 @@ export class OneboxReverseProxy {
/** /**
* 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 +183,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 +207,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 +224,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',
}; };
} }
} }
+106 -20
View File
@@ -15,6 +15,7 @@ export class OneboxServicesManager {
private oneboxRef: any; // Will be Onebox instance private oneboxRef: any; // Will be Onebox instance
private database: OneboxDatabase; private database: OneboxDatabase;
private docker: OneboxDockerManager; private docker: OneboxDockerManager;
private autoUpdateIntervalId: number | null = null;
constructor(oneboxRef: any) { constructor(oneboxRef: any) {
this.oneboxRef = oneboxRef; this.oneboxRef = oneboxRef;
@@ -22,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)
*/ */
@@ -49,11 +79,13 @@ export class OneboxServicesManager {
// Build platform requirements // Build platform requirements
const platformRequirements: IPlatformRequirements | undefined = const platformRequirements: IPlatformRequirements | undefined =
(options.enableMongoDB || options.enableS3 || options.enableClickHouse) (options.enableMongoDB || options.enableS3 || options.enableClickHouse || options.enableRedis || options.enableMariaDB)
? { ? {
mongodb: options.enableMongoDB, mongodb: options.enableMongoDB,
s3: options.enableS3, s3: options.enableS3,
clickhouse: options.enableClickHouse, clickhouse: options.enableClickHouse,
redis: options.enableRedis,
mariadb: options.enableMariaDB,
} }
: undefined; : undefined;
@@ -75,6 +107,9 @@ export class OneboxServicesManager {
autoUpdateOnPush: options.autoUpdateOnPush, autoUpdateOnPush: options.autoUpdateOnPush,
// Platform requirements // Platform requirements
platformRequirements, platformRequirements,
// App Store template tracking
appTemplateId: options.appTemplateId,
appTemplateVersion: options.appTemplateVersion,
}); });
// Provision platform resources if needed // Provision platform resources if needed
@@ -95,9 +130,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 });
} }
@@ -187,11 +228,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;
@@ -227,15 +272,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;
} }
} }
@@ -264,10 +313,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;
@@ -295,6 +346,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;
@@ -325,11 +377,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
} }
@@ -351,6 +405,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;
@@ -587,10 +642,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
@@ -619,7 +676,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;
@@ -653,11 +715,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)}`);
@@ -675,13 +733,36 @@ 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
*/ */
startAutoUpdateMonitoring(): void { startAutoUpdateMonitoring(): void {
// Check every 30 seconds // Check every 30 seconds
setInterval(async () => { this.autoUpdateIntervalId = setInterval(async () => {
try { try {
await this.checkForRegistryUpdates(); await this.checkForRegistryUpdates();
} catch (error) { } catch (error) {
@@ -692,6 +773,17 @@ export class OneboxServicesManager {
logger.info('Auto-update monitoring started (30s interval)'); logger.info('Auto-update monitoring started (30s interval)');
} }
/**
* Stop auto-update monitoring
*/
stopAutoUpdateMonitoring(): void {
if (this.autoUpdateIntervalId !== null) {
clearInterval(this.autoUpdateIntervalId);
this.autoUpdateIntervalId = null;
logger.debug('Auto-update monitoring stopped');
}
}
/** /**
* Check all services using onebox registry for updates * Check all services using onebox registry for updates
*/ */
@@ -739,12 +831,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!, {
+477
View File
@@ -0,0 +1,477 @@
/**
* SmartProxy Manager for Onebox
*
* Manages SmartProxy as a Docker Swarm service so it can route to services on
* the Onebox overlay network.
*/
import * as plugins from '../plugins.ts';
import { logger } from '../logging.ts';
import { getErrorMessage } from '../utils/error.ts';
const SMARTPROXY_SERVICE_NAME = 'onebox-smartproxy';
const LEGACY_CADDY_SERVICE_NAME = 'onebox-caddy';
const SMARTPROXY_IMAGE = 'code.foss.global/host.today/ht-docker-smartproxy:latest';
const SMARTPROXY_ADMIN_CONTAINER_PORT = 3000;
const SMARTPROXY_HTTP_CONTAINER_PORT = 80;
const SMARTPROXY_HTTPS_CONTAINER_PORT = 443;
export interface ISmartProxyRoute {
domain: string;
upstream: string;
}
export interface ISmartProxyCertificate {
domain: string;
certPem: string;
keyPem: string;
}
interface ISmartProxyRouteConfig {
name: string;
match: {
ports: number;
domains: string;
protocol?: 'http' | 'tcp' | 'udp' | 'quic' | 'http3';
};
action: {
type: 'forward';
targets: Array<{ host: string; port: number }>;
tls?: {
mode: 'terminate';
certificate: {
key: string;
cert: string;
};
};
websocket?: {
enabled: boolean;
};
};
priority?: number;
}
export class SmartProxyManager {
private dockerClient: InstanceType<typeof plugins.docker.Docker> | null = null;
private certsDir: string;
private adminUrl: string;
private adminPort: number;
private httpPort: number;
private httpsPort: number;
private routes: Map<string, ISmartProxyRoute> = new Map();
private certificates: Map<string, ISmartProxyCertificate> = new Map();
private networkName = 'onebox-network';
private serviceRunning = false;
constructor(options?: {
certsDir?: string;
adminPort?: number;
httpPort?: number;
httpsPort?: number;
}) {
this.certsDir = options?.certsDir || './.nogit/certs';
this.adminPort = options?.adminPort || 2019;
this.adminUrl = `http://localhost:${this.adminPort}`;
this.httpPort = options?.httpPort || 8080;
this.httpsPort = options?.httpsPort || 8443;
}
private async ensureDockerClient(): Promise<void> {
if (!this.dockerClient) {
this.dockerClient = new plugins.docker.Docker({
socketPath: 'unix:///var/run/docker.sock',
});
await this.dockerClient.start();
}
}
setPorts(httpPort: number, httpsPort: number): void {
this.httpPort = httpPort;
this.httpsPort = httpsPort;
}
async start(): Promise<void> {
if (this.serviceRunning) {
logger.warn('SmartProxy service is already running');
return;
}
try {
await this.ensureDockerClient();
await Deno.mkdir(this.certsDir, { recursive: true });
logger.info('Starting SmartProxy Docker service...');
const legacyService = await this.getExistingService(LEGACY_CADDY_SERVICE_NAME);
if (legacyService) {
logger.info('Legacy Caddy service exists, removing it before SmartProxy startup...');
await this.removeService(LEGACY_CADDY_SERVICE_NAME);
await new Promise((resolve) => setTimeout(resolve, 2000));
}
const existingService = await this.getExistingService();
if (existingService) {
logger.info('SmartProxy service exists, removing old service...');
await this.removeService();
await new Promise((resolve) => setTimeout(resolve, 2000));
}
const networkId = await this.getNetworkId();
const response = await this.dockerClient!.request('POST', '/services/create', {
Name: SMARTPROXY_SERVICE_NAME,
Labels: {
'managed-by': 'onebox',
'onebox-type': 'smartproxy',
},
TaskTemplate: {
ContainerSpec: {
Image: SMARTPROXY_IMAGE,
Env: [
'SMARTPROXY_ADMIN_HOST=0.0.0.0',
`SMARTPROXY_ADMIN_PORT=${SMARTPROXY_ADMIN_CONTAINER_PORT}`,
],
},
Networks: [
{
Target: networkId,
},
],
RestartPolicy: {
Condition: 'any',
MaxAttempts: 0,
},
},
Mode: {
Replicated: {
Replicas: 1,
},
},
EndpointSpec: {
Ports: [
{
Protocol: 'tcp',
TargetPort: SMARTPROXY_HTTP_CONTAINER_PORT,
PublishedPort: this.httpPort,
PublishMode: 'host',
},
{
Protocol: 'tcp',
TargetPort: SMARTPROXY_HTTPS_CONTAINER_PORT,
PublishedPort: this.httpsPort,
PublishMode: 'host',
},
{
Protocol: 'tcp',
TargetPort: SMARTPROXY_ADMIN_CONTAINER_PORT,
PublishedPort: this.adminPort,
PublishMode: 'host',
},
],
},
});
if (response.statusCode >= 300) {
throw new Error(`Failed to create SmartProxy service: HTTP ${response.statusCode} - ${JSON.stringify(response.body)}`);
}
logger.info(`SmartProxy service created: ${response.body.ID}`);
await this.waitForReady();
this.serviceRunning = true;
await this.reloadConfig();
logger.success(`SmartProxy started (HTTP: ${this.httpPort}, HTTPS: ${this.httpsPort}, Admin: ${this.adminUrl})`);
} catch (error) {
logger.error(`Failed to start SmartProxy: ${getErrorMessage(error)}`);
throw error;
}
}
private async getExistingService(serviceNameArg = SMARTPROXY_SERVICE_NAME): Promise<any | null> {
try {
const response = await this.dockerClient!.request('GET', `/services/${serviceNameArg}`, {});
if (response.statusCode === 200) {
return response.body;
}
return null;
} catch {
return null;
}
}
private async removeService(serviceNameArg = SMARTPROXY_SERVICE_NAME): Promise<void> {
try {
await this.dockerClient!.request('DELETE', `/services/${serviceNameArg}`, {});
} catch {
// Service may not exist.
}
}
private async getNetworkId(): Promise<string> {
const networks = await this.dockerClient!.listNetworks();
const network = networks.find((n: any) => n.Name === this.networkName);
if (!network) {
throw new Error(`Network not found: ${this.networkName}`);
}
return network.Id;
}
private async waitForReady(maxAttempts = 10, intervalMs = 1000): Promise<void> {
for (let i = 0; i < maxAttempts; i++) {
try {
const response = await fetch(`${this.adminUrl}/ready`);
if (response.ok) {
return;
}
} catch {
// Not ready yet.
}
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
throw new Error('SmartProxy service failed to start within timeout');
}
async stop(): Promise<void> {
try {
await this.ensureDockerClient();
if (!this.serviceRunning && !(await this.getExistingService())) {
return;
}
logger.info('Stopping SmartProxy service...');
await this.removeService();
this.serviceRunning = false;
logger.info('SmartProxy service stopped');
} catch (error) {
logger.error(`Failed to stop SmartProxy: ${getErrorMessage(error)}`);
} finally {
if (this.dockerClient) {
try {
await this.dockerClient.stop();
} catch (error) {
logger.error(`Failed to stop SmartProxy Docker client: ${getErrorMessage(error)}`);
} finally {
this.dockerClient = null;
}
}
}
}
async isHealthy(): Promise<boolean> {
try {
const response = await fetch(`${this.adminUrl}/health`);
return response.ok;
} catch {
return false;
}
}
async isRunning(): Promise<boolean> {
try {
await this.ensureDockerClient();
const service = await this.getExistingService();
if (!service) return false;
const tasksResponse = await this.dockerClient!.request(
'GET',
`/tasks?filters=${encodeURIComponent(JSON.stringify({ service: [SMARTPROXY_SERVICE_NAME] }))}`,
{},
);
if (tasksResponse.statusCode !== 200) return false;
const tasks = tasksResponse.body;
return tasks.some((task: any) => task.Status?.State === 'running');
} catch {
return false;
}
}
private routeName(prefixArg: string, domainArg: string): string {
return `${prefixArg}-${domainArg.replace(/[^a-zA-Z0-9]+/g, '-').replace(/^-|-$/g, '')}`;
}
private parseUpstream(upstreamArg: string): { host: string; port: number } {
const separatorIndex = upstreamArg.lastIndexOf(':');
if (separatorIndex <= 0 || separatorIndex === upstreamArg.length - 1) {
throw new Error(`Invalid upstream target: ${upstreamArg}`);
}
const host = upstreamArg.slice(0, separatorIndex);
const port = Number(upstreamArg.slice(separatorIndex + 1));
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error(`Invalid upstream port in target: ${upstreamArg}`);
}
return { host, port };
}
private buildRoutes(): ISmartProxyRouteConfig[] {
const routeConfigs: ISmartProxyRouteConfig[] = [];
for (const [domain, route] of this.routes) {
const target = this.parseUpstream(route.upstream);
const baseAction = {
type: 'forward' as const,
targets: [target],
websocket: {
enabled: true,
},
};
routeConfigs.push({
name: this.routeName('http', domain),
match: {
ports: SMARTPROXY_HTTP_CONTAINER_PORT,
domains: domain,
protocol: 'http',
},
action: baseAction,
priority: 10,
});
const certificate = this.certificates.get(domain);
if (certificate) {
routeConfigs.push({
name: this.routeName('https', domain),
match: {
ports: SMARTPROXY_HTTPS_CONTAINER_PORT,
domains: domain,
protocol: 'http',
},
action: {
...baseAction,
tls: {
mode: 'terminate',
certificate: {
key: certificate.keyPem,
cert: certificate.certPem,
},
},
},
priority: 20,
});
}
}
return routeConfigs;
}
async reloadConfig(): Promise<void> {
const isRunning = await this.isRunning();
if (!isRunning) {
logger.warn('SmartProxy not running, cannot reload config');
return;
}
const routes = this.buildRoutes();
try {
const response = await fetch(`${this.adminUrl}/routes`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ routes }),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Failed to reload SmartProxy routes: ${response.status} ${text}`);
}
logger.debug('SmartProxy routes reloaded');
} catch (error) {
logger.error(`Failed to reload SmartProxy routes: ${getErrorMessage(error)}`);
throw error;
}
}
async addRoute(domain: string, upstream: string): Promise<void> {
this.routes.set(domain, { domain, upstream });
if (await this.isRunning()) {
await this.reloadConfig();
}
logger.success(`Added SmartProxy route: ${domain} -> ${upstream}`);
}
async removeRoute(domain: string): Promise<void> {
if (this.routes.delete(domain)) {
if (await this.isRunning()) {
await this.reloadConfig();
}
logger.success(`Removed SmartProxy route: ${domain}`);
}
}
async addCertificate(domain: string, certPem: string, keyPem: string): Promise<void> {
this.certificates.set(domain, {
domain,
certPem,
keyPem,
});
try {
await Deno.mkdir(this.certsDir, { recursive: true });
await Deno.writeTextFile(`${this.certsDir}/${domain}.crt`, certPem);
await Deno.writeTextFile(`${this.certsDir}/${domain}.key`, keyPem);
} catch (error) {
logger.warn(`Failed to write certificate backup for ${domain}: ${getErrorMessage(error)}`);
}
if (await this.isRunning()) {
await this.reloadConfig();
}
logger.success(`Added TLS certificate for ${domain}`);
}
async removeCertificate(domain: string): Promise<void> {
if (this.certificates.delete(domain)) {
try {
await Deno.remove(`${this.certsDir}/${domain}.crt`);
await Deno.remove(`${this.certsDir}/${domain}.key`);
} catch {
// Files may not exist.
}
if (await this.isRunning()) {
await this.reloadConfig();
}
logger.success(`Removed TLS certificate for ${domain}`);
}
}
getRoutes(): ISmartProxyRoute[] {
return Array.from(this.routes.values());
}
getCertificates(): ISmartProxyCertificate[] {
return Array.from(this.certificates.values());
}
clear(): void {
this.routes.clear();
this.certificates.clear();
}
getStatus(): {
running: boolean;
httpPort: number;
httpsPort: number;
routes: number;
certificates: number;
} {
return {
running: this.serviceRunning,
httpPort: this.httpPort,
httpsPort: this.httpsPort,
routes: this.routes.size,
certificates: this.certificates.size,
};
}
}
+2 -2
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;
} }
+243
View File
@@ -0,0 +1,243 @@
/**
* Systemd Service Manager for Onebox
*
* Handles systemd unit file installation, enabling, starting, stopping,
* and status checking. Modeled on nupst's direct systemctl approach —
* no external library dependencies.
*/
import { logger } from '../logging.ts';
import { getErrorMessage } from '../utils/error.ts';
const SERVICE_NAME = 'onebox';
const SERVICE_FILE_PATH = '/etc/systemd/system/onebox.service';
const SERVICE_UNIT_TEMPLATE = `[Unit]
Description=Onebox - Self-hosted container platform
After=network-online.target docker.service
Wants=network-online.target
Requires=docker.service
[Service]
Type=simple
ExecStart=/usr/local/bin/onebox systemd start-daemon
Restart=always
RestartSec=10
WorkingDirectory=/var/lib/onebox
Environment=PATH=/usr/bin:/usr/local/bin
Environment=HOME=/root
Environment=DENO_DIR=/root/.cache/deno
[Install]
WantedBy=multi-user.target
`;
export class OneboxSystemd {
/**
* Install and enable the systemd service
*/
async enable(): Promise<void> {
try {
// Ensure Docker is installed before writing unit file (it requires docker.service)
await this.ensureDocker();
// Write the unit file
logger.info('Writing systemd unit file...');
await Deno.writeTextFile(SERVICE_FILE_PATH, SERVICE_UNIT_TEMPLATE);
logger.info(`Unit file written to ${SERVICE_FILE_PATH}`);
// Reload systemd daemon
await this.runSystemctl(['daemon-reload']);
// Enable the service
const result = await this.runSystemctl(['enable', `${SERVICE_NAME}.service`]);
if (!result.success) {
throw new Error(`Failed to enable service: ${result.stderr}`);
}
logger.success('Onebox systemd service enabled');
logger.info('Start with: onebox systemd start');
} catch (error) {
logger.error(`Failed to enable service: ${getErrorMessage(error)}`);
throw error;
}
}
/**
* Stop, disable, and remove the systemd service
*/
async disable(): Promise<void> {
try {
// Stop the service (ignore errors if not running)
await this.runSystemctl(['stop', `${SERVICE_NAME}.service`]);
// Disable the service
await this.runSystemctl(['disable', `${SERVICE_NAME}.service`]);
// Remove the unit file
try {
await Deno.remove(SERVICE_FILE_PATH);
logger.info(`Removed ${SERVICE_FILE_PATH}`);
} catch {
// File might not exist
}
// Reload systemd daemon
await this.runSystemctl(['daemon-reload']);
logger.success('Onebox systemd service disabled and removed');
} catch (error) {
logger.error(`Failed to disable service: ${getErrorMessage(error)}`);
throw error;
}
}
/**
* Start the service via systemctl
*/
async start(): Promise<void> {
const result = await this.runSystemctl(['start', `${SERVICE_NAME}.service`]);
if (!result.success) {
logger.error(`Failed to start service: ${result.stderr}`);
throw new Error(`Failed to start onebox service`);
}
logger.success('Onebox service started');
}
/**
* Stop the service via systemctl
*/
async stop(): Promise<void> {
const result = await this.runSystemctl(['stop', `${SERVICE_NAME}.service`]);
if (!result.success) {
logger.error(`Failed to stop service: ${result.stderr}`);
throw new Error(`Failed to stop onebox service`);
}
logger.success('Onebox service stopped');
}
/**
* Get and display service status
*/
async getStatus(): Promise<string> {
const result = await this.runSystemctl(['status', `${SERVICE_NAME}.service`]);
const output = result.stdout;
let status: string;
if (output.includes('active (running)')) {
status = 'running';
} else if (output.includes('inactive') || output.includes('dead')) {
status = 'stopped';
} else if (output.includes('failed')) {
status = 'failed';
} else if (!result.success && result.stderr.includes('could not be found')) {
status = 'not-installed';
} else {
status = 'unknown';
}
// Print the raw systemctl output for full details
if (output.trim()) {
console.log(output);
}
return status;
}
/**
* Show service logs via journalctl
*/
async showLogs(): Promise<void> {
const cmd = new Deno.Command('journalctl', {
args: ['-u', `${SERVICE_NAME}.service`, '-f'],
stdout: 'inherit',
stderr: 'inherit',
});
await cmd.output();
}
/**
* Check if the service unit file is installed
*/
async isInstalled(): Promise<boolean> {
try {
await Deno.stat(SERVICE_FILE_PATH);
return true;
} catch {
return false;
}
}
/**
* Ensure Docker is installed, installing it if necessary
*/
private async ensureDocker(): Promise<void> {
try {
const cmd = new Deno.Command('docker', {
args: ['--version'],
stdout: 'piped',
stderr: 'piped',
});
const result = await cmd.output();
if (result.success) {
const version = new TextDecoder().decode(result.stdout).trim();
logger.info(`Docker found: ${version}`);
return;
}
} catch {
// docker command not found
}
logger.info('Docker not found. Installing Docker...');
const installCmd = new Deno.Command('bash', {
args: ['-c', 'curl -fsSL https://get.docker.com | sh'],
stdin: 'inherit',
stdout: 'inherit',
stderr: 'inherit',
});
const installResult = await installCmd.output();
if (!installResult.success) {
throw new Error('Failed to install Docker. Please install it manually: curl -fsSL https://get.docker.com | sh');
}
logger.success('Docker installed successfully');
// Initialize Docker Swarm
logger.info('Initializing Docker Swarm...');
const swarmCmd = new Deno.Command('docker', {
args: ['swarm', 'init'],
stdout: 'piped',
stderr: 'piped',
});
const swarmResult = await swarmCmd.output();
if (swarmResult.success) {
logger.success('Docker Swarm initialized');
} else {
const stderr = new TextDecoder().decode(swarmResult.stderr);
if (stderr.includes('already part of a swarm')) {
logger.info('Docker Swarm already initialized');
} else {
logger.warn(`Docker Swarm init warning: ${stderr.trim()}`);
}
}
}
/**
* Run a systemctl command and return results
*/
private async runSystemctl(
args: string[]
): Promise<{ success: boolean; stdout: string; stderr: string }> {
const cmd = new Deno.Command('systemctl', {
args,
stdout: 'piped',
stderr: 'piped',
});
const result = await cmd.output();
return {
success: result.success,
stdout: new TextDecoder().decode(result.stdout),
stderr: new TextDecoder().decode(result.stderr),
};
}
}
+337 -48
View File
@@ -7,16 +7,18 @@ import { projectInfo } from './info.ts';
import { getErrorMessage } from './utils/error.ts'; 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 type { IAppVersionConfig } from './classes/appstore-types.ts';
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;
} }
@@ -25,6 +27,19 @@ export async function runCli(): Promise<void> {
const subcommand = args[1]; const subcommand = args[1];
try { try {
// === LIGHTWEIGHT COMMANDS (no init()) ===
if (command === 'systemd') {
await handleSystemdCommand(subcommand, args.slice(2));
return;
}
if (command === 'upgrade') {
await handleUpgradeCommand();
return;
}
// === HEAVY COMMANDS (require full init()) ===
// Server command has special handling (doesn't shut down) // Server command has special handling (doesn't shut down)
if (command === 'server') { if (command === 'server') {
const onebox = new Onebox(); const onebox = new Onebox();
@@ -56,12 +71,13 @@ export async function runCli(): Promise<void> {
await handleSslCommand(onebox, subcommand, args.slice(2)); await handleSslCommand(onebox, subcommand, args.slice(2));
break; break;
case 'nginx': case 'appstore':
await handleNginxCommand(onebox, subcommand, args.slice(2)); await handleAppStoreCommand(onebox, subcommand, args.slice(2));
break; break;
case 'daemon': case 'proxy':
await handleDaemonCommand(onebox, subcommand, args.slice(2)); case 'nginx':
await handleNginxCommand(onebox, subcommand, args.slice(2));
break; break;
case 'config': case 'config':
@@ -94,12 +110,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;
@@ -148,6 +163,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;
} }
@@ -170,6 +186,76 @@ async function handleRegistryCommand(onebox: Onebox, subcommand: string, args: s
} }
} }
// App Store commands
async function handleAppStoreCommand(onebox: Onebox, subcommand: string, args: string[]) {
switch (subcommand) {
case 'list': {
const apps = await onebox.appStore.getApps();
logger.table(
['ID', 'Name', 'Category', 'Latest'],
apps.map((app) => [app.id, app.name, app.category, app.latestVersion])
);
break;
}
case 'config': {
const appId = args[0];
requireValue(appId, 'app id');
const appMeta = await onebox.appStore.getAppMeta(appId);
const version = getArg(args, '--version') || appMeta.latestVersion;
const config = await onebox.appStore.getAppVersionConfig(appId, version);
console.log(JSON.stringify({ appMeta, version, config }, null, 2));
break;
}
case 'install': {
const appId = args[0];
requireValue(appId, 'app id');
const appMeta = await onebox.appStore.getAppMeta(appId);
const version = getArg(args, '--version') || appMeta.latestVersion;
const config = await onebox.appStore.getAppVersionConfig(appId, version);
const serviceName = getArg(args, '--name') || appId;
const domain = getArg(args, '--domain');
const port = parseInt(getArg(args, '--port') || String(config.port), 10);
const envVars = getAppStoreEnvVars(config, parseEnvArgs(args));
const autoDNS = getBooleanArg(args, '--auto-dns', true);
requireValue(serviceName, '--name');
assertValidPort(port, '--port');
if (requiresTemplateValue(envVars, 'SERVICE_DOMAIN')) {
requireValue(domain, '--domain');
}
const service = await onebox.services.deployService({
name: serviceName,
image: config.image,
port,
domain,
autoDNS,
envVars,
enableMongoDB: Boolean(config.platformRequirements?.mongodb),
enableS3: Boolean(config.platformRequirements?.s3),
enableClickHouse: Boolean(config.platformRequirements?.clickhouse),
enableRedis: Boolean(config.platformRequirements?.redis),
enableMariaDB: Boolean(config.platformRequirements?.mariadb),
appTemplateId: appId,
appTemplateVersion: version,
});
logger.success(`Installed ${appMeta.name} ${version} as ${service.name}`);
if (service.domain) {
logger.info(`Route: https://${service.domain}`);
}
break;
}
default:
logger.error(`Unknown appstore subcommand: ${subcommand}`);
logger.info('Available: list, config, install');
}
}
// DNS commands // DNS commands
async function handleDnsCommand(onebox: Onebox, subcommand: string, args: string[]) { async function handleDnsCommand(onebox: Onebox, subcommand: string, args: string[]) {
switch (subcommand) { switch (subcommand) {
@@ -278,7 +364,7 @@ async function handleServerCommand(onebox: Onebox, args: string[]) {
await OneboxDaemon.ensureNoDaemon(); await OneboxDaemon.ensureNoDaemon();
} catch (error) { } catch (error) {
logger.error('Cannot start in ephemeral mode: Daemon is already running'); logger.error('Cannot start in ephemeral mode: Daemon is already running');
logger.info('Stop the daemon first: onebox daemon stop'); logger.info('Stop the daemon first: onebox systemd stop');
logger.info('Or run without --ephemeral to use the existing daemon'); logger.info('Or run without --ephemeral to use the existing daemon');
Deno.exit(1); Deno.exit(1);
} }
@@ -286,8 +372,8 @@ async function handleServerCommand(onebox: Onebox, args: string[]) {
logger.info('Starting Onebox server...'); logger.info('Starting Onebox server...');
// Start HTTP server // Start OpsServer (serves new UI + TypedRequest API)
await onebox.httpServer.start(port); await onebox.opsServer.start(port);
// Start monitoring if requested // Start monitoring if requested
if (monitor) { if (monitor) {
@@ -308,7 +394,7 @@ async function handleServerCommand(onebox: Onebox, args: string[]) {
if (monitor) { if (monitor) {
onebox.daemon.stopMonitoring(); onebox.daemon.stopMonitoring();
} }
await onebox.httpServer.stop(); await onebox.opsServer.stop();
await onebox.shutdown(); await onebox.shutdown();
Deno.exit(0); Deno.exit(0);
}; };
@@ -322,39 +408,49 @@ async function handleServerCommand(onebox: Onebox, args: string[]) {
} }
} }
// Daemon commands // Systemd service commands (lightweight — no Onebox init)
async function handleDaemonCommand(onebox: Onebox, subcommand: string, _args: string[]) { async function handleSystemdCommand(subcommand: string, _args: string[]) {
const systemd = new OneboxSystemd();
switch (subcommand) { switch (subcommand) {
case 'install': case 'enable':
await onebox.daemon.installService(); await systemd.enable();
break;
case 'disable':
await systemd.disable();
break; break;
case 'start': case 'start':
await onebox.startDaemon(); await systemd.start();
break; break;
case 'stop': case 'stop':
await onebox.stopDaemon(); await systemd.stop();
break; break;
case 'logs': { case 'status': {
const command = new Deno.Command('journalctl', { const status = await systemd.getStatus();
args: ['-u', 'smartdaemon_onebox', '-f'], logger.info(`Service status: ${status}`);
stdout: 'inherit',
stderr: 'inherit',
});
await command.output();
break; break;
} }
case 'status': { case 'logs':
const status = await onebox.daemon.getServiceStatus(); await systemd.showLogs();
logger.info(`Daemon status: ${status}`); break;
case 'start-daemon': {
// This is what systemd's ExecStart calls — full init + daemon loop
const onebox = new Onebox();
await onebox.init();
await onebox.daemon.start();
// start() blocks (keepAlive loop) until SIGTERM/SIGINT
break; break;
} }
default: default:
logger.error(`Unknown daemon subcommand: ${subcommand}`); logger.error(`Unknown systemd subcommand: ${subcommand}`);
logger.info('Available: enable, disable, start, stop, status, logs');
} }
} }
@@ -362,7 +458,17 @@ async function handleDaemonCommand(onebox: Onebox, subcommand: string, _args: st
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])
@@ -371,7 +477,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;
@@ -386,10 +496,180 @@ async function handleStatusCommand(onebox: Onebox) {
console.log(JSON.stringify(status, null, 2)); console.log(JSON.stringify(status, null, 2));
} }
// Upgrade command - self-update onebox to latest version
async function handleUpgradeCommand(): Promise<void> {
// Check if running as root
if (Deno.uid() !== 0) {
logger.error('This command must be run as root to upgrade Onebox.');
logger.info('Try: sudo onebox upgrade');
Deno.exit(1);
}
logger.info('Checking for updates...');
try {
// Get current version
const currentVersion = projectInfo.version;
// Fetch latest version from Gitea API
const apiUrl = 'https://code.foss.global/api/v1/repos/serve.zone/onebox/releases/latest';
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('');
// Compare normalized versions
if (normalizedCurrent === normalizedLatest) {
logger.success('Already up to date!');
return;
}
logger.info(`New version available: ${latestVersion}`);
logger.info('Downloading and installing...');
console.log('');
// Download and run the install script
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('');
logger.success(`Upgraded to ${latestVersion}`);
} catch (error) {
logger.error(`Upgrade failed: ${getErrorMessage(error)}`);
Deno.exit(1);
}
}
// 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: IAppVersionConfig,
overridesArg: Record<string, string>,
): Record<string, string> {
const envVars: Record<string, string> = {};
const missingRequiredEnvVars: string[] = [];
for (const envVar of configArg.envVars || []) {
const value = overridesArg[envVar.key] ?? envVar.value ?? '';
if (envVar.required && !value) {
missingRequiredEnvVars.push(envVar.key);
}
envVars[envVar.key] = value;
}
for (const [key, value] of Object.entries(overridesArg)) {
envVars[key] = value;
}
if (missingRequiredEnvVars.length > 0) {
throw new Error(
`Missing required app env var(s): ${missingRequiredEnvVars.join(', ')}. Use --env KEY=VALUE.`
);
}
return envVars;
}
function requiresTemplateValue(envVarsArg: Record<string, string>, templateNameArg: string): boolean {
return Object.values(envVarsArg).some((value) => value.includes(`\${${templateNameArg}}`));
}
function requireValue(valueArg: string | undefined, labelArg: string): asserts valueArg is string {
if (!valueArg) {
throw new Error(`Missing required ${labelArg}`);
}
}
function assertValidPort(portArg: number, labelArg: string): void {
if (!Number.isInteger(portArg) || portArg <= 0 || portArg > 65535) {
throw new Error(`Invalid ${labelArg}: ${portArg}`);
}
} }
function printHelp(): void { function printHelp(): void {
@@ -426,21 +706,29 @@ 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]
daemon install proxy reload # nginx alias is still supported
daemon start proxy test
daemon stop proxy status
daemon logs
daemon status systemd enable Install and enable systemd service
systemd disable Stop, disable, and remove systemd service
systemd start Start onebox via systemctl
systemd stop Stop onebox via systemctl
systemd status Show systemd service status
systemd logs Follow service logs (journalctl)
config show config show
config set <key> <value> config set <key> <value>
status status
upgrade
Upgrade Onebox to the latest version (requires root)
Options: Options:
--help, -h Show this help message --help, -h Show this help message
--version, -v Show version --version, -v Show version
@@ -451,15 +739,16 @@ Development Workflow:
onebox service add ... # In another terminal onebox service add ... # In another terminal
Production Workflow: Production Workflow:
onebox daemon install # Install systemd service onebox systemd enable # Install and enable systemd service
onebox daemon start # Start daemon onebox systemd start # Start via systemctl
onebox service add ... # CLI uses daemon onebox service add ... # CLI manages services
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 daemon install onebox systemd enable
onebox daemon start onebox systemd start
`); `);
} }
+113 -512
View File
@@ -18,10 +18,15 @@ import type {
IDomain, IDomain,
ICertificate, ICertificate,
ICertRequirement, ICertRequirement,
IBackup,
IBackupSchedule,
IBackupScheduleUpdate,
} from '../types.ts'; } from '../types.ts';
import type { TBindValue } from './types.ts'; 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 { SecretSettingsManager } from './secret-settings.ts';
// Import repositories // Import repositories
import { import {
@@ -31,6 +36,7 @@ import {
AuthRepository, AuthRepository,
MetricsRepository, MetricsRepository,
PlatformRepository, PlatformRepository,
BackupRepository,
} from './repositories/index.ts'; } from './repositories/index.ts';
export class OneboxDatabase { export class OneboxDatabase {
@@ -44,6 +50,8 @@ export class OneboxDatabase {
private authRepo!: AuthRepository; private authRepo!: AuthRepository;
private metricsRepo!: MetricsRepository; private metricsRepo!: MetricsRepository;
private platformRepo!: PlatformRepository; private platformRepo!: PlatformRepository;
private backupRepo!: BackupRepository;
public secretSettings!: SecretSettingsManager;
constructor(dbPath = './.nogit/onebox.db') { constructor(dbPath = './.nogit/onebox.db') {
this.dbPath = dbPath; this.dbPath = dbPath;
@@ -66,7 +74,8 @@ export class OneboxDatabase {
await this.createTables(); await this.createTables();
// Run migrations if needed // Run migrations if needed
await this.runMigrations(); const runner = new MigrationRunner(this.query.bind(this));
runner.run();
// Initialize repositories with bound query function // Initialize repositories with bound query function
const queryFn = this.query.bind(this); const queryFn = this.query.bind(this);
@@ -76,6 +85,8 @@ export class OneboxDatabase {
this.authRepo = new AuthRepository(queryFn); this.authRepo = new AuthRepository(queryFn);
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.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;
@@ -221,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 (
@@ -235,516 +254,6 @@ export class OneboxDatabase {
/** /**
* Run database migrations * Run database migrations
*/ */
private async runMigrations(): Promise<void> {
if (!this.db) throw new Error('Database not initialized');
try {
const currentVersion = this.getMigrationVersion();
logger.info(`Current database migration version: ${currentVersion}`);
// Migration 1: Initial schema
if (currentVersion === 0) {
logger.info('Setting initial migration version to 1');
this.setMigrationVersion(1);
}
// Migration 2: Convert timestamp columns from INTEGER to REAL
const updatedVersion = this.getMigrationVersion();
if (updatedVersion < 2) {
logger.info('Running migration 2: Converting timestamps to REAL...');
// SSL certificates
this.query(`
CREATE TABLE ssl_certificates_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
domain TEXT NOT NULL UNIQUE,
cert_path TEXT NOT NULL,
key_path TEXT NOT NULL,
full_chain_path TEXT NOT NULL,
expiry_date REAL NOT NULL,
issuer TEXT NOT NULL,
created_at REAL NOT NULL,
updated_at REAL NOT NULL
)
`);
this.query(`INSERT INTO ssl_certificates_new SELECT * FROM ssl_certificates`);
this.query(`DROP TABLE ssl_certificates`);
this.query(`ALTER TABLE ssl_certificates_new RENAME TO ssl_certificates`);
// Services
this.query(`
CREATE TABLE services_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
image TEXT NOT NULL,
registry TEXT,
env_vars TEXT NOT NULL,
port INTEGER NOT NULL,
domain TEXT,
container_id TEXT,
status TEXT NOT NULL DEFAULT 'stopped',
created_at REAL NOT NULL,
updated_at REAL NOT NULL
)
`);
this.query(`INSERT INTO services_new SELECT * FROM services`);
this.query(`DROP TABLE services`);
this.query(`ALTER TABLE services_new RENAME TO services`);
// Registries
this.query(`
CREATE TABLE registries_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url TEXT NOT NULL UNIQUE,
username TEXT NOT NULL,
password_encrypted TEXT NOT NULL,
created_at REAL NOT NULL
)
`);
this.query(`INSERT INTO registries_new SELECT * FROM registries`);
this.query(`DROP TABLE registries`);
this.query(`ALTER TABLE registries_new RENAME TO registries`);
// Nginx configs
this.query(`
CREATE TABLE nginx_configs_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
service_id INTEGER NOT NULL,
domain TEXT NOT NULL,
port INTEGER NOT NULL,
ssl_enabled INTEGER NOT NULL DEFAULT 0,
config_template TEXT NOT NULL,
created_at REAL NOT NULL,
updated_at REAL NOT NULL,
FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE
)
`);
this.query(`INSERT INTO nginx_configs_new SELECT * FROM nginx_configs`);
this.query(`DROP TABLE nginx_configs`);
this.query(`ALTER TABLE nginx_configs_new RENAME TO nginx_configs`);
// DNS records
this.query(`
CREATE TABLE dns_records_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
domain TEXT NOT NULL UNIQUE,
type TEXT NOT NULL,
value TEXT NOT NULL,
cloudflare_id TEXT,
zone_id TEXT,
created_at REAL NOT NULL,
updated_at REAL NOT NULL
)
`);
this.query(`INSERT INTO dns_records_new SELECT * FROM dns_records`);
this.query(`DROP TABLE dns_records`);
this.query(`ALTER TABLE dns_records_new RENAME TO dns_records`);
// Metrics
this.query(`
CREATE TABLE metrics_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
service_id INTEGER NOT NULL,
timestamp REAL NOT NULL,
cpu_percent REAL NOT NULL,
memory_used INTEGER NOT NULL,
memory_limit INTEGER NOT NULL,
network_rx_bytes INTEGER NOT NULL,
network_tx_bytes INTEGER NOT NULL,
FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE
)
`);
this.query(`INSERT INTO metrics_new SELECT * FROM metrics`);
this.query(`DROP TABLE metrics`);
this.query(`ALTER TABLE metrics_new RENAME TO metrics`);
this.query(`CREATE INDEX IF NOT EXISTS idx_metrics_service_timestamp ON metrics(service_id, timestamp DESC)`);
// Logs
this.query(`
CREATE TABLE logs_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
service_id INTEGER NOT NULL,
timestamp REAL NOT NULL,
message TEXT NOT NULL,
level TEXT NOT NULL,
source TEXT NOT NULL,
FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE
)
`);
this.query(`INSERT INTO logs_new SELECT * FROM logs`);
this.query(`DROP TABLE logs`);
this.query(`ALTER TABLE logs_new RENAME TO logs`);
this.query(`CREATE INDEX IF NOT EXISTS idx_logs_service_timestamp ON logs(service_id, timestamp DESC)`);
// Users
this.query(`
CREATE TABLE users_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user',
created_at REAL NOT NULL,
updated_at REAL NOT NULL
)
`);
this.query(`INSERT INTO users_new SELECT * FROM users`);
this.query(`DROP TABLE users`);
this.query(`ALTER TABLE users_new RENAME TO users`);
// Settings
this.query(`
CREATE TABLE settings_new (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at REAL NOT NULL
)
`);
this.query(`INSERT INTO settings_new SELECT * FROM settings`);
this.query(`DROP TABLE settings`);
this.query(`ALTER TABLE settings_new RENAME TO settings`);
// Migrations table itself
this.query(`
CREATE TABLE migrations_new (
version INTEGER PRIMARY KEY,
applied_at REAL NOT NULL
)
`);
this.query(`INSERT INTO migrations_new SELECT * FROM migrations`);
this.query(`DROP TABLE migrations`);
this.query(`ALTER TABLE migrations_new RENAME TO migrations`);
this.setMigrationVersion(2);
logger.success('Migration 2 completed: All timestamps converted to REAL');
}
// Migration 3: Domain management tables
const version3 = this.getMigrationVersion();
if (version3 < 3) {
logger.info('Running migration 3: Creating domain management tables...');
this.query(`
CREATE TABLE domains (
id INTEGER PRIMARY KEY AUTOINCREMENT,
domain TEXT NOT NULL UNIQUE,
dns_provider TEXT,
cloudflare_zone_id TEXT,
is_obsolete INTEGER NOT NULL DEFAULT 0,
default_wildcard INTEGER NOT NULL DEFAULT 1,
created_at REAL NOT NULL,
updated_at REAL NOT NULL
)
`);
this.query(`
CREATE TABLE certificates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
domain_id INTEGER NOT NULL,
cert_domain TEXT NOT NULL,
is_wildcard INTEGER NOT NULL DEFAULT 0,
cert_path TEXT NOT NULL,
key_path TEXT NOT NULL,
full_chain_path TEXT NOT NULL,
expiry_date REAL NOT NULL,
issuer TEXT NOT NULL,
is_valid INTEGER NOT NULL DEFAULT 1,
created_at REAL NOT NULL,
updated_at REAL NOT NULL,
FOREIGN KEY (domain_id) REFERENCES domains(id) ON DELETE CASCADE
)
`);
this.query(`
CREATE TABLE cert_requirements (
id INTEGER PRIMARY KEY AUTOINCREMENT,
service_id INTEGER NOT NULL,
domain_id INTEGER NOT NULL,
subdomain TEXT NOT NULL,
certificate_id INTEGER,
status TEXT NOT NULL DEFAULT 'pending',
created_at REAL NOT NULL,
updated_at REAL NOT NULL,
FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE,
FOREIGN KEY (domain_id) REFERENCES domains(id) ON DELETE CASCADE,
FOREIGN KEY (certificate_id) REFERENCES certificates(id) ON DELETE SET NULL
)
`);
interface OldSslCert {
id?: number;
domain?: string;
cert_path?: string;
key_path?: string;
full_chain_path?: string;
expiry_date?: number;
issuer?: string;
created_at?: number;
updated_at?: number;
[key: number]: unknown;
}
const existingCerts = this.query<OldSslCert>('SELECT * FROM ssl_certificates');
const now = Date.now();
const domainMap = new Map<string, number>();
for (const cert of existingCerts) {
const domain = String(cert.domain ?? (cert as Record<number, unknown>)[1]);
if (!domainMap.has(domain)) {
this.query(
'INSERT INTO domains (domain, dns_provider, is_obsolete, default_wildcard, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)',
[domain, null, 0, 1, now, now]
);
const result = this.query<{ id?: number; [key: number]: unknown }>('SELECT last_insert_rowid() as id');
const domainId = result[0].id ?? (result[0] as Record<number, unknown>)[0];
domainMap.set(domain, Number(domainId));
}
}
for (const cert of existingCerts) {
const domain = String(cert.domain ?? (cert as Record<number, unknown>)[1]);
const domainId = domainMap.get(domain);
this.query(
`INSERT INTO certificates (
domain_id, cert_domain, is_wildcard, cert_path, key_path, full_chain_path,
expiry_date, issuer, is_valid, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
domainId,
domain,
0,
String(cert.cert_path ?? (cert as Record<number, unknown>)[2]),
String(cert.key_path ?? (cert as Record<number, unknown>)[3]),
String(cert.full_chain_path ?? (cert as Record<number, unknown>)[4]),
Number(cert.expiry_date ?? (cert as Record<number, unknown>)[5]),
String(cert.issuer ?? (cert as Record<number, unknown>)[6]),
1,
Number(cert.created_at ?? (cert as Record<number, unknown>)[7]),
Number(cert.updated_at ?? (cert as Record<number, unknown>)[8])
]
);
}
this.query('DROP TABLE ssl_certificates');
this.query('CREATE INDEX IF NOT EXISTS idx_domains_cloudflare_zone ON domains(cloudflare_zone_id)');
this.query('CREATE INDEX IF NOT EXISTS idx_certificates_domain ON certificates(domain_id)');
this.query('CREATE INDEX IF NOT EXISTS idx_certificates_expiry ON certificates(expiry_date)');
this.query('CREATE INDEX IF NOT EXISTS idx_cert_requirements_service ON cert_requirements(service_id)');
this.query('CREATE INDEX IF NOT EXISTS idx_cert_requirements_domain ON cert_requirements(domain_id)');
this.setMigrationVersion(3);
logger.success('Migration 3 completed: Domain management tables created');
}
// Migration 4: Add Onebox Registry support columns
const version4 = this.getMigrationVersion();
if (version4 < 4) {
logger.info('Running migration 4: Adding Onebox Registry columns to services table...');
this.query(`ALTER TABLE services ADD COLUMN use_onebox_registry INTEGER DEFAULT 0`);
this.query(`ALTER TABLE services ADD COLUMN registry_repository TEXT`);
this.query(`ALTER TABLE services ADD COLUMN registry_token TEXT`);
this.query(`ALTER TABLE services ADD COLUMN registry_image_tag TEXT DEFAULT 'latest'`);
this.query(`ALTER TABLE services ADD COLUMN auto_update_on_push INTEGER DEFAULT 0`);
this.query(`ALTER TABLE services ADD COLUMN image_digest TEXT`);
this.setMigrationVersion(4);
logger.success('Migration 4 completed: Onebox Registry columns added to services table');
}
// Migration 5: Registry tokens table
const version5 = this.getMigrationVersion();
if (version5 < 5) {
logger.info('Running migration 5: Creating registry_tokens table...');
this.query(`
CREATE TABLE registry_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
token_type TEXT NOT NULL,
scope TEXT NOT NULL,
expires_at REAL,
created_at REAL NOT NULL,
last_used_at REAL,
created_by TEXT NOT NULL
)
`);
this.query('CREATE INDEX IF NOT EXISTS idx_registry_tokens_type ON registry_tokens(token_type)');
this.query('CREATE INDEX IF NOT EXISTS idx_registry_tokens_hash ON registry_tokens(token_hash)');
this.setMigrationVersion(5);
logger.success('Migration 5 completed: Registry tokens table created');
}
// Migration 6: Drop registry_token column from services table
const version6 = this.getMigrationVersion();
if (version6 < 6) {
logger.info('Running migration 6: Dropping registry_token column from services table...');
this.query(`
CREATE TABLE services_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
image TEXT NOT NULL,
registry TEXT,
env_vars TEXT,
port INTEGER NOT NULL,
domain TEXT,
container_id TEXT,
status TEXT NOT NULL,
created_at REAL NOT NULL,
updated_at REAL NOT NULL,
use_onebox_registry INTEGER DEFAULT 0,
registry_repository TEXT,
registry_image_tag TEXT DEFAULT 'latest',
auto_update_on_push INTEGER DEFAULT 0,
image_digest TEXT
)
`);
this.query(`
INSERT INTO services_new (
id, name, image, registry, env_vars, port, domain, container_id, status,
created_at, updated_at, use_onebox_registry, registry_repository,
registry_image_tag, auto_update_on_push, image_digest
)
SELECT
id, name, image, registry, env_vars, port, domain, container_id, status,
created_at, updated_at, use_onebox_registry, registry_repository,
registry_image_tag, auto_update_on_push, image_digest
FROM services
`);
this.query('DROP TABLE services');
this.query('ALTER TABLE services_new RENAME TO services');
this.query('CREATE INDEX IF NOT EXISTS idx_services_name ON services(name)');
this.query('CREATE INDEX IF NOT EXISTS idx_services_status ON services(status)');
this.setMigrationVersion(6);
logger.success('Migration 6 completed: registry_token column dropped from services table');
}
// Migration 7: Platform services tables
const version7 = this.getMigrationVersion();
if (version7 < 7) {
logger.info('Running migration 7: Creating platform services tables...');
this.query(`
CREATE TABLE platform_services (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
type TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'stopped',
container_id TEXT,
config TEXT NOT NULL DEFAULT '{}',
admin_credentials_encrypted TEXT,
created_at REAL NOT NULL,
updated_at REAL NOT NULL
)
`);
this.query(`
CREATE TABLE platform_resources (
id INTEGER PRIMARY KEY AUTOINCREMENT,
platform_service_id INTEGER NOT NULL,
service_id INTEGER NOT NULL,
resource_type TEXT NOT NULL,
resource_name TEXT NOT NULL,
credentials_encrypted TEXT NOT NULL,
created_at REAL NOT NULL,
FOREIGN KEY (platform_service_id) REFERENCES platform_services(id) ON DELETE CASCADE,
FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE
)
`);
this.query(`ALTER TABLE services ADD COLUMN platform_requirements TEXT DEFAULT '{}'`);
this.query('CREATE INDEX IF NOT EXISTS idx_platform_services_type ON platform_services(type)');
this.query('CREATE INDEX IF NOT EXISTS idx_platform_resources_service ON platform_resources(service_id)');
this.query('CREATE INDEX IF NOT EXISTS idx_platform_resources_platform ON platform_resources(platform_service_id)');
this.setMigrationVersion(7);
logger.success('Migration 7 completed: Platform services tables created');
}
// Migration 8: Convert certificates table to store PEM content
const version8 = this.getMigrationVersion();
if (version8 < 8) {
logger.info('Running migration 8: Converting certificates table to store PEM content...');
this.query(`
CREATE TABLE certificates_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
domain_id INTEGER NOT NULL,
cert_domain TEXT NOT NULL,
is_wildcard INTEGER NOT NULL DEFAULT 0,
cert_pem TEXT NOT NULL DEFAULT '',
key_pem TEXT NOT NULL DEFAULT '',
fullchain_pem TEXT NOT NULL DEFAULT '',
expiry_date REAL NOT NULL,
issuer TEXT NOT NULL,
is_valid INTEGER NOT NULL DEFAULT 1,
created_at REAL NOT NULL,
updated_at REAL NOT NULL,
FOREIGN KEY (domain_id) REFERENCES domains(id) ON DELETE CASCADE
)
`);
this.query(`
INSERT INTO certificates_new (id, domain_id, cert_domain, is_wildcard, cert_pem, key_pem, fullchain_pem, expiry_date, issuer, is_valid, created_at, updated_at)
SELECT id, domain_id, cert_domain, is_wildcard, '', '', '', expiry_date, issuer, 0, created_at, updated_at FROM certificates
`);
this.query('DROP TABLE certificates');
this.query('ALTER TABLE certificates_new RENAME TO certificates');
this.query('CREATE INDEX IF NOT EXISTS idx_certificates_domain ON certificates(domain_id)');
this.query('CREATE INDEX IF NOT EXISTS idx_certificates_expiry ON certificates(expiry_date)');
this.setMigrationVersion(8);
logger.success('Migration 8 completed: Certificates table now stores PEM content');
}
} catch (error) {
logger.error(`Migration failed: ${getErrorMessage(error)}`);
if (error instanceof Error && error.stack) {
logger.error(`Stack: ${error.stack}`);
}
throw error;
}
}
/**
* Get current migration version
*/
private getMigrationVersion(): number {
if (!this.db) throw new Error('Database not initialized');
try {
const result = this.query<{ version?: number | null; [key: number]: unknown }>('SELECT MAX(version) as version FROM migrations');
if (result.length === 0) return 0;
const versionValue = result[0].version ?? (result[0] as Record<number, unknown>)[0];
return versionValue !== null && versionValue !== undefined ? Number(versionValue) : 0;
} catch (error) {
logger.warn(`Error getting migration version: ${getErrorMessage(error)}, defaulting to 0`);
return 0;
}
}
/**
* Set migration version
*/
private setMigrationVersion(version: number): void {
if (!this.db) throw new Error('Database not initialized');
this.query('INSERT INTO migrations (version, applied_at) VALUES (?, ?)', [
version,
Date.now(),
]);
logger.debug(`Migration version set to ${version}`);
}
/** /**
* Close database connection * Close database connection
*/ */
@@ -835,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> {
@@ -921,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);
} }
@@ -1078,4 +611,72 @@ export class OneboxDatabase {
deletePlatformResourcesByService(serviceId: number): void { deletePlatformResourcesByService(serviceId: number): void {
this.platformRepo.deletePlatformResourcesByService(serviceId); this.platformRepo.deletePlatformResourcesByService(serviceId);
} }
// ============ Backups (delegated to repository) ============
createBackup(backup: Omit<IBackup, 'id'>): IBackup {
return this.backupRepo.create(backup);
}
getBackupById(id: number): IBackup | null {
return this.backupRepo.getById(id);
}
getBackupsByService(serviceId: number): IBackup[] {
return this.backupRepo.getByService(serviceId);
}
getAllBackups(): IBackup[] {
return this.backupRepo.getAll();
}
deleteBackup(id: number): void {
this.backupRepo.delete(id);
}
deleteBackupsByService(serviceId: number): void {
this.backupRepo.deleteByService(serviceId);
}
getBackupsBySchedule(scheduleId: number): IBackup[] {
return this.backupRepo.getBySchedule(scheduleId);
}
getBackupBySnapshotId(snapshotId: string): IBackup | null {
return this.backupRepo.getBySnapshotId(snapshotId);
}
// ============ Backup Schedules (delegated to repository) ============
createBackupSchedule(schedule: Omit<IBackupSchedule, 'id'>): IBackupSchedule {
return this.backupRepo.createSchedule(schedule);
}
getBackupScheduleById(id: number): IBackupSchedule | null {
return this.backupRepo.getScheduleById(id);
}
getBackupSchedulesByService(serviceId: number): IBackupSchedule[] {
return this.backupRepo.getSchedulesByService(serviceId);
}
getEnabledBackupSchedules(): IBackupSchedule[] {
return this.backupRepo.getEnabledSchedules();
}
getAllBackupSchedules(): IBackupSchedule[] {
return this.backupRepo.getAllSchedules();
}
updateBackupSchedule(id: number, updates: IBackupScheduleUpdate & { lastRunAt?: number; nextRunAt?: number; lastStatus?: 'success' | 'failed' | null; lastError?: string | null }): void {
this.backupRepo.updateSchedule(id, updates);
}
deleteBackupSchedule(id: number): void {
this.backupRepo.deleteSchedule(id);
}
deleteBackupSchedulesByService(serviceId: number): void {
this.backupRepo.deleteSchedulesByService(serviceId);
}
} }
+22
View File
@@ -0,0 +1,22 @@
/**
* Abstract base class for database migrations.
* All migrations must extend this class and implement the abstract members.
*/
import type { TQueryFunction } from '../types.ts';
export abstract class BaseMigration {
/** The migration version number (must be unique and sequential) */
abstract readonly version: number;
/** A short description of what this migration does */
abstract readonly description: string;
/** Execute the migration's SQL statements */
abstract up(query: TQueryFunction): void;
/** Returns a human-readable name for logging */
getName(): string {
return `Migration ${this.version}: ${this.description}`;
}
}
+2
View File
@@ -0,0 +1,2 @@
export { BaseMigration } from './base-migration.ts';
export { MigrationRunner } from './migration-runner.ts';
@@ -0,0 +1,12 @@
import { BaseMigration } from './base-migration.ts';
import type { TQueryFunction } from '../types.ts';
export class Migration001Initial extends BaseMigration {
readonly version = 1;
readonly description = 'Initial schema';
up(_query: TQueryFunction): void {
// Initial schema is created by createTables() in the database class.
// This migration just marks the initial version.
}
}
@@ -0,0 +1,170 @@
import { BaseMigration } from './base-migration.ts';
import type { TQueryFunction } from '../types.ts';
export class Migration002TimestampsToReal extends BaseMigration {
readonly version = 2;
readonly description = 'Convert timestamp columns from INTEGER to REAL';
up(query: TQueryFunction): void {
// SSL certificates
query(`
CREATE TABLE ssl_certificates_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
domain TEXT NOT NULL UNIQUE,
cert_path TEXT NOT NULL,
key_path TEXT NOT NULL,
full_chain_path TEXT NOT NULL,
expiry_date REAL NOT NULL,
issuer TEXT NOT NULL,
created_at REAL NOT NULL,
updated_at REAL NOT NULL
)
`);
query(`INSERT INTO ssl_certificates_new SELECT * FROM ssl_certificates`);
query(`DROP TABLE ssl_certificates`);
query(`ALTER TABLE ssl_certificates_new RENAME TO ssl_certificates`);
// Services
query(`
CREATE TABLE services_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
image TEXT NOT NULL,
registry TEXT,
env_vars TEXT NOT NULL,
port INTEGER NOT NULL,
domain TEXT,
container_id TEXT,
status TEXT NOT NULL DEFAULT 'stopped',
created_at REAL NOT NULL,
updated_at REAL NOT NULL
)
`);
query(`INSERT INTO services_new SELECT * FROM services`);
query(`DROP TABLE services`);
query(`ALTER TABLE services_new RENAME TO services`);
// Registries
query(`
CREATE TABLE registries_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url TEXT NOT NULL UNIQUE,
username TEXT NOT NULL,
password_encrypted TEXT NOT NULL,
created_at REAL NOT NULL
)
`);
query(`INSERT INTO registries_new SELECT * FROM registries`);
query(`DROP TABLE registries`);
query(`ALTER TABLE registries_new RENAME TO registries`);
// Nginx configs
query(`
CREATE TABLE nginx_configs_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
service_id INTEGER NOT NULL,
domain TEXT NOT NULL,
port INTEGER NOT NULL,
ssl_enabled INTEGER NOT NULL DEFAULT 0,
config_template TEXT NOT NULL,
created_at REAL NOT NULL,
updated_at REAL NOT NULL,
FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE
)
`);
query(`INSERT INTO nginx_configs_new SELECT * FROM nginx_configs`);
query(`DROP TABLE nginx_configs`);
query(`ALTER TABLE nginx_configs_new RENAME TO nginx_configs`);
// DNS records
query(`
CREATE TABLE dns_records_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
domain TEXT NOT NULL UNIQUE,
type TEXT NOT NULL,
value TEXT NOT NULL,
cloudflare_id TEXT,
zone_id TEXT,
created_at REAL NOT NULL,
updated_at REAL NOT NULL
)
`);
query(`INSERT INTO dns_records_new SELECT * FROM dns_records`);
query(`DROP TABLE dns_records`);
query(`ALTER TABLE dns_records_new RENAME TO dns_records`);
// Metrics
query(`
CREATE TABLE metrics_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
service_id INTEGER NOT NULL,
timestamp REAL NOT NULL,
cpu_percent REAL NOT NULL,
memory_used INTEGER NOT NULL,
memory_limit INTEGER NOT NULL,
network_rx_bytes INTEGER NOT NULL,
network_tx_bytes INTEGER NOT NULL,
FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE
)
`);
query(`INSERT INTO metrics_new SELECT * FROM metrics`);
query(`DROP TABLE metrics`);
query(`ALTER TABLE metrics_new RENAME TO metrics`);
query(`CREATE INDEX IF NOT EXISTS idx_metrics_service_timestamp ON metrics(service_id, timestamp DESC)`);
// Logs
query(`
CREATE TABLE logs_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
service_id INTEGER NOT NULL,
timestamp REAL NOT NULL,
message TEXT NOT NULL,
level TEXT NOT NULL,
source TEXT NOT NULL,
FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE
)
`);
query(`INSERT INTO logs_new SELECT * FROM logs`);
query(`DROP TABLE logs`);
query(`ALTER TABLE logs_new RENAME TO logs`);
query(`CREATE INDEX IF NOT EXISTS idx_logs_service_timestamp ON logs(service_id, timestamp DESC)`);
// Users
query(`
CREATE TABLE users_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user',
created_at REAL NOT NULL,
updated_at REAL NOT NULL
)
`);
query(`INSERT INTO users_new SELECT * FROM users`);
query(`DROP TABLE users`);
query(`ALTER TABLE users_new RENAME TO users`);
// Settings
query(`
CREATE TABLE settings_new (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at REAL NOT NULL
)
`);
query(`INSERT INTO settings_new SELECT * FROM settings`);
query(`DROP TABLE settings`);
query(`ALTER TABLE settings_new RENAME TO settings`);
// Migrations table itself
query(`
CREATE TABLE migrations_new (
version INTEGER PRIMARY KEY,
applied_at REAL NOT NULL
)
`);
query(`INSERT INTO migrations_new SELECT * FROM migrations`);
query(`DROP TABLE migrations`);
query(`ALTER TABLE migrations_new RENAME TO migrations`);
}
}
@@ -0,0 +1,125 @@
import { BaseMigration } from './base-migration.ts';
import type { TQueryFunction } from '../types.ts';
export class Migration003DomainManagement extends BaseMigration {
readonly version = 3;
readonly description = 'Domain management tables';
up(query: TQueryFunction): void {
query(`
CREATE TABLE domains (
id INTEGER PRIMARY KEY AUTOINCREMENT,
domain TEXT NOT NULL UNIQUE,
dns_provider TEXT,
cloudflare_zone_id TEXT,
is_obsolete INTEGER NOT NULL DEFAULT 0,
default_wildcard INTEGER NOT NULL DEFAULT 1,
created_at REAL NOT NULL,
updated_at REAL NOT NULL
)
`);
query(`
CREATE TABLE certificates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
domain_id INTEGER NOT NULL,
cert_domain TEXT NOT NULL,
is_wildcard INTEGER NOT NULL DEFAULT 0,
cert_path TEXT NOT NULL,
key_path TEXT NOT NULL,
full_chain_path TEXT NOT NULL,
expiry_date REAL NOT NULL,
issuer TEXT NOT NULL,
is_valid INTEGER NOT NULL DEFAULT 1,
created_at REAL NOT NULL,
updated_at REAL NOT NULL,
FOREIGN KEY (domain_id) REFERENCES domains(id) ON DELETE CASCADE
)
`);
query(`
CREATE TABLE cert_requirements (
id INTEGER PRIMARY KEY AUTOINCREMENT,
service_id INTEGER NOT NULL,
domain_id INTEGER NOT NULL,
subdomain TEXT NOT NULL,
certificate_id INTEGER,
status TEXT NOT NULL DEFAULT 'pending',
created_at REAL NOT NULL,
updated_at REAL NOT NULL,
FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE,
FOREIGN KEY (domain_id) REFERENCES domains(id) ON DELETE CASCADE,
FOREIGN KEY (certificate_id) REFERENCES certificates(id) ON DELETE SET NULL
)
`);
// Migrate data from old ssl_certificates table
interface OldSslCert {
id?: number;
domain?: string;
cert_path?: string;
key_path?: string;
full_chain_path?: string;
expiry_date?: number;
issuer?: string;
created_at?: number;
updated_at?: number;
[key: number]: unknown;
}
const existingCerts = query<OldSslCert>('SELECT * FROM ssl_certificates');
const now = Date.now();
const domainMap = new Map<string, number>();
for (const cert of existingCerts) {
const domain = String(cert.domain ?? (cert as Record<number, unknown>)[1]);
if (!domainMap.has(domain)) {
query(
'INSERT INTO domains (domain, dns_provider, is_obsolete, default_wildcard, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)',
[domain, null, 0, 1, now, now],
);
const result = query<{ id?: number; [key: number]: unknown }>(
'SELECT last_insert_rowid() as id',
);
const domainId = result[0].id ?? (result[0] as Record<number, unknown>)[0];
domainMap.set(domain, Number(domainId));
}
}
for (const cert of existingCerts) {
const domain = String(cert.domain ?? (cert as Record<number, unknown>)[1]);
const domainId = domainMap.get(domain);
query(
`INSERT INTO certificates (
domain_id, cert_domain, is_wildcard, cert_path, key_path, full_chain_path,
expiry_date, issuer, is_valid, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
domainId,
domain,
0,
String(cert.cert_path ?? (cert as Record<number, unknown>)[2]),
String(cert.key_path ?? (cert as Record<number, unknown>)[3]),
String(cert.full_chain_path ?? (cert as Record<number, unknown>)[4]),
Number(cert.expiry_date ?? (cert as Record<number, unknown>)[5]),
String(cert.issuer ?? (cert as Record<number, unknown>)[6]),
1,
Number(cert.created_at ?? (cert as Record<number, unknown>)[7]),
Number(cert.updated_at ?? (cert as Record<number, unknown>)[8]),
],
);
}
query('DROP TABLE ssl_certificates');
query('CREATE INDEX IF NOT EXISTS idx_domains_cloudflare_zone ON domains(cloudflare_zone_id)');
query('CREATE INDEX IF NOT EXISTS idx_certificates_domain ON certificates(domain_id)');
query('CREATE INDEX IF NOT EXISTS idx_certificates_expiry ON certificates(expiry_date)');
query(
'CREATE INDEX IF NOT EXISTS idx_cert_requirements_service ON cert_requirements(service_id)',
);
query(
'CREATE INDEX IF NOT EXISTS idx_cert_requirements_domain ON cert_requirements(domain_id)',
);
}
}
@@ -0,0 +1,16 @@
import { BaseMigration } from './base-migration.ts';
import type { TQueryFunction } from '../types.ts';
export class Migration004RegistryColumns extends BaseMigration {
readonly version = 4;
readonly description = 'Add Onebox Registry columns to services table';
up(query: TQueryFunction): void {
query(`ALTER TABLE services ADD COLUMN use_onebox_registry INTEGER DEFAULT 0`);
query(`ALTER TABLE services ADD COLUMN registry_repository TEXT`);
query(`ALTER TABLE services ADD COLUMN registry_token TEXT`);
query(`ALTER TABLE services ADD COLUMN registry_image_tag TEXT DEFAULT 'latest'`);
query(`ALTER TABLE services ADD COLUMN auto_update_on_push INTEGER DEFAULT 0`);
query(`ALTER TABLE services ADD COLUMN image_digest TEXT`);
}
}
@@ -0,0 +1,30 @@
import { BaseMigration } from './base-migration.ts';
import type { TQueryFunction } from '../types.ts';
export class Migration005RegistryTokens extends BaseMigration {
readonly version = 5;
readonly description = 'Registry tokens table';
up(query: TQueryFunction): void {
query(`
CREATE TABLE registry_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
token_type TEXT NOT NULL,
scope TEXT NOT NULL,
expires_at REAL,
created_at REAL NOT NULL,
last_used_at REAL,
created_by TEXT NOT NULL
)
`);
query(
'CREATE INDEX IF NOT EXISTS idx_registry_tokens_type ON registry_tokens(token_type)',
);
query(
'CREATE INDEX IF NOT EXISTS idx_registry_tokens_hash ON registry_tokens(token_hash)',
);
}
}
@@ -0,0 +1,48 @@
import { BaseMigration } from './base-migration.ts';
import type { TQueryFunction } from '../types.ts';
export class Migration006DropRegistryToken extends BaseMigration {
readonly version = 6;
readonly description = 'Drop registry_token column from services table';
up(query: TQueryFunction): void {
query(`
CREATE TABLE services_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
image TEXT NOT NULL,
registry TEXT,
env_vars TEXT,
port INTEGER NOT NULL,
domain TEXT,
container_id TEXT,
status TEXT NOT NULL,
created_at REAL NOT NULL,
updated_at REAL NOT NULL,
use_onebox_registry INTEGER DEFAULT 0,
registry_repository TEXT,
registry_image_tag TEXT DEFAULT 'latest',
auto_update_on_push INTEGER DEFAULT 0,
image_digest TEXT
)
`);
query(`
INSERT INTO services_new (
id, name, image, registry, env_vars, port, domain, container_id, status,
created_at, updated_at, use_onebox_registry, registry_repository,
registry_image_tag, auto_update_on_push, image_digest
)
SELECT
id, name, image, registry, env_vars, port, domain, container_id, status,
created_at, updated_at, use_onebox_registry, registry_repository,
registry_image_tag, auto_update_on_push, image_digest
FROM services
`);
query('DROP TABLE services');
query('ALTER TABLE services_new RENAME TO services');
query('CREATE INDEX IF NOT EXISTS idx_services_name ON services(name)');
query('CREATE INDEX IF NOT EXISTS idx_services_status ON services(status)');
}
}
@@ -0,0 +1,49 @@
import { BaseMigration } from './base-migration.ts';
import type { TQueryFunction } from '../types.ts';
export class Migration007PlatformServices extends BaseMigration {
readonly version = 7;
readonly description = 'Platform services tables';
up(query: TQueryFunction): void {
query(`
CREATE TABLE platform_services (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
type TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'stopped',
container_id TEXT,
config TEXT NOT NULL DEFAULT '{}',
admin_credentials_encrypted TEXT,
created_at REAL NOT NULL,
updated_at REAL NOT NULL
)
`);
query(`
CREATE TABLE platform_resources (
id INTEGER PRIMARY KEY AUTOINCREMENT,
platform_service_id INTEGER NOT NULL,
service_id INTEGER NOT NULL,
resource_type TEXT NOT NULL,
resource_name TEXT NOT NULL,
credentials_encrypted TEXT NOT NULL,
created_at REAL NOT NULL,
FOREIGN KEY (platform_service_id) REFERENCES platform_services(id) ON DELETE CASCADE,
FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE
)
`);
query(`ALTER TABLE services ADD COLUMN platform_requirements TEXT DEFAULT '{}'`);
query(
'CREATE INDEX IF NOT EXISTS idx_platform_services_type ON platform_services(type)',
);
query(
'CREATE INDEX IF NOT EXISTS idx_platform_resources_service ON platform_resources(service_id)',
);
query(
'CREATE INDEX IF NOT EXISTS idx_platform_resources_platform ON platform_resources(platform_service_id)',
);
}
}
@@ -0,0 +1,41 @@
import { BaseMigration } from './base-migration.ts';
import type { TQueryFunction } from '../types.ts';
export class Migration008CertPemContent extends BaseMigration {
readonly version = 8;
readonly description = 'Convert certificates table to store PEM content';
up(query: TQueryFunction): void {
query(`
CREATE TABLE certificates_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
domain_id INTEGER NOT NULL,
cert_domain TEXT NOT NULL,
is_wildcard INTEGER NOT NULL DEFAULT 0,
cert_pem TEXT NOT NULL DEFAULT '',
key_pem TEXT NOT NULL DEFAULT '',
fullchain_pem TEXT NOT NULL DEFAULT '',
expiry_date REAL NOT NULL,
issuer TEXT NOT NULL,
is_valid INTEGER NOT NULL DEFAULT 1,
created_at REAL NOT NULL,
updated_at REAL NOT NULL,
FOREIGN KEY (domain_id) REFERENCES domains(id) ON DELETE CASCADE
)
`);
query(`
INSERT INTO certificates_new (id, domain_id, cert_domain, is_wildcard, cert_pem, key_pem, fullchain_pem, expiry_date, issuer, is_valid, created_at, updated_at)
SELECT id, domain_id, cert_domain, is_wildcard, '', '', '', expiry_date, issuer, 0, created_at, updated_at FROM certificates
`);
query('DROP TABLE certificates');
query('ALTER TABLE certificates_new RENAME TO certificates');
query(
'CREATE INDEX IF NOT EXISTS idx_certificates_domain ON certificates(domain_id)',
);
query(
'CREATE INDEX IF NOT EXISTS idx_certificates_expiry ON certificates(expiry_date)',
);
}
}
@@ -0,0 +1,29 @@
import { BaseMigration } from './base-migration.ts';
import type { TQueryFunction } from '../types.ts';
export class Migration009BackupSystem extends BaseMigration {
readonly version = 9;
readonly description = 'Backup system tables';
up(query: TQueryFunction): void {
query(`ALTER TABLE services ADD COLUMN include_image_in_backup INTEGER DEFAULT 1`);
query(`
CREATE TABLE backups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
service_id INTEGER NOT NULL,
service_name TEXT NOT NULL,
filename TEXT NOT NULL,
size_bytes INTEGER NOT NULL,
created_at REAL NOT NULL,
includes_image INTEGER NOT NULL,
platform_resources TEXT NOT NULL DEFAULT '[]',
checksum TEXT NOT NULL,
FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE
)
`);
query('CREATE INDEX IF NOT EXISTS idx_backups_service ON backups(service_id)');
query('CREATE INDEX IF NOT EXISTS idx_backups_created ON backups(created_at DESC)');
}
}
@@ -0,0 +1,39 @@
import { BaseMigration } from './base-migration.ts';
import type { TQueryFunction } from '../types.ts';
export class Migration010BackupSchedules extends BaseMigration {
readonly version = 10;
readonly description = 'Backup schedules table';
up(query: TQueryFunction): void {
query(`
CREATE TABLE backup_schedules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
service_id INTEGER NOT NULL,
service_name TEXT NOT NULL,
cron_expression TEXT NOT NULL,
retention_tier TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
last_run_at REAL,
next_run_at REAL,
last_status TEXT,
last_error TEXT,
created_at REAL NOT NULL,
updated_at REAL NOT NULL,
FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE
)
`);
query(
'CREATE INDEX IF NOT EXISTS idx_backup_schedules_service ON backup_schedules(service_id)',
);
query(
'CREATE INDEX IF NOT EXISTS idx_backup_schedules_enabled ON backup_schedules(enabled)',
);
query('ALTER TABLE backups ADD COLUMN retention_tier TEXT');
query(
'ALTER TABLE backups ADD COLUMN schedule_id INTEGER REFERENCES backup_schedules(id) ON DELETE SET NULL',
);
}
}
@@ -0,0 +1,54 @@
import { BaseMigration } from './base-migration.ts';
import type { TQueryFunction } from '../types.ts';
export class Migration011ScopeColumns extends BaseMigration {
readonly version = 11;
readonly description = 'Add scope columns to backup_schedules';
up(query: TQueryFunction): void {
query(`
CREATE TABLE backup_schedules_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
scope_type TEXT NOT NULL DEFAULT 'service',
scope_pattern TEXT,
service_id INTEGER,
service_name TEXT,
cron_expression TEXT NOT NULL,
retention_tier TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
last_run_at REAL,
next_run_at REAL,
last_status TEXT,
last_error TEXT,
created_at REAL NOT NULL,
updated_at REAL NOT NULL,
FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE
)
`);
query(`
INSERT INTO backup_schedules_new (
id, scope_type, scope_pattern, service_id, service_name, cron_expression,
retention_tier, enabled, last_run_at, next_run_at, last_status, last_error,
created_at, updated_at
)
SELECT
id, 'service', NULL, service_id, service_name, cron_expression,
retention_tier, enabled, last_run_at, next_run_at, last_status, last_error,
created_at, updated_at
FROM backup_schedules
`);
query('DROP TABLE backup_schedules');
query('ALTER TABLE backup_schedules_new RENAME TO backup_schedules');
query(
'CREATE INDEX IF NOT EXISTS idx_backup_schedules_service ON backup_schedules(service_id)',
);
query(
'CREATE INDEX IF NOT EXISTS idx_backup_schedules_enabled ON backup_schedules(enabled)',
);
query(
'CREATE INDEX IF NOT EXISTS idx_backup_schedules_scope ON backup_schedules(scope_type)',
);
}
}
@@ -0,0 +1,97 @@
import { BaseMigration } from './base-migration.ts';
import type { TQueryFunction } from '../types.ts';
export class Migration012GfsRetention extends BaseMigration {
readonly version = 12;
readonly description = 'GFS retention policy schema';
up(query: TQueryFunction): void {
// Recreate backup_schedules with GFS retention columns
query(`
CREATE TABLE backup_schedules_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
scope_type TEXT NOT NULL DEFAULT 'service',
scope_pattern TEXT,
service_id INTEGER,
service_name TEXT,
cron_expression TEXT NOT NULL,
retention_hourly INTEGER NOT NULL DEFAULT 0,
retention_daily INTEGER NOT NULL DEFAULT 7,
retention_weekly INTEGER NOT NULL DEFAULT 4,
retention_monthly INTEGER NOT NULL DEFAULT 12,
enabled INTEGER NOT NULL DEFAULT 1,
last_run_at REAL,
next_run_at REAL,
last_status TEXT,
last_error TEXT,
created_at REAL NOT NULL,
updated_at REAL NOT NULL,
FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE
)
`);
// Migrate existing data - convert old retention_tier to new format
query(`
INSERT INTO backup_schedules_new (
id, scope_type, scope_pattern, service_id, service_name, cron_expression,
retention_hourly, retention_daily, retention_weekly, retention_monthly,
enabled, last_run_at, next_run_at, last_status, last_error, created_at, updated_at
)
SELECT
id, scope_type, scope_pattern, service_id, service_name, cron_expression,
0,
CASE WHEN retention_tier = 'daily' THEN 7 ELSE 0 END,
CASE WHEN retention_tier IN ('daily', 'weekly') THEN 4 ELSE 0 END,
CASE WHEN retention_tier IN ('daily', 'weekly', 'monthly') THEN 12
WHEN retention_tier = 'yearly' THEN 24 ELSE 12 END,
enabled, last_run_at, next_run_at, last_status, last_error, created_at, updated_at
FROM backup_schedules
`);
query('DROP TABLE backup_schedules');
query('ALTER TABLE backup_schedules_new RENAME TO backup_schedules');
query(
'CREATE INDEX IF NOT EXISTS idx_backup_schedules_service ON backup_schedules(service_id)',
);
query(
'CREATE INDEX IF NOT EXISTS idx_backup_schedules_enabled ON backup_schedules(enabled)',
);
query(
'CREATE INDEX IF NOT EXISTS idx_backup_schedules_scope ON backup_schedules(scope_type)',
);
// Recreate backups table without retention_tier column
query(`
CREATE TABLE backups_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
service_id INTEGER NOT NULL,
service_name TEXT NOT NULL,
filename TEXT NOT NULL,
size_bytes INTEGER NOT NULL,
created_at REAL NOT NULL,
includes_image INTEGER NOT NULL,
platform_resources TEXT NOT NULL DEFAULT '[]',
checksum TEXT NOT NULL,
schedule_id INTEGER REFERENCES backup_schedules(id) ON DELETE SET NULL,
FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE
)
`);
query(`
INSERT INTO backups_new (
id, service_id, service_name, filename, size_bytes, created_at,
includes_image, platform_resources, checksum, schedule_id
)
SELECT
id, service_id, service_name, filename, size_bytes, created_at,
includes_image, platform_resources, checksum, schedule_id
FROM backups
`);
query('DROP TABLE backups');
query('ALTER TABLE backups_new RENAME TO backups');
query('CREATE INDEX IF NOT EXISTS idx_backups_service ON backups(service_id)');
query('CREATE INDEX IF NOT EXISTS idx_backups_created ON backups(created_at DESC)');
query('CREATE INDEX IF NOT EXISTS idx_backups_schedule ON backups(schedule_id)');
}
}
@@ -0,0 +1,12 @@
import { BaseMigration } from './base-migration.ts';
import type { TQueryFunction } from '../types.ts';
export class Migration013AppTemplateVersion extends BaseMigration {
readonly version = 13;
readonly description = 'Add app template tracking columns to services';
up(query: TQueryFunction): void {
query('ALTER TABLE services ADD COLUMN app_template_id TEXT');
query('ALTER TABLE services ADD COLUMN app_template_version TEXT');
}
}
@@ -0,0 +1,13 @@
import { BaseMigration } from './base-migration.ts';
import type { TQueryFunction } from '../types.ts';
export class Migration014ContainerArchive extends BaseMigration {
readonly version = 14;
readonly description = 'Add containerarchive snapshot tracking to backups';
up(query: TQueryFunction): void {
query('ALTER TABLE backups ADD COLUMN snapshot_id TEXT');
query('ALTER TABLE backups ADD COLUMN stored_size_bytes INTEGER DEFAULT 0');
query('CREATE INDEX IF NOT EXISTS idx_backups_snapshot ON backups(snapshot_id)');
}
}
@@ -0,0 +1,31 @@
import { BaseMigration } from './base-migration.ts';
import type { TQueryFunction } from '../types.ts';
export class Migration015SmartProxyPlatformService extends BaseMigration {
readonly version = 15;
readonly description = 'Rename Caddy platform service to SmartProxy';
up(query: TQueryFunction): void {
query(
`UPDATE platform_services
SET name = 'onebox-smartproxy',
type = 'smartproxy',
container_id = CASE
WHEN container_id = 'onebox-caddy' THEN 'onebox-smartproxy'
ELSE container_id
END,
config = ?,
updated_at = ?
WHERE type = 'caddy'`,
[
JSON.stringify({
image: 'code.foss.global/host.today/ht-docker-smartproxy:latest',
port: 80,
volumes: [],
environment: {},
}),
Date.now(),
],
);
}
}
+106
View File
@@ -0,0 +1,106 @@
/**
* Migration runner - discovers, orders, and executes database migrations.
* Mirrors the pattern from @serve.zone/nupst.
*/
import type { TQueryFunction } from '../types.ts';
import { logger } from '../../logging.ts';
import { getErrorMessage } from '../../utils/error.ts';
import { Migration001Initial } from './migration-001-initial.ts';
import { Migration002TimestampsToReal } from './migration-002-timestamps-to-real.ts';
import { Migration003DomainManagement } from './migration-003-domain-management.ts';
import { Migration004RegistryColumns } from './migration-004-registry-columns.ts';
import { Migration005RegistryTokens } from './migration-005-registry-tokens.ts';
import { Migration006DropRegistryToken } from './migration-006-drop-registry-token.ts';
import { Migration007PlatformServices } from './migration-007-platform-services.ts';
import { Migration008CertPemContent } from './migration-008-cert-pem-content.ts';
import { Migration009BackupSystem } from './migration-009-backup-system.ts';
import { Migration010BackupSchedules } from './migration-010-backup-schedules.ts';
import { Migration011ScopeColumns } from './migration-011-scope-columns.ts';
import { Migration012GfsRetention } from './migration-012-gfs-retention.ts';
import { Migration013AppTemplateVersion } from './migration-013-app-template-version.ts';
import { Migration014ContainerArchive } from './migration-014-containerarchive.ts';
import { Migration015SmartProxyPlatformService } from './migration-015-smartproxy-platform-service.ts';
import type { BaseMigration } from './base-migration.ts';
export class MigrationRunner {
private query: TQueryFunction;
private migrations: BaseMigration[];
constructor(query: TQueryFunction) {
this.query = query;
// Register all migrations in order
this.migrations = [
new Migration001Initial(),
new Migration002TimestampsToReal(),
new Migration003DomainManagement(),
new Migration004RegistryColumns(),
new Migration005RegistryTokens(),
new Migration006DropRegistryToken(),
new Migration007PlatformServices(),
new Migration008CertPemContent(),
new Migration009BackupSystem(),
new Migration010BackupSchedules(),
new Migration011ScopeColumns(),
new Migration012GfsRetention(),
new Migration013AppTemplateVersion(),
new Migration014ContainerArchive(),
new Migration015SmartProxyPlatformService(),
].sort((a, b) => a.version - b.version);
}
/** Run all pending migrations */
run(): void {
try {
const currentVersion = this.getMigrationVersion();
logger.info(`Current database migration version: ${currentVersion}`);
let applied = 0;
for (const migration of this.migrations) {
if (migration.version <= currentVersion) continue;
logger.info(`Running ${migration.getName()}...`);
migration.up(this.query);
this.setMigrationVersion(migration.version);
logger.success(`${migration.getName()} completed`);
applied++;
}
if (applied > 0) {
logger.success(`Applied ${applied} migration(s)`);
}
} catch (error) {
logger.error(`Migration failed: ${getErrorMessage(error)}`);
if (error instanceof Error && error.stack) {
logger.error(`Stack: ${error.stack}`);
}
throw error;
}
}
/** Get current migration version from the migrations table */
private getMigrationVersion(): number {
try {
const result = this.query<{ version?: number | null; [key: number]: unknown }>(
'SELECT MAX(version) as version FROM migrations',
);
if (result.length === 0) return 0;
const versionValue = result[0].version ?? (result[0] as Record<number, unknown>)[0];
return versionValue !== null && versionValue !== undefined ? Number(versionValue) : 0;
} catch {
// Table might not exist yet on fresh databases
return 0;
}
}
/** Record a migration version as applied */
private setMigrationVersion(version: number): void {
this.query('INSERT INTO migrations (version, applied_at) VALUES (?, ?)', [
version,
Date.now(),
]);
}
}
@@ -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]);
}
} }
@@ -0,0 +1,262 @@
/**
* Backup Repository
* Handles CRUD operations for backups and backup_schedules tables
*/
import { BaseRepository } from '../base.repository.ts';
import type {
IBackup,
IBackupSchedule,
IBackupScheduleUpdate,
TPlatformServiceType,
TBackupScheduleScope,
IRetentionPolicy,
} from '../../types.ts';
export class BackupRepository extends BaseRepository {
// ============ Backup CRUD ============
create(backup: Omit<IBackup, 'id'>): IBackup {
this.query(
`INSERT INTO backups (
service_id, service_name, filename, size_bytes, created_at,
includes_image, platform_resources, checksum, schedule_id,
snapshot_id, stored_size_bytes
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
backup.serviceId,
backup.serviceName,
backup.filename,
backup.sizeBytes,
backup.createdAt,
backup.includesImage ? 1 : 0,
JSON.stringify(backup.platformResources),
backup.checksum,
backup.scheduleId ?? null,
backup.snapshotId ?? null,
backup.storedSizeBytes ?? 0,
]
);
// Get the created backup by looking for the most recent one with matching filename
const rows = this.query(
'SELECT * FROM backups WHERE filename = ? ORDER BY id DESC LIMIT 1',
[backup.filename]
);
return this.rowToBackup(rows[0]);
}
getById(id: number): IBackup | null {
const rows = this.query('SELECT * FROM backups WHERE id = ?', [id]);
return rows.length > 0 ? this.rowToBackup(rows[0]) : null;
}
getByService(serviceId: number): IBackup[] {
const rows = this.query(
'SELECT * FROM backups WHERE service_id = ? ORDER BY created_at DESC',
[serviceId]
);
return rows.map((row) => this.rowToBackup(row));
}
getAll(): IBackup[] {
const rows = this.query('SELECT * FROM backups ORDER BY created_at DESC');
return rows.map((row) => this.rowToBackup(row));
}
delete(id: number): void {
this.query('DELETE FROM backups WHERE id = ?', [id]);
}
deleteByService(serviceId: number): void {
this.query('DELETE FROM backups WHERE service_id = ?', [serviceId]);
}
getBySchedule(scheduleId: number): IBackup[] {
const rows = this.query(
'SELECT * FROM backups WHERE schedule_id = ? ORDER BY created_at DESC',
[scheduleId]
);
return rows.map((row) => this.rowToBackup(row));
}
getBySnapshotId(snapshotId: string): IBackup | null {
const rows = this.query(
'SELECT * FROM backups WHERE snapshot_id = ?',
[snapshotId]
);
return rows.length > 0 ? this.rowToBackup(rows[0]) : null;
}
private rowToBackup(row: any): IBackup {
let platformResources: TPlatformServiceType[] = [];
const platformResourcesRaw = row.platform_resources;
if (platformResourcesRaw) {
try {
platformResources = JSON.parse(String(platformResourcesRaw));
} catch {
platformResources = [];
}
}
return {
id: Number(row.id),
serviceId: Number(row.service_id),
serviceName: String(row.service_name),
filename: String(row.filename),
snapshotId: row.snapshot_id ? String(row.snapshot_id) : undefined,
sizeBytes: Number(row.size_bytes),
storedSizeBytes: row.stored_size_bytes ? Number(row.stored_size_bytes) : undefined,
createdAt: Number(row.created_at),
includesImage: Boolean(row.includes_image),
platformResources,
checksum: String(row.checksum),
scheduleId: row.schedule_id ? Number(row.schedule_id) : undefined,
};
}
// ============ Backup Schedule CRUD ============
createSchedule(schedule: Omit<IBackupSchedule, 'id'>): IBackupSchedule {
const now = Date.now();
this.query(
`INSERT INTO backup_schedules (
scope_type, scope_pattern, service_id, service_name, cron_expression,
retention_hourly, retention_daily, retention_weekly, retention_monthly,
enabled, last_run_at, next_run_at, last_status, last_error, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
schedule.scopeType,
schedule.scopePattern ?? null,
schedule.serviceId ?? null,
schedule.serviceName ?? null,
schedule.cronExpression,
schedule.retention.hourly,
schedule.retention.daily,
schedule.retention.weekly,
schedule.retention.monthly,
schedule.enabled ? 1 : 0,
schedule.lastRunAt,
schedule.nextRunAt,
schedule.lastStatus,
schedule.lastError,
now,
now,
]
);
// Get the created schedule by looking for the most recent one with matching scope
const rows = this.query(
'SELECT * FROM backup_schedules WHERE scope_type = ? AND cron_expression = ? ORDER BY id DESC LIMIT 1',
[schedule.scopeType, schedule.cronExpression]
);
return this.rowToSchedule(rows[0]);
}
getScheduleById(id: number): IBackupSchedule | null {
const rows = this.query('SELECT * FROM backup_schedules WHERE id = ?', [id]);
return rows.length > 0 ? this.rowToSchedule(rows[0]) : null;
}
getSchedulesByService(serviceId: number): IBackupSchedule[] {
const rows = this.query(
'SELECT * FROM backup_schedules WHERE service_id = ? ORDER BY created_at DESC',
[serviceId]
);
return rows.map((row) => this.rowToSchedule(row));
}
getEnabledSchedules(): IBackupSchedule[] {
const rows = this.query(
'SELECT * FROM backup_schedules WHERE enabled = 1 ORDER BY next_run_at ASC'
);
return rows.map((row) => this.rowToSchedule(row));
}
getAllSchedules(): IBackupSchedule[] {
const rows = this.query('SELECT * FROM backup_schedules ORDER BY created_at DESC');
return rows.map((row) => this.rowToSchedule(row));
}
updateSchedule(id: number, updates: IBackupScheduleUpdate & { lastRunAt?: number; nextRunAt?: number; lastStatus?: 'success' | 'failed' | null; lastError?: string | null }): void {
const setClauses: string[] = [];
const params: (string | number | null)[] = [];
if (updates.cronExpression !== undefined) {
setClauses.push('cron_expression = ?');
params.push(updates.cronExpression);
}
if (updates.retention !== undefined) {
setClauses.push('retention_hourly = ?');
params.push(updates.retention.hourly);
setClauses.push('retention_daily = ?');
params.push(updates.retention.daily);
setClauses.push('retention_weekly = ?');
params.push(updates.retention.weekly);
setClauses.push('retention_monthly = ?');
params.push(updates.retention.monthly);
}
if (updates.enabled !== undefined) {
setClauses.push('enabled = ?');
params.push(updates.enabled ? 1 : 0);
}
if (updates.lastRunAt !== undefined) {
setClauses.push('last_run_at = ?');
params.push(updates.lastRunAt);
}
if (updates.nextRunAt !== undefined) {
setClauses.push('next_run_at = ?');
params.push(updates.nextRunAt);
}
if (updates.lastStatus !== undefined) {
setClauses.push('last_status = ?');
params.push(updates.lastStatus);
}
if (updates.lastError !== undefined) {
setClauses.push('last_error = ?');
params.push(updates.lastError);
}
if (setClauses.length === 0) return;
setClauses.push('updated_at = ?');
params.push(Date.now());
params.push(id);
this.query(`UPDATE backup_schedules SET ${setClauses.join(', ')} WHERE id = ?`, params);
}
deleteSchedule(id: number): void {
this.query('DELETE FROM backup_schedules WHERE id = ?', [id]);
}
deleteSchedulesByService(serviceId: number): void {
this.query('DELETE FROM backup_schedules WHERE service_id = ?', [serviceId]);
}
private rowToSchedule(row: any): IBackupSchedule {
return {
id: Number(row.id),
scopeType: (String(row.scope_type) || 'service') as TBackupScheduleScope,
scopePattern: row.scope_pattern ? String(row.scope_pattern) : undefined,
serviceId: row.service_id ? Number(row.service_id) : undefined,
serviceName: row.service_name ? String(row.service_name) : undefined,
cronExpression: String(row.cron_expression),
retention: {
hourly: Number(row.retention_hourly ?? 0),
daily: Number(row.retention_daily ?? 7),
weekly: Number(row.retention_weekly ?? 4),
monthly: Number(row.retention_monthly ?? 12),
} as IRetentionPolicy,
enabled: Boolean(row.enabled),
lastRunAt: row.last_run_at ? Number(row.last_run_at) : null,
nextRunAt: row.next_run_at ? Number(row.next_run_at) : null,
lastStatus: row.last_status ? (String(row.last_status) as 'success' | 'failed') : null,
lastError: row.last_error ? String(row.last_error) : null,
createdAt: Number(row.created_at),
updatedAt: Number(row.updated_at),
};
}
}
@@ -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));
} }
+1
View File
@@ -8,3 +8,4 @@ export { CertificateRepository } from './certificate.repository.ts';
export { AuthRepository } from './auth.repository.ts'; export { AuthRepository } from './auth.repository.ts';
export { MetricsRepository } from './metrics.repository.ts'; export { MetricsRepository } from './metrics.repository.ts';
export { PlatformRepository } from './platform.repository.ts'; export { PlatformRepository } from './platform.repository.ts';
export { BackupRepository } from './backup.repository.ts';
+22 -2
View File
@@ -17,8 +17,9 @@ export class ServiceRepository extends BaseRepository {
name, image, registry, env_vars, port, domain, container_id, status, name, image, registry, env_vars, 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,
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, app_template_id, app_template_version
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[ [
service.name, service.name,
service.image, service.image,
@@ -36,6 +37,8 @@ export class ServiceRepository extends BaseRepository {
service.autoUpdateOnPush ? 1 : 0, service.autoUpdateOnPush ? 1 : 0,
service.imageDigest || null, service.imageDigest || null,
JSON.stringify(service.platformRequirements || {}), JSON.stringify(service.platformRequirements || {}),
service.appTemplateId || null,
service.appTemplateVersion || null,
] ]
); );
@@ -119,6 +122,18 @@ export class ServiceRepository extends BaseRepository {
fields.push('platform_requirements = ?'); fields.push('platform_requirements = ?');
values.push(JSON.stringify(updates.platformRequirements)); values.push(JSON.stringify(updates.platformRequirements));
} }
if (updates.includeImageInBackup !== undefined) {
fields.push('include_image_in_backup = ?');
values.push(updates.includeImageInBackup ? 1 : 0);
}
if (updates.appTemplateId !== undefined) {
fields.push('app_template_id = ?');
values.push(updates.appTemplateId);
}
if (updates.appTemplateVersion !== undefined) {
fields.push('app_template_version = ?');
values.push(updates.appTemplateVersion);
}
fields.push('updated_at = ?'); fields.push('updated_at = ?');
values.push(Date.now()); values.push(Date.now());
@@ -172,6 +187,11 @@ export class ServiceRepository extends BaseRepository {
autoUpdateOnPush: row.auto_update_on_push ? Boolean(row.auto_update_on_push) : undefined, autoUpdateOnPush: row.auto_update_on_push ? Boolean(row.auto_update_on_push) : undefined,
imageDigest: row.image_digest ? String(row.image_digest) : undefined, imageDigest: row.image_digest ? String(row.image_digest) : undefined,
platformRequirements, platformRequirements,
includeImageInBackup: row.include_image_in_backup !== undefined
? Boolean(row.include_image_in_backup)
: true, // Default to true
appTemplateId: row.app_template_id ? String(row.app_template_id) : undefined,
appTemplateVersion: row.app_template_version ? String(row.app_template_version) : undefined,
}; };
} }
} }
+142
View File
@@ -0,0 +1,142 @@
import { credentialEncryption } from '../classes/encryption.ts';
import type { AuthRepository } from './repositories/auth.repository.ts';
const encryptedSecretPrefix = 'enc:v1:';
const secretSettingAliases = {
backupPassword: ['backup_encryption_password'],
cloudflareToken: ['cloudflareAPIKey'],
dcrouterGatewayApiToken: ['externalGatewayApiToken'],
} as const;
type TCanonicalSecretSettingKey = keyof typeof secretSettingAliases;
export class SecretSettingsManager {
constructor(private authRepo: AuthRepository) {}
public isSecretKey(key: string): boolean {
return this.resolveCanonicalKey(key) !== null;
}
public getCanonicalKeys(): TCanonicalSecretSettingKey[] {
return Object.keys(secretSettingAliases) as TCanonicalSecretSettingKey[];
}
public async get(key: string): Promise<string | null> {
const canonicalKey = this.resolveCanonicalKey(key);
if (!canonicalKey) {
return null;
}
for (const candidateKey of this.getCandidateKeys(canonicalKey)) {
const secretValue = this.authRepo.getSecretSetting(candidateKey);
if (secretValue !== null) {
const decryptedValue = await this.decodeStoredValue(secretValue);
await this.normalizeStoredSecret(canonicalKey, candidateKey, secretValue, decryptedValue);
return decryptedValue;
}
const legacyValue = this.authRepo.getSetting(candidateKey);
if (legacyValue !== null) {
await this.set(canonicalKey, legacyValue);
if (candidateKey !== canonicalKey) {
this.authRepo.deleteSetting(candidateKey);
}
this.authRepo.deleteSetting(canonicalKey);
return legacyValue;
}
}
return null;
}
public async set(key: string, value: string | null): Promise<void> {
const canonicalKey = this.resolveCanonicalKey(key);
if (!canonicalKey) {
throw new Error(`Unsupported secret setting key: ${key}`);
}
if (!value) {
this.clear(canonicalKey);
return;
}
const encryptedValue = await credentialEncryption.encrypt({ value });
this.authRepo.setSecretSetting(canonicalKey, `${encryptedSecretPrefix}${encryptedValue}`);
for (const aliasKey of secretSettingAliases[canonicalKey]) {
this.authRepo.deleteSecretSetting(aliasKey);
this.authRepo.deleteSetting(aliasKey);
}
this.authRepo.deleteSetting(canonicalKey);
}
public async has(key: string): Promise<boolean> {
return (await this.get(key)) !== null;
}
public clear(key: string): void {
const canonicalKey = this.resolveCanonicalKey(key);
if (!canonicalKey) {
return;
}
this.authRepo.deleteSecretSetting(canonicalKey);
this.authRepo.deleteSetting(canonicalKey);
for (const aliasKey of secretSettingAliases[canonicalKey]) {
this.authRepo.deleteSecretSetting(aliasKey);
this.authRepo.deleteSetting(aliasKey);
}
}
private resolveCanonicalKey(key: string): TCanonicalSecretSettingKey | null {
if (key in secretSettingAliases) {
return key as TCanonicalSecretSettingKey;
}
for (const [canonicalKey, aliases] of Object.entries(secretSettingAliases)) {
if ((aliases as readonly string[]).includes(key)) {
return canonicalKey as TCanonicalSecretSettingKey;
}
}
return null;
}
private getCandidateKeys(canonicalKey: TCanonicalSecretSettingKey): string[] {
return [canonicalKey, ...secretSettingAliases[canonicalKey]];
}
private async decodeStoredValue(value: string): Promise<string> {
if (value.startsWith(encryptedSecretPrefix)) {
const decrypted = await credentialEncryption.decrypt<{ value: string }>(
value.slice(encryptedSecretPrefix.length),
);
return decrypted.value;
}
// Compatibility for any earlier secret_settings rows stored without encryption.
return value;
}
private async normalizeStoredSecret(
canonicalKey: TCanonicalSecretSettingKey,
sourceKey: string,
storedValue: string,
decryptedValue: string,
): Promise<void> {
if (sourceKey !== canonicalKey || !storedValue.startsWith(encryptedSecretPrefix)) {
await this.set(canonicalKey, decryptedValue);
if (sourceKey !== canonicalKey) {
this.authRepo.deleteSecretSetting(sourceKey);
}
}
this.authRepo.deleteSetting(canonicalKey);
for (const aliasKey of secretSettingAliases[canonicalKey]) {
this.authRepo.deleteSetting(aliasKey);
}
}
}
+1 -2
View File
@@ -12,8 +12,7 @@ export { OneboxReverseProxy } from './classes/reverseproxy.ts';
export { OneboxDnsManager } from './classes/dns.ts'; 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 { OneboxHttpServer } from './classes/httpserver.ts'; export { OneboxSystemd } from './classes/systemd.ts';
export { OneboxApiClient } from './classes/apiclient.ts';
// Types // Types
export * from './types.ts'; export * from './types.ts';
+188
View File
@@ -0,0 +1,188 @@
import * as plugins from '../plugins.ts';
import { logger } from '../logging.ts';
import type { Onebox } from '../classes/onebox.ts';
import * as interfaces from '../../ts_interfaces/index.ts';
import * as handlers from './handlers/index.ts';
import { files as bundledFiles } from '../../ts_bundled/bundle.ts';
export class OpsServer {
public oneboxRef: Onebox;
public typedrouter = new plugins.typedrequest.TypedRouter();
public server!: plugins.typedserver.utilityservers.UtilityWebsiteServer;
// Handler instances
public adminHandler!: handlers.AdminHandler;
public statusHandler!: handlers.StatusHandler;
public servicesHandler!: handlers.ServicesHandler;
public platformHandler!: handlers.PlatformHandler;
public sslHandler!: handlers.SslHandler;
public domainsHandler!: handlers.DomainsHandler;
public dnsHandler!: handlers.DnsHandler;
public registryHandler!: handlers.RegistryHandler;
public networkHandler!: handlers.NetworkHandler;
public backupsHandler!: handlers.BackupsHandler;
public schedulesHandler!: handlers.SchedulesHandler;
public settingsHandler!: handlers.SettingsHandler;
public logsHandler!: handlers.LogsHandler;
public workspaceHandler!: handlers.WorkspaceHandler;
public appStoreHandler!: handlers.AppStoreHandler;
constructor(oneboxRef: Onebox) {
this.oneboxRef = oneboxRef;
}
public async start(port = 3000) {
this.server = new plugins.typedserver.utilityservers.UtilityWebsiteServer({
domain: 'localhost',
feedMetadata: undefined,
bundledContent: bundledFiles,
addCustomRoutes: async (typedserver) => this.registerCustomRoutes(typedserver),
});
// Chain typedrouters: server -> opsServer -> individual handlers
this.server.typedrouter.addTypedRouter(this.typedrouter);
// Set up all handlers
await this.setupHandlers();
await this.server.start(port);
logger.success(`OpsServer started on http://localhost:${port}`);
}
private async setupHandlers(): Promise<void> {
// AdminHandler requires async initialization for JWT key generation
this.adminHandler = new handlers.AdminHandler(this);
await this.adminHandler.initialize();
// All other handlers self-register in their constructors
this.statusHandler = new handlers.StatusHandler(this);
this.servicesHandler = new handlers.ServicesHandler(this);
this.platformHandler = new handlers.PlatformHandler(this);
this.sslHandler = new handlers.SslHandler(this);
this.domainsHandler = new handlers.DomainsHandler(this);
this.dnsHandler = new handlers.DnsHandler(this);
this.registryHandler = new handlers.RegistryHandler(this);
this.networkHandler = new handlers.NetworkHandler(this);
this.backupsHandler = new handlers.BackupsHandler(this);
this.schedulesHandler = new handlers.SchedulesHandler(this);
this.settingsHandler = new handlers.SettingsHandler(this);
this.logsHandler = new handlers.LogsHandler(this);
this.workspaceHandler = new handlers.WorkspaceHandler(this);
this.appStoreHandler = new handlers.AppStoreHandler(this);
logger.success('OpsServer TypedRequest handlers initialized');
}
private registerCustomRoutes(typedserver: plugins.typedserver.TypedServer): void {
typedserver.addRoute(
'/v2',
'ALL',
async (ctx) => this.oneboxRef.registry.handleRequest(ctx.request),
);
typedserver.addRoute(
'/v2/*',
'ALL',
async (ctx) => this.oneboxRef.registry.handleRequest(ctx.request),
);
typedserver.addRoute(
'/backups/:backupId/download',
'GET',
async (ctx) => {
const jwt = ctx.query.jwt;
if (!jwt) {
return new Response('Missing JWT', { status: 401 });
}
try {
await this.adminHandler.getVerifiedAdminIdentity({
jwt,
userId: '',
username: '',
expiresAt: 0,
role: 'user',
});
} catch {
return new Response('Unauthorized', { status: 401 });
}
const backupId = Number(ctx.params.backupId);
if (!Number.isInteger(backupId) || backupId < 1) {
return new Response('Invalid backup id', { status: 400 });
}
const backup = this.oneboxRef.database.getBackupById(backupId);
if (!backup) {
return new Response('Backup not found', { status: 404 });
}
const filename = this.sanitizeDownloadFilename(
backup.filename || `${backup.serviceName}-${backup.createdAt}.tar.enc`,
);
let filePath = this.oneboxRef.backupManager.getBackupFilePath(backupId);
let shouldCleanup = false;
if (!filePath) {
filePath = await this.oneboxRef.backupManager.getBackupExportPath(backupId);
shouldCleanup = !!filePath;
}
if (!filePath) {
return new Response('Backup export unavailable', { status: 404 });
}
try {
const fileData = await Deno.readFile(filePath);
return new Response(fileData, {
status: 200,
headers: {
'content-type': 'application/octet-stream',
'content-disposition': `attachment; filename="${filename}"`,
'content-length': String(fileData.byteLength),
'cache-control': 'no-store',
},
});
} finally {
if (shouldCleanup) {
await Deno.remove(filePath).catch(() => {});
}
}
},
);
}
private sanitizeDownloadFilename(filename: string): string {
return filename.replace(/["\\\r\n]/g, '_');
}
public async stop() {
if (this.server) {
await this.server.stop();
logger.success('OpsServer stopped');
}
}
public async pushDashboardEvent(method: string, payload: unknown): Promise<void> {
const typedsocket = (this.server as any)?.typedserver?.typedsocket;
if (!typedsocket) {
return;
}
const connections = await typedsocket.findAllTargetConnectionsByTag('role', 'ops_dashboard');
await Promise.allSettled(
connections.map((connection: any) => typedsocket.createTypedRequest(method, connection).fire(payload)),
);
}
public async broadcastServiceUpdate(
serviceName: string,
action: interfaces.requests.IReq_PushServiceUpdate['request']['action'],
service?: interfaces.data.IService | null,
): Promise<void> {
await this.pushDashboardEvent('pushServiceUpdate', {
action,
serviceName,
service: service || undefined,
});
}
}
+215
View File
@@ -0,0 +1,215 @@
import * as plugins from '../../plugins.ts';
import { logger } from '../../logging.ts';
import type { OpsServer } from '../classes.opsserver.ts';
import * as interfaces from '../../../ts_interfaces/index.ts';
import { hashPassword, verifyPassword } from '../../utils/auth.ts';
export interface IJwtData {
userId: string;
username: string;
role: 'admin' | 'user';
status: 'loggedIn' | 'loggedOut';
expiresAt: number;
}
export class AdminHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
public smartjwtInstance!: plugins.smartjwt.SmartJwt<IJwtData>;
constructor(private opsServerRef: OpsServer) {
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
}
public async initialize(): Promise<void> {
this.smartjwtInstance = new plugins.smartjwt.SmartJwt<IJwtData>();
await this.smartjwtInstance.init();
this.registerHandlers();
}
private async createIdentityForUser(
user: interfaces.data.IUser & { id?: number },
expiresAt: number,
): Promise<interfaces.data.IIdentity> {
const userId = String(user.id || user.username);
const jwt = await this.smartjwtInstance.createJWT({
userId,
username: user.username,
role: user.role,
status: 'loggedIn',
expiresAt,
});
return {
jwt,
userId,
username: user.username,
expiresAt,
role: user.role,
};
}
public async getVerifiedIdentity(
identityArg: interfaces.data.IIdentity | null | undefined,
): Promise<interfaces.data.IIdentity> {
if (!identityArg?.jwt) {
throw new plugins.typedrequest.TypedResponseError('No identity provided');
}
let jwtData: IJwtData;
try {
jwtData = await this.smartjwtInstance.verifyJWTAndGetData(identityArg.jwt);
} catch {
throw new plugins.typedrequest.TypedResponseError('Valid identity required');
}
if (jwtData.expiresAt < Date.now() || jwtData.status !== 'loggedIn') {
throw new plugins.typedrequest.TypedResponseError('Valid identity required');
}
const user = this.opsServerRef.oneboxRef.database.getUserByUsername(jwtData.username);
if (!user) {
throw new plugins.typedrequest.TypedResponseError('Valid identity required');
}
const userId = String(user.id || user.username);
if (jwtData.userId !== userId) {
throw new plugins.typedrequest.TypedResponseError('Valid identity required');
}
return {
jwt: identityArg.jwt,
userId,
username: user.username,
expiresAt: jwtData.expiresAt,
role: user.role,
};
}
public async getVerifiedAdminIdentity(
identityArg: interfaces.data.IIdentity | null | undefined,
): Promise<interfaces.data.IIdentity> {
const identity = await this.getVerifiedIdentity(identityArg);
if (identity.role !== 'admin') {
throw new plugins.typedrequest.TypedResponseError('Admin access required');
}
return identity;
}
private registerHandlers(): void {
// Login
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
'adminLoginWithUsernameAndPassword',
async (dataArg) => {
try {
const user = this.opsServerRef.oneboxRef.database.getUserByUsername(dataArg.username);
if (!user) {
throw new plugins.typedrequest.TypedResponseError('Invalid credentials');
}
const passwordMatches = await verifyPassword(dataArg.password, user.passwordHash);
if (!passwordMatches) {
throw new plugins.typedrequest.TypedResponseError('Invalid credentials');
}
const expiresAt = Date.now() + 24 * 3600 * 1000;
const freshUser = this.opsServerRef.oneboxRef.database.getUserByUsername(user.username) || user;
const identity = await this.createIdentityForUser(freshUser, expiresAt);
logger.info(`User logged in: ${user.username}`);
return {
identity,
};
} catch (error) {
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
throw new plugins.typedrequest.TypedResponseError('Login failed');
}
},
),
);
// Logout
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_AdminLogout>(
'adminLogout',
async (_dataArg) => {
return { ok: true };
},
),
);
// Verify Identity
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_VerifyIdentity>(
'verifyIdentity',
async (dataArg) => {
try {
const identity = await this.getVerifiedIdentity(dataArg.identity);
return {
valid: true,
identity,
};
} catch {
return { valid: false };
}
},
),
);
// Change Password
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ChangePassword>(
'changePassword',
async (dataArg) => {
const identity = await this.getVerifiedIdentity(dataArg.identity);
const user = this.opsServerRef.oneboxRef.database.getUserByUsername(identity.username);
if (!user) {
throw new plugins.typedrequest.TypedResponseError('User not found');
}
const currentPasswordMatches = await verifyPassword(dataArg.currentPassword, user.passwordHash);
if (!currentPasswordMatches) {
throw new plugins.typedrequest.TypedResponseError('Current password is incorrect');
}
const newHash = await hashPassword(dataArg.newPassword);
this.opsServerRef.oneboxRef.database.updateUserPassword(user.username, newHash);
logger.info(`Password changed for user: ${user.username}`);
return { ok: true };
},
),
);
}
// Guard for valid identity
public validIdentityGuard = new plugins.smartguard.Guard<{
identity: interfaces.data.IIdentity;
}>(
async (dataArg) => {
try {
await this.getVerifiedIdentity(dataArg.identity);
return true;
} catch {
return false;
}
},
{ failedHint: 'identity is not valid', name: 'validIdentityGuard' },
);
// Guard for admin identity
public adminIdentityGuard = new plugins.smartguard.Guard<{
identity: interfaces.data.IIdentity;
}>(
async (dataArg) => {
try {
const identity = await this.getVerifiedIdentity(dataArg.identity);
return identity.role === 'admin';
} catch {
return false;
}
},
{ failedHint: 'user is not admin', name: 'adminIdentityGuard' },
);
}
+104
View File
@@ -0,0 +1,104 @@
import * as plugins from '../../plugins.ts';
import { logger } from '../../logging.ts';
import type { OpsServer } from '../classes.opsserver.ts';
import * as interfaces from '../../../ts_interfaces/index.ts';
import { requireAdminIdentity } from '../helpers/guards.ts';
export class AppStoreHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
constructor(private opsServerRef: OpsServer) {
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
this.registerHandlers();
}
private registerHandlers(): void {
// Get app templates (catalog)
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetAppTemplates>(
'getAppTemplates',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const apps = await this.opsServerRef.oneboxRef.appStore.getApps();
return { apps };
},
),
);
// Get app config for a specific version
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetAppConfig>(
'getAppConfig',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const config = await this.opsServerRef.oneboxRef.appStore.getAppVersionConfig(
dataArg.appId,
dataArg.version,
);
const appMeta = await this.opsServerRef.oneboxRef.appStore.getAppMeta(dataArg.appId);
return { config, appMeta };
},
),
);
// Get services with available upgrades
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetUpgradeableServices>(
'getUpgradeableServices',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const services = await this.opsServerRef.oneboxRef.appStore.getUpgradeableServices();
return { services };
},
),
);
// Upgrade a service to a new template version
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpgradeService>(
'upgradeService',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const existingService = this.opsServerRef.oneboxRef.database.getServiceByName(dataArg.serviceName);
if (!existingService) {
throw new plugins.typedrequest.TypedResponseError(`Service not found: ${dataArg.serviceName}`);
}
if (!existingService.appTemplateId) {
throw new plugins.typedrequest.TypedResponseError('Service was not deployed from an app template');
}
if (!existingService.appTemplateVersion) {
throw new plugins.typedrequest.TypedResponseError('Service has no tracked template version');
}
logger.info(`Upgrading service '${dataArg.serviceName}' from v${existingService.appTemplateVersion} to v${dataArg.targetVersion}`);
// Execute migration
const migrationResult = await this.opsServerRef.oneboxRef.appStore.executeMigration(
existingService,
existingService.appTemplateVersion,
dataArg.targetVersion,
);
if (!migrationResult.success) {
throw new plugins.typedrequest.TypedResponseError(
`Migration failed: ${migrationResult.warnings.join('; ')}`,
);
}
// Apply the upgrade
const updatedService = await this.opsServerRef.oneboxRef.appStore.applyUpgrade(
dataArg.serviceName,
migrationResult,
dataArg.targetVersion,
);
return {
service: updatedService,
warnings: migrationResult.warnings,
};
},
),
);
}
}
+93
View File
@@ -0,0 +1,93 @@
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 BackupsHandler {
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_GetBackups>(
'getBackups',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const backups = this.opsServerRef.oneboxRef.backupManager.listBackups();
return { backups };
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetBackup>(
'getBackup',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const backup = this.opsServerRef.oneboxRef.database.getBackupById(dataArg.backupId);
if (!backup) {
throw new plugins.typedrequest.TypedResponseError('Backup not found');
}
return { backup };
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteBackup>(
'deleteBackup',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
await this.opsServerRef.oneboxRef.backupManager.deleteBackup(dataArg.backupId);
return { ok: true };
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RestoreBackup>(
'restoreBackup',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const rawResult = await this.opsServerRef.oneboxRef.backupManager.restoreBackup(
dataArg.backupId,
dataArg.options,
);
return {
result: {
service: {
name: rawResult.service.name,
status: rawResult.service.status,
},
platformResourcesRestored: rawResult.platformResourcesRestored,
warnings: rawResult.warnings,
},
};
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DownloadBackup>(
'downloadBackup',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const backup = this.opsServerRef.oneboxRef.database.getBackupById(dataArg.backupId);
if (!backup) {
throw new plugins.typedrequest.TypedResponseError('Backup not found');
}
// Return a download URL that the client can fetch directly
const filename = backup.filename || `${backup.serviceName}-${backup.createdAt}.tar.enc`;
return {
downloadUrl: `/backups/${dataArg.backupId}/download?jwt=${encodeURIComponent(dataArg.identity.jwt)}`,
filename,
};
},
),
);
}
}
+76
View File
@@ -0,0 +1,76 @@
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 DnsHandler {
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_GetDnsRecords>(
'getDnsRecords',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const records = this.opsServerRef.oneboxRef.dns.listDNSRecords();
return { records };
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateDnsRecord>(
'createDnsRecord',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
await this.opsServerRef.oneboxRef.dns.addDNSRecord(dataArg.domain, dataArg.value);
const records = this.opsServerRef.oneboxRef.dns.listDNSRecords();
const record = records.find((r: any) => r.domain === dataArg.domain);
return { record: record! };
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteDnsRecord>(
'deleteDnsRecord',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
await this.opsServerRef.oneboxRef.dns.removeDNSRecord(dataArg.domain);
return { ok: true };
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SyncDns>(
'syncDns',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
if (!this.opsServerRef.oneboxRef.dns.isConfigured()) {
throw new plugins.typedrequest.TypedResponseError('DNS manager not configured');
}
await this.opsServerRef.oneboxRef.dns.syncFromCloudflare();
const records = this.opsServerRef.oneboxRef.dns.listDNSRecords();
return { records };
},
),
);
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 };
},
),
);
}
}
+112
View File
@@ -0,0 +1,112 @@
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 DomainsHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
constructor(private opsServerRef: OpsServer) {
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
this.registerHandlers();
}
private buildDomainViews(): interfaces.data.IDomainDetail[] {
const domains = this.opsServerRef.oneboxRef.database.getAllDomains();
const allServices = this.opsServerRef.oneboxRef.database.getAllServices();
return domains.map((domain: any) => {
const certificates = this.opsServerRef.oneboxRef.database.getCertificatesByDomain(domain.id!);
const requirements = this.opsServerRef.oneboxRef.database.getCertRequirementsByDomain(domain.id!);
const serviceCount = allServices.filter((service: any) => {
if (!service.domain) return false;
const baseDomain = service.domain.split('.').slice(-2).join('.');
return baseDomain === domain.domain;
}).length;
let certificateStatus: 'valid' | 'expiring-soon' | 'expired' | 'pending' | 'none' = 'none';
let daysRemaining: number | null = null;
const validCerts = certificates.filter((cert: any) => cert.isValid && cert.expiryDate > Date.now());
if (validCerts.length > 0) {
const latestCert = validCerts.reduce((latest: any, cert: any) =>
cert.expiryDate > latest.expiryDate ? cert : latest
);
daysRemaining = Math.floor((latestCert.expiryDate - Date.now()) / (24 * 60 * 60 * 1000));
certificateStatus = daysRemaining <= 30 ? 'expiring-soon' : 'valid';
} else if (certificates.some((cert: any) => !cert.isValid)) {
certificateStatus = 'expired';
} else if (requirements.some((req: any) => req.status === 'pending')) {
certificateStatus = 'pending';
}
return {
domain,
certificates,
requirements,
serviceCount,
certificateStatus,
daysRemaining,
};
});
}
private registerHandlers(): void {
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetDomains>(
'getDomains',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const domains = this.buildDomainViews();
return { domains };
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetDomain>(
'getDomain',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const domain = this.opsServerRef.oneboxRef.database.getDomainByName(dataArg.domainName);
if (!domain) {
throw new plugins.typedrequest.TypedResponseError('Domain not found');
}
const views = this.buildDomainViews();
const domainView = views.find((v) => v.domain.domain === dataArg.domainName);
if (!domainView) {
throw new plugins.typedrequest.TypedResponseError('Domain not found');
}
return { domain: domainView };
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SyncDomains>(
'syncDomains',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
if (!this.opsServerRef.oneboxRef.cloudflareDomainSync) {
throw new plugins.typedrequest.TypedResponseError('Cloudflare domain sync not configured');
}
await this.opsServerRef.oneboxRef.cloudflareDomainSync.syncZones();
const domains = this.buildDomainViews();
return { domains };
},
),
);
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 };
},
),
);
}
}
+15
View File
@@ -0,0 +1,15 @@
export * from './admin.handler.ts';
export * from './status.handler.ts';
export * from './services.handler.ts';
export * from './platform.handler.ts';
export * from './ssl.handler.ts';
export * from './domains.handler.ts';
export * from './dns.handler.ts';
export * from './registry.handler.ts';
export * from './network.handler.ts';
export * from './backups.handler.ts';
export * from './schedules.handler.ts';
export * from './settings.handler.ts';
export * from './logs.handler.ts';
export * from './workspace.handler.ts';
export * from './appstore.handler.ts';
+219
View File
@@ -0,0 +1,219 @@
import * as plugins from '../../plugins.ts';
import { logger } from '../../logging.ts';
import type { OpsServer } from '../classes.opsserver.ts';
import * as interfaces from '../../../ts_interfaces/index.ts';
import { requireAdminIdentity } from '../helpers/guards.ts';
export class LogsHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
constructor(private opsServerRef: OpsServer) {
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
this.registerHandlers();
}
private registerHandlers(): void {
// Service log stream
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetServiceLogStream>(
'getServiceLogStream',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const service = this.opsServerRef.oneboxRef.database.getServiceByName(dataArg.serviceName);
if (!service) {
throw new plugins.typedrequest.TypedResponseError('Service not found');
}
const virtualStream = new plugins.typedrequest.VirtualStream<Uint8Array>();
const encoder = new TextEncoder();
// Get container and start streaming in background
(async () => {
try {
let container = await this.opsServerRef.oneboxRef.docker.getContainerById(service.containerID!);
if (!container) {
// Try finding by service label
const containers = await this.opsServerRef.oneboxRef.docker.listAllContainers();
const serviceContainer = containers.find((c: any) => {
const labels = c.Labels || {};
return labels['com.docker.swarm.service.id'] === service.containerID;
});
if (serviceContainer) {
container = await this.opsServerRef.oneboxRef.docker.getContainerById(serviceContainer.Id);
}
}
if (!container) {
virtualStream.sendData(encoder.encode(JSON.stringify({ error: 'Container not found' })));
return;
}
const logStream = await container.streamLogs({
stdout: true,
stderr: true,
timestamps: true,
tail: 100,
});
let buffer = new Uint8Array(0);
logStream.on('data', (chunk: Uint8Array) => {
// Append to buffer
const newBuffer = new Uint8Array(buffer.length + chunk.length);
newBuffer.set(buffer);
newBuffer.set(chunk, buffer.length);
buffer = newBuffer;
// Process Docker multiplexed frames
while (buffer.length >= 8) {
const frameSize = (buffer[4] << 24) | (buffer[5] << 16) | (buffer[6] << 8) | buffer[7];
if (buffer.length < 8 + frameSize) break;
const frameData = buffer.slice(8, 8 + frameSize);
try {
virtualStream.sendData(frameData);
} catch {
logStream.destroy();
return;
}
buffer = buffer.slice(8 + frameSize);
}
});
logStream.on('error', (error: Error) => {
logger.error(`Log stream error for ${dataArg.serviceName}: ${error.message}`);
});
} catch (error) {
logger.error(`Failed to start log stream: ${error}`);
}
})();
return { logStream: virtualStream as any };
},
),
);
// Platform service log stream
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetPlatformServiceLogStream>(
'getPlatformServiceLogStream',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const platformService = this.opsServerRef.oneboxRef.database.getPlatformServiceByType(
dataArg.serviceType,
);
if (!platformService || !platformService.containerId) {
throw new plugins.typedrequest.TypedResponseError('Platform service has no container');
}
const virtualStream = new plugins.typedrequest.VirtualStream<Uint8Array>();
(async () => {
try {
const container = await this.opsServerRef.oneboxRef.docker.getContainerById(
platformService.containerId!,
);
if (!container) return;
const logStream = await container.streamLogs({
stdout: true,
stderr: true,
timestamps: true,
tail: 100,
});
let buffer = new Uint8Array(0);
logStream.on('data', (chunk: Uint8Array) => {
const newBuffer = new Uint8Array(buffer.length + chunk.length);
newBuffer.set(buffer);
newBuffer.set(chunk, buffer.length);
buffer = newBuffer;
while (buffer.length >= 8) {
const frameSize = (buffer[4] << 24) | (buffer[5] << 16) | (buffer[6] << 8) | buffer[7];
if (buffer.length < 8 + frameSize) break;
const frameData = buffer.slice(8, 8 + frameSize);
try {
virtualStream.sendData(frameData);
} catch {
logStream.destroy();
return;
}
buffer = buffer.slice(8 + frameSize);
}
});
} catch (error) {
logger.error(`Failed to start platform log stream: ${error}`);
}
})();
return { logStream: virtualStream as any };
},
),
);
// Network log stream
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetNetworkLogStream>(
'getNetworkLogStream',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const virtualStream = new plugins.typedrequest.VirtualStream<Uint8Array>();
const encoder = new TextEncoder();
const clientId = crypto.randomUUID();
// Create a mock WebSocket-like object for the proxy log receiver.
const mockSocket = {
readyState: 1, // WebSocket.OPEN
send: (data: string) => {
try {
virtualStream.sendData(encoder.encode(data));
} catch {
this.opsServerRef.oneboxRef.proxyLogReceiver.removeClient(clientId);
}
},
};
const filter = dataArg.filter || {};
this.opsServerRef.oneboxRef.proxyLogReceiver.addClient(
clientId,
mockSocket as any,
filter,
);
return { logStream: virtualStream as any };
},
),
);
// Event stream (general updates)
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetEventStream>(
'getEventStream',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const virtualStream = new plugins.typedrequest.VirtualStream<Uint8Array>();
const encoder = new TextEncoder();
// Send initial connection message
virtualStream.sendData(
encoder.encode(
JSON.stringify({
type: 'connected',
message: 'Connected to Onebox event stream',
timestamp: Date.now(),
}),
),
);
return { eventStream: virtualStream as any };
},
),
);
}
}
+124
View File
@@ -0,0 +1,124 @@
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';
import type { TPlatformServiceType } from '../../types.ts';
export class NetworkHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
constructor(private opsServerRef: OpsServer) {
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
this.registerHandlers();
}
private getPlatformServicePort(type: TPlatformServiceType): number {
const ports: Record<TPlatformServiceType, number> = {
mongodb: 27017,
minio: 9000,
redis: 6379,
postgresql: 5432,
rabbitmq: 5672,
smartproxy: 80,
clickhouse: 8123,
mariadb: 3306,
};
return ports[type] || 0;
}
private registerHandlers(): void {
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetNetworkTargets>(
'getNetworkTargets',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const targets: interfaces.data.INetworkTarget[] = [];
// Services
const services = this.opsServerRef.oneboxRef.services.listServices();
for (const svc of services) {
targets.push({
type: 'service',
name: svc.name,
domain: svc.domain || null,
targetHost: (svc as any).containerIP || svc.containerID || 'unknown',
targetPort: svc.port || 80,
status: svc.status,
});
}
// Registry
const registryStatus = this.opsServerRef.oneboxRef.registry.getStatus();
if (registryStatus.running) {
targets.push({
type: 'registry',
name: 'onebox-registry',
domain: null,
targetHost: 'localhost',
targetPort: registryStatus.port,
status: 'running',
});
}
// Platform services
const platformServices = this.opsServerRef.oneboxRef.platformServices.getAllPlatformServices();
for (const ps of platformServices) {
const provider = this.opsServerRef.oneboxRef.platformServices.getProvider(ps.type);
targets.push({
type: 'platform',
name: provider?.displayName || ps.type,
domain: null,
targetHost: 'localhost',
targetPort: this.getPlatformServicePort(ps.type),
status: ps.status,
});
}
return { targets };
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetNetworkStats>(
'getNetworkStats',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const proxyStatus = this.opsServerRef.oneboxRef.reverseProxy.getStatus() as any;
const logReceiverStats = this.opsServerRef.oneboxRef.proxyLogReceiver.getStats();
return {
stats: {
proxy: {
running: proxyStatus.running ?? proxyStatus.http?.running ?? false,
httpPort: proxyStatus.httpPort ?? proxyStatus.http?.port ?? 80,
httpsPort: proxyStatus.httpsPort ?? proxyStatus.https?.port ?? 443,
routes: proxyStatus.routes ?? 0,
certificates: proxyStatus.certificates ?? proxyStatus.https?.certificates ?? 0,
},
logReceiver: {
running: logReceiverStats.running,
port: logReceiverStats.port,
clients: logReceiverStats.clients,
connections: logReceiverStats.connections,
sampleRate: logReceiverStats.sampleRate,
recentLogsCount: logReceiverStats.recentLogsCount,
},
},
};
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetTrafficStats>(
'getTrafficStats',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const trafficStats = this.opsServerRef.oneboxRef.proxyLogReceiver.getTrafficStats(60);
return { stats: trafficStats };
},
),
);
}
}
+303
View File
@@ -0,0 +1,303 @@
import * as plugins from '../../plugins.ts';
import { logger } from '../../logging.ts';
import type { OpsServer } from '../classes.opsserver.ts';
import * as interfaces from '../../../ts_interfaces/index.ts';
import { requireAdminIdentity } from '../helpers/guards.ts';
export class PlatformHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
private activeLogStreams = new Map<string, boolean>();
constructor(private opsServerRef: OpsServer) {
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
this.registerHandlers();
this.startLogStreaming();
}
/**
* Start streaming logs from all running containers (platform + user services)
* and push new entries to connected dashboard clients via TypedSocket
*/
private async startLogStreaming(): Promise<void> {
const checkAndStream = async () => {
// Stream platform service containers
const platformServices = this.opsServerRef.oneboxRef.database.getAllPlatformServices();
for (const service of platformServices) {
if (service.status !== 'running' || !service.containerId) continue;
const key = `platform:${service.type}`;
if (this.activeLogStreams.has(key)) continue;
this.activeLogStreams.set(key, true);
logger.info(`Starting log stream for platform service: ${service.type}`);
try {
await this.opsServerRef.oneboxRef.docker.streamContainerLogs(
service.containerId,
(line: string, isError: boolean) => {
this.pushPlatformLogToClients(service.type as interfaces.data.TPlatformServiceType, line, isError);
}
);
} catch (err) {
logger.warn(`Log stream failed for ${service.type}: ${(err as Error).message}`);
this.activeLogStreams.delete(key);
}
}
// Stream user service containers
const userServices = this.opsServerRef.oneboxRef.services.listServices();
for (const service of userServices) {
if (service.status !== 'running' || !service.containerID) continue;
const key = `service:${service.name}`;
if (this.activeLogStreams.has(key)) continue;
this.activeLogStreams.set(key, true);
logger.info(`Starting log stream for user service: ${service.name}`);
try {
await this.opsServerRef.oneboxRef.docker.streamContainerLogs(
service.containerID,
(line: string, isError: boolean) => {
this.pushServiceLogToClients(service.name, line, isError);
}
);
} catch (err) {
logger.warn(`Log stream failed for ${service.name}: ${(err as Error).message}`);
this.activeLogStreams.delete(key);
}
}
};
// Initial check after a short delay (let services start first)
setTimeout(() => checkAndStream(), 5000);
// Re-check periodically for newly started services
setInterval(() => checkAndStream(), 15000);
}
private parseLogLine(line: string, isError: boolean): { timestamp: string; level: string; message: string } {
const tsMatch = line.match(/^(\d{4}-\d{2}-\d{2}T[\d:.]+Z?)\s+(.*)/);
const timestamp = tsMatch ? tsMatch[1] : new Date().toISOString();
const message = tsMatch ? tsMatch[2] : line;
const msgLower = message.toLowerCase();
const level = isError || msgLower.includes('error') || msgLower.includes('fatal')
? 'error'
: msgLower.includes('warn')
? 'warn'
: 'info';
return { timestamp, level, message };
}
private pushPlatformLogToClients(
serviceType: interfaces.data.TPlatformServiceType,
line: string,
isError: boolean,
): void {
const entry = this.parseLogLine(line, isError);
void this.opsServerRef.pushDashboardEvent('pushPlatformServiceLog', { serviceType, entry });
}
private pushServiceLogToClients(
serviceName: string,
line: string,
isError: boolean,
): void {
const entry = this.parseLogLine(line, isError);
void this.opsServerRef.pushDashboardEvent('pushServiceLog', { serviceName, entry });
}
private registerHandlers(): void {
// Get all platform services
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetPlatformServices>(
'getPlatformServices',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const platformServices = this.opsServerRef.oneboxRef.platformServices.getAllPlatformServices();
const providers = this.opsServerRef.oneboxRef.platformServices.getAllProviders();
const result = providers.map((provider: any) => {
const service = platformServices.find((s: any) => s.type === provider.type);
const isCore = 'isCore' in provider && (provider as any).isCore === true;
let status: string = service?.status || 'not-deployed';
if (provider.type === 'smartproxy') {
const proxyStatus = this.opsServerRef.oneboxRef.reverseProxy.getStatus() as any;
status = (proxyStatus.running ?? proxyStatus.http?.running) ? 'running' : 'stopped';
}
return {
type: provider.type,
displayName: provider.displayName,
resourceTypes: provider.resourceTypes,
status: status as interfaces.data.TPlatformServiceStatus,
containerId: service?.containerId,
isCore,
createdAt: service?.createdAt,
updatedAt: service?.updatedAt,
};
});
return { platformServices: result as interfaces.data.IPlatformService[] };
},
),
);
// Get specific platform service
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetPlatformService>(
'getPlatformService',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const provider = this.opsServerRef.oneboxRef.platformServices.getProvider(dataArg.serviceType);
if (!provider) {
throw new plugins.typedrequest.TypedResponseError(`Unknown platform service type: ${dataArg.serviceType}`);
}
const service = this.opsServerRef.oneboxRef.database.getPlatformServiceByType(dataArg.serviceType);
const isCore = 'isCore' in provider && (provider as any).isCore === true;
let rawStatus: string = service?.status || 'not-deployed';
if (dataArg.serviceType === 'smartproxy') {
const proxyStatus = this.opsServerRef.oneboxRef.reverseProxy.getStatus() as any;
rawStatus = (proxyStatus.running ?? proxyStatus.http?.running) ? 'running' : 'stopped';
}
return {
platformService: {
type: provider.type,
displayName: provider.displayName,
resourceTypes: provider.resourceTypes,
status: rawStatus as interfaces.data.TPlatformServiceStatus,
containerId: service?.containerId,
isCore,
createdAt: service?.createdAt,
updatedAt: service?.updatedAt,
} as interfaces.data.IPlatformService,
};
},
),
);
// Start platform service
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_StartPlatformService>(
'startPlatformService',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const provider = this.opsServerRef.oneboxRef.platformServices.getProvider(dataArg.serviceType);
if (!provider) {
throw new plugins.typedrequest.TypedResponseError(`Unknown platform service type: ${dataArg.serviceType}`);
}
logger.info(`Starting platform service: ${dataArg.serviceType}`);
const service = await this.opsServerRef.oneboxRef.platformServices.ensureRunning(dataArg.serviceType);
return {
platformService: {
type: service.type,
displayName: provider.displayName,
resourceTypes: provider.resourceTypes,
status: service.status,
containerId: service.containerId,
},
};
},
),
);
// Stop platform service
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_StopPlatformService>(
'stopPlatformService',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const provider = this.opsServerRef.oneboxRef.platformServices.getProvider(dataArg.serviceType);
if (!provider) {
throw new plugins.typedrequest.TypedResponseError(`Unknown platform service type: ${dataArg.serviceType}`);
}
const isCore = 'isCore' in provider && (provider as any).isCore === true;
if (isCore) {
throw new plugins.typedrequest.TypedResponseError(
`${provider.displayName} is a core service and cannot be stopped`,
);
}
logger.info(`Stopping platform service: ${dataArg.serviceType}`);
await this.opsServerRef.oneboxRef.platformServices.stopPlatformService(dataArg.serviceType);
return {
platformService: {
type: dataArg.serviceType,
displayName: provider.displayName,
resourceTypes: provider.resourceTypes,
status: 'stopped' as const,
},
};
},
),
);
// Get platform service stats
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetPlatformServiceStats>(
'getPlatformServiceStats',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const service = this.opsServerRef.oneboxRef.database.getPlatformServiceByType(dataArg.serviceType);
if (!service || !service.containerId) {
throw new plugins.typedrequest.TypedResponseError('Platform service has no container');
}
const stats = await this.opsServerRef.oneboxRef.docker.getContainerStats(service.containerId);
if (!stats) {
throw new plugins.typedrequest.TypedResponseError('Could not retrieve container stats');
}
return { stats };
},
),
);
// Get platform service logs
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetPlatformServiceLogs>(
'getPlatformServiceLogs',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const service = this.opsServerRef.oneboxRef.database.getPlatformServiceByType(dataArg.serviceType);
if (!service || !service.containerId) {
throw new plugins.typedrequest.TypedResponseError('Platform service has no container');
}
const tail = dataArg.tail || 100;
const rawLogs = await this.opsServerRef.oneboxRef.docker.getContainerLogs(service.containerId, tail);
// Parse raw log output into structured entries
const logLines = (rawLogs.stdout + rawLogs.stderr)
.split('\n')
.filter((line: string) => line.trim());
const logs = logLines.map((line: string, index: number) => {
// Try to parse Docker timestamp from beginning of line
const tsMatch = line.match(/^(\d{4}-\d{2}-\d{2}T[\d:.]+Z?)\s+(.*)/);
const timestamp = tsMatch ? new Date(tsMatch[1]).getTime() : Date.now();
const message = tsMatch ? tsMatch[2] : line;
const msgLower = message.toLowerCase();
const isError = msgLower.includes('error') || msgLower.includes('fatal');
const isWarn = msgLower.includes('warn');
return {
id: index,
serviceId: 0,
timestamp,
message,
level: (isError ? 'error' : isWarn ? 'warn' : 'info') as 'info' | 'warn' | 'error' | 'debug',
source: 'stdout' as const,
};
});
return { logs };
},
),
);
}
}
+147
View File
@@ -0,0 +1,147 @@
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 RegistryHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
constructor(private opsServerRef: OpsServer) {
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
this.registerHandlers();
}
private registerHandlers(): void {
// Get registry tags
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRegistryTags>(
'getRegistryTags',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const tags = await this.opsServerRef.oneboxRef.registry.getImageTags(dataArg.serviceName);
return { tags };
},
),
);
// Get registry tokens
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRegistryTokens>(
'getRegistryTokens',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const rawTokens = this.opsServerRef.oneboxRef.database.getAllRegistryTokens();
const now = Date.now();
const tokens = rawTokens.map((token: any) => {
const isExpired = token.expiresAt !== null && token.expiresAt < now;
let scopeDisplay: string;
if (token.scope === 'all') {
scopeDisplay = 'All services';
} else if (Array.isArray(token.scope)) {
scopeDisplay = token.scope.length === 1 ? token.scope[0] : `${token.scope.length} services`;
} else {
scopeDisplay = 'Unknown';
}
return {
id: token.id!,
name: token.name,
type: token.type,
scope: token.scope,
scopeDisplay,
expiresAt: token.expiresAt,
createdAt: token.createdAt,
lastUsedAt: token.lastUsedAt,
createdBy: token.createdBy,
isExpired,
};
});
return { tokens };
},
),
);
// Create registry token
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateRegistryToken>(
'createRegistryToken',
async (dataArg) => {
const identity = await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const config = dataArg.tokenConfig;
// Calculate expiration
const now = Date.now();
let expiresAt: number | null = null;
if (config.expiresIn !== 'never') {
const daysMap: Record<string, number> = { '30d': 30, '90d': 90, '365d': 365 };
const days = daysMap[config.expiresIn];
if (days) expiresAt = now + days * 24 * 60 * 60 * 1000;
}
// Generate token
const plainToken = crypto.randomUUID() + crypto.randomUUID();
const encoder = new TextEncoder();
const hashBuffer = await crypto.subtle.digest('SHA-256', encoder.encode(plainToken));
const hashArray = Array.from(new Uint8Array(hashBuffer));
const tokenHash = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
const token = this.opsServerRef.oneboxRef.database.createRegistryToken({
name: config.name,
tokenHash,
type: config.type,
scope: config.scope,
expiresAt,
createdAt: now,
lastUsedAt: null,
createdBy: identity.username,
});
let scopeDisplay: string;
if (token.scope === 'all') {
scopeDisplay = 'All services';
} else if (Array.isArray(token.scope)) {
scopeDisplay = token.scope.length === 1 ? token.scope[0] : `${token.scope.length} services`;
} else {
scopeDisplay = 'Unknown';
}
return {
result: {
token: {
id: token.id!,
name: token.name,
type: token.type,
scope: token.scope,
scopeDisplay,
expiresAt: token.expiresAt,
createdAt: token.createdAt,
lastUsedAt: token.lastUsedAt,
createdBy: token.createdBy,
isExpired: false,
},
plainToken,
},
};
},
),
);
// Delete registry token
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteRegistryToken>(
'deleteRegistryToken',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const token = this.opsServerRef.oneboxRef.database.getRegistryTokenById(dataArg.tokenId);
if (!token) {
throw new plugins.typedrequest.TypedResponseError('Token not found');
}
this.opsServerRef.oneboxRef.database.deleteRegistryToken(dataArg.tokenId);
return { ok: true };
},
),
);
}
}
@@ -0,0 +1,93 @@
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 SchedulesHandler {
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_GetBackupSchedules>(
'getBackupSchedules',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const schedules = this.opsServerRef.oneboxRef.backupScheduler.getAllSchedules();
return { schedules };
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateBackupSchedule>(
'createBackupSchedule',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const schedule = await this.opsServerRef.oneboxRef.backupScheduler.createSchedule(
dataArg.scheduleConfig,
);
return { schedule };
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetBackupSchedule>(
'getBackupSchedule',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const schedule = this.opsServerRef.oneboxRef.backupScheduler.getScheduleById(dataArg.scheduleId);
if (!schedule) {
throw new plugins.typedrequest.TypedResponseError('Schedule not found');
}
return { schedule };
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateBackupSchedule>(
'updateBackupSchedule',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const schedule = await this.opsServerRef.oneboxRef.backupScheduler.updateSchedule(
dataArg.scheduleId,
dataArg.updates,
);
return { schedule };
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteBackupSchedule>(
'deleteBackupSchedule',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
await this.opsServerRef.oneboxRef.backupScheduler.deleteSchedule(dataArg.scheduleId);
return { ok: true };
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_TriggerBackupSchedule>(
'triggerBackupSchedule',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
await this.opsServerRef.oneboxRef.backupScheduler.triggerBackup(dataArg.scheduleId);
// triggerBackup is void; the backup is created async by the scheduler
// Return the most recent backup for the schedule
const allBackups = this.opsServerRef.oneboxRef.backupManager.listBackups();
const latestBackup = allBackups.find((b: any) => b.scheduleId === dataArg.scheduleId);
return { backup: latestBackup! };
},
),
);
}
}
+244
View File
@@ -0,0 +1,244 @@
import * as plugins from '../../plugins.ts';
import { logger } from '../../logging.ts';
import type { OpsServer } from '../classes.opsserver.ts';
import * as interfaces from '../../../ts_interfaces/index.ts';
import { requireAdminIdentity } from '../helpers/guards.ts';
export class ServicesHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
constructor(private opsServerRef: OpsServer) {
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
this.registerHandlers();
}
private registerHandlers(): void {
// Get all services
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetServices>(
'getServices',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const services = this.opsServerRef.oneboxRef.services.listServices();
return { services };
},
),
);
// Get single service
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetService>(
'getService',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const service = this.opsServerRef.oneboxRef.services.getService(dataArg.serviceName);
if (!service) {
throw new plugins.typedrequest.TypedResponseError('Service not found');
}
return { service };
},
),
);
// Create service
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateService>(
'createService',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const service = await this.opsServerRef.oneboxRef.services.deployService(dataArg.serviceConfig);
return { service };
},
),
);
// Update service
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateService>(
'updateService',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const service = await this.opsServerRef.oneboxRef.services.updateService(
dataArg.serviceName,
dataArg.updates,
);
return { service };
},
),
);
// Delete service
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteService>(
'deleteService',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
await this.opsServerRef.oneboxRef.services.removeService(dataArg.serviceName);
return { ok: true };
},
),
);
// Start service
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_StartService>(
'startService',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
await this.opsServerRef.oneboxRef.services.startService(dataArg.serviceName);
const service = this.opsServerRef.oneboxRef.services.getService(dataArg.serviceName);
return { service: service! };
},
),
);
// Stop service
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_StopService>(
'stopService',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
await this.opsServerRef.oneboxRef.services.stopService(dataArg.serviceName);
const service = this.opsServerRef.oneboxRef.services.getService(dataArg.serviceName);
return { service: service! };
},
),
);
// Restart service
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RestartService>(
'restartService',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
await this.opsServerRef.oneboxRef.services.restartService(dataArg.serviceName);
const service = this.opsServerRef.oneboxRef.services.getService(dataArg.serviceName);
return { service: service! };
},
),
);
// Get service logs
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetServiceLogs>(
'getServiceLogs',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const logs = await this.opsServerRef.oneboxRef.services.getServiceLogs(dataArg.serviceName);
return { logs: String(logs) };
},
),
);
// Get service stats
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetServiceStats>(
'getServiceStats',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const service = this.opsServerRef.oneboxRef.services.getService(dataArg.serviceName);
if (!service || !service.containerID) {
throw new plugins.typedrequest.TypedResponseError('Service has no container');
}
const stats = await this.opsServerRef.oneboxRef.docker.getContainerStats(service.containerID);
if (!stats) {
throw new plugins.typedrequest.TypedResponseError('Could not retrieve container stats');
}
return { stats };
},
),
);
// Get service metrics
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetServiceMetrics>(
'getServiceMetrics',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const service = this.opsServerRef.oneboxRef.services.getService(dataArg.serviceName);
if (!service || !service.id) {
throw new plugins.typedrequest.TypedResponseError('Service not found');
}
const metrics = this.opsServerRef.oneboxRef.database.getMetrics(service.id, dataArg.limit || 60);
return { metrics };
},
),
);
// Get service platform resources
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetServicePlatformResources>(
'getServicePlatformResources',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const rawResources = await this.opsServerRef.oneboxRef.services.getServicePlatformResources(
dataArg.serviceName,
);
const resources = rawResources.map((r: any) => ({
id: r.resource.id,
resourceType: r.resource.resourceType,
resourceName: r.resource.resourceName,
platformService: {
type: r.platformService.type,
name: r.platformService.name,
status: r.platformService.status,
},
envVars: Object.keys(r.credentials).reduce((acc: Record<string, string>, key: string) => {
const value = r.credentials[key];
if (key.toLowerCase().includes('password') || key.toLowerCase().includes('secret')) {
acc[key] = '********';
} else {
acc[key] = value;
}
return acc;
}, {}),
createdAt: r.resource.createdAt,
}));
return { resources };
},
),
);
// Get service backups
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetServiceBackups>(
'getServiceBackups',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const backups = this.opsServerRef.oneboxRef.backupManager.listBackups(dataArg.serviceName);
return { backups };
},
),
);
// Create service backup
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateServiceBackup>(
'createServiceBackup',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const result = await this.opsServerRef.oneboxRef.backupManager.createBackup(dataArg.serviceName);
return { backup: result.backup };
},
),
);
// Get service backup schedules
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetServiceBackupSchedules>(
'getServiceBackupSchedules',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const service = this.opsServerRef.oneboxRef.services.getService(dataArg.serviceName);
if (!service) {
throw new plugins.typedrequest.TypedResponseError('Service not found');
}
const schedules = this.opsServerRef.oneboxRef.backupScheduler.getSchedulesForService(
dataArg.serviceName,
);
return { schedules };
},
),
);
}
}
+130
View File
@@ -0,0 +1,130 @@
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';
import { logger } from '../../logging.ts';
import { getErrorMessage } from '../../utils/error.ts';
export class SettingsHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
constructor(private opsServerRef: OpsServer) {
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
this.registerHandlers();
}
private async getSettingsObject(): Promise<interfaces.data.ISettings> {
const db = this.opsServerRef.oneboxRef.database;
const cloudflareToken = await db.getSecretSetting('cloudflareToken');
const dcrouterGatewayApiToken = await db.getSecretSetting('dcrouterGatewayApiToken');
const settingsMap = db.getAllSettings();
return {
cloudflareToken: cloudflareToken || '',
cloudflareZoneId: settingsMap['cloudflareZoneId'] || '',
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',
renewalThreshold: parseInt(settingsMap['renewalThreshold'] || '30', 10),
acmeEmail: settingsMap['acmeEmail'] || '',
httpPort: parseInt(settingsMap['httpPort'] || '80', 10),
httpsPort: parseInt(settingsMap['httpsPort'] || '443', 10),
forceHttps: settingsMap['forceHttps'] === 'true',
};
}
private registerHandlers(): void {
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSettings>(
'getSettings',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const settings = await this.getSettingsObject();
return { settings };
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateSettings>(
'updateSettings',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const db = this.opsServerRef.oneboxRef.database;
const updates = dataArg.settings;
// Store each setting as key-value pair
for (const [key, value] of Object.entries(updates)) {
if (value !== undefined) {
if (db.isSecretSettingKey(key)) {
await db.setSecretSetting(key, String(value));
} else {
db.setSetting(key, String(value));
}
}
}
if (this.hasExternalGatewaySetting(updates)) {
this.refreshExternalGateway().catch((error) => {
logger.warn(`External gateway settings refresh failed: ${getErrorMessage(error)}`);
});
}
const settings = await this.getSettingsObject();
return { settings };
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SetBackupPassword>(
'setBackupPassword',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
await this.opsServerRef.oneboxRef.database.setSecretSetting('backupPassword', dataArg.password);
return { ok: true };
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetBackupPasswordStatus>(
'getBackupPasswordStatus',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const isConfigured = await this.opsServerRef.oneboxRef.database.hasSecretSetting('backupPassword');
return { status: { isConfigured } };
},
),
);
}
private hasExternalGatewaySetting(settings: Partial<interfaces.data.ISettings>): boolean {
return [
'dcrouterGatewayUrl',
'dcrouterGatewayApiToken',
'dcrouterGatewayClientId',
'dcrouterWorkHosterId',
'dcrouterTargetHost',
'dcrouterTargetPort',
].some((key) => Object.prototype.hasOwnProperty.call(settings, key));
}
private async refreshExternalGateway(): Promise<void> {
const onebox = this.opsServerRef.oneboxRef;
await onebox.externalGateway.syncDomains();
const services = onebox.database.getAllServices().filter((service) => service.domain);
await Promise.all(services.map(async (service) => {
try {
await onebox.externalGateway.syncServiceRoute(service);
} catch (error) {
logger.warn(`Failed to sync external gateway route for ${service.domain}: ${getErrorMessage(error)}`);
}
}));
}
}
+64
View File
@@ -0,0 +1,64 @@
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 SslHandler {
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_ObtainCertificate>(
'obtainCertificate',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
await this.opsServerRef.oneboxRef.ssl.obtainCertificate(dataArg.domain, false);
const certificate = this.opsServerRef.oneboxRef.ssl.getCertificate(dataArg.domain);
return { certificate: certificate as unknown as interfaces.data.ICertificate };
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ListCertificates>(
'listCertificates',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const certificates = this.opsServerRef.oneboxRef.ssl.listCertificates();
return { certificates: certificates as unknown as interfaces.data.ICertificate[] };
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetCertificate>(
'getCertificate',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const certificate = this.opsServerRef.oneboxRef.ssl.getCertificate(dataArg.domain);
if (!certificate) {
throw new plugins.typedrequest.TypedResponseError('Certificate not found');
}
return { certificate: certificate as unknown as interfaces.data.ICertificate };
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RenewCertificate>(
'renewCertificate',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
await this.opsServerRef.oneboxRef.ssl.renewCertificate(dataArg.domain);
const certificate = this.opsServerRef.oneboxRef.ssl.getCertificate(dataArg.domain);
return { certificate: certificate as unknown as interfaces.data.ICertificate };
},
),
);
}
}
+26
View File
@@ -0,0 +1,26 @@
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 StatusHandler {
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_GetSystemStatus>(
'getSystemStatus',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const status = await this.opsServerRef.oneboxRef.getSystemStatus();
return { status: status as unknown as interfaces.data.ISystemStatus };
},
),
);
}
}
+181
View File
@@ -0,0 +1,181 @@
import * as plugins from '../../plugins.ts';
import { logger } from '../../logging.ts';
import type { OpsServer } from '../classes.opsserver.ts';
import * as interfaces from '../../../ts_interfaces/index.ts';
import { requireAdminIdentity } from '../helpers/guards.ts';
import { getErrorMessage } from '../../utils/error.ts';
export class WorkspaceHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
constructor(private opsServerRef: OpsServer) {
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
this.registerHandlers();
}
/**
* Resolve a service name to a container ID (handling Swarm service IDs)
*/
private async resolveContainerId(serviceName: string): Promise<string> {
const service = this.opsServerRef.oneboxRef.services.getService(serviceName);
if (!service || !service.containerID) {
throw new plugins.typedrequest.TypedResponseError(`Service not found or has no container: ${serviceName}`);
}
return service.containerID;
}
private registerHandlers(): void {
// Read file from container
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_WorkspaceReadFile>(
'workspaceReadFile',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const containerId = await this.resolveContainerId(dataArg.serviceName);
const result = await this.opsServerRef.oneboxRef.docker.execInContainer(
containerId,
['cat', dataArg.path],
);
if (result.exitCode !== 0) {
throw new plugins.typedrequest.TypedResponseError(`Failed to read file: ${result.stderr || 'File not found'}`);
}
return { content: result.stdout };
},
),
);
// Write file to container
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_WorkspaceWriteFile>(
'workspaceWriteFile',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const containerId = await this.resolveContainerId(dataArg.serviceName);
// Use sh -c with printf to write content (handles special characters)
const escaped = dataArg.content.replace(/'/g, "'\\''");
const result = await this.opsServerRef.oneboxRef.docker.execInContainer(
containerId,
['sh', '-c', `printf '%s' '${escaped}' > ${dataArg.path}`],
);
if (result.exitCode !== 0) {
throw new plugins.typedrequest.TypedResponseError(`Failed to write file: ${result.stderr}`);
}
return {};
},
),
);
// Read directory from container
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_WorkspaceReadDir>(
'workspaceReadDir',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const containerId = await this.resolveContainerId(dataArg.serviceName);
// Use ls with -1 -F to get entries with type indicators (/ for dirs)
const result = await this.opsServerRef.oneboxRef.docker.execInContainer(
containerId,
['ls', '-1', '-F', dataArg.path],
);
if (result.exitCode !== 0) {
throw new plugins.typedrequest.TypedResponseError(`Failed to read directory: ${result.stderr}`);
}
const entries = result.stdout
.split('\n')
.filter((line) => line.trim())
.map((line) => {
const isDir = line.endsWith('/');
const name = isDir ? line.slice(0, -1) : line.replace(/[*@=|]$/, '');
const basePath = dataArg.path.endsWith('/') ? dataArg.path : dataArg.path + '/';
return {
type: (isDir ? 'directory' : 'file') as 'file' | 'directory',
name,
path: basePath + name,
};
});
return { entries };
},
),
);
// Create directory in container
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_WorkspaceMkdir>(
'workspaceMkdir',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const containerId = await this.resolveContainerId(dataArg.serviceName);
const result = await this.opsServerRef.oneboxRef.docker.execInContainer(
containerId,
['mkdir', '-p', dataArg.path],
);
if (result.exitCode !== 0) {
throw new plugins.typedrequest.TypedResponseError(`Failed to create directory: ${result.stderr}`);
}
return {};
},
),
);
// Remove file/directory from container
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_WorkspaceRm>(
'workspaceRm',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const containerId = await this.resolveContainerId(dataArg.serviceName);
const args = dataArg.recursive ? ['rm', '-rf', dataArg.path] : ['rm', '-f', dataArg.path];
const result = await this.opsServerRef.oneboxRef.docker.execInContainer(
containerId,
args,
);
if (result.exitCode !== 0) {
throw new plugins.typedrequest.TypedResponseError(`Failed to remove: ${result.stderr}`);
}
return {};
},
),
);
// Check if path exists in container
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_WorkspaceExists>(
'workspaceExists',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const containerId = await this.resolveContainerId(dataArg.serviceName);
const result = await this.opsServerRef.oneboxRef.docker.execInContainer(
containerId,
['test', '-e', dataArg.path],
);
return { exists: result.exitCode === 0 };
},
),
);
// Execute a command in the container (non-interactive)
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_WorkspaceExec>(
'workspaceExec',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const containerId = await this.resolveContainerId(dataArg.serviceName);
const cmd = dataArg.args
? [dataArg.command, ...dataArg.args]
: [dataArg.command];
const result = await this.opsServerRef.oneboxRef.docker.execInContainer(
containerId,
cmd,
);
return {
stdout: result.stdout,
stderr: result.stderr,
exitCode: result.exitCode,
};
},
),
);
logger.info('Workspace handler registered');
}
}
+17
View File
@@ -0,0 +1,17 @@
import * as plugins from '../../plugins.ts';
import type { AdminHandler } from '../handlers/admin.handler.ts';
import * as interfaces from '../../../ts_interfaces/index.ts';
export async function requireValidIdentity<T extends { identity?: interfaces.data.IIdentity }>(
adminHandler: AdminHandler,
dataArg: T,
): Promise<interfaces.data.IIdentity> {
return await adminHandler.getVerifiedIdentity(dataArg.identity);
}
export async function requireAdminIdentity<T extends { identity?: interfaces.data.IIdentity }>(
adminHandler: AdminHandler,
dataArg: T,
): Promise<interfaces.data.IIdentity> {
return await adminHandler.getVerifiedAdminIdentity(dataArg.identity);
}
+1
View File
@@ -0,0 +1 @@
export * from './classes.opsserver.ts';
+38 -9
View File
@@ -17,10 +17,6 @@ export { path, fs, http, encoding };
import { Database } from '@db/sqlite'; import { Database } from '@db/sqlite';
export const sqlite = { DB: Database }; export const sqlite = { DB: Database };
// Systemd Daemon Integration
import * as smartdaemon from '@push.rocks/smartdaemon';
export { smartdaemon };
// Docker API Client // Docker API Client
import { DockerHost } from '@apiclient.xyz/docker'; import { DockerHost } from '@apiclient.xyz/docker';
export const docker = { Docker: DockerHost }; export const docker = { Docker: DockerHost };
@@ -38,12 +34,26 @@ import * as smartregistry from '@push.rocks/smartregistry';
export { smartregistry }; export { smartregistry };
// S3-compatible storage server // S3-compatible storage server
import * as smarts3 from '@push.rocks/smarts3'; import * as smartstorage from '@push.rocks/smartstorage';
export { smarts3 }; export { smartstorage };
// Crypto utilities (for password hashing, encryption) // AWS S3 client for S3-compatible object operations
import * as bcrypt from 'https://deno.land/x/bcrypt@v0.4.1/mod.ts'; import {
export { bcrypt }; S3Client,
ListObjectsV2Command,
GetObjectCommand,
PutObjectCommand,
} from 'npm:@aws-sdk/client-s3@3.1009.0';
export const awsS3 = {
S3Client,
ListObjectsV2Command,
GetObjectCommand,
PutObjectCommand,
};
// Task scheduling and cron jobs
import * as taskbuffer from '@push.rocks/taskbuffer';
export { taskbuffer };
// 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';
@@ -57,3 +67,22 @@ export { crypto };
import * as nodeHttps from 'node:https'; import * as nodeHttps from 'node:https';
import * as nodeHttp from 'node:http'; import * as nodeHttp from 'node:http';
export { nodeHttps, nodeHttp }; export { nodeHttps, nodeHttp };
// TypedRequest/TypedServer infrastructure
import * as typedrequest from '@api.global/typedrequest';
import * as typedserver from '@api.global/typedserver';
export { typedrequest, typedserver };
// Auth & Guards
import * as smartguard from '@push.rocks/smartguard';
import * as smartjwt from '@push.rocks/smartjwt';
export { smartguard, smartjwt };
// Backup archive (content-addressed dedup storage)
import { ContainerArchive } from '@serve.zone/containerarchive';
export { ContainerArchive };
// Node.js compat for streaming
import * as nodeFs from 'node:fs';
import * as nodeStream from 'node:stream';
export { nodeFs, nodeStream };
+155 -6
View File
@@ -23,6 +23,11 @@ export interface IService {
imageDigest?: string; imageDigest?: string;
// Platform service requirements // Platform service requirements
platformRequirements?: IPlatformRequirements; platformRequirements?: IPlatformRequirements;
// Backup settings
includeImageInBackup?: boolean;
// App Store template tracking
appTemplateId?: string;
appTemplateVersion?: string;
} }
// Registry types // Registry types
@@ -73,7 +78,7 @@ export interface ITokenCreatedResponse {
} }
// Platform service types // Platform service types
export type TPlatformServiceType = 'mongodb' | 'minio' | 'redis' | 'postgresql' | 'rabbitmq' | 'caddy' | 'clickhouse'; 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';
@@ -111,6 +116,8 @@ export interface IPlatformRequirements {
mongodb?: boolean; mongodb?: boolean;
s3?: boolean; s3?: boolean;
clickhouse?: boolean; clickhouse?: boolean;
redis?: boolean;
mariadb?: boolean;
} }
export interface IProvisionedResource { export interface IProvisionedResource {
@@ -141,7 +148,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;
@@ -250,14 +257,23 @@ export interface ISetting {
// Application settings // Application settings
export interface IAppSettings { export interface IAppSettings {
serverIP?: string; serverIP?: string;
cloudflareAPIKey?: string; cloudflareToken?: string;
cloudflareEmail?: string; cloudflareZoneId?: string;
cloudflareZoneID?: 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;
} }
@@ -289,6 +305,11 @@ export interface IServiceDeployOptions {
enableMongoDB?: boolean; enableMongoDB?: boolean;
enableS3?: boolean; enableS3?: boolean;
enableClickHouse?: boolean; enableClickHouse?: boolean;
enableRedis?: boolean;
enableMariaDB?: boolean;
// App Store template tracking
appTemplateId?: string;
appTemplateVersion?: string;
} }
// HTTP API request/response types // HTTP API request/response types
@@ -317,3 +338,131 @@ export interface ICliArgs {
_: string[]; _: string[];
[key: string]: unknown; [key: string]: unknown;
} }
// Backup types
export type TBackupRestoreMode = 'restore' | 'import' | 'clone';
// Retention policy for GFS (Grandfather-Father-Son) time-window based retention
export interface IRetentionPolicy {
hourly: number; // 0 = disabled, else keep up to N backups from last 24h
daily: number; // Keep 1 backup per day for last N days
weekly: number; // Keep 1 backup per week for last N weeks
monthly: number; // Keep 1 backup per month for last N months
}
// Default retention presets
export const RETENTION_PRESETS = {
standard: { hourly: 0, daily: 7, weekly: 4, monthly: 12 },
frequent: { hourly: 24, daily: 7, weekly: 4, monthly: 12 },
minimal: { hourly: 0, daily: 3, weekly: 2, monthly: 6 },
longterm: { hourly: 0, daily: 14, weekly: 8, monthly: 24 },
} as const;
export type TRetentionPreset = keyof typeof RETENTION_PRESETS | 'custom';
export interface IBackup {
id?: number;
serviceId: number;
serviceName: string; // Denormalized for display
filename: string;
snapshotId?: string; // ContainerArchive snapshot ID (new backups)
sizeBytes: number;
storedSizeBytes?: number; // Actual stored size after dedup+compression
createdAt: number;
includesImage: boolean;
platformResources: TPlatformServiceType[]; // Which platform types were backed up
checksum: string;
// Scheduled backup fields
scheduleId?: number; // Links backup to its schedule for retention
}
export interface IBackupManifest {
version: string;
createdAt: number;
oneboxVersion: string;
serviceName: string;
includesImage: boolean;
platformResources: TPlatformServiceType[];
checksum: string;
}
export interface IBackupServiceConfig {
name: string;
image: string;
registry?: string;
envVars: Record<string, string>;
port: number;
domain?: string;
useOneboxRegistry?: boolean;
registryRepository?: string;
registryImageTag?: string;
autoUpdateOnPush?: boolean;
platformRequirements?: IPlatformRequirements;
includeImageInBackup?: boolean;
}
export interface IBackupPlatformResource {
resourceType: TPlatformResourceType;
resourceName: string;
platformServiceType: TPlatformServiceType;
credentials: Record<string, string>; // Decrypted for backup, re-encrypted on restore
}
export interface IBackupResult {
backup: IBackup;
filePath?: string; // Legacy file-based backups only
snapshotId?: string; // ContainerArchive snapshot ID
}
export interface IRestoreOptions {
mode: TBackupRestoreMode;
newServiceName?: string; // Required for 'import' and 'clone' modes
skipPlatformData?: boolean; // Restore config only, skip DB/bucket data
overwriteExisting?: boolean; // For 'restore' mode
}
export interface IRestoreResult {
service: IService;
platformResourcesRestored: number;
warnings: string[];
}
// Backup scheduling types (GFS retention scheme)
export type TBackupScheduleScope = 'all' | 'pattern' | 'service';
export interface IBackupSchedule {
id?: number;
scopeType: TBackupScheduleScope;
scopePattern?: string; // Glob pattern for 'pattern' scope type
serviceId?: number; // Only for 'service' scope type
serviceName?: string; // Only for 'service' scope type
cronExpression: string;
retention: IRetentionPolicy; // Per-tier retention counts
enabled: boolean;
lastRunAt: number | null;
nextRunAt: number | null;
lastStatus: 'success' | 'failed' | null;
lastError: string | null;
createdAt: number;
updatedAt: number;
}
export interface IBackupScheduleCreate {
scopeType: TBackupScheduleScope;
scopePattern?: string; // Required for 'pattern' scope type
serviceName?: string; // Required for 'service' scope type
cronExpression: string;
retention: IRetentionPolicy;
enabled?: boolean;
}
export interface IBackupScheduleUpdate {
cronExpression?: string;
retention?: IRetentionPolicy;
enabled?: boolean;
}
// Backup creation options (for scheduled backups)
export interface IBackupCreateOptions {
scheduleId?: number;
}
+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;
}

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