Add subspace-tv FastAPI service with Unraid deployment

Includes FastAPI application with device authentication, Docker configuration,
Bruno API tests, and git-based deployment scripts for Unraid.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Michael Simard
2026-01-22 09:22:32 -06:00
parent 171d55bd4e
commit 57ad4b0f33
21 changed files with 904 additions and 0 deletions

37
.dockerignore Normal file
View File

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

16
.env.example Normal file
View File

@@ -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=["*"]

1
.gitignore vendored
View File

@@ -4,3 +4,4 @@ __pycache__/
*.pyc
.pytest_cache/
config/authorized_devices.json
venv/

26
Dockerfile Normal file
View File

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

254
README.md Normal file
View File

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

View File

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

View File

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

View File

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

20
bruno/Health Check.bru Normal file
View File

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

5
bruno/bruno.json Normal file
View File

@@ -0,0 +1,5 @@
{
"version": "1",
"name": "Subspace TV Config API",
"type": "collection"
}

View File

@@ -0,0 +1,3 @@
vars {
baseUrl: http://localhost:8000
}

View File

@@ -0,0 +1,3 @@
vars {
baseUrl: https://your-production-server.com:8000
}

60
deploy.sh Executable file
View File

@@ -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 <<EOF
set -e
cd "$REMOTE_PATH"
# Ensure SSH config exists for git server
if [ ! -f ~/.ssh/config ] || ! grep -q "Host $GIT_HOST" ~/.ssh/config 2>/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

16
docker-compose.yml Normal file
View File

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

59
manage.sh Executable file
View File

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

5
requirements.txt Normal file
View File

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

97
setup-unraid.sh Executable file
View File

@@ -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 <<EOF
# Create .ssh directory if needed
mkdir -p ~/.ssh
chmod 700 ~/.ssh
# Check if SSH key exists
if [ ! -f ~/.ssh/id_ed25519 ]; then
echo " Generating SSH key..."
ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519 -N "" -q
echo ""
echo "=== ACTION REQUIRED ==="
echo "Add this public key to Gitea:"
echo ""
cat ~/.ssh/id_ed25519.pub
echo ""
echo "========================"
else
echo " SSH key already exists"
fi
# Configure SSH for git server
if ! grep -q "Host $GIT_HOST" ~/.ssh/config 2>/dev/null; then
echo " Adding git server to SSH config..."
cat >> ~/.ssh/config <<SSHCONFIG
Host $GIT_HOST
HostName $GIT_HOST
Port $GIT_PORT
User git
IdentityFile ~/.ssh/id_ed25519
StrictHostKeyChecking accept-new
SSHCONFIG
chmod 600 ~/.ssh/config
else
echo " Git server already in SSH config"
fi
EOF
echo ""
echo "Step 3: Creating project directory on Unraid..."
ssh "$UNRAID_HOST" "mkdir -p $REMOTE_PATH"
echo " Directory ready: $REMOTE_PATH"
echo ""
echo "Step 4: Copying .env to Unraid..."
scp -q ".env" "$UNRAID_HOST:$REMOTE_PATH/.env"
echo " .env copied"
echo ""
echo "Step 5: Testing git connectivity from Unraid..."
if ssh "$UNRAID_HOST" "ssh -o ConnectTimeout=10 -p $GIT_PORT git@$GIT_HOST 2>&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"

1
src/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Subspace TV Configuration Server."""

67
src/auth.py Normal file
View File

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

88
src/config.py Normal file
View File

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

65
src/main.py Normal file
View File

@@ -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": [],
}