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 target_id: int
schedule: str # cron, e.g. "0 2 * * *" schedule: str # cron, e.g. "0 2 * * *"
enabled: bool = True enabled: bool = True
s3_bucket: str = ""
s3_endpoint: str = ""
s3_key_prefix: str = ""
@router.get("/plans") @router.get("/plans")
@@ -45,6 +48,9 @@ async def backup_plans_list(
"target_id": r.target_id, "target_id": r.target_id,
"schedule": r.schedule, "schedule": r.schedule,
"enabled": r.enabled, "enabled": r.enabled,
"s3_bucket": getattr(r, "s3_bucket", None) or "",
"s3_endpoint": getattr(r, "s3_endpoint", None) or "",
"s3_key_prefix": getattr(r, "s3_key_prefix", None) or "",
} }
for r in rows for r in rows
] ]
@@ -79,6 +85,9 @@ async def backup_plan_create(
target_id=body.target_id, target_id=body.target_id,
schedule=body.schedule, schedule=body.schedule,
enabled=body.enabled, enabled=body.enabled,
s3_bucket=(body.s3_bucket or "")[:256],
s3_endpoint=(body.s3_endpoint or "")[:512],
s3_key_prefix=(body.s3_key_prefix or "")[:256],
) )
db.add(plan) db.add(plan)
await db.commit() await db.commit()
@@ -107,6 +116,9 @@ async def backup_plan_update(
plan.target_id = body.target_id plan.target_id = body.target_id
plan.schedule = body.schedule plan.schedule = body.schedule
plan.enabled = body.enabled plan.enabled = body.enabled
plan.s3_bucket = (body.s3_bucket or "")[:256]
plan.s3_endpoint = (body.s3_endpoint or "")[:512]
plan.s3_key_prefix = (body.s3_key_prefix or "")[:256]
await db.commit() await db.commit()
return {"status": True, "msg": "Updated"} return {"status": True, "msg": "Updated"}
@@ -143,6 +155,27 @@ def _run_site_backup(site: Site) -> tuple[bool, str, str | None]:
return False, str(e), None return False, str(e), None
def _maybe_upload_s3(local_file: str, plan: BackupPlan) -> tuple[bool, str]:
"""Copy backup file to S3-compatible bucket if plan.s3_bucket set. Uses AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY."""
bucket = (getattr(plan, "s3_bucket", None) or "").strip()
if not bucket or not os.path.isfile(local_file):
return True, ""
try:
import boto3
except ImportError:
return False, "boto3 not installed (pip install boto3)"
ep = (getattr(plan, "s3_endpoint", None) or "").strip() or None
prefix = (getattr(plan, "s3_key_prefix", None) or "").strip().strip("/")
key_base = os.path.basename(local_file)
key = f"{prefix}/{key_base}" if prefix else key_base
try:
client = boto3.client("s3", endpoint_url=ep)
client.upload_file(local_file, bucket, key)
return True, f"s3://{bucket}/{key}"
except Exception as e:
return False, str(e)
def _run_database_backup(dbo: Database) -> tuple[bool, str, str | None]: def _run_database_backup(dbo: Database) -> tuple[bool, str, str | None]:
"""Run database backup (sync). Returns (ok, msg, filename).""" """Run database backup (sync). Returns (ok, msg, filename)."""
cfg = get_runtime_config() cfg = get_runtime_config()
@@ -164,15 +197,17 @@ async def backup_run_scheduled(
"""Run all due backup plans. Call this from cron (e.g. every hour) or manually.""" """Run all due backup plans. Call this from cron (e.g. every hour) or manually."""
from datetime import datetime as dt from datetime import datetime as dt
now = dt.utcnow() now = dt.utcnow()
cfg = get_runtime_config()
result = await db.execute(select(BackupPlan).where(BackupPlan.enabled == True)) result = await db.execute(select(BackupPlan).where(BackupPlan.enabled == True))
plans = result.scalars().all() plans = result.scalars().all()
results = [] results = []
for plan in plans: for plan in plans:
ok = False
msg = ""
try: try:
prev_run = croniter(plan.schedule, now).get_prev(dt) prev_run = croniter(plan.schedule, now).get_prev(dt)
# Run if we're within 15 minutes after the scheduled time
secs_since = (now - prev_run).total_seconds() secs_since = (now - prev_run).total_seconds()
if secs_since > 900 or secs_since < 0: # Not within 15 min window if secs_since > 900 or secs_since < 0:
continue continue
except Exception: except Exception:
continue continue
@@ -183,6 +218,11 @@ async def backup_run_scheduled(
results.append({"plan": plan.name, "status": "skipped", "msg": "Site not found or path invalid"}) results.append({"plan": plan.name, "status": "skipped", "msg": "Site not found or path invalid"})
continue continue
ok, msg, filename = _run_site_backup(site) ok, msg, filename = _run_site_backup(site)
if ok and filename:
full = os.path.join(cfg["backup_path"], filename)
u_ok, u_msg = _maybe_upload_s3(full, plan)
if u_msg:
msg = f"{msg}; {u_msg}" if u_ok else f"{msg}; S3 failed: {u_msg}"
if ok: if ok:
send_email( send_email(
subject=f"YakPanel - Scheduled backup: {plan.name}", subject=f"YakPanel - Scheduled backup: {plan.name}",
@@ -195,6 +235,11 @@ async def backup_run_scheduled(
results.append({"plan": plan.name, "status": "skipped", "msg": "Database not found"}) results.append({"plan": plan.name, "status": "skipped", "msg": "Database not found"})
continue continue
ok, msg, filename = _run_database_backup(dbo) ok, msg, filename = _run_database_backup(dbo)
if ok and filename:
full = os.path.join(cfg["backup_path"], "database", filename)
u_ok, u_msg = _maybe_upload_s3(full, plan)
if u_msg:
msg = f"{msg}; {u_msg}" if u_ok else f"{msg}; S3 failed: {u_msg}"
if ok: if ok:
send_email( send_email(
subject=f"YakPanel - Scheduled backup: {plan.name}", subject=f"YakPanel - Scheduled backup: {plan.name}",

View File

@@ -1,6 +1,9 @@
"""YakPanel - Crontab API""" """YakPanel - Crontab API"""
import json
import tempfile import tempfile
import os import os
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
@@ -14,6 +17,20 @@ from app.models.crontab import Crontab
router = APIRouter(prefix="/crontab", tags=["crontab"]) router = APIRouter(prefix="/crontab", tags=["crontab"])
_CRON_TEMPLATES = Path(__file__).resolve().parent.parent / "data" / "cron_templates.json"
@router.get("/templates")
async def crontab_templates(current_user: User = Depends(get_current_user)):
"""YakPanel starter cron templates (edit before apply; no external branding)."""
if not _CRON_TEMPLATES.is_file():
return {"templates": []}
try:
data = json.loads(_CRON_TEMPLATES.read_text(encoding="utf-8"))
return {"templates": data if isinstance(data, list) else []}
except (json.JSONDecodeError, OSError):
return {"templates": []}
class CreateCrontabRequest(BaseModel): class CreateCrontabRequest(BaseModel):
name: str = "" name: str = ""

View File

@@ -22,7 +22,7 @@ def _resolve_path(path: str) -> str:
Resolve API path to an OS path. Resolve API path to an OS path.
On Linux/macOS: path is an absolute POSIX path from filesystem root (/) so admins On Linux/macOS: path is an absolute POSIX path from filesystem root (/) so admins
can browse the whole server (same expectation as BT/aaPanel-style panels). can browse the whole server (typical expectation for a full-server admin file manager).
On Windows (dev): paths stay sandboxed under www_root / setup_path. On Windows (dev): paths stay sandboxed under www_root / setup_path.
""" """

View File

@@ -1,5 +1,6 @@
"""YakPanel - FTP API""" """YakPanel - FTP API"""
from fastapi import APIRouter, Depends, HTTPException import os
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func from sqlalchemy import select, func
from pydantic import BaseModel from pydantic import BaseModel
@@ -103,6 +104,25 @@ async def ftp_delete(
return {"status": True, "msg": "FTP account deleted"} return {"status": True, "msg": "FTP account deleted"}
@router.get("/logs")
async def ftp_logs(
lines: int = Query(default=200, ge=1, le=5000),
current_user: User = Depends(get_current_user),
):
"""Tail common Pure-FTPd log paths if readable (non-destructive)."""
candidates = [
"/var/log/pure-ftpd/pure-ftpd.log",
"/var/log/pureftpd.log",
"/var/log/messages",
]
for path in candidates:
if os.path.isfile(path):
out, err = exec_shell_sync(f'tail -n {int(lines)} "{path}" 2>/dev/null', timeout=15)
text = (out or "") + (err or "")
return {"path": path, "content": text[-800000:] or "(empty)"}
return {"path": None, "content": "No known FTP log file found on this server."}
@router.get("/count") @router.get("/count")
async def ftp_count( async def ftp_count(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),

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 = "" ps: str = ""
php_version: str = "74" php_version: str = "74"
force_https: bool = False force_https: bool = False
proxy_upstream: str = ""
proxy_websocket: bool = False
dir_auth_path: str = ""
dir_auth_user_file: str = ""
php_deny_execute: bool = False
class UpdateSiteRequest(BaseModel): class UpdateSiteRequest(BaseModel):
@@ -37,6 +42,11 @@ class UpdateSiteRequest(BaseModel):
ps: str | None = None ps: str | None = None
php_version: str | None = None php_version: str | None = None
force_https: bool | None = None force_https: bool | None = None
proxy_upstream: str | None = None
proxy_websocket: bool | None = None
dir_auth_path: str | None = None
dir_auth_user_file: str | None = None
php_deny_execute: bool | None = None
@router.get("/list") @router.get("/list")
@@ -66,6 +76,11 @@ async def site_create(
ps=body.ps, ps=body.ps,
php_version=body.php_version or "74", php_version=body.php_version or "74",
force_https=1 if body.force_https else 0, force_https=1 if body.force_https else 0,
proxy_upstream=(body.proxy_upstream or "").strip(),
proxy_websocket=1 if body.proxy_websocket else 0,
dir_auth_path=(body.dir_auth_path or "").strip(),
dir_auth_user_file=(body.dir_auth_user_file or "").strip(),
php_deny_execute=1 if body.php_deny_execute else 0,
) )
if not result["status"]: if not result["status"]:
raise HTTPException(status_code=400, detail=result["msg"]) raise HTTPException(status_code=400, detail=result["msg"])
@@ -126,12 +141,22 @@ async def site_update(
): ):
"""Update site domains, path, or note""" """Update site domains, path, or note"""
result = await update_site( result = await update_site(
db, site_id, db,
site_id,
path=body.path, path=body.path,
domains=body.domains, domains=body.domains,
ps=body.ps, ps=body.ps,
php_version=body.php_version, php_version=body.php_version,
force_https=None if body.force_https is None else (1 if body.force_https else 0), force_https=None if body.force_https is None else (1 if body.force_https else 0),
proxy_upstream=body.proxy_upstream,
proxy_websocket=None
if body.proxy_websocket is None
else (1 if body.proxy_websocket else 0),
dir_auth_path=body.dir_auth_path,
dir_auth_user_file=body.dir_auth_user_file,
php_deny_execute=None
if body.php_deny_execute is None
else (1 if body.php_deny_execute else 0),
) )
if not result["status"]: if not result["status"]:
raise HTTPException(status_code=400, detail=result["msg"]) raise HTTPException(status_code=400, detail=result["msg"])

View File

@@ -5,6 +5,7 @@ import shutil
import socket import socket
import subprocess import subprocess
import sys import sys
import tempfile
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
@@ -403,9 +404,42 @@ async def ssl_diagnostics(current_user: User = Depends(get_current_user)):
"127.0.0.1:443 accepts TCP, but nginx -T from panel binaries did not show listen 443 — another process may own 443; check ss -tlnp and which nginx serves port 80." "127.0.0.1:443 accepts TCP, but nginx -T from panel binaries did not show listen 443 — another process may own 443; check ss -tlnp and which nginx serves port 80."
) )
debian_sites = os.path.isdir("/etc/nginx/sites-available")
rhel_conf = os.path.isdir("/etc/nginx/conf.d")
layout = "unknown"
if debian_sites:
layout = "debian_sites_available"
elif rhel_conf:
layout = "rhel_conf_d"
drop_deb = "/etc/nginx/sites-available/yakpanel-vhosts.conf"
drop_rhel = "/etc/nginx/conf.d/yakpanel-vhosts.conf"
nginx_wizard = {
"detected_layout": layout,
"include_snippet": include_snippet,
"dropin_file_suggested": drop_deb if debian_sites else drop_rhel,
"debian": {
"sites_available_file": drop_deb,
"sites_enabled_symlink": "/etc/nginx/sites-enabled/yakpanel-vhosts.conf",
"steps": [
f"printf '%s\\n' '{include_snippet}' | sudo tee {drop_deb}",
f"sudo ln -sf {drop_deb} /etc/nginx/sites-enabled/yakpanel-vhosts.conf",
"sudo nginx -t && sudo systemctl reload nginx",
],
},
"rhel": {
"conf_d_file": drop_rhel,
"steps": [
f"printf '%s\\n' '{include_snippet}' | sudo tee {drop_rhel}",
"sudo nginx -t && sudo systemctl reload nginx",
],
},
"note": "Run the steps for your distro as root. The include line must appear inside the main http { } context (conf.d files do automatically).",
}
return { return {
"vhost_dir": vhost_dir, "vhost_dir": vhost_dir,
"include_snippet": include_snippet, "include_snippet": include_snippet,
"nginx_wizard": nginx_wizard,
"vhosts": vhost_summaries, "vhosts": vhost_summaries,
"any_vhost_listen_ssl": any_vhost_443, "any_vhost_listen_ssl": any_vhost_443,
"nginx_effective_listen_443": effective_listen_443, "nginx_effective_listen_443": effective_listen_443,
@@ -417,6 +451,132 @@ async def ssl_diagnostics(current_user: User = Depends(get_current_user)):
} }
class DnsCertCloudflareRequest(BaseModel):
domain: str
email: str
api_token: str
class DnsManualInstructionsRequest(BaseModel):
domain: str
@router.post("/dns-request/cloudflare")
async def ssl_dns_cloudflare_cert(
body: DnsCertCloudflareRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Request Let's Encrypt certificate using DNS-01 via Cloudflare (requires certbot-dns-cloudflare)."""
dom = (body.domain or "").split(":")[0].strip()
if not dom or ".." in dom or not body.email or not body.api_token:
raise HTTPException(status_code=400, detail="domain, email, and api_token required")
result_dom = await db.execute(select(Domain).where(Domain.name == dom).limit(1))
dom_row = result_dom.scalar_one_or_none()
if dom_row:
regen_pre = await regenerate_site_vhost(db, dom_row.pid)
if not regen_pre.get("status"):
raise HTTPException(
status_code=500,
detail="Cannot refresh nginx vhost: " + str(regen_pre.get("msg", "")),
)
ok_ngx, err_ngx = _reload_panel_and_common_nginx()
if not ok_ngx:
raise HTTPException(
status_code=500,
detail="Nginx reload failed: " + err_ngx,
)
prefix = _certbot_command()
if not prefix:
raise HTTPException(status_code=500, detail=_certbot_missing_message())
hostnames = await _le_hostnames_for_domain_row(db, dom_row, dom)
if not hostnames:
hostnames = [dom]
cred_lines = f'dns_cloudflare_api_token = {body.api_token.strip()}\n'
fd, cred_path = tempfile.mkstemp(suffix=".ini", prefix="yakpanel_cf_")
try:
os.write(fd, cred_lines.encode())
os.close(fd)
os.chmod(cred_path, 0o600)
except OSError as e:
try:
os.close(fd)
except OSError:
pass
raise HTTPException(status_code=500, detail=f"Cannot write credentials temp file: {e}") from e
base_flags = [
"--non-interactive",
"--agree-tos",
"--email",
body.email.strip(),
"--no-eff-email",
"--dns-cloudflare",
"--dns-cloudflare-credentials",
cred_path,
]
cmd = prefix + ["certonly"] + base_flags
for h in hostnames:
cmd.extend(["-d", h])
env = environment_with_system_path()
try:
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=600, env=env)
except (FileNotFoundError, subprocess.TimeoutExpired) as e:
try:
os.unlink(cred_path)
except OSError:
pass
raise HTTPException(status_code=500, detail=str(e)) from e
finally:
try:
os.unlink(cred_path)
except OSError:
pass
if proc.returncode != 0:
err = (proc.stderr or proc.stdout or "").strip() or f"exit {proc.returncode}"
raise HTTPException(
status_code=500,
detail="certbot DNS failed. Install certbot-dns-cloudflare (pip or OS package) if missing. " + err[:6000],
)
if dom_row:
regen = await regenerate_site_vhost(db, dom_row.pid)
if not regen.get("status"):
return {
"status": True,
"msg": "Certificate issued but vhost regen failed: " + str(regen.get("msg", "")),
"output": (proc.stdout or "")[-2000:],
}
return {
"status": True,
"msg": "Certificate issued via Cloudflare DNS-01",
"output": (proc.stdout or "")[-2000:],
}
@router.post("/dns-request/manual-instructions")
async def ssl_dns_manual_instructions(
body: DnsManualInstructionsRequest,
current_user: User = Depends(get_current_user),
):
"""Return TXT record host for ACME DNS-01 (user creates record then runs certbot --manual)."""
d = (body.domain or "").split(":")[0].strip()
if not d or ".." in d:
raise HTTPException(status_code=400, detail="Invalid domain")
return {
"txt_record_name": f"_acme-challenge.{d}",
"certbot_example": (
f"sudo certbot certonly --manual --preferred-challenges dns --email you@example.com "
f"--agree-tos -d {d}"
),
"note": "Certbot will display the exact TXT value to create. After DNS propagates, continue in the terminal.",
}
@router.get("/certificates") @router.get("/certificates")
async def ssl_list_certificates(current_user: User = Depends(get_current_user)): async def ssl_list_certificates(current_user: User = Depends(get_current_user)):
"""List existing Let's Encrypt certificates""" """List existing Let's Encrypt certificates"""

View File

@@ -107,6 +107,16 @@ def _run_migrations(conn):
"ALTER TABLE sites ADD COLUMN force_https INTEGER DEFAULT 0" "ALTER TABLE sites ADD COLUMN force_https INTEGER DEFAULT 0"
) )
) )
if "proxy_upstream" not in cols:
conn.execute(sqlalchemy.text("ALTER TABLE sites ADD COLUMN proxy_upstream VARCHAR(512) DEFAULT ''"))
if "proxy_websocket" not in cols:
conn.execute(sqlalchemy.text("ALTER TABLE sites ADD COLUMN proxy_websocket INTEGER DEFAULT 0"))
if "dir_auth_path" not in cols:
conn.execute(sqlalchemy.text("ALTER TABLE sites ADD COLUMN dir_auth_path VARCHAR(256) DEFAULT ''"))
if "dir_auth_user_file" not in cols:
conn.execute(sqlalchemy.text("ALTER TABLE sites ADD COLUMN dir_auth_user_file VARCHAR(512) DEFAULT ''"))
if "php_deny_execute" not in cols:
conn.execute(sqlalchemy.text("ALTER TABLE sites ADD COLUMN php_deny_execute INTEGER DEFAULT 0"))
except Exception: except Exception:
pass pass
# Create backup_plans if not exists (create_all handles new installs) # Create backup_plans if not exists (create_all handles new installs)
@@ -123,6 +133,18 @@ def _run_migrations(conn):
""")) """))
except Exception: except Exception:
pass pass
try:
r = conn.execute(sqlalchemy.text("PRAGMA table_info(backup_plans)"))
bcols = [row[1] for row in r.fetchall()]
if bcols:
if "s3_bucket" not in bcols:
conn.execute(sqlalchemy.text("ALTER TABLE backup_plans ADD COLUMN s3_bucket VARCHAR(256) DEFAULT ''"))
if "s3_endpoint" not in bcols:
conn.execute(sqlalchemy.text("ALTER TABLE backup_plans ADD COLUMN s3_endpoint VARCHAR(512) DEFAULT ''"))
if "s3_key_prefix" not in bcols:
conn.execute(sqlalchemy.text("ALTER TABLE backup_plans ADD COLUMN s3_key_prefix VARCHAR(256) DEFAULT ''"))
except Exception:
pass
# Create custom_plugins if not exists # Create custom_plugins if not exists
try: try:
conn.execute(sqlalchemy.text(""" conn.execute(sqlalchemy.text("""

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, node,
service, service,
public_installer, public_installer,
security,
) )
@@ -87,6 +88,7 @@ app.include_router(config.router, prefix="/api/v1")
app.include_router(user.router, prefix="/api/v1") app.include_router(user.router, prefix="/api/v1")
app.include_router(logs.router, prefix="/api/v1") app.include_router(logs.router, prefix="/api/v1")
app.include_router(public_installer.router, prefix="/api/v1") app.include_router(public_installer.router, prefix="/api/v1")
app.include_router(security.router, prefix="/api/v1")
@app.get("/") @app.get("/")

View File

@@ -13,3 +13,7 @@ class BackupPlan(Base):
target_id: Mapped[int] = mapped_column(Integer, nullable=False) # site_id or database_id target_id: Mapped[int] = mapped_column(Integer, nullable=False) # site_id or database_id
schedule: Mapped[str] = mapped_column(String(64), nullable=False) # cron expression, e.g. "0 2 * * *" = daily 2am schedule: Mapped[str] = mapped_column(String(64), nullable=False) # cron expression, e.g. "0 2 * * *" = daily 2am
enabled: Mapped[bool] = mapped_column(Boolean, default=True) enabled: Mapped[bool] = mapped_column(Boolean, default=True)
# Optional S3-compatible copy after local backup (uses AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY env).
s3_bucket: Mapped[str] = mapped_column(String(256), default="")
s3_endpoint: Mapped[str] = mapped_column(String(512), default="")
s3_key_prefix: Mapped[str] = mapped_column(String(256), default="")

View File

@@ -16,6 +16,14 @@ class Site(Base):
project_type: Mapped[str] = mapped_column(String(32), default="PHP") project_type: Mapped[str] = mapped_column(String(32), default="PHP")
php_version: Mapped[str] = mapped_column(String(16), default="74") # 74, 80, 81, 82 php_version: Mapped[str] = mapped_column(String(16), default="74") # 74, 80, 81, 82
force_https: Mapped[int] = mapped_column(Integer, default=0) # 0=off, 1=redirect HTTP to HTTPS force_https: Mapped[int] = mapped_column(Integer, default=0) # 0=off, 1=redirect HTTP to HTTPS
# Reverse proxy: when proxy_upstream is non-empty, vhost uses proxy_pass instead of PHP root.
proxy_upstream: Mapped[str] = mapped_column(String(512), default="") # e.g. http://127.0.0.1:3000
proxy_websocket: Mapped[int] = mapped_column(Integer, default=0) # 1 = Upgrade headers for WS
# HTTP basic auth for a path prefix (nginx auth_basic). user_file = htpasswd path on server.
dir_auth_path: Mapped[str] = mapped_column(String(256), default="")
dir_auth_user_file: Mapped[str] = mapped_column(String(512), default="")
# Block execution of PHP under common upload paths (nginx deny).
php_deny_execute: Mapped[int] = mapped_column(Integer, default=0)
addtime: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) addtime: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)

View File

@@ -204,42 +204,42 @@ def _letsencrypt_paths_any(hostnames: list[str]) -> tuple[str, str] | None:
return None return None
def _build_ssl_server_block( def _build_php_deny_execute_block(enabled: int) -> str:
server_names: str, if not enabled:
root_path: str, return ""
logs_path: str, return (
site_name: str, r" location ~* ^/uploads/.*\.(php|phar|phtml|php5)$ {" + "\n"
php_version: str, r" deny all;" + "\n"
fullchain: str, r" }" + "\n"
privkey: str, r" location ~* ^/storage/.*\.(php|phar|phtml|php5)$ {" + "\n"
redirects: list[tuple[str, str, int]] | None, r" deny all;" + "\n"
) -> str: r" }" + "\n"
"""Second server {} for HTTPS when LE certs exist.""" )
pv = php_version or "74"
redirect_lines: list[str] = []
for src, tgt, code in (redirects or []): def _build_main_app_block(proxy_upstream: str, proxy_websocket: int, php_version: str) -> str:
if src and tgt: pu = (proxy_upstream or "").strip()
redirect_lines.append(f" location = {src} {{ return {code} {tgt}; }}") pv = php_version or "74"
redirect_block = ("\n" + "\n".join(redirect_lines)) if redirect_lines else "" if pu:
q_fc = fullchain.replace("\\", "\\\\").replace('"', '\\"') ws_lines = ""
q_pk = privkey.replace("\\", "\\\\").replace('"', '\\"') if proxy_websocket:
ws_lines = (
" proxy_set_header Upgrade $http_upgrade;\n"
' proxy_set_header Connection "upgrade";\n'
)
return (
f" location / {{\n"
f" proxy_pass {pu};\n"
f" proxy_http_version 1.1;\n"
f" proxy_set_header Host $host;\n"
f" proxy_set_header X-Real-IP $remote_addr;\n"
f" proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n"
f" proxy_set_header X-Forwarded-Proto $scheme;\n"
f"{ws_lines}"
f" proxy_read_timeout 3600s;\n"
f" }}\n"
)
return ( return (
f"server {{\n"
f" listen 443 ssl;\n"
f" server_name {server_names};\n"
f' ssl_certificate "{q_fc}";\n'
f' ssl_certificate_key "{q_pk}";\n'
f" index index.php index.html index.htm default.php default.htm default.html;\n"
f" root {root_path};\n"
f" error_page 404 /404.html;\n"
f" error_page 502 /502.html;\n"
f" location ^~ /.well-known/acme-challenge/ {{\n"
f" root {root_path};\n"
f' default_type "text/plain";\n'
f" allow all;\n"
f" access_log off;\n"
f" }}\n"
f"{redirect_block}\n"
r" location ~ .*\.(gif|jpg|jpeg|png|bmp|swf)$ {" + "\n" r" location ~ .*\.(gif|jpg|jpeg|png|bmp|swf)$ {" + "\n"
f" expires 30d;\n" f" expires 30d;\n"
f" access_log off;\n" f" access_log off;\n"
@@ -253,6 +253,116 @@ def _build_ssl_server_block(
f" fastcgi_index index.php;\n" f" fastcgi_index index.php;\n"
f" include fastcgi.conf;\n" f" include fastcgi.conf;\n"
f" }}\n" f" }}\n"
)
def _build_dir_auth_block(
dir_path: str,
user_file: str,
proxy_upstream: str,
root_path: str,
) -> str:
dp = (dir_path or "").strip()
uf = (user_file or "").strip()
if not dp or not uf or ".." in dp or ".." in uf:
return ""
if not dp.startswith("/"):
dp = "/" + dp
qf = uf.replace("\\", "\\\\").replace('"', '\\"')
qr = root_path.replace("\\", "\\\\")
pu = (proxy_upstream or "").strip()
if pu:
puc = pu.rstrip("/")
return (
f" location ^~ {dp} {{\n"
f' auth_basic "YakPanel";\n'
f' auth_basic_user_file "{qf}";\n'
f" proxy_pass {puc};\n"
f" proxy_http_version 1.1;\n"
f" proxy_set_header Host $host;\n"
f" proxy_set_header X-Real-IP $remote_addr;\n"
f" proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n"
f" proxy_set_header X-Forwarded-Proto $scheme;\n"
f" }}\n"
)
return (
f" location ^~ {dp} {{\n"
f' auth_basic "YakPanel";\n'
f' auth_basic_user_file "{qf}";\n'
f" root {qr};\n"
f" try_files $uri $uri/ =404;\n"
f" }}\n"
)
def _build_location_bundle(
root_path: str,
redirects: list[tuple[str, str, int]] | None,
proxy_upstream: str,
proxy_websocket: int,
dir_auth_path: str,
dir_auth_user_file: str,
php_deny_execute: int,
php_version: str,
) -> str:
acme = (
f" location ^~ /.well-known/acme-challenge/ {{\n"
f" root {root_path};\n"
f' default_type "text/plain";\n'
f" allow all;\n"
f" access_log off;\n"
f" }}\n"
)
redirect_lines = []
for src, tgt, code in redirects or []:
if src and tgt:
redirect_lines.append(f" location = {src} {{ return {code} {tgt}; }}")
redirect_block = ("\n" + "\n".join(redirect_lines)) if redirect_lines else ""
dir_auth = _build_dir_auth_block(dir_auth_path, dir_auth_user_file, proxy_upstream, root_path)
php_deny = _build_php_deny_execute_block(php_deny_execute)
main = _build_main_app_block(proxy_upstream, proxy_websocket, php_version)
return acme + redirect_block + "\n" + dir_auth + php_deny + main
def _build_ssl_server_block(
server_names: str,
root_path: str,
logs_path: str,
site_name: str,
php_version: str,
fullchain: str,
privkey: str,
redirects: list[tuple[str, str, int]] | None,
proxy_upstream: str = "",
proxy_websocket: int = 0,
dir_auth_path: str = "",
dir_auth_user_file: str = "",
php_deny_execute: int = 0,
) -> str:
"""Second server {} for HTTPS when LE certs exist."""
q_fc = fullchain.replace("\\", "\\\\").replace('"', '\\"')
q_pk = privkey.replace("\\", "\\\\").replace('"', '\\"')
bundle = _build_location_bundle(
root_path,
redirects,
proxy_upstream,
proxy_websocket,
dir_auth_path,
dir_auth_user_file,
php_deny_execute,
php_version,
)
return (
f"server {{\n"
f" listen 443 ssl;\n"
f" server_name {server_names};\n"
f' ssl_certificate "{q_fc}";\n'
f' ssl_certificate_key "{q_pk}";\n'
f" index index.php index.html index.htm default.php default.htm default.html;\n"
f" root {root_path};\n"
f" error_page 404 /404.html;\n"
f" error_page 502 /502.html;\n"
f"{bundle}"
f" access_log {logs_path}/{site_name}.log;\n" f" access_log {logs_path}/{site_name}.log;\n"
f" error_log {logs_path}/{site_name}.error.log;\n" f" error_log {logs_path}/{site_name}.error.log;\n"
f"}}\n" f"}}\n"
@@ -269,6 +379,11 @@ def _render_vhost(
force_https: int, force_https: int,
redirects: list[tuple[str, str, int]] | None = None, redirects: list[tuple[str, str, int]] | None = None,
le_hostnames: list[str] | None = None, le_hostnames: list[str] | None = None,
proxy_upstream: str = "",
proxy_websocket: int = 0,
dir_auth_path: str = "",
dir_auth_user_file: str = "",
php_deny_execute: int = 0,
) -> str: ) -> str:
"""Render nginx vhost template. redirects: [(source, target, code), ...]""" """Render nginx vhost template. redirects: [(source, target, code), ...]"""
if force_https: if force_https:
@@ -279,28 +394,43 @@ def _render_vhost(
) )
else: else:
force_block = "" force_block = ""
redirect_lines = []
for src, tgt, code in (redirects or []):
if src and tgt:
redirect_lines.append(f" location = {src} {{ return {code} {tgt}; }}")
redirect_block = "\n".join(redirect_lines) if redirect_lines else ""
hosts = le_hostnames if le_hostnames is not None else [p for p in server_names.split() if p] hosts = le_hostnames if le_hostnames is not None else [p for p in server_names.split() if p]
ssl_block = "" ssl_block = ""
for h in hosts: le = _letsencrypt_paths_any(hosts)
le = _letsencrypt_paths(h) if le:
if le: fc, pk = le
fc, pk = le ssl_block = _build_ssl_server_block(
ssl_block = _build_ssl_server_block( server_names,
server_names, root_path, logs_path, site_name, php_version, fc, pk, redirects root_path,
) logs_path,
break site_name,
php_version,
fc,
pk,
redirects,
proxy_upstream,
proxy_websocket,
dir_auth_path,
dir_auth_user_file,
php_deny_execute,
)
bundle = _build_location_bundle(
root_path,
redirects,
proxy_upstream,
proxy_websocket,
dir_auth_path,
dir_auth_user_file,
php_deny_execute,
php_version,
)
content = template.replace("{SERVER_NAMES}", server_names) content = template.replace("{SERVER_NAMES}", server_names)
content = content.replace("{ROOT_PATH}", root_path) content = content.replace("{ROOT_PATH}", root_path)
content = content.replace("{LOGS_PATH}", logs_path) content = content.replace("{LOGS_PATH}", logs_path)
content = content.replace("{SITE_NAME}", site_name) content = content.replace("{SITE_NAME}", site_name)
content = content.replace("{PHP_VERSION}", php_version or "74") content = content.replace("{PHP_VERSION}", php_version or "74")
content = content.replace("{FORCE_HTTPS_BLOCK}", force_block) content = content.replace("{FORCE_HTTPS_BLOCK}", force_block)
content = content.replace("{REDIRECTS_BLOCK}", redirect_block) content = content.replace("{LOCATION_BUNDLE}", bundle)
content = content.replace("{SSL_SERVER_BLOCK}", ssl_block) content = content.replace("{SSL_SERVER_BLOCK}", ssl_block)
return content return content
@@ -327,6 +457,16 @@ async def domain_exists(db: AsyncSession, domains: list[str], exclude_site_id: i
return None return None
def _vhost_kwargs_from_site(site: Site) -> dict:
return {
"proxy_upstream": getattr(site, "proxy_upstream", None) or "",
"proxy_websocket": int(getattr(site, "proxy_websocket", 0) or 0),
"dir_auth_path": getattr(site, "dir_auth_path", None) or "",
"dir_auth_user_file": getattr(site, "dir_auth_user_file", None) or "",
"php_deny_execute": int(getattr(site, "php_deny_execute", 0) or 0),
}
async def create_site( async def create_site(
db: AsyncSession, db: AsyncSession,
name: str, name: str,
@@ -336,6 +476,11 @@ async def create_site(
ps: str = "", ps: str = "",
php_version: str = "74", php_version: str = "74",
force_https: int = 0, force_https: int = 0,
proxy_upstream: str = "",
proxy_websocket: int = 0,
dir_auth_path: str = "",
dir_auth_user_file: str = "",
php_deny_execute: int = 0,
) -> dict: ) -> dict:
"""Create a new site with vhost config.""" """Create a new site with vhost config."""
if not path_safe_check(name) or not path_safe_check(path): if not path_safe_check(name) or not path_safe_check(path):
@@ -359,7 +504,19 @@ async def create_site(
if not os.path.exists(site_path): if not os.path.exists(site_path):
os.makedirs(site_path, 0o755) os.makedirs(site_path, 0o755)
site = Site(name=name, path=site_path, ps=ps, project_type=project_type, php_version=php_version or "74", force_https=force_https or 0) site = Site(
name=name,
path=site_path,
ps=ps,
project_type=project_type,
php_version=php_version or "74",
force_https=force_https or 0,
proxy_upstream=(proxy_upstream or "")[:512],
proxy_websocket=1 if proxy_websocket else 0,
dir_auth_path=(dir_auth_path or "")[:256],
dir_auth_user_file=(dir_auth_user_file or "")[:512],
php_deny_execute=1 if php_deny_execute else 0,
)
db.add(site) db.add(site)
await db.flush() await db.flush()
@@ -379,8 +536,18 @@ async def create_site(
template = read_file(template_path) or "" template = read_file(template_path) or ""
server_names = " ".join(d.split(":")[0] for d in domains) server_names = " ".join(d.split(":")[0] for d in domains)
le_hosts = [d.split(":")[0] for d in domains] le_hosts = [d.split(":")[0] for d in domains]
vk = _vhost_kwargs_from_site(site)
content = _render_vhost( content = _render_vhost(
template, server_names, site_path, www_logs, name, php_version or "74", force_https or 0, [], le_hosts template,
server_names,
site_path,
www_logs,
name,
php_version or "74",
force_https or 0,
[],
le_hosts,
**vk,
) )
write_file(conf_path, content) write_file(conf_path, content)
@@ -477,6 +644,11 @@ async def get_site_with_domains(db: AsyncSession, site_id: int) -> dict | None:
"project_type": site.project_type, "project_type": site.project_type,
"php_version": getattr(site, "php_version", None) or "74", "php_version": getattr(site, "php_version", None) or "74",
"force_https": getattr(site, "force_https", 0) or 0, "force_https": getattr(site, "force_https", 0) or 0,
"proxy_upstream": getattr(site, "proxy_upstream", None) or "",
"proxy_websocket": int(getattr(site, "proxy_websocket", 0) or 0),
"dir_auth_path": getattr(site, "dir_auth_path", None) or "",
"dir_auth_user_file": getattr(site, "dir_auth_user_file", None) or "",
"php_deny_execute": int(getattr(site, "php_deny_execute", 0) or 0),
"domains": domain_list, "domains": domain_list,
} }
@@ -489,6 +661,11 @@ async def update_site(
ps: str | None = None, ps: str | None = None,
php_version: str | None = None, php_version: str | None = None,
force_https: int | None = None, force_https: int | None = None,
proxy_upstream: str | None = None,
proxy_websocket: int | None = None,
dir_auth_path: str | None = None,
dir_auth_user_file: str | None = None,
php_deny_execute: int | None = None,
) -> dict: ) -> dict:
"""Update site domains, path, or note.""" """Update site domains, path, or note."""
result = await db.execute(select(Site).where(Site.id == site_id)) result = await db.execute(select(Site).where(Site.id == site_id))
@@ -518,11 +695,30 @@ async def update_site(
site.php_version = php_version or "74" site.php_version = php_version or "74"
if force_https is not None: if force_https is not None:
site.force_https = 1 if force_https else 0 site.force_https = 1 if force_https else 0
if proxy_upstream is not None:
site.proxy_upstream = (proxy_upstream or "")[:512]
if proxy_websocket is not None:
site.proxy_websocket = 1 if proxy_websocket else 0
if dir_auth_path is not None:
site.dir_auth_path = (dir_auth_path or "")[:256]
if dir_auth_user_file is not None:
site.dir_auth_user_file = (dir_auth_user_file or "")[:512]
if php_deny_execute is not None:
site.php_deny_execute = 1 if php_deny_execute else 0
await db.flush() await db.flush()
# Regenerate Nginx vhost if domains, php_version, or force_https changed regen = (
if domains is not None or php_version is not None or force_https is not None: domains is not None
or php_version is not None
or force_https is not None
or proxy_upstream is not None
or proxy_websocket is not None
or dir_auth_path is not None
or dir_auth_user_file is not None
or php_deny_execute is not None
)
if regen:
cfg = get_runtime_config() cfg = get_runtime_config()
vhost_path = os.path.join(cfg["setup_path"], "panel", "vhost", "nginx") vhost_path = os.path.join(cfg["setup_path"], "panel", "vhost", "nginx")
conf_path = os.path.join(vhost_path, f"{site.name}.conf") conf_path = os.path.join(vhost_path, f"{site.name}.conf")
@@ -538,8 +734,18 @@ async def update_site(
redir_result = await db.execute(select(SiteRedirect).where(SiteRedirect.site_id == site.id)) redir_result = await db.execute(select(SiteRedirect).where(SiteRedirect.site_id == site.id))
redirects = [(r.source, r.target, r.code or 301) for r in redir_result.scalars().all()] redirects = [(r.source, r.target, r.code or 301) for r in redir_result.scalars().all()]
le_hosts = [d.name for d in domain_rows] le_hosts = [d.name for d in domain_rows]
vk = _vhost_kwargs_from_site(site)
content = _render_vhost( content = _render_vhost(
template, server_names, site.path, cfg["www_logs"], site.name, php_ver, fhttps, redirects, le_hosts template,
server_names,
site.path,
cfg["www_logs"],
site.name,
php_ver,
fhttps,
redirects,
le_hosts,
**vk,
) )
write_file(conf_path, content) write_file(conf_path, content)
reload_ok, reload_err = nginx_reload_all_known() reload_ok, reload_err = nginx_reload_all_known()
@@ -623,8 +829,18 @@ async def regenerate_site_vhost(db: AsyncSession, site_id: int) -> dict:
redir_result = await db.execute(select(SiteRedirect).where(SiteRedirect.site_id == site.id)) redir_result = await db.execute(select(SiteRedirect).where(SiteRedirect.site_id == site.id))
redirects = [(r.source, r.target, r.code or 301) for r in redir_result.scalars().all()] redirects = [(r.source, r.target, r.code or 301) for r in redir_result.scalars().all()]
le_hosts = [d.name for d in domain_rows] le_hosts = [d.name for d in domain_rows]
vk = _vhost_kwargs_from_site(site)
content = _render_vhost( content = _render_vhost(
template, server_names, site.path, cfg["www_logs"], site.name, php_ver, fhttps, redirects, le_hosts template,
server_names,
site.path,
cfg["www_logs"],
site.name,
php_ver,
fhttps,
redirects,
le_hosts,
**vk,
) )
write_file(write_path, content) write_file(write_path, content)
reload_ok, reload_err = nginx_reload_all_known() reload_ok, reload_err = nginx_reload_all_known()

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) # Let's Encrypt (optional if system certbot/snap not used; enables python -m certbot from panel venv)
certbot>=3.0.0 certbot>=3.0.0
certbot-nginx>=3.0.0 certbot-nginx>=3.0.0
certbot-dns-cloudflare>=3.0.0
boto3>=1.34.0
# Utils # Utils
psutil>=5.9.0 psutil>=5.9.0

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

View File

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

View File

@@ -15,17 +15,24 @@ export const menuItems: MenuItem[] = [
{ title: 'Docker', href: '/docker', id: 'menuDocker', sort: 5, iconClass: 'ti ti-brand-docker' }, { title: 'Docker', href: '/docker', id: 'menuDocker', sort: 5, iconClass: 'ti ti-brand-docker' },
{ title: 'Monitor', href: '/control', id: 'menuControl', sort: 6, iconClass: 'ti ti-heart-rate-monitor' }, { title: 'Monitor', href: '/control', id: 'menuControl', sort: 6, iconClass: 'ti ti-heart-rate-monitor' },
{ title: 'Security', href: '/firewall', id: 'menuFirewall', sort: 7, iconClass: 'ti ti-shield-lock' }, { title: 'Security', href: '/firewall', id: 'menuFirewall', sort: 7, iconClass: 'ti ti-shield-lock' },
{ title: 'Files', href: '/files', id: 'menuFiles', sort: 8, iconClass: 'ti ti-folders' }, {
{ title: 'Node', href: '/node', id: 'menuNode', sort: 9, iconClass: 'ti ti-brand-nodejs' }, title: 'Security checklist',
{ title: 'Logs', href: '/logs', id: 'menuLogs', sort: 10, iconClass: 'ti ti-file-text' }, href: '/security-checklist',
{ title: 'Domains', href: '/ssl_domain', id: 'menuDomains', sort: 11, iconClass: 'ti ti-world-www' }, id: 'menuSecurityChecklist',
{ title: 'Terminal', href: '/xterm', id: 'menuXterm', sort: 12, iconClass: 'ti ti-terminal-2' }, sort: 8,
{ title: 'Cron', href: '/crontab', id: 'menuCrontab', sort: 13, iconClass: 'ti ti-clock' }, iconClass: 'ti ti-checklist',
{ title: 'App Store', href: '/soft', id: 'menuSoft', sort: 14, iconClass: 'ti ti-package' }, },
{ title: 'Services', href: '/services', id: 'menuServices', sort: 15, iconClass: 'ti ti-server' }, { title: 'Files', href: '/files', id: 'menuFiles', sort: 9, iconClass: 'ti ti-folders' },
{ title: 'Plugins', href: '/plugins', id: 'menuPlugins', sort: 16, iconClass: 'ti ti-puzzle' }, { title: 'Node', href: '/node', id: 'menuNode', sort: 10, iconClass: 'ti ti-brand-nodejs' },
{ title: 'Backup Plans', href: '/backup-plans', id: 'menuBackupPlans', sort: 17, iconClass: 'ti ti-archive' }, { title: 'Logs', href: '/logs', id: 'menuLogs', sort: 11, iconClass: 'ti ti-file-text' },
{ title: 'Users', href: '/users', id: 'menuUsers', sort: 18, iconClass: 'ti ti-users' }, { title: 'Domains', href: '/ssl_domain', id: 'menuDomains', sort: 12, iconClass: 'ti ti-world-www' },
{ title: 'Settings', href: '/config', id: 'menuConfig', sort: 19, iconClass: 'ti ti-settings' }, { title: 'Terminal', href: '/xterm', id: 'menuXterm', sort: 13, iconClass: 'ti ti-terminal-2' },
{ title: 'Log out', href: '/logout', id: 'menuLogout', sort: 20, iconClass: 'ti ti-logout' }, { title: 'Cron', href: '/crontab', id: 'menuCrontab', sort: 14, iconClass: 'ti ti-clock' },
{ title: 'App Store', href: '/soft', id: 'menuSoft', sort: 15, iconClass: 'ti ti-package' },
{ title: 'Services', href: '/services', id: 'menuServices', sort: 16, iconClass: 'ti ti-server' },
{ title: 'Plugins', href: '/plugins', id: 'menuPlugins', sort: 17, iconClass: 'ti ti-puzzle' },
{ title: 'Backup Plans', href: '/backup-plans', id: 'menuBackupPlans', sort: 18, iconClass: 'ti ti-archive' },
{ title: 'Users', href: '/users', id: 'menuUsers', sort: 19, iconClass: 'ti ti-users' },
{ title: 'Settings', href: '/config', id: 'menuConfig', sort: 20, iconClass: 'ti ti-settings' },
{ title: 'Log out', href: '/logout', id: 'menuLogout', sort: 21, iconClass: 'ti ti-logout' },
] ]

View File

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

View File

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

View File

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

View File

@@ -17,9 +17,19 @@ interface Certificate {
path: string path: string
} }
interface NginxWizard {
detected_layout: string
include_snippet: string
dropin_file_suggested: string
debian: { sites_available_file: string; sites_enabled_symlink: string; steps: string[] }
rhel: { conf_d_file: string; steps: string[] }
note: string
}
interface SslDiagnostics { interface SslDiagnostics {
vhost_dir: string vhost_dir: string
include_snippet: string include_snippet: string
nginx_wizard?: NginxWizard
vhosts: { file: string; has_listen_80: boolean; has_listen_443: boolean; has_ssl_directives: boolean }[] vhosts: { file: string; has_listen_80: boolean; has_listen_443: boolean; has_ssl_directives: boolean }[]
any_vhost_listen_ssl: boolean any_vhost_listen_ssl: boolean
nginx_effective_listen_443: boolean nginx_effective_listen_443: boolean
@@ -41,6 +51,13 @@ export function DomainsPage() {
const [requesting, setRequesting] = useState<string | null>(null) const [requesting, setRequesting] = useState<string | null>(null)
const [requestDomain, setRequestDomain] = useState<Domain | null>(null) const [requestDomain, setRequestDomain] = useState<Domain | null>(null)
const [requestEmail, setRequestEmail] = useState('') const [requestEmail, setRequestEmail] = useState('')
const [showDnsCf, setShowDnsCf] = useState(false)
const [cfDomain, setCfDomain] = useState('')
const [cfEmail, setCfEmail] = useState('')
const [cfToken, setCfToken] = useState('')
const [cfBusy, setCfBusy] = useState(false)
const [manualDom, setManualDom] = useState('')
const [manualOut, setManualOut] = useState<{ txt_record_name?: string; certbot_example?: string; note?: string } | null>(null)
const load = () => { const load = () => {
setLoading(true) setLoading(true)
@@ -155,9 +172,107 @@ export function DomainsPage() {
nginx -T probe: {diag.nginx_t_probe_errors.join(' | ')} nginx -T probe: {diag.nginx_t_probe_errors.join(' | ')}
</div> </div>
) : null} ) : null}
{diag.nginx_wizard ? (
<div className="mt-4 border-top pt-3">
<div className="fw-semibold mb-2">Nginx include wizard</div>
<p className="small text-secondary mb-2">{diag.nginx_wizard.note}</p>
<p className="small mb-1">
Detected layout: <code>{diag.nginx_wizard.detected_layout}</code> suggested file:{' '}
<code className="user-select-all">{diag.nginx_wizard.dropin_file_suggested}</code>
</p>
<div className="row g-2 small">
<div className="col-md-6">
<strong className="d-block mb-1">Debian / Ubuntu</strong>
<ol className="ps-3 mb-0">
{diag.nginx_wizard.debian.steps.map((s, i) => (
<li key={i} className="mb-1">
<code className="text-break user-select-all small d-block bg-body-secondary p-1 rounded">{s}</code>
</li>
))}
</ol>
</div>
<div className="col-md-6">
<strong className="d-block mb-1">RHEL / Rocky / Alma</strong>
<ol className="ps-3 mb-0">
{diag.nginx_wizard.rhel.steps.map((s, i) => (
<li key={i} className="mb-1">
<code className="text-break user-select-all small d-block bg-body-secondary p-1 rounded">{s}</code>
</li>
))}
</ol>
</div>
</div>
</div>
) : null}
</div> </div>
) : null} ) : null}
<div className="card mb-4">
<div className="card-header">DNS-01 Let&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="row g-4">
<div className="col-lg-6"> <div className="col-lg-6">
<div className="card h-100"> <div className="card h-100">
@@ -219,7 +334,7 @@ export function DomainsPage() {
<i className="ti ti-shield-check text-success flex-shrink-0" aria-hidden /> <i className="ti ti-shield-check text-success flex-shrink-0" aria-hidden />
<span className="font-monospace small text-break">{c.name}</span> <span className="font-monospace small text-break">{c.name}</span>
</div> </div>
)) ))
)} )}
</div> </div>
</div> </div>
@@ -269,6 +384,51 @@ export function DomainsPage() {
</form> </form>
) : null} ) : null}
</Modal> </Modal>
<Modal show={showDnsCf} onHide={() => setShowDnsCf(false)} centered>
<Modal.Header closeButton>
<Modal.Title>Let&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() { export function FtpPage() {
const [accounts, setAccounts] = useState<FtpAccount[]>([]) const [accounts, setAccounts] = useState<FtpAccount[]>([])
const [logPath, setLogPath] = useState<string | null>(null)
const [logContent, setLogContent] = useState('')
const [logLoading, setLogLoading] = useState(false)
const [logError, setLogError] = useState('')
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState('') const [error, setError] = useState('')
const [showCreate, setShowCreate] = useState(false) const [showCreate, setShowCreate] = useState(false)
@@ -32,6 +36,18 @@ export function FtpPage() {
loadAccounts() loadAccounts()
}, []) }, [])
const loadFtpLogs = () => {
setLogLoading(true)
setLogError('')
apiRequest<{ path: string | null; content: string }>('/ftp/logs?lines=400')
.then((r) => {
setLogPath(r.path)
setLogContent(r.content || '')
})
.catch((err) => setLogError(err.message))
.finally(() => setLogLoading(false))
}
const handleCreate = (e: React.FormEvent<HTMLFormElement>) => { const handleCreate = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault() e.preventDefault()
const form = e.currentTarget const form = e.currentTarget
@@ -119,6 +135,33 @@ export function FtpPage() {
<code>apt install pure-ftpd pure-ftpd-common</code> <code>apt install pure-ftpd pure-ftpd-common</code>
</div> </div>
<div className="card shadow-sm border-0 mb-4">
<div className="card-header d-flex flex-wrap align-items-center justify-content-between gap-2">
<span>FTP log (tail)</span>
<button type="button" className="btn btn-sm btn-outline-primary" disabled={logLoading} onClick={loadFtpLogs}>
{logLoading ? 'Loading…' : logContent ? 'Refresh' : 'Load log'}
</button>
</div>
<div className="card-body">
{logError ? <AdminAlert variant="danger">{logError}</AdminAlert> : null}
{logPath ? (
<p className="small text-muted mb-2">
Source: <code className="user-select-all">{logPath}</code>
</p>
) : null}
{logContent ? (
<pre
className="small bg-body-secondary border rounded p-3 mb-0 text-body"
style={{ maxHeight: '22rem', overflow: 'auto', whiteSpace: 'pre-wrap' }}
>
{logContent}
</pre>
) : !logLoading && !logError ? (
<p className="text-muted small mb-0">Click &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 show={showCreate} onHide={() => setShowCreate(false)} centered>
<Modal.Header closeButton> <Modal.Header closeButton>
<Modal.Title>Create FTP Account</Modal.Title> <Modal.Title>Create FTP Account</Modal.Title>

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

View File

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