From fe16dcf1eda41606f0dd8d333565ce55afb4a660 Mon Sep 17 00:00:00 2001 From: Brian Olson Date: Wed, 31 Dec 2025 21:20:51 -0500 Subject: [PATCH] feat: add local auth, proxy auth, health checks, and improved docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR adds several improvements for self-hosted deployments: ## Local Username/Password Authentication (#116) - Add LOCAL_AUTH_ENABLED and LOCAL_AUTH_REGISTRATION_ENABLED settings - Add /api/1/auth/local/login endpoint for username/password login - Add /api/1/auth/local/register endpoint for self-registration - Add /api/1/auth/change-password endpoint for password changes - Add /api/1/auth/status endpoint to show available auth methods ## Proxy Authentication (#116) - Add PROXY_AUTH_ENABLED setting for reverse proxy auth - Add ProxyAuthMiddleware to trust X-Auth-* headers - Supports Authentik, Authelia, Traefik ForwardAuth, etc. ## Health Check Endpoints - Add /health endpoint for basic health checks (load balancers) - Add /ready endpoint for readiness checks (DB connectivity) - Add /live endpoint for liveness checks (uptime info) ## Version Endpoint - Add /api/1/version endpoint exposing version and build info ## Improved Error Messages (#125, #124, #119) - Document ALLOWED_HOSTS fix for "Invalid HTTP_HOST header" error - Document OAuth callback URL requirements - Document database migration commands - Add troubleshooting section to README ## Docker Compose Improvements - Add docker-compose.prebuilt.yml for quick deployment - Add healthcheck configuration - Document all authentication options 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- README.md | 118 ++++++++++++-- backend/tabby/app/api/__init__.py | 19 ++- backend/tabby/app/api/auth.py | 255 +++++++++++++++++++++++++++++- backend/tabby/app/api/system.py | 123 ++++++++++++++ backend/tabby/middleware.py | 88 ++++++++++- backend/tabby/settings.py | 28 +++- docker-compose.prebuilt.yml | 65 ++++++++ 7 files changed, 681 insertions(+), 15 deletions(-) create mode 100644 backend/tabby/app/api/system.py create mode 100644 docker-compose.prebuilt.yml diff --git a/README.md b/README.md index 68afa4d..e6be2e6 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ ## Note on project status -> [!IMPORTANT] +> [!IMPORTANT] > At this time I don't have the time to work on `tabby-web` and won't be able to provide help or support for it. I'm still happy to merge any fixes/improvement PRs. :v: @@ -24,30 +24,104 @@ Tabby Web serves the [Tabby Terminal](https://github.com/Eugeny/tabby) as a web You'll need: -* OAuth credentials from GitHub, GitLab, Google or Microsoft for authentication. -* For SSH and Telnet: a [`tabby-connection-gateway`](https://github.com/Eugeny/tabby-connection-gateway) to forward traffic. -* Docker BuildKit: `export DOCKER_BUILDKIT=1` +* **For OAuth login**: Credentials from GitHub, GitLab, Google, Microsoft, or Auth0. +* **For local login**: Just set `LOCAL_AUTH_ENABLED=true` (no OAuth required!) +* **For SSH and Telnet**: a [`tabby-connection-gateway`](https://github.com/Eugeny/tabby-connection-gateway) to forward traffic. + +## Option 1: Pre-built Image (Recommended) + +Use the pre-built image from GitHub Container Registry - no build required: + +```bash +docker-compose -f docker-compose.prebuilt.yml up -d +``` + +The image is available at `ghcr.io/eugeny/tabby-web:latest`. + +## Option 2: Build from Source + +If you need to customize the build: ```bash - docker-compose up -e SOCIAL_AUTH_GITHUB_KEY=xxx -e SOCIAL_AUTH_GITHUB_SECRET=yyy +export DOCKER_BUILDKIT=1 +docker-compose up -d ``` -will start Tabby Web on port 9090 with MariaDB as a storage backend. +Both options will start Tabby Web on port 9090 with MariaDB as a storage backend. For SSH and Telnet, once logged in, enter your connection gateway address and auth token in the settings. ## Environment variables -* `DATABASE_URL` (required). -* `APP_DIST_STORAGE`: a `file://`, `s3://`, or `gcs://` URL to store app distros in. -* `SOCIAL_AUTH_*_KEY` & `SOCIAL_AUTH_*_SECRET`: social login credentials, supported providers are `GITHUB`, `GITLAB`, `MICROSOFT_GRAPH` and `GOOGLE_OAUTH2`. +### Core Settings + +| Variable | Required | Default | Description | +|----------|----------|---------|-------------| +| `DATABASE_URL` | Yes | - | Database connection URL (e.g., `mysql://user:pass@host/db`) | +| `APP_DIST_STORAGE` | No | `file://./app-dist` | Storage for app distributions (`file://`, `s3://`, `gcs://`) | +| `ALLOWED_HOSTS` | No | `*` | Comma-separated list of allowed hostnames | +| `DEBUG` | No | `false` | Enable debug mode (not for production) | + +### Authentication Options + +Tabby Web supports multiple authentication methods. You can enable any combination: + +#### Local Username/Password (No OAuth Required!) + +| Variable | Default | Description | +|----------|---------|-------------| +| `LOCAL_AUTH_ENABLED` | `false` | Enable username/password login | +| `LOCAL_AUTH_REGISTRATION_ENABLED` | `false` | Allow self-registration (requires `LOCAL_AUTH_ENABLED`) | + +#### Proxy Authentication (Authentik, Authelia, etc.) + +| Variable | Default | Description | +|----------|---------|-------------| +| `PROXY_AUTH_ENABLED` | `false` | Trust `X-Auth-*` headers from reverse proxy | + +When enabled, the app trusts these headers: +- `X-Auth-User-Email` (required) +- `X-Auth-User-Name` (optional) + +#### OAuth Providers + +| Variable | Description | +|----------|-------------| +| `SOCIAL_AUTH_GITHUB_KEY` / `SECRET` | GitHub OAuth | +| `SOCIAL_AUTH_GITLAB_KEY` / `SECRET` | GitLab OAuth | +| `SOCIAL_AUTH_GOOGLE_OAUTH2_KEY` / `SECRET` | Google OAuth | +| `SOCIAL_AUTH_MICROSOFT_GRAPH_KEY` / `SECRET` | Microsoft OAuth | +| `SOCIAL_AUTH_AUTH0_DOMAIN` / `KEY` / `SECRET` | Auth0 | +| `SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_KEY` / `SECRET` / `TENANT_ID` | Azure AD (single tenant) | + +### API Endpoints + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/health` | GET | Basic health check (load balancer probes) | +| `/ready` | GET | Readiness check (database connectivity) | +| `/live` | GET | Liveness check (uptime) | +| `/api/1/version` | GET | Version and build info | +| `/api/1/auth/status` | GET | Auth status and available providers | +| `/api/1/auth/local/login` | POST | Local username/password login | +| `/api/1/auth/local/register` | POST | Self-registration (if enabled) | ## Adding Tabby app versions -* `docker-compose run tabby /manage.sh add_version 1.0.163` +```bash +docker-compose run tabby /manage.sh add_version 1.0.163 +``` You can find the available version numbers [here](https://www.npmjs.com/package/tabby-web-container). +## Creating a Local Admin User + +If using local authentication, create an admin user: + +```bash +docker-compose exec tabby python /app/manage.py createsuperuser +``` + # Development setup Put your environment vars (`DATABASE_URL`, etc.) in the `.env` file in the root of the repo. @@ -70,7 +144,31 @@ poetry install PORT=9000 poetry run gunicorn # optionally with --reload ``` +# Troubleshooting + +## "Invalid HTTP_HOST header" error + +Set the `ALLOWED_HOSTS` environment variable to include your domain: +``` +ALLOWED_HOSTS=tabby.example.com,localhost +``` + +## "The redirect_uri MUST match" error + +Ensure your OAuth callback URL matches exactly. For GitHub, use: +``` +https://your-domain.com/complete/github/ +``` + +## "Table doesn't exist" error + +Run database migrations: +```bash +docker-compose exec tabby python /app/manage.py migrate +``` + # Security * When using Tabby Web for SSH/Telnet connectivity, your traffic will pass through a hosted gateway service. It's encrypted in transit (HTTPS) and the gateway servers authenticate themselves with a certificate before connections are made. However there's a non-zero risk of a MITM if a gateway service is compromised and the attacker gains access to the service's private key. * You can alleviate this risk by [hosting your own gateway service](https://github.com/Eugeny/tabby-connection-gateway), or your own copy of Tabby Web altogether. +* When using `PROXY_AUTH_ENABLED`, ensure your reverse proxy is properly configured to strip any `X-Auth-*` headers from client requests before adding its own. diff --git a/backend/tabby/app/api/__init__.py b/backend/tabby/app/api/__init__.py index 819cf18..6542abf 100644 --- a/backend/tabby/app/api/__init__.py +++ b/backend/tabby/app/api/__init__.py @@ -1,6 +1,6 @@ from django.urls import path, include from rest_framework import routers -from . import app_version, auth, config, gateway, user +from . import app_version, auth, config, gateway, user, system router = routers.DefaultRouter(trailing_slash=False) @@ -10,11 +10,28 @@ ) urlpatterns = [ + # Auth endpoints path("api/1/auth/logout", auth.LogoutView.as_view()), + path("api/1/auth/status", auth.AuthStatusView.as_view()), + path("api/1/auth/local/login", auth.LocalLoginView.as_view()), + path("api/1/auth/local/register", auth.LocalRegisterView.as_view()), + path("api/1/auth/change-password", auth.ChangePasswordView.as_view()), + + # User endpoints path("api/1/user", user.UserViewSet.as_view({"get": "retrieve", "put": "update"})), + + # Gateway endpoints path( "api/1/gateways/choose", gateway.ChooseGatewayViewSet.as_view({"post": "retrieve"}), ), + + # System endpoints + path("api/1/version", system.VersionView.as_view()), + path("health", system.HealthCheckView.as_view()), + path("ready", system.ReadinessCheckView.as_view()), + path("live", system.LivenessCheckView.as_view()), + + # Router URLs path("", include(router.urls)), ] diff --git a/backend/tabby/app/api/auth.py b/backend/tabby/app/api/auth.py index 72f91a7..1b23549 100644 --- a/backend/tabby/app/api/auth.py +++ b/backend/tabby/app/api/auth.py @@ -1,9 +1,262 @@ -from django.contrib.auth import logout +from django.contrib.auth import authenticate, login, logout +from django.conf import settings +from rest_framework import status from rest_framework.response import Response from rest_framework.views import APIView +from rest_framework.permissions import AllowAny, IsAuthenticated + +from ..models import User class LogoutView(APIView): + """Log out the current user.""" + def post(self, request, format=None): logout(request) return Response(None) + + +class LocalLoginView(APIView): + """ + Login with username/password when LOCAL_AUTH_ENABLED is true. + + This provides a fallback authentication method for self-hosted instances + that don't want to configure OAuth providers. + """ + permission_classes = [AllowAny] + + def post(self, request, format=None): + if not getattr(settings, "LOCAL_AUTH_ENABLED", False): + return Response( + {"error": "Local authentication is not enabled. Set LOCAL_AUTH_ENABLED=true to enable."}, + status=status.HTTP_403_FORBIDDEN + ) + + username = request.data.get("username", "").strip() + password = request.data.get("password", "") + + if not username or not password: + return Response( + {"error": "Username and password are required."}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Try to authenticate with username or email + user = authenticate(request, username=username, password=password) + + # If authentication failed and username looks like an email, try email lookup + if user is None and "@" in username: + try: + user_by_email = User.objects.get(email__iexact=username) + user = authenticate(request, username=user_by_email.username, password=password) + except User.DoesNotExist: + pass + + if user is None: + return Response( + {"error": "Invalid username or password."}, + status=status.HTTP_401_UNAUTHORIZED + ) + + if not user.is_active: + return Response( + {"error": "This account has been disabled."}, + status=status.HTTP_403_FORBIDDEN + ) + + login(request, user) + return Response({ + "success": True, + "user": { + "id": user.id, + "username": user.username, + "email": user.email, + } + }) + + +class LocalRegisterView(APIView): + """ + Register a new user with username/password when LOCAL_AUTH_REGISTRATION_ENABLED is true. + + This allows self-registration for self-hosted instances. Can be disabled + to only allow admin-created accounts. + """ + permission_classes = [AllowAny] + + def post(self, request, format=None): + if not getattr(settings, "LOCAL_AUTH_ENABLED", False): + return Response( + {"error": "Local authentication is not enabled."}, + status=status.HTTP_403_FORBIDDEN + ) + + if not getattr(settings, "LOCAL_AUTH_REGISTRATION_ENABLED", False): + return Response( + {"error": "Self-registration is not enabled. Please contact an administrator."}, + status=status.HTTP_403_FORBIDDEN + ) + + username = request.data.get("username", "").strip() + email = request.data.get("email", "").strip().lower() + password = request.data.get("password", "") + + # Validation + errors = {} + + if not username: + errors["username"] = "Username is required." + elif len(username) < 3: + errors["username"] = "Username must be at least 3 characters." + elif len(username) > 150: + errors["username"] = "Username must be at most 150 characters." + elif User.objects.filter(username__iexact=username).exists(): + errors["username"] = "This username is already taken." + + if not email: + errors["email"] = "Email is required." + elif "@" not in email: + errors["email"] = "Please enter a valid email address." + elif User.objects.filter(email__iexact=email).exists(): + errors["email"] = "This email is already registered." + + if not password: + errors["password"] = "Password is required." + elif len(password) < 8: + errors["password"] = "Password must be at least 8 characters." + + if errors: + return Response({"errors": errors}, status=status.HTTP_400_BAD_REQUEST) + + # Create user + user = User.objects.create_user( + username=username, + email=email, + password=password + ) + + # Log them in + login(request, user) + + return Response({ + "success": True, + "user": { + "id": user.id, + "username": user.username, + "email": user.email, + } + }, status=status.HTTP_201_CREATED) + + +class ChangePasswordView(APIView): + """Change password for the currently logged in user.""" + permission_classes = [IsAuthenticated] + + def post(self, request, format=None): + current_password = request.data.get("current_password", "") + new_password = request.data.get("new_password", "") + + if not current_password or not new_password: + return Response( + {"error": "Current password and new password are required."}, + status=status.HTTP_400_BAD_REQUEST + ) + + if len(new_password) < 8: + return Response( + {"error": "New password must be at least 8 characters."}, + status=status.HTTP_400_BAD_REQUEST + ) + + user = request.user + + if not user.check_password(current_password): + return Response( + {"error": "Current password is incorrect."}, + status=status.HTTP_401_UNAUTHORIZED + ) + + user.set_password(new_password) + user.save() + + # Re-login to update session + login(request, user) + + return Response({"success": True}) + + +class AuthStatusView(APIView): + """ + Get current authentication status and available auth methods. + + This helps the frontend know which login options to display. + """ + permission_classes = [AllowAny] + + def get(self, request, format=None): + # Get configured OAuth providers + providers = [] + + if getattr(settings, "SOCIAL_AUTH_GITHUB_KEY", None): + providers.append({ + "id": "github", + "name": "GitHub", + "url": "/api/1/auth/social/login/github/" + }) + + if getattr(settings, "SOCIAL_AUTH_GITLAB_KEY", None): + providers.append({ + "id": "gitlab", + "name": "GitLab", + "url": "/api/1/auth/social/login/gitlab/" + }) + + if getattr(settings, "SOCIAL_AUTH_GOOGLE_OAUTH2_KEY", None): + providers.append({ + "id": "google", + "name": "Google", + "url": "/api/1/auth/social/login/google-oauth2/" + }) + + if getattr(settings, "SOCIAL_AUTH_MICROSOFT_GRAPH_KEY", None): + providers.append({ + "id": "microsoft", + "name": "Microsoft", + "url": "/api/1/auth/social/login/microsoft-graph/" + }) + + if getattr(settings, "SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_KEY", None): + providers.append({ + "id": "azuread", + "name": "Azure AD", + "url": "/api/1/auth/social/login/azuread-tenant-oauth2/" + }) + + if getattr(settings, "SOCIAL_AUTH_AUTH0_KEY", None): + providers.append({ + "id": "auth0", + "name": "Auth0", + "url": "/api/1/auth/social/login/auth0/" + }) + + # Check for generic OIDC + if getattr(settings, "SOCIAL_AUTH_OIDC_KEY", None): + oidc_name = getattr(settings, "SOCIAL_AUTH_OIDC_DISPLAY_NAME", "OIDC") + providers.append({ + "id": "oidc", + "name": oidc_name, + "url": "/api/1/auth/social/login/oidc/" + }) + + return Response({ + "authenticated": request.user.is_authenticated, + "user": { + "id": request.user.id, + "username": request.user.username, + "email": request.user.email, + } if request.user.is_authenticated else None, + "local_auth_enabled": getattr(settings, "LOCAL_AUTH_ENABLED", False), + "local_registration_enabled": getattr(settings, "LOCAL_AUTH_REGISTRATION_ENABLED", False), + "proxy_auth_enabled": getattr(settings, "PROXY_AUTH_ENABLED", False), + "oauth_providers": providers, + }) diff --git a/backend/tabby/app/api/system.py b/backend/tabby/app/api/system.py new file mode 100644 index 0000000..bea37ae --- /dev/null +++ b/backend/tabby/app/api/system.py @@ -0,0 +1,123 @@ +""" +System endpoints for health checks, readiness, and version information. + +These endpoints are useful for container orchestration (Kubernetes, Docker Compose) +and monitoring systems. +""" +import os +import time +from django.conf import settings +from django.db import connection +from rest_framework import status +from rest_framework.response import Response +from rest_framework.views import APIView +from rest_framework.permissions import AllowAny + + +# Track when the application started +_start_time = time.time() + + +class HealthCheckView(APIView): + """ + Basic health check endpoint. + + Returns 200 if the application is running. + This is a lightweight check suitable for load balancer health probes. + + GET /health + """ + permission_classes = [AllowAny] + authentication_classes = [] # No auth required + + def get(self, request, format=None): + return Response({ + "status": "healthy", + "timestamp": time.time(), + }) + + +class ReadinessCheckView(APIView): + """ + Readiness check endpoint. + + Returns 200 if the application is ready to serve traffic. + This checks database connectivity and other dependencies. + + GET /ready + """ + permission_classes = [AllowAny] + authentication_classes = [] # No auth required + + def get(self, request, format=None): + checks = { + "database": False, + } + + # Check database connectivity + try: + with connection.cursor() as cursor: + cursor.execute("SELECT 1") + checks["database"] = True + except Exception as e: + checks["database"] = False + checks["database_error"] = str(e) + + # Overall status + all_healthy = all(v for k, v in checks.items() if not k.endswith("_error")) + + return Response( + { + "status": "ready" if all_healthy else "not_ready", + "checks": checks, + "timestamp": time.time(), + }, + status=status.HTTP_200_OK if all_healthy else status.HTTP_503_SERVICE_UNAVAILABLE + ) + + +class LivenessCheckView(APIView): + """ + Liveness check endpoint. + + Returns 200 if the application is alive and not stuck. + This is used by orchestrators to determine if the container needs to be restarted. + + GET /live + """ + permission_classes = [AllowAny] + authentication_classes = [] # No auth required + + def get(self, request, format=None): + uptime = time.time() - _start_time + + return Response({ + "status": "alive", + "uptime_seconds": round(uptime, 2), + "timestamp": time.time(), + }) + + +class VersionView(APIView): + """ + Version information endpoint. + + Returns the current application version and build information. + + GET /api/1/version + """ + permission_classes = [AllowAny] + + def get(self, request, format=None): + # Try to get version from environment or package + version = os.getenv("TABBY_VERSION", "unknown") + git_commit = os.getenv("GIT_COMMIT", os.getenv("COMMIT_SHA", "unknown")) + build_date = os.getenv("BUILD_DATE", "unknown") + + return Response({ + "version": version, + "git_commit": git_commit[:8] if git_commit != "unknown" else "unknown", + "build_date": build_date, + "python_version": os.popen("python --version 2>&1").read().strip(), + "debug": settings.DEBUG, + }) diff --git a/backend/tabby/middleware.py b/backend/tabby/middleware.py index 5197501..f1e2c40 100644 --- a/backend/tabby/middleware.py +++ b/backend/tabby/middleware.py @@ -1,4 +1,5 @@ import logging +import re from tabby.app.models import User from django.conf import settings from django.contrib.auth import login @@ -36,6 +37,91 @@ def __call__(self, request): return response +class ProxyAuthMiddleware(BaseMiddleware): + """ + Middleware to authenticate users via auth proxy headers. + + When running behind an authenticating proxy (like Authentik, Authelia, + Traefik ForwardAuth, or any other auth proxy), this middleware trusts + the X-Auth-* headers set by the proxy and automatically logs in users + based on their email. + + Headers: + X-Auth-User-Email: User's email address (required for auth) + X-Auth-User-Name: User's display name (optional) + X-Auth-User-Id: External user ID (optional) + X-Auth-Tenant-Id: Tenant ID (optional) + + Enable by setting PROXY_AUTH_ENABLED=true in environment. + + Security Note: Only enable this if you trust your reverse proxy to + properly validate authentication before forwarding requests. + """ + + def __call__(self, request): + # Only process if proxy auth is enabled + if not getattr(settings, "PROXY_AUTH_ENABLED", False): + return self.get_response(request) + + # Skip if user is already authenticated + if request.user.is_authenticated: + return self.get_response(request) + + # Check for proxy auth header (Django converts X-Auth-User-Email to HTTP_X_AUTH_USER_EMAIL) + user_email = request.META.get("HTTP_X_AUTH_USER_EMAIL") + if not user_email: + return self.get_response(request) + + user_name = request.META.get("HTTP_X_AUTH_USER_NAME", "") + # tenant_id = request.META.get("HTTP_X_AUTH_TENANT_ID", "") + + # Get or create user by email + user, created = User.objects.get_or_create( + email=user_email, + defaults={ + "username": self._generate_username(user_email, user_name), + "first_name": user_name.split()[0] if user_name else "", + "last_name": " ".join(user_name.split()[1:]) if user_name else "", + } + ) + + if created: + logging.info(f"ProxyAuth: Created new user {user.email}") + + # Update name if changed + if user_name and not created: + first_name = user_name.split()[0] if user_name else "" + last_name = " ".join(user_name.split()[1:]) if user_name else "" + if user.first_name != first_name or user.last_name != last_name: + user.first_name = first_name + user.last_name = last_name + user.save(update_fields=["first_name", "last_name"]) + + # Log the user in + setattr(user, "backend", "django.contrib.auth.backends.ModelBackend") + login(request, user) + + # Skip CSRF checks for proxy-authenticated requests + setattr(request, "_dont_enforce_csrf_checks", True) + + return self.get_response(request) + + def _generate_username(self, email: str, name: str) -> str: + """Generate a unique username from email or name.""" + # Try to use part before @ in email + base_username = email.split("@")[0] + # Sanitize: only alphanumeric and underscores + base_username = re.sub(r"[^\w]", "_", base_username)[:30] + + username = base_username + counter = 1 + while User.objects.filter(username=username).exists(): + username = f"{base_username}_{counter}" + counter += 1 + + return username + + class GAMiddleware(BaseMiddleware): def __init__(self, get_response): super().__init__(get_response) @@ -48,6 +134,6 @@ def __call__(self, request): try: self.tracker.track_pageview(Page(request.path), Session(), Visitor()) except Exception: - logging.exception() + logging.exception("Failed to track pageview") return response diff --git a/backend/tabby/settings.py b/backend/tabby/settings.py index 0714461..b29203d 100644 --- a/backend/tabby/settings.py +++ b/backend/tabby/settings.py @@ -10,12 +10,35 @@ BASE_DIR = Path(__file__).resolve().parent.parent SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "django-insecure") -DEBUG = bool(os.getenv("DEBUG", False)) +DEBUG = os.getenv("DEBUG", "").lower() in ("true", "1", "yes") + +# ALLOWED_HOSTS configuration +# Set ALLOWED_HOSTS env var to a comma-separated list of hostnames +# Example: ALLOWED_HOSTS=tabby.example.com,localhost +# If not set, defaults to ["*"] which allows all hosts (not recommended for production) +_allowed_hosts = os.getenv("ALLOWED_HOSTS", "") +if _allowed_hosts: + ALLOWED_HOSTS = [h.strip() for h in _allowed_hosts.split(",") if h.strip()] +else: + ALLOWED_HOSTS = ["*"] -ALLOWED_HOSTS = ["*"] USE_X_FORWARDED_HOST = True SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") +# ============================================================================ +# Local Authentication Settings +# ============================================================================ +# Enable username/password authentication (for self-hosted instances without OAuth) +LOCAL_AUTH_ENABLED = os.getenv("LOCAL_AUTH_ENABLED", "").lower() in ("true", "1", "yes") + +# Allow self-registration (only works if LOCAL_AUTH_ENABLED is true) +LOCAL_AUTH_REGISTRATION_ENABLED = os.getenv("LOCAL_AUTH_REGISTRATION_ENABLED", "").lower() in ("true", "1", "yes") + +# ============================================================================ +# Proxy Authentication Settings (for Authentik, Authelia, etc.) +# ============================================================================ +PROXY_AUTH_ENABLED = os.getenv("PROXY_AUTH_ENABLED", "").lower() in ("true", "1", "yes") + # Application definition @@ -39,6 +62,7 @@ "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", + "tabby.middleware.ProxyAuthMiddleware", # Must come after AuthenticationMiddleware "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", "corsheaders.middleware.CorsMiddleware", diff --git a/docker-compose.prebuilt.yml b/docker-compose.prebuilt.yml new file mode 100644 index 0000000..fe9c7cd --- /dev/null +++ b/docker-compose.prebuilt.yml @@ -0,0 +1,65 @@ +# Use this file for quick deployment with pre-built images (no build required) +# Usage: docker-compose -f docker-compose.prebuilt.yml up -d + +services: + tabby: + image: ghcr.io/eugeny/tabby-web:latest + restart: always + depends_on: + - db + ports: + - 9090:80 + environment: + - DATABASE_URL=mysql://root:123@db/tabby + - PORT=80 + - DEBUG=False + - DOCKERIZE_ARGS="-wait tcp://db:3306 -timeout 60s" + # + # ============================================================ + # Authentication Options (choose one or more) + # ============================================================ + # + # Option 1: Local username/password (no OAuth required!) + # - LOCAL_AUTH_ENABLED=true + # - LOCAL_AUTH_REGISTRATION_ENABLED=true # Allow self-registration + # + # Option 2: Proxy authentication (Authentik, Authelia, etc.) + # - PROXY_AUTH_ENABLED=true + # + # Option 3: OAuth Providers (uncomment and configure): + # - SOCIAL_AUTH_GITHUB_KEY=your_github_client_id + # - SOCIAL_AUTH_GITHUB_SECRET=your_github_client_secret + # - SOCIAL_AUTH_GITLAB_KEY=your_gitlab_client_id + # - SOCIAL_AUTH_GITLAB_SECRET=your_gitlab_client_secret + # - SOCIAL_AUTH_GOOGLE_OAUTH2_KEY=your_google_client_id + # - SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET=your_google_client_secret + # - SOCIAL_AUTH_MICROSOFT_GRAPH_KEY=your_microsoft_client_id + # - SOCIAL_AUTH_MICROSOFT_GRAPH_SECRET=your_microsoft_client_secret + # - SOCIAL_AUTH_AUTH0_DOMAIN=your_tenant.auth0.com + # - SOCIAL_AUTH_AUTH0_KEY=your_auth0_client_id + # - SOCIAL_AUTH_AUTH0_SECRET=your_auth0_client_secret + # + # ============================================================ + # Other Settings + # ============================================================ + # - ALLOWED_HOSTS=tabby.example.com,localhost + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:80/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + + db: + image: mariadb:10.7.1 + restart: always + environment: + MARIADB_DATABASE: tabby + MARIADB_USER: user + MARIADB_PASSWORD: 123 + MYSQL_ROOT_PASSWORD: 123 + volumes: + - db-data:/var/lib/mysql + +volumes: + db-data: