Compare commits

..

22 Commits

Author SHA1 Message Date
e10b51c8e3 Attempt to automate registry images
All checks were successful
CI / Lint & Format (push) Successful in 4s
CI / Tests (Pytest + Alembic + Postgres) (push) Successful in 9m25s
CI / Docker Build (push) Successful in 10m28s
2025-09-30 13:27:35 +01:00
48c5731a8a Updated CI.yml
All checks were successful
CI / Lint & Format (push) Successful in 4s
CI / Tests (Pytest + Alembic + Postgres) (push) Successful in 9m25s
CI / Docker Build (push) Successful in 1m12s
2025-09-30 13:15:40 +01:00
5e79768394 Updated CI.yml
Some checks failed
CI / Lint & Format (push) Successful in 3s
CI / Tests (Pytest + Alembic + Postgres) (push) Successful in 9m31s
CI / Docker Build (push) Failing after 3m14s
2025-09-30 12:48:39 +01:00
ef947d9888 Migrated CI health test to new JSON. Refactored NGINX config
Some checks failed
CI / Lint & Format (push) Successful in 3s
CI / Tests (Pytest + Alembic + Postgres) (push) Failing after 4m44s
CI / Docker Build (push) Has been skipped
2025-09-30 12:39:49 +01:00
7054ba7547 Fixed NGINX Config for new z endpoints
Some checks failed
CI / Lint & Format (push) Successful in 4s
CI / Tests (Pytest + Alembic + Postgres) (push) Failing after 4m45s
CI / Docker Build (push) Has been skipped
2025-09-30 12:24:51 +01:00
3a49d82d04 Added healthcheck, Added readyz, livez endpoints
Some checks failed
CI / Lint & Format (push) Successful in 6s
CI / Docker Build (push) Has been cancelled
CI / Tests (Pytest + Alembic + Postgres) (push) Has been cancelled
2025-09-30 12:20:11 +01:00
c93f0ccda6 Standardised Bases across models
Some checks failed
CI / Lint & Format (push) Successful in 4s
CI / Tests (Pytest + Alembic + Postgres) (push) Failing after 4m45s
CI / Docker Build (push) Has been skipped
2025-09-23 19:38:54 +01:00
3eaed88074 Moved models to shared base 2025-09-23 19:27:08 +01:00
7d811fede0 Added SETUP.md
Some checks failed
CI / Lint & Format (push) Successful in 4s
CI / Tests (Pytest + Alembic + Postgres) (push) Failing after 4m46s
CI / Docker Build (push) Has been skipped
2025-09-23 19:08:05 +01:00
f8776911dc Appended .env.example
Some checks failed
CI / Lint & Format (push) Successful in 4s
CI / Tests (Pytest + Alembic + Postgres) (push) Failing after 4m55s
CI / Docker Build (push) Has been skipped
2025-09-23 18:57:26 +01:00
7a672fc079 Merge branch 'main' of https://git.ntbx.io/boris/keywarden
Some checks failed
CI / Lint & Format (push) Successful in 4s
CI / Docker Build (push) Has been cancelled
CI / Tests (Pytest + Alembic + Postgres) (push) Has been cancelled
2025-09-23 18:55:10 +01:00
5aacf17ef2 Exposed docs endpoint, added default env 2025-09-23 18:55:07 +01:00
e29ec19122 Update README.md
Some checks failed
CI / Lint & Format (push) Successful in 4s
CI / Docker Build (push) Has been cancelled
CI / Tests (Pytest + Alembic + Postgres) (push) Has been cancelled
2025-09-23 17:53:07 +00:00
e3a1f35c67 Delete duplicate ci.yml
Some checks failed
CI / Lint & Format (push) Successful in 4s
CI / Docker Build (push) Has been cancelled
CI / Tests (Pytest + Alembic + Postgres) (push) Has been cancelled
2025-09-23 17:52:11 +00:00
6f95816fb4 Changed healthcheck endpoint output
Some checks failed
CI / Lint & Format (push) Successful in 1m8s
CI / Docker Build (push) Has been cancelled
CI / Tests (Pytest + Alembic + Postgres) (push) Has been cancelled
2025-09-22 20:36:53 +01:00
f04b04339f Linted .py files
Some checks failed
CI / Lint & Format (push) Has been cancelled
CI / Tests (Pytest + Alembic + Postgres) (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
2025-09-22 20:34:29 +01:00
e713f4cc6c Update ci.yml
Some checks failed
CI / Lint & Format (push) Has been cancelled
CI / Tests (Pytest + Alembic + Postgres) (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
2025-09-22 20:26:24 +01:00
262de452e4 Added tests/ for CI.
Some checks failed
CI / Lint & Format (push) Has been cancelled
CI / Tests (Pytest + Alembic + Postgres) (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
2025-09-22 20:23:26 +01:00
8c53c2ffaa Adjusted environment parsing in config.py
Some checks failed
CI / Lint & Format (push) Has been cancelled
CI / Tests (Pytest + Alembic + Postgres) (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
2025-09-22 20:15:46 +01:00
84e03b2ae9 Fixed alembic errors in CI (maybe)
Some checks failed
CI / Lint & Format (push) Has been cancelled
CI / Tests (Pytest + Alembic + Postgres) (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
2025-09-22 20:08:12 +01:00
cf1f01efc0 Fixed ci.yml
Some checks failed
CI / Lint & Format (push) Has been cancelled
CI / Tests (Pytest + Alembic + Postgres) (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
2025-09-22 19:57:52 +01:00
bfc6cbf7d9 Added alembic configuration 2025-09-22 19:55:39 +01:00
26 changed files with 480 additions and 210 deletions

24
.env.example Normal file
View File

@@ -0,0 +1,24 @@
DOCKERDIR=/opt/compose/keywarden
## Local Auth
KEYWARDEN_SECRET_KEY=<!GENERATE SECRET HERE!>
KEYWARDEN_ALLOW_LOCAL_LOGIN=true
KEYWARDEN_ACCESS_TOKEN_EXPIRE_MINUTES=60
## Optional OIDC
# KEYWARDEN_OIDC_ENABLED=true
# KEYWARDEN_OIDC_ISSUER=https://auth.example.com/application/o/<slug>
# KEYWARDEN_OIDC_CLIENT_ID=keywarden
# KEYWARDEN_OIDC_AUDIENCE=keywarden-api
# KEYWARDEN_OIDC_JWKS_URL=https://auth.example.com/application/o/<slug>/jwks
## Policy toggles
# KEYWARDEN_REQUIRE_SSO=false # if true, local login is disabled
# KEYWARDEN_AUTO_PROVISION_OIDC=true # JIT user creation
## Postgres
KEYWARDEN_POSTGRES_USER="postgres"
KEYWARDEN_POSTGRES_PASSWORD="postgres"
KEYWARDEN_POSTGRES_HOST="keywarden-db"
KEYWARDEN_POSTGRES_PORT=5432
KEYWARDEN_POSTGRES_DB="keywarden"

View File

@@ -11,9 +11,13 @@ permissions:
env: env:
PYTHON_VERSION: "3.11" PYTHON_VERSION: "3.11"
# Used by tests / alembic; matches docker-compose-style DSN IMAGE_NAME: keywarden-api
TEST_POSTGRES_DSN: postgresql+asyncpg://postgres:postgres@localhost:5432/keywarden # Used by tests / alembic; matches docker compose environment
KEYWARDEN_POSTGRES_USER: postgres
KEYWARDEN_POSTGRES_PASSWORD: postgres
KEYWARDEN_POSTGRES_HOST: localhost
KEYWARDEN_POSTGRES_PORT: 5432
KEYWARDEN_POSTGRES_DB: keywarden
jobs: jobs:
lint: lint:
name: Lint & Format name: Lint & Format
@@ -43,7 +47,7 @@ jobs:
needs: lint needs: lint
services: services:
postgres: postgres:
image: postgres:16 image: postgres:17-alpine
env: env:
POSTGRES_DB: keywarden POSTGRES_DB: keywarden
POSTGRES_USER: postgres POSTGRES_USER: postgres
@@ -68,28 +72,71 @@ jobs:
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
path: ~/.cache/pip path: ~/.cache/pip
key: pip-${{ runner.os }}-${{ env.PYTHON_VERSION }}-${{ hashFiles('**/requirements.txt') }} key: pip-${{ runner.os }}-${{ env.PYTHON_VERSION }}-${{ hashFiles('**/requirements*.txt', 'pyproject.toml') }}
restore-keys: | restore-keys: |
pip-${{ runner.os }}-${{ env.PYTHON_VERSION }}- pip-${{ runner.os }}-${{ env.PYTHON_VERSION }}-
- name: Install dependencies
- name: Install deps
run: | run: |
python -m pip install --upgrade pip python -m pip install --upgrade pip
pip install -r requirements.txt pip install -r requirements.txt
- name: Create .env for tests # optional: extras/dev
if [ -f pyproject.toml ]; then pip install -e .[dev] || pip install -e . ; fi
- name: Set PYTHONPATH
run: echo "PYTHONPATH=${GITHUB_WORKSPACE}" >> $GITHUB_ENV
# Gitea ACT Runner needs this for some reason.
- name: Select Postgres host for runner
run: | run: |
printf "KEYWARDEN_POSTGRES_DSN=%s\nKEYWARDEN_SECRET_KEY=%s\nKEYWARDEN_ACCESS_TOKEN_EXPIRE_MINUTES=60\n" \ if [ "${ACT:-}" = "true" ]; then
"${{ env.TEST_POSTGRES_DSN }}" "testsecret" > .env echo "KEYWARDEN_POSTGRES_HOST=postgres" >> "$GITHUB_ENV" # Gitea (act_runner)
echo "Wrote .env with DSN=${{ env.TEST_POSTGRES_DSN }}" else
echo "KEYWARDEN_POSTGRES_HOST=127.0.0.1" >> "$GITHUB_ENV" # GitHub Actions
fi
- name: Echo DB target
run: echo "DB → ${KEYWARDEN_POSTGRES_HOST:-unset}:${{ env.KEYWARDEN_POSTGRES_PORT }}"
# Explicit wait (removes race on act_runner)
- name: Wait for Postgres
run: |
for i in {1..60}; do
python - <<'PY'
import os, socket, sys
h=os.environ.get("KEYWARDEN_POSTGRES_HOST","127.0.0.1"); p=int(os.environ.get("KEYWARDEN_POSTGRES_PORT","5432"))
s=socket.socket(); s.settimeout(1)
try:
s.connect((h,p)); print("Postgres is up:", h,p); sys.exit(0)
except Exception as e:
print("waiting:", e); sys.exit(1)
finally:
s.close()
PY
if [ $? -eq 0 ]; then break; fi
sleep 2
done
- name: Run Alembic migrations - name: Run Alembic migrations
env: env:
KEYWARDEN_POSTGRES_DSN: ${{ env.TEST_POSTGRES_DSN }} KEYWARDEN_POSTGRES_USER: ${{ env.KEYWARDEN_POSTGRES_USER }}
run: | KEYWARDEN_POSTGRES_PASSWORD: ${{ env.KEYWARDEN_POSTGRES_PASSWORD }}
alembic upgrade head KEYWARDEN_POSTGRES_HOST: ${{ env.KEYWARDEN_POSTGRES_HOST }}
KEYWARDEN_POSTGRES_PORT: ${{ env.KEYWARDEN_POSTGRES_PORT }}
KEYWARDEN_POSTGRES_DB: ${{ env.KEYWARDEN_POSTGRES_DB }}
KEYWARDEN_POSTGRES_SSL: ${{ env.KEYWARDEN_POSTGRES_SSL }}
run: alembic upgrade head
- name: Pytest - name: Pytest
env: env:
KEYWARDEN_POSTGRES_DSN: ${{ env.TEST_POSTGRES_DSN }} KEYWARDEN_POSTGRES_USER: ${{ env.KEYWARDEN_POSTGRES_USER }}
KEYWARDEN_POSTGRES_PASSWORD: ${{ env.KEYWARDEN_POSTGRES_PASSWORD }}
KEYWARDEN_POSTGRES_HOST: ${{ env.KEYWARDEN_POSTGRES_HOST }}
KEYWARDEN_POSTGRES_PORT: ${{ env.KEYWARDEN_POSTGRES_PORT }}
KEYWARDEN_POSTGRES_DB: ${{ env.KEYWARDEN_POSTGRES_DB }}
KEYWARDEN_POSTGRES_SSL: ${{ env.KEYWARDEN_POSTGRES_SSL }}
run: | run: |
pytest -q pytest -q tests
docker-build: docker-build:
name: Docker Build name: Docker Build
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -98,15 +145,47 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
# Choose Buildx cache backend: gha on GitHub, local on act_runner
- name: Select Buildx cache backend
run: |
if [ "${ACT:-}" = "true" ]; then
echo "CACHE_TO=type=local,dest=/tmp/.buildx-cache,mode=max" >> $GITHUB_ENV
echo "CACHE_FROM=type=local,src=/tmp/.buildx-cache" >> $GITHUB_ENV
else
echo "CACHE_TO=type=gha,mode=max" >> $GITHUB_ENV
echo "CACHE_FROM=type=gha" >> $GITHUB_ENV
fi
- name: Prepare local cache dir (act_runner only)
if: ${{ env.ACT == 'true' }}
run: mkdir -p /tmp/.buildx-cache
- name: Set image reference (Gitea)
run: |
echo "GT_IMAGE=${{ secrets.GITEA_REGISTRY }}/${{ secrets.GITEA_NAMESPACE }}/${{ env.IMAGE_NAME }}" >> $GITHUB_ENV
- name: Set up QEMU (optional)
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: Build image (no push) - name: Set image reference
run: echo "GT_IMAGE=${{ secrets.REGISTRY_HOST }}/${{ secrets.REGISTRY_NAMESPACE }}/${{ env.IMAGE_NAME }}" >> $GITHUB_ENV
- name: Login to registry
uses: docker/login-action@v3
with:
registry: ${{ secrets.REGISTRY_HOST }}
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build & push
uses: docker/build-push-action@v5 uses: docker/build-push-action@v5
with: with:
context: . context: .
push: false push: true
tags: keywarden:ci tags: |
# speeds up builds by caching layers on GH Actions ${{ env.GT_IMAGE }}:${{ github.ref_name }}
cache-from: type=gha ${{ env.GT_IMAGE }}:sha-${{ github.sha }}
cache-to: type=gha,mode=max ${{ env.GT_IMAGE }}:latest

View File

@@ -27,9 +27,11 @@ Keywarden is a web-based service designed to simplify secure access to Linux ser
- Deployment: Docker & Docker Compose - Deployment: Docker & Docker Compose
📚 Motivation 📚 Motivation
SSH is the backbone of secure remote administration, but poor key lifecycle management and lack of auditing create major risks. Enterprise tools like Teleport exist, but are often heavy and complex. Keywarden fills the gap by providing a focused, lightweight, and educational tool for secure SSH access control. SSH is the backbone of secure remote administration, but poor key lifecycle management and lack of auditing create major risks. Enterprise tools like Teleport exist, but are often heavy and complex. Keywarden fills the gap by providing a focused, lightweight, and educational tool for secure SSH access control.
🛠️ Getting Started 🛠️ Getting Started
There are currently no built artefacts for Keywarden as of 22/09/2025. There are currently no built artefacts for Keywarden as of 22/09/2025.
```bash ```bash
# clone the repository # clone the repository

16
SETUP.md Normal file
View File

@@ -0,0 +1,16 @@
# NGINX - External Proxy
For setups behind an external reverse proxy (heavily recommended), using a local CA and self-signed certificates is not required, but also recommended.
After installing `mkcert` through your system package manager:
```bash
mkcert -install
mkcert abc.domain.xyz, bcd.domain.xyz
mv domain.xyz+X-key.pem nginx/certs/key.pem
mv domain.xyz.pem nginx/certs/certificate.pem
```
NGINX will find these certificates automatically and use them when proxying the application. Unless you know what you are doing, editing files under `nginx/configs/` is not recommended.
If preferred, NGINX can be used as a reverse proxy, however an additional `certbot/certbot:latest` container would be required unless other valid SSL certificates are provided under `nginx/certs`.

39
alembic.ini Normal file
View File

@@ -0,0 +1,39 @@
[alembic]
# Path to migration scripts
script_location = alembic
# Set to 'true' to auto-generate migrations with naming convention support
timezone = UTC
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s

64
alembic/env.py Normal file
View File

@@ -0,0 +1,64 @@
import asyncio
import os
import pathlib
import sys
from logging.config import fileConfig
from sqlalchemy import pool
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
from alembic import context
# Ensure project root is importable
PROJECT_ROOT = pathlib.Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
# Import metadata (should NOT import settings)
from app.db.base import Base # Base.metadata must include all models # noqa: E402
# Alembic config & logging
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
# Get DB URL from env (prefer KEYWARDEN_ prefix, fall back to unprefixed, then a sane default for local)
DB_USER = os.getenv("KEYWARDEN_POSTGRES_USER", "postgres")
DB_PASS = os.getenv("KEYWARDEN_POSTGRES_PASSWORD", "postgres")
DB_HOST = os.getenv("KEYWARDEN_POSTGRES_HOST", "keywarden-db")
DB_PORT = os.getenv("KEYWARDEN_POSTGRES_PORT", "5432")
DB_NAME = os.getenv("KEYWARDEN_POSTGRES_DB", "keywarden")
DB_URL = f"postgresql+asyncpg://{DB_USER}:{DB_PASS}@{DB_HOST}:{DB_PORT}/{DB_NAME}"
def run_migrations_offline():
context.configure(
url=DB_URL,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection):
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_migrations_online():
connectable: AsyncEngine = create_async_engine(DB_URL, poolclass=pool.NullPool)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
if context.is_offline_mode():
run_migrations_offline()
else:
asyncio.run(run_migrations_online())

0
alembic/script.py.mako Normal file
View File

View File

@@ -1,11 +1,13 @@
from fastapi import Depends, HTTPException, status # ruff was driving me crazy with imported not used, noqa on all of these..
from fastapi.security import HTTPBearer from fastapi import Depends, HTTPException, status # noqa: F401
from jose import jwt, JWTError from fastapi.security import HTTPBearer # noqa: F401
from app.core.config import settings from jose import JWTError, jwt # noqa: F401
from app.db.session import get_session from sqlalchemy import select # noqa: F401
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession # noqa: F401
from app.models.user import User
from sqlalchemy import select from app.core.config import settings # noqa: F401
from app.db.session import get_session # noqa: F401
from app.models.user import User # noqa: F401
bearer = HTTPBearer() bearer = HTTPBearer()

View File

@@ -1,13 +1,40 @@
from pydantic_settings import BaseSettings from pydantic import computed_field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings): class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_prefix="KEYWARDEN_",
extra="ignore",
)
PROJECT_NAME: str = "Keywarden" PROJECT_NAME: str = "Keywarden"
API_V1_STR: str = "/api/v1" API_V1_STR: str = "/api/v1"
SECRET_KEY: str
# Postgres split vars (with defaults)
POSTGRES_USER: str = "postgres"
POSTGRES_PASSWORD: str = "postgres"
POSTGRES_HOST: str = "keywarden-db"
POSTGRES_PORT: int = 5432
POSTGRES_DB: str = "keywarden"
SECRET_KEY: str = "insecure-dev-secret" # default for local dev only
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
POSTGRES_DSN: str # e.g. postgresql+asyncpg://user:pass@db:5432/keywarden
OIDC_ENABLED: bool = False
OIDC_ISSUER: str | None = None OIDC_ISSUER: str | None = None
OIDC_CLIENT_ID: str | None = None OIDC_CLIENT_ID: str | None = None
OIDC_CLIENT_SECRET: str | None = None OIDC_AUDIENCE: str | None = None # optional
OIDC_JWKS_URL: str | None = None # if not set, derive from issuer
@computed_field(return_type=str)
@property
def POSTGRES_DSN(self) -> str:
return (
f"postgresql+asyncpg://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}"
f"@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}"
)
settings = Settings() settings = Settings()

View File

@@ -1,6 +1,8 @@
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from jose import jwt from jose import jwt
from passlib.hash import argon2 from passlib.hash import argon2
from app.core.config import settings from app.core.config import settings
ALGO = "HS256" ALGO = "HS256"

View File

@@ -1,5 +1,6 @@
from app.models.user import User from app.db.base_class import Base # noqa: F401
from app.models.server import Server from app.models.access_request import AccessRequest # noqa: F401
from app.models.sshkey import SSHKey from app.models.audit import AuditEvent # noqa: F401
from app.models.access_request import AccessRequest from app.models.server import Server # noqa: F401
from app.models.audit import AuditEvent from app.models.sshkey import SSHKey # noqa: F401
from app.models.user import User # noqa: F401

13
app/db/base_class.py Normal file
View File

@@ -0,0 +1,13 @@
from sqlalchemy import MetaData
from sqlalchemy.orm import declarative_base
# Optional: naming convention keeps Alembic diffs stable
convention = {
"ix": "ix_%(column_0_label)s",
"uq": "uq_%(table_name)s_%(column_0_name)s",
"ck": "ck_%(table_name)s_%(constraint_name)s",
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
"pk": "pk_%(table_name)s",
}
metadata = MetaData(naming_convention=convention)
Base = declarative_base(metadata=metadata)

View File

@@ -1,4 +1,5 @@
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.core.config import settings from app.core.config import settings
engine = create_async_engine(settings.POSTGRES_DSN, echo=False, future=True) engine = create_async_engine(settings.POSTGRES_DSN, echo=False, future=True)

View File

@@ -1,12 +1,37 @@
from fastapi import FastAPI from fastapi import FastAPI
from app.core.config import settings from sqlalchemy import text
from app.api.v1 import auth, keys from sqlalchemy.exc import SQLAlchemyError
from starlette.responses import JSONResponse
app = FastAPI(title=settings.PROJECT_NAME) from app.api.v1 import auth, keys
from app.core.config import settings
from app.db.session import AsyncSessionLocal
app = FastAPI(
title=settings.PROJECT_NAME
)
app.include_router(auth.router, prefix=f"{settings.API_V1_STR}/auth", tags=["auth"]) app.include_router(auth.router, prefix=f"{settings.API_V1_STR}/auth", tags=["auth"])
app.include_router(keys.router, prefix=f"{settings.API_V1_STR}/keys", tags=["keys"]) app.include_router(keys.router, prefix=f"{settings.API_V1_STR}/keys", tags=["keys"])
# Health endpoint (useful for docker, agent and uptime) # Is the API running?
@app.get("/livez")
async def livez():
return {"status": "ok"}
# Is the application ready (including db)?
@app.get("/readyz")
async def readyz():
try:
async with AsyncSessionLocal() as session:
await session.execute(text("SELECT 1"))
return {"status": "ok", "db": "up"}
except SQLAlchemyError:
return JSONResponse(
status_code=503,
content={"status": "degraded", "db": "down"},
)
# Is the application healthy (ready)?
@app.get("/healthz") @app.get("/healthz")
def healthz(): async def healthz():
return {"status": 'ok'} return await readyz() # alias

View File

@@ -1,7 +1,10 @@
from sqlalchemy import ForeignKey, String, DateTime from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from datetime import datetime, timezone
from app.models.user import Base from app.db.base_class import Base
class AccessRequest(Base): class AccessRequest(Base):
__tablename__ = "access_requests" __tablename__ = "access_requests"

View File

@@ -1,7 +1,10 @@
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import String, DateTime
from datetime import datetime, timezone from datetime import datetime, timezone
from app.models.user import Base
from sqlalchemy import DateTime, String
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base_class import Base
class AuditEvent(Base): class AuditEvent(Base):
__tablename__ = "audit_events" __tablename__ = "audit_events"

View File

@@ -1,6 +1,8 @@
from sqlalchemy import JSON, Boolean, Integer, String
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import String, JSON, Boolean, Integer
from app.models.user import Base from app.db.base_class import Base
class Server(Base): class Server(Base):
__tablename__ = "servers" __tablename__ = "servers"

View File

@@ -1,7 +1,10 @@
from sqlalchemy import ForeignKey, String, DateTime, Boolean from datetime import datetime, timezone # noqa: F401
from sqlalchemy.orm import Mapped, mapped_column, relationship
from datetime import datetime, timezone from sqlalchemy import Boolean, DateTime, ForeignKey, String
from app.models.user import Base from sqlalchemy.orm import Mapped, mapped_column
from app.db.base_class import Base
class SSHKey(Base): class SSHKey(Base):
__tablename__ = "ssh_keys" __tablename__ = "ssh_keys"

View File

@@ -1,8 +1,8 @@
from sqlalchemy import Boolean, String
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import String, Boolean
from app.db.session import engine # only for Alembic discovery, not used here from app.db.base_class import Base
from sqlalchemy.orm import declarative_base
Base = declarative_base()
class User(Base): class User(Base):
__tablename__ = "users" __tablename__ = "users"

118
ci.yml
View File

@@ -1,118 +0,0 @@
name: CI
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
permissions:
contents: read
env:
PYTHON_VERSION: "3.11"
# Used by tests / alembic; matches docker-compose-style DSN
TEST_POSTGRES_DSN: postgresql+asyncpg://postgres:postgres@localhost:5432/keywarden
jobs:
lint:
name: Lint & Format
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Install linters
run: |
python -m pip install --upgrade pip
pip install ruff==0.6.4 black==24.8.0
- name: Ruff (lint)
run: ruff check .
- name: Black (format check)
run: black --check .
test:
name: Tests (Pytest + Alembic + Postgres)
runs-on: ubuntu-latest
needs: lint
services:
postgres:
image: postgres:16
env:
POSTGRES_DB: keywarden
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: >-
--health-cmd="pg_isready -U postgres -d keywarden"
--health-interval=10s
--health-timeout=5s
--health-retries=10
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Cache pip
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: pip-${{ runner.os }}-${{ env.PYTHON_VERSION }}-${{ hashFiles('**/requirements.txt') }}
restore-keys: |
pip-${{ runner.os }}-${{ env.PYTHON_VERSION }}-
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Create .env for tests
run: |
printf "KEYWARDEN_POSTGRES_DSN=%s\nKEYWARDEN_SECRET_KEY=%s\nKEYWARDEN_ACCESS_TOKEN_EXPIRE_MINUTES=60\n" \
"${{ env.TEST_POSTGRES_DSN }}" "testsecret" > .env
echo "Wrote .env with DSN=${{ env.TEST_POSTGRES_DSN }}"
- name: Run Alembic migrations
env:
KEYWARDEN_POSTGRES_DSN: ${{ env.TEST_POSTGRES_DSN }}
run: |
alembic upgrade head
- name: Pytest
env:
KEYWARDEN_POSTGRES_DSN: ${{ env.TEST_POSTGRES_DSN }}
run: |
pytest -q
docker-build:
name: Docker Build
runs-on: ubuntu-latest
needs: test
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build image (no push)
uses: docker/build-push-action@v5
with:
context: .
push: false
tags: keywarden:ci
# speeds up builds by caching layers on GH Actions
cache-from: type=gha
cache-to: type=gha,mode=max

View File

@@ -9,6 +9,7 @@ services:
- ${DOCKERDIR}/nginx/certs/:/certs/ - ${DOCKERDIR}/nginx/certs/:/certs/
- ${DOCKERDIR}/nginx/webdir/:/var/www/ - ${DOCKERDIR}/nginx/webdir/:/var/www/
- ${DOCKERDIR}/nginx/logs:/var/log/nginx/ - ${DOCKERDIR}/nginx/logs:/var/log/nginx/
# - "external:internal", change external to desired port
ports: ports:
- "443:443" - "443:443"
@@ -22,10 +23,13 @@ services:
db: db:
image: postgres:17-alpine image: postgres:17-alpine
environment: environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} POSTGRES_PASSWORD: ${KEYWARDEN_POSTGRES_PASSWORD:-postgres}
POSTGRES_DB: keywarden POSTGRES_DB: ${KEYWARDEN_POSTGRES_DB:-keywarden}
POSTGRES_USER: postgres POSTGRES_USER: ${KEYWARDEN_POSTGRES_USER:-keywarden}
# ports: ["5432:5432"] POSTGRES_PORT: ${KEYWARDEN_POSTGRES_PORT:-5432}
# Do not enable unless debugging, not needed to be exposed outside of docker network
# ports:
# - "5432:5432"
volumes: volumes:
- "pgdata:/var/lib/postgresql/data" - "pgdata:/var/lib/postgresql/data"
@@ -33,13 +37,17 @@ services:
build: . build: .
depends_on: depends_on:
- db - db
environment: env_file:
- SECRET_KEY=[CREATE SECRET KEY] - .env
- POSTGRES_DSN=postgresql+asyncpg://postgres:${POSTGRES_PASSWORD:-postgres}@keywarden-postgres:5432/keywarden # API runs on port 8000, but is unneeded to be external unless using a custom reverse proxy on another machine
- ACCESS_TOKEN_EXPIRE_MINUTES=60 # ports:
ports: # - "8000:8000"
- "8000:8000"
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 command: uvicorn app.main:app --host 0.0.0.0 --port 8000
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/healthz"]
interval: 10s
timeout: 5s
retries: 5
volumes: volumes:
pgdata: pgdata:

View File

@@ -21,21 +21,6 @@ http {
'"$http_user_agent" "$http_x_forwarded_for"'; '"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main; access_log /var/log/nginx/access.log main;
server_tokens off;
sendfile on;
tcp_nopush on;
keepalive_timeout 60;
tcp_nodelay on;
client_body_timeout 15;
gzip on;
gzip_vary on;
gzip_min_length 1k;
client_max_body_size 10G;
proxy_request_buffering off;
include /etc/nginx/conf.d/*.conf; include /etc/nginx/conf.d/*.conf;
types_hash_bucket_size 128; types_hash_bucket_size 128;

View File

@@ -25,15 +25,24 @@ server {
} }
# NOT FOR PROD vvv
location ~ ^/(docs|openapi.json)$ {
proxy_pass http://keywarden-api:8000;
}
## REMOVE IN PRODUCTION BUILDS ^^^
location /api/v1/ { location /api/v1/ {
proxy_pass http://api:8000; proxy_pass http://keywarden-api:8000;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
add_header Strict-Transport-Security "max-age=15552000; includeSubDomains" always;
} }
location /healthz { location ~ ^/(healthz|readyz|livez)$ {
proxy_pass http://api:8000; proxy_pass http://keywarden-api:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
} }
} }

View File

@@ -0,0 +1,60 @@
[build-system]
requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "keywarden"
version = "0.1.0"
description = "A lightweight, self-hosted SSH key management and access auditing platform."
readme = "README.md"
requires-python = ">=3.11"
license = {text = "MIT"}
authors = [
{ name="George Wilkinson", email="admin@ntbx.io" }
]
dependencies = [
"fastapi>=0.114",
"uvicorn[standard]>=0.30",
"SQLAlchemy[asyncio]>=2.0",
"asyncpg>=0.29",
"alembic>=1.13",
"python-jose[cryptography]>=3.3",
"passlib[argon2]>=1.7",
"pydantic-settings>=2.4",
"cryptography>=43",
"structlog>=24",
"prometheus-fastapi-instrumentator>=6.1"
]
[project.optional-dependencies]
dev = [
"pytest>=8.3",
"httpx>=0.27",
"ruff>=0.6",
"black>=24.8",
"mypy>=1.11"
]
[tool.setuptools.packages.find]
where = ["."]
include = ["app*"]
[tool.black]
line-length = 88
target-version = ["py311"]
[tool.ruff]
line-length = 88
target-version = "py311"
select = ["E", "F", "I"]
ignore = ["E501"] # handled by black
[tool.pytest.ini_options]
minversion = "8.0"
addopts = "-q"
testpaths = ["tests"]
[tool.mypy]
python_version = "3.11"
strict = true
ignore_missing_imports = true

13
tests/test_health.py Normal file
View File

@@ -0,0 +1,13 @@
#
# Tiny test to pass CI, just checks the health endpoint to ensure API running.
#
from fastapi.testclient import TestClient
from app.main import app
def test_healthz():
client = TestClient(app)
r = client.get("/readyz")
assert r.status_code == 200
assert r.json() == {"status": "ok", "db": "up"}

7
tests/test_model.py Normal file
View File

@@ -0,0 +1,7 @@
#
# 2nd tiny test to pass CI, just ensures package installs and models import OK.
#
def test_models_import():
from app.db.base import Base # noqa: F401
from app.models.user import User # noqa: F401
assert True