diff --git a/README.md b/README.md index 3e232a5..dfa8e5d 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,8 @@ Firefox uses its own certificate store, so even after OS-level install you need 3. Select `ca/ca.crt` from the project folder. 4. Check **Trust this CA to identify websites** → click **OK**. +> **Auto-install on startup:** When running in `apps_script` mode the proxy will automatically detect if the CA is not yet trusted and attempt to install it for you. If it succeeds you'll see a confirmation in the log; if it fails (e.g. needs administrator rights) it will print instructions. You can also run `python main.py --install-cert` at any time to (re-)install the certificate. + > ⚠️ **Security note:** This certificate only works locally on your machine. Don't share the `ca/` folder with anyone. If you want to start fresh, delete the `ca/` folder and the tool will generate a new one. --- @@ -222,8 +224,12 @@ python main.py # Normal start python main.py -p 9090 # Use port 9090 instead python main.py --log-level DEBUG # Show detailed logs python main.py -c /path/to/config.json # Use a different config file +python main.py --install-cert # Install MITM CA certificate and exit +python main.py --no-cert-check # Skip automatic CA install check on startup ``` +> **Auto-install:** On startup (MITM mode), the proxy automatically checks if the CA certificate is trusted and attempts to install it. Use `--no-cert-check` to skip this. If auto-install fails (e.g. needs elevation), run `python main.py --install-cert` manually or follow Step 6 above. + --- ## Architecture @@ -248,6 +254,7 @@ python main.py -c /path/to/config.json # Use a different config file | `domain_fronter.py` | Disguises traffic through CDN/Google | | `h2_transport.py` | Faster connections using HTTP/2 (optional) | | `mitm.py` | Handles HTTPS certificate generation | +| `cert_installer.py` | Cross-platform CA certificate installer (Windows/macOS/Linux + Firefox) | | `ws.py` | WebSocket support | | `Code.gs` | The relay script you deploy to Google Apps Script | | `config.example.json` | Example config — copy to `config.json` | diff --git a/README_FA.md b/README_FA.md index 8c3f9f7..e9e20c9 100644 --- a/README_FA.md +++ b/README_FA.md @@ -142,6 +142,8 @@ Firefox معمولا certificate store جداگانه دارد: 4. فایل `ca/ca.crt` را انتخاب کنید. 5. گزینه **Trust this CA to identify websites** را فعال کنید. +> **نصب خودکار هنگام اجرا:** در حالت `apps_script`، برنامه به صورت خودکار وضعیت اعتماد گواهی CA را بررسی کرده و در صورت نیاز نصب می‌کند. در صورت موفقیت پیام تأیید در لاگ نمایش داده می‌شود. اگر نصب خودکار ناموفق بود، می‌توانید دستور `python main.py --install-cert` را اجرا کنید. + نکته امنیتی: پوشه `ca/` را با کسی به اشتراک نگذارید. اگر خواستید از اول گواهی جدید بسازید، این پوشه را حذف کنید تا دوباره ساخته شود. --- @@ -210,8 +212,12 @@ python main.py python main.py -p 9090 python main.py --log-level DEBUG python main.py -c /path/to/config.json +python main.py --install-cert # نصب گواهی CA و خروج +python main.py --no-cert-check # رد شدن از بررسی خودکار گواهی ``` +> **نصب خودکار:** هنگام اجرا در حالت `apps_script`، برنامه به‌طور خودکار بررسی می‌کند که آیا گواهی CA قابل اعتماد است یا نه و در صورت نیاز آن را نصب می‌کند. اگر نصب خودکار ناموفق بود (مثلاً نیاز به دسترسی مدیر دارد)، می‌توانید دستور `python main.py --install-cert` را اجرا کنید یا مراحل مرحله ۶ را دنبال کنید. + --- ## معماری @@ -234,6 +240,7 @@ python main.py -c /path/to/config.json | `domain_fronter.py` | انجام domain fronting | | `h2_transport.py` | ارتباط سریع‌تر با HTTP/2 | | `mitm.py` | ساخت و مدیریت certificate | +| `cert_installer.py` | نصب خودکار گواهی CA در ویندوز، مک، لینوکس و Firefox | | `ws.py` | پشتیبانی WebSocket | | `Code.gs` | رله Apps Script | | `config.example.json` | فایل نمونه تنظیمات | diff --git a/cert_installer.py b/cert_installer.py new file mode 100644 index 0000000..1c49762 --- /dev/null +++ b/cert_installer.py @@ -0,0 +1,365 @@ +""" +Cross-platform trusted CA certificate installer. + +Supports: Windows, macOS, Linux (Debian/Ubuntu, RHEL/Fedora/CentOS, Arch). +Also attempts to install into Firefox's NSS certificate store when found. + +Usage: + from cert_installer import install_ca, is_ca_trusted + install_ca("/path/to/ca.crt", cert_name="MasterHttpRelayVPN") +""" + +import glob +import logging +import os +import platform +import shutil +import subprocess +import sys +import tempfile + +log = logging.getLogger("CertInstaller") + +CERT_NAME = "MasterHttpRelayVPN" + + +# ───────────────────────────────────────────────────────────────────────────── +# Helpers +# ───────────────────────────────────────────────────────────────────────────── + +def _run(cmd: list[str], *, check: bool = True, capture: bool = True) -> subprocess.CompletedProcess: + return subprocess.run( + cmd, + check=check, + stdout=subprocess.PIPE if capture else None, + stderr=subprocess.PIPE if capture else None, + ) + + +def _has_cmd(name: str) -> bool: + return shutil.which(name) is not None + + +# ───────────────────────────────────────────────────────────────────────────── +# Windows +# ───────────────────────────────────────────────────────────────────────────── + +def _install_windows(cert_path: str, cert_name: str) -> bool: + """ + Install into the current user's Trusted Root store (no admin required). + Falls back to the system store if certutil fails. + """ + # Per-user store — works without elevation + try: + _run(["certutil", "-addstore", "-user", "Root", cert_path]) + log.info("Certificate installed in Windows user Trusted Root store.") + return True + except (subprocess.CalledProcessError, FileNotFoundError) as exc: + log.warning("certutil user store failed: %s", exc) + + # Try system store (requires admin) + try: + _run(["certutil", "-addstore", "Root", cert_path]) + log.info("Certificate installed in Windows system Trusted Root store.") + return True + except (subprocess.CalledProcessError, FileNotFoundError) as exc: + log.error("certutil system store failed: %s", exc) + + # Fallback: use PowerShell + try: + ps_cmd = ( + f"Import-Certificate -FilePath '{cert_path}' " + f"-CertStoreLocation Cert:\\CurrentUser\\Root" + ) + _run(["powershell", "-NoProfile", "-Command", ps_cmd]) + log.info("Certificate installed via PowerShell.") + return True + except (subprocess.CalledProcessError, FileNotFoundError) as exc: + log.error("PowerShell install failed: %s", exc) + + return False + + +def _is_trusted_windows(cert_path: str) -> bool: + # Check by thumbprint only — the cert name is also the folder name, so + # a plain string search on certutil output would produce a false positive. + thumbprint = _cert_thumbprint(cert_path) + if not thumbprint: + return False + try: + result = _run(["certutil", "-user", "-store", "Root"]) + output = result.stdout.decode(errors="replace").upper() + return thumbprint in output + except Exception: + return False + + +def _cert_thumbprint(cert_path: str) -> str: + """Return the SHA-1 thumbprint of a PEM cert (uppercase hex, no colons).""" + try: + from cryptography import x509 as _x509 + from cryptography.hazmat.primitives import hashes as _hashes + with open(cert_path, "rb") as f: + cert = _x509.load_pem_x509_certificate(f.read()) + return cert.fingerprint(_hashes.SHA1()).hex().upper() + except Exception: + return "" + + +# ───────────────────────────────────────────────────────────────────────────── +# macOS +# ───────────────────────────────────────────────────────────────────────────── + +def _install_macos(cert_path: str, cert_name: str) -> bool: + """Install into the login keychain (per-user, no sudo required).""" + login_keychain = os.path.expanduser("~/Library/Keychains/login.keychain-db") + if not os.path.exists(login_keychain): + login_keychain = os.path.expanduser("~/Library/Keychains/login.keychain") + + try: + _run([ + "security", "add-trusted-cert", + "-d", "-r", "trustRoot", + "-k", login_keychain, + cert_path, + ]) + log.info("Certificate installed in macOS login keychain.") + return True + except (subprocess.CalledProcessError, FileNotFoundError) as exc: + log.warning("login keychain install failed: %s. Trying system keychain (needs sudo)…", exc) + + try: + _run([ + "sudo", "security", "add-trusted-cert", + "-d", "-r", "trustRoot", + "-k", "/Library/Keychains/System.keychain", + cert_path, + ]) + log.info("Certificate installed in macOS system keychain.") + return True + except (subprocess.CalledProcessError, FileNotFoundError) as exc: + log.error("System keychain install failed: %s", exc) + + return False + + +def _is_trusted_macos(cert_name: str) -> bool: + try: + result = _run(["security", "find-certificate", "-a", "-c", cert_name]) + return bool(result.stdout.strip()) + except Exception: + return False + + +# ───────────────────────────────────────────────────────────────────────────── +# Linux +# ───────────────────────────────────────────────────────────────────────────── + +def _detect_linux_distro() -> str: + """Return 'debian', 'rhel', 'arch', or 'unknown'.""" + if os.path.exists("/etc/debian_version") or os.path.exists("/etc/ubuntu"): + return "debian" + if os.path.exists("/etc/redhat-release") or os.path.exists("/etc/fedora-release"): + return "rhel" + if os.path.exists("/etc/arch-release"): + return "arch" + # Read /etc/os-release as fallback + try: + with open("/etc/os-release") as f: + content = f.read().lower() + if "debian" in content or "ubuntu" in content or "mint" in content: + return "debian" + if "fedora" in content or "rhel" in content or "centos" in content or "rocky" in content or "alma" in content: + return "rhel" + if "arch" in content or "manjaro" in content: + return "arch" + except OSError: + pass + return "unknown" + + +def _install_linux(cert_path: str, cert_name: str) -> bool: + distro = _detect_linux_distro() + log.info("Detected Linux distro family: %s", distro) + + installed = False + + if distro == "debian": + dest_dir = "/usr/local/share/ca-certificates" + dest_file = os.path.join(dest_dir, f"{cert_name.replace(' ', '_')}.crt") + try: + os.makedirs(dest_dir, exist_ok=True) + shutil.copy2(cert_path, dest_file) + _run(["update-ca-certificates"]) + log.info("Certificate installed via update-ca-certificates.") + installed = True + except (OSError, subprocess.CalledProcessError) as exc: + log.warning("Debian install failed (needs sudo?): %s", exc) + # Try with sudo + try: + _run(["sudo", "cp", cert_path, dest_file]) + _run(["sudo", "update-ca-certificates"]) + log.info("Certificate installed via sudo update-ca-certificates.") + installed = True + except (subprocess.CalledProcessError, FileNotFoundError) as exc2: + log.error("sudo Debian install failed: %s", exc2) + + elif distro == "rhel": + dest_dir = "/etc/pki/ca-trust/source/anchors" + dest_file = os.path.join(dest_dir, f"{cert_name.replace(' ', '_')}.crt") + try: + os.makedirs(dest_dir, exist_ok=True) + shutil.copy2(cert_path, dest_file) + _run(["update-ca-trust", "extract"]) + log.info("Certificate installed via update-ca-trust.") + installed = True + except (OSError, subprocess.CalledProcessError) as exc: + log.warning("RHEL install failed (needs sudo?): %s", exc) + try: + _run(["sudo", "cp", cert_path, dest_file]) + _run(["sudo", "update-ca-trust", "extract"]) + log.info("Certificate installed via sudo update-ca-trust.") + installed = True + except (subprocess.CalledProcessError, FileNotFoundError) as exc2: + log.error("sudo RHEL install failed: %s", exc2) + + elif distro == "arch": + dest_dir = "/etc/ca-certificates/trust-source/anchors" + dest_file = os.path.join(dest_dir, f"{cert_name.replace(' ', '_')}.crt") + try: + os.makedirs(dest_dir, exist_ok=True) + shutil.copy2(cert_path, dest_file) + _run(["trust", "extract-compat"]) + log.info("Certificate installed via trust extract-compat.") + installed = True + except (OSError, subprocess.CalledProcessError) as exc: + log.warning("Arch install failed (needs sudo?): %s", exc) + try: + _run(["sudo", "cp", cert_path, dest_file]) + _run(["sudo", "trust", "extract-compat"]) + log.info("Certificate installed via sudo trust extract-compat.") + installed = True + except (subprocess.CalledProcessError, FileNotFoundError) as exc2: + log.error("sudo Arch install failed: %s", exc2) + + else: + log.warning( + "Unknown Linux distro. Manually install %s as a trusted root CA.", cert_path + ) + + return installed + + +def _is_trusted_linux(cert_path: str) -> bool: + """Check if our cert thumbprint is in the system's OpenSSL trust bundle.""" + thumbprint = _cert_thumbprint(cert_path) + if not thumbprint: + return False + bundle_paths = [ + "/etc/ssl/certs/ca-certificates.crt", # Debian/Ubuntu + "/etc/pki/tls/certs/ca-bundle.crt", # RHEL/Fedora + "/etc/ssl/ca-bundle.pem", # OpenSUSE + "/etc/ca-certificates/ca-certificates.crt", + ] + # A fast heuristic: check if our CA cert file was copied to known dirs + anchor_dirs = [ + "/usr/local/share/ca-certificates", + "/etc/pki/ca-trust/source/anchors", + "/etc/ca-certificates/trust-source/anchors", + ] + for d in anchor_dirs: + if os.path.isdir(d): + for f in os.listdir(d): + if "DomainFront" in f or "domainfront" in f.lower(): + return True + return False + + +# ───────────────────────────────────────────────────────────────────────────── +# Firefox NSS (cross-platform) +# ───────────────────────────────────────────────────────────────────────────── + +def _install_firefox(cert_path: str, cert_name: str): + """Install into all detected Firefox profile NSS databases.""" + if not _has_cmd("certutil"): + log.debug("NSS certutil not found — skipping Firefox install.") + return + + profile_dirs: list[str] = [] + system = platform.system() + + if system == "Windows": + appdata = os.environ.get("APPDATA", "") + profile_dirs += glob.glob(os.path.join(appdata, r"Mozilla\Firefox\Profiles\*")) + elif system == "Darwin": + profile_dirs += glob.glob(os.path.expanduser("~/Library/Application Support/Firefox/Profiles/*")) + else: + profile_dirs += glob.glob(os.path.expanduser("~/.mozilla/firefox/*.default*")) + profile_dirs += glob.glob(os.path.expanduser("~/.mozilla/firefox/*.release*")) + + if not profile_dirs: + log.debug("No Firefox profiles found.") + return + + for profile in profile_dirs: + db = f"sql:{profile}" if os.path.exists(os.path.join(profile, "cert9.db")) else f"dbm:{profile}" + try: + # Remove old entry first (ignore errors) + _run(["certutil", "-D", "-n", cert_name, "-d", db], check=False) + _run([ + "certutil", "-A", + "-n", cert_name, + "-t", "CT,,", + "-i", cert_path, + "-d", db, + ]) + log.info("Installed in Firefox profile: %s", os.path.basename(profile)) + except (subprocess.CalledProcessError, FileNotFoundError) as exc: + log.warning("Firefox profile %s: %s", os.path.basename(profile), exc) + + +# ───────────────────────────────────────────────────────────────────────────── +# Public API +# ───────────────────────────────────────────────────────────────────────────── + +def is_ca_trusted(cert_path: str) -> bool: + """Return True if the CA cert appears to be already installed.""" + system = platform.system() + try: + if system == "Windows": + return _is_trusted_windows(cert_path) + if system == "Darwin": + return _is_trusted_macos(CERT_NAME) + return _is_trusted_linux(cert_path) + except Exception: + return False + + +def install_ca(cert_path: str, cert_name: str = CERT_NAME) -> bool: + """ + Install *cert_path* as a trusted root CA on the current platform. + Also attempts Firefox NSS installation. + + Returns True if the system store installation succeeded. + """ + if not os.path.exists(cert_path): + log.error("Certificate file not found: %s", cert_path) + return False + + system = platform.system() + log.info("Installing CA certificate on %s…", system) + + if system == "Windows": + ok = _install_windows(cert_path, cert_name) + elif system == "Darwin": + ok = _install_macos(cert_path, cert_name) + elif system == "Linux": + ok = _install_linux(cert_path, cert_name) + else: + log.error("Unsupported platform: %s", system) + return False + + # Best-effort Firefox install on all platforms + _install_firefox(cert_path, cert_name) + + return ok diff --git a/main.py b/main.py index 99b36f0..d80add6 100644 --- a/main.py +++ b/main.py @@ -14,6 +14,8 @@ import logging import os import sys +from cert_installer import install_ca, is_ca_trusted +from mitm import CA_CERT_FILE from proxy_server import ProxyServer __version__ = "1.0.0" @@ -60,6 +62,16 @@ def parse_args(): action="version", version=f"%(prog)s {__version__}", ) + parser.add_argument( + "--install-cert", + action="store_true", + help="Install the MITM CA certificate as a trusted root and exit.", + ) + parser.add_argument( + "--no-cert-check", + action="store_true", + help="Skip the certificate installation check on startup.", + ) return parser.parse_args() @@ -125,6 +137,14 @@ def main(): print("Deploy the Apps Script from appsscript/Code.gs and paste the Deployment ID.") sys.exit(1) + # ── Certificate installation ────────────────────────────────────────── + if args.install_cert: + setup_logging("INFO") + _log = logging.getLogger("Main") + _log.info("Installing CA certificate…") + ok = install_ca(CA_CERT_FILE) + sys.exit(0 if ok else 1) + setup_logging(config.get("log_level", "INFO")) log = logging.getLogger("Main") @@ -147,7 +167,27 @@ def main(): log.info(" [%d] %s", i + 1, sid) else: log.info("Script ID : %s", script_ids) - log.info("MITM enabled — install ca/ca.crt in your browser!") + + # Ensure CA file exists before checking / installing it. + # MITMCertManager generates ca/ca.crt on first instantiation. + if not os.path.exists(CA_CERT_FILE): + from mitm import MITMCertManager + MITMCertManager() # side-effect: creates ca/ca.crt + ca/ca.key + + # Auto-install MITM CA if not already trusted + if not args.no_cert_check: + if not is_ca_trusted(CA_CERT_FILE): + log.warning("MITM CA is not trusted — attempting automatic installation…") + ok = install_ca(CA_CERT_FILE) + if ok: + log.info("CA certificate installed. You may need to restart your browser.") + else: + log.error( + "Auto-install failed. Run with --install-cert (may need admin/sudo) " + "or manually install ca/ca.crt as a trusted root CA." + ) + else: + log.info("MITM CA is already trusted.") else: log.info("Front domain (SNI) : %s", config.get("front_domain", "?")) log.info("Worker host (Host) : %s", config.get("worker_host", "?")) diff --git a/mitm.py b/mitm.py index 36fc49d..e3c91b1 100644 --- a/mitm.py +++ b/mitm.py @@ -55,8 +55,8 @@ class MITMCertManager: public_exponent=65537, key_size=2048 ) subject = issuer = x509.Name([ - x509.NameAttribute(NameOID.COMMON_NAME, "DomainFront Tunnel CA"), - x509.NameAttribute(NameOID.ORGANIZATION_NAME, "DomainFront Tunnel"), + x509.NameAttribute(NameOID.COMMON_NAME, "MasterHttpRelayVPN"), + x509.NameAttribute(NameOID.ORGANIZATION_NAME, "MasterHttpRelayVPN"), ]) now = datetime.datetime.now(datetime.timezone.utc) self._ca_cert = ( diff --git a/proxy_server.py b/proxy_server.py index 5ade0ab..bda1dea 100644 --- a/proxy_server.py +++ b/proxy_server.py @@ -279,7 +279,13 @@ class ProxyServer: transport, protocol, ssl_ctx, server_side=True, ) except Exception as e: - log.error("TLS handshake failed for %s: %s", host, e) + # Non-HTTPS traffic (e.g. MTProto, plain HTTP on port 80/443) + # routed through the proxy will always fail TLS — log at DEBUG + # to avoid alarming noise. + if port != 443: + log.debug("TLS handshake skipped for %s:%d (non-HTTPS): %s", host, port, e) + else: + log.debug("TLS handshake failed for %s: %s", host, e) return # Update writer to use the new TLS transport