new changes
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -27,6 +27,9 @@ class CreateBackupPlanRequest(BaseModel):
|
||||
target_id: int
|
||||
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}",
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
@@ -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),
|
||||
|
||||
78
YakPanel-server/backend/app/api/security.py
Normal file
78
YakPanel-server/backend/app/api/security.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""YakPanel - read-only security checklist (local server probes)."""
|
||||
import os
|
||||
import re
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.api.auth import get_current_user
|
||||
from app.models.user import User
|
||||
from app.core.utils import read_file, exec_shell_sync
|
||||
|
||||
router = APIRouter(prefix="/security", tags=["security"])
|
||||
|
||||
|
||||
@router.get("/checklist")
|
||||
async def security_checklist(current_user: User = Depends(get_current_user)):
|
||||
"""Non-destructive hints: SSH config, firewall helper, fail2ban. Not a full audit."""
|
||||
items: list[dict] = []
|
||||
sshd = "/etc/ssh/sshd_config"
|
||||
body = read_file(sshd) if os.path.isfile(sshd) else None
|
||||
if isinstance(body, str) and body:
|
||||
if re.search(r"^\s*PasswordAuthentication\s+no\s*$", body, re.MULTILINE | re.IGNORECASE):
|
||||
items.append({
|
||||
"id": "ssh_password_auth",
|
||||
"ok": True,
|
||||
"title": "SSH password auth",
|
||||
"detail": "PasswordAuthentication appears set to no (prefer key-based login).",
|
||||
})
|
||||
elif re.search(r"^\s*PasswordAuthentication\s+yes", body, re.MULTILINE | re.IGNORECASE):
|
||||
items.append({
|
||||
"id": "ssh_password_auth",
|
||||
"ok": False,
|
||||
"title": "SSH password auth",
|
||||
"detail": "PasswordAuthentication is yes — consider disabling and using SSH keys.",
|
||||
})
|
||||
else:
|
||||
items.append({
|
||||
"id": "ssh_password_auth",
|
||||
"ok": None,
|
||||
"title": "SSH password auth",
|
||||
"detail": "Could not find an explicit PasswordAuthentication line (defaults depend on distro).",
|
||||
})
|
||||
else:
|
||||
items.append({
|
||||
"id": "ssh_password_auth",
|
||||
"ok": None,
|
||||
"title": "SSH password auth",
|
||||
"detail": "/etc/ssh/sshd_config not readable from the panel process.",
|
||||
})
|
||||
|
||||
ufw_out, _ = exec_shell_sync("ufw status 2>/dev/null", timeout=5)
|
||||
ufw = ufw_out or ""
|
||||
if "Status: active" in ufw:
|
||||
items.append({"id": "ufw", "ok": True, "title": "UFW firewall", "detail": "UFW reports active."})
|
||||
elif "Status: inactive" in ufw:
|
||||
items.append({
|
||||
"id": "ufw",
|
||||
"ok": None,
|
||||
"title": "UFW firewall",
|
||||
"detail": "UFW installed but inactive — enable if this host is public.",
|
||||
})
|
||||
else:
|
||||
items.append({
|
||||
"id": "ufw",
|
||||
"ok": None,
|
||||
"title": "UFW firewall",
|
||||
"detail": "UFW not detected (OK if you use firewalld/iptables only).",
|
||||
})
|
||||
|
||||
f2_out, _ = exec_shell_sync("systemctl is-active fail2ban 2>/dev/null", timeout=5)
|
||||
f2_active = (f2_out or "").strip() == "active"
|
||||
items.append({
|
||||
"id": "fail2ban",
|
||||
"ok": f2_active,
|
||||
"title": "fail2ban",
|
||||
"detail": "fail2ban is active." if f2_active else "fail2ban not active (optional hardening).",
|
||||
})
|
||||
|
||||
return {"items": items, "disclaimer": "YakPanel reads local settings only; this is not a compliance scan."}
|
||||
@@ -29,6 +29,11 @@ class CreateSiteRequest(BaseModel):
|
||||
ps: str = ""
|
||||
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"])
|
||||
|
||||
@@ -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"""
|
||||
|
||||
Binary file not shown.
@@ -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("""
|
||||
|
||||
30
YakPanel-server/backend/app/data/cron_templates.json
Normal file
30
YakPanel-server/backend/app/data/cron_templates.json
Normal file
@@ -0,0 +1,30 @@
|
||||
[
|
||||
{
|
||||
"id": "disk_space",
|
||||
"name": "Disk free space alert",
|
||||
"schedule": "0 */6 * * *",
|
||||
"execstr": "df -h / | tail -1 | awk '{if (int($5) > 90) print \"Disk usage over 90% on / — \" $0; exit 0}'",
|
||||
"description": "Print a line if root filesystem use exceeds 90% (extend with mail/curl as needed)."
|
||||
},
|
||||
{
|
||||
"id": "yakpanel_backup",
|
||||
"name": "Run YakPanel scheduled backups",
|
||||
"schedule": "15 * * * *",
|
||||
"execstr": "curl -fsS -H \"Authorization: Bearer YOUR_TOKEN\" http://127.0.0.1:8889/api/v1/backup/run-scheduled || true",
|
||||
"description": "Example: call the panel backup API hourly (set token and port; prefer localhost + firewall)."
|
||||
},
|
||||
{
|
||||
"id": "clear_tmp",
|
||||
"name": "Clean old temp files",
|
||||
"schedule": "0 3 * * *",
|
||||
"execstr": "find /tmp -type f -atime +7 -delete 2>/dev/null || true",
|
||||
"description": "Remove files in /tmp not accessed in 7 days."
|
||||
},
|
||||
{
|
||||
"id": "php_fpm_ping",
|
||||
"name": "PHP-FPM socket check",
|
||||
"schedule": "*/10 * * * *",
|
||||
"execstr": "test -S /tmp/php-cgi-74.sock && exit 0 || echo \"php-fpm 74 socket missing\"",
|
||||
"description": "Adjust php version/socket path for your stack."
|
||||
}
|
||||
]
|
||||
@@ -29,6 +29,7 @@ from app.api import (
|
||||
node,
|
||||
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("/")
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -13,3 +13,7 @@ class BackupPlan(Base):
|
||||
target_id: Mapped[int] = mapped_column(Integer, nullable=False) # site_id or database_id
|
||||
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="")
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
Binary file not shown.
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user