├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── feature_request.md │ └── question.md ├── dependabot.yml ├── pull_request_template.md └── workflows │ ├── ci.yml │ ├── publiccode-yml-validation.yml │ ├── pypi.yml │ └── version-branch.yml ├── .gitignore ├── .prettierignore ├── CHANGES.rst ├── CONTRIBUTING.rst ├── Dockerfile ├── LICENSE ├── MANIFEST.in ├── README.rst ├── SECURITY.md ├── docker-compose.yml ├── docs ├── developer │ ├── extending.rst │ ├── index.rst │ ├── installation.rst │ └── utils.rst ├── images │ └── architecture-v2-openwisp-controller.png ├── index.rst ├── partials │ ├── developer-docs.rst │ └── shared-object.rst └── user │ ├── device-config-status.rst │ ├── device-groups.rst │ ├── import-export.rst │ ├── intro.rst │ ├── openvpn.rst │ ├── organization-limits.rst │ ├── push-operations.rst │ ├── rest-api.rst │ ├── settings.rst │ ├── shell-commands.rst │ ├── subnet-division-rules.rst │ ├── templates.rst │ ├── variables.rst │ ├── vxlan-wireguard.rst │ ├── wireguard.rst │ └── zerotier.rst ├── openwisp.org.svg ├── openwisp_controller ├── __init__.py ├── admin.py ├── base.py ├── checks.py ├── config │ ├── __init__.py │ ├── admin.py │ ├── api │ │ ├── __init__.py │ │ ├── download_views.py │ │ ├── filters.py │ │ ├── serializers.py │ │ ├── urls.py │ │ ├── views.py │ │ └── zerotier_service.py │ ├── apps.py │ ├── base │ │ ├── __init__.py │ │ ├── base.py │ │ ├── channels_consumer.py │ │ ├── config.py │ │ ├── device.py │ │ ├── device_group.py │ │ ├── multitenancy.py │ │ ├── tag.py │ │ ├── template.py │ │ └── vpn.py │ ├── controller │ │ ├── __init__.py │ │ ├── urls.py │ │ └── views.py │ ├── crypto.py │ ├── exceptions.py │ ├── exportable.py │ ├── filters.py │ ├── fixtures │ │ └── test_templates.json │ ├── handlers.py │ ├── migrations │ │ ├── 0001_squashed_0002_config_settings_uuid.py │ │ ├── 0003_template_tags.py │ │ ├── 0004_add_device_model.py │ │ ├── 0005_populate_device.py │ │ ├── 0006_config_device_not_null.py │ │ ├── 0007_simplify_config.py │ │ ├── 0008_update_indexes.py │ │ ├── 0009_device_system.py │ │ ├── 0010_auto_20180106_1814.py │ │ ├── 0011_update_device_mac_address.py │ │ ├── 0012_auto_20180219_1501.py │ │ ├── 0013_last_ip_management_ip_and_status_applied.py │ │ ├── 0014_device_hardware_id.py │ │ ├── 0015_default_groups_permissions.py │ │ ├── 0016_default_organization_config_settings.py │ │ ├── 0017_template_name_organization_unique_together.py │ │ ├── 0018_config_context.py │ │ ├── 0019_organization_mac_add_hardware_id_name_unique_together.py │ │ ├── 0020_remove_config_organization.py │ │ ├── 0021_vpn_key.py │ │ ├── 0022_vpn_format_dh.py │ │ ├── 0023_update_context.py │ │ ├── 0024_update_context_data.py │ │ ├── 0025_update_device_key.py │ │ ├── 0026_hardware_id_not_unique.py │ │ ├── 0027_add_indexes_on_ip_fields.py │ │ ├── 0028_template_default_values.py │ │ ├── 0029_merge_django_netjsonconfig.py │ │ ├── 0030_django_taggit_update.py │ │ ├── 0031_update_vpn_dh_param.py │ │ ├── 0032_update_legacy_vpn_backend.py │ │ ├── 0033_name_unique_per_organization.py │ │ ├── 0034_template_required.py │ │ ├── 0035_device_name_unique_optional.py │ │ ├── 0036_device_group.py │ │ ├── 0037_alter_taggedtemplate.py │ │ ├── 0038_vpn_subnet.py │ │ ├── 0039_wireguard_vxlan_ipam.py │ │ ├── 0040_vpnclient_ip_setnull.py │ │ ├── 0041_default_groups_organizationconfigsettings_permission.py │ │ ├── 0042_multiple_wireguard_tunnels.py │ │ ├── 0043_devicegroup_templates.py │ │ ├── 0044_config_error_reason.py │ │ ├── 0045_alter_vpn_webhook_endpoint.py │ │ ├── 0046_organizationlimits.py │ │ ├── 0047_add_organizationlimits.py │ │ ├── 0048_wifi_radio_band_migration.py │ │ ├── 0049_devicegroup_context.py │ │ ├── 0050_alter_vpnclient_unique_together.py │ │ ├── 0051_organizationconfigsettings_context.py │ │ ├── 0052_vpn_node_network_id.py │ │ ├── 0053_vpnclient_secret.py │ │ ├── 0054_device__is_deactivated.py │ │ ├── 0055_alter_config_status.py │ │ ├── 0056_vpnclient_template.py │ │ ├── 0057_populate_vpnclient_template.py │ │ ├── 0058_alter_vpnclient_template.py │ │ ├── 0059_zerotier_templates_ow_zt_to_global.py │ │ ├── 0060_cleanup_api_task_notification_types.py │ │ ├── 0061_config_checksum_db.py │ │ └── __init__.py │ ├── models.py │ ├── settings.py │ ├── signals.py │ ├── sortedm2m │ │ ├── __init__.py │ │ ├── fields.py │ │ └── forms.py │ ├── static │ │ ├── config │ │ │ ├── css │ │ │ │ ├── admin.css │ │ │ │ ├── device-delete-confirmation.css │ │ │ │ ├── devicegroup.css │ │ │ │ └── lib │ │ │ │ │ ├── advanced-mode.css │ │ │ │ │ ├── img │ │ │ │ │ └── jsoneditor-icons.svg │ │ │ │ │ └── jsonschema-ui.css │ │ │ └── js │ │ │ │ ├── device-delete-confirmation.js │ │ │ │ ├── lib │ │ │ │ ├── advanced-mode.js │ │ │ │ ├── jsonschema-ui.js │ │ │ │ └── tomorrow_night_bright.js │ │ │ │ ├── management_ip.js │ │ │ │ ├── preview.js │ │ │ │ ├── relevant_templates.js │ │ │ │ ├── switcher.js │ │ │ │ ├── tabs.js │ │ │ │ ├── unsaved_changes.js │ │ │ │ ├── utils.js │ │ │ │ ├── vpn.js │ │ │ │ └── widget.js │ │ ├── import_export │ │ │ └── import-openwisp.css │ │ ├── sortedm2m │ │ │ └── patch_sortedm2m.js │ │ └── support.css │ ├── tasks.py │ ├── tasks_zerotier.py │ ├── templates │ │ ├── admin │ │ │ ├── config │ │ │ │ ├── change_device_group.html │ │ │ │ ├── change_form.html │ │ │ │ ├── change_list_device.html │ │ │ │ ├── clone_template_form.html │ │ │ │ ├── device │ │ │ │ │ ├── change_form.html │ │ │ │ │ ├── delete_confirmation.html │ │ │ │ │ └── delete_selected_confirmation.html │ │ │ │ ├── device_recover_form.html │ │ │ │ ├── jsonschema-widget.html │ │ │ │ ├── preview.html │ │ │ │ └── system_context.html │ │ │ ├── device_group │ │ │ │ └── change_form.html │ │ │ └── import_export │ │ │ │ └── import.html │ │ └── reversion │ │ │ └── config │ │ │ └── revision_form.html │ ├── tests │ │ ├── __init__.py │ │ ├── pytest.py │ │ ├── test_admin.py │ │ ├── test_api.py │ │ ├── test_apps.py │ │ ├── test_config.py │ │ ├── test_controller.py │ │ ├── test_device.py │ │ ├── test_device_group.py │ │ ├── test_handlers.py │ │ ├── test_notifications.py │ │ ├── test_selenium.py │ │ ├── test_tag.py │ │ ├── test_template.py │ │ ├── test_views.py │ │ ├── test_vpn.py │ │ └── utils.py │ ├── urls.py │ ├── utils.py │ ├── validators.py │ ├── views.py │ └── widgets.py ├── connection │ ├── __init__.py │ ├── admin.py │ ├── api │ │ ├── serializers.py │ │ ├── urls.py │ │ └── views.py │ ├── apps.py │ ├── base │ │ ├── __init__.py │ │ └── models.py │ ├── channels │ │ ├── consumers.py │ │ └── routing.py │ ├── commands.py │ ├── connectors │ │ ├── __init__.py │ │ ├── airos │ │ │ └── snmp.py │ │ ├── exceptions.py │ │ ├── openwrt │ │ │ ├── __init__.py │ │ │ ├── snmp.py │ │ │ └── ssh.py │ │ ├── snmp.py │ │ └── ssh.py │ ├── exceptions.py │ ├── migrations │ │ ├── 0001_initial.py │ │ ├── 0002_credentials_auto_add.py │ │ ├── 0003_default_group_permissions.py │ │ ├── 0004_django3_1_upgrade.py │ │ ├── 0005_device_connection_failure_reason.py │ │ ├── 0006_name_unique_per_organization.py │ │ ├── 0007_command.py │ │ ├── 0008_remove_conflicting_deviceconnections.py │ │ ├── 0009_alter_deviceconnection_unique_together.py │ │ └── __init__.py │ ├── models.py │ ├── schema.py │ ├── settings.py │ ├── signals.py │ ├── static │ │ └── connection │ │ │ ├── css │ │ │ ├── command-inline.css │ │ │ └── credentials.css │ │ │ └── js │ │ │ ├── commands.js │ │ │ ├── credentials.js │ │ │ └── lib │ │ │ └── reconnecting-websocket.min.js │ ├── tasks.py │ ├── tests │ │ ├── __init__.py │ │ ├── base.py │ │ ├── pytest.py │ │ ├── test-key.ed25519 │ │ ├── test-key.rsa │ │ ├── test_admin.py │ │ ├── test_api.py │ │ ├── test_command_utilities.py │ │ ├── test_models.py │ │ ├── test_notifications.py │ │ ├── test_selenium.py │ │ ├── test_snmp.py │ │ ├── test_ssh.py │ │ ├── test_tasks.py │ │ └── utils.py │ ├── utils.py │ └── widgets.py ├── context_processors.py ├── geo │ ├── __init__.py │ ├── admin.py │ ├── api │ │ ├── __init__.py │ │ ├── filters.py │ │ ├── serializers.py │ │ ├── urls.py │ │ └── views.py │ ├── apps.py │ ├── base │ │ ├── __init__.py │ │ └── models.py │ ├── channels │ │ ├── __init__.py │ │ ├── consumers.py │ │ └── routing.py │ ├── exportable.py │ ├── migrations │ │ ├── 0001_initial.py │ │ ├── 0002_default_groups_permissions.py │ │ ├── 0003_alter_devicelocation_floorplan_location.py │ │ └── __init__.py │ ├── models.py │ ├── templates │ │ └── admin │ │ │ └── widgets │ │ │ └── foreign_key_raw_id.html │ ├── tests │ │ ├── __init__.py │ │ ├── pytest.py │ │ ├── test_admin.py │ │ ├── test_admin_inline.py │ │ ├── test_api.py │ │ ├── test_apps.py │ │ ├── test_models.py │ │ ├── test_selenium.py │ │ └── utils.py │ └── utils.py ├── migrations.py ├── mixins.py ├── pki │ ├── __init__.py │ ├── admin.py │ ├── api │ │ ├── serializers.py │ │ ├── urls.py │ │ └── views.py │ ├── apps.py │ ├── base │ │ ├── __init__.py │ │ └── models.py │ ├── migrations │ │ ├── 0001_initial.py │ │ ├── 0002_add_organization_name.py │ │ ├── 0003_fill_organization_name.py │ │ ├── 0004_auto_20180106_1814.py │ │ ├── 0005_organizational_unit_name.py │ │ ├── 0006_add_x509_passphrase_field.py │ │ ├── 0007_default_groups_permissions.py │ │ ├── 0008_serial_number_length.py │ │ ├── 0009_common_name_maxlength_64.py │ │ ├── 0010_common_name_organization_unique.py │ │ ├── 0011_disallowed_blank_key_length_or_digest.py │ │ └── __init__.py │ ├── models.py │ ├── static │ │ └── admin │ │ │ └── pki │ │ │ └── js │ │ │ └── show-org-field.js │ ├── templates │ │ └── admin │ │ │ └── pki │ │ │ └── change_form.html │ ├── tests │ │ ├── __init__.py │ │ ├── test_admin.py │ │ ├── test_api.py │ │ ├── test_models.py │ │ └── utils.py │ ├── urls.py │ └── utils.py ├── routing.py ├── serializers.py ├── settings.py ├── subnet_division │ ├── __init__.py │ ├── admin.py │ ├── apps.py │ ├── base │ │ └── models.py │ ├── filters.py │ ├── migrations │ │ ├── 0001_initial.py │ │ ├── 0002_default_group_migration.py │ │ ├── 0003_related_field_allow_blank.py │ │ ├── 0004_index_rule_on_delete.py │ │ ├── 0005_number_of_subnets_and_ips.py │ │ └── __init__.py │ ├── models.py │ ├── rule_types │ │ ├── __init__.py │ │ ├── base.py │ │ ├── device.py │ │ └── vpn.py │ ├── settings.py │ ├── signals.py │ ├── static │ │ └── subnet-division │ │ │ ├── css │ │ │ └── subnet-division.css │ │ │ └── js │ │ │ └── subnet-division.js │ ├── tasks.py │ ├── tests │ │ ├── __init__.py │ │ ├── helpers.py │ │ ├── test_admin.py │ │ ├── test_models.py │ │ └── test_rule.py │ └── utils.py ├── tests │ ├── __init__.py │ ├── mixins.py │ ├── test_selenium.py │ ├── test_users_integration.py │ ├── test_utilities.py │ └── utils.py ├── urls.py └── vpn_backends.py ├── publiccode.yml ├── pyproject.toml ├── pytest.ini ├── requirements-test.txt ├── requirements.txt ├── run-qa-checks ├── runtests ├── runtests.py ├── setup.cfg ├── setup.py └── tests ├── __init__.py ├── docker-entrypoint.sh ├── manage.py ├── media ├── .gitignore └── floorplan.jpg └── openwisp2 ├── __init__.py ├── asgi.py ├── celery.py ├── local_settings.example.py ├── postgresql_settings.py ├── sample_config ├── __init__.py ├── admin.py ├── api │ └── views.py ├── apps.py ├── fixtures │ └── test_templates.json ├── migrations │ ├── 0001_initial.py │ ├── 0002_default_groups_permissions.py │ ├── 0003_name_unique_per_organization.py │ ├── 0004_devicegroup_templates.py │ ├── 0005_add_organizationalloweddevice.py │ ├── 0006_device__is_deactivated_alter_config_status.py │ ├── 0007_alter_config_status.py │ └── __init__.py ├── models.py ├── pytest.py ├── tests.py └── views.py ├── sample_connection ├── __init__.py ├── admin.py ├── api │ ├── __init__.py │ └── views.py ├── apps.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_default_group_permissions.py │ ├── 0003_name_unique_per_organization.py │ └── __init__.py ├── models.py ├── pytest.py └── tests.py ├── sample_geo ├── __init__.py ├── admin.py ├── apps.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_default_group_permissions.py │ ├── 0003_alter_devicelocation_floorplan_location.py │ └── __init__.py ├── models.py ├── pytest.py ├── tests.py └── views.py ├── sample_pki ├── __init__.py ├── admin.py ├── apps.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_default_group_permissions.py │ ├── 0003_disallowed_blank_key_length_or_digest.py │ └── __init__.py ├── models.py └── tests.py ├── sample_subnet_division ├── __init__.py ├── admin.py ├── apps.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_default_group_permissions.py │ └── __init__.py ├── models.py └── tests.py ├── sample_users ├── __init__.py ├── admin.py ├── apps.py ├── migrations │ ├── 0001_initial.py │ ├── 0004_default_groups.py │ └── __init__.py ├── models.py └── tests.py ├── settings.py └── urls.py /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | github: [openwisp] 3 | patreon: # Replace with a single Patreon username 4 | open_collective: # Replace with a single Open Collective username 5 | ko_fi: # Replace with a single Ko-fi username 6 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 7 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 8 | liberapay: # Replace with a single Liberapay username 9 | issuehunt: # Replace with a single IssueHunt username 10 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 11 | polar: # Replace with a single Polar username 12 | buy_me_a_coffee: # Replace with a single Buy Me a Coffee username 13 | thanks_dev: # Replace with a single thanks.dev username 14 | custom: ["https://openwisp.org/sponsorship/"] 15 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Open a bug report 4 | title: "[bug] " 5 | labels: bug 6 | assignees: "" 7 | --- 8 | 9 | **Describe the bug** 10 | A clear and concise description of the bug or unexpected behavior. 11 | 12 | **Steps To Reproduce** 13 | Steps to reproduce the behavior: 14 | 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **System Informatioon:** 27 | 28 | - OS: [e.g. Ubuntu 24.04 LTS] 29 | - Python Version: [e.g. Python 3.11.2] 30 | - Django Version: [e.g. Django 4.2.5] 31 | - Browser and Browser Version (if applicable): [e.g. Chromium v126.0.6478.126] 32 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "[feature] " 5 | labels: enhancement 6 | assignees: "" 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** 10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 11 | 12 | **Describe the solution you'd like** 13 | A clear and concise description of what you want to happen. 14 | 15 | **Describe alternatives you've considered** 16 | A clear and concise description of any alternative solutions or features you've considered. 17 | 18 | **Additional context** 19 | Add any other context or screenshots about the feature request here. 20 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Question 3 | about: Please use the Discussion Forum to ask questions 4 | title: "[question] " 5 | labels: question 6 | assignees: "" 7 | --- 8 | 9 | Please use the [Discussion Forum](https://github.com/orgs/openwisp/discussions) to ask questions. 10 | 11 | We will take care of moving the discussion to a more relevant repository if needed. 12 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "pip" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "monthly" 12 | commit-message: 13 | prefix: "[deps] " 14 | - package-ecosystem: "github-actions" # Check for GitHub Actions updates 15 | directory: "/" # The root directory where the Ansible role is located 16 | schedule: 17 | interval: "monthly" # Check for updates weekly 18 | commit-message: 19 | prefix: "[ci] " 20 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Checklist 2 | 3 | - [ ] I have read the [OpenWISP Contributing Guidelines](http://openwisp.io/docs/developer/contributing.html). 4 | - [ ] I have manually tested the changes proposed in this pull request. 5 | - [ ] I have written new test cases for new code and/or updated existing tests for changes to existing code. 6 | - [ ] I have updated the documentation. 7 | 8 | ## Reference to Existing Issue 9 | 10 | Closes #. 11 | 12 | Please [open a new issue](https://github.com/openwisp/openwisp-controller/issues/new/choose) if there isn't an existing issue yet. 13 | 14 | ## Description of Changes 15 | 16 | Please describe these changes. 17 | 18 | ## Screenshot 19 | 20 | Please include any relevant screenshots. 21 | -------------------------------------------------------------------------------- /.github/workflows/publiccode-yml-validation.yml: -------------------------------------------------------------------------------- 1 | name: publiccode.yml validation 2 | 3 | on: [pull_request, push] 4 | 5 | jobs: 6 | publiccode_yml_validation: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v5 10 | - uses: italia/publiccode-parser-action@v1 11 | with: 12 | publiccode: "publiccode.yml" 13 | -------------------------------------------------------------------------------- /.github/workflows/pypi.yml: -------------------------------------------------------------------------------- 1 | name: Publish Python Package to Pypi.org 2 | 3 | on: 4 | release: 5 | types: [published, edited] 6 | 7 | permissions: 8 | id-token: write 9 | 10 | jobs: 11 | pypi-publish: 12 | name: Release Python Package on Pypi.org 13 | runs-on: ubuntu-latest 14 | environment: 15 | name: pypi 16 | url: https://pypi.org/p/openwisp-controller 17 | permissions: 18 | id-token: write 19 | steps: 20 | - uses: actions/checkout@v5 21 | - name: Set up Python 22 | uses: actions/setup-python@v6 23 | with: 24 | python-version: "3.10" 25 | - name: Install dependencies 26 | run: | 27 | pip install -U pip 28 | pip install build 29 | - name: Build package 30 | run: python -m build 31 | - name: Publish package distributions to PyPI 32 | uses: pypa/gh-action-pypi-publish@v1.13.0 33 | -------------------------------------------------------------------------------- /.github/workflows/version-branch.yml: -------------------------------------------------------------------------------- 1 | name: Replicate Commits to Version Branch 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | version-branch: 10 | uses: openwisp/openwisp-utils/.github/workflows/reusable-version-branch.yml@master 11 | with: 12 | module_name: openwisp_controller 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | .pytest_cache/ 4 | *.py[cod] 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | /lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | *.egg-info/ 23 | .installed.cfg 24 | *.egg 25 | 26 | # PyInstaller 27 | # Usually these files are written by a python script from a template 28 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 29 | *.manifest 30 | *.spec 31 | 32 | # Installer logs 33 | pip-log.txt 34 | pip-delete-this-directory.txt 35 | 36 | # Unit test / coverage reports 37 | htmlcov/ 38 | .tox/ 39 | .coverage 40 | # When running coverage testing 41 | # it creates .coverage-username.123x* files 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | 47 | # Translations 48 | *.mo 49 | *.pot 50 | 51 | # Django stuff: 52 | *.log 53 | 54 | # Sphinx documentation 55 | docs/_build/ 56 | 57 | # PyBuilder 58 | target/ 59 | 60 | # editors 61 | *.komodoproject 62 | .vscode 63 | 64 | # other 65 | *.DS_Store* 66 | *~ 67 | ._* 68 | local_settings.py 69 | *.db 70 | *.tar.gz 71 | 72 | # Pipenv 73 | Pipfile 74 | Pipfile.lock 75 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | openwisp_controller/config/static/config/js/lib/*.js 2 | openwisp_controller/connection/static/connection/js/lib/*.js 3 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | Please refer to the `OpenWISP Contribution Guidelines 2 | `_. 3 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # NOTE: This Docker image is for development purposes only. 2 | 3 | FROM python:3.9-slim-buster 4 | 5 | RUN apt update && \ 6 | apt install --yes zlib1g-dev libjpeg-dev gdal-bin libproj-dev \ 7 | libgeos-dev libspatialite-dev libsqlite3-mod-spatialite \ 8 | sqlite3 libsqlite3-dev openssl libssl-dev && \ 9 | rm -rf /var/lib/apt/lists/* /root/.cache/pip/* /tmp/* 10 | 11 | COPY requirements-test.txt requirements.txt /opt/openwisp/ 12 | RUN pip install -r /opt/openwisp/requirements.txt && \ 13 | pip install -r /opt/openwisp/requirements-test.txt && \ 14 | pip install redis && \ 15 | rm -rf /var/lib/apt/lists/* /root/.cache/pip/* /tmp/* 16 | 17 | ADD . /opt/openwisp 18 | RUN pip install -U /opt/openwisp && \ 19 | rm -rf /var/lib/apt/lists/* /root/.cache/pip/* /tmp/* 20 | 21 | WORKDIR /opt/openwisp/tests/ 22 | ENV NAME=openwisp-controller \ 23 | PYTHONBUFFERED=1 24 | CMD ["sh", "docker-entrypoint.sh"] 25 | EXPOSE 8000 26 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.rst 3 | include CHANGES.rst 4 | include requirements.txt 5 | recursive-include openwisp_controller * 6 | recursive-exclude * *.pyc 7 | recursive-exclude * *.swp 8 | recursive-exclude * __pycache__ 9 | recursive-exclude * *.db 10 | recursive-exclude * local_settings.py 11 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | Latest release and development version. 6 | 7 | ## Reporting a Vulnerability 8 | 9 | Please write to security@openwisp.io. 10 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | # NOTE: This Docker image is for development purposes only. 2 | 3 | services: 4 | controller: 5 | image: openwisp/controller-development:latest 6 | environment: 7 | - REDIS_URL=redis://redis:6379 8 | build: 9 | context: . 10 | ports: 11 | - 8000:8000 12 | depends_on: 13 | - redis 14 | 15 | redis: 16 | image: redis:alpine 17 | ports: 18 | - "6379:6379" 19 | entrypoint: redis-server --appendonly yes 20 | 21 | postgres: 22 | image: postgis/postgis:17-3.5-alpine 23 | environment: 24 | POSTGRES_PASSWORD: openwisp2 25 | POSTGRES_USER: openwisp2 26 | POSTGRES_DB: openwisp2 27 | ports: 28 | - 5432:5432 29 | -------------------------------------------------------------------------------- /docs/developer/index.rst: -------------------------------------------------------------------------------- 1 | Developer Docs 2 | ============== 3 | 4 | .. include:: ../partials/developer-docs.rst 5 | 6 | .. toctree:: 7 | :maxdepth: 2 8 | 9 | ./installation.rst 10 | ./utils.rst 11 | ./extending.rst 12 | 13 | Other useful resources: 14 | 15 | - :doc:`../user/rest-api` 16 | - :doc:`../user/settings` 17 | -------------------------------------------------------------------------------- /docs/images/architecture-v2-openwisp-controller.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openwisp/openwisp-controller/ba5e7a18f331f09f1cfcac0f5743d13dd16e31aa/docs/images/architecture-v2-openwisp-controller.png -------------------------------------------------------------------------------- /docs/partials/developer-docs.rst: -------------------------------------------------------------------------------- 1 | .. note:: 2 | 3 | This page is for developers who want to customize or extend OpenWISP 4 | Controller, whether for bug fixes, new features, or contributions. 5 | 6 | For user guides and general information, please see: 7 | 8 | - :doc:`General OpenWISP Quickstart ` 9 | - :doc:`OpenWISP Controller User Docs ` 10 | -------------------------------------------------------------------------------- /docs/partials/shared-object.rst: -------------------------------------------------------------------------------- 1 | .. note:: 2 | 3 | This guide creates the VPN server and VPN client templates as **Shared 4 | systemwide (no organization)** objects. This allows any device of any 5 | organization to use the automation. 6 | 7 | If needed, you can use any organization as long as the VPN server, the 8 | VPN client template, and devices have the same organization. 9 | -------------------------------------------------------------------------------- /docs/user/device-config-status.rst: -------------------------------------------------------------------------------- 1 | Device Configuration Status 2 | =========================== 3 | 4 | The device's configuration status (`Device.config.status`) indicates the 5 | current state of the configuration as managed by OpenWISP. The possible 6 | statuses and their meanings are explained below. 7 | 8 | ``modified`` 9 | ------------ 10 | 11 | The device configuration has been updated in OpenWISP, but these changes 12 | have not yet been applied to the device. The device is pending an update. 13 | 14 | ``applied`` 15 | ----------- 16 | 17 | The device has successfully applied the configuration changes made in 18 | OpenWISP. The current configuration on the device matches the latest 19 | changes. 20 | 21 | ``error`` 22 | --------- 23 | 24 | An issue occurred while applying the configuration to the device, causing 25 | the device to revert to its previous working configuration. 26 | 27 | ``deactivating`` 28 | ---------------- 29 | 30 | The device is in the process of being deactivated. The configuration is 31 | scheduled to be removed from the device. 32 | 33 | ``deactivated`` 34 | --------------- 35 | 36 | The device has been deactivated. The configuration applied through 37 | OpenWISP has been removed, and any other operation to manage the device 38 | will be prevented or rejected. 39 | 40 | .. note:: 41 | 42 | If a device becomes unreachable (e.g., lost, stolen, or 43 | decommissioned) before it can be properly deactivated, you can still 44 | force the deletion from OpenWISP by hitting the delete button in the 45 | device detail page after having deactivated the device or by using the 46 | bulk delete action from the device list page. 47 | -------------------------------------------------------------------------------- /docs/user/import-export.rst: -------------------------------------------------------------------------------- 1 | Import/Export Device Data 2 | ========================= 3 | 4 | .. figure:: https://raw.githubusercontent.com/openwisp/openwisp-controller/docs/docs/1.1/import-export/device-list.png 5 | :target: https://raw.githubusercontent.com/openwisp/openwisp-controller/docs/docs/1.1/import-export/device-list.png 6 | :alt: Import / Export 7 | 8 | The device list page offers two buttons to export and import device data 9 | in different formats. 10 | 11 | Importing 12 | --------- 13 | 14 | For importing devices into the system, only the required fields are 15 | needed, for example, the following CSV file will import a device named 16 | ``TestImport`` with mac address ``00:11:22:09:44:55`` in the organization 17 | with UUID ``3cb5e18c-0312-48ab-8dbd-038b8415bd6f``: 18 | 19 | .. code-block:: 20 | 21 | organization,name,mac_address 22 | 3cb5e18c-0312-48ab-8dbd-038b8415bd6f,TestImport,00:11:22:09:44:55 23 | 24 | .. figure:: https://raw.githubusercontent.com/openwisp/openwisp-controller/docs/docs/1.1/import-export/import-page.png 25 | :target: https://raw.githubusercontent.com/openwisp/openwisp-controller/docs/docs/1.1/import-export/import-page.png 26 | :alt: Import / Export 27 | 28 | Exporting 29 | --------- 30 | 31 | The export feature respects any filters selected in the device list. 32 | 33 | .. figure:: https://raw.githubusercontent.com/openwisp/openwisp-controller/docs/docs/1.1/import-export/export-page.png 34 | :target: https://raw.githubusercontent.com/openwisp/openwisp-controller/docs/docs/1.1/import-export/export-page.png 35 | :alt: Export 36 | -------------------------------------------------------------------------------- /docs/user/organization-limits.rst: -------------------------------------------------------------------------------- 1 | Organization Limits 2 | =================== 3 | 4 | You can restrict the number of devices managed by each organization. 5 | 6 | To set these limits: 7 | 8 | 1. Navigate to **USERS & ORGANIZATIONS** on the left-hand navigation menu. 9 | 2. Go to **Organizations**. 10 | 3. Click on the specific organization you want to limit. 11 | 4. In the **CONTROLLER LIMIT** section, set the desired limit. 12 | 13 | Refer to the screenshot below for guidance: 14 | 15 | .. figure:: https://raw.githubusercontent.com/openwisp/openwisp-controller/docs/docs/1.1/organization-limits.png 16 | :target: https://raw.githubusercontent.com/openwisp/openwisp-controller/docs/docs/1.1/organization-limits.png 17 | :alt: Organization limits 18 | -------------------------------------------------------------------------------- /openwisp_controller/__init__.py: -------------------------------------------------------------------------------- 1 | VERSION = (1, 2, 0, "alpha") 2 | __version__ = VERSION # alias 3 | 4 | 5 | def get_version(): 6 | version = f"{VERSION[0]}.{VERSION[1]}" 7 | if VERSION[2]: 8 | version = f"{version}.{VERSION[2]}" 9 | if VERSION[3:] == ("alpha", 0): 10 | version = f"{version} pre-alpha" 11 | if "post" in VERSION[3]: 12 | version = f"{version}.{VERSION[3]}" 13 | else: 14 | if VERSION[3] != "final": 15 | try: 16 | rev = VERSION[4] 17 | except IndexError: 18 | rev = 0 19 | version = f"{version}{VERSION[3][0:1]}{rev}" 20 | return version 21 | -------------------------------------------------------------------------------- /openwisp_controller/admin.py: -------------------------------------------------------------------------------- 1 | """ 2 | Base admin classes and mixins 3 | """ 4 | 5 | from django.core.exceptions import PermissionDenied 6 | 7 | from openwisp_users.multitenancy import ( 8 | MultitenantAdminMixin as BaseMultitenantAdminMixin, 9 | ) 10 | 11 | 12 | class OrgVersionMixin(object): 13 | """ 14 | Base VersionAdmin for openwisp_controller 15 | """ 16 | 17 | def recoverlist_view(self, request, extra_context=None): 18 | """only superusers are allowed to recover deleted objects""" 19 | if not request.user.is_superuser: 20 | raise PermissionDenied 21 | return super().recoverlist_view(request, extra_context) 22 | 23 | 24 | class MultitenantAdminMixin(OrgVersionMixin, BaseMultitenantAdminMixin): 25 | """ 26 | openwisp_utils.admin.MultitenantAdminMixin + OrgVersionMixin 27 | """ 28 | 29 | pass 30 | -------------------------------------------------------------------------------- /openwisp_controller/base.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import ValidationError 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | from openwisp_users.mixins import ShareableOrgMixin 5 | 6 | 7 | class ShareableOrgMixinUniqueName(ShareableOrgMixin): 8 | """ 9 | Like ShareableOrgMixin but performs special validation on the name 10 | """ 11 | 12 | # needed to turn on the special validation in preview 13 | _validate_name = True 14 | 15 | class Meta: 16 | abstract = True 17 | 18 | def clean(self, *args, **kwargs): 19 | if self._validate_name: 20 | self._clean_name() 21 | return super().clean(*args, **kwargs) 22 | 23 | def _clean_name(self): 24 | model = self.__class__ 25 | model_name = model.__name__.lower() 26 | qs = model.objects.filter(name=self.name) 27 | if not self._state.adding: 28 | qs = qs.exclude(id=self.id) 29 | shared_message = _( 30 | "Shared objects are visible to all organizations and " 31 | "must have unique names to avoid confusion." 32 | ) 33 | 34 | if qs.filter(organization=None).exists(): 35 | msg = _(f"There is already another shared {model_name} with this name.") 36 | raise ValidationError({"name": f"{msg} {shared_message}"}) 37 | 38 | if not self.organization and qs.filter(organization__isnull=False).exists(): 39 | msg = _( 40 | f"There is already a {model_name} of " 41 | "another organization with this name." 42 | ) 43 | raise ValidationError({"name": f"{msg} {shared_message}"}) 44 | -------------------------------------------------------------------------------- /openwisp_controller/config/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openwisp/openwisp-controller/ba5e7a18f331f09f1cfcac0f5743d13dd16e31aa/openwisp_controller/config/__init__.py -------------------------------------------------------------------------------- /openwisp_controller/config/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openwisp/openwisp-controller/ba5e7a18f331f09f1cfcac0f5743d13dd16e31aa/openwisp_controller/config/api/__init__.py -------------------------------------------------------------------------------- /openwisp_controller/config/base/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openwisp/openwisp-controller/ba5e7a18f331f09f1cfcac0f5743d13dd16e31aa/openwisp_controller/config/base/__init__.py -------------------------------------------------------------------------------- /openwisp_controller/config/base/tag.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.utils.translation import gettext_lazy as _ 3 | from swapper import get_model_name 4 | from taggit.models import GenericUUIDTaggedItemBase, TagBase, TaggedItemBase 5 | 6 | from openwisp_utils.base import UUIDModel 7 | 8 | 9 | class AbstractTemplateTag(TagBase, UUIDModel): 10 | class Meta: 11 | abstract = True 12 | verbose_name = _("Tag") 13 | verbose_name_plural = _("Tags") 14 | 15 | 16 | class AbstractTaggedTemplate(GenericUUIDTaggedItemBase, TaggedItemBase): 17 | tag = models.ForeignKey( 18 | get_model_name("config", "TemplateTag"), 19 | related_name="%(app_label)s_%(class)s_items", 20 | on_delete=models.CASCADE, 21 | ) 22 | 23 | class Meta: 24 | abstract = True 25 | verbose_name = _("Tagged item") 26 | verbose_name_plural = _("Tags") 27 | -------------------------------------------------------------------------------- /openwisp_controller/config/controller/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openwisp/openwisp-controller/ba5e7a18f331f09f1cfcac0f5743d13dd16e31aa/openwisp_controller/config/controller/__init__.py -------------------------------------------------------------------------------- /openwisp_controller/config/controller/urls.py: -------------------------------------------------------------------------------- 1 | from ..utils import get_controller_urls 2 | from . import views 3 | 4 | app_name = "openwisp_controller" 5 | urlpatterns = get_controller_urls(views) 6 | -------------------------------------------------------------------------------- /openwisp_controller/config/crypto.py: -------------------------------------------------------------------------------- 1 | import codecs 2 | 3 | from cryptography.hazmat.primitives import serialization 4 | from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey 5 | 6 | 7 | def generate_wireguard_keys(): 8 | private_key = X25519PrivateKey.generate() 9 | bytes_ = private_key.private_bytes( 10 | encoding=serialization.Encoding.Raw, 11 | format=serialization.PrivateFormat.Raw, 12 | encryption_algorithm=serialization.NoEncryption(), 13 | ) 14 | private_key_str = codecs.encode(bytes_, "base64").decode("utf8").strip() 15 | # private key 16 | public_key = private_key.public_key().public_bytes( 17 | encoding=serialization.Encoding.Raw, format=serialization.PublicFormat.Raw 18 | ) 19 | public_key_str = codecs.encode(public_key, "base64").decode("utf8").strip() 20 | return private_key_str, public_key_str 21 | -------------------------------------------------------------------------------- /openwisp_controller/config/exceptions.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import ValidationError 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | 5 | class OrganizationDeviceLimitExceeded(ValidationError): 6 | """ 7 | Raised when the registered devices exceeds configured 8 | device limit for an organization. 9 | """ 10 | 11 | error_message = _( 12 | "The specified limit is lower than the amount of" 13 | " devices currently held by this organization." 14 | " Please remove some devices or consider increasing" 15 | " the device limit." 16 | ) 17 | 18 | def __init__(self): 19 | error = {"device_limit": [self.error_message]} 20 | super().__init__(error, code=None, params=None) 21 | 22 | 23 | class ZeroTierIdentityGenerationError(Exception): 24 | pass 25 | -------------------------------------------------------------------------------- /openwisp_controller/config/filters.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.utils.translation import gettext_lazy as _ 3 | from swapper import load_model 4 | 5 | from openwisp_users.multitenancy import MultitenantRelatedOrgFilter 6 | 7 | Config = load_model("config", "Config") 8 | 9 | 10 | class TemplatesFilter(MultitenantRelatedOrgFilter): 11 | title = _("template") 12 | field_name = "templates" 13 | parameter_name = "config__templates" 14 | rel_model = Config 15 | 16 | 17 | class GroupFilter(MultitenantRelatedOrgFilter): 18 | title = _("group") 19 | field_name = "group" 20 | parameter_name = "group_id" 21 | widget_attrs = MultitenantRelatedOrgFilter.widget_attrs.copy() 22 | widget_attrs.update({"data-empty-label": "-"}) 23 | 24 | 25 | class DeviceGroupFilter(admin.SimpleListFilter): 26 | title = _("has devices?") 27 | parameter_name = "empty" 28 | 29 | def lookups(self, request, model_admin): 30 | return ( 31 | ("true", _("No")), 32 | ("false", _("Yes")), 33 | ) 34 | 35 | def queryset(self, request, queryset): 36 | if self.value(): 37 | return queryset.filter(device__isnull=self.value() == "true").distinct() 38 | return queryset 39 | -------------------------------------------------------------------------------- /openwisp_controller/config/fixtures/test_templates.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "pk": "d083b494-8e16-4054-9537-fb9eba914861", 4 | "model": "config.template", 5 | "fields": { 6 | "name": "dhcp", 7 | "backend": "netjsonconfig.OpenWrt", 8 | "config": { 9 | "interfaces": [ 10 | { 11 | "name": "eth0", 12 | "type": "ethernet", 13 | "addresses": [ 14 | { 15 | "proto": "dhcp", 16 | "family": "ipv4" 17 | } 18 | ] 19 | } 20 | ] 21 | }, 22 | "created": "2015-05-16T20:02:52.483Z", 23 | "modified": "2015-05-16T19:33:41.621Z" 24 | } 25 | }, 26 | { 27 | "pk": "d083b494-8e16-4054-9537-fb9eba914862", 28 | "model": "config.template", 29 | "fields": { 30 | "name": "radio0", 31 | "backend": "netjsonconfig.OpenWrt", 32 | "config": { 33 | "radios": [ 34 | { 35 | "name": "radio0", 36 | "phy": "phy0", 37 | "driver": "mac80211", 38 | "protocol": "802.11n", 39 | "channel": 11, 40 | "channel_width": 20, 41 | "tx_power": 8, 42 | "country": "IT" 43 | } 44 | ] 45 | }, 46 | "created": "2015-05-16T20:02:52.483Z", 47 | "modified": "2015-05-16T19:33:41.621Z" 48 | } 49 | } 50 | ] 51 | -------------------------------------------------------------------------------- /openwisp_controller/config/migrations/0005_populate_device.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from django.db import migrations 3 | 4 | 5 | def forward(apps, schema_editor): 6 | """ 7 | Creates a Device record for each existing Config 8 | TODO: delete this migration in future releases 9 | """ 10 | if not schema_editor.connection.alias == "default": 11 | return 12 | Device = apps.get_model("config", "Device") 13 | Config = apps.get_model("config", "Config") 14 | 15 | for config in Config.objects.all(): 16 | device = Device( 17 | id=config.id, 18 | organization=config.organization, 19 | name=config.name, 20 | mac_address=config.mac_address, 21 | key=config.key, 22 | created=config.created, 23 | modified=config.modified, 24 | ) 25 | device.full_clean() 26 | device.save() 27 | config.device = device 28 | config.save() 29 | 30 | 31 | class Migration(migrations.Migration): 32 | dependencies = [("config", "0004_add_device_model")] 33 | 34 | operations = [migrations.RunPython(forward, reverse_code=migrations.RunPython.noop)] 35 | -------------------------------------------------------------------------------- /openwisp_controller/config/migrations/0006_config_device_not_null.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.5 on 2017-05-11 18:35 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [("config", "0005_populate_device")] 9 | 10 | operations = [ 11 | migrations.AlterField( 12 | model_name="config", 13 | name="device", 14 | field=models.OneToOneField( 15 | on_delete=django.db.models.deletion.CASCADE, to="config.Device" 16 | ), 17 | ) 18 | ] 19 | -------------------------------------------------------------------------------- /openwisp_controller/config/migrations/0007_simplify_config.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.5 on 2017-05-11 18:41 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [("config", "0006_config_device_not_null")] 8 | 9 | operations = [ 10 | migrations.RemoveField(model_name="config", name="name"), 11 | migrations.RemoveField(model_name="config", name="key"), 12 | migrations.RemoveField(model_name="config", name="mac_address"), 13 | ] 14 | -------------------------------------------------------------------------------- /openwisp_controller/config/migrations/0009_device_system.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.7 on 2017-06-01 16:00 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [("config", "0008_update_indexes")] 8 | 9 | operations = [ 10 | migrations.AddField( 11 | model_name="device", 12 | name="system", 13 | field=models.CharField( 14 | blank=True, 15 | db_index=True, 16 | help_text="system on chip or CPU info", 17 | max_length=128, 18 | verbose_name="SOC / CPU", 19 | ), 20 | ) 21 | ] 22 | -------------------------------------------------------------------------------- /openwisp_controller/config/migrations/0010_auto_20180106_1814.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.7 on 2018-01-06 17:14 3 | import re 4 | 5 | import django.core.validators 6 | from django.db import migrations, models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | dependencies = [("config", "0009_device_system")] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="device", 15 | name="mac_address", 16 | field=models.CharField( 17 | db_index=True, 18 | help_text="primary mac address", 19 | max_length=17, 20 | unique=True, 21 | validators=[ 22 | django.core.validators.RegexValidator( 23 | re.compile("^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$", 32), 24 | code="invalid", 25 | message="Must be a valid mac address.", 26 | ) 27 | ], 28 | ), 29 | ) 30 | ] 31 | -------------------------------------------------------------------------------- /openwisp_controller/config/migrations/0011_update_device_mac_address.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.9 on 2018-01-31 02:09 3 | import re 4 | 5 | import django.core.validators 6 | from django.db import migrations, models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | dependencies = [("config", "0010_auto_20180106_1814")] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="device", 15 | name="mac_address", 16 | field=models.CharField( 17 | db_index=True, 18 | help_text="primary mac address", 19 | max_length=17, 20 | unique=True, 21 | validators=[ 22 | django.core.validators.RegexValidator( 23 | re.compile("^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})", 32), 24 | code="invalid", 25 | message="Must be a valid mac address.", 26 | ) 27 | ], 28 | ), 29 | ) 30 | ] 31 | -------------------------------------------------------------------------------- /openwisp_controller/config/migrations/0012_auto_20180219_1501.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0.2 on 2018-02-19 14:01 2 | 3 | import re 4 | 5 | import django.core.validators 6 | from django.db import migrations, models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | dependencies = [("config", "0011_update_device_mac_address")] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="device", 15 | name="mac_address", 16 | field=models.CharField( 17 | db_index=True, 18 | help_text="primary mac address", 19 | max_length=17, 20 | unique=True, 21 | validators=[ 22 | django.core.validators.RegexValidator( 23 | re.compile("^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$"), 24 | code="invalid", 25 | message="Must be a valid mac address.", 26 | ) 27 | ], 28 | ), 29 | ) 30 | ] 31 | -------------------------------------------------------------------------------- /openwisp_controller/config/migrations/0014_device_hardware_id.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0.9 on 2018-10-25 14:06 2 | 3 | from django.db import migrations, models 4 | 5 | from openwisp_controller.config import settings as app_settings 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [("config", "0013_last_ip_management_ip_and_status_applied")] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="device", 14 | name="hardware_id", 15 | field=models.CharField(**(app_settings.HARDWARE_ID_OPTIONS)), 16 | ) 17 | ] 18 | -------------------------------------------------------------------------------- /openwisp_controller/config/migrations/0015_default_groups_permissions.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations 2 | 3 | from . import assign_permissions_to_groups 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("config", "0014_device_hardware_id"), 9 | ] 10 | 11 | operations = [ 12 | migrations.RunPython( 13 | assign_permissions_to_groups, reverse_code=migrations.RunPython.noop 14 | ) 15 | ] 16 | -------------------------------------------------------------------------------- /openwisp_controller/config/migrations/0016_default_organization_config_settings.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations 2 | 3 | from ...migrations import get_swapped_model 4 | 5 | 6 | def create_default_config_settings_organization(apps, schema_editor): 7 | organization_model = get_swapped_model(apps, "openwisp_users", "Organization") 8 | config_settings_model = apps.get_model("config", "OrganizationConfigSettings") 9 | for organization in organization_model.objects.all(): 10 | try: 11 | organization.config_settings 12 | except organization_model.config_settings.RelatedObjectDoesNotExist: 13 | # if there is no OrganizationConfigSettings 14 | # associated to this organization, create it 15 | config_settings_model.objects.create(organization=organization) 16 | 17 | 18 | class Migration(migrations.Migration): 19 | dependencies = [ 20 | ("config", "0015_default_groups_permissions"), 21 | ] 22 | operations = [ 23 | migrations.RunPython( 24 | create_default_config_settings_organization, 25 | reverse_code=migrations.RunPython.noop, 26 | ) 27 | ] 28 | -------------------------------------------------------------------------------- /openwisp_controller/config/migrations/0017_template_name_organization_unique_together.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0.9 on 2018-12-08 05:22 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("config", "0016_default_organization_config_settings"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterUniqueTogether( 13 | name="template", unique_together={("organization", "name")} 14 | ) 15 | ] 16 | -------------------------------------------------------------------------------- /openwisp_controller/config/migrations/0018_config_context.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.4 on 2019-01-09 01:52 2 | 3 | import collections 4 | 5 | import jsonfield.fields 6 | from django.db import migrations 7 | 8 | 9 | class Migration(migrations.Migration): 10 | dependencies = [("config", "0017_template_name_organization_unique_together")] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="config", 15 | name="context", 16 | field=jsonfield.fields.JSONField( 17 | blank=True, 18 | dump_kwargs={"indent": 4}, 19 | help_text=( 20 | 'Additional context ' 22 | "(configuration variables) in JSON format" 23 | ), 24 | load_kwargs={"object_pairs_hook": collections.OrderedDict}, 25 | null=True, 26 | ), 27 | ) 28 | ] 29 | -------------------------------------------------------------------------------- /openwisp_controller/config/migrations/0020_remove_config_organization.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0.10 on 2019-02-09 07:01 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("config", "0019_organization_mac_add_hardware_id_name_unique_together") 9 | ] 10 | 11 | operations = [migrations.RemoveField(model_name="config", name="organization")] 12 | -------------------------------------------------------------------------------- /openwisp_controller/config/migrations/0021_vpn_key.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.8 on 2019-04-22 23:55 2 | 3 | import re 4 | 5 | import django.core.validators 6 | from django.db import migrations 7 | 8 | import openwisp_utils.base 9 | import openwisp_utils.utils 10 | 11 | 12 | class Migration(migrations.Migration): 13 | dependencies = [("config", "0020_remove_config_organization")] 14 | 15 | operations = [ 16 | migrations.AddField( 17 | model_name="vpn", 18 | name="key", 19 | field=openwisp_utils.base.KeyField( 20 | db_index=True, 21 | default=openwisp_utils.utils.get_random_key, 22 | help_text=None, 23 | max_length=64, 24 | validators=[ 25 | django.core.validators.RegexValidator( 26 | re.compile("^[^\\s/\\.]+$"), 27 | code="invalid", 28 | message="This value must not contain spaces, dots or slashes.", 29 | ) 30 | ], 31 | ), 32 | ) 33 | ] 34 | -------------------------------------------------------------------------------- /openwisp_controller/config/migrations/0022_vpn_format_dh.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations 2 | 3 | 4 | def format_dh(apps, schema_editor): 5 | Vpn = apps.get_model("config", "Vpn") 6 | 7 | for vpn in Vpn.objects.all(): 8 | if vpn.dh.startswith("b'") and vpn.dh.endswith("'"): 9 | vpn.dh = vpn.dh[2:-1] 10 | vpn.save() 11 | 12 | 13 | class Migration(migrations.Migration): 14 | dependencies = [("config", "0021_vpn_key")] 15 | 16 | operations = [ 17 | migrations.RunPython(format_dh, reverse_code=migrations.RunPython.noop) 18 | ] 19 | -------------------------------------------------------------------------------- /openwisp_controller/config/migrations/0023_update_context.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.3 on 2020-02-26 19:58 2 | 3 | import collections 4 | 5 | import jsonfield.fields 6 | from django.db import migrations 7 | 8 | 9 | class Migration(migrations.Migration): 10 | dependencies = [("config", "0022_vpn_format_dh")] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="config", 15 | name="context", 16 | field=jsonfield.fields.JSONField( 17 | blank=True, 18 | default=dict, 19 | dump_kwargs={"ensure_ascii": False, "indent": 4}, 20 | help_text=( 21 | 'Additional ' 23 | "context (configuration variables) in JSON format" 24 | ), 25 | load_kwargs={"object_pairs_hook": collections.OrderedDict}, 26 | ), 27 | ) 28 | ] 29 | -------------------------------------------------------------------------------- /openwisp_controller/config/migrations/0024_update_context_data.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations 2 | 3 | 4 | def forward(apps, schema_editor): 5 | """ 6 | Updates default value of context field 7 | """ 8 | if not schema_editor.connection.alias == "default": 9 | return 10 | Config = apps.get_model("config", "Config") 11 | 12 | for config in Config.objects.filter(context__isnull=True): 13 | config.context = {} 14 | config.save() 15 | 16 | 17 | class Migration(migrations.Migration): 18 | dependencies = [("config", "0023_update_context")] 19 | 20 | operations = [migrations.RunPython(forward, reverse_code=migrations.RunPython.noop)] 21 | -------------------------------------------------------------------------------- /openwisp_controller/config/migrations/0025_update_device_key.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.3 on 2020-02-07 06:34 2 | 3 | import re 4 | 5 | import django.core.validators 6 | from django.db import migrations 7 | 8 | import openwisp_utils.base 9 | import openwisp_utils.utils 10 | 11 | 12 | class Migration(migrations.Migration): 13 | dependencies = [("config", "0024_update_context_data")] 14 | 15 | operations = [ 16 | migrations.AlterField( 17 | model_name="device", 18 | name="key", 19 | field=openwisp_utils.base.KeyField( 20 | blank=True, 21 | db_index=True, 22 | default=None, 23 | help_text="unique device key", 24 | max_length=64, 25 | unique=True, 26 | validators=[ 27 | django.core.validators.RegexValidator( 28 | re.compile("^[^\\s/\\.]+$"), 29 | code="invalid", 30 | message="This value must not contain spaces, dots or slashes.", 31 | ) 32 | ], 33 | ), 34 | ) 35 | ] 36 | -------------------------------------------------------------------------------- /openwisp_controller/config/migrations/0026_hardware_id_not_unique.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.3 on 2020-03-13 23:45 2 | 3 | from django.db import migrations, models 4 | 5 | from openwisp_controller.config import settings as app_settings 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [("config", "0025_update_device_key")] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="device", 14 | name="hardware_id", 15 | field=models.CharField(**app_settings.HARDWARE_ID_OPTIONS), 16 | ) 17 | ] 18 | -------------------------------------------------------------------------------- /openwisp_controller/config/migrations/0027_add_indexes_on_ip_fields.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.4 on 2020-04-15 23:40 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [("config", "0026_hardware_id_not_unique")] 8 | 9 | operations = [ 10 | migrations.AlterField( 11 | model_name="device", 12 | name="last_ip", 13 | field=models.GenericIPAddressField( 14 | blank=True, 15 | db_index=True, 16 | help_text=( 17 | "indicates the IP address logged from the " 18 | "last request coming from the device" 19 | ), 20 | null=True, 21 | ), 22 | ), 23 | migrations.AlterField( 24 | model_name="device", 25 | name="management_ip", 26 | field=models.GenericIPAddressField( 27 | blank=True, 28 | db_index=True, 29 | help_text=( 30 | "IP address used by the system to reach the device when " 31 | "performing any type of push operation or active check. The " 32 | "value of this field is generally sent by the device and hence " 33 | "does not need to be changed, but can be changed or cleared " 34 | "manually if needed." 35 | ), 36 | null=True, 37 | ), 38 | ), 39 | ] 40 | -------------------------------------------------------------------------------- /openwisp_controller/config/migrations/0028_template_default_values.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.3 on 2020-06-29 23:54 2 | 3 | import collections 4 | 5 | import jsonfield.fields 6 | from django.db import migrations 7 | 8 | 9 | class Migration(migrations.Migration): 10 | dependencies = [("config", "0027_add_indexes_on_ip_fields")] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="template", 15 | name="default_values", 16 | field=jsonfield.fields.JSONField( 17 | blank=True, 18 | default=dict, 19 | dump_kwargs={"ensure_ascii": False, "indent": 4}, 20 | help_text=( 21 | "A dictionary containing the default values for " 22 | "the variables used by this template; these default " 23 | "variables will be used during schema validation." 24 | ), 25 | load_kwargs={"object_pairs_hook": collections.OrderedDict}, 26 | verbose_name="Default Values", 27 | ), 28 | ) 29 | ] 30 | -------------------------------------------------------------------------------- /openwisp_controller/config/migrations/0029_merge_django_netjsonconfig.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.6 on 2020-05-10 07:25 2 | 3 | import re 4 | 5 | import django.core.validators 6 | from django.db import migrations, models 7 | 8 | from .. import settings as app_settings 9 | 10 | 11 | class Migration(migrations.Migration): 12 | dependencies = [("config", "0028_template_default_values")] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name="device", 17 | name="name", 18 | field=models.CharField( 19 | db_index=True, 20 | max_length=64, 21 | validators=[ 22 | django.core.validators.RegexValidator( 23 | re.compile( 24 | "^([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\\-]{0,61}[a-zA-Z0-9])" 25 | "(\\.([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\\-]{0,61}" 26 | "[a-zA-Z0-9]))*$|^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$" 27 | ), 28 | code="invalid", 29 | message="Must be either a valid hostname or mac address.", 30 | ) 31 | ], 32 | help_text=("must be either a valid hostname or mac address"), 33 | ), 34 | ), 35 | migrations.AlterField( 36 | model_name="vpn", 37 | name="backend", 38 | field=models.CharField( 39 | choices=app_settings.VPN_BACKENDS, 40 | help_text="Select VPN configuration backend", 41 | max_length=128, 42 | verbose_name="VPN backend", 43 | ), 44 | ), 45 | ] 46 | -------------------------------------------------------------------------------- /openwisp_controller/config/migrations/0030_django_taggit_update.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.7 on 2020-07-05 04:37 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("contenttypes", "0002_remove_content_type_name"), 10 | ("config", "0029_merge_django_netjsonconfig"), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name="taggedtemplate", 16 | name="content_type", 17 | field=models.ForeignKey( 18 | on_delete=django.db.models.deletion.CASCADE, 19 | related_name="config_taggedtemplate_tagged_items", 20 | to="contenttypes.ContentType", 21 | verbose_name="content type", 22 | ), 23 | ), 24 | migrations.AlterField( 25 | model_name="taggedtemplate", 26 | name="object_id", 27 | field=models.UUIDField(db_index=True, verbose_name="object ID"), 28 | ), 29 | migrations.AlterField( 30 | model_name="templatetag", 31 | name="name", 32 | field=models.CharField(max_length=100, unique=True, verbose_name="name"), 33 | ), 34 | migrations.AlterField( 35 | model_name="templatetag", 36 | name="slug", 37 | field=models.SlugField( 38 | allow_unicode=True, max_length=100, unique=True, verbose_name="slug" 39 | ), 40 | ), 41 | ] 42 | -------------------------------------------------------------------------------- /openwisp_controller/config/migrations/0031_update_vpn_dh_param.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.6 on 2020-05-10 07:25 2 | 3 | from django.db import migrations 4 | 5 | from . import update_vpn_dhparam_length 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [("config", "0030_django_taggit_update")] 10 | 11 | operations = [ 12 | migrations.RunPython( 13 | update_vpn_dhparam_length, reverse_code=migrations.RunPython.noop 14 | ) 15 | ] 16 | -------------------------------------------------------------------------------- /openwisp_controller/config/migrations/0032_update_legacy_vpn_backend.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations 2 | 3 | 4 | def update_legacy_vpn_backend(apps, schema_editor): 5 | Vpn = apps.get_model("config", "Vpn") 6 | Vpn.objects.filter(backend="django_netjsonconfig.vpn_backends.OpenVpn").update( 7 | backend="openwisp_controller.vpn_backends.OpenVpn" 8 | ) 9 | 10 | 11 | class Migration(migrations.Migration): 12 | dependencies = [("config", "0031_update_vpn_dh_param")] 13 | 14 | operations = [ 15 | migrations.RunPython( 16 | update_legacy_vpn_backend, reverse_code=migrations.RunPython.noop 17 | ) 18 | ] 19 | -------------------------------------------------------------------------------- /openwisp_controller/config/migrations/0033_name_unique_per_organization.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.6 on 2021-02-11 21:11 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("config", "0032_update_legacy_vpn_backend"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="template", 14 | name="name", 15 | field=models.CharField(db_index=True, max_length=64), 16 | ), 17 | migrations.AlterField( 18 | model_name="vpn", 19 | name="name", 20 | field=models.CharField(db_index=True, max_length=64), 21 | ), 22 | migrations.AlterUniqueTogether( 23 | name="vpn", unique_together={("organization", "name")} 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /openwisp_controller/config/migrations/0034_template_required.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.1 on 2020-12-02 23:56 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [("config", "0033_name_unique_per_organization")] 8 | 9 | operations = [ 10 | migrations.AddField( 11 | model_name="template", 12 | name="required", 13 | field=models.BooleanField( 14 | db_index=True, 15 | default=False, 16 | help_text=( 17 | "if checked, will force the assignment of this template to all the " 18 | "devices of the organization (if no organization is selected, it " 19 | "will be required for every device in the system)" 20 | ), 21 | verbose_name="required", 22 | ), 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /openwisp_controller/config/migrations/0035_device_name_unique_optional.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.8 on 2021-05-04 20:53 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("config", "0034_template_required"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterUniqueTogether( 13 | name="device", 14 | unique_together={ 15 | ("mac_address", "organization"), 16 | ("hardware_id", "organization"), 17 | }, 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /openwisp_controller/config/migrations/0037_alter_taggedtemplate.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0 on 2021-12-17 16:02 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("contenttypes", "0002_remove_content_type_name"), 10 | ("config", "0036_device_group"), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name="taggedtemplate", 16 | name="content_type", 17 | field=models.ForeignKey( 18 | on_delete=django.db.models.deletion.CASCADE, 19 | related_name="%(app_label)s_%(class)s_tagged_items", 20 | to="contenttypes.contenttype", 21 | verbose_name="content type", 22 | ), 23 | ), 24 | migrations.AlterField( 25 | model_name="taggedtemplate", 26 | name="tag", 27 | field=models.ForeignKey( 28 | on_delete=django.db.models.deletion.CASCADE, 29 | related_name="%(app_label)s_%(class)s_items", 30 | to="config.templatetag", 31 | ), 32 | ), 33 | ] 34 | -------------------------------------------------------------------------------- /openwisp_controller/config/migrations/0038_vpn_subnet.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.7 on 2021-03-08 12:17 2 | 3 | import django.db.models.deletion 4 | from django.conf import settings 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | migrations.swappable_dependency(settings.OPENWISP_IPAM_SUBNET_MODEL), 11 | ("config", "0037_alter_taggedtemplate"), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name="vpn", 17 | name="subnet", 18 | field=models.ForeignKey( 19 | blank=True, 20 | help_text="Subnet IP addresses used by VPN clients, if applicable", 21 | null=True, 22 | on_delete=django.db.models.deletion.SET_NULL, 23 | to=settings.OPENWISP_IPAM_SUBNET_MODEL, 24 | verbose_name="Subnet", 25 | ), 26 | ), 27 | ] 28 | -------------------------------------------------------------------------------- /openwisp_controller/config/migrations/0040_vpnclient_ip_setnull.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.13 on 2021-09-20 13:01 2 | 3 | import django.db.models.deletion 4 | from django.conf import settings 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | migrations.swappable_dependency(settings.OPENWISP_IPAM_IPADDRESS_MODEL), 11 | ("config", "0039_wireguard_vxlan_ipam"), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name="vpn", 17 | name="ip", 18 | field=models.ForeignKey( 19 | blank=True, 20 | help_text=( 21 | "Internal IP address of the VPN server interface, if applicable" 22 | ), 23 | null=True, 24 | on_delete=django.db.models.deletion.SET_NULL, 25 | to=settings.OPENWISP_IPAM_IPADDRESS_MODEL, 26 | verbose_name="Internal IP", 27 | ), 28 | ), 29 | migrations.AlterField( 30 | model_name="vpnclient", 31 | name="ip", 32 | field=models.ForeignKey( 33 | blank=True, 34 | null=True, 35 | on_delete=django.db.models.deletion.SET_NULL, 36 | to=settings.OPENWISP_IPAM_IPADDRESS_MODEL, 37 | ), 38 | ), 39 | ] 40 | -------------------------------------------------------------------------------- /openwisp_controller/config/migrations/0041_default_groups_organizationconfigsettings_permission.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.5 on 2022-06-21 17:49 2 | 3 | from django.db import migrations 4 | 5 | from . import assign_organization_config_settings_permissions_to_groups 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | ("config", "0040_vpnclient_ip_setnull"), 11 | ] 12 | 13 | operations = [ 14 | migrations.RunPython( 15 | code=assign_organization_config_settings_permissions_to_groups, 16 | reverse_code=migrations.operations.special.RunPython.noop, 17 | ) 18 | ] 19 | -------------------------------------------------------------------------------- /openwisp_controller/config/migrations/0043_devicegroup_templates.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.4 on 2022-05-05 06:44 2 | 3 | from django.conf import settings 4 | from django.db import migrations 5 | 6 | import openwisp_controller.config.sortedm2m.fields 7 | 8 | 9 | class Migration(migrations.Migration): 10 | dependencies = [ 11 | ("config", "0042_multiple_wireguard_tunnels"), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name="devicegroup", 17 | name="templates", 18 | field=openwisp_controller.config.sortedm2m.fields.SortedManyToManyField( 19 | blank=True, 20 | help_text=( 21 | "These templates are automatically assigned to the devices " 22 | "that are part of the group. Default and required templates " 23 | "are excluded from this list. If the group of the device is " 24 | "changed, these templates will be automatically removed and " 25 | "the templates of the new group will be assigned." 26 | ), 27 | related_name="device_group_relations", 28 | to=settings.CONFIG_TEMPLATE_MODEL, 29 | verbose_name="templates", 30 | ), 31 | ), 32 | ] 33 | -------------------------------------------------------------------------------- /openwisp_controller/config/migrations/0044_config_error_reason.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.3 on 2022-07-07 16:14 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("config", "0043_devicegroup_templates"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="config", 14 | name="error_reason", 15 | field=models.CharField( 16 | blank=True, 17 | help_text="Error reason reported by the device", 18 | max_length=1024, 19 | verbose_name="error reason", 20 | ), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /openwisp_controller/config/migrations/0045_alter_vpn_webhook_endpoint.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.15 on 2022-09-09 12:49 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("config", "0044_config_error_reason"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="vpn", 14 | name="webhook_endpoint", 15 | field=models.URLField( 16 | blank=True, 17 | help_text=( 18 | "Webhook to trigger for updating server configuration" 19 | " (e.g. https://openwisp2.mydomain.com:8081/trigger-update)" 20 | ), 21 | null=True, 22 | verbose_name="Webhook Endpoint", 23 | ), 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /openwisp_controller/config/migrations/0047_add_organizationlimits.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.15 on 2022-09-16 11:45 2 | 3 | 4 | from django.db import migrations 5 | 6 | from . import populate_organization_allowed_device 7 | 8 | 9 | class Migration(migrations.Migration): 10 | dependencies = [ 11 | ("config", "0046_organizationlimits"), 12 | ] 13 | 14 | operations = [ 15 | migrations.RunPython( 16 | populate_organization_allowed_device, migrations.RunPython.noop 17 | ) 18 | ] 19 | -------------------------------------------------------------------------------- /openwisp_controller/config/migrations/0049_devicegroup_context.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.19 on 2023-06-27 14:45 2 | 3 | import collections 4 | 5 | import jsonfield.fields 6 | from django.db import migrations 7 | 8 | 9 | class Migration(migrations.Migration): 10 | dependencies = [ 11 | ("config", "0048_wifi_radio_band_migration"), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name="devicegroup", 17 | name="context", 18 | field=jsonfield.fields.JSONField( 19 | blank=True, 20 | default=dict, 21 | dump_kwargs={"ensure_ascii": False, "indent": 4}, 22 | help_text=( 23 | "This field can be used to add meta data for the group" 24 | ' or to add "Configuration Variables" to the devices.' 25 | ), 26 | load_kwargs={"object_pairs_hook": collections.OrderedDict}, 27 | verbose_name="Configuration Variables", 28 | ), 29 | ), 30 | ] 31 | -------------------------------------------------------------------------------- /openwisp_controller/config/migrations/0050_alter_vpnclient_unique_together.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.3 on 2023-07-18 16:59 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("config", "0049_devicegroup_context"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterUniqueTogether( 13 | name="vpnclient", 14 | unique_together={("config", "vpn")}, 15 | ), 16 | ] 17 | -------------------------------------------------------------------------------- /openwisp_controller/config/migrations/0051_organizationconfigsettings_context.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.20 on 2023-07-22 10:49 2 | 3 | import collections 4 | 5 | import jsonfield.fields 6 | from django.db import migrations 7 | 8 | 9 | class Migration(migrations.Migration): 10 | dependencies = [ 11 | ("config", "0050_alter_vpnclient_unique_together"), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name="organizationconfigsettings", 17 | name="context", 18 | field=jsonfield.fields.JSONField( 19 | blank=True, 20 | default=dict, 21 | dump_kwargs={"indent": 4}, 22 | help_text=( 23 | 'This field can be used to add "Configuration Variables"' 24 | " to the devices." 25 | ), 26 | load_kwargs={"object_pairs_hook": collections.OrderedDict}, 27 | verbose_name="Configuration Variables", 28 | ), 29 | ), 30 | ] 31 | -------------------------------------------------------------------------------- /openwisp_controller/config/migrations/0052_vpn_node_network_id.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.19 on 2023-07-02 18:13 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("config", "0051_organizationconfigsettings_context"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="vpn", 14 | name="node_id", 15 | field=models.CharField(blank=True, max_length=10), 16 | ), 17 | migrations.AddField( 18 | model_name="vpn", 19 | name="network_id", 20 | field=models.CharField(blank=True, max_length=16), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /openwisp_controller/config/migrations/0053_vpnclient_secret.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.19 on 2023-07-27 12:14 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("config", "0052_vpn_node_network_id"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="vpnclient", 14 | name="secret", 15 | field=models.TextField(blank=True), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /openwisp_controller/config/migrations/0054_device__is_deactivated.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.20 on 2024-02-29 11:56 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("config", "0053_vpnclient_secret"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="device", 14 | name="_is_deactivated", 15 | field=models.BooleanField(default=False), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /openwisp_controller/config/migrations/0055_alter_config_status.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.10 on 2024-03-01 16:35 2 | 3 | import model_utils.fields 4 | from django.db import migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("config", "0054_device__is_deactivated"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="config", 15 | name="status", 16 | field=model_utils.fields.StatusField( 17 | choices=[ 18 | ("modified", "modified"), 19 | ("applied", "applied"), 20 | ("error", "error"), 21 | ("deactivating", "deactivating"), 22 | ("deactivated", "deactivated"), 23 | ], 24 | default="modified", 25 | help_text=( 26 | '"modified" means the configuration is not applied yet; \n' 27 | '"applied" means the configuration is applied successfully; \n' 28 | '"error" means the configuration caused issues and it was' 29 | ' rolled back; \n"deactivating" means the device has been' 30 | " deactivated and the configuration is being removed; \n" 31 | '"deactivated" means the configuration has been removed ' 32 | "from the device;" 33 | ), 34 | max_length=100, 35 | no_check_for_status=True, 36 | verbose_name="configuration status", 37 | ), 38 | ), 39 | ] 40 | -------------------------------------------------------------------------------- /openwisp_controller/config/migrations/0056_vpnclient_template.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.18 on 2025-03-18 15:53 2 | 3 | import django.db.models.deletion 4 | from django.conf import settings 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | ("config", "0055_alter_config_status"), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name="vpnclient", 16 | name="template", 17 | field=models.ForeignKey( 18 | default=None, 19 | null=True, 20 | on_delete=django.db.models.deletion.CASCADE, 21 | to=settings.CONFIG_TEMPLATE_MODEL, 22 | ), 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /openwisp_controller/config/migrations/0057_populate_vpnclient_template.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.15 on 2022-09-16 11:45 2 | 3 | 4 | from django.db import migrations 5 | 6 | 7 | def populate_vpnclient_template(apps, schema_editor): 8 | VpnClient = apps.get_model("config", "VpnClient") 9 | 10 | for vpn_client in VpnClient.objects.iterator(): 11 | if vpn_client.template is None: 12 | vpn_client.template = vpn_client.config.templates.get( 13 | vpn_id=vpn_client.vpn_id 14 | ) 15 | vpn_client.save() 16 | 17 | 18 | class Migration(migrations.Migration): 19 | dependencies = [ 20 | ("config", "0056_vpnclient_template"), 21 | ] 22 | 23 | operations = [ 24 | migrations.RunPython(populate_vpnclient_template, migrations.RunPython.noop) 25 | ] 26 | -------------------------------------------------------------------------------- /openwisp_controller/config/migrations/0058_alter_vpnclient_template.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.18 on 2025-03-18 15:55 2 | 3 | import django.db.models.deletion 4 | from django.conf import settings 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | ("config", "0057_populate_vpnclient_template"), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name="vpnclient", 16 | name="template", 17 | field=models.ForeignKey( 18 | on_delete=django.db.models.deletion.CASCADE, 19 | to=settings.CONFIG_TEMPLATE_MODEL, 20 | ), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /openwisp_controller/config/migrations/0059_zerotier_templates_ow_zt_to_global.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations 2 | 3 | 4 | def change_owzt_to_global(apps, schema_editor): 5 | Template = apps.get_model("config", "Template") 6 | updated_templates = set() 7 | for template in Template.objects.filter( 8 | type="vpn", vpn__backend="openwisp_controller.vpn_backends.ZeroTier" 9 | ).iterator(): 10 | if "zerotier" in template.config: 11 | for item in template.config.get("zerotier", []): 12 | if item.get("name") == "ow_zt": 13 | item["name"] = "global" 14 | updated_templates.add(template) 15 | Template.objects.bulk_update(updated_templates, ["config"]) 16 | 17 | 18 | class Migration(migrations.Migration): 19 | dependencies = [("config", "0058_alter_vpnclient_template")] 20 | 21 | operations = [ 22 | migrations.RunPython( 23 | change_owzt_to_global, reverse_code=migrations.RunPython.noop 24 | ) 25 | ] 26 | -------------------------------------------------------------------------------- /openwisp_controller/config/migrations/0060_cleanup_api_task_notification_types.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.2.2 on 2025-06-30 11:34 2 | 3 | from django.db import migrations 4 | 5 | 6 | def cleanup_api_task_notification_types(apps, schema_editor): 7 | if not apps.is_installed("openwisp_notifications"): 8 | return 9 | Notification = apps.get_model("openwisp_notifications", "Notification") 10 | NotificationSetting = apps.get_model( 11 | "openwisp_notifications", "NotificationSetting" 12 | ) 13 | NotificationSetting.objects.filter( 14 | type__in=["api_task_error", "api_task_recovery"] 15 | ).delete() 16 | Notification.objects.filter( 17 | type__in=["api_task_error", "api_task_recovery"] 18 | ).delete() 19 | 20 | 21 | class Migration(migrations.Migration): 22 | 23 | dependencies = [ 24 | ("config", "0059_zerotier_templates_ow_zt_to_global"), 25 | ( 26 | "openwisp_notifications", 27 | "0009_alter_notificationsetting_organization_and_more", 28 | ), 29 | ] 30 | 31 | operations = [ 32 | migrations.RunPython( 33 | cleanup_api_task_notification_types, reverse_code=migrations.RunPython.noop 34 | ), 35 | ] 36 | -------------------------------------------------------------------------------- /openwisp_controller/config/sortedm2m/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openwisp/openwisp-controller/ba5e7a18f331f09f1cfcac0f5743d13dd16e31aa/openwisp_controller/config/sortedm2m/__init__.py -------------------------------------------------------------------------------- /openwisp_controller/config/sortedm2m/forms.py: -------------------------------------------------------------------------------- 1 | from sortedm2m.forms import ( 2 | SortedCheckboxSelectMultiple as BaseSortedCheckboxSelectMultiple, 3 | ) 4 | from sortedm2m.forms import SortedMultipleChoiceField as BaseSortedMultipleChoiceField 5 | 6 | 7 | class SortedCheckboxSelectMultiple(BaseSortedCheckboxSelectMultiple): 8 | class Media(BaseSortedCheckboxSelectMultiple.Media): 9 | # The django-sortedm2m library has a bug when the widget is 10 | # use in StackedInline Admin, see 11 | # https://github.com/jazzband/django-sortedm2m/pull/213 12 | # The workaround in sortedm2m/patch_sortedm2m.js fixes the bug. 13 | # TODO: Remove this workaround when a new version of django-sortedm2m 14 | # is released. 15 | js = ( 16 | "admin/js/jquery.init.js", 17 | "sortedm2m/widget.js", 18 | "sortedm2m/jquery-ui.js", 19 | "sortedm2m/patch_sortedm2m.js", 20 | ) 21 | 22 | 23 | class SortedMultipleChoiceField(BaseSortedMultipleChoiceField): 24 | widget = SortedCheckboxSelectMultiple 25 | -------------------------------------------------------------------------------- /openwisp_controller/config/static/config/css/device-delete-confirmation.css: -------------------------------------------------------------------------------- 1 | #deactivating-warning .warning p { 2 | margin-top: 0px; 3 | } 4 | #deactivating-warning .messagelist button { 5 | font-size: 15px; 6 | vertical-align: middle; 7 | line-height: inherit !important; 8 | padding: 0.625rem 1rem; 9 | } 10 | #deactivating-warning .messagelist button + button { 11 | margin-left: 10px; 12 | } 13 | #main ul.messagelist li.warning ul li { 14 | display: list-item; 15 | padding: 0px; 16 | background: inherit; 17 | } 18 | ul.messagelist li { 19 | font-size: unset; 20 | } 21 | -------------------------------------------------------------------------------- /openwisp_controller/config/static/config/css/devicegroup.css: -------------------------------------------------------------------------------- 1 | #id_meta_data_jsoneditor { 2 | min-height: 75px; 3 | } 4 | #id_meta_data_jsoneditor > div[data-schemaid="root"] { 5 | margin-top: 15px; 6 | } 7 | .jsoneditor-wrapper div[data-schemaid="root"] > label:first-of-type, 8 | .jsoneditor-wrapper div[data-schemaid="root"] > select:first-of-type { 9 | position: static; 10 | } 11 | -------------------------------------------------------------------------------- /openwisp_controller/config/static/config/js/device-delete-confirmation.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | (function ($) { 4 | $(document).ready(function () { 5 | $("#warning-ack").click(function (event) { 6 | $("#deactivating-warning").slideUp("fast"); 7 | $("#delete-confirm-container").slideDown("fast"); 8 | $('input[name="force_delete"]').val("true"); 9 | }); 10 | }); 11 | })(django.jQuery); 12 | -------------------------------------------------------------------------------- /openwisp_controller/config/static/config/js/management_ip.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | django.jQuery(function ($) { 3 | if ($(".add-form").length || !$("#device_form").length) { 4 | return; 5 | } 6 | var editAltText = gettext("Edit"), 7 | cancelAltText = gettext("Cancel"); 8 | 9 | // replaces the management ip field with text with the option to edit it 10 | var ipInput = $("#id_management_ip"), 11 | initialIp = ipInput.val() === "" ? "-" : ipInput.val(); 12 | ipInput.after(function () { 13 | ipInput.hide(); 14 | return ( 15 | `${initialIp}` + 16 | `${editAltText}` 18 | ); 19 | }); 20 | $("#edit_management_ip").click(function () { 21 | var ipReadonly = $("#management_ip_text"); 22 | var imgEl = $("#edit_management_ip > img"); 23 | if (imgEl.attr("value") === "edit") { 24 | ipInput.show(); 25 | ipReadonly.hide(); 26 | imgEl.attr("src", `${window.staticUrl}admin/img/icon-deletelink.svg`); 27 | imgEl.attr("value", "cancel"); 28 | imgEl.attr("alt", cancelAltText); 29 | imgEl.attr("title", cancelAltText); 30 | } else { 31 | ipReadonly.show(); 32 | ipInput.hide(); 33 | ipInput.val(initialIp); 34 | imgEl.attr("src", `${window.staticUrl}admin/img/icon-changelink.svg`); 35 | imgEl.attr("value", "edit"); 36 | imgEl.attr("alt", editAltText); 37 | imgEl.attr("title", editAltText); 38 | } 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /openwisp_controller/config/static/config/js/switcher.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | django.jQuery(function ($) { 3 | var type_select = $("#id_type"), 4 | vpn_specific = $(".field-vpn, .field-auto_cert"), 5 | gettext = 6 | window.gettext || 7 | function (v) { 8 | return v; 9 | }, 10 | toggle_vpn_specific = function (changed) { 11 | if (type_select.val() == "vpn") { 12 | vpn_specific.show(); 13 | if ( 14 | changed === true && 15 | $(".autovpn").length < 1 && 16 | $("#id_config").val() === "{}" 17 | ) { 18 | var p1 = gettext( 19 | "Click on Save to automatically generate the " + 20 | "VPN client configuration (will be based on " + 21 | "the configuration of the server).", 22 | ), 23 | p2 = gettext( 24 | "You can then tweak the VPN client " + "configuration in the next step.", 25 | ); 26 | $(".jsoneditor-wrapper").hide().after('
'); 27 | $(".autovpn").html( 28 | "

" + p1 + "

" + "

" + p2 + "

", 29 | ); 30 | } 31 | } else { 32 | vpn_specific.hide(); 33 | if ($(".autovpn").length > 0) { 34 | $(".jsoneditor-wrapper").show(); 35 | $(".autovpn").hide(); 36 | } 37 | } 38 | }; 39 | type_select.on("change", function () { 40 | toggle_vpn_specific(true); 41 | }); 42 | toggle_vpn_specific(); 43 | }); 44 | -------------------------------------------------------------------------------- /openwisp_controller/config/static/import_export/import-openwisp.css: -------------------------------------------------------------------------------- 1 | body { 2 | overflow-y: auto; 3 | } 4 | ins { 5 | padding: 5px; 6 | white-space: nowrap; 7 | } 8 | #main-content .results tbody td, 9 | #main-content .results tbody th, 10 | div#main-content table tbody th, 11 | div#main-content table tbody td { 12 | padding: 0.2rem 10px; 13 | } 14 | 15 | /* Overrides system/browser dark theme, which is not supported in OpenWISP yet */ 16 | @media (prefers-color-scheme: dark) { 17 | table.import-preview tr.skip { 18 | background-color: #d2d2d2; 19 | } 20 | table.import-preview tr.new { 21 | background-color: #bdd8b2; 22 | } 23 | table.import-preview tr.delete { 24 | background-color: #f9bebf; 25 | } 26 | table.import-preview tr.update { 27 | background-color: #fdfdcf; 28 | } 29 | .validation-error-container { 30 | background-color: #ffc1c1; 31 | } 32 | table.import-preview td ins { 33 | background-color: #ededed; 34 | } 35 | table.import-preview td del { 36 | background-color: #ededed; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /openwisp_controller/config/static/sortedm2m/patch_sortedm2m.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | (function ($) { 3 | $(document).ready(function () { 4 | if ($(".sortedm2m-items").length < 2) { 5 | destroyHiddenSortableWidget($); 6 | } 7 | $(document).on("click", "a.inline-deletelink", function () { 8 | destroyHiddenSortableWidget($); 9 | }); 10 | }); 11 | 12 | function destroyHiddenSortableWidget($) { 13 | $(".inline-related.empty-form .sortedm2m-items").sortable("destroy"); 14 | } 15 | })(django.jQuery); 16 | -------------------------------------------------------------------------------- /openwisp_controller/config/static/support.css: -------------------------------------------------------------------------------- 1 | .help.icon { 2 | mask-image: url(http://localhost:8000/media/support.png); 3 | -webkit-mask-image: url(http://localhost:8000/media/support.png); 4 | } 5 | -------------------------------------------------------------------------------- /openwisp_controller/config/templates/admin/config/change_device_group.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/base_site.html" %} 2 | {% load i18n l10n admin_urls static %} 3 | 4 | {% block extrahead %} 5 | {{ block.super }} 6 | {{ media }} 7 | 12 | {% endblock %} 13 | 14 | {% block bodyclass %} 15 | change-device-group admin-actions 16 | {% endblock %} 17 | 18 | {% block breadcrumbs %} 19 | 25 | {% endblock %} 26 | 27 | {% block content %} 28 |
29 |

30 | {% trans 'What group do you want to assign to the selected devices?' %} 31 |

32 |
33 | {% csrf_token %} 34 | {% for obj in queryset %} 35 | 36 | {% endfor %} 37 |

38 | {{ form }} 39 |

40 | 41 |

42 | 43 | {% trans 'Cancel' %} 44 |

45 |
46 |
47 | {% endblock %} 48 | -------------------------------------------------------------------------------- /openwisp_controller/config/templates/admin/config/change_list_device.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/change_list.html" %} 2 | {% load i18n admin_urls %} 3 | 4 | {% block object-tools-items %} 5 | {% if not is_popup and has_add_permission and has_change_permission %} 6 |
  • {% blocktrans with cl.opts.verbose_name_plural|escape as name %}Recover deleted {{name}}{% endblocktrans %}
  • 7 | {% endif %} 8 | {{ block.super }} 9 | {% endblock %} 10 | -------------------------------------------------------------------------------- /openwisp_controller/config/templates/admin/config/clone_template_form.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/base_site.html" %} 2 | {% load i18n l10n admin_urls static %} 3 | 4 | {% block extrahead %} 5 | {{ block.super }} 6 | {{ media }} 7 | 12 | {% endblock %} 13 | 14 | {% block bodyclass %} 15 | organization-selection admin-actions 16 | {% endblock %} 17 | 18 | {% block breadcrumbs %} 19 | 25 | {% endblock %} 26 | 27 | {% block content %} 28 |
    29 |

    30 | {% trans 'What organization do you want clone selected templates to?' %} 31 |

    32 |
    33 | {% csrf_token %} 34 | {% for obj in queryset %} 35 | 36 | {% endfor %} 37 |

    38 | {{ form }} 39 |

    40 | 41 |

    42 | 43 | {% trans 'Cancel' %} 44 |

    45 |
    46 |
    47 | {% endblock %} 48 | -------------------------------------------------------------------------------- /openwisp_controller/config/templates/admin/config/device/change_form.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/config/change_form.html" %} 2 | {% load admin_urls i18n l10n %} 3 | 4 | {% block messages %} 5 | {{ block.super }} 6 | {% if original and original.is_deactivated %} 7 | 10 | {% endif %} 11 | {% endblock messages %} 12 | 13 | {% block content %} 14 | {% comment %} 15 | Due to HTML's limitation in supporting nested forms, we employ a 16 | workaround for activating and deactivating device operations within 17 | the change form. 18 | 19 | We utilize a distinct form element (id="act_deact_device_form") 20 | specifically for these actions. The form attribute of the submit buttons (Acivate/Deactivate) 21 | within the submit-row div references this form. By doing so, we ensure that 22 | these actions can be submitted independently without causing any 23 | disruption to the device form. 24 | 25 | For further information, refer to: https://www.impressivewebs.com/html5-form-attribute/ 26 | {% endcomment %} 27 | {% url opts|admin_urlname:'changelist' as changelist_url %} 28 |
    29 | {% csrf_token %} 30 | 31 | 32 |
    33 | {{ block.super }} 34 | {% endblock content %} 35 | -------------------------------------------------------------------------------- /openwisp_controller/config/templates/admin/config/device_recover_form.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/config/change_form.html" %} 2 | {% load i18n admin_urls %} 3 | 4 | 5 | {% comment %} Following content is take from "reversion/templates/reversion/recover_form.html" {% endcomment %} 6 | {% block breadcrumbs %} 7 | 14 | {% endblock %} 15 | 16 | {% block object-tools %}{% endblock %} 17 | 18 | {% block form_top %} 19 |

    {% blocktrans %}Press the save button below to recover this version of the object.{% endblocktrans %}

    20 | {% endblock %} 21 | 22 | {% block submit_buttons_top %}{% with is_popup=1 %}{{block.super}}{% endwith %}{% endblock %} 23 | {% block submit_buttons_bottom %}{% with is_popup=1 %}{{block.super}}{% endwith %}{% endblock %} 24 | -------------------------------------------------------------------------------- /openwisp_controller/config/templates/admin/config/jsonschema-widget.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {% if advanced_mode %} 3 | 4 | {% endif %} 5 | {% if netjsonconfig_hint %} 6 | 11 | {% endif %} 12 | -------------------------------------------------------------------------------- /openwisp_controller/config/templates/admin/config/preview.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/change_form.html" %} 2 | {% load i18n admin_urls %} 3 | 4 | {% block content %} 5 |
    6 |
    7 | × {% trans 'Close' %} 8 | {% if not error %} 9 |
    {{ output }}
    10 | {% else %} 11 |
    {{ error }}
    12 | {% endif %} 13 |
    14 |
    15 | {% endblock %} 16 | -------------------------------------------------------------------------------- /openwisp_controller/config/templates/admin/config/system_context.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 |
    3 |
    4 | {% if system_context|length > 0%} 5 | {% for key, value in system_context.items %} 6 | {% if new_line in value %} 7 |
    {{ key }}:
    {{ value }}
    8 | {% else %} 9 |
    {{ key }}: {{ value }}
    10 | {% endif %} 11 | {% endfor %} 12 | {% else %} 13 |
    {% trans "There are no system defined variables available right now." %}
    14 | {% endif %} 15 |
    16 | 17 |
    18 | {{system_context|json_script:"system_context"}} 19 | -------------------------------------------------------------------------------- /openwisp_controller/config/templates/admin/device_group/change_form.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/change_form.html" %} 2 | {% load i18n admin_modify %} 3 | 4 | {% block content %} 5 |
    6 | {{ block.super }} 7 | 8 | {% block default_templates_js %} 9 | {% if relevant_template_url %} 10 | 20 | {% endif %} 21 | {% endblock %} 22 |
    23 | {% endblock %} 24 | -------------------------------------------------------------------------------- /openwisp_controller/config/templates/admin/import_export/import.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/import_export/import.html" %} 2 | {% load static %} 3 | 4 | {% block extrastyle %} 5 | {{ block.super }} 6 | 7 | {% endblock %} 8 | -------------------------------------------------------------------------------- /openwisp_controller/config/templates/reversion/config/revision_form.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/config/change_form.html" %} 2 | {% load i18n admin_urls %} 3 | 4 | {% comment %} 5 | The default "reversion/templates/reversion/recover_form.html" template extends 6 | "admin/change_form.html". Since, the "config" app uses a custom "change_form.html", 7 | we need to create a custom recovery form which extends that. 8 | The following content is taken as-is from "reversion/templates/reversion/recover_form.html" 9 | {% endcomment %} 10 | {% block breadcrumbs %} 11 | 19 | {% endblock %} 20 | 21 | 22 | {% block object-tools %}{% endblock %} 23 | 24 | 25 | {% block form_top %} 26 |

    {% blocktrans %}Press the save button below to revert to this version of the object.{% endblocktrans %}

    27 | {% endblock %} 28 | 29 | 30 | {% block submit_buttons_top %}{% with is_popup=1 %}{{block.super}}{% endwith %}{% endblock %} 31 | {% block submit_buttons_bottom %}{% with is_popup=1 %}{{block.super}}{% endwith %}{% endblock %} 32 | -------------------------------------------------------------------------------- /openwisp_controller/config/tests/__init__.py: -------------------------------------------------------------------------------- 1 | # pragma: no cover 2 | # kept for backward compatibility with previous versions 3 | from .utils import ( # noqa 4 | CreateConfigMixin, 5 | CreateConfigTemplateMixin, 6 | CreateDeviceMixin, 7 | CreateTemplateMixin, 8 | CreateVpnMixin, 9 | TestVpnX509Mixin, 10 | ) 11 | -------------------------------------------------------------------------------- /openwisp_controller/config/tests/test_handlers.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | 3 | from django.test import TestCase 4 | 5 | from openwisp_users.tests.utils import TestOrganizationMixin 6 | 7 | from .. import tasks 8 | 9 | 10 | class TestHandlers(TestOrganizationMixin, TestCase): 11 | @patch.object(tasks.invalidate_controller_views_cache, "delay") 12 | def test_organization_disabled_handler(self, mocked_task): 13 | with self.subTest("Test task not executed on creating active orgs"): 14 | org = self._create_org() 15 | mocked_task.assert_not_called() 16 | 17 | with self.subTest("Test task executed on changing active to inactive org"): 18 | org.is_active = False 19 | org.save() 20 | mocked_task.assert_called_once() 21 | 22 | mocked_task.reset_mock() 23 | with self.subTest("Test task not executed on saving inactive org"): 24 | org.name = "Changed named" 25 | org.save() 26 | mocked_task.assert_not_called() 27 | 28 | with self.subTest("Test task not executed on creating inactive org"): 29 | inactive_org = self._create_org( 30 | is_active=False, name="inactive", slug="inactive" 31 | ) 32 | mocked_task.assert_not_called() 33 | 34 | with self.subTest("Test task not executed on changing inactive to active org"): 35 | inactive_org.is_active = True 36 | inactive_org.save() 37 | mocked_task.assert_not_called() 38 | -------------------------------------------------------------------------------- /openwisp_controller/config/tests/test_tag.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from swapper import load_model 3 | 4 | from openwisp_users.tests.utils import TestOrganizationMixin 5 | 6 | from .utils import CreateTemplateMixin 7 | 8 | Template = load_model("config", "Template") 9 | 10 | 11 | class TestTag(TestOrganizationMixin, CreateTemplateMixin, TestCase): 12 | """ 13 | tests for Tag model 14 | """ 15 | 16 | def test_tag(self): 17 | t = self._create_template(organization=self._get_org()) 18 | t.tags.add("mesh") 19 | self.assertEqual(t.tags.filter(name="mesh").count(), 1) 20 | -------------------------------------------------------------------------------- /openwisp_controller/config/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from .views import schema 4 | 5 | app_name = "openwisp_controller" 6 | urlpatterns = [path("config/schema.json", schema, name="schema")] 7 | -------------------------------------------------------------------------------- /openwisp_controller/config/validators.py: -------------------------------------------------------------------------------- 1 | from django.core.validators import RegexValidator, _lazy_re_compile 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | key_validator = RegexValidator( 5 | _lazy_re_compile(r"^[^\s/\.]+$"), 6 | message=_("Key must not contain spaces, dots or slashes."), 7 | code="invalid", 8 | ) 9 | 10 | mac_address_regex = "^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$" 11 | mac_address_validator = RegexValidator( 12 | _lazy_re_compile(mac_address_regex), 13 | message=_("Must be a valid mac address."), 14 | code="invalid", 15 | ) 16 | 17 | # device name must either be a hostname or a valid mac address 18 | hostname_regex = ( 19 | r"^([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]{0,61}" 20 | r"[a-zA-Z0-9])(\.([a-zA-Z0-9]|[a-zA-Z0-9]" 21 | r"[a-zA-Z0-9\-]{0,61}[a-zA-Z0-9]))*$" 22 | ) 23 | device_name_validator = RegexValidator( 24 | _lazy_re_compile("{0}|{1}".format(hostname_regex, mac_address_regex)), 25 | message=_("Must be either a valid hostname or mac address."), 26 | code="invalid", 27 | ) 28 | -------------------------------------------------------------------------------- /openwisp_controller/connection/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openwisp/openwisp-controller/ba5e7a18f331f09f1cfcac0f5743d13dd16e31aa/openwisp_controller/connection/__init__.py -------------------------------------------------------------------------------- /openwisp_controller/connection/api/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from . import views as api_views 4 | 5 | app_name = "openwisp_controller" 6 | 7 | 8 | def get_api_urls(api_views): 9 | """ 10 | returns:: all the API urls of the config app 11 | """ 12 | return [ 13 | path( 14 | "api/v1/controller/device//command/", 15 | api_views.command_list_create_view, 16 | name="device_command_list", 17 | ), 18 | path( 19 | "api/v1/controller/device//command//", 20 | api_views.command_details_view, 21 | name="device_command_details", 22 | ), 23 | path( 24 | "api/v1/controller/credential/", 25 | api_views.credential_list_create_view, 26 | name="credential_list", 27 | ), 28 | path( 29 | "api/v1/controller/credential//", 30 | api_views.credential_detail_view, 31 | name="credential_detail", 32 | ), 33 | path( 34 | "api/v1/controller/device//connection/", 35 | api_views.deviceconnection_list_create_view, 36 | name="deviceconnection_list", 37 | ), 38 | path( 39 | "api/v1/controller/device//connection//", 40 | api_views.deviceconnection_details_view, 41 | name="deviceconnection_detail", 42 | ), 43 | ] 44 | 45 | 46 | urlpatterns = get_api_urls(api_views) 47 | -------------------------------------------------------------------------------- /openwisp_controller/connection/base/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openwisp/openwisp-controller/ba5e7a18f331f09f1cfcac0f5743d13dd16e31aa/openwisp_controller/connection/base/__init__.py -------------------------------------------------------------------------------- /openwisp_controller/connection/channels/consumers.py: -------------------------------------------------------------------------------- 1 | import json 2 | from copy import deepcopy 3 | 4 | from swapper import load_model 5 | 6 | from ...config.base.channels_consumer import BaseDeviceConsumer 7 | 8 | Device = load_model("config", "Device") 9 | 10 | 11 | class CommandConsumer(BaseDeviceConsumer): 12 | def send_update(self, event): 13 | data = deepcopy(event) 14 | data.pop("type") 15 | self.send(json.dumps(data)) 16 | -------------------------------------------------------------------------------- /openwisp_controller/connection/channels/routing.py: -------------------------------------------------------------------------------- 1 | from django.urls import re_path 2 | 3 | from . import consumers as ow_consumer 4 | 5 | 6 | def get_routes(consumer=ow_consumer): 7 | return [ 8 | re_path( 9 | r"^ws/controller/device/(?P[^/]+)/command$", 10 | consumer.CommandConsumer.as_asgi(), 11 | ) 12 | ] 13 | -------------------------------------------------------------------------------- /openwisp_controller/connection/connectors/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openwisp/openwisp-controller/ba5e7a18f331f09f1cfcac0f5743d13dd16e31aa/openwisp_controller/connection/connectors/__init__.py -------------------------------------------------------------------------------- /openwisp_controller/connection/connectors/airos/snmp.py: -------------------------------------------------------------------------------- 1 | from ..snmp import Snmp as BaseSnmp 2 | 3 | 4 | class AirOsSnmp(BaseSnmp): 5 | pass 6 | -------------------------------------------------------------------------------- /openwisp_controller/connection/connectors/exceptions.py: -------------------------------------------------------------------------------- 1 | class CommandFailedException(Exception): 2 | """ 3 | raised when a command returns an unexpected result 4 | """ 5 | 6 | pass 7 | -------------------------------------------------------------------------------- /openwisp_controller/connection/connectors/openwrt/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openwisp/openwisp-controller/ba5e7a18f331f09f1cfcac0f5743d13dd16e31aa/openwisp_controller/connection/connectors/openwrt/__init__.py -------------------------------------------------------------------------------- /openwisp_controller/connection/connectors/openwrt/snmp.py: -------------------------------------------------------------------------------- 1 | from ..snmp import Snmp as BaseSnmp 2 | 3 | 4 | class OpenWRTSnmp(BaseSnmp): 5 | pass 6 | -------------------------------------------------------------------------------- /openwisp_controller/connection/connectors/snmp.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from jsonschema import validate 4 | 5 | logger = logging.getLogger(__name__) 6 | 7 | 8 | class Snmp(object): 9 | schema = { 10 | "$schema": "http://json-schema.org/draft-04/schema#", 11 | "type": "object", 12 | "required": ["community", "agent"], 13 | "additionalProperties": False, 14 | "properties": { 15 | "community": {"type": "string", "default": "public"}, 16 | "agent": {"type": "string"}, 17 | "port": { 18 | "type": "integer", 19 | "default": 161, 20 | "minimum": 1, 21 | "maximum": 65535, 22 | }, 23 | }, 24 | } 25 | 26 | has_update_strategy = False 27 | 28 | def __init__(self, params, addresses): 29 | self.params = params 30 | self.addresses = addresses 31 | 32 | @classmethod 33 | def validate(cls, params): 34 | validate(params, cls.schema) 35 | -------------------------------------------------------------------------------- /openwisp_controller/connection/exceptions.py: -------------------------------------------------------------------------------- 1 | class NoWorkingDeviceConnectionError(Exception): 2 | """ 3 | raised when none of the device's DeviceConnection 4 | are working. 5 | """ 6 | 7 | def __init__(self, connection, *args: object): 8 | self.connection = connection 9 | super().__init__(*args) 10 | -------------------------------------------------------------------------------- /openwisp_controller/connection/migrations/0002_credentials_auto_add.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.3 on 2018-12-05 13:37 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [("connection", "0001_initial")] 8 | 9 | operations = [ 10 | migrations.AddField( 11 | model_name="credentials", 12 | name="auto_add", 13 | field=models.BooleanField( 14 | default=False, 15 | help_text=( 16 | "automatically add these credentials to the " 17 | "devices of this organization; if no organization is " 18 | "specified will be added to all the new devices" 19 | ), 20 | verbose_name="auto add", 21 | ), 22 | ) 23 | ] 24 | -------------------------------------------------------------------------------- /openwisp_controller/connection/migrations/0003_default_group_permissions.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations 2 | 3 | from . import assign_permissions_to_groups 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [("connection", "0002_credentials_auto_add")] 8 | 9 | operations = [ 10 | migrations.RunPython( 11 | assign_permissions_to_groups, reverse_code=migrations.RunPython.noop 12 | ) 13 | ] 14 | -------------------------------------------------------------------------------- /openwisp_controller/connection/migrations/0004_django3_1_upgrade.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1 on 2020-08-18 11:34 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [("connection", "0003_default_group_permissions")] 8 | 9 | operations = [ 10 | migrations.AlterField( 11 | model_name="deviceconnection", 12 | name="is_working", 13 | field=models.BooleanField(blank=True, default=None, null=True), 14 | ) 15 | ] 16 | -------------------------------------------------------------------------------- /openwisp_controller/connection/migrations/0005_device_connection_failure_reason.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations, models 2 | 3 | 4 | def truncate_failure_reason(apps, schema_editor): 5 | DeviceConnection = apps.get_model("connection", "DeviceConnection") 6 | 7 | for device_connection in DeviceConnection.objects.iterator(): 8 | device_connection.failure_reason = device_connection.failure_reason[:128] 9 | device_connection.save() 10 | 11 | 12 | class Migration(migrations.Migration): 13 | dependencies = [("connection", "0004_django3_1_upgrade")] 14 | 15 | operations = [ 16 | migrations.AlterField( 17 | model_name="deviceconnection", 18 | name="failure_reason", 19 | field=models.TextField(blank=True, verbose_name="reason of failure"), 20 | ), 21 | migrations.RunPython(migrations.RunPython.noop, truncate_failure_reason), 22 | ] 23 | -------------------------------------------------------------------------------- /openwisp_controller/connection/migrations/0006_name_unique_per_organization.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.6 on 2021-02-11 22:54 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("connection", "0005_device_connection_failure_reason"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="credentials", 14 | name="name", 15 | field=models.CharField(db_index=True, max_length=64), 16 | ), 17 | migrations.AlterUniqueTogether( 18 | name="credentials", unique_together={("name", "organization")} 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /openwisp_controller/connection/migrations/0008_remove_conflicting_deviceconnections.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations, models 2 | 3 | 4 | def remove_conflicting_deviceconnections(apps, schema_editor): 5 | DeviceConnection = apps.get_model("connection", "DeviceConnection") 6 | duplicates = ( 7 | DeviceConnection.objects.values("device_id", "credentials_id") 8 | .annotate(count=models.Count("id")) 9 | .filter(count__gt=1) 10 | ) 11 | for duplicate in duplicates: 12 | # Get all instances of this duplicate and order them by oldest to newest 13 | instances = DeviceConnection.objects.filter( 14 | device_id=duplicate["device_id"], credentials_id=duplicate["credentials_id"] 15 | ).order_by("created") 16 | # Keep the old instance and delete the rest 17 | for instance in instances[1:]: 18 | instance.delete() 19 | 20 | 21 | class Migration(migrations.Migration): 22 | dependencies = [ 23 | ("connection", "0007_command"), 24 | ] 25 | 26 | operations = [ 27 | migrations.RunPython( 28 | remove_conflicting_deviceconnections, reverse_code=migrations.RunPython.noop 29 | ), 30 | ] 31 | -------------------------------------------------------------------------------- /openwisp_controller/connection/migrations/0009_alter_deviceconnection_unique_together.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.20 on 2023-08-24 12:35 2 | 3 | from django.conf import settings 4 | from django.db import migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | migrations.swappable_dependency(settings.CONFIG_DEVICE_MODEL), 10 | ("connection", "0008_remove_conflicting_deviceconnections"), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterUniqueTogether( 15 | name="deviceconnection", 16 | unique_together={("device", "credentials")}, 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /openwisp_controller/connection/models.py: -------------------------------------------------------------------------------- 1 | import swapper 2 | 3 | from .base.models import AbstractCommand, AbstractCredentials, AbstractDeviceConnection 4 | 5 | 6 | class Credentials(AbstractCredentials): 7 | class Meta(AbstractCredentials.Meta): 8 | abstract = False 9 | swappable = swapper.swappable_setting("connection", "Credentials") 10 | 11 | 12 | class DeviceConnection(AbstractDeviceConnection): 13 | class Meta(AbstractDeviceConnection.Meta): 14 | abstract = False 15 | swappable = swapper.swappable_setting("connection", "DeviceConnection") 16 | 17 | 18 | class Command(AbstractCommand): 19 | class Meta(AbstractCommand.Meta): 20 | abstract = False 21 | swappable = swapper.swappable_setting("connection", "Command") 22 | -------------------------------------------------------------------------------- /openwisp_controller/connection/schema.py: -------------------------------------------------------------------------------- 1 | from django.utils.module_loading import import_string 2 | 3 | from .settings import CONNECTORS 4 | 5 | schema = {} 6 | 7 | for connector in CONNECTORS: 8 | class_path = connector[0] 9 | class_ = import_string(class_path) 10 | schema[class_path] = class_.schema 11 | -------------------------------------------------------------------------------- /openwisp_controller/connection/signals.py: -------------------------------------------------------------------------------- 1 | from django.dispatch import Signal 2 | 3 | is_working_changed = Signal() 4 | is_working_changed.__doc__ = """ 5 | Providing araguments: [ 6 | 'is_working', 7 | 'old_is_working', 8 | 'instance', 9 | 'failure_reason', 10 | 'old_failure_reason' 11 | ] 12 | """ 13 | -------------------------------------------------------------------------------- /openwisp_controller/connection/static/connection/css/credentials.css: -------------------------------------------------------------------------------- 1 | #id_params_jsoneditor > div[data-schemaid="root"] { 2 | margin-top: -32px; 3 | } 4 | .jsoneditor-wrapper > fieldset:first-of-type { 5 | margin-bottom: 0; 6 | } 7 | #credentials_form fieldset > .form-row:not(.field-connector), 8 | .jsoneditor-wrapper { 9 | display: none; 10 | } 11 | #credentials_form fieldset > .form-row.errors { 12 | display: block !important; 13 | } 14 | .jsoneditor-wrapper .form-row textarea { 15 | font-family: monospace; 16 | } 17 | #credentials_form .jsoneditor-wrapper div.jsoneditor .inline-group { 18 | border: 0 none; 19 | } 20 | #credentials_form fieldset > .form-row.errors.field-params > div { 21 | display: none; 22 | } 23 | .jsoneditor-wrapper .form-row { 24 | display: block; 25 | } 26 | #advanced_editor, 27 | .jsoneditor-raw { 28 | display: none; 29 | } 30 | 31 | @media screen and (max-width: 767px) { 32 | #id_params_jsoneditor > div[data-schemaid="root"] label { 33 | display: none; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /openwisp_controller/connection/static/connection/js/credentials.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | django.jQuery(function ($) { 3 | var selector = $("#id_connector"), 4 | showFields = function () { 5 | var fields = $( 6 | "#credentials_form fieldset > .form-row:not(.field-connector):not(.field-params), .jsoneditor-wrapper", 7 | ), 8 | value = selector.val(); 9 | if (!value) { 10 | fields.hide(); 11 | } else { 12 | fields.show(); 13 | } 14 | }; 15 | selector.change(function () { 16 | showFields(); 17 | }); 18 | 19 | $("#id_params").on("jsonschema-schemaloaded", function () { 20 | showFields(); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /openwisp_controller/connection/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openwisp/openwisp-controller/ba5e7a18f331f09f1cfcac0f5743d13dd16e31aa/openwisp_controller/connection/tests/__init__.py -------------------------------------------------------------------------------- /openwisp_controller/connection/tests/base.py: -------------------------------------------------------------------------------- 1 | # pragma: no cover 2 | # kept for backward compatibility with previous versions 3 | from .utils import CreateConnectionsMixin, SshServer # noqa 4 | -------------------------------------------------------------------------------- /openwisp_controller/connection/tests/test-key.ed25519: -------------------------------------------------------------------------------- 1 | -----BEGIN OPENSSH PRIVATE KEY----- 2 | b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW 3 | QyNTUxOQAAACBSjdrpn90sb9ydtqddFY4PZDBpe5YFxGYhqx4OM+dBTgAAAJBLNjRpSzY0 4 | aQAAAAtzc2gtZWQyNTUxOQAAACBSjdrpn90sb9ydtqddFY4PZDBpe5YFxGYhqx4OM+dBTg 5 | AAAEAgZ6vvYLFXzDzPCCZ4+dsc6PG9IJg/3W5oeoXy51HVuFKN2umf3Sxv3J22p10Vjg9k 6 | MGl7lgXEZiGrHg4z50FOAAAADG5lbWVzaXNAZW52eQE= 7 | -----END OPENSSH PRIVATE KEY----- 8 | -------------------------------------------------------------------------------- /openwisp_controller/connection/tests/test-key.rsa: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIICXgIBAAKBgQDQwuFrDNYUCi5Doy2xrc71P06vEuNG5i2rNgIHm95IuP8WrFwZ 3 | W/VfNviGyhA8JwmWwHco9uzgKthaMKrGKB5Oeu/Z2F6SZPdCAdamCdbCcihXZ4g1 4 | RGbX5wECH7UjTx0th4GV6jwRAvJM/MpVJcCkTIzBHVHOC5jYotDuTnjJdwIDAQAB 5 | AoGAHvfp7LF4yHxCJLJ+Qs9f1i3QBFSu9oOK3s0iO/K5ZNxcqwZimzhzC+7hq00q 6 | X2IDICPpCWCn/xEcCzURAFhPNlx0RYZUzXOiW1JL7MzLYny87UAuW+TDaS4eEV9r 7 | YX8acLWfg+aEw/pF0FRb2AuoRClztAyNF6GJtR/ky4z7vnECQQD3NEcEL1s913HW 8 | 1yV4RHBZO8n8oH2WidXtFDstmdmAvDQv7KC8c6rPJ6VVH5IlY+WyDIzI6X1IJFew 9 | DXhO3A8zAkEA2DBvhy5TbAOPX7wQN53SA9+z4sdhOlYwcDpq2YuYvKH3ZFIWQEAX 10 | cTQSjvaI35jWyKNYL+8T+Pqsngd3AUNsrQJBAI1yCSx42FFDRCz0v8jYCBzW3BVD 11 | 03hed9yGlfHatRw3E/lUAQizekm72pshTGM+jMBa8/dFulycBtyCaJNe0QcCQQCQ 12 | uoxPcWIDs7ZuHta0hQEt+rrQnS2oAj9XQqR5kwzja4LVNGcVCFMpQ/UQpFcpaYaQ 13 | t1m4bVNvoVGiUdkHjX3ZAkEAmHvrBB2TvcPZkhuUGviIlXbIeHWZMRF7wh0wZ7SH 14 | SZWnv9EqwFcOGqqoLhQDznTI9TmWdpkxPxLzVwnjWLT4qw== 15 | -----END RSA PRIVATE KEY----- 16 | -------------------------------------------------------------------------------- /openwisp_controller/connection/utils.py: -------------------------------------------------------------------------------- 1 | from openwisp_notifications.utils import _get_object_link 2 | 3 | 4 | def get_connection_working_notification_target_url(obj, field, absolute_url=True): 5 | url = _get_object_link(obj._related_object(field), absolute_url) 6 | return f"{url}#deviceconnection_set-group" 7 | -------------------------------------------------------------------------------- /openwisp_controller/connection/widgets.py: -------------------------------------------------------------------------------- 1 | import swapper 2 | from django import forms 3 | 4 | from ..config.widgets import JsonSchemaWidget as BaseJsonSchemaWidget 5 | 6 | Credentials = swapper.load_model("connection", "Credentials") 7 | Command = swapper.load_model("connection", "Command") 8 | app_label = Credentials._meta.app_label 9 | model_name = Credentials._meta.model_name 10 | 11 | 12 | class CredentialsSchemaWidget(BaseJsonSchemaWidget): 13 | schema_view_name = f"admin:{app_label}_{model_name}_schema" 14 | netjsonconfig_hint = False 15 | advanced_mode = False 16 | extra_attrs = {"data-schema-selector": "#id_connector"} 17 | 18 | @property 19 | def media(self): 20 | js = ["admin/js/jquery.init.js", "connection/js/credentials.js"] 21 | css = {"all": ["connection/css/credentials.css"]} 22 | return super().media + forms.Media(js=js, css=css) 23 | 24 | 25 | class CommandSchemaWidget(BaseJsonSchemaWidget): 26 | schema_view_name = ( 27 | f"admin:{Command._meta.app_label}_{Command._meta.model_name}_schema" 28 | ) 29 | 30 | app_label_model = f"{Command._meta.app_label}_{Command._meta.model_name}" 31 | netjsonconfig_hint = False 32 | advanced_mode = False 33 | extra_attrs = { 34 | "data-schema-selector": "#id_command_set-0-type", 35 | "data-show-errors": "never", 36 | "data-query-params": '{"organization_id": "id_organization"}', 37 | } 38 | 39 | @property 40 | def media(self): 41 | js = [ 42 | "admin/js/jquery.init.js", 43 | "connection/js/lib/reconnecting-websocket.min.js", 44 | "connection/js/commands.js", 45 | ] 46 | css = {"all": ["connection/css/command-inline.css"]} 47 | media = forms.Media(js=js, css=css) 48 | return super().media + media 49 | -------------------------------------------------------------------------------- /openwisp_controller/context_processors.py: -------------------------------------------------------------------------------- 1 | from . import settings as app_settings 2 | 3 | 4 | def controller_api_settings(request): 5 | return { 6 | "OPENWISP_CONTROLLER_API_HOST": app_settings.OPENWISP_CONTROLLER_API_HOST, 7 | } 8 | -------------------------------------------------------------------------------- /openwisp_controller/geo/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openwisp/openwisp-controller/ba5e7a18f331f09f1cfcac0f5743d13dd16e31aa/openwisp_controller/geo/__init__.py -------------------------------------------------------------------------------- /openwisp_controller/geo/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openwisp/openwisp-controller/ba5e7a18f331f09f1cfcac0f5743d13dd16e31aa/openwisp_controller/geo/api/__init__.py -------------------------------------------------------------------------------- /openwisp_controller/geo/api/filters.py: -------------------------------------------------------------------------------- 1 | from django.utils.translation import gettext_lazy as _ 2 | from django_filters import rest_framework as filters 3 | 4 | from openwisp_controller.config.api.filters import ( 5 | DeviceListFilter as BaseDeviceListFilter, 6 | ) 7 | 8 | 9 | class DeviceListFilter(BaseDeviceListFilter): 10 | # Using filter query param name `with_geo` 11 | # which is similar to admin filter 12 | with_geo = filters.BooleanFilter( 13 | field_name="devicelocation", method="filter_devicelocation" 14 | ) 15 | 16 | def _set_valid_filterform_lables(self): 17 | super()._set_valid_filterform_lables() 18 | self.filters["with_geo"].label = _("Has geographic location set?") 19 | 20 | def filter_devicelocation(self, queryset, name, value): 21 | # Returns list of device that have devicelocation objects 22 | return queryset.exclude(devicelocation__isnull=value) 23 | 24 | class Meta: 25 | model = BaseDeviceListFilter.Meta.model 26 | fields = BaseDeviceListFilter.Meta.fields[:] 27 | fields.insert(fields.index("created"), "with_geo") 28 | -------------------------------------------------------------------------------- /openwisp_controller/geo/api/urls.py: -------------------------------------------------------------------------------- 1 | from ..utils import get_geo_urls 2 | from . import views as geo_views 3 | 4 | app_name = "openwisp_controller" 5 | 6 | urlpatterns = get_geo_urls(geo_views) 7 | -------------------------------------------------------------------------------- /openwisp_controller/geo/base/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openwisp/openwisp-controller/ba5e7a18f331f09f1cfcac0f5743d13dd16e31aa/openwisp_controller/geo/base/__init__.py -------------------------------------------------------------------------------- /openwisp_controller/geo/channels/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openwisp/openwisp-controller/ba5e7a18f331f09f1cfcac0f5743d13dd16e31aa/openwisp_controller/geo/channels/__init__.py -------------------------------------------------------------------------------- /openwisp_controller/geo/channels/consumers.py: -------------------------------------------------------------------------------- 1 | import swapper 2 | from django_loci.channels.base import BaseLocationBroadcast 3 | 4 | Location = swapper.load_model("geo", "Location") 5 | 6 | 7 | class LocationBroadcast(BaseLocationBroadcast): 8 | model = Location 9 | 10 | def is_authorized(self, user, location): 11 | result = super().is_authorized(user, location) 12 | # non superusers must also be members of the org 13 | if ( 14 | result 15 | and not user.is_superuser 16 | and not user.is_manager(location.organization) 17 | ): 18 | return False 19 | return result 20 | -------------------------------------------------------------------------------- /openwisp_controller/geo/channels/routing.py: -------------------------------------------------------------------------------- 1 | from channels.auth import AuthMiddlewareStack 2 | from channels.routing import ProtocolTypeRouter, URLRouter 3 | from channels.security.websocket import AllowedHostsOriginValidator 4 | from django.urls import path 5 | from django_loci.channels.base import location_broadcast_path 6 | from openwisp_notifications.websockets.routing import ( 7 | get_routes as get_notification_routes, 8 | ) 9 | 10 | from .consumers import LocationBroadcast 11 | 12 | 13 | def get_routes(): 14 | return [ 15 | path( 16 | location_broadcast_path, LocationBroadcast.as_asgi(), name="LocationChannel" 17 | ) 18 | ] 19 | 20 | 21 | # Kept for backward compatibility 22 | geo_routes = get_routes() 23 | 24 | channel_routing = ProtocolTypeRouter( 25 | { 26 | "websocket": AllowedHostsOriginValidator( 27 | AuthMiddlewareStack(URLRouter(get_notification_routes() + geo_routes)) 28 | ) 29 | } 30 | ) 31 | -------------------------------------------------------------------------------- /openwisp_controller/geo/migrations/0002_default_groups_permissions.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations 2 | 3 | from . import assign_permissions_to_groups 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("geo", "0001_initial"), 9 | ] 10 | operations = [ 11 | migrations.RunPython( 12 | assign_permissions_to_groups, reverse_code=migrations.RunPython.noop 13 | ) 14 | ] 15 | -------------------------------------------------------------------------------- /openwisp_controller/geo/migrations/0003_alter_devicelocation_floorplan_location.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.16 on 2023-01-05 09:50 2 | 3 | import django.db.models.deletion 4 | from django.conf import settings 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | ("geo", "0002_default_groups_permissions"), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name="devicelocation", 16 | name="floorplan", 17 | field=models.ForeignKey( 18 | blank=True, 19 | null=True, 20 | on_delete=django.db.models.deletion.CASCADE, 21 | to=settings.GEO_FLOORPLAN_MODEL, 22 | ), 23 | ), 24 | migrations.AlterField( 25 | model_name="devicelocation", 26 | name="location", 27 | field=models.ForeignKey( 28 | blank=True, 29 | null=True, 30 | on_delete=django.db.models.deletion.CASCADE, 31 | to=settings.GEO_LOCATION_MODEL, 32 | ), 33 | ), 34 | ] 35 | -------------------------------------------------------------------------------- /openwisp_controller/geo/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import Permission 2 | 3 | from ...migrations import create_default_permissions, get_swapped_model 4 | 5 | 6 | def assign_permissions_to_groups(apps, schema_editor): 7 | create_default_permissions(apps, schema_editor) 8 | operators_and_admins_can_change = ["location", "floorplan", "devicelocation"] 9 | manage_operations = ["add", "change", "delete"] 10 | Group = get_swapped_model(apps, "openwisp_users", "Group") 11 | 12 | try: 13 | admin = Group.objects.get(name="Administrator") 14 | operator = Group.objects.get(name="Operator") 15 | # consider failures custom cases 16 | # that do not have to be dealt with 17 | except Group.DoesNotExist: 18 | return 19 | 20 | for model_name in operators_and_admins_can_change: 21 | for operation in manage_operations: 22 | permission = Permission.objects.get( 23 | codename="{}_{}".format(operation, model_name) 24 | ) 25 | admin.permissions.add(permission.pk) 26 | operator.permissions.add(permission.pk) 27 | -------------------------------------------------------------------------------- /openwisp_controller/geo/models.py: -------------------------------------------------------------------------------- 1 | import swapper 2 | 3 | from .base.models import BaseDeviceLocation, BaseFloorPlan, BaseLocation 4 | 5 | 6 | class Location(BaseLocation): 7 | class Meta(BaseLocation.Meta): 8 | abstract = False 9 | swappable = swapper.swappable_setting("geo", "Location") 10 | 11 | 12 | class FloorPlan(BaseFloorPlan): 13 | class Meta(BaseFloorPlan.Meta): 14 | abstract = False 15 | swappable = swapper.swappable_setting("geo", "FloorPlan") 16 | 17 | 18 | class DeviceLocation(BaseDeviceLocation): 19 | class Meta(BaseDeviceLocation.Meta): 20 | abstract = False 21 | swappable = swapper.swappable_setting("geo", "DeviceLocation") 22 | 23 | 24 | # maintain compatibility with django_loci 25 | Location.objectlocation_set = Location.devicelocation_set 26 | FloorPlan.objectlocation_set = FloorPlan.devicelocation_set 27 | -------------------------------------------------------------------------------- /openwisp_controller/geo/templates/admin/widgets/foreign_key_raw_id.html: -------------------------------------------------------------------------------- 1 | {% include 'admin/django_loci/foreign_key_raw_id.html' %} 2 | -------------------------------------------------------------------------------- /openwisp_controller/geo/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openwisp/openwisp-controller/ba5e7a18f331f09f1cfcac0f5743d13dd16e31aa/openwisp_controller/geo/tests/__init__.py -------------------------------------------------------------------------------- /openwisp_controller/geo/tests/test_apps.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | from openwisp_utils.admin_theme.dashboard import DASHBOARD_CHARTS 4 | 5 | 6 | class TestApps(TestCase): 7 | def test_geo_chart_registered(self): 8 | chart_config = DASHBOARD_CHARTS.get(2, None) 9 | self.assertIsNotNone(chart_config) 10 | self.assertEqual(chart_config["name"], "Geographic positioning") 11 | self.assertIn("labels", chart_config) 12 | query_params = chart_config["query_params"] 13 | self.assertIn("annotate", query_params) 14 | self.assertIn("aggregate", query_params) 15 | self.assertIn("filters", chart_config) 16 | filters = chart_config["filters"] 17 | self.assertIn("key", filters) 18 | self.assertIn("with_geo__sum", chart_config["filters"]) 19 | self.assertIn("without_geo__sum", chart_config["filters"]) 20 | -------------------------------------------------------------------------------- /openwisp_controller/geo/tests/test_models.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import ValidationError 2 | from django.test import TestCase 3 | from django_loci.tests.base.test_models import BaseTestModels 4 | from swapper import load_model 5 | 6 | from .utils import TestGeoMixin 7 | 8 | Device = load_model("config", "Device") 9 | Location = load_model("geo", "Location") 10 | FloorPlan = load_model("geo", "FloorPlan") 11 | DeviceLocation = load_model("geo", "DeviceLocation") 12 | 13 | 14 | class TestModels(TestGeoMixin, BaseTestModels, TestCase): 15 | object_model = Device 16 | location_model = Location 17 | floorplan_model = FloorPlan 18 | object_location_model = DeviceLocation 19 | 20 | def test_floorplan_location_validation(self): 21 | fl = self._create_floorplan() 22 | fl.location = None 23 | self.assertFalse(hasattr(fl, "location")) 24 | try: 25 | fl.full_clean() 26 | except ValidationError as e: 27 | self.assertIn("location", e.message_dict) 28 | else: 29 | self.fail("ValidationError not raised") 30 | -------------------------------------------------------------------------------- /openwisp_controller/geo/utils.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | 4 | def get_geo_urls(geo_views): 5 | return [ 6 | path( 7 | "api/v1/controller/device//coordinates/", 8 | geo_views.device_coordinates, 9 | name="device_coordinates", 10 | ), 11 | path( 12 | "api/v1/controller/device//location/", 13 | geo_views.device_location, 14 | name="device_location", 15 | ), 16 | path( 17 | "api/v1/controller/location/geojson/", 18 | geo_views.geojson, 19 | name="location_geojson", 20 | ), 21 | path( 22 | "api/v1/controller/location//device/", 23 | geo_views.location_device_list, 24 | name="location_device_list", 25 | ), 26 | path( 27 | "api/v1/controller/floorplan/", 28 | geo_views.list_floorplan, 29 | name="list_floorplan", 30 | ), 31 | path( 32 | "api/v1/controller/floorplan//", 33 | geo_views.detail_floorplan, 34 | name="detail_floorplan", 35 | ), 36 | path( 37 | "api/v1/controller/location/", geo_views.list_location, name="list_location" 38 | ), 39 | path( 40 | "api/v1/controller/location//", 41 | geo_views.detail_location, 42 | name="detail_location", 43 | ), 44 | ] 45 | -------------------------------------------------------------------------------- /openwisp_controller/migrations.py: -------------------------------------------------------------------------------- 1 | # Used in migrations of apps 2 | import swapper 3 | from django.contrib.auth.management import create_permissions 4 | 5 | 6 | def create_default_permissions(apps, schema_editor): 7 | for app_config in apps.get_app_configs(): 8 | app_config.models_module = True 9 | create_permissions(app_config, apps=apps, verbosity=0) 10 | app_config.models_module = None 11 | 12 | 13 | def get_swapped_model(apps, app_name, model_name): 14 | model_path = swapper.get_model_name(app_name, model_name) 15 | app, model = swapper.split(model_path) 16 | return apps.get_model(app, model) 17 | -------------------------------------------------------------------------------- /openwisp_controller/mixins.py: -------------------------------------------------------------------------------- 1 | from openwisp_users.api.mixins import FilterByOrganizationManaged, FilterByParentManaged 2 | from openwisp_users.api.mixins import ProtectedAPIMixin as BaseProtectedAPIMixin 3 | from openwisp_users.api.permissions import DjangoModelPermissions, IsOrganizationManager 4 | 5 | 6 | class RelatedDeviceModelPermission(DjangoModelPermissions): 7 | _device_field = "device" 8 | 9 | def _has_permissions(self, request, view, perm, obj=None): 10 | if request.method in self.READ_ONLY_METHOD: 11 | return perm 12 | if obj: 13 | device = getattr(obj, self._device_field) 14 | else: 15 | device = view.get_parent_queryset().first() 16 | return perm and device and not device.is_deactivated() 17 | 18 | def has_permission(self, request, view): 19 | perm = super().has_permission(request, view) 20 | return self._has_permissions(request, view, perm) 21 | 22 | def has_object_permission(self, request, view, obj): 23 | perm = super().has_object_permission(request, view, obj) 24 | return self._has_permissions(request, view, perm, obj) 25 | 26 | 27 | class RelatedDeviceProtectedAPIMixin(FilterByParentManaged, BaseProtectedAPIMixin): 28 | permission_classes = [ 29 | IsOrganizationManager, 30 | RelatedDeviceModelPermission, 31 | ] 32 | 33 | 34 | class ProtectedAPIMixin(BaseProtectedAPIMixin, FilterByOrganizationManaged): 35 | pass 36 | -------------------------------------------------------------------------------- /openwisp_controller/pki/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openwisp/openwisp-controller/ba5e7a18f331f09f1cfcac0f5743d13dd16e31aa/openwisp_controller/pki/__init__.py -------------------------------------------------------------------------------- /openwisp_controller/pki/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django_x509.base.admin import AbstractCaAdmin, AbstractCertAdmin 3 | from reversion.admin import VersionAdmin 4 | from swapper import load_model 5 | 6 | from openwisp_users.multitenancy import MultitenantOrgFilter 7 | 8 | from ..admin import MultitenantAdminMixin 9 | 10 | Ca = load_model("django_x509", "Ca") 11 | Cert = load_model("django_x509", "Cert") 12 | 13 | 14 | @admin.register(Ca) 15 | class CaAdmin(MultitenantAdminMixin, AbstractCaAdmin, VersionAdmin): 16 | history_latest_first = True 17 | 18 | 19 | CaAdmin.fields.insert(2, "organization") 20 | CaAdmin.list_filter.insert(0, MultitenantOrgFilter) 21 | CaAdmin.list_display.insert(1, "organization") 22 | CaAdmin.Media.js += ("admin/pki/js/show-org-field.js",) 23 | 24 | 25 | @admin.register(Cert) 26 | class CertAdmin(MultitenantAdminMixin, AbstractCertAdmin, VersionAdmin): 27 | multitenant_shared_relations = ("ca",) 28 | history_latest_first = True 29 | 30 | 31 | CertAdmin.fields.insert(2, "organization") 32 | CertAdmin.list_filter.insert(0, MultitenantOrgFilter) 33 | CertAdmin.list_filter.remove("ca") 34 | CertAdmin.list_display.insert(1, "organization") 35 | CertAdmin.Media.js += ("admin/pki/js/show-org-field.js",) 36 | -------------------------------------------------------------------------------- /openwisp_controller/pki/api/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.urls import path 3 | 4 | from . import views as api_views 5 | 6 | app_name = "openwisp_controller" 7 | 8 | 9 | def get_pki_api_urls(api_views): 10 | """ 11 | returns:: all the API urls of the PKI app 12 | """ 13 | if getattr(settings, "OPENWISP_CONTROLLER_PKI_API", True): 14 | return [ 15 | path("controller/ca/", api_views.ca_list, name="ca_list"), 16 | path("controller/ca//", api_views.ca_detail, name="ca_detail"), 17 | path("controller/ca//renew/", api_views.ca_renew, name="ca_renew"), 18 | path( 19 | "controller/ca//crl", 20 | api_views.crl_download, 21 | name="crl_download", 22 | ), 23 | path("controller/cert/", api_views.cert_list, name="cert_list"), 24 | path( 25 | "controller/cert//", api_views.cert_detail, name="cert_detail" 26 | ), 27 | path( 28 | "controller/cert//revoke/", 29 | api_views.cert_revoke, 30 | name="cert_revoke", 31 | ), 32 | path( 33 | "controller/cert//renew/", 34 | api_views.cert_renew, 35 | name="cert_renew", 36 | ), 37 | ] 38 | else: 39 | return [] 40 | 41 | 42 | urlpatterns = get_pki_api_urls(api_views) 43 | -------------------------------------------------------------------------------- /openwisp_controller/pki/apps.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.utils.translation import gettext_lazy as _ 3 | from django_x509.apps import DjangoX509Config 4 | from swapper import get_model_name 5 | 6 | from openwisp_utils.admin_theme.menu import register_menu_group 7 | 8 | if not hasattr(settings, "DJANGO_X509_CA_MODEL"): 9 | setattr(settings, "DJANGO_X509_CA_MODEL", "pki.Ca") 10 | if not hasattr(settings, "DJANGO_X509_CERT_MODEL"): 11 | setattr(settings, "DJANGO_X509_CERT_MODEL", "pki.Cert") 12 | 13 | 14 | class PkiConfig(DjangoX509Config): 15 | name = "openwisp_controller.pki" 16 | verbose_name = _("Public Key Infrastructure") 17 | 18 | def ready(self): 19 | super().ready() 20 | self.register_menu_groups() 21 | 22 | def register_menu_groups(self): 23 | register_menu_group( 24 | position=60, 25 | config={ 26 | "label": "Cas & Certificates", 27 | "items": { 28 | 1: { 29 | "label": "Certification Authorities", 30 | "model": get_model_name("django_x509", "Ca"), 31 | "name": "changelist", 32 | "icon": "ow-ca", 33 | }, 34 | 2: { 35 | "label": "Certificates", 36 | "model": get_model_name("django_x509", "Cert"), 37 | "name": "changelist", 38 | "icon": "ow-certificate", 39 | }, 40 | }, 41 | "icon": "ow-cer-group", 42 | }, 43 | ) 44 | 45 | 46 | del DjangoX509Config 47 | -------------------------------------------------------------------------------- /openwisp_controller/pki/base/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openwisp/openwisp-controller/ba5e7a18f331f09f1cfcac0f5743d13dd16e31aa/openwisp_controller/pki/base/__init__.py -------------------------------------------------------------------------------- /openwisp_controller/pki/base/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.utils.translation import gettext_lazy as _ 3 | from django_x509.base.models import AbstractCa as BaseCa 4 | from django_x509.base.models import AbstractCert as BaseCert 5 | from swapper import get_model_name 6 | 7 | from openwisp_users.mixins import ShareableOrgMixin 8 | 9 | from ..utils import UnqiueCommonNameMixin 10 | 11 | 12 | class AbstractCa(ShareableOrgMixin, UnqiueCommonNameMixin, BaseCa): 13 | class Meta(BaseCa.Meta): 14 | abstract = True 15 | constraints = [ 16 | models.UniqueConstraint( 17 | fields=["common_name", "organization"], 18 | name="%(app_label)s_%(class)s_comman_name_and_organization_is_unique", 19 | ), 20 | ] 21 | 22 | 23 | class AbstractCert(ShareableOrgMixin, UnqiueCommonNameMixin, BaseCert): 24 | ca = models.ForeignKey( 25 | get_model_name("django_x509", "Ca"), 26 | verbose_name=_("CA"), 27 | on_delete=models.CASCADE, 28 | ) 29 | 30 | class Meta(BaseCert.Meta): 31 | abstract = True 32 | constraints = [ 33 | models.UniqueConstraint( 34 | fields=["common_name", "organization"], 35 | name="%(app_label)s_%(class)s_comman_name_and_organization_is_unique", 36 | ), 37 | ] 38 | 39 | def clean(self): 40 | self._validate_org_relation("ca") 41 | -------------------------------------------------------------------------------- /openwisp_controller/pki/migrations/0002_add_organization_name.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.5 on 2017-11-03 15:47 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [("pki", "0001_initial")] 8 | 9 | operations = [ 10 | migrations.AddField( 11 | model_name="ca", 12 | name="organization_name", 13 | field=models.CharField( 14 | blank=True, max_length=64, verbose_name="organization" 15 | ), 16 | ), 17 | migrations.AddField( 18 | model_name="cert", 19 | name="organization_name", 20 | field=models.CharField( 21 | blank=True, max_length=64, verbose_name="organization" 22 | ), 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /openwisp_controller/pki/migrations/0003_fill_organization_name.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from django.db import migrations 3 | 4 | from ..models import Ca, Cert 5 | 6 | 7 | def forward(apps, schema_editor): 8 | """ 9 | Fills the organization_name field of the following models: 10 | * ``openwisp_controller.pki.Ca`` 11 | * ``openwisp_controller.pki.Cert`` 12 | """ 13 | if not schema_editor.connection.alias == "default": 14 | return 15 | ca_model = apps.get_model("pki", "Ca") 16 | cert_model = apps.get_model("pki", "Cert") 17 | 18 | for model, real_model in [(ca_model, Ca), (cert_model, Cert)]: 19 | for obj in model.objects.all(): 20 | model_instance = real_model.objects.get(pk=obj.pk) 21 | obj.organization_name = ( 22 | model_instance.x509.get_subject().organizationName or "" 23 | ) 24 | obj.save() 25 | 26 | 27 | class Migration(migrations.Migration): 28 | dependencies = [("pki", "0002_add_organization_name")] 29 | 30 | operations = [migrations.RunPython(forward, reverse_code=migrations.RunPython.noop)] 31 | -------------------------------------------------------------------------------- /openwisp_controller/pki/migrations/0004_auto_20180106_1814.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.7 on 2018-01-06 17:14 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [("pki", "0003_fill_organization_name")] 8 | 9 | operations = [ 10 | migrations.AlterField( 11 | model_name="ca", 12 | name="serial_number", 13 | field=models.CharField( 14 | blank=True, 15 | help_text="leave blank to determine automatically", 16 | max_length=39, 17 | null=True, 18 | verbose_name="serial number", 19 | ), 20 | ), 21 | migrations.AlterField( 22 | model_name="cert", 23 | name="serial_number", 24 | field=models.CharField( 25 | blank=True, 26 | help_text="leave blank to determine automatically", 27 | max_length=39, 28 | null=True, 29 | verbose_name="serial number", 30 | ), 31 | ), 32 | ] 33 | -------------------------------------------------------------------------------- /openwisp_controller/pki/migrations/0005_organizational_unit_name.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.9 on 2018-02-19 11:53 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [("pki", "0004_auto_20180106_1814")] 8 | 9 | operations = [ 10 | migrations.AddField( 11 | model_name="ca", 12 | name="organizational_unit_name", 13 | field=models.CharField( 14 | blank=True, max_length=64, verbose_name="organizational unit name" 15 | ), 16 | ), 17 | migrations.AddField( 18 | model_name="cert", 19 | name="organizational_unit_name", 20 | field=models.CharField( 21 | blank=True, max_length=64, verbose_name="organizational unit name" 22 | ), 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /openwisp_controller/pki/migrations/0006_add_x509_passphrase_field.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0.8 on 2018-09-18 12:41 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [("pki", "0005_organizational_unit_name")] 8 | 9 | operations = [ 10 | migrations.AddField( 11 | model_name="ca", 12 | name="passphrase", 13 | field=models.CharField( 14 | blank=True, 15 | help_text="Passphrase for the private key, if present", 16 | max_length=64, 17 | ), 18 | ), 19 | migrations.AddField( 20 | model_name="cert", 21 | name="passphrase", 22 | field=models.CharField( 23 | blank=True, 24 | help_text="Passphrase for the private key, if present", 25 | max_length=64, 26 | ), 27 | ), 28 | ] 29 | -------------------------------------------------------------------------------- /openwisp_controller/pki/migrations/0007_default_groups_permissions.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations 2 | 3 | from . import assign_permissions_to_groups 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("pki", "0006_add_x509_passphrase_field"), 9 | ] 10 | operations = [ 11 | migrations.RunPython( 12 | assign_permissions_to_groups, reverse_code=migrations.RunPython.noop 13 | ) 14 | ] 15 | -------------------------------------------------------------------------------- /openwisp_controller/pki/migrations/0008_serial_number_length.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.2 on 2020-02-05 10:13 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [("pki", "0007_default_groups_permissions")] 8 | 9 | operations = [ 10 | migrations.AlterField( 11 | model_name="ca", 12 | name="serial_number", 13 | field=models.CharField( 14 | blank=True, 15 | help_text="leave blank to determine automatically", 16 | max_length=48, 17 | null=True, 18 | verbose_name="serial number", 19 | ), 20 | ), 21 | migrations.AlterField( 22 | model_name="cert", 23 | name="serial_number", 24 | field=models.CharField( 25 | blank=True, 26 | help_text="leave blank to determine automatically", 27 | max_length=48, 28 | null=True, 29 | verbose_name="serial number", 30 | ), 31 | ), 32 | ] 33 | -------------------------------------------------------------------------------- /openwisp_controller/pki/migrations/0009_common_name_maxlength_64.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.1 on 2020-09-19 00:52 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [("pki", "0008_serial_number_length")] 8 | 9 | operations = [ 10 | migrations.AlterField( 11 | model_name="ca", 12 | name="common_name", 13 | field=models.CharField( 14 | blank=True, max_length=64, verbose_name="common name" 15 | ), 16 | ), 17 | migrations.AlterField( 18 | model_name="cert", 19 | name="common_name", 20 | field=models.CharField( 21 | blank=True, max_length=64, verbose_name="common name" 22 | ), 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /openwisp_controller/pki/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import Permission 2 | 3 | from ...migrations import create_default_permissions, get_swapped_model 4 | 5 | 6 | def assign_permissions_to_groups(apps, schema_editor): 7 | create_default_permissions(apps, schema_editor) 8 | operators_read_only_admins_manage = ["ca", "cert"] 9 | manage_operations = ["add", "change", "delete"] 10 | Group = get_swapped_model(apps, "openwisp_users", "Group") 11 | 12 | try: 13 | admin = Group.objects.get(name="Administrator") 14 | operator = Group.objects.get(name="Operator") 15 | # consider failures custom cases 16 | # that do not have to be dealt with 17 | except Group.DoesNotExist: 18 | return 19 | 20 | for model_name in operators_read_only_admins_manage: 21 | try: 22 | permission = Permission.objects.get(codename="view_{}".format(model_name)) 23 | operator.permissions.add(permission.pk) 24 | except Permission.DoesNotExist: 25 | pass 26 | for operation in manage_operations: 27 | admin.permissions.add( 28 | Permission.objects.get( 29 | codename="{}_{}".format(operation, model_name) 30 | ).pk 31 | ) 32 | -------------------------------------------------------------------------------- /openwisp_controller/pki/models.py: -------------------------------------------------------------------------------- 1 | import swapper 2 | 3 | from .base.models import AbstractCa, AbstractCert 4 | 5 | 6 | class Ca(AbstractCa): 7 | class Meta(AbstractCa.Meta): 8 | abstract = False 9 | swappable = swapper.swappable_setting("django_x509", "Ca") 10 | 11 | 12 | class Cert(AbstractCert): 13 | class Meta(AbstractCert.Meta): 14 | abstract = False 15 | swappable = swapper.swappable_setting("django_x509", "Cert") 16 | -------------------------------------------------------------------------------- /openwisp_controller/pki/static/admin/pki/js/show-org-field.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | django.jQuery(function ($) { 3 | var showField = function () { 4 | $(".form-row.field-organization").show(); 5 | }; 6 | $(".field-operation_type select").on("change", function () { 7 | showField(); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /openwisp_controller/pki/templates/admin/pki/change_form.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/django_x509/change_form.html" %} 2 | -------------------------------------------------------------------------------- /openwisp_controller/pki/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openwisp/openwisp-controller/ba5e7a18f331f09f1cfcac0f5743d13dd16e31aa/openwisp_controller/pki/tests/__init__.py -------------------------------------------------------------------------------- /openwisp_controller/pki/tests/utils.py: -------------------------------------------------------------------------------- 1 | from django_x509.tests import TestX509Mixin 2 | from swapper import load_model 3 | 4 | 5 | class TestPkiMixin(TestX509Mixin): 6 | ca_model = load_model("django_x509", "Ca") 7 | cert_model = load_model("django_x509", "Cert") 8 | 9 | def _create_ca(self, **kwargs): 10 | if "organization" not in kwargs: 11 | kwargs["organization"] = None 12 | return super()._create_ca(**kwargs) 13 | 14 | def _create_cert(self, **kwargs): 15 | if "organization" not in kwargs: 16 | kwargs["organization"] = None 17 | return super()._create_cert(**kwargs) 18 | -------------------------------------------------------------------------------- /openwisp_controller/pki/urls.py: -------------------------------------------------------------------------------- 1 | """ 2 | There are no urls in this file. It has no functional use. 3 | This file exists only to maintain backward compatibility. 4 | """ 5 | 6 | app_name = "openwisp_controller" # pragma: no cover 7 | urlpatterns = [] # pragma: no cover 8 | -------------------------------------------------------------------------------- /openwisp_controller/pki/utils.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import ValidationError 2 | 3 | 4 | class UnqiueCommonNameMixin(object): 5 | def validate_unique(self, exclude=None): 6 | super().validate_unique(exclude=exclude) 7 | if ( 8 | self.organization is None 9 | and self._meta.model.objects.filter( 10 | organization=None, common_name=self.common_name 11 | ) 12 | .exclude(pk=self.pk) 13 | .exists() 14 | ): 15 | raise ValidationError( 16 | { 17 | "__all__": [ 18 | f"{self._meta.model._meta.verbose_name} with this Common name " 19 | "and Organization already exists." 20 | ] 21 | } 22 | ) 23 | -------------------------------------------------------------------------------- /openwisp_controller/routing.py: -------------------------------------------------------------------------------- 1 | from openwisp_notifications.websockets.routing import ( 2 | get_routes as get_notification_routes, 3 | ) 4 | 5 | from openwisp_controller.connection.channels.routing import ( 6 | get_routes as get_connection_routes, 7 | ) 8 | from openwisp_controller.geo.channels.routing import get_routes as get_geo_routes 9 | 10 | 11 | def get_routes(): 12 | return get_geo_routes() + get_connection_routes() + get_notification_routes() 13 | -------------------------------------------------------------------------------- /openwisp_controller/serializers.py: -------------------------------------------------------------------------------- 1 | from openwisp_users.api.mixins import FilterSerializerByOrgManaged 2 | from openwisp_utils.api.serializers import ValidatedModelSerializer 3 | 4 | 5 | class BaseSerializer(FilterSerializerByOrgManaged, ValidatedModelSerializer): 6 | """BaseSerializer for most API endpoints. 7 | 8 | - FilterSerializerByOrgManaged: for multi-tenancy 9 | - ValidatedModelSerializer: for model validation 10 | """ 11 | 12 | pass 13 | -------------------------------------------------------------------------------- /openwisp_controller/settings.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | OPENWISP_CONTROLLER_API_HOST = getattr(settings, "OPENWISP_CONTROLLER_API_HOST", None) 4 | -------------------------------------------------------------------------------- /openwisp_controller/subnet_division/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openwisp/openwisp-controller/ba5e7a18f331f09f1cfcac0f5743d13dd16e31aa/openwisp_controller/subnet_division/__init__.py -------------------------------------------------------------------------------- /openwisp_controller/subnet_division/migrations/0002_default_group_migration.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations 2 | 3 | from . import assign_permissions_to_groups 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [("subnet_division", "0001_initial")] 8 | 9 | operations = [ 10 | migrations.RunPython( 11 | assign_permissions_to_groups, reverse_code=migrations.RunPython.noop 12 | ) 13 | ] 14 | -------------------------------------------------------------------------------- /openwisp_controller/subnet_division/migrations/0003_related_field_allow_blank.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.12 on 2021-06-23 18:01 2 | 3 | import django.db.models.deletion 4 | from django.conf import settings 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | migrations.swappable_dependency(settings.CONFIG_CONFIG_MODEL), 11 | migrations.swappable_dependency(settings.OPENWISP_IPAM_IPADDRESS_MODEL), 12 | migrations.swappable_dependency(settings.OPENWISP_IPAM_SUBNET_MODEL), 13 | ("subnet_division", "0002_default_group_migration"), 14 | ] 15 | 16 | operations = [ 17 | migrations.AlterField( 18 | model_name="subnetdivisionindex", 19 | name="config", 20 | field=models.ForeignKey( 21 | blank=True, 22 | null=True, 23 | on_delete=django.db.models.deletion.CASCADE, 24 | to=settings.CONFIG_CONFIG_MODEL, 25 | ), 26 | ), 27 | migrations.AlterField( 28 | model_name="subnetdivisionindex", 29 | name="ip", 30 | field=models.ForeignKey( 31 | blank=True, 32 | null=True, 33 | on_delete=django.db.models.deletion.CASCADE, 34 | to=settings.OPENWISP_IPAM_IPADDRESS_MODEL, 35 | ), 36 | ), 37 | migrations.AlterField( 38 | model_name="subnetdivisionindex", 39 | name="subnet", 40 | field=models.ForeignKey( 41 | blank=True, 42 | null=True, 43 | on_delete=django.db.models.deletion.CASCADE, 44 | to=settings.OPENWISP_IPAM_SUBNET_MODEL, 45 | ), 46 | ), 47 | ] 48 | -------------------------------------------------------------------------------- /openwisp_controller/subnet_division/migrations/0004_index_rule_on_delete.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.13 on 2021-09-27 19:25 2 | 3 | import django.db.models.deletion 4 | from django.conf import settings 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | ("subnet_division", "0003_related_field_allow_blank"), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name="subnetdivisionindex", 16 | name="rule", 17 | field=models.ForeignKey( 18 | null=True, 19 | on_delete=django.db.models.deletion.SET_NULL, 20 | to=settings.SUBNET_DIVISION_SUBNETDIVISIONRULE_MODEL, 21 | ), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /openwisp_controller/subnet_division/migrations/0005_number_of_subnets_and_ips.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.16 on 2023-03-24 16:33 2 | 3 | import django.core.validators 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("subnet_division", "0004_index_rule_on_delete"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="subnetdivisionrule", 15 | name="number_of_ips", 16 | field=models.PositiveSmallIntegerField( 17 | help_text=( 18 | "Indicates how many IP addresses will be created for each subnet" 19 | ), 20 | verbose_name="Number of IPs", 21 | validators=[django.core.validators.MinValueValidator(1)], 22 | ), 23 | ), 24 | migrations.AlterField( 25 | model_name="subnetdivisionrule", 26 | name="number_of_subnets", 27 | field=models.PositiveSmallIntegerField( 28 | help_text="Indicates how many subnets will be created", 29 | validators=[django.core.validators.MinValueValidator(1)], 30 | verbose_name="Number of Subnets", 31 | ), 32 | ), 33 | migrations.AlterField( 34 | model_name="subnetdivisionrule", 35 | name="size", 36 | field=models.PositiveSmallIntegerField( 37 | help_text="Indicates the size of each created subnet", 38 | verbose_name="Size of subnets", 39 | ), 40 | ), 41 | ] 42 | -------------------------------------------------------------------------------- /openwisp_controller/subnet_division/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import Permission 2 | 3 | from ...migrations import create_default_permissions, get_swapped_model 4 | 5 | 6 | def assign_permissions_to_groups(apps, schema_editor): 7 | create_default_permissions(apps, schema_editor) 8 | operators_and_admins_can_manage = ["subnetdivisionrule"] 9 | admin_manage_operations = ["add", "change", "delete", "view"] 10 | operator_manage_operations = ["view"] 11 | Group = get_swapped_model(apps, "openwisp_users", "Group") 12 | 13 | try: 14 | admin = Group.objects.get(name="Administrator") 15 | operator = Group.objects.get(name="Operator") 16 | # consider failures custom cases 17 | # that do not have to be dealt with 18 | except Group.DoesNotExist: 19 | return 20 | 21 | for model_name in operators_and_admins_can_manage: 22 | for operation in admin_manage_operations: 23 | permission = Permission.objects.get( 24 | codename="{}_{}".format(operation, model_name) 25 | ) 26 | admin.permissions.add(permission.pk) 27 | 28 | for model_name in operators_and_admins_can_manage: 29 | for operation in operator_manage_operations: 30 | permission = Permission.objects.get( 31 | codename="{}_{}".format(operation, model_name) 32 | ) 33 | operator.permissions.add(permission.pk) 34 | -------------------------------------------------------------------------------- /openwisp_controller/subnet_division/models.py: -------------------------------------------------------------------------------- 1 | from swapper import swappable_setting 2 | 3 | from .base.models import AbstractSubnetDivisionIndex, AbstractSubnetDivisionRule 4 | 5 | 6 | class SubnetDivisionRule(AbstractSubnetDivisionRule): 7 | class Meta(AbstractSubnetDivisionRule.Meta): 8 | abstract = False 9 | swappable = swappable_setting("subnet_division", "SubnetDivisionRule") 10 | 11 | 12 | class SubnetDivisionIndex(AbstractSubnetDivisionIndex): 13 | class Meta(AbstractSubnetDivisionIndex.Meta): 14 | abstract = False 15 | swappable = swappable_setting("subnet_division", "SubnetDivisionIndex") 16 | -------------------------------------------------------------------------------- /openwisp_controller/subnet_division/rule_types/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openwisp/openwisp-controller/ba5e7a18f331f09f1cfcac0f5743d13dd16e31aa/openwisp_controller/subnet_division/rule_types/__init__.py -------------------------------------------------------------------------------- /openwisp_controller/subnet_division/settings.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | SUBNET_DIVISION_TYPES = getattr( 4 | settings, 5 | "OPENWISP_CONTROLLER_SUBNET_DIVISION_TYPES", 6 | ( 7 | ( 8 | ( 9 | "openwisp_controller.subnet_division.rule_types." 10 | "vpn.VpnSubnetDivisionRuleType" 11 | ), 12 | "VPN", 13 | ), 14 | ( 15 | ( 16 | "openwisp_controller.subnet_division.rule_types." 17 | "device.DeviceSubnetDivisionRuleType" 18 | ), 19 | "Device", 20 | ), 21 | ), 22 | ) 23 | 24 | HIDE_GENERATED_SUBNETS = getattr( 25 | settings, 26 | "OPENWISP_CONTROLLER_HIDE_AUTOMATICALLY_GENERATED_SUBNETS_AND_IPS", 27 | False, 28 | ) 29 | -------------------------------------------------------------------------------- /openwisp_controller/subnet_division/signals.py: -------------------------------------------------------------------------------- 1 | from django.dispatch import Signal 2 | 3 | subnet_provisioned = Signal() 4 | subnet_provisioned.__doc__ = """ 5 | Providing arguments: ['instance', 'provisioned'] 6 | """ 7 | -------------------------------------------------------------------------------- /openwisp_controller/subnet_division/static/subnet-division/css/subnet-division.css: -------------------------------------------------------------------------------- 1 | input.readonly { 2 | border: 1px solid rgba(0, 0, 0, 0.05) !important; 3 | background-color: rgba(0, 0, 0, 0.07); 4 | } 5 | .help-text-warning { 6 | background-color: #ffe5e5; 7 | padding: 5px 10px; 8 | font-size: 15px; 9 | font-weight: bolder; 10 | display: flex; 11 | } 12 | .help-text-warning img { 13 | min-width: 30px; 14 | } 15 | -------------------------------------------------------------------------------- /openwisp_controller/subnet_division/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openwisp/openwisp-controller/ba5e7a18f331f09f1cfcac0f5743d13dd16e31aa/openwisp_controller/subnet_division/tests/__init__.py -------------------------------------------------------------------------------- /openwisp_controller/subnet_division/tests/test_rule.py: -------------------------------------------------------------------------------- 1 | from django.db import connection 2 | from django.test import TestCase, tag 3 | from openwisp_ipam.tests import CreateModelsMixin as SubnetIpamMixin 4 | from swapper import load_model 5 | 6 | from ..rule_types.base import BaseSubnetDivisionRuleType 7 | from ..rule_types.vpn import VpnSubnetDivisionRuleType 8 | 9 | SubnetDivisionRule = load_model("subnet_division", "SubnetDivisionRule") 10 | 11 | 12 | class TestBaseSubnetDivisionRuleType(SubnetIpamMixin, TestCase): 13 | def test_should_create_subnets_ips(self): 14 | with self.assertRaises(NotImplementedError): 15 | BaseSubnetDivisionRuleType.should_create_subnets_ips(instance=None) 16 | 17 | def test_provision_for_existing_objects(self): 18 | with self.assertRaises(NotImplementedError): 19 | BaseSubnetDivisionRuleType.provision_for_existing_objects(rule_obj=None) 20 | 21 | @tag("db_tests") 22 | def test_get_max_subnet(self): 23 | rule = SubnetDivisionRule( 24 | **{ 25 | "label": "OW", 26 | "size": 28, 27 | "number_of_ips": 2, 28 | "number_of_subnets": 2, 29 | "type": VpnSubnetDivisionRuleType, 30 | } 31 | ) 32 | master_subnet = self._create_subnet(subnet="10.0.0.0/16") 33 | self._create_subnet(subnet="10.0.0.16/28", master_subnet=master_subnet) 34 | self._create_subnet(subnet="10.0.0.0/28", master_subnet=master_subnet) 35 | max_subnet = VpnSubnetDivisionRuleType.get_max_subnet(master_subnet, rule) 36 | if connection.vendor == "postgresql": 37 | self.assertEqual(str(max_subnet), "10.0.0.16/28") 38 | else: 39 | self.assertEqual(str(max_subnet), "10.0.0.0/28") 40 | -------------------------------------------------------------------------------- /openwisp_controller/subnet_division/utils.py: -------------------------------------------------------------------------------- 1 | def get_subnet_division_config_context(config): 2 | """ 3 | Returns SubnetDivision context containing subnet 4 | and IP address provisioned for the "config" object. 5 | 6 | This function is called by "Config.get_context" method. 7 | """ 8 | context = {} 9 | qs = config.subnetdivisionindex_set.values( 10 | "keyword", "subnet__subnet", "ip__ip_address" 11 | ) 12 | for entry in qs: 13 | if entry["ip__ip_address"] is None: 14 | context[entry["keyword"]] = str(entry["subnet__subnet"]) 15 | else: 16 | context[entry["keyword"]] = str(entry["ip__ip_address"]) 17 | prefixlen = ( 18 | config.subnetdivisionindex_set.select_related("rule") 19 | .values("rule__label", "rule__size") 20 | .first() 21 | ) 22 | if prefixlen: 23 | context[f'{prefixlen["rule__label"]}_prefixlen'] = str(prefixlen["rule__size"]) 24 | return context 25 | 26 | 27 | def subnet_division_vpnclient_auto_ip(vpn_client): 28 | """ 29 | Overrides the the default behavior of VpnClient.auto_ip 30 | which automatically assigns an IP to the VpnClient. 31 | This assignment is handled by SubnetDivision rule, 32 | so we need to skip it here. 33 | 34 | This function is called by "VpnClient._auto_ip" method. 35 | """ 36 | return ( 37 | vpn_client.vpn.subnet 38 | and vpn_client.vpn.subnet.subnetdivisionrule_set.filter( 39 | organization_id=vpn_client.config.device.organization, 40 | type=( 41 | "openwisp_controller.subnet_division.rule_types." 42 | "vpn.VpnSubnetDivisionRuleType" 43 | ), 44 | ).exists() 45 | ) 46 | -------------------------------------------------------------------------------- /openwisp_controller/tests/__init__.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | from openwisp_utils.utils import deepcopy 4 | 5 | 6 | def _get_updated_templates_settings(context_processors=[]): 7 | template_settings = deepcopy(settings.TEMPLATES[0]) 8 | if len(context_processors): 9 | template_settings["OPTIONS"]["context_processors"].extend(context_processors) 10 | else: 11 | template_settings["OPTIONS"]["context_processors"].append( 12 | "openwisp_controller.context_processors.controller_api_settings" 13 | ) 14 | return [template_settings] 15 | -------------------------------------------------------------------------------- /openwisp_controller/tests/mixins.py: -------------------------------------------------------------------------------- 1 | # Mixins used for unit tests of openwisp_controller 2 | 3 | 4 | class GetEditFormInlineMixin(object): 5 | def _get_org_edit_form_inline_params(self, user, org): 6 | params = super()._get_org_edit_form_inline_params(user, org) 7 | params.update( 8 | { 9 | # config inline 10 | "config_settings-TOTAL_FORMS": 0, 11 | "config_settings-INITIAL_FORMS": 0, 12 | "config_settings-MIN_NUM_FORMS": 0, 13 | "config_settings-MAX_NUM_FORMS": 0, 14 | # device limit inline 15 | "config_limits-TOTAL_FORMS": 0, 16 | "config_limits-INITIAL_FORMS": 0, 17 | # notification settings inline 18 | "notification_settings-TOTAL_FORMS": 1, 19 | "notification_settings-INITIAL_FORMS": 1, 20 | "notification_settings-MIN_NUM_FORMS": 0, 21 | "notification_settings-MAX_NUM_FORMS": 1, 22 | } 23 | ) 24 | return params 25 | 26 | def _get_user_edit_form_inline_params(self, user, organization): 27 | params = super()._get_user_edit_form_inline_params(user, organization) 28 | params.update( 29 | { 30 | "notificationsetting_set-TOTAL_FORMS": 0, 31 | "notificationsetting_set-INITIAL_FORMS": 0, 32 | "notificationsetting_set-MIN_NUM_FORMS": 0, 33 | "notificationsetting_set-MAX_NUM_FORMS": 0, 34 | } 35 | ) 36 | return params 37 | -------------------------------------------------------------------------------- /openwisp_controller/tests/test_users_integration.py: -------------------------------------------------------------------------------- 1 | from openwisp_users.tests.test_admin import TestUsersAdmin 2 | 3 | from .mixins import GetEditFormInlineMixin 4 | 5 | 6 | class TestUsersIntegration(GetEditFormInlineMixin, TestUsersAdmin): 7 | """ 8 | tests integration with openwisp_users 9 | """ 10 | 11 | is_integration_test = True 12 | 13 | 14 | del TestUsersAdmin 15 | -------------------------------------------------------------------------------- /openwisp_controller/tests/utils.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | from django.urls import reverse 3 | 4 | from openwisp_users.tests.utils import TestMultitenantAdminMixin 5 | 6 | user_model = get_user_model() 7 | 8 | 9 | class TestAdminMixin(TestMultitenantAdminMixin): 10 | def _test_changelist_recover_deleted(self, app_label, model_label): 11 | self._test_multitenant_admin( 12 | url=reverse("admin:{0}_{1}_changelist".format(app_label, model_label)), 13 | visible=[], 14 | hidden=[], 15 | ) 16 | 17 | def _login(self, username="admin", password="tester"): 18 | self.client.force_login(user_model.objects.get(username=username)) 19 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.coverage.run] 2 | source = ["openwisp_controller"] 3 | parallel = true 4 | concurrency = ["multiprocessing", "thread"] 5 | omit = [ 6 | "openwisp_controller/__init__.py", 7 | "*/tests/*", 8 | "*/migrations/*", 9 | ] 10 | 11 | [tool.docstrfmt] 12 | extend_exclude = ["**/*.py"] 13 | 14 | [tool.isort] 15 | known_third_party = ["django", "django_x509"] 16 | known_first_party = ["openwisp_users", "openwisp_utils"] 17 | default_section = "THIRDPARTY" 18 | line_length = 88 19 | multi_line_output = 3 20 | use_parentheses = true 21 | include_trailing_comma = true 22 | force_grid_wrap = 0 23 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts = -p no:warnings --create-db --reuse-db --nomigrations 3 | DJANGO_SETTINGS_MODULE = openwisp2.settings 4 | python_files = pytest*.py 5 | python_classes = *Test* 6 | pythonpath = tests 7 | -------------------------------------------------------------------------------- /requirements-test.txt: -------------------------------------------------------------------------------- 1 | pytest-cov~=7.0.0 2 | openwisp-utils[qa,selenium,channels-test] @ https://github.com/openwisp/openwisp-utils/tarball/1.2 3 | django_redis~=6.0.0 4 | mock-ssh-server~=0.9.1 5 | responses~=0.25.8 6 | psycopg2-binary~=2.9.10 7 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | django-sortedm2m~=4.0.0 2 | django-reversion~=5.1.0 3 | django-taggit~=6.1.0 4 | netjsonconfig @ https://github.com/openwisp/netjsonconfig/tarball/1.2 5 | django-x509 @ https://github.com/openwisp/django-x509/tarball/1.3 6 | django-loci @ https://github.com/openwisp/django-loci/tarball/1.2 7 | django-flat-json-widget~=0.3.1 8 | openwisp-users @ https://github.com/openwisp/openwisp-users/tarball/1.2 9 | openwisp-utils[celery,channels] @ https://github.com/openwisp/openwisp-utils/tarball/1.2 10 | openwisp-notifications @ https://github.com/openwisp/openwisp-notifications/tarball/1.2 11 | openwisp-ipam @ https://github.com/openwisp/openwisp-ipam/tarball/1.2 12 | djangorestframework-gis @ https://github.com/openwisp/django-rest-framework-gis/tarball/1.2 13 | paramiko~=4.0.0 14 | scp~=0.15.0 15 | django-cache-memoize~=0.2.1 16 | shortuuid~=1.0.13 17 | netaddr~=1.3.0 18 | django-import-export~=4.3.10 19 | -------------------------------------------------------------------------------- /run-qa-checks: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | rm -rf ./openwisp_controller/geo/tests/.pytest_cache \ 6 | ./openwisp_controller/config/tests/.pytest_cache/ \ 7 | ./openwisp_controller/connection/tests/.pytest_cache/ \ 8 | ./openwisp_controller/.pytest_cache/ \ 9 | ./tests/openwisp2/sample_geo/.pytest_cache \ 10 | ./tests/.pytest_cache \ 11 | ./tests/openwisp2/.pytest_cache/ \ 12 | ./htmlcov/ 2> /dev/null || true 13 | 14 | openwisp-qa-check \ 15 | --csslinter \ 16 | --jslinter \ 17 | --migrations-to-ignore "12 0 0 4" \ 18 | --migration-path "./openwisp_controller/config/migrations 19 | ./openwisp_controller/connection/migrations 20 | ./openwisp_controller/geo/migrations 21 | ./openwisp_controller/pki/migrations 22 | ./openwisp_controller/subnet_division/migrations" 23 | 24 | echo '' 25 | echo 'Running checks for SAMPLE_APP' 26 | SAMPLE_APP=1 openwisp-qa-check \ 27 | --skip-isort \ 28 | --skip-flake8 \ 29 | --skip-black \ 30 | --skip-checkendline \ 31 | --skip-checkcommit \ 32 | --migration-path "./tests/openwisp2/sample_config/migrations/ 33 | ./tests/openwisp2/sample_pki/migrations/ 34 | ./tests/openwisp2/sample_connection/migrations/ 35 | ./tests/openwisp2/sample_geo/migrations/ 36 | ./tests/openwisp2/sample_subnet_division/migrations/" 37 | -------------------------------------------------------------------------------- /runtests: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | # standard tests 5 | coverage run runtests.py --parallel \ 6 | || coverage run ./runtests.py 7 | # tests the extension capability 8 | SAMPLE_APP=1 coverage run ./runtests.py \ 9 | --parallel --exclude-tag=selenium_tests \ 10 | || SAMPLE_APP=1 coverage run ./runtests.py \ 11 | --exclude-tag=selenium_tests 12 | coverage combine 13 | coverage xml 14 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal=1 3 | 4 | [flake8] 5 | # W503: line break before or after operator 6 | # W504: line break after or after operator 7 | # W605: invalid escape sequence 8 | ignore = W605, W503, W504 9 | exclude = ./tests/*settings*.py 10 | max-line-length = 88 11 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openwisp/openwisp-controller/ba5e7a18f331f09f1cfcac0f5743d13dd16e31aa/tests/__init__.py -------------------------------------------------------------------------------- /tests/docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | create_superuser () { 4 | local username="$1" 5 | local email="$2" 6 | local password="$3" 7 | cat <