├── .coveragerc ├── .deepsource.toml ├── .flake8 ├── .github ├── CHANGELOG.md ├── FUNDING.yml ├── codeql.yml └── workflows │ ├── codeql.yml │ ├── python-publish.yml │ └── test.yml ├── .gitignore ├── .lgtm.yml ├── .pre-commit-config.yaml ├── LICENSE ├── MANIFEST.in ├── README.md ├── docs ├── Makefile ├── make.bat └── source │ ├── Configuration.md │ ├── Examples.md │ ├── Quickstart.md │ ├── api.rst │ ├── conf.py │ ├── index.rst │ └── requirements.txt ├── examples ├── __init__.py ├── basic.py ├── custom_save_fn.py ├── deletion.py ├── multiple_files.py ├── run_script.py ├── with_callback.py ├── with_decorators.py └── with_signals.py ├── flask_shell2http ├── __init__.py ├── api.py ├── base_entrypoint.py ├── classes.py ├── exceptions.py └── helpers.py ├── post-request-schema.json ├── requirements.dev.txt ├── requirements.txt ├── setup.py ├── tests ├── __init__.py ├── _utils.py ├── test_basic.py ├── test_callback_signal.py ├── test_decorators.py ├── test_deletion.py └── test_multiple_files.py ├── tox.ini └── version.txt /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | parallel = True 4 | source = 5 | flask_shell2http 6 | omit = 7 | examples/* 8 | 9 | [report] 10 | exclude_lines = 11 | if self.debug: 12 | pragma: no cover 13 | raise NotImplementedError 14 | if __name__ == .__main__.: 15 | ignore_errors = True 16 | omit = 17 | examples/* 18 | -------------------------------------------------------------------------------- /.deepsource.toml: -------------------------------------------------------------------------------- 1 | version = 1 2 | 3 | test_patterns = [ 4 | "tests/**", 5 | "test_*.py" 6 | ] 7 | 8 | [[analyzers]] 9 | name = "secrets" 10 | enabled = true 11 | 12 | [[analyzers]] 13 | name = "test-coverage" 14 | enabled = true 15 | 16 | [[analyzers]] 17 | name = "python" 18 | enabled = true 19 | 20 | [analyzers.meta] 21 | runtime_version = "3.x.x" 22 | 23 | [[transformers]] 24 | name = "black" 25 | enabled = true 26 | 27 | [[transformers]] 28 | name = "isort" 29 | enabled = true 30 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 88 3 | ignore = 4 | W503, # line break before binary operator 5 | E231, # missing whitespace after ',' 6 | exclude = 7 | Dockerfile, 8 | venv, 9 | virtualenv, 10 | .tox*, 11 | isort*, -------------------------------------------------------------------------------- /.github/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | **[Get it on PyPi](https://pypi.org/project/Flask-Shell2HTTP/)** 4 | 5 | ## [v1.9.1](https://github.com/eshaan7/Flask-Shell2HTTP/releases/tag/v1.9.1) 6 | 7 | Added support for Flask version >=2.1.0 . 8 | 9 | - Fix import errors. [[Issue 42](https://github.com/eshaan7/Flask-Shell2HTTP/issues/42)]. 10 | - Fix dependency conflicts in `requirements.dev.txt` file. 11 | 12 | ## [v1.9.0](https://github.com/eshaan7/Flask-Shell2HTTP/releases/tag/v1.9.0) 13 | 14 | - It's now possible to request deletion/cancellation of jobs. [[Issue 38](https://github.com/eshaan7/Flask-Shell2HTTP/issues/38)]. 15 | 16 | Say for example, you register a command with the endpoint `/myendpoint` then the different HTTP operations supported are: 17 | 18 | - `POST /myendpoint` to create a job and get unique key (same as earlier) 19 | - `GET /myendpoint?key={key}` to request result of a job (same as earlier) 20 | - `DELETE /myendpoint?key={key}` to request cancellation/deletion of a job. (new) 21 | 22 | ## [v1.8.0](https://github.com/eshaan7/Flask-Shell2HTTP/releases/tag/v1.8.0) 23 | 24 | - Allow `&wait=[false|true]` query parameter in `GET` request. Use `wait=true` when you don't wish to HTTP poll and want the result in a single request only. 25 | 26 | ## [v1.7.0](https://github.com/eshaan7/Flask-Shell2HTTP/releases/tag/v1.7.0) 27 | 28 | **For you:** 29 | 30 | - Deps: Support for both Flask version 1.x and 2.x. 31 | - Feature: The `key` and `result_url` attributes are returned in response even if error is raised (if and when applicable) (See [#25](https://github.com/eshaan7/Flask-Shell2HTTP/issues/25)). 32 | - Docs: Add info about `force_unique_key` option to quickstart guide. 33 | 34 | **Internal:** 35 | 36 | - Much better and improved test cases via tox matrix for both major flask versions, 1.x and 2.x. 37 | - Much better overall type hinting. 38 | 39 | ## [v1.6.0](https://github.com/eshaan7/Flask-Shell2HTTP/releases/tag/v1.6.0) 40 | 41 | Added support for a new parameter `force_unique_key` in the POST request. 42 | 43 | ```json 44 | "force_unique_key": { 45 | "type": "boolean", 46 | "title": "Flag to enable/disable internal rate limiting mechanism", 47 | "description": "By default, the key is the SHA1 sum of the command + args POSTed to the API. This is done as a rate limiting measure so as to prevent multiple jobs with same parameters, if one such job is already running. If force_unique_key is set to true, the API will bypass this default behaviour and a psuedorandom key will be returned instead", 48 | "default": false 49 | } 50 | ``` 51 | 52 | See [post-request-options configuration](https://flask-shell2http.readthedocs.io/en/latest/Configuration.html#post-request-options) in docs for more info. 53 | 54 | _For prior versions, see directly [here](https://github.com/eshaan7/Flask-Shell2HTTP/releases)._ 55 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | custom: ["https://xscode.com/eshaan7/flask-shell2http"] 2 | -------------------------------------------------------------------------------- /.github/codeql.yml: -------------------------------------------------------------------------------- 1 | query-filters: 2 | - exclude: 3 | id: py/similar-function 4 | - exclude: 5 | id: py/empty-except 6 | - exclude: 7 | id: py/call-to-non-callable 8 | - include: 9 | id: py/undefined-placeholder-variable 10 | - include: 11 | id: py/uninitialized-local-variable 12 | - include: 13 | id: py/request-without-cert-validation 14 | - include: 15 | id: py/return-or-yield-outside-function 16 | - include: 17 | id: py/file-not-closed 18 | - include: 19 | id: py/exit-from-finally 20 | - include: 21 | id: py/ineffectual-statement 22 | - include: 23 | id: py/unused-global-variable 24 | - include: 25 | id: py/hardcoded-credentials 26 | - include: 27 | id: py/import-of-mutable-attribute 28 | - include: 29 | id: py/cyclic-import 30 | - include: 31 | id: py/unnecessary-lambda 32 | - include: 33 | id: py/print-during-import 34 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | pull_request: 7 | branches: [ "master" ] 8 | schedule: 9 | - cron: "26 5 * * 0" 10 | 11 | jobs: 12 | analyze: 13 | name: Analyze 14 | runs-on: ubuntu-latest 15 | permissions: 16 | actions: read 17 | contents: read 18 | security-events: write 19 | 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | language: [ python ] 24 | 25 | steps: 26 | - name: Checkout 27 | uses: actions/checkout@v3 28 | 29 | - name: Initialize CodeQL 30 | uses: github/codeql-action/init@v2 31 | with: 32 | languages: ${{ matrix.language }} 33 | config-file: ./.github/codeql.yml 34 | queries: +security-and-quality 35 | 36 | - name: Autobuild 37 | uses: github/codeql-action/autobuild@v2 38 | 39 | - name: Perform CodeQL Analysis 40 | uses: github/codeql-action/analyze@v2 41 | with: 42 | category: "/language:${{ matrix.language }}" 43 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflows will upload a Python Package using Twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | name: Upload Python Package 5 | 6 | on: 7 | release: 8 | types: [created] 9 | 10 | jobs: 11 | deploy: 12 | 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Set up Python 18 | uses: actions/setup-python@v2 19 | with: 20 | python-version: '3.x' 21 | - name: Install dependencies 22 | run: | 23 | python -m pip install --upgrade pip 24 | pip install setuptools wheel twine 25 | - name: Build and publish 26 | env: 27 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 28 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 29 | run: | 30 | python setup.py sdist bdist_wheel 31 | twine upload dist/* 32 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | 9 | jobs: 10 | test: 11 | env: 12 | DEEPSOURCE_DSN: ${{ secrets.DEEPSOURCE_DSN }} 13 | runs-on: ${{ matrix.os || 'ubuntu-latest' }} 14 | strategy: 15 | fail-fast: false 16 | max-parallel: 6 17 | matrix: 18 | python-version: ['3.7', '3.8', '3.9', '3.10', '3.11'] 19 | include: 20 | - python-version: 3.6 21 | os: ubuntu-20.04 22 | steps: 23 | - uses: actions/checkout@v2 24 | with: 25 | fetch-depth: 1 26 | ref: ${{ github.event.pull_request.head.sha }} 27 | 28 | - name: Set up Python ${{ matrix.python-version }} 29 | uses: actions/setup-python@v2 30 | with: 31 | python-version: ${{ matrix.python-version }} 32 | 33 | - name: Install dependencies 34 | run: | 35 | python -m pip install --upgrade pip 36 | pip install -r requirements.dev.txt 37 | 38 | - name: Run tox tests 39 | run: | 40 | # run tests with coverage 41 | tox 42 | 43 | - name: Report test-coverage to DeepSource 44 | if: ${{ matrix.python-version == '3.11' }} 45 | run: | 46 | # Install the CLI 47 | curl https://deepsource.io/cli | sh 48 | # Send the report to DeepSource 49 | ./bin/deepsource report --analyzer test-coverage --key python --value-file coverage.xml 50 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | venv/ 3 | virtualenv/ 4 | __pycache__/ 5 | app.py 6 | test.py 7 | build/ 8 | dist/ 9 | *.egg-info -------------------------------------------------------------------------------- /.lgtm.yml: -------------------------------------------------------------------------------- 1 | queries: 2 | - exclude: py/similar-function 3 | - exclude: py/empty-except 4 | - exclude: py/call-to-non-callable 5 | - include: py/undefined-placeholder-variable 6 | - include: py/uninitialized-local-variable 7 | - include: py/request-without-cert-validation 8 | - include: py/return-or-yield-outside-function 9 | - include: py/file-not-closed 10 | - include: py/exit-from-finally 11 | - include: py/ineffectual-statement 12 | - include: py/unused-global-variable 13 | - include: py/hardcoded-credentials 14 | - include: py/import-of-mutable-attribute 15 | - include: py/cyclic-import 16 | - include: py/unnecessary-lambda 17 | - include: py/print-during-import 18 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/psf/black 3 | rev: 20.8b1 4 | hooks: 5 | - id: black 6 | - repo: https://gitlab.com/pycqa/flake8 7 | rev: 3.8.4 8 | hooks: 9 | - id: flake8 -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2020, Eshaan Bansal 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include version.txt 3 | include requirements.txt 4 | include requirements.dev.txt 5 | include docs/source/requirements.txt 6 | include LICENSE -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Flask-Shell2HTTP 2 | 3 | [![flask-shell2http on pypi](https://img.shields.io/pypi/v/flask-shell2http)](https://pypi.org/project/Flask-Shell2HTTP/) 4 | [![Build Status](https://github.com/Eshaan7/flask-shell2http/workflows/Linter%20&%20Tests/badge.svg?branch=master)](https://github.com/Eshaan7/flask-shell2http/actions?query=workflow%3A%22Linter+%26+Tests%22) 5 | [![codecov](https://codecov.io/gh/Eshaan7/Flask-Shell2HTTP/branch/master/graph/badge.svg?token=UQ43PYQPMR)](https://codecov.io/gh/Eshaan7/flask-shell2http/) 6 | [![CodeFactor](https://www.codefactor.io/repository/github/eshaan7/flask-shell2http/badge)](https://www.codefactor.io/repository/github/eshaan7/flask-shell2http) 7 | 8 | Language grade: Python 9 | 10 | 11 | A minimalist [Flask](https://github.com/pallets/flask) extension that serves as a RESTful/HTTP wrapper for python's subprocess API. 12 | 13 | - **Convert any command-line tool into a REST API service.** 14 | - Execute pre-defined shell commands asynchronously and securely via flask's endpoints with dynamic arguments, file upload, callback function capabilities. 15 | - Designed for binary to binary/HTTP communication, development, prototyping, remote control and [more](https://flask-shell2http.readthedocs.io/en/stable/Examples.html). 16 | 17 | ## Use Cases 18 | 19 | - Set a script that runs on a succesful POST request to an endpoint of your choice. See [Example code](examples/run_script.py). 20 | - Map a base command to an endpoint and pass dynamic arguments to it. See [Example code](examples/basic.py). 21 | - Can also process multiple uploaded files in one command. See [Example code](examples/multiple_files.py). 22 | - This is useful for internal docker-to-docker communications if you have different binaries distributed in micro-containers. See [real-life example](https://github.com/intelowlproject/IntelOwl/blob/master/integrations/malware_tools_analyzers/app.py). 23 | - You can define a callback function/ use signals to listen for process completion. See [Example code](examples/with_callback.py). 24 | - Maybe want to pass some additional context to the callback function ? 25 | - Maybe intercept on completion and update the result ? See [Example code](examples/custom_save_fn.py) 26 | - You can also apply [View Decorators](https://flask.palletsprojects.com/en/1.1.x/patterns/viewdecorators/) to the exposed endpoint. See [Example code](examples/with_decorators.py). 27 | 28 | > Note: This extension is primarily meant for executing long-running 29 | > shell commands/scripts (like nmap, code-analysis' tools) in background from an HTTP request and getting the result at a later time. 30 | 31 | ## Documentation 32 | 33 | [![Documentation Status](https://readthedocs.org/projects/flask-shell2http/badge/?version=latest)](https://flask-shell2http.readthedocs.io/en/latest/?badge=latest) 34 | 35 | - Read the [Quickstart](https://flask-shell2http.readthedocs.io/en/stable/Quickstart.html) from the [documentation](https://flask-shell2http.readthedocs.io/) to get started! 36 | - I also highly recommend the [Examples](https://flask-shell2http.readthedocs.io/en/stable/Examples.html) section. 37 | - [CHANGELOG](https://github.com/eshaan7/Flask-Shell2HTTP/blob/master/.github/CHANGELOG.md). 38 | 39 | ## Quick Start 40 | 41 | ##### Dependencies 42 | 43 | - Python: `>=v3.6` 44 | - [Flask](https://pypi.org/project/Flask/) 45 | - [Flask-Executor](https://pypi.org/project/Flask-Executor) 46 | 47 | ##### Installation 48 | 49 | ```bash 50 | $ pip install flask flask_shell2http 51 | ``` 52 | 53 | ##### Example Program 54 | 55 | Create a file called `app.py`. 56 | 57 | ```python 58 | from flask import Flask 59 | from flask_executor import Executor 60 | from flask_shell2http import Shell2HTTP 61 | 62 | # Flask application instance 63 | app = Flask(__name__) 64 | 65 | executor = Executor(app) 66 | shell2http = Shell2HTTP(app=app, executor=executor, base_url_prefix="/commands/") 67 | 68 | def my_callback_fn(context, future): 69 | # optional user-defined callback function 70 | print(context, future.result()) 71 | 72 | shell2http.register_command(endpoint="saythis", command_name="echo", callback_fn=my_callback_fn, decorators=[]) 73 | ``` 74 | 75 | Run the application server with, `$ flask run -p 4000`. 76 | 77 | With <10 lines of code, we succesfully mapped the shell command `echo` to the endpoint `/commands/saythis`. 78 | 79 | ##### Making HTTP calls 80 | 81 | This section demonstrates how we can now call/ execute commands over HTTP that we just mapped in the [example](#example-program) above. 82 | 83 | ```bash 84 | $ curl -X POST -H 'Content-Type: application/json' -d '{"args": ["Hello", "World!"]}' http://localhost:4000/commands/saythis 85 | ``` 86 | 87 |
or using python's requests module, 88 | 89 | ```python 90 | # You can also add a timeout if you want, default value is 3600 seconds 91 | data = {"args": ["Hello", "World!"], "timeout": 60} 92 | resp = requests.post("http://localhost:4000/commands/saythis", json=data) 93 | print("Result:", resp.json()) 94 | ``` 95 | 96 |
97 | 98 | > Note: You can see the JSON schema for the POST request [here](https://github.com/Eshaan7/Flask-Shell2HTTP/blob/master/post-request-schema.json). 99 | 100 | returns JSON, 101 | 102 | ```json 103 | { 104 | "key": "ddbe0a94", 105 | "result_url": "http://localhost:4000/commands/saythis?key=ddbe0a94&wait=false", 106 | "status": "running" 107 | } 108 | ``` 109 | 110 | Then using this `key` you can query for the result or just by going to the `result_url`, 111 | 112 | ```bash 113 | $ curl http://localhost:4000/commands/saythis?key=ddbe0a94&wait=true # wait=true so we do not have to poll 114 | ``` 115 | 116 | Returns result in JSON, 117 | 118 | ```json 119 | { 120 | "report": "Hello World!\n", 121 | "key": "ddbe0a94", 122 | "start_time": 1593019807.7754705, 123 | "end_time": 1593019807.782958, 124 | "process_time": 0.00748753547668457, 125 | "returncode": 0, 126 | "error": null 127 | } 128 | ``` 129 | 130 | ## Inspiration 131 | 132 | This was initially made to integrate various command-line tools easily with [Intel Owl](https://github.com/intelowlproject/IntelOwl), which I am working on as part of Google Summer of Code. 133 | 134 | The name was inspired by the awesome folks over at [msoap/shell2http](https://github.com/msoap/shell2http). 135 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/source/Configuration.md: -------------------------------------------------------------------------------- 1 | ## Configuration 2 | 3 | ### POST Request Options 4 | 5 | One can read [post-request-schema.json](https://github.com/Eshaan7/Flask-Shell2HTTP/blob/master/post-request-schema.json) 6 | to see and understand the various _optional_ tweaks which can be done when making requests to the API. 7 | 8 | There are many [example programs](Examples.md) with client requests given which demonstrate these different behaviours. 9 | 10 | 11 | ### Logging Configuration 12 | 13 | This extension logs messages of different severity `INFO`, `DEBUG`, `ERROR` 14 | using the python's inbuilt [logging](https://docs.python.org/3/library/logging.html) module. 15 | 16 | There are no default handlers or stream defined for the logger so it's upto the user to define them. 17 | 18 | Here's a snippet of code that shows how you can access this extension's logger object and add a custom handler to it. 19 | 20 | ```python 21 | # python's inbuilt logging module 22 | import logging 23 | # get the flask_shell2http logger 24 | logger = logging.getLogger("flask_shell2http") 25 | # create new handler 26 | handler = logging.StreamHandler(sys.stdout) 27 | logger.addHandler(handler) 28 | # log messages of severity DEBUG or lower to the console 29 | logger.setLevel(logging.DEBUG) # this is really important! 30 | ``` 31 | 32 | Please consult the Flask's official docs on 33 | [extension logs](https://flask.palletsprojects.com/en/1.1.x/logging/#other-libraries) for more details. -------------------------------------------------------------------------------- /docs/source/Examples.md: -------------------------------------------------------------------------------- 1 | ## Examples 2 | 3 | I have created some example python scripts to demonstrate various use-cases. These include extension setup as well as making test HTTP calls with python's [requests](https://requests.readthedocs.io/en/master/) module. 4 | 5 | - [run_script.py](https://github.com/Eshaan7/Flask-Shell2HTTP/blob/master/examples/run_script.py): Execute a script on a succesful POST request to an endpoint. 6 | - [basic.py](https://github.com/Eshaan7/Flask-Shell2HTTP/blob/master/examples/basic.py): Map a base command to an endpoint and pass dynamic arguments to it. Can also pass in a timeout. 7 | - [multiple_files.py](https://github.com/Eshaan7/Flask-Shell2HTTP/blob/master/examples/multiple_files.py): Upload multiple files for a single command. 8 | - [with_callback.py](https://github.com/Eshaan7/Flask-Shell2HTTP/blob/master/examples/with_callback.py): Define a callback function that executes on command/process completion. 9 | - [with_signals.py](https://github.com/Eshaan7/Flask-Shell2HTTP/blob/master/examples/with_signals.py): Using [Flask Signals](https://flask.palletsprojects.com/en/1.1.x/signals/) as callback function. 10 | - [with_decorators.py](https://github.com/Eshaan7/Flask-Shell2HTTP/blob/master/examples/with_decorators.py): Shows how to apply [View Decorators](https://flask.palletsprojects.com/en/1.1.x/patterns/viewdecorators/) to the exposed endpoint. Useful in case you wish to apply authentication, caching, etc. to the endpoint. 11 | - [custom_save_fn.py](https://github.com/Eshaan7/Flask-Shell2HTTP/blob/master/examples/custom_save_fn.py): There may be cases where the process doesn't print result to standard output but to a file/database. This example shows how to pass additional context to the callback function, intercept the future object after completion and update it's result attribute before it's ready to be consumed. 12 | - [deletion.py](https://github.com/Eshaan7/Flask-Shell2HTTP/blob/master/examples/deletion.py): Example demonstrating how to request cancellation/deletion of an already running job. 13 | -------------------------------------------------------------------------------- /docs/source/Quickstart.md: -------------------------------------------------------------------------------- 1 | ## Quick Start 2 | 3 | ##### Dependencies 4 | 5 | * Python: `>=v3.6` 6 | * [Flask](https://pypi.org/project/Flask/) 7 | * [Flask-Executor](https://pypi.org/project/Flask-Executor) 8 | 9 | ##### Installation 10 | 11 | ```bash 12 | $ pip install flask flask_shell2http 13 | ``` 14 | 15 | ##### Example Program 16 | 17 | Create a file called `app.py`. 18 | 19 | ```python 20 | from flask import Flask 21 | from flask_executor import Executor 22 | from flask_shell2http import Shell2HTTP 23 | 24 | # Flask application instance 25 | app = Flask(__name__) 26 | 27 | executor = Executor(app) 28 | shell2http = Shell2HTTP(app=app, executor=executor, base_url_prefix="/commands/") 29 | 30 | def my_callback_fn(context, future): 31 | # optional user-defined callback function 32 | print(context, future.result()) 33 | 34 | shell2http.register_command(endpoint="saythis", command_name="echo", callback_fn=my_callback_fn, decorators=[]) 35 | ``` 36 | 37 | Run the application server with, `$ flask run -p 4000`. 38 | 39 | With <10 lines of code, we succesfully mapped the shell command `echo` to the endpoint `/commands/saythis`. 40 | 41 | ##### Making HTTP calls 42 | 43 | This section demonstrates how we can now call/ execute commands over HTTP that we just mapped in the [example](#example-program) above. 44 | 45 | ```bash 46 | $ curl -X POST -H 'Content-Type: application/json' -d '{"args": ["Hello", "World!"]}' http://localhost:4000/commands/saythis 47 | ``` 48 | 49 |
or using python's requests module, 50 | 51 | ```python 52 | # You can also add a timeout if you want, default value is 3600 seconds 53 | data = {"args": ["Hello", "World!"], "timeout": 60, "force_unique_key": False} 54 | resp = requests.post("http://localhost:4000/commands/saythis", json=data) 55 | print("Result:", resp.json()) 56 | ``` 57 | 58 |
59 | 60 | returns JSON, 61 | 62 | ```json 63 | { 64 | "key": "ddbe0a94", 65 | "result_url": "http://localhost:4000/commands/saythis?key=ddbe0a94&wait=false", 66 | "status": "running" 67 | } 68 | ``` 69 | 70 | Then using this `key` you can query for the result or just by going to the `result_url`, 71 | 72 | ```bash 73 | $ curl http://localhost:4000/commands/saythis?key=ddbe0a94&wait=true # wait=true so we don't need to poll 74 | ``` 75 | 76 | Returns result in JSON, 77 | 78 | ```json 79 | { 80 | "report": "Hello World!\n", 81 | "key": "ddbe0a94", 82 | "start_time": 1593019807.7754705, 83 | "end_time": 1593019807.782958, 84 | "process_time": 0.00748753547668457, 85 | "returncode": 0, 86 | "error": null, 87 | } 88 | ``` 89 | 90 |
91 |

Hint

92 | Use wait=true when you don't wish to HTTP poll and want the result in a single request only. 93 | This is especially ideal in case you specified a low timeout value in the POST request. 94 |
95 | 96 | 97 |
98 |

Hint

99 | By default, the key is the SHA1 sum of the command + args POSTed to the API. This is done as a rate limiting measure so as to prevent multiple jobs with same parameters, if one such job is already running. If force_unique_key is set to true, the API will bypass this default behaviour and a psuedorandom key will be returned instead. 100 |
101 | 102 |
103 |

Note

104 | You can see the full JSON schema for the POST request, here. 105 |
106 | 107 | 108 | ##### Bonus 109 | 110 | You can also define callback functions or use signals for reactive programming. There may be cases where the process doesn't print result to standard output but to a file/database. In such cases, you may want to intercept the future object and update it's result attribute. 111 | I request you to take a look at [Examples.md](Examples.md) for such use-cases. -------------------------------------------------------------------------------- /docs/source/api.rst: -------------------------------------------------------------------------------- 1 | API Reference 2 | ------------- 3 | 4 | If you are looking for information on a specific function, class or 5 | method, this part of the documentation is for you. 6 | 7 | .. automodule:: flask_shell2http.base_entrypoint 8 | :members: 9 | 10 | .. toctree:: 11 | :maxdepth: 2 -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | import os 14 | import pathlib 15 | import sys 16 | 17 | from pallets_sphinx_themes import ProjectLink 18 | 19 | sys.path.insert(0, os.path.abspath("../..")) 20 | 21 | 22 | # -- Project information ----------------------------------------------------- 23 | 24 | project = "Flask-Shell2HTTP" 25 | copyright = "2020, Eshaan Bansal" 26 | author = "Eshaan Bansal" 27 | 28 | # The full version, including alpha/beta/rc tags 29 | release = (pathlib.Path(__file__).parent.parent.parent / "version.txt").read_text() 30 | 31 | 32 | # -- General configuration --------------------------------------------------- 33 | 34 | # Add any Sphinx extension module names here, as strings. They can be 35 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 36 | # ones. 37 | extensions = [ 38 | "recommonmark", 39 | "pallets_sphinx_themes", 40 | "sphinx.ext.autodoc", 41 | "sphinx.ext.napoleon", 42 | "sphinx.ext.autosectionlabel", 43 | ] 44 | 45 | source_suffix = [".rst", ".md"] 46 | 47 | # Add any paths that contain templates here, relative to this directory. 48 | templates_path = ["_templates"] 49 | 50 | # List of patterns, relative to source directory, that match files and 51 | # directories to ignore when looking for source files. 52 | # This pattern also affects html_static_path and html_extra_path. 53 | exclude_patterns = [] 54 | 55 | 56 | # -- Options for HTML output ------------------------------------------------- 57 | 58 | # The theme to use for HTML and HTML Help pages. See the documentation for 59 | # a list of builtin themes. 60 | # 61 | html_theme = "flask" 62 | 63 | # Custom options 64 | 65 | html_context = { 66 | "project_links": [ 67 | ProjectLink("Donate To The Author", "https://paypal.me/eshaanbansal"), 68 | ProjectLink( 69 | "Flask-Shell2HTTP Website", "https://flask-shell2http.readthedocs.io/" 70 | ), 71 | ProjectLink("PyPI releases", "https://pypi.org/project/Flask-Shell2HTTP/"), 72 | ProjectLink("Source Code", "https://github.com/eshaan7/flask-shell2http"), 73 | ProjectLink( 74 | "Issue Tracker", "https://github.com/eshaan7/flask-shell2http/issues" 75 | ), 76 | ] 77 | } 78 | html_sidebars = { 79 | "index": ["project.html", "localtoc.html", "searchbox.html"], 80 | "**": ["localtoc.html", "relations.html", "searchbox.html"], 81 | } 82 | singlehtml_sidebars = {"index": ["project.html", "localtoc.html"]} 83 | html_static_path = ["_static"] 84 | # html_favicon = "_static/flask-icon.png" 85 | # html_logo = "_static/flask-icon.png" 86 | html_title = f"Flask-Shell2HTTP Documentation ({release})" 87 | 88 | 89 | # Add any paths that contain custom static files (such as style sheets) here, 90 | # relative to this directory. They are copied after the builtin static files, 91 | # so a file named "default.css" will overwrite the builtin "default.css". 92 | html_static_path = ["_static"] 93 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | Welcome to Flask-Shell2HTTP! 2 | ================================ 3 | 4 | .. image:: https://img.shields.io/lgtm/grade/python/g/Eshaan7/Flask-Shell2HTTP.svg?logo=lgtm&logoWidth=18 5 | .. image:: https://img.shields.io/pypi/v/flask-shell2http 6 | .. image:: https://github.com/Eshaan7/Flask-Shell2HTTP/workflows/Linter%20&%20Tests/badge.svg?branch=master 7 | .. image:: https://codecov.io/gh/Eshaan7/Flask-Shell2HTTP/branch/master/graph/badge.svg?token=UQ43PYQPMR 8 | .. image:: https://www.codefactor.io/repository/github/eshaan7/flask-shell2http/badge 9 | 10 | A minimalist Flask_ extension that serves as a RESTful/HTTP wrapper for python's subprocess API. 11 | 12 | - **Convert any command-line tool into a REST API service.** 13 | - Execute shell commands asynchronously and safely via flask's endpoints. 14 | - Designed for binary to binary/HTTP communication, development, prototyping, remote control and more. 15 | 16 | .. _Flask: https://github.com/pallets/flask 17 | 18 | **Use Cases:** 19 | 20 | - Set a script that runs on a succesful POST request to an endpoint of your choice. 21 | - Map a base command to an endpoint and pass dynamic arguments to it. 22 | - Can also process multiple uploaded files in one command. 23 | - This is useful for internal docker-to-docker communications if you have different binaries distributed in micro-containers. 24 | - You can define a callback function/ use signals to listen for process completion. 25 | - You can also apply View Decorators to the exposed endpoint. 26 | 27 | `Note: This extension is primarily meant for executing long-running 28 | shell commands/scripts (like nmap, code-analysis' tools) in background from an HTTP request and getting the result at a later time.` 29 | 30 | Quickstart 31 | ------------------------------- 32 | Get started at :doc:`Quickstart`. There are also 33 | more detailed :doc:`Examples` that shows different use-cases for this package. 34 | 35 | .. toctree:: 36 | :maxdepth: 2 37 | 38 | Quickstart 39 | Examples 40 | Configuration 41 | 42 | API Reference 43 | ------------------------------- 44 | If you are looking for information on a specific function, class or 45 | method, this part of the documentation is for you. 46 | 47 | .. toctree:: 48 | :maxdepth: 2 49 | 50 | api 51 | 52 | Indices and tables 53 | ================================ 54 | 55 | * :ref:`genindex` 56 | * :ref:`modindex` 57 | * :ref:`search` 58 | -------------------------------------------------------------------------------- /docs/source/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx==4.4.0 2 | Pallets-Sphinx-Themes>=2.0.2 3 | sphinxcontrib-applehelp==1.0.2 4 | sphinxcontrib-devhelp==1.0.2 5 | sphinxcontrib-htmlhelp>=2.0.0 6 | sphinxcontrib-jsmath==1.0.1 7 | sphinxcontrib-napoleon==0.7 8 | sphinxcontrib-qthelp==1.0.3 9 | sphinxcontrib-serializinghtml>=1.1.5 10 | commonmark 11 | recommonmark -------------------------------------------------------------------------------- /examples/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eshaan7/Flask-Shell2HTTP/9e0fe36b45a8baee486538eb7484b7c63f80f78b/examples/__init__.py -------------------------------------------------------------------------------- /examples/basic.py: -------------------------------------------------------------------------------- 1 | # system imports 2 | import logging 3 | import sys 4 | 5 | # web imports 6 | from flask import Flask 7 | from flask_executor import Executor 8 | 9 | from flask_shell2http import Shell2HTTP 10 | 11 | # Flask application instance 12 | app = Flask(__name__) 13 | 14 | # Logging 15 | formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") 16 | logger = logging.getLogger("flask_shell2http") 17 | handler = logging.StreamHandler(sys.stdout) 18 | handler.setFormatter(formatter) 19 | logger.addHandler(handler) 20 | logger.setLevel(logging.INFO) 21 | 22 | 23 | # application factory 24 | executor = Executor(app) 25 | shell2http = Shell2HTTP(app, executor, base_url_prefix="/cmd/") 26 | 27 | ENDPOINT = "echo" 28 | shell2http.register_command(endpoint=ENDPOINT, command_name=ENDPOINT) 29 | 30 | 31 | # Test Runner 32 | if __name__ == "__main__": 33 | app.testing = True 34 | c = app.test_client() 35 | """ 36 | The final executed command becomes: 37 | ```bash 38 | $ echo hello world 39 | ``` 40 | """ 41 | # make new request for a command with arguments 42 | uri = f"/cmd/{ENDPOINT}" 43 | # timeout in seconds, default value is 3600 44 | # force_unique_key disables rate-limiting 45 | data = {"args": ["hello", "world"], "timeout": 60, "force_unique_key": True} 46 | resp1 = c.post(uri, json=data).get_json() 47 | print(resp1) 48 | # fetch result 49 | result_url = resp1["result_url"].replace("wait=false", "wait=true") 50 | resp2 = c.get(result_url).get_json() 51 | print(resp2) 52 | -------------------------------------------------------------------------------- /examples/custom_save_fn.py: -------------------------------------------------------------------------------- 1 | # web imports 2 | from flask import Flask 3 | from flask_executor import Executor 4 | from flask_executor.futures import Future 5 | 6 | from flask_shell2http import Shell2HTTP 7 | 8 | # Flask application instance 9 | app = Flask(__name__) 10 | 11 | # application factory 12 | executor = Executor(app) 13 | shell2http = Shell2HTTP(app, executor) 14 | 15 | ENDPOINT = "echo" 16 | 17 | 18 | def intercept_result(context, future: Future): 19 | """ 20 | Will be invoked on every process completion 21 | """ 22 | data = None 23 | if future.done(): 24 | fname = context.get("read_result_from", None) 25 | with open(fname) as f: 26 | data = f.read() 27 | # 1. get current result object 28 | res = future.result() # this returns a dictionary 29 | # 2. update the report variable, 30 | # you may update only these: report,error,status 31 | res["report"] = data 32 | if context.get("force_success", False): 33 | res["status"] = "success" 34 | # 3. set new result 35 | future._result = res 36 | 37 | 38 | shell2http.register_command( 39 | endpoint=ENDPOINT, command_name=ENDPOINT, callback_fn=intercept_result 40 | ) 41 | 42 | 43 | # Test Runner 44 | if __name__ == "__main__": 45 | app.testing = True 46 | c = app.test_client() 47 | # request new process 48 | data = { 49 | "args": ["hello", "world"], 50 | "callback_context": { 51 | "read_result_from": "/path/to/saved/file", 52 | "force_success": True, 53 | }, 54 | } 55 | r = c.post(f"/{ENDPOINT}", json=data) 56 | # get result 57 | result_url = r.get_json()["result_url"] 58 | resp = c.get(result_url) 59 | print(resp.get_json()) 60 | -------------------------------------------------------------------------------- /examples/deletion.py: -------------------------------------------------------------------------------- 1 | # web imports 2 | from flask import Flask 3 | from flask_executor import Executor 4 | 5 | from flask_shell2http import Shell2HTTP 6 | 7 | # Flask application instance 8 | app = Flask(__name__) 9 | 10 | # application factory 11 | executor = Executor(app) 12 | shell2http = Shell2HTTP(app, executor) 13 | 14 | 15 | shell2http.register_command( 16 | endpoint="sleep", 17 | command_name="sleep", 18 | ) 19 | 20 | 21 | # Test Runner 22 | if __name__ == "__main__": 23 | app.testing = True 24 | c = app.test_client() 25 | # request new process 26 | r1 = c.post("/sleep", json={"args": ["10"], "force_unique_key": True}) 27 | print(r1) 28 | # request cancellation 29 | r2 = c.delete(f"/sleep?key={r1.get_json()['key']}") 30 | print(r2) 31 | -------------------------------------------------------------------------------- /examples/multiple_files.py: -------------------------------------------------------------------------------- 1 | # system imports 2 | import json 3 | import tempfile 4 | 5 | import requests 6 | 7 | # web imports 8 | from flask import Flask 9 | from flask_executor import Executor 10 | 11 | from flask_shell2http import Shell2HTTP 12 | 13 | # Flask application instance 14 | app = Flask(__name__) 15 | 16 | # application factory 17 | executor = Executor() 18 | executor.init_app(app) 19 | shell2http = Shell2HTTP(base_url_prefix="/cmd/") 20 | shell2http.init_app(app, executor) 21 | 22 | shell2http.register_command(endpoint="strings", command_name="strings") 23 | 24 | 25 | # go to http://localhost:4000/ to execute 26 | @app.route("/") 27 | def test(): 28 | """ 29 | Prefix each filename with @ in arguments.\n 30 | Files are stored in temporary directories which are flushed on command completion.\n 31 | The final executed command becomes: 32 | ```bash 33 | $ strings /tmp/inputfile /tmp/someotherfile 34 | ``` 35 | """ 36 | url = "http://localhost:4000/cmd/strings" 37 | # create and read dummy data from temporary files 38 | with tempfile.TemporaryFile() as fp: 39 | fp.write(b"Hello world!") 40 | fp.seek(0) 41 | f = fp.read() 42 | # they key should be `request_json` only. 43 | form_data = {"args": ["@inputfile", "@someotherfile"]} 44 | req_data = {"request_json": json.dumps(form_data)} 45 | req_files = {"inputfile": f, "someotherfile": f} 46 | resp = requests.post(url=url, files=req_files, data=req_data) 47 | resp_data = resp.json() 48 | print(resp_data) 49 | key = resp_data["key"] 50 | if key: 51 | report = requests.get(f"{url}?key={key}") 52 | return report.json() 53 | return resp_data 54 | 55 | 56 | # Application Runner 57 | if __name__ == "__main__": 58 | app.run(port=4000) 59 | -------------------------------------------------------------------------------- /examples/run_script.py: -------------------------------------------------------------------------------- 1 | # web imports 2 | from flask import Flask 3 | from flask_executor import Executor 4 | 5 | from flask_shell2http import Shell2HTTP 6 | 7 | # Flask application instance 8 | app = Flask(__name__) 9 | 10 | # application factory 11 | executor = Executor(app) 12 | shell2http = Shell2HTTP(app, executor, base_url_prefix="/scripts/") 13 | 14 | shell2http.register_command(endpoint="hacktheplanet", command_name="./fuxsocy.py") 15 | 16 | 17 | # Application Runner 18 | if __name__ == "__main__": 19 | app.testing = True 20 | c = app.test_client() 21 | """ 22 | The final executed command becomes: 23 | ```bash 24 | $ ./fuxsocy.py 25 | ``` 26 | """ 27 | uri = "/scripts/hacktheplanet" 28 | resp1 = c.post(uri, json={"args": []}).get_json() 29 | print(resp1) 30 | # fetch result 31 | result_url = resp1["result_url"] 32 | resp2 = c.get(result_url).get_json() 33 | print(resp2) 34 | -------------------------------------------------------------------------------- /examples/with_callback.py: -------------------------------------------------------------------------------- 1 | # web imports 2 | from flask import Flask 3 | from flask_executor import Executor 4 | from flask_executor.futures import Future 5 | 6 | from flask_shell2http import Shell2HTTP 7 | 8 | # Flask application instance 9 | app = Flask(__name__) 10 | 11 | # application factory 12 | executor = Executor(app) 13 | shell2http = Shell2HTTP(app, executor) 14 | 15 | 16 | def my_callback_fn(extra_callback_context, future: Future): 17 | """ 18 | Will be invoked on every process completion 19 | """ 20 | print("[i] Process running ?:", future.running()) 21 | print("[i] Process completed ?:", future.done()) 22 | print("[+] Result: ", future.result()) 23 | # future.result() returns a dictionary 24 | print("[+] Context: ", extra_callback_context) 25 | 26 | 27 | shell2http.register_command( 28 | endpoint="echo/callback", command_name="echo", callback_fn=my_callback_fn 29 | ) 30 | 31 | 32 | # Test Runner 33 | if __name__ == "__main__": 34 | app.testing = True 35 | c = app.test_client() 36 | # request new process 37 | data = {"args": ["hello", "world"]} 38 | c.post("/echo/callback", json=data) 39 | # request another new process 40 | data = {"args": ["Hello", "Friend!"], "callback_context": {"testkey": "testvalue"}} 41 | c.post("/echo/callback", json=data) 42 | -------------------------------------------------------------------------------- /examples/with_decorators.py: -------------------------------------------------------------------------------- 1 | # generic imports 2 | import functools 3 | 4 | # web imports 5 | from flask import Flask, Response, abort, g, request 6 | from flask_executor import Executor 7 | 8 | from flask_shell2http import Shell2HTTP 9 | 10 | # Flask application instance 11 | app = Flask(__name__) 12 | 13 | # application factory 14 | executor = Executor(app) 15 | shell2http = Shell2HTTP(app, executor, base_url_prefix="/cmd/") 16 | 17 | 18 | # few decorators [1] 19 | def logging_decorator(f): 20 | @functools.wraps(f) 21 | def decorator(*args, **kwargs): 22 | print("*" * 64) 23 | print( 24 | "from logging_decorator: " + request.url + " : " + str(request.remote_addr) 25 | ) 26 | print("*" * 64) 27 | return f(*args, **kwargs) 28 | 29 | return decorator 30 | 31 | 32 | def login_required(f): 33 | @functools.wraps(f) 34 | def decorator(*args, **kwargs): 35 | if not hasattr(g, "user") or g.user is None: 36 | abort(Response("You are not logged in.", 401)) 37 | return f(*args, **kwargs) 38 | 39 | return decorator 40 | 41 | 42 | shell2http.register_command( 43 | endpoint="public/echo", command_name="echo", decorators=[logging_decorator] 44 | ) 45 | 46 | shell2http.register_command( 47 | endpoint="protected/echo", 48 | command_name="echo", 49 | decorators=[login_required, logging_decorator], # [2] 50 | ) 51 | 52 | # [1] View Decorators: 53 | # https://flask.palletsprojects.com/en/1.1.x/patterns/viewdecorators/ 54 | # [2] remember that decorators are applied from left to right in a stack manner. 55 | # But are executed in right to left manner. 56 | # Put logging_decorator first and you will see what happens. 57 | 58 | 59 | # Test Runner 60 | if __name__ == "__main__": 61 | app.testing = True 62 | c = app.test_client() 63 | # request 1 64 | data = {"args": ["hello", "world"]} 65 | r1 = c.post("cmd/public/echo", json=data) 66 | print(r1.json, r1.status_code) 67 | # request 2 68 | data = {"args": ["Hello", "Friend!"]} 69 | r2 = c.post("cmd/protected/echo", json=data) 70 | print(r2.data, r2.status_code) 71 | -------------------------------------------------------------------------------- /examples/with_signals.py: -------------------------------------------------------------------------------- 1 | # web imports 2 | from blinker import Namespace # or from flask.signals import Namespace 3 | from flask import Flask 4 | from flask_executor import Executor 5 | from flask_executor.futures import Future 6 | 7 | from flask_shell2http import Shell2HTTP 8 | 9 | # Flask application instance 10 | app = Flask(__name__) 11 | 12 | # application factory 13 | executor = Executor(app) 14 | shell2http = Shell2HTTP(app, executor) 15 | 16 | # Signal Handling 17 | signal_handler = Namespace() 18 | my_signal = signal_handler.signal("on_echo_complete") 19 | # ..or any other name of your choice 20 | 21 | 22 | @my_signal.connect 23 | def my_callback_fn(sender, extra_callback_context, future: Future): 24 | """ 25 | Will be invoked on every process completion 26 | """ 27 | print("Process completed ?:", future.done()) 28 | print("Result: ", future.result()) 29 | 30 | 31 | def send_proxy(extra_callback_context, future: Future): 32 | my_signal.send( 33 | "send_proxy", extra_callback_context=extra_callback_context, future=future 34 | ) 35 | 36 | 37 | shell2http.register_command( 38 | endpoint="echo/signal", command_name="echo", callback_fn=send_proxy 39 | ) 40 | 41 | 42 | # Test Runner 43 | if __name__ == "__main__": 44 | app.testing = True 45 | c = app.test_client() 46 | # request new process 47 | data = {"args": ["Hello", "Friend!"]} 48 | c.post("/echo/signal", json=data) 49 | # request new process 50 | data = {"args": ["Bye", "Friend!"]} 51 | c.post("/echo/signal", json=data) 52 | -------------------------------------------------------------------------------- /flask_shell2http/__init__.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | """ 3 | flask_shell2http 4 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 5 | Flask-Shell2HTTP 6 | :copyright: (c) 2020 by Eshaan Bansal. 7 | :license: BSD, see LICENSE for more details. 8 | """ 9 | 10 | from .base_entrypoint import Shell2HTTP 11 | -------------------------------------------------------------------------------- /flask_shell2http/api.py: -------------------------------------------------------------------------------- 1 | """ 2 | flask_shell2http.api 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | Flask-Shell2HTTP API class 5 | :copyright: (c) 2020 by Eshaan Bansal. 6 | :license: BSD, see LICENSE for more details. 7 | """ 8 | 9 | # system imports 10 | import functools 11 | from http import HTTPStatus 12 | from typing import Any, Callable, Dict 13 | 14 | # web imports 15 | from flask import jsonify, make_response, request 16 | from flask.views import MethodView 17 | from flask_executor import Executor 18 | from flask_executor.futures import Future 19 | 20 | # lib imports 21 | from .classes import RunnerParser 22 | from .exceptions import JobNotFoundException, JobStillRunningException 23 | from .helpers import get_logger 24 | 25 | logger = get_logger() 26 | runner_parser = RunnerParser() 27 | 28 | 29 | class Shell2HttpAPI(MethodView): 30 | """ 31 | ``Flask.MethodView`` that creates ``GET``, ``POST`` and ``DELETE`` 32 | methods for a given endpoint. 33 | This is invoked on ``Shell2HTTP.register_command``. 34 | 35 | *Internal use only.* 36 | """ 37 | 38 | def get(self): 39 | """ 40 | Get report by job key. 41 | Args: 42 | key (str): 43 | - Future key 44 | wait (str): 45 | - If ``true``, then wait for future to finish and return result. 46 | """ 47 | 48 | key: str = "" 49 | report: Dict = {} 50 | try: 51 | key = request.args.get("key") 52 | wait = request.args.get("wait", "").lower() == "true" 53 | logger.info( 54 | f"Job: '{key}' --> Report requested. " 55 | f"Requester: '{request.remote_addr}'." 56 | ) 57 | if not key: 58 | raise Exception("No key provided in arguments.") 59 | 60 | # get the future object 61 | future: Future = self.executor.futures._futures.get(key) 62 | if not future: 63 | raise JobNotFoundException(f"No report exists for key: '{key}'.") 64 | 65 | # check if job has been finished 66 | if not wait and not future.done(): 67 | raise JobStillRunningException() 68 | 69 | # pop future object since it has been finished 70 | self.executor.futures.pop(key) 71 | 72 | # if yes, get result from store 73 | report = future.result() 74 | if not report: 75 | raise JobNotFoundException(f"Job: '{key}' --> No report exists.") 76 | 77 | logger.debug(f"Job: '{key}' --> Requested report: {report}") 78 | return jsonify(report) 79 | 80 | except JobNotFoundException as e: 81 | logger.error(e) 82 | return make_response(jsonify(error=str(e)), HTTPStatus.NOT_FOUND) 83 | 84 | except JobStillRunningException: 85 | logger.debug(f"Job: '{key}' --> still running.") 86 | return make_response( 87 | jsonify( 88 | status="running", 89 | key=key, 90 | result_url=self.__build_result_url(key), 91 | ), 92 | HTTPStatus.OK, 93 | ) 94 | 95 | except Exception as e: 96 | logger.error(e) 97 | return make_response(jsonify(error=str(e)), HTTPStatus.BAD_REQUEST) 98 | 99 | def post(self): 100 | key: str = "" 101 | try: 102 | logger.info( 103 | f"Received request for endpoint: '{request.url_rule}'. " 104 | f"Requester: '{request.remote_addr}'." 105 | ) 106 | # Check if request data is correct and parse it 107 | cmd, timeout, callback_context, key = runner_parser.parse_req( 108 | request, self.command_name 109 | ) 110 | 111 | # run executor job in background 112 | future = self.executor.submit_stored( 113 | future_key=key, 114 | fn=runner_parser.run_command, 115 | cmd=cmd, 116 | timeout=timeout, 117 | key=key, 118 | ) 119 | # callback that removes the temporary directory 120 | future.add_done_callback(runner_parser.cleanup_temp_dir) 121 | if self.user_callback_fn: 122 | # user defined callback fn with callback_context if any 123 | future.add_done_callback( 124 | functools.partial(self.user_callback_fn, callback_context) 125 | ) 126 | 127 | logger.info(f"Job: '{key}' --> added to queue for command: {cmd}") 128 | result_url = self.__build_result_url(key) 129 | return make_response( 130 | jsonify(status="running", key=key, result_url=result_url), 131 | HTTPStatus.ACCEPTED, 132 | ) 133 | 134 | except Exception as e: 135 | logger.error(e) 136 | response_dict = {"error": str(e)} 137 | if key: 138 | response_dict["key"] = key 139 | response_dict["result_url"] = self.__build_result_url(key) 140 | return make_response(jsonify(response_dict), HTTPStatus.BAD_REQUEST) 141 | 142 | def delete(self): 143 | """ 144 | Cancel (if running) and delete job by job key. 145 | Args: 146 | key (str): 147 | - Future key 148 | """ 149 | try: 150 | key = request.args.get("key") 151 | logger.info( 152 | f"Job: '{key}' --> deletion requested. " 153 | f"Requester: '{request.remote_addr}'." 154 | ) 155 | if not key: 156 | raise Exception("No key provided in arguments.") 157 | 158 | # get the future object 159 | future: Future = self.executor.futures._futures.get(key) 160 | if not future: 161 | raise JobNotFoundException(f"No job exists for key: '{key}'.") 162 | 163 | # cancel and delete from memory 164 | future.cancel() 165 | self.executor.futures.pop(key) 166 | 167 | return make_response({}, HTTPStatus.NO_CONTENT) 168 | 169 | except JobNotFoundException as e: 170 | logger.error(e) 171 | return make_response(jsonify(error=str(e)), HTTPStatus.NOT_FOUND) 172 | 173 | except Exception as e: 174 | logger.error(e) 175 | return make_response(jsonify(error=str(e)), HTTPStatus.BAD_REQUEST) 176 | 177 | @classmethod 178 | def __build_result_url(cls, key: str) -> str: 179 | return f"{request.base_url}?key={key}&wait=false" 180 | 181 | def __init__( 182 | self, 183 | command_name: str, 184 | user_callback_fn: Callable[[Dict, Future], Any], 185 | executor: Executor, 186 | ): 187 | self.command_name: str = command_name 188 | self.user_callback_fn = user_callback_fn 189 | self.executor: Executor = executor 190 | -------------------------------------------------------------------------------- /flask_shell2http/base_entrypoint.py: -------------------------------------------------------------------------------- 1 | # system imports 2 | from collections import OrderedDict 3 | from typing import Any, Callable, Dict, List 4 | 5 | # web imports 6 | from flask_executor import Executor 7 | from flask_executor.futures import Future 8 | 9 | # lib imports 10 | from .api import Shell2HttpAPI 11 | from .helpers import get_logger 12 | 13 | logger = get_logger() 14 | 15 | 16 | class Shell2HTTP: 17 | """ 18 | Flask-Shell2HTTP base entrypoint class. 19 | The only public API available to users. 20 | 21 | Attributes: 22 | app: Flask application instance. 23 | executor: Flask-Executor instance 24 | base_url_prefix (str): base prefix to apply to endpoints. Defaults to "/". 25 | 26 | Example:: 27 | 28 | app = Flask(__name__) 29 | executor = Executor(app) 30 | shell2http = Shell2HTTP(app=app, executor=executor, base_url_prefix="/tasks/") 31 | """ 32 | 33 | __commands: "OrderedDict[str, str]" = OrderedDict() 34 | __url_prefix: str = "/" 35 | 36 | def __init__( 37 | self, app=None, executor: Executor = None, base_url_prefix: str = "/" 38 | ) -> None: 39 | self.__url_prefix = base_url_prefix 40 | if app and executor: 41 | self.init_app(app, executor) 42 | 43 | def init_app(self, app, executor: Executor) -> None: 44 | """ 45 | For use with Flask's `Application Factory`_ method. 46 | 47 | Example:: 48 | 49 | executor = Executor() 50 | shell2http = Shell2HTTP(base_url_prefix="/commands/") 51 | app = Flask(__name__) 52 | executor.init_app(app) 53 | shell2http.init_app(app=app, executor=executor) 54 | 55 | .. _Application Factory: 56 | https://flask.palletsprojects.com/en/1.1.x/patterns/appfactories/ 57 | """ 58 | self.app = app 59 | self.__executor: Executor = executor 60 | self.__init_extension() 61 | 62 | def __init_extension(self) -> None: 63 | """ 64 | Adds the Shell2HTTP() instance to `app.extensions` list. 65 | For internal use only. 66 | """ 67 | if not hasattr(self.app, "extensions"): 68 | self.app.extensions = {} 69 | 70 | self.app.extensions["shell2http"] = self 71 | 72 | def register_command( 73 | self, 74 | endpoint: str, 75 | command_name: str, 76 | callback_fn: Callable[[Dict, Future], Any] = None, 77 | decorators: List = None, 78 | ) -> None: 79 | """ 80 | Function to map a shell command to an endpoint. 81 | 82 | Args: 83 | endpoint (str): 84 | - your command would live here: ``/{base_url_prefix}/{endpoint}`` 85 | command_name (str): 86 | - The base command which can be executed from the given endpoint. 87 | - If ``command_name='echo'``, then all arguments passed 88 | to this endpoint will be appended to ``echo``.\n 89 | For example, 90 | if you pass ``{ "args": ["Hello", "World"] }`` 91 | in POST request, it gets converted to ``echo Hello World``.\n 92 | callback_fn (Callable[[Dict, Future], Any]): 93 | - An optional function that is invoked when a requested process 94 | to this endpoint completes execution. 95 | - This is added as a 96 | ``concurrent.Future.add_done_callback(fn=callback_fn)`` 97 | - The same callback function may be used for multiple commands. 98 | - if request JSON contains a `callback_context` attr, it will be passed 99 | as the first argument to this function. 100 | decorators (List[Callable]): 101 | - A List of view decorators to apply to the endpoint. 102 | - *New in version v1.5.0* 103 | 104 | Examples:: 105 | 106 | def my_callback_fn(context: dict, future: Future) -> None: 107 | print(future.result(), context) 108 | 109 | shell2http.register_command(endpoint="echo", command_name="echo") 110 | shell2http.register_command( 111 | endpoint="myawesomescript", 112 | command_name="./fuxsocy.py", 113 | callback_fn=my_callback_fn, 114 | decorators=[], 115 | ) 116 | """ 117 | if decorators is None: 118 | decorators = [] 119 | uri: str = self.__construct_route(endpoint) 120 | # make sure the given endpoint is not already registered 121 | cmd_already_exists = self.__commands.get(uri) 122 | if cmd_already_exists: 123 | logger.error( 124 | "Failed to register since given endpoint: " 125 | f"'{endpoint}' already maps to command: '{cmd_already_exists}'." 126 | ) 127 | return None 128 | 129 | # else, add new URL rule 130 | view_func = Shell2HttpAPI.as_view( 131 | endpoint, 132 | command_name=command_name, 133 | user_callback_fn=callback_fn, 134 | executor=self.__executor, 135 | ) 136 | # apply decorators, if any 137 | for dec in decorators: 138 | view_func = dec(view_func) 139 | # register URL rule 140 | self.app.add_url_rule( 141 | uri, 142 | view_func=view_func, 143 | ) 144 | self.__commands.update({uri: command_name}) 145 | logger.info(f"New endpoint: '{uri}' registered for command: '{command_name}'.") 146 | 147 | def get_registered_commands(self) -> "OrderedDict[str, str]": 148 | """ 149 | Most of the time you won't need this since 150 | Flask provides a ``Flask.url_map`` attribute. 151 | 152 | Returns: 153 | OrderedDict[uri, command] 154 | i.e. mapping of registered commands and their URLs. 155 | """ 156 | return self.__commands 157 | 158 | def __construct_route(self, endpoint: str) -> str: 159 | """ 160 | For internal use only. 161 | """ 162 | return self.__url_prefix + endpoint 163 | -------------------------------------------------------------------------------- /flask_shell2http/classes.py: -------------------------------------------------------------------------------- 1 | # system imports 2 | import json 3 | import shutil 4 | import subprocess 5 | import tempfile 6 | import time 7 | from typing import Any, Dict, List, Optional, Tuple 8 | 9 | # web imports 10 | from flask_executor.futures import Future 11 | from werkzeug.utils import secure_filename 12 | 13 | try: 14 | from flask.helpers import safe_join 15 | except ImportError: 16 | from werkzeug.utils import safe_join 17 | 18 | # lib imports 19 | from .helpers import DEFAULT_TIMEOUT, gen_key, get_logger, list_replace 20 | 21 | logger = get_logger() 22 | 23 | 24 | class RunnerParser: 25 | """ 26 | Utility class to parse incoming POST request 27 | data into meaningful arguments, generate key and run command. 28 | Internal use Only. 29 | """ 30 | 31 | __tmpdirs: Dict[str, str] = {} 32 | 33 | @staticmethod 34 | def __parse_multipart_req(args: List[str], files) -> Tuple[List[str], str]: 35 | # Check if file part exists 36 | fnames = [] 37 | for arg in args: 38 | if arg.startswith("@"): 39 | fnames.append(arg.strip("@")) 40 | 41 | if not fnames: 42 | raise Exception( 43 | "No filename(s) specified." 44 | "Please prefix file argument(s) with @ character." 45 | ) 46 | 47 | # create a new temporary directory 48 | tmpdir: str = tempfile.mkdtemp() 49 | for fname in fnames: 50 | if fname not in files: 51 | raise Exception(f"No File part with filename: {fname} in request.") 52 | req_file = files.get(fname) 53 | filename = secure_filename(req_file.filename) 54 | # calc file location 55 | f_loc = safe_join(tmpdir, filename) 56 | # save file into temp directory 57 | req_file.save(f_loc) 58 | # replace @filename with it's file location in system 59 | list_replace(args, "@" + fname, f_loc) 60 | 61 | logger.debug(f"Request files saved under temp directory: '{tmpdir}'") 62 | return args, tmpdir 63 | 64 | def parse_req( 65 | self, request, base_command: str 66 | ) -> Tuple[List[str], int, Dict[str, Any], str]: 67 | # default values if request is w/o any data 68 | # i.e. just run-script 69 | tmpdir = None 70 | # default values 71 | args: List[str] = [] 72 | timeout: int = DEFAULT_TIMEOUT 73 | callback_context: Dict[str, Any] = {} 74 | randomize_key = False 75 | if request.is_json: 76 | # request does not contain a file 77 | args = request.json.get("args", []) 78 | timeout = request.json.get("timeout", DEFAULT_TIMEOUT) 79 | callback_context = request.json.get("callback_context", {}) 80 | randomize_key = request.json.get("force_unique_key", False) 81 | elif request.files: 82 | # request contains file and form_data 83 | data = json.loads(request.form.get("request_json", "{}")) 84 | received_args = data.get("args", []) 85 | timeout = data.get("timeout", DEFAULT_TIMEOUT) 86 | callback_context = data.get("callback_context", {}) 87 | randomize_key = data.get("force_unique_key", False) 88 | args, tmpdir = self.__parse_multipart_req(received_args, request.files) 89 | 90 | cmd: List[str] = base_command.split(" ") 91 | cmd.extend(args) 92 | key: str = gen_key(cmd, randomize=randomize_key) 93 | if tmpdir: 94 | self.__tmpdirs.update({key: tmpdir}) 95 | 96 | return cmd, timeout, callback_context, key 97 | 98 | def cleanup_temp_dir(self, future: Future) -> None: 99 | key: Optional[str] = future.result().get("key", None) 100 | if not key: 101 | return None 102 | tmpdir: Optional[str] = self.__tmpdirs.get(key, None) 103 | if not tmpdir: 104 | return None 105 | 106 | try: 107 | shutil.rmtree(tmpdir) 108 | logger.debug( 109 | f"Job: '{key}' --> Temporary directory: '{tmpdir}' " 110 | "successfully deleted." 111 | ) 112 | self.__tmpdirs.pop(key) 113 | except Exception: 114 | logger.debug( 115 | f"Job: '{key}' --> Failed to clear Temporary directory: '{tmpdir}'." 116 | ) 117 | 118 | @staticmethod 119 | def run_command(cmd: List[str], timeout: int, key: str) -> Dict[str, Any]: 120 | """ 121 | This function is called by the executor to run given command 122 | using a subprocess asynchronously. 123 | 124 | :param cmd: List[str] 125 | command to run split as a list 126 | :param key: str 127 | future_key of particular Future instance 128 | :param timeout: int 129 | maximum timeout in seconds (default = 3600) 130 | 131 | :rtype: Dict[str, Any] 132 | 133 | :returns: 134 | A Concurrent.Future object where future.result() is the report 135 | """ 136 | start_time: float = time.time() 137 | proc = subprocess.Popen( 138 | cmd, 139 | stdout=subprocess.PIPE, 140 | stderr=subprocess.PIPE, 141 | ) 142 | stdout: Optional[str] = None 143 | stderr: Optional[str] = None 144 | returncode: int = 0 145 | try: 146 | outs, errs = proc.communicate(timeout=int(timeout)) 147 | stdout = outs.decode("utf-8") 148 | stderr = errs.decode("utf-8") 149 | returncode = proc.returncode 150 | logger.info(f"Job: '{key}' --> finished with returncode: '{returncode}'.") 151 | 152 | except subprocess.TimeoutExpired: 153 | proc.kill() 154 | stdout, _ = [s.decode("utf-8") for s in proc.communicate()] 155 | stderr = f"command timedout after {timeout} seconds." 156 | returncode = proc.returncode 157 | logger.error(f"Job: '{key}' --> failed. Reason: \"{stderr}\".") 158 | 159 | except Exception as e: 160 | proc.kill() 161 | returncode = -1 162 | stdout = None 163 | stderr = str(e) 164 | logger.error(f"Job: '{key}' --> failed. Reason: \"{stderr}\".") 165 | 166 | end_time: float = time.time() 167 | process_time = end_time - start_time 168 | return dict( 169 | key=key, 170 | report=stdout, 171 | error=stderr, 172 | returncode=returncode, 173 | start_time=start_time, 174 | end_time=end_time, 175 | process_time=process_time, 176 | ) 177 | -------------------------------------------------------------------------------- /flask_shell2http/exceptions.py: -------------------------------------------------------------------------------- 1 | class JobNotFoundException(Exception): 2 | """ 3 | Raised when no job exists for requested key. 4 | """ 5 | 6 | 7 | class JobStillRunningException(Exception): 8 | """ 9 | Raised when job is still running. 10 | """ 11 | -------------------------------------------------------------------------------- /flask_shell2http/helpers.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import logging 3 | import uuid 4 | from typing import List 5 | 6 | from flask import current_app 7 | 8 | DEFAULT_TIMEOUT = 3600 9 | 10 | 11 | def list_replace(lst: List, old, new) -> None: 12 | """ 13 | replace list elements (inplace) 14 | """ 15 | idx = -1 16 | try: 17 | while True: 18 | i = lst.index(old, idx + 1) 19 | lst[i] = new 20 | except ValueError: 21 | pass 22 | 23 | 24 | def gen_key(lst: List, randomize=False) -> str: 25 | if randomize: 26 | return str(uuid.uuid4())[:8] 27 | return calc_hash(lst)[:8] 28 | 29 | 30 | def calc_hash(lst: List) -> str: 31 | """ 32 | Internal use only. 33 | Calculates sha1sum of given command with it's byte-string. 34 | This is for non-cryptographic purpose, 35 | that's why a faster and insecure hashing algorithm is chosen. 36 | """ 37 | current_app.config.get("Shell2HTTP_") 38 | to_hash = " ".join(lst).encode("utf-8") 39 | return hashlib.sha1(to_hash).hexdigest() 40 | 41 | 42 | def get_logger() -> logging.Logger: 43 | return logging.getLogger("flask_shell2http") 44 | -------------------------------------------------------------------------------- /post-request-schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema", 3 | "type": "object", 4 | "title": "The POST request schema", 5 | "default": {}, 6 | "examples": [ 7 | { 8 | "args": ["hello", "world"], 9 | "timeout": 60, 10 | "force_unique_key": true, 11 | "callback_context": { 12 | "read_result_from": "/path/to/saved/file", 13 | "force_success": true 14 | } 15 | } 16 | ], 17 | "required": [], 18 | "properties": { 19 | "args": { 20 | "type": "array", 21 | "title": "Arguments that will be appended to the base command", 22 | "default": [], 23 | "examples": [ 24 | ["hello", "world"], 25 | ["@inputfile", "@someotherfile"] 26 | ] 27 | }, 28 | "timeout": { 29 | "type": "integer", 30 | "title": "subprocess timeout in seconds", 31 | "description": "Maximum timeout after which subprocess fails if not already complete.", 32 | "default": 3600 33 | }, 34 | "force_unique_key": { 35 | "type": "boolean", 36 | "title": "Flag to enable/disable internal rate limiting mechanism", 37 | "description": "By default, the key is the SHA1 sum of the command + args POSTed to the API. This is done as a rate limiting measure so as to prevent multiple jobs with same parameters, if one such job is already running. If force_unique_key is set to true, the API will bypass this default behaviour and a psuedorandom key will be returned instead", 38 | "default": false 39 | }, 40 | "callback_context": { 41 | "type": "object", 42 | "title": "Additional context for user defined callback function", 43 | "description": "This object will be passed as the first argument of user-defined callback function", 44 | "default": {}, 45 | "examples": [ 46 | { 47 | "read_result_from": "/path/to/saved/file", 48 | "force_success": true 49 | } 50 | ] 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /requirements.dev.txt: -------------------------------------------------------------------------------- 1 | flask_testing 2 | coverage 3 | tox 4 | tox-gh-actions 5 | 6 | black==22.3.0 7 | flake8==3.8.4 8 | pre-commit==2.9.2 9 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | flask>=1.0.0 2 | blinker 3 | flask-executor 4 | contextvars;python_version<"3.7" 5 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """ 2 | # Flask-Shell2HTTP 3 | 4 | A minimalist REST API wrapper for python's subprocess API.
5 | Execute shell commands asynchronously and safely from flask's endpoints. 6 | 7 | ##### Docs & Example usage on GitHub: https://github.com/eshaan7/flask-shell2http 8 | """ 9 | import pathlib 10 | 11 | from setuptools import setup 12 | 13 | # The directory containing this file 14 | HERE = pathlib.Path(__file__).parent 15 | 16 | # The text of the README file 17 | README = (HERE / "README.md").read_text() 18 | 19 | # version 20 | VERSION = (HERE / "version.txt").read_text() 21 | 22 | GITHUB_URL = "https://github.com/eshaan7/flask-shell2http" 23 | 24 | requirements = (HERE / "requirements.txt").read_text().split("\n") 25 | 26 | requirements_test = (HERE / "requirements.dev.txt").read_text().split("\n") 27 | 28 | # This call to setup() does all the work 29 | setup( 30 | name="Flask-Shell2HTTP", 31 | version=VERSION, 32 | url=GITHUB_URL, 33 | license="BSD", 34 | author="Eshaan Bansal", 35 | author_email="eshaan7bansal@gmail.com", 36 | description="A minimalist REST API wrapper for python's subprocess API.", 37 | long_description=README, 38 | long_description_content_type="text/markdown", 39 | py_modules=["flask_shell2http"], 40 | zip_safe=False, 41 | packages=["flask_shell2http"], 42 | include_package_data=True, 43 | platforms="any", 44 | python_requires=">= 3.6", 45 | install_requires=requirements, 46 | classifiers=[ 47 | "Environment :: Web Environment", 48 | "Intended Audience :: Developers", 49 | "Operating System :: OS Independent", 50 | "License :: OSI Approved :: BSD License", 51 | "Programming Language :: Python :: 3", 52 | "Programming Language :: Python :: 3.6", 53 | "Programming Language :: Python :: 3.7", 54 | "Programming Language :: Python :: 3.8", 55 | "Programming Language :: Python :: 3.9", 56 | "Programming Language :: Python :: 3.10", 57 | "Topic :: Internet :: WWW/HTTP :: Dynamic Content", 58 | "Topic :: Software Development :: Libraries :: Python Modules", 59 | ], 60 | keywords="flask shell2http subprocess python", 61 | project_urls={ 62 | "Documentation": GITHUB_URL, 63 | "Funding": "https://www.paypal.me/eshaanbansal", 64 | "Source": GITHUB_URL, 65 | "Tracker": "{}/issues".format(GITHUB_URL), 66 | }, 67 | # List additional groups of dependencies here (e.g. development 68 | # dependencies). You can install these using the following syntax, 69 | # for example: 70 | # $ pip install -e .[dev,test] 71 | extras_require={ 72 | "test": requirements + requirements_test, 73 | }, 74 | ) 75 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eshaan7/Flask-Shell2HTTP/9e0fe36b45a8baee486538eb7484b7c63f80f78b/tests/__init__.py -------------------------------------------------------------------------------- /tests/_utils.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from flask_testing import TestCase 4 | 5 | post_req_keys = ("key", "status", "result_url") 6 | 7 | get_req_keys = ( 8 | "end_time", 9 | "process_time", 10 | "error", 11 | "key", 12 | "report", 13 | "returncode", 14 | "start_time", 15 | ) 16 | 17 | 18 | class CustomTestCase(TestCase): 19 | def fetch_result(self, key: str): 20 | uri = self.uri + f"?key={key}" 21 | running = True 22 | r = None 23 | while running: 24 | r = self.client.get(uri) 25 | status = r.json.get("status", None) 26 | if not status or status != "running": 27 | running = False 28 | # sleep a bit before next request 29 | time.sleep(0.5) 30 | return r 31 | -------------------------------------------------------------------------------- /tests/test_basic.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from examples.basic import app, shell2http 4 | from tests._utils import CustomTestCase, get_req_keys, post_req_keys 5 | 6 | 7 | class TestBasic(CustomTestCase): 8 | uri = "/cmd/echo" 9 | 10 | @staticmethod 11 | def create_app(): 12 | app.config["TESTING"] = True 13 | shell2http.register_command(endpoint="sleep", command_name="sleep") 14 | return app 15 | 16 | def test_keys_and_basic_sanity(self): 17 | # make request 18 | r1 = self.client.post(self.uri, json={"args": ["hello", "world"]}) 19 | self.assertStatus(r1, 202) 20 | r1_json = r1.get_json() 21 | for k in post_req_keys: 22 | self.assertIn(k, r1_json) 23 | # fetch result 24 | r2 = self.fetch_result(r1_json["key"]) 25 | self.assertStatus(r2, 200) 26 | r2_json = r2.get_json() 27 | for k in get_req_keys: 28 | self.assertIn(k, r2_json) 29 | self.assertEqual(r2_json["key"], r1_json["key"]) 30 | self.assertEqual(r2_json["report"], "hello world\n") 31 | self.assertEqual(r2_json["returncode"], 0) 32 | self.assertEqual( 33 | r2_json["process_time"], r2_json["end_time"] - r2_json["start_time"] 34 | ) 35 | 36 | def test_timeout_raises_error(self): 37 | # make request 38 | # timeout in seconds, default value is 3600 39 | r1 = self.client.post("/cmd/sleep", json={"args": ["5"], "timeout": 1}) 40 | self.assertStatus(r1, 202) 41 | r1_json = r1.get_json() 42 | # sleep for sometime 43 | time.sleep(2) 44 | # fetch result 45 | r2 = self.fetch_result(r1_json["key"]) 46 | self.assertStatus(r2, 200) 47 | r2_json = r2.get_json() 48 | print(r2_json) 49 | self.assertEqual(r2_json["key"], r1_json["key"]) 50 | self.assertEqual(r2_json["report"], "") 51 | self.assertEqual(r2_json["error"], "command timedout after 1 seconds.") 52 | self.assertEqual(r2_json["returncode"], -9) 53 | 54 | def test_duplicate_request_raises_error(self): 55 | data = {"args": ["test_duplicate_request_raises_error"]} 56 | _ = self.client.post(self.uri, json=data) 57 | r2 = self.client.post(self.uri, json=data) 58 | self.assertStatus( 59 | r2, 400, message="future key would already exist thus bad request" 60 | ) 61 | r2_json = r2.get_json() 62 | self.assertIn("error", r2_json) 63 | self.assertIn("key", r2_json) 64 | self.assertIn("result_url", r2_json) 65 | 66 | def test_duplicate_request_after_report_fetch(self): 67 | data = {"args": ["test_duplicate_request_after_report_fetch"]} 68 | # make 1st request 69 | r1 = self.client.post(self.uri, json=data) 70 | r1_json = r1.get_json() 71 | # wait for initial request to complete 72 | _ = self.fetch_result(r1_json["key"]) 73 | # now make 2nd request 74 | r2 = self.client.post(self.uri, json=data) 75 | r2_json = r2.get_json() 76 | # should have same response 77 | self.assertDictEqual(r2_json, r1_json) 78 | 79 | def test_force_unique_key(self): 80 | data = {"args": ["test_force_unique_key"]} 81 | r1 = self.client.post(self.uri, json=data) 82 | r1_json = r1.get_json() 83 | r2 = self.client.post(self.uri, json={**data, "force_unique_key": True}) 84 | r2_json = r2.get_json() 85 | self.assertNotEqual(r2_json["key"], r1_json["key"]) 86 | 87 | def test_get_with_wait(self): 88 | # 1. POST request 89 | r1 = self.client.post("/cmd/sleep", json={"args": ["2"]}) 90 | r1_json = r1.get_json() 91 | # 2. GET request with wait=True 92 | r2 = self.client.get(r1_json["result_url"].replace("false", "true")) 93 | r2_json = r2.get_json() 94 | # 3. asserts 95 | self.assertEqual(r2_json["key"], r1_json["key"]) 96 | self.assertEqual(r2_json["report"], "") 97 | self.assertEqual(r2_json["error"], "") 98 | self.assertEqual(r2_json["returncode"], 0) 99 | -------------------------------------------------------------------------------- /tests/test_callback_signal.py: -------------------------------------------------------------------------------- 1 | from examples.with_signals import app, my_signal 2 | from tests._utils import CustomTestCase 3 | 4 | 5 | class TestCallbackAndSignal(CustomTestCase): 6 | uri = "/echo/signal" 7 | 8 | @staticmethod 9 | def create_app(): 10 | app.config["TESTING"] = True 11 | return app 12 | 13 | def test_callback_fn_gets_called(self): 14 | self.signal_was_called = False 15 | 16 | @my_signal.connect 17 | def handler(sender, **kwargs): 18 | self.signal_was_called = True 19 | 20 | data = {"args": ["test_callback_fn_gets_called"]} 21 | # make request 22 | r1 = self.client.post(self.uri, json=data) 23 | # fetch report 24 | _ = self.fetch_result(r1.json["key"]) 25 | self.assertTrue(self.signal_was_called) 26 | -------------------------------------------------------------------------------- /tests/test_decorators.py: -------------------------------------------------------------------------------- 1 | from examples.with_decorators import app 2 | from tests._utils import CustomTestCase 3 | 4 | 5 | class TestDecorators(CustomTestCase): 6 | public_uri = "/cmd/public/echo" 7 | private_uri = "/cmd/protected/echo" 8 | uri = public_uri # default 9 | 10 | @staticmethod 11 | def create_app(): 12 | app.config["TESTING"] = True 13 | return app 14 | 15 | def test_decorators(self): 16 | data = {"args": ["hello", "world"]} 17 | # make request 18 | r1 = self.client.post(self.public_uri, json=data) 19 | self.assertStatus( 20 | r1, 21 | 202, 22 | message="202 status code because `login_required` decorator not applied", 23 | ) 24 | r2 = self.client.post(self.private_uri, json=data) 25 | print(r1.json, r2.json) 26 | self.assertStatus( 27 | r2, 28 | 401, 29 | message="401 status code because `login_required` decorator was applied", 30 | ) 31 | -------------------------------------------------------------------------------- /tests/test_deletion.py: -------------------------------------------------------------------------------- 1 | from examples.deletion import app 2 | from tests._utils import CustomTestCase 3 | 4 | 5 | class TestDeletion(CustomTestCase): 6 | uri = "/sleep" 7 | 8 | @staticmethod 9 | def create_app(): 10 | app.config["TESTING"] = True 11 | return app 12 | 13 | def test_delete__204(self): 14 | # create command process 15 | r1 = self.client.post(self.uri, json={"args": ["10"], "force_unique_key": True}) 16 | r1_json = r1.get_json() 17 | self.assertStatus(r1, 202) 18 | # request cancellation: correct key 19 | r2 = self.client.delete(f"{self.uri}?key={r1_json['key']}") 20 | self.assertStatus(r2, 204) 21 | 22 | def test_delete__400(self): 23 | # create command process 24 | r1 = self.client.post(self.uri, json={"args": ["10"], "force_unique_key": True}) 25 | self.assertStatus(r1, 202) 26 | # request cancellation: no key 27 | r2 = self.client.delete(f"{self.uri}?key=") 28 | self.assertStatus(r2, 400) 29 | 30 | def test_delete__404(self): 31 | # create command process 32 | r1 = self.client.post(self.uri, json={"args": ["10"], "force_unique_key": True}) 33 | self.assertStatus(r1, 202) 34 | # request cancellation: invalid key 35 | r2 = self.client.delete(f"{self.uri}?key=abcdefg") 36 | self.assertStatus(r2, 404) 37 | -------------------------------------------------------------------------------- /tests/test_multiple_files.py: -------------------------------------------------------------------------------- 1 | import io 2 | import json 3 | 4 | from examples.multiple_files import app 5 | from tests._utils import CustomTestCase 6 | 7 | 8 | class TestMultipleFiles(CustomTestCase): 9 | uri = "/cmd/strings" 10 | 11 | @staticmethod 12 | def create_app(): 13 | app.config["TESTING"] = True 14 | return app 15 | 16 | def test_multiple_files(self): 17 | req_files = { 18 | "inputfile": (io.BytesIO(b"Test File #1"), "inputfile"), 19 | "someotherfile": (io.BytesIO(b"Test File #2"), "someotherfile"), 20 | } 21 | # the key should be `request_json` only. 22 | req_data = { 23 | "request_json": json.dumps({"args": ["@inputfile", "@someotherfile"]}) 24 | } 25 | data = {**req_data, **req_files} 26 | # make request 27 | r1 = self.client.post(self.uri, data=data, content_type="multipart/form-data") 28 | key = r1.json["key"] 29 | r2 = self.fetch_result(key) 30 | r2_json = r2.get_json() 31 | self.assertEqual(r2_json["key"], key) 32 | self.assertEqual(r2_json["report"], "Test File #1\nTest File #2\n") 33 | self.assertEqual(r2_json["returncode"], 0) 34 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | docs-html, 4 | py{36,37,38,39,310,311}-flask1 5 | py{36,37,38,39,310,311}-flask2 6 | 7 | [testenv] 8 | commands = 9 | coverage run -m unittest discover tests 10 | coverage combine 11 | coverage report -m 12 | coverage xml 13 | setenv = 14 | PIP_INDEX_URL = https://pypi.python.org/simple/ 15 | deps = 16 | -r requirements.txt 17 | coverage 18 | flask_testing 19 | requests 20 | flask1: flask>=1.1.3,<2.0.0 21 | flask1: markupsafe==2.0.1 22 | flask2: flask>=2.0.0,<3.0.0 23 | 24 | [gh-actions] 25 | python = 26 | 3.6: py36 27 | 3.7: py37 28 | 3.8: py38, docs-html 29 | 3.9: py39 30 | 3.10: py310 31 | 3.11: py311 32 | 33 | [testenv:docs-html] 34 | deps = 35 | -r docs/source/requirements.txt 36 | commands = sphinx-build -b html -d docs/build/doctrees docs/source docs/build/html 37 | -------------------------------------------------------------------------------- /version.txt: -------------------------------------------------------------------------------- 1 | 1.9.1 --------------------------------------------------------------------------------