Initial commit
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
# ─── امنیت (حتماً عوض کنید) ───
|
||||
SECRET_KEY=change-me-to-a-long-random-string-min-32-chars
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=change-me-strong-password
|
||||
|
||||
# ─── دامنه ───
|
||||
MIRROR_DOMAIN=mirror.itistan.ir
|
||||
|
||||
# ─── مسیرها (در container ثابت بماند) ───
|
||||
HOST_ROOT=/host
|
||||
DATA_DIR=/app/data
|
||||
|
||||
# ─── رفتار ───
|
||||
ROLLBACK_MINUTES=15
|
||||
AUTO_TEST_INTERVAL_MINUTES=30
|
||||
FLASK_ENV=production
|
||||
|
||||
# ─── CLI روی host ───
|
||||
MIRROR_CONTAINER_NAME=mirror-manager
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.db
|
||||
.env
|
||||
data/
|
||||
*.log
|
||||
.venv/
|
||||
venv/
|
||||
dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
FROM python:3.12-slim-bookworm
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
git \
|
||||
util-linux \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
ENV HOST_ROOT=/host
|
||||
ENV DATA_DIR=/app/data
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
EXPOSE 8765
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --retries=3 \
|
||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8765/login')" || exit 1
|
||||
|
||||
CMD ["gunicorn", "-b", "0.0.0.0:8765", "-w", "2", "--timeout", "180", "run:app"]
|
||||
@@ -0,0 +1,192 @@
|
||||
# Mirror Manager
|
||||
|
||||
پنل وب فارسی برای مدیریت DNS، میرور APT، Docker Registry، GitHub و pip/npm روی Ubuntu (فوکوس 24.04).
|
||||
|
||||
## قابلیتها
|
||||
|
||||
- CRUD میرورها و پروفایلها
|
||||
- اعمال سراسری تنظیمات روی host (systemd-resolved، apt، docker، git)
|
||||
- تست latency و دسترسی میرورها
|
||||
- Backup خودکار (اولیه + قبل از هر apply)
|
||||
- Rollback با پنجره زمانی (پیشفرض ۱۵ دقیقه)
|
||||
- Auto-switch اختیاری per-category
|
||||
- راهنمای فارسی برای هر بخش
|
||||
- CLI برای SSH: restore، enable، disable
|
||||
- **بدون تغییر SSH یا فایروال**
|
||||
|
||||
---
|
||||
|
||||
## پیشنیاز
|
||||
|
||||
- Ubuntu 18.04 / 22.04 / 24.04 (فوکوس noble)
|
||||
- Docker + Dokploy + Traefik
|
||||
- دسترسی root/privileged برای container
|
||||
|
||||
---
|
||||
|
||||
## نصب روی Dokploy
|
||||
|
||||
### ۱. Clone پروژه روی سرور
|
||||
|
||||
```bash
|
||||
git clone <repo-url> /opt/mirror-manager
|
||||
cd /opt/mirror-manager
|
||||
```
|
||||
|
||||
### ۲. تنظیم `.env` (فقط برای deploy دستی؛ در Dokploy از Environment UI استفاده کنید)
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
nano .env
|
||||
```
|
||||
|
||||
| متغیر | مقدار |
|
||||
|-------|-------|
|
||||
| `MIRROR_DOMAIN` | `mirror.itistan.ir` |
|
||||
| `SECRET_KEY` | رشته تصادفی ۳۲+ کاراکتر |
|
||||
| `ADMIN_PASSWORD` | رمز قوی |
|
||||
|
||||
### ۳. Repo Gitea
|
||||
|
||||
```
|
||||
https://git.itistan.ir/mohammadian7/mirror
|
||||
```
|
||||
|
||||
راهنمای کامل Dokploy: [deploy/DOKPLOY.md](deploy/DOKPLOY.md)
|
||||
|
||||
---
|
||||
|
||||
## نصب روی Dokploy (خلاصه)
|
||||
|
||||
1. Push به Gitea → Deploy از Dokploy
|
||||
2. Environment variables را در Dokploy تنظیم کنید
|
||||
3. رکورد A: `mirror.itistan.ir` → IP سرور
|
||||
4. **https://mirror.itistan.ir**
|
||||
|
||||
---
|
||||
|
||||
## نصب دستی (بدون Dokploy)
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Volumeها:
|
||||
- `/etc` → `/host/etc` (برای apply)
|
||||
- `/var/lib/mirror-manager/data` → دیتابیس و backup
|
||||
|
||||
### ۴. Traefik / Dokploy
|
||||
|
||||
در Dokploy یک Application از نوع Docker Compose بسازید یا labelهای Traefik را در `docker-compose.yml` تنظیم کنید:
|
||||
|
||||
```yaml
|
||||
traefik.http.routers.mirror-itistan.rule=Host(`mirror.itistan.ir`)
|
||||
```
|
||||
|
||||
### ۵. نصب CLI روی host
|
||||
|
||||
```bash
|
||||
chmod +x mirror-manager deploy/install-cli.sh
|
||||
sudo cp mirror-manager /usr/local/bin/mirror-manager
|
||||
# PYTHONPATH را در /usr/local/bin/mirror-manager-wrapper یا:
|
||||
echo 'export PYTHONPATH=/opt/mirror-manager' | sudo tee /etc/profile.d/mirror-manager.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## استفاده
|
||||
|
||||
1. به **https://mirror.itistan.ir** بروید
|
||||
2. با `ADMIN_USERNAME` / `ADMIN_PASSWORD` وارد شوید
|
||||
3. در **اولین ورود** backup اولیه خودکار گرفته میشود
|
||||
4. یک **پروفایل** انتخاب و **اعمال** کنید
|
||||
5. در پنجره rollback تست کنید → **تأیید** یا **rollback**
|
||||
|
||||
---
|
||||
|
||||
## CLI (از SSH)
|
||||
|
||||
```bash
|
||||
# بازگردانی تنظیمات اولیه (قبل از نصب Mirror Manager)
|
||||
mirror-manager restore --initial
|
||||
|
||||
# بازگردانی یک backup مشخص
|
||||
mirror-manager restore --backup /var/lib/mirror-manager/data/backups/20250603_120000_before_apply_1
|
||||
|
||||
# غیرفعال / فعال container
|
||||
mirror-manager disable
|
||||
mirror-manager enable
|
||||
|
||||
# وضعیت
|
||||
mirror-manager status
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## پروفایلهای پیشفرض
|
||||
|
||||
| پروفایل | محتوا |
|
||||
|---------|--------|
|
||||
| آروان کامل | Shecan + Arvan APT/Docker + GitClone |
|
||||
| 403 + Shatel | 403 DNS + Shatel APT + GHProxy |
|
||||
| حداقلی | Shecan + Arvan Docker |
|
||||
|
||||
---
|
||||
|
||||
## میرورهای seed
|
||||
|
||||
### DNS
|
||||
Shecan، 403.online، Begzar، Electro + fallback Cloudflare/Google
|
||||
|
||||
### APT
|
||||
Arvan، Shatel، Ubuntu Archive
|
||||
|
||||
### Docker
|
||||
Arvan Docker Registry
|
||||
|
||||
### GitHub
|
||||
GitClone، GHProxy
|
||||
|
||||
---
|
||||
|
||||
## نکات امنیتی
|
||||
|
||||
- رمز admin را حتماً عوض کنید
|
||||
- `SECRET_KEY` تصادفی و طولانی
|
||||
- UI را فقط از طریق HTTPS (Traefik) در دسترس بگذارید
|
||||
- قبل از apply با **IP** به SSH وصل شوید (نه hostname)
|
||||
|
||||
---
|
||||
|
||||
## ساختار پروژه
|
||||
|
||||
```
|
||||
app/
|
||||
models.py # SQLite models
|
||||
services/ # apply, backup, test, dns, apt, docker, github
|
||||
routes/ # Flask blueprints
|
||||
templates/ # UI فارسی RTL
|
||||
guides.py # راهنمای هر بخش
|
||||
docker-compose.yml
|
||||
Dockerfile
|
||||
mirror-manager # CLI entry
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## توسعه محلی
|
||||
|
||||
```bash
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate # Windows: .venv\Scripts\activate
|
||||
pip install -r requirements.txt
|
||||
set DISABLE_SCHEDULER=1
|
||||
set HOST_ROOT=
|
||||
python run.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
import os
|
||||
|
||||
import click
|
||||
from flask import Flask
|
||||
from flask.cli import with_appcontext
|
||||
|
||||
from app.config import Config
|
||||
from app.extensions import db, login_manager
|
||||
from app.models import AdminUser
|
||||
from app.routes.apply import apply_bp, restore_bp, settings_bp
|
||||
from app.routes.auth import auth_bp
|
||||
from app.routes.main import main_bp
|
||||
from app.routes.mirrors import mirrors_bp
|
||||
from app.routes.profiles import profiles_bp
|
||||
from app.seed import seed_all
|
||||
from app.services.applier import ensure_initial_backup, restore_initial
|
||||
from app.services.backup import restore_backup
|
||||
from app.services.scheduler import init_scheduler
|
||||
|
||||
|
||||
def create_app(config_class=Config) -> Flask:
|
||||
app = Flask(__name__)
|
||||
app.config.from_object(config_class)
|
||||
Config.ensure_dirs()
|
||||
|
||||
db.init_app(app)
|
||||
login_manager.init_app(app)
|
||||
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(main_bp)
|
||||
app.register_blueprint(mirrors_bp)
|
||||
app.register_blueprint(profiles_bp)
|
||||
app.register_blueprint(apply_bp)
|
||||
app.register_blueprint(restore_bp)
|
||||
app.register_blueprint(settings_bp)
|
||||
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
seed_all()
|
||||
_ensure_admin_user()
|
||||
ensure_initial_backup()
|
||||
|
||||
if not app.config.get("TESTING") and not os.environ.get("DISABLE_SCHEDULER"):
|
||||
init_scheduler(app)
|
||||
|
||||
_register_cli(app)
|
||||
return app
|
||||
|
||||
|
||||
def _ensure_admin_user() -> None:
|
||||
if AdminUser.query.first():
|
||||
return
|
||||
user = AdminUser(username=Config.ADMIN_USERNAME)
|
||||
user.set_password(Config.ADMIN_PASSWORD)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def _register_cli(app: Flask) -> None:
|
||||
@app.cli.command("init-db")
|
||||
@with_appcontext
|
||||
def init_db():
|
||||
db.create_all()
|
||||
seed_all()
|
||||
_ensure_admin_user()
|
||||
ensure_initial_backup()
|
||||
click.echo("Database initialized.")
|
||||
|
||||
@app.cli.command("create-admin")
|
||||
@click.argument("username")
|
||||
@click.argument("password")
|
||||
@with_appcontext
|
||||
def create_admin(username, password):
|
||||
user = AdminUser.query.filter_by(username=username).first()
|
||||
if not user:
|
||||
user = AdminUser(username=username)
|
||||
db.session.add(user)
|
||||
user.set_password(password)
|
||||
db.session.commit()
|
||||
click.echo(f"Admin user '{username}' ready.")
|
||||
|
||||
|
||||
@click.group()
|
||||
def cli():
|
||||
"""Mirror Manager CLI — قابل استفاده از SSH"""
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.option("--initial", is_flag=True, help="بازگردانی تنظیمات اولیه")
|
||||
@click.option("--backup", default=None, help="مسیر backup مشخص")
|
||||
def restore(initial, backup):
|
||||
os.environ["DISABLE_SCHEDULER"] = "1"
|
||||
app = create_app()
|
||||
with app.app_context():
|
||||
if initial:
|
||||
result = restore_initial()
|
||||
elif backup:
|
||||
result = restore_backup(backup)
|
||||
else:
|
||||
click.echo("یکی از --initial یا --backup را مشخص کنید.")
|
||||
return
|
||||
if result.get("success"):
|
||||
click.echo("بازگردانی موفق:")
|
||||
for item in result.get("restored", []):
|
||||
click.echo(f" - {item}")
|
||||
else:
|
||||
click.echo(f"خطا: {result.get('error')}")
|
||||
|
||||
|
||||
@cli.command()
|
||||
def disable():
|
||||
container = os.environ.get("MIRROR_CONTAINER_NAME", "mirror-manager")
|
||||
import subprocess
|
||||
|
||||
result = subprocess.run(["docker", "stop", container], capture_output=True, text=True)
|
||||
if result.returncode == 0:
|
||||
click.echo(f"سرویس {container} متوقف شد.")
|
||||
else:
|
||||
click.echo(f"خطا: {result.stderr or result.stdout}")
|
||||
click.echo("دستی: docker stop mirror-manager")
|
||||
|
||||
|
||||
@cli.command()
|
||||
def enable():
|
||||
container = os.environ.get("MIRROR_CONTAINER_NAME", "mirror-manager")
|
||||
import subprocess
|
||||
|
||||
result = subprocess.run(["docker", "start", container], capture_output=True, text=True)
|
||||
if result.returncode == 0:
|
||||
click.echo(f"سرویس {container} راهاندازی شد.")
|
||||
else:
|
||||
click.echo(f"خطا: {result.stderr or result.stdout}")
|
||||
click.echo("دستی: docker compose up -d")
|
||||
|
||||
|
||||
@cli.command()
|
||||
def status():
|
||||
os.environ["DISABLE_SCHEDULER"] = "1"
|
||||
app = create_app()
|
||||
with app.app_context():
|
||||
from app.services.applier import get_system_status
|
||||
|
||||
info = get_system_status()
|
||||
click.echo(f"Ubuntu: {info.get('ubuntu_description')}")
|
||||
click.echo(f"Host root: {info.get('host_root')}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
cli()
|
||||
@@ -0,0 +1,34 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class Config:
|
||||
SECRET_KEY = os.environ.get("SECRET_KEY", "dev-secret-change-in-production")
|
||||
SQLALCHEMY_DATABASE_URI = os.environ.get(
|
||||
"DATABASE_URL",
|
||||
f"sqlite:///{Path(os.environ.get('DATA_DIR', 'data')).resolve() / 'mirror.db'}",
|
||||
)
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
|
||||
ADMIN_USERNAME = os.environ.get("ADMIN_USERNAME", "admin")
|
||||
ADMIN_PASSWORD = os.environ.get("ADMIN_PASSWORD", "admin")
|
||||
|
||||
DATA_DIR = Path(os.environ.get("DATA_DIR", "data"))
|
||||
BACKUP_DIR = DATA_DIR / "backups"
|
||||
HOST_ROOT = os.environ.get("HOST_ROOT", "")
|
||||
|
||||
ROLLBACK_MINUTES = int(os.environ.get("ROLLBACK_MINUTES", "15"))
|
||||
AUTO_TEST_INTERVAL_MINUTES = int(os.environ.get("AUTO_TEST_INTERVAL_MINUTES", "30"))
|
||||
|
||||
RESOLVED_DROPIN = "mirror-manager.conf"
|
||||
RESOLVED_DROPIN_DIR = "resolved.conf.d"
|
||||
GIT_CONFIG_GLOBAL = Path.home() / ".gitconfig"
|
||||
|
||||
@classmethod
|
||||
def host_path(cls, *parts: str) -> Path:
|
||||
return Path(cls.HOST_ROOT).joinpath(*parts)
|
||||
|
||||
@classmethod
|
||||
def ensure_dirs(cls) -> None:
|
||||
cls.DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
cls.BACKUP_DIR.mkdir(parents=True, exist_ok=True)
|
||||
@@ -0,0 +1,7 @@
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_login import LoginManager
|
||||
|
||||
db = SQLAlchemy()
|
||||
login_manager = LoginManager()
|
||||
login_manager.login_view = "auth.login"
|
||||
login_manager.login_message = "لطفاً وارد شوید."
|
||||
+187
@@ -0,0 +1,187 @@
|
||||
GUIDES = {
|
||||
"dns": {
|
||||
"title": "راهنمای DNS",
|
||||
"summary": "تنظیم DNS از طریق systemd-resolved",
|
||||
"content": """
|
||||
<h5>DNS چیست؟</h5>
|
||||
<p>DNS آدرس دامنهها (مثل github.com) را به IP تبدیل میکند. در ایران برای دسترسی به سرویسهای فیلترشده از DNSهای داخلی استفاده میشود.</p>
|
||||
|
||||
<h5>چه کاری انجام میدهد؟</h5>
|
||||
<ul>
|
||||
<li>یک فایل drop-in در <code>/etc/systemd/resolved.conf.d/mirror-manager.conf</code> ایجاد میکند</li>
|
||||
<li>سرویس <code>systemd-resolved</code> را restart میکند</li>
|
||||
<li>فایل SSH دستنخورده میماند</li>
|
||||
</ul>
|
||||
|
||||
<h5>DNSهای پیشفرض</h5>
|
||||
<ul>
|
||||
<li><strong>Shecan:</strong> 178.22.122.100, 185.51.200.2</li>
|
||||
<li><strong>403.online:</strong> 10.202.10.202, 10.202.10.102</li>
|
||||
<li><strong>Begzar:</strong> 185.55.226.26, 185.55.227.27</li>
|
||||
</ul>
|
||||
|
||||
<h5>نکات مهم</h5>
|
||||
<ul>
|
||||
<li>اگر با IP به SSH وصل میشوید، تغییر DNS معمولاً SSH را قطع نمیکند</li>
|
||||
<li>قبل از apply، backup خودکار گرفته میشود</li>
|
||||
<li>پنجره rollback به شما اجازه تست میدهد</li>
|
||||
</ul>
|
||||
""",
|
||||
},
|
||||
"apt": {
|
||||
"title": "راهنمای APT",
|
||||
"summary": "تنظیم مخزن Ubuntu برای apt update/install",
|
||||
"content": """
|
||||
<h5>APT چیست؟</h5>
|
||||
<p>مدیر بسته Ubuntu. برای نصب و آپدیت پکیجهای سیستم از مخزن (mirror) استفاده میکند.</p>
|
||||
|
||||
<h5>چه کاری انجام میدهد؟</h5>
|
||||
<ul>
|
||||
<li>فایل <code>/etc/apt/sources.list</code> را با میرور انتخابی جایگزین میکند</li>
|
||||
<li>codename سیستم (noble/jammy/bionic) را خودکار تشخیص میدهد</li>
|
||||
<li>دستور <code>apt-get update</code> را برای تست اجرا میکند</li>
|
||||
</ul>
|
||||
|
||||
<h5>میرورهای پیشفرض</h5>
|
||||
<ul>
|
||||
<li><strong>Arvan:</strong> mirror.arvancloud.ir/ubuntu</li>
|
||||
<li><strong>Shatel:</strong> mirror.shatel.ir/ubuntu</li>
|
||||
</ul>
|
||||
|
||||
<h5>نسخههای Ubuntu</h5>
|
||||
<ul>
|
||||
<li>24.04 → noble (فوکوس اصلی)</li>
|
||||
<li>22.04 → jammy</li>
|
||||
<li>18.04 → bionic</li>
|
||||
</ul>
|
||||
""",
|
||||
},
|
||||
"docker": {
|
||||
"title": "راهنمای Docker Registry",
|
||||
"summary": "تنظیم registry-mirrors برای pull ایمیج",
|
||||
"content": """
|
||||
<h5>Registry Mirror چیست؟</h5>
|
||||
<p>وقتی <code>docker pull</code> میزنید، درخواست از mirror ایرانی عبور میکند و سرعت بیشتر و قطعی کمتر میشود.</p>
|
||||
|
||||
<h5>چه کاری انجام میدهد؟</h5>
|
||||
<ul>
|
||||
<li>فایل <code>/etc/docker/daemon.json</code> را ویرایش میکند</li>
|
||||
<li>کلید <code>registry-mirrors</code> را تنظیم میکند</li>
|
||||
<li>سرویس Docker را restart میکند</li>
|
||||
</ul>
|
||||
|
||||
<h5>هشدار</h5>
|
||||
<p>با restart Docker، containerها (از جمله Dokploy) چند ثانیه قطع میشوند. بهتر است در ساعات کمترافیک apply کنید.</p>
|
||||
|
||||
<h5>میرور پیشفرض</h5>
|
||||
<ul>
|
||||
<li><strong>Arvan:</strong> docker.arvancloud.ir</li>
|
||||
</ul>
|
||||
""",
|
||||
},
|
||||
"github": {
|
||||
"title": "راهنمای GitHub",
|
||||
"summary": "تنظیم git برای clone/pull از GitHub",
|
||||
"content": """
|
||||
<h5>مشکل GitHub</h5>
|
||||
<p>معمولاً <code>git pull</code> و <code>git clone</code> از GitHub کند یا timeout میشود.</p>
|
||||
|
||||
<h5>چه کاری انجام میدهد؟</h5>
|
||||
<ul>
|
||||
<li>در <code>~/.gitconfig</code> یک rule از نوع <code>insteadOf</code> اضافه میکند</li>
|
||||
<li>آدرس github.com به mirror انتخابی redirect میشود</li>
|
||||
<li>SSH clone (git@github.com) هم به HTTPS mirror تبدیل میشود</li>
|
||||
</ul>
|
||||
|
||||
<h5>تست</h5>
|
||||
<p>بعد از apply، دستور <code>git ls-remote</code> روی repo نمونه اجرا میشود.</p>
|
||||
|
||||
<h5>میرورهای پیشفرض</h5>
|
||||
<ul>
|
||||
<li><strong>GitClone:</strong> gitclone.com/github.com</li>
|
||||
<li><strong>GHProxy:</strong> mirror.ghproxy.com</li>
|
||||
</ul>
|
||||
""",
|
||||
},
|
||||
"pip": {
|
||||
"title": "راهنمای pip",
|
||||
"summary": "تنظیم index-url برای pip install",
|
||||
"content": """
|
||||
<h5>pip چیست؟</h5>
|
||||
<p>مدیر بسته Python. فایل <code>/etc/pip.conf</code> برای تنظیم سراسری استفاده میشود.</p>
|
||||
|
||||
<h5>میرور پیشفرض</h5>
|
||||
<ul>
|
||||
<li><strong>Arvan PyPI:</strong> mirror.arvancloud.ir/pypi/simple</li>
|
||||
</ul>
|
||||
""",
|
||||
},
|
||||
"npm": {
|
||||
"title": "راهنمای npm",
|
||||
"summary": "تنظیم registry برای npm install",
|
||||
"content": """
|
||||
<h5>npm چیست؟</h5>
|
||||
<p>مدیر بسته Node.js. فایل <code>/root/.npmrc</code> برای تنظیم registry سراسری استفاده میشود.</p>
|
||||
""",
|
||||
},
|
||||
"profiles": {
|
||||
"title": "راهنمای پروفایلها",
|
||||
"summary": "ترکیب چند تنظیم در یک کلیک",
|
||||
"content": """
|
||||
<h5>پروفایل چیست؟</h5>
|
||||
<p>مجموعهای از میرورها برای DNS، APT، Docker، GitHub و... که با یک کلیک apply میشوند.</p>
|
||||
|
||||
<h5>پروفایلهای پیشفرض</h5>
|
||||
<ul>
|
||||
<li><strong>آروان کامل:</strong> Shecan + Arvan APT/Docker + GitClone</li>
|
||||
<li><strong>403 + Shatel:</strong> DNS 403 + Shatel APT</li>
|
||||
<li><strong>حداقلی:</strong> فقط DNS + Docker</li>
|
||||
</ul>
|
||||
|
||||
<h5>Rollback</h5>
|
||||
<p>بعد از apply، پنجره زمانی (پیشفرض ۱۵ دقیقه) برای تست دارید. اگر تأیید نکنید، تنظیمات قبلی بازگردانده میشود.</p>
|
||||
""",
|
||||
},
|
||||
"settings": {
|
||||
"title": "راهنمای تنظیمات",
|
||||
"summary": "Auto-switch و rollback",
|
||||
"content": """
|
||||
<h5>Auto-switch</h5>
|
||||
<p>اگر فعال باشد، برنامه به صورت دورهای میرور فعال هر بخش را تست میکند. در صورت fail، به بهترین میرور جایگزین switch میکند.</p>
|
||||
|
||||
<h5>Rollback خودکار</h5>
|
||||
<p>بعد از apply، تا پایان پنجره زمانی میتوانید تأیید کنید. در غیر این صورت backup قبلی restore میشود.</p>
|
||||
|
||||
<h5>محدودیت switch</h5>
|
||||
<p>حداکثر تعداد switch خودکار در روز قابل تنظیم است (پیشفرض: ۳).</p>
|
||||
""",
|
||||
},
|
||||
"restore": {
|
||||
"title": "راهنمای بازگردانی",
|
||||
"summary": "Restore backup اولیه یا backupهای قبلی",
|
||||
"content": """
|
||||
<h5>Backup اولیه</h5>
|
||||
<p>در اولین اجرا، snapshot از تنظیمات فعلی سیستم گرفته میشود.</p>
|
||||
|
||||
<h5>بازگردانی</h5>
|
||||
<ul>
|
||||
<li><strong>تنظیمات اولیه:</strong> برگشت به وضعیت قبل از نصب Mirror Manager</li>
|
||||
<li><strong>backup apply:</strong> برگشت به وضعیت قبل از آخرین apply</li>
|
||||
</ul>
|
||||
|
||||
<h5>از SSH</h5>
|
||||
<pre>mirror-manager restore --initial
|
||||
mirror-manager restore --backup /path/to/backup
|
||||
mirror-manager disable
|
||||
mirror-manager enable</pre>
|
||||
""",
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "راهنمای داشبورد",
|
||||
"summary": "نمای کلی وضعیت سیستم",
|
||||
"content": """
|
||||
<h5>داشبورد</h5>
|
||||
<p>وضعیت فعلی DNS، APT، Docker و GitHub را نشان میدهد. آخرین apply و rollback pending هم نمایش داده میشود.</p>
|
||||
""",
|
||||
},
|
||||
}
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.extensions import db
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class Mirror(db.Model):
|
||||
__tablename__ = "mirrors"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
category = db.Column(db.String(32), nullable=False, index=True)
|
||||
name = db.Column(db.String(128), nullable=False)
|
||||
url = db.Column(db.String(512), nullable=True)
|
||||
ips = db.Column(db.Text, nullable=True)
|
||||
priority = db.Column(db.Integer, default=100, nullable=False)
|
||||
enabled = db.Column(db.Boolean, default=True, nullable=False)
|
||||
notes = db.Column(db.Text, nullable=True)
|
||||
meta_json = db.Column(db.Text, nullable=True)
|
||||
created_at = db.Column(db.DateTime, default=utcnow, nullable=False)
|
||||
updated_at = db.Column(db.DateTime, default=utcnow, onupdate=utcnow, nullable=False)
|
||||
|
||||
test_results = db.relationship("TestResult", back_populates="mirror", cascade="all, delete-orphan")
|
||||
|
||||
CATEGORIES = ("dns", "apt", "docker", "github", "pip", "npm")
|
||||
|
||||
CATEGORY_LABELS = {
|
||||
"dns": "DNS",
|
||||
"apt": "مخزن APT",
|
||||
"docker": "رجیstry داکر",
|
||||
"github": "گیتهاب",
|
||||
"pip": "pip",
|
||||
"npm": "npm",
|
||||
}
|
||||
|
||||
def get_ips(self) -> list[str]:
|
||||
if not self.ips:
|
||||
return []
|
||||
return [ip.strip() for ip in self.ips.split(",") if ip.strip()]
|
||||
|
||||
def get_meta(self) -> dict:
|
||||
if not self.meta_json:
|
||||
return {}
|
||||
try:
|
||||
return json.loads(self.meta_json)
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
|
||||
def set_meta(self, data: dict) -> None:
|
||||
self.meta_json = json.dumps(data, ensure_ascii=False)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Mirror {self.category}:{self.name}>"
|
||||
|
||||
|
||||
class Profile(db.Model):
|
||||
__tablename__ = "profiles"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(128), nullable=False, unique=True)
|
||||
description = db.Column(db.Text, nullable=True)
|
||||
is_default = db.Column(db.Boolean, default=False, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=utcnow, nullable=False)
|
||||
|
||||
items = db.relationship("ProfileItem", back_populates="profile", cascade="all, delete-orphan")
|
||||
apply_logs = db.relationship("ApplyLog", back_populates="profile")
|
||||
|
||||
def mirrors_by_category(self) -> dict[str, Mirror]:
|
||||
result: dict[str, Mirror] = {}
|
||||
for item in sorted(self.items, key=lambda x: x.order):
|
||||
if item.mirror and item.mirror.enabled:
|
||||
result[item.category] = item.mirror
|
||||
return result
|
||||
|
||||
|
||||
class ProfileItem(db.Model):
|
||||
__tablename__ = "profile_items"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
profile_id = db.Column(db.Integer, db.ForeignKey("profiles.id"), nullable=False)
|
||||
mirror_id = db.Column(db.Integer, db.ForeignKey("mirrors.id"), nullable=False)
|
||||
category = db.Column(db.String(32), nullable=False)
|
||||
order = db.Column(db.Integer, default=0, nullable=False)
|
||||
|
||||
profile = db.relationship("Profile", back_populates="items")
|
||||
mirror = db.relationship("Mirror")
|
||||
|
||||
|
||||
class Setting(db.Model):
|
||||
__tablename__ = "settings"
|
||||
|
||||
key = db.Column(db.String(64), primary_key=True)
|
||||
value = db.Column(db.Text, nullable=False)
|
||||
|
||||
@staticmethod
|
||||
def get(key: str, default: str | None = None) -> str | None:
|
||||
row = Setting.query.get(key)
|
||||
return row.value if row else default
|
||||
|
||||
@staticmethod
|
||||
def set(key: str, value: str) -> None:
|
||||
row = Setting.query.get(key)
|
||||
if row:
|
||||
row.value = value
|
||||
else:
|
||||
db.session.add(Setting(key=key, value=value))
|
||||
db.session.commit()
|
||||
|
||||
|
||||
class ApplyLog(db.Model):
|
||||
__tablename__ = "apply_logs"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
profile_id = db.Column(db.Integer, db.ForeignKey("profiles.id"), nullable=True)
|
||||
status = db.Column(db.String(32), nullable=False, default="pending")
|
||||
backup_path = db.Column(db.String(512), nullable=True)
|
||||
previous_backup_path = db.Column(db.String(512), nullable=True)
|
||||
details_json = db.Column(db.Text, nullable=True)
|
||||
rollback_at = db.Column(db.DateTime, nullable=True)
|
||||
confirmed = db.Column(db.Boolean, default=False, nullable=False)
|
||||
started_at = db.Column(db.DateTime, default=utcnow, nullable=False)
|
||||
finished_at = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
profile = db.relationship("Profile", back_populates="apply_logs")
|
||||
|
||||
def get_details(self) -> dict:
|
||||
if not self.details_json:
|
||||
return {}
|
||||
try:
|
||||
return json.loads(self.details_json)
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
|
||||
def set_details(self, data: dict) -> None:
|
||||
self.details_json = json.dumps(data, ensure_ascii=False)
|
||||
|
||||
|
||||
class TestResult(db.Model):
|
||||
__tablename__ = "test_results"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
mirror_id = db.Column(db.Integer, db.ForeignKey("mirrors.id"), nullable=False)
|
||||
test_type = db.Column(db.String(64), nullable=False)
|
||||
success = db.Column(db.Boolean, nullable=False)
|
||||
latency_ms = db.Column(db.Float, nullable=True)
|
||||
error = db.Column(db.Text, nullable=True)
|
||||
tested_at = db.Column(db.DateTime, default=utcnow, nullable=False)
|
||||
|
||||
mirror = db.relationship("Mirror", back_populates="test_results")
|
||||
|
||||
|
||||
class SystemState(db.Model):
|
||||
__tablename__ = "system_state"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
initial_backup_path = db.Column(db.String(512), nullable=True)
|
||||
initial_backup_done = db.Column(db.Boolean, default=False, nullable=False)
|
||||
current_profile_id = db.Column(db.Integer, db.ForeignKey("profiles.id"), nullable=True)
|
||||
pending_apply_log_id = db.Column(db.Integer, db.ForeignKey("apply_logs.id"), nullable=True)
|
||||
updated_at = db.Column(db.DateTime, default=utcnow, onupdate=utcnow, nullable=False)
|
||||
|
||||
current_profile = db.relationship("Profile", foreign_keys=[current_profile_id])
|
||||
pending_apply = db.relationship("ApplyLog", foreign_keys=[pending_apply_log_id])
|
||||
|
||||
|
||||
class AdminUser(db.Model):
|
||||
__tablename__ = "admin_users"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
username = db.Column(db.String(64), unique=True, nullable=False)
|
||||
password_hash = db.Column(db.String(256), nullable=False)
|
||||
|
||||
def check_password(self, password: str) -> bool:
|
||||
from werkzeug.security import check_password_hash
|
||||
|
||||
return check_password_hash(self.password_hash, password)
|
||||
|
||||
def set_password(self, password: str) -> None:
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
self.password_hash = generate_password_hash(password)
|
||||
|
||||
@property
|
||||
def is_authenticated(self) -> bool:
|
||||
return True
|
||||
|
||||
@property
|
||||
def is_active(self) -> bool:
|
||||
return True
|
||||
|
||||
@property
|
||||
def is_anonymous(self) -> bool:
|
||||
return False
|
||||
|
||||
def get_id(self) -> str:
|
||||
return str(self.id)
|
||||
@@ -0,0 +1,176 @@
|
||||
from flask import Blueprint, flash, jsonify, redirect, render_template, request, url_for
|
||||
from flask_login import login_required
|
||||
|
||||
from app.extensions import db
|
||||
from app.guides import GUIDES
|
||||
from app.models import ApplyLog, Profile, Setting, SystemState
|
||||
from app.models import utcnow
|
||||
from app.services.applier import apply_profile, confirm_apply, get_system_status, restore_initial, rollback_apply
|
||||
from app.services.backup import list_backups, restore_backup
|
||||
|
||||
apply_bp = Blueprint("apply", __name__, url_prefix="/apply")
|
||||
|
||||
|
||||
@apply_bp.route("/")
|
||||
@login_required
|
||||
def index():
|
||||
profiles = Profile.query.order_by(Profile.name).all()
|
||||
state = SystemState.query.first()
|
||||
pending = None
|
||||
if state and state.pending_apply_log_id:
|
||||
pending = ApplyLog.query.get(state.pending_apply_log_id)
|
||||
|
||||
recent_logs = ApplyLog.query.order_by(ApplyLog.started_at.desc()).limit(10).all()
|
||||
return render_template(
|
||||
"apply/index.html",
|
||||
profiles=profiles,
|
||||
pending=pending,
|
||||
recent_logs=recent_logs,
|
||||
guide=GUIDES.get("profiles", {}),
|
||||
)
|
||||
|
||||
|
||||
@apply_bp.route("/profile/<int:profile_id>", methods=["POST"])
|
||||
@login_required
|
||||
def apply(profile_id):
|
||||
profile = Profile.query.get_or_404(profile_id)
|
||||
rollback_minutes = int(request.form.get("rollback_minutes", Setting.get("rollback_minutes", "15") or "15"))
|
||||
|
||||
log = apply_profile(profile, rollback_minutes=rollback_minutes)
|
||||
if log.status == "failed":
|
||||
flash("اعمال پروفایل با خطا مواجه شد. جزئیات را بررسی کنید.", "danger")
|
||||
else:
|
||||
flash(
|
||||
f"پروفایل اعمال شد. {rollback_minutes} دقیقه برای تست دارید — سپس rollback خودکار انجام میشود.",
|
||||
"warning",
|
||||
)
|
||||
return redirect(url_for("apply.status", log_id=log.id))
|
||||
|
||||
|
||||
@apply_bp.route("/status/<int:log_id>")
|
||||
@login_required
|
||||
def status(log_id):
|
||||
log = ApplyLog.query.get_or_404(log_id)
|
||||
details = log.get_details()
|
||||
remaining_seconds = 0
|
||||
if log.rollback_at and not log.confirmed:
|
||||
remaining_seconds = max(0, int((log.rollback_at - utcnow()).total_seconds()))
|
||||
return render_template(
|
||||
"apply/status.html",
|
||||
log=log,
|
||||
details=details,
|
||||
remaining_seconds=remaining_seconds,
|
||||
)
|
||||
|
||||
|
||||
@apply_bp.route("/confirm/<int:log_id>", methods=["POST"])
|
||||
@login_required
|
||||
def confirm(log_id):
|
||||
confirm_apply(log_id)
|
||||
flash("تغییرات تأیید و ثبت شد.", "success")
|
||||
return redirect(url_for("main.dashboard"))
|
||||
|
||||
|
||||
@apply_bp.route("/rollback/<int:log_id>", methods=["POST"])
|
||||
@login_required
|
||||
def rollback(log_id):
|
||||
result = rollback_apply(log_id)
|
||||
if result.get("success"):
|
||||
flash("تنظیمات قبلی بازگردانده شد.", "info")
|
||||
else:
|
||||
flash(result.get("error", "خطا در rollback"), "danger")
|
||||
return redirect(url_for("main.dashboard"))
|
||||
|
||||
|
||||
@apply_bp.route("/api/pending/<int:log_id>")
|
||||
@login_required
|
||||
def pending_api(log_id):
|
||||
log = ApplyLog.query.get_or_404(log_id)
|
||||
remaining = 0
|
||||
if log.rollback_at and not log.confirmed:
|
||||
remaining = max(0, int((log.rollback_at - utcnow()).total_seconds()))
|
||||
return jsonify(
|
||||
{
|
||||
"status": log.status,
|
||||
"confirmed": log.confirmed,
|
||||
"remaining_seconds": remaining,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
restore_bp = Blueprint("restore", __name__, url_prefix="/restore")
|
||||
|
||||
|
||||
@restore_bp.route("/")
|
||||
@login_required
|
||||
def index():
|
||||
backups = list_backups()
|
||||
state = SystemState.query.first()
|
||||
return render_template(
|
||||
"restore/index.html",
|
||||
backups=backups,
|
||||
state=state,
|
||||
guide=GUIDES.get("restore", {}),
|
||||
)
|
||||
|
||||
|
||||
@restore_bp.route("/initial", methods=["POST"])
|
||||
@login_required
|
||||
def restore_initial_view():
|
||||
result = restore_initial()
|
||||
if result.get("success"):
|
||||
flash("تنظیمات اولیه بازگردانده شد.", "success")
|
||||
else:
|
||||
flash(result.get("error", "خطا"), "danger")
|
||||
return redirect(url_for("restore.index"))
|
||||
|
||||
|
||||
@restore_bp.route("/backup", methods=["POST"])
|
||||
@login_required
|
||||
def restore_backup_view():
|
||||
path = request.form.get("backup_path", "").strip()
|
||||
if not path:
|
||||
flash("مسیر backup انتخاب نشده.", "danger")
|
||||
return redirect(url_for("restore.index"))
|
||||
result = restore_backup(path)
|
||||
if result.get("success"):
|
||||
flash("backup بازگردانده شد.", "success")
|
||||
else:
|
||||
flash(result.get("error", "خطا"), "danger")
|
||||
return redirect(url_for("restore.index"))
|
||||
|
||||
|
||||
settings_bp = Blueprint("settings", __name__, url_prefix="/settings")
|
||||
|
||||
|
||||
@settings_bp.route("/", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def index():
|
||||
if request.method == "POST":
|
||||
for key in (
|
||||
"auto_switch_dns",
|
||||
"auto_switch_apt",
|
||||
"auto_switch_docker",
|
||||
"auto_switch_github",
|
||||
"auto_switch_pip",
|
||||
"auto_switch_npm",
|
||||
):
|
||||
Setting.set(key, "true" if request.form.get(key) == "on" else "false")
|
||||
|
||||
Setting.set("rollback_minutes", request.form.get("rollback_minutes", "15"))
|
||||
Setting.set("auto_test_interval_minutes", request.form.get("auto_test_interval_minutes", "30"))
|
||||
Setting.set("max_switches_per_day", request.form.get("max_switches_per_day", "3"))
|
||||
Setting.set(
|
||||
"rollback_on_all_fail",
|
||||
"true" if request.form.get("rollback_on_all_fail") == "on" else "false",
|
||||
)
|
||||
flash("تنظیمات ذخیره شد.", "success")
|
||||
return redirect(url_for("settings.index"))
|
||||
|
||||
settings = {key: Setting.get(key) for key in (
|
||||
"auto_switch_dns", "auto_switch_apt", "auto_switch_docker",
|
||||
"auto_switch_github", "auto_switch_pip", "auto_switch_npm",
|
||||
"rollback_minutes", "auto_test_interval_minutes",
|
||||
"max_switches_per_day", "rollback_on_all_fail",
|
||||
)}
|
||||
return render_template("settings/index.html", settings=settings, guide=GUIDES.get("settings", {}))
|
||||
@@ -0,0 +1,34 @@
|
||||
from flask import Blueprint, flash, redirect, render_template, request, url_for
|
||||
from flask_login import login_required, login_user, logout_user
|
||||
|
||||
from app.extensions import db, login_manager
|
||||
from app.models import AdminUser
|
||||
|
||||
auth_bp = Blueprint("auth", __name__)
|
||||
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id: str):
|
||||
return AdminUser.query.get(int(user_id))
|
||||
|
||||
|
||||
@auth_bp.route("/login", methods=["GET", "POST"])
|
||||
def login():
|
||||
if request.method == "POST":
|
||||
username = request.form.get("username", "").strip()
|
||||
password = request.form.get("password", "")
|
||||
user = AdminUser.query.filter_by(username=username).first()
|
||||
if user and user.check_password(password):
|
||||
login_user(user)
|
||||
next_page = request.args.get("next") or url_for("main.dashboard")
|
||||
return redirect(next_page)
|
||||
flash("نام کاربری یا رمز عبور اشتباه است.", "danger")
|
||||
return render_template("auth/login.html")
|
||||
|
||||
|
||||
@auth_bp.route("/logout")
|
||||
@login_required
|
||||
def logout():
|
||||
logout_user()
|
||||
flash("با موفقیت خارج شدید.", "info")
|
||||
return redirect(url_for("auth.login"))
|
||||
@@ -0,0 +1,41 @@
|
||||
from flask import Blueprint, render_template
|
||||
from flask_login import login_required
|
||||
|
||||
from app.guides import GUIDES
|
||||
from app.models import ApplyLog, SystemState, TestResult
|
||||
from app.services.applier import get_system_status
|
||||
|
||||
main_bp = Blueprint("main", __name__)
|
||||
|
||||
|
||||
@main_bp.route("/")
|
||||
@login_required
|
||||
def dashboard():
|
||||
state = SystemState.query.first()
|
||||
pending = None
|
||||
if state and state.pending_apply_log_id:
|
||||
pending = ApplyLog.query.get(state.pending_apply_log_id)
|
||||
|
||||
recent_tests = (
|
||||
TestResult.query.order_by(TestResult.tested_at.desc()).limit(10).all()
|
||||
)
|
||||
status = get_system_status()
|
||||
guide = GUIDES.get("dashboard", {})
|
||||
|
||||
return render_template(
|
||||
"dashboard.html",
|
||||
status=status,
|
||||
state=state,
|
||||
pending=pending,
|
||||
recent_tests=recent_tests,
|
||||
guide=guide,
|
||||
)
|
||||
|
||||
|
||||
@main_bp.route("/guide/<section>")
|
||||
@login_required
|
||||
def guide(section):
|
||||
guide_data = GUIDES.get(section)
|
||||
if not guide_data:
|
||||
guide_data = {"title": "راهنما", "summary": "", "content": "<p>راهنما یافت نشد.</p>"}
|
||||
return render_template("guide.html", guide=guide_data, section=section)
|
||||
@@ -0,0 +1,133 @@
|
||||
from flask import Blueprint, flash, redirect, render_template, request, url_for
|
||||
from flask_login import login_required
|
||||
|
||||
from app.extensions import db
|
||||
from app.guides import GUIDES
|
||||
from app.models import Mirror, Profile, ProfileItem, TestResult
|
||||
from app.services.tester import test_all_enabled, test_mirror
|
||||
|
||||
mirrors_bp = Blueprint("mirrors", __name__, url_prefix="/mirrors")
|
||||
|
||||
|
||||
@mirrors_bp.route("/")
|
||||
@login_required
|
||||
def list_mirrors():
|
||||
category = request.args.get("category")
|
||||
query = Mirror.query.order_by(Mirror.category, Mirror.priority)
|
||||
if category:
|
||||
query = query.filter_by(category=category)
|
||||
mirrors = query.all()
|
||||
categories = Mirror.CATEGORIES
|
||||
return render_template(
|
||||
"mirrors/list.html",
|
||||
mirrors=mirrors,
|
||||
categories=categories,
|
||||
category_labels=Mirror.CATEGORY_LABELS,
|
||||
current_category=category,
|
||||
guides=GUIDES,
|
||||
)
|
||||
|
||||
|
||||
@mirrors_bp.route("/create", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def create():
|
||||
if request.method == "POST":
|
||||
mirror = Mirror(
|
||||
category=request.form["category"],
|
||||
name=request.form["name"].strip(),
|
||||
url=request.form.get("url", "").strip() or None,
|
||||
ips=request.form.get("ips", "").strip() or None,
|
||||
priority=int(request.form.get("priority", 100)),
|
||||
enabled=request.form.get("enabled") == "on",
|
||||
notes=request.form.get("notes", "").strip() or None,
|
||||
)
|
||||
meta_prefix = request.form.get("instead_prefix", "").strip()
|
||||
if meta_prefix:
|
||||
mirror.set_meta({"instead_prefix": meta_prefix})
|
||||
db.session.add(mirror)
|
||||
db.session.commit()
|
||||
flash("میرور با موفقیت ایجاد شد.", "success")
|
||||
return redirect(url_for("mirrors.list_mirrors"))
|
||||
|
||||
return render_template(
|
||||
"mirrors/form.html",
|
||||
mirror=None,
|
||||
categories=Mirror.CATEGORIES,
|
||||
category_labels=Mirror.CATEGORY_LABELS,
|
||||
)
|
||||
|
||||
|
||||
@mirrors_bp.route("/<int:mirror_id>/edit", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def edit(mirror_id):
|
||||
mirror = Mirror.query.get_or_404(mirror_id)
|
||||
if request.method == "POST":
|
||||
mirror.category = request.form["category"]
|
||||
mirror.name = request.form["name"].strip()
|
||||
mirror.url = request.form.get("url", "").strip() or None
|
||||
mirror.ips = request.form.get("ips", "").strip() or None
|
||||
mirror.priority = int(request.form.get("priority", 100))
|
||||
mirror.enabled = request.form.get("enabled") == "on"
|
||||
mirror.notes = request.form.get("notes", "").strip() or None
|
||||
meta_prefix = request.form.get("instead_prefix", "").strip()
|
||||
if meta_prefix:
|
||||
mirror.set_meta({"instead_prefix": meta_prefix})
|
||||
db.session.commit()
|
||||
flash("میرور بهروزرسانی شد.", "success")
|
||||
return redirect(url_for("mirrors.list_mirrors"))
|
||||
|
||||
return render_template(
|
||||
"mirrors/form.html",
|
||||
mirror=mirror,
|
||||
categories=Mirror.CATEGORIES,
|
||||
category_labels=Mirror.CATEGORY_LABELS,
|
||||
)
|
||||
|
||||
|
||||
@mirrors_bp.route("/<int:mirror_id>/delete", methods=["POST"])
|
||||
@login_required
|
||||
def delete(mirror_id):
|
||||
mirror = Mirror.query.get_or_404(mirror_id)
|
||||
db.session.delete(mirror)
|
||||
db.session.commit()
|
||||
flash("میرور حذف شد.", "warning")
|
||||
return redirect(url_for("mirrors.list_mirrors"))
|
||||
|
||||
|
||||
@mirrors_bp.route("/<int:mirror_id>/test", methods=["POST"])
|
||||
@login_required
|
||||
def test_one(mirror_id):
|
||||
mirror = Mirror.query.get_or_404(mirror_id)
|
||||
results = test_mirror(mirror)
|
||||
ok = all(r.success for r in results)
|
||||
flash(
|
||||
f"تست {mirror.name}: {'موفق' if ok else 'ناموفق'}",
|
||||
"success" if ok else "danger",
|
||||
)
|
||||
return redirect(url_for("mirrors.list_mirrors", category=mirror.category))
|
||||
|
||||
|
||||
@mirrors_bp.route("/test-all", methods=["POST"])
|
||||
@login_required
|
||||
def test_all():
|
||||
category = request.form.get("category") or None
|
||||
test_all_enabled(category)
|
||||
flash("تست همه میرورها انجام شد.", "info")
|
||||
return redirect(url_for("mirrors.list_mirrors", category=category))
|
||||
|
||||
|
||||
@mirrors_bp.route("/results")
|
||||
@login_required
|
||||
def results():
|
||||
category = request.args.get("category")
|
||||
query = TestResult.query.join(Mirror).order_by(TestResult.tested_at.desc())
|
||||
if category:
|
||||
query = query.filter(Mirror.category == category)
|
||||
results_list = query.limit(100).all()
|
||||
return render_template(
|
||||
"mirrors/results.html",
|
||||
results=results_list,
|
||||
category_labels=Mirror.CATEGORY_LABELS,
|
||||
current_category=category,
|
||||
categories=Mirror.CATEGORIES,
|
||||
)
|
||||
@@ -0,0 +1,117 @@
|
||||
from flask import Blueprint, flash, redirect, render_template, request, url_for
|
||||
from flask_login import login_required
|
||||
|
||||
from app.extensions import db
|
||||
from app.guides import GUIDES
|
||||
from app.models import Mirror, Profile, ProfileItem
|
||||
|
||||
profiles_bp = Blueprint("profiles", __name__, url_prefix="/profiles")
|
||||
|
||||
|
||||
@profiles_bp.route("/")
|
||||
@login_required
|
||||
def list_profiles():
|
||||
profiles = Profile.query.order_by(Profile.name).all()
|
||||
return render_template("profiles/list.html", profiles=profiles, guide=GUIDES.get("profiles", {}))
|
||||
|
||||
|
||||
@profiles_bp.route("/create", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def create():
|
||||
mirrors = Mirror.query.filter_by(enabled=True).order_by(Mirror.category, Mirror.priority).all()
|
||||
mirrors_by_cat: dict[str, list] = {}
|
||||
for m in mirrors:
|
||||
mirrors_by_cat.setdefault(m.category, []).append(m)
|
||||
|
||||
if request.method == "POST":
|
||||
profile = Profile(
|
||||
name=request.form["name"].strip(),
|
||||
description=request.form.get("description", "").strip() or None,
|
||||
is_default=request.form.get("is_default") == "on",
|
||||
)
|
||||
if profile.is_default:
|
||||
Profile.query.update({Profile.is_default: False})
|
||||
db.session.add(profile)
|
||||
db.session.flush()
|
||||
|
||||
for category in Mirror.CATEGORIES:
|
||||
mirror_id = request.form.get(f"mirror_{category}")
|
||||
if mirror_id:
|
||||
db.session.add(
|
||||
ProfileItem(
|
||||
profile_id=profile.id,
|
||||
mirror_id=int(mirror_id),
|
||||
category=category,
|
||||
order=0,
|
||||
)
|
||||
)
|
||||
db.session.commit()
|
||||
flash("پروفایل ایجاد شد.", "success")
|
||||
return redirect(url_for("profiles.list_profiles"))
|
||||
|
||||
return render_template(
|
||||
"profiles/form.html",
|
||||
profile=None,
|
||||
mirrors_by_cat=mirrors_by_cat,
|
||||
categories=Mirror.CATEGORIES,
|
||||
category_labels=Mirror.CATEGORY_LABELS,
|
||||
current={},
|
||||
guide=GUIDES.get("profiles", {}),
|
||||
)
|
||||
|
||||
|
||||
@profiles_bp.route("/<int:profile_id>/edit", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def edit(profile_id):
|
||||
profile = Profile.query.get_or_404(profile_id)
|
||||
mirrors = Mirror.query.filter_by(enabled=True).order_by(Mirror.category, Mirror.priority).all()
|
||||
mirrors_by_cat: dict[str, list] = {}
|
||||
for m in mirrors:
|
||||
mirrors_by_cat.setdefault(m.category, []).append(m)
|
||||
|
||||
current = {item.category: item.mirror_id for item in profile.items}
|
||||
|
||||
if request.method == "POST":
|
||||
profile.name = request.form["name"].strip()
|
||||
profile.description = request.form.get("description", "").strip() or None
|
||||
if request.form.get("is_default") == "on":
|
||||
Profile.query.filter(Profile.id != profile.id).update({Profile.is_default: False})
|
||||
profile.is_default = True
|
||||
else:
|
||||
profile.is_default = False
|
||||
|
||||
ProfileItem.query.filter_by(profile_id=profile.id).delete()
|
||||
for category in Mirror.CATEGORIES:
|
||||
mirror_id = request.form.get(f"mirror_{category}")
|
||||
if mirror_id:
|
||||
db.session.add(
|
||||
ProfileItem(
|
||||
profile_id=profile.id,
|
||||
mirror_id=int(mirror_id),
|
||||
category=category,
|
||||
order=0,
|
||||
)
|
||||
)
|
||||
db.session.commit()
|
||||
flash("پروفایل بهروزرسانی شد.", "success")
|
||||
return redirect(url_for("profiles.list_profiles"))
|
||||
|
||||
return render_template(
|
||||
"profiles/form.html",
|
||||
profile=profile,
|
||||
mirrors_by_cat=mirrors_by_cat,
|
||||
categories=Mirror.CATEGORIES,
|
||||
category_labels=Mirror.CATEGORY_LABELS,
|
||||
current=current,
|
||||
guide=GUIDES.get("profiles", {}),
|
||||
)
|
||||
|
||||
|
||||
@profiles_bp.route("/<int:profile_id>/delete", methods=["POST"])
|
||||
@login_required
|
||||
def delete(profile_id):
|
||||
profile = Profile.query.get_or_404(profile_id)
|
||||
db.session.delete(profile)
|
||||
db.session.commit()
|
||||
flash("پروفایل حذف شد.", "warning")
|
||||
return redirect(url_for("profiles.list_profiles"))
|
||||
+218
@@ -0,0 +1,218 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from app.extensions import db
|
||||
from app.models import Mirror, Profile, ProfileItem
|
||||
|
||||
|
||||
def seed_mirrors() -> None:
|
||||
if Mirror.query.first():
|
||||
return
|
||||
|
||||
mirrors = [
|
||||
# DNS
|
||||
Mirror(
|
||||
category="dns",
|
||||
name="Shecan",
|
||||
ips="178.22.122.100,185.51.200.2",
|
||||
priority=10,
|
||||
notes="DNS شکن — مناسب دسترسی به سرویسهای فیلترشده",
|
||||
),
|
||||
Mirror(
|
||||
category="dns",
|
||||
name="403.online",
|
||||
ips="10.202.10.202,10.202.10.102",
|
||||
priority=20,
|
||||
notes="DNS رادار / 403",
|
||||
),
|
||||
Mirror(
|
||||
category="dns",
|
||||
name="Begzar",
|
||||
ips="185.55.226.26,185.55.227.27",
|
||||
priority=30,
|
||||
notes="DNS community",
|
||||
),
|
||||
Mirror(
|
||||
category="dns",
|
||||
name="Electro",
|
||||
ips="10.10.34.36,10.10.34.35",
|
||||
priority=40,
|
||||
notes="DNS Electro",
|
||||
),
|
||||
Mirror(
|
||||
category="dns",
|
||||
name="Cloudflare",
|
||||
ips="1.1.1.1,1.0.0.1",
|
||||
priority=90,
|
||||
notes="fallback بینالمللی",
|
||||
),
|
||||
Mirror(
|
||||
category="dns",
|
||||
name="Google DNS",
|
||||
ips="8.8.8.8,8.8.4.4",
|
||||
priority=95,
|
||||
notes="fallback بینالمللی",
|
||||
),
|
||||
# APT
|
||||
Mirror(
|
||||
category="apt",
|
||||
name="Arvan Ubuntu",
|
||||
url="https://mirror.arvancloud.ir/ubuntu",
|
||||
priority=10,
|
||||
notes="میرور ابر آروان — Ubuntu",
|
||||
),
|
||||
Mirror(
|
||||
category="apt",
|
||||
name="Shatel Ubuntu",
|
||||
url="https://mirror.shatel.ir/ubuntu",
|
||||
priority=20,
|
||||
notes="میرور شاتل",
|
||||
),
|
||||
Mirror(
|
||||
category="apt",
|
||||
name="Ubuntu Archive",
|
||||
url="http://archive.ubuntu.com/ubuntu",
|
||||
priority=100,
|
||||
notes="مخزن رسمی — fallback",
|
||||
enabled=False,
|
||||
),
|
||||
# Docker
|
||||
Mirror(
|
||||
category="docker",
|
||||
name="Arvan Docker Registry",
|
||||
url="https://docker.arvancloud.ir",
|
||||
priority=10,
|
||||
notes="رجیstry mirror ابر آروان",
|
||||
),
|
||||
Mirror(
|
||||
category="docker",
|
||||
name="Docker Hub Direct",
|
||||
url="https://registry-1.docker.io",
|
||||
priority=100,
|
||||
notes="رجیstry مستقیم — fallback",
|
||||
enabled=False,
|
||||
),
|
||||
# GitHub
|
||||
Mirror(
|
||||
category="github",
|
||||
name="GitClone",
|
||||
url="https://gitclone.com/github.com",
|
||||
priority=10,
|
||||
notes="پروکسی clone/pull گیتهاب",
|
||||
meta_json=json.dumps({"instead_prefix": "https://gitclone.com/github.com/"}, ensure_ascii=False),
|
||||
),
|
||||
Mirror(
|
||||
category="github",
|
||||
name="GHProxy",
|
||||
url="https://mirror.ghproxy.com/https://github.com",
|
||||
priority=20,
|
||||
notes="پروکسی ghproxy",
|
||||
meta_json=json.dumps({"instead_prefix": "https://mirror.ghproxy.com/https://github.com/"}, ensure_ascii=False),
|
||||
),
|
||||
Mirror(
|
||||
category="github",
|
||||
name="GitHub Direct",
|
||||
url="https://github.com",
|
||||
priority=100,
|
||||
notes="دسترسی مستقیم",
|
||||
enabled=False,
|
||||
),
|
||||
# pip
|
||||
Mirror(
|
||||
category="pip",
|
||||
name="Arvan PyPI",
|
||||
url="https://mirror.arvancloud.ir/pypi/simple",
|
||||
priority=10,
|
||||
notes="میرور pip آروان",
|
||||
),
|
||||
Mirror(
|
||||
category="pip",
|
||||
name="PyPI Official",
|
||||
url="https://pypi.org/simple",
|
||||
priority=100,
|
||||
notes="مخزن رسمی",
|
||||
enabled=False,
|
||||
),
|
||||
# npm
|
||||
Mirror(
|
||||
category="npm",
|
||||
name="npm Official",
|
||||
url="https://registry.npmjs.org",
|
||||
priority=100,
|
||||
notes="رجیstry رسمی npm",
|
||||
enabled=False,
|
||||
),
|
||||
]
|
||||
|
||||
for mirror in mirrors:
|
||||
db.session.add(mirror)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def seed_profiles() -> None:
|
||||
if Profile.query.first():
|
||||
return
|
||||
|
||||
profiles_data = [
|
||||
(
|
||||
"آروان کامل",
|
||||
"DNS Shecan + میرور APT/Docker آروان + GitClone",
|
||||
{"dns": "Shecan", "apt": "Arvan Ubuntu", "docker": "Arvan Docker Registry", "github": "GitClone"},
|
||||
),
|
||||
(
|
||||
"403 + Shatel",
|
||||
"DNS 403 + میرور شاتل",
|
||||
{"dns": "403.online", "apt": "Shatel Ubuntu", "docker": "Arvan Docker Registry", "github": "GHProxy"},
|
||||
),
|
||||
(
|
||||
"حداقلی",
|
||||
"فقط DNS و Docker",
|
||||
{"dns": "Shecan", "docker": "Arvan Docker Registry"},
|
||||
),
|
||||
]
|
||||
|
||||
for idx, (name, desc, mapping) in enumerate(profiles_data):
|
||||
profile = Profile(name=name, description=desc, is_default=(idx == 0))
|
||||
db.session.add(profile)
|
||||
db.session.flush()
|
||||
|
||||
for order, (category, mirror_name) in enumerate(mapping.items()):
|
||||
mirror = Mirror.query.filter_by(category=category, name=mirror_name).first()
|
||||
if mirror:
|
||||
db.session.add(
|
||||
ProfileItem(
|
||||
profile_id=profile.id,
|
||||
mirror_id=mirror.id,
|
||||
category=category,
|
||||
order=order,
|
||||
)
|
||||
)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def seed_settings() -> None:
|
||||
defaults = {
|
||||
"auto_switch_dns": "false",
|
||||
"auto_switch_apt": "false",
|
||||
"auto_switch_docker": "false",
|
||||
"auto_switch_github": "false",
|
||||
"auto_switch_pip": "false",
|
||||
"auto_switch_npm": "false",
|
||||
"auto_test_interval_minutes": "30",
|
||||
"max_switches_per_day": "3",
|
||||
"rollback_minutes": "15",
|
||||
"rollback_on_all_fail": "true",
|
||||
}
|
||||
from app.models import Setting
|
||||
|
||||
for key, value in defaults.items():
|
||||
if Setting.get(key) is None:
|
||||
Setting.set(key, value)
|
||||
|
||||
|
||||
def seed_all() -> None:
|
||||
seed_mirrors()
|
||||
seed_profiles()
|
||||
seed_settings()
|
||||
@@ -0,0 +1,142 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from app.config import Config
|
||||
from app.extensions import db
|
||||
from app.models import ApplyLog, Profile, SystemState
|
||||
from app.models import utcnow
|
||||
from app.services import apt, backup, dns, docker_svc, github, pip_npm
|
||||
from app.services.host import run_on_host
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
APPLIERS = {
|
||||
"dns": dns.apply_dns,
|
||||
"apt": apt.apply_apt,
|
||||
"docker": docker_svc.apply_docker,
|
||||
"github": github.apply_github,
|
||||
"pip": pip_npm.apply_pip,
|
||||
"npm": pip_npm.apply_npm,
|
||||
}
|
||||
|
||||
|
||||
def ensure_initial_backup() -> SystemState:
|
||||
state = SystemState.query.first()
|
||||
if not state:
|
||||
state = SystemState()
|
||||
db.session.add(state)
|
||||
db.session.commit()
|
||||
|
||||
if not state.initial_backup_done:
|
||||
path = backup.create_initial_backup()
|
||||
state.initial_backup_path = str(path)
|
||||
state.initial_backup_done = True
|
||||
db.session.commit()
|
||||
logger.info("Initial backup created at %s", path)
|
||||
|
||||
return state
|
||||
|
||||
|
||||
def apply_profile(profile: Profile, rollback_minutes: int | None = None) -> ApplyLog:
|
||||
state = ensure_initial_backup()
|
||||
rollback_minutes = rollback_minutes or int(Config.ROLLBACK_MINUTES)
|
||||
|
||||
previous_backup = backup.create_backup(f"before_apply_{profile.id}")
|
||||
apply_log = ApplyLog(
|
||||
profile_id=profile.id,
|
||||
status="running",
|
||||
backup_path=str(previous_backup),
|
||||
previous_backup_path=state.initial_backup_path,
|
||||
rollback_at=utcnow() + timedelta(minutes=rollback_minutes),
|
||||
)
|
||||
db.session.add(apply_log)
|
||||
db.session.flush()
|
||||
|
||||
details: dict = {"steps": [], "profile": profile.name}
|
||||
mirrors = profile.mirrors_by_category()
|
||||
order = ["dns", "apt", "docker", "github", "pip", "npm"]
|
||||
|
||||
for category in order:
|
||||
mirror = mirrors.get(category)
|
||||
if not mirror:
|
||||
continue
|
||||
applier = APPLIERS.get(category)
|
||||
if not applier:
|
||||
continue
|
||||
try:
|
||||
result = applier(mirror)
|
||||
details["steps"].append({"category": category, "mirror": mirror.name, **result})
|
||||
except Exception as exc:
|
||||
logger.exception("Apply failed for %s", category)
|
||||
details["steps"].append(
|
||||
{"category": category, "mirror": mirror.name, "success": False, "error": str(exc)}
|
||||
)
|
||||
|
||||
failed = [s for s in details["steps"] if not s.get("success", True)]
|
||||
if failed and apply_log.backup_path:
|
||||
rollback_result = backup.restore_backup(apply_log.backup_path)
|
||||
details["auto_rollback"] = rollback_result
|
||||
|
||||
apply_log.set_details(details)
|
||||
apply_log.status = "failed" if failed else "pending_confirm"
|
||||
apply_log.finished_at = utcnow()
|
||||
|
||||
state.current_profile_id = profile.id if not failed else state.current_profile_id
|
||||
state.pending_apply_log_id = apply_log.id if not failed else None
|
||||
db.session.commit()
|
||||
|
||||
return apply_log
|
||||
|
||||
|
||||
def confirm_apply(apply_log_id: int) -> ApplyLog:
|
||||
apply_log = ApplyLog.query.get_or_404(apply_log_id)
|
||||
apply_log.confirmed = True
|
||||
apply_log.status = "confirmed"
|
||||
apply_log.rollback_at = None
|
||||
|
||||
state = SystemState.query.first()
|
||||
if state:
|
||||
state.pending_apply_log_id = None
|
||||
db.session.commit()
|
||||
return apply_log
|
||||
|
||||
|
||||
def rollback_apply(apply_log_id: int) -> dict:
|
||||
apply_log = ApplyLog.query.get_or_404(apply_log_id)
|
||||
if not apply_log.backup_path:
|
||||
return {"success": False, "error": "مسیر backup یافت نشد"}
|
||||
|
||||
result = backup.restore_backup(apply_log.backup_path)
|
||||
apply_log.status = "rolled_back"
|
||||
apply_log.finished_at = utcnow()
|
||||
|
||||
state = SystemState.query.first()
|
||||
if state:
|
||||
state.pending_apply_log_id = None
|
||||
db.session.commit()
|
||||
return result
|
||||
|
||||
|
||||
def restore_initial() -> dict:
|
||||
state = SystemState.query.first()
|
||||
if not state or not state.initial_backup_path:
|
||||
return {"success": False, "error": "backup اولیه یافت نشد"}
|
||||
return backup.restore_backup(state.initial_backup_path)
|
||||
|
||||
|
||||
def get_system_status() -> dict:
|
||||
codename_result = run_on_host(["lsb_release", "-cs"])
|
||||
version_result = run_on_host(["lsb_release", "-d"])
|
||||
|
||||
return {
|
||||
"ubuntu_codename": codename_result.stdout.strip() if codename_result.returncode == 0 else "unknown",
|
||||
"ubuntu_description": version_result.stdout.strip() if version_result.returncode == 0 else "unknown",
|
||||
"dns": dns.read_current_dns(),
|
||||
"apt": apt.read_current_apt(),
|
||||
"docker": docker_svc.read_current_docker(),
|
||||
"github": github.read_current_github(),
|
||||
"host_root": Config.HOST_ROOT or "(local mode)",
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from app.models import Mirror
|
||||
from app.services.host import get_ubuntu_codename, host_path, run_on_host, validate_codename
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SOURCES_PATH = "etc/apt/sources.list"
|
||||
SOURCES_D_PATH = "etc/apt/sources.list.d"
|
||||
MIRROR_MARKER = "mirror-manager"
|
||||
|
||||
|
||||
def read_current_apt() -> dict:
|
||||
sources = host_path(SOURCES_PATH)
|
||||
return {
|
||||
"sources_list": sources.read_text(encoding="utf-8") if sources.exists() else None,
|
||||
"path": str(sources),
|
||||
}
|
||||
|
||||
|
||||
def _build_sources_content(base_url: str, codename: str) -> str:
|
||||
url = base_url.rstrip("/")
|
||||
return f"""# Managed by Mirror Manager - {MIRROR_MARKER}
|
||||
deb {url} {codename} main restricted universe multiverse
|
||||
deb {url} {codename}-updates main restricted universe multiverse
|
||||
deb {url} {codename}-backports main restricted universe multiverse
|
||||
deb {url} {codename}-security main restricted universe multiverse
|
||||
"""
|
||||
|
||||
|
||||
def apply_apt(mirror: Mirror) -> dict:
|
||||
if not mirror.url:
|
||||
return {"success": False, "error": "URL میرور APT تعریف نشده"}
|
||||
|
||||
codename = validate_codename(get_ubuntu_codename())
|
||||
sources_path = host_path(SOURCES_PATH)
|
||||
sources_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
content = _build_sources_content(mirror.url, codename)
|
||||
sources_path.write_text(content, encoding="utf-8")
|
||||
|
||||
sources_d = host_path(SOURCES_D_PATH)
|
||||
if sources_d.exists():
|
||||
for f in sources_d.glob("*.list"):
|
||||
if MIRROR_MARKER not in f.read_text(encoding="utf-8", errors="ignore"):
|
||||
backup_name = f.with_suffix(".list.disabled")
|
||||
f.rename(backup_name)
|
||||
|
||||
result = run_on_host(["apt-get", "update", "-qq"], timeout=180)
|
||||
if result.returncode != 0:
|
||||
return {
|
||||
"success": False,
|
||||
"error": (result.stderr or result.stdout or "apt-get update ناموفق")[:500],
|
||||
"codename": codename,
|
||||
}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"APT mirror اعمال شد: {mirror.name}",
|
||||
"codename": codename,
|
||||
"url": mirror.url,
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from app.config import Config
|
||||
from app.services.host import host_path, run_on_host
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BACKUP_TARGETS = {
|
||||
"resolved.conf": "etc/systemd/resolved.conf",
|
||||
"resolved.conf.d": "etc/systemd/resolved.conf.d",
|
||||
"sources.list": "etc/apt/sources.list",
|
||||
"sources.list.d": "etc/apt/sources.list.d",
|
||||
"daemon.json": "etc/docker/daemon.json",
|
||||
"gitconfig_system": "etc/gitconfig",
|
||||
}
|
||||
|
||||
|
||||
def _backup_dir(name: str) -> Path:
|
||||
path = Config.BACKUP_DIR / name
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def _copy_if_exists(src: Path, dst: Path) -> None:
|
||||
if not src.exists():
|
||||
return
|
||||
if src.is_dir():
|
||||
if dst.exists():
|
||||
shutil.rmtree(dst)
|
||||
shutil.copytree(src, dst)
|
||||
else:
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(src, dst)
|
||||
|
||||
|
||||
def _restore_file(relative: str, backup_root: Path) -> None:
|
||||
key = relative.replace("/", "_").replace(".", "_")
|
||||
for name, rel in BACKUP_TARGETS.items():
|
||||
if rel == relative or rel.endswith(relative):
|
||||
key = name
|
||||
break
|
||||
|
||||
src = backup_root / key
|
||||
dst = host_path(relative)
|
||||
if not src.exists():
|
||||
if dst.exists() and key in ("daemon.json", "gitconfig_system"):
|
||||
dst.unlink(missing_ok=True)
|
||||
return
|
||||
|
||||
if src.is_dir():
|
||||
if dst.exists():
|
||||
shutil.rmtree(dst)
|
||||
shutil.copytree(src, dst)
|
||||
else:
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(src, dst)
|
||||
|
||||
|
||||
def create_backup(label: str = "manual") -> Path:
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
backup_root = _backup_dir(f"{timestamp}_{label}")
|
||||
manifest: dict = {"label": label, "timestamp": timestamp, "files": {}}
|
||||
|
||||
for key, relative in BACKUP_TARGETS.items():
|
||||
src = host_path(relative)
|
||||
dst = backup_root / key
|
||||
_copy_if_exists(src, dst)
|
||||
manifest["files"][key] = {
|
||||
"relative": relative,
|
||||
"existed": src.exists(),
|
||||
}
|
||||
|
||||
gitconfig_global = host_path("root/.gitconfig")
|
||||
if not gitconfig_global.exists():
|
||||
gitconfig_global = Path.home() / ".gitconfig"
|
||||
if gitconfig_global.exists():
|
||||
shutil.copy2(gitconfig_global, backup_root / "gitconfig_global")
|
||||
manifest["files"]["gitconfig_global"] = {"path": str(gitconfig_global), "existed": True}
|
||||
|
||||
pip_conf = host_path("etc/pip.conf")
|
||||
if pip_conf.exists():
|
||||
shutil.copy2(pip_conf, backup_root / "pip.conf")
|
||||
manifest["files"]["pip.conf"] = {"relative": "etc/pip.conf", "existed": True}
|
||||
|
||||
npmrc = host_path("root/.npmrc")
|
||||
if npmrc.exists():
|
||||
shutil.copy2(npmrc, backup_root / "npmrc")
|
||||
manifest["files"]["npmrc"] = {"relative": "root/.npmrc", "existed": True}
|
||||
|
||||
mirror_dropin = host_path("etc/systemd/resolved.conf.d/mirror-manager.conf")
|
||||
if mirror_dropin.exists():
|
||||
shutil.copy2(mirror_dropin, backup_root / "mirror_manager_resolved.conf")
|
||||
|
||||
with open(backup_root / "manifest.json", "w", encoding="utf-8") as f:
|
||||
json.dump(manifest, f, ensure_ascii=False, indent=2)
|
||||
|
||||
logger.info("Backup created at %s", backup_root)
|
||||
return backup_root
|
||||
|
||||
|
||||
def restore_backup(backup_root: Path | str) -> dict:
|
||||
backup_root = Path(backup_root)
|
||||
if not backup_root.exists():
|
||||
return {"success": False, "error": "مسیر backup یافت نشد"}
|
||||
|
||||
results: list[str] = []
|
||||
|
||||
for key, relative in BACKUP_TARGETS.items():
|
||||
src = backup_root / key
|
||||
if src.exists():
|
||||
dst = host_path(relative)
|
||||
_copy_if_exists(src, dst)
|
||||
results.append(f"بازگردانی {relative}")
|
||||
|
||||
gitconfig_backup = backup_root / "gitconfig_global"
|
||||
if gitconfig_backup.exists():
|
||||
for target in [host_path("root/.gitconfig"), host_path("home/.gitconfig")]:
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(gitconfig_backup, target)
|
||||
results.append("بازگردانی gitconfig global")
|
||||
|
||||
pip_backup = backup_root / "pip.conf"
|
||||
if pip_backup.exists():
|
||||
dst = host_path("etc/pip.conf")
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(pip_backup, dst)
|
||||
results.append("بازگردانی pip.conf")
|
||||
|
||||
npmrc_backup = backup_root / "npmrc"
|
||||
if npmrc_backup.exists():
|
||||
dst = host_path("root/.npmrc")
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(npmrc_backup, dst)
|
||||
results.append("بازگردانی .npmrc")
|
||||
|
||||
dropin = host_path("etc/systemd/resolved.conf.d/mirror-manager.conf")
|
||||
mirror_backup = backup_root / "mirror_manager_resolved.conf"
|
||||
if mirror_backup.exists():
|
||||
dropin.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(mirror_backup, dropin)
|
||||
elif dropin.exists():
|
||||
dropin.unlink()
|
||||
|
||||
run_on_host(["systemctl", "restart", "systemd-resolved"])
|
||||
run_on_host(["systemctl", "restart", "docker"])
|
||||
|
||||
return {"success": True, "restored": results}
|
||||
|
||||
|
||||
def create_initial_backup() -> Path:
|
||||
return create_backup("initial")
|
||||
|
||||
|
||||
def list_backups() -> list[dict]:
|
||||
backups = []
|
||||
if not Config.BACKUP_DIR.exists():
|
||||
return backups
|
||||
for path in sorted(Config.BACKUP_DIR.iterdir(), reverse=True):
|
||||
if path.is_dir():
|
||||
manifest_path = path / "manifest.json"
|
||||
label = path.name
|
||||
if manifest_path.exists():
|
||||
with open(manifest_path, encoding="utf-8") as f:
|
||||
manifest = json.load(f)
|
||||
label = manifest.get("label", path.name)
|
||||
backups.append({"path": str(path), "name": path.name, "label": label})
|
||||
return backups
|
||||
@@ -0,0 +1,65 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from app.config import Config
|
||||
from app.models import Mirror
|
||||
from app.services.host import host_path, run_on_host
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DROPIN_FILENAME = "mirror-manager.conf"
|
||||
|
||||
|
||||
def read_current_dns() -> dict:
|
||||
dropin = host_path("etc/systemd/resolved.conf.d", DROPIN_FILENAME)
|
||||
main_conf = host_path("etc/systemd/resolved.conf")
|
||||
return {
|
||||
"dropin_exists": dropin.exists(),
|
||||
"dropin_path": str(dropin),
|
||||
"main_conf_exists": main_conf.exists(),
|
||||
"content": dropin.read_text(encoding="utf-8") if dropin.exists() else None,
|
||||
}
|
||||
|
||||
|
||||
def apply_dns(mirror: Mirror) -> dict:
|
||||
ips = mirror.get_ips()
|
||||
if not ips:
|
||||
return {"success": False, "error": "آدرس DNS تعریف نشده"}
|
||||
|
||||
dropin_dir = host_path("etc/systemd/resolved.conf.d")
|
||||
dropin_dir.mkdir(parents=True, exist_ok=True)
|
||||
dropin_path = dropin_dir / DROPIN_FILENAME
|
||||
|
||||
dns_line = " ".join(ips)
|
||||
content = f"""# Managed by Mirror Manager - do not edit manually
|
||||
[Resolve]
|
||||
DNS={dns_line}
|
||||
FallbackDNS=1.1.1.1 8.8.8.8
|
||||
DNSStubListener=yes
|
||||
"""
|
||||
|
||||
dropin_path.write_text(content, encoding="utf-8")
|
||||
|
||||
result = run_on_host(["systemctl", "restart", "systemd-resolved"])
|
||||
if result.returncode != 0:
|
||||
return {
|
||||
"success": False,
|
||||
"error": result.stderr or "خطا در restart systemd-resolved",
|
||||
"applied_content": content,
|
||||
}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"DNS اعمال شد: {mirror.name}",
|
||||
"ips": ips,
|
||||
"path": str(dropin_path),
|
||||
}
|
||||
|
||||
|
||||
def remove_dns_dropin() -> None:
|
||||
dropin = host_path("etc/systemd/resolved.conf.d", DROPIN_FILENAME)
|
||||
if dropin.exists():
|
||||
dropin.unlink()
|
||||
run_on_host(["systemctl", "restart", "systemd-resolved"])
|
||||
@@ -0,0 +1,57 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from app.models import Mirror
|
||||
from app.services.host import host_path, run_on_host
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DAEMON_JSON = "etc/docker/daemon.json"
|
||||
MIRROR_MANAGER_KEY = "mirror-manager"
|
||||
|
||||
|
||||
def read_current_docker() -> dict:
|
||||
path = host_path(DAEMON_JSON)
|
||||
if not path.exists():
|
||||
return {"exists": False, "config": {}}
|
||||
try:
|
||||
return {"exists": True, "config": json.loads(path.read_text(encoding="utf-8"))}
|
||||
except json.JSONDecodeError:
|
||||
return {"exists": True, "config": {}, "parse_error": True}
|
||||
|
||||
|
||||
def apply_docker(mirror: Mirror) -> dict:
|
||||
if not mirror.url:
|
||||
return {"success": False, "error": "URL رجیstry داکر تعریف نشده"}
|
||||
|
||||
path = host_path(DAEMON_JSON)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
config: dict = {}
|
||||
if path.exists():
|
||||
try:
|
||||
config = json.loads(path.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError:
|
||||
config = {}
|
||||
|
||||
config["registry-mirrors"] = [mirror.url.rstrip("/")]
|
||||
config["_mirror_manager"] = MIRROR_MANAGER_KEY
|
||||
|
||||
path.write_text(json.dumps(config, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
result = run_on_host(["systemctl", "restart", "docker"], timeout=120)
|
||||
if result.returncode != 0:
|
||||
return {
|
||||
"success": False,
|
||||
"error": (result.stderr or "خطا در restart docker")[:500],
|
||||
}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Docker registry mirror اعمال شد: {mirror.name}",
|
||||
"url": mirror.url,
|
||||
"warning": "سرویس Docker restart شد — containerها موقتاً قطع میشوند",
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import configparser
|
||||
import logging
|
||||
from io import StringIO
|
||||
from pathlib import Path
|
||||
|
||||
from app.models import Mirror
|
||||
from app.services.host import host_path, run_on_host
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MARKER = "mirror-manager"
|
||||
INSTEAD_SECTION = f'url "https://github.com/" managed by {MARKER}'
|
||||
|
||||
|
||||
def _gitconfig_paths() -> list[Path]:
|
||||
paths = []
|
||||
for rel in ("root/.gitconfig", "home/.gitconfig"):
|
||||
p = host_path(rel)
|
||||
if p not in paths:
|
||||
paths.append(p)
|
||||
local = Path.home() / ".gitconfig"
|
||||
if local not in paths:
|
||||
paths.append(local)
|
||||
system = host_path("etc/gitconfig")
|
||||
if system not in paths:
|
||||
paths.append(system)
|
||||
return paths
|
||||
|
||||
|
||||
def read_current_github() -> dict:
|
||||
configs = {}
|
||||
for path in _gitconfig_paths():
|
||||
if path.exists():
|
||||
configs[str(path)] = path.read_text(encoding="utf-8")
|
||||
return configs
|
||||
|
||||
|
||||
def _parse_gitconfig(content: str) -> configparser.ConfigParser:
|
||||
parser = configparser.ConfigParser()
|
||||
parser.read_string(content if content.strip() else "[core]\n")
|
||||
return parser
|
||||
|
||||
|
||||
def _serialize_gitconfig(parser: configparser.ConfigParser) -> str:
|
||||
buf = StringIO()
|
||||
parser.write(buf)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _remove_mirror_sections(parser: configparser.ConfigParser) -> None:
|
||||
to_remove = []
|
||||
for section in parser.sections():
|
||||
if not section.startswith('url "'):
|
||||
continue
|
||||
instead_of = parser.get(section, "insteadOf", fallback="")
|
||||
if instead_of in ("https://github.com/", "git@github.com:"):
|
||||
to_remove.append(section)
|
||||
for section in to_remove:
|
||||
parser.remove_section(section)
|
||||
|
||||
|
||||
def apply_github(mirror: Mirror) -> dict:
|
||||
meta = mirror.get_meta()
|
||||
instead_prefix = meta.get("instead_prefix") or mirror.url
|
||||
if not instead_prefix:
|
||||
return {"success": False, "error": "prefix میرور GitHub تعریف نشده"}
|
||||
|
||||
instead_prefix = instead_prefix.rstrip("/") + "/"
|
||||
section_name = f'url "{instead_prefix}"'
|
||||
|
||||
applied_paths = []
|
||||
for path in _gitconfig_paths():
|
||||
content = path.read_text(encoding="utf-8") if path.exists() else ""
|
||||
parser = _parse_gitconfig(content)
|
||||
_remove_mirror_sections(parser)
|
||||
|
||||
if not parser.has_section(section_name):
|
||||
parser.add_section(section_name)
|
||||
parser.set(section_name, "insteadOf", "https://github.com/")
|
||||
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(_serialize_gitconfig(parser), encoding="utf-8")
|
||||
applied_paths.append(str(path))
|
||||
|
||||
test = run_on_host(
|
||||
["git", "ls-remote", "https://github.com/octocat/Hello-World.git", "HEAD"],
|
||||
timeout=60,
|
||||
)
|
||||
|
||||
return {
|
||||
"success": test.returncode == 0,
|
||||
"message": f"GitHub mirror اعمال شد: {mirror.name}",
|
||||
"paths": applied_paths,
|
||||
"test_output": (test.stdout or test.stderr)[:300] if test.returncode != 0 else "git ls-remote موفق",
|
||||
"error": test.stderr[:300] if test.returncode != 0 else None,
|
||||
}
|
||||
|
||||
|
||||
def remove_github_config() -> None:
|
||||
for path in _gitconfig_paths():
|
||||
if not path.exists():
|
||||
continue
|
||||
parser = _parse_gitconfig(path.read_text(encoding="utf-8"))
|
||||
_remove_mirror_sections(parser)
|
||||
path.write_text(_serialize_gitconfig(parser), encoding="utf-8")
|
||||
@@ -0,0 +1,60 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from app.config import Config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def host_path(*parts: str) -> Path:
|
||||
return Config.host_path(*parts)
|
||||
|
||||
|
||||
def is_container_mode() -> bool:
|
||||
return bool(Config.HOST_ROOT) and Path(Config.HOST_ROOT).exists()
|
||||
|
||||
|
||||
def run_on_host(command: list[str], timeout: int = 120) -> subprocess.CompletedProcess:
|
||||
"""Run a command on the host when inside a privileged container."""
|
||||
if is_container_mode():
|
||||
full_cmd = ["nsenter", "-t", "1", "-m", "-u", "-i", "-n", "-p", "--"] + command
|
||||
else:
|
||||
full_cmd = command
|
||||
return subprocess.run(
|
||||
full_cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
def get_ubuntu_codename() -> str:
|
||||
result = run_on_host(["lsb_release", "-cs"])
|
||||
if result.returncode == 0:
|
||||
return result.stdout.strip()
|
||||
return "noble"
|
||||
|
||||
|
||||
def get_ubuntu_version() -> str:
|
||||
result = run_on_host(["lsb_release", "-rs"])
|
||||
if result.returncode == 0:
|
||||
return result.stdout.strip()
|
||||
return "24.04"
|
||||
|
||||
|
||||
SUPPORTED_CODENAMES = {"noble", "jammy", "bionic"}
|
||||
|
||||
|
||||
def validate_codename(codename: str) -> str:
|
||||
if codename in SUPPORTED_CODENAMES:
|
||||
return codename
|
||||
mapping = {"24.04": "noble", "22.04": "jammy", "18.04": "bionic"}
|
||||
version = get_ubuntu_version()
|
||||
return mapping.get(version, "noble")
|
||||
@@ -0,0 +1,35 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from app.models import Mirror
|
||||
from app.services.host import host_path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def apply_pip(mirror: Mirror) -> dict:
|
||||
if not mirror.url:
|
||||
return {"success": False, "error": "URL میرور pip تعریف نشده"}
|
||||
|
||||
conf_path = host_path("etc/pip.conf")
|
||||
conf_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
content = f"""# Managed by Mirror Manager
|
||||
[global]
|
||||
index-url = {mirror.url.rstrip("/")}
|
||||
trusted-host = {mirror.url.split("//")[-1].split("/")[0]}
|
||||
"""
|
||||
conf_path.write_text(content, encoding="utf-8")
|
||||
return {"success": True, "message": f"pip mirror اعمال شد: {mirror.name}", "path": str(conf_path)}
|
||||
|
||||
|
||||
def apply_npm(mirror: Mirror) -> dict:
|
||||
if not mirror.url:
|
||||
return {"success": False, "error": "URL رجیstry npm تعریف نشده"}
|
||||
|
||||
npmrc = host_path("root/.npmrc")
|
||||
npmrc.parent.mkdir(parents=True, exist_ok=True)
|
||||
content = f"# Managed by Mirror Manager\nregistry={mirror.url.rstrip('/')}\n"
|
||||
npmrc.write_text(content, encoding="utf-8")
|
||||
return {"success": True, "message": f"npm registry اعمال شد: {mirror.name}", "path": str(npmrc)}
|
||||
@@ -0,0 +1,111 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.extensions import db
|
||||
from app.models import ApplyLog, Mirror, Profile, ProfileItem, Setting, SystemState
|
||||
from app.models import utcnow
|
||||
from app.services.applier import apply_profile, rollback_apply
|
||||
from app.services.tester import get_best_mirror, test_mirror
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_scheduler = None
|
||||
|
||||
|
||||
def _auto_switch_enabled(category: str) -> bool:
|
||||
return Setting.get(f"auto_switch_{category}", "false").lower() == "true"
|
||||
|
||||
|
||||
def _get_switch_count_today() -> int:
|
||||
key = f"switch_count_{utcnow().strftime('%Y%m%d')}"
|
||||
return int(Setting.get(key, "0") or "0")
|
||||
|
||||
|
||||
def _increment_switch_count() -> None:
|
||||
key = f"switch_count_{utcnow().strftime('%Y%m%d')}"
|
||||
Setting.set(key, str(_get_switch_count_today() + 1))
|
||||
|
||||
|
||||
def check_pending_rollbacks(app) -> None:
|
||||
with app.app_context():
|
||||
now = utcnow()
|
||||
pending = ApplyLog.query.filter(
|
||||
ApplyLog.status == "pending_confirm",
|
||||
ApplyLog.confirmed.is_(False),
|
||||
ApplyLog.rollback_at.isnot(None),
|
||||
ApplyLog.rollback_at <= now,
|
||||
).all()
|
||||
|
||||
for log in pending:
|
||||
logger.info("Auto rollback for apply log %s", log.id)
|
||||
rollback_apply(log.id)
|
||||
|
||||
|
||||
def run_auto_switch(app) -> None:
|
||||
with app.app_context():
|
||||
max_switches = int(Setting.get("max_switches_per_day", "3") or "3")
|
||||
if _get_switch_count_today() >= max_switches:
|
||||
logger.info("Max switches per day reached")
|
||||
return
|
||||
|
||||
state = SystemState.query.first()
|
||||
if not state or not state.current_profile_id:
|
||||
return
|
||||
|
||||
profile = Profile.query.get(state.current_profile_id)
|
||||
if not profile:
|
||||
return
|
||||
|
||||
for category in ("dns", "apt", "docker", "github", "pip", "npm"):
|
||||
if not _auto_switch_enabled(category):
|
||||
continue
|
||||
|
||||
item = ProfileItem.query.filter_by(profile_id=profile.id, category=category).first()
|
||||
if not item or not item.mirror:
|
||||
continue
|
||||
|
||||
results = test_mirror(item.mirror)
|
||||
if results and all(r.success for r in results):
|
||||
continue
|
||||
|
||||
best = get_best_mirror(category)
|
||||
if not best or best.id == item.mirror_id:
|
||||
if Setting.get("rollback_on_all_fail", "true").lower() == "true":
|
||||
logger.warning("All mirrors failed for %s", category)
|
||||
continue
|
||||
|
||||
item.mirror_id = best.id
|
||||
db.session.commit()
|
||||
apply_profile(profile)
|
||||
_increment_switch_count()
|
||||
logger.info("Auto-switched %s to %s", category, best.name)
|
||||
break
|
||||
|
||||
|
||||
def init_scheduler(app) -> None:
|
||||
global _scheduler
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
|
||||
if _scheduler is not None:
|
||||
return
|
||||
|
||||
interval = int(app.config.get("AUTO_TEST_INTERVAL_MINUTES", 30))
|
||||
_scheduler = BackgroundScheduler(daemon=True)
|
||||
_scheduler.add_job(
|
||||
check_pending_rollbacks,
|
||||
"interval",
|
||||
minutes=1,
|
||||
args=[app],
|
||||
id="rollback_checker",
|
||||
)
|
||||
_scheduler.add_job(
|
||||
run_auto_switch,
|
||||
"interval",
|
||||
minutes=interval,
|
||||
args=[app],
|
||||
id="auto_switch",
|
||||
)
|
||||
_scheduler.start()
|
||||
logger.info("Scheduler started (interval=%s min)", interval)
|
||||
@@ -0,0 +1,157 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
import time
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import requests
|
||||
|
||||
from app.extensions import db
|
||||
from app.models import Mirror, TestResult
|
||||
from app.services.host import get_ubuntu_codename, run_on_host, validate_codename
|
||||
|
||||
|
||||
def _record(mirror: Mirror, test_type: str, success: bool, latency_ms: float | None, error: str | None) -> TestResult:
|
||||
result = TestResult(
|
||||
mirror_id=mirror.id,
|
||||
test_type=test_type,
|
||||
success=success,
|
||||
latency_ms=latency_ms,
|
||||
error=error,
|
||||
)
|
||||
db.session.add(result)
|
||||
db.session.commit()
|
||||
return result
|
||||
|
||||
|
||||
def test_dns(mirror: Mirror) -> TestResult:
|
||||
ips = mirror.get_ips()
|
||||
if not ips:
|
||||
return _record(mirror, "dns_resolve", False, None, "IP تعریف نشده")
|
||||
|
||||
start = time.perf_counter()
|
||||
try:
|
||||
for domain in ("github.com", "docker.io"):
|
||||
socket.getaddrinfo(domain, 443, type=socket.SOCK_STREAM)
|
||||
latency = (time.perf_counter() - start) * 1000
|
||||
return _record(mirror, "dns_resolve", True, latency, None)
|
||||
except socket.gaierror as exc:
|
||||
latency = (time.perf_counter() - start) * 1000
|
||||
return _record(mirror, "dns_resolve", False, latency, str(exc))
|
||||
|
||||
|
||||
def test_tcp(url: str, timeout: int = 10) -> tuple[bool, float | None, str | None]:
|
||||
parsed = urlparse(url if "://" in url else f"https://{url}")
|
||||
host = parsed.hostname
|
||||
port = parsed.port or (443 if parsed.scheme == "https" else 80)
|
||||
if not host:
|
||||
return False, None, "host نامعتبر"
|
||||
|
||||
start = time.perf_counter()
|
||||
try:
|
||||
sock = socket.create_connection((host, port), timeout=timeout)
|
||||
sock.close()
|
||||
return True, (time.perf_counter() - start) * 1000, None
|
||||
except OSError as exc:
|
||||
return False, (time.perf_counter() - start) * 1000, str(exc)
|
||||
|
||||
|
||||
def test_http(url: str, timeout: int = 15) -> tuple[bool, float | None, str | None]:
|
||||
start = time.perf_counter()
|
||||
try:
|
||||
response = requests.head(url.rstrip("/"), timeout=timeout, allow_redirects=True)
|
||||
if response.status_code >= 500:
|
||||
return False, (time.perf_counter() - start) * 1000, f"HTTP {response.status_code}"
|
||||
return True, (time.perf_counter() - start) * 1000, None
|
||||
except requests.RequestException as exc:
|
||||
return False, (time.perf_counter() - start) * 1000, str(exc)
|
||||
|
||||
|
||||
def test_apt(mirror: Mirror) -> TestResult:
|
||||
if not mirror.url:
|
||||
return _record(mirror, "apt_release", False, None, "URL تعریف نشده")
|
||||
|
||||
codename = validate_codename(get_ubuntu_codename())
|
||||
release_url = f"{mirror.url.rstrip('/')}/dists/{codename}/Release"
|
||||
ok, latency, error = test_http(release_url)
|
||||
return _record(mirror, "apt_release", ok, latency, error)
|
||||
|
||||
|
||||
def test_docker(mirror: Mirror) -> TestResult:
|
||||
if not mirror.url:
|
||||
return _record(mirror, "docker_registry", False, None, "URL تعریف نشده")
|
||||
|
||||
api_url = f"{mirror.url.rstrip('/')}/v2/"
|
||||
ok, latency, error = test_http(api_url)
|
||||
return _record(mirror, "docker_registry", ok, latency, error)
|
||||
|
||||
|
||||
def test_github(mirror: Mirror) -> TestResult:
|
||||
meta = mirror.get_meta()
|
||||
prefix = meta.get("instead_prefix") or mirror.url
|
||||
if not prefix:
|
||||
return _record(mirror, "github_ls_remote", False, None, "prefix تعریف نشده")
|
||||
|
||||
test_url = f"{prefix.rstrip('/')}/octocat/Hello-World.git"
|
||||
start = time.perf_counter()
|
||||
result = run_on_host(["git", "ls-remote", test_url, "HEAD"], timeout=45)
|
||||
latency = (time.perf_counter() - start) * 1000
|
||||
if result.returncode == 0:
|
||||
return _record(mirror, "github_ls_remote", True, latency, None)
|
||||
return _record(mirror, "github_ls_remote", False, latency, (result.stderr or result.stdout)[:300])
|
||||
|
||||
|
||||
def test_pip(mirror: Mirror) -> TestResult:
|
||||
if not mirror.url:
|
||||
return _record(mirror, "pip_index", False, None, "URL تعریف نشده")
|
||||
ok, latency, error = test_http(mirror.url)
|
||||
return _record(mirror, "pip_index", ok, latency, error)
|
||||
|
||||
|
||||
def test_npm(mirror: Mirror) -> TestResult:
|
||||
if not mirror.url:
|
||||
return _record(mirror, "npm_registry", False, None, "URL تعریف نشده")
|
||||
ok, latency, error = test_http(mirror.url)
|
||||
return _record(mirror, "npm_registry", ok, latency, error)
|
||||
|
||||
|
||||
def test_mirror(mirror: Mirror) -> list[TestResult]:
|
||||
testers = {
|
||||
"dns": [test_dns],
|
||||
"apt": [test_apt],
|
||||
"docker": [test_docker],
|
||||
"github": [test_github],
|
||||
"pip": [test_pip],
|
||||
"npm": [test_npm],
|
||||
}
|
||||
results = []
|
||||
for fn in testers.get(mirror.category, []):
|
||||
results.append(fn(mirror))
|
||||
return results
|
||||
|
||||
|
||||
def test_all_enabled(category: str | None = None) -> list[TestResult]:
|
||||
query = Mirror.query.filter_by(enabled=True)
|
||||
if category:
|
||||
query = query.filter_by(category=category)
|
||||
all_results: list[TestResult] = []
|
||||
for mirror in query.order_by(Mirror.priority).all():
|
||||
all_results.extend(test_mirror(mirror))
|
||||
return all_results
|
||||
|
||||
|
||||
def get_best_mirror(category: str) -> Mirror | None:
|
||||
mirrors = Mirror.query.filter_by(category=category, enabled=True).order_by(Mirror.priority).all()
|
||||
best: Mirror | None = None
|
||||
best_latency = float("inf")
|
||||
|
||||
for mirror in mirrors:
|
||||
results = test_mirror(mirror)
|
||||
if not results:
|
||||
continue
|
||||
if all(r.success for r in results):
|
||||
avg_latency = sum(r.latency_ms or 9999 for r in results) / len(results)
|
||||
if avg_latency < best_latency:
|
||||
best_latency = avg_latency
|
||||
best = mirror
|
||||
return best
|
||||
@@ -0,0 +1,25 @@
|
||||
body {
|
||||
font-family: "Segoe UI", Tahoma, Vazirmatn, sans-serif;
|
||||
}
|
||||
|
||||
.guide-content h5 {
|
||||
margin-top: 1rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.guide-content ul {
|
||||
padding-right: 1.25rem;
|
||||
}
|
||||
|
||||
pre {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.card-footer form {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: 0.85em;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "macros.html" import guide_box %}
|
||||
{% block title %}اعمال تنظیمات{% endblock %}
|
||||
{% block content %}
|
||||
<h2>اعمال پروفایل</h2>
|
||||
{{ guide_box(guide, 'profiles') }}
|
||||
|
||||
{% if pending and not pending.confirmed %}
|
||||
<div class="alert alert-warning">
|
||||
یک apply در انتظار تأیید است.
|
||||
<a href="{{ url_for('apply.status', log_id=pending.id) }}">ادامه</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="row g-3 mb-4">
|
||||
{% for p in profiles %}
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5>{{ p.name }}</h5>
|
||||
<p class="small text-muted">{{ p.description }}</p>
|
||||
<form method="post" action="{{ url_for('apply.apply', profile_id=p.id) }}">
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">پنجره rollback (دقیقه)</label>
|
||||
<input type="number" name="rollback_minutes" class="form-control form-control-sm" value="15" min="5" max="60">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-success" onclick="return confirm('Backup گرفته میشود. Docker ممکن است restart شود. ادامه؟')">
|
||||
اعمال پروفایل
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<h4>تاریخچه apply</h4>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm">
|
||||
<thead><tr><th>پروفایل</th><th>وضعیت</th><th>شروع</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for log in recent_logs %}
|
||||
<tr>
|
||||
<td>{{ log.profile.name if log.profile else '-' }}</td>
|
||||
<td><span class="badge bg-secondary">{{ log.status }}</span></td>
|
||||
<td>{{ log.started_at.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||
<td><a href="{{ url_for('apply.status', log_id=log.id) }}">جزئیات</a></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,62 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}وضعیت Apply{% endblock %}
|
||||
{% block content %}
|
||||
<h2>وضعیت اعمال پروفایل</h2>
|
||||
<p>پروفایل: <strong>{{ log.profile.name if log.profile else '-' }}</strong></p>
|
||||
<p>وضعیت: <span class="badge bg-{% if log.status == 'confirmed' %}success{% elif log.status == 'failed' %}danger{% else %}warning{% endif %}">{{ log.status }}</span></p>
|
||||
|
||||
{% if not log.confirmed and log.status == 'pending_confirm' %}
|
||||
<div class="alert alert-warning" id="rollback-alert">
|
||||
<strong>پنجره تست:</strong>
|
||||
<span id="countdown">{{ remaining_seconds }}</span> ثانیه تا rollback خودکار
|
||||
</div>
|
||||
<div class="d-flex gap-2 mb-4">
|
||||
<form method="post" action="{{ url_for('apply.confirm', log_id=log.id) }}">
|
||||
<button type="submit" class="btn btn-success">تأیید و نگهداشتن تغییرات</button>
|
||||
</form>
|
||||
<form method="post" action="{{ url_for('apply.rollback', log_id=log.id) }}">
|
||||
<button type="submit" class="btn btn-warning">Rollback دستی</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<h4>جزئیات مراحل</h4>
|
||||
{% for step in details.get('steps', []) %}
|
||||
<div class="card mb-2">
|
||||
<div class="card-body py-2">
|
||||
<div class="d-flex justify-content-between">
|
||||
<span><strong>{{ step.category }}</strong> — {{ step.get('mirror', '') }}</span>
|
||||
{% if step.get('success', True) %}
|
||||
<span class="text-success">موفق</span>
|
||||
{% else %}
|
||||
<span class="text-danger">ناموفق</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if step.get('message') %}<small class="text-muted">{{ step.message }}</small>{% endif %}
|
||||
{% if step.get('error') %}<small class="text-danger d-block">{{ step.error }}</small>{% endif %}
|
||||
{% if step.get('warning') %}<small class="text-warning d-block">{{ step.warning }}</small>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<a href="{{ url_for('main.dashboard') }}" class="btn btn-secondary mt-3">داشبورد</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
{% if not log.confirmed and log.status == 'pending_confirm' %}
|
||||
<script>
|
||||
(function() {
|
||||
let remaining = {{ remaining_seconds }};
|
||||
const el = document.getElementById('countdown');
|
||||
const tick = setInterval(function() {
|
||||
remaining--;
|
||||
if (el) el.textContent = remaining;
|
||||
if (remaining <= 0) {
|
||||
clearInterval(tick);
|
||||
location.reload();
|
||||
}
|
||||
}, 1000);
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,24 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}ورود — Mirror Manager{% endblock %}
|
||||
{% block content %}
|
||||
<div class="row justify-content-center mt-5">
|
||||
<div class="col-md-5">
|
||||
<div class="card shadow">
|
||||
<div class="card-body p-4">
|
||||
<h4 class="card-title text-center mb-4">ورود به Mirror Manager</h4>
|
||||
<form method="post">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">نام کاربری</label>
|
||||
<input type="text" name="username" class="form-control" required autofocus>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">رمز عبور</label>
|
||||
<input type="password" name="password" class="form-control" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-100">ورود</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,51 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fa" dir="rtl">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}مدیریت میرور{% endblock %}</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.rtl.min.css" rel="stylesheet">
|
||||
<link href="{{ url_for('static', filename='css/style.css') }}" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mb-4">
|
||||
<div class="container">
|
||||
<a class="navbar-brand" href="{{ url_for('main.dashboard') }}">Mirror Manager</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#nav">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="nav">
|
||||
<ul class="navbar-nav me-auto">
|
||||
<li class="nav-item"><a class="nav-link" href="{{ url_for('main.dashboard') }}">داشبورد</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="{{ url_for('mirrors.list_mirrors') }}">میرورها</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="{{ url_for('profiles.list_profiles') }}">پروفایلها</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="{{ url_for('apply.index') }}">اعمال تنظیمات</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="{{ url_for('restore.index') }}">بازگردانی</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="{{ url_for('settings.index') }}">تنظیمات</a></li>
|
||||
</ul>
|
||||
<ul class="navbar-nav">
|
||||
<li class="nav-item"><a class="nav-link" href="{{ url_for('auth.logout') }}">خروج</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container pb-5">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,105 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "macros.html" import guide_box %}
|
||||
{% block title %}داشبورد{% endblock %}
|
||||
{% block content %}
|
||||
<h2 class="mb-4">داشبورد</h2>
|
||||
{{ guide_box(guide, 'dashboard') }}
|
||||
|
||||
{% if pending and not pending.confirmed %}
|
||||
<div class="alert alert-warning">
|
||||
<strong>اعمال در انتظار تأیید!</strong>
|
||||
پروفایل «{{ pending.profile.name if pending.profile else '-' }}» اعمال شده.
|
||||
<a href="{{ url_for('apply.status', log_id=pending.id) }}" class="alert-link">مشاهده و تأیید / rollback</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">اطلاعات سیستم</div>
|
||||
<div class="card-body">
|
||||
<p><strong>Ubuntu:</strong> {{ status.ubuntu_description }}</p>
|
||||
<p><strong>Codename:</strong> {{ status.ubuntu_codename }}</p>
|
||||
<p><strong>Host root:</strong> <code>{{ status.host_root }}</code></p>
|
||||
{% if state and state.initial_backup_done %}
|
||||
<span class="badge bg-success">Backup اولیه گرفته شده</span>
|
||||
{% else %}
|
||||
<span class="badge bg-warning">Backup اولیه در انتظار</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">دسترسی سریع</div>
|
||||
<div class="card-body d-grid gap-2">
|
||||
<a href="{{ url_for('apply.index') }}" class="btn btn-primary">اعمال پروفایل</a>
|
||||
<a href="{{ url_for('mirrors.list_mirrors') }}" class="btn btn-outline-secondary">مدیریت میرورها</a>
|
||||
<a href="{{ url_for('restore.index') }}" class="btn btn-outline-warning">بازگردانی</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between">
|
||||
<span>DNS</span>
|
||||
<a href="{{ url_for('main.guide', section='dns') }}" class="btn btn-sm btn-outline-info">راهنما</a>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if status.dns.dropin_exists %}
|
||||
<pre class="small bg-light p-2">{{ status.dns.content }}</pre>
|
||||
{% else %}
|
||||
<p class="text-muted">drop-in Mirror Manager تنظیم نشده</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between">
|
||||
<span>Docker</span>
|
||||
<a href="{{ url_for('main.guide', section='docker') }}" class="btn btn-sm btn-outline-info">راهنما</a>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<pre class="small bg-light p-2">{{ status.docker.config | tojson(indent=2) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between">
|
||||
<span>APT sources.list</span>
|
||||
<a href="{{ url_for('main.guide', section='apt') }}" class="btn btn-sm btn-outline-info">راهنما</a>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<pre class="small bg-light p-2">{{ status.apt.sources_list or 'تنظیم نشده' }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if recent_tests %}
|
||||
<h4 class="mt-4">آخرین تستها</h4>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-striped">
|
||||
<thead>
|
||||
<tr><th>میرور</th><th>نوع</th><th>نتیجه</th><th>تأخیر</th><th>زمان</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in recent_tests %}
|
||||
<tr>
|
||||
<td>{{ r.mirror.name }}</td>
|
||||
<td>{{ r.test_type }}</td>
|
||||
<td>{% if r.success %}<span class="text-success">✓</span>{% else %}<span class="text-danger">✗</span>{% endif %}</td>
|
||||
<td>{{ '%.0f'|format(r.latency_ms) if r.latency_ms else '-' }} ms</td>
|
||||
<td>{{ r.tested_at.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,18 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ guide.title }}{% endblock %}
|
||||
{% block content %}
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('main.dashboard') }}">داشبورد</a></li>
|
||||
<li class="breadcrumb-item active">{{ guide.title }}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
<h2>{{ guide.title }}</h2>
|
||||
<p class="text-muted">{{ guide.summary }}</p>
|
||||
<div class="card">
|
||||
<div class="card-body guide-content">
|
||||
{{ guide.content | safe }}
|
||||
</div>
|
||||
</div>
|
||||
<a href="javascript:history.back()" class="btn btn-secondary mt-3">بازگشت</a>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,13 @@
|
||||
{% macro guide_box(guide, section) %}
|
||||
{% if guide %}
|
||||
<div class="card mb-4 border-info">
|
||||
<div class="card-header bg-info text-white d-flex justify-content-between align-items-center">
|
||||
<span>{{ guide.title }}</span>
|
||||
<a href="{{ url_for('main.guide', section=section) }}" class="btn btn-sm btn-light">راهنمای کامل</a>
|
||||
</div>
|
||||
<div class="card-body guide-content small">
|
||||
{{ guide.summary }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
@@ -0,0 +1,52 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{% if mirror %}ویرایش{% else %}ایجاد{% endif %} میرور{% endblock %}
|
||||
{% block content %}
|
||||
<h2>{% if mirror %}ویرایش میرور{% else %}میرور جدید{% endif %}</h2>
|
||||
<form method="post" class="card p-4 mt-3">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">دسته</label>
|
||||
<select name="category" class="form-select" required id="category">
|
||||
{% for cat in categories %}
|
||||
<option value="{{ cat }}" {% if mirror and mirror.category == cat %}selected{% endif %}>{{ category_labels[cat] }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">نام</label>
|
||||
<input type="text" name="name" class="form-control" required value="{{ mirror.name if mirror else '' }}">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">URL</label>
|
||||
<input type="text" name="url" class="form-control" value="{{ mirror.url if mirror and mirror.url else '' }}" placeholder="https://...">
|
||||
<div class="form-text">برای APT، Docker، GitHub، pip، npm</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">IPها (DNS)</label>
|
||||
<input type="text" name="ips" class="form-control" value="{{ mirror.ips if mirror and mirror.ips else '' }}" placeholder="1.2.3.4,5.6.7.8">
|
||||
</div>
|
||||
<div class="col-md-6" id="github-prefix-field">
|
||||
<label class="form-label">GitHub insteadOf prefix</label>
|
||||
<input type="text" name="instead_prefix" class="form-control" value="{{ mirror.get_meta().get('instead_prefix', '') if mirror else '' }}">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">اولویت (کمتر = بالاتر)</label>
|
||||
<input type="number" name="priority" class="form-control" value="{{ mirror.priority if mirror else 100 }}">
|
||||
</div>
|
||||
<div class="col-md-3 d-flex align-items-end">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="enabled" id="enabled" {% if not mirror or mirror.enabled %}checked{% endif %}>
|
||||
<label class="form-check-label" for="enabled">فعال</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">یادداشت</label>
|
||||
<textarea name="notes" class="form-control" rows="2">{{ mirror.notes if mirror and mirror.notes else '' }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<button type="submit" class="btn btn-primary">ذخیره</button>
|
||||
<a href="{{ url_for('mirrors.list_mirrors') }}" class="btn btn-secondary">انصراف</a>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,64 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}میرورها{% endblock %}
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2>مدیریت میرورها</h2>
|
||||
<div>
|
||||
<a href="{{ url_for('mirrors.create') }}" class="btn btn-primary">+ میرور جدید</a>
|
||||
<a href="{{ url_for('mirrors.results') }}" class="btn btn-outline-secondary">نتایج تست</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="btn-group mb-3 flex-wrap" role="group">
|
||||
<a href="{{ url_for('mirrors.list_mirrors') }}" class="btn btn-sm {% if not current_category %}btn-dark{% else %}btn-outline-dark{% endif %}">همه</a>
|
||||
{% for cat in categories %}
|
||||
<a href="{{ url_for('mirrors.list_mirrors', category=cat) }}" class="btn btn-sm {% if current_category == cat %}btn-dark{% else %}btn-outline-dark{% endif %}">
|
||||
{{ category_labels[cat] }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if current_category and guides.get(current_category) %}
|
||||
<div class="alert alert-info d-flex justify-content-between">
|
||||
<span>{{ guides[current_category].summary }}</span>
|
||||
<a href="{{ url_for('main.guide', section=current_category) }}" class="alert-link">راهنمای {{ guides[current_category].title }}</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="{{ url_for('mirrors.test_all') }}" class="mb-3">
|
||||
{% if current_category %}<input type="hidden" name="category" value="{{ current_category }}">{% endif %}
|
||||
<button type="submit" class="btn btn-sm btn-info">تست همه{% if current_category %} {{ category_labels[current_category] }}{% endif %}</button>
|
||||
</form>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>دسته</th><th>نام</th><th>URL / IP</th><th>اولویت</th><th>فعال</th><th>عملیات</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for m in mirrors %}
|
||||
<tr>
|
||||
<td><span class="badge bg-secondary">{{ category_labels.get(m.category, m.category) }}</span></td>
|
||||
<td>{{ m.name }}</td>
|
||||
<td><code class="small">{{ m.url or m.ips or '-' }}</code></td>
|
||||
<td>{{ m.priority }}</td>
|
||||
<td>{% if m.enabled %}<span class="text-success">بله</span>{% else %}<span class="text-muted">خیر</span>{% endif %}</td>
|
||||
<td>
|
||||
<form method="post" action="{{ url_for('mirrors.test_one', mirror_id=m.id) }}" class="d-inline">
|
||||
<button type="submit" class="btn btn-sm btn-outline-info">تست</button>
|
||||
</form>
|
||||
<a href="{{ url_for('mirrors.edit', mirror_id=m.id) }}" class="btn btn-sm btn-outline-primary">ویرایش</a>
|
||||
<form method="post" action="{{ url_for('mirrors.delete', mirror_id=m.id) }}" class="d-inline" onsubmit="return confirm('حذف شود؟')">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger">حذف</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="6" class="text-center text-muted">میروری یافت نشد</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,32 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}نتایج تست{% endblock %}
|
||||
{% block content %}
|
||||
<h2>نتایج تست میرورها</h2>
|
||||
<div class="btn-group mb-3">
|
||||
<a href="{{ url_for('mirrors.results') }}" class="btn btn-sm btn-outline-dark">همه</a>
|
||||
{% for cat in categories %}
|
||||
<a href="{{ url_for('mirrors.results', category=cat) }}" class="btn btn-sm btn-outline-dark">{{ category_labels[cat] }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-sm">
|
||||
<thead><tr><th>میرور</th><th>دسته</th><th>تست</th><th>موفق</th><th>ms</th><th>خطا</th><th>زمان</th></tr></thead>
|
||||
<tbody>
|
||||
{% for r in results %}
|
||||
<tr>
|
||||
<td>{{ r.mirror.name }}</td>
|
||||
<td>{{ category_labels.get(r.mirror.category, r.mirror.category) }}</td>
|
||||
<td>{{ r.test_type }}</td>
|
||||
<td>{% if r.success %}✓{% else %}✗{% endif %}</td>
|
||||
<td>{{ '%.0f'|format(r.latency_ms) if r.latency_ms else '-' }}</td>
|
||||
<td class="small text-danger">{{ r.error or '' }}</td>
|
||||
<td>{{ r.tested_at.strftime('%m-%d %H:%M') }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="7" class="text-center">نتیجهای نیست — تست را اجرا کنید</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<a href="{{ url_for('mirrors.list_mirrors') }}" class="btn btn-secondary">بازگشت</a>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,41 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "macros.html" import guide_box %}
|
||||
{% block title %}{% if profile %}ویرایش{% else %}ایجاد{% endif %} پروفایل{% endblock %}
|
||||
{% block content %}
|
||||
<h2>{% if profile %}ویرایش پروفایل{% else %}پروفایل جدید{% endif %}</h2>
|
||||
{{ guide_box(guide, 'profiles') }}
|
||||
<form method="post" class="card p-4 mt-3">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">نام پروفایل</label>
|
||||
<input type="text" name="name" class="form-control" required value="{{ profile.name if profile else '' }}">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">توضیحات</label>
|
||||
<textarea name="description" class="form-control" rows="2">{{ profile.description if profile else '' }}</textarea>
|
||||
</div>
|
||||
<div class="form-check mb-4">
|
||||
<input class="form-check-input" type="checkbox" name="is_default" id="is_default" {% if profile and profile.is_default %}checked{% endif %}>
|
||||
<label class="form-check-label" for="is_default">پروفایل پیشفرض</label>
|
||||
</div>
|
||||
<h5>انتخاب میرور برای هر بخش</h5>
|
||||
<p class="text-muted small">بخشی که انتخاب نکنید در apply نادیده گرفته میشود.</p>
|
||||
<div class="row g-3">
|
||||
{% for cat in categories %}
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">{{ category_labels[cat] }}</label>
|
||||
<select name="mirror_{{ cat }}" class="form-select">
|
||||
<option value="">— انتخاب نشود —</option>
|
||||
{% for m in mirrors_by_cat.get(cat, []) %}
|
||||
<option value="{{ m.id }}" {% if current.get(cat) == m.id %}selected{% endif %}>{{ m.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<a href="{{ url_for('main.guide', section=cat) }}" class="small">راهنما</a>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<button type="submit" class="btn btn-primary">ذخیره</button>
|
||||
<a href="{{ url_for('profiles.list_profiles') }}" class="btn btn-secondary">انصراف</a>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,39 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "macros.html" import guide_box %}
|
||||
{% block title %}پروفایلها{% endblock %}
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between mb-4">
|
||||
<h2>پروفایلها</h2>
|
||||
<a href="{{ url_for('profiles.create') }}" class="btn btn-primary">+ پروفایل جدید</a>
|
||||
</div>
|
||||
{{ guide_box(guide, 'profiles') }}
|
||||
<div class="row g-3">
|
||||
{% for p in profiles %}
|
||||
<div class="col-md-4">
|
||||
<div class="card h-100 {% if p.is_default %}border-primary{% endif %}">
|
||||
<div class="card-body">
|
||||
<h5>{{ p.name }}{% if p.is_default %} <span class="badge bg-primary">پیشفرض</span>{% endif %}</h5>
|
||||
<p class="text-muted small">{{ p.description or '' }}</p>
|
||||
<ul class="list-unstyled small">
|
||||
{% for item in p.items %}
|
||||
<li>{{ item.category }}: <strong>{{ item.mirror.name if item.mirror else '-' }}</strong></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="card-footer bg-transparent">
|
||||
<a href="{{ url_for('profiles.edit', profile_id=p.id) }}" class="btn btn-sm btn-outline-primary">ویرایش</a>
|
||||
<form method="post" action="{{ url_for('apply.apply', profile_id=p.id) }}" class="d-inline">
|
||||
<input type="hidden" name="rollback_minutes" value="15">
|
||||
<button type="submit" class="btn btn-sm btn-success" onclick="return confirm('پروفایل اعمال شود؟ Docker restart میشود.')">اعمال</button>
|
||||
</form>
|
||||
<form method="post" action="{{ url_for('profiles.delete', profile_id=p.id) }}" class="d-inline" onsubmit="return confirm('حذف شود؟')">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger">حذف</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="col-12"><p class="text-muted">پروفایلی وجود ندارد</p></div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,59 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "macros.html" import guide_box %}
|
||||
{% block title %}بازگردانی{% endblock %}
|
||||
{% block content %}
|
||||
<h2>بازگردانی تنظیمات</h2>
|
||||
{{ guide_box(guide, 'restore') }}
|
||||
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card border-warning">
|
||||
<div class="card-body">
|
||||
<h5>بازگردانی به تنظیمات اولیه</h5>
|
||||
<p class="small text-muted">وضعیت سیستم قبل از اولین اجرای Mirror Manager</p>
|
||||
{% if state and state.initial_backup_path %}
|
||||
<p class="small"><code>{{ state.initial_backup_path }}</code></p>
|
||||
<form method="post" action="{{ url_for('restore.restore_initial_view') }}" onsubmit="return confirm('مطمئنید؟')">
|
||||
<button type="submit" class="btn btn-warning">بازگردانی اولیه</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<p class="text-danger">Backup اولیه یافت نشد</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4>Backupهای موجود</h4>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm">
|
||||
<thead><tr><th>نام</th><th>برچسب</th><th>عملیات</th></tr></thead>
|
||||
<tbody>
|
||||
{% for b in backups %}
|
||||
<tr>
|
||||
<td><code class="small">{{ b.name }}</code></td>
|
||||
<td>{{ b.label }}</td>
|
||||
<td>
|
||||
<form method="post" action="{{ url_for('restore.restore_backup_view') }}" class="d-inline" onsubmit="return confirm('این backup restore شود؟')">
|
||||
<input type="hidden" name="backup_path" value="{{ b.path }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-warning">Restore</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="3" class="text-muted">backupی وجود ندارد</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="card mt-4 bg-light">
|
||||
<div class="card-body">
|
||||
<h6>دستورات SSH</h6>
|
||||
<pre class="mb-0">python3 mirror-manager restore --initial
|
||||
python3 mirror-manager restore --backup /path/to/backup
|
||||
python3 mirror-manager disable
|
||||
python3 mirror-manager enable</pre>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,52 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "macros.html" import guide_box %}
|
||||
{% block title %}تنظیمات{% endblock %}
|
||||
{% block content %}
|
||||
<h2>تنظیمات سیستم</h2>
|
||||
{{ guide_box(guide, 'settings') }}
|
||||
|
||||
<form method="post" class="card p-4">
|
||||
<h5>Auto-switch (اختیاری)</h5>
|
||||
<p class="text-muted small">در صورت fail شدن میرور فعال، به بهترین میرور جایگزین switch میشود.</p>
|
||||
<div class="row g-2 mb-4">
|
||||
{% for key, label in [
|
||||
('auto_switch_dns', 'DNS'),
|
||||
('auto_switch_apt', 'APT'),
|
||||
('auto_switch_docker', 'Docker'),
|
||||
('auto_switch_github', 'GitHub'),
|
||||
('auto_switch_pip', 'pip'),
|
||||
('auto_switch_npm', 'npm'),
|
||||
] %}
|
||||
<div class="col-md-4">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="{{ key }}" id="{{ key }}" {% if settings.get(key) == 'true' %}checked{% endif %}>
|
||||
<label class="form-check-label" for="{{ key }}">{{ label }}</label>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<h5>Rollback و تست</h5>
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">پنجره rollback پیشفرض (دقیقه)</label>
|
||||
<input type="number" name="rollback_minutes" class="form-control" value="{{ settings.get('rollback_minutes', '15') }}" min="5" max="60">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">فاصله تست auto-switch (دقیقه)</label>
|
||||
<input type="number" name="auto_test_interval_minutes" class="form-control" value="{{ settings.get('auto_test_interval_minutes', '30') }}" min="10" max="1440">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">حداکثر switch در روز</label>
|
||||
<input type="number" name="max_switches_per_day" class="form-control" value="{{ settings.get('max_switches_per_day', '3') }}" min="1" max="20">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-check mb-4">
|
||||
<input class="form-check-input" type="checkbox" name="rollback_on_all_fail" id="rollback_on_all_fail" {% if settings.get('rollback_on_all_fail', 'true') == 'true' %}checked{% endif %}>
|
||||
<label class="form-check-label" for="rollback_on_all_fail">هشدار وقتی همه میرورهای یک بخش fail شوند</label>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">ذخیره تنظیمات</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,279 @@
|
||||
# استقرار Mirror Manager روی Dokploy
|
||||
|
||||
**دامنه:** `mirror.itistan.ir`
|
||||
**Repo:** https://git.itistan.ir/mohammadian7/mirror
|
||||
|
||||
---
|
||||
|
||||
## نمای کلی
|
||||
|
||||
```
|
||||
Gitea (git.itistan.ir)
|
||||
│
|
||||
▼ clone + build
|
||||
Dokploy
|
||||
│
|
||||
├── Traefik → https://mirror.itistan.ir
|
||||
└── Container (privileged)
|
||||
└── mount /etc → apply DNS/APT/Docker/Git
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## مرحله ۰ — پیشنیاز DNS
|
||||
|
||||
در پنل DNS دامنه `itistan.ir` یک رکورد **A** بسازید:
|
||||
|
||||
| نوع | نام | مقدار |
|
||||
|-----|-----|-------|
|
||||
| A | `mirror` | IP سرور VPS |
|
||||
|
||||
بعد از propagate شدن:
|
||||
|
||||
```bash
|
||||
ping mirror.itistan.ir
|
||||
```
|
||||
|
||||
باید IP سرور را برگرداند.
|
||||
|
||||
---
|
||||
|
||||
## مرحله ۱ — Push کد به Gitea
|
||||
|
||||
روی **کامپیوتر خودتان** (Windows) در پوشه پروژه:
|
||||
|
||||
```powershell
|
||||
cd C:\Users\DELL\Desktop\Project\mirror
|
||||
|
||||
git init
|
||||
git branch -M main
|
||||
git remote add origin https://git.itistan.ir/mohammadian7/mirror.git
|
||||
|
||||
git add .
|
||||
git commit -m "Initial Mirror Manager for mirror.itistan.ir"
|
||||
git push -u origin main
|
||||
```
|
||||
|
||||
> اگر Gitea username/password خواست، از token یا رمز Gitea استفاده کنید.
|
||||
> repo فعلاً public است؛ بعداً میتوانید private کنید.
|
||||
|
||||
---
|
||||
|
||||
## مرحله ۲ — آمادهسازی سرور (یک بار)
|
||||
|
||||
SSH به VPS:
|
||||
|
||||
```bash
|
||||
# پوشه داده persistent
|
||||
sudo mkdir -p /var/lib/mirror-manager/data
|
||||
sudo chmod 755 /var/lib/mirror-manager/data
|
||||
|
||||
# (اختیاری) clone دستی برای CLI
|
||||
sudo git clone https://git.itistan.ir/mohammadian7/mirror.git /opt/mirror-manager
|
||||
sudo bash /opt/mirror-manager/deploy/install-cli.sh /opt/mirror-manager
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## مرحله ۳ — ساخت پروژه در Dokploy
|
||||
|
||||
### ۳.۱ Project
|
||||
|
||||
1. وارد پنل Dokploy شوید
|
||||
2. **Create Project** → نام مثلاً `mirror-manager`
|
||||
|
||||
### ۳.۲ اتصال Gitea (اگر قبلاً نکردید)
|
||||
|
||||
1. **Settings → Git Providers**
|
||||
2. **Add Provider** → Gitea
|
||||
3. URL: `https://git.itistan.ir`
|
||||
4. Token با دسترسی `read:repository` بسازید و وارد کنید
|
||||
|
||||
### ۳.۳ Application — Docker Compose
|
||||
|
||||
1. داخل پروژه → **Create Service** → **Docker Compose**
|
||||
2. تنظیمات:
|
||||
|
||||
| فیلد | مقدار |
|
||||
|------|-------|
|
||||
| Name | `mirror-manager` |
|
||||
| Source | Git |
|
||||
| Repository | `mohammadian7/mirror` |
|
||||
| Branch | `main` |
|
||||
| Compose Path | `docker-compose.yml` |
|
||||
| Build Path | `.` (root) |
|
||||
|
||||
3. **Save**
|
||||
|
||||
---
|
||||
|
||||
## مرحله ۴ — Environment Variables در Dokploy
|
||||
|
||||
در تب **Environment** این متغیرها را اضافه کنید:
|
||||
|
||||
```env
|
||||
SECRET_KEY=یک-رشته-تصادفی-حداقل-۳۲-کاراکتر
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=رمز-قوی-شما
|
||||
HOST_ROOT=/host
|
||||
DATA_DIR=/app/data
|
||||
ROLLBACK_MINUTES=15
|
||||
AUTO_TEST_INTERVAL_MINUTES=30
|
||||
MIRROR_CONTAINER_NAME=mirror-manager
|
||||
```
|
||||
|
||||
**SECRET_KEY** تصادفی:
|
||||
|
||||
```bash
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
> `.env` روی سرور لازم نیست — Dokploy خودش env را inject میکند.
|
||||
|
||||
---
|
||||
|
||||
## مرحله ۵ — Domain در Dokploy (دو روش)
|
||||
|
||||
### روش A — Traefik labels (پیشنهادی، از قبل در compose هست)
|
||||
|
||||
در `docker-compose.yml` labelها برای `mirror.itistan.ir` تنظیم شده. فقط **Deploy** کنید.
|
||||
|
||||
### روش B — Dokploy Domains UI
|
||||
|
||||
اگر label کار نکرد:
|
||||
|
||||
1. تب **Domains**
|
||||
2. Host: `mirror.itistan.ir`
|
||||
3. Port: `8765`
|
||||
4. HTTPS: فعال / Let's Encrypt
|
||||
5. **Deploy** مجدد
|
||||
|
||||
> هر دو روش همزمان نباشد — یکی کافی است.
|
||||
|
||||
---
|
||||
|
||||
## مرحله ۶ — Deploy
|
||||
|
||||
1. **Deploy** را بزنید
|
||||
2. لاگ build را ببینید (اولین بار build image ممکن است ۲–۵ دقیقه طول بکشد)
|
||||
3. ۱۰–۳۰ ثانیه صبر کنید تا Traefik گواهی SSL بگیرد
|
||||
4. باز کنید: **https://mirror.itistan.ir**
|
||||
5. login: `ADMIN_USERNAME` / `ADMIN_PASSWORD`
|
||||
|
||||
---
|
||||
|
||||
## مرحله ۷ — اولین استفاده
|
||||
|
||||
1. با **IP** به SSH وصل باشید (نه hostname)
|
||||
2. اولین login → backup اولیه خودکار
|
||||
3. **پروفایلها** → «آروان کامل» → **اعمال**
|
||||
4. ۱۵ دقیقه تست → **تأیید** یا **Rollback**
|
||||
|
||||
---
|
||||
|
||||
## CLI از SSH (بعد از deploy)
|
||||
|
||||
```bash
|
||||
# وضعیت
|
||||
mirror-manager status
|
||||
|
||||
# غیرفعال کردن UI (تنظیمات host دست نخورده)
|
||||
mirror-manager disable
|
||||
|
||||
# فعال کردن مجدد
|
||||
mirror-manager enable
|
||||
|
||||
# بازگردانی به قبل از نصب Mirror Manager
|
||||
mirror-manager restore --initial
|
||||
```
|
||||
|
||||
اگر `mirror-manager` پیدا نشد:
|
||||
|
||||
```bash
|
||||
export PYTHONPATH=/opt/mirror-manager
|
||||
python3 /opt/mirror-manager/mirror-manager status
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## بهروزرسانی بعدی
|
||||
|
||||
```bash
|
||||
# روی PC: push تغییرات
|
||||
git push origin main
|
||||
```
|
||||
|
||||
در Dokploy → **Redeploy** (یا webhook auto-deploy اگر فعال کردید).
|
||||
|
||||
---
|
||||
|
||||
## عیبیابی
|
||||
|
||||
### ۱. 502 / 504 از Traefik
|
||||
|
||||
```bash
|
||||
docker ps | grep mirror-manager
|
||||
docker logs mirror-manager --tail 50
|
||||
```
|
||||
|
||||
- container باید `Up` باشد
|
||||
- روی network `dokploy-network` باشد:
|
||||
|
||||
```bash
|
||||
docker network inspect dokploy-network | grep mirror-manager
|
||||
```
|
||||
|
||||
### ۲. SSL صادر نمیشود
|
||||
|
||||
- رکورد A درست است؟
|
||||
- پورت 80/443 از اینترنت باز است (فایروال سرور / cloud panel)
|
||||
- ۲–۳ دقیقه صبر + Redeploy
|
||||
|
||||
### ۳. Apply کار نمیکند
|
||||
|
||||
Container باید **privileged** + `pid: host` + mount `/etc:/host/etc` داشته باشد.
|
||||
|
||||
```bash
|
||||
docker inspect mirror-manager --format '{{.HostConfig.Privileged}}'
|
||||
# باید true باشد
|
||||
```
|
||||
|
||||
### ۴. apt update fail
|
||||
|
||||
میرور را در UI **تست** کنید؛ URL میرور را CRUD کنید.
|
||||
|
||||
### ۵. Docker restart و Dokploy down
|
||||
|
||||
طبیعی است — apply را در ساعات کمترافیک انجام دهید.
|
||||
|
||||
---
|
||||
|
||||
## امنیت (repo public)
|
||||
|
||||
Repo فعلاً public است:
|
||||
|
||||
- **هرگز** `.env` با رمز واقعی commit نکنید
|
||||
- `SECRET_KEY` و `ADMIN_PASSWORD` فقط در Dokploy Environment
|
||||
- بعداً repo را **private** کنید
|
||||
- رمز admin را بلافاصله عوض کنید
|
||||
|
||||
---
|
||||
|
||||
## چکلیست نهایی
|
||||
|
||||
- [ ] رکورد A: `mirror.itistan.ir` → IP سرور
|
||||
- [ ] کد push شده به Gitea
|
||||
- [ ] Dokploy Compose از repo clone میکند
|
||||
- [ ] Environment variables تنظیم شده
|
||||
- [ ] Deploy موفق
|
||||
- [ ] https://mirror.itistan.ir/login باز میشود
|
||||
- [ ] `install-cli.sh` روی host اجرا شده
|
||||
- [ ] backup اولیه در UI تأیید شده
|
||||
- [ ] SSH با IP (نه hostname)
|
||||
|
||||
---
|
||||
|
||||
## پشتیبانی
|
||||
|
||||
- راهنمای هر بخش داخل UI: `/guide/dns` و ...
|
||||
- README اصلی: [README.md](../README.md)
|
||||
@@ -0,0 +1,18 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
INSTALL_DIR="${1:-/opt/mirror-manager}"
|
||||
BIN="/usr/local/bin/mirror-manager"
|
||||
|
||||
echo "نصب Mirror Manager CLI..."
|
||||
sudo mkdir -p /var/lib/mirror-manager/data
|
||||
sudo cp "$INSTALL_DIR/mirror-manager" "$BIN"
|
||||
sudo chmod +x "$BIN"
|
||||
|
||||
PROFILE_D="/etc/profile.d/mirror-manager.sh"
|
||||
echo "export PYTHONPATH=$INSTALL_DIR" | sudo tee "$PROFILE_D" > /dev/null
|
||||
echo "export MIRROR_CONTAINER_NAME=mirror-manager" | sudo tee -a "$PROFILE_D" > /dev/null
|
||||
|
||||
echo "CLI نصب شد: mirror-manager"
|
||||
echo " mirror-manager status"
|
||||
echo " mirror-manager restore --initial"
|
||||
@@ -0,0 +1,33 @@
|
||||
services:
|
||||
mirror-manager:
|
||||
build: .
|
||||
container_name: mirror-manager
|
||||
restart: unless-stopped
|
||||
privileged: true
|
||||
pid: host
|
||||
environment:
|
||||
- SECRET_KEY=${SECRET_KEY:-change-me-in-production}
|
||||
- ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
|
||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-change-me}
|
||||
- HOST_ROOT=/host
|
||||
- DATA_DIR=/app/data
|
||||
- ROLLBACK_MINUTES=${ROLLBACK_MINUTES:-15}
|
||||
- AUTO_TEST_INTERVAL_MINUTES=${AUTO_TEST_INTERVAL_MINUTES:-30}
|
||||
volumes:
|
||||
- /etc:/host/etc
|
||||
- /var/lib/mirror-manager/data:/app/data
|
||||
- /usr/bin/docker:/usr/bin/docker:ro
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
networks:
|
||||
- dokploy-network
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.docker.network=dokploy-network
|
||||
- traefik.http.routers.mirror-itistan.rule=Host(`mirror.itistan.ir`)
|
||||
- traefik.http.routers.mirror-itistan.entrypoints=websecure
|
||||
- traefik.http.routers.mirror-itistan.tls.certResolver=letsencrypt
|
||||
- traefik.http.services.mirror-itistan.loadbalancer.server.port=8765
|
||||
|
||||
networks:
|
||||
dokploy-network:
|
||||
external: true
|
||||
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env python3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from app import cli # noqa: E402
|
||||
|
||||
if __name__ == "__main__":
|
||||
cli()
|
||||
@@ -0,0 +1,8 @@
|
||||
Flask==3.0.3
|
||||
Flask-SQLAlchemy==3.1.1
|
||||
Flask-Admin==1.6.1
|
||||
Flask-Login==0.6.3
|
||||
APScheduler==3.10.4
|
||||
WTForms==3.1.2
|
||||
requests==2.32.3
|
||||
gunicorn==22.0.0
|
||||
Reference in New Issue
Block a user