commit 9608342428171dd3e86fb07d82119fc52bd4c0e3 Author: mohammadian7 Date: Wed Jun 3 17:33:19 2026 +0330 Initial commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..56378a1 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9291276 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +__pycache__/ +*.py[cod] +*.db +.env +data/ +*.log +.venv/ +venv/ +dist/ +build/ +*.egg-info/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2c179e9 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..5a6ae54 --- /dev/null +++ b/README.md @@ -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 /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 diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..2bba85c --- /dev/null +++ b/app/__init__.py @@ -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() diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..9846f44 --- /dev/null +++ b/app/config.py @@ -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) diff --git a/app/extensions.py b/app/extensions.py new file mode 100644 index 0000000..3c7055b --- /dev/null +++ b/app/extensions.py @@ -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 = "لطفاً وارد شوید." diff --git a/app/guides.py b/app/guides.py new file mode 100644 index 0000000..4f0bd85 --- /dev/null +++ b/app/guides.py @@ -0,0 +1,187 @@ +GUIDES = { + "dns": { + "title": "راهنمای DNS", + "summary": "تنظیم DNS از طریق systemd-resolved", + "content": """ +
DNS چیست؟
+

DNS آدرس دامنه‌ها (مثل github.com) را به IP تبدیل می‌کند. در ایران برای دسترسی به سرویس‌های فیلترشده از DNSهای داخلی استفاده می‌شود.

+ +
چه کاری انجام می‌دهد؟
+ + +
DNSهای پیش‌فرض
+ + +
نکات مهم
+ +""", + }, + "apt": { + "title": "راهنمای APT", + "summary": "تنظیم مخزن Ubuntu برای apt update/install", + "content": """ +
APT چیست؟
+

مدیر بسته Ubuntu. برای نصب و آپدیت پکیج‌های سیستم از مخزن (mirror) استفاده می‌کند.

+ +
چه کاری انجام می‌دهد؟
+ + +
میرورهای پیش‌فرض
+ + +
نسخه‌های Ubuntu
+ +""", + }, + "docker": { + "title": "راهنمای Docker Registry", + "summary": "تنظیم registry-mirrors برای pull ایمیج", + "content": """ +
Registry Mirror چیست؟
+

وقتی docker pull می‌زنید، درخواست از mirror ایرانی عبور می‌کند و سرعت بیشتر و قطعی کمتر می‌شود.

+ +
چه کاری انجام می‌دهد؟
+ + +
هشدار
+

با restart Docker، containerها (از جمله Dokploy) چند ثانیه قطع می‌شوند. بهتر است در ساعات کم‌ترافیک apply کنید.

+ +
میرور پیش‌فرض
+ +""", + }, + "github": { + "title": "راهنمای GitHub", + "summary": "تنظیم git برای clone/pull از GitHub", + "content": """ +
مشکل GitHub
+

معمولاً git pull و git clone از GitHub کند یا timeout می‌شود.

+ +
چه کاری انجام می‌دهد؟
+ + +
تست
+

بعد از apply، دستور git ls-remote روی repo نمونه اجرا می‌شود.

+ +
میرورهای پیش‌فرض
+ +""", + }, + "pip": { + "title": "راهنمای pip", + "summary": "تنظیم index-url برای pip install", + "content": """ +
pip چیست؟
+

مدیر بسته Python. فایل /etc/pip.conf برای تنظیم سراسری استفاده می‌شود.

+ +
میرور پیش‌فرض
+ +""", + }, + "npm": { + "title": "راهنمای npm", + "summary": "تنظیم registry برای npm install", + "content": """ +
npm چیست؟
+

مدیر بسته Node.js. فایل /root/.npmrc برای تنظیم registry سراسری استفاده می‌شود.

+""", + }, + "profiles": { + "title": "راهنمای پروفایل‌ها", + "summary": "ترکیب چند تنظیم در یک کلیک", + "content": """ +
پروفایل چیست؟
+

مجموعه‌ای از میرورها برای DNS، APT، Docker، GitHub و... که با یک کلیک apply می‌شوند.

+ +
پروفایل‌های پیش‌فرض
+ + +
Rollback
+

بعد از apply، پنجره زمانی (پیش‌فرض ۱۵ دقیقه) برای تست دارید. اگر تأیید نکنید، تنظیمات قبلی بازگردانده می‌شود.

+""", + }, + "settings": { + "title": "راهنمای تنظیمات", + "summary": "Auto-switch و rollback", + "content": """ +
Auto-switch
+

اگر فعال باشد، برنامه به صورت دوره‌ای میرور فعال هر بخش را تست می‌کند. در صورت fail، به بهترین میرور جایگزین switch می‌کند.

+ +
Rollback خودکار
+

بعد از apply، تا پایان پنجره زمانی می‌توانید تأیید کنید. در غیر این صورت backup قبلی restore می‌شود.

+ +
محدودیت switch
+

حداکثر تعداد switch خودکار در روز قابل تنظیم است (پیش‌فرض: ۳).

+""", + }, + "restore": { + "title": "راهنمای بازگردانی", + "summary": "Restore backup اولیه یا backupهای قبلی", + "content": """ +
Backup اولیه
+

در اولین اجرا، snapshot از تنظیمات فعلی سیستم گرفته می‌شود.

+ +
بازگردانی
+ + +
از SSH
+
mirror-manager restore --initial
+mirror-manager restore --backup /path/to/backup
+mirror-manager disable
+mirror-manager enable
+""", + }, + "dashboard": { + "title": "راهنمای داشبورد", + "summary": "نمای کلی وضعیت سیستم", + "content": """ +
داشبورد
+

وضعیت فعلی DNS، APT، Docker و GitHub را نشان می‌دهد. آخرین apply و rollback pending هم نمایش داده می‌شود.

+""", + }, +} diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..62b49f4 --- /dev/null +++ b/app/models.py @@ -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"" + + +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) diff --git a/app/routes/__init__.py b/app/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/routes/apply.py b/app/routes/apply.py new file mode 100644 index 0000000..22b7d6f --- /dev/null +++ b/app/routes/apply.py @@ -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/", 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/") +@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/", methods=["POST"]) +@login_required +def confirm(log_id): + confirm_apply(log_id) + flash("تغییرات تأیید و ثبت شد.", "success") + return redirect(url_for("main.dashboard")) + + +@apply_bp.route("/rollback/", 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/") +@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", {})) diff --git a/app/routes/auth.py b/app/routes/auth.py new file mode 100644 index 0000000..c8c2520 --- /dev/null +++ b/app/routes/auth.py @@ -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")) diff --git a/app/routes/main.py b/app/routes/main.py new file mode 100644 index 0000000..0af815c --- /dev/null +++ b/app/routes/main.py @@ -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/
") +@login_required +def guide(section): + guide_data = GUIDES.get(section) + if not guide_data: + guide_data = {"title": "راهنما", "summary": "", "content": "

راهنما یافت نشد.

"} + return render_template("guide.html", guide=guide_data, section=section) diff --git a/app/routes/mirrors.py b/app/routes/mirrors.py new file mode 100644 index 0000000..305ec6a --- /dev/null +++ b/app/routes/mirrors.py @@ -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("//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("//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("//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, + ) diff --git a/app/routes/profiles.py b/app/routes/profiles.py new file mode 100644 index 0000000..f18a3d2 --- /dev/null +++ b/app/routes/profiles.py @@ -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("//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("//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")) diff --git a/app/seed.py b/app/seed.py new file mode 100644 index 0000000..875e6f4 --- /dev/null +++ b/app/seed.py @@ -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() diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/services/applier.py b/app/services/applier.py new file mode 100644 index 0000000..aa142f8 --- /dev/null +++ b/app/services/applier.py @@ -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)", + } diff --git a/app/services/apt.py b/app/services/apt.py new file mode 100644 index 0000000..e9d3849 --- /dev/null +++ b/app/services/apt.py @@ -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, + } diff --git a/app/services/backup.py b/app/services/backup.py new file mode 100644 index 0000000..b2cfa82 --- /dev/null +++ b/app/services/backup.py @@ -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 diff --git a/app/services/dns.py b/app/services/dns.py new file mode 100644 index 0000000..fafb7c2 --- /dev/null +++ b/app/services/dns.py @@ -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"]) diff --git a/app/services/docker_svc.py b/app/services/docker_svc.py new file mode 100644 index 0000000..c11e645 --- /dev/null +++ b/app/services/docker_svc.py @@ -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ها موقتاً قطع می‌شوند", + } diff --git a/app/services/github.py b/app/services/github.py new file mode 100644 index 0000000..fb22913 --- /dev/null +++ b/app/services/github.py @@ -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") diff --git a/app/services/host.py b/app/services/host.py new file mode 100644 index 0000000..d12d113 --- /dev/null +++ b/app/services/host.py @@ -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") diff --git a/app/services/pip_npm.py b/app/services/pip_npm.py new file mode 100644 index 0000000..29af726 --- /dev/null +++ b/app/services/pip_npm.py @@ -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)} diff --git a/app/services/scheduler.py b/app/services/scheduler.py new file mode 100644 index 0000000..13f28f0 --- /dev/null +++ b/app/services/scheduler.py @@ -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) diff --git a/app/services/tester.py b/app/services/tester.py new file mode 100644 index 0000000..8d80d6a --- /dev/null +++ b/app/services/tester.py @@ -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 diff --git a/app/static/css/style.css b/app/static/css/style.css new file mode 100644 index 0000000..50e7f6c --- /dev/null +++ b/app/static/css/style.css @@ -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; +} diff --git a/app/templates/apply/index.html b/app/templates/apply/index.html new file mode 100644 index 0000000..eb6b6b0 --- /dev/null +++ b/app/templates/apply/index.html @@ -0,0 +1,53 @@ +{% extends "base.html" %} +{% from "macros.html" import guide_box %} +{% block title %}اعمال تنظیمات{% endblock %} +{% block content %} +

اعمال پروفایل

+{{ guide_box(guide, 'profiles') }} + +{% if pending and not pending.confirmed %} +
+ یک apply در انتظار تأیید است. + ادامه +
+{% endif %} + +
+ {% for p in profiles %} +
+
+
+
{{ p.name }}
+

{{ p.description }}

+
+
+ + +
+ +
+
+
+
+ {% endfor %} +
+ +

تاریخچه apply

+
+ + + + {% for log in recent_logs %} + + + + + + + {% endfor %} + +
پروفایلوضعیتشروع
{{ log.profile.name if log.profile else '-' }}{{ log.status }}{{ log.started_at.strftime('%Y-%m-%d %H:%M') }}جزئیات
+
+{% endblock %} diff --git a/app/templates/apply/status.html b/app/templates/apply/status.html new file mode 100644 index 0000000..3268ac6 --- /dev/null +++ b/app/templates/apply/status.html @@ -0,0 +1,62 @@ +{% extends "base.html" %} +{% block title %}وضعیت Apply{% endblock %} +{% block content %} +

وضعیت اعمال پروفایل

+

پروفایل: {{ log.profile.name if log.profile else '-' }}

+

وضعیت: {{ log.status }}

+ +{% if not log.confirmed and log.status == 'pending_confirm' %} +
+ پنجره تست: + {{ remaining_seconds }} ثانیه تا rollback خودکار +
+
+
+ +
+
+ +
+
+{% endif %} + +

جزئیات مراحل

+{% for step in details.get('steps', []) %} +
+
+
+ {{ step.category }} — {{ step.get('mirror', '') }} + {% if step.get('success', True) %} + موفق + {% else %} + ناموفق + {% endif %} +
+ {% if step.get('message') %}{{ step.message }}{% endif %} + {% if step.get('error') %}{{ step.error }}{% endif %} + {% if step.get('warning') %}{{ step.warning }}{% endif %} +
+
+{% endfor %} + +داشبورد +{% endblock %} + +{% block scripts %} +{% if not log.confirmed and log.status == 'pending_confirm' %} + +{% endif %} +{% endblock %} diff --git a/app/templates/auth/login.html b/app/templates/auth/login.html new file mode 100644 index 0000000..4b00a36 --- /dev/null +++ b/app/templates/auth/login.html @@ -0,0 +1,24 @@ +{% extends "base.html" %} +{% block title %}ورود — Mirror Manager{% endblock %} +{% block content %} +
+
+
+
+

ورود به Mirror Manager

+
+
+ + +
+
+ + +
+ +
+
+
+
+
+{% endblock %} diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..736896f --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,51 @@ + + + + + + {% block title %}مدیریت میرور{% endblock %} + + + + + + +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} + + {% endfor %} + {% endif %} + {% endwith %} + + {% block content %}{% endblock %} +
+ + + {% block scripts %}{% endblock %} + + diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html new file mode 100644 index 0000000..194070f --- /dev/null +++ b/app/templates/dashboard.html @@ -0,0 +1,105 @@ +{% extends "base.html" %} +{% from "macros.html" import guide_box %} +{% block title %}داشبورد{% endblock %} +{% block content %} +

داشبورد

+{{ guide_box(guide, 'dashboard') }} + +{% if pending and not pending.confirmed %} +
+ اعمال در انتظار تأیید! + پروفایل «{{ pending.profile.name if pending.profile else '-' }}» اعمال شده. + مشاهده و تأیید / rollback +
+{% endif %} + +
+
+
+
اطلاعات سیستم
+
+

Ubuntu: {{ status.ubuntu_description }}

+

Codename: {{ status.ubuntu_codename }}

+

Host root: {{ status.host_root }}

+ {% if state and state.initial_backup_done %} + Backup اولیه گرفته شده + {% else %} + Backup اولیه در انتظار + {% endif %} +
+
+
+ +
+ +
+
+
+
+ DNS + راهنما +
+
+ {% if status.dns.dropin_exists %} +
{{ status.dns.content }}
+ {% else %} +

drop-in Mirror Manager تنظیم نشده

+ {% endif %} +
+
+
+
+
+
+ Docker + راهنما +
+
+
{{ status.docker.config | tojson(indent=2) }}
+
+
+
+
+
+
+ APT sources.list + راهنما +
+
+
{{ status.apt.sources_list or 'تنظیم نشده' }}
+
+
+
+
+ +{% if recent_tests %} +

آخرین تست‌ها

+
+ + + + + + {% for r in recent_tests %} + + + + + + + + {% endfor %} + +
میرورنوعنتیجهتأخیرزمان
{{ r.mirror.name }}{{ r.test_type }}{% if r.success %}{% else %}{% endif %}{{ '%.0f'|format(r.latency_ms) if r.latency_ms else '-' }} ms{{ r.tested_at.strftime('%Y-%m-%d %H:%M') }}
+
+{% endif %} +{% endblock %} diff --git a/app/templates/guide.html b/app/templates/guide.html new file mode 100644 index 0000000..b93e4d5 --- /dev/null +++ b/app/templates/guide.html @@ -0,0 +1,18 @@ +{% extends "base.html" %} +{% block title %}{{ guide.title }}{% endblock %} +{% block content %} + +

{{ guide.title }}

+

{{ guide.summary }}

+
+
+ {{ guide.content | safe }} +
+
+بازگشت +{% endblock %} diff --git a/app/templates/macros.html b/app/templates/macros.html new file mode 100644 index 0000000..bf371dd --- /dev/null +++ b/app/templates/macros.html @@ -0,0 +1,13 @@ +{% macro guide_box(guide, section) %} +{% if guide %} +
+
+ {{ guide.title }} + راهنمای کامل +
+
+ {{ guide.summary }} +
+
+{% endif %} +{% endmacro %} diff --git a/app/templates/mirrors/form.html b/app/templates/mirrors/form.html new file mode 100644 index 0000000..164e97f --- /dev/null +++ b/app/templates/mirrors/form.html @@ -0,0 +1,52 @@ +{% extends "base.html" %} +{% block title %}{% if mirror %}ویرایش{% else %}ایجاد{% endif %} میرور{% endblock %} +{% block content %} +

{% if mirror %}ویرایش میرور{% else %}میرور جدید{% endif %}

+
+
+
+ + +
+
+ + +
+
+ + +
برای APT، Docker، GitHub، pip، npm
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + +
+
+
+ + انصراف +
+
+{% endblock %} diff --git a/app/templates/mirrors/list.html b/app/templates/mirrors/list.html new file mode 100644 index 0000000..d35f9f5 --- /dev/null +++ b/app/templates/mirrors/list.html @@ -0,0 +1,64 @@ +{% extends "base.html" %} +{% block title %}میرورها{% endblock %} +{% block content %} +
+

مدیریت میرورها

+ +
+ +
+ همه + {% for cat in categories %} + + {{ category_labels[cat] }} + + {% endfor %} +
+ +{% if current_category and guides.get(current_category) %} +
+ {{ guides[current_category].summary }} + راهنمای {{ guides[current_category].title }} +
+{% endif %} + +
+ {% if current_category %}{% endif %} + +
+ +
+ + + + + + + + {% for m in mirrors %} + + + + + + + + + {% else %} + + {% endfor %} + +
دستهنامURL / IPاولویتفعالعملیات
{{ category_labels.get(m.category, m.category) }}{{ m.name }}{{ m.url or m.ips or '-' }}{{ m.priority }}{% if m.enabled %}بله{% else %}خیر{% endif %} +
+ +
+ ویرایش +
+ +
+
میروری یافت نشد
+
+{% endblock %} diff --git a/app/templates/mirrors/results.html b/app/templates/mirrors/results.html new file mode 100644 index 0000000..2858a78 --- /dev/null +++ b/app/templates/mirrors/results.html @@ -0,0 +1,32 @@ +{% extends "base.html" %} +{% block title %}نتایج تست{% endblock %} +{% block content %} +

نتایج تست میرورها

+
+ همه + {% for cat in categories %} + {{ category_labels[cat] }} + {% endfor %} +
+
+ + + + {% for r in results %} + + + + + + + + + + {% else %} + + {% endfor %} + +
میروردستهتستموفقmsخطازمان
{{ r.mirror.name }}{{ category_labels.get(r.mirror.category, r.mirror.category) }}{{ r.test_type }}{% if r.success %}✓{% else %}✗{% endif %}{{ '%.0f'|format(r.latency_ms) if r.latency_ms else '-' }}{{ r.error or '' }}{{ r.tested_at.strftime('%m-%d %H:%M') }}
نتیجه‌ای نیست — تست را اجرا کنید
+
+بازگشت +{% endblock %} diff --git a/app/templates/profiles/form.html b/app/templates/profiles/form.html new file mode 100644 index 0000000..456548c --- /dev/null +++ b/app/templates/profiles/form.html @@ -0,0 +1,41 @@ +{% extends "base.html" %} +{% from "macros.html" import guide_box %} +{% block title %}{% if profile %}ویرایش{% else %}ایجاد{% endif %} پروفایل{% endblock %} +{% block content %} +

{% if profile %}ویرایش پروفایل{% else %}پروفایل جدید{% endif %}

+{{ guide_box(guide, 'profiles') }} +
+
+ + +
+
+ + +
+
+ + +
+
انتخاب میرور برای هر بخش
+

بخشی که انتخاب نکنید در apply نادیده گرفته می‌شود.

+
+ {% for cat in categories %} +
+ + + راهنما +
+ {% endfor %} +
+
+ + انصراف +
+
+{% endblock %} diff --git a/app/templates/profiles/list.html b/app/templates/profiles/list.html new file mode 100644 index 0000000..694edc9 --- /dev/null +++ b/app/templates/profiles/list.html @@ -0,0 +1,39 @@ +{% extends "base.html" %} +{% from "macros.html" import guide_box %} +{% block title %}پروفایل‌ها{% endblock %} +{% block content %} +
+

پروفایل‌ها

+ + پروفایل جدید +
+{{ guide_box(guide, 'profiles') }} +
+ {% for p in profiles %} +
+
+
+
{{ p.name }}{% if p.is_default %} پیش‌فرض{% endif %}
+

{{ p.description or '' }}

+
    + {% for item in p.items %} +
  • {{ item.category }}: {{ item.mirror.name if item.mirror else '-' }}
  • + {% endfor %} +
+
+ +
+
+ {% else %} +

پروفایلی وجود ندارد

+ {% endfor %} +
+{% endblock %} diff --git a/app/templates/restore/index.html b/app/templates/restore/index.html new file mode 100644 index 0000000..7a1847b --- /dev/null +++ b/app/templates/restore/index.html @@ -0,0 +1,59 @@ +{% extends "base.html" %} +{% from "macros.html" import guide_box %} +{% block title %}بازگردانی{% endblock %} +{% block content %} +

بازگردانی تنظیمات

+{{ guide_box(guide, 'restore') }} + +
+
+
+
+
بازگردانی به تنظیمات اولیه
+

وضعیت سیستم قبل از اولین اجرای Mirror Manager

+ {% if state and state.initial_backup_path %} +

{{ state.initial_backup_path }}

+
+ +
+ {% else %} +

Backup اولیه یافت نشد

+ {% endif %} +
+
+
+
+ +

Backupهای موجود

+
+ + + + {% for b in backups %} + + + + + + {% else %} + + {% endfor %} + +
نامبرچسبعملیات
{{ b.name }}{{ b.label }} +
+ + +
+
backupی وجود ندارد
+
+ +
+
+
دستورات SSH
+
python3 mirror-manager restore --initial
+python3 mirror-manager restore --backup /path/to/backup
+python3 mirror-manager disable
+python3 mirror-manager enable
+
+
+{% endblock %} diff --git a/app/templates/settings/index.html b/app/templates/settings/index.html new file mode 100644 index 0000000..0f62790 --- /dev/null +++ b/app/templates/settings/index.html @@ -0,0 +1,52 @@ +{% extends "base.html" %} +{% from "macros.html" import guide_box %} +{% block title %}تنظیمات{% endblock %} +{% block content %} +

تنظیمات سیستم

+{{ guide_box(guide, 'settings') }} + +
+
Auto-switch (اختیاری)
+

در صورت fail شدن میرور فعال، به بهترین میرور جایگزین switch می‌شود.

+
+ {% 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'), + ] %} +
+
+ + +
+
+ {% endfor %} +
+ +
Rollback و تست
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+ + +
+{% endblock %} diff --git a/deploy/DOKPLOY.md b/deploy/DOKPLOY.md new file mode 100644 index 0000000..4cbf4bd --- /dev/null +++ b/deploy/DOKPLOY.md @@ -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) diff --git a/deploy/install-cli.sh b/deploy/install-cli.sh new file mode 100644 index 0000000..85f131e --- /dev/null +++ b/deploy/install-cli.sh @@ -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" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8b29859 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/mirror-manager b/mirror-manager new file mode 100644 index 0000000..4ec86d3 --- /dev/null +++ b/mirror-manager @@ -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() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..0f6963b --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/run.py b/run.py new file mode 100644 index 0000000..d16b5b5 --- /dev/null +++ b/run.py @@ -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)