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
|
*.pyc
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
config/authorized_devices.json
|
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