diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9cd4e5a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,37 @@ +# Environment files +.env +.env.local +.env.* + +# Python artifacts +__pycache__/ +*.pyc +*.pyo +*.pyd +.pytest_cache/ + +# Git +.git/ +.gitignore + +# Virtual environment +venv/ + +# Documentation +README.md + +# IDE configs +.vscode/ +.idea/ + +# API testing +bruno/ + +# Logs +*.log + +# Claude +.claude/ + +# Config (managed separately) +config/authorized_devices.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0901c42 --- /dev/null +++ b/.env.example @@ -0,0 +1,16 @@ +# Server +HOST=0.0.0.0 +PORT=8000 +DEBUG=false + +# Authorization file path +AUTHORIZATION_FILE=/app/config/authorized_devices.json + +# Privileged Configuration (returned to authorized devices) +CONFIG_URL=https://your-server.example.com +CONFIG_PORT=8000 +CONFIG_TIMEZONE=America/New_York +CONFIG_UPDATE_INTERVAL=3600 + +# CORS +CORS_ORIGINS=["*"] diff --git a/.gitignore b/.gitignore index 0e419ef..3b1d983 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ __pycache__/ *.pyc .pytest_cache/ config/authorized_devices.json +venv/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0f9dba7 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,26 @@ +# Use official Python runtime as base image +FROM python:3.11-slim + +# Set working directory in container +WORKDIR /app + +# Set environment variables +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 + +# Install dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application code +COPY src/ ./src/ +COPY config/ ./config/ + +# Create non-root user for security +RUN useradd -m -u 1000 appuser && \ + chown -R appuser:appuser /app + +USER appuser + +# Run the application +CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..d60eb4a --- /dev/null +++ b/README.md @@ -0,0 +1,254 @@ +# Subspace TV Configuration Server + +A Docker-hosted FastAPI service that serves JSON configuration to tvOS clients. Authorized devices receive privileged configuration while unauthorized devices receive blank configuration. + +## Features + +- Device-based authorization via custom header (`X-Device-ID`) +- Hot-reload of authorization list without service restart +- Docker containerization with non-root user +- CORS support for web-based clients +- Health check endpoint for monitoring + +## Architecture + +``` +subspace-tv/ +├── src/ +│ ├── __init__.py +│ ├── config.py # Pydantic settings management +│ ├── auth.py # Authorization logic with hot-reload +│ └── main.py # FastAPI application +├── bruno/ +│ ├── environments/ # Bruno environment configurations +│ └── *.bru # Bruno API test requests +├── config/ +│ └── authorized_devices.json # Device authorization list +├── .env.example # Environment variable template +├── Dockerfile # Container image definition +├── docker-compose.yml # Service orchestration +└── requirements.txt # Python dependencies +``` + +## Local Development + +### Prerequisites + +- Python 3.11+ +- pip + +### Setup + +1. Clone the repository and navigate to the project directory + +2. Install dependencies: + ```bash + pip install -r requirements.txt + ``` + +3. Create environment file: + ```bash + cp .env.example .env + ``` + +4. Edit `.env` with your configuration values + +5. Run the development server: + ```bash + uvicorn src.main:app --reload + ``` + +6. Access the API at `http://localhost:8000` + +## Docker Deployment + +### Prerequisites + +- Docker +- Docker Compose + +### Setup + +1. Create environment file: + ```bash + cp .env.example .env + ``` + +2. Configure `.env` with production values: + ```env + CONFIG_URL=https://your-production-server.com + CONFIG_PORT=8000 + CONFIG_TIMEZONE=America/New_York + CONFIG_UPDATE_INTERVAL=3600 + ``` + +3. Configure authorized devices in `config/authorized_devices.json`: + ```json + { + "authorized_devices": [ + "tvos-device-001", + "tvos-device-002" + ] + } + ``` + +4. Build and launch the container: + ```bash + docker-compose up -d + ``` + +5. Verify the service is running: + ```bash + curl http://localhost:8000/health + ``` + +## API Endpoints + +### Health Check + +**GET** `/health` + +Returns service health status. + +**Response:** +```json +{ + "status": "healthy" +} +``` + +### Get Configuration + +**GET** `/api/v1/config` + +**Headers:** +- `X-Device-ID` (required): Device identifier + +**Response (Authorized):** +```json +{ + "authorized": true, + "config": { + "url": "https://your-server.example.com", + "port": 8000, + "timezone": "America/New_York", + "update_interval": 3600 + } +} +``` + +**Response (Unauthorized):** +```json +{ + "authorized": false, + "config": {} +} +``` + +**Error Response (Missing Header):** +```json +{ + "detail": "X-Device-ID header is required" +} +``` +Status: `400 Bad Request` + +## Usage Examples + +### Test with curl (Authorized Device) + +```bash +curl -H "X-Device-ID: device-example-001" http://localhost:8000/api/v1/config +``` + +### Test with curl (Unauthorized Device) + +```bash +curl -H "X-Device-ID: unknown-device" http://localhost:8000/api/v1/config +``` + +### Test with Bruno + +A Bruno API collection is available in the `bruno/` directory for comprehensive API testing. + +**Prerequisites:** +- Install [Bruno](https://www.usebruno.com/) + +**Usage:** +1. Open Bruno +2. Open Collection → Select the `bruno/` directory in this project +3. Select environment (Local or Production) +4. Run individual requests or entire collection + +**Available Requests:** +- `Health Check` - Verify service status +- `Config - Authorized Device` - Test authorized device response +- `Config - Unauthorized Device` - Test unauthorized device response +- `Config - Missing Header` - Test error handling for missing header + +All requests include assertions to validate expected responses. + +## Authorization Management + +### Adding Authorized Devices + +Edit `config/authorized_devices.json` and add device IDs: + +```json +{ + "authorized_devices": [ + "tvos-device-001", + "tvos-device-002", + "new-device-id" + ] +} +``` + +The authorization list is automatically reloaded when the file is modified. No service restart required. + +### Removing Authorized Devices + +Remove device IDs from `config/authorized_devices.json`. Changes take effect immediately. + +## Configuration Reference + +### Environment Variables + +| Variable | Description | Default | +|----------|-------------|---------| +| `HOST` | Server bind address | `0.0.0.0` | +| `PORT` | Server port | `8000` | +| `DEBUG` | Debug mode | `false` | +| `AUTHORIZATION_FILE` | Path to authorization JSON | `/app/config/authorized_devices.json` | +| `CONFIG_URL` | URL returned to authorized devices | `https://your-server.example.com` | +| `CONFIG_PORT` | Port returned to authorized devices | `8000` | +| `CONFIG_TIMEZONE` | Timezone for authorized devices | `America/New_York` | +| `CONFIG_UPDATE_INTERVAL` | Update interval in seconds | `3600` | +| `CORS_ORIGINS` | Allowed CORS origins | `["*"]` | + +## Security Considerations + +- The service runs as a non-root user (`appuser`) in the Docker container +- Authorization file is mounted from the host filesystem +- Privileged configuration data is stored in environment variables +- No sensitive data is returned to unauthorized devices +- Health check endpoint is publicly accessible for orchestration + +## Troubleshooting + +### Service won't start + +Check Docker logs: +```bash +docker-compose logs -f +``` + +### Authorization not working + +1. Verify `config/authorized_devices.json` format is valid JSON +2. Check that device ID matches exactly (case-sensitive) +3. Confirm file permissions allow reading by container user + +### Hot-reload not working + +The authorization file is checked for modifications on each request. Ensure the file modification timestamp changes when editing. diff --git a/bruno/Config - Authorized Device.bru b/bruno/Config - Authorized Device.bru new file mode 100644 index 0000000..a98cefb --- /dev/null +++ b/bruno/Config - Authorized Device.bru @@ -0,0 +1,30 @@ +meta { + name: Config - Authorized Device + type: http + seq: 2 +} + +get { + url: {{baseUrl}}/api/v1/config + body: none + auth: none +} + +headers { + X-Password: 1 +} + +assert { + res.status: eq 200 + res.body.authorized: eq true + res.body.config.url: isDefined + res.body.config.username: isDefined + res.body.config.password: isDefined +} + +docs { + Get configuration for an authorized password. + + This request uses password "1" which is in the authorized_devices.json list. + Expected response includes full configuration with url, username, and password. +} diff --git a/bruno/Config - Missing Header.bru b/bruno/Config - Missing Header.bru new file mode 100644 index 0000000..929d4d4 --- /dev/null +++ b/bruno/Config - Missing Header.bru @@ -0,0 +1,23 @@ +meta { + name: Config - Missing Header + type: http + seq: 4 +} + +get { + url: {{baseUrl}}/api/v1/config + body: none + auth: none +} + +assert { + res.status: eq 400 + res.body.detail: eq X-Password header is required +} + +docs { + Attempt to get configuration without X-Password header. + + This request intentionally omits the required X-Password header. + Expected response is 400 Bad Request with error message. +} diff --git a/bruno/Config - Unauthorized Device.bru b/bruno/Config - Unauthorized Device.bru new file mode 100644 index 0000000..7661585 --- /dev/null +++ b/bruno/Config - Unauthorized Device.bru @@ -0,0 +1,28 @@ +meta { + name: Config - Unauthorized Device + type: http + seq: 3 +} + +get { + url: {{baseUrl}}/api/v1/config + body: none + auth: none +} + +headers { + X-Password: invalid-password +} + +assert { + res.status: eq 200 + res.body.authorized: eq false + res.body.config: isEmpty +} + +docs { + Get configuration for an unauthorized password. + + This request uses a password that is NOT in the authorized_devices.json list. + Expected response shows authorized: false with empty config object. +} diff --git a/bruno/Health Check.bru b/bruno/Health Check.bru new file mode 100644 index 0000000..8c0384f --- /dev/null +++ b/bruno/Health Check.bru @@ -0,0 +1,20 @@ +meta { + name: Health Check + type: http + seq: 1 +} + +get { + url: {{baseUrl}}/health + body: none + auth: none +} + +assert { + res.status: eq 200 + res.body.status: eq healthy +} + +docs { + Health check endpoint to verify service is running. +} diff --git a/bruno/bruno.json b/bruno/bruno.json new file mode 100644 index 0000000..f98dc99 --- /dev/null +++ b/bruno/bruno.json @@ -0,0 +1,5 @@ +{ + "version": "1", + "name": "Subspace TV Config API", + "type": "collection" +} diff --git a/bruno/environments/Local.bru b/bruno/environments/Local.bru new file mode 100644 index 0000000..2de9da6 --- /dev/null +++ b/bruno/environments/Local.bru @@ -0,0 +1,3 @@ +vars { + baseUrl: http://localhost:8000 +} diff --git a/bruno/environments/Production.bru b/bruno/environments/Production.bru new file mode 100644 index 0000000..1bf8736 --- /dev/null +++ b/bruno/environments/Production.bru @@ -0,0 +1,3 @@ +vars { + baseUrl: https://your-production-server.com:8000 +} diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..9fd787d --- /dev/null +++ b/deploy.sh @@ -0,0 +1,60 @@ +#!/bin/bash +set -e + +# Configuration +UNRAID_HOST="root@192.168.2.61" +REMOTE_PATH="/boot/config/plugins/compose.manager/projects/subspace-tv" +GIT_HOST="git.michaelsimard.ca" +GIT_PORT="28" +GIT_REPO="git@git.michaelsimard.ca:msimard/subspace-tv.git" + +echo "=== Subspace TV - Deploy ===" +echo "" + +echo "Connecting to Unraid and deploying..." +ssh "$UNRAID_HOST" bash </dev/null; then + echo "ERROR: SSH not configured for git server" + echo "Run ./setup-unraid.sh first" + exit 1 +fi + +# Clone or pull repository +if [ ! -d ".git" ]; then + echo "Initial clone..." + cd .. + rm -rf subspace-tv + git clone "$GIT_REPO" subspace-tv + cd subspace-tv +else + echo "Pulling latest changes..." + git fetch origin + git reset --hard origin/main +fi + +# Fix permissions for /boot partition (FAT32) +echo "Fixing file permissions..." +chmod +x *.sh 2>/dev/null || true + +# Validate .env +if [ ! -f ".env" ]; then + echo "ERROR: .env file missing" + echo "Run ./setup-unraid.sh to copy .env" + exit 1 +fi + +# Build and deploy +echo "Building and starting container..." +docker compose up -d --build + +echo "" +echo "Deployment complete" +EOF + +echo "" +echo "=== Deploy Complete ===" +./manage.sh status diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a6696b5 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,16 @@ +services: + subspace-tv-config: + build: . + container_name: subspace-tv-config + restart: unless-stopped + ports: + - "8000:8000" + env_file: + - .env + volumes: + - ./config:/app/config + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" diff --git a/manage.sh b/manage.sh new file mode 100755 index 0000000..286b294 --- /dev/null +++ b/manage.sh @@ -0,0 +1,59 @@ +#!/bin/bash + +# Configuration +UNRAID_HOST="root@192.168.2.61" +REMOTE_PATH="/boot/config/plugins/compose.manager/projects/subspace-tv" + +usage() { + echo "Usage: $0 " + echo "" + echo "Commands:" + echo " logs - View live container logs" + echo " status - Show container status" + echo " restart - Restart container" + echo " stop - Stop container" + echo " start - Start container" + echo " rebuild - Full rebuild and restart" + echo " shell - SSH to Unraid at project directory" + exit 1 +} + +remote_cmd() { + ssh "$UNRAID_HOST" "cd $REMOTE_PATH && $1" +} + +case "$1" in + logs) + ssh -t "$UNRAID_HOST" "cd $REMOTE_PATH && docker compose logs -f" + ;; + status) + echo "=== Container Status ===" + remote_cmd "docker compose ps" + ;; + restart) + echo "Restarting container..." + remote_cmd "docker compose restart" + echo "Done" + ;; + stop) + echo "Stopping container..." + remote_cmd "docker compose down" + echo "Done" + ;; + start) + echo "Starting container..." + remote_cmd "docker compose up -d" + echo "Done" + ;; + rebuild) + echo "Rebuilding container..." + remote_cmd "docker compose down && docker compose up -d --build" + echo "Done" + ;; + shell) + ssh -t "$UNRAID_HOST" "cd $REMOTE_PATH && bash" + ;; + *) + usage + ;; +esac diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..6267f3d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +fastapi==0.115.5 +uvicorn[standard]==0.32.1 +pydantic==2.10.3 +pydantic-settings==2.6.1 +python-dotenv==1.1.1 diff --git a/setup-unraid.sh b/setup-unraid.sh new file mode 100755 index 0000000..3fbfa3d --- /dev/null +++ b/setup-unraid.sh @@ -0,0 +1,97 @@ +#!/bin/bash +set -e + +# Configuration +UNRAID_HOST="root@192.168.2.61" +REMOTE_PATH="/boot/config/plugins/compose.manager/projects/subspace-tv" +GIT_HOST="git.michaelsimard.ca" +GIT_PORT="28" +GIT_REPO="git@git.michaelsimard.ca:msimard/subspace-tv.git" + +echo "=== Subspace TV - Unraid Setup ===" +echo "" + +# Check local .env exists +if [ ! -f ".env" ]; then + echo "ERROR: Local .env file not found" + echo "Copy .env.example to .env and configure it first" + exit 1 +fi + +echo "Step 1: Checking SSH connectivity to Unraid..." +if ! ssh -o ConnectTimeout=5 "$UNRAID_HOST" "echo 'Connected'" 2>/dev/null; then + echo "ERROR: Cannot connect to Unraid at $UNRAID_HOST" + echo "Ensure SSH is enabled and key-based auth is configured" + exit 1 +fi +echo " Connected to Unraid" + +echo "" +echo "Step 2: Configuring SSH for git server on Unraid..." +ssh "$UNRAID_HOST" bash </dev/null; then + echo " Adding git server to SSH config..." + cat >> ~/.ssh/config <&1 | grep -q 'successfully authenticated'"; then + echo " Git server accessible" +else + echo "" + echo "WARNING: Git authentication may not be configured" + echo "Ensure the Unraid SSH public key is added to Gitea" + echo "" + echo "To view the public key, run:" + echo " ssh $UNRAID_HOST 'cat ~/.ssh/id_ed25519.pub'" +fi + +echo "" +echo "=== Setup Complete ===" +echo "" +echo "Next steps:" +echo " 1. Ensure git push is up to date" +echo " 2. Run ./deploy.sh to deploy" diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..8d793af --- /dev/null +++ b/src/__init__.py @@ -0,0 +1 @@ +"""Subspace TV Configuration Server.""" diff --git a/src/auth.py b/src/auth.py new file mode 100644 index 0000000..c0db622 --- /dev/null +++ b/src/auth.py @@ -0,0 +1,67 @@ +"""Authorization management for device access control.""" +import json +import os +from pathlib import Path +from typing import Set + + +class AuthorizationManager: + """Manages device authorization with hot-reload capability.""" + + def __init__(self, file_path: str): + """Initialize authorization manager with file path. + + Args: + file_path: Path to the authorized_devices.json file + """ + self.file_path = Path(file_path) + self._authorized_devices: Set[str] = set() + self._last_modified: float = 0.0 + self._load_authorization_list() + + def _load_authorization_list(self) -> None: + """Load authorization list from JSON file.""" + try: + if not self.file_path.exists(): + self._authorized_devices = set() + self._last_modified = 0.0 + return + + stat = os.stat(self.file_path) + self._last_modified = stat.st_mtime + + with open(self.file_path, "r") as f: + data = json.load(f) + self._authorized_devices = set(data.get("authorized_devices", [])) + + except (json.JSONDecodeError, OSError) as e: + self._authorized_devices = set() + self._last_modified = 0.0 + + def _check_reload(self) -> None: + """Check if authorization file has been modified and reload if necessary.""" + try: + if not self.file_path.exists(): + if self._last_modified != 0.0: + self._authorized_devices = set() + self._last_modified = 0.0 + return + + stat = os.stat(self.file_path) + if stat.st_mtime > self._last_modified: + self._load_authorization_list() + + except OSError: + pass + + def is_authorized(self, device_id: str) -> bool: + """Check if a device ID is authorized. + + Args: + device_id: The device identifier to check + + Returns: + True if authorized, False otherwise + """ + self._check_reload() + return device_id in self._authorized_devices diff --git a/src/config.py b/src/config.py new file mode 100644 index 0000000..4e99ed7 --- /dev/null +++ b/src/config.py @@ -0,0 +1,88 @@ +"""Application configuration settings.""" +import json +from typing import Literal, TypedDict + +from pydantic_settings import BaseSettings, SettingsConfigDict + +ConfigType = Literal["vod", "live", "all"] + + +class ConfigItem(TypedDict): + """Configuration item structure.""" + + type: ConfigType + url: str + username: str + password: str + + +class Settings(BaseSettings): + """Application settings loaded from environment variables.""" + + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + case_sensitive=False, + ) + + # Server Configuration + host: str = "0.0.0.0" + port: int = 8000 + debug: bool = False + + # Authorization + authorization_file: str = "/app/config/authorized_devices.json" + + # Privileged Configuration (JSON array) + configs_json: str = "" + + # Legacy config fields for backwards compatibility + config_url: str = "https://your-server.example.com" + config_username: str = "" + config_password: str = "" + + # CORS + cors_origins: list[str] = ["*"] + + @property + def configs(self) -> list[ConfigItem]: + """Parse configs from JSON string or fall back to legacy fields.""" + if self.configs_json: + try: + parsed = json.loads(self.configs_json) + if isinstance(parsed, list): + return [ + ConfigItem( + type=item.get("type", "vod"), + url=item.get("url", ""), + username=item.get("username", ""), + password=item.get("password", ""), + ) + for item in parsed + ] + except json.JSONDecodeError: + pass + # Fall back to legacy single config as "vod" type + return [ + ConfigItem( + type="vod", + url=self.config_url, + username=self.config_username, + password=self.config_password, + ) + ] + + @property + def cors_origins_list(self) -> list[str]: + """Parse CORS origins from string if necessary.""" + if isinstance(self.cors_origins, str): + try: + return json.loads(self.cors_origins) + except json.JSONDecodeError: + return [self.cors_origins] + return self.cors_origins + + +def get_settings() -> Settings: + """Returns the application settings singleton.""" + return Settings() diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..02d4b81 --- /dev/null +++ b/src/main.py @@ -0,0 +1,65 @@ +"""FastAPI application entry point.""" +from fastapi import FastAPI, Header, HTTPException +from fastapi.middleware.cors import CORSMiddleware + +from src.auth import AuthorizationManager +from src.config import get_settings + +settings = get_settings() + +app = FastAPI( + title="Subspace TV Configuration Server", + version="1.0.0", + debug=settings.debug, +) + +# Configure CORS +app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origins_list, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Initialize authorization manager +auth_manager = AuthorizationManager(settings.authorization_file) + + +@app.get("/health") +async def health_check(): + """Health check endpoint.""" + return {"status": "healthy"} + + +@app.get("/api/v1/config") +async def get_config(x_password: str = Header(None)): + """Get device configuration based on authorization. + + Args: + x_password: Password from request header + + Returns: + Configuration object with authorization status and config data + + Raises: + HTTPException: If X-Password header is missing + """ + if x_password is None: + raise HTTPException( + status_code=400, + detail="X-Password header is required" + ) + + is_authorized = auth_manager.is_authorized(x_password) + + if is_authorized: + return { + "authorized": True, + "configs": settings.configs, + } + else: + return { + "authorized": False, + "configs": [], + }