Add latest changes

Dieser Commit ist enthalten in:
2025-07-03 20:38:33 +00:00
Ursprung 63f3d92724
Commit 6f6cde65db
129 geänderte Dateien mit 3998 neuen und 1199 gelöschten Zeilen

Datei anzeigen

@@ -1,11 +1,12 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from sqlalchemy import text
from datetime import datetime, timedelta
from typing import Dict, Any
import uuid
from app.db.database import get_db
from app.models.models import License, Activation, Version
from app.models.models import License, DeviceRegistration, LicenseSession, Version
from app.schemas.license import (
LicenseActivationRequest,
LicenseActivationResponse,
@@ -47,58 +48,72 @@ async def activate_license(
message="License has expired"
)
existing_activations = db.query(Activation).filter(
Activation.license_id == license.id,
Activation.is_active == True
existing_devices = db.query(DeviceRegistration).filter(
DeviceRegistration.license_id == license.id,
DeviceRegistration.is_active == True
).all()
existing_machine = next(
(a for a in existing_activations if a.machine_id == request.machine_id),
existing_device = next(
(d for d in existing_devices if d.hardware_fingerprint == request.hardware_fingerprint),
None
)
if existing_machine:
if existing_machine.hardware_hash != request.hardware_hash:
return LicenseActivationResponse(
success=False,
message="Hardware mismatch for this machine"
)
existing_machine.last_heartbeat = datetime.utcnow()
existing_machine.app_version = request.app_version
existing_machine.os_info = request.os_info
if existing_device:
# Update existing device info
existing_device.last_seen_at = datetime.utcnow()
existing_device.app_version = request.app_version
existing_device.device_metadata = request.os_info
existing_device.device_name = request.machine_name
db.commit()
return LicenseActivationResponse(
success=True,
message="License reactivated successfully",
activation_id=existing_machine.id,
activation_id=existing_device.id,
expires_at=license.expires_at,
features={"all_features": True}
)
if len(existing_activations) >= license.max_activations:
if len(existing_devices) >= license.device_limit:
return LicenseActivationResponse(
success=False,
message=f"Maximum activations ({license.max_activations}) reached"
message=f"Device limit ({license.device_limit}) reached"
)
new_activation = Activation(
# Determine device type from OS info
device_type = 'unknown'
if request.os_info:
os_string = str(request.os_info).lower()
if 'windows' in os_string:
device_type = 'desktop'
elif 'mac' in os_string or 'darwin' in os_string:
device_type = 'desktop'
elif 'linux' in os_string:
device_type = 'desktop'
elif 'android' in os_string:
device_type = 'mobile'
elif 'ios' in os_string:
device_type = 'mobile'
new_device = DeviceRegistration(
license_id=license.id,
machine_id=request.machine_id,
hardware_hash=request.hardware_hash,
os_info=request.os_info,
app_version=request.app_version
device_name=request.machine_name,
hardware_fingerprint=request.hardware_fingerprint,
device_type=device_type,
operating_system=request.os_info.get('os', 'unknown') if isinstance(request.os_info, dict) else str(request.os_info),
metadata=request.os_info,
app_version=request.app_version,
ip_address=request.ip_address if hasattr(request, 'ip_address') else None
)
db.add(new_activation)
db.add(new_device)
db.commit()
db.refresh(new_activation)
db.refresh(new_device)
return LicenseActivationResponse(
success=True,
message="License activated successfully",
activation_id=new_activation.id,
activation_id=new_device.id,
expires_at=license.expires_at,
features={"all_features": True}
)
@@ -109,19 +124,20 @@ async def verify_license(
db: Session = Depends(get_db),
api_key: str = Depends(validate_api_key)
):
activation = db.query(Activation).filter(
Activation.id == request.activation_id,
Activation.machine_id == request.machine_id,
Activation.is_active == True
device = db.query(DeviceRegistration).filter(
DeviceRegistration.id == request.activation_id,
DeviceRegistration.device_name == request.machine_name,
DeviceRegistration.hardware_fingerprint == request.hardware_fingerprint,
DeviceRegistration.is_active == True
).first()
if not activation:
if not device:
return LicenseVerificationResponse(
valid=False,
message="Invalid activation"
)
license = activation.license
license = device.license
if not license.is_active:
return LicenseVerificationResponse(
@@ -135,36 +151,22 @@ async def verify_license(
message="License key mismatch"
)
if activation.hardware_hash != request.hardware_hash:
grace_period = datetime.utcnow() - timedelta(days=settings.OFFLINE_GRACE_PERIOD_DAYS)
if activation.last_heartbeat < grace_period:
return LicenseVerificationResponse(
valid=False,
message="Hardware mismatch and grace period expired"
)
else:
return LicenseVerificationResponse(
valid=True,
message="Hardware mismatch but within grace period",
expires_at=license.expires_at,
features={"all_features": True}
)
if license.expires_at and license.expires_at < datetime.utcnow():
return LicenseVerificationResponse(
valid=False,
message="License has expired"
)
activation.last_heartbeat = datetime.utcnow()
# Update last seen timestamp
device.last_seen_at = datetime.utcnow()
db.commit()
latest_version = db.query(Version).order_by(Version.release_date.desc()).first()
requires_update = False
update_url = None
if latest_version and activation.app_version:
if latest_version.version_number > activation.app_version:
if latest_version and device.app_version:
if latest_version.version_number > device.app_version:
requires_update = True
update_url = latest_version.download_url
@@ -190,9 +192,9 @@ async def get_license_info(
if not license:
raise HTTPException(status_code=404, detail="License not found")
activations = db.query(Activation).filter(
Activation.license_id == license.id,
Activation.is_active == True
devices = db.query(DeviceRegistration).filter(
DeviceRegistration.license_id == license.id,
DeviceRegistration.is_active == True
).all()
return {
@@ -202,17 +204,19 @@ async def get_license_info(
"customer_name": license.customer_name,
"is_active": license.is_active,
"expires_at": license.expires_at,
"max_activations": license.max_activations,
"current_activations": len(activations),
"activations": [
"device_limit": license.device_limit,
"current_devices": len(devices),
"devices": [
{
"id": a.id,
"machine_id": a.machine_id,
"activation_date": a.activation_date,
"last_heartbeat": a.last_heartbeat,
"app_version": a.app_version
"id": d.id,
"hardware_fingerprint": d.hardware_fingerprint,
"device_name": d.device_name,
"device_type": d.device_type,
"first_activated_at": d.first_activated_at,
"last_seen_at": d.last_seen_at,
"app_version": d.app_version
}
for a in activations
for d in devices
]
}
@@ -222,12 +226,12 @@ async def start_session(
db: Session = Depends(get_db),
api_key: str = Depends(validate_api_key)
):
# Verify API key matches client config
# Get client config (API key already validated by dependency)
from sqlalchemy import text
result = db.execute(text("SELECT api_key, current_version, minimum_version FROM client_configs WHERE client_name = 'Account Forger'")).first()
result = db.execute(text("SELECT current_version, minimum_version FROM client_configs WHERE client_name = 'Account Forger'")).first()
if not result or result.api_key != api_key:
raise HTTPException(status_code=401, detail="Invalid API key")
if not result:
raise HTTPException(status_code=404, detail="Client configuration not found")
# Check if version is supported
if request.version < result.minimum_version:
@@ -260,47 +264,96 @@ async def start_session(
session_token=None
)
# Check for existing active session
existing_session_result = db.execute(
text("SELECT session_token, hardware_id FROM license_sessions WHERE license_id = :license_id"),
# Get license details with concurrent_sessions_limit
license_details = db.execute(
text("SELECT concurrent_sessions_limit FROM licenses WHERE id = :license_id"),
{"license_id": license.id}
).first()
if existing_session_result:
if existing_session_result.hardware_id == request.hardware_id:
# Same device, update heartbeat
db.execute(
text("UPDATE license_sessions SET last_heartbeat = CURRENT_TIMESTAMP WHERE session_token = :token"),
{"token": existing_session_result.session_token}
)
db.commit()
return SessionStartResponse(
success=True,
message="Existing session resumed",
session_token=existing_session_result.session_token,
requires_update=request.version < result.current_version,
update_url=None,
whats_new=None
)
else:
max_concurrent_sessions = license_details.concurrent_sessions_limit if license_details else 1
# Check if device is registered
device = db.query(DeviceRegistration).filter(
DeviceRegistration.license_id == license.id,
DeviceRegistration.hardware_fingerprint == request.hardware_fingerprint,
DeviceRegistration.is_active == True
).first()
if not device:
# Register new device if under limit
device_count = db.query(DeviceRegistration).filter(
DeviceRegistration.license_id == license.id,
DeviceRegistration.is_active == True
).count()
if device_count >= license.device_limit:
return SessionStartResponse(
success=False,
message="Es ist nur eine Sitzung erlaubt, stelle sicher, dass nirgendwo sonst das Programm läuft",
message=f"Device limit ({license.device_limit}) reached",
session_token=None
)
# Register device
device = DeviceRegistration(
license_id=license.id,
hardware_fingerprint=request.hardware_fingerprint,
device_name=request.machine_name,
device_type='desktop',
app_version=request.version,
ip_address=request.ip_address
)
db.add(device)
db.commit()
db.refresh(device)
# Check if this device already has an active session
existing_session = db.execute(
text("SELECT session_token FROM license_sessions WHERE license_id = :license_id AND device_registration_id = :device_id AND ended_at IS NULL"),
{"license_id": license.id, "device_id": device.id}
).first()
if existing_session:
# Same device, update heartbeat
db.execute(
text("UPDATE license_sessions SET last_heartbeat = CURRENT_TIMESTAMP WHERE session_token = :token"),
{"token": existing_session.session_token}
)
db.commit()
return SessionStartResponse(
success=True,
message="Existing session resumed",
session_token=existing_session.session_token,
requires_update=request.version < result.current_version,
update_url=None,
whats_new=None
)
# Count active sessions for this license
active_sessions_count = db.execute(
text("SELECT COUNT(*) FROM license_sessions WHERE license_id = :license_id AND ended_at IS NULL"),
{"license_id": license.id}
).scalar()
if active_sessions_count >= max_concurrent_sessions:
return SessionStartResponse(
success=False,
message=f"Maximum concurrent sessions ({max_concurrent_sessions}) reached. Please close another session first.",
session_token=None
)
# Create new session
session_token = str(uuid.uuid4())
db.execute(
text("""
INSERT INTO license_sessions (license_id, hardware_id, ip_address, client_version, session_token)
VALUES (:license_id, :hardware_id, :ip_address, :version, :token)
INSERT INTO license_sessions (license_id, device_registration_id, hardware_fingerprint, ip_address, client_version, session_token)
VALUES (:license_id, :device_id, :hardware_fingerprint, :ip_address, :version, :token)
"""),
{
"license_id": license.id,
"hardware_id": request.hardware_id,
"device_id": device.id,
"hardware_fingerprint": request.hardware_fingerprint,
"ip_address": request.ip_address,
"version": request.version,
"token": session_token
@@ -356,7 +409,7 @@ async def end_session(
# Get session info before deleting
session_info = db.execute(
text("""
SELECT license_id, hardware_id, ip_address, client_version, started_at
SELECT license_id, hardware_fingerprint, machine_name, ip_address, client_version, started_at
FROM license_sessions
WHERE session_token = :token
"""),
@@ -372,21 +425,22 @@ async def end_session(
# Log to session history
db.execute(
text("""
INSERT INTO session_history (license_id, hardware_id, ip_address, client_version, started_at, ended_at, end_reason)
VALUES (:license_id, :hardware_id, :ip_address, :version, :started, CURRENT_TIMESTAMP, 'normal')
INSERT INTO session_history (license_id, hardware_fingerprint, machine_name, ip_address, client_version, started_at, ended_at, end_reason)
VALUES (:license_id, :hardware_fingerprint, :machine_name, :ip_address, :version, :started, CURRENT_TIMESTAMP, 'normal')
"""),
{
"license_id": session_info.license_id,
"hardware_id": session_info.hardware_id,
"hardware_fingerprint": session_info.hardware_fingerprint,
"machine_name": session_info.machine_name if session_info.machine_name else None,
"ip_address": session_info.ip_address,
"version": session_info.client_version,
"started": session_info.started_at
}
)
# Delete the session
# Mark session as ended instead of deleting it
db.execute(
text("DELETE FROM license_sessions WHERE session_token = :token"),
text("UPDATE license_sessions SET ended_at = CURRENT_TIMESTAMP, end_reason = 'normal' WHERE session_token = :token"),
{"token": request.session_token}
)

Datei anzeigen

@@ -11,10 +11,25 @@ logger = logging.getLogger(__name__)
async def validate_api_key(request: Request, db: Session = Depends(get_db)):
"""Validate API key from X-API-Key header against system_api_key table"""
# Debug: Log all headers
headers_dict = dict(request.headers)
# logger.warning(f"DEBUG: All headers: {headers_dict}")
# logger.warning(f"DEBUG: Header keys: {list(headers_dict.keys())}")
# Try different variations
api_key = request.headers.get("X-API-Key")
if not api_key:
api_key = request.headers.get("x-api-key")
if not api_key:
# Try case-insensitive search
for key, value in headers_dict.items():
if key.lower() == "x-api-key":
api_key = value
logger.warning(f"DEBUG: Found API key under header: {key}")
break
if not api_key:
logger.warning("API request without API key")
logger.warning(f"API request without API key. Headers: {list(request.headers.keys())}")
raise HTTPException(
status_code=401,
detail="API key required",
@@ -22,13 +37,43 @@ async def validate_api_key(request: Request, db: Session = Depends(get_db)):
)
# Query the system API key
cursor = db.execute(
text("SELECT api_key FROM system_api_key WHERE id = 1")
)
result = cursor.fetchone()
try:
cursor = db.execute(
text("SELECT api_key FROM system_api_key WHERE id = 1")
)
result = cursor.fetchone()
# logger.warning(f"DEBUG: DB query result: {result}")
except Exception as e:
logger.error(f"Database query error: {str(e)}")
raise HTTPException(
status_code=500,
detail="Database error"
)
if not result or result[0] != api_key:
logger.warning(f"Invalid API key attempt: {api_key[:8]}...")
if not result:
logger.warning(f"No API key found in database")
raise HTTPException(
status_code=401,
detail="Invalid API key"
)
logger.warning(f"DEBUG: Found API key in request: {api_key}")
logger.warning(f"DEBUG: API key from DB: {result[0]}")
logger.warning(f"DEBUG: API key match: {result[0] == api_key}")
logger.warning(f"DEBUG: Types - DB: {type(result[0])}, Request: {type(api_key)}")
if result[0] != api_key:
logger.warning(f"API key mismatch!")
logger.warning(f"Expected (DB): '{result[0]}'")
logger.warning(f"Got (Request): '{api_key}'")
logger.warning(f"API key lengths - DB: {len(result[0])}, Request: {len(api_key)}")
# Character by character comparison
for i, (c1, c2) in enumerate(zip(result[0], api_key)):
if c1 != c2:
logger.warning(f"First difference at position {i}: DB='{c1}' (ord={ord(c1)}), Request='{c2}' (ord={ord(c2)})")
break
raise HTTPException(
status_code=401,
detail="Invalid API key"

Datei anzeigen

@@ -10,7 +10,7 @@ class Settings(BaseSettings):
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
DATABASE_URL: str = "postgresql://license_user:license_password@db:5432/license_db"
DATABASE_URL: str = "postgresql://adminuser:supergeheimespasswort@postgres:5432/meinedatenbank"
ALLOWED_ORIGINS: List[str] = [

Datei anzeigen

@@ -0,0 +1,116 @@
"""
Background scheduler for License Server
Handles periodic tasks like session cleanup
"""
import logging
from datetime import datetime, timedelta
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.interval import IntervalTrigger
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.db.database import SessionLocal
logger = logging.getLogger(__name__)
scheduler = BackgroundScheduler()
def cleanup_expired_sessions():
"""Clean up sessions that haven't sent heartbeat within timeout period"""
db: Session = SessionLocal()
try:
# Get session timeout from config (default 60 seconds)
result = db.execute(text("""
SELECT session_timeout
FROM client_configs
WHERE client_name = 'Account Forger'
""")).first()
timeout_seconds = result[0] if result else 60
# Find expired sessions
expired_sessions = db.execute(text(f"""
SELECT id, license_id, hardware_fingerprint, session_token
FROM license_sessions
WHERE ended_at IS NULL
AND last_heartbeat < NOW() - INTERVAL '{timeout_seconds} seconds'
""")).fetchall()
if expired_sessions:
logger.info(f"Found {len(expired_sessions)} expired sessions to clean up")
# Mark sessions as ended
for session in expired_sessions:
db.execute(text("""
UPDATE license_sessions
SET ended_at = NOW(), end_reason = 'timeout'
WHERE id = :session_id
"""), {"session_id": session[0]})
logger.info(f"Ended session {session[0]} for license {session[1]} due to timeout")
db.commit()
logger.info(f"Successfully cleaned up {len(expired_sessions)} expired sessions")
except Exception as e:
logger.error(f"Error cleaning up sessions: {str(e)}")
db.rollback()
finally:
db.close()
def cleanup_old_sessions():
"""Remove old ended sessions from database (older than 30 days)"""
db: Session = SessionLocal()
try:
result = db.execute(text("""
DELETE FROM license_sessions
WHERE ended_at IS NOT NULL
AND ended_at < NOW() - INTERVAL '30 days'
"""))
if result.rowcount > 0:
db.commit()
logger.info(f"Cleaned up {result.rowcount} old sessions")
except Exception as e:
logger.error(f"Error cleaning up old sessions: {str(e)}")
db.rollback()
finally:
db.close()
def init_scheduler():
"""Initialize and start the background scheduler"""
# Add job to cleanup expired sessions every 30 seconds
scheduler.add_job(
func=cleanup_expired_sessions,
trigger=IntervalTrigger(seconds=30),
id='cleanup_expired_sessions',
name='Cleanup expired sessions',
replace_existing=True
)
# Add job to cleanup old sessions daily at 3 AM
scheduler.add_job(
func=cleanup_old_sessions,
trigger='cron',
hour=3,
minute=0,
id='cleanup_old_sessions',
name='Cleanup old sessions',
replace_existing=True
)
scheduler.start()
logger.info("Background scheduler started")
logger.info("- Session cleanup runs every 30 seconds")
logger.info("- Old session cleanup runs daily at 3:00 AM")
def shutdown_scheduler():
"""Shutdown the scheduler gracefully"""
if scheduler.running:
scheduler.shutdown()
logger.info("Background scheduler stopped")

Datei anzeigen

@@ -9,6 +9,7 @@ from prometheus_client import generate_latest, CONTENT_TYPE_LATEST
from app.api import license, version
from app.core.config import settings
from app.core.metrics import init_metrics, track_request_metrics
from app.core.scheduler import init_scheduler, shutdown_scheduler
from app.db.database import engine, Base
logging.basicConfig(level=logging.INFO)
@@ -19,6 +20,9 @@ Base.metadata.create_all(bind=engine)
# Initialize metrics
init_metrics(version="1.0.0")
# Initialize scheduler
init_scheduler()
app = FastAPI(
title="License Server API",
description="API for software license management",
@@ -66,6 +70,23 @@ async def metrics():
app.include_router(license.router, prefix="/api/license", tags=["license"])
app.include_router(version.router, prefix="/api/version", tags=["version"])
@app.post("/debug/headers")
async def debug_headers(request: Request):
"""Debug endpoint to check headers"""
headers = dict(request.headers)
return {
"headers": headers,
"x-api-key": headers.get("x-api-key"),
"X-API-Key": headers.get("X-API-Key"),
"all_keys": list(headers.keys())
}
@app.on_event("shutdown")
async def shutdown_event():
"""Cleanup on shutdown"""
logger.info("Shutting down scheduler...")
shutdown_scheduler()
if __name__ == "__main__":
uvicorn.run(
"app.main:app",

Datei anzeigen

@@ -1 +1 @@
from .models import License, Activation, Version, ApiKey
from .models import License, DeviceRegistration, LicenseSession, Version, ApiKey

Datei anzeigen

@@ -13,31 +13,64 @@ class License(Base):
customer_email = Column(String, nullable=False)
customer_name = Column(String)
max_activations = Column(Integer, default=1)
device_limit = Column(Integer, default=3)
concurrent_sessions_limit = Column(Integer, default=1)
is_active = Column(Boolean, default=True)
expires_at = Column(DateTime, nullable=True)
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, onupdate=func.now())
activations = relationship("Activation", back_populates="license")
device_registrations = relationship("DeviceRegistration", back_populates="license")
sessions = relationship("LicenseSession", back_populates="license")
class Activation(Base):
__tablename__ = "activations"
class DeviceRegistration(Base):
__tablename__ = "device_registrations"
id = Column(Integer, primary_key=True, index=True)
license_id = Column(Integer, ForeignKey("licenses.id"))
machine_id = Column(String, nullable=False)
hardware_hash = Column(String, nullable=False)
hardware_fingerprint = Column(String, nullable=False)
device_name = Column(String, nullable=False)
device_type = Column(String, default="unknown")
operating_system = Column(String)
activation_date = Column(DateTime, server_default=func.now())
last_heartbeat = Column(DateTime, server_default=func.now())
first_activated_at = Column(DateTime, server_default=func.now())
last_seen_at = Column(DateTime, server_default=func.now())
is_active = Column(Boolean, default=True)
os_info = Column(JSON)
app_version = Column(String)
deactivated_at = Column(DateTime, nullable=True)
deactivated_by = Column(String, nullable=True)
license = relationship("License", back_populates="activations")
ip_address = Column(String)
user_agent = Column(String)
app_version = Column(String)
device_metadata = Column("metadata", JSON, default={})
license = relationship("License", back_populates="device_registrations")
sessions = relationship("LicenseSession", back_populates="device_registration")
class LicenseSession(Base):
__tablename__ = "license_sessions"
id = Column(Integer, primary_key=True, index=True)
license_id = Column(Integer, ForeignKey("licenses.id"))
device_registration_id = Column(Integer, ForeignKey("device_registrations.id"))
session_token = Column(String, unique=True, nullable=False)
hardware_fingerprint = Column(String, nullable=False)
started_at = Column(DateTime, server_default=func.now())
last_heartbeat = Column(DateTime, server_default=func.now())
ended_at = Column(DateTime, nullable=True)
end_reason = Column(String, nullable=True)
ip_address = Column(String)
client_version = Column(String)
user_agent = Column(String)
license = relationship("License", back_populates="sessions")
device_registration = relationship("DeviceRegistration", back_populates="sessions")
class Version(Base):
__tablename__ = "versions"

Datei anzeigen

@@ -1,11 +1,11 @@
from pydantic import BaseModel, EmailStr
from pydantic import BaseModel, EmailStr, field_validator, model_validator
from datetime import datetime
from typing import Optional, Dict, Any
class LicenseActivationRequest(BaseModel):
license_key: str
machine_id: str
hardware_hash: str
machine_name: str
hardware_fingerprint: str
os_info: Optional[Dict[str, Any]] = None
app_version: Optional[str] = None
@@ -18,8 +18,8 @@ class LicenseActivationResponse(BaseModel):
class LicenseVerificationRequest(BaseModel):
license_key: str
machine_id: str
hardware_hash: str
machine_name: str
hardware_fingerprint: str
activation_id: int
class LicenseVerificationResponse(BaseModel):
@@ -44,9 +44,8 @@ class VersionCheckResponse(BaseModel):
class SessionStartRequest(BaseModel):
license_key: str
machine_id: str
hardware_id: str
hardware_hash: str
machine_name: str
hardware_fingerprint: str
version: str
ip_address: Optional[str] = None