feat(config): TZ + NTP_SERVER in .env mit sinnvollen Defaults

- .env / .env.example: TZ=Europe/Berlin und NTP_SERVER=ptbtime1.ptb.de
  (offizielle deutsche Zeitreferenz, hohe Verfuegbarkeit)
- app/__init__.py setzt prozessweite Zeitzone frueh via os.environ+tzset
- Leichtgewichtiger SNTP-Client (pure socket, keine deps) prueft den
  Uhr-Offset beim Start im Hintergrund-Thread und warnt bei Abweichung >5s
- Dockerfile installiert tzdata und ENV TZ=Europe/Berlin als Fallback

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Hacker 2026-04-14 15:15:57 +02:00
parent ba3e619963
commit dca064427e
5 changed files with 104 additions and 0 deletions

View File

@ -31,6 +31,18 @@ FRONTEND_URL=https://cloud.example.com
# Max Upload-Groesse in MB # Max Upload-Groesse in MB
MAX_UPLOAD_SIZE_MB=500 MAX_UPLOAD_SIZE_MB=500
# Zeitzone (prozessweit) - z.B. Europe/Berlin, Europe/Vienna, UTC
# Wirkt auf datetime.now(), strftime %Z und Kalender/Task-Zeitstempel.
TZ=Europe/Berlin
# NTP-Server zum Pruefen der Uhrzeit beim Start (nicht-invasiver Offset-Check
# - im Container kann die Systemuhr nicht gesetzt werden; bei Abweichung >5s
# erscheint eine Warnung im Log, dann bitte die Host-Uhr synchronisieren).
# Leerlassen um den Check zu deaktivieren.
# Default: Physikalisch-Technische Bundesanstalt (offizielle deutsche Zeit).
# Alternativen: ptbtime2.ptb.de, ptbtime3.ptb.de, de.pool.ntp.org, time.cloudflare.com
NTP_SERVER=ptbtime1.ptb.de
# OnlyOffice Document Server (optional) # OnlyOffice Document Server (optional)
# Eigene Subdomain mit HTTPS, z.B. https://office.example.com # Eigene Subdomain mit HTTPS, z.B. https://office.example.com
# JWT wird automatisch vom JWT_SECRET_KEY oben verwendet # JWT wird automatisch vom JWT_SECRET_KEY oben verwendet

View File

@ -13,6 +13,7 @@ WORKDIR /app
# Install system dependencies # Install system dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \ gcc \
tzdata \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Install Python dependencies # Install Python dependencies
@ -30,6 +31,7 @@ RUN mkdir -p /app/data/files
# Environment # Environment
ENV FLASK_ENV=production ENV FLASK_ENV=production
ENV TZ=Europe/Berlin
ENV DATABASE_PATH=/app/data/minicloud.db ENV DATABASE_PATH=/app/data/minicloud.db
ENV UPLOAD_PATH=/app/data/files ENV UPLOAD_PATH=/app/data/files

View File

@ -1,4 +1,5 @@
import os import os
import time
from pathlib import Path from pathlib import Path
from flask import Flask, Response, redirect, send_from_directory from flask import Flask, Response, redirect, send_from_directory
@ -8,6 +9,20 @@ from app.config import Config
from app.extensions import db, bcrypt, migrate from app.extensions import db, bcrypt, migrate
def _configure_timezone(tz_name: str) -> None:
"""Prozess-Zeitzone setzen, sodass datetime.now(), strftime %Z etc.
die konfigurierte TZ verwenden. Sichere no-op wenn tzdata fehlt."""
if not tz_name:
return
os.environ['TZ'] = tz_name
tzset = getattr(time, 'tzset', None)
if tzset:
try:
tzset()
except Exception:
pass
def _auto_migrate(db): def _auto_migrate(db):
"""Add missing columns to existing tables by comparing model definitions """Add missing columns to existing tables by comparing model definitions
with actual database schema. This handles the case where new columns are with actual database schema. This handles the case where new columns are
@ -61,6 +76,9 @@ def _auto_migrate(db):
def create_app(config_class=Config): def create_app(config_class=Config):
# Zeitzone moeglichst frueh setzen - vor allen datetime.now()-Aufrufen
_configure_timezone(getattr(config_class, 'TIMEZONE', None) or os.environ.get('TZ'))
# Check if static frontend build exists (Docker production mode) # Check if static frontend build exists (Docker production mode)
static_dir = Path(__file__).resolve().parent.parent / 'static' static_dir = Path(__file__).resolve().parent.parent / 'static'
if static_dir.exists(): if static_dir.exists():
@ -171,4 +189,15 @@ def create_app(config_class=Config):
from app.services.backup_scheduler import start_backup_scheduler from app.services.backup_scheduler import start_backup_scheduler
start_backup_scheduler(app) start_backup_scheduler(app)
# NTP-Offset gegen den konfigurierten Zeitserver pruefen (nicht fatal).
ntp_server = app.config.get('NTP_SERVER') or ''
if ntp_server.strip():
import threading
from app.services.ntp_check import check_and_log
threading.Thread(
target=check_and_log,
args=(ntp_server.strip(), app.logger),
daemon=True,
).start()
return app return app

View File

@ -40,3 +40,8 @@ class Config:
# CORS # CORS
FRONTEND_URL = os.environ.get('FRONTEND_URL', 'http://localhost:3000') FRONTEND_URL = os.environ.get('FRONTEND_URL', 'http://localhost:3000')
# Zeitzone (prozessweit, wirkt nach time.tzset())
TIMEZONE = os.environ.get('TZ', 'Europe/Berlin')
# NTP-Server fuer Offset-Check beim Start. Leerstring deaktiviert den Check.
NTP_SERVER = os.environ.get('NTP_SERVER', 'ptbtime1.ptb.de')

View File

@ -0,0 +1,56 @@
"""Leichtgewichtiger SNTP-Client zum Pruefen des Zeit-Offsets.
Im Container koennen wir die Systemzeit nicht wirklich setzen (braucht
CAP_SYS_TIME). Aber wir koennen den Offset ermitteln und loggen, damit
der Admin weiss, ob der Host driftet. Fuer einen harten Sync muss auf
dem Host selbst ein NTP-Daemon laufen.
"""
from __future__ import annotations
import socket
import struct
import time
_NTP_EPOCH_OFFSET = 2208988800 # Sekunden zwischen 1900 und 1970
def query_ntp(server: str, timeout: float = 3.0, port: int = 123) -> float | None:
"""Fragt einen NTP-Server und gibt das Offset (Server - Local) in
Sekunden zurueck, oder None bei Fehler."""
packet = b'\x1b' + 47 * b'\0' # LI=0, VN=3, Mode=3 (client)
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.settimeout(timeout)
try:
t0 = time.time()
sock.sendto(packet, (server, port))
data, _ = sock.recvfrom(1024)
t3 = time.time()
except (socket.gaierror, socket.timeout, OSError):
return None
finally:
sock.close()
if len(data) < 48:
return None
# Transmit timestamp: Offset 40, 8 bytes, fixed point 32.32
secs, frac = struct.unpack('!II', data[40:48])
if secs == 0:
return None
t2 = secs - _NTP_EPOCH_OFFSET + frac / 2**32
# Einfacher Offset (sans roundtrip): (t2 - ((t0 + t3) / 2))
return t2 - (t0 + t3) / 2
def check_and_log(server: str, logger=None) -> float | None:
import logging
log = logger or logging.getLogger('ntp')
offset = query_ntp(server)
if offset is None:
log.warning('NTP-Check: Server %s nicht erreichbar', server)
return None
if abs(offset) > 5.0:
log.warning('NTP-Check: Systemzeit weicht um %.2fs von %s ab -> Host-Uhr synchronisieren!',
offset, server)
else:
log.info('NTP-Check: Offset %.3fs gegen %s (ok)', offset, server)
return offset