Compare commits
153 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3c99ee5f83 | |||
| 2faa416895 | |||
| acbf448c6f | |||
| 5c48ae4156 | |||
| 3108408133 | |||
| 6defdb4431 | |||
| f63be883ce | |||
| 87844bbb8e | |||
| 02b7cda2be | |||
| 3b8f95e8e1 | |||
| ee774e3f41 | |||
| 6d93dfa459 | |||
| ac394cfafc | |||
| 97e9f232fa | |||
| 3dcb6a38e5 | |||
| ca33970e9a | |||
| cd34b98a25 | |||
| a089e5bedb | |||
| 9786ff62f0 | |||
| 4a5abc4a0a | |||
| 893a532758 | |||
| 7ea286c0a9 | |||
| f94f47e313 | |||
| b1a46f8757 | |||
| 56c71226e5 | |||
| f53109a01e | |||
| bcb2473cc5 | |||
| 689dcf295b | |||
| c1e14e9fc7 | |||
| d5fd57e2c3 | |||
| 079e6a64a9 | |||
| a04cf053db | |||
| ec0e377ccb | |||
| 3b3d0433cb | |||
| 5f876449ca | |||
| 8e781c7f9d | |||
| a3eefbe92c | |||
| 41679427c6 | |||
| c420a30341 | |||
| fe109f0953 | |||
| 012dce63b1 | |||
| 54780482c7 | |||
| 7ab0fb3c1f | |||
| 713fda2a86 | |||
| ec32c19300 | |||
| 7d1d91157c | |||
| b69c96c240 | |||
| 9ee8851d03 | |||
| 7f6031f31a | |||
| 6f1b8469e0 | |||
| cd06c74cc3 | |||
| d3acc720ca | |||
| 1b6de75097 | |||
| 497f8f59a7 | |||
| 0c7d65e4ad | |||
| 3f2cd074ce | |||
| 59ed7233bd | |||
| 01e3ba16c4 | |||
| f5c1d5fcda | |||
| 45b0971f2f | |||
| 178f440d7e | |||
| 7fff15a90c | |||
| 69e23f667e | |||
| a2bf4df7c2 | |||
| 9e0a0b5a89 | |||
| 3a227bd838 | |||
| f5a7fccfc2 | |||
| a30d2029a5 | |||
| 88727dd47d | |||
| 9a5ed2220e | |||
| decd39e7c4 | |||
| ad2e228208 | |||
| cf06019d79 | |||
| cf44b0047d | |||
| 260b5364e6 | |||
| 51c1962042 | |||
| d3b78054ad | |||
| d2ae35f0ce | |||
| a605477663 | |||
| ba98086548 | |||
| 0b3c22556b | |||
| 069e6e6c8f | |||
| 10598520d8 | |||
| 075b7946b1 | |||
| f47fca3304 | |||
| 575e010a6b | |||
| 60a5dc4663 | |||
| 36d80b1e27 | |||
| 465cf0ee72 | |||
| bd5cd5c0cb | |||
| b622565e34 | |||
| 56376121ab | |||
| e3359d1235 | |||
| f1eeec6922 | |||
| 69362bb529 | |||
| 857fcc50ba | |||
| 5d0df006eb | |||
| e6256502ce | |||
| d5dc141171 | |||
| 2538f5ae2c | |||
| 4613193dcc | |||
| 848b3afe54 | |||
| dd86bae942 | |||
| 4691c61544 | |||
| dfb2d3b340 | |||
| 6a19ab05e3 | |||
| 7b718da7a2 | |||
| ebaf545418 | |||
| 2cdfdaed55 | |||
| 2216804652 | |||
| 1b177037f5 | |||
| 9d6590927c | |||
| eaf401200c | |||
| e97a4d53ae | |||
| ca2b3b25a5 | |||
| 19703de50d | |||
| bcab4f274e | |||
| 64e947735f | |||
| 1e05c08002 | |||
| 167df321f9 | |||
| 49998c4c32 | |||
| 8045ec38df | |||
| 793fb18b43 | |||
| 09534fd899 | |||
| 5f3783a5e9 | |||
| 92555c5a5e | |||
| ddc7fa4bee | |||
| eceb5d99c8 | |||
| 0631b7731f | |||
| 4c485cdc0a | |||
| 0f0da0f2ef | |||
| 88367f70eb | |||
| bfcfef79da | |||
| d95270613b | |||
| 14f6746833 | |||
| fe8ca00337 | |||
| ba05cc84fe | |||
| 84c47cd7f5 | |||
| 9365f20f6d | |||
| bc2ed4b03a | |||
| e4dd4cce0a | |||
| 34c90e21db | |||
| ea7bb1395f | |||
| c529dfe34d | |||
| 6ba7e655e3 | |||
| c5d239ab28 | |||
| 5cd7e7c252 | |||
| e7ade45097 | |||
| 7b159a3486 | |||
| 9470c7911d | |||
| 3d7727c304 | |||
| ff5b51072f | |||
| 633cbe696e |
@@ -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
.gitea/release-template.md
Normal file
37
.gitea/release-template.md
Normal 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.
|
||||
114
.gitea/workflows/ci.yml
Normal file
114
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,114 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: Type Check & Lint
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: code.foss.global/host.today/ht-docker-node:latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Deno
|
||||
uses: denoland/setup-deno@v1
|
||||
with:
|
||||
deno-version: v2.x
|
||||
|
||||
- name: Install dependencies
|
||||
run: deno install --entrypoint mod.ts
|
||||
|
||||
- name: Check TypeScript types
|
||||
run: deno check mod.ts
|
||||
|
||||
- name: Lint code
|
||||
run: deno lint
|
||||
continue-on-error: true
|
||||
|
||||
- name: Format check
|
||||
run: deno fmt --check
|
||||
continue-on-error: true
|
||||
|
||||
build:
|
||||
name: Build Test (Current Platform)
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: code.foss.global/host.today/ht-docker-node:latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- 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: Compile for current platform
|
||||
run: |
|
||||
echo "Testing compilation for Linux x86_64..."
|
||||
npx tsdeno compile --allow-all --no-check \
|
||||
--output onebox-test \
|
||||
--target x86_64-unknown-linux-gnu mod.ts
|
||||
|
||||
- name: Test binary execution
|
||||
run: |
|
||||
chmod +x onebox-test
|
||||
./onebox-test --version
|
||||
./onebox-test --help
|
||||
|
||||
build-all:
|
||||
name: Build All Platforms
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: code.foss.global/host.today/ht-docker-node:latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- 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: Compile all platform binaries
|
||||
run: mkdir -p dist/binaries && npx tsdeno compile
|
||||
|
||||
- name: Upload all binaries as artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: onebox-binaries.zip
|
||||
path: dist/binaries/*
|
||||
retention-days: 30
|
||||
131
.gitea/workflows/npm-publish.yml
Normal file
131
.gitea/workflows/npm-publish.yml
Normal file
@@ -0,0 +1,131 @@
|
||||
name: Publish to npm
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
npm-publish:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: code.foss.global/host.today/ht-docker-node:latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Deno
|
||||
uses: denoland/setup-deno@v1
|
||||
with:
|
||||
deno-version: v2.x
|
||||
|
||||
- name: Setup Node.js for npm publishing
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18.x'
|
||||
registry-url: 'https://registry.npmjs.org/'
|
||||
|
||||
- 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 "Publishing 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 npm package
|
||||
run: |
|
||||
echo "Compiling binaries for npm package..."
|
||||
deno task compile
|
||||
echo ""
|
||||
echo "Binary sizes:"
|
||||
ls -lh dist/binaries/
|
||||
|
||||
- name: Generate SHA256 checksums
|
||||
run: |
|
||||
cd dist/binaries
|
||||
sha256sum * > SHA256SUMS
|
||||
cat SHA256SUMS
|
||||
cd ../..
|
||||
|
||||
- name: Sync package.json version
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version_number }}"
|
||||
echo "Syncing package.json to version ${VERSION}..."
|
||||
npm version ${VERSION} --no-git-tag-version --allow-same-version
|
||||
echo "package.json version: $(grep '"version"' package.json | head -1)"
|
||||
|
||||
- name: Create npm package
|
||||
run: |
|
||||
echo "Creating npm package..."
|
||||
npm pack
|
||||
echo ""
|
||||
echo "Package created:"
|
||||
ls -lh *.tgz
|
||||
|
||||
- name: Test local installation
|
||||
run: |
|
||||
echo "Testing local package installation..."
|
||||
PACKAGE_FILE=$(ls *.tgz)
|
||||
npm install -g ${PACKAGE_FILE}
|
||||
echo ""
|
||||
echo "Testing onebox command:"
|
||||
onebox --version || echo "Note: Binary execution may fail in CI environment"
|
||||
echo ""
|
||||
echo "Checking installed files:"
|
||||
npm ls -g @serve.zone/onebox || true
|
||||
|
||||
- name: Publish to npm
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: |
|
||||
echo "Publishing to npm registry..."
|
||||
npm publish --access public
|
||||
echo ""
|
||||
echo "Successfully published @serve.zone/onebox to npm!"
|
||||
echo ""
|
||||
echo "Package info:"
|
||||
npm view @serve.zone/onebox
|
||||
|
||||
- name: Verify npm package
|
||||
run: |
|
||||
echo "Waiting for npm propagation..."
|
||||
sleep 30
|
||||
echo ""
|
||||
echo "Verifying published package..."
|
||||
npm view @serve.zone/onebox
|
||||
echo ""
|
||||
echo "Testing installation from npm:"
|
||||
npm install -g @serve.zone/onebox
|
||||
echo ""
|
||||
echo "Package installed successfully!"
|
||||
which onebox || echo "Binary location check skipped"
|
||||
|
||||
- name: Publish Summary
|
||||
run: |
|
||||
echo "================================================"
|
||||
echo " npm Publish Complete!"
|
||||
echo "================================================"
|
||||
echo ""
|
||||
echo "Package: @serve.zone/onebox"
|
||||
echo "Version: ${{ steps.version.outputs.version }}"
|
||||
echo ""
|
||||
echo "Installation:"
|
||||
echo " npm install -g @serve.zone/onebox"
|
||||
echo ""
|
||||
echo "Registry:"
|
||||
echo " https://www.npmjs.com/package/@serve.zone/onebox"
|
||||
echo ""
|
||||
211
.gitea/workflows/release.yml
Normal file
211
.gitea/workflows/release.yml
Normal 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 ""
|
||||
467
changelog.md
467
changelog.md
@@ -1,5 +1,472 @@
|
||||
# Changelog
|
||||
|
||||
## 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)
|
||||
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).
|
||||
- Implement traffic statistics aggregation in CaddyLogReceiver using rolling per-minute buckets (requestCount, errorCount, avgResponseTime, totalBytes, statusCounts, requestsPerMinute, errorRate).
|
||||
- Expose getTrafficStats(minutes?) in the Angular ApiService and add ITrafficStats type to the client API types.
|
||||
- Add dashboard UI components: TrafficCard, PlatformServicesCard, CertificatesCard and integrate them into the main Dashboard (including links to Platform Services).
|
||||
- Enhance system status data: platformServices entries now include displayName and resourceCount; add certificateHealth summary (valid, expiringSoon, expired, expiringDomains) returned by Onebox status.
|
||||
- Platform services manager and Onebox code updated to surface provider information and resource counts for the UI.
|
||||
- Add VSCode workspace launch/tasks recommendations for the UI development environment.
|
||||
|
||||
## 2025-11-26 - 1.4.0 - feat(platform-services)
|
||||
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
|
||||
- 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
|
||||
- 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
|
||||
|
||||
## 2025-11-26 - 1.3.0 - feat(platform-services)
|
||||
Add ClickHouse platform service support (provider, types, provisioning, UI and port mappings)
|
||||
|
||||
|
||||
17
deno.json
17
deno.json
@@ -1,12 +1,11 @@
|
||||
{
|
||||
"name": "@serve.zone/onebox",
|
||||
"version": "1.3.0",
|
||||
"version": "1.22.0",
|
||||
"exports": "./mod.ts",
|
||||
"nodeModulesDir": "auto",
|
||||
"tasks": {
|
||||
"test": "deno test --allow-all test/",
|
||||
"test:watch": "deno test --allow-all --watch test/",
|
||||
"compile": "bash scripts/compile-all.sh",
|
||||
"compile": "tsdeno compile",
|
||||
"dev": "pnpm run watch"
|
||||
},
|
||||
"imports": {
|
||||
@@ -16,12 +15,18 @@
|
||||
"@std/assert": "jsr:@std/assert@^1.0.15",
|
||||
"@std/encoding": "jsr:@std/encoding@^1.0.10",
|
||||
"@db/sqlite": "jsr:@db/sqlite@0.12.0",
|
||||
"@push.rocks/smartdaemon": "npm:@push.rocks/smartdaemon@^2.1.0",
|
||||
"@apiclient.xyz/docker": "npm:@apiclient.xyz/docker@^5.1.0",
|
||||
"@apiclient.xyz/docker": "npm:@apiclient.xyz/docker@^5.1.1",
|
||||
"@apiclient.xyz/cloudflare": "npm:@apiclient.xyz/cloudflare@6.4.3",
|
||||
"@push.rocks/smartacme": "npm:@push.rocks/smartacme@^8.0.0",
|
||||
"@push.rocks/smartregistry": "npm:@push.rocks/smartregistry@^2.2.0",
|
||||
"@push.rocks/smarts3": "npm:@push.rocks/smarts3@^5.1.0"
|
||||
"@push.rocks/smarts3": "npm:@push.rocks/smarts3@^5.1.0",
|
||||
"@push.rocks/taskbuffer": "npm:@push.rocks/taskbuffer@^3.1.0",
|
||||
"@api.global/typedrequest-interfaces": "npm:@api.global/typedrequest-interfaces@^3.0.19",
|
||||
"@api.global/typedrequest": "npm:@api.global/typedrequest@^3.2.6",
|
||||
"@api.global/typedserver": "npm:@api.global/typedserver@^8.3.1",
|
||||
"@push.rocks/smartguard": "npm:@push.rocks/smartguard@^3.1.0",
|
||||
"@push.rocks/smartjwt": "npm:@push.rocks/smartjwt@^2.2.1",
|
||||
"@api.global/typedsocket": "npm:@api.global/typedsocket@^4.1.2"
|
||||
},
|
||||
"compilerOptions": {
|
||||
"lib": [
|
||||
|
||||
36196
dist_serve/bundle.js
Normal file
36196
dist_serve/bundle.js
Normal file
File diff suppressed because one or more lines are too long
33
dist_serve/index.html
Normal file
33
dist_serve/index.html
Normal 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>
|
||||
33
html/index.html
Normal file
33
html/index.html
Normal 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>
|
||||
448
install.sh
448
install.sh
@@ -1,192 +1,310 @@
|
||||
#!/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
|
||||
|
||||
# Configuration
|
||||
REPO_URL="https://code.foss.global/serve.zone/onebox"
|
||||
# Default values
|
||||
SHOW_HELP=0
|
||||
SPECIFIED_VERSION=""
|
||||
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
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
# Parse command line arguments
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
-h|--help)
|
||||
SHOW_HELP=1
|
||||
shift
|
||||
;;
|
||||
--version)
|
||||
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
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Functions
|
||||
error() {
|
||||
echo -e "${RED}Error: $1${NC}" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
info() {
|
||||
echo -e "${GREEN}$1${NC}"
|
||||
}
|
||||
|
||||
warn() {
|
||||
echo -e "${YELLOW}$1${NC}"
|
||||
}
|
||||
|
||||
# Detect platform and architecture
|
||||
detect_platform() {
|
||||
OS=$(uname -s | tr '[:upper:]' '[:lower:]')
|
||||
ARCH=$(uname -m)
|
||||
|
||||
case "$OS" in
|
||||
linux)
|
||||
PLATFORM="linux"
|
||||
;;
|
||||
darwin)
|
||||
PLATFORM="macos"
|
||||
;;
|
||||
*)
|
||||
error "Unsupported operating system: $OS"
|
||||
;;
|
||||
esac
|
||||
|
||||
case "$ARCH" in
|
||||
x86_64|amd64)
|
||||
ARCH="x64"
|
||||
;;
|
||||
aarch64|arm64)
|
||||
ARCH="arm64"
|
||||
;;
|
||||
*)
|
||||
error "Unsupported architecture: $ARCH"
|
||||
;;
|
||||
esac
|
||||
|
||||
BINARY_NAME="onebox-${PLATFORM}-${ARCH}"
|
||||
}
|
||||
|
||||
# Get latest version from Gitea API
|
||||
get_latest_version() {
|
||||
info "Fetching latest version..."
|
||||
VERSION=$(curl -s "${REPO_URL}/releases" | grep -o '"tag_name":"v[^"]*' | head -1 | cut -d'"' -f4 | cut -c2-)
|
||||
|
||||
if [ -z "$VERSION" ]; then
|
||||
warn "Could not fetch latest version, using 'main' branch"
|
||||
VERSION="main"
|
||||
else
|
||||
info "Latest version: v${VERSION}"
|
||||
fi
|
||||
}
|
||||
if [ $SHOW_HELP -eq 1 ]; then
|
||||
echo "Onebox Installer Script"
|
||||
echo "Downloads and installs pre-compiled Onebox binary"
|
||||
echo ""
|
||||
echo "Usage: $0 [options]"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " -h, --help Show this help message"
|
||||
echo " --version VERSION Install specific version (e.g., v1.11.0)"
|
||||
echo " --install-dir DIR Installation directory (default: /opt/onebox)"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " # Install latest version"
|
||||
echo " curl -sSL https://code.foss.global/serve.zone/onebox/raw/branch/main/install.sh | sudo bash"
|
||||
echo ""
|
||||
echo " # Install specific version"
|
||||
echo " curl -sSL https://code.foss.global/serve.zone/onebox/raw/branch/main/install.sh | sudo bash -s -- --version v1.11.0"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Check if running as root
|
||||
check_root() {
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
error "This script must be run as root (use sudo)"
|
||||
fi
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
echo "Please run as root (sudo bash install.sh or pipe to sudo bash)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Helper function to detect OS and architecture
|
||||
detect_platform() {
|
||||
local os=$(uname -s)
|
||||
local arch=$(uname -m)
|
||||
|
||||
# Map OS
|
||||
case "$os" in
|
||||
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
|
||||
|
||||
# Map architecture
|
||||
case "$arch" in
|
||||
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
|
||||
echo "onebox-${os_name}-${arch_name}"
|
||||
fi
|
||||
}
|
||||
|
||||
# Get latest release version from Gitea API
|
||||
get_latest_version() {
|
||||
echo "Fetching latest release version from Gitea..." >&2
|
||||
|
||||
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"
|
||||
|
||||
# Download binary
|
||||
download_binary() {
|
||||
info "Downloading Onebox ${VERSION} for ${PLATFORM}-${ARCH}..."
|
||||
echo "Downloading Onebox binary..."
|
||||
TEMP_FILE="$INSTALL_DIR/onebox.download"
|
||||
curl -sSL "$DOWNLOAD_URL" -o "$TEMP_FILE"
|
||||
|
||||
# Create temp directory
|
||||
TMP_DIR=$(mktemp -d)
|
||||
TMP_FILE="${TMP_DIR}/${BINARY_NAME}"
|
||||
if [ $? -ne 0 ]; then
|
||||
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
|
||||
|
||||
# Try release download first
|
||||
if [ "$VERSION" != "main" ]; then
|
||||
DOWNLOAD_URL="${REPO_URL}/releases/download/v${VERSION}/${BINARY_NAME}"
|
||||
else
|
||||
DOWNLOAD_URL="${REPO_URL}/raw/branch/main/dist/binaries/${BINARY_NAME}"
|
||||
fi
|
||||
# Check if download was successful (file exists and not empty)
|
||||
if [ ! -s "$TEMP_FILE" ]; then
|
||||
echo "Error: Downloaded file is empty or does not exist"
|
||||
rm -f "$TEMP_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! curl -L -f -o "$TMP_FILE" "$DOWNLOAD_URL"; then
|
||||
error "Failed to download binary from $DOWNLOAD_URL"
|
||||
fi
|
||||
# Move to final location
|
||||
BINARY_PATH="$INSTALL_DIR/onebox"
|
||||
mv "$TEMP_FILE" "$BINARY_PATH"
|
||||
|
||||
# Verify download
|
||||
if [ ! -f "$TMP_FILE" ] || [ ! -s "$TMP_FILE" ]; then
|
||||
error "Downloaded file is empty or missing"
|
||||
fi
|
||||
if [ $? -ne 0 ] || [ ! -f "$BINARY_PATH" ]; then
|
||||
echo "Error: Failed to move binary to $BINARY_PATH"
|
||||
rm -f "$TEMP_FILE" 2>/dev/null
|
||||
exit 1
|
||||
fi
|
||||
|
||||
info "✓ Download complete"
|
||||
}
|
||||
# Make executable
|
||||
chmod +x "$BINARY_PATH"
|
||||
|
||||
# Install binary
|
||||
install_binary() {
|
||||
info "Installing Onebox to ${INSTALL_DIR}..."
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Error: Failed to make binary executable"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Create install directory
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
echo "Binary installed successfully to: $BINARY_PATH"
|
||||
echo ""
|
||||
|
||||
# Copy binary
|
||||
cp "$TMP_FILE" "${INSTALL_DIR}/onebox"
|
||||
chmod +x "${INSTALL_DIR}/onebox"
|
||||
# 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
|
||||
ln -sf "${INSTALL_DIR}/onebox" "$BIN_LINK"
|
||||
# Create symlink for global access
|
||||
ln -sf "$BINARY_PATH" "$BIN_DIR/onebox"
|
||||
echo "Symlink created: $BIN_DIR/onebox -> $BINARY_PATH"
|
||||
echo ""
|
||||
|
||||
# Cleanup temp files
|
||||
rm -rf "$TMP_DIR"
|
||||
# Create data directories
|
||||
mkdir -p /var/lib/onebox
|
||||
mkdir -p /var/www/certbot
|
||||
|
||||
info "✓ Installation 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
|
||||
|
||||
# Initialize database and config
|
||||
initialize() {
|
||||
info "Initializing Onebox..."
|
||||
echo "================================================"
|
||||
echo " Onebox Installation Complete!"
|
||||
echo "================================================"
|
||||
echo ""
|
||||
echo "Installation details:"
|
||||
echo " Binary location: $BINARY_PATH"
|
||||
echo " Symlink location: $BIN_DIR/onebox"
|
||||
echo " Version: $VERSION"
|
||||
echo ""
|
||||
|
||||
# Create data directory
|
||||
mkdir -p /var/lib/onebox
|
||||
|
||||
# Create certbot directory for ACME challenges
|
||||
mkdir -p /var/www/certbot
|
||||
|
||||
info "✓ Initialization complete"
|
||||
}
|
||||
|
||||
# Print success message
|
||||
print_success() {
|
||||
echo ""
|
||||
info "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
info " Onebox installed successfully!"
|
||||
info "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo ""
|
||||
echo "1. Configure Cloudflare (optional):"
|
||||
echo " onebox config set cloudflareAPIKey <key>"
|
||||
echo " onebox config set cloudflareEmail <email>"
|
||||
echo " onebox config set cloudflareZoneID <zone-id>"
|
||||
echo " onebox config set serverIP <your-server-ip>"
|
||||
echo ""
|
||||
echo "2. Configure ACME email:"
|
||||
echo " onebox config set acmeEmail <your@email.com>"
|
||||
echo ""
|
||||
echo "3. Install daemon:"
|
||||
echo " onebox daemon install"
|
||||
echo ""
|
||||
echo "4. Start daemon:"
|
||||
echo " onebox daemon start"
|
||||
echo ""
|
||||
echo "5. Deploy your first service:"
|
||||
echo " onebox service add myapp --image nginx:latest --domain app.example.com"
|
||||
echo ""
|
||||
echo "Web UI: http://localhost:3000"
|
||||
echo "Default credentials: admin / admin"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# Main installation flow
|
||||
main() {
|
||||
info "Onebox Installer"
|
||||
echo ""
|
||||
|
||||
check_root
|
||||
detect_platform
|
||||
get_latest_version
|
||||
download_binary
|
||||
install_binary
|
||||
initialize
|
||||
print_success
|
||||
}
|
||||
|
||||
# Run main function
|
||||
main
|
||||
# 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 " 1. Configure Cloudflare (optional):"
|
||||
echo " onebox config set cloudflareAPIKey <key>"
|
||||
echo " onebox config set cloudflareEmail <email>"
|
||||
echo " onebox config set cloudflareZoneID <zone-id>"
|
||||
echo " onebox config set serverIP <your-server-ip>"
|
||||
echo ""
|
||||
echo " 2. Configure ACME email:"
|
||||
echo " onebox config set acmeEmail <your@email.com>"
|
||||
echo ""
|
||||
echo " 3. Enable systemd service:"
|
||||
echo " onebox systemd enable"
|
||||
echo ""
|
||||
echo " 4. Start service:"
|
||||
echo " onebox systemd start"
|
||||
echo ""
|
||||
echo " 5. Deploy your first service:"
|
||||
echo " onebox service add myapp --image nginx:latest --domain app.example.com"
|
||||
echo ""
|
||||
echo " Web UI: http://localhost:3000"
|
||||
echo " Default credentials: admin / admin"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
57
npmextra.json
Normal file
57
npmextra.json
Normal file
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"@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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
18
package.json
18
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@serve.zone/onebox",
|
||||
"version": "1.3.0",
|
||||
"version": "1.22.0",
|
||||
"description": "Self-hosted container platform with automatic SSL and DNS - a mini Heroku for single servers",
|
||||
"main": "mod.ts",
|
||||
"type": "module",
|
||||
@@ -9,7 +9,9 @@
|
||||
},
|
||||
"scripts": {
|
||||
"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": [
|
||||
"docker",
|
||||
@@ -51,8 +53,16 @@
|
||||
"arm64"
|
||||
],
|
||||
"packageManager": "pnpm@10.18.1+sha512.77a884a165cbba2d8d1c19e3b4880eee6d2fcabd0d879121e282196b80042351d5eb3ca0935fa599da1dc51265cc68816ad2bddd2a2de5ea9fdf92adbec7cd34",
|
||||
"dependencies": {},
|
||||
"dependencies": {
|
||||
"@api.global/typedrequest-interfaces": "^3.0.19",
|
||||
"@api.global/typedsocket": "^4.1.2",
|
||||
"@design.estate/dees-catalog": "^3.43.3",
|
||||
"@design.estate/dees-element": "^2.1.6",
|
||||
"@serve.zone/catalog": "^2.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.1.2"
|
||||
"@git.zone/tsbundle": "^2.9.0",
|
||||
"@git.zone/tsdeno": "^1.2.0",
|
||||
"@git.zone/tswatch": "^3.2.0"
|
||||
}
|
||||
}
|
||||
|
||||
5182
pnpm-lock.yaml
generated
5182
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
16
readme.md
16
readme.md
@@ -47,10 +47,11 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
# Download the latest release for your platform
|
||||
curl -sSL https://code.foss.global/serve.zone/onebox/releases/latest/download/onebox-linux-x64 -o onebox
|
||||
chmod +x onebox
|
||||
sudo mv onebox /usr/local/bin/
|
||||
# One-line install (recommended)
|
||||
curl -sSL https://code.foss.global/serve.zone/onebox/raw/branch/main/install.sh | sudo bash
|
||||
|
||||
# Install a specific version
|
||||
curl -sSL https://code.foss.global/serve.zone/onebox/raw/branch/main/install.sh | sudo bash -s -- --version v1.11.0
|
||||
|
||||
# Or install from npm
|
||||
pnpm install -g @serve.zone/onebox
|
||||
@@ -242,6 +243,13 @@ onebox config set cloudflareZoneID your-zone-id
|
||||
onebox status
|
||||
```
|
||||
|
||||
### Upgrade
|
||||
|
||||
```bash
|
||||
# Upgrade to the latest version (requires root)
|
||||
sudo onebox upgrade
|
||||
```
|
||||
|
||||
## Configuration 🔧
|
||||
|
||||
### System Requirements
|
||||
|
||||
@@ -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"
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/onebox',
|
||||
version: '1.3.0',
|
||||
version: '1.22.0',
|
||||
description: 'Self-hosted container platform with automatic SSL and DNS - a mini Heroku for single servers'
|
||||
}
|
||||
|
||||
1117
ts/classes/backup-manager.ts
Normal file
1117
ts/classes/backup-manager.ts
Normal file
File diff suppressed because it is too large
Load Diff
650
ts/classes/backup-scheduler.ts
Normal file
650
ts/classes/backup-scheduler.ts
Normal file
@@ -0,0 +1,650 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
// 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!);
|
||||
logger.info(`Deleted backup ${backup.filename} (retention policy)`);
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to delete old backup ${backup.filename}: ${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}`;
|
||||
}
|
||||
}
|
||||
@@ -79,8 +79,84 @@ export class CaddyLogReceiver {
|
||||
private recentLogs: ICaddyAccessLog[] = [];
|
||||
private maxRecentLogs = 100;
|
||||
|
||||
// Traffic stats aggregation (hourly rolling window)
|
||||
private trafficStats: {
|
||||
timestamp: number;
|
||||
requestCount: number;
|
||||
errorCount: number; // 4xx + 5xx
|
||||
totalDuration: number; // microseconds
|
||||
totalSize: number; // bytes
|
||||
statusCounts: Record<string, number>; // "2xx", "3xx", "4xx", "5xx"
|
||||
}[] = [];
|
||||
private maxStatsAge = 3600 * 1000; // 1 hour in ms
|
||||
private statsInterval = 60 * 1000; // 1 minute buckets
|
||||
|
||||
constructor(port = 9999) {
|
||||
this.port = port;
|
||||
// Initialize first stats bucket
|
||||
this.trafficStats.push(this.createStatsBucket());
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new stats bucket
|
||||
*/
|
||||
private createStatsBucket(): typeof this.trafficStats[0] {
|
||||
return {
|
||||
timestamp: Math.floor(Date.now() / this.statsInterval) * this.statsInterval,
|
||||
requestCount: 0,
|
||||
errorCount: 0,
|
||||
totalDuration: 0,
|
||||
totalSize: 0,
|
||||
statusCounts: { '2xx': 0, '3xx': 0, '4xx': 0, '5xx': 0 },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current stats bucket, creating new one if needed
|
||||
*/
|
||||
private getCurrentStatsBucket(): typeof this.trafficStats[0] {
|
||||
const now = Date.now();
|
||||
const currentBucketTime = Math.floor(now / this.statsInterval) * this.statsInterval;
|
||||
|
||||
// Get or create current bucket
|
||||
let bucket = this.trafficStats[this.trafficStats.length - 1];
|
||||
if (!bucket || bucket.timestamp !== currentBucketTime) {
|
||||
bucket = this.createStatsBucket();
|
||||
this.trafficStats.push(bucket);
|
||||
|
||||
// Clean up old buckets
|
||||
const cutoff = now - this.maxStatsAge;
|
||||
while (this.trafficStats.length > 0 && this.trafficStats[0].timestamp < cutoff) {
|
||||
this.trafficStats.shift();
|
||||
}
|
||||
}
|
||||
|
||||
return bucket;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a request in traffic stats
|
||||
*/
|
||||
private recordTrafficStats(log: ICaddyAccessLog): void {
|
||||
const bucket = this.getCurrentStatsBucket();
|
||||
|
||||
bucket.requestCount++;
|
||||
bucket.totalDuration += log.duration;
|
||||
bucket.totalSize += log.size || 0;
|
||||
|
||||
// Categorize status code
|
||||
const statusCategory = Math.floor(log.status / 100);
|
||||
if (statusCategory === 2) {
|
||||
bucket.statusCounts['2xx']++;
|
||||
} else if (statusCategory === 3) {
|
||||
bucket.statusCounts['3xx']++;
|
||||
} else if (statusCategory === 4) {
|
||||
bucket.statusCounts['4xx']++;
|
||||
bucket.errorCount++;
|
||||
} else if (statusCategory === 5) {
|
||||
bucket.statusCounts['5xx']++;
|
||||
bucket.errorCount++;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -181,6 +257,9 @@ export class CaddyLogReceiver {
|
||||
return;
|
||||
}
|
||||
|
||||
// Always record traffic stats (before sampling) for accurate aggregation
|
||||
this.recordTrafficStats(log);
|
||||
|
||||
// Update adaptive sampling
|
||||
this.updateSampling();
|
||||
|
||||
@@ -414,4 +493,57 @@ export class CaddyLogReceiver {
|
||||
recentLogsCount: this.recentLogs.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get aggregated traffic stats for the specified time range
|
||||
* @param minutes Number of minutes to aggregate (default: 60)
|
||||
*/
|
||||
getTrafficStats(minutes = 60): {
|
||||
requestCount: number;
|
||||
errorCount: number;
|
||||
avgResponseTime: number; // in milliseconds
|
||||
totalBytes: number;
|
||||
statusCounts: Record<string, number>;
|
||||
requestsPerMinute: number;
|
||||
errorRate: number; // percentage
|
||||
} {
|
||||
const now = Date.now();
|
||||
const cutoff = now - (minutes * 60 * 1000);
|
||||
|
||||
// Aggregate all buckets within the time range
|
||||
let requestCount = 0;
|
||||
let errorCount = 0;
|
||||
let totalDuration = 0;
|
||||
let totalBytes = 0;
|
||||
const statusCounts: Record<string, number> = { '2xx': 0, '3xx': 0, '4xx': 0, '5xx': 0 };
|
||||
|
||||
for (const bucket of this.trafficStats) {
|
||||
if (bucket.timestamp >= cutoff) {
|
||||
requestCount += bucket.requestCount;
|
||||
errorCount += bucket.errorCount;
|
||||
totalDuration += bucket.totalDuration;
|
||||
totalBytes += bucket.totalSize;
|
||||
for (const [status, count] of Object.entries(bucket.statusCounts)) {
|
||||
statusCounts[status] = (statusCounts[status] || 0) + count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate averages
|
||||
const avgResponseTime = requestCount > 0
|
||||
? (totalDuration / requestCount) / 1000 // Convert from microseconds to milliseconds
|
||||
: 0;
|
||||
const requestsPerMinute = requestCount / Math.max(minutes, 1);
|
||||
const errorRate = requestCount > 0 ? (errorCount / requestCount) * 100 : 0;
|
||||
|
||||
return {
|
||||
requestCount,
|
||||
errorCount,
|
||||
avgResponseTime: Math.round(avgResponseTime * 100) / 100, // Round to 2 decimal places
|
||||
totalBytes,
|
||||
statusCounts,
|
||||
requestsPerMinute: Math.round(requestsPerMinute * 100) / 100,
|
||||
errorRate: Math.round(errorRate * 100) / 100,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,7 @@
|
||||
* Handles background monitoring, metrics collection, and automatic tasks
|
||||
*/
|
||||
|
||||
import * as plugins from '../plugins.ts';
|
||||
import { logger } from '../logging.ts';
|
||||
import { projectInfo } from '../info.ts';
|
||||
import { getErrorMessage } from '../utils/error.ts';
|
||||
import type { Onebox } from './onebox.ts';
|
||||
|
||||
@@ -18,7 +16,6 @@ const FALLBACK_PID_FILE = `${FALLBACK_PID_DIR}/onebox.pid`;
|
||||
|
||||
export class OneboxDaemon {
|
||||
private oneboxRef: Onebox;
|
||||
private smartdaemon: plugins.smartdaemon.SmartDaemon | null = null;
|
||||
private running = false;
|
||||
private monitoringInterval: 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)
|
||||
*/
|
||||
@@ -131,9 +66,9 @@ export class OneboxDaemon {
|
||||
// Start monitoring loop
|
||||
this.startMonitoring();
|
||||
|
||||
// Start HTTP server
|
||||
// Start OpsServer (serves new UI + TypedRequest API)
|
||||
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.info(`Web UI available at http://localhost:${httpPort}`);
|
||||
@@ -163,8 +98,8 @@ export class OneboxDaemon {
|
||||
// Stop monitoring
|
||||
this.stopMonitoring();
|
||||
|
||||
// Stop HTTP server
|
||||
await this.oneboxRef.httpServer.stop();
|
||||
// Stop OpsServer
|
||||
await this.oneboxRef.opsServer.stop();
|
||||
|
||||
// Remove PID file
|
||||
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> {
|
||||
try {
|
||||
const services = this.oneboxRef.services.listServices();
|
||||
const runningServices = services.filter(s => s.status === 'running' && s.containerID);
|
||||
|
||||
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)}`);
|
||||
}
|
||||
// Stats broadcasting via WebSocket is not yet implemented in OpsServer.
|
||||
// Metrics are still collected and stored in the DB by collectMetrics().
|
||||
// The new UI fetches stats via TypedRequests on demand.
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -501,36 +417,7 @@ export class OneboxDaemon {
|
||||
static async ensureNoDaemon(): Promise<void> {
|
||||
const running = await OneboxDaemon.isDaemonRunning();
|
||||
if (running) {
|
||||
throw new Error('Daemon is already running. Please stop it first with: onebox daemon 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';
|
||||
throw new Error('Daemon is already running. Please stop it first with: onebox systemd stop');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -596,18 +596,26 @@ export class OneboxDockerManager {
|
||||
async getContainerStats(containerID: string): Promise<IContainerStats | null> {
|
||||
try {
|
||||
// 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 (!container) {
|
||||
const serviceContainerId = await this.getContainerIdForService(containerID);
|
||||
if (serviceContainerId) {
|
||||
container = await this.dockerClient!.getContainerById(serviceContainerId);
|
||||
try {
|
||||
container = await this.dockerClient!.getContainerById(serviceContainerId);
|
||||
} catch {
|
||||
// Service container also not found
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!container) {
|
||||
// Container/service not found
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -849,7 +857,23 @@ export class OneboxDockerManager {
|
||||
cmd: string[]
|
||||
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
||||
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) {
|
||||
throw new Error(`Container not found: ${containerID}`);
|
||||
@@ -881,12 +905,12 @@ export class OneboxDockerManager {
|
||||
]);
|
||||
|
||||
const execInfo = await inspect();
|
||||
const exitCode = execInfo.ExitCode || 0;
|
||||
const exitCode = execInfo.ExitCode ?? -1;
|
||||
|
||||
return { stdout, stderr, exitCode };
|
||||
} catch (error) {
|
||||
logger.error(`Failed to exec in container ${containerID}: ${getErrorMessage(error)}`);
|
||||
throw error;
|
||||
return { stdout: '', stderr: getErrorMessage(error), exitCode: -1 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1011,7 +1035,23 @@ export class OneboxDockerManager {
|
||||
callback: (line: string, isError: boolean) => void
|
||||
): Promise<void> {
|
||||
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) {
|
||||
throw new Error(`Container not found: ${containerID}`);
|
||||
|
||||
@@ -8,7 +8,15 @@ import * as plugins from '../plugins.ts';
|
||||
import { logger } from '../logging.ts';
|
||||
import { getErrorMessage } from '../utils/error.ts';
|
||||
import type { Onebox } from './onebox.ts';
|
||||
import type { IApiResponse, ICreateRegistryTokenRequest, IRegistryTokenView, TPlatformServiceType, IContainerStats } from '../types.ts';
|
||||
import type {
|
||||
IApiResponse,
|
||||
ICreateRegistryTokenRequest,
|
||||
IRegistryTokenView,
|
||||
TPlatformServiceType,
|
||||
IContainerStats,
|
||||
IBackupScheduleCreate,
|
||||
IBackupScheduleUpdate,
|
||||
} from '../types.ts';
|
||||
|
||||
export class OneboxHttpServer {
|
||||
private oneboxRef: Onebox;
|
||||
@@ -297,16 +305,16 @@ export class OneboxHttpServer {
|
||||
// Platform Services endpoints
|
||||
} else if (path === '/api/platform-services' && method === 'GET') {
|
||||
return await this.handleListPlatformServicesRequest();
|
||||
} else if (path.match(/^\/api\/platform-services\/(mongodb|minio|redis|postgresql|rabbitmq|caddy)$/) && method === 'GET') {
|
||||
} else if (path.match(/^\/api\/platform-services\/(mongodb|minio|redis|postgresql|rabbitmq|caddy|clickhouse)$/) && method === 'GET') {
|
||||
const type = path.split('/').pop()! as TPlatformServiceType;
|
||||
return await this.handleGetPlatformServiceRequest(type);
|
||||
} else if (path.match(/^\/api\/platform-services\/(mongodb|minio|redis|postgresql|rabbitmq|caddy)\/start$/) && method === 'POST') {
|
||||
} else if (path.match(/^\/api\/platform-services\/(mongodb|minio|redis|postgresql|rabbitmq|caddy|clickhouse)\/start$/) && method === 'POST') {
|
||||
const type = path.split('/')[3] as TPlatformServiceType;
|
||||
return await this.handleStartPlatformServiceRequest(type);
|
||||
} else if (path.match(/^\/api\/platform-services\/(mongodb|minio|redis|postgresql|rabbitmq|caddy)\/stop$/) && method === 'POST') {
|
||||
} else if (path.match(/^\/api\/platform-services\/(mongodb|minio|redis|postgresql|rabbitmq|caddy|clickhouse)\/stop$/) && method === 'POST') {
|
||||
const type = path.split('/')[3] as TPlatformServiceType;
|
||||
return await this.handleStopPlatformServiceRequest(type);
|
||||
} else if (path.match(/^\/api\/platform-services\/(mongodb|minio|redis|postgresql|rabbitmq|caddy)\/stats$/) && method === 'GET') {
|
||||
} else if (path.match(/^\/api\/platform-services\/(mongodb|minio|redis|postgresql|rabbitmq|caddy|clickhouse)\/stats$/) && method === 'GET') {
|
||||
const type = path.split('/')[3] as TPlatformServiceType;
|
||||
return await this.handleGetPlatformServiceStatsRequest(type);
|
||||
} else if (path.match(/^\/api\/services\/[^/]+\/platform-resources$/) && method === 'GET') {
|
||||
@@ -317,6 +325,54 @@ export class OneboxHttpServer {
|
||||
return await this.handleGetNetworkTargetsRequest();
|
||||
} else if (path === '/api/network/stats' && method === 'GET') {
|
||||
return await this.handleGetNetworkStatsRequest();
|
||||
} else if (path === '/api/network/traffic-stats' && method === 'GET') {
|
||||
return await this.handleGetTrafficStatsRequest(new URL(req.url));
|
||||
// Backup endpoints
|
||||
} else if (path === '/api/backups' && method === 'GET') {
|
||||
return await this.handleListBackupsRequest();
|
||||
} else if (path.match(/^\/api\/services\/[^/]+\/backups$/) && method === 'GET') {
|
||||
const serviceName = path.split('/')[3];
|
||||
return await this.handleListServiceBackupsRequest(serviceName);
|
||||
} else if (path.match(/^\/api\/services\/[^/]+\/backup$/) && method === 'POST') {
|
||||
const serviceName = path.split('/')[3];
|
||||
return await this.handleCreateBackupRequest(serviceName);
|
||||
} else if (path.match(/^\/api\/backups\/\d+$/) && method === 'GET') {
|
||||
const backupId = Number(path.split('/').pop());
|
||||
return await this.handleGetBackupRequest(backupId);
|
||||
} else if (path.match(/^\/api\/backups\/\d+\/download$/) && method === 'GET') {
|
||||
const backupId = Number(path.split('/')[3]);
|
||||
return await this.handleDownloadBackupRequest(backupId);
|
||||
} else if (path.match(/^\/api\/backups\/\d+$/) && method === 'DELETE') {
|
||||
const backupId = Number(path.split('/').pop());
|
||||
return await this.handleDeleteBackupRequest(backupId);
|
||||
} else if (path === '/api/backups/restore' && method === 'POST') {
|
||||
return await this.handleRestoreBackupRequest(req);
|
||||
} else if (path === '/api/backups/import' && method === 'POST') {
|
||||
return await this.handleImportBackupRequest(req);
|
||||
} else if (path === '/api/settings/backup-password' && method === 'POST') {
|
||||
return await this.handleSetBackupPasswordRequest(req);
|
||||
} else if (path === '/api/settings/backup-password' && method === 'GET') {
|
||||
return await this.handleCheckBackupPasswordRequest();
|
||||
// Backup Schedule endpoints
|
||||
} else if (path === '/api/backup-schedules' && method === 'GET') {
|
||||
return await this.handleListBackupSchedulesRequest();
|
||||
} else if (path === '/api/backup-schedules' && method === 'POST') {
|
||||
return await this.handleCreateBackupScheduleRequest(req);
|
||||
} else if (path.match(/^\/api\/backup-schedules\/\d+$/) && method === 'GET') {
|
||||
const scheduleId = Number(path.split('/').pop());
|
||||
return await this.handleGetBackupScheduleRequest(scheduleId);
|
||||
} else if (path.match(/^\/api\/backup-schedules\/\d+$/) && method === 'PUT') {
|
||||
const scheduleId = Number(path.split('/').pop());
|
||||
return await this.handleUpdateBackupScheduleRequest(scheduleId, req);
|
||||
} else if (path.match(/^\/api\/backup-schedules\/\d+$/) && method === 'DELETE') {
|
||||
const scheduleId = Number(path.split('/').pop());
|
||||
return await this.handleDeleteBackupScheduleRequest(scheduleId);
|
||||
} else if (path.match(/^\/api\/backup-schedules\/\d+\/trigger$/) && method === 'POST') {
|
||||
const scheduleId = Number(path.split('/')[3]);
|
||||
return await this.handleTriggerBackupScheduleRequest(scheduleId);
|
||||
} else if (path.match(/^\/api\/services\/[^/]+\/backup-schedules$/) && method === 'GET') {
|
||||
const serviceName = path.split('/')[3];
|
||||
return await this.handleListServiceBackupSchedulesRequest(serviceName);
|
||||
} else {
|
||||
return this.jsonResponse({ success: false, error: 'Not found' }, 404);
|
||||
}
|
||||
@@ -1365,6 +1421,37 @@ export class OneboxHttpServer {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get traffic stats from Caddy access logs
|
||||
*/
|
||||
private async handleGetTrafficStatsRequest(url: URL): Promise<Response> {
|
||||
try {
|
||||
// Get minutes parameter (default: 60)
|
||||
const minutesParam = url.searchParams.get('minutes');
|
||||
const minutes = minutesParam ? parseInt(minutesParam, 10) : 60;
|
||||
|
||||
if (isNaN(minutes) || minutes < 1 || minutes > 60) {
|
||||
return this.jsonResponse({
|
||||
success: false,
|
||||
error: 'Invalid minutes parameter. Must be between 1 and 60.',
|
||||
}, 400);
|
||||
}
|
||||
|
||||
const trafficStats = this.oneboxRef.caddyLogReceiver.getTrafficStats(minutes);
|
||||
|
||||
return this.jsonResponse({
|
||||
success: true,
|
||||
data: trafficStats,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`Failed to get traffic stats: ${getErrorMessage(error)}`);
|
||||
return this.jsonResponse({
|
||||
success: false,
|
||||
error: getErrorMessage(error) || 'Failed to get traffic stats',
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast message to all connected WebSocket clients
|
||||
*/
|
||||
@@ -1984,6 +2071,626 @@ export class OneboxHttpServer {
|
||||
}
|
||||
}
|
||||
|
||||
// ============ Backup Endpoints ============
|
||||
|
||||
/**
|
||||
* List all backups
|
||||
*/
|
||||
private async handleListBackupsRequest(): Promise<Response> {
|
||||
try {
|
||||
const backups = this.oneboxRef.backupManager.listBackups();
|
||||
return this.jsonResponse({ success: true, data: backups });
|
||||
} catch (error) {
|
||||
logger.error(`Failed to list backups: ${getErrorMessage(error)}`);
|
||||
return this.jsonResponse({
|
||||
success: false,
|
||||
error: getErrorMessage(error) || 'Failed to list backups',
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List backups for a specific service
|
||||
*/
|
||||
private async handleListServiceBackupsRequest(serviceName: string): Promise<Response> {
|
||||
try {
|
||||
const service = this.oneboxRef.services.getService(serviceName);
|
||||
if (!service) {
|
||||
return this.jsonResponse({ success: false, error: 'Service not found' }, 404);
|
||||
}
|
||||
|
||||
const backups = this.oneboxRef.backupManager.listBackups(serviceName);
|
||||
return this.jsonResponse({ success: true, data: backups });
|
||||
} catch (error) {
|
||||
logger.error(`Failed to list backups for service ${serviceName}: ${getErrorMessage(error)}`);
|
||||
return this.jsonResponse({
|
||||
success: false,
|
||||
error: getErrorMessage(error) || 'Failed to list backups',
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a backup for a service
|
||||
*/
|
||||
private async handleCreateBackupRequest(serviceName: string): Promise<Response> {
|
||||
try {
|
||||
const service = this.oneboxRef.services.getService(serviceName);
|
||||
if (!service) {
|
||||
return this.jsonResponse({ success: false, error: 'Service not found' }, 404);
|
||||
}
|
||||
|
||||
const result = await this.oneboxRef.backupManager.createBackup(serviceName);
|
||||
|
||||
return this.jsonResponse({
|
||||
success: true,
|
||||
message: `Backup created for service ${serviceName}`,
|
||||
data: result.backup,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`Failed to create backup for service ${serviceName}: ${getErrorMessage(error)}`);
|
||||
return this.jsonResponse({
|
||||
success: false,
|
||||
error: getErrorMessage(error) || 'Failed to create backup',
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific backup by ID
|
||||
*/
|
||||
private async handleGetBackupRequest(backupId: number): Promise<Response> {
|
||||
try {
|
||||
const backup = this.oneboxRef.database.getBackupById(backupId);
|
||||
if (!backup) {
|
||||
return this.jsonResponse({ success: false, error: 'Backup not found' }, 404);
|
||||
}
|
||||
|
||||
return this.jsonResponse({ success: true, data: backup });
|
||||
} catch (error) {
|
||||
logger.error(`Failed to get backup ${backupId}: ${getErrorMessage(error)}`);
|
||||
return this.jsonResponse({
|
||||
success: false,
|
||||
error: getErrorMessage(error) || 'Failed to get backup',
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a backup file
|
||||
*/
|
||||
private async handleDownloadBackupRequest(backupId: number): Promise<Response> {
|
||||
try {
|
||||
const filePath = this.oneboxRef.backupManager.getBackupFilePath(backupId);
|
||||
if (!filePath) {
|
||||
return this.jsonResponse({ success: false, error: 'Backup not found' }, 404);
|
||||
}
|
||||
|
||||
// Check if file exists
|
||||
try {
|
||||
await Deno.stat(filePath);
|
||||
} catch {
|
||||
return this.jsonResponse({ success: false, error: 'Backup file not found on disk' }, 404);
|
||||
}
|
||||
|
||||
// Read file and return as download
|
||||
const backup = this.oneboxRef.database.getBackupById(backupId);
|
||||
const file = await Deno.readFile(filePath);
|
||||
|
||||
return new Response(file, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'Content-Disposition': `attachment; filename="${backup?.filename || 'backup.tar.enc'}"`,
|
||||
'Content-Length': String(file.length),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`Failed to download backup ${backupId}: ${getErrorMessage(error)}`);
|
||||
return this.jsonResponse({
|
||||
success: false,
|
||||
error: getErrorMessage(error) || 'Failed to download backup',
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a backup
|
||||
*/
|
||||
private async handleDeleteBackupRequest(backupId: number): Promise<Response> {
|
||||
try {
|
||||
const backup = this.oneboxRef.database.getBackupById(backupId);
|
||||
if (!backup) {
|
||||
return this.jsonResponse({ success: false, error: 'Backup not found' }, 404);
|
||||
}
|
||||
|
||||
await this.oneboxRef.backupManager.deleteBackup(backupId);
|
||||
|
||||
return this.jsonResponse({
|
||||
success: true,
|
||||
message: 'Backup deleted successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`Failed to delete backup ${backupId}: ${getErrorMessage(error)}`);
|
||||
return this.jsonResponse({
|
||||
success: false,
|
||||
error: getErrorMessage(error) || 'Failed to delete backup',
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore a backup
|
||||
*/
|
||||
private async handleRestoreBackupRequest(req: Request): Promise<Response> {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const { backupId, mode, newServiceName, overwriteExisting, skipPlatformData } = body;
|
||||
|
||||
if (!backupId) {
|
||||
return this.jsonResponse({
|
||||
success: false,
|
||||
error: 'Backup ID is required',
|
||||
}, 400);
|
||||
}
|
||||
|
||||
if (!mode || !['restore', 'import', 'clone'].includes(mode)) {
|
||||
return this.jsonResponse({
|
||||
success: false,
|
||||
error: 'Valid mode required: restore, import, or clone',
|
||||
}, 400);
|
||||
}
|
||||
|
||||
// Get backup file path
|
||||
const filePath = this.oneboxRef.backupManager.getBackupFilePath(backupId);
|
||||
if (!filePath) {
|
||||
return this.jsonResponse({ success: false, error: 'Backup not found' }, 404);
|
||||
}
|
||||
|
||||
// Validate mode-specific requirements
|
||||
if ((mode === 'import' || mode === 'clone') && !newServiceName) {
|
||||
return this.jsonResponse({
|
||||
success: false,
|
||||
error: `New service name required for '${mode}' mode`,
|
||||
}, 400);
|
||||
}
|
||||
|
||||
const result = await this.oneboxRef.backupManager.restoreBackup(filePath, {
|
||||
mode,
|
||||
newServiceName,
|
||||
overwriteExisting: overwriteExisting === true,
|
||||
skipPlatformData: skipPlatformData === true,
|
||||
});
|
||||
|
||||
return this.jsonResponse({
|
||||
success: true,
|
||||
message: `Backup restored successfully as service '${result.service.name}'`,
|
||||
data: {
|
||||
service: result.service,
|
||||
platformResourcesRestored: result.platformResourcesRestored,
|
||||
warnings: result.warnings,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`Failed to restore backup: ${getErrorMessage(error)}`);
|
||||
return this.jsonResponse({
|
||||
success: false,
|
||||
error: getErrorMessage(error) || 'Failed to restore backup',
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Import a backup from file upload or URL
|
||||
*/
|
||||
private async handleImportBackupRequest(req: Request): Promise<Response> {
|
||||
try {
|
||||
const contentType = req.headers.get('content-type') || '';
|
||||
let filePath: string | null = null;
|
||||
let newServiceName: string | undefined;
|
||||
let tempFile = false;
|
||||
|
||||
if (contentType.includes('multipart/form-data')) {
|
||||
// Handle file upload
|
||||
const formData = await req.formData();
|
||||
const file = formData.get('file');
|
||||
newServiceName = formData.get('newServiceName')?.toString() || undefined;
|
||||
|
||||
if (!file || !(file instanceof File)) {
|
||||
return this.jsonResponse({
|
||||
success: false,
|
||||
error: 'No file provided',
|
||||
}, 400);
|
||||
}
|
||||
|
||||
// Validate file extension
|
||||
if (!file.name.endsWith('.tar.enc')) {
|
||||
return this.jsonResponse({
|
||||
success: false,
|
||||
error: 'Invalid file format. Expected .tar.enc file',
|
||||
}, 400);
|
||||
}
|
||||
|
||||
// Save to temp location
|
||||
const tempDir = './.nogit/temp-imports';
|
||||
await Deno.mkdir(tempDir, { recursive: true });
|
||||
filePath = `${tempDir}/${Date.now()}-${file.name}`;
|
||||
tempFile = true;
|
||||
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
await Deno.writeFile(filePath, new Uint8Array(arrayBuffer));
|
||||
|
||||
logger.info(`Saved uploaded backup to ${filePath}`);
|
||||
} else {
|
||||
// Handle JSON body with URL
|
||||
const body = await req.json();
|
||||
const { url, newServiceName: serviceName } = body;
|
||||
newServiceName = serviceName;
|
||||
|
||||
if (!url) {
|
||||
return this.jsonResponse({
|
||||
success: false,
|
||||
error: 'URL is required when not uploading a file',
|
||||
}, 400);
|
||||
}
|
||||
|
||||
// Download from URL
|
||||
const tempDir = './.nogit/temp-imports';
|
||||
await Deno.mkdir(tempDir, { recursive: true });
|
||||
|
||||
const urlFilename = url.split('/').pop() || 'backup.tar.enc';
|
||||
filePath = `${tempDir}/${Date.now()}-${urlFilename}`;
|
||||
tempFile = true;
|
||||
|
||||
logger.info(`Downloading backup from ${url}...`);
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
return this.jsonResponse({
|
||||
success: false,
|
||||
error: `Failed to download from URL: ${response.statusText}`,
|
||||
}, 400);
|
||||
}
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
await Deno.writeFile(filePath, new Uint8Array(arrayBuffer));
|
||||
|
||||
logger.info(`Downloaded backup to ${filePath}`);
|
||||
}
|
||||
|
||||
// Import using restoreBackup with mode='import'
|
||||
const result = await this.oneboxRef.backupManager.restoreBackup(filePath, {
|
||||
mode: 'import',
|
||||
newServiceName,
|
||||
overwriteExisting: false,
|
||||
skipPlatformData: false,
|
||||
});
|
||||
|
||||
// Clean up temp file
|
||||
if (tempFile && filePath) {
|
||||
try {
|
||||
await Deno.remove(filePath);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
|
||||
return this.jsonResponse({
|
||||
success: true,
|
||||
message: `Backup imported successfully as service '${result.service.name}'`,
|
||||
data: {
|
||||
service: result.service,
|
||||
platformResourcesRestored: result.platformResourcesRestored,
|
||||
warnings: result.warnings,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`Failed to import backup: ${getErrorMessage(error)}`);
|
||||
return this.jsonResponse({
|
||||
success: false,
|
||||
error: getErrorMessage(error) || 'Failed to import backup',
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set backup encryption password
|
||||
*/
|
||||
private async handleSetBackupPasswordRequest(req: Request): Promise<Response> {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const { password } = body;
|
||||
|
||||
if (!password || typeof password !== 'string') {
|
||||
return this.jsonResponse({
|
||||
success: false,
|
||||
error: 'Password is required',
|
||||
}, 400);
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
return this.jsonResponse({
|
||||
success: false,
|
||||
error: 'Password must be at least 8 characters',
|
||||
}, 400);
|
||||
}
|
||||
|
||||
// Store password in settings
|
||||
this.oneboxRef.database.setSetting('backup_encryption_password', password);
|
||||
|
||||
return this.jsonResponse({
|
||||
success: true,
|
||||
message: 'Backup password set successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`Failed to set backup password: ${getErrorMessage(error)}`);
|
||||
return this.jsonResponse({
|
||||
success: false,
|
||||
error: getErrorMessage(error) || 'Failed to set backup password',
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if backup password is configured
|
||||
*/
|
||||
private async handleCheckBackupPasswordRequest(): Promise<Response> {
|
||||
try {
|
||||
const password = this.oneboxRef.database.getSetting('backup_encryption_password');
|
||||
const isConfigured = password !== null && password.length > 0;
|
||||
|
||||
return this.jsonResponse({
|
||||
success: true,
|
||||
data: {
|
||||
isConfigured,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`Failed to check backup password: ${getErrorMessage(error)}`);
|
||||
return this.jsonResponse({
|
||||
success: false,
|
||||
error: getErrorMessage(error) || 'Failed to check backup password',
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
// ============ Backup Schedule Endpoints ============
|
||||
|
||||
/**
|
||||
* List all backup schedules
|
||||
*/
|
||||
private async handleListBackupSchedulesRequest(): Promise<Response> {
|
||||
try {
|
||||
const schedules = this.oneboxRef.backupScheduler.getAllSchedules();
|
||||
return this.jsonResponse({ success: true, data: schedules });
|
||||
} catch (error) {
|
||||
logger.error(`Failed to list backup schedules: ${getErrorMessage(error)}`);
|
||||
return this.jsonResponse({
|
||||
success: false,
|
||||
error: getErrorMessage(error) || 'Failed to list backup schedules',
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new backup schedule
|
||||
*/
|
||||
private async handleCreateBackupScheduleRequest(req: Request): Promise<Response> {
|
||||
try {
|
||||
const body = await req.json() as IBackupScheduleCreate;
|
||||
|
||||
// Validate scope type
|
||||
if (!body.scopeType) {
|
||||
return this.jsonResponse({
|
||||
success: false,
|
||||
error: 'Scope type is required (all, pattern, or service)',
|
||||
}, 400);
|
||||
}
|
||||
|
||||
if (!['all', 'pattern', 'service'].includes(body.scopeType)) {
|
||||
return this.jsonResponse({
|
||||
success: false,
|
||||
error: 'Invalid scope type. Must be: all, pattern, or service',
|
||||
}, 400);
|
||||
}
|
||||
|
||||
// Validate scope-specific requirements
|
||||
if (body.scopeType === 'service' && !body.serviceName) {
|
||||
return this.jsonResponse({
|
||||
success: false,
|
||||
error: 'Service name is required for service-specific schedules',
|
||||
}, 400);
|
||||
}
|
||||
|
||||
if (body.scopeType === 'pattern' && !body.scopePattern) {
|
||||
return this.jsonResponse({
|
||||
success: false,
|
||||
error: 'Scope pattern is required for pattern-based schedules',
|
||||
}, 400);
|
||||
}
|
||||
|
||||
if (!body.cronExpression) {
|
||||
return this.jsonResponse({
|
||||
success: false,
|
||||
error: 'Cron expression is required',
|
||||
}, 400);
|
||||
}
|
||||
|
||||
if (!body.retention) {
|
||||
return this.jsonResponse({
|
||||
success: false,
|
||||
error: 'Retention policy is required',
|
||||
}, 400);
|
||||
}
|
||||
|
||||
// Validate retention policy
|
||||
const { hourly, daily, weekly, monthly } = body.retention;
|
||||
if (typeof hourly !== 'number' || typeof daily !== 'number' ||
|
||||
typeof weekly !== 'number' || typeof monthly !== 'number') {
|
||||
return this.jsonResponse({
|
||||
success: false,
|
||||
error: 'Retention policy must have hourly, daily, weekly, and monthly as numbers',
|
||||
}, 400);
|
||||
}
|
||||
|
||||
if (hourly < 0 || daily < 0 || weekly < 0 || monthly < 0) {
|
||||
return this.jsonResponse({
|
||||
success: false,
|
||||
error: 'Retention values must be non-negative',
|
||||
}, 400);
|
||||
}
|
||||
|
||||
const schedule = await this.oneboxRef.backupScheduler.createSchedule(body);
|
||||
|
||||
// Build descriptive message based on scope type
|
||||
let scopeDesc: string;
|
||||
switch (body.scopeType) {
|
||||
case 'all':
|
||||
scopeDesc = 'all services';
|
||||
break;
|
||||
case 'pattern':
|
||||
scopeDesc = `pattern '${body.scopePattern}'`;
|
||||
break;
|
||||
case 'service':
|
||||
scopeDesc = `service '${body.serviceName}'`;
|
||||
break;
|
||||
}
|
||||
|
||||
return this.jsonResponse({
|
||||
success: true,
|
||||
message: `Backup schedule created for ${scopeDesc}`,
|
||||
data: schedule,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`Failed to create backup schedule: ${getErrorMessage(error)}`);
|
||||
return this.jsonResponse({
|
||||
success: false,
|
||||
error: getErrorMessage(error) || 'Failed to create backup schedule',
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific backup schedule
|
||||
*/
|
||||
private async handleGetBackupScheduleRequest(scheduleId: number): Promise<Response> {
|
||||
try {
|
||||
const schedule = this.oneboxRef.backupScheduler.getScheduleById(scheduleId);
|
||||
if (!schedule) {
|
||||
return this.jsonResponse({ success: false, error: 'Backup schedule not found' }, 404);
|
||||
}
|
||||
|
||||
return this.jsonResponse({ success: true, data: schedule });
|
||||
} catch (error) {
|
||||
logger.error(`Failed to get backup schedule ${scheduleId}: ${getErrorMessage(error)}`);
|
||||
return this.jsonResponse({
|
||||
success: false,
|
||||
error: getErrorMessage(error) || 'Failed to get backup schedule',
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a backup schedule
|
||||
*/
|
||||
private async handleUpdateBackupScheduleRequest(scheduleId: number, req: Request): Promise<Response> {
|
||||
try {
|
||||
const body = await req.json() as IBackupScheduleUpdate;
|
||||
|
||||
// Validate retention policy if provided
|
||||
if (body.retention) {
|
||||
const { hourly, daily, weekly, monthly } = body.retention;
|
||||
if (typeof hourly !== 'number' || typeof daily !== 'number' ||
|
||||
typeof weekly !== 'number' || typeof monthly !== 'number') {
|
||||
return this.jsonResponse({
|
||||
success: false,
|
||||
error: 'Retention policy must have hourly, daily, weekly, and monthly as numbers',
|
||||
}, 400);
|
||||
}
|
||||
if (hourly < 0 || daily < 0 || weekly < 0 || monthly < 0) {
|
||||
return this.jsonResponse({
|
||||
success: false,
|
||||
error: 'Retention values must be non-negative',
|
||||
}, 400);
|
||||
}
|
||||
}
|
||||
|
||||
const schedule = await this.oneboxRef.backupScheduler.updateSchedule(scheduleId, body);
|
||||
|
||||
return this.jsonResponse({
|
||||
success: true,
|
||||
message: 'Backup schedule updated',
|
||||
data: schedule,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`Failed to update backup schedule ${scheduleId}: ${getErrorMessage(error)}`);
|
||||
return this.jsonResponse({
|
||||
success: false,
|
||||
error: getErrorMessage(error) || 'Failed to update backup schedule',
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a backup schedule
|
||||
*/
|
||||
private async handleDeleteBackupScheduleRequest(scheduleId: number): Promise<Response> {
|
||||
try {
|
||||
await this.oneboxRef.backupScheduler.deleteSchedule(scheduleId);
|
||||
|
||||
return this.jsonResponse({
|
||||
success: true,
|
||||
message: 'Backup schedule deleted',
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`Failed to delete backup schedule ${scheduleId}: ${getErrorMessage(error)}`);
|
||||
return this.jsonResponse({
|
||||
success: false,
|
||||
error: getErrorMessage(error) || 'Failed to delete backup schedule',
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger immediate backup for a schedule
|
||||
*/
|
||||
private async handleTriggerBackupScheduleRequest(scheduleId: number): Promise<Response> {
|
||||
try {
|
||||
await this.oneboxRef.backupScheduler.triggerBackup(scheduleId);
|
||||
|
||||
return this.jsonResponse({
|
||||
success: true,
|
||||
message: 'Backup triggered successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`Failed to trigger backup for schedule ${scheduleId}: ${getErrorMessage(error)}`);
|
||||
return this.jsonResponse({
|
||||
success: false,
|
||||
error: getErrorMessage(error) || 'Failed to trigger backup',
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List backup schedules for a specific service
|
||||
*/
|
||||
private async handleListServiceBackupSchedulesRequest(serviceName: string): Promise<Response> {
|
||||
try {
|
||||
const service = this.oneboxRef.services.getService(serviceName);
|
||||
if (!service) {
|
||||
return this.jsonResponse({ success: false, error: 'Service not found' }, 404);
|
||||
}
|
||||
|
||||
const schedules = this.oneboxRef.backupScheduler.getSchedulesForService(serviceName);
|
||||
return this.jsonResponse({ success: true, data: schedules });
|
||||
} catch (error) {
|
||||
logger.error(`Failed to list backup schedules for service ${serviceName}: ${getErrorMessage(error)}`);
|
||||
return this.jsonResponse({
|
||||
success: false,
|
||||
error: getErrorMessage(error) || 'Failed to list backup schedules',
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to create JSON response
|
||||
*/
|
||||
|
||||
@@ -14,12 +14,16 @@ import { OneboxReverseProxy } from './reverseproxy.ts';
|
||||
import { OneboxDnsManager } from './dns.ts';
|
||||
import { OneboxSslManager } from './ssl.ts';
|
||||
import { OneboxDaemon } from './daemon.ts';
|
||||
import { OneboxSystemd } from './systemd.ts';
|
||||
import { OneboxHttpServer } from './httpserver.ts';
|
||||
import { CloudflareDomainSync } from './cloudflare-sync.ts';
|
||||
import { CertRequirementManager } from './cert-requirement-manager.ts';
|
||||
import { RegistryManager } from './registry.ts';
|
||||
import { PlatformServicesManager } from './platform-services/index.ts';
|
||||
import { CaddyLogReceiver } from './caddy-log-receiver.ts';
|
||||
import { BackupManager } from './backup-manager.ts';
|
||||
import { BackupScheduler } from './backup-scheduler.ts';
|
||||
import { OpsServer } from '../opsserver/index.ts';
|
||||
|
||||
export class Onebox {
|
||||
public database: OneboxDatabase;
|
||||
@@ -30,12 +34,16 @@ export class Onebox {
|
||||
public dns: OneboxDnsManager;
|
||||
public ssl: OneboxSslManager;
|
||||
public daemon: OneboxDaemon;
|
||||
public systemd: OneboxSystemd;
|
||||
public httpServer: OneboxHttpServer;
|
||||
public cloudflareDomainSync: CloudflareDomainSync;
|
||||
public certRequirementManager: CertRequirementManager;
|
||||
public registry: RegistryManager;
|
||||
public platformServices: PlatformServicesManager;
|
||||
public caddyLogReceiver: CaddyLogReceiver;
|
||||
public backupManager: BackupManager;
|
||||
public backupScheduler: BackupScheduler;
|
||||
public opsServer: OpsServer;
|
||||
|
||||
private initialized = false;
|
||||
|
||||
@@ -51,6 +59,7 @@ export class Onebox {
|
||||
this.dns = new OneboxDnsManager(this);
|
||||
this.ssl = new OneboxSslManager(this);
|
||||
this.daemon = new OneboxDaemon(this);
|
||||
this.systemd = new OneboxSystemd();
|
||||
this.httpServer = new OneboxHttpServer(this);
|
||||
this.registry = new RegistryManager({
|
||||
dataDir: './.nogit/registry-data',
|
||||
@@ -67,6 +76,15 @@ export class Onebox {
|
||||
|
||||
// Initialize Caddy log receiver
|
||||
this.caddyLogReceiver = new CaddyLogReceiver(9999);
|
||||
|
||||
// Initialize Backup manager
|
||||
this.backupManager = new BackupManager(this);
|
||||
|
||||
// Initialize Backup scheduler
|
||||
this.backupScheduler = new BackupScheduler(this);
|
||||
|
||||
// Initialize OpsServer (TypedRequest-based server)
|
||||
this.opsServer = new OpsServer(this);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -161,6 +179,14 @@ export class Onebox {
|
||||
// Start auto-update monitoring for registry services
|
||||
this.services.startAutoUpdateMonitoring();
|
||||
|
||||
// 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;
|
||||
logger.success('Onebox initialized successfully');
|
||||
} catch (error) {
|
||||
@@ -219,17 +245,111 @@ export class Onebox {
|
||||
const runningServices = services.filter((s) => s.status === 'running').length;
|
||||
const totalServices = services.length;
|
||||
|
||||
// Get platform services status
|
||||
// Get platform services status with resource counts
|
||||
const platformServices = this.platformServices.getAllPlatformServices();
|
||||
const platformServicesStatus = platformServices.map((ps) => ({
|
||||
type: ps.type,
|
||||
status: ps.status,
|
||||
}));
|
||||
const providers = this.platformServices.getAllProviders();
|
||||
const platformServicesStatus = providers.map((provider) => {
|
||||
const service = platformServices.find((s) => s.type === provider.type);
|
||||
// For Caddy, check actual runtime status since it starts without a DB record
|
||||
let status = service?.status || 'not-deployed';
|
||||
if (provider.type === 'caddy') {
|
||||
status = proxyStatus.http.running ? 'running' : 'stopped';
|
||||
}
|
||||
// Count resources for this platform service
|
||||
const resourceCount = service?.id
|
||||
? this.database.getPlatformResourcesByPlatformService(service.id).length
|
||||
: 0;
|
||||
return {
|
||||
type: provider.type,
|
||||
displayName: provider.displayName,
|
||||
status,
|
||||
resourceCount,
|
||||
};
|
||||
});
|
||||
|
||||
// Get certificate health summary
|
||||
const certificates = this.ssl.listCertificates();
|
||||
const now = Date.now();
|
||||
const thirtyDaysMs = 30 * 24 * 60 * 60 * 1000;
|
||||
let validCount = 0;
|
||||
let expiringCount = 0;
|
||||
let expiredCount = 0;
|
||||
const expiringDomains: { domain: string; daysRemaining: number }[] = [];
|
||||
|
||||
for (const cert of certificates) {
|
||||
if (cert.expiryDate <= now) {
|
||||
expiredCount++;
|
||||
} else if (cert.expiryDate <= now + thirtyDaysMs) {
|
||||
expiringCount++;
|
||||
const daysRemaining = Math.floor((cert.expiryDate - now) / (24 * 60 * 60 * 1000));
|
||||
expiringDomains.push({ domain: cert.domain, daysRemaining });
|
||||
} else {
|
||||
validCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Sort expiring domains by days remaining (ascending)
|
||||
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 {
|
||||
docker: {
|
||||
running: dockerRunning,
|
||||
version: dockerRunning ? await this.docker.getDockerVersion() : null,
|
||||
cpuUsage: Math.round(totalCpu * 10) / 10,
|
||||
memoryUsage: totalMemoryUsed,
|
||||
memoryTotal: totalMemoryLimit,
|
||||
networkIn: totalNetworkIn,
|
||||
networkOut: totalNetworkOut,
|
||||
},
|
||||
reverseProxy: proxyStatus,
|
||||
dns: {
|
||||
@@ -245,6 +365,12 @@ export class Onebox {
|
||||
stopped: totalServices - runningServices,
|
||||
},
|
||||
platformServices: platformServicesStatus,
|
||||
certificateHealth: {
|
||||
valid: validCount,
|
||||
expiringSoon: expiringCount,
|
||||
expired: expiredCount,
|
||||
expiringDomains: expiringDomains.slice(0, 5), // Top 5 expiring
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`Failed to get system status: ${getErrorMessage(error)}`);
|
||||
@@ -253,31 +379,17 @@ export class Onebox {
|
||||
}
|
||||
|
||||
/**
|
||||
* Start daemon mode
|
||||
*/
|
||||
async startDaemon(): Promise<void> {
|
||||
await this.daemon.start();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop daemon mode
|
||||
*/
|
||||
async stopDaemon(): Promise<void> {
|
||||
await this.daemon.stop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start HTTP server
|
||||
* Start OpsServer (TypedRequest-based, serves new UI)
|
||||
*/
|
||||
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> {
|
||||
await this.httpServer.stop();
|
||||
await this.opsServer.stop();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -287,11 +399,17 @@ export class Onebox {
|
||||
try {
|
||||
logger.info('Shutting down Onebox...');
|
||||
|
||||
// Stop auto-update monitoring
|
||||
this.services.stopAutoUpdateMonitoring();
|
||||
|
||||
// Stop backup scheduler
|
||||
await this.backupScheduler.stop();
|
||||
|
||||
// Stop daemon if running
|
||||
await this.daemon.stop();
|
||||
|
||||
// Stop HTTP server if running
|
||||
await this.httpServer.stop();
|
||||
// Stop OpsServer if running
|
||||
await this.opsServer.stop();
|
||||
|
||||
// Stop reverse proxy if running
|
||||
await this.reverseProxy.stop();
|
||||
|
||||
@@ -166,10 +166,10 @@ export class ClickHouseProvider extends BasePlatformServiceProvider {
|
||||
|
||||
// Use docker exec to run health check inside the container
|
||||
// This avoids network issues with overlay networks
|
||||
// Note: ClickHouse image has wget but not curl
|
||||
// Note: ClickHouse image has wget but not curl - use full path for reliability
|
||||
const result = await this.oneboxRef.docker.execInContainer(
|
||||
platformService.containerId,
|
||||
['wget', '-q', '-O', '-', 'http://localhost:8123/ping']
|
||||
['/usr/bin/wget', '-q', '-O', '-', 'http://localhost:8123/ping']
|
||||
);
|
||||
|
||||
if (result.exitCode === 0) {
|
||||
@@ -194,12 +194,6 @@ export class ClickHouseProvider extends BasePlatformServiceProvider {
|
||||
const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
|
||||
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
|
||||
const dbName = this.generateResourceName(userService.name);
|
||||
const username = this.generateResourceName(userService.name);
|
||||
@@ -207,35 +201,16 @@ export class ClickHouseProvider extends BasePlatformServiceProvider {
|
||||
|
||||
logger.info(`Provisioning ClickHouse database '${dbName}' for service '${userService.name}'...`);
|
||||
|
||||
// Connect to ClickHouse via localhost and the mapped host port
|
||||
const baseUrl = `http://127.0.0.1:${hostPort}`;
|
||||
// Use docker exec to provision inside the container (avoids host port mapping issues)
|
||||
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
|
||||
await this.executeQuery(
|
||||
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}'`);
|
||||
for (const query of queries) {
|
||||
await this.execClickHouseQuery(platformService.containerId, adminCreds, query);
|
||||
}
|
||||
|
||||
logger.success(`ClickHouse database '${dbName}' provisioned with user '${username}'`);
|
||||
|
||||
@@ -274,37 +249,11 @@ export class ClickHouseProvider extends BasePlatformServiceProvider {
|
||||
|
||||
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, 8123);
|
||||
if (!hostPort) {
|
||||
throw new Error('Could not get ClickHouse container host port');
|
||||
}
|
||||
|
||||
logger.info(`Deprovisioning ClickHouse database '${resource.resourceName}'...`);
|
||||
|
||||
const baseUrl = `http://127.0.0.1:${hostPort}`;
|
||||
|
||||
try {
|
||||
// Drop the user
|
||||
try {
|
||||
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}`
|
||||
);
|
||||
await this.execClickHouseQuery(platformService.containerId, adminCreds, `DROP USER IF EXISTS ${credentials.username}`);
|
||||
await this.execClickHouseQuery(platformService.containerId, adminCreds, `DROP DATABASE IF EXISTS ${resource.resourceName}`);
|
||||
logger.success(`ClickHouse database '${resource.resourceName}' dropped`);
|
||||
} catch (e) {
|
||||
logger.error(`Failed to deprovision ClickHouse database: ${getErrorMessage(e)}`);
|
||||
@@ -313,26 +262,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(
|
||||
baseUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
private async execClickHouseQuery(
|
||||
containerId: string,
|
||||
adminCreds: { username: string; password: string },
|
||||
query: 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, {
|
||||
method: 'POST',
|
||||
body: query,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`ClickHouse query failed: ${errorText}`);
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(`ClickHouse query failed (exit ${result.exitCode}): ${result.stderr.substring(0, 200)}`);
|
||||
}
|
||||
|
||||
return await response.text();
|
||||
return result.stdout;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,84 +196,28 @@ export class MinioProvider extends BasePlatformServiceProvider {
|
||||
const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
|
||||
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, 9000);
|
||||
if (!hostPort) {
|
||||
throw new Error('Could not get MinIO container host port');
|
||||
}
|
||||
|
||||
// Generate bucket name and credentials
|
||||
// Generate bucket 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}'...`);
|
||||
|
||||
// Connect to MinIO via localhost and the mapped host port (for provisioning from host)
|
||||
const provisioningEndpoint = `http://127.0.0.1:${hostPort}`;
|
||||
|
||||
// Import AWS S3 client
|
||||
const { S3Client, CreateBucketCommand, PutBucketPolicyCommand } = await import('npm:@aws-sdk/client-s3@3');
|
||||
|
||||
// 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,
|
||||
});
|
||||
// Use docker exec with mc (MinIO Client) inside the container
|
||||
// First configure mc alias for local server
|
||||
await this.execMc(platformService.containerId, [
|
||||
'alias', 'set', 'local', 'http://localhost:9000',
|
||||
adminCreds.username, adminCreds.password,
|
||||
]);
|
||||
|
||||
// Create the bucket
|
||||
try {
|
||||
await s3Client.send(new CreateBucketCommand({
|
||||
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`);
|
||||
}
|
||||
const mbResult = await this.execMc(platformService.containerId, [
|
||||
'mb', '--ignore-existing', `local/${bucketName}`,
|
||||
]);
|
||||
logger.info(`Created MinIO bucket '${bucketName}'`);
|
||||
|
||||
// Create service account/access key using MinIO Admin API
|
||||
// MinIO Admin API requires mc client or direct API calls
|
||||
// For simplicity, we'll use root credentials and bucket policy isolation
|
||||
// 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.');
|
||||
// Set bucket policy to allow public read/write (services on the same network use root creds)
|
||||
await this.execMc(platformService.containerId, [
|
||||
'anonymous', 'set', 'none', `local/${bucketName}`,
|
||||
]);
|
||||
|
||||
// Use container name for the endpoint in credentials (user services run in same network)
|
||||
const serviceEndpoint = `http://${containerName}:9000`;
|
||||
@@ -281,7 +225,7 @@ export class MinioProvider extends BasePlatformServiceProvider {
|
||||
const credentials: Record<string, string> = {
|
||||
endpoint: serviceEndpoint,
|
||||
bucket: bucketName,
|
||||
accessKey: adminCreds.username, // Using root for now
|
||||
accessKey: adminCreds.username,
|
||||
secretKey: adminCreds.password,
|
||||
region: 'us-east-1',
|
||||
};
|
||||
@@ -312,57 +256,37 @@ export class MinioProvider extends BasePlatformServiceProvider {
|
||||
|
||||
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}'...`);
|
||||
|
||||
const { S3Client, DeleteBucketCommand, ListObjectsV2Command, DeleteObjectsCommand } = await import('npm:@aws-sdk/client-s3@3');
|
||||
|
||||
const s3Client = new S3Client({
|
||||
endpoint: `http://127.0.0.1:${hostPort}`,
|
||||
region: 'us-east-1',
|
||||
credentials: {
|
||||
accessKeyId: adminCreds.username,
|
||||
secretAccessKey: adminCreds.password,
|
||||
},
|
||||
forcePathStyle: true,
|
||||
});
|
||||
// Configure mc alias
|
||||
await this.execMc(platformService.containerId, [
|
||||
'alias', 'set', 'local', 'http://localhost:9000',
|
||||
adminCreds.username, adminCreds.password,
|
||||
]);
|
||||
|
||||
try {
|
||||
// First, delete all objects in the bucket
|
||||
let continuationToken: string | undefined;
|
||||
do {
|
||||
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,
|
||||
}));
|
||||
|
||||
// Remove all objects and the bucket
|
||||
await this.execMc(platformService.containerId, [
|
||||
'rb', '--force', `local/${resource.resourceName}`,
|
||||
]);
|
||||
logger.success(`MinIO bucket '${resource.resourceName}' deleted`);
|
||||
} catch (e) {
|
||||
logger.error(`Failed to delete MinIO bucket: ${getErrorMessage(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,7 +28,7 @@ export class MongoDBProvider extends BasePlatformServiceProvider {
|
||||
|
||||
getDefaultConfig(): IPlatformServiceConfig {
|
||||
return {
|
||||
image: 'mongo:7',
|
||||
image: 'mongo:4.4',
|
||||
port: 27017,
|
||||
volumes: ['/var/lib/onebox/mongodb:/data/db'],
|
||||
environment: {
|
||||
@@ -165,7 +165,7 @@ export class MongoDBProvider extends BasePlatformServiceProvider {
|
||||
// This avoids network issues with overlay networks
|
||||
const result = await this.oneboxRef.docker.execInContainer(
|
||||
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) {
|
||||
@@ -190,12 +190,6 @@ export class MongoDBProvider extends BasePlatformServiceProvider {
|
||||
const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
|
||||
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
|
||||
const dbName = this.generateResourceName(userService.name);
|
||||
const username = this.generateResourceName(userService.name);
|
||||
@@ -203,32 +197,40 @@ export class MongoDBProvider extends BasePlatformServiceProvider {
|
||||
|
||||
logger.info(`Provisioning MongoDB database '${dbName}' for service '${userService.name}'...`);
|
||||
|
||||
// Connect to MongoDB via localhost and the mapped host port
|
||||
const { MongoClient } = await import('npm:mongodb@6');
|
||||
const adminUri = `mongodb://${adminCreds.username}:${adminCreds.password}@127.0.0.1:${hostPort}/?authSource=admin`;
|
||||
// Use docker exec to provision inside the container (avoids host port mapping issues)
|
||||
const escapedPassword = password.replace(/'/g, "'\\''");
|
||||
const escapedAdminPassword = adminCreds.password.replace(/'/g, "'\\''");
|
||||
|
||||
const client = new MongoClient(adminUri);
|
||||
await client.connect();
|
||||
|
||||
try {
|
||||
// Create the database by switching to it (MongoDB creates on first write)
|
||||
const db = client.db(dbName);
|
||||
|
||||
// Create a collection to ensure the database exists
|
||||
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 }],
|
||||
// Create database and user via mongo inside the container
|
||||
const mongoScript = `
|
||||
db = db.getSiblingDB('${dbName}');
|
||||
db.createCollection('_onebox_init');
|
||||
db.createUser({
|
||||
user: '${username}',
|
||||
pwd: '${escapedPassword}',
|
||||
roles: [{ role: 'readWrite', db: '${dbName}' }]
|
||||
});
|
||||
print('PROVISION_SUCCESS');
|
||||
`;
|
||||
|
||||
logger.success(`MongoDB database '${dbName}' provisioned with user '${username}'`);
|
||||
} finally {
|
||||
await client.close();
|
||||
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}'`);
|
||||
|
||||
// Build the credentials and env vars
|
||||
const credentials: Record<string, string> = {
|
||||
host: containerName,
|
||||
@@ -262,37 +264,33 @@ export class MongoDBProvider extends BasePlatformServiceProvider {
|
||||
}
|
||||
|
||||
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, 27017);
|
||||
if (!hostPort) {
|
||||
throw new Error('Could not get MongoDB container host port');
|
||||
}
|
||||
const escapedAdminPassword = adminCreds.password.replace(/'/g, "'\\''");
|
||||
|
||||
logger.info(`Deprovisioning MongoDB database '${resource.resourceName}'...`);
|
||||
|
||||
const { MongoClient } = await import('npm:mongodb@6');
|
||||
const adminUri = `mongodb://${adminCreds.username}:${adminCreds.password}@127.0.0.1:${hostPort}/?authSource=admin`;
|
||||
const mongoScript = `
|
||||
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);
|
||||
await client.connect();
|
||||
const result = await this.oneboxRef.docker.execInContainer(
|
||||
platformService.containerId,
|
||||
[
|
||||
'mongo',
|
||||
'--username', adminCreds.username,
|
||||
'--password', escapedAdminPassword,
|
||||
'--authenticationDatabase', 'admin',
|
||||
'--quiet',
|
||||
'--eval', mongoScript,
|
||||
]
|
||||
);
|
||||
|
||||
try {
|
||||
const db = client.db(resource.resourceName);
|
||||
|
||||
// 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`);
|
||||
} finally {
|
||||
await client.close();
|
||||
if (result.exitCode !== 0) {
|
||||
logger.warn(`MongoDB deprovision returned exit code ${result.exitCode}: ${result.stderr.substring(0, 200)}`);
|
||||
}
|
||||
|
||||
logger.success(`MongoDB database '${resource.resourceName}' dropped`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ export class OneboxServicesManager {
|
||||
private oneboxRef: any; // Will be Onebox instance
|
||||
private database: OneboxDatabase;
|
||||
private docker: OneboxDockerManager;
|
||||
private autoUpdateIntervalId: number | null = null;
|
||||
|
||||
constructor(oneboxRef: any) {
|
||||
this.oneboxRef = oneboxRef;
|
||||
@@ -681,7 +682,7 @@ export class OneboxServicesManager {
|
||||
*/
|
||||
startAutoUpdateMonitoring(): void {
|
||||
// Check every 30 seconds
|
||||
setInterval(async () => {
|
||||
this.autoUpdateIntervalId = setInterval(async () => {
|
||||
try {
|
||||
await this.checkForRegistryUpdates();
|
||||
} catch (error) {
|
||||
@@ -692,6 +693,17 @@ export class OneboxServicesManager {
|
||||
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
|
||||
*/
|
||||
|
||||
243
ts/classes/systemd.ts
Normal file
243
ts/classes/systemd.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
}
|
||||
166
ts/cli.ts
166
ts/cli.ts
@@ -7,6 +7,7 @@ import { projectInfo } from './info.ts';
|
||||
import { getErrorMessage } from './utils/error.ts';
|
||||
import { Onebox } from './classes/onebox.ts';
|
||||
import { OneboxDaemon } from './classes/daemon.ts';
|
||||
import { OneboxSystemd } from './classes/systemd.ts';
|
||||
|
||||
export async function runCli(): Promise<void> {
|
||||
const args = Deno.args;
|
||||
@@ -25,6 +26,19 @@ export async function runCli(): Promise<void> {
|
||||
const subcommand = args[1];
|
||||
|
||||
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)
|
||||
if (command === 'server') {
|
||||
const onebox = new Onebox();
|
||||
@@ -60,10 +74,6 @@ export async function runCli(): Promise<void> {
|
||||
await handleNginxCommand(onebox, subcommand, args.slice(2));
|
||||
break;
|
||||
|
||||
case 'daemon':
|
||||
await handleDaemonCommand(onebox, subcommand, args.slice(2));
|
||||
break;
|
||||
|
||||
case 'config':
|
||||
await handleConfigCommand(onebox, subcommand, args.slice(2));
|
||||
break;
|
||||
@@ -278,7 +288,7 @@ async function handleServerCommand(onebox: Onebox, args: string[]) {
|
||||
await OneboxDaemon.ensureNoDaemon();
|
||||
} catch (error) {
|
||||
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');
|
||||
Deno.exit(1);
|
||||
}
|
||||
@@ -286,8 +296,8 @@ async function handleServerCommand(onebox: Onebox, args: string[]) {
|
||||
|
||||
logger.info('Starting Onebox server...');
|
||||
|
||||
// Start HTTP server
|
||||
await onebox.httpServer.start(port);
|
||||
// Start OpsServer (serves new UI + TypedRequest API)
|
||||
await onebox.opsServer.start(port);
|
||||
|
||||
// Start monitoring if requested
|
||||
if (monitor) {
|
||||
@@ -308,7 +318,7 @@ async function handleServerCommand(onebox: Onebox, args: string[]) {
|
||||
if (monitor) {
|
||||
onebox.daemon.stopMonitoring();
|
||||
}
|
||||
await onebox.httpServer.stop();
|
||||
await onebox.opsServer.stop();
|
||||
await onebox.shutdown();
|
||||
Deno.exit(0);
|
||||
};
|
||||
@@ -322,39 +332,49 @@ async function handleServerCommand(onebox: Onebox, args: string[]) {
|
||||
}
|
||||
}
|
||||
|
||||
// Daemon commands
|
||||
async function handleDaemonCommand(onebox: Onebox, subcommand: string, _args: string[]) {
|
||||
// Systemd service commands (lightweight — no Onebox init)
|
||||
async function handleSystemdCommand(subcommand: string, _args: string[]) {
|
||||
const systemd = new OneboxSystemd();
|
||||
|
||||
switch (subcommand) {
|
||||
case 'install':
|
||||
await onebox.daemon.installService();
|
||||
case 'enable':
|
||||
await systemd.enable();
|
||||
break;
|
||||
|
||||
case 'disable':
|
||||
await systemd.disable();
|
||||
break;
|
||||
|
||||
case 'start':
|
||||
await onebox.startDaemon();
|
||||
await systemd.start();
|
||||
break;
|
||||
|
||||
case 'stop':
|
||||
await onebox.stopDaemon();
|
||||
await systemd.stop();
|
||||
break;
|
||||
|
||||
case 'logs': {
|
||||
const command = new Deno.Command('journalctl', {
|
||||
args: ['-u', 'smartdaemon_onebox', '-f'],
|
||||
stdout: 'inherit',
|
||||
stderr: 'inherit',
|
||||
});
|
||||
await command.output();
|
||||
case 'status': {
|
||||
const status = await systemd.getStatus();
|
||||
logger.info(`Service status: ${status}`);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'status': {
|
||||
const status = await onebox.daemon.getServiceStatus();
|
||||
logger.info(`Daemon status: ${status}`);
|
||||
case 'logs':
|
||||
await systemd.showLogs();
|
||||
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;
|
||||
}
|
||||
|
||||
default:
|
||||
logger.error(`Unknown daemon subcommand: ${subcommand}`);
|
||||
logger.error(`Unknown systemd subcommand: ${subcommand}`);
|
||||
logger.info('Available: enable, disable, start, stop, status, logs');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -386,6 +406,78 @@ async function handleStatusCommand(onebox: Onebox) {
|
||||
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
|
||||
function getArg(args: string[], flag: string): string {
|
||||
const arg = args.find((a) => a.startsWith(`${flag}=`));
|
||||
@@ -430,17 +522,21 @@ Commands:
|
||||
nginx test
|
||||
nginx status
|
||||
|
||||
daemon install
|
||||
daemon start
|
||||
daemon stop
|
||||
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 set <key> <value>
|
||||
|
||||
status
|
||||
|
||||
upgrade
|
||||
Upgrade Onebox to the latest version (requires root)
|
||||
|
||||
Options:
|
||||
--help, -h Show this help message
|
||||
--version, -v Show version
|
||||
@@ -451,15 +547,15 @@ Development Workflow:
|
||||
onebox service add ... # In another terminal
|
||||
|
||||
Production Workflow:
|
||||
onebox daemon install # Install systemd service
|
||||
onebox daemon start # Start daemon
|
||||
onebox service add ... # CLI uses daemon
|
||||
onebox systemd enable # Install and enable systemd service
|
||||
onebox systemd start # Start via systemctl
|
||||
onebox service add ... # CLI manages services
|
||||
|
||||
Examples:
|
||||
onebox server --ephemeral # Start dev server
|
||||
onebox service add myapp --image nginx:latest --domain app.example.com --port 80
|
||||
onebox registry add --url registry.example.com --username user --password pass
|
||||
onebox daemon install
|
||||
onebox daemon start
|
||||
onebox systemd enable
|
||||
onebox systemd start
|
||||
`);
|
||||
}
|
||||
|
||||
@@ -18,10 +18,14 @@ import type {
|
||||
IDomain,
|
||||
ICertificate,
|
||||
ICertRequirement,
|
||||
IBackup,
|
||||
IBackupSchedule,
|
||||
IBackupScheduleUpdate,
|
||||
} from '../types.ts';
|
||||
import type { TBindValue } from './types.ts';
|
||||
import { logger } from '../logging.ts';
|
||||
import { getErrorMessage } from '../utils/error.ts';
|
||||
import { MigrationRunner } from './migrations/index.ts';
|
||||
|
||||
// Import repositories
|
||||
import {
|
||||
@@ -31,6 +35,7 @@ import {
|
||||
AuthRepository,
|
||||
MetricsRepository,
|
||||
PlatformRepository,
|
||||
BackupRepository,
|
||||
} from './repositories/index.ts';
|
||||
|
||||
export class OneboxDatabase {
|
||||
@@ -44,6 +49,7 @@ export class OneboxDatabase {
|
||||
private authRepo!: AuthRepository;
|
||||
private metricsRepo!: MetricsRepository;
|
||||
private platformRepo!: PlatformRepository;
|
||||
private backupRepo!: BackupRepository;
|
||||
|
||||
constructor(dbPath = './.nogit/onebox.db') {
|
||||
this.dbPath = dbPath;
|
||||
@@ -66,7 +72,8 @@ export class OneboxDatabase {
|
||||
await this.createTables();
|
||||
|
||||
// Run migrations if needed
|
||||
await this.runMigrations();
|
||||
const runner = new MigrationRunner(this.query.bind(this));
|
||||
runner.run();
|
||||
|
||||
// Initialize repositories with bound query function
|
||||
const queryFn = this.query.bind(this);
|
||||
@@ -76,6 +83,7 @@ export class OneboxDatabase {
|
||||
this.authRepo = new AuthRepository(queryFn);
|
||||
this.metricsRepo = new MetricsRepository(queryFn);
|
||||
this.platformRepo = new PlatformRepository(queryFn);
|
||||
this.backupRepo = new BackupRepository(queryFn);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to initialize database: ${getErrorMessage(error)}`);
|
||||
throw error;
|
||||
@@ -235,516 +243,6 @@ export class OneboxDatabase {
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
@@ -1078,4 +576,68 @@ export class OneboxDatabase {
|
||||
deletePlatformResourcesByService(serviceId: number): void {
|
||||
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);
|
||||
}
|
||||
|
||||
// ============ 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
ts/database/migrations/base-migration.ts
Normal file
22
ts/database/migrations/base-migration.ts
Normal 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
ts/database/migrations/index.ts
Normal file
2
ts/database/migrations/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { BaseMigration } from './base-migration.ts';
|
||||
export { MigrationRunner } from './migration-runner.ts';
|
||||
12
ts/database/migrations/migration-001-initial.ts
Normal file
12
ts/database/migrations/migration-001-initial.ts
Normal file
@@ -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.
|
||||
}
|
||||
}
|
||||
170
ts/database/migrations/migration-002-timestamps-to-real.ts
Normal file
170
ts/database/migrations/migration-002-timestamps-to-real.ts
Normal file
@@ -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`);
|
||||
}
|
||||
}
|
||||
125
ts/database/migrations/migration-003-domain-management.ts
Normal file
125
ts/database/migrations/migration-003-domain-management.ts
Normal file
@@ -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)',
|
||||
);
|
||||
}
|
||||
}
|
||||
16
ts/database/migrations/migration-004-registry-columns.ts
Normal file
16
ts/database/migrations/migration-004-registry-columns.ts
Normal file
@@ -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`);
|
||||
}
|
||||
}
|
||||
30
ts/database/migrations/migration-005-registry-tokens.ts
Normal file
30
ts/database/migrations/migration-005-registry-tokens.ts
Normal file
@@ -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)',
|
||||
);
|
||||
}
|
||||
}
|
||||
48
ts/database/migrations/migration-006-drop-registry-token.ts
Normal file
48
ts/database/migrations/migration-006-drop-registry-token.ts
Normal file
@@ -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)');
|
||||
}
|
||||
}
|
||||
49
ts/database/migrations/migration-007-platform-services.ts
Normal file
49
ts/database/migrations/migration-007-platform-services.ts
Normal file
@@ -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)',
|
||||
);
|
||||
}
|
||||
}
|
||||
41
ts/database/migrations/migration-008-cert-pem-content.ts
Normal file
41
ts/database/migrations/migration-008-cert-pem-content.ts
Normal file
@@ -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)',
|
||||
);
|
||||
}
|
||||
}
|
||||
29
ts/database/migrations/migration-009-backup-system.ts
Normal file
29
ts/database/migrations/migration-009-backup-system.ts
Normal file
@@ -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)');
|
||||
}
|
||||
}
|
||||
39
ts/database/migrations/migration-010-backup-schedules.ts
Normal file
39
ts/database/migrations/migration-010-backup-schedules.ts
Normal file
@@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
54
ts/database/migrations/migration-011-scope-columns.ts
Normal file
54
ts/database/migrations/migration-011-scope-columns.ts
Normal file
@@ -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)',
|
||||
);
|
||||
}
|
||||
}
|
||||
97
ts/database/migrations/migration-012-gfs-retention.ts
Normal file
97
ts/database/migrations/migration-012-gfs-retention.ts
Normal file
@@ -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)');
|
||||
}
|
||||
}
|
||||
100
ts/database/migrations/migration-runner.ts
Normal file
100
ts/database/migrations/migration-runner.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* 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 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(),
|
||||
].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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
249
ts/database/repositories/backup.repository.ts
Normal file
249
ts/database/repositories/backup.repository.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* 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
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
backup.serviceId,
|
||||
backup.serviceName,
|
||||
backup.filename,
|
||||
backup.sizeBytes,
|
||||
backup.createdAt,
|
||||
backup.includesImage ? 1 : 0,
|
||||
JSON.stringify(backup.platformResources),
|
||||
backup.checksum,
|
||||
backup.scheduleId ?? null,
|
||||
]
|
||||
);
|
||||
|
||||
// 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));
|
||||
}
|
||||
|
||||
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),
|
||||
sizeBytes: Number(row.size_bytes),
|
||||
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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -8,3 +8,4 @@ export { CertificateRepository } from './certificate.repository.ts';
|
||||
export { AuthRepository } from './auth.repository.ts';
|
||||
export { MetricsRepository } from './metrics.repository.ts';
|
||||
export { PlatformRepository } from './platform.repository.ts';
|
||||
export { BackupRepository } from './backup.repository.ts';
|
||||
|
||||
@@ -119,6 +119,10 @@ export class ServiceRepository extends BaseRepository {
|
||||
fields.push('platform_requirements = ?');
|
||||
values.push(JSON.stringify(updates.platformRequirements));
|
||||
}
|
||||
if (updates.includeImageInBackup !== undefined) {
|
||||
fields.push('include_image_in_backup = ?');
|
||||
values.push(updates.includeImageInBackup ? 1 : 0);
|
||||
}
|
||||
|
||||
fields.push('updated_at = ?');
|
||||
values.push(Date.now());
|
||||
@@ -172,6 +176,9 @@ export class ServiceRepository extends BaseRepository {
|
||||
autoUpdateOnPush: row.auto_update_on_push ? Boolean(row.auto_update_on_push) : undefined,
|
||||
imageDigest: row.image_digest ? String(row.image_digest) : undefined,
|
||||
platformRequirements,
|
||||
includeImageInBackup: row.include_image_in_backup !== undefined
|
||||
? Boolean(row.include_image_in_backup)
|
||||
: true, // Default to true
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ export { OneboxReverseProxy } from './classes/reverseproxy.ts';
|
||||
export { OneboxDnsManager } from './classes/dns.ts';
|
||||
export { OneboxSslManager } from './classes/ssl.ts';
|
||||
export { OneboxDaemon } from './classes/daemon.ts';
|
||||
export { OneboxSystemd } from './classes/systemd.ts';
|
||||
export { OneboxHttpServer } from './classes/httpserver.ts';
|
||||
export { OneboxApiClient } from './classes/apiclient.ts';
|
||||
|
||||
|
||||
78
ts/opsserver/classes.opsserver.ts
Normal file
78
ts/opsserver/classes.opsserver.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import * as plugins from '../plugins.ts';
|
||||
import { logger } from '../logging.ts';
|
||||
import type { Onebox } from '../classes/onebox.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;
|
||||
|
||||
constructor(oneboxRef: Onebox) {
|
||||
this.oneboxRef = oneboxRef;
|
||||
}
|
||||
|
||||
public async start(port = 3000) {
|
||||
this.server = new plugins.typedserver.utilityservers.UtilityWebsiteServer({
|
||||
domain: 'localhost',
|
||||
feedMetadata: undefined,
|
||||
bundledContent: bundledFiles,
|
||||
});
|
||||
|
||||
// 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);
|
||||
|
||||
logger.success('OpsServer TypedRequest handlers initialized');
|
||||
}
|
||||
|
||||
public async stop() {
|
||||
if (this.server) {
|
||||
await this.server.stop();
|
||||
logger.success('OpsServer stopped');
|
||||
}
|
||||
}
|
||||
}
|
||||
175
ts/opsserver/handlers/admin.handler.ts
Normal file
175
ts/opsserver/handlers/admin.handler.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
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';
|
||||
|
||||
export interface IJwtData {
|
||||
userId: string;
|
||||
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();
|
||||
await this.smartjwtInstance.init();
|
||||
await this.smartjwtInstance.createNewKeyPair();
|
||||
this.registerHandlers();
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
// Verify password (base64 comparison to match existing DB scheme)
|
||||
const passwordHash = btoa(dataArg.password);
|
||||
if (passwordHash !== user.passwordHash) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Invalid credentials');
|
||||
}
|
||||
|
||||
const expiresAt = Date.now() + 24 * 3600 * 1000;
|
||||
const userId = String(user.id || user.username);
|
||||
const jwt = await this.smartjwtInstance.createJWT({
|
||||
userId,
|
||||
status: 'loggedIn',
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
logger.info(`User logged in: ${user.username}`);
|
||||
|
||||
return {
|
||||
identity: {
|
||||
jwt,
|
||||
userId,
|
||||
username: user.username,
|
||||
expiresAt,
|
||||
role: user.role,
|
||||
},
|
||||
};
|
||||
} 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) => {
|
||||
if (!dataArg.identity?.jwt) {
|
||||
return { valid: false };
|
||||
}
|
||||
try {
|
||||
const jwtData = await this.smartjwtInstance.verifyJWTAndGetData(dataArg.identity.jwt);
|
||||
if (jwtData.expiresAt < Date.now()) return { valid: false };
|
||||
if (jwtData.status !== 'loggedIn') return { valid: false };
|
||||
return {
|
||||
valid: true,
|
||||
identity: {
|
||||
jwt: dataArg.identity.jwt,
|
||||
userId: jwtData.userId,
|
||||
username: dataArg.identity.username,
|
||||
expiresAt: jwtData.expiresAt,
|
||||
role: dataArg.identity.role,
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return { valid: false };
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Change Password
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ChangePassword>(
|
||||
'changePassword',
|
||||
async (dataArg) => {
|
||||
await this.requireValidIdentity(dataArg);
|
||||
const user = this.opsServerRef.oneboxRef.database.getUserByUsername(dataArg.identity.username);
|
||||
if (!user) {
|
||||
throw new plugins.typedrequest.TypedResponseError('User not found');
|
||||
}
|
||||
|
||||
const currentHash = btoa(dataArg.currentPassword);
|
||||
if (currentHash !== user.passwordHash) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Current password is incorrect');
|
||||
}
|
||||
|
||||
const newHash = btoa(dataArg.newPassword);
|
||||
this.opsServerRef.oneboxRef.database.updateUserPassword(user.username, newHash);
|
||||
logger.info(`Password changed for user: ${user.username}`);
|
||||
|
||||
return { ok: true };
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private async requireValidIdentity(dataArg: { identity: interfaces.data.IIdentity }): Promise<void> {
|
||||
const passed = await this.validIdentityGuard.exec({ identity: dataArg.identity });
|
||||
if (!passed) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Valid identity required');
|
||||
}
|
||||
}
|
||||
|
||||
// Guard for valid identity
|
||||
public validIdentityGuard = new plugins.smartguard.Guard<{
|
||||
identity: interfaces.data.IIdentity;
|
||||
}>(
|
||||
async (dataArg) => {
|
||||
if (!dataArg.identity?.jwt) return false;
|
||||
try {
|
||||
const jwtData = await this.smartjwtInstance.verifyJWTAndGetData(dataArg.identity.jwt);
|
||||
if (jwtData.expiresAt < Date.now()) return false;
|
||||
if (jwtData.status !== 'loggedIn') return false;
|
||||
if (dataArg.identity.expiresAt !== jwtData.expiresAt) return false;
|
||||
if (dataArg.identity.userId !== jwtData.userId) return false;
|
||||
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) => {
|
||||
const isValid = await this.validIdentityGuard.exec(dataArg);
|
||||
if (!isValid) return false;
|
||||
return dataArg.identity.role === 'admin';
|
||||
},
|
||||
{ failedHint: 'user is not admin', name: 'adminIdentityGuard' },
|
||||
);
|
||||
}
|
||||
100
ts/opsserver/handlers/backups.handler.ts
Normal file
100
ts/opsserver/handlers/backups.handler.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import * as plugins from '../../plugins.ts';
|
||||
import type { OpsServer } from '../classes.opsserver.ts';
|
||||
import * as interfaces from '../../../ts_interfaces/index.ts';
|
||||
import { requireValidIdentity } from '../helpers/guards.ts';
|
||||
|
||||
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 requireValidIdentity(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 requireValidIdentity(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 requireValidIdentity(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 requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const backupPath = this.opsServerRef.oneboxRef.backupManager.getBackupFilePath(dataArg.backupId);
|
||||
if (!backupPath) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Backup file not found');
|
||||
}
|
||||
const rawResult = await this.opsServerRef.oneboxRef.backupManager.restoreBackup(
|
||||
backupPath,
|
||||
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 requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const backup = this.opsServerRef.oneboxRef.database.getBackupById(dataArg.backupId);
|
||||
if (!backup) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Backup not found');
|
||||
}
|
||||
const filePath = this.opsServerRef.oneboxRef.backupManager.getBackupFilePath(dataArg.backupId);
|
||||
if (!filePath) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Backup file not found');
|
||||
}
|
||||
// Return a download URL that the client can fetch directly
|
||||
return {
|
||||
downloadUrl: `/api/backups/${dataArg.backupId}/download`,
|
||||
filename: backup.filename,
|
||||
};
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
65
ts/opsserver/handlers/dns.handler.ts
Normal file
65
ts/opsserver/handlers/dns.handler.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import * as plugins from '../../plugins.ts';
|
||||
import type { OpsServer } from '../classes.opsserver.ts';
|
||||
import * as interfaces from '../../../ts_interfaces/index.ts';
|
||||
import { requireValidIdentity } from '../helpers/guards.ts';
|
||||
|
||||
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 requireValidIdentity(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 requireValidIdentity(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 requireValidIdentity(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 requireValidIdentity(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 };
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
101
ts/opsserver/handlers/domains.handler.ts
Normal file
101
ts/opsserver/handlers/domains.handler.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import * as plugins from '../../plugins.ts';
|
||||
import type { OpsServer } from '../classes.opsserver.ts';
|
||||
import * as interfaces from '../../../ts_interfaces/index.ts';
|
||||
import { requireValidIdentity } from '../helpers/guards.ts';
|
||||
|
||||
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 requireValidIdentity(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 requireValidIdentity(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 requireValidIdentity(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 };
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
14
ts/opsserver/handlers/index.ts
Normal file
14
ts/opsserver/handlers/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
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';
|
||||
219
ts/opsserver/handlers/logs.handler.ts
Normal file
219
ts/opsserver/handlers/logs.handler.ts
Normal 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 { requireValidIdentity } 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 requireValidIdentity(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 requireValidIdentity(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 requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
|
||||
const virtualStream = new plugins.typedrequest.VirtualStream<Uint8Array>();
|
||||
const encoder = new TextEncoder();
|
||||
const clientId = crypto.randomUUID();
|
||||
|
||||
// Create a mock WebSocket-like object for the CaddyLogReceiver
|
||||
const mockSocket = {
|
||||
readyState: 1, // WebSocket.OPEN
|
||||
send: (data: string) => {
|
||||
try {
|
||||
virtualStream.sendData(encoder.encode(data));
|
||||
} catch {
|
||||
this.opsServerRef.oneboxRef.caddyLogReceiver.removeClient(clientId);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const filter = dataArg.filter || {};
|
||||
this.opsServerRef.oneboxRef.caddyLogReceiver.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 requireValidIdentity(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 };
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
123
ts/opsserver/handlers/network.handler.ts
Normal file
123
ts/opsserver/handlers/network.handler.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import * as plugins from '../../plugins.ts';
|
||||
import type { OpsServer } from '../classes.opsserver.ts';
|
||||
import * as interfaces from '../../../ts_interfaces/index.ts';
|
||||
import { requireValidIdentity } from '../helpers/guards.ts';
|
||||
import 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,
|
||||
caddy: 80,
|
||||
clickhouse: 8123,
|
||||
};
|
||||
return ports[type] || 0;
|
||||
}
|
||||
|
||||
private registerHandlers(): void {
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetNetworkTargets>(
|
||||
'getNetworkTargets',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(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 requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const proxyStatus = this.opsServerRef.oneboxRef.reverseProxy.getStatus() as any;
|
||||
const logReceiverStats = this.opsServerRef.oneboxRef.caddyLogReceiver.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 requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const trafficStats = this.opsServerRef.oneboxRef.caddyLogReceiver.getTrafficStats(60);
|
||||
return { stats: trafficStats };
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
329
ts/opsserver/handlers/platform.handler.ts
Normal file
329
ts/opsserver/handlers/platform.handler.ts
Normal file
@@ -0,0 +1,329 @@
|
||||
import * as plugins from '../../plugins.ts';
|
||||
import { logger } from '../../logging.ts';
|
||||
import type { OpsServer } from '../classes.opsserver.ts';
|
||||
import * as interfaces from '../../../ts_interfaces/index.ts';
|
||||
import { requireValidIdentity } from '../helpers/guards.ts';
|
||||
|
||||
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 typedsocket = (this.opsServerRef.server as any)?.typedserver?.typedsocket;
|
||||
if (!typedsocket) return;
|
||||
|
||||
const entry = this.parseLogLine(line, isError);
|
||||
|
||||
typedsocket.findAllTargetConnectionsByTag('role', 'ops_dashboard')
|
||||
.then((connections: any[]) => {
|
||||
for (const conn of connections) {
|
||||
typedsocket.createTypedRequest<interfaces.requests.IReq_PushPlatformServiceLog>(
|
||||
'pushPlatformServiceLog',
|
||||
conn,
|
||||
).fire({ serviceType, entry }).catch(() => {});
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
private pushServiceLogToClients(
|
||||
serviceName: string,
|
||||
line: string,
|
||||
isError: boolean,
|
||||
): void {
|
||||
const typedsocket = (this.opsServerRef.server as any)?.typedserver?.typedsocket;
|
||||
if (!typedsocket) return;
|
||||
|
||||
const entry = this.parseLogLine(line, isError);
|
||||
|
||||
typedsocket.findAllTargetConnectionsByTag('role', 'ops_dashboard')
|
||||
.then((connections: any[]) => {
|
||||
for (const conn of connections) {
|
||||
typedsocket.createTypedRequest<interfaces.requests.IReq_PushServiceLog>(
|
||||
'pushServiceLog',
|
||||
conn,
|
||||
).fire({ serviceName, entry }).catch(() => {});
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
private registerHandlers(): void {
|
||||
// Get all platform services
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetPlatformServices>(
|
||||
'getPlatformServices',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(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 === 'caddy') {
|
||||
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 requireValidIdentity(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 === 'caddy') {
|
||||
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 requireValidIdentity(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 requireValidIdentity(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 requireValidIdentity(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 requireValidIdentity(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
ts/opsserver/handlers/registry.handler.ts
Normal file
147
ts/opsserver/handlers/registry.handler.ts
Normal 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 { requireValidIdentity } 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 requireValidIdentity(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 requireValidIdentity(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) => {
|
||||
await requireValidIdentity(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: dataArg.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 requireValidIdentity(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 };
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
93
ts/opsserver/handlers/schedules.handler.ts
Normal file
93
ts/opsserver/handlers/schedules.handler.ts
Normal 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 { requireValidIdentity } 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 requireValidIdentity(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 requireValidIdentity(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 requireValidIdentity(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 requireValidIdentity(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 requireValidIdentity(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 requireValidIdentity(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
ts/opsserver/handlers/services.handler.ts
Normal file
244
ts/opsserver/handlers/services.handler.ts
Normal 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 { requireValidIdentity } 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 requireValidIdentity(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 requireValidIdentity(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 requireValidIdentity(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 requireValidIdentity(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 requireValidIdentity(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 requireValidIdentity(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 requireValidIdentity(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 requireValidIdentity(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 requireValidIdentity(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 requireValidIdentity(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 requireValidIdentity(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 requireValidIdentity(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 requireValidIdentity(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 requireValidIdentity(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 requireValidIdentity(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 };
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
86
ts/opsserver/handlers/settings.handler.ts
Normal file
86
ts/opsserver/handlers/settings.handler.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import * as plugins from '../../plugins.ts';
|
||||
import type { OpsServer } from '../classes.opsserver.ts';
|
||||
import * as interfaces from '../../../ts_interfaces/index.ts';
|
||||
import { requireValidIdentity } from '../helpers/guards.ts';
|
||||
|
||||
export class SettingsHandler {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
constructor(private opsServerRef: OpsServer) {
|
||||
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||
this.registerHandlers();
|
||||
}
|
||||
|
||||
private getSettingsObject(): interfaces.data.ISettings {
|
||||
const db = this.opsServerRef.oneboxRef.database;
|
||||
const settingsMap = db.getAllSettings(); // Returns Record<string, string>
|
||||
|
||||
return {
|
||||
cloudflareToken: settingsMap['cloudflareToken'] || '',
|
||||
cloudflareZoneId: settingsMap['cloudflareZoneId'] || '',
|
||||
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 requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const settings = this.getSettingsObject();
|
||||
return { settings };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateSettings>(
|
||||
'updateSettings',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const db = this.opsServerRef.oneboxRef.database;
|
||||
const updates = dataArg.settings;
|
||||
|
||||
// Store each setting as key-value pair
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
if (value !== undefined) {
|
||||
db.setSetting(key, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
const settings = this.getSettingsObject();
|
||||
return { settings };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SetBackupPassword>(
|
||||
'setBackupPassword',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
this.opsServerRef.oneboxRef.database.setSetting('backupPassword', dataArg.password);
|
||||
return { ok: true };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetBackupPasswordStatus>(
|
||||
'getBackupPasswordStatus',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const backupPassword = this.opsServerRef.oneboxRef.database.getSetting('backupPassword');
|
||||
const isConfigured = !!backupPassword;
|
||||
return { status: { isConfigured } };
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
64
ts/opsserver/handlers/ssl.handler.ts
Normal file
64
ts/opsserver/handlers/ssl.handler.ts
Normal 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 { requireValidIdentity } 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 requireValidIdentity(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 requireValidIdentity(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 requireValidIdentity(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 requireValidIdentity(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
ts/opsserver/handlers/status.handler.ts
Normal file
26
ts/opsserver/handlers/status.handler.ts
Normal 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 { requireValidIdentity } 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 requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const status = await this.opsServerRef.oneboxRef.getSystemStatus();
|
||||
return { status: status as unknown as interfaces.data.ISystemStatus };
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
181
ts/opsserver/handlers/workspace.handler.ts
Normal file
181
ts/opsserver/handlers/workspace.handler.ts
Normal 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 { requireValidIdentity } 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 requireValidIdentity(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 requireValidIdentity(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 requireValidIdentity(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 requireValidIdentity(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 requireValidIdentity(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 requireValidIdentity(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 requireValidIdentity(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');
|
||||
}
|
||||
}
|
||||
29
ts/opsserver/helpers/guards.ts
Normal file
29
ts/opsserver/helpers/guards.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
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<void> {
|
||||
if (!dataArg.identity) {
|
||||
throw new plugins.typedrequest.TypedResponseError('No identity provided');
|
||||
}
|
||||
const passed = await adminHandler.validIdentityGuard.exec({ identity: dataArg.identity });
|
||||
if (!passed) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Valid identity required');
|
||||
}
|
||||
}
|
||||
|
||||
export async function requireAdminIdentity<T extends { identity?: interfaces.data.IIdentity }>(
|
||||
adminHandler: AdminHandler,
|
||||
dataArg: T,
|
||||
): Promise<void> {
|
||||
if (!dataArg.identity) {
|
||||
throw new plugins.typedrequest.TypedResponseError('No identity provided');
|
||||
}
|
||||
const passed = await adminHandler.adminIdentityGuard.exec({ identity: dataArg.identity });
|
||||
if (!passed) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Admin access required');
|
||||
}
|
||||
}
|
||||
1
ts/opsserver/index.ts
Normal file
1
ts/opsserver/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './classes.opsserver.ts';
|
||||
@@ -17,10 +17,6 @@ export { path, fs, http, encoding };
|
||||
import { Database } from '@db/sqlite';
|
||||
export const sqlite = { DB: Database };
|
||||
|
||||
// Systemd Daemon Integration
|
||||
import * as smartdaemon from '@push.rocks/smartdaemon';
|
||||
export { smartdaemon };
|
||||
|
||||
// Docker API Client
|
||||
import { DockerHost } from '@apiclient.xyz/docker';
|
||||
export const docker = { Docker: DockerHost };
|
||||
@@ -41,6 +37,10 @@ export { smartregistry };
|
||||
import * as smarts3 from '@push.rocks/smarts3';
|
||||
export { smarts3 };
|
||||
|
||||
// Task scheduling and cron jobs
|
||||
import * as taskbuffer from '@push.rocks/taskbuffer';
|
||||
export { taskbuffer };
|
||||
|
||||
// Crypto utilities (for password hashing, encryption)
|
||||
import * as bcrypt from 'https://deno.land/x/bcrypt@v0.4.1/mod.ts';
|
||||
export { bcrypt };
|
||||
@@ -57,3 +57,13 @@ export { crypto };
|
||||
import * as nodeHttps from 'node:https';
|
||||
import * as nodeHttp from 'node:http';
|
||||
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 };
|
||||
|
||||
127
ts/types.ts
127
ts/types.ts
@@ -23,6 +23,8 @@ export interface IService {
|
||||
imageDigest?: string;
|
||||
// Platform service requirements
|
||||
platformRequirements?: IPlatformRequirements;
|
||||
// Backup settings
|
||||
includeImageInBackup?: boolean;
|
||||
}
|
||||
|
||||
// Registry types
|
||||
@@ -317,3 +319,128 @@ export interface ICliArgs {
|
||||
_: string[];
|
||||
[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;
|
||||
sizeBytes: number;
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
11
ts_bundled/bundle.ts
Normal file
11
ts_bundled/bundle.ts
Normal file
File diff suppressed because one or more lines are too long
16
ts_interfaces/data/auth.ts
Normal file
16
ts_interfaces/data/auth.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Auth-related data shapes for Onebox
|
||||
*/
|
||||
|
||||
export interface IIdentity {
|
||||
jwt: string;
|
||||
userId: string;
|
||||
username: string;
|
||||
expiresAt: number;
|
||||
role: 'admin' | 'user';
|
||||
}
|
||||
|
||||
export interface IUser {
|
||||
username: string;
|
||||
role: 'admin' | 'user';
|
||||
}
|
||||
89
ts_interfaces/data/backup.ts
Normal file
89
ts_interfaces/data/backup.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Backup-related data shapes for Onebox
|
||||
*/
|
||||
|
||||
import type { TPlatformServiceType } from './platform.ts';
|
||||
|
||||
export type TBackupRestoreMode = 'restore' | 'import' | 'clone';
|
||||
export type TBackupScheduleScope = 'all' | 'pattern' | 'service';
|
||||
|
||||
export interface IRetentionPolicy {
|
||||
hourly: number;
|
||||
daily: number;
|
||||
weekly: number;
|
||||
monthly: number;
|
||||
}
|
||||
|
||||
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;
|
||||
filename: string;
|
||||
sizeBytes: number;
|
||||
createdAt: number;
|
||||
includesImage: boolean;
|
||||
platformResources: TPlatformServiceType[];
|
||||
checksum: string;
|
||||
scheduleId?: number;
|
||||
}
|
||||
|
||||
export interface IBackupSchedule {
|
||||
id?: number;
|
||||
scopeType: TBackupScheduleScope;
|
||||
scopePattern?: string;
|
||||
serviceId?: number;
|
||||
serviceName?: string;
|
||||
cronExpression: string;
|
||||
retention: IRetentionPolicy;
|
||||
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;
|
||||
serviceName?: string;
|
||||
cronExpression: string;
|
||||
retention: IRetentionPolicy;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface IBackupScheduleUpdate {
|
||||
cronExpression?: string;
|
||||
retention?: IRetentionPolicy;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface IRestoreOptions {
|
||||
mode: TBackupRestoreMode;
|
||||
newServiceName?: string;
|
||||
overwriteExisting?: boolean;
|
||||
skipPlatformData?: boolean;
|
||||
}
|
||||
|
||||
export interface IRestoreResult {
|
||||
service: {
|
||||
name: string;
|
||||
status: string;
|
||||
};
|
||||
platformResourcesRestored: number;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface IBackupPasswordStatus {
|
||||
isConfigured: boolean;
|
||||
}
|
||||
59
ts_interfaces/data/domain.ts
Normal file
59
ts_interfaces/data/domain.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Domain, DNS, and certificate data shapes for Onebox
|
||||
*/
|
||||
|
||||
export interface IDomain {
|
||||
id?: number;
|
||||
domain: string;
|
||||
dnsProvider: 'cloudflare' | 'manual' | null;
|
||||
cloudflareZoneId?: string;
|
||||
isObsolete: boolean;
|
||||
defaultWildcard: boolean;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface ICertificate {
|
||||
id?: number;
|
||||
domainId: number;
|
||||
certDomain: string;
|
||||
isWildcard: boolean;
|
||||
certPem: string;
|
||||
keyPem: string;
|
||||
fullchainPem: string;
|
||||
expiryDate: number;
|
||||
issuer: string;
|
||||
isValid: boolean;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface ICertRequirement {
|
||||
id?: number;
|
||||
domainId: number;
|
||||
serviceId: number;
|
||||
subdomain: string;
|
||||
status: 'pending' | 'active' | 'renewing' | 'failed';
|
||||
certificateId?: number;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface IDomainDetail {
|
||||
domain: IDomain;
|
||||
certificates: ICertificate[];
|
||||
requirements: ICertRequirement[];
|
||||
serviceCount: number;
|
||||
certificateStatus: 'valid' | 'expiring-soon' | 'expired' | 'pending' | 'none';
|
||||
daysRemaining: number | null;
|
||||
}
|
||||
|
||||
export interface IDnsRecord {
|
||||
id?: number;
|
||||
domain: string;
|
||||
type: 'A' | 'AAAA' | 'CNAME';
|
||||
value: string;
|
||||
cloudflareID?: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
9
ts_interfaces/data/index.ts
Normal file
9
ts_interfaces/data/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export * from './auth.ts';
|
||||
export * from './service.ts';
|
||||
export * from './platform.ts';
|
||||
export * from './network.ts';
|
||||
export * from './domain.ts';
|
||||
export * from './registry.ts';
|
||||
export * from './backup.ts';
|
||||
export * from './settings.ts';
|
||||
export * from './system.ts';
|
||||
64
ts_interfaces/data/network.ts
Normal file
64
ts_interfaces/data/network.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Network-related data shapes for Onebox
|
||||
*/
|
||||
|
||||
export type TNetworkTargetType = 'service' | 'registry' | 'platform';
|
||||
|
||||
export interface INetworkTarget {
|
||||
type: TNetworkTargetType;
|
||||
name: string;
|
||||
domain: string | null;
|
||||
targetHost: string;
|
||||
targetPort: number;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface INetworkStats {
|
||||
proxy: {
|
||||
running: boolean;
|
||||
httpPort: number;
|
||||
httpsPort: number;
|
||||
routes: number;
|
||||
certificates: number;
|
||||
};
|
||||
logReceiver: {
|
||||
running: boolean;
|
||||
port: number;
|
||||
clients: number;
|
||||
connections: number;
|
||||
sampleRate: number;
|
||||
recentLogsCount: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ITrafficStats {
|
||||
requestCount: number;
|
||||
errorCount: number;
|
||||
avgResponseTime: number;
|
||||
totalBytes: number;
|
||||
statusCounts: Record<string, number>;
|
||||
requestsPerMinute: number;
|
||||
errorRate: number;
|
||||
}
|
||||
|
||||
export interface ICaddyAccessLog {
|
||||
ts: number;
|
||||
request: {
|
||||
remote_ip: string;
|
||||
method: string;
|
||||
host: string;
|
||||
uri: string;
|
||||
proto: string;
|
||||
};
|
||||
status: number;
|
||||
duration: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface INetworkLogMessage {
|
||||
type: 'connected' | 'access_log' | 'filter_updated';
|
||||
clientId?: string;
|
||||
filter?: { domain?: string; sampleRate?: number };
|
||||
data?: ICaddyAccessLog;
|
||||
timestamp: number;
|
||||
}
|
||||
37
ts_interfaces/data/platform.ts
Normal file
37
ts_interfaces/data/platform.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Platform service data shapes for Onebox
|
||||
*/
|
||||
|
||||
export type TPlatformServiceType = 'mongodb' | 'minio' | 'redis' | 'postgresql' | 'rabbitmq' | 'caddy' | 'clickhouse';
|
||||
export type TPlatformServiceStatus = 'not-deployed' | 'stopped' | 'starting' | 'running' | 'stopping' | 'failed';
|
||||
export type TPlatformResourceType = 'database' | 'bucket' | 'cache' | 'queue';
|
||||
|
||||
export interface IPlatformRequirements {
|
||||
mongodb?: boolean;
|
||||
s3?: boolean;
|
||||
clickhouse?: boolean;
|
||||
}
|
||||
|
||||
export interface IPlatformService {
|
||||
type: TPlatformServiceType;
|
||||
displayName: string;
|
||||
resourceTypes: TPlatformResourceType[];
|
||||
status: TPlatformServiceStatus;
|
||||
containerId?: string;
|
||||
isCore?: boolean;
|
||||
createdAt?: number;
|
||||
updatedAt?: number;
|
||||
}
|
||||
|
||||
export interface IPlatformResource {
|
||||
id: number;
|
||||
resourceType: TPlatformResourceType;
|
||||
resourceName: string;
|
||||
platformService: {
|
||||
type: TPlatformServiceType;
|
||||
name: string;
|
||||
status: TPlatformServiceStatus;
|
||||
};
|
||||
envVars: Record<string, string>;
|
||||
createdAt: number;
|
||||
}
|
||||
35
ts_interfaces/data/registry.ts
Normal file
35
ts_interfaces/data/registry.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Registry-related data shapes for Onebox
|
||||
*/
|
||||
|
||||
export interface IRegistry {
|
||||
id?: number;
|
||||
url: string;
|
||||
username: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface IRegistryToken {
|
||||
id: number;
|
||||
name: string;
|
||||
type: 'global' | 'ci';
|
||||
scope: 'all' | string[];
|
||||
scopeDisplay: string;
|
||||
expiresAt: number | null;
|
||||
createdAt: number;
|
||||
lastUsedAt: number | null;
|
||||
createdBy: string;
|
||||
isExpired: boolean;
|
||||
}
|
||||
|
||||
export interface ICreateTokenRequest {
|
||||
name: string;
|
||||
type: 'global' | 'ci';
|
||||
scope: 'all' | string[];
|
||||
expiresIn: '30d' | '90d' | '365d' | 'never';
|
||||
}
|
||||
|
||||
export interface ITokenCreatedResponse {
|
||||
token: IRegistryToken;
|
||||
plainToken: string;
|
||||
}
|
||||
82
ts_interfaces/data/service.ts
Normal file
82
ts_interfaces/data/service.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Service-related data shapes for Onebox
|
||||
*/
|
||||
|
||||
import type { IPlatformRequirements } from './platform.ts';
|
||||
|
||||
export type TServiceStatus = 'stopped' | 'starting' | 'running' | 'stopping' | 'failed';
|
||||
|
||||
export interface IService {
|
||||
id?: number;
|
||||
name: string;
|
||||
image: string;
|
||||
registry?: string;
|
||||
envVars: Record<string, string>;
|
||||
port: number;
|
||||
domain?: string;
|
||||
containerID?: string;
|
||||
status: TServiceStatus;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
// Onebox Registry fields
|
||||
useOneboxRegistry?: boolean;
|
||||
registryRepository?: string;
|
||||
registryImageTag?: string;
|
||||
autoUpdateOnPush?: boolean;
|
||||
imageDigest?: string;
|
||||
// Platform service requirements
|
||||
platformRequirements?: IPlatformRequirements;
|
||||
// Backup settings
|
||||
includeImageInBackup?: boolean;
|
||||
}
|
||||
|
||||
export interface IServiceCreate {
|
||||
name: string;
|
||||
image: string;
|
||||
port: number;
|
||||
domain?: string;
|
||||
envVars?: Record<string, string>;
|
||||
useOneboxRegistry?: boolean;
|
||||
registryImageTag?: string;
|
||||
autoUpdateOnPush?: boolean;
|
||||
enableMongoDB?: boolean;
|
||||
enableS3?: boolean;
|
||||
enableClickHouse?: boolean;
|
||||
}
|
||||
|
||||
export interface IServiceUpdate {
|
||||
image?: string;
|
||||
registry?: string;
|
||||
port?: number;
|
||||
domain?: string;
|
||||
envVars?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface IContainerStats {
|
||||
cpuPercent: number;
|
||||
memoryUsed: number;
|
||||
memoryLimit: number;
|
||||
memoryPercent: number;
|
||||
networkRx: number;
|
||||
networkTx: number;
|
||||
}
|
||||
|
||||
export interface IMetric {
|
||||
id?: number;
|
||||
serviceId: number;
|
||||
timestamp: number;
|
||||
cpuPercent: number;
|
||||
memoryUsed: number;
|
||||
memoryLimit: number;
|
||||
networkRxBytes: number;
|
||||
networkTxBytes: number;
|
||||
}
|
||||
|
||||
export interface ILogEntry {
|
||||
id?: number;
|
||||
serviceId: number;
|
||||
timestamp: number;
|
||||
message: string;
|
||||
level: 'info' | 'warn' | 'error' | 'debug';
|
||||
source: 'stdout' | 'stderr';
|
||||
}
|
||||
14
ts_interfaces/data/settings.ts
Normal file
14
ts_interfaces/data/settings.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Settings data shapes for Onebox
|
||||
*/
|
||||
|
||||
export interface ISettings {
|
||||
cloudflareToken: string;
|
||||
cloudflareZoneId: string;
|
||||
autoRenewCerts: boolean;
|
||||
renewalThreshold: number;
|
||||
acmeEmail: string;
|
||||
httpPort: number;
|
||||
httpsPort: number;
|
||||
forceHttps: boolean;
|
||||
}
|
||||
37
ts_interfaces/data/system.ts
Normal file
37
ts_interfaces/data/system.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* System status data shapes for Onebox
|
||||
*/
|
||||
|
||||
import type { TPlatformServiceType, TPlatformServiceStatus } from './platform.ts';
|
||||
|
||||
export interface ISystemStatus {
|
||||
docker: {
|
||||
running: boolean;
|
||||
version: unknown;
|
||||
cpuUsage: number;
|
||||
memoryUsage: number;
|
||||
memoryTotal: number;
|
||||
networkIn: number;
|
||||
networkOut: number;
|
||||
};
|
||||
reverseProxy: {
|
||||
http: { running: boolean; port: number };
|
||||
https: { running: boolean; port: number; certificates: number };
|
||||
routes: number;
|
||||
};
|
||||
dns: { configured: boolean };
|
||||
ssl: { configured: boolean; certificateCount: number };
|
||||
services: { total: number; running: number; stopped: number };
|
||||
platformServices: Array<{
|
||||
type: TPlatformServiceType;
|
||||
displayName: string;
|
||||
status: TPlatformServiceStatus;
|
||||
resourceCount: number;
|
||||
}>;
|
||||
certificateHealth: {
|
||||
valid: number;
|
||||
expiringSoon: number;
|
||||
expired: number;
|
||||
expiringDomains: Array<{ domain: string; daysRemaining: number }>;
|
||||
};
|
||||
}
|
||||
9
ts_interfaces/index.ts
Normal file
9
ts_interfaces/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export * from './plugins.ts';
|
||||
|
||||
// Data types
|
||||
import * as data from './data/index.ts';
|
||||
export { data };
|
||||
|
||||
// Request interfaces
|
||||
import * as requests from './requests/index.ts';
|
||||
export { requests };
|
||||
6
ts_interfaces/plugins.ts
Normal file
6
ts_interfaces/plugins.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
// @apiglobal scope
|
||||
import * as typedrequestInterfaces from '@api.global/typedrequest-interfaces';
|
||||
|
||||
export {
|
||||
typedrequestInterfaces,
|
||||
};
|
||||
58
ts_interfaces/requests/admin.ts
Normal file
58
ts_interfaces/requests/admin.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import * as plugins from '../plugins.ts';
|
||||
import * as data from '../data/index.ts';
|
||||
|
||||
export interface IReq_AdminLoginWithUsernameAndPassword extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_AdminLoginWithUsernameAndPassword
|
||||
> {
|
||||
method: 'adminLoginWithUsernameAndPassword';
|
||||
request: {
|
||||
username: string;
|
||||
password: string;
|
||||
};
|
||||
response: {
|
||||
identity?: data.IIdentity;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_AdminLogout extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_AdminLogout
|
||||
> {
|
||||
method: 'adminLogout';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
};
|
||||
response: {
|
||||
ok: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_VerifyIdentity extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_VerifyIdentity
|
||||
> {
|
||||
method: 'verifyIdentity';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
};
|
||||
response: {
|
||||
valid: boolean;
|
||||
identity?: data.IIdentity;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_ChangePassword extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_ChangePassword
|
||||
> {
|
||||
method: 'changePassword';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
currentPassword: string;
|
||||
newPassword: string;
|
||||
};
|
||||
response: {
|
||||
ok: boolean;
|
||||
};
|
||||
}
|
||||
86
ts_interfaces/requests/backup-schedules.ts
Normal file
86
ts_interfaces/requests/backup-schedules.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import * as plugins from '../plugins.ts';
|
||||
import * as data from '../data/index.ts';
|
||||
|
||||
export interface IReq_GetBackupSchedules extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetBackupSchedules
|
||||
> {
|
||||
method: 'getBackupSchedules';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
};
|
||||
response: {
|
||||
schedules: data.IBackupSchedule[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_CreateBackupSchedule extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_CreateBackupSchedule
|
||||
> {
|
||||
method: 'createBackupSchedule';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
scheduleConfig: data.IBackupScheduleCreate;
|
||||
};
|
||||
response: {
|
||||
schedule: data.IBackupSchedule;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_GetBackupSchedule extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetBackupSchedule
|
||||
> {
|
||||
method: 'getBackupSchedule';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
scheduleId: number;
|
||||
};
|
||||
response: {
|
||||
schedule: data.IBackupSchedule;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_UpdateBackupSchedule extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_UpdateBackupSchedule
|
||||
> {
|
||||
method: 'updateBackupSchedule';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
scheduleId: number;
|
||||
updates: data.IBackupScheduleUpdate;
|
||||
};
|
||||
response: {
|
||||
schedule: data.IBackupSchedule;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_DeleteBackupSchedule extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_DeleteBackupSchedule
|
||||
> {
|
||||
method: 'deleteBackupSchedule';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
scheduleId: number;
|
||||
};
|
||||
response: {
|
||||
ok: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_TriggerBackupSchedule extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_TriggerBackupSchedule
|
||||
> {
|
||||
method: 'triggerBackupSchedule';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
scheduleId: number;
|
||||
};
|
||||
response: {
|
||||
backup: data.IBackup;
|
||||
};
|
||||
}
|
||||
73
ts_interfaces/requests/backups.ts
Normal file
73
ts_interfaces/requests/backups.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import * as plugins from '../plugins.ts';
|
||||
import * as data from '../data/index.ts';
|
||||
|
||||
export interface IReq_GetBackups extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetBackups
|
||||
> {
|
||||
method: 'getBackups';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
};
|
||||
response: {
|
||||
backups: data.IBackup[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_GetBackup extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetBackup
|
||||
> {
|
||||
method: 'getBackup';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
backupId: number;
|
||||
};
|
||||
response: {
|
||||
backup: data.IBackup;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_DeleteBackup extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_DeleteBackup
|
||||
> {
|
||||
method: 'deleteBackup';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
backupId: number;
|
||||
};
|
||||
response: {
|
||||
ok: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_RestoreBackup extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_RestoreBackup
|
||||
> {
|
||||
method: 'restoreBackup';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
backupId: number;
|
||||
options: data.IRestoreOptions;
|
||||
};
|
||||
response: {
|
||||
result: data.IRestoreResult;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_DownloadBackup extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_DownloadBackup
|
||||
> {
|
||||
method: 'downloadBackup';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
backupId: number;
|
||||
};
|
||||
response: {
|
||||
downloadUrl: string;
|
||||
filename: string;
|
||||
};
|
||||
}
|
||||
58
ts_interfaces/requests/dns.ts
Normal file
58
ts_interfaces/requests/dns.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import * as plugins from '../plugins.ts';
|
||||
import * as data from '../data/index.ts';
|
||||
|
||||
export interface IReq_GetDnsRecords extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetDnsRecords
|
||||
> {
|
||||
method: 'getDnsRecords';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
};
|
||||
response: {
|
||||
records: data.IDnsRecord[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_CreateDnsRecord extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_CreateDnsRecord
|
||||
> {
|
||||
method: 'createDnsRecord';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
domain: string;
|
||||
type: 'A' | 'AAAA' | 'CNAME';
|
||||
value: string;
|
||||
};
|
||||
response: {
|
||||
record: data.IDnsRecord;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_DeleteDnsRecord extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_DeleteDnsRecord
|
||||
> {
|
||||
method: 'deleteDnsRecord';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
domain: string;
|
||||
};
|
||||
response: {
|
||||
ok: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_SyncDns extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_SyncDns
|
||||
> {
|
||||
method: 'syncDns';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
};
|
||||
response: {
|
||||
records: data.IDnsRecord[];
|
||||
};
|
||||
}
|
||||
42
ts_interfaces/requests/domains.ts
Normal file
42
ts_interfaces/requests/domains.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import * as plugins from '../plugins.ts';
|
||||
import * as data from '../data/index.ts';
|
||||
|
||||
export interface IReq_GetDomains extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetDomains
|
||||
> {
|
||||
method: 'getDomains';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
};
|
||||
response: {
|
||||
domains: data.IDomainDetail[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_GetDomain extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetDomain
|
||||
> {
|
||||
method: 'getDomain';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
domainName: string;
|
||||
};
|
||||
response: {
|
||||
domain: data.IDomainDetail;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_SyncDomains extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_SyncDomains
|
||||
> {
|
||||
method: 'syncDomains';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
};
|
||||
response: {
|
||||
domains: data.IDomainDetail[];
|
||||
};
|
||||
}
|
||||
14
ts_interfaces/requests/index.ts
Normal file
14
ts_interfaces/requests/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export * from './admin.ts';
|
||||
export * from './status.ts';
|
||||
export * from './services.ts';
|
||||
export * from './platform-services.ts';
|
||||
export * from './ssl.ts';
|
||||
export * from './domains.ts';
|
||||
export * from './dns.ts';
|
||||
export * from './registry.ts';
|
||||
export * from './network.ts';
|
||||
export * from './backups.ts';
|
||||
export * from './backup-schedules.ts';
|
||||
export * from './settings.ts';
|
||||
export * from './logs.ts';
|
||||
export * from './workspace.ts';
|
||||
60
ts_interfaces/requests/logs.ts
Normal file
60
ts_interfaces/requests/logs.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import * as plugins from '../plugins.ts';
|
||||
import * as data from '../data/index.ts';
|
||||
|
||||
export interface IReq_GetServiceLogStream extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetServiceLogStream
|
||||
> {
|
||||
method: 'getServiceLogStream';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
serviceName: string;
|
||||
};
|
||||
response: {
|
||||
logStream: plugins.typedrequestInterfaces.IVirtualStream;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_GetPlatformServiceLogStream extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetPlatformServiceLogStream
|
||||
> {
|
||||
method: 'getPlatformServiceLogStream';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
serviceType: data.TPlatformServiceType;
|
||||
};
|
||||
response: {
|
||||
logStream: plugins.typedrequestInterfaces.IVirtualStream;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_GetNetworkLogStream extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetNetworkLogStream
|
||||
> {
|
||||
method: 'getNetworkLogStream';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
filter?: {
|
||||
domain?: string;
|
||||
sampleRate?: number;
|
||||
};
|
||||
};
|
||||
response: {
|
||||
logStream: plugins.typedrequestInterfaces.IVirtualStream;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_GetEventStream extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetEventStream
|
||||
> {
|
||||
method: 'getEventStream';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
};
|
||||
response: {
|
||||
eventStream: plugins.typedrequestInterfaces.IVirtualStream;
|
||||
};
|
||||
}
|
||||
41
ts_interfaces/requests/network.ts
Normal file
41
ts_interfaces/requests/network.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import * as plugins from '../plugins.ts';
|
||||
import * as data from '../data/index.ts';
|
||||
|
||||
export interface IReq_GetNetworkTargets extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetNetworkTargets
|
||||
> {
|
||||
method: 'getNetworkTargets';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
};
|
||||
response: {
|
||||
targets: data.INetworkTarget[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_GetNetworkStats extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetNetworkStats
|
||||
> {
|
||||
method: 'getNetworkStats';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
};
|
||||
response: {
|
||||
stats: data.INetworkStats;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_GetTrafficStats extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetTrafficStats
|
||||
> {
|
||||
method: 'getTrafficStats';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
};
|
||||
response: {
|
||||
stats: data.ITrafficStats;
|
||||
};
|
||||
}
|
||||
102
ts_interfaces/requests/platform-services.ts
Normal file
102
ts_interfaces/requests/platform-services.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import * as plugins from '../plugins.ts';
|
||||
import * as data from '../data/index.ts';
|
||||
|
||||
export interface IReq_GetPlatformServices extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetPlatformServices
|
||||
> {
|
||||
method: 'getPlatformServices';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
};
|
||||
response: {
|
||||
platformServices: data.IPlatformService[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_GetPlatformService extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetPlatformService
|
||||
> {
|
||||
method: 'getPlatformService';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
serviceType: data.TPlatformServiceType;
|
||||
};
|
||||
response: {
|
||||
platformService: data.IPlatformService;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_StartPlatformService extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_StartPlatformService
|
||||
> {
|
||||
method: 'startPlatformService';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
serviceType: data.TPlatformServiceType;
|
||||
};
|
||||
response: {
|
||||
platformService: data.IPlatformService;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_StopPlatformService extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_StopPlatformService
|
||||
> {
|
||||
method: 'stopPlatformService';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
serviceType: data.TPlatformServiceType;
|
||||
};
|
||||
response: {
|
||||
platformService: data.IPlatformService;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_GetPlatformServiceStats extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetPlatformServiceStats
|
||||
> {
|
||||
method: 'getPlatformServiceStats';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
serviceType: data.TPlatformServiceType;
|
||||
};
|
||||
response: {
|
||||
stats: data.IContainerStats;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_GetPlatformServiceLogs extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetPlatformServiceLogs
|
||||
> {
|
||||
method: 'getPlatformServiceLogs';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
serviceType: data.TPlatformServiceType;
|
||||
tail?: number;
|
||||
};
|
||||
response: {
|
||||
logs: data.ILogEntry[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_PushPlatformServiceLog extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_PushPlatformServiceLog
|
||||
> {
|
||||
method: 'pushPlatformServiceLog';
|
||||
request: {
|
||||
serviceType: data.TPlatformServiceType;
|
||||
entry: {
|
||||
timestamp: string;
|
||||
level: string;
|
||||
message: string;
|
||||
};
|
||||
};
|
||||
response: {};
|
||||
}
|
||||
57
ts_interfaces/requests/registry.ts
Normal file
57
ts_interfaces/requests/registry.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import * as plugins from '../plugins.ts';
|
||||
import * as data from '../data/index.ts';
|
||||
|
||||
export interface IReq_GetRegistryTags extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetRegistryTags
|
||||
> {
|
||||
method: 'getRegistryTags';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
serviceName: string;
|
||||
};
|
||||
response: {
|
||||
tags: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_GetRegistryTokens extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetRegistryTokens
|
||||
> {
|
||||
method: 'getRegistryTokens';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
};
|
||||
response: {
|
||||
tokens: data.IRegistryToken[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_CreateRegistryToken extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_CreateRegistryToken
|
||||
> {
|
||||
method: 'createRegistryToken';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
tokenConfig: data.ICreateTokenRequest;
|
||||
};
|
||||
response: {
|
||||
result: data.ITokenCreatedResponse;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_DeleteRegistryToken extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_DeleteRegistryToken
|
||||
> {
|
||||
method: 'deleteRegistryToken';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
tokenId: number;
|
||||
};
|
||||
response: {
|
||||
ok: boolean;
|
||||
};
|
||||
}
|
||||
230
ts_interfaces/requests/services.ts
Normal file
230
ts_interfaces/requests/services.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
import * as plugins from '../plugins.ts';
|
||||
import * as data from '../data/index.ts';
|
||||
|
||||
export interface IReq_GetServices extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetServices
|
||||
> {
|
||||
method: 'getServices';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
};
|
||||
response: {
|
||||
services: data.IService[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_GetService extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetService
|
||||
> {
|
||||
method: 'getService';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
serviceName: string;
|
||||
};
|
||||
response: {
|
||||
service: data.IService;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_CreateService extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_CreateService
|
||||
> {
|
||||
method: 'createService';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
serviceConfig: data.IServiceCreate;
|
||||
};
|
||||
response: {
|
||||
service: data.IService;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_UpdateService extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_UpdateService
|
||||
> {
|
||||
method: 'updateService';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
serviceName: string;
|
||||
updates: data.IServiceUpdate;
|
||||
};
|
||||
response: {
|
||||
service: data.IService;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_DeleteService extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_DeleteService
|
||||
> {
|
||||
method: 'deleteService';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
serviceName: string;
|
||||
};
|
||||
response: {
|
||||
ok: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_StartService extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_StartService
|
||||
> {
|
||||
method: 'startService';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
serviceName: string;
|
||||
};
|
||||
response: {
|
||||
service: data.IService;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_StopService extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_StopService
|
||||
> {
|
||||
method: 'stopService';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
serviceName: string;
|
||||
};
|
||||
response: {
|
||||
service: data.IService;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_RestartService extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_RestartService
|
||||
> {
|
||||
method: 'restartService';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
serviceName: string;
|
||||
};
|
||||
response: {
|
||||
service: data.IService;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_GetServiceLogs extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetServiceLogs
|
||||
> {
|
||||
method: 'getServiceLogs';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
serviceName: string;
|
||||
tail?: number;
|
||||
};
|
||||
response: {
|
||||
logs: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_GetServiceStats extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetServiceStats
|
||||
> {
|
||||
method: 'getServiceStats';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
serviceName: string;
|
||||
};
|
||||
response: {
|
||||
stats: data.IContainerStats;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_GetServiceMetrics extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetServiceMetrics
|
||||
> {
|
||||
method: 'getServiceMetrics';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
serviceName: string;
|
||||
limit?: number;
|
||||
};
|
||||
response: {
|
||||
metrics: data.IMetric[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_GetServicePlatformResources extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetServicePlatformResources
|
||||
> {
|
||||
method: 'getServicePlatformResources';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
serviceName: string;
|
||||
};
|
||||
response: {
|
||||
resources: data.IPlatformResource[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_GetServiceBackups extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetServiceBackups
|
||||
> {
|
||||
method: 'getServiceBackups';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
serviceName: string;
|
||||
};
|
||||
response: {
|
||||
backups: data.IBackup[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_CreateServiceBackup extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_CreateServiceBackup
|
||||
> {
|
||||
method: 'createServiceBackup';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
serviceName: string;
|
||||
};
|
||||
response: {
|
||||
backup: data.IBackup;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_GetServiceBackupSchedules extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetServiceBackupSchedules
|
||||
> {
|
||||
method: 'getServiceBackupSchedules';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
serviceName: string;
|
||||
};
|
||||
response: {
|
||||
schedules: data.IBackupSchedule[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_PushServiceLog extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_PushServiceLog
|
||||
> {
|
||||
method: 'pushServiceLog';
|
||||
request: {
|
||||
serviceName: string;
|
||||
entry: {
|
||||
timestamp: string;
|
||||
level: string;
|
||||
message: string;
|
||||
};
|
||||
};
|
||||
response: {};
|
||||
}
|
||||
56
ts_interfaces/requests/settings.ts
Normal file
56
ts_interfaces/requests/settings.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import * as plugins from '../plugins.ts';
|
||||
import * as data from '../data/index.ts';
|
||||
|
||||
export interface IReq_GetSettings extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetSettings
|
||||
> {
|
||||
method: 'getSettings';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
};
|
||||
response: {
|
||||
settings: data.ISettings;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_UpdateSettings extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_UpdateSettings
|
||||
> {
|
||||
method: 'updateSettings';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
settings: Partial<data.ISettings>;
|
||||
};
|
||||
response: {
|
||||
settings: data.ISettings;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_SetBackupPassword extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_SetBackupPassword
|
||||
> {
|
||||
method: 'setBackupPassword';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
password: string;
|
||||
};
|
||||
response: {
|
||||
ok: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_GetBackupPasswordStatus extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetBackupPasswordStatus
|
||||
> {
|
||||
method: 'getBackupPasswordStatus';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
};
|
||||
response: {
|
||||
status: data.IBackupPasswordStatus;
|
||||
};
|
||||
}
|
||||
57
ts_interfaces/requests/ssl.ts
Normal file
57
ts_interfaces/requests/ssl.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import * as plugins from '../plugins.ts';
|
||||
import * as data from '../data/index.ts';
|
||||
|
||||
export interface IReq_ObtainCertificate extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_ObtainCertificate
|
||||
> {
|
||||
method: 'obtainCertificate';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
domain: string;
|
||||
};
|
||||
response: {
|
||||
certificate: data.ICertificate;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_ListCertificates extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_ListCertificates
|
||||
> {
|
||||
method: 'listCertificates';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
};
|
||||
response: {
|
||||
certificates: data.ICertificate[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_GetCertificate extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetCertificate
|
||||
> {
|
||||
method: 'getCertificate';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
domain: string;
|
||||
};
|
||||
response: {
|
||||
certificate: data.ICertificate;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_RenewCertificate extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_RenewCertificate
|
||||
> {
|
||||
method: 'renewCertificate';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
domain: string;
|
||||
};
|
||||
response: {
|
||||
certificate: data.ICertificate;
|
||||
};
|
||||
}
|
||||
15
ts_interfaces/requests/status.ts
Normal file
15
ts_interfaces/requests/status.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import * as plugins from '../plugins.ts';
|
||||
import * as data from '../data/index.ts';
|
||||
|
||||
export interface IReq_GetSystemStatus extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetSystemStatus
|
||||
> {
|
||||
method: 'getSystemStatus';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
};
|
||||
response: {
|
||||
status: data.ISystemStatus;
|
||||
};
|
||||
}
|
||||
106
ts_interfaces/requests/workspace.ts
Normal file
106
ts_interfaces/requests/workspace.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import * as plugins from '../plugins.ts';
|
||||
import * as data from '../data/index.ts';
|
||||
|
||||
export interface IReq_WorkspaceReadFile extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_WorkspaceReadFile
|
||||
> {
|
||||
method: 'workspaceReadFile';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
serviceName: string;
|
||||
path: string;
|
||||
};
|
||||
response: {
|
||||
content: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_WorkspaceWriteFile extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_WorkspaceWriteFile
|
||||
> {
|
||||
method: 'workspaceWriteFile';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
serviceName: string;
|
||||
path: string;
|
||||
content: string;
|
||||
};
|
||||
response: {};
|
||||
}
|
||||
|
||||
export interface IReq_WorkspaceReadDir extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_WorkspaceReadDir
|
||||
> {
|
||||
method: 'workspaceReadDir';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
serviceName: string;
|
||||
path: string;
|
||||
};
|
||||
response: {
|
||||
entries: Array<{ type: 'file' | 'directory'; name: string; path: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_WorkspaceMkdir extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_WorkspaceMkdir
|
||||
> {
|
||||
method: 'workspaceMkdir';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
serviceName: string;
|
||||
path: string;
|
||||
};
|
||||
response: {};
|
||||
}
|
||||
|
||||
export interface IReq_WorkspaceRm extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_WorkspaceRm
|
||||
> {
|
||||
method: 'workspaceRm';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
serviceName: string;
|
||||
path: string;
|
||||
recursive?: boolean;
|
||||
};
|
||||
response: {};
|
||||
}
|
||||
|
||||
export interface IReq_WorkspaceExists extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_WorkspaceExists
|
||||
> {
|
||||
method: 'workspaceExists';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
serviceName: string;
|
||||
path: string;
|
||||
};
|
||||
response: {
|
||||
exists: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_WorkspaceExec extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_WorkspaceExec
|
||||
> {
|
||||
method: 'workspaceExec';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
serviceName: string;
|
||||
command: string;
|
||||
args?: string[];
|
||||
};
|
||||
response: {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number;
|
||||
};
|
||||
}
|
||||
8
ts_web/00_commitinfo_data.ts
Normal file
8
ts_web/00_commitinfo_data.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* autocreated commitinfo by @push.rocks/commitinfo
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/onebox',
|
||||
version: '1.22.0',
|
||||
description: 'Self-hosted container platform with automatic SSL and DNS - a mini Heroku for single servers'
|
||||
}
|
||||
1065
ts_web/appstate.ts
Normal file
1065
ts_web/appstate.ts
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user