From 05c89e5c6113ad111bd40154a29629887f75e9ea Mon Sep 17 00:00:00 2001 From: lexzach Date: Sat, 18 Oct 2025 16:00:07 -0400 Subject: [PATCH] progress on license server --- Dockerfile | 10 + README.md | 13 +- docker-compose.yml | 24 +++ requirements.txt | 5 + src/main.py | 500 +++++++++++++++++++++++++++++++++++++++++++++ tests/tests.py | 0 6 files changed, 551 insertions(+), 1 deletion(-) create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 requirements.txt create mode 100644 src/main.py create mode 100644 tests/tests.py diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..150c162 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3 + +WORKDIR /src + +COPY requirements.txt ./ +COPY .env ./ + +RUN pip install --no-cache-dir -r requirements.txt + +COPY src /src \ No newline at end of file diff --git a/README.md b/README.md index 851f4ec..d8f4bc2 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,14 @@ # micro-license-server -A very small, self-hosted license server intended for lower traffic projects. \ No newline at end of file +A very small, self-hosted license server intended for lower traffic projects. + +## Environment Setup + +Replace the placeholder secrets in `.env` with secure random values using `openssl rand`: + +```bash +sed -i \ + -e "s/PG_CHANGEME/$(openssl rand -base64 32)/" \ + -e "s/API_CHANGEME/$(openssl rand -base64 48)/" \ + .env +``` diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..abbcfde --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,24 @@ +services: + postgresql: + image: postgres:17 + env_file: + - .env + restart: unless-stopped + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: ${POSTGRESQL_PASSWORD} + volumes: + - pgdata:/var/lib/postgresql/data + + odtserver: + build: + context: . + dockerfile: Dockerfile + restart: unless-stopped + depends_on: + - postgresql + ports: + - "8000:8000" + command: ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] +volumes: + pgdata: diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e46d914 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +fastapi~=0.116.1 +uvicorn +psycopg~=3.2.9 +python-dotenv~=1.1.1 +loguru~=0.7.3 \ No newline at end of file diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..6bd042a --- /dev/null +++ b/src/main.py @@ -0,0 +1,500 @@ +from fastapi import Depends, FastAPI, HTTPException, status, Response +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from dotenv import dotenv_values +from loguru import logger +from datetime import datetime, timezone +from typing import Optional +import secrets +import string +import psycopg +import asyncio + +ENV = dotenv_values(".env") # .env file +VERSION = "v0.0.1" # version number +ALPHABET = string.ascii_uppercase + string.digits #alphabet and numbers for token generation +LICENSE_KEY_PARTS = 5 if "NUM_KEY_CHUNKS" not in ENV else ENV["NUM_KEY_CHUNKS"] #number of chunks in the new generated license keys +LICENSE_KEY_PART_LENGTH = 5 if "KEY_CHUNK_LENGTH" not in ENV else ENV["KEY_CHUNK_LENGTH"] #number of characters in each chunk + +api_key = "" +security = HTTPBearer(auto_error=False) + +if "POSTGRESQL_PASSWORD" in ENV: + logger.debug("POSTGRESQL_PASSWORD successfully read.") + connect_statement = "dbname=postgres user=postgres host=postgresql password='{password}'".format(password = ENV['POSTGRESQL_PASSWORD']) +else: + logger.error("POSTGRESQL_PASSWORD not found in .env file... cannot continue.") + exit() + +if "API_KEY" in ENV: + logger.debug("API_KEY successfully read.") + api_key = ENV['API_KEY'] +else: + logger.error("API_KEY not found in .env file... cannot continue.") + exit() + +with psycopg.connect(connect_statement) as conn: + with conn.cursor() as cur: + cur.execute( + """ + CREATE TABLE IF NOT EXISTS license_keys ( + key TEXT PRIMARY KEY, + issue_timestamp TIMESTAMPTZ NOT NULL, + expiration_timestamp TIMESTAMPTZ, + last_used_timestamp TIMESTAMPTZ, + is_active BOOLEAN NOT NULL DEFAULT TRUE + ) + """ + ) + cur.execute( + """ + CREATE TABLE IF NOT EXISTS history ( + action TEXT NOT NULL, + timestamp TIMESTAMPTZ NOT NULL + ) + """ + ) + conn.commit() + +#app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None) +app = FastAPI() +logger.info("Started FastAPI.") + + + +def _generate_license_key() -> str: + """ + Generate a cryptographically secure token + """ + return "-".join( + "".join(secrets.choice(ALPHABET) for _ in range(LICENSE_KEY_PART_LENGTH)) + for _ in range(LICENSE_KEY_PARTS) + ) + + +@app.post("/license", status_code=status.HTTP_201_CREATED) +async def create_license_key( + is_active: bool = True, + credentials: HTTPAuthorizationCredentials = Depends(security), + expiration_date: Optional[datetime] = None, +): + if ( + credentials is None + or credentials.scheme.lower() != "bearer" + or credentials.credentials != api_key + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or missing API key.", + ) + + license_key = _generate_license_key() + issued_at = datetime.now(timezone.utc) + expiration_ts: Optional[datetime] = None + if expiration_date is not None: + expiration_ts = expiration_date + if expiration_ts.tzinfo is None: + expiration_ts = expiration_ts.replace(tzinfo=timezone.utc) + else: + expiration_ts = expiration_ts.astimezone(timezone.utc) + + def _persist_license(): + with psycopg.connect(connect_statement) as conn: + with conn.cursor() as cur: + cur.execute( + """ + INSERT INTO license_keys ( + key, + issue_timestamp, + expiration_timestamp, + last_used_timestamp, + is_active + ) + VALUES (%s, %s, %s, %s, %s) + """, + ( + license_key, + issued_at, + expiration_ts, + None, + is_active, + ), + ) + cur.execute( + """ + INSERT INTO history (action, timestamp) + VALUES (%s, %s) + """, + ( + f"create_license_key key={license_key} active={is_active} expiration={expiration_ts.isoformat() if expiration_ts else 'none'}", + issued_at, + ), + ) + conn.commit() + + try: + await asyncio.to_thread(_persist_license) + except Exception as exc: + logger.exception("Failed to create license key.") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to create license key.", + ) from exc + + return { + "license_key": license_key, + "expiration_timestamp": expiration_ts.isoformat() if expiration_ts else None, + "is_active": is_active, + } + +@app.get("/is_valid") +async def is_license_key_valid(license_key: str) -> bool: + """Validate the supplied license key against the database.""" + now = datetime.now(timezone.utc) + + def _lookup() -> bool: + try: + with psycopg.connect(connect_statement) as conn: + with conn.cursor() as cur: + cur.execute( + """ + UPDATE license_keys + SET last_used_timestamp = %s + WHERE key = %s + AND is_active = TRUE + AND (expiration_timestamp IS NULL OR expiration_timestamp > %s) + RETURNING 1 + """, + (now, license_key, now), + ) + if cur.fetchone() is None: + return False + conn.commit() + return True + except Exception: + logger.exception("Failed validating license key.") + return False + + return await asyncio.to_thread(_lookup) + +@app.post("/license/{license_key}/disable", status_code=status.HTTP_200_OK) +async def disable_license_key( + license_key: str, + credentials: HTTPAuthorizationCredentials = Depends(security), +): + if ( + credentials is None + or credentials.scheme.lower() != "bearer" + or credentials.credentials != api_key + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or missing API key.", + ) + + disabled_at = datetime.now(timezone.utc) + + def _disable() -> bool: + with psycopg.connect(connect_statement) as conn: + with conn.cursor() as cur: + cur.execute( + """ + UPDATE license_keys + SET is_active = FALSE + WHERE key = %s AND is_active = TRUE + """, + (license_key,), + ) + if cur.rowcount == 0: + return False + cur.execute( + """ + INSERT INTO history (action, timestamp) + VALUES (%s, %s) + """, + (f"disable_license_key key={license_key}", disabled_at), + ) + conn.commit() + return True + + try: + disabled = await asyncio.to_thread(_disable) + except Exception as exc: + logger.exception("Failed disabling license key.") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to disable license key.", + ) from exc + + if not disabled: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="License key not found or already disabled.", + ) + + return {"license_key": license_key, "is_active": False} + +@app.post("/license/{license_key}/enable", status_code=status.HTTP_200_OK) +async def enable_license_key( + license_key: str, + credentials: HTTPAuthorizationCredentials = Depends(security), +): + if ( + credentials is None + or credentials.scheme.lower() != "bearer" + or credentials.credentials != api_key + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or missing API key.", + ) + + enabled_at = datetime.now(timezone.utc) + + def _enable() -> bool: + with psycopg.connect(connect_statement) as conn: + with conn.cursor() as cur: + cur.execute( + """ + UPDATE license_keys + SET is_active = TRUE + WHERE key = %s AND is_active = FALSE + """, + (license_key,), + ) + if cur.rowcount == 0: + return False + cur.execute( + """ + INSERT INTO history (action, timestamp) + VALUES (%s, %s) + """, + (f"enable_license_key key={license_key}", enabled_at), + ) + conn.commit() + return True + + try: + enabled = await asyncio.to_thread(_enable) + except Exception as exc: + logger.exception("Failed enabling license key.") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to enable license key.", + ) from exc + + if not enabled: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="License key not found or already enabled.", + ) + + return {"license_key": license_key, "is_active": True} + +@app.post("/license/{license_key}/expiration", status_code=status.HTTP_200_OK) +async def update_license_expiration( + license_key: str, + expiration_date: Optional[datetime] = None, + credentials: HTTPAuthorizationCredentials = Depends(security), +): + if ( + credentials is None + or credentials.scheme.lower() != "bearer" + or credentials.credentials != api_key + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or missing API key.", + ) + + expiration_ts: Optional[datetime] = None + if expiration_date is not None: + expiration_ts = expiration_date + if expiration_ts.tzinfo is None: + expiration_ts = expiration_ts.replace(tzinfo=timezone.utc) + else: + expiration_ts = expiration_ts.astimezone(timezone.utc) + + updated_at = datetime.now(timezone.utc) + + def _update(): + with psycopg.connect(connect_statement) as conn: + with conn.cursor() as cur: + cur.execute( + """ + UPDATE license_keys + SET expiration_timestamp = %s + WHERE key = %s + RETURNING expiration_timestamp + """, + (expiration_ts, license_key), + ) + row = cur.fetchone() + if row is None: + return False, None + new_expiration = row[0] + cur.execute( + """ + INSERT INTO history (action, timestamp) + VALUES (%s, %s) + """, + ( + ( + "update_expiration license_key={key} expiration={expiration}" + .format( + key=license_key, + expiration=new_expiration.isoformat() + if new_expiration + else "cleared", + ) + ), + updated_at, + ), + ) + conn.commit() + return True, new_expiration + + try: + success, new_expiration = await asyncio.to_thread(_update) + except Exception as exc: + logger.exception("Failed updating license expiration.") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to update license expiration.", + ) from exc + + if not success: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="License key not found.", + ) + + return { + "license_key": license_key, + "expiration_timestamp": new_expiration.isoformat() + if new_expiration + else None, + } + +@app.get("/license/export", status_code=status.HTTP_200_OK) +async def export_license_keys( + credentials: HTTPAuthorizationCredentials = Depends(security), +): + if ( + credentials is None + or credentials.scheme.lower() != "bearer" + or credentials.credentials != api_key + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or missing API key.", + ) + + def _fetch(): + with psycopg.connect(connect_statement) as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT key, issue_timestamp, expiration_timestamp, is_active + FROM license_keys + ORDER BY issue_timestamp ASC + """ + ) + return cur.fetchall() + + try: + rows = await asyncio.to_thread(_fetch) + except Exception as exc: + logger.exception("Failed exporting license keys.") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to export license keys.", + ) from exc + + lines = ["license_key,issue_timestamp,expiration_timestamp,is_active"] + for license_key, issue_ts, expiration_ts, is_active in rows: + lines.append( + "{key},{issue},{expiration},{active}".format( + key=license_key, + issue=issue_ts.isoformat(), + expiration=expiration_ts.isoformat() + if expiration_ts + else "", + active="true" if is_active else "false", + ) + ) + csv_content = "\n".join(lines) + return Response( + content=csv_content, + media_type="text/csv", + headers={"Content-Disposition": 'attachment; filename="license_export.csv"'}, + ) + +@app.get("/history/export", status_code=status.HTTP_200_OK) +async def export_history( + token: Optional[str] = None, + credentials: HTTPAuthorizationCredentials = Depends(security), +): + if ( + credentials is None + or credentials.scheme.lower() != "bearer" + or credentials.credentials != api_key + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or missing API key.", + ) + + def _fetch(): + with psycopg.connect(connect_statement) as conn: + with conn.cursor() as cur: + if token: + cur.execute( + """ + SELECT action, timestamp + FROM history + WHERE action LIKE %s + ORDER BY timestamp ASC + """, + (f"%{token}%",), + ) + else: + cur.execute( + """ + SELECT action, timestamp + FROM history + ORDER BY timestamp ASC + """ + ) + return cur.fetchall() + + try: + rows = await asyncio.to_thread(_fetch) + except Exception as exc: + logger.exception("Failed exporting history.") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to export history.", + ) from exc + + lines = ["action,timestamp"] + for action, ts in rows: + lines.append(f"{action},{ts.isoformat()}") + csv_content = "\n".join(lines) + filename_token = token.replace(" ", "_") if token else "history" + return Response( + content=csv_content, + media_type="text/csv", + headers={ + "Content-Disposition": f'attachment; filename="{filename_token}_export.csv"' + }, + ) + +@app.get("/") +async def get_server_info(): + """ + Return the server name and version + """ + info = { + "version":"Micro License Server {version}".format(version=VERSION) + } + return info diff --git a/tests/tests.py b/tests/tests.py new file mode 100644 index 0000000..e69de29