new changes

This commit is contained in:
Niranjan
2026-04-07 13:23:35 +05:30
parent df015e4d5a
commit 6dea3b4307
38 changed files with 1332 additions and 119 deletions

View File

@@ -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}",

View File

@@ -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 = ""

View File

@@ -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.
"""

View File

@@ -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),

View 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."}

View File

@@ -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"])

View File

@@ -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"""

View File

@@ -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("""

View 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."
}
]

View File

@@ -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("/")

View File

@@ -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="")

View File

@@ -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)

View File

@@ -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()

View File

@@ -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

View File

@@ -0,0 +1,24 @@
# YakPanel feature parity checklist (clean-room)
Internal checklist against common hosting-panel capabilities used as a product roadmap only. No third-party panel code is shipped.
| Area | Status | YakPanel location |
|------|--------|-------------------|
| Sites, domains, redirects | Done | `api/site.py`, `site_service.py` |
| Nginx vhost + SSL (HTTP-01) | Done | `webserver/templates/nginx_site.conf`, `ssl.py` |
| SSL diagnostics + port 443 probe | Done | `GET /ssl/diagnostics` |
| Nginx include wizard (drop-in hints) | Done | `GET /ssl/diagnostics``nginx_wizard` |
| Reverse proxy site mode | Done | `Site.proxy_upstream`, vhost `proxy_pass` |
| WebSocket proxy hint | Done | `Site.proxy_websocket` |
| Directory HTTP basic auth | Done | `Site.dir_auth_path`, `dir_auth_user_file` |
| Disable PHP execution (uploads) | Done | `Site.php_deny_execute` |
| DNS-01 Let's Encrypt (Cloudflare / manual TXT) | Done | `POST /ssl/dns-request/*` |
| Security checklist (read-only probes) | Done | `GET /security/checklist` |
| FTP logs (tail) | Done | `GET /ftp/logs` |
| Cron job templates (YakPanel JSON) | Done | `GET /crontab/templates`, `data/cron_templates.json` |
| Backup plans + optional S3 upload | Done | `backup.py`, `BackupPlan` S3 fields, `boto3` optional |
| Database / FTP / firewall / monitor | Partial (pre-existing) | respective `api/*.py` |
| Mail server | Not planned | — |
| WordPress one-click | Not planned | plugin later |
_Last updated: parity pass (this implementation)._

View File

@@ -16,6 +16,9 @@ const CrontabPage = lazy(() => import('./pages/CrontabPage').then((m) => ({ defa
const ConfigPage = lazy(() => import('./pages/ConfigPage').then((m) => ({ default: m.ConfigPage })))
const LogsPage = lazy(() => import('./pages/LogsPage').then((m) => ({ default: m.LogsPage })))
const FirewallPage = lazy(() => import('./pages/FirewallPage').then((m) => ({ default: m.FirewallPage })))
const SecurityChecklistPage = lazy(() =>
import('./pages/SecurityChecklistPage').then((m) => ({ default: m.SecurityChecklistPage })),
)
const DomainsPage = lazy(() => import('./pages/DomainsPage').then((m) => ({ default: m.DomainsPage })))
const DockerPage = lazy(() => import('./pages/DockerPage').then((m) => ({ default: m.DockerPage })))
const NodePage = lazy(() => import('./pages/NodePage').then((m) => ({ default: m.NodePage })))
@@ -125,6 +128,14 @@ export default function App() {
</Suspense>
}
/>
<Route
path="security-checklist"
element={
<Suspense fallback={<PageSkeleton />}>
<SecurityChecklistPage />
</Suspense>
}
/>
<Route
path="files"
element={

View File

@@ -67,7 +67,19 @@ export async function login(username: string, password: string) {
return data
}
export async function createSite(data: { name: string; domains: string[]; path?: string; ps?: string; php_version?: string; force_https?: boolean }) {
export async function createSite(data: {
name: string
domains: string[]
path?: string
ps?: string
php_version?: string
force_https?: boolean
proxy_upstream?: string
proxy_websocket?: boolean
dir_auth_path?: string
dir_auth_user_file?: string
php_deny_execute?: boolean
}) {
return apiRequest<{ status: boolean; msg: string; id?: number }>('/site/create', {
method: 'POST',
body: JSON.stringify(data),
@@ -108,14 +120,38 @@ export async function siteBatch(action: 'enable' | 'disable' | 'delete', ids: nu
}
export async function getSite(siteId: number) {
return apiRequest<{ id: number; name: string; path: string; status: number; ps: string; project_type: string; php_version: string; force_https: number; domains: string[] }>(
`/site/${siteId}`
)
return apiRequest<{
id: number
name: string
path: string
status: number
ps: string
project_type: string
php_version: string
force_https: number
domains: string[]
proxy_upstream?: string
proxy_websocket?: number
dir_auth_path?: string
dir_auth_user_file?: string
php_deny_execute?: number
}>(`/site/${siteId}`)
}
export async function updateSite(
siteId: number,
data: { path?: string; domains?: string[]; ps?: string; php_version?: string; force_https?: boolean }
data: {
path?: string
domains?: string[]
ps?: string
php_version?: string
force_https?: boolean
proxy_upstream?: string
proxy_websocket?: boolean
dir_auth_path?: string
dir_auth_user_file?: string
php_deny_execute?: boolean
}
) {
return apiRequest<{ status: boolean; msg: string }>(`/site/${siteId}`, {
method: 'PUT',
@@ -530,17 +566,50 @@ export async function getMonitorNetwork() {
}
export async function listBackupPlans() {
return apiRequest<{ id: number; name: string; plan_type: string; target_id: number; schedule: string; enabled: boolean }[]>('/backup/plans')
return apiRequest<
{
id: number
name: string
plan_type: string
target_id: number
schedule: string
enabled: boolean
s3_bucket?: string
s3_endpoint?: string
s3_key_prefix?: string
}[]
>('/backup/plans')
}
export async function createBackupPlan(data: { name: string; plan_type: string; target_id: number; schedule: string; enabled?: boolean }) {
export async function createBackupPlan(data: {
name: string
plan_type: string
target_id: number
schedule: string
enabled?: boolean
s3_bucket?: string
s3_endpoint?: string
s3_key_prefix?: string
}) {
return apiRequest<{ status: boolean; msg: string; id: number }>('/backup/plans', {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function updateBackupPlan(planId: number, data: { name: string; plan_type: string; target_id: number; schedule: string; enabled?: boolean }) {
export async function updateBackupPlan(
planId: number,
data: {
name: string
plan_type: string
target_id: number
schedule: string
enabled?: boolean
s3_bucket?: string
s3_endpoint?: string
s3_key_prefix?: string
}
) {
return apiRequest<{ status: boolean; msg: string }>(`/backup/plans/${planId}`, {
method: 'PUT',
body: JSON.stringify(data),

View File

@@ -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' },
]

View File

@@ -7,6 +7,7 @@ export const routeTitleMap: Record<string, string> = {
'/docker': 'Docker',
'/control': 'Monitor',
'/firewall': 'Security',
'/security-checklist': 'Security checklist',
'/files': 'Files',
'/node': 'Node',
'/logs': 'Logs',

View File

@@ -17,6 +17,9 @@ interface BackupPlanRecord {
target_id: number
schedule: string
enabled: boolean
s3_bucket?: string
s3_endpoint?: string
s3_key_prefix?: string
}
interface SiteRecord {
@@ -74,13 +77,16 @@ export function BackupPlansPage() {
const target_id = Number((form.elements.namedItem('target_id') as HTMLSelectElement).value)
const schedule = (form.elements.namedItem('schedule') as HTMLInputElement).value.trim()
const enabled = (form.elements.namedItem('enabled') as HTMLInputElement).checked
const s3_bucket = (form.elements.namedItem('s3_bucket') as HTMLInputElement).value.trim()
const s3_endpoint = (form.elements.namedItem('s3_endpoint') as HTMLInputElement).value.trim()
const s3_key_prefix = (form.elements.namedItem('s3_key_prefix') as HTMLInputElement).value.trim()
if (!name || !schedule || !target_id) {
setError('Name, target and schedule are required')
return
}
setCreating(true)
createBackupPlan({ name, plan_type, target_id, schedule, enabled })
createBackupPlan({ name, plan_type, target_id, schedule, enabled, s3_bucket, s3_endpoint, s3_key_prefix })
.then(() => {
setShowCreate(false)
form.reset()
@@ -110,9 +116,21 @@ export function BackupPlansPage() {
const target_id = Number((form.elements.namedItem('edit_target_id') as HTMLSelectElement).value)
const schedule = (form.elements.namedItem('edit_schedule') as HTMLInputElement).value.trim()
const enabled = (form.elements.namedItem('edit_enabled') as HTMLInputElement).checked
const s3_bucket = (form.elements.namedItem('edit_s3_bucket') as HTMLInputElement).value.trim()
const s3_endpoint = (form.elements.namedItem('edit_s3_endpoint') as HTMLInputElement).value.trim()
const s3_key_prefix = (form.elements.namedItem('edit_s3_key_prefix') as HTMLInputElement).value.trim()
if (!name || !schedule || !target_id) return
updateBackupPlan(editPlan.id, { name, plan_type: editPlanType, target_id, schedule, enabled })
updateBackupPlan(editPlan.id, {
name,
plan_type: editPlanType,
target_id,
schedule,
enabled,
s3_bucket,
s3_endpoint,
s3_key_prefix,
})
.then(() => {
setEditPlan(null)
loadPlans()
@@ -188,7 +206,9 @@ export function BackupPlansPage() {
<p className="small text-secondary mb-3">
Schedule automated backups. Add a cron entry (e.g. <code>0 * * * *</code> hourly) to call{' '}
<code>POST /api/v1/backup/run-scheduled</code> with your auth token.
<code>POST /api/v1/backup/run-scheduled</code> with your auth token. Optional S3-compatible upload uses{' '}
<code>AWS_ACCESS_KEY_ID</code> and <code>AWS_SECRET_ACCESS_KEY</code> in the panel environment when a bucket
name is set.
</p>
<div className="card">
@@ -199,6 +219,7 @@ export function BackupPlansPage() {
<th>Type</th>
<th>Target</th>
<th>Schedule</th>
<th>S3</th>
<th>Enabled</th>
<th className="text-end">Actions</th>
</tr>
@@ -206,7 +227,7 @@ export function BackupPlansPage() {
<tbody>
{plans.length === 0 ? (
<tr>
<td colSpan={6} className="p-0">
<td colSpan={7} className="p-0">
<EmptyState
title="No backup plans"
description='Click "Add Plan" to create one.'
@@ -222,6 +243,9 @@ export function BackupPlansPage() {
<td>
<code className="small">{p.schedule}</code>
</td>
<td className="small">
{p.s3_bucket ? <span className="badge text-bg-info">{p.s3_bucket}</span> : '—'}
</td>
<td>{p.enabled ? 'Yes' : 'No'}</td>
<td className="text-end">
<button
@@ -314,6 +338,36 @@ export function BackupPlansPage() {
required
/>
</div>
<div className="mb-3">
<label className="form-label">S3 bucket (optional)</label>
<input
name="edit_s3_bucket"
type="text"
placeholder="my-backups"
defaultValue={editPlan.s3_bucket || ''}
className="form-control"
/>
</div>
<div className="mb-3">
<label className="form-label">S3 endpoint (optional)</label>
<input
name="edit_s3_endpoint"
type="text"
placeholder="https://s3.example.com or leave empty for AWS"
defaultValue={editPlan.s3_endpoint || ''}
className="form-control"
/>
</div>
<div className="mb-3">
<label className="form-label">S3 key prefix (optional)</label>
<input
name="edit_s3_key_prefix"
type="text"
placeholder="yakpanel/backups"
defaultValue={editPlan.s3_key_prefix || ''}
className="form-control"
/>
</div>
<div className="form-check">
<input name="edit_enabled" type="checkbox" defaultChecked={editPlan.enabled} className="form-check-input" id="edit_enabled" />
<label className="form-check-label" htmlFor="edit_enabled">
@@ -383,6 +437,18 @@ export function BackupPlansPage() {
<input name="schedule" type="text" placeholder="0 2 * * *" className="form-control" required />
<div className="form-text">e.g. 0 2 * * * = daily at 2am, 0 */6 * * * = every 6 hours</div>
</div>
<div className="mb-3">
<label className="form-label">S3 bucket (optional)</label>
<input name="s3_bucket" type="text" placeholder="my-backups" className="form-control" />
</div>
<div className="mb-3">
<label className="form-label">S3 endpoint (optional)</label>
<input name="s3_endpoint" type="text" placeholder="Custom S3 API URL" className="form-control" />
</div>
<div className="mb-3">
<label className="form-label">S3 key prefix (optional)</label>
<input name="s3_key_prefix" type="text" placeholder="yakpanel/backups" className="form-control" />
</div>
<div className="form-check">
<input name="enabled" type="checkbox" defaultChecked className="form-check-input" id="plan_enabled" />
<label className="form-check-label" htmlFor="plan_enabled">

View File

@@ -11,6 +11,14 @@ interface CronJob {
execstr: string
}
interface CronTemplate {
id: string
name: string
schedule: string
execstr: string
description?: string
}
const SCHEDULE_PRESETS = [
{ label: 'Every minute', value: '* * * * *' },
{ label: 'Every 5 min', value: '*/5 * * * *' },
@@ -29,6 +37,9 @@ export function CrontabPage() {
const [saving, setSaving] = useState(false)
const [formError, setFormError] = useState('')
const [applying, setApplying] = useState(false)
const [templates, setTemplates] = useState<CronTemplate[]>([])
const [prefill, setPrefill] = useState<{ name?: string; schedule?: string; execstr?: string } | null>(null)
const [formNonce, setFormNonce] = useState(0)
const loadJobs = () => {
setLoading(true)
@@ -42,6 +53,12 @@ export function CrontabPage() {
loadJobs()
}, [])
useEffect(() => {
apiRequest<{ templates: CronTemplate[] }>('/crontab/templates')
.then((r) => setTemplates(Array.isArray(r.templates) ? r.templates : []))
.catch(() => setTemplates([]))
}, [])
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
const form = e.currentTarget
@@ -63,6 +80,8 @@ export function CrontabPage() {
.then(() => {
setShowForm(false)
setEditingId(null)
setEditJob(null)
setPrefill(null)
form.reset()
loadJobs()
})
@@ -71,11 +90,20 @@ export function CrontabPage() {
}
const handleEdit = (job: CronJob) => {
setPrefill(null)
setEditingId(job.id)
setEditJob(job)
setShowForm(true)
}
const useTemplate = (t: CronTemplate) => {
setEditingId(null)
setEditJob(null)
setPrefill({ name: t.name, schedule: t.schedule, execstr: t.execstr })
setFormNonce((n) => n + 1)
setShowForm(true)
}
const handleDelete = (id: number) => {
if (!confirm('Delete this cron job?')) return
apiRequest(`/crontab/${id}`, { method: 'DELETE' })
@@ -131,11 +159,21 @@ export function CrontabPage() {
{error ? <AdminAlert className="mb-3">{error}</AdminAlert> : null}
<Modal show={showForm} onHide={() => { setShowForm(false); setEditingId(null); setEditJob(null) }} centered size="lg">
<Modal
show={showForm}
onHide={() => {
setShowForm(false)
setEditingId(null)
setEditJob(null)
setPrefill(null)
}}
centered
size="lg"
>
<Modal.Header closeButton>
<Modal.Title>{editingId ? 'Edit Cron Job' : 'Create Cron Job'}</Modal.Title>
</Modal.Header>
<form key={editingId ?? 'new'} onSubmit={handleSubmit}>
<form key={`${editingId ?? 'new'}-${formNonce}`} onSubmit={handleSubmit}>
<Modal.Body>
{formError ? <AdminAlert className="mb-3">{formError}</AdminAlert> : null}
<div className="mb-3">
@@ -145,7 +183,7 @@ export function CrontabPage() {
name="name"
type="text"
placeholder="My task"
defaultValue={editJob?.name}
defaultValue={editJob?.name ?? prefill?.name ?? ''}
className="form-control"
/>
</div>
@@ -170,7 +208,7 @@ export function CrontabPage() {
name="schedule"
type="text"
placeholder="* * * * *"
defaultValue={editJob?.schedule}
defaultValue={editJob?.schedule ?? prefill?.schedule ?? ''}
className="form-control"
required
/>
@@ -182,7 +220,7 @@ export function CrontabPage() {
name="execstr"
rows={3}
placeholder="/usr/bin/php /www/wwwroot/script.php"
defaultValue={editJob?.execstr}
defaultValue={editJob?.execstr ?? prefill?.execstr ?? ''}
className="form-control"
required
/>
@@ -196,6 +234,7 @@ export function CrontabPage() {
setShowForm(false)
setEditingId(null)
setEditJob(null)
setPrefill(null)
}}
>
Cancel
@@ -211,6 +250,32 @@ export function CrontabPage() {
Jobs are stored in the panel. Click &quot;Apply to System&quot; to sync them to the system crontab (root).
</div>
{templates.length > 0 ? (
<div className="card mb-3">
<div className="card-header">YakPanel starter templates</div>
<div className="card-body py-3">
<p className="small text-secondary mb-2">Review and edit commands before saving adjust paths and tokens for your server.</p>
<div className="list-group list-group-flush border rounded">
{templates.map((t) => (
<div
key={t.id}
className="list-group-item d-flex flex-wrap align-items-center justify-content-between gap-2"
>
<div className="flex-grow-1" style={{ minWidth: 200 }}>
<div className="fw-medium">{t.name}</div>
{t.description ? <div className="small text-muted">{t.description}</div> : null}
<code className="small d-block mt-1 text-break">{t.schedule}</code>
</div>
<button type="button" className="btn btn-sm btn-outline-primary flex-shrink-0" onClick={() => useTemplate(t)}>
Use in new job
</button>
</div>
))}
</div>
</div>
</div>
) : null}
<div className="card">
<AdminTable>
<thead>

View File

@@ -17,9 +17,19 @@ interface Certificate {
path: string
}
interface NginxWizard {
detected_layout: string
include_snippet: string
dropin_file_suggested: string
debian: { sites_available_file: string; sites_enabled_symlink: string; steps: string[] }
rhel: { conf_d_file: string; steps: string[] }
note: string
}
interface SslDiagnostics {
vhost_dir: string
include_snippet: string
nginx_wizard?: NginxWizard
vhosts: { file: string; has_listen_80: boolean; has_listen_443: boolean; has_ssl_directives: boolean }[]
any_vhost_listen_ssl: boolean
nginx_effective_listen_443: boolean
@@ -41,6 +51,13 @@ export function DomainsPage() {
const [requesting, setRequesting] = useState<string | null>(null)
const [requestDomain, setRequestDomain] = useState<Domain | null>(null)
const [requestEmail, setRequestEmail] = useState('')
const [showDnsCf, setShowDnsCf] = useState(false)
const [cfDomain, setCfDomain] = useState('')
const [cfEmail, setCfEmail] = useState('')
const [cfToken, setCfToken] = useState('')
const [cfBusy, setCfBusy] = useState(false)
const [manualDom, setManualDom] = useState('')
const [manualOut, setManualOut] = useState<{ txt_record_name?: string; certbot_example?: string; note?: string } | null>(null)
const load = () => {
setLoading(true)
@@ -155,9 +172,107 @@ export function DomainsPage() {
nginx -T probe: {diag.nginx_t_probe_errors.join(' | ')}
</div>
) : null}
{diag.nginx_wizard ? (
<div className="mt-4 border-top pt-3">
<div className="fw-semibold mb-2">Nginx include wizard</div>
<p className="small text-secondary mb-2">{diag.nginx_wizard.note}</p>
<p className="small mb-1">
Detected layout: <code>{diag.nginx_wizard.detected_layout}</code> suggested file:{' '}
<code className="user-select-all">{diag.nginx_wizard.dropin_file_suggested}</code>
</p>
<div className="row g-2 small">
<div className="col-md-6">
<strong className="d-block mb-1">Debian / Ubuntu</strong>
<ol className="ps-3 mb-0">
{diag.nginx_wizard.debian.steps.map((s, i) => (
<li key={i} className="mb-1">
<code className="text-break user-select-all small d-block bg-body-secondary p-1 rounded">{s}</code>
</li>
))}
</ol>
</div>
<div className="col-md-6">
<strong className="d-block mb-1">RHEL / Rocky / Alma</strong>
<ol className="ps-3 mb-0">
{diag.nginx_wizard.rhel.steps.map((s, i) => (
<li key={i} className="mb-1">
<code className="text-break user-select-all small d-block bg-body-secondary p-1 rounded">{s}</code>
</li>
))}
</ol>
</div>
</div>
</div>
) : null}
</div>
) : null}
<div className="card mb-4">
<div className="card-header">DNS-01 Let&apos;s Encrypt (CDN / no HTTP)</div>
<div className="card-body small">
<p className="text-secondary mb-3">
Use when HTTP validation cannot reach this server (e.g. Cloudflare &quot;orange cloud&quot;). Requires{' '}
<code>certbot-dns-cloudflare</code> on the server for the Cloudflare option.
</p>
<div className="d-flex flex-wrap gap-2 mb-3">
<AdminButton type="button" size="sm" variant="primary" onClick={() => { setShowDnsCf(true); setCfDomain(''); setCfEmail(''); setCfToken('') }}>
Cloudflare DNS-01
</AdminButton>
<AdminButton
type="button"
size="sm"
variant="outline-secondary"
onClick={() => {
setManualDom('')
setManualOut(null)
}}
>
Clear manual help
</AdminButton>
</div>
<div className="row g-2 align-items-end">
<div className="col-md-4">
<label className="form-label small mb-0">Domain (manual TXT hint)</label>
<input
className="form-control form-control-sm"
value={manualDom}
onChange={(e) => setManualDom(e.target.value)}
placeholder="example.com"
/>
</div>
<div className="col-auto">
<AdminButton
type="button"
size="sm"
variant="outline-primary"
disabled={!manualDom.trim()}
onClick={() => {
apiRequest<{ txt_record_name: string; certbot_example: string; note: string }>('/ssl/dns-request/manual-instructions', {
method: 'POST',
body: JSON.stringify({ domain: manualDom.trim() }),
})
.then(setManualOut)
.catch((err) => setError(err.message))
}}
>
Get TXT / certbot hint
</AdminButton>
</div>
</div>
{manualOut ? (
<div className="mt-2 p-2 bg-body-secondary rounded small">
<div>
<strong>TXT name:</strong> <code className="user-select-all">{manualOut.txt_record_name}</code>
</div>
<div className="mt-1">
<strong>Example:</strong> <code className="user-select-all text-break">{manualOut.certbot_example}</code>
</div>
{manualOut.note ? <p className="mb-0 mt-1 text-secondary">{manualOut.note}</p> : null}
</div>
) : null}
</div>
</div>
<div className="row g-4">
<div className="col-lg-6">
<div className="card h-100">
@@ -219,7 +334,7 @@ export function DomainsPage() {
<i className="ti ti-shield-check text-success flex-shrink-0" aria-hidden />
<span className="font-monospace small text-break">{c.name}</span>
</div>
))
))
)}
</div>
</div>
@@ -269,6 +384,51 @@ export function DomainsPage() {
</form>
) : null}
</Modal>
<Modal show={showDnsCf} onHide={() => setShowDnsCf(false)} centered>
<Modal.Header closeButton>
<Modal.Title>Let&apos;s Encrypt via Cloudflare DNS-01</Modal.Title>
</Modal.Header>
<form
onSubmit={(e) => {
e.preventDefault()
setCfBusy(true)
apiRequest<{ status: boolean; msg?: string }>('/ssl/dns-request/cloudflare', {
method: 'POST',
body: JSON.stringify({ domain: cfDomain.trim(), email: cfEmail.trim(), api_token: cfToken.trim() }),
})
.then(() => {
setShowDnsCf(false)
load()
})
.catch((err) => setError(err.message))
.finally(() => setCfBusy(false))
}}
>
<Modal.Body>
<div className="mb-3">
<label className="form-label">Domain (primary)</label>
<input className="form-control" value={cfDomain} onChange={(e) => setCfDomain(e.target.value)} required placeholder="example.com" />
</div>
<div className="mb-3">
<label className="form-label">Let&apos;s Encrypt email</label>
<input type="email" className="form-control" value={cfEmail} onChange={(e) => setCfEmail(e.target.value)} required />
</div>
<div className="mb-0">
<label className="form-label">Cloudflare API token (DNS:Edit)</label>
<input type="password" className="form-control" value={cfToken} onChange={(e) => setCfToken(e.target.value)} required autoComplete="off" />
</div>
</Modal.Body>
<Modal.Footer>
<AdminButton type="button" variant="secondary" onClick={() => setShowDnsCf(false)}>
Cancel
</AdminButton>
<AdminButton type="submit" variant="primary" disabled={cfBusy}>
{cfBusy ? 'Running certbot…' : 'Issue certificate'}
</AdminButton>
</Modal.Footer>
</form>
</Modal>
</>
)
}

View File

@@ -12,6 +12,10 @@ interface FtpAccount {
export function FtpPage() {
const [accounts, setAccounts] = useState<FtpAccount[]>([])
const [logPath, setLogPath] = useState<string | null>(null)
const [logContent, setLogContent] = useState('')
const [logLoading, setLogLoading] = useState(false)
const [logError, setLogError] = useState('')
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [showCreate, setShowCreate] = useState(false)
@@ -32,6 +36,18 @@ export function FtpPage() {
loadAccounts()
}, [])
const loadFtpLogs = () => {
setLogLoading(true)
setLogError('')
apiRequest<{ path: string | null; content: string }>('/ftp/logs?lines=400')
.then((r) => {
setLogPath(r.path)
setLogContent(r.content || '')
})
.catch((err) => setLogError(err.message))
.finally(() => setLogLoading(false))
}
const handleCreate = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
const form = e.currentTarget
@@ -119,6 +135,33 @@ export function FtpPage() {
<code>apt install pure-ftpd pure-ftpd-common</code>
</div>
<div className="card shadow-sm border-0 mb-4">
<div className="card-header d-flex flex-wrap align-items-center justify-content-between gap-2">
<span>FTP log (tail)</span>
<button type="button" className="btn btn-sm btn-outline-primary" disabled={logLoading} onClick={loadFtpLogs}>
{logLoading ? 'Loading…' : logContent ? 'Refresh' : 'Load log'}
</button>
</div>
<div className="card-body">
{logError ? <AdminAlert variant="danger">{logError}</AdminAlert> : null}
{logPath ? (
<p className="small text-muted mb-2">
Source: <code className="user-select-all">{logPath}</code>
</p>
) : null}
{logContent ? (
<pre
className="small bg-body-secondary border rounded p-3 mb-0 text-body"
style={{ maxHeight: '22rem', overflow: 'auto', whiteSpace: 'pre-wrap' }}
>
{logContent}
</pre>
) : !logLoading && !logError ? (
<p className="text-muted small mb-0">Click &quot;Load log&quot; to tail common Pure-FTPd paths on this server.</p>
) : null}
</div>
</div>
<Modal show={showCreate} onHide={() => setShowCreate(false)} centered>
<Modal.Header closeButton>
<Modal.Title>Create FTP Account</Modal.Title>

View File

@@ -0,0 +1,71 @@
import { useEffect, useState } from 'react'
import { apiRequest } from '../api/client'
import { PageHeader, AdminAlert } from '../components/admin'
interface CheckItem {
id: string
ok: boolean | null
title: string
detail: string
}
export function SecurityChecklistPage() {
const [items, setItems] = useState<CheckItem[]>([])
const [disclaimer, setDisclaimer] = useState('')
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
useEffect(() => {
apiRequest<{ items: CheckItem[]; disclaimer?: string }>('/security/checklist')
.then((r) => {
setItems(r.items || [])
setDisclaimer(r.disclaimer || '')
})
.catch((err) => setError(err.message))
.finally(() => setLoading(false))
}, [])
if (loading) {
return (
<>
<PageHeader title="Security checklist" />
<p className="text-secondary">Loading</p>
</>
)
}
return (
<>
<PageHeader title="Security checklist" />
{error ? <AdminAlert className="mb-3">{error}</AdminAlert> : null}
{disclaimer ? (
<p className="small text-secondary mb-3">{disclaimer}</p>
) : null}
<div className="list-group shadow-sm">
{items.length === 0 ? (
<div className="list-group-item text-muted">No checks returned.</div>
) : (
items.map((it) => (
<div key={it.id} className="list-group-item d-flex gap-3 align-items-start">
<span
className={`badge rounded-pill flex-shrink-0 mt-1 ${
it.ok === true ? 'text-bg-success' : it.ok === false ? 'text-bg-warning' : 'text-bg-secondary'
}`}
aria-hidden
>
{it.ok === true ? 'OK' : it.ok === false ? '!' : '?'}
</span>
<div>
<div className="fw-semibold">{it.title}</div>
<div className="small text-secondary">{it.detail}</div>
</div>
</div>
))
)}
</div>
</>
)
}

View File

@@ -46,6 +46,11 @@ export function SitePage() {
ps: string
php_version: string
force_https: boolean
proxy_upstream: string
proxy_websocket: boolean
dir_auth_path: string
dir_auth_user_file: string
php_deny_execute: boolean
} | null>(null)
const [editLoading, setEditLoading] = useState(false)
const [editError, setEditError] = useState('')
@@ -241,6 +246,11 @@ export function SitePage() {
ps: s.ps || '',
php_version: s.php_version || '74',
force_https: !!(s.force_https && s.force_https !== 0),
proxy_upstream: s.proxy_upstream || '',
proxy_websocket: !!(s.proxy_websocket && Number(s.proxy_websocket) !== 0),
dir_auth_path: s.dir_auth_path || '',
dir_auth_user_file: s.dir_auth_user_file || '',
php_deny_execute: !!(s.php_deny_execute && Number(s.php_deny_execute) !== 0),
})
)
.catch((err) => setEditError(err.message))
@@ -262,6 +272,11 @@ export function SitePage() {
ps: editForm.ps || undefined,
php_version: editForm.php_version,
force_https: editForm.force_https,
proxy_upstream: editForm.proxy_upstream,
proxy_websocket: editForm.proxy_websocket,
dir_auth_path: editForm.dir_auth_path,
dir_auth_user_file: editForm.dir_auth_user_file,
php_deny_execute: editForm.php_deny_execute,
})
.then(() => {
setEditSiteId(null)
@@ -415,6 +430,31 @@ export function SitePage() {
Force HTTPS (redirect HTTP to HTTPS)
</label>
</div>
<hr className="my-3" />
<p className="small text-secondary mb-2">Reverse proxy (optional): leave empty for PHP/static site.</p>
<div className="mb-3">
<label className="form-label">Upstream URL</label>
<input name="proxy_upstream" type="text" className="form-control" placeholder="http://127.0.0.1:3000" />
</div>
<div className="form-check mb-3">
<input name="proxy_websocket" type="checkbox" id="create_proxy_ws" className="form-check-input" />
<label htmlFor="create_proxy_ws" className="form-check-label">
WebSocket headers (Upgrade)
</label>
</div>
<p className="small text-secondary mb-2">Directory HTTP auth (requires htpasswd file on server).</p>
<div className="mb-2">
<input name="dir_auth_path" type="text" className="form-control form-control-sm" placeholder="Path prefix e.g. /staff" />
</div>
<div className="mb-3">
<input name="dir_auth_user_file" type="text" className="form-control form-control-sm" placeholder="Absolute path to htpasswd" />
</div>
<div className="form-check mb-3">
<input name="php_deny_execute" type="checkbox" id="create_php_deny" className="form-check-input" />
<label htmlFor="create_php_deny" className="form-check-label">
Block PHP execution under /uploads and /storage
</label>
</div>
<div className="mb-0">
<label className="form-label">Note (optional)</label>
<input name="ps" type="text" placeholder="My website" className="form-control" />
@@ -900,6 +940,59 @@ export function SitePage() {
Force HTTPS
</label>
</div>
<hr className="my-2" />
<div className="mb-2">
<label className="form-label small">Reverse proxy upstream</label>
<input
value={editForm.proxy_upstream}
onChange={(e) => setEditForm({ ...editForm, proxy_upstream: e.target.value })}
type="text"
className="form-control form-control-sm"
placeholder="http://127.0.0.1:3000"
/>
</div>
<div className="form-check mb-3">
<input
type="checkbox"
id="edit_proxy_ws"
className="form-check-input"
checked={editForm.proxy_websocket}
onChange={(e) => setEditForm({ ...editForm, proxy_websocket: e.target.checked })}
/>
<label htmlFor="edit_proxy_ws" className="form-check-label small">
WebSocket proxy headers
</label>
</div>
<div className="mb-2">
<label className="form-label small">Auth path prefix</label>
<input
value={editForm.dir_auth_path}
onChange={(e) => setEditForm({ ...editForm, dir_auth_path: e.target.value })}
type="text"
className="form-control form-control-sm"
/>
</div>
<div className="mb-3">
<label className="form-label small">htpasswd file path</label>
<input
value={editForm.dir_auth_user_file}
onChange={(e) => setEditForm({ ...editForm, dir_auth_user_file: e.target.value })}
type="text"
className="form-control form-control-sm"
/>
</div>
<div className="form-check mb-3">
<input
type="checkbox"
id="edit_php_deny"
className="form-check-input"
checked={editForm.php_deny_execute}
onChange={(e) => setEditForm({ ...editForm, php_deny_execute: e.target.checked })}
/>
<label htmlFor="edit_php_deny" className="form-check-label small">
Deny PHP under /uploads and /storage
</label>
</div>
<div className="mb-0">
<label className="form-label">Note (optional)</label>
<input

View File

@@ -8,36 +8,10 @@ server {
error_page 404 /404.html;
error_page 502 /502.html;
# ACME HTTP-01 (Let's Encrypt). Prefix match beats regex; explicit root; no try_files so server error_page cannot mask failures.
location ^~ /.well-known/acme-challenge/ {
root {ROOT_PATH};
default_type "text/plain";
allow all;
access_log off;
}
# Force HTTPS (skipped for ACME — see if block)
{FORCE_HTTPS_BLOCK}
# Custom redirects
{REDIRECTS_BLOCK}
# Static assets
location ~ .*\.(gif|jpg|jpeg|png|bmp|swf)$ {
expires 30d;
access_log off;
}
location ~ .*\.(js|css)?$ {
expires 12h;
access_log off;
}
# PHP
location ~ \.php$ {
fastcgi_pass unix:/tmp/php-cgi-{PHP_VERSION}.sock;
fastcgi_index index.php;
include fastcgi.conf;
}
# ACME, redirects, optional auth, PHP hardening, app (PHP static or reverse proxy)
{LOCATION_BUNDLE}
access_log {LOGS_PATH}/{SITE_NAME}.log;
error_log {LOGS_PATH}/{SITE_NAME}.error.log;