AI Update: معماری امنیتی و ثبت در دیتابیس مورد تأیید است، لطفاً پیاده‌س

This commit is contained in:
Antigravity Bot
2026-08-31 10:46:10 +03:30
parent 0496369b05
commit 56be74ccc9
38 changed files with 2354 additions and 9 deletions
@@ -0,0 +1,84 @@
Metadata-Version: 2.4
Name: paramiko
Version: 5.0.0
Summary: SSH2 protocol library
Author-email: Jeff Forcier <jeff@bitprophet.org>
License-Expression: LGPL-2.1
Project-URL: Docs, https://docs.paramiko.org
Project-URL: Source, https://github.com/paramiko/paramiko
Project-URL: Changelog, https://www.paramiko.org/changelog.html
Project-URL: CI, https://app.circleci.com/pipelines/github/paramiko/paramiko
Project-URL: Issues, https://github.com/paramiko/paramiko/issues
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Topic :: Internet
Classifier: Topic :: Security :: Cryptography
Classifier: Programming Language :: Python
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Requires-Python: >=3.9
Description-Content-Type: text/x-rst
License-File: LICENSE
Requires-Dist: bcrypt>=3.2
Requires-Dist: cryptography>=3.3
Requires-Dist: invoke>=2.0
Requires-Dist: pynacl>=1.5
Dynamic: license-file
|version| |python| |license| |ci| |coverage|
.. |version| image:: https://img.shields.io/pypi/v/paramiko
:target: https://pypi.org/project/paramiko/
:alt: PyPI - Package Version
.. |python| image:: https://img.shields.io/pypi/pyversions/paramiko
:target: https://pypi.org/project/paramiko/
:alt: PyPI - Python Version
.. |license| image:: https://img.shields.io/pypi/l/paramiko
:target: https://github.com/paramiko/paramiko/blob/main/LICENSE
:alt: PyPI - License
.. |ci| image:: https://img.shields.io/circleci/build/github/paramiko/paramiko/main
:target: https://app.circleci.com/pipelines/github/paramiko/paramiko
:alt: CircleCI
.. |coverage| image:: https://img.shields.io/codecov/c/gh/paramiko/paramiko
:target: https://app.codecov.io/gh/paramiko/paramiko
:alt: Codecov
Welcome to Paramiko!
====================
Paramiko is a pure-Python [#]_ implementation of the SSHv2 protocol [#]_,
providing both client and server functionality. It provides the foundation for
the high-level SSH library `Fabric <https://fabfile.org>`_, which is what we
recommend you use for common client use-cases such as running remote shell
commands or transferring files.
Direct use of Paramiko itself is only intended for users who need
advanced/low-level primitives or want to run an in-Python sshd.
For installation information, changelogs, FAQs and similar, please visit `our
main project website <https://paramiko.org>`_; for API details, see `the
versioned docs <https://docs.paramiko.org>`_. Additionally, the project
maintainer keeps a `roadmap <http://bitprophet.org/projects#roadmap>`_ on his
personal site.
.. [#]
Paramiko relies on `cryptography <https://cryptography.io>`_ for crypto
functionality, which makes use of C and Rust extensions but has many
precompiled options available. See `our installation page
<https://www.paramiko.org/installing.html>`_ for details.
.. [#]
OpenSSH's RFC specification page is a fantastic resource and collection of
links that we won't bother replicating here:
https://www.openssh.com/specs.html
OpenSSH itself also happens to be our primary reference implementation:
when in doubt, we consult how they do things, unless there are good reasons
not to. There are always some gaps, but we do our best to reconcile them
when possible.
@@ -0,0 +1,275 @@
Metadata-Version: 2.4
Name: PyNaCl
Version: 1.6.2
Summary: Python binding to the Networking and Cryptography (NaCl) library
Home-page: https://github.com/pyca/pynacl/
Author-email: The PyNaCl developers <cryptography-dev@python.org>
License: Apache-2.0
Project-URL: Homepage, https://github.com/pyca/pynacl
Project-URL: Bug Tracker, https://github.com/pyca/pynacl/issues
Project-URL: Documentation, https://pynacl.readthedocs.io
Classifier: License :: OSI Approved :: Apache Software License
Classifier: Programming Language :: Python :: Implementation :: CPython
Classifier: Programming Language :: Python :: Implementation :: PyPy
Classifier: Programming Language :: Python :: Free Threading :: 3 - Stable
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.8
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Requires-Python: >=3.8
License-File: LICENSE
License-File: licenses/LICENSE.libsodium.txt
Requires-Dist: cffi>=1.4.1; platform_python_implementation != "PyPy" and python_version < "3.9"
Requires-Dist: cffi>=2.0.0; platform_python_implementation != "PyPy" and python_version >= "3.9"
Provides-Extra: tests
Requires-Dist: pytest>=7.4.0; extra == "tests"
Requires-Dist: pytest-cov>=2.10.1; extra == "tests"
Requires-Dist: pytest-xdist>=3.5.0; extra == "tests"
Requires-Dist: hypothesis>=3.27.0; extra == "tests"
Provides-Extra: docs
Requires-Dist: sphinx<7; extra == "docs"
Requires-Dist: sphinx_rtd_theme; extra == "docs"
Dynamic: description
Dynamic: home-page
Dynamic: license-file
===============================================
PyNaCl: Python binding to the libsodium library
===============================================
.. image:: https://img.shields.io/pypi/v/pynacl.svg
:target: https://pypi.org/project/PyNaCl/
:alt: Latest Version
PyNaCl is a Python binding to `libsodium`_, which is a fork of the
`Networking and Cryptography library`_. These libraries have a stated goal of
improving usability, security and speed. It supports Python 3.8+ as well as
PyPy 3.
.. _libsodium: https://github.com/jedisct1/libsodium
.. _Networking and Cryptography library: https://nacl.cr.yp.to/
Features
--------
* Digital signatures
* Secret-key encryption
* Public-key encryption
* Hashing and message authentication
* Password based key derivation and password hashing
`Changelog`_
------------
.. _Changelog: https://pynacl.readthedocs.io/en/latest/changelog/
Installation
============
Binary wheel install
--------------------
PyNaCl ships as a binary wheel on macOS, Windows and Linux ``manylinux1`` [#many]_ ,
so all dependencies are included. Make sure you have an up-to-date pip
and run:
.. code-block:: console
$ pip install pynacl
Faster wheel build
------------------
You can define the environment variable ``LIBSODIUM_MAKE_ARGS`` to pass arguments to ``make``
and enable `parallelization`_:
.. code-block:: console
$ LIBSODIUM_MAKE_ARGS=-j4 pip install pynacl
Linux source build
------------------
PyNaCl relies on `libsodium`_, a portable C library. A copy is bundled
with PyNaCl so to install you can run:
.. code-block:: console
$ pip install pynacl
If you'd prefer to use the version of ``libsodium`` provided by your
distribution, you can disable the bundled copy during install by running:
.. code-block:: console
$ SODIUM_INSTALL=system pip install pynacl
.. warning:: Usage of the legacy ``easy_install`` command provided by setuptools
is generally discouraged, and is completely unsupported in PyNaCl's case.
.. _parallelization: https://www.gnu.org/software/make/manual/html_node/Parallel.html
.. _libsodium: https://github.com/jedisct1/libsodium
.. [#many] `manylinux1 wheels <https://www.python.org/dev/peps/pep-0513/>`_
are built on a baseline linux environment based on Centos 5.11
and should work on most x86 and x86_64 glibc based linux environments.
GNU Make (gmake) may be required for newer versions of PyNaCl. You can
set the binary to use by setting the ``MAKE`` environment variable:
.. code-block:: console
$ MAKE=gmake pip install pynacl
Changelog
=========
1.6.2 (2026-01-01)
------------------
* Updated ``libsodium`` to 1.0.20-stable (2025-12-31 build) to resolve
``CVE-2025-69277``.
1.6.1 (2025-11-10)
------------------
* The ``MAKE`` environment variable can now be used to specify the ``make``
binary that should be used in the build process.
1.6.0 (2025-09-11)
------------------
* **BACKWARDS INCOMPATIBLE:** Removed support for Python 3.6 and 3.7.
* Added support for the low level AEAD AES bindings.
* Added support for ``crypto_core_ed25519_from_uniform``.
* Update ``libsodium`` to 1.0.20-stable (2025-08-27 build).
* Added support for free-threaded Python 3.14.
* Added support for Windows on ARM wheels.
1.5.0 (2022-01-07)
------------------
* **BACKWARDS INCOMPATIBLE:** Removed support for Python 2.7 and Python 3.5.
* **BACKWARDS INCOMPATIBLE:** We no longer distribute ``manylinux1``
wheels.
* Added ``manylinux2014``, ``manylinux_2_24``, ``musllinux``, and macOS
``universal2`` wheels (the latter supports macOS ``arm64``).
* Update ``libsodium`` to 1.0.18-stable (July 25, 2021 release).
* Add inline type hints.
1.4.0 (2020-05-25)
------------------
* Update ``libsodium`` to 1.0.18.
* **BACKWARDS INCOMPATIBLE:** We no longer distribute 32-bit ``manylinux1``
wheels. Continuing to produce them was a maintenance burden.
* Added support for Python 3.8, and removed support for Python 3.4.
* Add low level bindings for extracting the seed and the public key
from crypto_sign_ed25519 secret key
* Add low level bindings for deterministic random generation.
* Add ``wheel`` and ``setuptools`` setup_requirements in ``setup.py`` (#485)
* Fix checks on very slow builders (#481, #495)
* Add low-level bindings to ed25519 arithmetic functions
* Update low-level blake2b state implementation
* Fix wrong short-input behavior of SealedBox.decrypt() (#517)
* Raise CryptPrefixError exception instead of InvalidkeyError when trying
to check a password against a verifier stored in a unknown format (#519)
* Add support for minimal builds of libsodium. Trying to call functions
not available in a minimal build will raise an UnavailableError
exception. To compile a minimal build of the bundled libsodium, set
the SODIUM_INSTALL_MINIMAL environment variable to any non-empty
string (e.g. ``SODIUM_INSTALL_MINIMAL=1``) for setup.
1.3.0 2018-09-26
----------------
* Added support for Python 3.7.
* Update ``libsodium`` to 1.0.16.
* Run and test all code examples in PyNaCl docs through sphinx's
doctest builder.
* Add low-level bindings for chacha20-poly1305 AEAD constructions.
* Add low-level bindings for the chacha20-poly1305 secretstream constructions.
* Add low-level bindings for ed25519ph pre-hashed signing construction.
* Add low-level bindings for constant-time increment and addition
on fixed-precision big integers represented as little-endian
byte sequences.
* Add low-level bindings for the ISO/IEC 7816-4 compatible padding API.
* Add low-level bindings for libsodium's crypto_kx... key exchange
construction.
* Set hypothesis deadline to None in tests/test_pwhash.py to avoid
incorrect test failures on slower processor architectures. GitHub
issue #370
1.2.1 - 2017-12-04
------------------
* Update hypothesis minimum allowed version.
* Infrastructure: add proper configuration for readthedocs builder
runtime environment.
1.2.0 - 2017-11-01
------------------
* Update ``libsodium`` to 1.0.15.
* Infrastructure: add jenkins support for automatic build of
``manylinux1`` binary wheels
* Added support for ``SealedBox`` construction.
* Added support for ``argon2i`` and ``argon2id`` password hashing constructs
and restructured high-level password hashing implementation to expose
the same interface for all hashers.
* Added support for 128 bit ``siphashx24`` variant of ``siphash24``.
* Added support for ``from_seed`` APIs for X25519 key pair generation.
* Dropped support for Python 3.3.
1.1.2 - 2017-03-31
------------------
* reorder link time library search path when using bundled
libsodium
1.1.1 - 2017-03-15
------------------
* Fixed a circular import bug in ``nacl.utils``.
1.1.0 - 2017-03-14
------------------
* Dropped support for Python 2.6.
* Added ``shared_key()`` method on ``Box``.
* You can now pass ``None`` to ``nonce`` when encrypting with ``Box`` or
``SecretBox`` and it will automatically generate a random nonce.
* Added support for ``siphash24``.
* Added support for ``blake2b``.
* Added support for ``scrypt``.
* Update ``libsodium`` to 1.0.11.
* Default to the bundled ``libsodium`` when compiling.
* All raised exceptions are defined mixing-in
``nacl.exceptions.CryptoError``
1.0.1 - 2016-01-24
------------------
* Fix an issue with absolute paths that prevented the creation of wheels.
1.0 - 2016-01-23
----------------
* PyNaCl has been ported to use the new APIs available in cffi 1.0+.
Due to this change we no longer support PyPy releases older than 2.6.
* Python 3.2 support has been dropped.
* Functions to convert between Ed25519 and Curve25519 keys have been added.
0.3.0 - 2015-03-04
------------------
* The low-level API (`nacl.c.*`) has been changed to match the
upstream NaCl C/C++ conventions (as well as those of other NaCl bindings).
The order of arguments and return values has changed significantly. To
avoid silent failures, `nacl.c` has been removed, and replaced with
`nacl.bindings` (with the new argument ordering). If you have code which
calls these functions (e.g. `nacl.c.crypto_box_keypair()`), you must review
the new docstrings and update your code/imports to match the new
conventions.
@@ -0,0 +1,74 @@
Metadata-Version: 2.4
Name: invoke
Version: 3.0.3
Summary: Pythonic task execution
Author-email: Jeff Forcier <jeff@bitprophet.org>
License-Expression: BSD-2-Clause
Project-URL: Docs, https://docs.pyinvoke.org
Project-URL: Source, https://github.com/pyinvoke/invoke
Project-URL: Changelog, https://www.pyinvoke.org/changelog.html
Project-URL: CI, https://app.circleci.com/pipelines/github/pyinvoke/invoke
Project-URL: Issues, https://github.com/pyinvoke/invoke/issues
Classifier: Development Status :: 5 - Production/Stable
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: System Administrators
Classifier: Operating System :: POSIX
Classifier: Operating System :: Unix
Classifier: Operating System :: MacOS :: MacOS X
Classifier: Operating System :: Microsoft :: Windows
Classifier: Programming Language :: Python
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Software Development
Classifier: Topic :: Software Development :: Build Tools
Classifier: Topic :: Software Development :: Libraries
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Topic :: System :: Software Distribution
Classifier: Topic :: System :: Systems Administration
Requires-Python: >=3.9
Description-Content-Type: text/x-rst
License-File: LICENSE
Dynamic: license-file
|version| |python| |license| |ci| |coverage|
.. |version| image:: https://img.shields.io/pypi/v/invoke
:target: https://pypi.org/project/invoke/
:alt: PyPI - Package Version
.. |python| image:: https://img.shields.io/pypi/pyversions/invoke
:target: https://pypi.org/project/invoke/
:alt: PyPI - Python Version
.. |license| image:: https://img.shields.io/pypi/l/invoke
:target: https://github.com/pyinvoke/invoke/blob/main/LICENSE
:alt: PyPI - License
.. |ci| image:: https://img.shields.io/circleci/build/github/pyinvoke/invoke/main
:target: https://app.circleci.com/pipelines/github/pyinvoke/invoke
:alt: CircleCI
.. |coverage| image:: https://img.shields.io/codecov/c/gh/pyinvoke/invoke
:target: https://app.codecov.io/gh/pyinvoke/invoke
:alt: Codecov
Welcome to Invoke!
==================
Invoke is a Python library for managing shell-oriented subprocesses and
organizing executable Python code into CLI-invokable tasks. It draws
inspiration from various sources (``make``/``rake``, Fabric 1.x, etc) to arrive
at a powerful & clean feature set.
To find out what's new in this version of Invoke, please see `the changelog
<https://pyinvoke.org/changelog.html#{}>`_.
For a high level introduction, including example code, please see `our main
project website <https://pyinvoke.org>`_; or for detailed API docs, see `the
versioned API website <https://docs.pyinvoke.org>`_.
The project maintainer keeps a `roadmap
<https://bitprophet.org/projects#roadmap>`_ on his website.
@@ -0,0 +1,67 @@
Metadata-Version: 2.4
Name: cffi
Version: 2.1.1
Summary: Foreign Function Interface for Python calling C code.
Author: Armin Rigo, Maciej Fijalkowski
Maintainer: Matt Davis, Matt Clay
License-Expression: MIT-0
Project-URL: Documentation, https://cffi.readthedocs.io/
Project-URL: Changelog, https://cffi.readthedocs.io/en/latest/whatsnew.html
Project-URL: Download, https://github.com/python-cffi/cffi/releases
Project-URL: Contact, https://groups.google.com/forum/#!forum/python-cffi
Project-URL: Source Code, https://github.com/python-cffi/cffi
Project-URL: Issue Tracker, https://github.com/python-cffi/cffi/issues
Classifier: Programming Language :: Python
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Programming Language :: Python :: 3.15
Classifier: Programming Language :: Python :: Free Threading :: 2 - Beta
Classifier: Programming Language :: Python :: Implementation :: CPython
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: pycparser; implementation_name != "PyPy"
Dynamic: license-file
[![GitHub Actions Status](https://github.com/python-cffi/cffi/actions/workflows/ci.yaml/badge.svg?branch=main)](https://github.com/python-cffi/cffi/actions/workflows/ci.yaml?query=branch%3Amain++)
[![PyPI version](https://img.shields.io/pypi/v/cffi.svg)](https://pypi.org/project/cffi)
[![Read the Docs](https://img.shields.io/badge/docs-latest-blue.svg)][Documentation]
CFFI
====
Foreign Function Interface for Python calling C code.
Please see the [Documentation] or uncompiled in the `doc/` subdirectory.
Download
--------
[Download page](https://github.com/python-cffi/cffi/releases)
Source Code
-----------
Source code is publicly available on
[GitHub](https://github.com/python-cffi/cffi).
Contact
-------
[Mailing list](https://groups.google.com/forum/#!forum/python-cffi)
Testing/development tips
------------------------
After `git clone` or `wget && tar`, we will get a directory called `cffi` or `cffi-x.x.x`. We call it `repo-directory`. To run tests under CPython, run the following in the `repo-directory`:
pip install pytest
pip install -e . # editable install of CFFI for local development
pytest src/c/ testing/
[Documentation]: http://cffi.readthedocs.org/
@@ -0,0 +1,343 @@
Metadata-Version: 2.4
Name: bcrypt
Version: 5.0.0
Summary: Modern password hashing for your software and your servers
Author-email: The Python Cryptographic Authority developers <cryptography-dev@python.org>
License: Apache-2.0
Project-URL: homepage, https://github.com/pyca/bcrypt/
Classifier: Development Status :: 5 - Production/Stable
Classifier: License :: OSI Approved :: Apache Software License
Classifier: Programming Language :: Python :: Implementation :: CPython
Classifier: Programming Language :: Python :: Implementation :: PyPy
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.8
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Programming Language :: Python :: Free Threading :: 3 - Stable
Requires-Python: >=3.8
Description-Content-Type: text/x-rst
License-File: LICENSE
Provides-Extra: tests
Requires-Dist: pytest!=3.3.0,>=3.2.1; extra == "tests"
Provides-Extra: typecheck
Requires-Dist: mypy; extra == "typecheck"
Dynamic: license-file
bcrypt
======
.. image:: https://img.shields.io/pypi/v/bcrypt.svg
:target: https://pypi.org/project/bcrypt/
:alt: Latest Version
.. image:: https://github.com/pyca/bcrypt/workflows/CI/badge.svg?branch=main
:target: https://github.com/pyca/bcrypt/actions?query=workflow%3ACI+branch%3Amain
Acceptable password hashing for your software and your servers (but you should
really use argon2id or scrypt)
Installation
============
To install bcrypt, simply:
.. code:: console
$ pip install bcrypt
Note that bcrypt should build very easily on Linux provided you have a C
compiler and a Rust compiler (the minimum supported Rust version is 1.56.0).
For Debian and Ubuntu, the following command will ensure that the required dependencies are installed:
.. code:: console
$ sudo apt-get install build-essential cargo
For Fedora and RHEL-derivatives, the following command will ensure that the required dependencies are installed:
.. code:: console
$ sudo yum install gcc cargo
For Alpine, the following command will ensure that the required dependencies are installed:
.. code:: console
$ apk add --update musl-dev gcc cargo
Alternatives
============
While bcrypt remains an acceptable choice for password storage, depending on your specific use case you may also want to consider using scrypt (either via `standard library`_ or `cryptography`_) or argon2id via `argon2_cffi`_.
Changelog
=========
5.0.0
-----
* Bumped MSRV to 1.74.
* Added support for Python 3.14 and free-threaded Python 3.14.
* Added support for Windows on ARM.
* Passing ``hashpw`` a password longer than 72 bytes now raises a
``ValueError``. Previously the password was silently truncated, following the
behavior of the original OpenBSD ``bcrypt`` implementation.
4.3.0
-----
* Dropped support for Python 3.7.
* We now support free-threaded Python 3.13.
* We now support PyPy 3.11.
* We now publish wheels for free-threaded Python 3.13, for PyPy 3.11 on
``manylinux``, and for ARMv7l on ``manylinux``.
4.2.1
-----
* Bump Rust dependency versions - this should resolve crashes on Python 3.13
free-threaded builds.
* We no longer build ``manylinux`` wheels for PyPy 3.9.
4.2.0
-----
* Bump Rust dependency versions
* Removed the ``BCRYPT_ALLOW_RUST_163`` environment variable.
4.1.3
-----
* Bump Rust dependency versions
4.1.2
-----
* Publish both ``py37`` and ``py39`` wheels. This should resolve some errors
relating to initializing a module multiple times per process.
4.1.1
-----
* Fixed the type signature on the ``kdf`` method.
* Fixed packaging bug on Windows.
* Fixed incompatibility with passlib package detection assumptions.
4.1.0
-----
* Dropped support for Python 3.6.
* Bumped MSRV to 1.64. (Note: Rust 1.63 can be used by setting the ``BCRYPT_ALLOW_RUST_163`` environment variable)
4.0.1
-----
* We now build PyPy ``manylinux`` wheels.
* Fixed a bug where passing an invalid ``salt`` to ``checkpw`` could result in
a ``pyo3_runtime.PanicException``. It now correctly raises a ``ValueError``.
4.0.0
-----
* ``bcrypt`` is now implemented in Rust. Users building from source will need
to have a Rust compiler available. Nothing will change for users downloading
wheels.
* We no longer ship ``manylinux2010`` wheels. Users should upgrade to the latest
``pip`` to ensure this doesnt cause issues downloading wheels on their
platform. We now ship ``manylinux_2_28`` wheels for users on new enough platforms.
* ``NUL`` bytes are now allowed in inputs.
3.2.2
-----
* Fixed packaging of ``py.typed`` files in wheels so that ``mypy`` works.
3.2.1
-----
* Added support for compilation on z/OS
* The next release of ``bcrypt`` with be 4.0 and it will require Rust at
compile time, for users building from source. There will be no additional
requirement for users who are installing from wheels. Users on most
platforms will be able to obtain a wheel by making sure they have an up to
date ``pip``. The minimum supported Rust version will be 1.56.0.
* This will be the final release for which we ship ``manylinux2010`` wheels.
Going forward the minimum supported manylinux ABI for our wheels will be
``manylinux2014``. The vast majority of users will continue to receive
``manylinux`` wheels provided they have an up to date ``pip``.
3.2.0
-----
* Added typehints for library functions.
* Dropped support for Python versions less than 3.6 (2.7, 3.4, 3.5).
* Shipped ``abi3`` Windows wheels (requires pip >= 20).
3.1.7
-----
* Set a ``setuptools`` lower bound for PEP517 wheel building.
* We no longer distribute 32-bit ``manylinux1`` wheels. Continuing to produce
them was a maintenance burden.
3.1.6
-----
* Added support for compilation on Haiku.
3.1.5
-----
* Added support for compilation on AIX.
* Dropped Python 2.6 and 3.3 support.
* Switched to using ``abi3`` wheels for Python 3. If you are not getting a
wheel on a compatible platform please upgrade your ``pip`` version.
3.1.4
-----
* Fixed compilation with mingw and on illumos.
3.1.3
-----
* Fixed a compilation issue on Solaris.
* Added a warning when using too few rounds with ``kdf``.
3.1.2
-----
* Fixed a compile issue affecting big endian platforms.
* Fixed invalid escape sequence warnings on Python 3.6.
* Fixed building in non-UTF8 environments on Python 2.
3.1.1
-----
* Resolved a ``UserWarning`` when used with ``cffi`` 1.8.3.
3.1.0
-----
* Added support for ``checkpw``, a convenience method for verifying a password.
* Ensure that you get a ``$2y$`` hash when you input a ``$2y$`` salt.
* Fixed a regression where ``$2a`` hashes were vulnerable to a wraparound bug.
* Fixed compilation under Alpine Linux.
3.0.0
-----
* Switched the C backend to code obtained from the OpenBSD project rather than
openwall.
* Added support for ``bcrypt_pbkdf`` via the ``kdf`` function.
2.0.0
-----
* Added support for an adjustible prefix when calling ``gensalt``.
* Switched to CFFI 1.0+
Usage
-----
Password Hashing
~~~~~~~~~~~~~~~~
Hashing and then later checking that a password matches the previous hashed
password is very simple:
.. code:: pycon
>>> import bcrypt
>>> password = b"super secret password"
>>> # Hash a password for the first time, with a randomly-generated salt
>>> hashed = bcrypt.hashpw(password, bcrypt.gensalt())
>>> # Check that an unhashed password matches one that has previously been
>>> # hashed
>>> if bcrypt.checkpw(password, hashed):
... print("It Matches!")
... else:
... print("It Does not Match :(")
KDF
~~~
As of 3.0.0 ``bcrypt`` now offers a ``kdf`` function which does ``bcrypt_pbkdf``.
This KDF is used in OpenSSH's newer encrypted private key format.
.. code:: pycon
>>> import bcrypt
>>> key = bcrypt.kdf(
... password=b'password',
... salt=b'salt',
... desired_key_bytes=32,
... rounds=100)
Adjustable Work Factor
~~~~~~~~~~~~~~~~~~~~~~
One of bcrypt's features is an adjustable logarithmic work factor. To adjust
the work factor merely pass the desired number of rounds to
``bcrypt.gensalt(rounds=12)`` which defaults to 12):
.. code:: pycon
>>> import bcrypt
>>> password = b"super secret password"
>>> # Hash a password for the first time, with a certain number of rounds
>>> hashed = bcrypt.hashpw(password, bcrypt.gensalt(14))
>>> # Check that a unhashed password matches one that has previously been
>>> # hashed
>>> if bcrypt.checkpw(password, hashed):
... print("It Matches!")
... else:
... print("It Does not Match :(")
Adjustable Prefix
~~~~~~~~~~~~~~~~~
Another one of bcrypt's features is an adjustable prefix to let you define what
libraries you'll remain compatible with. To adjust this, pass either ``2a`` or
``2b`` (the default) to ``bcrypt.gensalt(prefix=b"2b")`` as a bytes object.
As of 3.0.0 the ``$2y$`` prefix is still supported in ``hashpw`` but deprecated.
Maximum Password Length
~~~~~~~~~~~~~~~~~~~~~~~
The bcrypt algorithm only handles passwords up to 72 characters, any characters
beyond that are ignored. To work around this, a common approach is to hash a
password with a cryptographic hash (such as ``sha256``) and then base64
encode it to prevent NULL byte problems before hashing the result with
``bcrypt``:
.. code:: pycon
>>> password = b"an incredibly long password" * 10
>>> hashed = bcrypt.hashpw(
... base64.b64encode(hashlib.sha256(password).digest()),
... bcrypt.gensalt()
... )
Compatibility
-------------
This library should be compatible with py-bcrypt and it will run on Python
3.8+ (including free-threaded builds), and PyPy 3.
Security
--------
``bcrypt`` follows the `same security policy as cryptography`_, if you
identify a vulnerability, we ask you to contact us privately.
.. _`same security policy as cryptography`: https://cryptography.io/en/latest/security.html
.. _`standard library`: https://docs.python.org/3/library/hashlib.html#hashlib.scrypt
.. _`argon2_cffi`: https://argon2-cffi.readthedocs.io
.. _`cryptography`: https://cryptography.io/en/latest/hazmat/primitives/key-derivation-functions/#cryptography.hazmat.primitives.kdf.scrypt.Scrypt
+18
View File
@@ -130,6 +130,12 @@ class Project:
ftp_password: Optional[str] = None
ftp_path: str = "/"
ftp_tls: bool = False
ssh_host: Optional[str] = None
ssh_port: int = 22
ssh_user: Optional[str] = None
ssh_password: Optional[str] = None
ssh_key: Optional[str] = None
ssh_path: str = "/"
@dataclass
class Session:
@@ -272,6 +278,12 @@ class SessionManager:
"ftp_password",
"ftp_path",
"ftp_tls",
"ssh_host",
"ssh_port",
"ssh_user",
"ssh_password",
"ssh_key",
"ssh_path",
)
}
if "name" not in clean_p:
@@ -367,6 +379,12 @@ class SessionManager:
"ftp_password": p_obj.ftp_password,
"ftp_path": p_obj.ftp_path,
"ftp_tls": p_obj.ftp_tls,
"ssh_host": p_obj.ssh_host,
"ssh_port": p_obj.ssh_port,
"ssh_user": p_obj.ssh_user,
"ssh_password": p_obj.ssh_password,
"ssh_key": p_obj.ssh_key,
"ssh_path": p_obj.ssh_path,
}
curr = self.get_current_project(sess.chat_id)
data[str(k)] = {
+203
View File
@@ -50,6 +50,7 @@ from formatters import (
format_git_commits_page,
format_commit_detail_view,
format_ftp_info,
format_ssh_info,
split_message,
escape_html,
)
@@ -69,6 +70,8 @@ from invite_manager import (
from backup_manager import backup_manager
from git_manager import git_manager
from ftp_manager import ftp_manager
from ssh_manager import ssh_manager
from remote_audit import remote_audit
from sys_monitor import render_server_hardware_report
from usage_monitor import fetch_and_render_usage_report
from bot_actions import process_all_ai_actions
@@ -326,6 +329,9 @@ async def build_git_menu(chat_id: int) -> tuple[str, InlineKeyboardMarkup]:
],
[
InlineKeyboardButton("🚀 انتشار به پروداکشن (Publish)", callback_data="btn_git_publish"),
],
[
InlineKeyboardButton("💻 ریموت SSH", callback_data="btn_ssh_menu"),
InlineKeyboardButton("🚀 دیپلوی FTP", callback_data="btn_ftp_menu"),
],
[
@@ -352,6 +358,9 @@ async def build_git_menu(chat_id: int) -> tuple[str, InlineKeyboardMarkup]:
],
[
InlineKeyboardButton("🚀 Publish to Production", callback_data="btn_git_publish"),
],
[
InlineKeyboardButton("💻 Remote SSH", callback_data="btn_ssh_menu"),
InlineKeyboardButton("🚀 Deploy via FTP", callback_data="btn_ftp_menu"),
],
[
@@ -454,6 +463,91 @@ async def build_ftp_menu(chat_id: int) -> tuple[str, InlineKeyboardMarkup]:
return text, InlineKeyboardMarkup(keyboard)
async def build_ssh_menu(chat_id: int) -> tuple[str, InlineKeyboardMarkup]:
"""Generates the interactive SSH Remote Dev and deployment view."""
session = session_manager.get_or_create(chat_id)
curr_proj = session_manager.get_current_project(chat_id)
is_fa = (session.language or "").lower() in ("fa", "farsi", "persian", "🇮🇷 persian / farsi (فارسی)")
if not curr_proj:
msg = "⚠️ شما هنوز هیچ پروژه‌ای ایجاد نکرده‌اید." if is_fa else "⚠️ No active project found."
return msg, InlineKeyboardMarkup([[InlineKeyboardButton("🏠 منوی اصلی" if is_fa else "🏠 Main Dashboard", callback_data="btn_dashboard")]])
text = format_ssh_info(
project_name=curr_proj.name,
host=curr_proj.ssh_host,
port=curr_proj.ssh_port,
user=curr_proj.ssh_user,
path=curr_proj.ssh_path,
password=curr_proj.ssh_password,
has_key=bool(curr_proj.ssh_key),
lang=session.language,
)
if is_fa:
keyboard = [
[
InlineKeyboardButton("🚀 ارسال به پروداکشن (Push)", callback_data="btn_ssh_push"),
InlineKeyboardButton("📥 دریافت از سرور (Pull)", callback_data="btn_ssh_pull"),
],
[
InlineKeyboardButton("🔍 تست اتصال SSH", callback_data="btn_ssh_test"),
InlineKeyboardButton("💻 اجرای فرمان ریموت", callback_data="btn_ssh_exec_prompt"),
],
[
InlineKeyboardButton("🌐 ویرایش هاست/پورت", callback_data="ssh_set:host"),
InlineKeyboardButton("👤 ویرایش نام کاربری", callback_data="ssh_set:user"),
],
[
InlineKeyboardButton("🔑 ویرایش رمز عبور", callback_data="ssh_set:pass"),
InlineKeyboardButton("🗝️ تنظیم کلید SSH Key", callback_data="ssh_set:key"),
],
[
InlineKeyboardButton("📂 ویرایش مسیر ریموت", callback_data="ssh_set:path"),
InlineKeyboardButton("📜 تاریخچه عملیات", callback_data="btn_ssh_logs"),
],
[
InlineKeyboardButton("🗑️ پاک‌سازی اطلاعات", callback_data="ssh_clear_conf"),
InlineKeyboardButton("🐙 منوی گیت و برنچ‌ها", callback_data="btn_git_menu"),
],
[
InlineKeyboardButton("🏠 منوی اصلی", callback_data="btn_dashboard"),
],
]
else:
keyboard = [
[
InlineKeyboardButton("🚀 Push to Production", callback_data="btn_ssh_push"),
InlineKeyboardButton("📥 Pull from Server", callback_data="btn_ssh_pull"),
],
[
InlineKeyboardButton("🔍 Test SSH Connection", callback_data="btn_ssh_test"),
InlineKeyboardButton("💻 Run Remote Command", callback_data="btn_ssh_exec_prompt"),
],
[
InlineKeyboardButton("🌐 Edit Host/Port", callback_data="ssh_set:host"),
InlineKeyboardButton("👤 Edit Username", callback_data="ssh_set:user"),
],
[
InlineKeyboardButton("🔑 Edit Password", callback_data="ssh_set:pass"),
InlineKeyboardButton("🗝️ Set SSH Key", callback_data="ssh_set:key"),
],
[
InlineKeyboardButton("📂 Edit Remote Path", callback_data="ssh_set:path"),
InlineKeyboardButton("📜 Audit Logs", callback_data="btn_ssh_logs"),
],
[
InlineKeyboardButton("🗑️ Clear Config", callback_data="ssh_clear_conf"),
InlineKeyboardButton("🐙 Git & Branches Menu", callback_data="btn_git_menu"),
],
[
InlineKeyboardButton("🏠 Main Dashboard", callback_data="btn_dashboard"),
],
]
return text, InlineKeyboardMarkup(keyboard)
async def build_git_history_menu(chat_id: int, page: int = 1) -> tuple[str, InlineKeyboardMarkup]:
"""Generates paginated commit history view."""
@@ -3502,6 +3596,13 @@ async def ftp_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
text, markup = await build_ftp_menu(chat_id)
await update.message.reply_html(text, reply_markup=markup, disable_web_page_preview=True)
# Command: /ssh or /remotedev
@check_auth
async def ssh_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
chat_id = update.effective_chat.id
text, markup = await build_ssh_menu(chat_id)
await update.message.reply_html(text, reply_markup=markup, disable_web_page_preview=True)
# Command: /sync
@check_auth
async def sync_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
@@ -6723,6 +6824,108 @@ async def message_handler(update: Update, context: ContextTypes.DEFAULT_TYPE):
await update.message.reply_html(text, reply_markup=markup, disable_web_page_preview=True)
return
if state.get("type") == "ssh_set" and curr_proj:
field = state.get("field")
val = update.message.text.strip()
if field == "host":
if ":" in val:
h, p = val.rsplit(":", 1)
curr_proj.ssh_host = h.strip()
try:
curr_proj.ssh_port = int(p.strip())
except Exception:
curr_proj.ssh_port = 22
else:
curr_proj.ssh_host = val
curr_proj.ssh_port = 22
elif field == "user":
curr_proj.ssh_user = val
elif field == "pass":
curr_proj.ssh_password = val
try:
await update.message.delete()
except Exception:
pass
elif field == "key":
curr_proj.ssh_key = val
try:
await update.message.delete()
except Exception:
pass
elif field == "path":
curr_proj.ssh_path = val if val.startswith("/") else f"/{val}"
session_manager.save()
field_labels = {
"host": "هاست و پورت SSH",
"user": "نام کاربری SSH",
"pass": "رمز عبور SSH",
"key": "کلید اختصاصی SSH Key",
"path": "مسیر مقصد ریموت",
}
label_fa = field_labels.get(field, field)
ack_msg = f"✅ <b>{label_fa} با موفقیت ذخیره شد.</b>" if is_fa else f"✅ <b>SSH {field} updated successfully.</b>"
await update.message.reply_html(ack_msg)
# Auto-test connection if host is present
if curr_proj.ssh_host:
test_ok, test_msg = await ssh_manager.test_connection(
host=curr_proj.ssh_host,
port=curr_proj.ssh_port,
user=curr_proj.ssh_user or "",
password=curr_proj.ssh_password,
key=curr_proj.ssh_key,
path=curr_proj.ssh_path or "/",
user_id=chat_id,
project_name=curr_proj.name,
requester="user_button",
)
test_status = "✅ اتصال SSH با موفقیت بررسی و تایید شد." if test_ok else f"⚠️ نتیجه تست اتصال SSH: {test_msg}"
await update.message.reply_html(f"<i>{test_status}</i>")
text, markup = await build_ssh_menu(chat_id)
await update.message.reply_html(text, reply_markup=markup, disable_web_page_preview=True)
return
if state.get("type") == "ssh_exec" and curr_proj:
cmd = update.message.text.strip()
if not curr_proj.ssh_host:
await update.message.reply_html("⚠️ اطلاعات سرور SSH تنظیم نشده است.")
text, markup = await build_ssh_menu(chat_id)
await update.message.reply_html(text, reply_markup=markup)
return
wait_msg = await update.message.reply_html(f"⏳ <i>در حال اجرای فرمان در سرور ریموت...</i>\n<code>{escape_html(cmd)}</code>")
ok, out, code = await ssh_manager.execute_command(
host=curr_proj.ssh_host,
port=curr_proj.ssh_port,
user=curr_proj.ssh_user or "",
password=curr_proj.ssh_password,
key=curr_proj.ssh_key,
remote_path=curr_proj.ssh_path or "/",
command=cmd,
user_id=chat_id,
project_name=curr_proj.name,
requester="user_command",
)
st_icon = "" if ok else ""
res_text = (
f"{st_icon} <b>نتیجه اجرای دستور ریموت:</b>\n"
f"• 💻 <b>دستور:</b> <code>{escape_html(cmd)}</code>\n"
f"• 🔢 <b>کد خروج:</b> <code>{code}</code>\n"
f"• 📜 <b>خروجی:</b>\n<pre>{escape_html(out)}</pre>"
)
try:
await wait_msg.edit_text(res_text, parse_mode=constants.ParseMode.HTML)
except Exception:
await update.message.reply_html(res_text)
text, markup = await build_ssh_menu(chat_id)
await update.message.reply_html(text, reply_markup=markup, disable_web_page_preview=True)
return
if not curr_proj:
if is_fa:
msg = (
+295 -1
View File
@@ -1623,7 +1623,301 @@ async def execute_action(
return badge, side_effects, created_task
# -------------------------------------------------------------
# 35. SAVE_MEMORY / STORE_MEMORY / SET_MEMORY
# 35. SSH_CONFIG / SETUP_SSH / SET_SSH
# -------------------------------------------------------------
elif act in ("SSH_CONFIG", "SETUP_SSH", "SET_SSH", "CONFIGURE_SSH"):
p_name = attrs.get("project") or attrs.get("name") or current_project_name
proj = session.projects.get(p_name) if p_name else session_manager.get_current_project(chat_id)
if not proj:
return f"\n⚠️ پروژه <code>{escape_html(p_name or '')}</code> یافت نشد." if is_fa else f"\n⚠️ Project <code>{escape_html(p_name or '')}</code> not found.", side_effects, created_task
host = attrs.get("host") or attrs.get("server") or attrs.get("ssh_host")
port_raw = attrs.get("port") or attrs.get("ssh_port") or "22"
user = attrs.get("user") or attrs.get("username") or attrs.get("ssh_user") or ""
path = attrs.get("path") or attrs.get("dir") or attrs.get("remote_path") or attrs.get("ssh_path") or "/"
try:
port = int(port_raw)
except Exception:
port = 22
if host:
proj.ssh_host = host.strip()
proj.ssh_port = port
proj.ssh_user = user.strip() if user else proj.ssh_user
proj.ssh_path = path.strip()
session_manager.save()
from remote_audit import remote_audit
remote_audit.log_operation(
user_id=chat_id,
project_name=proj.name,
service_type="ssh",
action_type="update_config",
requester="ai_agent",
status="success",
details=f"Host: {proj.ssh_host}:{proj.ssh_port}, Path: {proj.ssh_path}",
result_summary="SSH configuration updated",
)
# Test connection
from ssh_manager import ssh_manager
test_ok, test_msg = await ssh_manager.test_connection(
host=proj.ssh_host,
port=proj.ssh_port,
user=proj.ssh_user or "",
password=proj.ssh_password,
key=proj.ssh_key,
path=proj.ssh_path,
user_id=chat_id,
project_name=proj.name,
requester="ai_agent",
)
status_icon = "" if test_ok else "⚠️"
badge = (
f"\n\n💻 <b>تنظیمات اتصال SSH برای پروژه <code>{escape_html(proj.name)}</code> به‌روز شد:</b>\n"
f"• 🌐 <b>هاست:</b> <code>{escape_html(proj.ssh_host)}:{proj.ssh_port}</code>\n"
f"• 👤 <b>نام کاربری:</b> <code>{escape_html(proj.ssh_user or '(پیش‌فرض root)')}</code>\n"
f"• 📂 <b>مسیر ریموت:</b> <code>{escape_html(proj.ssh_path)}</code>\n"
f"{status_icon} <b>نتیجه تست اتصال:</b> {test_msg}"
if is_fa else
f"\n\n💻 <b>SSH Configuration updated for project <code>{escape_html(proj.name)}</code>:</b>\n"
f"• 🌐 <b>Host:</b> <code>{escape_html(proj.ssh_host)}:{proj.ssh_port}</code>\n"
f"• 👤 <b>User:</b> <code>{escape_html(proj.ssh_user or '(default root)')}</code>\n"
f"• 📂 <b>Remote Path:</b> <code>{escape_html(proj.ssh_path)}</code>\n"
f"{status_icon} <b>Connection test:</b> {test_msg}"
)
else:
curr_host = proj.ssh_host or "(تنظیم نشده)"
badge = (
f"\n\n️ <b>وضعیت تنظیمات SSH پروژه <code>{escape_html(proj.name)}</code>:</b>\n"
f"• 🌐 <b>هاست:</b> <code>{escape_html(curr_host)}:{proj.ssh_port}</code>\n"
f"• 👤 <b>کاربر:</b> <code>{escape_html(proj.ssh_user or '(تعیین نشده)')}</code>\n"
f"• 📂 <b>مسیر:</b> <code>{escape_html(proj.ssh_path or '/')}</code>"
if is_fa else
f"\n\n️ <b>SSH Status for <code>{escape_html(proj.name)}</code>:</b>\n"
f"• 🌐 <b>Host:</b> <code>{escape_html(curr_host)}:{proj.ssh_port}</code>\n"
f"• 👤 <b>User:</b> <code>{escape_html(proj.ssh_user or '(none)')}</code>\n"
f"• 📂 <b>Path:</b> <code>{escape_html(proj.ssh_path or '/')}</code>"
)
return badge, side_effects, created_task
# -------------------------------------------------------------
# 36. SSH_TEST / TEST_SSH
# -------------------------------------------------------------
elif act in ("SSH_TEST", "TEST_SSH", "CHECK_SSH"):
p_name = attrs.get("project") or attrs.get("name") or current_project_name
proj = session.projects.get(p_name) if p_name else session_manager.get_current_project(chat_id)
if not proj:
return f"\n⚠️ پروژه <code>{escape_html(p_name or '')}</code> یافت نشد." if is_fa else f"\n⚠️ Project <code>{escape_html(p_name or '')}</code> not found.", side_effects, created_task
if not proj.ssh_host:
return "\n⚠️ اطلاعات سرور SSH برای این پروژه ثبت نشده است. می‌توانید از منوی <code>/ssh</code> آن را تنظیم کنید." if is_fa else "\n⚠️ SSH credentials are not set for this project.", side_effects, created_task
from ssh_manager import ssh_manager
test_ok, test_msg = await ssh_manager.test_connection(
host=proj.ssh_host,
port=proj.ssh_port,
user=proj.ssh_user or "",
password=proj.ssh_password,
key=proj.ssh_key,
path=proj.ssh_path or "/",
user_id=chat_id,
project_name=proj.name,
requester="ai_agent",
)
icon = "" if test_ok else ""
badge = (
f"\n\n{icon} <b>تست اتصال سرور SSH پروژه <code>{escape_html(proj.name)}</code>:</b>\n"
f"• 🌐 <b>سرور:</b> <code>{escape_html(proj.ssh_host)}:{proj.ssh_port}</code>\n"
f"• 📂 <b>مسیر:</b> <code>{escape_html(proj.ssh_path or '/')}</code>\n"
f"• 📋 <b>وضعیت:</b>\n{test_msg}"
if is_fa else
f"\n\n{icon} <b>SSH Test Result for <code>{escape_html(proj.name)}</code>:</b>\n"
f"• 🌐 <b>Server:</b> <code>{escape_html(proj.ssh_host)}:{proj.ssh_port}</code>\n"
f"• 📂 <b>Path:</b> <code>{escape_html(proj.ssh_path or '/')}</code>\n"
f"• 📋 <b>Status:</b>\n{test_msg}"
)
return badge, side_effects, created_task
# -------------------------------------------------------------
# 37. SSH_PUSH / PUSH_SSH / SSH_DEPLOY
# -------------------------------------------------------------
elif act in ("SSH_PUSH", "PUSH_SSH", "SSH_DEPLOY", "DEPLOY_SSH"):
p_name = attrs.get("project") or attrs.get("name") or current_project_name
proj = session.projects.get(p_name) if p_name else session_manager.get_current_project(chat_id)
if not proj:
return f"\n⚠️ پروژه <code>{escape_html(p_name or '')}</code> یافت نشد." if is_fa else f"\n⚠️ Project <code>{escape_html(p_name or '')}</code> not found.", side_effects, created_task
if not proj.ssh_host:
return "\n⚠️ مشخصات سرور SSH برای این پروژه یافت نشد. لطفاً ابتدا در منوی <code>/ssh</code> مشخصات را تنظیم نمایید." if is_fa else "\n⚠️ SSH configuration not found for this project.", side_effects, created_task
from ssh_manager import ssh_manager
from git_manager import git_manager
try:
await git_manager.git_publish(proj.workspace, message="Auto-publish before SSH push", repo_name=proj.name)
except Exception as pe:
logger.warning(f"Auto-publish warning before SSH push: {pe}")
res = await ssh_manager.push_project(
workspace_path=proj.workspace,
host=proj.ssh_host,
port=proj.ssh_port,
user=proj.ssh_user or "",
password=proj.ssh_password,
key=proj.ssh_key,
remote_path=proj.ssh_path or "/",
branch="production",
user_id=chat_id,
project_name=proj.name,
requester="ai_agent",
)
if res.get("success"):
kb = round(res.get("bytes_transferred", 0) / 1024, 2)
mb = round(kb / 1024, 2)
size_str = f"{mb} مگابایت" if mb >= 1.0 else f"{kb} کیلوبایت"
files_up = res.get("files_uploaded", 0)
files_skip = res.get("files_skipped", 0)
duration = round(res.get("duration", 0), 2)
target_p = proj.ssh_path or "/"
badge = (
f"\n\n🚀 <b>کدهای شاخه production با موفقیت به سرور SSH منتقل شدند:</b>\n"
f"• 📁 <b>پروژه:</b> <code>{escape_html(proj.name)}</code>\n"
f"• 🌐 <b>مقصد ریموت:</b> <code>{escape_html(proj.ssh_host)}:{proj.ssh_port}</code> (مسیر: <code>{escape_html(target_p)}</code>)\n"
f"• 📤 <b>فایل‌های آپلودشده:</b> <b>{files_up}</b> فایل ({size_str})\n"
f"• 🛡️ <b>داده‌های محلی/دیتابیس/آپلود نادیده گرفته‌شده:</b> <b>{files_skip}</b> آیتم\n"
f"• 🔒 <b>داده‌های سرور پروداکشن:</b> کاملاً دست‌نخورده و ایمن حفظ شدند\n"
f"• ⏱️ <b>مدت زمان انتقال:</b> <code>{duration}</code> ثانیه"
if is_fa else
f"\n\n🚀 <b>Production branch pushed successfully to SSH server:</b>\n"
f"• 📁 <b>Project:</b> <code>{escape_html(proj.name)}</code>\n"
f"• 🌐 <b>Remote Target:</b> <code>{escape_html(proj.ssh_host)}:{proj.ssh_port}</code> (Path: <code>{escape_html(target_p)}</code>)\n"
f"• 📤 <b>Files Uploaded:</b> <b>{files_up}</b> ({size_str})\n"
f"• 🛡️ <b>Skipped Junk/Databases:</b> <b>{files_skip}</b> items\n"
f"• 🔒 <b>Remote production data:</b> Intact & Protected\n"
f"• ⏱️ <b>Duration:</b> <code>{duration}s</code>"
)
else:
err = res.get("error", "Unknown error")
badge = f"\n⚠️ <b>خطا در پوش SSH به سرور مقصد:</b> {escape_html(str(err))}" if is_fa else f"\n⚠️ <b>SSH Push Failed:</b> {escape_html(str(err))}"
return badge, side_effects, created_task
# -------------------------------------------------------------
# 38. SSH_PULL / PULL_SSH / SSH_FETCH
# -------------------------------------------------------------
elif act in ("SSH_PULL", "PULL_SSH", "SSH_FETCH", "FETCH_SSH"):
p_name = attrs.get("project") or attrs.get("name") or current_project_name
proj = session.projects.get(p_name) if p_name else session_manager.get_current_project(chat_id)
if not proj:
return f"\n⚠️ پروژه <code>{escape_html(p_name or '')}</code> یافت نشد." if is_fa else f"\n⚠️ Project <code>{escape_html(p_name or '')}</code> not found.", side_effects, created_task
if not proj.ssh_host:
return "\n⚠️ مشخصات سرور SSH برای این پروژه یافت نشد." if is_fa else "\n⚠️ SSH configuration not found for this project.", side_effects, created_task
from ssh_manager import ssh_manager
res = await ssh_manager.pull_project(
workspace_path=proj.workspace,
host=proj.ssh_host,
port=proj.ssh_port,
user=proj.ssh_user or "",
password=proj.ssh_password,
key=proj.ssh_key,
remote_path=proj.ssh_path or "/",
branch="production",
user_id=chat_id,
project_name=proj.name,
requester="ai_agent",
)
if res.get("success"):
kb = round(res.get("bytes_transferred", 0) / 1024, 2)
mb = round(kb / 1024, 2)
size_str = f"{mb} مگابایت" if mb >= 1.0 else f"{kb} کیلوبایت"
files_down = res.get("files_downloaded", 0)
files_skip = res.get("files_skipped", 0)
duration = round(res.get("duration", 0), 2)
badge = (
f"\n\n📥 <b>فایل‌های پروژه از سرور SSH با موفقیت دریافت شدند (Pull):</b>\n"
f"• 📁 <b>پروژه:</b> <code>{escape_html(proj.name)}</code>\n"
f"• 📥 <b>فایل‌های دریافت‌شده:</b> <b>{files_down}</b> فایل ({size_str})\n"
f"• 🛡️ <b>فایل‌های نادیده گرفته‌شده (دیتابیس/لاگ/آپلود):</b> <b>{files_skip}</b> آیتم\n"
f"• ⏱️ <b>مدت زمان انتقال:</b> <code>{duration}</code> ثانیه"
if is_fa else
f"\n\n📥 <b>Project pulled successfully from SSH server:</b>\n"
f"• 📁 <b>Project:</b> <code>{escape_html(proj.name)}</code>\n"
f"• 📥 <b>Files Downloaded:</b> <b>{files_down}</b> ({size_str})\n"
f"• 🛡️ <b>Skipped (Databases/Logs/Uploads):</b> <b>{files_skip}</b> items\n"
f"• ⏱️ <b>Duration:</b> <code>{duration}s</code>"
)
else:
err = res.get("error", "Unknown error")
badge = f"\n⚠️ <b>خطا در دریافت فایل‌ها از سرور SSH:</b> {escape_html(str(err))}" if is_fa else f"\n⚠️ <b>SSH Pull Failed:</b> {escape_html(str(err))}"
return badge, side_effects, created_task
# -------------------------------------------------------------
# 39. SSH_EXEC / EXEC_SSH / RUN_SSH
# -------------------------------------------------------------
elif act in ("SSH_EXEC", "EXEC_SSH", "RUN_SSH", "SSH_COMMAND"):
p_name = attrs.get("project") or attrs.get("name") or current_project_name
proj = session.projects.get(p_name) if p_name else session_manager.get_current_project(chat_id)
if not proj:
return f"\n⚠️ پروژه <code>{escape_html(p_name or '')}</code> یافت نشد." if is_fa else f"\n⚠️ Project <code>{escape_html(p_name or '')}</code> not found.", side_effects, created_task
if not proj.ssh_host:
return "\n⚠️ مشخصات سرور SSH برای این پروژه یافت نشد." if is_fa else "\n⚠️ SSH configuration not found for this project.", side_effects, created_task
cmd = attrs.get("command") or attrs.get("cmd") or attrs.get("_default") or ""
if not cmd.strip():
return "\n⚠️ دستور اجرایی برای سرور SSH تعیین نشده است." if is_fa else "\n⚠️ SSH command is required.", side_effects, created_task
from ssh_manager import ssh_manager
ok, out, code = await ssh_manager.execute_command(
host=proj.ssh_host,
port=proj.ssh_port,
user=proj.ssh_user or "",
password=proj.ssh_password,
key=proj.ssh_key,
remote_path=proj.ssh_path or "/",
command=cmd,
user_id=chat_id,
project_name=proj.name,
requester="ai_agent",
)
st_icon = "" if ok else ""
badge = (
f"\n\n{st_icon} <b>اجرای دستور روی سرور SSH پروژه <code>{escape_html(proj.name)}</code>:</b>\n"
f"• 💻 <b>دستور:</b> <code>{escape_html(cmd)}</code>\n"
f"• 🔢 <b>کد خروج:</b> <code>{code}</code>\n"
f"• 📜 <b>خروجی سرور:</b>\n<pre>{escape_html(out)}</pre>"
if is_fa else
f"\n\n{st_icon} <b>SSH Command Execution on <code>{escape_html(proj.name)}</code>:</b>\n"
f"• 💻 <b>Command:</b> <code>{escape_html(cmd)}</code>\n"
f"• 🔢 <b>Exit Code:</b> <code>{code}</code>\n"
f"• 📜 <b>Output:</b>\n<pre>{escape_html(out)}</pre>"
)
return badge, side_effects, created_task
# -------------------------------------------------------------
# 40. SSH_LOGS / REMOTE_LOGS / AUDIT_LOGS
# -------------------------------------------------------------
elif act in ("SSH_LOGS", "REMOTE_LOGS", "AUDIT_LOGS", "SSH_AUDIT"):
p_name = attrs.get("project") or attrs.get("name") or current_project_name
proj = session.projects.get(p_name) if p_name else session_manager.get_current_project(chat_id)
proj_name = proj.name if proj else (p_name or "default")
from remote_audit import remote_audit
logs = remote_audit.get_recent_logs(user_id=chat_id, project_name=proj_name, limit=10)
badge = "\n\n" + remote_audit.format_logs_for_tg(logs, project_name=proj_name, is_fa=is_fa)
return badge, side_effects, created_task
# -------------------------------------------------------------
# 41. SAVE_MEMORY / STORE_MEMORY / SET_MEMORY
# -------------------------------------------------------------
elif act in ("SAVE_MEMORY", "STORE_MEMORY", "SET_MEMORY", "ADD_MEMORY"):
from memory_manager import memory_manager
+58
View File
@@ -659,4 +659,62 @@ def format_ftp_info(
)
def format_ssh_info(
project_name: str,
host: Optional[str],
port: int = 22,
user: Optional[str] = None,
path: str = "/",
password: Optional[str] = None,
has_key: bool = False,
lang: str = "fa",
) -> str:
"""Formats SSH connection and remote development status for Telegram HTML."""
is_fa = (lang or "").lower() in ("fa", "farsi", "persian", "🇮🇷 persian / farsi (فارسی)")
clean_p = escape_html(project_name)
host_str = f"<code>{escape_html(host)}:{port}</code>" if host else ("<i>(تنظیم نشده)</i>" if is_fa else "<i>(Not configured)</i>")
user_str = f"<code>{escape_html(user)}</code>" if user else ("<i>(ندارد)</i>" if is_fa else "<i>(None)</i>")
path_str = f"<code>{escape_html(path)}</code>"
auth_methods = []
if password:
auth_methods.append("رمز عبور 🔑" if is_fa else "Password 🔑")
if has_key:
auth_methods.append("کلید اختصاصی SSH 🗝️" if is_fa else "SSH Private Key 🗝️")
if not auth_methods:
auth_str = "<i>(تعیین نشده)</i>" if is_fa else "<i>(Not set)</i>"
else:
auth_str = " + ".join(auth_methods)
if is_fa:
return (
f"💻 <b>تنظیمات ریموت دولوپ و سرور SSH پروژه</b>\n\n"
f"• 📁 <b>پروژه:</b> <code>{clean_p}</code>\n"
f"• 🌐 <b>سرور / هاست:</b> {host_str}\n"
f"• 👤 <b>نام کاربری:</b> {user_str}\n"
f"• 🔐 <b>نوع احراز هویت:</b> {auth_str}\n"
f"• 📂 <b>مسیر مقصد روی سرور:</b> {path_str}\n\n"
f"🛡️ <b>اصول ایمنی پروداکشن:</b>\n"
f"├ پوش کدهای محلی منحصراً از شاخه <code>production</code> انجام می‌شود.\n"
f"├ فایلهای دیتابیس (SQLite)، آپلودهای کاربران و فایل‌های سشن سرور ریموت دست‌نخورده باقی می‌مانند.\n"
f"└ تمام درخواست‌ها و لاگ‌ها به صورت خودکار در دیتابیس ثبت می‌شوند.\n\n"
f"💡 <i>از دکمه‌های زیر برای تست اتصال، ارسال کدها (Push)، دریافت کدها (Pull) یا مدیریت مشخصات استفاده کنید.</i>"
)
else:
return (
f"💻 <b>Project SSH Remote Dev & Server Settings</b>\n\n"
f"• 📁 <b>Project:</b> <code>{clean_p}</code>\n"
f"• 🌐 <b>SSH Host:</b> {host_str}\n"
f"• 👤 <b>Username:</b> {user_str}\n"
f"• 🔐 <b>Authentication:</b> {auth_str}\n"
f"• 📂 <b>Remote Path:</b> {path_str}\n\n"
f"🛡️ <b>Production Safety:</b>\n"
f"├ Pushes strictly deploy from <code>production</code> branch.\n"
f"├ Remote user uploads, SQLite databases, and session files are preserved.\n"
f"└ All operations are audited in SQLite.\n\n"
f"💡 <i>Use buttons below to test connection, push, pull, or manage server config.</i>"
)
+210
View File
@@ -0,0 +1,210 @@
import os
import sys
import time
import sqlite3
import logging
from pathlib import Path
from typing import Optional, Dict, Any, List, Tuple
from datetime import datetime
logger = logging.getLogger("AGYRemoteAudit")
DATA_DIR = Path("/root/telegram-agy-bot/data")
DB_PATH = DATA_DIR / "memory.db"
class RemoteAuditLogger:
"""Manages audit logging for SSH, FTP, and remote operations into SQLite."""
def __init__(self, db_path: Path = DB_PATH):
self.db_path = db_path
self._init_db()
def _get_connection(self) -> sqlite3.Connection:
self.db_path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(self.db_path), timeout=15.0)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL;")
conn.execute("PRAGMA synchronous=NORMAL;")
return conn
def _init_db(self):
"""Initializes remote_audit_logs table and indexes."""
try:
with self._get_connection() as conn:
conn.execute("""
CREATE TABLE IF NOT EXISTS remote_audit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp REAL NOT NULL,
created_at TEXT NOT NULL,
user_id INTEGER NOT NULL,
project_name TEXT NOT NULL,
service_type TEXT NOT NULL, -- 'ssh', 'ftp'
action_type TEXT NOT NULL, -- 'push', 'pull', 'exec', 'test_connection', 'update_config'
requester TEXT NOT NULL, -- 'ai_agent', 'user_button', 'user_command'
details TEXT, -- command or parameters (never contains passwords)
status TEXT NOT NULL, -- 'success', 'failed', 'running'
result_summary TEXT, -- short summary or error message
duration_ms REAL DEFAULT 0.0
);
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_remote_audit_lookup
ON remote_audit_logs(user_id, project_name, service_type, timestamp DESC);
""")
conn.commit()
except Exception as e:
logger.error(f"Error initializing remote_audit_logs table: {e}")
def log_operation(
self,
user_id: int,
project_name: str,
service_type: str,
action_type: str,
requester: str,
status: str,
details: Optional[str] = None,
result_summary: Optional[str] = None,
duration_ms: float = 0.0,
timestamp: Optional[float] = None,
) -> int:
"""Logs a remote operation into the audit database."""
now_ts = timestamp or time.time()
dt_str = datetime.fromtimestamp(now_ts).strftime("%Y-%m-%d %H:%M:%S")
# Sanitize details (ensure no raw passwords accidentally entered)
safe_details = details or ""
if "password" in safe_details.lower():
safe_details = "[FILTERED DETAILS]"
try:
with self._get_connection() as conn:
cur = conn.execute(
"""
INSERT INTO remote_audit_logs (
timestamp, created_at, user_id, project_name,
service_type, action_type, requester, details,
status, result_summary, duration_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
now_ts,
dt_str,
int(user_id),
str(project_name),
str(service_type).lower(),
str(action_type).lower(),
str(requester).lower(),
safe_details[:1000] if safe_details else None,
str(status).lower(),
str(result_summary)[:1000] if result_summary else None,
float(duration_ms),
),
)
conn.commit()
return cur.lastrowid
except Exception as e:
logger.error(f"Error writing to remote_audit_logs: {e}")
return 0
def get_recent_logs(
self,
user_id: Optional[int] = None,
project_name: Optional[str] = None,
service_type: Optional[str] = None,
limit: int = 15,
) -> List[Dict[str, Any]]:
"""Retrieves recent audit log records."""
try:
with self._get_connection() as conn:
query = "SELECT * FROM remote_audit_logs WHERE 1=1"
params: List[Any] = []
if user_id is not None:
query += " AND user_id = ?"
params.append(user_id)
if project_name:
query += " AND project_name = ?"
params.append(project_name)
if service_type:
query += " AND service_type = ?"
params.append(service_type)
query += " ORDER BY timestamp DESC LIMIT ?"
params.append(int(limit))
cur = conn.execute(query, params)
rows = cur.fetchall()
return [dict(r) for r in rows]
except Exception as e:
logger.error(f"Error fetching remote_audit_logs: {e}")
return []
def format_logs_for_tg(
self,
logs: List[Dict[str, Any]],
project_name: str,
is_fa: bool = True,
) -> str:
"""Formats audit logs into a clean Telegram HTML message."""
if not logs:
if is_fa:
return (
f"📜 <b>تاریخچه و لاگ عملیات ریموت</b>\n\n"
f"• 📁 <b>پروژه:</b> <code>{project_name}</code>\n\n"
f"️ <i>هنوز هیچ عملیات SSH یا FTP برای این پروژه ثبت نشده است.</i>"
)
else:
return (
f"📜 <b>Remote Operations Audit Log</b>\n\n"
f"• 📁 <b>Project:</b> <code>{project_name}</code>\n\n"
f"️ <i>No SSH or FTP operations logged for this project yet.</i>"
)
lines = []
if is_fa:
lines.append(f"📜 <b>تاریخچه عملیات ریموت و SSH/FTP</b>")
lines.append(f"• 📁 <b>پروژه:</b> <code>{project_name}</code>")
lines.append(f"• 📊 <b>تعداد لاگ‌های اخیر:</b> <code>{len(logs)}</code>\n")
else:
lines.append(f"📜 <b>Remote Operations & SSH/FTP Audit Log</b>")
lines.append(f"• 📁 <b>Project:</b> <code>{project_name}</code>")
lines.append(f"• 📊 <b>Recent records:</b> <code>{len(logs)}</code>\n")
for idx, log in enumerate(logs, 1):
st = log.get("status", "unknown")
st_icon = "" if st == "success" else ("" if st == "failed" else "")
srv = log.get("service_type", "").upper()
act = log.get("action_type", "")
req = log.get("requester", "")
req_label = "🤖 AI" if "ai" in req else ("👤 کاربر" if is_fa else "👤 User")
dt = log.get("created_at", "")
dur = log.get("duration_ms", 0.0)
dur_str = f" ({dur:.1f}ms)" if dur > 0 else ""
res = log.get("result_summary") or ""
det = log.get("details") or ""
act_fa = {
"push": "پوش به پروداکشن 🚀",
"pull": "دریافت از سرور 📥",
"exec": "اجرای فرمان 💻",
"test_connection": "تست اتصال 🔍",
"update_config": "ویرایش تنظیمات ⚙️",
}.get(act, act)
act_display = act_fa if is_fa else act.capitalize()
item_text = f"<b>{idx}. {st_icon} [{srv}] {act_display}</b> | {req_label}\n"
item_text += f" 🕒 <code>{dt}</code>{dur_str}\n"
if det:
item_text += f" 📝 <code>{det[:60]}</code>\n"
if res:
item_text += f" 💬 <i>{res[:100]}</i>\n"
lines.append(item_text)
return "\n".join(lines)
# Global singleton instance
remote_audit = RemoteAuditLogger()
File diff suppressed because one or more lines are too long
+719
View File
@@ -0,0 +1,719 @@
import os
import sys
import time
import fnmatch
import logging
import asyncio
from pathlib import Path
from typing import Optional, Dict, Any, List, Tuple, Set
import socket
import subprocess
import tempfile
import shutil
import io
import paramiko
from remote_audit import remote_audit
logger = logging.getLogger("AGYSSHManager")
# Standard patterns for junk, temporary, local configs, and production user data that must NEVER be overwritten or pushed
DEFAULT_SSH_EXCLUDES = [
# Git and version control
".git",
".git/*",
".gitignore",
".gitattributes",
".gitmodules",
# Environment & local secret files
".env",
".env.*",
"*.env",
"*.pem",
"*.key",
# User databases and data stores (never overwrite production sqlite / db files!)
"*.db",
"*.db-shm",
"*.db-wal",
"*.sqlite",
"*.sqlite3",
"data/*.db",
"data/*.sqlite",
# Dynamic uploads & media directories (production user uploads must never be touched)
"uploads",
"uploads/*",
"*/uploads",
"*/uploads/*",
"media",
"media/*",
"storage/uploads",
"storage/uploads/*",
"storage/framework",
"storage/framework/*",
# AI & Bot internal session / agent configs
".agents",
".agents/*",
".gemini",
".gemini/*",
"sessions_data",
"sessions_data/*",
"uploads_temp",
"uploads_temp/*",
# Python cache & virtual environments
"__pycache__",
"__pycache__/*",
"*.pyc",
"*.pyo",
"*.pyd",
".venv",
".venv/*",
"venv",
"venv/*",
"env",
"env/*",
# Node.js dependencies
"node_modules",
"node_modules/*",
# Logs & temporary files
"*.log",
"logs",
"logs/*",
"tmp",
"tmp/*",
"temp",
"temp/*",
# IDE & OS files
".DS_Store",
"Thumbs.db",
"desktop.ini",
".idea",
".idea/*",
".vscode",
".vscode/*",
"*.swp",
"*.swo",
"*~",
]
def should_exclude_ssh(rel_path: str, custom_excludes: Optional[List[str]] = None) -> bool:
"""Checks if a relative path matches any exclusion pattern for SSH sync."""
excludes = DEFAULT_SSH_EXCLUDES + (custom_excludes or [])
norm_path = rel_path.replace("\\", "/").strip("/")
parts = [p for p in norm_path.split("/") if p]
for pattern in excludes:
pat = pattern.replace("\\", "/").strip("/")
clean_pat = pat.rstrip("/*")
if fnmatch.fnmatch(norm_path, pat) or fnmatch.fnmatch(norm_path, clean_pat):
return True
if norm_path == clean_pat or norm_path.startswith(clean_pat + "/"):
return True
for part in parts:
if fnmatch.fnmatch(part, pat) or fnmatch.fnmatch(part, clean_pat):
return True
return False
class SSHManager:
"""Manages SSH connections, remote dev synchronization, execution, and audit logging."""
def _create_ssh_client(
self,
host: str,
port: int = 22,
user: str = "",
password: Optional[str] = None,
key_content_or_path: Optional[str] = None,
timeout: int = 15,
) -> paramiko.SSHClient:
"""Helper to create and authenticate a paramiko SSHClient."""
clean_host = host.strip()
clean_user = user.strip() if user else "root"
clean_pass = password.strip() if password else None
clean_port = int(port) if port else 22
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
pkey = None
if key_content_or_path and key_content_or_path.strip():
raw_k = key_content_or_path.strip()
# Check if key is a file path
if os.path.isfile(raw_k):
try:
pkey = paramiko.RSAKey.from_private_key_file(raw_k, password=clean_pass)
except Exception:
try:
pkey = paramiko.Ed25519Key.from_private_key_file(raw_k, password=clean_pass)
except Exception:
try:
pkey = paramiko.ECDSAKey.from_private_key_file(raw_k, password=clean_pass)
except Exception as e:
logger.warning(f"Could not load private key from file {raw_k}: {e}")
else:
# Key is provided as string text
key_file_obj = io.StringIO(raw_k)
for key_cls in (paramiko.RSAKey, paramiko.Ed25519Key, paramiko.ECDSAKey, paramiko.DSSKey):
try:
key_file_obj.seek(0)
pkey = key_cls.from_private_key(key_file_obj, password=clean_pass)
if pkey:
break
except Exception:
continue
connect_kwargs: Dict[str, Any] = {
"hostname": clean_host,
"port": clean_port,
"username": clean_user,
"timeout": timeout,
"banner_timeout": timeout,
"auth_timeout": timeout,
"allow_agent": False,
"look_for_keys": False,
}
if pkey:
connect_kwargs["pkey"] = pkey
elif clean_pass:
connect_kwargs["password"] = clean_pass
else:
# Try agent / default keys if neither provided
connect_kwargs["allow_agent"] = True
connect_kwargs["look_for_keys"] = True
client.connect(**connect_kwargs)
return client
async def test_connection(
self,
host: str,
port: int = 22,
user: str = "",
password: Optional[str] = None,
key: Optional[str] = None,
path: str = "/",
timeout: int = 12,
user_id: int = 0,
project_name: str = "",
requester: str = "user_button",
) -> Tuple[bool, str]:
"""Tests SSH connection and checks remote path accessibility."""
start_t = time.time()
if not host or not host.strip():
return False, "آدرس هاست (Host) سرور SSH مشخص نشده است."
def _test():
client = None
try:
client = self._create_ssh_client(
host=host.strip(),
port=port,
user=user.strip(),
password=password,
key_content_or_path=key,
timeout=timeout,
)
# Run simple probe command
stdin, stdout, stderr = client.exec_command("uname -sr || echo Linux", timeout=timeout)
os_info = stdout.read().decode("utf-8", errors="ignore").strip()
# Test SFTP session and target path
sftp = client.open_sftp()
target_path = (path or "/").strip()
path_status = "موجود است"
item_count = 0
try:
sftp.stat(target_path)
try:
dir_items = sftp.listdir(target_path)
item_count = len(dir_items)
except Exception:
item_count = 0
except IOError:
path_status = "موجود نیست (در اولین استقرار به صورت خودکار ایجاد می‌شود)"
sftp.close()
client.close()
msg = (
f"اتصال SSH با موفقیت برقرار شد ✅\n"
f"• 🖥️ <b>سیستم‌عامل:</b> <code>{os_info}</code>\n"
f"• 📂 <b>مسیر ریموت:</b> <code>{target_path}</code> ({path_status})\n"
f"• 📊 <b>تعداد آیتم‌ها در مسیر:</b> <code>{item_count}</code>"
)
return True, msg
except socket.gaierror as e:
return False, f"نام دامنه یا IP سرور یافت نشد ({host}): {e}"
except (socket.timeout, TimeoutError) as e:
return False, f"مهلت زمانی اتصال SSH به پایان رسید (Timeout روی پورت {port}): {e}"
except ConnectionRefusedError as e:
return False, f"اتصال SSH توسط سرور رد شد (پورت {port} مسدود است یا SSH اجرا نمی‌شود)."
except paramiko.AuthenticationException as e:
return False, f"خطای احراز هویت SSH: نام کاربری، رمز عبور یا کلید نامعتبر است ({e})."
except Exception as e:
return False, f"خطا در برقراری ارتباط SSH: {e}"
finally:
if client:
try:
client.close()
except Exception:
pass
success, result_msg = await asyncio.to_thread(_test)
dur = (time.time() - start_t) * 1000
# Record audit log
remote_audit.log_operation(
user_id=user_id,
project_name=project_name or "unknown",
service_type="ssh",
action_type="test_connection",
requester=requester,
status="success" if success else "failed",
details=f"Host: {host}:{port}, Path: {path}",
result_summary=result_msg[:200],
duration_ms=dur,
)
return success, result_msg
async def push_project(
self,
workspace_path: str,
host: str,
port: int = 22,
user: str = "",
password: Optional[str] = None,
key: Optional[str] = None,
remote_path: str = "/",
branch: str = "production",
custom_excludes: Optional[List[str]] = None,
user_id: int = 0,
project_name: str = "",
requester: str = "user_button",
) -> Dict[str, Any]:
"""
Pushes files from the git branch (strictly 'production') to remote server via SFTP.
Ensures existing production user data, SQLite databases, and dynamic uploads are NEVER modified.
"""
start_t = time.time()
ws = Path(workspace_path).expanduser().resolve()
if not ws.exists() or not ws.is_dir():
err = f"دایرکتوری محلی پروژه در مسیر {workspace_path} یافت نشد."
remote_audit.log_operation(
user_id=user_id,
project_name=project_name,
service_type="ssh",
action_type="push",
requester=requester,
status="failed",
details=f"Push branch={branch} to {host}:{port}{remote_path}",
result_summary=err,
)
return {
"success": False,
"error": err,
"files_uploaded": 0,
"files_skipped": 0,
"bytes_transferred": 0,
"duration": 0.0,
}
def _run_push():
client = None
temp_export_dir = None
uploaded_files = []
skipped_files = []
total_bytes = 0
try:
# 1. Extract strictly from production git branch
source_dir = ws
if (ws / ".git").exists():
try:
branches_proc = subprocess.run(
["git", "branch", "--list", branch],
cwd=str(ws),
capture_output=True,
text=True,
)
target_branch = branch if branch in branches_proc.stdout else "HEAD"
temp_export_dir = Path(tempfile.mkdtemp(prefix="ssh_push_prod_"))
archive_proc = subprocess.Popen(
["git", "archive", target_branch],
cwd=str(ws),
stdout=subprocess.PIPE,
)
tar_proc = subprocess.Popen(
["tar", "-x", "-C", str(temp_export_dir)],
stdin=archive_proc.stdout,
)
archive_proc.stdout.close()
tar_proc.communicate()
if tar_proc.returncode == 0:
source_dir = temp_export_dir
except Exception as ge:
logger.warning(f"Git archive export failed for {branch} (using ws): {ge}")
source_dir = ws
client = self._create_ssh_client(
host=host.strip(),
port=port,
user=user.strip(),
password=password,
key_content_or_path=key,
timeout=25,
)
sftp = client.open_sftp()
# Prepare remote path
target_root = (remote_path or "/").strip().replace("\\", "/")
if not target_root.startswith("/"):
target_root = "/" + target_root
target_root = target_root.rstrip("/")
if not target_root:
target_root = "/"
def ensure_remote_dir(r_dir: str):
if r_dir in ("/", ""):
return
parts = [p for p in r_dir.split("/") if p]
curr = ""
for p in parts:
curr += "/" + p
try:
sftp.stat(curr)
except IOError:
try:
sftp.mkdir(curr)
except Exception:
pass
ensure_remote_dir(target_root)
# Scan and filter local files
for root, dirs, files in os.walk(str(source_dir)):
rel_dir = os.path.relpath(root, str(source_dir))
if rel_dir == ".":
rel_dir = ""
dirs_to_keep = []
for d in dirs:
dir_rel = f"{rel_dir}/{d}".strip("/")
if should_exclude_ssh(dir_rel, custom_excludes):
skipped_files.append(dir_rel + "/")
else:
dirs_to_keep.append(d)
dirs[:] = dirs_to_keep
for f in files:
file_rel = f"{rel_dir}/{f}".strip("/")
if should_exclude_ssh(file_rel, custom_excludes):
skipped_files.append(file_rel)
else:
local_file = Path(root) / f
remote_file_path = f"{target_root}/{file_rel}".replace("//", "/")
remote_file_dir = os.path.dirname(remote_file_path)
ensure_remote_dir(remote_file_dir)
sftp.put(str(local_file), remote_file_path)
try:
f_size = local_file.stat().st_size
total_bytes += f_size
except Exception:
pass
uploaded_files.append(file_rel)
sftp.close()
client.close()
dur = time.time() - start_t
return {
"success": True,
"files_uploaded": len(uploaded_files),
"files_skipped": len(skipped_files),
"bytes_transferred": total_bytes,
"duration": dur,
"uploaded_list": uploaded_files,
}
except Exception as e:
logger.error(f"SSH push failed: {e}", exc_info=True)
dur = time.time() - start_t
return {
"success": False,
"error": f"خطا در ارسال فایل‌ها به سرور SSH: {e}",
"files_uploaded": len(uploaded_files),
"files_skipped": len(skipped_files),
"bytes_transferred": total_bytes,
"duration": dur,
}
finally:
if client:
try:
client.close()
except Exception:
pass
if temp_export_dir and os.path.exists(temp_export_dir):
shutil.rmtree(temp_export_dir, ignore_errors=True)
res = await asyncio.to_thread(_run_push)
# Audit log record
status_str = "success" if res.get("success") else "failed"
summary_str = f"Uploaded {res.get('files_uploaded', 0)} files ({res.get('bytes_transferred', 0)} bytes)" if res.get("success") else str(res.get("error", "Error"))
remote_audit.log_operation(
user_id=user_id,
project_name=project_name,
service_type="ssh",
action_type="push",
requester=requester,
status=status_str,
details=f"Push to {host}:{port}{remote_path} (Branch: {branch})",
result_summary=summary_str[:200],
duration_ms=res.get("duration", 0.0) * 1000,
)
return res
async def pull_project(
self,
workspace_path: str,
host: str,
port: int = 22,
user: str = "",
password: Optional[str] = None,
key: Optional[str] = None,
remote_path: str = "/",
branch: str = "production",
custom_excludes: Optional[List[str]] = None,
user_id: int = 0,
project_name: str = "",
requester: str = "user_button",
) -> Dict[str, Any]:
"""
Pulls files from the remote SSH server into local workspace / production branch.
Skips remote database and user upload files according to exclusion rules.
"""
start_t = time.time()
ws = Path(workspace_path).expanduser().resolve()
ws.mkdir(parents=True, exist_ok=True)
def _run_pull():
client = None
downloaded_files = []
skipped_files = []
total_bytes = 0
try:
client = self._create_ssh_client(
host=host.strip(),
port=port,
user=user.strip(),
password=password,
key_content_or_path=key,
timeout=25,
)
sftp = client.open_sftp()
target_root = (remote_path or "/").strip().replace("\\", "/").rstrip("/")
if not target_root:
target_root = "/"
def recursive_download(r_dir: str, rel_dir: str = ""):
nonlocal total_bytes
try:
entries = sftp.listdir_attr(r_dir)
except IOError as e:
logger.warning(f"Cannot list remote dir {r_dir}: {e}")
return
for entry in entries:
fname = entry.filename
if fname in (".", ".."):
continue
cur_rel = f"{rel_dir}/{fname}".strip("/")
cur_remote = f"{r_dir}/{fname}".replace("//", "/")
# Check if directory
import stat
is_dir = stat.S_ISDIR(entry.st_mode)
if should_exclude_ssh(cur_rel if not is_dir else cur_rel + "/", custom_excludes):
skipped_files.append(cur_rel + ("/" if is_dir else ""))
continue
if is_dir:
local_sub = ws / cur_rel
local_sub.mkdir(parents=True, exist_ok=True)
recursive_download(cur_remote, cur_rel)
else:
local_dest = ws / cur_rel
local_dest.parent.mkdir(parents=True, exist_ok=True)
sftp.get(cur_remote, str(local_dest))
total_bytes += entry.st_size
downloaded_files.append(cur_rel)
recursive_download(target_root)
sftp.close()
client.close()
dur = time.time() - start_t
return {
"success": True,
"files_downloaded": len(downloaded_files),
"files_skipped": len(skipped_files),
"bytes_transferred": total_bytes,
"duration": dur,
"downloaded_list": downloaded_files,
}
except Exception as e:
logger.error(f"SSH pull failed: {e}", exc_info=True)
dur = time.time() - start_t
return {
"success": False,
"error": f"خطا در دریافت فایل‌ها از سرور SSH: {e}",
"files_downloaded": len(downloaded_files),
"files_skipped": len(skipped_files),
"bytes_transferred": total_bytes,
"duration": dur,
}
finally:
if client:
try:
client.close()
except Exception:
pass
res = await asyncio.to_thread(_run_pull)
# Audit log record
status_str = "success" if res.get("success") else "failed"
summary_str = f"Downloaded {res.get('files_downloaded', 0)} files ({res.get('bytes_transferred', 0)} bytes)" if res.get("success") else str(res.get("error", "Error"))
remote_audit.log_operation(
user_id=user_id,
project_name=project_name,
service_type="ssh",
action_type="pull",
requester=requester,
status=status_str,
details=f"Pull from {host}:{port}{remote_path} to {workspace_path}",
result_summary=summary_str[:200],
duration_ms=res.get("duration", 0.0) * 1000,
)
return res
async def execute_command(
self,
host: str,
port: int = 22,
user: str = "",
password: Optional[str] = None,
key: Optional[str] = None,
remote_path: str = "/",
command: str = "",
timeout: int = 60,
user_id: int = 0,
project_name: str = "",
requester: str = "user_button",
) -> Tuple[bool, str, int]:
"""
Executes a bash command remotely inside the specified remote_path directory.
"""
start_t = time.time()
if not command or not command.strip():
return False, "دستور اجرایی خالی است.", -1
def _run_exec():
client = None
try:
client = self._create_ssh_client(
host=host.strip(),
port=port,
user=user.strip(),
password=password,
key_content_or_path=key,
timeout=timeout,
)
cd_prefix = f"cd {remote_path} && " if remote_path and remote_path != "/" else ""
full_cmd = f"bash -c {subprocess.list2cmdline([cd_prefix + command])}" if cd_prefix else command
stdin, stdout, stderr = client.exec_command(full_cmd, timeout=timeout)
out = stdout.read().decode("utf-8", errors="ignore")
err = stderr.read().decode("utf-8", errors="ignore")
exit_code = stdout.channel.recv_exit_status()
client.close()
combined = ""
if out:
combined += out
if err:
if combined:
combined += "\n"
combined += f"[STDERR]\n{err}"
if not combined:
combined = f"(دستور با کد {exit_code} بدون خروجی اجرا شد)"
# Limit output size to prevent overflow
if len(combined) > 3500:
combined = combined[:3500] + "\n... (خروجی طولانی کوتاه شد)"
return (exit_code == 0), combined, exit_code
except Exception as e:
logger.error(f"SSH command execution failed: {e}")
return False, f"خطا در اجرای فرمان SSH: {e}", -1
finally:
if client:
try:
client.close()
except Exception:
pass
success, output, code = await asyncio.to_thread(_run_exec)
dur = (time.time() - start_t) * 1000
# Audit log record
remote_audit.log_operation(
user_id=user_id,
project_name=project_name,
service_type="ssh",
action_type="exec",
requester=requester,
status="success" if success else "failed",
details=f"cmd: {command[:200]}",
result_summary=f"exit_code={code}, output={output[:100]}",
duration_ms=dur,
)
return success, output, code
# Global singleton instance
ssh_manager = SSHManager()