From 6dea3b43072bd0074e91d30a1321a1a5377affec Mon Sep 17 00:00:00 2001 From: Niranjan Date: Tue, 7 Apr 2026 13:23:35 +0530 Subject: [PATCH] new changes --- .../app/__pycache__/main.cpython-314.pyc | Bin 5399 -> 5435 bytes .../api/__pycache__/backup.cpython-314.pyc | Bin 13956 -> 17437 bytes .../api/__pycache__/crontab.cpython-314.pyc | Bin 8408 -> 9638 bytes .../app/api/__pycache__/files.cpython-314.pyc | Bin 37512 -> 37461 bytes .../app/api/__pycache__/ftp.cpython-314.pyc | Bin 8405 -> 9603 bytes .../api/__pycache__/security.cpython-314.pyc | Bin 0 -> 3706 bytes .../app/api/__pycache__/site.cpython-314.pyc | Bin 28211 -> 29610 bytes .../app/api/__pycache__/ssl.cpython-314.pyc | Bin 25939 -> 34484 bytes YakPanel-server/backend/app/api/backup.py | 49 ++- YakPanel-server/backend/app/api/crontab.py | 17 + YakPanel-server/backend/app/api/files.py | 2 +- YakPanel-server/backend/app/api/ftp.py | 22 +- YakPanel-server/backend/app/api/security.py | 78 +++++ YakPanel-server/backend/app/api/site.py | 27 +- YakPanel-server/backend/app/api/ssl.py | 160 +++++++++ .../core/__pycache__/database.cpython-314.pyc | Bin 7514 -> 9369 bytes YakPanel-server/backend/app/core/database.py | 22 ++ .../backend/app/data/cron_templates.json | 30 ++ YakPanel-server/backend/app/main.py | 2 + .../__pycache__/backup_plan.cpython-314.pyc | Bin 1642 -> 1923 bytes .../models/__pycache__/site.cpython-314.pyc | Bin 3047 -> 3534 bytes .../backend/app/models/backup_plan.py | 4 + YakPanel-server/backend/app/models/site.py | 8 + .../__pycache__/site_service.cpython-314.pyc | Bin 43479 -> 50615 bytes .../backend/app/services/site_service.py | 326 +++++++++++++++--- YakPanel-server/backend/requirements.txt | 2 + YakPanel-server/docs/FEATURE-PARITY.md | 24 ++ YakPanel-server/frontend/src/App.tsx | 11 + YakPanel-server/frontend/src/api/client.ts | 85 ++++- YakPanel-server/frontend/src/config/menu.ts | 33 +- .../frontend/src/config/routes-meta.ts | 1 + .../frontend/src/pages/BackupPlansPage.tsx | 74 +++- .../frontend/src/pages/CrontabPage.tsx | 75 +++- .../frontend/src/pages/DomainsPage.tsx | 162 ++++++++- .../frontend/src/pages/FtpPage.tsx | 43 +++ .../src/pages/SecurityChecklistPage.tsx | 71 ++++ .../frontend/src/pages/SitePage.tsx | 93 +++++ .../webserver/templates/nginx_site.conf | 30 +- 38 files changed, 1332 insertions(+), 119 deletions(-) create mode 100644 YakPanel-server/backend/app/api/__pycache__/security.cpython-314.pyc create mode 100644 YakPanel-server/backend/app/api/security.py create mode 100644 YakPanel-server/backend/app/data/cron_templates.json create mode 100644 YakPanel-server/docs/FEATURE-PARITY.md create mode 100644 YakPanel-server/frontend/src/pages/SecurityChecklistPage.tsx diff --git a/YakPanel-server/backend/app/__pycache__/main.cpython-314.pyc b/YakPanel-server/backend/app/__pycache__/main.cpython-314.pyc index bc134b3c449db5c9058fa7f9eb3be6178acc927a..843c6961b9bcddb7b585dd4d3cd4a46122339399 100644 GIT binary patch delta 1211 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 JG5PrJe*voh>Tdu5 delta 4201 zcmbVPeQaCR6~FiSz2~p^D~|0ZPGTp`m)nx0O@ga@wT|5;#ceTft_v*Sxv^O0*jrJLegv zAAD+8^6#E|?z#8fbI-Zw9RKKP`sBQMtJ!2A&|W-$A^q33=gphwAAZ$a62u3(BKPp> zh@E!v7250Wb`hn=MKab59Y%3z#X*H`CcfTPL34uALH=FQ-4Y{1R1rW}2imT0_Mjl{ z&{^BT2P8x2z=>9XCGrumQMrn6vC&!4Kz~WsRF$A-TR~t>0H`+$5Kj;yUiks#$8_(~ z1AMps#pwb0pb);B5It!pB_R)2r6v1MmIRMFN|O;yn)-An9Q)ISqBoJrc<~lWd2c?G z$QBNkg>;g&!^V8Y&?NX8_*;f{*C>q3+QM8SlVF9i1{Q6I=zS8q6Q-G;Z!vm0@n*`l z+KzE`$9M+5!=`}mgIh$+r7#Z6I3~ zkK~e%)lc|IgDd@oMnEkJc*kf258X$rDqb{nR5Uoe1on`SxGEF9qpC%+W)lr8sy7`F zs!f{sqRC5bt1G50njMPKFV!USVpNE1u1nwOqv9}n1~d-=iEeJSw(!$7yS_m&DQ3l@ zSb5oY*fIjPT!)`uwPGWmYW$5BEKoNX_uIQRqqTej+Y4P;mrN8BM-qjU&(1u+U@}4n zf85^OI)xW0>~x4;@{L zRUI)!Y1K(2=k-DYgqH_rbab^BV9o8A?j~!4fmRr-H_#a>uA7>X?hRn~8lf|y_!?Dd zcVGxu=gf3Q7SCCnNOw0+PioFhia@_EQbZZ?gmpkwVBkB@@t-*#UR2#ji_ADYiCb0n zi3~ll76YtEq1}CyeW7>V2lItN0Q4>=?XMzSG+8Se=r4hfNzj95*KfS|tnv;0)c*kYgREyfc~hw|VL zDR1KDH-C`rhyDKLn7R=LyxCmQ`%tcwO|lu7W77y>go6N42GIrLL2a|7&%$na5{5zc z4FuJXqDaY$h#d$<1O-9$E!88vI7BaFF#w;;?nnC8`)0e4cNn06%7ce}zKfMq{n&#b zgKk#%C%^}Uf9U=xf3LMosCxK+TYL7JRkxaex#fTm^3gSshVC=DJUBl|KoK&T2_bJ_Wyqm7_w#V9h%WM5~k2yTfYTo+* zomO?-tA2o`iY1mk2+yDvI*O%yCdIl@7Gv9&J%nZ6)Hwti&vfq7jH4Go1RLyhL59rule!hF6wGRcXaaPL43+ZAiUW+&E zB(RrPdwTqo_xC?Cr9Nd%@<@)QU;>Zb2t9iU;cFfG1^#|N9@;VRODZ43Wf6XEaPMLU z$vna%0A+3RNWA!1KE;}lrMkESDJKB9#p6hq5FQ06XaF|cxJlKa9Wpo4uu1^bIs;<#uwQe$t(L`-AZKR1`A8Mm6?iy~>>kjYSrdFNY zGTefzd-XBBBJO;VkfZSJRua=cNOppalW@naBpe~cO`_UJD^{d_X(z;n9jL z#(PW6+74bCJ>*G0iKfKht7btp4?{(MJe|*{lI(8&;pk?*W$b{grp*I^6I!2Fx>6yRr^@&0HCw_I}8Ge3p3%$m# zO&0m-U3Yf`DX}>#Axcb+=aQ%Ptt`B49C%wCxE!X$9HrN;Hu3)5uEpvmV!M}KFJMYo z4r~gK)3p{F?hx19TYx?z2lU}CVcin&!02p04g2M@18$&SGxj5nP7Ol$`W6}10vEx6zO4=9;LwXZ7*B2@pv*f7mwF0!B+ph`xuQ)!C@fD z=HPD_xFQtu`BG6Wk=PL!vN;4ym<$cV@cP&`1Pn~IGF~@6pG%f9sY&)S@F@HW3O_v+ zZIEd5sq|F>UB$}VgY6dhpL#_@1ZPD6s?x{9L9Y$}C?V5T5s<8e_{m_W1u9!)dMg4@ Yl^y)m;0XVFu&WU=5b`~jg?cXf9~kp&?EnA( diff --git a/YakPanel-server/backend/app/api/__pycache__/crontab.cpython-314.pyc b/YakPanel-server/backend/app/api/__pycache__/crontab.cpython-314.pyc index 60c245c3ccbc084eefade5cafd57479bfae95669..9804695d66f3175ae9df42141d6c11000e494c7c 100644 GIT binary patch delta 3413 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 delta 150 zcmcb*gsEdG6R$QOFBbz4n73TXJiU>(h21g0*(#>Iyu2tsza*wIF}ol!FEvLmIX^ch zGBG;<#L>-7EG|hcLhy@Ii^_ofq{QUx)V!3K&6C(SurRVrcIKI)U7VPks*qYykeXbQ iSdy8ar;u2z;1r^tmCRRL^8@n(Krw^jh4BQ@Fp diff --git a/YakPanel-server/backend/app/api/__pycache__/ftp.cpython-314.pyc b/YakPanel-server/backend/app/api/__pycache__/ftp.cpython-314.pyc index f1c17cd0f4824d9a76a63645cc84077765e0593b..c2451af4e43af96a466f57bab4f400b13a26454e 100644 GIT binary patch delta 3471 zcma)9U2Gf25#GBaj}(6-iu$1?(d6mJlux##T2ZRDHso4%ZPKn4`Ao=hA&EVaCyDTh zWcH47L?EGKH;4@sNYVpZJ-R50st*NRC=YH?6zN-m=0T_(B)%Fb()J~NvLm2>FYe49 zDNAbL>HrOQXLr7xot^z=_3^1&7pg}ZgFXV^TOT~kCz}cRD|YIir`Fi|3s1-mq7akJ z5rs=}CI`QKicborkaU@@q}y~SMN^F9xRCNBy{4D7U8#no&-5kzrl0lQsX#Jl23cE7 zH74J$zBI!+{?vhFhuM+rG&_@BW>=id3f)8rD8V64X&i&)@iU`s1hh>NqJ(CHqe4x4 zkZGGi8{VOf%|@qAT0d@RIn^@CIWuQn-DGxmucxwwQby_#YHwHRT(FH>0XdHid+KnoPgK5`u!vX5EWY55% zPS6-lApZ3Zxk(;1HotZ83xRMPWx)L~Fe!ViH$1(^=rB&*;mI-RQxpsXuNGjuL%v5G z+hf*8o+~HO9XpUMQ>qorOxe(=&FAKS&eI@Tiy**HbdVwU)Lg@{J`izh-Yav7jqiJZ z$i0fo&?t%+2-#w_^jKMg1zT*wwNt0FXv4b7TH73;1p;3*g8qb2(kI%HTrGrhCvwULn4uo;A5v^`!G6L zs;Llh1qS7fWay{dB#V!TWBJ@3T-ik>n8=c3e+PcrIv)Ny#Ht)#;l2v0iH-lXy~&-zkT_V|A%={RP`j^;xoZ6? zG7M4pJQCuiX*9cn!ifjAc80_1=6z!PqE^ z%vb;L@NN;}+By*hI_n{Ez!)Nm{}rypPxB#ic9oElFjJpSrgj?Fv{P{GazhI)IN)GY$=;FuOb3M1~hCdE~M6I_SY=(p0Yo<(;_@Jyd2qWz~(m$^JP9xdPxI`5k*wIibwGc^S$I{ zAk<-1u^>&AsWu43D<{EeN+s1?GNk;11T>IS=c~FV9R?m6%xOTH<*b=s z(d40u*F=E9f>hz9o=UJs8W|tXX)D8pvaZXV?J@JqTCr@>2>8n=%l2f81rsX7WW{q> zTP~Taa^=WmQMy(rUN5lHiHk4fVY!83xsa2@(zR3#`VD;gb=g^D!1qEbw`;S#zG zgp_;U8UlT#ZPyH(z|_s#>SfI^)J4t6lRs=dlifgwhU1=?H*j&g2`f&jZ)iXYOk9$UPW51@v2jG;HuDN0JsK!#t%Sz z4q5p{z!es6oZs@1=C0eW$Nu2EV|T{h9ltZa*7u1&{?K2IKaM2U4}8#jPum!{7rn21 z=(@l1;p}Gr<;~g5!^n5-b+aY)$3W_71FZMNM}jTyomrDN4=J1e%bTvt|2EpeubW3F0^G(3 zh~Fg|BKR}jI1#XZ*_BSb48!zmAYOEFtJaa|Auh3TDcZ!%Jm)REl(YoG_8UM?Su?Xh zi_1<`7oEZfY_ZL!jHV2@Y(T@cI5x2Q1nFT?1N3iNzd1+;WZCv)GPz{e120TS*-dv?vPwW8dVU;1ZY%b9Ns+v%>Tm;fIhVUK47|w3op#}eGqxCEn ztFnU52pj8^EmFuKu)y%MVrmPEn)67pU8N#iJ*>yJP=Y&!Wr!koQQ&35Qi_FxB5yb^ zvtDS~-V@+US=YwtE$HI2&M1R?B5)iRctj5Ti8TC~oOnP^Y}}0pxq)*JxYo~_+t-Y{ nudSyaHjg~&kT$!IJ?I$u2P4@nf$(ilc;s8_C(@7$UyuI<@YKqR delta 2498 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 literal 0 HcmV?d00001 diff --git a/YakPanel-server/backend/app/api/__pycache__/site.cpython-314.pyc b/YakPanel-server/backend/app/api/__pycache__/site.cpython-314.pyc index cb856c85483b683cf9a452cd8a9c138fc2f6ea16..c117a0582b939ce0dc44086fd6ba8eff03dbc1e6 100644 GIT binary patch delta 6774 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 H6&C$JB0<)z delta 5628 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 diff --git a/YakPanel-server/backend/app/api/__pycache__/ssl.cpython-314.pyc b/YakPanel-server/backend/app/api/__pycache__/ssl.cpython-314.pyc index 1943f038aa7700c4114c6002c6130eaa7ddde6c3..db4f8b34accd95ddcca751156b065fffefd8c06d 100644 GIT binary patch delta 8711 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 diff --git a/YakPanel-server/backend/app/api/backup.py b/YakPanel-server/backend/app/api/backup.py index 46642776..1f9f053f 100644 --- a/YakPanel-server/backend/app/api/backup.py +++ b/YakPanel-server/backend/app/api/backup.py @@ -27,6 +27,9 @@ class CreateBackupPlanRequest(BaseModel): target_id: int schedule: str # cron, e.g. "0 2 * * *" enabled: bool = True + s3_bucket: str = "" + s3_endpoint: str = "" + s3_key_prefix: str = "" @router.get("/plans") @@ -45,6 +48,9 @@ async def backup_plans_list( "target_id": r.target_id, "schedule": r.schedule, "enabled": r.enabled, + "s3_bucket": getattr(r, "s3_bucket", None) or "", + "s3_endpoint": getattr(r, "s3_endpoint", None) or "", + "s3_key_prefix": getattr(r, "s3_key_prefix", None) or "", } for r in rows ] @@ -79,6 +85,9 @@ async def backup_plan_create( target_id=body.target_id, schedule=body.schedule, enabled=body.enabled, + s3_bucket=(body.s3_bucket or "")[:256], + s3_endpoint=(body.s3_endpoint or "")[:512], + s3_key_prefix=(body.s3_key_prefix or "")[:256], ) db.add(plan) await db.commit() @@ -107,6 +116,9 @@ async def backup_plan_update( plan.target_id = body.target_id plan.schedule = body.schedule plan.enabled = body.enabled + plan.s3_bucket = (body.s3_bucket or "")[:256] + plan.s3_endpoint = (body.s3_endpoint or "")[:512] + plan.s3_key_prefix = (body.s3_key_prefix or "")[:256] await db.commit() return {"status": True, "msg": "Updated"} @@ -143,6 +155,27 @@ def _run_site_backup(site: Site) -> tuple[bool, str, str | None]: return False, str(e), None +def _maybe_upload_s3(local_file: str, plan: BackupPlan) -> tuple[bool, str]: + """Copy backup file to S3-compatible bucket if plan.s3_bucket set. Uses AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY.""" + bucket = (getattr(plan, "s3_bucket", None) or "").strip() + if not bucket or not os.path.isfile(local_file): + return True, "" + try: + import boto3 + except ImportError: + return False, "boto3 not installed (pip install boto3)" + ep = (getattr(plan, "s3_endpoint", None) or "").strip() or None + prefix = (getattr(plan, "s3_key_prefix", None) or "").strip().strip("/") + key_base = os.path.basename(local_file) + key = f"{prefix}/{key_base}" if prefix else key_base + try: + client = boto3.client("s3", endpoint_url=ep) + client.upload_file(local_file, bucket, key) + return True, f"s3://{bucket}/{key}" + except Exception as e: + return False, str(e) + + def _run_database_backup(dbo: Database) -> tuple[bool, str, str | None]: """Run database backup (sync). Returns (ok, msg, filename).""" cfg = get_runtime_config() @@ -164,15 +197,17 @@ async def backup_run_scheduled( """Run all due backup plans. Call this from cron (e.g. every hour) or manually.""" from datetime import datetime as dt now = dt.utcnow() + cfg = get_runtime_config() result = await db.execute(select(BackupPlan).where(BackupPlan.enabled == True)) plans = result.scalars().all() results = [] for plan in plans: + ok = False + msg = "" try: prev_run = croniter(plan.schedule, now).get_prev(dt) - # Run if we're within 15 minutes after the scheduled time secs_since = (now - prev_run).total_seconds() - if secs_since > 900 or secs_since < 0: # Not within 15 min window + if secs_since > 900 or secs_since < 0: continue except Exception: continue @@ -183,6 +218,11 @@ async def backup_run_scheduled( results.append({"plan": plan.name, "status": "skipped", "msg": "Site not found or path invalid"}) continue ok, msg, filename = _run_site_backup(site) + if ok and filename: + full = os.path.join(cfg["backup_path"], filename) + u_ok, u_msg = _maybe_upload_s3(full, plan) + if u_msg: + msg = f"{msg}; {u_msg}" if u_ok else f"{msg}; S3 failed: {u_msg}" if ok: send_email( subject=f"YakPanel - Scheduled backup: {plan.name}", @@ -195,6 +235,11 @@ async def backup_run_scheduled( results.append({"plan": plan.name, "status": "skipped", "msg": "Database not found"}) continue ok, msg, filename = _run_database_backup(dbo) + if ok and filename: + full = os.path.join(cfg["backup_path"], "database", filename) + u_ok, u_msg = _maybe_upload_s3(full, plan) + if u_msg: + msg = f"{msg}; {u_msg}" if u_ok else f"{msg}; S3 failed: {u_msg}" if ok: send_email( subject=f"YakPanel - Scheduled backup: {plan.name}", diff --git a/YakPanel-server/backend/app/api/crontab.py b/YakPanel-server/backend/app/api/crontab.py index 5142a413..65339634 100644 --- a/YakPanel-server/backend/app/api/crontab.py +++ b/YakPanel-server/backend/app/api/crontab.py @@ -1,6 +1,9 @@ """YakPanel - Crontab API""" +import json import tempfile import os +from pathlib import Path + from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select @@ -14,6 +17,20 @@ from app.models.crontab import Crontab router = APIRouter(prefix="/crontab", tags=["crontab"]) +_CRON_TEMPLATES = Path(__file__).resolve().parent.parent / "data" / "cron_templates.json" + + +@router.get("/templates") +async def crontab_templates(current_user: User = Depends(get_current_user)): + """YakPanel starter cron templates (edit before apply; no external branding).""" + if not _CRON_TEMPLATES.is_file(): + return {"templates": []} + try: + data = json.loads(_CRON_TEMPLATES.read_text(encoding="utf-8")) + return {"templates": data if isinstance(data, list) else []} + except (json.JSONDecodeError, OSError): + return {"templates": []} + class CreateCrontabRequest(BaseModel): name: str = "" diff --git a/YakPanel-server/backend/app/api/files.py b/YakPanel-server/backend/app/api/files.py index 3b91f9cf..5b0ca07c 100644 --- a/YakPanel-server/backend/app/api/files.py +++ b/YakPanel-server/backend/app/api/files.py @@ -22,7 +22,7 @@ def _resolve_path(path: str) -> str: Resolve API path to an OS path. On Linux/macOS: path is an absolute POSIX path from filesystem root (/) so admins - can browse the whole server (same expectation as BT/aaPanel-style panels). + can browse the whole server (typical expectation for a full-server admin file manager). On Windows (dev): paths stay sandboxed under www_root / setup_path. """ diff --git a/YakPanel-server/backend/app/api/ftp.py b/YakPanel-server/backend/app/api/ftp.py index 1f5183d9..44f629fa 100644 --- a/YakPanel-server/backend/app/api/ftp.py +++ b/YakPanel-server/backend/app/api/ftp.py @@ -1,5 +1,6 @@ """YakPanel - FTP API""" -from fastapi import APIRouter, Depends, HTTPException +import os +from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, func from pydantic import BaseModel @@ -103,6 +104,25 @@ async def ftp_delete( return {"status": True, "msg": "FTP account deleted"} +@router.get("/logs") +async def ftp_logs( + lines: int = Query(default=200, ge=1, le=5000), + current_user: User = Depends(get_current_user), +): + """Tail common Pure-FTPd log paths if readable (non-destructive).""" + candidates = [ + "/var/log/pure-ftpd/pure-ftpd.log", + "/var/log/pureftpd.log", + "/var/log/messages", + ] + for path in candidates: + if os.path.isfile(path): + out, err = exec_shell_sync(f'tail -n {int(lines)} "{path}" 2>/dev/null', timeout=15) + text = (out or "") + (err or "") + return {"path": path, "content": text[-800000:] or "(empty)"} + return {"path": None, "content": "No known FTP log file found on this server."} + + @router.get("/count") async def ftp_count( current_user: User = Depends(get_current_user), diff --git a/YakPanel-server/backend/app/api/security.py b/YakPanel-server/backend/app/api/security.py new file mode 100644 index 00000000..603c4b57 --- /dev/null +++ b/YakPanel-server/backend/app/api/security.py @@ -0,0 +1,78 @@ +"""YakPanel - read-only security checklist (local server probes).""" +import os +import re + +from fastapi import APIRouter, Depends + +from app.api.auth import get_current_user +from app.models.user import User +from app.core.utils import read_file, exec_shell_sync + +router = APIRouter(prefix="/security", tags=["security"]) + + +@router.get("/checklist") +async def security_checklist(current_user: User = Depends(get_current_user)): + """Non-destructive hints: SSH config, firewall helper, fail2ban. Not a full audit.""" + items: list[dict] = [] + sshd = "/etc/ssh/sshd_config" + body = read_file(sshd) if os.path.isfile(sshd) else None + if isinstance(body, str) and body: + if re.search(r"^\s*PasswordAuthentication\s+no\s*$", body, re.MULTILINE | re.IGNORECASE): + items.append({ + "id": "ssh_password_auth", + "ok": True, + "title": "SSH password auth", + "detail": "PasswordAuthentication appears set to no (prefer key-based login).", + }) + elif re.search(r"^\s*PasswordAuthentication\s+yes", body, re.MULTILINE | re.IGNORECASE): + items.append({ + "id": "ssh_password_auth", + "ok": False, + "title": "SSH password auth", + "detail": "PasswordAuthentication is yes — consider disabling and using SSH keys.", + }) + else: + items.append({ + "id": "ssh_password_auth", + "ok": None, + "title": "SSH password auth", + "detail": "Could not find an explicit PasswordAuthentication line (defaults depend on distro).", + }) + else: + items.append({ + "id": "ssh_password_auth", + "ok": None, + "title": "SSH password auth", + "detail": "/etc/ssh/sshd_config not readable from the panel process.", + }) + + ufw_out, _ = exec_shell_sync("ufw status 2>/dev/null", timeout=5) + ufw = ufw_out or "" + if "Status: active" in ufw: + items.append({"id": "ufw", "ok": True, "title": "UFW firewall", "detail": "UFW reports active."}) + elif "Status: inactive" in ufw: + items.append({ + "id": "ufw", + "ok": None, + "title": "UFW firewall", + "detail": "UFW installed but inactive — enable if this host is public.", + }) + else: + items.append({ + "id": "ufw", + "ok": None, + "title": "UFW firewall", + "detail": "UFW not detected (OK if you use firewalld/iptables only).", + }) + + f2_out, _ = exec_shell_sync("systemctl is-active fail2ban 2>/dev/null", timeout=5) + f2_active = (f2_out or "").strip() == "active" + items.append({ + "id": "fail2ban", + "ok": f2_active, + "title": "fail2ban", + "detail": "fail2ban is active." if f2_active else "fail2ban not active (optional hardening).", + }) + + return {"items": items, "disclaimer": "YakPanel reads local settings only; this is not a compliance scan."} diff --git a/YakPanel-server/backend/app/api/site.py b/YakPanel-server/backend/app/api/site.py index e0c1a63a..406c65f1 100644 --- a/YakPanel-server/backend/app/api/site.py +++ b/YakPanel-server/backend/app/api/site.py @@ -29,6 +29,11 @@ class CreateSiteRequest(BaseModel): ps: str = "" php_version: str = "74" force_https: bool = False + proxy_upstream: str = "" + proxy_websocket: bool = False + dir_auth_path: str = "" + dir_auth_user_file: str = "" + php_deny_execute: bool = False class UpdateSiteRequest(BaseModel): @@ -37,6 +42,11 @@ class UpdateSiteRequest(BaseModel): ps: str | None = None php_version: str | None = None force_https: bool | None = None + proxy_upstream: str | None = None + proxy_websocket: bool | None = None + dir_auth_path: str | None = None + dir_auth_user_file: str | None = None + php_deny_execute: bool | None = None @router.get("/list") @@ -66,6 +76,11 @@ async def site_create( ps=body.ps, php_version=body.php_version or "74", force_https=1 if body.force_https else 0, + proxy_upstream=(body.proxy_upstream or "").strip(), + proxy_websocket=1 if body.proxy_websocket else 0, + dir_auth_path=(body.dir_auth_path or "").strip(), + dir_auth_user_file=(body.dir_auth_user_file or "").strip(), + php_deny_execute=1 if body.php_deny_execute else 0, ) if not result["status"]: raise HTTPException(status_code=400, detail=result["msg"]) @@ -126,12 +141,22 @@ async def site_update( ): """Update site domains, path, or note""" result = await update_site( - db, site_id, + db, + site_id, path=body.path, domains=body.domains, ps=body.ps, php_version=body.php_version, force_https=None if body.force_https is None else (1 if body.force_https else 0), + proxy_upstream=body.proxy_upstream, + proxy_websocket=None + if body.proxy_websocket is None + else (1 if body.proxy_websocket else 0), + dir_auth_path=body.dir_auth_path, + dir_auth_user_file=body.dir_auth_user_file, + php_deny_execute=None + if body.php_deny_execute is None + else (1 if body.php_deny_execute else 0), ) if not result["status"]: raise HTTPException(status_code=400, detail=result["msg"]) diff --git a/YakPanel-server/backend/app/api/ssl.py b/YakPanel-server/backend/app/api/ssl.py index 6c866833..0f26050f 100644 --- a/YakPanel-server/backend/app/api/ssl.py +++ b/YakPanel-server/backend/app/api/ssl.py @@ -5,6 +5,7 @@ import shutil import socket import subprocess import sys +import tempfile from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select @@ -403,9 +404,42 @@ async def ssl_diagnostics(current_user: User = Depends(get_current_user)): "127.0.0.1:443 accepts TCP, but nginx -T from panel binaries did not show listen 443 — another process may own 443; check ss -tlnp and which nginx serves port 80." ) + debian_sites = os.path.isdir("/etc/nginx/sites-available") + rhel_conf = os.path.isdir("/etc/nginx/conf.d") + layout = "unknown" + if debian_sites: + layout = "debian_sites_available" + elif rhel_conf: + layout = "rhel_conf_d" + drop_deb = "/etc/nginx/sites-available/yakpanel-vhosts.conf" + drop_rhel = "/etc/nginx/conf.d/yakpanel-vhosts.conf" + nginx_wizard = { + "detected_layout": layout, + "include_snippet": include_snippet, + "dropin_file_suggested": drop_deb if debian_sites else drop_rhel, + "debian": { + "sites_available_file": drop_deb, + "sites_enabled_symlink": "/etc/nginx/sites-enabled/yakpanel-vhosts.conf", + "steps": [ + f"printf '%s\\n' '{include_snippet}' | sudo tee {drop_deb}", + f"sudo ln -sf {drop_deb} /etc/nginx/sites-enabled/yakpanel-vhosts.conf", + "sudo nginx -t && sudo systemctl reload nginx", + ], + }, + "rhel": { + "conf_d_file": drop_rhel, + "steps": [ + f"printf '%s\\n' '{include_snippet}' | sudo tee {drop_rhel}", + "sudo nginx -t && sudo systemctl reload nginx", + ], + }, + "note": "Run the steps for your distro as root. The include line must appear inside the main http { } context (conf.d files do automatically).", + } + return { "vhost_dir": vhost_dir, "include_snippet": include_snippet, + "nginx_wizard": nginx_wizard, "vhosts": vhost_summaries, "any_vhost_listen_ssl": any_vhost_443, "nginx_effective_listen_443": effective_listen_443, @@ -417,6 +451,132 @@ async def ssl_diagnostics(current_user: User = Depends(get_current_user)): } +class DnsCertCloudflareRequest(BaseModel): + domain: str + email: str + api_token: str + + +class DnsManualInstructionsRequest(BaseModel): + domain: str + + +@router.post("/dns-request/cloudflare") +async def ssl_dns_cloudflare_cert( + body: DnsCertCloudflareRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Request Let's Encrypt certificate using DNS-01 via Cloudflare (requires certbot-dns-cloudflare).""" + dom = (body.domain or "").split(":")[0].strip() + if not dom or ".." in dom or not body.email or not body.api_token: + raise HTTPException(status_code=400, detail="domain, email, and api_token required") + result_dom = await db.execute(select(Domain).where(Domain.name == dom).limit(1)) + dom_row = result_dom.scalar_one_or_none() + if dom_row: + regen_pre = await regenerate_site_vhost(db, dom_row.pid) + if not regen_pre.get("status"): + raise HTTPException( + status_code=500, + detail="Cannot refresh nginx vhost: " + str(regen_pre.get("msg", "")), + ) + ok_ngx, err_ngx = _reload_panel_and_common_nginx() + if not ok_ngx: + raise HTTPException( + status_code=500, + detail="Nginx reload failed: " + err_ngx, + ) + + prefix = _certbot_command() + if not prefix: + raise HTTPException(status_code=500, detail=_certbot_missing_message()) + + hostnames = await _le_hostnames_for_domain_row(db, dom_row, dom) + if not hostnames: + hostnames = [dom] + cred_lines = f'dns_cloudflare_api_token = {body.api_token.strip()}\n' + fd, cred_path = tempfile.mkstemp(suffix=".ini", prefix="yakpanel_cf_") + try: + os.write(fd, cred_lines.encode()) + os.close(fd) + os.chmod(cred_path, 0o600) + except OSError as e: + try: + os.close(fd) + except OSError: + pass + raise HTTPException(status_code=500, detail=f"Cannot write credentials temp file: {e}") from e + + base_flags = [ + "--non-interactive", + "--agree-tos", + "--email", + body.email.strip(), + "--no-eff-email", + "--dns-cloudflare", + "--dns-cloudflare-credentials", + cred_path, + ] + cmd = prefix + ["certonly"] + base_flags + for h in hostnames: + cmd.extend(["-d", h]) + env = environment_with_system_path() + try: + proc = subprocess.run(cmd, capture_output=True, text=True, timeout=600, env=env) + except (FileNotFoundError, subprocess.TimeoutExpired) as e: + try: + os.unlink(cred_path) + except OSError: + pass + raise HTTPException(status_code=500, detail=str(e)) from e + finally: + try: + os.unlink(cred_path) + except OSError: + pass + + if proc.returncode != 0: + err = (proc.stderr or proc.stdout or "").strip() or f"exit {proc.returncode}" + raise HTTPException( + status_code=500, + detail="certbot DNS failed. Install certbot-dns-cloudflare (pip or OS package) if missing. " + err[:6000], + ) + + if dom_row: + regen = await regenerate_site_vhost(db, dom_row.pid) + if not regen.get("status"): + return { + "status": True, + "msg": "Certificate issued but vhost regen failed: " + str(regen.get("msg", "")), + "output": (proc.stdout or "")[-2000:], + } + + return { + "status": True, + "msg": "Certificate issued via Cloudflare DNS-01", + "output": (proc.stdout or "")[-2000:], + } + + +@router.post("/dns-request/manual-instructions") +async def ssl_dns_manual_instructions( + body: DnsManualInstructionsRequest, + current_user: User = Depends(get_current_user), +): + """Return TXT record host for ACME DNS-01 (user creates record then runs certbot --manual).""" + d = (body.domain or "").split(":")[0].strip() + if not d or ".." in d: + raise HTTPException(status_code=400, detail="Invalid domain") + return { + "txt_record_name": f"_acme-challenge.{d}", + "certbot_example": ( + f"sudo certbot certonly --manual --preferred-challenges dns --email you@example.com " + f"--agree-tos -d {d}" + ), + "note": "Certbot will display the exact TXT value to create. After DNS propagates, continue in the terminal.", + } + + @router.get("/certificates") async def ssl_list_certificates(current_user: User = Depends(get_current_user)): """List existing Let's Encrypt certificates""" diff --git a/YakPanel-server/backend/app/core/__pycache__/database.cpython-314.pyc b/YakPanel-server/backend/app/core/__pycache__/database.cpython-314.pyc index d38a8fcdc6af7f0acbb7d9c2e00d726dde29492a..3a934a04f953bf8f0ac81439e3c6a9d3bc7af1f5 100644 GIT binary patch delta 1560 zcma)6Pi)&%7=JH`*FSb@J58G=N*Z_F)`n^$EoEp~wJB?|kdTcLWfLj_D{0-uOX^s* z)289jozOr621Z(YS}K8nBMK5oNc=ez)QUqT`w)BpiCutGg?g9-2j1&&*&-(GN&bGH zf4|@N-uKB*wqhIUzKK3}FF@<5&rR_Q-)DUvIH0$6&%wJLC8CvJrlBL(rT@&F(SK*X zum@)Yz0(j^0AOPn;M-6nE25r$sWb$>G;Lt&9 ze*F*jY5i4Ck1GkY0sW$fqfkPM;H)zQMEJgE6uySolQ5ubo?ksypR82tt9(sXHAN^a zHD>i6J;UxtIGr6F>bK|z#ucHcR_Dut);NcqlLyX2l1pFCCsWBBmq=cWU(M#ZsO4S| z6~0i@O1xaqN{tJsDs=V{j=L7m%|0K`jZZy$I&xS!HfDJaZ8cR;_(icIG*YCsib-Y-ZFV|#V zt`sCSV)dxc@WtA}sL6OXkA}*}FJzP4!!9nKNN}?+X0Kkl%pKYvI}eF0g0vu4MQKWF zq@Hloe=9UR;hg10dz6J$URH!fvEE2O;U@o;Jg&ReH{PG%%SSBNd{vT^Yv|fqP_dj! zbp=xu)gp=*)FVha!QrLQ*hKS6P2!it>qoaOu;&WszYX48 z>jhM>Ic<_-ZHjp}`(}1Ma5uaW-i=6xziLu)n+!G+COO`riiZD&NtN4Vpn1$BPjskD zhF>zNYMTr<0d`UX1sT(_o(%`yZi>f zdr~t(HIrFx)4^uaq$3^bvJqM_nR=TZZVD#tpXiY5MrhS!8mPOsd?V9k2$<;o{ZoJ% z*awhsTiw*26X9R`!A|P$Kf3`td4HeLw|vLfD(G9hF_j(%U$W^@*LOXMew1&8xO5C` zotw(A;QJ_>pJ9bxVII_%)q^(7m5`biql5MeIM$g2tBs delta 514 zcmbQ~dCQ7Vn~#@^0SJEUUdiMY+Q>JB$uY#)DyF==yeL1vB&ISkyC5+yHAgQwKQ|^a zF*^Xn(alXPE=esy@QYK6%7FZ&#N_PMyp)*Di<#ninRJ*ovj~_lP7>BM9 zpvdHtA~L!x3=A?1@(i*JK4F;**37aDnT!eyF&rRQfB+jq3POe3WG7KwM$X9vqK`KJ z7SCj4N?bnquekQ+EmCV316XgdBm-C``d#axtM4id^KE@lM^sVM+K5kE+h zKfb6mFFrRjy(qCHGe57mC=4hX4aCKqj0_A743AkvKd=Z)-XYV#C@}e=tOBFdGlEECZWl)8BMginl7-tvnE8khql@t7B6&R~ zUXY_SnQk#B78Dc(OEC=>@c^S^B#>F;3L 90) print \"Disk usage over 90% on / — \" $0; exit 0}'", + "description": "Print a line if root filesystem use exceeds 90% (extend with mail/curl as needed)." + }, + { + "id": "yakpanel_backup", + "name": "Run YakPanel scheduled backups", + "schedule": "15 * * * *", + "execstr": "curl -fsS -H \"Authorization: Bearer YOUR_TOKEN\" http://127.0.0.1:8889/api/v1/backup/run-scheduled || true", + "description": "Example: call the panel backup API hourly (set token and port; prefer localhost + firewall)." + }, + { + "id": "clear_tmp", + "name": "Clean old temp files", + "schedule": "0 3 * * *", + "execstr": "find /tmp -type f -atime +7 -delete 2>/dev/null || true", + "description": "Remove files in /tmp not accessed in 7 days." + }, + { + "id": "php_fpm_ping", + "name": "PHP-FPM socket check", + "schedule": "*/10 * * * *", + "execstr": "test -S /tmp/php-cgi-74.sock && exit 0 || echo \"php-fpm 74 socket missing\"", + "description": "Adjust php version/socket path for your stack." + } +] diff --git a/YakPanel-server/backend/app/main.py b/YakPanel-server/backend/app/main.py index 729b80af..197c3f0b 100644 --- a/YakPanel-server/backend/app/main.py +++ b/YakPanel-server/backend/app/main.py @@ -29,6 +29,7 @@ from app.api import ( node, service, public_installer, + security, ) @@ -87,6 +88,7 @@ app.include_router(config.router, prefix="/api/v1") app.include_router(user.router, prefix="/api/v1") app.include_router(logs.router, prefix="/api/v1") app.include_router(public_installer.router, prefix="/api/v1") +app.include_router(security.router, prefix="/api/v1") @app.get("/") diff --git a/YakPanel-server/backend/app/models/__pycache__/backup_plan.cpython-314.pyc b/YakPanel-server/backend/app/models/__pycache__/backup_plan.cpython-314.pyc index 89e427f98de1bab0b85b3b8bbe06f6badc33e9c0..193002ac4baa36e41b51219e953a1b1a6d91c4d0 100644 GIT binary patch delta 562 zcmaFG)6CDO&Bx2d00iGRU&*v%naKB*NsnJijV-S}(Lk>rYP!M;Ca2P|0NDz-QgC_4SE~mug$#)oi zR9-Rwt-i&O$pEx98RQD6OBh&yY-S+-EXFw5f$7lXNlfC5?34SLq!~FtBqygDP!ebs z*JL(k3-w!^#m4bTrODZ;CAYYN%+$P;g8a<9l3Tn$PIhW#d_hraT4u#$J7x<;$;sQ9 zT^Z+1{>z-{^ihz3k@2GlgMiq?}h7LJ~8Xr%GRBas4O`5|CgJk(#MI z)%qff`zJ}Dz-OT43@aJ@G({(uviKVd19?TDa4Ql45uzYM3`B^72#{BbBtV2Dh>!vi z(vx4XILj*lnZ+O8*93hD#%0?5CO8_7Kcr4 deoARhs$G%MWOFtpK^{gA#t9p2+u==^VpkR>mDtVGPEMLG0oTIUFVYK^!FlVGJdL zL7d7Anp~63nF1w}8G)KW7z9{=m>GyaA7YrihUpLsP!;>+W6TzlRaqwsk zc?nA<|0h8PM#j$|D_1i3X$npDXZ2SR1oDcQK!gy85C#z?lmbYFJcs~kxW!?Uo1apelWJF_ WJ2`+|Nsxolk#T~^HwF+5Rt5lJB2Co* diff --git a/YakPanel-server/backend/app/models/__pycache__/site.cpython-314.pyc b/YakPanel-server/backend/app/models/__pycache__/site.cpython-314.pyc index 8f0cb324edb3c23e90260989bd9270006cd169c2..5aec88646539b617ee519ce994aecba4c4725643 100644 GIT binary patch delta 1012 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 lCUT{bC&4QgNFrZdjA@!H-D*8l@f4HYAVvtLi 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 fstIlju9e_{>}mtAfh tuple[str, str] | None: return None -def _build_ssl_server_block( - server_names: str, - root_path: str, - logs_path: str, - site_name: str, - php_version: str, - fullchain: str, - privkey: str, - redirects: list[tuple[str, str, int]] | None, -) -> str: - """Second server {} for HTTPS when LE certs exist.""" - pv = php_version or "74" - redirect_lines: list[str] = [] - for src, tgt, code in (redirects or []): - if src and tgt: - redirect_lines.append(f" location = {src} {{ return {code} {tgt}; }}") - redirect_block = ("\n" + "\n".join(redirect_lines)) if redirect_lines else "" - q_fc = fullchain.replace("\\", "\\\\").replace('"', '\\"') - q_pk = privkey.replace("\\", "\\\\").replace('"', '\\"') +def _build_php_deny_execute_block(enabled: int) -> str: + if not enabled: + return "" + return ( + r" location ~* ^/uploads/.*\.(php|phar|phtml|php5)$ {" + "\n" + r" deny all;" + "\n" + r" }" + "\n" + r" location ~* ^/storage/.*\.(php|phar|phtml|php5)$ {" + "\n" + r" deny all;" + "\n" + r" }" + "\n" + ) + + +def _build_main_app_block(proxy_upstream: str, proxy_websocket: int, php_version: str) -> str: + pu = (proxy_upstream or "").strip() + pv = php_version or "74" + if pu: + ws_lines = "" + if proxy_websocket: + ws_lines = ( + " proxy_set_header Upgrade $http_upgrade;\n" + ' proxy_set_header Connection "upgrade";\n' + ) + return ( + f" location / {{\n" + f" proxy_pass {pu};\n" + f" proxy_http_version 1.1;\n" + f" proxy_set_header Host $host;\n" + f" proxy_set_header X-Real-IP $remote_addr;\n" + f" proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n" + f" proxy_set_header X-Forwarded-Proto $scheme;\n" + f"{ws_lines}" + f" proxy_read_timeout 3600s;\n" + f" }}\n" + ) return ( - f"server {{\n" - f" listen 443 ssl;\n" - f" server_name {server_names};\n" - f' ssl_certificate "{q_fc}";\n' - f' ssl_certificate_key "{q_pk}";\n' - f" index index.php index.html index.htm default.php default.htm default.html;\n" - f" root {root_path};\n" - f" error_page 404 /404.html;\n" - f" error_page 502 /502.html;\n" - f" location ^~ /.well-known/acme-challenge/ {{\n" - f" root {root_path};\n" - f' default_type "text/plain";\n' - f" allow all;\n" - f" access_log off;\n" - f" }}\n" - f"{redirect_block}\n" r" location ~ .*\.(gif|jpg|jpeg|png|bmp|swf)$ {" + "\n" f" expires 30d;\n" f" access_log off;\n" @@ -253,6 +253,116 @@ def _build_ssl_server_block( f" fastcgi_index index.php;\n" f" include fastcgi.conf;\n" f" }}\n" + ) + + +def _build_dir_auth_block( + dir_path: str, + user_file: str, + proxy_upstream: str, + root_path: str, +) -> str: + dp = (dir_path or "").strip() + uf = (user_file or "").strip() + if not dp or not uf or ".." in dp or ".." in uf: + return "" + if not dp.startswith("/"): + dp = "/" + dp + qf = uf.replace("\\", "\\\\").replace('"', '\\"') + qr = root_path.replace("\\", "\\\\") + pu = (proxy_upstream or "").strip() + if pu: + puc = pu.rstrip("/") + return ( + f" location ^~ {dp} {{\n" + f' auth_basic "YakPanel";\n' + f' auth_basic_user_file "{qf}";\n' + f" proxy_pass {puc};\n" + f" proxy_http_version 1.1;\n" + f" proxy_set_header Host $host;\n" + f" proxy_set_header X-Real-IP $remote_addr;\n" + f" proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n" + f" proxy_set_header X-Forwarded-Proto $scheme;\n" + f" }}\n" + ) + return ( + f" location ^~ {dp} {{\n" + f' auth_basic "YakPanel";\n' + f' auth_basic_user_file "{qf}";\n' + f" root {qr};\n" + f" try_files $uri $uri/ =404;\n" + f" }}\n" + ) + + +def _build_location_bundle( + root_path: str, + redirects: list[tuple[str, str, int]] | None, + proxy_upstream: str, + proxy_websocket: int, + dir_auth_path: str, + dir_auth_user_file: str, + php_deny_execute: int, + php_version: str, +) -> str: + acme = ( + f" location ^~ /.well-known/acme-challenge/ {{\n" + f" root {root_path};\n" + f' default_type "text/plain";\n' + f" allow all;\n" + f" access_log off;\n" + f" }}\n" + ) + redirect_lines = [] + for src, tgt, code in redirects or []: + if src and tgt: + redirect_lines.append(f" location = {src} {{ return {code} {tgt}; }}") + redirect_block = ("\n" + "\n".join(redirect_lines)) if redirect_lines else "" + dir_auth = _build_dir_auth_block(dir_auth_path, dir_auth_user_file, proxy_upstream, root_path) + php_deny = _build_php_deny_execute_block(php_deny_execute) + main = _build_main_app_block(proxy_upstream, proxy_websocket, php_version) + return acme + redirect_block + "\n" + dir_auth + php_deny + main + + +def _build_ssl_server_block( + server_names: str, + root_path: str, + logs_path: str, + site_name: str, + php_version: str, + fullchain: str, + privkey: str, + redirects: list[tuple[str, str, int]] | None, + proxy_upstream: str = "", + proxy_websocket: int = 0, + dir_auth_path: str = "", + dir_auth_user_file: str = "", + php_deny_execute: int = 0, +) -> str: + """Second server {} for HTTPS when LE certs exist.""" + q_fc = fullchain.replace("\\", "\\\\").replace('"', '\\"') + q_pk = privkey.replace("\\", "\\\\").replace('"', '\\"') + bundle = _build_location_bundle( + root_path, + redirects, + proxy_upstream, + proxy_websocket, + dir_auth_path, + dir_auth_user_file, + php_deny_execute, + php_version, + ) + return ( + f"server {{\n" + f" listen 443 ssl;\n" + f" server_name {server_names};\n" + f' ssl_certificate "{q_fc}";\n' + f' ssl_certificate_key "{q_pk}";\n' + f" index index.php index.html index.htm default.php default.htm default.html;\n" + f" root {root_path};\n" + f" error_page 404 /404.html;\n" + f" error_page 502 /502.html;\n" + f"{bundle}" f" access_log {logs_path}/{site_name}.log;\n" f" error_log {logs_path}/{site_name}.error.log;\n" f"}}\n" @@ -269,6 +379,11 @@ def _render_vhost( force_https: int, redirects: list[tuple[str, str, int]] | None = None, le_hostnames: list[str] | None = None, + proxy_upstream: str = "", + proxy_websocket: int = 0, + dir_auth_path: str = "", + dir_auth_user_file: str = "", + php_deny_execute: int = 0, ) -> str: """Render nginx vhost template. redirects: [(source, target, code), ...]""" if force_https: @@ -279,28 +394,43 @@ def _render_vhost( ) else: force_block = "" - redirect_lines = [] - for src, tgt, code in (redirects or []): - if src and tgt: - redirect_lines.append(f" location = {src} {{ return {code} {tgt}; }}") - redirect_block = "\n".join(redirect_lines) if redirect_lines else "" hosts = le_hostnames if le_hostnames is not None else [p for p in server_names.split() if p] ssl_block = "" - for h in hosts: - le = _letsencrypt_paths(h) - if le: - fc, pk = le - ssl_block = _build_ssl_server_block( - server_names, root_path, logs_path, site_name, php_version, fc, pk, redirects - ) - break + le = _letsencrypt_paths_any(hosts) + if le: + fc, pk = le + ssl_block = _build_ssl_server_block( + server_names, + root_path, + logs_path, + site_name, + php_version, + fc, + pk, + redirects, + proxy_upstream, + proxy_websocket, + dir_auth_path, + dir_auth_user_file, + php_deny_execute, + ) + bundle = _build_location_bundle( + root_path, + redirects, + proxy_upstream, + proxy_websocket, + dir_auth_path, + dir_auth_user_file, + php_deny_execute, + php_version, + ) content = template.replace("{SERVER_NAMES}", server_names) content = content.replace("{ROOT_PATH}", root_path) content = content.replace("{LOGS_PATH}", logs_path) content = content.replace("{SITE_NAME}", site_name) content = content.replace("{PHP_VERSION}", php_version or "74") content = content.replace("{FORCE_HTTPS_BLOCK}", force_block) - content = content.replace("{REDIRECTS_BLOCK}", redirect_block) + content = content.replace("{LOCATION_BUNDLE}", bundle) content = content.replace("{SSL_SERVER_BLOCK}", ssl_block) return content @@ -327,6 +457,16 @@ async def domain_exists(db: AsyncSession, domains: list[str], exclude_site_id: i return None +def _vhost_kwargs_from_site(site: Site) -> dict: + return { + "proxy_upstream": getattr(site, "proxy_upstream", None) or "", + "proxy_websocket": int(getattr(site, "proxy_websocket", 0) or 0), + "dir_auth_path": getattr(site, "dir_auth_path", None) or "", + "dir_auth_user_file": getattr(site, "dir_auth_user_file", None) or "", + "php_deny_execute": int(getattr(site, "php_deny_execute", 0) or 0), + } + + async def create_site( db: AsyncSession, name: str, @@ -336,6 +476,11 @@ async def create_site( ps: str = "", php_version: str = "74", force_https: int = 0, + proxy_upstream: str = "", + proxy_websocket: int = 0, + dir_auth_path: str = "", + dir_auth_user_file: str = "", + php_deny_execute: int = 0, ) -> dict: """Create a new site with vhost config.""" if not path_safe_check(name) or not path_safe_check(path): @@ -359,7 +504,19 @@ async def create_site( if not os.path.exists(site_path): os.makedirs(site_path, 0o755) - site = Site(name=name, path=site_path, ps=ps, project_type=project_type, php_version=php_version or "74", force_https=force_https or 0) + site = Site( + name=name, + path=site_path, + ps=ps, + project_type=project_type, + php_version=php_version or "74", + force_https=force_https or 0, + proxy_upstream=(proxy_upstream or "")[:512], + proxy_websocket=1 if proxy_websocket else 0, + dir_auth_path=(dir_auth_path or "")[:256], + dir_auth_user_file=(dir_auth_user_file or "")[:512], + php_deny_execute=1 if php_deny_execute else 0, + ) db.add(site) await db.flush() @@ -379,8 +536,18 @@ async def create_site( template = read_file(template_path) or "" server_names = " ".join(d.split(":")[0] for d in domains) le_hosts = [d.split(":")[0] for d in domains] + vk = _vhost_kwargs_from_site(site) content = _render_vhost( - template, server_names, site_path, www_logs, name, php_version or "74", force_https or 0, [], le_hosts + template, + server_names, + site_path, + www_logs, + name, + php_version or "74", + force_https or 0, + [], + le_hosts, + **vk, ) write_file(conf_path, content) @@ -477,6 +644,11 @@ async def get_site_with_domains(db: AsyncSession, site_id: int) -> dict | None: "project_type": site.project_type, "php_version": getattr(site, "php_version", None) or "74", "force_https": getattr(site, "force_https", 0) or 0, + "proxy_upstream": getattr(site, "proxy_upstream", None) or "", + "proxy_websocket": int(getattr(site, "proxy_websocket", 0) or 0), + "dir_auth_path": getattr(site, "dir_auth_path", None) or "", + "dir_auth_user_file": getattr(site, "dir_auth_user_file", None) or "", + "php_deny_execute": int(getattr(site, "php_deny_execute", 0) or 0), "domains": domain_list, } @@ -489,6 +661,11 @@ async def update_site( ps: str | None = None, php_version: str | None = None, force_https: int | None = None, + proxy_upstream: str | None = None, + proxy_websocket: int | None = None, + dir_auth_path: str | None = None, + dir_auth_user_file: str | None = None, + php_deny_execute: int | None = None, ) -> dict: """Update site domains, path, or note.""" result = await db.execute(select(Site).where(Site.id == site_id)) @@ -518,11 +695,30 @@ async def update_site( site.php_version = php_version or "74" if force_https is not None: site.force_https = 1 if force_https else 0 + if proxy_upstream is not None: + site.proxy_upstream = (proxy_upstream or "")[:512] + if proxy_websocket is not None: + site.proxy_websocket = 1 if proxy_websocket else 0 + if dir_auth_path is not None: + site.dir_auth_path = (dir_auth_path or "")[:256] + if dir_auth_user_file is not None: + site.dir_auth_user_file = (dir_auth_user_file or "")[:512] + if php_deny_execute is not None: + site.php_deny_execute = 1 if php_deny_execute else 0 await db.flush() - # Regenerate Nginx vhost if domains, php_version, or force_https changed - if domains is not None or php_version is not None or force_https is not None: + regen = ( + domains is not None + or php_version is not None + or force_https is not None + or proxy_upstream is not None + or proxy_websocket is not None + or dir_auth_path is not None + or dir_auth_user_file is not None + or php_deny_execute is not None + ) + if regen: cfg = get_runtime_config() vhost_path = os.path.join(cfg["setup_path"], "panel", "vhost", "nginx") conf_path = os.path.join(vhost_path, f"{site.name}.conf") @@ -538,8 +734,18 @@ async def update_site( redir_result = await db.execute(select(SiteRedirect).where(SiteRedirect.site_id == site.id)) redirects = [(r.source, r.target, r.code or 301) for r in redir_result.scalars().all()] le_hosts = [d.name for d in domain_rows] + vk = _vhost_kwargs_from_site(site) content = _render_vhost( - template, server_names, site.path, cfg["www_logs"], site.name, php_ver, fhttps, redirects, le_hosts + template, + server_names, + site.path, + cfg["www_logs"], + site.name, + php_ver, + fhttps, + redirects, + le_hosts, + **vk, ) write_file(conf_path, content) reload_ok, reload_err = nginx_reload_all_known() @@ -623,8 +829,18 @@ async def regenerate_site_vhost(db: AsyncSession, site_id: int) -> dict: redir_result = await db.execute(select(SiteRedirect).where(SiteRedirect.site_id == site.id)) redirects = [(r.source, r.target, r.code or 301) for r in redir_result.scalars().all()] le_hosts = [d.name for d in domain_rows] + vk = _vhost_kwargs_from_site(site) content = _render_vhost( - template, server_names, site.path, cfg["www_logs"], site.name, php_ver, fhttps, redirects, le_hosts + template, + server_names, + site.path, + cfg["www_logs"], + site.name, + php_ver, + fhttps, + redirects, + le_hosts, + **vk, ) write_file(write_path, content) reload_ok, reload_err = nginx_reload_all_known() diff --git a/YakPanel-server/backend/requirements.txt b/YakPanel-server/backend/requirements.txt index 6b52c6c1..447b1a24 100644 --- a/YakPanel-server/backend/requirements.txt +++ b/YakPanel-server/backend/requirements.txt @@ -23,6 +23,8 @@ celery>=5.3.0 # Let's Encrypt (optional if system certbot/snap not used; enables python -m certbot from panel venv) certbot>=3.0.0 certbot-nginx>=3.0.0 +certbot-dns-cloudflare>=3.0.0 +boto3>=1.34.0 # Utils psutil>=5.9.0 diff --git a/YakPanel-server/docs/FEATURE-PARITY.md b/YakPanel-server/docs/FEATURE-PARITY.md new file mode 100644 index 00000000..d4cc4d05 --- /dev/null +++ b/YakPanel-server/docs/FEATURE-PARITY.md @@ -0,0 +1,24 @@ +# YakPanel feature parity checklist (clean-room) + +Internal checklist against common hosting-panel capabilities used as a product roadmap only. No third-party panel code is shipped. + +| Area | Status | YakPanel location | +|------|--------|-------------------| +| Sites, domains, redirects | Done | `api/site.py`, `site_service.py` | +| Nginx vhost + SSL (HTTP-01) | Done | `webserver/templates/nginx_site.conf`, `ssl.py` | +| SSL diagnostics + port 443 probe | Done | `GET /ssl/diagnostics` | +| Nginx include wizard (drop-in hints) | Done | `GET /ssl/diagnostics` → `nginx_wizard` | +| Reverse proxy site mode | Done | `Site.proxy_upstream`, vhost `proxy_pass` | +| WebSocket proxy hint | Done | `Site.proxy_websocket` | +| Directory HTTP basic auth | Done | `Site.dir_auth_path`, `dir_auth_user_file` | +| Disable PHP execution (uploads) | Done | `Site.php_deny_execute` | +| DNS-01 Let's Encrypt (Cloudflare / manual TXT) | Done | `POST /ssl/dns-request/*` | +| Security checklist (read-only probes) | Done | `GET /security/checklist` | +| FTP logs (tail) | Done | `GET /ftp/logs` | +| Cron job templates (YakPanel JSON) | Done | `GET /crontab/templates`, `data/cron_templates.json` | +| Backup plans + optional S3 upload | Done | `backup.py`, `BackupPlan` S3 fields, `boto3` optional | +| Database / FTP / firewall / monitor | Partial (pre-existing) | respective `api/*.py` | +| Mail server | Not planned | — | +| WordPress one-click | Not planned | plugin later | + +_Last updated: parity pass (this implementation)._ diff --git a/YakPanel-server/frontend/src/App.tsx b/YakPanel-server/frontend/src/App.tsx index 83514a66..2b24daa3 100644 --- a/YakPanel-server/frontend/src/App.tsx +++ b/YakPanel-server/frontend/src/App.tsx @@ -16,6 +16,9 @@ const CrontabPage = lazy(() => import('./pages/CrontabPage').then((m) => ({ defa const ConfigPage = lazy(() => import('./pages/ConfigPage').then((m) => ({ default: m.ConfigPage }))) const LogsPage = lazy(() => import('./pages/LogsPage').then((m) => ({ default: m.LogsPage }))) const FirewallPage = lazy(() => import('./pages/FirewallPage').then((m) => ({ default: m.FirewallPage }))) +const SecurityChecklistPage = lazy(() => + import('./pages/SecurityChecklistPage').then((m) => ({ default: m.SecurityChecklistPage })), +) const DomainsPage = lazy(() => import('./pages/DomainsPage').then((m) => ({ default: m.DomainsPage }))) const DockerPage = lazy(() => import('./pages/DockerPage').then((m) => ({ default: m.DockerPage }))) const NodePage = lazy(() => import('./pages/NodePage').then((m) => ({ default: m.NodePage }))) @@ -125,6 +128,14 @@ export default function App() { } /> + }> + + + } + /> ('/site/create', { method: 'POST', body: JSON.stringify(data), @@ -108,14 +120,38 @@ export async function siteBatch(action: 'enable' | 'disable' | 'delete', ids: nu } export async function getSite(siteId: number) { - return apiRequest<{ id: number; name: string; path: string; status: number; ps: string; project_type: string; php_version: string; force_https: number; domains: string[] }>( - `/site/${siteId}` - ) + return apiRequest<{ + id: number + name: string + path: string + status: number + ps: string + project_type: string + php_version: string + force_https: number + domains: string[] + proxy_upstream?: string + proxy_websocket?: number + dir_auth_path?: string + dir_auth_user_file?: string + php_deny_execute?: number + }>(`/site/${siteId}`) } export async function updateSite( siteId: number, - data: { path?: string; domains?: string[]; ps?: string; php_version?: string; force_https?: boolean } + data: { + path?: string + domains?: string[] + ps?: string + php_version?: string + force_https?: boolean + proxy_upstream?: string + proxy_websocket?: boolean + dir_auth_path?: string + dir_auth_user_file?: string + php_deny_execute?: boolean + } ) { return apiRequest<{ status: boolean; msg: string }>(`/site/${siteId}`, { method: 'PUT', @@ -530,17 +566,50 @@ export async function getMonitorNetwork() { } export async function listBackupPlans() { - return apiRequest<{ id: number; name: string; plan_type: string; target_id: number; schedule: string; enabled: boolean }[]>('/backup/plans') + return apiRequest< + { + id: number + name: string + plan_type: string + target_id: number + schedule: string + enabled: boolean + s3_bucket?: string + s3_endpoint?: string + s3_key_prefix?: string + }[] + >('/backup/plans') } -export async function createBackupPlan(data: { name: string; plan_type: string; target_id: number; schedule: string; enabled?: boolean }) { +export async function createBackupPlan(data: { + name: string + plan_type: string + target_id: number + schedule: string + enabled?: boolean + s3_bucket?: string + s3_endpoint?: string + s3_key_prefix?: string +}) { return apiRequest<{ status: boolean; msg: string; id: number }>('/backup/plans', { method: 'POST', body: JSON.stringify(data), }) } -export async function updateBackupPlan(planId: number, data: { name: string; plan_type: string; target_id: number; schedule: string; enabled?: boolean }) { +export async function updateBackupPlan( + planId: number, + data: { + name: string + plan_type: string + target_id: number + schedule: string + enabled?: boolean + s3_bucket?: string + s3_endpoint?: string + s3_key_prefix?: string + } +) { return apiRequest<{ status: boolean; msg: string }>(`/backup/plans/${planId}`, { method: 'PUT', body: JSON.stringify(data), diff --git a/YakPanel-server/frontend/src/config/menu.ts b/YakPanel-server/frontend/src/config/menu.ts index d2ea2f2c..3c6d4355 100644 --- a/YakPanel-server/frontend/src/config/menu.ts +++ b/YakPanel-server/frontend/src/config/menu.ts @@ -15,17 +15,24 @@ export const menuItems: MenuItem[] = [ { title: 'Docker', href: '/docker', id: 'menuDocker', sort: 5, iconClass: 'ti ti-brand-docker' }, { title: 'Monitor', href: '/control', id: 'menuControl', sort: 6, iconClass: 'ti ti-heart-rate-monitor' }, { title: 'Security', href: '/firewall', id: 'menuFirewall', sort: 7, iconClass: 'ti ti-shield-lock' }, - { title: 'Files', href: '/files', id: 'menuFiles', sort: 8, iconClass: 'ti ti-folders' }, - { title: 'Node', href: '/node', id: 'menuNode', sort: 9, iconClass: 'ti ti-brand-nodejs' }, - { title: 'Logs', href: '/logs', id: 'menuLogs', sort: 10, iconClass: 'ti ti-file-text' }, - { title: 'Domains', href: '/ssl_domain', id: 'menuDomains', sort: 11, iconClass: 'ti ti-world-www' }, - { title: 'Terminal', href: '/xterm', id: 'menuXterm', sort: 12, iconClass: 'ti ti-terminal-2' }, - { title: 'Cron', href: '/crontab', id: 'menuCrontab', sort: 13, iconClass: 'ti ti-clock' }, - { title: 'App Store', href: '/soft', id: 'menuSoft', sort: 14, iconClass: 'ti ti-package' }, - { title: 'Services', href: '/services', id: 'menuServices', sort: 15, iconClass: 'ti ti-server' }, - { title: 'Plugins', href: '/plugins', id: 'menuPlugins', sort: 16, iconClass: 'ti ti-puzzle' }, - { title: 'Backup Plans', href: '/backup-plans', id: 'menuBackupPlans', sort: 17, iconClass: 'ti ti-archive' }, - { title: 'Users', href: '/users', id: 'menuUsers', sort: 18, iconClass: 'ti ti-users' }, - { title: 'Settings', href: '/config', id: 'menuConfig', sort: 19, iconClass: 'ti ti-settings' }, - { title: 'Log out', href: '/logout', id: 'menuLogout', sort: 20, iconClass: 'ti ti-logout' }, + { + title: 'Security checklist', + href: '/security-checklist', + id: 'menuSecurityChecklist', + sort: 8, + iconClass: 'ti ti-checklist', + }, + { title: 'Files', href: '/files', id: 'menuFiles', sort: 9, iconClass: 'ti ti-folders' }, + { title: 'Node', href: '/node', id: 'menuNode', sort: 10, iconClass: 'ti ti-brand-nodejs' }, + { title: 'Logs', href: '/logs', id: 'menuLogs', sort: 11, iconClass: 'ti ti-file-text' }, + { title: 'Domains', href: '/ssl_domain', id: 'menuDomains', sort: 12, iconClass: 'ti ti-world-www' }, + { title: 'Terminal', href: '/xterm', id: 'menuXterm', sort: 13, iconClass: 'ti ti-terminal-2' }, + { title: 'Cron', href: '/crontab', id: 'menuCrontab', sort: 14, iconClass: 'ti ti-clock' }, + { title: 'App Store', href: '/soft', id: 'menuSoft', sort: 15, iconClass: 'ti ti-package' }, + { title: 'Services', href: '/services', id: 'menuServices', sort: 16, iconClass: 'ti ti-server' }, + { title: 'Plugins', href: '/plugins', id: 'menuPlugins', sort: 17, iconClass: 'ti ti-puzzle' }, + { title: 'Backup Plans', href: '/backup-plans', id: 'menuBackupPlans', sort: 18, iconClass: 'ti ti-archive' }, + { title: 'Users', href: '/users', id: 'menuUsers', sort: 19, iconClass: 'ti ti-users' }, + { title: 'Settings', href: '/config', id: 'menuConfig', sort: 20, iconClass: 'ti ti-settings' }, + { title: 'Log out', href: '/logout', id: 'menuLogout', sort: 21, iconClass: 'ti ti-logout' }, ] diff --git a/YakPanel-server/frontend/src/config/routes-meta.ts b/YakPanel-server/frontend/src/config/routes-meta.ts index b7586143..ef1f8d2d 100644 --- a/YakPanel-server/frontend/src/config/routes-meta.ts +++ b/YakPanel-server/frontend/src/config/routes-meta.ts @@ -7,6 +7,7 @@ export const routeTitleMap: Record = { '/docker': 'Docker', '/control': 'Monitor', '/firewall': 'Security', + '/security-checklist': 'Security checklist', '/files': 'Files', '/node': 'Node', '/logs': 'Logs', diff --git a/YakPanel-server/frontend/src/pages/BackupPlansPage.tsx b/YakPanel-server/frontend/src/pages/BackupPlansPage.tsx index 2bf00c7d..51baedba 100644 --- a/YakPanel-server/frontend/src/pages/BackupPlansPage.tsx +++ b/YakPanel-server/frontend/src/pages/BackupPlansPage.tsx @@ -17,6 +17,9 @@ interface BackupPlanRecord { target_id: number schedule: string enabled: boolean + s3_bucket?: string + s3_endpoint?: string + s3_key_prefix?: string } interface SiteRecord { @@ -74,13 +77,16 @@ export function BackupPlansPage() { const target_id = Number((form.elements.namedItem('target_id') as HTMLSelectElement).value) const schedule = (form.elements.namedItem('schedule') as HTMLInputElement).value.trim() const enabled = (form.elements.namedItem('enabled') as HTMLInputElement).checked + const s3_bucket = (form.elements.namedItem('s3_bucket') as HTMLInputElement).value.trim() + const s3_endpoint = (form.elements.namedItem('s3_endpoint') as HTMLInputElement).value.trim() + const s3_key_prefix = (form.elements.namedItem('s3_key_prefix') as HTMLInputElement).value.trim() if (!name || !schedule || !target_id) { setError('Name, target and schedule are required') return } setCreating(true) - createBackupPlan({ name, plan_type, target_id, schedule, enabled }) + createBackupPlan({ name, plan_type, target_id, schedule, enabled, s3_bucket, s3_endpoint, s3_key_prefix }) .then(() => { setShowCreate(false) form.reset() @@ -110,9 +116,21 @@ export function BackupPlansPage() { const target_id = Number((form.elements.namedItem('edit_target_id') as HTMLSelectElement).value) const schedule = (form.elements.namedItem('edit_schedule') as HTMLInputElement).value.trim() const enabled = (form.elements.namedItem('edit_enabled') as HTMLInputElement).checked + const s3_bucket = (form.elements.namedItem('edit_s3_bucket') as HTMLInputElement).value.trim() + const s3_endpoint = (form.elements.namedItem('edit_s3_endpoint') as HTMLInputElement).value.trim() + const s3_key_prefix = (form.elements.namedItem('edit_s3_key_prefix') as HTMLInputElement).value.trim() if (!name || !schedule || !target_id) return - updateBackupPlan(editPlan.id, { name, plan_type: editPlanType, target_id, schedule, enabled }) + updateBackupPlan(editPlan.id, { + name, + plan_type: editPlanType, + target_id, + schedule, + enabled, + s3_bucket, + s3_endpoint, + s3_key_prefix, + }) .then(() => { setEditPlan(null) loadPlans() @@ -188,7 +206,9 @@ export function BackupPlansPage() {

Schedule automated backups. Add a cron entry (e.g. 0 * * * * hourly) to call{' '} - POST /api/v1/backup/run-scheduled with your auth token. + POST /api/v1/backup/run-scheduled with your auth token. Optional S3-compatible upload uses{' '} + AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY in the panel environment when a bucket + name is set.

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

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

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

{diag.nginx_wizard.note}

+

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

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

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

+
+ { setShowDnsCf(true); setCfDomain(''); setCfEmail(''); setCfToken('') }}> + Cloudflare DNS-01 + + { + setManualDom('') + setManualOut(null) + }} + > + Clear manual help + +
+
+
+ + setManualDom(e.target.value)} + placeholder="example.com" + /> +
+
+ { + apiRequest<{ txt_record_name: string; certbot_example: string; note: string }>('/ssl/dns-request/manual-instructions', { + method: 'POST', + body: JSON.stringify({ domain: manualDom.trim() }), + }) + .then(setManualOut) + .catch((err) => setError(err.message)) + }} + > + Get TXT / certbot hint + +
+
+ {manualOut ? ( +
+
+ TXT name: {manualOut.txt_record_name} +
+
+ Example: {manualOut.certbot_example} +
+ {manualOut.note ?

{manualOut.note}

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

+ Source: {logPath} +

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

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

+ ) : null} +
+
+ setShowCreate(false)} centered> Create FTP Account diff --git a/YakPanel-server/frontend/src/pages/SecurityChecklistPage.tsx b/YakPanel-server/frontend/src/pages/SecurityChecklistPage.tsx new file mode 100644 index 00000000..930c98c0 --- /dev/null +++ b/YakPanel-server/frontend/src/pages/SecurityChecklistPage.tsx @@ -0,0 +1,71 @@ +import { useEffect, useState } from 'react' +import { apiRequest } from '../api/client' +import { PageHeader, AdminAlert } from '../components/admin' + +interface CheckItem { + id: string + ok: boolean | null + title: string + detail: string +} + +export function SecurityChecklistPage() { + const [items, setItems] = useState([]) + const [disclaimer, setDisclaimer] = useState('') + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + + useEffect(() => { + apiRequest<{ items: CheckItem[]; disclaimer?: string }>('/security/checklist') + .then((r) => { + setItems(r.items || []) + setDisclaimer(r.disclaimer || '') + }) + .catch((err) => setError(err.message)) + .finally(() => setLoading(false)) + }, []) + + if (loading) { + return ( + <> + +

Loading…

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

{disclaimer}

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

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

+
+ + +
+
+ + +
+

Directory HTTP auth (requires htpasswd file on server).

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