new changes
This commit is contained in:
@@ -16,6 +16,9 @@ const CrontabPage = lazy(() => import('./pages/CrontabPage').then((m) => ({ defa
|
||||
const ConfigPage = lazy(() => import('./pages/ConfigPage').then((m) => ({ default: m.ConfigPage })))
|
||||
const LogsPage = lazy(() => import('./pages/LogsPage').then((m) => ({ default: m.LogsPage })))
|
||||
const FirewallPage = lazy(() => import('./pages/FirewallPage').then((m) => ({ default: m.FirewallPage })))
|
||||
const SecurityChecklistPage = lazy(() =>
|
||||
import('./pages/SecurityChecklistPage').then((m) => ({ default: m.SecurityChecklistPage })),
|
||||
)
|
||||
const DomainsPage = lazy(() => import('./pages/DomainsPage').then((m) => ({ default: m.DomainsPage })))
|
||||
const DockerPage = lazy(() => import('./pages/DockerPage').then((m) => ({ default: m.DockerPage })))
|
||||
const NodePage = lazy(() => import('./pages/NodePage').then((m) => ({ default: m.NodePage })))
|
||||
@@ -125,6 +128,14 @@ export default function App() {
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="security-checklist"
|
||||
element={
|
||||
<Suspense fallback={<PageSkeleton />}>
|
||||
<SecurityChecklistPage />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="files"
|
||||
element={
|
||||
|
||||
@@ -67,7 +67,19 @@ export async function login(username: string, password: string) {
|
||||
return data
|
||||
}
|
||||
|
||||
export async function createSite(data: { name: string; domains: string[]; path?: string; ps?: string; php_version?: string; force_https?: boolean }) {
|
||||
export async function createSite(data: {
|
||||
name: string
|
||||
domains: string[]
|
||||
path?: string
|
||||
ps?: string
|
||||
php_version?: string
|
||||
force_https?: boolean
|
||||
proxy_upstream?: string
|
||||
proxy_websocket?: boolean
|
||||
dir_auth_path?: string
|
||||
dir_auth_user_file?: string
|
||||
php_deny_execute?: boolean
|
||||
}) {
|
||||
return apiRequest<{ status: boolean; msg: string; id?: number }>('/site/create', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
@@ -108,14 +120,38 @@ export async function siteBatch(action: 'enable' | 'disable' | 'delete', ids: nu
|
||||
}
|
||||
|
||||
export async function getSite(siteId: number) {
|
||||
return apiRequest<{ id: number; name: string; path: string; status: number; ps: string; project_type: string; php_version: string; force_https: number; domains: string[] }>(
|
||||
`/site/${siteId}`
|
||||
)
|
||||
return apiRequest<{
|
||||
id: number
|
||||
name: string
|
||||
path: string
|
||||
status: number
|
||||
ps: string
|
||||
project_type: string
|
||||
php_version: string
|
||||
force_https: number
|
||||
domains: string[]
|
||||
proxy_upstream?: string
|
||||
proxy_websocket?: number
|
||||
dir_auth_path?: string
|
||||
dir_auth_user_file?: string
|
||||
php_deny_execute?: number
|
||||
}>(`/site/${siteId}`)
|
||||
}
|
||||
|
||||
export async function updateSite(
|
||||
siteId: number,
|
||||
data: { path?: string; domains?: string[]; ps?: string; php_version?: string; force_https?: boolean }
|
||||
data: {
|
||||
path?: string
|
||||
domains?: string[]
|
||||
ps?: string
|
||||
php_version?: string
|
||||
force_https?: boolean
|
||||
proxy_upstream?: string
|
||||
proxy_websocket?: boolean
|
||||
dir_auth_path?: string
|
||||
dir_auth_user_file?: string
|
||||
php_deny_execute?: boolean
|
||||
}
|
||||
) {
|
||||
return apiRequest<{ status: boolean; msg: string }>(`/site/${siteId}`, {
|
||||
method: 'PUT',
|
||||
@@ -530,17 +566,50 @@ export async function getMonitorNetwork() {
|
||||
}
|
||||
|
||||
export async function listBackupPlans() {
|
||||
return apiRequest<{ id: number; name: string; plan_type: string; target_id: number; schedule: string; enabled: boolean }[]>('/backup/plans')
|
||||
return apiRequest<
|
||||
{
|
||||
id: number
|
||||
name: string
|
||||
plan_type: string
|
||||
target_id: number
|
||||
schedule: string
|
||||
enabled: boolean
|
||||
s3_bucket?: string
|
||||
s3_endpoint?: string
|
||||
s3_key_prefix?: string
|
||||
}[]
|
||||
>('/backup/plans')
|
||||
}
|
||||
|
||||
export async function createBackupPlan(data: { name: string; plan_type: string; target_id: number; schedule: string; enabled?: boolean }) {
|
||||
export async function createBackupPlan(data: {
|
||||
name: string
|
||||
plan_type: string
|
||||
target_id: number
|
||||
schedule: string
|
||||
enabled?: boolean
|
||||
s3_bucket?: string
|
||||
s3_endpoint?: string
|
||||
s3_key_prefix?: string
|
||||
}) {
|
||||
return apiRequest<{ status: boolean; msg: string; id: number }>('/backup/plans', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateBackupPlan(planId: number, data: { name: string; plan_type: string; target_id: number; schedule: string; enabled?: boolean }) {
|
||||
export async function updateBackupPlan(
|
||||
planId: number,
|
||||
data: {
|
||||
name: string
|
||||
plan_type: string
|
||||
target_id: number
|
||||
schedule: string
|
||||
enabled?: boolean
|
||||
s3_bucket?: string
|
||||
s3_endpoint?: string
|
||||
s3_key_prefix?: string
|
||||
}
|
||||
) {
|
||||
return apiRequest<{ status: boolean; msg: string }>(`/backup/plans/${planId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
|
||||
@@ -15,17 +15,24 @@ export const menuItems: MenuItem[] = [
|
||||
{ title: 'Docker', href: '/docker', id: 'menuDocker', sort: 5, iconClass: 'ti ti-brand-docker' },
|
||||
{ title: 'Monitor', href: '/control', id: 'menuControl', sort: 6, iconClass: 'ti ti-heart-rate-monitor' },
|
||||
{ title: 'Security', href: '/firewall', id: 'menuFirewall', sort: 7, iconClass: 'ti ti-shield-lock' },
|
||||
{ title: 'Files', href: '/files', id: 'menuFiles', sort: 8, iconClass: 'ti ti-folders' },
|
||||
{ title: 'Node', href: '/node', id: 'menuNode', sort: 9, iconClass: 'ti ti-brand-nodejs' },
|
||||
{ title: 'Logs', href: '/logs', id: 'menuLogs', sort: 10, iconClass: 'ti ti-file-text' },
|
||||
{ title: 'Domains', href: '/ssl_domain', id: 'menuDomains', sort: 11, iconClass: 'ti ti-world-www' },
|
||||
{ title: 'Terminal', href: '/xterm', id: 'menuXterm', sort: 12, iconClass: 'ti ti-terminal-2' },
|
||||
{ title: 'Cron', href: '/crontab', id: 'menuCrontab', sort: 13, iconClass: 'ti ti-clock' },
|
||||
{ title: 'App Store', href: '/soft', id: 'menuSoft', sort: 14, iconClass: 'ti ti-package' },
|
||||
{ title: 'Services', href: '/services', id: 'menuServices', sort: 15, iconClass: 'ti ti-server' },
|
||||
{ title: 'Plugins', href: '/plugins', id: 'menuPlugins', sort: 16, iconClass: 'ti ti-puzzle' },
|
||||
{ title: 'Backup Plans', href: '/backup-plans', id: 'menuBackupPlans', sort: 17, iconClass: 'ti ti-archive' },
|
||||
{ title: 'Users', href: '/users', id: 'menuUsers', sort: 18, iconClass: 'ti ti-users' },
|
||||
{ title: 'Settings', href: '/config', id: 'menuConfig', sort: 19, iconClass: 'ti ti-settings' },
|
||||
{ title: 'Log out', href: '/logout', id: 'menuLogout', sort: 20, iconClass: 'ti ti-logout' },
|
||||
{
|
||||
title: 'Security checklist',
|
||||
href: '/security-checklist',
|
||||
id: 'menuSecurityChecklist',
|
||||
sort: 8,
|
||||
iconClass: 'ti ti-checklist',
|
||||
},
|
||||
{ title: 'Files', href: '/files', id: 'menuFiles', sort: 9, iconClass: 'ti ti-folders' },
|
||||
{ title: 'Node', href: '/node', id: 'menuNode', sort: 10, iconClass: 'ti ti-brand-nodejs' },
|
||||
{ title: 'Logs', href: '/logs', id: 'menuLogs', sort: 11, iconClass: 'ti ti-file-text' },
|
||||
{ title: 'Domains', href: '/ssl_domain', id: 'menuDomains', sort: 12, iconClass: 'ti ti-world-www' },
|
||||
{ title: 'Terminal', href: '/xterm', id: 'menuXterm', sort: 13, iconClass: 'ti ti-terminal-2' },
|
||||
{ title: 'Cron', href: '/crontab', id: 'menuCrontab', sort: 14, iconClass: 'ti ti-clock' },
|
||||
{ title: 'App Store', href: '/soft', id: 'menuSoft', sort: 15, iconClass: 'ti ti-package' },
|
||||
{ title: 'Services', href: '/services', id: 'menuServices', sort: 16, iconClass: 'ti ti-server' },
|
||||
{ title: 'Plugins', href: '/plugins', id: 'menuPlugins', sort: 17, iconClass: 'ti ti-puzzle' },
|
||||
{ title: 'Backup Plans', href: '/backup-plans', id: 'menuBackupPlans', sort: 18, iconClass: 'ti ti-archive' },
|
||||
{ title: 'Users', href: '/users', id: 'menuUsers', sort: 19, iconClass: 'ti ti-users' },
|
||||
{ title: 'Settings', href: '/config', id: 'menuConfig', sort: 20, iconClass: 'ti ti-settings' },
|
||||
{ title: 'Log out', href: '/logout', id: 'menuLogout', sort: 21, iconClass: 'ti ti-logout' },
|
||||
]
|
||||
|
||||
@@ -7,6 +7,7 @@ export const routeTitleMap: Record<string, string> = {
|
||||
'/docker': 'Docker',
|
||||
'/control': 'Monitor',
|
||||
'/firewall': 'Security',
|
||||
'/security-checklist': 'Security checklist',
|
||||
'/files': 'Files',
|
||||
'/node': 'Node',
|
||||
'/logs': 'Logs',
|
||||
|
||||
@@ -17,6 +17,9 @@ interface BackupPlanRecord {
|
||||
target_id: number
|
||||
schedule: string
|
||||
enabled: boolean
|
||||
s3_bucket?: string
|
||||
s3_endpoint?: string
|
||||
s3_key_prefix?: string
|
||||
}
|
||||
|
||||
interface SiteRecord {
|
||||
@@ -74,13 +77,16 @@ export function BackupPlansPage() {
|
||||
const target_id = Number((form.elements.namedItem('target_id') as HTMLSelectElement).value)
|
||||
const schedule = (form.elements.namedItem('schedule') as HTMLInputElement).value.trim()
|
||||
const enabled = (form.elements.namedItem('enabled') as HTMLInputElement).checked
|
||||
const s3_bucket = (form.elements.namedItem('s3_bucket') as HTMLInputElement).value.trim()
|
||||
const s3_endpoint = (form.elements.namedItem('s3_endpoint') as HTMLInputElement).value.trim()
|
||||
const s3_key_prefix = (form.elements.namedItem('s3_key_prefix') as HTMLInputElement).value.trim()
|
||||
|
||||
if (!name || !schedule || !target_id) {
|
||||
setError('Name, target and schedule are required')
|
||||
return
|
||||
}
|
||||
setCreating(true)
|
||||
createBackupPlan({ name, plan_type, target_id, schedule, enabled })
|
||||
createBackupPlan({ name, plan_type, target_id, schedule, enabled, s3_bucket, s3_endpoint, s3_key_prefix })
|
||||
.then(() => {
|
||||
setShowCreate(false)
|
||||
form.reset()
|
||||
@@ -110,9 +116,21 @@ export function BackupPlansPage() {
|
||||
const target_id = Number((form.elements.namedItem('edit_target_id') as HTMLSelectElement).value)
|
||||
const schedule = (form.elements.namedItem('edit_schedule') as HTMLInputElement).value.trim()
|
||||
const enabled = (form.elements.namedItem('edit_enabled') as HTMLInputElement).checked
|
||||
const s3_bucket = (form.elements.namedItem('edit_s3_bucket') as HTMLInputElement).value.trim()
|
||||
const s3_endpoint = (form.elements.namedItem('edit_s3_endpoint') as HTMLInputElement).value.trim()
|
||||
const s3_key_prefix = (form.elements.namedItem('edit_s3_key_prefix') as HTMLInputElement).value.trim()
|
||||
|
||||
if (!name || !schedule || !target_id) return
|
||||
updateBackupPlan(editPlan.id, { name, plan_type: editPlanType, target_id, schedule, enabled })
|
||||
updateBackupPlan(editPlan.id, {
|
||||
name,
|
||||
plan_type: editPlanType,
|
||||
target_id,
|
||||
schedule,
|
||||
enabled,
|
||||
s3_bucket,
|
||||
s3_endpoint,
|
||||
s3_key_prefix,
|
||||
})
|
||||
.then(() => {
|
||||
setEditPlan(null)
|
||||
loadPlans()
|
||||
@@ -188,7 +206,9 @@ export function BackupPlansPage() {
|
||||
|
||||
<p className="small text-secondary mb-3">
|
||||
Schedule automated backups. Add a cron entry (e.g. <code>0 * * * *</code> hourly) to call{' '}
|
||||
<code>POST /api/v1/backup/run-scheduled</code> with your auth token.
|
||||
<code>POST /api/v1/backup/run-scheduled</code> with your auth token. Optional S3-compatible upload uses{' '}
|
||||
<code>AWS_ACCESS_KEY_ID</code> and <code>AWS_SECRET_ACCESS_KEY</code> in the panel environment when a bucket
|
||||
name is set.
|
||||
</p>
|
||||
|
||||
<div className="card">
|
||||
@@ -199,6 +219,7 @@ export function BackupPlansPage() {
|
||||
<th>Type</th>
|
||||
<th>Target</th>
|
||||
<th>Schedule</th>
|
||||
<th>S3</th>
|
||||
<th>Enabled</th>
|
||||
<th className="text-end">Actions</th>
|
||||
</tr>
|
||||
@@ -206,7 +227,7 @@ export function BackupPlansPage() {
|
||||
<tbody>
|
||||
{plans.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="p-0">
|
||||
<td colSpan={7} className="p-0">
|
||||
<EmptyState
|
||||
title="No backup plans"
|
||||
description='Click "Add Plan" to create one.'
|
||||
@@ -222,6 +243,9 @@ export function BackupPlansPage() {
|
||||
<td>
|
||||
<code className="small">{p.schedule}</code>
|
||||
</td>
|
||||
<td className="small">
|
||||
{p.s3_bucket ? <span className="badge text-bg-info">{p.s3_bucket}</span> : '—'}
|
||||
</td>
|
||||
<td>{p.enabled ? 'Yes' : 'No'}</td>
|
||||
<td className="text-end">
|
||||
<button
|
||||
@@ -314,6 +338,36 @@ export function BackupPlansPage() {
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="form-label">S3 bucket (optional)</label>
|
||||
<input
|
||||
name="edit_s3_bucket"
|
||||
type="text"
|
||||
placeholder="my-backups"
|
||||
defaultValue={editPlan.s3_bucket || ''}
|
||||
className="form-control"
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="form-label">S3 endpoint (optional)</label>
|
||||
<input
|
||||
name="edit_s3_endpoint"
|
||||
type="text"
|
||||
placeholder="https://s3.example.com or leave empty for AWS"
|
||||
defaultValue={editPlan.s3_endpoint || ''}
|
||||
className="form-control"
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="form-label">S3 key prefix (optional)</label>
|
||||
<input
|
||||
name="edit_s3_key_prefix"
|
||||
type="text"
|
||||
placeholder="yakpanel/backups"
|
||||
defaultValue={editPlan.s3_key_prefix || ''}
|
||||
className="form-control"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-check">
|
||||
<input name="edit_enabled" type="checkbox" defaultChecked={editPlan.enabled} className="form-check-input" id="edit_enabled" />
|
||||
<label className="form-check-label" htmlFor="edit_enabled">
|
||||
@@ -383,6 +437,18 @@ export function BackupPlansPage() {
|
||||
<input name="schedule" type="text" placeholder="0 2 * * *" className="form-control" required />
|
||||
<div className="form-text">e.g. 0 2 * * * = daily at 2am, 0 */6 * * * = every 6 hours</div>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="form-label">S3 bucket (optional)</label>
|
||||
<input name="s3_bucket" type="text" placeholder="my-backups" className="form-control" />
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="form-label">S3 endpoint (optional)</label>
|
||||
<input name="s3_endpoint" type="text" placeholder="Custom S3 API URL" className="form-control" />
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="form-label">S3 key prefix (optional)</label>
|
||||
<input name="s3_key_prefix" type="text" placeholder="yakpanel/backups" className="form-control" />
|
||||
</div>
|
||||
<div className="form-check">
|
||||
<input name="enabled" type="checkbox" defaultChecked className="form-check-input" id="plan_enabled" />
|
||||
<label className="form-check-label" htmlFor="plan_enabled">
|
||||
|
||||
@@ -11,6 +11,14 @@ interface CronJob {
|
||||
execstr: string
|
||||
}
|
||||
|
||||
interface CronTemplate {
|
||||
id: string
|
||||
name: string
|
||||
schedule: string
|
||||
execstr: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
const SCHEDULE_PRESETS = [
|
||||
{ label: 'Every minute', value: '* * * * *' },
|
||||
{ label: 'Every 5 min', value: '*/5 * * * *' },
|
||||
@@ -29,6 +37,9 @@ export function CrontabPage() {
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [formError, setFormError] = useState('')
|
||||
const [applying, setApplying] = useState(false)
|
||||
const [templates, setTemplates] = useState<CronTemplate[]>([])
|
||||
const [prefill, setPrefill] = useState<{ name?: string; schedule?: string; execstr?: string } | null>(null)
|
||||
const [formNonce, setFormNonce] = useState(0)
|
||||
|
||||
const loadJobs = () => {
|
||||
setLoading(true)
|
||||
@@ -42,6 +53,12 @@ export function CrontabPage() {
|
||||
loadJobs()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
apiRequest<{ templates: CronTemplate[] }>('/crontab/templates')
|
||||
.then((r) => setTemplates(Array.isArray(r.templates) ? r.templates : []))
|
||||
.catch(() => setTemplates([]))
|
||||
}, [])
|
||||
|
||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
const form = e.currentTarget
|
||||
@@ -63,6 +80,8 @@ export function CrontabPage() {
|
||||
.then(() => {
|
||||
setShowForm(false)
|
||||
setEditingId(null)
|
||||
setEditJob(null)
|
||||
setPrefill(null)
|
||||
form.reset()
|
||||
loadJobs()
|
||||
})
|
||||
@@ -71,11 +90,20 @@ export function CrontabPage() {
|
||||
}
|
||||
|
||||
const handleEdit = (job: CronJob) => {
|
||||
setPrefill(null)
|
||||
setEditingId(job.id)
|
||||
setEditJob(job)
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
const useTemplate = (t: CronTemplate) => {
|
||||
setEditingId(null)
|
||||
setEditJob(null)
|
||||
setPrefill({ name: t.name, schedule: t.schedule, execstr: t.execstr })
|
||||
setFormNonce((n) => n + 1)
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
const handleDelete = (id: number) => {
|
||||
if (!confirm('Delete this cron job?')) return
|
||||
apiRequest(`/crontab/${id}`, { method: 'DELETE' })
|
||||
@@ -131,11 +159,21 @@ export function CrontabPage() {
|
||||
|
||||
{error ? <AdminAlert className="mb-3">{error}</AdminAlert> : null}
|
||||
|
||||
<Modal show={showForm} onHide={() => { setShowForm(false); setEditingId(null); setEditJob(null) }} centered size="lg">
|
||||
<Modal
|
||||
show={showForm}
|
||||
onHide={() => {
|
||||
setShowForm(false)
|
||||
setEditingId(null)
|
||||
setEditJob(null)
|
||||
setPrefill(null)
|
||||
}}
|
||||
centered
|
||||
size="lg"
|
||||
>
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>{editingId ? 'Edit Cron Job' : 'Create Cron Job'}</Modal.Title>
|
||||
</Modal.Header>
|
||||
<form key={editingId ?? 'new'} onSubmit={handleSubmit}>
|
||||
<form key={`${editingId ?? 'new'}-${formNonce}`} onSubmit={handleSubmit}>
|
||||
<Modal.Body>
|
||||
{formError ? <AdminAlert className="mb-3">{formError}</AdminAlert> : null}
|
||||
<div className="mb-3">
|
||||
@@ -145,7 +183,7 @@ export function CrontabPage() {
|
||||
name="name"
|
||||
type="text"
|
||||
placeholder="My task"
|
||||
defaultValue={editJob?.name}
|
||||
defaultValue={editJob?.name ?? prefill?.name ?? ''}
|
||||
className="form-control"
|
||||
/>
|
||||
</div>
|
||||
@@ -170,7 +208,7 @@ export function CrontabPage() {
|
||||
name="schedule"
|
||||
type="text"
|
||||
placeholder="* * * * *"
|
||||
defaultValue={editJob?.schedule}
|
||||
defaultValue={editJob?.schedule ?? prefill?.schedule ?? ''}
|
||||
className="form-control"
|
||||
required
|
||||
/>
|
||||
@@ -182,7 +220,7 @@ export function CrontabPage() {
|
||||
name="execstr"
|
||||
rows={3}
|
||||
placeholder="/usr/bin/php /www/wwwroot/script.php"
|
||||
defaultValue={editJob?.execstr}
|
||||
defaultValue={editJob?.execstr ?? prefill?.execstr ?? ''}
|
||||
className="form-control"
|
||||
required
|
||||
/>
|
||||
@@ -196,6 +234,7 @@ export function CrontabPage() {
|
||||
setShowForm(false)
|
||||
setEditingId(null)
|
||||
setEditJob(null)
|
||||
setPrefill(null)
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
@@ -211,6 +250,32 @@ export function CrontabPage() {
|
||||
Jobs are stored in the panel. Click "Apply to System" to sync them to the system crontab (root).
|
||||
</div>
|
||||
|
||||
{templates.length > 0 ? (
|
||||
<div className="card mb-3">
|
||||
<div className="card-header">YakPanel starter templates</div>
|
||||
<div className="card-body py-3">
|
||||
<p className="small text-secondary mb-2">Review and edit commands before saving — adjust paths and tokens for your server.</p>
|
||||
<div className="list-group list-group-flush border rounded">
|
||||
{templates.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
className="list-group-item d-flex flex-wrap align-items-center justify-content-between gap-2"
|
||||
>
|
||||
<div className="flex-grow-1" style={{ minWidth: 200 }}>
|
||||
<div className="fw-medium">{t.name}</div>
|
||||
{t.description ? <div className="small text-muted">{t.description}</div> : null}
|
||||
<code className="small d-block mt-1 text-break">{t.schedule}</code>
|
||||
</div>
|
||||
<button type="button" className="btn btn-sm btn-outline-primary flex-shrink-0" onClick={() => useTemplate(t)}>
|
||||
Use in new job
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="card">
|
||||
<AdminTable>
|
||||
<thead>
|
||||
|
||||
@@ -17,9 +17,19 @@ interface Certificate {
|
||||
path: string
|
||||
}
|
||||
|
||||
interface NginxWizard {
|
||||
detected_layout: string
|
||||
include_snippet: string
|
||||
dropin_file_suggested: string
|
||||
debian: { sites_available_file: string; sites_enabled_symlink: string; steps: string[] }
|
||||
rhel: { conf_d_file: string; steps: string[] }
|
||||
note: string
|
||||
}
|
||||
|
||||
interface SslDiagnostics {
|
||||
vhost_dir: string
|
||||
include_snippet: string
|
||||
nginx_wizard?: NginxWizard
|
||||
vhosts: { file: string; has_listen_80: boolean; has_listen_443: boolean; has_ssl_directives: boolean }[]
|
||||
any_vhost_listen_ssl: boolean
|
||||
nginx_effective_listen_443: boolean
|
||||
@@ -41,6 +51,13 @@ export function DomainsPage() {
|
||||
const [requesting, setRequesting] = useState<string | null>(null)
|
||||
const [requestDomain, setRequestDomain] = useState<Domain | null>(null)
|
||||
const [requestEmail, setRequestEmail] = useState('')
|
||||
const [showDnsCf, setShowDnsCf] = useState(false)
|
||||
const [cfDomain, setCfDomain] = useState('')
|
||||
const [cfEmail, setCfEmail] = useState('')
|
||||
const [cfToken, setCfToken] = useState('')
|
||||
const [cfBusy, setCfBusy] = useState(false)
|
||||
const [manualDom, setManualDom] = useState('')
|
||||
const [manualOut, setManualOut] = useState<{ txt_record_name?: string; certbot_example?: string; note?: string } | null>(null)
|
||||
|
||||
const load = () => {
|
||||
setLoading(true)
|
||||
@@ -155,9 +172,107 @@ export function DomainsPage() {
|
||||
nginx -T probe: {diag.nginx_t_probe_errors.join(' | ')}
|
||||
</div>
|
||||
) : null}
|
||||
{diag.nginx_wizard ? (
|
||||
<div className="mt-4 border-top pt-3">
|
||||
<div className="fw-semibold mb-2">Nginx include wizard</div>
|
||||
<p className="small text-secondary mb-2">{diag.nginx_wizard.note}</p>
|
||||
<p className="small mb-1">
|
||||
Detected layout: <code>{diag.nginx_wizard.detected_layout}</code> — suggested file:{' '}
|
||||
<code className="user-select-all">{diag.nginx_wizard.dropin_file_suggested}</code>
|
||||
</p>
|
||||
<div className="row g-2 small">
|
||||
<div className="col-md-6">
|
||||
<strong className="d-block mb-1">Debian / Ubuntu</strong>
|
||||
<ol className="ps-3 mb-0">
|
||||
{diag.nginx_wizard.debian.steps.map((s, i) => (
|
||||
<li key={i} className="mb-1">
|
||||
<code className="text-break user-select-all small d-block bg-body-secondary p-1 rounded">{s}</code>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
<div className="col-md-6">
|
||||
<strong className="d-block mb-1">RHEL / Rocky / Alma</strong>
|
||||
<ol className="ps-3 mb-0">
|
||||
{diag.nginx_wizard.rhel.steps.map((s, i) => (
|
||||
<li key={i} className="mb-1">
|
||||
<code className="text-break user-select-all small d-block bg-body-secondary p-1 rounded">{s}</code>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="card mb-4">
|
||||
<div className="card-header">DNS-01 Let's Encrypt (CDN / no HTTP)</div>
|
||||
<div className="card-body small">
|
||||
<p className="text-secondary mb-3">
|
||||
Use when HTTP validation cannot reach this server (e.g. Cloudflare "orange cloud"). Requires{' '}
|
||||
<code>certbot-dns-cloudflare</code> on the server for the Cloudflare option.
|
||||
</p>
|
||||
<div className="d-flex flex-wrap gap-2 mb-3">
|
||||
<AdminButton type="button" size="sm" variant="primary" onClick={() => { setShowDnsCf(true); setCfDomain(''); setCfEmail(''); setCfToken('') }}>
|
||||
Cloudflare DNS-01
|
||||
</AdminButton>
|
||||
<AdminButton
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline-secondary"
|
||||
onClick={() => {
|
||||
setManualDom('')
|
||||
setManualOut(null)
|
||||
}}
|
||||
>
|
||||
Clear manual help
|
||||
</AdminButton>
|
||||
</div>
|
||||
<div className="row g-2 align-items-end">
|
||||
<div className="col-md-4">
|
||||
<label className="form-label small mb-0">Domain (manual TXT hint)</label>
|
||||
<input
|
||||
className="form-control form-control-sm"
|
||||
value={manualDom}
|
||||
onChange={(e) => setManualDom(e.target.value)}
|
||||
placeholder="example.com"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-auto">
|
||||
<AdminButton
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline-primary"
|
||||
disabled={!manualDom.trim()}
|
||||
onClick={() => {
|
||||
apiRequest<{ txt_record_name: string; certbot_example: string; note: string }>('/ssl/dns-request/manual-instructions', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ domain: manualDom.trim() }),
|
||||
})
|
||||
.then(setManualOut)
|
||||
.catch((err) => setError(err.message))
|
||||
}}
|
||||
>
|
||||
Get TXT / certbot hint
|
||||
</AdminButton>
|
||||
</div>
|
||||
</div>
|
||||
{manualOut ? (
|
||||
<div className="mt-2 p-2 bg-body-secondary rounded small">
|
||||
<div>
|
||||
<strong>TXT name:</strong> <code className="user-select-all">{manualOut.txt_record_name}</code>
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
<strong>Example:</strong> <code className="user-select-all text-break">{manualOut.certbot_example}</code>
|
||||
</div>
|
||||
{manualOut.note ? <p className="mb-0 mt-1 text-secondary">{manualOut.note}</p> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="row g-4">
|
||||
<div className="col-lg-6">
|
||||
<div className="card h-100">
|
||||
@@ -219,7 +334,7 @@ export function DomainsPage() {
|
||||
<i className="ti ti-shield-check text-success flex-shrink-0" aria-hidden />
|
||||
<span className="font-monospace small text-break">{c.name}</span>
|
||||
</div>
|
||||
))
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -269,6 +384,51 @@ export function DomainsPage() {
|
||||
</form>
|
||||
) : null}
|
||||
</Modal>
|
||||
|
||||
<Modal show={showDnsCf} onHide={() => setShowDnsCf(false)} centered>
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>Let's Encrypt via Cloudflare DNS-01</Modal.Title>
|
||||
</Modal.Header>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
setCfBusy(true)
|
||||
apiRequest<{ status: boolean; msg?: string }>('/ssl/dns-request/cloudflare', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ domain: cfDomain.trim(), email: cfEmail.trim(), api_token: cfToken.trim() }),
|
||||
})
|
||||
.then(() => {
|
||||
setShowDnsCf(false)
|
||||
load()
|
||||
})
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setCfBusy(false))
|
||||
}}
|
||||
>
|
||||
<Modal.Body>
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Domain (primary)</label>
|
||||
<input className="form-control" value={cfDomain} onChange={(e) => setCfDomain(e.target.value)} required placeholder="example.com" />
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Let's Encrypt email</label>
|
||||
<input type="email" className="form-control" value={cfEmail} onChange={(e) => setCfEmail(e.target.value)} required />
|
||||
</div>
|
||||
<div className="mb-0">
|
||||
<label className="form-label">Cloudflare API token (DNS:Edit)</label>
|
||||
<input type="password" className="form-control" value={cfToken} onChange={(e) => setCfToken(e.target.value)} required autoComplete="off" />
|
||||
</div>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<AdminButton type="button" variant="secondary" onClick={() => setShowDnsCf(false)}>
|
||||
Cancel
|
||||
</AdminButton>
|
||||
<AdminButton type="submit" variant="primary" disabled={cfBusy}>
|
||||
{cfBusy ? 'Running certbot…' : 'Issue certificate'}
|
||||
</AdminButton>
|
||||
</Modal.Footer>
|
||||
</form>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,10 @@ interface FtpAccount {
|
||||
|
||||
export function FtpPage() {
|
||||
const [accounts, setAccounts] = useState<FtpAccount[]>([])
|
||||
const [logPath, setLogPath] = useState<string | null>(null)
|
||||
const [logContent, setLogContent] = useState('')
|
||||
const [logLoading, setLogLoading] = useState(false)
|
||||
const [logError, setLogError] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
@@ -32,6 +36,18 @@ export function FtpPage() {
|
||||
loadAccounts()
|
||||
}, [])
|
||||
|
||||
const loadFtpLogs = () => {
|
||||
setLogLoading(true)
|
||||
setLogError('')
|
||||
apiRequest<{ path: string | null; content: string }>('/ftp/logs?lines=400')
|
||||
.then((r) => {
|
||||
setLogPath(r.path)
|
||||
setLogContent(r.content || '')
|
||||
})
|
||||
.catch((err) => setLogError(err.message))
|
||||
.finally(() => setLogLoading(false))
|
||||
}
|
||||
|
||||
const handleCreate = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
const form = e.currentTarget
|
||||
@@ -119,6 +135,33 @@ export function FtpPage() {
|
||||
<code>apt install pure-ftpd pure-ftpd-common</code>
|
||||
</div>
|
||||
|
||||
<div className="card shadow-sm border-0 mb-4">
|
||||
<div className="card-header d-flex flex-wrap align-items-center justify-content-between gap-2">
|
||||
<span>FTP log (tail)</span>
|
||||
<button type="button" className="btn btn-sm btn-outline-primary" disabled={logLoading} onClick={loadFtpLogs}>
|
||||
{logLoading ? 'Loading…' : logContent ? 'Refresh' : 'Load log'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{logError ? <AdminAlert variant="danger">{logError}</AdminAlert> : null}
|
||||
{logPath ? (
|
||||
<p className="small text-muted mb-2">
|
||||
Source: <code className="user-select-all">{logPath}</code>
|
||||
</p>
|
||||
) : null}
|
||||
{logContent ? (
|
||||
<pre
|
||||
className="small bg-body-secondary border rounded p-3 mb-0 text-body"
|
||||
style={{ maxHeight: '22rem', overflow: 'auto', whiteSpace: 'pre-wrap' }}
|
||||
>
|
||||
{logContent}
|
||||
</pre>
|
||||
) : !logLoading && !logError ? (
|
||||
<p className="text-muted small mb-0">Click "Load log" to tail common Pure-FTPd paths on this server.</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal show={showCreate} onHide={() => setShowCreate(false)} centered>
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>Create FTP Account</Modal.Title>
|
||||
|
||||
71
YakPanel-server/frontend/src/pages/SecurityChecklistPage.tsx
Normal file
71
YakPanel-server/frontend/src/pages/SecurityChecklistPage.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { apiRequest } from '../api/client'
|
||||
import { PageHeader, AdminAlert } from '../components/admin'
|
||||
|
||||
interface CheckItem {
|
||||
id: string
|
||||
ok: boolean | null
|
||||
title: string
|
||||
detail: string
|
||||
}
|
||||
|
||||
export function SecurityChecklistPage() {
|
||||
const [items, setItems] = useState<CheckItem[]>([])
|
||||
const [disclaimer, setDisclaimer] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
apiRequest<{ items: CheckItem[]; disclaimer?: string }>('/security/checklist')
|
||||
.then((r) => {
|
||||
setItems(r.items || [])
|
||||
setDisclaimer(r.disclaimer || '')
|
||||
})
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Security checklist" />
|
||||
<p className="text-secondary">Loading…</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Security checklist" />
|
||||
|
||||
{error ? <AdminAlert className="mb-3">{error}</AdminAlert> : null}
|
||||
|
||||
{disclaimer ? (
|
||||
<p className="small text-secondary mb-3">{disclaimer}</p>
|
||||
) : null}
|
||||
|
||||
<div className="list-group shadow-sm">
|
||||
{items.length === 0 ? (
|
||||
<div className="list-group-item text-muted">No checks returned.</div>
|
||||
) : (
|
||||
items.map((it) => (
|
||||
<div key={it.id} className="list-group-item d-flex gap-3 align-items-start">
|
||||
<span
|
||||
className={`badge rounded-pill flex-shrink-0 mt-1 ${
|
||||
it.ok === true ? 'text-bg-success' : it.ok === false ? 'text-bg-warning' : 'text-bg-secondary'
|
||||
}`}
|
||||
aria-hidden
|
||||
>
|
||||
{it.ok === true ? 'OK' : it.ok === false ? '!' : '?'}
|
||||
</span>
|
||||
<div>
|
||||
<div className="fw-semibold">{it.title}</div>
|
||||
<div className="small text-secondary">{it.detail}</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -46,6 +46,11 @@ export function SitePage() {
|
||||
ps: string
|
||||
php_version: string
|
||||
force_https: boolean
|
||||
proxy_upstream: string
|
||||
proxy_websocket: boolean
|
||||
dir_auth_path: string
|
||||
dir_auth_user_file: string
|
||||
php_deny_execute: boolean
|
||||
} | null>(null)
|
||||
const [editLoading, setEditLoading] = useState(false)
|
||||
const [editError, setEditError] = useState('')
|
||||
@@ -241,6 +246,11 @@ export function SitePage() {
|
||||
ps: s.ps || '',
|
||||
php_version: s.php_version || '74',
|
||||
force_https: !!(s.force_https && s.force_https !== 0),
|
||||
proxy_upstream: s.proxy_upstream || '',
|
||||
proxy_websocket: !!(s.proxy_websocket && Number(s.proxy_websocket) !== 0),
|
||||
dir_auth_path: s.dir_auth_path || '',
|
||||
dir_auth_user_file: s.dir_auth_user_file || '',
|
||||
php_deny_execute: !!(s.php_deny_execute && Number(s.php_deny_execute) !== 0),
|
||||
})
|
||||
)
|
||||
.catch((err) => setEditError(err.message))
|
||||
@@ -262,6 +272,11 @@ export function SitePage() {
|
||||
ps: editForm.ps || undefined,
|
||||
php_version: editForm.php_version,
|
||||
force_https: editForm.force_https,
|
||||
proxy_upstream: editForm.proxy_upstream,
|
||||
proxy_websocket: editForm.proxy_websocket,
|
||||
dir_auth_path: editForm.dir_auth_path,
|
||||
dir_auth_user_file: editForm.dir_auth_user_file,
|
||||
php_deny_execute: editForm.php_deny_execute,
|
||||
})
|
||||
.then(() => {
|
||||
setEditSiteId(null)
|
||||
@@ -415,6 +430,31 @@ export function SitePage() {
|
||||
Force HTTPS (redirect HTTP to HTTPS)
|
||||
</label>
|
||||
</div>
|
||||
<hr className="my-3" />
|
||||
<p className="small text-secondary mb-2">Reverse proxy (optional): leave empty for PHP/static site.</p>
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Upstream URL</label>
|
||||
<input name="proxy_upstream" type="text" className="form-control" placeholder="http://127.0.0.1:3000" />
|
||||
</div>
|
||||
<div className="form-check mb-3">
|
||||
<input name="proxy_websocket" type="checkbox" id="create_proxy_ws" className="form-check-input" />
|
||||
<label htmlFor="create_proxy_ws" className="form-check-label">
|
||||
WebSocket headers (Upgrade)
|
||||
</label>
|
||||
</div>
|
||||
<p className="small text-secondary mb-2">Directory HTTP auth (requires htpasswd file on server).</p>
|
||||
<div className="mb-2">
|
||||
<input name="dir_auth_path" type="text" className="form-control form-control-sm" placeholder="Path prefix e.g. /staff" />
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<input name="dir_auth_user_file" type="text" className="form-control form-control-sm" placeholder="Absolute path to htpasswd" />
|
||||
</div>
|
||||
<div className="form-check mb-3">
|
||||
<input name="php_deny_execute" type="checkbox" id="create_php_deny" className="form-check-input" />
|
||||
<label htmlFor="create_php_deny" className="form-check-label">
|
||||
Block PHP execution under /uploads and /storage
|
||||
</label>
|
||||
</div>
|
||||
<div className="mb-0">
|
||||
<label className="form-label">Note (optional)</label>
|
||||
<input name="ps" type="text" placeholder="My website" className="form-control" />
|
||||
@@ -900,6 +940,59 @@ export function SitePage() {
|
||||
Force HTTPS
|
||||
</label>
|
||||
</div>
|
||||
<hr className="my-2" />
|
||||
<div className="mb-2">
|
||||
<label className="form-label small">Reverse proxy upstream</label>
|
||||
<input
|
||||
value={editForm.proxy_upstream}
|
||||
onChange={(e) => setEditForm({ ...editForm, proxy_upstream: e.target.value })}
|
||||
type="text"
|
||||
className="form-control form-control-sm"
|
||||
placeholder="http://127.0.0.1:3000"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-check mb-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="edit_proxy_ws"
|
||||
className="form-check-input"
|
||||
checked={editForm.proxy_websocket}
|
||||
onChange={(e) => setEditForm({ ...editForm, proxy_websocket: e.target.checked })}
|
||||
/>
|
||||
<label htmlFor="edit_proxy_ws" className="form-check-label small">
|
||||
WebSocket proxy headers
|
||||
</label>
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<label className="form-label small">Auth path prefix</label>
|
||||
<input
|
||||
value={editForm.dir_auth_path}
|
||||
onChange={(e) => setEditForm({ ...editForm, dir_auth_path: e.target.value })}
|
||||
type="text"
|
||||
className="form-control form-control-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="form-label small">htpasswd file path</label>
|
||||
<input
|
||||
value={editForm.dir_auth_user_file}
|
||||
onChange={(e) => setEditForm({ ...editForm, dir_auth_user_file: e.target.value })}
|
||||
type="text"
|
||||
className="form-control form-control-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-check mb-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="edit_php_deny"
|
||||
className="form-check-input"
|
||||
checked={editForm.php_deny_execute}
|
||||
onChange={(e) => setEditForm({ ...editForm, php_deny_execute: e.target.checked })}
|
||||
/>
|
||||
<label htmlFor="edit_php_deny" className="form-check-label small">
|
||||
Deny PHP under /uploads and /storage
|
||||
</label>
|
||||
</div>
|
||||
<div className="mb-0">
|
||||
<label className="form-label">Note (optional)</label>
|
||||
<input
|
||||
|
||||
Reference in New Issue
Block a user