check in current project state
This commit is contained in:
19
README.md
19
README.md
@ -10,6 +10,8 @@ consuming a lot more time and energy than what feels justified.
|
||||
|
||||
This system has been created to provide a centralized solution that works well-enough.
|
||||
|
||||
One clear goal was to have all the complexity on the server-side, and be able to construct a minimal client.
|
||||
|
||||
## Components
|
||||
|
||||
This system has been designed with modularity and extensibility in mind. It has the following building blocks:
|
||||
@ -47,13 +49,14 @@ If permitted to access the secret, it will returned encrypted with the client RS
|
||||
|
||||
This allows the client to decrypt and get the clear text value easily.
|
||||
|
||||
## Usage
|
||||
# FAQ
|
||||
## Why not use Age?
|
||||
I like age a lot, and it's ability to use more ssh key types is certainly a winner feature.
|
||||
However, one goal here is to be able to construct a client with minimal dependencies, and that speaks in favor of the current solution.
|
||||
|
||||
# Next step
|
||||
## Rewrite encryption to use age
|
||||
The RSA implementation works alright, but requires some work on the client side converting back to a readable format.
|
||||
Age seem better suited, as it can also use ed25519 keys.
|
||||
Using just RSA keys, you can construct a client using only the following tools:
|
||||
- base64
|
||||
- openssl
|
||||
- ssh
|
||||
|
||||
|
||||
## Dedicated client?
|
||||
If `age` works out, it may be entirely unnecessary to have a dedicated client. Who knows...
|
||||
This means that you can create a client using just a shell script.
|
||||
|
||||
@ -10,11 +10,18 @@ requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"asyncssh>=2.20.0",
|
||||
"click>=8.1.8",
|
||||
"click-repl>=0.3.0",
|
||||
"click-shell",
|
||||
"cryptography>=44.0.2",
|
||||
"fastapi[standard]>=0.115.12",
|
||||
"httpx>=0.28.1",
|
||||
"jinja2>=3.1.6",
|
||||
"littletable>=3.0.1",
|
||||
"paramiko>=3.5.1",
|
||||
"pydantic>=2.10.6",
|
||||
"pydantic-settings>=2.8.1",
|
||||
"pykeepass>=4.1.1.post1",
|
||||
"python-dotenv>=1.0.1",
|
||||
"python-json-logger>=3.3.0",
|
||||
]
|
||||
|
||||
@ -40,9 +47,13 @@ executionEnvironments = [
|
||||
[tool.uv.workspace]
|
||||
members = ["packages/sshecret_client"]
|
||||
|
||||
[tool.uv.sources]
|
||||
click-shell = { git = "https://github.com/clarkperkins/click-shell" }
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"construct-typing>=0.6.2",
|
||||
"mypy>=1.15.0",
|
||||
"pytest>=8.3.5",
|
||||
"python-dotenv>=1.0.1",
|
||||
]
|
||||
|
||||
446
src/sshecret/api.py
Normal file
446
src/sshecret/api.py
Normal 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)
|
||||
@ -32,7 +32,6 @@ class AuditMessage(BaseModel):
|
||||
client_name: str | None = None
|
||||
source_address: str | None = None
|
||||
secret_name: str | None = None
|
||||
details: str | None = None
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""Stringify object as JSON."""
|
||||
@ -45,7 +44,7 @@ def audit_message(
|
||||
client_name: str | None = None,
|
||||
secret_name: str | None = None,
|
||||
source_address: str | None = None,
|
||||
details: str | None = None,
|
||||
**details: str
|
||||
) -> None:
|
||||
"""Create an audit message."""
|
||||
if not audit_type:
|
||||
@ -60,7 +59,8 @@ def audit_message(
|
||||
client_name=client_name,
|
||||
source_address=source_address,
|
||||
secret_name=secret_name,
|
||||
details=details,
|
||||
)
|
||||
|
||||
AUDIT_LOG.info(audit_message.model_dump(exclude_none=True))
|
||||
audit_dict = audit_message.model_dump(exclude_none=True)
|
||||
|
||||
AUDIT_LOG.info({**audit_dict, **details})
|
||||
|
||||
@ -32,6 +32,7 @@ def load_clients_from_dir(directory: Path) -> dict[Path, ClientSpecification]:
|
||||
return clients
|
||||
|
||||
|
||||
|
||||
class FileTableBackend(BaseClientBackend):
|
||||
"""In-memory littletable based backend."""
|
||||
|
||||
@ -119,3 +120,14 @@ class FileTableBackend(BaseClientBackend):
|
||||
if existing:
|
||||
self.table.remove(existing)
|
||||
self.add_client(spec)
|
||||
|
||||
@override
|
||||
def get_all(self) -> list[ClientSpecification]:
|
||||
"""Get all clients."""
|
||||
return list(self.table)
|
||||
|
||||
@override
|
||||
def lookup_by_secret(self, secret_name: str) -> list[ClientSpecification]:
|
||||
"""Lookup by secret name."""
|
||||
results = self.table.where(lambda client: secret_name in client.secrets)
|
||||
return list(results)
|
||||
|
||||
18
src/sshecret/config.py
Normal file
18
src/sshecret/config.py
Normal 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
|
||||
@ -1,9 +1,14 @@
|
||||
"""Encryption related functions."""
|
||||
"""Encryption related functions.
|
||||
|
||||
Note! Encryption uses the less secure PKCS1v15 padding. This is to allow
|
||||
decryption via openssl on the command line.
|
||||
|
||||
"""
|
||||
|
||||
import base64
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from cryptography.hazmat.primitives import serialization, hashes
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from cryptography.hazmat.primitives.asymmetric import padding
|
||||
|
||||
@ -23,6 +28,8 @@ def load_public_key(keybytes: bytes) -> rsa.RSAPublicKey:
|
||||
public_key = serialization.load_ssh_public_key(keybytes)
|
||||
if not isinstance(public_key, rsa.RSAPublicKey):
|
||||
raise RuntimeError("Only RSA keys are supported.")
|
||||
pem_public_key = public_key.public_bytes(encoding=serialization.Encoding.PEM, format=serialization.PublicFormat.SubjectPublicKeyInfo)
|
||||
LOG.info("pem:\n%s", pem_public_key)
|
||||
return public_key
|
||||
|
||||
|
||||
@ -40,11 +47,7 @@ def encrypt_string(string: str, public_key: rsa.RSAPublicKey) -> str:
|
||||
message = string.encode()
|
||||
ciphertext = public_key.encrypt(
|
||||
message,
|
||||
padding.OAEP(
|
||||
mgf=padding.MGF1(algorithm=hashes.SHA256()),
|
||||
algorithm=hashes.SHA256(),
|
||||
label=None,
|
||||
),
|
||||
padding.PKCS1v15(),
|
||||
)
|
||||
return base64.b64encode(ciphertext).decode()
|
||||
|
||||
@ -54,11 +57,7 @@ def decode_string(ciphertext: str, private_key: rsa.RSAPrivateKey) -> str:
|
||||
decoded = base64.b64decode(ciphertext)
|
||||
decrypted = private_key.decrypt(
|
||||
decoded,
|
||||
padding.OAEP(
|
||||
mgf=padding.MGF1(algorithm=hashes.SHA256()),
|
||||
algorithm=hashes.SHA256(),
|
||||
label=None,
|
||||
),
|
||||
padding.PKCS1v15(),
|
||||
)
|
||||
return decrypted.decode()
|
||||
|
||||
|
||||
@ -89,6 +89,5 @@ def run_async_server(directory: str, port: int) -> None:
|
||||
|
||||
loop.run_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
cli()
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import final, override, Self
|
||||
from typing import cast, final, overload, override, Self
|
||||
|
||||
import pykeepass
|
||||
from . import constants
|
||||
@ -18,11 +18,32 @@ class KeepassManager(BasePasswordManager):
|
||||
|
||||
master_password_identifier = constants.MASTER_PASSWORD
|
||||
|
||||
def __init__(self, location: Path) -> None:
|
||||
def __init__(self) -> None:
|
||||
"""Initialize password manager."""
|
||||
self.location = location
|
||||
self._location: Path | None = None
|
||||
self._keepass: pykeepass.PyKeePass | None = None
|
||||
|
||||
@property
|
||||
def location(self) -> Path:
|
||||
"""Get location."""
|
||||
if not self._location:
|
||||
raise RuntimeError("No location has been specified.")
|
||||
return self._location
|
||||
|
||||
@location.setter
|
||||
def location(self, location: Path) -> None:
|
||||
"""Set location."""
|
||||
if not location.exists() or not location.is_file():
|
||||
raise RuntimeError("Unable to read provided password file.")
|
||||
self._location = location
|
||||
|
||||
@override
|
||||
def set_manager_options(self, options: dict[str, str]) -> None:
|
||||
"""Set manager options."""
|
||||
if "location" in options:
|
||||
location = Path(str(options["location"]))
|
||||
self.location = location
|
||||
|
||||
@property
|
||||
def keepass(self) -> pykeepass.PyKeePass:
|
||||
"""Return keepass instance."""
|
||||
@ -35,27 +56,45 @@ class KeepassManager(BasePasswordManager):
|
||||
"""Set the keepass instance."""
|
||||
self._keepass = instance
|
||||
|
||||
@override
|
||||
def get_entries(self) -> list[str]:
|
||||
"""Get all entries."""
|
||||
entries = self.keepass.entries
|
||||
if not entries:
|
||||
return []
|
||||
return [
|
||||
str(entry.title) for entry in entries
|
||||
]
|
||||
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def create_database(
|
||||
cls, location: str, reader_context: PasswordContext, overwrite: bool = False
|
||||
cls, location: str, password_context: PasswordContext | str, overwrite: bool = False
|
||||
) -> Self:
|
||||
"""Create database."""
|
||||
if Path(location).exists() and not overwrite:
|
||||
raise RuntimeError("Error: Database exists.")
|
||||
|
||||
master_password = reader_context.get_password(cls.master_password_identifier)
|
||||
if isinstance(password_context, PasswordContext):
|
||||
master_password = password_context.get_password(cls.master_password_identifier, True)
|
||||
else:
|
||||
master_password = password_context
|
||||
|
||||
# TODO: should we delete if overwrite is set?
|
||||
keepass = pykeepass.create_database(location, password=master_password)
|
||||
instance = cls(Path(location))
|
||||
instance = cls()
|
||||
instance.set_manager_options({"location": str(location)})
|
||||
instance.keepass = keepass
|
||||
return instance
|
||||
|
||||
@override
|
||||
def open_database(self, reader: PasswordContext) -> None:
|
||||
def open_database(self, password_context: PasswordContext | str) -> None:
|
||||
"""Open the database"""
|
||||
password = reader.get_password(self.master_password_identifier)
|
||||
if isinstance(password_context, PasswordContext):
|
||||
password = password_context.get_password(self.master_password_identifier)
|
||||
else:
|
||||
password = password_context
|
||||
instance = pykeepass.PyKeePass(str(self.location.absolute()), password=password)
|
||||
self.keepass = instance
|
||||
|
||||
@ -65,14 +104,17 @@ class KeepassManager(BasePasswordManager):
|
||||
self._keepass = None
|
||||
|
||||
@override
|
||||
def get_password(self, identifier: str) -> str:
|
||||
def get_password(self, identifier: str) -> str | None:
|
||||
"""Get password."""
|
||||
if entry := self.keepass.find_entries(title=identifier, first=True):
|
||||
if password := entry.password:
|
||||
return str(password)
|
||||
entry = cast("pykeepass.entry.Entry | None", self.keepass.find_entries(title=identifier, first=True))
|
||||
if not entry:
|
||||
return None
|
||||
|
||||
if password := cast(str, entry.password):
|
||||
return str(password)
|
||||
raise RuntimeError(f"Cannot get password for entry {identifier}")
|
||||
|
||||
|
||||
@override
|
||||
def generate_password(self, identifier: str) -> str:
|
||||
"""Generate password."""
|
||||
@ -84,3 +126,55 @@ class KeepassManager(BasePasswordManager):
|
||||
self.keepass.save()
|
||||
LOG.debug("Created Entry %r", _entry)
|
||||
return password
|
||||
|
||||
@override
|
||||
def add_password(self, identifier: str, password: str) -> None:
|
||||
"""Add a password."""
|
||||
entry = cast("pykeepass.entry.Entry | None", self.keepass.find_entries(title=identifier, first=True))
|
||||
if not entry:
|
||||
_entry = self.keepass.add_entry(self.keepass.root_group, identifier, constants.NO_USERNAME, password)
|
||||
self.keepass.save()
|
||||
LOG.debug("Created entry %r", _entry)
|
||||
return
|
||||
self.change_password(identifier, password)
|
||||
LOG.debug("Updated password on entry %r", entry)
|
||||
|
||||
|
||||
@overload
|
||||
def change_password(self, identifier: str, password: None) -> str: ...
|
||||
|
||||
@overload
|
||||
def change_password(self, identifier: str, password: str) -> None: ...
|
||||
|
||||
@override
|
||||
def change_password(self, identifier: str, password: str | None) -> str | None:
|
||||
"""Change a password."""
|
||||
generated_password = False
|
||||
if not password:
|
||||
password = generate_password()
|
||||
generated_password = True
|
||||
|
||||
entry = cast("pykeepass.entry.Entry | None", self.keepass.find_entries(title=identifier, first=True))
|
||||
|
||||
if not entry:
|
||||
raise ValueError("Error: Entry not found!")
|
||||
|
||||
entry.password = password
|
||||
|
||||
self.keepass.save()
|
||||
if generated_password:
|
||||
return password
|
||||
|
||||
return None
|
||||
|
||||
@override
|
||||
def delete_password(self, identifier: str) -> None:
|
||||
"""Delete password."""
|
||||
entry = cast("pykeepass.entry.Entry | None", self.keepass.find_entries(title=identifier, first=True))
|
||||
if not entry:
|
||||
return
|
||||
LOG.info("Deleting entry %s for keepass.", entry.uuid)
|
||||
|
||||
self.keepass.delete_entry(entry)
|
||||
|
||||
self.keepass.save()
|
||||
|
||||
@ -7,7 +7,8 @@ InputPasswordReader and EnvironmentPasswordReader.
|
||||
|
||||
import re
|
||||
import os
|
||||
from typing import override
|
||||
import sys
|
||||
from typing import TextIO, override
|
||||
import click
|
||||
|
||||
from .types import BasePasswordReader
|
||||
@ -20,11 +21,10 @@ class InputPasswordReader(BasePasswordReader):
|
||||
"""Read a password from stdin."""
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def get_password(cls, identifier: str) -> str:
|
||||
def get_password(self, identifier: str, repeated: bool = False) -> str:
|
||||
"""Get password."""
|
||||
if password := click.prompt(
|
||||
f"Enter password for {identifier}", hide_input=True, type=str
|
||||
f"Enter password for {identifier}", hide_input=True, type=str, confirmation_prompt=repeated
|
||||
):
|
||||
return str(password)
|
||||
raise ValueError("No password received.")
|
||||
@ -37,13 +37,9 @@ class EnvironmentPasswordReader(BasePasswordReader):
|
||||
Final environemnt variable will be validated according to the regex `[a-zA-Z_]+[a-zA-Z0-9_]*`
|
||||
"""
|
||||
|
||||
def __init__(self, identifier: str) -> None:
|
||||
"""Initialize class."""
|
||||
self._identifier: str = identifier
|
||||
|
||||
def _resolve_var_name(self) -> str:
|
||||
def _resolve_var_name(self, identifier: str) -> str:
|
||||
"""Resolve variable name."""
|
||||
identifier = self._identifier.replace("-", "_")
|
||||
identifier = identifier.replace("-", "_")
|
||||
fields = [constants.VAR_PREFIX, identifier]
|
||||
varname = "_".join(fields)
|
||||
if not RE_VARNAME.fullmatch(varname):
|
||||
@ -52,16 +48,14 @@ class EnvironmentPasswordReader(BasePasswordReader):
|
||||
)
|
||||
return varname
|
||||
|
||||
def get_password_from_env(self) -> str:
|
||||
def get_password_from_env(self, identifier: str) -> str:
|
||||
"""Get password from environment."""
|
||||
varname = self._resolve_var_name()
|
||||
varname = self._resolve_var_name(identifier)
|
||||
if password := os.getenv(varname, None):
|
||||
return password
|
||||
raise ValueError(f"Error: No variable named {varname} resolved.")
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def get_password(cls, identifier: str) -> str:
|
||||
def get_password(self, identifier: str, repeated: bool = False) -> str:
|
||||
"""Get password."""
|
||||
instance = cls(identifier)
|
||||
return instance.get_password_from_env()
|
||||
return self.get_password_from_env(identifier)
|
||||
|
||||
36
src/sshecret/server/ssh_password_reader.py
Normal file
36
src/sshecret/server/ssh_password_reader.py
Normal 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
82
src/sshecret/settings.py
Normal 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)
|
||||
1
src/sshecret/shell/__init__.py
Normal file
1
src/sshecret/shell/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Shell interface."""
|
||||
55
src/sshecret/shell/admin_shell.py
Normal file
55
src/sshecret/shell/admin_shell.py
Normal 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)
|
||||
14
src/sshecret/shell/commands.py
Normal file
14
src/sshecret/shell/commands.py
Normal 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
|
||||
|
||||
"""
|
||||
29
src/sshecret/shell/shell_client.py
Normal file
29
src/sshecret/shell/shell_client.py
Normal 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
|
||||
45
src/sshecret/shell/shell_context.py
Normal file
45
src/sshecret/shell/shell_context.py
Normal 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)
|
||||
@ -1,11 +1,16 @@
|
||||
"""Testing utilities and classes."""
|
||||
|
||||
from io import StringIO
|
||||
import tempfile
|
||||
from dataclasses import dataclass, field
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from collections.abc import Iterator
|
||||
from .utils import create_client_file
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from .utils import create_client_file, generate_password
|
||||
from . import settings as app_settings
|
||||
from .keepass import KeepassManager
|
||||
|
||||
|
||||
@dataclass
|
||||
@ -16,6 +21,46 @@ class TestClientSpec:
|
||||
secrets: dict[str, str] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TestContext:
|
||||
"""Test context."""
|
||||
|
||||
path: Path
|
||||
master_password: str
|
||||
|
||||
@property
|
||||
def password_database(self) -> Path:
|
||||
"""Return password database location."""
|
||||
return self.path / "test.kdbx"
|
||||
|
||||
def get_settings(self) -> app_settings.Settings:
|
||||
"""Get settings."""
|
||||
return app_settings.Settings(
|
||||
backend=app_settings.BackendSettings(
|
||||
backend=app_settings.FileBackendSettings(
|
||||
type="Files", location=self.path
|
||||
),
|
||||
),
|
||||
password_manager=app_settings.PasswordManagerSettings(
|
||||
manager=app_settings.KeepassPDBSettings(
|
||||
type="KeePass", location=self.password_database
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def set_environment(context: TestContext) -> None:
|
||||
"""Set environment."""
|
||||
password_path = str(context.password_database)
|
||||
env: list[str] = [
|
||||
f"sshecret_backend__backend_location={str(context.path)}",
|
||||
"sshecret_backend__password_manager__manager_type=KeePass",
|
||||
f"sshecret_backend__password_manager__manager_location={password_path}",
|
||||
]
|
||||
env_str = StringIO("\n".join(env))
|
||||
load_dotenv(stream=env_str)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def test_context(clients: list[TestClientSpec]) -> Iterator[Path]:
|
||||
"""Create a test context."""
|
||||
@ -26,3 +71,26 @@ def test_context(clients: list[TestClientSpec]) -> Iterator[Path]:
|
||||
create_client_file(client.name, filename, client.secrets)
|
||||
|
||||
yield dirpath
|
||||
|
||||
|
||||
@contextmanager
|
||||
def api_context(clients: list[TestClientSpec]) -> Iterator[TestContext]:
|
||||
"""Create a context for testing the full API."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
dirpath = Path(tmpdir)
|
||||
master_password = generate_password()
|
||||
context = TestContext(dirpath, master_password)
|
||||
keepass = KeepassManager.create_database(
|
||||
str(context.password_database), master_password
|
||||
)
|
||||
seen_secrets: list[str] = []
|
||||
for client in clients:
|
||||
filename = dirpath / f"{client.name}.json"
|
||||
create_client_file(client.name, filename, client.secrets)
|
||||
for secret, value in client.secrets.items():
|
||||
if secret in seen_secrets:
|
||||
continue
|
||||
keepass.add_password(secret, value)
|
||||
seen_secrets.append(secret)
|
||||
|
||||
yield context
|
||||
|
||||
@ -1,41 +1,41 @@
|
||||
"""Interfaces and types."""
|
||||
|
||||
import abc
|
||||
from typing import Self
|
||||
from types import NotImplementedType
|
||||
from typing import Self, overload
|
||||
|
||||
from pydantic import BaseModel, field_serializer
|
||||
from pydantic import BaseModel
|
||||
from pydantic.networks import IPvAnyAddress, IPvAnyNetwork
|
||||
|
||||
|
||||
class BasePasswordReader(abc.ABC):
|
||||
"""Abstract strategy class to read a passwords."""
|
||||
|
||||
@classmethod
|
||||
@abc.abstractmethod
|
||||
def get_password(cls, identifier: str) -> str:
|
||||
def get_password(self, identifier: str, repeated: bool = False) -> str:
|
||||
"""Resolve the password, e.g., via input."""
|
||||
|
||||
|
||||
class PasswordContext:
|
||||
"""Context class for resolving a password."""
|
||||
|
||||
def __init__(self, reader: type[BasePasswordReader]) -> None:
|
||||
def __init__(self, reader: BasePasswordReader) -> None:
|
||||
"""Initialize password context."""
|
||||
self._reader: type[BasePasswordReader] = reader
|
||||
self._reader: BasePasswordReader = reader
|
||||
|
||||
@property
|
||||
def reader(self) -> type[BasePasswordReader]:
|
||||
def reader(self) -> BasePasswordReader:
|
||||
"""Return reader."""
|
||||
return self._reader
|
||||
|
||||
@reader.setter
|
||||
def reader(self, reader: type[BasePasswordReader]) -> None:
|
||||
def reader(self, reader: BasePasswordReader) -> None:
|
||||
"""Set the reader instance."""
|
||||
self._reader = reader
|
||||
|
||||
def get_password(self, identifier: str) -> str:
|
||||
def get_password(self, identifier: str, repeated: bool = False) -> str:
|
||||
"""Get the password."""
|
||||
return self.reader.get_password(identifier)
|
||||
return self.reader.get_password(identifier, repeated)
|
||||
|
||||
|
||||
class BasePasswordManager(abc.ABC):
|
||||
@ -46,7 +46,10 @@ class BasePasswordManager(abc.ABC):
|
||||
@classmethod
|
||||
@abc.abstractmethod
|
||||
def create_database(
|
||||
cls, location: str, reader_context: PasswordContext, overwrite: bool = False
|
||||
cls,
|
||||
location: str,
|
||||
password_context: PasswordContext | str,
|
||||
overwrite: bool = False,
|
||||
) -> Self:
|
||||
"""Create database.
|
||||
|
||||
@ -54,7 +57,7 @@ class BasePasswordManager(abc.ABC):
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def open_database(self, reader: PasswordContext) -> None:
|
||||
def open_database(self, password_context: PasswordContext | str) -> None:
|
||||
"""Open database."""
|
||||
|
||||
@abc.abstractmethod
|
||||
@ -62,7 +65,7 @@ class BasePasswordManager(abc.ABC):
|
||||
"""Close database."""
|
||||
|
||||
@abc.abstractmethod
|
||||
def get_password(self, identifier: str) -> str:
|
||||
def get_password(self, identifier: str) -> str | None:
|
||||
"""Get a password from the manager."""
|
||||
|
||||
@abc.abstractmethod
|
||||
@ -74,6 +77,32 @@ class BasePasswordManager(abc.ABC):
|
||||
Returns the generated password.
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def add_password(self, identifier: str, password: str) -> None:
|
||||
"""Add a pre-defined password."""
|
||||
|
||||
@abc.abstractmethod
|
||||
def get_entries(self) -> list[str]:
|
||||
"""Get names of all entries."""
|
||||
|
||||
def set_manager_options(self, options: dict[str, str]) -> None:
|
||||
"""Set manager options."""
|
||||
pass
|
||||
|
||||
@overload
|
||||
def change_password(self, identifier: str, password: None) -> str: ...
|
||||
|
||||
@overload
|
||||
def change_password(self, identifier: str, password: str) -> None: ...
|
||||
|
||||
@abc.abstractmethod
|
||||
def change_password(self, identifier: str, password: str | None) -> str | None:
|
||||
"""Change password."""
|
||||
|
||||
@abc.abstractmethod
|
||||
def delete_password(self, identifier: str) -> None:
|
||||
"""Delete a password."""
|
||||
|
||||
|
||||
class ClientSpecification(BaseModel):
|
||||
"""Specification of client."""
|
||||
@ -117,3 +146,34 @@ class BaseClientBackend(abc.ABC):
|
||||
@abc.abstractmethod
|
||||
def remove_client(self, name: str, persistent: bool = True) -> None:
|
||||
"""Delete a client."""
|
||||
|
||||
@abc.abstractmethod
|
||||
def get_all(self) -> list[ClientSpecification]:
|
||||
"""Get all clients."""
|
||||
|
||||
@abc.abstractmethod
|
||||
def lookup_by_secret(self, secret_name: str) -> list[ClientSpecification]:
|
||||
"""Lookup by the name of a secret."""
|
||||
|
||||
|
||||
class BaseAPIClient(abc.ABC):
|
||||
"""Base API Client."""
|
||||
|
||||
source: str
|
||||
method: str
|
||||
|
||||
@abc.abstractmethod
|
||||
def password_manager(
|
||||
self, manager_options: dict[str, str] | None = None
|
||||
) -> BasePasswordManager:
|
||||
"""Instantiate password manager."""
|
||||
|
||||
def get_reader(self) -> BasePasswordReader:
|
||||
"""Get the reader."""
|
||||
raise NotImplementedError("Class-based password reading not implemented.")
|
||||
|
||||
def get_context(self, reader: BasePasswordReader | None = None) -> PasswordContext:
|
||||
"""Get password context."""
|
||||
if not reader:
|
||||
reader = self.get_reader()
|
||||
return PasswordContext(reader)
|
||||
|
||||
1
src/sshecret/webapi/__init__.py
Normal file
1
src/sshecret/webapi/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
279
src/sshecret/webapi/api.py
Normal file
279
src/sshecret/webapi/api.py
Normal 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)
|
||||
25
src/sshecret/webapi/api_client.py
Normal file
25
src/sshecret/webapi/api_client.py
Normal 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
|
||||
33
src/sshecret/webapi/frontend.py
Normal file
33
src/sshecret/webapi/frontend.py
Normal 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")
|
||||
71
src/sshecret/webapi/models.py
Normal file
71
src/sshecret/webapi/models.py
Normal 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 = "*"
|
||||
16
src/sshecret/webapi/router.py
Normal file
16
src/sshecret/webapi/router.py
Normal 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
416
tests/test_admin_api.py
Normal 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()
|
||||
@ -96,6 +96,30 @@ class TestFileTableBackend(unittest.TestCase):
|
||||
webserver_file = testdir / "webserver.json"
|
||||
self.assertFalse(webserver_file.exists())
|
||||
|
||||
def test_lookup_by_secret(self) -> None:
|
||||
"""Test lookup of secrets."""
|
||||
dataset = [
|
||||
TestClientSpec("webserver", {"SECRET_TOKEN": "mysecrettoken"}),
|
||||
TestClientSpec("webserver2", {"SECRET_TOKEN": "mysecrettoken"}),
|
||||
TestClientSpec("webserver3", {"SECRET_TOKEN": "mysecrettoken"}),
|
||||
TestClientSpec("dbserver", {"DB_ROOT_PASSWORD": "mysecretpassword"}),
|
||||
TestClientSpec("dbserver2", {"DB_ROOT_PASSWORD": "mysecretpassword"}),
|
||||
TestClientSpec("appserver", {"DB_ROOT_PASSWORD": "mysecretpassword", "SECRET_TOKEN": "mysecrettoken"}),
|
||||
]
|
||||
with test_context(dataset) as testdir:
|
||||
backend = FileTableBackend(testdir)
|
||||
token_mapping = backend.lookup_by_secret("SECRET_TOKEN")
|
||||
self.assertEqual(len(token_mapping), 4)
|
||||
token_mapping_names = [client.name for client in token_mapping]
|
||||
self.assertIn("webserver2", token_mapping_names)
|
||||
self.assertIn("appserver", token_mapping_names)
|
||||
db_mapping = backend.lookup_by_secret("DB_ROOT_PASSWORD")
|
||||
db_mapping_names = [client.name for client in db_mapping]
|
||||
self.assertEqual(len(db_mapping), 3)
|
||||
self.assertNotIn("webserver", db_mapping_names)
|
||||
|
||||
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@ -13,8 +13,7 @@ class Rot13PasswordReader(BasePasswordReader):
|
||||
"""This password reader returns the identifier backwards."""
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def get_password(cls, identifier: str) -> str:
|
||||
def get_password(self, identifier: str, repeated: bool = False) -> str:
|
||||
"""Get password."""
|
||||
return identifier[::-1]
|
||||
|
||||
@ -29,7 +28,7 @@ class TestKeepass(unittest.TestCase):
|
||||
@override
|
||||
def setUp(self) -> None:
|
||||
"""Set up testing."""
|
||||
self.reader_context = PasswordContext(Rot13PasswordReader)
|
||||
self.reader_context = PasswordContext(Rot13PasswordReader())
|
||||
|
||||
def test_db_create(self) -> None:
|
||||
"""Test db creation."""
|
||||
|
||||
@ -17,7 +17,7 @@ class TestInputPasswordReader(unittest.TestCase):
|
||||
"""Test reader."""
|
||||
input_password = "testpassword"
|
||||
with patch("getpass.getpass", return_value=input_password):
|
||||
received_password = InputPasswordReader.get_password("test_password")
|
||||
received_password = InputPasswordReader().get_password("test_password")
|
||||
self.assertEqual(received_password, "testpassword")
|
||||
|
||||
|
||||
@ -32,7 +32,7 @@ class TestEnvPasswordReader(unittest.TestCase):
|
||||
|
||||
def test_env_loader(self) -> None:
|
||||
"""Test environment loading."""
|
||||
password = EnvironmentPasswordReader.get_password("test")
|
||||
password = EnvironmentPasswordReader().get_password("test")
|
||||
self.assertEqual(password, "secretthing")
|
||||
|
||||
|
||||
|
||||
510
uv.lock
generated
510
uv.lock
generated
@ -16,6 +16,19 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyio"
|
||||
version = "4.9.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "idna" },
|
||||
{ name = "sniffio" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/95/7d/4c1bd541d4dffa1b52bd83fb8527089e097a106fc90b467a7313b105f840/anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028", size = 190949 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "argon2-cffi"
|
||||
version = "23.1.0"
|
||||
@ -112,6 +125,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/cf/45fb5261ece3e6b9817d3d82b2f343a505fd58674a92577923bc500bd1aa/bcrypt-4.3.0-cp39-abi3-win_amd64.whl", hash = "sha256:e53e074b120f2877a35cc6c736b8eb161377caae8925c17688bd46ba56daaa5b", size = 152799 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2025.1.31"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1c/ab/c9f1e32b7b1bf505bf26f0ef697775960db7932abeb7b516de930ba2705f/certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651", size = 167577 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/38/fc/bce832fd4fd99766c04d1ee0eead6b0ec6486fb100ae5e74c1d91292b982/certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe", size = 166393 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cffi"
|
||||
version = "1.17.1"
|
||||
@ -146,6 +168,27 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "click-repl"
|
||||
version = "0.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "prompt-toolkit" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cb/a2/57f4ac79838cfae6912f997b4d1a64a858fb0c86d7fcaae6f7b58d267fca/click-repl-0.3.0.tar.gz", hash = "sha256:17849c23dba3d667247dc4defe1757fff98694e90fe37474f3feebb69ced26a9", size = 10449 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/52/40/9d857001228658f0d59e97ebd4c346fe73e138c6de1bce61dc568a57c7f8/click_repl-0.3.0-py3-none-any.whl", hash = "sha256:fb7e06deb8da8de86180a33a9da97ac316751c094c6899382da7feeeeb51b812", size = 10289 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "click-shell"
|
||||
version = "3.0.dev0"
|
||||
source = { git = "https://github.com/clarkperkins/click-shell#12d4544e8475419c81f32b412c9eba04abe3dd73" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
@ -209,6 +252,153 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/33/cf/1f7649b8b9a3543e042d3f348e398a061923ac05b507f3f4d95f11938aa9/cryptography-44.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:5f6f90b72d8ccadb9c6e311c775c8305381db88374c65fa1a68250aa8a9cb3a6", size = 3210957 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dnspython"
|
||||
version = "2.7.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b5/4a/263763cb2ba3816dd94b08ad3a33d5fdae34ecb856678773cc40a3605829/dnspython-2.7.0.tar.gz", hash = "sha256:ce9c432eda0dc91cf618a5cedf1a4e142651196bbcd2c80e89ed5a907e5cfaf1", size = 345197 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/68/1b/e0a87d256e40e8c888847551b20a017a6b98139178505dc7ffb96f04e954/dnspython-2.7.0-py3-none-any.whl", hash = "sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86", size = 313632 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "email-validator"
|
||||
version = "2.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "dnspython" },
|
||||
{ name = "idna" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/48/ce/13508a1ec3f8bb981ae4ca79ea40384becc868bfae97fd1c942bb3a001b1/email_validator-2.2.0.tar.gz", hash = "sha256:cb690f344c617a714f22e66ae771445a1ceb46821152df8e165c5f9a364582b7", size = 48967 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/ee/bf0adb559ad3c786f12bcbc9296b3f5675f529199bef03e2df281fa1fadb/email_validator-2.2.0-py3-none-any.whl", hash = "sha256:561977c2d73ce3611850a06fa56b414621e0c8faa9d66f2611407d87465da631", size = 33521 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastapi"
|
||||
version = "0.115.12"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pydantic" },
|
||||
{ name = "starlette" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f4/55/ae499352d82338331ca1e28c7f4a63bfd09479b16395dce38cf50a39e2c2/fastapi-0.115.12.tar.gz", hash = "sha256:1e2c2a2646905f9e83d32f04a3f86aff4a286669c6c950ca95b5fd68c2602681", size = 295236 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/50/b3/b51f09c2ba432a576fe63758bddc81f78f0c6309d9e5c10d194313bf021e/fastapi-0.115.12-py3-none-any.whl", hash = "sha256:e94613d6c05e27be7ffebdd6ea5f388112e5e430c8f7d6494a9d1d88d43e814d", size = 95164 },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
standard = [
|
||||
{ name = "email-validator" },
|
||||
{ name = "fastapi-cli", extra = ["standard"] },
|
||||
{ name = "httpx" },
|
||||
{ name = "jinja2" },
|
||||
{ name = "python-multipart" },
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastapi-cli"
|
||||
version = "0.0.7"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "rich-toolkit" },
|
||||
{ name = "typer" },
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fe/73/82a5831fbbf8ed75905bacf5b2d9d3dfd6f04d6968b29fe6f72a5ae9ceb1/fastapi_cli-0.0.7.tar.gz", hash = "sha256:02b3b65956f526412515907a0793c9094abd4bfb5457b389f645b0ea6ba3605e", size = 16753 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/e6/5daefc851b514ce2287d8f5d358ae4341089185f78f3217a69d0ce3a390c/fastapi_cli-0.0.7-py3-none-any.whl", hash = "sha256:d549368ff584b2804336c61f192d86ddea080c11255f375959627911944804f4", size = 10705 },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
standard = [
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h11"
|
||||
version = "0.14.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f5/38/3af3d3633a34a3316095b39c8e8fb4853a28a536e55d347bd8d8e9a14b03/h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", size = 100418 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761", size = 58259 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpcore"
|
||||
version = "1.0.7"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "h11" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6a/41/d7d0a89eb493922c37d343b607bc1b5da7f5be7e383740b4753ad8943e90/httpcore-1.0.7.tar.gz", hash = "sha256:8551cb62a169ec7162ac7be8d4817d561f60e08eaa485234898414bb5a8a0b4c", size = 85196 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/87/f5/72347bc88306acb359581ac4d52f23c0ef445b57157adedb9aee0cd689d2/httpcore-1.0.7-py3-none-any.whl", hash = "sha256:a3fff8f43dc260d5bd363d9f9cf1830fa3a458b332856f34282de498ed420edd", size = 78551 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httptools"
|
||||
version = "0.6.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a7/9a/ce5e1f7e131522e6d3426e8e7a490b3a01f39a6696602e1c4f33f9e94277/httptools-0.6.4.tar.gz", hash = "sha256:4e93eee4add6493b59a5c514da98c939b244fce4a0d8879cd3f466562f4b7d5c", size = 240639 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/94/a3/9fe9ad23fd35f7de6b91eeb60848986058bd8b5a5c1e256f5860a160cc3e/httptools-0.6.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ade273d7e767d5fae13fa637f4d53b6e961fb7fd93c7797562663f0171c26660", size = 197214 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/d9/82d5e68bab783b632023f2fa31db20bebb4e89dfc4d2293945fd68484ee4/httptools-0.6.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:856f4bc0478ae143bad54a4242fccb1f3f86a6e1be5548fecfd4102061b3a083", size = 102431 },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/c1/cb499655cbdbfb57b577734fde02f6fa0bbc3fe9fb4d87b742b512908dff/httptools-0.6.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:322d20ea9cdd1fa98bd6a74b77e2ec5b818abdc3d36695ab402a0de8ef2865a3", size = 473121 },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/71/ee32fd358f8a3bb199b03261f10921716990808a675d8160b5383487a317/httptools-0.6.4-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4d87b29bd4486c0093fc64dea80231f7c7f7eb4dc70ae394d70a495ab8436071", size = 473805 },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/0a/0d4df132bfca1507114198b766f1737d57580c9ad1cf93c1ff673e3387be/httptools-0.6.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:342dd6946aa6bda4b8f18c734576106b8a31f2fe31492881a9a160ec84ff4bd5", size = 448858 },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/6a/787004fdef2cabea27bad1073bf6a33f2437b4dbd3b6fb4a9d71172b1c7c/httptools-0.6.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b36913ba52008249223042dca46e69967985fb4051951f94357ea681e1f5dc0", size = 452042 },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/dc/7decab5c404d1d2cdc1bb330b1bf70e83d6af0396fd4fc76fc60c0d522bf/httptools-0.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:28908df1b9bb8187393d5b5db91435ccc9c8e891657f9cbb42a2541b44c82fc8", size = 87682 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpx"
|
||||
version = "0.28.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "certifi" },
|
||||
{ name = "httpcore" },
|
||||
{ name = "idna" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.10"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jinja2"
|
||||
version = "3.1.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markupsafe" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "littletable"
|
||||
version = "3.0.1"
|
||||
@ -243,6 +433,55 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/80/83/8c54533b3576f4391eebea88454738978669a6cad0d8e23266224007939d/lxml-5.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:91fb6a43d72b4f8863d21f347a9163eecbf36e76e2f51068d59cd004c506f332", size = 3814484 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markdown-it-py"
|
||||
version = "3.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "mdurl" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markupsafe"
|
||||
version = "3.0.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274 },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122 },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978 },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101 },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603 },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510 },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486 },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480 },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914 },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796 },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114 },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098 },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208 },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mdurl"
|
||||
version = "0.1.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mypy"
|
||||
version = "1.15.0"
|
||||
@ -271,6 +510,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/e2/5d3f6ada4297caebe1a2add3b126fe800c96f56dbe5d1988a2cbe0b267aa/mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", size = 4695 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "24.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "paramiko"
|
||||
version = "3.5.1"
|
||||
@ -285,6 +533,27 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/15/f8/c7bd0ef12954a81a1d3cea60a13946bd9a49a0036a5927770c461eade7ae/paramiko-3.5.1-py3-none-any.whl", hash = "sha256:43b9a0501fc2b5e70680388d9346cf252cfb7d00b0667c39e80eb43a408b8f61", size = 227298 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.5.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prompt-toolkit"
|
||||
version = "3.0.50"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "wcwidth" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a1/e1/bd15cb8ffdcfeeb2bdc215de3c3cffca11408d829e4b8416dcfe71ba8854/prompt_toolkit-3.0.50.tar.gz", hash = "sha256:544748f3860a2623ca5cd6d2795e7a14f3d0e1c3c9728359013f79877fc89bab", size = 429087 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/ea/d836f008d33151c7a1f62caf3d8dd782e4d15f6a43897f64480c2b8de2ad/prompt_toolkit-3.0.50-py3-none-any.whl", hash = "sha256:9b6427eb19e479d98acff65196a307c555eb567989e6d88ebbb1b509d9779198", size = 387816 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pycparser"
|
||||
version = "2.22"
|
||||
@ -351,6 +620,28 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/51/b2/b2b50d5ecf21acf870190ae5d093602d95f66c9c31f9d5de6062eb329ad1/pydantic_core-2.27.2-cp313-cp313-win_arm64.whl", hash = "sha256:ac4dbfd1691affb8f48c2c13241a2e3b60ff23247cbcf981759c768b6633cf8b", size = 1885186 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-settings"
|
||||
version = "2.8.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pydantic" },
|
||||
{ name = "python-dotenv" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/88/82/c79424d7d8c29b994fb01d277da57b0a9b09cc03c3ff875f9bd8a86b2145/pydantic_settings-2.8.1.tar.gz", hash = "sha256:d5c663dfbe9db9d5e1c646b2e161da12f0d734d422ee56f567d0ea2cee4e8585", size = 83550 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/53/a64f03044927dc47aafe029c42a5b7aabc38dfb813475e0e1bf71c4a59d0/pydantic_settings-2.8.1-py3-none-any.whl", hash = "sha256:81942d5ac3d905f7f3ee1a70df5dfb62d5569c12f51a5a647defc1c3d9ee2e9c", size = 30839 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.19.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pykeepass"
|
||||
version = "4.1.1.post1"
|
||||
@ -396,6 +687,21 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/c0/c33c8792c3e50193ef55adb95c1c3c2786fe281123291c2dbf0eaab95a6f/pyotp-2.9.0-py3-none-any.whl", hash = "sha256:81c2e5865b8ac55e825b0358e496e1d9387c811e85bb40e71a3b29b288963612", size = 13376 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "8.3.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "iniconfig" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pluggy" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dotenv"
|
||||
version = "1.0.1"
|
||||
@ -414,6 +720,77 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/08/20/0f2523b9e50a8052bc6a8b732dfc8568abbdc42010aef03a2d750bdab3b2/python_json_logger-3.3.0-py3-none-any.whl", hash = "sha256:dd980fae8cffb24c13caf6e158d3d61c0d6d22342f932cb6e9deedab3d35eec7", size = 15163 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-multipart"
|
||||
version = "0.0.20"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyyaml"
|
||||
version = "6.0.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309 },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679 },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361 },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523 },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660 },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597 },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527 },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rich"
|
||||
version = "13.9.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markdown-it-py" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ab/3a/0316b28d0761c6734d6bc14e770d85506c986c85ffb239e688eeaab2c2bc/rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098", size = 223149 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rich-toolkit"
|
||||
version = "0.13.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "rich" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5b/8a/71cfbf6bf6257ea785d1f030c22468f763eea1b3e5417620f2ba9abd6dca/rich_toolkit-0.13.2.tar.gz", hash = "sha256:fea92557530de7c28f121cbed572ad93d9e0ddc60c3ca643f1b831f2f56b95d3", size = 72288 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/1b/1c2f43af46456050b27810a7a013af8a7e12bc545a0cdc00eb0df55eb769/rich_toolkit-0.13.2-py3-none-any.whl", hash = "sha256:f3f6c583e5283298a2f7dbd3c65aca18b7f818ad96174113ab5bec0b0e35ed61", size = 13566 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shellingham"
|
||||
version = "1.5.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sniffio"
|
||||
version = "1.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sshecret"
|
||||
version = "0.1.0"
|
||||
@ -421,11 +798,18 @@ source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "asyncssh" },
|
||||
{ name = "click" },
|
||||
{ name = "click-repl" },
|
||||
{ name = "click-shell" },
|
||||
{ name = "cryptography" },
|
||||
{ name = "fastapi", extra = ["standard"] },
|
||||
{ name = "httpx" },
|
||||
{ name = "jinja2" },
|
||||
{ name = "littletable" },
|
||||
{ name = "paramiko" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pydantic-settings" },
|
||||
{ name = "pykeepass" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "python-json-logger" },
|
||||
]
|
||||
|
||||
@ -433,6 +817,7 @@ dependencies = [
|
||||
dev = [
|
||||
{ name = "construct-typing" },
|
||||
{ name = "mypy" },
|
||||
{ name = "pytest" },
|
||||
{ name = "python-dotenv" },
|
||||
]
|
||||
|
||||
@ -440,11 +825,18 @@ dev = [
|
||||
requires-dist = [
|
||||
{ name = "asyncssh", specifier = ">=2.20.0" },
|
||||
{ name = "click", specifier = ">=8.1.8" },
|
||||
{ name = "click-repl", specifier = ">=0.3.0" },
|
||||
{ name = "click-shell", git = "https://github.com/clarkperkins/click-shell" },
|
||||
{ name = "cryptography", specifier = ">=44.0.2" },
|
||||
{ name = "fastapi", extras = ["standard"], specifier = ">=0.115.12" },
|
||||
{ name = "httpx", specifier = ">=0.28.1" },
|
||||
{ name = "jinja2", specifier = ">=3.1.6" },
|
||||
{ name = "littletable", specifier = ">=3.0.1" },
|
||||
{ name = "paramiko", specifier = ">=3.5.1" },
|
||||
{ name = "pydantic", specifier = ">=2.10.6" },
|
||||
{ name = "pydantic-settings", specifier = ">=2.8.1" },
|
||||
{ name = "pykeepass", specifier = ">=4.1.1.post1" },
|
||||
{ name = "python-dotenv", specifier = ">=1.0.1" },
|
||||
{ name = "python-json-logger", specifier = ">=3.3.0" },
|
||||
]
|
||||
|
||||
@ -452,6 +844,7 @@ requires-dist = [
|
||||
dev = [
|
||||
{ name = "construct-typing", specifier = ">=0.6.2" },
|
||||
{ name = "mypy", specifier = ">=1.15.0" },
|
||||
{ name = "pytest", specifier = ">=8.3.5" },
|
||||
{ name = "python-dotenv", specifier = ">=1.0.1" },
|
||||
]
|
||||
|
||||
@ -476,6 +869,33 @@ requires-dist = [
|
||||
{ name = "sshecret", editable = "." },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "starlette"
|
||||
version = "0.46.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/04/1b/52b27f2e13ceedc79a908e29eac426a63465a1a01248e5f24aa36a62aeb3/starlette-0.46.1.tar.gz", hash = "sha256:3c88d58ee4bd1bb807c0d1acb381838afc7752f9ddaec81bbe4383611d833230", size = 2580102 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/4b/528ccf7a982216885a1ff4908e886b8fb5f19862d1962f56a3fce2435a70/starlette-0.46.1-py3-none-any.whl", hash = "sha256:77c74ed9d2720138b25875133f3a2dae6d854af2ec37dceb56aef370c1d8a227", size = 71995 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typer"
|
||||
version = "0.15.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "rich" },
|
||||
{ name = "shellingham" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8b/6f/3991f0f1c7fcb2df31aef28e0594d8d54b05393a0e4e34c65e475c2a5d41/typer-0.15.2.tar.gz", hash = "sha256:ab2fab47533a813c49fe1f16b1a370fd5819099c00b119e0633df65f22144ba5", size = 100711 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/fc/5b29fea8cee020515ca82cc68e3b8e1e34bb19a3535ad854cac9257b414c/typer-0.15.2-py3-none-any.whl", hash = "sha256:46a499c6107d645a9c13f7ee46c5d5096cae6f5fc57dd11eccbbb9ae3e44ddfc", size = 45061 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.12.2"
|
||||
@ -484,3 +904,93 @@ sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec3
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uvicorn"
|
||||
version = "0.34.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "h11" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4b/4d/938bd85e5bf2edeec766267a5015ad969730bb91e31b44021dfe8b22df6c/uvicorn-0.34.0.tar.gz", hash = "sha256:404051050cd7e905de2c9a7e61790943440b3416f49cb409f965d9dcd0fa73e9", size = 76568 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/61/14/33a3a1352cfa71812a3a21e8c9bfb83f60b0011f5e36f2b1399d51928209/uvicorn-0.34.0-py3-none-any.whl", hash = "sha256:023dc038422502fa28a09c7a30bf2b6991512da7dcdb8fd35fe57cfc154126f4", size = 62315 },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
standard = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "httptools" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" },
|
||||
{ name = "watchfiles" },
|
||||
{ name = "websockets" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uvloop"
|
||||
version = "0.21.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/af/c0/854216d09d33c543f12a44b393c402e89a920b1a0a7dc634c42de91b9cf6/uvloop-0.21.0.tar.gz", hash = "sha256:3bf12b0fda68447806a7ad847bfa591613177275d35b6724b1ee573faa3704e3", size = 2492741 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/8d/2cbef610ca21539f0f36e2b34da49302029e7c9f09acef0b1c3b5839412b/uvloop-0.21.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bfd55dfcc2a512316e65f16e503e9e450cab148ef11df4e4e679b5e8253a5281", size = 1468123 },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/0d/b0038d5a469f94ed8f2b2fce2434a18396d8fbfb5da85a0a9781ebbdec14/uvloop-0.21.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:787ae31ad8a2856fc4e7c095341cccc7209bd657d0e71ad0dc2ea83c4a6fa8af", size = 819325 },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/94/0a687f39e78c4c1e02e3272c6b2ccdb4e0085fda3b8352fecd0410ccf915/uvloop-0.21.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ee4d4ef48036ff6e5cfffb09dd192c7a5027153948d85b8da7ff705065bacc6", size = 4582806 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/19/f5b78616566ea68edd42aacaf645adbf71fbd83fc52281fba555dc27e3f1/uvloop-0.21.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3df876acd7ec037a3d005b3ab85a7e4110422e4d9c1571d4fc89b0fc41b6816", size = 4701068 },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/57/66f061ee118f413cd22a656de622925097170b9380b30091b78ea0c6ea75/uvloop-0.21.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd53ecc9a0f3d87ab847503c2e1552b690362e005ab54e8a48ba97da3924c0dc", size = 4454428 },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/9a/0962b05b308494e3202d3f794a6e85abe471fe3cafdbcf95c2e8c713aabd/uvloop-0.21.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a5c39f217ab3c663dc699c04cbd50c13813e31d917642d459fdcec07555cc553", size = 4660018 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "watchfiles"
|
||||
version = "1.0.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f5/26/c705fc77d0a9ecdb9b66f1e2976d95b81df3cae518967431e7dbf9b5e219/watchfiles-1.0.4.tar.gz", hash = "sha256:6ba473efd11062d73e4f00c2b730255f9c1bdd73cd5f9fe5b5da8dbd4a717205", size = 94625 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/08/98/f03efabec64b5b1fa58c0daab25c68ef815b0f320e54adcacd0d6847c339/watchfiles-1.0.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:8012bd820c380c3d3db8435e8cf7592260257b378b649154a7948a663b5f84e9", size = 390954 },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/09/4dd49ba0a32a45813debe5fb3897955541351ee8142f586303b271a02b40/watchfiles-1.0.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aa216f87594f951c17511efe5912808dfcc4befa464ab17c98d387830ce07b60", size = 381133 },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/59/5aa6fc93553cd8d8ee75c6247763d77c02631aed21551a97d94998bf1dae/watchfiles-1.0.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:62c9953cf85529c05b24705639ffa390f78c26449e15ec34d5339e8108c7c407", size = 449516 },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/aa/df4b6fe14b6317290b91335b23c96b488d365d65549587434817e06895ea/watchfiles-1.0.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7cf684aa9bba4cd95ecb62c822a56de54e3ae0598c1a7f2065d51e24637a3c5d", size = 454820 },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/71/185f8672f1094ce48af33252c73e39b48be93b761273872d9312087245f6/watchfiles-1.0.4-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f44a39aee3cbb9b825285ff979ab887a25c5d336e5ec3574f1506a4671556a8d", size = 481550 },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/d7/50ebba2c426ef1a5cb17f02158222911a2e005d401caf5d911bfca58f4c4/watchfiles-1.0.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a38320582736922be8c865d46520c043bff350956dfc9fbaee3b2df4e1740a4b", size = 518647 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/7a/4c009342e393c545d68987e8010b937f72f47937731225b2b29b7231428f/watchfiles-1.0.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39f4914548b818540ef21fd22447a63e7be6e24b43a70f7642d21f1e73371590", size = 497547 },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/7c/1cf50b35412d5c72d63b2bf9a4fffee2e1549a245924960dd087eb6a6de4/watchfiles-1.0.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f12969a3765909cf5dc1e50b2436eb2c0e676a3c75773ab8cc3aa6175c16e902", size = 452179 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/a9/3db1410e1c1413735a9a472380e4f431ad9a9e81711cda2aaf02b7f62693/watchfiles-1.0.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:0986902677a1a5e6212d0c49b319aad9cc48da4bd967f86a11bde96ad9676ca1", size = 614125 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/e1/0025d365cf6248c4d1ee4c3d2e3d373bdd3f6aff78ba4298f97b4fad2740/watchfiles-1.0.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:308ac265c56f936636e3b0e3f59e059a40003c655228c131e1ad439957592303", size = 611911 },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/55/035838277d8c98fc8c917ac9beeb0cd6c59d675dc2421df5f9fcf44a0070/watchfiles-1.0.4-cp313-cp313-win32.whl", hash = "sha256:aee397456a29b492c20fda2d8961e1ffb266223625346ace14e4b6d861ba9c80", size = 271152 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/e5/96b8e55271685ddbadc50ce8bc53aa2dff278fb7ac4c2e473df890def2dc/watchfiles-1.0.4-cp313-cp313-win_amd64.whl", hash = "sha256:d6097538b0ae5c1b88c3b55afa245a66793a8fec7ada6755322e465fb1a0e8cc", size = 285216 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wcwidth"
|
||||
version = "0.2.13"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6c/63/53559446a878410fc5a5974feb13d31d78d752eb18aeba59c7fef1af7598/wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5", size = 101301 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/84/fd2ba7aafacbad3c4201d395674fc6348826569da3c0937e75505ead3528/wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859", size = 34166 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "websockets"
|
||||
version = "15.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440 },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329 },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111 },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496 },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217 },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195 },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393 },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837 },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743 },
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user