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:
37
.dockerignore
Normal file
37
.dockerignore
Normal 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
16
.env.example
Normal 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
1
.gitignore
vendored
@@ -4,3 +4,4 @@ __pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
config/authorized_devices.json
|
||||
venv/
|
||||
|
||||
26
Dockerfile
Normal file
26
Dockerfile
Normal 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
254
README.md
Normal 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.
|
||||
30
bruno/Config - Authorized Device.bru
Normal file
30
bruno/Config - Authorized Device.bru
Normal 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.
|
||||
}
|
||||
23
bruno/Config - Missing Header.bru
Normal file
23
bruno/Config - Missing Header.bru
Normal 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.
|
||||
}
|
||||
28
bruno/Config - Unauthorized Device.bru
Normal file
28
bruno/Config - Unauthorized Device.bru
Normal 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
20
bruno/Health Check.bru
Normal 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
5
bruno/bruno.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"version": "1",
|
||||
"name": "Subspace TV Config API",
|
||||
"type": "collection"
|
||||
}
|
||||
3
bruno/environments/Local.bru
Normal file
3
bruno/environments/Local.bru
Normal file
@@ -0,0 +1,3 @@
|
||||
vars {
|
||||
baseUrl: http://localhost:8000
|
||||
}
|
||||
3
bruno/environments/Production.bru
Normal file
3
bruno/environments/Production.bru
Normal file
@@ -0,0 +1,3 @@
|
||||
vars {
|
||||
baseUrl: https://your-production-server.com:8000
|
||||
}
|
||||
60
deploy.sh
Executable file
60
deploy.sh
Executable 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
16
docker-compose.yml
Normal 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
59
manage.sh
Executable 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
5
requirements.txt
Normal 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
97
setup-unraid.sh
Executable 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
1
src/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Subspace TV Configuration Server."""
|
||||
67
src/auth.py
Normal file
67
src/auth.py
Normal 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
88
src/config.py
Normal 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
65
src/main.py
Normal 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": [],
|
||||
}
|
||||
Reference in New Issue
Block a user