Initial project structure with documentation

This commit is contained in:
XPS\Micro 2026-01-30 18:00:41 +01:00
parent 406ed2c158
commit c363351483
64 changed files with 15227 additions and 2567 deletions

14
.dockerignore Normal file
View File

@ -0,0 +1,14 @@
__pycache__
*.pyc
*.pyo
*.pyd
.Python
.env
.venv
venv/
data/*.db
logs/*.log
.git
.gitignore
.DS_Store
*.md

95
.env.example Normal file
View File

@ -0,0 +1,95 @@
# Container Spawner Konfiguration
# Kopiere diese Datei nach .env und passe die Werte an
# cp .env.example .env
# ============================================================
# PFLICHT - Diese Werte MUESSEN angepasst werden
# ============================================================
# Flask Session Secret - Generiere mit:
# python3 -c "import secrets; print(secrets.token_hex(32))"
SECRET_KEY=HIER_DEINEN_GEHEIMEN_SCHLUESSEL_EINTRAGEN
# Deine Domain (z.B. example.com, wieland.org)
BASE_DOMAIN=example.com
# Subdomain fuer den Spawner (z.B. coder -> coder.example.com)
SPAWNER_SUBDOMAIN=coder
# Name des Traefik-Netzwerks (muss existieren oder wird erstellt)
TRAEFIK_NETWORK=web
# ============================================================
# TRAEFIK - Anpassen an deine Traefik-Konfiguration
# ============================================================
# Name des Traefik Certificate Resolvers (aus deiner traefik.yml)
# Typische Namen: lets-encrypt, letsencrypt, hetzner, cloudflare, default
TRAEFIK_CERTRESOLVER=lets-encrypt
# Traefik Entrypoint fuer HTTPS (Standard: websecure)
TRAEFIK_ENTRYPOINT=websecure
# ============================================================
# DOCKER - Normalerweise keine Aenderung noetig
# ============================================================
# Docker Socket Pfad (Standard fuer Linux)
# Fuer Windows: npipe:////./pipe/docker_engine
# Fuer TCP: tcp://localhost:2375
DOCKER_HOST=unix:///var/run/docker.sock
# Docker-Image fuer User-Container
# Verfuegbare Templates:
# - user-service-template:latest (nginx, einfache Willkommensseite)
# - user-template-next:latest (Next.js, moderne React-App)
USER_TEMPLATE_IMAGE=user-service-template:latest
# ============================================================
# RESSOURCEN - Container-Limits
# ============================================================
# RAM-Limit pro User-Container
DEFAULT_MEMORY_LIMIT=512m
# CPU-Quota (50000 = 0.5 CPU, 100000 = 1 CPU)
DEFAULT_CPU_QUOTA=50000
# ============================================================
# JWT - Authentifizierung
# ============================================================
# JWT Secret (verwendet SECRET_KEY wenn nicht gesetzt)
# JWT_SECRET_KEY=
# JWT Token Gueltigkeitsdauer in Sekunden (Standard: 1 Stunde)
JWT_ACCESS_TOKEN_EXPIRES=3600
# ============================================================
# CORS - Cross-Origin Resource Sharing
# ============================================================
# CORS erlaubte Origins (kommasepariert)
# WICHTIG: Wird automatisch aus SPAWNER_SUBDOMAIN und BASE_DOMAIN generiert
# Nur setzen wenn du zusaetzliche Origins brauchst
# CORS_ORIGINS=https://coder.example.com,http://localhost:3000
# ============================================================
# OPTIONAL - Logging und Debugging
# ============================================================
# Spawner-Port (intern, nur fuer direkten Zugriff)
SPAWNER_PORT=5000
# Log-Level (DEBUG, INFO, WARNING, ERROR)
LOG_LEVEL=INFO
# Container-Timeout in Sekunden (fuer Auto-Shutdown, noch nicht implementiert)
CONTAINER_IDLE_TIMEOUT=3600
# ============================================================
# PRODUKTION - Erweiterte Einstellungen
# ============================================================
# PostgreSQL statt SQLite (empfohlen fuer Produktion)
# DATABASE_URL=postgresql://spawner:password@postgres:5432/spawner

174
.gitignore vendored Normal file
View File

@ -0,0 +1,174 @@
# ============================================================
# Container Spawner - .gitignore
# ============================================================
# ============================================================
# Umgebung und Secrets
# ============================================================
.env
.env.local
.env.*.local
!.env.example
# ============================================================
# Python
# ============================================================
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Virtual Environments
venv/
.venv/
ENV/
env/
# PyInstaller
*.manifest
*.spec
# Pytest
.pytest_cache/
.coverage
htmlcov/
# mypy
.mypy_cache/
# ============================================================
# Node.js / Next.js
# ============================================================
node_modules/
.next/
out/
.pnp
.pnp.js
.yarn/install-state.gz
# Build Output
build/
dist/
# Package Manager Logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
.npm/
# Lock Files (optional - auskommentieren wenn gewuenscht)
# package-lock.json
# yarn.lock
# pnpm-lock.yaml
# TypeScript
*.tsbuildinfo
# Vercel
.vercel/
# ============================================================
# Generierte Daten (Server)
# ============================================================
data/
logs/
*.db
*.sqlite
*.sqlite3
# Backups
*.bak
*.backup
backup-*.db
# ============================================================
# IDE / Editoren
# ============================================================
.idea/
.vscode/
*.swp
*.swo
*~
.project
.classpath
.settings/
*.sublime-project
*.sublime-workspace
# ============================================================
# Betriebssystem
# ============================================================
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
desktop.ini
# ============================================================
# Logs und temporaere Dateien
# ============================================================
*.log
*.tmp
*.temp
*.cache
# ============================================================
# Sicherheit
# ============================================================
*.pem
*.key
*.crt
*.p12
*.pfx
secrets/
credentials/
# ============================================================
# Docker (nur generierte Dateien)
# ============================================================
# Keine Docker-Dateien ignorieren - diese werden benoetigt
# Aber lokale Override-Dateien ignorieren
docker-compose.override.yml
docker-compose.local.yml
# ============================================================
# Test und Coverage
# ============================================================
coverage/
.nyc_output/
test-results/
junit.xml
# ============================================================
# Projekt-spezifisch
# ============================================================
# CLAUDE.md enthaelt projektspezifische Instruktionen fuer Claude Code
# Kann bei Bedarf auskommentiert werden um es zu behalten
CLAUDE.md
# ============================================================
# Misc
# ============================================================
*.pid
*.seed
*.pid.lock

29
Dockerfile Normal file
View File

@ -0,0 +1,29 @@
FROM python:3.11-slim
WORKDIR /app
# System-Dependencies
RUN apt-get update && \
apt-get install -y --no-install-recommends \
curl \
ca-certificates && \
rm -rf /var/lib/apt/lists/*
# Python-Dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Application-Code
COPY . .
# Daten-Verzeichnisse
RUN mkdir -p /app/data /app/logs && \
chmod 755 /app/data /app/logs
EXPOSE 5000
# Health-Check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost:5000/health || exit 1
CMD ["python", "app.py"]

2618
README.md

File diff suppressed because it is too large Load Diff

251
api.py Normal file
View File

@ -0,0 +1,251 @@
from flask import Blueprint, jsonify, request, current_app
from flask_jwt_extended import (
create_access_token,
jwt_required,
get_jwt_identity,
get_jwt
)
from datetime import timedelta
from models import db, User
from container_manager import ContainerManager
api_bp = Blueprint('api', __name__, url_prefix='/api')
# Token-Blacklist für Logout
token_blacklist = set()
@api_bp.route('/auth/login', methods=['POST'])
def api_login():
"""API-Login - gibt JWT-Token zurück"""
data = request.get_json()
if not data:
return jsonify({'error': 'Keine Daten übermittelt'}), 400
username = data.get('username')
password = data.get('password')
if not username or not password:
return jsonify({'error': 'Username und Passwort erforderlich'}), 400
user = User.query.filter_by(username=username).first()
if not user or not user.check_password(password):
return jsonify({'error': 'Ungültige Anmeldedaten'}), 401
# Container spawnen wenn noch nicht vorhanden
if not user.container_id:
try:
container_mgr = ContainerManager()
container_id, port = container_mgr.spawn_container(user.id, user.username)
user.container_id = container_id
user.container_port = port
db.session.commit()
except Exception as e:
current_app.logger.error(f"Container-Start fehlgeschlagen: {str(e)}")
return jsonify({'error': f'Container-Start fehlgeschlagen: {str(e)}'}), 500
# JWT-Token erstellen
expires = timedelta(seconds=current_app.config.get('JWT_ACCESS_TOKEN_EXPIRES', 3600))
access_token = create_access_token(
identity=str(user.id),
expires_delta=expires,
additional_claims={'username': user.username}
)
return jsonify({
'access_token': access_token,
'token_type': 'Bearer',
'expires_in': int(expires.total_seconds()),
'user': {
'id': user.id,
'username': user.username,
'email': user.email
}
}), 200
@api_bp.route('/auth/signup', methods=['POST'])
def api_signup():
"""API-Registrierung - erstellt User, spawnt Container, gibt JWT zurück"""
data = request.get_json()
if not data:
return jsonify({'error': 'Keine Daten übermittelt'}), 400
username = data.get('username')
email = data.get('email')
password = data.get('password')
if not username or not email or not password:
return jsonify({'error': 'Username, Email und Passwort erforderlich'}), 400
# Validierung
if len(username) < 3:
return jsonify({'error': 'Username muss mindestens 3 Zeichen lang sein'}), 400
if len(password) < 6:
return jsonify({'error': 'Passwort muss mindestens 6 Zeichen lang sein'}), 400
# Prüfe ob User existiert
if User.query.filter_by(username=username).first():
return jsonify({'error': 'Username bereits vergeben'}), 409
if User.query.filter_by(email=email).first():
return jsonify({'error': 'Email bereits registriert'}), 409
# Neuen User anlegen
user = User(username=username, email=email)
user.set_password(password)
db.session.add(user)
db.session.commit()
# Container spawnen
try:
container_mgr = ContainerManager()
container_id, port = container_mgr.spawn_container(user.id, user.username)
user.container_id = container_id
user.container_port = port
db.session.commit()
except Exception as e:
db.session.delete(user)
db.session.commit()
current_app.logger.error(f"Registrierung fehlgeschlagen: {str(e)}")
return jsonify({'error': f'Container-Erstellung fehlgeschlagen: {str(e)}'}), 500
# JWT-Token erstellen
expires = timedelta(seconds=current_app.config.get('JWT_ACCESS_TOKEN_EXPIRES', 3600))
access_token = create_access_token(
identity=str(user.id),
expires_delta=expires,
additional_claims={'username': user.username}
)
return jsonify({
'access_token': access_token,
'token_type': 'Bearer',
'expires_in': int(expires.total_seconds()),
'user': {
'id': user.id,
'username': user.username,
'email': user.email
}
}), 201
@api_bp.route('/auth/logout', methods=['POST'])
@jwt_required()
def api_logout():
"""API-Logout - invalidiert Token"""
jti = get_jwt()['jti']
token_blacklist.add(jti)
return jsonify({'message': 'Erfolgreich abgemeldet'}), 200
@api_bp.route('/user/me', methods=['GET'])
@jwt_required()
def api_user_me():
"""Gibt aktuellen User und Container-Info zurück"""
user_id = get_jwt_identity()
user = User.query.get(int(user_id))
if not user:
return jsonify({'error': 'User nicht gefunden'}), 404
# Service-URL berechnen
scheme = current_app.config['PREFERRED_URL_SCHEME']
spawner_domain = f"{current_app.config['SPAWNER_SUBDOMAIN']}.{current_app.config['BASE_DOMAIN']}"
service_url = f"{scheme}://{spawner_domain}/{user.username}"
# Container-Status abrufen
container_status = 'unknown'
if user.container_id:
try:
container_mgr = ContainerManager()
container_status = container_mgr.get_container_status(user.container_id)
except Exception:
container_status = 'error'
return jsonify({
'user': {
'id': user.id,
'username': user.username,
'email': user.email,
'created_at': user.created_at.isoformat() if user.created_at else None
},
'container': {
'id': user.container_id,
'port': user.container_port,
'status': container_status,
'service_url': service_url
}
}), 200
@api_bp.route('/container/status', methods=['GET'])
@jwt_required()
def api_container_status():
"""Gibt Container-Status zurück"""
user_id = get_jwt_identity()
user = User.query.get(int(user_id))
if not user:
return jsonify({'error': 'User nicht gefunden'}), 404
container_status = 'no_container'
if user.container_id:
try:
container_mgr = ContainerManager()
container_status = container_mgr.get_container_status(user.container_id)
except Exception as e:
container_status = f'error: {str(e)}'
return jsonify({
'container_id': user.container_id,
'status': container_status
}), 200
@api_bp.route('/container/restart', methods=['POST'])
@jwt_required()
def api_container_restart():
"""Startet Container neu"""
user_id = get_jwt_identity()
user = User.query.get(int(user_id))
if not user:
return jsonify({'error': 'User nicht gefunden'}), 404
container_mgr = ContainerManager()
# Alten Container stoppen falls vorhanden
if user.container_id:
try:
container_mgr.stop_container(user.container_id)
container_mgr.remove_container(user.container_id)
except Exception as e:
current_app.logger.warning(f"Alter Container konnte nicht gestoppt werden: {str(e)}")
# Neuen Container starten
try:
container_id, port = container_mgr.spawn_container(user.id, user.username)
user.container_id = container_id
user.container_port = port
db.session.commit()
return jsonify({
'message': 'Container erfolgreich neugestartet',
'container_id': container_id,
'status': 'running'
}), 200
except Exception as e:
current_app.logger.error(f"Container-Restart fehlgeschlagen: {str(e)}")
return jsonify({'error': f'Container-Restart fehlgeschlagen: {str(e)}'}), 500
def check_if_token_revoked(jwt_header, jwt_payload):
"""Callback für flask-jwt-extended um revoked Tokens zu prüfen"""
jti = jwt_payload['jti']
return jti in token_blacklist

158
app.py Normal file
View File

@ -0,0 +1,158 @@
from flask import Flask, render_template, redirect, url_for, jsonify
from flask_login import LoginManager, login_required, current_user
from flask_jwt_extended import JWTManager
from flask_cors import CORS
from sqlalchemy import text
from models import db, User
from auth import auth_bp
from api import api_bp, check_if_token_revoked
from config import Config
from container_manager import ContainerManager
# Flask-App initialisieren
app = Flask(__name__)
app.config.from_object(Config)
# Datenbank initialisieren
db.init_app(app)
# CORS initialisieren
CORS(app, resources={
r"/api/*": {
"origins": app.config.get('CORS_ORIGINS', ['http://localhost:3000']),
"methods": ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
"allow_headers": ["Content-Type", "Authorization"],
"supports_credentials": True
}
})
# JWT initialisieren
jwt = JWTManager(app)
@jwt.token_in_blocklist_loader
def check_if_token_in_blocklist(jwt_header, jwt_payload):
return check_if_token_revoked(jwt_header, jwt_payload)
@jwt.expired_token_loader
def expired_token_callback(jwt_header, jwt_payload):
return jsonify({'error': 'Token abgelaufen'}), 401
@jwt.invalid_token_loader
def invalid_token_callback(error):
return jsonify({'error': 'Ungültiger Token'}), 401
@jwt.unauthorized_loader
def missing_token_callback(error):
return jsonify({'error': 'Authentifizierung erforderlich'}), 401
# Flask-Login initialisieren
login_manager = LoginManager()
login_manager.init_app(app)
login_manager.login_view = 'auth.login'
login_manager.login_message = 'Bitte melde dich an, um auf diese Seite zuzugreifen.'
login_manager.login_message_category = 'error'
# Blueprints registrieren
app.register_blueprint(auth_bp)
app.register_blueprint(api_bp)
@login_manager.user_loader
def load_user(user_id):
"""Lädt User für Flask-Login"""
return User.query.get(int(user_id))
@app.route('/')
def index():
"""Startseite - Redirect zu Dashboard oder Login"""
if current_user.is_authenticated:
return redirect(url_for('dashboard'))
return redirect(url_for('auth.login'))
@app.route('/dashboard')
@login_required
def dashboard():
"""Dashboard - zeigt Container-Status und Service-URL"""
container_mgr = ContainerManager()
container_status = 'unknown'
if current_user.container_id:
container_status = container_mgr.get_container_status(current_user.container_id)
# Service-URL für den User (pfad-basiert)
scheme = app.config['PREFERRED_URL_SCHEME']
spawner_domain = f"{app.config['SPAWNER_SUBDOMAIN']}.{app.config['BASE_DOMAIN']}"
service_url = f"{scheme}://{spawner_domain}/{current_user.username}"
return render_template('dashboard.html',
user=current_user,
service_url=service_url,
container_status=container_status)
@app.route('/container/restart')
@login_required
def restart_container():
"""Startet Container des Users neu"""
container_mgr = ContainerManager()
# Alten Container stoppen falls vorhanden
if current_user.container_id:
container_mgr.stop_container(current_user.container_id)
container_mgr.remove_container(current_user.container_id)
# Neuen Container starten
try:
container_id, port = container_mgr.spawn_container(current_user.id, current_user.username)
current_user.container_id = container_id
current_user.container_port = port
db.session.commit()
except Exception as e:
app.logger.error(f"Container-Restart fehlgeschlagen: {str(e)}")
return redirect(url_for('dashboard'))
@app.route('/health')
def health():
"""Health-Check für Docker und Monitoring"""
db_status = 'ok'
docker_status = 'warning'
try:
# DB-Check (KRITISCH)
db.session.execute(text('SELECT 1'))
except Exception as e:
db_status = f'error: {str(e)}'
app.logger.error(f"Database health check failed: {str(e)}")
try:
# Docker-Check (OPTIONAL)
container_mgr = ContainerManager()
container_mgr._get_client().ping()
docker_status = 'ok'
except Exception as e:
docker_status = f'warning: {str(e)}'
app.logger.warning(f"Docker health check failed (non-critical): {str(e)}")
# Status 503 nur wenn DATABASE down ist, nicht wenn Docker down ist
status_code = 200 if db_status == 'ok' else 503
response = {
'status': 'healthy' if status_code == 200 else 'unhealthy',
'database': db_status,
'docker': docker_status,
'version': '1.0.0'
}
if status_code != 200:
app.logger.error(f"Health check CRITICAL: {response}")
else:
app.logger.info(f"Health check OK")
return response, status_code
# Datenbank-Tabellen erstellen beim ersten Start
with app.app_context():
db.create_all()
app.logger.info('Datenbank-Tabellen erstellt')
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000, debug=False)

87
auth.py Normal file
View File

@ -0,0 +1,87 @@
from flask import Blueprint, render_template, redirect, url_for, request, flash
from flask_login import login_user, logout_user, login_required, current_user
from models import db, User
from container_manager import ContainerManager
auth_bp = Blueprint('auth', __name__)
@auth_bp.route('/login', methods=['GET', 'POST'])
def login():
"""User-Login"""
if request.method == 'POST':
username = request.form.get('username')
password = request.form.get('password')
user = User.query.filter_by(username=username).first()
if user and user.check_password(password):
login_user(user)
# Container spawnen wenn noch nicht vorhanden
if not user.container_id:
try:
container_mgr = ContainerManager()
container_id, port = container_mgr.spawn_container(user.id, user.username)
user.container_id = container_id
user.container_port = port
db.session.commit()
except Exception as e:
flash(f'Container-Start fehlgeschlagen: {str(e)}', 'error')
return redirect(url_for('auth.login'))
flash('Login erfolgreich!', 'success')
return redirect(url_for('dashboard'))
else:
flash('Ungültige Anmeldedaten', 'error')
return render_template('login.html')
@auth_bp.route('/signup', methods=['GET', 'POST'])
def signup():
"""User-Registrierung"""
if request.method == 'POST':
username = request.form.get('username')
email = request.form.get('email')
password = request.form.get('password')
# Prüfe ob User existiert
if User.query.filter_by(username=username).first():
flash('Username bereits vergeben', 'error')
return redirect(url_for('auth.signup'))
if User.query.filter_by(email=email).first():
flash('Email bereits registriert', 'error')
return redirect(url_for('auth.signup'))
# Neuen User anlegen
user = User(username=username, email=email)
user.set_password(password)
db.session.add(user)
db.session.commit()
# Container aus Template bauen und starten
try:
container_mgr = ContainerManager()
container_id, port = container_mgr.spawn_container(user.id, user.username)
user.container_id = container_id
user.container_port = port
db.session.commit()
flash('Registrierung erfolgreich! Container wird gestartet...', 'success')
login_user(user)
return redirect(url_for('dashboard'))
except Exception as e:
db.session.delete(user)
db.session.commit()
flash(f'Registrierung fehlgeschlagen: {str(e)}', 'error')
return render_template('signup.html')
@auth_bp.route('/logout')
@login_required
def logout():
"""User-Logout"""
logout_user()
flash('Erfolgreich abgemeldet', 'success')
return redirect(url_for('auth.login'))

113
config.py Normal file
View File

@ -0,0 +1,113 @@
import os
from dotenv import load_dotenv
load_dotenv()
class Config:
# ========================================
# Sicherheit
# ========================================
SECRET_KEY = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production')
# JWT-Konfiguration
JWT_SECRET_KEY = os.getenv('JWT_SECRET_KEY', SECRET_KEY)
JWT_ACCESS_TOKEN_EXPIRES = int(os.getenv('JWT_ACCESS_TOKEN_EXPIRES', 3600)) # 1 Stunde
JWT_TOKEN_LOCATION = ['headers']
JWT_HEADER_NAME = 'Authorization'
JWT_HEADER_TYPE = 'Bearer'
# CORS-Konfiguration
CORS_ORIGINS = os.getenv('CORS_ORIGINS', 'http://localhost:3000').split(',')
# Session-Sicherheit
SESSION_COOKIE_SECURE = os.getenv('BASE_DOMAIN', 'localhost') != 'localhost'
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = 'Lax'
PERMANENT_SESSION_LIFETIME = 3600 # 1 Stunde
# ========================================
# Datenbank
# ========================================
SQLALCHEMY_DATABASE_URI = os.getenv(
'DATABASE_URL',
'sqlite:////app/data/users.db' # 4 slashes: sqlite:// + /app/data/users.db
)
SQLALCHEMY_TRACK_MODIFICATIONS = False
# ========================================
# Docker-Konfiguration
# ========================================
DOCKER_SOCKET = os.getenv('DOCKER_SOCKET', 'unix://var/run/docker.sock')
USER_TEMPLATE_IMAGE = os.getenv('USER_TEMPLATE_IMAGE', 'user-service-template:latest')
# ========================================
# Traefik/Domain-Konfiguration
# ========================================
BASE_DOMAIN = os.getenv('BASE_DOMAIN', 'localhost')
SPAWNER_SUBDOMAIN = os.getenv('SPAWNER_SUBDOMAIN', 'spawner')
TRAEFIK_NETWORK = os.getenv('TRAEFIK_NETWORK', 'web')
TRAEFIK_CERTRESOLVER = os.getenv('TRAEFIK_CERTRESOLVER', 'lets-encrypt')
TRAEFIK_ENTRYPOINT = os.getenv('TRAEFIK_ENTRYPOINT', 'websecure')
# Vollständige Spawner-URL
SPAWNER_URL = f"{SPAWNER_SUBDOMAIN}.{BASE_DOMAIN}"
# ========================================
# Application-Settings
# ========================================
# HTTPS automatisch für Nicht-Localhost
PREFERRED_URL_SCHEME = 'https' if BASE_DOMAIN != 'localhost' else 'http'
# Spawner-Port (nur für Debugging wichtig)
SPAWNER_PORT = int(os.getenv('SPAWNER_PORT', 5000))
# ========================================
# Optionale Einstellungen
# ========================================
# Logging
LOG_LEVEL = os.getenv('LOG_LEVEL', 'INFO')
LOG_FILE = os.getenv('LOG_FILE', '/app/logs/spawner.log')
# Container-Limits (für container_manager.py)
DEFAULT_MEMORY_LIMIT = os.getenv('DEFAULT_MEMORY_LIMIT', '512m')
DEFAULT_CPU_QUOTA = int(os.getenv('DEFAULT_CPU_QUOTA', 50000)) # 0.5 CPU
# Container-Cleanup
CONTAINER_IDLE_TIMEOUT = int(os.getenv('CONTAINER_IDLE_TIMEOUT', 3600)) # 1h in Sekunden
class DevelopmentConfig(Config):
"""Konfiguration für Entwicklung"""
DEBUG = True
TESTING = False
class ProductionConfig(Config):
"""Konfiguration für Produktion"""
DEBUG = False
TESTING = False
# Strikte Session-Sicherheit
SESSION_COOKIE_SECURE = True
# Optional: PostgreSQL statt SQLite
# SQLALCHEMY_DATABASE_URI = os.getenv(
# 'DATABASE_URL',
# 'postgresql://spawner:password@postgres:5432/spawner'
# )
class TestingConfig(Config):
"""Konfiguration für Tests"""
TESTING = True
SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:'
WTF_CSRF_ENABLED = False
# Config-Dict für einfaches Laden
config = {
'development': DevelopmentConfig,
'production': ProductionConfig,
'testing': TestingConfig,
'default': DevelopmentConfig
}

128
container_manager.py Normal file
View File

@ -0,0 +1,128 @@
import requests_unixsocket
import docker
from config import Config
class ContainerManager:
def __init__(self):
self.client = None
def _get_client(self):
"""Lazy initialization of Docker client"""
if self.client is None:
try:
# Nutze from_env() - DOCKER_HOST aus Umgebungsvariable
self.client = docker.from_env()
except Exception as e:
raise Exception(f"Docker connection failed: {str(e)}")
return self.client
def spawn_container(self, user_id, username):
"""Spawnt einen neuen Container für den User"""
try:
existing = self._get_user_container(username)
if existing and existing.status == 'running':
return existing.id, self._get_container_port(existing)
# Pfad-basiertes Routing: User unter coder.wieland.org/username
base_host = f"{Config.SPAWNER_SUBDOMAIN}.{Config.BASE_DOMAIN}"
# Labels vorbereiten
labels = {
'traefik.enable': 'true',
'traefik.docker.network': Config.TRAEFIK_NETWORK,
# HTTPS Router mit PathPrefix
f'traefik.http.routers.user{user_id}.rule':
f'Host(`{base_host}`) && PathPrefix(`/{username}`)',
f'traefik.http.routers.user{user_id}.entrypoints': Config.TRAEFIK_ENTRYPOINT,
f'traefik.http.routers.user{user_id}.priority': '100',
# StripPrefix Middleware - entfernt /{username} bevor Container Request erhält
f'traefik.http.routers.user{user_id}.middlewares': f'user{user_id}-strip',
f'traefik.http.middlewares.user{user_id}-strip.stripprefix.prefixes': f'/{username}',
# TLS für HTTPS
f'traefik.http.routers.user{user_id}.tls': 'true',
f'traefik.http.routers.user{user_id}.tls.certresolver': Config.TRAEFIK_CERTRESOLVER,
# Service
f'traefik.http.services.user{user_id}.loadbalancer.server.port': '8080',
# Metadata
'spawner.user_id': str(user_id),
'spawner.username': username,
'spawner.managed': 'true'
}
# Logging: Traefik-Labels ausgeben
print(f"[SPAWNER] Creating container user-{username}-{user_id}")
print(f"[SPAWNER] Traefik Labels:")
for key, value in labels.items():
if 'traefik' in key:
print(f"[SPAWNER] {key}: {value}")
container = self._get_client().containers.run(
Config.USER_TEMPLATE_IMAGE,
name=f"user-{username}-{user_id}",
detach=True,
network=Config.TRAEFIK_NETWORK,
labels=labels,
environment={
'USER_ID': str(user_id),
'USERNAME': username
},
restart_policy={'Name': 'unless-stopped'},
mem_limit=Config.DEFAULT_MEMORY_LIMIT,
cpu_quota=Config.DEFAULT_CPU_QUOTA
)
print(f"[SPAWNER] Container created: {container.id[:12]}")
print(f"[SPAWNER] URL: https://{base_host}/{username}")
return container.id, 8080
except docker.errors.ImageNotFound as e:
error_msg = f"Template-Image '{Config.USER_TEMPLATE_IMAGE}' nicht gefunden"
print(f"[SPAWNER] ERROR: {error_msg}")
raise Exception(error_msg)
except docker.errors.APIError as e:
error_msg = f"Docker API Fehler: {str(e)}"
print(f"[SPAWNER] ERROR: {error_msg}")
raise Exception(error_msg)
except Exception as e:
print(f"[SPAWNER] ERROR: {str(e)}")
raise
def stop_container(self, container_id):
"""Stoppt einen User-Container"""
try:
container = self._get_client().containers.get(container_id)
container.stop(timeout=10)
return True
except docker.errors.NotFound:
return False
def remove_container(self, container_id):
"""Entfernt einen User-Container komplett"""
try:
container = self._get_client().containers.get(container_id)
container.remove(force=True)
return True
except docker.errors.NotFound:
return False
def get_container_status(self, container_id):
"""Gibt Status eines Containers zurück"""
try:
container = self._get_client().containers.get(container_id)
return container.status
except docker.errors.NotFound:
return 'not_found'
def _get_user_container(self, username):
"""Findet existierenden Container für User"""
filters = {'label': f'spawner.username={username}'}
containers = self._get_client().containers.list(all=True, filters=filters)
return containers[0] if containers else None
def _get_container_port(self, container):
"""Extrahiert Port aus Container-Config"""
return 8080

119
docker-compose.yml Normal file
View File

@ -0,0 +1,119 @@
version: '3.8'
services:
# Flask API Backend
spawner:
build: .
container_name: spawner
restart: unless-stopped
env_file:
- .env
ports:
- "5000:5000" # Optional: Direktzugriff für Debugging
volumes:
# Docker-Socket für Container-Management
- /var/run/docker.sock:/var/run/docker.sock:rw
# Persistente Daten
- ./data:/app/data
# Logs
- ./logs:/app/logs
environment:
# Aus .env-Datei
- SECRET_KEY=${SECRET_KEY}
- JWT_SECRET_KEY=${JWT_SECRET_KEY:-${SECRET_KEY}}
- BASE_DOMAIN=${BASE_DOMAIN}
- TRAEFIK_NETWORK=${TRAEFIK_NETWORK}
- USER_TEMPLATE_IMAGE=${USER_TEMPLATE_IMAGE:-user-service-template:latest}
- SPAWNER_SUBDOMAIN=${SPAWNER_SUBDOMAIN:-coder}
- CORS_ORIGINS=https://${SPAWNER_SUBDOMAIN:-coder}.${BASE_DOMAIN},http://localhost:3000
# Traefik-Konfiguration
- TRAEFIK_CERTRESOLVER=${TRAEFIK_CERTRESOLVER:-lets-encrypt}
- TRAEFIK_ENTRYPOINT=${TRAEFIK_ENTRYPOINT:-websecure}
# Docker-Verbindung
- DOCKER_HOST=${DOCKER_HOST:-unix:///var/run/docker.sock}
networks:
- web
labels:
# Traefik aktivieren
- "traefik.enable=true"
- "traefik.docker.network=web"
# API-Router (hoehere Prioritaet fuer /api/*)
- "traefik.http.routers.spawner-api.rule=Host(`${SPAWNER_SUBDOMAIN:-coder}.${BASE_DOMAIN}`) && PathPrefix(`/api`)"
- "traefik.http.routers.spawner-api.entrypoints=${TRAEFIK_ENTRYPOINT:-websecure}"
- "traefik.http.routers.spawner-api.tls.certresolver=${TRAEFIK_CERTRESOLVER:-lets-encrypt}"
- "traefik.http.routers.spawner-api.priority=200"
- "traefik.http.routers.spawner-api.service=spawner-api-service"
- "traefik.http.services.spawner-api-service.loadbalancer.server.port=5000"
# Legacy-Router fuer alte Flask-Templates (niedrige Prioritaet)
- "traefik.http.routers.spawner-legacy.rule=Host(`${SPAWNER_SUBDOMAIN:-coder}.${BASE_DOMAIN}`) && (PathPrefix(`/login`) || PathPrefix(`/signup`) || PathPrefix(`/logout`) || PathPrefix(`/dashboard`) || PathPrefix(`/container`) || PathPrefix(`/health`))"
- "traefik.http.routers.spawner-legacy.entrypoints=${TRAEFIK_ENTRYPOINT:-websecure}"
- "traefik.http.routers.spawner-legacy.tls.certresolver=${TRAEFIK_CERTRESOLVER:-lets-encrypt}"
- "traefik.http.routers.spawner-legacy.priority=100"
- "traefik.http.routers.spawner-legacy.service=spawner-api-service"
# Metadata
- "spawner.managed=true"
- "spawner.version=2.0.0"
- "spawner.type=api-service"
# Health-Check
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:5000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
# Next.js Frontend
frontend:
build: ./frontend
container_name: spawner-frontend
restart: unless-stopped
environment:
- NEXT_PUBLIC_API_URL=
networks:
- web
labels:
# Traefik aktivieren
- "traefik.enable=true"
- "traefik.docker.network=web"
# Frontend-Router (niedrigere Prioritaet - Catch-All)
- "traefik.http.routers.spawner-frontend.rule=Host(`${SPAWNER_SUBDOMAIN:-coder}.${BASE_DOMAIN}`)"
- "traefik.http.routers.spawner-frontend.entrypoints=${TRAEFIK_ENTRYPOINT:-websecure}"
- "traefik.http.routers.spawner-frontend.tls.certresolver=${TRAEFIK_CERTRESOLVER:-lets-encrypt}"
- "traefik.http.routers.spawner-frontend.priority=50"
- "traefik.http.routers.spawner-frontend.service=spawner-frontend-service"
- "traefik.http.services.spawner-frontend-service.loadbalancer.server.port=3000"
# Metadata
- "spawner.managed=true"
- "spawner.version=2.0.0"
- "spawner.type=frontend-service"
# Health-Check
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
depends_on:
- spawner
# Externes Netzwerk (von deinem Traefik bereits vorhanden)
networks:
web:
external: true

44
docs/README.md Normal file
View File

@ -0,0 +1,44 @@
# Container Spawner - Dokumentation
Willkommen zur Dokumentation des Container Spawner Projekts.
## Inhaltsverzeichnis
| Dokument | Beschreibung |
|----------|--------------|
| [Installation](install/README.md) | Neu- und Update-Installation, Umgebungsvariablen |
| [Architektur](architecture/README.md) | Vollstaendige technische Dokumentation |
| [Sicherheit](security/README.md) | Sicherheitsrisiken und Gegenmassnahmen |
| [Versionen](versions/README.md) | Versionierung und Changelog |
| [Bekannte Bugs](bugs/README.md) | Bekannte Probleme und Workarounds |
| [Best Practices](dos-n-donts/README.md) | Dos and Don'ts fuer Produktion |
## Schnellnavigation
### Fuer Einsteiger
1. [Voraussetzungen](install/README.md#voraussetzungen) pruefen
2. [Neuinstallation](install/README.md#neuinstallation) durchfuehren
3. [Konfiguration](install/README.md#umgebungsvariablen) anpassen
### Fuer Entwickler
- [Architektur-Uebersicht](architecture/README.md#architektur)
- [Komponenten im Detail](architecture/README.md#komponenten-im-detail)
- [Traefik-Integration](architecture/README.md#traefik-integration)
### Fuer Administratoren
- [Sicherheits-Checkliste](security/README.md)
- [Produktions-Empfehlungen](dos-n-donts/README.md)
- [Troubleshooting](architecture/README.md#troubleshooting)
## Projekt-Links
- **Repository**: https://gitea.iotxs.de/RainerWieland/spawner
- **Issue Tracker**: https://gitea.iotxs.de/RainerWieland/spawner/issues
---
**Aktuelle Version**: 0.1.0
**Letzte Aktualisierung**: Januar 2026

2599
docs/architecture/README.md Normal file

File diff suppressed because it is too large Load Diff

248
docs/bugs/README.md Normal file
View File

@ -0,0 +1,248 @@
# Bekannte Bugs und Limitationen
Aktuelle bekannte Probleme und moegliche Workarounds.
## Inhaltsverzeichnis
- [Bekannte Limitationen](#bekannte-limitationen)
- [Bekannte Bugs](#bekannte-bugs)
- [Workarounds](#workarounds)
- [Issue Tracker](#issue-tracker)
---
## Bekannte Limitationen
### Container Auto-Shutdown nicht implementiert
**Status**: Geplant fuer v0.2.0
**Beschreibung**: Die Variable `CONTAINER_IDLE_TIMEOUT` ist definiert, aber die Logik zum automatischen Stoppen inaktiver Container fehlt noch.
**Auswirkung**: Container laufen unbegrenzt weiter, auch bei Inaktivitaet.
**Workaround**: Manuelles Aufraumen mit Cron-Job:
```bash
# cleanup-idle.sh
#!/bin/bash
# Container die aelter als 24h sind und keinen Traffic haben
docker ps --filter 'label=spawner.managed=true' \
--format '{{.ID}} {{.RunningFor}}' | \
grep -E 'days|weeks' | \
awk '{print $1}' | \
xargs -r docker stop
```
---
### Keine Volume-Persistenz
**Status**: Geplant fuer v0.2.0
**Beschreibung**: User-Daten in Containern gehen bei Neustart verloren.
**Auswirkung**: Alle Dateien die ein User im Container erstellt werden bei Restart geloescht.
**Workaround**: Volume-Mounts manuell in `container_manager.py` hinzufuegen:
```python
# In spawn_container()
volumes = {
f'/data/users/{username}': {
'bind': '/app/data',
'mode': 'rw'
}
}
```
---
### Kein Multi-Template-Support
**Status**: Geplant fuer v1.0.0
**Beschreibung**: Alle User erhalten das gleiche Container-Template.
**Auswirkung**: Keine Moeglichkeit verschiedene Umgebungen anzubieten (z.B. Python, Node.js).
**Workaround**: Mehrere Spawner-Instanzen mit unterschiedlichen `USER_TEMPLATE_IMAGE` Werten.
---
### Minimale Input-Validierung
**Status**: Bekannt
**Beschreibung**: Username und Email werden nur minimal validiert.
**Auswirkung**: Potenzielle Injection-Risiken bei speziellen Zeichen.
**Workaround**: Siehe [Sicherheits-Dokumentation](../security/README.md#input-validierung)
---
### Kein Rate-Limiting
**Status**: Geplant fuer v1.0.0
**Beschreibung**: Keine Begrenzung von Login-Versuchen oder API-Aufrufen.
**Auswirkung**: Anfaellig fuer Brute-Force-Angriffe.
**Workaround**: Rate-Limiting via Traefik:
```yaml
# In docker-compose.yml Labels
labels:
- "traefik.http.middlewares.ratelimit.ratelimit.average=10"
- "traefik.http.middlewares.ratelimit.ratelimit.burst=20"
- "traefik.http.routers.spawner.middlewares=ratelimit"
```
---
### Kein Admin-Dashboard
**Status**: Geplant fuer v0.2.0
**Beschreibung**: Keine Web-UI zum Verwalten von Usern und Containern.
**Workaround**: Direkte Datenbank-/Docker-Befehle:
```bash
# User auflisten
docker exec spawner sqlite3 /app/data/users.db "SELECT id, username, email FROM user"
# Container auflisten
docker ps --filter 'label=spawner.managed=true'
# Container eines Users stoppen
docker stop user-<username>-<id>
```
---
## Bekannte Bugs
### BUG-001: Health-Check schlaegt bei erstem Start fehl
**Schweregrad**: Niedrig
**Beschreibung**: Der Health-Check kann beim allerersten Start fehlschlagen, bevor die Datenbank initialisiert ist.
**Schritte zum Reproduzieren**:
1. Frische Installation ohne existierende DB
2. `docker-compose up -d`
3. Sofortiger Health-Check: `curl http://localhost:5000/health`
**Erwartetes Verhalten**: 200 OK
**Tatsaechliches Verhalten**: 503 oder Connection Refused
**Workaround**: 5-10 Sekunden warten nach Start.
**Status**: Akzeptiert (normales Verhalten bei Kaltstart)
---
### BUG-002: Container-Neustart loescht Container-ID nicht bei Fehler
**Schweregrad**: Mittel
**Beschreibung**: Wenn ein Container-Spawn fehlschlaegt, bleibt die alte Container-ID im User-Record.
**Schritte zum Reproduzieren**:
1. User hat laufenden Container
2. Admin loescht Container manuell: `docker rm -f user-xxx`
3. User klickt "Neustart" im Dashboard
4. Spawn schlaegt fehl (z.B. Image nicht gefunden)
5. Container-ID zeigt auf nicht-existierenden Container
**Workaround**: Container-ID manuell zuruecksetzen:
```bash
docker exec spawner sqlite3 /app/data/users.db \
"UPDATE user SET container_id=NULL WHERE username='<username>'"
```
**Status**: Fix geplant
---
## Workarounds
### Alle User-Container auflisten
```bash
docker ps --filter 'label=spawner.managed=true' \
--format 'table {{.Names}}\t{{.Status}}\t{{.RunningFor}}'
```
### Container-Ressourcen pruefen
```bash
docker stats --filter 'label=spawner.managed=true' --no-stream
```
### Verwaiste Container aufraumen
```bash
# Container ohne zugehoerigen User finden
for container in $(docker ps -q --filter 'label=spawner.managed=true'); do
username=$(docker inspect $container --format '{{index .Config.Labels "spawner.username"}}')
exists=$(docker exec spawner sqlite3 /app/data/users.db \
"SELECT COUNT(*) FROM user WHERE username='$username'")
if [ "$exists" = "0" ]; then
echo "Verwaist: $container ($username)"
# docker rm -f $container # Zum Loeschen auskommentieren
fi
done
```
### Datenbank-Backup manuell erstellen
```bash
docker exec spawner sqlite3 /app/data/users.db ".backup '/app/data/backup-$(date +%Y%m%d).db'"
docker cp spawner:/app/data/backup-*.db ./backups/
```
---
## Issue Tracker
Neue Bugs oder Feature-Requests bitte hier melden:
**Repository Issues**: https://gitea.iotxs.de/RainerWieland/spawner/issues
### Issue erstellen - Vorlage
```markdown
## Beschreibung
[Kurze Beschreibung des Problems]
## Schritte zum Reproduzieren
1. [Erster Schritt]
2. [Zweiter Schritt]
3. [...]
## Erwartetes Verhalten
[Was sollte passieren]
## Tatsaechliches Verhalten
[Was passiert stattdessen]
## Umgebung
- Spawner Version: [z.B. 0.1.0]
- Docker Version: [z.B. 24.0.5]
- OS: [z.B. Ubuntu 22.04]
## Logs
\`\`\`
[Relevante Log-Ausgaben]
\`\`\`
```
---
Zurueck zur [Dokumentations-Uebersicht](../README.md)

411
docs/dos-n-donts/README.md Normal file
View File

@ -0,0 +1,411 @@
# Best Practices - Dos and Don'ts
Empfehlungen fuer den sicheren und effizienten Betrieb des Container Spawners.
## Inhaltsverzeichnis
- [Produktions-Checkliste](#produktions-checkliste)
- [Dos - Empfohlene Praktiken](#dos---empfohlene-praktiken)
- [Don'ts - Zu vermeiden](#donts---zu-vermeiden)
- [Haeufige Fehler](#haeufige-fehler)
---
## Produktions-Checkliste
### Vor dem Go-Live
| Kategorie | Aufgabe | Status |
|-----------|---------|--------|
| **Sicherheit** | SECRET_KEY generiert (min. 32 Bytes) | [ ] |
| | `.env` nicht im Repository | [ ] |
| | HTTPS aktiviert | [ ] |
| | Docker Socket Proxy konfiguriert | [ ] |
| **Konfiguration** | BASE_DOMAIN korrekt | [ ] |
| | TRAEFIK_NETWORK existiert | [ ] |
| | Resource-Limits angemessen | [ ] |
| **Infrastruktur** | DNS-Eintraege (Wildcard) | [ ] |
| | Traefik laeuft stabil | [ ] |
| | Firewall konfiguriert | [ ] |
| **Monitoring** | Health-Check funktioniert | [ ] |
| | Logs werden geschrieben | [ ] |
| | Backup-Strategie | [ ] |
| **Testing** | Login/Signup funktioniert | [ ] |
| | Container wird erstellt | [ ] |
| | Subdomain erreichbar | [ ] |
---
## Dos - Empfohlene Praktiken
### Konfiguration
**DO: SECRET_KEY sicher generieren**
```bash
# Guter Key (32 Bytes = 64 Hex-Zeichen)
python3 -c "import secrets; print(secrets.token_hex(32))"
```
**DO: Umgebungsvariablen fuer sensible Daten**
```bash
# In .env (nie committen!)
SECRET_KEY=abc123...
DATABASE_URL=postgresql://...
```
**DO: Resource-Limits setzen**
```bash
# In .env
DEFAULT_MEMORY_LIMIT=512m
DEFAULT_CPU_QUOTA=50000
```
---
### Deployment
**DO: Docker Compose fuer Orchestrierung**
```bash
# Nicht einzelne docker run Befehle
docker-compose up -d
docker-compose logs -f
docker-compose down
```
**DO: Images taggen**
```bash
# Versionierte Tags statt :latest in Produktion
docker build -t spawner:0.1.0 .
docker build -t spawner:latest .
```
**DO: Health-Checks nutzen**
```yaml
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:5000/health"]
interval: 30s
timeout: 10s
retries: 3
```
---
### Monitoring
**DO: Logs zentralisieren**
```yaml
# docker-compose.yml
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
```
**DO: Regelmaessige Backups**
```bash
# Cronjob fuer taegliches Backup
0 2 * * * /pfad/zu/spawner/backup.sh
```
**DO: Disk-Space ueberwachen**
```bash
# Docker-Ressourcen pruefen
docker system df
docker system prune -f # Vorsicht in Produktion!
```
---
### Sicherheit
**DO: HTTPS erzwingen**
```yaml
# Traefik-Labels
- "traefik.http.routers.spawner.entrypoints=websecure"
- "traefik.http.routers.spawner.tls=true"
```
**DO: Minimale Berechtigungen**
```yaml
# Docker Socket Proxy statt direktem Zugriff
DOCKER_HOST: tcp://docker-proxy:2375
```
**DO: Container als Non-Root**
```dockerfile
# Im User-Template
USER nginx
# oder
RUN useradd -m appuser && chown -R appuser /app
USER appuser
```
---
## Don'ts - Zu vermeiden
### Konfiguration
**DON'T: Hardcoded Secrets**
```python
# NIEMALS!
SECRET_KEY = "supersecret123"
```
**DON'T: Debug-Mode in Produktion**
```python
# NIEMALS in Produktion!
app.run(debug=True)
FLASK_DEBUG=1
```
**DON'T: Schwache Keys**
```bash
# ZU KURZ / ZU EINFACH
SECRET_KEY=test
SECRET_KEY=12345
```
---
### Deployment
**DON'T: Manuelles Container-Management**
```bash
# Vermeiden - besser docker-compose
docker run -d --name spawner ...
docker stop spawner
docker rm spawner
```
**DON'T: Unversionierte Images in Produktion**
```bash
# Vermeiden
USER_TEMPLATE_IMAGE=user-service-template:latest
# Besser
USER_TEMPLATE_IMAGE=user-service-template:0.1.0
```
**DON'T: Ohne Health-Checks deployen**
---
### Sicherheit
**DON'T: Docker Socket direkt exponieren**
```yaml
# NIEMALS!
ports:
- "2375:2375" # Docker API oeffentlich
```
**DON'T: .env committen**
```bash
# .gitignore MUSS enthalten:
.env
*.db
```
**DON'T: Container ohne Resource-Limits**
```python
# Vermeiden - DoS-Risiko
container = client.containers.run(image)
# Besser
container = client.containers.run(
image,
mem_limit='512m',
cpu_quota=50000
)
```
**DON'T: Root-Container in Produktion**
---
### Wartung
**DON'T: Logs ignorieren**
```bash
# Regelmaessig pruefen!
docker-compose logs --tail=100 spawner
```
**DON'T: Backups vergessen**
**DON'T: Updates aufschieben**
```bash
# Regelmaessig aktualisieren
git pull origin main
docker-compose build
docker-compose up -d
```
---
## Haeufige Fehler
### 1. "Connection refused" beim Health-Check
**Ursache**: Container noch nicht bereit
**Loesung**: Warten oder start_period erhoehen:
```yaml
healthcheck:
start_period: 30s
```
---
### 2. "Network not found"
**Ursache**: Traefik-Netzwerk existiert nicht
**Loesung**:
```bash
docker network create web
# Oder TRAEFIK_NETWORK in .env anpassen
```
---
### 3. "Permission denied" bei Docker Socket
**Ursache**: Spawner-Container hat keinen Zugriff
**Loesung**:
```yaml
volumes:
- /var/run/docker.sock:/var/run/docker.sock:rw
```
Oder Docker-Gruppe:
```bash
sudo usermod -aG docker $USER
```
---
### 4. "Image not found"
**Ursache**: User-Template nicht gebaut
**Loesung**:
```bash
docker build -t user-service-template:latest ./user-template/
```
---
### 5. Subdomain nicht erreichbar
**Ursache**: DNS oder Traefik-Konfiguration
**Diagnose**:
```bash
# DNS pruefen
nslookup username.example.com
# Traefik-Routes pruefen (Dashboard)
# Container-Labels pruefen
docker inspect user-xxx | jq '.[0].Config.Labels'
```
---
### 6. "Database locked"
**Ursache**: SQLite-Konkurrenzzugriff
**Loesung fuer Produktion**: PostgreSQL verwenden:
```bash
DATABASE_URL=postgresql://spawner:pass@postgres:5432/spawner
```
---
### 7. Container startet, aber Service nicht erreichbar
**Ursache**: Falscher Port in Labels
**Loesung**: Port in `container_manager.py` pruefen:
```python
# Muss mit EXPOSE im Dockerfile uebereinstimmen
f'traefik.http.services.user{user_id}.loadbalancer.server.port': '8080'
```
---
## Quick Reference
### Nuetzliche Befehle
```bash
# Status
docker-compose ps
docker ps --filter 'label=spawner.managed=true'
# Logs
docker-compose logs -f spawner
docker logs user-xxx-1
# Neustart
docker-compose restart spawner
# Komplett neu
docker-compose down
docker-compose up -d --build
# Cleanup
docker system prune -f
docker volume prune -f
```
### Debugging
```bash
# In Container einsteigen
docker exec -it spawner bash
# Python-Shell
docker exec -it spawner python
# Datenbank
docker exec spawner sqlite3 /app/data/users.db ".tables"
```
---
Zurueck zur [Dokumentations-Uebersicht](../README.md)

BIN
docs/images/info.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

350
docs/install/README.md Normal file
View File

@ -0,0 +1,350 @@
# Installation
Anleitung zur Installation und Aktualisierung des Container Spawner.
## Inhaltsverzeichnis
- [Voraussetzungen](#voraussetzungen)
- [Neuinstallation](#neuinstallation)
- [Update/Upgrade](#updateupgrade)
- [Umgebungsvariablen](#umgebungsvariablen)
- [Manuelle Installation](#manuelle-installation)
- [Troubleshooting](#troubleshooting)
---
## Voraussetzungen
### Hardware
| Komponente | Minimum | Empfohlen |
|------------|---------|-----------|
| RAM | 2 GB | 4+ GB |
| Disk | 20 GB | 50+ GB |
| CPU | 2 Cores | 4+ Cores |
### Software
- **Docker**: Version 20.10+
- **Docker Compose**: Version 2.0+
- **Git**: Fuer Repository-Clone
- **Traefik**: Version 2.x oder 3.x (laufend)
- **curl** oder **wget**: Fuer Installationsskript
### Netzwerk
- **Port 5000**: Spawner-Service (intern)
- **Port 80/443**: Traefik Entrypoints
- **Docker-Netzwerk**: Traefik-Netzwerk muss existieren (Standard: `web`)
- **DNS**: Wildcard-DNS fuer Subdomains oder manuelle Eintraege
---
## Neuinstallation
### Schnellstart (Ein-Befehl-Installation)
```bash
# In ein leeres Verzeichnis wechseln
mkdir spawner && cd spawner
# Installationsskript ausfuehren
curl -sSL https://gitea.iotxs.de/RainerWieland/spawner/raw/branch/main/install.sh | bash
```
Das Skript erkennt automatisch, dass keine `.env` existiert und:
1. Laedt `.env.example` herunter
2. Gibt Anweisungen zur Konfiguration
### Konfiguration anpassen
```bash
# Vorlage kopieren
cp .env.example .env
# Werte anpassen
nano .env
```
**Wichtig**: Mindestens diese Werte anpassen:
```bash
# Secret-Key generieren
python3 -c "import secrets; print(secrets.token_hex(32))"
# In .env eintragen:
SECRET_KEY=<generierter-key>
BASE_DOMAIN=deine-domain.de
SPAWNER_SUBDOMAIN=coder
TRAEFIK_NETWORK=web # Name deines Traefik-Netzwerks
```
### Installation abschliessen
```bash
bash install.sh
```
Das Skript:
1. Klont das Repository
2. Erstellt Verzeichnisse und setzt Berechtigungen (`data/`, `logs/`, `.env`)
3. Prueft/erstellt Docker-Netzwerk
4. Baut alle Docker-Images
5. Startet die Container
---
## Update/Upgrade
### Automatisches Update
```bash
cd /pfad/zu/spawner
bash install.sh
```
Das Skript erkennt automatisch ein bestehendes Git-Repository und:
1. Holt neueste Aenderungen (`git pull`)
2. Prueft/aktualisiert Verzeichnisrechte
3. Baut Images neu
4. Startet Container neu
### Manuelles Update
```bash
cd /pfad/zu/spawner
# Aenderungen holen
git fetch origin
git pull origin main
# Images neu bauen
docker-compose down
docker build --no-cache -t user-service-template:latest ./user-template/
docker-compose build
# Container starten
docker-compose up -d
```
### Rollback
```bash
# Zu spezifischer Version zurueck
git checkout v0.1.0
docker-compose down
docker-compose build
docker-compose up -d
```
---
## Umgebungsvariablen
### Pflicht-Variablen
| Variable | Beschreibung | Beispiel |
|----------|--------------|----------|
| `SECRET_KEY` | Flask Session Secret | `python3 -c "import secrets; print(secrets.token_hex(32))"` |
| `BASE_DOMAIN` | Haupt-Domain | `example.com` |
| `SPAWNER_SUBDOMAIN` | Subdomain fuer Spawner-UI | `coder` |
| `TRAEFIK_NETWORK` | Docker-Netzwerk fuer Traefik | `web` |
### Traefik-Variablen
| Variable | Standard | Beschreibung |
|----------|----------|--------------|
| `TRAEFIK_CERTRESOLVER` | `lets-encrypt` | Name des Certificate Resolvers aus traefik.yml |
| `TRAEFIK_ENTRYPOINT` | `websecure` | HTTPS Entrypoint Name |
### Optionale Variablen
| Variable | Standard | Beschreibung |
|----------|----------|--------------|
| `USER_TEMPLATE_IMAGE` | `user-service-template:latest` | Docker-Image fuer User-Container |
| `DEFAULT_MEMORY_LIMIT` | `512m` | RAM-Limit pro Container |
| `DEFAULT_CPU_QUOTA` | `50000` | CPU-Quota (50000 = 0.5 CPU) |
| `SPAWNER_PORT` | `5000` | Interner Port des Spawners |
| `LOG_LEVEL` | `INFO` | Log-Level (DEBUG, INFO, WARNING, ERROR) |
| `JWT_ACCESS_TOKEN_EXPIRES` | `3600` | JWT Token Gueltigkeitsdauer (Sekunden) |
| `CONTAINER_IDLE_TIMEOUT` | `3600` | Timeout in Sekunden (noch nicht implementiert) |
### User-Templates
Es stehen zwei Templates fuer User-Container zur Verfuegung:
| Image | Verzeichnis | Beschreibung |
|-------|-------------|--------------|
| `user-service-template:latest` | `user-template/` | Einfache nginx-Willkommensseite (Standard) |
| `user-template-next:latest` | `user-template-next/` | Moderne Next.js React-Anwendung |
Um ein anderes Template zu verwenden, aendere `USER_TEMPLATE_IMAGE` in `.env`:
```bash
USER_TEMPLATE_IMAGE=user-template-next:latest
```
### Produktions-Variablen
| Variable | Beschreibung |
|----------|--------------|
| `DATABASE_URL` | PostgreSQL-Verbindung (statt SQLite) |
| `JWT_SECRET_KEY` | Separater JWT-Secret |
| `CORS_ORIGINS` | Erlaubte CORS-Origins |
| `DOCKER_HOST` | Docker Socket Pfad (Standard: unix:///var/run/docker.sock) |
---
## Manuelle Installation
Falls das Installationsskript nicht verwendet werden kann:
```bash
# 1. Repository klonen
git clone https://gitea.iotxs.de/RainerWieland/spawner.git
cd spawner
# 2. Konfiguration erstellen
cp .env.example .env
nano .env # Werte anpassen
# 3. Verzeichnisse und Rechte setzen
mkdir -p data logs
chmod 755 data logs
chmod 600 .env
# 4. Docker-Netzwerk pruefen
docker network ls | grep web
# Falls nicht vorhanden:
docker network create web
# 5. User-Template Images bauen
docker build -t user-service-template:latest ./user-template/
docker build -t user-template-next:latest ./user-template-next/ # Optional
# 6. Spawner bauen und starten
docker-compose build
docker-compose up -d
# 7. Logs pruefen
docker-compose logs -f spawner
```
---
## Troubleshooting
### Spawner startet nicht
```bash
# Logs pruefen
docker-compose logs spawner
# Health-Check
curl http://localhost:5000/health
```
**Haeufige Ursachen**:
- `.env` fehlt oder hat falsche Werte
- Docker-Socket nicht gemountet
- Netzwerk existiert nicht
### Container wird nicht erstellt
```bash
# Docker-Verbindung testen
docker ps
# Template-Image pruefen
docker images | grep user-service-template
```
**Haeufige Ursachen**:
- Template-Image nicht gebaut
- Netzwerk-Name falsch in `.env`
### Traefik routet nicht
```bash
# Traefik-Dashboard pruefen (falls aktiviert)
# Container-Labels pruefen
docker inspect spawner | jq '.[0].Config.Labels'
# Netzwerk-Verbindung pruefen
docker network inspect web | grep spawner
```
**Haeufige Ursachen**:
- Container nicht im Traefik-Netzwerk
- Labels falsch konfiguriert
- DNS nicht konfiguriert
### Datenbank-Fehler
```bash
# In Container einsteigen
docker exec -it spawner bash
# DB manuell initialisieren
python -c "from app import app, db; app.app_context().push(); db.create_all()"
```
---
## Verzeichnisrechte
Das Installationsskript setzt die Berechtigungen automatisch. Bei manueller Installation muessen diese selbst gesetzt werden.
### Vom Skript automatisch gesetzt
| Pfad | Berechtigung | Zweck |
|------|--------------|-------|
| `./data/` | `755` | SQLite-Datenbank |
| `./logs/` | `755` | Log-Dateien |
| `./.env` | `600` | Sensible Konfiguration (nur Owner) |
| `./install.sh` | `+x` | Ausfuehrbar |
### Vom System benoetigt
| Pfad | Berechtigung | Zweck |
|------|--------------|-------|
| `/var/run/docker.sock` | `rw` | Docker-API-Zugriff |
### Manuelle Rechte setzen
```bash
# Verzeichnisse erstellen
mkdir -p data logs
# Berechtigungen setzen
chmod 755 data logs
chmod 600 .env
chmod +x install.sh
```
### Non-Root Container
Falls der Spawner-Container als non-root User laeuft, muessen die Verzeichnisse fuer diesen beschreibbar sein:
```bash
# Option 1: Volle Schreibrechte (einfach, aber weniger sicher)
chmod 777 data logs
# Option 2: Owner auf Container-UID setzen (empfohlen)
# UID des Container-Users ermitteln und setzen
chown 1000:1000 data logs
```
### Synology NAS Hinweis
Auf Synology NAS (DSM) kann es noetig sein, die Verzeichnisse dem Docker-User zuzuweisen:
```bash
# Als root auf der Synology
chown -R 1000:1000 /volume1/docker/spawner/data
chown -R 1000:1000 /volume1/docker/spawner/logs
```
---
Zurueck zur [Dokumentations-Uebersicht](../README.md)

312
docs/security/README.md Normal file
View File

@ -0,0 +1,312 @@
# Sicherheit
Sicherheitsrisiken und Gegenmassnahmen fuer den Container Spawner.
## Inhaltsverzeichnis
- [Docker Socket Risiko](#docker-socket-risiko)
- [Container Isolation](#container-isolation)
- [Session-Sicherheit](#session-sicherheit)
- [Input-Validierung](#input-validierung)
- [Secrets Management](#secrets-management)
- [Netzwerksicherheit](#netzwerksicherheit)
- [Sicherheits-Checkliste](#sicherheits-checkliste)
---
## Docker Socket Risiko
### Problem
Der Spawner benoetigt Zugriff auf `/var/run/docker.sock` um Container zu erstellen. Dies entspricht Root-Privilegien auf dem Host-System.
### Risiken
- Ein kompromittierter Spawner kann alle Container kontrollieren
- Potenzieller Container-Escape moeglich
- Zugriff auf Host-Dateisystem via Volume-Mounts
### Gegenmassnahmen
**Option 1: Docker Socket Proxy (Empfohlen fuer Produktion)**
```yaml
services:
docker-proxy:
image: tecnativa/docker-socket-proxy
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
environment:
CONTAINERS: 1 # Container-Operationen erlauben
NETWORKS: 1 # Netzwerk-Operationen erlauben
SERVICES: 0 # Swarm-Services blockieren
SWARM: 0 # Swarm-Operationen blockieren
VOLUMES: 0 # Volume-Operationen blockieren
IMAGES: 1 # Image-Operationen erlauben
networks:
- internal
spawner:
environment:
DOCKER_HOST: tcp://docker-proxy:2375
networks:
- internal
- web
networks:
internal:
internal: true # Kein externer Zugriff
```
**Option 2: Minimale Permissions**
```bash
# User-Namespace aktivieren (in /etc/docker/daemon.json)
{
"userns-remap": "default"
}
```
---
## Container Isolation
### Aktuelle Massnahmen
| Massnahme | Status | Beschreibung |
|-----------|--------|--------------|
| Memory-Limit | Aktiv | Standard 512m pro Container |
| CPU-Quota | Aktiv | Standard 0.5 CPU pro Container |
| Restart-Policy | Aktiv | `unless-stopped` |
| Network-Isolation | Teilweise | Alle im gleichen Traefik-Netzwerk |
### Empfehlungen
**Read-Only Filesystem**
```python
# In container_manager.py
container = client.containers.run(
read_only=True,
tmpfs={'/tmp': 'size=100M,noexec'}
)
```
**Security Options**
```python
container = client.containers.run(
security_opt=['no-new-privileges:true'],
cap_drop=['ALL'],
cap_add=['NET_BIND_SERVICE'] # Nur wenn noetig
)
```
**Separate Netzwerke pro User** (Fuer hohe Isolation)
```python
# Dediziertes Netzwerk pro User
user_network = f"user-{username}-network"
client.networks.create(user_network, driver='bridge', internal=True)
```
---
## Session-Sicherheit
### Aktuelle Konfiguration
```python
SESSION_COOKIE_SECURE = True # Nur HTTPS (Produktion)
SESSION_COOKIE_HTTPONLY = True # Kein JavaScript-Zugriff
SESSION_COOKIE_SAMESITE = 'Lax' # CSRF-Schutz
PERMANENT_SESSION_LIFETIME = 3600 # 1h Timeout
```
### Empfehlungen
- **SECRET_KEY**: Mindestens 32 Bytes, zufaellig generiert
- **HTTPS erzwingen**: Immer in Produktion
- **Session-Rotation**: Nach Login neue Session-ID
```python
# Session-Rotation nach Login
from flask import session
session.regenerate()
```
---
## Input-Validierung
### Aktuelle Risiken
- Username wird direkt in Container-Namen verwendet
- Minimale Validierung bei Registrierung
### Empfohlene Validierung
```python
import re
def validate_username(username):
"""Validiert Username gegen Injection-Angriffe"""
if not username:
return False, "Username erforderlich"
if len(username) < 3 or len(username) > 20:
return False, "Username muss 3-20 Zeichen lang sein"
if not re.match(r'^[a-zA-Z0-9_]+$', username):
return False, "Nur Buchstaben, Zahlen und Unterstriche erlaubt"
# Reservierte Namen
reserved = ['admin', 'root', 'system', 'spawner', 'traefik']
if username.lower() in reserved:
return False, "Dieser Username ist reserviert"
return True, None
# Container-Name sicher erstellen
def safe_container_name(username, user_id):
"""Erstellt sicheren Container-Namen"""
safe_name = re.sub(r'[^a-zA-Z0-9_-]', '', username)
return f"user-{safe_name}-{user_id}"
```
---
## Secrets Management
### Entwicklung vs. Produktion
| Umgebung | Methode |
|----------|---------|
| Entwicklung | `.env` Datei (nie committen!) |
| Produktion | Docker Secrets oder Vault |
### Docker Secrets (Produktion)
```bash
# Secret erstellen
echo "supersecretkey" | docker secret create flask_secret -
# In docker-compose.yml
services:
spawner:
secrets:
- flask_secret
environment:
SECRET_KEY_FILE: /run/secrets/flask_secret
secrets:
flask_secret:
external: true
```
### Environment-Variable Sicherheit
```bash
# .env NIEMALS committen
echo ".env" >> .gitignore
# Sensible Werte nicht in Logs
LOG_LEVEL=INFO # Nicht DEBUG in Produktion
```
---
## Netzwerksicherheit
### Traefik-Konfiguration
**HTTPS erzwingen**
```yaml
# In container_manager.py Labels
labels={
'traefik.http.routers.user{id}.entrypoints': 'websecure',
'traefik.http.routers.user{id}.tls': 'true',
'traefik.http.routers.user{id}.tls.certresolver': 'letsencrypt',
# HTTP zu HTTPS Redirect
'traefik.http.middlewares.redirect-https.redirectscheme.scheme': 'https',
'traefik.http.routers.user{id}-http.middlewares': 'redirect-https'
}
```
**Rate-Limiting via Traefik**
```yaml
labels={
'traefik.http.middlewares.ratelimit.ratelimit.average': '100',
'traefik.http.middlewares.ratelimit.ratelimit.burst': '50',
'traefik.http.routers.spawner.middlewares': 'ratelimit'
}
```
### Firewall-Empfehlungen
```bash
# Nur Traefik-Ports oeffentlich
ufw allow 80/tcp
ufw allow 443/tcp
# Docker-Socket NIE oeffentlich!
# Port 5000 nur intern (ueber Traefik)
```
---
## Sicherheits-Checkliste
### Vor Go-Live
- [ ] SECRET_KEY generiert (32+ Bytes)
- [ ] `.env` nicht im Repository
- [ ] HTTPS konfiguriert und getestet
- [ ] Docker Socket Proxy aktiviert (Produktion)
- [ ] Resource-Limits angemessen
- [ ] Input-Validierung implementiert
- [ ] Rate-Limiting aktiv
- [ ] Logs auf sensible Daten geprueft
- [ ] Backup-Strategie implementiert
### Regelmaessig pruefen
- [ ] Container auf Vulnerabilities scannen
- [ ] Dependencies aktualisieren
- [ ] Logs auf verdaechtige Aktivitaeten pruefen
- [ ] Unbefugte Container entfernen
### Vulnerability Scanning
```bash
# Mit Trivy scannen
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
aquasec/trivy image spawner:latest
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
aquasec/trivy image user-service-template:latest
```
---
## Incident Response
### Bei Verdacht auf Kompromittierung
1. **Spawner stoppen**: `docker-compose down`
2. **Alle User-Container stoppen**: `docker stop $(docker ps -q --filter 'label=spawner.managed=true')`
3. **Logs sichern**: `docker-compose logs > incident-logs.txt`
4. **Secrets rotieren**: Neue SECRET_KEY generieren
5. **Analyse durchfuehren**
6. **Behobene Version deployen**
### Kontakt
Bei Sicherheitsproblemen: Issue im Repository erstellen (privat falls sensibel)
---
Zurueck zur [Dokumentations-Uebersicht](../README.md)

View File

@ -0,0 +1,63 @@
# Changelog
Alle nennenswerten Aenderungen werden in dieser Datei dokumentiert.
Das Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.0.0/).
---
## [Unreleased]
### Hinzugefuegt
- Dokumentationsstruktur mit mehreren Kategorien
- Automatisches Installationsskript (`install.sh`)
- `.env.example` Vorlage
### Geaendert
- `doc/` Verzeichnis umbenannt zu `docs/`
- README.md ueberarbeitet mit Schnellstart-Anleitung
---
## [0.1.0] - 2026-01-30
### Hinzugefuegt
- Flask-Anwendung mit User-Management
- Docker-Container-Spawning pro User
- Traefik-Integration via Labels
- SQLite-Datenbank fuer User-Daten
- Next.js Frontend
- REST-API fuer Container-Management
- User-Template (nginx-basiert)
- Health-Check Endpoint
- Docker-Compose Setup
### Sicherheit
- Passwort-Hashing mit Werkzeug
- Session-Cookies mit HttpOnly/Secure
- Resource-Limits fuer Container
---
## [0.0.1] - 2026-01-27
### Hinzugefuegt
- Initiales Projekt-Setup
- Grundlegende Dokumentation
---
## Legende
- **Hinzugefuegt**: Neue Features
- **Geaendert**: Aenderungen an bestehenden Features
- **Veraltet**: Features die bald entfernt werden
- **Entfernt**: Entfernte Features
- **Behoben**: Bugfixes
- **Sicherheit**: Sicherheitsrelevante Aenderungen
---
[Unreleased]: https://gitea.iotxs.de/RainerWieland/spawner/compare/v0.1.0...HEAD
[0.1.0]: https://gitea.iotxs.de/RainerWieland/spawner/releases/tag/v0.1.0
[0.0.1]: https://gitea.iotxs.de/RainerWieland/spawner/releases/tag/v0.0.1

68
docs/versions/README.md Normal file
View File

@ -0,0 +1,68 @@
# Versionierung
Dieses Projekt verwendet [Semantic Versioning](https://semver.org/lang/de/).
## Aktuelle Version
**Version**: 0.1.0 (Entwicklung)
**Release-Datum**: Januar 2026
**Status**: Alpha
## Versionierungsschema
```
MAJOR.MINOR.PATCH
MAJOR: Inkompatible API-Aenderungen
MINOR: Neue Features (rueckwaertskompatibel)
PATCH: Bugfixes (rueckwaertskompatibel)
```
### Pre-Release Versionen
- `0.x.x` - Entwicklungsphase, API kann sich aendern
- `1.0.0` - Erstes stabiles Release
## Changelog
Siehe [CHANGELOG.md](CHANGELOG.md) fuer detaillierte Aenderungen.
## Upgrade-Pfade
### Von 0.0.x auf 0.1.0
Keine Breaking Changes. Einfaches Update moeglich:
```bash
bash install.sh
```
## Git-Tags
Releases werden mit Git-Tags markiert:
```bash
# Alle Tags anzeigen
git tag -l
# Zu spezifischer Version wechseln
git checkout v0.1.0
```
## Geplante Features
### Version 0.2.0 (geplant)
- [ ] Container Auto-Shutdown nach Inaktivitaet
- [ ] Volume-Persistenz fuer User-Daten
- [ ] Admin-Dashboard
### Version 1.0.0 (geplant)
- [ ] Multi-Template-Support
- [ ] API-Rate-Limiting
- [ ] PostgreSQL-Support (Standard)
---
Zurueck zur [Dokumentations-Uebersicht](../README.md)

5
frontend/.env.example Normal file
View File

@ -0,0 +1,5 @@
# API-URL (leer lassen für lokale Entwicklung mit Proxy)
NEXT_PUBLIC_API_URL=
# Fuer Produktion:
# NEXT_PUBLIC_API_URL=https://coder.wieland.org

35
frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,35 @@
# Dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
# Testing
/coverage
# Next.js
/.next/
/out/
# Production
/build
# Misc
.DS_Store
*.pem
# Debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Local env files
.env*.local
.env
# Vercel
.vercel
# TypeScript
*.tsbuildinfo
next-env.d.ts

40
frontend/Dockerfile Normal file
View File

@ -0,0 +1,40 @@
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
# Dependencies
COPY package.json package-lock.json* ./
RUN npm ci
# Source code
COPY . .
# Build
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
# Production stage
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Copy standalone build
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

16
frontend/next.config.mjs Normal file
View File

@ -0,0 +1,16 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
async rewrites() {
return [
{
source: '/api/:path*',
destination: process.env.NEXT_PUBLIC_API_URL
? `${process.env.NEXT_PUBLIC_API_URL}/api/:path*`
: 'http://localhost:5000/api/:path*',
},
];
},
};
export default nextConfig;

7337
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

36
frontend/package.json Normal file
View File

@ -0,0 +1,36 @@
{
"name": "container-spawner-frontend",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"next": "14.2.5",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"tailwind-merge": "^2.4.0",
"lucide-react": "^0.408.0",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-avatar": "^1.1.0",
"@radix-ui/react-dropdown-menu": "^2.1.1"
},
"devDependencies": {
"@types/node": "^20.14.10",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"autoprefixer": "^10.4.19",
"eslint": "^8.57.0",
"eslint-config-next": "14.2.5",
"postcss": "^8.4.39",
"tailwindcss": "^3.4.6",
"typescript": "^5.5.3"
}
}

View File

@ -0,0 +1,9 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
export default config;

0
frontend/public/.gitkeep Normal file
View File

View File

@ -0,0 +1,314 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/hooks/use-auth";
import { api, UserResponse } from "@/lib/api";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import {
Container,
ExternalLink,
RefreshCw,
LogOut,
Loader2,
CheckCircle2,
XCircle,
AlertCircle,
} from "lucide-react";
export default function DashboardPage() {
const { user, logout, isLoading: authLoading } = useAuth();
const router = useRouter();
const [userData, setUserData] = useState<UserResponse | null>(null);
const [isRefreshing, setIsRefreshing] = useState(false);
const [isRestarting, setIsRestarting] = useState(false);
const [error, setError] = useState("");
const fetchUserData = useCallback(async () => {
const { data, error } = await api.getUser();
if (data) {
setUserData(data);
} else if (error) {
setError(error);
}
}, []);
useEffect(() => {
if (!authLoading && !user) {
router.replace("/login");
} else if (user) {
fetchUserData();
}
}, [user, authLoading, router, fetchUserData]);
const handleRefresh = async () => {
setIsRefreshing(true);
await fetchUserData();
setIsRefreshing(false);
};
const handleRestart = async () => {
setIsRestarting(true);
setError("");
const { data, error } = await api.restartContainer();
if (error) {
setError(error);
} else {
await fetchUserData();
}
setIsRestarting(false);
};
const handleLogout = async () => {
await logout();
router.push("/login");
};
const getStatusBadge = (status: string) => {
switch (status) {
case "running":
return (
<Badge variant="success" className="gap-1">
<CheckCircle2 className="h-3 w-3" />
Lauft
</Badge>
);
case "exited":
case "stopped":
return (
<Badge variant="destructive" className="gap-1">
<XCircle className="h-3 w-3" />
Gestoppt
</Badge>
);
case "no_container":
return (
<Badge variant="warning" className="gap-1">
<AlertCircle className="h-3 w-3" />
Kein Container
</Badge>
);
default:
return (
<Badge variant="secondary" className="gap-1">
<AlertCircle className="h-3 w-3" />
{status}
</Badge>
);
}
};
if (authLoading || !user) {
return (
<div className="flex min-h-screen items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="min-h-screen bg-muted/50">
{/* Header */}
<header className="border-b bg-background">
<div className="container mx-auto flex h-16 items-center justify-between px-4">
<div className="flex items-center gap-2">
<Container className="h-6 w-6 text-primary" />
<span className="text-lg font-semibold">Container Spawner</span>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Avatar className="h-8 w-8">
<AvatarFallback className="text-xs">
{user.username.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<span className="text-sm font-medium">{user.username}</span>
</div>
<Button variant="ghost" size="sm" onClick={handleLogout}>
<LogOut className="mr-2 h-4 w-4" />
Abmelden
</Button>
</div>
</div>
</header>
{/* Main Content */}
<main className="container mx-auto p-4 md:p-8">
<div className="mb-8">
<h1 className="text-3xl font-bold">Dashboard</h1>
<p className="text-muted-foreground">
Verwalte deinen personlichen Container
</p>
</div>
{error && (
<div className="mb-6 rounded-md bg-destructive/10 p-4 text-sm text-destructive">
{error}
</div>
)}
<div className="grid gap-6 md:grid-cols-2">
{/* Container Status Card */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Container className="h-5 w-5" />
Container Status
</CardTitle>
<CardDescription>
Informationen zu deinem personlichen Container
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Status</span>
{userData ? (
getStatusBadge(userData.container.status)
) : (
<Loader2 className="h-4 w-4 animate-spin" />
)}
</div>
<Separator />
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
Container ID
</span>
<code className="rounded bg-muted px-2 py-1 text-xs">
{userData?.container.id?.slice(0, 12) || "-"}
</code>
</div>
<Separator />
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={handleRefresh}
disabled={isRefreshing}
>
{isRefreshing ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<RefreshCw className="mr-2 h-4 w-4" />
)}
Aktualisieren
</Button>
<Button
variant="default"
size="sm"
onClick={handleRestart}
disabled={isRestarting}
>
{isRestarting ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<RefreshCw className="mr-2 h-4 w-4" />
)}
Neu starten
</Button>
</div>
</CardContent>
</Card>
{/* Service URL Card */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ExternalLink className="h-5 w-5" />
Dein Service
</CardTitle>
<CardDescription>
Zugriff auf deinen personlichen Bereich
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="rounded-md border bg-muted/50 p-4">
<p className="mb-2 text-sm text-muted-foreground">
Deine Service-URL:
</p>
{userData?.container.service_url ? (
<a
href={userData.container.service_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-primary hover:underline"
>
{userData.container.service_url}
<ExternalLink className="h-4 w-4" />
</a>
) : (
<span className="text-muted-foreground">Laden...</span>
)}
</div>
<Button
className="w-full"
asChild
disabled={
!userData?.container.service_url ||
userData?.container.status !== "running"
}
>
<a
href={userData?.container.service_url || "#"}
target="_blank"
rel="noopener noreferrer"
>
<ExternalLink className="mr-2 h-4 w-4" />
Service offnen
</a>
</Button>
{userData?.container.status !== "running" && (
<p className="text-center text-sm text-muted-foreground">
Container muss laufen, um den Service zu nutzen
</p>
)}
</CardContent>
</Card>
{/* User Info Card */}
<Card className="md:col-span-2">
<CardHeader>
<CardTitle>Kontoinformationen</CardTitle>
<CardDescription>Deine personlichen Daten</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-4 md:grid-cols-3">
<div>
<p className="text-sm text-muted-foreground">Benutzername</p>
<p className="font-medium">{user.username}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">E-Mail</p>
<p className="font-medium">{user.email}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Registriert</p>
<p className="font-medium">
{userData?.user.created_at
? new Date(userData.user.created_at).toLocaleDateString(
"de-DE"
)
: "-"}
</p>
</div>
</div>
</CardContent>
</Card>
</div>
</main>
</div>
);
}

View File

@ -0,0 +1,59 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

View File

@ -0,0 +1,25 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import { AuthProvider } from "@/hooks/use-auth";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "Container Spawner",
description: "Dein persönlicher Container-Service",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="de">
<body className={inter.className}>
<AuthProvider>{children}</AuthProvider>
</body>
</html>
);
}

View File

@ -0,0 +1,121 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { useAuth } from "@/hooks/use-auth";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Container, Loader2 } from "lucide-react";
export default function LoginPage() {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const { login, user, isLoading } = useAuth();
const router = useRouter();
useEffect(() => {
if (!isLoading && user) {
router.replace("/dashboard");
}
}, [user, isLoading, router]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
setIsSubmitting(true);
const result = await login(username, password);
if (result.success) {
router.push("/dashboard");
} else {
setError(result.error || "Login fehlgeschlagen");
setIsSubmitting(false);
}
};
if (isLoading) {
return (
<div className="flex min-h-screen items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-muted/50 p-4">
<Card className="w-full max-w-md">
<CardHeader className="space-y-1 text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-primary">
<Container className="h-6 w-6 text-primary-foreground" />
</div>
<CardTitle className="text-2xl font-bold">Willkommen</CardTitle>
<CardDescription>
Melde dich an, um auf deinen Container zuzugreifen
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
)}
<div className="space-y-2">
<Label htmlFor="username">Benutzername</Label>
<Input
id="username"
type="text"
placeholder="dein-username"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
disabled={isSubmitting}
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Passwort</Label>
<Input
id="password"
type="password"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
disabled={isSubmitting}
/>
</div>
<Button type="submit" className="w-full" disabled={isSubmitting}>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Anmelden...
</>
) : (
"Anmelden"
)}
</Button>
</form>
<div className="mt-6 text-center text-sm">
Noch kein Konto?{" "}
<Link href="/signup" className="text-primary hover:underline">
Registrieren
</Link>
</div>
</CardContent>
</Card>
</div>
);
}

26
frontend/src/app/page.tsx Normal file
View File

@ -0,0 +1,26 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/hooks/use-auth";
export default function Home() {
const { user, isLoading } = useAuth();
const router = useRouter();
useEffect(() => {
if (!isLoading) {
if (user) {
router.replace("/dashboard");
} else {
router.replace("/login");
}
}
}, [user, isLoading, router]);
return (
<div className="flex min-h-screen items-center justify-center">
<div className="animate-pulse text-muted-foreground">Laden...</div>
</div>
);
}

View File

@ -0,0 +1,166 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { useAuth } from "@/hooks/use-auth";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Container, Loader2 } from "lucide-react";
export default function SignupPage() {
const [username, setUsername] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [error, setError] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const { signup, user, isLoading } = useAuth();
const router = useRouter();
useEffect(() => {
if (!isLoading && user) {
router.replace("/dashboard");
}
}, [user, isLoading, router]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
if (password !== confirmPassword) {
setError("Passworter stimmen nicht uberein");
return;
}
if (password.length < 6) {
setError("Passwort muss mindestens 6 Zeichen lang sein");
return;
}
if (username.length < 3) {
setError("Benutzername muss mindestens 3 Zeichen lang sein");
return;
}
setIsSubmitting(true);
const result = await signup(username, email, password);
if (result.success) {
router.push("/dashboard");
} else {
setError(result.error || "Registrierung fehlgeschlagen");
setIsSubmitting(false);
}
};
if (isLoading) {
return (
<div className="flex min-h-screen items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-muted/50 p-4">
<Card className="w-full max-w-md">
<CardHeader className="space-y-1 text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-primary">
<Container className="h-6 w-6 text-primary-foreground" />
</div>
<CardTitle className="text-2xl font-bold">Konto erstellen</CardTitle>
<CardDescription>
Registriere dich, um deinen eigenen Container zu erhalten
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
)}
<div className="space-y-2">
<Label htmlFor="username">Benutzername</Label>
<Input
id="username"
type="text"
placeholder="dein-username"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
disabled={isSubmitting}
/>
<p className="text-xs text-muted-foreground">
Dieser wird Teil deiner Service-URL
</p>
</div>
<div className="space-y-2">
<Label htmlFor="email">E-Mail</Label>
<Input
id="email"
type="email"
placeholder="name@beispiel.de"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
disabled={isSubmitting}
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Passwort</Label>
<Input
id="password"
type="password"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
disabled={isSubmitting}
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">Passwort bestatigen</Label>
<Input
id="confirmPassword"
type="password"
placeholder="••••••••"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
disabled={isSubmitting}
/>
</div>
<Button type="submit" className="w-full" disabled={isSubmitting}>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Container wird erstellt...
</>
) : (
"Registrieren"
)}
</Button>
</form>
<div className="mt-6 text-center text-sm">
Bereits ein Konto?{" "}
<Link href="/login" className="text-primary hover:underline">
Anmelden
</Link>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,50 @@
"use client";
import * as React from "react";
import * as AvatarPrimitive from "@radix-ui/react-avatar";
import { cn } from "@/lib/utils";
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
));
Avatar.displayName = AvatarPrimitive.Root.displayName;
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
));
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted",
className
)}
{...props}
/>
));
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
export { Avatar, AvatarImage, AvatarFallback };

View File

@ -0,0 +1,40 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
success:
"border-transparent bg-green-500 text-white hover:bg-green-500/80",
warning:
"border-transparent bg-yellow-500 text-white hover:bg-yellow-500/80",
},
},
defaultVariants: {
variant: "default",
},
}
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
export { Badge, badgeVariants };

View File

@ -0,0 +1,56 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = "Button";
export { Button, buttonVariants };

View File

@ -0,0 +1,79 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
));
Card.displayName = "Card";
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
));
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
));
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
));
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
));
CardFooter.displayName = "CardFooter";
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };

View File

@ -0,0 +1,25 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
);
}
);
Input.displayName = "Input";
export { Input };

View File

@ -0,0 +1,26 @@
"use client";
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
);
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };

View File

@ -0,0 +1,31 @@
"use client";
import * as React from "react";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import { cn } from "@/lib/utils";
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
);
Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator };

View File

@ -0,0 +1,129 @@
"use client";
import {
createContext,
useContext,
useState,
useEffect,
ReactNode,
} from "react";
import { api, LoginResponse, UserResponse } from "@/lib/api";
interface User {
id: number;
username: string;
email: string;
}
interface AuthContextType {
user: User | null;
token: string | null;
isLoading: boolean;
login: (
username: string,
password: string
) => Promise<{ success: boolean; error?: string }>;
signup: (
username: string,
email: string,
password: string
) => Promise<{ success: boolean; error?: string }>;
logout: () => Promise<void>;
refreshUser: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [token, setToken] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const storedToken = localStorage.getItem("token");
if (storedToken) {
setToken(storedToken);
fetchUser(storedToken);
} else {
setIsLoading(false);
}
}, []);
const fetchUser = async (accessToken?: string) => {
const currentToken = accessToken || token;
if (!currentToken) {
setIsLoading(false);
return;
}
const { data, error } = await api.getUser();
if (data && !error) {
setUser(data.user);
} else {
localStorage.removeItem("token");
setToken(null);
setUser(null);
}
setIsLoading(false);
};
const login = async (
username: string,
password: string
): Promise<{ success: boolean; error?: string }> => {
const { data, error } = await api.login(username, password);
if (error || !data) {
return { success: false, error: error || "Login fehlgeschlagen" };
}
localStorage.setItem("token", data.access_token);
setToken(data.access_token);
setUser(data.user);
return { success: true };
};
const signup = async (
username: string,
email: string,
password: string
): Promise<{ success: boolean; error?: string }> => {
const { data, error } = await api.signup(username, email, password);
if (error || !data) {
return { success: false, error: error || "Registrierung fehlgeschlagen" };
}
localStorage.setItem("token", data.access_token);
setToken(data.access_token);
setUser(data.user);
return { success: true };
};
const logout = async () => {
await api.logout();
localStorage.removeItem("token");
setToken(null);
setUser(null);
};
const refreshUser = async () => {
await fetchUser();
};
return (
<AuthContext.Provider
value={{ user, token, isLoading, login, signup, logout, refreshUser }}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
}

View File

@ -0,0 +1,57 @@
import type { Config } from "tailwindcss";
const config: Config = {
darkMode: ["class"],
content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
},
},
plugins: [],
};
export default config;

26
frontend/tsconfig.json Normal file
View File

@ -0,0 +1,26 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

313
install.sh Normal file
View File

@ -0,0 +1,313 @@
#!/bin/bash
set -e
# ============================================================
# Container Spawner - Installationsskript
# https://gitea.iotxs.de/RainerWieland/spawner
# ============================================================
REPO_URL="https://gitea.iotxs.de/RainerWieland/spawner.git"
RAW_URL="https://gitea.iotxs.de/RainerWieland/spawner/raw/branch/main"
INSTALL_DIR="${PWD}"
VERSION="0.1.0"
# Farben fuer Output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
echo ""
echo "============================================================"
echo " Container Spawner Installation v${VERSION}"
echo "============================================================"
echo ""
# ============================================================
# 1. Pruefe .env
# ============================================================
if [ ! -f "${INSTALL_DIR}/.env" ]; then
echo -e "${YELLOW}HINWEIS: Keine .env-Datei gefunden!${NC}"
echo ""
# Erstelle .env.example aus Repository
echo "Lade .env.example herunter..."
if command -v curl >/dev/null 2>&1; then
curl -sSL "${RAW_URL}/.env.example" -o "${INSTALL_DIR}/.env.example"
elif command -v wget >/dev/null 2>&1; then
wget -q "${RAW_URL}/.env.example" -O "${INSTALL_DIR}/.env.example"
else
echo -e "${RED}Fehler: Weder curl noch wget gefunden!${NC}"
exit 1
fi
echo -e "${GREEN}Vorlage erstellt: .env.example${NC}"
echo ""
echo "Naechste Schritte:"
echo " 1. Kopiere die Vorlage: cp .env.example .env"
echo " 2. Passe die Werte an: nano .env"
echo " - SECRET_KEY generieren (siehe Kommentar in .env)"
echo " - BASE_DOMAIN setzen"
echo " - TRAEFIK_NETWORK pruefen"
echo " 3. Fuehre erneut aus: bash install.sh"
echo ""
exit 0
fi
echo -e "${GREEN}.env-Datei gefunden${NC}"
# Lade .env Variablen
set -a
source "${INSTALL_DIR}/.env"
set +a
# ============================================================
# 2. Pruefe Voraussetzungen
# ============================================================
echo ""
echo "Pruefe Voraussetzungen..."
# Docker
if ! command -v docker >/dev/null 2>&1; then
echo -e "${RED}Fehler: Docker nicht gefunden!${NC}"
echo "Installiere Docker: https://docs.docker.com/get-docker/"
exit 1
fi
echo -e " Docker: ${GREEN}OK${NC}"
# Docker Compose
if command -v docker-compose >/dev/null 2>&1; then
COMPOSE_CMD="docker-compose"
elif docker compose version >/dev/null 2>&1; then
COMPOSE_CMD="docker compose"
else
echo -e "${RED}Fehler: docker-compose nicht gefunden!${NC}"
exit 1
fi
echo -e " Docker Compose: ${GREEN}OK${NC} (${COMPOSE_CMD})"
# Git
if ! command -v git >/dev/null 2>&1; then
echo -e "${RED}Fehler: Git nicht gefunden!${NC}"
exit 1
fi
echo -e " Git: ${GREEN}OK${NC}"
# ============================================================
# 3. Pruefe ob bereits installiert (Update vs. Neuinstallation)
# ============================================================
echo ""
if [ -d "${INSTALL_DIR}/.git" ]; then
echo -e "${YELLOW}Update erkannt - hole neueste Aenderungen...${NC}"
# Sichere lokale Aenderungen
if ! git diff --quiet 2>/dev/null; then
echo "Lokale Aenderungen gefunden, erstelle Stash..."
git stash
fi
git fetch origin
git pull origin main
echo -e "${GREEN}Repository aktualisiert${NC}"
else
echo "Neuinstallation - klone Repository..."
# Temporaeres Verzeichnis fuer Clone
TEMP_DIR=$(mktemp -d)
git clone "${REPO_URL}" "${TEMP_DIR}"
# Kopiere Dateien (ueberschreibt nicht .env)
shopt -s dotglob
for item in "${TEMP_DIR}"/*; do
basename_item=$(basename "$item")
if [ "$basename_item" != ".env" ] && [ "$basename_item" != ".git" ]; then
cp -r "$item" "${INSTALL_DIR}/"
fi
done
# .git Verzeichnis kopieren fuer Updates
cp -r "${TEMP_DIR}/.git" "${INSTALL_DIR}/"
rm -rf "${TEMP_DIR}"
echo -e "${GREEN}Repository geklont${NC}"
fi
# ============================================================
# 4. Verzeichnisse und Rechte setzen
# ============================================================
echo ""
echo "Setze Verzeichnisse und Berechtigungen..."
# Verzeichnisse erstellen falls nicht vorhanden
mkdir -p "${INSTALL_DIR}/data"
mkdir -p "${INSTALL_DIR}/logs"
# Berechtigungen setzen (rwx fuer Owner, rx fuer Group/Other)
chmod 755 "${INSTALL_DIR}/data"
chmod 755 "${INSTALL_DIR}/logs"
# Fuer Docker: Verzeichnisse muessen vom Container beschreibbar sein
# Option 1: Wenn Container als root laeuft (Standard) - 755 reicht
# Option 2: Wenn Container als non-root laeuft - 777 oder chown noetig
# Pruefen ob wir als root laufen (fuer chown)
if [ "$(id -u)" = "0" ]; then
# Als root: Owner auf aktuellen User setzen (oder Docker-User)
# Standard: belassen wie es ist (root kann alles)
echo -e " data/: ${GREEN}OK${NC} (755, root)"
echo -e " logs/: ${GREEN}OK${NC} (755, root)"
else
# Als normaler User: Verzeichnisse muessen beschreibbar sein
# Docker-Container laeuft meist als root, daher 755 ausreichend
# Falls Container als non-root laeuft, auf 777 setzen:
# chmod 777 "${INSTALL_DIR}/data" "${INSTALL_DIR}/logs"
echo -e " data/: ${GREEN}OK${NC} (755)"
echo -e " logs/: ${GREEN}OK${NC} (755)"
fi
# .env Datei schuetzen (nur Owner kann lesen/schreiben)
if [ -f "${INSTALL_DIR}/.env" ]; then
chmod 600 "${INSTALL_DIR}/.env"
echo -e " .env: ${GREEN}OK${NC} (600, nur Owner)"
fi
# install.sh ausfuehrbar machen
if [ -f "${INSTALL_DIR}/install.sh" ]; then
chmod +x "${INSTALL_DIR}/install.sh"
fi
# ============================================================
# 5. Docker-Netzwerk pruefen/erstellen
# ============================================================
echo ""
NETWORK="${TRAEFIK_NETWORK:-web}"
echo "Pruefe Docker-Netzwerk: ${NETWORK}"
if docker network inspect "${NETWORK}" >/dev/null 2>&1; then
echo -e " Netzwerk '${NETWORK}': ${GREEN}existiert${NC}"
else
echo -e " ${YELLOW}Netzwerk '${NETWORK}' nicht gefunden - erstelle...${NC}"
docker network create "${NETWORK}"
echo -e " Netzwerk '${NETWORK}': ${GREEN}erstellt${NC}"
fi
# ============================================================
# 6. Docker-Images bauen
# ============================================================
echo ""
echo "Baue Docker-Images..."
# Stoppe laufende Container
${COMPOSE_CMD} down 2>/dev/null || true
# User-Template Image bauen (fuer User-Container)
if [ -d "${INSTALL_DIR}/user-template" ]; then
echo " [1/4] Baue user-service-template (User-Container)..."
if docker build --no-cache -t user-service-template:latest "${INSTALL_DIR}/user-template/" > /dev/null 2>&1; then
echo -e " user-service-template: ${GREEN}OK${NC}"
else
echo -e " user-service-template: ${RED}FEHLER${NC}"
echo " Versuche mit detaillierter Ausgabe..."
docker build --no-cache -t user-service-template:latest "${INSTALL_DIR}/user-template/"
exit 1
fi
fi
# User-Template-Next Image bauen (alternatives Template, optional)
if [ -d "${INSTALL_DIR}/user-template-next" ]; then
echo " [2/4] Baue user-template-next (alternatives Template)..."
if docker build -t user-template-next:latest "${INSTALL_DIR}/user-template-next/" > /dev/null 2>&1; then
echo -e " user-template-next: ${GREEN}OK${NC}"
else
echo -e " user-template-next: ${YELLOW}WARNUNG - Build fehlgeschlagen (optional)${NC}"
fi
fi
# Spawner Backend Image bauen
echo " [3/4] Baue Spawner API (Flask Backend)..."
if docker build -t spawner:latest "${INSTALL_DIR}/" > /dev/null 2>&1; then
echo -e " spawner-api: ${GREEN}OK${NC}"
else
echo -e " spawner-api: ${RED}FEHLER${NC}"
docker build -t spawner:latest "${INSTALL_DIR}/"
exit 1
fi
# Frontend Image bauen
if [ -d "${INSTALL_DIR}/frontend" ]; then
echo " [4/4] Baue Frontend (Next.js)..."
echo " Dies kann einige Minuten dauern (npm install + build)..."
if docker build -t spawner-frontend:latest "${INSTALL_DIR}/frontend/" 2>&1 | grep -E "(error|Error|ERROR)" > /dev/null; then
echo -e " spawner-frontend: ${RED}FEHLER${NC}"
echo " Versuche mit detaillierter Ausgabe..."
docker build -t spawner-frontend:latest "${INSTALL_DIR}/frontend/"
exit 1
else
docker build -t spawner-frontend:latest "${INSTALL_DIR}/frontend/" > /dev/null 2>&1
echo -e " spawner-frontend: ${GREEN}OK${NC}"
fi
fi
echo ""
echo "Alle Images erfolgreich gebaut."
# ============================================================
# 7. Container starten
# ============================================================
echo ""
echo "Starte Container..."
${COMPOSE_CMD} up -d
# Warte auf Health-Check
echo ""
echo "Warte auf Spawner-Start..."
sleep 5
# Health-Check fuer API
SPAWNER_URL="http://localhost:${SPAWNER_PORT:-5000}/health"
if curl -sf "${SPAWNER_URL}" >/dev/null 2>&1; then
echo -e " API Health-Check: ${GREEN}OK${NC}"
else
echo -e " API Health-Check: ${YELLOW}Noch nicht bereit (normal beim ersten Start)${NC}"
fi
# Health-Check fuer Frontend
if curl -sf "http://localhost:3000/" >/dev/null 2>&1; then
echo -e " Frontend Health-Check: ${GREEN}OK${NC}"
else
echo -e " Frontend Health-Check: ${YELLOW}Noch nicht bereit (normal beim ersten Start)${NC}"
fi
# ============================================================
# 8. Fertig
# ============================================================
echo ""
echo "============================================================"
echo -e " ${GREEN}Installation abgeschlossen!${NC}"
echo "============================================================"
echo ""
# URLs anzeigen
SCHEME="https"
if [ "${BASE_DOMAIN:-localhost}" = "localhost" ]; then
SCHEME="http"
fi
FULL_URL="${SCHEME}://${SPAWNER_SUBDOMAIN:-coder}.${BASE_DOMAIN:-localhost}"
echo "Zugriff:"
echo " Frontend: ${FULL_URL}"
echo " API: ${FULL_URL}/api"
echo " Health: ${FULL_URL}/health"
echo ""
echo "Lokaler Zugriff (ohne Traefik):"
echo " API: http://localhost:${SPAWNER_PORT:-5000}"
echo " Frontend: http://localhost:3000"
echo ""
echo "Nuetzliche Befehle:"
echo " Status: ${COMPOSE_CMD} ps"
echo " Logs API: ${COMPOSE_CMD} logs -f spawner"
echo " Logs FE: ${COMPOSE_CMD} logs -f frontend"
echo " Neustart: ${COMPOSE_CMD} restart"
echo " Stoppen: ${COMPOSE_CMD} down"
echo ""

21
models.py Normal file
View File

@ -0,0 +1,21 @@
from flask_sqlalchemy import SQLAlchemy
from flask_login import UserMixin
from werkzeug.security import generate_password_hash, check_password_hash
from datetime import datetime
db = SQLAlchemy()
class User(UserMixin, db.Model):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False)
email = db.Column(db.String(120), unique=True, nullable=False)
password_hash = db.Column(db.String(200), nullable=False)
container_id = db.Column(db.String(100), nullable=True)
container_port = db.Column(db.Integer, nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
def set_password(self, password):
self.password_hash = generate_password_hash(password)
def check_password(self, password):
return check_password_hash(self.password_hash, password)

12
requirements.txt Normal file
View File

@ -0,0 +1,12 @@
flask==3.0.0
flask-login==0.6.3
flask-sqlalchemy==3.1.1
flask-jwt-extended==4.6.0
flask-cors==4.0.0
werkzeug==3.0.1
docker==6.1.0
requests==2.28.0
urllib3==1.26.12
requests-unixsocket>=0.3.0
PyJWT==2.8.0
python-dotenv==1.0.0

32
user-template-next/.gitignore vendored Normal file
View File

@ -0,0 +1,32 @@
# Dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
# Testing
/coverage
# Next.js
/.next/
/out/
# Production
/build
# Misc
.DS_Store
*.pem
# Debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Local env files
.env*.local
.env
# TypeScript
*.tsbuildinfo
next-env.d.ts

View File

@ -0,0 +1,30 @@
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
COPY . .
RUN npm run build
# Production stage - serve static files with nginx
FROM nginx:alpine
# Copy static export
COPY --from=builder /app/out /usr/share/nginx/html
# Nginx config for SPA
RUN echo 'server { \
listen 8080; \
root /usr/share/nginx/html; \
index index.html; \
location / { \
try_files $uri $uri/ /index.html; \
} \
}' > /etc/nginx/conf.d/default.conf
EXPOSE 8080
CMD ["nginx", "-g", "daemon off;"]

View File

@ -0,0 +1,10 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'export',
trailingSlash: true,
images: {
unoptimized: true,
},
};
export default nextConfig;

View File

@ -0,0 +1,32 @@
{
"name": "user-template",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"next": "14.2.5",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"tailwind-merge": "^2.4.0",
"lucide-react": "^0.408.0",
"@radix-ui/react-slot": "^1.1.0"
},
"devDependencies": {
"@types/node": "^20.14.10",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"autoprefixer": "^10.4.19",
"eslint": "^8.57.0",
"eslint-config-next": "14.2.5",
"postcss": "^8.4.39",
"tailwindcss": "^3.4.6",
"typescript": "^5.5.3"
}
}

View File

@ -0,0 +1,9 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
export default config;

View File

@ -0,0 +1,51 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--primary: 221.2 83.2% 53.3%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 221.2 83.2% 53.3%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--primary: 217.2 91.2% 59.8%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 224.3 76.3% 48%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

View File

@ -0,0 +1,22 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "Mein Container",
description: "Dein persoenlicher Container-Bereich",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="de">
<body className={inter.className}>{children}</body>
</html>
);
}

View File

@ -0,0 +1,133 @@
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Container, Rocket, Code, Terminal } from "lucide-react";
export default function Home() {
return (
<div className="min-h-screen bg-gradient-to-b from-background to-muted/50">
{/* Header */}
<header className="border-b bg-background/80 backdrop-blur-sm">
<div className="container mx-auto flex h-16 items-center px-4">
<div className="flex items-center gap-2">
<Container className="h-6 w-6 text-primary" />
<span className="text-lg font-semibold">Mein Container</span>
</div>
</div>
</header>
{/* Hero Section */}
<main className="container mx-auto px-4 py-16">
<div className="mx-auto max-w-3xl text-center">
<div className="mb-6 inline-flex h-16 w-16 items-center justify-center rounded-full bg-primary/10">
<Rocket className="h-8 w-8 text-primary" />
</div>
<h1 className="mb-4 text-4xl font-bold tracking-tight sm:text-5xl">
Willkommen in deinem Container!
</h1>
<p className="mb-8 text-lg text-muted-foreground">
Dies ist dein persoenlicher Bereich. Du kannst diese Seite anpassen
und eigene Anwendungen deployen.
</p>
</div>
{/* Feature Cards */}
<div className="mx-auto mt-16 grid max-w-4xl gap-6 md:grid-cols-2">
<Card>
<CardHeader>
<div className="mb-2 flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<Code className="h-5 w-5 text-primary" />
</div>
<CardTitle>Entwicklung</CardTitle>
<CardDescription>
Starte hier mit deiner eigenen Anwendung
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Dieses Template basiert auf Next.js und React. Du kannst es
anpassen oder komplett ersetzen.
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<div className="mb-2 flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<Terminal className="h-5 w-5 text-primary" />
</div>
<CardTitle>Technologie</CardTitle>
<CardDescription>Moderne Tools fuer moderne Apps</CardDescription>
</CardHeader>
<CardContent>
<ul className="space-y-2 text-sm text-muted-foreground">
<li className="flex items-center gap-2">
<span className="h-1.5 w-1.5 rounded-full bg-primary" />
Next.js 14 mit App Router
</li>
<li className="flex items-center gap-2">
<span className="h-1.5 w-1.5 rounded-full bg-primary" />
TypeScript fuer Typsicherheit
</li>
<li className="flex items-center gap-2">
<span className="h-1.5 w-1.5 rounded-full bg-primary" />
Tailwind CSS fuer Styling
</li>
<li className="flex items-center gap-2">
<span className="h-1.5 w-1.5 rounded-full bg-primary" />
shadcn/ui Komponenten
</li>
</ul>
</CardContent>
</Card>
</div>
{/* CTA Section */}
<div className="mx-auto mt-16 max-w-xl text-center">
<Card className="bg-primary/5 border-primary/20">
<CardContent className="pt-6">
<h2 className="mb-2 text-xl font-semibold">Bereit loszulegen?</h2>
<p className="mb-4 text-sm text-muted-foreground">
Bearbeite die Dateien im Container, um diese Seite anzupassen.
</p>
<div className="flex justify-center gap-2">
<Button variant="outline" asChild>
<a
href="https://nextjs.org/docs"
target="_blank"
rel="noopener noreferrer"
>
Next.js Docs
</a>
</Button>
<Button variant="outline" asChild>
<a
href="https://ui.shadcn.com"
target="_blank"
rel="noopener noreferrer"
>
shadcn/ui
</a>
</Button>
</div>
</CardContent>
</Card>
</div>
</main>
{/* Footer */}
<footer className="border-t mt-16">
<div className="container mx-auto flex h-16 items-center justify-center px-4">
<p className="text-sm text-muted-foreground">
Container Spawner - Dein persoenlicher Entwicklungsbereich
</p>
</div>
</footer>
</div>
);
}

View File

@ -0,0 +1,56 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = "Button";
export { Button, buttonVariants };

View File

@ -0,0 +1,79 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
));
Card.displayName = "Card";
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
));
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
));
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
));
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
));
CardFooter.displayName = "CardFooter";
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };

View File

@ -0,0 +1,49 @@
import type { Config } from "tailwindcss";
const config: Config = {
darkMode: ["class"],
content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
},
},
plugins: [],
};
export default config;

View File

@ -0,0 +1,26 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

23
user-template/Dockerfile Normal file
View File

@ -0,0 +1,23 @@
FROM nginxinc/nginx-unprivileged:alpine
# Wechsel zu root um Pakete zu installieren
USER root
# gettext für envsubst installieren
RUN apk add --no-cache gettext
# HTML-Template kopieren
COPY index.html /usr/share/nginx/html/index.html.template
# Nginx-Konfiguration kopieren
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Entrypoint Script kopieren und executable machen
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh && \
chmod 777 /usr/share/nginx/html
EXPOSE 8080
# Entrypoint Script starten (läuft als root, startet Nginx)
ENTRYPOINT ["/entrypoint.sh"]

View File

@ -0,0 +1,8 @@
#!/bin/sh
set -e
# Umgebungsvariablen in die HTML-Datei einfügen
envsubst < /usr/share/nginx/html/index.html.template > /usr/share/nginx/html/index.html
# Nginx starten
exec nginx -g "daemon off;"

284
user-template/index.html Normal file

File diff suppressed because one or more lines are too long

15
user-template/nginx.conf Normal file
View File

@ -0,0 +1,15 @@
server {
listen 8080;
server_name _;
# Root-Verzeichnis
root /usr/share/nginx/html;
# Alle Anfragen einfach auf root Location
location / {
try_files $uri $uri/ /index.html;
}
# Error pages
error_page 404 /index.html;
}