mirror of
https://github.com/Oak-and-Sprout/sprout-track.git
synced 2026-05-07 23:49:52 -05:00
added base st-guardian package
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
# dependencies
|
||||
/node_modules
|
||||
/st-guardian/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
@@ -34,6 +35,8 @@ next-env.d.ts
|
||||
|
||||
*.db
|
||||
/db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
|
||||
# custom port configuration
|
||||
scripts/port-config.sh
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { withSysAdminAuth } from '../../utils/auth';
|
||||
|
||||
interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const getGuardianUrl = () => {
|
||||
const port = process.env.ST_GUARDIAN_PORT || '3001';
|
||||
return `http://127.0.0.1:${port}`;
|
||||
};
|
||||
|
||||
const getGuardianKey = () => {
|
||||
return process.env.ST_GUARDIAN_KEY || '';
|
||||
};
|
||||
|
||||
async function getHandler(req: NextRequest): Promise<NextResponse<ApiResponse<any>>> {
|
||||
const key = getGuardianKey();
|
||||
if (!key) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'ST_GUARDIAN_KEY not configured in environment' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
const url = new URL(req.url);
|
||||
const endpoint = url.searchParams.get('endpoint') || 'status';
|
||||
|
||||
const guardianUrl = getGuardianUrl();
|
||||
let targetPath: string;
|
||||
|
||||
switch (endpoint) {
|
||||
case 'status':
|
||||
targetPath = '/status';
|
||||
break;
|
||||
case 'update-status':
|
||||
targetPath = '/update/status';
|
||||
break;
|
||||
case 'version':
|
||||
targetPath = '/version';
|
||||
break;
|
||||
default:
|
||||
targetPath = '/status';
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${guardianUrl}${targetPath}`, {
|
||||
headers: { 'X-Guardian-Key': key },
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
return NextResponse.json({ success: true, data });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Unable to connect to ST-Guardian service' },
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function postHandler(req: NextRequest): Promise<NextResponse<ApiResponse<any>>> {
|
||||
const key = getGuardianKey();
|
||||
if (!key) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'ST_GUARDIAN_KEY not configured in environment' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
const guardianUrl = getGuardianUrl();
|
||||
|
||||
try {
|
||||
const response = await fetch(`${guardianUrl}/update`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Guardian-Key': key,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.status === 409) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'An update is already in progress' },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: data.error || 'Update request failed' },
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, data });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Unable to connect to ST-Guardian service' },
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const GET = withSysAdminAuth(getHandler);
|
||||
export const POST = withSysAdminAuth(postHandler);
|
||||
@@ -0,0 +1,149 @@
|
||||
# ST-Guardian: Sprout Track Service Manager
|
||||
|
||||
Build a lightweight Node.js sidecar service called `st-guardian` for the Sprout Track application (Next.js). It acts as a reverse proxy, health monitor, maintenance page server, and update orchestrator.
|
||||
|
||||
## Architecture
|
||||
|
||||
- st-guardian runs on port 3001 (configurable via `ST_GUARDIAN_PORT`)
|
||||
- Next.js (Sprout Track) runs on its default port 3000 (configurable via `ST_APP_PORT`)
|
||||
- st-guardian sits in front of the app and proxies all non-management requests to Next.js
|
||||
- In maintenance mode, st-guardian stops proxying and serves a static maintenance page instead
|
||||
- Docker-aware: detects `/.dockerenv` and disables update/restart functionality, acts as passthrough proxy only
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Keep dependencies minimal. Use `http-proxy` for proxying. No frameworks. Single entry point file if possible.
|
||||
|
||||
## Management Routes
|
||||
|
||||
These routes are intercepted by st-guardian before reaching the proxy. All management routes (except `/status` and `/health`) require authentication via `ST_GUARDIAN_KEY` env var, passed as `?key=` query param or `X-Guardian-Key` header.
|
||||
|
||||
### `GET /status`
|
||||
- Public endpoint
|
||||
- Returns JSON: app status (up/down/maintenance), current version (read from package.json), uptime, last update timestamp, guardian version
|
||||
|
||||
### `GET /health`
|
||||
- Public endpoint
|
||||
- Returns 200 if app is up and proxying, 503 if in maintenance mode or app is down
|
||||
- Designed for external uptime monitors
|
||||
|
||||
### `POST /maintenance`
|
||||
- Authenticated
|
||||
- Enables maintenance mode: stops proxying, serves maintenance page
|
||||
- Optional JSON body: `{ "message": "Custom maintenance message" }`
|
||||
|
||||
### `DELETE /maintenance`
|
||||
- Authenticated
|
||||
- Disables maintenance mode: resumes proxying to Next.js
|
||||
|
||||
### `POST /update`
|
||||
- Authenticated
|
||||
- Triggers the full update cycle by calling the existing deployment script at `scripts/deployment.sh`
|
||||
- The deployment script already handles:
|
||||
1. Creating a backup (`scripts/backup.sh`)
|
||||
2. Stopping the service (`scripts/service.sh stop`)
|
||||
3. Deleting the `.next` folder
|
||||
4. Updating environment configuration (`scripts/env-update.sh`)
|
||||
5. Updating the application (`scripts/update.sh`)
|
||||
6. Rollback behavior: if any step fails, it attempts to restart the service before aborting
|
||||
- Guardian's responsibilities around the deployment script:
|
||||
1. Enable maintenance mode before calling the script
|
||||
2. Shell out to `scripts/deployment.sh` and capture stdout/stderr in real time
|
||||
3. When the script completes, health check the app (poll until responsive or timeout)
|
||||
4. If healthy, disable maintenance mode
|
||||
5. If unhealthy or script exited non-zero, stay in maintenance mode and log the error
|
||||
- Optional JSON body: `{ "ref": "v2.1.0" }` to checkout a specific ref before running deployment
|
||||
- Returns JSON with job status. Should support polling via `GET /update/status`
|
||||
|
||||
### `GET /update/status`
|
||||
- Authenticated
|
||||
- Returns current/last update job progress: step, status, logs, started_at, completed_at
|
||||
|
||||
### `GET /logs`
|
||||
- Authenticated
|
||||
- Returns recent stdout/stderr from the Next.js process
|
||||
- Query param `?lines=100` to control how many lines (default 100)
|
||||
|
||||
## Maintenance Page
|
||||
|
||||
- Static HTML embedded in the script, no external dependencies
|
||||
- Sprout Track branded: use the teal/green color scheme
|
||||
- Shows a spinner or animation and message: "Sprout Track is under maintenance and will be back shortly."
|
||||
- Support custom messages passed via the `POST /maintenance` body
|
||||
- Auto-refreshes every 15 seconds, redirects back to the app when maintenance mode ends (poll `/health`)
|
||||
- Must be lightweight and work without any assets from the Next.js app
|
||||
|
||||
## Health Monitoring
|
||||
|
||||
- Periodically ping Next.js (configurable interval via `ST_HEALTH_INTERVAL`, default 30 seconds)
|
||||
- If Next.js is unresponsive for 3 consecutive checks, attempt automatic restart
|
||||
- Log health events
|
||||
- If auto-restart fails, flip to maintenance mode and log an error
|
||||
|
||||
## Process Management
|
||||
|
||||
- st-guardian uses the existing `scripts/service.sh` for process management (start/stop/status)
|
||||
- Captures stdout/stderr from Next.js for the `/logs` endpoint
|
||||
- Handles graceful shutdown: SIGTERM/SIGINT should call `scripts/service.sh stop` then exit guardian
|
||||
- On Next.js crash, attempt restart via `scripts/service.sh start` up to 3 times within 5 minutes before giving up and entering maintenance mode
|
||||
|
||||
## Docker Awareness
|
||||
|
||||
- On startup, check for `/.dockerenv` file
|
||||
- If detected, disable: `/update`, `/logs`, process management, auto-restart
|
||||
- In Docker mode, guardian is proxy + maintenance page only
|
||||
- Log a message on startup indicating which mode it's running in
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `ST_GUARDIAN_PORT` | 3001 | Port guardian listens on |
|
||||
| `ST_APP_PORT` | 3000 | Port Next.js runs on |
|
||||
| `ST_GUARDIAN_KEY` | (required for mgmt routes) | Auth key for management endpoints |
|
||||
| `ST_HEALTH_INTERVAL` | 30000 | Health check interval in ms |
|
||||
| `ST_SCRIPTS_DIR` | `./scripts` | Path to Sprout Track scripts directory (deployment.sh, service.sh, backup.sh, etc.) |
|
||||
| `ST_APP_DIR` | `.` | Working directory for the Next.js app |
|
||||
| `ST_LOG_BUFFER` | 500 | Number of log lines to retain in memory |
|
||||
|
||||
## Logging
|
||||
|
||||
- Log to stdout with timestamps
|
||||
- Prefix logs with `[guardian]` to distinguish from Next.js output
|
||||
- Log: startup mode (docker/standalone), proxy events, health check failures, maintenance mode changes, update steps
|
||||
|
||||
## File Structure
|
||||
|
||||
Keep it simple. Suggested:
|
||||
|
||||
```
|
||||
st-guardian/
|
||||
index.js # Main entry point
|
||||
maintenance.html # Maintenance page template (or embedded string)
|
||||
st-guardian-setup.sh # Service installer script
|
||||
package.json
|
||||
README.md
|
||||
```
|
||||
|
||||
## Setup Script (`st-guardian-setup.sh`)
|
||||
|
||||
Create an interactive setup script that installs st-guardian as a systemd service. The script should:
|
||||
|
||||
1. Check that it's running as root/sudo
|
||||
2. Prompt for configuration:
|
||||
- `ST_GUARDIAN_KEY` (generate a random one if user hits enter)
|
||||
- `ST_APP_DIR` (path to the Sprout Track installation, validate it exists)
|
||||
- `ST_GUARDIAN_PORT` (default 3001)
|
||||
- `ST_APP_PORT` (default 3000)
|
||||
3. Detect Docker environment and warn if detected (guardian service doesn't make sense in Docker)
|
||||
4. Install npm dependencies (`npm install --production`)
|
||||
5. Create a systemd unit file at `/etc/systemd/system/st-guardian.service`:
|
||||
- Set `WorkingDirectory` to the guardian directory
|
||||
- Set all `ST_*` environment variables
|
||||
- Set `Restart=always` with `RestartSec=5`
|
||||
- Run as the current non-root user (not root)
|
||||
- Set `After=network.target`
|
||||
6. Reload systemd, enable the service, and start it
|
||||
7. Print a summary: service status, management URL, the guardian key, and a reminder to update Nginx to point at the guardian port
|
||||
|
||||
The script should also support an `--uninstall` flag that stops the service, disables it, removes the unit file, and reloads systemd.
|
||||
@@ -0,0 +1,293 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/src/components/ui/button';
|
||||
import { Label } from '@/src/components/ui/label';
|
||||
import { RefreshCw, Loader2, CheckCircle, XCircle, AlertTriangle, ArrowUpCircle } from 'lucide-react';
|
||||
import { useLocalization } from '@/src/context/localization';
|
||||
|
||||
interface GuardianUpdateProps {
|
||||
isLoading: boolean;
|
||||
isSaving: boolean;
|
||||
onError: (error: string) => void;
|
||||
}
|
||||
|
||||
interface VersionInfo {
|
||||
currentVersion: string;
|
||||
latestVersion: string | null;
|
||||
updateAvailable: boolean | null;
|
||||
repository: string;
|
||||
}
|
||||
|
||||
export function GuardianUpdate({ isLoading, isSaving, onError }: GuardianUpdateProps) {
|
||||
const { t } = useLocalization();
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [updating, setUpdating] = useState(false);
|
||||
const [guardianReachable, setGuardianReachable] = useState<boolean | null>(null);
|
||||
const [versionInfo, setVersionInfo] = useState<VersionInfo | null>(null);
|
||||
const [dockerMode, setDockerMode] = useState(false);
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
|
||||
const getAuthHeaders = (): HeadersInit => {
|
||||
const authToken = localStorage.getItem('authToken');
|
||||
return authToken ? { 'Authorization': `Bearer ${authToken}` } : {};
|
||||
};
|
||||
|
||||
const checkForUpdates = async () => {
|
||||
try {
|
||||
setChecking(true);
|
||||
setShowConfirm(false);
|
||||
|
||||
// Check guardian status and version in parallel
|
||||
const [statusRes, versionRes] = await Promise.all([
|
||||
fetch('/api/guardian/update?endpoint=status', { headers: getAuthHeaders() }),
|
||||
fetch('/api/guardian/update?endpoint=version', { headers: getAuthHeaders() }),
|
||||
]);
|
||||
|
||||
const statusResult = await statusRes.json();
|
||||
const versionResult = await versionRes.json();
|
||||
|
||||
if (!statusResult.success) {
|
||||
setGuardianReachable(false);
|
||||
if (statusResult.error) onError(statusResult.error);
|
||||
return;
|
||||
}
|
||||
|
||||
setGuardianReachable(true);
|
||||
setDockerMode(statusResult.data?.dockerMode || false);
|
||||
|
||||
if (versionResult.success && versionResult.data) {
|
||||
setVersionInfo(versionResult.data);
|
||||
} else {
|
||||
// Guardian reachable but version check failed (maybe GitHub rate limited)
|
||||
setVersionInfo({
|
||||
currentVersion: statusResult.data?.version || 'unknown',
|
||||
latestVersion: null,
|
||||
updateAvailable: null,
|
||||
repository: 'https://github.com/Oak-and-Sprout/sprout-track',
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
setGuardianReachable(false);
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const triggerUpdate = async () => {
|
||||
try {
|
||||
setUpdating(true);
|
||||
setShowConfirm(false);
|
||||
|
||||
const response = await fetch('/api/guardian/update', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
setTimeout(() => {
|
||||
window.location.href = '/';
|
||||
}, 1500);
|
||||
} else {
|
||||
onError(result.error || t('Failed to trigger update'));
|
||||
setUpdating(false);
|
||||
}
|
||||
} catch {
|
||||
onError(t('Failed to connect to update service'));
|
||||
setUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<RefreshCw className="h-5 w-5 text-teal-600" />
|
||||
<Label className="text-lg font-semibold">
|
||||
{t('System Updates')}
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 p-4 border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||
{/* Initial state — not checked yet */}
|
||||
{guardianReachable === null ? (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{t('Check for available updates from GitHub')}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={checkForUpdates}
|
||||
disabled={checking || isLoading || isSaving}
|
||||
>
|
||||
{checking ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
{t('Checking...')}
|
||||
</>
|
||||
) : (
|
||||
t('Check for Updates')
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
) : guardianReachable ? (
|
||||
<>
|
||||
{/* Version info */}
|
||||
{versionInfo && (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div className="text-gray-500 dark:text-gray-400">{t('Installed Version')}</div>
|
||||
<div className="font-medium">v{versionInfo.currentVersion}</div>
|
||||
<div className="text-gray-500 dark:text-gray-400">{t('Latest Version')}</div>
|
||||
<div className="font-medium">
|
||||
{versionInfo.latestVersion
|
||||
? `v${versionInfo.latestVersion}`
|
||||
: t('Unable to check')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Update available banner */}
|
||||
{versionInfo.updateAvailable === true && (
|
||||
<div className="flex items-center space-x-2 p-3 bg-teal-50 dark:bg-teal-900/20 border border-teal-200 dark:border-teal-800 rounded-lg">
|
||||
<ArrowUpCircle className="h-5 w-5 text-teal-600 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-teal-800 dark:text-teal-300">
|
||||
{t('Update available!')}
|
||||
</p>
|
||||
<p className="text-xs text-teal-700 dark:text-teal-400">
|
||||
v{versionInfo.currentVersion} → v{versionInfo.latestVersion}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Already up to date */}
|
||||
{versionInfo.updateAvailable === false && (
|
||||
<div className="flex items-center space-x-2 p-3 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg">
|
||||
<CheckCircle className="h-5 w-5 text-green-600 flex-shrink-0" />
|
||||
<p className="text-sm font-medium text-green-800 dark:text-green-300">
|
||||
{t('You are running the latest version')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Could not check GitHub */}
|
||||
{versionInfo.updateAvailable === null && versionInfo.latestVersion === null && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{t('Could not reach GitHub to check for updates. You can still trigger a manual update.')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Docker mode warning */}
|
||||
{dockerMode ? (
|
||||
<p className="text-xs text-yellow-600 dark:text-yellow-400 bg-yellow-50 dark:bg-yellow-900/20 p-3 rounded-md">
|
||||
{t('Updates are managed by Docker and cannot be triggered from the admin panel.')}
|
||||
</p>
|
||||
) : !showConfirm ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className={`flex-1 ${
|
||||
versionInfo?.updateAvailable
|
||||
? 'border-teal-300 text-teal-700 hover:bg-teal-50 dark:border-teal-700 dark:text-teal-400 dark:hover:bg-teal-900/20'
|
||||
: 'border-orange-300 text-orange-700 hover:bg-orange-50 dark:border-orange-700 dark:text-orange-400 dark:hover:bg-orange-900/20'
|
||||
}`}
|
||||
onClick={() => setShowConfirm(true)}
|
||||
disabled={updating || isLoading || isSaving}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
{versionInfo?.updateAvailable ? t('Update Now') : t('Force Update')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={checkForUpdates}
|
||||
disabled={checking}
|
||||
>
|
||||
<RefreshCw className={`h-3 w-3 ${checking ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
/* Confirmation dialog */
|
||||
<div className="space-y-3 p-3 bg-orange-50 dark:bg-orange-900/20 border border-orange-200 dark:border-orange-800 rounded-lg">
|
||||
<div className="flex items-start space-x-2">
|
||||
<AlertTriangle className="h-5 w-5 text-orange-500 flex-shrink-0 mt-0.5" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-orange-800 dark:text-orange-300">
|
||||
{t('Are you sure?')}
|
||||
</p>
|
||||
<p className="text-xs text-orange-700 dark:text-orange-400">
|
||||
{t('This will restart the application. All users will be temporarily disconnected while the update is applied.')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
onClick={() => setShowConfirm(false)}
|
||||
disabled={updating}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="flex-1 bg-orange-600 text-white hover:bg-orange-700"
|
||||
onClick={triggerUpdate}
|
||||
disabled={updating}
|
||||
>
|
||||
{updating ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
{t('Updating...')}
|
||||
</>
|
||||
) : (
|
||||
t('Confirm Update')
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{updating && (
|
||||
<p className="text-sm text-teal-600 dark:text-teal-400 text-center animate-pulse">
|
||||
{t('Update triggered. Redirecting to maintenance page...')}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
/* Guardian not reachable */
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<XCircle className="h-4 w-4 text-gray-400" />
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('Update service not available')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{t('ST-Guardian is not running or not configured. Updates must be performed manually via the command line.')}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={checkForUpdates}
|
||||
disabled={checking}
|
||||
>
|
||||
{t('Retry')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
# ST-Guardian
|
||||
|
||||
Lightweight Node.js sidecar service for Sprout Track. Acts as a reverse proxy, health monitor, maintenance page server, and update orchestrator.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Systemd Installation (recommended for production)
|
||||
|
||||
```bash
|
||||
sudo bash st-guardian-setup.sh
|
||||
```
|
||||
|
||||
The setup script will prompt for configuration and install st-guardian as a systemd service.
|
||||
|
||||
### Manual / Development
|
||||
|
||||
```bash
|
||||
cd st-guardian
|
||||
npm install
|
||||
ST_GUARDIAN_KEY=your-secret-key ST_APP_DIR=/path/to/sprout-track node index.js
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
- Listens on port 3001 (configurable) and proxies all requests to the Next.js app on port 3000
|
||||
- Management routes (`/status`, `/health`, `/maintenance`, `/update`, `/logs`) are intercepted before reaching the proxy
|
||||
- In maintenance mode, a branded status page is served instead of proxying
|
||||
- Health checks ping the app periodically; if it goes down, guardian attempts automatic restart
|
||||
- Docker-aware: when running in Docker, update/restart/log features are disabled (proxy + maintenance only)
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `ST_GUARDIAN_PORT` | `3001` | Port guardian listens on |
|
||||
| `ST_APP_PORT` | `3000` | Port the Next.js app runs on |
|
||||
| `ST_GUARDIAN_KEY` | *(required)* | API key for authenticated management endpoints |
|
||||
| `ST_HEALTH_INTERVAL` | `30000` | Health check interval in milliseconds |
|
||||
| `ST_SCRIPTS_DIR` | `./scripts` | Path to Sprout Track scripts directory |
|
||||
| `ST_APP_DIR` | `.` | Path to the Sprout Track installation |
|
||||
| `ST_LOG_BUFFER` | `500` | Max log lines retained for `/logs` and update output |
|
||||
|
||||
## API Reference
|
||||
|
||||
### Public Endpoints
|
||||
|
||||
**GET /status**
|
||||
```bash
|
||||
curl http://localhost:3001/status
|
||||
```
|
||||
Returns: `{ "status": "up|down|maintenance", "version": "...", "guardianVersion": "1.0.0", "uptime": 3600, "dockerMode": false }`
|
||||
|
||||
**GET /health**
|
||||
```bash
|
||||
curl http://localhost:3001/health
|
||||
```
|
||||
Returns 200 `{"status":"healthy"}` or 503 `{"status":"unhealthy"}`
|
||||
|
||||
### Authenticated Endpoints
|
||||
|
||||
Pass the key as a query parameter or header:
|
||||
|
||||
```bash
|
||||
# Query parameter
|
||||
curl -X POST "http://localhost:3001/maintenance?key=YOUR_KEY"
|
||||
|
||||
# Header
|
||||
curl -X POST http://localhost:3001/maintenance -H "X-Guardian-Key: YOUR_KEY"
|
||||
```
|
||||
|
||||
**POST /maintenance** — Enable maintenance mode
|
||||
```bash
|
||||
curl -X POST "http://localhost:3001/maintenance?key=KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"message": "Updating to v2.0..."}'
|
||||
```
|
||||
|
||||
**DELETE /maintenance** — Disable maintenance mode
|
||||
```bash
|
||||
curl -X DELETE "http://localhost:3001/maintenance?key=KEY"
|
||||
```
|
||||
|
||||
**POST /update** — Trigger deployment
|
||||
```bash
|
||||
curl -X POST "http://localhost:3001/update?key=KEY"
|
||||
```
|
||||
Returns 202 with `{"pollUrl": "/update/status"}`. Automatically enters maintenance mode, runs `deployment.sh`, and exits maintenance when the app is healthy.
|
||||
|
||||
**GET /update/status** — Check update progress
|
||||
```bash
|
||||
curl "http://localhost:3001/update/status?key=KEY"
|
||||
```
|
||||
|
||||
**GET /logs** — Read application logs
|
||||
```bash
|
||||
curl "http://localhost:3001/logs?key=KEY&lines=50"
|
||||
```
|
||||
|
||||
## Docker Mode
|
||||
|
||||
When `/.dockerenv` is detected, the following endpoints return 403:
|
||||
- `POST /update`
|
||||
- `GET /logs`
|
||||
|
||||
Auto-restart and process management are also disabled. Guardian operates as a proxy and maintenance page server only.
|
||||
|
||||
## Uninstall
|
||||
|
||||
```bash
|
||||
sudo bash st-guardian-setup.sh --uninstall
|
||||
```
|
||||
|
||||
This stops the service, removes the systemd unit, and cleans up the sudoers configuration.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **"ST_GUARDIAN_KEY not configured"** — Set the `ST_GUARDIAN_KEY` environment variable
|
||||
- **"SERVICE_NAME not found"** — Ensure the Sprout Track `.env` file has `SERVICE_NAME="your-service"`
|
||||
- **Logs endpoint returns errors** — The user running guardian needs to be in the `systemd-journal` group
|
||||
- **Service management fails** — Check that `/etc/sudoers.d/st-guardian` exists and grants the correct permissions
|
||||
File diff suppressed because it is too large
Load Diff
Generated
+511
@@ -0,0 +1,511 @@
|
||||
{
|
||||
"name": "st-guardian",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "st-guardian",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^11.0.0",
|
||||
"http-proxy": "^1.18.1"
|
||||
}
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/better-sqlite3": {
|
||||
"version": "11.10.0",
|
||||
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz",
|
||||
"integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bindings": "^1.5.0",
|
||||
"prebuild-install": "^7.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/bindings": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
|
||||
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"file-uri-to-path": "1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bl": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
|
||||
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer": "^5.5.0",
|
||||
"inherits": "^2.0.4",
|
||||
"readable-stream": "^3.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer": {
|
||||
"version": "5.7.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
|
||||
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.1",
|
||||
"ieee754": "^1.1.13"
|
||||
}
|
||||
},
|
||||
"node_modules/chownr": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
|
||||
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/decompress-response": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
||||
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mimic-response": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/deep-extend": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
|
||||
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/end-of-stream": {
|
||||
"version": "1.4.5",
|
||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
||||
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"once": "^1.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eventemitter3": {
|
||||
"version": "4.0.7",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
|
||||
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/expand-template": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
|
||||
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
|
||||
"license": "(MIT OR WTFPL)",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/file-uri-to-path": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
|
||||
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.11",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"debug": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fs-constants": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
|
||||
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/github-from-package": {
|
||||
"version": "0.0.0",
|
||||
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
|
||||
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/http-proxy": {
|
||||
"version": "1.18.1",
|
||||
"resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz",
|
||||
"integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"eventemitter3": "^4.0.0",
|
||||
"follow-redirects": "^1.0.0",
|
||||
"requires-port": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ieee754": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ini": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
|
||||
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/mimic-response": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
|
||||
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/mkdirp-classic": {
|
||||
"version": "0.5.3",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
|
||||
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/napi-build-utils": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
|
||||
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-abi": {
|
||||
"version": "3.88.0",
|
||||
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.88.0.tgz",
|
||||
"integrity": "sha512-At6b4UqIEVudaqPsXjmUO1r/N5BUr4yhDGs5PkBE8/oG5+TfLPhFechiskFsnT6Ql0VfUXbalUUCbfXxtj7K+w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"semver": "^7.3.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/once": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/prebuild-install": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
|
||||
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
|
||||
"deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.0",
|
||||
"expand-template": "^2.0.3",
|
||||
"github-from-package": "0.0.0",
|
||||
"minimist": "^1.2.3",
|
||||
"mkdirp-classic": "^0.5.3",
|
||||
"napi-build-utils": "^2.0.0",
|
||||
"node-abi": "^3.3.0",
|
||||
"pump": "^3.0.0",
|
||||
"rc": "^1.2.7",
|
||||
"simple-get": "^4.0.0",
|
||||
"tar-fs": "^2.0.0",
|
||||
"tunnel-agent": "^0.6.0"
|
||||
},
|
||||
"bin": {
|
||||
"prebuild-install": "bin.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/pump": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
|
||||
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"end-of-stream": "^1.1.0",
|
||||
"once": "^1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/rc": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
|
||||
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
|
||||
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
|
||||
"dependencies": {
|
||||
"deep-extend": "^0.6.0",
|
||||
"ini": "~1.3.0",
|
||||
"minimist": "^1.2.0",
|
||||
"strip-json-comments": "~2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"rc": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "3.6.2",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
"string_decoder": "^1.1.1",
|
||||
"util-deprecate": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/requires-port": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
|
||||
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.7.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/simple-concat": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
|
||||
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/simple-get": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
|
||||
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"decompress-response": "^6.0.0",
|
||||
"once": "^1.3.1",
|
||||
"simple-concat": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-json-comments": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
|
||||
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tar-fs": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
|
||||
"integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chownr": "^1.1.1",
|
||||
"mkdirp-classic": "^0.5.2",
|
||||
"pump": "^3.0.0",
|
||||
"tar-stream": "^2.1.4"
|
||||
}
|
||||
},
|
||||
"node_modules/tar-stream": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
|
||||
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bl": "^4.0.3",
|
||||
"end-of-stream": "^1.4.1",
|
||||
"fs-constants": "^1.0.0",
|
||||
"inherits": "^2.0.3",
|
||||
"readable-stream": "^3.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/tunnel-agent": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
|
||||
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/wrappy": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"license": "ISC"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "st-guardian",
|
||||
"version": "1.0.0",
|
||||
"description": "Lightweight reverse proxy, health monitor, and update orchestrator for Sprout Track",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"start": "node index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^11.0.0",
|
||||
"http-proxy": "^1.18.1"
|
||||
}
|
||||
}
|
||||
Executable
+240
@@ -0,0 +1,240 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ST-Guardian Setup Script
|
||||
# Installs st-guardian as a systemd service for Sprout Track
|
||||
|
||||
set -e
|
||||
|
||||
GUARDIAN_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
TEAL='\033[0;36m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
print_header() {
|
||||
echo -e "${TEAL}"
|
||||
echo "╔══════════════════════════════════════╗"
|
||||
echo "║ ST-Guardian Setup ║"
|
||||
echo "║ Sprout Track Service Manager ║"
|
||||
echo "╚══════════════════════════════════════╝"
|
||||
echo -e "${NC}"
|
||||
}
|
||||
|
||||
# --uninstall flag
|
||||
if [ "$1" = "--uninstall" ]; then
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
echo -e "${RED}Error: Please run with sudo${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${YELLOW}Uninstalling st-guardian...${NC}"
|
||||
|
||||
if systemctl is-active --quiet st-guardian 2>/dev/null; then
|
||||
echo "Stopping st-guardian service..."
|
||||
systemctl stop st-guardian
|
||||
fi
|
||||
|
||||
if systemctl is-enabled --quiet st-guardian 2>/dev/null; then
|
||||
echo "Disabling st-guardian service..."
|
||||
systemctl disable st-guardian
|
||||
fi
|
||||
|
||||
if [ -f /etc/systemd/system/st-guardian.service ]; then
|
||||
echo "Removing systemd unit file..."
|
||||
rm /etc/systemd/system/st-guardian.service
|
||||
fi
|
||||
|
||||
if [ -f /etc/sudoers.d/st-guardian ]; then
|
||||
echo "Removing sudoers configuration..."
|
||||
rm /etc/sudoers.d/st-guardian
|
||||
fi
|
||||
|
||||
systemctl daemon-reload
|
||||
|
||||
echo -e "${GREEN}st-guardian has been uninstalled.${NC}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Check root/sudo
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
echo -e "${RED}Error: Please run with sudo${NC}"
|
||||
echo "Usage: sudo bash $0"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
print_header
|
||||
|
||||
# Detect Docker
|
||||
if [ -f /.dockerenv ]; then
|
||||
echo -e "${YELLOW}WARNING: Docker environment detected.${NC}"
|
||||
echo "Running st-guardian as a systemd service inside Docker is not recommended."
|
||||
echo "In Docker, st-guardian should be started directly (node index.js)."
|
||||
read -p "Continue anyway? (y/N): " CONTINUE
|
||||
if [ "$CONTINUE" != "y" ] && [ "$CONTINUE" != "Y" ]; then
|
||||
echo "Aborted."
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# Detect the non-root user
|
||||
if [ -n "$SUDO_USER" ]; then
|
||||
RUN_USER="$SUDO_USER"
|
||||
else
|
||||
RUN_USER=$(logname 2>/dev/null || echo "")
|
||||
fi
|
||||
|
||||
if [ -z "$RUN_USER" ] || [ "$RUN_USER" = "root" ]; then
|
||||
echo -e "${YELLOW}Could not detect non-root user.${NC}"
|
||||
read -p "Enter the user to run st-guardian as: " RUN_USER
|
||||
if [ -z "$RUN_USER" ]; then
|
||||
echo -e "${RED}Error: A non-root user is required.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo -e "Service will run as user: ${GREEN}${RUN_USER}${NC}"
|
||||
echo ""
|
||||
|
||||
# Prompt for ST_APP_DIR
|
||||
DEFAULT_APP_DIR="$(dirname "$GUARDIAN_DIR")"
|
||||
read -p "Sprout Track installation directory [$DEFAULT_APP_DIR]: " ST_APP_DIR
|
||||
ST_APP_DIR="${ST_APP_DIR:-$DEFAULT_APP_DIR}"
|
||||
|
||||
# Validate
|
||||
if [ ! -f "$ST_APP_DIR/package.json" ]; then
|
||||
echo -e "${RED}Error: package.json not found in $ST_APP_DIR${NC}"
|
||||
exit 1
|
||||
fi
|
||||
echo -e "App directory: ${GREEN}${ST_APP_DIR}${NC}"
|
||||
|
||||
# Prompt for ports
|
||||
read -p "Guardian port [3001]: " ST_GUARDIAN_PORT
|
||||
ST_GUARDIAN_PORT="${ST_GUARDIAN_PORT:-3001}"
|
||||
|
||||
read -p "App port [3000]: " ST_APP_PORT
|
||||
ST_APP_PORT="${ST_APP_PORT:-3000}"
|
||||
|
||||
# Prompt for guardian key
|
||||
read -p "Guardian API key (leave blank to auto-generate): " ST_GUARDIAN_KEY
|
||||
if [ -z "$ST_GUARDIAN_KEY" ]; then
|
||||
ST_GUARDIAN_KEY=$(openssl rand -hex 32)
|
||||
echo -e "Generated key: ${GREEN}${ST_GUARDIAN_KEY}${NC}"
|
||||
fi
|
||||
|
||||
# Prompt for health interval
|
||||
read -p "Health check interval in ms [30000]: " ST_HEALTH_INTERVAL
|
||||
ST_HEALTH_INTERVAL="${ST_HEALTH_INTERVAL:-30000}"
|
||||
|
||||
echo ""
|
||||
echo "Configuration:"
|
||||
echo " App directory: $ST_APP_DIR"
|
||||
echo " Guardian port: $ST_GUARDIAN_PORT"
|
||||
echo " App port: $ST_APP_PORT"
|
||||
echo " Health interval: ${ST_HEALTH_INTERVAL}ms"
|
||||
echo " Run as user: $RUN_USER"
|
||||
echo ""
|
||||
read -p "Proceed with installation? (Y/n): " CONFIRM
|
||||
if [ "$CONFIRM" = "n" ] || [ "$CONFIRM" = "N" ]; then
|
||||
echo "Aborted."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Install npm dependencies
|
||||
echo ""
|
||||
echo -e "${TEAL}Installing dependencies...${NC}"
|
||||
cd "$GUARDIAN_DIR"
|
||||
sudo -u "$RUN_USER" npm install --omit=dev
|
||||
|
||||
# Read SERVICE_NAME from app .env for sudoers
|
||||
SERVICE_NAME=""
|
||||
if [ -f "$ST_APP_DIR/.env" ]; then
|
||||
SERVICE_NAME=$(grep 'SERVICE_NAME' "$ST_APP_DIR/.env" | cut -d '"' -f 2)
|
||||
fi
|
||||
|
||||
# Configure sudoers for service management
|
||||
if [ -n "$SERVICE_NAME" ]; then
|
||||
echo -e "${TEAL}Configuring sudo permissions for service management...${NC}"
|
||||
SYSTEMCTL_PATH=$(which systemctl)
|
||||
cat > /etc/sudoers.d/st-guardian << SUDOERS
|
||||
# Allow st-guardian to manage the Sprout Track service
|
||||
${RUN_USER} ALL=(ALL) NOPASSWD: ${SYSTEMCTL_PATH} start ${SERVICE_NAME}
|
||||
${RUN_USER} ALL=(ALL) NOPASSWD: ${SYSTEMCTL_PATH} stop ${SERVICE_NAME}
|
||||
${RUN_USER} ALL=(ALL) NOPASSWD: ${SYSTEMCTL_PATH} restart ${SERVICE_NAME}
|
||||
${RUN_USER} ALL=(ALL) NOPASSWD: ${SYSTEMCTL_PATH} status ${SERVICE_NAME}
|
||||
SUDOERS
|
||||
chmod 0440 /etc/sudoers.d/st-guardian
|
||||
echo "Sudoers configured for service: $SERVICE_NAME"
|
||||
else
|
||||
echo -e "${YELLOW}WARNING: SERVICE_NAME not found in $ST_APP_DIR/.env${NC}"
|
||||
echo "Sudoers configuration skipped. Service management may require manual sudo setup."
|
||||
fi
|
||||
|
||||
# Add user to systemd-journal group for log access
|
||||
if getent group systemd-journal > /dev/null 2>&1; then
|
||||
echo -e "${TEAL}Adding $RUN_USER to systemd-journal group...${NC}"
|
||||
usermod -aG systemd-journal "$RUN_USER"
|
||||
fi
|
||||
|
||||
# Detect node path
|
||||
NODE_PATH=$(which node)
|
||||
|
||||
# Create systemd unit file
|
||||
echo -e "${TEAL}Creating systemd service...${NC}"
|
||||
cat > /etc/systemd/system/st-guardian.service << UNIT
|
||||
[Unit]
|
||||
Description=ST-Guardian - Sprout Track Service Manager
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=${RUN_USER}
|
||||
WorkingDirectory=${GUARDIAN_DIR}
|
||||
ExecStart=${NODE_PATH} index.js
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
Environment=ST_GUARDIAN_PORT=${ST_GUARDIAN_PORT}
|
||||
Environment=ST_APP_PORT=${ST_APP_PORT}
|
||||
Environment=ST_GUARDIAN_KEY=${ST_GUARDIAN_KEY}
|
||||
Environment=ST_HEALTH_INTERVAL=${ST_HEALTH_INTERVAL}
|
||||
Environment=ST_SCRIPTS_DIR=${ST_APP_DIR}/scripts
|
||||
Environment=ST_APP_DIR=${ST_APP_DIR}
|
||||
Environment=ST_LOG_BUFFER=500
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
UNIT
|
||||
|
||||
# Enable and start
|
||||
echo -e "${TEAL}Starting st-guardian...${NC}"
|
||||
systemctl daemon-reload
|
||||
systemctl enable st-guardian
|
||||
systemctl start st-guardian
|
||||
|
||||
# Wait a moment then check status
|
||||
sleep 2
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}╔══════════════════════════════════════╗${NC}"
|
||||
echo -e "${GREEN}║ ST-Guardian Installed! ║${NC}"
|
||||
echo -e "${GREEN}╚══════════════════════════════════════╝${NC}"
|
||||
echo ""
|
||||
|
||||
# Show status
|
||||
systemctl status st-guardian --no-pager || true
|
||||
|
||||
echo ""
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo -e " Status URL: ${TEAL}http://localhost:${ST_GUARDIAN_PORT}/status${NC}"
|
||||
echo -e " Health URL: ${TEAL}http://localhost:${ST_GUARDIAN_PORT}/health${NC}"
|
||||
echo ""
|
||||
echo -e " Guardian Key: ${YELLOW}${ST_GUARDIAN_KEY}${NC}"
|
||||
echo -e " ${RED}Save this key! It's required for management endpoints.${NC}"
|
||||
echo ""
|
||||
echo -e " ${YELLOW}Reminder:${NC} Update your Nginx/reverse proxy to point"
|
||||
echo -e " at port ${ST_GUARDIAN_PORT} instead of ${ST_APP_PORT}."
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo ""
|
||||
echo "To uninstall: sudo bash $0 --uninstall"
|
||||
Reference in New Issue
Block a user