From db36753fdffb0b158ba9730a83481b114d6799f1 Mon Sep 17 00:00:00 2001 From: Michael Woods Date: Sat, 20 Dec 2025 20:22:36 -0500 Subject: [PATCH] adding http package --- .gitignore | 1 + packetserver/http/__init__.py | 0 packetserver/http/auth.py | 114 +++++++++++++++++++++++++ packetserver/http/server.py | 94 ++++++++++++++++++++ packetserver/http/static/.gitignore | 0 packetserver/http/templates/.gitignore | 0 packetserver/http/templates/index.html | 11 +++ packetserver/runners/http_server.py | 15 ++++ requirements.txt | 7 +- 9 files changed, 241 insertions(+), 1 deletion(-) create mode 100644 packetserver/http/__init__.py create mode 100644 packetserver/http/auth.py create mode 100644 packetserver/http/server.py create mode 100644 packetserver/http/static/.gitignore create mode 100644 packetserver/http/templates/.gitignore create mode 100644 packetserver/http/templates/index.html create mode 100644 packetserver/runners/http_server.py diff --git a/.gitignore b/.gitignore index 06fd642..e87e0ea 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .idea* .venv* +*__pycache__* diff --git a/packetserver/http/__init__.py b/packetserver/http/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/packetserver/http/auth.py b/packetserver/http/auth.py new file mode 100644 index 0000000..b8bb07e --- /dev/null +++ b/packetserver/http/auth.py @@ -0,0 +1,114 @@ +# packetserver/http/auth.py +from persistent import Persistent +from argon2 import PasswordHasher +from argon2.exceptions import VerifyMismatchError +import time + +ph = PasswordHasher() + + +class HttpUser(Persistent): + """ + Persistent object for HTTP API users. + Separate from server.users.User to avoid privilege bleed. + """ + + def __init__(self, username: str, password: str): + self.username = username.upper() # stored uppercase like regular users + self.password_hash = ph.hash(password) + self.created_at = time.time() + self.last_login = None + self.failed_attempts = 0 + + # New fields + self._enabled = True # HTTP access enabled by default + # rf_enabled is a @property – no direct storage needed + + # ------------------------------------------------------------------ + # Simple enabled flag (admin can disable HTTP login entirely) + # ------------------------------------------------------------------ + @property + def enabled(self) -> bool: + return getattr(self, '_enabled', True) + + @enabled.setter + def enabled(self, value: bool): + self._enabled = bool(value) + self._p_changed = True + + # ------------------------------------------------------------------ + # rf_enabled property – tied directly to the main server's blacklist + # ------------------------------------------------------------------ + @property + def rf_enabled(self) -> bool: + """ + True if the callsign is NOT in the global blacklist. + This allows HTTP users to act as RF gateways only if explicitly allowed. + """ + from ZODB import DB # deferred import to avoid circular issues + # We'll get the db from the transaction in most contexts + # But for safety, we'll reach into the current connection's root + import transaction + try: + root = transaction.get().db().root() + blacklist = root.get('config', {}).get('blacklist', []) + return self.username not in blacklist + except Exception: + # If we're outside a transaction (e.g. during tests), default safe + return False + + @rf_enabled.setter + def rf_enabled(self, allow: bool): + """ + Enable/disable RF gateway capability by adding/removing from the global blacklist. + Only allows enabling if the username is a valid AX.25 callsign. + """ + import transaction + from packetserver.utils import is_valid_ax25_callsign # assuming you have this helper + + root = transaction.get().db().root() + config = root.setdefault('config', PersistentMapping()) + blacklist = config.setdefault('blacklist', PersistentList()) + + upper_name = self.username + + if allow: + # Trying to enable RF access + if not is_valid_ax25_callsign(upper_name): + raise ValueError(f"{upper_name} is not a valid AX.25 callsign – cannot enable RF access") + + if upper_name in blacklist: + blacklist.remove(upper_name) + config['blacklist'] = blacklist + self._p_changed = True + else: + # Disable RF access + if upper_name not in blacklist: + blacklist.append(upper_name) + config['blacklist'] = blacklist + self._p_changed = True + + # Ensure changes are marked + root._p_changed = True + config._p_changed = True + + # ------------------------------------------------------------------ + # Password handling (unchanged) + # ------------------------------------------------------------------ + def verify_password(self, password: str) -> bool: + try: + ph.verify(self.password_hash, password) + if ph.check_needs_rehash(self.password_hash): + self.password_hash = ph.hash(password) + return True + except VerifyMismatchError: + return False + + def record_login_success(self): + self.last_login = time.time() + self.failed_attempts = 0 + self._p_changed = True + + def record_login_failure(self): + self.failed_attempts += 1 + self._p_changed = True \ No newline at end of file diff --git a/packetserver/http/server.py b/packetserver/http/server.py new file mode 100644 index 0000000..9df2b29 --- /dev/null +++ b/packetserver/http/server.py @@ -0,0 +1,94 @@ +# packetserver/http/server.py +from fastapi import FastAPI, Depends, HTTPException, status +from fastapi.security import HTTPBasic, HTTPBasicCredentials +from fastapi.responses import HTMLResponse +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates +from starlette.requests import Request + +from ..database import get_db_connection # reuse existing helper if available +from .database import get_http_user +from .auth import HttpUser + +app = FastAPI( + title="PacketServer HTTP API", + description="RESTful interface to the AX.25 packet radio BBS", + version="0.1.0", +) + +# Mount static files +app.mount("/static", StaticFiles(directory="packetserver/http/static"), name="static") + +# Templates +templates = Jinja2Templates(directory="packetserver/http/templates") + +security = HTTPBasic() + + +async def get_current_http_user(credentials: HTTPBasicCredentials = Depends(security)): + db = get_db_connection() # your existing way to get the open DB + user: HttpUser | None = get_http_user(db, credentials.username) + + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid username or password", + headers={"WWW-Authenticate": "Basic"}, + ) + + if not user.enabled: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="HTTP access disabled for this user", + ) + + if not user.verify_password(credentials.password): + user.record_login_failure() + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid username or password", + headers={"WWW-Authenticate": "Basic"}, + ) + + user.record_login_success() + return user + + +# ------------------------------------------------------------------ +# Public routes (no auth) +# ------------------------------------------------------------------ +@app.get("/", response_class=HTMLResponse) +async def root(request: Request): + return templates.TemplateResponse( + "index.html", + {"request": request, "message": "Welcome to PacketServer HTTP Interface"} + ) + + +@app.get("/health") +async def health(): + return {"status": "ok", "service": "packetserver-http"} + + +# ------------------------------------------------------------------ +# Protected routes (require auth) +# ------------------------------------------------------------------ +@app.get("/api/v1/profile") +async def profile(current_user: HttpUser = Depends(get_current_http_user)): + return { + "username": current_user.username, + "enabled": current_user.enabled, + "rf_enabled": current_user.rf_enabled, + "created_at": current_user.created_at, + "last_login": current_user.last_login, + } + + +# Example future endpoint – list recent messages (placeholder) +@app.get("/api/v1/messages") +async def list_messages( + limit: int = 20, + current_user: HttpUser = Depends(get_current_http_user) +): + # TODO: implement actual message fetching from ZODB + return {"messages": [], "note": "Not implemented yet"} \ No newline at end of file diff --git a/packetserver/http/static/.gitignore b/packetserver/http/static/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/packetserver/http/templates/.gitignore b/packetserver/http/templates/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/packetserver/http/templates/index.html b/packetserver/http/templates/index.html new file mode 100644 index 0000000..3cfd5bf --- /dev/null +++ b/packetserver/http/templates/index.html @@ -0,0 +1,11 @@ + + + + PacketServer + + + +

{{ message }}

+

API docs available at /docs

+ + \ No newline at end of file diff --git a/packetserver/runners/http_server.py b/packetserver/runners/http_server.py new file mode 100644 index 0000000..1a577c9 --- /dev/null +++ b/packetserver/runners/http_server.py @@ -0,0 +1,15 @@ +# runners/http_server.py +import uvicorn +from packetserver.http.server import app +from packetserver.database import open_db # adjust to your actual DB opener + +# Ensure DB is open (same as main server) +open_db() # or however you initialize the global DB connection + +if __name__ == "__main__": + uvicorn.run( + "packetserver.http.server:app", + host="0.0.0.0", + port=8080, + reload=True, # convenient during development + ) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 42d0614..a99eec9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,4 +7,9 @@ transaction ZEO podman click -tabulate \ No newline at end of file +tabulate +fastapi +uvicorn[standard] +jinja2 +python-multipart +argon2-cffi \ No newline at end of file