new changes
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -27,6 +27,9 @@ class CreateBackupPlanRequest(BaseModel):
|
|||||||
target_id: int
|
target_id: int
|
||||||
schedule: str # cron, e.g. "0 2 * * *"
|
schedule: str # cron, e.g. "0 2 * * *"
|
||||||
enabled: bool = True
|
enabled: bool = True
|
||||||
|
s3_bucket: str = ""
|
||||||
|
s3_endpoint: str = ""
|
||||||
|
s3_key_prefix: str = ""
|
||||||
|
|
||||||
|
|
||||||
@router.get("/plans")
|
@router.get("/plans")
|
||||||
@@ -45,6 +48,9 @@ async def backup_plans_list(
|
|||||||
"target_id": r.target_id,
|
"target_id": r.target_id,
|
||||||
"schedule": r.schedule,
|
"schedule": r.schedule,
|
||||||
"enabled": r.enabled,
|
"enabled": r.enabled,
|
||||||
|
"s3_bucket": getattr(r, "s3_bucket", None) or "",
|
||||||
|
"s3_endpoint": getattr(r, "s3_endpoint", None) or "",
|
||||||
|
"s3_key_prefix": getattr(r, "s3_key_prefix", None) or "",
|
||||||
}
|
}
|
||||||
for r in rows
|
for r in rows
|
||||||
]
|
]
|
||||||
@@ -79,6 +85,9 @@ async def backup_plan_create(
|
|||||||
target_id=body.target_id,
|
target_id=body.target_id,
|
||||||
schedule=body.schedule,
|
schedule=body.schedule,
|
||||||
enabled=body.enabled,
|
enabled=body.enabled,
|
||||||
|
s3_bucket=(body.s3_bucket or "")[:256],
|
||||||
|
s3_endpoint=(body.s3_endpoint or "")[:512],
|
||||||
|
s3_key_prefix=(body.s3_key_prefix or "")[:256],
|
||||||
)
|
)
|
||||||
db.add(plan)
|
db.add(plan)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
@@ -107,6 +116,9 @@ async def backup_plan_update(
|
|||||||
plan.target_id = body.target_id
|
plan.target_id = body.target_id
|
||||||
plan.schedule = body.schedule
|
plan.schedule = body.schedule
|
||||||
plan.enabled = body.enabled
|
plan.enabled = body.enabled
|
||||||
|
plan.s3_bucket = (body.s3_bucket or "")[:256]
|
||||||
|
plan.s3_endpoint = (body.s3_endpoint or "")[:512]
|
||||||
|
plan.s3_key_prefix = (body.s3_key_prefix or "")[:256]
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return {"status": True, "msg": "Updated"}
|
return {"status": True, "msg": "Updated"}
|
||||||
|
|
||||||
@@ -143,6 +155,27 @@ def _run_site_backup(site: Site) -> tuple[bool, str, str | None]:
|
|||||||
return False, str(e), None
|
return False, str(e), None
|
||||||
|
|
||||||
|
|
||||||
|
def _maybe_upload_s3(local_file: str, plan: BackupPlan) -> tuple[bool, str]:
|
||||||
|
"""Copy backup file to S3-compatible bucket if plan.s3_bucket set. Uses AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY."""
|
||||||
|
bucket = (getattr(plan, "s3_bucket", None) or "").strip()
|
||||||
|
if not bucket or not os.path.isfile(local_file):
|
||||||
|
return True, ""
|
||||||
|
try:
|
||||||
|
import boto3
|
||||||
|
except ImportError:
|
||||||
|
return False, "boto3 not installed (pip install boto3)"
|
||||||
|
ep = (getattr(plan, "s3_endpoint", None) or "").strip() or None
|
||||||
|
prefix = (getattr(plan, "s3_key_prefix", None) or "").strip().strip("/")
|
||||||
|
key_base = os.path.basename(local_file)
|
||||||
|
key = f"{prefix}/{key_base}" if prefix else key_base
|
||||||
|
try:
|
||||||
|
client = boto3.client("s3", endpoint_url=ep)
|
||||||
|
client.upload_file(local_file, bucket, key)
|
||||||
|
return True, f"s3://{bucket}/{key}"
|
||||||
|
except Exception as e:
|
||||||
|
return False, str(e)
|
||||||
|
|
||||||
|
|
||||||
def _run_database_backup(dbo: Database) -> tuple[bool, str, str | None]:
|
def _run_database_backup(dbo: Database) -> tuple[bool, str, str | None]:
|
||||||
"""Run database backup (sync). Returns (ok, msg, filename)."""
|
"""Run database backup (sync). Returns (ok, msg, filename)."""
|
||||||
cfg = get_runtime_config()
|
cfg = get_runtime_config()
|
||||||
@@ -164,15 +197,17 @@ async def backup_run_scheduled(
|
|||||||
"""Run all due backup plans. Call this from cron (e.g. every hour) or manually."""
|
"""Run all due backup plans. Call this from cron (e.g. every hour) or manually."""
|
||||||
from datetime import datetime as dt
|
from datetime import datetime as dt
|
||||||
now = dt.utcnow()
|
now = dt.utcnow()
|
||||||
|
cfg = get_runtime_config()
|
||||||
result = await db.execute(select(BackupPlan).where(BackupPlan.enabled == True))
|
result = await db.execute(select(BackupPlan).where(BackupPlan.enabled == True))
|
||||||
plans = result.scalars().all()
|
plans = result.scalars().all()
|
||||||
results = []
|
results = []
|
||||||
for plan in plans:
|
for plan in plans:
|
||||||
|
ok = False
|
||||||
|
msg = ""
|
||||||
try:
|
try:
|
||||||
prev_run = croniter(plan.schedule, now).get_prev(dt)
|
prev_run = croniter(plan.schedule, now).get_prev(dt)
|
||||||
# Run if we're within 15 minutes after the scheduled time
|
|
||||||
secs_since = (now - prev_run).total_seconds()
|
secs_since = (now - prev_run).total_seconds()
|
||||||
if secs_since > 900 or secs_since < 0: # Not within 15 min window
|
if secs_since > 900 or secs_since < 0:
|
||||||
continue
|
continue
|
||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
@@ -183,6 +218,11 @@ async def backup_run_scheduled(
|
|||||||
results.append({"plan": plan.name, "status": "skipped", "msg": "Site not found or path invalid"})
|
results.append({"plan": plan.name, "status": "skipped", "msg": "Site not found or path invalid"})
|
||||||
continue
|
continue
|
||||||
ok, msg, filename = _run_site_backup(site)
|
ok, msg, filename = _run_site_backup(site)
|
||||||
|
if ok and filename:
|
||||||
|
full = os.path.join(cfg["backup_path"], filename)
|
||||||
|
u_ok, u_msg = _maybe_upload_s3(full, plan)
|
||||||
|
if u_msg:
|
||||||
|
msg = f"{msg}; {u_msg}" if u_ok else f"{msg}; S3 failed: {u_msg}"
|
||||||
if ok:
|
if ok:
|
||||||
send_email(
|
send_email(
|
||||||
subject=f"YakPanel - Scheduled backup: {plan.name}",
|
subject=f"YakPanel - Scheduled backup: {plan.name}",
|
||||||
@@ -195,6 +235,11 @@ async def backup_run_scheduled(
|
|||||||
results.append({"plan": plan.name, "status": "skipped", "msg": "Database not found"})
|
results.append({"plan": plan.name, "status": "skipped", "msg": "Database not found"})
|
||||||
continue
|
continue
|
||||||
ok, msg, filename = _run_database_backup(dbo)
|
ok, msg, filename = _run_database_backup(dbo)
|
||||||
|
if ok and filename:
|
||||||
|
full = os.path.join(cfg["backup_path"], "database", filename)
|
||||||
|
u_ok, u_msg = _maybe_upload_s3(full, plan)
|
||||||
|
if u_msg:
|
||||||
|
msg = f"{msg}; {u_msg}" if u_ok else f"{msg}; S3 failed: {u_msg}"
|
||||||
if ok:
|
if ok:
|
||||||
send_email(
|
send_email(
|
||||||
subject=f"YakPanel - Scheduled backup: {plan.name}",
|
subject=f"YakPanel - Scheduled backup: {plan.name}",
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
"""YakPanel - Crontab API"""
|
"""YakPanel - Crontab API"""
|
||||||
|
import json
|
||||||
import tempfile
|
import tempfile
|
||||||
import os
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
@@ -14,6 +17,20 @@ from app.models.crontab import Crontab
|
|||||||
|
|
||||||
router = APIRouter(prefix="/crontab", tags=["crontab"])
|
router = APIRouter(prefix="/crontab", tags=["crontab"])
|
||||||
|
|
||||||
|
_CRON_TEMPLATES = Path(__file__).resolve().parent.parent / "data" / "cron_templates.json"
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/templates")
|
||||||
|
async def crontab_templates(current_user: User = Depends(get_current_user)):
|
||||||
|
"""YakPanel starter cron templates (edit before apply; no external branding)."""
|
||||||
|
if not _CRON_TEMPLATES.is_file():
|
||||||
|
return {"templates": []}
|
||||||
|
try:
|
||||||
|
data = json.loads(_CRON_TEMPLATES.read_text(encoding="utf-8"))
|
||||||
|
return {"templates": data if isinstance(data, list) else []}
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
return {"templates": []}
|
||||||
|
|
||||||
|
|
||||||
class CreateCrontabRequest(BaseModel):
|
class CreateCrontabRequest(BaseModel):
|
||||||
name: str = ""
|
name: str = ""
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ def _resolve_path(path: str) -> str:
|
|||||||
Resolve API path to an OS path.
|
Resolve API path to an OS path.
|
||||||
|
|
||||||
On Linux/macOS: path is an absolute POSIX path from filesystem root (/) so admins
|
On Linux/macOS: path is an absolute POSIX path from filesystem root (/) so admins
|
||||||
can browse the whole server (same expectation as BT/aaPanel-style panels).
|
can browse the whole server (typical expectation for a full-server admin file manager).
|
||||||
|
|
||||||
On Windows (dev): paths stay sandboxed under www_root / setup_path.
|
On Windows (dev): paths stay sandboxed under www_root / setup_path.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""YakPanel - FTP API"""
|
"""YakPanel - FTP API"""
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
import os
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select, func
|
from sqlalchemy import select, func
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
@@ -103,6 +104,25 @@ async def ftp_delete(
|
|||||||
return {"status": True, "msg": "FTP account deleted"}
|
return {"status": True, "msg": "FTP account deleted"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/logs")
|
||||||
|
async def ftp_logs(
|
||||||
|
lines: int = Query(default=200, ge=1, le=5000),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Tail common Pure-FTPd log paths if readable (non-destructive)."""
|
||||||
|
candidates = [
|
||||||
|
"/var/log/pure-ftpd/pure-ftpd.log",
|
||||||
|
"/var/log/pureftpd.log",
|
||||||
|
"/var/log/messages",
|
||||||
|
]
|
||||||
|
for path in candidates:
|
||||||
|
if os.path.isfile(path):
|
||||||
|
out, err = exec_shell_sync(f'tail -n {int(lines)} "{path}" 2>/dev/null', timeout=15)
|
||||||
|
text = (out or "") + (err or "")
|
||||||
|
return {"path": path, "content": text[-800000:] or "(empty)"}
|
||||||
|
return {"path": None, "content": "No known FTP log file found on this server."}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/count")
|
@router.get("/count")
|
||||||
async def ftp_count(
|
async def ftp_count(
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
|
|||||||
78
YakPanel-server/backend/app/api/security.py
Normal file
78
YakPanel-server/backend/app/api/security.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
"""YakPanel - read-only security checklist (local server probes)."""
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
|
from app.api.auth import get_current_user
|
||||||
|
from app.models.user import User
|
||||||
|
from app.core.utils import read_file, exec_shell_sync
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/security", tags=["security"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/checklist")
|
||||||
|
async def security_checklist(current_user: User = Depends(get_current_user)):
|
||||||
|
"""Non-destructive hints: SSH config, firewall helper, fail2ban. Not a full audit."""
|
||||||
|
items: list[dict] = []
|
||||||
|
sshd = "/etc/ssh/sshd_config"
|
||||||
|
body = read_file(sshd) if os.path.isfile(sshd) else None
|
||||||
|
if isinstance(body, str) and body:
|
||||||
|
if re.search(r"^\s*PasswordAuthentication\s+no\s*$", body, re.MULTILINE | re.IGNORECASE):
|
||||||
|
items.append({
|
||||||
|
"id": "ssh_password_auth",
|
||||||
|
"ok": True,
|
||||||
|
"title": "SSH password auth",
|
||||||
|
"detail": "PasswordAuthentication appears set to no (prefer key-based login).",
|
||||||
|
})
|
||||||
|
elif re.search(r"^\s*PasswordAuthentication\s+yes", body, re.MULTILINE | re.IGNORECASE):
|
||||||
|
items.append({
|
||||||
|
"id": "ssh_password_auth",
|
||||||
|
"ok": False,
|
||||||
|
"title": "SSH password auth",
|
||||||
|
"detail": "PasswordAuthentication is yes — consider disabling and using SSH keys.",
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
items.append({
|
||||||
|
"id": "ssh_password_auth",
|
||||||
|
"ok": None,
|
||||||
|
"title": "SSH password auth",
|
||||||
|
"detail": "Could not find an explicit PasswordAuthentication line (defaults depend on distro).",
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
items.append({
|
||||||
|
"id": "ssh_password_auth",
|
||||||
|
"ok": None,
|
||||||
|
"title": "SSH password auth",
|
||||||
|
"detail": "/etc/ssh/sshd_config not readable from the panel process.",
|
||||||
|
})
|
||||||
|
|
||||||
|
ufw_out, _ = exec_shell_sync("ufw status 2>/dev/null", timeout=5)
|
||||||
|
ufw = ufw_out or ""
|
||||||
|
if "Status: active" in ufw:
|
||||||
|
items.append({"id": "ufw", "ok": True, "title": "UFW firewall", "detail": "UFW reports active."})
|
||||||
|
elif "Status: inactive" in ufw:
|
||||||
|
items.append({
|
||||||
|
"id": "ufw",
|
||||||
|
"ok": None,
|
||||||
|
"title": "UFW firewall",
|
||||||
|
"detail": "UFW installed but inactive — enable if this host is public.",
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
items.append({
|
||||||
|
"id": "ufw",
|
||||||
|
"ok": None,
|
||||||
|
"title": "UFW firewall",
|
||||||
|
"detail": "UFW not detected (OK if you use firewalld/iptables only).",
|
||||||
|
})
|
||||||
|
|
||||||
|
f2_out, _ = exec_shell_sync("systemctl is-active fail2ban 2>/dev/null", timeout=5)
|
||||||
|
f2_active = (f2_out or "").strip() == "active"
|
||||||
|
items.append({
|
||||||
|
"id": "fail2ban",
|
||||||
|
"ok": f2_active,
|
||||||
|
"title": "fail2ban",
|
||||||
|
"detail": "fail2ban is active." if f2_active else "fail2ban not active (optional hardening).",
|
||||||
|
})
|
||||||
|
|
||||||
|
return {"items": items, "disclaimer": "YakPanel reads local settings only; this is not a compliance scan."}
|
||||||
@@ -29,6 +29,11 @@ class CreateSiteRequest(BaseModel):
|
|||||||
ps: str = ""
|
ps: str = ""
|
||||||
php_version: str = "74"
|
php_version: str = "74"
|
||||||
force_https: bool = False
|
force_https: bool = False
|
||||||
|
proxy_upstream: str = ""
|
||||||
|
proxy_websocket: bool = False
|
||||||
|
dir_auth_path: str = ""
|
||||||
|
dir_auth_user_file: str = ""
|
||||||
|
php_deny_execute: bool = False
|
||||||
|
|
||||||
|
|
||||||
class UpdateSiteRequest(BaseModel):
|
class UpdateSiteRequest(BaseModel):
|
||||||
@@ -37,6 +42,11 @@ class UpdateSiteRequest(BaseModel):
|
|||||||
ps: str | None = None
|
ps: str | None = None
|
||||||
php_version: str | None = None
|
php_version: str | None = None
|
||||||
force_https: bool | None = None
|
force_https: bool | None = None
|
||||||
|
proxy_upstream: str | None = None
|
||||||
|
proxy_websocket: bool | None = None
|
||||||
|
dir_auth_path: str | None = None
|
||||||
|
dir_auth_user_file: str | None = None
|
||||||
|
php_deny_execute: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
@router.get("/list")
|
@router.get("/list")
|
||||||
@@ -66,6 +76,11 @@ async def site_create(
|
|||||||
ps=body.ps,
|
ps=body.ps,
|
||||||
php_version=body.php_version or "74",
|
php_version=body.php_version or "74",
|
||||||
force_https=1 if body.force_https else 0,
|
force_https=1 if body.force_https else 0,
|
||||||
|
proxy_upstream=(body.proxy_upstream or "").strip(),
|
||||||
|
proxy_websocket=1 if body.proxy_websocket else 0,
|
||||||
|
dir_auth_path=(body.dir_auth_path or "").strip(),
|
||||||
|
dir_auth_user_file=(body.dir_auth_user_file or "").strip(),
|
||||||
|
php_deny_execute=1 if body.php_deny_execute else 0,
|
||||||
)
|
)
|
||||||
if not result["status"]:
|
if not result["status"]:
|
||||||
raise HTTPException(status_code=400, detail=result["msg"])
|
raise HTTPException(status_code=400, detail=result["msg"])
|
||||||
@@ -126,12 +141,22 @@ async def site_update(
|
|||||||
):
|
):
|
||||||
"""Update site domains, path, or note"""
|
"""Update site domains, path, or note"""
|
||||||
result = await update_site(
|
result = await update_site(
|
||||||
db, site_id,
|
db,
|
||||||
|
site_id,
|
||||||
path=body.path,
|
path=body.path,
|
||||||
domains=body.domains,
|
domains=body.domains,
|
||||||
ps=body.ps,
|
ps=body.ps,
|
||||||
php_version=body.php_version,
|
php_version=body.php_version,
|
||||||
force_https=None if body.force_https is None else (1 if body.force_https else 0),
|
force_https=None if body.force_https is None else (1 if body.force_https else 0),
|
||||||
|
proxy_upstream=body.proxy_upstream,
|
||||||
|
proxy_websocket=None
|
||||||
|
if body.proxy_websocket is None
|
||||||
|
else (1 if body.proxy_websocket else 0),
|
||||||
|
dir_auth_path=body.dir_auth_path,
|
||||||
|
dir_auth_user_file=body.dir_auth_user_file,
|
||||||
|
php_deny_execute=None
|
||||||
|
if body.php_deny_execute is None
|
||||||
|
else (1 if body.php_deny_execute else 0),
|
||||||
)
|
)
|
||||||
if not result["status"]:
|
if not result["status"]:
|
||||||
raise HTTPException(status_code=400, detail=result["msg"])
|
raise HTTPException(status_code=400, detail=result["msg"])
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import shutil
|
|||||||
import socket
|
import socket
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import tempfile
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
@@ -403,9 +404,42 @@ async def ssl_diagnostics(current_user: User = Depends(get_current_user)):
|
|||||||
"127.0.0.1:443 accepts TCP, but nginx -T from panel binaries did not show listen 443 — another process may own 443; check ss -tlnp and which nginx serves port 80."
|
"127.0.0.1:443 accepts TCP, but nginx -T from panel binaries did not show listen 443 — another process may own 443; check ss -tlnp and which nginx serves port 80."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
debian_sites = os.path.isdir("/etc/nginx/sites-available")
|
||||||
|
rhel_conf = os.path.isdir("/etc/nginx/conf.d")
|
||||||
|
layout = "unknown"
|
||||||
|
if debian_sites:
|
||||||
|
layout = "debian_sites_available"
|
||||||
|
elif rhel_conf:
|
||||||
|
layout = "rhel_conf_d"
|
||||||
|
drop_deb = "/etc/nginx/sites-available/yakpanel-vhosts.conf"
|
||||||
|
drop_rhel = "/etc/nginx/conf.d/yakpanel-vhosts.conf"
|
||||||
|
nginx_wizard = {
|
||||||
|
"detected_layout": layout,
|
||||||
|
"include_snippet": include_snippet,
|
||||||
|
"dropin_file_suggested": drop_deb if debian_sites else drop_rhel,
|
||||||
|
"debian": {
|
||||||
|
"sites_available_file": drop_deb,
|
||||||
|
"sites_enabled_symlink": "/etc/nginx/sites-enabled/yakpanel-vhosts.conf",
|
||||||
|
"steps": [
|
||||||
|
f"printf '%s\\n' '{include_snippet}' | sudo tee {drop_deb}",
|
||||||
|
f"sudo ln -sf {drop_deb} /etc/nginx/sites-enabled/yakpanel-vhosts.conf",
|
||||||
|
"sudo nginx -t && sudo systemctl reload nginx",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"rhel": {
|
||||||
|
"conf_d_file": drop_rhel,
|
||||||
|
"steps": [
|
||||||
|
f"printf '%s\\n' '{include_snippet}' | sudo tee {drop_rhel}",
|
||||||
|
"sudo nginx -t && sudo systemctl reload nginx",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"note": "Run the steps for your distro as root. The include line must appear inside the main http { } context (conf.d files do automatically).",
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"vhost_dir": vhost_dir,
|
"vhost_dir": vhost_dir,
|
||||||
"include_snippet": include_snippet,
|
"include_snippet": include_snippet,
|
||||||
|
"nginx_wizard": nginx_wizard,
|
||||||
"vhosts": vhost_summaries,
|
"vhosts": vhost_summaries,
|
||||||
"any_vhost_listen_ssl": any_vhost_443,
|
"any_vhost_listen_ssl": any_vhost_443,
|
||||||
"nginx_effective_listen_443": effective_listen_443,
|
"nginx_effective_listen_443": effective_listen_443,
|
||||||
@@ -417,6 +451,132 @@ async def ssl_diagnostics(current_user: User = Depends(get_current_user)):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class DnsCertCloudflareRequest(BaseModel):
|
||||||
|
domain: str
|
||||||
|
email: str
|
||||||
|
api_token: str
|
||||||
|
|
||||||
|
|
||||||
|
class DnsManualInstructionsRequest(BaseModel):
|
||||||
|
domain: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/dns-request/cloudflare")
|
||||||
|
async def ssl_dns_cloudflare_cert(
|
||||||
|
body: DnsCertCloudflareRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Request Let's Encrypt certificate using DNS-01 via Cloudflare (requires certbot-dns-cloudflare)."""
|
||||||
|
dom = (body.domain or "").split(":")[0].strip()
|
||||||
|
if not dom or ".." in dom or not body.email or not body.api_token:
|
||||||
|
raise HTTPException(status_code=400, detail="domain, email, and api_token required")
|
||||||
|
result_dom = await db.execute(select(Domain).where(Domain.name == dom).limit(1))
|
||||||
|
dom_row = result_dom.scalar_one_or_none()
|
||||||
|
if dom_row:
|
||||||
|
regen_pre = await regenerate_site_vhost(db, dom_row.pid)
|
||||||
|
if not regen_pre.get("status"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail="Cannot refresh nginx vhost: " + str(regen_pre.get("msg", "")),
|
||||||
|
)
|
||||||
|
ok_ngx, err_ngx = _reload_panel_and_common_nginx()
|
||||||
|
if not ok_ngx:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail="Nginx reload failed: " + err_ngx,
|
||||||
|
)
|
||||||
|
|
||||||
|
prefix = _certbot_command()
|
||||||
|
if not prefix:
|
||||||
|
raise HTTPException(status_code=500, detail=_certbot_missing_message())
|
||||||
|
|
||||||
|
hostnames = await _le_hostnames_for_domain_row(db, dom_row, dom)
|
||||||
|
if not hostnames:
|
||||||
|
hostnames = [dom]
|
||||||
|
cred_lines = f'dns_cloudflare_api_token = {body.api_token.strip()}\n'
|
||||||
|
fd, cred_path = tempfile.mkstemp(suffix=".ini", prefix="yakpanel_cf_")
|
||||||
|
try:
|
||||||
|
os.write(fd, cred_lines.encode())
|
||||||
|
os.close(fd)
|
||||||
|
os.chmod(cred_path, 0o600)
|
||||||
|
except OSError as e:
|
||||||
|
try:
|
||||||
|
os.close(fd)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
raise HTTPException(status_code=500, detail=f"Cannot write credentials temp file: {e}") from e
|
||||||
|
|
||||||
|
base_flags = [
|
||||||
|
"--non-interactive",
|
||||||
|
"--agree-tos",
|
||||||
|
"--email",
|
||||||
|
body.email.strip(),
|
||||||
|
"--no-eff-email",
|
||||||
|
"--dns-cloudflare",
|
||||||
|
"--dns-cloudflare-credentials",
|
||||||
|
cred_path,
|
||||||
|
]
|
||||||
|
cmd = prefix + ["certonly"] + base_flags
|
||||||
|
for h in hostnames:
|
||||||
|
cmd.extend(["-d", h])
|
||||||
|
env = environment_with_system_path()
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=600, env=env)
|
||||||
|
except (FileNotFoundError, subprocess.TimeoutExpired) as e:
|
||||||
|
try:
|
||||||
|
os.unlink(cred_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
raise HTTPException(status_code=500, detail=str(e)) from e
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
os.unlink(cred_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if proc.returncode != 0:
|
||||||
|
err = (proc.stderr or proc.stdout or "").strip() or f"exit {proc.returncode}"
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail="certbot DNS failed. Install certbot-dns-cloudflare (pip or OS package) if missing. " + err[:6000],
|
||||||
|
)
|
||||||
|
|
||||||
|
if dom_row:
|
||||||
|
regen = await regenerate_site_vhost(db, dom_row.pid)
|
||||||
|
if not regen.get("status"):
|
||||||
|
return {
|
||||||
|
"status": True,
|
||||||
|
"msg": "Certificate issued but vhost regen failed: " + str(regen.get("msg", "")),
|
||||||
|
"output": (proc.stdout or "")[-2000:],
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": True,
|
||||||
|
"msg": "Certificate issued via Cloudflare DNS-01",
|
||||||
|
"output": (proc.stdout or "")[-2000:],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/dns-request/manual-instructions")
|
||||||
|
async def ssl_dns_manual_instructions(
|
||||||
|
body: DnsManualInstructionsRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Return TXT record host for ACME DNS-01 (user creates record then runs certbot --manual)."""
|
||||||
|
d = (body.domain or "").split(":")[0].strip()
|
||||||
|
if not d or ".." in d:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid domain")
|
||||||
|
return {
|
||||||
|
"txt_record_name": f"_acme-challenge.{d}",
|
||||||
|
"certbot_example": (
|
||||||
|
f"sudo certbot certonly --manual --preferred-challenges dns --email you@example.com "
|
||||||
|
f"--agree-tos -d {d}"
|
||||||
|
),
|
||||||
|
"note": "Certbot will display the exact TXT value to create. After DNS propagates, continue in the terminal.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/certificates")
|
@router.get("/certificates")
|
||||||
async def ssl_list_certificates(current_user: User = Depends(get_current_user)):
|
async def ssl_list_certificates(current_user: User = Depends(get_current_user)):
|
||||||
"""List existing Let's Encrypt certificates"""
|
"""List existing Let's Encrypt certificates"""
|
||||||
|
|||||||
Binary file not shown.
@@ -107,6 +107,16 @@ def _run_migrations(conn):
|
|||||||
"ALTER TABLE sites ADD COLUMN force_https INTEGER DEFAULT 0"
|
"ALTER TABLE sites ADD COLUMN force_https INTEGER DEFAULT 0"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
if "proxy_upstream" not in cols:
|
||||||
|
conn.execute(sqlalchemy.text("ALTER TABLE sites ADD COLUMN proxy_upstream VARCHAR(512) DEFAULT ''"))
|
||||||
|
if "proxy_websocket" not in cols:
|
||||||
|
conn.execute(sqlalchemy.text("ALTER TABLE sites ADD COLUMN proxy_websocket INTEGER DEFAULT 0"))
|
||||||
|
if "dir_auth_path" not in cols:
|
||||||
|
conn.execute(sqlalchemy.text("ALTER TABLE sites ADD COLUMN dir_auth_path VARCHAR(256) DEFAULT ''"))
|
||||||
|
if "dir_auth_user_file" not in cols:
|
||||||
|
conn.execute(sqlalchemy.text("ALTER TABLE sites ADD COLUMN dir_auth_user_file VARCHAR(512) DEFAULT ''"))
|
||||||
|
if "php_deny_execute" not in cols:
|
||||||
|
conn.execute(sqlalchemy.text("ALTER TABLE sites ADD COLUMN php_deny_execute INTEGER DEFAULT 0"))
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
# Create backup_plans if not exists (create_all handles new installs)
|
# Create backup_plans if not exists (create_all handles new installs)
|
||||||
@@ -123,6 +133,18 @@ def _run_migrations(conn):
|
|||||||
"""))
|
"""))
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
try:
|
||||||
|
r = conn.execute(sqlalchemy.text("PRAGMA table_info(backup_plans)"))
|
||||||
|
bcols = [row[1] for row in r.fetchall()]
|
||||||
|
if bcols:
|
||||||
|
if "s3_bucket" not in bcols:
|
||||||
|
conn.execute(sqlalchemy.text("ALTER TABLE backup_plans ADD COLUMN s3_bucket VARCHAR(256) DEFAULT ''"))
|
||||||
|
if "s3_endpoint" not in bcols:
|
||||||
|
conn.execute(sqlalchemy.text("ALTER TABLE backup_plans ADD COLUMN s3_endpoint VARCHAR(512) DEFAULT ''"))
|
||||||
|
if "s3_key_prefix" not in bcols:
|
||||||
|
conn.execute(sqlalchemy.text("ALTER TABLE backup_plans ADD COLUMN s3_key_prefix VARCHAR(256) DEFAULT ''"))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
# Create custom_plugins if not exists
|
# Create custom_plugins if not exists
|
||||||
try:
|
try:
|
||||||
conn.execute(sqlalchemy.text("""
|
conn.execute(sqlalchemy.text("""
|
||||||
|
|||||||
30
YakPanel-server/backend/app/data/cron_templates.json
Normal file
30
YakPanel-server/backend/app/data/cron_templates.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "disk_space",
|
||||||
|
"name": "Disk free space alert",
|
||||||
|
"schedule": "0 */6 * * *",
|
||||||
|
"execstr": "df -h / | tail -1 | awk '{if (int($5) > 90) print \"Disk usage over 90% on / — \" $0; exit 0}'",
|
||||||
|
"description": "Print a line if root filesystem use exceeds 90% (extend with mail/curl as needed)."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "yakpanel_backup",
|
||||||
|
"name": "Run YakPanel scheduled backups",
|
||||||
|
"schedule": "15 * * * *",
|
||||||
|
"execstr": "curl -fsS -H \"Authorization: Bearer YOUR_TOKEN\" http://127.0.0.1:8889/api/v1/backup/run-scheduled || true",
|
||||||
|
"description": "Example: call the panel backup API hourly (set token and port; prefer localhost + firewall)."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "clear_tmp",
|
||||||
|
"name": "Clean old temp files",
|
||||||
|
"schedule": "0 3 * * *",
|
||||||
|
"execstr": "find /tmp -type f -atime +7 -delete 2>/dev/null || true",
|
||||||
|
"description": "Remove files in /tmp not accessed in 7 days."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "php_fpm_ping",
|
||||||
|
"name": "PHP-FPM socket check",
|
||||||
|
"schedule": "*/10 * * * *",
|
||||||
|
"execstr": "test -S /tmp/php-cgi-74.sock && exit 0 || echo \"php-fpm 74 socket missing\"",
|
||||||
|
"description": "Adjust php version/socket path for your stack."
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -29,6 +29,7 @@ from app.api import (
|
|||||||
node,
|
node,
|
||||||
service,
|
service,
|
||||||
public_installer,
|
public_installer,
|
||||||
|
security,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -87,6 +88,7 @@ app.include_router(config.router, prefix="/api/v1")
|
|||||||
app.include_router(user.router, prefix="/api/v1")
|
app.include_router(user.router, prefix="/api/v1")
|
||||||
app.include_router(logs.router, prefix="/api/v1")
|
app.include_router(logs.router, prefix="/api/v1")
|
||||||
app.include_router(public_installer.router, prefix="/api/v1")
|
app.include_router(public_installer.router, prefix="/api/v1")
|
||||||
|
app.include_router(security.router, prefix="/api/v1")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -13,3 +13,7 @@ class BackupPlan(Base):
|
|||||||
target_id: Mapped[int] = mapped_column(Integer, nullable=False) # site_id or database_id
|
target_id: Mapped[int] = mapped_column(Integer, nullable=False) # site_id or database_id
|
||||||
schedule: Mapped[str] = mapped_column(String(64), nullable=False) # cron expression, e.g. "0 2 * * *" = daily 2am
|
schedule: Mapped[str] = mapped_column(String(64), nullable=False) # cron expression, e.g. "0 2 * * *" = daily 2am
|
||||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
# Optional S3-compatible copy after local backup (uses AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY env).
|
||||||
|
s3_bucket: Mapped[str] = mapped_column(String(256), default="")
|
||||||
|
s3_endpoint: Mapped[str] = mapped_column(String(512), default="")
|
||||||
|
s3_key_prefix: Mapped[str] = mapped_column(String(256), default="")
|
||||||
|
|||||||
@@ -16,6 +16,14 @@ class Site(Base):
|
|||||||
project_type: Mapped[str] = mapped_column(String(32), default="PHP")
|
project_type: Mapped[str] = mapped_column(String(32), default="PHP")
|
||||||
php_version: Mapped[str] = mapped_column(String(16), default="74") # 74, 80, 81, 82
|
php_version: Mapped[str] = mapped_column(String(16), default="74") # 74, 80, 81, 82
|
||||||
force_https: Mapped[int] = mapped_column(Integer, default=0) # 0=off, 1=redirect HTTP to HTTPS
|
force_https: Mapped[int] = mapped_column(Integer, default=0) # 0=off, 1=redirect HTTP to HTTPS
|
||||||
|
# Reverse proxy: when proxy_upstream is non-empty, vhost uses proxy_pass instead of PHP root.
|
||||||
|
proxy_upstream: Mapped[str] = mapped_column(String(512), default="") # e.g. http://127.0.0.1:3000
|
||||||
|
proxy_websocket: Mapped[int] = mapped_column(Integer, default=0) # 1 = Upgrade headers for WS
|
||||||
|
# HTTP basic auth for a path prefix (nginx auth_basic). user_file = htpasswd path on server.
|
||||||
|
dir_auth_path: Mapped[str] = mapped_column(String(256), default="")
|
||||||
|
dir_auth_user_file: Mapped[str] = mapped_column(String(512), default="")
|
||||||
|
# Block execution of PHP under common upload paths (nginx deny).
|
||||||
|
php_deny_execute: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
addtime: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
addtime: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@@ -204,42 +204,42 @@ def _letsencrypt_paths_any(hostnames: list[str]) -> tuple[str, str] | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _build_ssl_server_block(
|
def _build_php_deny_execute_block(enabled: int) -> str:
|
||||||
server_names: str,
|
if not enabled:
|
||||||
root_path: str,
|
return ""
|
||||||
logs_path: str,
|
return (
|
||||||
site_name: str,
|
r" location ~* ^/uploads/.*\.(php|phar|phtml|php5)$ {" + "\n"
|
||||||
php_version: str,
|
r" deny all;" + "\n"
|
||||||
fullchain: str,
|
r" }" + "\n"
|
||||||
privkey: str,
|
r" location ~* ^/storage/.*\.(php|phar|phtml|php5)$ {" + "\n"
|
||||||
redirects: list[tuple[str, str, int]] | None,
|
r" deny all;" + "\n"
|
||||||
) -> str:
|
r" }" + "\n"
|
||||||
"""Second server {} for HTTPS when LE certs exist."""
|
)
|
||||||
pv = php_version or "74"
|
|
||||||
redirect_lines: list[str] = []
|
|
||||||
for src, tgt, code in (redirects or []):
|
def _build_main_app_block(proxy_upstream: str, proxy_websocket: int, php_version: str) -> str:
|
||||||
if src and tgt:
|
pu = (proxy_upstream or "").strip()
|
||||||
redirect_lines.append(f" location = {src} {{ return {code} {tgt}; }}")
|
pv = php_version or "74"
|
||||||
redirect_block = ("\n" + "\n".join(redirect_lines)) if redirect_lines else ""
|
if pu:
|
||||||
q_fc = fullchain.replace("\\", "\\\\").replace('"', '\\"')
|
ws_lines = ""
|
||||||
q_pk = privkey.replace("\\", "\\\\").replace('"', '\\"')
|
if proxy_websocket:
|
||||||
|
ws_lines = (
|
||||||
|
" proxy_set_header Upgrade $http_upgrade;\n"
|
||||||
|
' proxy_set_header Connection "upgrade";\n'
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
f" location / {{\n"
|
||||||
|
f" proxy_pass {pu};\n"
|
||||||
|
f" proxy_http_version 1.1;\n"
|
||||||
|
f" proxy_set_header Host $host;\n"
|
||||||
|
f" proxy_set_header X-Real-IP $remote_addr;\n"
|
||||||
|
f" proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n"
|
||||||
|
f" proxy_set_header X-Forwarded-Proto $scheme;\n"
|
||||||
|
f"{ws_lines}"
|
||||||
|
f" proxy_read_timeout 3600s;\n"
|
||||||
|
f" }}\n"
|
||||||
|
)
|
||||||
return (
|
return (
|
||||||
f"server {{\n"
|
|
||||||
f" listen 443 ssl;\n"
|
|
||||||
f" server_name {server_names};\n"
|
|
||||||
f' ssl_certificate "{q_fc}";\n'
|
|
||||||
f' ssl_certificate_key "{q_pk}";\n'
|
|
||||||
f" index index.php index.html index.htm default.php default.htm default.html;\n"
|
|
||||||
f" root {root_path};\n"
|
|
||||||
f" error_page 404 /404.html;\n"
|
|
||||||
f" error_page 502 /502.html;\n"
|
|
||||||
f" location ^~ /.well-known/acme-challenge/ {{\n"
|
|
||||||
f" root {root_path};\n"
|
|
||||||
f' default_type "text/plain";\n'
|
|
||||||
f" allow all;\n"
|
|
||||||
f" access_log off;\n"
|
|
||||||
f" }}\n"
|
|
||||||
f"{redirect_block}\n"
|
|
||||||
r" location ~ .*\.(gif|jpg|jpeg|png|bmp|swf)$ {" + "\n"
|
r" location ~ .*\.(gif|jpg|jpeg|png|bmp|swf)$ {" + "\n"
|
||||||
f" expires 30d;\n"
|
f" expires 30d;\n"
|
||||||
f" access_log off;\n"
|
f" access_log off;\n"
|
||||||
@@ -253,6 +253,116 @@ def _build_ssl_server_block(
|
|||||||
f" fastcgi_index index.php;\n"
|
f" fastcgi_index index.php;\n"
|
||||||
f" include fastcgi.conf;\n"
|
f" include fastcgi.conf;\n"
|
||||||
f" }}\n"
|
f" }}\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_dir_auth_block(
|
||||||
|
dir_path: str,
|
||||||
|
user_file: str,
|
||||||
|
proxy_upstream: str,
|
||||||
|
root_path: str,
|
||||||
|
) -> str:
|
||||||
|
dp = (dir_path or "").strip()
|
||||||
|
uf = (user_file or "").strip()
|
||||||
|
if not dp or not uf or ".." in dp or ".." in uf:
|
||||||
|
return ""
|
||||||
|
if not dp.startswith("/"):
|
||||||
|
dp = "/" + dp
|
||||||
|
qf = uf.replace("\\", "\\\\").replace('"', '\\"')
|
||||||
|
qr = root_path.replace("\\", "\\\\")
|
||||||
|
pu = (proxy_upstream or "").strip()
|
||||||
|
if pu:
|
||||||
|
puc = pu.rstrip("/")
|
||||||
|
return (
|
||||||
|
f" location ^~ {dp} {{\n"
|
||||||
|
f' auth_basic "YakPanel";\n'
|
||||||
|
f' auth_basic_user_file "{qf}";\n'
|
||||||
|
f" proxy_pass {puc};\n"
|
||||||
|
f" proxy_http_version 1.1;\n"
|
||||||
|
f" proxy_set_header Host $host;\n"
|
||||||
|
f" proxy_set_header X-Real-IP $remote_addr;\n"
|
||||||
|
f" proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n"
|
||||||
|
f" proxy_set_header X-Forwarded-Proto $scheme;\n"
|
||||||
|
f" }}\n"
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
f" location ^~ {dp} {{\n"
|
||||||
|
f' auth_basic "YakPanel";\n'
|
||||||
|
f' auth_basic_user_file "{qf}";\n'
|
||||||
|
f" root {qr};\n"
|
||||||
|
f" try_files $uri $uri/ =404;\n"
|
||||||
|
f" }}\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_location_bundle(
|
||||||
|
root_path: str,
|
||||||
|
redirects: list[tuple[str, str, int]] | None,
|
||||||
|
proxy_upstream: str,
|
||||||
|
proxy_websocket: int,
|
||||||
|
dir_auth_path: str,
|
||||||
|
dir_auth_user_file: str,
|
||||||
|
php_deny_execute: int,
|
||||||
|
php_version: str,
|
||||||
|
) -> str:
|
||||||
|
acme = (
|
||||||
|
f" location ^~ /.well-known/acme-challenge/ {{\n"
|
||||||
|
f" root {root_path};\n"
|
||||||
|
f' default_type "text/plain";\n'
|
||||||
|
f" allow all;\n"
|
||||||
|
f" access_log off;\n"
|
||||||
|
f" }}\n"
|
||||||
|
)
|
||||||
|
redirect_lines = []
|
||||||
|
for src, tgt, code in redirects or []:
|
||||||
|
if src and tgt:
|
||||||
|
redirect_lines.append(f" location = {src} {{ return {code} {tgt}; }}")
|
||||||
|
redirect_block = ("\n" + "\n".join(redirect_lines)) if redirect_lines else ""
|
||||||
|
dir_auth = _build_dir_auth_block(dir_auth_path, dir_auth_user_file, proxy_upstream, root_path)
|
||||||
|
php_deny = _build_php_deny_execute_block(php_deny_execute)
|
||||||
|
main = _build_main_app_block(proxy_upstream, proxy_websocket, php_version)
|
||||||
|
return acme + redirect_block + "\n" + dir_auth + php_deny + main
|
||||||
|
|
||||||
|
|
||||||
|
def _build_ssl_server_block(
|
||||||
|
server_names: str,
|
||||||
|
root_path: str,
|
||||||
|
logs_path: str,
|
||||||
|
site_name: str,
|
||||||
|
php_version: str,
|
||||||
|
fullchain: str,
|
||||||
|
privkey: str,
|
||||||
|
redirects: list[tuple[str, str, int]] | None,
|
||||||
|
proxy_upstream: str = "",
|
||||||
|
proxy_websocket: int = 0,
|
||||||
|
dir_auth_path: str = "",
|
||||||
|
dir_auth_user_file: str = "",
|
||||||
|
php_deny_execute: int = 0,
|
||||||
|
) -> str:
|
||||||
|
"""Second server {} for HTTPS when LE certs exist."""
|
||||||
|
q_fc = fullchain.replace("\\", "\\\\").replace('"', '\\"')
|
||||||
|
q_pk = privkey.replace("\\", "\\\\").replace('"', '\\"')
|
||||||
|
bundle = _build_location_bundle(
|
||||||
|
root_path,
|
||||||
|
redirects,
|
||||||
|
proxy_upstream,
|
||||||
|
proxy_websocket,
|
||||||
|
dir_auth_path,
|
||||||
|
dir_auth_user_file,
|
||||||
|
php_deny_execute,
|
||||||
|
php_version,
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
f"server {{\n"
|
||||||
|
f" listen 443 ssl;\n"
|
||||||
|
f" server_name {server_names};\n"
|
||||||
|
f' ssl_certificate "{q_fc}";\n'
|
||||||
|
f' ssl_certificate_key "{q_pk}";\n'
|
||||||
|
f" index index.php index.html index.htm default.php default.htm default.html;\n"
|
||||||
|
f" root {root_path};\n"
|
||||||
|
f" error_page 404 /404.html;\n"
|
||||||
|
f" error_page 502 /502.html;\n"
|
||||||
|
f"{bundle}"
|
||||||
f" access_log {logs_path}/{site_name}.log;\n"
|
f" access_log {logs_path}/{site_name}.log;\n"
|
||||||
f" error_log {logs_path}/{site_name}.error.log;\n"
|
f" error_log {logs_path}/{site_name}.error.log;\n"
|
||||||
f"}}\n"
|
f"}}\n"
|
||||||
@@ -269,6 +379,11 @@ def _render_vhost(
|
|||||||
force_https: int,
|
force_https: int,
|
||||||
redirects: list[tuple[str, str, int]] | None = None,
|
redirects: list[tuple[str, str, int]] | None = None,
|
||||||
le_hostnames: list[str] | None = None,
|
le_hostnames: list[str] | None = None,
|
||||||
|
proxy_upstream: str = "",
|
||||||
|
proxy_websocket: int = 0,
|
||||||
|
dir_auth_path: str = "",
|
||||||
|
dir_auth_user_file: str = "",
|
||||||
|
php_deny_execute: int = 0,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Render nginx vhost template. redirects: [(source, target, code), ...]"""
|
"""Render nginx vhost template. redirects: [(source, target, code), ...]"""
|
||||||
if force_https:
|
if force_https:
|
||||||
@@ -279,28 +394,43 @@ def _render_vhost(
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
force_block = ""
|
force_block = ""
|
||||||
redirect_lines = []
|
|
||||||
for src, tgt, code in (redirects or []):
|
|
||||||
if src and tgt:
|
|
||||||
redirect_lines.append(f" location = {src} {{ return {code} {tgt}; }}")
|
|
||||||
redirect_block = "\n".join(redirect_lines) if redirect_lines else ""
|
|
||||||
hosts = le_hostnames if le_hostnames is not None else [p for p in server_names.split() if p]
|
hosts = le_hostnames if le_hostnames is not None else [p for p in server_names.split() if p]
|
||||||
ssl_block = ""
|
ssl_block = ""
|
||||||
for h in hosts:
|
le = _letsencrypt_paths_any(hosts)
|
||||||
le = _letsencrypt_paths(h)
|
if le:
|
||||||
if le:
|
fc, pk = le
|
||||||
fc, pk = le
|
ssl_block = _build_ssl_server_block(
|
||||||
ssl_block = _build_ssl_server_block(
|
server_names,
|
||||||
server_names, root_path, logs_path, site_name, php_version, fc, pk, redirects
|
root_path,
|
||||||
)
|
logs_path,
|
||||||
break
|
site_name,
|
||||||
|
php_version,
|
||||||
|
fc,
|
||||||
|
pk,
|
||||||
|
redirects,
|
||||||
|
proxy_upstream,
|
||||||
|
proxy_websocket,
|
||||||
|
dir_auth_path,
|
||||||
|
dir_auth_user_file,
|
||||||
|
php_deny_execute,
|
||||||
|
)
|
||||||
|
bundle = _build_location_bundle(
|
||||||
|
root_path,
|
||||||
|
redirects,
|
||||||
|
proxy_upstream,
|
||||||
|
proxy_websocket,
|
||||||
|
dir_auth_path,
|
||||||
|
dir_auth_user_file,
|
||||||
|
php_deny_execute,
|
||||||
|
php_version,
|
||||||
|
)
|
||||||
content = template.replace("{SERVER_NAMES}", server_names)
|
content = template.replace("{SERVER_NAMES}", server_names)
|
||||||
content = content.replace("{ROOT_PATH}", root_path)
|
content = content.replace("{ROOT_PATH}", root_path)
|
||||||
content = content.replace("{LOGS_PATH}", logs_path)
|
content = content.replace("{LOGS_PATH}", logs_path)
|
||||||
content = content.replace("{SITE_NAME}", site_name)
|
content = content.replace("{SITE_NAME}", site_name)
|
||||||
content = content.replace("{PHP_VERSION}", php_version or "74")
|
content = content.replace("{PHP_VERSION}", php_version or "74")
|
||||||
content = content.replace("{FORCE_HTTPS_BLOCK}", force_block)
|
content = content.replace("{FORCE_HTTPS_BLOCK}", force_block)
|
||||||
content = content.replace("{REDIRECTS_BLOCK}", redirect_block)
|
content = content.replace("{LOCATION_BUNDLE}", bundle)
|
||||||
content = content.replace("{SSL_SERVER_BLOCK}", ssl_block)
|
content = content.replace("{SSL_SERVER_BLOCK}", ssl_block)
|
||||||
return content
|
return content
|
||||||
|
|
||||||
@@ -327,6 +457,16 @@ async def domain_exists(db: AsyncSession, domains: list[str], exclude_site_id: i
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _vhost_kwargs_from_site(site: Site) -> dict:
|
||||||
|
return {
|
||||||
|
"proxy_upstream": getattr(site, "proxy_upstream", None) or "",
|
||||||
|
"proxy_websocket": int(getattr(site, "proxy_websocket", 0) or 0),
|
||||||
|
"dir_auth_path": getattr(site, "dir_auth_path", None) or "",
|
||||||
|
"dir_auth_user_file": getattr(site, "dir_auth_user_file", None) or "",
|
||||||
|
"php_deny_execute": int(getattr(site, "php_deny_execute", 0) or 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def create_site(
|
async def create_site(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
name: str,
|
name: str,
|
||||||
@@ -336,6 +476,11 @@ async def create_site(
|
|||||||
ps: str = "",
|
ps: str = "",
|
||||||
php_version: str = "74",
|
php_version: str = "74",
|
||||||
force_https: int = 0,
|
force_https: int = 0,
|
||||||
|
proxy_upstream: str = "",
|
||||||
|
proxy_websocket: int = 0,
|
||||||
|
dir_auth_path: str = "",
|
||||||
|
dir_auth_user_file: str = "",
|
||||||
|
php_deny_execute: int = 0,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Create a new site with vhost config."""
|
"""Create a new site with vhost config."""
|
||||||
if not path_safe_check(name) or not path_safe_check(path):
|
if not path_safe_check(name) or not path_safe_check(path):
|
||||||
@@ -359,7 +504,19 @@ async def create_site(
|
|||||||
if not os.path.exists(site_path):
|
if not os.path.exists(site_path):
|
||||||
os.makedirs(site_path, 0o755)
|
os.makedirs(site_path, 0o755)
|
||||||
|
|
||||||
site = Site(name=name, path=site_path, ps=ps, project_type=project_type, php_version=php_version or "74", force_https=force_https or 0)
|
site = Site(
|
||||||
|
name=name,
|
||||||
|
path=site_path,
|
||||||
|
ps=ps,
|
||||||
|
project_type=project_type,
|
||||||
|
php_version=php_version or "74",
|
||||||
|
force_https=force_https or 0,
|
||||||
|
proxy_upstream=(proxy_upstream or "")[:512],
|
||||||
|
proxy_websocket=1 if proxy_websocket else 0,
|
||||||
|
dir_auth_path=(dir_auth_path or "")[:256],
|
||||||
|
dir_auth_user_file=(dir_auth_user_file or "")[:512],
|
||||||
|
php_deny_execute=1 if php_deny_execute else 0,
|
||||||
|
)
|
||||||
db.add(site)
|
db.add(site)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|
||||||
@@ -379,8 +536,18 @@ async def create_site(
|
|||||||
template = read_file(template_path) or ""
|
template = read_file(template_path) or ""
|
||||||
server_names = " ".join(d.split(":")[0] for d in domains)
|
server_names = " ".join(d.split(":")[0] for d in domains)
|
||||||
le_hosts = [d.split(":")[0] for d in domains]
|
le_hosts = [d.split(":")[0] for d in domains]
|
||||||
|
vk = _vhost_kwargs_from_site(site)
|
||||||
content = _render_vhost(
|
content = _render_vhost(
|
||||||
template, server_names, site_path, www_logs, name, php_version or "74", force_https or 0, [], le_hosts
|
template,
|
||||||
|
server_names,
|
||||||
|
site_path,
|
||||||
|
www_logs,
|
||||||
|
name,
|
||||||
|
php_version or "74",
|
||||||
|
force_https or 0,
|
||||||
|
[],
|
||||||
|
le_hosts,
|
||||||
|
**vk,
|
||||||
)
|
)
|
||||||
write_file(conf_path, content)
|
write_file(conf_path, content)
|
||||||
|
|
||||||
@@ -477,6 +644,11 @@ async def get_site_with_domains(db: AsyncSession, site_id: int) -> dict | None:
|
|||||||
"project_type": site.project_type,
|
"project_type": site.project_type,
|
||||||
"php_version": getattr(site, "php_version", None) or "74",
|
"php_version": getattr(site, "php_version", None) or "74",
|
||||||
"force_https": getattr(site, "force_https", 0) or 0,
|
"force_https": getattr(site, "force_https", 0) or 0,
|
||||||
|
"proxy_upstream": getattr(site, "proxy_upstream", None) or "",
|
||||||
|
"proxy_websocket": int(getattr(site, "proxy_websocket", 0) or 0),
|
||||||
|
"dir_auth_path": getattr(site, "dir_auth_path", None) or "",
|
||||||
|
"dir_auth_user_file": getattr(site, "dir_auth_user_file", None) or "",
|
||||||
|
"php_deny_execute": int(getattr(site, "php_deny_execute", 0) or 0),
|
||||||
"domains": domain_list,
|
"domains": domain_list,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -489,6 +661,11 @@ async def update_site(
|
|||||||
ps: str | None = None,
|
ps: str | None = None,
|
||||||
php_version: str | None = None,
|
php_version: str | None = None,
|
||||||
force_https: int | None = None,
|
force_https: int | None = None,
|
||||||
|
proxy_upstream: str | None = None,
|
||||||
|
proxy_websocket: int | None = None,
|
||||||
|
dir_auth_path: str | None = None,
|
||||||
|
dir_auth_user_file: str | None = None,
|
||||||
|
php_deny_execute: int | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Update site domains, path, or note."""
|
"""Update site domains, path, or note."""
|
||||||
result = await db.execute(select(Site).where(Site.id == site_id))
|
result = await db.execute(select(Site).where(Site.id == site_id))
|
||||||
@@ -518,11 +695,30 @@ async def update_site(
|
|||||||
site.php_version = php_version or "74"
|
site.php_version = php_version or "74"
|
||||||
if force_https is not None:
|
if force_https is not None:
|
||||||
site.force_https = 1 if force_https else 0
|
site.force_https = 1 if force_https else 0
|
||||||
|
if proxy_upstream is not None:
|
||||||
|
site.proxy_upstream = (proxy_upstream or "")[:512]
|
||||||
|
if proxy_websocket is not None:
|
||||||
|
site.proxy_websocket = 1 if proxy_websocket else 0
|
||||||
|
if dir_auth_path is not None:
|
||||||
|
site.dir_auth_path = (dir_auth_path or "")[:256]
|
||||||
|
if dir_auth_user_file is not None:
|
||||||
|
site.dir_auth_user_file = (dir_auth_user_file or "")[:512]
|
||||||
|
if php_deny_execute is not None:
|
||||||
|
site.php_deny_execute = 1 if php_deny_execute else 0
|
||||||
|
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|
||||||
# Regenerate Nginx vhost if domains, php_version, or force_https changed
|
regen = (
|
||||||
if domains is not None or php_version is not None or force_https is not None:
|
domains is not None
|
||||||
|
or php_version is not None
|
||||||
|
or force_https is not None
|
||||||
|
or proxy_upstream is not None
|
||||||
|
or proxy_websocket is not None
|
||||||
|
or dir_auth_path is not None
|
||||||
|
or dir_auth_user_file is not None
|
||||||
|
or php_deny_execute is not None
|
||||||
|
)
|
||||||
|
if regen:
|
||||||
cfg = get_runtime_config()
|
cfg = get_runtime_config()
|
||||||
vhost_path = os.path.join(cfg["setup_path"], "panel", "vhost", "nginx")
|
vhost_path = os.path.join(cfg["setup_path"], "panel", "vhost", "nginx")
|
||||||
conf_path = os.path.join(vhost_path, f"{site.name}.conf")
|
conf_path = os.path.join(vhost_path, f"{site.name}.conf")
|
||||||
@@ -538,8 +734,18 @@ async def update_site(
|
|||||||
redir_result = await db.execute(select(SiteRedirect).where(SiteRedirect.site_id == site.id))
|
redir_result = await db.execute(select(SiteRedirect).where(SiteRedirect.site_id == site.id))
|
||||||
redirects = [(r.source, r.target, r.code or 301) for r in redir_result.scalars().all()]
|
redirects = [(r.source, r.target, r.code or 301) for r in redir_result.scalars().all()]
|
||||||
le_hosts = [d.name for d in domain_rows]
|
le_hosts = [d.name for d in domain_rows]
|
||||||
|
vk = _vhost_kwargs_from_site(site)
|
||||||
content = _render_vhost(
|
content = _render_vhost(
|
||||||
template, server_names, site.path, cfg["www_logs"], site.name, php_ver, fhttps, redirects, le_hosts
|
template,
|
||||||
|
server_names,
|
||||||
|
site.path,
|
||||||
|
cfg["www_logs"],
|
||||||
|
site.name,
|
||||||
|
php_ver,
|
||||||
|
fhttps,
|
||||||
|
redirects,
|
||||||
|
le_hosts,
|
||||||
|
**vk,
|
||||||
)
|
)
|
||||||
write_file(conf_path, content)
|
write_file(conf_path, content)
|
||||||
reload_ok, reload_err = nginx_reload_all_known()
|
reload_ok, reload_err = nginx_reload_all_known()
|
||||||
@@ -623,8 +829,18 @@ async def regenerate_site_vhost(db: AsyncSession, site_id: int) -> dict:
|
|||||||
redir_result = await db.execute(select(SiteRedirect).where(SiteRedirect.site_id == site.id))
|
redir_result = await db.execute(select(SiteRedirect).where(SiteRedirect.site_id == site.id))
|
||||||
redirects = [(r.source, r.target, r.code or 301) for r in redir_result.scalars().all()]
|
redirects = [(r.source, r.target, r.code or 301) for r in redir_result.scalars().all()]
|
||||||
le_hosts = [d.name for d in domain_rows]
|
le_hosts = [d.name for d in domain_rows]
|
||||||
|
vk = _vhost_kwargs_from_site(site)
|
||||||
content = _render_vhost(
|
content = _render_vhost(
|
||||||
template, server_names, site.path, cfg["www_logs"], site.name, php_ver, fhttps, redirects, le_hosts
|
template,
|
||||||
|
server_names,
|
||||||
|
site.path,
|
||||||
|
cfg["www_logs"],
|
||||||
|
site.name,
|
||||||
|
php_ver,
|
||||||
|
fhttps,
|
||||||
|
redirects,
|
||||||
|
le_hosts,
|
||||||
|
**vk,
|
||||||
)
|
)
|
||||||
write_file(write_path, content)
|
write_file(write_path, content)
|
||||||
reload_ok, reload_err = nginx_reload_all_known()
|
reload_ok, reload_err = nginx_reload_all_known()
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ celery>=5.3.0
|
|||||||
# Let's Encrypt (optional if system certbot/snap not used; enables python -m certbot from panel venv)
|
# Let's Encrypt (optional if system certbot/snap not used; enables python -m certbot from panel venv)
|
||||||
certbot>=3.0.0
|
certbot>=3.0.0
|
||||||
certbot-nginx>=3.0.0
|
certbot-nginx>=3.0.0
|
||||||
|
certbot-dns-cloudflare>=3.0.0
|
||||||
|
boto3>=1.34.0
|
||||||
|
|
||||||
# Utils
|
# Utils
|
||||||
psutil>=5.9.0
|
psutil>=5.9.0
|
||||||
|
|||||||
24
YakPanel-server/docs/FEATURE-PARITY.md
Normal file
24
YakPanel-server/docs/FEATURE-PARITY.md
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# YakPanel feature parity checklist (clean-room)
|
||||||
|
|
||||||
|
Internal checklist against common hosting-panel capabilities used as a product roadmap only. No third-party panel code is shipped.
|
||||||
|
|
||||||
|
| Area | Status | YakPanel location |
|
||||||
|
|------|--------|-------------------|
|
||||||
|
| Sites, domains, redirects | Done | `api/site.py`, `site_service.py` |
|
||||||
|
| Nginx vhost + SSL (HTTP-01) | Done | `webserver/templates/nginx_site.conf`, `ssl.py` |
|
||||||
|
| SSL diagnostics + port 443 probe | Done | `GET /ssl/diagnostics` |
|
||||||
|
| Nginx include wizard (drop-in hints) | Done | `GET /ssl/diagnostics` → `nginx_wizard` |
|
||||||
|
| Reverse proxy site mode | Done | `Site.proxy_upstream`, vhost `proxy_pass` |
|
||||||
|
| WebSocket proxy hint | Done | `Site.proxy_websocket` |
|
||||||
|
| Directory HTTP basic auth | Done | `Site.dir_auth_path`, `dir_auth_user_file` |
|
||||||
|
| Disable PHP execution (uploads) | Done | `Site.php_deny_execute` |
|
||||||
|
| DNS-01 Let's Encrypt (Cloudflare / manual TXT) | Done | `POST /ssl/dns-request/*` |
|
||||||
|
| Security checklist (read-only probes) | Done | `GET /security/checklist` |
|
||||||
|
| FTP logs (tail) | Done | `GET /ftp/logs` |
|
||||||
|
| Cron job templates (YakPanel JSON) | Done | `GET /crontab/templates`, `data/cron_templates.json` |
|
||||||
|
| Backup plans + optional S3 upload | Done | `backup.py`, `BackupPlan` S3 fields, `boto3` optional |
|
||||||
|
| Database / FTP / firewall / monitor | Partial (pre-existing) | respective `api/*.py` |
|
||||||
|
| Mail server | Not planned | — |
|
||||||
|
| WordPress one-click | Not planned | plugin later |
|
||||||
|
|
||||||
|
_Last updated: parity pass (this implementation)._
|
||||||
@@ -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 ConfigPage = lazy(() => import('./pages/ConfigPage').then((m) => ({ default: m.ConfigPage })))
|
||||||
const LogsPage = lazy(() => import('./pages/LogsPage').then((m) => ({ default: m.LogsPage })))
|
const LogsPage = lazy(() => import('./pages/LogsPage').then((m) => ({ default: m.LogsPage })))
|
||||||
const FirewallPage = lazy(() => import('./pages/FirewallPage').then((m) => ({ default: m.FirewallPage })))
|
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 DomainsPage = lazy(() => import('./pages/DomainsPage').then((m) => ({ default: m.DomainsPage })))
|
||||||
const DockerPage = lazy(() => import('./pages/DockerPage').then((m) => ({ default: m.DockerPage })))
|
const DockerPage = lazy(() => import('./pages/DockerPage').then((m) => ({ default: m.DockerPage })))
|
||||||
const NodePage = lazy(() => import('./pages/NodePage').then((m) => ({ default: m.NodePage })))
|
const NodePage = lazy(() => import('./pages/NodePage').then((m) => ({ default: m.NodePage })))
|
||||||
@@ -125,6 +128,14 @@ export default function App() {
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="security-checklist"
|
||||||
|
element={
|
||||||
|
<Suspense fallback={<PageSkeleton />}>
|
||||||
|
<SecurityChecklistPage />
|
||||||
|
</Suspense>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="files"
|
path="files"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -67,7 +67,19 @@ export async function login(username: string, password: string) {
|
|||||||
return data
|
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', {
|
return apiRequest<{ status: boolean; msg: string; id?: number }>('/site/create', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
@@ -108,14 +120,38 @@ export async function siteBatch(action: 'enable' | 'disable' | 'delete', ids: nu
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getSite(siteId: number) {
|
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[] }>(
|
return apiRequest<{
|
||||||
`/site/${siteId}`
|
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(
|
export async function updateSite(
|
||||||
siteId: number,
|
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}`, {
|
return apiRequest<{ status: boolean; msg: string }>(`/site/${siteId}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
@@ -530,17 +566,50 @@ export async function getMonitorNetwork() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function listBackupPlans() {
|
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', {
|
return apiRequest<{ status: boolean; msg: string; id: number }>('/backup/plans', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(data),
|
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}`, {
|
return apiRequest<{ status: boolean; msg: string }>(`/backup/plans/${planId}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify(data),
|
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: '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: '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: '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: 'Security checklist',
|
||||||
{ title: 'Logs', href: '/logs', id: 'menuLogs', sort: 10, iconClass: 'ti ti-file-text' },
|
href: '/security-checklist',
|
||||||
{ title: 'Domains', href: '/ssl_domain', id: 'menuDomains', sort: 11, iconClass: 'ti ti-world-www' },
|
id: 'menuSecurityChecklist',
|
||||||
{ title: 'Terminal', href: '/xterm', id: 'menuXterm', sort: 12, iconClass: 'ti ti-terminal-2' },
|
sort: 8,
|
||||||
{ title: 'Cron', href: '/crontab', id: 'menuCrontab', sort: 13, iconClass: 'ti ti-clock' },
|
iconClass: 'ti ti-checklist',
|
||||||
{ 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: 'Files', href: '/files', id: 'menuFiles', sort: 9, iconClass: 'ti ti-folders' },
|
||||||
{ title: 'Plugins', href: '/plugins', id: 'menuPlugins', sort: 16, iconClass: 'ti ti-puzzle' },
|
{ title: 'Node', href: '/node', id: 'menuNode', sort: 10, iconClass: 'ti ti-brand-nodejs' },
|
||||||
{ title: 'Backup Plans', href: '/backup-plans', id: 'menuBackupPlans', sort: 17, iconClass: 'ti ti-archive' },
|
{ title: 'Logs', href: '/logs', id: 'menuLogs', sort: 11, iconClass: 'ti ti-file-text' },
|
||||||
{ title: 'Users', href: '/users', id: 'menuUsers', sort: 18, iconClass: 'ti ti-users' },
|
{ title: 'Domains', href: '/ssl_domain', id: 'menuDomains', sort: 12, iconClass: 'ti ti-world-www' },
|
||||||
{ title: 'Settings', href: '/config', id: 'menuConfig', sort: 19, iconClass: 'ti ti-settings' },
|
{ title: 'Terminal', href: '/xterm', id: 'menuXterm', sort: 13, iconClass: 'ti ti-terminal-2' },
|
||||||
{ title: 'Log out', href: '/logout', id: 'menuLogout', sort: 20, iconClass: 'ti ti-logout' },
|
{ 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',
|
'/docker': 'Docker',
|
||||||
'/control': 'Monitor',
|
'/control': 'Monitor',
|
||||||
'/firewall': 'Security',
|
'/firewall': 'Security',
|
||||||
|
'/security-checklist': 'Security checklist',
|
||||||
'/files': 'Files',
|
'/files': 'Files',
|
||||||
'/node': 'Node',
|
'/node': 'Node',
|
||||||
'/logs': 'Logs',
|
'/logs': 'Logs',
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ interface BackupPlanRecord {
|
|||||||
target_id: number
|
target_id: number
|
||||||
schedule: string
|
schedule: string
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
|
s3_bucket?: string
|
||||||
|
s3_endpoint?: string
|
||||||
|
s3_key_prefix?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SiteRecord {
|
interface SiteRecord {
|
||||||
@@ -74,13 +77,16 @@ export function BackupPlansPage() {
|
|||||||
const target_id = Number((form.elements.namedItem('target_id') as HTMLSelectElement).value)
|
const target_id = Number((form.elements.namedItem('target_id') as HTMLSelectElement).value)
|
||||||
const schedule = (form.elements.namedItem('schedule') as HTMLInputElement).value.trim()
|
const schedule = (form.elements.namedItem('schedule') as HTMLInputElement).value.trim()
|
||||||
const enabled = (form.elements.namedItem('enabled') as HTMLInputElement).checked
|
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) {
|
if (!name || !schedule || !target_id) {
|
||||||
setError('Name, target and schedule are required')
|
setError('Name, target and schedule are required')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setCreating(true)
|
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(() => {
|
.then(() => {
|
||||||
setShowCreate(false)
|
setShowCreate(false)
|
||||||
form.reset()
|
form.reset()
|
||||||
@@ -110,9 +116,21 @@ export function BackupPlansPage() {
|
|||||||
const target_id = Number((form.elements.namedItem('edit_target_id') as HTMLSelectElement).value)
|
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 schedule = (form.elements.namedItem('edit_schedule') as HTMLInputElement).value.trim()
|
||||||
const enabled = (form.elements.namedItem('edit_enabled') as HTMLInputElement).checked
|
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
|
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(() => {
|
.then(() => {
|
||||||
setEditPlan(null)
|
setEditPlan(null)
|
||||||
loadPlans()
|
loadPlans()
|
||||||
@@ -188,7 +206,9 @@ export function BackupPlansPage() {
|
|||||||
|
|
||||||
<p className="small text-secondary mb-3">
|
<p className="small text-secondary mb-3">
|
||||||
Schedule automated backups. Add a cron entry (e.g. <code>0 * * * *</code> hourly) to call{' '}
|
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>
|
</p>
|
||||||
|
|
||||||
<div className="card">
|
<div className="card">
|
||||||
@@ -199,6 +219,7 @@ export function BackupPlansPage() {
|
|||||||
<th>Type</th>
|
<th>Type</th>
|
||||||
<th>Target</th>
|
<th>Target</th>
|
||||||
<th>Schedule</th>
|
<th>Schedule</th>
|
||||||
|
<th>S3</th>
|
||||||
<th>Enabled</th>
|
<th>Enabled</th>
|
||||||
<th className="text-end">Actions</th>
|
<th className="text-end">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -206,7 +227,7 @@ export function BackupPlansPage() {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{plans.length === 0 ? (
|
{plans.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={6} className="p-0">
|
<td colSpan={7} className="p-0">
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="No backup plans"
|
title="No backup plans"
|
||||||
description='Click "Add Plan" to create one.'
|
description='Click "Add Plan" to create one.'
|
||||||
@@ -222,6 +243,9 @@ export function BackupPlansPage() {
|
|||||||
<td>
|
<td>
|
||||||
<code className="small">{p.schedule}</code>
|
<code className="small">{p.schedule}</code>
|
||||||
</td>
|
</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>{p.enabled ? 'Yes' : 'No'}</td>
|
||||||
<td className="text-end">
|
<td className="text-end">
|
||||||
<button
|
<button
|
||||||
@@ -314,6 +338,36 @@ export function BackupPlansPage() {
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="form-check">
|
||||||
<input name="edit_enabled" type="checkbox" defaultChecked={editPlan.enabled} className="form-check-input" id="edit_enabled" />
|
<input name="edit_enabled" type="checkbox" defaultChecked={editPlan.enabled} className="form-check-input" id="edit_enabled" />
|
||||||
<label className="form-check-label" htmlFor="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 />
|
<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 className="form-text">e.g. 0 2 * * * = daily at 2am, 0 */6 * * * = every 6 hours</div>
|
||||||
</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">
|
<div className="form-check">
|
||||||
<input name="enabled" type="checkbox" defaultChecked className="form-check-input" id="plan_enabled" />
|
<input name="enabled" type="checkbox" defaultChecked className="form-check-input" id="plan_enabled" />
|
||||||
<label className="form-check-label" htmlFor="plan_enabled">
|
<label className="form-check-label" htmlFor="plan_enabled">
|
||||||
|
|||||||
@@ -11,6 +11,14 @@ interface CronJob {
|
|||||||
execstr: string
|
execstr: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CronTemplate {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
schedule: string
|
||||||
|
execstr: string
|
||||||
|
description?: string
|
||||||
|
}
|
||||||
|
|
||||||
const SCHEDULE_PRESETS = [
|
const SCHEDULE_PRESETS = [
|
||||||
{ label: 'Every minute', value: '* * * * *' },
|
{ label: 'Every minute', value: '* * * * *' },
|
||||||
{ label: 'Every 5 min', value: '*/5 * * * *' },
|
{ label: 'Every 5 min', value: '*/5 * * * *' },
|
||||||
@@ -29,6 +37,9 @@ export function CrontabPage() {
|
|||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [formError, setFormError] = useState('')
|
const [formError, setFormError] = useState('')
|
||||||
const [applying, setApplying] = useState(false)
|
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 = () => {
|
const loadJobs = () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -42,6 +53,12 @@ export function CrontabPage() {
|
|||||||
loadJobs()
|
loadJobs()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
apiRequest<{ templates: CronTemplate[] }>('/crontab/templates')
|
||||||
|
.then((r) => setTemplates(Array.isArray(r.templates) ? r.templates : []))
|
||||||
|
.catch(() => setTemplates([]))
|
||||||
|
}, [])
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const form = e.currentTarget
|
const form = e.currentTarget
|
||||||
@@ -63,6 +80,8 @@ export function CrontabPage() {
|
|||||||
.then(() => {
|
.then(() => {
|
||||||
setShowForm(false)
|
setShowForm(false)
|
||||||
setEditingId(null)
|
setEditingId(null)
|
||||||
|
setEditJob(null)
|
||||||
|
setPrefill(null)
|
||||||
form.reset()
|
form.reset()
|
||||||
loadJobs()
|
loadJobs()
|
||||||
})
|
})
|
||||||
@@ -71,11 +90,20 @@ export function CrontabPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleEdit = (job: CronJob) => {
|
const handleEdit = (job: CronJob) => {
|
||||||
|
setPrefill(null)
|
||||||
setEditingId(job.id)
|
setEditingId(job.id)
|
||||||
setEditJob(job)
|
setEditJob(job)
|
||||||
setShowForm(true)
|
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) => {
|
const handleDelete = (id: number) => {
|
||||||
if (!confirm('Delete this cron job?')) return
|
if (!confirm('Delete this cron job?')) return
|
||||||
apiRequest(`/crontab/${id}`, { method: 'DELETE' })
|
apiRequest(`/crontab/${id}`, { method: 'DELETE' })
|
||||||
@@ -131,11 +159,21 @@ export function CrontabPage() {
|
|||||||
|
|
||||||
{error ? <AdminAlert className="mb-3">{error}</AdminAlert> : null}
|
{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.Header closeButton>
|
||||||
<Modal.Title>{editingId ? 'Edit Cron Job' : 'Create Cron Job'}</Modal.Title>
|
<Modal.Title>{editingId ? 'Edit Cron Job' : 'Create Cron Job'}</Modal.Title>
|
||||||
</Modal.Header>
|
</Modal.Header>
|
||||||
<form key={editingId ?? 'new'} onSubmit={handleSubmit}>
|
<form key={`${editingId ?? 'new'}-${formNonce}`} onSubmit={handleSubmit}>
|
||||||
<Modal.Body>
|
<Modal.Body>
|
||||||
{formError ? <AdminAlert className="mb-3">{formError}</AdminAlert> : null}
|
{formError ? <AdminAlert className="mb-3">{formError}</AdminAlert> : null}
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
@@ -145,7 +183,7 @@ export function CrontabPage() {
|
|||||||
name="name"
|
name="name"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="My task"
|
placeholder="My task"
|
||||||
defaultValue={editJob?.name}
|
defaultValue={editJob?.name ?? prefill?.name ?? ''}
|
||||||
className="form-control"
|
className="form-control"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -170,7 +208,7 @@ export function CrontabPage() {
|
|||||||
name="schedule"
|
name="schedule"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="* * * * *"
|
placeholder="* * * * *"
|
||||||
defaultValue={editJob?.schedule}
|
defaultValue={editJob?.schedule ?? prefill?.schedule ?? ''}
|
||||||
className="form-control"
|
className="form-control"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
@@ -182,7 +220,7 @@ export function CrontabPage() {
|
|||||||
name="execstr"
|
name="execstr"
|
||||||
rows={3}
|
rows={3}
|
||||||
placeholder="/usr/bin/php /www/wwwroot/script.php"
|
placeholder="/usr/bin/php /www/wwwroot/script.php"
|
||||||
defaultValue={editJob?.execstr}
|
defaultValue={editJob?.execstr ?? prefill?.execstr ?? ''}
|
||||||
className="form-control"
|
className="form-control"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
@@ -196,6 +234,7 @@ export function CrontabPage() {
|
|||||||
setShowForm(false)
|
setShowForm(false)
|
||||||
setEditingId(null)
|
setEditingId(null)
|
||||||
setEditJob(null)
|
setEditJob(null)
|
||||||
|
setPrefill(null)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Cancel
|
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).
|
Jobs are stored in the panel. Click "Apply to System" to sync them to the system crontab (root).
|
||||||
</div>
|
</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">
|
<div className="card">
|
||||||
<AdminTable>
|
<AdminTable>
|
||||||
<thead>
|
<thead>
|
||||||
|
|||||||
@@ -17,9 +17,19 @@ interface Certificate {
|
|||||||
path: string
|
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 {
|
interface SslDiagnostics {
|
||||||
vhost_dir: string
|
vhost_dir: string
|
||||||
include_snippet: string
|
include_snippet: string
|
||||||
|
nginx_wizard?: NginxWizard
|
||||||
vhosts: { file: string; has_listen_80: boolean; has_listen_443: boolean; has_ssl_directives: boolean }[]
|
vhosts: { file: string; has_listen_80: boolean; has_listen_443: boolean; has_ssl_directives: boolean }[]
|
||||||
any_vhost_listen_ssl: boolean
|
any_vhost_listen_ssl: boolean
|
||||||
nginx_effective_listen_443: boolean
|
nginx_effective_listen_443: boolean
|
||||||
@@ -41,6 +51,13 @@ export function DomainsPage() {
|
|||||||
const [requesting, setRequesting] = useState<string | null>(null)
|
const [requesting, setRequesting] = useState<string | null>(null)
|
||||||
const [requestDomain, setRequestDomain] = useState<Domain | null>(null)
|
const [requestDomain, setRequestDomain] = useState<Domain | null>(null)
|
||||||
const [requestEmail, setRequestEmail] = useState('')
|
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 = () => {
|
const load = () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -155,9 +172,107 @@ export function DomainsPage() {
|
|||||||
nginx -T probe: {diag.nginx_t_probe_errors.join(' | ')}
|
nginx -T probe: {diag.nginx_t_probe_errors.join(' | ')}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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>
|
</div>
|
||||||
) : null}
|
) : 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="row g-4">
|
||||||
<div className="col-lg-6">
|
<div className="col-lg-6">
|
||||||
<div className="card h-100">
|
<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 />
|
<i className="ti ti-shield-check text-success flex-shrink-0" aria-hidden />
|
||||||
<span className="font-monospace small text-break">{c.name}</span>
|
<span className="font-monospace small text-break">{c.name}</span>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -269,6 +384,51 @@ export function DomainsPage() {
|
|||||||
</form>
|
</form>
|
||||||
) : null}
|
) : null}
|
||||||
</Modal>
|
</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() {
|
export function FtpPage() {
|
||||||
const [accounts, setAccounts] = useState<FtpAccount[]>([])
|
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 [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [showCreate, setShowCreate] = useState(false)
|
const [showCreate, setShowCreate] = useState(false)
|
||||||
@@ -32,6 +36,18 @@ export function FtpPage() {
|
|||||||
loadAccounts()
|
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>) => {
|
const handleCreate = (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const form = e.currentTarget
|
const form = e.currentTarget
|
||||||
@@ -119,6 +135,33 @@ export function FtpPage() {
|
|||||||
<code>apt install pure-ftpd pure-ftpd-common</code>
|
<code>apt install pure-ftpd pure-ftpd-common</code>
|
||||||
</div>
|
</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 show={showCreate} onHide={() => setShowCreate(false)} centered>
|
||||||
<Modal.Header closeButton>
|
<Modal.Header closeButton>
|
||||||
<Modal.Title>Create FTP Account</Modal.Title>
|
<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
|
ps: string
|
||||||
php_version: string
|
php_version: string
|
||||||
force_https: boolean
|
force_https: boolean
|
||||||
|
proxy_upstream: string
|
||||||
|
proxy_websocket: boolean
|
||||||
|
dir_auth_path: string
|
||||||
|
dir_auth_user_file: string
|
||||||
|
php_deny_execute: boolean
|
||||||
} | null>(null)
|
} | null>(null)
|
||||||
const [editLoading, setEditLoading] = useState(false)
|
const [editLoading, setEditLoading] = useState(false)
|
||||||
const [editError, setEditError] = useState('')
|
const [editError, setEditError] = useState('')
|
||||||
@@ -241,6 +246,11 @@ export function SitePage() {
|
|||||||
ps: s.ps || '',
|
ps: s.ps || '',
|
||||||
php_version: s.php_version || '74',
|
php_version: s.php_version || '74',
|
||||||
force_https: !!(s.force_https && s.force_https !== 0),
|
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))
|
.catch((err) => setEditError(err.message))
|
||||||
@@ -262,6 +272,11 @@ export function SitePage() {
|
|||||||
ps: editForm.ps || undefined,
|
ps: editForm.ps || undefined,
|
||||||
php_version: editForm.php_version,
|
php_version: editForm.php_version,
|
||||||
force_https: editForm.force_https,
|
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(() => {
|
.then(() => {
|
||||||
setEditSiteId(null)
|
setEditSiteId(null)
|
||||||
@@ -415,6 +430,31 @@ export function SitePage() {
|
|||||||
Force HTTPS (redirect HTTP to HTTPS)
|
Force HTTPS (redirect HTTP to HTTPS)
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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">
|
<div className="mb-0">
|
||||||
<label className="form-label">Note (optional)</label>
|
<label className="form-label">Note (optional)</label>
|
||||||
<input name="ps" type="text" placeholder="My website" className="form-control" />
|
<input name="ps" type="text" placeholder="My website" className="form-control" />
|
||||||
@@ -900,6 +940,59 @@ export function SitePage() {
|
|||||||
Force HTTPS
|
Force HTTPS
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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">
|
<div className="mb-0">
|
||||||
<label className="form-label">Note (optional)</label>
|
<label className="form-label">Note (optional)</label>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -8,36 +8,10 @@ server {
|
|||||||
error_page 404 /404.html;
|
error_page 404 /404.html;
|
||||||
error_page 502 /502.html;
|
error_page 502 /502.html;
|
||||||
|
|
||||||
# ACME HTTP-01 (Let's Encrypt). Prefix match beats regex; explicit root; no try_files so server error_page cannot mask failures.
|
|
||||||
location ^~ /.well-known/acme-challenge/ {
|
|
||||||
root {ROOT_PATH};
|
|
||||||
default_type "text/plain";
|
|
||||||
allow all;
|
|
||||||
access_log off;
|
|
||||||
}
|
|
||||||
|
|
||||||
# Force HTTPS (skipped for ACME — see if block)
|
|
||||||
{FORCE_HTTPS_BLOCK}
|
{FORCE_HTTPS_BLOCK}
|
||||||
|
|
||||||
# Custom redirects
|
# ACME, redirects, optional auth, PHP hardening, app (PHP static or reverse proxy)
|
||||||
{REDIRECTS_BLOCK}
|
{LOCATION_BUNDLE}
|
||||||
|
|
||||||
# Static assets
|
|
||||||
location ~ .*\.(gif|jpg|jpeg|png|bmp|swf)$ {
|
|
||||||
expires 30d;
|
|
||||||
access_log off;
|
|
||||||
}
|
|
||||||
location ~ .*\.(js|css)?$ {
|
|
||||||
expires 12h;
|
|
||||||
access_log off;
|
|
||||||
}
|
|
||||||
|
|
||||||
# PHP
|
|
||||||
location ~ \.php$ {
|
|
||||||
fastcgi_pass unix:/tmp/php-cgi-{PHP_VERSION}.sock;
|
|
||||||
fastcgi_index index.php;
|
|
||||||
include fastcgi.conf;
|
|
||||||
}
|
|
||||||
|
|
||||||
access_log {LOGS_PATH}/{SITE_NAME}.log;
|
access_log {LOGS_PATH}/{SITE_NAME}.log;
|
||||||
error_log {LOGS_PATH}/{SITE_NAME}.error.log;
|
error_log {LOGS_PATH}/{SITE_NAME}.error.log;
|
||||||
|
|||||||
Reference in New Issue
Block a user