Complete admin package restructuring

This commit is contained in:
2025-05-10 08:28:15 +02:00
parent 4f970a3f71
commit 0a427b6a91
80 changed files with 1282 additions and 843 deletions

View File

@ -0,0 +1,112 @@
"""FastAPI app."""
# pyright: reportUnusedFunction=false
#
import logging
import os
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI, Request, Response, status
from fastapi.encoders import jsonable_encoder
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from sqlmodel import Session, select
from sshecret_admin import api, frontend
from sshecret_admin.auth.models import PasswordDB, init_db
from sshecret_admin.core.db import setup_database
from sshecret_admin.frontend.exceptions import RedirectException
from sshecret_admin.services.master_password import setup_master_password
from .dependencies import BaseDependencies
from .settings import AdminServerSettings
LOG = logging.getLogger(__name__)
# dir_path = os.path.dirname(os.path.realpath(__file__))
def setup_frontend(
app: FastAPI, dependencies: BaseDependencies
) -> None:
"""Setup frontend."""
script_path = Path(os.path.dirname(os.path.realpath(__file__)))
static_path = script_path.parent / "static"
app.mount("/static", StaticFiles(directory=static_path), name="static")
app.include_router(frontend.create_frontend_router(dependencies))
def create_admin_app(
settings: AdminServerSettings, with_frontend: bool = True
) -> FastAPI:
"""Create admin app."""
engine, get_db_session = setup_database(settings.admin_db)
def setup_password_manager() -> None:
"""Setup password manager."""
encr_master_password = setup_master_password(
settings=settings, regenerate=False
)
with Session(engine) as session:
existing_password = session.exec(select(PasswordDB).where(PasswordDB.id == 1)).first()
if not encr_master_password:
if existing_password:
LOG.info("Master password already defined.")
return
# Looks like we have to regenerate it
LOG.warning("Master password was set, but not saved to the database. Regenerating it.")
encr_master_password = setup_master_password(settings=settings, regenerate=True)
assert encr_master_password is not None
with Session(engine) as session:
pwdb = PasswordDB(id=1, encrypted_password=encr_master_password)
session.add(pwdb)
session.commit()
@asynccontextmanager
async def lifespan(_app: FastAPI):
"""Create database before starting the server."""
init_db(engine)
setup_password_manager()
yield
app = FastAPI(lifespan=lifespan)
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(
request: Request, exc: RequestValidationError
):
return JSONResponse(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
content=jsonable_encoder({"detail": exc.errors(), "body": exc.body}),
)
@app.exception_handler(RedirectException)
async def redirect_handler(request: Request, exc: RedirectException) -> Response:
"""Handle redirect exceptions."""
if "hx-request" in request.headers:
response = Response()
response.headers["HX-Redirect"] = str(exc.to)
return response
return RedirectResponse(url=str(exc.to))
@app.get("/health")
async def get_health() -> JSONResponse:
"""Provide simple health check."""
return JSONResponse(
status_code=status.HTTP_200_OK, content=jsonable_encoder({"status": "LIVE"})
)
dependencies = BaseDependencies(settings, get_db_session)
app.include_router(api.create_api_router(dependencies))
if with_frontend:
setup_frontend(app, dependencies)
return app

View File

@ -0,0 +1,152 @@
"""Sshecret admin CLI helper."""
import asyncio
import code
from collections.abc import Awaitable
import logging
from typing import Any, cast
import bcrypt
import click
from sshecret_admin.services.admin_backend import AdminBackend
import uvicorn
from pydantic import ValidationError
from sqlmodel import Session, create_engine, select
from sshecret_admin.auth.models import init_db, User, PasswordDB
from sshecret_admin.core.settings import AdminServerSettings
handler = logging.StreamHandler()
formatter = logging.Formatter(
"%(asctime)s [%(processName)s: %(process)d] [%(threadName)s: %(thread)d] [%(levelname)s] %(name)s: %(message)s"
)
handler.setFormatter(formatter)
LOG = logging.getLogger()
LOG.addHandler(handler)
LOG.setLevel(logging.INFO)
def hash_password(password: str) -> str:
"""Hash password."""
salt = bcrypt.gensalt()
hashed_password = bcrypt.hashpw(password.encode(), salt)
return hashed_password.decode()
def create_user(session: Session, username: str, password: str) -> None:
"""Create a user."""
hashed_password = hash_password(password)
user = User(username=username, hashed_password=hashed_password)
session.add(user)
session.commit()
@click.group()
@click.option("--debug", is_flag=True)
@click.pass_context
def cli(ctx: click.Context, debug: bool) -> None:
"""Sshecret Admin."""
if debug:
LOG.setLevel(logging.DEBUG)
try:
settings = AdminServerSettings() # pyright: ignore[reportCallIssue]
except ValidationError as e:
raise click.ClickException(
"Error: One or more required environment options are missing."
) from e
ctx.obj = settings
@cli.command("adduser")
@click.argument("username")
@click.password_option()
@click.pass_context
def cli_create_user(ctx: click.Context, username: str, password: str) -> None:
"""Create user."""
settings = cast(AdminServerSettings, ctx.obj)
engine = create_engine(settings.admin_db)
init_db(engine)
with Session(engine) as session:
create_user(session, username, password)
click.echo("User created.")
@cli.command("passwd")
@click.argument("username")
@click.password_option()
@click.pass_context
def cli_change_user_passwd(ctx: click.Context, username: str, password: str) -> None:
"""Change password on user."""
settings = cast(AdminServerSettings, ctx.obj)
engine = create_engine(settings.admin_db)
init_db(engine)
with Session(engine) as session:
user = session.exec(select(User).where(User.username == username)).first()
if not user:
raise click.ClickException(f"Error: No such user, {username}.")
new_passwd_hash = hash_password(password)
user.hashed_password = new_passwd_hash
session.add(user)
session.commit()
click.echo("Password updated.")
@cli.command("deluser")
@click.argument("username")
@click.confirmation_option()
@click.pass_context
def cli_delete_user(ctx: click.Context, username: str) -> None:
"""Remove a user."""
settings = cast(AdminServerSettings, ctx.obj)
engine = create_engine(settings.admin_db)
init_db(engine)
with Session(engine) as session:
user = session.exec(select(User).where(User.username == username)).first()
if not user:
raise click.ClickException(f"Error: No such user, {username}.")
session.delete(user)
session.commit()
click.echo("User deleted.")
@cli.command("run")
@click.option("--host", default="127.0.0.1")
@click.option("--port", default=8822, type=click.INT)
@click.option("--dev", is_flag=True)
@click.option("--workers", type=click.INT)
def cli_run(host: str, port: int, dev: bool, workers: int | None) -> None:
"""Run the server."""
uvicorn.run(
"sshecret_admin.core.main:app", host=host, port=port, reload=dev, workers=workers
)
@cli.command("repl")
@click.pass_context
def cli_repl(ctx: click.Context) -> None:
"""Run an interactive console."""
settings = cast(AdminServerSettings, ctx.obj)
engine = create_engine(settings.admin_db)
init_db(engine)
with Session(engine) as session:
password_db = session.exec(select(PasswordDB).where(PasswordDB.id == 1)).first()
if not password_db:
raise click.ClickException(
"Error: Password database has not yet been setup. Start the server to finish setup."
)
def run(func: Awaitable[Any]) -> Any:
"""Run an async function."""
loop = asyncio.get_event_loop()
return loop.run_until_complete(func)
admin = AdminBackend(settings, password_db.encrypted_password)
locals = {
"run": run,
"admin": admin,
}
banner = "Sshecret-admin REPL\nAdmin backend API bound to 'admin'. Run async functions with run()"
console = code.InteractiveConsole(locals=locals, local_exit=True)
console.interact(banner=banner, exitmsg="Bye!")

View File

@ -0,0 +1,22 @@
"""Database setup."""
from collections.abc import Generator, Callable
from sqlmodel import Session, create_engine
import sqlalchemy as sa
from sqlalchemy.engine import URL
def setup_database(
db_url: URL | str,
) -> tuple[sa.Engine, Callable[[], Generator[Session, None, None]]]:
"""Setup database."""
engine = create_engine(db_url, echo=True)
def get_db_session() -> Generator[Session, None, None]:
"""Get DB Session."""
with Session(engine) as session:
yield session
return engine, get_db_session

View File

@ -0,0 +1,37 @@
"""Common type definitions."""
from collections.abc import AsyncGenerator, Callable, Generator
from dataclasses import dataclass
from typing import Self
from sqlmodel import Session
from sshecret_admin.services import AdminBackend
from sshecret_admin.core.settings import AdminServerSettings
DBSessionDep = Callable[[], Generator[Session, None, None]]
AdminDep = Callable[[Session], AsyncGenerator[AdminBackend, None]]
@dataclass
class BaseDependencies:
"""Base level dependencies."""
settings: AdminServerSettings
get_db_session: DBSessionDep
@dataclass
class AdminDependencies(BaseDependencies):
"""Dependency class with admin."""
get_admin_backend: AdminDep
@classmethod
def create(cls, deps: BaseDependencies, get_admin_backend: AdminDep) -> Self:
"""Create from base dependencies."""
return cls(
settings=deps.settings,
get_db_session=deps.get_db_session,
get_admin_backend=get_admin_backend,
)

View File

@ -0,0 +1,17 @@
"""Main server app."""
import sys
import click
from pydantic import ValidationError
from .app import create_admin_app
from .settings import AdminServerSettings
try:
app = create_admin_app(AdminServerSettings()) # pyright: ignore[reportCallIssue]
except ValidationError as e:
error = click.style("Error", bold=True, fg="red")
click.echo(f"{error}: One or more required environment variables are missing.")
for error in e.errors():
click.echo(f" - {error['loc'][0]}")
sys.exit(1)

View File

@ -0,0 +1,33 @@
"""SSH Server settings."""
from pydantic import AnyHttpUrl, Field
from pydantic_settings import BaseSettings, SettingsConfigDict
from sqlalchemy import URL
DEFAULT_LISTEN_PORT = 8822
DEFAULT_DATABASE = "ssh_admin.db"
class AdminServerSettings(BaseSettings):
"""Server Settings."""
model_config = SettingsConfigDict(
env_file=".admin.env", env_prefix="sshecret_admin_", secrets_dir="/var/run"
)
backend_url: AnyHttpUrl = Field(alias="sshecret_backend_url")
backend_token: str
listen_address: str = Field(default="")
secret_key: str
port: int = DEFAULT_LISTEN_PORT
database: str = Field(default=DEFAULT_DATABASE)
#admin_db: str = Field(default=DEFAULT_DATABASE)
debug: bool = False
@property
def admin_db(self) -> URL:
"""Construct database url."""
return URL.create(drivername="sqlite", database=self.database)