diff --git a/YakPanel-server/backend/app/__pycache__/main.cpython-314.pyc b/YakPanel-server/backend/app/__pycache__/main.cpython-314.pyc
index bc134b3c..843c6961 100644
Binary files a/YakPanel-server/backend/app/__pycache__/main.cpython-314.pyc and b/YakPanel-server/backend/app/__pycache__/main.cpython-314.pyc differ
diff --git a/YakPanel-server/backend/app/api/__pycache__/backup.cpython-314.pyc b/YakPanel-server/backend/app/api/__pycache__/backup.cpython-314.pyc
index a75a509b..e6a0a3db 100644
Binary files a/YakPanel-server/backend/app/api/__pycache__/backup.cpython-314.pyc and b/YakPanel-server/backend/app/api/__pycache__/backup.cpython-314.pyc differ
diff --git a/YakPanel-server/backend/app/api/__pycache__/crontab.cpython-314.pyc b/YakPanel-server/backend/app/api/__pycache__/crontab.cpython-314.pyc
index 60c245c3..9804695d 100644
Binary files a/YakPanel-server/backend/app/api/__pycache__/crontab.cpython-314.pyc and b/YakPanel-server/backend/app/api/__pycache__/crontab.cpython-314.pyc differ
diff --git a/YakPanel-server/backend/app/api/__pycache__/files.cpython-314.pyc b/YakPanel-server/backend/app/api/__pycache__/files.cpython-314.pyc
index 7d1ff311..defb2e3f 100644
Binary files a/YakPanel-server/backend/app/api/__pycache__/files.cpython-314.pyc and b/YakPanel-server/backend/app/api/__pycache__/files.cpython-314.pyc differ
diff --git a/YakPanel-server/backend/app/api/__pycache__/ftp.cpython-314.pyc b/YakPanel-server/backend/app/api/__pycache__/ftp.cpython-314.pyc
index f1c17cd0..c2451af4 100644
Binary files a/YakPanel-server/backend/app/api/__pycache__/ftp.cpython-314.pyc and b/YakPanel-server/backend/app/api/__pycache__/ftp.cpython-314.pyc differ
diff --git a/YakPanel-server/backend/app/api/__pycache__/security.cpython-314.pyc b/YakPanel-server/backend/app/api/__pycache__/security.cpython-314.pyc
new file mode 100644
index 00000000..1fdeacc8
Binary files /dev/null and b/YakPanel-server/backend/app/api/__pycache__/security.cpython-314.pyc differ
diff --git a/YakPanel-server/backend/app/api/__pycache__/site.cpython-314.pyc b/YakPanel-server/backend/app/api/__pycache__/site.cpython-314.pyc
index cb856c85..c117a058 100644
Binary files a/YakPanel-server/backend/app/api/__pycache__/site.cpython-314.pyc and b/YakPanel-server/backend/app/api/__pycache__/site.cpython-314.pyc differ
diff --git a/YakPanel-server/backend/app/api/__pycache__/ssl.cpython-314.pyc b/YakPanel-server/backend/app/api/__pycache__/ssl.cpython-314.pyc
index 1943f038..db4f8b34 100644
Binary files a/YakPanel-server/backend/app/api/__pycache__/ssl.cpython-314.pyc and b/YakPanel-server/backend/app/api/__pycache__/ssl.cpython-314.pyc differ
diff --git a/YakPanel-server/backend/app/api/backup.py b/YakPanel-server/backend/app/api/backup.py
index 46642776..1f9f053f 100644
--- a/YakPanel-server/backend/app/api/backup.py
+++ b/YakPanel-server/backend/app/api/backup.py
@@ -27,6 +27,9 @@ class CreateBackupPlanRequest(BaseModel):
target_id: int
schedule: str # cron, e.g. "0 2 * * *"
enabled: bool = True
+ s3_bucket: str = ""
+ s3_endpoint: str = ""
+ s3_key_prefix: str = ""
@router.get("/plans")
@@ -45,6 +48,9 @@ async def backup_plans_list(
"target_id": r.target_id,
"schedule": r.schedule,
"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
]
@@ -79,6 +85,9 @@ async def backup_plan_create(
target_id=body.target_id,
schedule=body.schedule,
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)
await db.commit()
@@ -107,6 +116,9 @@ async def backup_plan_update(
plan.target_id = body.target_id
plan.schedule = body.schedule
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()
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
+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]:
"""Run database backup (sync). Returns (ok, msg, filename)."""
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."""
from datetime import datetime as dt
now = dt.utcnow()
+ cfg = get_runtime_config()
result = await db.execute(select(BackupPlan).where(BackupPlan.enabled == True))
plans = result.scalars().all()
results = []
for plan in plans:
+ ok = False
+ msg = ""
try:
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()
- if secs_since > 900 or secs_since < 0: # Not within 15 min window
+ if secs_since > 900 or secs_since < 0:
continue
except Exception:
continue
@@ -183,6 +218,11 @@ async def backup_run_scheduled(
results.append({"plan": plan.name, "status": "skipped", "msg": "Site not found or path invalid"})
continue
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:
send_email(
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"})
continue
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:
send_email(
subject=f"YakPanel - Scheduled backup: {plan.name}",
diff --git a/YakPanel-server/backend/app/api/crontab.py b/YakPanel-server/backend/app/api/crontab.py
index 5142a413..65339634 100644
--- a/YakPanel-server/backend/app/api/crontab.py
+++ b/YakPanel-server/backend/app/api/crontab.py
@@ -1,6 +1,9 @@
"""YakPanel - Crontab API"""
+import json
import tempfile
import os
+from pathlib import Path
+
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
@@ -14,6 +17,20 @@ from app.models.crontab import 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):
name: str = ""
diff --git a/YakPanel-server/backend/app/api/files.py b/YakPanel-server/backend/app/api/files.py
index 3b91f9cf..5b0ca07c 100644
--- a/YakPanel-server/backend/app/api/files.py
+++ b/YakPanel-server/backend/app/api/files.py
@@ -22,7 +22,7 @@ def _resolve_path(path: str) -> str:
Resolve API path to an OS path.
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.
"""
diff --git a/YakPanel-server/backend/app/api/ftp.py b/YakPanel-server/backend/app/api/ftp.py
index 1f5183d9..44f629fa 100644
--- a/YakPanel-server/backend/app/api/ftp.py
+++ b/YakPanel-server/backend/app/api/ftp.py
@@ -1,5 +1,6 @@
"""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 import select, func
from pydantic import BaseModel
@@ -103,6 +104,25 @@ async def ftp_delete(
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")
async def ftp_count(
current_user: User = Depends(get_current_user),
diff --git a/YakPanel-server/backend/app/api/security.py b/YakPanel-server/backend/app/api/security.py
new file mode 100644
index 00000000..603c4b57
--- /dev/null
+++ b/YakPanel-server/backend/app/api/security.py
@@ -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."}
diff --git a/YakPanel-server/backend/app/api/site.py b/YakPanel-server/backend/app/api/site.py
index e0c1a63a..406c65f1 100644
--- a/YakPanel-server/backend/app/api/site.py
+++ b/YakPanel-server/backend/app/api/site.py
@@ -29,6 +29,11 @@ class CreateSiteRequest(BaseModel):
ps: str = ""
php_version: str = "74"
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):
@@ -37,6 +42,11 @@ class UpdateSiteRequest(BaseModel):
ps: str | None = None
php_version: str | 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")
@@ -66,6 +76,11 @@ async def site_create(
ps=body.ps,
php_version=body.php_version or "74",
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"]:
raise HTTPException(status_code=400, detail=result["msg"])
@@ -126,12 +141,22 @@ async def site_update(
):
"""Update site domains, path, or note"""
result = await update_site(
- db, site_id,
+ db,
+ site_id,
path=body.path,
domains=body.domains,
ps=body.ps,
php_version=body.php_version,
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"]:
raise HTTPException(status_code=400, detail=result["msg"])
diff --git a/YakPanel-server/backend/app/api/ssl.py b/YakPanel-server/backend/app/api/ssl.py
index 6c866833..0f26050f 100644
--- a/YakPanel-server/backend/app/api/ssl.py
+++ b/YakPanel-server/backend/app/api/ssl.py
@@ -5,6 +5,7 @@ import shutil
import socket
import subprocess
import sys
+import tempfile
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
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."
)
+ 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 {
"vhost_dir": vhost_dir,
"include_snippet": include_snippet,
+ "nginx_wizard": nginx_wizard,
"vhosts": vhost_summaries,
"any_vhost_listen_ssl": any_vhost_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")
async def ssl_list_certificates(current_user: User = Depends(get_current_user)):
"""List existing Let's Encrypt certificates"""
diff --git a/YakPanel-server/backend/app/core/__pycache__/database.cpython-314.pyc b/YakPanel-server/backend/app/core/__pycache__/database.cpython-314.pyc
index d38a8fcd..3a934a04 100644
Binary files a/YakPanel-server/backend/app/core/__pycache__/database.cpython-314.pyc and b/YakPanel-server/backend/app/core/__pycache__/database.cpython-314.pyc differ
diff --git a/YakPanel-server/backend/app/core/database.py b/YakPanel-server/backend/app/core/database.py
index d5da952c..11394911 100644
--- a/YakPanel-server/backend/app/core/database.py
+++ b/YakPanel-server/backend/app/core/database.py
@@ -107,6 +107,16 @@ def _run_migrations(conn):
"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:
pass
# Create backup_plans if not exists (create_all handles new installs)
@@ -123,6 +133,18 @@ def _run_migrations(conn):
"""))
except Exception:
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
try:
conn.execute(sqlalchemy.text("""
diff --git a/YakPanel-server/backend/app/data/cron_templates.json b/YakPanel-server/backend/app/data/cron_templates.json
new file mode 100644
index 00000000..8cea8908
--- /dev/null
+++ b/YakPanel-server/backend/app/data/cron_templates.json
@@ -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."
+ }
+]
diff --git a/YakPanel-server/backend/app/main.py b/YakPanel-server/backend/app/main.py
index 729b80af..197c3f0b 100644
--- a/YakPanel-server/backend/app/main.py
+++ b/YakPanel-server/backend/app/main.py
@@ -29,6 +29,7 @@ from app.api import (
node,
service,
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(logs.router, prefix="/api/v1")
app.include_router(public_installer.router, prefix="/api/v1")
+app.include_router(security.router, prefix="/api/v1")
@app.get("/")
diff --git a/YakPanel-server/backend/app/models/__pycache__/backup_plan.cpython-314.pyc b/YakPanel-server/backend/app/models/__pycache__/backup_plan.cpython-314.pyc
index 89e427f9..193002ac 100644
Binary files a/YakPanel-server/backend/app/models/__pycache__/backup_plan.cpython-314.pyc and b/YakPanel-server/backend/app/models/__pycache__/backup_plan.cpython-314.pyc differ
diff --git a/YakPanel-server/backend/app/models/__pycache__/site.cpython-314.pyc b/YakPanel-server/backend/app/models/__pycache__/site.cpython-314.pyc
index 8f0cb324..5aec8864 100644
Binary files a/YakPanel-server/backend/app/models/__pycache__/site.cpython-314.pyc and b/YakPanel-server/backend/app/models/__pycache__/site.cpython-314.pyc differ
diff --git a/YakPanel-server/backend/app/models/backup_plan.py b/YakPanel-server/backend/app/models/backup_plan.py
index b32e21f6..d40a2e33 100644
--- a/YakPanel-server/backend/app/models/backup_plan.py
+++ b/YakPanel-server/backend/app/models/backup_plan.py
@@ -13,3 +13,7 @@ class BackupPlan(Base):
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
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="")
diff --git a/YakPanel-server/backend/app/models/site.py b/YakPanel-server/backend/app/models/site.py
index feb479e8..7c93e011 100644
--- a/YakPanel-server/backend/app/models/site.py
+++ b/YakPanel-server/backend/app/models/site.py
@@ -16,6 +16,14 @@ class Site(Base):
project_type: Mapped[str] = mapped_column(String(32), default="PHP")
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
+ # 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)
diff --git a/YakPanel-server/backend/app/services/__pycache__/site_service.cpython-314.pyc b/YakPanel-server/backend/app/services/__pycache__/site_service.cpython-314.pyc
index 9a732737..95af3f2f 100644
Binary files a/YakPanel-server/backend/app/services/__pycache__/site_service.cpython-314.pyc and b/YakPanel-server/backend/app/services/__pycache__/site_service.cpython-314.pyc differ
diff --git a/YakPanel-server/backend/app/services/site_service.py b/YakPanel-server/backend/app/services/site_service.py
index 4bc09fb1..11ca7936 100644
--- a/YakPanel-server/backend/app/services/site_service.py
+++ b/YakPanel-server/backend/app/services/site_service.py
@@ -204,42 +204,42 @@ def _letsencrypt_paths_any(hostnames: list[str]) -> tuple[str, str] | None:
return None
-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,
-) -> str:
- """Second server {} for HTTPS when LE certs exist."""
- pv = php_version or "74"
- redirect_lines: list[str] = []
- 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 ""
- q_fc = fullchain.replace("\\", "\\\\").replace('"', '\\"')
- q_pk = privkey.replace("\\", "\\\\").replace('"', '\\"')
+def _build_php_deny_execute_block(enabled: int) -> str:
+ if not enabled:
+ return ""
+ return (
+ r" location ~* ^/uploads/.*\.(php|phar|phtml|php5)$ {" + "\n"
+ r" deny all;" + "\n"
+ r" }" + "\n"
+ r" location ~* ^/storage/.*\.(php|phar|phtml|php5)$ {" + "\n"
+ r" deny all;" + "\n"
+ r" }" + "\n"
+ )
+
+
+def _build_main_app_block(proxy_upstream: str, proxy_websocket: int, php_version: str) -> str:
+ pu = (proxy_upstream or "").strip()
+ pv = php_version or "74"
+ if pu:
+ ws_lines = ""
+ 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 (
- 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"
f" expires 30d;\n"
f" access_log off;\n"
@@ -253,6 +253,116 @@ def _build_ssl_server_block(
f" fastcgi_index index.php;\n"
f" include fastcgi.conf;\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" error_log {logs_path}/{site_name}.error.log;\n"
f"}}\n"
@@ -269,6 +379,11 @@ def _render_vhost(
force_https: int,
redirects: list[tuple[str, str, int]] | 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:
"""Render nginx vhost template. redirects: [(source, target, code), ...]"""
if force_https:
@@ -279,28 +394,43 @@ def _render_vhost(
)
else:
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]
ssl_block = ""
- for h in hosts:
- le = _letsencrypt_paths(h)
- if le:
- fc, pk = le
- ssl_block = _build_ssl_server_block(
- server_names, root_path, logs_path, site_name, php_version, fc, pk, redirects
- )
- break
+ le = _letsencrypt_paths_any(hosts)
+ if le:
+ fc, pk = le
+ ssl_block = _build_ssl_server_block(
+ server_names,
+ root_path,
+ logs_path,
+ 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 = content.replace("{ROOT_PATH}", root_path)
content = content.replace("{LOGS_PATH}", logs_path)
content = content.replace("{SITE_NAME}", site_name)
content = content.replace("{PHP_VERSION}", php_version or "74")
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)
return content
@@ -327,6 +457,16 @@ async def domain_exists(db: AsyncSession, domains: list[str], exclude_site_id: i
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(
db: AsyncSession,
name: str,
@@ -336,6 +476,11 @@ async def create_site(
ps: str = "",
php_version: str = "74",
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:
"""Create a new site with vhost config."""
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):
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)
await db.flush()
@@ -379,8 +536,18 @@ async def create_site(
template = read_file(template_path) or ""
server_names = " ".join(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(
- 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)
@@ -477,6 +644,11 @@ async def get_site_with_domains(db: AsyncSession, site_id: int) -> dict | None:
"project_type": site.project_type,
"php_version": getattr(site, "php_version", None) or "74",
"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,
}
@@ -489,6 +661,11 @@ async def update_site(
ps: str | None = None,
php_version: str | 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:
"""Update site domains, path, or note."""
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"
if force_https is not None:
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()
- # Regenerate Nginx vhost if domains, php_version, or force_https changed
- if domains is not None or php_version is not None or force_https is not None:
+ regen = (
+ 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()
vhost_path = os.path.join(cfg["setup_path"], "panel", "vhost", "nginx")
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))
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]
+ vk = _vhost_kwargs_from_site(site)
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)
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))
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]
+ vk = _vhost_kwargs_from_site(site)
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)
reload_ok, reload_err = nginx_reload_all_known()
diff --git a/YakPanel-server/backend/requirements.txt b/YakPanel-server/backend/requirements.txt
index 6b52c6c1..447b1a24 100644
--- a/YakPanel-server/backend/requirements.txt
+++ b/YakPanel-server/backend/requirements.txt
@@ -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)
certbot>=3.0.0
certbot-nginx>=3.0.0
+certbot-dns-cloudflare>=3.0.0
+boto3>=1.34.0
# Utils
psutil>=5.9.0
diff --git a/YakPanel-server/docs/FEATURE-PARITY.md b/YakPanel-server/docs/FEATURE-PARITY.md
new file mode 100644
index 00000000..d4cc4d05
--- /dev/null
+++ b/YakPanel-server/docs/FEATURE-PARITY.md
@@ -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)._
diff --git a/YakPanel-server/frontend/src/App.tsx b/YakPanel-server/frontend/src/App.tsx
index 83514a66..2b24daa3 100644
--- a/YakPanel-server/frontend/src/App.tsx
+++ b/YakPanel-server/frontend/src/App.tsx
@@ -16,6 +16,9 @@ const CrontabPage = lazy(() => import('./pages/CrontabPage').then((m) => ({ defa
const ConfigPage = lazy(() => import('./pages/ConfigPage').then((m) => ({ default: m.ConfigPage })))
const LogsPage = lazy(() => import('./pages/LogsPage').then((m) => ({ default: m.LogsPage })))
const FirewallPage = lazy(() => import('./pages/FirewallPage').then((m) => ({ default: m.FirewallPage })))
+const SecurityChecklistPage = lazy(() =>
+ import('./pages/SecurityChecklistPage').then((m) => ({ default: m.SecurityChecklistPage })),
+)
const DomainsPage = lazy(() => import('./pages/DomainsPage').then((m) => ({ default: m.DomainsPage })))
const DockerPage = lazy(() => import('./pages/DockerPage').then((m) => ({ default: m.DockerPage })))
const NodePage = lazy(() => import('./pages/NodePage').then((m) => ({ default: m.NodePage })))
@@ -125,6 +128,14 @@ export default function App() {
}
/>
+ }>
+
+
+ }
+ />
('/site/create', {
method: 'POST',
body: JSON.stringify(data),
@@ -108,14 +120,38 @@ export async function siteBatch(action: 'enable' | 'disable' | 'delete', ids: nu
}
export async function getSite(siteId: number) {
- return apiRequest<{ id: number; name: string; path: string; status: number; ps: string; project_type: string; php_version: string; force_https: number; domains: string[] }>(
- `/site/${siteId}`
- )
+ return apiRequest<{
+ id: number
+ name: string
+ path: string
+ status: number
+ ps: string
+ project_type: string
+ php_version: string
+ force_https: number
+ domains: string[]
+ proxy_upstream?: string
+ proxy_websocket?: number
+ dir_auth_path?: string
+ dir_auth_user_file?: string
+ php_deny_execute?: number
+ }>(`/site/${siteId}`)
}
export async function updateSite(
siteId: number,
- data: { path?: string; domains?: string[]; ps?: string; php_version?: string; force_https?: boolean }
+ data: {
+ path?: string
+ domains?: string[]
+ ps?: string
+ php_version?: string
+ force_https?: boolean
+ proxy_upstream?: string
+ proxy_websocket?: boolean
+ dir_auth_path?: string
+ dir_auth_user_file?: string
+ php_deny_execute?: boolean
+ }
) {
return apiRequest<{ status: boolean; msg: string }>(`/site/${siteId}`, {
method: 'PUT',
@@ -530,17 +566,50 @@ export async function getMonitorNetwork() {
}
export async function listBackupPlans() {
- return apiRequest<{ id: number; name: string; plan_type: string; target_id: number; schedule: string; enabled: boolean }[]>('/backup/plans')
+ return apiRequest<
+ {
+ id: number
+ name: string
+ plan_type: string
+ target_id: number
+ schedule: string
+ enabled: boolean
+ s3_bucket?: string
+ s3_endpoint?: string
+ s3_key_prefix?: string
+ }[]
+ >('/backup/plans')
}
-export async function createBackupPlan(data: { name: string; plan_type: string; target_id: number; schedule: string; enabled?: boolean }) {
+export async function createBackupPlan(data: {
+ name: string
+ plan_type: string
+ target_id: number
+ schedule: string
+ enabled?: boolean
+ s3_bucket?: string
+ s3_endpoint?: string
+ s3_key_prefix?: string
+}) {
return apiRequest<{ status: boolean; msg: string; id: number }>('/backup/plans', {
method: 'POST',
body: JSON.stringify(data),
})
}
-export async function updateBackupPlan(planId: number, data: { name: string; plan_type: string; target_id: number; schedule: string; enabled?: boolean }) {
+export async function updateBackupPlan(
+ planId: number,
+ data: {
+ name: string
+ plan_type: string
+ target_id: number
+ schedule: string
+ enabled?: boolean
+ s3_bucket?: string
+ s3_endpoint?: string
+ s3_key_prefix?: string
+ }
+) {
return apiRequest<{ status: boolean; msg: string }>(`/backup/plans/${planId}`, {
method: 'PUT',
body: JSON.stringify(data),
diff --git a/YakPanel-server/frontend/src/config/menu.ts b/YakPanel-server/frontend/src/config/menu.ts
index d2ea2f2c..3c6d4355 100644
--- a/YakPanel-server/frontend/src/config/menu.ts
+++ b/YakPanel-server/frontend/src/config/menu.ts
@@ -15,17 +15,24 @@ export const menuItems: MenuItem[] = [
{ title: 'Docker', href: '/docker', id: 'menuDocker', sort: 5, iconClass: 'ti ti-brand-docker' },
{ title: 'Monitor', href: '/control', id: 'menuControl', sort: 6, iconClass: 'ti ti-heart-rate-monitor' },
{ title: 'Security', href: '/firewall', id: 'menuFirewall', sort: 7, iconClass: 'ti ti-shield-lock' },
- { title: 'Files', href: '/files', id: 'menuFiles', sort: 8, iconClass: 'ti ti-folders' },
- { title: 'Node', href: '/node', id: 'menuNode', sort: 9, iconClass: 'ti ti-brand-nodejs' },
- { title: 'Logs', href: '/logs', id: 'menuLogs', sort: 10, iconClass: 'ti ti-file-text' },
- { title: 'Domains', href: '/ssl_domain', id: 'menuDomains', sort: 11, iconClass: 'ti ti-world-www' },
- { title: 'Terminal', href: '/xterm', id: 'menuXterm', sort: 12, iconClass: 'ti ti-terminal-2' },
- { title: 'Cron', href: '/crontab', id: 'menuCrontab', sort: 13, iconClass: 'ti ti-clock' },
- { title: 'App Store', href: '/soft', id: 'menuSoft', sort: 14, iconClass: 'ti ti-package' },
- { title: 'Services', href: '/services', id: 'menuServices', sort: 15, iconClass: 'ti ti-server' },
- { title: 'Plugins', href: '/plugins', id: 'menuPlugins', sort: 16, iconClass: 'ti ti-puzzle' },
- { title: 'Backup Plans', href: '/backup-plans', id: 'menuBackupPlans', sort: 17, iconClass: 'ti ti-archive' },
- { title: 'Users', href: '/users', id: 'menuUsers', sort: 18, iconClass: 'ti ti-users' },
- { title: 'Settings', href: '/config', id: 'menuConfig', sort: 19, iconClass: 'ti ti-settings' },
- { title: 'Log out', href: '/logout', id: 'menuLogout', sort: 20, iconClass: 'ti ti-logout' },
+ {
+ title: 'Security checklist',
+ href: '/security-checklist',
+ id: 'menuSecurityChecklist',
+ sort: 8,
+ iconClass: 'ti ti-checklist',
+ },
+ { title: 'Files', href: '/files', id: 'menuFiles', sort: 9, iconClass: 'ti ti-folders' },
+ { title: 'Node', href: '/node', id: 'menuNode', sort: 10, iconClass: 'ti ti-brand-nodejs' },
+ { title: 'Logs', href: '/logs', id: 'menuLogs', sort: 11, iconClass: 'ti ti-file-text' },
+ { title: 'Domains', href: '/ssl_domain', id: 'menuDomains', sort: 12, iconClass: 'ti ti-world-www' },
+ { title: 'Terminal', href: '/xterm', id: 'menuXterm', sort: 13, iconClass: 'ti ti-terminal-2' },
+ { title: 'Cron', href: '/crontab', id: 'menuCrontab', sort: 14, iconClass: 'ti ti-clock' },
+ { title: 'App Store', href: '/soft', id: 'menuSoft', sort: 15, iconClass: 'ti ti-package' },
+ { title: 'Services', href: '/services', id: 'menuServices', sort: 16, iconClass: 'ti ti-server' },
+ { title: 'Plugins', href: '/plugins', id: 'menuPlugins', sort: 17, iconClass: 'ti ti-puzzle' },
+ { title: 'Backup Plans', href: '/backup-plans', id: 'menuBackupPlans', sort: 18, iconClass: 'ti ti-archive' },
+ { title: 'Users', href: '/users', id: 'menuUsers', sort: 19, iconClass: 'ti ti-users' },
+ { title: 'Settings', href: '/config', id: 'menuConfig', sort: 20, iconClass: 'ti ti-settings' },
+ { title: 'Log out', href: '/logout', id: 'menuLogout', sort: 21, iconClass: 'ti ti-logout' },
]
diff --git a/YakPanel-server/frontend/src/config/routes-meta.ts b/YakPanel-server/frontend/src/config/routes-meta.ts
index b7586143..ef1f8d2d 100644
--- a/YakPanel-server/frontend/src/config/routes-meta.ts
+++ b/YakPanel-server/frontend/src/config/routes-meta.ts
@@ -7,6 +7,7 @@ export const routeTitleMap: Record = {
'/docker': 'Docker',
'/control': 'Monitor',
'/firewall': 'Security',
+ '/security-checklist': 'Security checklist',
'/files': 'Files',
'/node': 'Node',
'/logs': 'Logs',
diff --git a/YakPanel-server/frontend/src/pages/BackupPlansPage.tsx b/YakPanel-server/frontend/src/pages/BackupPlansPage.tsx
index 2bf00c7d..51baedba 100644
--- a/YakPanel-server/frontend/src/pages/BackupPlansPage.tsx
+++ b/YakPanel-server/frontend/src/pages/BackupPlansPage.tsx
@@ -17,6 +17,9 @@ interface BackupPlanRecord {
target_id: number
schedule: string
enabled: boolean
+ s3_bucket?: string
+ s3_endpoint?: string
+ s3_key_prefix?: string
}
interface SiteRecord {
@@ -74,13 +77,16 @@ export function BackupPlansPage() {
const target_id = Number((form.elements.namedItem('target_id') as HTMLSelectElement).value)
const schedule = (form.elements.namedItem('schedule') as HTMLInputElement).value.trim()
const enabled = (form.elements.namedItem('enabled') as HTMLInputElement).checked
+ const s3_bucket = (form.elements.namedItem('s3_bucket') as HTMLInputElement).value.trim()
+ const s3_endpoint = (form.elements.namedItem('s3_endpoint') as HTMLInputElement).value.trim()
+ const s3_key_prefix = (form.elements.namedItem('s3_key_prefix') as HTMLInputElement).value.trim()
if (!name || !schedule || !target_id) {
setError('Name, target and schedule are required')
return
}
setCreating(true)
- createBackupPlan({ name, plan_type, target_id, schedule, enabled })
+ createBackupPlan({ name, plan_type, target_id, schedule, enabled, s3_bucket, s3_endpoint, s3_key_prefix })
.then(() => {
setShowCreate(false)
form.reset()
@@ -110,9 +116,21 @@ export function BackupPlansPage() {
const target_id = Number((form.elements.namedItem('edit_target_id') as HTMLSelectElement).value)
const schedule = (form.elements.namedItem('edit_schedule') as HTMLInputElement).value.trim()
const enabled = (form.elements.namedItem('edit_enabled') as HTMLInputElement).checked
+ const s3_bucket = (form.elements.namedItem('edit_s3_bucket') as HTMLInputElement).value.trim()
+ const s3_endpoint = (form.elements.namedItem('edit_s3_endpoint') as HTMLInputElement).value.trim()
+ const s3_key_prefix = (form.elements.namedItem('edit_s3_key_prefix') as HTMLInputElement).value.trim()
if (!name || !schedule || !target_id) return
- updateBackupPlan(editPlan.id, { name, plan_type: editPlanType, target_id, schedule, enabled })
+ updateBackupPlan(editPlan.id, {
+ name,
+ plan_type: editPlanType,
+ target_id,
+ schedule,
+ enabled,
+ s3_bucket,
+ s3_endpoint,
+ s3_key_prefix,
+ })
.then(() => {
setEditPlan(null)
loadPlans()
@@ -188,7 +206,9 @@ export function BackupPlansPage() {
Schedule automated backups. Add a cron entry (e.g. 0 * * * * hourly) to call{' '}
- POST /api/v1/backup/run-scheduled with your auth token.
+ POST /api/v1/backup/run-scheduled with your auth token. Optional S3-compatible upload uses{' '}
+ AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY in the panel environment when a bucket
+ name is set.
@@ -199,6 +219,7 @@ export function BackupPlansPage() {
+ Use when HTTP validation cannot reach this server (e.g. Cloudflare "orange cloud"). Requires{' '}
+ certbot-dns-cloudflare on the server for the Cloudflare option.
+