Complete admin package restructuring
This commit is contained in:
112
packages/sshecret-admin/src/sshecret_admin/core/app.py
Normal file
112
packages/sshecret-admin/src/sshecret_admin/core/app.py
Normal 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
|
||||
152
packages/sshecret-admin/src/sshecret_admin/core/cli.py
Normal file
152
packages/sshecret-admin/src/sshecret_admin/core/cli.py
Normal 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!")
|
||||
22
packages/sshecret-admin/src/sshecret_admin/core/db.py
Normal file
22
packages/sshecret-admin/src/sshecret_admin/core/db.py
Normal 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
|
||||
@ -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,
|
||||
)
|
||||
17
packages/sshecret-admin/src/sshecret_admin/core/main.py
Normal file
17
packages/sshecret-admin/src/sshecret_admin/core/main.py
Normal 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)
|
||||
33
packages/sshecret-admin/src/sshecret_admin/core/settings.py
Normal file
33
packages/sshecret-admin/src/sshecret_admin/core/settings.py
Normal 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)
|
||||
Reference in New Issue
Block a user