├── .jujuignore ├── requirements.txt ├── .gitignore ├── templates └── config.yaml.j2 ├── .github ├── renovate-config.js ├── workflows │ ├── cla_check.yaml │ ├── pull-request.yaml │ ├── renovate.yaml │ └── build-and-test.yaml └── renovate.json ├── constants.py ├── charmcraft.yaml ├── config.yaml ├── pyproject.toml ├── metadata.yaml ├── icon.svg ├── tox.ini ├── CONTRIBUTING.md ├── src └── charm.py ├── README.md ├── aar.py ├── interfaces.py └── lib └── charms ├── operator_libs_linux └── v2 │ └── snap.py └── tls_certificates_interface └── v2 └── tls_certificates.py /.jujuignore: -------------------------------------------------------------------------------- 1 | /venv 2 | *.py[cod] 3 | *.charm 4 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | ops==2.7.0 2 | jinja2==3.1.2 3 | netifaces==0.11.0 4 | jsonschema==4.19.1 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | 3 | venv/ 4 | build/ 5 | *.charm 6 | .tox/ 7 | .coverage 8 | __pycache__/ 9 | *.py[cod] 10 | coverage.xml 11 | 12 | -------------------------------------------------------------------------------- /templates/config.yaml.j2: -------------------------------------------------------------------------------- 1 | http: 2 | addr: {{ listen_address }} 3 | {% if storage_config|length > 0 -%} 4 | {{ storage_config }} 5 | {% else %} 6 | storage: 7 | fs: 8 | root-directory: /var/snap/aar/common/data 9 | {%- endif %} -------------------------------------------------------------------------------- /.github/renovate-config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | branchPrefix: "renovate/", 3 | dryRun: null, 4 | username: "renovate-release", 5 | gitAuthor: "Renovate Bot ", 6 | onboarding: true, 7 | platform: "github", 8 | includeForks: true, 9 | repositories: ["anbox-cloud/aar-operator"], 10 | } 11 | -------------------------------------------------------------------------------- /.github/workflows/cla_check.yaml: -------------------------------------------------------------------------------- 1 | name: CLA check 2 | 3 | on: 4 | pull_request: 5 | branches: [main] 6 | 7 | jobs: 8 | cla-check: 9 | runs-on: ubuntu-22.04 10 | steps: 11 | - name: Check if Canonical's Contributor License Agreement has been signed 12 | uses: canonical/has-signed-canonical-cla@v1 13 | -------------------------------------------------------------------------------- /constants.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2023 Canonical Ltd. All rights reserved. 3 | # 4 | 5 | from snap import AAR 6 | 7 | SNAP_BASE_PATH = AAR.get_path() 8 | AAR_CONFIG_PATH = SNAP_BASE_PATH / "conf/main.yaml" 9 | 10 | AAR_CERT_BASE_PATH = SNAP_BASE_PATH / "certs" 11 | 12 | AAR_SERVER_CERT_PATH = AAR_CERT_BASE_PATH / "server.crt" 13 | AAR_SERVER_KEY_PATH = AAR_CERT_BASE_PATH / "server.key" 14 | 15 | CLIENTS_CERT_PATH = AAR_CERT_BASE_PATH / "clients" 16 | PUBLISHERS_CERT_PATH = AAR_CERT_BASE_PATH / "publishers" 17 | 18 | -------------------------------------------------------------------------------- /charmcraft.yaml: -------------------------------------------------------------------------------- 1 | type: charm 2 | bases: 3 | - build-on: 4 | - name: "ubuntu" 5 | channel: "20.04" 6 | - name: "ubuntu" 7 | channel: "22.04" 8 | run-on: 9 | - name: "ubuntu" 10 | channel: "20.04" 11 | architectures: [amd64, arm64] 12 | - name: "ubuntu" 13 | channel: "22.04" 14 | architectures: [amd64, arm64] 15 | parts: 16 | charm: 17 | charm-requirements: ["requirements.txt"] 18 | build-packages: 19 | - git 20 | - libffi-dev 21 | - libssl-dev 22 | - rustc 23 | - cargo 24 | - pkg-config 25 | 26 | -------------------------------------------------------------------------------- /.github/workflows/pull-request.yaml: -------------------------------------------------------------------------------- 1 | name: Pull Request 2 | on: 3 | pull_request: 4 | paths-ignore: 5 | - ".github/renovate*" 6 | - ".github/workflows/release.yaml" 7 | - ".github/workflows/renovate.yaml" 8 | - ".github/workflows/update-libs.yaml" 9 | - ".gitignore" 10 | - ".jujuignore" 11 | push: 12 | branches: 13 | - "renovate/*" 14 | 15 | concurrency: 16 | group: ${{ github.workflow }}-${{ github.ref }} 17 | cancel-in-progress: true 18 | 19 | jobs: 20 | test: 21 | uses: ./.github/workflows/build-and-test.yaml 22 | -------------------------------------------------------------------------------- /config.yaml: -------------------------------------------------------------------------------- 1 | options: 2 | port: 3 | type: int 4 | default: 3000 5 | description: Port the Anbox Application Registry listens on 6 | storage_config: 7 | type: string 8 | default: "" 9 | description: Storage configuration for the Anbox Application Registry 10 | public_interface: 11 | type: string 12 | default: "" 13 | description: Identifies which network interface to use for the public address 14 | location: 15 | type: string 16 | default: "" 17 | description: | 18 | IP address of the machine hosting the AAR. If not set, 19 | the public IP address of the host will be used. 20 | -------------------------------------------------------------------------------- /.github/workflows/renovate.yaml: -------------------------------------------------------------------------------- 1 | # workflow for checking package versions and opening PRs to bump 2 | name: Renovate 3 | on: 4 | schedule: 5 | - cron: "0 12 * * *" 6 | workflow_dispatch: 7 | workflow_call: 8 | 9 | jobs: 10 | renovate: 11 | runs-on: ubuntu-22.04 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 15 | 16 | - name: Self-hosted Renovate 17 | uses: renovatebot/github-action@5e224f3a02c7ce9cadc83f82d65f1b6dd73876c1 # v39.0.3 18 | with: 19 | configurationFile: .github/renovate-config.js 20 | token: ${{ github.token }} 21 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | # Copyright 2023 Canonical Ltd. 2 | # See LICENSE file for licensing details. 3 | 4 | # Testing tools configuration 5 | [tool.coverage.run] 6 | branch = true 7 | 8 | [tool.coverage.report] 9 | show_missing = true 10 | 11 | [tool.pytest.ini_options] 12 | minversion = "6.0" 13 | log_cli_level = "INFO" 14 | 15 | # Formatting tools configuration 16 | [tool.black] 17 | line-length = 99 18 | target-version = ["py38"] 19 | 20 | # Linting tools configuration 21 | [tool.ruff] 22 | line-length = 99 23 | select = ["E", "W", "F", "C", "N", "D", "I001"] 24 | extend-ignore = [ 25 | "D203", 26 | "D204", 27 | "D213", 28 | "D215", 29 | "D400", 30 | "D404", 31 | "D406", 32 | "D407", 33 | "D408", 34 | "D409", 35 | "D413", 36 | ] 37 | ignore = ["E501", "D107"] 38 | extend-exclude = ["__pycache__", "*.egg_info"] 39 | per-file-ignores = {"tests/*" = ["D100","D101","D102","D103","D104"]} 40 | 41 | [tool.ruff.mccabe] 42 | max-complexity = 10 43 | 44 | [tool.codespell] 45 | skip = "build,lib,venv,icon.svg,.tox,.git,.mypy_cache,.ruff_cache,.vscode,.coverage" 46 | -------------------------------------------------------------------------------- /metadata.yaml: -------------------------------------------------------------------------------- 1 | name: aar 2 | display-name: Anbox Application Registry 3 | summary: | 4 | The Anbox Application Registry (AAR) is a central repository for applications 5 | created on Anbox Cloud. 6 | website: https://anbox-cloud.io 7 | issues: https://bugs.launchpad.net/anbox-cloud 8 | maintainers: 9 | - Anbox Cloud Team 10 | description: | 11 | For larger deployments involving multiple regions, AAR helps to keep the 12 | applications in sync. Once deployed and configured, AAR can be connected to 13 | multiple Anbox Management Service (AMS) units to synchronise the applications. 14 | See https://anbox-cloud.io/docs/exp/aar for understanding more about AAR. 15 | tags: 16 | - registry 17 | subordinate: false 18 | provides: 19 | client: 20 | interface: aar 21 | publisher: 22 | interface: aar 23 | 24 | # FIXME: remove this resource requirement when the snap has been moved to the 25 | # snapstore and marked as unlisted 26 | resources: 27 | aar-snap: 28 | type: file 29 | description: Snap for Anbox Application Registry 30 | filename: aar.snap 31 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:base", 5 | ":disableDependencyDashboard", 6 | ":automergeDigest", 7 | ":automergePatch", 8 | ":automergeMinor", 9 | ":rebaseStalePrs", 10 | ":semanticCommits", 11 | ":semanticCommitScope(deps)", 12 | "docker:pinDigests", 13 | "helpers:pinGitHubActionDigests", 14 | "regexManagers:dockerfileVersions" 15 | ], 16 | "automergeType": "branch", 17 | "packageRules": [ 18 | { 19 | "groupName": "github actions", 20 | "matchManagers": ["github-actions"], 21 | "automerge": true, 22 | "schedule": ["on monday"] 23 | }, 24 | { 25 | "groupName": "testing deps", 26 | "matchFiles": ["tox.ini"], 27 | "matchUpdateTypes": ["major", "minor", "patch", "pin", "digest"], 28 | "automerge": true, 29 | "schedule": ["on monday"] 30 | }, 31 | { 32 | "groupName": "renovate packages", 33 | "matchSourceUrlPrefixes": ["https://github.com/renovatebot/"], 34 | "matchUpdateTypes": ["major", "minor", "patch", "pin", "digest"], 35 | "automerge": true, 36 | "schedule": ["on monday"] 37 | } 38 | ], 39 | "regexManagers": [ 40 | { 41 | "fileMatch": ["tox.ini"], 42 | "matchStrings": [ 43 | "# renovate: datasource=(?\\S+)\n\\s+(?.*?)==(?.*?)\\n" 44 | ] 45 | } 46 | ] 47 | } 48 | -------------------------------------------------------------------------------- /.github/workflows/build-and-test.yaml: -------------------------------------------------------------------------------- 1 | name: Build/Test 2 | 3 | on: 4 | workflow_call: 5 | 6 | jobs: 7 | lint: 8 | name: Lint 9 | runs-on: ubuntu-22.04 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v3 # v3 13 | - name: Install dependencies 14 | run: python3 -m pip install tox 15 | - name: Run linters 16 | run: tox -e lint 17 | 18 | unit-test: 19 | name: Unit tests 20 | runs-on: ubuntu-22.04 21 | steps: 22 | - name: Checkout 23 | uses: actions/checkout@v3 # v3 24 | - name: Install dependencies 25 | run: python -m pip install tox 26 | - name: Run tests 27 | run: tox -e unit 28 | 29 | integration-test: 30 | name: Integration tests 31 | runs-on: ubuntu-20.04 32 | needs: 33 | - lint 34 | - unit-test 35 | strategy: 36 | fail-fast: false 37 | max-parallel: 6 38 | matrix: 39 | agent-versions: 40 | - "3.2.2" # renovate: latest juju 3 41 | - "2.9.44" # renovate: latest juju 2 42 | steps: 43 | - name: Checkout 44 | uses: actions/checkout@v3 # v3 45 | - name: Set channel 46 | run: | 47 | juju_channel=$(echo "${{ matrix.agent-versions }}" | cut -c 1-3) 48 | echo "channel=${juju_channel}/stable" >> "$GITHUB_ENV" 49 | juju_major=$(echo "${{ matrix.agent-versions }}" | cut -c 1) 50 | echo "libjuju=juju${juju_major}" >> "$GITHUB_ENV" 51 | - name: Setup operator environment 52 | uses: charmed-kubernetes/actions-operator@main 53 | with: 54 | provider: lxd 55 | juju-channel: "${{ env.channel }}" 56 | bootstrap-options: "--agent-version ${{ matrix.agent-versions }}" 57 | - name: Run integration tests 58 | run: tox -e integration-${{ env.libjuju }} 59 | -------------------------------------------------------------------------------- /icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | skipsdist=True 3 | skip_missing_interpreters = True 4 | envlist = fmt, lint, unit 5 | 6 | [vars] 7 | src_path = {toxinidir}/src/ 8 | tst_path = {toxinidir}/tests/ 9 | lib_path = {toxinidir}/lib/charms/nrpe/ 10 | all_path = {[vars]src_path} {[vars]tst_path} 11 | 12 | [testenv] 13 | setenv = 14 | PYTHONPATH = {toxinidir}:{toxinidir}/lib:{[vars]src_path} 15 | PYTHONBREAKPOINT=pdb.set_trace 16 | PY_COLORS=1 17 | juju2: LIBJUJU="2.9.44" # libjuju2 18 | juju3: LIBJUJU="3.2.2" # libjuju3 19 | passenv = 20 | PYTHONPATH 21 | CHARM_BUILD_DIR 22 | MODEL_SETTINGS 23 | 24 | [testenv:fmt] 25 | description = Apply coding style standards to code 26 | deps = 27 | # renovate: datasource=pypj 28 | black==23.7.0 29 | # renovate: datasource=pypi 30 | ruff==0.0.287 31 | commands = 32 | ruff --fix {[vars]all_path} 33 | black {[vars]all_path} 34 | 35 | [testenv:lint] 36 | description = Check code against coding style standards 37 | deps = 38 | # renovate: datasource=pypi 39 | black==23.7.0 40 | # renovate: datasource=pypi 41 | ruff==0.0.287 42 | # renovate: datasource=pypi 43 | codespell==2.2.5 44 | commands = 45 | codespell {toxinidir} 46 | ruff {[vars]all_path} 47 | black --check --diff {[vars]all_path} 48 | 49 | [testenv:unit] 50 | description = Run unit tests 51 | deps = 52 | -r{toxinidir}/requirements.txt 53 | # renovate: datasource=pypi 54 | pytest==7.4.1 55 | # renovate: datasource=pypi 56 | coverage[toml]==6.5.0 57 | commands = 58 | coverage run --source={[vars]src_path} \ 59 | -m pytest \ 60 | --ignore={[vars]tst_path}integration \ 61 | --tb native \ 62 | -v \ 63 | -s \ 64 | {posargs} 65 | coverage report 66 | 67 | [testenv:integration-{juju2,juju3}] 68 | description = Run integration tests 69 | deps = 70 | # renovate: datasource=pypi 71 | pytest==7.4.1 72 | # renovate: datasource=pypi 73 | pytest-operator==0.29.0 74 | -r{toxinidir}/requirements.txt 75 | commands = 76 | pip install juju=={env:LIBJUJU} 77 | pytest -v \ 78 | -s \ 79 | --tb native \ 80 | --ignore={[vars]tst_path}unit \ 81 | --log-cli-level=INFO \ 82 | {posargs} 83 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## Overview 4 | 5 | This documents explains the processes and practices recommended for contributing enhancements to 6 | this operator. 7 | 8 | - Generally, before developing enhancements to this charm, consider [opening an issue 9 | ](https://github.com/canonical/aar-operator/issues) explaining your use case. 10 | - Familiarising yourself with the [Charmed Operator Framework](https://juju.is/docs/sdk) library 11 | will help you a lot when working on new features or bug fixes. 12 | - All enhancements require review before being merged. Code review typically examines 13 | - code quality 14 | - test coverage 15 | - user experience for Juju administrators of this charm. 16 | - It is a good practice to rebase your pull request branch onto the `main` branch for a linear 17 | commit history, avoiding merge commits and easy reviews. 18 | 19 | ## Developing 20 | 21 | ### Prerequisites 22 | 23 | To run integration tests you require a juju deployment with a juju controller ready. You can refer to 24 | [how to setup a juju deployment](https://juju.is/docs/juju/get-started-with-juju). 25 | 26 | 27 | ### Develop 28 | You can create an environment for development with `tox`: 29 | 30 | ```shell 31 | tox devenv -e integration-juju3 32 | source venv/bin/activate 33 | ``` 34 | 35 | ### Test 36 | 37 | ```shell 38 | tox run -e format # update your code according to linting rules 39 | tox run -e lint # code style 40 | tox run -e unit # unit tests 41 | tox run -e integration-juju2 # integration tests for juju 2.9 42 | tox run -e integration-juju3 # integration tests for juju 3.2 43 | tox # runs 'lint' and 'unit' environments 44 | ``` 45 | 46 | ### Build 47 | 48 | Build the charm in this git repository using: 49 | 50 | ```shell 51 | charmcraft pack 52 | ``` 53 | 54 | ### Deploy 55 | 56 | ```bash 57 | # Create a model 58 | juju add-model dev 59 | 60 | # Enable DEBUG logging 61 | juju model-config logging-config="=INFO;unit=DEBUG" 62 | 63 | # Deploy the charm 64 | juju deploy ./aar_ubuntu-22.04-amd64.charm 65 | ``` 66 | 67 | ## Canonical Contributor Agreement 68 | 69 | Canonical welcomes contributions to the AAR Operator. Please check out our 70 | [contributor agreement](https://ubuntu.com/legal/contributors) if you're 71 | interested in contributing to the solution. 72 | -------------------------------------------------------------------------------- /src/charm.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # Copyright 2023 Canonical Ltd. All rights reserved. 4 | # 5 | 6 | """Charmed Machine Operator For Anbox Application Registry (AAR).""" 7 | 8 | import logging 9 | 10 | import ops 11 | 12 | from aar import AAR 13 | from interfaces import AAREndpointProvider, ClientRegisteredEvent 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | 18 | class AARCharm(ops.CharmBase): 19 | """Charmed Operator to deploy AAR - Anbox Application Registry.""" 20 | 21 | def __init__(self, *args): 22 | super().__init__(*args) 23 | self._snap = AAR(self) 24 | self.framework.observe(self.on.config_changed, self._on_config_changed) 25 | self.framework.observe(self.on.stop, self._on_stop) 26 | 27 | self._client = AAREndpointProvider(self, "client") 28 | self.framework.observe(self._client.on.client_registered, self._on_aar_client_registered) 29 | 30 | self._publisher = AAREndpointProvider(self, "publisher") 31 | self.framework.observe( 32 | self._publisher.on.client_registered, self._on_aar_client_registered 33 | ) 34 | 35 | @property 36 | def public_ip(self) -> str: 37 | """Public address of the unit.""" 38 | public_iface = self.config["public_interface"] 39 | if public_iface: 40 | public_address = AAR.get_ip_for_interface(public_iface) 41 | if public_address: 42 | return public_address 43 | logger.warning( 44 | "Could not obtain a valid IP for the configured public_interface. \ 45 | Using the default one" 46 | ) 47 | return self.model.get_binding("juju-info").network.ingress_address.exploded 48 | 49 | @property 50 | def private_ip(self) -> str: 51 | """Private address of the unit.""" 52 | return self.model.get_binding("juju-info").network.bind_address.exploded 53 | 54 | def _on_config_changed(self, _: ops.ConfigChangedEvent): 55 | if not self._snap.installed: 56 | self.unit.status = ops.MaintenanceStatus("Installing AAR") 57 | self._snap.install() 58 | 59 | self.unit.status = ops.MaintenanceStatus("Configuring AAR") 60 | port = ops.Port(protocol="tcp", port=int(self.config["port"])) 61 | self._snap.configure(port, self.config["storage_config"], self.private_ip, self.public_ip) 62 | self.unit.set_ports(port) 63 | 64 | self.unit.set_workload_version(self._snap.version) 65 | self._snap.restart() 66 | self.unit.status = ops.ActiveStatus() 67 | 68 | def _on_stop(self, _: ops.StopEvent): 69 | self._snap.remove() 70 | 71 | def _on_aar_client_registered(self, _: ClientRegisteredEvent): 72 | self.unit.status = ops.ActiveStatus() 73 | 74 | 75 | if __name__ == "__main__": 76 | ops.main(AARCharm) 77 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Anbox Application Registry (AAR) Charmed Operator 2 | 3 | ## Description 4 | 5 | ### Anbox Cloud 6 | 7 | Anbox Cloud provides a rich software stack that enables you to run Android applications in the cloud for 8 | different use cases, including high-performance streaming of graphics to desktop and mobile client devices. 9 | 10 | Anbox Cloud maintains a single Android system per instance, providing higher density and better performance 11 | per host while ensuring security and isolation of each instance. Depending on the target platform, payload, 12 | and desired application performance (for example, frame rate), Anbox Cloud can run more than 100 instances 13 | on a single machine. 14 | 15 | Also have a look at the official Anbox Cloud website (https://anbox-cloud.io) for more information. 16 | 17 | > NOTE: Anbox Cloud is a paid offering. You will need a Ubuntu Pro (https://ubuntu.com/pro) subscription 18 | > for this charm to work. You can learn more at https://anbox-cloud.io 19 | 20 | > **WARNING:** The *Ubuntu Pro (infra only)* subscription every Ubuntu user gets for free for 21 | > personal use does **NOT** work and will result in a failed deployment! You either need a 22 | > *Ubuntu Pro* or *Ubuntu Pro (apps only)* subscription to be able to deploy successfully. 23 | 24 | ### Anbox Application Registry 25 | 26 | The Anbox Application Registry, or *AAR*, charm provides a central repository for applications created 27 | on Anbox Cloud. It is extremely useful for larger deployments involving multiple regions in order to 28 | keep applications in sync. 29 | 30 | #### Client Types 31 | 32 | There are two types of consumers that can connect to AAR: 33 | - **Client/Pull Only**: A Read-Only Client which can only pull from the registry 34 | - **Publisher/Push & Pull**: A Read-Write Client which can pull as well as publish applications to the registry 35 | 36 | For more information on how to configure the AAR and [its clients](https://anbox-cloud.io/docs/exp/aar), 37 | visit the official documentation at https://anbox-cloud.io/docs/installation/installation-application-registry 38 | 39 | ## Usage 40 | 41 | > **WARNING:** This charm requires a resource to work which is described as follows: 42 | > ```yaml 43 | > resources: 44 | > aar-snap: 45 | > type: file 46 | > description: Snap for Anbox Application Registry 47 | > filename: aar.snap 48 | > ``` 49 | > This resource represents the AAR snap package which will be installed when the charm gets installed. 50 | 51 | ### Basic Usage 52 | 53 | ```sh 54 | $ juju deploy aar --resource aar-snap=aar.snap 55 | ``` 56 | 57 | ## Integrations (Relations) 58 | 59 | ### `aar` interface: 60 | 61 | This interface is used to register a client and interact with AAR. 62 | 63 | #### Provides Side: 64 | 65 | ```yaml 66 | provides: 67 | client: 68 | interface: aar 69 | publisher: 70 | interface: aar 71 | ``` 72 | This interface is used by two integrations in the charm both corresponding to the [types of clients](#client-types) to register. 73 | The data provided to the consumer charms looks like the following: 74 | ```yaml 75 | certificate: 76 | fingerprint: 77 | ip: 78 | port: 79 | ``` 80 | 81 | #### Requires Side: 82 | 83 | The data provided to the provider side should look like the following: 84 | ```yaml 85 | certificate: "-----BEGIN CERTIFICATE-----\n 86 | .... 87 | -----END CERTIFICATE-----\n" 88 | mode: "publisher" 89 | ``` 90 | 91 | ## Security 92 | Security issues in the operator can be reported through [LaunchPad](https://wiki.ubuntu.com/DebuggingSecurity#How%20to%20File) on the [Anbox Cloud](https://bugs.launchpad.net/anbox-cloud) project. Please do not file GitHub issues about security issues. 93 | 94 | ## Contributing 95 | Please see the [Juju SDK docs](https://juju.is/docs/sdk) for guidelines on enhancements to this charm following best practice guidelines, and [CONTRIBUTING.md](./CONTRIBUTING.md) for developer guidance. 96 | 97 | ## License 98 | The AAR Charm is distributed under the Apache Software License, version 2.0. 99 | -------------------------------------------------------------------------------- /aar.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2023 Canonical Ltd. All rights reserved. 3 | # 4 | 5 | import os 6 | import netifaces 7 | from jinja2 import Environment, FileSystemLoader 8 | from pathlib import Path 9 | import ops 10 | from lib.charms.operator_libs_linux.v2 import snap 11 | from lib.charms.tls_certificates_interface.v2.tls_certificates import generate_ca, generate_certificate, generate_csr, generate_private_key 12 | 13 | SNAP_NAME = "aar" 14 | SNAP_COMMON_PATH = Path("/var/snap/aar/common") 15 | AAR_CONFIG_PATH = SNAP_COMMON_PATH / "conf/main.yaml" 16 | 17 | AAR_CERT_BASE_PATH = SNAP_COMMON_PATH / "certs" 18 | 19 | AAR_SERVER_CERT_PATH = AAR_CERT_BASE_PATH / "server.crt" 20 | AAR_SERVER_KEY_PATH = AAR_CERT_BASE_PATH / "server.key" 21 | 22 | CLIENTS_CERT_PATH = AAR_CERT_BASE_PATH / "clients" 23 | PUBLISHERS_CERT_PATH = AAR_CERT_BASE_PATH / "publishers" 24 | 25 | class AAR: 26 | 27 | def __init__(self, charm: ops.CharmBase): 28 | self._sc = snap.SnapCache() 29 | self._charm = charm 30 | 31 | def restart(self): 32 | self._sc['aar'].restart() 33 | 34 | def remove(self): 35 | self._sc['aar']._remove() 36 | 37 | def install(self): 38 | try: 39 | res = self._charm.model.resources.fetch("aar-snap") 40 | except ops.ModelError: 41 | res = None 42 | 43 | # FIXME: Install the aar snap from a resource until we make the 44 | # snaps in the snap store unlisted 45 | if res is not None and res.stat().st_size: 46 | snap.install_local(res, classic=False, dangerous=True) 47 | else: 48 | self._charm.unit.status = ops.BlockedStatus("cannot install aar: snap resource not found") 49 | return 50 | 51 | aar_snap = self._sc["aar"] 52 | aar_snap.connect(plug="home", slot=":home") 53 | aar_snap.connect(plug="network", slot=":network") 54 | 55 | # TODO: remove this function to get snap from SnapCache()['aar'] after the 56 | # snap is made publicly available in the snap store 57 | def _get_snap(self) -> dict | None: 58 | snaps = self._sc._snap_client.get_installed_snaps() 59 | for installed_snap in snaps: 60 | if installed_snap["name"] == SNAP_NAME: 61 | return installed_snap 62 | return None 63 | 64 | @property 65 | def version(self) -> str: 66 | _snap = self._get_snap() 67 | if not _snap: 68 | raise snap.SnapNotFoundError(SNAP_NAME) 69 | return _snap["version"] 70 | 71 | @property 72 | def installed(self) -> bool: 73 | _snap = self._get_snap() 74 | if not _snap: 75 | return False 76 | return True 77 | 78 | @staticmethod 79 | def get_ip_for_interface(interface): 80 | """Return the ip address associated to the given interface.""" 81 | addresses = netifaces.ifaddresses(interface) 82 | if netifaces.AF_INET not in addresses or "addr" not in addresses[netifaces.AF_INET][0]: 83 | raise Exception("No IP associated to requested device") 84 | return addresses[netifaces.AF_INET][0]["addr"] 85 | 86 | def configure(self, 87 | port: ops.Port, 88 | storage_config: str, 89 | listen_address: str, 90 | ingress_address: str, 91 | ): 92 | tenv = Environment(loader=FileSystemLoader("templates")) 93 | template = tenv.get_template("config.yaml.j2") 94 | rendered_content = template.render( 95 | { 96 | "listen_address": f"{listen_address}:{port.port}", 97 | "storage_config": storage_config, 98 | } 99 | ) 100 | AAR_CONFIG_PATH.write_text(rendered_content) 101 | self._setup_certs(ingress_address, ingress_address, listen_address) 102 | 103 | def _setup_certs(self, hostname: str, public_ip: str, private_ip: str): 104 | 105 | cert_base_path = os.path.dirname(AAR_CERT_BASE_PATH) 106 | if not os.path.exists(cert_base_path): 107 | os.makedirs(cert_base_path, mode=0o0700) 108 | 109 | os.makedirs(CLIENTS_CERT_PATH, 0o700, exist_ok=True) 110 | os.makedirs(PUBLISHERS_CERT_PATH, 0o700, exist_ok=True) 111 | 112 | if os.path.exists(AAR_SERVER_CERT_PATH) and os.path.exists(AAR_SERVER_KEY_PATH): 113 | return 114 | 115 | cert, key = self._generate_selfsigned_cert(hostname, public_ip, private_ip) 116 | 117 | with open(os.open(AAR_SERVER_CERT_PATH, os.O_CREAT | os.O_WRONLY, 0o600), "w") as f: 118 | f.write(str(cert, "UTF-8")) 119 | 120 | with open(os.open(AAR_SERVER_KEY_PATH, os.O_CREAT | os.O_WRONLY, 0o600), "w") as f: 121 | f.write(str(key, "UTF-8")) 122 | 123 | def _generate_selfsigned_cert(self, hostname, public_ip, private_ip) -> tuple[bytes, bytes]: 124 | if not hostname: 125 | raise Exception("A hostname is required") 126 | 127 | if not public_ip: 128 | raise Exception("A public IP is required") 129 | 130 | if not private_ip: 131 | raise Exception("A private IP is required") 132 | 133 | ca_key = generate_private_key(key_size=4096) 134 | ca_cert = generate_ca(ca_key, hostname) 135 | 136 | key = generate_private_key(key_size=4096) 137 | csr = generate_csr( 138 | private_key=key, 139 | subject=hostname, 140 | sans_dns=[public_ip, private_ip, hostname], 141 | sans_ip=[public_ip,private_ip] ) 142 | cert = generate_certificate(csr=csr, ca=ca_cert, ca_key=ca_key) 143 | return cert, key 144 | -------------------------------------------------------------------------------- /interfaces.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2023 Canonical Ltd. All rights reserved. 3 | # 4 | 5 | import json 6 | import os 7 | import ops 8 | import subprocess 9 | import shutil 10 | import logging 11 | from cryptography.x509.extensions import hashlib 12 | 13 | from constants import AAR_SERVER_CERT_PATH, CLIENTS_CERT_PATH, PUBLISHERS_CERT_PATH 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | class ClientRegisteredEvent(ops.EventBase): 18 | """Event emitted when a new client is registered""" 19 | 20 | class AAREndpointProviderEvents(ops.CharmEvents): 21 | client_registered = ops.EventSource(ClientRegisteredEvent) 22 | 23 | class AAREndpointProvider(ops.Object): 24 | on: ops.CharmEvents = AAREndpointProviderEvents() 25 | 26 | def __init__(self, charm: "AARCharm", relation_name: str): 27 | self._charm = charm 28 | events = self._charm.on[relation_name] 29 | self.framework.observe(events.relation_changed, self._on_aar_changed) 30 | self.framework.observe(events.relation_joined, self._on_aar_joined) 31 | 32 | def _on_aar_joined(self, event: ops.RelationJoinedEvent): 33 | with open(AAR_SERVER_CERT_PATH, "r") as f: 34 | cert = f.read() 35 | if not cert: 36 | self._charm.unit.status = ops.BlockedStatus("No registry certificate") 37 | return 38 | 39 | listen_address = self._charm.private_ip 40 | location = self._charm.config['location'] 41 | if location: 42 | listen_address = location 43 | 44 | unit_data = event.relation.data[self._charm.unit] 45 | unit_data["certificate"] = cert 46 | unit_data["fingerprint"] = hashlib.sha256(cert.encode("utf-8")).hexdigest() 47 | unit_data["ip"] = listen_address 48 | unit_data["port"] = str(self._charm.config['port']) 49 | 50 | def _on_aar_changed(self, event: ops.RelationChangedEvent): 51 | self._charm.unit.status = ops.MaintenanceStatus("Confguring new AAR Client") 52 | # remove certificates of previous clients to avoid conflicts for same 53 | # certificates 54 | self._remove_all_certificates() 55 | ams_clients = [] 56 | for unit in event.relation.units: 57 | cert = event.relation.data[unit].get("certificate") 58 | if not cert: 59 | continue 60 | 61 | # If there is a relation mismatch, we set an error status 62 | # ex: juju add-relation aar:client ams:publisher 63 | mode = event.relation.data[unit]["mode"].strip('"') 64 | if mode not in event.relation.name: 65 | self._charm.unit.status = ops.BlockedStatus( 66 | "Invalid relation {} to {}".format("arr-" + mode, event.relation.name) 67 | ) 68 | return 69 | ams_clients.append(event.relation.data[unit]) 70 | 71 | if not ams_clients: 72 | return 73 | for client in ams_clients: 74 | try: 75 | self._register_aar_client(client["certificate"], event.relation.name) 76 | except subprocess.CalledProcessError as ex: 77 | logger.error(f"failed to add client to aar: {ex.output}") 78 | self._charm.unit.status = ops.BlockedStatus('Failed to register client certificate') 79 | return 80 | except UnicodeError as ex: 81 | logger.error(f"failed to add client to aar: {ex}") 82 | self._charm.unit.status = ops.BlockedStatus('Failed to register client certificate: invalid certificate') 83 | return 84 | self.on.client_registered.emit() 85 | 86 | def _remove_all_certificates(self): 87 | """Remove all old certificates held by AAR. 88 | 89 | All client certificates are kept by juju in the relation until the hook 90 | fails, so every time a new client is registered, the previous certificates 91 | will be added again. To avoid duplicates and properly take into account 92 | units that departed, we remove all certificates and add the ones that 93 | are still active again. 94 | """ 95 | shutil.rmtree(CLIENTS_CERT_PATH) 96 | shutil.rmtree(PUBLISHERS_CERT_PATH) 97 | os.makedirs(CLIENTS_CERT_PATH, 0o700, exist_ok=True) 98 | os.makedirs(PUBLISHERS_CERT_PATH, 0o700, exist_ok=True) 99 | 100 | def _register_aar_client(self, client_certificate: str, mode: str): 101 | client_certificate = client_certificate.replace("\\n", "\n").strip('"') 102 | fp = hashlib.sha256(client_certificate.encode("utf-8")).hexdigest() 103 | output = subprocess.run(['/snap/bin/aar', 'trust', 'list', 104 | '--format=json'], stderr=subprocess.STDOUT, 105 | encoding='utf-8', errors='ignore').stdout 106 | data = json.loads(output) 107 | for d in data: 108 | # if the fingerprint to be added has existed in aar but associated 109 | # a different role, refresh the certificate with a requested role, 110 | # otherwise do not attempt to add same certificate to aar. 111 | if fp.startswith(d["Fingerprint"]): 112 | if d["Role"] != mode: 113 | subprocess.run(['/snap/bin/aar', 'trust', 'remove', 114 | d["Fingerprint"]], check=True) 115 | break 116 | else: 117 | logger.info('Client already exists with same mode, skipping client registration') 118 | return 119 | 120 | cmd = ["/snap/bin/aar", "trust", "add"] 121 | if mode == "publisher": 122 | cmd.append("--publisher") 123 | subprocess.run(cmd, stderr=subprocess.STDOUT, check=True, input=client_certificate.encode("utf-8")) 124 | -------------------------------------------------------------------------------- /lib/charms/operator_libs_linux/v2/snap.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Canonical Ltd. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Representations of the system's Snaps, and abstractions around managing them. 16 | 17 | The `snap` module provides convenience methods for listing, installing, refreshing, and removing 18 | Snap packages, in addition to setting and getting configuration options for them. 19 | 20 | In the `snap` module, `SnapCache` creates a dict-like mapping of `Snap` objects at when 21 | instantiated. Installed snaps are fully populated, and available snaps are lazily-loaded upon 22 | request. This module relies on an installed and running `snapd` daemon to perform operations over 23 | the `snapd` HTTP API. 24 | 25 | `SnapCache` objects can be used to install or modify Snap packages by name in a manner similar to 26 | using the `snap` command from the commandline. 27 | 28 | An example of adding Juju to the system with `SnapCache` and setting a config value: 29 | 30 | ```python 31 | try: 32 | cache = snap.SnapCache() 33 | juju = cache["juju"] 34 | 35 | if not juju.present: 36 | juju.ensure(snap.SnapState.Latest, channel="beta") 37 | juju.set({"some.key": "value", "some.key2": "value2"}) 38 | except snap.SnapError as e: 39 | logger.error("An exception occurred when installing charmcraft. Reason: %s", e.message) 40 | ``` 41 | 42 | In addition, the `snap` module provides "bare" methods which can act on Snap packages as 43 | simple function calls. :meth:`add`, :meth:`remove`, and :meth:`ensure` are provided, as 44 | well as :meth:`add_local` for installing directly from a local `.snap` file. These return 45 | `Snap` objects. 46 | 47 | As an example of installing several Snaps and checking details: 48 | 49 | ```python 50 | try: 51 | nextcloud, charmcraft = snap.add(["nextcloud", "charmcraft"]) 52 | if nextcloud.get("mode") != "production": 53 | nextcloud.set({"mode": "production"}) 54 | except snap.SnapError as e: 55 | logger.error("An exception occurred when installing snaps. Reason: %s" % e.message) 56 | ``` 57 | """ 58 | 59 | import http.client 60 | import json 61 | import logging 62 | import os 63 | import re 64 | import socket 65 | import subprocess 66 | import sys 67 | import urllib.error 68 | import urllib.parse 69 | import urllib.request 70 | from collections.abc import Mapping 71 | from datetime import datetime, timedelta, timezone 72 | from enum import Enum 73 | from subprocess import CalledProcessError, CompletedProcess 74 | from typing import Any, Dict, Iterable, List, Optional, Union 75 | 76 | logger = logging.getLogger(__name__) 77 | 78 | # The unique Charmhub library identifier, never change it 79 | LIBID = "05394e5893f94f2d90feb7cbe6b633cd" 80 | 81 | # Increment this major API version when introducing breaking changes 82 | LIBAPI = 2 83 | 84 | # Increment this PATCH version before using `charmcraft publish-lib` or reset 85 | # to 0 if you are raising the major API version 86 | LIBPATCH = 3 87 | 88 | 89 | # Regex to locate 7-bit C1 ANSI sequences 90 | ansi_filter = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])") 91 | 92 | 93 | def _cache_init(func): 94 | def inner(*args, **kwargs): 95 | if _Cache.cache is None: 96 | _Cache.cache = SnapCache() 97 | return func(*args, **kwargs) 98 | 99 | return inner 100 | 101 | 102 | # recursive hints seems to error out pytest 103 | JSONType = Union[Dict[str, Any], List[Any], str, int, float] 104 | 105 | 106 | class SnapService: 107 | """Data wrapper for snap services.""" 108 | 109 | def __init__( 110 | self, 111 | daemon: Optional[str] = None, 112 | daemon_scope: Optional[str] = None, 113 | enabled: bool = False, 114 | active: bool = False, 115 | activators: List[str] = [], 116 | **kwargs, 117 | ): 118 | self.daemon = daemon 119 | self.daemon_scope = kwargs.get("daemon-scope", None) or daemon_scope 120 | self.enabled = enabled 121 | self.active = active 122 | self.activators = activators 123 | 124 | def as_dict(self) -> Dict: 125 | """Return instance representation as dict.""" 126 | return { 127 | "daemon": self.daemon, 128 | "daemon_scope": self.daemon_scope, 129 | "enabled": self.enabled, 130 | "active": self.active, 131 | "activators": self.activators, 132 | } 133 | 134 | 135 | class MetaCache(type): 136 | """MetaCache class used for initialising the snap cache.""" 137 | 138 | @property 139 | def cache(cls) -> "SnapCache": 140 | """Property for returning the snap cache.""" 141 | return cls._cache 142 | 143 | @cache.setter 144 | def cache(cls, cache: "SnapCache") -> None: 145 | """Setter for the snap cache.""" 146 | cls._cache = cache 147 | 148 | def __getitem__(cls, name) -> "Snap": 149 | """Snap cache getter.""" 150 | return cls._cache[name] 151 | 152 | 153 | class _Cache(object, metaclass=MetaCache): 154 | _cache = None 155 | 156 | 157 | class Error(Exception): 158 | """Base class of most errors raised by this library.""" 159 | 160 | def __repr__(self): 161 | """Represent the Error class.""" 162 | return "<{}.{} {}>".format(type(self).__module__, type(self).__name__, self.args) 163 | 164 | @property 165 | def name(self): 166 | """Return a string representation of the model plus class.""" 167 | return "<{}.{}>".format(type(self).__module__, type(self).__name__) 168 | 169 | @property 170 | def message(self): 171 | """Return the message passed as an argument.""" 172 | return self.args[0] 173 | 174 | 175 | class SnapAPIError(Error): 176 | """Raised when an HTTP API error occurs talking to the Snapd server.""" 177 | 178 | def __init__(self, body: Dict, code: int, status: str, message: str): 179 | super().__init__(message) # Makes str(e) return message 180 | self.body = body 181 | self.code = code 182 | self.status = status 183 | self._message = message 184 | 185 | def __repr__(self): 186 | """Represent the SnapAPIError class.""" 187 | return "APIError({!r}, {!r}, {!r}, {!r})".format( 188 | self.body, self.code, self.status, self._message 189 | ) 190 | 191 | 192 | class SnapState(Enum): 193 | """The state of a snap on the system or in the cache.""" 194 | 195 | Present = "present" 196 | Absent = "absent" 197 | Latest = "latest" 198 | Available = "available" 199 | 200 | 201 | class SnapError(Error): 202 | """Raised when there's an error running snap control commands.""" 203 | 204 | 205 | class SnapNotFoundError(Error): 206 | """Raised when a requested snap is not known to the system.""" 207 | 208 | 209 | class Snap(object): 210 | """Represents a snap package and its properties. 211 | 212 | `Snap` exposes the following properties about a snap: 213 | - name: the name of the snap 214 | - state: a `SnapState` representation of its install status 215 | - channel: "stable", "candidate", "beta", and "edge" are common 216 | - revision: a string representing the snap's revision 217 | - confinement: "classic" or "strict" 218 | """ 219 | 220 | def __init__( 221 | self, 222 | name, 223 | state: SnapState, 224 | channel: str, 225 | revision: str, 226 | confinement: str, 227 | apps: Optional[List[Dict[str, str]]] = None, 228 | cohort: Optional[str] = "", 229 | ) -> None: 230 | self._name = name 231 | self._state = state 232 | self._channel = channel 233 | self._revision = revision 234 | self._confinement = confinement 235 | self._cohort = cohort 236 | self._apps = apps or [] 237 | self._snap_client = SnapClient() 238 | 239 | def __eq__(self, other) -> bool: 240 | """Equality for comparison.""" 241 | return isinstance(other, self.__class__) and ( 242 | self._name, 243 | self._revision, 244 | ) == (other._name, other._revision) 245 | 246 | def __hash__(self): 247 | """Calculate a hash for this snap.""" 248 | return hash((self._name, self._revision)) 249 | 250 | def __repr__(self): 251 | """Represent the object such that it can be reconstructed.""" 252 | return "<{}.{}: {}>".format(self.__module__, self.__class__.__name__, self.__dict__) 253 | 254 | def __str__(self): 255 | """Represent the snap object as a string.""" 256 | return "<{}: {}-{}.{} -- {}>".format( 257 | self.__class__.__name__, 258 | self._name, 259 | self._revision, 260 | self._channel, 261 | str(self._state), 262 | ) 263 | 264 | def _snap(self, command: str, optargs: Optional[Iterable[str]] = None) -> str: 265 | """Perform a snap operation. 266 | 267 | Args: 268 | command: the snap command to execute 269 | optargs: an (optional) list of additional arguments to pass, 270 | commonly confinement or channel 271 | 272 | Raises: 273 | SnapError if there is a problem encountered 274 | """ 275 | optargs = optargs or [] 276 | args = ["snap", command, self._name, *optargs] 277 | try: 278 | return subprocess.check_output(args, universal_newlines=True) 279 | except CalledProcessError as e: 280 | raise SnapError( 281 | "Snap: {!r}; command {!r} failed with output = {!r}".format( 282 | self._name, args, e.output 283 | ) 284 | ) 285 | 286 | def _snap_daemons( 287 | self, 288 | command: List[str], 289 | services: Optional[List[str]] = None, 290 | ) -> CompletedProcess: 291 | """Perform snap app commands. 292 | 293 | Args: 294 | command: the snap command to execute 295 | services: the snap service to execute command on 296 | 297 | Raises: 298 | SnapError if there is a problem encountered 299 | """ 300 | if services: 301 | # an attempt to keep the command constrained to the snap instance's services 302 | services = ["{}.{}".format(self._name, service) for service in services] 303 | else: 304 | services = [self._name] 305 | 306 | args = ["snap", *command, *services] 307 | 308 | try: 309 | return subprocess.run(args, universal_newlines=True, check=True, capture_output=True) 310 | except CalledProcessError as e: 311 | raise SnapError("Could not {} for snap [{}]: {}".format(args, self._name, e.stderr)) 312 | 313 | def get(self, key: Optional[str], *, typed: bool = False) -> Any: 314 | """Fetch snap configuration values. 315 | 316 | Args: 317 | key: the key to retrieve. Default to retrieve all values for typed=True. 318 | typed: set to True to retrieve typed values (set with typed=True). 319 | Default is to return a string. 320 | """ 321 | if typed: 322 | config = json.loads(self._snap("get", ["-d", key])) 323 | if key: 324 | return config.get(key) 325 | return config 326 | 327 | if not key: 328 | raise TypeError("Key must be provided when typed=False") 329 | 330 | return self._snap("get", [key]).strip() 331 | 332 | def set(self, config: Dict[str, Any], *, typed: bool = False) -> str: 333 | """Set a snap configuration value. 334 | 335 | Args: 336 | config: a dictionary containing keys and values specifying the config to set. 337 | typed: set to True to convert all values in the config into typed values while 338 | configuring the snap (set with typed=True). Default is not to convert. 339 | """ 340 | if typed: 341 | kv = [f"{key}={json.dumps(val)}" for key, val in config.items()] 342 | return self._snap("set", ["-t"] + kv) 343 | 344 | return self._snap("set", [f"{key}={val}" for key, val in config.items()]) 345 | 346 | def unset(self, key) -> str: 347 | """Unset a snap configuration value. 348 | 349 | Args: 350 | key: the key to unset 351 | """ 352 | return self._snap("unset", [key]) 353 | 354 | def start(self, services: Optional[List[str]] = None, enable: Optional[bool] = False) -> None: 355 | """Start a snap's services. 356 | 357 | Args: 358 | services (list): (optional) list of individual snap services to start (otherwise all) 359 | enable (bool): (optional) flag to enable snap services on start. Default `false` 360 | """ 361 | args = ["start", "--enable"] if enable else ["start"] 362 | self._snap_daemons(args, services) 363 | 364 | def stop(self, services: Optional[List[str]] = None, disable: Optional[bool] = False) -> None: 365 | """Stop a snap's services. 366 | 367 | Args: 368 | services (list): (optional) list of individual snap services to stop (otherwise all) 369 | disable (bool): (optional) flag to disable snap services on stop. Default `False` 370 | """ 371 | args = ["stop", "--disable"] if disable else ["stop"] 372 | self._snap_daemons(args, services) 373 | 374 | def logs(self, services: Optional[List[str]] = None, num_lines: Optional[int] = 10) -> str: 375 | """Fetch a snap services' logs. 376 | 377 | Args: 378 | services (list): (optional) list of individual snap services to show logs from 379 | (otherwise all) 380 | num_lines (int): (optional) integer number of log lines to return. Default `10` 381 | """ 382 | args = ["logs", "-n={}".format(num_lines)] if num_lines else ["logs"] 383 | return self._snap_daemons(args, services).stdout 384 | 385 | def connect( 386 | self, plug: str, service: Optional[str] = None, slot: Optional[str] = None 387 | ) -> None: 388 | """Connect a plug to a slot. 389 | 390 | Args: 391 | plug (str): the plug to connect 392 | service (str): (optional) the snap service name to plug into 393 | slot (str): (optional) the snap service slot to plug in to 394 | 395 | Raises: 396 | SnapError if there is a problem encountered 397 | """ 398 | command = ["connect", "{}:{}".format(self._name, plug)] 399 | 400 | if service and slot: 401 | command = command + ["{}:{}".format(service, slot)] 402 | elif slot: 403 | command = command + [slot] 404 | 405 | args = ["snap", *command] 406 | try: 407 | subprocess.run(args, universal_newlines=True, check=True, capture_output=True) 408 | except CalledProcessError as e: 409 | raise SnapError("Could not {} for snap [{}]: {}".format(args, self._name, e.stderr)) 410 | 411 | def hold(self, duration: Optional[timedelta] = None) -> None: 412 | """Add a refresh hold to a snap. 413 | 414 | Args: 415 | duration: duration for the hold, or None (the default) to hold this snap indefinitely. 416 | """ 417 | hold_str = "forever" 418 | if duration is not None: 419 | seconds = round(duration.total_seconds()) 420 | hold_str = f"{seconds}s" 421 | self._snap("refresh", [f"--hold={hold_str}"]) 422 | 423 | def unhold(self) -> None: 424 | """Remove the refresh hold of a snap.""" 425 | self._snap("refresh", ["--unhold"]) 426 | 427 | def alias(self, application: str, alias: Optional[str] = None) -> None: 428 | """Create an alias for a given application. 429 | 430 | Args: 431 | application: application to get an alias. 432 | alias: (optional) name of the alias; if not provided, the application name is used. 433 | """ 434 | if alias is None: 435 | alias = application 436 | args = ["snap", "alias", f"{self.name}.{application}", alias] 437 | try: 438 | subprocess.check_output(args, universal_newlines=True) 439 | except CalledProcessError as e: 440 | raise SnapError( 441 | "Snap: {!r}; command {!r} failed with output = {!r}".format( 442 | self._name, args, e.output 443 | ) 444 | ) 445 | 446 | def restart( 447 | self, services: Optional[List[str]] = None, reload: Optional[bool] = False 448 | ) -> None: 449 | """Restarts a snap's services. 450 | 451 | Args: 452 | services (list): (optional) list of individual snap services to restart. 453 | (otherwise all) 454 | reload (bool): (optional) flag to use the service reload command, if available. 455 | Default `False` 456 | """ 457 | args = ["restart", "--reload"] if reload else ["restart"] 458 | self._snap_daemons(args, services) 459 | 460 | def _install( 461 | self, 462 | channel: Optional[str] = "", 463 | cohort: Optional[str] = "", 464 | revision: Optional[str] = None, 465 | ) -> None: 466 | """Add a snap to the system. 467 | 468 | Args: 469 | channel: the channel to install from 470 | cohort: optional, the key of a cohort that this snap belongs to 471 | revision: optional, the revision of the snap to install 472 | """ 473 | cohort = cohort or self._cohort 474 | 475 | args = [] 476 | if self.confinement == "classic": 477 | args.append("--classic") 478 | if channel: 479 | args.append('--channel="{}"'.format(channel)) 480 | if revision: 481 | args.append('--revision="{}"'.format(revision)) 482 | if cohort: 483 | args.append('--cohort="{}"'.format(cohort)) 484 | 485 | self._snap("install", args) 486 | 487 | def _refresh( 488 | self, 489 | channel: Optional[str] = "", 490 | cohort: Optional[str] = "", 491 | revision: Optional[str] = None, 492 | leave_cohort: Optional[bool] = False, 493 | ) -> None: 494 | """Refresh a snap. 495 | 496 | Args: 497 | channel: the channel to install from 498 | cohort: optionally, specify a cohort. 499 | revision: optionally, specify the revision of the snap to refresh 500 | leave_cohort: leave the current cohort. 501 | """ 502 | args = [] 503 | if channel: 504 | args.append('--channel="{}"'.format(channel)) 505 | 506 | if revision: 507 | args.append('--revision="{}"'.format(revision)) 508 | 509 | if not cohort: 510 | cohort = self._cohort 511 | 512 | if leave_cohort: 513 | self._cohort = "" 514 | args.append("--leave-cohort") 515 | elif cohort: 516 | args.append('--cohort="{}"'.format(cohort)) 517 | 518 | self._snap("refresh", args) 519 | 520 | def _remove(self) -> str: 521 | """Remove a snap from the system.""" 522 | return self._snap("remove") 523 | 524 | @property 525 | def name(self) -> str: 526 | """Returns the name of the snap.""" 527 | return self._name 528 | 529 | def ensure( 530 | self, 531 | state: SnapState, 532 | classic: Optional[bool] = False, 533 | channel: Optional[str] = "", 534 | cohort: Optional[str] = "", 535 | revision: Optional[str] = None, 536 | ): 537 | """Ensure that a snap is in a given state. 538 | 539 | Args: 540 | state: a `SnapState` to reconcile to. 541 | classic: an (Optional) boolean indicating whether classic confinement should be used 542 | channel: the channel to install from 543 | cohort: optional. Specify the key of a snap cohort. 544 | revision: optional. the revision of the snap to install/refresh 545 | 546 | While both channel and revision could be specified, the underlying snap install/refresh 547 | command will determine which one takes precedence (revision at this time) 548 | 549 | Raises: 550 | SnapError if an error is encountered 551 | """ 552 | self._confinement = "classic" if classic or self._confinement == "classic" else "" 553 | 554 | if state not in (SnapState.Present, SnapState.Latest): 555 | # We are attempting to remove this snap. 556 | if self._state in (SnapState.Present, SnapState.Latest): 557 | # The snap is installed, so we run _remove. 558 | self._remove() 559 | else: 560 | # The snap is not installed -- no need to do anything. 561 | pass 562 | else: 563 | # We are installing or refreshing a snap. 564 | if self._state not in (SnapState.Present, SnapState.Latest): 565 | # The snap is not installed, so we install it. 566 | self._install(channel, cohort, revision) 567 | else: 568 | # The snap is installed, but we are changing it (e.g., switching channels). 569 | self._refresh(channel, cohort, revision) 570 | 571 | self._update_snap_apps() 572 | self._state = state 573 | 574 | def _update_snap_apps(self) -> None: 575 | """Update a snap's apps after snap changes state.""" 576 | try: 577 | self._apps = self._snap_client.get_installed_snap_apps(self._name) 578 | except SnapAPIError: 579 | logger.debug("Unable to retrieve snap apps for {}".format(self._name)) 580 | self._apps = [] 581 | 582 | @property 583 | def present(self) -> bool: 584 | """Report whether or not a snap is present.""" 585 | return self._state in (SnapState.Present, SnapState.Latest) 586 | 587 | @property 588 | def latest(self) -> bool: 589 | """Report whether the snap is the most recent version.""" 590 | return self._state is SnapState.Latest 591 | 592 | @property 593 | def state(self) -> SnapState: 594 | """Report the current snap state.""" 595 | return self._state 596 | 597 | @state.setter 598 | def state(self, state: SnapState) -> None: 599 | """Set the snap state to a given value. 600 | 601 | Args: 602 | state: a `SnapState` to reconcile the snap to. 603 | 604 | Raises: 605 | SnapError if an error is encountered 606 | """ 607 | if self._state is not state: 608 | self.ensure(state) 609 | self._state = state 610 | 611 | @property 612 | def revision(self) -> str: 613 | """Returns the revision for a snap.""" 614 | return self._revision 615 | 616 | @property 617 | def channel(self) -> str: 618 | """Returns the channel for a snap.""" 619 | return self._channel 620 | 621 | @property 622 | def confinement(self) -> str: 623 | """Returns the confinement for a snap.""" 624 | return self._confinement 625 | 626 | @property 627 | def apps(self) -> List: 628 | """Returns (if any) the installed apps of the snap.""" 629 | self._update_snap_apps() 630 | return self._apps 631 | 632 | @property 633 | def services(self) -> Dict: 634 | """Returns (if any) the installed services of the snap.""" 635 | self._update_snap_apps() 636 | services = {} 637 | for app in self._apps: 638 | if "daemon" in app: 639 | services[app["name"]] = SnapService(**app).as_dict() 640 | 641 | return services 642 | 643 | @property 644 | def held(self) -> bool: 645 | """Report whether the snap has a hold.""" 646 | info = self._snap("info") 647 | return "hold:" in info 648 | 649 | 650 | class _UnixSocketConnection(http.client.HTTPConnection): 651 | """Implementation of HTTPConnection that connects to a named Unix socket.""" 652 | 653 | def __init__(self, host, timeout=None, socket_path=None): 654 | if timeout is None: 655 | super().__init__(host) 656 | else: 657 | super().__init__(host, timeout=timeout) 658 | self.socket_path = socket_path 659 | 660 | def connect(self): 661 | """Override connect to use Unix socket (instead of TCP socket).""" 662 | if not hasattr(socket, "AF_UNIX"): 663 | raise NotImplementedError("Unix sockets not supported on {}".format(sys.platform)) 664 | self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) 665 | self.sock.connect(self.socket_path) 666 | if self.timeout is not None: 667 | self.sock.settimeout(self.timeout) 668 | 669 | 670 | class _UnixSocketHandler(urllib.request.AbstractHTTPHandler): 671 | """Implementation of HTTPHandler that uses a named Unix socket.""" 672 | 673 | def __init__(self, socket_path: str): 674 | super().__init__() 675 | self.socket_path = socket_path 676 | 677 | def http_open(self, req) -> http.client.HTTPResponse: 678 | """Override http_open to use a Unix socket connection (instead of TCP).""" 679 | return self.do_open(_UnixSocketConnection, req, socket_path=self.socket_path) 680 | 681 | 682 | class SnapClient: 683 | """Snapd API client to talk to HTTP over UNIX sockets. 684 | 685 | In order to avoid shelling out and/or involving sudo in calling the snapd API, 686 | use a wrapper based on the Pebble Client, trimmed down to only the utility methods 687 | needed for talking to snapd. 688 | """ 689 | 690 | def __init__( 691 | self, 692 | socket_path: str = "/run/snapd.socket", 693 | opener: Optional[urllib.request.OpenerDirector] = None, 694 | base_url: str = "http://localhost/v2/", 695 | timeout: float = 30.0, 696 | ): 697 | """Initialize a client instance. 698 | 699 | Args: 700 | socket_path: a path to the socket on the filesystem. Defaults to /run/snap/snapd.socket 701 | opener: specifies an opener for unix socket, if unspecified a default is used 702 | base_url: base url for making requests to the snap client. Defaults to 703 | http://localhost/v2/ 704 | timeout: timeout in seconds to use when making requests to the API. Default is 30.0s. 705 | """ 706 | if opener is None: 707 | opener = self._get_default_opener(socket_path) 708 | self.opener = opener 709 | self.base_url = base_url 710 | self.timeout = timeout 711 | 712 | @classmethod 713 | def _get_default_opener(cls, socket_path): 714 | """Build the default opener to use for requests (HTTP over Unix socket).""" 715 | opener = urllib.request.OpenerDirector() 716 | opener.add_handler(_UnixSocketHandler(socket_path)) 717 | opener.add_handler(urllib.request.HTTPDefaultErrorHandler()) 718 | opener.add_handler(urllib.request.HTTPRedirectHandler()) 719 | opener.add_handler(urllib.request.HTTPErrorProcessor()) 720 | return opener 721 | 722 | def _request( 723 | self, 724 | method: str, 725 | path: str, 726 | query: Dict = None, 727 | body: Dict = None, 728 | ) -> JSONType: 729 | """Make a JSON request to the Snapd server with the given HTTP method and path. 730 | 731 | If query dict is provided, it is encoded and appended as a query string 732 | to the URL. If body dict is provided, it is serialied as JSON and used 733 | as the HTTP body (with Content-Type: "application/json"). The resulting 734 | body is decoded from JSON. 735 | """ 736 | headers = {"Accept": "application/json"} 737 | data = None 738 | if body is not None: 739 | data = json.dumps(body).encode("utf-8") 740 | headers["Content-Type"] = "application/json" 741 | 742 | response = self._request_raw(method, path, query, headers, data) 743 | return json.loads(response.read().decode())["result"] 744 | 745 | def _request_raw( 746 | self, 747 | method: str, 748 | path: str, 749 | query: Dict = None, 750 | headers: Dict = None, 751 | data: bytes = None, 752 | ) -> http.client.HTTPResponse: 753 | """Make a request to the Snapd server; return the raw HTTPResponse object.""" 754 | url = self.base_url + path 755 | if query: 756 | url = url + "?" + urllib.parse.urlencode(query) 757 | 758 | if headers is None: 759 | headers = {} 760 | request = urllib.request.Request(url, method=method, data=data, headers=headers) 761 | 762 | try: 763 | response = self.opener.open(request, timeout=self.timeout) 764 | except urllib.error.HTTPError as e: 765 | code = e.code 766 | status = e.reason 767 | message = "" 768 | try: 769 | body = json.loads(e.read().decode())["result"] 770 | except (IOError, ValueError, KeyError) as e2: 771 | # Will only happen on read error or if Pebble sends invalid JSON. 772 | body = {} 773 | message = "{} - {}".format(type(e2).__name__, e2) 774 | raise SnapAPIError(body, code, status, message) 775 | except urllib.error.URLError as e: 776 | raise SnapAPIError({}, 500, "Not found", e.reason) 777 | return response 778 | 779 | def get_installed_snaps(self) -> Dict: 780 | """Get information about currently installed snaps.""" 781 | return self._request("GET", "snaps") 782 | 783 | def get_snap_information(self, name: str) -> Dict: 784 | """Query the snap server for information about single snap.""" 785 | return self._request("GET", "find", {"name": name})[0] 786 | 787 | def get_installed_snap_apps(self, name: str) -> List: 788 | """Query the snap server for apps belonging to a named, currently installed snap.""" 789 | return self._request("GET", "apps", {"names": name, "select": "service"}) 790 | 791 | 792 | class SnapCache(Mapping): 793 | """An abstraction to represent installed/available packages. 794 | 795 | When instantiated, `SnapCache` iterates through the list of installed 796 | snaps using the `snapd` HTTP API, and a list of available snaps by reading 797 | the filesystem to populate the cache. Information about available snaps is lazily-loaded 798 | from the `snapd` API when requested. 799 | """ 800 | 801 | def __init__(self): 802 | if not self.snapd_installed: 803 | raise SnapError("snapd is not installed or not in /usr/bin") from None 804 | self._snap_client = SnapClient() 805 | self._snap_map = {} 806 | if self.snapd_installed: 807 | self._load_available_snaps() 808 | self._load_installed_snaps() 809 | 810 | def __contains__(self, key: str) -> bool: 811 | """Check if a given snap is in the cache.""" 812 | return key in self._snap_map 813 | 814 | def __len__(self) -> int: 815 | """Report number of items in the snap cache.""" 816 | return len(self._snap_map) 817 | 818 | def __iter__(self) -> Iterable["Snap"]: 819 | """Provide iterator for the snap cache.""" 820 | return iter(self._snap_map.values()) 821 | 822 | def __getitem__(self, snap_name: str) -> Snap: 823 | """Return either the installed version or latest version for a given snap.""" 824 | snap = self._snap_map.get(snap_name, None) 825 | if snap is None: 826 | # The snapd cache file may not have existed when _snap_map was 827 | # populated. This is normal. 828 | try: 829 | self._snap_map[snap_name] = self._load_info(snap_name) 830 | except SnapAPIError: 831 | raise SnapNotFoundError("Snap '{}' not found!".format(snap_name)) 832 | 833 | return self._snap_map[snap_name] 834 | 835 | @property 836 | def snapd_installed(self) -> bool: 837 | """Check whether snapd has been installled on the system.""" 838 | return os.path.isfile("/usr/bin/snap") 839 | 840 | def _load_available_snaps(self) -> None: 841 | """Load the list of available snaps from disk. 842 | 843 | Leave them empty and lazily load later if asked for. 844 | """ 845 | if not os.path.isfile("/var/cache/snapd/names"): 846 | # The snap catalog may not be populated yet; this is normal. 847 | # snapd updates the cache infrequently and the cache file may not 848 | # currently exist. 849 | return 850 | 851 | with open("/var/cache/snapd/names", "r") as f: 852 | for line in f: 853 | if line.strip(): 854 | self._snap_map[line.strip()] = None 855 | 856 | def _load_installed_snaps(self) -> None: 857 | """Load the installed snaps into the dict.""" 858 | installed = self._snap_client.get_installed_snaps() 859 | 860 | for i in installed: 861 | snap = Snap( 862 | name=i["name"], 863 | state=SnapState.Latest, 864 | channel=i["channel"], 865 | revision=i["revision"], 866 | confinement=i["confinement"], 867 | apps=i.get("apps", None), 868 | ) 869 | self._snap_map[snap.name] = snap 870 | 871 | def _load_info(self, name) -> Snap: 872 | """Load info for snaps which are not installed if requested. 873 | 874 | Args: 875 | name: a string representing the name of the snap 876 | """ 877 | info = self._snap_client.get_snap_information(name) 878 | 879 | return Snap( 880 | name=info["name"], 881 | state=SnapState.Available, 882 | channel=info["channel"], 883 | revision=info["revision"], 884 | confinement=info["confinement"], 885 | apps=None, 886 | ) 887 | 888 | 889 | @_cache_init 890 | def add( 891 | snap_names: Union[str, List[str]], 892 | state: Union[str, SnapState] = SnapState.Latest, 893 | channel: Optional[str] = "", 894 | classic: Optional[bool] = False, 895 | cohort: Optional[str] = "", 896 | revision: Optional[str] = None, 897 | ) -> Union[Snap, List[Snap]]: 898 | """Add a snap to the system. 899 | 900 | Args: 901 | snap_names: the name or names of the snaps to install 902 | state: a string or `SnapState` representation of the desired state, one of 903 | [`Present` or `Latest`] 904 | channel: an (Optional) channel as a string. Defaults to 'latest' 905 | classic: an (Optional) boolean specifying whether it should be added with classic 906 | confinement. Default `False` 907 | cohort: an (Optional) string specifying the snap cohort to use 908 | revision: an (Optional) string specifying the snap revision to use 909 | 910 | Raises: 911 | SnapError if some snaps failed to install or were not found. 912 | """ 913 | if not channel and not revision: 914 | channel = "latest" 915 | 916 | snap_names = [snap_names] if isinstance(snap_names, str) else snap_names 917 | if not snap_names: 918 | raise TypeError("Expected at least one snap to add, received zero!") 919 | 920 | if isinstance(state, str): 921 | state = SnapState(state) 922 | 923 | return _wrap_snap_operations(snap_names, state, channel, classic, cohort, revision) 924 | 925 | 926 | @_cache_init 927 | def remove(snap_names: Union[str, List[str]]) -> Union[Snap, List[Snap]]: 928 | """Remove specified snap(s) from the system. 929 | 930 | Args: 931 | snap_names: the name or names of the snaps to install 932 | 933 | Raises: 934 | SnapError if some snaps failed to install. 935 | """ 936 | snap_names = [snap_names] if isinstance(snap_names, str) else snap_names 937 | if not snap_names: 938 | raise TypeError("Expected at least one snap to add, received zero!") 939 | 940 | return _wrap_snap_operations(snap_names, SnapState.Absent, "", False) 941 | 942 | 943 | @_cache_init 944 | def ensure( 945 | snap_names: Union[str, List[str]], 946 | state: str, 947 | channel: Optional[str] = "", 948 | classic: Optional[bool] = False, 949 | cohort: Optional[str] = "", 950 | revision: Optional[int] = None, 951 | ) -> Union[Snap, List[Snap]]: 952 | """Ensure specified snaps are in a given state on the system. 953 | 954 | Args: 955 | snap_names: the name(s) of the snaps to operate on 956 | state: a string representation of the desired state, from `SnapState` 957 | channel: an (Optional) channel as a string. Defaults to 'latest' 958 | classic: an (Optional) boolean specifying whether it should be added with classic 959 | confinement. Default `False` 960 | cohort: an (Optional) string specifying the snap cohort to use 961 | revision: an (Optional) integer specifying the snap revision to use 962 | 963 | When both channel and revision are specified, the underlying snap install/refresh 964 | command will determine the precedence (revision at the time of adding this) 965 | 966 | Raises: 967 | SnapError if the snap is not in the cache. 968 | """ 969 | if not revision and not channel: 970 | channel = "latest" 971 | 972 | if state in ("present", "latest") or revision: 973 | return add(snap_names, SnapState(state), channel, classic, cohort, revision) 974 | else: 975 | return remove(snap_names) 976 | 977 | 978 | def _wrap_snap_operations( 979 | snap_names: List[str], 980 | state: SnapState, 981 | channel: str, 982 | classic: bool, 983 | cohort: Optional[str] = "", 984 | revision: Optional[str] = None, 985 | ) -> Union[Snap, List[Snap]]: 986 | """Wrap common operations for bare commands.""" 987 | snaps = {"success": [], "failed": []} 988 | 989 | op = "remove" if state is SnapState.Absent else "install or refresh" 990 | 991 | for s in snap_names: 992 | try: 993 | snap = _Cache[s] 994 | if state is SnapState.Absent: 995 | snap.ensure(state=SnapState.Absent) 996 | else: 997 | snap.ensure( 998 | state=state, classic=classic, channel=channel, cohort=cohort, revision=revision 999 | ) 1000 | snaps["success"].append(snap) 1001 | except SnapError as e: 1002 | logger.warning("Failed to {} snap {}: {}!".format(op, s, e.message)) 1003 | snaps["failed"].append(s) 1004 | except SnapNotFoundError: 1005 | logger.warning("Snap '{}' not found in cache!".format(s)) 1006 | snaps["failed"].append(s) 1007 | 1008 | if len(snaps["failed"]): 1009 | raise SnapError( 1010 | "Failed to install or refresh snap(s): {}".format(", ".join(list(snaps["failed"]))) 1011 | ) 1012 | 1013 | return snaps["success"] if len(snaps["success"]) > 1 else snaps["success"][0] 1014 | 1015 | 1016 | def install_local( 1017 | filename: str, classic: Optional[bool] = False, dangerous: Optional[bool] = False 1018 | ) -> Snap: 1019 | """Perform a snap operation. 1020 | 1021 | Args: 1022 | filename: the path to a local .snap file to install 1023 | classic: whether to use classic confinement 1024 | dangerous: whether --dangerous should be passed to install snaps without a signature 1025 | 1026 | Raises: 1027 | SnapError if there is a problem encountered 1028 | """ 1029 | args = [ 1030 | "snap", 1031 | "install", 1032 | filename, 1033 | ] 1034 | if classic: 1035 | args.append("--classic") 1036 | if dangerous: 1037 | args.append("--dangerous") 1038 | try: 1039 | result = subprocess.check_output(args, universal_newlines=True).splitlines()[-1] 1040 | snap_name, _ = result.split(" ", 1) 1041 | snap_name = ansi_filter.sub("", snap_name) 1042 | 1043 | c = SnapCache() 1044 | 1045 | try: 1046 | return c[snap_name] 1047 | except SnapAPIError as e: 1048 | logger.error( 1049 | "Could not find snap {} when querying Snapd socket: {}".format(snap_name, e.body) 1050 | ) 1051 | raise SnapError("Failed to find snap {} in Snap cache".format(snap_name)) 1052 | except CalledProcessError as e: 1053 | raise SnapError("Could not install snap {}: {}".format(filename, e.output)) 1054 | 1055 | 1056 | def _system_set(config_item: str, value: str) -> None: 1057 | """Set system snapd config values. 1058 | 1059 | Args: 1060 | config_item: name of snap system setting. E.g. 'refresh.hold' 1061 | value: value to assign 1062 | """ 1063 | args = ["snap", "set", "system", "{}={}".format(config_item, value)] 1064 | try: 1065 | subprocess.check_call(args, universal_newlines=True) 1066 | except CalledProcessError: 1067 | raise SnapError("Failed setting system config '{}' to '{}'".format(config_item, value)) 1068 | 1069 | 1070 | def hold_refresh(days: int = 90, forever: bool = False) -> bool: 1071 | """Set the system-wide snap refresh hold. 1072 | 1073 | Args: 1074 | days: number of days to hold system refreshes for. Maximum 90. Set to zero to remove hold. 1075 | forever: if True, will set a hold forever. 1076 | """ 1077 | if not isinstance(forever, bool): 1078 | raise TypeError("forever must be a bool") 1079 | if not isinstance(days, int): 1080 | raise TypeError("days must be an int") 1081 | if forever: 1082 | _system_set("refresh.hold", "forever") 1083 | logger.info("Set system-wide snap refresh hold to: forever") 1084 | elif days == 0: 1085 | _system_set("refresh.hold", "") 1086 | logger.info("Removed system-wide snap refresh hold") 1087 | else: 1088 | # Currently the snap daemon can only hold for a maximum of 90 days 1089 | if not 1 <= days <= 90: 1090 | raise ValueError("days must be between 1 and 90") 1091 | # Add the number of days to current time 1092 | target_date = datetime.now(timezone.utc).astimezone() + timedelta(days=days) 1093 | # Format for the correct datetime format 1094 | hold_date = target_date.strftime("%Y-%m-%dT%H:%M:%S%z") 1095 | # Python dumps the offset in format '+0100', we need '+01:00' 1096 | hold_date = "{0}:{1}".format(hold_date[:-2], hold_date[-2:]) 1097 | # Actually set the hold date 1098 | _system_set("refresh.hold", hold_date) 1099 | logger.info("Set system-wide snap refresh hold to: %s", hold_date) 1100 | -------------------------------------------------------------------------------- /lib/charms/tls_certificates_interface/v2/tls_certificates.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Canonical Ltd. 2 | # See LICENSE file for licensing details. 3 | 4 | 5 | """Library for the tls-certificates relation. 6 | 7 | This library contains the Requires and Provides classes for handling the tls-certificates 8 | interface. 9 | 10 | ## Getting Started 11 | From a charm directory, fetch the library using `charmcraft`: 12 | 13 | ```shell 14 | charmcraft fetch-lib charms.tls_certificates_interface.v2.tls_certificates 15 | ``` 16 | 17 | Add the following libraries to the charm's `requirements.txt` file: 18 | - jsonschema 19 | - cryptography 20 | 21 | Add the following section to the charm's `charmcraft.yaml` file: 22 | ```yaml 23 | parts: 24 | charm: 25 | build-packages: 26 | - libffi-dev 27 | - libssl-dev 28 | - rustc 29 | - cargo 30 | ``` 31 | 32 | ### Provider charm 33 | The provider charm is the charm providing certificates to another charm that requires them. In 34 | this example, the provider charm is storing its private key using a peer relation interface called 35 | `replicas`. 36 | 37 | Example: 38 | ```python 39 | from charms.tls_certificates_interface.v2.tls_certificates import ( 40 | CertificateCreationRequestEvent, 41 | CertificateRevocationRequestEvent, 42 | TLSCertificatesProvidesV2, 43 | generate_private_key, 44 | ) 45 | from ops.charm import CharmBase, InstallEvent 46 | from ops.main import main 47 | from ops.model import ActiveStatus, WaitingStatus 48 | 49 | 50 | def generate_ca(private_key: bytes, subject: str) -> str: 51 | return "whatever ca content" 52 | 53 | 54 | def generate_certificate(ca: str, private_key: str, csr: str) -> str: 55 | return "Whatever certificate" 56 | 57 | 58 | class ExampleProviderCharm(CharmBase): 59 | 60 | def __init__(self, *args): 61 | super().__init__(*args) 62 | self.certificates = TLSCertificatesProvidesV2(self, "certificates") 63 | self.framework.observe( 64 | self.certificates.on.certificate_request, 65 | self._on_certificate_request 66 | ) 67 | self.framework.observe( 68 | self.certificates.on.certificate_revocation_request, 69 | self._on_certificate_revocation_request 70 | ) 71 | self.framework.observe(self.on.install, self._on_install) 72 | 73 | def _on_install(self, event: InstallEvent) -> None: 74 | private_key_password = b"banana" 75 | private_key = generate_private_key(password=private_key_password) 76 | ca_certificate = generate_ca(private_key=private_key, subject="whatever") 77 | replicas_relation = self.model.get_relation("replicas") 78 | if not replicas_relation: 79 | self.unit.status = WaitingStatus("Waiting for peer relation to be created") 80 | event.defer() 81 | return 82 | replicas_relation.data[self.app].update( 83 | { 84 | "private_key_password": "banana", 85 | "private_key": private_key, 86 | "ca_certificate": ca_certificate, 87 | } 88 | ) 89 | self.unit.status = ActiveStatus() 90 | 91 | def _on_certificate_request(self, event: CertificateCreationRequestEvent) -> None: 92 | replicas_relation = self.model.get_relation("replicas") 93 | if not replicas_relation: 94 | self.unit.status = WaitingStatus("Waiting for peer relation to be created") 95 | event.defer() 96 | return 97 | ca_certificate = replicas_relation.data[self.app].get("ca_certificate") 98 | private_key = replicas_relation.data[self.app].get("private_key") 99 | certificate = generate_certificate( 100 | ca=ca_certificate, 101 | private_key=private_key, 102 | csr=event.certificate_signing_request, 103 | ) 104 | 105 | self.certificates.set_relation_certificate( 106 | certificate=certificate, 107 | certificate_signing_request=event.certificate_signing_request, 108 | ca=ca_certificate, 109 | chain=[ca_certificate, certificate], 110 | relation_id=event.relation_id, 111 | ) 112 | 113 | def _on_certificate_revocation_request(self, event: CertificateRevocationRequestEvent) -> None: 114 | # Do what you want to do with this information 115 | pass 116 | 117 | 118 | if __name__ == "__main__": 119 | main(ExampleProviderCharm) 120 | ``` 121 | 122 | ### Requirer charm 123 | The requirer charm is the charm requiring certificates from another charm that provides them. In 124 | this example, the requirer charm is storing its certificates using a peer relation interface called 125 | `replicas`. 126 | 127 | Example: 128 | ```python 129 | from charms.tls_certificates_interface.v2.tls_certificates import ( 130 | CertificateAvailableEvent, 131 | CertificateExpiringEvent, 132 | CertificateRevokedEvent, 133 | TLSCertificatesRequiresV2, 134 | generate_csr, 135 | generate_private_key, 136 | ) 137 | from ops.charm import CharmBase, RelationJoinedEvent 138 | from ops.main import main 139 | from ops.model import ActiveStatus, WaitingStatus 140 | from typing import Union 141 | 142 | 143 | class ExampleRequirerCharm(CharmBase): 144 | 145 | def __init__(self, *args): 146 | super().__init__(*args) 147 | self.cert_subject = "whatever" 148 | self.certificates = TLSCertificatesRequiresV2(self, "certificates") 149 | self.framework.observe(self.on.install, self._on_install) 150 | self.framework.observe( 151 | self.on.certificates_relation_joined, self._on_certificates_relation_joined 152 | ) 153 | self.framework.observe( 154 | self.certificates.on.certificate_available, self._on_certificate_available 155 | ) 156 | self.framework.observe( 157 | self.certificates.on.certificate_expiring, self._on_certificate_expiring 158 | ) 159 | self.framework.observe( 160 | self.certificates.on.certificate_invalidated, self._on_certificate_invalidated 161 | ) 162 | self.framework.observe( 163 | self.certificates.on.all_certificates_invalidated, 164 | self._on_all_certificates_invalidated 165 | ) 166 | 167 | def _on_install(self, event) -> None: 168 | private_key_password = b"banana" 169 | private_key = generate_private_key(password=private_key_password) 170 | replicas_relation = self.model.get_relation("replicas") 171 | if not replicas_relation: 172 | self.unit.status = WaitingStatus("Waiting for peer relation to be created") 173 | event.defer() 174 | return 175 | replicas_relation.data[self.app].update( 176 | {"private_key_password": "banana", "private_key": private_key.decode()} 177 | ) 178 | 179 | def _on_certificates_relation_joined(self, event: RelationJoinedEvent) -> None: 180 | replicas_relation = self.model.get_relation("replicas") 181 | if not replicas_relation: 182 | self.unit.status = WaitingStatus("Waiting for peer relation to be created") 183 | event.defer() 184 | return 185 | private_key_password = replicas_relation.data[self.app].get("private_key_password") 186 | private_key = replicas_relation.data[self.app].get("private_key") 187 | csr = generate_csr( 188 | private_key=private_key.encode(), 189 | private_key_password=private_key_password.encode(), 190 | subject=self.cert_subject, 191 | ) 192 | replicas_relation.data[self.app].update({"csr": csr.decode()}) 193 | self.certificates.request_certificate_creation(certificate_signing_request=csr) 194 | 195 | def _on_certificate_available(self, event: CertificateAvailableEvent) -> None: 196 | replicas_relation = self.model.get_relation("replicas") 197 | if not replicas_relation: 198 | self.unit.status = WaitingStatus("Waiting for peer relation to be created") 199 | event.defer() 200 | return 201 | replicas_relation.data[self.app].update({"certificate": event.certificate}) 202 | replicas_relation.data[self.app].update({"ca": event.ca}) 203 | replicas_relation.data[self.app].update({"chain": event.chain}) 204 | self.unit.status = ActiveStatus() 205 | 206 | def _on_certificate_expiring( 207 | self, event: Union[CertificateExpiringEvent, CertificateInvalidatedEvent] 208 | ) -> None: 209 | replicas_relation = self.model.get_relation("replicas") 210 | if not replicas_relation: 211 | self.unit.status = WaitingStatus("Waiting for peer relation to be created") 212 | event.defer() 213 | return 214 | old_csr = replicas_relation.data[self.app].get("csr") 215 | private_key_password = replicas_relation.data[self.app].get("private_key_password") 216 | private_key = replicas_relation.data[self.app].get("private_key") 217 | new_csr = generate_csr( 218 | private_key=private_key.encode(), 219 | private_key_password=private_key_password.encode(), 220 | subject=self.cert_subject, 221 | ) 222 | self.certificates.request_certificate_renewal( 223 | old_certificate_signing_request=old_csr, 224 | new_certificate_signing_request=new_csr, 225 | ) 226 | replicas_relation.data[self.app].update({"csr": new_csr.decode()}) 227 | 228 | def _certificate_revoked(self) -> None: 229 | old_csr = replicas_relation.data[self.app].get("csr") 230 | private_key_password = replicas_relation.data[self.app].get("private_key_password") 231 | private_key = replicas_relation.data[self.app].get("private_key") 232 | new_csr = generate_csr( 233 | private_key=private_key.encode(), 234 | private_key_password=private_key_password.encode(), 235 | subject=self.cert_subject, 236 | ) 237 | self.certificates.request_certificate_renewal( 238 | old_certificate_signing_request=old_csr, 239 | new_certificate_signing_request=new_csr, 240 | ) 241 | replicas_relation.data[self.app].update({"csr": new_csr.decode()}) 242 | replicas_relation.data[self.app].pop("certificate") 243 | replicas_relation.data[self.app].pop("ca") 244 | replicas_relation.data[self.app].pop("chain") 245 | self.unit.status = WaitingStatus("Waiting for new certificate") 246 | 247 | def _on_certificate_invalidated(self, event: CertificateInvalidatedEvent) -> None: 248 | replicas_relation = self.model.get_relation("replicas") 249 | if not replicas_relation: 250 | self.unit.status = WaitingStatus("Waiting for peer relation to be created") 251 | event.defer() 252 | return 253 | if event.reason == "revoked": 254 | self._certificate_revoked() 255 | if event.reason == "expired": 256 | self._on_certificate_expiring(event) 257 | 258 | def _on_all_certificates_invalidated(self, event: AllCertificatesInvalidatedEvent) -> None: 259 | # Do what you want with this information, probably remove all certificates. 260 | pass 261 | 262 | 263 | if __name__ == "__main__": 264 | main(ExampleRequirerCharm) 265 | ``` 266 | 267 | You can relate both charms by running: 268 | 269 | ```bash 270 | juju relate 271 | ``` 272 | 273 | """ # noqa: D405, D410, D411, D214, D416 274 | 275 | import copy 276 | import json 277 | import logging 278 | import uuid 279 | from contextlib import suppress 280 | from datetime import datetime, timedelta 281 | from ipaddress import IPv4Address 282 | from typing import Any, Dict, List, Literal, Optional, Union 283 | 284 | from cryptography import x509 285 | from cryptography.hazmat._oid import ExtensionOID 286 | from cryptography.hazmat.primitives import hashes, serialization 287 | from cryptography.hazmat.primitives.asymmetric import rsa 288 | from cryptography.hazmat.primitives.serialization import pkcs12 289 | from cryptography.x509.extensions import Extension, ExtensionNotFound 290 | from jsonschema import exceptions, validate # type: ignore[import] 291 | from ops.charm import ( 292 | CharmBase, 293 | CharmEvents, 294 | RelationBrokenEvent, 295 | RelationChangedEvent, 296 | SecretExpiredEvent, 297 | UpdateStatusEvent, 298 | ) 299 | from ops.framework import EventBase, EventSource, Handle, Object 300 | from ops.jujuversion import JujuVersion 301 | from ops.model import Relation, SecretNotFoundError 302 | 303 | # The unique Charmhub library identifier, never change it 304 | LIBID = "afd8c2bccf834997afce12c2706d2ede" 305 | 306 | # Increment this major API version when introducing breaking changes 307 | LIBAPI = 2 308 | 309 | # Increment this PATCH version before using `charmcraft publish-lib` or reset 310 | # to 0 if you are raising the major API version 311 | LIBPATCH = 16 312 | 313 | PYDEPS = ["cryptography", "jsonschema"] 314 | 315 | REQUIRER_JSON_SCHEMA = { 316 | "$schema": "http://json-schema.org/draft-04/schema#", 317 | "$id": "https://canonical.github.io/charm-relation-interfaces/tls_certificates/v2/schemas/requirer.json", # noqa: E501 318 | "type": "object", 319 | "title": "`tls_certificates` requirer root schema", 320 | "description": "The `tls_certificates` root schema comprises the entire requirer databag for this interface.", # noqa: E501 321 | "examples": [ 322 | { 323 | "certificate_signing_requests": [ 324 | { 325 | "certificate_signing_request": "-----BEGIN CERTIFICATE REQUEST-----\\nMIICWjCCAUICAQAwFTETMBEGA1UEAwwKYmFuYW5hLmNvbTCCASIwDQYJKoZIhvcN\\nAQEBBQADggEPADCCAQoCggEBANWlx9wE6cW7Jkb4DZZDOZoEjk1eDBMJ+8R4pyKp\\nFBeHMl1SQSDt6rAWsrfL3KOGiIHqrRY0B5H6c51L8LDuVrJG0bPmyQ6rsBo3gVke\\nDSivfSLtGvHtp8lwYnIunF8r858uYmblAR0tdXQNmnQvm+6GERvURQ6sxpgZ7iLC\\npPKDoPt+4GKWL10FWf0i82FgxWC2KqRZUtNbgKETQuARLig7etBmCnh20zmynorA\\ncY7vrpTPAaeQpGLNqqYvKV9W6yWVY08V+nqARrFrjk3vSioZSu8ZJUdZ4d9++SGl\\nbH7A6e77YDkX9i/dQ3Pa/iDtWO3tXS2MvgoxX1iSWlGNOHcCAwEAAaAAMA0GCSqG\\nSIb3DQEBCwUAA4IBAQCW1fKcHessy/ZhnIwAtSLznZeZNH8LTVOzkhVd4HA7EJW+\\nKVLBx8DnN7L3V2/uPJfHiOg4Rx7fi7LkJPegl3SCqJZ0N5bQS/KvDTCyLG+9E8Y+\\n7wqCmWiXaH1devimXZvazilu4IC2dSks2D8DPWHgsOdVks9bme8J3KjdNMQudegc\\newWZZ1Dtbd+Rn7cpKU3jURMwm4fRwGxbJ7iT5fkLlPBlyM/yFEik4SmQxFYrZCQg\\n0f3v4kBefTh5yclPy5tEH+8G0LMsbbo3dJ5mPKpAShi0QEKDLd7eR1R/712lYTK4\\ndi4XaEfqERgy68O4rvb4PGlJeRGS7AmL7Ss8wfAq\\n-----END CERTIFICATE REQUEST-----\\n" # noqa: E501 326 | }, 327 | { 328 | "certificate_signing_request": "-----BEGIN CERTIFICATE REQUEST-----\\nMIICWjCCAUICAQAwFTETMBEGA1UEAwwKYmFuYW5hLmNvbTCCASIwDQYJKoZIhvcN\\nAQEBBQADggEPADCCAQoCggEBAMk3raaX803cHvzlBF9LC7KORT46z4VjyU5PIaMb\\nQLIDgYKFYI0n5hf2Ra4FAHvOvEmW7bjNlHORFEmvnpcU5kPMNUyKFMTaC8LGmN8z\\nUBH3aK+0+FRvY4afn9tgj5435WqOG9QdoDJ0TJkjJbJI9M70UOgL711oU7ql6HxU\\n4d2ydFK9xAHrBwziNHgNZ72L95s4gLTXf0fAHYf15mDA9U5yc+YDubCKgTXzVySQ\\nUx73VCJLfC/XkZIh559IrnRv5G9fu6BMLEuBwAz6QAO4+/XidbKWN4r2XSq5qX4n\\n6EPQQWP8/nd4myq1kbg6Q8w68L/0YdfjCmbyf2TuoWeImdUCAwEAAaAAMA0GCSqG\\nSIb3DQEBCwUAA4IBAQBIdwraBvpYo/rl5MH1+1Um6HRg4gOdQPY5WcJy9B9tgzJz\\nittRSlRGTnhyIo6fHgq9KHrmUthNe8mMTDailKFeaqkVNVvk7l0d1/B90Kz6OfmD\\nxN0qjW53oP7y3QB5FFBM8DjqjmUnz5UePKoX4AKkDyrKWxMwGX5RoET8c/y0y9jp\\nvSq3Wh5UpaZdWbe1oVY8CqMVUEVQL2DPjtopxXFz2qACwsXkQZxWmjvZnRiP8nP8\\nbdFaEuh9Q6rZ2QdZDEtrU4AodPU3NaukFr5KlTUQt3w/cl+5//zils6G5zUWJ2pN\\ng7+t9PTvXHRkH+LnwaVnmsBFU2e05qADQbfIn7JA\\n-----END CERTIFICATE REQUEST-----\\n" # noqa: E501 329 | }, 330 | ] 331 | } 332 | ], 333 | "properties": { 334 | "certificate_signing_requests": { 335 | "type": "array", 336 | "items": { 337 | "type": "object", 338 | "properties": {"certificate_signing_request": {"type": "string"}}, 339 | "required": ["certificate_signing_request"], 340 | }, 341 | } 342 | }, 343 | "required": ["certificate_signing_requests"], 344 | "additionalProperties": True, 345 | } 346 | 347 | PROVIDER_JSON_SCHEMA = { 348 | "$schema": "http://json-schema.org/draft-04/schema#", 349 | "$id": "https://canonical.github.io/charm-relation-interfaces/tls_certificates/v2/schemas/provider.json", # noqa: E501 350 | "type": "object", 351 | "title": "`tls_certificates` provider root schema", 352 | "description": "The `tls_certificates` root schema comprises the entire provider databag for this interface.", # noqa: E501 353 | "examples": [ 354 | { 355 | "certificates": [ 356 | { 357 | "ca": "-----BEGIN CERTIFICATE-----\\nMIIDJTCCAg2gAwIBAgIUMsSK+4FGCjW6sL/EXMSxColmKw8wDQYJKoZIhvcNAQEL\\nBQAwIDELMAkGA1UEBhMCVVMxETAPBgNVBAMMCHdoYXRldmVyMB4XDTIyMDcyOTIx\\nMTgyN1oXDTIzMDcyOTIxMTgyN1owIDELMAkGA1UEBhMCVVMxETAPBgNVBAMMCHdo\\nYXRldmVyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA55N9DkgFWbJ/\\naqcdQhso7n1kFvt6j/fL1tJBvRubkiFMQJnZFtekfalN6FfRtA3jq+nx8o49e+7t\\nLCKT0xQ+wufXfOnxv6/if6HMhHTiCNPOCeztUgQ2+dfNwRhYYgB1P93wkUVjwudK\\n13qHTTZ6NtEF6EzOqhOCe6zxq6wrr422+ZqCvcggeQ5tW9xSd/8O1vNID/0MTKpy\\nET3drDtBfHmiUEIBR3T3tcy6QsIe4Rz/2sDinAcM3j7sG8uY6drh8jY3PWar9til\\nv2l4qDYSU8Qm5856AB1FVZRLRJkLxZYZNgreShAIYgEd0mcyI2EO/UvKxsIcxsXc\\nd45GhGpKkwIDAQABo1cwVTAfBgNVHQ4EGAQWBBRXBrXKh3p/aFdQjUcT/UcvICBL\\nODAhBgNVHSMEGjAYgBYEFFcGtcqHen9oV1CNRxP9Ry8gIEs4MA8GA1UdEwEB/wQF\\nMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAGmCEvcoFUrT9e133SHkgF/ZAgzeIziO\\nBjfAdU4fvAVTVfzaPm0yBnGqzcHyacCzbZjKQpaKVgc5e6IaqAQtf6cZJSCiJGhS\\nJYeosWrj3dahLOUAMrXRr8G/Ybcacoqc+osKaRa2p71cC3V6u2VvcHRV7HDFGJU7\\noijbdB+WhqET6Txe67rxZCJG9Ez3EOejBJBl2PJPpy7m1Ml4RR+E8YHNzB0lcBzc\\nEoiJKlDfKSO14E2CPDonnUoWBJWjEvJys3tbvKzsRj2fnLilytPFU0gH3cEjCopi\\nzFoWRdaRuNHYCqlBmso1JFDl8h4fMmglxGNKnKRar0WeGyxb4xXBGpI=\\n-----END CERTIFICATE-----\\n", # noqa: E501 358 | "chain": [ 359 | "-----BEGIN CERTIFICATE-----\\nMIIDJTCCAg2gAwIBAgIUMsSK+4FGCjW6sL/EXMSxColmKw8wDQYJKoZIhvcNAQEL\\nBQAwIDELMAkGA1UEBhMCVVMxETAPBgNVBAMMCHdoYXRldmVyMB4XDTIyMDcyOTIx\\nMTgyN1oXDTIzMDcyOTIxMTgyN1owIDELMAkGA1UEBhMCVVMxETAPBgNVBAMMCHdo\\nYXRldmVyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA55N9DkgFWbJ/\\naqcdQhso7n1kFvt6j/fL1tJBvRubkiFMQJnZFtekfalN6FfRtA3jq+nx8o49e+7t\\nLCKT0xQ+wufXfOnxv6/if6HMhHTiCNPOCeztUgQ2+dfNwRhYYgB1P93wkUVjwudK\\n13qHTTZ6NtEF6EzOqhOCe6zxq6wrr422+ZqCvcggeQ5tW9xSd/8O1vNID/0MTKpy\\nET3drDtBfHmiUEIBR3T3tcy6QsIe4Rz/2sDinAcM3j7sG8uY6drh8jY3PWar9til\\nv2l4qDYSU8Qm5856AB1FVZRLRJkLxZYZNgreShAIYgEd0mcyI2EO/UvKxsIcxsXc\\nd45GhGpKkwIDAQABo1cwVTAfBgNVHQ4EGAQWBBRXBrXKh3p/aFdQjUcT/UcvICBL\\nODAhBgNVHSMEGjAYgBYEFFcGtcqHen9oV1CNRxP9Ry8gIEs4MA8GA1UdEwEB/wQF\\nMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAGmCEvcoFUrT9e133SHkgF/ZAgzeIziO\\nBjfAdU4fvAVTVfzaPm0yBnGqzcHyacCzbZjKQpaKVgc5e6IaqAQtf6cZJSCiJGhS\\nJYeosWrj3dahLOUAMrXRr8G/Ybcacoqc+osKaRa2p71cC3V6u2VvcHRV7HDFGJU7\\noijbdB+WhqET6Txe67rxZCJG9Ez3EOejBJBl2PJPpy7m1Ml4RR+E8YHNzB0lcBzc\\nEoiJKlDfKSO14E2CPDonnUoWBJWjEvJys3tbvKzsRj2fnLilytPFU0gH3cEjCopi\\nzFoWRdaRuNHYCqlBmso1JFDl8h4fMmglxGNKnKRar0WeGyxb4xXBGpI=\\n-----END CERTIFICATE-----\\n" # noqa: E501, W505 360 | ], 361 | "certificate_signing_request": "-----BEGIN CERTIFICATE REQUEST-----\nMIICWjCCAUICAQAwFTETMBEGA1UEAwwKYmFuYW5hLmNvbTCCASIwDQYJKoZIhvcN\nAQEBBQADggEPADCCAQoCggEBANWlx9wE6cW7Jkb4DZZDOZoEjk1eDBMJ+8R4pyKp\nFBeHMl1SQSDt6rAWsrfL3KOGiIHqrRY0B5H6c51L8LDuVrJG0bPmyQ6rsBo3gVke\nDSivfSLtGvHtp8lwYnIunF8r858uYmblAR0tdXQNmnQvm+6GERvURQ6sxpgZ7iLC\npPKDoPt+4GKWL10FWf0i82FgxWC2KqRZUtNbgKETQuARLig7etBmCnh20zmynorA\ncY7vrpTPAaeQpGLNqqYvKV9W6yWVY08V+nqARrFrjk3vSioZSu8ZJUdZ4d9++SGl\nbH7A6e77YDkX9i/dQ3Pa/iDtWO3tXS2MvgoxX1iSWlGNOHcCAwEAAaAAMA0GCSqG\nSIb3DQEBCwUAA4IBAQCW1fKcHessy/ZhnIwAtSLznZeZNH8LTVOzkhVd4HA7EJW+\nKVLBx8DnN7L3V2/uPJfHiOg4Rx7fi7LkJPegl3SCqJZ0N5bQS/KvDTCyLG+9E8Y+\n7wqCmWiXaH1devimXZvazilu4IC2dSks2D8DPWHgsOdVks9bme8J3KjdNMQudegc\newWZZ1Dtbd+Rn7cpKU3jURMwm4fRwGxbJ7iT5fkLlPBlyM/yFEik4SmQxFYrZCQg\n0f3v4kBefTh5yclPy5tEH+8G0LMsbbo3dJ5mPKpAShi0QEKDLd7eR1R/712lYTK4\ndi4XaEfqERgy68O4rvb4PGlJeRGS7AmL7Ss8wfAq\n-----END CERTIFICATE REQUEST-----\n", # noqa: E501 362 | "certificate": "-----BEGIN CERTIFICATE-----\nMIICvDCCAaQCFFPAOD7utDTsgFrm0vS4We18OcnKMA0GCSqGSIb3DQEBCwUAMCAx\nCzAJBgNVBAYTAlVTMREwDwYDVQQDDAh3aGF0ZXZlcjAeFw0yMjA3MjkyMTE5Mzha\nFw0yMzA3MjkyMTE5MzhaMBUxEzARBgNVBAMMCmJhbmFuYS5jb20wggEiMA0GCSqG\nSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDVpcfcBOnFuyZG+A2WQzmaBI5NXgwTCfvE\neKciqRQXhzJdUkEg7eqwFrK3y9yjhoiB6q0WNAeR+nOdS/Cw7layRtGz5skOq7Aa\nN4FZHg0or30i7Rrx7afJcGJyLpxfK/OfLmJm5QEdLXV0DZp0L5vuhhEb1EUOrMaY\nGe4iwqTyg6D7fuBili9dBVn9IvNhYMVgtiqkWVLTW4ChE0LgES4oO3rQZgp4dtM5\nsp6KwHGO766UzwGnkKRizaqmLylfVusllWNPFfp6gEaxa45N70oqGUrvGSVHWeHf\nfvkhpWx+wOnu+2A5F/Yv3UNz2v4g7Vjt7V0tjL4KMV9YklpRjTh3AgMBAAEwDQYJ\nKoZIhvcNAQELBQADggEBAChjRzuba8zjQ7NYBVas89Oy7u++MlS8xWxh++yiUsV6\nWMk3ZemsPtXc1YmXorIQohtxLxzUPm2JhyzFzU/sOLmJQ1E/l+gtZHyRCwsb20fX\nmphuJsMVd7qv/GwEk9PBsk2uDqg4/Wix0Rx5lf95juJP7CPXQJl5FQauf3+LSz0y\nwF/j+4GqvrwsWr9hKOLmPdkyKkR6bHKtzzsxL9PM8GnElk2OpaPMMnzbL/vt2IAt\nxK01ZzPxCQCzVwHo5IJO5NR/fIyFbEPhxzG17QsRDOBR9fl9cOIvDeSO04vyZ+nz\n+kA2c3fNrZFAtpIlOOmFh8Q12rVL4sAjI5mVWnNEgvI=\n-----END CERTIFICATE-----\n", # noqa: E501 363 | } 364 | ] 365 | }, 366 | { 367 | "certificates": [ 368 | { 369 | "ca": "-----BEGIN CERTIFICATE-----\\nMIIDJTCCAg2gAwIBAgIUMsSK+4FGCjW6sL/EXMSxColmKw8wDQYJKoZIhvcNAQEL\\nBQAwIDELMAkGA1UEBhMCVVMxETAPBgNVBAMMCHdoYXRldmVyMB4XDTIyMDcyOTIx\\nMTgyN1oXDTIzMDcyOTIxMTgyN1owIDELMAkGA1UEBhMCVVMxETAPBgNVBAMMCHdo\\nYXRldmVyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA55N9DkgFWbJ/\\naqcdQhso7n1kFvt6j/fL1tJBvRubkiFMQJnZFtekfalN6FfRtA3jq+nx8o49e+7t\\nLCKT0xQ+wufXfOnxv6/if6HMhHTiCNPOCeztUgQ2+dfNwRhYYgB1P93wkUVjwudK\\n13qHTTZ6NtEF6EzOqhOCe6zxq6wrr422+ZqCvcggeQ5tW9xSd/8O1vNID/0MTKpy\\nET3drDtBfHmiUEIBR3T3tcy6QsIe4Rz/2sDinAcM3j7sG8uY6drh8jY3PWar9til\\nv2l4qDYSU8Qm5856AB1FVZRLRJkLxZYZNgreShAIYgEd0mcyI2EO/UvKxsIcxsXc\\nd45GhGpKkwIDAQABo1cwVTAfBgNVHQ4EGAQWBBRXBrXKh3p/aFdQjUcT/UcvICBL\\nODAhBgNVHSMEGjAYgBYEFFcGtcqHen9oV1CNRxP9Ry8gIEs4MA8GA1UdEwEB/wQF\\nMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAGmCEvcoFUrT9e133SHkgF/ZAgzeIziO\\nBjfAdU4fvAVTVfzaPm0yBnGqzcHyacCzbZjKQpaKVgc5e6IaqAQtf6cZJSCiJGhS\\nJYeosWrj3dahLOUAMrXRr8G/Ybcacoqc+osKaRa2p71cC3V6u2VvcHRV7HDFGJU7\\noijbdB+WhqET6Txe67rxZCJG9Ez3EOejBJBl2PJPpy7m1Ml4RR+E8YHNzB0lcBzc\\nEoiJKlDfKSO14E2CPDonnUoWBJWjEvJys3tbvKzsRj2fnLilytPFU0gH3cEjCopi\\nzFoWRdaRuNHYCqlBmso1JFDl8h4fMmglxGNKnKRar0WeGyxb4xXBGpI=\\n-----END CERTIFICATE-----\\n", # noqa: E501 370 | "chain": [ 371 | "-----BEGIN CERTIFICATE-----\\nMIIDJTCCAg2gAwIBAgIUMsSK+4FGCjW6sL/EXMSxColmKw8wDQYJKoZIhvcNAQEL\\nBQAwIDELMAkGA1UEBhMCVVMxETAPBgNVBAMMCHdoYXRldmVyMB4XDTIyMDcyOTIx\\nMTgyN1oXDTIzMDcyOTIxMTgyN1owIDELMAkGA1UEBhMCVVMxETAPBgNVBAMMCHdo\\nYXRldmVyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA55N9DkgFWbJ/\\naqcdQhso7n1kFvt6j/fL1tJBvRubkiFMQJnZFtekfalN6FfRtA3jq+nx8o49e+7t\\nLCKT0xQ+wufXfOnxv6/if6HMhHTiCNPOCeztUgQ2+dfNwRhYYgB1P93wkUVjwudK\\n13qHTTZ6NtEF6EzOqhOCe6zxq6wrr422+ZqCvcggeQ5tW9xSd/8O1vNID/0MTKpy\\nET3drDtBfHmiUEIBR3T3tcy6QsIe4Rz/2sDinAcM3j7sG8uY6drh8jY3PWar9til\\nv2l4qDYSU8Qm5856AB1FVZRLRJkLxZYZNgreShAIYgEd0mcyI2EO/UvKxsIcxsXc\\nd45GhGpKkwIDAQABo1cwVTAfBgNVHQ4EGAQWBBRXBrXKh3p/aFdQjUcT/UcvICBL\\nODAhBgNVHSMEGjAYgBYEFFcGtcqHen9oV1CNRxP9Ry8gIEs4MA8GA1UdEwEB/wQF\\nMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAGmCEvcoFUrT9e133SHkgF/ZAgzeIziO\\nBjfAdU4fvAVTVfzaPm0yBnGqzcHyacCzbZjKQpaKVgc5e6IaqAQtf6cZJSCiJGhS\\nJYeosWrj3dahLOUAMrXRr8G/Ybcacoqc+osKaRa2p71cC3V6u2VvcHRV7HDFGJU7\\noijbdB+WhqET6Txe67rxZCJG9Ez3EOejBJBl2PJPpy7m1Ml4RR+E8YHNzB0lcBzc\\nEoiJKlDfKSO14E2CPDonnUoWBJWjEvJys3tbvKzsRj2fnLilytPFU0gH3cEjCopi\\nzFoWRdaRuNHYCqlBmso1JFDl8h4fMmglxGNKnKRar0WeGyxb4xXBGpI=\\n-----END CERTIFICATE-----\\n" # noqa: E501, W505 372 | ], 373 | "certificate_signing_request": "-----BEGIN CERTIFICATE REQUEST-----\nMIICWjCCAUICAQAwFTETMBEGA1UEAwwKYmFuYW5hLmNvbTCCASIwDQYJKoZIhvcN\nAQEBBQADggEPADCCAQoCggEBANWlx9wE6cW7Jkb4DZZDOZoEjk1eDBMJ+8R4pyKp\nFBeHMl1SQSDt6rAWsrfL3KOGiIHqrRY0B5H6c51L8LDuVrJG0bPmyQ6rsBo3gVke\nDSivfSLtGvHtp8lwYnIunF8r858uYmblAR0tdXQNmnQvm+6GERvURQ6sxpgZ7iLC\npPKDoPt+4GKWL10FWf0i82FgxWC2KqRZUtNbgKETQuARLig7etBmCnh20zmynorA\ncY7vrpTPAaeQpGLNqqYvKV9W6yWVY08V+nqARrFrjk3vSioZSu8ZJUdZ4d9++SGl\nbH7A6e77YDkX9i/dQ3Pa/iDtWO3tXS2MvgoxX1iSWlGNOHcCAwEAAaAAMA0GCSqG\nSIb3DQEBCwUAA4IBAQCW1fKcHessy/ZhnIwAtSLznZeZNH8LTVOzkhVd4HA7EJW+\nKVLBx8DnN7L3V2/uPJfHiOg4Rx7fi7LkJPegl3SCqJZ0N5bQS/KvDTCyLG+9E8Y+\n7wqCmWiXaH1devimXZvazilu4IC2dSks2D8DPWHgsOdVks9bme8J3KjdNMQudegc\newWZZ1Dtbd+Rn7cpKU3jURMwm4fRwGxbJ7iT5fkLlPBlyM/yFEik4SmQxFYrZCQg\n0f3v4kBefTh5yclPy5tEH+8G0LMsbbo3dJ5mPKpAShi0QEKDLd7eR1R/712lYTK4\ndi4XaEfqERgy68O4rvb4PGlJeRGS7AmL7Ss8wfAq\n-----END CERTIFICATE REQUEST-----\n", # noqa: E501 374 | "certificate": "-----BEGIN CERTIFICATE-----\nMIICvDCCAaQCFFPAOD7utDTsgFrm0vS4We18OcnKMA0GCSqGSIb3DQEBCwUAMCAx\nCzAJBgNVBAYTAlVTMREwDwYDVQQDDAh3aGF0ZXZlcjAeFw0yMjA3MjkyMTE5Mzha\nFw0yMzA3MjkyMTE5MzhaMBUxEzARBgNVBAMMCmJhbmFuYS5jb20wggEiMA0GCSqG\nSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDVpcfcBOnFuyZG+A2WQzmaBI5NXgwTCfvE\neKciqRQXhzJdUkEg7eqwFrK3y9yjhoiB6q0WNAeR+nOdS/Cw7layRtGz5skOq7Aa\nN4FZHg0or30i7Rrx7afJcGJyLpxfK/OfLmJm5QEdLXV0DZp0L5vuhhEb1EUOrMaY\nGe4iwqTyg6D7fuBili9dBVn9IvNhYMVgtiqkWVLTW4ChE0LgES4oO3rQZgp4dtM5\nsp6KwHGO766UzwGnkKRizaqmLylfVusllWNPFfp6gEaxa45N70oqGUrvGSVHWeHf\nfvkhpWx+wOnu+2A5F/Yv3UNz2v4g7Vjt7V0tjL4KMV9YklpRjTh3AgMBAAEwDQYJ\nKoZIhvcNAQELBQADggEBAChjRzuba8zjQ7NYBVas89Oy7u++MlS8xWxh++yiUsV6\nWMk3ZemsPtXc1YmXorIQohtxLxzUPm2JhyzFzU/sOLmJQ1E/l+gtZHyRCwsb20fX\nmphuJsMVd7qv/GwEk9PBsk2uDqg4/Wix0Rx5lf95juJP7CPXQJl5FQauf3+LSz0y\nwF/j+4GqvrwsWr9hKOLmPdkyKkR6bHKtzzsxL9PM8GnElk2OpaPMMnzbL/vt2IAt\nxK01ZzPxCQCzVwHo5IJO5NR/fIyFbEPhxzG17QsRDOBR9fl9cOIvDeSO04vyZ+nz\n+kA2c3fNrZFAtpIlOOmFh8Q12rVL4sAjI5mVWnNEgvI=\n-----END CERTIFICATE-----\n", # noqa: E501 375 | "revoked": True, 376 | } 377 | ] 378 | }, 379 | ], 380 | "properties": { 381 | "certificates": { 382 | "$id": "#/properties/certificates", 383 | "type": "array", 384 | "items": { 385 | "$id": "#/properties/certificates/items", 386 | "type": "object", 387 | "required": ["certificate_signing_request", "certificate", "ca", "chain"], 388 | "properties": { 389 | "certificate_signing_request": { 390 | "$id": "#/properties/certificates/items/certificate_signing_request", 391 | "type": "string", 392 | }, 393 | "certificate": { 394 | "$id": "#/properties/certificates/items/certificate", 395 | "type": "string", 396 | }, 397 | "ca": {"$id": "#/properties/certificates/items/ca", "type": "string"}, 398 | "chain": { 399 | "$id": "#/properties/certificates/items/chain", 400 | "type": "array", 401 | "items": { 402 | "type": "string", 403 | "$id": "#/properties/certificates/items/chain/items", 404 | }, 405 | }, 406 | "revoked": { 407 | "$id": "#/properties/certificates/items/revoked", 408 | "type": "boolean", 409 | }, 410 | }, 411 | "additionalProperties": True, 412 | }, 413 | } 414 | }, 415 | "required": ["certificates"], 416 | "additionalProperties": True, 417 | } 418 | 419 | 420 | logger = logging.getLogger(__name__) 421 | 422 | 423 | class CertificateAvailableEvent(EventBase): 424 | """Charm Event triggered when a TLS certificate is available.""" 425 | 426 | def __init__( 427 | self, 428 | handle: Handle, 429 | certificate: str, 430 | certificate_signing_request: str, 431 | ca: str, 432 | chain: List[str], 433 | ): 434 | super().__init__(handle) 435 | self.certificate = certificate 436 | self.certificate_signing_request = certificate_signing_request 437 | self.ca = ca 438 | self.chain = chain 439 | 440 | def snapshot(self) -> dict: 441 | """Returns snapshot.""" 442 | return { 443 | "certificate": self.certificate, 444 | "certificate_signing_request": self.certificate_signing_request, 445 | "ca": self.ca, 446 | "chain": self.chain, 447 | } 448 | 449 | def restore(self, snapshot: dict): 450 | """Restores snapshot.""" 451 | self.certificate = snapshot["certificate"] 452 | self.certificate_signing_request = snapshot["certificate_signing_request"] 453 | self.ca = snapshot["ca"] 454 | self.chain = snapshot["chain"] 455 | 456 | 457 | class CertificateExpiringEvent(EventBase): 458 | """Charm Event triggered when a TLS certificate is almost expired.""" 459 | 460 | def __init__(self, handle, certificate: str, expiry: str): 461 | """CertificateExpiringEvent. 462 | 463 | Args: 464 | handle (Handle): Juju framework handle 465 | certificate (str): TLS Certificate 466 | expiry (str): Datetime string representing the time at which the certificate 467 | won't be valid anymore. 468 | """ 469 | super().__init__(handle) 470 | self.certificate = certificate 471 | self.expiry = expiry 472 | 473 | def snapshot(self) -> dict: 474 | """Returns snapshot.""" 475 | return {"certificate": self.certificate, "expiry": self.expiry} 476 | 477 | def restore(self, snapshot: dict): 478 | """Restores snapshot.""" 479 | self.certificate = snapshot["certificate"] 480 | self.expiry = snapshot["expiry"] 481 | 482 | 483 | class CertificateInvalidatedEvent(EventBase): 484 | """Charm Event triggered when a TLS certificate is invalidated.""" 485 | 486 | def __init__( 487 | self, 488 | handle: Handle, 489 | reason: Literal["expired", "revoked"], 490 | certificate: str, 491 | certificate_signing_request: str, 492 | ca: str, 493 | chain: List[str], 494 | ): 495 | super().__init__(handle) 496 | self.reason = reason 497 | self.certificate_signing_request = certificate_signing_request 498 | self.certificate = certificate 499 | self.ca = ca 500 | self.chain = chain 501 | 502 | def snapshot(self) -> dict: 503 | """Returns snapshot.""" 504 | return { 505 | "reason": self.reason, 506 | "certificate_signing_request": self.certificate_signing_request, 507 | "certificate": self.certificate, 508 | "ca": self.ca, 509 | "chain": self.chain, 510 | } 511 | 512 | def restore(self, snapshot: dict): 513 | """Restores snapshot.""" 514 | self.reason = snapshot["reason"] 515 | self.certificate_signing_request = snapshot["certificate_signing_request"] 516 | self.certificate = snapshot["certificate"] 517 | self.ca = snapshot["ca"] 518 | self.chain = snapshot["chain"] 519 | 520 | 521 | class AllCertificatesInvalidatedEvent(EventBase): 522 | """Charm Event triggered when all TLS certificates are invalidated.""" 523 | 524 | def __init__(self, handle: Handle): 525 | super().__init__(handle) 526 | 527 | def snapshot(self) -> dict: 528 | """Returns snapshot.""" 529 | return {} 530 | 531 | def restore(self, snapshot: dict): 532 | """Restores snapshot.""" 533 | pass 534 | 535 | 536 | class CertificateCreationRequestEvent(EventBase): 537 | """Charm Event triggered when a TLS certificate is required.""" 538 | 539 | def __init__(self, handle: Handle, certificate_signing_request: str, relation_id: int): 540 | super().__init__(handle) 541 | self.certificate_signing_request = certificate_signing_request 542 | self.relation_id = relation_id 543 | 544 | def snapshot(self) -> dict: 545 | """Returns snapshot.""" 546 | return { 547 | "certificate_signing_request": self.certificate_signing_request, 548 | "relation_id": self.relation_id, 549 | } 550 | 551 | def restore(self, snapshot: dict): 552 | """Restores snapshot.""" 553 | self.certificate_signing_request = snapshot["certificate_signing_request"] 554 | self.relation_id = snapshot["relation_id"] 555 | 556 | 557 | class CertificateRevocationRequestEvent(EventBase): 558 | """Charm Event triggered when a TLS certificate needs to be revoked.""" 559 | 560 | def __init__( 561 | self, 562 | handle: Handle, 563 | certificate: str, 564 | certificate_signing_request: str, 565 | ca: str, 566 | chain: str, 567 | ): 568 | super().__init__(handle) 569 | self.certificate = certificate 570 | self.certificate_signing_request = certificate_signing_request 571 | self.ca = ca 572 | self.chain = chain 573 | 574 | def snapshot(self) -> dict: 575 | """Returns snapshot.""" 576 | return { 577 | "certificate": self.certificate, 578 | "certificate_signing_request": self.certificate_signing_request, 579 | "ca": self.ca, 580 | "chain": self.chain, 581 | } 582 | 583 | def restore(self, snapshot: dict): 584 | """Restores snapshot.""" 585 | self.certificate = snapshot["certificate"] 586 | self.certificate_signing_request = snapshot["certificate_signing_request"] 587 | self.ca = snapshot["ca"] 588 | self.chain = snapshot["chain"] 589 | 590 | 591 | def _load_relation_data(raw_relation_data: dict) -> dict: 592 | """Loads relation data from the relation data bag. 593 | 594 | Json loads all data. 595 | 596 | Args: 597 | raw_relation_data: Relation data from the databag 598 | 599 | Returns: 600 | dict: Relation data in dict format. 601 | """ 602 | certificate_data = dict() 603 | for key in raw_relation_data: 604 | try: 605 | certificate_data[key] = json.loads(raw_relation_data[key]) 606 | except (json.decoder.JSONDecodeError, TypeError): 607 | certificate_data[key] = raw_relation_data[key] 608 | return certificate_data 609 | 610 | 611 | def generate_ca( 612 | private_key: bytes, 613 | subject: str, 614 | private_key_password: Optional[bytes] = None, 615 | validity: int = 365, 616 | country: str = "US", 617 | ) -> bytes: 618 | """Generates a CA Certificate. 619 | 620 | Args: 621 | private_key (bytes): Private key 622 | subject (str): Certificate subject 623 | private_key_password (bytes): Private key password 624 | validity (int): Certificate validity time (in days) 625 | country (str): Certificate Issuing country 626 | 627 | Returns: 628 | bytes: CA Certificate. 629 | """ 630 | private_key_object = serialization.load_pem_private_key( 631 | private_key, password=private_key_password 632 | ) 633 | subject = issuer = x509.Name( 634 | [ 635 | x509.NameAttribute(x509.NameOID.COUNTRY_NAME, country), 636 | x509.NameAttribute(x509.NameOID.COMMON_NAME, subject), 637 | ] 638 | ) 639 | subject_identifier_object = x509.SubjectKeyIdentifier.from_public_key( 640 | private_key_object.public_key() # type: ignore[arg-type] 641 | ) 642 | subject_identifier = key_identifier = subject_identifier_object.public_bytes() 643 | key_usage = x509.KeyUsage( 644 | digital_signature=True, 645 | key_encipherment=True, 646 | key_cert_sign=True, 647 | key_agreement=False, 648 | content_commitment=False, 649 | data_encipherment=False, 650 | crl_sign=False, 651 | encipher_only=False, 652 | decipher_only=False, 653 | ) 654 | cert = ( 655 | x509.CertificateBuilder() 656 | .subject_name(subject) 657 | .issuer_name(issuer) 658 | .public_key(private_key_object.public_key()) # type: ignore[arg-type] 659 | .serial_number(x509.random_serial_number()) 660 | .not_valid_before(datetime.utcnow()) 661 | .not_valid_after(datetime.utcnow() + timedelta(days=validity)) 662 | .add_extension(x509.SubjectKeyIdentifier(digest=subject_identifier), critical=False) 663 | .add_extension( 664 | x509.AuthorityKeyIdentifier( 665 | key_identifier=key_identifier, 666 | authority_cert_issuer=None, 667 | authority_cert_serial_number=None, 668 | ), 669 | critical=False, 670 | ) 671 | .add_extension(key_usage, critical=True) 672 | .add_extension( 673 | x509.BasicConstraints(ca=True, path_length=None), 674 | critical=True, 675 | ) 676 | .sign(private_key_object, hashes.SHA256()) # type: ignore[arg-type] 677 | ) 678 | return cert.public_bytes(serialization.Encoding.PEM) 679 | 680 | 681 | def generate_certificate( 682 | csr: bytes, 683 | ca: bytes, 684 | ca_key: bytes, 685 | ca_key_password: Optional[bytes] = None, 686 | validity: int = 365, 687 | alt_names: Optional[List[str]] = None, 688 | ) -> bytes: 689 | """Generates a TLS certificate based on a CSR. 690 | 691 | Args: 692 | csr (bytes): CSR 693 | ca (bytes): CA Certificate 694 | ca_key (bytes): CA private key 695 | ca_key_password: CA private key password 696 | validity (int): Certificate validity (in days) 697 | alt_names (list): List of alt names to put on cert - prefer putting SANs in CSR 698 | 699 | Returns: 700 | bytes: Certificate 701 | """ 702 | csr_object = x509.load_pem_x509_csr(csr) 703 | subject = csr_object.subject 704 | ca_pem = x509.load_pem_x509_certificate(ca) 705 | issuer = ca_pem.issuer 706 | private_key = serialization.load_pem_private_key(ca_key, password=ca_key_password) 707 | 708 | certificate_builder = ( 709 | x509.CertificateBuilder() 710 | .subject_name(subject) 711 | .issuer_name(issuer) 712 | .public_key(csr_object.public_key()) 713 | .serial_number(x509.random_serial_number()) 714 | .not_valid_before(datetime.utcnow()) 715 | .not_valid_after(datetime.utcnow() + timedelta(days=validity)) 716 | .add_extension( 717 | x509.AuthorityKeyIdentifier( 718 | key_identifier=ca_pem.extensions.get_extension_for_class( 719 | x509.SubjectKeyIdentifier 720 | ).value.key_identifier, 721 | authority_cert_issuer=None, 722 | authority_cert_serial_number=None, 723 | ), 724 | critical=False, 725 | ) 726 | .add_extension( 727 | x509.SubjectKeyIdentifier.from_public_key(csr_object.public_key()), critical=False 728 | ) 729 | .add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=False) 730 | ) 731 | 732 | extensions_list = csr_object.extensions 733 | san_ext: Optional[x509.Extension] = None 734 | if alt_names: 735 | full_sans_dns = alt_names.copy() 736 | try: 737 | loaded_san_ext = csr_object.extensions.get_extension_for_class( 738 | x509.SubjectAlternativeName 739 | ) 740 | full_sans_dns.extend(loaded_san_ext.value.get_values_for_type(x509.DNSName)) 741 | except ExtensionNotFound: 742 | pass 743 | finally: 744 | san_ext = Extension( 745 | ExtensionOID.SUBJECT_ALTERNATIVE_NAME, 746 | False, 747 | x509.SubjectAlternativeName([x509.DNSName(name) for name in full_sans_dns]), 748 | ) 749 | if not extensions_list: 750 | extensions_list = x509.Extensions([san_ext]) 751 | 752 | for extension in extensions_list: 753 | if extension.value.oid == ExtensionOID.SUBJECT_ALTERNATIVE_NAME and san_ext: 754 | extension = san_ext 755 | 756 | certificate_builder = certificate_builder.add_extension( 757 | extension.value, 758 | critical=extension.critical, 759 | ) 760 | 761 | certificate_builder._version = x509.Version.v3 762 | cert = certificate_builder.sign(private_key, hashes.SHA256()) # type: ignore[arg-type] 763 | return cert.public_bytes(serialization.Encoding.PEM) 764 | 765 | 766 | def generate_pfx_package( 767 | certificate: bytes, 768 | private_key: bytes, 769 | package_password: str, 770 | private_key_password: Optional[bytes] = None, 771 | ) -> bytes: 772 | """Generates a PFX package to contain the TLS certificate and private key. 773 | 774 | Args: 775 | certificate (bytes): TLS certificate 776 | private_key (bytes): Private key 777 | package_password (str): Password to open the PFX package 778 | private_key_password (bytes): Private key password 779 | 780 | Returns: 781 | bytes: 782 | """ 783 | private_key_object = serialization.load_pem_private_key( 784 | private_key, password=private_key_password 785 | ) 786 | certificate_object = x509.load_pem_x509_certificate(certificate) 787 | name = certificate_object.subject.rfc4514_string() 788 | pfx_bytes = pkcs12.serialize_key_and_certificates( 789 | name=name.encode(), 790 | cert=certificate_object, 791 | key=private_key_object, # type: ignore[arg-type] 792 | cas=None, 793 | encryption_algorithm=serialization.BestAvailableEncryption(package_password.encode()), 794 | ) 795 | return pfx_bytes 796 | 797 | 798 | def generate_private_key( 799 | password: Optional[bytes] = None, 800 | key_size: int = 2048, 801 | public_exponent: int = 65537, 802 | ) -> bytes: 803 | """Generates a private key. 804 | 805 | Args: 806 | password (bytes): Password for decrypting the private key 807 | key_size (int): Key size in bytes 808 | public_exponent: Public exponent. 809 | 810 | Returns: 811 | bytes: Private Key 812 | """ 813 | private_key = rsa.generate_private_key( 814 | public_exponent=public_exponent, 815 | key_size=key_size, 816 | ) 817 | key_bytes = private_key.private_bytes( 818 | encoding=serialization.Encoding.PEM, 819 | format=serialization.PrivateFormat.TraditionalOpenSSL, 820 | encryption_algorithm=serialization.BestAvailableEncryption(password) 821 | if password 822 | else serialization.NoEncryption(), 823 | ) 824 | return key_bytes 825 | 826 | 827 | def generate_csr( 828 | private_key: bytes, 829 | subject: str, 830 | add_unique_id_to_subject_name: bool = True, 831 | organization: Optional[str] = None, 832 | email_address: Optional[str] = None, 833 | country_name: Optional[str] = None, 834 | private_key_password: Optional[bytes] = None, 835 | sans: Optional[List[str]] = None, 836 | sans_oid: Optional[List[str]] = None, 837 | sans_ip: Optional[List[str]] = None, 838 | sans_dns: Optional[List[str]] = None, 839 | additional_critical_extensions: Optional[List] = None, 840 | ) -> bytes: 841 | """Generates a CSR using private key and subject. 842 | 843 | Args: 844 | private_key (bytes): Private key 845 | subject (str): CSR Subject. 846 | add_unique_id_to_subject_name (bool): Whether a unique ID must be added to the CSR's 847 | subject name. Always leave to "True" when the CSR is used to request certificates 848 | using the tls-certificates relation. 849 | organization (str): Name of organization. 850 | email_address (str): Email address. 851 | country_name (str): Country Name. 852 | private_key_password (bytes): Private key password 853 | sans (list): Use sans_dns - this will be deprecated in a future release 854 | List of DNS subject alternative names (keeping it for now for backward compatibility) 855 | sans_oid (list): List of registered ID SANs 856 | sans_dns (list): List of DNS subject alternative names (similar to the arg: sans) 857 | sans_ip (list): List of IP subject alternative names 858 | additional_critical_extensions (list): List of critical additional extension objects. 859 | Object must be a x509 ExtensionType. 860 | 861 | Returns: 862 | bytes: CSR 863 | """ 864 | signing_key = serialization.load_pem_private_key(private_key, password=private_key_password) 865 | subject_name = [x509.NameAttribute(x509.NameOID.COMMON_NAME, subject)] 866 | if add_unique_id_to_subject_name: 867 | unique_identifier = uuid.uuid4() 868 | subject_name.append( 869 | x509.NameAttribute(x509.NameOID.X500_UNIQUE_IDENTIFIER, str(unique_identifier)) 870 | ) 871 | if organization: 872 | subject_name.append(x509.NameAttribute(x509.NameOID.ORGANIZATION_NAME, organization)) 873 | if email_address: 874 | subject_name.append(x509.NameAttribute(x509.NameOID.EMAIL_ADDRESS, email_address)) 875 | if country_name: 876 | subject_name.append(x509.NameAttribute(x509.NameOID.COUNTRY_NAME, country_name)) 877 | csr = x509.CertificateSigningRequestBuilder(subject_name=x509.Name(subject_name)) 878 | 879 | _sans: List[x509.GeneralName] = [] 880 | if sans_oid: 881 | _sans.extend([x509.RegisteredID(x509.ObjectIdentifier(san)) for san in sans_oid]) 882 | if sans_ip: 883 | _sans.extend([x509.IPAddress(IPv4Address(san)) for san in sans_ip]) 884 | if sans: 885 | _sans.extend([x509.DNSName(san) for san in sans]) 886 | if sans_dns: 887 | _sans.extend([x509.DNSName(san) for san in sans_dns]) 888 | if _sans: 889 | csr = csr.add_extension(x509.SubjectAlternativeName(set(_sans)), critical=False) 890 | 891 | if additional_critical_extensions: 892 | for extension in additional_critical_extensions: 893 | csr = csr.add_extension(extension, critical=True) 894 | 895 | signed_certificate = csr.sign(signing_key, hashes.SHA256()) # type: ignore[arg-type] 896 | return signed_certificate.public_bytes(serialization.Encoding.PEM) 897 | 898 | 899 | class CertificatesProviderCharmEvents(CharmEvents): 900 | """List of events that the TLS Certificates provider charm can leverage.""" 901 | 902 | certificate_creation_request = EventSource(CertificateCreationRequestEvent) 903 | certificate_revocation_request = EventSource(CertificateRevocationRequestEvent) 904 | 905 | 906 | class CertificatesRequirerCharmEvents(CharmEvents): 907 | """List of events that the TLS Certificates requirer charm can leverage.""" 908 | 909 | certificate_available = EventSource(CertificateAvailableEvent) 910 | certificate_expiring = EventSource(CertificateExpiringEvent) 911 | certificate_invalidated = EventSource(CertificateInvalidatedEvent) 912 | all_certificates_invalidated = EventSource(AllCertificatesInvalidatedEvent) 913 | 914 | 915 | class TLSCertificatesProvidesV2(Object): 916 | """TLS certificates provider class to be instantiated by TLS certificates providers.""" 917 | 918 | on = CertificatesProviderCharmEvents() 919 | 920 | def __init__(self, charm: CharmBase, relationship_name: str): 921 | super().__init__(charm, relationship_name) 922 | self.framework.observe( 923 | charm.on[relationship_name].relation_changed, self._on_relation_changed 924 | ) 925 | self.charm = charm 926 | self.relationship_name = relationship_name 927 | 928 | def _load_app_relation_data(self, relation: Relation) -> dict: 929 | """Loads relation data from the application relation data bag. 930 | 931 | Json loads all data. 932 | 933 | Args: 934 | relation_object: Relation data from the application databag 935 | 936 | Returns: 937 | dict: Relation data in dict format. 938 | """ 939 | # If unit is not leader, it does not try to reach relation data. 940 | if not self.model.unit.is_leader(): 941 | return {} 942 | return _load_relation_data(relation.data[self.charm.app]) 943 | 944 | def _add_certificate( 945 | self, 946 | relation_id: int, 947 | certificate: str, 948 | certificate_signing_request: str, 949 | ca: str, 950 | chain: List[str], 951 | ) -> None: 952 | """Adds certificate to relation data. 953 | 954 | Args: 955 | relation_id (int): Relation id 956 | certificate (str): Certificate 957 | certificate_signing_request (str): Certificate Signing Request 958 | ca (str): CA Certificate 959 | chain (list): CA Chain 960 | 961 | Returns: 962 | None 963 | """ 964 | relation = self.model.get_relation( 965 | relation_name=self.relationship_name, relation_id=relation_id 966 | ) 967 | if not relation: 968 | raise RuntimeError( 969 | f"Relation {self.relationship_name} does not exist - " 970 | f"The certificate request can't be completed" 971 | ) 972 | new_certificate = { 973 | "certificate": certificate, 974 | "certificate_signing_request": certificate_signing_request, 975 | "ca": ca, 976 | "chain": chain, 977 | } 978 | provider_relation_data = self._load_app_relation_data(relation) 979 | provider_certificates = provider_relation_data.get("certificates", []) 980 | certificates = copy.deepcopy(provider_certificates) 981 | if new_certificate in certificates: 982 | logger.info("Certificate already in relation data - Doing nothing") 983 | return 984 | certificates.append(new_certificate) 985 | relation.data[self.model.app]["certificates"] = json.dumps(certificates) 986 | 987 | def _remove_certificate( 988 | self, 989 | relation_id: int, 990 | certificate: Optional[str] = None, 991 | certificate_signing_request: Optional[str] = None, 992 | ) -> None: 993 | """Removes certificate from a given relation based on user provided certificate or csr. 994 | 995 | Args: 996 | relation_id (int): Relation id 997 | certificate (str): Certificate (optional) 998 | certificate_signing_request: Certificate signing request (optional) 999 | 1000 | Returns: 1001 | None 1002 | """ 1003 | relation = self.model.get_relation( 1004 | relation_name=self.relationship_name, 1005 | relation_id=relation_id, 1006 | ) 1007 | if not relation: 1008 | raise RuntimeError( 1009 | f"Relation {self.relationship_name} with relation id {relation_id} does not exist" 1010 | ) 1011 | provider_relation_data = self._load_app_relation_data(relation) 1012 | provider_certificates = provider_relation_data.get("certificates", []) 1013 | certificates = copy.deepcopy(provider_certificates) 1014 | for certificate_dict in certificates: 1015 | if certificate and certificate_dict["certificate"] == certificate: 1016 | certificates.remove(certificate_dict) 1017 | if ( 1018 | certificate_signing_request 1019 | and certificate_dict["certificate_signing_request"] == certificate_signing_request 1020 | ): 1021 | certificates.remove(certificate_dict) 1022 | relation.data[self.model.app]["certificates"] = json.dumps(certificates) 1023 | 1024 | @staticmethod 1025 | def _relation_data_is_valid(certificates_data: dict) -> bool: 1026 | """Uses JSON schema validator to validate relation data content. 1027 | 1028 | Args: 1029 | certificates_data (dict): Certificate data dictionary as retrieved from relation data. 1030 | 1031 | Returns: 1032 | bool: True/False depending on whether the relation data follows the json schema. 1033 | """ 1034 | try: 1035 | validate(instance=certificates_data, schema=REQUIRER_JSON_SCHEMA) 1036 | return True 1037 | except exceptions.ValidationError: 1038 | return False 1039 | 1040 | def revoke_all_certificates(self) -> None: 1041 | """Revokes all certificates of this provider. 1042 | 1043 | This method is meant to be used when the Root CA has changed. 1044 | """ 1045 | for relation in self.model.relations[self.relationship_name]: 1046 | provider_relation_data = self._load_app_relation_data(relation) 1047 | provider_certificates = copy.deepcopy(provider_relation_data.get("certificates", [])) 1048 | for certificate in provider_certificates: 1049 | certificate["revoked"] = True 1050 | relation.data[self.model.app]["certificates"] = json.dumps(provider_certificates) 1051 | 1052 | def set_relation_certificate( 1053 | self, 1054 | certificate: str, 1055 | certificate_signing_request: str, 1056 | ca: str, 1057 | chain: List[str], 1058 | relation_id: int, 1059 | ) -> None: 1060 | """Adds certificates to relation data. 1061 | 1062 | Args: 1063 | certificate (str): Certificate 1064 | certificate_signing_request (str): Certificate signing request 1065 | ca (str): CA Certificate 1066 | chain (list): CA Chain 1067 | relation_id (int): Juju relation ID 1068 | 1069 | Returns: 1070 | None 1071 | """ 1072 | if not self.model.unit.is_leader(): 1073 | return 1074 | certificates_relation = self.model.get_relation( 1075 | relation_name=self.relationship_name, relation_id=relation_id 1076 | ) 1077 | if not certificates_relation: 1078 | raise RuntimeError(f"Relation {self.relationship_name} does not exist") 1079 | self._remove_certificate( 1080 | certificate_signing_request=certificate_signing_request.strip(), 1081 | relation_id=relation_id, 1082 | ) 1083 | self._add_certificate( 1084 | relation_id=relation_id, 1085 | certificate=certificate.strip(), 1086 | certificate_signing_request=certificate_signing_request.strip(), 1087 | ca=ca.strip(), 1088 | chain=[cert.strip() for cert in chain], 1089 | ) 1090 | 1091 | def remove_certificate(self, certificate: str) -> None: 1092 | """Removes a given certificate from relation data. 1093 | 1094 | Args: 1095 | certificate (str): TLS Certificate 1096 | 1097 | Returns: 1098 | None 1099 | """ 1100 | certificates_relation = self.model.relations[self.relationship_name] 1101 | if not certificates_relation: 1102 | raise RuntimeError(f"Relation {self.relationship_name} does not exist") 1103 | for certificate_relation in certificates_relation: 1104 | self._remove_certificate(certificate=certificate, relation_id=certificate_relation.id) 1105 | 1106 | def get_issued_certificates( 1107 | self, relation_id: Optional[int] = None 1108 | ) -> Dict[str, List[Dict[str, str]]]: 1109 | """Returns a dictionary of issued certificates. 1110 | 1111 | It returns certificates from all relations if relation_id is not specified. 1112 | Certificates are returned per application name and CSR. 1113 | 1114 | Returns: 1115 | dict: Certificates per application name. 1116 | """ 1117 | certificates: Dict[str, List[Dict[str, str]]] = {} 1118 | relations = ( 1119 | [ 1120 | relation 1121 | for relation in self.model.relations[self.relationship_name] 1122 | if relation.id == relation_id 1123 | ] 1124 | if relation_id is not None 1125 | else self.model.relations.get(self.relationship_name, []) 1126 | ) 1127 | for relation in relations: 1128 | provider_relation_data = self._load_app_relation_data(relation) 1129 | provider_certificates = provider_relation_data.get("certificates", []) 1130 | 1131 | certificates[relation.app.name] = [] # type: ignore[union-attr] 1132 | for certificate in provider_certificates: 1133 | if not certificate.get("revoked", False): 1134 | certificates[relation.app.name].append( # type: ignore[union-attr] 1135 | { 1136 | "csr": certificate["certificate_signing_request"], 1137 | "certificate": certificate["certificate"], 1138 | } 1139 | ) 1140 | 1141 | return certificates 1142 | 1143 | def _on_relation_changed(self, event: RelationChangedEvent) -> None: 1144 | """Handler triggered on relation changed event. 1145 | 1146 | Looks at the relation data and either emits: 1147 | - certificate request event: If the unit relation data contains a CSR for which 1148 | a certificate does not exist in the provider relation data. 1149 | - certificate revocation event: If the provider relation data contains a CSR for which 1150 | a csr does not exist in the requirer relation data. 1151 | 1152 | Args: 1153 | event: Juju event 1154 | 1155 | Returns: 1156 | None 1157 | """ 1158 | if event.unit is None: 1159 | logger.error("Relation_changed event does not have a unit.") 1160 | return 1161 | if not self.model.unit.is_leader(): 1162 | return 1163 | requirer_relation_data = _load_relation_data(event.relation.data[event.unit]) 1164 | provider_relation_data = self._load_app_relation_data(event.relation) 1165 | if not self._relation_data_is_valid(requirer_relation_data): 1166 | logger.debug("Relation data did not pass JSON Schema validation") 1167 | return 1168 | provider_certificates = provider_relation_data.get("certificates", []) 1169 | requirer_csrs = requirer_relation_data.get("certificate_signing_requests", []) 1170 | provider_csrs = [ 1171 | certificate_creation_request["certificate_signing_request"] 1172 | for certificate_creation_request in provider_certificates 1173 | ] 1174 | requirer_unit_csrs = [ 1175 | certificate_creation_request["certificate_signing_request"] 1176 | for certificate_creation_request in requirer_csrs 1177 | ] 1178 | for certificate_signing_request in requirer_unit_csrs: 1179 | if certificate_signing_request not in provider_csrs: 1180 | self.on.certificate_creation_request.emit( 1181 | certificate_signing_request=certificate_signing_request, 1182 | relation_id=event.relation.id, 1183 | ) 1184 | self._revoke_certificates_for_which_no_csr_exists(relation_id=event.relation.id) 1185 | 1186 | def _revoke_certificates_for_which_no_csr_exists(self, relation_id: int) -> None: 1187 | """Revokes certificates for which no unit has a CSR. 1188 | 1189 | Goes through all generated certificates and compare against the list of CSRs for all units 1190 | of a given relationship. 1191 | 1192 | Args: 1193 | relation_id (int): Relation id 1194 | 1195 | Returns: 1196 | None 1197 | """ 1198 | certificates_relation = self.model.get_relation( 1199 | relation_name=self.relationship_name, relation_id=relation_id 1200 | ) 1201 | if not certificates_relation: 1202 | raise RuntimeError(f"Relation {self.relationship_name} does not exist") 1203 | provider_relation_data = self._load_app_relation_data(certificates_relation) 1204 | list_of_csrs: List[str] = [] 1205 | for unit in certificates_relation.units: 1206 | requirer_relation_data = _load_relation_data(certificates_relation.data[unit]) 1207 | requirer_csrs = requirer_relation_data.get("certificate_signing_requests", []) 1208 | list_of_csrs.extend(csr["certificate_signing_request"] for csr in requirer_csrs) 1209 | provider_certificates = provider_relation_data.get("certificates", []) 1210 | for certificate in provider_certificates: 1211 | if certificate["certificate_signing_request"] not in list_of_csrs: 1212 | self.on.certificate_revocation_request.emit( 1213 | certificate=certificate["certificate"], 1214 | certificate_signing_request=certificate["certificate_signing_request"], 1215 | ca=certificate["ca"], 1216 | chain=certificate["chain"], 1217 | ) 1218 | self.remove_certificate(certificate=certificate["certificate"]) 1219 | 1220 | def get_requirer_csrs_with_no_certs( 1221 | self, relation_id: Optional[int] = None 1222 | ) -> List[Dict[str, Union[int, str, List[Dict[str, str]]]]]: 1223 | """Filters the requirer's units csrs. 1224 | 1225 | Keeps the ones for which no certificate was provided. 1226 | 1227 | Args: 1228 | relation_id (int): Relation id 1229 | 1230 | Returns: 1231 | list: List of dictionaries that contain the unit's csrs 1232 | that don't have a certificate issued. 1233 | """ 1234 | all_unit_csr_mappings = copy.deepcopy(self.get_requirer_csrs(relation_id=relation_id)) 1235 | filtered_all_unit_csr_mappings: List[Dict[str, Union[int, str, List[Dict[str, str]]]]] = [] 1236 | for unit_csr_mapping in all_unit_csr_mappings: 1237 | csrs_without_certs = [] 1238 | for csr in unit_csr_mapping["unit_csrs"]: # type: ignore[union-attr] 1239 | if not self.certificate_issued_for_csr( 1240 | app_name=unit_csr_mapping["application_name"], # type: ignore[arg-type] 1241 | csr=csr["certificate_signing_request"], # type: ignore[index] 1242 | ): 1243 | csrs_without_certs.append(csr) 1244 | if csrs_without_certs: 1245 | unit_csr_mapping["unit_csrs"] = csrs_without_certs # type: ignore[assignment] 1246 | filtered_all_unit_csr_mappings.append(unit_csr_mapping) 1247 | return filtered_all_unit_csr_mappings 1248 | 1249 | def get_requirer_csrs( 1250 | self, relation_id: Optional[int] = None 1251 | ) -> List[Dict[str, Union[int, str, List[Dict[str, str]]]]]: 1252 | """Returns a list of requirers' CSRs grouped by unit. 1253 | 1254 | It returns CSRs from all relations if relation_id is not specified. 1255 | CSRs are returned per relation id, application name and unit name. 1256 | 1257 | Returns: 1258 | list: List of dictionaries that contain the unit's csrs 1259 | with the following information 1260 | relation_id, application_name and unit_name. 1261 | """ 1262 | unit_csr_mappings: List[Dict[str, Union[int, str, List[Dict[str, str]]]]] = [] 1263 | 1264 | relations = ( 1265 | [ 1266 | relation 1267 | for relation in self.model.relations[self.relationship_name] 1268 | if relation.id == relation_id 1269 | ] 1270 | if relation_id is not None 1271 | else self.model.relations.get(self.relationship_name, []) 1272 | ) 1273 | 1274 | for relation in relations: 1275 | for unit in relation.units: 1276 | requirer_relation_data = _load_relation_data(relation.data[unit]) 1277 | unit_csrs_list = requirer_relation_data.get("certificate_signing_requests", []) 1278 | unit_csr_mappings.append( 1279 | { 1280 | "relation_id": relation.id, 1281 | "application_name": relation.app.name, # type: ignore[union-attr] 1282 | "unit_name": unit.name, 1283 | "unit_csrs": unit_csrs_list, 1284 | } 1285 | ) 1286 | return unit_csr_mappings 1287 | 1288 | def certificate_issued_for_csr(self, app_name: str, csr: str) -> bool: 1289 | """Checks whether a certificate has been issued for a given CSR. 1290 | 1291 | Args: 1292 | app_name (str): Application name that the CSR belongs to. 1293 | csr (str): Certificate Signing Request. 1294 | 1295 | Returns: 1296 | bool: True/False depending on whether a certificate has been issued for the given CSR. 1297 | """ 1298 | issued_certificates_per_csr = self.get_issued_certificates()[app_name] 1299 | for issued_pair in issued_certificates_per_csr: 1300 | if "csr" in issued_pair and issued_pair["csr"] == csr: 1301 | return csr_matches_certificate(csr, issued_pair["certificate"]) 1302 | return False 1303 | 1304 | 1305 | class TLSCertificatesRequiresV2(Object): 1306 | """TLS certificates requirer class to be instantiated by TLS certificates requirers.""" 1307 | 1308 | on = CertificatesRequirerCharmEvents() 1309 | 1310 | def __init__( 1311 | self, 1312 | charm: CharmBase, 1313 | relationship_name: str, 1314 | expiry_notification_time: int = 168, 1315 | ): 1316 | """Generates/use private key and observes relation changed event. 1317 | 1318 | Args: 1319 | charm: Charm object 1320 | relationship_name: Juju relation name 1321 | expiry_notification_time (int): Time difference between now and expiry (in hours). 1322 | Used to trigger the CertificateExpiring event. Default: 7 days. 1323 | """ 1324 | super().__init__(charm, relationship_name) 1325 | self.relationship_name = relationship_name 1326 | self.charm = charm 1327 | self.expiry_notification_time = expiry_notification_time 1328 | self.framework.observe( 1329 | charm.on[relationship_name].relation_changed, self._on_relation_changed 1330 | ) 1331 | self.framework.observe( 1332 | charm.on[relationship_name].relation_broken, self._on_relation_broken 1333 | ) 1334 | if JujuVersion.from_environ().has_secrets: 1335 | self.framework.observe(charm.on.secret_expired, self._on_secret_expired) 1336 | else: 1337 | self.framework.observe(charm.on.update_status, self._on_update_status) 1338 | 1339 | @property 1340 | def _requirer_csrs(self) -> List[Dict[str, str]]: 1341 | """Returns list of requirer's CSRs from relation data.""" 1342 | relation = self.model.get_relation(self.relationship_name) 1343 | if not relation: 1344 | raise RuntimeError(f"Relation {self.relationship_name} does not exist") 1345 | requirer_relation_data = _load_relation_data(relation.data[self.model.unit]) 1346 | return requirer_relation_data.get("certificate_signing_requests", []) 1347 | 1348 | @property 1349 | def _provider_certificates(self) -> List[Dict[str, str]]: 1350 | """Returns list of certificates from the provider's relation data.""" 1351 | relation = self.model.get_relation(self.relationship_name) 1352 | if not relation: 1353 | logger.debug("No relation: %s", self.relationship_name) 1354 | return [] 1355 | if not relation.app: 1356 | logger.debug("No remote app in relation: %s", self.relationship_name) 1357 | return [] 1358 | provider_relation_data = _load_relation_data(relation.data[relation.app]) 1359 | if not self._relation_data_is_valid(provider_relation_data): 1360 | logger.warning("Provider relation data did not pass JSON Schema validation") 1361 | return [] 1362 | return provider_relation_data.get("certificates", []) 1363 | 1364 | def _add_requirer_csr(self, csr: str) -> None: 1365 | """Adds CSR to relation data. 1366 | 1367 | Args: 1368 | csr (str): Certificate Signing Request 1369 | 1370 | Returns: 1371 | None 1372 | """ 1373 | relation = self.model.get_relation(self.relationship_name) 1374 | if not relation: 1375 | raise RuntimeError( 1376 | f"Relation {self.relationship_name} does not exist - " 1377 | f"The certificate request can't be completed" 1378 | ) 1379 | new_csr_dict = {"certificate_signing_request": csr} 1380 | if new_csr_dict in self._requirer_csrs: 1381 | logger.info("CSR already in relation data - Doing nothing") 1382 | return 1383 | requirer_csrs = copy.deepcopy(self._requirer_csrs) 1384 | requirer_csrs.append(new_csr_dict) 1385 | relation.data[self.model.unit]["certificate_signing_requests"] = json.dumps(requirer_csrs) 1386 | 1387 | def _remove_requirer_csr(self, csr: str) -> None: 1388 | """Removes CSR from relation data. 1389 | 1390 | Args: 1391 | csr (str): Certificate signing request 1392 | 1393 | Returns: 1394 | None 1395 | """ 1396 | relation = self.model.get_relation(self.relationship_name) 1397 | if not relation: 1398 | raise RuntimeError( 1399 | f"Relation {self.relationship_name} does not exist - " 1400 | f"The certificate request can't be completed" 1401 | ) 1402 | requirer_csrs = copy.deepcopy(self._requirer_csrs) 1403 | csr_dict = {"certificate_signing_request": csr} 1404 | if csr_dict not in requirer_csrs: 1405 | logger.info("CSR not in relation data - Doing nothing") 1406 | return 1407 | requirer_csrs.remove(csr_dict) 1408 | relation.data[self.model.unit]["certificate_signing_requests"] = json.dumps(requirer_csrs) 1409 | 1410 | def request_certificate_creation(self, certificate_signing_request: bytes) -> None: 1411 | """Request TLS certificate to provider charm. 1412 | 1413 | Args: 1414 | certificate_signing_request (bytes): Certificate Signing Request 1415 | 1416 | Returns: 1417 | None 1418 | """ 1419 | relation = self.model.get_relation(self.relationship_name) 1420 | if not relation: 1421 | raise RuntimeError( 1422 | f"Relation {self.relationship_name} does not exist - " 1423 | f"The certificate request can't be completed" 1424 | ) 1425 | self._add_requirer_csr(certificate_signing_request.decode().strip()) 1426 | logger.info("Certificate request sent to provider") 1427 | 1428 | def request_certificate_revocation(self, certificate_signing_request: bytes) -> None: 1429 | """Removes CSR from relation data. 1430 | 1431 | The provider of this relation is then expected to remove certificates associated to this 1432 | CSR from the relation data as well and emit a request_certificate_revocation event for the 1433 | provider charm to interpret. 1434 | 1435 | Args: 1436 | certificate_signing_request (bytes): Certificate Signing Request 1437 | 1438 | Returns: 1439 | None 1440 | """ 1441 | self._remove_requirer_csr(certificate_signing_request.decode().strip()) 1442 | logger.info("Certificate revocation sent to provider") 1443 | 1444 | def request_certificate_renewal( 1445 | self, old_certificate_signing_request: bytes, new_certificate_signing_request: bytes 1446 | ) -> None: 1447 | """Renews certificate. 1448 | 1449 | Removes old CSR from relation data and adds new one. 1450 | 1451 | Args: 1452 | old_certificate_signing_request: Old CSR 1453 | new_certificate_signing_request: New CSR 1454 | 1455 | Returns: 1456 | None 1457 | """ 1458 | try: 1459 | self.request_certificate_revocation( 1460 | certificate_signing_request=old_certificate_signing_request 1461 | ) 1462 | except RuntimeError: 1463 | logger.warning("Certificate revocation failed.") 1464 | self.request_certificate_creation( 1465 | certificate_signing_request=new_certificate_signing_request 1466 | ) 1467 | logger.info("Certificate renewal request completed.") 1468 | 1469 | @staticmethod 1470 | def _relation_data_is_valid(certificates_data: dict) -> bool: 1471 | """Checks whether relation data is valid based on json schema. 1472 | 1473 | Args: 1474 | certificates_data: Certificate data in dict format. 1475 | 1476 | Returns: 1477 | bool: Whether relation data is valid. 1478 | """ 1479 | try: 1480 | validate(instance=certificates_data, schema=PROVIDER_JSON_SCHEMA) 1481 | return True 1482 | except exceptions.ValidationError: 1483 | return False 1484 | 1485 | def _on_relation_changed(self, event: RelationChangedEvent) -> None: 1486 | """Handler triggered on relation changed events. 1487 | 1488 | Goes through all providers certificates that match a requested CSR. 1489 | 1490 | If the provider certificate is revoked, emit a CertificateInvalidateEvent, 1491 | otherwise emit a CertificateAvailableEvent. 1492 | 1493 | When Juju secrets are available, remove the secret for revoked certificate, 1494 | or add a secret with the correct expiry time for new certificates. 1495 | 1496 | 1497 | Args: 1498 | event: Juju event 1499 | 1500 | Returns: 1501 | None 1502 | """ 1503 | requirer_csrs = [ 1504 | certificate_creation_request["certificate_signing_request"] 1505 | for certificate_creation_request in self._requirer_csrs 1506 | ] 1507 | for certificate in self._provider_certificates: 1508 | if certificate["certificate_signing_request"] in requirer_csrs: 1509 | if certificate.get("revoked", False): 1510 | if JujuVersion.from_environ().has_secrets: 1511 | with suppress(SecretNotFoundError): 1512 | secret = self.model.get_secret( 1513 | label=f"{LIBID}-{certificate['certificate_signing_request']}" 1514 | ) 1515 | secret.remove_all_revisions() 1516 | self.on.certificate_invalidated.emit( 1517 | reason="revoked", 1518 | certificate=certificate["certificate"], 1519 | certificate_signing_request=certificate["certificate_signing_request"], 1520 | ca=certificate["ca"], 1521 | chain=certificate["chain"], 1522 | ) 1523 | else: 1524 | if JujuVersion.from_environ().has_secrets: 1525 | try: 1526 | secret = self.model.get_secret( 1527 | label=f"{LIBID}-{certificate['certificate_signing_request']}" 1528 | ) 1529 | secret.set_content({"certificate": certificate["certificate"]}) 1530 | secret.set_info( 1531 | expire=self._get_next_secret_expiry_time( 1532 | certificate["certificate"] 1533 | ), 1534 | ) 1535 | except SecretNotFoundError: 1536 | secret = self.charm.unit.add_secret( 1537 | {"certificate": certificate["certificate"]}, 1538 | label=f"{LIBID}-{certificate['certificate_signing_request']}", 1539 | expire=self._get_next_secret_expiry_time( 1540 | certificate["certificate"] 1541 | ), 1542 | ) 1543 | self.on.certificate_available.emit( 1544 | certificate_signing_request=certificate["certificate_signing_request"], 1545 | certificate=certificate["certificate"], 1546 | ca=certificate["ca"], 1547 | chain=certificate["chain"], 1548 | ) 1549 | 1550 | def _get_next_secret_expiry_time(self, certificate: str) -> Optional[datetime]: 1551 | """Return the expiry time or expiry notification time. 1552 | 1553 | Extracts the expiry time from the provided certificate, calculates the 1554 | expiry notification time and return the closest of the two, that is in 1555 | the future. 1556 | 1557 | Args: 1558 | certificate: x509 certificate 1559 | 1560 | Returns: 1561 | Optional[datetime]: None if the certificate expiry time cannot be read, 1562 | next expiry time otherwise. 1563 | """ 1564 | expiry_time = _get_certificate_expiry_time(certificate) 1565 | if not expiry_time: 1566 | return None 1567 | expiry_notification_time = expiry_time - timedelta(hours=self.expiry_notification_time) 1568 | return _get_closest_future_time(expiry_notification_time, expiry_time) 1569 | 1570 | def _on_relation_broken(self, event: RelationBrokenEvent) -> None: 1571 | """Handler triggered on relation broken event. 1572 | 1573 | Emitting `all_certificates_invalidated` from `relation-broken` rather 1574 | than `relation-departed` since certs are stored in app data. 1575 | 1576 | Args: 1577 | event: Juju event 1578 | 1579 | Returns: 1580 | None 1581 | """ 1582 | self.on.all_certificates_invalidated.emit() 1583 | 1584 | def _on_secret_expired(self, event: SecretExpiredEvent) -> None: 1585 | """Triggered when a certificate is set to expire. 1586 | 1587 | Loads the certificate from the secret, and will emit 1 of 2 1588 | events. 1589 | 1590 | If the certificate is not yet expired, emits CertificateExpiringEvent 1591 | and updates the expiry time of the secret to the exact expiry time on 1592 | the certificate. 1593 | 1594 | If the certificate is expired, emits CertificateInvalidedEvent and 1595 | deletes the secret. 1596 | 1597 | Args: 1598 | event (SecretExpiredEvent): Juju event 1599 | """ 1600 | if not event.secret.label or not event.secret.label.startswith(f"{LIBID}-"): 1601 | return 1602 | csr = event.secret.label[len(f"{LIBID}-") :] 1603 | certificate_dict = self._find_certificate_in_relation_data(csr) 1604 | if not certificate_dict: 1605 | # A secret expired but we did not find matching certificate. Cleaning up 1606 | event.secret.remove_all_revisions() 1607 | return 1608 | 1609 | expiry_time = _get_certificate_expiry_time(certificate_dict["certificate"]) 1610 | if not expiry_time: 1611 | # A secret expired but matching certificate is invalid. Cleaning up 1612 | event.secret.remove_all_revisions() 1613 | return 1614 | 1615 | if datetime.utcnow() < expiry_time: 1616 | logger.warning("Certificate almost expired") 1617 | self.on.certificate_expiring.emit( 1618 | certificate=certificate_dict["certificate"], 1619 | expiry=expiry_time.isoformat(), 1620 | ) 1621 | event.secret.set_info( 1622 | expire=_get_certificate_expiry_time(certificate_dict["certificate"]), 1623 | ) 1624 | else: 1625 | logger.warning("Certificate is expired") 1626 | self.on.certificate_invalidated.emit( 1627 | reason="expired", 1628 | certificate=certificate_dict["certificate"], 1629 | certificate_signing_request=certificate_dict["certificate_signing_request"], 1630 | ca=certificate_dict["ca"], 1631 | chain=certificate_dict["chain"], 1632 | ) 1633 | self.request_certificate_revocation(certificate_dict["certificate"].encode()) 1634 | event.secret.remove_all_revisions() 1635 | 1636 | def _find_certificate_in_relation_data(self, csr: str) -> Optional[Dict[str, Any]]: 1637 | """Returns the certificate that match the given CSR.""" 1638 | for certificate_dict in self._provider_certificates: 1639 | if certificate_dict["certificate_signing_request"] != csr: 1640 | continue 1641 | return certificate_dict 1642 | return None 1643 | 1644 | def _on_update_status(self, event: UpdateStatusEvent) -> None: 1645 | """Triggered on update status event. 1646 | 1647 | Goes through each certificate in the "certificates" relation and checks their expiry date. 1648 | If they are close to expire (<7 days), emits a CertificateExpiringEvent event and if 1649 | they are expired, emits a CertificateExpiredEvent. 1650 | 1651 | Args: 1652 | event (UpdateStatusEvent): Juju event 1653 | 1654 | Returns: 1655 | None 1656 | """ 1657 | for certificate_dict in self._provider_certificates: 1658 | expiry_time = _get_certificate_expiry_time(certificate_dict["certificate"]) 1659 | if not expiry_time: 1660 | continue 1661 | time_difference = expiry_time - datetime.utcnow() 1662 | if time_difference.total_seconds() < 0: 1663 | logger.warning("Certificate is expired") 1664 | self.on.certificate_invalidated.emit( 1665 | reason="expired", 1666 | certificate=certificate_dict["certificate"], 1667 | certificate_signing_request=certificate_dict["certificate_signing_request"], 1668 | ca=certificate_dict["ca"], 1669 | chain=certificate_dict["chain"], 1670 | ) 1671 | self.request_certificate_revocation(certificate_dict["certificate"].encode()) 1672 | continue 1673 | if time_difference.total_seconds() < (self.expiry_notification_time * 60 * 60): 1674 | logger.warning("Certificate almost expired") 1675 | self.on.certificate_expiring.emit( 1676 | certificate=certificate_dict["certificate"], 1677 | expiry=expiry_time.isoformat(), 1678 | ) 1679 | 1680 | 1681 | def csr_matches_certificate(csr: str, cert: str) -> bool: 1682 | """Check if a CSR matches a certificate. 1683 | 1684 | expects to get the original string representations. 1685 | 1686 | Args: 1687 | csr (str): Certificate Signing Request 1688 | cert (str): Certificate 1689 | Returns: 1690 | bool: True/False depending on whether the CSR matches the certificate. 1691 | """ 1692 | try: 1693 | csr_object = x509.load_pem_x509_csr(csr.encode("utf-8")) 1694 | cert_object = x509.load_pem_x509_certificate(cert.encode("utf-8")) 1695 | 1696 | if csr_object.public_key().public_bytes( 1697 | encoding=serialization.Encoding.PEM, 1698 | format=serialization.PublicFormat.SubjectPublicKeyInfo, 1699 | ) != cert_object.public_key().public_bytes( 1700 | encoding=serialization.Encoding.PEM, 1701 | format=serialization.PublicFormat.SubjectPublicKeyInfo, 1702 | ): 1703 | return False 1704 | if csr_object.subject != cert_object.subject: 1705 | return False 1706 | except ValueError: 1707 | logger.warning("Could not load certificate or CSR.") 1708 | return False 1709 | return True 1710 | 1711 | 1712 | def _get_closest_future_time( 1713 | expiry_notification_time: datetime, expiry_time: datetime 1714 | ) -> datetime: 1715 | """Return expiry_notification_time if not in the past, otherwise return expiry_time. 1716 | 1717 | Args: 1718 | expiry_notification_time (datetime): Notification time of impending expiration 1719 | expiry_time (datetime): Expiration time 1720 | 1721 | Returns: 1722 | datetime: expiry_notification_time if not in the past, expiry_time otherwise 1723 | """ 1724 | return ( 1725 | expiry_notification_time if datetime.utcnow() < expiry_notification_time else expiry_time 1726 | ) 1727 | 1728 | 1729 | def _get_certificate_expiry_time(certificate: str) -> Optional[datetime]: 1730 | """Extract expiry time from a certificate string. 1731 | 1732 | Args: 1733 | certificate (str): x509 certificate as a string 1734 | 1735 | Returns: 1736 | Optional[datetime]: Expiry datetime or None 1737 | """ 1738 | try: 1739 | certificate_object = x509.load_pem_x509_certificate(data=certificate.encode()) 1740 | return certificate_object.not_valid_after 1741 | except ValueError: 1742 | logger.warning("Could not load certificate.") 1743 | return None 1744 | --------------------------------------------------------------------------------