adding http package
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,2 +1,3 @@
|
||||
.idea*
|
||||
.venv*
|
||||
*__pycache__*
|
||||
|
||||
0
packetserver/http/__init__.py
Normal file
0
packetserver/http/__init__.py
Normal file
114
packetserver/http/auth.py
Normal file
114
packetserver/http/auth.py
Normal 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
|
||||
94
packetserver/http/server.py
Normal file
94
packetserver/http/server.py
Normal 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
0
packetserver/http/static/.gitignore
vendored
Normal file
0
packetserver/http/templates/.gitignore
vendored
Normal file
0
packetserver/http/templates/.gitignore
vendored
Normal file
11
packetserver/http/templates/index.html
Normal file
11
packetserver/http/templates/index.html
Normal 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>
|
||||
15
packetserver/runners/http_server.py
Normal file
15
packetserver/runners/http_server.py
Normal 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
|
||||
)
|
||||
@@ -8,3 +8,8 @@ ZEO
|
||||
podman
|
||||
click
|
||||
tabulate
|
||||
fastapi
|
||||
uvicorn[standard]
|
||||
jinja2
|
||||
python-multipart
|
||||
argon2-cffi
|
||||
Reference in New Issue
Block a user