Phase 4: cloud backend — licensing, heartbeat, site management

- New cloud_backend/ FastAPI service on port 8001 (SQLite for dev, swappable to PostgreSQL)
- Endpoints: sysadmin auth (JWT), site registration, lock/unlock, heartbeat (X-Site-ID + X-Site-Key headers)
- Default sysadmin seeded on first startup from ADMIN_USERNAME/ADMIN_PASSWORD env vars
- cloud_backend added to docker-compose.yml with persistent data volume at ./data/cloud/
- local_backend cloud_sync.py updated to use correct /api/heartbeat/ endpoint with header auth
- local_backend config.py: added SITE_KEY setting
- Smoke tested: login, register site, heartbeat, lock, unlock, list all pass
This commit is contained in:
2026-04-20 19:05:14 +03:00
parent 10b44d9a1a
commit 9812a25198
20 changed files with 421 additions and 2 deletions

View File

@@ -0,0 +1,14 @@
# Cloud Backend — copy this to .env and fill in values
# Generate with: python -c "import secrets; print(secrets.token_hex(32))"
SECRET_KEY=change-me-generate-a-long-random-string
# SQLite for local dev. Switch to postgresql://user:pass@host/dbname for VPS.
DATABASE_URL=sqlite:////app/data/cloud.db
# JWT expiry in minutes
ACCESS_TOKEN_EXPIRE_MINUTES=60
# Default sysadmin credentials (created on first startup if admin table is empty)
ADMIN_USERNAME=sysadmin
ADMIN_PASSWORD=changeme

10
cloud_backend/Dockerfile Normal file
View File

@@ -0,0 +1,10 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8001"]

View File

@@ -0,0 +1,44 @@
from datetime import datetime, timedelta, timezone
from jose import jwt, JWTError
from passlib.context import CryptContext
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.orm import Session
from config import settings
from database import get_db
from models.admin import Admin
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
ALGORITHM = "HS256"
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
def create_access_token(data: dict) -> str:
payload = data.copy()
payload["exp"] = datetime.now(timezone.utc) + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
return jwt.encode(payload, settings.SECRET_KEY, algorithm=ALGORITHM)
def get_current_admin(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)) -> Admin:
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
username: str = payload.get("sub")
if not username:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
except JWTError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
admin = db.query(Admin).filter(Admin.username == username).first()
if not admin:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Admin not found")
return admin

17
cloud_backend/config.py Normal file
View File

@@ -0,0 +1,17 @@
from pathlib import Path
from pydantic_settings import BaseSettings
_HERE = Path(__file__).parent
class Settings(BaseSettings):
SECRET_KEY: str = "change-me-generate-a-long-random-string"
DATABASE_URL: str = "sqlite:////app/data/cloud.db"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60
ADMIN_USERNAME: str = "sysadmin"
ADMIN_PASSWORD: str = "changeme"
model_config = {"env_file": str(_HERE / ".env")}
settings = Settings()

17
cloud_backend/database.py Normal file
View File

@@ -0,0 +1,17 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import declarative_base, sessionmaker
from config import settings
connect_args = {"check_same_thread": False} if settings.DATABASE_URL.startswith("sqlite") else {}
engine = create_engine(settings.DATABASE_URL, connect_args=connect_args)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

51
cloud_backend/main.py Normal file
View File

@@ -0,0 +1,51 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from config import settings
from database import engine, Base
from auth_utils import hash_password
import models.admin # noqa: F401
import models.site # noqa: F401
from routers import auth, sites, heartbeat
def _seed_default_admin():
from sqlalchemy.orm import Session
from models.admin import Admin
with Session(engine) as db:
if not db.query(Admin).filter(Admin.username == settings.ADMIN_USERNAME).first():
db.add(Admin(
username=settings.ADMIN_USERNAME,
password_hash=hash_password(settings.ADMIN_PASSWORD),
role="sysadmin",
))
db.commit()
@asynccontextmanager
async def lifespan(app: FastAPI):
Base.metadata.create_all(bind=engine)
_seed_default_admin()
yield
app = FastAPI(title="POS Cloud Backend", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
app.include_router(sites.router, prefix="/api/sites", tags=["sites"])
app.include_router(heartbeat.router, prefix="/api/heartbeat", tags=["heartbeat"])
@app.get("/health")
def health():
return {"status": "ok"}

View File

View File

@@ -0,0 +1,11 @@
from sqlalchemy import Column, Integer, String
from database import Base
class Admin(Base):
__tablename__ = "admins"
id = Column(Integer, primary_key=True, index=True)
username = Column(String, unique=True, nullable=False)
password_hash = Column(String, nullable=False)
role = Column(String, default="sysadmin")

View File

@@ -0,0 +1,21 @@
from sqlalchemy import Column, Integer, String, Boolean, DateTime
from sqlalchemy.sql import func
from database import Base
class Site(Base):
__tablename__ = "sites"
id = Column(Integer, primary_key=True, index=True)
site_id = Column(String, unique=True, nullable=False, index=True)
name = Column(String, nullable=False)
owner_name = Column(String, nullable=False)
contact_email = Column(String, nullable=False)
secret_key_hash = Column(String, nullable=False)
is_active = Column(Boolean, default=True)
is_locked = Column(Boolean, default=False)
lock_reason = Column(String, nullable=True)
license_expires_at = Column(DateTime(timezone=True), nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
last_seen_at = Column(DateTime(timezone=True), nullable=True)
last_seen_ip = Column(String, nullable=True)

View File

@@ -0,0 +1,10 @@
fastapi==0.115.0
uvicorn[standard]==0.30.6
sqlalchemy==2.0.35
pydantic==2.9.2
pydantic-settings==2.5.2
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
bcrypt==4.0.1
python-multipart==0.0.9
httpx==0.27.2

View File

View File

@@ -0,0 +1,18 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from auth_utils import verify_password, create_access_token
from database import get_db
from models.admin import Admin
from schemas.admin import LoginRequest, TokenOut
router = APIRouter()
@router.post("/login", response_model=TokenOut)
def login(body: LoginRequest, db: Session = Depends(get_db)):
admin = db.query(Admin).filter(Admin.username == body.username).first()
if not admin or not verify_password(body.password, admin.password_hash):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
token = create_access_token({"sub": admin.username, "role": admin.role})
return TokenOut(access_token=token)

View File

@@ -0,0 +1,37 @@
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Header, Request, status
from passlib.context import CryptContext
from sqlalchemy.orm import Session
from database import get_db
from models.site import Site
from schemas.site import HeartbeatRequest, HeartbeatResponse
router = APIRouter()
_pwd = CryptContext(schemes=["bcrypt"], deprecated="auto")
@router.post("/", response_model=HeartbeatResponse)
def heartbeat(
body: HeartbeatRequest,
request: Request,
x_site_id: str = Header(..., alias="X-Site-ID"),
x_site_key: str = Header(..., alias="X-Site-Key"),
db: Session = Depends(get_db),
):
site = db.query(Site).filter(Site.site_id == x_site_id).first()
if not site or not _pwd.verify(x_site_key, site.secret_key_hash):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid site credentials")
now = datetime.now(timezone.utc)
site.last_seen_at = now
site.last_seen_ip = request.client.host if request.client else None
db.commit()
licensed = site.is_active and (site.license_expires_at.replace(tzinfo=timezone.utc) > now)
return HeartbeatResponse(
licensed=licensed,
locked=site.is_locked,
lock_reason=site.lock_reason,
expires_at=site.license_expires_at,
)

View File

@@ -0,0 +1,90 @@
import secrets
import uuid
from passlib.context import CryptContext
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from auth_utils import get_current_admin
from database import get_db
from models.site import Site
from schemas.site import SiteCreate, SiteUpdate, SiteOut, SiteCreatedOut, LockRequest
router = APIRouter()
_pwd = CryptContext(schemes=["bcrypt"], deprecated="auto")
@router.get("/", response_model=list[SiteOut])
def list_sites(db: Session = Depends(get_db), _=Depends(get_current_admin)):
return db.query(Site).all()
@router.post("/", response_model=SiteCreatedOut, status_code=status.HTTP_201_CREATED)
def create_site(body: SiteCreate, db: Session = Depends(get_db), _=Depends(get_current_admin)):
raw_key = secrets.token_urlsafe(32)
site = Site(
site_id=str(uuid.uuid4()),
name=body.name,
owner_name=body.owner_name,
contact_email=body.contact_email,
secret_key_hash=_pwd.hash(raw_key),
license_expires_at=body.license_expires_at,
)
db.add(site)
db.commit()
db.refresh(site)
data = SiteOut.model_validate(site).model_dump()
data["secret_key"] = raw_key
return SiteCreatedOut(**data)
@router.get("/{site_id}", response_model=SiteOut)
def get_site(site_id: str, db: Session = Depends(get_db), _=Depends(get_current_admin)):
site = db.query(Site).filter(Site.site_id == site_id).first()
if not site:
raise HTTPException(status_code=404, detail="Site not found")
return site
@router.put("/{site_id}", response_model=SiteOut)
def update_site(site_id: str, body: SiteUpdate, db: Session = Depends(get_db), _=Depends(get_current_admin)):
site = db.query(Site).filter(Site.site_id == site_id).first()
if not site:
raise HTTPException(status_code=404, detail="Site not found")
for field, value in body.model_dump(exclude_none=True).items():
setattr(site, field, value)
db.commit()
db.refresh(site)
return site
@router.post("/{site_id}/lock", response_model=SiteOut)
def lock_site(site_id: str, body: LockRequest, db: Session = Depends(get_db), _=Depends(get_current_admin)):
site = db.query(Site).filter(Site.site_id == site_id).first()
if not site:
raise HTTPException(status_code=404, detail="Site not found")
site.is_locked = True
site.lock_reason = body.reason
db.commit()
db.refresh(site)
return site
@router.post("/{site_id}/unlock", response_model=SiteOut)
def unlock_site(site_id: str, db: Session = Depends(get_db), _=Depends(get_current_admin)):
site = db.query(Site).filter(Site.site_id == site_id).first()
if not site:
raise HTTPException(status_code=404, detail="Site not found")
site.is_locked = False
site.lock_reason = None
db.commit()
db.refresh(site)
return site
@router.delete("/{site_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_site(site_id: str, db: Session = Depends(get_db), _=Depends(get_current_admin)):
site = db.query(Site).filter(Site.site_id == site_id).first()
if not site:
raise HTTPException(status_code=404, detail="Site not found")
db.delete(site)
db.commit()

View File

View File

@@ -0,0 +1,11 @@
from pydantic import BaseModel
class LoginRequest(BaseModel):
username: str
password: str
class TokenOut(BaseModel):
access_token: str
token_type: str = "bearer"

View File

@@ -0,0 +1,53 @@
from datetime import datetime
from pydantic import BaseModel
class SiteCreate(BaseModel):
name: str
owner_name: str
contact_email: str
license_expires_at: datetime
class SiteUpdate(BaseModel):
name: str | None = None
owner_name: str | None = None
contact_email: str | None = None
license_expires_at: datetime | None = None
class SiteOut(BaseModel):
id: int
site_id: str
name: str
owner_name: str
contact_email: str
is_active: bool
is_locked: bool
lock_reason: str | None
license_expires_at: datetime
created_at: datetime
last_seen_at: datetime | None
last_seen_ip: str | None
model_config = {"from_attributes": True}
class SiteCreatedOut(SiteOut):
secret_key: str
class LockRequest(BaseModel):
reason: str
class HeartbeatRequest(BaseModel):
version: str = "1.0.0"
uptime_seconds: int = 0
class HeartbeatResponse(BaseModel):
licensed: bool
locked: bool
lock_reason: str | None
expires_at: datetime