├── tests ├── data │ ├── fortigate_config_empty.conf │ ├── test_fmg.key │ ├── vault_token.key │ ├── ems_dummy.csrf │ ├── dummy.j2 │ ├── ems_dummy.cookie │ ├── convert_cache_hosts.json │ ├── fortigate_checks.yaml │ ├── inventory.yaml │ ├── fortigate_config_single.conf │ └── fortigate_config_vdom.conf ├── fotoobo_broken.yaml ├── cli │ ├── __init__.py │ ├── cloud │ │ ├── __init__.py │ │ ├── asset │ │ │ ├── __init__.py │ │ │ ├── test_asset.py │ │ │ └── test_get.py │ │ └── test_cloud.py │ ├── ems │ │ ├── __init__.py │ │ └── test_ems.py │ ├── faz │ │ ├── __init__.py │ │ ├── test_faz.py │ │ └── test_get.py │ ├── fgt │ │ ├── __init__.py │ │ ├── get │ │ │ ├── __init__.py │ │ │ └── cmdb │ │ │ │ ├── __init__.py │ │ │ │ ├── firewall │ │ │ │ ├── __init__.py │ │ │ │ └── test_firewall.py │ │ │ │ └── test_cmdb.py │ │ ├── test_config.py │ │ ├── test_monitor.py │ │ ├── test_fgt.py │ │ └── test_config_info.py │ └── fmg │ │ ├── __init__.py │ │ └── test_fmg.py ├── tools │ ├── __init__.py │ ├── cloud │ │ ├── __init__.py │ │ ├── asset │ │ │ ├── __init__.py │ │ │ └── get │ │ │ │ ├── __init__.py │ │ │ │ ├── test_version.py │ │ │ │ └── test_products.py │ │ └── conftest.py │ ├── ems │ │ ├── __init__.py │ │ ├── get │ │ │ ├── __init__.py │ │ │ └── test_version.py │ │ └── conftest.py │ ├── faz │ │ ├── __init__.py │ │ ├── get │ │ │ ├── __init__.py │ │ │ └── test_version.py │ │ └── conftest.py │ ├── fgt │ │ ├── __init__.py │ │ ├── cmdb │ │ │ ├── __init__.py │ │ │ └── firewall │ │ │ │ ├── __init__.py │ │ │ │ ├── test_addrgrp.py │ │ │ │ ├── test_service_group.py │ │ │ │ ├── test_address.py │ │ │ │ └── test_service_custom.py │ │ ├── config │ │ │ ├── __init__.py │ │ │ └── test_info.py │ │ ├── get │ │ │ ├── __init__.py │ │ │ ├── test_api_get.py │ │ │ └── test_version.py │ │ └── monitor │ │ │ └── __init__.py │ ├── fmg │ │ ├── __init__.py │ │ ├── get │ │ │ ├── __init__.py │ │ │ ├── test_version.py │ │ │ ├── test_adoms.py │ │ │ ├── test_devices.py │ │ │ └── test_policy.py │ │ ├── conftest.py │ │ ├── test_post.py │ │ └── test_assign.py │ ├── conftest.py │ ├── test_greet.py │ └── test_get.py ├── fortinet │ ├── __init__.py │ └── test_fortianalyzer.py ├── helpers │ ├── __init__.py │ └── test_output.py ├── inventory │ └── __init__.py ├── fotoobo.yaml ├── __init__.py ├── test_fotoobo.py ├── conftest.py └── helper.py ├── .coverage_unittests_rc ├── fotoobo ├── tools │ ├── cloud │ │ ├── __init__.py │ │ └── asset │ │ │ ├── __init__.py │ │ │ └── get.py │ ├── fgt │ │ ├── cmdb │ │ │ ├── __init__.py │ │ │ └── firewall │ │ │ │ ├── __init__.py │ │ │ │ ├── addrgrp.py │ │ │ │ ├── service_group.py │ │ │ │ ├── address.py │ │ │ │ └── service_custom.py │ │ ├── __init__.py │ │ ├── get.py │ │ └── main.py │ ├── faz │ │ ├── __init__.py │ │ └── get.py │ ├── fcasset │ │ └── __init__.py │ ├── ems │ │ ├── __init__.py │ │ └── get.py │ ├── fmg │ │ ├── __init__.py │ │ └── main.py │ ├── __init__.py │ ├── convert.py │ ├── greet.py │ └── get.py ├── cli │ ├── faz │ │ ├── __init__.py │ │ ├── faz.py │ │ └── get.py │ ├── fgt │ │ ├── __init__.py │ │ ├── get │ │ │ ├── __init__.py │ │ │ ├── cmdb │ │ │ │ ├── __init__.py │ │ │ │ ├── firewall │ │ │ │ │ └── __init__.py │ │ │ │ └── cmdb.py │ │ │ └── get.py │ │ └── config.py │ ├── fmg │ │ ├── __init__.py │ │ └── get.py │ ├── cloud │ │ ├── __init__.py │ │ ├── asset │ │ │ ├── __init__.py │ │ │ ├── asset.py │ │ │ └── get.py │ │ └── cloud.py │ ├── ems │ │ ├── __init__.py │ │ ├── ems.py │ │ └── get.py │ ├── __init__.py │ ├── get.py │ └── convert.py ├── inventory │ ├── __init__.py │ └── generic.py ├── exceptions │ ├── __init__.py │ └── exceptions.py ├── helpers │ ├── __init__.py │ └── cli.py ├── __init__.py ├── fortinet │ ├── __init__.py │ ├── fortigate_info.py │ └── fortianalyzer.py └── main.py ├── docs ├── source │ ├── ftb.png │ ├── fotoobo.png │ ├── _static │ │ └── .placeholder │ ├── developer │ │ ├── how_to │ │ │ ├── fortinet │ │ │ │ ├── fortigate │ │ │ │ │ └── fortigate_config_parse_to_dict.rst │ │ │ │ ├── forticlientems.rst │ │ │ │ └── fortigate.rst │ │ │ ├── how_to_release.rst │ │ │ └── how_to_test.rst │ │ ├── architecture │ │ │ ├── 6_runtime_deployment_view.rst │ │ │ ├── 2_constraints.rst │ │ │ ├── 3_context_scope.rst │ │ │ ├── 9_risks_technical_depths.rst │ │ │ ├── 5_building_block_view.rst │ │ │ ├── 7_crosscutting_concepts.rst │ │ │ ├── 8_quality_requirements.rst │ │ │ └── 1_introduction_goals.rst │ │ ├── how_to.rst │ │ └── architecture_documentation.rst │ ├── code.rst │ ├── usage │ │ ├── import_fotoobo.rst │ │ └── convert.rst │ ├── conf.py │ ├── migros.svg │ └── index.rst ├── Makefile └── make.bat ├── WHATSNEW.md ├── .gitignore ├── .github └── workflows │ ├── publish-to-pypi.yaml │ ├── create-github-release.yaml │ └── tests.yaml ├── .tag_release.sh ├── .readthedocs.yaml ├── .make_release.sh ├── CONTRIBUTING.md ├── fotoobo.yaml.sample ├── README.md └── logging-config.yaml.sample /tests/data/fortigate_config_empty.conf: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/data/test_fmg.key: -------------------------------------------------------------------------------- 1 | dummy_session_key -------------------------------------------------------------------------------- /tests/data/vault_token.key: -------------------------------------------------------------------------------- 1 | dummy_vault_token -------------------------------------------------------------------------------- /tests/fotoobo_broken.yaml: -------------------------------------------------------------------------------- 1 | broken 2 | 3 | -------------------------------------------------------------------------------- /tests/cli/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | __init__ 3 | """ 4 | -------------------------------------------------------------------------------- /tests/tools/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | __init__ 3 | """ 4 | -------------------------------------------------------------------------------- /.coverage_unittests_rc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = fotoobo/cli/* 3 | -------------------------------------------------------------------------------- /tests/cli/cloud/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | __init__ 3 | """ 4 | -------------------------------------------------------------------------------- /tests/cli/ems/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | __init__ 3 | """ 4 | -------------------------------------------------------------------------------- /tests/cli/faz/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | __init__ 3 | """ 4 | -------------------------------------------------------------------------------- /tests/cli/fgt/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | __init__ 3 | """ 4 | -------------------------------------------------------------------------------- /tests/cli/fgt/get/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | __init__ 3 | """ 4 | -------------------------------------------------------------------------------- /tests/cli/fmg/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | __init__ 3 | """ 4 | -------------------------------------------------------------------------------- /tests/data/ems_dummy.csrf: -------------------------------------------------------------------------------- 1 | dummy_csrf_token_from_cache 2 | -------------------------------------------------------------------------------- /tests/fortinet/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | __init__ 3 | """ 4 | -------------------------------------------------------------------------------- /tests/helpers/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | __init__ 3 | """ 4 | -------------------------------------------------------------------------------- /tests/inventory/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | __init__ 3 | """ 4 | -------------------------------------------------------------------------------- /tests/tools/cloud/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | __init__ 3 | """ 4 | -------------------------------------------------------------------------------- /tests/tools/ems/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | __init__ 3 | """ 4 | -------------------------------------------------------------------------------- /tests/tools/faz/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | __init__ 3 | """ 4 | -------------------------------------------------------------------------------- /tests/tools/fgt/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | __init__ 3 | """ 4 | -------------------------------------------------------------------------------- /tests/tools/fmg/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | __init__ 3 | """ 4 | -------------------------------------------------------------------------------- /tests/cli/cloud/asset/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | __init__ 3 | """ 4 | -------------------------------------------------------------------------------- /tests/cli/fgt/get/cmdb/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | __init__ 3 | """ 4 | -------------------------------------------------------------------------------- /tests/tools/ems/get/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | __init__ 3 | """ 4 | -------------------------------------------------------------------------------- /tests/tools/faz/get/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | __init__ 3 | """ 4 | -------------------------------------------------------------------------------- /tests/tools/fgt/cmdb/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | __init__ 3 | """ 4 | -------------------------------------------------------------------------------- /tests/tools/fgt/config/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | __init__ 3 | """ 4 | -------------------------------------------------------------------------------- /tests/tools/fgt/get/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | __init__ 3 | """ 4 | -------------------------------------------------------------------------------- /tests/tools/fmg/get/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | __init__ 3 | """ 4 | -------------------------------------------------------------------------------- /fotoobo/tools/cloud/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | fmg tools cloud 3 | """ 4 | -------------------------------------------------------------------------------- /tests/tools/cloud/asset/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | __init__ 3 | """ 4 | -------------------------------------------------------------------------------- /tests/tools/cloud/asset/get/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | __init__ 3 | """ 4 | -------------------------------------------------------------------------------- /tests/tools/fgt/monitor/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | __init__ 3 | """ 4 | -------------------------------------------------------------------------------- /fotoobo/tools/fgt/cmdb/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | fgt tools cmdb 3 | """ 4 | -------------------------------------------------------------------------------- /tests/cli/fgt/get/cmdb/firewall/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | __init__ 3 | """ 4 | -------------------------------------------------------------------------------- /tests/data/dummy.j2: -------------------------------------------------------------------------------- 1 | { 2 | "dummy": {{ fotoobo.dummy_var }} 3 | } 4 | -------------------------------------------------------------------------------- /tests/tools/fgt/cmdb/firewall/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | __init__ 3 | """ 4 | -------------------------------------------------------------------------------- /fotoobo/tools/cloud/asset/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | fmg tools cloud asset 3 | """ 4 | -------------------------------------------------------------------------------- /docs/source/ftb.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/migros/fotoobo/HEAD/docs/source/ftb.png -------------------------------------------------------------------------------- /docs/source/fotoobo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/migros/fotoobo/HEAD/docs/source/fotoobo.png -------------------------------------------------------------------------------- /tests/data/ems_dummy.cookie: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/migros/fotoobo/HEAD/tests/data/ems_dummy.cookie -------------------------------------------------------------------------------- /fotoobo/cli/faz/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | __init__.py 3 | """ 4 | 5 | from . import faz 6 | 7 | __all__ = ["faz"] 8 | -------------------------------------------------------------------------------- /fotoobo/cli/fgt/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | __init__.py 3 | """ 4 | 5 | from . import fgt 6 | 7 | __all__ = ["fgt"] 8 | -------------------------------------------------------------------------------- /fotoobo/cli/fmg/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | __init__.py 3 | """ 4 | 5 | from . import fmg 6 | 7 | __all__ = ["fmg"] 8 | -------------------------------------------------------------------------------- /fotoobo/tools/faz/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | faz tools 3 | """ 4 | 5 | from . import get 6 | 7 | __all__ = ["get"] 8 | -------------------------------------------------------------------------------- /fotoobo/cli/fgt/get/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | __init__.py 3 | """ 4 | 5 | from . import get 6 | 7 | __all__ = ["get"] 8 | -------------------------------------------------------------------------------- /tests/tools/fgt/get/test_api_get.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test fgt tools api_get 3 | """ 4 | 5 | # There are no tests yet !?! 6 | -------------------------------------------------------------------------------- /fotoobo/cli/cloud/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | __init__.py 3 | """ 4 | 5 | from . import asset 6 | 7 | __all__ = ["asset"] 8 | -------------------------------------------------------------------------------- /fotoobo/cli/fgt/get/cmdb/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | __init__.py 3 | """ 4 | 5 | from . import cmdb 6 | 7 | __all__ = ["cmdb"] 8 | -------------------------------------------------------------------------------- /fotoobo/tools/fcasset/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | fcasset tools 3 | """ 4 | 5 | from ..cloud.asset import get 6 | 7 | __all__ = ["get"] 8 | -------------------------------------------------------------------------------- /fotoobo/cli/cloud/asset/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | __init__.py 3 | """ 4 | 5 | from . import asset, get 6 | 7 | __all__ = ["asset", "get"] 8 | -------------------------------------------------------------------------------- /fotoobo/cli/fgt/get/cmdb/firewall/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | __init__.py 3 | """ 4 | 5 | from . import firewall 6 | 7 | __all__ = ["firewall"] 8 | -------------------------------------------------------------------------------- /fotoobo/cli/ems/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | __init__.py 3 | """ 4 | 5 | from . import ems, get, monitor 6 | 7 | __all__ = ["ems", "get", "monitor"] 8 | -------------------------------------------------------------------------------- /fotoobo/tools/ems/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | ems tools 3 | """ 4 | 5 | from fotoobo.tools.ems import get, monitor 6 | 7 | __all__ = ["get", "monitor"] 8 | -------------------------------------------------------------------------------- /fotoobo/tools/fmg/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | fmg tools 3 | """ 4 | 5 | from . import get 6 | from .main import assign, post 7 | 8 | __all__ = ["assign", "get", "post"] 9 | -------------------------------------------------------------------------------- /fotoobo/inventory/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | init.py 3 | """ 4 | 5 | from .generic import GenericDevice 6 | from .inventory import Inventory 7 | 8 | __all__ = ["Inventory", "GenericDevice"] 9 | -------------------------------------------------------------------------------- /tests/fotoobo.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | inventory: data/inventory.yaml 3 | 4 | logging: 5 | enabled: true 6 | level: DEBUG 7 | 8 | log_file: 9 | name: tests/data/testlog.txt 10 | -------------------------------------------------------------------------------- /fotoobo/tools/fgt/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | fgt tools 3 | """ 4 | 5 | from . import config, get, monitor 6 | from .main import backup 7 | 8 | __all__ = ["backup", "monitor", "config", "get"] 9 | -------------------------------------------------------------------------------- /WHATSNEW.md: -------------------------------------------------------------------------------- 1 | ### Changed 2 | 3 | - Update pyproject.toml to new format an remove deprecated settings 4 | - Update GitHub action for new pyproject.toml format (use --extras for optional dependencies) 5 | -------------------------------------------------------------------------------- /docs/source/_static/.placeholder: -------------------------------------------------------------------------------- 1 | Add this .placeholder file to the _static directory so that the directory is pushed to a git 2 | repository even if it's empty. If the directory _static is not present the Sphinx documentation 3 | creation process fails. 4 | -------------------------------------------------------------------------------- /fotoobo/exceptions/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | The exceptions module 3 | 4 | Here we define fotoobo specific exceptions 5 | """ 6 | 7 | from .exceptions import APIError, GeneralError, GeneralWarning 8 | 9 | __all__ = ["APIError", "GeneralError", "GeneralWarning"] 10 | -------------------------------------------------------------------------------- /fotoobo/cli/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | This is the main init file for the cli module 3 | 4 | Do not add too much logic into the cli commands. Just add the tools module and add the 5 | logic there. Therefore, we can segregate the duties. 6 | """ 7 | 8 | from . import convert, get 9 | 10 | __all__ = ["convert", "get"] 11 | -------------------------------------------------------------------------------- /fotoobo/helpers/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | The helper module 3 | 4 | Here you'll find some useful functions and classes for interacting with fotoobo. Mainly there are 5 | configuration, logging and input/output functions. 6 | Put everything into this module what does not belong directly to any other module. 7 | """ 8 | 9 | cli_path: list[str] = [] 10 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | The tests module for pytest unit testing 3 | 4 | Here we initialize the pytest module. To not fill the logfile or /syslog destination whenever tests 5 | are run the logging will be set to disabled here. 6 | """ 7 | 8 | from fotoobo.helpers.log import audit_logger, logger 9 | 10 | logger.disabled = True 11 | audit_logger.disabled = True 12 | -------------------------------------------------------------------------------- /docs/source/developer/how_to/fortinet/fortigate/fortigate_config_parse_to_dict.rst: -------------------------------------------------------------------------------- 1 | .. Describes the FortiGate parse_to_dict process 2 | 3 | .. _how_to_fortigate_parse_to_dict: 4 | 5 | FortiGate parse_to_dict_process 6 | =============================== 7 | 8 | .. image:: fortigate_config_parse_to_dict.drawio.svg 9 | :width: 100% 10 | :alt: The FortiGate parse_to_dict process 11 | -------------------------------------------------------------------------------- /docs/source/developer/architecture/6_runtime_deployment_view.rst: -------------------------------------------------------------------------------- 1 | .. Chapter six according to https://arc42.org/overview 2 | 3 | .. _RuntimeView: 4 | 5 | 6 | 6. Runtime & Deployment View 7 | ============================ 8 | 9 | **fotoobo** is invoked as a command line program. Thus it will run on the host you installed it 10 | and run the commands you instruct it to, either directly or by using a cron job for example. 11 | -------------------------------------------------------------------------------- /docs/source/developer/how_to/fortinet/forticlientems.rst: -------------------------------------------------------------------------------- 1 | .. Describes how to use FortiClient ESM devices 2 | 3 | .. _how_to_forticlientems: 4 | 5 | How To Use FortiClient EMS Devices 6 | ================================== 7 | 8 | 9 | FortiClient EMS Login 10 | --------------------- 11 | 12 | .. image:: forticlientems/forticlientems_login.drawio.svg 13 | :width: 100% 14 | :alt: The FortiClient EMS login process 15 | -------------------------------------------------------------------------------- /fotoobo/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | fotoobo - make IT easy 3 | 4 | This is fotoobo, the mighty Fortinet toolbox for managing your Fortinet environment. It is meant 5 | to be extendable to your needs. It's most likely the swiss army knife for Fortinet infrastructure. 6 | """ 7 | 8 | from .fortinet import FortiAnalyzer, FortiClientEMS, FortiGate, FortiManager 9 | 10 | __all__ = ["FortiAnalyzer", "FortiClientEMS", "FortiGate", "FortiManager"] 11 | __version__: str = "3.1.0" 12 | -------------------------------------------------------------------------------- /tests/tools/conftest.py: -------------------------------------------------------------------------------- 1 | """ 2 | The pytest fxtures for the tools package 3 | """ 4 | 5 | from pathlib import Path 6 | 7 | import pytest 8 | 9 | 10 | @pytest.fixture(autouse=True) 11 | def inventory_file(monkeypatch: pytest.MonkeyPatch) -> None: 12 | """ 13 | Change inventory file in config to test inventory. 14 | """ 15 | 16 | monkeypatch.setattr( 17 | "fotoobo.helpers.config.config.inventory_file", Path("tests/data/inventory.yaml") 18 | ) 19 | -------------------------------------------------------------------------------- /docs/source/developer/how_to/fortinet/fortigate.rst: -------------------------------------------------------------------------------- 1 | .. Describes how to use FortiGate devices 2 | 3 | .. _how_to_fortigate: 4 | 5 | How To Use FortiGate Devices 6 | ============================ 7 | 8 | Here you'll find some tips about how to access and use FortiGate devices. We tend to only document 9 | complex stuff which might not be clear at first sight. 10 | 11 | .. toctree:: 12 | :maxdepth: 1 13 | 14 | fortigate/fortigate_config_parse_to_dict 15 | fortigate/fortigate_config 16 | -------------------------------------------------------------------------------- /tests/tools/ems/conftest.py: -------------------------------------------------------------------------------- 1 | """ 2 | The pytest fixtures for the ems tools package 3 | """ 4 | 5 | from unittest.mock import Mock 6 | 7 | import pytest 8 | 9 | 10 | @pytest.fixture(autouse=True) 11 | def ems_login(monkeypatch: pytest.MonkeyPatch) -> None: 12 | """ 13 | Mock the FortiClient EMS login to always return 200 without to really login. 14 | """ 15 | 16 | monkeypatch.setattr( 17 | "fotoobo.fortinet.forticlientems.FortiClientEMS.login", Mock(return_value=200) 18 | ) 19 | -------------------------------------------------------------------------------- /fotoobo/tools/fgt/cmdb/firewall/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | fgt tools cmdb firewall 3 | """ 4 | 5 | from .address import get_cmdb_firewall_address 6 | from .addrgrp import get_cmdb_firewall_addrgrp 7 | from .service_custom import get_cmdb_firewall_service_custom 8 | from .service_group import get_cmdb_firewall_service_group 9 | 10 | __all__ = [ 11 | "get_cmdb_firewall_address", 12 | "get_cmdb_firewall_addrgrp", 13 | "get_cmdb_firewall_service_custom", 14 | "get_cmdb_firewall_service_group", 15 | ] 16 | -------------------------------------------------------------------------------- /tests/tools/cloud/conftest.py: -------------------------------------------------------------------------------- 1 | """ 2 | The pytest fixtures for the forticloud tools package 3 | """ 4 | 5 | from unittest.mock import Mock 6 | 7 | import pytest 8 | 9 | 10 | @pytest.fixture(autouse=True) 11 | def fc_login(monkeypatch: pytest.MonkeyPatch) -> None: 12 | """ 13 | Mock the FortiCloud login to always return 200 without to really login. 14 | """ 15 | 16 | monkeypatch.setattr( 17 | "fotoobo.fortinet.forticloudasset.FortiCloudAsset.login", Mock(return_value=200) 18 | ) 19 | -------------------------------------------------------------------------------- /fotoobo/tools/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | The tools module 3 | 4 | Use this module to add business logic for your Fortinet infrastructure. This module uses the 5 | relevant fotoobo and Fortinet modules. By default the tools functions are invoked by the cli app, 6 | but they may also be accessed directly. 7 | """ 8 | 9 | from . import convert, ems, faz, fgt, fmg, get 10 | from .greet import greet 11 | 12 | __all__ = [ 13 | "convert", 14 | "ems", 15 | "faz", 16 | "fgt", 17 | "fmg", 18 | "get", 19 | "greet", 20 | ] 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # pycharm idea dir 2 | .idea/ 3 | # visual studio code configuration 4 | .vscode/ 5 | 6 | #virtual environment 7 | .venv/ 8 | 9 | # caches 10 | __pycache__ 11 | .pytest_chache/ 12 | .tox/ 13 | .coverage 14 | 15 | # Builds 16 | dist/ 17 | 18 | # files and directories 19 | /tests/data/testlog.txt 20 | /tests/temp/ 21 | /*.yaml 22 | /data/ 23 | *.log 24 | 25 | # try*.py files are used to do some spontaneous tests and experiments 26 | try*.py 27 | 28 | # documentation /docs/build is ignored as it is generated only when needed 29 | /docs/build/ 30 | -------------------------------------------------------------------------------- /fotoobo/fortinet/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | The Fortinet module 3 | 4 | Here we add all the functions, classes and methods for accessing and interacting with the Fortinet 5 | infrastructure. This module is meant to be held very generic. 6 | """ 7 | 8 | from .fortianalyzer import FortiAnalyzer 9 | from .forticlientems import FortiClientEMS 10 | from .forticloudasset import FortiCloudAsset 11 | from .fortigate import FortiGate 12 | from .fortimanager import FortiManager 13 | 14 | __all__ = ["FortiAnalyzer", "FortiClientEMS", "FortiCloudAsset", "FortiGate", "FortiManager"] 15 | -------------------------------------------------------------------------------- /docs/source/developer/how_to.rst: -------------------------------------------------------------------------------- 1 | .. Main index for any How To's 2 | 3 | .. _HowTo: 4 | 5 | How To 6 | ====== 7 | 8 | General How To 9 | -------------- 10 | 11 | .. toctree:: 12 | :maxdepth: 1 13 | 14 | how_to/how_to_code 15 | how_to/how_to_test 16 | how_to/how_to_release 17 | 18 | How To Use Helpers 19 | ------------------ 20 | 21 | .. toctree:: 22 | :maxdepth: 1 23 | 24 | how_to/helpers/result 25 | 26 | 27 | How To Use Fortinet Devices 28 | --------------------------- 29 | 30 | .. toctree:: 31 | :maxdepth: 1 32 | 33 | how_to/fortinet/forticlientems 34 | how_to/fortinet/fortigate 35 | -------------------------------------------------------------------------------- /tests/data/convert_cache_hosts.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "data": { 4 | "comment": "dummy_comment_1", 5 | "name": "dummy_1", 6 | "subnet": "1.1.1.1/32", 7 | "uuid": "aaaa-aaaa-aaaa-aaaa" 8 | }, 9 | "url": "/pm/config/{adom}/obj/firewall/address/dummy_1" 10 | }, 11 | { 12 | "data": { 13 | "comment": "dummy_comment_2", 14 | "name": "dummy_2", 15 | "subnet": "2.2.2.2/32", 16 | "uuid": "bbbb-bbbb-bbbb-bbbb" 17 | }, 18 | "url": "/pm/config/{adom}/obj/firewall/address/dummy_2" 19 | } 20 | ] 21 | -------------------------------------------------------------------------------- /tests/tools/fmg/get/test_version.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test fmg tools get version. 3 | """ 4 | 5 | from unittest.mock import Mock 6 | 7 | from pytest import MonkeyPatch 8 | 9 | from fotoobo.tools.fmg.get import version 10 | 11 | 12 | def test_version(monkeypatch: MonkeyPatch) -> None: 13 | """ 14 | Test get version. 15 | """ 16 | 17 | # Arrange 18 | monkeypatch.setattr( 19 | "fotoobo.fortinet.fortimanager.FortiManager.get_version", 20 | Mock(return_value="1.1.1"), 21 | ) 22 | 23 | # Act 24 | result = version("test_fmg") 25 | 26 | # Assert 27 | assert result.get_result("test_fmg") == "1.1.1" 28 | -------------------------------------------------------------------------------- /tests/tools/faz/get/test_version.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test faz tools get version. 3 | """ 4 | 5 | from unittest.mock import Mock 6 | 7 | from pytest import MonkeyPatch 8 | 9 | from fotoobo.tools.faz.get import version 10 | 11 | 12 | def test_version(monkeypatch: MonkeyPatch) -> None: 13 | """ 14 | Test get version. 15 | """ 16 | 17 | # Arrange 18 | monkeypatch.setattr( 19 | "fotoobo.fortinet.fortianalyzer.FortiAnalyzer.get_version", Mock(return_value="1.1.1") 20 | ) 21 | 22 | # Act 23 | result = version("test_faz") 24 | 25 | # Assert 26 | data = result.get_result("test_faz") 27 | assert data == "1.1.1" 28 | -------------------------------------------------------------------------------- /fotoobo/inventory/generic.py: -------------------------------------------------------------------------------- 1 | """ 2 | A module for defining a generic device 3 | """ 4 | 5 | 6 | class GenericDevice: 7 | """ 8 | This is the class for a generic device. 9 | 10 | All kwarg will be unpacked and written to the instance as attributes. There are some mandatory 11 | attributes which will be filled with a default value. 12 | """ 13 | 14 | def __init__(self, **kwargs: str | int | bool) -> None: 15 | """init""" 16 | for key, value in kwargs.items(): 17 | setattr(self, key, value) 18 | 19 | self.hostname = getattr(self, "hostname", "") 20 | self.type = getattr(self, "type", "generic") 21 | -------------------------------------------------------------------------------- /tests/tools/ems/get/test_version.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test ems tools get version. 3 | """ 4 | 5 | from unittest.mock import Mock 6 | 7 | from pytest import MonkeyPatch 8 | 9 | from fotoobo.tools.ems.get import version 10 | 11 | 12 | def test_version(monkeypatch: MonkeyPatch) -> None: 13 | """ 14 | Test get version. 15 | """ 16 | 17 | # Arrange 18 | monkeypatch.setattr( 19 | "fotoobo.fortinet.forticlientems.FortiClientEMS.get_version", 20 | Mock(return_value="1.1.1"), 21 | ) 22 | 23 | # Act 24 | result = version("test_ems") 25 | 26 | # Assert 27 | data = result.get_result("test_ems") 28 | assert data == "v1.1.1" 29 | -------------------------------------------------------------------------------- /tests/tools/cloud/asset/get/test_version.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test fcasset tools get version. 3 | """ 4 | 5 | from unittest.mock import Mock 6 | 7 | from pytest import MonkeyPatch 8 | 9 | from fotoobo.tools.cloud.asset.get import version 10 | 11 | 12 | def test_version(monkeypatch: MonkeyPatch) -> None: 13 | """ 14 | Test get version. 15 | """ 16 | 17 | # Arrange 18 | monkeypatch.setattr( 19 | "fotoobo.fortinet.forticloudasset.FortiCloudAsset.get_version", 20 | Mock(return_value="3.0"), 21 | ) 22 | 23 | # Act 24 | result = version("forticloudasset") 25 | 26 | # Assert 27 | data = result.get_result("forticloudasset") 28 | assert data == "3.0" 29 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /fotoobo/fortinet/fortigate_info.py: -------------------------------------------------------------------------------- 1 | """ 2 | The FortiGateInfo class 3 | """ 4 | 5 | from dataclasses import dataclass 6 | 7 | 8 | @dataclass(eq=False, order=False) 9 | class FortiGateInfo: 10 | """ 11 | This dataclass holds FortiGate meta information from the configuration file. 12 | """ 13 | 14 | # pylint: disable=too-many-instance-attributes 15 | 16 | # attributes in alphabetical order 17 | buildno: str = "" 18 | conf_file_ver: str = "" 19 | global_vdom: str = "" 20 | hostname: str = "" 21 | model: str = "" 22 | opmode: str = "" 23 | os_version: str = "" 24 | type: str = "" 25 | user: str = "" 26 | vdom: str = "" # 0 in single vdom mode, 1 in multiple vdom mode 27 | -------------------------------------------------------------------------------- /fotoobo/cli/faz/faz.py: -------------------------------------------------------------------------------- 1 | """ 2 | The FortiAnalyzer commands 3 | """ 4 | 5 | import logging 6 | 7 | import typer 8 | 9 | from fotoobo.helpers import cli_path 10 | 11 | from . import get 12 | 13 | app = typer.Typer(no_args_is_help=True, rich_markup_mode="rich") 14 | log = logging.getLogger("fotoobo") 15 | 16 | 17 | @app.callback() 18 | def callback(context: typer.Context) -> None: 19 | """ 20 | The faz subcommand callback 21 | 22 | Args: 23 | context: The context object of the typer app 24 | """ 25 | cli_path.append(str(context.invoked_subcommand)) 26 | log.debug("About to execute command: '%s'", context.invoked_subcommand) 27 | 28 | 29 | app.add_typer(get.app, name="get", help="FortiAnalyzer get commands.") 30 | -------------------------------------------------------------------------------- /fotoobo/cli/cloud/asset/asset.py: -------------------------------------------------------------------------------- 1 | """ 2 | The FortiCloud commands 3 | """ 4 | 5 | import logging 6 | 7 | import typer 8 | 9 | from fotoobo.helpers import cli_path 10 | 11 | from . import get 12 | 13 | app = typer.Typer(no_args_is_help=True, rich_markup_mode="rich") 14 | log = logging.getLogger("fotoobo") 15 | 16 | 17 | @app.callback() 18 | def callback(context: typer.Context) -> None: 19 | """ 20 | The fc subcommand callback 21 | 22 | Args: 23 | context: The context object of the typer app 24 | """ 25 | cli_path.append(str(context.invoked_subcommand)) 26 | log.debug("About to execute command: '%s'", context.invoked_subcommand) 27 | 28 | 29 | app.add_typer(get.app, name="get", help="FortiCloud Asset Management get commands.") 30 | -------------------------------------------------------------------------------- /fotoobo/cli/cloud/cloud.py: -------------------------------------------------------------------------------- 1 | """ 2 | The FortiCloud commands 3 | """ 4 | 5 | import logging 6 | 7 | import typer 8 | 9 | from fotoobo.helpers import cli_path 10 | 11 | from .asset import asset 12 | 13 | app = typer.Typer(no_args_is_help=True, rich_markup_mode="rich") 14 | log = logging.getLogger("fotoobo") 15 | 16 | 17 | @app.callback() 18 | def callback(context: typer.Context) -> None: 19 | """ 20 | The cloud subcommand callback 21 | 22 | Args: 23 | context: The context object of the typer app 24 | """ 25 | cli_path.append(str(context.invoked_subcommand)) 26 | log.debug("About to execute command: '%s'", context.invoked_subcommand) 27 | 28 | 29 | app.add_typer(asset.app, name="asset", help="FortiCloud Asset Management commands.") 30 | -------------------------------------------------------------------------------- /tests/data/fortigate_checks.yaml: -------------------------------------------------------------------------------- 1 | - type: count 2 | scope: vdom 3 | path: /leaf_81/leaf_82 4 | checks: 5 | gt: 1 6 | eq: 2 7 | lt: 3 8 | 9 | - type: exist 10 | scope: global 11 | path: /system/global 12 | checks: 13 | option_1: true 14 | option_2: true 15 | option_99: false 16 | 17 | - type: value 18 | name: value_everything_ok 19 | scope: global 20 | path: /system/global 21 | checks: 22 | option_1: value_1 23 | option_2: value_2 24 | option_3: 3 25 | 26 | - type: value_in_list 27 | name: check_if_value_in_list 28 | scope: vdom 29 | path: /leaf_81/leaf_82 30 | checks: 31 | id: 1 32 | 33 | - type: exist 34 | scope: vdom 35 | path: /system/vdom_setting 36 | checks: 37 | option_1: true 38 | -------------------------------------------------------------------------------- /fotoobo/cli/fgt/get/cmdb/cmdb.py: -------------------------------------------------------------------------------- 1 | """ 2 | The FortiGate commands 3 | """ 4 | 5 | import logging 6 | 7 | import typer 8 | 9 | from fotoobo.helpers import cli_path 10 | 11 | from .firewall import firewall 12 | 13 | app = typer.Typer(no_args_is_help=True, rich_markup_mode="rich") 14 | log = logging.getLogger("fotoobo") 15 | 16 | 17 | app.add_typer(firewall.app, name="firewall", help="FortiGate get cmdb firewall commands.") 18 | 19 | 20 | @app.callback() 21 | def callback(context: typer.Context) -> None: 22 | """ 23 | The fgt get cmdb subcommand callback 24 | 25 | Args: 26 | context: The context object of the typer app 27 | """ 28 | cli_path.append(str(context.invoked_subcommand)) 29 | log.debug("About to execute command: '%s'", context.invoked_subcommand) 30 | -------------------------------------------------------------------------------- /tests/tools/test_greet.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test the hidden greeting utility. 3 | """ 4 | 5 | from typing import Any 6 | 7 | from fotoobo.tools import greet 8 | 9 | 10 | def test_greet(capsys: Any) -> None: 11 | """ 12 | Test greet. 13 | """ 14 | 15 | # Act 16 | greet("name", False, False) 17 | 18 | # Assert 19 | out, _ = capsys.readouterr() 20 | assert "Hi name" in out 21 | assert "Aloha" in out 22 | assert "Good Bye" not in out 23 | 24 | 25 | def test_greet_bye(capsys: Any) -> None: 26 | """ 27 | Test greet with bye. 28 | """ 29 | 30 | # Act 31 | greet("name", True, False) 32 | 33 | # Assert 34 | out, _ = capsys.readouterr() 35 | assert "Hi name" in out 36 | assert "Aloha" in out 37 | assert "Good Bye" in out 38 | -------------------------------------------------------------------------------- /.github/workflows/publish-to-pypi.yaml: -------------------------------------------------------------------------------- 1 | name: Publish fotoobo distributions 📦 to PyPI 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" # Push events to matching v*, i.e. v1.0, v20.15.10 7 | 8 | jobs: 9 | build-n-publish-to-pypi: 10 | name: Build and publish fotoobo distributions 📦 to PyPI 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - name: Set up Python 3.10 15 | uses: actions/setup-python@v5 16 | with: 17 | python-version: "3.10" 18 | - name: Install poetry 19 | run: pip install poetry 20 | - name: Configure API token 21 | run: poetry config pypi-token.pypi "${{ secrets.PYPI_API_TOKEN }}" 22 | - name: Build and then publish fotoobo to PyPI 23 | run: poetry publish --build 24 | -------------------------------------------------------------------------------- /tests/tools/fmg/conftest.py: -------------------------------------------------------------------------------- 1 | """ 2 | The pytest fixtures for the FortiManager tools package. 3 | """ 4 | 5 | from unittest.mock import Mock 6 | 7 | import pytest 8 | 9 | 10 | @pytest.fixture(autouse=True) 11 | def fmg_login(monkeypatch: pytest.MonkeyPatch) -> None: 12 | """ 13 | Mock the FortiManager login to always return 200 without to really login. 14 | """ 15 | 16 | monkeypatch.setattr("fotoobo.fortinet.fortimanager.FortiManager.login", Mock(return_value=200)) 17 | 18 | 19 | @pytest.fixture(autouse=True) 20 | def fmg_logout(monkeypatch: pytest.MonkeyPatch) -> None: 21 | """ 22 | Mock the FortiManager logout to always return 200 without to really logout. 23 | """ 24 | 25 | monkeypatch.setattr("fotoobo.fortinet.fortimanager.FortiManager.logout", Mock(return_value=200)) 26 | -------------------------------------------------------------------------------- /tests/cli/faz/test_faz.py: -------------------------------------------------------------------------------- 1 | """ 2 | Testing the cli app. 3 | """ 4 | 5 | from typer.testing import CliRunner 6 | 7 | from fotoobo.cli.main import app 8 | from tests.helper import parse_help_output 9 | 10 | runner = CliRunner() 11 | 12 | 13 | def test_cli_app_faz_help(help_args_with_none: str) -> None: 14 | """ 15 | Test cli help for faz. 16 | """ 17 | 18 | # Arrange 19 | args = ["-c", "tests/fotoobo.yaml", "faz"] 20 | args.append(help_args_with_none) 21 | args = list(filter(None, args)) 22 | 23 | # Act 24 | result = runner.invoke(app, args) 25 | 26 | # Assert 27 | assert result.exit_code in [0, 2] 28 | arguments, options, commands = parse_help_output(result.stdout) 29 | assert not arguments 30 | assert options == {"-h", "--help"} 31 | assert set(commands) == {"get"} 32 | -------------------------------------------------------------------------------- /.tag_release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Tag a new release, prepared by .make_release.sh . 3 | # This needs to be run on the main repository. 4 | 5 | new_version=`poetry version | sed 's/fotoobo \(.*\)/\1/'` 6 | 7 | echo 'Create and push tag...' 8 | git tag -a "v$new_version" -m "Version v$new_version" 9 | git push origin "v$new_version" 10 | 11 | echo "Done. Version $new_version is now released on GitHub (and soon on PyPi, etc.)." 12 | echo -e "Congratulations" 13 | 14 | 15 | # After the release we have to empty the WHATSNEW file and commit/push it again 16 | echo "Emptiying WHATSNEW.md" 17 | printf "### Added\n\n" > WHATSNEW.md 18 | printf "### Changed\n\n" >> WHATSNEW.md 19 | printf "### Removed\n\n" >> WHATSNEW.md 20 | git add WHATSNEW.md 21 | git commit -m ":memo: Reset WHATSNEW.md with template" 22 | git push 23 | echo "finished" 24 | -------------------------------------------------------------------------------- /tests/cli/cloud/test_cloud.py: -------------------------------------------------------------------------------- 1 | """ 2 | Testing the cloud app. 3 | """ 4 | 5 | from typer.testing import CliRunner 6 | 7 | from fotoobo.cli.main import app 8 | from tests.helper import parse_help_output 9 | 10 | runner = CliRunner() 11 | 12 | 13 | def test_cli_app_cloud(help_args_with_none: str) -> None: 14 | """ 15 | Test cli help for cloud. 16 | """ 17 | 18 | # Arrange 19 | args = ["-c", "tests/fotoobo.yaml", "cloud"] 20 | args.append(help_args_with_none) 21 | args = list(filter(None, args)) 22 | 23 | # Act 24 | result = runner.invoke(app, args) 25 | 26 | # Assert 27 | assert result.exit_code in [0, 2] 28 | arguments, options, commands = parse_help_output(result.stdout) 29 | assert not arguments 30 | assert options == {"-h", "--help"} 31 | assert set(commands) == {"asset"} 32 | -------------------------------------------------------------------------------- /fotoobo/cli/ems/ems.py: -------------------------------------------------------------------------------- 1 | """ 2 | The FortiClient EMS commands 3 | """ 4 | 5 | import logging 6 | 7 | import typer 8 | 9 | from fotoobo.helpers import cli_path 10 | 11 | from . import get, monitor 12 | 13 | app = typer.Typer(no_args_is_help=True, rich_markup_mode="rich") 14 | log = logging.getLogger("fotoobo") 15 | 16 | 17 | @app.callback() 18 | def callback(context: typer.Context) -> None: 19 | """ 20 | The ems subcommand callback 21 | 22 | Args: 23 | context: The context object of the typer app 24 | """ 25 | cli_path.append(str(context.invoked_subcommand)) 26 | log.debug("About to execute command: '%s'", context.invoked_subcommand) 27 | 28 | 29 | app.add_typer(get.app, name="get", help="FortiClient EMS get commands.") 30 | app.add_typer(monitor.app, name="monitor", help="FortiClient EMS monitor commands.") 31 | -------------------------------------------------------------------------------- /tests/cli/ems/test_ems.py: -------------------------------------------------------------------------------- 1 | """ 2 | Testing the ems cli app. 3 | """ 4 | 5 | from typer.testing import CliRunner 6 | 7 | from fotoobo.cli.main import app 8 | from tests.helper import parse_help_output 9 | 10 | runner = CliRunner() 11 | 12 | 13 | def test_cli_app_ems_help(help_args_with_none: str) -> None: 14 | """ 15 | Test cli help for ems. 16 | """ 17 | 18 | # Arrange 19 | args = ["-c", "tests/fotoobo.yaml", "ems"] 20 | args.append(help_args_with_none) 21 | args = list(filter(None, args)) 22 | 23 | # Act 24 | result = runner.invoke(app, args) 25 | 26 | # Assert 27 | assert result.exit_code in [0, 2] 28 | arguments, options, commands = parse_help_output(result.stdout) 29 | assert not arguments 30 | assert options == {"-h", "--help"} 31 | assert set(commands) == {"get", "monitor"} 32 | -------------------------------------------------------------------------------- /tests/tools/faz/conftest.py: -------------------------------------------------------------------------------- 1 | """ 2 | The pytest fixture for the FAZ tools package. 3 | """ 4 | 5 | from unittest.mock import Mock 6 | 7 | import pytest 8 | 9 | 10 | @pytest.fixture(autouse=True) 11 | def faz_login(monkeypatch: pytest.MonkeyPatch) -> None: 12 | """ 13 | Mock the FortiAnalyzer login to always return 200 without to really login. 14 | """ 15 | 16 | monkeypatch.setattr( 17 | "fotoobo.fortinet.fortianalyzer.FortiAnalyzer.login", Mock(return_value=200) 18 | ) 19 | 20 | 21 | @pytest.fixture(autouse=True) 22 | def faz_logout(monkeypatch: pytest.MonkeyPatch) -> None: 23 | """ 24 | Mock the FortiAnalyzer logout to always return 200 without to really logout. 25 | """ 26 | 27 | monkeypatch.setattr( 28 | "fotoobo.fortinet.fortianalyzer.FortiAnalyzer.logout", Mock(return_value=200) 29 | ) 30 | -------------------------------------------------------------------------------- /tests/cli/cloud/asset/test_asset.py: -------------------------------------------------------------------------------- 1 | """ 2 | Testing the cloud asset cli app. 3 | """ 4 | 5 | from typer.testing import CliRunner 6 | 7 | from fotoobo.cli.main import app 8 | from tests.helper import parse_help_output 9 | 10 | runner = CliRunner() 11 | 12 | 13 | def test_cli_app_asset_help(help_args_with_none: str) -> None: 14 | """ 15 | Test cli help for asset. 16 | """ 17 | 18 | # Arrange 19 | args = ["-c", "tests/fotoobo.yaml", "cloud", "asset"] 20 | args.append(help_args_with_none) 21 | args = list(filter(None, args)) 22 | 23 | # Act 24 | result = runner.invoke(app, args) 25 | 26 | # Assert 27 | assert result.exit_code in [0, 2] 28 | arguments, options, commands = parse_help_output(result.stdout) 29 | assert not arguments 30 | assert options == {"-h", "--help"} 31 | assert set(commands) == {"get"} 32 | -------------------------------------------------------------------------------- /tests/tools/fmg/get/test_adoms.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test fmg tools get adoms. 3 | """ 4 | 5 | from unittest.mock import Mock 6 | 7 | from pytest import MonkeyPatch 8 | 9 | from fotoobo.tools.fmg.get import adoms 10 | 11 | 12 | def test_adoms(monkeypatch: MonkeyPatch) -> None: 13 | """ 14 | Test get adoms. 15 | """ 16 | 17 | # Arrange 18 | monkeypatch.setattr( 19 | "fotoobo.fortinet.fortimanager.FortiManager.get_adoms", 20 | Mock( 21 | return_value=[ 22 | {"name": "adom_1", "os_ver": "1", "mr": "2"}, 23 | {"name": "adom_2", "os_ver": "3", "mr": "4"}, 24 | ] 25 | ), 26 | ) 27 | 28 | # Act 29 | result = adoms("test_fmg") 30 | 31 | # Assert 32 | assert len(result.results) == 2 33 | assert result.get_result("adom_1") == "1.2" 34 | assert result.get_result("adom_2") == "3.4" 35 | -------------------------------------------------------------------------------- /.github/workflows/create-github-release.yaml: -------------------------------------------------------------------------------- 1 | name: Create a fotoobo release 📦 on GitHub 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" # Push events to matching v*, i.e. v1.0, v20.15.10 7 | 8 | jobs: 9 | create-release-on-github: 10 | name: Create GitHub Release 11 | runs-on: ubuntu-latest 12 | permissions: 13 | contents: write 14 | steps: 15 | - name: Checkout code 16 | uses: actions/checkout@v4 17 | - name: Create Release 18 | id: create_release 19 | uses: actions/create-release@latest 20 | env: 21 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # This token is provided by Actions, you do not need to create your own token 22 | with: 23 | tag_name: ${{ github.ref }} 24 | release_name: Release ${{ github.ref }} 25 | body_path: WHATSNEW.md 26 | draft: false 27 | prerelease: false 28 | -------------------------------------------------------------------------------- /tests/cli/fgt/test_config.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test the cli fgt config. 3 | """ 4 | 5 | from typer.testing import CliRunner 6 | 7 | from fotoobo.cli.main import app 8 | from tests.helper import parse_help_output 9 | 10 | runner = CliRunner() 11 | 12 | 13 | def test_cli_app_fgt_config_help(help_args_with_none: str) -> None: 14 | """ 15 | Test cli help for fgt config help. 16 | """ 17 | 18 | # Arrange 19 | args = ["-c", "tests/fotoobo.yaml", "fgt", "config"] 20 | args.append(help_args_with_none) 21 | args = list(filter(None, args)) 22 | 23 | # Act 24 | result = runner.invoke(app, args) 25 | 26 | # Assert 27 | assert result.exit_code in [0, 2] 28 | assert "Usage: root fgt config" in result.stdout 29 | arguments, options, commands = parse_help_output(result.stdout) 30 | assert not arguments 31 | assert options == {"-h", "--help"} 32 | assert set(commands) == {"check", "get", "info"} 33 | -------------------------------------------------------------------------------- /tests/cli/fgt/get/cmdb/test_cmdb.py: -------------------------------------------------------------------------------- 1 | """ 2 | Testing the cli fgt get cmdb. 3 | """ 4 | 5 | from typer.testing import CliRunner 6 | 7 | from fotoobo.cli.main import app 8 | from tests.helper import parse_help_output 9 | 10 | runner = CliRunner() 11 | 12 | 13 | def test_cli_app_fgt_get_cmdb_help(help_args_with_none: str) -> None: 14 | """ 15 | Test cli help for fgt get cmdb. 16 | """ 17 | 18 | # Arrange 19 | args = ["-c", "tests/fotoobo.yaml", "fgt", "get", "cmdb"] 20 | args.append(help_args_with_none) 21 | args = list(filter(None, args)) 22 | 23 | # Act 24 | result = runner.invoke(app, args) 25 | 26 | # Assert 27 | assert result.exit_code in [0, 2] 28 | assert "Usage: root fgt get cmdb" in result.stdout 29 | arguments, options, commands = parse_help_output(result.stdout) 30 | assert not arguments 31 | assert options == {"-h", "--help"} 32 | assert set(commands) == {"firewall"} 33 | -------------------------------------------------------------------------------- /fotoobo/tools/faz/get.py: -------------------------------------------------------------------------------- 1 | """ 2 | FortiAnalyzer get version utility 3 | """ 4 | 5 | import logging 6 | 7 | from fotoobo.helpers.config import config 8 | from fotoobo.helpers.result import Result 9 | from fotoobo.inventory import Inventory 10 | 11 | log = logging.getLogger("fotoobo") 12 | 13 | 14 | def version(host: str) -> Result[str]: 15 | """ 16 | FortiAnalyzer get version 17 | 18 | Args: 19 | host: Host defined in inventory 20 | 21 | Returns: 22 | The version string per FAZ 23 | 24 | Raises: 25 | GeneralWarning: GeneralWarning 26 | """ 27 | result = Result[str]() 28 | inventory = Inventory(config.inventory_file) 29 | assets = inventory.get(host, "fortianalyzer") 30 | log.debug("FortiAnalyzer get version ...") 31 | assets[host].login() 32 | faz_version = assets[host].get_version() 33 | assets[host].logout() 34 | result.push_result(host, faz_version) 35 | return result 36 | -------------------------------------------------------------------------------- /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 | %SPHINXBUILD% >NUL 2>NUL 14 | if errorlevel 9009 ( 15 | echo. 16 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 17 | echo.installed, then set the SPHINXBUILD environment variable to point 18 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 19 | echo.may add the Sphinx directory to PATH. 20 | echo. 21 | echo.If you don't have Sphinx installed, grab it from 22 | echo.https://www.sphinx-doc.org/ 23 | exit /b 1 24 | ) 25 | 26 | if "%1" == "" goto help 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/developer/architecture_documentation.rst: -------------------------------------------------------------------------------- 1 | .. Base file for the architecture documentation 2 | 3 | .. _ArchitectureDocumentation: 4 | 5 | fotoobo Architecture Documentation 6 | ================================== 7 | 8 | This documentation describes the architecture of **fotoobo** which means 9 | 10 | - The goals, constraints and scope of **fotoobo** 11 | - The structure of **fotoobo** 12 | - Common patterns 13 | 14 | This part of the documentation is based on the `Arc42 template `_. 15 | 16 | Together with the :ref:`C4` and the :ref:`ModuleDocumentation` it builds the developer documentation 17 | of **fotoobo**. 18 | 19 | .. toctree:: 20 | architecture/1_introduction_goals 21 | architecture/2_constraints 22 | architecture/3_context_scope 23 | architecture/4_solution_strategy 24 | architecture/5_building_block_view 25 | architecture/6_runtime_deployment_view 26 | architecture/7_crosscutting_concepts 27 | architecture/8_quality_requirements 28 | architecture/9_risks_technical_depths 29 | -------------------------------------------------------------------------------- /docs/source/developer/architecture/2_constraints.rst: -------------------------------------------------------------------------------- 1 | .. Chapter one according to https://arc42.org/overview 2 | 3 | .. _2Constraints: 4 | 5 | 2. Constraints 6 | ============== 7 | 8 | The Fortinet-APIs 9 | ----------------- 10 | 11 | Not all functionality that **fotoobo** offers or likes to offer is possible with the use of the 12 | *documented parts of the* API of the respective Fortinet product. Sometimes there is a possibility 13 | to use undocumented API calls learned by debugging the official web interface of the respective 14 | product. But these cases are risky because Fortinet does not necessarily keep backwards 15 | compatibility for these API calls and will also not document any changes. 16 | Other times we can not overcome the limitations and needed to use other ways to get the required 17 | information - historically we used SNMP for such a case which is now using the REST API again. 18 | 19 | This is another reason **fotoobo** does mostly just read, but not write to the APIs to prevent 20 | unintended side effects by using undocumented API parts. 21 | -------------------------------------------------------------------------------- /tests/test_fotoobo.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test fotoobo package. 3 | """ 4 | 5 | import re 6 | 7 | import pytest 8 | 9 | from fotoobo import __version__ 10 | from fotoobo.main import main 11 | 12 | 13 | def test_version() -> None: 14 | """ 15 | Testing the fotoobo version. 16 | 17 | The RegEx used for testing the semantic version string is very simplified here but it is 18 | enough for fotoobo. 19 | Detailed information on RegEx testing semantic versions can be found here: 20 | https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string 21 | """ 22 | 23 | # Act & Assert 24 | assert re.search(r"^[0-9]+\.[0-9]+\.[0-9]+", __version__) 25 | 26 | 27 | def test_main() -> None: 28 | """ 29 | Test the main() function of the fotoobo package. 30 | 31 | Calling the main() function directly has the same effect as calling the cli app without any 32 | command line arguments. So main() will always exit with sys.exit(2). 33 | """ 34 | 35 | # Act & Assert 36 | with pytest.raises(SystemExit): 37 | main() 38 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yaml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | # 5 | # To have an example on how to use this settings file with Poetry see: 6 | # https://docs.readthedocs.io/en/stable/build-customization.html#install-dependencies-with-poetry 7 | 8 | # Required 9 | version: 2 10 | 11 | # Set the version of Python and other tools you might need 12 | build: 13 | os: ubuntu-22.04 14 | tools: 15 | python: "3.12" 16 | jobs: 17 | post_create_environment: 18 | # Install poetry 19 | # https://python-poetry.org/docs/#installing-manually 20 | - pip install poetry 21 | post_install: 22 | # Install dependencies with 'docs' dependency group 23 | # https://python-poetry.org/docs/managing-dependencies/#dependency-groups 24 | # VIRTUAL_ENV needs to be set manually for now. 25 | # See https://github.com/readthedocs/readthedocs.org/pull/11152/ 26 | - VIRTUAL_ENV=$READTHEDOCS_VIRTUALENV_PATH poetry install --extras docs 27 | 28 | # Build documentation in the docs/source/ directory with Sphinx 29 | sphinx: 30 | configuration: docs/source/conf.py 31 | -------------------------------------------------------------------------------- /fotoobo/fortinet/fortianalyzer.py: -------------------------------------------------------------------------------- 1 | """ 2 | FortiAnalyzer Class 3 | """ 4 | 5 | import logging 6 | from typing import Any 7 | 8 | from .fortimanager import FortiManager 9 | 10 | log = logging.getLogger("fotoobo") 11 | 12 | 13 | class FortiAnalyzer(FortiManager): 14 | """ 15 | Represents one FortiAnalyzer (digital twin) 16 | 17 | This class is inherited from FortiManager. Only the type of the object differs. 18 | """ 19 | 20 | def __init__(self, hostname: str, username: str, password: str, **kwargs: Any) -> None: 21 | """ 22 | Set some initial parameters. 23 | 24 | Args: 25 | hostname: The hostname of the FortiGate to connect to 26 | username: Username 27 | password: Password 28 | **kwargs: See Fortinet class for available arguments 29 | """ 30 | super().__init__(hostname, username, password, **kwargs) 31 | self.type = "fortianalyzer" 32 | 33 | def get_version(self) -> str: 34 | """ 35 | Get FortiAnalyzer version 36 | 37 | Returns: 38 | FortiAnalyzer version 39 | """ 40 | faz_version = super().get_version() 41 | return faz_version 42 | -------------------------------------------------------------------------------- /fotoobo/cli/faz/get.py: -------------------------------------------------------------------------------- 1 | """ 2 | The FortiAnalyzer get commands 3 | """ 4 | 5 | import logging 6 | 7 | import typer 8 | from typing_extensions import Annotated 9 | 10 | from fotoobo.helpers import cli_path 11 | from fotoobo.tools import faz 12 | 13 | app = typer.Typer(no_args_is_help=True, rich_markup_mode="rich") 14 | log = logging.getLogger("fotoobo") 15 | 16 | 17 | @app.callback() 18 | def callback(context: typer.Context) -> None: 19 | """ 20 | The faz get subcommand callback 21 | 22 | Args: 23 | context: The context object of the typer app 24 | """ 25 | cli_path.append(str(context.invoked_subcommand)) 26 | log.debug("About to execute command: '%s'", context.invoked_subcommand) 27 | 28 | 29 | @app.command() 30 | def version( 31 | host: Annotated[ 32 | str, 33 | typer.Argument( 34 | help="The FortiAnalyzer hostname to access (must be defined in the inventory).", 35 | metavar="[host]", 36 | ), 37 | ] = "faz", 38 | ) -> None: 39 | """ 40 | Get the FortiAnalyzer version. 41 | """ 42 | result = faz.get.version(host) 43 | result.print_result_as_table( 44 | title="FortiAnalyzer Version", 45 | headers=["FortiAnalyzer", "Version"], 46 | ) 47 | -------------------------------------------------------------------------------- /tests/data/inventory.yaml: -------------------------------------------------------------------------------- 1 | globals: 2 | fortigate: 3 | https_port: 222 4 | 5 | forticloudasset: 6 | username: dummy_username 7 | password: dummy_password 8 | type: forticloudasset 9 | 10 | test_fgt_1: 11 | hostname: dummy 12 | ssl_verify: false 13 | https_port: 111 14 | token: dummy 15 | type: fortigate 16 | 17 | test_fgt_2: 18 | hostname: dummy 19 | token: dummy 20 | type: fortigate 21 | 22 | test_fgt_3: # no hostname specified 23 | token: dummy 24 | type: fortigate 25 | 26 | test_fgt_4: # no token specified 27 | hostname: dummy 28 | type: fortigate 29 | 30 | test_fgt_5: # no hostname and token specified 31 | type: fortigate 32 | 33 | test_ems: 34 | hostname: dummy 35 | username: dummy 36 | password: dummy 37 | type: forticlientems 38 | 39 | test_fmg: 40 | hostname: dummy 41 | username: dummy 42 | password: dummy 43 | type: fortimanager 44 | 45 | test_faz: 46 | hostname: dummy 47 | username: dummy 48 | password: dummy 49 | type: fortianalyzer 50 | 51 | test_ftp: 52 | hostname: dummy_hostname 53 | directory: dummy_directory 54 | username: dummy_username 55 | password: dummy_password 56 | type: ftp 57 | 58 | test_smtp: 59 | hostname: dummy_hostname 60 | recipient: fotoobo@local 61 | sender: fotoobo@local 62 | subject: fotoobo notification 63 | type: smtp 64 | -------------------------------------------------------------------------------- /fotoobo/helpers/cli.py: -------------------------------------------------------------------------------- 1 | """ 2 | Helper functions for testing the cli 3 | """ 4 | 5 | from typing import Any 6 | 7 | from rich.text import Text 8 | from rich.tree import Tree 9 | 10 | 11 | def walk_cli_info(info: dict[str, Any], tree: Tree, command_path: list[str] | None = None) -> Tree: 12 | """ 13 | Recursively create the cli command tree from a Typer info dict. 14 | 15 | Args: 16 | info: The Typer info dict from Typer.Context.to_info_dict() to walk through 17 | tree: The rich tree object to build 18 | command_path: The command path as a list 19 | 20 | Returns: 21 | The complete rich Tree object 22 | """ 23 | command_path = command_path or [] 24 | commands = info["commands"] 25 | 26 | for command in sorted(commands): 27 | if commands[command]["hidden"]: 28 | continue 29 | 30 | text = Text(overflow="ellipsis", no_wrap=True) 31 | text.append(commands[command]["name"], style="bold cyan") 32 | text.append(" " * max((25 - len(command_path) * 4 - len(command)), 2)) 33 | text.append(commands[command]["help"].split("\n")[0], style="grey53") 34 | branch = tree.add(text) 35 | 36 | if "commands" in commands[command]: 37 | walk_cli_info(commands[command], branch, command_path + [command]) 38 | 39 | return tree 40 | -------------------------------------------------------------------------------- /tests/tools/fmg/test_post.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test fmg tools post. 3 | """ 4 | 5 | from pathlib import Path 6 | from unittest.mock import Mock 7 | 8 | import pytest 9 | from pytest import MonkeyPatch 10 | 11 | from fotoobo.exceptions import GeneralWarning 12 | from fotoobo.tools.fmg import post 13 | 14 | 15 | def test_post(monkeypatch: MonkeyPatch) -> None: 16 | """ 17 | Test POST. 18 | """ 19 | 20 | # Arrange 21 | monkeypatch.setattr( 22 | "fotoobo.tools.fmg.main.load_json_file", Mock(return_value={"dummy": "dummy"}) 23 | ) 24 | monkeypatch.setattr( 25 | "fotoobo.fortinet.fortimanager.FortiManager.post", Mock(return_value=["dummy_message"]) 26 | ) 27 | 28 | # Act 29 | result = post(file=Path("dummy_file"), adom="dummy_adom", host="test_fmg") 30 | 31 | # Assert 32 | assert result.get_messages("test_fmg")[0]["message"] == "dummy_message" 33 | 34 | 35 | def test_post_exception_empty_payload_file(monkeypatch: MonkeyPatch) -> None: 36 | """ 37 | Test POST with exception when there is no data in the payload file. 38 | """ 39 | 40 | # Arrange 41 | monkeypatch.setattr("fotoobo.tools.fmg.main.load_json_file", Mock(return_value=[])) 42 | 43 | # Act & Assert 44 | with pytest.raises(GeneralWarning, match=r"There is no data in the given file"): 45 | post(file=Path("dummy_file"), adom="dummy_adom", host="dummy_host") 46 | -------------------------------------------------------------------------------- /fotoobo/tools/convert.py: -------------------------------------------------------------------------------- 1 | """ 2 | The fotoobo converter utility 3 | """ 4 | 5 | import logging 6 | from pathlib import Path 7 | from typing import Any 8 | 9 | from fotoobo.fortinet.convert import CheckpointConverter 10 | from fotoobo.helpers.files import create_dir 11 | from fotoobo.helpers.result import Result 12 | 13 | log = logging.getLogger("fotoobo") 14 | 15 | 16 | def checkpoint( 17 | checkpoint_assets: list[Any] | dict[str, Any] | None, 18 | obj_type: str, 19 | conversion_id: str, 20 | cache_dir: Path | None = None, 21 | ) -> Result[list[Any]]: 22 | """ 23 | This function lets you convert Checkpoint assets into Fortinet syntax. 24 | 25 | Args: 26 | checkpoint_assets: The assets to convert 27 | obj_type: The type of asset to convert 28 | conversion_id: An ID to the conversion. Needed to cache different conversion tasks 29 | cache_dir: The cache directory to use. Defaults to None. 30 | """ 31 | log.info("Start converting checkpoint assets of type '%s'", obj_type) 32 | result = Result[list[Any]]() 33 | 34 | if cache_dir: 35 | create_dir(cache_dir) 36 | cache_file = Path(cache_dir, conversion_id) 37 | 38 | else: 39 | cache_file = None 40 | 41 | converter = CheckpointConverter(checkpoint_assets, cache_file) 42 | result.push_result("fortinet_assets", converter.convert(obj_type)) 43 | return result 44 | -------------------------------------------------------------------------------- /tests/data/fortigate_config_single.conf: -------------------------------------------------------------------------------- 1 | #config-version=FGT999-9.9.9-FW-build8303-210217:opmode=0:vdom=0:user=pi 2 | #conf_file_ver=84659144068220130 3 | #buildno=8303 4 | #global_vdom=1 5 | config system global 6 | set option_1 value_1 7 | set option_2 "value_2" 8 | set option_3 3 9 | end 10 | config system vdom_setting 11 | set option_1 value_1 12 | end 13 | config leaf_1 14 | set option_1 value_1 15 | set option_2 "This is a multiline 16 | string which ends three lines after the 17 | start of the line. Enjoy testing" 18 | end 19 | config leaf_2 20 | set option_1 value_1 21 | end 22 | config leaf_11 leaf_12 23 | set option_1 value_1 24 | end 25 | config leaf_21 leaf_22 leaf_23 26 | set option_1 value_1 27 | end 28 | config leaf_31 leaf_32 leaf_33 leaf_34 29 | set option_1 value_1 30 | end 31 | config leaf_41 leaf_42 leaf_43 leaf_44 leaf_45 32 | set option_1 value_1 33 | end 34 | config leaf_81 35 | config leaf_82 36 | edit 1 37 | set option_1 value_1 38 | next 39 | edit 2 40 | set option_1 value_1 41 | next 42 | end 43 | config leaf_83 44 | edit "name_1" 45 | set option_1 value_1 46 | next 47 | edit "name_2" 48 | set option_1 value_1 49 | next 50 | end 51 | end 52 | config leaf_n 53 | set option_n value_n 54 | end 55 | config leaf_z 56 | set option_z value_z 57 | end 58 | -------------------------------------------------------------------------------- /tests/helpers/test_output.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test the output helper. 3 | """ 4 | 5 | from pathlib import Path 6 | from typing import Any 7 | 8 | import pytest 9 | 10 | from fotoobo.helpers.output import ( 11 | print_logo, 12 | write_policy_to_html, 13 | ) 14 | 15 | 16 | @pytest.fixture 17 | def html_test_file(function_dir: str) -> Path: 18 | """ 19 | Returns the filename of a json test file. 20 | """ 21 | 22 | return Path(function_dir) / "test_file.html" 23 | 24 | 25 | def test_print_logo(capsys: Any) -> None: 26 | """ 27 | Test print_logo. 28 | """ 29 | 30 | # Act 31 | print_logo() 32 | 33 | # Assert 34 | out, _ = capsys.readouterr() 35 | assert "f o t o o b o" in out 36 | 37 | 38 | def test_write_policy_to_html(html_test_file: Path) -> None: # pylint: disable=redefined-outer-name 39 | """ 40 | Test write_policy_to_html. 41 | """ 42 | 43 | # Arrange 44 | assert not html_test_file.is_file() 45 | 46 | # Act 47 | write_policy_to_html( 48 | [ 49 | {"h1": "h1", "h2": "h2", "h3": "h3"}, 50 | {"global-label": "dummy", "groups": "dummy"}, 51 | {"_hitcount": 0, "status": 0, "_last_hit": 0, "action": 0, "send-deny-packet": 0}, 52 | {"_hitcount": 1, "status": 1, "_last_hit": 1, "action": 1}, 53 | ], 54 | html_test_file, 55 | ) 56 | 57 | # Assert 58 | assert html_test_file.is_file() 59 | -------------------------------------------------------------------------------- /tests/tools/fmg/test_assign.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test fmg tools assign. 3 | """ 4 | 5 | from unittest.mock import Mock 6 | 7 | from pytest import MonkeyPatch 8 | 9 | from fotoobo.tools.fmg import assign 10 | 11 | 12 | def test_assign(monkeypatch: MonkeyPatch) -> None: 13 | """ 14 | Test assign. 15 | """ 16 | 17 | # Arrange 18 | monkeypatch.setattr( 19 | "fotoobo.fortinet.fortimanager.FortiManager.assign_all_objects", Mock(return_value=1) 20 | ) 21 | monkeypatch.setattr( 22 | "fotoobo.fortinet.fortimanager.FortiManager.wait_for_task", 23 | Mock( 24 | return_value=( 25 | [ 26 | { 27 | "name": "dummy", 28 | "state": 4, 29 | "task_id": 42, 30 | "detail": "dummy_detail", 31 | "start_tm": 10, 32 | "end_tm": 20, 33 | "history": [{"detail": "dummy_history"}], 34 | } 35 | ] 36 | ) 37 | ), 38 | ) 39 | 40 | # Act 41 | result = assign("dummy_adoms", "dummy_policy", "test_fmg") 42 | 43 | # Assert 44 | messages = result.get_messages("test_fmg") 45 | assert len(messages) == 2 46 | assert messages[0]["level"] == "debug" 47 | assert messages[0]["message"] == "42: dummy / dummy_detail (10 sec)" 48 | assert messages[1]["message"] == "- dummy_history" 49 | -------------------------------------------------------------------------------- /fotoobo/tools/greet.py: -------------------------------------------------------------------------------- 1 | """ 2 | The fotoobo greeting utility 3 | 4 | This function is a hidden fotoobo command which is meant for testing purposes. It has no functional 5 | effect. Use and modify it whenever you want. But be sure to also write the tests for it. 6 | """ 7 | 8 | import logging 9 | 10 | import typer 11 | 12 | log = logging.getLogger("fotoobo") 13 | 14 | 15 | def greet(name: str, bye: bool, log_enabled: bool) -> None: 16 | """ 17 | This is the hidden Greeting function. 18 | It allows you to greet someone with different colors in different languages. 19 | 20 | Args: 21 | name: 22 | bye: 23 | log_enabled: 24 | """ 25 | log.debug("Local logging option is: '%s'", log_enabled) 26 | greeting = f"{typer.style('🌼Aloha🌼', fg=typer.colors.GREEN)}" 27 | greeting += f", {typer.style('⚽Hola⚽', fg=typer.colors.YELLOW)}" 28 | greeting += f", {typer.style('✨Bonjour✨', fg=typer.colors.BLUE)}" 29 | greeting += f", {typer.style('⚡Hallo⚡', fg=typer.colors.RED)}" 30 | greeting += f", {typer.style('☀Ciao☀', fg=typer.colors.BRIGHT_GREEN)}" 31 | greeting += f", {typer.style('🌟Konnichiwa🌟', fg=typer.colors.BRIGHT_YELLOW)}" 32 | greeting += f", {typer.style('🎉Howdy-doody🎉', fg=typer.colors.BRIGHT_BLUE)}!" 33 | 34 | if name: 35 | greeting = f"Hi {typer.style(name, bold=True)}, {greeting}" 36 | 37 | typer.echo(greeting) 38 | 39 | if bye: 40 | typer.echo(typer.style("Good Bye", fg=typer.colors.BLACK, bg=typer.colors.WHITE)) 41 | -------------------------------------------------------------------------------- /fotoobo/main.py: -------------------------------------------------------------------------------- 1 | """ 2 | This is the main project entry point. 3 | 4 | When invoking fotoobo it starts the main() function in this file. Its main purpose is to start the 5 | typer cli with app(). 6 | The second task is to catch all exceptions and print a friendly message on the screen instead of 7 | a traceback. The traceback is written to a traceback.log file in the local directory for debug 8 | purposes. 9 | 10 | The exceptions in the main functions cannot be tested because the test-method for typer always 11 | exits with its own exit codes (1 or 2) when an exception rises. 12 | """ 13 | 14 | import sys 15 | import traceback 16 | 17 | from fotoobo.cli.main import app 18 | from fotoobo.exceptions import APIError, GeneralError, GeneralWarning 19 | 20 | 21 | def main() -> None: 22 | """ 23 | This is the main function 24 | """ 25 | try: 26 | app() 27 | 28 | except GeneralWarning as warn: # pragma: no cover 29 | print(f"Warning: {warn.message}") 30 | sys.exit(30) 31 | 32 | except (GeneralError, APIError) as err: # pragma: no cover 33 | print(f"Error: {err.message}") 34 | sys.exit(40) 35 | 36 | except Exception: # pylint: disable=broad-except # pragma: no cover 37 | print("oops, something did not work as expected. See traceback.log for more info") 38 | with open("traceback.log", "w", encoding="UTF-8") as exc_file: 39 | traceback.print_exc(file=exc_file) 40 | sys.exit(50) 41 | 42 | 43 | if __name__ == "__main__": # pragma: no cover 44 | main() 45 | -------------------------------------------------------------------------------- /tests/tools/fgt/config/test_info.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test fgt tools config info. 3 | """ 4 | 5 | from pathlib import Path 6 | 7 | import pytest 8 | 9 | from fotoobo.exceptions.exceptions import GeneralWarning 10 | from fotoobo.tools.fgt.config import info 11 | 12 | 13 | @pytest.mark.parametrize( 14 | "file", 15 | ( 16 | pytest.param(Path("tests/data/fortigate_config_single.conf"), id="single"), 17 | pytest.param(Path("tests/data/fortigate_config_vdom.conf"), id="vdom"), 18 | ), 19 | ) 20 | def test_info(file: Path) -> None: 21 | """ 22 | Test the info utility. 23 | """ 24 | 25 | # Act 26 | infos = info(file) 27 | 28 | # Assert 29 | assert infos.get_result("HOSTNAME UNKNOWN").buildno == "8303" 30 | 31 | 32 | @pytest.mark.parametrize( 33 | "file", 34 | ( 35 | pytest.param(Path("tests/data/fortigate_config_empty.conf"), id="single"), 36 | pytest.param(Path("tests/data/"), id="vdom"), 37 | ), 38 | ) 39 | def test_info_empty(file: Path) -> None: 40 | """ 41 | Test the info utility with directory and empty configuration file. 42 | """ 43 | 44 | # Act & Assert 45 | with pytest.raises(GeneralWarning, match=r"There is no info in"): 46 | info(file) 47 | 48 | 49 | def test_info_no_files_in_dir() -> None: 50 | """ 51 | Test the info utility with directory and empty configuration file. 52 | """ 53 | 54 | # Act & Assert 55 | with pytest.raises(GeneralWarning, match=r"There are no configuration files"): 56 | info(Path("tests/")) 57 | -------------------------------------------------------------------------------- /fotoobo/tools/fgt/cmdb/firewall/addrgrp.py: -------------------------------------------------------------------------------- 1 | """ 2 | FortiGate CMDB firewall addrgrp module 3 | """ 4 | 5 | from pathlib import Path 6 | from typing import Any 7 | 8 | from fotoobo.fortinet.fortigate import FortiGate 9 | from fotoobo.helpers.config import config 10 | from fotoobo.helpers.result import Result 11 | from fotoobo.inventory import Inventory 12 | 13 | 14 | def get_cmdb_firewall_addrgrp( 15 | host: str, name: str, vdom: str, output_file: str | None 16 | ) -> Result[list[Any]]: 17 | """ 18 | Get the firewall address group object(s). 19 | 20 | The FortiGate api endpoint is: /cmdb/firewall/addrgrp 21 | """ 22 | inventory = Inventory(config.inventory_file) 23 | fgt: FortiGate = inventory.get_item(host, "fortigate") 24 | result = Result[list[Any]]() 25 | 26 | addrgrp_list = fgt.api_get(url=f"/cmdb/firewall/addrgrp/{name}", vdom=vdom) 27 | 28 | if output_file: 29 | result.push_result(key=host, data=addrgrp_list) 30 | result.save_raw(file=Path(output_file), key=host) 31 | 32 | assets = [] 33 | if addrgrp_list: 34 | for vd in addrgrp_list: 35 | for asset in vd["results"]: 36 | # print(asset) 37 | data: dict[str, str] = { 38 | "name": asset["name"], 39 | "vdom": vd["vdom"], 40 | "content": "\n".join(_["name"] for _ in asset["member"]), 41 | } 42 | 43 | assets.append(data) 44 | 45 | result.push_result(host, assets) 46 | 47 | return result 48 | -------------------------------------------------------------------------------- /fotoobo/cli/fgt/get/get.py: -------------------------------------------------------------------------------- 1 | """ 2 | The FortiGate commands 3 | """ 4 | 5 | import logging 6 | 7 | import typer 8 | from typing_extensions import Annotated 9 | 10 | from fotoobo.helpers import cli_path 11 | from fotoobo.tools import fgt 12 | 13 | from .cmdb import cmdb 14 | 15 | app = typer.Typer(no_args_is_help=True, rich_markup_mode="rich") 16 | log = logging.getLogger("fotoobo") 17 | 18 | 19 | app.add_typer(cmdb.app, name="cmdb", help="FortiGate get cmdb commands.") 20 | 21 | 22 | @app.callback() 23 | def callback(context: typer.Context) -> None: 24 | """ 25 | The fgt get subcommand callback 26 | 27 | Args: 28 | context: The context object of the typer app 29 | """ 30 | cli_path.append(str(context.invoked_subcommand)) 31 | log.debug("About to execute command: '%s'", context.invoked_subcommand) 32 | 33 | 34 | @app.command() 35 | def version( 36 | host: Annotated[ 37 | str | None, 38 | typer.Argument( 39 | help="The FortiGate hostname to access (must be defined in the inventory). ", 40 | metavar="[host]", 41 | ), 42 | ] = None, 43 | ) -> None: 44 | """ 45 | Get the FortiGate(s) version(s). 46 | 47 | The optional argument \\[host] makes this command somewhat magic. If you omit \\[host] it 48 | searches for all devices of type 'fortigate' in the inventory and tries to get their FortiOS 49 | version. 50 | """ 51 | result = fgt.get.version(host) 52 | result.print_result_as_table(title="FortiGate Versions", headers=["FortiGate", "Version"]) 53 | -------------------------------------------------------------------------------- /fotoobo/tools/fgt/cmdb/firewall/service_group.py: -------------------------------------------------------------------------------- 1 | """ 2 | FortiGate CMDB firewall service group module 3 | """ 4 | 5 | from pathlib import Path 6 | from typing import Any 7 | 8 | from fotoobo.fortinet.fortigate import FortiGate 9 | from fotoobo.helpers.config import config 10 | from fotoobo.helpers.result import Result 11 | from fotoobo.inventory import Inventory 12 | 13 | 14 | def get_cmdb_firewall_service_group( 15 | host: str, name: str, vdom: str, output_file: str | None 16 | ) -> Result[list[Any]]: 17 | """ 18 | Get the firewall service group object(s). 19 | 20 | The FortiGate api endpoint is: /cmdb/firewall.service/group 21 | """ 22 | inventory = Inventory(config.inventory_file) 23 | fgt: FortiGate = inventory.get_item(host, "fortigate") 24 | result = Result[list[Any]]() 25 | 26 | service_group_list = fgt.api_get(url=f"/cmdb/firewall/addrgrp/{name}", vdom=vdom) 27 | 28 | if output_file: 29 | result.push_result(key=host, data=service_group_list) 30 | result.save_raw(file=Path(output_file), key=host) 31 | 32 | assets = [] 33 | if service_group_list: 34 | for vd in service_group_list: 35 | for asset in vd["results"]: 36 | data: dict[str, str] = { 37 | "name": asset["name"], 38 | "vdom": vd["vdom"], 39 | "content": "\n".join(_["name"] for _ in asset["member"]), 40 | } 41 | 42 | assets.append(data) 43 | 44 | result.push_result(host, assets) 45 | 46 | return result 47 | -------------------------------------------------------------------------------- /tests/tools/fgt/cmdb/firewall/test_addrgrp.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test fgt cmdb firewall address group. 3 | """ 4 | 5 | # mypy: disable-error-code=attr-defined 6 | 7 | from pathlib import Path 8 | from unittest.mock import Mock 9 | 10 | from pytest import MonkeyPatch 11 | 12 | from fotoobo.tools.fgt.cmdb.firewall import get_cmdb_firewall_addrgrp 13 | 14 | 15 | def test_get_cmdb_firewall_addrgrp(monkeypatch: MonkeyPatch) -> None: 16 | """ 17 | Test the get cmdb firewall address group method. 18 | """ 19 | 20 | # Arrange 21 | result_mock = [ 22 | { 23 | "results": [ 24 | {"name": "group_1", "member": [{"name": "member_1"}, {"name": "member_2"}]}, 25 | {"name": "group_2", "member": [{"name": "member_3"}, {"name": "member_4"}]}, 26 | ], 27 | "vdom": "vdom_1", 28 | } 29 | ] 30 | api_get_mock = Mock(return_value=result_mock) 31 | monkeypatch.setattr("fotoobo.fortinet.fortigate.FortiGate.api_get", api_get_mock) 32 | save_raw_mock = Mock(return_value=True) 33 | monkeypatch.setattr("fotoobo.helpers.result.Result.save_raw", save_raw_mock) 34 | 35 | # Act 36 | result = get_cmdb_firewall_addrgrp("test_fgt_1", "", "", "test.json") 37 | 38 | # Assert 39 | data = result.get_result("test_fgt_1") 40 | assert len(data) == 2 41 | assert data[0]["content"] == "member_1\nmember_2" 42 | assert data[1]["content"] == "member_3\nmember_4" 43 | api_get_mock.assert_called_with(url="/cmdb/firewall/addrgrp/", vdom="") 44 | save_raw_mock.assert_called_with(file=Path("test.json"), key="test_fgt_1") 45 | -------------------------------------------------------------------------------- /tests/tools/fgt/cmdb/firewall/test_service_group.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test fgt cmdb firewall service group. 3 | """ 4 | 5 | # mypy: disable-error-code=attr-defined 6 | 7 | from pathlib import Path 8 | from unittest.mock import Mock 9 | 10 | from pytest import MonkeyPatch 11 | 12 | from fotoobo.tools.fgt.cmdb.firewall import get_cmdb_firewall_service_group 13 | 14 | 15 | def test_get_cmdb_firewall_addrgrp(monkeypatch: MonkeyPatch) -> None: 16 | """ 17 | Test the get cmdb firewall service group method. 18 | """ 19 | 20 | # Arrange 21 | result_mock = [ 22 | { 23 | "results": [ 24 | {"name": "group_1", "member": [{"name": "member_1"}, {"name": "member_2"}]}, 25 | {"name": "group_2", "member": [{"name": "member_3"}, {"name": "member_4"}]}, 26 | ], 27 | "vdom": "vdom_1", 28 | } 29 | ] 30 | api_get_mock = Mock(return_value=result_mock) 31 | monkeypatch.setattr("fotoobo.fortinet.fortigate.FortiGate.api_get", api_get_mock) 32 | save_raw_mock = Mock(return_value=True) 33 | monkeypatch.setattr("fotoobo.helpers.result.Result.save_raw", save_raw_mock) 34 | 35 | # Act 36 | result = get_cmdb_firewall_service_group("test_fgt_1", "", "", "test.json") 37 | 38 | # Assert 39 | data = result.get_result("test_fgt_1") 40 | assert len(data) == 2 41 | assert data[0]["content"] == "member_1\nmember_2" 42 | assert data[1]["content"] == "member_3\nmember_4" 43 | api_get_mock.assert_called_with(url="/cmdb/firewall/addrgrp/", vdom="") 44 | save_raw_mock.assert_called_with(file=Path("test.json"), key="test_fgt_1") 45 | -------------------------------------------------------------------------------- /tests/cli/fgt/test_monitor.py: -------------------------------------------------------------------------------- 1 | """ 2 | Testing the cli fgt monitor. 3 | """ 4 | 5 | from typer.testing import CliRunner 6 | 7 | from fotoobo.cli.main import app 8 | from tests.helper import parse_help_output 9 | 10 | runner = CliRunner() 11 | 12 | 13 | def test_cli_app_fgt_monitor_help(help_args_with_none: str) -> None: 14 | """ 15 | Test cli help for fgt monitor. 16 | """ 17 | 18 | # Arrange 19 | args = ["-c", "tests/fotoobo.yaml", "fgt", "monitor"] 20 | args.append(help_args_with_none) 21 | args = list(filter(None, args)) 22 | 23 | # Act 24 | result = runner.invoke(app, args) 25 | 26 | # Assert 27 | assert result.exit_code in [0, 2] 28 | arguments, options, commands = parse_help_output(result.stdout) 29 | assert not arguments 30 | assert options == {"-h", "--help"} 31 | assert set(commands) == {"hamaster"} 32 | 33 | 34 | def test_cli_app_fgt_monitor_hamaster_help(help_args: str) -> None: 35 | """ 36 | Test cli help for fgt monitor hamaster. 37 | """ 38 | 39 | # Arrange 40 | args = ["-c", "tests/fotoobo.yaml", "fgt", "monitor", "hamaster"] 41 | args.append(help_args) 42 | 43 | # Act 44 | result = runner.invoke(app, args) 45 | 46 | # Assert 47 | assert result.exit_code == 0 48 | arguments, options, commands = parse_help_output(result.stdout) 49 | assert set(arguments) == {"host"} 50 | assert options == { 51 | "-h", 52 | "--help", 53 | "-o", 54 | "--output", 55 | "-r", 56 | "--raw", 57 | "--smtp", 58 | "-t", 59 | "--template", 60 | } 61 | assert not commands 62 | -------------------------------------------------------------------------------- /docs/source/developer/architecture/3_context_scope.rst: -------------------------------------------------------------------------------- 1 | .. Chapter three according to https://arc42.org/overview 2 | 3 | .. _ContextScope: 4 | 5 | 3. Context & Scope 6 | ================== 7 | 8 | Context 9 | ------- 10 | 11 | **fotoobo** communicates with different Fortinet products on one side and other devices like 12 | SFTP and SMTP servers on the other side. 13 | 14 | .. image:: diagrams/fotoobo_context.drawio.svg 15 | :width: 100% 16 | :alt: The fotoobo context visualized with FortiGate, FortiManager, FortiAnalyzer and FortiClient 17 | EMS on one side and (S)FTP-, SMTP- and Syslog server on the other. 18 | 19 | The main output formats are console or files written to the file system. 20 | 21 | 22 | Scope 23 | ----- 24 | 25 | Fortinet products are used worldwide and by a large number of companies. There is already a lot of 26 | tooling to support Fortinet devices. **fotoobo** aims to fill a gap in this tooling support and 27 | solve some common tasks in a simple way. 28 | 29 | Based on this the scope of **fotoobo** is defined as follows: 30 | 31 | - Get, enrich and summarize information from one or more Fortinet product(s), that is otherwise not 32 | easily available 33 | 34 | - for presentation on console 35 | - for further use as raw JSON output (for a monitoring system for example) 36 | - to render any Jinja2 template based on it (for HTML-Reports or config input to other systems 37 | for example) 38 | 39 | - **fotoobo** will basically only *read* from the respective Fortinet devices. Write operations 40 | should be done with the `Fortinet ansible Module `_ 41 | or by using custom scripts. 42 | 43 | 44 | -------------------------------------------------------------------------------- /docs/source/code.rst: -------------------------------------------------------------------------------- 1 | .. Here we add the modules which should be documented with autodoc 2 | 3 | .. _ModuleDocumentation: 4 | 5 | fotoobo Module Documentation 6 | ============================ 7 | 8 | 9 | Exceptions 10 | ---------- 11 | 12 | .. automodule:: fotoobo.exceptions.exceptions 13 | :members: 14 | 15 | 16 | .. _auto_fortinet_classes: 17 | 18 | 19 | Fortinet Classes 20 | ---------------- 21 | 22 | :ref:`fortinet_classes` 23 | 24 | .. autoclass:: fotoobo.fortinet.fortinet.Fortinet 25 | :members: 26 | 27 | .. autoclass:: fotoobo.fortinet.fortianalyzer.FortiAnalyzer 28 | :members: 29 | 30 | .. autoclass:: fotoobo.fortinet.forticlientems.FortiClientEMS 31 | :members: 32 | 33 | .. autoclass:: fotoobo.fortinet.forticloudasset.FortiCloudAsset 34 | :members: 35 | 36 | .. autoclass:: fotoobo.fortinet.fortigate.FortiGate 37 | :members: 38 | 39 | .. autoclass:: fotoobo.fortinet.fortimanager.FortiManager 40 | :members: 41 | 42 | Inventory 43 | --------- 44 | 45 | .. autoclass:: fotoobo.inventory.inventory.Inventory 46 | :members: 47 | 48 | .. autoclass:: fotoobo.inventory.generic.GenericDevice 49 | :members: 50 | 51 | 52 | Helpers 53 | ------- 54 | 55 | cli 56 | ^^^ 57 | 58 | .. automodule:: fotoobo.helpers.cli 59 | :members: 60 | 61 | config 62 | ^^^^^^ 63 | 64 | .. automodule:: fotoobo.helpers.config 65 | :members: 66 | 67 | files 68 | ^^^^^ 69 | 70 | .. automodule:: fotoobo.helpers.files 71 | :members: 72 | 73 | log 74 | ^^^ 75 | 76 | .. automodule:: fotoobo.helpers.log 77 | :members: 78 | 79 | output 80 | ^^^^^^ 81 | 82 | .. automodule:: fotoobo.helpers.output 83 | :members: 84 | 85 | result 86 | ^^^^^^ 87 | 88 | :ref:`how_to_helpers_result` 89 | 90 | .. automodule:: fotoobo.helpers.result 91 | :members: 92 | -------------------------------------------------------------------------------- /fotoobo/tools/ems/get.py: -------------------------------------------------------------------------------- 1 | """ 2 | FortiClient EMS get module 3 | """ 4 | 5 | import logging 6 | 7 | from fotoobo.fortinet.forticlientems import FortiClientEMS 8 | from fotoobo.helpers.config import config 9 | from fotoobo.helpers.result import Result 10 | from fotoobo.inventory import Inventory 11 | 12 | log = logging.getLogger("fotoobo") 13 | 14 | 15 | def version(host: str) -> Result[str]: 16 | """ 17 | ems get version 18 | 19 | Args: 20 | host: Host defined in inventory 21 | 22 | Returns: 23 | Version data in a Result object 24 | """ 25 | result = Result[str]() 26 | inventory = Inventory(config.inventory_file) 27 | ems: FortiClientEMS = inventory.get_item(host, "forticlientems") 28 | log.debug("FortiClient EMS get version ...") 29 | ems.login() 30 | ems_version = ems.get_version() 31 | result.push_result(host, f"v{ems_version}") 32 | 33 | return result 34 | 35 | 36 | def workgroups(host: str, custom: bool = False) -> Result[dict[str, str]]: 37 | """ 38 | ems get workgroups 39 | 40 | Args: 41 | host: Host defined in inventory 42 | custom: If true it only returns custom groups 43 | 44 | Returns: 45 | Workgroups data in a Results object with keys: id, total_devices 46 | """ 47 | result = Result[dict[str, str]]() 48 | inventory = Inventory(config.inventory_file) 49 | ems: FortiClientEMS = inventory.get_item(host, "forticlientems") 50 | log.debug("FortiClient EMS get workgroups ...") 51 | ems.login() 52 | raw_data = ems.api("get", f"/workgroups/index?custom={custom}").json()["data"] 53 | for entry in raw_data: 54 | result.push_result(entry["name"], {"id": entry["id"], "count": entry["total_devices"]}) 55 | 56 | return result 57 | -------------------------------------------------------------------------------- /docs/source/usage/import_fotoobo.rst: -------------------------------------------------------------------------------- 1 | .. Describes the prerequisites and installation of fotoobo 2 | 3 | .. _import_fotoobo: 4 | 5 | Importing fotoobo 6 | ================= 7 | 8 | Although **fotoobo** is meant to be used as a CLI application you may also import it into your own 9 | Python modules. With this you can write your own business logic without changing the **fotoobo** 10 | code itself. 11 | 12 | First install **fotoobo** as documented in :ref:`usage_getting_started`: 13 | 14 | .. code-block:: bash 15 | 16 | pip install fotoobo 17 | 18 | After successful installation you may import the desired module into your code. Refer to the 19 | :ref:`auto_fortinet_classes` to get a list of available module parameters. 20 | 21 | 22 | Examples 23 | -------- 24 | 25 | FortiGate 26 | ^^^^^^^^^ 27 | 28 | .. code-block:: python 29 | 30 | from fotoobo import FortiGate 31 | fgt = FortiGate("", "", https_port=8443, ssl_verify=False) 32 | print(fgt.get_version()) 33 | 34 | FortiManager 35 | ^^^^^^^^^^^^ 36 | 37 | .. code-block:: python 38 | 39 | from fotoobo import FortiManager 40 | fmg = FortiManager("", "", "", ssl_verify=False) 41 | print(fmg.get_version()) 42 | fmg.logout() 43 | 44 | FortiAnalyzer 45 | ^^^^^^^^^^^^^ 46 | 47 | .. code-block:: python 48 | 49 | from fotoobo import FortiAnalyzer 50 | faz = FortiAnalyzer("", "", "", ssl_verify=False) 51 | print(faz.get_version()) 52 | faz.logout() 53 | 54 | FortiClient EMS 55 | ^^^^^^^^^^^^^^^ 56 | 57 | .. code-block:: python 58 | 59 | from fotoobo import FortiClientEMS 60 | ems = FortiClientEMS("", "", "", ssl_verify=False) 61 | ems.login() 62 | print(ems.get_version()) 63 | ems.logout() 64 | 65 | -------------------------------------------------------------------------------- /tests/data/fortigate_config_vdom.conf: -------------------------------------------------------------------------------- 1 | #config-version=FGT999-9.9.9-FW-build8303-210217:opmode=0:vdom=1:user=pi 2 | #conf_file_ver=84659144068220130 3 | #buildno=8303 4 | #global_vdom=1 5 | 6 | config vdom 7 | edit root 8 | next 9 | edit vdom_n 10 | next 11 | edit vdom_z 12 | next 13 | end 14 | 15 | config global 16 | config system global 17 | set option_1 value_1 18 | set option_2 "value_2" 19 | set option_3 3 20 | end 21 | end 22 | 23 | config vdom 24 | edit root 25 | config system vdom_setting 26 | set option_1 value_1 27 | end 28 | config leaf_1 29 | set option_1 value_1 30 | set option_2 "This is a multiline 31 | string which ends three lines after the 32 | start of the line. Enjoy testing" 33 | end 34 | config leaf_2 35 | set option_1 value_1 36 | end 37 | config leaf_11 leaf_12 38 | set option_1 value_1 39 | end 40 | config leaf_21 leaf_22 leaf_23 41 | set option_1 value_1 42 | end 43 | config leaf_31 leaf_32 leaf_33 leaf_34 44 | set option_1 value_1 45 | end 46 | config leaf_41 leaf_42 leaf_43 leaf_44 leaf_45 47 | set option_1 value_1 48 | end 49 | config leaf_81 50 | config leaf_82 51 | edit 1 52 | set option_1 value_1 53 | next 54 | edit 2 55 | set option_1 value_1 56 | next 57 | end 58 | config leaf_83 59 | edit "name_1" 60 | set option_1 value_1 61 | next 62 | edit "name_2" 63 | set option_1 value_1 64 | next 65 | end 66 | end 67 | end 68 | 69 | config vdom 70 | edit vdom_n 71 | config system vdom_setting 72 | set option_1 value_1 73 | end 74 | config leaf_n 75 | set option_n value_n 76 | end 77 | end 78 | 79 | config vdom 80 | edit vdom_z 81 | config system vdom_setting 82 | set option_1 value_1 83 | end 84 | config leaf_z 85 | set option_z value_z 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /fotoobo/tools/cloud/asset/get.py: -------------------------------------------------------------------------------- 1 | """ 2 | FortiCloud cloud asset get utility 3 | """ 4 | 5 | import logging 6 | from typing import Any 7 | 8 | from fotoobo.fortinet.forticloudasset import FortiCloudAsset 9 | from fotoobo.helpers.config import config 10 | from fotoobo.helpers.result import Result 11 | from fotoobo.inventory import Inventory 12 | 13 | log = logging.getLogger("fotoobo") 14 | 15 | 16 | def products(host: str) -> Result[list[dict[str, Any]]]: 17 | """ 18 | FortiCloud API get products from Asset Management 19 | 20 | Here we assume that just one page is returned. If it happens that more that one page is returned 21 | pagination has to be implemented. 22 | 23 | Args: 24 | host: Host defined in inventory 25 | 26 | Returns: 27 | The asset list as a result object 28 | """ 29 | result = Result[list[dict[str, Any]]]() 30 | inventory = Inventory(config.inventory_file) 31 | fc: FortiCloudAsset = inventory.get_item(host, "forticloudasset") 32 | log.debug("FortiCloud get assets ...") 33 | response = fc.post("/products/list", payload={"serialNumber": "%"}) 34 | if response["error"]: 35 | result.push_message(host, response["message"], "info") 36 | 37 | else: 38 | result.push_result(host, response["assets"]) 39 | 40 | return result 41 | 42 | 43 | def version(host: str) -> Result[str]: 44 | """ 45 | FortiCloud API get version 46 | 47 | Args: 48 | host: Host defined in inventory 49 | 50 | Returns: 51 | The version string for the FortiCloud API in a result object 52 | """ 53 | result = Result[str]() 54 | inventory = Inventory(config.inventory_file) 55 | fcasset: FortiCloudAsset = inventory.get_item(host, "forticloudasset") 56 | log.debug("FortiCloud Asset Management get version ...") 57 | result.push_result(host, fcasset.get_version()) 58 | 59 | return result 60 | -------------------------------------------------------------------------------- /fotoobo/cli/ems/get.py: -------------------------------------------------------------------------------- 1 | """ 2 | The FortiClient EMS get commands 3 | """ 4 | 5 | import logging 6 | 7 | import typer 8 | from typing_extensions import Annotated 9 | 10 | from fotoobo.helpers import cli_path 11 | from fotoobo.tools import ems 12 | 13 | app = typer.Typer(no_args_is_help=True, rich_markup_mode="rich") 14 | log = logging.getLogger("fotoobo") 15 | 16 | 17 | @app.callback() 18 | def callback(context: typer.Context) -> None: 19 | """ 20 | The ems get subcommand callback 21 | 22 | Args: 23 | context: The context object of the typer app 24 | """ 25 | cli_path.append(str(context.invoked_subcommand)) 26 | log.debug("About to execute command: '%s'", context.invoked_subcommand) 27 | 28 | 29 | @app.command() 30 | def version( 31 | host: Annotated[ 32 | str, 33 | typer.Argument( 34 | help="The FortiClientEMS hostname to access (must be defined in the inventory).", 35 | metavar="[host]", 36 | ), 37 | ] = "ems", 38 | ) -> None: 39 | """ 40 | Get the FortiClient EMS version. 41 | """ 42 | result = ems.get.version(host) 43 | result.print_result_as_table( 44 | title="FortiClient EMS Version", 45 | headers=["FortiClient EMS", "Version"], 46 | ) 47 | 48 | 49 | @app.command() 50 | def workgroups( 51 | host: Annotated[ 52 | str, 53 | typer.Argument( 54 | help="The FortiClientEMS hostname to access (must be defined in the inventory).", 55 | metavar="[host]", 56 | ), 57 | ] = "ems", 58 | custom: Annotated[bool, typer.Option("--custom", "-c", help="Only show custom groups")] = False, 59 | ) -> None: 60 | """ 61 | Get the FortiClient EMS workgroups. 62 | """ 63 | result = ems.get.workgroups(host, custom) 64 | result.print_result_as_table( 65 | title="FortiClient EMS Workgroups", headers=["Group", "ID", "Count"] 66 | ) 67 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | """Configuration file for the Sphinx documentation builder. 2 | 3 | For the full list of built-in configuration values, see the documentation: 4 | https://www.sphinx-doc.org/en/master/usage/configuration.html 5 | """ 6 | 7 | # -- configure linter ------------------------------------------------------------------------------ 8 | # pylint: disable=invalid-name 9 | 10 | 11 | # -- configure sys path for extensions ------------------------------------------------------------- 12 | # If extensions (or modules to document with autodoc) are in another directory, add these 13 | # directories to sys.path here. If the directory is relative to the documentation root, use 14 | # os.path.abspath to make it absolute, like shown here. 15 | # 16 | import os 17 | import sys 18 | 19 | sys.path.insert(0, os.path.abspath(os.path.join("..", ".."))) 20 | 21 | 22 | # -- Project information --------------------------------------------------------------------------- 23 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information 24 | 25 | project = "fotoobo" 26 | author = "The amazing fotoobo project team" 27 | 28 | 29 | # -- General configuration ------------------------------------------------------------------------- 30 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration 31 | 32 | extensions = ["sphinx.ext.autodoc", "sphinx.ext.napoleon"] 33 | templates_path = ["_templates"] 34 | exclude_patterns = [] 35 | 36 | # Napoleon settings 37 | napoleon_google_docstring = True 38 | napoleon_numpy_docstring = False 39 | napoleon_include_init_with_doc = True 40 | napoleon_use_rtype = False 41 | 42 | # -- Options for HTML output ----------------------------------------------------------------------- 43 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output 44 | 45 | html_theme = "sphinx_rtd_theme" 46 | html_static_path = ["_static"] 47 | html_favicon = "ftb.png" 48 | -------------------------------------------------------------------------------- /.make_release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Bump a new release. Handling is the same as with poetry version, 3 | # but also takes care of __init__.py:version, git tag, ... 4 | 5 | if [[ -n "$(git status --porcelain | grep -v ' M WHATSNEW.md')" ]] ; then 6 | echo "error: git repository is not clean (except of WHATSNEW.md), please commit and/or stash all changes (except of WHATSNEW.md) before running this script." 7 | exit 1 8 | fi 9 | 10 | if [[ $# != 1 ]] ; then 11 | version_bump="not_implemented" 12 | else 13 | version_bump=$1 14 | fi 15 | 16 | case $version_bump in 17 | major|minor|patch) 18 | ;; 19 | *) 20 | echo "ERROR: Please provide the version desired bump as 'major', 'minor' or 'patch'" >&2 21 | exit 1 22 | esac 23 | 24 | old_version=`poetry version | sed 's/fotoobo \(.*\)/\1/'` 25 | 26 | poetry version $version_bump 27 | 28 | new_version=`poetry version | sed 's/fotoobo \(.*\)/\1/'` 29 | 30 | echo "New version would be $new_version. Is this ok? [y/N]" 31 | read ok 32 | 33 | case $ok in 34 | y) 35 | ;; 36 | *) 37 | echo "aborting..." 38 | git restore pyproject.toml 39 | exit 1 40 | esac 41 | 42 | echo 'Writing new version to "fotoobo/__init__.py"...' 43 | sed -i "s/$old_version/$new_version/g" fotoobo/__init__.py 44 | 45 | echo "Updating CHANGELOG.md" 46 | sed -i -e '/./,$!d' -e :a -e '/^\n*$/{$d;N;ba' -e '}' WHATSNEW.md 47 | VERSION="## [$new_version] - $(date +%Y-%m-%d)" 48 | WHATSNEW="$( None: 37 | """ 38 | Test FortiAnalyzer get version. 39 | """ 40 | 41 | # Arrange 42 | response_mock = Mock(return_value=ResponseMock(json=response, status_code=200)) 43 | monkeypatch.setattr("fotoobo.fortinet.fortinet.requests.Session.post", response_mock) 44 | 45 | # Act & Assert 46 | assert FortiAnalyzer("host", "", "").get_version() == expected 47 | response_mock.assert_called_with( 48 | "https://host:443/jsonrpc", 49 | headers=None, 50 | json={"method": "get", "params": [{"url": "/sys/status"}], "session": ""}, 51 | params=None, 52 | timeout=3, 53 | verify=True, 54 | ) 55 | -------------------------------------------------------------------------------- /tests/cli/fmg/test_fmg.py: -------------------------------------------------------------------------------- 1 | """ 2 | Testing the cli app. 3 | """ 4 | 5 | from typer.testing import CliRunner 6 | 7 | from fotoobo.cli.main import app 8 | from tests.helper import parse_help_output 9 | 10 | runner = CliRunner() 11 | 12 | 13 | def test_cli_app_fmg_help(help_args_with_none: str) -> None: 14 | """ 15 | Test cli help for fmg. 16 | """ 17 | 18 | # Arrange 19 | args = ["-c", "tests/fotoobo.yaml", "fmg"] 20 | args.append(help_args_with_none) 21 | args = list(filter(None, args)) 22 | 23 | # Act 24 | result = runner.invoke(app, args) 25 | 26 | # Assert 27 | assert result.exit_code in [0, 2] 28 | arguments, options, commands = parse_help_output(result.stdout) 29 | assert not arguments 30 | assert options == {"-h", "--help"} 31 | assert set(commands) == {"assign", "get", "post"} 32 | 33 | 34 | def test_cli_app_fmg_assign_help(help_args: str) -> None: 35 | """ 36 | Test cli help for fmg assign. 37 | """ 38 | 39 | # Arrange 40 | args = ["-c", "tests/fotoobo.yaml", "fmg", "assign"] 41 | args.append(help_args) 42 | 43 | # Act 44 | result = runner.invoke(app, args) 45 | 46 | # Assert 47 | assert result.exit_code == 0 48 | arguments, options, commands = parse_help_output(result.stdout) 49 | assert set(arguments) == {"adoms", "host", "policy"} 50 | assert options == {"-h", "--help", "-s", "--smtp", "-t", "--timeout"} 51 | assert not commands 52 | 53 | 54 | def test_cli_app_fmg_post_help(help_args: str) -> None: 55 | """ 56 | Test cli help for fmg post. 57 | """ 58 | 59 | # Arrange 60 | args = ["-c", "tests/fotoobo.yaml", "fmg", "post"] 61 | args.append(help_args) 62 | 63 | # Act 64 | result = runner.invoke(app, args) 65 | 66 | # Assert 67 | assert result.exit_code == 0 68 | arguments, options, commands = parse_help_output(result.stdout) 69 | assert set(arguments) == {"file", "adom", "host"} 70 | assert options == {"-h", "--help", "-s", "--smtp"} 71 | assert not commands 72 | -------------------------------------------------------------------------------- /fotoobo/cli/get.py: -------------------------------------------------------------------------------- 1 | """ 2 | The fotoobo get commands 3 | """ 4 | 5 | import logging 6 | 7 | import typer 8 | from rich import print as rich_print 9 | from rich.panel import Panel 10 | from typing_extensions import Annotated 11 | 12 | from fotoobo.helpers import cli_path 13 | from fotoobo.tools import get 14 | 15 | app = typer.Typer(no_args_is_help=True, rich_markup_mode="rich") 16 | log = logging.getLogger("fotoobo") 17 | 18 | 19 | @app.callback() 20 | def callback(context: typer.Context) -> None: 21 | """ 22 | The fotoobo get command callback 23 | 24 | Args: 25 | context: The context object of the typer app 26 | """ 27 | cli_path.append(str(context.invoked_subcommand)) 28 | log.debug("About to execute command: '%s'", context.invoked_subcommand) 29 | 30 | 31 | @app.command() 32 | def inventory() -> None: 33 | """ 34 | Print a summary over your fotoobo inventory. 35 | """ 36 | result = get.inventory() 37 | result.print_result_as_table(title="fotoobo inventory", headers=["Device", "Hostname", "Type"]) 38 | 39 | 40 | @app.command() 41 | def version( 42 | verbose: Annotated[ 43 | bool, typer.Option("-v", help="Verbose output (also show the most important modules).") 44 | ] = False, 45 | ) -> None: 46 | """ 47 | Print the fotoobo version. 48 | """ 49 | result = get.version(verbose) 50 | 51 | if verbose: 52 | out_list = [ 53 | { 54 | "module": "[bold]fotoobo[/]", 55 | "version": f"[bold]{result.get_result('version')[0]['version']}[/]", 56 | } 57 | ] 58 | out_list += result.get_result("version")[1:] 59 | 60 | else: 61 | out_list = result.get_result("version") 62 | 63 | result.print_table_raw(out_list, headers=[], auto_header=False, title="fotoobo version") 64 | 65 | 66 | @app.command() 67 | def commands() -> None: 68 | """ 69 | Print the fotoobo commands structure. 70 | """ 71 | result = get.commands() 72 | tree = result.get_result("commands") 73 | rich_print( 74 | Panel(tree, border_style="#FF33BB", title="cli commands structure", title_align="right") 75 | ) 76 | -------------------------------------------------------------------------------- /fotoobo/tools/get.py: -------------------------------------------------------------------------------- 1 | """ 2 | The fotoobo get utility 3 | """ 4 | 5 | import importlib.metadata 6 | import logging 7 | 8 | from rich.text import Text 9 | from rich.tree import Tree 10 | 11 | from fotoobo import __version__ 12 | from fotoobo.helpers.cli import walk_cli_info 13 | from fotoobo.helpers.config import config 14 | from fotoobo.helpers.result import Result 15 | from fotoobo.inventory import Inventory 16 | 17 | log = logging.getLogger("fotoobo") 18 | 19 | 20 | def inventory() -> Result[dict[str, str]]: 21 | """ 22 | Get the fotoobo inventory 23 | 24 | Returns: 25 | Result 26 | """ 27 | log.debug("Print fotoobo inventory information") 28 | result = Result[dict[str, str]]() 29 | _inventory = Inventory(config.inventory_file) 30 | 31 | for host, data in _inventory.assets.items(): 32 | result.push_result(host, {"hostname": data.hostname, "type": data.type}) 33 | 34 | return result 35 | 36 | 37 | def version(verbose: bool = False) -> Result[list[dict[str, str]]]: 38 | """ 39 | Get the fotoobo version 40 | 41 | Args: 42 | verbose: Whether we want verbose output 43 | """ 44 | log.debug("Print fotoobo version information: '%s'", __version__) 45 | result = Result[list[dict[str, str]]]() 46 | versions = [{"module": "fotoobo", "version": __version__}] 47 | 48 | if verbose: 49 | modules = ["jinja2", "PyYAML", "requests", "rich", "typer"] 50 | 51 | for module in modules: 52 | versions.append({"module": module, "version": importlib.metadata.version(module)}) 53 | 54 | result.push_result("version", versions) 55 | return result 56 | 57 | 58 | def commands() -> Result[Tree]: 59 | """Get the fotoobo cli commands 60 | 61 | Walk through the typer cli app and return its commands as a beautiful rich tree. The commands 62 | are sorted in alphabetical order 63 | """ 64 | result = Result[Tree]() 65 | result.push_result( 66 | "commands", 67 | walk_cli_info( 68 | config.cli_info["command"], 69 | Tree(Text().append(config.cli_info["info_name"], style="bold cyan")), 70 | ), 71 | ) 72 | 73 | return result 74 | -------------------------------------------------------------------------------- /fotoobo/tools/fgt/cmdb/firewall/address.py: -------------------------------------------------------------------------------- 1 | """ 2 | FortiGate CMDB firewall address module 3 | """ 4 | 5 | from pathlib import Path 6 | from typing import Any 7 | 8 | 9 | from fotoobo.fortinet.fortigate import FortiGate 10 | from fotoobo.helpers.config import config 11 | from fotoobo.helpers.result import Result 12 | from fotoobo.inventory import Inventory 13 | 14 | 15 | def get_cmdb_firewall_address( 16 | host: str, name: str, vdom: str, output_file: str | None 17 | ) -> Result[list[Any]]: 18 | """ 19 | Get the firewall address object(s). 20 | 21 | The FortiGate api endpoint is: /cmdb/firewall/address 22 | """ 23 | inventory = Inventory(config.inventory_file) 24 | fgt: FortiGate = inventory.get_item(host, "fortigate") 25 | result = Result[list[Any]]() 26 | 27 | address_list = fgt.api_get(url=f"/cmdb/firewall/address/{name}", vdom=vdom) 28 | result.push_result(key=host, data=address_list) 29 | 30 | if output_file: 31 | result.push_result(key=host, data=address_list) 32 | result.save_raw(file=Path(output_file), key=host) 33 | 34 | assets = [] 35 | if address_list: 36 | for vd in address_list: 37 | for asset in vd["results"]: 38 | 39 | data: dict[str, str] = { 40 | "name": asset["name"], 41 | "vdom": vd["vdom"], 42 | "type": asset["type"], 43 | } 44 | 45 | if asset["type"] == "fqdn": 46 | data["content"] = asset["fqdn"] 47 | 48 | elif asset["type"] == "geography": 49 | data["content"] = asset["country"] 50 | 51 | elif asset["type"] == "ipmask": 52 | data["content"] = "/".join( 53 | [asset["subnet"].split(" ")[0], asset["subnet"].split(" ")[1]] 54 | ) 55 | 56 | elif asset["type"] == "iprange": 57 | data["content"] = " - ".join([asset["start-ip"], asset["end-ip"]]) 58 | 59 | else: 60 | data["content"] = "" 61 | 62 | assets.append(data) 63 | 64 | result.push_result(host, assets) 65 | 66 | return result 67 | -------------------------------------------------------------------------------- /fotoobo/exceptions/exceptions.py: -------------------------------------------------------------------------------- 1 | """ 2 | This is the fotoobo exception module. 3 | """ 4 | 5 | from typing import Any 6 | 7 | from requests.exceptions import HTTPError 8 | 9 | 10 | class GeneralException(Exception): 11 | """ 12 | This is the generic fotoobo exception which can be used by any sub exception. 13 | """ 14 | 15 | def __init__(self, message: str) -> None: 16 | """init""" 17 | self.message = message 18 | super().__init__(self.message) 19 | 20 | 21 | class APIError(GeneralException): 22 | """Exception for errors with the network API""" 23 | 24 | def __init__(self, err: Any) -> None: 25 | """init""" 26 | self.http_status_codes = { 27 | 200: "OK", 28 | 400: "Bad Request", 29 | 401: "Not Authorized", 30 | 403: "Forbidden", 31 | 404: "Resource Not Found", 32 | 405: "Method Not Allowed", 33 | 413: "Request Entity Too Large", 34 | 424: "Failed Dependency", 35 | 429: "Access temporarily blocked", 36 | 500: "Internal Server Error", 37 | } 38 | # self.message = "general API Error" 39 | self.code = 999 40 | self.message = "unknown" 41 | 42 | if isinstance(err, HTTPError): 43 | self.code = err.response.status_code 44 | message = self.http_status_codes.get(self.code, "general API Error") 45 | self.message = f"HTTP/{str(self.code)} {message}" 46 | 47 | super().__init__(self.message) 48 | 49 | 50 | class GeneralError(GeneralException): 51 | """ 52 | The exception to raise if a general error occurred. 53 | The class does not have any methods as the only one (__init__) is inherited from its parent. 54 | Raise a GeneralError when it does not make sense to do further processing and the program should 55 | stop and exit. 56 | """ 57 | 58 | 59 | class GeneralWarning(GeneralException): 60 | """ 61 | The exception to raise if a general warning occurred. 62 | The class does not have any methods as the only one (__init__) is inherited from its parent. 63 | Raise a GeneralWarning if a part of the program fails but it is safe to do further processing. 64 | """ 65 | -------------------------------------------------------------------------------- /fotoobo/tools/fgt/cmdb/firewall/service_custom.py: -------------------------------------------------------------------------------- 1 | """ 2 | FortiGate CMDB firewall service custom module 3 | """ 4 | 5 | from pathlib import Path 6 | from typing import Any 7 | 8 | from fotoobo.fortinet.fortigate import FortiGate 9 | from fotoobo.helpers.config import config 10 | from fotoobo.helpers.result import Result 11 | from fotoobo.inventory import Inventory 12 | 13 | 14 | def get_cmdb_firewall_service_custom( 15 | host: str, name: str, vdom: str, output_file: str | None 16 | ) -> Result[list[Any]]: 17 | """ 18 | Get the firewall service custom object(s). 19 | 20 | The FortiGate api endpoint is: /cmdb/firewall.service/custom 21 | """ 22 | inventory = Inventory(config.inventory_file) 23 | fgt: FortiGate = inventory.get_item(host, "fortigate") 24 | result = Result[list[Any]]() 25 | 26 | service_custom_list = fgt.api_get(url=f"/cmdb/firewall.service/custom/{name}", vdom=vdom) 27 | 28 | if output_file: 29 | result.push_result(key=host, data=service_custom_list) 30 | result.save_raw(file=Path(output_file), key=host) 31 | 32 | assets = [] 33 | if service_custom_list: 34 | for vd in service_custom_list: 35 | for asset in vd["results"]: 36 | 37 | data: dict[str, str] = { 38 | "name": asset["name"], 39 | "vdom": vd["vdom"], 40 | "protocol": asset["protocol"], 41 | } 42 | 43 | if asset["protocol"] == "TCP/UDP/SCTP": 44 | data["data_1"] = asset.get("tcp-portrange", "") 45 | data["data_2"] = asset.get("udp-portrange", "") 46 | 47 | elif asset["protocol"] in ["ICMP", "ICMP6"]: 48 | data["data_1"] = asset.get("icmptype", "") 49 | data["data_2"] = asset.get("icmpcode", "") 50 | 51 | elif asset["protocol"] == "IP": 52 | data["data_1"] = asset.get("protocol-number", "") 53 | data["data_2"] = "" 54 | 55 | else: 56 | data["data_1"] = "" 57 | data["data_2"] = "" 58 | 59 | assets.append(data) 60 | 61 | result.push_result(host, assets) 62 | 63 | return result 64 | -------------------------------------------------------------------------------- /fotoobo/cli/convert.py: -------------------------------------------------------------------------------- 1 | """ 2 | The fotoobo convert commands 3 | """ 4 | 5 | import logging 6 | from pathlib import Path 7 | 8 | import typer 9 | from typing_extensions import Annotated 10 | 11 | from fotoobo.helpers import cli_path 12 | from fotoobo.helpers.files import load_json_file, save_json_file 13 | from fotoobo.tools import convert 14 | 15 | app = typer.Typer(no_args_is_help=True, rich_markup_mode="rich") 16 | log = logging.getLogger("fotoobo") 17 | 18 | 19 | @app.callback() 20 | def callback(context: typer.Context) -> None: 21 | """ 22 | The fotoobo convert command callback 23 | 24 | Args: 25 | context: The context object of the typer app 26 | """ 27 | cli_path.append(str(context.invoked_subcommand)) 28 | log.debug("About to execute command: '%s'", context.invoked_subcommand) 29 | 30 | 31 | @app.command(no_args_is_help=True) 32 | def checkpoint( 33 | infile: Annotated[ 34 | Path, 35 | typer.Argument( 36 | help="The json file to read the Checkpoint objects from.", 37 | show_default=False, 38 | metavar="[infile]", 39 | ), 40 | ], 41 | outfile: Annotated[ 42 | Path, 43 | typer.Argument( 44 | help="The json file to write the converted objects to.", 45 | show_default=False, 46 | metavar="[outfile]", 47 | ), 48 | ], 49 | obj_type: Annotated[ 50 | str, 51 | typer.Argument( 52 | help="The type of objects to convert.", 53 | show_default=False, 54 | metavar="[type]", 55 | ), 56 | ], 57 | cache_dir: Annotated[ 58 | Path | None, 59 | typer.Argument( 60 | help="The cache directory to use.", 61 | show_default=False, 62 | metavar="[cache_dir]", 63 | ), 64 | ] = None, 65 | ) -> None: 66 | """ 67 | Convert Checkpoint assets into Fortinet objects. 68 | 69 | The Checkpoint objects have to be prepared in a json file. See 70 | https://fotoobo.readthedocs.io/en/latest/usage/convert.html for more information. 71 | """ 72 | checkpoint_assets = load_json_file(infile) 73 | result = convert.checkpoint(checkpoint_assets, obj_type, outfile.name, cache_dir) 74 | save_json_file(outfile, result.get_result("fortinet_assets")) 75 | -------------------------------------------------------------------------------- /tests/tools/fgt/get/test_version.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test fgt tools get version. 3 | """ 4 | 5 | from typing import Any 6 | from unittest.mock import Mock 7 | 8 | import pytest 9 | from pytest import MonkeyPatch 10 | 11 | from fotoobo.exceptions import GeneralError, GeneralWarning 12 | from fotoobo.tools.fgt.get import version 13 | 14 | 15 | @pytest.mark.parametrize( 16 | "host", 17 | ( 18 | pytest.param("", id="test version with no host"), 19 | pytest.param("test_fgt_1", id="test version with valid host"), 20 | ), 21 | ) 22 | def test_version(host: str, monkeypatch: MonkeyPatch) -> None: 23 | """ 24 | Test get version. 25 | """ 26 | 27 | # Arrange 28 | monkeypatch.setattr( 29 | "fotoobo.fortinet.fortigate.FortiGate.get_version", Mock(return_value="1.1.1") 30 | ) 31 | 32 | # Act 33 | result = version(host) 34 | 35 | # Assert 36 | if host: 37 | assert isinstance(result.get_result(host), str) 38 | assert result.get_result(host) == "1.1.1" 39 | else: 40 | assert len(result.results) == 3 41 | assert result.get_result("test_fgt_2") == "1.1.1" 42 | 43 | 44 | @pytest.mark.parametrize( 45 | "side_effect", 46 | ( 47 | pytest.param(GeneralWarning("dummy message"), id="GeneralWarning"), 48 | pytest.param(GeneralError("dummy message"), id="GeneralError"), 49 | ), 50 | ) 51 | def test_version_exception_from_fortigate(side_effect: Any, monkeypatch: MonkeyPatch) -> None: 52 | """ 53 | Test get version with exception thrown from FortiGate module. 54 | """ 55 | 56 | # Arrange 57 | monkeypatch.setattr( 58 | "fotoobo.fortinet.fortigate.FortiGate.get_version", Mock(side_effect=side_effect) 59 | ) 60 | 61 | # Act 62 | result = version("test_fgt_1") 63 | 64 | # Assert 65 | assert result.get_result("test_fgt_1") == "unknown due to dummy message" 66 | 67 | 68 | def test_version_no_fortigates(monkeypatch: MonkeyPatch) -> None: 69 | """ 70 | Test get version with no FortiGates in inventory. 71 | """ 72 | 73 | # Arrange 74 | monkeypatch.setattr("fotoobo.tools.fgt.get.Inventory._load_inventory", Mock(return_value=None)) 75 | 76 | # Act & Assert 77 | with pytest.raises(GeneralWarning, match=r"no asset of type 'fortigate' .* was found.*"): 78 | version("") 79 | -------------------------------------------------------------------------------- /docs/source/developer/how_to/how_to_release.rst: -------------------------------------------------------------------------------- 1 | .. Describes how to do a new release 2 | 3 | .. _how_to_release: 4 | 5 | How To Release 6 | ============== 7 | 8 | Only maintainers are allowed to release code. So, if someone did some breaking changes which should 9 | be released ask a maintainer to do so. 10 | 11 | Making a release is done by a strictly defined process. To help you publishing a release there are 12 | some scripts and workflows which do some checks and automation. 13 | 14 | Here are the steps you need to perform to make a release. 15 | 16 | #. Make sure all tests pass 17 | #. Update the WHATSNEW file 18 | #. Execute the ``.make_release.sh`` script giving the type of release (major/minor/patch) 19 | #. Make a pull request 20 | #. Switch to the main repository (migros/fotoobo) 21 | #. Execute the ``.tag_release.sh`` script 22 | 23 | .make_release.sh 24 | ---------------- 25 | 26 | The script `.make_release.sh `_ 27 | prepares the repository for the release, does some simple checks and releases the code and its 28 | documentation. These are the steps it goes through: 29 | 30 | #. It checks if there are uncommitted changes in the repository 31 | #. It bumps the version by using ``poetry version`` 32 | #. It asks you if the new version is correct 33 | #. It writes the new version to `fotoobo/__init__.py` 34 | #. It commits the changes to the repository 35 | #. It pushes the repository to the remote 36 | 37 | .tag_release.sh 38 | --------------- 39 | 40 | The script `.tag_release.sh `_ just 41 | creates the release tag. This script has to be run in the main repository `migros/fotoobo 42 | `_. 43 | 44 | #. It creates a git tag with the version 45 | #. It pushes the tag to the repository 46 | 47 | The release workflow 48 | -------------------- 49 | 50 | Whenever a tag is pushed the release workflows are started. 51 | 52 | * The `create-github-release.yaml 53 | `_ 54 | workflow creates the release on GitHub. 55 | * The `publish-to-pypi.yaml 56 | `_ workflow 57 | publishes the package to the PyPI registry. 58 | -------------------------------------------------------------------------------- /docs/source/migros.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 9 | 10 | 14 | 18 | 21 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /docs/source/developer/architecture/9_risks_technical_depths.rst: -------------------------------------------------------------------------------- 1 | .. Chapter eleven according to https://arc42.org/overview 2 | 3 | .. _RisksTechnicalDepths: 4 | 5 | 9. Risks & Technical Depths 6 | ============================ 7 | 8 | Use of undocumented API parts 9 | ----------------------------- 10 | 11 | Description 12 | ^^^^^^^^^^^ 13 | 14 | To achieve some of its goals **fotoobo** makes use of undocumented API calls. This is due to 15 | missing functionality in the official Fortinet APIs. 16 | 17 | Risk 18 | ^^^^ 19 | 20 | Undocumented API parts are subject to be changed at any time by Fortinet without notification or any 21 | deprecation process. This will possibly break the respective **fotoobo** functionality. 22 | 23 | Mitigation 24 | ^^^^^^^^^^ 25 | 26 | Any part of **fotoobo** is tested regularly against real Fortinet devices so we will get to know, 27 | when some API calls break and break the respective **fotoobo** part. 28 | 29 | *Note: This is done using a best-effort approach of the maintainers and is mostly based on the 30 | regular use of the current* **fotoobo** *features using cron-jobs and alike.* 31 | 32 | 33 | Use of unencrypted inventory including credentials 34 | -------------------------------------------------- 35 | 36 | Description 37 | ^^^^^^^^^^^ 38 | 39 | Currently **fotoobo** may use only its own inventory format which is entirely unencrypted. The 40 | required login data (tokens, username & password) need to be part of the inventory. 41 | 42 | Risk 43 | ^^^^ 44 | 45 | Because of the supported functionality of **fotoobo** these credentials usually have admin rights on 46 | the respective Fortinet devices. So loss of this information may pose a big security threat to the 47 | company using **fotoobo**. 48 | 49 | Mitigation 50 | ^^^^^^^^^^ 51 | 52 | There are several things done to mitigate this risk: 53 | 54 | - **fotoobo** by default uses only encrypted and verified connections to communicate with the 55 | Fortinet devices and other backend systems. Lowering the security bar is in the user's hands and 56 | not the first suggestion in the documentation. 57 | - The risk of exposing the inventory and how to minimize this risk is clearly stated in the 58 | documentation at :ref:`usage_inventory`. 59 | - To make the handling of sensitive data a bit safer you may store such data in a Hashicorp Vault 60 | service which is documented here: :ref:`vault_service`. 61 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Contributing 2 | 3 | Hi there! We're excited that you'd like to contribute to this project. Your help is essential for 4 | keeping it awesome. Thank you very much! 5 | 6 | Contributions to this project are [released](https://help.github.com/articles/github-terms-of-service/#6-contributions-under-repository-license) to the public under the [project's open source license](LICENSE). 7 | 8 | Please note that this project is released with a [Collective Code Construction Contract](https://fotoobo.readthedocs.io/en/latest/developer/c4.html). 9 | By participating in this project you agree to abide by its terms. 10 | 11 | ## Submitting a feature request or bug report 12 | 13 | If you find a bug or miss a feature feel free to submit a bug report or a feature request 14 | respectively in the [GitHub issues](https://github.com/migros/fotoobo/issues). 15 | 16 | The following few things will increase the likelihood of your bug report or feature request being 17 | processed in a timely manner: 18 | 19 | - Describe in a simple, easily to reproduce example how to reproduce the bug or what the feature 20 | request should do. 21 | - Mark it with the respective labels (bugs with "bug", features with "enhancement" and any other 22 | that apply). 23 | - Help us clarify the issue by answering our possible questions in a timely manner. 24 | 25 | 26 | ## Submitting a pull request 27 | 28 | 1. Fork and clone the repository 29 | 2. Configure and install the dependencies: `poetry install` 30 | 3. Make sure the tests pass on your machine: `poetry run tox` 31 | 4. Make your change, add tests, and make sure the tests still pass 32 | 5. Push to your fork and submit a pull request 33 | 6. Pat your self on the back and wait for your pull request to be reviewed and merged 34 | 35 | Here are a few things you can do that will increase the likelihood of your pull request being accepted: 36 | 37 | - Write tests and make them pass 38 | - Keep your change as focused as possible 39 | - If there are multiple changes you would like to make that are not dependent upon each other, 40 | consider submitting them as separate pull requests 41 | - Write a good and explanatory commit message 42 | 43 | ## Resources 44 | 45 | - [How to Contribute to Open Source](https://opensource.guide/how-to-contribute/) 46 | - [Using Pull Requests](https://help.github.com/articles/about-pull-requests/) 47 | - [GitHub Help](https://help.github.com) 48 | -------------------------------------------------------------------------------- /tests/tools/fgt/cmdb/firewall/test_address.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test fgt cmdb firewall address. 3 | """ 4 | 5 | # mypy: disable-error-code=attr-defined 6 | 7 | from pathlib import Path 8 | from unittest.mock import Mock 9 | 10 | from pytest import MonkeyPatch 11 | 12 | from fotoobo.tools.fgt.cmdb.firewall import get_cmdb_firewall_address 13 | 14 | 15 | def test_get_cmdb_firewall_address(monkeypatch: MonkeyPatch) -> None: 16 | """ 17 | Test the get cmdb firewall address method. 18 | """ 19 | 20 | # Arrange 21 | result_mock = [ 22 | { 23 | "results": [ 24 | { 25 | "name": "dummy_1", 26 | "type": "fqdn", 27 | "fqdn": "dummy.local", 28 | }, 29 | { 30 | "name": "dummy_2", 31 | "type": "geography", 32 | "country": "dummy-country", 33 | }, 34 | { 35 | "name": "dummy_3", 36 | "type": "ipmask", 37 | "subnet": "1.1.1.1 2.2.2.2", 38 | }, 39 | { 40 | "name": "dummy_4", 41 | "type": "iprange", 42 | "start-ip": "1.1.1.1", 43 | "end-ip": "2.2.2.2", 44 | }, 45 | { 46 | "name": "dummy_5", 47 | "type": "dummy-type", 48 | }, 49 | ], 50 | "vdom": "vdom_1", 51 | } 52 | ] 53 | api_get_mock = Mock(return_value=result_mock) 54 | monkeypatch.setattr("fotoobo.fortinet.fortigate.FortiGate.api_get", api_get_mock) 55 | save_raw_mock = Mock(return_value=True) 56 | monkeypatch.setattr("fotoobo.helpers.result.Result.save_raw", save_raw_mock) 57 | 58 | # Act 59 | result = get_cmdb_firewall_address("test_fgt_1", "", "", "test.json") 60 | 61 | # Assert 62 | data = result.get_result("test_fgt_1") 63 | assert len(data) == 5 64 | assert data[0]["content"] == "dummy.local" 65 | assert data[1]["content"] == "dummy-country" 66 | assert data[2]["content"] == "1.1.1.1/2.2.2.2" 67 | assert data[3]["content"] == "1.1.1.1 - 2.2.2.2" 68 | assert data[4]["content"] == "" 69 | api_get_mock.assert_called_with(url="/cmdb/firewall/address/", vdom="") 70 | save_raw_mock.assert_called_with(file=Path("test.json"), key="test_fgt_1") 71 | -------------------------------------------------------------------------------- /docs/source/usage/convert.rst: -------------------------------------------------------------------------------- 1 | .. Describes the object converter 2 | 3 | .. _convert: 4 | 5 | Configuration Object Converter 6 | ============================== 7 | 8 | This module can be used to convert configuration objects from other vendors into Fortinet syntax. 9 | Each vendor is represented as a subcommand. The following vendors are supported: 10 | 11 | * Checkpoint 12 | 13 | 14 | Checkpoint Object Converter 15 | --------------------------- 16 | 17 | .. code-block:: bash 18 | 19 | fotoobo convert checkpoint [infile] [outfile] [obj_type] [cache_dir] 20 | 21 | infile 22 | specifies the json file containing the Checkpoint objects. The [obj_type] has to be the top 23 | level dict in the json file containing data for the specified type. The json file has to be in 24 | the following format: 25 | 26 | .. code-block:: json 27 | 28 | { 29 | "hosts": [ 30 | { 31 | "...": "..." 32 | } 33 | ], 34 | "networks": [ 35 | { 36 | "...": "..." 37 | } 38 | ], 39 | "groups": [ 40 | { 41 | "...": "..." 42 | } 43 | ] 44 | } 45 | 46 | outfile 47 | specifies the json file to write the Fortinet specific objects into. 48 | 49 | type 50 | specifies the type of objects to convert. If [type] is omitted, the converter tries to convert all objects in the [infile]. The following types are supported. Click on the link to see it's field mapping. 51 | 52 | * hosts 53 | * networks 54 | * address_ranges 55 | * groups 56 | * services_icmp 57 | * services_icmp6 58 | * services_tcp 59 | * services_udp 60 | * service_groups 61 | 62 | cache_dir 63 | Specifies the cache directory to use. If given, the converted outfile will be cached into this 64 | directory. Subsequent convert jobs will only return new assets which are not already in this 65 | cache. 66 | 67 | Be aware that caching only affects the convert function. If further steps in your process do 68 | result in an error the cache is not affected (thus left updated). 69 | 70 | The following mappings take place when converting checkpoint objects into Fortinet objects: 71 | 72 | .. image:: convert_checkpoint_mappings.drawio.svg 73 | :width: 100% 74 | :alt: The fotoobo convert checkpoint mappings 75 | 76 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /fotoobo.yaml.sample: -------------------------------------------------------------------------------- 1 | --- 2 | # Note: All paths may be absolute or relative. If they are given relative, it is 3 | # from the cwd where fotoobo has been run. 4 | 5 | # The path to a inventory file 6 | inventory: inventory.yaml 7 | 8 | # Hide the fotoobo logo 9 | no_logo: false 10 | 11 | # Configure how fotoobo logs 12 | # - Each section ("log_file", "log_console" and "log_syslog") can be commented out 13 | # to disable this output completely 14 | # - The log level given on the command line will overwrite all given log-levels for 15 | # each output configuration. 16 | logging: 17 | # The log level 18 | level: INFO 19 | 20 | # Log to the console 21 | log_console: 22 | 23 | # The file where fotoobo should log to 24 | log_file: 25 | name: fotoobo.log 26 | 27 | # Log to a syslog server 28 | # log_syslog: 29 | # host: localhost 30 | # port: 514 31 | # protocol: UDP # UDP or TCP 32 | 33 | # If you need more complex logging, provide a python logging configuration 34 | # by yourself. See the "logging-config.yaml.sample" for more information. 35 | # NOTE: This will overwrite all the above settings! 36 | # log_configuration_file: "logging-config.yaml" 37 | 38 | 39 | # Configure how fotoobo handles audit logs 40 | # Each section ("log_file", "log_console" and "log_syslog") can be commented out 41 | # to disable this output completely 42 | # NOTE: If you have given a log_configuration_file above, the options given here have no effect 43 | #audit_logging: 44 | # # File to log audit logs to 45 | # log_file: 46 | # name: fotoobo_audit.log 47 | # 48 | # # Syslog server to log audit logs to 49 | # log_syslog: 50 | # host: localhost 51 | # port: 514 52 | # protocol: UDP # UDP or TCP 53 | 54 | 55 | # Configure the Hashicorp Vault service 56 | # Instead of storing credentials in the inventory file you may use VAULT as a placeholder. All asset 57 | # attributes that are VAULT will be retreived from the Hashicorp Vault service specified here. 58 | # The Hashicorp Vault service will use approle login to get the data. 59 | # We may also read the role_id and secret_id from the system environment. This makes it a little bit 60 | # more secure. 61 | vault: 62 | url: https://vault.local 63 | ssl_verify: false 64 | namespace: vault_namespace 65 | data_path: /v1/kv/data/fotoobo 66 | role_id: ... 67 | secret_id: ... 68 | token_file: ~/.cache/token.key 69 | -------------------------------------------------------------------------------- /tests/tools/fgt/cmdb/firewall/test_service_custom.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test fgt cmdb firewall service custom. 3 | """ 4 | 5 | # mypy: disable-error-code=attr-defined 6 | 7 | from pathlib import Path 8 | from unittest.mock import Mock 9 | 10 | from pytest import MonkeyPatch 11 | 12 | from fotoobo.tools.fgt.cmdb.firewall import get_cmdb_firewall_service_custom 13 | 14 | 15 | def test_get_cmdb_firewall_service_custom(monkeypatch: MonkeyPatch) -> None: 16 | """ 17 | Test the get cmdb firewall service custom method. 18 | """ 19 | 20 | # Arrange 21 | result_mock = [ 22 | { 23 | "results": [ 24 | { 25 | "name": "dummy_1", 26 | "protocol": "TCP/UDP/SCTP", 27 | "tcp-portrange": "88", 28 | "udp-portrange": "99", 29 | }, 30 | { 31 | "name": "dummy_2", 32 | "protocol": "ICMP", 33 | "icmptype": "8", 34 | "icmpcode": "9", 35 | }, 36 | { 37 | "name": "dummy_3", 38 | "protocol": "ICMP6", 39 | "icmptype": "8", 40 | "icmpcode": "9", 41 | }, 42 | { 43 | "name": "dummy_4", 44 | "protocol": "IP", 45 | "protocol-number": "89", 46 | }, 47 | { 48 | "name": "dummy_5", 49 | "protocol": "dummy-type", 50 | }, 51 | ], 52 | "vdom": "vdom_1", 53 | } 54 | ] 55 | api_get_mock = Mock(return_value=result_mock) 56 | monkeypatch.setattr("fotoobo.fortinet.fortigate.FortiGate.api_get", api_get_mock) 57 | save_raw_mock = Mock(return_value=True) 58 | monkeypatch.setattr("fotoobo.helpers.result.Result.save_raw", save_raw_mock) 59 | 60 | # Act 61 | result = get_cmdb_firewall_service_custom("test_fgt_1", "", "", "test.json") 62 | 63 | # Assert 64 | data = result.get_result("test_fgt_1") 65 | assert len(data) == 5 66 | assert data[0]["data_1"] == "88" 67 | assert data[1]["data_1"] == "8" 68 | assert data[2]["data_1"] == "8" 69 | assert data[3]["data_1"] == "89" 70 | assert data[4]["data_1"] == "" 71 | api_get_mock.assert_called_with(url="/cmdb/firewall.service/custom/", vdom="") 72 | save_raw_mock.assert_called_with(file=Path("test.json"), key="test_fgt_1") 73 | -------------------------------------------------------------------------------- /tests/tools/fmg/get/test_devices.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test fmg tools get devices. 3 | """ 4 | 5 | from unittest.mock import Mock 6 | 7 | from pytest import MonkeyPatch 8 | 9 | from fotoobo.tools.fmg.get import devices 10 | from tests.helper import ResponseMock 11 | 12 | 13 | def test_devices(monkeypatch: MonkeyPatch) -> None: 14 | """ 15 | Test get devices. 16 | """ 17 | 18 | # Arrange 19 | monkeypatch.setattr( 20 | "fotoobo.fortinet.fortimanager.FortiManager.api", 21 | Mock( 22 | return_value=ResponseMock( 23 | json={ 24 | "result": [ 25 | { 26 | "data": [ 27 | { 28 | "name": "dummy_1", 29 | "os_ver": 1, 30 | "mr": 2, 31 | "patch": 3, 32 | "ha_mode": 0, 33 | "platform_str": "dummy_platform_1", 34 | "desc": "dummy_description_1", 35 | }, 36 | { 37 | "name": "dummy_2", 38 | "os_ver": 4, 39 | "mr": 5, 40 | "patch": 6, 41 | "ha_mode": 1, 42 | "platform_str": "dummy_platform_2", 43 | "desc": "dummy_description_2", 44 | "ha_slave": [{"name": "node_1"}, {"name": "node_2"}], 45 | }, 46 | ], 47 | }, 48 | ], 49 | }, 50 | status=200, 51 | ), 52 | ), 53 | ) 54 | 55 | # Act 56 | result = devices("test_fmg") 57 | 58 | # Assert 59 | assert len(result.results) == 2 60 | 61 | host_1 = result.get_result("dummy_1") 62 | assert host_1["version"] == "1.2.3" 63 | assert host_1["ha_mode"] == "0" 64 | assert host_1["platform"] == "dummy_platform_1" 65 | assert host_1["desc"] == "dummy_description_1" 66 | assert host_1["ha_nodes"] == [] 67 | 68 | host_2 = result.get_result("dummy_2") 69 | assert host_2["version"] == "4.5.6" 70 | assert host_2["ha_mode"] == "1" 71 | assert host_2["platform"] == "dummy_platform_2" 72 | assert host_2["desc"] == "dummy_description_2" 73 | assert host_2["ha_nodes"] == ["node_1", "node_2"] 74 | -------------------------------------------------------------------------------- /fotoobo/tools/fgt/get.py: -------------------------------------------------------------------------------- 1 | """ 2 | FortiGate get version utility 3 | """ 4 | 5 | import concurrent.futures 6 | import logging 7 | 8 | from rich.progress import Progress 9 | 10 | from fotoobo.exceptions import GeneralError, GeneralWarning 11 | from fotoobo.fortinet.fortigate import FortiGate 12 | from fotoobo.helpers.config import config 13 | from fotoobo.helpers.result import Result 14 | from fotoobo.inventory import Inventory 15 | 16 | log = logging.getLogger("fotoobo") 17 | 18 | 19 | def version(host: str | None = None) -> Result[str]: 20 | """ 21 | FortiGate get version. 22 | 23 | Get the version(s) of one ore more FortiGates. 24 | 25 | Args: 26 | host: The host from the inventory to get the version. If you omit host, it will run over 27 | all FortiGates in the inventory. 28 | 29 | Returns: 30 | The Result object with all the results 31 | """ 32 | 33 | def _get_single_version(name: str, fgt: FortiGate) -> tuple[str, str]: 34 | """ 35 | Get the version from a FortiGate. 36 | 37 | This private method is used for multithreading. It only queries one single FortiGate for its 38 | version number status and returns it. 39 | 40 | Args: 41 | name: The name of the FortiGate (as defined in the inventory) 42 | fgt: The FortiGate object to query 43 | 44 | Returns: 45 | name: The name of the FortiGate (as defined in the inventory) 46 | status: The HA status of the FortiGate (fgt) 47 | """ 48 | log.debug("Getting FortiGate version for '%s'", name) 49 | try: 50 | fortigate_version = fgt.get_version() 51 | 52 | except (GeneralWarning, GeneralError) as exception: 53 | fortigate_version = f"unknown due to {exception.message}" 54 | 55 | return name, fortigate_version 56 | 57 | inventory = Inventory(config.inventory_file) 58 | fgts = inventory.get(host, "fortigate") 59 | result = Result[str]() 60 | 61 | with Progress() as progress: 62 | task = progress.add_task("getting FortiGate versions...", total=len(fgts)) 63 | with concurrent.futures.ThreadPoolExecutor(max_workers=10) as executor: 64 | futures = [] 65 | for name, fgt in fgts.items(): 66 | futures.append(executor.submit(_get_single_version, name, fgt)) 67 | 68 | for future in concurrent.futures.as_completed(futures): 69 | name, fortigate_version = future.result() 70 | result.push_result(name, fortigate_version) 71 | progress.update(task, advance=1) 72 | 73 | return result 74 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | # fotoobo | Fortinet Toolbox 4 | **make IT easy** 5 | 6 | [![GitHub release (latest by date)](https://img.shields.io/github/v/release/migros/fotoobo?style=plastic)](https://github.com/migros/fotoobo/releases) 7 | [![GitHub License](https://img.shields.io/github/license/migros/fotoobo?style=plastic)](https://github.com/migros/fotoobo/blob/main/LICENSE) 8 | ![PyPi Python versions](https://img.shields.io/pypi/pyversions/fotoobo?style=plastic) \ 9 | [![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/migros/fotoobo/tests.yaml?label=tests&style=plastic)](https://github.com/migros/fotoobo/actions) 10 | [![GitHub open issues](https://img.shields.io/github/issues-raw/migros/fotoobo?style=plastic)](https://github.com/migros/fotoobo/issues?q=is%3Aissue+is%3Aopen) 11 | [![GitHub closed issues](https://img.shields.io/github/issues-closed-raw/migros/fotoobo?style=plastic)](https://github.com/migros/fotoobo/issues?q=is%3Aissue+is%3Aclosed) \ 12 | [![GitHub Repo stars](https://img.shields.io/github/stars/migros/fotoobo?style=plastic)](https://github.com/migros/fotoobo/stargazers) 13 | [![GitHub watchers](https://img.shields.io/github/watchers/migros/fotoobo?style=plastic)](https://github.com/migros/fotoobo/watchers) 14 | [![GitHub forks](https://img.shields.io/github/forks/migros/fotoobo?style=plastic)](https://github.com/migros/fotoobo/forks) 15 | [![GitHub contributors](https://img.shields.io/github/contributors/migros/fotoobo?style=plastic)](https://github.com/migros/fotoobo/graphs/contributors) 16 | 17 | This is fotoobo, the mighty **Fo**rtinet **too**l**bo**x for managing your Fortinet environment. It 18 | is meant to be extendable to your needs. 19 | 20 | For detailed documentation see [fotoobo.readthedocs.io](https://fotoobo.readthedocs.io/) 21 | 22 | 23 | # Contribution 24 | 25 | To [contribute](CONTRIBUTING.md) to this project please start by reading and accepting our 26 | [Collective Code Construction Contract](https://fotoobo.readthedocs.io/en/latest/developer/c4.html). 27 | 28 | 29 | # License 30 | 31 | fotoobo is licensed under the [LGPL v3](https://www.gnu.org/licenses/lgpl-3.0.html.en). 32 | 33 | Since it is a python application which does not compile to a binary before distribution, we want to 34 | clarify the following: 35 | 36 | fotoobo may be used as an imported module under the same terms that the LGPL states for dynamically 37 | linked (binary) libraries. So you may use fotoobo (unmodified) with code under another license (free 38 | or commercial). 39 | 40 | All changes to the fotoobo code itself are subject to the LGPL v3. 41 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """The pytest global fixtures""" 2 | 3 | from pathlib import Path 4 | from typing import Any 5 | 6 | import pytest 7 | 8 | 9 | @pytest.fixture(scope="session") 10 | def session_dir(tmp_path_factory: pytest.TempPathFactory) -> Path: 11 | """ 12 | Creates and maintains a session temp directory. 13 | 14 | This directory is persistent over th whole test session and for every test. So be sure to not 15 | rely on a particular content of this dir. Only use it for long term temp files. 16 | """ 17 | 18 | return tmp_path_factory.mktemp("session_") 19 | 20 | 21 | @pytest.fixture(scope="module") 22 | def module_dir(tmp_path_factory: pytest.TempPathFactory, request: pytest.FixtureRequest) -> Path: 23 | """Creates and maintains a module temp directory. 24 | 25 | This directory is persistent over all tests in a module. Every test in that module accesses 26 | this same directory so pbe aware to not rely on a particular content of thos dir. 27 | """ 28 | 29 | return tmp_path_factory.mktemp(f"module_{request.module.__name__}_") 30 | 31 | 32 | @pytest.fixture(scope="function") 33 | def function_dir(tmp_path_factory: pytest.TempPathFactory, request: pytest.FixtureRequest) -> Path: 34 | """Creates and maintains a function temp directory. 35 | 36 | This directory is created for every single test. Tests functions or methods can not access 37 | others tests or functions directories. 38 | """ 39 | 40 | return tmp_path_factory.mktemp( 41 | f"function_{request.module.__name__}_{request.function.__name__}_" 42 | ) 43 | 44 | 45 | @pytest.fixture( 46 | params=[ 47 | pytest.param("-h", id="with -h"), 48 | pytest.param("--help", id="with --help"), 49 | ] 50 | ) 51 | def help_args(request: pytest.FixtureRequest) -> Any: 52 | """A parametrized fixture to return '-h' and '--help' 53 | 54 | Use this fixture when a command can be invoked without any arguments. In this case invoking the 55 | command without any argument starts the command instead of showing the help. 56 | This is when no_args_is_help=False which is default""" 57 | return request.param 58 | 59 | 60 | @pytest.fixture( 61 | params=[ 62 | pytest.param("", id="no help arg"), 63 | pytest.param("-h", id="with -h"), 64 | pytest.param("--help", id="with --help"), 65 | ] 66 | ) 67 | def help_args_with_none(request: pytest.FixtureRequest) -> Any: 68 | """A parametrized fixture to return '', '-h' and '--help' 69 | 70 | Use this fixture when a command can't be invoked without any arguments. In this case invoking 71 | the command without any argument shows the help. 72 | This is when no_args_is_help=True 73 | """ 74 | return request.param 75 | -------------------------------------------------------------------------------- /.github/workflows/tests.yaml: -------------------------------------------------------------------------------- 1 | name: Code Tests 🚨 2 | run-name: testing workflow invoked by ${{ github.actor }} 3 | 4 | on: [push, pull_request] 5 | 6 | jobs: 7 | tests: 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] 12 | steps: 13 | - uses: actions/checkout@v4 14 | - name: Install poetry 15 | run: | 16 | pipx install poetry 17 | - name: Set up Python ${{ matrix.python-version }} 18 | uses: actions/setup-python@v5 19 | with: 20 | python-version: ${{ matrix.python-version }} 21 | - run: poetry install --extras dev --extras docs 22 | - name: Analyzing the code with pylint 23 | run: | 24 | poetry run tox -e pylint 25 | - name: Type checking with mypy 26 | run: | 27 | poetry run tox -- -e mypy -- --junit-xml mypy.xml 28 | - name: Upload mypy report 29 | uses: actions/upload-artifact@v4 30 | with: 31 | name: mypy-report-${{ matrix.python-version }} 32 | path: mypy.xml 33 | - name: Check formatting with black 34 | run: | 35 | poetry run tox -e black 36 | - name: Unittests with pytest 37 | run: | 38 | poetry run tox -e pytest 39 | 40 | coverage: 41 | runs-on: ubuntu-latest 42 | steps: 43 | - uses: actions/checkout@v4 44 | - name: Install poetry 45 | run: | 46 | pipx install poetry 47 | - name: Set up Python 48 | uses: actions/setup-python@v5 49 | with: 50 | python-version: "3.14" 51 | - run: poetry install --extras dev 52 | - name: Check code coverage 53 | run: | 54 | poetry run tox -- -e coverage -- --cov-report=xml 55 | - name: Upload covearge report 56 | uses: actions/upload-artifact@v4 57 | with: 58 | name: coverage.xml 59 | path: coverage.xml 60 | 61 | poetry-check: 62 | runs-on: ubuntu-latest 63 | continue-on-error: true 64 | steps: 65 | - uses: actions/checkout@v4 66 | - name: Install poetry 67 | run: | 68 | pipx install poetry 69 | - name: Check pyproject.toml 70 | run: | 71 | poetry check --strict 72 | 73 | 74 | docs: 75 | runs-on: ubuntu-latest 76 | needs: tests 77 | steps: 78 | - uses: actions/checkout@v4 79 | - name: Install poetry 80 | run: | 81 | pipx install poetry 82 | - name: Set up Python 83 | uses: actions/setup-python@v5 84 | with: 85 | python-version: "3.14" 86 | - run: poetry install --extras docs 87 | - name: Create documentation 88 | run: | 89 | poetry run tox -e docs 90 | -------------------------------------------------------------------------------- /tests/cli/cloud/asset/test_get.py: -------------------------------------------------------------------------------- 1 | """ 2 | Testing the cloud asset get cli app. 3 | """ 4 | 5 | from unittest.mock import Mock 6 | 7 | from pytest import MonkeyPatch 8 | from typer.testing import CliRunner 9 | 10 | from fotoobo.cli.main import app 11 | from tests.helper import parse_help_output 12 | 13 | runner = CliRunner() 14 | 15 | 16 | def test_cli_app_asset_get_help(help_args_with_none: str) -> None: 17 | """ 18 | Test cli help for asset get. 19 | """ 20 | 21 | # Arrange 22 | args = ["-c", "tests/fotoobo.yaml", "cloud", "asset", "get"] 23 | args.append(help_args_with_none) 24 | args = list(filter(None, args)) 25 | 26 | # Act 27 | result = runner.invoke(app, args) 28 | 29 | # Assert 30 | assert result.exit_code in [0, 2] 31 | arguments, options, commands = parse_help_output(result.stdout) 32 | assert not arguments 33 | assert options == {"-h", "--help"} 34 | assert set(commands) == {"products", "version"} 35 | 36 | 37 | def test_cli_app_asset_get_version_help(help_args: str) -> None: 38 | """ 39 | Test cli help for asset get version. 40 | """ 41 | 42 | # Arrange 43 | args = ["-c", "tests/fotoobo.yaml", "cloud", "asset", "get", "version"] 44 | args.append(help_args) 45 | 46 | # Act 47 | result = runner.invoke(app, args) 48 | 49 | # Assert 50 | assert result.exit_code == 0 51 | arguments, options, commands = parse_help_output(result.stdout) 52 | assert not arguments 53 | assert options == {"-h", "--help"} 54 | assert not commands 55 | 56 | 57 | def test_cli_app_asset_get_version(monkeypatch: MonkeyPatch) -> None: 58 | """ 59 | Test cli options and commands for asset get version. 60 | """ 61 | 62 | # Arrange 63 | monkeypatch.setattr( 64 | "fotoobo.fortinet.forticloudasset.FortiCloudAsset.post", 65 | Mock(return_value={"version": "3.0"}), 66 | ) 67 | 68 | # Act 69 | result = runner.invoke(app, ["-c", "tests/fotoobo.yaml", "cloud", "asset", "get", "version"]) 70 | 71 | # Assert 72 | assert result.exit_code == 0 73 | assert "forticloudasset " in result.stdout 74 | assert " │ 3.0" in result.stdout 75 | 76 | 77 | def test_cli_app_asset_get_products_help(help_args: str) -> None: 78 | """ 79 | Test cli help for asset get products. 80 | """ 81 | 82 | # Arrange 83 | args = ["-c", "tests/fotoobo.yaml", "cloud", "asset", "get", "products"] 84 | args.append(help_args) 85 | 86 | # Act 87 | result = runner.invoke(app, args) 88 | 89 | # Assert 90 | assert result.exit_code == 0 91 | arguments, options, commands = parse_help_output(result.stdout) 92 | assert not arguments 93 | assert options == {"--raw", "-r", "--output", "-o", "-h", "--help"} 94 | assert not commands 95 | -------------------------------------------------------------------------------- /tests/tools/fmg/get/test_policy.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test fmg tools get policy. 3 | """ 4 | 5 | from unittest.mock import Mock 6 | 7 | import pytest 8 | from pytest import MonkeyPatch 9 | 10 | from fotoobo.exceptions import GeneralError 11 | from fotoobo.tools.fmg.get import policy 12 | from tests.helper import ResponseMock 13 | 14 | 15 | def test_policy(monkeypatch: MonkeyPatch) -> None: 16 | """ 17 | Test get policy. 18 | """ 19 | 20 | # Arrange 21 | monkeypatch.setattr( 22 | "fotoobo.fortinet.fortimanager.FortiManager.api", 23 | Mock( 24 | return_value=ResponseMock( 25 | json={ 26 | "result": [ 27 | { 28 | "status": {"code": 0}, 29 | "data": [ 30 | { 31 | "status": "0", 32 | "global-label": "1", 33 | "policyid": "2", 34 | "srcaddr": "3", 35 | "groups": "4", 36 | "dstaddr": "5", 37 | "service": "6", 38 | "action": "7", 39 | "send-deny-packet": "8", 40 | "comments": "9", 41 | "dummy": "10", 42 | } 43 | ], 44 | } 45 | ], 46 | }, 47 | status=200, 48 | ) 49 | ), 50 | ) 51 | 52 | # Act 53 | result = policy("test_fmg", "", "") 54 | 55 | # Assert 56 | data = result.get_result("test_fmg") 57 | assert data == [ 58 | { 59 | "status": "0", 60 | "global-label": "1", 61 | "policyid": "2", 62 | "srcaddr": "3", 63 | "groups": "4", 64 | "dstaddr": "5", 65 | "service": "6", 66 | "action": "7", 67 | "send-deny-packet": "8", 68 | "comments": "9", 69 | } 70 | ] 71 | 72 | 73 | def test_policy_exception_status_not_0(monkeypatch: MonkeyPatch) -> None: 74 | """ 75 | Test get policy with exception when status is not 200. 76 | """ 77 | 78 | # Arrange 79 | monkeypatch.setattr( 80 | "fotoobo.fortinet.fortimanager.FortiManager.api", 81 | Mock( 82 | return_value=ResponseMock( 83 | json={"result": [{"status": {"code": 42, "message": "msg"}}]}, 84 | status=200, 85 | ) 86 | ), 87 | ) 88 | 89 | # Act & Assert 90 | with pytest.raises(GeneralError, match=r"FortiManager test_fmg returned 42: msg"): 91 | policy("test_fmg", "", "") 92 | -------------------------------------------------------------------------------- /tests/tools/cloud/asset/get/test_products.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test fcasset tools get version. 3 | """ 4 | 5 | from unittest.mock import Mock 6 | 7 | from pytest import MonkeyPatch 8 | 9 | from fotoobo.tools.cloud.asset.get import products 10 | 11 | 12 | def test_products(monkeypatch: MonkeyPatch) -> None: 13 | """ 14 | Test get products. 15 | """ 16 | 17 | # Arrange 18 | monkeypatch.setattr( 19 | "fotoobo.fortinet.forticloudasset.FortiCloudAsset.post", 20 | Mock( 21 | return_value={ 22 | "build": "1.0.0", 23 | "error": None, 24 | "message": "Request processed successfully", 25 | "status": 0, 26 | "token": "dummy_token", 27 | "version": "3.0", 28 | "assets": [ 29 | { 30 | "description": "dummy_fortigate", 31 | "entitlements": [], 32 | "isDecommissioned": False, 33 | "productModel": "FortiGate 100F", 34 | "registrationDate": "2025-10-20T08:09:10", 35 | "serialNumber": "FG100F0123456789", 36 | "assetGroups": [], 37 | "contracts": [], 38 | "productModelEoR": "2099-10-20T08:09:10", 39 | "productModelEoS": "2099-10-20T08:09:10", 40 | "accountId": 123456, 41 | "folderId": 12345, 42 | "folderPath": "/dummy/folder", 43 | "status": "Registered", 44 | } 45 | ], 46 | "pageNumber": 1, 47 | "totalPages": 1, 48 | } 49 | ), 50 | ) 51 | 52 | # Act 53 | result = products("forticloudasset") 54 | 55 | # Assert 56 | data = result.get_result("forticloudasset") 57 | assert data[0]["description"] == "dummy_fortigate" 58 | 59 | 60 | def test_products_empty(monkeypatch: MonkeyPatch) -> None: 61 | """ 62 | Test get products when there are no products in list. 63 | """ 64 | 65 | # Arrange 66 | monkeypatch.setattr( 67 | "fotoobo.fortinet.forticloudasset.FortiCloudAsset.post", 68 | Mock( 69 | return_value={ 70 | "build": "1.0.0", 71 | "error": { 72 | "errorCode": 301, 73 | "message": "No product found", 74 | }, 75 | "message": "No product found", 76 | "status": 0, 77 | "token": "dummy_token", 78 | "version": "3.0", 79 | "assets": None, 80 | "pageNumber": 0, 81 | "totalPages": 0, 82 | } 83 | ), 84 | ) 85 | 86 | # Act 87 | result = products("forticloudasset") 88 | 89 | # Assert 90 | msg = result.get_messages("forticloudasset") 91 | assert msg[0]["message"] == "No product found" 92 | -------------------------------------------------------------------------------- /logging-config.yaml.sample: -------------------------------------------------------------------------------- 1 | --- 2 | version: 1 3 | # WARNING: This is only needed for extended logging needs! 4 | # Basic logging can be configured in fotoobo.yaml 5 | # 6 | # This is a detailed logging configuration for fotoobo 7 | # 8 | # See https://docs.python.org/3/library/logging.config.html#configuration-dictionary-schema 9 | # for details on the configuration file format. 10 | # 11 | # Please note following specifics to fotoobo logging: 12 | # - fotoobo uses the following two loggers: 13 | # - "audit" for audit logs 14 | # - "fotoobo" for any other logs 15 | # - The following external loggers may also be configured up to your desire: 16 | # - "requests" 17 | # - "urllib3" 18 | # - "urllib3.connectionpool" (Note: this one is very, very chatty if the loglevel is set to 19 | # anything other than "CRITICAL") 20 | 21 | loggers: 22 | # Configure the fotoobo logger 23 | fotoobo: 24 | level: DEBUG 25 | handlers: [console, file, syslog] 26 | propagate: no 27 | 28 | # Configure the fotoobo audit logger 29 | audit: 30 | level: DEBUG 31 | handlers: [console, audit_file, audit_syslog] 32 | propagate: no 33 | 34 | 35 | handlers: 36 | # For console output 37 | console: 38 | class: logging.StreamHandler 39 | level: DEBUG 40 | formatter: simple 41 | stream: ext://sys.stdout 42 | 43 | # Output logs to a file 44 | file: 45 | class: logging.handlers.RotatingFileHandler 46 | level: DEBUG 47 | formatter: precise 48 | filename: fotoobo.log 49 | maxBytes: 10485760 # 10 MByte 50 | backupCount: 3 51 | 52 | # Output logs to syslog 53 | syslog: 54 | class: logging.handlers.SysLogHandler 55 | level: DEBUG 56 | formatter: syslog 57 | address: 58 | - 'localhost' 59 | - 514 60 | socktype: 2 # Use 1 for TCP and 2 for UDP 61 | 62 | # Output audit logs to a (separate) file 63 | audit_file: 64 | class: logging.handlers.RotatingFileHandler 65 | level: INFO 66 | formatter: precise_audit 67 | filename: fotoobo-audit.log 68 | maxBytes: 10485760 # 10 MByte 69 | backupCount: 5 70 | 71 | # Output audit logs to syslog 72 | audit_syslog: 73 | class: logging.handlers.SysLogHandler 74 | level: DEBUG 75 | formatter: syslog_audit 76 | address: 77 | - 'localhost' 78 | - 514 79 | socktype: 2 # Use 1 for TCP and 2 for UDP 80 | 81 | formatters: 82 | # A simple formatter, usually needed for console output 83 | simple: 84 | format: '%(asctime)s - %(name)s - %(levelname)s - %(message)s' 85 | # A precise formatter, usually needed for file output 86 | precise: 87 | format: '%(asctime)s - %(name)s - %(levelname)s - %(filename)s:%(lineno)d: %(message)s' 88 | # A precise formatter for audit logs, usually needed for file output 89 | precise_audit: 90 | format: '%(asctime)s - AUDIT - %(filename)s:%(lineno)d: %(message)s' 91 | # The syslog formatter for general log messages 92 | syslog: 93 | format: '%(levelname)s:%(name)s:%(message)s' 94 | # The syslog formatter for audit logs 95 | syslog_audit: 96 | format: 'AUDIT:fotoobo:%(message)s' 97 | -------------------------------------------------------------------------------- /fotoobo/cli/cloud/asset/get.py: -------------------------------------------------------------------------------- 1 | """ 2 | The FortiCloudAsset get commands 3 | """ 4 | 5 | import logging 6 | from pathlib import Path 7 | from typing import Any 8 | 9 | import typer 10 | from typing_extensions import Annotated 11 | 12 | from fotoobo.exceptions.exceptions import GeneralError 13 | from fotoobo.helpers import cli_path 14 | from fotoobo.helpers.files import save_json_file 15 | from fotoobo.helpers.result import Result 16 | from fotoobo.tools import fcasset 17 | 18 | app = typer.Typer(no_args_is_help=True, rich_markup_mode="rich") 19 | log = logging.getLogger("fotoobo") 20 | 21 | 22 | HELP_TEXT_OPTION_OUTPUT_FILE = "The file to write the output to." 23 | 24 | 25 | @app.callback() 26 | def callback(context: typer.Context) -> None: 27 | """ 28 | The fcasset get subcommand callback 29 | 30 | Args: 31 | context: The context object of the typer app 32 | """ 33 | cli_path.append(str(context.invoked_subcommand)) 34 | log.debug("About to execute command: '%s'", context.invoked_subcommand) 35 | 36 | 37 | @app.command() 38 | def products( 39 | output_file: Annotated[ 40 | Path | None, 41 | typer.Option( 42 | "--output", 43 | "-o", 44 | help=HELP_TEXT_OPTION_OUTPUT_FILE, 45 | metavar="[output]", 46 | ), 47 | ] = None, 48 | raw: Annotated[bool, typer.Option("-r", "--raw", help="Output raw data.")] = False, 49 | ) -> None: 50 | """ 51 | Get the FortiCloud products from asset management. 52 | """ 53 | result = fcasset.get.products("forticloudasset") 54 | messages = result.get_messages("forticloudasset") 55 | if messages: 56 | for message in messages: 57 | if message["level"] == "error": 58 | log.error(message["message"]) 59 | 60 | else: 61 | log.info(message["message"]) 62 | 63 | raise GeneralError("Error getting FortiCloud products") 64 | 65 | if output_file: 66 | log.debug("output_file is: '%s'", output_file) 67 | save_json_file(output_file, result.get_result("forticloudasset")) 68 | 69 | else: 70 | if raw: 71 | result.print_raw() 72 | 73 | else: 74 | data = Result[Any]() 75 | for asset in result.get_result("forticloudasset"): 76 | asset.pop("entitlements") 77 | asset.pop("contracts") 78 | asset.pop("productModelEoR") 79 | asset.pop("productModelEoS") 80 | asset.pop("accountId") 81 | asset.pop("folderId") 82 | asset.pop("assetGroups") 83 | data.push_result(asset["serialNumber"], asset) 84 | 85 | data.print_result_as_table(title="FortiCloud API products", auto_header=True) 86 | 87 | 88 | @app.command() 89 | def version() -> None: 90 | """ 91 | Get the FortiCloud API version. 92 | """ 93 | result = fcasset.get.version("forticloudasset") 94 | result.print_result_as_table( 95 | title="FortiCloud Asset Management API Version", 96 | headers=["FortiCloud Asset Management", "Version"], 97 | ) 98 | -------------------------------------------------------------------------------- /docs/source/developer/architecture/5_building_block_view.rst: -------------------------------------------------------------------------------- 1 | .. Chapter five according to https://arc42.org/overview 2 | 3 | .. _BuildingBlockView: 4 | 5 | 6 | 7 | 5. Building Block View 8 | ====================== 9 | 10 | The layers of fotoobo 11 | --------------------- 12 | 13 | There are three designated layers in **fotoobo**. These are the interface layer, the business logic 14 | layer and the infrastructure layer. 15 | 16 | .. image:: diagrams/fotoobo_layers.drawio.svg 17 | :width: 100% 18 | :alt: The layers of fotoobo visualized 19 | 20 | In this diagram you see the designated paths to use when accessing a Fortinet device. Although these 21 | are the recommended ways to use it, you may also access the layers in other ways. Just keep in mind 22 | there always should be a rational and understandable approach. 23 | 24 | 25 | The Interface Layer 26 | ^^^^^^^^^^^^^^^^^^^ 27 | 28 | This is the main entry point for interaction with **fotoobo** when you installed it as an 29 | application on your system. It acts as a frontend for users and automation tools. At the Moment only 30 | the CLI part is implemented. In future version a REST API may be available if there is a need for 31 | it. 32 | 33 | Call **fotoobo** with its command(s) directly from the command line or from any automation engine 34 | like cron jobs, Rundeck or others. 35 | 36 | The Business Logic Layer 37 | ^^^^^^^^^^^^^^^^^^^^^^^^ 38 | 39 | At this level you have access to specific use cases. These are called **tools** in **fotoobo**. Any 40 | CLI command or API endpoint should point to such a **tool** which then interacts with the 41 | infrastructure layer. 42 | 43 | Whenever you use **fotoobo** as a module in your own code (instead of using it as an installed 44 | application) you may directly access this layer. 45 | 46 | The Infrastructure Layer 47 | ^^^^^^^^^^^^^^^^^^^^^^^^ 48 | 49 | In this lowest level of **fotoobo** we directly interact with the infrastructure, meaning we handle 50 | the authentication and API calls to the devices and services like FortiGate, FortiManger and 51 | others. The interface layer should not directly access this infrastructure layer. Instead there 52 | should always be a **tool** in the business logic layer which connects these two. No other layer 53 | than the infrastructure layer should directly access any fortinet device. 54 | 55 | As a module in your own code you may also directly use the infrastructure layer. 56 | 57 | 58 | Package structure 59 | ----------------- 60 | 61 | The package structure describes the internal structure of **fotoobo**. The following diagram 62 | visualizes the top level modules in the code. There are direct associations for these modules to 63 | the layers of **fotoobo**: 64 | 65 | * cli: Interface Layer 66 | * tools: Business Logic Layer 67 | * fortinet: Infrastructure Layer 68 | 69 | 70 | .. image:: diagrams/package_structure.drawio.svg 71 | :width: 100% 72 | :alt: The fotoobo package structure visualized 73 | 74 | 75 | .. _fortinet_classes: 76 | 77 | Class view 78 | ---------- 79 | 80 | .. image:: diagrams/classes.drawio.svg 81 | :width: 100% 82 | :alt: The fotoobo Fortinet classes visualized 83 | 84 | -------------------------------------------------------------------------------- /tests/tools/test_get.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test fotoobo get tools. 3 | """ 4 | 5 | from unittest.mock import Mock 6 | 7 | from pytest import MonkeyPatch 8 | from rich.tree import Tree 9 | 10 | from fotoobo.tools.get import commands, inventory, version 11 | 12 | 13 | def test_get_commands(monkeypatch: MonkeyPatch) -> None: 14 | """ 15 | Test get commands. 16 | """ 17 | 18 | # Arrange 19 | monkeypatch.setattr( 20 | "fotoobo.helpers.config.config.cli_info", 21 | { 22 | "info_name": "fotoobo", 23 | "command": { 24 | "commands": { 25 | "level1cmd1": { 26 | "commands": {}, 27 | "help": "help1_1\n\nhelp1_2", 28 | "hidden": False, 29 | "name": "level1cmd1", 30 | }, 31 | "level1cmd2": { 32 | "commands": {}, 33 | "help": "help2_1\n\nhelp2_2", 34 | "hidden": True, 35 | "name": "level1cmd2", 36 | }, 37 | } 38 | }, 39 | }, 40 | ) 41 | 42 | # Act 43 | result = commands().get_result("commands") 44 | 45 | # Assert 46 | assert isinstance(result, Tree) 47 | assert "level1cmd1" in result.children[0].label.plain # type: ignore 48 | assert "help1_1" in result.children[0].label.plain # type: ignore 49 | 50 | 51 | def test_get_inventory(monkeypatch: MonkeyPatch) -> None: 52 | """ 53 | Test get inventory. 54 | """ 55 | 56 | # Arrange 57 | monkeypatch.setattr( 58 | "fotoobo.inventory.inventory.load_yaml_file", 59 | Mock( 60 | return_value={ 61 | "dummy_1": {"hostname": "name_1"}, 62 | "dummy_2": {"hostname": "name_2", "type": "type_2"}, 63 | } 64 | ), 65 | ) 66 | 67 | # Act 68 | result = inventory().all_results() 69 | 70 | # Assert 71 | assert isinstance(result, dict) 72 | assert len(result) == 2 73 | assert result["dummy_1"]["hostname"] == "name_1" 74 | assert result["dummy_2"]["hostname"] == "name_2" 75 | assert result["dummy_2"]["type"] == "type_2" 76 | 77 | 78 | def test_get_version() -> None: 79 | """ 80 | Test get version. 81 | """ 82 | 83 | # Act 84 | result = version().get_result("version") 85 | 86 | # Assert 87 | assert len(result) == 1 88 | assert result[0]["module"] == "fotoobo" 89 | 90 | 91 | def test_get_version_verbose() -> None: 92 | """ 93 | Test get version with verbose level set. 94 | """ 95 | 96 | # Act 97 | result = version(True).get_result("version") 98 | 99 | # Assert 100 | assert len(result) >= 1 101 | 102 | version_keys = [] 103 | for entry in result: 104 | version_keys.append(entry["module"]) 105 | 106 | assert "jinja2" in version_keys 107 | assert "rich" in version_keys 108 | assert "requests" in version_keys 109 | assert "PyYAML" in version_keys 110 | assert "typer" in version_keys 111 | -------------------------------------------------------------------------------- /tests/cli/fgt/test_fgt.py: -------------------------------------------------------------------------------- 1 | """ 2 | Testing the cli app. 3 | """ 4 | 5 | from pathlib import Path 6 | from unittest.mock import Mock 7 | 8 | from pytest import MonkeyPatch 9 | from typer.testing import CliRunner 10 | 11 | from fotoobo.cli.main import app 12 | from tests.helper import parse_help_output 13 | 14 | runner = CliRunner() 15 | 16 | 17 | def test_cli_app_fgt_help(help_args_with_none: str) -> None: 18 | """ 19 | Test cli help for fgt. 20 | """ 21 | 22 | # Arrange 23 | args = ["-c", "tests/fotoobo.yaml", "fgt"] 24 | args.append(help_args_with_none) 25 | args = list(filter(None, args)) 26 | 27 | # Act 28 | result = runner.invoke(app, args) 29 | 30 | # Assert 31 | assert result.exit_code in [0, 2] 32 | arguments, options, commands = parse_help_output(result.stdout) 33 | assert not arguments 34 | assert options == {"-h", "--help"} 35 | assert set(commands) == {"backup", "monitor", "get", "config"} 36 | 37 | 38 | def test_cli_app_fgt_backup_help(help_args: str) -> None: 39 | """ 40 | Test cli help for fgt backup. 41 | """ 42 | 43 | # Arrange 44 | args = ["-c", "tests/fotoobo.yaml", "fgt", "backup"] 45 | args.append(help_args) 46 | 47 | # Act 48 | result = runner.invoke(app, args) 49 | 50 | # Assert 51 | assert result.exit_code == 0 52 | arguments, options, commands = parse_help_output(result.stdout) 53 | assert set(arguments) == {"host"} 54 | assert options == { 55 | "--timeout", 56 | "-t", 57 | "--backup-dir", 58 | "-b", 59 | "--ftp", 60 | "-f", 61 | "--smtp", 62 | "-s", 63 | "-h", 64 | "--help", 65 | } 66 | assert not commands 67 | 68 | 69 | def test_cli_app_fgt_backup_single(monkeypatch: MonkeyPatch, function_dir: Path) -> None: 70 | """ 71 | Test cli fgt backup with no FortiGate given so it backups all. 72 | """ 73 | 74 | # Arrange 75 | monkeypatch.setattr( 76 | "fotoobo.fortinet.fortigate.FortiGate.backup", 77 | Mock(return_value="#config-version\ntest-1234"), 78 | ) 79 | 80 | # Act 81 | result = runner.invoke( 82 | app, ["-c", "tests/fotoobo.yaml", "fgt", "backup", "test_fgt_1", "-b", str(function_dir)] 83 | ) 84 | 85 | # Assert 86 | assert result.exit_code == 0 87 | assert (function_dir / "test_fgt_1.conf").exists() 88 | assert not (function_dir / "test_fgt_2.conf").exists() 89 | 90 | 91 | def test_cli_app_fgt_backup_all(monkeypatch: MonkeyPatch, function_dir: Path) -> None: 92 | """ 93 | Test cli fgt backup with no FortiGate given so it backups all. 94 | """ 95 | 96 | # Arrange 97 | monkeypatch.setattr( 98 | "fotoobo.fortinet.fortigate.FortiGate.backup", 99 | Mock(return_value="#config-version\ntest-1234"), 100 | ) 101 | 102 | # Act 103 | result = runner.invoke( 104 | app, ["-c", "tests/fotoobo.yaml", "fgt", "backup", "-b", str(function_dir)] 105 | ) 106 | 107 | # Assert 108 | assert result.exit_code == 0 109 | assert (function_dir / "test_fgt_1.conf").exists() 110 | assert (function_dir / "test_fgt_2.conf").exists() 111 | -------------------------------------------------------------------------------- /fotoobo/tools/fgt/main.py: -------------------------------------------------------------------------------- 1 | """ 2 | FortiGate backup utility 3 | """ 4 | 5 | import concurrent.futures 6 | import json 7 | import logging 8 | 9 | from rich.progress import Progress 10 | 11 | from fotoobo.exceptions import APIError, GeneralError 12 | from fotoobo.fortinet.fortigate import FortiGate 13 | from fotoobo.helpers.config import config 14 | from fotoobo.helpers.result import Result 15 | from fotoobo.inventory import Inventory 16 | 17 | log = logging.getLogger("fotoobo") 18 | 19 | 20 | def backup( 21 | host: str | None = None, 22 | timeout: int = 60, 23 | ) -> Result[str]: 24 | """ 25 | Create a FortiGate configuration backup into a file and optionally upload it to an FTP server. 26 | 27 | Args: 28 | host: The host from the inventory to get the backup. If no host is given all FortiGate 29 | devices in the inventory are backed up. 30 | timeout: Timeout in seconds to wait for each FortiGate to 31 | 32 | Returns: 33 | The Result object with all the results 34 | """ 35 | result = Result[str]() 36 | inventory = Inventory(config.inventory_file) 37 | fgts = inventory.get(host, "fortigate") 38 | 39 | def _get_single_backup(name: str, fgt: FortiGate) -> tuple[str, str]: 40 | """Get the configuration backup from a single FortiGate. 41 | 42 | This private method is used for multithreading. It only queries one single FortiGate for its 43 | configuration backup and returns it. 44 | 45 | Args: 46 | name: The name of the FortiGate (as defined in the inventory) 47 | fgt: The FortiGate object to query 48 | 49 | Returns: 50 | name: The name of the FortiGate (as defined in the inventory) 51 | data: The configuration backup of the FortiGate (fgt) 52 | """ 53 | log.debug("Backup FortiGate '%s'", name) 54 | data: str = "" 55 | 56 | try: 57 | data = fgt.backup(timeout=timeout) 58 | if data.startswith("#config-version"): 59 | message = f"Config backup for '{name}' succeeded" 60 | log.info(message) 61 | result.push_message(name, message) 62 | 63 | else: 64 | data_json = json.loads(data) 65 | message = f"Backup '{name}' failed with error '{data_json['http_status']}'" 66 | log.error(message) 67 | result.push_message(name, message, level="error") 68 | 69 | except GeneralError as err: 70 | result.push_message(name, err.message, level="error") 71 | 72 | except APIError as err: 73 | result.push_message(name, f"{name} returned {err.message}", level="error") 74 | 75 | return name, data 76 | 77 | with Progress() as progress: 78 | task = progress.add_task("Download FortiGate backups...", total=len(fgts)) 79 | with concurrent.futures.ThreadPoolExecutor(max_workers=10) as executor: 80 | futures = [] 81 | for name, fgt in fgts.items(): 82 | futures.append(executor.submit(_get_single_backup, name, fgt)) 83 | 84 | for future in concurrent.futures.as_completed(futures): 85 | name, configuration_backup = future.result() 86 | result.push_result(name, configuration_backup) 87 | progress.update(task, advance=1) 88 | 89 | return result 90 | -------------------------------------------------------------------------------- /tests/helper.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module defines some global helpers for the test package. 3 | """ 4 | 5 | from typing import Any 6 | from unittest.mock import Mock 7 | 8 | from requests.exceptions import HTTPError 9 | 10 | 11 | class ResponseMock: # pylint: disable=too-many-instance-attributes 12 | """ 13 | This class mocks a http response object. 14 | """ 15 | 16 | def __init__(self, **kwargs: Any) -> None: 17 | """ 18 | Give the mock the response you expect. 19 | 20 | **kwargs: 21 | content: (Any, optional): Content of the response 22 | headers: (List, optional): List of headers 23 | json (Any, optional): JSON response. Defaults to "" 24 | ok (bool, optional): The OK flag. Defaults to True 25 | reason(str, optional): The response reason. Defaults to "" 26 | status_code (int, optional): HTTP status code. Defaults to 444 (No Response) 27 | text (str, optional): Text response. Defaults to "" 28 | """ 29 | self.content = kwargs.get("content", "") 30 | self.headers = kwargs.get("headers", []) 31 | self.json = Mock(return_value=kwargs.get("json", None)) 32 | self.ok = kwargs.get("ok", True) 33 | self.raise_for_status = Mock() 34 | self.reason = kwargs.get("reason", "") 35 | self.status_code = kwargs.get("status_code", 444) 36 | self.text = kwargs.get("text", "") 37 | 38 | if self.status_code >= 300: 39 | self.raise_for_status = Mock( 40 | side_effect=HTTPError("mocked HTTPError", response=self) # type: ignore 41 | ) 42 | 43 | 44 | def parse_help_output( # pylint: disable=too-many-branches 45 | output: str, 46 | ) -> tuple[dict[str, str], set[str], dict[str, str]]: 47 | """ 48 | Parse the output of the cli help and return the available arguments, options and commands. 49 | 50 | Args: 51 | output: the help output from the cli 52 | 53 | Returns: 54 | Dicts and Sets of strings containing arguments, options and commands 55 | """ 56 | arguments = {} 57 | options = set() 58 | commands = {} 59 | context = None 60 | for line in output.split("\n"): 61 | if line.startswith("╰──"): 62 | context = None 63 | continue 64 | 65 | if context == "arg": 66 | if not line.startswith("│ "): 67 | parts = line[3:-1].strip().split(maxsplit=2) 68 | if len(parts) > 2: 69 | arguments[parts[0]] = parts[2] 70 | 71 | if context == "opt": 72 | if line[1:-1].strip().startswith("-"): 73 | parts = line[1:-1].strip().split() 74 | if len(parts) > 1: 75 | options.add(parts[0].strip(",")) 76 | if parts[1].startswith("-"): 77 | options.add(parts[1]) 78 | 79 | if context == "cmd": 80 | if not line.startswith("│ "): 81 | parts = line[1:-1].strip().split(maxsplit=1) 82 | if len(parts) > 1: 83 | commands[parts[0]] = parts[1] 84 | 85 | if line.startswith("╭─ Arguments ─"): 86 | context = "arg" 87 | 88 | if line.startswith("╭─ Options ─"): 89 | context = "opt" 90 | 91 | if line.startswith("╭─ Commands ─"): 92 | context = "cmd" 93 | 94 | return arguments, options, commands 95 | -------------------------------------------------------------------------------- /fotoobo/tools/fmg/main.py: -------------------------------------------------------------------------------- 1 | """ 2 | FortiManager assign utility 3 | """ 4 | 5 | import logging 6 | from pathlib import Path 7 | 8 | from fotoobo.exceptions.exceptions import GeneralWarning 9 | from fotoobo.helpers.config import config 10 | from fotoobo.helpers.files import load_json_file 11 | from fotoobo.helpers.result import Result 12 | from fotoobo.inventory import Inventory 13 | 14 | log = logging.getLogger("fotoobo") 15 | 16 | 17 | def assign(adoms: str, policy: str, host: str, timeout: int = 60) -> Result[str]: 18 | """ 19 | Assign the global policy to the given ADOM 20 | 21 | Args: 22 | adoms: The ADOMs to assign the global policy to. Specify multiple ADOMs as a comma 23 | separated list (no spaces). 24 | policy: Specify the global policy to assign [Default: 'default']. 25 | host: The FortiManager defined in inventory. 26 | timeout Timeout in sec. to wait for the FortiManager task to finish [Default: 60]. 27 | """ 28 | result = Result[str]() 29 | inventory = Inventory(config.inventory_file) 30 | fmg = inventory.get_item(host, "fortimanager") 31 | log.debug("Assigning global policy/objects to ADOM '%s'", adoms) 32 | 33 | task_id = fmg.assign_all_objects(adoms=adoms, policy=policy) 34 | if task_id > 0: 35 | log.info("Created FortiManager task id '%s'", task_id) 36 | messages = fmg.wait_for_task(task_id, timeout=timeout) 37 | 38 | for message in messages: 39 | level = "debug" if message["state"] == 4 else "error" 40 | elapsed = ( 41 | str(message["end_tm"] - message["start_tm"]) + " sec" 42 | if message["end_tm"] > 0 43 | else "unfinished" 44 | ) 45 | result_message = f"{message['task_id']}: {message['name']}" 46 | 47 | if message["detail"]: 48 | result_message += f" / {message['detail']}" 49 | 50 | result_message += f" ({elapsed})" 51 | getattr(log, level)(result_message) 52 | result.push_message(host, result_message, level) 53 | 54 | if message["history"]: 55 | for line in message["history"]: 56 | result_message = f"- {line['detail']}" 57 | getattr(log, level)(result_message) 58 | result.push_message(host, result_message, level) 59 | 60 | return result 61 | 62 | 63 | def post(file: Path, adom: str, host: str) -> Result[str]: 64 | """ 65 | POST the given configuration from a JSON file to the FortiManager 66 | 67 | Args: 68 | file: The configuration file to add the configuration from 69 | adom: The ADOM to assign the global policy to 70 | host: The FortiManager defined in inventory 71 | 72 | Returns: 73 | Result 74 | 75 | Raises: 76 | GeneralWarning 77 | """ 78 | if not (payloads := load_json_file(file)): 79 | raise GeneralWarning(f"There is no data in the given file ({file})") 80 | 81 | inventory = Inventory(config.inventory_file) 82 | result: Result[str] = Result() 83 | fmg = inventory.get_item(host, "fortimanager") 84 | log.debug("FortiManager post command ...") 85 | log.info("Start posting assets to '%s'", host + "/" + adom) 86 | 87 | result_list = fmg.post(adom, payloads) 88 | if result_list: 89 | for line in result_list: 90 | result.push_message(host, line, "error") 91 | 92 | return result 93 | -------------------------------------------------------------------------------- /tests/cli/faz/test_get.py: -------------------------------------------------------------------------------- 1 | """ 2 | Testing the cli app. 3 | """ 4 | 5 | from unittest.mock import Mock 6 | 7 | from pytest import MonkeyPatch 8 | from typer.testing import CliRunner 9 | 10 | from fotoobo.cli.main import app 11 | from tests.helper import ResponseMock, parse_help_output 12 | 13 | runner = CliRunner() 14 | 15 | 16 | def test_cli_app_faz_get_help(help_args_with_none: str) -> None: 17 | """ 18 | Test cli help for faz get. 19 | """ 20 | 21 | # Arrange 22 | args = ["-c", "tests/fotoobo.yaml", "faz", "get"] 23 | args.append(help_args_with_none) 24 | args = list(filter(None, args)) 25 | 26 | # Act 27 | result = runner.invoke(app, args) 28 | 29 | # Assert 30 | assert result.exit_code in [0, 2] 31 | arguments, options, commands = parse_help_output(result.stdout) 32 | assert not arguments 33 | assert options == {"-h", "--help"} 34 | assert set(commands) == {"version"} 35 | 36 | 37 | def test_cli_app_faz_get_version_help(help_args: str) -> None: 38 | """ 39 | Test cli help for faz get version. 40 | """ 41 | 42 | # Arrange 43 | args = ["-c", "tests/fotoobo.yaml", "faz", "get", "version"] 44 | args.append(help_args) 45 | 46 | # Act 47 | result = runner.invoke(app, args) 48 | 49 | # Assert 50 | assert result.exit_code == 0 51 | arguments, options, commands = parse_help_output(result.stdout) 52 | assert set(arguments) == {"host"} 53 | assert options == {"-h", "--help"} 54 | assert not commands 55 | 56 | 57 | def test_cli_app_faz_get_version(monkeypatch: MonkeyPatch) -> None: 58 | """ 59 | Test cli options and commands for faz get version. 60 | """ 61 | 62 | # Arrange 63 | monkeypatch.setattr( 64 | "fotoobo.fortinet.fortinet.requests.Session.post", 65 | Mock( 66 | return_value=ResponseMock( 67 | json={"result": [{"data": {"Version": "v1.1.1-build1111 111111 (GA)"}}]}, 68 | status_code=200, 69 | ) 70 | ), 71 | ) 72 | 73 | # Act 74 | result = runner.invoke(app, ["-c", "tests/fotoobo.yaml", "faz", "get", "version", "test_faz"]) 75 | 76 | # Assert 77 | assert result.exit_code == 0 78 | assert "test_faz │ v1.1.1" in result.stdout 79 | 80 | 81 | def test_cli_app_faz_get_version_dummy() -> None: 82 | """ 83 | Test cli options and commands for faz get version with an unknown host. 84 | """ 85 | 86 | # Act 87 | result = runner.invoke(app, ["-c", "tests/fotoobo.yaml", "faz", "get", "version", "dummy_faz"]) 88 | 89 | # Assert 90 | assert result.exit_code == 1 91 | 92 | 93 | def test_cli_app_faz_get_version_none(monkeypatch: MonkeyPatch) -> None: 94 | """ 95 | Test cli options and commands for faz get version when there is no version in the response. 96 | """ 97 | 98 | # Arrange 99 | monkeypatch.setattr( 100 | "fotoobo.fortinet.fortinet.requests.Session.post", 101 | Mock( 102 | return_value=ResponseMock( 103 | json={"result": [{"data": {"Version": "dummy"}}]}, status_code=200 104 | ) 105 | ), 106 | ) 107 | 108 | # Act 109 | result = runner.invoke( 110 | app, ["-c", "tests/dummy_fotoobo.yaml", "faz", "get", "version", "test_faz"] 111 | ) 112 | 113 | # Assert 114 | assert result.exit_code == 0 115 | assert result.stdout.count("1.1.1") == 0 116 | -------------------------------------------------------------------------------- /docs/source/developer/architecture/7_crosscutting_concepts.rst: -------------------------------------------------------------------------------- 1 | .. Chapter eight according to https://arc42.org/overview 2 | 3 | .. _CrosscuttingConcepts: 4 | 5 | 6 | 7. Crosscutting concepts 7 | ======================== 8 | 9 | Separation of Input, Output and Processing 10 | ------------------------------------------ 11 | 12 | For **fotoobo** to be easily extendable with additional interfaces the input/output and processing 13 | layer need to be strictly separated. This is done by several measures: 14 | 15 | - Passing of **input** parameters is done by using parameters only. Any input specific parameter 16 | preprocessing should be done on the input/output layer. 17 | - Passing of **return** values is done by using a common `Result` class (see below). 18 | - The **processing** of the request is done by a so called **fotoobo** "tool", inside the **tools** 19 | directory. 20 | 21 | 22 | Common Result class 23 | ------------------- 24 | 25 | For passing output of a **fotoobo** tool to the interface there is a common `Result` class found 26 | in the fotoobo helpers. 27 | 28 | See :ref:`how_to_helpers_result` for more information about this. 29 | 30 | 31 | Parallelization 32 | --------------- 33 | 34 | By separating the *processing* from the *intput/output* part running a task in parallel against 35 | several Fortinet devices becomes easier to do. Please follow some guidelines when doing this: 36 | 37 | - *Always* do the parallelization inside a tool. The *input/output* layer should not be concerned 38 | about parallelization at all (except for example disabling it on request, if applicable). 39 | - You *should* make a nested function inside the tool function that will query one Fortinet instance 40 | and return the raw data. 41 | - Then use `ThreadPoolExecutor `_ 42 | of the Python `concurrent.futures` standard library to process and execute the single parts as 43 | depicted below. 44 | 45 | 46 | .. code-block:: python 47 | 48 | def my_tools_method(any, parameters, needed) -> Result: 49 | """ 50 | Example for parallelization, taken from tools.fgt.get.version() 51 | 52 | This example also includes the display of a progress bar on the console using 53 | rich.progress.Progress(). 54 | """ 55 | def _get_single_version(name: str, fgt: FortiGate): 56 | """ 57 | Get version for single FortiGate. 58 | 59 | NOTE: Method shortened for legibility 60 | """ 61 | fortigate_version = fgt.get_version() 62 | 63 | return name, fortigate_version 64 | 65 | result = Result[str]() 66 | 67 | with Progress() as progress: 68 | task = progress.add_task("getting FortiGate versions...", total=len(fgts)) 69 | with concurrent.futures.ThreadPoolExecutor(max_workers=10) as executor: 70 | futures = [] 71 | # Register all the tasks that need to be done 72 | for name, fgt in fgts.items(): 73 | futures.append(executor.submit(_get_single_version, name, fgt)) 74 | 75 | # Process the results of the single tasks as they are finished 76 | for future in concurrent.futures.as_completed(futures): 77 | name, fortigate_version = future.result() 78 | result.push_result(name, fortigate_version) 79 | progress.update(task, advance=1) 80 | 81 | return result 82 | -------------------------------------------------------------------------------- /tests/cli/fgt/test_config_info.py: -------------------------------------------------------------------------------- 1 | """ 2 | Testing the cli fgt config info. 3 | """ 4 | 5 | import pytest 6 | from typer.testing import CliRunner 7 | 8 | from fotoobo.cli.main import app 9 | from fotoobo.exceptions.exceptions import GeneralWarning 10 | from tests.helper import parse_help_output 11 | 12 | runner = CliRunner() 13 | 14 | 15 | def test_cli_app_fgt_config_info_help(help_args_with_none: str) -> None: 16 | """ 17 | Test cli help for fgt config info help. 18 | """ 19 | 20 | # Arrange 21 | args = ["-c", "tests/fotoobo.yaml", "fgt", "config", "info"] 22 | args.append(help_args_with_none) 23 | args = list(filter(None, args)) 24 | 25 | # Act 26 | result = runner.invoke(app, args) 27 | 28 | # Assert 29 | assert result.exit_code in [0, 2] 30 | assert "Usage: root fgt config info" in result.stdout 31 | arguments, options, commands = parse_help_output(result.stdout) 32 | assert set(arguments) == {"configuration"} 33 | assert options == {"-h", "--help", "-l", "--list"} 34 | assert not commands 35 | 36 | 37 | @pytest.mark.parametrize( 38 | "file", 39 | ( 40 | pytest.param("tests/data/fortigate_config_single.conf", id="single"), 41 | pytest.param("tests/data/fortigate_config_vdom.conf", id="vdom"), 42 | ), 43 | ) 44 | def test_cli_app_fgt_config_info(file: str) -> None: 45 | """ 46 | Test fgt config info. 47 | """ 48 | 49 | # Act 50 | result = runner.invoke( 51 | app, 52 | ["-c", "tests/fotoobo.yaml", "fgt", "config", "info", file], 53 | ) 54 | 55 | # Assert 56 | assert result.exit_code == 0 57 | assert "FGT999" in result.stdout 58 | 59 | 60 | def test_cli_app_fgt_config_info_empty_config() -> None: 61 | """ 62 | Test cli options and commands for fgt config info with an empty configuration. 63 | """ 64 | 65 | # Act & Assert 66 | with pytest.raises(GeneralWarning, match=r"There is no info in"): 67 | runner.invoke( 68 | app, 69 | [ 70 | "-c", 71 | "tests/fotoobo.yaml", 72 | "fgt", 73 | "config", 74 | "info", 75 | "tests/data/fortigate_config_empty.conf", 76 | ], 77 | catch_exceptions=False, 78 | ) 79 | 80 | 81 | def test_cli_app_fgt_config_info_nonexist_config_file() -> None: 82 | """ 83 | Test cli options and commands for fgt config info with an nonexisting configuration. 84 | """ 85 | 86 | # Act & Assert 87 | with pytest.raises(GeneralWarning, match=r"There are no"): 88 | runner.invoke( 89 | app, 90 | [ 91 | "-c", 92 | "tests/fotoobo.yaml", 93 | "fgt", 94 | "config", 95 | "info", 96 | "tests/data/fortigate_config_nonexist.conf", 97 | ], 98 | catch_exceptions=False, 99 | ) 100 | 101 | 102 | def test_cli_app_fgt_config_info_dir() -> None: 103 | """ 104 | Test cli options and commands for fgt config info if a directory is given. 105 | """ 106 | 107 | # Act & Assert 108 | with pytest.raises(GeneralWarning, match=r"There is no info in"): 109 | runner.invoke( 110 | app, 111 | ["-c", "tests/fotoobo.yaml", "fgt", "config", "info", "tests/data"], 112 | catch_exceptions=False, 113 | ) 114 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | .. fotoobo documentation master file 2 | You can adapt this file completely to your liking, but it should at least contain the root 3 | `toctree` directive. 4 | 5 | .. image:: fotoobo.png 6 | :width: 200px 7 | :align: right 8 | 9 | 10 | The fotoobo documentation 11 | ========================= 12 | 13 | This is fotoobo, the mighty **Fo**\ rtinet **too**\ l\ **bo**\ x for managing your Fortinet 14 | environment. The tool consists of several useful utilities with functionality which is not covered 15 | by the standard Fortinet management suite. It is meant to be extendable to your needs. So feel free 16 | to add or update the utilities as needed but please be aware that there are some guidelines on how 17 | to `contribute `_ to the development. 18 | 19 | For a more detailed introduction to what **fotoobo** is, read chapter 1 of the architecture 20 | documentation: :ref:`1IntroductionGoals`. 21 | 22 | .. toctree:: 23 | :maxdepth: 1 24 | :caption: User Documentation 25 | 26 | usage/getting_started 27 | usage/configuration 28 | usage/inventory 29 | usage/fortigate_config_check 30 | usage/convert 31 | usage/templating 32 | usage/import_fotoobo 33 | 34 | .. toctree:: 35 | :maxdepth: 1 36 | :caption: Developer Documentation 37 | 38 | developer/c4 39 | developer/architecture_documentation 40 | developer/how_to 41 | code 42 | 43 | 44 | Changelog 45 | --------- 46 | 47 | The changelog for this project is maintained in the repository and can be found here: 48 | ``_ 49 | 50 | 51 | Indices and tables 52 | ------------------ 53 | 54 | * :ref:`genindex` 55 | * :ref:`modindex` 56 | 57 | 58 | License 59 | ------- 60 | 61 | fotoobo is licensed under the `LGPL v3 `_. 62 | 63 | Since it is a python application which does not compile to a binary before distribution, we want to 64 | clarify the following: 65 | 66 | * fotoobo may be used as an imported module under the same terms that the LGPL states for 67 | dynamically linked (binary) libraries. So you may use fotoobo (unmodified) with code under another 68 | license (free or commercial). 69 | * All changes to the fotoobo code itself are subject to the LGPL v3. 70 | 71 | 72 | Built with 73 | ---------- 74 | 75 | * `Python `_ 76 | * `Poetry `_ 77 | * `Typer `_ 78 | * `Tox `_ 79 | * `Sphinx `_ 80 | with `Autodoc `_ 81 | * `Jinja2 `_ 82 | * and some more 83 | 84 | 85 | Authors 86 | ------- 87 | 88 | * `Patrik Spiess `_ 89 | (`Migros Genossenschafts Bund `_) 90 | * `Lukas Murer `_ 91 | (`Migros Genossenschafts Bund `_) 92 | (`onway `_) 93 | 94 | 95 | Sponsors 96 | -------- 97 | 98 | The main work for fotoobo is done by Fortinet enthusiasts at Migros Genossenschafts Bund, 99 | Switzerland. Thanks for the time and effort we are able to spend to this project in our daily work. 100 | 101 | .. image:: migros.svg 102 | :width: 200px 103 | :target: https://www.migros.ch/ 104 | 105 | .. image:: ftb.png 106 | :width: 50px 107 | :align: right 108 | -------------------------------------------------------------------------------- /docs/source/developer/how_to/how_to_test.rst: -------------------------------------------------------------------------------- 1 | .. Describes how to do write tests 2 | 3 | .. _how_to_test: 4 | 5 | How To Test 6 | =========== 7 | 8 | To test the code we use `pytest `_. 9 | 10 | Tests Structure 11 | --------------- 12 | 13 | According to `Anatomy of a test `_ we use the Arrange, Act, Assert, (Cleanup) terminology for every test function or method. 14 | 15 | **Good Example** 16 | 17 | .. code-block:: python 18 | 19 | from pathlib import Path 20 | 21 | def test_good() -> None: 22 | """ 23 | Test something with good style. 24 | """ 25 | 26 | # Arrange 27 | test_file = Path("testfile.txt") 28 | test_file.write_text("Hello") 29 | assert test_file.exists() 30 | 31 | # Act 32 | my_test_function(testfile) 33 | 34 | # Assert 35 | text = testfile.read_text() 36 | assert "Hello" in text 37 | 38 | **Bad Example** 39 | 40 | .. code-block:: python 41 | 42 | from pathlib import Path 43 | 44 | def test_bad() -> None: 45 | """ 46 | Test something with bad style. 47 | """ 48 | test_file = Path("testfile.txt") 49 | test_file.write_text("Hello") 50 | assert test_file.exists() 51 | my_test_function(testfile) 52 | text = testfile.read_text() 53 | assert "Hello" in text 54 | 55 | Static Data 56 | ----------- 57 | 58 | If you need static data for your tests use the directory *tests/data*. There you can put your data. 59 | If you change existing data make sure you do not break other tests. Make sure to not add any kind of 60 | productive and sensitive data into this directory as it is pushed to the repository. 61 | 62 | Temporary Data 63 | -------------- 64 | 65 | For temporary data use the pytest `TempPathFactory 66 | `_. See the 67 | following example on how to setup a temporary directory. 68 | 69 | .. code-block:: python 70 | 71 | from pathlib import Path 72 | 73 | import pytest 74 | 75 | @pytest.fixture(scope="session") 76 | def session_dir(tmp_path_factory: pytest.TempPathFactory) -> Path: 77 | """ 78 | Creates and maintains a session temp directory. 79 | """ 80 | 81 | return tmp_path_factory.mktemp("session_") 82 | 83 | There is also the same for module or function scope depending on how long you wish your temp directory exists. These fixtures are predefined in *tests/conftest.py* so you can use it in any test function or method. 84 | 85 | Mocking 86 | ------- 87 | 88 | For mocking we use the pytest predefined `monkeypatch 89 | `_ fixture. 90 | 91 | **Good Example** 92 | 93 | .. code-block:: python 94 | 95 | from unittest.mock import Mock 96 | 97 | import pytest 98 | 99 | def test_something_correct(monkeypatch: pytest.MonkeyPatch): 100 | """ 101 | This tests something the preferred way. 102 | """ 103 | 104 | monkeypatch.setattr("path.to.your.object", Mock(return_value=None)) 105 | ... 106 | 107 | **Bad Example** 108 | 109 | .. code-block:: python 110 | 111 | from unittest.mock import Mock, patch 112 | 113 | @patch("path.to.your.object", Mock(return_value=None)) 114 | def test_something_wrong(): 115 | """ 116 | This tests something the wrong way. 117 | """ 118 | 119 | ... 120 | -------------------------------------------------------------------------------- /docs/source/developer/architecture/8_quality_requirements.rst: -------------------------------------------------------------------------------- 1 | .. Chapter ten according to https://arc42.org/overview 2 | 3 | .. _QualityAssurance: 4 | 5 | 8. Quality Assurance 6 | ===================== 7 | 8 | Since most Fortinet products are security devices the stability and reliability of those devices is 9 | crucial to companies using **fotoobo**. **fotoobo** thus needs to be well tested, stable and reliable 10 | too. 11 | 12 | Quality Goals 13 | ------------- 14 | 15 | In particular **fotoobo** should be: 16 | 17 | - **Reliable**: 18 | 19 | - Updates and changes to **fotoobo** should not break any existing functionality, if not 20 | otherwise noted (and allowed by `Semantic Versioning `_). 21 | - **fotoobo** should behave the same on every request and as such should only depend on the input 22 | data given for the current request (and some weird internal state). 23 | 24 | - **Usable**: 25 | 26 | - The use of **fotoobo** should be as intuitive as possible for the core audience (computer 27 | network & security professionals). 28 | - The use of **fotoobo** should be well documented using an example based approach. 29 | 30 | - **Secure**: 31 | 32 | - **fotoobo** tries to use secure transmission by default everywhere and will not store or cache 33 | any sensitive information anywhere it was not instructed to. 34 | 35 | 36 | How quality assurance is done 37 | ----------------------------- 38 | 39 | Testing, Types & Linting 40 | ^^^^^^^^^^^^^^^^^^^^^^^^ 41 | 42 | A high code quality is the foundation of a stable and reliable software. We especially do the 43 | following: 44 | 45 | - The use of *static typing* using MyPy minimizes the risk of runtime errors based on using wrong 46 | types. 47 | - The use of *linting* using Pylint and enforcing consistent *code formatting* using Black and isort 48 | ensures that the code uses consistent formatting. 49 | - The use of *unit-* and *integration tests* with a minimum coverage of at least 90% ensures a high 50 | degree of resilience against accidental errors and regressions. 51 | 52 | - *Unit testing* ensures that the basic functionality of every layer is consistently working. 53 | - *Integration testing* ensures that the user experience remains consistent by testing the 54 | whole stack by running the respective commands and parsing the output for the expected answers 55 | and formatting. 56 | 57 | All of these are part of a build pipeline at Github that any pull request must pass before being 58 | able to be merged into the `main` branch. This is enforced by the repository settings. 59 | 60 | 61 | Four eyes principle 62 | ^^^^^^^^^^^^^^^^^^^ 63 | 64 | Any pull request must be reviewed by a maintainer (other than the one that opened the pull request, 65 | if this person happens to be **fotoobo** maintainer) before it can be merged into the `main` branch. 66 | This provides another layer of security to accidental or evil code changes. This principle is 67 | enforced by the repository settings. 68 | 69 | 70 | Extensive Documentation 71 | ^^^^^^^^^^^^^^^^^^^^^^^ 72 | 73 | Part of the usability goal is to not only have a simple to use user interface but also a well 74 | understandable documentation. 75 | 76 | Every new user is encouraged to give us feedback where possible problems were and how we could 77 | improve the documentation to avoid these for future users. If the maintainers know the users 78 | personally this encouragement is often expressed explicitly when talking about **fotoobo**. 79 | 80 | Furthermore documentation should be *example based* and describe how to use **fotoobo**. Reference 81 | documentation is important and also provided but should not be the first contact for **fotoobo** 82 | users. 83 | -------------------------------------------------------------------------------- /fotoobo/cli/fgt/config.py: -------------------------------------------------------------------------------- 1 | """ 2 | The FortiGate get commands 3 | """ 4 | 5 | import logging 6 | from pathlib import Path 7 | 8 | import typer 9 | from typing_extensions import Annotated 10 | 11 | from fotoobo.helpers.config import config 12 | from fotoobo.inventory.inventory import Inventory 13 | from fotoobo.tools import fgt 14 | 15 | app = typer.Typer(no_args_is_help=True, rich_markup_mode="rich") 16 | log = logging.getLogger("fotoobo") 17 | 18 | 19 | @app.command(no_args_is_help=True) 20 | def check( 21 | configuration: Annotated[ 22 | Path, 23 | typer.Argument( 24 | help="The FortiGate configuration file or directory.", 25 | metavar="[config]", 26 | show_default=False, 27 | ), 28 | ], 29 | bundles: Annotated[ 30 | Path, 31 | typer.Argument( 32 | help="Filename of the file containing the check bundles.", 33 | metavar="[bundles]", 34 | show_default=False, 35 | ), 36 | ], 37 | smtp_server: Annotated[ 38 | str | None, 39 | typer.Option( 40 | "--smtp", 41 | help="The smtp configuration from the inventory.", 42 | metavar="[server]", 43 | show_default=False, 44 | ), 45 | ] = None, 46 | ) -> None: 47 | """ 48 | Check one or more FortiGate configuration files. 49 | """ 50 | inventory = Inventory(config.inventory_file) 51 | result = fgt.config.check(configuration, bundles) 52 | 53 | if smtp_server: 54 | if smtp_server in inventory.assets: 55 | result.send_messages_as_mail( 56 | inventory.assets[smtp_server], 57 | count=True, 58 | command=True, 59 | ) 60 | 61 | else: 62 | log.warning("SMTP server '%s' not in found in inventory.", smtp_server) 63 | 64 | result.print_messages() 65 | 66 | 67 | @app.command(no_args_is_help=True) 68 | def get( 69 | configuration: Annotated[ 70 | Path, 71 | typer.Argument( 72 | help="The FortiGate configuration file or directory.", 73 | metavar="[config]", 74 | show_default=False, 75 | ), 76 | ], 77 | scope: Annotated[ 78 | str, 79 | typer.Argument(help="Scope of the configuration ('global' or 'vdom')", metavar="[scope]"), 80 | ], 81 | path: Annotated[str, typer.Argument(help="Configuration path", metavar="[path]")] = "/", 82 | ) -> None: 83 | """Get configuration or parts of it from one or more FortiGate configuration files.""" 84 | result = fgt.config.get(configuration, scope, path) 85 | result.print_raw() 86 | 87 | 88 | @app.command(no_args_is_help=True) 89 | def info( 90 | configuration: Annotated[ 91 | Path, 92 | typer.Argument( 93 | help="The FortiGate configuration file or directory.", 94 | metavar="[config]", 95 | show_default=False, 96 | ), 97 | ], 98 | as_list: Annotated[ 99 | bool, 100 | typer.Option("--list", "-l", help="Print the result as a list instead of separate blocks."), 101 | ] = False, 102 | ) -> None: 103 | """ 104 | Get the information from one or more FortiGate configuration files. 105 | """ 106 | result = fgt.config.info(configuration) 107 | 108 | if as_list: 109 | info_dicts = [] 110 | for _, _info in result.all_results().items(): 111 | info_dicts.append(_info.__dict__) 112 | 113 | result.print_table_raw(info_dicts, [], auto_header=True) 114 | 115 | else: 116 | result.print_result_as_table() 117 | -------------------------------------------------------------------------------- /docs/source/developer/architecture/1_introduction_goals.rst: -------------------------------------------------------------------------------- 1 | .. Chapter one according to https://arc42.org/overview 2 | 3 | .. _1IntroductionGoals: 4 | 5 | 1. Introduction and Goals 6 | ========================= 7 | 8 | **fotoobo** is the mighty **Fo**\ rtinet **too**\l\ **bo**\ x for managing your Fortinet environment. 9 | The tool consists of several useful utilities with functionality which is not covered by the 10 | standard Fortinet management suite. It is meant to be extendable to your needs. 11 | 12 | One of its main use cases for the initial developers has been to give several different scripts a 13 | common home. With this they could apply the DRY principle to them by using a common platform for 14 | getting and pushing data to several Fortinet products. 15 | 16 | Goals 17 | ----- 18 | - **fotoobo** should simplify managing your Fortinet device zoo by providing some simple to use 19 | commands for doing daily tasks or gather information for further use, e.g. monitoring. 20 | - It should be easily extendable for further needs. 21 | - It should be easily usable in cron-jobs or custom scripts. 22 | - Fill in gaps in the Fortinet management tools but do not recreate existing ways of getting data 23 | from your Fortinet products. This means a bit more precisely put: 24 | 25 | - Summarize or enhance information that can be got using the REST API 26 | - Create simple, well integrable tools for common tasks: backing up configs, enhance data for 27 | simpler use in monitoring systems, ... 28 | 29 | 30 | Supported Fortinet products 31 | --------------------------- 32 | 33 | At the moment **fotoobo** supports the following Fortinet products: 34 | 35 | - FortiGate (tested with FortiOS 6.4, 7.0 and 7.2) 36 | - FortiManager 37 | - FortiClient EMS 38 | - FortiAnalyzer 39 | - FortiCloud Portal (support.fortinet.com) 40 | 41 | 42 | Core use cases 43 | -------------- 44 | 45 | Some of **fotoobo**'s main use cases are: 46 | 47 | - Backup configurations from FortiGate devices (and push them to an SFTP server) 48 | - Fill in gaps in the monitoring of different Fortinet products (e.g. SSL-VPN pool usage when more 49 | than one IP subnet is available for client IPs). 50 | - Give an overview over your infrastructure in a nice way: 51 | 52 | - Version of your Fortinet devices, 53 | - Version distribution of the FortiClients managed by your FortiClient EMS 54 | - Some summary information of your (backed up) FortiGate configurations 55 | - Check (the backed up) FortiGate configurations against a set of policy checks 56 | - Besides "usual" logging capabilities write an audit log to a syslog server 57 | 58 | 59 | fotoobo vs. Fortinet REST API 60 | ----------------------------- 61 | 62 | There is a general purpose API for all of the supported devices. **fotoobo** uses them all to "talk" 63 | to the respective device, so basically it's no "vs.". For you this means you are always free to 64 | implement your use cases in own scripts directly utilizing the REST API. What **fotoobo** can offer 65 | you is to remove the boiler plate code needed to get and push data to the respective APIs. See the 66 | :ref:`ModuleDocumentation` for further information on whether **fotoobo** is usable to you. If not, 67 | please help us to make it so :-). 68 | 69 | 70 | fotoobo vs. Fortinet ansible module 71 | ----------------------------------- 72 | 73 | The main goal of **fotoobo** is not (yet) to provide tools to manipulate the configuration of the 74 | respective devices. For this please use the 75 | `Fortinet ansible module `_ 76 | or the REST API's directly. 77 | 78 | What **fotoobo** can do for you in this cases is for example information gathering and enhancing, e.g. 79 | through the `convert` commands. 80 | -------------------------------------------------------------------------------- /tests/cli/fgt/get/cmdb/firewall/test_firewall.py: -------------------------------------------------------------------------------- 1 | """ 2 | Testing the cli fgt get cmdb firewall. 3 | """ 4 | 5 | from typer.testing import CliRunner 6 | 7 | from fotoobo.cli.main import app 8 | from tests.helper import parse_help_output 9 | 10 | runner = CliRunner() 11 | 12 | 13 | def test_cli_app_fgt_get_cmdb_firewall_help(help_args_with_none: str) -> None: 14 | """ 15 | Test cli help for fgt get cmdb firewall. 16 | """ 17 | 18 | # Arrange 19 | args = ["-c", "tests/fotoobo.yaml", "fgt", "get", "cmdb", "firewall"] 20 | args.append(help_args_with_none) 21 | args = list(filter(None, args)) 22 | 23 | # Act 24 | result = runner.invoke(app, args) 25 | 26 | # Assert 27 | assert result.exit_code in [0, 2] 28 | arguments, options, commands = parse_help_output(result.stdout) 29 | assert not arguments 30 | assert options == {"-h", "--help"} 31 | assert set(commands) == {"address", "addrgrp", "service-custom", "service-group"} 32 | 33 | 34 | def test_cli_app_fgt_get_cmdb_firewall_address_help(help_args: str) -> None: 35 | """ 36 | Test cli help for fgt get cmdb firewall address. 37 | """ 38 | 39 | # Arrange 40 | args = ["-c", "tests/fotoobo.yaml", "fgt", "get", "cmdb", "firewall", "address"] 41 | args.append(help_args) 42 | 43 | # Act 44 | result = runner.invoke(app, args) 45 | 46 | # Assert 47 | assert result.exit_code == 0 48 | arguments, options, commands = parse_help_output(result.stdout) 49 | assert set(arguments) == {"host", "name"} 50 | assert options == {"-h", "--help", "-o", "--output", "--vdom"} 51 | assert not commands 52 | 53 | 54 | def test_cli_app_fgt_get_cmdb_firewall_addrgrp_help(help_args: str) -> None: 55 | """ 56 | Test cli help for fgt get cmdb firewall address group. 57 | """ 58 | 59 | # Arrange 60 | args = ["-c", "tests/fotoobo.yaml", "fgt", "get", "cmdb", "firewall", "addrgrp"] 61 | args.append(help_args) 62 | 63 | # Act 64 | result = runner.invoke(app, args) 65 | 66 | # Assert 67 | assert result.exit_code == 0 68 | arguments, options, commands = parse_help_output(result.stdout) 69 | assert set(arguments) == {"host", "name"} 70 | assert options == {"-h", "--help", "-o", "--output", "--vdom"} 71 | assert not commands 72 | 73 | 74 | def test_cli_app_fgt_get_cmdb_firewall_service_custom_help(help_args: str) -> None: 75 | """ 76 | Test cli help for fgt get cmdb firewall service-custom. 77 | """ 78 | 79 | # Arrange 80 | args = ["-c", "tests/fotoobo.yaml", "fgt", "get", "cmdb", "firewall", "service-custom"] 81 | args.append(help_args) 82 | 83 | # Act 84 | result = runner.invoke(app, args) 85 | 86 | # Assert 87 | assert result.exit_code == 0 88 | arguments, options, commands = parse_help_output(result.stdout) 89 | assert set(arguments) == {"host", "name"} 90 | assert options == {"-h", "--help", "-o", "--output", "--vdom"} 91 | assert not commands 92 | 93 | 94 | def test_cli_app_fgt_get_cmdb_firewall_service_group_help(help_args: str) -> None: 95 | """ 96 | Test cli help for fgt get cmdb firewall service-group. 97 | """ 98 | 99 | # Arrange 100 | args = ["-c", "tests/fotoobo.yaml", "fgt", "get", "cmdb", "firewall", "service-group"] 101 | args.append(help_args) 102 | 103 | # Act 104 | result = runner.invoke(app, args) 105 | 106 | # Assert 107 | assert result.exit_code == 0 108 | arguments, options, commands = parse_help_output(result.stdout) 109 | assert set(arguments) == {"host", "name"} 110 | assert options == {"-h", "--help", "-o", "--output", "--vdom"} 111 | assert not commands 112 | -------------------------------------------------------------------------------- /fotoobo/cli/fmg/get.py: -------------------------------------------------------------------------------- 1 | """ 2 | The FortiManager get commands 3 | """ 4 | 5 | import logging 6 | from pathlib import Path 7 | 8 | import typer 9 | from typing_extensions import Annotated 10 | 11 | from fotoobo.helpers import cli_path 12 | from fotoobo.helpers.output import write_policy_to_html 13 | from fotoobo.helpers.result import Result 14 | from fotoobo.tools import fmg 15 | 16 | app = typer.Typer(no_args_is_help=True, rich_markup_mode="rich") 17 | log = logging.getLogger("fotoobo") 18 | 19 | HELP_TEXT_HOST = "The FortiManager to access (must be defined in the inventory)." 20 | 21 | 22 | @app.callback() 23 | def callback(context: typer.Context) -> None: 24 | """ 25 | The fmg get subcommand callback 26 | 27 | Args: 28 | context: The context object of the typer app 29 | """ 30 | cli_path.append(str(context.invoked_subcommand)) 31 | log.debug("About to execute command: '%s'", context.invoked_subcommand) 32 | 33 | 34 | @app.command() 35 | def adoms( 36 | host: Annotated[ 37 | str, 38 | typer.Argument( 39 | help=HELP_TEXT_HOST, 40 | metavar="[host]", 41 | ), 42 | ] = "fmg", 43 | ) -> None: 44 | """ 45 | Get the FortiManager ADOM list. 46 | """ 47 | result = fmg.get.adoms(host) 48 | result.print_result_as_table(title="FortiManager ADOMs", headers=["Name", "Version"]) 49 | 50 | 51 | @app.command() 52 | def devices( 53 | host: Annotated[ 54 | str, 55 | typer.Argument( 56 | help=HELP_TEXT_HOST, 57 | metavar="[host]", 58 | ), 59 | ] = "fmg", 60 | raw: Annotated[bool, typer.Option("-r", "--raw", help="Output raw data.")] = False, 61 | ) -> None: 62 | """ 63 | Get the FortiManager devices list. 64 | 65 | In case of a cluster the 'Device Name' is the name of the cluster and the 'HA Nodes' holds the 66 | hostnames of the actual cluster nodes. 67 | """ 68 | result = fmg.get.devices(host) 69 | if raw: 70 | result.print_raw() 71 | 72 | else: 73 | # Make a string from the list of ha nodes 74 | new_result = Result[dict[str, str | list[str]]]() 75 | for key, value in result.all_results().items(): 76 | value["ha_nodes"] = ", ".join(value["ha_nodes"]) 77 | new_result.push_result(key, value) 78 | 79 | new_result.print_result_as_table( 80 | title="Fortinet devices", 81 | headers=["Device Name", "Version", "HA", "Platform", "Description", "HA nodes"], 82 | ) 83 | 84 | 85 | @app.command(no_args_is_help=True) 86 | def policy( 87 | adom: Annotated[ 88 | str, 89 | typer.Argument( 90 | help="The FortiManager ADOM to get the firewall policy from.", 91 | metavar="[adom]", 92 | show_default=False, 93 | ), 94 | ], 95 | policy_name: Annotated[ 96 | str, 97 | typer.Argument( 98 | help="The name of the policy to get.", metavar="[policy]", show_default=False 99 | ), 100 | ], 101 | filename: Annotated[ 102 | Path, 103 | typer.Argument( 104 | help="The filename to write the policy to.", metavar="[file]", show_default=False 105 | ), 106 | ], 107 | host: Annotated[str, typer.Argument(help=HELP_TEXT_HOST, metavar="[host]")] = "fmg", 108 | ) -> None: 109 | """ 110 | Get a FortiManager policy. 111 | """ 112 | result = fmg.get.policy(host, adom, policy_name) 113 | write_policy_to_html(result.get_result(host), filename) 114 | 115 | 116 | @app.command() 117 | def version( 118 | host: Annotated[str, typer.Argument(help=HELP_TEXT_HOST, metavar="[host]")] = "fmg", 119 | ) -> None: 120 | """ 121 | Get the FortiManager version. 122 | """ 123 | result = fmg.get.version(host) 124 | result.print_result_as_table(title="FortiManager Version", headers=["FortiManager", "Version"]) 125 | --------------------------------------------------------------------------------