From b367b6487900de38bfa9d5c097d0c26f2197c5fa Mon Sep 17 00:00:00 2001 From: mr-forust Date: Sat, 15 Nov 2025 23:42:03 +0100 Subject: [PATCH 01/89] fix: removed ssh port-forwarding for gitea --- gitea/compose.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gitea/compose.yaml b/gitea/compose.yaml index 376cda9..e3d7350 100644 --- a/gitea/compose.yaml +++ b/gitea/compose.yaml @@ -25,8 +25,8 @@ services: labels: - "traefik.enable=true" - "traefik.docker.network=traefik-proxy" - ports: - - "2221:22" + # ports: + # - "2221:22" depends_on: - db From 625eb9561b92e62d5492b4ea46a9a78ca4abc0db Mon Sep 17 00:00:00 2001 From: mr-forust Date: Sat, 15 Nov 2025 23:48:35 +0100 Subject: [PATCH 02/89] testing: removed https:// from root url --- gitea/compose.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gitea/compose.yaml b/gitea/compose.yaml index e3d7350..9b70a93 100644 --- a/gitea/compose.yaml +++ b/gitea/compose.yaml @@ -12,7 +12,7 @@ services: - GITEA__database__PASSWD=gitea - GITEA__database__NAME=gitea #Server - - GITEA__server__ROOT_URL=https://gitea.forust.xyz + - GITEA__server__ROOT_URL=gitea.forust.xyz - GITEA__server__SSH_PORT=2221 restart: always networks: From 0dfb09590def7e257204ce2c210185b92d42a806 Mon Sep 17 00:00:00 2001 From: mr-forust Date: Sat, 15 Nov 2025 23:55:02 +0100 Subject: [PATCH 03/89] fix: added ssh domain --- gitea/compose.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/gitea/compose.yaml b/gitea/compose.yaml index 9b70a93..f392306 100644 --- a/gitea/compose.yaml +++ b/gitea/compose.yaml @@ -13,6 +13,7 @@ services: - GITEA__database__NAME=gitea #Server - GITEA__server__ROOT_URL=gitea.forust.xyz + - SSH_DOMAIN=gitea.forust.xyz - GITEA__server__SSH_PORT=2221 restart: always networks: From 6843befac3323c09a64f38c6eb62356d9a4ad995 Mon Sep 17 00:00:00 2001 From: mr-forust Date: Sun, 16 Nov 2025 00:01:35 +0100 Subject: [PATCH 04/89] fix: temp labels for routing SSH for gitea --- gitea/compose.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/gitea/compose.yaml b/gitea/compose.yaml index f392306..b7986ff 100644 --- a/gitea/compose.yaml +++ b/gitea/compose.yaml @@ -26,6 +26,9 @@ services: labels: - "traefik.enable=true" - "traefik.docker.network=traefik-proxy" + - "traefik.tcp.routers.gitea.entrypoints=ssh" + - "traefik.tcp.routers.gitea.rule=HostSNI(`*`)" + - "traefik.tcp.services.gitea.loadbalancer.server.port=22" # ports: # - "2221:22" depends_on: From 7cbc5bf4f32e6550ce9abd3981dff94d08bcd2db Mon Sep 17 00:00:00 2001 From: mr-forust Date: Wed, 26 Nov 2025 17:00:44 +0100 Subject: [PATCH 05/89] feat: edu phpsessid updater bot --- edu_master/.env.example | 7 + edu_master/backend/Dockerfile | 15 -- edu_master/backend/extra_requirements.txt | 1 - edu_master/compose.yaml | 264 +--------------------- edu_master/lessons_bot/Dockerfile | 16 -- edu_master/lessons_bot/config.py | 26 --- edu_master/lessons_bot/handlers.py | 131 ----------- edu_master/lessons_bot/main.py | 42 ---- edu_master/lessons_bot/requirements.txt | 5 - edu_master/lessons_bot/utils.py | 258 --------------------- edu_master/nginx/entrypoint.sh | 8 - edu_master/nginx/nginx.conf | 87 ------- edu_master/phpsessid-bot/Dockerfile | 12 + edu_master/phpsessid-bot/bot.py | 103 +++++++++ edu_master/phpsessid_bot/Dockerfile | 15 -- edu_master/phpsessid_bot/main.py | 216 ------------------ edu_master/phpsessid_bot/requirements.txt | 3 - 17 files changed, 128 insertions(+), 1081 deletions(-) create mode 100644 edu_master/.env.example delete mode 100644 edu_master/backend/Dockerfile delete mode 100644 edu_master/backend/extra_requirements.txt delete mode 100644 edu_master/lessons_bot/Dockerfile delete mode 100644 edu_master/lessons_bot/config.py delete mode 100644 edu_master/lessons_bot/handlers.py delete mode 100644 edu_master/lessons_bot/main.py delete mode 100644 edu_master/lessons_bot/requirements.txt delete mode 100644 edu_master/lessons_bot/utils.py delete mode 100644 edu_master/nginx/entrypoint.sh delete mode 100644 edu_master/nginx/nginx.conf create mode 100644 edu_master/phpsessid-bot/Dockerfile create mode 100644 edu_master/phpsessid-bot/bot.py delete mode 100644 edu_master/phpsessid_bot/Dockerfile delete mode 100644 edu_master/phpsessid_bot/main.py delete mode 100644 edu_master/phpsessid_bot/requirements.txt diff --git a/edu_master/.env.example b/edu_master/.env.example new file mode 100644 index 0000000..70573f0 --- /dev/null +++ b/edu_master/.env.example @@ -0,0 +1,7 @@ +EDU_LOGIN=login +EDU_PASSWORD=password +EDU_URL_LOGIN=https://edu.edu.vn.ua/user/login +EDU_URL_VERIFY=https://edu.edu.vn.ua/course/userlist +EDU_INTERVAL=10 +EDU_USER_AGENT="Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36" +EDU_PHPSESSID= diff --git a/edu_master/backend/Dockerfile b/edu_master/backend/Dockerfile deleted file mode 100644 index 89681fd..0000000 --- a/edu_master/backend/Dockerfile +++ /dev/null @@ -1,15 +0,0 @@ -ARG VERSION -# Use the official WaterCrawl image as the base image -FROM watercrawl/watercrawl:${VERSION:-v0.10.2} - -# Set working directory -WORKDIR /var/www - -# Copy the extra requirements file -COPY extra_requirements.txt /var/www/extra_requirements.txt - -# Install any additional packages -RUN poetry run pip install -r /var/www/extra_requirements.txt - -# The rest of the configuration is inherited from the base image -# The entrypoint and command should be defined in docker-compose.yml diff --git a/edu_master/backend/extra_requirements.txt b/edu_master/backend/extra_requirements.txt deleted file mode 100644 index 54bb109..0000000 --- a/edu_master/backend/extra_requirements.txt +++ /dev/null @@ -1 +0,0 @@ -# Add your additional Python packages here, one per line diff --git a/edu_master/compose.yaml b/edu_master/compose.yaml index 0aa1fbd..13284c6 100644 --- a/edu_master/compose.yaml +++ b/edu_master/compose.yaml @@ -1,262 +1,10 @@ -x-app: &app - build: - context: ./backend/ - dockerfile: Dockerfile - args: - - VERSION=${VERSION:-v0.10.2} - depends_on: - db: - condition: service_healthy - dns: - - 8.8.8.8 - - 1.1.1.1 - environment: - - SECRET_KEY=${SECRET_KEY:-django-insecure-el4wo4a4--=f0+ag#omp@^w4eq^8v4(scda&1a(td_y2@=sh6&} - - API_ENCRYPTION_KEY=${API_ENCRYPTION_KEY:-8zSd6JIuC7ovfZ4AoxG_XmhubW6CPnQWW7Qe_4TD1TQ=} - - DEBUG=${DEBUG:-True} - - ALLOWED_HOSTS=${ALLOWED_HOSTS:-*} - - LANGUAGE_CODE=${LANGUAGE_CODE:-en-us} - - TIME_ZONE=${TIME_ZONE:-UTC} - - USE_I18N=${USE_I18N:-True} - - USE_TZ=${USE_TZ:-True} - - STATIC_ROOT=${STATIC_ROOT:-storage/static/} - - MEDIA_ROOT=${MEDIA_ROOT:-storage/media/} - - LOG_LEVEL=${LOG_LEVEL:-INFO} - - REDIS_URL=${REDIS_URL:-redis://redis:6379/1} - - DATABASE_URL=postgres://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@${POSTGRES_HOST:-db}:${POSTGRES_PORT:-5432}/${POSTGRES_DB:-postgres} - - CELERY_BROKER_URL=${CELERY_BROKER_URL:-redis://redis:6379/0} - - CELERY_RESULT_BACKEND=${CELERY_RESULT_BACKEND:-django-db} - - REDIS_LOCKER_URL=${REDIS_LOCKER_URL:-redis://redis:6379/3} - - MINIO_ENDPOINT=minio:9000 - - MINIO_EXTERNAL_ENDPOINT=nginx - - MINIO_REGION=us-east-1 - - MINIO_ACCESS_KEY=minio - - MINIO_SECRET_KEY=minio123 - - MINIO_USE_HTTPS=False - - MINIO_EXTERNAL_ENDPOINT_USE_HTTPS=False - - MINIO_URL_EXPIRY_HOURS=7 - - MINIO_PRIVATE_BUCKET=private - - MINIO_PUBLIC_BUCKET=public - - CSRF_TRUSTED_ORIGINS=${CSRF_TRUSTED_ORIGINS:-} - - CORS_ALLOWED_ORIGINS=${CORS_ALLOWED_ORIGINS:-} - - CORS_ALLOWED_ORIGIN_REGEXES=${CORS_ALLOWED_ORIGIN_REGEXES:-} - - CORS_ALLOW_ALL_ORIGINS=${CORS_ALLOW_ALL_ORIGINS:-False} - - FRONTEND_URL=${FRONTEND_URL:-http://localhost} - - IS_LOGIN_ACTIVE=${IS_LOGIN_ACTIVE:-True} - - IS_SIGNUP_ACTIVE=${IS_SIGNUP_ACTIVE:-True} - - IS_GITHUB_LOGIN_ACTIVE=${IS_GITHUB_LOGIN_ACTIVE:-True} - - IS_GOOGLE_LOGIN_ACTIVE=${IS_GOOGLE_LOGIN_ACTIVE:-True} - - GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID:-} - - GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET:-} - - GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-} - - GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-} - - ACCESS_TOKEN_LIFETIME_MINUTES=${ACCESS_TOKEN_LIFETIME_MINUTES:-5} - - REFRESH_TOKEN_LIFETIME_DAYS=${REFRESH_TOKEN_LIFETIME_DAYS:-30} - - EMAIL_BACKEND=${EMAIL_BACKEND:-django.core.mail.backends.smtp.EmailBackend} - - EMAIL_HOST=${EMAIL_HOST:-} - - EMAIL_PORT=${EMAIL_PORT:-587} - - EMAIL_USE_TLS=${EMAIL_USE_TLS:-True} - - EMAIL_HOST_USER=${EMAIL_HOST_USER:-} - - EMAIL_HOST_PASSWORD=${EMAIL_HOST_PASSWORD:-} - - DEFAULT_FROM_EMAIL=${DEFAULT_FROM_EMAIL:-} - - SCRAPY_USER_AGENT=${SCRAPY_USER_AGENT:-WaterCrawl/0.1 (+https://github.com/watercrawl/watercrawl)} - - SCRAPY_ROBOTSTXT_OBEY=${SCRAPY_ROBOTSTXT_OBEY:-True} - - SCRAPY_CONCURRENT_REQUESTS=${SCRAPY_CONCURRENT_REQUESTS:-16} - - SCRAPY_DOWNLOAD_DELAY=${SCRAPY_DOWNLOAD_DELAY:-0} - - SCRAPY_CONCURRENT_REQUESTS_PER_DOMAIN=${SCRAPY_CONCURRENT_REQUESTS_PER_DOMAIN:-4} - - SCRAPY_CONCURRENT_REQUESTS_PER_IP=${SCRAPY_CONCURRENT_REQUESTS_PER_IP:-4} - - SCRAPY_COOKIES_ENABLED=${SCRAPY_COOKIES_ENABLED:-False} - - SCRAPY_HTTPCACHE_ENABLED=${SCRAPY_HTTPCACHE_ENABLED:-True} - - SCRAPY_HTTPCACHE_EXPIRATION_SECS=${SCRAPY_HTTPCACHE_EXPIRATION_SECS:-3600} - - SCRAPY_HTTPCACHE_DIR=${SCRAPY_HTTPCACHE_DIR:-httpcache} - - SCRAPY_LOG_LEVEL=${SCRAPY_LOG_LEVEL:-ERROR} - - SCRAPY_GOOGLE_API_KEY=${SCRAPY_GOOGLE_API_KEY:-} - - SCRAPY_GOOGLE_CSE_ID=${SCRAPY_GOOGLE_CSE_ID:-} - - SCRAPY_MAX_NUMBER_OF_SITEMAP_URLS=${SCRAPY_MAX_NUMBER_OF_SITEMAP_URLS:-20000} - - SCRAPY_SITEMAP_CRAWL_PAGE_LIMIT=${SCRAPY_SITEMAP_CRAWL_PAGE_LIMIT:-100} - - PLAYWRIGHT_SERVER=${PLAYWRIGHT_SERVER:-http://playwright:8000} - - PLAYWRIGHT_API_KEY=${PLAYWRIGHT_API_KEY:-your-secret-api-key} - - OPENAI_API_KEY=${OPENAI_API_KEY:-} - - STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY:-} - - STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET:-} - - GOOGLE_ANALYTICS_ID=${GOOGLE_ANALYTICS_ID:-} - - IS_ENTERPRISE_MODE_ACTIVE=${IS_ENTERPRISE_MODE_ACTIVE:-False} - - MAX_CRAWL_DEPTH=${MAX_CRAWL_DEPTH:--1} - - CAPTURE_USAGE_HISTORY=${CAPTURE_USAGE_HISTORY:-True} - - MCP_SERVER=${MCP_SERVER:-http://localhost/sse} - networks: - - traefik-proxy - - default - - n8n - -x-frontend: &frontend - image: watercrawl/frontend:${VERSION:-v0.10.2} - environment: - - VITE_API_BASE_URL=${API_BASE_URL:-http://localhost/api} - depends_on: - - app - services: - nginx: - image: nginx:alpine - volumes: - - ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf.template - - ./nginx/entrypoint.sh:/entrypoint.sh - environment: - - MINIO_PRIVATE_BUCKET=${MINIO_PRIVATE_BUCKET:-private} - - MINIO_PUBLIC_BUCKET=${MINIO_PUBLIC_BUCKET:-public} - command: ["/bin/sh", "/entrypoint.sh"] - depends_on: - - app - - frontend - - minio + session-keeper: + build: ./phpsessid-bot + env_file: .env restart: unless-stopped - networks: - - traefik-proxy - - n8n - - labels: - - "traefik.enable=true" - - "traefik.docker.network=traefik-proxy" - - app: - <<: *app - command: [ "gunicorn", "-b", "0.0.0.0:9000", "-w", "2", "watercrawl.wsgi:application", "--access-logfile", "-", "--error-logfile", "-", "--timeout", "60" ] - - celery: - <<: *app - command: [ "celery", "-A", "watercrawl", "worker", "-l", "info", "-S", "django" ] - dns: - - 1.1.1.1 - - 8.8.8.8 - - celery-beat: - <<: *app - command: [ "celery", "-A", "watercrawl", "beat", "-l", "info", "-S", "django" ] - - frontend: - <<: *frontend - command: [ "npm", "run", "serve" ] - - minio: - image: minio/minio:RELEASE.2024-11-07T00-52-20Z - restart: unless-stopped - volumes: - - ./volumes/minio-data:/data - command: server /data --console-address ":9001" - environment: - - MINIO_BROWSER_REDIRECT_URL=${MINIO_BROWSER_REDIRECT_URL:-http://localhost/minio-console/} - - MINIO_SERVER_URL=${MINIO_SERVER_URL:-http://localhost/} - - MINIO_ROOT_USER=${MINIO_ACCESS_KEY:-minio} - - MINIO_ROOT_PASSWORD=${MINIO_SECRET_KEY:-minio123} - - playwright: - image: watercrawl/playwright:1.1 - restart: unless-stopped - user: root - environment: - - AUTH_API_KEY=${PLAYWRIGHT_API_KEY:-your-secret-api-key} - - PORT=${PLAYWRIGHT_PORT:-8000} - - HOST=${PLAYWRIGHT_HOST:-0.0.0.0} - dns: - - 8.8.8.8 - - 1.1.1.1 - networks: - - traefik-proxy - - n8n - - db: - image: postgres:17.2-alpine3.21 - restart: unless-stopped - environment: - - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-postgres} - - POSTGRES_USER=${POSTGRES_USER:-postgres} - - POSTGRES_DB=${POSTGRES_DB:-postgres} - volumes: - - ./volumes/postgres-db:/var/lib/postgresql/data healthcheck: - test: [ "CMD-SHELL", "pg_isready" ] - interval: 10s + test: ["CMD-SHELL", "test $(find /tmp/last_success -mmin -15)"] + interval: 1m timeout: 5s - retries: 5 - - mcp: - image: watercrawl/mcp:v1.2.0 - restart: unless-stopped - command: [ "sse", "--base-url", "http://app:9000", '--port', '3000', '--endpoint', '/sse' ] - networks: - - n8n - - redis: - image: redis:latest - restart: unless-stopped - - llm: - image: ollama/ollama:latest - restart: unless-stopped - volumes: - - ./volumes/ollama-models:/root/.ollama - environment: - - OLLAMA_DISABLE_TELEMETRY=true - - OLLAMA_KEEP_ALIVE=5m - - OLLAMA_HOST=0.0.0.0:11434 - - OLLAMA_NUM_PARALLEL=1 - - OLLAMA_MAX_LOADED_MODELS=1 - dns: - - 1.1.1.1 - - 8.8.8.8 - networks: - - n8n - -# docker exec -it edu_master-llm-1 ollama pull neural-chat:7b-q4 -# docker exec -it edu_master-llm-1 ollama pull mistral:7b-q4 - - # lessons-bot: - # build: - # context: edu_master/lessons_bot/ - # dockerfile: Dockerfile - # restart: unless-stopped - # environment: - # - LESSONS_BOT_TOKEN=${LESSONS_BOT_TOKEN} - # - N8N_WEBHOOK_URL=${N8N_WEBHOOK_URL:-http://n8n:5678/webhook-test/get-lessons} - # - N8N_SECRET=${N8N_SECRET:-your-secret-token-here} - # - WATERCRAWL_API_URL=${WATERCRAWL_API_URL:-http://app:9000/api} - # - PHPSESSID_BOT_URL=${PHPSESSID_BOT_URL:-http://phpsessid-bot:5000} - # - EDU_HOST=${EDU_HOST:-edu.edu.vn.ua} - # depends_on: - # - n8n - # - app - # - phpsessid-bot - # dns: - # - 1.1.1.1 - # - 8.8.8.8 - # networks: - # - default - - phpsessid-bot: - build: - context: ./phpsessid_bot/ - dockerfile: Dockerfile - restart: unless-stopped - environment: - - EDU_HOST=${EDU_HOST:-edu.edu.vn.ua} - - EDU_LOGIN=${EDU_LOGIN} - - EDU_PASSWORD=${EDU_PASSWORD} - - BOT_PORT=${PHPSESSID_BOT_PORT:-5000} - - BOT_HOST=${PHPSESSID_BOT_HOST:-0.0.0.0} - dns: - - 1.1.1.1 - - 8.8.8.8 - networks: - - default - - -volumes: - n8n_data: - postgres-db: - minio-data: - ollama-models: - lmstudio_data: -networks: - traefik-proxy: - external: true + retries: 3 diff --git a/edu_master/lessons_bot/Dockerfile b/edu_master/lessons_bot/Dockerfile deleted file mode 100644 index 1af5983..0000000 --- a/edu_master/lessons_bot/Dockerfile +++ /dev/null @@ -1,16 +0,0 @@ -FROM python:3.11-slim - -WORKDIR /app - -# Установка зависимостей -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt - -# Копирование кода -COPY config.py . -COPY utils.py . -COPY handlers.py . -COPY main.py . - -# Запуск бота -CMD ["python", "-u", "main.py"] \ No newline at end of file diff --git a/edu_master/lessons_bot/config.py b/edu_master/lessons_bot/config.py deleted file mode 100644 index 8326eca..0000000 --- a/edu_master/lessons_bot/config.py +++ /dev/null @@ -1,26 +0,0 @@ -import os -from dotenv import load_dotenv - -load_dotenv() - -# Telegram -BOT_TOKEN = os.getenv('LESSONS_BOT_TOKEN') - -# n8n -N8N_WEBHOOK_URL = os.getenv('N8N_WEBHOOK_URL', 'http://n8n:5678/webhook/homework-check') -N8N_SECRET = os.getenv('N8N_SECRET', 'your-secret-token-here') - -# WaterCrawl API -WATERCRAWL_API_URL = os.getenv('WATERCRAWL_API_URL', 'http://app:9000/api') - -# PHPSESSID Bot -PHPSESSID_BOT_URL = os.getenv('PHPSESSID_BOT_URL', 'http://phpsessid-bot:5000') - -# EDU site -EDU_HOST = os.getenv('EDU_HOST', 'edu.edu.vn.ua') -EDU_WEBINAR_URL = f'https://{EDU_HOST}/webinar/useractive' - -# Playwright -PLAYWRIGHT_SERVER = os.getenv('PLAYWRIGHT_SERVER', 'http://playwright:8000') -PLAYWRIGHT_API_KEY = os.getenv('PLAYWRIGHT_API_KEY', 'your-secret-api-key') -WEBINAR_WAIT_TIME = int(os.getenv('WEBINAR_WAIT_TIME', '3')) # Секунды ожидания загрузки \ No newline at end of file diff --git a/edu_master/lessons_bot/handlers.py b/edu_master/lessons_bot/handlers.py deleted file mode 100644 index 5ff92dc..0000000 --- a/edu_master/lessons_bot/handlers.py +++ /dev/null @@ -1,131 +0,0 @@ -import logging -import requests -from telegram import Update -from telegram.ext import ContextTypes -import config -from utils import fetch_webinars, format_webinar_message - -logger = logging.getLogger(__name__) - - -async def start(update: Update, context: ContextTypes.DEFAULT_TYPE): - """Команда /start""" - welcome_message = """ -Привет! Я бот для проверки домашних заданий и вебинаров. - -Команды: -/check - Проверить несделанные уроки -/webinar - Проверить активные онлайн уроки -/help - Помощь - """ - await update.message.reply_text(welcome_message, parse_mode='HTML') - - -async def help_command(update: Update, context: ContextTypes.DEFAULT_TYPE): - """Команда /help""" - help_text = """ -Как пользоваться ботом: - -/check - Проверка домашних заданий -- Поиск несделанных уроков - -⏱ Проверка занимает 10-30 секунд - -/webinar - Активные онлайн уроки -- Проверка активных вебинаровв -⏱ Проверка занимает 3-5 секунд - """ - await update.message.reply_text(help_text, parse_mode='HTML') - - -async def check_homework(update: Update, context: ContextTypes.DEFAULT_TYPE): - """Команда /check - запускает проверку уроков""" - chat_id = update.effective_chat.id - user_id = update.effective_user.id - username = update.effective_user.username or "unknown" - - # Отправляем уведомление что начали работу - status_message = await update.message.reply_text("Запускаю проверку уроков...") - - # Формируем данные для n8n - payload = { - "chat_id": chat_id, - "user_id": user_id, - "username": username, - "timestamp": update.message.date.isoformat() - } - - headers = { - "Authorization": f"Bearer {config.N8N_SECRET}", - "Content-Type": "application/json" - } - - try: - logger.info(f"Sending request to n8n for user {user_id}") - - # Отправляем запрос в n8n - response = requests.post( - config.N8N_WEBHOOK_URL, - json=payload, - headers=headers, - timeout=5 # Короткий таймаут т.к. это асинхронный запрос - ) - - if response.status_code == 200: - await status_message.edit_text( - "✅ Запрос принят!\n" - "🔄 Парсинг сайта и анализ данных...\n" - "⏱ Это займет 10-30 секунд" - ) - logger.info(f"Request accepted for user {user_id}") - else: - await status_message.edit_text( - f"Ошибка при отправке запроса. Функция в разработке\n" - f"Код: {response.status_code}" - ) - logger.error(f"n8n returned status {response.status_code}") - - except requests.Timeout: - await status_message.edit_text("⏱ Запрос обрабатывается (таймаут соединения)") - logger.warning(f"Timeout for user {user_id}") - except Exception as e: - await status_message.edit_text(f"❌ Ошибка: {str(e)}") - logger.error(f"Error for user {user_id}: {e}", exc_info=True) - - -async def check_webinar(update: Update, context: ContextTypes.DEFAULT_TYPE): - """Команда /webinar - проверяет активные онлайн уроки""" - user_id = update.effective_user.id - - # Отправляем уведомление что начали работу - status_message = await update.message.reply_text("Проверяю активные вебинары...") - - try: - logger.info(f"Checking webinars for user {user_id}") - - # Получаем список вебинаров - webinars = fetch_webinars() - - if webinars is None: - await status_message.edit_text( - "❌ Не удалось получить информацию о вебинарах\n" - "Попробуйте позже или обратитесь к администратору\n" - "|@MrForust|mr.forust| Либо же прямо сюда." - ) - logger.error(f"Failed to fetch webinars for user {user_id}") - return - - # Форматируем и отправляем результат - message = format_webinar_message(webinars) - await status_message.edit_text(message, parse_mode='HTML', disable_web_page_preview=True) - - logger.info(f"Webinar check completed for user {user_id}: found {len(webinars)} webinars") - - except Exception as e: - await status_message.edit_text(f"❌ Ошибка: {str(e)}") - logger.error(f"Error checking webinars for user {user_id}: {e}", exc_info=True) - - -async def error_handler(update: Update, context: ContextTypes.DEFAULT_TYPE): - """Обработчик ошибок""" - logger.error(f"Update {update} caused error {context.error}", exc_info=context.error) diff --git a/edu_master/lessons_bot/main.py b/edu_master/lessons_bot/main.py deleted file mode 100644 index 5758598..0000000 --- a/edu_master/lessons_bot/main.py +++ /dev/null @@ -1,42 +0,0 @@ -import logging -from telegram import Update -from telegram.ext import Application, CommandHandler -import config -from handlers import start, help_command, check_homework, check_webinar, error_handler - -# Настройка логирования -logging.basicConfig( - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', - level=logging.INFO -) -logger = logging.getLogger(__name__) - - -def main(): - """Запуск бота""" - if not config.BOT_TOKEN: - logger.error("LESSONS_BOT_TOKEN not set!") - return - - # Создаем приложение - application = Application.builder().token(config.BOT_TOKEN).build() - - # Регистрируем обработчики команд - application.add_handler(CommandHandler("start", start)) - application.add_handler(CommandHandler("help", help_command)) - application.add_handler(CommandHandler("check", check_homework)) - application.add_handler(CommandHandler("webinar", check_webinar)) - - # Регистрируем обработчик ошибок - application.add_error_handler(error_handler) - - # Запускаем бота - logger.info("Lessons Bot started!") - logger.info(f"PHPSESSID Bot URL: {config.PHPSESSID_BOT_URL}") - logger.info(f"n8n Webhook URL: {config.N8N_WEBHOOK_URL}") - - application.run_polling(allowed_updates=Update.ALL_TYPES) - - -if __name__ == '__main__': - main() \ No newline at end of file diff --git a/edu_master/lessons_bot/requirements.txt b/edu_master/lessons_bot/requirements.txt deleted file mode 100644 index 9753745..0000000 --- a/edu_master/lessons_bot/requirements.txt +++ /dev/null @@ -1,5 +0,0 @@ -python-telegram-bot==20.7 -requests==2.31.0 -beautifulsoup4==4.12.2 -python-dotenv==1.0.0 -lxml==4.9.3 diff --git a/edu_master/lessons_bot/utils.py b/edu_master/lessons_bot/utils.py deleted file mode 100644 index 7e3c57b..0000000 --- a/edu_master/lessons_bot/utils.py +++ /dev/null @@ -1,258 +0,0 @@ -import logging -import requests -from bs4 import BeautifulSoup -from typing import Optional, Dict, List -import config - -logger = logging.getLogger(__name__) - - -def get_phpsessid() -> Optional[str]: - """ - Получает валидный PHPSESSID через phpsessid-bot - - Returns: - str: PHPSESSID или None в случае ошибки - """ - try: - url = f"{config.PHPSESSID_BOT_URL}/get-session" - logger.info(f"Requesting PHPSESSID from {url}") - - response = requests.post(url, timeout=10) - - if response.status_code == 200: - data = response.json() - if data.get('success'): - phpsessid = data.get('phpsessid') - logger.info(f"Got PHPSESSID: {phpsessid[:10]}...") - return phpsessid - else: - logger.error(f"Failed to get PHPSESSID: {data.get('error')}") - return None - else: - logger.error(f"PHPSESSID bot returned status {response.status_code}") - return None - - except Exception as e: - logger.error(f"Error getting PHPSESSID: {e}", exc_info=True) - return None - - -def parse_webinar_table(html_content: str) -> List[Dict[str, str]]: - """ - Парсит таблицу с вебинарами - - Args: - html_content: HTML контент страницы - - Returns: - List[Dict]: Список вебинаров или пустой список - """ - try: - soup = BeautifulSoup(html_content, 'html.parser') - - # Находим таблицу с вебинарами - meetings_div = soup.find('div', {'id': 'meetings'}) - if not meetings_div: - logger.warning("meetings div not found") - return [] - - table = meetings_div.find('table', {'class': 'table table-zebra'}) - if not table: - logger.warning("table not found") - return [] - - tbody = table.find('tbody') - if not tbody: - logger.warning("tbody not found") - return [] - - rows = tbody.find_all('tr') - if not rows: - return [] - - # Проверяем на сообщение "Жодного онлайн уроку зараз" - first_row = rows[0] - td = first_row.find('td') - if td and 'Жодного онлайн уроку зараз' in td.get_text(strip=True): - logger.info("No webinars available") - return [] - - # Парсим активные вебинары - webinars = [] - for row in rows: - tds = row.find_all('td') - if len(tds) >= 4: - webinar = { - 'topic': tds[0].get_text(strip=True), - 'course': tds[1].get_text(strip=True), - 'teacher': tds[2].get_text(strip=True), - 'join_link': tds[3].find('a')['href'] if tds[3].find('a') else '' - } - webinars.append(webinar) - - logger.info(f"Parsed {len(webinars)} webinars") - return webinars - - except Exception as e: - logger.error(f"Error parsing webinar table: {e}", exc_info=True) - return [] - - -def fetch_webinars_with_playwright() -> Optional[List[Dict[str, str]]]: - """ - Получает список активных вебинаров используя Playwright для динамического контента - - Returns: - List[Dict]: Список вебинаров или None в случае ошибки - """ - # Получаем PHPSESSID - phpsessid = get_phpsessid() - if not phpsessid: - logger.error("Failed to get PHPSESSID") - return None - - try: - # Подготавливаем cookies для Playwright - cookies = [ - { - 'name': 'PHPSESSID', - 'value': phpsessid, - 'domain': config.EDU_HOST, - 'path': '/' - } - ] - - # Запрос к Playwright серверу - playwright_request = { - 'url': config.EDU_WEBINAR_URL, - 'cookies': cookies, - 'wait_until': 'networkidle', # Ждем пока сеть успокоится - 'wait_time': config.WEBINAR_WAIT_TIME * 1000, # Дополнительное ожидание в миллисекундах - 'user_agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36' - } - - headers = { - 'Authorization': f'Bearer {config.PLAYWRIGHT_API_KEY}', - 'Content-Type': 'application/json' - } - - logger.info(f"Fetching webinars via Playwright from {config.EDU_WEBINAR_URL}") - logger.info(f"Will wait {config.WEBINAR_WAIT_TIME} seconds for dynamic content") - - response = requests.post( - f"{config.PLAYWRIGHT_SERVER}/render", - json=playwright_request, - headers=headers, - timeout=30 - ) - - if response.status_code != 200: - logger.error(f"Playwright server returned status {response.status_code}") - logger.error(f"Response: {response.text}") - return None - - result = response.json() - html_content = result.get('html', '') - - if not html_content: - logger.error("No HTML content in Playwright response") - return None - - # Парсим таблицу - webinars = parse_webinar_table(html_content) - return webinars - - except Exception as e: - logger.error(f"Error fetching webinars via Playwright: {e}", exc_info=True) - return None - - -def fetch_webinars() -> Optional[List[Dict[str, str]]]: - """ - Получает список активных вебинаров - Сначала пробует через Playwright (для динамического контента), - при неудаче - через обычный requests - - Returns: - List[Dict]: Список вебинаров или None в случае ошибки - """ - # Пробуем через Playwright - logger.info("Attempting to fetch via Playwright for dynamic content") - webinars = fetch_webinars_with_playwright() - - if webinars is not None: - return webinars - - # Fallback на обычный requests - logger.warning("Playwright fetch failed, falling back to simple requests") - - # Получаем PHPSESSID - phpsessid = get_phpsessid() - if not phpsessid: - logger.error("Failed to get PHPSESSID") - return None - - # Запрашиваем страницу с вебинарами - try: - headers = { - 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36', - 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', - 'Accept-Language': 'ru-RU,ru;q=0.9,uk;q=0.8', - 'Referer': f'https://{config.EDU_HOST}/' - } - - cookies = { - 'PHPSESSID': phpsessid - } - - logger.info(f"Fetching webinars from {config.EDU_WEBINAR_URL}") - response = requests.get( - config.EDU_WEBINAR_URL, - headers=headers, - cookies=cookies, - timeout=15 - ) - - if response.status_code != 200: - logger.error(f"Failed to fetch webinars page: {response.status_code}") - return None - - # Парсим таблицу - webinars = parse_webinar_table(response.text) - return webinars - - except Exception as e: - logger.error(f"Error fetching webinars: {e}", exc_info=True) - return None - - -def format_webinar_message(webinars: List[Dict[str, str]]) -> str: - """ - Форматирует список вебинаров для отправки в Telegram - - Args: - webinars: Список вебинаров - - Returns: - str: Отформатированное сообщение - """ - if not webinars: - return "📭 Жодного онлайн уроку зараз" - - message = "🎓 Активні онлайн уроки:\n\n" - - for i, webinar in enumerate(webinars, 1): - message += f"{i}. {webinar['topic']}\n" - message += f"📚 Курс: {webinar['course']}\n" - message += f"👨‍🏫 Вчитель: {webinar['teacher']}\n" - - if webinar['join_link']: - full_link = webinar['join_link'] - if not full_link.startswith('http'): - full_link = f"https://{config.EDU_HOST}{webinar['join_link']}" - message += f"🔗 Увійти до уроку\n" - - message += "\n" - - return message \ No newline at end of file diff --git a/edu_master/nginx/entrypoint.sh b/edu_master/nginx/entrypoint.sh deleted file mode 100644 index 349f084..0000000 --- a/edu_master/nginx/entrypoint.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/bin/sh -set -e - -# Replace environment variables in the Nginx configuration template -envsubst '${MINIO_PRIVATE_BUCKET} ${MINIO_PUBLIC_BUCKET}' < /etc/nginx/conf.d/default.conf.template > /etc/nginx/conf.d/default.conf - -# Start Nginx -exec nginx -g 'daemon off;' diff --git a/edu_master/nginx/nginx.conf b/edu_master/nginx/nginx.conf deleted file mode 100644 index 84925e2..0000000 --- a/edu_master/nginx/nginx.conf +++ /dev/null @@ -1,87 +0,0 @@ -server { - listen 80; - server_name localhost; - client_max_body_size 100M; - - # Frontend - location / { - proxy_pass http://frontend:80; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } - - # API - location /api/ { - proxy_pass http://app:9000; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } - - # MCP - location ~ ^/(sse|messages) { - proxy_pass http://mcp:3000; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - - - # Important SSE settings - proxy_http_version 1.1; - proxy_set_header Connection ""; - - # Disable buffering so events are sent immediately - proxy_buffering off; - proxy_cache off; - - # Increase timeouts so connection stays open - proxy_read_timeout 3600s; - proxy_send_timeout 3600s; - } - - - # MinIO private bucket - location /${MINIO_PRIVATE_BUCKET}/ { - proxy_pass http://minio:9000/${MINIO_PRIVATE_BUCKET}/; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_buffering off; - } - - # MinIO public bucket - location /${MINIO_PUBLIC_BUCKET}/ { - proxy_pass http://minio:9000/${MINIO_PUBLIC_BUCKET}/; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_buffering off; - } - - # MinIO API - for direct S3 operations - # location /minio/api/ { - # proxy_pass http://minio:9000/; - # proxy_set_header Host $host; - # proxy_set_header X-Real-IP $remote_addr; - # proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - # proxy_set_header X-Forwarded-Proto $scheme; - # proxy_buffering off; - # } - - # MinIO Console - location /minio-console/ { - proxy_pass http://minio:9001/; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - # Rewrite location headers - proxy_redirect / /minio-console/; - } -} \ No newline at end of file diff --git a/edu_master/phpsessid-bot/Dockerfile b/edu_master/phpsessid-bot/Dockerfile new file mode 100644 index 0000000..4ef38ab --- /dev/null +++ b/edu_master/phpsessid-bot/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.11-slim + +WORKDIR /app + +# Install dependencies +RUN pip install requests + +# Copy application code +COPY . . + +# Run the bot +CMD ["python", "bot.py"] diff --git a/edu_master/phpsessid-bot/bot.py b/edu_master/phpsessid-bot/bot.py new file mode 100644 index 0000000..d0f1ca0 --- /dev/null +++ b/edu_master/phpsessid-bot/bot.py @@ -0,0 +1,103 @@ +import os +import time +import requests +import logging +from datetime import datetime + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + +# Load configuration +LOGIN = os.getenv('EDU_LOGIN') +PASSWORD = os.getenv('EDU_PASSWORD') +URL_LOGIN = os.getenv('EDU_URL_LOGIN', 'https://edu.edu.vn.ua/user/login') +URL_VERIFY = os.getenv('EDU_URL_VERIFY', 'https://edu.edu.vn.ua/course/userlist') +INTERVAL = int(os.getenv('EDU_INTERVAL', 10)) +USER_AGENT = os.getenv('EDU_USER_AGENT', 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36') +INITIAL_PHPSESSID = os.getenv('EDU_PHPSESSID') + +SUCCESS_FILE = '/tmp/last_success' + +def touch_success_file(): + """Updates the timestamp of the success file for healthchecks.""" + try: + with open(SUCCESS_FILE, 'w') as f: + f.write(str(datetime.now().timestamp())) + except Exception as e: + logger.error(f"Failed to touch success file: {e}") + +def main(): + logger.info("Starting Session Keeper Bot") + + session = requests.Session() + + # Set headers + headers = { + 'User-Agent': USER_AGENT, + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7', + 'Accept-Language': 'en-US,en;q=0.9', + 'Cache-Control': 'max-age=0', + 'Upgrade-Insecure-Requests': '1', + 'Sec-Fetch-Site': 'same-origin', + 'Sec-Fetch-Mode': 'navigate', + 'Sec-Fetch-User': '?1', + 'Sec-Fetch-Dest': 'document', + 'Sec-Ch-Ua': '"Not_A Brand";v="99", "Chromium";v="142"', + 'Sec-Ch-Ua-Mobile': '?0', + 'Sec-Ch-Ua-Platform': '"Linux"', + 'Accept-Encoding': 'gzip, deflate, br', + 'Priority': 'u=0, i' + } + session.headers.update(headers) + + # If an initial PHPSESSID is provided, set it in the session + if INITIAL_PHPSESSID: + logger.info(f"Setting initial PHPSESSID: {INITIAL_PHPSESSID}") + session.cookies.set('PHPSESSID', INITIAL_PHPSESSID, domain='edu.edu.vn.ua') + + while True: + try: + logger.info("Attempting login...") + + # Login payload + payload = { + 'login': LOGIN, + 'password': PASSWORD + } + + # Perform Login + # Note: The user request shows a POST to /user/login with form data + # We need to make sure we handle the PHPSESSID correctly. + # If we already have a PHPSESSID, requests will send it. + + login_response = session.post(URL_LOGIN, data=payload, allow_redirects=True) + + logger.info(f"Login Response Status: {login_response.status_code}") + logger.info(f"Cookies after login: {session.cookies.get_dict()}") + + # Verify Session + logger.info("Verifying session...") + verify_response = session.get(URL_VERIFY, allow_redirects=False) + + logger.info(f"Verify Response Status: {verify_response.status_code}") + + if verify_response.status_code == 200: + logger.info("Session verification SUCCESS (200 OK).") + touch_success_file() + elif verify_response.status_code == 302: + logger.warning("Session verification FAILED (302 Redirect). Session might be invalid.") + else: + logger.warning(f"Session verification returned unexpected status: {verify_response.status_code}") + + except Exception as e: + logger.error(f"An error occurred: {e}") + + logger.info(f"Sleeping for {INTERVAL} minutes...") + time.sleep(INTERVAL * 60) + +if __name__ == "__main__": + main() diff --git a/edu_master/phpsessid_bot/Dockerfile b/edu_master/phpsessid_bot/Dockerfile deleted file mode 100644 index 6506397..0000000 --- a/edu_master/phpsessid_bot/Dockerfile +++ /dev/null @@ -1,15 +0,0 @@ -FROM python:3.11-slim - -WORKDIR /app - -# Устанавливаем зависимости -RUN pip install --no-cache-dir flask requests - -# Копируем код бота -COPY main.py . - -# Открываем порт -EXPOSE 5000 - -# Запускаем бот -CMD ["python", "-u", "main.py"] diff --git a/edu_master/phpsessid_bot/main.py b/edu_master/phpsessid_bot/main.py deleted file mode 100644 index 50afec9..0000000 --- a/edu_master/phpsessid_bot/main.py +++ /dev/null @@ -1,216 +0,0 @@ -import os -import logging -from flask import Flask, request, jsonify -import requests -from datetime import datetime - -# Настройка логирования -logging.basicConfig( - level=logging.INFO, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' -) -logger = logging.getLogger(__name__) - -app = Flask(__name__) - -# Конфигурация из переменных окружения -EDU_HOST = os.getenv('EDU_HOST', 'edu.edu.vn.ua') -EDU_LOGIN = os.getenv('EDU_LOGIN', '') -EDU_PASSWORD = os.getenv('EDU_PASSWORD', '') -BOT_PORT = int(os.getenv('BOT_PORT', '5000')) -BOT_HOST = os.getenv('BOT_HOST', '0.0.0.0') - -# Кэш для хранения актуальной сессии -session_cache = { - 'phpsessid': None, - 'expires_at': None -} - - -def login_and_get_session(): - """ - Выполняет логин и возвращает новый PHPSESSID - """ - url = f"https://{EDU_HOST}/user/login" - - headers = { - 'Cache-Control': 'max-age=0', - 'Sec-Ch-Ua': '"Chromium";v="141", "Not?A_Brand";v="8"', - 'Sec-Ch-Ua-Mobile': '?0', - 'Sec-Ch-Ua-Platform': '"Linux"', - 'Accept-Language': 'ru-RU,ru;q=0.9', - 'Origin': f'https://{EDU_HOST}', - 'Content-Type': 'application/x-www-form-urlencoded', - 'Upgrade-Insecure-Requests': '1', - 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36', - 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7', - 'Sec-Fetch-Site': 'same-origin', - 'Sec-Fetch-Mode': 'navigate', - 'Sec-Fetch-User': '?1', - 'Sec-Fetch-Dest': 'document', - 'Referer': f'https://{EDU_HOST}/', - 'Accept-Encoding': 'gzip, deflate, br', - 'Priority': 'u=0, i' - } - - data = { - 'login': EDU_LOGIN, - 'password': EDU_PASSWORD - } - - try: - logger.info(f"Attempting login to {url}") - response = requests.post( - url, - data=data, - headers=headers, - allow_redirects=False, - timeout=10 - ) - - # Получаем PHPSESSID из cookies - phpsessid = response.cookies.get('PHPSESSID') - - if phpsessid: - logger.info(f"Login successful, got PHPSESSID: {phpsessid[:10]}...") - return { - 'success': True, - 'phpsessid': phpsessid, - 'status_code': response.status_code - } - else: - logger.warning(f"Login failed: no PHPSESSID in response. Status: {response.status_code}") - return { - 'success': False, - 'error': 'No PHPSESSID in response', - 'status_code': response.status_code - } - - except requests.exceptions.RequestException as e: - logger.error(f"Login request failed: {str(e)}") - return { - 'success': False, - 'error': str(e) - } - - -def validate_phpsessid(phpsessid): - """ - Проверяет валидность существующего PHPSESSID - """ - url = f"https://{EDU_HOST}/" - - headers = { - 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36', - 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8' - } - - cookies = { - 'PHPSESSID': phpsessid - } - - try: - response = requests.get(url, headers=headers, cookies=cookies, timeout=10) - - # Проверяем, не редиректит ли на страницу логина - is_valid = response.status_code == 200 and '/user/login' not in response.url - - return { - 'valid': is_valid, - 'status_code': response.status_code, - 'url': response.url - } - except requests.exceptions.RequestException as e: - logger.error(f"Validation request failed: {str(e)}") - return { - 'valid': False, - 'error': str(e) - } - - -@app.route('/health', methods=['GET']) -def health(): - """Health check endpoint""" - return jsonify({'status': 'ok', 'timestamp': datetime.now().isoformat()}) - - -@app.route('/get-session', methods=['POST', 'GET']) -def get_session(): - """ - Основной endpoint для получения валидного PHPSESSID - Возвращает кэшированную сессию или создает новую - """ - result = login_and_get_session() - - if result['success']: - session_cache['phpsessid'] = result['phpsessid'] - session_cache['last_updated'] = datetime.now().isoformat() - - return jsonify({ - 'success': True, - 'phpsessid': result['phpsessid'], - 'timestamp': datetime.now().isoformat() - }) - else: - return jsonify({ - 'success': False, - 'error': result.get('error', 'Login failed'), - 'timestamp': datetime.now().isoformat() - }), 400 - - -@app.route('/validate-session', methods=['POST']) -def validate_session(): - """ - Проверяет валидность переданного PHPSESSID - """ - data = request.get_json() or {} - phpsessid = data.get('phpsessid') or request.args.get('phpsessid') - - if not phpsessid: - return jsonify({ - 'success': False, - 'error': 'PHPSESSID not provided' - }), 400 - - validation_result = validate_phpsessid(phpsessid) - - return jsonify({ - 'success': True, - 'valid': validation_result.get('valid', False), - 'details': validation_result, - 'timestamp': datetime.now().isoformat() - }) - - -@app.route('/refresh-session', methods=['POST', 'GET']) -def refresh_session(): - """ - Принудительно обновляет сессию - """ - result = login_and_get_session() - - if result['success']: - return jsonify({ - 'success': True, - 'phpsessid': result['phpsessid'], - 'message': 'Session refreshed successfully', - 'timestamp': datetime.now().isoformat() - }) - else: - return jsonify({ - 'success': False, - 'error': result.get('error', 'Failed to refresh session'), - 'timestamp': datetime.now().isoformat() - }), 400 - - -if __name__ == '__main__': - if not EDU_LOGIN or not EDU_PASSWORD: - logger.error("EDU_LOGIN and EDU_PASSWORD must be set!") - exit(1) - - logger.info(f"Starting PHPSESSID validator bot on {BOT_HOST}:{BOT_PORT}") - logger.info(f"Target host: {EDU_HOST}") - - app.run(host=BOT_HOST, port=BOT_PORT, debug=False) \ No newline at end of file diff --git a/edu_master/phpsessid_bot/requirements.txt b/edu_master/phpsessid_bot/requirements.txt deleted file mode 100644 index 4614e8a..0000000 --- a/edu_master/phpsessid_bot/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -flask==3.0.0 -requests==2.31.0 -Werkzeug==3.0.1 From bfbe841154722a7bfd6d97956a523bcf5ab11fc8 Mon Sep 17 00:00:00 2001 From: mr-forust Date: Wed, 26 Nov 2025 20:53:53 +0100 Subject: [PATCH 06/89] feat: redis integration --- edu_master/.env.example | 16 ++++++---- edu_master/compose.yaml | 45 +++++++++++++++++++++++++++-- edu_master/phpsessid-bot/Dockerfile | 5 +++- edu_master/phpsessid-bot/bot.py | 31 +++++++++++++++----- 4 files changed, 80 insertions(+), 17 deletions(-) diff --git a/edu_master/.env.example b/edu_master/.env.example index 70573f0..bfb8684 100644 --- a/edu_master/.env.example +++ b/edu_master/.env.example @@ -1,7 +1,13 @@ -EDU_LOGIN=login -EDU_PASSWORD=password +EDU_LOGIN=your_edu_login_here +EDU_PASSWORD=your_edu_password_here EDU_URL_LOGIN=https://edu.edu.vn.ua/user/login EDU_URL_VERIFY=https://edu.edu.vn.ua/course/userlist -EDU_INTERVAL=10 -EDU_USER_AGENT="Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36" -EDU_PHPSESSID= +PHPSESSID_INTERVAL=10 +USER_AGENT="Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36" +WEBINAR_URL=https://edu.edu.vn.ua/webinar/useractive +WEBINAR_CHECK_INTERVAL=60 +REDIS_HOST=redis +REDIS_PORT=6379 +PLAYWRIGHT_WS=ws://playwright-service:3000/ws +WEBINAR_TELEGRAM_TOKEN=your_telegram_bot_token_here +WEBINAR_TELEGRAM_CHAT_ID=your_telegram_chat_id_here diff --git a/edu_master/compose.yaml b/edu_master/compose.yaml index 13284c6..5b934f7 100644 --- a/edu_master/compose.yaml +++ b/edu_master/compose.yaml @@ -1,10 +1,49 @@ services: + redis: + image: redis:alpine + restart: unless-stopped + volumes: + - redis-data:/data + ports: + - "6379:6379" + healthcheck: + test: [ "CMD", "redis-cli", "ping" ] + interval: 5s + timeout: 3s + retries: 5 + + playwright-service: + image: mcr.microsoft.com/playwright:v1.56.0-noble + restart: unless-stopped + command: npx -y playwright run-server --port 3000 --path /ws + ports: + - "3000:3000" + session-keeper: build: ./phpsessid-bot env_file: .env restart: unless-stopped + depends_on: + redis: + condition: service_healthy healthcheck: - test: ["CMD-SHELL", "test $(find /tmp/last_success -mmin -15)"] - interval: 1m + test: [ "CMD-SHELL", "redis-cli -h redis EXISTS EDU_PHPSESSID | grep -q 1" ] + interval: 30s timeout: 5s - retries: 3 + retries: 10 + start_period: 60s + + webinar-checker: + build: ./webinar-checker + env_file: .env + restart: unless-stopped + depends_on: + redis: + condition: service_healthy + session-keeper: + condition: service_healthy + playwright-service: + condition: service_started + +volumes: + redis-data: diff --git a/edu_master/phpsessid-bot/Dockerfile b/edu_master/phpsessid-bot/Dockerfile index 4ef38ab..b66780c 100644 --- a/edu_master/phpsessid-bot/Dockerfile +++ b/edu_master/phpsessid-bot/Dockerfile @@ -2,8 +2,11 @@ FROM python:3.11-slim WORKDIR /app +# Install system dependencies +RUN apt-get update && apt-get install -y redis-tools && rm -rf /var/lib/apt/lists/* + # Install dependencies -RUN pip install requests +RUN pip install requests redis # Copy application code COPY . . diff --git a/edu_master/phpsessid-bot/bot.py b/edu_master/phpsessid-bot/bot.py index d0f1ca0..48aa466 100644 --- a/edu_master/phpsessid-bot/bot.py +++ b/edu_master/phpsessid-bot/bot.py @@ -2,6 +2,7 @@ import os import time import requests import logging +import redis from datetime import datetime # Configure logging @@ -16,9 +17,10 @@ LOGIN = os.getenv('EDU_LOGIN') PASSWORD = os.getenv('EDU_PASSWORD') URL_LOGIN = os.getenv('EDU_URL_LOGIN', 'https://edu.edu.vn.ua/user/login') URL_VERIFY = os.getenv('EDU_URL_VERIFY', 'https://edu.edu.vn.ua/course/userlist') -INTERVAL = int(os.getenv('EDU_INTERVAL', 10)) -USER_AGENT = os.getenv('EDU_USER_AGENT', 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36') -INITIAL_PHPSESSID = os.getenv('EDU_PHPSESSID') +INTERVAL = int(os.getenv('PHPSESSID_INTERVAL', 10)) +USER_AGENT = os.getenv('USER_AGENT', 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36') +REDIS_HOST = os.getenv('REDIS_HOST', 'redis') +REDIS_PORT = int(os.getenv('REDIS_PORT', 6379)) SUCCESS_FILE = '/tmp/last_success' @@ -33,6 +35,15 @@ def touch_success_file(): def main(): logger.info("Starting Session Keeper Bot") + # Connect to Redis + try: + redis_client = redis.Redis(host=REDIS_HOST, port=REDIS_PORT, decode_responses=True) + redis_client.ping() + logger.info(f"Connected to Redis at {REDIS_HOST}:{REDIS_PORT}") + except Exception as e: + logger.error(f"Failed to connect to Redis: {e}") + return + session = requests.Session() # Set headers @@ -54,11 +65,6 @@ def main(): } session.headers.update(headers) - # If an initial PHPSESSID is provided, set it in the session - if INITIAL_PHPSESSID: - logger.info(f"Setting initial PHPSESSID: {INITIAL_PHPSESSID}") - session.cookies.set('PHPSESSID', INITIAL_PHPSESSID, domain='edu.edu.vn.ua') - while True: try: logger.info("Attempting login...") @@ -88,6 +94,15 @@ def main(): if verify_response.status_code == 200: logger.info("Session verification SUCCESS (200 OK).") touch_success_file() + + # Save PHPSESSID to Redis + phpsessid = session.cookies.get('PHPSESSID') + if phpsessid: + try: + redis_client.set('EDU_PHPSESSID', phpsessid) + logger.info(f"Saved PHPSESSID to Redis: {phpsessid}") + except Exception as e: + logger.error(f"Failed to save PHPSESSID to Redis: {e}") elif verify_response.status_code == 302: logger.warning("Session verification FAILED (302 Redirect). Session might be invalid.") else: From cb92bff0c5efce777ffc96c352f5b59b8bd1045f Mon Sep 17 00:00:00 2001 From: mr-forust Date: Wed, 26 Nov 2025 21:08:43 +0100 Subject: [PATCH 07/89] feat: basic webinar checker bot --- edu_master/compose.yaml | 4 +- edu_master/webinar-checker/Dockerfile | 10 ++ edu_master/webinar-checker/checker.py | 147 ++++++++++++++++++++++++++ 3 files changed, 159 insertions(+), 2 deletions(-) create mode 100644 edu_master/webinar-checker/Dockerfile create mode 100644 edu_master/webinar-checker/checker.py diff --git a/edu_master/compose.yaml b/edu_master/compose.yaml index 5b934f7..e0f4e9b 100644 --- a/edu_master/compose.yaml +++ b/edu_master/compose.yaml @@ -13,9 +13,9 @@ services: retries: 5 playwright-service: - image: mcr.microsoft.com/playwright:v1.56.0-noble + image: mcr.microsoft.com/playwright:v1.56.0-jammy restart: unless-stopped - command: npx -y playwright run-server --port 3000 --path /ws + command: npx -y playwright@1.56.0 run-server --port 3000 --path /ws ports: - "3000:3000" diff --git a/edu_master/webinar-checker/Dockerfile b/edu_master/webinar-checker/Dockerfile new file mode 100644 index 0000000..67f5496 --- /dev/null +++ b/edu_master/webinar-checker/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.11-slim + +WORKDIR /app + +# Install dependencies +RUN pip install --upgrade pip && pip install playwright==1.56.0 redis requests + +COPY checker.py . + +CMD ["python", "checker.py"] diff --git a/edu_master/webinar-checker/checker.py b/edu_master/webinar-checker/checker.py new file mode 100644 index 0000000..3f9e913 --- /dev/null +++ b/edu_master/webinar-checker/checker.py @@ -0,0 +1,147 @@ +import os +import time +import logging +import redis +import requests +from playwright.sync_api import sync_playwright + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + +# Load configuration +WEBINAR_URL = os.getenv('WEBINAR_URL', 'https://edu.edu.vn.ua/webinar/useractive') +WEBINAR_CHECK_INTERVAL = int(os.getenv('WEBINAR_CHECK_INTERVAL', 60)) +REDIS_HOST = os.getenv('REDIS_HOST', 'redis') +REDIS_PORT = int(os.getenv('REDIS_PORT', 6379)) +PLAYWRIGHT_WS = os.getenv('PLAYWRIGHT_WS', 'ws://playwright-service:3000/ws') +USER_AGENT = os.getenv('USER_AGENT', 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36') +WEBINAR_TELEGRAM_TOKEN = os.getenv('WEBINAR_TELEGRAM_TOKEN') +WEBINAR_TELEGRAM_CHAT_ID = os.getenv('WEBINAR_TELEGRAM_CHAT_ID') + +def send_telegram_message(message): + """Send message to Telegram.""" + if not WEBINAR_TELEGRAM_TOKEN or not WEBINAR_TELEGRAM_CHAT_ID: + logger.warning("Telegram credentials not configured. Skipping message.") + return + + try: + url = f"https://api.telegram.org/bot{WEBINAR_TELEGRAM_TOKEN}/sendMessage" + payload = { + 'chat_id': WEBINAR_TELEGRAM_CHAT_ID, + 'text': message, + 'parse_mode': 'HTML' + } + response = requests.post(url, json=payload) + if response.status_code == 200: + logger.info("Telegram message sent successfully.") + else: + logger.error(f"Failed to send Telegram message: {response.text}") + except Exception as e: + logger.error(f"Error sending Telegram message: {e}") + +def check_webinars(redis_client): + """Check for active webinars and notify via Telegram.""" + + # Get PHPSESSID from Redis + phpsessid = redis_client.get('EDU_PHPSESSID') + if not phpsessid: + logger.warning("PHPSESSID not found in Redis. Waiting for session-keeper to provide it.") + return + + logger.info(f"Using PHPSESSID from Redis: {phpsessid}") + + with sync_playwright() as p: + try: + # Connect to remote Playwright server + browser = p.chromium.connect(PLAYWRIGHT_WS) + logger.info(f"Connected to Playwright server at {PLAYWRIGHT_WS}") + + context = browser.new_context( + user_agent=USER_AGENT, + viewport={'width': 1280, 'height': 720} + ) + + # Set PHPSESSID cookie + context.add_cookies([{ + 'name': 'PHPSESSID', + 'value': phpsessid, + 'domain': 'edu.edu.vn.ua', + 'path': '/' + }]) + + page = context.new_page() + + try: + logger.info(f"Navigating to {WEBINAR_URL}...") + page.goto(WEBINAR_URL, wait_until='domcontentloaded') + + # Wait for the table to load (dynamic content) + try: + page.wait_for_selector('#meetings table', timeout=10000) + except Exception: + logger.error("Timeout waiting for #meetings table. Page might not have loaded correctly.") + return + + content = page.content() + + if "Жодного онлайн уроку зараз" in content: + logger.info("Webinars check: No active webinars.") + else: + # Webinars found! + logger.info("!!! WEBINAR FOUND !!!") + + # Extract details + rows = page.locator('#meetings table tbody tr') + count = rows.count() + + webinar_details = [] + if count > 0: + for i in range(count): + text = rows.nth(i).inner_text() + # Skip the "no webinars" row + if "Жодного онлайн уроку зараз" not in text: + webinar_details.append(text.strip()) + + # Send Telegram notification (Russian) + if webinar_details: + message = "🎓 Найден вебинар!\n\n" + for detail in webinar_details: + message += f"📌 {detail}\n\n" + send_telegram_message(message) + else: + send_telegram_message("🎓 Найден вебинар!\n\nДетали не удалось извлечь.") + + except Exception as e: + logger.error(f"An error occurred during check: {e}") + finally: + browser.close() + + except Exception as e: + logger.error(f"Failed to connect to Playwright server: {e}") + +def main(): + logger.info("Starting Webinar Checker Bot") + + # Connect to Redis + try: + redis_client = redis.Redis(host=REDIS_HOST, port=REDIS_PORT, decode_responses=True) + redis_client.ping() + logger.info(f"Connected to Redis at {REDIS_HOST}:{REDIS_PORT}") + except Exception as e: + logger.error(f"Failed to connect to Redis: {e}") + return + + # Send startup message (Russian) + send_telegram_message("🤖 Бот запущен\n\nМониторинг вебинаров активирован...") + + while True: + check_webinars(redis_client) + logger.info(f"Sleeping for {WEBINAR_CHECK_INTERVAL} seconds...") + time.sleep(WEBINAR_CHECK_INTERVAL) + +if __name__ == "__main__": + main() From d4e0e7f37a6d900a1e45454e48259c7ca7ab6e73 Mon Sep 17 00:00:00 2001 From: mr-forust Date: Wed, 26 Nov 2025 22:12:03 +0100 Subject: [PATCH 08/89] feat: telegram webianr checker bot --- edu_master/.env.example | 2 +- edu_master/webinar-checker/Dockerfile | 2 +- edu_master/webinar-checker/checker.py | 322 +++++++++++++++++--------- 3 files changed, 217 insertions(+), 109 deletions(-) diff --git a/edu_master/.env.example b/edu_master/.env.example index bfb8684..e0c4a1e 100644 --- a/edu_master/.env.example +++ b/edu_master/.env.example @@ -10,4 +10,4 @@ REDIS_HOST=redis REDIS_PORT=6379 PLAYWRIGHT_WS=ws://playwright-service:3000/ws WEBINAR_TELEGRAM_TOKEN=your_telegram_bot_token_here -WEBINAR_TELEGRAM_CHAT_ID=your_telegram_chat_id_here +WEBINAR_ADMIN_ID=123456789 diff --git a/edu_master/webinar-checker/Dockerfile b/edu_master/webinar-checker/Dockerfile index 67f5496..ec581f7 100644 --- a/edu_master/webinar-checker/Dockerfile +++ b/edu_master/webinar-checker/Dockerfile @@ -3,7 +3,7 @@ FROM python:3.11-slim WORKDIR /app # Install dependencies -RUN pip install --upgrade pip && pip install playwright==1.56.0 redis requests +RUN pip install --upgrade pip && pip install playwright==1.56.0 redis requests "python-telegram-bot[job-queue]" COPY checker.py . diff --git a/edu_master/webinar-checker/checker.py b/edu_master/webinar-checker/checker.py index 3f9e913..5ecef74 100644 --- a/edu_master/webinar-checker/checker.py +++ b/edu_master/webinar-checker/checker.py @@ -1,9 +1,10 @@ import os -import time import logging import redis -import requests -from playwright.sync_api import sync_playwright + +from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup +from telegram.ext import Application, CommandHandler, CallbackQueryHandler, ContextTypes, JobQueue +from playwright.async_api import async_playwright # Configure logging logging.basicConfig( @@ -20,128 +21,235 @@ REDIS_PORT = int(os.getenv('REDIS_PORT', 6379)) PLAYWRIGHT_WS = os.getenv('PLAYWRIGHT_WS', 'ws://playwright-service:3000/ws') USER_AGENT = os.getenv('USER_AGENT', 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36') WEBINAR_TELEGRAM_TOKEN = os.getenv('WEBINAR_TELEGRAM_TOKEN') -WEBINAR_TELEGRAM_CHAT_ID = os.getenv('WEBINAR_TELEGRAM_CHAT_ID') +ADMIN_ID = int(os.getenv('WEBINAR_ADMIN_ID', '0')) -def send_telegram_message(message): - """Send message to Telegram.""" - if not WEBINAR_TELEGRAM_TOKEN or not WEBINAR_TELEGRAM_CHAT_ID: - logger.warning("Telegram credentials not configured. Skipping message.") +# Redis Keys +KEY_WHITELIST = "bot:whitelist" +KEY_WHITELIST_ENABLED = "bot:whitelist_enabled" +KEY_SUBSCRIBERS = "bot:subscribers" +KEY_PHPSESSID = "EDU_PHPSESSID" + +# Initialize Redis +try: + redis_client = redis.Redis(host=REDIS_HOST, port=REDIS_PORT, decode_responses=True) + redis_client.ping() + logger.info(f"Connected to Redis at {REDIS_HOST}:{REDIS_PORT}") +except Exception as e: + logger.error(f"Failed to connect to Redis: {e}") + exit(1) + +# --- Helper Functions --- + +def is_whitelisted(user_id: int) -> bool: + """Check if user is allowed to use the bot.""" + if user_id == ADMIN_ID: + return True + + enabled = redis_client.get(KEY_WHITELIST_ENABLED) + if enabled == "0": # Whitelist disabled + return True + + return redis_client.sismember(KEY_WHITELIST, str(user_id)) + +def get_admin_keyboard(): + """Generate admin panel keyboard.""" + whitelist_enabled = redis_client.get(KEY_WHITELIST_ENABLED) != "0" + toggle_text = "🔒 Disable Whitelist" if whitelist_enabled else "🔓 Enable Whitelist" + + keyboard = [ + [InlineKeyboardButton(toggle_text, callback_data="toggle_whitelist")], + [InlineKeyboardButton("📋 View Whitelist", callback_data="view_whitelist")], + [InlineKeyboardButton("👥 View Subscribers", callback_data="view_subscribers")], + [InlineKeyboardButton("🔄 Force Check", callback_data="force_check")] + ] + return InlineKeyboardMarkup(keyboard) + +# --- Command Handlers --- + +async def start(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handle /start command.""" + user = update.effective_user + logger.info(f"User {user.id} ({user.username}) started the bot.") + + if not is_whitelisted(user.id): + await update.message.reply_text("⛔ Access denied. You are not on the whitelist.") return + + # Add to subscribers + redis_client.sadd(KEY_SUBSCRIBERS, user.id) + + msg = ( + f"👋 Hello {user.first_name}!\n\n" + "I am the Webinar Checker Bot. I will notify you when a new webinar appears.\n" + "You have been subscribed to notifications." + ) + + if user.id == ADMIN_ID: + msg += "\n\n👑 Admin Mode Active" + await update.message.reply_text(msg, parse_mode='HTML', reply_markup=get_admin_keyboard()) + else: + await update.message.reply_text(msg, parse_mode='HTML') + +async def help_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handle /help command.""" + msg = ( + "🤖 Bot Help\n\n" + "/start - Subscribe to notifications\n" + "/help - Show this message\n" + ) + if update.effective_user.id == ADMIN_ID: + msg += "\nAdmin Commands:\nUse the panel below to manage settings." + await update.message.reply_text(msg, parse_mode='HTML', reply_markup=get_admin_keyboard()) + else: + await update.message.reply_text(msg, parse_mode='HTML') + +# --- Admin Callbacks --- + +async def admin_callback(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handle admin panel button clicks.""" + query = update.callback_query + user_id = query.from_user.id + + if user_id != ADMIN_ID: + await query.answer("⛔ Admin only!", show_alert=True) + return + + await query.answer() + data = query.data + + if data == "toggle_whitelist": + current = redis_client.get(KEY_WHITELIST_ENABLED) + new_state = "0" if current != "0" else "1" + redis_client.set(KEY_WHITELIST_ENABLED, new_state) + state_text = "Disabled" if new_state == "0" else "Enabled" + await query.edit_message_reply_markup(reply_markup=get_admin_keyboard()) + await query.message.reply_text(f"✅ Whitelist {state_text}") + + elif data == "view_whitelist": + members = redis_client.smembers(KEY_WHITELIST) + msg = "📋 Whitelist:\n" + ("\n".join(members) if members else "Empty") + await query.message.reply_text(msg, parse_mode='HTML') + + elif data == "view_subscribers": + subs = redis_client.smembers(KEY_SUBSCRIBERS) + msg = "👥 Subscribers:\n" + ("\n".join(subs) if subs else "Empty") + await query.message.reply_text(msg, parse_mode='HTML') + + elif data == "force_check": + await query.message.reply_text("🔄 Running immediate check...") + await check_webinars_job(context) + +# --- Webinar Checking Job --- + +async def check_webinars_job(context: ContextTypes.DEFAULT_TYPE): + """Background job to check for webinars using Async Playwright.""" + logger.info("Running webinar check...") + + phpsessid = redis_client.get(KEY_PHPSESSID) + if not phpsessid: + logger.warning("PHPSESSID missing. Skipping check.") + return + + webinar_details = [] try: - url = f"https://api.telegram.org/bot{WEBINAR_TELEGRAM_TOKEN}/sendMessage" - payload = { - 'chat_id': WEBINAR_TELEGRAM_CHAT_ID, - 'text': message, - 'parse_mode': 'HTML' - } - response = requests.post(url, json=payload) - if response.status_code == 200: - logger.info("Telegram message sent successfully.") - else: - logger.error(f"Failed to send Telegram message: {response.text}") - except Exception as e: - logger.error(f"Error sending Telegram message: {e}") - -def check_webinars(redis_client): - """Check for active webinars and notify via Telegram.""" - - # Get PHPSESSID from Redis - phpsessid = redis_client.get('EDU_PHPSESSID') - if not phpsessid: - logger.warning("PHPSESSID not found in Redis. Waiting for session-keeper to provide it.") - return - - logger.info(f"Using PHPSESSID from Redis: {phpsessid}") - - with sync_playwright() as p: - try: - # Connect to remote Playwright server - browser = p.chromium.connect(PLAYWRIGHT_WS) - logger.info(f"Connected to Playwright server at {PLAYWRIGHT_WS}") - - context = browser.new_context( - user_agent=USER_AGENT, - viewport={'width': 1280, 'height': 720} - ) - - # Set PHPSESSID cookie - context.add_cookies([{ - 'name': 'PHPSESSID', - 'value': phpsessid, - 'domain': 'edu.edu.vn.ua', - 'path': '/' - }]) - - page = context.new_page() + async with async_playwright() as p: + # Connect to remote Playwright service + browser = await p.chromium.connect(PLAYWRIGHT_WS) try: - logger.info(f"Navigating to {WEBINAR_URL}...") - page.goto(WEBINAR_URL, wait_until='domcontentloaded') + # Create browser context with user agent + context_browser = await browser.new_context(user_agent=USER_AGENT) + + # Add PHPSESSID cookie + await context_browser.add_cookies([{ + 'name': 'PHPSESSID', + 'value': phpsessid, + 'domain': 'edu.edu.vn.ua', + 'path': '/' + }]) + + # Create new page + page = await context_browser.new_page() - # Wait for the table to load (dynamic content) try: - page.wait_for_selector('#meetings table', timeout=10000) - except Exception: - logger.error("Timeout waiting for #meetings table. Page might not have loaded correctly.") - return - - content = page.content() - - if "Жодного онлайн уроку зараз" in content: - logger.info("Webinars check: No active webinars.") - else: - # Webinars found! - logger.info("!!! WEBINAR FOUND !!!") + # Navigate to webinar page + await page.goto(WEBINAR_URL, wait_until='domcontentloaded') - # Extract details - rows = page.locator('#meetings table tbody tr') - count = rows.count() + # Wait for the table to load + await page.wait_for_selector('#meetings table', timeout=10000) - webinar_details = [] - if count > 0: + # Get page content + content = await page.content() + + # Check if "no webinar" message is present + if "Жодного онлайн уроку зараз" not in content: + logger.info("!!! WEBINAR FOUND !!!") + + # Extract webinar details from table rows + rows = page.locator('#meetings table tbody tr') + count = await rows.count() + for i in range(count): - text = rows.nth(i).inner_text() - # Skip the "no webinars" row + text = await rows.nth(i).inner_text() if "Жодного онлайн уроку зараз" not in text: webinar_details.append(text.strip()) - - # Send Telegram notification (Russian) - if webinar_details: - message = "🎓 Найден вебинар!\n\n" - for detail in webinar_details: - message += f"📌 {detail}\n\n" - send_telegram_message(message) else: - send_telegram_message("🎓 Найден вебинар!\n\nДетали не удалось извлечь.") - - except Exception as e: - logger.error(f"An error occurred during check: {e}") + logger.info("No webinars found (expected message present)") + + except Exception as e: + logger.error(f"Error checking page: {e}") + finally: + await page.close() + await context_browser.close() + finally: - browser.close() + await browser.close() - except Exception as e: - logger.error(f"Failed to connect to Playwright server: {e}") + except Exception as e: + logger.error(f"Playwright error: {e}") + return + + # Notify subscribers if webinars found + if webinar_details: + message = "🎓 Найден вебинар!\n\n" + "\n\n".join([f"📌 {d}" for d in webinar_details]) + subscribers = redis_client.smembers(KEY_SUBSCRIBERS) + + for sub_id in subscribers: + try: + await context.bot.send_message(chat_id=sub_id, text=message, parse_mode='HTML') + logger.info(f"Notification sent to {sub_id}") + except Exception as e: + logger.error(f"Failed to send to {sub_id}: {e}") + +# --- Main --- def main(): - logger.info("Starting Webinar Checker Bot") - - # Connect to Redis - try: - redis_client = redis.Redis(host=REDIS_HOST, port=REDIS_PORT, decode_responses=True) - redis_client.ping() - logger.info(f"Connected to Redis at {REDIS_HOST}:{REDIS_PORT}") - except Exception as e: - logger.error(f"Failed to connect to Redis: {e}") + if not WEBINAR_TELEGRAM_TOKEN: + logger.error("WEBINAR_TELEGRAM_TOKEN is missing!") return - - # Send startup message (Russian) - send_telegram_message("🤖 Бот запущен\n\nМониторинг вебинаров активирован...") - - while True: - check_webinars(redis_client) - logger.info(f"Sleeping for {WEBINAR_CHECK_INTERVAL} seconds...") - time.sleep(WEBINAR_CHECK_INTERVAL) + + # Set default whitelist state if not set + if not redis_client.exists(KEY_WHITELIST_ENABLED): + redis_client.set(KEY_WHITELIST_ENABLED, "1") # Enabled by default + + # Add admin to whitelist + if ADMIN_ID: + redis_client.sadd(KEY_WHITELIST, str(ADMIN_ID)) + + app = Application.builder().token(WEBINAR_TELEGRAM_TOKEN).build() + + # Handlers + app.add_handler(CommandHandler("start", start)) + app.add_handler(CommandHandler("help", help_command)) + app.add_handler(CallbackQueryHandler(admin_callback)) + + # Job Queue + job_queue = app.job_queue + job_queue.run_repeating(check_webinars_job, interval=WEBINAR_CHECK_INTERVAL, first=10) + + logger.info("Bot started polling...") + app.run_polling() if __name__ == "__main__": - main() + main() \ No newline at end of file From 8fae8bf75d94dae2de85dae7e2840c33895d6994 Mon Sep 17 00:00:00 2001 From: mr-forust Date: Wed, 26 Nov 2025 22:25:47 +0100 Subject: [PATCH 09/89] feat: add admin commands to manage whitelist, extended webinar reporting --- edu_master/webinar-checker/checker.py | 74 +++++++++++++++++++++++++-- 1 file changed, 69 insertions(+), 5 deletions(-) diff --git a/edu_master/webinar-checker/checker.py b/edu_master/webinar-checker/checker.py index 5ecef74..d09c9fe 100644 --- a/edu_master/webinar-checker/checker.py +++ b/edu_master/webinar-checker/checker.py @@ -98,11 +98,59 @@ async def help_command(update: Update, context: ContextTypes.DEFAULT_TYPE): "/help - Show this message\n" ) if update.effective_user.id == ADMIN_ID: - msg += "\nAdmin Commands:\nUse the panel below to manage settings." + msg += ( + "\nAdmin Commands:\n" + "/adduser [user_id] - Add user to whitelist\n" + "/removeuser [user_id] - Remove user from whitelist\n" + "Or use the panel below to manage settings." + ) await update.message.reply_text(msg, parse_mode='HTML', reply_markup=get_admin_keyboard()) else: await update.message.reply_text(msg, parse_mode='HTML') +async def add_user(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Add user to whitelist (admin only).""" + if update.effective_user.id != ADMIN_ID: + await update.message.reply_text("⛔ Admin only!") + return + + if not context.args: + await update.message.reply_text("Usage: /adduser [user_id]") + return + + try: + user_id = int(context.args[0]) + redis_client.sadd(KEY_WHITELIST, str(user_id)) + await update.message.reply_text(f"✅ User {user_id} added to whitelist") + logger.info(f"Admin added user {user_id} to whitelist") + except ValueError: + await update.message.reply_text("❌ Invalid user ID. Must be a number.") + +async def remove_user(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Remove user from whitelist (admin only).""" + if update.effective_user.id != ADMIN_ID: + await update.message.reply_text("⛔ Admin only!") + return + + if not context.args: + await update.message.reply_text("Usage: /removeuser [user_id]") + return + + try: + user_id = int(context.args[0]) + if str(user_id) == str(ADMIN_ID): + await update.message.reply_text("❌ Cannot remove admin from whitelist") + return + + removed = redis_client.srem(KEY_WHITELIST, str(user_id)) + if removed: + await update.message.reply_text(f"✅ User {user_id} removed from whitelist") + logger.info(f"Admin removed user {user_id} from whitelist") + else: + await update.message.reply_text(f"⚠️ User {user_id} was not in whitelist") + except ValueError: + await update.message.reply_text("❌ Invalid user ID. Must be a number.") + # --- Admin Callbacks --- async def admin_callback(update: Update, context: ContextTypes.DEFAULT_TYPE): @@ -137,18 +185,29 @@ async def admin_callback(update: Update, context: ContextTypes.DEFAULT_TYPE): elif data == "force_check": await query.message.reply_text("🔄 Running immediate check...") - await check_webinars_job(context) + result = await check_webinars_job(context) + + if result is None: + await query.message.reply_text("❌ Check failed. See logs for details.") + elif result == 0: + await query.message.reply_text("✅ Check completed. No webinars found.") + else: + await query.message.reply_text(f"✅ Check completed. Found {result} webinar(s)!") # --- Webinar Checking Job --- async def check_webinars_job(context: ContextTypes.DEFAULT_TYPE): - """Background job to check for webinars using Async Playwright.""" + """Background job to check for webinars using Async Playwright. + + Returns: + int: Number of webinars found, or None if check failed + """ logger.info("Running webinar check...") phpsessid = redis_client.get(KEY_PHPSESSID) if not phpsessid: logger.warning("PHPSESSID missing. Skipping check.") - return + return None webinar_details = [] @@ -199,6 +258,7 @@ async def check_webinars_job(context: ContextTypes.DEFAULT_TYPE): except Exception as e: logger.error(f"Error checking page: {e}") + return None finally: await page.close() await context_browser.close() @@ -208,7 +268,7 @@ async def check_webinars_job(context: ContextTypes.DEFAULT_TYPE): except Exception as e: logger.error(f"Playwright error: {e}") - return + return None # Notify subscribers if webinars found if webinar_details: @@ -221,6 +281,8 @@ async def check_webinars_job(context: ContextTypes.DEFAULT_TYPE): logger.info(f"Notification sent to {sub_id}") except Exception as e: logger.error(f"Failed to send to {sub_id}: {e}") + + return len(webinar_details) # --- Main --- @@ -242,6 +304,8 @@ def main(): # Handlers app.add_handler(CommandHandler("start", start)) app.add_handler(CommandHandler("help", help_command)) + app.add_handler(CommandHandler("adduser", add_user)) + app.add_handler(CommandHandler("removeuser", remove_user)) app.add_handler(CallbackQueryHandler(admin_callback)) # Job Queue From 380288d1036851df6ee477d2aeafed8d7d289c1b Mon Sep 17 00:00:00 2001 From: mr-forust Date: Wed, 26 Nov 2025 22:55:16 +0100 Subject: [PATCH 10/89] fix: port conflicts --- edu_master/compose.yaml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/edu_master/compose.yaml b/edu_master/compose.yaml index e0f4e9b..09cc10e 100644 --- a/edu_master/compose.yaml +++ b/edu_master/compose.yaml @@ -4,8 +4,6 @@ services: restart: unless-stopped volumes: - redis-data:/data - ports: - - "6379:6379" healthcheck: test: [ "CMD", "redis-cli", "ping" ] interval: 5s @@ -16,8 +14,6 @@ services: image: mcr.microsoft.com/playwright:v1.56.0-jammy restart: unless-stopped command: npx -y playwright@1.56.0 run-server --port 3000 --path /ws - ports: - - "3000:3000" session-keeper: build: ./phpsessid-bot From 5b1fa11b5e6c3a8f20b1849f1c25796062e1aee2 Mon Sep 17 00:00:00 2001 From: mr-forust Date: Thu, 27 Nov 2025 02:09:55 +0100 Subject: [PATCH 11/89] chore: switching to latest release of portainer --- portainer/compose.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/portainer/compose.yaml b/portainer/compose.yaml index aefd9d6..8d255c5 100644 --- a/portainer/compose.yaml +++ b/portainer/compose.yaml @@ -1,7 +1,7 @@ services: portainer: container_name: portainer - image: portainer/portainer-ce:lts + image: portainer/portainer-ce:latest restart: always volumes: - /var/run/docker.sock:/var/run/docker.sock From a54b7b000fca552f47717e2f314fef566cac9161 Mon Sep 17 00:00:00 2001 From: mr-forust Date: Thu, 27 Nov 2025 15:50:12 +0100 Subject: [PATCH 12/89] fix: add timeout for playwright to load page and debug response saving --- edu_master/webinar-checker/checker.py | 34 +++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/edu_master/webinar-checker/checker.py b/edu_master/webinar-checker/checker.py index d09c9fe..e0217f2 100644 --- a/edu_master/webinar-checker/checker.py +++ b/edu_master/webinar-checker/checker.py @@ -3,17 +3,17 @@ import logging import redis from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup -from telegram.ext import Application, CommandHandler, CallbackQueryHandler, ContextTypes, JobQueue +from telegram.ext import Application, CommandHandler, CallbackQueryHandler, ContextTypes from playwright.async_api import async_playwright -# Configure logging +# Logger logging.basicConfig( level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s' ) logger = logging.getLogger(__name__) -# Load configuration +# Load environment variables WEBINAR_URL = os.getenv('WEBINAR_URL', 'https://edu.edu.vn.ua/webinar/useractive') WEBINAR_CHECK_INTERVAL = int(os.getenv('WEBINAR_CHECK_INTERVAL', 60)) REDIS_HOST = os.getenv('REDIS_HOST', 'redis') @@ -207,9 +207,17 @@ async def check_webinars_job(context: ContextTypes.DEFAULT_TYPE): phpsessid = redis_client.get(KEY_PHPSESSID) if not phpsessid: logger.warning("PHPSESSID missing. Skipping check.") + # --- DEBUG LOGGING --- + try: + with open('phpsessid_missing.log', 'a') as f: + f.write(f"[{os.getcwd()}] PHPSESSID missing at {context.job.last_run: %Y-%m-%d %H:%M:%S}\n") + except Exception as e: + logger.error(f"Failed to write PHPSESSID debug log: {e}") + # --------------------- return None webinar_details = [] + content = "" try: async with async_playwright() as p: @@ -237,6 +245,7 @@ async def check_webinars_job(context: ContextTypes.DEFAULT_TYPE): # Wait for the table to load await page.wait_for_selector('#meetings table', timeout=10000) + await page.wait_for_timeout(2000) # Get page content content = await page.content() @@ -257,7 +266,14 @@ async def check_webinars_job(context: ContextTypes.DEFAULT_TYPE): logger.info("No webinars found (expected message present)") except Exception as e: - logger.error(f"Error checking page: {e}") + logger.error(f"Error checking page: {e}. Saving content for debug.") + # If page content is available, save it on error + try: + if page and not content: + content = await page.content() + except Exception: + pass # Ignore error during content retrieval on check error + return None finally: await page.close() @@ -269,6 +285,16 @@ async def check_webinars_job(context: ContextTypes.DEFAULT_TYPE): except Exception as e: logger.error(f"Playwright error: {e}") return None + + # --- DEBUG LOGGING (Saving last response content) --- + if not webinar_details and content: #if no webinars found, save the page content + try: + with open('response.html', 'w', encoding='utf-8') as f: + f.write(content) + logger.info("Saved page content to response.html for debug.") + except Exception as e: + logger.error(f"Failed to write debug HTML: {e}") + # ----------------------------------------------------- # Notify subscribers if webinars found if webinar_details: From 3bcabcce4b9f80ff0888d5376dfe5eff5752b2b0 Mon Sep 17 00:00:00 2001 From: mr-forust Date: Thu, 27 Nov 2025 16:33:15 +0100 Subject: [PATCH 13/89] feat: notify on new webinar, storing N last webinars in redis --- edu_master/webinar-checker/checker.py | 103 ++++++++++++++++++++++---- 1 file changed, 88 insertions(+), 15 deletions(-) diff --git a/edu_master/webinar-checker/checker.py b/edu_master/webinar-checker/checker.py index e0217f2..6eb5f50 100644 --- a/edu_master/webinar-checker/checker.py +++ b/edu_master/webinar-checker/checker.py @@ -1,6 +1,7 @@ import os import logging import redis +import json from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup from telegram.ext import Application, CommandHandler, CallbackQueryHandler, ContextTypes @@ -28,6 +29,7 @@ KEY_WHITELIST = "bot:whitelist" KEY_WHITELIST_ENABLED = "bot:whitelist_enabled" KEY_SUBSCRIBERS = "bot:subscribers" KEY_PHPSESSID = "EDU_PHPSESSID" +KEY_WEBINAR_HISTORY = "bot:webinar_history" # Stores last 5 webinars # Initialize Redis try: @@ -196,6 +198,30 @@ async def admin_callback(update: Update, context: ContextTypes.DEFAULT_TYPE): # --- Webinar Checking Job --- +def get_webinar_key(name: str, url: str) -> str: + """Generate unique key for a webinar based on name and URL.""" + return f"{name}|{url}" + +def get_stored_webinars() -> list: + """Get list of stored webinar keys from Redis.""" + data = redis_client.get(KEY_WEBINAR_HISTORY) + if data: + try: + return json.loads(data) + except Exception as e: + logger.error(f"Failed to parse webinar history: {e}") + return [] + +def store_webinars(webinar_keys: list): + """Store up to 5 most recent webinar keys in Redis.""" + # Keep only last 5 + webinar_keys = webinar_keys[-5:] + try: + redis_client.set(KEY_WEBINAR_HISTORY, json.dumps(webinar_keys)) + logger.info(f"Stored {len(webinar_keys)} webinar(s) in history") + except Exception as e: + logger.error(f"Failed to store webinar history: {e}") + async def check_webinars_job(context: ContextTypes.DEFAULT_TYPE): """Background job to check for webinars using Async Playwright. @@ -216,7 +242,7 @@ async def check_webinars_job(context: ContextTypes.DEFAULT_TYPE): # --------------------- return None - webinar_details = [] + current_webinars = [] # List of dicts with name, url, and formatted text content = "" try: @@ -259,9 +285,26 @@ async def check_webinars_job(context: ContextTypes.DEFAULT_TYPE): count = await rows.count() for i in range(count): - text = await rows.nth(i).inner_text() + row = rows.nth(i) + text = await row.inner_text() + if "Жодного онлайн уроку зараз" not in text: - webinar_details.append(text.strip()) + # Extract name (topic) from first column + name_elem = row.locator('td').nth(0) + name = await name_elem.inner_text() + name = name.strip() + + # Extract join URL from fourth column + url_elem = row.locator('td').nth(3).locator('a[href*="/webinar/join/"]').first + url = await url_elem.get_attribute('href') + + if name and url: + current_webinars.append({ + 'name': name, + 'url': url, + 'text': text.strip() + }) + logger.info(f"Found webinar: {name} -> {url}") else: logger.info("No webinars found (expected message present)") @@ -287,7 +330,7 @@ async def check_webinars_job(context: ContextTypes.DEFAULT_TYPE): return None # --- DEBUG LOGGING (Saving last response content) --- - if not webinar_details and content: #if no webinars found, save the page content + if not current_webinars and content: #if no webinars found, save the page content try: with open('response.html', 'w', encoding='utf-8') as f: f.write(content) @@ -296,19 +339,49 @@ async def check_webinars_job(context: ContextTypes.DEFAULT_TYPE): logger.error(f"Failed to write debug HTML: {e}") # ----------------------------------------------------- - # Notify subscribers if webinars found - if webinar_details: - message = "🎓 Найден вебинар!\n\n" + "\n\n".join([f"📌 {d}" for d in webinar_details]) - subscribers = redis_client.smembers(KEY_SUBSCRIBERS) + # Check for NEW webinars and notify + if current_webinars: + # Get stored webinar history + stored_keys = get_stored_webinars() + logger.info(f"Stored webinar keys: {stored_keys}") - for sub_id in subscribers: - try: - await context.bot.send_message(chat_id=sub_id, text=message, parse_mode='HTML') - logger.info(f"Notification sent to {sub_id}") - except Exception as e: - logger.error(f"Failed to send to {sub_id}: {e}") + # Find new webinars (not in history) + new_webinars = [] + current_keys = [] + + for webinar in current_webinars: + key = get_webinar_key(webinar['name'], webinar['url']) + current_keys.append(key) + + if key not in stored_keys: + new_webinars.append(webinar) + logger.info(f"NEW webinar detected: {webinar['name']}") + + # Update stored history with current webinars + # Merge old and new, keeping only last 5 + updated_keys = stored_keys + [k for k in current_keys if k not in stored_keys] + store_webinars(updated_keys) + + # Notify subscribers ONLY about NEW webinars + if new_webinars: + message = "🎓 Новий вебінар!\n\n" + "\n\n".join([ + f"📌 {w['name']}\n🔗 https://edu.edu.vn.ua{w['url']}" + for w in new_webinars + ]) + + subscribers = redis_client.smembers(KEY_SUBSCRIBERS) + logger.info(f"Sending notification about {len(new_webinars)} new webinar(s) to {len(subscribers)} subscriber(s)") + + for sub_id in subscribers: + try: + await context.bot.send_message(chat_id=sub_id, text=message, parse_mode='HTML') + logger.info(f"Notification sent to {sub_id}") + except Exception as e: + logger.error(f"Failed to send to {sub_id}: {e}") + else: + logger.info(f"Found {len(current_webinars)} webinar(s), but all are already known") - return len(webinar_details) + return len(current_webinars) # --- Main --- From 6cb49be951ee93f39ecb3a33d614a56d577a1a42 Mon Sep 17 00:00:00 2001 From: mr-forust Date: Thu, 27 Nov 2025 18:58:17 +0100 Subject: [PATCH 14/89] chore: update gitignore, add translations --- .gitignore | 31 ++- edu_master/webinar-checker/checker.py | 277 +++++++++++++++++++++----- userbot/.gitignore | 11 +- 3 files changed, 254 insertions(+), 65 deletions(-) diff --git a/.gitignore b/.gitignore index f29520c..f36e287 100644 --- a/.gitignore +++ b/.gitignore @@ -29,8 +29,35 @@ traefik/certs/* # Python .python-version -.venv/ venv/ +pyc +unknown_errors.txt +moonlogs.txt +thumb.jpg +antipm_pic.jpg +musicbot/ +.trunk/ +previous_profiles/ +.python-version +/modules/__pycache__/ +__pycache__/ +*.session +*.session-old +*.db +*.sqlite3 +*-journal +/venv/ +.venv/ -#DataSecurity +# DataSecurity replacements.txt + +# Vscode +.vscode + +# Git +.gitattributes + +# Misc +.DS_Store +.idea \ No newline at end of file diff --git a/edu_master/webinar-checker/checker.py b/edu_master/webinar-checker/checker.py index 6eb5f50..0477481 100644 --- a/edu_master/webinar-checker/checker.py +++ b/edu_master/webinar-checker/checker.py @@ -40,6 +40,156 @@ except Exception as e: logger.error(f"Failed to connect to Redis: {e}") exit(1) +# --- Translations --- + +TRANSLATIONS = { + 'ru': { + 'welcome': "👋 Привет, {name}!\n\nЯ бот-уведомитель о вебинарах. Я буду сообщать вам, когда появится новый вебинар.\nВы подписаны на уведомления.", + 'welcome_admin': "\n\n👑 Режим администратора активен", + 'access_denied': "⛔ Доступ запрещен. Вас нет в белом списке.", + 'help_title': "🤖 Помощь по боту\n\n", + 'help_commands': "/start - Подписаться на уведомления\n/help - Показать это сообщение\n/language - Сменить язык", + 'help_admin': "\nКоманды администратора:\n/adduser [user_id] - Добавить пользователя в белый список\n/removeuser [user_id] - Удалить пользователя из белого списка\nИли используйте панель ниже для управления настройками.", + 'admin_only': "⛔ Только для администратора!", + 'user_added': "✅ Пользователь {user_id} добавлен в белый список", + 'user_removed': "✅ Пользователь {user_id} удален из белого списка", + 'user_not_in_whitelist': "⚠️ Пользователь {user_id} не был в белом списке", + 'cannot_remove_admin': "❌ Невозможно удалить администратора из белого списка", + 'invalid_user_id': "❌ Неверный ID пользователя. Должно быть число.", + 'usage_adduser': "Использование: /adduser [user_id]", + 'usage_removeuser': "Использование: /removeuser [user_id]", + 'whitelist_enabled': "✅ Белый список включен", + 'whitelist_disabled': "✅ Белый список отключен", + 'whitelist_title': "📋 Белый список:\n", + 'subscribers_title': "👥 Подписчики:\n", + 'empty': "Пусто", + 'force_check_running': "🔄 Запускаю проверку...", + 'check_failed': "❌ Проверка не удалась. Смотрите логи.", + 'check_completed_none': "✅ Проверка завершена. Вебинаров не найдено.", + 'check_completed': "✅ Проверка завершена. Найдено {count} вебинар(ов)!", + 'toggle_whitelist_disable': "🔒 Отключить белый список", + 'toggle_whitelist_enable': "🔓 Включить белый список", + 'view_whitelist': "📋 Посмотреть белый список", + 'view_subscribers': "👥 Посмотреть подписчиков", + 'force_check': "🔄 Принудительная проверка", + 'webinar_found': "🎓 Новый вебинар!\n\n", + 'webinar_item': "📌 {name}\n🔗 https://edu.edu.vn.ua{url}", + 'select_language': "🌐 Выберите язык / Оберіть мову / Select language:", + 'language_changed': "✅ Язык изменен на русский", + 'flag_ru': "🇷🇺 Русский", + 'flag_uk': "🇺🇦 Українська", + 'flag_en': "🇬🇧 English", + }, + 'uk': { + 'welcome': "👋 Привіт, {name}!\n\nЯ бот-сповіщувач про вебінари. Я повідомлятиму вас, коли з'явиться новий вебінар.\nВи підписані на сповіщення.", + 'welcome_admin': "\n\n👑 Режим адміністратора активний", + 'access_denied': "⛔ Доступ заборонено. Вас немає в білому списку.", + 'help_title': "🤖 Довідка по боту\n\n", + 'help_commands': "/start - Підписатися на сповіщення\n/help - Показати це повідомлення\n/language - Змінити мову", + 'help_admin': "\nКоманди адміністратора:\n/adduser [user_id] - Додати користувача до білого списку\n/removeuser [user_id] - Видалити користувача з білого списку\nАбо використовуйте панель нижче для керування налаштуваннями.", + 'admin_only': "⛔ Тільки для адміністратора!", + 'user_added': "✅ Користувач {user_id} доданий до білого списку", + 'user_removed': "✅ Користувач {user_id} видалений з білого списку", + 'user_not_in_whitelist': "⚠️ Користувач {user_id} не був у білому списку", + 'cannot_remove_admin': "❌ Неможливо видалити адміністратора з білого списку", + 'invalid_user_id': "❌ Невірний ID користувача. Має бути число.", + 'usage_adduser': "Використання: /adduser [user_id]", + 'usage_removeuser': "Використання: /removeuser [user_id]", + 'whitelist_enabled': "✅ Білий список увімкнено", + 'whitelist_disabled': "✅ Білий список вимкнено", + 'whitelist_title': "📋 Білий список:\n", + 'subscribers_title': "👥 Підписники:\n", + 'empty': "Порожньо", + 'force_check_running': "🔄 Запускаю перевірку...", + 'check_failed': "❌ Перевірка не вдалася. Дивіться логи.", + 'check_completed_none': "✅ Перевірка завершена. Вебінарів не знайдено.", + 'check_completed': "✅ Перевірка завершена. Знайдено {count} вебінар(ів)!", + 'toggle_whitelist_disable': "🔒 Вимкнути білий список", + 'toggle_whitelist_enable': "🔓 Увімкнути білий список", + 'view_whitelist': "📋 Переглянути білий список", + 'view_subscribers': "👥 Переглянути підписників", + 'force_check': "🔄 Примусова перевірка", + 'webinar_found': "🎓 Новий вебінар!\n\n", + 'webinar_item': "📌 {name}\n🔗 https://edu.edu.vn.ua{url}", + 'select_language': "🌐 Виберіть мову / Выберите язык / Select language:", + 'language_changed': "✅ Мову змінено на українську", + 'flag_ru': "🇷🇺 Русский", + 'flag_uk': "🇺🇦 Українська", + 'flag_en': "🇬🇧 English", + }, + 'en': { + 'welcome': "👋 Hello, {name}!\n\nI am the Webinar Checker Bot. I will notify you when a new webinar appears.\nYou have been subscribed to notifications.", + 'welcome_admin': "\n\n👑 Admin Mode Active", + 'access_denied': "⛔ Access denied. You are not on the whitelist.", + 'help_title': "🤖 Bot Help\n\n", + 'help_commands': "/start - Subscribe to notifications\n/help - Show this message\n/language - Change language", + 'help_admin': "\nAdmin Commands:\n/adduser [user_id] - Add user to whitelist\n/removeuser [user_id] - Remove user from whitelist\nOr use the panel below to manage settings.", + 'admin_only': "⛔ Admin only!", + 'user_added': "✅ User {user_id} added to whitelist", + 'user_removed': "✅ User {user_id} removed from whitelist", + 'user_not_in_whitelist': "⚠️ User {user_id} was not in whitelist", + 'cannot_remove_admin': "❌ Cannot remove admin from whitelist", + 'invalid_user_id': "❌ Invalid user ID. Must be a number.", + 'usage_adduser': "Usage: /adduser [user_id]", + 'usage_removeuser': "Usage: /removeuser [user_id]", + 'whitelist_enabled': "✅ Whitelist Enabled", + 'whitelist_disabled': "✅ Whitelist Disabled", + 'whitelist_title': "📋 Whitelist:\n", + 'subscribers_title': "👥 Subscribers:\n", + 'empty': "Empty", + 'force_check_running': "🔄 Running immediate check...", + 'check_failed': "❌ Check failed. See logs for details.", + 'check_completed_none': "✅ Check completed. No webinars found.", + 'check_completed': "✅ Check completed. Found {count} webinar(s)!", + 'toggle_whitelist_disable': "🔒 Disable Whitelist", + 'toggle_whitelist_enable': "🔓 Enable Whitelist", + 'view_whitelist': "📋 View Whitelist", + 'view_subscribers': "👥 View Subscribers", + 'force_check': "🔄 Force Check", + 'webinar_found': "🎓 New webinar found!\n\n", + 'webinar_item': "📌 {name}\n🔗 https://edu.edu.vn.ua{url}", + 'select_language': "🌐 Select language / Виберіть мову / Выберите язык:", + 'language_changed': "✅ Language changed to English", + 'flag_ru': "🇷🇺 Русский", + 'flag_uk': "🇺🇦 Українська", + 'flag_en': "🇬🇧 English", + } +} + +# --- Language Helper Functions --- + +def get_user_language(user_id: int) -> str: + """Get user's preferred language from Redis. Default: Ukrainian.""" + lang = redis_client.get(f"user:{user_id}:language") + return lang if lang in ['ru', 'uk', 'en'] else 'uk' + +def set_user_language(user_id: int, lang: str): + """Save user's language preference to Redis.""" + if lang in ['ru', 'uk', 'en']: + redis_client.set(f"user:{user_id}:language", lang) + logger.info(f"User {user_id} language set to {lang}") + +def t(user_id: int, key: str, **kwargs) -> str: + """Translate message for user with optional formatting.""" + lang = get_user_language(user_id) + message = TRANSLATIONS.get(lang, TRANSLATIONS['uk']).get(key, key) + if kwargs: + return message.format(**kwargs) + return message + +def get_language_keyboard(): + """Generate language selection keyboard.""" + keyboard = [ + [ + InlineKeyboardButton("🇷🇺 Русский", callback_data="lang_ru"), + InlineKeyboardButton("🇺🇦 Українська", callback_data="lang_uk"), + ], + [ + InlineKeyboardButton("🇬🇧 English", callback_data="lang_en"), + ] + ] + return InlineKeyboardMarkup(keyboard) + # --- Helper Functions --- def is_whitelisted(user_id: int) -> bool: @@ -53,16 +203,16 @@ def is_whitelisted(user_id: int) -> bool: return redis_client.sismember(KEY_WHITELIST, str(user_id)) -def get_admin_keyboard(): +def get_admin_keyboard(user_id: int): """Generate admin panel keyboard.""" whitelist_enabled = redis_client.get(KEY_WHITELIST_ENABLED) != "0" - toggle_text = "🔒 Disable Whitelist" if whitelist_enabled else "🔓 Enable Whitelist" + toggle_text = t(user_id, 'toggle_whitelist_disable') if whitelist_enabled else t(user_id, 'toggle_whitelist_enable') keyboard = [ [InlineKeyboardButton(toggle_text, callback_data="toggle_whitelist")], - [InlineKeyboardButton("📋 View Whitelist", callback_data="view_whitelist")], - [InlineKeyboardButton("👥 View Subscribers", callback_data="view_subscribers")], - [InlineKeyboardButton("🔄 Force Check", callback_data="force_check")] + [InlineKeyboardButton(t(user_id, 'view_whitelist'), callback_data="view_whitelist")], + [InlineKeyboardButton(t(user_id, 'view_subscribers'), callback_data="view_subscribers")], + [InlineKeyboardButton(t(user_id, 'force_check'), callback_data="force_check")] ] return InlineKeyboardMarkup(keyboard) @@ -74,84 +224,84 @@ async def start(update: Update, context: ContextTypes.DEFAULT_TYPE): logger.info(f"User {user.id} ({user.username}) started the bot.") if not is_whitelisted(user.id): - await update.message.reply_text("⛔ Access denied. You are not on the whitelist.") + await update.message.reply_text(t(user.id, 'access_denied')) return # Add to subscribers redis_client.sadd(KEY_SUBSCRIBERS, user.id) - msg = ( - f"👋 Hello {user.first_name}!\n\n" - "I am the Webinar Checker Bot. I will notify you when a new webinar appears.\n" - "You have been subscribed to notifications." - ) + msg = t(user.id, 'welcome', name=user.first_name) if user.id == ADMIN_ID: - msg += "\n\n👑 Admin Mode Active" - await update.message.reply_text(msg, parse_mode='HTML', reply_markup=get_admin_keyboard()) + msg += t(user.id, 'welcome_admin') + await update.message.reply_text(msg, parse_mode='HTML', reply_markup=get_admin_keyboard(user.id)) else: await update.message.reply_text(msg, parse_mode='HTML') async def help_command(update: Update, context: ContextTypes.DEFAULT_TYPE): """Handle /help command.""" - msg = ( - "🤖 Bot Help\n\n" - "/start - Subscribe to notifications\n" - "/help - Show this message\n" - ) - if update.effective_user.id == ADMIN_ID: - msg += ( - "\nAdmin Commands:\n" - "/adduser [user_id] - Add user to whitelist\n" - "/removeuser [user_id] - Remove user from whitelist\n" - "Or use the panel below to manage settings." - ) - await update.message.reply_text(msg, parse_mode='HTML', reply_markup=get_admin_keyboard()) + user_id = update.effective_user.id + msg = t(user_id, 'help_title') + t(user_id, 'help_commands') + + if user_id == ADMIN_ID: + msg += t(user_id, 'help_admin') + await update.message.reply_text(msg, parse_mode='HTML', reply_markup=get_admin_keyboard(user_id)) else: await update.message.reply_text(msg, parse_mode='HTML') +async def language_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handle /language command.""" + user_id = update.effective_user.id + await update.message.reply_text( + t(user_id, 'select_language'), + parse_mode='HTML', + reply_markup=get_language_keyboard() + ) + async def add_user(update: Update, context: ContextTypes.DEFAULT_TYPE): """Add user to whitelist (admin only).""" - if update.effective_user.id != ADMIN_ID: - await update.message.reply_text("⛔ Admin only!") + admin_id = update.effective_user.id + if admin_id != ADMIN_ID: + await update.message.reply_text(t(admin_id, 'admin_only')) return if not context.args: - await update.message.reply_text("Usage: /adduser [user_id]") + await update.message.reply_text(t(admin_id, 'usage_adduser')) return try: user_id = int(context.args[0]) redis_client.sadd(KEY_WHITELIST, str(user_id)) - await update.message.reply_text(f"✅ User {user_id} added to whitelist") + await update.message.reply_text(t(admin_id, 'user_added', user_id=user_id)) logger.info(f"Admin added user {user_id} to whitelist") except ValueError: - await update.message.reply_text("❌ Invalid user ID. Must be a number.") + await update.message.reply_text(t(admin_id, 'invalid_user_id')) async def remove_user(update: Update, context: ContextTypes.DEFAULT_TYPE): """Remove user from whitelist (admin only).""" - if update.effective_user.id != ADMIN_ID: - await update.message.reply_text("⛔ Admin only!") + admin_id = update.effective_user.id + if admin_id != ADMIN_ID: + await update.message.reply_text(t(admin_id, 'admin_only')) return if not context.args: - await update.message.reply_text("Usage: /removeuser [user_id]") + await update.message.reply_text(t(admin_id, 'usage_removeuser')) return try: user_id = int(context.args[0]) if str(user_id) == str(ADMIN_ID): - await update.message.reply_text("❌ Cannot remove admin from whitelist") + await update.message.reply_text(t(admin_id, 'cannot_remove_admin')) return removed = redis_client.srem(KEY_WHITELIST, str(user_id)) if removed: - await update.message.reply_text(f"✅ User {user_id} removed from whitelist") + await update.message.reply_text(t(admin_id, 'user_removed', user_id=user_id)) logger.info(f"Admin removed user {user_id} from whitelist") else: - await update.message.reply_text(f"⚠️ User {user_id} was not in whitelist") + await update.message.reply_text(t(admin_id, 'user_not_in_whitelist', user_id=user_id)) except ValueError: - await update.message.reply_text("❌ Invalid user ID. Must be a number.") + await update.message.reply_text(t(admin_id, 'invalid_user_id')) # --- Admin Callbacks --- @@ -161,7 +311,7 @@ async def admin_callback(update: Update, context: ContextTypes.DEFAULT_TYPE): user_id = query.from_user.id if user_id != ADMIN_ID: - await query.answer("⛔ Admin only!", show_alert=True) + await query.answer(t(user_id, 'admin_only'), show_alert=True) return await query.answer() @@ -171,30 +321,45 @@ async def admin_callback(update: Update, context: ContextTypes.DEFAULT_TYPE): current = redis_client.get(KEY_WHITELIST_ENABLED) new_state = "0" if current != "0" else "1" redis_client.set(KEY_WHITELIST_ENABLED, new_state) - state_text = "Disabled" if new_state == "0" else "Enabled" - await query.edit_message_reply_markup(reply_markup=get_admin_keyboard()) - await query.message.reply_text(f"✅ Whitelist {state_text}") + state_text = t(user_id, 'whitelist_disabled') if new_state == "0" else t(user_id, 'whitelist_enabled') + await query.edit_message_reply_markup(reply_markup=get_admin_keyboard(user_id)) + await query.message.reply_text(state_text) elif data == "view_whitelist": members = redis_client.smembers(KEY_WHITELIST) - msg = "📋 Whitelist:\n" + ("\n".join(members) if members else "Empty") + msg = t(user_id, 'whitelist_title') + ("\n".join(members) if members else t(user_id, 'empty')) await query.message.reply_text(msg, parse_mode='HTML') elif data == "view_subscribers": subs = redis_client.smembers(KEY_SUBSCRIBERS) - msg = "👥 Subscribers:\n" + ("\n".join(subs) if subs else "Empty") + msg = t(user_id, 'subscribers_title') + ("\n".join(subs) if subs else t(user_id, 'empty')) await query.message.reply_text(msg, parse_mode='HTML') elif data == "force_check": - await query.message.reply_text("🔄 Running immediate check...") + await query.message.reply_text(t(user_id, 'force_check_running')) result = await check_webinars_job(context) if result is None: - await query.message.reply_text("❌ Check failed. See logs for details.") + await query.message.reply_text(t(user_id, 'check_failed')) elif result == 0: - await query.message.reply_text("✅ Check completed. No webinars found.") + await query.message.reply_text(t(user_id, 'check_completed_none')) else: - await query.message.reply_text(f"✅ Check completed. Found {result} webinar(s)!") + await query.message.reply_text(t(user_id, 'check_completed', count=result)) + +async def language_callback(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handle language selection button clicks.""" + query = update.callback_query + user_id = query.from_user.id + data = query.data + + if data.startswith("lang_"): + lang = data.split("_")[1] + set_user_language(user_id, lang) + await query.answer() + await query.edit_message_text( + t(user_id, 'language_changed'), + parse_mode='HTML' + ) # --- Webinar Checking Job --- @@ -364,16 +529,18 @@ async def check_webinars_job(context: ContextTypes.DEFAULT_TYPE): # Notify subscribers ONLY about NEW webinars if new_webinars: - message = "🎓 Новий вебінар!\n\n" + "\n\n".join([ - f"📌 {w['name']}\n🔗 https://edu.edu.vn.ua{w['url']}" - for w in new_webinars - ]) - subscribers = redis_client.smembers(KEY_SUBSCRIBERS) logger.info(f"Sending notification about {len(new_webinars)} new webinar(s) to {len(subscribers)} subscriber(s)") for sub_id in subscribers: try: + # Build message in user's language + webinar_items = "\n\n".join([ + t(int(sub_id), 'webinar_item', name=w['name'], url=w['url']) + for w in new_webinars + ]) + message = t(int(sub_id), 'webinar_found') + webinar_items + await context.bot.send_message(chat_id=sub_id, text=message, parse_mode='HTML') logger.info(f"Notification sent to {sub_id}") except Exception as e: @@ -403,8 +570,12 @@ def main(): # Handlers app.add_handler(CommandHandler("start", start)) app.add_handler(CommandHandler("help", help_command)) + app.add_handler(CommandHandler("language", language_command)) app.add_handler(CommandHandler("adduser", add_user)) app.add_handler(CommandHandler("removeuser", remove_user)) + + # Callback handlers - language selection first, then admin panel + app.add_handler(CallbackQueryHandler(language_callback, pattern="^lang_")) app.add_handler(CallbackQueryHandler(admin_callback)) # Job Queue diff --git a/userbot/.gitignore b/userbot/.gitignore index 64097b5..fccd91b 100644 --- a/userbot/.gitignore +++ b/userbot/.gitignore @@ -1,5 +1,6 @@ .DS_Store .gitattributes + .vscode /modules/__pycache__/ __pycache__/ @@ -12,14 +13,4 @@ __pycache__/ .venv/ /downloads/ /Downloads/ -/modules/custom_modules -.idea config.ini -unknown_errors.txt -moonlogs.txt -thumb.jpg -antipm_pic.jpg -musicbot/ -.trunk/ -previous_profiles/ -.python-version \ No newline at end of file From 6f2f70ad095076555f3b24e49cf623350162ef24 Mon Sep 17 00:00:00 2001 From: mr-forust Date: Fri, 28 Nov 2025 08:16:04 +0100 Subject: [PATCH 15/89] chore: ingore streaming services --- .gitignore | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.gitignore b/.gitignore index f36e287..166fda0 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,15 @@ metube/MeTube_downloads uptime-kuma/data/ termix/termix-data/* +# Steaming services files +streaming/jellyfin/* +streaming/jellyseerr/* +streaming/sonarr/* +streaming/radarr/* +streaming/data/* +streaming/qbittorrent/* +streaming/prowlarr/* + # Homepage homepage/files/assets/images/team/* From 2d05a1911c575efaf8989de29aa2a185f7ec0ca8 Mon Sep 17 00:00:00 2001 From: mr-forust Date: Sun, 30 Nov 2025 03:28:41 +0100 Subject: [PATCH 16/89] feat: cloudflare IP sync (ddns) --- .gitignore | 3 ++- cfddns/compose.yaml | 13 +++++++++++++ cfddns/config.json.example | 22 ++++++++++++++++++++++ 3 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 cfddns/compose.yaml create mode 100644 cfddns/config.json.example diff --git a/.gitignore b/.gitignore index 166fda0..ade6b3e 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ portainer/portainer_data/* metube/MeTube_downloads uptime-kuma/data/ termix/termix-data/* +cfddns/config.json # Steaming services files streaming/jellyfin/* @@ -69,4 +70,4 @@ replacements.txt # Misc .DS_Store -.idea \ No newline at end of file +.idea diff --git a/cfddns/compose.yaml b/cfddns/compose.yaml new file mode 100644 index 0000000..8c3a0f3 --- /dev/null +++ b/cfddns/compose.yaml @@ -0,0 +1,13 @@ +services: + cloudflare-ddns: + image: timothyjmiller/cloudflare-ddns:latest + container_name: cloudflare-ddns + security_opt: + - no-new-privileges:true + network_mode: 'host' + environment: + - PUID=1000 + - PGID=1000 + volumes: + - ./config.json:/config.json + restart: unless-stopped diff --git a/cfddns/config.json.example b/cfddns/config.json.example new file mode 100644 index 0000000..d290046 --- /dev/null +++ b/cfddns/config.json.example @@ -0,0 +1,22 @@ +{ + "cloudflare": [ + { + "authentication": { + "api_token": "API_TOKEN" + }, + "api_key": { + "api_key": "api_key_here", + "account_email": "your_email_here" + } + "zone_id": "your_zone-id", + "subdomains": [ + { "name": "", "proxied": true }, + { "name": "www", "proxied": true } + ] + } + ], + "a": true, + "aaaa": false, + "purgeUnknownRecords": false, + "ttl": 300 +} From 8b6815f31454f71d3a850e0c6f4444e3d39b3139 Mon Sep 17 00:00:00 2001 From: mr-forust Date: Mon, 1 Dec 2025 23:03:28 +0100 Subject: [PATCH 17/89] chore: gitignored temp folder for references --- .gitignore | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.gitignore b/.gitignore index ade6b3e..b6bc453 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,8 @@ sync.ffs_lock .env .env.anna .env.forust +.env.* +.env.*example # Volumes and data directories gitea/gitea-db/ @@ -71,3 +73,9 @@ replacements.txt # Misc .DS_Store .idea +<<<<<<< HEAD +======= + +# Temp files +edu_master/temp/ +>>>>>>> 1abf4a7 (chore: gitignored temp folder for references) From 1ec145d4cf38e8a6b5137daae3068849ba217aea Mon Sep 17 00:00:00 2001 From: mr-forust Date: Wed, 3 Dec 2025 00:30:06 +0100 Subject: [PATCH 18/89] chore: add xdfnx domain to team links --- homepage/files/index.html | 41 ++++++++++++++++++++++++++------------- 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/homepage/files/index.html b/homepage/files/index.html index 1573d24..3c2f5eb 100644 --- a/homepage/files/index.html +++ b/homepage/files/index.html @@ -1,5 +1,6 @@ + @@ -8,6 +9,7 @@ +
@@ -22,23 +24,23 @@

./socials

@@ -94,31 +96,41 @@

./xrock_team

# It's a select caste. Cybershamans. Cryptoanarchists. Shadows on the net..

- +
-
+
+
MrForust
-
+
+
Anna~
-
+
+
Chernuha
-
+
+
p1ngvi
- +
-
- xdfnx +
+
+ xdfnx
@@ -152,7 +164,7 @@ - +

root@xrock:~$ cat ./miku

miku?

@@ -162,4 +174,5 @@
+ \ No newline at end of file From 15f0f35ce4667f2aa7f896d0ec7e6c5c6f428027 Mon Sep 17 00:00:00 2001 From: mr-forust Date: Wed, 3 Dec 2025 04:23:34 +0100 Subject: [PATCH 19/89] chore: exclude prometheus config --- .gitignore | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index b6bc453..be074b0 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,9 @@ traefik/letsencrypt/acme.json traefik/logs/* traefik/certs/* +# Monitoring +monitoring/prometheus.yml + # Python .python-version venv/ @@ -73,9 +76,6 @@ replacements.txt # Misc .DS_Store .idea -<<<<<<< HEAD -======= # Temp files edu_master/temp/ ->>>>>>> 1abf4a7 (chore: gitignored temp folder for references) From 8a0ed85e7cc9e06a17a4803d2237f4920373745e Mon Sep 17 00:00:00 2001 From: mr-forust Date: Thu, 4 Dec 2025 20:04:49 +0100 Subject: [PATCH 20/89] feat: Penpoint service, traefik integration --- penpot/compose.yaml | 281 ++++++++++++++++++++++++++++++ traefik/dynamic/routers-dev.yml | 18 +- traefik/dynamic/routers-local.yml | 13 +- traefik/dynamic/routers-prod.yml | 14 +- traefik/dynamic/services.yml | 6 + 5 files changed, 324 insertions(+), 8 deletions(-) create mode 100644 penpot/compose.yaml diff --git a/penpot/compose.yaml b/penpot/compose.yaml new file mode 100644 index 0000000..9f5c1c4 --- /dev/null +++ b/penpot/compose.yaml @@ -0,0 +1,281 @@ +## Common flags: +# demo-users +# email-verification +# log-emails +# log-invitation-tokens +# login-with-github +# login-with-gitlab +# login-with-google +# login-with-ldap +# login-with-oidc +# login-with-password +# prepl-server +# registration +# secure-session-cookies +# smtp +# smtp-debug +# telemetry +# webhooks +## +## You can read more about all available flags and other +## environment variables here: +## https://help.penpot.app/technical-guide/configuration/#penpot-configuration +# +# WARNING: if you're exposing Penpot to the internet, you should remove the flags +# 'disable-secure-session-cookies' and 'disable-email-verification' +x-flags: &penpot-flags + PENPOT_FLAGS: disable-email-verification enable-prepl-server disable-secure-session-cookies login-with-github + +x-uri: &penpot-public-uri + PENPOT_PUBLIC_URI: http://localhost:9001 + +x-body-size: &penpot-http-body-size + # Max body size (30MiB); Used for plain requests, should never be + # greater than multi-part size + PENPOT_HTTP_SERVER_MAX_BODY_SIZE: 31457280 + + # Max multipart body size (350MiB) + PENPOT_HTTP_SERVER_MAX_MULTIPART_BODY_SIZE: 367001600 + +## Penpot SECRET KEY. It serves as a master key from which other keys for subsystems +## (eg http sessions, or invitations) are derived. +## +## We recommend to use a trully randomly generated +## 512 bits base64 encoded string here. You can generate one with: +## +## python3 -c "import secrets; print(secrets.token_urlsafe(64))" +x-secret-key: &penpot-secret-key + PENPOT_SECRET_KEY: ${PENPOT_SECRET_KEY} + +networks: + penpot: + traefik-proxy: + external: true + +volumes: + penpot_postgres_v15: + penpot_assets: + penpot_traefik: + # penpot_minio: + +services: + ## Traefik service declaration example. Consider using it if you are going to expose + ## penpot to the internet, or a different host than `localhost`. + + # traefik: + # image: traefik:v3.3 + # networks: + # - penpot + # command: + # - "--api.insecure=true" + # - "--entryPoints.web.address=:80" + # - "--providers.docker=true" + # - "--providers.docker.exposedbydefault=false" + # - "--entryPoints.websecure.address=:443" + # - "--certificatesresolvers.letsencrypt.acme.tlschallenge=true" + # - "--certificatesresolvers.letsencrypt.acme.email=" + # - "--certificatesresolvers.letsencrypt.acme.storage=/traefik/acme.json" + # volumes: + # - "penpot_traefik:/traefik" + # - "/var/run/docker.sock:/var/run/docker.sock" + # ports: + # - "80:80" + # - "443:443" + + penpot-frontend: + image: "penpotapp/frontend:${PENPOT_VERSION:-latest}" + restart: always + ports: + - 9001:8080 + + volumes: + - penpot_assets:/opt/data/assets + + depends_on: + - penpot-backend + - penpot-exporter + + networks: + - penpot + - traefik-proxy + + # labels: + # - "traefik.enable=true" + + # ## HTTPS: example of labels for the case where penpot will be exposed to the + # ## internet with HTTPS using traefik. + + # - "traefik.http.routers.penpot-https.rule=Host(``)" + # - "traefik.http.routers.penpot-https.entrypoints=websecure" + # - "traefik.http.routers.penpot-https.tls.certresolver=letsencrypt" + # - "traefik.http.routers.penpot-https.tls=true" + + environment: + << : [*penpot-flags, *penpot-http-body-size] + + penpot-backend: + image: "penpotapp/backend:${PENPOT_VERSION:-latest}" + restart: always + + volumes: + - penpot_assets:/opt/data/assets + + depends_on: + penpot-postgres: + condition: service_healthy + penpot-valkey: + condition: service_healthy + + networks: + - penpot + + ## Configuration envronment variables for the backend container. + + environment: + << : [*penpot-flags, *penpot-public-uri, *penpot-http-body-size, *penpot-secret-key] + + ## The PREPL host. Mainly used for external programatic access to penpot backend + ## (example: admin). By default it will listen on `localhost` but if you are going to use + ## the `admin`, you will need to uncomment this and set the host to `0.0.0.0`. + + # PENPOT_PREPL_HOST: 0.0.0.0 + + ## Database connection parameters. Don't touch them unless you are using custom + ## postgresql connection parameters. + + PENPOT_DATABASE_URI: postgresql://penpot-postgres/penpot + PENPOT_DATABASE_USERNAME: penpot + PENPOT_DATABASE_PASSWORD: penpot + + ## Valkey (or previously redis) is used for the websockets notifications. Don't touch + ## unless the valkey container has different parameters or different name. + + PENPOT_REDIS_URI: redis://penpot-valkey/0 + + ## Default configuration for assets storage: using filesystem based with all files + ## stored in a docker volume. + + PENPOT_ASSETS_STORAGE_BACKEND: assets-fs + PENPOT_STORAGE_ASSETS_FS_DIRECTORY: /opt/data/assets + + ## Also can be configured to to use a S3 compatible storage + ## service like MiniIO. Look below for minio service setup. + + # AWS_ACCESS_KEY_ID: + # AWS_SECRET_ACCESS_KEY: + # PENPOT_ASSETS_STORAGE_BACKEND: assets-s3 + # PENPOT_STORAGE_ASSETS_S3_ENDPOINT: http://penpot-minio:9000 + # PENPOT_STORAGE_ASSETS_S3_BUCKET: + + ## Telemetry. When enabled, a periodical process will send anonymous data about this + ## instance. Telemetry data will enable us to learn how the application is used, + ## based on real scenarios. If you want to help us, please leave it enabled. You can + ## audit what data we send with the code available on github. + + PENPOT_TELEMETRY_ENABLED: true + PENPOT_TELEMETRY_REFERER: compose + + # PENPOT_SMTP_DEFAULT_FROM: no-reply@example.com + # PENPOT_SMTP_DEFAULT_REPLY_TO: no-reply@example.com + # PENPOT_SMTP_HOST: penpot-mailcatch + # PENPOT_SMTP_PORT: 1025 + # PENPOT_SMTP_USERNAME: + # PENPOT_SMTP_PASSWORD: + # PENPOT_SMTP_TLS: false + # PENPOT_SMTP_SSL: false + + penpot-exporter: + image: "penpotapp/exporter:${PENPOT_VERSION:-latest}" + restart: always + + depends_on: + penpot-valkey: + condition: service_healthy + + networks: + - penpot + + environment: + << : [*penpot-secret-key] + # Don't touch it; this uses an internal docker network to + # communicate with the frontend. + PENPOT_PUBLIC_URI: http://penpot-frontend:8080 + + ## Valkey (or previously Redis) is used for the websockets notifications. + PENPOT_REDIS_URI: redis://penpot-valkey/0 + + penpot-postgres: + image: "postgres:15" + restart: always + stop_signal: SIGINT + + healthcheck: + test: ["CMD-SHELL", "pg_isready -U penpot"] + interval: 2s + timeout: 10s + retries: 5 + start_period: 2s + + volumes: + - penpot_postgres_v15:/var/lib/postgresql/data + + networks: + - penpot + + environment: + - POSTGRES_INITDB_ARGS=--data-checksums + - POSTGRES_DB=penpot + - POSTGRES_USER=penpot + - POSTGRES_PASSWORD=penpot + + penpot-valkey: + image: valkey/valkey:8.1 + restart: always + + healthcheck: + test: ["CMD-SHELL", "valkey-cli ping | grep PONG"] + interval: 1s + timeout: 3s + retries: 5 + start_period: 3s + + networks: + - penpot + + environment: + # You can increase the max memory size if you have sufficient resources, + # although this should not be necessary. + - VALKEY_EXTRA_FLAGS=--maxmemory 128mb --maxmemory-policy volatile-lfu + + ## A mailcatch service, used as temporal SMTP server. You can access via HTTP to the + ## port 1080 for read all emails the penpot platform has sent. Should be only used as a + ## temporal solution while no real SMTP provider is configured. + + # penpot-mailcatch: + # image: sj26/mailcatcher:latest + # restart: always + # expose: + # - '1025' + # ports: + # - "1080:1080" + # networks: + # - penpot + + ## Example configuration of MiniIO (S3 compatible object storage service); If you don't + ## have preference, then just use filesystem, this is here just for the completeness. + + # minio: + # image: "minio/minio:latest" + # command: minio server /mnt/data --console-address ":9001" + # restart: always + # + # volumes: + # - "penpot_minio:/mnt/data" + # + # environment: + # - MINIO_ROOT_USER=minioadmin + # - MINIO_ROOT_PASSWORD=minioadmin + # + # ports: + # - 9000:9000 + # - 9001:9001 diff --git a/traefik/dynamic/routers-dev.yml b/traefik/dynamic/routers-dev.yml index 58c16d4..4a3ebea 100644 --- a/traefik/dynamic/routers-dev.yml +++ b/traefik/dynamic/routers-dev.yml @@ -11,7 +11,6 @@ http: - security-headers tls: {} - # Nextcloud AIO Interface (dev access. local only) nextcloud-aio-dev: rule: "Host(`nextcloud-aio.gigaforust`)" @@ -42,7 +41,7 @@ http: - metube-auth - security-headers tls: {} - + # Portainer (dev acess, (account required)) portainer-dev: rule: "Host(`portainer.gigaforust`)" @@ -131,7 +130,7 @@ http: middlewares: - security-headers tls: {} - + # Landing Page (public) forust-homepage-dev: rule: "Host(`landing.gigaforust`)" @@ -140,4 +139,15 @@ http: service: forust-homepage middlewares: - security-headers - tls: {} \ No newline at end of file + tls: {} + + # Penpot + penpot-dev: + rule: "Host(`penpot.gigaforust`)" + entryPoints: + - websecure + - web + service: penpot + middlewares: + - security-headers + tls: {} diff --git a/traefik/dynamic/routers-local.yml b/traefik/dynamic/routers-local.yml index ecbab2f..c01d51c 100644 --- a/traefik/dynamic/routers-local.yml +++ b/traefik/dynamic/routers-local.yml @@ -11,7 +11,6 @@ http: - security-headers tls: {} - # Nextcloud AIO Interface (local access. ADMIN, local only) nextcloud-aio-local: rule: "Host(`nextcloud-aio.workstation`)" @@ -42,7 +41,7 @@ http: - metube-auth - security-headers tls: {} - + # Portainer (dev acess, (account required)) portainer-local: rule: "Host(`portainer.workstation`)" @@ -81,6 +80,16 @@ http: - nextcloud-chain tls: {} + # Nextcloud Main (public (account required)) + penpot-local: + rule: "Host(`penpot.workstation`)" + entrypoints: + - websecure + service: penpot + middlewares: + - security-headers + tls: {} + # Dockmon (local access (account required)) dockmon-local: rule: "Host(`dockmon.workstation`)" diff --git a/traefik/dynamic/routers-prod.yml b/traefik/dynamic/routers-prod.yml index fd9e4b6..ff9cd44 100644 --- a/traefik/dynamic/routers-prod.yml +++ b/traefik/dynamic/routers-prod.yml @@ -21,6 +21,16 @@ http: - security-headers tls: {} + # Nextcloud AIO Interface (ADMIN, without subdomeain) + penpot: + rule: "Host(`penpot.forust.xyz`)" + entryPoints: + - websecure + service: penpot + middlewares: + - security-headers + tls: {} + # Termix (public (account required)) termix: rule: "Host(`termix.forust.xyz`)" @@ -41,7 +51,7 @@ http: - metube-auth - security-headers tls: {} - + # Portainer (public (account required)) portainer: rule: "Host(`portainer.forust.xyz`)" @@ -139,4 +149,4 @@ http: service: forust-homepage middlewares: - security-headers - tls: {} \ No newline at end of file + tls: {} diff --git a/traefik/dynamic/services.yml b/traefik/dynamic/services.yml index 712706f..7200893 100644 --- a/traefik/dynamic/services.yml +++ b/traefik/dynamic/services.yml @@ -20,6 +20,12 @@ http: - url: "https://nextcloud-aio-mastercontainer:8080" serversTransport: insecureTransport + # Penpot + penpot: + loadBalancer: + servers: + - url: "http://penpot-frontend:8080" + # Nextcloud Main nextcloud: loadBalancer: From d776124f2617dbe6a47a7c8f3a61ae67edcb94f8 Mon Sep 17 00:00:00 2001 From: mr-forust Date: Thu, 4 Dec 2025 20:25:32 +0100 Subject: [PATCH 21/89] chore: unignored examples, example env for penpot --- .gitignore | 2 +- penpot/.env.example | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 penpot/.env.example diff --git a/.gitignore b/.gitignore index be074b0..f84f396 100644 --- a/.gitignore +++ b/.gitignore @@ -7,7 +7,7 @@ sync.ffs_lock .env.anna .env.forust .env.* -.env.*example +!.env.*example # Volumes and data directories gitea/gitea-db/ diff --git a/penpot/.env.example b/penpot/.env.example new file mode 100644 index 0000000..040fc0a --- /dev/null +++ b/penpot/.env.example @@ -0,0 +1 @@ +PENPOT_SECRET_KEY=somesecretfdsfsdfds \ No newline at end of file From fc6a11397bd2dff8c950844c1c40a01ba0b2ad4e Mon Sep 17 00:00:00 2001 From: mr-forust Date: Fri, 5 Dec 2025 04:03:24 +0100 Subject: [PATCH 22/89] refactor: move traefik configuration to docker-compose files via labels --- adguardhome/compose.yaml | 26 +++++++++- dockmon/compose.yaml | 42 ++++++++++++---- gitea/compose.yaml | 26 +++++++++- glance/compose.yaml | 26 ++++++++-- homepage/compose.yaml | 27 +++++++++- metube/compose.yaml | 26 +++++++++- n8n/compose.yaml | 39 ++++++++++++--- nextcloud/compose.yaml | 48 ++++++++++++++---- penpot/compose.yaml | 50 ++++++++++++------- portainer/compose.yaml | 26 +++++++++- termix/compose.yaml | 27 +++++++++- traefik/compose.yaml | 45 ++++++++++++----- .../{routers-dev.yml => routers-dev.yml.bak} | 0 ...outers-local.yml => routers-local.yml.bak} | 0 ...{routers-prod.yml => routers-prod.yml.bak} | 0 .../{services.yml => services.yml.bak} | 0 uptime-kuma/compose.yaml | 22 ++++++-- 17 files changed, 356 insertions(+), 74 deletions(-) rename traefik/dynamic/{routers-dev.yml => routers-dev.yml.bak} (100%) rename traefik/dynamic/{routers-local.yml => routers-local.yml.bak} (100%) rename traefik/dynamic/{routers-prod.yml => routers-prod.yml.bak} (100%) rename traefik/dynamic/{services.yml => services.yml.bak} (100%) diff --git a/adguardhome/compose.yaml b/adguardhome/compose.yaml index 0b38acd..ac2d4a7 100644 --- a/adguardhome/compose.yaml +++ b/adguardhome/compose.yaml @@ -19,10 +19,32 @@ services: labels: - "traefik.enable=true" - "traefik.docker.network=traefik-proxy" + + # Prod Router + - "traefik.http.routers.adguard.rule=Host(`adguard.forust.xyz`)" + - "traefik.http.routers.adguard.entrypoints=websecure" + - "traefik.http.routers.adguard.middlewares=security-headers" + - "traefik.http.routers.adguard.service=adguard" + - "traefik.http.routers.adguard.tls=true" + - "traefik.http.services.adguard.loadbalancer.server.port=3000" + + # Local Router + - "traefik.http.routers.adguard-local.rule=Host(`adguard.workstation.local`) || Host(`adguard.local`)" + - "traefik.http.routers.adguard-local.entrypoints=websecure" + - "traefik.http.routers.adguard-local.middlewares=security-headers" + - "traefik.http.routers.adguard-local.service=adguard" + - "traefik.http.routers.adguard-local.tls=true" + + # Dev Router + - "traefik.http.routers.adguard-dev.rule=Host(`adguard.gigaforust.local`)" + - "traefik.http.routers.adguard-dev.entrypoints=websecure" + - "traefik.http.routers.adguard-dev.middlewares=security-headers" + - "traefik.http.routers.adguard-dev.service=adguard" + - "traefik.http.routers.adguard-dev.tls=true" + - glance.name=adguard - # - glance.icon=si:adguard - glance.url=https://adguard.forust.xyz/ - glance.description=AdGuard Home is a network-wide software for blocking ads. networks: traefik-proxy: - external: true \ No newline at end of file + external: true diff --git a/dockmon/compose.yaml b/dockmon/compose.yaml index 6caae8d..85218bd 100644 --- a/dockmon/compose.yaml +++ b/dockmon/compose.yaml @@ -11,20 +11,44 @@ services: - ./data:/app/data - /var/run/docker.sock:/var/run/docker.sock healthcheck: - test: ["CMD", "curl", "-k", "-f", "https://localhost:443/health"] + test: [ "CMD", "curl", "-k", "-f", "https://localhost:443/health" ] interval: 30s timeout: 10s retries: 3 networks: - traefik-proxy labels: - - "traefik.enable=true" - - "traefik.docker.network=traefik-proxy" - - glance.name=dockmon - # - glance.icon=sh:dockmon - - glance.url=https://dockmon.forust.xyz/ - - glance.description=Dockmon is a lightweight Docker container monitoring and management tool with a user-friendly web interface. + - "traefik.enable=true" + - "traefik.docker.network=traefik-proxy" + + # Prod Router + - "traefik.http.routers.dockmon.rule=Host(`dockmon.forust.xyz`)" + - "traefik.http.routers.dockmon.entrypoints=websecure" + - "traefik.http.routers.dockmon.middlewares=security-headers,dockmon-auth" + - "traefik.http.routers.dockmon.service=dockmon" + - "traefik.http.routers.dockmon.tls=true" + - "traefik.http.services.dockmon.loadbalancer.server.port=443" + - "traefik.http.services.dockmon.loadbalancer.server.scheme=https" + - "traefik.http.services.dockmon.loadbalancer.serverstransport=insecureTransport@file" + + # Local Router + - "traefik.http.routers.dockmon-local.rule=Host(`dockmon.workstation.local`) || Host(`dockmon.local`)" + - "traefik.http.routers.dockmon-local.entrypoints=websecure" + - "traefik.http.routers.dockmon-local.middlewares=security-headers" + - "traefik.http.routers.dockmon-local.service=dockmon" + - "traefik.http.routers.dockmon-local.tls=true" + + # Dev Router + - "traefik.http.routers.dockmon-dev.rule=Host(`dockmon.gigaforust.local`)" + - "traefik.http.routers.dockmon-dev.entrypoints=websecure" + - "traefik.http.routers.dockmon-dev.middlewares=security-headers" + - "traefik.http.routers.dockmon-dev.service=dockmon" + - "traefik.http.routers.dockmon-dev.tls=true" + + - glance.name=dockmon + - glance.url=https://dockmon.forust.xyz/ + - glance.description=Dockmon is a lightweight Docker container monitoring and management tool with a user-friendly web interface. networks: - traefik-proxy: - external: true + traefik-proxy: + external: true diff --git a/gitea/compose.yaml b/gitea/compose.yaml index 376cda9..54d4db1 100644 --- a/gitea/compose.yaml +++ b/gitea/compose.yaml @@ -25,11 +25,33 @@ services: labels: - "traefik.enable=true" - "traefik.docker.network=traefik-proxy" + + # Prod Router + - "traefik.http.routers.gitea.rule=Host(`gitea.forust.xyz`)" + - "traefik.http.routers.gitea.entrypoints=websecure" + - "traefik.http.routers.gitea.middlewares=security-headers" + - "traefik.http.routers.gitea.service=gitea" + - "traefik.http.routers.gitea.tls=true" + - "traefik.http.services.gitea.loadbalancer.server.port=3000" + + # Local Router + - "traefik.http.routers.gitea-local.rule=Host(`gitea.workstation.local`) || Host(`gitea.local`)" + - "traefik.http.routers.gitea-local.entrypoints=websecure" + - "traefik.http.routers.gitea-local.middlewares=security-headers" + - "traefik.http.routers.gitea-local.service=gitea" + - "traefik.http.routers.gitea-local.tls=true" + + # Dev Router + - "traefik.http.routers.gitea-dev.rule=Host(`gitea.gigaforust.local`)" + - "traefik.http.routers.gitea-dev.entrypoints=websecure" + - "traefik.http.routers.gitea-dev.middlewares=security-headers" + - "traefik.http.routers.gitea-dev.service=gitea" + - "traefik.http.routers.gitea-dev.tls=true" ports: - "2221:22" depends_on: - db - + db: image: docker.io/library/postgres:14 restart: always @@ -41,7 +63,7 @@ services: - gitea-db volumes: - ./gitea-db/:/var/lib/postgresql/data - + networks: gitea-db: external: false diff --git a/glance/compose.yaml b/glance/compose.yaml index f393688..198c7a4 100644 --- a/glance/compose.yaml +++ b/glance/compose.yaml @@ -10,13 +10,31 @@ services: - /var/run/docker.sock:/var/run/docker.sock:ro env_file: .env labels: - - "traefik.enable=true" - - "traefik.docker.network=traefik-proxy" + - "traefik.enable=true" + - "traefik.docker.network=traefik-proxy" + + # Prod Router + - "traefik.http.routers.glance.rule=Host(`glance.forust.xyz`)" + - "traefik.http.routers.glance.entrypoints=websecure" + - "traefik.http.routers.glance.middlewares=security-headers" + - "traefik.http.routers.glance.tls=true" + + # Local Router + - "traefik.http.routers.glance-local.rule=Host(`glance.workstation.local`) || Host(`glance.local`)" + - "traefik.http.routers.glance-local.entrypoints=websecure" + - "traefik.http.routers.glance-local.middlewares=security-headers" + - "traefik.http.routers.glance-local.tls=true" + + # Dev Router + - "traefik.http.routers.glance-dev.rule=Host(`glance.gigaforust.local`)" + - "traefik.http.routers.glance-dev.entrypoints=websecure" + - "traefik.http.routers.glance-dev.middlewares=security-headers" + - "traefik.http.routers.glance-dev.tls=true" networks: - - traefik-proxy + - traefik-proxy dns: - 1.1.1.1 - 8.8.8.8 networks: traefik-proxy: - external: true \ No newline at end of file + external: true diff --git a/homepage/compose.yaml b/homepage/compose.yaml index dc5a182..83bc617 100644 --- a/homepage/compose.yaml +++ b/homepage/compose.yaml @@ -8,7 +8,32 @@ services: - ./files:/usr/share/nginx/html networks: - traefik-proxy + labels: + - "traefik.enable=true" + - "traefik.docker.network=traefik-proxy" + + # Prod Router + - "traefik.http.routers.forust-homepage.rule=Host(`forust.xyz`)" + - "traefik.http.routers.forust-homepage.entrypoints=websecure" + - "traefik.http.routers.forust-homepage.middlewares=security-headers" + - "traefik.http.routers.forust-homepage.service=forust-homepage" + - "traefik.http.routers.forust-homepage.tls=true" + - "traefik.http.services.forust-homepage.loadbalancer.server.port=80" + + # Local Router + - "traefik.http.routers.forust-homepage-local.rule=Host(`landing.workstation.local`) || Host(`landing.local`)" + - "traefik.http.routers.forust-homepage-local.entrypoints=websecure" + - "traefik.http.routers.forust-homepage-local.middlewares=security-headers" + - "traefik.http.routers.forust-homepage-local.service=forust-homepage" + - "traefik.http.routers.forust-homepage-local.tls=true" + + # Dev Router + - "traefik.http.routers.forust-homepage-dev.rule=Host(`landing.gigaforust.local`)" + - "traefik.http.routers.forust-homepage-dev.entrypoints=websecure" + - "traefik.http.routers.forust-homepage-dev.middlewares=security-headers" + - "traefik.http.routers.forust-homepage-dev.service=forust-homepage" + - "traefik.http.routers.forust-homepage-dev.tls=true" networks: traefik-proxy: - external: true \ No newline at end of file + external: true diff --git a/metube/compose.yaml b/metube/compose.yaml index 0d0d9b4..895c685 100644 --- a/metube/compose.yaml +++ b/metube/compose.yaml @@ -4,7 +4,7 @@ services: # container_name: metube restart: unless-stopped # ports: - # - "8081:8081" + # - "8081:8081" environment: - DOWNLOAD_MODE=limited - MAX_CONCURRENT_DOWNLOADS=3 @@ -16,8 +16,30 @@ services: - traefik.enable=true - "traefik.docker.network=traefik-proxy" + # Prod Router + - "traefik.http.routers.metube.rule=Host(`metube.forust.xyz`)" + - "traefik.http.routers.metube.entrypoints=websecure" + - "traefik.http.routers.metube.middlewares=security-headers,metube-auth" + - "traefik.http.routers.metube.service=metube" + - "traefik.http.routers.metube.tls=true" + - "traefik.http.services.metube.loadbalancer.server.port=8081" + + # Local Router + - "traefik.http.routers.metube-local.rule=Host(`metube.workstation.local`) || Host(`metube.local`)" + - "traefik.http.routers.metube-local.entrypoints=websecure" + - "traefik.http.routers.metube-local.middlewares=security-headers,metube-auth" + - "traefik.http.routers.metube-local.service=metube" + - "traefik.http.routers.metube-local.tls=true" + + # Dev Router + - "traefik.http.routers.metube-dev.rule=Host(`metube.gigaforust.local`)" + - "traefik.http.routers.metube-dev.entrypoints=websecure" + - "traefik.http.routers.metube-dev.middlewares=security-headers,metube-auth" + - "traefik.http.routers.metube-dev.service=metube" + - "traefik.http.routers.metube-dev.tls=true" + networks: - traefik-proxy networks: traefik-proxy: - external: true \ No newline at end of file + external: true diff --git a/n8n/compose.yaml b/n8n/compose.yaml index 390dbc8..9da0dc6 100644 --- a/n8n/compose.yaml +++ b/n8n/compose.yaml @@ -23,17 +23,40 @@ services: - 1.1.1.1 - 8.8.8.8 networks: - - traefik-proxy + - traefik-proxy - n8n labels: - - "traefik.enable=true" - - "traefik.docker.network=traefik-proxy" - - glance.name=n8n - - glance.icon=si:n8n - - glance.url=https://n8n.forust.xyz/ - - glance.description=n8n is a workflow automation tool that enables you to connect various apps and services to automate tasks and processes. + - "traefik.enable=true" + - "traefik.docker.network=traefik-proxy" + + # Prod Router + - "traefik.http.routers.n8n.rule=Host(`n8n.forust.xyz`)" + - "traefik.http.routers.n8n.entrypoints=websecure" + - "traefik.http.routers.n8n.middlewares=security-headers" + - "traefik.http.routers.n8n.service=n8n" + - "traefik.http.routers.n8n.tls=true" + - "traefik.http.services.n8n.loadbalancer.server.port=5678" + + # Local Router + - "traefik.http.routers.n8n-local.rule=Host(`n8n.workstation.local`) || Host(`n8n.local`)" + - "traefik.http.routers.n8n-local.entrypoints=websecure" + - "traefik.http.routers.n8n-local.middlewares=security-headers" + - "traefik.http.routers.n8n-local.service=n8n" + - "traefik.http.routers.n8n-local.tls=true" + + # Dev Router + - "traefik.http.routers.n8n-dev.rule=Host(`n8n.gigaforust.local`)" + - "traefik.http.routers.n8n-dev.entrypoints=websecure" + - "traefik.http.routers.n8n-dev.middlewares=security-headers" + - "traefik.http.routers.n8n-dev.service=n8n" + - "traefik.http.routers.n8n-dev.tls=true" + + - glance.name=n8n + - glance.icon=si:n8n + - glance.url=https://n8n.forust.xyz/ + - glance.description=n8n is a workflow automation tool that enables you to connect various apps and services to automate tasks and processes. networks: n8n: external: false traefik-proxy: - external: true \ No newline at end of file + external: true diff --git a/nextcloud/compose.yaml b/nextcloud/compose.yaml index 987c542..ad2d2c6 100644 --- a/nextcloud/compose.yaml +++ b/nextcloud/compose.yaml @@ -8,23 +8,48 @@ services: - nextcloud_aio_mastercontainer:/mnt/docker-aio-config # This line is not allowed to be changed as otherwise the built-in backup solution will not work - /var/run/docker.sock:/var/run/docker.sock:ro # May be changed on macOS, Windows or docker rootless. See the applicable documentation. If adjusting, don't forget to also set 'WATCHTOWER_DOCKER_SOCKET_PATH'! # network_mode: bridge # This adds the container to the same network as docker run would do. Comment this line and uncomment the line below and the networks section at the end of the file if you want to define a custom MTU size for the docker network - networks: + networks: - nextcloud-aio - traefik-proxy # Optional: Connects the mastercontainer to the traefik-proxy network in order to make the built-in reverse proxy detection work. - # ports: + # ports: # - 8081:80 # Can be removed when running behind a web server or reverse proxy (like Apache, Nginx, Caddy, Cloudflare Tunnel and else). See https://github.com/nextcloud/all-in-one/blob/main/reverse-proxy.md # - 8888:8080 # This is the AIO interface, served via https and self-signed certificate. See https://github.com/nextcloud/all-in-one#explanation-of-used-ports # - 8443:8443 # Can be removed when running behind a web server or reverse proxy (like Apache, Nginx, Caddy, Cloudflare Tunnel and else). See https://github.com/nextcloud/all-in-one/blob/main/reverse-proxy.md labels: - - "traefik.enable=true" - - "traefik.docker.network=traefik-proxy" - - glance.name=Nextcloud - # - glance.icon=si:nextcloud - - glance.url=https://nextcloud.forust.xyz/ - - glance.description=Nextcloud is a suite of client-server software for creating and using file hosting services. + - "traefik.enable=true" + - "traefik.docker.network=traefik-proxy" + # Prod Router - DISABLED per user request + # - "traefik.http.routers.nextcloud-aio.rule=Host(`nextcloud-aio.forust.xyz`)" + # - "traefik.http.routers.nextcloud-aio.entrypoints=websecure" + # - "traefik.http.routers.nextcloud-aio.middlewares=security-headers" + # - "traefik.http.routers.nextcloud-aio.service=nextcloud-aio" + # - "traefik.http.routers.nextcloud-aio.tls=true" + # - "traefik.http.services.nextcloud-aio.loadbalancer.server.port=8080" + # - "traefik.http.services.nextcloud-aio.loadbalancer.server.scheme=https" + # - "traefik.http.services.nextcloud-aio.loadbalancer.serverstransport=insecureTransport@file" - environment: # Is needed when using any of the options below + # Local Router + - "traefik.http.routers.nextcloud-aio-local.rule=Host(`nextcloud-aio.workstation.local`) || Host(`nextcloud-aio.local`)" + - "traefik.http.routers.nextcloud-aio-local.entrypoints=websecure" + - "traefik.http.routers.nextcloud-aio-local.middlewares=security-headers" + - "traefik.http.routers.nextcloud-aio-local.service=nextcloud-aio" + - "traefik.http.routers.nextcloud-aio-local.tls=true" + + # Dev Router + - "traefik.http.routers.nextcloud-aio-dev.rule=Host(`nextcloud-aio.gigaforust.local`)" + - "traefik.http.routers.nextcloud-aio-dev.entrypoints=websecure" + - "traefik.http.routers.nextcloud-aio-dev.middlewares=security-headers" + - "traefik.http.routers.nextcloud-aio-dev.service=nextcloud-aio" + - "traefik.http.routers.nextcloud-aio-dev.tls=true" + + - glance.name=Nextcloud + # - glance.icon=si:nextcloud + - glance.url=https://nextcloud.forust.xyz/ + - glance.description=Nextcloud is a suite of client-server software for creating and using file hosting services. + + environment: + # Is needed when using any of the options below AIO_DISABLE_BACKUP_SECTION: false # Setting this to true allows to hide the backup section in the AIO interface. See https://github.com/nextcloud/all-in-one#how-to-disable-the-backup-section APACHE_PORT: 11000 # Is needed when running behind a web server or reverse proxy (like Apache, Nginx, Caddy, Cloudflare Tunnel and else). See https://github.com/nextcloud/all-in-one/blob/main/reverse-proxy.md APACHE_IP_BINDING: 0.0.0.0 # Should be set when running behind a web server or reverse proxy (like Apache, Nginx, Caddy, Cloudflare Tunnel and else) that is running on the same host. See https://github.com/nextcloud/all-in-one/blob/main/reverse-proxy.md @@ -53,6 +78,7 @@ networks: nextcloud-aio: driver: bridge external: false -volumes: # If you want to store the data on a different drive, see https://github.com/nextcloud/all-in-one#how-to-store-the-filesinstallation-on-a-separate-drive +volumes: + # If you want to store the data on a different drive, see https://github.com/nextcloud/all-in-one#how-to-store-the-filesinstallation-on-a-separate-drive nextcloud_aio_mastercontainer: - name: nextcloud_aio_mastercontainer # This line is not allowed to be changed as otherwise the built-in backup solution will not work \ No newline at end of file + name: nextcloud_aio_mastercontainer # This line is not allowed to be changed as otherwise the built-in backup solution will not work diff --git a/penpot/compose.yaml b/penpot/compose.yaml index 9f5c1c4..3dde4be 100644 --- a/penpot/compose.yaml +++ b/penpot/compose.yaml @@ -26,12 +26,13 @@ x-flags: &penpot-flags PENPOT_FLAGS: disable-email-verification enable-prepl-server disable-secure-session-cookies login-with-github -x-uri: &penpot-public-uri +x-uri: &penpot-public-urш PENPOT_PUBLIC_URI: http://localhost:9001 -x-body-size: &penpot-http-body-size +x-body-size: # Max body size (30MiB); Used for plain requests, should never be # greater than multi-part size + &penpot-http-body-size PENPOT_HTTP_SERVER_MAX_BODY_SIZE: 31457280 # Max multipart body size (350MiB) @@ -56,7 +57,7 @@ volumes: penpot_postgres_v15: penpot_assets: penpot_traefik: - # penpot_minio: + # penpot_minio: services: ## Traefik service declaration example. Consider using it if you are going to expose @@ -99,19 +100,34 @@ services: - penpot - traefik-proxy - # labels: - # - "traefik.enable=true" + labels: + - "traefik.enable=true" + - "traefik.docker.network=traefik-proxy" - # ## HTTPS: example of labels for the case where penpot will be exposed to the - # ## internet with HTTPS using traefik. + # Prod Router + - "traefik.http.routers.penpot.rule=Host(`penpot.forust.xyz`)" + - "traefik.http.routers.penpot.entrypoints=websecure" + - "traefik.http.routers.penpot.middlewares=security-headers" + - "traefik.http.routers.penpot.service=penpot" + - "traefik.http.routers.penpot.tls=true" + - "traefik.http.services.penpot.loadbalancer.server.port=8080" - # - "traefik.http.routers.penpot-https.rule=Host(``)" - # - "traefik.http.routers.penpot-https.entrypoints=websecure" - # - "traefik.http.routers.penpot-https.tls.certresolver=letsencrypt" - # - "traefik.http.routers.penpot-https.tls=true" + # Local Router + - "traefik.http.routers.penpot-local.rule=Host(`penpot.workstation.local`) || Host(`penpot.local`)" + - "traefik.http.routers.penpot-local.entrypoints=websecure" + - "traefik.http.routers.penpot-local.middlewares=security-headers" + - "traefik.http.routers.penpot-local.service=penpot" + - "traefik.http.routers.penpot-local.tls=true" + + # Dev Router + - "traefik.http.routers.penpot-dev.rule=Host(`penpot.gigaforust.local`)" + - "traefik.http.routers.penpot-dev.entrypoints=websecure" + - "traefik.http.routers.penpot-dev.middlewares=security-headers" + - "traefik.http.routers.penpot-dev.service=penpot" + - "traefik.http.routers.penpot-dev.tls=true" environment: - << : [*penpot-flags, *penpot-http-body-size] + <<: [ *penpot-flags, *penpot-http-body-size ] penpot-backend: image: "penpotapp/backend:${PENPOT_VERSION:-latest}" @@ -132,7 +148,7 @@ services: ## Configuration envronment variables for the backend container. environment: - << : [*penpot-flags, *penpot-public-uri, *penpot-http-body-size, *penpot-secret-key] + <<: [ *penpot-flags, *penpot-public-uri, *penpot-http-body-size, *penpot-secret-key ] ## The PREPL host. Mainly used for external programatic access to penpot backend ## (example: admin). By default it will listen on `localhost` but if you are going to use @@ -174,7 +190,6 @@ services: PENPOT_TELEMETRY_ENABLED: true PENPOT_TELEMETRY_REFERER: compose - # PENPOT_SMTP_DEFAULT_FROM: no-reply@example.com # PENPOT_SMTP_DEFAULT_REPLY_TO: no-reply@example.com # PENPOT_SMTP_HOST: penpot-mailcatch @@ -196,7 +211,7 @@ services: - penpot environment: - << : [*penpot-secret-key] + <<: [ *penpot-secret-key ] # Don't touch it; this uses an internal docker network to # communicate with the frontend. PENPOT_PUBLIC_URI: http://penpot-frontend:8080 @@ -210,7 +225,7 @@ services: stop_signal: SIGINT healthcheck: - test: ["CMD-SHELL", "pg_isready -U penpot"] + test: [ "CMD-SHELL", "pg_isready -U penpot" ] interval: 2s timeout: 10s retries: 5 @@ -233,7 +248,7 @@ services: restart: always healthcheck: - test: ["CMD-SHELL", "valkey-cli ping | grep PONG"] + test: [ "CMD-SHELL", "valkey-cli ping | grep PONG" ] interval: 1s timeout: 3s retries: 5 @@ -246,7 +261,6 @@ services: # You can increase the max memory size if you have sufficient resources, # although this should not be necessary. - VALKEY_EXTRA_FLAGS=--maxmemory 128mb --maxmemory-policy volatile-lfu - ## A mailcatch service, used as temporal SMTP server. You can access via HTTP to the ## port 1080 for read all emails the penpot platform has sent. Should be only used as a ## temporal solution while no real SMTP provider is configured. diff --git a/portainer/compose.yaml b/portainer/compose.yaml index 8d255c5..9cc147b 100644 --- a/portainer/compose.yaml +++ b/portainer/compose.yaml @@ -14,8 +14,32 @@ services: labels: - "traefik.enable=true" - "traefik.docker.network=traefik-proxy" + + # Prod Router + - "traefik.http.routers.portainer.rule=Host(`portainer.forust.xyz`)" + - "traefik.http.routers.portainer.entrypoints=websecure" + - "traefik.http.routers.portainer.middlewares=security-headers" + - "traefik.http.routers.portainer.service=portainer" + - "traefik.http.routers.portainer.tls=true" + - "traefik.http.services.portainer.loadbalancer.server.port=9443" + - "traefik.http.services.portainer.loadbalancer.server.scheme=https" + - "traefik.http.services.portainer.loadbalancer.serverstransport=insecureTransport@file" + + # Local Router + - "traefik.http.routers.portainer-local.rule=Host(`portainer.workstation.local`) || Host(`portainer.local`)" + - "traefik.http.routers.portainer-local.entrypoints=websecure" + - "traefik.http.routers.portainer-local.middlewares=security-headers" + - "traefik.http.routers.portainer-local.service=portainer" + - "traefik.http.routers.portainer-local.tls=true" + + # Dev Router + - "traefik.http.routers.portainer-dev.rule=Host(`portainer.gigaforust.local`)" + - "traefik.http.routers.portainer-dev.entrypoints=websecure" + - "traefik.http.routers.portainer-dev.middlewares=security-headers" + - "traefik.http.routers.portainer-dev.service=portainer" + - "traefik.http.routers.portainer-dev.tls=true" + - glance.name=Portainer - # - glance.icon=si:portainer - glance.url=https://portainer.forust.xyz/ - glance.description=Portainer is a lightweight management UI which allows you to easily manage your Docker environments. diff --git a/termix/compose.yaml b/termix/compose.yaml index 241fa19..03d39a6 100644 --- a/termix/compose.yaml +++ b/termix/compose.yaml @@ -9,6 +9,31 @@ services: - ./termix-data:/app/data environment: PORT: "8080" + labels: + - "traefik.enable=true" + - "traefik.docker.network=traefik-proxy" + + # Prod Router + - "traefik.http.routers.termix.rule=Host(`termix.forust.xyz`)" + - "traefik.http.routers.termix.entrypoints=websecure" + - "traefik.http.routers.termix.middlewares=security-headers" + - "traefik.http.routers.termix.service=termix" + - "traefik.http.routers.termix.tls=true" + - "traefik.http.services.termix.loadbalancer.server.port=8080" + + # Local Router + - "traefik.http.routers.termix-local.rule=Host(`termix.workstation.local`) || Host(`termix.local`)" + - "traefik.http.routers.termix-local.entrypoints=websecure" + - "traefik.http.routers.termix-local.middlewares=security-headers" + - "traefik.http.routers.termix-local.service=termix" + - "traefik.http.routers.termix-local.tls=true" + + # Dev Router + - "traefik.http.routers.termix-dev.rule=Host(`termix.gigaforust.local`)" + - "traefik.http.routers.termix-dev.entrypoints=websecure" + - "traefik.http.routers.termix-dev.middlewares=security-headers" + - "traefik.http.routers.termix-dev.service=termix" + - "traefik.http.routers.termix-dev.tls=true" networks: - traefik-proxy @@ -18,4 +43,4 @@ networks: volumes: termix-data: - driver: local \ No newline at end of file + driver: local diff --git a/traefik/compose.yaml b/traefik/compose.yaml index 3dcb7a9..884460b 100644 --- a/traefik/compose.yaml +++ b/traefik/compose.yaml @@ -7,14 +7,14 @@ services: # API - "--api.insecure=false" - "--api.dashboard=true" - + # Providers - "--providers.docker=true" - "--providers.docker.exposedbydefault=false" - "--providers.docker.network=traefik-proxy" - "--providers.file.directory=/etc/traefik/dynamic" - "--providers.file.watch=true" - + # EntryPoints - "--entryPoints.web.address=:80" - "--entryPoints.websecure.address=:443" @@ -22,48 +22,69 @@ services: - "--entryPoints.web.http.redirections.entryPoint.to=websecure" - "--entryPoints.web.http.redirections.entryPoint.scheme=https" - "--entryPoints.ssh.address=:2221" - + # Let's Encrypt STAGING. CURRENTLY USING CF ORIGIN CA INSTEAD # - "--certificatesresolvers.letsencrypt.acme.email=${EMAIL}" # - "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json" # - "--certificatesresolvers.letsencrypt.acme.caserver=https://acme-staging-v02.api.letsencrypt.org/directory" # - "--certificatesresolvers.letsencrypt.acme.httpchallenge=true" # - "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web" - + # Cloudflare - "--entryPoints.web.forwardedHeaders.trustedIPs=173.245.48.0/20,103.21.244.0/22,103.22.200.0/22,103.31.4.0/22,141.101.64.0/18,108.162.192.0/18,190.93.240.0/20,188.114.96.0/20,197.234.240.0/22,198.41.128.0/17,162.158.0.0/15,104.16.0.0/13,104.24.0.0/14,172.64.0.0/13,131.0.72.0/22" - "--entryPoints.websecure.forwardedHeaders.trustedIPs=173.245.48.0/20,103.21.244.0/22,103.22.200.0/22,103.31.4.0/22,141.101.64.0/18,108.162.192.0/18,190.93.240.0/20,188.114.96.0/20,197.234.240.0/22,198.41.128.0/17,162.158.0.0/15,104.16.0.0/13,104.24.0.0/14,172.64.0.0/13,131.0.72.0/22" - + # Logging - "--log.level=INFO" - "--log.filePath=/var/log/traefik/traefik.log" - "--accesslog=true" - "--accesslog.filepath=/var/log/traefik/access.log" - - labels: + - "traefik.enable=true" - "traefik.docker.network=traefik-proxy" + + # Prod Router (Dashboard) - DISABLED per user request + # - "traefik.http.routers.traefik-dashboard.rule=Host(`traefik.forust.xyz`)" + # - "traefik.http.routers.traefik-dashboard.entrypoints=websecure" + # - "traefik.http.routers.traefik-dashboard.middlewares=auth,security-headers" + # - "traefik.http.routers.traefik-dashboard.service=api@internal" + # - "traefik.http.routers.traefik-dashboard.tls=true" + + # Local Router + - "traefik.http.routers.traefik-dashboard-local.rule=Host(`traefik.workstation.local`) || Host(`traefik.local`)" + - "traefik.http.routers.traefik-dashboard-local.entrypoints=websecure" + - "traefik.http.routers.traefik-dashboard-local.middlewares=auth,security-headers" + - "traefik.http.routers.traefik-dashboard-local.service=api@internal" + - "traefik.http.routers.traefik-dashboard-local.tls=true" + + # Dev Router + - "traefik.http.routers.traefik-dashboard-dev.rule=Host(`traefik.gigaforust.local`)" + - "traefik.http.routers.traefik-dashboard-dev.entrypoints=websecure" + - "traefik.http.routers.traefik-dashboard-dev.middlewares=security-headers" + - "traefik.http.routers.traefik-dashboard-dev.service=api@internal" + - "traefik.http.routers.traefik-dashboard-dev.tls=true" + - glance.name=Traefik - glance.url=https://traefik.forust.xyz/ - glance.description=Traefik is a modern reverse proxy and load balancer - + ports: - "80:80" - "443:443" - + volumes: - /var/run/docker.sock:/var/run/docker.sock:ro - ./dynamic:/etc/traefik/dynamic:ro - ./certs:/certs:ro - ./logs:/var/log/traefik # - ./traefik/letsencrypt:/letsencrypt - + networks: - traefik-proxy - + environment: - TZ=Europe/Bratislava networks: traefik-proxy: - external: true \ No newline at end of file + external: true diff --git a/traefik/dynamic/routers-dev.yml b/traefik/dynamic/routers-dev.yml.bak similarity index 100% rename from traefik/dynamic/routers-dev.yml rename to traefik/dynamic/routers-dev.yml.bak diff --git a/traefik/dynamic/routers-local.yml b/traefik/dynamic/routers-local.yml.bak similarity index 100% rename from traefik/dynamic/routers-local.yml rename to traefik/dynamic/routers-local.yml.bak diff --git a/traefik/dynamic/routers-prod.yml b/traefik/dynamic/routers-prod.yml.bak similarity index 100% rename from traefik/dynamic/routers-prod.yml rename to traefik/dynamic/routers-prod.yml.bak diff --git a/traefik/dynamic/services.yml b/traefik/dynamic/services.yml.bak similarity index 100% rename from traefik/dynamic/services.yml rename to traefik/dynamic/services.yml.bak diff --git a/uptime-kuma/compose.yaml b/uptime-kuma/compose.yaml index d9d97fe..1f834ba 100644 --- a/uptime-kuma/compose.yaml +++ b/uptime-kuma/compose.yaml @@ -9,10 +9,26 @@ services: # : - "3001:3001" labels: - - "traefik.enable=true" + - "traefik.enable=true" + - "traefik.docker.network=traefik-proxy" + + # Prod Router + - "traefik.http.routers.uptime-kuma.rule=Host(`uptime.forust.xyz`)" + - "traefik.http.routers.uptime-kuma.entrypoints=websecure" + - "traefik.http.routers.uptime-kuma.tls=true" + - "traefik.http.services.uptime-kuma.loadbalancer.server.port=3001" + + # Local Router + - "traefik.http.routers.uptime-kuma-local.rule=Host(`uptime.workstation.local`) || Host(`uptime.local`)" + - "traefik.http.routers.uptime-kuma-local.entrypoints=websecure" + - "traefik.http.routers.uptime-kuma-local.tls=true" + + # Dev Router + - "traefik.http.routers.uptime-kuma-dev.rule=Host(`uptime.gigaforust.local`)" + - "traefik.http.routers.uptime-kuma-dev.entrypoints=websecure" + - "traefik.http.routers.uptime-kuma-dev.tls=true" networks: - traefik-proxy networks: traefik-proxy: - external: true - \ No newline at end of file + external: true From e68e37c285fedd11b6f9b0350e71f615d20c5ec6 Mon Sep 17 00:00:00 2001 From: mr-forust Date: Fri, 5 Dec 2025 04:30:51 +0100 Subject: [PATCH 23/89] refactor: use internal tld according to ICAAN --- adguardhome/compose.yaml | 4 ++-- dockmon/compose.yaml | 4 ++-- gitea/compose.yaml | 4 ++-- glance/compose.yaml | 4 ++-- homepage/compose.yaml | 4 ++-- metube/compose.yaml | 4 ++-- n8n/compose.yaml | 4 ++-- nextcloud/compose.yaml | 4 ++-- penpot/compose.yaml | 4 ++-- portainer/compose.yaml | 4 ++-- termix/compose.yaml | 4 ++-- traefik/compose.yaml | 4 ++-- uptime-kuma/compose.yaml | 4 ++-- 13 files changed, 26 insertions(+), 26 deletions(-) diff --git a/adguardhome/compose.yaml b/adguardhome/compose.yaml index ac2d4a7..0ca5cfc 100644 --- a/adguardhome/compose.yaml +++ b/adguardhome/compose.yaml @@ -29,14 +29,14 @@ services: - "traefik.http.services.adguard.loadbalancer.server.port=3000" # Local Router - - "traefik.http.routers.adguard-local.rule=Host(`adguard.workstation.local`) || Host(`adguard.local`)" + - "traefik.http.routers.adguard-local.rule=Host(`adguard.workstation.internal`) || Host(`adguard.internal`)" - "traefik.http.routers.adguard-local.entrypoints=websecure" - "traefik.http.routers.adguard-local.middlewares=security-headers" - "traefik.http.routers.adguard-local.service=adguard" - "traefik.http.routers.adguard-local.tls=true" # Dev Router - - "traefik.http.routers.adguard-dev.rule=Host(`adguard.gigaforust.local`)" + - "traefik.http.routers.adguard-dev.rule=Host(`adguard.gigaforust.internal`)" - "traefik.http.routers.adguard-dev.entrypoints=websecure" - "traefik.http.routers.adguard-dev.middlewares=security-headers" - "traefik.http.routers.adguard-dev.service=adguard" diff --git a/dockmon/compose.yaml b/dockmon/compose.yaml index 85218bd..3f5bf60 100644 --- a/dockmon/compose.yaml +++ b/dockmon/compose.yaml @@ -32,14 +32,14 @@ services: - "traefik.http.services.dockmon.loadbalancer.serverstransport=insecureTransport@file" # Local Router - - "traefik.http.routers.dockmon-local.rule=Host(`dockmon.workstation.local`) || Host(`dockmon.local`)" + - "traefik.http.routers.dockmon-local.rule=Host(`dockmon.workstation.internal`) || Host(`dockmon.internal`)" - "traefik.http.routers.dockmon-local.entrypoints=websecure" - "traefik.http.routers.dockmon-local.middlewares=security-headers" - "traefik.http.routers.dockmon-local.service=dockmon" - "traefik.http.routers.dockmon-local.tls=true" # Dev Router - - "traefik.http.routers.dockmon-dev.rule=Host(`dockmon.gigaforust.local`)" + - "traefik.http.routers.dockmon-dev.rule=Host(`dockmon.gigaforust.internal`)" - "traefik.http.routers.dockmon-dev.entrypoints=websecure" - "traefik.http.routers.dockmon-dev.middlewares=security-headers" - "traefik.http.routers.dockmon-dev.service=dockmon" diff --git a/gitea/compose.yaml b/gitea/compose.yaml index 54d4db1..b2314ca 100644 --- a/gitea/compose.yaml +++ b/gitea/compose.yaml @@ -35,14 +35,14 @@ services: - "traefik.http.services.gitea.loadbalancer.server.port=3000" # Local Router - - "traefik.http.routers.gitea-local.rule=Host(`gitea.workstation.local`) || Host(`gitea.local`)" + - "traefik.http.routers.gitea-local.rule=Host(`gitea.workstation.internal`) || Host(`gitea.internal`)" - "traefik.http.routers.gitea-local.entrypoints=websecure" - "traefik.http.routers.gitea-local.middlewares=security-headers" - "traefik.http.routers.gitea-local.service=gitea" - "traefik.http.routers.gitea-local.tls=true" # Dev Router - - "traefik.http.routers.gitea-dev.rule=Host(`gitea.gigaforust.local`)" + - "traefik.http.routers.gitea-dev.rule=Host(`gitea.gigaforust.internal`)" - "traefik.http.routers.gitea-dev.entrypoints=websecure" - "traefik.http.routers.gitea-dev.middlewares=security-headers" - "traefik.http.routers.gitea-dev.service=gitea" diff --git a/glance/compose.yaml b/glance/compose.yaml index 198c7a4..82f5267 100644 --- a/glance/compose.yaml +++ b/glance/compose.yaml @@ -20,13 +20,13 @@ services: - "traefik.http.routers.glance.tls=true" # Local Router - - "traefik.http.routers.glance-local.rule=Host(`glance.workstation.local`) || Host(`glance.local`)" + - "traefik.http.routers.glance-local.rule=Host(`glance.workstation.internal`) || Host(`glance.internal`)" - "traefik.http.routers.glance-local.entrypoints=websecure" - "traefik.http.routers.glance-local.middlewares=security-headers" - "traefik.http.routers.glance-local.tls=true" # Dev Router - - "traefik.http.routers.glance-dev.rule=Host(`glance.gigaforust.local`)" + - "traefik.http.routers.glance-dev.rule=Host(`glance.gigaforust.internal`)" - "traefik.http.routers.glance-dev.entrypoints=websecure" - "traefik.http.routers.glance-dev.middlewares=security-headers" - "traefik.http.routers.glance-dev.tls=true" diff --git a/homepage/compose.yaml b/homepage/compose.yaml index 83bc617..bb36bed 100644 --- a/homepage/compose.yaml +++ b/homepage/compose.yaml @@ -21,14 +21,14 @@ services: - "traefik.http.services.forust-homepage.loadbalancer.server.port=80" # Local Router - - "traefik.http.routers.forust-homepage-local.rule=Host(`landing.workstation.local`) || Host(`landing.local`)" + - "traefik.http.routers.forust-homepage-local.rule=Host(`landing.workstation.internal`) || Host(`landing.internal`)" - "traefik.http.routers.forust-homepage-local.entrypoints=websecure" - "traefik.http.routers.forust-homepage-local.middlewares=security-headers" - "traefik.http.routers.forust-homepage-local.service=forust-homepage" - "traefik.http.routers.forust-homepage-local.tls=true" # Dev Router - - "traefik.http.routers.forust-homepage-dev.rule=Host(`landing.gigaforust.local`)" + - "traefik.http.routers.forust-homepage-dev.rule=Host(`landing.gigaforust.internal`)" - "traefik.http.routers.forust-homepage-dev.entrypoints=websecure" - "traefik.http.routers.forust-homepage-dev.middlewares=security-headers" - "traefik.http.routers.forust-homepage-dev.service=forust-homepage" diff --git a/metube/compose.yaml b/metube/compose.yaml index 895c685..950baa0 100644 --- a/metube/compose.yaml +++ b/metube/compose.yaml @@ -25,14 +25,14 @@ services: - "traefik.http.services.metube.loadbalancer.server.port=8081" # Local Router - - "traefik.http.routers.metube-local.rule=Host(`metube.workstation.local`) || Host(`metube.local`)" + - "traefik.http.routers.metube-local.rule=Host(`metube.workstation.internal`) || Host(`metube.internal`)" - "traefik.http.routers.metube-local.entrypoints=websecure" - "traefik.http.routers.metube-local.middlewares=security-headers,metube-auth" - "traefik.http.routers.metube-local.service=metube" - "traefik.http.routers.metube-local.tls=true" # Dev Router - - "traefik.http.routers.metube-dev.rule=Host(`metube.gigaforust.local`)" + - "traefik.http.routers.metube-dev.rule=Host(`metube.gigaforust.internal`)" - "traefik.http.routers.metube-dev.entrypoints=websecure" - "traefik.http.routers.metube-dev.middlewares=security-headers,metube-auth" - "traefik.http.routers.metube-dev.service=metube" diff --git a/n8n/compose.yaml b/n8n/compose.yaml index 9da0dc6..32500e3 100644 --- a/n8n/compose.yaml +++ b/n8n/compose.yaml @@ -38,14 +38,14 @@ services: - "traefik.http.services.n8n.loadbalancer.server.port=5678" # Local Router - - "traefik.http.routers.n8n-local.rule=Host(`n8n.workstation.local`) || Host(`n8n.local`)" + - "traefik.http.routers.n8n-local.rule=Host(`n8n.workstation.internal`) || Host(`n8n.internal`)" - "traefik.http.routers.n8n-local.entrypoints=websecure" - "traefik.http.routers.n8n-local.middlewares=security-headers" - "traefik.http.routers.n8n-local.service=n8n" - "traefik.http.routers.n8n-local.tls=true" # Dev Router - - "traefik.http.routers.n8n-dev.rule=Host(`n8n.gigaforust.local`)" + - "traefik.http.routers.n8n-dev.rule=Host(`n8n.gigaforust.internal`)" - "traefik.http.routers.n8n-dev.entrypoints=websecure" - "traefik.http.routers.n8n-dev.middlewares=security-headers" - "traefik.http.routers.n8n-dev.service=n8n" diff --git a/nextcloud/compose.yaml b/nextcloud/compose.yaml index ad2d2c6..196410e 100644 --- a/nextcloud/compose.yaml +++ b/nextcloud/compose.yaml @@ -30,14 +30,14 @@ services: # - "traefik.http.services.nextcloud-aio.loadbalancer.serverstransport=insecureTransport@file" # Local Router - - "traefik.http.routers.nextcloud-aio-local.rule=Host(`nextcloud-aio.workstation.local`) || Host(`nextcloud-aio.local`)" + - "traefik.http.routers.nextcloud-aio-local.rule=Host(`nextcloud-aio.workstation.internal`) || Host(`nextcloud-aio.internal`)" - "traefik.http.routers.nextcloud-aio-local.entrypoints=websecure" - "traefik.http.routers.nextcloud-aio-local.middlewares=security-headers" - "traefik.http.routers.nextcloud-aio-local.service=nextcloud-aio" - "traefik.http.routers.nextcloud-aio-local.tls=true" # Dev Router - - "traefik.http.routers.nextcloud-aio-dev.rule=Host(`nextcloud-aio.gigaforust.local`)" + - "traefik.http.routers.nextcloud-aio-dev.rule=Host(`nextcloud-aio.gigaforust.internal`)" - "traefik.http.routers.nextcloud-aio-dev.entrypoints=websecure" - "traefik.http.routers.nextcloud-aio-dev.middlewares=security-headers" - "traefik.http.routers.nextcloud-aio-dev.service=nextcloud-aio" diff --git a/penpot/compose.yaml b/penpot/compose.yaml index 3dde4be..5d8eba4 100644 --- a/penpot/compose.yaml +++ b/penpot/compose.yaml @@ -113,14 +113,14 @@ services: - "traefik.http.services.penpot.loadbalancer.server.port=8080" # Local Router - - "traefik.http.routers.penpot-local.rule=Host(`penpot.workstation.local`) || Host(`penpot.local`)" + - "traefik.http.routers.penpot-local.rule=Host(`penpot.workstation.internal`) || Host(`penpot.internal`)" - "traefik.http.routers.penpot-local.entrypoints=websecure" - "traefik.http.routers.penpot-local.middlewares=security-headers" - "traefik.http.routers.penpot-local.service=penpot" - "traefik.http.routers.penpot-local.tls=true" # Dev Router - - "traefik.http.routers.penpot-dev.rule=Host(`penpot.gigaforust.local`)" + - "traefik.http.routers.penpot-dev.rule=Host(`penpot.gigaforust.internal`)" - "traefik.http.routers.penpot-dev.entrypoints=websecure" - "traefik.http.routers.penpot-dev.middlewares=security-headers" - "traefik.http.routers.penpot-dev.service=penpot" diff --git a/portainer/compose.yaml b/portainer/compose.yaml index 9cc147b..26e6f19 100644 --- a/portainer/compose.yaml +++ b/portainer/compose.yaml @@ -26,14 +26,14 @@ services: - "traefik.http.services.portainer.loadbalancer.serverstransport=insecureTransport@file" # Local Router - - "traefik.http.routers.portainer-local.rule=Host(`portainer.workstation.local`) || Host(`portainer.local`)" + - "traefik.http.routers.portainer-local.rule=Host(`portainer.workstation.internal`) || Host(`portainer.internal`)" - "traefik.http.routers.portainer-local.entrypoints=websecure" - "traefik.http.routers.portainer-local.middlewares=security-headers" - "traefik.http.routers.portainer-local.service=portainer" - "traefik.http.routers.portainer-local.tls=true" # Dev Router - - "traefik.http.routers.portainer-dev.rule=Host(`portainer.gigaforust.local`)" + - "traefik.http.routers.portainer-dev.rule=Host(`portainer.gigaforust.internal`)" - "traefik.http.routers.portainer-dev.entrypoints=websecure" - "traefik.http.routers.portainer-dev.middlewares=security-headers" - "traefik.http.routers.portainer-dev.service=portainer" diff --git a/termix/compose.yaml b/termix/compose.yaml index 03d39a6..955ada5 100644 --- a/termix/compose.yaml +++ b/termix/compose.yaml @@ -22,14 +22,14 @@ services: - "traefik.http.services.termix.loadbalancer.server.port=8080" # Local Router - - "traefik.http.routers.termix-local.rule=Host(`termix.workstation.local`) || Host(`termix.local`)" + - "traefik.http.routers.termix-local.rule=Host(`termix.workstation.internal`) || Host(`termix.internal`)" - "traefik.http.routers.termix-local.entrypoints=websecure" - "traefik.http.routers.termix-local.middlewares=security-headers" - "traefik.http.routers.termix-local.service=termix" - "traefik.http.routers.termix-local.tls=true" # Dev Router - - "traefik.http.routers.termix-dev.rule=Host(`termix.gigaforust.local`)" + - "traefik.http.routers.termix-dev.rule=Host(`termix.gigaforust.internal`)" - "traefik.http.routers.termix-dev.entrypoints=websecure" - "traefik.http.routers.termix-dev.middlewares=security-headers" - "traefik.http.routers.termix-dev.service=termix" diff --git a/traefik/compose.yaml b/traefik/compose.yaml index 884460b..53b0741 100644 --- a/traefik/compose.yaml +++ b/traefik/compose.yaml @@ -51,14 +51,14 @@ services: # - "traefik.http.routers.traefik-dashboard.tls=true" # Local Router - - "traefik.http.routers.traefik-dashboard-local.rule=Host(`traefik.workstation.local`) || Host(`traefik.local`)" + - "traefik.http.routers.traefik-dashboard-local.rule=Host(`traefik.workstation.internal`) || Host(`traefik.internal`)" - "traefik.http.routers.traefik-dashboard-local.entrypoints=websecure" - "traefik.http.routers.traefik-dashboard-local.middlewares=auth,security-headers" - "traefik.http.routers.traefik-dashboard-local.service=api@internal" - "traefik.http.routers.traefik-dashboard-local.tls=true" # Dev Router - - "traefik.http.routers.traefik-dashboard-dev.rule=Host(`traefik.gigaforust.local`)" + - "traefik.http.routers.traefik-dashboard-dev.rule=Host(`traefik.gigaforust.internal`)" - "traefik.http.routers.traefik-dashboard-dev.entrypoints=websecure" - "traefik.http.routers.traefik-dashboard-dev.middlewares=security-headers" - "traefik.http.routers.traefik-dashboard-dev.service=api@internal" diff --git a/uptime-kuma/compose.yaml b/uptime-kuma/compose.yaml index 1f834ba..699ba79 100644 --- a/uptime-kuma/compose.yaml +++ b/uptime-kuma/compose.yaml @@ -19,12 +19,12 @@ services: - "traefik.http.services.uptime-kuma.loadbalancer.server.port=3001" # Local Router - - "traefik.http.routers.uptime-kuma-local.rule=Host(`uptime.workstation.local`) || Host(`uptime.local`)" + - "traefik.http.routers.uptime-kuma-local.rule=Host(`uptime.workstation.internal`) || Host(`uptime.internal`)" - "traefik.http.routers.uptime-kuma-local.entrypoints=websecure" - "traefik.http.routers.uptime-kuma-local.tls=true" # Dev Router - - "traefik.http.routers.uptime-kuma-dev.rule=Host(`uptime.gigaforust.local`)" + - "traefik.http.routers.uptime-kuma-dev.rule=Host(`uptime.gigaforust.internal`)" - "traefik.http.routers.uptime-kuma-dev.entrypoints=websecure" - "traefik.http.routers.uptime-kuma-dev.tls=true" networks: From a767107277bfdad90756d9c110bebc47f71cf10a Mon Sep 17 00:00:00 2001 From: mr-forust Date: Fri, 5 Dec 2025 14:36:57 +0100 Subject: [PATCH 24/89] fix: refering to file-defined middlewares --- adguardhome/compose.yaml | 6 +++--- dockmon/compose.yaml | 6 +++--- gitea/compose.yaml | 6 +++--- glance/compose.yaml | 8 ++++---- homepage/compose.yaml | 6 +++--- metube/compose.yaml | 6 +++--- n8n/compose.yaml | 6 +++--- nextcloud/compose.yaml | 4 ++-- penpot/compose.yaml | 6 +++--- portainer/compose.yaml | 6 +++--- termix/compose.yaml | 6 +++--- traefik/compose.yaml | 11 ++++++----- traefik/dynamic/tls.yml | 15 ++++++--------- 13 files changed, 45 insertions(+), 47 deletions(-) diff --git a/adguardhome/compose.yaml b/adguardhome/compose.yaml index 0ca5cfc..bbcd151 100644 --- a/adguardhome/compose.yaml +++ b/adguardhome/compose.yaml @@ -23,7 +23,7 @@ services: # Prod Router - "traefik.http.routers.adguard.rule=Host(`adguard.forust.xyz`)" - "traefik.http.routers.adguard.entrypoints=websecure" - - "traefik.http.routers.adguard.middlewares=security-headers" + - "traefik.http.routers.adguard.middlewares=security-headers@file" - "traefik.http.routers.adguard.service=adguard" - "traefik.http.routers.adguard.tls=true" - "traefik.http.services.adguard.loadbalancer.server.port=3000" @@ -31,14 +31,14 @@ services: # Local Router - "traefik.http.routers.adguard-local.rule=Host(`adguard.workstation.internal`) || Host(`adguard.internal`)" - "traefik.http.routers.adguard-local.entrypoints=websecure" - - "traefik.http.routers.adguard-local.middlewares=security-headers" + - "traefik.http.routers.adguard-local.middlewares=security-headers@file" - "traefik.http.routers.adguard-local.service=adguard" - "traefik.http.routers.adguard-local.tls=true" # Dev Router - "traefik.http.routers.adguard-dev.rule=Host(`adguard.gigaforust.internal`)" - "traefik.http.routers.adguard-dev.entrypoints=websecure" - - "traefik.http.routers.adguard-dev.middlewares=security-headers" + - "traefik.http.routers.adguard-dev.middlewares=security-headers@file" - "traefik.http.routers.adguard-dev.service=adguard" - "traefik.http.routers.adguard-dev.tls=true" diff --git a/dockmon/compose.yaml b/dockmon/compose.yaml index 3f5bf60..ba2b458 100644 --- a/dockmon/compose.yaml +++ b/dockmon/compose.yaml @@ -24,7 +24,7 @@ services: # Prod Router - "traefik.http.routers.dockmon.rule=Host(`dockmon.forust.xyz`)" - "traefik.http.routers.dockmon.entrypoints=websecure" - - "traefik.http.routers.dockmon.middlewares=security-headers,dockmon-auth" + - "traefik.http.routers.dockmon.middlewares=security-headers@file,dockmon-auth@file" - "traefik.http.routers.dockmon.service=dockmon" - "traefik.http.routers.dockmon.tls=true" - "traefik.http.services.dockmon.loadbalancer.server.port=443" @@ -34,14 +34,14 @@ services: # Local Router - "traefik.http.routers.dockmon-local.rule=Host(`dockmon.workstation.internal`) || Host(`dockmon.internal`)" - "traefik.http.routers.dockmon-local.entrypoints=websecure" - - "traefik.http.routers.dockmon-local.middlewares=security-headers" + - "traefik.http.routers.dockmon-local.middlewares=security-headers@file" - "traefik.http.routers.dockmon-local.service=dockmon" - "traefik.http.routers.dockmon-local.tls=true" # Dev Router - "traefik.http.routers.dockmon-dev.rule=Host(`dockmon.gigaforust.internal`)" - "traefik.http.routers.dockmon-dev.entrypoints=websecure" - - "traefik.http.routers.dockmon-dev.middlewares=security-headers" + - "traefik.http.routers.dockmon-dev.middlewares=security-headers@file" - "traefik.http.routers.dockmon-dev.service=dockmon" - "traefik.http.routers.dockmon-dev.tls=true" diff --git a/gitea/compose.yaml b/gitea/compose.yaml index b2314ca..2e0c67b 100644 --- a/gitea/compose.yaml +++ b/gitea/compose.yaml @@ -29,7 +29,7 @@ services: # Prod Router - "traefik.http.routers.gitea.rule=Host(`gitea.forust.xyz`)" - "traefik.http.routers.gitea.entrypoints=websecure" - - "traefik.http.routers.gitea.middlewares=security-headers" + - "traefik.http.routers.gitea.middlewares=security-headers@file" - "traefik.http.routers.gitea.service=gitea" - "traefik.http.routers.gitea.tls=true" - "traefik.http.services.gitea.loadbalancer.server.port=3000" @@ -37,14 +37,14 @@ services: # Local Router - "traefik.http.routers.gitea-local.rule=Host(`gitea.workstation.internal`) || Host(`gitea.internal`)" - "traefik.http.routers.gitea-local.entrypoints=websecure" - - "traefik.http.routers.gitea-local.middlewares=security-headers" + - "traefik.http.routers.gitea-local.middlewares=security-headers@file" - "traefik.http.routers.gitea-local.service=gitea" - "traefik.http.routers.gitea-local.tls=true" # Dev Router - "traefik.http.routers.gitea-dev.rule=Host(`gitea.gigaforust.internal`)" - "traefik.http.routers.gitea-dev.entrypoints=websecure" - - "traefik.http.routers.gitea-dev.middlewares=security-headers" + - "traefik.http.routers.gitea-dev.middlewares=security-headers@file" - "traefik.http.routers.gitea-dev.service=gitea" - "traefik.http.routers.gitea-dev.tls=true" ports: diff --git a/glance/compose.yaml b/glance/compose.yaml index 82f5267..6a06f66 100644 --- a/glance/compose.yaml +++ b/glance/compose.yaml @@ -16,19 +16,19 @@ services: # Prod Router - "traefik.http.routers.glance.rule=Host(`glance.forust.xyz`)" - "traefik.http.routers.glance.entrypoints=websecure" - - "traefik.http.routers.glance.middlewares=security-headers" + - "traefik.http.routers.glance.middlewares=security-headers@file" - "traefik.http.routers.glance.tls=true" # Local Router - "traefik.http.routers.glance-local.rule=Host(`glance.workstation.internal`) || Host(`glance.internal`)" - "traefik.http.routers.glance-local.entrypoints=websecure" - - "traefik.http.routers.glance-local.middlewares=security-headers" + - "traefik.http.routers.glance-local.middlewares=security-headers@file" - "traefik.http.routers.glance-local.tls=true" # Dev Router - "traefik.http.routers.glance-dev.rule=Host(`glance.gigaforust.internal`)" - "traefik.http.routers.glance-dev.entrypoints=websecure" - - "traefik.http.routers.glance-dev.middlewares=security-headers" + - "traefik.http.routers.glance-dev.middlewares=security-headers@file" - "traefik.http.routers.glance-dev.tls=true" networks: - traefik-proxy @@ -37,4 +37,4 @@ services: - 8.8.8.8 networks: traefik-proxy: - external: true + external: true \ No newline at end of file diff --git a/homepage/compose.yaml b/homepage/compose.yaml index bb36bed..453fe6a 100644 --- a/homepage/compose.yaml +++ b/homepage/compose.yaml @@ -15,7 +15,7 @@ services: # Prod Router - "traefik.http.routers.forust-homepage.rule=Host(`forust.xyz`)" - "traefik.http.routers.forust-homepage.entrypoints=websecure" - - "traefik.http.routers.forust-homepage.middlewares=security-headers" + - "traefik.http.routers.forust-homepage.middlewares=security-headers@file" - "traefik.http.routers.forust-homepage.service=forust-homepage" - "traefik.http.routers.forust-homepage.tls=true" - "traefik.http.services.forust-homepage.loadbalancer.server.port=80" @@ -23,14 +23,14 @@ services: # Local Router - "traefik.http.routers.forust-homepage-local.rule=Host(`landing.workstation.internal`) || Host(`landing.internal`)" - "traefik.http.routers.forust-homepage-local.entrypoints=websecure" - - "traefik.http.routers.forust-homepage-local.middlewares=security-headers" + - "traefik.http.routers.forust-homepage-local.middlewares=security-headers@file" - "traefik.http.routers.forust-homepage-local.service=forust-homepage" - "traefik.http.routers.forust-homepage-local.tls=true" # Dev Router - "traefik.http.routers.forust-homepage-dev.rule=Host(`landing.gigaforust.internal`)" - "traefik.http.routers.forust-homepage-dev.entrypoints=websecure" - - "traefik.http.routers.forust-homepage-dev.middlewares=security-headers" + - "traefik.http.routers.forust-homepage-dev.middlewares=security-headers@file" - "traefik.http.routers.forust-homepage-dev.service=forust-homepage" - "traefik.http.routers.forust-homepage-dev.tls=true" diff --git a/metube/compose.yaml b/metube/compose.yaml index 950baa0..df63607 100644 --- a/metube/compose.yaml +++ b/metube/compose.yaml @@ -19,7 +19,7 @@ services: # Prod Router - "traefik.http.routers.metube.rule=Host(`metube.forust.xyz`)" - "traefik.http.routers.metube.entrypoints=websecure" - - "traefik.http.routers.metube.middlewares=security-headers,metube-auth" + - "traefik.http.routers.metube.middlewares=security-headers@file,metube-auth@file" - "traefik.http.routers.metube.service=metube" - "traefik.http.routers.metube.tls=true" - "traefik.http.services.metube.loadbalancer.server.port=8081" @@ -27,14 +27,14 @@ services: # Local Router - "traefik.http.routers.metube-local.rule=Host(`metube.workstation.internal`) || Host(`metube.internal`)" - "traefik.http.routers.metube-local.entrypoints=websecure" - - "traefik.http.routers.metube-local.middlewares=security-headers,metube-auth" + - "traefik.http.routers.metube-local.middlewares=security-headers@file,metube-auth@file" - "traefik.http.routers.metube-local.service=metube" - "traefik.http.routers.metube-local.tls=true" # Dev Router - "traefik.http.routers.metube-dev.rule=Host(`metube.gigaforust.internal`)" - "traefik.http.routers.metube-dev.entrypoints=websecure" - - "traefik.http.routers.metube-dev.middlewares=security-headers,metube-auth" + - "traefik.http.routers.metube-dev.middlewares=security-headers@file,metube-auth@file" - "traefik.http.routers.metube-dev.service=metube" - "traefik.http.routers.metube-dev.tls=true" diff --git a/n8n/compose.yaml b/n8n/compose.yaml index 32500e3..bcaa985 100644 --- a/n8n/compose.yaml +++ b/n8n/compose.yaml @@ -32,7 +32,7 @@ services: # Prod Router - "traefik.http.routers.n8n.rule=Host(`n8n.forust.xyz`)" - "traefik.http.routers.n8n.entrypoints=websecure" - - "traefik.http.routers.n8n.middlewares=security-headers" + - "traefik.http.routers.n8n.middlewares=security-headers@file" - "traefik.http.routers.n8n.service=n8n" - "traefik.http.routers.n8n.tls=true" - "traefik.http.services.n8n.loadbalancer.server.port=5678" @@ -40,14 +40,14 @@ services: # Local Router - "traefik.http.routers.n8n-local.rule=Host(`n8n.workstation.internal`) || Host(`n8n.internal`)" - "traefik.http.routers.n8n-local.entrypoints=websecure" - - "traefik.http.routers.n8n-local.middlewares=security-headers" + - "traefik.http.routers.n8n-local.middlewares=security-headers@file" - "traefik.http.routers.n8n-local.service=n8n" - "traefik.http.routers.n8n-local.tls=true" # Dev Router - "traefik.http.routers.n8n-dev.rule=Host(`n8n.gigaforust.internal`)" - "traefik.http.routers.n8n-dev.entrypoints=websecure" - - "traefik.http.routers.n8n-dev.middlewares=security-headers" + - "traefik.http.routers.n8n-dev.middlewares=security-headers@file" - "traefik.http.routers.n8n-dev.service=n8n" - "traefik.http.routers.n8n-dev.tls=true" diff --git a/nextcloud/compose.yaml b/nextcloud/compose.yaml index 196410e..641f988 100644 --- a/nextcloud/compose.yaml +++ b/nextcloud/compose.yaml @@ -32,14 +32,14 @@ services: # Local Router - "traefik.http.routers.nextcloud-aio-local.rule=Host(`nextcloud-aio.workstation.internal`) || Host(`nextcloud-aio.internal`)" - "traefik.http.routers.nextcloud-aio-local.entrypoints=websecure" - - "traefik.http.routers.nextcloud-aio-local.middlewares=security-headers" + - "traefik.http.routers.nextcloud-aio-local.middlewares=security-headers@file" - "traefik.http.routers.nextcloud-aio-local.service=nextcloud-aio" - "traefik.http.routers.nextcloud-aio-local.tls=true" # Dev Router - "traefik.http.routers.nextcloud-aio-dev.rule=Host(`nextcloud-aio.gigaforust.internal`)" - "traefik.http.routers.nextcloud-aio-dev.entrypoints=websecure" - - "traefik.http.routers.nextcloud-aio-dev.middlewares=security-headers" + - "traefik.http.routers.nextcloud-aio-dev.middlewares=security-headers@file" - "traefik.http.routers.nextcloud-aio-dev.service=nextcloud-aio" - "traefik.http.routers.nextcloud-aio-dev.tls=true" diff --git a/penpot/compose.yaml b/penpot/compose.yaml index 5d8eba4..d9eb665 100644 --- a/penpot/compose.yaml +++ b/penpot/compose.yaml @@ -107,7 +107,7 @@ services: # Prod Router - "traefik.http.routers.penpot.rule=Host(`penpot.forust.xyz`)" - "traefik.http.routers.penpot.entrypoints=websecure" - - "traefik.http.routers.penpot.middlewares=security-headers" + - "traefik.http.routers.penpot.middlewares=security-headers@file" - "traefik.http.routers.penpot.service=penpot" - "traefik.http.routers.penpot.tls=true" - "traefik.http.services.penpot.loadbalancer.server.port=8080" @@ -115,14 +115,14 @@ services: # Local Router - "traefik.http.routers.penpot-local.rule=Host(`penpot.workstation.internal`) || Host(`penpot.internal`)" - "traefik.http.routers.penpot-local.entrypoints=websecure" - - "traefik.http.routers.penpot-local.middlewares=security-headers" + - "traefik.http.routers.penpot-local.middlewares=security-headers@file" - "traefik.http.routers.penpot-local.service=penpot" - "traefik.http.routers.penpot-local.tls=true" # Dev Router - "traefik.http.routers.penpot-dev.rule=Host(`penpot.gigaforust.internal`)" - "traefik.http.routers.penpot-dev.entrypoints=websecure" - - "traefik.http.routers.penpot-dev.middlewares=security-headers" + - "traefik.http.routers.penpot-dev.middlewares=security-headers@file" - "traefik.http.routers.penpot-dev.service=penpot" - "traefik.http.routers.penpot-dev.tls=true" diff --git a/portainer/compose.yaml b/portainer/compose.yaml index 26e6f19..84651df 100644 --- a/portainer/compose.yaml +++ b/portainer/compose.yaml @@ -18,7 +18,7 @@ services: # Prod Router - "traefik.http.routers.portainer.rule=Host(`portainer.forust.xyz`)" - "traefik.http.routers.portainer.entrypoints=websecure" - - "traefik.http.routers.portainer.middlewares=security-headers" + - "traefik.http.routers.portainer.middlewares=security-headers@file" - "traefik.http.routers.portainer.service=portainer" - "traefik.http.routers.portainer.tls=true" - "traefik.http.services.portainer.loadbalancer.server.port=9443" @@ -28,14 +28,14 @@ services: # Local Router - "traefik.http.routers.portainer-local.rule=Host(`portainer.workstation.internal`) || Host(`portainer.internal`)" - "traefik.http.routers.portainer-local.entrypoints=websecure" - - "traefik.http.routers.portainer-local.middlewares=security-headers" + - "traefik.http.routers.portainer-local.middlewares=security-headers@file" - "traefik.http.routers.portainer-local.service=portainer" - "traefik.http.routers.portainer-local.tls=true" # Dev Router - "traefik.http.routers.portainer-dev.rule=Host(`portainer.gigaforust.internal`)" - "traefik.http.routers.portainer-dev.entrypoints=websecure" - - "traefik.http.routers.portainer-dev.middlewares=security-headers" + - "traefik.http.routers.portainer-dev.middlewares=security-headers@file" - "traefik.http.routers.portainer-dev.service=portainer" - "traefik.http.routers.portainer-dev.tls=true" diff --git a/termix/compose.yaml b/termix/compose.yaml index 955ada5..44a87c1 100644 --- a/termix/compose.yaml +++ b/termix/compose.yaml @@ -16,7 +16,7 @@ services: # Prod Router - "traefik.http.routers.termix.rule=Host(`termix.forust.xyz`)" - "traefik.http.routers.termix.entrypoints=websecure" - - "traefik.http.routers.termix.middlewares=security-headers" + - "traefik.http.routers.termix.middlewares=security-headers@file" - "traefik.http.routers.termix.service=termix" - "traefik.http.routers.termix.tls=true" - "traefik.http.services.termix.loadbalancer.server.port=8080" @@ -24,14 +24,14 @@ services: # Local Router - "traefik.http.routers.termix-local.rule=Host(`termix.workstation.internal`) || Host(`termix.internal`)" - "traefik.http.routers.termix-local.entrypoints=websecure" - - "traefik.http.routers.termix-local.middlewares=security-headers" + - "traefik.http.routers.termix-local.middlewares=security-headers@file" - "traefik.http.routers.termix-local.service=termix" - "traefik.http.routers.termix-local.tls=true" # Dev Router - "traefik.http.routers.termix-dev.rule=Host(`termix.gigaforust.internal`)" - "traefik.http.routers.termix-dev.entrypoints=websecure" - - "traefik.http.routers.termix-dev.middlewares=security-headers" + - "traefik.http.routers.termix-dev.middlewares=security-headers@file" - "traefik.http.routers.termix-dev.service=termix" - "traefik.http.routers.termix-dev.tls=true" networks: diff --git a/traefik/compose.yaml b/traefik/compose.yaml index 53b0741..f2c851b 100644 --- a/traefik/compose.yaml +++ b/traefik/compose.yaml @@ -1,11 +1,11 @@ services: traefik: - image: traefik:v3.5 + image: traefik:latest container_name: traefik restart: unless-stopped command: # API - - "--api.insecure=false" + - "--api.insecure=true" - "--api.dashboard=true" # Providers @@ -35,11 +35,12 @@ services: - "--entryPoints.websecure.forwardedHeaders.trustedIPs=173.245.48.0/20,103.21.244.0/22,103.22.200.0/22,103.31.4.0/22,141.101.64.0/18,108.162.192.0/18,190.93.240.0/20,188.114.96.0/20,197.234.240.0/22,198.41.128.0/17,162.158.0.0/15,104.16.0.0/13,104.24.0.0/14,172.64.0.0/13,131.0.72.0/22" # Logging - - "--log.level=INFO" + - "--log.level=DEBUG" - "--log.filePath=/var/log/traefik/traefik.log" - "--accesslog=true" - "--accesslog.filepath=/var/log/traefik/access.log" + labels: - "traefik.enable=true" - "traefik.docker.network=traefik-proxy" @@ -53,14 +54,14 @@ services: # Local Router - "traefik.http.routers.traefik-dashboard-local.rule=Host(`traefik.workstation.internal`) || Host(`traefik.internal`)" - "traefik.http.routers.traefik-dashboard-local.entrypoints=websecure" - - "traefik.http.routers.traefik-dashboard-local.middlewares=auth,security-headers" + - "traefik.http.routers.traefik-dashboard-local.middlewares=auth@file,security-headers@file" - "traefik.http.routers.traefik-dashboard-local.service=api@internal" - "traefik.http.routers.traefik-dashboard-local.tls=true" # Dev Router - "traefik.http.routers.traefik-dashboard-dev.rule=Host(`traefik.gigaforust.internal`)" - "traefik.http.routers.traefik-dashboard-dev.entrypoints=websecure" - - "traefik.http.routers.traefik-dashboard-dev.middlewares=security-headers" + - "traefik.http.routers.traefik-dashboard-dev.middlewares=security-headers@file" - "traefik.http.routers.traefik-dashboard-dev.service=api@internal" - "traefik.http.routers.traefik-dashboard-dev.tls=true" diff --git a/traefik/dynamic/tls.yml b/traefik/dynamic/tls.yml index 83ab0ce..9416a04 100644 --- a/traefik/dynamic/tls.yml +++ b/traefik/dynamic/tls.yml @@ -4,22 +4,19 @@ tls: # Cloudflare Origin CA *.forust.xyz - certFile: /certs/cloudflare.pem keyFile: /certs/cloudflare.key - # Local certificate - - certFile: /certs/workstation+1.pem - keyFile: /certs/workstation+1-key.pem - + stores: default: defaultCertificate: # Fallback local certificate - certFile: /certs/workstation+1.pem - keyFile: /certs/workstation+1-key.pem - + certFile: /certs/gigaforust.internal+1.pem + keyFile: /certs/gigaforust.internal+1-key.pem + options: default: minVersion: VersionTLS12 - sniStrict: true + sniStrict: false cipherSuites: - TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 - TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 - - TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305 \ No newline at end of file + - TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305 From 7d7a0e5c8a6856fa9b888a4e0003f52ab685d28a Mon Sep 17 00:00:00 2001 From: mr-forust Date: Fri, 5 Dec 2025 18:53:19 +0100 Subject: [PATCH 25/89] fix: penpot non- latin symbol --- penpot/compose.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/penpot/compose.yaml b/penpot/compose.yaml index d9eb665..db2b1b1 100644 --- a/penpot/compose.yaml +++ b/penpot/compose.yaml @@ -26,7 +26,7 @@ x-flags: &penpot-flags PENPOT_FLAGS: disable-email-verification enable-prepl-server disable-secure-session-cookies login-with-github -x-uri: &penpot-public-urш +x-uri: &penpot-public-uri PENPOT_PUBLIC_URI: http://localhost:9001 x-body-size: From 23e955bde7c42d0f631bb713f803394ad69aabd6 Mon Sep 17 00:00:00 2001 From: mr-forust Date: Fri, 5 Dec 2025 20:33:14 +0100 Subject: [PATCH 26/89] chore: new auth credentials, revert to info logging --- traefik/compose.yaml | 2 +- traefik/dynamic/middlewares.yml | 8 ++------ 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/traefik/compose.yaml b/traefik/compose.yaml index f2c851b..68f57dc 100644 --- a/traefik/compose.yaml +++ b/traefik/compose.yaml @@ -35,7 +35,7 @@ services: - "--entryPoints.websecure.forwardedHeaders.trustedIPs=173.245.48.0/20,103.21.244.0/22,103.22.200.0/22,103.31.4.0/22,141.101.64.0/18,108.162.192.0/18,190.93.240.0/20,188.114.96.0/20,197.234.240.0/22,198.41.128.0/17,162.158.0.0/15,104.16.0.0/13,104.24.0.0/14,172.64.0.0/13,131.0.72.0/22" # Logging - - "--log.level=DEBUG" + - "--log.level=INFO" - "--log.filePath=/var/log/traefik/traefik.log" - "--accesslog=true" - "--accesslog.filepath=/var/log/traefik/access.log" diff --git a/traefik/dynamic/middlewares.yml b/traefik/dynamic/middlewares.yml index 8237b09..aae708c 100644 --- a/traefik/dynamic/middlewares.yml +++ b/traefik/dynamic/middlewares.yml @@ -10,8 +10,6 @@ http: metube-auth: basicAuth: users: - - "admin:$2y$05$3Q6gyLFW3NFNp4C6elnyfupntqB6VNB/tcIAeo8NEzvaqPxOjN0iC" - # - "jeepik:$2y$05$tIKrmhd7SYOe6yImRRAfpen7hpVdF8PnSbgBTCDZ.GI0Djx.Le2bq" - "vv:$2y$05$JdT8AGUO9bd.E/PiCmKaoOJS1RFlXkrrmZ5mJ4f8/a1bEW39L3FbS" realm: "MeTube Access" @@ -20,16 +18,14 @@ http: auth: basicAuth: users: - - "admin:$$apr1$$57E60OUM$$JoYwmLr/uZKaTy6U4IQd9." - # - "jeepik:$2y$05$Q8QqJwSjpycVYONyk4id/.rDFApW9oL8tycRMlGttNySsDv71Rnsu" - # - "vv:" + - "admin:$2y$05$.CKDD82sNUxcpaRrLbHuK.dopqt1fgurc2yfTKAT5OFzT7RvPrJHK" realm: "Traefik Dashboard" # Basic Auth Dockmon dockmon-auth: basicAuth: users: - - "admin:$$apr1$$.bCpmIHl$$dxPEKdw5aZLAwo8wUz52b1" + - "admin:$2y$05$.CKDD82sNUxcpaRrLbHuK.dopqt1fgurc2yfTKAT5OFzT7RvPrJHK" realm: "Dockmon Access" # Cloudflare IP Whitelist From 99d50b43fe69fd2de949cb53f939081d406c948a Mon Sep 17 00:00:00 2001 From: mr-forust Date: Fri, 5 Dec 2025 21:00:55 +0100 Subject: [PATCH 27/89] fix: traefik service --- nextcloud/compose.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/nextcloud/compose.yaml b/nextcloud/compose.yaml index 641f988..10899b5 100644 --- a/nextcloud/compose.yaml +++ b/nextcloud/compose.yaml @@ -25,8 +25,8 @@ services: # - "traefik.http.routers.nextcloud-aio.middlewares=security-headers" # - "traefik.http.routers.nextcloud-aio.service=nextcloud-aio" # - "traefik.http.routers.nextcloud-aio.tls=true" - # - "traefik.http.services.nextcloud-aio.loadbalancer.server.port=8080" - # - "traefik.http.services.nextcloud-aio.loadbalancer.server.scheme=https" + - "traefik.http.services.nextcloud-aio.loadbalancer.server.port=8080" + - "traefik.http.services.nextcloud-aio.loadbalancer.server.scheme=https" # - "traefik.http.services.nextcloud-aio.loadbalancer.serverstransport=insecureTransport@file" # Local Router From d48a01775db6573b83907aaaeae67610575347a4 Mon Sep 17 00:00:00 2001 From: mr-forust Date: Sat, 6 Dec 2025 00:38:16 +0100 Subject: [PATCH 28/89] fix: routers for nextcloud frontend --- nextcloud/compose.yaml | 2 ++ traefik/dynamic/dynamic.yml | 19 ++++++++++++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/nextcloud/compose.yaml b/nextcloud/compose.yaml index 10899b5..3755139 100644 --- a/nextcloud/compose.yaml +++ b/nextcloud/compose.yaml @@ -27,6 +27,8 @@ services: # - "traefik.http.routers.nextcloud-aio.tls=true" - "traefik.http.services.nextcloud-aio.loadbalancer.server.port=8080" - "traefik.http.services.nextcloud-aio.loadbalancer.server.scheme=https" + - "traefik.http.services.nextcloud-aio.loadbalancer.serverstransport=insecureTransport@file" + # - "traefik.http.services.nextcloud-aio.loadbalancer.serverstransport=insecureTransport@file" # Local Router diff --git a/traefik/dynamic/dynamic.yml b/traefik/dynamic/dynamic.yml index 70bbffa..e6caf4e 100644 --- a/traefik/dynamic/dynamic.yml +++ b/traefik/dynamic/dynamic.yml @@ -1,4 +1,21 @@ http: serversTransports: insecureTransport: - insecureSkipVerify: true \ No newline at end of file + insecureSkipVerify: true + + routers: + # Nextcloud Main (public (account required)) + nextcloud: + rule: "Host(`nextcloud.forust.xyz`)" + entrypoints: + - websecure + service: nextcloud + middlewares: + - nextcloud-chain + tls: {} + services: + # Nextcloud Main + nextcloud: + loadBalancer: + servers: + - url: "http://nextcloud-aio-apache:11000" \ No newline at end of file From aca68243094f2e024de4830bf5e570813f8bb0ab Mon Sep 17 00:00:00 2001 From: mr-forust Date: Sat, 6 Dec 2025 01:31:50 +0100 Subject: [PATCH 29/89] chore: comments cleanup --- nextcloud/compose.yaml | 59 +++++++++++++++++++++--------------------- 1 file changed, 30 insertions(+), 29 deletions(-) diff --git a/nextcloud/compose.yaml b/nextcloud/compose.yaml index 3755139..7d5b6f0 100644 --- a/nextcloud/compose.yaml +++ b/nextcloud/compose.yaml @@ -1,35 +1,36 @@ services: nextcloud-aio-mastercontainer: - image: ghcr.io/nextcloud-releases/all-in-one:latest # This is the container image used. You can switch to ghcr.io/nextcloud-releases/all-in-one:beta if you want to help testing new releases. See https://github.com/nextcloud/all-in-one#how-to-switch-the-channel + image: ghcr.io/nextcloud-releases/all-in-one:latest init: true # This setting makes sure that signals from main process inside the container are correctly forwarded to children. See https://docs.docker.com/reference/compose-file/services/#init - restart: unless-stopped # This makes sure that the container starts always together with the host OS. See https://docs.docker.com/reference/compose-file/services/#restart - container_name: nextcloud-aio-mastercontainer # This line is not allowed to be changed as otherwise AIO will not work correctly + restart: unless-stopped + container_name: nextcloud-aio-mastercontainer # Do not change volumes: - - nextcloud_aio_mastercontainer:/mnt/docker-aio-config # This line is not allowed to be changed as otherwise the built-in backup solution will not work - - /var/run/docker.sock:/var/run/docker.sock:ro # May be changed on macOS, Windows or docker rootless. See the applicable documentation. If adjusting, don't forget to also set 'WATCHTOWER_DOCKER_SOCKET_PATH'! - # network_mode: bridge # This adds the container to the same network as docker run would do. Comment this line and uncomment the line below and the networks section at the end of the file if you want to define a custom MTU size for the docker network + - nextcloud_aio_mastercontainer:/mnt/docker-aio-config # Do not change (backup) + - /var/run/docker.sock:/var/run/docker.sock:ro networks: - nextcloud-aio - - traefik-proxy # Optional: Connects the mastercontainer to the traefik-proxy network in order to make the built-in reverse proxy detection work. + - traefik-proxy # Optional: Connects the mastercontainer to the traefik-proxy network in order to make the built-in reverse proxy detection work. See https://github.com/nextcloud/all-in-one/blob/main/reverse-proxy.md + # ports: - # - 8081:80 # Can be removed when running behind a web server or reverse proxy (like Apache, Nginx, Caddy, Cloudflare Tunnel and else). See https://github.com/nextcloud/all-in-one/blob/main/reverse-proxy.md - # - 8888:8080 # This is the AIO interface, served via https and self-signed certificate. See https://github.com/nextcloud/all-in-one#explanation-of-used-ports - # - 8443:8443 # Can be removed when running behind a web server or reverse proxy (like Apache, Nginx, Caddy, Cloudflare Tunnel and else). See https://github.com/nextcloud/all-in-one/blob/main/reverse-proxy.md + # - 8081:80 # may be removed if under reverse-proxy + # - 8443:8443 + # - 8888:8080 # AIO + labels: - "traefik.enable=true" - "traefik.docker.network=traefik-proxy" - # Prod Router - DISABLED per user request + # AIO Services configuration + - "traefik.http.services.nextcloud-aio.loadbalancer.server.port=8080" + - "traefik.http.services.nextcloud-aio.loadbalancer.server.scheme=https" + - "traefik.http.services.nextcloud-aio.loadbalancer.serverstransport=insecureTransport@file" + + # Prod Router # - "traefik.http.routers.nextcloud-aio.rule=Host(`nextcloud-aio.forust.xyz`)" # - "traefik.http.routers.nextcloud-aio.entrypoints=websecure" # - "traefik.http.routers.nextcloud-aio.middlewares=security-headers" # - "traefik.http.routers.nextcloud-aio.service=nextcloud-aio" # - "traefik.http.routers.nextcloud-aio.tls=true" - - "traefik.http.services.nextcloud-aio.loadbalancer.server.port=8080" - - "traefik.http.services.nextcloud-aio.loadbalancer.server.scheme=https" - - "traefik.http.services.nextcloud-aio.loadbalancer.serverstransport=insecureTransport@file" - - # - "traefik.http.services.nextcloud-aio.loadbalancer.serverstransport=insecureTransport@file" # Local Router - "traefik.http.routers.nextcloud-aio-local.rule=Host(`nextcloud-aio.workstation.internal`) || Host(`nextcloud-aio.internal`)" @@ -45,34 +46,34 @@ services: - "traefik.http.routers.nextcloud-aio-dev.service=nextcloud-aio" - "traefik.http.routers.nextcloud-aio-dev.tls=true" + # Glanceapp/glance config - glance.name=Nextcloud # - glance.icon=si:nextcloud - glance.url=https://nextcloud.forust.xyz/ - glance.description=Nextcloud is a suite of client-server software for creating and using file hosting services. environment: - # Is needed when using any of the options below - AIO_DISABLE_BACKUP_SECTION: false # Setting this to true allows to hide the backup section in the AIO interface. See https://github.com/nextcloud/all-in-one#how-to-disable-the-backup-section + AIO_DISABLE_BACKUP_SECTION: false APACHE_PORT: 11000 # Is needed when running behind a web server or reverse proxy (like Apache, Nginx, Caddy, Cloudflare Tunnel and else). See https://github.com/nextcloud/all-in-one/blob/main/reverse-proxy.md - APACHE_IP_BINDING: 0.0.0.0 # Should be set when running behind a web server or reverse proxy (like Apache, Nginx, Caddy, Cloudflare Tunnel and else) that is running on the same host. See https://github.com/nextcloud/all-in-one/blob/main/reverse-proxy.md - APACHE_ADDITIONAL_NETWORK: traefik-proxy # (Optional) Connect the apache container to an additional docker network. Needed when behind a web server or reverse proxy (like Apache, Nginx, Caddy, Cloudflare Tunnel and else) running in a different docker network on same server. See https://github.com/nextcloud/all-in-one/blob/main/reverse-proxy.md - BORG_RETENTION_POLICY: --keep-within=7d --keep-weekly=4 --keep-monthly=6 # Allows to adjust borgs retention policy. See https://github.com/nextcloud/all-in-one#how-to-adjust-borgs-retention-policy + APACHE_IP_BINDING: 0.0.0.0 # Configure when going with reverse-proxy https://github.com/nextcloud/all-in-one/blob/main/reverse-proxy.md + APACHE_ADDITIONAL_NETWORK: traefik-proxy # (Optional) Connect the apache container to an additional docker network. When going with reverse-proxy. See https://github.com/nextcloud/all-in-one/blob/main/reverse-proxy.md + BORG_RETENTION_POLICY: --keep-within=7d --keep-weekly=4 --keep-monthly=6 # Backup retention See https://github.com/nextcloud/all-in-one#how-to-adjust-borgs-retention-policy COLLABORA_SECCOMP_DISABLED: false # Setting this to true allows to disable Collabora's Seccomp feature. See https://github.com/nextcloud/all-in-one#how-to-disable-collaboras-seccomp-feature - FULLTEXTSEARCH_JAVA_OPTIONS: "-Xms1024M -Xmx1024M" # Allows to adjust the fulltextsearch java options. See https://github.com/nextcloud/all-in-one#how-to-adjust-the-fulltextsearch-java-options + FULLTEXTSEARCH_JAVA_OPTIONS: "-Xms1024M -Xmx1024M" # adjust fulltextsearch java options. https://github.com/nextcloud/all-in-one#how-to-adjust-the-fulltextsearch-java-options NEXTCLOUD_DATADIR: /media/forust/nextcloud/ncdata # Allows to set the host directory for Nextcloud's datadir. ⚠️⚠️⚠️ Warning: do not set or adjust this value after the initial Nextcloud installation is done! See https://github.com/nextcloud/all-in-one#how-to-change-the-default-location-of-nextclouds-datadir - NEXTCLOUD_MOUNT: /media/forust/nextcloud # Allows the Nextcloud container to access the chosen directory on the host. See https://github.com/nextcloud/all-in-one#how-to-allow-the-nextcloud-container-to-access-directories-on-the-host - NEXTCLOUD_UPLOAD_LIMIT: 16G # Can be adjusted if you need more. See https://github.com/nextcloud/all-in-one#how-to-adjust-the-upload-limit-for-nextcloud - NEXTCLOUD_MAX_TIME: 3600 # Can be adjusted if you need more. See https://github.com/nextcloud/all-in-one#how-to-adjust-the-max-execution-time-for-nextcloud + NEXTCLOUD_MOUNT: /mnt/ # Allows the Nextcloud container to access the chosen directory on the host. See https://github.com/nextcloud/all-in-one#how-to-allow-the-nextcloud-container-to-access-directories-on-the-host + NEXTCLOUD_UPLOAD_LIMIT: 16G # https://github.com/nextcloud/all-in-one#how-to-adjust-the-upload-limit-for-nextcloud + NEXTCLOUD_MAX_TIME: 7200 # Max uploading time See https://github.com/nextcloud/all-in-one#how-to-adjust-the-max-execution-time-for-nextcloud NEXTCLOUD_MEMORY_LIMIT: 512M # Can be adjusted if you need more. See https://github.com/nextcloud/all-in-one#how-to-adjust-the-php-memory-limit-for-nextcloud - # NEXTCLOUD_TRUSTED_CACERTS_DIR: /path/to/my/cacerts # CA certificates in this directory will be trusted by the OS of the nextcloud container (Useful e.g. for LDAPS) See https://github.com/nextcloud/all-in-one#how-to-trust-user-defined-certification-authorities-ca + # NEXTCLOUD_TRUSTED_CACERTS_DIR: /path/to/my/cacerts # CA certificates will be trusted by the OS of the nextcloud container See https://github.com/nextcloud/all-in-one#how-to-trust-user-defined-certification-authorities-ca NEXTCLOUD_STARTUP_APPS: deck twofactor_totp tasks calendar contacts notes # Allows to modify the Nextcloud apps that are installed on starting AIO the first time. See https://github.com/nextcloud/all-in-one#how-to-change-the-nextcloud-apps-that-are-installed-on-the-first-startup NEXTCLOUD_ADDITIONAL_APKS: imagemagick # This allows to add additional packages to the Nextcloud container permanently. Default is imagemagick but can be overwritten by modifying this value. See https://github.com/nextcloud/all-in-one#how-to-add-os-packages-permanently-to-the-nextcloud-container - NEXTCLOUD_ADDITIONAL_PHP_EXTENSIONS: imagick # This allows to add additional php extensions to the Nextcloud container permanently. Default is imagick but can be overwritten by modifying this value. See https://github.com/nextcloud/all-in-one#how-to-add-php-extensions-permanently-to-the-nextcloud-container + NEXTCLOUD_ADDITIONAL_PHP_EXTENSIONS: imagick # dditional php extensions to the Nextcloud container permanently. Default is imagick but can be overwritten by modifying this value. See https://github.com/nextcloud/all-in-one#how-to-add-php-extensions-permanently-to-the-nextcloud-container NEXTCLOUD_ENABLE_DRI_DEVICE: true # This allows to enable the /dev/dri device for containers that profit from it. ⚠️⚠️⚠️ Warning: this only works if the '/dev/dri' device is present on the host! If it should not exist on your host, don't set this to true as otherwise the Nextcloud container will fail to start! See https://github.com/nextcloud/all-in-one#how-to-enable-hardware-acceleration-for-nextcloud # NEXTCLOUD_KEEP_DISABLED_APPS: false # Setting this to true will keep Nextcloud apps that are disabled in the AIO interface and not uninstall them if they should be installed. See https://github.com/nextcloud/all-in-one#how-to-keep-disabled-apps SKIP_DOMAIN_VALIDATION: false # This should only be set to true if things are correctly configured. See https://github.com/nextcloud/all-in-one?tab=readme-ov-file#how-to-skip-the-domain-validation # TALK_PORT: 3478 # This a-llows to adjust the port that the talk container is using which is exposed on the host. See https://github.com/nextcloud/all-in-one#how-to-adjust-the-talk-port - # WATCHTOWER_DOCKER_SOCKET_PATH: /var/run/docker.sock # Needs to be specified if the docker socket on the host is not located in the default '/var/run/docker.sock'. Otherwise mastercontainer updates will fail. For macos it needs to be '/var/run/docker.sock' + # WATCHTOWER_DOCKER_SOCKET_PATH: /var/run/docker.sock # Needs to be specified if the docker socket on the host is not located in the default '/var/run/docker.sock'. For macos it needs to be '/var/run/docker.sock' networks: traefik-proxy: @@ -83,4 +84,4 @@ networks: volumes: # If you want to store the data on a different drive, see https://github.com/nextcloud/all-in-one#how-to-store-the-filesinstallation-on-a-separate-drive nextcloud_aio_mastercontainer: - name: nextcloud_aio_mastercontainer # This line is not allowed to be changed as otherwise the built-in backup solution will not work + name: nextcloud_aio_mastercontainer # Do not change From 70d60ed40a31c4386b43742f9455a2e857926bec Mon Sep 17 00:00:00 2001 From: mr-forust Date: Sat, 6 Dec 2025 02:33:10 +0100 Subject: [PATCH 30/89] chore: add traefik dev rule for main nextcloud --- nextcloud/compose.yaml | 2 +- traefik/dynamic/dynamic.yml | 17 ----------------- 2 files changed, 1 insertion(+), 18 deletions(-) diff --git a/nextcloud/compose.yaml b/nextcloud/compose.yaml index 7d5b6f0..0d0082d 100644 --- a/nextcloud/compose.yaml +++ b/nextcloud/compose.yaml @@ -71,7 +71,7 @@ services: NEXTCLOUD_ADDITIONAL_PHP_EXTENSIONS: imagick # dditional php extensions to the Nextcloud container permanently. Default is imagick but can be overwritten by modifying this value. See https://github.com/nextcloud/all-in-one#how-to-add-php-extensions-permanently-to-the-nextcloud-container NEXTCLOUD_ENABLE_DRI_DEVICE: true # This allows to enable the /dev/dri device for containers that profit from it. ⚠️⚠️⚠️ Warning: this only works if the '/dev/dri' device is present on the host! If it should not exist on your host, don't set this to true as otherwise the Nextcloud container will fail to start! See https://github.com/nextcloud/all-in-one#how-to-enable-hardware-acceleration-for-nextcloud # NEXTCLOUD_KEEP_DISABLED_APPS: false # Setting this to true will keep Nextcloud apps that are disabled in the AIO interface and not uninstall them if they should be installed. See https://github.com/nextcloud/all-in-one#how-to-keep-disabled-apps - SKIP_DOMAIN_VALIDATION: false # This should only be set to true if things are correctly configured. See https://github.com/nextcloud/all-in-one?tab=readme-ov-file#how-to-skip-the-domain-validation + SKIP_DOMAIN_VALIDATION: true # This should only be set to true if things are correctly configured. See https://github.com/nextcloud/all-in-one?tab=readme-ov-file#how-to-skip-the-domain-validation # TALK_PORT: 3478 # This a-llows to adjust the port that the talk container is using which is exposed on the host. See https://github.com/nextcloud/all-in-one#how-to-adjust-the-talk-port # WATCHTOWER_DOCKER_SOCKET_PATH: /var/run/docker.sock # Needs to be specified if the docker socket on the host is not located in the default '/var/run/docker.sock'. For macos it needs to be '/var/run/docker.sock' diff --git a/traefik/dynamic/dynamic.yml b/traefik/dynamic/dynamic.yml index e6caf4e..7e569bd 100644 --- a/traefik/dynamic/dynamic.yml +++ b/traefik/dynamic/dynamic.yml @@ -2,20 +2,3 @@ http: serversTransports: insecureTransport: insecureSkipVerify: true - - routers: - # Nextcloud Main (public (account required)) - nextcloud: - rule: "Host(`nextcloud.forust.xyz`)" - entrypoints: - - websecure - service: nextcloud - middlewares: - - nextcloud-chain - tls: {} - services: - # Nextcloud Main - nextcloud: - loadBalancer: - servers: - - url: "http://nextcloud-aio-apache:11000" \ No newline at end of file From 6d9427cf2a5a6b821e72fa972d13807162f2bed8 Mon Sep 17 00:00:00 2001 From: mr-forust Date: Sat, 6 Dec 2025 02:34:19 +0100 Subject: [PATCH 31/89] chore: add traefik dev rule for main nextcloud --- traefik/dynamic/nextcloud-apache.yml | 29 ++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 traefik/dynamic/nextcloud-apache.yml diff --git a/traefik/dynamic/nextcloud-apache.yml b/traefik/dynamic/nextcloud-apache.yml new file mode 100644 index 0000000..55357a6 --- /dev/null +++ b/traefik/dynamic/nextcloud-apache.yml @@ -0,0 +1,29 @@ +http: + routers: + # Nextcloud prod + nextcloud: + rule: "Host(`nextcloud.forust.xyz`)" + entrypoints: + - websecure + service: nextcloud + middlewares: + - nextcloud-chain + tls: {} + + # Nextcloud dev + nextcloud-dev: + rule: "Host(`nextcloud.gigaforust.internal`)" + entrypoints: + - websecure + - web + service: nextcloud + middlewares: + - nextcloud-chain + tls: {} + + services: + # Nextcloud Main + nextcloud: + loadBalancer: + servers: + - url: "http://nextcloud-aio-apache:11000" From aa7239ee83104f0d552f38d0a0321bbd0b430929 Mon Sep 17 00:00:00 2001 From: mr-forust Date: Sun, 7 Dec 2025 01:39:58 +0100 Subject: [PATCH 32/89] refactor: traefik dynamic configuration --- traefik/dynamic/dynamic.bak | 328 -------------------------- traefik/dynamic/nextcloud-apache.yml | 13 + traefik/dynamic/routers-dev.yml.bak | 153 ------------ traefik/dynamic/routers-local.yml.bak | 152 ------------ traefik/dynamic/routers-prod.yml.bak | 152 ------------ traefik/dynamic/services.yml.bak | 86 ------- 6 files changed, 13 insertions(+), 871 deletions(-) delete mode 100644 traefik/dynamic/dynamic.bak delete mode 100644 traefik/dynamic/routers-dev.yml.bak delete mode 100644 traefik/dynamic/routers-local.yml.bak delete mode 100644 traefik/dynamic/routers-prod.yml.bak delete mode 100644 traefik/dynamic/services.yml.bak diff --git a/traefik/dynamic/dynamic.bak b/traefik/dynamic/dynamic.bak deleted file mode 100644 index dc39b9b..0000000 --- a/traefik/dynamic/dynamic.bak +++ /dev/null @@ -1,328 +0,0 @@ -http: - routers: - # Traefik Dashboard (ADMIN, local only) - traefik-dashboard: - rule: "Host(`traefik.workstation`)" - entryPoints: - - websecure - service: api@internal - middlewares: - # - auth - - security-headers - tls: {} - - # Nextcloud AIO Interface (ADMIN, local is better) - nextcloud-aio: - rule: "Host(`nextcloud-aio.workstation`)" - entryPoints: - - websecure - service: nextcloud-aio - middlewares: - - security-headers - tls: {} - - # Termix (public (account required)) - termix: - rule: "Host(`termix.forust.xyz`)" - entryPoints: - - websecure - service: termix - middlewares: - - security-headers - tls: {} - - # MeTube - metube: - rule: "Host(`metube.forust.xyz`)" - entryPoints: - - websecure - service: metube - middlewares: - - metube-auth - - security-headers - tls: {} - - # Portainer (public (account required)) - portainer: - rule: "Host(`portainer.forust.xyz`)" - entryPoints: - - websecure - service: portainer - middlewares: - - security-headers - tls: {} - - # Uptime Kuma - uptime-kuma: - rule: "Host(`uptime.forust.xyz`)" - entryPoints: - - websecure - service: uptime-kuma - tls: {} - - # AdGuard Home (public (account required)) - adguard: - rule: "Host(`adguard.forust.xyz`)" - entryPoints: - - websecure - service: adguard - middlewares: - - security-headers - tls: {} - - - # Nextcloud Main (public (account required)) - nextcloud: - rule: "Host(`nextcloud.forust.xyz`)" - entrypoints: - - websecure - service: nextcloud - middlewares: - - nextcloud-chain - tls: {} - - # Dockmon (public (account required)) - dockmon: - rule: "Host(`dockmon.forust.xyz`)" - entryPoints: - - websecure - service: dockmon - middlewares: - # - dockmon-auth - - security-headers - tls: {} - - # Gitea (public (account required)) - gitea: - rule: "Host(`gitea.forust.xyz`)" - entryPoints: - - websecure - - ssh - service: gitea - middlewares: - - security-headers - tls: {} - - # # Glance (local only) - # glance: - # rule: "Host(`glance.workstation`)" - # entryPoints: - # - websecure - # service: glance - # middlewares: - # - security-headers - # tls: {} - - # Watercrawl (local only) - watercrawl: - rule: "Host(`watercrawl.workstation`)" - entryPoints: - - websecure - service: watercrawl - middlewares: - - security-headers - tls: {} - - # N8N (local only) - n8n: - rule: "Host(`n8n.workstation`)" - entryPoints: - - websecure - service: n8n - middlewares: - - security-headers - tls: {} - - - services: - # Portainer - portainer: - loadBalancer: - servers: - - url: "https://portainer:9443" - serversTransport: insecureTransport - - # AdGuard Home - adguard: - loadBalancer: - servers: - - url: "http://adguardhome:3000" - - # Nextcloud AIO Interface - nextcloud-aio: - loadBalancer: - servers: - - url: "https://nextcloud-aio-mastercontainer:8080" - serversTransport: insecureTransport - - # Nextcloud Main - nextcloud: - loadBalancer: - servers: - - url: "http://nextcloud-aio-apache:11000" - - # Uptime Kuma - uptime-kuma: - loadBalancer: - servers: - - url: "http://uptime-kuma:3001" - - # Termix - termix: - loadBalancer: - servers: - - url: "http://termix:8080" - - # Dockmon - dockmon: - loadBalancer: - servers: - - url: "https://dockmon:443" - serversTransport: insecureTransport - - # # Glance - # glance: - # loadBalancer: - # servers: - # - url: "http://glance:8080" - - # Watercrawl - watercrawl: - loadBalancer: - servers: - - url: "http://nginx:80" - - # N8N - n8n: - loadBalancer: - servers: - - url: "http://n8n:5678" - - # Gitea - gitea: - loadBalancer: - servers: - - url: "http://gitea:3000" - # MeTube - metube: - loadBalancer: - servers: - - url: "http://metube:8081" - - serversTransports: - insecureTransport: - insecureSkipVerify: true - - middlewares: - # HTTPS Redirect - redirect-https: - redirectScheme: - scheme: https - permanent: true - - # Metube Basic Auth - metube-auth: - basicAuth: - users: - - "admin:$2y$05$3Q6gyLFW3NFNp4C6elnyfupntqB6VNB/tcIAeo8NEzvaqPxOjN0iC" - # - "jeepik:$2y$05$tIKrmhd7SYOe6yImRRAfpen7hpVdF8PnSbgBTCDZ.GI0Djx.Le2bq" - realm: "MeTube Access" - - # Basic Auth Traefik Dashboard - auth: - basicAuth: - users: - - "admin:$$apr1$$57E60OUM$$JoYwmLr/uZKaTy6U4IQd9." - # - "jeepik:$2y$05$Q8QqJwSjpycVYONyk4id/.rDFApW9oL8tycRMlGttNySsDv71Rnsu" - # - "vv:" - realm: "Traefik Dashboard" - - # Basic Auth Dockmon - dockmon-auth: - basicAuth: - users: - - "admin:$$apr1$$.bCpmIHl$$dxPEKdw5aZLAwo8wUz52b1" - realm: "Dockmon Access" - - # Cloudflare IP Whitelist - cloudflare-ipwhitelist: - ipWhiteList: - sourceRange: - - "173.245.48.0/20" - - "103.21.244.0/22" - - "103.22.200.0/22" - - "103.31.4.0/22" - - "141.101.64.0/18" - - "108.162.192.0/18" - - "190.93.240.0/20" - - "188.114.96.0/20" - - "197.234.240.0/22" - - "198.41.128.0/17" - - "162.158.0.0/15" - - "104.16.0.0/13" - - "104.24.0.0/14" - - "172.64.0.0/13" - - "131.0.72.0/22" - - # Security Headers - security-headers: - headers: - browserXssFilter: true - contentTypeNosniff: true - forceSTSHeader: true - stsIncludeSubdomains: true - stsPreload: true - stsSeconds: 31536000 - customFrameOptionsValue: "SAMEORIGIN" - customResponseHeaders: - X-Content-Type-Options: "nosniff" - Referrer-Policy: "strict-origin-when-cross-origin" - - # Nextcloud specific headers - nextcloud-secure-headers: - headers: - hostsProxyHeaders: - - "X-Forwarded-Host" - - "X-Forwarded-Proto" - referrerPolicy: "same-origin" - customFrameOptionsValue: "SAMEORIGIN" - - # Rate limiting - rate-limit: - rateLimit: - average: 100 - burst: 50 - period: 1m - - # Nextcloud chain - nextcloud-chain: - chain: - middlewares: - - nextcloud-secure-headers - - security-headers - -# TLS Configuration -tls: - certificates: - # Cloudflare Origin CA *.forust.xyz - - certFile: /certs/cloudflare.pem - keyFile: /certs/cloudflare.key - # Local certificate - - certFile: /certs/workstation+1.pem - keyFile: /certs/workstation+1-key.pem - - stores: - default: - defaultCertificate: - # Fallback local certificate - certFile: /certs/workstation+1.pem - keyFile: /certs/workstation+1-key.pem - - options: - default: - minVersion: VersionTLS12 - sniStrict: true - cipherSuites: - - TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 - - TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 - - TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305 \ No newline at end of file diff --git a/traefik/dynamic/nextcloud-apache.yml b/traefik/dynamic/nextcloud-apache.yml index 55357a6..a0d69de 100644 --- a/traefik/dynamic/nextcloud-apache.yml +++ b/traefik/dynamic/nextcloud-apache.yml @@ -10,6 +10,19 @@ http: - nextcloud-chain tls: {} + + # Nextcloud dev + nextcloud-local: + rule: "Host(`nextcloud.workstation.internal`)" + entrypoints: + - websecure + - web + service: nextcloud + middlewares: + - nextcloud-chain + tls: {} +w + # Nextcloud dev nextcloud-dev: rule: "Host(`nextcloud.gigaforust.internal`)" diff --git a/traefik/dynamic/routers-dev.yml.bak b/traefik/dynamic/routers-dev.yml.bak deleted file mode 100644 index 4a3ebea..0000000 --- a/traefik/dynamic/routers-dev.yml.bak +++ /dev/null @@ -1,153 +0,0 @@ -http: - routers: - # Traefik Dashboard (dev access. local only) - traefik-dashboard-dev: - rule: "Host(`traefik.gigaforust`)" - entryPoints: - - websecure - service: api@internal - middlewares: - # - auth - - security-headers - tls: {} - - # Nextcloud AIO Interface (dev access. local only) - nextcloud-aio-dev: - rule: "Host(`nextcloud-aio.gigaforust`)" - entryPoints: - - websecure - service: nextcloud-aio - middlewares: - - security-headers - tls: {} - - # Termix (dev access, (account required)) - termix-dev: - rule: "Host(`termix.gigaforust`)" - entryPoints: - - websecure - service: termix - middlewares: - - security-headers - tls: {} - - # MeTube (dev access, (auth required)) - metube-dev: - rule: "Host(`metube.gigaforust`)" - entryPoints: - - websecure - service: metube - middlewares: - - metube-auth - - security-headers - tls: {} - - # Portainer (dev acess, (account required)) - portainer-dev: - rule: "Host(`portainer.gigaforust`)" - entryPoints: - - websecure - service: portainer - middlewares: - - security-headers - tls: {} - - # Uptime Kuma (dev access, (account required)) - uptime-kuma-dev: - rule: "Host(`uptime.gigaforust`)" - entryPoints: - - websecure - service: uptime-kuma - tls: {} - - # AdGuard Home (dev access, (account required)) - adguard-dev: - rule: "Host(`adguard.gigaforust`)" - entryPoints: - - websecure - service: adguard - middlewares: - - security-headers - tls: {} - - # Nextcloud Main (public (account required)) - nextcloud-dev: - rule: "Host(`nextcloud.gigaforust`)" - entrypoints: - - websecure - service: nextcloud - middlewares: - - nextcloud-chain - tls: {} - - # Dockmon (dev access (account required)) - dockmon-dev: - rule: "Host(`dockmon.gigaforust`)" - entryPoints: - - websecure - service: dockmon - middlewares: - - security-headers - tls: {} - - # Gitea (dev access (account required)) - gitea-dev: - rule: "Host(`gitea.gigaforust`)" - entryPoints: - - websecure - - ssh - service: gitea - middlewares: - - security-headers - tls: {} - - # Glance (dev access, local only) - glance-dev: - rule: "Host(`glance.gigaforust`)" - entryPoints: - - websecure - service: glance - middlewares: - - security-headers - tls: {} - - # Watercrawl (dev access, local only) - watercrawl-dev: - rule: "Host(`watercrawl.gigaforust`)" - entryPoints: - - websecure - service: watercrawl - middlewares: - - security-headers - tls: {} - - # N8N (local only) - n8n-dev: - rule: "Host(`n8n.gigaforust`)" - entryPoints: - - websecure - service: n8n - middlewares: - - security-headers - tls: {} - - # Landing Page (public) - forust-homepage-dev: - rule: "Host(`landing.gigaforust`)" - entryPoints: - - websecure - service: forust-homepage - middlewares: - - security-headers - tls: {} - - # Penpot - penpot-dev: - rule: "Host(`penpot.gigaforust`)" - entryPoints: - - websecure - - web - service: penpot - middlewares: - - security-headers - tls: {} diff --git a/traefik/dynamic/routers-local.yml.bak b/traefik/dynamic/routers-local.yml.bak deleted file mode 100644 index c01d51c..0000000 --- a/traefik/dynamic/routers-local.yml.bak +++ /dev/null @@ -1,152 +0,0 @@ -http: - routers: - # Traefik Dashboard (local access, ADMIN, local only) - traefik-dashboard-local: - rule: "Host(`traefik.workstation`)" - entryPoints: - - websecure - service: api@internal - middlewares: - # - auth - - security-headers - tls: {} - - # Nextcloud AIO Interface (local access. ADMIN, local only) - nextcloud-aio-local: - rule: "Host(`nextcloud-aio.workstation`)" - entryPoints: - - websecure - service: nextcloud-aio - middlewares: - - security-headers - tls: {} - - # Termix (local access, (account required)) - termix-local: - rule: "Host(`termix.workstation`)" - entryPoints: - - websecure - service: termix - middlewares: - - security-headers - tls: {} - - # MeTube (local access, (auth required)) - metube-local: - rule: "Host(`metube.workstation`)" - entryPoints: - - websecure - service: metube - middlewares: - - metube-auth - - security-headers - tls: {} - - # Portainer (dev acess, (account required)) - portainer-local: - rule: "Host(`portainer.workstation`)" - entryPoints: - - websecure - service: portainer - middlewares: - - security-headers - tls: {} - - # Uptime Kuma (local access, (account required)) - uptime-kuma-local: - rule: "Host(`uptime.workstation`)" - entryPoints: - - websecure - service: uptime-kuma - tls: {} - - # AdGuard Home (local access, (account required)) - adguard-local: - rule: "Host(`adguard.workstation`)" - entryPoints: - - websecure - service: adguard - middlewares: - - security-headers - tls: {} - - # Nextcloud Main (public (account required)) - nextcloud-local: - rule: "Host(`nextcloud.workstation`)" - entrypoints: - - websecure - service: nextcloud - middlewares: - - nextcloud-chain - tls: {} - - # Nextcloud Main (public (account required)) - penpot-local: - rule: "Host(`penpot.workstation`)" - entrypoints: - - websecure - service: penpot - middlewares: - - security-headers - tls: {} - - # Dockmon (local access (account required)) - dockmon-local: - rule: "Host(`dockmon.workstation`)" - entryPoints: - - websecure - service: dockmon - middlewares: - - security-headers - tls: {} - - # Gitea (local access (account required)) - gitea-local: - rule: "Host(`gitea.workstation`)" - entryPoints: - - websecure - - ssh - service: gitea - middlewares: - - security-headers - tls: {} - - # Glance (local access) - glance-local: - rule: "Host(`glance.workstation`)" - entryPoints: - - websecure - service: glance - middlewares: - - security-headers - tls: {} - - # Watercrawl (local access) - watercrawl-local: - rule: "Host(`watercrawl.workstation`)" - entryPoints: - - websecure - service: watercrawl - middlewares: - - security-headers - tls: {} - - # N8N (local only) - n8n-local: - rule: "Host(`n8n.workstation`)" - entryPoints: - - websecure - service: n8n - middlewares: - - security-headers - tls: {} - - # Landing Page (local access) - forust-homepage-local: - rule: "Host(`landing.workstation`)" - entryPoints: - - websecure - service: forust-homepage - middlewares: - - security-headers - tls: {} diff --git a/traefik/dynamic/routers-prod.yml.bak b/traefik/dynamic/routers-prod.yml.bak deleted file mode 100644 index ff9cd44..0000000 --- a/traefik/dynamic/routers-prod.yml.bak +++ /dev/null @@ -1,152 +0,0 @@ -http: - routers: - # Traefik Dashboard (ADMIN, without subdomeain) - traefik-dashboard: - rule: "Host(`traefik.forust.xyz`)" - entryPoints: - - websecure - service: api@internal - middlewares: - - auth - - security-headers - tls: {} - - # Nextcloud AIO Interface (ADMIN, without subdomeain) - nextcloud-aio: - rule: "Host(`nextcloud-aio.forust.xyz`)" - entryPoints: - - websecure - service: nextcloud-aio - middlewares: - - security-headers - tls: {} - - # Nextcloud AIO Interface (ADMIN, without subdomeain) - penpot: - rule: "Host(`penpot.forust.xyz`)" - entryPoints: - - websecure - service: penpot - middlewares: - - security-headers - tls: {} - - # Termix (public (account required)) - termix: - rule: "Host(`termix.forust.xyz`)" - entryPoints: - - websecure - service: termix - middlewares: - - security-headers - tls: {} - - # MeTube (public (auth required)) - metube: - rule: "Host(`metube.forust.xyz`)" - entryPoints: - - websecure - service: metube - middlewares: - - metube-auth - - security-headers - tls: {} - - # Portainer (public (account required)) - portainer: - rule: "Host(`portainer.forust.xyz`)" - entryPoints: - - websecure - service: portainer - middlewares: - - security-headers - tls: {} - - # Uptime Kuma (public (account required)) - uptime-kuma: - rule: "Host(`uptime.forust.xyz`)" - entryPoints: - - websecure - service: uptime-kuma - tls: {} - - # AdGuard Home (public (account required)) - adguard: - rule: "Host(`adguard.forust.xyz`)" - entryPoints: - - websecure - service: adguard - middlewares: - - security-headers - tls: {} - - # Nextcloud Main (public (account required)) - nextcloud: - rule: "Host(`nextcloud.forust.xyz`)" - entrypoints: - - websecure - service: nextcloud - middlewares: - - nextcloud-chain - tls: {} - - # Dockmon (public (account required)) - dockmon: - rule: "Host(`dockmon.forust.xyz`)" - entryPoints: - - websecure - service: dockmon - middlewares: - - security-headers - tls: {} - - # Gitea (public (account required)) - gitea: - rule: "Host(`gitea.forust.xyz`)" - entryPoints: - - websecure - - ssh - service: gitea - middlewares: - - security-headers - tls: {} - - # Glance (public, without subdomain) - glance: - rule: "Host(`glance.forust.xyz`)" - entryPoints: - - websecure - service: glance - middlewares: - - security-headers - tls: {} - - # Watercrawl (public, without subdomain) - watercrawl: - rule: "Host(`watercrawl.fourst.xyz`)" - entryPoints: - - websecure - service: watercrawl - middlewares: - - security-headers - tls: {} - - # N8N (public, without subdomain) - n8n: - rule: "Host(`n8n.forust.xyz`)" - entryPoints: - - websecure - service: n8n - middlewares: - - security-headers - tls: {} - - # Landing Page (public) - forust-homepage: - rule: "Host(`forust.xyz`)" - entryPoints: - - websecure - service: forust-homepage - middlewares: - - security-headers - tls: {} diff --git a/traefik/dynamic/services.yml.bak b/traefik/dynamic/services.yml.bak deleted file mode 100644 index 7200893..0000000 --- a/traefik/dynamic/services.yml.bak +++ /dev/null @@ -1,86 +0,0 @@ -http: - services: - # Portainer - portainer: - loadBalancer: - servers: - - url: "https://portainer:9443" - serversTransport: insecureTransport - - # AdGuard Home - adguard: - loadBalancer: - servers: - - url: "http://adguardhome:3000" - - # Nextcloud AIO Interface - nextcloud-aio: - loadBalancer: - servers: - - url: "https://nextcloud-aio-mastercontainer:8080" - serversTransport: insecureTransport - - # Penpot - penpot: - loadBalancer: - servers: - - url: "http://penpot-frontend:8080" - - # Nextcloud Main - nextcloud: - loadBalancer: - servers: - - url: "http://nextcloud-aio-apache:11000" - - # Uptime Kuma - uptime-kuma: - loadBalancer: - servers: - - url: "http://uptime-kuma:3001" - - # Termix - termix: - loadBalancer: - servers: - - url: "http://termix:8080" - - # Dockmon - dockmon: - loadBalancer: - servers: - - url: "https://dockmon:443" - serversTransport: insecureTransport - - # Glance - glance: - loadBalancer: - servers: - - url: "http://glance:8080" - - # Watercrawl - watercrawl: - loadBalancer: - servers: - - url: "http://nginx:80" - - # N8N - n8n: - loadBalancer: - servers: - - url: "http://n8n:5678" - - # Gitea - gitea: - loadBalancer: - servers: - - url: "http://gitea:3000" - # MeTube - metube: - loadBalancer: - servers: - - url: "http://metube:8081" - # Landing Page - forust-homepage: - loadBalancer: - servers: - - url: "http://forust-homepage:80" \ No newline at end of file From 504cbc81a06297a6eefa229ac648069a5aeb875e Mon Sep 17 00:00:00 2001 From: mr-forust Date: Sun, 7 Dec 2025 02:16:59 +0100 Subject: [PATCH 33/89] fix: nextcloud traefik router parse error --- traefik/dynamic/nextcloud-apache.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/traefik/dynamic/nextcloud-apache.yml b/traefik/dynamic/nextcloud-apache.yml index a0d69de..e7d38e3 100644 --- a/traefik/dynamic/nextcloud-apache.yml +++ b/traefik/dynamic/nextcloud-apache.yml @@ -10,7 +10,6 @@ http: - nextcloud-chain tls: {} - # Nextcloud dev nextcloud-local: rule: "Host(`nextcloud.workstation.internal`)" @@ -21,7 +20,6 @@ http: middlewares: - nextcloud-chain tls: {} -w # Nextcloud dev nextcloud-dev: From 3bfcd6edf49b90f41f754d2b0495b0112f192fe2 Mon Sep 17 00:00:00 2001 From: mr-forust Date: Sun, 7 Dec 2025 02:17:19 +0100 Subject: [PATCH 34/89] feat: add xdfnx's landing site --- .gitignore | 3 +- homepage/compose.yaml | 39 - homepages/Dockerfile.forust | 10 + .../Dockerfile => homepages/Dockerfile.xdfnx | 2 +- homepages/compose.yaml | 83 ++ .../forust_files}/assets/css/style.css | 0 .../forust_files}/index.html | 0 .../forust_files}/miku.html | 0 homepages/xdfnx_files/index.html | 1142 +++++++++++++++++ 9 files changed, 1238 insertions(+), 41 deletions(-) delete mode 100644 homepage/compose.yaml create mode 100644 homepages/Dockerfile.forust rename homepage/Dockerfile => homepages/Dockerfile.xdfnx (75%) create mode 100644 homepages/compose.yaml rename {homepage/files => homepages/forust_files}/assets/css/style.css (100%) rename {homepage/files => homepages/forust_files}/index.html (100%) rename {homepage/files => homepages/forust_files}/miku.html (100%) create mode 100644 homepages/xdfnx_files/index.html diff --git a/.gitignore b/.gitignore index f84f396..3de6b68 100644 --- a/.gitignore +++ b/.gitignore @@ -32,7 +32,8 @@ streaming/qbittorrent/* streaming/prowlarr/* # Homepage -homepage/files/assets/images/team/* +homepages/forust_files/assets/images/team/* + # Traefik files traefik/letsencrypt/acme.json diff --git a/homepage/compose.yaml b/homepage/compose.yaml deleted file mode 100644 index 453fe6a..0000000 --- a/homepage/compose.yaml +++ /dev/null @@ -1,39 +0,0 @@ -services: - forust-homepage: - build: . - ports: - - "8085:80" - restart: unless-stopped - volumes: - - ./files:/usr/share/nginx/html - networks: - - traefik-proxy - labels: - - "traefik.enable=true" - - "traefik.docker.network=traefik-proxy" - - # Prod Router - - "traefik.http.routers.forust-homepage.rule=Host(`forust.xyz`)" - - "traefik.http.routers.forust-homepage.entrypoints=websecure" - - "traefik.http.routers.forust-homepage.middlewares=security-headers@file" - - "traefik.http.routers.forust-homepage.service=forust-homepage" - - "traefik.http.routers.forust-homepage.tls=true" - - "traefik.http.services.forust-homepage.loadbalancer.server.port=80" - - # Local Router - - "traefik.http.routers.forust-homepage-local.rule=Host(`landing.workstation.internal`) || Host(`landing.internal`)" - - "traefik.http.routers.forust-homepage-local.entrypoints=websecure" - - "traefik.http.routers.forust-homepage-local.middlewares=security-headers@file" - - "traefik.http.routers.forust-homepage-local.service=forust-homepage" - - "traefik.http.routers.forust-homepage-local.tls=true" - - # Dev Router - - "traefik.http.routers.forust-homepage-dev.rule=Host(`landing.gigaforust.internal`)" - - "traefik.http.routers.forust-homepage-dev.entrypoints=websecure" - - "traefik.http.routers.forust-homepage-dev.middlewares=security-headers@file" - - "traefik.http.routers.forust-homepage-dev.service=forust-homepage" - - "traefik.http.routers.forust-homepage-dev.tls=true" - -networks: - traefik-proxy: - external: true diff --git a/homepages/Dockerfile.forust b/homepages/Dockerfile.forust new file mode 100644 index 0000000..1ad49a9 --- /dev/null +++ b/homepages/Dockerfile.forust @@ -0,0 +1,10 @@ +# everyone use that +FROM nginx:alpine + +RUN rm -rf /usr/share/nginx/html/* + +COPY ./forust_files /usr/share/nginx/html + +EXPOSE 80 +# Start +CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/homepage/Dockerfile b/homepages/Dockerfile.xdfnx similarity index 75% rename from homepage/Dockerfile rename to homepages/Dockerfile.xdfnx index 51b2d93..e788c02 100644 --- a/homepage/Dockerfile +++ b/homepages/Dockerfile.xdfnx @@ -3,7 +3,7 @@ FROM nginx:alpine RUN rm -rf /usr/share/nginx/html/* -COPY ./files /usr/share/nginx/html +COPY ./xdfnx_files /usr/share/nginx/html EXPOSE 80 # Start diff --git a/homepages/compose.yaml b/homepages/compose.yaml new file mode 100644 index 0000000..6a246a4 --- /dev/null +++ b/homepages/compose.yaml @@ -0,0 +1,83 @@ +services: + forust: + build: + context: . + dockerfile: Dockerfile.forust + ports: + - "8085:80" + restart: unless-stopped + volumes: + - ./forust_files:/usr/share/nginx/html + networks: + - traefik-proxy + labels: + - "traefik.enable=true" + - "traefik.docker.network=traefik-proxy" + + # Services + - "traefik.http.services.forust-homepage.loadbalancer.server.port=80" + + # Prod Router + - "traefik.http.routers.forust-homepage.rule=Host(`forust.xyz`)" + - "traefik.http.routers.forust-homepage.entrypoints=websecure" + - "traefik.http.routers.forust-homepage.middlewares=security-headers@file" + - "traefik.http.routers.forust-homepage.service=forust-homepage" + - "traefik.http.routers.forust-homepage.tls=true" + + # Local Router + - "traefik.http.routers.forust-homepage-local.rule=Host(`landing.workstation.internal`) || Host(`landing.internal`)" + - "traefik.http.routers.forust-homepage-local.entrypoints=websecure" + - "traefik.http.routers.forust-homepage-local.middlewares=security-headers@file" + - "traefik.http.routers.forust-homepage-local.service=forust-homepage" + - "traefik.http.routers.forust-homepage-local.tls=true" + + # Dev Router + - "traefik.http.routers.forust-homepage-dev.rule=Host(`landing.gigaforust.internal`)" + - "traefik.http.routers.forust-homepage-dev.entrypoints=websecure" + - "traefik.http.routers.forust-homepage-dev.middlewares=security-headers@file" + - "traefik.http.routers.forust-homepage-dev.service=forust-homepage" + - "traefik.http.routers.forust-homepage-dev.tls=true" + + xdfnx: + build: + context: . + dockerfile: Dockerfile.xdfnx + ports: + - "8086:80" + restart: unless-stopped + volumes: + - ./xdfnx_files:/usr/share/nginx/html + networks: + - traefik-proxy + labels: + - "traefik.enable=true" + - "traefik.docker.network=traefik-proxy" + + # Services + - "traefik.http.services.xdfnx-homepage.loadbalancer.server.port=80" + + # Prod Router + - "traefik.http.routers.xdfnx.rule=Host(`xdfnx.cfd`)" + - "traefik.http.routers.xdfnx.entrypoints=websecure" + - "traefik.http.routers.xdfnx.middlewares=security-headers@file" + - "traefik.http.routers.xdfnx.service=xdfnx-homepage" + - "traefik.http.routers.xdfnx.tls=true" + + # Local Router + - "traefik.http.routers.xdfnx-local.rule=Host(`xdfnx.workstation.internal`) || Host(`xdfnx.internal`)" + - "traefik.http.routers.xdfnx-local.entrypoints=websecure" + - "traefik.http.routers.xdfnx-local.middlewares=security-headers@file" + - "traefik.http.routers.xdfnx-local.service=xdfnx-homepage" + - "traefik.http.routers.xdfnx-local.tls=true" + + # Dev Router + - "traefik.http.routers.xdfnx-dev.rule=Host(`xdfnx.gigaforust.internal`)" + - "traefik.http.routers.xdfnx-dev.entrypoints=websecure" + - "traefik.http.routers.xdfnx-dev.middlewares=security-headers@file" + - "traefik.http.routers.xdfnx-dev.service=xdfnx-homepage" + - "traefik.http.routers.xdfnx-dev.tls=true" + + +networks: + traefik-proxy: + external: true diff --git a/homepage/files/assets/css/style.css b/homepages/forust_files/assets/css/style.css similarity index 100% rename from homepage/files/assets/css/style.css rename to homepages/forust_files/assets/css/style.css diff --git a/homepage/files/index.html b/homepages/forust_files/index.html similarity index 100% rename from homepage/files/index.html rename to homepages/forust_files/index.html diff --git a/homepage/files/miku.html b/homepages/forust_files/miku.html similarity index 100% rename from homepage/files/miku.html rename to homepages/forust_files/miku.html diff --git a/homepages/xdfnx_files/index.html b/homepages/xdfnx_files/index.html new file mode 100644 index 0000000..8f623bd --- /dev/null +++ b/homepages/xdfnx_files/index.html @@ -0,0 +1,1142 @@ + + + + + + + xdfnx + + + + + + + +
+
+
+
+ +
+
+ + + +
+ +
+
+
Open for Complex Projects +
+

xdfnx

+

+ Senior Full Stack Engineer with a focus on system performance. + I build scalable web applications and optimize core infrastructure using Rust & + C++. +

+ + + +
+
+ 6+ + Years Experience +
+
+ 20+ + Production Deploys +
+
+ 100% + Performance Focused +
+
+
+ +
+

Professional Experience

+
+ +
+

Senior Full Stack Engineer

+ TechFlow Systems + 2025 - Present +

+ Leading the backend migration to microservices. Implemented a high-throughput event processing pipeline + using Python and Rust, reducing latency by 40%. Oversaw the React frontend + architecture for the main dashboard. +

+
+ +
+

Software Engineer

+ Creative Solutions Ltd. + 2020 - 2023 +

+ Developed full-stack web applications using Node.js and TypeScript. Integrated native C++ + modules for image processing tasks, speeding up user workflows by 3x. +

+
+ +
+
+ +
+

Technical Arsenal

+
+ +
+
Modern Frontend
+
+ Frontend +
+

+ Building responsive, type-safe interfaces with a focus on UX and accessibility. +

+
+ +
+
Backend & Infra
+
+ Backend +
+

+ Scalable architectures using Node & Python. Dockerized deployments and cloud infrastructure. +

+
+ +
+
Systems & Low Level
+
+ Systems +
+

+ When JS isn't fast enough. Writing memory-safe, high-performance modules for critical paths. +

+
+ +
+
+ +
+

Selected Work

+
+ +
+
+

Fintech Analytics Engine

+
+

+ A real-time dashboard for financial data visualization. The backend aggregates streams from multiple sources + using a custom Rust service for zero-cost abstraction performance. +

+
+ Next.js + Rust + WebSockets +
+
+ +
+
+

AI Data Pipeline

+
+

+ Automated ETL pipeline processing terabytes of data. Written in Python for flexibility with + C++ bindings for heavy computational steps. +

+
+ Python + C++ + Docker +
+
+ +
+
+

Distributed Task Queue

+
+

+ Fault-tolerant job scheduler inspired by Celery but optimized for low-memory environments. +

+
+ Node.js + Redis + System Design +
+
+ +
+
+ +
+

The Team

+
+ + +
+
C
+

chernuha

+ chernuha.space +
+ + +
+
M
+

MrForust

+ forust.xyz +
+ + +
+
H
+

hudan

+ hudan.xyz +
+ +
+
+ +
+

Ready to scale?

+

Let's discuss how I can help your team build faster and better.

+ contact@xdfnx.cfd +

© 2025 xdfnx. Engineering + Excellence. +

+
+ +
+ + + + + \ No newline at end of file From c31369f2b7cee7ad6b28284daa264450202193bc Mon Sep 17 00:00:00 2001 From: mr-forust Date: Sun, 7 Dec 2025 02:28:55 +0100 Subject: [PATCH 35/89] add certfiles for xdfnx landing --- traefik/dynamic/tls.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/traefik/dynamic/tls.yml b/traefik/dynamic/tls.yml index 9416a04..cae87e2 100644 --- a/traefik/dynamic/tls.yml +++ b/traefik/dynamic/tls.yml @@ -4,7 +4,8 @@ tls: # Cloudflare Origin CA *.forust.xyz - certFile: /certs/cloudflare.pem keyFile: /certs/cloudflare.key - + - certFile: /certs/xdfnx.pem + keyFile: /certs/xdfnx.key stores: default: defaultCertificate: From a07e27bff6502c8504d96d6e7262993fee1bf4ab Mon Sep 17 00:00:00 2001 From: mr-forust Date: Sun, 7 Dec 2025 02:44:12 +0100 Subject: [PATCH 36/89] feat: checkmk service --- checkmk/compose.yaml | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 checkmk/compose.yaml diff --git a/checkmk/compose.yaml b/checkmk/compose.yaml new file mode 100644 index 0000000..b4d562d --- /dev/null +++ b/checkmk/compose.yaml @@ -0,0 +1,41 @@ +services: + checkmk: + image: checkmk/check-mk-raw:2.3.0-latest + container_name: checkmk + restart: unless-stopped + ports: + - "5000:5000" # UI + - "6556:6556" # AGENT + + volumes: + - ./checkmk:/omd/sites + + environment: + CMK_SITE_ID: "cmk" + + labels: + - "traefik.enable=true" + - "traefik.docker.network=traefik-proxy" + + # Prod Router + - "traefik.http.routers.checkmk.rule=Host(`checkmk.forust.xyz`)" + - "traefik.http.routers.checkmk.entrypoints=websecure" + - "traefik.http.routers.checkmk.tls=true" + - "traefik.http.services.checkmk.loadbalancer.server.port=5000" + + # Local Router + - "traefik.http.routers.checkmk-local.rule=Host(`checkmk.workstation.internal`) || Host(`checkmk.internal`)" + - "traefik.http.routers.checkmk-local.entrypoints=websecure" + - "traefik.http.routers.checkmk-local.tls=true" + + # Dev Router + - "traefik.http.routers.checkmk-dev.rule=Host(`checkmk.gigaforust.internal`)" + - "traefik.http.routers.checkmk-dev.entrypoints=websecure" + - "traefik.http.routers.checkmk-dev.tls=true" + + networks: + - traefik-proxy + +networks: + traefik-proxy: + external: true From 9f784d2c316ae1dc9c8d2eed864f4e3b3266b880 Mon Sep 17 00:00:00 2001 From: mr-forust Date: Sun, 7 Dec 2025 02:59:18 +0100 Subject: [PATCH 37/89] chore: gitignore checkmk's files --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 3de6b68..592cc49 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ metube/MeTube_downloads uptime-kuma/data/ termix/termix-data/* cfddns/config.json +checkmk/checkmk/* # Steaming services files streaming/jellyfin/* From 4f9e7ea9908b62b2d6efcc5ab6d2423ec6e033b2 Mon Sep 17 00:00:00 2001 From: mr-forust Date: Sun, 7 Dec 2025 03:51:43 +0100 Subject: [PATCH 38/89] feat: nextcloud beta --- nextcloud/compose.yaml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/nextcloud/compose.yaml b/nextcloud/compose.yaml index 0d0082d..e9bf811 100644 --- a/nextcloud/compose.yaml +++ b/nextcloud/compose.yaml @@ -1,6 +1,6 @@ services: nextcloud-aio-mastercontainer: - image: ghcr.io/nextcloud-releases/all-in-one:latest + image: ghcr.io/nextcloud-releases/all-in-one:beta init: true # This setting makes sure that signals from main process inside the container are correctly forwarded to children. See https://docs.docker.com/reference/compose-file/services/#init restart: unless-stopped container_name: nextcloud-aio-mastercontainer # Do not change @@ -10,7 +10,6 @@ services: networks: - nextcloud-aio - traefik-proxy # Optional: Connects the mastercontainer to the traefik-proxy network in order to make the built-in reverse proxy detection work. See https://github.com/nextcloud/all-in-one/blob/main/reverse-proxy.md - # ports: # - 8081:80 # may be removed if under reverse-proxy # - 8443:8443 From 9c5e037567e3789d1938c0d6e55d903a8abee4fe Mon Sep 17 00:00:00 2001 From: mr-forust Date: Sun, 7 Dec 2025 04:10:32 +0100 Subject: [PATCH 39/89] refactor: switch to official checkmk dockercompose --- checkmk/.env.example | 2 ++ checkmk/compose.yaml | 29 +++++++++++++++++------------ 2 files changed, 19 insertions(+), 12 deletions(-) create mode 100644 checkmk/.env.example diff --git a/checkmk/.env.example b/checkmk/.env.example new file mode 100644 index 0000000..a57b7d2 --- /dev/null +++ b/checkmk/.env.example @@ -0,0 +1,2 @@ +CMK_PASSWORD=password +TZ=Europe/Berlin \ No newline at end of file diff --git a/checkmk/compose.yaml b/checkmk/compose.yaml index b4d562d..4d8e69d 100644 --- a/checkmk/compose.yaml +++ b/checkmk/compose.yaml @@ -1,18 +1,21 @@ services: checkmk: - image: checkmk/check-mk-raw:2.3.0-latest - container_name: checkmk - restart: unless-stopped - ports: - - "5000:5000" # UI - - "6556:6556" # AGENT + image: "checkmk/check-mk-raw:2.4.0-latest" + container_name: "monitoring" + environment: + - CMK_PASSWORD=${CMK_PASSWORD:-password} + - TZ=${TZ:-Europe/Moscow} + - CMK_SITE_ID=cmk volumes: - - ./checkmk:/omd/sites - - environment: - CMK_SITE_ID: "cmk" - + - checkmk:/omd/sites + tmpfs: + - /opt/omd/sites/cmk/tmp:uid=1000,gid=1000 + ports: + - 5000:5000 + - 6776:8000 + - 6556:6556 + restart: always labels: - "traefik.enable=true" - "traefik.docker.network=traefik-proxy" @@ -36,6 +39,8 @@ services: networks: - traefik-proxy +volumes: + checkmk: networks: traefik-proxy: - external: true + external: true \ No newline at end of file From 1c75382a6eacfc6bf15617d565f3565915d07ad0 Mon Sep 17 00:00:00 2001 From: mr-forust Date: Sun, 7 Dec 2025 04:14:56 +0100 Subject: [PATCH 40/89] fix: replace container_name to avoid misunderstandings --- checkmk/compose.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/checkmk/compose.yaml b/checkmk/compose.yaml index 4d8e69d..da2c8bd 100644 --- a/checkmk/compose.yaml +++ b/checkmk/compose.yaml @@ -1,7 +1,7 @@ services: checkmk: image: "checkmk/check-mk-raw:2.4.0-latest" - container_name: "monitoring" + container_name: "checkmk" environment: - CMK_PASSWORD=${CMK_PASSWORD:-password} - TZ=${TZ:-Europe/Moscow} From bfb21adff74a085623af6ccacfb95e6b7f7816ae Mon Sep 17 00:00:00 2001 From: mr-forust Date: Sun, 7 Dec 2025 22:06:30 +0100 Subject: [PATCH 41/89] using local directory for storing chkmk data --- checkmk/compose.yaml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/checkmk/compose.yaml b/checkmk/compose.yaml index da2c8bd..512474f 100644 --- a/checkmk/compose.yaml +++ b/checkmk/compose.yaml @@ -8,11 +8,11 @@ services: - CMK_SITE_ID=cmk volumes: - - checkmk:/omd/sites + - ./checkmk:/omd/sites tmpfs: - /opt/omd/sites/cmk/tmp:uid=1000,gid=1000 ports: - - 5000:5000 + - 5555:5000 - 6776:8000 - 6556:6556 restart: always @@ -39,8 +39,6 @@ services: networks: - traefik-proxy -volumes: - checkmk: networks: traefik-proxy: external: true \ No newline at end of file From be049cfa0da9e0fb63f45d9aab65ec25b2cb747f Mon Sep 17 00:00:00 2001 From: mr-forust Date: Sun, 7 Dec 2025 02:59:18 +0100 Subject: [PATCH 42/89] chore: gitignore checkmk's files --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 3de6b68..592cc49 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ metube/MeTube_downloads uptime-kuma/data/ termix/termix-data/* cfddns/config.json +checkmk/checkmk/* # Steaming services files streaming/jellyfin/* From f3b73bae11a9a004af4228b99a6b690aec338dee Mon Sep 17 00:00:00 2001 From: mr-forust Date: Mon, 8 Dec 2025 01:29:00 +0100 Subject: [PATCH 43/89] feat: authentik service+envfile for traefik and authentik --- traefik/.env.example | 22 +++++++++++++ traefik/compose.yaml | 75 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 traefik/.env.example diff --git a/traefik/.env.example b/traefik/.env.example new file mode 100644 index 0000000..45a90cb --- /dev/null +++ b/traefik/.env.example @@ -0,0 +1,22 @@ +# =================================== +# Traefik envs +EMAIL=bobrovod@national.shitposting.agency + +# =================================== +# Authentification app (authentik) + +# PostgresQL conf +PG_PASS=change_this_cuz_its_ur_db_pass +PG_USER=authentik # it's okay + +# Image Settings +AUTHENTIK_IMAGE=ghcr.io/goauthentik/server +AUTHENTIK_TAG=2025.10.2 + +# Networking +PORT_HTTP=9000 +PORT_HTTPS=9443 # btw likely already used by portainer + +AUTHENTIK_SECRET_KEY=super_secret_super_scary_authenik_key + +AUTHENTIK_ERROR_REPORTING__ENABLED=true # Or false to turn off \ No newline at end of file diff --git a/traefik/compose.yaml b/traefik/compose.yaml index 68f57dc..1998e1d 100644 --- a/traefik/compose.yaml +++ b/traefik/compose.yaml @@ -86,6 +86,81 @@ services: environment: - TZ=Europe/Bratislava + postgresql: + image: docker.io/library/postgres:15 + restart: unless-stopped + container_name: authentik-postgresql + env_file: + - .env + environment: + POSTGRES_DB: ${PG_DB:-authentik} + POSTGRES_PASSWORD: ${PG_PASS:?database password required} + POSTGRES_USER: ${PG_USER:-authentik} + volumes: + - auth-database:/var/lib/postgresql/data + healthcheck: + interval: 30s + retries: 5 + start_period: 20s + test: + - CMD-SHELL + - pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER} + timeout: 5s + + server: + image: ghcr.io/goauthentik/server:${AUTHENTIK_TAG:-2025.10.2} + restart: unless-stopped + container_name: authentik-server + command: server + ports: + - ${PORT_HTTP:-9000}:9000 + - ${PORT_HTTPS:-9443}:9443 + env_file: + - .env + environment: + AUTHENTIK_POSTGRESQL__HOST: postgresql + AUTHENTIK_POSTGRESQL__NAME: ${PG_DB:-authentik} + AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS} + AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik} + AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?secret key required} + volumes: + - ./auth-media:/media + - ./auth-custom-templates:/templates + networks: + - traefik-proxy + depends_on: + postgresql: + condition: service_healthy + + worker: + image: ghcr.io/goauthentik/server:${AUTHENTIK_TAG:-2025.10.2} + restart: unless-stopped + container_name: authentik-worker + command: worker + user: root + env_file: + - .env + environment: + AUTHENTIK_POSTGRESQL__HOST: postgresql + AUTHENTIK_POSTGRESQL__NAME: ${PG_DB:-authentik} + AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS} + AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik} + AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?secret key required} + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + - ./auth-media:/media + - ./certs:/certs + - ./auth-custom-templates:/templates + depends_on: + postgresql: + condition: service_healthy + networks: + - traefik-proxy + +volumes: + auth-database: + driver: local + networks: traefik-proxy: external: true From aed28ed15c51721ed016d9c717a5c670ef8f3866 Mon Sep 17 00:00:00 2001 From: mr-forust Date: Mon, 8 Dec 2025 13:30:32 +0100 Subject: [PATCH 44/89] refactor: sepparate traefik and authentik --- authentik/.env.example | 20 ++++++++ authentik/compose.yaml | 105 +++++++++++++++++++++++++++++++++++++++++ traefik/.env.example | 19 -------- traefik/compose.yaml | 75 ----------------------------- 4 files changed, 125 insertions(+), 94 deletions(-) create mode 100644 authentik/.env.example create mode 100644 authentik/compose.yaml diff --git a/authentik/.env.example b/authentik/.env.example new file mode 100644 index 0000000..a1222c4 --- /dev/null +++ b/authentik/.env.example @@ -0,0 +1,20 @@ +# =================================== +# Authentification app (authentik) + +# PostgresQL conf +PG_PASS=change_this_cuz_its_ur_db_pass +PG_USER=authentik # it's okay + +# Image Settings +AUTHENTIK_IMAGE=ghcr.io/goauthentik/server +AUTHENTIK_TAG=2025.10.2 + +# Networking +PORT_HTTP=9000 +PORT_HTTPS=9443 # btw likely already used by portainer + +AUTHENTIK_SECRET_KEY=super_secret_super_scary_authenik_key + +AUTHENTIK_BOOTSTRAP_PASSWORD=pls_change_this + +AUTHENTIK_ERROR_REPORTING__ENABLED=true # Or false to turn off \ No newline at end of file diff --git a/authentik/compose.yaml b/authentik/compose.yaml new file mode 100644 index 0000000..fa3c125 --- /dev/null +++ b/authentik/compose.yaml @@ -0,0 +1,105 @@ +services: + postgresql: + image: docker.io/library/postgres:15-alpine + restart: unless-stopped + env_file: + - .env + environment: + POSTGRES_DB: ${PG_DB:-authentik} + POSTGRES_PASSWORD: ${PG_PASS:?database password required} + POSTGRES_USER: ${PG_USER:-authentik} + healthcheck: + interval: 30s + retries: 5 + start_period: 20s + test: + - CMD-SHELL + - pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER} + timeout: 5s + volumes: + - database:/var/lib/postgresql/data + networks: + - authentik + + server: + image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.10.2} + command: server + restart: unless-stopped + ports: + - ${PORT_HTTP:-9000}:9000 + - ${PORT_HTTPS:-9443}:9443 + env_file: + - .env + environment: + AUTHENTIK_POSTGRESQL__HOST: postgresql + AUTHENTIK_POSTGRESQL__NAME: ${PG_DB:-authentik} + AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS} + AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik} + AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?secret key required} + + labels: + - "traefik.enable=true" + - "traefik.docker.network=traefik-proxy" + # Services + # - "traefik.http.services.authentik-server.loadbalancer.server.port=9443" + - "traefik.http.services.authentik-server.loadbalancer.server.port=9000" + + # Prod Router + - "traefik.http.routers.authentik-server.rule=Host(`auth.forust.xyz`)" + - "traefik.http.routers.authentik-server.entrypoints=websecure" + - "traefik.http.routers.authentik-server.middlewares=security-headers@file" + - "traefik.http.routers.authentik-server.service=authentik-server" + - "traefik.http.routers.authentik-server.tls=true" + + # Local Router + - "traefik.http.routers.authentik-server-local.rule=Host(`auth.workstation.internal`) || Host(`auth-dashboard.internal`)" + - "traefik.http.routers.authentik-server-local.entrypoints=websecure" + - "traefik.http.routers.authentik-server-local.middlewares=security-headers@file" + - "traefik.http.routers.authentik-server-local.service=authentik-server" + - "traefik.http.routers.authentik-server-local.tls=true" + + # Dev Router + - "traefik.http.routers.authentik-server-dev.rule=Host(`auth.gigaforust.internal`)" + - "traefik.http.routers.authentik-server-dev.entrypoints=websecure" + - "traefik.http.routers.authentik-server-dev.middlewares=security-headers@file" + - "traefik.http.routers.authentik-server-dev.service=authentik-server" + - "traefik.http.routers.authentik-server-dev.tls=true" + volumes: + - ./media:/media + - ./custom-templates:/templates + networks: + - traefik-proxy + - authentik + depends_on: + postgresql: + condition: service_healthy + worker: + image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.10.2} + restart: unless-stopped + user: root + command: worker + env_file: + - .env + environment: + AUTHENTIK_POSTGRESQL__HOST: postgresql + AUTHENTIK_POSTGRESQL__NAME: ${PG_DB:-authentik} + AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS} + AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik} + AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?secret key required} + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - ./media:/media + - ./certs:/certs + - ./custom-templates:/templates + networks: + - authentik + depends_on: + postgresql: + condition: service_healthy +volumes: + database: + driver: local +networks: + authentik: + traefik-proxy: + external: true diff --git a/traefik/.env.example b/traefik/.env.example index 45a90cb..cf72c84 100644 --- a/traefik/.env.example +++ b/traefik/.env.example @@ -1,22 +1,3 @@ # =================================== # Traefik envs EMAIL=bobrovod@national.shitposting.agency - -# =================================== -# Authentification app (authentik) - -# PostgresQL conf -PG_PASS=change_this_cuz_its_ur_db_pass -PG_USER=authentik # it's okay - -# Image Settings -AUTHENTIK_IMAGE=ghcr.io/goauthentik/server -AUTHENTIK_TAG=2025.10.2 - -# Networking -PORT_HTTP=9000 -PORT_HTTPS=9443 # btw likely already used by portainer - -AUTHENTIK_SECRET_KEY=super_secret_super_scary_authenik_key - -AUTHENTIK_ERROR_REPORTING__ENABLED=true # Or false to turn off \ No newline at end of file diff --git a/traefik/compose.yaml b/traefik/compose.yaml index 1998e1d..68f57dc 100644 --- a/traefik/compose.yaml +++ b/traefik/compose.yaml @@ -86,81 +86,6 @@ services: environment: - TZ=Europe/Bratislava - postgresql: - image: docker.io/library/postgres:15 - restart: unless-stopped - container_name: authentik-postgresql - env_file: - - .env - environment: - POSTGRES_DB: ${PG_DB:-authentik} - POSTGRES_PASSWORD: ${PG_PASS:?database password required} - POSTGRES_USER: ${PG_USER:-authentik} - volumes: - - auth-database:/var/lib/postgresql/data - healthcheck: - interval: 30s - retries: 5 - start_period: 20s - test: - - CMD-SHELL - - pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER} - timeout: 5s - - server: - image: ghcr.io/goauthentik/server:${AUTHENTIK_TAG:-2025.10.2} - restart: unless-stopped - container_name: authentik-server - command: server - ports: - - ${PORT_HTTP:-9000}:9000 - - ${PORT_HTTPS:-9443}:9443 - env_file: - - .env - environment: - AUTHENTIK_POSTGRESQL__HOST: postgresql - AUTHENTIK_POSTGRESQL__NAME: ${PG_DB:-authentik} - AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS} - AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik} - AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?secret key required} - volumes: - - ./auth-media:/media - - ./auth-custom-templates:/templates - networks: - - traefik-proxy - depends_on: - postgresql: - condition: service_healthy - - worker: - image: ghcr.io/goauthentik/server:${AUTHENTIK_TAG:-2025.10.2} - restart: unless-stopped - container_name: authentik-worker - command: worker - user: root - env_file: - - .env - environment: - AUTHENTIK_POSTGRESQL__HOST: postgresql - AUTHENTIK_POSTGRESQL__NAME: ${PG_DB:-authentik} - AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS} - AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik} - AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?secret key required} - volumes: - - /var/run/docker.sock:/var/run/docker.sock:ro - - ./auth-media:/media - - ./certs:/certs - - ./auth-custom-templates:/templates - depends_on: - postgresql: - condition: service_healthy - networks: - - traefik-proxy - -volumes: - auth-database: - driver: local - networks: traefik-proxy: external: true From 0e46193f53b73031a31d8a5d6d2bf957b2c06660 Mon Sep 17 00:00:00 2001 From: mr-forust Date: Mon, 8 Dec 2025 03:58:28 +0100 Subject: [PATCH 45/89] chore: replace sec middlewares with sec chain --- traefik/dynamic/middlewares.yml | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/traefik/dynamic/middlewares.yml b/traefik/dynamic/middlewares.yml index aae708c..0bd02ec 100644 --- a/traefik/dynamic/middlewares.yml +++ b/traefik/dynamic/middlewares.yml @@ -48,6 +48,29 @@ http: - "172.64.0.0/13" - "131.0.72.0/22" + # Authentik + secure headers + security-chain: + chain: + middlewares: + - authentik@file + - security-headers@file + authentik: + forwardAuth: + address: "http://authentik-server:9000/outpost.goauthentik.io/auth/traefik" + trustForwardHeader: true + authResponseHeaders: + - X-authentik-username + - X-authentik-groups + - X-authentik-email + - X-authentik-name + - X-authentik-uid + - X-authentik-jwt + - X-authentik-meta-jwks + - X-authentik-meta-outpost + - X-authentik-meta-provider + - X-authentik-meta-app + - X-authentik-meta-version + # Security Headers security-headers: headers: @@ -82,5 +105,5 @@ http: nextcloud-chain: chain: middlewares: - - nextcloud-secure-headers - - security-headers \ No newline at end of file + - nextcloud-secure-headers@file + - security-headers@file From 586f0e3f7d00f91a8fdea2058c5632df2f7bd05d Mon Sep 17 00:00:00 2001 From: mr-forust Date: Tue, 9 Dec 2025 03:58:36 +0100 Subject: [PATCH 46/89] feat: authenitk-only access for metube --- metube/compose.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/metube/compose.yaml b/metube/compose.yaml index df63607..27e323d 100644 --- a/metube/compose.yaml +++ b/metube/compose.yaml @@ -19,7 +19,7 @@ services: # Prod Router - "traefik.http.routers.metube.rule=Host(`metube.forust.xyz`)" - "traefik.http.routers.metube.entrypoints=websecure" - - "traefik.http.routers.metube.middlewares=security-headers@file,metube-auth@file" + - "traefik.http.routers.metube.middlewares=security-chain@file" - "traefik.http.routers.metube.service=metube" - "traefik.http.routers.metube.tls=true" - "traefik.http.services.metube.loadbalancer.server.port=8081" @@ -27,14 +27,14 @@ services: # Local Router - "traefik.http.routers.metube-local.rule=Host(`metube.workstation.internal`) || Host(`metube.internal`)" - "traefik.http.routers.metube-local.entrypoints=websecure" - - "traefik.http.routers.metube-local.middlewares=security-headers@file,metube-auth@file" + - "traefik.http.routers.metube-local.middlewares=security-headers@file" - "traefik.http.routers.metube-local.service=metube" - "traefik.http.routers.metube-local.tls=true" # Dev Router - "traefik.http.routers.metube-dev.rule=Host(`metube.gigaforust.internal`)" - "traefik.http.routers.metube-dev.entrypoints=websecure" - - "traefik.http.routers.metube-dev.middlewares=security-headers@file,metube-auth@file" + - "traefik.http.routers.metube-dev.middlewares=security-chain@file" - "traefik.http.routers.metube-dev.service=metube" - "traefik.http.routers.metube-dev.tls=true" From 6eb62f41cc5be920cba862daf97afdb6fd3cb71f Mon Sep 17 00:00:00 2001 From: mr-forust Date: Tue, 9 Dec 2025 13:23:48 +0100 Subject: [PATCH 47/89] fix: authentik-traefik dns lookup --- authentik/compose.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/authentik/compose.yaml b/authentik/compose.yaml index fa3c125..537793d 100644 --- a/authentik/compose.yaml +++ b/authentik/compose.yaml @@ -24,6 +24,7 @@ services: server: image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.10.2} command: server + container_name: authentik-server restart: unless-stopped ports: - ${PORT_HTTP:-9000}:9000 From 6bdb16ad21f77ef7fe1215e19341bf219a07223d Mon Sep 17 00:00:00 2001 From: mr-forust Date: Tue, 9 Dec 2025 13:23:59 +0100 Subject: [PATCH 48/89] chore: whitespaces --- traefik/dynamic/middlewares.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/traefik/dynamic/middlewares.yml b/traefik/dynamic/middlewares.yml index 0bd02ec..28d552b 100644 --- a/traefik/dynamic/middlewares.yml +++ b/traefik/dynamic/middlewares.yml @@ -10,7 +10,7 @@ http: metube-auth: basicAuth: users: - - "vv:$2y$05$JdT8AGUO9bd.E/PiCmKaoOJS1RFlXkrrmZ5mJ4f8/a1bEW39L3FbS" + - "vv:$2y$05$JdT8AGUO9bd.E/PiCmKaoOJS1RFlXkrrmZ5mJ4f8/a1bEW39L3FbS" realm: "MeTube Access" @@ -27,7 +27,7 @@ http: users: - "admin:$2y$05$.CKDD82sNUxcpaRrLbHuK.dopqt1fgurc2yfTKAT5OFzT7RvPrJHK" realm: "Dockmon Access" - + # Cloudflare IP Whitelist cloudflare-ipwhitelist: ipWhiteList: From f4c695f95e9ad5424530cc4bc202bcc319a8946f Mon Sep 17 00:00:00 2001 From: mr-forust Date: Tue, 9 Dec 2025 15:14:42 +0100 Subject: [PATCH 49/89] feat: security-chain (authentik) on no-login services, SSO traefik dashboard --- dockmon/compose.yaml | 4 ++-- glance/compose.yaml | 4 ++-- traefik/compose.yaml | 16 ++++++++-------- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/dockmon/compose.yaml b/dockmon/compose.yaml index ba2b458..b3dc1c3 100644 --- a/dockmon/compose.yaml +++ b/dockmon/compose.yaml @@ -24,7 +24,7 @@ services: # Prod Router - "traefik.http.routers.dockmon.rule=Host(`dockmon.forust.xyz`)" - "traefik.http.routers.dockmon.entrypoints=websecure" - - "traefik.http.routers.dockmon.middlewares=security-headers@file,dockmon-auth@file" + - "traefik.http.routers.dockmon.middlewares=security-chain@file" - "traefik.http.routers.dockmon.service=dockmon" - "traefik.http.routers.dockmon.tls=true" - "traefik.http.services.dockmon.loadbalancer.server.port=443" @@ -41,7 +41,7 @@ services: # Dev Router - "traefik.http.routers.dockmon-dev.rule=Host(`dockmon.gigaforust.internal`)" - "traefik.http.routers.dockmon-dev.entrypoints=websecure" - - "traefik.http.routers.dockmon-dev.middlewares=security-headers@file" + - "traefik.http.routers.dockmon-dev.middlewares=security-chain@file" - "traefik.http.routers.dockmon-dev.service=dockmon" - "traefik.http.routers.dockmon-dev.tls=true" diff --git a/glance/compose.yaml b/glance/compose.yaml index 6a06f66..6c940ab 100644 --- a/glance/compose.yaml +++ b/glance/compose.yaml @@ -16,7 +16,7 @@ services: # Prod Router - "traefik.http.routers.glance.rule=Host(`glance.forust.xyz`)" - "traefik.http.routers.glance.entrypoints=websecure" - - "traefik.http.routers.glance.middlewares=security-headers@file" + - "traefik.http.routers.glance.middlewares=security-chain@file" - "traefik.http.routers.glance.tls=true" # Local Router @@ -28,7 +28,7 @@ services: # Dev Router - "traefik.http.routers.glance-dev.rule=Host(`glance.gigaforust.internal`)" - "traefik.http.routers.glance-dev.entrypoints=websecure" - - "traefik.http.routers.glance-dev.middlewares=security-headers@file" + - "traefik.http.routers.glance-dev.middlewares=security-chain@file" - "traefik.http.routers.glance-dev.tls=true" networks: - traefik-proxy diff --git a/traefik/compose.yaml b/traefik/compose.yaml index 68f57dc..3b6e66e 100644 --- a/traefik/compose.yaml +++ b/traefik/compose.yaml @@ -44,24 +44,24 @@ services: - "traefik.enable=true" - "traefik.docker.network=traefik-proxy" - # Prod Router (Dashboard) - DISABLED per user request - # - "traefik.http.routers.traefik-dashboard.rule=Host(`traefik.forust.xyz`)" - # - "traefik.http.routers.traefik-dashboard.entrypoints=websecure" - # - "traefik.http.routers.traefik-dashboard.middlewares=auth,security-headers" - # - "traefik.http.routers.traefik-dashboard.service=api@internal" - # - "traefik.http.routers.traefik-dashboard.tls=true" + # Prod Router (Dash) + - "traefik.http.routers.traefik-dashboard.rule=Host(`traefik.forust.xyz`)" + - "traefik.http.routers.traefik-dashboard.entrypoints=websecure" + - "traefik.http.routers.traefik-dashboard.middlewares=security-chain@file" + - "traefik.http.routers.traefik-dashboard.service=api@internal" + - "traefik.http.routers.traefik-dashboard.tls=true" # Local Router - "traefik.http.routers.traefik-dashboard-local.rule=Host(`traefik.workstation.internal`) || Host(`traefik.internal`)" - "traefik.http.routers.traefik-dashboard-local.entrypoints=websecure" - - "traefik.http.routers.traefik-dashboard-local.middlewares=auth@file,security-headers@file" + - "traefik.http.routers.traefik-dashboard-local.middlewares=security-headers@file" - "traefik.http.routers.traefik-dashboard-local.service=api@internal" - "traefik.http.routers.traefik-dashboard-local.tls=true" # Dev Router - "traefik.http.routers.traefik-dashboard-dev.rule=Host(`traefik.gigaforust.internal`)" - "traefik.http.routers.traefik-dashboard-dev.entrypoints=websecure" - - "traefik.http.routers.traefik-dashboard-dev.middlewares=security-headers@file" + - "traefik.http.routers.traefik-dashboard-dev.middlewares=security-chain@file" - "traefik.http.routers.traefik-dashboard-dev.service=api@internal" - "traefik.http.routers.traefik-dashboard-dev.tls=true" From 17cae719523bceed207112552db010218749d117 Mon Sep 17 00:00:00 2001 From: mr-forust Date: Tue, 9 Dec 2025 21:45:24 +0100 Subject: [PATCH 50/89] refactor: userbots' compose-files cleanup --- userbot/{docker-compose.yaml => compose.yaml} | 31 ++----------------- 1 file changed, 3 insertions(+), 28 deletions(-) rename userbot/{docker-compose.yaml => compose.yaml} (52%) diff --git a/userbot/docker-compose.yaml b/userbot/compose.yaml similarity index 52% rename from userbot/docker-compose.yaml rename to userbot/compose.yaml index 01a1109..9cf008f 100644 --- a/userbot/docker-compose.yaml +++ b/userbot/compose.yaml @@ -1,9 +1,8 @@ services: - userbot_forust: + forust: build: context: . dockerfile: Dockerfile - container_name: userbot_forust restart: unless-stopped env_file: - .env @@ -16,11 +15,10 @@ services: - 8.8.8.8 - 1.1.1.1 - userbot_anna: + anna: build: context: . dockerfile: Dockerfile - container_name: userbot_anna restart: unless-stopped env_file: - .env @@ -31,27 +29,4 @@ services: - ./volumes/logs_anna:/app/logs dns: - 8.8.8.8 - - 1.1.1.1 - develop: - watch: - - action: sync - path: modules - target: /app/modules - - action: sync - path: utils - target: /app/utils - - action: sync - path: main.py - target: /app/main.py - - action: rebuild - path: .env - - action: rebuild - path: .env.anna - - -volumes: - downloads: - -networks: - userbot_network: - driver: bridge + - 1.1.1.1 \ No newline at end of file From 5e4c60bb303994f217352763478f9753731e288b Mon Sep 17 00:00:00 2001 From: mr-forust Date: Sun, 14 Dec 2025 02:42:46 +0100 Subject: [PATCH 51/89] chore (security): port hardening, commented out non-critical ports --- adguardhome/compose.yaml | 2 +- authentik/compose.yaml | 6 +++--- homepages/compose.yaml | 11 +++++------ penpot/compose.yaml | 4 ++-- termix/compose.yaml | 4 ++-- uptime-kuma/compose.yaml | 4 ++-- 6 files changed, 15 insertions(+), 16 deletions(-) diff --git a/adguardhome/compose.yaml b/adguardhome/compose.yaml index bbcd151..f423425 100644 --- a/adguardhome/compose.yaml +++ b/adguardhome/compose.yaml @@ -10,7 +10,7 @@ services: - "53:53/udp" # - "67:67/udp" # DHCP # - "68:68/tcp" # DHCP - - "3000:3000/tcp" + # - "3000:3000/tcp" volumes: - ./data/work:/opt/adguardhome/work - ./data/conf:/opt/adguardhome/conf diff --git a/authentik/compose.yaml b/authentik/compose.yaml index 537793d..a156025 100644 --- a/authentik/compose.yaml +++ b/authentik/compose.yaml @@ -26,9 +26,9 @@ services: command: server container_name: authentik-server restart: unless-stopped - ports: - - ${PORT_HTTP:-9000}:9000 - - ${PORT_HTTPS:-9443}:9443 + # ports: + # - ${PORT_HTTP:-9000}:9000 + # - ${PORT_HTTPS:-9443}:9443 env_file: - .env environment: diff --git a/homepages/compose.yaml b/homepages/compose.yaml index 6a246a4..58ca2cf 100644 --- a/homepages/compose.yaml +++ b/homepages/compose.yaml @@ -3,8 +3,8 @@ services: build: context: . dockerfile: Dockerfile.forust - ports: - - "8085:80" + # ports: + # - "8085:80" restart: unless-stopped volumes: - ./forust_files:/usr/share/nginx/html @@ -42,8 +42,8 @@ services: build: context: . dockerfile: Dockerfile.xdfnx - ports: - - "8086:80" + # ports: + # - "8086:80" restart: unless-stopped volumes: - ./xdfnx_files:/usr/share/nginx/html @@ -52,7 +52,7 @@ services: labels: - "traefik.enable=true" - "traefik.docker.network=traefik-proxy" - + # Services - "traefik.http.services.xdfnx-homepage.loadbalancer.server.port=80" @@ -77,7 +77,6 @@ services: - "traefik.http.routers.xdfnx-dev.service=xdfnx-homepage" - "traefik.http.routers.xdfnx-dev.tls=true" - networks: traefik-proxy: external: true diff --git a/penpot/compose.yaml b/penpot/compose.yaml index db2b1b1..46b47c4 100644 --- a/penpot/compose.yaml +++ b/penpot/compose.yaml @@ -86,8 +86,8 @@ services: penpot-frontend: image: "penpotapp/frontend:${PENPOT_VERSION:-latest}" restart: always - ports: - - 9001:8080 + # ports: + # - 9001:8080 volumes: - penpot_assets:/opt/data/assets diff --git a/termix/compose.yaml b/termix/compose.yaml index 44a87c1..dbcd411 100644 --- a/termix/compose.yaml +++ b/termix/compose.yaml @@ -3,8 +3,8 @@ services: image: ghcr.io/lukegus/termix:latest container_name: termix restart: unless-stopped - ports: - - "3331:8080" + # ports: + # - "3331:8080" volumes: - ./termix-data:/app/data environment: diff --git a/uptime-kuma/compose.yaml b/uptime-kuma/compose.yaml index 699ba79..1eff102 100644 --- a/uptime-kuma/compose.yaml +++ b/uptime-kuma/compose.yaml @@ -5,9 +5,9 @@ services: container_name: uptime-kuma volumes: - ./data:/app/data - ports: + # ports: # : - - "3001:3001" + # - "3001:3001" labels: - "traefik.enable=true" - "traefik.docker.network=traefik-proxy" From db0f0f7bb6ff2b7066354737a699c18ad3a5b21a Mon Sep 17 00:00:00 2001 From: mr-forust Date: Sun, 14 Dec 2025 02:43:00 +0100 Subject: [PATCH 52/89] feat: tailscale server --- tailscale/.env.example | 1 + tailscale/compose.yaml | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+) create mode 100644 tailscale/.env.example create mode 100644 tailscale/compose.yaml diff --git a/tailscale/.env.example b/tailscale/.env.example new file mode 100644 index 0000000..ad82e8d --- /dev/null +++ b/tailscale/.env.example @@ -0,0 +1 @@ +TS_AUTHKEY=tskey-auth-xxxxx-CNTRL diff --git a/tailscale/compose.yaml b/tailscale/compose.yaml new file mode 100644 index 0000000..8f8f8b8 --- /dev/null +++ b/tailscale/compose.yaml @@ -0,0 +1,21 @@ +services: + tailscale: + image: tailscale/tailscale:latest + container_name: tailscale + network_mode: host + restart: unless-stopped + cap_add: + - NET_ADMIN + - NET_RAW + volumes: + - tailscale_data:/var/lib/tailscale + - /dev/net/tun:/dev/net/tun + environment: + - TS_AUTHKEY=${TS_AUTHKEY} + - TS_STATE_DIR=/var/lib/tailscale + - TS_HOSTNAME=forust-server + # - TS_EXTRA_ARGS=--advertise-tags=tag:container + +volumes: + tailscale_data: + name: tailscale_data From dc75dbaf7c152c20c4c12f90fe1aefd9c4b4f9a5 Mon Sep 17 00:00:00 2001 From: mr-forust Date: Wed, 17 Dec 2025 03:03:32 +0100 Subject: [PATCH 53/89] chore: disable logging on pre-dormant period --- traefik/compose.yaml | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/traefik/compose.yaml b/traefik/compose.yaml index 3b6e66e..b4c87dc 100644 --- a/traefik/compose.yaml +++ b/traefik/compose.yaml @@ -33,12 +33,11 @@ services: # Cloudflare - "--entryPoints.web.forwardedHeaders.trustedIPs=173.245.48.0/20,103.21.244.0/22,103.22.200.0/22,103.31.4.0/22,141.101.64.0/18,108.162.192.0/18,190.93.240.0/20,188.114.96.0/20,197.234.240.0/22,198.41.128.0/17,162.158.0.0/15,104.16.0.0/13,104.24.0.0/14,172.64.0.0/13,131.0.72.0/22" - "--entryPoints.websecure.forwardedHeaders.trustedIPs=173.245.48.0/20,103.21.244.0/22,103.22.200.0/22,103.31.4.0/22,141.101.64.0/18,108.162.192.0/18,190.93.240.0/20,188.114.96.0/20,197.234.240.0/22,198.41.128.0/17,162.158.0.0/15,104.16.0.0/13,104.24.0.0/14,172.64.0.0/13,131.0.72.0/22" - - # Logging - - "--log.level=INFO" - - "--log.filePath=/var/log/traefik/traefik.log" - - "--accesslog=true" - - "--accesslog.filepath=/var/log/traefik/access.log" + # # Logging + # - "--log.level=INFO" + # - "--log.filePath=/var/log/traefik/traefik.log" + # - "--accesslog=true" + # - "--accesslog.filepath=/var/log/traefik/access.log" labels: - "traefik.enable=true" From 163ea867ce20d82b560ff4d8a33db05dec462c88 Mon Sep 17 00:00:00 2001 From: mr-forust Date: Sat, 20 Dec 2025 02:00:10 +0100 Subject: [PATCH 54/89] feat: downtify service, traefik integration --- .gitignore | 1 + downtify/compose.yaml | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 downtify/compose.yaml diff --git a/.gitignore b/.gitignore index 592cc49..6cc9cbd 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ uptime-kuma/data/ termix/termix-data/* cfddns/config.json checkmk/checkmk/* +downtify/Downtify_downloads # Steaming services files streaming/jellyfin/* diff --git a/downtify/compose.yaml b/downtify/compose.yaml new file mode 100644 index 0000000..06f92f7 --- /dev/null +++ b/downtify/compose.yaml @@ -0,0 +1,39 @@ +services: + downtify: + container_name: downtify + image: ghcr.io/henriquesebastiao/downtify:latest + # ports: + # - '7077:8000' + labels: + - traefik.enable=true + - traefik.http.services.downtify.loadbalancer.server.port=8000 + + # Prod Router + - traefik.http.routers.downtify.rule=Host(`downtify.forust.xyz`) + - traefik.http.routers.downtify.entrypoints=websecure + - traefik.http.routers.downtify.middlewares=security-chain@file + - traefik.http.routers.downtify.service=downtify + - traefik.http.routers.downtify.tls=true + + # Local Router + - traefik.http.routers.downtify-local.rule=Host(`downtify.workstation.internal`) || Host(`downtify.internal`) + - traefik.http.routers.downtify-local.entrypoints=websecure + - traefik.http.routers.downtify-local.middlewares=security-headers@file + - traefik.http.routers.downtify-local.service=downtify + - traefik.http.routers.downtify-local.tls=true + + # Dev Router + - traefik.http.routers.downtify-dev.rule=Host(`downtify.gigaforust.internal`) + - traefik.http.routers.downtify-dev.entrypoints=websecure + - traefik.http.routers.downtify-dev.middlewares=security-chain@file + - traefik.http.routers.downtify-dev.service=downtify + - traefik.http.routers.downtify-dev.tls=true + networks: + - traefik-proxy + + volumes: + - ./Downtify_downloads:/downloads + +networks: + traefik-proxy: + external: true From 4471da827cbeb2c783502db85de3b1e857f16fac Mon Sep 17 00:00:00 2001 From: mr-forust Date: Thu, 25 Dec 2025 19:41:25 +0100 Subject: [PATCH 55/89] kek --- homepages/xdfnx_files/favicon.ico | Bin 0 -> 176704 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 homepages/xdfnx_files/favicon.ico diff --git a/homepages/xdfnx_files/favicon.ico b/homepages/xdfnx_files/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..828259e73d9c94a15fea6e9dcfd4787fa94a916c GIT binary patch literal 176704 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 LQ Date: Thu, 25 Dec 2025 19:54:32 +0100 Subject: [PATCH 56/89] kek --- homepages/xdfnx_files/favicon.ico | Bin 176704 -> 43006 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/homepages/xdfnx_files/favicon.ico b/homepages/xdfnx_files/favicon.ico index 828259e73d9c94a15fea6e9dcfd4787fa94a916c..93ed59e3a7861f5a93e00c5ee688f2e9cb4f6e8c 100644 GIT binary patch literal 43006 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 LQ Date: Thu, 25 Dec 2025 19:58:38 +0100 Subject: [PATCH 57/89] feat: add gitea-basic-runner --- gitea/.env.example | 3 ++- gitea/compose.yaml | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/gitea/.env.example b/gitea/.env.example index b5a1c0a..6f45966 100644 --- a/gitea/.env.example +++ b/gitea/.env.example @@ -1,3 +1,4 @@ GITEA_POSTGRES_USER= GITEA_POSTGRES_PASSWORD= -GITEA_POSTGRES_DB=gitea \ No newline at end of file +GITEA_POSTGRES_DB=gitea +BASIC-RUNNER_TOKEN= \ No newline at end of file diff --git a/gitea/compose.yaml b/gitea/compose.yaml index 2e0c67b..ba532e2 100644 --- a/gitea/compose.yaml +++ b/gitea/compose.yaml @@ -52,6 +52,25 @@ services: depends_on: - db + runner: + image: gitea/act_runner:0.2.11 + container_name: gitea-runner + restart: always + depends_on: + - server + env_file: + - .env + networks: + - gitea-db + environment: + - GITEA_INSTANCE_URL=http://server:3000 + - GITEA_RUNNER_REGISTRATION_TOKEN=${BASIC-RUNNER_TOKEN} + - GITEA_RUNNER_NAME=basic-runner + - GITEA_RUNNER_LABELS=docker:docker://node:20-bookworm,ubuntu-latest:docker://node:20-bookworm + volumes: + - ./gitea-runner:/data + - /var/run/docker.sock:/var/run/docker.sock + db: image: docker.io/library/postgres:14 restart: always From e0f7ab656182397ff67b5d73a97723779ceac4c2 Mon Sep 17 00:00:00 2001 From: mr-forust Date: Thu, 25 Dec 2025 20:00:22 +0100 Subject: [PATCH 58/89] chore: gitea runners gitignored --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 6cc9cbd..7d81695 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ sync.ffs_lock # Volumes and data directories gitea/gitea-db/ gitea/gitea-data/* +gitea/*runner/* n8n/n8n-data/* n8n/n8n-node-data/* adguardhome/data/* From d20ec696a389c848929df1ae42eb016584aa58d6 Mon Sep 17 00:00:00 2001 From: mr-forust Date: Fri, 26 Dec 2025 21:02:47 +0100 Subject: [PATCH 59/89] tcp router for gite ssh --- gitea/compose.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gitea/compose.yaml b/gitea/compose.yaml index ad4982a..7c3a43f 100644 --- a/gitea/compose.yaml +++ b/gitea/compose.yaml @@ -12,8 +12,8 @@ services: - GITEA__database__PASSWD=gitea - GITEA__database__NAME=gitea #Server - - GITEA__server__ROOT_URL=gitea.forust.xyz - - SSH_DOMAIN=gitea.forust.xyz + - GITEA__server__ROOT_URL=https://gitea.forust.xyz + - GITEA__server__SSH_DOMAIN=gitssh.forust.xyz - GITEA__server__SSH_PORT=2221 restart: always networks: From 60c7d4ff177fd298a5ea2c8944ceb96f0f12ebec Mon Sep 17 00:00:00 2001 From: mr-forust Date: Sun, 28 Dec 2025 21:08:18 +0100 Subject: [PATCH 60/89] feat: add mailer support and credentials for gitea --- gitea/.env.example | 5 ++++- gitea/compose.yaml | 9 +++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/gitea/.env.example b/gitea/.env.example index 6f45966..672c2e6 100644 --- a/gitea/.env.example +++ b/gitea/.env.example @@ -1,4 +1,7 @@ GITEA_POSTGRES_USER= GITEA_POSTGRES_PASSWORD= GITEA_POSTGRES_DB=gitea -BASIC-RUNNER_TOKEN= \ No newline at end of file +BASIC-RUNNER_TOKEN= +GITEA_SMTP_PASS= +MAILER_ADDR= +SERVICE_EMAIL=email.used.by.services@domain.tld \ No newline at end of file diff --git a/gitea/compose.yaml b/gitea/compose.yaml index 7c3a43f..1bdd309 100644 --- a/gitea/compose.yaml +++ b/gitea/compose.yaml @@ -15,6 +15,15 @@ services: - GITEA__server__ROOT_URL=https://gitea.forust.xyz - GITEA__server__SSH_DOMAIN=gitssh.forust.xyz - GITEA__server__SSH_PORT=2221 + # Mailer + - GITEA__mailer__ENABLED=true + - GITEA__mailer__FROM=${SERVICE_EMAIL} + - GITEA__mailer__SMTP_ADDR=${MAILER_ADDR}:465 + - GITEA__mailer__USER=${SERVICE_EMAIL} + - GITEA__mailer__PASSWD=${GITEA_SMTP_PASS} + - GITEA__mailer__PROTOCOL=SMTP + - GITEA__service__REGISTER_EMAIL_CONFIRM=true + - GITEA__service__ENABLE_NOTIFY_MAIL=true restart: always networks: - gitea-db From fb2f42052026ea88bad4aa9ee2d123069a4f9c41 Mon Sep 17 00:00:00 2001 From: mr-forust Date: Tue, 30 Dec 2025 14:14:24 +0100 Subject: [PATCH 61/89] refactor: update network refs form "traefik-proxy" to "proxy" - To allow testing dev vers of other services --- adguardhome/compose.yaml | 6 +++--- authentik/compose.yaml | 6 +++--- dockmon/compose.yaml | 6 +++--- downtify/compose.yaml | 4 ++-- gitea/compose.yaml | 6 +++--- glance/compose.yaml | 6 +++--- homepages/compose.yaml | 10 +++++----- metube/compose.yaml | 6 +++--- n8n/compose.yaml | 6 +++--- nextcloud/compose.yaml | 8 ++++---- penpot/compose.yaml | 6 +++--- portainer/compose.yaml | 6 +++--- termix/compose.yaml | 6 +++--- traefik/compose.yaml | 10 +++++----- uptime-kuma/compose.yaml | 6 +++--- 15 files changed, 49 insertions(+), 49 deletions(-) diff --git a/adguardhome/compose.yaml b/adguardhome/compose.yaml index bbcd151..2e3b7c6 100644 --- a/adguardhome/compose.yaml +++ b/adguardhome/compose.yaml @@ -15,10 +15,10 @@ services: - ./data/work:/opt/adguardhome/work - ./data/conf:/opt/adguardhome/conf networks: - - traefik-proxy + - proxy labels: - "traefik.enable=true" - - "traefik.docker.network=traefik-proxy" + - "traefik.docker.network=proxy" # Prod Router - "traefik.http.routers.adguard.rule=Host(`adguard.forust.xyz`)" @@ -46,5 +46,5 @@ services: - glance.url=https://adguard.forust.xyz/ - glance.description=AdGuard Home is a network-wide software for blocking ads. networks: - traefik-proxy: + proxy: external: true diff --git a/authentik/compose.yaml b/authentik/compose.yaml index 537793d..2311c1c 100644 --- a/authentik/compose.yaml +++ b/authentik/compose.yaml @@ -40,7 +40,7 @@ services: labels: - "traefik.enable=true" - - "traefik.docker.network=traefik-proxy" + - "traefik.docker.network=proxy" # Services # - "traefik.http.services.authentik-server.loadbalancer.server.port=9443" - "traefik.http.services.authentik-server.loadbalancer.server.port=9000" @@ -69,7 +69,7 @@ services: - ./media:/media - ./custom-templates:/templates networks: - - traefik-proxy + - proxy - authentik depends_on: postgresql: @@ -102,5 +102,5 @@ volumes: driver: local networks: authentik: - traefik-proxy: + proxy: external: true diff --git a/dockmon/compose.yaml b/dockmon/compose.yaml index b3dc1c3..b7f11de 100644 --- a/dockmon/compose.yaml +++ b/dockmon/compose.yaml @@ -16,10 +16,10 @@ services: timeout: 10s retries: 3 networks: - - traefik-proxy + - proxy labels: - "traefik.enable=true" - - "traefik.docker.network=traefik-proxy" + - "traefik.docker.network=proxy" # Prod Router - "traefik.http.routers.dockmon.rule=Host(`dockmon.forust.xyz`)" @@ -50,5 +50,5 @@ services: - glance.description=Dockmon is a lightweight Docker container monitoring and management tool with a user-friendly web interface. networks: - traefik-proxy: + proxy: external: true diff --git a/downtify/compose.yaml b/downtify/compose.yaml index 06f92f7..f507091 100644 --- a/downtify/compose.yaml +++ b/downtify/compose.yaml @@ -29,11 +29,11 @@ services: - traefik.http.routers.downtify-dev.service=downtify - traefik.http.routers.downtify-dev.tls=true networks: - - traefik-proxy + - proxy volumes: - ./Downtify_downloads:/downloads networks: - traefik-proxy: + proxy: external: true diff --git a/gitea/compose.yaml b/gitea/compose.yaml index 1bdd309..bf2d696 100644 --- a/gitea/compose.yaml +++ b/gitea/compose.yaml @@ -27,14 +27,14 @@ services: restart: always networks: - gitea-db - - traefik-proxy + - proxy volumes: - ./gitea-data:/data - /etc/timezone:/etc/timezone:ro - /etc/localtime:/etc/localtime:ro labels: - "traefik.enable=true" - - "traefik.docker.network=traefik-proxy" + - "traefik.docker.network=proxy" # Prod Router - "traefik.http.routers.gitea.rule=Host(`gitea.forust.xyz`)" @@ -99,5 +99,5 @@ services: networks: gitea-db: external: false - traefik-proxy: + proxy: external: true diff --git a/glance/compose.yaml b/glance/compose.yaml index 6c940ab..cd853ab 100644 --- a/glance/compose.yaml +++ b/glance/compose.yaml @@ -11,7 +11,7 @@ services: env_file: .env labels: - "traefik.enable=true" - - "traefik.docker.network=traefik-proxy" + - "traefik.docker.network=proxy" # Prod Router - "traefik.http.routers.glance.rule=Host(`glance.forust.xyz`)" @@ -31,10 +31,10 @@ services: - "traefik.http.routers.glance-dev.middlewares=security-chain@file" - "traefik.http.routers.glance-dev.tls=true" networks: - - traefik-proxy + - proxy dns: - 1.1.1.1 - 8.8.8.8 networks: - traefik-proxy: + proxy: external: true \ No newline at end of file diff --git a/homepages/compose.yaml b/homepages/compose.yaml index 6a246a4..eeabd28 100644 --- a/homepages/compose.yaml +++ b/homepages/compose.yaml @@ -9,10 +9,10 @@ services: volumes: - ./forust_files:/usr/share/nginx/html networks: - - traefik-proxy + - proxy labels: - "traefik.enable=true" - - "traefik.docker.network=traefik-proxy" + - "traefik.docker.network=proxy" # Services - "traefik.http.services.forust-homepage.loadbalancer.server.port=80" @@ -48,10 +48,10 @@ services: volumes: - ./xdfnx_files:/usr/share/nginx/html networks: - - traefik-proxy + - proxy labels: - "traefik.enable=true" - - "traefik.docker.network=traefik-proxy" + - "traefik.docker.network=proxy" # Services - "traefik.http.services.xdfnx-homepage.loadbalancer.server.port=80" @@ -79,5 +79,5 @@ services: networks: - traefik-proxy: + proxy: external: true diff --git a/metube/compose.yaml b/metube/compose.yaml index 27e323d..47490eb 100644 --- a/metube/compose.yaml +++ b/metube/compose.yaml @@ -14,7 +14,7 @@ services: - ./MeTube_downloads:/downloads labels: - traefik.enable=true - - "traefik.docker.network=traefik-proxy" + - "traefik.docker.network=proxy" # Prod Router - "traefik.http.routers.metube.rule=Host(`metube.forust.xyz`)" @@ -39,7 +39,7 @@ services: - "traefik.http.routers.metube-dev.tls=true" networks: - - traefik-proxy + - proxy networks: - traefik-proxy: + proxy: external: true diff --git a/n8n/compose.yaml b/n8n/compose.yaml index bcaa985..ff73617 100644 --- a/n8n/compose.yaml +++ b/n8n/compose.yaml @@ -23,11 +23,11 @@ services: - 1.1.1.1 - 8.8.8.8 networks: - - traefik-proxy + - proxy - n8n labels: - "traefik.enable=true" - - "traefik.docker.network=traefik-proxy" + - "traefik.docker.network=proxy" # Prod Router - "traefik.http.routers.n8n.rule=Host(`n8n.forust.xyz`)" @@ -58,5 +58,5 @@ services: networks: n8n: external: false - traefik-proxy: + proxy: external: true diff --git a/nextcloud/compose.yaml b/nextcloud/compose.yaml index e9bf811..dfc77a3 100644 --- a/nextcloud/compose.yaml +++ b/nextcloud/compose.yaml @@ -9,7 +9,7 @@ services: - /var/run/docker.sock:/var/run/docker.sock:ro networks: - nextcloud-aio - - traefik-proxy # Optional: Connects the mastercontainer to the traefik-proxy network in order to make the built-in reverse proxy detection work. See https://github.com/nextcloud/all-in-one/blob/main/reverse-proxy.md + - proxy # Optional: Connects the mastercontainer to the proxy network in order to make the built-in reverse proxy detection work. See https://github.com/nextcloud/all-in-one/blob/main/reverse-proxy.md # ports: # - 8081:80 # may be removed if under reverse-proxy # - 8443:8443 @@ -17,7 +17,7 @@ services: labels: - "traefik.enable=true" - - "traefik.docker.network=traefik-proxy" + - "traefik.docker.network=proxy" # AIO Services configuration - "traefik.http.services.nextcloud-aio.loadbalancer.server.port=8080" @@ -55,7 +55,7 @@ services: AIO_DISABLE_BACKUP_SECTION: false APACHE_PORT: 11000 # Is needed when running behind a web server or reverse proxy (like Apache, Nginx, Caddy, Cloudflare Tunnel and else). See https://github.com/nextcloud/all-in-one/blob/main/reverse-proxy.md APACHE_IP_BINDING: 0.0.0.0 # Configure when going with reverse-proxy https://github.com/nextcloud/all-in-one/blob/main/reverse-proxy.md - APACHE_ADDITIONAL_NETWORK: traefik-proxy # (Optional) Connect the apache container to an additional docker network. When going with reverse-proxy. See https://github.com/nextcloud/all-in-one/blob/main/reverse-proxy.md + APACHE_ADDITIONAL_NETWORK: proxy # (Optional) Connect the apache container to an additional docker network. When going with reverse-proxy. See https://github.com/nextcloud/all-in-one/blob/main/reverse-proxy.md BORG_RETENTION_POLICY: --keep-within=7d --keep-weekly=4 --keep-monthly=6 # Backup retention See https://github.com/nextcloud/all-in-one#how-to-adjust-borgs-retention-policy COLLABORA_SECCOMP_DISABLED: false # Setting this to true allows to disable Collabora's Seccomp feature. See https://github.com/nextcloud/all-in-one#how-to-disable-collaboras-seccomp-feature FULLTEXTSEARCH_JAVA_OPTIONS: "-Xms1024M -Xmx1024M" # adjust fulltextsearch java options. https://github.com/nextcloud/all-in-one#how-to-adjust-the-fulltextsearch-java-options @@ -75,7 +75,7 @@ services: # WATCHTOWER_DOCKER_SOCKET_PATH: /var/run/docker.sock # Needs to be specified if the docker socket on the host is not located in the default '/var/run/docker.sock'. For macos it needs to be '/var/run/docker.sock' networks: - traefik-proxy: + proxy: external: true nextcloud-aio: driver: bridge diff --git a/penpot/compose.yaml b/penpot/compose.yaml index db2b1b1..aebb831 100644 --- a/penpot/compose.yaml +++ b/penpot/compose.yaml @@ -50,7 +50,7 @@ x-secret-key: &penpot-secret-key networks: penpot: - traefik-proxy: + proxy: external: true volumes: @@ -98,11 +98,11 @@ services: networks: - penpot - - traefik-proxy + - proxy labels: - "traefik.enable=true" - - "traefik.docker.network=traefik-proxy" + - "traefik.docker.network=proxy" # Prod Router - "traefik.http.routers.penpot.rule=Host(`penpot.forust.xyz`)" diff --git a/portainer/compose.yaml b/portainer/compose.yaml index 84651df..c56e465 100644 --- a/portainer/compose.yaml +++ b/portainer/compose.yaml @@ -10,10 +10,10 @@ services: - 9443:9443 # - 8000:8000 # Remove if you do not intend to use Edge Agents networks: - - traefik-proxy + - proxy labels: - "traefik.enable=true" - - "traefik.docker.network=traefik-proxy" + - "traefik.docker.network=proxy" # Prod Router - "traefik.http.routers.portainer.rule=Host(`portainer.forust.xyz`)" @@ -50,5 +50,5 @@ volumes: networks: default: name: portainer_network - traefik-proxy: + proxy: external: true diff --git a/termix/compose.yaml b/termix/compose.yaml index 44a87c1..28051b7 100644 --- a/termix/compose.yaml +++ b/termix/compose.yaml @@ -11,7 +11,7 @@ services: PORT: "8080" labels: - "traefik.enable=true" - - "traefik.docker.network=traefik-proxy" + - "traefik.docker.network=proxy" # Prod Router - "traefik.http.routers.termix.rule=Host(`termix.forust.xyz`)" @@ -35,10 +35,10 @@ services: - "traefik.http.routers.termix-dev.service=termix" - "traefik.http.routers.termix-dev.tls=true" networks: - - traefik-proxy + - proxy networks: - traefik-proxy: + proxy: external: true volumes: diff --git a/traefik/compose.yaml b/traefik/compose.yaml index b4c87dc..4f29341 100644 --- a/traefik/compose.yaml +++ b/traefik/compose.yaml @@ -11,7 +11,7 @@ services: # Providers - "--providers.docker=true" - "--providers.docker.exposedbydefault=false" - - "--providers.docker.network=traefik-proxy" + - "--providers.docker.network=proxy" - "--providers.file.directory=/etc/traefik/dynamic" - "--providers.file.watch=true" @@ -41,7 +41,7 @@ services: labels: - "traefik.enable=true" - - "traefik.docker.network=traefik-proxy" + - "traefik.docker.network=proxy" # Prod Router (Dash) - "traefik.http.routers.traefik-dashboard.rule=Host(`traefik.forust.xyz`)" @@ -80,11 +80,11 @@ services: # - ./traefik/letsencrypt:/letsencrypt networks: - - traefik-proxy + - proxy environment: - TZ=Europe/Bratislava networks: - traefik-proxy: - external: true + proxy: + external: true \ No newline at end of file diff --git a/uptime-kuma/compose.yaml b/uptime-kuma/compose.yaml index 699ba79..4051573 100644 --- a/uptime-kuma/compose.yaml +++ b/uptime-kuma/compose.yaml @@ -10,7 +10,7 @@ services: - "3001:3001" labels: - "traefik.enable=true" - - "traefik.docker.network=traefik-proxy" + - "traefik.docker.network=proxy" # Prod Router - "traefik.http.routers.uptime-kuma.rule=Host(`uptime.forust.xyz`)" @@ -28,7 +28,7 @@ services: - "traefik.http.routers.uptime-kuma-dev.entrypoints=websecure" - "traefik.http.routers.uptime-kuma-dev.tls=true" networks: - - traefik-proxy + - proxy networks: - traefik-proxy: + proxy: external: true From f2b951673c266841f27e26f8368afbb7bca6cc96 Mon Sep 17 00:00:00 2001 From: mr-forust Date: Tue, 30 Dec 2025 15:42:42 +0100 Subject: [PATCH 62/89] fix: add traefik route for dns o https --- adguardhome/compose.yaml | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/adguardhome/compose.yaml b/adguardhome/compose.yaml index 2e3b7c6..205479e 100644 --- a/adguardhome/compose.yaml +++ b/adguardhome/compose.yaml @@ -3,8 +3,6 @@ services: image: adguard/adguardhome:latest container_name: adguardhome restart: unless-stopped - environment: - - TZ=${TZ} ports: - "53:53/tcp" - "53:53/udp" @@ -21,7 +19,7 @@ services: - "traefik.docker.network=proxy" # Prod Router - - "traefik.http.routers.adguard.rule=Host(`adguard.forust.xyz`)" + - "traefik.http.routers.adguard.rule=Host(`adguard.forust.xyz`) || Host(`dns.forust.xyz`)" - "traefik.http.routers.adguard.entrypoints=websecure" - "traefik.http.routers.adguard.middlewares=security-headers@file" - "traefik.http.routers.adguard.service=adguard" @@ -29,14 +27,14 @@ services: - "traefik.http.services.adguard.loadbalancer.server.port=3000" # Local Router - - "traefik.http.routers.adguard-local.rule=Host(`adguard.workstation.internal`) || Host(`adguard.internal`)" + - "traefik.http.routers.adguard-local.rule=Host(`adguard.workstation.internal`) || Host(`adguard.internal`) || Host(`dns.workstation.internal`)" - "traefik.http.routers.adguard-local.entrypoints=websecure" - "traefik.http.routers.adguard-local.middlewares=security-headers@file" - "traefik.http.routers.adguard-local.service=adguard" - "traefik.http.routers.adguard-local.tls=true" # Dev Router - - "traefik.http.routers.adguard-dev.rule=Host(`adguard.gigaforust.internal`)" + - "traefik.http.routers.adguard-dev.rule=Host(`adguard.gigaforust.internal`) || Host(`dns.gigaforust.internal`)" - "traefik.http.routers.adguard-dev.entrypoints=websecure" - "traefik.http.routers.adguard-dev.middlewares=security-headers@file" - "traefik.http.routers.adguard-dev.service=adguard" From c047cc291dbe36f17505d815f9d0995b6bdfa886 Mon Sep 17 00:00:00 2001 From: mr-forust Date: Tue, 30 Dec 2025 16:54:23 +0100 Subject: [PATCH 63/89] LE for DoH --- adguardhome/compose.yaml | 9 ++++++++- traefik/compose.yaml | 15 ++++++++------- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/adguardhome/compose.yaml b/adguardhome/compose.yaml index 205479e..d61a6af 100644 --- a/adguardhome/compose.yaml +++ b/adguardhome/compose.yaml @@ -34,12 +34,19 @@ services: - "traefik.http.routers.adguard-local.tls=true" # Dev Router - - "traefik.http.routers.adguard-dev.rule=Host(`adguard.gigaforust.internal`) || Host(`dns.gigaforust.internal`)" + - "traefik.http.routers.adguard-dev.rule=Host(`adguard.gigaforust.internal`)" - "traefik.http.routers.adguard-dev.entrypoints=websecure" - "traefik.http.routers.adguard-dev.middlewares=security-headers@file" - "traefik.http.routers.adguard-dev.service=adguard" - "traefik.http.routers.adguard-dev.tls=true" + # DNS + - "traefik.http.routers.dns.rule=(Host(`dns.forust.xyz`) && PathPrefix(`/dns-query`)) || (Host(`dns.workstation.internal`) && PathPrefix(`/dns-query`)) || (Host(`dns.gigaforust.internal`) && PathPrefix(`/dns-query`))" + - "traefik.http.routers.dns.entrypoints=websecure" + - "traefik.http.routers.dns.service=adguard" + - "traefik.http.routers.dns.tls.certresolver=letsencrypt" + - "traefik.http.routers.dns.tls=true" + - glance.name=adguard - glance.url=https://adguard.forust.xyz/ - glance.description=AdGuard Home is a network-wide software for blocking ads. diff --git a/traefik/compose.yaml b/traefik/compose.yaml index 4f29341..b06b28b 100644 --- a/traefik/compose.yaml +++ b/traefik/compose.yaml @@ -23,12 +23,13 @@ services: - "--entryPoints.web.http.redirections.entryPoint.scheme=https" - "--entryPoints.ssh.address=:2221" - # Let's Encrypt STAGING. CURRENTLY USING CF ORIGIN CA INSTEAD - # - "--certificatesresolvers.letsencrypt.acme.email=${EMAIL}" - # - "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json" - # - "--certificatesresolvers.letsencrypt.acme.caserver=https://acme-staging-v02.api.letsencrypt.org/directory" - # - "--certificatesresolvers.letsencrypt.acme.httpchallenge=true" - # - "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web" + # Let's Encrypt + - "--certificatesresolvers.letsencrypt.acme.email=${EMAIL}" + - "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json" + - "--certificatesresolvers.letsencrypt.acme.httpchallenge=true" + - "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web" + # STAGING + - "--certificatesresolvers.letsencrypt.acme.caserver=https://acme-staging-v02.api.letsencrypt.org/directory" # Cloudflare - "--entryPoints.web.forwardedHeaders.trustedIPs=173.245.48.0/20,103.21.244.0/22,103.22.200.0/22,103.31.4.0/22,141.101.64.0/18,108.162.192.0/18,190.93.240.0/20,188.114.96.0/20,197.234.240.0/22,198.41.128.0/17,162.158.0.0/15,104.16.0.0/13,104.24.0.0/14,172.64.0.0/13,131.0.72.0/22" @@ -87,4 +88,4 @@ services: networks: proxy: - external: true \ No newline at end of file + external: true From 502810a12ee83257d3d9b33b95f78e53e1e97005 Mon Sep 17 00:00:00 2001 From: mr-forust Date: Tue, 30 Dec 2025 17:20:13 +0100 Subject: [PATCH 64/89] fix: update traefik router rules for DNS and adjust letsencrypt volume path --- adguardhome/compose.yaml | 6 +++--- traefik/compose.yaml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/adguardhome/compose.yaml b/adguardhome/compose.yaml index d61a6af..0224100 100644 --- a/adguardhome/compose.yaml +++ b/adguardhome/compose.yaml @@ -19,7 +19,7 @@ services: - "traefik.docker.network=proxy" # Prod Router - - "traefik.http.routers.adguard.rule=Host(`adguard.forust.xyz`) || Host(`dns.forust.xyz`)" + - "traefik.http.routers.adguard.rule=Host(`adguard.forust.xyz`)" - "traefik.http.routers.adguard.entrypoints=websecure" - "traefik.http.routers.adguard.middlewares=security-headers@file" - "traefik.http.routers.adguard.service=adguard" @@ -27,7 +27,7 @@ services: - "traefik.http.services.adguard.loadbalancer.server.port=3000" # Local Router - - "traefik.http.routers.adguard-local.rule=Host(`adguard.workstation.internal`) || Host(`adguard.internal`) || Host(`dns.workstation.internal`)" + - "traefik.http.routers.adguard-local.rule=Host(`adguard.workstation.internal`) || Host(`adguard.internal`)" - "traefik.http.routers.adguard-local.entrypoints=websecure" - "traefik.http.routers.adguard-local.middlewares=security-headers@file" - "traefik.http.routers.adguard-local.service=adguard" @@ -41,7 +41,7 @@ services: - "traefik.http.routers.adguard-dev.tls=true" # DNS - - "traefik.http.routers.dns.rule=(Host(`dns.forust.xyz`) && PathPrefix(`/dns-query`)) || (Host(`dns.workstation.internal`) && PathPrefix(`/dns-query`)) || (Host(`dns.gigaforust.internal`) && PathPrefix(`/dns-query`))" + - "traefik.http.routers.dns.rule=(Host(`dns.forust.xyz`) && PathPrefix(`/dns-query`))" - "traefik.http.routers.dns.entrypoints=websecure" - "traefik.http.routers.dns.service=adguard" - "traefik.http.routers.dns.tls.certresolver=letsencrypt" diff --git a/traefik/compose.yaml b/traefik/compose.yaml index b06b28b..cdf4096 100644 --- a/traefik/compose.yaml +++ b/traefik/compose.yaml @@ -78,7 +78,7 @@ services: - ./dynamic:/etc/traefik/dynamic:ro - ./certs:/certs:ro - ./logs:/var/log/traefik - # - ./traefik/letsencrypt:/letsencrypt + - ./letsencrypt:/letsencrypt networks: - proxy From dc7fe64fbcecaa6325e877ef23585051820ae302 Mon Sep 17 00:00:00 2001 From: mr-forust Date: Tue, 30 Dec 2025 22:44:52 +0100 Subject: [PATCH 65/89] fix: clean up traefik configuration and add redirect middleware --- .gitignore | 1 + adguardhome/compose.yaml | 1 - traefik/compose.yaml | 7 ++----- traefik/dynamic/middlewares.yml | 28 ---------------------------- traefik/dynamic/redirect.yml | 23 +++++++++++++++++++++++ 5 files changed, 26 insertions(+), 34 deletions(-) create mode 100644 traefik/dynamic/redirect.yml diff --git a/.gitignore b/.gitignore index 7d81695..5e00c81 100644 --- a/.gitignore +++ b/.gitignore @@ -83,3 +83,4 @@ replacements.txt # Temp files edu_master/temp/ +temp/* diff --git a/adguardhome/compose.yaml b/adguardhome/compose.yaml index 0224100..7582125 100644 --- a/adguardhome/compose.yaml +++ b/adguardhome/compose.yaml @@ -45,7 +45,6 @@ services: - "traefik.http.routers.dns.entrypoints=websecure" - "traefik.http.routers.dns.service=adguard" - "traefik.http.routers.dns.tls.certresolver=letsencrypt" - - "traefik.http.routers.dns.tls=true" - glance.name=adguard - glance.url=https://adguard.forust.xyz/ diff --git a/traefik/compose.yaml b/traefik/compose.yaml index cdf4096..eff00c3 100644 --- a/traefik/compose.yaml +++ b/traefik/compose.yaml @@ -19,17 +19,14 @@ services: - "--entryPoints.web.address=:80" - "--entryPoints.websecure.address=:443" - "--entryPoints.websecure.http.tls=true" - - "--entryPoints.web.http.redirections.entryPoint.to=websecure" - - "--entryPoints.web.http.redirections.entryPoint.scheme=https" - "--entryPoints.ssh.address=:2221" # Let's Encrypt - "--certificatesresolvers.letsencrypt.acme.email=${EMAIL}" - "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json" - - "--certificatesresolvers.letsencrypt.acme.httpchallenge=true" - "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web" - # STAGING - - "--certificatesresolvers.letsencrypt.acme.caserver=https://acme-staging-v02.api.letsencrypt.org/directory" + # # STAGING + # - "--certificatesresolvers.letsencrypt.acme.caserver=https://acme-staging-v02.api.letsencrypt.org/directory" # Cloudflare - "--entryPoints.web.forwardedHeaders.trustedIPs=173.245.48.0/20,103.21.244.0/22,103.22.200.0/22,103.31.4.0/22,141.101.64.0/18,108.162.192.0/18,190.93.240.0/20,188.114.96.0/20,197.234.240.0/22,198.41.128.0/17,162.158.0.0/15,104.16.0.0/13,104.24.0.0/14,172.64.0.0/13,131.0.72.0/22" diff --git a/traefik/dynamic/middlewares.yml b/traefik/dynamic/middlewares.yml index 28d552b..0dba63f 100644 --- a/traefik/dynamic/middlewares.yml +++ b/traefik/dynamic/middlewares.yml @@ -1,33 +1,5 @@ http: middlewares: - # HTTPS Redirect - redirect-https: - redirectScheme: - scheme: https - permanent: true - - # Metube Basic Auth - metube-auth: - basicAuth: - users: - - "vv:$2y$05$JdT8AGUO9bd.E/PiCmKaoOJS1RFlXkrrmZ5mJ4f8/a1bEW39L3FbS" - - realm: "MeTube Access" - - # Basic Auth Traefik Dashboard - auth: - basicAuth: - users: - - "admin:$2y$05$.CKDD82sNUxcpaRrLbHuK.dopqt1fgurc2yfTKAT5OFzT7RvPrJHK" - realm: "Traefik Dashboard" - - # Basic Auth Dockmon - dockmon-auth: - basicAuth: - users: - - "admin:$2y$05$.CKDD82sNUxcpaRrLbHuK.dopqt1fgurc2yfTKAT5OFzT7RvPrJHK" - realm: "Dockmon Access" - # Cloudflare IP Whitelist cloudflare-ipwhitelist: ipWhiteList: diff --git a/traefik/dynamic/redirect.yml b/traefik/dynamic/redirect.yml new file mode 100644 index 0000000..fdcea94 --- /dev/null +++ b/traefik/dynamic/redirect.yml @@ -0,0 +1,23 @@ +http: + middlewares: + redirect-https: + redirectScheme: + scheme: https + permanent: true + + routers: + acme-challenge-exempt: + rule: "PathPrefix(`/.well-known/acme-challenge`)" + entryPoints: + - web + service: noop@internal + priority: 100 + + http-catchall: + rule: "HostRegexp(`{host:.+}`)" + entryPoints: + - web + middlewares: + - redirect-https + service: noop@internal + priority: 1 From 9675eac2bf67319989c11959dab98b8779586a59 Mon Sep 17 00:00:00 2001 From: mr-forust Date: Wed, 31 Dec 2025 03:41:38 +0100 Subject: [PATCH 66/89] feat: add DoT support on dns.forust.xyz - Made adguard available on adguard. and dns. subdomain - DoT and DoH implementation complete --- .gitignore | 3 +++ adguardhome/compose.yaml | 12 +++++++----- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index 5e00c81..2761f85 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,9 @@ homepages/forust_files/assets/images/team/* # Traefik files traefik/letsencrypt/acme.json traefik/logs/* + +# SSL Certificates +adguardhome/certs/* traefik/certs/* # Monitoring diff --git a/adguardhome/compose.yaml b/adguardhome/compose.yaml index 7582125..e2d40f6 100644 --- a/adguardhome/compose.yaml +++ b/adguardhome/compose.yaml @@ -6,41 +6,43 @@ services: ports: - "53:53/tcp" - "53:53/udp" + - "853:853/tcp" # DNS over TLS # - "67:67/udp" # DHCP # - "68:68/tcp" # DHCP - "3000:3000/tcp" volumes: - ./data/work:/opt/adguardhome/work - ./data/conf:/opt/adguardhome/conf + - ./certs:/certs:ro networks: - proxy labels: - "traefik.enable=true" - "traefik.docker.network=proxy" + - "traefik.http.services.adguard.loadbalancer.server.port=3000" # Prod Router - - "traefik.http.routers.adguard.rule=Host(`adguard.forust.xyz`)" + - "traefik.http.routers.adguard.rule=Host(`dns.forust.xyz`) || Host(`adguard.forust.xyz`)" - "traefik.http.routers.adguard.entrypoints=websecure" - "traefik.http.routers.adguard.middlewares=security-headers@file" - "traefik.http.routers.adguard.service=adguard" - "traefik.http.routers.adguard.tls=true" - - "traefik.http.services.adguard.loadbalancer.server.port=3000" # Local Router - - "traefik.http.routers.adguard-local.rule=Host(`adguard.workstation.internal`) || Host(`adguard.internal`)" + - "traefik.http.routers.adguard-local.rule=Host(`dns.workstation.internal`) || Host(`adguard.internal`) || Host(`adguard.workstation.internal`)" - "traefik.http.routers.adguard-local.entrypoints=websecure" - "traefik.http.routers.adguard-local.middlewares=security-headers@file" - "traefik.http.routers.adguard-local.service=adguard" - "traefik.http.routers.adguard-local.tls=true" # Dev Router - - "traefik.http.routers.adguard-dev.rule=Host(`adguard.gigaforust.internal`)" + - "traefik.http.routers.adguard-dev.rule=Host(`dns.gigaforust.internal`) || Host(`adguard.gigaforust.internal`)" - "traefik.http.routers.adguard-dev.entrypoints=websecure" - "traefik.http.routers.adguard-dev.middlewares=security-headers@file" - "traefik.http.routers.adguard-dev.service=adguard" - "traefik.http.routers.adguard-dev.tls=true" - # DNS + # DoH - "traefik.http.routers.dns.rule=(Host(`dns.forust.xyz`) && PathPrefix(`/dns-query`))" - "traefik.http.routers.dns.entrypoints=websecure" - "traefik.http.routers.dns.service=adguard" From 1f1e13ff39cbf0a9a8f13400d42e3188f99385ae Mon Sep 17 00:00:00 2001 From: mr-forust Date: Thu, 1 Jan 2026 00:25:17 +0100 Subject: [PATCH 67/89] WIP: fix: update checkmk configuration for correct routing --- checkmk/compose.yaml | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/checkmk/compose.yaml b/checkmk/compose.yaml index 512474f..40a89a6 100644 --- a/checkmk/compose.yaml +++ b/checkmk/compose.yaml @@ -4,7 +4,6 @@ services: container_name: "checkmk" environment: - CMK_PASSWORD=${CMK_PASSWORD:-password} - - TZ=${TZ:-Europe/Moscow} - CMK_SITE_ID=cmk volumes: @@ -12,33 +11,39 @@ services: tmpfs: - /opt/omd/sites/cmk/tmp:uid=1000,gid=1000 ports: - - 5555:5000 + - 5000:5000 - 6776:8000 - 6556:6556 restart: always labels: - "traefik.enable=true" - - "traefik.docker.network=traefik-proxy" - - # Prod Router - - "traefik.http.routers.checkmk.rule=Host(`checkmk.forust.xyz`)" - - "traefik.http.routers.checkmk.entrypoints=websecure" - - "traefik.http.routers.checkmk.tls=true" + - "traefik.docker.network=proxy" - "traefik.http.services.checkmk.loadbalancer.server.port=5000" + # Prod Router + - "traefik.http.routers.checkmk.rule=Host(`cmk.forust.xyz`)" + - "traefik.http.routers.checkmk.entrypoints=websecure" + - "traefik.http.routers.checkmk.service=checkmk" + - "traefik.http.routers.checkmk.middlewares=security-headers@file" + - "traefik.http.routers.checkmk.tls=true" + # Local Router - - "traefik.http.routers.checkmk-local.rule=Host(`checkmk.workstation.internal`) || Host(`checkmk.internal`)" + - "traefik.http.routers.checkmk-local.rule=Host(`cmk.workstation.internal`) || Host(`cmk.internal`)" - "traefik.http.routers.checkmk-local.entrypoints=websecure" + - "traefik.http.routers.checkmk-local.service=checkmk" + - "traefik.http.routers.checkmk-local.middlewares=security-headers@file" - "traefik.http.routers.checkmk-local.tls=true" # Dev Router - - "traefik.http.routers.checkmk-dev.rule=Host(`checkmk.gigaforust.internal`)" + - "traefik.http.routers.checkmk-dev.rule=Host(`cmk.gigaforust.internal`)" + - "traefik.http.routers.checkmk-dev.middlewares=security-headers@file" + - "traefik.http.routers.checkmk-dev.service=checkmk" - "traefik.http.routers.checkmk-dev.entrypoints=websecure" - "traefik.http.routers.checkmk-dev.tls=true" networks: - - traefik-proxy + - proxy networks: - traefik-proxy: + proxy: external: true \ No newline at end of file From 8f4f460ff3ee103e3ed096bbeb97c4b4ffb81da9 Mon Sep 17 00:00:00 2001 From: mr-forust Date: Thu, 1 Jan 2026 02:39:28 +0100 Subject: [PATCH 68/89] feat: add headscale server and UI configuration with Traefik routing --- .gitignore | 15 +- headscale/compose.yaml | 61 +++++ headscale/config/config.yaml.example | 325 +++++++++++++++++++++++++++ 3 files changed, 395 insertions(+), 6 deletions(-) create mode 100644 headscale/compose.yaml create mode 100644 headscale/config/config.yaml.example diff --git a/.gitignore b/.gitignore index 2761f85..b973855 100644 --- a/.gitignore +++ b/.gitignore @@ -2,12 +2,7 @@ sync.ffs_lock .sync.ffs_db -# Environment -.env -.env.anna -.env.forust -.env.* -!.env.*example + # Volumes and data directories gitea/gitea-db/ @@ -24,6 +19,7 @@ termix/termix-data/* cfddns/config.json checkmk/checkmk/* downtify/Downtify_downloads +headscale/config/* # Steaming services files streaming/jellyfin/* @@ -87,3 +83,10 @@ replacements.txt # Temp files edu_master/temp/ temp/* + +# Environment +.env +.env.anna +.env.forust +.env.* +!*example diff --git a/headscale/compose.yaml b/headscale/compose.yaml new file mode 100644 index 0000000..d86d50f --- /dev/null +++ b/headscale/compose.yaml @@ -0,0 +1,61 @@ +services: + server: + image: headscale/headscale:latest + restart: unless-stopped + command: headscale serve + networks: + - proxy + volumes: + - ./config:/etc/headscale + - ./data:/var/lib/headscale + labels: + - "traefik.enable=true" + - "traefik.docker.network=proxy" + - "traefik.http.services.headscale.loadbalancer.server.port=8080" + + # Prod Router + - "traefik.http.routers.headscale.rule=Host(`hs.forust.xyz`) && PathPrefix(`/`)" + - "traefik.http.routers.headscale.entrypoints=websecure" + - "traefik.http.routers.headscale.service=headscale" + - "traefik.http.routers.headscale.tls=true" + # Local Router + - "traefik.http.routers.headscale.rule=Host(`hs.workstation.internal`) && PathPrefix(`/`)" + - "traefik.http.routers.headscale.entrypoints=websecure" + - "traefik.http.routers.headscale.service=headscale" + - "traefik.http.routers.headscale.tls=true" + # Dev Router + - "traefik.http.routers.headscale.rule=Host(`hs.gigaforust.internal`) && PathPrefix(`/`)" + - "traefik.http.routers.headscale.entrypoints=websecure" + - "traefik.http.routers.headscale.service=headscale" + - "traefik.http.routers.headscale.tls=true" + ui: + image: ghcr.io/gurucomputing/headscale-ui:latest + container_name: headscale-ui + restart: unless-stopped + networks: + - proxy + labels: + # My normal config + - "traefik.enable=true" + - "traefik.http.services.headscale-ui.loadbalancer.server.port=80" + - "treafik.docker.network=proxy" + + # Prod Router + - "traefik.http.routers.headscale-ui.rule=Host(`hs.forust.xyz`) && PathPrefix(`/web`)" + - "traefik.http.routers.headscale-ui.entrypoints=websecure" + - "traefik.http.routers.headscale-ui.service=headscale-ui" + - "traefik.http.routers.headscale-ui.tls=true" + # Local Router + - "traefik.http.routers.headscale-ui-local.rule=Host(`hs.workstation.internal`) && PathPrefix(`/web`)" + - "traefik.http.routers.headscale-ui-local.entrypoints=websecure" + - "traefik.http.routers.headscale-ui-local.service=headscale-ui" + - "traefik.http.routers.headscale-ui-local.tls=true" + # Dev Router + - "traefik.http.routers.headscale-ui-dev.rule=Host(`hs.gigaforust.internal`) && PathPrefix(`/web`)" + - "traefik.http.routers.headscale-ui-dev.entrypoints=websecure" + - "traefik.http.routers.headscale-ui-dev.service=headscale-ui" + - "traefik.http.routers.headscale-ui-dev.tls=true" + +networks: + proxy: + external: true diff --git a/headscale/config/config.yaml.example b/headscale/config/config.yaml.example new file mode 100644 index 0000000..0dc572c --- /dev/null +++ b/headscale/config/config.yaml.example @@ -0,0 +1,325 @@ +--- +# headscale will look for a configuration file named `config.yaml` (or `config.json`) in the following order: +# +# - `/etc/headscale` +# - `~/.headscale` +# - current working directory + +# The url clients will connect to. +# Typically this will be a domain like: +# +# https://myheadscale.example.com:443 +# +server_url: https://hs.yourdomain.com:443 + +# Address to listen to / bind to on the server +# +# For production: +# listen_addr: 0.0.0.0:8080 +listen_addr: 0.0.0.0:8080 + +# Address to listen to /metrics, you may want +# to keep this endpoint private to your internal +# network +# +metrics_listen_addr: 127.0.0.1:9090 + +# Address to listen for gRPC. +# gRPC is used for controlling a headscale server +# remotely with the CLI +# Note: Remote access _only_ works if you have +# valid certificates. +# +# For production: +# grpc_listen_addr: 0.0.0.0:50443 +grpc_listen_addr: 127.0.0.1:50443 + +# Allow the gRPC admin interface to run in INSECURE +# mode. This is not recommended as the traffic will +# be unencrypted. Only enable if you know what you +# are doing. +grpc_allow_insecure: false + +# Private key used to encrypt the traffic between headscale +# and Tailscale clients. +# The private key file will be autogenerated if it's missing. +# +private_key_path: /var/lib/headscale/private.key + +# The Noise section includes specific configuration for the +# TS2021 Noise protocol +noise: + # The Noise private key is used to encrypt the + # traffic between headscale and Tailscale clients when + # using the new Noise-based protocol. It must be different + # from the legacy private key. + private_key_path: /var/lib/headscale/noise_private.key + +# List of IP prefixes to allocate tailaddresses from. +# Each prefix consists of either an IPv4 or IPv6 address, +# and the associated prefix length, delimited by a slash. +# It must be within IP ranges supported by the Tailscale +# client - i.e., subnets of 100.64.0.0/10 and fd7a:115c:a1e0::/48. +# See below: +# IPv6: https://github.com/tailscale/tailscale/blob/22ebb25e833264f58d7c3f534a8b166894a89536/net/tsaddr/tsaddr.go#LL81C52-L81C71 +# IPv4: https://github.com/tailscale/tailscale/blob/22ebb25e833264f58d7c3f534a8b166894a89536/net/tsaddr/tsaddr.go#L33 +# Any other range is NOT supported, and it will cause unexpected issues. +ip_prefixes: + - fd7a:115c:a1e0::/48 + - 100.64.0.0/10 + +# DERP is a relay system that Tailscale uses when a direct +# connection cannot be established. +# https://tailscale.com/blog/how-tailscale-works/#encrypted-tcp-relays-derp +# +# headscale needs a list of DERP servers that can be presented +# to the clients. +derp: + server: + # If enabled, runs the embedded DERP server and merges it into the rest of the DERP config + # The Headscale server_url defined above MUST be using https, DERP requires TLS to be in place + enabled: false + + # Region ID to use for the embedded DERP server. + # The local DERP prevails if the region ID collides with other region ID coming from + # the regular DERP config. + region_id: 999 + + # Region code and name are displayed in the Tailscale UI to identify a DERP region + region_code: "headscale" + region_name: "Headscale Embedded DERP" + + # Listens over UDP at the configured address for STUN connections - to help with NAT traversal. + # When the embedded DERP server is enabled stun_listen_addr MUST be defined. + # + # For more details on how this works, check this great article: https://tailscale.com/blog/how-tailscale-works/ + stun_listen_addr: "0.0.0.0:3478" + + # List of externally available DERP maps encoded in JSON + urls: + - https://controlplane.tailscale.com/derpmap/default + + # Locally available DERP map files encoded in YAML + # + # This option is mostly interesting for people hosting + # their own DERP servers: + # https://tailscale.com/kb/1118/custom-derp-servers/ + # + # paths: + # - /etc/headscale/derp-example.yaml + paths: [] + + # If enabled, a worker will be set up to periodically + # refresh the given sources and update the derpmap + # will be set up. + auto_update_enabled: true + + # How often should we check for DERP updates? + update_frequency: 24h + +# Disables the automatic check for headscale updates on startup +disable_check_updates: false + +# Time before an inactive ephemeral node is deleted? +ephemeral_node_inactivity_timeout: 30m + +# Period to check for node updates within the tailnet. A value too low will severely affect +# CPU consumption of Headscale. A value too high (over 60s) will cause problems +# for the nodes, as they won't get updates or keep alive messages frequently enough. +# In case of doubts, do not touch the default 10s. +node_update_check_interval: 10s + +# SQLite config +db_type: sqlite3 + +# For production: +db_path: /var/lib/headscale/db.sqlite + +# # Postgres config +# If using a Unix socket to connect to Postgres, set the socket path in the 'host' field and leave 'port' blank. +# db_type: postgres +# db_host: localhost +# db_port: 5432 +# db_name: headscale +# db_user: foo +# db_pass: bar + +# If other 'sslmode' is required instead of 'require(true)' and 'disabled(false)', set the 'sslmode' you need +# in the 'db_ssl' field. Refers to https://www.postgresql.org/docs/current/libpq-ssl.html Table 34.1. +# db_ssl: false + +### TLS configuration +# +## Let's encrypt / ACME +# +# headscale supports automatically requesting and setting up +# TLS for a domain with Let's Encrypt. +# +# URL to ACME directory +acme_url: https://acme-v02.api.letsencrypt.org/directory + +# Email to register with ACME provider +acme_email: "" + +# Domain name to request a TLS certificate for: +tls_letsencrypt_hostname: "" + +# Path to store certificates and metadata needed by +# letsencrypt +# For production: +tls_letsencrypt_cache_dir: /var/lib/headscale/cache + +# Type of ACME challenge to use, currently supported types: +# HTTP-01 or TLS-ALPN-01 +# See [docs/tls.md](docs/tls.md) for more information +tls_letsencrypt_challenge_type: HTTP-01 +# When HTTP-01 challenge is chosen, letsencrypt must set up a +# verification endpoint, and it will be listening on: +# :http = port 80 +tls_letsencrypt_listen: ":http" + +## Use already defined certificates: +tls_cert_path: "" +tls_key_path: "" + +log: + # Output formatting for logs: text or json + format: text + level: info + +# Path to a file containg ACL policies. +# ACLs can be defined as YAML or HUJSON. +# https://tailscale.com/kb/1018/acls/ +acl_policy_path: "" + +## DNS +# +# headscale supports Tailscale's DNS configuration and MagicDNS. +# Please have a look to their KB to better understand the concepts: +# +# - https://tailscale.com/kb/1054/dns/ +# - https://tailscale.com/kb/1081/magicdns/ +# - https://tailscale.com/blog/2021-09-private-dns-with-magicdns/ +# +dns_config: + # Whether to prefer using Headscale provided DNS or use local. + override_local_dns: true + + # List of DNS servers to expose to clients. + nameservers: + - 1.1.1.1 + + # NextDNS (see https://tailscale.com/kb/1218/nextdns/). + # "abc123" is example NextDNS ID, replace with yours. + # + # With metadata sharing: + # nameservers: + # - https://dns.nextdns.io/abc123 + # + # Without metadata sharing: + # nameservers: + # - 2a07:a8c0::ab:c123 + # - 2a07:a8c1::ab:c123 + + # Split DNS (see https://tailscale.com/kb/1054/dns/), + # list of search domains and the DNS to query for each one. + # + # restricted_nameservers: + # foo.bar.com: + # - 1.1.1.1 + # darp.headscale.net: + # - 1.1.1.1 + # - 8.8.8.8 + + # Search domains to inject. + domains: [] + + # Extra DNS records + # so far only A-records are supported (on the tailscale side) + # See https://github.com/juanfont/headscale/blob/main/docs/dns-records.md#Limitations + # extra_records: + # - name: "grafana.myvpn.example.com" + # type: "A" + # value: "100.64.0.3" + # + # # you can also put it in one line + # - { name: "prometheus.myvpn.example.com", type: "A", value: "100.64.0.3" } + + # Whether to use [MagicDNS](https://tailscale.com/kb/1081/magicdns/). + # Only works if there is at least a nameserver defined. + magic_dns: true + + # Defines the base domain to create the hostnames for MagicDNS. + # `base_domain` must be a FQDNs, without the trailing dot. + # The FQDN of the hosts will be + # `hostname.user.base_domain` (e.g., _myhost.myuser.example.com_). + base_domain: example.com + +# Unix socket used for the CLI to connect without authentication +# Note: for production you will want to set this to something like: +unix_socket: /var/run/headscale/headscale.sock +unix_socket_permission: "0770" +# +# headscale supports experimental OpenID connect support, +# it is still being tested and might have some bugs, please +# help us test it. +# OpenID Connect +# oidc: +# only_start_if_oidc_is_available: true +# issuer: "https://auth.yourdomain.com" +# client_id: "zP5EQnctz7TxGgMBh5QnjnuYHciCqjnx" +# client_secret: "tjsZKmTGcB4tXgr16hG003qhmznZZSzuZgeGWfkXjuADiYFaf92MnBidSfnvGn8I" +# # Alternatively, set `client_secret_path` to read the secret from the file. +# # It resolves environment variables, making integration to systemd's +# # `LoadCredential` straightforward: +# # client_secret_path: "${CREDENTIALS_DIRECTORY}/oidc_client_secret" +# # client_secret and client_secret_path are mutually exclusive. + +# # The amount of time from a node is authenticated with OpenID until it +# # expires and needs to reauthenticate. +# # Setting the value to "0" will mean no expiry. +# expiry: 180d + +# # Use the expiry from the token received from OpenID when the user logged +# # in, this will typically lead to frequent need to reauthenticate and should +# # only been enabled if you know what you are doing. +# # Note: enabling this will cause `oidc.expiry` to be ignored. +# use_expiry_from_token: false + +# # Customize the scopes used in the OIDC flow, defaults to "openid", "profile" and "email" and add custom query +# # parameters to the Authorize Endpoint request. Scopes default to "openid", "profile" and "email". + +# scope: ["openid", "profile", "email", "custom"] +# extra_params: +# domain_hint: yourdomain.com + +# # List allowed principal domains and/or users. If an authenticated user's domain is not in this list, the +# # authentication request will be rejected. + +# allowed_domains: +# # Note: Groups from keycloak have a leading '/' +# allowed_groups: +# - admin +# allowed_users: +# - user@yourdomain.com + +# # If `strip_email_domain` is set to `true`, the domain part of the username email address will be removed. +# # This will transform `first-name.last-name@example.com` to the user `first-name.last-name` +# # If `strip_email_domain` is set to `false` the domain part will NOT be removed resulting to the following +# # user: `first-name.last-name.example.com` + +# strip_email_domain: false + +# Logtail configuration +# Logtail is Tailscales logging and auditing infrastructure, it allows the control panel +# to instruct tailscale nodes to log their activity to a remote server. +logtail: + # Enable logtail for this headscales clients. + # As there is currently no support for overriding the log server in headscale, this is + # disabled by default. Enabling this will make your clients send logs to Tailscale Inc. + enabled: false + +# Enabling this option makes devices prefer a random port for WireGuard traffic over the +# default static port 41641. This option is intended as a workaround for some buggy +# firewall devices. See https://tailscale.com/kb/1181/firewalls/ for more information. +randomize_client_port: false \ No newline at end of file From ce0bc4613ac525797c95730004da767270e6783c Mon Sep 17 00:00:00 2001 From: mr-forust Date: Thu, 1 Jan 2026 03:19:45 +0100 Subject: [PATCH 69/89] fix: headscale command --- .gitignore | 1 + headscale/compose.yaml | 2 +- headscale/config/config.yaml.example | 325 --------------------------- 3 files changed, 2 insertions(+), 326 deletions(-) delete mode 100644 headscale/config/config.yaml.example diff --git a/.gitignore b/.gitignore index b973855..ed4a51d 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ cfddns/config.json checkmk/checkmk/* downtify/Downtify_downloads headscale/config/* +headscale/data/* # Steaming services files streaming/jellyfin/* diff --git a/headscale/compose.yaml b/headscale/compose.yaml index d86d50f..c028313 100644 --- a/headscale/compose.yaml +++ b/headscale/compose.yaml @@ -2,7 +2,7 @@ services: server: image: headscale/headscale:latest restart: unless-stopped - command: headscale serve + command: serve networks: - proxy volumes: diff --git a/headscale/config/config.yaml.example b/headscale/config/config.yaml.example deleted file mode 100644 index 0dc572c..0000000 --- a/headscale/config/config.yaml.example +++ /dev/null @@ -1,325 +0,0 @@ ---- -# headscale will look for a configuration file named `config.yaml` (or `config.json`) in the following order: -# -# - `/etc/headscale` -# - `~/.headscale` -# - current working directory - -# The url clients will connect to. -# Typically this will be a domain like: -# -# https://myheadscale.example.com:443 -# -server_url: https://hs.yourdomain.com:443 - -# Address to listen to / bind to on the server -# -# For production: -# listen_addr: 0.0.0.0:8080 -listen_addr: 0.0.0.0:8080 - -# Address to listen to /metrics, you may want -# to keep this endpoint private to your internal -# network -# -metrics_listen_addr: 127.0.0.1:9090 - -# Address to listen for gRPC. -# gRPC is used for controlling a headscale server -# remotely with the CLI -# Note: Remote access _only_ works if you have -# valid certificates. -# -# For production: -# grpc_listen_addr: 0.0.0.0:50443 -grpc_listen_addr: 127.0.0.1:50443 - -# Allow the gRPC admin interface to run in INSECURE -# mode. This is not recommended as the traffic will -# be unencrypted. Only enable if you know what you -# are doing. -grpc_allow_insecure: false - -# Private key used to encrypt the traffic between headscale -# and Tailscale clients. -# The private key file will be autogenerated if it's missing. -# -private_key_path: /var/lib/headscale/private.key - -# The Noise section includes specific configuration for the -# TS2021 Noise protocol -noise: - # The Noise private key is used to encrypt the - # traffic between headscale and Tailscale clients when - # using the new Noise-based protocol. It must be different - # from the legacy private key. - private_key_path: /var/lib/headscale/noise_private.key - -# List of IP prefixes to allocate tailaddresses from. -# Each prefix consists of either an IPv4 or IPv6 address, -# and the associated prefix length, delimited by a slash. -# It must be within IP ranges supported by the Tailscale -# client - i.e., subnets of 100.64.0.0/10 and fd7a:115c:a1e0::/48. -# See below: -# IPv6: https://github.com/tailscale/tailscale/blob/22ebb25e833264f58d7c3f534a8b166894a89536/net/tsaddr/tsaddr.go#LL81C52-L81C71 -# IPv4: https://github.com/tailscale/tailscale/blob/22ebb25e833264f58d7c3f534a8b166894a89536/net/tsaddr/tsaddr.go#L33 -# Any other range is NOT supported, and it will cause unexpected issues. -ip_prefixes: - - fd7a:115c:a1e0::/48 - - 100.64.0.0/10 - -# DERP is a relay system that Tailscale uses when a direct -# connection cannot be established. -# https://tailscale.com/blog/how-tailscale-works/#encrypted-tcp-relays-derp -# -# headscale needs a list of DERP servers that can be presented -# to the clients. -derp: - server: - # If enabled, runs the embedded DERP server and merges it into the rest of the DERP config - # The Headscale server_url defined above MUST be using https, DERP requires TLS to be in place - enabled: false - - # Region ID to use for the embedded DERP server. - # The local DERP prevails if the region ID collides with other region ID coming from - # the regular DERP config. - region_id: 999 - - # Region code and name are displayed in the Tailscale UI to identify a DERP region - region_code: "headscale" - region_name: "Headscale Embedded DERP" - - # Listens over UDP at the configured address for STUN connections - to help with NAT traversal. - # When the embedded DERP server is enabled stun_listen_addr MUST be defined. - # - # For more details on how this works, check this great article: https://tailscale.com/blog/how-tailscale-works/ - stun_listen_addr: "0.0.0.0:3478" - - # List of externally available DERP maps encoded in JSON - urls: - - https://controlplane.tailscale.com/derpmap/default - - # Locally available DERP map files encoded in YAML - # - # This option is mostly interesting for people hosting - # their own DERP servers: - # https://tailscale.com/kb/1118/custom-derp-servers/ - # - # paths: - # - /etc/headscale/derp-example.yaml - paths: [] - - # If enabled, a worker will be set up to periodically - # refresh the given sources and update the derpmap - # will be set up. - auto_update_enabled: true - - # How often should we check for DERP updates? - update_frequency: 24h - -# Disables the automatic check for headscale updates on startup -disable_check_updates: false - -# Time before an inactive ephemeral node is deleted? -ephemeral_node_inactivity_timeout: 30m - -# Period to check for node updates within the tailnet. A value too low will severely affect -# CPU consumption of Headscale. A value too high (over 60s) will cause problems -# for the nodes, as they won't get updates or keep alive messages frequently enough. -# In case of doubts, do not touch the default 10s. -node_update_check_interval: 10s - -# SQLite config -db_type: sqlite3 - -# For production: -db_path: /var/lib/headscale/db.sqlite - -# # Postgres config -# If using a Unix socket to connect to Postgres, set the socket path in the 'host' field and leave 'port' blank. -# db_type: postgres -# db_host: localhost -# db_port: 5432 -# db_name: headscale -# db_user: foo -# db_pass: bar - -# If other 'sslmode' is required instead of 'require(true)' and 'disabled(false)', set the 'sslmode' you need -# in the 'db_ssl' field. Refers to https://www.postgresql.org/docs/current/libpq-ssl.html Table 34.1. -# db_ssl: false - -### TLS configuration -# -## Let's encrypt / ACME -# -# headscale supports automatically requesting and setting up -# TLS for a domain with Let's Encrypt. -# -# URL to ACME directory -acme_url: https://acme-v02.api.letsencrypt.org/directory - -# Email to register with ACME provider -acme_email: "" - -# Domain name to request a TLS certificate for: -tls_letsencrypt_hostname: "" - -# Path to store certificates and metadata needed by -# letsencrypt -# For production: -tls_letsencrypt_cache_dir: /var/lib/headscale/cache - -# Type of ACME challenge to use, currently supported types: -# HTTP-01 or TLS-ALPN-01 -# See [docs/tls.md](docs/tls.md) for more information -tls_letsencrypt_challenge_type: HTTP-01 -# When HTTP-01 challenge is chosen, letsencrypt must set up a -# verification endpoint, and it will be listening on: -# :http = port 80 -tls_letsencrypt_listen: ":http" - -## Use already defined certificates: -tls_cert_path: "" -tls_key_path: "" - -log: - # Output formatting for logs: text or json - format: text - level: info - -# Path to a file containg ACL policies. -# ACLs can be defined as YAML or HUJSON. -# https://tailscale.com/kb/1018/acls/ -acl_policy_path: "" - -## DNS -# -# headscale supports Tailscale's DNS configuration and MagicDNS. -# Please have a look to their KB to better understand the concepts: -# -# - https://tailscale.com/kb/1054/dns/ -# - https://tailscale.com/kb/1081/magicdns/ -# - https://tailscale.com/blog/2021-09-private-dns-with-magicdns/ -# -dns_config: - # Whether to prefer using Headscale provided DNS or use local. - override_local_dns: true - - # List of DNS servers to expose to clients. - nameservers: - - 1.1.1.1 - - # NextDNS (see https://tailscale.com/kb/1218/nextdns/). - # "abc123" is example NextDNS ID, replace with yours. - # - # With metadata sharing: - # nameservers: - # - https://dns.nextdns.io/abc123 - # - # Without metadata sharing: - # nameservers: - # - 2a07:a8c0::ab:c123 - # - 2a07:a8c1::ab:c123 - - # Split DNS (see https://tailscale.com/kb/1054/dns/), - # list of search domains and the DNS to query for each one. - # - # restricted_nameservers: - # foo.bar.com: - # - 1.1.1.1 - # darp.headscale.net: - # - 1.1.1.1 - # - 8.8.8.8 - - # Search domains to inject. - domains: [] - - # Extra DNS records - # so far only A-records are supported (on the tailscale side) - # See https://github.com/juanfont/headscale/blob/main/docs/dns-records.md#Limitations - # extra_records: - # - name: "grafana.myvpn.example.com" - # type: "A" - # value: "100.64.0.3" - # - # # you can also put it in one line - # - { name: "prometheus.myvpn.example.com", type: "A", value: "100.64.0.3" } - - # Whether to use [MagicDNS](https://tailscale.com/kb/1081/magicdns/). - # Only works if there is at least a nameserver defined. - magic_dns: true - - # Defines the base domain to create the hostnames for MagicDNS. - # `base_domain` must be a FQDNs, without the trailing dot. - # The FQDN of the hosts will be - # `hostname.user.base_domain` (e.g., _myhost.myuser.example.com_). - base_domain: example.com - -# Unix socket used for the CLI to connect without authentication -# Note: for production you will want to set this to something like: -unix_socket: /var/run/headscale/headscale.sock -unix_socket_permission: "0770" -# -# headscale supports experimental OpenID connect support, -# it is still being tested and might have some bugs, please -# help us test it. -# OpenID Connect -# oidc: -# only_start_if_oidc_is_available: true -# issuer: "https://auth.yourdomain.com" -# client_id: "zP5EQnctz7TxGgMBh5QnjnuYHciCqjnx" -# client_secret: "tjsZKmTGcB4tXgr16hG003qhmznZZSzuZgeGWfkXjuADiYFaf92MnBidSfnvGn8I" -# # Alternatively, set `client_secret_path` to read the secret from the file. -# # It resolves environment variables, making integration to systemd's -# # `LoadCredential` straightforward: -# # client_secret_path: "${CREDENTIALS_DIRECTORY}/oidc_client_secret" -# # client_secret and client_secret_path are mutually exclusive. - -# # The amount of time from a node is authenticated with OpenID until it -# # expires and needs to reauthenticate. -# # Setting the value to "0" will mean no expiry. -# expiry: 180d - -# # Use the expiry from the token received from OpenID when the user logged -# # in, this will typically lead to frequent need to reauthenticate and should -# # only been enabled if you know what you are doing. -# # Note: enabling this will cause `oidc.expiry` to be ignored. -# use_expiry_from_token: false - -# # Customize the scopes used in the OIDC flow, defaults to "openid", "profile" and "email" and add custom query -# # parameters to the Authorize Endpoint request. Scopes default to "openid", "profile" and "email". - -# scope: ["openid", "profile", "email", "custom"] -# extra_params: -# domain_hint: yourdomain.com - -# # List allowed principal domains and/or users. If an authenticated user's domain is not in this list, the -# # authentication request will be rejected. - -# allowed_domains: -# # Note: Groups from keycloak have a leading '/' -# allowed_groups: -# - admin -# allowed_users: -# - user@yourdomain.com - -# # If `strip_email_domain` is set to `true`, the domain part of the username email address will be removed. -# # This will transform `first-name.last-name@example.com` to the user `first-name.last-name` -# # If `strip_email_domain` is set to `false` the domain part will NOT be removed resulting to the following -# # user: `first-name.last-name.example.com` - -# strip_email_domain: false - -# Logtail configuration -# Logtail is Tailscales logging and auditing infrastructure, it allows the control panel -# to instruct tailscale nodes to log their activity to a remote server. -logtail: - # Enable logtail for this headscales clients. - # As there is currently no support for overriding the log server in headscale, this is - # disabled by default. Enabling this will make your clients send logs to Tailscale Inc. - enabled: false - -# Enabling this option makes devices prefer a random port for WireGuard traffic over the -# default static port 41641. This option is intended as a workaround for some buggy -# firewall devices. See https://tailscale.com/kb/1181/firewalls/ for more information. -randomize_client_port: false \ No newline at end of file From 122e6f6986a49220ef1618f37d2b446fe5e31029 Mon Sep 17 00:00:00 2001 From: mr-forust Date: Fri, 2 Jan 2026 00:37:47 +0100 Subject: [PATCH 70/89] Revert "chore: gitea runners gitignored" This reverts commit e0f7ab656182397ff67b5d73a97723779ceac4c2. --- .gitignore | 1 - 1 file changed, 1 deletion(-) diff --git a/.gitignore b/.gitignore index 2761f85..63c171b 100644 --- a/.gitignore +++ b/.gitignore @@ -12,7 +12,6 @@ sync.ffs_lock # Volumes and data directories gitea/gitea-db/ gitea/gitea-data/* -gitea/*runner/* n8n/n8n-data/* n8n/n8n-node-data/* adguardhome/data/* From 26f5fb2fb9d7fa37e997cd8e85c1c4c10dc4dd7b Mon Sep 17 00:00:00 2001 From: mr-forust Date: Fri, 2 Jan 2026 00:38:04 +0100 Subject: [PATCH 71/89] Revert "feat: add gitea-basic-runner" This reverts commit f0682319a79ca72e084f68553318c2267f3ab445. --- gitea/.env.example | 1 - gitea/compose.yaml | 19 ------------------- 2 files changed, 20 deletions(-) diff --git a/gitea/.env.example b/gitea/.env.example index 672c2e6..916e272 100644 --- a/gitea/.env.example +++ b/gitea/.env.example @@ -1,7 +1,6 @@ GITEA_POSTGRES_USER= GITEA_POSTGRES_PASSWORD= GITEA_POSTGRES_DB=gitea -BASIC-RUNNER_TOKEN= GITEA_SMTP_PASS= MAILER_ADDR= SERVICE_EMAIL=email.used.by.services@domain.tld \ No newline at end of file diff --git a/gitea/compose.yaml b/gitea/compose.yaml index bf2d696..16ca04f 100644 --- a/gitea/compose.yaml +++ b/gitea/compose.yaml @@ -65,25 +65,6 @@ services: depends_on: - db - runner: - image: gitea/act_runner:0.2.11 - container_name: gitea-runner - restart: always - depends_on: - - server - env_file: - - .env - networks: - - gitea-db - environment: - - GITEA_INSTANCE_URL=http://server:3000 - - GITEA_RUNNER_REGISTRATION_TOKEN=${BASIC-RUNNER_TOKEN} - - GITEA_RUNNER_NAME=basic-runner - - GITEA_RUNNER_LABELS=docker:docker://node:20-bookworm,ubuntu-latest:docker://node:20-bookworm - volumes: - - ./gitea-runner:/data - - /var/run/docker.sock:/var/run/docker.sock - db: image: docker.io/library/postgres:14 restart: always From 023452463579c2950ace33661834f1be1170abe8 Mon Sep 17 00:00:00 2001 From: mr-forust Date: Fri, 2 Jan 2026 01:29:51 +0100 Subject: [PATCH 72/89] feat: headscale implementation - Headscale server - Using goodieshq/headscale-admin as ui - UI protected by security-chain - Metrics available at /metrics --- headscale/compose.yaml | 56 +++++++++++++------- headscale/config/config.yaml.example | 79 ++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+), 18 deletions(-) create mode 100644 headscale/config/config.yaml.example diff --git a/headscale/compose.yaml b/headscale/compose.yaml index c028313..0eb13db 100644 --- a/headscale/compose.yaml +++ b/headscale/compose.yaml @@ -12,47 +12,67 @@ services: - "traefik.enable=true" - "traefik.docker.network=proxy" - "traefik.http.services.headscale.loadbalancer.server.port=8080" + - "traefik.http.services.headscale-metrics.loadbalancer.server.port=9090" + ## SERVICE # Prod Router - - "traefik.http.routers.headscale.rule=Host(`hs.forust.xyz`) && PathPrefix(`/`)" + - "traefik.http.routers.headscale.rule=Host(`hs.forust.xyz`)" - "traefik.http.routers.headscale.entrypoints=websecure" - "traefik.http.routers.headscale.service=headscale" - "traefik.http.routers.headscale.tls=true" # Local Router - - "traefik.http.routers.headscale.rule=Host(`hs.workstation.internal`) && PathPrefix(`/`)" - - "traefik.http.routers.headscale.entrypoints=websecure" - - "traefik.http.routers.headscale.service=headscale" - - "traefik.http.routers.headscale.tls=true" + - "traefik.http.routers.headscale-local.rule=Host(`hs.workstation.internal`)" + - "traefik.http.routers.headscale-local.entrypoints=websecure" + - "traefik.http.routers.headscale-local.service=headscale" + - "traefik.http.routers.headscale-local.tls=true" # Dev Router - - "traefik.http.routers.headscale.rule=Host(`hs.gigaforust.internal`) && PathPrefix(`/`)" - - "traefik.http.routers.headscale.entrypoints=websecure" - - "traefik.http.routers.headscale.service=headscale" - - "traefik.http.routers.headscale.tls=true" - ui: - image: ghcr.io/gurucomputing/headscale-ui:latest - container_name: headscale-ui + - "traefik.http.routers.headscale-dev.rule=Host(`hs.gigaforust.internal`)" + - "traefik.http.routers.headscale-dev.entrypoints=websecure" + - "traefik.http.routers.headscale-dev.service=headscale" + - "traefik.http.routers.headscale-dev.tls=true" + + ## METRICS + # Prod Router + - "traefik.http.routers.headscale-metrics.rule=Host(`hs.forust.xyz`) && PathPrefix(`/metrics`)" + - "traefik.http.routers.headscale-metrics.entrypoints=websecure" + - "traefik.http.routers.headscale-metrics.service=headscale-metrics" + - "traefik.http.routers.headscale-metrics.tls=true" + # Local Router + - "traefik.http.routers.headscale-metrics-local.rule=Host(`hs.workstation.internal`) && PathPrefix(`/metrics`)" + - "traefik.http.routers.headscale-metrics-local.entrypoints=websecure" + - "traefik.http.routers.headscale-metrics-local.service=headscale-metrics" + - "traefik.http.routers.headscale-metrics-local.tls=true" + # Dev Router + - "traefik.http.routers.headscale-metrics-dev.rule=Host(`hs.gigaforust.internal`) && PathPrefix(`/metrics`)" + - "traefik.http.routers.headscale-metrics-dev.entrypoints=websecure" + - "traefik.http.routers.headscale-metrics-dev.service=headscale-metrics" + - "traefik.http.routers.headscale-metrics-dev.tls=true" + admin-ui: + image: goodieshq/headscale-admin:latest restart: unless-stopped networks: - proxy labels: - # My normal config - "traefik.enable=true" - - "traefik.http.services.headscale-ui.loadbalancer.server.port=80" - "treafik.docker.network=proxy" - + - "traefik.http.services.headscale-ui.loadbalancer.server.port=80" + # Prod Router - - "traefik.http.routers.headscale-ui.rule=Host(`hs.forust.xyz`) && PathPrefix(`/web`)" + - "traefik.http.routers.headscale-ui.rule=Host(`hs.forust.xyz`) && PathPrefix(`/admin`)" - "traefik.http.routers.headscale-ui.entrypoints=websecure" + - "traefik.http.routers.headscale-ui.middlewares=security-chain@file" - "traefik.http.routers.headscale-ui.service=headscale-ui" - "traefik.http.routers.headscale-ui.tls=true" # Local Router - - "traefik.http.routers.headscale-ui-local.rule=Host(`hs.workstation.internal`) && PathPrefix(`/web`)" + - "traefik.http.routers.headscale-ui-local.rule=Host(`hs.workstation.internal`) && PathPrefix(`/admin`)" - "traefik.http.routers.headscale-ui-local.entrypoints=websecure" + - "traefik.http.routers.headscale-ui.middlewares=security-chain@file" - "traefik.http.routers.headscale-ui-local.service=headscale-ui" - "traefik.http.routers.headscale-ui-local.tls=true" # Dev Router - - "traefik.http.routers.headscale-ui-dev.rule=Host(`hs.gigaforust.internal`) && PathPrefix(`/web`)" + - "traefik.http.routers.headscale-ui-dev.rule=Host(`hs.gigaforust.internal`) && PathPrefix(`/admin`)" - "traefik.http.routers.headscale-ui-dev.entrypoints=websecure" + - "traefik.http.routers.headscale-ui.middlewares=security-chain@file" - "traefik.http.routers.headscale-ui-dev.service=headscale-ui" - "traefik.http.routers.headscale-ui-dev.tls=true" diff --git a/headscale/config/config.yaml.example b/headscale/config/config.yaml.example new file mode 100644 index 0000000..1a485bf --- /dev/null +++ b/headscale/config/config.yaml.example @@ -0,0 +1,79 @@ +# https://wiki.serversatho.me/en/headscale + +# # Prod server_url +# server_url: https://hs.example.com +# # Local server_url +# server_url: https://hs.internal_domain.internal +# # Dev server_url +# server_url: https://hs.dev_internal_domain.internal +listen_addr: 0.0.0.0:8080 +metrics_listen_addr: 127.0.0.1:9090 +grpc_listen_addr: 127.0.0.1:50443 +grpc_allow_insecure: false +noise: + private_key_path: /var/lib/headscale/noise_private.key +prefixes: + v4: 100.64.0.0/10 + v6: fd7a:115c:a1e0::/48 + allocation: sequential +derp: + server: + enabled: true + region_id: 999 + region_code: "headscale" + region_name: "Headscale Embedded DERP" + stun_listen_addr: "0.0.0.0:3478" + private_key_path: /var/lib/headscale/derp_server_private.key + automatically_add_embedded_derp_region: true + ipv4: 1.2.3.4 + ipv6: 2001:db8::1 + urls: + - https://controlplane.tailscale.com/derpmap/default + paths: [] + auto_update_enabled: true + update_frequency: 24h +disable_check_updates: false +ephemeral_node_inactivity_timeout: 30m +database: + type: sqlite + debug: false + gorm: + prepare_stmt: true + parameterized_queries: true + skip_err_record_not_found: true + slow_threshold: 1000 + sqlite: + path: /var/lib/headscale/db.sqlite + write_ahead_log: true + wal_autocheckpoint: 1000 +acme_url: https://acme-v02.api.letsencrypt.org/directory +acme_email: "" +tls_letsencrypt_hostname: "" +tls_letsencrypt_cache_dir: /var/lib/headscale/cache +tls_letsencrypt_challenge_type: HTTP-01 +tls_letsencrypt_listen: ":http" +tls_cert_path: "" +tls_key_path: "" +log: + format: text + level: info +policy: + mode: database + path: "" +dns: + magic_dns: true + base_domain: example.com + nameservers: + global: + - 1.1.1.1 + - 1.0.0.1 + - 2606:4700:4700::1111 + - 2606:4700:4700::1001 + split: {} + search_domains: [] + extra_records: [] +unix_socket: /var/run/headscale/headscale.sock +unix_socket_permission: "0770" +logtail: + enabled: false +randomize_client_port: false From 21f4e460283d1770150baa3fbc66b3a8bf9facaf Mon Sep 17 00:00:00 2001 From: mr-forust Date: Fri, 2 Jan 2026 03:06:24 +0100 Subject: [PATCH 73/89] chore: container name change for headscale web panel --- headscale/compose.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/headscale/compose.yaml b/headscale/compose.yaml index 0eb13db..9d07753 100644 --- a/headscale/compose.yaml +++ b/headscale/compose.yaml @@ -47,7 +47,7 @@ services: - "traefik.http.routers.headscale-metrics-dev.entrypoints=websecure" - "traefik.http.routers.headscale-metrics-dev.service=headscale-metrics" - "traefik.http.routers.headscale-metrics-dev.tls=true" - admin-ui: + web: image: goodieshq/headscale-admin:latest restart: unless-stopped networks: From a9edfc0a25dc09160b2deef4f8eae0ace9bef39a Mon Sep 17 00:00:00 2001 From: mr-forust Date: Fri, 2 Jan 2026 13:06:49 +0100 Subject: [PATCH 74/89] fix: traefik middlewares for local and dev envs --- headscale/compose.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/headscale/compose.yaml b/headscale/compose.yaml index 9d07753..b934b1e 100644 --- a/headscale/compose.yaml +++ b/headscale/compose.yaml @@ -56,7 +56,7 @@ services: - "traefik.enable=true" - "treafik.docker.network=proxy" - "traefik.http.services.headscale-ui.loadbalancer.server.port=80" - + # Prod Router - "traefik.http.routers.headscale-ui.rule=Host(`hs.forust.xyz`) && PathPrefix(`/admin`)" - "traefik.http.routers.headscale-ui.entrypoints=websecure" @@ -66,13 +66,13 @@ services: # Local Router - "traefik.http.routers.headscale-ui-local.rule=Host(`hs.workstation.internal`) && PathPrefix(`/admin`)" - "traefik.http.routers.headscale-ui-local.entrypoints=websecure" - - "traefik.http.routers.headscale-ui.middlewares=security-chain@file" + - "traefik.http.routers.headscale-ui-local.middlewares=security-chain@file" - "traefik.http.routers.headscale-ui-local.service=headscale-ui" - "traefik.http.routers.headscale-ui-local.tls=true" # Dev Router - "traefik.http.routers.headscale-ui-dev.rule=Host(`hs.gigaforust.internal`) && PathPrefix(`/admin`)" - "traefik.http.routers.headscale-ui-dev.entrypoints=websecure" - - "traefik.http.routers.headscale-ui.middlewares=security-chain@file" + - "traefik.http.routers.headscale-ui-dev.middlewares=security-chain@file" - "traefik.http.routers.headscale-ui-dev.service=headscale-ui" - "traefik.http.routers.headscale-ui-dev.tls=true" From 89576032b93cf90f498fe5c24fc3c3d6f6c1bc4c Mon Sep 17 00:00:00 2001 From: mr-forust Date: Sat, 3 Jan 2026 00:04:32 +0100 Subject: [PATCH 75/89] fix: update DNS settings for headscale --- headscale/compose.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/headscale/compose.yaml b/headscale/compose.yaml index b934b1e..e4c299e 100644 --- a/headscale/compose.yaml +++ b/headscale/compose.yaml @@ -47,6 +47,9 @@ services: - "traefik.http.routers.headscale-metrics-dev.entrypoints=websecure" - "traefik.http.routers.headscale-metrics-dev.service=headscale-metrics" - "traefik.http.routers.headscale-metrics-dev.tls=true" + dns: + - 1.1.1.1 + - 1.0.0.1 web: image: goodieshq/headscale-admin:latest restart: unless-stopped From e4e9d96a0b31177e5bfe38c4166c2200f66d6ef8 Mon Sep 17 00:00:00 2001 From: mr-forust Date: Sat, 3 Jan 2026 02:25:12 +0100 Subject: [PATCH 76/89] add pgp pubkey to landing page, minor info changes --- .gitignore | 2 +- homepages/forust_files/index.html | 9 ++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index c07bb84..e02f82b 100644 --- a/.gitignore +++ b/.gitignore @@ -32,7 +32,7 @@ streaming/prowlarr/* # Homepage homepages/forust_files/assets/images/team/* - +homepages/forust_files/.well-known/* # Traefik files traefik/letsencrypt/acme.json diff --git a/homepages/forust_files/index.html b/homepages/forust_files/index.html index 3c2f5eb..8aff9c9 100644 --- a/homepages/forust_files/index.html +++ b/homepages/forust_files/index.html @@ -41,7 +41,14 @@
  • - forust@forust.xyz + mail/contact@forust.xyz +
  • +
  • + + security/PGP Key +
  • +
  • +

    # PGP Key Fingerprint: A777 7CB7 D9C4 0A97 443D CCF0 7A3D A455 F820 5B82

  • From 2b03335af5d870fcfbb56bd9abda16b8a99ef018 Mon Sep 17 00:00:00 2001 From: mr-forust Date: Tue, 6 Jan 2026 19:04:23 +0100 Subject: [PATCH 77/89] chore: shorten aio subdomain --- nextcloud/compose.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nextcloud/compose.yaml b/nextcloud/compose.yaml index dfc77a3..62de8ca 100644 --- a/nextcloud/compose.yaml +++ b/nextcloud/compose.yaml @@ -25,21 +25,21 @@ services: - "traefik.http.services.nextcloud-aio.loadbalancer.serverstransport=insecureTransport@file" # Prod Router - # - "traefik.http.routers.nextcloud-aio.rule=Host(`nextcloud-aio.forust.xyz`)" + # - "traefik.http.routers.nextcloud-aio.rule=Host(`naio.forust.xyz`)" # - "traefik.http.routers.nextcloud-aio.entrypoints=websecure" - # - "traefik.http.routers.nextcloud-aio.middlewares=security-headers" + # - "traefik.http.routers.nextcloud-aio.middlewares=security-chain@file" # - "traefik.http.routers.nextcloud-aio.service=nextcloud-aio" # - "traefik.http.routers.nextcloud-aio.tls=true" # Local Router - - "traefik.http.routers.nextcloud-aio-local.rule=Host(`nextcloud-aio.workstation.internal`) || Host(`nextcloud-aio.internal`)" + - "traefik.http.routers.nextcloud-aio-local.rule=Host(`naio.workstation.internal`) || Host(`nextcloud-aio.internal`)" - "traefik.http.routers.nextcloud-aio-local.entrypoints=websecure" - "traefik.http.routers.nextcloud-aio-local.middlewares=security-headers@file" - "traefik.http.routers.nextcloud-aio-local.service=nextcloud-aio" - "traefik.http.routers.nextcloud-aio-local.tls=true" # Dev Router - - "traefik.http.routers.nextcloud-aio-dev.rule=Host(`nextcloud-aio.gigaforust.internal`)" + - "traefik.http.routers.nextcloud-aio-dev.rule=Host(`naio.gigaforust.internal`)" - "traefik.http.routers.nextcloud-aio-dev.entrypoints=websecure" - "traefik.http.routers.nextcloud-aio-dev.middlewares=security-headers@file" - "traefik.http.routers.nextcloud-aio-dev.service=nextcloud-aio" From 9b43a9bef4023e7d18a05248364a172fe7e4e98c Mon Sep 17 00:00:00 2001 From: mr-forust Date: Fri, 9 Jan 2026 14:48:42 +0100 Subject: [PATCH 78/89] feat: add traefik configuration for external fileservers --- .gitignore | 3 +-- traefik/dynamic/fileservers.yml.example | 23 +++++++++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) create mode 100644 traefik/dynamic/fileservers.yml.example diff --git a/.gitignore b/.gitignore index e02f82b..41f6e38 100644 --- a/.gitignore +++ b/.gitignore @@ -2,8 +2,6 @@ sync.ffs_lock .sync.ffs_db - - # Volumes and data directories gitea/gitea-db/ gitea/gitea-data/* @@ -36,6 +34,7 @@ homepages/forust_files/.well-known/* # Traefik files traefik/letsencrypt/acme.json +traefik/dynamic/fileservers.yml traefik/logs/* # SSL Certificates diff --git a/traefik/dynamic/fileservers.yml.example b/traefik/dynamic/fileservers.yml.example new file mode 100644 index 0000000..8bafc4c --- /dev/null +++ b/traefik/dynamic/fileservers.yml.example @@ -0,0 +1,23 @@ +http: + routers: + fs1-public: + rule: "Host(`fs1.domain.xyz`)" + entrypoints: + - websecure + service: fs1 + middlewares: + - security-chain@file + tls: {} + + fs1-workstation: + rule: "Host(`fs1.workstation.internal`)" + entrypoints: + - websecure + service: fs1 + tls: {} + + services: + fs1: + loadBalancer: + servers: + - url: "http://127.0.0.1:3923" # Copyparty port example From 9480576966d7c092ec2fa3003f5ff69eafea9952 Mon Sep 17 00:00:00 2001 From: mr-forust Date: Mon, 12 Jan 2026 15:33:42 +0100 Subject: [PATCH 79/89] fix: update bots' code to match .env keys --- edu_master/phpsessid-bot/bot.py | 29 ++++++++++++++++++--------- edu_master/webinar-checker/checker.py | 25 +++++++++++++++-------- 2 files changed, 37 insertions(+), 17 deletions(-) diff --git a/edu_master/phpsessid-bot/bot.py b/edu_master/phpsessid-bot/bot.py index 48aa466..d74cc76 100644 --- a/edu_master/phpsessid-bot/bot.py +++ b/edu_master/phpsessid-bot/bot.py @@ -12,15 +12,26 @@ logging.basicConfig( ) logger = logging.getLogger(__name__) -# Load configuration -LOGIN = os.getenv('EDU_LOGIN') -PASSWORD = os.getenv('EDU_PASSWORD') -URL_LOGIN = os.getenv('EDU_URL_LOGIN', 'https://edu.edu.vn.ua/user/login') -URL_VERIFY = os.getenv('EDU_URL_VERIFY', 'https://edu.edu.vn.ua/course/userlist') -INTERVAL = int(os.getenv('PHPSESSID_INTERVAL', 10)) -USER_AGENT = os.getenv('USER_AGENT', 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36') -REDIS_HOST = os.getenv('REDIS_HOST', 'redis') -REDIS_PORT = int(os.getenv('REDIS_PORT', 6379)) +# Load configuration (adapted to .env keys) +def _env(key, default=None): + v = os.getenv(key, default) + if isinstance(v, str) and len(v) >= 2 and ((v[0] == '"' and v[-1] == '"') or (v[0] == "'" and v[-1] == "'")): + return v[1:-1] + return v + +LOGIN = _env('KEEPER_LOGIN') +PASSWORD = _env('KEEPER_PASSWORD') + +EDU_BASE = _env('EDU_URL_BASE', 'https://edu.edu.vn.ua') +EDU_LOGIN_PATH = _env('EDU_URL_LOGIN', '/user/login') +EDU_COURSES_PATH = _env('EDU_URL_COURSES', '/course/userlist') +URL_LOGIN = f"{EDU_BASE.rstrip('/')}/{EDU_LOGIN_PATH.lstrip('/')}" +URL_VERIFY = f"{EDU_BASE.rstrip('/')}/{EDU_COURSES_PATH.lstrip('/')}" + +INTERVAL = int(_env('KEEPER_INTERVAL', 10)) +USER_AGENT = _env('USER_AGENT', 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36') +REDIS_HOST = _env('REDIS_HOST', 'redis') +REDIS_PORT = int(_env('REDIS_PORT', 6379)) SUCCESS_FILE = '/tmp/last_success' diff --git a/edu_master/webinar-checker/checker.py b/edu_master/webinar-checker/checker.py index 0477481..2efd86a 100644 --- a/edu_master/webinar-checker/checker.py +++ b/edu_master/webinar-checker/checker.py @@ -15,14 +15,23 @@ logging.basicConfig( logger = logging.getLogger(__name__) # Load environment variables -WEBINAR_URL = os.getenv('WEBINAR_URL', 'https://edu.edu.vn.ua/webinar/useractive') -WEBINAR_CHECK_INTERVAL = int(os.getenv('WEBINAR_CHECK_INTERVAL', 60)) -REDIS_HOST = os.getenv('REDIS_HOST', 'redis') -REDIS_PORT = int(os.getenv('REDIS_PORT', 6379)) -PLAYWRIGHT_WS = os.getenv('PLAYWRIGHT_WS', 'ws://playwright-service:3000/ws') -USER_AGENT = os.getenv('USER_AGENT', 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36') -WEBINAR_TELEGRAM_TOKEN = os.getenv('WEBINAR_TELEGRAM_TOKEN') -ADMIN_ID = int(os.getenv('WEBINAR_ADMIN_ID', '0')) +def _env(key, default=None): + v = os.getenv(key, default) + if isinstance(v, str) and len(v) >= 2 and ((v[0] == '"' and v[-1] == '"') or (v[0] == "'" and v[-1] == "'")): + return v[1:-1] + return v + +EDU_BASE = _env('EDU_URL_BASE', 'https://edu.edu.vn.ua') +EDU_WEBINAR_PATH = _env('EDU_URL_WEBINAR', '/webinar/useractive') +WEBINAR_URL = f"{EDU_BASE.rstrip('/')}/{EDU_WEBINAR_PATH.lstrip('/')}" + +WEBINAR_CHECK_INTERVAL = int(_env('WEBINAR_CHECK_INTERVAL', 60)) +REDIS_HOST = _env('REDIS_HOST', 'redis') +REDIS_PORT = int(_env('REDIS_PORT', 6379)) +PLAYWRIGHT_WS = _env('PLAYWRIGHT_WS', 'ws://playwright-service:3000/ws') +USER_AGENT = _env('USER_AGENT', 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36') +WEBINAR_TELEGRAM_TOKEN = _env('WEBINAR_TELEGRAM_TOKEN') +ADMIN_ID = int(_env('WEBINAR_ADMIN_ID', '0')) # Redis Keys KEY_WHITELIST = "bot:whitelist" From 81592b61424db7a509a96a090e9d101efe30ec4a Mon Sep 17 00:00:00 2001 From: mr-forust Date: Tue, 13 Jan 2026 00:24:04 +0100 Subject: [PATCH 80/89] feat: add group support for webinar notifier --- .gitignore | 5 +- edu_master/webinar-checker/checker.py | 70 +++++++++++++++++++++------ 2 files changed, 59 insertions(+), 16 deletions(-) diff --git a/.gitignore b/.gitignore index 41f6e38..49b651c 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,9 @@ sync.ffs_lock .sync.ffs_db +# Copyparty +*.hist/ + # Volumes and data directories gitea/gitea-db/ gitea/gitea-data/* @@ -88,4 +91,4 @@ temp/* .env.anna .env.forust .env.* -!*example +!*example \ No newline at end of file diff --git a/edu_master/webinar-checker/checker.py b/edu_master/webinar-checker/checker.py index 2efd86a..60c3dbb 100644 --- a/edu_master/webinar-checker/checker.py +++ b/edu_master/webinar-checker/checker.py @@ -3,7 +3,8 @@ import logging import redis import json -from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup +from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup, ChatMember +from telegram.constants import ChatType from telegram.ext import Application, CommandHandler, CallbackQueryHandler, ContextTypes from playwright.async_api import async_playwright @@ -57,7 +58,7 @@ TRANSLATIONS = { 'welcome_admin': "\n\n👑 Режим администратора активен", 'access_denied': "⛔ Доступ запрещен. Вас нет в белом списке.", 'help_title': "🤖 Помощь по боту\n\n", - 'help_commands': "/start - Подписаться на уведомления\n/help - Показать это сообщение\n/language - Сменить язык", + 'help_commands': "/start - Подписаться на уведомления\n/stop - Отписаться от уведомлений\n/help - Показать это сообщение\n/language - Сменить язык", 'help_admin': "\nКоманды администратора:\n/adduser [user_id] - Добавить пользователя в белый список\n/removeuser [user_id] - Удалить пользователя из белого списка\nИли используйте панель ниже для управления настройками.", 'admin_only': "⛔ Только для администратора!", 'user_added': "✅ Пользователь {user_id} добавлен в белый список", @@ -94,7 +95,7 @@ TRANSLATIONS = { 'welcome_admin': "\n\n👑 Режим адміністратора активний", 'access_denied': "⛔ Доступ заборонено. Вас немає в білому списку.", 'help_title': "🤖 Довідка по боту\n\n", - 'help_commands': "/start - Підписатися на сповіщення\n/help - Показати це повідомлення\n/language - Змінити мову", + 'help_commands': "/start - Підписатися на сповіщення\n/stop - Відписатися від сповіщень\n/help - Показати це повідомлення\n/language - Змінити мову", 'help_admin': "\nКоманди адміністратора:\n/adduser [user_id] - Додати користувача до білого списку\n/removeuser [user_id] - Видалити користувача з білого списку\nАбо використовуйте панель нижче для керування налаштуваннями.", 'admin_only': "⛔ Тільки для адміністратора!", 'user_added': "✅ Користувач {user_id} доданий до білого списку", @@ -131,7 +132,7 @@ TRANSLATIONS = { 'welcome_admin': "\n\n👑 Admin Mode Active", 'access_denied': "⛔ Access denied. You are not on the whitelist.", 'help_title': "🤖 Bot Help\n\n", - 'help_commands': "/start - Subscribe to notifications\n/help - Show this message\n/language - Change language", + 'help_commands': "/start - Subscribe to notifications\n/stop - Unsubscribe from notifications\n/help - Show this message\n/language - Change language", 'help_admin': "\nAdmin Commands:\n/adduser [user_id] - Add user to whitelist\n/removeuser [user_id] - Remove user from whitelist\nOr use the panel below to manage settings.", 'admin_only': "⛔ Admin only!", 'user_added': "✅ User {user_id} added to whitelist", @@ -212,6 +213,21 @@ def is_whitelisted(user_id: int) -> bool: return redis_client.sismember(KEY_WHITELIST, str(user_id)) +async def is_group_admin(update: Update, context: ContextTypes.DEFAULT_TYPE) -> bool: + """Check if the user is an administrator in the group.""" + user = update.effective_user + chat = update.effective_chat + + if chat.type in [ChatType.PRIVATE, "private"]: + return True + + try: + member = await context.bot.get_chat_member(chat.id, user.id) + return member.status in [ChatMember.OWNER, ChatMember.ADMINISTRATOR] + except Exception as e: + logger.error(f"Failed to check admin status: {e}") + return False + def get_admin_keyboard(user_id: int): """Generate admin panel keyboard.""" whitelist_enabled = redis_client.get(KEY_WHITELIST_ENABLED) != "0" @@ -230,19 +246,21 @@ def get_admin_keyboard(user_id: int): async def start(update: Update, context: ContextTypes.DEFAULT_TYPE): """Handle /start command.""" user = update.effective_user - logger.info(f"User {user.id} ({user.username}) started the bot.") + chat = update.effective_chat + logger.info(f"User {user.id} ({user.username}) started the bot in chat {chat.id} ({chat.type}).") + # Check whitelist - MUST be the user executing the command if not is_whitelisted(user.id): await update.message.reply_text(t(user.id, 'access_denied')) return - # Add to subscribers - redis_client.sadd(KEY_SUBSCRIBERS, user.id) + # Add to subscribers (Chat ID!) + redis_client.sadd(KEY_SUBSCRIBERS, chat.id) - msg = t(user.id, 'welcome', name=user.first_name) + msg = t(chat.id, 'welcome', name=user.first_name) - if user.id == ADMIN_ID: - msg += t(user.id, 'welcome_admin') + if user.id == ADMIN_ID and chat.type == "private": + msg += t(chat.id, 'welcome_admin') await update.message.reply_text(msg, parse_mode='HTML', reply_markup=get_admin_keyboard(user.id)) else: await update.message.reply_text(msg, parse_mode='HTML') @@ -250,19 +268,39 @@ async def start(update: Update, context: ContextTypes.DEFAULT_TYPE): async def help_command(update: Update, context: ContextTypes.DEFAULT_TYPE): """Handle /help command.""" user_id = update.effective_user.id - msg = t(user_id, 'help_title') + t(user_id, 'help_commands') + chat_id = update.effective_chat.id + msg = t(chat_id, 'help_title') + t(chat_id, 'help_commands') - if user_id == ADMIN_ID: - msg += t(user_id, 'help_admin') + if user_id == ADMIN_ID and update.effective_chat.type == "private": + msg += t(chat_id, 'help_admin') await update.message.reply_text(msg, parse_mode='HTML', reply_markup=get_admin_keyboard(user_id)) else: await update.message.reply_text(msg, parse_mode='HTML') +async def stop_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handle /stop command (unsubscribe).""" + user = update.effective_user + chat = update.effective_chat + + # Permission check: Whitelisted user OR Group Admin + if not (is_whitelisted(user.id) or await is_group_admin(update, context)): + await update.message.reply_text(t(chat.id, 'access_denied')) # Or specific "admin only" message + return + + redis_client.srem(KEY_SUBSCRIBERS, chat.id) + await update.message.reply_text(t(chat.id, 'whitelist_disabled').replace(" whitelist", " notifications").replace("Білий список", "Сповіщення").replace("Белый список", "Уведомления") if chat.id else "Unsubscribed") + async def language_command(update: Update, context: ContextTypes.DEFAULT_TYPE): """Handle /language command.""" - user_id = update.effective_user.id + user = update.effective_user + chat = update.effective_chat + + # Permission check for groups + if not (is_whitelisted(user.id) or await is_group_admin(update, context)): + return + await update.message.reply_text( - t(user_id, 'select_language'), + t(chat.id, 'select_language'), parse_mode='HTML', reply_markup=get_language_keyboard() ) @@ -544,6 +582,7 @@ async def check_webinars_job(context: ContextTypes.DEFAULT_TYPE): for sub_id in subscribers: try: # Build message in user's language + # sub_id comes from redis set as string, convert to int for translation lookup webinar_items = "\n\n".join([ t(int(sub_id), 'webinar_item', name=w['name'], url=w['url']) for w in new_webinars @@ -578,6 +617,7 @@ def main(): # Handlers app.add_handler(CommandHandler("start", start)) + app.add_handler(CommandHandler("stop", stop_command)) app.add_handler(CommandHandler("help", help_command)) app.add_handler(CommandHandler("language", language_command)) app.add_handler(CommandHandler("adduser", add_user)) From d03a4844fb15bd93d094c1ce5c69e5029281370e Mon Sep 17 00:00:00 2001 From: mr-forust Date: Sat, 17 Jan 2026 15:38:27 +0100 Subject: [PATCH 81/89] chore: remove unused tailscale setup --- tailscale/.env.example | 1 - tailscale/compose.yaml | 21 --------------------- 2 files changed, 22 deletions(-) delete mode 100644 tailscale/.env.example delete mode 100644 tailscale/compose.yaml diff --git a/tailscale/.env.example b/tailscale/.env.example deleted file mode 100644 index ad82e8d..0000000 --- a/tailscale/.env.example +++ /dev/null @@ -1 +0,0 @@ -TS_AUTHKEY=tskey-auth-xxxxx-CNTRL diff --git a/tailscale/compose.yaml b/tailscale/compose.yaml deleted file mode 100644 index 8f8f8b8..0000000 --- a/tailscale/compose.yaml +++ /dev/null @@ -1,21 +0,0 @@ -services: - tailscale: - image: tailscale/tailscale:latest - container_name: tailscale - network_mode: host - restart: unless-stopped - cap_add: - - NET_ADMIN - - NET_RAW - volumes: - - tailscale_data:/var/lib/tailscale - - /dev/net/tun:/dev/net/tun - environment: - - TS_AUTHKEY=${TS_AUTHKEY} - - TS_STATE_DIR=/var/lib/tailscale - - TS_HOSTNAME=forust-server - # - TS_EXTRA_ARGS=--advertise-tags=tag:container - -volumes: - tailscale_data: - name: tailscale_data From 403e88d548b7f9e5d756bb36d838ba738a6a2480 Mon Sep 17 00:00:00 2001 From: mr-forust Date: Sat, 17 Jan 2026 17:44:11 +0100 Subject: [PATCH 82/89] fix: update NEXTCLOUD_DATADIR path to match new hardware --- nextcloud/compose.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nextcloud/compose.yaml b/nextcloud/compose.yaml index 62de8ca..fa087c4 100644 --- a/nextcloud/compose.yaml +++ b/nextcloud/compose.yaml @@ -59,7 +59,7 @@ services: BORG_RETENTION_POLICY: --keep-within=7d --keep-weekly=4 --keep-monthly=6 # Backup retention See https://github.com/nextcloud/all-in-one#how-to-adjust-borgs-retention-policy COLLABORA_SECCOMP_DISABLED: false # Setting this to true allows to disable Collabora's Seccomp feature. See https://github.com/nextcloud/all-in-one#how-to-disable-collaboras-seccomp-feature FULLTEXTSEARCH_JAVA_OPTIONS: "-Xms1024M -Xmx1024M" # adjust fulltextsearch java options. https://github.com/nextcloud/all-in-one#how-to-adjust-the-fulltextsearch-java-options - NEXTCLOUD_DATADIR: /media/forust/nextcloud/ncdata # Allows to set the host directory for Nextcloud's datadir. ⚠️⚠️⚠️ Warning: do not set or adjust this value after the initial Nextcloud installation is done! See https://github.com/nextcloud/all-in-one#how-to-change-the-default-location-of-nextclouds-datadir + NEXTCLOUD_DATADIR: /mnt/nextcloud/ncdata # Allows to set the host directory for Nextcloud's datadir. ⚠️⚠️⚠️ Warning: do not set or adjust this value after the initial Nextcloud installation is done! See https://github.com/nextcloud/all-in-one#how-to-change-the-default-location-of-nextclouds-datadir NEXTCLOUD_MOUNT: /mnt/ # Allows the Nextcloud container to access the chosen directory on the host. See https://github.com/nextcloud/all-in-one#how-to-allow-the-nextcloud-container-to-access-directories-on-the-host NEXTCLOUD_UPLOAD_LIMIT: 16G # https://github.com/nextcloud/all-in-one#how-to-adjust-the-upload-limit-for-nextcloud NEXTCLOUD_MAX_TIME: 7200 # Max uploading time See https://github.com/nextcloud/all-in-one#how-to-adjust-the-max-execution-time-for-nextcloud From 94b5f392072be56ac3e0f70a89ab3591641ae16c Mon Sep 17 00:00:00 2001 From: mr-forust Date: Sun, 18 Jan 2026 00:19:39 +0100 Subject: [PATCH 83/89] make glance dashboard public again --- glance/compose.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/glance/compose.yaml b/glance/compose.yaml index cd853ab..32cb82e 100644 --- a/glance/compose.yaml +++ b/glance/compose.yaml @@ -16,7 +16,7 @@ services: # Prod Router - "traefik.http.routers.glance.rule=Host(`glance.forust.xyz`)" - "traefik.http.routers.glance.entrypoints=websecure" - - "traefik.http.routers.glance.middlewares=security-chain@file" + - "traefik.http.routers.glance.middlewares=security-headers@file" - "traefik.http.routers.glance.tls=true" # Local Router From 0138fbd276be261b42fc8501776c2a59fbfb0bce Mon Sep 17 00:00:00 2001 From: mr-forust Date: Sun, 18 Jan 2026 19:24:41 +0100 Subject: [PATCH 84/89] fix: update middleware for dev router in glance compose file --- glance/compose.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/glance/compose.yaml b/glance/compose.yaml index 32cb82e..2ebc851 100644 --- a/glance/compose.yaml +++ b/glance/compose.yaml @@ -28,7 +28,7 @@ services: # Dev Router - "traefik.http.routers.glance-dev.rule=Host(`glance.gigaforust.internal`)" - "traefik.http.routers.glance-dev.entrypoints=websecure" - - "traefik.http.routers.glance-dev.middlewares=security-chain@file" + - "traefik.http.routers.glance-dev.middlewares=security-headers@file" - "traefik.http.routers.glance-dev.tls=true" networks: - proxy From 02f4e0ab4225e8b1b39a251efc0e9e377172dd7d Mon Sep 17 00:00:00 2001 From: mr-forust Date: Sun, 18 Jan 2026 22:39:48 +0100 Subject: [PATCH 85/89] chore: switch to named volumes for site storing --- checkmk/compose.yaml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/checkmk/compose.yaml b/checkmk/compose.yaml index 40a89a6..a16c514 100644 --- a/checkmk/compose.yaml +++ b/checkmk/compose.yaml @@ -7,7 +7,7 @@ services: - CMK_SITE_ID=cmk volumes: - - ./checkmk:/omd/sites + - sites:/omd/sites tmpfs: - /opt/omd/sites/cmk/tmp:uid=1000,gid=1000 ports: @@ -46,4 +46,6 @@ services: networks: proxy: - external: true \ No newline at end of file + external: true +volumes: + sites: \ No newline at end of file From 697027931193a0dbf377f46f616ed30f24da6bb4 Mon Sep 17 00:00:00 2001 From: mr-forust Date: Sun, 18 Jan 2026 23:09:41 +0100 Subject: [PATCH 86/89] fix: remove checkmk port for docker cmk agent, using external agent --- checkmk/compose.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/checkmk/compose.yaml b/checkmk/compose.yaml index a16c514..1ca7bc1 100644 --- a/checkmk/compose.yaml +++ b/checkmk/compose.yaml @@ -13,7 +13,6 @@ services: ports: - 5000:5000 - 6776:8000 - - 6556:6556 restart: always labels: - "traefik.enable=true" From 6d97246997393030760514821d093715d11bc86b Mon Sep 17 00:00:00 2001 From: mr-forust Date: Mon, 19 Jan 2026 00:08:16 +0100 Subject: [PATCH 87/89] fix: change checkmk container restart policy to 'unless-stopped' --- checkmk/compose.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/checkmk/compose.yaml b/checkmk/compose.yaml index 1ca7bc1..5cc9cc0 100644 --- a/checkmk/compose.yaml +++ b/checkmk/compose.yaml @@ -13,7 +13,7 @@ services: ports: - 5000:5000 - 6776:8000 - restart: always + restart: unless-stopped labels: - "traefik.enable=true" - "traefik.docker.network=proxy" @@ -47,4 +47,4 @@ networks: proxy: external: true volumes: - sites: \ No newline at end of file + sites: From 5fe8af82d594fcb051d0d1df2213041bbffc5d71 Mon Sep 17 00:00:00 2001 From: mr-forust Date: Tue, 30 Dec 2025 22:44:52 +0100 Subject: [PATCH 88/89] Revert "fix: clean up traefik configuration and add redirect middleware" This reverts commit dc7fe64fbcecaa6325e877ef23585051820ae302. --- traefik/compose.yaml | 3 +++ traefik/dynamic/middlewares.yml | 5 +++++ traefik/dynamic/redirect.yml | 15 --------------- 3 files changed, 8 insertions(+), 15 deletions(-) diff --git a/traefik/compose.yaml b/traefik/compose.yaml index eff00c3..492f401 100644 --- a/traefik/compose.yaml +++ b/traefik/compose.yaml @@ -19,11 +19,14 @@ services: - "--entryPoints.web.address=:80" - "--entryPoints.websecure.address=:443" - "--entryPoints.websecure.http.tls=true" + - "--entryPoints.web.http.redirections.entryPoint.to=websecure" + - "--entryPoints.web.http.redirections.entryPoint.scheme=https" - "--entryPoints.ssh.address=:2221" # Let's Encrypt - "--certificatesresolvers.letsencrypt.acme.email=${EMAIL}" - "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json" + - "--certificatesresolvers.letsencrypt.acme.httpchallenge=true" - "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web" # # STAGING # - "--certificatesresolvers.letsencrypt.acme.caserver=https://acme-staging-v02.api.letsencrypt.org/directory" diff --git a/traefik/dynamic/middlewares.yml b/traefik/dynamic/middlewares.yml index 0dba63f..40eddfb 100644 --- a/traefik/dynamic/middlewares.yml +++ b/traefik/dynamic/middlewares.yml @@ -1,5 +1,10 @@ http: middlewares: + # HTTPS Redirect + redirect-https: + redirectScheme: + scheme: https + permanent: true # Cloudflare IP Whitelist cloudflare-ipwhitelist: ipWhiteList: diff --git a/traefik/dynamic/redirect.yml b/traefik/dynamic/redirect.yml index fdcea94..334325f 100644 --- a/traefik/dynamic/redirect.yml +++ b/traefik/dynamic/redirect.yml @@ -1,10 +1,4 @@ http: - middlewares: - redirect-https: - redirectScheme: - scheme: https - permanent: true - routers: acme-challenge-exempt: rule: "PathPrefix(`/.well-known/acme-challenge`)" @@ -12,12 +6,3 @@ http: - web service: noop@internal priority: 100 - - http-catchall: - rule: "HostRegexp(`{host:.+}`)" - entryPoints: - - web - middlewares: - - redirect-https - service: noop@internal - priority: 1 From d7c05fd058df8a6cf417414ba643fa803199952f Mon Sep 17 00:00:00 2001 From: mr-forust Date: Mon, 19 Jan 2026 17:01:23 +0100 Subject: [PATCH 89/89] fix: use webinar links to detect duplicates, supress logspam from telegram bot --- edu_master/webinar-checker/checker.py | 43 ++++++++++++++++++++++----- 1 file changed, 35 insertions(+), 8 deletions(-) diff --git a/edu_master/webinar-checker/checker.py b/edu_master/webinar-checker/checker.py index 60c3dbb..65043cf 100644 --- a/edu_master/webinar-checker/checker.py +++ b/edu_master/webinar-checker/checker.py @@ -15,6 +15,11 @@ logging.basicConfig( ) logger = logging.getLogger(__name__) +# Suppress HTTP request logs +logging.getLogger('urllib3').setLevel(logging.WARNING) +logging.getLogger('httpx').setLevel(logging.WARNING) +logging.getLogger('telegram.ext._application').setLevel(logging.WARNING) + # Load environment variables def _env(key, default=None): v = os.getenv(key, default) @@ -39,7 +44,7 @@ KEY_WHITELIST = "bot:whitelist" KEY_WHITELIST_ENABLED = "bot:whitelist_enabled" KEY_SUBSCRIBERS = "bot:subscribers" KEY_PHPSESSID = "EDU_PHPSESSID" -KEY_WEBINAR_HISTORY = "bot:webinar_history" # Stores last 5 webinars +KEY_WEBINAR_HISTORY = "bot:webinar_history" # Stores last 3 webinars # Initialize Redis try: @@ -89,6 +94,8 @@ TRANSLATIONS = { 'flag_ru': "🇷🇺 Русский", 'flag_uk': "🇺🇦 Українська", 'flag_en': "🇬🇧 English", + 'history_cleared': "✅ История вебинаров очищена", + 'history_clear_failed': "❌ Ошибка при очистке истории", }, 'uk': { 'welcome': "👋 Привіт, {name}!\n\nЯ бот-сповіщувач про вебінари. Я повідомлятиму вас, коли з'явиться новий вебінар.\nВи підписані на сповіщення.", @@ -126,6 +133,8 @@ TRANSLATIONS = { 'flag_ru': "🇷🇺 Русский", 'flag_uk': "🇺🇦 Українська", 'flag_en': "🇬🇧 English", + 'history_cleared': "✅ Історія вебінарів очищена", + 'history_clear_failed': "❌ Помилка при очищенні історії", }, 'en': { 'welcome': "👋 Hello, {name}!\n\nI am the Webinar Checker Bot. I will notify you when a new webinar appears.\nYou have been subscribed to notifications.", @@ -163,6 +172,8 @@ TRANSLATIONS = { 'flag_ru': "🇷🇺 Русский", 'flag_uk': "🇺🇦 Українська", 'flag_en': "🇬🇧 English", + 'history_cleared': "✅ Webinar history cleared", + 'history_clear_failed': "❌ Error clearing history", } } @@ -350,6 +361,21 @@ async def remove_user(update: Update, context: ContextTypes.DEFAULT_TYPE): except ValueError: await update.message.reply_text(t(admin_id, 'invalid_user_id')) +async def clear_history(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Clear webinar history (admin only).""" + admin_id = update.effective_user.id + if admin_id != ADMIN_ID: + await update.message.reply_text(t(admin_id, 'admin_only')) + return + + try: + redis_client.delete(KEY_WEBINAR_HISTORY) + await update.message.reply_text(t(admin_id, 'history_cleared')) + logger.info("Admin cleared webinar history") + except Exception as e: + logger.error(f"Failed to clear history: {e}") + await update.message.reply_text(t(admin_id, 'history_clear_failed')) + # --- Admin Callbacks --- async def admin_callback(update: Update, context: ContextTypes.DEFAULT_TYPE): @@ -410,9 +436,9 @@ async def language_callback(update: Update, context: ContextTypes.DEFAULT_TYPE): # --- Webinar Checking Job --- -def get_webinar_key(name: str, url: str) -> str: - """Generate unique key for a webinar based on name and URL.""" - return f"{name}|{url}" +def get_webinar_key(url: str) -> str: + """Generate unique key for a webinar based on URL.""" + return url def get_stored_webinars() -> list: """Get list of stored webinar keys from Redis.""" @@ -425,9 +451,9 @@ def get_stored_webinars() -> list: return [] def store_webinars(webinar_keys: list): - """Store up to 5 most recent webinar keys in Redis.""" - # Keep only last 5 - webinar_keys = webinar_keys[-5:] + """Store up to 3 most recent webinar keys in Redis.""" + # Keep only last 3 + webinar_keys = webinar_keys[-3:] try: redis_client.set(KEY_WEBINAR_HISTORY, json.dumps(webinar_keys)) logger.info(f"Stored {len(webinar_keys)} webinar(s) in history") @@ -562,7 +588,7 @@ async def check_webinars_job(context: ContextTypes.DEFAULT_TYPE): current_keys = [] for webinar in current_webinars: - key = get_webinar_key(webinar['name'], webinar['url']) + key = get_webinar_key(webinar['url']) current_keys.append(key) if key not in stored_keys: @@ -622,6 +648,7 @@ def main(): app.add_handler(CommandHandler("language", language_command)) app.add_handler(CommandHandler("adduser", add_user)) app.add_handler(CommandHandler("removeuser", remove_user)) + app.add_handler(CommandHandler("clearhistory", clear_history)) # Callback handlers - language selection first, then admin panel app.add_handler(CallbackQueryHandler(language_callback, pattern="^lang_"))