From 30692614e59fb79af33d5ea6d754ce8a3b6f4b54 Mon Sep 17 00:00:00 2001 From: Allan Eising Date: Tue, 1 Apr 2025 18:35:11 +0200 Subject: [PATCH] check in current project state --- README.md | 19 +- pyproject.toml | 11 + src/sshecret/api.py | 446 ++++++++++++++++++ src/sshecret/audit.py | 8 +- src/sshecret/backends/file_table.py | 12 + src/sshecret/config.py | 18 + src/sshecret/crypto.py | 23 +- src/sshecret/dev_cli.py | 1 - src/sshecret/keepass.py | 118 ++++- src/sshecret/password_readers.py | 26 +- src/sshecret/server/ssh_password_reader.py | 36 ++ src/sshecret/settings.py | 82 ++++ src/sshecret/shell/__init__.py | 1 + src/sshecret/shell/admin_shell.py | 55 +++ src/sshecret/shell/commands.py | 14 + src/sshecret/shell/shell_client.py | 29 ++ src/sshecret/shell/shell_context.py | 45 ++ src/sshecret/testing.py | 70 ++- src/sshecret/types.py | 86 +++- src/sshecret/webapi/__init__.py | 1 + src/sshecret/webapi/api.py | 279 +++++++++++ src/sshecret/webapi/api_client.py | 25 + src/sshecret/webapi/frontend.py | 33 ++ src/sshecret/webapi/models.py | 71 +++ src/sshecret/webapi/router.py | 16 + tests/test_admin_api.py | 416 +++++++++++++++++ tests/test_client_backend.py | 24 + tests/test_keepass.py | 5 +- tests/test_password_readers.py | 4 +- uv.lock | 510 +++++++++++++++++++++ 30 files changed, 2412 insertions(+), 72 deletions(-) create mode 100644 src/sshecret/api.py create mode 100644 src/sshecret/config.py create mode 100644 src/sshecret/server/ssh_password_reader.py create mode 100644 src/sshecret/settings.py create mode 100644 src/sshecret/shell/__init__.py create mode 100644 src/sshecret/shell/admin_shell.py create mode 100644 src/sshecret/shell/commands.py create mode 100644 src/sshecret/shell/shell_client.py create mode 100644 src/sshecret/shell/shell_context.py create mode 100644 src/sshecret/webapi/__init__.py create mode 100644 src/sshecret/webapi/api.py create mode 100644 src/sshecret/webapi/api_client.py create mode 100644 src/sshecret/webapi/frontend.py create mode 100644 src/sshecret/webapi/models.py create mode 100644 src/sshecret/webapi/router.py create mode 100644 tests/test_admin_api.py diff --git a/README.md b/README.md index 4d7c4eb..97ada9c 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ consuming a lot more time and energy than what feels justified. This system has been created to provide a centralized solution that works well-enough. +One clear goal was to have all the complexity on the server-side, and be able to construct a minimal client. + ## Components This system has been designed with modularity and extensibility in mind. It has the following building blocks: @@ -47,13 +49,14 @@ If permitted to access the secret, it will returned encrypted with the client RS This allows the client to decrypt and get the clear text value easily. -## Usage +# FAQ +## Why not use Age? +I like age a lot, and it's ability to use more ssh key types is certainly a winner feature. +However, one goal here is to be able to construct a client with minimal dependencies, and that speaks in favor of the current solution. -# Next step -## Rewrite encryption to use age -The RSA implementation works alright, but requires some work on the client side converting back to a readable format. -Age seem better suited, as it can also use ed25519 keys. +Using just RSA keys, you can construct a client using only the following tools: +- base64 +- openssl +- ssh - -## Dedicated client? -If `age` works out, it may be entirely unnecessary to have a dedicated client. Who knows... +This means that you can create a client using just a shell script. diff --git a/pyproject.toml b/pyproject.toml index 3c294ac..b0aa715 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,11 +10,18 @@ requires-python = ">=3.13" dependencies = [ "asyncssh>=2.20.0", "click>=8.1.8", + "click-repl>=0.3.0", + "click-shell", "cryptography>=44.0.2", + "fastapi[standard]>=0.115.12", + "httpx>=0.28.1", + "jinja2>=3.1.6", "littletable>=3.0.1", "paramiko>=3.5.1", "pydantic>=2.10.6", + "pydantic-settings>=2.8.1", "pykeepass>=4.1.1.post1", + "python-dotenv>=1.0.1", "python-json-logger>=3.3.0", ] @@ -40,9 +47,13 @@ executionEnvironments = [ [tool.uv.workspace] members = ["packages/sshecret_client"] +[tool.uv.sources] +click-shell = { git = "https://github.com/clarkperkins/click-shell" } + [dependency-groups] dev = [ "construct-typing>=0.6.2", "mypy>=1.15.0", + "pytest>=8.3.5", "python-dotenv>=1.0.1", ] diff --git a/src/sshecret/api.py b/src/sshecret/api.py new file mode 100644 index 0000000..494de1e --- /dev/null +++ b/src/sshecret/api.py @@ -0,0 +1,446 @@ +"""API. + +This module is an attempt to create some sort of meaningfull API around the +actions exposed here. +""" + +import abc + +from contextlib import contextmanager +from collections.abc import Iterator + +from click import password_option +from pydantic.networks import IPvAnyAddress, IPvAnyNetwork + +from .audit import audit_message + +from .crypto import load_client_key, load_public_key, encrypt_string + +from .types import ( + BaseAPIClient, + BaseClientBackend, + BasePasswordManager, + BasePasswordReader, + ClientSpecification, + PasswordContext, +) + + +@contextmanager +def password_manager_session( + password_manager: BasePasswordManager, + password_context: PasswordContext | str, + api_client: BaseAPIClient, +) -> Iterator[BasePasswordManager]: + """Open password manager for read/write in a context.""" + audit_message( + "Opening password manager session", + "SECURITY", + source_address=api_client.source, + ) + password_manager.open_database(password_context) + yield password_manager + + audit_message( + "Closing password manager session", + "SECURITY", + source_address=api_client.source, + ) + password_manager.close_database() + + +class BaseSshecretAPI(abc.ABC): + """Base API class.""" + + def __init__( + self, + backend: BaseClientBackend, + api_client: BaseAPIClient, + manager_options: dict[str, str] | None = None, + ) -> None: + """Initialize API.""" + + self.backend: BaseClientBackend = backend + self.api_client: BaseAPIClient = api_client + self.manager_options: dict[str, str] | None = manager_options + + def _log_audit( + self, + message: str, + audit_type: str, + client_name: str | None = None, + **details: str, + ) -> None: + """Log an audit message.""" + audit_message( + message, + audit_type, + client_name, + source_address=self.api_client.source, + **details, + ) + + @contextmanager + def password_session( + self, reader: BasePasswordReader | None = None, password: str | None = None + ) -> Iterator[BasePasswordManager]: + """Open a password session.""" + if password: + context = password + else: + if not reader: + reader = self.api_client.get_reader() + context = self.api_client.get_context(reader) + + password_manager = self.api_client.password_manager(self.manager_options) + with password_manager_session( + password_manager, context, self.api_client + ) as session: + yield session + + +class ClientManagementAPI(BaseSshecretAPI): + """API for managing clients.""" + + def __init__( + self, + backend: BaseClientBackend, + client: ClientSpecification, + api_client: BaseAPIClient, + manager_options: dict[str, str] | None = None, + ) -> None: + """Create client management API instance.""" + super().__init__(backend, api_client, manager_options) + self.client: ClientSpecification = client + self.__password_manager: BasePasswordManager | None = None + + def log_security(self, message: str, **details: str) -> None: + """Log a security related message.""" + self._log_audit(message, "SECURITY", self.client.name, **details) + + def log_info(self, message: str) -> None: + """Log an informational message.""" + self._log_audit(message, "INFORMATIONAL", self.client.name) + + @property + def password_manager(self) -> BasePasswordManager: + """Get password manager.""" + if self.__password_manager: + self.log_security("Accessed password manager") + return self.__password_manager + raise RuntimeError("Password manager not initialized.") + + @password_manager.setter + def password_manager(self, instance: BasePasswordManager) -> None: + """Set password manager instance.""" + self.log_security("Opened password manager.") + self.__password_manager = instance + + def get_secrets(self) -> list[str]: + """Get names of the secrets that the client has access to..""" + self.log_security("Listing secret names.") + return list(self.client.secrets.keys()) + + def update_client( + self, + client: ClientSpecification, + reader: BasePasswordReader | None = None, + password: str | None = None, + ) -> ClientSpecification: + """Update client.""" + self.log_info("Updating client") + update_data = client.model_dump(exclude_unset=True) + if client.public_key != self.client.public_key: + self.log_security("Client public key has changed.") + if client.secrets != self.client.secrets: + raise RuntimeError( + "Error: Cannot update public key and secrets in the same operation." + ) + del update_data["secrets"] + secrets = self._re_encrypt(client.public_key, reader, password) + update_data["secrets"] = secrets + + updated_client = self.client.model_copy(update=update_data) + self.backend.update_client(self.client.name, updated_client) + self.client = updated_client + return updated_client + + def update_secret(self, name: str, password: str) -> None: + """Update a secret. + + If secret is not already a part of the client, it will be added. + """ + if name in self.client.secrets: + self.log_security("Updating secret", secret_name=name) + else: + self.log_security("Adding secret", secret_name=name) + public_key = load_client_key(self.client) + encrypted = encrypt_string(password, public_key) + client_secrets = {**self.client.secrets, name: encrypted} + updated_client = self.client.model_copy(update={"secrets": client_secrets}) + self.backend.update_client(self.client.name, updated_client) + self.client = updated_client + + def _re_encrypt( + self, + new_key: str, + reader: BasePasswordReader | None = None, + password: str | None = None, + ) -> dict[str, str]: + """Update public key on given client.""" + audit_message( + "Updating public key", + "INFORMATIONAL", + self.client.name, + source_address=self.api_client.source, + ) + if password: + context = password + else: + if not reader: + reader = self.api_client.get_reader() + context = self.api_client.get_context(reader) + + password_manager = self.api_client.password_manager(self.manager_options) + self.password_manager = password_manager + + client_key = load_public_key(new_key.encode()) + secrets: dict[str, str] = {} + with password_manager_session( + password_manager, context, self.api_client + ) as password_session: + for name in self.get_secrets(): + secret = password_session.get_password(name) + audit_message( + "Updating encrypted value", + "SECURITY", + self.client.name, + secret_name=name, + source_address=self.api_client.source, + ) + new_value = encrypt_string(secret, client_key) + secrets[name] = new_value + + return secrets + + def update_public_key( + self, + new_key: str, + reader: BasePasswordReader | None = None, + password: str | None = None, + ) -> None: + """Update the public key.""" + audit_message( + "Updating public key", + "INFORMATIONAL", + self.client.name, + source_address=self.api_client.source, + ) + secrets = self._re_encrypt(new_key, reader, password) + + updated = self.client.model_copy(update={"secrets": secrets}) + self.backend.update_client(self.client.name, updated) + + @classmethod + def get_client( + cls, + backend: BaseClientBackend, + name: str, + api_client: BaseAPIClient, + ) -> "ClientManagementAPI | None": + """Get client.""" + client = backend.lookup_name(name) + if not client: + return None + return cls(backend, client, api_client) + + @classmethod + def create_client( + cls, + backend: BaseClientBackend, + name: str, + api_client: BaseAPIClient, + public_key: str, + allowed_ips: list[IPvAnyAddress | IPvAnyNetwork] | str = "*", + ) -> "ClientManagementAPI": + """Create a client.""" + client = ClientSpecification( + name=name, public_key=public_key, allowed_ips=allowed_ips + ) + backend.add_client(client) + return cls(backend, client, api_client) + + +class ManagementApi(BaseSshecretAPI): + """Api for general management.""" + + def __init__( + self, + backend: BaseClientBackend, + api_client: BaseAPIClient, + manager_options: dict[str, str] | None = None, + ) -> None: + """Initialize API.""" + super().__init__(backend, api_client, manager_options) + + def log_security( + self, message: str, client_name: str | None = None, **details: str + ) -> None: + """Log a security related message.""" + self._log_audit(message, "SECURITY", client_name, **details) + + def log_info(self, message: str, client_name: str | None = None) -> None: + """Log an informational message.""" + self._log_audit(message, "INFORMATIONAL", client_name) + + def get_client(self, name: str) -> ClientManagementAPI | None: + """Get a client.""" + client = self.backend.lookup_name(name) + if not client: + return None + return ClientManagementAPI( + self.backend, client, self.api_client, self.manager_options + ) + + def get_clients(self) -> list[ClientSpecification]: + """Get clients.""" + self.log_info("Fetched all clients") + return self.backend.get_all() + + def _get_clients(self) -> list[ClientSpecification]: + """Get clients.""" + return self.backend.get_all() + + def delete_client(self, name: str) -> None: + """Delete client.""" + client = self.backend.lookup_name(name) + if not client: + self.log_info("Attempted to delete a non-existing client.", name) + return + self.log_security("Deleting client", name) + self.backend.remove_client(name) + + def create_client( + self, + name: str, + public_key: str, + allowed_ips: list[IPvAnyAddress | IPvAnyNetwork] | str = "*", + ) -> ClientManagementAPI: + """Create a client.""" + self.log_info("Creating new client", name) + client = ClientSpecification( + name=name, public_key=public_key, allowed_ips=allowed_ips + ) + self.backend.add_client(client) + return ClientManagementAPI(self.backend, client, self.api_client) + + def get_secret_names( + self, reader: BasePasswordReader | None = None, password: str | None = None + ) -> dict[str, list[str]]: + """Get secret names and which clients have these..""" + self.log_security("Listing all secret names.") + with self.password_session(reader=reader, password=password) as session: + secret_names = session.get_entries() + + secret_mapping: dict[str, list[str]] = {} + for name in secret_names: + secret_mapping[name] = [ + client.name for client in self.backend.lookup_by_secret(name) + ] + + return secret_mapping + + def add_secret( + self, + name: str, + secret_value: str | None, + reader: BasePasswordReader | None = None, + password: str | None = None, + ) -> str: + """Add a secret.""" + self.log_security("Adding new secret", secret_name=name) + with self.password_session(reader=reader, password=password) as session: + if not secret_value: + self.log_security("Auto-generating a secret value", secret_name=name) + secret_value = session.generate_password(name) + else: + session.add_password(name, secret_value) + return secret_value + + def get_secret( + self, + name: str, + reader: BasePasswordReader | None = None, + password: str | None = None, + ) -> str | None: + """Get the clear-text value of a secret.""" + self.log_security("Client requested secret value", secret_name=name) + with self.password_session(reader=reader, password=password) as session: + secret = session.get_password(name) + + return secret + + def update_secret( + self, + name: str, + new_value: str, + reader: BasePasswordReader | None = None, + password: str | None = None, + ) -> None: + """Update a secret with a given name.""" + self.log_security("Changing secret", secret_name=name) + with self.password_session(reader=reader, password=password) as session: + session.change_password(name, new_value) + + clients = self.backend.lookup_by_secret(name) + for client in clients: + client_api = self.get_client(client.name) + if not client_api: + continue + client_api.update_secret(name, new_value) + + def regenerate_secret( + self, + name: str, + reader: BasePasswordReader | None = None, + password: str | None = None, + ) -> str: + """Regenerate a secret.""" + self.log_security("Generating a new secret value", secret_name=name) + with self.password_session(reader=reader, password=password) as session: + new_value = session.change_password(name, None) + + clients = self.backend.lookup_by_secret(name) + for client in clients: + client_api = self.get_client(client.name) + if not client_api: + continue + client_api.update_secret(name, new_value) + + return new_value + + def delete_secret( + self, + name: str, + reader: BasePasswordReader | None = None, + password: str | None = None, + ) -> None: + """Delete secret.""" + clients = self.backend.lookup_by_secret(name) + self.log_security("Deleting secret", secret_name=name) + with self.password_session(reader=reader, password=password) as session: + session.delete_password(name) + + for client in clients: + secrets = {**client.secrets} + del secrets[name] + new_client = client.model_copy(update={"secrets": secrets}) + client_api = self.get_client(client.name) + if not client_api: + continue + self.log_security( + "Removing secret from client.", + client_name=client.name, + secret_name=name, + ) + client_api.update_client(new_client, password=password) diff --git a/src/sshecret/audit.py b/src/sshecret/audit.py index 3355e54..0894d1e 100644 --- a/src/sshecret/audit.py +++ b/src/sshecret/audit.py @@ -32,7 +32,6 @@ class AuditMessage(BaseModel): client_name: str | None = None source_address: str | None = None secret_name: str | None = None - details: str | None = None def __str__(self) -> str: """Stringify object as JSON.""" @@ -45,7 +44,7 @@ def audit_message( client_name: str | None = None, secret_name: str | None = None, source_address: str | None = None, - details: str | None = None, + **details: str ) -> None: """Create an audit message.""" if not audit_type: @@ -60,7 +59,8 @@ def audit_message( client_name=client_name, source_address=source_address, secret_name=secret_name, - details=details, ) - AUDIT_LOG.info(audit_message.model_dump(exclude_none=True)) + audit_dict = audit_message.model_dump(exclude_none=True) + + AUDIT_LOG.info({**audit_dict, **details}) diff --git a/src/sshecret/backends/file_table.py b/src/sshecret/backends/file_table.py index 57e7abd..4883ec1 100644 --- a/src/sshecret/backends/file_table.py +++ b/src/sshecret/backends/file_table.py @@ -32,6 +32,7 @@ def load_clients_from_dir(directory: Path) -> dict[Path, ClientSpecification]: return clients + class FileTableBackend(BaseClientBackend): """In-memory littletable based backend.""" @@ -119,3 +120,14 @@ class FileTableBackend(BaseClientBackend): if existing: self.table.remove(existing) self.add_client(spec) + + @override + def get_all(self) -> list[ClientSpecification]: + """Get all clients.""" + return list(self.table) + + @override + def lookup_by_secret(self, secret_name: str) -> list[ClientSpecification]: + """Lookup by secret name.""" + results = self.table.where(lambda client: secret_name in client.secrets) + return list(results) diff --git a/src/sshecret/config.py b/src/sshecret/config.py new file mode 100644 index 0000000..75915a4 --- /dev/null +++ b/src/sshecret/config.py @@ -0,0 +1,18 @@ +"""Config file.""" +from pathlib import Path +from pydantic import SecretStr +from pydantic_settings import BaseSettings + + +class KeepassSettings(BaseSettings): + """Settings for Keepasss password database.""" + + database_path: Path + + +class SshecretSettings(BaseSettings): + """Settings model.""" + + admin_password: SecretStr + admin_ssh_key: str | None = None + keepass: KeepassSettings diff --git a/src/sshecret/crypto.py b/src/sshecret/crypto.py index b863ebe..8afd75d 100644 --- a/src/sshecret/crypto.py +++ b/src/sshecret/crypto.py @@ -1,9 +1,14 @@ -"""Encryption related functions.""" +"""Encryption related functions. + +Note! Encryption uses the less secure PKCS1v15 padding. This is to allow +decryption via openssl on the command line. + +""" import base64 import logging from pathlib import Path -from cryptography.hazmat.primitives import serialization, hashes +from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import rsa from cryptography.hazmat.primitives.asymmetric import padding @@ -23,6 +28,8 @@ def load_public_key(keybytes: bytes) -> rsa.RSAPublicKey: public_key = serialization.load_ssh_public_key(keybytes) if not isinstance(public_key, rsa.RSAPublicKey): raise RuntimeError("Only RSA keys are supported.") + pem_public_key = public_key.public_bytes(encoding=serialization.Encoding.PEM, format=serialization.PublicFormat.SubjectPublicKeyInfo) + LOG.info("pem:\n%s", pem_public_key) return public_key @@ -40,11 +47,7 @@ def encrypt_string(string: str, public_key: rsa.RSAPublicKey) -> str: message = string.encode() ciphertext = public_key.encrypt( message, - padding.OAEP( - mgf=padding.MGF1(algorithm=hashes.SHA256()), - algorithm=hashes.SHA256(), - label=None, - ), + padding.PKCS1v15(), ) return base64.b64encode(ciphertext).decode() @@ -54,11 +57,7 @@ def decode_string(ciphertext: str, private_key: rsa.RSAPrivateKey) -> str: decoded = base64.b64decode(ciphertext) decrypted = private_key.decrypt( decoded, - padding.OAEP( - mgf=padding.MGF1(algorithm=hashes.SHA256()), - algorithm=hashes.SHA256(), - label=None, - ), + padding.PKCS1v15(), ) return decrypted.decode() diff --git a/src/sshecret/dev_cli.py b/src/sshecret/dev_cli.py index 1792521..cedcf0d 100644 --- a/src/sshecret/dev_cli.py +++ b/src/sshecret/dev_cli.py @@ -89,6 +89,5 @@ def run_async_server(directory: str, port: int) -> None: loop.run_forever() - if __name__ == "__main__": cli() diff --git a/src/sshecret/keepass.py b/src/sshecret/keepass.py index 4364d9c..144b065 100644 --- a/src/sshecret/keepass.py +++ b/src/sshecret/keepass.py @@ -2,7 +2,7 @@ import logging from pathlib import Path -from typing import final, override, Self +from typing import cast, final, overload, override, Self import pykeepass from . import constants @@ -18,11 +18,32 @@ class KeepassManager(BasePasswordManager): master_password_identifier = constants.MASTER_PASSWORD - def __init__(self, location: Path) -> None: + def __init__(self) -> None: """Initialize password manager.""" - self.location = location + self._location: Path | None = None self._keepass: pykeepass.PyKeePass | None = None + @property + def location(self) -> Path: + """Get location.""" + if not self._location: + raise RuntimeError("No location has been specified.") + return self._location + + @location.setter + def location(self, location: Path) -> None: + """Set location.""" + if not location.exists() or not location.is_file(): + raise RuntimeError("Unable to read provided password file.") + self._location = location + + @override + def set_manager_options(self, options: dict[str, str]) -> None: + """Set manager options.""" + if "location" in options: + location = Path(str(options["location"])) + self.location = location + @property def keepass(self) -> pykeepass.PyKeePass: """Return keepass instance.""" @@ -35,27 +56,45 @@ class KeepassManager(BasePasswordManager): """Set the keepass instance.""" self._keepass = instance + @override + def get_entries(self) -> list[str]: + """Get all entries.""" + entries = self.keepass.entries + if not entries: + return [] + return [ + str(entry.title) for entry in entries + ] + + @override @classmethod def create_database( - cls, location: str, reader_context: PasswordContext, overwrite: bool = False + cls, location: str, password_context: PasswordContext | str, overwrite: bool = False ) -> Self: """Create database.""" if Path(location).exists() and not overwrite: raise RuntimeError("Error: Database exists.") - master_password = reader_context.get_password(cls.master_password_identifier) + if isinstance(password_context, PasswordContext): + master_password = password_context.get_password(cls.master_password_identifier, True) + else: + master_password = password_context # TODO: should we delete if overwrite is set? keepass = pykeepass.create_database(location, password=master_password) - instance = cls(Path(location)) + instance = cls() + instance.set_manager_options({"location": str(location)}) instance.keepass = keepass return instance @override - def open_database(self, reader: PasswordContext) -> None: + def open_database(self, password_context: PasswordContext | str) -> None: """Open the database""" - password = reader.get_password(self.master_password_identifier) + if isinstance(password_context, PasswordContext): + password = password_context.get_password(self.master_password_identifier) + else: + password = password_context instance = pykeepass.PyKeePass(str(self.location.absolute()), password=password) self.keepass = instance @@ -65,14 +104,17 @@ class KeepassManager(BasePasswordManager): self._keepass = None @override - def get_password(self, identifier: str) -> str: + def get_password(self, identifier: str) -> str | None: """Get password.""" - if entry := self.keepass.find_entries(title=identifier, first=True): - if password := entry.password: - return str(password) + entry = cast("pykeepass.entry.Entry | None", self.keepass.find_entries(title=identifier, first=True)) + if not entry: + return None + if password := cast(str, entry.password): + return str(password) raise RuntimeError(f"Cannot get password for entry {identifier}") + @override def generate_password(self, identifier: str) -> str: """Generate password.""" @@ -84,3 +126,55 @@ class KeepassManager(BasePasswordManager): self.keepass.save() LOG.debug("Created Entry %r", _entry) return password + + @override + def add_password(self, identifier: str, password: str) -> None: + """Add a password.""" + entry = cast("pykeepass.entry.Entry | None", self.keepass.find_entries(title=identifier, first=True)) + if not entry: + _entry = self.keepass.add_entry(self.keepass.root_group, identifier, constants.NO_USERNAME, password) + self.keepass.save() + LOG.debug("Created entry %r", _entry) + return + self.change_password(identifier, password) + LOG.debug("Updated password on entry %r", entry) + + + @overload + def change_password(self, identifier: str, password: None) -> str: ... + + @overload + def change_password(self, identifier: str, password: str) -> None: ... + + @override + def change_password(self, identifier: str, password: str | None) -> str | None: + """Change a password.""" + generated_password = False + if not password: + password = generate_password() + generated_password = True + + entry = cast("pykeepass.entry.Entry | None", self.keepass.find_entries(title=identifier, first=True)) + + if not entry: + raise ValueError("Error: Entry not found!") + + entry.password = password + + self.keepass.save() + if generated_password: + return password + + return None + + @override + def delete_password(self, identifier: str) -> None: + """Delete password.""" + entry = cast("pykeepass.entry.Entry | None", self.keepass.find_entries(title=identifier, first=True)) + if not entry: + return + LOG.info("Deleting entry %s for keepass.", entry.uuid) + + self.keepass.delete_entry(entry) + + self.keepass.save() diff --git a/src/sshecret/password_readers.py b/src/sshecret/password_readers.py index 0b5e7a0..0cff46b 100644 --- a/src/sshecret/password_readers.py +++ b/src/sshecret/password_readers.py @@ -7,7 +7,8 @@ InputPasswordReader and EnvironmentPasswordReader. import re import os -from typing import override +import sys +from typing import TextIO, override import click from .types import BasePasswordReader @@ -20,11 +21,10 @@ class InputPasswordReader(BasePasswordReader): """Read a password from stdin.""" @override - @classmethod - def get_password(cls, identifier: str) -> str: + def get_password(self, identifier: str, repeated: bool = False) -> str: """Get password.""" if password := click.prompt( - f"Enter password for {identifier}", hide_input=True, type=str + f"Enter password for {identifier}", hide_input=True, type=str, confirmation_prompt=repeated ): return str(password) raise ValueError("No password received.") @@ -37,13 +37,9 @@ class EnvironmentPasswordReader(BasePasswordReader): Final environemnt variable will be validated according to the regex `[a-zA-Z_]+[a-zA-Z0-9_]*` """ - def __init__(self, identifier: str) -> None: - """Initialize class.""" - self._identifier: str = identifier - - def _resolve_var_name(self) -> str: + def _resolve_var_name(self, identifier: str) -> str: """Resolve variable name.""" - identifier = self._identifier.replace("-", "_") + identifier = identifier.replace("-", "_") fields = [constants.VAR_PREFIX, identifier] varname = "_".join(fields) if not RE_VARNAME.fullmatch(varname): @@ -52,16 +48,14 @@ class EnvironmentPasswordReader(BasePasswordReader): ) return varname - def get_password_from_env(self) -> str: + def get_password_from_env(self, identifier: str) -> str: """Get password from environment.""" - varname = self._resolve_var_name() + varname = self._resolve_var_name(identifier) if password := os.getenv(varname, None): return password raise ValueError(f"Error: No variable named {varname} resolved.") @override - @classmethod - def get_password(cls, identifier: str) -> str: + def get_password(self, identifier: str, repeated: bool = False) -> str: """Get password.""" - instance = cls(identifier) - return instance.get_password_from_env() + return self.get_password_from_env(identifier) diff --git a/src/sshecret/server/ssh_password_reader.py b/src/sshecret/server/ssh_password_reader.py new file mode 100644 index 0000000..7aeb50c --- /dev/null +++ b/src/sshecret/server/ssh_password_reader.py @@ -0,0 +1,36 @@ +"""Password reader for use with the SSH server.""" + +from typing import override, TextIO +import asyncssh +from sshecret.types import BasePasswordReader + + +class SSHPasswordReader(BasePasswordReader): + """SSH Password reader.""" + + def __init__(self, channel: asyncssh.SSHLineEditorChannel, stdin: asyncssh.SSHReader[str], stdout: asyncssh.SSHWriter[str]) -> None: + """Initialize password reader.""" + self.channel: asyncssh.SSHLineEditorChannel = channel + self.stdin: asyncssh.SSHReader[str] = stdin + self.stdout: asyncssh.SSHWriter[str] = stdout + + @override + def get_password(self, identifier: str, repeated: bool = False) -> str: + """Get password.""" + raise RuntimeError("Use get_password_async!") + + async def get_password_async(self, identifier: str, repeated: bool = False) -> str: + """Get password async.""" + self.stdout.write(f"Enter password for {identifier}: ") + self.channel.set_echo(False) + while True: + password = await self.stdin.readline() + if not repeated: + break + self.stdout.write(f"\nRe-enter password for {identifier}: ") + password2 = await self.stdin.readline() + if password == password2: + break + self.stdout.write(f"Passwords did not match. Try again.\n") + self.channel.set_echo(True) + return password.strip() diff --git a/src/sshecret/settings.py b/src/sshecret/settings.py new file mode 100644 index 0000000..9e86e0f --- /dev/null +++ b/src/sshecret/settings.py @@ -0,0 +1,82 @@ +"""Get settings.""" + +import abc +import enum +import os +import tomllib +from pathlib import Path +from typing import Literal +from dotenv import load_dotenv + +from pydantic import BaseModel, DirectoryPath, Field, FilePath +from pydantic_settings import BaseSettings, SettingsConfigDict + +from sshecret.keepass import KeepassManager + +SETTINGS_FILE = "sshecret.toml" + + +class Backend(enum.StrEnum): + """Supported backends.""" + + FILES = "FILES" + + +class PasswordManager(enum.StrEnum): + """Supported password managers.""" + + KEEPASS = "KeePass" + + +class SSHServerSettings(BaseModel): + """SSH Server settings.""" + + port: int = 22 + private_key: FilePath | None = None + + +class AdminApiSettings(BaseModel): + """Admin API settings.""" + + port: int = 8022 + + +class FileBackendSettings(BaseModel): + """File backend settings. + + This will eventually have the Discriminator pattern described in pydantic. + """ + + type: Literal["Files"] + + location: DirectoryPath + + +class KeepassPDBSettings(BaseModel): + """Keepass backend settings.""" + + type: Literal["KeePass"] + location: FilePath + +class Settings(BaseSettings): + """Sshecret settings.""" + + model_config = SettingsConfigDict(env_prefix="sshecret_", env_nested_delimiter="__") + + backend: FileBackendSettings + password_manager: KeepassPDBSettings + admin_api: AdminApiSettings = Field(default_factory=AdminApiSettings) + ssh_server: SSHServerSettings = Field(default_factory=SSHServerSettings) + + +def get_settings() -> Settings: + """Get settings.""" + cwd = Path(os.getcwd()) + settings_file = cwd / SETTINGS_FILE + if not settings_file.exists(): + # This should fail if the current env variables don't exist. + return Settings() # pyright: ignore[reportCallIssue] + with open(settings_file, "rb") as f: + settings_data = tomllib.load(f) + + return Settings.model_validate(settings_data) diff --git a/src/sshecret/shell/__init__.py b/src/sshecret/shell/__init__.py new file mode 100644 index 0000000..a0e2394 --- /dev/null +++ b/src/sshecret/shell/__init__.py @@ -0,0 +1 @@ +"""Shell interface.""" diff --git a/src/sshecret/shell/admin_shell.py b/src/sshecret/shell/admin_shell.py new file mode 100644 index 0000000..b213fc2 --- /dev/null +++ b/src/sshecret/shell/admin_shell.py @@ -0,0 +1,55 @@ +"""Admin shell.""" + +import os +import click +from click_repl import register_repl + +from sshecret.api import ClientManagementAPI + +from sshecret import constants +from sshecret.password_readers import InputPasswordReader +from sshecret.keepass import KeepassManager +from sshecret.types import PasswordContext +from .shell_client import ShellClient + +DB_PATH = os.path.join(os.getcwd(), "sshecrets.kdbx") + +api_client: ShellClient | None = None + + +@click.group() +@click.pass_context +def cli(ctx: click.Context) -> None: + """General CLI.""" + if api_client is None: + raise RuntimeError("No client object defined.") + +@cli.group(name="clients") +def cmd_clients() -> None: + """Client context.""" + +@cmd_clients.command(name="show") +def show_clients() -> None: + """Show clients.""" + example_set = ["client1", "client2", "client3"] + for client in example_set: + click.echo(f"- {client}") + +@cmd_clients.command(name="add") +@click.argument("name") +def add_client(name: str) -> None: + """Add a client.""" + public_key = click.prompt("Please paste RSA public key") + + + +@cli.command() +@click.option("--overwrite", is_flag=True, help="Overwrite password database.") +def create_database(overwrite: bool) -> None: + """Create database.""" + context = PasswordContext(InputPasswordReader) + KeepassManager.create_database(DB_PATH, context, overwrite) + + +if __name__ == "__main__": + api_client = ShellClient("127.0.0.1", KeepassManager) diff --git a/src/sshecret/shell/commands.py b/src/sshecret/shell/commands.py new file mode 100644 index 0000000..724997e --- /dev/null +++ b/src/sshecret/shell/commands.py @@ -0,0 +1,14 @@ +"""Shell commands. + +The shell needs to implement the following shell commands: + +- Client management +client create/read/update/delete +secret create/read/update/delete +client permit secret +client revoke secret +client key rotate + +audit show + +""" diff --git a/src/sshecret/shell/shell_client.py b/src/sshecret/shell/shell_client.py new file mode 100644 index 0000000..6a7eff2 --- /dev/null +++ b/src/sshecret/shell/shell_client.py @@ -0,0 +1,29 @@ +"""Shell API Client object for auditing.""" + +from dataclasses import dataclass, field +from typing import override +from sshecret.password_readers import InputPasswordReader +from sshecret.types import BaseAPIClient, BasePasswordManager, BasePasswordReader, PasswordContext + + +@dataclass(frozen=True) +class ShellClient(BaseAPIClient): + """Client connecting from local host.""" + + source: str + password_manager_type: type[BasePasswordManager] + method: str = field(init=False, default="shell") + + @override + def get_reader(self) -> type[BasePasswordReader]: + """Get reader.""" + return InputPasswordReader + + @override + def password_manager(self, manager_options: dict[str, str] | None = None) -> BasePasswordManager: + """Instantiate password manager.""" + manager_instance = self.password_manager_type() + if manager_options: + manager_instance.set_manager_options(manager_options) + + return manager_instance diff --git a/src/sshecret/shell/shell_context.py b/src/sshecret/shell/shell_context.py new file mode 100644 index 0000000..8904a6d --- /dev/null +++ b/src/sshecret/shell/shell_context.py @@ -0,0 +1,45 @@ +"""Shell context manager.""" + +import sys +from dataclasses import dataclass +from contextlib import contextmanager +from contextvars import ContextVar +from typing import Iterator, TextIO +from click_shell.core import Shell + +from sshecret.api import ManagementApi +from sshecret.password_readers import InputPasswordReader +from sshecret.types import BaseClientBackend, BasePasswordManager + +from .shell_client import ShellClient + + +@dataclass(frozen=True) +class ShellContext: + """Shell context.""" + + api: ManagementApi + shell: Shell + streams: tuple[TextIO, TextIO] | None = None + + + + + + +@contextmanager +def shell_session( + shell: Shell, + backend: BaseClientBackend, + password_manager: type[BasePasswordManager], + source_address: str, + manager_options: dict[str, str] | None = None, +) -> Iterator[ShellContext]: + """Start a shell session. + + The idea here is to collect the context, store it in an instance variable, + and run the shell. + """ + reader = InputPasswordReader + client = ShellClient(source_address, password_manager) + api = ManagementApi(backend, client, manager_options) diff --git a/src/sshecret/testing.py b/src/sshecret/testing.py index 5e9bb59..927211c 100644 --- a/src/sshecret/testing.py +++ b/src/sshecret/testing.py @@ -1,11 +1,16 @@ """Testing utilities and classes.""" +from io import StringIO import tempfile from dataclasses import dataclass, field from contextlib import contextmanager from pathlib import Path from collections.abc import Iterator -from .utils import create_client_file +from dotenv import load_dotenv + +from .utils import create_client_file, generate_password +from . import settings as app_settings +from .keepass import KeepassManager @dataclass @@ -16,6 +21,46 @@ class TestClientSpec: secrets: dict[str, str] = field(default_factory=dict) +@dataclass +class TestContext: + """Test context.""" + + path: Path + master_password: str + + @property + def password_database(self) -> Path: + """Return password database location.""" + return self.path / "test.kdbx" + + def get_settings(self) -> app_settings.Settings: + """Get settings.""" + return app_settings.Settings( + backend=app_settings.BackendSettings( + backend=app_settings.FileBackendSettings( + type="Files", location=self.path + ), + ), + password_manager=app_settings.PasswordManagerSettings( + manager=app_settings.KeepassPDBSettings( + type="KeePass", location=self.password_database + ) + ), + ) + + +def set_environment(context: TestContext) -> None: + """Set environment.""" + password_path = str(context.password_database) + env: list[str] = [ + f"sshecret_backend__backend_location={str(context.path)}", + "sshecret_backend__password_manager__manager_type=KeePass", + f"sshecret_backend__password_manager__manager_location={password_path}", + ] + env_str = StringIO("\n".join(env)) + load_dotenv(stream=env_str) + + @contextmanager def test_context(clients: list[TestClientSpec]) -> Iterator[Path]: """Create a test context.""" @@ -26,3 +71,26 @@ def test_context(clients: list[TestClientSpec]) -> Iterator[Path]: create_client_file(client.name, filename, client.secrets) yield dirpath + + +@contextmanager +def api_context(clients: list[TestClientSpec]) -> Iterator[TestContext]: + """Create a context for testing the full API.""" + with tempfile.TemporaryDirectory() as tmpdir: + dirpath = Path(tmpdir) + master_password = generate_password() + context = TestContext(dirpath, master_password) + keepass = KeepassManager.create_database( + str(context.password_database), master_password + ) + seen_secrets: list[str] = [] + for client in clients: + filename = dirpath / f"{client.name}.json" + create_client_file(client.name, filename, client.secrets) + for secret, value in client.secrets.items(): + if secret in seen_secrets: + continue + keepass.add_password(secret, value) + seen_secrets.append(secret) + + yield context diff --git a/src/sshecret/types.py b/src/sshecret/types.py index 1faf786..cd2b4ba 100644 --- a/src/sshecret/types.py +++ b/src/sshecret/types.py @@ -1,41 +1,41 @@ """Interfaces and types.""" import abc -from typing import Self +from types import NotImplementedType +from typing import Self, overload -from pydantic import BaseModel, field_serializer +from pydantic import BaseModel from pydantic.networks import IPvAnyAddress, IPvAnyNetwork class BasePasswordReader(abc.ABC): """Abstract strategy class to read a passwords.""" - @classmethod @abc.abstractmethod - def get_password(cls, identifier: str) -> str: + def get_password(self, identifier: str, repeated: bool = False) -> str: """Resolve the password, e.g., via input.""" class PasswordContext: """Context class for resolving a password.""" - def __init__(self, reader: type[BasePasswordReader]) -> None: + def __init__(self, reader: BasePasswordReader) -> None: """Initialize password context.""" - self._reader: type[BasePasswordReader] = reader + self._reader: BasePasswordReader = reader @property - def reader(self) -> type[BasePasswordReader]: + def reader(self) -> BasePasswordReader: """Return reader.""" return self._reader @reader.setter - def reader(self, reader: type[BasePasswordReader]) -> None: + def reader(self, reader: BasePasswordReader) -> None: """Set the reader instance.""" self._reader = reader - def get_password(self, identifier: str) -> str: + def get_password(self, identifier: str, repeated: bool = False) -> str: """Get the password.""" - return self.reader.get_password(identifier) + return self.reader.get_password(identifier, repeated) class BasePasswordManager(abc.ABC): @@ -46,7 +46,10 @@ class BasePasswordManager(abc.ABC): @classmethod @abc.abstractmethod def create_database( - cls, location: str, reader_context: PasswordContext, overwrite: bool = False + cls, + location: str, + password_context: PasswordContext | str, + overwrite: bool = False, ) -> Self: """Create database. @@ -54,7 +57,7 @@ class BasePasswordManager(abc.ABC): """ @abc.abstractmethod - def open_database(self, reader: PasswordContext) -> None: + def open_database(self, password_context: PasswordContext | str) -> None: """Open database.""" @abc.abstractmethod @@ -62,7 +65,7 @@ class BasePasswordManager(abc.ABC): """Close database.""" @abc.abstractmethod - def get_password(self, identifier: str) -> str: + def get_password(self, identifier: str) -> str | None: """Get a password from the manager.""" @abc.abstractmethod @@ -74,6 +77,32 @@ class BasePasswordManager(abc.ABC): Returns the generated password. """ + @abc.abstractmethod + def add_password(self, identifier: str, password: str) -> None: + """Add a pre-defined password.""" + + @abc.abstractmethod + def get_entries(self) -> list[str]: + """Get names of all entries.""" + + def set_manager_options(self, options: dict[str, str]) -> None: + """Set manager options.""" + pass + + @overload + def change_password(self, identifier: str, password: None) -> str: ... + + @overload + def change_password(self, identifier: str, password: str) -> None: ... + + @abc.abstractmethod + def change_password(self, identifier: str, password: str | None) -> str | None: + """Change password.""" + + @abc.abstractmethod + def delete_password(self, identifier: str) -> None: + """Delete a password.""" + class ClientSpecification(BaseModel): """Specification of client.""" @@ -117,3 +146,34 @@ class BaseClientBackend(abc.ABC): @abc.abstractmethod def remove_client(self, name: str, persistent: bool = True) -> None: """Delete a client.""" + + @abc.abstractmethod + def get_all(self) -> list[ClientSpecification]: + """Get all clients.""" + + @abc.abstractmethod + def lookup_by_secret(self, secret_name: str) -> list[ClientSpecification]: + """Lookup by the name of a secret.""" + + +class BaseAPIClient(abc.ABC): + """Base API Client.""" + + source: str + method: str + + @abc.abstractmethod + def password_manager( + self, manager_options: dict[str, str] | None = None + ) -> BasePasswordManager: + """Instantiate password manager.""" + + def get_reader(self) -> BasePasswordReader: + """Get the reader.""" + raise NotImplementedError("Class-based password reading not implemented.") + + def get_context(self, reader: BasePasswordReader | None = None) -> PasswordContext: + """Get password context.""" + if not reader: + reader = self.get_reader() + return PasswordContext(reader) diff --git a/src/sshecret/webapi/__init__.py b/src/sshecret/webapi/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/sshecret/webapi/__init__.py @@ -0,0 +1 @@ + diff --git a/src/sshecret/webapi/api.py b/src/sshecret/webapi/api.py new file mode 100644 index 0000000..8f9d304 --- /dev/null +++ b/src/sshecret/webapi/api.py @@ -0,0 +1,279 @@ +"""WebAPI.""" + +import asyncio +from functools import lru_cache +import logging +import secrets +import time + +from typing import Annotated +from fastapi import Header, HTTPException, Depends, Request, APIRouter + +from cryptography.fernet import Fernet + +from sshecret.api import ManagementApi +from sshecret.types import ( + BaseClientBackend, + BasePasswordManager, + ClientSpecification, +) +from sshecret.keepass import KeepassManager +from sshecret.backends.file_table import FileTableBackend + +from sshecret.settings import Settings, get_settings +from sshecret.webapi.api_client import WebManagementAPIClient +from . import models + +API_VERSION = "v1" + +admin_router = APIRouter(prefix=f"/api/{API_VERSION}") + +encryption_key = Fernet.generate_key() +cipher = Fernet(encryption_key) + +# We store sessions in memory. +sessions: dict[str, tuple[str, float]] = {} + +SESSION_TIMEOUT = 600 # 10 minutes + +LOG = logging.getLogger(__name__) + +session_lock = asyncio.Lock() + + +def encrypt_session_password(password: str) -> str: + """Encrypts the master password.""" + return cipher.encrypt(password.encode()).decode() + + +def decrypt_password(encrypted_password: str) -> str: + """Decrypts the master password asynchronously.""" + return cipher.decrypt(encrypted_password.encode()).decode() + + +async def validate_session(session_id: Annotated[str | None, Header()] = None) -> str: + """Middleware to validate session and enforce timeout.""" + if not session_id: + raise HTTPException(status_code=401, detail="Session ID required") + + async with session_lock: + if session_id not in sessions: + raise HTTPException(status_code=401, detail="Session invalid or expired") + + encrypted_password, last_access_time = sessions[session_id] + current_time = asyncio.get_event_loop().time() + + # Check for session timeout + if current_time - last_access_time > SESSION_TIMEOUT: + del sessions[session_id] # Auto-lock on timeout + raise HTTPException(status_code=401, detail="Session expired") + + # Update last access time + sessions[session_id] = (encrypted_password, current_time) + + return decrypt_password(encrypted_password) + + +@lru_cache +def get_app_settings() -> Settings: + """Get app settings.""" + return get_settings() + + +def get_password_manager( + settings: Annotated[Settings, Depends(get_app_settings)] +) -> BasePasswordManager: + """Get password manager.""" + # Currently only keepass is supported. + keepass = KeepassManager() + keepass.location = settings.password_manager.location + return keepass + + +async def get_backend( + settings: Annotated[Settings, Depends(get_app_settings)] +) -> BaseClientBackend: + """Get backend.""" + location = settings.backend.location + filetable = FileTableBackend(location) + return filetable + + +async def get_management_api( + request: Request, settings: Annotated[Settings, Depends(get_app_settings)] +) -> ManagementApi: + """Get management api.""" + client_ip = "unknown" + if req_client := request.client: + client_ip = req_client.host + + api_client = WebManagementAPIClient(client_ip, settings) + backend = await get_backend(settings) + return ManagementApi(backend, api_client) + + +BackendDependency = Annotated[BaseClientBackend, Depends(get_backend)] +ManagementAPIDependency = Annotated[ManagementApi, Depends(get_management_api)] +SessionPasswdDependency = Annotated[str, Depends(validate_session)] + + +@admin_router.post("/auth/unlock") +async def unlock_database( + password: models.PasswordBody, + password_manager: Annotated[BasePasswordManager, Depends(get_password_manager)], +) -> models.SessionResponse: + """Unlock database with master password sent in POST body.""" + password_str = password.password.get_secret_value() + try: + password_manager.open_database(password_str) + except Exception as e: + LOG.debug("Exception: %s", e, exc_info=True) + raise HTTPException(status_code=401, detail="Invalid password.") + + session_id = secrets.token_urlsafe(32) + sessions[session_id] = (encrypt_session_password(password_str), time.time()) + + return models.SessionResponse(session_id=session_id) + + +@admin_router.post("/auth/lock") +async def lock_database( + session_id: Annotated[str | None, Header()] = None +) -> dict[str, str]: + """Lock database.""" + if session_id and session_id in sessions: + del sessions[session_id] + + return {"message": "LOCKED"} + raise HTTPException(400, detail="Missing session ID.") + + +@admin_router.get("/auth/status") +async def get_lock_status( + session_id: Annotated[str | None, Header()] = None +) -> dict[str, str]: + """Get current lock status.""" + if session_id and session_id in sessions: + return {"message": "UNLOCKED"} + return {"message": "LOCKED"} + + +@admin_router.get("/clients") +async def get_clients(admin_api: ManagementAPIDependency) -> list[ClientSpecification]: + """Get clients.""" + return admin_api.get_clients() + + +@admin_router.get("/clients/{client_id}") +async def get_client( + client_id: str, admin_api: ManagementAPIDependency +) -> ClientSpecification: + """Get client.""" + if client_api := admin_api.get_client(client_id): + return client_api.client + raise HTTPException(status_code=404, detail="Client not found.") + + +@admin_router.put("/clients/{client_id}") +async def update_client( + client_id: str, + client: ClientSpecification, + admin_api: ManagementAPIDependency, + master_password: SessionPasswdDependency, +) -> ClientSpecification: + """Update client.""" + client_api = admin_api.get_client(client_id) + if not client_api: + raise HTTPException(status_code=404, detail="Client not found.") + new_client = client_api.update_client(client, password=master_password) + return new_client + + +@admin_router.delete("/clients/{client_id}", status_code=204) +async def delete_client(client_id: str, admin_api: ManagementAPIDependency) -> None: + """Delete client.""" + if admin_api.get_client(client_id): + admin_api.delete_client(client_id) + else: + raise HTTPException(status_code=404, detail="Client not found.") + + +@admin_router.post("/clients", status_code=201) +async def add_client( + client: models.CreateClientModel, admin_api: ManagementAPIDependency +) -> ClientSpecification: + """Add client.""" + new_client = admin_api.create_client( + client.name, client.public_key, client.allowed_ips + ) + return new_client.client + + +@admin_router.get("/secrets") +async def list_secrets( + admin_api: ManagementAPIDependency, password: SessionPasswdDependency +) -> list[models.SecretListResponse]: + """List secrets.""" + secrets = admin_api.get_secret_names(password=password) + return [ + models.SecretListResponse(name=name, assigned_clients=assigned_clients) + for name, assigned_clients in secrets.items() + ] + + +@admin_router.post("/secrets") +async def add_secret( + secret: models.CreateSecretSpecification, + password: SessionPasswdDependency, + admin_api: ManagementAPIDependency, +) -> models.RevealSecretResponse: + """Add secret. + + Will generate a password if none is specified. + """ + secret_value: str | None = None + if secret.secret: + secret_value = secret.secret.get_secret_value() + result_secret = admin_api.add_secret(secret.name, secret_value, password=password) + return models.RevealSecretResponse(name=secret.name, secret=result_secret) + + +@admin_router.get("/secrets/{name}") +async def get_secret( + name: str, admin_api: ManagementAPIDependency, password: SessionPasswdDependency +) -> models.RevealSecretResponse: + """Get secret.""" + if secret_value := admin_api.get_secret(name, password=password): + return models.RevealSecretResponse(name=name, secret=secret_value) + raise HTTPException(status_code=404, detail="Secret not found.") + + +@admin_router.put("/secrets/{name}") +async def update_secret( + name: str, + spec: models.UpdateSecretSpecification, + admin_api: ManagementAPIDependency, + password: SessionPasswdDependency, +) -> models.MaybeRevalSecretResponse: + """Update secret.""" + if spec.auto_generate: + secret_value = admin_api.regenerate_secret(name, password=password) + return models.MaybeRevalSecretResponse(name=name, secret=secret_value) + + if not spec.secret: + raise HTTPException( + status_code=400, + detail="Secret value must be specified if auto_generate is False", + ) + admin_api.update_secret(name, spec.secret, password=password) + return models.MaybeRevalSecretResponse(name=name, secret=None) + + +@admin_router.delete("/secrets/{name}", status_code=204) +async def delete_secret( + name: str, + admin_api: ManagementAPIDependency, + password: SessionPasswdDependency, +) -> None: + """Delete secret.""" + admin_api.delete_secret(name, password=password) diff --git a/src/sshecret/webapi/api_client.py b/src/sshecret/webapi/api_client.py new file mode 100644 index 0000000..f6cf112 --- /dev/null +++ b/src/sshecret/webapi/api_client.py @@ -0,0 +1,25 @@ +"""API Client.""" + +from typing import override +from sshecret.keepass import KeepassManager +from sshecret.types import BaseAPIClient, BasePasswordManager +from sshecret.settings import Settings, get_settings + + +class WebManagementAPIClient(BaseAPIClient): + """Client class for the web management API.""" + + method: str = "admin-web-api" + + def __init__(self, source: str, settings: Settings | None = None) -> None: + """Construct client.""" + if not settings: + settings = get_settings() + self.source: str = source + self._password_manager: BasePasswordManager = KeepassManager() + self._password_manager.location = settings.password_manager.manager.location + + @override + def password_manager(self, manager_options: dict[str, str] | None = None) -> BasePasswordManager: + """Get password manager.""" + return self._password_manager diff --git a/src/sshecret/webapi/frontend.py b/src/sshecret/webapi/frontend.py new file mode 100644 index 0000000..3074af8 --- /dev/null +++ b/src/sshecret/webapi/frontend.py @@ -0,0 +1,33 @@ +"""Admin frontend.""" + +from fastapi import APIRouter, Request + +from fastapi.responses import HTMLResponse +from fastapi.templating import Jinja2Templates + +templates = Jinja2Templates(directory="templates") + +frontend = APIRouter() + +# I'm just making some placeholders here +@frontend.get("/") +async def index(request: Request) -> HTMLResponse: + """Get frontpage.""" + return templates.TemplateResponse(request, name="index.html") + + +@frontend.get("/login") +async def login(request: Request) -> HTMLResponse: + """Get login page.""" + return templates.TemplateResponse(request, name="login.html") + + +@frontend.get("/clients") +async def clients(request: Request) -> HTMLResponse: + """Get login page.""" + return templates.TemplateResponse(request, name="clients.html") + +@frontend.get("/secrets") +async def secrets(request: Request) -> HTMLResponse: + """Get login page.""" + return templates.TemplateResponse(request, name="secrets.html") diff --git a/src/sshecret/webapi/models.py b/src/sshecret/webapi/models.py new file mode 100644 index 0000000..ee40f08 --- /dev/null +++ b/src/sshecret/webapi/models.py @@ -0,0 +1,71 @@ +"""Response models.""" + +from pydantic import BaseModel, IPvAnyAddress, IPvAnyNetwork, SecretStr + + +class SSHKeyResponse(BaseModel): + """Response model for updated SSH keys.""" + + updated_secrets: list[str] + + +class SecretListResponse(BaseModel): + """Response for listing secrets.""" + + name: str + assigned_clients: list[str] + + +class CreateSecretSpecification(BaseModel): + """Model for creating a secret.""" + + name: str + secret: SecretStr | None + + +class SecretSpecification(BaseModel): + """Secret specification.""" + + name: str + secret: SecretStr + + +class UpdateSecretSpecification(BaseModel): + """Model for updating a secret.""" + + secret: str | None + auto_generate: bool | None = None + + +class RevealSecretResponse(BaseModel): + """Reveal secret.""" + + name: str + secret: str + + +class MaybeRevalSecretResponse(BaseModel): + """Model where the secret may be specified.""" + + name: str + secret: str | None + + +class PasswordBody(BaseModel): + """Password body.""" + + password: SecretStr + + +class SessionResponse(BaseModel): + """Session response.""" + + session_id: str + + +class CreateClientModel(BaseModel): + """Model for creating a client.""" + + name: str + public_key: str + allowed_ips: list[IPvAnyAddress | IPvAnyNetwork] | str = "*" diff --git a/src/sshecret/webapi/router.py b/src/sshecret/webapi/router.py new file mode 100644 index 0000000..f720bf7 --- /dev/null +++ b/src/sshecret/webapi/router.py @@ -0,0 +1,16 @@ +"""API router.""" + +from fastapi import FastAPI +from fastapi.staticfiles import StaticFiles + + +from .api import admin_router +from .frontend import frontend + + +app = FastAPI() + +app.include_router(admin_router) +app.include_router(frontend) + +app.mount("/static", StaticFiles(directory="static"), name="static") diff --git a/tests/test_admin_api.py b/tests/test_admin_api.py new file mode 100644 index 0000000..79acaff --- /dev/null +++ b/tests/test_admin_api.py @@ -0,0 +1,416 @@ +"""Tests for the Admin HTTP API""" + +from ipaddress import IPv4Address +import unittest + +from fastapi.testclient import TestClient + +from sshecret.types import ClientSpecification +from sshecret.testing import TestClientSpec, TestContext, api_context +from sshecret.webapi.api import get_app_settings +from sshecret.webapi.router import app +from sshecret.crypto import ( + generate_private_key, + generate_public_key_string, + decode_string, +) + + +class TestLockUnlock(unittest.TestCase): + """Test lock and unlock.""" + + def setUp(self) -> None: + """Set up testing.""" + + def test_unlock_lock(self) -> None: + """Test unlocking.""" + with api_context([]) as context: + app.dependency_overrides[get_app_settings] = context.get_settings + testclient: TestClient = TestClient(app) + response = testclient.post( + "api/v1/auth/unlock", json={"password": context.master_password} + ) + body = response.json() + self.assertEqual(response.status_code, 200) + self.assertIn("session_id", body) + session_id = body["session_id"] + session_header = {"session-id": str(session_id)} + status_resp = testclient.get("/api/v1/auth/status", headers=session_header) + self.assertEqual(status_resp.status_code, 200) + status_body = status_resp.json() + self.assertIn("message", status_body) + self.assertEqual(str(status_body["message"]), "UNLOCKED") + lock_resp = testclient.post("/api/v1/auth/lock", headers=session_header) + self.assertEqual(lock_resp.status_code, 200) + lock_body = lock_resp.json() + lock_status = lock_body.get("message") + self.assertEqual(lock_status, "LOCKED") + + def test_get_clients(self) -> None: + """Test get clients.""" + test_data = [ + TestClientSpec( + "webserver", + { + "API_KEY": "test", + "OTHER_API_KEY": "test2", + }, + ), + TestClientSpec( + "db_server", + { + "DB_PASSWORD": "test", + }, + ), + ] + with api_context(test_data) as context: + app.dependency_overrides[get_app_settings] = context.get_settings + testclient: TestClient = TestClient(app) + client_resp = testclient.get("/api/v1/clients") + clients = client_resp.json() + self.assertIsInstance(clients, list) + self.assertEqual(len(clients), 2) + + for client in clients: + ClientSpecification.model_validate(client) + + def test_get_client(self) -> None: + """Test get specific client.""" + test_data = [ + TestClientSpec( + "webserver", + { + "API_KEY": "test", + "OTHER_API_KEY": "test2", + }, + ), + TestClientSpec( + "db_server", + { + "DB_PASSWORD": "test", + }, + ), + ] + with api_context(test_data) as context: + app.dependency_overrides[get_app_settings] = context.get_settings + testclient: TestClient = TestClient(app) + client_resp = testclient.get("/api/v1/clients/webserver") + self.assertEqual(client_resp.status_code, 200) + client_dict = client_resp.json() + ClientSpecification.model_validate(client_dict) + + def test_update_client(self) -> None: + """Test update client with trivial value.""" + test_data = [ + TestClientSpec( + "webserver", + { + "API_KEY": "test", + "OTHER_API_KEY": "test2", + }, + ), + TestClientSpec( + "db_server", + { + "DB_PASSWORD": "test", + }, + ), + ] + with api_context(test_data) as context: + app.dependency_overrides[get_app_settings] = context.get_settings + testclient: TestClient = TestClient(app) + client_resp = testclient.get("/api/v1/clients/webserver") + self.assertEqual(client_resp.status_code, 200) + client_dict = client_resp.json() + client = ClientSpecification.model_validate(client_dict) + unlock_response = testclient.post( + "/api/v1/auth/unlock", json={"password": context.master_password} + ) + body = unlock_response.json() + self.assertEqual(unlock_response.status_code, 200) + self.assertIn("session_id", body) + session_id = body["session_id"] + session_header = {"session-id": str(session_id)} + serialized_client = client.model_dump(exclude_unset=True) + serialized_client["allowed_ips"] = ["192.0.2.1"] + update_response = testclient.put( + "/api/v1/clients/webserver", + json=serialized_client, + headers=session_header, + ) + self.assertAlmostEqual(update_response.status_code, 200) + update_body = update_response.json() + updated_client = ClientSpecification.model_validate(update_body) + assert updated_client.allowed_ips == [IPv4Address("192.0.2.1")] + + def test_update_client_sshkey(self) -> None: + """Update client SSH key.""" + test_data = [ + TestClientSpec( + "webserver", + { + "API_KEY": "test", + "OTHER_API_KEY": "test2", + }, + ), + TestClientSpec( + "db_server", + { + "DB_PASSWORD": "test", + }, + ), + ] + with api_context(test_data) as context: + app.dependency_overrides[get_app_settings] = context.get_settings + testclient: TestClient = TestClient(app) + new_private_key = generate_private_key() + public_key = generate_public_key_string(new_private_key.public_key()) + client_resp = testclient.get("/api/v1/clients/webserver") + self.assertEqual(client_resp.status_code, 200) + client_dict = client_resp.json() + client = ClientSpecification.model_validate(client_dict) + unlock_response = testclient.post( + "/api/v1/auth/unlock", json={"password": context.master_password} + ) + body = unlock_response.json() + self.assertEqual(unlock_response.status_code, 200) + self.assertIn("session_id", body) + session_id = body["session_id"] + session_header = {"session-id": str(session_id)} + + serialized_client = client.model_dump(exclude_unset=True) + serialized_client["public_key"] = public_key + update_response = testclient.put( + "/api/v1/clients/webserver", + json=serialized_client, + headers=session_header, + ) + self.assertAlmostEqual(update_response.status_code, 200) + update_body = update_response.json() + updated_client = ClientSpecification.model_validate(update_body) + for secret, value in updated_client.secrets.items(): + old_secret = client.secrets[secret] + self.assertNotEqual(old_secret, value) + cleartext = decode_string(value, new_private_key) + self.assertTrue(cleartext.startswith("test")) + + # check that the backend is properly updated. + new_client_resp = testclient.get("/api/v1/clients/webserver") + new_client_dict = new_client_resp.json() + self.assertEqual(new_client_resp.status_code, 200) + new_client = ClientSpecification.model_validate(new_client_dict) + for secret, value in new_client.secrets.items(): + matching_value = updated_client.secrets[secret] + self.assertEqual(value, matching_value) + + def test_delete_client(self) -> None: + """Test the delete_client API.""" + test_data = [ + TestClientSpec( + "webserver", + { + "API_KEY": "test", + "OTHER_API_KEY": "test2", + }, + ), + TestClientSpec( + "db_server", + { + "DB_PASSWORD": "test", + }, + ), + ] + with api_context(test_data) as context: + app.dependency_overrides[get_app_settings] = context.get_settings + testclient: TestClient = TestClient(app) + client_resp = testclient.get("/api/v1/clients/webserver") + self.assertEqual(client_resp.status_code, 200) + delete_resp = testclient.delete("/api/v1/clients/webserver") + self.assertEqual(delete_resp.status_code, 204) + get_resp = testclient.get("/api/v1/clients/webserver") + self.assertEqual(get_resp.status_code, 404) + + def test_add_client(self) -> None: + """Test the add_client API.""" + test_data = [ + TestClientSpec( + "webserver", + { + "API_KEY": "test", + "OTHER_API_KEY": "test2", + }, + ), + TestClientSpec( + "db_server", + { + "DB_PASSWORD": "test", + }, + ), + ] + with api_context(test_data) as context: + app.dependency_overrides[get_app_settings] = context.get_settings + testclient: TestClient = TestClient(app) + private_key = generate_private_key() + public_key = generate_public_key_string(private_key.public_key()) + new_client = ClientSpecification( + name="webserver2", + public_key=public_key, + ) + add_resp = testclient.post( + "/api/v1/clients", + json=new_client.model_dump(exclude_unset=True, exclude_defaults=True), + ) + self.assertEqual(add_resp.status_code, 201) + body = add_resp.json() + client = ClientSpecification.model_validate(body) + self.assertEqual(client.public_key, public_key) + fetched_client = self.fetch_client(testclient, "webserver2") + self.assertEqual(fetched_client, client) + + def test_list_secrets(self) -> None: + """Test the list_secrets API.""" + test_data = [ + TestClientSpec( + "webserver", + { + "API_KEY": "test", + "OTHER_API_KEY": "test2", + }, + ), + TestClientSpec( + "db_server", + { + "DB_PASSWORD": "test", + }, + ), + ] + with api_context(test_data) as context: + app.dependency_overrides[get_app_settings] = context.get_settings + testclient: TestClient = TestClient(app) + headers = self.unlock(context, testclient) + resp = testclient.get("/api/v1/secrets", headers=headers) + self.assertEqual(resp.status_code, 200) + expected = [ + {"name": "API_KEY", "assigned_clients": ["webserver"]}, + {"name": "OTHER_API_KEY", "assigned_clients": ["webserver"]}, + {"name": "DB_PASSWORD", "assigned_clients": ["db_server"]}, + ] + body = resp.json() + + self.assertListEqual(body, expected) + + def test_get_secret(self) -> None: + """Test the get_secret API.""" + test_data = [ + TestClientSpec( + "db_server", + { + "DB_PASSWORD": "test", + }, + ), + ] + with api_context(test_data) as context: + app.dependency_overrides[get_app_settings] = context.get_settings + testclient: TestClient = TestClient(app) + headers = self.unlock(context, testclient) + resp = testclient.get("/api/v1/secrets/DB_PASSWORD", headers=headers) + self.assertEqual(resp.status_code, 200) + expected = {"name": "DB_PASSWORD", "secret": "test"} + body = resp.json() + self.assertDictEqual(body, expected) + + def test_update_secret_provided(self) -> None: + """Test the update_secret API. + + Tests updating a secret with a provided string. + """ + test_data = [ + TestClientSpec( + "db_server", + { + "DB_PASSWORD": "test", + }, + ), + ] + with api_context(test_data) as context: + app.dependency_overrides[get_app_settings] = context.get_settings + testclient: TestClient = TestClient(app) + headers = self.unlock(context, testclient) + request = {"secret": "not-so-secret"} + resp = testclient.put( + "/api/v1/secrets/DB_PASSWORD", json=request, headers=headers + ) + self.assertEqual(resp.status_code, 200) + expected = {"name": "DB_PASSWORD", "secret": None} + body = resp.json() + self.assertDictEqual(body, expected) + + def test_update_secret_auto(self) -> None: + """Test the update_secret API. + + Tests updating a secret with auto-generated string. + """ + test_data = [ + TestClientSpec( + "db_server", + { + "DB_PASSWORD": "test", + }, + ), + ] + with api_context(test_data) as context: + app.dependency_overrides[get_app_settings] = context.get_settings + testclient: TestClient = TestClient(app) + headers = self.unlock(context, testclient) + request = {"secret": None, "auto_generate": True} + resp = testclient.put( + "/api/v1/secrets/DB_PASSWORD", json=request, headers=headers + ) + self.assertEqual(resp.status_code, 200) + body = resp.json() + secret = body.get("secret") + self.assertIsNotNone(secret) + + def test_delete_secret(self) -> None: + """Test delete_secret API.""" + test_data = [ + TestClientSpec( + "webserver", + { + "API_KEY": "test", + "OTHER_API_KEY": "test2", + }, + ), + ] + with api_context(test_data) as context: + app.dependency_overrides[get_app_settings] = context.get_settings + testclient: TestClient = TestClient(app) + headers = self.unlock(context, testclient) + resp = testclient.delete("/api/v1/secrets/OTHER_API_KEY", headers=headers) + self.assertEqual(resp.status_code, 204) + get_resp = testclient.get("/api/v1/secrets/OTHER_API_KEY", headers=headers) + self.assertEqual(get_resp.status_code, 404) + + def fetch_client( + self, testclient: TestClient, client_name: str + ) -> ClientSpecification: + """Fetch a client.""" + client_resp = testclient.get(f"/api/v1/clients/{client_name}") + self.assertEqual(client_resp.status_code, 200) + client_dict = client_resp.json() + client = ClientSpecification.model_validate(client_dict) + return client + + def unlock(self, context: TestContext, testclient: TestClient) -> dict[str, str]: + """Unlock the session.""" + response = testclient.post( + "/api/v1/auth/unlock", json={"password": context.master_password} + ) + body = response.json() + session_id = body["session_id"] + session_header = {"session-id": str(session_id)} + return session_header + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_client_backend.py b/tests/test_client_backend.py index 23550a9..a5516c3 100644 --- a/tests/test_client_backend.py +++ b/tests/test_client_backend.py @@ -96,6 +96,30 @@ class TestFileTableBackend(unittest.TestCase): webserver_file = testdir / "webserver.json" self.assertFalse(webserver_file.exists()) + def test_lookup_by_secret(self) -> None: + """Test lookup of secrets.""" + dataset = [ + TestClientSpec("webserver", {"SECRET_TOKEN": "mysecrettoken"}), + TestClientSpec("webserver2", {"SECRET_TOKEN": "mysecrettoken"}), + TestClientSpec("webserver3", {"SECRET_TOKEN": "mysecrettoken"}), + TestClientSpec("dbserver", {"DB_ROOT_PASSWORD": "mysecretpassword"}), + TestClientSpec("dbserver2", {"DB_ROOT_PASSWORD": "mysecretpassword"}), + TestClientSpec("appserver", {"DB_ROOT_PASSWORD": "mysecretpassword", "SECRET_TOKEN": "mysecrettoken"}), + ] + with test_context(dataset) as testdir: + backend = FileTableBackend(testdir) + token_mapping = backend.lookup_by_secret("SECRET_TOKEN") + self.assertEqual(len(token_mapping), 4) + token_mapping_names = [client.name for client in token_mapping] + self.assertIn("webserver2", token_mapping_names) + self.assertIn("appserver", token_mapping_names) + db_mapping = backend.lookup_by_secret("DB_ROOT_PASSWORD") + db_mapping_names = [client.name for client in db_mapping] + self.assertEqual(len(db_mapping), 3) + self.assertNotIn("webserver", db_mapping_names) + + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_keepass.py b/tests/test_keepass.py index 2b1748b..7622988 100644 --- a/tests/test_keepass.py +++ b/tests/test_keepass.py @@ -13,8 +13,7 @@ class Rot13PasswordReader(BasePasswordReader): """This password reader returns the identifier backwards.""" @override - @classmethod - def get_password(cls, identifier: str) -> str: + def get_password(self, identifier: str, repeated: bool = False) -> str: """Get password.""" return identifier[::-1] @@ -29,7 +28,7 @@ class TestKeepass(unittest.TestCase): @override def setUp(self) -> None: """Set up testing.""" - self.reader_context = PasswordContext(Rot13PasswordReader) + self.reader_context = PasswordContext(Rot13PasswordReader()) def test_db_create(self) -> None: """Test db creation.""" diff --git a/tests/test_password_readers.py b/tests/test_password_readers.py index 80f21c7..5b35abb 100644 --- a/tests/test_password_readers.py +++ b/tests/test_password_readers.py @@ -17,7 +17,7 @@ class TestInputPasswordReader(unittest.TestCase): """Test reader.""" input_password = "testpassword" with patch("getpass.getpass", return_value=input_password): - received_password = InputPasswordReader.get_password("test_password") + received_password = InputPasswordReader().get_password("test_password") self.assertEqual(received_password, "testpassword") @@ -32,7 +32,7 @@ class TestEnvPasswordReader(unittest.TestCase): def test_env_loader(self) -> None: """Test environment loading.""" - password = EnvironmentPasswordReader.get_password("test") + password = EnvironmentPasswordReader().get_password("test") self.assertEqual(password, "secretthing") diff --git a/uv.lock b/uv.lock index 22a2b9d..9a24103 100644 --- a/uv.lock +++ b/uv.lock @@ -16,6 +16,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, ] +[[package]] +name = "anyio" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "sniffio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/7d/4c1bd541d4dffa1b52bd83fb8527089e097a106fc90b467a7313b105f840/anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028", size = 190949 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916 }, +] + [[package]] name = "argon2-cffi" version = "23.1.0" @@ -112,6 +125,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a9/cf/45fb5261ece3e6b9817d3d82b2f343a505fd58674a92577923bc500bd1aa/bcrypt-4.3.0-cp39-abi3-win_amd64.whl", hash = "sha256:e53e074b120f2877a35cc6c736b8eb161377caae8925c17688bd46ba56daaa5b", size = 152799 }, ] +[[package]] +name = "certifi" +version = "2025.1.31" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/ab/c9f1e32b7b1bf505bf26f0ef697775960db7932abeb7b516de930ba2705f/certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651", size = 167577 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/fc/bce832fd4fd99766c04d1ee0eead6b0ec6486fb100ae5e74c1d91292b982/certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe", size = 166393 }, +] + [[package]] name = "cffi" version = "1.17.1" @@ -146,6 +168,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188 }, ] +[[package]] +name = "click-repl" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "prompt-toolkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/a2/57f4ac79838cfae6912f997b4d1a64a858fb0c86d7fcaae6f7b58d267fca/click-repl-0.3.0.tar.gz", hash = "sha256:17849c23dba3d667247dc4defe1757fff98694e90fe37474f3feebb69ced26a9", size = 10449 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/40/9d857001228658f0d59e97ebd4c346fe73e138c6de1bce61dc568a57c7f8/click_repl-0.3.0-py3-none-any.whl", hash = "sha256:fb7e06deb8da8de86180a33a9da97ac316751c094c6899382da7feeeeb51b812", size = 10289 }, +] + +[[package]] +name = "click-shell" +version = "3.0.dev0" +source = { git = "https://github.com/clarkperkins/click-shell#12d4544e8475419c81f32b412c9eba04abe3dd73" } +dependencies = [ + { name = "click" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -209,6 +252,153 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/33/cf/1f7649b8b9a3543e042d3f348e398a061923ac05b507f3f4d95f11938aa9/cryptography-44.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:5f6f90b72d8ccadb9c6e311c775c8305381db88374c65fa1a68250aa8a9cb3a6", size = 3210957 }, ] +[[package]] +name = "dnspython" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/4a/263763cb2ba3816dd94b08ad3a33d5fdae34ecb856678773cc40a3605829/dnspython-2.7.0.tar.gz", hash = "sha256:ce9c432eda0dc91cf618a5cedf1a4e142651196bbcd2c80e89ed5a907e5cfaf1", size = 345197 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/1b/e0a87d256e40e8c888847551b20a017a6b98139178505dc7ffb96f04e954/dnspython-2.7.0-py3-none-any.whl", hash = "sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86", size = 313632 }, +] + +[[package]] +name = "email-validator" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/ce/13508a1ec3f8bb981ae4ca79ea40384becc868bfae97fd1c942bb3a001b1/email_validator-2.2.0.tar.gz", hash = "sha256:cb690f344c617a714f22e66ae771445a1ceb46821152df8e165c5f9a364582b7", size = 48967 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/ee/bf0adb559ad3c786f12bcbc9296b3f5675f529199bef03e2df281fa1fadb/email_validator-2.2.0-py3-none-any.whl", hash = "sha256:561977c2d73ce3611850a06fa56b414621e0c8faa9d66f2611407d87465da631", size = 33521 }, +] + +[[package]] +name = "fastapi" +version = "0.115.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f4/55/ae499352d82338331ca1e28c7f4a63bfd09479b16395dce38cf50a39e2c2/fastapi-0.115.12.tar.gz", hash = "sha256:1e2c2a2646905f9e83d32f04a3f86aff4a286669c6c950ca95b5fd68c2602681", size = 295236 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/b3/b51f09c2ba432a576fe63758bddc81f78f0c6309d9e5c10d194313bf021e/fastapi-0.115.12-py3-none-any.whl", hash = "sha256:e94613d6c05e27be7ffebdd6ea5f388112e5e430c8f7d6494a9d1d88d43e814d", size = 95164 }, +] + +[package.optional-dependencies] +standard = [ + { name = "email-validator" }, + { name = "fastapi-cli", extra = ["standard"] }, + { name = "httpx" }, + { name = "jinja2" }, + { name = "python-multipart" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[[package]] +name = "fastapi-cli" +version = "0.0.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "rich-toolkit" }, + { name = "typer" }, + { name = "uvicorn", extra = ["standard"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fe/73/82a5831fbbf8ed75905bacf5b2d9d3dfd6f04d6968b29fe6f72a5ae9ceb1/fastapi_cli-0.0.7.tar.gz", hash = "sha256:02b3b65956f526412515907a0793c9094abd4bfb5457b389f645b0ea6ba3605e", size = 16753 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/e6/5daefc851b514ce2287d8f5d358ae4341089185f78f3217a69d0ce3a390c/fastapi_cli-0.0.7-py3-none-any.whl", hash = "sha256:d549368ff584b2804336c61f192d86ddea080c11255f375959627911944804f4", size = 10705 }, +] + +[package.optional-dependencies] +standard = [ + { name = "uvicorn", extra = ["standard"] }, +] + +[[package]] +name = "h11" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/38/3af3d3633a34a3316095b39c8e8fb4853a28a536e55d347bd8d8e9a14b03/h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", size = 100418 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761", size = 58259 }, +] + +[[package]] +name = "httpcore" +version = "1.0.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6a/41/d7d0a89eb493922c37d343b607bc1b5da7f5be7e383740b4753ad8943e90/httpcore-1.0.7.tar.gz", hash = "sha256:8551cb62a169ec7162ac7be8d4817d561f60e08eaa485234898414bb5a8a0b4c", size = 85196 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/f5/72347bc88306acb359581ac4d52f23c0ef445b57157adedb9aee0cd689d2/httpcore-1.0.7-py3-none-any.whl", hash = "sha256:a3fff8f43dc260d5bd363d9f9cf1830fa3a458b332856f34282de498ed420edd", size = 78551 }, +] + +[[package]] +name = "httptools" +version = "0.6.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/9a/ce5e1f7e131522e6d3426e8e7a490b3a01f39a6696602e1c4f33f9e94277/httptools-0.6.4.tar.gz", hash = "sha256:4e93eee4add6493b59a5c514da98c939b244fce4a0d8879cd3f466562f4b7d5c", size = 240639 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/a3/9fe9ad23fd35f7de6b91eeb60848986058bd8b5a5c1e256f5860a160cc3e/httptools-0.6.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ade273d7e767d5fae13fa637f4d53b6e961fb7fd93c7797562663f0171c26660", size = 197214 }, + { url = "https://files.pythonhosted.org/packages/ea/d9/82d5e68bab783b632023f2fa31db20bebb4e89dfc4d2293945fd68484ee4/httptools-0.6.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:856f4bc0478ae143bad54a4242fccb1f3f86a6e1be5548fecfd4102061b3a083", size = 102431 }, + { url = "https://files.pythonhosted.org/packages/96/c1/cb499655cbdbfb57b577734fde02f6fa0bbc3fe9fb4d87b742b512908dff/httptools-0.6.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:322d20ea9cdd1fa98bd6a74b77e2ec5b818abdc3d36695ab402a0de8ef2865a3", size = 473121 }, + { url = "https://files.pythonhosted.org/packages/af/71/ee32fd358f8a3bb199b03261f10921716990808a675d8160b5383487a317/httptools-0.6.4-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4d87b29bd4486c0093fc64dea80231f7c7f7eb4dc70ae394d70a495ab8436071", size = 473805 }, + { url = "https://files.pythonhosted.org/packages/8a/0a/0d4df132bfca1507114198b766f1737d57580c9ad1cf93c1ff673e3387be/httptools-0.6.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:342dd6946aa6bda4b8f18c734576106b8a31f2fe31492881a9a160ec84ff4bd5", size = 448858 }, + { url = "https://files.pythonhosted.org/packages/1e/6a/787004fdef2cabea27bad1073bf6a33f2437b4dbd3b6fb4a9d71172b1c7c/httptools-0.6.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b36913ba52008249223042dca46e69967985fb4051951f94357ea681e1f5dc0", size = 452042 }, + { url = "https://files.pythonhosted.org/packages/4d/dc/7decab5c404d1d2cdc1bb330b1bf70e83d6af0396fd4fc76fc60c0d522bf/httptools-0.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:28908df1b9bb8187393d5b5db91435ccc9c8e891657f9cbb42a2541b44c82fc8", size = 87682 }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 }, +] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, +] + +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050 }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899 }, +] + [[package]] name = "littletable" version = "3.0.1" @@ -243,6 +433,55 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/80/83/8c54533b3576f4391eebea88454738978669a6cad0d8e23266224007939d/lxml-5.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:91fb6a43d72b4f8863d21f347a9163eecbf36e76e2f51068d59cd004c506f332", size = 3814484 }, ] +[[package]] +name = "markdown-it-py" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528 }, +] + +[[package]] +name = "markupsafe" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274 }, + { url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352 }, + { url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122 }, + { url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085 }, + { url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978 }, + { url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208 }, + { url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357 }, + { url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344 }, + { url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101 }, + { url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603 }, + { url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510 }, + { url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486 }, + { url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480 }, + { url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914 }, + { url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796 }, + { url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473 }, + { url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114 }, + { url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098 }, + { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208 }, + { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739 }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, +] + [[package]] name = "mypy" version = "1.15.0" @@ -271,6 +510,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/e2/5d3f6ada4297caebe1a2add3b126fe800c96f56dbe5d1988a2cbe0b267aa/mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", size = 4695 }, ] +[[package]] +name = "packaging" +version = "24.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 }, +] + [[package]] name = "paramiko" version = "3.5.1" @@ -285,6 +533,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/15/f8/c7bd0ef12954a81a1d3cea60a13946bd9a49a0036a5927770c461eade7ae/paramiko-3.5.1-py3-none-any.whl", hash = "sha256:43b9a0501fc2b5e70680388d9346cf252cfb7d00b0667c39e80eb43a408b8f61", size = 227298 }, ] +[[package]] +name = "pluggy" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 }, +] + +[[package]] +name = "prompt-toolkit" +version = "3.0.50" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/e1/bd15cb8ffdcfeeb2bdc215de3c3cffca11408d829e4b8416dcfe71ba8854/prompt_toolkit-3.0.50.tar.gz", hash = "sha256:544748f3860a2623ca5cd6d2795e7a14f3d0e1c3c9728359013f79877fc89bab", size = 429087 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/ea/d836f008d33151c7a1f62caf3d8dd782e4d15f6a43897f64480c2b8de2ad/prompt_toolkit-3.0.50-py3-none-any.whl", hash = "sha256:9b6427eb19e479d98acff65196a307c555eb567989e6d88ebbb1b509d9779198", size = 387816 }, +] + [[package]] name = "pycparser" version = "2.22" @@ -351,6 +620,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/51/b2/b2b50d5ecf21acf870190ae5d093602d95f66c9c31f9d5de6062eb329ad1/pydantic_core-2.27.2-cp313-cp313-win_arm64.whl", hash = "sha256:ac4dbfd1691affb8f48c2c13241a2e3b60ff23247cbcf981759c768b6633cf8b", size = 1885186 }, ] +[[package]] +name = "pydantic-settings" +version = "2.8.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/88/82/c79424d7d8c29b994fb01d277da57b0a9b09cc03c3ff875f9bd8a86b2145/pydantic_settings-2.8.1.tar.gz", hash = "sha256:d5c663dfbe9db9d5e1c646b2e161da12f0d734d422ee56f567d0ea2cee4e8585", size = 83550 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/53/a64f03044927dc47aafe029c42a5b7aabc38dfb813475e0e1bf71c4a59d0/pydantic_settings-2.8.1-py3-none-any.whl", hash = "sha256:81942d5ac3d905f7f3ee1a70df5dfb62d5569c12f51a5a647defc1c3d9ee2e9c", size = 30839 }, +] + +[[package]] +name = "pygments" +version = "2.19.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293 }, +] + [[package]] name = "pykeepass" version = "4.1.1.post1" @@ -396,6 +687,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c3/c0/c33c8792c3e50193ef55adb95c1c3c2786fe281123291c2dbf0eaab95a6f/pyotp-2.9.0-py3-none-any.whl", hash = "sha256:81c2e5865b8ac55e825b0358e496e1d9387c811e85bb40e71a3b29b288963612", size = 13376 }, ] +[[package]] +name = "pytest" +version = "8.3.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634 }, +] + [[package]] name = "python-dotenv" version = "1.0.1" @@ -414,6 +720,77 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/08/20/0f2523b9e50a8052bc6a8b732dfc8568abbdc42010aef03a2d750bdab3b2/python_json_logger-3.3.0-py3-none-any.whl", hash = "sha256:dd980fae8cffb24c13caf6e158d3d61c0d6d22342f932cb6e9deedab3d35eec7", size = 15163 }, ] +[[package]] +name = "python-multipart" +version = "0.0.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546 }, +] + +[[package]] +name = "pyyaml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309 }, + { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679 }, + { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428 }, + { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361 }, + { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523 }, + { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660 }, + { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597 }, + { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527 }, + { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446 }, +] + +[[package]] +name = "rich" +version = "13.9.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ab/3a/0316b28d0761c6734d6bc14e770d85506c986c85ffb239e688eeaab2c2bc/rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098", size = 223149 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424 }, +] + +[[package]] +name = "rich-toolkit" +version = "0.13.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/8a/71cfbf6bf6257ea785d1f030c22468f763eea1b3e5417620f2ba9abd6dca/rich_toolkit-0.13.2.tar.gz", hash = "sha256:fea92557530de7c28f121cbed572ad93d9e0ddc60c3ca643f1b831f2f56b95d3", size = 72288 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/1b/1c2f43af46456050b27810a7a013af8a7e12bc545a0cdc00eb0df55eb769/rich_toolkit-0.13.2-py3-none-any.whl", hash = "sha256:f3f6c583e5283298a2f7dbd3c65aca18b7f818ad96174113ab5bec0b0e35ed61", size = 13566 }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755 }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, +] + [[package]] name = "sshecret" version = "0.1.0" @@ -421,11 +798,18 @@ source = { editable = "." } dependencies = [ { name = "asyncssh" }, { name = "click" }, + { name = "click-repl" }, + { name = "click-shell" }, { name = "cryptography" }, + { name = "fastapi", extra = ["standard"] }, + { name = "httpx" }, + { name = "jinja2" }, { name = "littletable" }, { name = "paramiko" }, { name = "pydantic" }, + { name = "pydantic-settings" }, { name = "pykeepass" }, + { name = "python-dotenv" }, { name = "python-json-logger" }, ] @@ -433,6 +817,7 @@ dependencies = [ dev = [ { name = "construct-typing" }, { name = "mypy" }, + { name = "pytest" }, { name = "python-dotenv" }, ] @@ -440,11 +825,18 @@ dev = [ requires-dist = [ { name = "asyncssh", specifier = ">=2.20.0" }, { name = "click", specifier = ">=8.1.8" }, + { name = "click-repl", specifier = ">=0.3.0" }, + { name = "click-shell", git = "https://github.com/clarkperkins/click-shell" }, { name = "cryptography", specifier = ">=44.0.2" }, + { name = "fastapi", extras = ["standard"], specifier = ">=0.115.12" }, + { name = "httpx", specifier = ">=0.28.1" }, + { name = "jinja2", specifier = ">=3.1.6" }, { name = "littletable", specifier = ">=3.0.1" }, { name = "paramiko", specifier = ">=3.5.1" }, { name = "pydantic", specifier = ">=2.10.6" }, + { name = "pydantic-settings", specifier = ">=2.8.1" }, { name = "pykeepass", specifier = ">=4.1.1.post1" }, + { name = "python-dotenv", specifier = ">=1.0.1" }, { name = "python-json-logger", specifier = ">=3.3.0" }, ] @@ -452,6 +844,7 @@ requires-dist = [ dev = [ { name = "construct-typing", specifier = ">=0.6.2" }, { name = "mypy", specifier = ">=1.15.0" }, + { name = "pytest", specifier = ">=8.3.5" }, { name = "python-dotenv", specifier = ">=1.0.1" }, ] @@ -476,6 +869,33 @@ requires-dist = [ { name = "sshecret", editable = "." }, ] +[[package]] +name = "starlette" +version = "0.46.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/1b/52b27f2e13ceedc79a908e29eac426a63465a1a01248e5f24aa36a62aeb3/starlette-0.46.1.tar.gz", hash = "sha256:3c88d58ee4bd1bb807c0d1acb381838afc7752f9ddaec81bbe4383611d833230", size = 2580102 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/4b/528ccf7a982216885a1ff4908e886b8fb5f19862d1962f56a3fce2435a70/starlette-0.46.1-py3-none-any.whl", hash = "sha256:77c74ed9d2720138b25875133f3a2dae6d854af2ec37dceb56aef370c1d8a227", size = 71995 }, +] + +[[package]] +name = "typer" +version = "0.15.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/6f/3991f0f1c7fcb2df31aef28e0594d8d54b05393a0e4e34c65e475c2a5d41/typer-0.15.2.tar.gz", hash = "sha256:ab2fab47533a813c49fe1f16b1a370fd5819099c00b119e0633df65f22144ba5", size = 100711 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/fc/5b29fea8cee020515ca82cc68e3b8e1e34bb19a3535ad854cac9257b414c/typer-0.15.2-py3-none-any.whl", hash = "sha256:46a499c6107d645a9c13f7ee46c5d5096cae6f5fc57dd11eccbbb9ae3e44ddfc", size = 45061 }, +] + [[package]] name = "typing-extensions" version = "4.12.2" @@ -484,3 +904,93 @@ sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec3 wheels = [ { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, ] + +[[package]] +name = "uvicorn" +version = "0.34.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4b/4d/938bd85e5bf2edeec766267a5015ad969730bb91e31b44021dfe8b22df6c/uvicorn-0.34.0.tar.gz", hash = "sha256:404051050cd7e905de2c9a7e61790943440b3416f49cb409f965d9dcd0fa73e9", size = 76568 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/14/33a3a1352cfa71812a3a21e8c9bfb83f60b0011f5e36f2b1399d51928209/uvicorn-0.34.0-py3-none-any.whl", hash = "sha256:023dc038422502fa28a09c7a30bf2b6991512da7dcdb8fd35fe57cfc154126f4", size = 62315 }, +] + +[package.optional-dependencies] +standard = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/c0/854216d09d33c543f12a44b393c402e89a920b1a0a7dc634c42de91b9cf6/uvloop-0.21.0.tar.gz", hash = "sha256:3bf12b0fda68447806a7ad847bfa591613177275d35b6724b1ee573faa3704e3", size = 2492741 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/8d/2cbef610ca21539f0f36e2b34da49302029e7c9f09acef0b1c3b5839412b/uvloop-0.21.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bfd55dfcc2a512316e65f16e503e9e450cab148ef11df4e4e679b5e8253a5281", size = 1468123 }, + { url = "https://files.pythonhosted.org/packages/93/0d/b0038d5a469f94ed8f2b2fce2434a18396d8fbfb5da85a0a9781ebbdec14/uvloop-0.21.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:787ae31ad8a2856fc4e7c095341cccc7209bd657d0e71ad0dc2ea83c4a6fa8af", size = 819325 }, + { url = "https://files.pythonhosted.org/packages/50/94/0a687f39e78c4c1e02e3272c6b2ccdb4e0085fda3b8352fecd0410ccf915/uvloop-0.21.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ee4d4ef48036ff6e5cfffb09dd192c7a5027153948d85b8da7ff705065bacc6", size = 4582806 }, + { url = "https://files.pythonhosted.org/packages/d2/19/f5b78616566ea68edd42aacaf645adbf71fbd83fc52281fba555dc27e3f1/uvloop-0.21.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3df876acd7ec037a3d005b3ab85a7e4110422e4d9c1571d4fc89b0fc41b6816", size = 4701068 }, + { url = "https://files.pythonhosted.org/packages/47/57/66f061ee118f413cd22a656de622925097170b9380b30091b78ea0c6ea75/uvloop-0.21.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd53ecc9a0f3d87ab847503c2e1552b690362e005ab54e8a48ba97da3924c0dc", size = 4454428 }, + { url = "https://files.pythonhosted.org/packages/63/9a/0962b05b308494e3202d3f794a6e85abe471fe3cafdbcf95c2e8c713aabd/uvloop-0.21.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a5c39f217ab3c663dc699c04cbd50c13813e31d917642d459fdcec07555cc553", size = 4660018 }, +] + +[[package]] +name = "watchfiles" +version = "1.0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/26/c705fc77d0a9ecdb9b66f1e2976d95b81df3cae518967431e7dbf9b5e219/watchfiles-1.0.4.tar.gz", hash = "sha256:6ba473efd11062d73e4f00c2b730255f9c1bdd73cd5f9fe5b5da8dbd4a717205", size = 94625 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/98/f03efabec64b5b1fa58c0daab25c68ef815b0f320e54adcacd0d6847c339/watchfiles-1.0.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:8012bd820c380c3d3db8435e8cf7592260257b378b649154a7948a663b5f84e9", size = 390954 }, + { url = "https://files.pythonhosted.org/packages/16/09/4dd49ba0a32a45813debe5fb3897955541351ee8142f586303b271a02b40/watchfiles-1.0.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aa216f87594f951c17511efe5912808dfcc4befa464ab17c98d387830ce07b60", size = 381133 }, + { url = "https://files.pythonhosted.org/packages/76/59/5aa6fc93553cd8d8ee75c6247763d77c02631aed21551a97d94998bf1dae/watchfiles-1.0.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:62c9953cf85529c05b24705639ffa390f78c26449e15ec34d5339e8108c7c407", size = 449516 }, + { url = "https://files.pythonhosted.org/packages/4c/aa/df4b6fe14b6317290b91335b23c96b488d365d65549587434817e06895ea/watchfiles-1.0.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7cf684aa9bba4cd95ecb62c822a56de54e3ae0598c1a7f2065d51e24637a3c5d", size = 454820 }, + { url = "https://files.pythonhosted.org/packages/5e/71/185f8672f1094ce48af33252c73e39b48be93b761273872d9312087245f6/watchfiles-1.0.4-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f44a39aee3cbb9b825285ff979ab887a25c5d336e5ec3574f1506a4671556a8d", size = 481550 }, + { url = "https://files.pythonhosted.org/packages/85/d7/50ebba2c426ef1a5cb17f02158222911a2e005d401caf5d911bfca58f4c4/watchfiles-1.0.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a38320582736922be8c865d46520c043bff350956dfc9fbaee3b2df4e1740a4b", size = 518647 }, + { url = "https://files.pythonhosted.org/packages/f0/7a/4c009342e393c545d68987e8010b937f72f47937731225b2b29b7231428f/watchfiles-1.0.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39f4914548b818540ef21fd22447a63e7be6e24b43a70f7642d21f1e73371590", size = 497547 }, + { url = "https://files.pythonhosted.org/packages/0f/7c/1cf50b35412d5c72d63b2bf9a4fffee2e1549a245924960dd087eb6a6de4/watchfiles-1.0.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f12969a3765909cf5dc1e50b2436eb2c0e676a3c75773ab8cc3aa6175c16e902", size = 452179 }, + { url = "https://files.pythonhosted.org/packages/d6/a9/3db1410e1c1413735a9a472380e4f431ad9a9e81711cda2aaf02b7f62693/watchfiles-1.0.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:0986902677a1a5e6212d0c49b319aad9cc48da4bd967f86a11bde96ad9676ca1", size = 614125 }, + { url = "https://files.pythonhosted.org/packages/f2/e1/0025d365cf6248c4d1ee4c3d2e3d373bdd3f6aff78ba4298f97b4fad2740/watchfiles-1.0.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:308ac265c56f936636e3b0e3f59e059a40003c655228c131e1ad439957592303", size = 611911 }, + { url = "https://files.pythonhosted.org/packages/55/55/035838277d8c98fc8c917ac9beeb0cd6c59d675dc2421df5f9fcf44a0070/watchfiles-1.0.4-cp313-cp313-win32.whl", hash = "sha256:aee397456a29b492c20fda2d8961e1ffb266223625346ace14e4b6d861ba9c80", size = 271152 }, + { url = "https://files.pythonhosted.org/packages/f0/e5/96b8e55271685ddbadc50ce8bc53aa2dff278fb7ac4c2e473df890def2dc/watchfiles-1.0.4-cp313-cp313-win_amd64.whl", hash = "sha256:d6097538b0ae5c1b88c3b55afa245a66793a8fec7ada6755322e465fb1a0e8cc", size = 285216 }, +] + +[[package]] +name = "wcwidth" +version = "0.2.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/63/53559446a878410fc5a5974feb13d31d78d752eb18aeba59c7fef1af7598/wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5", size = 101301 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/84/fd2ba7aafacbad3c4201d395674fc6348826569da3c0937e75505ead3528/wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859", size = 34166 }, +] + +[[package]] +name = "websockets" +version = "15.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440 }, + { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098 }, + { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329 }, + { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111 }, + { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054 }, + { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496 }, + { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829 }, + { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217 }, + { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195 }, + { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393 }, + { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837 }, + { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743 }, +]