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() { Type Target Schedule + S3 Enabled Actions @@ -206,7 +227,7 @@ export function BackupPlansPage() { {plans.length === 0 ? ( - + {p.schedule} + + {p.s3_bucket ? {p.s3_bucket} : '—'} + {p.enabled ? 'Yes' : 'No'}
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ {templates.length > 0 ? ( +
+
YakPanel starter templates
+
+

Review and edit commands before saving — adjust paths and tokens for your server.

+
+ {templates.map((t) => ( +
+
+
{t.name}
+ {t.description ?
{t.description}
: null} + {t.schedule} +
+ +
+ ))} +
+
+
+ ) : null} +
diff --git a/YakPanel-server/frontend/src/pages/DomainsPage.tsx b/YakPanel-server/frontend/src/pages/DomainsPage.tsx index 04d03294..c83bb1ee 100644 --- a/YakPanel-server/frontend/src/pages/DomainsPage.tsx +++ b/YakPanel-server/frontend/src/pages/DomainsPage.tsx @@ -17,9 +17,19 @@ interface Certificate { path: string } +interface NginxWizard { + detected_layout: string + include_snippet: string + dropin_file_suggested: string + debian: { sites_available_file: string; sites_enabled_symlink: string; steps: string[] } + rhel: { conf_d_file: string; steps: string[] } + note: string +} + interface SslDiagnostics { vhost_dir: string include_snippet: string + nginx_wizard?: NginxWizard vhosts: { file: string; has_listen_80: boolean; has_listen_443: boolean; has_ssl_directives: boolean }[] any_vhost_listen_ssl: boolean nginx_effective_listen_443: boolean @@ -41,6 +51,13 @@ export function DomainsPage() { const [requesting, setRequesting] = useState(null) const [requestDomain, setRequestDomain] = useState(null) const [requestEmail, setRequestEmail] = useState('') + const [showDnsCf, setShowDnsCf] = useState(false) + const [cfDomain, setCfDomain] = useState('') + const [cfEmail, setCfEmail] = useState('') + const [cfToken, setCfToken] = useState('') + const [cfBusy, setCfBusy] = useState(false) + const [manualDom, setManualDom] = useState('') + const [manualOut, setManualOut] = useState<{ txt_record_name?: string; certbot_example?: string; note?: string } | null>(null) const load = () => { setLoading(true) @@ -155,9 +172,107 @@ export function DomainsPage() { nginx -T probe: {diag.nginx_t_probe_errors.join(' | ')}
) : null} + {diag.nginx_wizard ? ( +
+
Nginx include wizard
+

{diag.nginx_wizard.note}

+

+ Detected layout: {diag.nginx_wizard.detected_layout} — suggested file:{' '} + {diag.nginx_wizard.dropin_file_suggested} +

+
+
+ Debian / Ubuntu +
    + {diag.nginx_wizard.debian.steps.map((s, i) => ( +
  1. + {s} +
  2. + ))} +
+
+
+ RHEL / Rocky / Alma +
    + {diag.nginx_wizard.rhel.steps.map((s, i) => ( +
  1. + {s} +
  2. + ))} +
+
+
+
+ ) : null} ) : null} +
+
DNS-01 Let's Encrypt (CDN / no HTTP)
+
+

+ Use when HTTP validation cannot reach this server (e.g. Cloudflare "orange cloud"). Requires{' '} + certbot-dns-cloudflare on the server for the Cloudflare option. +

+
+ { setShowDnsCf(true); setCfDomain(''); setCfEmail(''); setCfToken('') }}> + Cloudflare DNS-01 + + { + setManualDom('') + setManualOut(null) + }} + > + Clear manual help + +
+
+
+ + setManualDom(e.target.value)} + placeholder="example.com" + /> +
+
+ { + 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 + +
+
+ {manualOut ? ( +
+
+ TXT name: {manualOut.txt_record_name} +
+
+ Example: {manualOut.certbot_example} +
+ {manualOut.note ?

{manualOut.note}

: null} +
+ ) : null} +
+
+
@@ -219,7 +334,7 @@ export function DomainsPage() { {c.name}
-)) + )) )}
@@ -269,6 +384,51 @@ export function DomainsPage() { ) : null} + + setShowDnsCf(false)} centered> + + Let's Encrypt via Cloudflare DNS-01 + +
{ + 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)) + }} + > + +
+ + setCfDomain(e.target.value)} required placeholder="example.com" /> +
+
+ + setCfEmail(e.target.value)} required /> +
+
+ + setCfToken(e.target.value)} required autoComplete="off" /> +
+
+ + setShowDnsCf(false)}> + Cancel + + + {cfBusy ? 'Running certbot…' : 'Issue certificate'} + + +
+
) } diff --git a/YakPanel-server/frontend/src/pages/FtpPage.tsx b/YakPanel-server/frontend/src/pages/FtpPage.tsx index 36ca03bb..9e7cfbdb 100644 --- a/YakPanel-server/frontend/src/pages/FtpPage.tsx +++ b/YakPanel-server/frontend/src/pages/FtpPage.tsx @@ -12,6 +12,10 @@ interface FtpAccount { export function FtpPage() { const [accounts, setAccounts] = useState([]) + const [logPath, setLogPath] = useState(null) + const [logContent, setLogContent] = useState('') + const [logLoading, setLogLoading] = useState(false) + const [logError, setLogError] = useState('') const [loading, setLoading] = useState(true) const [error, setError] = useState('') const [showCreate, setShowCreate] = useState(false) @@ -32,6 +36,18 @@ export function FtpPage() { loadAccounts() }, []) + const loadFtpLogs = () => { + setLogLoading(true) + setLogError('') + apiRequest<{ path: string | null; content: string }>('/ftp/logs?lines=400') + .then((r) => { + setLogPath(r.path) + setLogContent(r.content || '') + }) + .catch((err) => setLogError(err.message)) + .finally(() => setLogLoading(false)) + } + const handleCreate = (e: React.FormEvent) => { e.preventDefault() const form = e.currentTarget @@ -119,6 +135,33 @@ export function FtpPage() { apt install pure-ftpd pure-ftpd-common +
+
+ FTP log (tail) + +
+
+ {logError ? {logError} : null} + {logPath ? ( +

+ Source: {logPath} +

+ ) : null} + {logContent ? ( +
+              {logContent}
+            
+ ) : !logLoading && !logError ? ( +

Click "Load log" to tail common Pure-FTPd paths on this server.

+ ) : null} +
+
+ setShowCreate(false)} centered> Create FTP Account diff --git a/YakPanel-server/frontend/src/pages/SecurityChecklistPage.tsx b/YakPanel-server/frontend/src/pages/SecurityChecklistPage.tsx new file mode 100644 index 00000000..930c98c0 --- /dev/null +++ b/YakPanel-server/frontend/src/pages/SecurityChecklistPage.tsx @@ -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([]) + 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 ( + <> + +

Loading…

+ + ) + } + + return ( + <> + + + {error ? {error} : null} + + {disclaimer ? ( +

{disclaimer}

+ ) : null} + +
+ {items.length === 0 ? ( +
No checks returned.
+ ) : ( + items.map((it) => ( +
+ + {it.ok === true ? 'OK' : it.ok === false ? '!' : '?'} + +
+
{it.title}
+
{it.detail}
+
+
+ )) + )} +
+ + ) +} diff --git a/YakPanel-server/frontend/src/pages/SitePage.tsx b/YakPanel-server/frontend/src/pages/SitePage.tsx index 3a4a3693..d88088ed 100644 --- a/YakPanel-server/frontend/src/pages/SitePage.tsx +++ b/YakPanel-server/frontend/src/pages/SitePage.tsx @@ -46,6 +46,11 @@ export function SitePage() { ps: string php_version: string force_https: boolean + proxy_upstream: string + proxy_websocket: boolean + dir_auth_path: string + dir_auth_user_file: string + php_deny_execute: boolean } | null>(null) const [editLoading, setEditLoading] = useState(false) const [editError, setEditError] = useState('') @@ -241,6 +246,11 @@ export function SitePage() { ps: s.ps || '', php_version: s.php_version || '74', force_https: !!(s.force_https && s.force_https !== 0), + proxy_upstream: s.proxy_upstream || '', + proxy_websocket: !!(s.proxy_websocket && Number(s.proxy_websocket) !== 0), + dir_auth_path: s.dir_auth_path || '', + dir_auth_user_file: s.dir_auth_user_file || '', + php_deny_execute: !!(s.php_deny_execute && Number(s.php_deny_execute) !== 0), }) ) .catch((err) => setEditError(err.message)) @@ -262,6 +272,11 @@ export function SitePage() { ps: editForm.ps || undefined, php_version: editForm.php_version, force_https: editForm.force_https, + proxy_upstream: editForm.proxy_upstream, + proxy_websocket: editForm.proxy_websocket, + dir_auth_path: editForm.dir_auth_path, + dir_auth_user_file: editForm.dir_auth_user_file, + php_deny_execute: editForm.php_deny_execute, }) .then(() => { setEditSiteId(null) @@ -415,6 +430,31 @@ export function SitePage() { Force HTTPS (redirect HTTP to HTTPS) +
+

Reverse proxy (optional): leave empty for PHP/static site.

+
+ + +
+
+ + +
+

Directory HTTP auth (requires htpasswd file on server).

+
+ +
+
+ +
+
+ + +
@@ -900,6 +940,59 @@ export function SitePage() { Force HTTPS
+
+
+ + setEditForm({ ...editForm, proxy_upstream: e.target.value })} + type="text" + className="form-control form-control-sm" + placeholder="http://127.0.0.1:3000" + /> +
+
+ setEditForm({ ...editForm, proxy_websocket: e.target.checked })} + /> + +
+
+ + setEditForm({ ...editForm, dir_auth_path: e.target.value })} + type="text" + className="form-control form-control-sm" + /> +
+
+ + setEditForm({ ...editForm, dir_auth_user_file: e.target.value })} + type="text" + className="form-control form-control-sm" + /> +
+
+ setEditForm({ ...editForm, php_deny_execute: e.target.checked })} + /> + +