Initial commit

This commit is contained in:
2026-06-03 17:33:19 +03:30
commit 9608342428
48 changed files with 3562 additions and 0 deletions
+19
View File
@@ -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
View File
@@ -0,0 +1,11 @@
__pycache__/
*.py[cod]
*.db
.env
data/
*.log
.venv/
venv/
dist/
build/
*.egg-info/
+27
View File
@@ -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"]
+192
View File
@@ -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
View File
@@ -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()
+34
View File
@@ -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)
+7
View File
@@ -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
View File
@@ -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
View File
@@ -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)
View File
+176
View File
@@ -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", {}))
+34
View File
@@ -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"))
+41
View File
@@ -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)
+133
View File
@@ -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,
)
+117
View File
@@ -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
View File
@@ -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()
View File
+142
View File
@@ -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)",
}
+65
View File
@@ -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,
}
+173
View File
@@ -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
+65
View File
@@ -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"])
+57
View File
@@ -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ها موقتاً قطع می‌شوند",
}
+107
View File
@@ -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")
+60
View File
@@ -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")
+35
View File
@@ -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)}
+111
View File
@@ -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)
+157
View File
@@ -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
+25
View File
@@ -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;
}
+53
View File
@@ -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 %}
+62
View File
@@ -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 %}
+24
View File
@@ -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 %}
+51
View File
@@ -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>
+105
View File
@@ -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 %}
+18
View File
@@ -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 %}
+13
View File
@@ -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 %}
+52
View File
@@ -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 %}
+64
View File
@@ -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 %}
+32
View File
@@ -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 %}
+41
View File
@@ -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 %}
+39
View File
@@ -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 %}
+59
View File
@@ -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 %}
+52
View File
@@ -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 %}
+279
View File
@@ -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)
+18
View File
@@ -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"
+33
View File
@@ -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
+10
View File
@@ -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()
+8
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
from app import create_app
app = create_app()
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8765, debug=False)