Файловый менеджер - Редактировать - /opt/dedrads/cpanel_postgres_manager.py
Назад
#!/usr/lib/rads/venv/bin/python3 """ Install, reset, or logically upgrade PostgreSQL for cPanel on AlmaLinux 8/9. When a PostgreSQL version is passed, the script resets/enables that dnf module stream before running /scripts/installpostgres. When no version is passed, it prompts for one before continuing. """ import argparse from contextlib import contextmanager import datetime as _dt import logging import os import re import shutil import socket import subprocess import sys from pathlib import Path from typing import Dict, Iterator, List, Optional, Sequence, Tuple DATA_DIR = Path("/var/lib/pgsql/data") PG_HBA = DATA_DIR / "pg_hba.conf" PGSQL_BASE_DIR = DATA_DIR.parent POSTGRES_SERVICE = "postgresql" CPANEL_INSTALLPOSTGRES = Path("/scripts/installpostgres") BACKUP_DIR = Path("/root/postgresql-backups") POSTGRES_USER_COMMAND = ["runuser", "-u", "postgres", "--"] POSTGRESQL_PACKAGE_QUERY_PATTERNS = [ "postgresql", "postgresql-server", "postgresql-libs", "postgresql-private-libs", "postgresql[0-9]*", ] YUM_REPO_DIR = Path("/etc/yum.repos.d") EXTERNAL_POSTGRES_REPO_PATTERNS = ( "pgdg", "postgresql.org", "yum.postgresql.org", "ftp.postgresql.org", ) LOGGER = logging.getLogger(__name__) class Fatal(RuntimeError): pass def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( description="Install, reset, or logically upgrade PostgreSQL for cPanel." ) parser.add_argument( "version", nargs="?", type=int, help=( "PostgreSQL module stream to enable before install. Omit to display " "available streams and choose interactively." ), ) parser.add_argument( "--upgrade", action="store_true", help="Backup the current cluster, reinstall/init the target version, then restore.", ) parser.add_argument( "--allow-logical-downgrade", action="store_true", help=( "Allow --upgrade to attempt a pg_dumpall restore into an older major " "version. This is best-effort and may fail if the dump uses newer " "PostgreSQL features." ), ) parser.add_argument( "--reset-data", action="store_true", help="Destroy /var/lib/pgsql/data contents and initialize a fresh cluster.", ) parser.add_argument( "--backup-dir", default=str(BACKUP_DIR), help=f"Directory for pg_dumpall backups. Default: {BACKUP_DIR}", ) parser.add_argument( "--skip-backup", action="store_true", help="Allow destructive reset without creating a pg_dumpall backup.", ) parser.add_argument( "--yes", action="store_true", help="Answer yes to required prompts. Destructive prompts still require --i-understand-data-loss.", ) parser.add_argument( "--i-understand-data-loss", action="store_true", help="Required with --yes for destructive data directory reset.", ) parser.add_argument( "--dry-run", action="store_true", help="Log commands without running them.", ) return parser.parse_args() def run( command: Sequence[str], *, dry_run: bool = False, check: bool = True, capture: bool = False, env: Optional[Dict[str, str]] = None, ) -> subprocess.CompletedProcess: printable = " ".join(str(part) for part in command) LOGGER.info("+ %s", printable) if dry_run: return subprocess.CompletedProcess(command, 0, "", "") return subprocess.run( list(command), check=check, text=True, stdout=subprocess.PIPE if capture else None, stderr=subprocess.PIPE if capture else None, env=env, ) def require_root() -> None: if os.geteuid() != 0: raise Fatal("Run this script as root.") def command_exists(name: str) -> bool: return shutil.which(name) is not None def service_is_active(dry_run: bool) -> bool: if dry_run: return False result = run(["systemctl", "is-active", "--quiet", POSTGRES_SERVICE], check=False) return result.returncode == 0 def existing_data_dir_has_cluster() -> bool: return (DATA_DIR / "PG_VERSION").exists() def existing_cluster_version() -> Optional[str]: pg_version = DATA_DIR / "PG_VERSION" if pg_version.exists(): return pg_version.read_text().strip() if command_exists("postgres"): result = run(["postgres", "--version"], check=False, capture=True) match = re.search(r"(\d+)(?:\.\d+)?", result.stdout or "") if match: return match.group(1) return None def installed_postgres_server_version() -> Optional[str]: if not command_exists("postgres"): return None result = run(["postgres", "--version"], check=False, capture=True) match = re.search(r"(\d+)(?:\.\d+)?", result.stdout or "") if match: return match.group(1) return None def require_installed_server_matches_target(version: Optional[int]) -> str: installed = installed_postgres_server_version() if installed is None: raise Fatal("postgres executable was not found after package installation.") if version is not None and installed != str(version): raise Fatal( f"Installed PostgreSQL server is version {installed}, but target module " f"stream is {version}. Refusing to initialize, start, restore, or run " "cPanel setup against the data directory. Check the enabled DNF module " "stream and repository state." ) return installed def require_data_dir_matches_installed_server() -> None: if not existing_data_dir_has_cluster(): return cluster = existing_cluster_version() installed = installed_postgres_server_version() if installed is None or cluster is None: return if cluster != installed: raise Fatal( f"Existing PostgreSQL data directory is version {cluster}, but the " f"installed PostgreSQL server is version {installed}. Refusing to run " f"cPanel setup or start {POSTGRES_SERVICE} with an incompatible cluster. " "Use --upgrade for a logical dump/restore or --reset-data for a fresh " "cluster." ) def prompt(message: str, *, default_no: bool = True, assume_yes: bool = False) -> bool: if assume_yes: LOGGER.info("%s yes", message) return True suffix = " [y/N]: " if default_no else " [Y/n]: " answer = input(message + suffix).strip().lower() if not answer: return not default_no return answer in {"y", "yes"} def available_postgresql_versions() -> List[Tuple[int, str]]: result = run(["dnf", "module", "list", "postgresql"], check=False, capture=True) versions: List[Tuple[int, str]] = [] seen = set() for line in (result.stdout or "").splitlines(): parts = line.split() if len(parts) < 2 or parts[0] != "postgresql": continue if not parts[1].isdigit(): continue version = int(parts[1]) if version in seen: continue seen.add(version) flags = parts[2] if len(parts) > 2 and parts[2].startswith("[") else "" versions.append((version, flags)) return versions def version_flag_labels(flags: str) -> str: labels = [] if "e" in flags: labels.append("enabled") if "d" in flags: labels.append("default") if "i" in flags: labels.append("installed") if "x" in flags: labels.append("disabled") return ", ".join(labels) def log_available_postgresql_versions(versions: Sequence[Tuple[int, str]]) -> None: if not versions: LOGGER.info("No PostgreSQL module streams were found in DNF output.") return LOGGER.info("Available PostgreSQL module streams:") for version, flags in versions: labels = version_flag_labels(flags) suffix = f" ({labels})" if labels else "" LOGGER.info(" %s%s", version, suffix) def prompt_for_version() -> Optional[int]: versions = available_postgresql_versions() log_available_postgresql_versions(versions) answer = input( "PostgreSQL version to install [press Enter for repository default]: " ).strip() if not answer: return None if not answer.isdigit(): raise Fatal(f"Invalid PostgreSQL version: {answer}") version = int(answer) available = {available_version for available_version, _ in versions} if available and version not in available: available_list = ", ".join(str(available_version) for available_version in sorted(available)) raise Fatal( f"PostgreSQL version {version} is not in the available module streams: " f"{available_list}" ) return version def validate_selected_version(version: Optional[int]) -> None: if version is None: return versions = available_postgresql_versions() available = {available_version for available_version, _ in versions} if available and version not in available: available_list = ", ".join(str(available_version) for available_version in sorted(available)) raise Fatal( f"PostgreSQL version {version} is not in the available module streams: " f"{available_list}" ) def require_cpanel() -> None: if not CPANEL_INSTALLPOSTGRES.exists(): raise Fatal(f"{CPANEL_INSTALLPOSTGRES} was not found. This does not look like cPanel.") def ensure_backup_directory(path: Path, dry_run: bool) -> None: if dry_run: LOGGER.info("+ mkdir -p %s", path) return path.mkdir(mode=0o700, parents=True, exist_ok=True) def backup_cluster(backup_dir: Path, dry_run: bool) -> Optional[Path]: ensure_backup_directory(backup_dir, dry_run) stamp = _dt.datetime.now().strftime("%Y%m%d-%H%M%S") backup_file = backup_dir / f"postgresql-all-{stamp}.sql" if not command_exists("pg_dumpall"): raise Fatal("pg_dumpall was not found; cannot create a PostgreSQL backup.") if not service_is_active(dry_run): run(["systemctl", "start", POSTGRES_SERVICE], dry_run=dry_run) LOGGER.info("Creating logical backup at %s", backup_file) with local_postgres_peer_auth(dry_run): with backup_file.open("w") if not dry_run else open(os.devnull, "w") as output: LOGGER.info("+ runuser -u postgres -- pg_dumpall") if not dry_run: subprocess.run( [*POSTGRES_USER_COMMAND, "pg_dumpall"], check=True, cwd="/tmp", stdout=output, text=True, ) return backup_file def stop_postgresql(dry_run: bool) -> None: run(["systemctl", "stop", POSTGRES_SERVICE], dry_run=dry_run, check=False) def reload_postgresql(dry_run: bool) -> None: run(["systemctl", "reload", POSTGRES_SERVICE], dry_run=dry_run, check=False) @contextmanager def local_postgres_peer_auth(dry_run: bool) -> Iterator[None]: rule = "local all postgres peer\n" if dry_run: LOGGER.info("+ temporarily prepend to %s: %s", PG_HBA, rule.strip()) reload_postgresql(dry_run) try: yield finally: LOGGER.info("+ restore original %s", PG_HBA) reload_postgresql(dry_run) return if not PG_HBA.exists(): yield return original = PG_HBA.read_text() if original.startswith(rule): yield return PG_HBA.write_text(rule + original) reload_postgresql(dry_run) try: yield finally: PG_HBA.write_text(original) reload_postgresql(dry_run) def remove_external_postgres_libraries(dry_run: bool) -> None: if dry_run: LOGGER.info("+ rpm -q libpq5") LOGGER.info("+ rpm -e --nodeps libpq5 # only if installed") LOGGER.info("+ rpm -qa 'postgresql*-libs'") LOGGER.info("+ rpm -e --nodeps postgresqlNN-libs # only for PGDG versioned libs") return installed = run(["rpm", "-q", "libpq5"], check=False, capture=True) if installed.returncode == 0: package = installed.stdout.strip().splitlines()[0] LOGGER.info("Removing installed %s; it obsoletes AlmaLinux libpq.", package) run(["rpm", "-e", "--nodeps", "libpq5"]) versioned_libs = run(["rpm", "-qa", "postgresql*-libs"], check=False, capture=True) for package in (versioned_libs.stdout or "").splitlines(): if re.match(r"^postgresql\d+-libs-", package): LOGGER.info( "Removing installed %s; it is from an external PostgreSQL repo.", package, ) run(["rpm", "-e", "--nodeps", package]) def installed_postgresql_packages(dry_run: bool) -> List[str]: if dry_run: LOGGER.info( "+ rpm -qa postgresql postgresql-server postgresql-libs " "postgresql-private-libs 'postgresql[0-9]*'" ) return ["postgresql", "postgresql-server", "postgresql-libs"] packages: List[str] = [] seen = set() for pattern in POSTGRESQL_PACKAGE_QUERY_PATTERNS: result = run(["rpm", "-qa", pattern], check=False, capture=True) for package in (result.stdout or "").splitlines(): if package and package not in seen: seen.add(package) packages.append(package) return packages def remove_installed_postgresql_packages(dry_run: bool) -> None: packages = installed_postgresql_packages(dry_run) if not packages: LOGGER.info("No installed PostgreSQL RPM packages found to remove.") return LOGGER.info( "Removing installed PostgreSQL RPM packages before installing the selected " "module stream." ) stop_postgresql(dry_run) run(["rpm", "-e", "--nodeps", *packages], dry_run=dry_run) def repo_section_matches(lines: Sequence[str]) -> bool: text = "\n".join(lines).lower() return any(pattern in text for pattern in EXTERNAL_POSTGRES_REPO_PATTERNS) def disable_matching_repo_sections(text: str) -> Tuple[str, List[str]]: lines = text.splitlines(keepends=True) output: List[str] = [] changed_sections: List[str] = [] section: List[str] = [] section_name: Optional[str] = None def flush() -> None: nonlocal section, section_name if not section: return if section_name is None or not repo_section_matches(section): output.extend(section) section = [] section_name = None return saw_enabled = False changed = False for index, line in enumerate(section): if re.match(r"^\s*enabled\s*=", line): saw_enabled = True if not re.match(r"^\s*enabled\s*=\s*0\s*(?:[#;].*)?$", line): newline = "\n" if line.endswith("\n") else "" section[index] = "enabled=0" + newline changed = True break if not saw_enabled: insert_at = 1 if section and section[0].lstrip().startswith("[") else len(section) section.insert(insert_at, "enabled=0\n") changed = True if changed: changed_sections.append(section_name) output.extend(section) section = [] section_name = None for line in lines: match = re.match(r"^\s*\[([^\]]+)\]\s*$", line) if match: flush() section = [line] section_name = match.group(1) else: section.append(line) flush() return "".join(output), changed_sections @contextmanager def external_postgres_repos_disabled(dry_run: bool) -> Iterator[None]: if dry_run: LOGGER.info("+ temporarily disable external PostgreSQL repos during cPanel install") yield return originals: Dict[Path, str] = {} changed: List[str] = [] for repo_file in sorted(YUM_REPO_DIR.glob("*.repo")): original = repo_file.read_text() updated, sections = disable_matching_repo_sections(original) if sections: originals[repo_file] = original repo_file.write_text(updated) changed.extend(f"{repo_file.name}:{section}" for section in sections) if changed: LOGGER.info( "Temporarily disabled external PostgreSQL repos: %s", ", ".join(changed) ) try: yield finally: for repo_file, original in originals.items(): repo_file.write_text(original) if changed: LOGGER.info("Restored external PostgreSQL repo configuration.") def installpostgres_with_recovery(dry_run: bool, assume_yes: bool) -> None: if not dry_run: require_data_dir_matches_installed_server() command = [str(CPANEL_INSTALLPOSTGRES)] if assume_yes: command.append("--yes") result = run(command, dry_run=dry_run, check=False) if result.returncode == 0: if not dry_run: require_data_dir_matches_installed_server() return if existing_data_dir_has_cluster(): if not dry_run: require_data_dir_matches_installed_server() raise subprocess.CalledProcessError(result.returncode, result.args) if not command_exists("postgresql-setup"): raise subprocess.CalledProcessError(result.returncode, result.args) LOGGER.info( "cPanel installpostgres failed before a PostgreSQL cluster existed; " "initializing the cluster and rerunning cPanel configuration." ) initdb_and_start(dry_run) run(command, dry_run=dry_run) if not dry_run: require_data_dir_matches_installed_server() def finalize_cpanel_postgresql(dry_run: bool, assume_yes: bool) -> None: LOGGER.info("Finalizing PostgreSQL configuration for cPanel") with external_postgres_repos_disabled(dry_run): installpostgres_with_recovery(dry_run, assume_yes) def ensure_cluster_running(dry_run: bool) -> None: if not existing_data_dir_has_cluster(): initdb_and_start(dry_run) return if not dry_run: require_data_dir_matches_installed_server() run(["systemctl", "enable", "--now", POSTGRES_SERVICE], dry_run=dry_run) run(["systemctl", "status", "--no-pager", POSTGRES_SERVICE], dry_run=dry_run, check=False) def reset_module_and_install( version: Optional[int], dry_run: bool, assume_yes: bool ) -> None: if version is not None: remove_installed_postgresql_packages(dry_run) run(["dnf", "-y", "module", "reset", "postgresql"], dry_run=dry_run) if version is not None: run(["dnf", "-y", "module", "enable", f"postgresql:{version}"], dry_run=dry_run) else: LOGGER.info("No PostgreSQL module stream selected; using the repository default.") remove_external_postgres_libraries(dry_run) with external_postgres_repos_disabled(dry_run): installpostgres_with_recovery(dry_run, assume_yes) if not dry_run: installed = require_installed_server_matches_target(version) LOGGER.info("Installed PostgreSQL server version: %s", installed) def clear_data_dir(dry_run: bool) -> None: if not DATA_DIR.exists(): return LOGGER.info("Clearing %s contents", DATA_DIR) if dry_run: LOGGER.info("+ remove all entries under %s", DATA_DIR) return for child in DATA_DIR.iterdir(): if child.is_dir() and not child.is_symlink(): shutil.rmtree(child) else: child.unlink() def move_data_dir_aside(reason: str, dry_run: bool) -> Optional[Path]: if not DATA_DIR.exists(): return None stamp = _dt.datetime.now().strftime("%Y%m%d-%H%M%S") destination = PGSQL_BASE_DIR / f"data.{reason}.{stamp}" LOGGER.info("Moving existing PostgreSQL data directory to %s", destination) if dry_run: LOGGER.info("+ mv %s %s", DATA_DIR, destination) return destination DATA_DIR.rename(destination) return destination def initdb_and_start(dry_run: bool) -> None: ensure_pgsql_base_dir(dry_run) run(["postgresql-setup", "--initdb"], dry_run=dry_run) if not dry_run: require_data_dir_matches_installed_server() run(["systemctl", "enable", "--now", POSTGRES_SERVICE], dry_run=dry_run) run(["systemctl", "status", "--no-pager", POSTGRES_SERVICE], dry_run=dry_run, check=False) def ensure_pgsql_base_dir(dry_run: bool) -> None: if dry_run: LOGGER.info("+ mkdir -p %s", PGSQL_BASE_DIR) LOGGER.info("+ chown postgres:postgres %s", PGSQL_BASE_DIR) LOGGER.info("+ chmod 700 %s", PGSQL_BASE_DIR) return PGSQL_BASE_DIR.mkdir(mode=0o700, parents=True, exist_ok=True) shutil.chown(PGSQL_BASE_DIR, user="postgres", group="postgres") PGSQL_BASE_DIR.chmod(0o700) def restore_backup(backup_file: Path, dry_run: bool) -> None: if not backup_file.exists() and not dry_run: raise Fatal(f"Backup file does not exist: {backup_file}") LOGGER.info("Restoring %s", backup_file) LOGGER.info("+ runuser -u postgres -- psql < %s", backup_file) with local_postgres_peer_auth(dry_run): if not dry_run: with backup_file.open() as input_file: subprocess.run( [*POSTGRES_USER_COMMAND, "psql"], check=True, cwd="/tmp", stdin=input_file, ) def destructive_confirmation(args: argparse.Namespace, reason: str) -> None: if args.yes and args.i_understand_data_loss: return LOGGER.warning("WARNING: This operation destroys the current PostgreSQL data directory.") LOGGER.warning("Reason: %s", reason) LOGGER.warning("Target directory: %s", DATA_DIR) if not prompt("Have you created and verified a backup?", assume_yes=False): raise Fatal("Cancelled. Create a backup before continuing.") phrase = input('Type "DESTROY POSTGRES DATA" to continue: ').strip() if phrase != "DESTROY POSTGRES DATA": raise Fatal("Cancelled.") def logical_upgrade_confirmation(args: argparse.Namespace, backup_file: Path) -> None: if args.yes: LOGGER.info( "Proceeding with logical upgrade using backup %s. The existing data " "directory will be moved aside before the target cluster is initialized.", backup_file, ) return LOGGER.warning("WARNING: Logical upgrade requires a clean target data directory.") LOGGER.warning("Backup that will be restored: %s", backup_file) LOGGER.warning( "The current PostgreSQL data directory will be moved aside, not deleted: %s", DATA_DIR, ) if not prompt("Proceed with the logical upgrade?"): raise Fatal("Cancelled.") def maybe_backup_before_destructive(args: argparse.Namespace) -> Optional[Path]: backup_dir = Path(args.backup_dir) has_cluster = existing_data_dir_has_cluster() if not has_cluster: return None if args.skip_backup: if not prompt( "You used --skip-backup. Continue without a pg_dumpall backup?", assume_yes=args.yes and args.i_understand_data_loss, ): raise Fatal("Cancelled.") return None if prompt("Create a pg_dumpall backup before changing PostgreSQL?", assume_yes=args.yes): return backup_cluster(backup_dir, args.dry_run) if not prompt("Continue without creating a new backup?", assume_yes=False): raise Fatal("Cancelled.") return None def install_or_reset(args: argparse.Namespace, version: Optional[int]) -> None: has_cluster = existing_data_dir_has_cluster() current = existing_cluster_version() if has_cluster else None if current: LOGGER.info("Detected existing PostgreSQL cluster version: %s", current) if ( has_cluster and version is not None and current is not None and current != str(version) and not args.reset_data ): raise Fatal( f"Existing PostgreSQL data directory is version {current}, but target " f"module stream is {version}. Use --upgrade for a logical dump/restore " "or --reset-data if you intend to destroy and recreate the cluster." ) if args.reset_data and has_cluster: maybe_backup_before_destructive(args) destructive_confirmation(args, "Fresh initialization requested with --reset-data.") stop_postgresql(args.dry_run) clear_data_dir(args.dry_run) reset_module_and_install(version, args.dry_run, args.yes) if args.reset_data: if args.dry_run or not existing_data_dir_has_cluster(): initdb_and_start(args.dry_run) finalize_cpanel_postgresql(args.dry_run, args.yes) elif not existing_data_dir_has_cluster(): initdb_and_start(args.dry_run) finalize_cpanel_postgresql(args.dry_run, args.yes) else: if not args.dry_run: require_data_dir_matches_installed_server() run(["systemctl", "enable", "--now", POSTGRES_SERVICE], dry_run=args.dry_run) run(["systemctl", "status", "--no-pager", POSTGRES_SERVICE], dry_run=args.dry_run, check=False) def logical_upgrade(args: argparse.Namespace, version: Optional[int]) -> None: if not existing_data_dir_has_cluster(): LOGGER.info("No existing PostgreSQL data directory found; running a normal install.") install_or_reset(args, version) return current = existing_cluster_version() LOGGER.info("Detected existing PostgreSQL cluster version: %s", current or "unknown") target = str(version) if version is not None else "repository default" LOGGER.info("Target PostgreSQL version: %s", target) if ( current is not None and version is not None and int(current) > version and not args.allow_logical_downgrade ): raise Fatal( f"Logical downgrade from PostgreSQL {current} to {version} is not supported. " "Use --allow-logical-downgrade to attempt a best-effort pg_dumpall restore, " "or --reset-data with a verified backup if you need to recreate the cluster." ) if current is not None and version is not None and int(current) > version: LOGGER.warning( f"WARNING: Attempting logical downgrade from PostgreSQL {current} to {version}. " "The restore can fail if the dump contains newer PostgreSQL features." ) backup_file = maybe_backup_before_destructive(args) if backup_file is None: raise Fatal("Upgrade requires a pg_dumpall backup so the cluster can be restored.") logical_upgrade_confirmation(args, backup_file) moved_data_dir: Optional[Path] = None try: stop_postgresql(args.dry_run) moved_data_dir = move_data_dir_aside("pre-upgrade", args.dry_run) reset_module_and_install(version, args.dry_run, args.yes) ensure_cluster_running(args.dry_run) LOGGER.info("Restoring logical backup into the new PostgreSQL cluster.") restore_backup(backup_file, args.dry_run) run(["systemctl", "restart", POSTGRES_SERVICE], dry_run=args.dry_run) run(["systemctl", "status", "--no-pager", POSTGRES_SERVICE], dry_run=args.dry_run, check=False) finalize_cpanel_postgresql(args.dry_run, args.yes) except Exception: LOGGER.error("Upgrade did not complete.") LOGGER.error("Logical backup: %s", backup_file) if moved_data_dir is not None: LOGGER.error("Previous data directory: %s", moved_data_dir) LOGGER.error("Current data directory: %s", DATA_DIR) raise def main() -> int: logging.basicConfig(level=logging.INFO, format="%(message)s") args = parse_args() try: require_root() require_cpanel() version = args.version if args.version is not None else prompt_for_version() validate_selected_version(version) if args.upgrade: logical_upgrade(args, version) else: install_or_reset(args, version) LOGGER.info("Done on %s.", socket.gethostname()) return 0 except KeyboardInterrupt: LOGGER.info("") LOGGER.info("Cancelled.") return 130 except subprocess.CalledProcessError as exc: LOGGER.error( "Command failed with exit code %s: %s", exc.returncode, " ".join(exc.cmd), ) if exc.stdout: LOGGER.error("%s", exc.stdout.rstrip()) if exc.stderr: LOGGER.error("%s", exc.stderr.rstrip()) return exc.returncode or 1 except Fatal as exc: LOGGER.error("ERROR: %s", exc) return 1 if __name__ == "__main__": sys.exit(main())
| ver. 1.4 |
Github
|
.
| PHP 7.4.33 | Генерация страницы: 0 |
proxy
|
phpinfo
|
Настройка