check in current project state

This commit is contained in:
2025-04-01 18:35:11 +02:00
parent 8eeb98801d
commit 30692614e5
30 changed files with 2412 additions and 72 deletions

View File

@ -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. 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 ## Components
This system has been designed with modularity and extensibility in mind. It has the following building blocks: 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. 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 Using just RSA keys, you can construct a client using only the following tools:
## Rewrite encryption to use age - base64
The RSA implementation works alright, but requires some work on the client side converting back to a readable format. - openssl
Age seem better suited, as it can also use ed25519 keys. - ssh
This means that you can create a client using just a shell script.
## Dedicated client?
If `age` works out, it may be entirely unnecessary to have a dedicated client. Who knows...

View File

@ -10,11 +10,18 @@ requires-python = ">=3.13"
dependencies = [ dependencies = [
"asyncssh>=2.20.0", "asyncssh>=2.20.0",
"click>=8.1.8", "click>=8.1.8",
"click-repl>=0.3.0",
"click-shell",
"cryptography>=44.0.2", "cryptography>=44.0.2",
"fastapi[standard]>=0.115.12",
"httpx>=0.28.1",
"jinja2>=3.1.6",
"littletable>=3.0.1", "littletable>=3.0.1",
"paramiko>=3.5.1", "paramiko>=3.5.1",
"pydantic>=2.10.6", "pydantic>=2.10.6",
"pydantic-settings>=2.8.1",
"pykeepass>=4.1.1.post1", "pykeepass>=4.1.1.post1",
"python-dotenv>=1.0.1",
"python-json-logger>=3.3.0", "python-json-logger>=3.3.0",
] ]
@ -40,9 +47,13 @@ executionEnvironments = [
[tool.uv.workspace] [tool.uv.workspace]
members = ["packages/sshecret_client"] members = ["packages/sshecret_client"]
[tool.uv.sources]
click-shell = { git = "https://github.com/clarkperkins/click-shell" }
[dependency-groups] [dependency-groups]
dev = [ dev = [
"construct-typing>=0.6.2", "construct-typing>=0.6.2",
"mypy>=1.15.0", "mypy>=1.15.0",
"pytest>=8.3.5",
"python-dotenv>=1.0.1", "python-dotenv>=1.0.1",
] ]

446
src/sshecret/api.py Normal file
View File

@ -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)

View File

@ -32,7 +32,6 @@ class AuditMessage(BaseModel):
client_name: str | None = None client_name: str | None = None
source_address: str | None = None source_address: str | None = None
secret_name: str | None = None secret_name: str | None = None
details: str | None = None
def __str__(self) -> str: def __str__(self) -> str:
"""Stringify object as JSON.""" """Stringify object as JSON."""
@ -45,7 +44,7 @@ def audit_message(
client_name: str | None = None, client_name: str | None = None,
secret_name: str | None = None, secret_name: str | None = None,
source_address: str | None = None, source_address: str | None = None,
details: str | None = None, **details: str
) -> None: ) -> None:
"""Create an audit message.""" """Create an audit message."""
if not audit_type: if not audit_type:
@ -60,7 +59,8 @@ def audit_message(
client_name=client_name, client_name=client_name,
source_address=source_address, source_address=source_address,
secret_name=secret_name, 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})

View File

@ -32,6 +32,7 @@ def load_clients_from_dir(directory: Path) -> dict[Path, ClientSpecification]:
return clients return clients
class FileTableBackend(BaseClientBackend): class FileTableBackend(BaseClientBackend):
"""In-memory littletable based backend.""" """In-memory littletable based backend."""
@ -119,3 +120,14 @@ class FileTableBackend(BaseClientBackend):
if existing: if existing:
self.table.remove(existing) self.table.remove(existing)
self.add_client(spec) 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)

18
src/sshecret/config.py Normal file
View File

@ -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

View File

@ -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 base64
import logging import logging
from pathlib import Path 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 rsa
from cryptography.hazmat.primitives.asymmetric import padding 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) public_key = serialization.load_ssh_public_key(keybytes)
if not isinstance(public_key, rsa.RSAPublicKey): if not isinstance(public_key, rsa.RSAPublicKey):
raise RuntimeError("Only RSA keys are supported.") 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 return public_key
@ -40,11 +47,7 @@ def encrypt_string(string: str, public_key: rsa.RSAPublicKey) -> str:
message = string.encode() message = string.encode()
ciphertext = public_key.encrypt( ciphertext = public_key.encrypt(
message, message,
padding.OAEP( padding.PKCS1v15(),
mgf=padding.MGF1(algorithm=hashes.SHA256()),
algorithm=hashes.SHA256(),
label=None,
),
) )
return base64.b64encode(ciphertext).decode() return base64.b64encode(ciphertext).decode()
@ -54,11 +57,7 @@ def decode_string(ciphertext: str, private_key: rsa.RSAPrivateKey) -> str:
decoded = base64.b64decode(ciphertext) decoded = base64.b64decode(ciphertext)
decrypted = private_key.decrypt( decrypted = private_key.decrypt(
decoded, decoded,
padding.OAEP( padding.PKCS1v15(),
mgf=padding.MGF1(algorithm=hashes.SHA256()),
algorithm=hashes.SHA256(),
label=None,
),
) )
return decrypted.decode() return decrypted.decode()

View File

@ -89,6 +89,5 @@ def run_async_server(directory: str, port: int) -> None:
loop.run_forever() loop.run_forever()
if __name__ == "__main__": if __name__ == "__main__":
cli() cli()

View File

@ -2,7 +2,7 @@
import logging import logging
from pathlib import Path from pathlib import Path
from typing import final, override, Self from typing import cast, final, overload, override, Self
import pykeepass import pykeepass
from . import constants from . import constants
@ -18,11 +18,32 @@ class KeepassManager(BasePasswordManager):
master_password_identifier = constants.MASTER_PASSWORD master_password_identifier = constants.MASTER_PASSWORD
def __init__(self, location: Path) -> None: def __init__(self) -> None:
"""Initialize password manager.""" """Initialize password manager."""
self.location = location self._location: Path | None = None
self._keepass: pykeepass.PyKeePass | 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 @property
def keepass(self) -> pykeepass.PyKeePass: def keepass(self) -> pykeepass.PyKeePass:
"""Return keepass instance.""" """Return keepass instance."""
@ -35,27 +56,45 @@ class KeepassManager(BasePasswordManager):
"""Set the keepass instance.""" """Set the keepass instance."""
self._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 @override
@classmethod @classmethod
def create_database( def create_database(
cls, location: str, reader_context: PasswordContext, overwrite: bool = False cls, location: str, password_context: PasswordContext | str, overwrite: bool = False
) -> Self: ) -> Self:
"""Create database.""" """Create database."""
if Path(location).exists() and not overwrite: if Path(location).exists() and not overwrite:
raise RuntimeError("Error: Database exists.") 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? # TODO: should we delete if overwrite is set?
keepass = pykeepass.create_database(location, password=master_password) keepass = pykeepass.create_database(location, password=master_password)
instance = cls(Path(location)) instance = cls()
instance.set_manager_options({"location": str(location)})
instance.keepass = keepass instance.keepass = keepass
return instance return instance
@override @override
def open_database(self, reader: PasswordContext) -> None: def open_database(self, password_context: PasswordContext | str) -> None:
"""Open the database""" """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) instance = pykeepass.PyKeePass(str(self.location.absolute()), password=password)
self.keepass = instance self.keepass = instance
@ -65,14 +104,17 @@ class KeepassManager(BasePasswordManager):
self._keepass = None self._keepass = None
@override @override
def get_password(self, identifier: str) -> str: def get_password(self, identifier: str) -> str | None:
"""Get password.""" """Get password."""
if entry := self.keepass.find_entries(title=identifier, first=True): entry = cast("pykeepass.entry.Entry | None", self.keepass.find_entries(title=identifier, first=True))
if password := entry.password: if not entry:
return str(password) return None
if password := cast(str, entry.password):
return str(password)
raise RuntimeError(f"Cannot get password for entry {identifier}") raise RuntimeError(f"Cannot get password for entry {identifier}")
@override @override
def generate_password(self, identifier: str) -> str: def generate_password(self, identifier: str) -> str:
"""Generate password.""" """Generate password."""
@ -84,3 +126,55 @@ class KeepassManager(BasePasswordManager):
self.keepass.save() self.keepass.save()
LOG.debug("Created Entry %r", _entry) LOG.debug("Created Entry %r", _entry)
return password 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()

View File

@ -7,7 +7,8 @@ InputPasswordReader and EnvironmentPasswordReader.
import re import re
import os import os
from typing import override import sys
from typing import TextIO, override
import click import click
from .types import BasePasswordReader from .types import BasePasswordReader
@ -20,11 +21,10 @@ class InputPasswordReader(BasePasswordReader):
"""Read a password from stdin.""" """Read a password from stdin."""
@override @override
@classmethod def get_password(self, identifier: str, repeated: bool = False) -> str:
def get_password(cls, identifier: str) -> str:
"""Get password.""" """Get password."""
if password := click.prompt( 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) return str(password)
raise ValueError("No password received.") 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_]*` Final environemnt variable will be validated according to the regex `[a-zA-Z_]+[a-zA-Z0-9_]*`
""" """
def __init__(self, identifier: str) -> None: def _resolve_var_name(self, identifier: str) -> str:
"""Initialize class."""
self._identifier: str = identifier
def _resolve_var_name(self) -> str:
"""Resolve variable name.""" """Resolve variable name."""
identifier = self._identifier.replace("-", "_") identifier = identifier.replace("-", "_")
fields = [constants.VAR_PREFIX, identifier] fields = [constants.VAR_PREFIX, identifier]
varname = "_".join(fields) varname = "_".join(fields)
if not RE_VARNAME.fullmatch(varname): if not RE_VARNAME.fullmatch(varname):
@ -52,16 +48,14 @@ class EnvironmentPasswordReader(BasePasswordReader):
) )
return varname return varname
def get_password_from_env(self) -> str: def get_password_from_env(self, identifier: str) -> str:
"""Get password from environment.""" """Get password from environment."""
varname = self._resolve_var_name() varname = self._resolve_var_name(identifier)
if password := os.getenv(varname, None): if password := os.getenv(varname, None):
return password return password
raise ValueError(f"Error: No variable named {varname} resolved.") raise ValueError(f"Error: No variable named {varname} resolved.")
@override @override
@classmethod def get_password(self, identifier: str, repeated: bool = False) -> str:
def get_password(cls, identifier: str) -> str:
"""Get password.""" """Get password."""
instance = cls(identifier) return self.get_password_from_env(identifier)
return instance.get_password_from_env()

View File

@ -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()

82
src/sshecret/settings.py Normal file
View File

@ -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)

View File

@ -0,0 +1 @@
"""Shell interface."""

View File

@ -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)

View File

@ -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
"""

View File

@ -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

View File

@ -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)

View File

@ -1,11 +1,16 @@
"""Testing utilities and classes.""" """Testing utilities and classes."""
from io import StringIO
import tempfile import tempfile
from dataclasses import dataclass, field from dataclasses import dataclass, field
from contextlib import contextmanager from contextlib import contextmanager
from pathlib import Path from pathlib import Path
from collections.abc import Iterator 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 @dataclass
@ -16,6 +21,46 @@ class TestClientSpec:
secrets: dict[str, str] = field(default_factory=dict) 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 @contextmanager
def test_context(clients: list[TestClientSpec]) -> Iterator[Path]: def test_context(clients: list[TestClientSpec]) -> Iterator[Path]:
"""Create a test context.""" """Create a test context."""
@ -26,3 +71,26 @@ def test_context(clients: list[TestClientSpec]) -> Iterator[Path]:
create_client_file(client.name, filename, client.secrets) create_client_file(client.name, filename, client.secrets)
yield dirpath 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

View File

@ -1,41 +1,41 @@
"""Interfaces and types.""" """Interfaces and types."""
import abc 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 from pydantic.networks import IPvAnyAddress, IPvAnyNetwork
class BasePasswordReader(abc.ABC): class BasePasswordReader(abc.ABC):
"""Abstract strategy class to read a passwords.""" """Abstract strategy class to read a passwords."""
@classmethod
@abc.abstractmethod @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.""" """Resolve the password, e.g., via input."""
class PasswordContext: class PasswordContext:
"""Context class for resolving a password.""" """Context class for resolving a password."""
def __init__(self, reader: type[BasePasswordReader]) -> None: def __init__(self, reader: BasePasswordReader) -> None:
"""Initialize password context.""" """Initialize password context."""
self._reader: type[BasePasswordReader] = reader self._reader: BasePasswordReader = reader
@property @property
def reader(self) -> type[BasePasswordReader]: def reader(self) -> BasePasswordReader:
"""Return reader.""" """Return reader."""
return self._reader return self._reader
@reader.setter @reader.setter
def reader(self, reader: type[BasePasswordReader]) -> None: def reader(self, reader: BasePasswordReader) -> None:
"""Set the reader instance.""" """Set the reader instance."""
self._reader = reader self._reader = reader
def get_password(self, identifier: str) -> str: def get_password(self, identifier: str, repeated: bool = False) -> str:
"""Get the password.""" """Get the password."""
return self.reader.get_password(identifier) return self.reader.get_password(identifier, repeated)
class BasePasswordManager(abc.ABC): class BasePasswordManager(abc.ABC):
@ -46,7 +46,10 @@ class BasePasswordManager(abc.ABC):
@classmethod @classmethod
@abc.abstractmethod @abc.abstractmethod
def create_database( def create_database(
cls, location: str, reader_context: PasswordContext, overwrite: bool = False cls,
location: str,
password_context: PasswordContext | str,
overwrite: bool = False,
) -> Self: ) -> Self:
"""Create database. """Create database.
@ -54,7 +57,7 @@ class BasePasswordManager(abc.ABC):
""" """
@abc.abstractmethod @abc.abstractmethod
def open_database(self, reader: PasswordContext) -> None: def open_database(self, password_context: PasswordContext | str) -> None:
"""Open database.""" """Open database."""
@abc.abstractmethod @abc.abstractmethod
@ -62,7 +65,7 @@ class BasePasswordManager(abc.ABC):
"""Close database.""" """Close database."""
@abc.abstractmethod @abc.abstractmethod
def get_password(self, identifier: str) -> str: def get_password(self, identifier: str) -> str | None:
"""Get a password from the manager.""" """Get a password from the manager."""
@abc.abstractmethod @abc.abstractmethod
@ -74,6 +77,32 @@ class BasePasswordManager(abc.ABC):
Returns the generated password. 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): class ClientSpecification(BaseModel):
"""Specification of client.""" """Specification of client."""
@ -117,3 +146,34 @@ class BaseClientBackend(abc.ABC):
@abc.abstractmethod @abc.abstractmethod
def remove_client(self, name: str, persistent: bool = True) -> None: def remove_client(self, name: str, persistent: bool = True) -> None:
"""Delete a client.""" """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)

View File

@ -0,0 +1 @@

279
src/sshecret/webapi/api.py Normal file
View File

@ -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)

View File

@ -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

View File

@ -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")

View File

@ -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 = "*"

View File

@ -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")

416
tests/test_admin_api.py Normal file
View File

@ -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()

View File

@ -96,6 +96,30 @@ class TestFileTableBackend(unittest.TestCase):
webserver_file = testdir / "webserver.json" webserver_file = testdir / "webserver.json"
self.assertFalse(webserver_file.exists()) 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__": if __name__ == "__main__":
unittest.main() unittest.main()

View File

@ -13,8 +13,7 @@ class Rot13PasswordReader(BasePasswordReader):
"""This password reader returns the identifier backwards.""" """This password reader returns the identifier backwards."""
@override @override
@classmethod def get_password(self, identifier: str, repeated: bool = False) -> str:
def get_password(cls, identifier: str) -> str:
"""Get password.""" """Get password."""
return identifier[::-1] return identifier[::-1]
@ -29,7 +28,7 @@ class TestKeepass(unittest.TestCase):
@override @override
def setUp(self) -> None: def setUp(self) -> None:
"""Set up testing.""" """Set up testing."""
self.reader_context = PasswordContext(Rot13PasswordReader) self.reader_context = PasswordContext(Rot13PasswordReader())
def test_db_create(self) -> None: def test_db_create(self) -> None:
"""Test db creation.""" """Test db creation."""

View File

@ -17,7 +17,7 @@ class TestInputPasswordReader(unittest.TestCase):
"""Test reader.""" """Test reader."""
input_password = "testpassword" input_password = "testpassword"
with patch("getpass.getpass", return_value=input_password): 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") self.assertEqual(received_password, "testpassword")
@ -32,7 +32,7 @@ class TestEnvPasswordReader(unittest.TestCase):
def test_env_loader(self) -> None: def test_env_loader(self) -> None:
"""Test environment loading.""" """Test environment loading."""
password = EnvironmentPasswordReader.get_password("test") password = EnvironmentPasswordReader().get_password("test")
self.assertEqual(password, "secretthing") self.assertEqual(password, "secretthing")

510
uv.lock generated
View File

@ -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 }, { 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]] [[package]]
name = "argon2-cffi" name = "argon2-cffi"
version = "23.1.0" 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 }, { 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]] [[package]]
name = "cffi" name = "cffi"
version = "1.17.1" 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 }, { 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]] [[package]]
name = "colorama" name = "colorama"
version = "0.4.6" 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 }, { 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]] [[package]]
name = "littletable" name = "littletable"
version = "3.0.1" 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 }, { 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]] [[package]]
name = "mypy" name = "mypy"
version = "1.15.0" 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 }, { 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]] [[package]]
name = "paramiko" name = "paramiko"
version = "3.5.1" 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 }, { 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]] [[package]]
name = "pycparser" name = "pycparser"
version = "2.22" 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 }, { 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]] [[package]]
name = "pykeepass" name = "pykeepass"
version = "4.1.1.post1" 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 }, { 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]] [[package]]
name = "python-dotenv" name = "python-dotenv"
version = "1.0.1" 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 }, { 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]] [[package]]
name = "sshecret" name = "sshecret"
version = "0.1.0" version = "0.1.0"
@ -421,11 +798,18 @@ source = { editable = "." }
dependencies = [ dependencies = [
{ name = "asyncssh" }, { name = "asyncssh" },
{ name = "click" }, { name = "click" },
{ name = "click-repl" },
{ name = "click-shell" },
{ name = "cryptography" }, { name = "cryptography" },
{ name = "fastapi", extra = ["standard"] },
{ name = "httpx" },
{ name = "jinja2" },
{ name = "littletable" }, { name = "littletable" },
{ name = "paramiko" }, { name = "paramiko" },
{ name = "pydantic" }, { name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "pykeepass" }, { name = "pykeepass" },
{ name = "python-dotenv" },
{ name = "python-json-logger" }, { name = "python-json-logger" },
] ]
@ -433,6 +817,7 @@ dependencies = [
dev = [ dev = [
{ name = "construct-typing" }, { name = "construct-typing" },
{ name = "mypy" }, { name = "mypy" },
{ name = "pytest" },
{ name = "python-dotenv" }, { name = "python-dotenv" },
] ]
@ -440,11 +825,18 @@ dev = [
requires-dist = [ requires-dist = [
{ name = "asyncssh", specifier = ">=2.20.0" }, { name = "asyncssh", specifier = ">=2.20.0" },
{ name = "click", specifier = ">=8.1.8" }, { 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 = "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 = "littletable", specifier = ">=3.0.1" },
{ name = "paramiko", specifier = ">=3.5.1" }, { name = "paramiko", specifier = ">=3.5.1" },
{ name = "pydantic", specifier = ">=2.10.6" }, { name = "pydantic", specifier = ">=2.10.6" },
{ name = "pydantic-settings", specifier = ">=2.8.1" },
{ name = "pykeepass", specifier = ">=4.1.1.post1" }, { name = "pykeepass", specifier = ">=4.1.1.post1" },
{ name = "python-dotenv", specifier = ">=1.0.1" },
{ name = "python-json-logger", specifier = ">=3.3.0" }, { name = "python-json-logger", specifier = ">=3.3.0" },
] ]
@ -452,6 +844,7 @@ requires-dist = [
dev = [ dev = [
{ name = "construct-typing", specifier = ">=0.6.2" }, { name = "construct-typing", specifier = ">=0.6.2" },
{ name = "mypy", specifier = ">=1.15.0" }, { name = "mypy", specifier = ">=1.15.0" },
{ name = "pytest", specifier = ">=8.3.5" },
{ name = "python-dotenv", specifier = ">=1.0.1" }, { name = "python-dotenv", specifier = ">=1.0.1" },
] ]
@ -476,6 +869,33 @@ requires-dist = [
{ name = "sshecret", editable = "." }, { 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]] [[package]]
name = "typing-extensions" name = "typing-extensions"
version = "4.12.2" version = "4.12.2"
@ -484,3 +904,93 @@ sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec3
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, { 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 },
]