adding http package

This commit is contained in:
Michael Woods
2025-12-20 20:22:36 -05:00
parent 6386bda4b0
commit db36753fdf
9 changed files with 241 additions and 1 deletions

1
.gitignore vendored
View File

@@ -1,2 +1,3 @@
.idea*
.venv*
*__pycache__*

View File

114
packetserver/http/auth.py Normal file
View File

@@ -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

View File

@@ -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"}

0
packetserver/http/static/.gitignore vendored Normal file
View File

View File

View File

@@ -0,0 +1,11 @@
<!DOCTYPE html>
<html>
<head>
<title>PacketServer</title>
<link rel="stylesheet" href="{{ url_for('static', path='/css/style.css') }}">
</head>
<body>
<h1>{{ message }}</h1>
<p>API docs available at <a href="/docs">/docs</a></p>
</body>
</html>

View File

@@ -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
)

View File

@@ -8,3 +8,8 @@ ZEO
podman
click
tabulate
fastapi
uvicorn[standard]
jinja2
python-multipart
argon2-cffi