Initial project structure with documentation
This commit is contained in:
parent
406ed2c158
commit
c363351483
14
.dockerignore
Normal file
14
.dockerignore
Normal 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
95
.env.example
Normal 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
174
.gitignore
vendored
Normal 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
29
Dockerfile
Normal 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"]
|
||||
251
api.py
Normal file
251
api.py
Normal 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
158
app.py
Normal 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
87
auth.py
Normal 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
113
config.py
Normal 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
128
container_manager.py
Normal 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
119
docker-compose.yml
Normal 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
44
docs/README.md
Normal 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
2599
docs/architecture/README.md
Normal file
File diff suppressed because it is too large
Load Diff
248
docs/bugs/README.md
Normal file
248
docs/bugs/README.md
Normal 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
411
docs/dos-n-donts/README.md
Normal 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
BIN
docs/images/info.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.7 MiB |
BIN
docs/images/spawner-logo.png
Normal file
BIN
docs/images/spawner-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 182 KiB |
350
docs/install/README.md
Normal file
350
docs/install/README.md
Normal 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
312
docs/security/README.md
Normal 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)
|
||||
63
docs/versions/CHANGELOG.md
Normal file
63
docs/versions/CHANGELOG.md
Normal 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
68
docs/versions/README.md
Normal 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
5
frontend/.env.example
Normal 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
35
frontend/.gitignore
vendored
Normal 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
40
frontend/Dockerfile
Normal 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
16
frontend/next.config.mjs
Normal 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
7337
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
36
frontend/package.json
Normal file
36
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
9
frontend/postcss.config.mjs
Normal file
9
frontend/postcss.config.mjs
Normal 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
0
frontend/public/.gitkeep
Normal file
314
frontend/src/app/dashboard/page.tsx
Normal file
314
frontend/src/app/dashboard/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
59
frontend/src/app/globals.css
Normal file
59
frontend/src/app/globals.css
Normal 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;
|
||||
}
|
||||
}
|
||||
25
frontend/src/app/layout.tsx
Normal file
25
frontend/src/app/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
121
frontend/src/app/login/page.tsx
Normal file
121
frontend/src/app/login/page.tsx
Normal 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
26
frontend/src/app/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
166
frontend/src/app/signup/page.tsx
Normal file
166
frontend/src/app/signup/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
50
frontend/src/components/ui/avatar.tsx
Normal file
50
frontend/src/components/ui/avatar.tsx
Normal 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 };
|
||||
40
frontend/src/components/ui/badge.tsx
Normal file
40
frontend/src/components/ui/badge.tsx
Normal 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 };
|
||||
56
frontend/src/components/ui/button.tsx
Normal file
56
frontend/src/components/ui/button.tsx
Normal 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 };
|
||||
79
frontend/src/components/ui/card.tsx
Normal file
79
frontend/src/components/ui/card.tsx
Normal 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 };
|
||||
25
frontend/src/components/ui/input.tsx
Normal file
25
frontend/src/components/ui/input.tsx
Normal 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 };
|
||||
26
frontend/src/components/ui/label.tsx
Normal file
26
frontend/src/components/ui/label.tsx
Normal 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 };
|
||||
31
frontend/src/components/ui/separator.tsx
Normal file
31
frontend/src/components/ui/separator.tsx
Normal 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 };
|
||||
129
frontend/src/hooks/use-auth.tsx
Normal file
129
frontend/src/hooks/use-auth.tsx
Normal 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;
|
||||
}
|
||||
57
frontend/tailwind.config.ts
Normal file
57
frontend/tailwind.config.ts
Normal 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
26
frontend/tsconfig.json
Normal 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
313
install.sh
Normal 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
21
models.py
Normal 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
12
requirements.txt
Normal 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
32
user-template-next/.gitignore
vendored
Normal 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
|
||||
30
user-template-next/Dockerfile
Normal file
30
user-template-next/Dockerfile
Normal 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;"]
|
||||
10
user-template-next/next.config.mjs
Normal file
10
user-template-next/next.config.mjs
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: 'export',
|
||||
trailingSlash: true,
|
||||
images: {
|
||||
unoptimized: true,
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
32
user-template-next/package.json
Normal file
32
user-template-next/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
9
user-template-next/postcss.config.mjs
Normal file
9
user-template-next/postcss.config.mjs
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
51
user-template-next/src/app/globals.css
Normal file
51
user-template-next/src/app/globals.css
Normal 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;
|
||||
}
|
||||
}
|
||||
22
user-template-next/src/app/layout.tsx
Normal file
22
user-template-next/src/app/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
133
user-template-next/src/app/page.tsx
Normal file
133
user-template-next/src/app/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
56
user-template-next/src/components/ui/button.tsx
Normal file
56
user-template-next/src/components/ui/button.tsx
Normal 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 };
|
||||
79
user-template-next/src/components/ui/card.tsx
Normal file
79
user-template-next/src/components/ui/card.tsx
Normal 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 };
|
||||
49
user-template-next/tailwind.config.ts
Normal file
49
user-template-next/tailwind.config.ts
Normal 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;
|
||||
26
user-template-next/tsconfig.json
Normal file
26
user-template-next/tsconfig.json
Normal 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
23
user-template/Dockerfile
Normal 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"]
|
||||
8
user-template/entrypoint.sh
Normal file
8
user-template/entrypoint.sh
Normal 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
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
15
user-template/nginx.conf
Normal 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;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user